@appkit/llamacpp-cli 1.12.0 → 1.13.0

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 (136) hide show
  1. package/README.md +294 -168
  2. package/dist/cli.js +35 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/launch/claude.d.ts +6 -0
  5. package/dist/commands/launch/claude.d.ts.map +1 -0
  6. package/dist/commands/launch/claude.js +277 -0
  7. package/dist/commands/launch/claude.js.map +1 -0
  8. package/dist/lib/integration-checker.d.ts +26 -0
  9. package/dist/lib/integration-checker.d.ts.map +1 -0
  10. package/dist/lib/integration-checker.js +77 -0
  11. package/dist/lib/integration-checker.js.map +1 -0
  12. package/dist/lib/router-manager.d.ts +4 -0
  13. package/dist/lib/router-manager.d.ts.map +1 -1
  14. package/dist/lib/router-manager.js +10 -0
  15. package/dist/lib/router-manager.js.map +1 -1
  16. package/dist/lib/router-server.d.ts +13 -0
  17. package/dist/lib/router-server.d.ts.map +1 -1
  18. package/dist/lib/router-server.js +267 -7
  19. package/dist/lib/router-server.js.map +1 -1
  20. package/dist/types/integration-config.d.ts +28 -0
  21. package/dist/types/integration-config.d.ts.map +1 -0
  22. package/dist/types/integration-config.js +3 -0
  23. package/dist/types/integration-config.js.map +1 -0
  24. package/package.json +10 -2
  25. package/web/dist/assets/index-Bin89Lwr.css +1 -0
  26. package/web/dist/assets/index-CVmonw3T.js +17 -0
  27. package/web/{index.html → dist/index.html} +2 -1
  28. package/.versionrc.json +0 -16
  29. package/CHANGELOG.md +0 -213
  30. package/docs/images/.gitkeep +0 -1
  31. package/docs/images/web-ui-servers.png +0 -0
  32. package/src/cli.ts +0 -523
  33. package/src/commands/admin/config.ts +0 -121
  34. package/src/commands/admin/logs.ts +0 -91
  35. package/src/commands/admin/restart.ts +0 -26
  36. package/src/commands/admin/start.ts +0 -27
  37. package/src/commands/admin/status.ts +0 -84
  38. package/src/commands/admin/stop.ts +0 -16
  39. package/src/commands/config-global.ts +0 -38
  40. package/src/commands/config.ts +0 -323
  41. package/src/commands/create.ts +0 -183
  42. package/src/commands/delete.ts +0 -74
  43. package/src/commands/list.ts +0 -37
  44. package/src/commands/logs-all.ts +0 -251
  45. package/src/commands/logs.ts +0 -345
  46. package/src/commands/monitor.ts +0 -110
  47. package/src/commands/ps.ts +0 -84
  48. package/src/commands/pull.ts +0 -44
  49. package/src/commands/rm.ts +0 -107
  50. package/src/commands/router/config.ts +0 -116
  51. package/src/commands/router/logs.ts +0 -256
  52. package/src/commands/router/restart.ts +0 -36
  53. package/src/commands/router/start.ts +0 -60
  54. package/src/commands/router/status.ts +0 -119
  55. package/src/commands/router/stop.ts +0 -33
  56. package/src/commands/run.ts +0 -233
  57. package/src/commands/search.ts +0 -107
  58. package/src/commands/server-show.ts +0 -161
  59. package/src/commands/show.ts +0 -207
  60. package/src/commands/start.ts +0 -101
  61. package/src/commands/stop.ts +0 -39
  62. package/src/commands/tui.ts +0 -25
  63. package/src/lib/admin-manager.ts +0 -435
  64. package/src/lib/admin-server.ts +0 -1243
  65. package/src/lib/config-generator.ts +0 -130
  66. package/src/lib/download-job-manager.ts +0 -213
  67. package/src/lib/history-manager.ts +0 -172
  68. package/src/lib/launchctl-manager.ts +0 -225
  69. package/src/lib/metrics-aggregator.ts +0 -257
  70. package/src/lib/model-downloader.ts +0 -328
  71. package/src/lib/model-scanner.ts +0 -157
  72. package/src/lib/model-search.ts +0 -114
  73. package/src/lib/models-dir-setup.ts +0 -46
  74. package/src/lib/port-manager.ts +0 -80
  75. package/src/lib/router-logger.ts +0 -201
  76. package/src/lib/router-manager.ts +0 -414
  77. package/src/lib/router-server.ts +0 -538
  78. package/src/lib/state-manager.ts +0 -206
  79. package/src/lib/status-checker.ts +0 -113
  80. package/src/lib/system-collector.ts +0 -315
  81. package/src/tui/ConfigApp.ts +0 -1085
  82. package/src/tui/HistoricalMonitorApp.ts +0 -587
  83. package/src/tui/ModelsApp.ts +0 -368
  84. package/src/tui/MonitorApp.ts +0 -386
  85. package/src/tui/MultiServerMonitorApp.ts +0 -1833
  86. package/src/tui/RootNavigator.ts +0 -74
  87. package/src/tui/SearchApp.ts +0 -511
  88. package/src/tui/SplashScreen.ts +0 -149
  89. package/src/types/admin-config.ts +0 -25
  90. package/src/types/global-config.ts +0 -26
  91. package/src/types/history-types.ts +0 -39
  92. package/src/types/model-info.ts +0 -8
  93. package/src/types/monitor-types.ts +0 -162
  94. package/src/types/router-config.ts +0 -25
  95. package/src/types/server-config.ts +0 -46
  96. package/src/utils/downsample-utils.ts +0 -128
  97. package/src/utils/file-utils.ts +0 -146
  98. package/src/utils/format-utils.ts +0 -98
  99. package/src/utils/log-parser.ts +0 -284
  100. package/src/utils/log-utils.ts +0 -178
  101. package/src/utils/process-utils.ts +0 -316
  102. package/src/utils/prompt-utils.ts +0 -47
  103. package/test-load.sh +0 -100
  104. package/tsconfig.json +0 -20
  105. package/web/eslint.config.js +0 -23
  106. package/web/llamacpp-web-dist.tar.gz +0 -0
  107. package/web/package-lock.json +0 -4017
  108. package/web/package.json +0 -38
  109. package/web/postcss.config.js +0 -6
  110. package/web/src/App.css +0 -42
  111. package/web/src/App.tsx +0 -86
  112. package/web/src/assets/react.svg +0 -1
  113. package/web/src/components/ApiKeyPrompt.tsx +0 -71
  114. package/web/src/components/CreateServerModal.tsx +0 -372
  115. package/web/src/components/DownloadProgress.tsx +0 -123
  116. package/web/src/components/Nav.tsx +0 -89
  117. package/web/src/components/RouterConfigModal.tsx +0 -240
  118. package/web/src/components/SearchModal.tsx +0 -306
  119. package/web/src/components/ServerConfigModal.tsx +0 -291
  120. package/web/src/hooks/useApi.ts +0 -259
  121. package/web/src/index.css +0 -42
  122. package/web/src/lib/api.ts +0 -226
  123. package/web/src/main.tsx +0 -10
  124. package/web/src/pages/Dashboard.tsx +0 -103
  125. package/web/src/pages/Models.tsx +0 -258
  126. package/web/src/pages/Router.tsx +0 -270
  127. package/web/src/pages/RouterLogs.tsx +0 -201
  128. package/web/src/pages/ServerLogs.tsx +0 -553
  129. package/web/src/pages/Servers.tsx +0 -358
  130. package/web/src/types/api.ts +0 -140
  131. package/web/tailwind.config.js +0 -31
  132. package/web/tsconfig.app.json +0 -28
  133. package/web/tsconfig.json +0 -7
  134. package/web/tsconfig.node.json +0 -26
  135. package/web/vite.config.ts +0 -25
  136. /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
- }