dsh-plugin-local-infer 0.1.1 → 0.1.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.
Files changed (108) hide show
  1. package/README.md +14 -3
  2. package/docs/zh/README.md +14 -3
  3. package/dsh/client.js +1214 -242
  4. package/package.json +16 -10
  5. package/packages/core/lib/adapter.d.ts +119 -6
  6. package/packages/core/lib/deps.d.ts +4 -3
  7. package/packages/core/lib/index.d.ts +2 -1
  8. package/packages/core/lib/index.js +1 -0
  9. package/packages/core/lib/launch-command.d.ts +13 -0
  10. package/packages/core/lib/launch-command.js +64 -0
  11. package/packages/core/lib/models.d.ts +2 -1
  12. package/packages/core/lib/models.js +10 -1
  13. package/packages/core/lib/registrar.d.ts +5 -5
  14. package/packages/core/lib/registrar.js +3 -3
  15. package/packages/core/lib/status.d.ts +9 -1
  16. package/packages/core/lib/status.js +6 -1
  17. package/packages/core/package.json +1 -1
  18. package/packages/providers/lib/acquire.d.ts +127 -0
  19. package/packages/providers/lib/acquire.js +346 -0
  20. package/packages/providers/lib/adapters.d.ts +19 -172
  21. package/packages/providers/lib/adapters.js +114 -659
  22. package/packages/providers/lib/deps.d.ts +26 -2
  23. package/packages/providers/lib/deps.js +37 -4
  24. package/packages/providers/lib/engines/hyperqwenAdapter.d.ts +38 -0
  25. package/packages/providers/lib/engines/hyperqwenAdapter.js +105 -0
  26. package/packages/providers/lib/engines/infernix.d.ts +170 -0
  27. package/packages/providers/lib/engines/infernix.js +671 -0
  28. package/packages/providers/lib/engines/infernixAdapter.d.ts +53 -0
  29. package/packages/providers/lib/engines/infernixAdapter.js +202 -0
  30. package/packages/providers/lib/engines/llamacppAdapter.d.ts +51 -0
  31. package/packages/providers/lib/engines/llamacppAdapter.js +232 -0
  32. package/packages/providers/lib/{ninfer.d.ts → engines/ninfer.d.ts} +7 -0
  33. package/packages/providers/lib/{ninfer.js → engines/ninfer.js} +8 -1
  34. package/packages/providers/lib/engines/ninferAdapter.d.ts +82 -0
  35. package/packages/providers/lib/engines/ninferAdapter.js +324 -0
  36. package/packages/providers/lib/engines/strata.d.ts +53 -0
  37. package/packages/providers/lib/engines/strata.js +197 -0
  38. package/packages/providers/lib/engines/strataAdapter.d.ts +98 -0
  39. package/packages/providers/lib/engines/strataAdapter.js +427 -0
  40. package/packages/providers/lib/engines.d.ts +5 -3
  41. package/packages/providers/lib/engines.js +16 -8
  42. package/packages/providers/lib/guidelines.js +16 -0
  43. package/packages/providers/lib/index.d.ts +21 -10
  44. package/packages/providers/lib/index.js +23 -9
  45. package/packages/providers/lib/model-defaults.d.ts +8 -5
  46. package/packages/providers/lib/model-defaults.js +47 -5
  47. package/packages/providers/lib/platform.d.ts +31 -0
  48. package/packages/providers/lib/platform.js +64 -0
  49. package/packages/providers/package.json +1 -1
  50. package/packages/service/lib/index.d.ts +74 -22
  51. package/packages/service/lib/index.js +354 -44
  52. package/packages/service/lib/types.d.ts +48 -5
  53. package/packages/service/package.json +1 -1
  54. package/packages/tool/package.json +1 -1
  55. package/packages/ui/lib/config.d.ts +79 -0
  56. package/packages/ui/lib/config.js +235 -0
  57. package/packages/ui/lib/deps.d.ts +3 -3
  58. package/packages/ui/lib/ops/engine.d.ts +108 -0
  59. package/packages/ui/lib/ops/engine.js +493 -0
  60. package/packages/ui/lib/ops/hf.d.ts +27 -0
  61. package/packages/ui/lib/ops/hf.js +60 -0
  62. package/packages/ui/lib/ops/logs.d.ts +23 -0
  63. package/packages/ui/lib/ops/logs.js +58 -0
  64. package/packages/ui/lib/ops/model.d.ts +79 -0
  65. package/packages/ui/lib/ops/model.js +130 -0
  66. package/packages/ui/lib/request.d.ts +106 -0
  67. package/packages/ui/lib/request.js +80 -0
  68. package/packages/ui/lib/route.d.ts +96 -357
  69. package/packages/ui/lib/route.js +123 -1415
  70. package/packages/ui/lib/routes/chat-template.d.ts +14 -0
  71. package/packages/ui/lib/routes/chat-template.js +37 -0
  72. package/packages/ui/lib/routes/deps.d.ts +16 -0
  73. package/packages/ui/lib/routes/deps.js +27 -0
  74. package/packages/ui/lib/routes/download.d.ts +16 -0
  75. package/packages/ui/lib/routes/download.js +48 -0
  76. package/packages/ui/lib/routes/get.d.ts +18 -0
  77. package/packages/ui/lib/routes/get.js +57 -0
  78. package/packages/ui/lib/routes/install.d.ts +16 -0
  79. package/packages/ui/lib/routes/install.js +36 -0
  80. package/packages/ui/lib/routes/launch.d.ts +18 -0
  81. package/packages/ui/lib/routes/launch.js +50 -0
  82. package/packages/ui/lib/routes/provider-delete.d.ts +15 -0
  83. package/packages/ui/lib/routes/provider-delete.js +37 -0
  84. package/packages/ui/lib/routes/register.d.ts +16 -0
  85. package/packages/ui/lib/routes/register.js +28 -0
  86. package/packages/ui/lib/routes/save.d.ts +18 -0
  87. package/packages/ui/lib/routes/save.js +139 -0
  88. package/packages/ui/lib/routes/stop.d.ts +16 -0
  89. package/packages/ui/lib/routes/stop.js +29 -0
  90. package/packages/ui/lib/routes/summary.d.ts +15 -0
  91. package/packages/ui/lib/routes/summary.js +26 -0
  92. package/packages/ui/lib/routes/unregister.d.ts +14 -0
  93. package/packages/ui/lib/routes/unregister.js +26 -0
  94. package/packages/ui/lib/viewmodels/deps.d.ts +31 -0
  95. package/packages/ui/lib/viewmodels/deps.js +89 -0
  96. package/packages/ui/lib/viewmodels/depsHelpers.d.ts +28 -0
  97. package/packages/ui/lib/viewmodels/depsHelpers.js +33 -0
  98. package/packages/ui/lib/viewmodels/summary.d.ts +140 -0
  99. package/packages/ui/lib/viewmodels/summary.js +225 -0
  100. package/packages/ui/lib/viewmodels/summaryHelpers.d.ts +30 -0
  101. package/packages/ui/lib/viewmodels/summaryHelpers.js +42 -0
  102. package/packages/ui/package.json +1 -1
  103. package/packages/providers/lib/strata.d.ts +0 -15
  104. package/packages/providers/lib/strata.js +0 -14
  105. /package/packages/providers/lib/{hyperqwen.d.ts → engines/hyperqwen.d.ts} +0 -0
  106. /package/packages/providers/lib/{hyperqwen.js → engines/hyperqwen.js} +0 -0
  107. /package/packages/providers/lib/{llamacpp.d.ts → engines/llamacpp.d.ts} +0 -0
  108. /package/packages/providers/lib/{llamacpp.js → engines/llamacpp.js} +0 -0
