opencode-localhost 0.1.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +227 -68
- package/package.json +5 -15
- package/src/server/backend.ts +22 -1
- package/src/server/index.ts +14 -12
- package/src/server/llamacpp/index.ts +160 -1
- package/src/server/llamacpp/models-ini.ts +21 -3
- package/src/shared/types.ts +27 -6
- package/src/tui/backends.ts +14 -0
- package/src/tui/index.tsx +69 -17
- package/src/tui/panel.tsx +228 -148
- package/src/tui/setup.tsx +16 -3
package/src/tui/panel.tsx
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
|
-
import { createMemo, createSignal, createEffect, onCleanup, For, Show } from "solid-js"
|
|
2
|
-
import type { GpuStat, PanelData, ProviderStatus, SystemStat } from "../shared/types.ts"
|
|
1
|
+
import { createMemo, createSignal, createEffect, onCleanup, untrack, For, Show } from "solid-js"
|
|
2
|
+
import type { BackendPanel, GpuStat, LoadEvent, PanelData, ProviderStatus, SystemStat } from "../shared/types.ts"
|
|
3
3
|
import { gpus } from "./hardware/nvidia.ts"
|
|
4
4
|
import { system } from "./hardware/system.ts"
|
|
5
|
-
import
|
|
6
|
-
import { collapseHome } from "../shared/paths.ts"
|
|
5
|
+
import { BACKENDS } from "./backends.ts"
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
|
-
*
|
|
8
|
+
* Three sections: hardware, provider, model.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* Hardware is one fixed block because the GPUs are shared no matter how many
|
|
11
|
+
* engines are installed. PROVIDER is the section that multiplies — one entry
|
|
12
|
+
* per configured engine. MODEL describes what is actually loaded, naming the
|
|
13
|
+
* engine only when more than one holds something.
|
|
14
|
+
*
|
|
15
|
+
* Rows are padded explicitly rather than laid out with flex: in a monospace
|
|
16
|
+
* grid padStart/padEnd is what actually makes the columns line up.
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
19
|
const POLL_MS = 2_000
|
|
20
|
+
/** While weights stream in, 2s makes the bar jump in visible steps. */
|
|
21
|
+
const POLL_LOADING_MS = 500
|
|
17
22
|
const MIB_PER_GIB = 1024
|
|
18
23
|
|
|
19
24
|
type Theme = Record<string, any>
|
|
@@ -62,30 +67,21 @@ function statusWord(status: ProviderStatus) {
|
|
|
62
67
|
}
|
|
63
68
|
}
|
|
64
69
|
|
|
65
|
-
function DeviceRow(props: {
|
|
66
|
-
theme: Theme
|
|
67
|
-
label: string
|
|
68
|
-
stat: GpuStat | SystemStat
|
|
69
|
-
labelWidth: number
|
|
70
|
-
memWidth: number
|
|
71
|
-
pctWidth: number
|
|
72
|
-
}) {
|
|
70
|
+
function DeviceRow(props: { theme: Theme; label: string; stat: GpuStat | SystemStat }) {
|
|
73
71
|
const used = () => props.stat.usedMiB
|
|
74
72
|
const total = () => props.stat.totalMiB
|
|
75
73
|
return (
|
|
76
74
|
<box flexDirection="row">
|
|
77
|
-
<text fg={props.theme.textMuted}>{props.label.padEnd(
|
|
78
|
-
<text fg={memoryColor(props.theme, used(), total())}>
|
|
79
|
-
{`${gib(used())}/${gib(total())}G`.padStart(props.memWidth)}
|
|
80
|
-
</text>
|
|
75
|
+
<text fg={props.theme.textMuted}>{props.label.padEnd(5)}</text>
|
|
76
|
+
<text fg={memoryColor(props.theme, used(), total())}>{`${gib(used())}/${gib(total())}G`.padStart(14)}</text>
|
|
81
77
|
<text fg={props.theme.text}>
|
|
82
|
-
{(props.stat.utilization === undefined ? "—" : `${props.stat.utilization}%`).padStart(
|
|
78
|
+
{(props.stat.utilization === undefined ? "—" : `${props.stat.utilization}%`).padStart(9)}
|
|
83
79
|
</text>
|
|
84
80
|
</box>
|
|
85
81
|
)
|
|
86
82
|
}
|
|
87
83
|
|
|
88
|
-
/** Rows the hardware grid
|
|
84
|
+
/** Rows the hardware grid renders, so the divider can match its height. */
|
|
89
85
|
export function hardwareRows(data: PanelData) {
|
|
90
86
|
return data.gpus.length + (data.memory ? 1 : 0)
|
|
91
87
|
}
|
|
@@ -101,175 +97,259 @@ export function Hardware(props: { theme: Theme; data: PanelData }) {
|
|
|
101
97
|
<Show when={rows().length > 0}>
|
|
102
98
|
<box flexDirection="column">
|
|
103
99
|
<box flexDirection="row">
|
|
104
|
-
<text fg={props.theme.textMuted}>{"HARDWARE".padEnd(
|
|
100
|
+
<text fg={props.theme.textMuted}>{"HARDWARE".padEnd(8)}</text>
|
|
105
101
|
<text fg={props.theme.textMuted}>{"memory".padStart(11)}</text>
|
|
106
|
-
<text fg={props.theme.textMuted}>{"compute".padStart(
|
|
102
|
+
<text fg={props.theme.textMuted}>{"compute".padStart(9)}</text>
|
|
107
103
|
</box>
|
|
108
|
-
<For each={rows()}>
|
|
109
|
-
{(row) => (
|
|
110
|
-
<DeviceRow theme={props.theme} label={row.label} stat={row.stat} labelWidth={6} memWidth={14} pctWidth={10} />
|
|
111
|
-
)}
|
|
112
|
-
</For>
|
|
104
|
+
<For each={rows()}>{(row) => <DeviceRow theme={props.theme} label={row.label} stat={row.stat} />}</For>
|
|
113
105
|
</box>
|
|
114
106
|
</Show>
|
|
115
107
|
)
|
|
116
108
|
}
|
|
117
109
|
|
|
118
|
-
|
|
119
|
-
|
|
110
|
+
function endpointOf(status: ProviderStatus) {
|
|
111
|
+
if (status.state !== "running") return undefined
|
|
112
|
+
return status.endpoint.replace(/^https?:\/\//, "").replace(/\/v1$/, "")
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Two lines per engine: name, then state or endpoint, then the control. */
|
|
116
|
+
function ProviderEntry(props: { theme: Theme; backend: BackendPanel; busy?: boolean; onToggle?: (id: string) => void }) {
|
|
117
|
+
const status = () => props.backend.status
|
|
118
|
+
const actionable = () => status().state === "running" || status().state === "stopped"
|
|
120
119
|
return (
|
|
121
120
|
<box flexDirection="column">
|
|
122
|
-
<
|
|
123
|
-
|
|
124
|
-
</Show>
|
|
125
|
-
<text fg={props.theme.text} wrapMode="none">
|
|
126
|
-
{props.data.backend.name}
|
|
121
|
+
<text fg={props.theme.text} wrapMode="word">
|
|
122
|
+
{props.backend.name}
|
|
127
123
|
</text>
|
|
128
124
|
<box flexDirection="row">
|
|
129
125
|
<text fg={statusColor(props.theme, status().state)}>{`${GLYPH[status().state]} `}</text>
|
|
130
|
-
<text fg={props.theme.text} wrapMode="none">
|
|
131
|
-
{statusWord(status())}
|
|
132
|
-
</text>
|
|
133
|
-
</box>
|
|
134
|
-
<Show when={status().state === "running" && "endpoint" in status()}>
|
|
135
126
|
<text fg={props.theme.textMuted} wrapMode="none">
|
|
136
|
-
{(status()
|
|
127
|
+
{endpointOf(status()) ?? statusWord(status())}
|
|
137
128
|
</text>
|
|
138
|
-
</
|
|
129
|
+
</box>
|
|
139
130
|
<Show when={"message" in status()}>
|
|
140
|
-
<text fg={props.theme.textMuted} wrapMode="
|
|
131
|
+
<text fg={props.theme.textMuted} wrapMode="word">
|
|
141
132
|
{(status() as { message: string }).message}
|
|
142
133
|
</text>
|
|
143
134
|
</Show>
|
|
144
|
-
{
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
135
|
+
<Show when={props.onToggle && actionable()}>
|
|
136
|
+
<text
|
|
137
|
+
fg={props.busy ? props.theme.textMuted : props.theme.primary}
|
|
138
|
+
wrapMode="none"
|
|
139
|
+
onMouseUp={() => !props.busy && props.onToggle?.(props.backend.id)}
|
|
140
|
+
>
|
|
141
|
+
{props.busy ? "…working" : status().state === "running" ? "[stop]" : "[start]"}
|
|
148
142
|
</text>
|
|
149
143
|
</Show>
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
144
|
+
</box>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function Provider(props: {
|
|
149
|
+
theme: Theme
|
|
150
|
+
data: PanelData
|
|
151
|
+
stacked?: boolean
|
|
152
|
+
busy?: string
|
|
153
|
+
onToggle?: (id: string) => void
|
|
154
|
+
}) {
|
|
155
|
+
return (
|
|
156
|
+
<box flexDirection="column">
|
|
157
|
+
<Show when={props.stacked}>
|
|
158
|
+
<text fg={props.theme.textMuted}>PROVIDER</text>
|
|
159
|
+
</Show>
|
|
160
|
+
<Show
|
|
161
|
+
when={props.data.backends.length > 0}
|
|
162
|
+
fallback={
|
|
163
|
+
<text fg={props.theme.textMuted} wrapMode="word">
|
|
164
|
+
nothing configured — /localhost
|
|
165
|
+
</text>
|
|
166
|
+
}
|
|
167
|
+
>
|
|
168
|
+
<For each={props.data.backends}>
|
|
169
|
+
{(backend) => (
|
|
170
|
+
<ProviderEntry
|
|
171
|
+
theme={props.theme}
|
|
172
|
+
backend={backend}
|
|
173
|
+
busy={props.busy === backend.id}
|
|
174
|
+
onToggle={props.onToggle}
|
|
175
|
+
/>
|
|
176
|
+
)}
|
|
177
|
+
</For>
|
|
154
178
|
</Show>
|
|
155
179
|
</box>
|
|
156
180
|
)
|
|
157
181
|
}
|
|
158
182
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
183
|
+
/** A tenth-width bar: enough to read at a glance, cheap in a 25-column column. */
|
|
184
|
+
function bar(fraction: number) {
|
|
185
|
+
const filled = Math.max(0, Math.min(10, Math.round(fraction * 10)))
|
|
186
|
+
return "█".repeat(filled) + "░".repeat(10 - filled)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function detailOf(backend: BackendPanel, throughput?: number) {
|
|
190
|
+
const model = backend.loaded
|
|
191
|
+
if (!model) return []
|
|
192
|
+
// while weights stream in there are no launch flags to report yet, and the
|
|
193
|
+
// thing worth showing is how far along it is
|
|
194
|
+
if (model.loading) {
|
|
195
|
+
// Measured: llama-server reports 0% for ~9s while it allocates, then runs
|
|
196
|
+
// 0->100% in about 3s. A bar pinned at 0% for nine seconds reads as hung,
|
|
197
|
+
// so say what it is actually doing until there is progress to show.
|
|
198
|
+
const pct = model.progress === undefined ? undefined : Math.round(model.progress * 100)
|
|
199
|
+
const line = !pct ? "allocating…" : `${bar(model.progress!)} ${pct}%`
|
|
200
|
+
return [line, model.stage ?? ""].filter(Boolean)
|
|
201
|
+
}
|
|
202
|
+
const args = model.args
|
|
203
|
+
const first: string[] = []
|
|
204
|
+
if (args["ctx-size"]) first.push(`${args["ctx-size"]} ctx`)
|
|
205
|
+
if (args["cache-type-k"]) first.push(`${args["cache-type-k"]} KV`)
|
|
206
|
+
const second: string[] = []
|
|
207
|
+
if (args["gpu-layers"]) second.push(`ngl ${args["gpu-layers"]}`)
|
|
208
|
+
if (args["tensor-split"]) second.push(`split ${args["tensor-split"]}`)
|
|
209
|
+
if (throughput) second.push(`${throughput.toFixed(1)} tok/s`)
|
|
210
|
+
return [first.join(" · "), second.join(" · ")].filter(Boolean)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function Model(props: { theme: Theme; data: PanelData; stacked?: boolean; onChange?: () => void }) {
|
|
214
|
+
const withModel = createMemo(() => props.data.backends.filter((backend) => backend.loaded))
|
|
215
|
+
// the engine name is only worth repeating when more than one holds something
|
|
216
|
+
const grouped = createMemo(() => withModel().length > 1)
|
|
174
217
|
return (
|
|
175
218
|
<box flexDirection="column">
|
|
176
219
|
<Show when={props.stacked}>
|
|
177
220
|
<text fg={props.theme.textMuted}>MODEL</text>
|
|
178
221
|
</Show>
|
|
179
|
-
<
|
|
180
|
-
{
|
|
181
|
-
|
|
182
|
-
<For each={detail()}>
|
|
183
|
-
{(line) => (
|
|
222
|
+
<Show
|
|
223
|
+
when={withModel().length > 0}
|
|
224
|
+
fallback={
|
|
184
225
|
<text fg={props.theme.textMuted} wrapMode="none">
|
|
185
|
-
|
|
226
|
+
no model loaded
|
|
186
227
|
</text>
|
|
187
|
-
|
|
188
|
-
|
|
228
|
+
}
|
|
229
|
+
>
|
|
230
|
+
<For each={withModel()}>
|
|
231
|
+
{(backend) => (
|
|
232
|
+
<box flexDirection="column">
|
|
233
|
+
<Show when={grouped()}>
|
|
234
|
+
<text fg={props.theme.textMuted} wrapMode="none">
|
|
235
|
+
{backend.name}
|
|
236
|
+
</text>
|
|
237
|
+
</Show>
|
|
238
|
+
{/* ids carry a publisher prefix; wrap rather than truncate */}
|
|
239
|
+
<text
|
|
240
|
+
fg={backend.loaded!.loading ? props.theme.warning : props.theme.text}
|
|
241
|
+
wrapMode="word"
|
|
242
|
+
onMouseUp={() => props.onChange?.()}
|
|
243
|
+
>
|
|
244
|
+
{backend.loaded!.loading ? `◐ ${backend.loaded!.id}` : backend.loaded!.id}
|
|
245
|
+
</text>
|
|
246
|
+
<For each={detailOf(backend, props.data.throughput)}>
|
|
247
|
+
{(line) => (
|
|
248
|
+
<text fg={props.theme.textMuted} wrapMode="word">
|
|
249
|
+
{line}
|
|
250
|
+
</text>
|
|
251
|
+
)}
|
|
252
|
+
</For>
|
|
253
|
+
</box>
|
|
254
|
+
)}
|
|
255
|
+
</For>
|
|
256
|
+
</Show>
|
|
257
|
+
<Show when={props.onChange}>
|
|
258
|
+
<text fg={props.theme.primary} wrapMode="none" onMouseUp={() => props.onChange?.()}>
|
|
259
|
+
[change]
|
|
260
|
+
</text>
|
|
261
|
+
</Show>
|
|
189
262
|
</box>
|
|
190
263
|
)
|
|
191
264
|
}
|
|
192
265
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
266
|
+
// One source of truth for every mount. The home strip and the session sidebar
|
|
267
|
+
// are separate components, so a per-component hook meant two polling loops, two
|
|
268
|
+
// SSE subscriptions and two nvidia-smi calls whenever both were alive.
|
|
269
|
+
const [data, setData] = createSignal<PanelData>({ backends: [], gpus: [] })
|
|
270
|
+
// live load progress, keyed by backend id. Polling cannot see this: the REST
|
|
271
|
+
// listing only flips unloaded -> loaded once the model is already up.
|
|
272
|
+
const [live, setLive] = createSignal<Record<string, LoadEvent>>({})
|
|
273
|
+
let mounted = 0
|
|
274
|
+
let stopPolling: (() => void) | undefined
|
|
275
|
+
let stopWatching: (() => void) | undefined
|
|
276
|
+
let registeredCheck: (() => boolean) | undefined
|
|
277
|
+
|
|
278
|
+
async function refresh() {
|
|
279
|
+
const [gpuStats, sysStat] = await Promise.all([gpus().catch(() => []), system().catch(() => undefined)])
|
|
280
|
+
const backends = await Promise.all(
|
|
281
|
+
BACKENDS.map(async (backend) => {
|
|
282
|
+
const status = await backend.status().catch<ProviderStatus>(() => ({ state: "stopped" }))
|
|
283
|
+
const polled = status.state === "running" ? await backend.loaded().catch(() => undefined) : undefined
|
|
284
|
+
const event = live()[backend.id]
|
|
285
|
+
// a load in flight is only visible on the stream, and it names the
|
|
286
|
+
// incoming model before the listing has caught up
|
|
287
|
+
const loaded =
|
|
288
|
+
event?.loading === true
|
|
289
|
+
? { id: event.model || polled?.id || "", args: polled?.args ?? {}, ...event }
|
|
290
|
+
: polled
|
|
291
|
+
return { id: backend.id, name: backend.name, status, loaded }
|
|
292
|
+
}),
|
|
293
|
+
)
|
|
294
|
+
// an engine with no binary or no models directory is not configured;
|
|
295
|
+
// carrying it on screen forever is noise, and /localhost lists them all
|
|
296
|
+
setData({
|
|
297
|
+
backends: backends.filter((backend) => backend.status.state !== "unconfigured"),
|
|
298
|
+
gpus: gpuStats,
|
|
299
|
+
memory: sysStat,
|
|
300
|
+
registered: registeredCheck?.() ?? true,
|
|
199
301
|
})
|
|
302
|
+
}
|
|
200
303
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
304
|
+
function startPolling() {
|
|
305
|
+
let stopped = false
|
|
306
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
307
|
+
const tick = async () => {
|
|
308
|
+
await refresh()
|
|
309
|
+
if (stopped) return
|
|
310
|
+
const loading =
|
|
311
|
+
Object.values(untrack(live)).some((event) => event.loading) ||
|
|
312
|
+
untrack(data).backends.some((backend) => backend.loaded?.loading)
|
|
313
|
+
timer = setTimeout(tick, loading ? POLL_LOADING_MS : POLL_MS)
|
|
211
314
|
}
|
|
315
|
+
void tick()
|
|
316
|
+
return () => {
|
|
317
|
+
stopped = true
|
|
318
|
+
if (timer) clearTimeout(timer)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
212
321
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
322
|
+
function startWatching() {
|
|
323
|
+
const stops = BACKENDS.map((backend) =>
|
|
324
|
+
backend.watch((event) => {
|
|
325
|
+
setLive((current) => ({ ...current, [backend.id]: event }))
|
|
326
|
+
// the stream is the only timely signal; fold it in immediately rather
|
|
327
|
+
// than waiting for the next poll tick
|
|
328
|
+
void refresh()
|
|
329
|
+
}),
|
|
330
|
+
)
|
|
331
|
+
return () => stops.forEach((stop) => stop())
|
|
220
332
|
}
|
|
221
333
|
|
|
222
|
-
/**
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
state: "unconfigured",
|
|
232
|
-
missing: "models-dir",
|
|
233
|
-
message: "models-dir not set",
|
|
234
|
-
hint: `set it in ${collapseHome(Server.FILE)}`,
|
|
334
|
+
/** Shared across mounts: the first mount starts the work, the last stops it. */
|
|
335
|
+
export function usePanelData(isRegistered?: () => boolean) {
|
|
336
|
+
if (isRegistered) registeredCheck = isRegistered
|
|
337
|
+
|
|
338
|
+
createEffect(() => {
|
|
339
|
+
mounted++
|
|
340
|
+
if (mounted === 1) {
|
|
341
|
+
stopPolling = startPolling()
|
|
342
|
+
stopWatching = startWatching()
|
|
235
343
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
344
|
+
onCleanup(() => {
|
|
345
|
+
mounted--
|
|
346
|
+
if (mounted > 0) return
|
|
347
|
+
stopPolling?.()
|
|
348
|
+
stopWatching?.()
|
|
349
|
+
stopPolling = undefined
|
|
350
|
+
stopWatching = undefined
|
|
243
351
|
})
|
|
244
|
-
|
|
245
|
-
const body: any = await res.json()
|
|
246
|
-
const entries: any[] = Array.isArray(body?.data) ? body.data : []
|
|
247
|
-
const active = entries.find((entry) => entry?.status?.args || entry?.status?.status === "loading")
|
|
248
|
-
if (!active) return { state: "running", endpoint: baseURL }
|
|
249
|
-
return {
|
|
250
|
-
state: "running",
|
|
251
|
-
endpoint: baseURL,
|
|
252
|
-
loaded: {
|
|
253
|
-
id: String(active.id ?? ""),
|
|
254
|
-
args: argsOf(active?.status?.args),
|
|
255
|
-
loading: active?.status?.status === "loading",
|
|
256
|
-
},
|
|
257
|
-
}
|
|
258
|
-
} catch {
|
|
259
|
-
return { state: "stopped" }
|
|
260
|
-
}
|
|
261
|
-
}
|
|
352
|
+
})
|
|
262
353
|
|
|
263
|
-
|
|
264
|
-
function argsOf(argv: unknown): Record<string, string> {
|
|
265
|
-
if (!Array.isArray(argv)) return {}
|
|
266
|
-
const out: Record<string, string> = {}
|
|
267
|
-
for (let i = 0; i < argv.length; i++) {
|
|
268
|
-
const token = String(argv[i])
|
|
269
|
-
if (!token.startsWith("--")) continue
|
|
270
|
-
const next = argv[i + 1]
|
|
271
|
-
if (next === undefined || String(next).startsWith("--")) continue
|
|
272
|
-
out[token.slice(2)] = String(next)
|
|
273
|
-
}
|
|
274
|
-
return out
|
|
354
|
+
return data
|
|
275
355
|
}
|
package/src/tui/setup.tsx
CHANGED
|
@@ -2,6 +2,9 @@ import fs from "fs/promises"
|
|
|
2
2
|
import { BACKENDS, allOnPath, expand, supported, type BackendSpec } from "../shared/backends.ts"
|
|
3
3
|
import { collapseHome } from "../shared/paths.ts"
|
|
4
4
|
import * as Server from "../server/llamacpp/server-ini.ts"
|
|
5
|
+
import { BACKENDS as ENGINES } from "./backends.ts"
|
|
6
|
+
|
|
7
|
+
const backend = ENGINES[0]!
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Setup, kept separate from the status strip.
|
|
@@ -119,12 +122,22 @@ async function rows(api: any, reopen: () => void): Promise<Row[]> {
|
|
|
119
122
|
run: () => promptPath(api, "Models directory", dir, "models-dir", reopen),
|
|
120
123
|
})
|
|
121
124
|
|
|
125
|
+
const status = await backend.status().catch(() => undefined)
|
|
126
|
+
const running = status?.state === "running"
|
|
127
|
+
|
|
128
|
+
// the action is on the row itself — selecting it starts or stops, rather
|
|
129
|
+
// than opening a submenu to find the one obvious thing to do
|
|
122
130
|
out.push({
|
|
123
|
-
title: "
|
|
124
|
-
description: `${settings.host}:${settings.port}
|
|
125
|
-
run: () =>
|
|
131
|
+
title: running ? "● Server running [stop]" : "○ Server stopped [start]",
|
|
132
|
+
description: `${settings.host}:${settings.port}`,
|
|
133
|
+
run: async () => {
|
|
134
|
+
api.ui.toast({ message: running ? "stopping…" : "starting…", variant: "info" })
|
|
135
|
+
await (running ? backend.stop() : backend.start()).catch(() => {})
|
|
136
|
+
reopen()
|
|
137
|
+
},
|
|
126
138
|
})
|
|
127
139
|
|
|
140
|
+
|
|
128
141
|
return out
|
|
129
142
|
}
|
|
130
143
|
|