opencode-localhost 0.1.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -2
- 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/README.md
CHANGED
|
@@ -26,14 +26,27 @@ opencode loads server-side and TUI plugins separately, so it goes in two files.
|
|
|
26
26
|
|
|
27
27
|
```jsonc
|
|
28
28
|
// ~/.config/opencode/opencode.jsonc
|
|
29
|
-
{ "plugin": ["opencode-localhost"] }
|
|
29
|
+
{ "plugin": ["/absolute/path/to/opencode-localhost"] }
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
```jsonc
|
|
33
33
|
// ~/.config/opencode/tui.jsonc
|
|
34
|
-
{ "plugin": ["opencode-localhost"] }
|
|
34
|
+
{ "plugin": ["/absolute/path/to/opencode-localhost"] }
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
```sh
|
|
38
|
+
git clone https://github.com/dushyant30suthar/opencode-localhost
|
|
39
|
+
cd opencode-localhost && bun install
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Why a path and not the package name?** On opencode 1.18.5, a TUI plugin
|
|
43
|
+
installed by name never loads. opencode resolves an npm plugin against the
|
|
44
|
+
wrapper `package.json` it generates in `~/.cache/opencode/packages/<name>@latest/`,
|
|
45
|
+
which has no `exports` field — so `exports["./tui"]` is never found and the TUI
|
|
46
|
+
half is skipped silently. The server half survives on a fallback path, which
|
|
47
|
+
makes it look like the provider works but the panel is broken. Referencing the
|
|
48
|
+
package by path avoids that resolution entirely.
|
|
49
|
+
|
|
37
50
|
Start opencode. The panel appears under the prompt and tells you what is still
|
|
38
51
|
missing.
|
|
39
52
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-localhost",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Run local models in opencode. llama.cpp as a provider, with live GPU, VRAM and CPU telemetry in the sidebar.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|
|
@@ -32,26 +32,16 @@
|
|
|
32
32
|
"README.md",
|
|
33
33
|
"LICENSE"
|
|
34
34
|
],
|
|
35
|
-
"peerDependencies": {
|
|
36
|
-
"@opentui/solid": ">=0.4.5",
|
|
37
|
-
"solid-js": ">=1.9.0"
|
|
38
|
-
},
|
|
39
|
-
"peerDependenciesMeta": {
|
|
40
|
-
"@opentui/solid": {
|
|
41
|
-
"optional": true
|
|
42
|
-
},
|
|
43
|
-
"solid-js": {
|
|
44
|
-
"optional": true
|
|
45
|
-
}
|
|
46
|
-
},
|
|
47
35
|
"devDependencies": {
|
|
48
36
|
"@opencode-ai/plugin": "^1.18.4",
|
|
49
|
-
"@opentui/solid": "^0.4.5",
|
|
50
37
|
"@types/node": "^26.1.1",
|
|
51
|
-
"solid-js": "^1.9.0",
|
|
52
38
|
"typescript": "^7.0.2"
|
|
53
39
|
},
|
|
54
40
|
"engines": {
|
|
55
41
|
"node": ">=22"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@opentui/solid": "^0.4.5",
|
|
45
|
+
"solid-js": "^1.9.0"
|
|
56
46
|
}
|
|
57
47
|
}
|
package/src/server/backend.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ProviderStatus } from "../shared/types.ts"
|
|
1
|
+
import type { LoadEvent, LoadedModel, ProviderStatus } from "../shared/types.ts"
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* What every backend implements. llama.cpp today; vLLM and OpenVINO are new
|
|
@@ -22,8 +22,16 @@ export type DiscoveredModel = {
|
|
|
22
22
|
|
|
23
23
|
export interface Backend {
|
|
24
24
|
readonly id: string
|
|
25
|
+
|
|
26
|
+
/** Short, for the panel — everything there is local, so no prefix. */
|
|
25
27
|
readonly name: string
|
|
26
28
|
|
|
29
|
+
/**
|
|
30
|
+
* How it appears in opencode's provider list, where it sits beside cloud
|
|
31
|
+
* providers and several engines should group together.
|
|
32
|
+
*/
|
|
33
|
+
readonly providerName: string
|
|
34
|
+
|
|
27
35
|
/** Never throws. "unconfigured" is a normal answer on a fresh install. */
|
|
28
36
|
status(): Promise<ProviderStatus>
|
|
29
37
|
|
|
@@ -33,6 +41,19 @@ export interface Backend {
|
|
|
33
41
|
/** Idempotent: starts the server only if it is not already answering. */
|
|
34
42
|
start(): Promise<ProviderStatus>
|
|
35
43
|
|
|
44
|
+
/** Stops a server we started. Never touches one we did not spawn. */
|
|
45
|
+
stop(): Promise<boolean>
|
|
46
|
+
|
|
47
|
+
/** What the server currently holds, if anything. Undefined when it is down. */
|
|
48
|
+
loaded(): Promise<LoadedModel | undefined>
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Live load progress. Polling cannot see this: a model streaming into VRAM
|
|
52
|
+
* takes tens of seconds and the REST listing only flips from unloaded to
|
|
53
|
+
* loaded at the end. Returns an unsubscribe.
|
|
54
|
+
*/
|
|
55
|
+
watch(onEvent: (event: LoadEvent) => void): () => void
|
|
56
|
+
|
|
36
57
|
baseURL(): string
|
|
37
58
|
apiKey(): string | undefined
|
|
38
59
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -22,7 +22,7 @@ const sampling = new Map<string, Record<string, number>>()
|
|
|
22
22
|
|
|
23
23
|
function providerEntry(backend: Backend, models: DiscoveredModel[]) {
|
|
24
24
|
return {
|
|
25
|
-
name:
|
|
25
|
+
name: backend.providerName,
|
|
26
26
|
api: backend.baseURL(),
|
|
27
27
|
npm: "@ai-sdk/openai-compatible",
|
|
28
28
|
options: {
|
|
@@ -50,17 +50,12 @@ async function register(input: any) {
|
|
|
50
50
|
// unconfigured or missing binary: contribute nothing. The panel explains why.
|
|
51
51
|
if (!status || status.state === "unconfigured" || status.state === "failed") continue
|
|
52
52
|
|
|
53
|
-
//
|
|
53
|
+
// Configured is enough. The models exist on disk whether or not a server
|
|
54
|
+
// happens to be up, and a picker that stays empty until you find and press
|
|
55
|
+
// [start] is indistinguishable from the plugin not working at all.
|
|
54
56
|
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// server is failing to start. opencode reads its config once per instance
|
|
58
|
-
// and exposes no way to re-read it, so the provider genuinely cannot appear
|
|
59
|
-
// until the next launch; the panel says so rather than leaving it silent.
|
|
60
|
-
if (status.state !== "running") {
|
|
61
|
-
void backend.start().catch(() => {})
|
|
62
|
-
continue
|
|
63
|
-
}
|
|
57
|
+
// Nothing is spawned here: the server comes up on the first request, in
|
|
58
|
+
// chat.params below.
|
|
64
59
|
|
|
65
60
|
const models = await backend.models().catch(() => [])
|
|
66
61
|
if (models.length === 0) continue
|
|
@@ -83,7 +78,14 @@ const server = async () => ({
|
|
|
83
78
|
|
|
84
79
|
"chat.params": async (input: any, output: any) => {
|
|
85
80
|
const providerID = input?.model?.providerID
|
|
86
|
-
|
|
81
|
+
const backend = BACKENDS.find((item) => item.id === providerID)
|
|
82
|
+
if (!backend) return
|
|
83
|
+
|
|
84
|
+
// Bring the engine up if this is the first request against it. Sending a
|
|
85
|
+
// message is a clear enough signal of intent — the objection was to
|
|
86
|
+
// spawning a server at launch, not to starting one you are about to use.
|
|
87
|
+
const status = await backend.status().catch(() => undefined)
|
|
88
|
+
if (status && status.state === "stopped") await backend.start().catch(() => {})
|
|
87
89
|
const values = sampling.get(`${providerID}/${input?.model?.id}`)
|
|
88
90
|
if (!values) return
|
|
89
91
|
// opencode names three of these directly and passes the rest through options
|
|
@@ -2,7 +2,7 @@ import fs from "fs/promises"
|
|
|
2
2
|
import path from "path"
|
|
3
3
|
import { spawn } from "child_process"
|
|
4
4
|
import { stateDir, collapseHome } from "../../shared/paths.ts"
|
|
5
|
-
import type { ProviderStatus } from "../../shared/types.ts"
|
|
5
|
+
import type { LoadEvent, LoadedModel, ProviderStatus } from "../../shared/types.ts"
|
|
6
6
|
import type { Backend, DiscoveredModel } from "../backend.ts"
|
|
7
7
|
import * as Server from "./server-ini.ts"
|
|
8
8
|
import * as Models from "./models-ini.ts"
|
|
@@ -176,12 +176,171 @@ export function create(): Backend {
|
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Stop the server we spawned. Checks /proc before signalling so a recycled
|
|
181
|
+
* pid is never killed, and only kills a process that is actually
|
|
182
|
+
* llama-server — the pidfile outlives crashes and reboots.
|
|
183
|
+
*/
|
|
184
|
+
async function stop(): Promise<boolean> {
|
|
185
|
+
const raw = await fs.readFile(PID_FILE, "utf8").catch(() => "")
|
|
186
|
+
const pid = Number.parseInt(raw.trim(), 10)
|
|
187
|
+
if (!Number.isFinite(pid) || pid <= 0) return false
|
|
188
|
+
const exe = await fs.readlink(`/proc/${pid}/exe`).catch(() => "")
|
|
189
|
+
if (!exe.includes("llama-server")) {
|
|
190
|
+
await fs.rm(PID_FILE, { force: true }).catch(() => {})
|
|
191
|
+
return false
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
process.kill(pid, "SIGTERM")
|
|
195
|
+
} catch {
|
|
196
|
+
return false
|
|
197
|
+
}
|
|
198
|
+
for (let i = 0; i < 40; i++) {
|
|
199
|
+
try {
|
|
200
|
+
process.kill(pid, 0)
|
|
201
|
+
} catch {
|
|
202
|
+
break
|
|
203
|
+
}
|
|
204
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
process.kill(pid, "SIGKILL")
|
|
208
|
+
} catch {
|
|
209
|
+
// already gone
|
|
210
|
+
}
|
|
211
|
+
await fs.rm(PID_FILE, { force: true }).catch(() => {})
|
|
212
|
+
return true
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** llama-server reports its launch flags as a flat argv array. */
|
|
216
|
+
function argsOf(argv: unknown): Record<string, string> {
|
|
217
|
+
if (!Array.isArray(argv)) return {}
|
|
218
|
+
const out: Record<string, string> = {}
|
|
219
|
+
for (let i = 0; i < argv.length; i++) {
|
|
220
|
+
const token = String(argv[i])
|
|
221
|
+
if (!token.startsWith("--")) continue
|
|
222
|
+
const next = argv[i + 1]
|
|
223
|
+
if (next === undefined || String(next).startsWith("--")) continue
|
|
224
|
+
out[token.slice(2)] = String(next)
|
|
225
|
+
}
|
|
226
|
+
return out
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function loaded(): Promise<LoadedModel | undefined> {
|
|
230
|
+
const cfg = await config()
|
|
231
|
+
try {
|
|
232
|
+
const res = await fetch(`${baseURL()}/models`, {
|
|
233
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT),
|
|
234
|
+
headers: cfg.apiKey ? { Authorization: `Bearer ${cfg.apiKey}` } : {},
|
|
235
|
+
})
|
|
236
|
+
if (!res.ok) return undefined
|
|
237
|
+
const body: any = await res.json()
|
|
238
|
+
const entries: any[] = Array.isArray(body?.data) ? body.data : []
|
|
239
|
+
// llama-server reports state as status.value: "loaded" | "unloaded" |
|
|
240
|
+
// "loading". Every preset carries args, so matching on args alone picked
|
|
241
|
+
// whichever model sorted first rather than the one actually loaded.
|
|
242
|
+
const active =
|
|
243
|
+
entries.find((entry) => entry?.status?.value === "loading") ??
|
|
244
|
+
entries.find((entry) => entry?.status?.value === "loaded")
|
|
245
|
+
if (!active) return undefined
|
|
246
|
+
// llama-server reports load progress as {stages, current, value} while
|
|
247
|
+
// weights stream in; value is 0-1 for the stage named by `current`
|
|
248
|
+
return {
|
|
249
|
+
id: String(active.id ?? ""),
|
|
250
|
+
args: argsOf(active?.status?.args),
|
|
251
|
+
loading: active?.status?.value === "loading",
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
return undefined
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Stream model state from /models/sse. The REST listing only flips from
|
|
260
|
+
* unloaded to loaded at the very end, so a thirty-second load looks like a
|
|
261
|
+
* hang unless we read this.
|
|
262
|
+
*
|
|
263
|
+
* Captured from a real load: one "model_status" frame announces the load,
|
|
264
|
+
* then ~20 "status_change" frames carry the progress, ending with
|
|
265
|
+
* status "loaded". The outgoing model gets its own "unloaded" frame.
|
|
266
|
+
*
|
|
267
|
+
* data: {"model":"...","event":"status_change",
|
|
268
|
+
* "data":{"status":"loading",
|
|
269
|
+
* "progress":{"stages":["text_model"],"current":"text_model","value":0.19}}}
|
|
270
|
+
*/
|
|
271
|
+
function watch(onEvent: (event: LoadEvent) => void): () => void {
|
|
272
|
+
const controller = new AbortController()
|
|
273
|
+
let stopped = false
|
|
274
|
+
|
|
275
|
+
const connect = async () => {
|
|
276
|
+
while (!stopped) {
|
|
277
|
+
try {
|
|
278
|
+
const cfg = await config()
|
|
279
|
+
const host = cfg.host === "0.0.0.0" ? "127.0.0.1" : cfg.host
|
|
280
|
+
const res = await fetch(`http://${host}:${cfg.port}/models/sse`, {
|
|
281
|
+
signal: controller.signal,
|
|
282
|
+
headers: cfg.apiKey ? { Authorization: `Bearer ${cfg.apiKey}` } : {},
|
|
283
|
+
})
|
|
284
|
+
if (!res.ok || !res.body) throw new Error("no stream")
|
|
285
|
+
const reader = res.body.getReader()
|
|
286
|
+
const decoder = new TextDecoder()
|
|
287
|
+
let buffer = ""
|
|
288
|
+
while (!stopped) {
|
|
289
|
+
const { done, value } = await reader.read()
|
|
290
|
+
if (done) break
|
|
291
|
+
buffer += decoder.decode(value, { stream: true })
|
|
292
|
+
// frames are separated by a blank line; keep any partial tail
|
|
293
|
+
const frames = buffer.split("\n\n")
|
|
294
|
+
buffer = frames.pop() ?? ""
|
|
295
|
+
for (const frame of frames) {
|
|
296
|
+
const line = frame.split("\n").find((item) => item.startsWith("data:"))
|
|
297
|
+
if (!line) continue
|
|
298
|
+
try {
|
|
299
|
+
const payload = JSON.parse(line.slice(5).trim())
|
|
300
|
+
// progress rides on status_change; model_status only announces
|
|
301
|
+
// the start. Filtering to model_status dropped every update.
|
|
302
|
+
const kind = payload?.event
|
|
303
|
+
if (kind && kind !== "model_status" && kind !== "status_change") continue
|
|
304
|
+
const body = payload?.data ?? {}
|
|
305
|
+
const progress = body?.progress
|
|
306
|
+
onEvent({
|
|
307
|
+
model: String(payload?.model ?? ""),
|
|
308
|
+
loading: body?.status === "loading",
|
|
309
|
+
failed: body?.status === "failed" || body?.status === "error",
|
|
310
|
+
loaded: body?.status === "loaded",
|
|
311
|
+
progress: typeof progress?.value === "number" ? progress.value : undefined,
|
|
312
|
+
stage: typeof progress?.current === "string" ? progress.current : undefined,
|
|
313
|
+
})
|
|
314
|
+
} catch {
|
|
315
|
+
// a malformed frame is not worth tearing the stream down for
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} catch {
|
|
320
|
+
// server down or restarted; wait before reconnecting
|
|
321
|
+
}
|
|
322
|
+
if (stopped) return
|
|
323
|
+
await new Promise((resolve) => setTimeout(resolve, 2_000))
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
void connect()
|
|
328
|
+
return () => {
|
|
329
|
+
stopped = true
|
|
330
|
+
controller.abort()
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
179
334
|
return {
|
|
180
335
|
id: Server.BACKEND,
|
|
181
336
|
name: "llama.cpp",
|
|
337
|
+
providerName: "Localhost-llama.cpp",
|
|
182
338
|
status,
|
|
183
339
|
models,
|
|
184
340
|
start,
|
|
341
|
+
stop,
|
|
342
|
+
loaded,
|
|
343
|
+
watch,
|
|
185
344
|
baseURL,
|
|
186
345
|
apiKey: () => settings?.apiKey || undefined,
|
|
187
346
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "fs/promises"
|
|
2
2
|
import path from "path"
|
|
3
3
|
import * as Ini from "../../shared/ini.ts"
|
|
4
|
-
import { configDir } from "../../shared/paths.ts"
|
|
4
|
+
import { configDir, expandHome } from "../../shared/paths.ts"
|
|
5
5
|
import type { LocalModel } from "./discover.ts"
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -86,12 +86,29 @@ export type SyncResult = {
|
|
|
86
86
|
export async function sync(models: LocalModel[]): Promise<SyncResult> {
|
|
87
87
|
const existing = await fs.readFile(FILE, "utf8").catch(() => "")
|
|
88
88
|
const doc = Ini.parse(existing)
|
|
89
|
+
// llama-server opens these paths literally — it does not expand `~`, so a
|
|
90
|
+
// hand-written `model = ~/...` fails with "failed to open GGUF file". This is
|
|
91
|
+
// the one case where an existing section is rewritten, because leaving it
|
|
92
|
+
// alone means the model silently never loads.
|
|
93
|
+
let rewrote = false
|
|
94
|
+
for (const item of doc.sections) {
|
|
95
|
+
for (const key of ["model", "mmproj"] as const) {
|
|
96
|
+
const raw = Ini.get(item, key)?.trim()
|
|
97
|
+
if (!raw || !raw.startsWith("~")) continue
|
|
98
|
+
Ini.set(item, key, expandHome(raw))
|
|
99
|
+
rewrote = true
|
|
100
|
+
}
|
|
101
|
+
}
|
|
89
102
|
const names = new Set(doc.sections.map((item) => item.name))
|
|
90
103
|
|
|
104
|
+
// Compare expanded paths. Hand-written sections routinely use `~/...` while
|
|
105
|
+
// discovery always produces absolute paths; comparing the raw strings makes
|
|
106
|
+
// an already-tuned model look new, and appends a default 32k section beside
|
|
107
|
+
// it that then wins — silently replacing tuned settings.
|
|
91
108
|
const byFile = new Map<string, string>()
|
|
92
109
|
for (const item of doc.sections) {
|
|
93
110
|
const file = Ini.get(item, "model")?.trim()
|
|
94
|
-
if (file) byFile.set(file, item.name)
|
|
111
|
+
if (file) byFile.set(expandHome(file), item.name)
|
|
95
112
|
}
|
|
96
113
|
|
|
97
114
|
const added: string[] = []
|
|
@@ -110,7 +127,8 @@ export async function sync(models: LocalModel[]): Promise<SyncResult> {
|
|
|
110
127
|
added.push(section({ ...model, id }))
|
|
111
128
|
}
|
|
112
129
|
|
|
113
|
-
const
|
|
130
|
+
const source = rewrote ? Ini.serialize(doc) : existing
|
|
131
|
+
const body = source.trim().length > 0 ? source.replace(/\s+$/, "") : HEADER
|
|
114
132
|
const next = added.length > 0 ? [body, "", added.join("\n\n"), ""].join("\n") : body + "\n"
|
|
115
133
|
|
|
116
134
|
await fs.mkdir(path.dirname(FILE), { recursive: true }).catch(() => {})
|
package/src/shared/types.ts
CHANGED
|
@@ -21,6 +21,19 @@ export type LoadedModel = {
|
|
|
21
21
|
loading?: boolean
|
|
22
22
|
/** 0-1 while weights stream in. */
|
|
23
23
|
progress?: number
|
|
24
|
+
/** Which stage is streaming, e.g. "text_model" or "mmproj". */
|
|
25
|
+
stage?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A model changing state, streamed while it loads. */
|
|
29
|
+
export type LoadEvent = {
|
|
30
|
+
model: string
|
|
31
|
+
loading: boolean
|
|
32
|
+
loaded?: boolean
|
|
33
|
+
failed?: boolean
|
|
34
|
+
/** 0-1 for the stage named below. */
|
|
35
|
+
progress?: number
|
|
36
|
+
stage?: string
|
|
24
37
|
}
|
|
25
38
|
|
|
26
39
|
/**
|
|
@@ -34,18 +47,26 @@ export type ProviderStatus =
|
|
|
34
47
|
| { state: "running"; endpoint: string; loaded?: LoadedModel }
|
|
35
48
|
| { state: "failed"; message: string; hint?: string }
|
|
36
49
|
|
|
37
|
-
|
|
38
|
-
|
|
50
|
+
/** One engine. A machine can have several, each its own opencode provider. */
|
|
51
|
+
export type BackendPanel = {
|
|
52
|
+
id: string
|
|
53
|
+
name: string
|
|
39
54
|
status: ProviderStatus
|
|
55
|
+
loaded?: LoadedModel
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type PanelData = {
|
|
40
59
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
60
|
+
* Only backends the user has actually configured. Listing every engine we
|
|
61
|
+
* know about would put three dead rows on screen forever; /localhost is
|
|
62
|
+
* where the full list with install state lives.
|
|
44
63
|
*/
|
|
64
|
+
backends: BackendPanel[]
|
|
65
|
+
/** Whether opencode has picked any of these up as providers yet. */
|
|
45
66
|
registered?: boolean
|
|
67
|
+
/** Shared: one GPU pool no matter how many engines are installed. */
|
|
46
68
|
gpus: GpuStat[]
|
|
47
69
|
memory?: SystemStat
|
|
48
|
-
cpu?: SystemStat
|
|
49
70
|
/** Tokens per second, while generating. */
|
|
50
71
|
throughput?: number
|
|
51
72
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { create as llamacpp } from "../server/llamacpp/index.ts"
|
|
2
|
+
import type { Backend } from "../server/backend.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The engines this TUI half talks to, shared so the panel, the setup screen
|
|
6
|
+
* and the commands all see the same instances and the same busy state.
|
|
7
|
+
*
|
|
8
|
+
* A machine can have several; each is its own opencode provider.
|
|
9
|
+
*/
|
|
10
|
+
export const BACKENDS: Backend[] = [llamacpp()]
|
|
11
|
+
|
|
12
|
+
export function backendById(id: string): Backend | undefined {
|
|
13
|
+
return BACKENDS.find((backend) => backend.id === id)
|
|
14
|
+
}
|
package/src/tui/index.tsx
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
|
-
import { createEffect, For, Show } from "solid-js"
|
|
1
|
+
import { createEffect, createSignal, For, Show } from "solid-js"
|
|
2
2
|
import { Hardware, Provider, Model, hardwareRows, usePanelData } from "./panel.tsx"
|
|
3
3
|
import { openSetup } from "./setup.tsx"
|
|
4
|
+
import { BACKENDS, backendById } from "./backends.ts"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared so the strip, the sidebar and the command all show the same busy
|
|
8
|
+
* state — start can take twenty seconds and three views disagreeing about
|
|
9
|
+
* whether it is running looks broken.
|
|
10
|
+
*/
|
|
11
|
+
const [busy, setBusy] = createSignal<string | undefined>(undefined)
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* opencode's own model picker, rather than a second one of ours: selecting a
|
|
15
|
+
* model is TUI-local state a plugin cannot write, so a custom list could show
|
|
16
|
+
* models but never switch to one.
|
|
17
|
+
*/
|
|
18
|
+
function openModelPicker(api: any) {
|
|
19
|
+
try {
|
|
20
|
+
api.keymap.dispatchCommand("model.list")
|
|
21
|
+
} catch {
|
|
22
|
+
api.ui.toast({ message: "could not open the model picker", variant: "error" })
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function toggleServer(id: string) {
|
|
27
|
+
if (busy()) return
|
|
28
|
+
const backend = backendById(id)
|
|
29
|
+
if (!backend) return
|
|
30
|
+
setBusy(id)
|
|
31
|
+
try {
|
|
32
|
+
const status = await backend.status()
|
|
33
|
+
await (status.state === "running" ? backend.stop() : backend.start())
|
|
34
|
+
} catch {
|
|
35
|
+
// status reflects whatever actually happened on the next poll
|
|
36
|
+
} finally {
|
|
37
|
+
setBusy(undefined)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
4
40
|
|
|
5
41
|
/**
|
|
6
42
|
* The TUI half.
|
|
@@ -15,13 +51,17 @@ import { openSetup } from "./setup.tsx"
|
|
|
15
51
|
*/
|
|
16
52
|
|
|
17
53
|
/**
|
|
18
|
-
*
|
|
19
|
-
* under its own content
|
|
20
|
-
*
|
|
54
|
+
* Hardware and provider are fixed and cannot shrink — without that the row
|
|
55
|
+
* collapses under its own content and the sections overprint each other.
|
|
56
|
+
*
|
|
57
|
+
* MODEL takes whatever is left rather than a fixed width, and wraps: model ids
|
|
58
|
+
* carry a publisher prefix ("lmstudio-community/Qwen3.6-35B-A3B-GGUF"), which a
|
|
59
|
+
* fixed column truncated at exactly the identifying part. The strip keeps the
|
|
60
|
+
* prompt's 75-column max so it stays centred with everything else on the home
|
|
61
|
+
* screen — full width made it hug the left edge while the prompt stayed centred.
|
|
21
62
|
*/
|
|
22
|
-
const HARDWARE_WIDTH =
|
|
23
|
-
const PROVIDER_WIDTH =
|
|
24
|
-
const MODEL_WIDTH = 23
|
|
63
|
+
const HARDWARE_WIDTH = 28
|
|
64
|
+
const PROVIDER_WIDTH = 17
|
|
25
65
|
|
|
26
66
|
/** One `│` per row, so the rule spans the whole block rather than its first line. */
|
|
27
67
|
function Divider(props: { color: string; rows: number }) {
|
|
@@ -35,7 +75,8 @@ function Divider(props: { color: string; rows: number }) {
|
|
|
35
75
|
|
|
36
76
|
/** opencode exposes the providers it loaded, which is how we detect the gap. */
|
|
37
77
|
function registered(api: any) {
|
|
38
|
-
|
|
78
|
+
const known = new Set(BACKENDS.map((backend) => backend.id))
|
|
79
|
+
return () => (api.state?.provider ?? []).some((item: any) => known.has(item?.id))
|
|
39
80
|
}
|
|
40
81
|
|
|
41
82
|
/**
|
|
@@ -64,10 +105,10 @@ function requestReload(api: any): boolean {
|
|
|
64
105
|
* Guarded because the reload is asynchronous: without it, every poll before
|
|
65
106
|
* the provider list refreshes would fire another signal.
|
|
66
107
|
*/
|
|
67
|
-
function useAutoReload(api: any, data: () => { status: { state: string }; registered?: boolean }) {
|
|
108
|
+
function useAutoReload(api: any, data: () => { backends: { status: { state: string } }[]; registered?: boolean }) {
|
|
68
109
|
let fired = false
|
|
69
110
|
createEffect(() => {
|
|
70
|
-
const ready = data().status.state === "running"
|
|
111
|
+
const ready = data().backends.some((backend) => backend.status.state === "running")
|
|
71
112
|
if (!ready || data().registered !== false) {
|
|
72
113
|
// reset once opencode has caught up, so a later restart of the backend
|
|
73
114
|
// can trigger exactly one more reload
|
|
@@ -90,11 +131,11 @@ function HomePanel(props: { api: any }) {
|
|
|
90
131
|
</box>
|
|
91
132
|
<Divider color={theme().border} rows={hardwareRows(data()) + 1} />
|
|
92
133
|
<box width={PROVIDER_WIDTH} flexShrink={0} flexDirection="column">
|
|
93
|
-
<Provider theme={theme()} data={data()} />
|
|
134
|
+
<Provider theme={theme()} data={data()} busy={busy()} onToggle={(id) => void toggleServer(id)} />
|
|
94
135
|
</box>
|
|
95
136
|
<Divider color={theme().border} rows={hardwareRows(data()) + 1} />
|
|
96
|
-
<box
|
|
97
|
-
<Model theme={theme()} data={data()} />
|
|
137
|
+
<box flexGrow={1} minWidth={20} flexDirection="column">
|
|
138
|
+
<Model theme={theme()} data={data()} onChange={() => openModelPicker(props.api)} />
|
|
98
139
|
</box>
|
|
99
140
|
</box>
|
|
100
141
|
)
|
|
@@ -107,11 +148,9 @@ function SidebarPanel(props: { api: any }) {
|
|
|
107
148
|
<box flexDirection="column">
|
|
108
149
|
<Hardware theme={theme()} data={data()} />
|
|
109
150
|
<box height={1} />
|
|
110
|
-
<Provider theme={theme()} data={data()} stacked />
|
|
151
|
+
<Provider theme={theme()} data={data()} stacked busy={busy()} onToggle={(id) => void toggleServer(id)} />
|
|
111
152
|
<box height={1} />
|
|
112
|
-
<
|
|
113
|
-
<Model theme={theme()} data={data()} stacked />
|
|
114
|
-
</Show>
|
|
153
|
+
<Model theme={theme()} data={data()} stacked onChange={() => openModelPicker(props.api)} />
|
|
115
154
|
</box>
|
|
116
155
|
)
|
|
117
156
|
}
|
|
@@ -127,6 +166,19 @@ const tui = async (api: any) => {
|
|
|
127
166
|
slash: { name: "localhost" },
|
|
128
167
|
onSelect: () => openSetup(api),
|
|
129
168
|
},
|
|
169
|
+
{
|
|
170
|
+
// keyboard route to the same action: tab is opencode's agent cycler, so
|
|
171
|
+
// the panel cannot be tabbed into. This is bindable and in ctrl+p.
|
|
172
|
+
title: "Local models: start/stop server",
|
|
173
|
+
value: "localhost.server.toggle",
|
|
174
|
+
category: "Provider",
|
|
175
|
+
onSelect: () => {
|
|
176
|
+
// with one engine this is unambiguous; with several the setup screen
|
|
177
|
+
// is where you pick which, so this acts on the first configured one
|
|
178
|
+
const first = BACKENDS[0]
|
|
179
|
+
if (first) void toggleServer(first.id)
|
|
180
|
+
},
|
|
181
|
+
},
|
|
130
182
|
])
|
|
131
183
|
|
|
132
184
|
api.slots.register({
|
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
|
|