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
|
@@ -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({
|