@appkit/llamacpp-cli 1.12.0 → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +217 -168
  2. package/package.json +10 -2
  3. package/web/dist/assets/index-Bin89Lwr.css +1 -0
  4. package/web/dist/assets/index-CVmonw3T.js +17 -0
  5. package/web/{index.html → dist/index.html} +2 -1
  6. package/.versionrc.json +0 -16
  7. package/CHANGELOG.md +0 -213
  8. package/docs/images/.gitkeep +0 -1
  9. package/docs/images/web-ui-servers.png +0 -0
  10. package/src/cli.ts +0 -523
  11. package/src/commands/admin/config.ts +0 -121
  12. package/src/commands/admin/logs.ts +0 -91
  13. package/src/commands/admin/restart.ts +0 -26
  14. package/src/commands/admin/start.ts +0 -27
  15. package/src/commands/admin/status.ts +0 -84
  16. package/src/commands/admin/stop.ts +0 -16
  17. package/src/commands/config-global.ts +0 -38
  18. package/src/commands/config.ts +0 -323
  19. package/src/commands/create.ts +0 -183
  20. package/src/commands/delete.ts +0 -74
  21. package/src/commands/list.ts +0 -37
  22. package/src/commands/logs-all.ts +0 -251
  23. package/src/commands/logs.ts +0 -345
  24. package/src/commands/monitor.ts +0 -110
  25. package/src/commands/ps.ts +0 -84
  26. package/src/commands/pull.ts +0 -44
  27. package/src/commands/rm.ts +0 -107
  28. package/src/commands/router/config.ts +0 -116
  29. package/src/commands/router/logs.ts +0 -256
  30. package/src/commands/router/restart.ts +0 -36
  31. package/src/commands/router/start.ts +0 -60
  32. package/src/commands/router/status.ts +0 -119
  33. package/src/commands/router/stop.ts +0 -33
  34. package/src/commands/run.ts +0 -233
  35. package/src/commands/search.ts +0 -107
  36. package/src/commands/server-show.ts +0 -161
  37. package/src/commands/show.ts +0 -207
  38. package/src/commands/start.ts +0 -101
  39. package/src/commands/stop.ts +0 -39
  40. package/src/commands/tui.ts +0 -25
  41. package/src/lib/admin-manager.ts +0 -435
  42. package/src/lib/admin-server.ts +0 -1243
  43. package/src/lib/config-generator.ts +0 -130
  44. package/src/lib/download-job-manager.ts +0 -213
  45. package/src/lib/history-manager.ts +0 -172
  46. package/src/lib/launchctl-manager.ts +0 -225
  47. package/src/lib/metrics-aggregator.ts +0 -257
  48. package/src/lib/model-downloader.ts +0 -328
  49. package/src/lib/model-scanner.ts +0 -157
  50. package/src/lib/model-search.ts +0 -114
  51. package/src/lib/models-dir-setup.ts +0 -46
  52. package/src/lib/port-manager.ts +0 -80
  53. package/src/lib/router-logger.ts +0 -201
  54. package/src/lib/router-manager.ts +0 -414
  55. package/src/lib/router-server.ts +0 -538
  56. package/src/lib/state-manager.ts +0 -206
  57. package/src/lib/status-checker.ts +0 -113
  58. package/src/lib/system-collector.ts +0 -315
  59. package/src/tui/ConfigApp.ts +0 -1085
  60. package/src/tui/HistoricalMonitorApp.ts +0 -587
  61. package/src/tui/ModelsApp.ts +0 -368
  62. package/src/tui/MonitorApp.ts +0 -386
  63. package/src/tui/MultiServerMonitorApp.ts +0 -1833
  64. package/src/tui/RootNavigator.ts +0 -74
  65. package/src/tui/SearchApp.ts +0 -511
  66. package/src/tui/SplashScreen.ts +0 -149
  67. package/src/types/admin-config.ts +0 -25
  68. package/src/types/global-config.ts +0 -26
  69. package/src/types/history-types.ts +0 -39
  70. package/src/types/model-info.ts +0 -8
  71. package/src/types/monitor-types.ts +0 -162
  72. package/src/types/router-config.ts +0 -25
  73. package/src/types/server-config.ts +0 -46
  74. package/src/utils/downsample-utils.ts +0 -128
  75. package/src/utils/file-utils.ts +0 -146
  76. package/src/utils/format-utils.ts +0 -98
  77. package/src/utils/log-parser.ts +0 -284
  78. package/src/utils/log-utils.ts +0 -178
  79. package/src/utils/process-utils.ts +0 -316
  80. package/src/utils/prompt-utils.ts +0 -47
  81. package/test-load.sh +0 -100
  82. package/tsconfig.json +0 -20
  83. package/web/eslint.config.js +0 -23
  84. package/web/llamacpp-web-dist.tar.gz +0 -0
  85. package/web/package-lock.json +0 -4017
  86. package/web/package.json +0 -38
  87. package/web/postcss.config.js +0 -6
  88. package/web/src/App.css +0 -42
  89. package/web/src/App.tsx +0 -86
  90. package/web/src/assets/react.svg +0 -1
  91. package/web/src/components/ApiKeyPrompt.tsx +0 -71
  92. package/web/src/components/CreateServerModal.tsx +0 -372
  93. package/web/src/components/DownloadProgress.tsx +0 -123
  94. package/web/src/components/Nav.tsx +0 -89
  95. package/web/src/components/RouterConfigModal.tsx +0 -240
  96. package/web/src/components/SearchModal.tsx +0 -306
  97. package/web/src/components/ServerConfigModal.tsx +0 -291
  98. package/web/src/hooks/useApi.ts +0 -259
  99. package/web/src/index.css +0 -42
  100. package/web/src/lib/api.ts +0 -226
  101. package/web/src/main.tsx +0 -10
  102. package/web/src/pages/Dashboard.tsx +0 -103
  103. package/web/src/pages/Models.tsx +0 -258
  104. package/web/src/pages/Router.tsx +0 -270
  105. package/web/src/pages/RouterLogs.tsx +0 -201
  106. package/web/src/pages/ServerLogs.tsx +0 -553
  107. package/web/src/pages/Servers.tsx +0 -358
  108. package/web/src/types/api.ts +0 -140
  109. package/web/tailwind.config.js +0 -31
  110. package/web/tsconfig.app.json +0 -28
  111. package/web/tsconfig.json +0 -7
  112. package/web/tsconfig.node.json +0 -26
  113. package/web/vite.config.ts +0 -25
  114. /package/web/{public → dist}/vite.svg +0 -0