package/dsh/client.js CHANGED
@@ -83,6 +83,8 @@ window.__ModuleLoader__.load({
83
83
  run: 'Run',
84
84
  running: 'running...',
85
85
  get: 'Get',
86
+ getting: 'Getting...',
87
+ getFailed: 'get failed',
86
88
  allowRun: 'Allow one-click install (sudo)',
87
89
  depsSatisfied: 'All dependencies satisfied.',
88
90
  installRejected: 'one-click install is not allowed; copy the install string instead',
@@ -94,6 +96,7 @@ window.__ModuleLoader__.load({
94
96
  unregistered: 'removed',
95
97
  unregisterFailed: 'remove failed',
96
98
  launch: 'Launch',
99
+ autoRebuild: 'Auto Rebuild',
97
100
  launching: 'Launching...',
98
101
  launchFailed: 'launch failed',
99
102
  runningOn: 'running on port',
@@ -106,6 +109,8 @@ window.__ModuleLoader__.load({
106
109
  enginePlatform: 'Engine platform',
107
110
  engineDirectory: 'Engine Directory',
108
111
  engineFork: 'Engine Fork (Git Repo)',
112
+ buildCommands: 'Build commands',
113
+ dockerTag: 'Docker tag',
109
114
  dirPlaceholder: 'directory path',
110
115
  forkPlaceholder: 'git repo url',
111
116
  hfCliDetected: 'HF CLI detected:',
@@ -115,6 +120,7 @@ window.__ModuleLoader__.load({
115
120
  downloaded: 'download complete',
116
121
  downloadFailed: 'download failed',
117
122
  helpToggle: 'Show or hide field help',
123
+ refresh: 'Re-fetch the card data from the host',
118
124
  engineHelp:
119
125
  'Inference engines have different strengths and weaknesses for specific models and hardware.',
120
126
  modelHelp:
@@ -123,8 +129,12 @@ window.__ModuleLoader__.load({
123
129
  'How the engine is deployed. local builds it from source on the host; docker pulls a prebuilt image. The dependency plan and hardware guidelines price the selected mode.',
124
130
  modelDirHelp: 'Where model files are downloaded and served from.',
125
131
  engineDirHelp:
126
- 'Local directory of the engine source. In local build mode the deploy step clones and builds the engine here.',
132
+ 'Local directory of the engine source. Get clones or updates the git repo and builds the engine here; with no override it lands in the model directory under the engine name.',
127
133
  engineForkHelp: 'Git repo that overrides the engine default upstream for the clone step.',
134
+ buildCommandsHelp:
135
+ 'Local build command that overrides the engine default for the checkout build. Shown only in local build mode.',
136
+ dockerTagHelp:
137
+ 'Docker image tag the docker deploy pulls. Shown only in docker build mode.',
128
138
  providers: 'Providers',
129
139
  newProvider: 'New provider',
130
140
  edit: 'Edit',
@@ -139,9 +149,14 @@ window.__ModuleLoader__.load({
139
149
  registerNeedsSave: 'Save the Provider configuration before registering with DSH.',
140
150
  logs: 'Logs',
141
151
  logLocation: 'Log location',
152
+ resize: 'resize',
142
153
  entryLaunch: 'Launch',
143
154
  entryStop: 'Stop',
144
155
  entryLogs: 'Logs',
156
+ entryPrereqs: 'Prereqs...',
157
+ entryDownloading: 'Downloading...',
158
+ entryBuilding: 'Building...',
159
+ entryLaunching: 'Launching...',
145
160
  chatTemplatePreset: 'Chat template preset',
146
161
  customChatTemplate: 'Custom Chat template',
147
162
  custom: 'Custom',
@@ -210,6 +225,8 @@ window.__ModuleLoader__.load({
210
225
  run: '运行',
211
226
  running: '运行中…',
212
227
  get: '获取',
228
+ getting: '获取中…',
229
+ getFailed: '获取失败',
213
230
  allowRun: '允许一键安装 (sudo)',
214
231
  depsSatisfied: '所有依赖已满足。',
215
232
  installRejected: '未允许一键安装;请复制安装命令。',
@@ -221,6 +238,7 @@ window.__ModuleLoader__.load({
221
238
  unregistered: '已移除',
222
239
  unregisterFailed: '移除失败',
223
240
  launch: '启动',
241
+ autoRebuild: '自动重建',
224
242
  launching: '启动中…',
225
243
  launchFailed: '启动失败',
226
244
  runningOn: '运行于端口',
@@ -233,6 +251,8 @@ window.__ModuleLoader__.load({
233
251
  enginePlatform: '引擎平台',
234
252
  engineDirectory: '引擎目录',
235
253
  engineFork: '引擎 Fork (Git 仓库)',
254
+ buildCommands: '构建命令',
255
+ dockerTag: 'Docker 标签',
236
256
  dirPlaceholder: '目录路径',
237
257
  forkPlaceholder: 'git 仓库地址',
238
258
  hfCliDetected: '检测到 HF CLI:',
@@ -242,13 +262,16 @@ window.__ModuleLoader__.load({
242
262
  downloaded: '下载完成',
243
263
  downloadFailed: '下载失败',
244
264
  helpToggle: '显示或隐藏字段帮助',
265
+ refresh: '从主机重新加载卡片数据',
245
266
  engineHelp: '推理引擎针对特定模型和硬件各有优劣。',
246
267
  modelHelp: '引擎服务的模型。可选项包括引擎默认模型和模型目录中已有的模型文件。',
247
268
  buildHelp:
248
269
  '引擎的部署方式。local 在主机上从源码构建;docker 拉取预构建镜像。依赖计划和硬件准则按所选模式计价。',
249
270
  modelDirHelp: '模型文件的下载和服务目录。',
250
- engineDirHelp: '引擎源码的本地目录。本地构建模式下,部署步骤在此克隆并构建引擎。',
271
+ engineDirHelp: '引擎源码的本地目录。“获取”会在此克隆或更新 git 仓库并构建引擎;未覆盖时,仓库落在模型目录下以引擎命名的子目录中。',
251
272
  engineForkHelp: '用于克隆步骤、覆盖引擎默认上游的 git 仓库。',
273
+ buildCommandsHelp: '本地构建命令,覆盖引擎默认的检查构建。仅在本地构建模式下显示。',
274
+ dockerTagHelp: 'docker 部署拉取的镜像标签。仅在 docker 构建模式下显示。',
252
275
  providers: '提供方',
253
276
  newProvider: '新增提供方',
254
277
  edit: '编辑',
@@ -262,9 +285,14 @@ window.__ModuleLoader__.load({
262
285
  registerNeedsSave: '注册到 DSH 之前请先保存该提供方配置。',
263
286
  logs: '日志',
264
287
  logLocation: '日志位置',
288
+ resize: '调整大小',
265
289
  entryLaunch: '启动',
266
290
  entryStop: '停止',
267
291
  entryLogs: '日志',
292
+ entryPrereqs: '前置条件…',
293
+ entryDownloading: '下载中…',
294
+ entryBuilding: '构建中…',
295
+ entryLaunching: '启动中…',
268
296
  chatTemplatePreset: '聊天模板预设',
269
297
  customChatTemplate: '自定义聊天模板',
270
298
  custom: '自定义',
@@ -432,6 +460,127 @@ window.__ModuleLoader__.load({
432
460
  color: '#fff',
433
461
  border: 'none',
434
462
  });
463
+
464
+ // The per-entry provider state machine: the card's Launch/Stop control
465
+ // lifecycle. The states are the button's reads: idle (a plain Launch), the
466
+ // four rebuild phases (prereqs, download, build, launch), running (a
467
+ // Stop), stopping (a disabled throbber while the stop is in flight), and
468
+ // failed (a red Logs). launchNextState is the hardened transition: a step
469
+ // event only advances forward through the rebuild phases (a stale or
470
+ // out-of-order step is a no-op), a healthy result settles to running, a
471
+ // failed result settles to failed, a stop moves a running (or idle, a
472
+ // provider running per the summary with no machine entry) engine to
473
+ // stopping, a confirmed stop settles to idle, a stop that is still running
474
+ // at the deadline settles to failed with the cause 'stop', and a break
475
+ // (the Logs control, a navigation away) resets: a launch failure reads as
476
+ // Launch (idle), a stop timeout reads as Stop (running), because the
477
+ // engine is probably still up. The machine takes the entry ({status,
478
+ // cause}) and returns the next entry, so the cause-aware reset stays in
479
+ // one place. An event that names no legal next state leaves the control
480
+ // where it is, so a late event never corrupts it.
481
+ var LAUNCH_STEP_ORDER = { prereqs: 0, download: 1, build: 2, launch: 3 };
482
+ var LAUNCH_PHASE_STATES = ['prereqs', 'download', 'build', 'launch'];
483
+ // The health-poll deadline: the route's poll runs up to two minutes, so a
484
+ // client-side deadline just past that cap forces a hung launch to the red
485
+ // Logs state. It arms only for the launch phase; the rebuild phases
486
+ // (prereqs, download, build) run as long as they need to, so a slow build
487
+ // never reads as a failure.
488
+ var LAUNCH_HEALTH_DEADLINE_MS = 150000;
489
+ // The stop-poll interval: while a stop is in flight, the card re-fetches
490
+ // the summary on this cadence so the provider's status dot repaints the
491
+ // moment the engine is down, instead of waiting out the host's 5-second
492
+ // process-watch and probe cycle.
493
+ var STOP_POLL_MS = 1000;
494
+ // The stop deadline: a provider still running 10 seconds after a Stop
495
+ // click settles to the red Logs state (the same path a failed launch
496
+ // takes), so the user can open the entry, fix the config, and retry.
497
+ var STOP_HEALTH_DEADLINE_MS = 10000;
498
+
499
+ function launchIsPhase(state) {
500
+ return LAUNCH_PHASE_STATES.indexOf(state) !== -1;
501
+ }
502
+
503
+ function launchNextState(entry, event) {
504
+ var current = entry && entry.status !== undefined ? entry.status : 'idle';
505
+ var cause = entry && entry.cause !== undefined ? entry.cause : undefined;
506
+ var type = event.type;
507
+ var next;
508
+ var nextCause;
509
+ if (type === 'start') {
510
+ // A launch begins from idle (a plain Launch), failed (a re-launch
511
+ // after the Logs control cleared the failed state), or downloading
512
+ // (a re-poll while the engine fetches its model files).
513
+ next =
514
+ current === 'idle' || current === 'failed' || current === 'downloading'
515
+ ? 'prereqs'
516
+ : current;
517
+ nextCause = undefined;
518
+ } else if (type === 'step') {
519
+ var target = event.step;
520
+ if (LAUNCH_STEP_ORDER[target] === undefined) {
521
+ next = current;
522
+ nextCause = cause;
523
+ } else if (current === 'idle' || current === 'failed') {
524
+ next = target;
525
+ nextCause = undefined;
526
+ } else if (current === 'running' || current === 'stopping') {
527
+ // A stop in flight supersedes a late launch step.
528
+ next = current;
529
+ nextCause = cause;
530
+ } else {
531
+ var currentOrder = LAUNCH_STEP_ORDER[current];
532
+ if (currentOrder === undefined) {
533
+ next = current;
534
+ nextCause = cause;
535
+ } else {
536
+ next = LAUNCH_STEP_ORDER[target] >= currentOrder ? target : current;
537
+ nextCause = undefined;
538
+ }
539
+ }
540
+ } else if (type === 'healthy') {
541
+ next = launchIsPhase(current) ? 'running' : current;
542
+ nextCause = undefined;
543
+ } else if (type === 'failed') {
544
+ next = launchIsPhase(current) ? 'failed' : current;
545
+ nextCause = undefined;
546
+ } else if (type === 'downloading') {
547
+ // The engine is fetching its model files: a busy throbber, not a
548
+ // failure. The card re-polls (a re-launch) until the engine reports
549
+ // ready, which settles the control to running.
550
+ next = launchIsPhase(current) ? 'downloading' : current;
551
+ nextCause = undefined;
552
+ } else if (type === 'stop') {
553
+ // A stop moves a running engine (or an idle one running per the
554
+ // summary, or a launch in flight) to the stopping state; a stop
555
+ // already in flight is a no-op.
556
+ next =
557
+ current === 'running' ||
558
+ current === 'stopping' ||
559
+ current === 'idle' ||
560
+ launchIsPhase(current)
561
+ ? 'stopping'
562
+ : current;
563
+ nextCause = undefined;
564
+ } else if (type === 'stopped') {
565
+ // The fast poll confirmed the provider is no longer running.
566
+ next = current === 'stopping' ? 'idle' : current;
567
+ nextCause = undefined;
568
+ } else if (type === 'stopFailed') {
569
+ // The deadline fired while the provider was still running.
570
+ next = current === 'stopping' ? 'failed' : current;
571
+ nextCause = current === 'stopping' ? 'stop' : cause;
572
+ } else if (type === 'reset') {
573
+ // A launch failure reads as Launch (idle); a stop timeout reads as
574
+ // Stop (running), because the engine is probably still up.
575
+ next = current === 'failed' ? (cause === 'stop' ? 'running' : 'idle') : 'idle';
576
+ nextCause = undefined;
577
+ } else {
578
+ next = current;
579
+ nextCause = cause;
580
+ }
581
+ return { status: next, cause: nextCause };
582
+ }
583
+
435
584
  // The deps panel. The install block is a copy/paste target, so it gets a
436
585
  // monospace pre with its own copy button; the dep rows read their status
437
586
  // from a state dot (the DSH StateDot, or its glyph fallback), not a word,
@@ -476,10 +625,10 @@ window.__ModuleLoader__.load({
476
625
  gap: 4,
477
626
  justifyContent: 'flex-end',
478
627
  };
479
- // The circular "?" button in the card header. It toggles the per-field
480
- // descriptions; the preference persists through the save route, so it
481
- // survives a reload. The active state reads as the accent fill; the
482
- // inactive state as the muted outline.
628
+ // The circular header buttons: the "?" help toggle and the refresh
629
+ // action. The active state reads as the accent fill; the inactive state
630
+ // as the muted outline. The help preference persists through the save
631
+ // route, so it survives a reload.
483
632
  var HELP_BUTTON_STYLE = (active) => ({
484
633
  width: 20,
485
634
  height: 20,
@@ -818,36 +967,60 @@ window.__ModuleLoader__.load({
818
967
  // a collapsed card with a status dot (green running, red stopped, yellow
819
968
  // The per-entry Launch control: a card entry's own lifecycle button, shown
820
969
  // alongside its Edit and Delete controls. Idle reads as a Launch button
821
- // that starts the entry's engine; a launch in flight shows a disabled
822
- // control with the spinner inside; a healthy engine reads as an enabled
823
- // Stop; a launch that timed out reads as a red Logs control that opens the
824
- // entry for editing, the same action as Edit.
970
+ // that starts the entry's engine; a rebuild in flight shows a disabled
971
+ // control with the spinner and the phase label (Prereqs, Downloading,
972
+ // Building, Launching); a healthy engine reads as an enabled Stop; a launch
973
+ // that timed out reads as a red Logs control that opens the entry for
974
+ // editing, the same action as Edit.
825
975
  function renderEntryLaunchButton(react, primitives, provider, opts) {
826
976
  var state = (opts.launchState || {})[provider.id];
827
- if (state !== undefined && state.status === 'launching') {
828
- return react.createElement(
829
- 'button',
830
- {
831
- type: 'button',
832
- disabled: true,
833
- style: Object.assign(
834
- { display: 'inline-flex', alignItems: 'center', gap: 4, opacity: 0.7 },
835
- GHOST_BUTTON,
836
- ),
837
- 'aria-label': opts.launchingLabel,
838
- },
839
- renderThrobber(react, primitives),
840
- react.createElement('span', null, opts.launchingLabel),
841
- );
977
+ var status = state !== undefined ? state.status : 'idle';
978
+ // A rebuild phase in flight: a disabled control with the spinner and the
979
+ // phase label, so the button reads the step that is actually running.
980
+ var phaseLabels = {
981
+ prereqs: opts.prereqsLabel,
982
+ download: opts.downloadingLabel,
983
+ build: opts.buildingLabel,
984
+ launch: opts.launchingLabel,
985
+ };
986
+ var phaseLabel = phaseLabels[status];
987
+ if (phaseLabel !== undefined) {
988
+ return renderProcessButton(react, primitives, {
989
+ busy: true,
990
+ label: phaseLabel,
991
+ busyLabel: phaseLabel,
992
+ variant: 'ghost',
993
+ onClick: function () {},
994
+ });
995
+ }
996
+ // A stop in flight: a disabled control with the spinner and the
997
+ // stopping label, the same throbber the launch phases show.
998
+ if (status === 'stopping') {
999
+ return renderProcessButton(react, primitives, {
1000
+ busy: true,
1001
+ label: opts.stoppingLabel,
1002
+ busyLabel: opts.stoppingLabel,
1003
+ variant: 'ghost',
1004
+ onClick: function () {},
1005
+ });
1006
+ }
1007
+ // The engine is fetching its model files (the Strata download): a busy
1008
+ // throbber that reads as in progress, not a failure. The card re-polls
1009
+ // (a re-launch) until the engine reports ready.
1010
+ if (status === 'downloading') {
1011
+ return renderProcessButton(react, primitives, {
1012
+ busy: true,
1013
+ label: opts.downloadingLabel,
1014
+ busyLabel: opts.downloadingLabel,
1015
+ variant: 'ghost',
1016
+ onClick: function () {},
1017
+ });
842
1018
  }
843
1019
  // The Stop control reads from the provider status dot, not just the
844
1020
  // per-entry launch state: the launch state is lost when the user
845
1021
  // navigates away from the plugin page and back, so a running engine
846
1022
  // (a green dot) must keep the control as Stop, not reset to Launch.
847
- if (
848
- (state !== undefined && state.status === 'running') ||
849
- provider.status === 'running'
850
- ) {
1023
+ if (status === 'running' || provider.status === 'running') {
851
1024
  return renderButton(
852
1025
  react,
853
1026
  primitives,
@@ -856,10 +1029,10 @@ window.__ModuleLoader__.load({
856
1029
  opts.stopLabel,
857
1030
  );
858
1031
  }
859
- if (state !== undefined && state.status === 'failed') {
860
- // A launch that timed out: a red Logs control that opens the entry for
861
- // editing and clears the failed state, so the control reads as Launch
862
- // again and the user can fix the config and re-launch.
1032
+ if (status === 'failed') {
1033
+ // A launch that timed out or failed: a red Logs control that opens the
1034
+ // entry for editing and clears the failed state, so the control reads
1035
+ // as Launch again and the user can fix the config and re-launch.
863
1036
  return react.createElement(
864
1037
  'button',
865
1038
  {
@@ -890,6 +1063,43 @@ window.__ModuleLoader__.load({
890
1063
  );
891
1064
  }
892
1065
 
1066
+ // The owner/repo short name the provider title bar renders when an entry
1067
+ // names a fork (the git repo that overrides the engine default upstream).
1068
+ // It accepts a protocol URL (https://github.com/owner/repo), an scp-style
1069
+ // git@host:owner/repo reference, and an already-short owner/repo form,
1070
+ // stripping a trailing .git. It returns undefined when no repo name is
1071
+ // present, so the title bar falls back to the engine label.
1072
+ function repoShortName(ref) {
1073
+ if (typeof ref !== 'string' || ref.trim() === '') {
1074
+ return undefined;
1075
+ }
1076
+ var value = ref.trim();
1077
+ var isUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(value);
1078
+ if (isUrl) {
1079
+ // Drop the scheme and the host segment, leaving owner/repo.
1080
+ value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/^[^/]+\//, '');
1081
+ } else {
1082
+ var colon = value.indexOf(':');
1083
+ var slash = value.indexOf('/');
1084
+ // An scp-style git@host:owner/repo reference: drop the host prefix.
1085
+ if (colon !== -1 && (slash === -1 || colon < slash)) {
1086
+ value = value.slice(colon + 1);
1087
+ }
1088
+ }
1089
+ var segments = value.split('/').filter(function (part) {
1090
+ return part !== '';
1091
+ });
1092
+ if (segments.length === 0) {
1093
+ return undefined;
1094
+ }
1095
+ var repo = segments[segments.length - 1].replace(/\.git$/, '');
1096
+ if (repo === '') {
1097
+ return undefined;
1098
+ }
1099
+ var owner = segments.length > 1 ? segments[segments.length - 2] : undefined;
1100
+ return owner ? owner + '/' + repo : repo;
1101
+ }
1102
+
893
1103
  // unsure), an "Engine: Model" title, and Edit/Delete controls. A New
894
1104
  // provider button resets the form to a fresh entry. The list sits above
895
1105
  // the editing form, so a provider's full config form stays the single
@@ -906,6 +1116,15 @@ window.__ModuleLoader__.load({
906
1116
  ((opts.engines || []).find(function (engine) {
907
1117
  return engine.id === provider.engine;
908
1118
  }) || {}).label || provider.engine;
1119
+ // The title bar renders the fork's owner/repo when the entry names a
1120
+ // fork, so a forked engine reads as its repo (Ninfer/ninfer-all)
1121
+ // rather than the engine label (NInfer). No fork, or an unparseable
1122
+ // one, keeps the engine label.
1123
+ var forkName =
1124
+ provider.platform && typeof provider.platform === 'object'
1125
+ ? repoShortName(provider.platform.engineFork)
1126
+ : undefined;
1127
+ var titleLabel = forkName !== undefined ? forkName : engineLabel;
909
1128
  var modelLabel =
910
1129
  provider.model && provider.model !== '' ? provider.model : opts.modelAuto;
911
1130
  return react.createElement(
@@ -934,21 +1153,21 @@ window.__ModuleLoader__.load({
934
1153
  whiteSpace: 'nowrap',
935
1154
  },
936
1155
  title:
937
- engineLabel +
1156
+ titleLabel +
938
1157
  ': ' +
939
1158
  modelLabel +
940
1159
  ' (' +
941
1160
  (statusToLabel[provider.status] || provider.status) +
942
1161
  ')',
943
1162
  'aria-label':
944
- engineLabel +
1163
+ titleLabel +
945
1164
  ': ' +
946
1165
  modelLabel +
947
1166
  ' (' +
948
1167
  (statusToLabel[provider.status] || provider.status) +
949
1168
  ')',
950
1169
  },
951
- engineLabel +
1170
+ titleLabel +
952
1171
  ': ' +
953
1172
  modelLabel +
954
1173
  ' (' +
@@ -1039,6 +1258,39 @@ window.__ModuleLoader__.load({
1039
1258
  });
1040
1259
  }
1041
1260
 
1261
+ // The reusable stateful process button: the Disable/Throbber/Reset
1262
+ // lifecycle every process control (Get, Launch, Stop, Run, Download)
1263
+ // shares. While the process is in flight (busy) it reads as a disabled
1264
+ // control with the spinner and the in-progress label; when it settles the
1265
+ // caller flips busy back to false and the control resets to the idle
1266
+ // action button (the label and the click handler). A host without the
1267
+ // primitives seed keeps the native fallback through renderButton.
1268
+ function renderProcessButton(react, primitives, opts) {
1269
+ if (opts.busy === true) {
1270
+ return react.createElement(
1271
+ 'button',
1272
+ {
1273
+ type: 'button',
1274
+ disabled: true,
1275
+ style: Object.assign(
1276
+ { display: 'inline-flex', alignItems: 'center', gap: 4, opacity: 0.7 },
1277
+ GHOST_BUTTON,
1278
+ ),
1279
+ 'aria-label': opts.busyLabel,
1280
+ },
1281
+ renderThrobber(react, primitives),
1282
+ react.createElement('span', null, opts.busyLabel),
1283
+ );
1284
+ }
1285
+ return renderButton(
1286
+ react,
1287
+ primitives,
1288
+ opts.variant,
1289
+ { onClick: opts.onClick, disabled: opts.disabled === true },
1290
+ opts.label,
1291
+ );
1292
+ }
1293
+
1042
1294
  // The chat-template control, split from the old finicky free-text combo
1043
1295
  // into a preset dropdown (the shipped presets plus a "Custom" entry) and a
1044
1296
  // "Custom Chat template" text field that shows while the preset is
@@ -1568,6 +1820,11 @@ window.__ModuleLoader__.load({
1568
1820
  );
1569
1821
  }
1570
1822
 
1823
+ // The Get run's output-idle cap. Two minutes without a streamed line
1824
+ // reads as a stalled build: the card re-enables Launch while the run
1825
+ // keeps streaming in the background.
1826
+ var GET_IDLE_MS = 120000;
1827
+
1571
1828
  // The "System dependencies" panel: the per-dep status list, the copy/paste
1572
1829
  // install block, the Play/Run arrow (only while the trust gate is on),
1573
1830
  // and the "Get" gate that stays disabled until every dep reports
@@ -1684,21 +1941,22 @@ window.__ModuleLoader__.load({
1684
1941
  label: t.allowRun,
1685
1942
  }),
1686
1943
  deps.runAllowed && !deps.allSatisfied
1687
- ? renderButton(
1688
- react,
1689
- primitives,
1690
- 'ghost',
1691
- { onClick: actions.onRun, disabled: actions.installing },
1692
- actions.installing ? t.running : '▶ ' + t.run,
1693
- )
1944
+ ? renderProcessButton(react, primitives, {
1945
+ busy: actions.installing === true,
1946
+ label: '▶ ' + t.run,
1947
+ busyLabel: t.running,
1948
+ variant: 'ghost',
1949
+ onClick: actions.onRun,
1950
+ })
1694
1951
  : null,
1695
- renderButton(
1696
- react,
1697
- primitives,
1698
- 'primary',
1699
- { onClick: actions.onGet, disabled: !deps.allSatisfied },
1700
- t.get,
1701
- ),
1952
+ renderProcessButton(react, primitives, {
1953
+ busy: actions.getting === true,
1954
+ label: t.get,
1955
+ busyLabel: t.getting,
1956
+ variant: 'primary',
1957
+ onClick: actions.onGet,
1958
+ disabled: !deps.allSatisfied,
1959
+ }),
1702
1960
  ),
1703
1961
  actions.depsNote
1704
1962
  ? react.createElement(
@@ -1766,12 +2024,26 @@ window.__ModuleLoader__.load({
1766
2024
  // does.
1767
2025
  var launchingState = react.useState(false);
1768
2026
  var stoppingState = react.useState(false);
2027
+ // The Get step's state. Get runs the git sync, the dependency plan,
2028
+ // and the engine build on the host through /local-infer/get, and the
2029
+ // streamed output lands in getLogs (the Logs section shows it while
2030
+ // the run is in flight). Launch stays disabled while a Get is in
2031
+ // flight; the idle timer re-enables it once GET_IDLE_MS passes with
2032
+ // no output, so a silent build never parks the button.
2033
+ var gettingState = react.useState(false);
2034
+ var getLogsState = react.useState(null);
2035
+ var getIdleTimerRef = react.useRef(null);
1769
2036
  // Whether the draft's engine is confirmed running, so the single
1770
2037
  // Launch/Stop control reads as Stop while the engine is up and Launch
1771
2038
  // while it is down, the same toggle the per-entry control does. A
1772
2039
  // healthy launch sets it, a stop clears it.
1773
2040
  var draftRunningState = react.useState(false);
1774
2041
  var lifecycleNoteState = react.useState('');
2042
+ // The Auto Rebuild checkbox next to the form's Launch button. Checked
2043
+ // by default: a launch runs the git sync (fetch, rebase, rebuild)
2044
+ // before spawning the engine. Unchecked, the launch skips the sync and
2045
+ // starts the checkout as-is.
2046
+ var autoRebuildState = react.useState(true);
1775
2047
  // The model download state. The Download button runs the hf CLI
1776
2048
  // download through the /local-infer/download route, and the note
1777
2049
  // reports the answer the route returned.
@@ -1815,11 +2087,39 @@ window.__ModuleLoader__.load({
1815
2087
  // The log-file location the card's log-location field edits. Seeded
1816
2088
  // from the summary's stored template on load; a Save persists it.
1817
2089
  var logPathState = react.useState('');
2090
+ // The Logs section's textarea height in px, resized by the diagonal
2091
+ // chevron drag handle in the section's bottom-right corner.
2092
+ var logsHeightState = react.useState(96);
2093
+ // The Logs section's auto-scroll mode. 'fresh' is the pane just
2094
+ // opened: the first content render scrolls to the bottom and arms
2095
+ // 'scrolling'. 'scrolling' pins the view to the bottom on content
2096
+ // updates. 'manual' means the user scrolled up, so content updates
2097
+ // leave the view where the user put it. A scroll to the very bottom
2098
+ // re-arms 'scrolling'.
2099
+ var logsScrollModeState = react.useState('fresh');
2100
+ // The Logs textarea element, so the auto-scroll effect and the scroll
2101
+ // handler read the live scroll metrics.
2102
+ var logsTextareaRef = react.useRef(null);
2103
+ // The in-flight chevron drag: the pointer's starting Y and the height
2104
+ // it started from, so a move computes the new height from a stable
2105
+ // base instead of a stale render closure.
2106
+ var logsDragRef = react.useRef(null);
1818
2107
  // The per-entry launch state: a map from provider id to the entry's
1819
- // lifecycle ('launching', 'running', 'failed') and the start time a
1820
- // launch began, so a card entry can show its spinner, Stop, or red
2108
+ // launch-state-machine state (the rebuild phases 'prereqs', 'download',
2109
+ // 'build', 'launch', plus 'running' and 'failed') and the start time a
2110
+ // launch began, so a card entry can show its phase label, Stop, or red
1821
2111
  // Logs control. Absent entries read as idle (a plain Launch button).
1822
2112
  var entryLaunchState = react.useState({});
2113
+ // The provider statuses the summary last painted, mirrored into a ref
2114
+ // so the stop fast-poll reads the value the last fetch settled, not a
2115
+ // stale closure.
2116
+ var providerStatusRef = react.useRef({});
2117
+ // The latest fetchSummary, mirrored into a ref so the stop fast-poll
2118
+ // re-fetches through the current callback, not a stale one.
2119
+ var fetchSummaryRef = react.useRef(null);
2120
+ // The stop fast-poll and deadline timers (per-entry), plus the
2121
+ // draft-based stop's poll timer, so an unmount clears them all.
2122
+ var stopTimersRef = react.useRef({ poll: null, deadline: null, draftPoll: null });
1823
2123
  var data = dataState[0];
1824
2124
  var draft = draftState[0];
1825
2125
  var note = noteState[0];
@@ -1834,8 +2134,11 @@ window.__ModuleLoader__.load({
1834
2134
  var agentDefault = agentDefaultState[0];
1835
2135
  var launching = launchingState[0];
1836
2136
  var stopping = stoppingState[0];
2137
+ var getting = gettingState[0];
2138
+ var getLogs = getLogsState[0];
1837
2139
  var draftRunning = draftRunningState[0];
1838
2140
  var lifecycleNote = lifecycleNoteState[0];
2141
+ var autoRebuild = autoRebuildState[0];
1839
2142
  var downloading = downloadingState[0];
1840
2143
  var downloadNote = downloadNoteState[0];
1841
2144
  var templateDownloading = templateDownloadingState[0];
@@ -1847,7 +2150,36 @@ window.__ModuleLoader__.load({
1847
2150
  var providerFormMode = providerFormModeState[0];
1848
2151
  var cachedDraft = cachedDraftState[0];
1849
2152
  var logPath = logPathState[0];
2153
+ var logsHeight = logsHeightState[0];
2154
+ var logsScrollMode = logsScrollModeState[0];
2155
+ // The Logs section's displayed text: the streamed Get buffer while a
2156
+ // run is in flight, else the persisted last-run logs the summary
2157
+ // carries. One value the auto-scroll effect and the textarea share.
2158
+ var displayedLogs =
2159
+ getLogs !== null ? getLogs : data !== null && data.lastLogs ? data.lastLogs : '';
1850
2160
  var entryLaunch = entryLaunchState[0];
2161
+ // The title-bar Launch/Stop control mirrors the edited provider's
2162
+ // per-entry launch state, so the entry's Launch button and the form's
2163
+ // control read the same lifecycle (a phase throbber, a Stop when
2164
+ // healthy, a red Logs on a failed launch).
2165
+ var editedProvider =
2166
+ editingProviderId !== null
2167
+ ? (data.providers || []).find((provider) => provider.id === editingProviderId)
2168
+ : undefined;
2169
+ var mirroredStatus =
2170
+ editedProvider !== undefined ? (entryLaunch[editedProvider.id] || {}).status : undefined;
2171
+ var mirroredPhaseLabel =
2172
+ {
2173
+ prereqs: t.entryPrereqs,
2174
+ download: t.entryDownloading,
2175
+ build: t.entryBuilding,
2176
+ launch: t.entryLaunching,
2177
+ // The engine model fetch (the Strata download): the same throbber
2178
+ // label the download phase shows, so the title bar mirrors the
2179
+ // entry's Launch button while the engine fetches its files.
2180
+ downloading: t.entryDownloading,
2181
+ stopping: t.stopping,
2182
+ }[mirroredStatus];
1851
2183
  // The no-entries case forces the form into new-provider mode on
1852
2184
  // render: with nothing stored, the form is where the first provider
1853
2185
  // is entered, and the "New provider" control reads as plain text.
@@ -1856,6 +2188,77 @@ window.__ModuleLoader__.load({
1856
2188
  ? 'new'
1857
2189
  : providerFormMode;
1858
2190
  var timerRef = react.useRef(null);
2191
+ // The latest committed draft, mirrored into a ref so the debounced
2192
+ // re-price reads the value the user last typed, not a stale closure.
2193
+ // The effect runs after each render, so the ref settles before the
2194
+ // 300ms timer fires.
2195
+ var draftRef = react.useRef(null);
2196
+ react.useEffect(() => {
2197
+ draftRef.current = draft;
2198
+ }, [draft]);
2199
+
2200
+ // The Logs auto-scroll: while the mode is 'fresh' (the pane just
2201
+ // opened) or 'scrolling', a content update pins the view to the
2202
+ // bottom. The first render scrolls and arms 'scrolling'; a manual
2203
+ // scroll up parks the mode at 'manual', so later updates leave the
2204
+ // view where the user put it.
2205
+ react.useEffect(() => {
2206
+ var el = logsTextareaRef.current;
2207
+ if (el === null) {
2208
+ return;
2209
+ }
2210
+ if (logsScrollMode === 'fresh' || logsScrollMode === 'scrolling') {
2211
+ el.scrollTop = el.scrollHeight;
2212
+ if (logsScrollMode === 'fresh') {
2213
+ logsScrollModeState[1]('scrolling');
2214
+ }
2215
+ }
2216
+ }, [displayedLogs, logsScrollMode]);
2217
+
2218
+ // The Logs scroll handler: a scroll to the very bottom re-arms
2219
+ // auto-scroll ('scrolling'); any other position parks the mode at
2220
+ // 'manual', so content updates stop pinning the view. The 4px
2221
+ // tolerance absorbs sub-pixel rounding at the end of the range.
2222
+ var onLogsScroll = react.useCallback(() => {
2223
+ var el = logsTextareaRef.current;
2224
+ if (el === null) {
2225
+ return;
2226
+ }
2227
+ var atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 4;
2228
+ logsScrollModeState[1](atBottom ? 'scrolling' : 'manual');
2229
+ }, []);
2230
+
2231
+ // The Logs resize handle: a pointerdown on the chevron starts a
2232
+ // vertical drag. A move computes the new height from the stable
2233
+ // starting base (drag up shrinks the section to the default height,
2234
+ // drag down expands it), clamped to a readable range. The window
2235
+ // listeners read the drag base from the ref, so a re-render mid-drag
2236
+ // never resets it.
2237
+ var onLogsResizeStart = react.useCallback(
2238
+ (event) => {
2239
+ event.preventDefault();
2240
+ logsDragRef.current = { startClientY: event.clientY, startHeight: logsHeight };
2241
+ var onMove = (moveEvent) => {
2242
+ var drag = logsDragRef.current;
2243
+ if (drag === null) {
2244
+ return;
2245
+ }
2246
+ moveEvent.preventDefault();
2247
+ // Drag down (clientY grows) expands, drag up shrinks to the
2248
+ // default height, so the delta is the pointer's downward travel.
2249
+ var delta = moveEvent.clientY - drag.startClientY;
2250
+ logsHeightState[1](Math.max(96, Math.min(480, drag.startHeight + delta)));
2251
+ };
2252
+ var onUp = () => {
2253
+ logsDragRef.current = null;
2254
+ window.removeEventListener('pointermove', onMove);
2255
+ window.removeEventListener('pointerup', onUp);
2256
+ };
2257
+ window.addEventListener('pointermove', onMove);
2258
+ window.addEventListener('pointerup', onUp);
2259
+ },
2260
+ [logsHeight],
2261
+ );
1859
2262
 
1860
2263
  var fetchSummary = react.useCallback(
1861
2264
  (engine, config) => {
@@ -1874,6 +2277,17 @@ window.__ModuleLoader__.load({
1874
2277
  )
1875
2278
  .then((next) => {
1876
2279
  dataState[1](next);
2280
+ // Mirror the provider statuses the summary painted, so the
2281
+ // stop fast-poll reads the latest dot values.
2282
+ var statuses = {};
2283
+ (next.providers || []).forEach(function (provider) {
2284
+ statuses[provider.id] = provider.status;
2285
+ });
2286
+ providerStatusRef.current = statuses;
2287
+ // The streamed Get buffer yields to the persisted last-run
2288
+ // logs the summary now carries, so the Logs section keeps
2289
+ // the run's tail without a second copy.
2290
+ getLogsState[1](null);
1877
2291
  draftState[1]((current) => {
1878
2292
  // The summary's platformConfigs is the host-persisted
1879
2293
  // platform state, while the draft's platform entries are
@@ -1914,6 +2328,31 @@ window.__ModuleLoader__.load({
1914
2328
  [t],
1915
2329
  );
1916
2330
 
2331
+ // Mirror the latest fetchSummary into a ref, so the stop fast-poll
2332
+ // re-fetches through the current callback (its deps change with the
2333
+ // locale), not a stale closure.
2334
+ react.useEffect(() => {
2335
+ fetchSummaryRef.current = fetchSummary;
2336
+ }, [fetchSummary]);
2337
+
2338
+ // Clear the stop fast-poll and deadline timers on unmount, so a
2339
+ // settled or abandoned stop never keeps re-fetching the summary.
2340
+ react.useEffect(
2341
+ () => () => {
2342
+ var timers = stopTimersRef.current;
2343
+ if (timers.poll !== null) {
2344
+ clearTimeout(timers.poll);
2345
+ }
2346
+ if (timers.deadline !== null) {
2347
+ clearTimeout(timers.deadline);
2348
+ }
2349
+ if (timers.draftPoll !== null) {
2350
+ clearInterval(timers.draftPoll);
2351
+ }
2352
+ },
2353
+ [],
2354
+ );
2355
+
1917
2356
  // The deps panel reads its own route, because it re-probes the host
1918
2357
  // on every fetch (the "Get" gate must read the live checkDeps
1919
2358
  // answer), while the summary re-prices the meter. Both ride the same
@@ -1945,6 +2384,25 @@ window.__ModuleLoader__.load({
1945
2384
  [],
1946
2385
  );
1947
2386
 
2387
+ // scheduleReprice resets the re-price timeout on every field change
2388
+ // and fetches the summary (the VRAM Est. meter) and the deps panel
2389
+ // only after typing pauses, so a fast keystroke stream is one round
2390
+ // trip, not one per letter. The ref reads the latest committed draft,
2391
+ // so the fetch prices the value the user last typed.
2392
+ var scheduleReprice = react.useCallback(() => {
2393
+ if (timerRef.current) {
2394
+ clearTimeout(timerRef.current);
2395
+ }
2396
+ timerRef.current = setTimeout(() => {
2397
+ var latest = draftRef.current;
2398
+ if (latest === null || latest === undefined) {
2399
+ return;
2400
+ }
2401
+ fetchSummary(latest.engine, latest.config);
2402
+ fetchDeps(latest.engine, latest.config);
2403
+ }, 300);
2404
+ }, [fetchSummary, fetchDeps]);
2405
+
1948
2406
  var load = react.useCallback(() => {
1949
2407
  fetchSummary(null, null);
1950
2408
  fetchDeps(null, null);
@@ -1956,9 +2414,11 @@ window.__ModuleLoader__.load({
1956
2414
  }
1957
2415
  }, [data, draft, load]);
1958
2416
 
1959
- // A field edit updates the draft config and re-prices the meter and
1960
- // form gating. Debounced so a slider drag is one round trip, not one
1961
- // per tick.
2417
+ // A field edit updates the draft config and schedules a debounced
2418
+ // re-price of the meter and form gating. The draft update is pure;
2419
+ // the re-price (the VRAM Est. visualization) runs only after typing
2420
+ // pauses, so a fast keystroke stream is one round trip, not one per
2421
+ // letter.
1962
2422
  var onValue = react.useCallback(
1963
2423
  (key, value) => {
1964
2424
  noteState[1]('');
@@ -1968,22 +2428,15 @@ window.__ModuleLoader__.load({
1968
2428
  draftState[1]((current) => {
1969
2429
  var config = Object.assign({}, current.config);
1970
2430
  config[key] = value;
1971
- var next = {
2431
+ return {
1972
2432
  engine: current.engine,
1973
2433
  config: config,
1974
2434
  platform: current.platform || {},
1975
2435
  };
1976
- if (timerRef.current) {
1977
- clearTimeout(timerRef.current);
1978
- }
1979
- timerRef.current = setTimeout(() => {
1980
- fetchSummary(next.engine, next.config);
1981
- fetchDeps(next.engine, next.config);
1982
- }, 300);
1983
- return next;
1984
2436
  });
2437
+ scheduleReprice();
1985
2438
  },
1986
- [fetchSummary, fetchDeps],
2439
+ [scheduleReprice],
1987
2440
  );
1988
2441
 
1989
2442
  // The Model Directory field edit. A valid directory (non-blank, no
@@ -2023,6 +2476,9 @@ window.__ModuleLoader__.load({
2023
2476
  }
2024
2477
  downloadingState[1](true);
2025
2478
  downloadNoteState[1]('');
2479
+ // The click writes the "Downloading..." separator, so the run reads
2480
+ // as a fresh Logs section that the download appends to.
2481
+ startProcessLog(t.downloading);
2026
2482
  fetch('/local-infer/download', {
2027
2483
  method: 'POST',
2028
2484
  headers: { 'content-type': 'application/json' },
@@ -2054,7 +2510,7 @@ window.__ModuleLoader__.load({
2054
2510
  downloadingState[1](false);
2055
2511
  });
2056
2512
  },
2057
- [draft, downloading, t, fetchSummary],
2513
+ [draft, downloading, t, fetchSummary, startProcessLog],
2058
2514
  );
2059
2515
 
2060
2516
  // The chat-template download: pulls the selected template (the
@@ -2141,7 +2597,9 @@ window.__ModuleLoader__.load({
2141
2597
  // The help toggle: flip the local state and persist the preference
2142
2598
  // through the save route, so it survives a reload. The save response
2143
2599
  // carries the fresh summary, so the data and the draft resync from
2144
- // the same round trip, the way the allow-run toggle does.
2600
+ // the same round trip, the way the allow-run toggle does. The
2601
+ // settings-only flag keeps the route from upserting a provider entry,
2602
+ // so a toggle never mints a duplicate at the top of the list.
2145
2603
  var onToggleHelp = react.useCallback(
2146
2604
  (next) => {
2147
2605
  if (draft === null) {
@@ -2157,6 +2615,7 @@ window.__ModuleLoader__.load({
2157
2615
  allowRunDeps: deps !== null && deps.runAllowed === true,
2158
2616
  platform: draft.platform || {},
2159
2617
  showHelp: next,
2618
+ settingsOnly: true,
2160
2619
  }),
2161
2620
  })
2162
2621
  .then((response) =>
@@ -2265,18 +2724,11 @@ window.__ModuleLoader__.load({
2265
2724
  draftState[1]((_current) => {
2266
2725
  // The platform config is per-engine, so an engine switch keeps
2267
2726
  // the map and only resets the per-engine parameter config.
2268
- var next = { engine: engine, config: {}, platform: _current.platform || {} };
2269
- if (timerRef.current) {
2270
- clearTimeout(timerRef.current);
2271
- }
2272
- timerRef.current = setTimeout(() => {
2273
- fetchSummary(next.engine, next.config);
2274
- fetchDeps(next.engine, next.config);
2275
- }, 300);
2276
- return next;
2727
+ return { engine: engine, config: {}, platform: _current.platform || {} };
2277
2728
  });
2729
+ scheduleReprice();
2278
2730
  },
2279
- [fetchSummary, fetchDeps],
2731
+ [scheduleReprice],
2280
2732
  );
2281
2733
 
2282
2734
  var save = react.useCallback(() => {
@@ -2492,6 +2944,9 @@ window.__ModuleLoader__.load({
2492
2944
  }
2493
2945
  }, [deps, t]);
2494
2946
 
2947
+ // The allow-run toggle persists the trust gate through the save route.
2948
+ // The settings-only flag keeps the route from upserting a provider
2949
+ // entry, so a toggle never mints a duplicate at the top of the list.
2495
2950
  var onAllowRun = react.useCallback(
2496
2951
  (checked) => {
2497
2952
  if (draft === null) {
@@ -2506,6 +2961,7 @@ window.__ModuleLoader__.load({
2506
2961
  allowRunDeps: checked,
2507
2962
  platform: draft.platform || {},
2508
2963
  showHelp: help,
2964
+ settingsOnly: true,
2509
2965
  }),
2510
2966
  })
2511
2967
  .then((response) =>
@@ -2571,15 +3027,97 @@ window.__ModuleLoader__.load({
2571
3027
  });
2572
3028
  }, [deps, draft, installing, t]);
2573
3029
 
3030
+ // The Logs-section separator a provider process button writes when
3031
+ // clicked: it appends a "--- {{label}}" line to the live Logs buffer
3032
+ // (seeding from the persisted last-run logs when no run is in flight),
3033
+ // so the new process reads as a fresh section that will append its
3034
+ // commands and output to the buffer and the log file. The host route
3035
+ // persists the same separator, so it survives a reload.
3036
+ var startProcessLog = react.useCallback(
3037
+ (label) => {
3038
+ getLogsState[1]((current) => {
3039
+ var base =
3040
+ current !== null ? current : (data !== null && data.lastLogs ? data.lastLogs : '');
3041
+ return base + (base === '' ? '' : '\n') + '--- ' + label + '\n';
3042
+ });
3043
+ },
3044
+ [data],
3045
+ );
3046
+
2574
3047
  var onGet = react.useCallback(() => {
2575
- if (deps === null || draft === null) {
3048
+ if (deps === null || draft === null || getting) {
2576
3049
  return;
2577
3050
  }
2578
- // "Get" is the gate: it re-checks the live dep state, so the button
2579
- // opens only after a re-check passes.
2580
- fetchDeps(draft.engine, draft.config);
2581
- depsNoteState[1](t.depsSatisfied);
2582
- }, [deps, draft, t, fetchDeps]);
3051
+ // "Get" runs the git sync, the dependency plan, and the build on
3052
+ // the host through /local-infer/get. The route streams its output;
3053
+ // each chunk appends to the Logs section and re-arms the idle
3054
+ // timer, and Launch stays disabled until the run finishes, fails,
3055
+ // or falls silent for GET_IDLE_MS. The click writes the "Getting..."
3056
+ // separator, so the run reads as a fresh Logs section.
3057
+ gettingState[1](true);
3058
+ depsNoteState[1]('');
3059
+ startProcessLog(t.getting);
3060
+ var append = (text) => {
3061
+ getLogsState[1]((current) => (current === null ? '' : current) + text);
3062
+ };
3063
+ var armIdle = () => {
3064
+ if (getIdleTimerRef.current) {
3065
+ clearTimeout(getIdleTimerRef.current);
3066
+ }
3067
+ // Two minutes without a line reads as a stalled build: release
3068
+ // Launch while the run keeps streaming in the background.
3069
+ getIdleTimerRef.current = setTimeout(() => {
3070
+ gettingState[1](false);
3071
+ }, GET_IDLE_MS);
3072
+ };
3073
+ armIdle();
3074
+ var platform = (draft.platform || {})[draft.engine];
3075
+ fetch('/local-infer/get', {
3076
+ method: 'POST',
3077
+ headers: { 'content-type': 'application/json' },
3078
+ body: JSON.stringify({
3079
+ engine: draft.engine,
3080
+ config: draft.config,
3081
+ platform: platform,
3082
+ }),
3083
+ })
3084
+ .then((response) => {
3085
+ if (!response.ok) {
3086
+ return response.text().then((text) => {
3087
+ throw new Error(text || 'HTTP ' + response.status);
3088
+ });
3089
+ }
3090
+ if (!response.body || typeof response.body.getReader !== 'function') {
3091
+ // A host whose response cannot stream: read the whole body.
3092
+ return response.text().then((text) => append(text));
3093
+ }
3094
+ var reader = response.body.getReader();
3095
+ var decoder = new TextDecoder();
3096
+ var pump = () =>
3097
+ reader.read().then(({ done, value }) => {
3098
+ if (done) {
3099
+ return undefined;
3100
+ }
3101
+ append(decoder.decode(value, { stream: true }));
3102
+ armIdle();
3103
+ return pump();
3104
+ });
3105
+ return pump();
3106
+ })
3107
+ .catch((error) => {
3108
+ append('\n' + t.getFailed + ': ' + (error && error.message ? error.message : '') + '\n');
3109
+ })
3110
+ .then(() => {
3111
+ if (getIdleTimerRef.current) {
3112
+ clearTimeout(getIdleTimerRef.current);
3113
+ getIdleTimerRef.current = null;
3114
+ }
3115
+ gettingState[1](false);
3116
+ // The route persisted the run as the last logs; the summary
3117
+ // re-read hands the Logs section back to the stored value.
3118
+ fetchSummary(draft.engine, draft.config);
3119
+ });
3120
+ }, [deps, draft, getting, data, t, fetchSummary, startProcessLog]);
2583
3121
 
2584
3122
  var onRegister = react.useCallback(() => {
2585
3123
  if (draft === null || registering) {
@@ -2678,6 +3216,10 @@ window.__ModuleLoader__.load({
2678
3216
  engine: draft.engine,
2679
3217
  config: draft.config,
2680
3218
  platform: platform,
3219
+ // The Auto Rebuild checkbox: false skips the git sync (fetch,
3220
+ // rebase, rebuild) and launches the checkout as-is. The stop
3221
+ // route ignores the key.
3222
+ autoRebuild: autoRebuild,
2681
3223
  };
2682
3224
  // The provider entry the draft edits: the route persists the launch
2683
3225
  // record (and the stop's clear) on that entry, so a restart re-links
@@ -2694,14 +3236,56 @@ window.__ModuleLoader__.load({
2694
3236
  }
2695
3237
  launchingState[1](true);
2696
3238
  lifecycleNoteState[1]('');
3239
+ // The click writes the "Launching..." separator, so the run reads as
3240
+ // a fresh Logs section that the launch appends to.
3241
+ startProcessLog(t.launching);
2697
3242
  fetch('/local-infer/launch', {
2698
3243
  method: 'POST',
2699
3244
  headers: { 'content-type': 'application/json' },
2700
3245
  body: launchBody(),
2701
3246
  })
2702
- .then((response) => response.json().then((body) => ({ ok: response.ok, body: body })))
2703
- .then(({ ok, body }) => {
2704
- if (ok) {
3247
+ // The launch route streams NDJSON (a step event per phase, then a
3248
+ // final result event). The main form's control does not show the
3249
+ // phase labels, so read the stream and keep only the result event.
3250
+ .then(function (response) {
3251
+ if (response.body === null || typeof response.body.getReader !== 'function') {
3252
+ return response.json().then(function (body) {
3253
+ return { ok: response.ok, result: body };
3254
+ });
3255
+ }
3256
+ var reader = response.body.getReader();
3257
+ var decoder = new TextDecoder();
3258
+ var buffer = '';
3259
+ var resultEvent = null;
3260
+ function pump() {
3261
+ return reader.read().then(function (chunk) {
3262
+ if (chunk.done) {
3263
+ return { ok: response.ok, result: resultEvent };
3264
+ }
3265
+ buffer += decoder.decode(chunk.value, { stream: true });
3266
+ var lines = buffer.split('\n');
3267
+ buffer = lines.pop();
3268
+ for (var i = 0; i < lines.length; i++) {
3269
+ if (lines[i] === '') {
3270
+ continue;
3271
+ }
3272
+ var event;
3273
+ try {
3274
+ event = JSON.parse(lines[i]);
3275
+ } catch {
3276
+ continue;
3277
+ }
3278
+ if (event.result !== undefined) {
3279
+ resultEvent = event.result;
3280
+ }
3281
+ }
3282
+ return pump();
3283
+ });
3284
+ }
3285
+ return pump();
3286
+ })
3287
+ .then(({ ok, result: body }) => {
3288
+ if (ok && body !== null) {
2705
3289
  if (body.healthy) {
2706
3290
  lifecycleNoteState[1](
2707
3291
  t.runningOn + ' ' + (body.plan && body.plan.port ? body.plan.port : ''),
@@ -2713,7 +3297,9 @@ window.__ModuleLoader__.load({
2713
3297
  lifecycleNoteState[1](t.notHealthy + (body.detail ? ': ' + body.detail : ''));
2714
3298
  }
2715
3299
  } else {
2716
- lifecycleNoteState[1](t.launchFailed + (body.error ? ': ' + body.error : ''));
3300
+ lifecycleNoteState[1](
3301
+ t.launchFailed + (body && body.detail ? ': ' + body.detail : ''),
3302
+ );
2717
3303
  }
2718
3304
  })
2719
3305
  .catch((error) => {
@@ -2723,8 +3309,13 @@ window.__ModuleLoader__.load({
2723
3309
  })
2724
3310
  .then(() => {
2725
3311
  launchingState[1](false);
3312
+ // The route persisted the run's launch command line and engine
3313
+ // output as the last logs; the summary re-read hands the Logs
3314
+ // section the new run, the same rule the per-entry launch and
3315
+ // the Get apply when their run ends.
3316
+ fetchSummary(draft.engine, draft.config);
2726
3317
  });
2727
- }, [draft, launching, editingProviderId, t]);
3318
+ }, [draft, launching, editingProviderId, autoRebuild, t, fetchSummary, startProcessLog]);
2728
3319
 
2729
3320
  var onStop = react.useCallback(() => {
2730
3321
  if (draft === null || stopping) {
@@ -2732,6 +3323,15 @@ window.__ModuleLoader__.load({
2732
3323
  }
2733
3324
  stoppingState[1](true);
2734
3325
  lifecycleNoteState[1]('');
3326
+ // Re-fetch the summary on a fast cadence for the duration of the
3327
+ // stop action, so the provider's status dot repaints the moment the
3328
+ // engine is down, instead of waiting out the host's 5-second
3329
+ // process-watch cycle.
3330
+ if (stopTimersRef.current.draftPoll === null) {
3331
+ stopTimersRef.current.draftPoll = setInterval(function () {
3332
+ fetchSummaryRef.current(null, null);
3333
+ }, STOP_POLL_MS);
3334
+ }
2735
3335
  fetch('/local-infer/stop', {
2736
3336
  method: 'POST',
2737
3337
  headers: { 'content-type': 'application/json' },
@@ -2755,43 +3355,79 @@ window.__ModuleLoader__.load({
2755
3355
  })
2756
3356
  .then(() => {
2757
3357
  stoppingState[1](false);
3358
+ if (stopTimersRef.current.draftPoll !== null) {
3359
+ clearInterval(stopTimersRef.current.draftPoll);
3360
+ stopTimersRef.current.draftPoll = null;
3361
+ }
2758
3362
  });
2759
3363
  }, [draft, stopping, editingProviderId, t]);
2760
3364
 
3365
+ // Move one entry through the provider state machine, reading the
3366
+ // current entry from the latest map so a late event prices the state it
3367
+ // lands on, not a stale one. The per-entry launch and stop both drive
3368
+ // the same machine through this one helper, so a transition is never
3369
+ // priced twice.
3370
+ var moveEntry = react.useCallback((providerId, event) => {
3371
+ entryLaunchState[1]((current) => {
3372
+ var entry = current[providerId] || { status: 'idle' };
3373
+ var next = launchNextState(entry, event);
3374
+ return Object.assign({}, current, {
3375
+ [providerId]: Object.assign({}, next, { startTime: Date.now() }),
3376
+ });
3377
+ });
3378
+ }, []);
3379
+
2761
3380
  // The per-entry launch: a card entry's own Launch control drives its
2762
- // own engine, not the form's draft. It records a start time and the
2763
- // 'launching' state (the spinner), then reads the launch result's
2764
- // healthy flag: a 200 before the route's health-poll timeout reads as
2765
- // 'running' (the control becomes Stop), a timeout reads as 'failed'
2766
- // (the control becomes a red Logs that opens the entry for editing).
3381
+ // own engine, not the form's draft. It starts the launch state machine
3382
+ // at the prereqs phase, then reads the launch stream (an NDJSON step
3383
+ // event as each phase begins, then a final result event) and drives the
3384
+ // machine: each step event advances the phase label (Prereqs,
3385
+ // Downloading, Building, Launching), a healthy result settles to
3386
+ // running (the control becomes Stop), and a failed result or the
3387
+ // health-poll deadline settles to failed (the control becomes a red
3388
+ // Logs that opens the entry for editing).
2767
3389
  var onLaunchEntry = react.useCallback(
2768
3390
  (provider) => {
2769
- var state = entryLaunch[provider.id];
2770
- if (state !== undefined && state.status === 'launching') {
3391
+ var prior = entryLaunch[provider.id];
3392
+ var status = prior !== undefined ? prior.status : 'idle';
3393
+ // A launch in flight (a rebuild phase) or a stop in flight is
3394
+ // ignored; a running engine is stopped by its own Stop control,
3395
+ // not re-launched.
3396
+ if (status !== 'idle' && status !== 'failed') {
2771
3397
  return;
2772
3398
  }
2773
- entryLaunchState[1]((current) =>
2774
- Object.assign({}, current, {
2775
- [provider.id]: { status: 'launching', startTime: Date.now() },
2776
- }),
2777
- );
2778
- // The route's health-poll can run up to two minutes, and a build
2779
- // step before it can run longer still, so a slow or hung route
2780
- // would leave the control on the spinner forever. A client-side
2781
- // deadline just past the route's cap forces the control to the red
2782
- // Logs state so the entry can be opened and fixed.
3399
+ moveEntry(provider.id, { type: 'start' });
3400
+ // The click writes the "Launching..." separator, so the entry's run
3401
+ // reads as a fresh Logs section that the launch appends to.
3402
+ startProcessLog(t.entryLaunching);
3403
+ var move = function (event) {
3404
+ moveEntry(provider.id, event);
3405
+ };
3406
+ // The health-poll deadline: the route's poll runs up to two minutes,
3407
+ // so a client-side deadline just past that cap forces a hung launch
3408
+ // to the red Logs state. It arms only for the launch phase; the
3409
+ // rebuild phases (prereqs, download, build) run as long as they
3410
+ // need to, so a slow build never reads as a failure.
2783
3411
  var settled = false;
2784
- var deadline = setTimeout(function () {
2785
- if (settled) {
3412
+ var deadline = null;
3413
+ var armDeadline = function () {
3414
+ if (deadline !== null) {
2786
3415
  return;
2787
3416
  }
2788
- settled = true;
2789
- entryLaunchState[1]((current) =>
2790
- Object.assign({}, current, {
2791
- [provider.id]: { status: 'failed', startTime: Date.now() },
2792
- }),
2793
- );
2794
- }, 150000);
3417
+ deadline = setTimeout(function () {
3418
+ if (settled) {
3419
+ return;
3420
+ }
3421
+ settled = true;
3422
+ move({ type: 'failed' });
3423
+ }, LAUNCH_HEALTH_DEADLINE_MS);
3424
+ };
3425
+ var clearDeadline = function () {
3426
+ if (deadline !== null) {
3427
+ clearTimeout(deadline);
3428
+ deadline = null;
3429
+ }
3430
+ };
2795
3431
  fetch('/local-infer/launch', {
2796
3432
  method: 'POST',
2797
3433
  headers: { 'content-type': 'application/json' },
@@ -2804,36 +3440,157 @@ window.__ModuleLoader__.load({
2804
3440
  providerId: provider.id,
2805
3441
  }),
2806
3442
  })
2807
- .then((response) => response.json().then((body) => ({ ok: response.ok, body: body })))
2808
- .then(({ ok, body }) => {
2809
- settled = true;
2810
- clearTimeout(deadline);
2811
- var nextStatus = ok && body.healthy ? 'running' : 'failed';
2812
- entryLaunchState[1]((current) =>
2813
- Object.assign({}, current, {
2814
- [provider.id]: { status: nextStatus, startTime: Date.now() },
2815
- }),
2816
- );
2817
- // The provider status dots price the process probe on a
2818
- // summary fetch, so a launch that turned an engine healthy
2819
- // re-fetches the summary to repaint the dot from red to green.
2820
- fetchSummary(provider.engine, provider.config || {});
3443
+ .then(function (response) {
3444
+ // The launch streams NDJSON: a step event as each phase begins,
3445
+ // then a final result event. Read the stream and drive the
3446
+ // state machine; a response with no body (a host without
3447
+ // streaming) settles straight to failed.
3448
+ if (response.body === null || typeof response.body.getReader !== 'function') {
3449
+ settled = true;
3450
+ clearDeadline();
3451
+ move({ type: 'failed' });
3452
+ return Promise.resolve();
3453
+ }
3454
+ var reader = response.body.getReader();
3455
+ var decoder = new TextDecoder();
3456
+ var buffer = '';
3457
+ function pump() {
3458
+ return reader.read().then(function (result) {
3459
+ if (result.done) {
3460
+ // The stream ended without a result event (a host that
3461
+ // cannot stream, a route that broke mid-stream): settle
3462
+ // to failed so the control does not sit on a phase label.
3463
+ if (!settled) {
3464
+ settled = true;
3465
+ clearDeadline();
3466
+ move({ type: 'failed' });
3467
+ }
3468
+ return;
3469
+ }
3470
+ buffer += decoder.decode(result.value, { stream: true });
3471
+ var lines = buffer.split('\n');
3472
+ buffer = lines.pop();
3473
+ for (var i = 0; i < lines.length; i++) {
3474
+ var line = lines[i];
3475
+ if (line === '') {
3476
+ continue;
3477
+ }
3478
+ var event;
3479
+ try {
3480
+ event = JSON.parse(line);
3481
+ } catch {
3482
+ // A malformed line is not a launch event; skip it.
3483
+ continue;
3484
+ }
3485
+ if (event.step !== undefined) {
3486
+ move({ type: 'step', step: event.step });
3487
+ // The build step writes a "Building..." separator to
3488
+ // the live Logs buffer, so the build reads as a fresh
3489
+ // section the way the launch click writes
3490
+ // "Launching...".
3491
+ if (event.step === 'build') {
3492
+ getLogsState[1]((current) => {
3493
+ var base = current !== null ? current : '';
3494
+ return base + (base === '' ? '' : '\n') + '--- ' + t.entryBuilding + '\n';
3495
+ });
3496
+ }
3497
+ // Arm the deadline only for the launch (health-poll)
3498
+ // step; the rebuild steps run without a timeout.
3499
+ if (event.step === 'launch') {
3500
+ armDeadline();
3501
+ }
3502
+ } else if (event.result !== undefined) {
3503
+ settled = true;
3504
+ clearDeadline();
3505
+ var healthy = event.result.healthy === true;
3506
+ // A launch that is still fetching its model files reads
3507
+ // as downloading (a busy throbber the card re-polls),
3508
+ // not as a failure; only a result that is neither
3509
+ // healthy nor downloading settles to the red Logs.
3510
+ if (healthy) {
3511
+ move({ type: 'healthy' });
3512
+ } else if (event.result.state === 'downloading') {
3513
+ move({ type: 'downloading' });
3514
+ } else {
3515
+ move({ type: 'failed' });
3516
+ }
3517
+ // The provider status dots price the process probe on a
3518
+ // summary fetch, so a launch that turned an engine
3519
+ // healthy re-fetches the summary to repaint the dot
3520
+ // from red to green.
3521
+ fetchSummary(provider.engine, provider.config || {});
3522
+ return;
3523
+ }
3524
+ }
3525
+ return pump();
3526
+ });
3527
+ }
3528
+ return pump();
2821
3529
  })
2822
- .catch(() => {
3530
+ .catch(function () {
2823
3531
  settled = true;
2824
- clearTimeout(deadline);
2825
- entryLaunchState[1]((current) =>
2826
- Object.assign({}, current, {
2827
- [provider.id]: { status: 'failed', startTime: Date.now() },
2828
- }),
2829
- );
3532
+ clearDeadline();
3533
+ move({ type: 'failed' });
2830
3534
  });
2831
3535
  },
2832
- [entryLaunch, fetchSummary],
3536
+ [entryLaunch, fetchSummary, startProcessLog, moveEntry, getLogsState, t],
2833
3537
  );
2834
3538
 
3539
+ // The per-entry stop: a running engine's Stop control moves the
3540
+ // provider state machine to stopping (the control shows the throbber,
3541
+ // disabled, the same display the launch phases show). For the duration
3542
+ // of the stop action the card re-fetches the summary on a fast cadence
3543
+ // so the provider's status dot repaints the moment the engine is down,
3544
+ // instead of waiting out the host's 5-second process-watch cycle. A
3545
+ // provider still running when the 10-second deadline fires settles to
3546
+ // the red Logs state (the same path a failed launch takes); the Logs
3547
+ // control opens the entry for editing and resets the machine to
3548
+ // running, so the control reads as Stop again. The stop clears the
3549
+ // launch record on this entry, so a recycled pid cannot re-link it.
2835
3550
  var onStopEntry = react.useCallback(
2836
3551
  (provider) => {
3552
+ var prior = entryLaunch[provider.id];
3553
+ var priorStatus = prior !== undefined ? prior.status : 'idle';
3554
+ // A failed launch reads as Logs, not Stop, so a stop only starts
3555
+ // from the other states.
3556
+ if (priorStatus === 'failed') {
3557
+ return;
3558
+ }
3559
+ var move = function (event) {
3560
+ moveEntry(provider.id, event);
3561
+ };
3562
+ var clearStopTimers = function () {
3563
+ if (stopTimersRef.current.poll !== null) {
3564
+ clearTimeout(stopTimersRef.current.poll);
3565
+ stopTimersRef.current.poll = null;
3566
+ }
3567
+ if (stopTimersRef.current.deadline !== null) {
3568
+ clearTimeout(stopTimersRef.current.deadline);
3569
+ stopTimersRef.current.deadline = null;
3570
+ }
3571
+ };
3572
+ // A second Stop click re-arms the timers, so clear the in-flight
3573
+ // ones first, or they would stack and double-settle.
3574
+ clearStopTimers();
3575
+ move({ type: 'stop' });
3576
+ var poll = function () {
3577
+ var status = providerStatusRef.current[provider.id];
3578
+ if (status !== undefined && status !== 'running') {
3579
+ // The provider is no longer running: the stop succeeded.
3580
+ move({ type: 'stopped' });
3581
+ clearStopTimers();
3582
+ return;
3583
+ }
3584
+ fetchSummaryRef.current(null, null);
3585
+ stopTimersRef.current.poll = setTimeout(poll, STOP_POLL_MS);
3586
+ };
3587
+ stopTimersRef.current.poll = setTimeout(poll, STOP_POLL_MS);
3588
+ stopTimersRef.current.deadline = setTimeout(function () {
3589
+ // The provider is still running after the deadline: settle to
3590
+ // the red Logs state, the same path a failed launch takes.
3591
+ clearStopTimers();
3592
+ move({ type: 'stopFailed' });
3593
+ }, STOP_HEALTH_DEADLINE_MS);
2837
3594
  fetch('/local-infer/stop', {
2838
3595
  method: 'POST',
2839
3596
  headers: { 'content-type': 'application/json' },
@@ -2846,32 +3603,32 @@ window.__ModuleLoader__.load({
2846
3603
  providerId: provider.id,
2847
3604
  }),
2848
3605
  })
2849
- .then(() => {
2850
- entryLaunchState[1]((current) => {
2851
- var next = Object.assign({}, current);
2852
- delete next[provider.id];
2853
- return next;
2854
- });
2855
- })
2856
3606
  .catch(() => {
2857
- /* a stop that fails leaves the entry in its last state */
3607
+ /* a stop that fails leaves the entry in its last state; the
3608
+ fast-poll and the deadline settle it */
2858
3609
  });
2859
3610
  },
2860
- [],
3611
+ [entryLaunch, moveEntry],
2861
3612
  );
2862
3613
 
2863
3614
  // The per-entry Logs control: opening the logs loads the entry for
2864
- // editing AND clears the failed launch state, so the per-entry control
2865
- // reads as Launch again. The user fixes the config in the form and
2866
- // re-launches from the same entry, instead of being stuck on a
3615
+ // editing AND resets the launch state machine to idle, so the per-entry
3616
+ // control reads as Launch again. The user fixes the config in the form
3617
+ // and re-launches from the same entry, instead of being stuck on a
2867
3618
  // non-actionable Logs control.
2868
3619
  var onLogsEntry = react.useCallback(
2869
3620
  (provider) => {
2870
3621
  onEditProvider(provider);
2871
3622
  entryLaunchState[1]((current) => {
2872
- var next = Object.assign({}, current);
2873
- delete next[provider.id];
2874
- return next;
3623
+ var entry = current[provider.id] || { status: 'idle' };
3624
+ var next = launchNextState(entry, { type: 'reset' });
3625
+ var map = Object.assign({}, current);
3626
+ if (next.status === 'idle') {
3627
+ delete map[provider.id];
3628
+ } else {
3629
+ map[provider.id] = Object.assign({}, next, { startTime: Date.now() });
3630
+ }
3631
+ return map;
2875
3632
  });
2876
3633
  },
2877
3634
  [onEditProvider],
@@ -2956,9 +3713,14 @@ window.__ModuleLoader__.load({
2956
3713
  : null,
2957
3714
  )
2958
3715
  : null,
2959
- // The card header: the section title plus the circular "?" button
2960
- // that shows or hides the per-field descriptions. The preference
2961
- // persists through the save route, so it survives a reload.
3716
+ // The card header: the section title plus the circular refresh and
3717
+ // "?" buttons. The refresh button re-fetches the summary and deps
3718
+ // from the host, so the card re-prices after the harness pauses
3719
+ // the plugin's routes and re-activates them. The refresh glyph is
3720
+ // the primitives seed's IconRefreshOutlineRegular; a host without
3721
+ // the seed keeps the native arrow character. The "?" button shows
3722
+ // or hides the per-field descriptions; the preference persists
3723
+ // through the save route, so it survives a reload.
2962
3724
  react.createElement(
2963
3725
  'div',
2964
3726
  {
@@ -2971,16 +3733,39 @@ window.__ModuleLoader__.load({
2971
3733
  },
2972
3734
  react.createElement('div', { style: { fontWeight: 600, fontSize: 14 } }, t.section),
2973
3735
  react.createElement(
2974
- 'button',
3736
+ 'div',
2975
3737
  {
2976
- type: 'button',
2977
- style: HELP_BUTTON_STYLE(help),
2978
- title: t.helpToggle,
2979
- 'aria-label': t.helpToggle,
2980
- 'aria-pressed': help,
2981
- onClick: () => onToggleHelp(!help),
3738
+ style: {
3739
+ display: 'flex',
3740
+ alignItems: 'center',
3741
+ gap: 8,
3742
+ },
2982
3743
  },
2983
- '?',
3744
+ react.createElement(
3745
+ 'button',
3746
+ {
3747
+ type: 'button',
3748
+ style: HELP_BUTTON_STYLE(false),
3749
+ title: t.refresh,
3750
+ 'aria-label': t.refresh,
3751
+ onClick: () => load(),
3752
+ },
3753
+ primitives && typeof primitives.IconRefreshOutlineRegular === 'function'
3754
+ ? react.createElement(primitives.IconRefreshOutlineRegular, { size: 12 })
3755
+ : '↻',
3756
+ ),
3757
+ react.createElement(
3758
+ 'button',
3759
+ {
3760
+ type: 'button',
3761
+ style: HELP_BUTTON_STYLE(help),
3762
+ title: t.helpToggle,
3763
+ 'aria-label': t.helpToggle,
3764
+ 'aria-pressed': help,
3765
+ onClick: () => onToggleHelp(!help),
3766
+ },
3767
+ '?',
3768
+ ),
2984
3769
  ),
2985
3770
  ),
2986
3771
  // The provider list: the saved provider entries, each a collapsed
@@ -3010,8 +3795,12 @@ window.__ModuleLoader__.load({
3010
3795
  onLogs: onLogsEntry,
3011
3796
  launchLabel: t.entryLaunch,
3012
3797
  stopLabel: t.entryStop,
3798
+ stoppingLabel: t.stopping,
3013
3799
  logsLabel: t.entryLogs,
3014
- launchingLabel: t.launching,
3800
+ launchingLabel: t.entryLaunching,
3801
+ prereqsLabel: t.entryPrereqs,
3802
+ downloadingLabel: t.entryDownloading,
3803
+ buildingLabel: t.entryBuilding,
3015
3804
  })
3016
3805
  : null,
3017
3806
  // The provider form: the Engine/Model config the card prices. It is
@@ -3176,13 +3965,13 @@ window.__ModuleLoader__.load({
3176
3965
  },
3177
3966
  t.hfCliDetected + ' ' + status.downloadCommand,
3178
3967
  ),
3179
- renderButton(
3180
- react,
3181
- primitives,
3182
- 'ghost',
3183
- { onClick: onDownload, disabled: downloading },
3184
- downloading ? t.downloading : t.download,
3185
- ),
3968
+ renderProcessButton(react, primitives, {
3969
+ busy: downloading === true,
3970
+ label: t.download,
3971
+ busyLabel: t.downloading,
3972
+ variant: 'ghost',
3973
+ onClick: onDownload,
3974
+ }),
3186
3975
  );
3187
3976
  }
3188
3977
  return react.createElement(
@@ -3235,18 +4024,15 @@ window.__ModuleLoader__.load({
3235
4024
  (status.hfCli ? t.hfCliDetected : t.noHfCli) + ' ' + status.downloadCommand,
3236
4025
  ),
3237
4026
  status.hfCli
3238
- ? renderButton(
3239
- react,
3240
- primitives,
3241
- 'ghost',
3242
- {
3243
- onClick: function () {
3244
- onDownloadTemplate(status.repo);
3245
- },
3246
- disabled: templateDownloading,
4027
+ ? renderProcessButton(react, primitives, {
4028
+ busy: templateDownloading === true,
4029
+ label: t.templateDownload,
4030
+ busyLabel: t.templateDownloading,
4031
+ variant: 'ghost',
4032
+ onClick: function () {
4033
+ onDownloadTemplate(status.repo);
3247
4034
  },
3248
- templateDownloading ? t.templateDownloading : t.templateDownload,
3249
- )
4035
+ })
3250
4036
  : null,
3251
4037
  ),
3252
4038
  templateDownloadNote !== ''
@@ -3314,6 +4100,94 @@ window.__ModuleLoader__.load({
3314
4100
  ),
3315
4101
  ),
3316
4102
  help ? renderHelpRow(react, t.engineForkHelp) : null,
4103
+ (() => {
4104
+ // The build-method-dependent platform fields: local build mode
4105
+ // shows the Build commands override, docker build mode shows the
4106
+ // Docker tag override. Only one is visible at a time, keyed off
4107
+ // the same buildMode the Build row prices. The placeholder seeds
4108
+ // the engine's own default (the build step the deploy runs, or the
4109
+ // image tag the docker deploy pulls), so an empty field reads as
4110
+ // "use the default."
4111
+ var engineEntry = (data.engines || []).find(
4112
+ (entry) => entry.id === draft.engine,
4113
+ );
4114
+ var buildModes = [];
4115
+ if (engineEntry && engineEntry.supports.local) buildModes.push('local');
4116
+ if (engineEntry && engineEntry.supports.docker) buildModes.push('docker');
4117
+ var buildMode =
4118
+ typeof draft.config.buildMode === 'string'
4119
+ ? draft.config.buildMode
4120
+ : buildModes[0] || 'local';
4121
+ if (buildMode === 'local') {
4122
+ return react.createElement(
4123
+ 'div',
4124
+ null,
4125
+ react.createElement(
4126
+ 'div',
4127
+ { style: ROW_STYLE },
4128
+ react.createElement('label', { style: LABEL_STYLE }, t.buildCommands),
4129
+ react.createElement(
4130
+ 'input',
4131
+ Object.assign(
4132
+ { style: INPUT_STYLE },
4133
+ {
4134
+ type: 'text',
4135
+ value:
4136
+ typeof enginePlatform.buildCommands === 'string'
4137
+ ? enginePlatform.buildCommands
4138
+ : '',
4139
+ placeholder:
4140
+ engineEntry && engineEntry.defaultBuildCommand
4141
+ ? engineEntry.defaultBuildCommand
4142
+ : '',
4143
+ onChange: (event) =>
4144
+ onPlatform(
4145
+ 'buildCommands',
4146
+ event.target.value === '' ? undefined : event.target.value,
4147
+ ),
4148
+ },
4149
+ ),
4150
+ ),
4151
+ ),
4152
+ help ? renderHelpRow(react, t.buildCommandsHelp) : null,
4153
+ );
4154
+ }
4155
+ if (buildMode === 'docker') {
4156
+ return react.createElement(
4157
+ 'div',
4158
+ null,
4159
+ react.createElement(
4160
+ 'div',
4161
+ { style: ROW_STYLE },
4162
+ react.createElement('label', { style: LABEL_STYLE }, t.dockerTag),
4163
+ react.createElement(
4164
+ 'input',
4165
+ Object.assign(
4166
+ { style: INPUT_STYLE },
4167
+ {
4168
+ type: 'text',
4169
+ value:
4170
+ typeof enginePlatform.dockerTag === 'string'
4171
+ ? enginePlatform.dockerTag
4172
+ : '',
4173
+ placeholder:
4174
+ engineEntry && engineEntry.defaultDockerTag
4175
+ ? engineEntry.defaultDockerTag
4176
+ : '',
4177
+ onChange: (event) =>
4178
+ onPlatform(
4179
+ 'dockerTag',
4180
+ event.target.value === '' ? undefined : event.target.value,
4181
+ ),
4182
+ },
4183
+ ),
4184
+ ),
4185
+ ),
4186
+ help ? renderHelpRow(react, t.dockerTagHelp) : null,
4187
+ );
4188
+ }
4189
+ return null;
4190
+ })(),
3317
4191
  );
3318
4192
  })(),
3319
4193
  hasFields
@@ -3322,17 +4196,28 @@ window.__ModuleLoader__.load({
3322
4196
  'div',
3323
4197
  { key: section.group },
3324
4198
  react.createElement('div', { style: SECTION_STYLE }, section.group),
3325
- section.fields.map((field) =>
3326
- renderField(
4199
+ section.fields.map((field) => {
4200
+ // The field value binds to the local draft, not the server
4201
+ // render model, so a typed keystroke shows in the input
4202
+ // immediately. The server model (field.value) resolves the
4203
+ // spec default and only surfaces after the debounced
4204
+ // re-price settles. A key the user has not edited is
4205
+ // absent from the draft, so it falls back to the model.
4206
+ var draftValue = draft.config[field.key];
4207
+ var effectiveField =
4208
+ draftValue !== undefined
4209
+ ? Object.assign({}, field, { value: draftValue })
4210
+ : field;
4211
+ return renderField(
3327
4212
  react,
3328
- field,
4213
+ effectiveField,
3329
4214
  onValue,
3330
4215
  inlineWarnings[field.key],
3331
4216
  primitives,
3332
4217
  help,
3333
4218
  t,
3334
- ),
3335
- ),
4219
+ );
4220
+ }),
3336
4221
  ),
3337
4222
  )
3338
4223
  : react.createElement('div', { style: { fontSize: 12, opacity: 0.7 } }, t.noParameters),
@@ -3408,37 +4293,92 @@ window.__ModuleLoader__.load({
3408
4293
  onAllowRun: onAllowRun,
3409
4294
  onRun: onRun,
3410
4295
  onGet: onGet,
4296
+ getting: getting,
3411
4297
  installing: installing,
3412
4298
  copyNote: copyNote,
3413
4299
  depsNote: depsNote,
3414
4300
  },
3415
4301
  primitives,
3416
4302
  ),
3417
- // The Logs section: the last run's captured output in a fixed-height
3418
- // read-only text area (a launch fills it; the card never edits it),
3419
- // and the log-file location the launch writes to. The location field
3420
- // edits the template the route resolves and writes on the next run.
4303
+ // The Logs section: the last run's captured output in a read-only
4304
+ // text area (a launch fills it; the card never edits it), and the
4305
+ // log-file location the launch writes to. The location field edits
4306
+ // the template the route resolves and writes on the next run. While
4307
+ // a Get is in flight the streamed buffer wins: the git sync,
4308
+ // dependency, and build output appends there as it lands.
4309
+ //
4310
+ // The textarea auto-scrolls to the bottom while the scroll mode is
4311
+ // 'fresh' (the pane just opened) or 'scrolling'; a manual scroll up
4312
+ // parks the mode at 'manual' so updates stop moving the view, and a
4313
+ // scroll back to the bottom re-arms 'scrolling'. The diagonal
4314
+ // chevron in the section's bottom-right corner drags the height
4315
+ // vertically only.
3421
4316
  react.createElement(
3422
4317
  'div',
3423
4318
  { style: { marginTop: 8 } },
3424
4319
  react.createElement('div', { style: LABEL_STYLE }, t.logs),
3425
- react.createElement('textarea', {
3426
- style: {
3427
- width: '100%',
3428
- height: 96,
3429
- resize: 'none',
3430
- fontFamily: 'monospace',
3431
- fontSize: 11,
3432
- padding: 6,
3433
- border: '1px solid rgba(127,127,127,0.25)',
3434
- borderRadius: 6,
3435
- color: 'inherit',
3436
- background: 'transparent',
3437
- },
3438
- value: data.lastLogs || '',
3439
- readOnly: true,
3440
- 'aria-label': t.logs,
3441
- }),
4320
+ react.createElement(
4321
+ 'div',
4322
+ { style: { position: 'relative' } },
4323
+ react.createElement('textarea', {
4324
+ ref: logsTextareaRef,
4325
+ style: {
4326
+ width: '100%',
4327
+ height: logsHeight,
4328
+ boxSizing: 'border-box',
4329
+ resize: 'none',
4330
+ fontFamily: 'monospace',
4331
+ fontSize: 11,
4332
+ padding: 6,
4333
+ border: '1px solid rgba(127,127,127,0.25)',
4334
+ borderRadius: 6,
4335
+ color: 'inherit',
4336
+ background: 'transparent',
4337
+ },
4338
+ value: displayedLogs,
4339
+ readOnly: true,
4340
+ onScroll: onLogsScroll,
4341
+ 'aria-label': t.logs,
4342
+ }),
4343
+ // The diagonal resize chevron: a DSH primitives down chevron
4344
+ // rotated -45 degrees points into the bottom-right corner and
4345
+ // reads as a resize handle. Dragging it vertically resizes the
4346
+ // section; a host without the seed keeps the native corner glyph.
4347
+ react.createElement(
4348
+ 'div',
4349
+ {
4350
+ style: {
4351
+ position: 'absolute',
4352
+ bottom: 0,
4353
+ right: 0,
4354
+ width: 16,
4355
+ height: 16,
4356
+ display: 'flex',
4357
+ alignItems: 'flex-end',
4358
+ justifyContent: 'flex-end',
4359
+ cursor: 'ns-resize',
4360
+ color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))',
4361
+ userSelect: 'none',
4362
+ touchAction: 'none',
4363
+ },
4364
+ onPointerDown: onLogsResizeStart,
4365
+ role: 'button',
4366
+ 'aria-label': t.logs + ' ' + t.resize,
4367
+ },
4368
+ primitives && typeof primitives.IconChevronDownOutlineRegular === 'function'
4369
+ ? react.createElement(
4370
+ 'span',
4371
+ {
4372
+ style: {
4373
+ display: 'inline-flex',
4374
+ transform: 'rotate(-45deg)',
4375
+ },
4376
+ },
4377
+ react.createElement(primitives.IconChevronDownOutlineRegular, { size: 12 }),
4378
+ )
4379
+ : '\u231F',
4380
+ ),
4381
+ ),
3442
4382
  react.createElement(
3443
4383
  'div',
3444
4384
  { style: ROW_STYLE },
@@ -3455,46 +4395,78 @@ window.__ModuleLoader__.load({
3455
4395
  // draft's engine is down, Stop while a healthy launch set draftRunning,
3456
4396
  // and an in-flight spinner (disabled) while a launch or stop is in
3457
4397
  // flight. Launch stays disabled while the deps panel reports a
3458
- // missing host dependency, the same gate the panel's "Get" button
3459
- // reads. The note reports the answer the route returned.
4398
+ // missing host dependency or a Get run is in flight, and re-enables
4399
+ // when the build finishes, errors, or falls silent for GET_IDLE_MS.
4400
+ // The Auto Rebuild checkbox sits after the control: checked, a
4401
+ // launch runs the git sync (fetch, rebase, rebuild) first;
4402
+ // unchecked, the launch starts the checkout as-is. The note reports
4403
+ // the answer the route returned.
3460
4404
  react.createElement(
3461
4405
  'div',
3462
4406
  { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 } },
3463
- launching
3464
- ? react.createElement(
3465
- 'button',
3466
- {
3467
- type: 'button',
3468
- disabled: true,
3469
- style: Object.assign(
3470
- { display: 'inline-flex', alignItems: 'center', gap: 4, opacity: 0.7 },
3471
- GHOST_BUTTON,
3472
- ),
3473
- 'aria-label': t.launching,
3474
- },
3475
- renderThrobber(react, primitives),
3476
- react.createElement('span', null, t.launching),
3477
- )
3478
- : stopping
3479
- ? renderButton(
3480
- react,
3481
- primitives,
3482
- 'ghost',
3483
- { onClick: function () {}, disabled: true },
3484
- t.stopping,
3485
- )
3486
- : draftRunning
3487
- ? renderButton(react, primitives, 'ghost', { onClick: onStop }, t.stop)
3488
- : renderButton(
3489
- react,
3490
- primitives,
3491
- 'primary',
4407
+ mirroredPhaseLabel !== undefined
4408
+ ? renderProcessButton(react, primitives, {
4409
+ busy: true,
4410
+ label: mirroredPhaseLabel,
4411
+ busyLabel: mirroredPhaseLabel,
4412
+ variant: 'ghost',
4413
+ onClick: function () {},
4414
+ })
4415
+ : mirroredStatus === 'running'
4416
+ ? renderButton(react, primitives, 'ghost', {
4417
+ onClick: function () {
4418
+ onStopEntry(editedProvider);
4419
+ },
4420
+ }, t.stop)
4421
+ : mirroredStatus === 'failed'
4422
+ ? react.createElement(
4423
+ 'button',
3492
4424
  {
3493
- onClick: onLaunch,
3494
- disabled: deps !== null && deps.allSatisfied !== true,
4425
+ type: 'button',
4426
+ onClick: function () {
4427
+ onLogsEntry(editedProvider);
4428
+ },
4429
+ style: Object.assign(
4430
+ { color: 'var(--dsw-alias-label-error, #ff3b30)' },
4431
+ GHOST_BUTTON,
4432
+ ),
4433
+ 'aria-label': t.entryLogs,
3495
4434
  },
3496
- t.launch,
3497
- ),
4435
+ t.entryLogs,
4436
+ )
4437
+ : renderProcessButton(react, primitives, {
4438
+ busy: launching || stopping,
4439
+ label: draftRunning ? t.stop : t.launch,
4440
+ busyLabel: launching ? t.launching : t.stopping,
4441
+ variant: draftRunning ? 'ghost' : 'primary',
4442
+ // While a provider is being edited, the control drives the
4443
+ // provider state machine (the same entry the title bar
4444
+ // mirrors), so the throbber, the fast-poll, and the
4445
+ // 10-second Logs deadline apply. A fresh provider (no
4446
+ // entry) keeps the draft-based Launch/Stop.
4447
+ onClick: draftRunning
4448
+ ? editedProvider !== undefined
4449
+ ? function () {
4450
+ onStopEntry(editedProvider);
4451
+ }
4452
+ : onStop
4453
+ : editedProvider !== undefined
4454
+ ? function () {
4455
+ onLaunchEntry(editedProvider);
4456
+ }
4457
+ : onLaunch,
4458
+ disabled:
4459
+ !draftRunning &&
4460
+ (getting || (deps !== null && deps.allSatisfied !== true)),
4461
+ }),
4462
+ // The Auto Rebuild checkbox: checked, a launch runs the git sync
4463
+ // (fetch, rebase, rebuild) before the spawn; unchecked, the
4464
+ // launch starts the checkout as-is.
4465
+ renderCheckbox(react, primitives, {
4466
+ checked: autoRebuild,
4467
+ onChange: (next) => autoRebuildState[1](next),
4468
+ label: t.autoRebuild,
4469
+ }),
3498
4470
  ),
3499
4471
  lifecycleNote
3500
4472
  ? react.createElement('div', { style: { fontSize: 12 } }, lifecycleNote)