@@ -1,1085 +0,0 @@
1
- import blessed from 'blessed';
2
- import * as path from 'path';
3
- import { ServerConfig, sanitizeModelName } from '../types/server-config.js';
4
- import { stateManager } from '../lib/state-manager.js';
5
- import { launchctlManager } from '../lib/launchctl-manager.js';
6
- import { statusChecker } from '../lib/status-checker.js';
7
- import { portManager } from '../lib/port-manager.js';
8
- import { modelScanner } from '../lib/model-scanner.js';
9
- import { ModelInfo } from '../types/model-info.js';
10
- import { getLogsDir, getLaunchAgentsDir } from '../utils/file-utils.js';
11
- import { autoRotateIfNeeded } from '../utils/log-utils.js';
12
-
13
- interface ConfigField {
14
- key: string;
15
- label: string;
16
- type: 'model' | 'number' | 'text' | 'toggle' | 'select';
17
- value: any;
18
- originalValue: any;
19
- options?: string[];
20
- validation?: (value: any) => string | null; // Returns error message or null if valid
21
- }
22
-
23
- interface ConfigState {
24
- fields: ConfigField[];
25
- selectedIndex: number;
26
- hasChanges: boolean;
27
- }
28
-
29
- /**
30
- * Config screen TUI for editing server configuration
31
- */
32
- export async function createConfigUI(
33
- screen: blessed.Widgets.Screen,
34
- server: ServerConfig,
35
- onBack: (updatedServer?: ServerConfig) => void
36
- ): Promise<void> {
37
- // Initialize state
38
- const state: ConfigState = {
39
- fields: [
40
- {
41
- key: 'model',
42
- label: 'Model',
43
- type: 'model',
44
- value: server.modelName,
45
- originalValue: server.modelName,
46
- },
47
- {
48
- key: 'host',
49
- label: 'Host',
50
- type: 'select',
51
- value: server.host || '127.0.0.1',
52
- originalValue: server.host || '127.0.0.1',
53
- options: ['127.0.0.1', '0.0.0.0'],
54
- },
55
- {
56
- key: 'port',
57
- label: 'Port',
58
- type: 'number',
59
- value: server.port,
60
- originalValue: server.port,
61
- validation: (value: number) => {
62
- if (value < 1024) return 'Port must be >= 1024';
63
- if (value > 65535) return 'Port must be <= 65535';
64
- return null;
65
- },
66
- },
67
- {
68
- key: 'threads',
69
- label: 'Threads',
70
- type: 'number',
71
- value: server.threads,
72
- originalValue: server.threads,
73
- validation: (value: number) => {
74
- if (value < 1) return 'Must be at least 1';
75
- if (value > 256) return 'Must be at most 256';
76
- return null;
77
- },
78
- },
79
- {
80
- key: 'ctxSize',
81
- label: 'Context Size',
82
- type: 'number',
83
- value: server.ctxSize,
84
- originalValue: server.ctxSize,
85
- validation: (value: number) => {
86
- if (value < 512) return 'Must be at least 512';
87
- if (value > 2097152) return 'Must be at most 2097152 (2M)';
88
- return null;
89
- },
90
- },
91
- {
92
- key: 'gpuLayers',
93
- label: 'GPU Layers',
94
- type: 'number',
95
- value: server.gpuLayers,
96
- originalValue: server.gpuLayers,
97
- validation: (value: number) => {
98
- if (value < 0) return 'Must be at least 0';
99
- if (value > 999) return 'Must be at most 999';
100
- return null;
101
- },
102
- },
103
- {
104
- key: 'verbose',
105
- label: 'Verbose Logs',
106
- type: 'toggle',
107
- value: server.verbose,
108
- originalValue: server.verbose,
109
- options: ['Disabled', 'Enabled'],
110
- },
111
- {
112
- key: 'customFlags',
113
- label: 'Custom Flags',
114
- type: 'text',
115
- value: server.customFlags?.join(', ') || '',
116
- originalValue: server.customFlags?.join(', ') || '',
117
- },
118
- ],
119
- selectedIndex: 0,
120
- hasChanges: false,
121
- };
122
-
123
- // Create content box
124
- const contentBox = blessed.box({
125
- top: 0,
126
- left: 0,
127
- width: '100%',
128
- height: '100%',
129
- tags: true,
130
- scrollable: true,
131
- alwaysScroll: true,
132
- keys: true,
133
- vi: true,
134
- mouse: true,
135
- scrollbar: {
136
- ch: '█',
137
- style: { fg: 'blue' },
138
- },
139
- });
140
- screen.append(contentBox);
141
-
142
- // Check if any field has changed
143
- function updateHasChanges(): void {
144
- state.hasChanges = state.fields.some(f => {
145
- if (typeof f.value === 'string' && typeof f.originalValue === 'string') {
146
- return f.value.trim() !== f.originalValue.trim();
147
- }
148
- return f.value !== f.originalValue;
149
- });
150
- }
151
-
152
- // Parse context size with k/K suffix support (e.g., "4k" -> 4096, "64K" -> 65536)
153
- function parseContextSize(input: string): number | null {
154
- const trimmed = input.trim().toLowerCase();
155
- const match = trimmed.match(/^(\d+(?:\.\d+)?)(k)?$/);
156
- if (!match) return null;
157
-
158
- const num = parseFloat(match[1]);
159
- const hasK = match[2] === 'k';
160
-
161
- if (isNaN(num) || num <= 0) return null;
162
-
163
- return hasK ? Math.round(num * 1024) : Math.round(num);
164
- }
165
-
166
- // Format context size for display (e.g., 4096 -> "4k", 65536 -> "64k")
167
- function formatContextSize(value: number): string {
168
- if (value >= 1024 && value % 1024 === 0) {
169
- return `${value / 1024}k`;
170
- }
171
- return value.toLocaleString();
172
- }
173
-
174
- // Format display value for a field
175
- function formatValue(field: ConfigField): string {
176
- if (field.type === 'toggle') {
177
- return field.value ? 'Enabled' : 'Disabled';
178
- }
179
- if (field.type === 'text' && !field.value) {
180
- return '(none)';
181
- }
182
- if (field.type === 'number') {
183
- // Don't use commas for port numbers
184
- if (field.key === 'port') {
185
- return String(field.value);
186
- }
187
- // Use k notation for context size
188
- if (field.key === 'ctxSize') {
189
- return formatContextSize(field.value);
190
- }
191
- return field.value.toLocaleString();
192
- }
193
- return String(field.value);
194
- }
195
-
196
- // Render the main config screen
197
- function render(): void {
198
- const termWidth = (screen.width as number) || 80;
199
- const divider = '─'.repeat(termWidth - 2);
200
- let content = '';
201
-
202
- // Header
203
- content += `{bold}{blue-fg}═══ Configure: ${server.id} (${server.port}){/blue-fg}{/bold}\n\n`;
204
-
205
- // Section header
206
- content += '{bold}Server Configuration{/bold}\n';
207
- content += divider + '\n';
208
-
209
- // Fields
210
- for (let i = 0; i < state.fields.length; i++) {
211
- const field = state.fields[i];
212
- const isSelected = i === state.selectedIndex;
213
- const hasChanged = field.value !== field.originalValue;
214
-
215
- const indicator = isSelected ? '►' : ' ';
216
- const label = field.label.padEnd(16);
217
- const value = formatValue(field);
218
-
219
- // Color coding: cyan for selected, yellow for changed
220
- let valueDisplay = value;
221
- if (hasChanged) {
222
- valueDisplay = `{yellow-fg}${value}{/yellow-fg}`;
223
- }
224
-
225
- if (isSelected) {
226
- content += `{cyan-bg}{15-fg} ${indicator} ${label}${value}{/15-fg}{/cyan-bg}\n`;
227
- } else {
228
- content += ` ${indicator} ${label}${valueDisplay}\n`;
229
- }
230
- }
231
-
232
- content += divider + '\n\n';
233
-
234
- // Show changes summary if any
235
- if (state.hasChanges) {
236
- content += '{yellow-fg}* Unsaved Changes:{/yellow-fg}\n';
237
- for (const field of state.fields) {
238
- if (field.value !== field.originalValue) {
239
- const oldVal = field.type === 'toggle'
240
- ? (field.originalValue ? 'Enabled' : 'Disabled')
241
- : (field.originalValue || '(none)');
242
- const newVal = field.type === 'toggle'
243
- ? (field.value ? 'Enabled' : 'Disabled')
244
- : (field.value || '(none)');
245
- content += ` ${field.label}: ${oldVal} → {yellow-fg}${newVal}{/yellow-fg}\n`;
246
- }
247
- }
248
- content += '\n';
249
- }
250
-
251
- // Footer
252
- content += divider + '\n';
253
- content += '{gray-fg}[↑/↓] Navigate [Enter] Edit [S]ave [ESC] Cancel{/gray-fg}';
254
-
255
- contentBox.setContent(content);
256
- screen.render();
257
- }
258
-
259
- // Create a centered modal box
260
- function createModal(title: string, height: number | string = 'shrink'): blessed.Widgets.BoxElement {
261
- return blessed.box({
262
- parent: screen,
263
- top: 'center',
264
- left: 'center',
265
- width: '60%',
266
- height,
267
- border: { type: 'line' },
268
- style: {
269
- border: { fg: 'cyan' },
270
- fg: 'white',
271
- },
272
- tags: true,
273
- label: ` ${title} `,
274
- });
275
- }
276
-
277
- // Number input modal
278
- async function editNumber(field: ConfigField): Promise<void> {
279
- unregisterHandlers();
280
- return new Promise((resolve) => {
281
- const isCtxSize = field.key === 'ctxSize';
282
- const modal = createModal(`Edit ${field.label}`, isCtxSize ? 11 : 10);
283
-
284
- const currentDisplay = isCtxSize
285
- ? formatContextSize(field.value)
286
- : field.value;
287
-
288
- const infoText = blessed.text({
289
- parent: modal,
290
- top: 1,
291
- left: 2,
292
- content: `Current: ${currentDisplay}`,
293
- tags: true,
294
- });
295
-
296
- // Add hint for context size
297
- if (isCtxSize) {
298
- blessed.text({
299
- parent: modal,
300
- top: 2,
301
- left: 2,
302
- content: '{gray-fg}Accepts: 4096, 4k, 8k, 16k, 32k, 64k, 128k{/gray-fg}',
303
- tags: true,
304
- });
305
- }
306
-
307
- const inputBox = blessed.textbox({
308
- parent: modal,
309
- top: isCtxSize ? 4 : 3,
310
- left: 2,
311
- right: 2,
312
- height: 3,
313
- inputOnFocus: true,
314
- border: { type: 'line' },
315
- style: {
316
- border: { fg: 'white' },
317
- focus: { border: { fg: 'green' } },
318
- },
319
- });
320
-
321
- blessed.text({
322
- parent: modal,
323
- bottom: 1,
324
- left: 2,
325
- content: '{gray-fg}[Enter] Confirm [ESC] Cancel{/gray-fg}',
326
- tags: true,
327
- });
328
-
329
- // Pre-fill with k notation for context size
330
- const initialValue = isCtxSize
331
- ? formatContextSize(field.value)
332
- : String(field.value);
333
- inputBox.setValue(initialValue);
334
- screen.render();
335
- inputBox.focus();
336
-
337
- inputBox.on('submit', (value: string) => {
338
- let numValue: number | null;
339
-
340
- if (isCtxSize) {
341
- numValue = parseContextSize(value);
342
- } else {
343
- numValue = parseInt(value, 10);
344
- if (isNaN(numValue)) numValue = null;
345
- }
346
-
347
- if (numValue !== null) {
348
- if (field.validation) {
349
- const error = field.validation(numValue);
350
- if (error) {
351
- infoText.setContent(`{red-fg}Error: ${error}{/red-fg}`);
352
- screen.render();
353
- inputBox.focus();
354
- return;
355
- }
356
- }
357
- field.value = numValue;
358
- updateHasChanges();
359
- }
360
- screen.remove(modal);
361
- registerHandlers();
362
- render();
363
- resolve();
364
- });
365
-
366
- inputBox.on('cancel', () => {
367
- screen.remove(modal);
368
- registerHandlers();
369
- render();
370
- resolve();
371
- });
372
-
373
- inputBox.key(['escape'], () => {
374
- screen.remove(modal);
375
- registerHandlers();
376
- render();
377
- resolve();
378
- });
379
- });
380
- }
381
-
382
- // Toggle/select modal
383
- async function editSelect(field: ConfigField): Promise<void> {
384
- unregisterHandlers();
385
- return new Promise((resolve) => {
386
- const options = field.options || [];
387
- let selectedOption = field.type === 'toggle'
388
- ? (field.value ? 1 : 0)
389
- : options.indexOf(String(field.value));
390
- if (selectedOption < 0) selectedOption = 0;
391
-
392
- const modal = createModal(field.label, options.length + 6);
393
-
394
- function renderOptions(): void {
395
- let content = '\n';
396
- for (let i = 0; i < options.length; i++) {
397
- const isSelected = i === selectedOption;
398
- const indicator = isSelected ? '●' : '○';
399
- if (isSelected) {
400
- content += ` {cyan-fg}${indicator} ${options[i]}{/cyan-fg}\n`;
401
- } else {
402
- content += ` {gray-fg}${indicator} ${options[i]}{/gray-fg}\n`;
403
- }
404
- }
405
-
406
- // Add warning for 0.0.0.0
407
- if (field.key === 'host' && options[selectedOption] === '0.0.0.0') {
408
- content += '\n {yellow-fg}⚠ Warning: Exposes server to network{/yellow-fg}';
409
- }
410
-
411
- content += '\n\n{gray-fg} [↑/↓] Select [Enter] Confirm [ESC] Cancel{/gray-fg}';
412
- modal.setContent(content);
413
- screen.render();
414
- }
415
-
416
- renderOptions();
417
- modal.focus();
418
-
419
- modal.key(['up', 'k'], () => {
420
- selectedOption = Math.max(0, selectedOption - 1);
421
- renderOptions();
422
- });
423
-
424
- modal.key(['down', 'j'], () => {
425
- selectedOption = Math.min(options.length - 1, selectedOption + 1);
426
- renderOptions();
427
- });
428
-
429
- modal.key(['enter'], () => {
430
- if (field.type === 'toggle') {
431
- field.value = selectedOption === 1;
432
- } else {
433
- field.value = options[selectedOption];
434
- }
435
- updateHasChanges();
436
- screen.remove(modal);
437
- registerHandlers();
438
- render();
439
- resolve();
440
- });
441
-
442
- modal.key(['escape'], () => {
443
- screen.remove(modal);
444
- registerHandlers();
445
- render();
446
- resolve();
447
- });
448
- });
449
- }
450
-
451
- // Text input modal
452
- async function editText(field: ConfigField): Promise<void> {
453
- unregisterHandlers();
454
- return new Promise((resolve) => {
455
- const modal = createModal(`Edit ${field.label}`, 10);
456
-
457
- const infoText = blessed.text({
458
- parent: modal,
459
- top: 1,
460
- left: 2,
461
- content: 'Enter comma-separated flags:',
462
- tags: true,
463
- });
464
-
465
- const inputBox = blessed.textbox({
466
- parent: modal,
467
- top: 3,
468
- left: 2,
469
- right: 2,
470
- height: 3,
471
- inputOnFocus: true,
472
- border: { type: 'line' },
473
- style: {
474
- border: { fg: 'white' },
475
- focus: { border: { fg: 'green' } },
476
- },
477
- });
478
-
479
- const helpText = blessed.text({
480
- parent: modal,
481
- bottom: 1,
482
- left: 2,
483
- content: '{gray-fg}[Enter] Confirm [ESC] Cancel{/gray-fg}',
484
- tags: true,
485
- });
486
-
487
- inputBox.setValue(field.value || '');
488
- screen.render();
489
- inputBox.focus();
490
-
491
- inputBox.on('submit', (value: string) => {
492
- field.value = value.trim();
493
- updateHasChanges();
494
- screen.remove(modal);
495
- registerHandlers();
496
- render();
497
- resolve();
498
- });
499
-
500
- inputBox.on('cancel', () => {
501
- screen.remove(modal);
502
- registerHandlers();
503
- render();
504
- resolve();
505
- });
506
-
507
- inputBox.key(['escape'], () => {
508
- screen.remove(modal);
509
- registerHandlers();
510
- render();
511
- resolve();
512
- });
513
- });
514
- }
515
-
516
- // Model picker modal
517
- async function editModel(field: ConfigField): Promise<void> {
518
- unregisterHandlers();
519
- return new Promise(async (resolve) => {
520
- const models = await modelScanner.scanModels();
521
- if (models.length === 0) {
522
- // Show error modal
523
- const errorModal = createModal('Error', 7);
524
- errorModal.setContent('\n {red-fg}No models found in ~/models{/red-fg}\n\n {gray-fg}[ESC] Close{/gray-fg}');
525
- screen.render();
526
- errorModal.focus();
527
- errorModal.key(['escape', 'enter'], () => {
528
- screen.remove(errorModal);
529
- registerHandlers();
530
- render();
531
- resolve();
532
- });
533
- return;
534
- }
535
-
536
- let selectedIndex = models.findIndex(m => m.filename === field.value);
537
- if (selectedIndex < 0) selectedIndex = 0;
538
- let scrollOffset = 0;
539
- const maxVisible = 8;
540
-
541
- const modal = createModal('Select Model', maxVisible + 6);
542
-
543
- function renderModels(): void {
544
- // Adjust scroll offset
545
- if (selectedIndex < scrollOffset) {
546
- scrollOffset = selectedIndex;
547
- } else if (selectedIndex >= scrollOffset + maxVisible) {
548
- scrollOffset = selectedIndex - maxVisible + 1;
549
- }
550
-
551
- let content = '\n';
552
- const visibleModels = models.slice(scrollOffset, scrollOffset + maxVisible);
553
-
554
- for (let i = 0; i < visibleModels.length; i++) {
555
- const model = visibleModels[i];
556
- const actualIndex = scrollOffset + i;
557
- const isSelected = actualIndex === selectedIndex;
558
- const indicator = isSelected ? '►' : ' ';
559
-
560
- // Truncate filename if too long
561
- let displayName = model.filename;
562
- const maxLen = 40;
563
- if (displayName.length > maxLen) {
564
- displayName = displayName.substring(0, maxLen - 3) + '...';
565
- }
566
- displayName = displayName.padEnd(maxLen);
567
-
568
- const size = model.sizeFormatted.padStart(8);
569
-
570
- if (isSelected) {
571
- content += ` {cyan-bg}{15-fg}${indicator} ${displayName} ${size}{/15-fg}{/cyan-bg}\n`;
572
- } else {
573
- content += ` ${indicator} ${displayName} {gray-fg}${size}{/gray-fg}\n`;
574
- }
575
- }
576
-
577
- // Scroll indicator
578
- if (models.length > maxVisible) {
579
- const scrollInfo = `${selectedIndex + 1}/${models.length}`;
580
- content += `\n {gray-fg}${scrollInfo}{/gray-fg}`;
581
- }
582
-
583
- content += '\n\n{gray-fg} [↑/↓] Navigate [Enter] Select [ESC] Cancel{/gray-fg}';
584
- modal.setContent(content);
585
- screen.render();
586
- }
587
-
588
- renderModels();
589
- modal.focus();
590
-
591
- modal.key(['up', 'k'], () => {
592
- selectedIndex = Math.max(0, selectedIndex - 1);
593
- renderModels();
594
- });
595
-
596
- modal.key(['down', 'j'], () => {
597
- selectedIndex = Math.min(models.length - 1, selectedIndex + 1);
598
- renderModels();
599
- });
600
-
601
- modal.key(['enter'], () => {
602
- field.value = models[selectedIndex].filename;
603
- updateHasChanges();
604
- screen.remove(modal);
605
- registerHandlers();
606
- render();
607
- resolve();
608
- });
609
-
610
- modal.key(['escape'], () => {
611
- screen.remove(modal);
612
- registerHandlers();
613
- render();
614
- resolve();
615
- });
616
- });
617
- }
618
-
619
- // Show unsaved changes dialog
620
- async function showUnsavedDialog(): Promise<'save' | 'discard' | 'continue'> {
621
- unregisterHandlers();
622
- return new Promise((resolve) => {
623
- const modal = createModal('Unsaved Changes', 10);
624
-
625
- let selectedOption = 0;
626
- const options = [
627
- { key: 'save', label: '[S]ave and exit' },
628
- { key: 'discard', label: '[D]iscard changes' },
629
- { key: 'continue', label: '[C]ontinue editing' },
630
- ];
631
-
632
- function renderDialog(): void {
633
- let content = '\n';
634
- for (let i = 0; i < options.length; i++) {
635
- const isSelected = i === selectedOption;
636
- if (isSelected) {
637
- content += ` {cyan-fg}► ${options[i].label}{/cyan-fg}\n`;
638
- } else {
639
- content += ` ${options[i].label}\n`;
640
- }
641
- }
642
- modal.setContent(content);
643
- screen.render();
644
- }
645
-
646
- renderDialog();
647
- modal.focus();
648
-
649
- modal.key(['up', 'k'], () => {
650
- selectedOption = Math.max(0, selectedOption - 1);
651
- renderDialog();
652
- });
653
-
654
- modal.key(['down', 'j'], () => {
655
- selectedOption = Math.min(options.length - 1, selectedOption + 1);
656
- renderDialog();
657
- });
658
-
659
- modal.key(['enter'], () => {
660
- screen.remove(modal);
661
- registerHandlers();
662
- resolve(options[selectedOption].key as 'save' | 'discard' | 'continue');
663
- });
664
-
665
- modal.key(['s', 'S'], () => {
666
- screen.remove(modal);
667
- registerHandlers();
668
- resolve('save');
669
- });
670
-
671
- modal.key(['d', 'D'], () => {
672
- screen.remove(modal);
673
- registerHandlers();
674
- resolve('discard');
675
- });
676
-
677
- modal.key(['c', 'C', 'escape'], () => {
678
- screen.remove(modal);
679
- registerHandlers();
680
- resolve('continue');
681
- });
682
- });
683
- }
684
-
685
- // Show restart confirmation dialog
686
- async function showRestartDialog(): Promise<boolean> {
687
- unregisterHandlers();
688
- return new Promise((resolve) => {
689
- const modal = createModal('Server is Running', 10);
690
-
691
- let selectedOption = 0;
692
- const options = [
693
- { key: true, label: '[Y]es - Restart now' },
694
- { key: false, label: '[N]o - Apply later' },
695
- ];
696
-
697
- function renderDialog(): void {
698
- let content = '\n Restart to apply changes?\n\n';
699
- for (let i = 0; i < options.length; i++) {
700
- const isSelected = i === selectedOption;
701
- if (isSelected) {
702
- content += ` {cyan-fg}► ${options[i].label}{/cyan-fg}\n`;
703
- } else {
704
- content += ` ${options[i].label}\n`;
705
- }
706
- }
707
- modal.setContent(content);
708
- screen.render();
709
- }
710
-
711
- renderDialog();
712
- modal.focus();
713
-
714
- modal.key(['up', 'k'], () => {
715
- selectedOption = Math.max(0, selectedOption - 1);
716
- renderDialog();
717
- });
718
-
719
- modal.key(['down', 'j'], () => {
720
- selectedOption = Math.min(options.length - 1, selectedOption + 1);
721
- renderDialog();
722
- });
723
-
724
- modal.key(['enter'], () => {
725
- screen.remove(modal);
726
- registerHandlers();
727
- resolve(options[selectedOption].key);
728
- });
729
-
730
- modal.key(['y', 'Y'], () => {
731
- screen.remove(modal);
732
- registerHandlers();
733
- resolve(true);
734
- });
735
-
736
- modal.key(['n', 'N', 'escape'], () => {
737
- screen.remove(modal);
738
- registerHandlers();
739
- resolve(false);
740
- });
741
- });
742
- }
743
-
744
- // Show progress modal
745
- function showProgress(message: string): blessed.Widgets.BoxElement {
746
- const modal = createModal('Working', 6);
747
- modal.setContent(`\n {cyan-fg}${message}{/cyan-fg}`);
748
- screen.render();
749
- return modal;
750
- }
751
-
752
- // Show error message
753
- async function showError(message: string): Promise<void> {
754
- unregisterHandlers();
755
- return new Promise((resolve) => {
756
- const modal = createModal('Error', 8);
757
- modal.setContent(`\n {red-fg}❌ ${message}{/red-fg}\n\n {gray-fg}[Enter] Close{/gray-fg}`);
758
- screen.render();
759
- modal.focus();
760
- modal.key(['enter', 'escape'], () => {
761
- screen.remove(modal);
762
- registerHandlers();
763
- render();
764
- resolve();
765
- });
766
- });
767
- }
768
-
769
- // Save changes
770
- async function saveChanges(): Promise<ServerConfig | null> {
771
- // Build updates object
772
- const modelField = state.fields.find(f => f.key === 'model')!;
773
- const hostField = state.fields.find(f => f.key === 'host')!;
774
- const portField = state.fields.find(f => f.key === 'port')!;
775
- const threadsField = state.fields.find(f => f.key === 'threads')!;
776
- const ctxSizeField = state.fields.find(f => f.key === 'ctxSize')!;
777
- const gpuLayersField = state.fields.find(f => f.key === 'gpuLayers')!;
778
- const verboseField = state.fields.find(f => f.key === 'verbose')!;
779
- const customFlagsField = state.fields.find(f => f.key === 'customFlags')!;
780
-
781
- // Check for port conflict if port changed
782
- if (portField.value !== portField.originalValue) {
783
- const hasConflict = await portManager.checkPortConflict(portField.value, server.id);
784
- if (hasConflict) {
785
- await showError(`Port ${portField.value} is already in use by another server`);
786
- return null;
787
- }
788
- }
789
-
790
- // Check if model changed (requires migration)
791
- let newModelPath: string | undefined;
792
- let newModelName: string | undefined;
793
- let newServerId: string | undefined;
794
- let isModelMigration = false;
795
-
796
- if (modelField.value !== modelField.originalValue) {
797
- const resolvedPath = await modelScanner.resolveModelPath(modelField.value);
798
- if (!resolvedPath) {
799
- await showError(`Model not found: ${modelField.value}`);
800
- return null;
801
- }
802
- newModelPath = resolvedPath;
803
- newModelName = modelField.value;
804
- newServerId = sanitizeModelName(modelField.value);
805
-
806
- if (newServerId !== server.id) {
807
- isModelMigration = true;
808
- const existingServer = await stateManager.loadServerConfig(newServerId);
809
- if (existingServer) {
810
- await showError(`Server ID "${newServerId}" already exists. Delete it first.`);
811
- return null;
812
- }
813
- }
814
- }
815
-
816
- // Check if server is running and prompt for restart
817
- const currentStatus = await statusChecker.updateServerStatus(server);
818
- const wasRunning = currentStatus.status === 'running';
819
- let shouldRestart = false;
820
-
821
- if (wasRunning) {
822
- shouldRestart = await showRestartDialog();
823
- }
824
-
825
- // Show progress
826
- const progressModal = showProgress('Saving configuration...');
827
-
828
- try {
829
- // Parse custom flags
830
- const customFlags = customFlagsField.value
831
- ? customFlagsField.value.split(',').map((f: string) => f.trim()).filter((f: string) => f.length > 0)
832
- : undefined;
833
-
834
- if (isModelMigration && newServerId && newModelPath && newModelName) {
835
- // Model migration path
836
- if (wasRunning) {
837
- progressModal.setContent('\n {cyan-fg}Stopping old server...{/cyan-fg}');
838
- screen.render();
839
- await launchctlManager.unloadService(server.plistPath);
840
- await new Promise(resolve => setTimeout(resolve, 1000));
841
- }
842
-
843
- progressModal.setContent('\n {cyan-fg}Removing old configuration...{/cyan-fg}');
844
- screen.render();
845
-
846
- try {
847
- await launchctlManager.deletePlist(server.plistPath);
848
- } catch (err) {
849
- // Plist might not exist
850
- }
851
- await stateManager.deleteServerConfig(server.id);
852
-
853
- // Create new config with new ID
854
- const logsDir = getLogsDir();
855
- const plistDir = getLaunchAgentsDir();
856
-
857
- const newConfig: ServerConfig = {
858
- ...server,
859
- id: newServerId,
860
- modelPath: newModelPath,
861
- modelName: newModelName,
862
- host: hostField.value,
863
- port: portField.value,
864
- threads: threadsField.value,
865
- ctxSize: ctxSizeField.value,
866
- gpuLayers: gpuLayersField.value,
867
- verbose: verboseField.value,
868
- customFlags: customFlags && customFlags.length > 0 ? customFlags : undefined,
869
- label: `com.llama.${newServerId}`,
870
- plistPath: path.join(plistDir, `com.llama.${newServerId}.plist`),
871
- stdoutPath: path.join(logsDir, `${newServerId}.stdout`),
872
- stderrPath: path.join(logsDir, `${newServerId}.stderr`),
873
- status: 'stopped',
874
- pid: undefined,
875
- lastStopped: new Date().toISOString(),
876
- };
877
-
878
- progressModal.setContent('\n {cyan-fg}Creating new configuration...{/cyan-fg}');
879
- screen.render();
880
-
881
- await stateManager.saveServerConfig(newConfig);
882
- await launchctlManager.createPlist(newConfig);
883
-
884
- if (shouldRestart) {
885
- progressModal.setContent('\n {cyan-fg}Starting new server...{/cyan-fg}');
886
- screen.render();
887
-
888
- await launchctlManager.loadService(newConfig.plistPath);
889
- await launchctlManager.startService(newConfig.label);
890
- await new Promise(resolve => setTimeout(resolve, 2000));
891
-
892
- const finalStatus = await statusChecker.updateServerStatus(newConfig);
893
- screen.remove(progressModal);
894
- return finalStatus;
895
- }
896
-
897
- screen.remove(progressModal);
898
- return newConfig;
899
- } else {
900
- // Normal config update (no migration)
901
- if (wasRunning && shouldRestart) {
902
- progressModal.setContent('\n {cyan-fg}Stopping server...{/cyan-fg}');
903
- screen.render();
904
- await launchctlManager.unloadService(server.plistPath);
905
- await new Promise(resolve => setTimeout(resolve, 1000));
906
- }
907
-
908
- const updatedConfig: Partial<ServerConfig> = {
909
- host: hostField.value,
910
- port: portField.value,
911
- threads: threadsField.value,
912
- ctxSize: ctxSizeField.value,
913
- gpuLayers: gpuLayersField.value,
914
- verbose: verboseField.value,
915
- customFlags: customFlags && customFlags.length > 0 ? customFlags : undefined,
916
- };
917
-
918
- if (newModelPath && newModelName) {
919
- updatedConfig.modelPath = newModelPath;
920
- updatedConfig.modelName = newModelName;
921
- }
922
-
923
- progressModal.setContent('\n {cyan-fg}Updating configuration...{/cyan-fg}');
924
- screen.render();
925
-
926
- await stateManager.updateServerConfig(server.id, updatedConfig);
927
-
928
- // Regenerate plist
929
- const fullConfig = await stateManager.loadServerConfig(server.id);
930
- if (fullConfig) {
931
- await launchctlManager.createPlist(fullConfig);
932
-
933
- if (wasRunning && shouldRestart) {
934
- // Auto-rotate logs if needed
935
- try {
936
- await autoRotateIfNeeded(fullConfig.stdoutPath, fullConfig.stderrPath, 100);
937
- } catch (err) {
938
- // Non-fatal
939
- }
940
-
941
- progressModal.setContent('\n {cyan-fg}Starting server...{/cyan-fg}');
942
- screen.render();
943
-
944
- await launchctlManager.loadService(fullConfig.plistPath);
945
- await launchctlManager.startService(fullConfig.label);
946
- await new Promise(resolve => setTimeout(resolve, 2000));
947
-
948
- const finalStatus = await statusChecker.updateServerStatus(fullConfig);
949
- screen.remove(progressModal);
950
- return finalStatus;
951
- }
952
-
953
- screen.remove(progressModal);
954
- return fullConfig;
955
- }
956
- }
957
- } catch (err) {
958
- screen.remove(progressModal);
959
- await showError(err instanceof Error ? err.message : 'Unknown error');
960
- return null;
961
- }
962
-
963
- screen.remove(progressModal);
964
- return null;
965
- }
966
-
967
- // Handle edit action for selected field
968
- async function handleEdit(): Promise<void> {
969
- const field = state.fields[state.selectedIndex];
970
-
971
- switch (field.type) {
972
- case 'number':
973
- await editNumber(field);
974
- break;
975
- case 'toggle':
976
- case 'select':
977
- await editSelect(field);
978
- break;
979
- case 'text':
980
- await editText(field);
981
- break;
982
- case 'model':
983
- await editModel(field);
984
- break;
985
- }
986
- }
987
-
988
- // Handle escape/cancel
989
- async function handleEscape(): Promise<void> {
990
- if (state.hasChanges) {
991
- const result = await showUnsavedDialog();
992
- if (result === 'save') {
993
- const updated = await saveChanges();
994
- cleanup();
995
- onBack(updated || undefined);
996
- } else if (result === 'discard') {
997
- cleanup();
998
- onBack();
999
- }
1000
- // 'continue' - just return to config screen
1001
- render();
1002
- } else {
1003
- cleanup();
1004
- onBack();
1005
- }
1006
- }
1007
-
1008
- // Handle save
1009
- async function handleSave(): Promise<void> {
1010
- if (!state.hasChanges) {
1011
- cleanup();
1012
- onBack();
1013
- return;
1014
- }
1015
-
1016
- const updated = await saveChanges();
1017
- if (updated) {
1018
- cleanup();
1019
- onBack(updated);
1020
- } else {
1021
- render();
1022
- }
1023
- }
1024
-
1025
- // Key handlers
1026
- const keyHandlers = {
1027
- up: () => {
1028
- state.selectedIndex = Math.max(0, state.selectedIndex - 1);
1029
- render();
1030
- },
1031
- down: () => {
1032
- state.selectedIndex = Math.min(state.fields.length - 1, state.selectedIndex + 1);
1033
- render();
1034
- },
1035
- enter: () => {
1036
- handleEdit();
1037
- },
1038
- save: () => {
1039
- handleSave();
1040
- },
1041
- escape: () => {
1042
- handleEscape();
1043
- },
1044
- quit: () => {
1045
- screen.destroy();
1046
- process.exit(0);
1047
- },
1048
- };
1049
-
1050
- // Unregister handlers (for modal dialogs)
1051
- function unregisterHandlers(): void {
1052
- screen.unkey('up', keyHandlers.up);
1053
- screen.unkey('k', keyHandlers.up);
1054
- screen.unkey('down', keyHandlers.down);
1055
- screen.unkey('j', keyHandlers.down);
1056
- screen.unkey('enter', keyHandlers.enter);
1057
- screen.unkey('s', keyHandlers.save);
1058
- screen.unkey('S', keyHandlers.save);
1059
- screen.unkey('escape', keyHandlers.escape);
1060
- screen.unkey('q', keyHandlers.quit);
1061
- screen.unkey('Q', keyHandlers.quit);
1062
- }
1063
-
1064
- // Register handlers
1065
- function registerHandlers(): void {
1066
- screen.key(['up', 'k'], keyHandlers.up);
1067
- screen.key(['down', 'j'], keyHandlers.down);
1068
- screen.key(['enter'], keyHandlers.enter);
1069
- screen.key(['s', 'S'], keyHandlers.save);
1070
- screen.key(['escape'], keyHandlers.escape);
1071
- screen.key(['q', 'Q'], keyHandlers.quit);
1072
- }
1073
-
1074
- // Cleanup function (for exiting config screen)
1075
- function cleanup(): void {
1076
- unregisterHandlers();
1077
- screen.remove(contentBox);
1078
- }
1079
-
1080
- // Initial registration
1081
- registerHandlers();
1082
-
1083
- // Initial render
1084
- render();
1085
- }