@tiens.nguyen/gu-cli 1.0.768 → 1.0.770
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/agent-stop.mjs +22 -5
- package/client-expert-api.mjs +89 -8
- package/client-expert-run.mjs +104 -12
- package/gonext_agent_chat.py +217 -11
- package/gu-cli.mjs +56 -29
- package/gu-repl.mjs +311 -31
- package/image-paste.mjs +10 -0
- package/model-setup-ui.mjs +114 -0
- package/ollama-setup.mjs +128 -23
- package/package.json +5 -1
- package/setup-prompts.mjs +82 -0
- package/terminal-layout.mjs +31 -1
- package/turn-stop.mjs +78 -0
- package/workspace-revert.mjs +106 -0
package/agent-stop.mjs
CHANGED
|
@@ -41,11 +41,28 @@ export const STOP_GRACE_MS = 3000;
|
|
|
41
41
|
* Stop a child process: SIGTERM now, SIGKILL after `graceMs` unless it has exited.
|
|
42
42
|
* → a function that cancels the pending SIGKILL (call it from the child's `close`).
|
|
43
43
|
*/
|
|
44
|
-
export function stopGracefully(child, {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
export function stopGracefully(child, {
|
|
45
|
+
graceMs = STOP_GRACE_MS,
|
|
46
|
+
setTimer = setTimeout,
|
|
47
|
+
clearTimer = clearTimeout,
|
|
48
|
+
platform = process.platform,
|
|
49
|
+
// WINDOWS HAS NO SIGTERM (task #302): child.kill("SIGTERM") there IS TerminateProcess, which no
|
|
50
|
+
// handler sees — so a stop could land in the middle of the agent writing a file. There, ask
|
|
51
|
+
// first (the agent watches GONEXT_STOP_FILE and stops at a safe point) and kill after the grace.
|
|
52
|
+
requestStop = null,
|
|
53
|
+
} = {}) {
|
|
54
|
+
if (platform === "win32" && typeof requestStop === "function") {
|
|
55
|
+
try {
|
|
56
|
+
requestStop();
|
|
57
|
+
} catch {
|
|
58
|
+
/* could not ask — the kill after the grace still stops it */
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
try {
|
|
62
|
+
child.kill("SIGTERM");
|
|
63
|
+
} catch {
|
|
64
|
+
/* already gone */
|
|
65
|
+
}
|
|
49
66
|
}
|
|
50
67
|
const t = setTimer(() => {
|
|
51
68
|
if (child.exitCode === null && child.signalCode === null) {
|
package/client-expert-api.mjs
CHANGED
|
@@ -516,6 +516,23 @@ export const CLIENT_EXPERT_MODELS = {
|
|
|
516
516
|
* is written — while Kimi is registered as "openai" so `/model` lists it and asks for the key
|
|
517
517
|
* only if the user actually switches. One slot per kind, so these cannot collide.
|
|
518
518
|
*/
|
|
519
|
+
/** The row setup writes for an agent server that differs from the coder's and has a key (#301). */
|
|
520
|
+
export const AGENT_ROW_ID = "ollama-agent";
|
|
521
|
+
/** The implicit id of the coder's row — setup writes it without one; the API names it by kind. */
|
|
522
|
+
export const CODER_ROW_ID = "ollama";
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Which row a key typed at setup binds to (#301): the coder's row for the coder's server (or for
|
|
526
|
+
* the agent's when they share one), the agent row otherwise. "" when neither server was used.
|
|
527
|
+
*/
|
|
528
|
+
export function rowIdForKey(models, url) {
|
|
529
|
+
const M = models ?? {};
|
|
530
|
+
const codeUrl = M.codeUrl || M.url;
|
|
531
|
+
if (url && url === codeUrl) return CODER_ROW_ID;
|
|
532
|
+
if (url && url === M.url) return AGENT_ROW_ID;
|
|
533
|
+
return "";
|
|
534
|
+
}
|
|
535
|
+
|
|
519
536
|
export function codingBackendsForClientExpert(models = CLIENT_EXPERT_MODELS) {
|
|
520
537
|
const M = models ?? CLIENT_EXPERT_MODELS;
|
|
521
538
|
// The chat model is offered too, so `/model` can drop to the fast one for simple work, plus
|
|
@@ -525,17 +542,33 @@ export function codingBackendsForClientExpert(models = CLIENT_EXPERT_MODELS) {
|
|
|
525
542
|
// SHARED_ALT_CODE_MODEL is the seeded default for the shared box. Deduped and stripped of the
|
|
526
543
|
// primary coder, because a `/model` list that offers the model you are already on — twice —
|
|
527
544
|
// is a list nobody trusts.
|
|
528
|
-
|
|
545
|
+
//
|
|
546
|
+
// THE CODER'S OWN SERVER (#301). Setup has asked "Coding-model server" separately since #220 and
|
|
547
|
+
// returned it as codeUrl, but this row was always built on M.url — the AGENT's server — so a
|
|
548
|
+
// coder chosen on a second box was silently configured on the first. The agent model is offered
|
|
549
|
+
// on the coder's row only when it lives on that same server; elsewhere it is not there to offer.
|
|
550
|
+
const codeUrl = M.codeUrl || M.url;
|
|
551
|
+
const sameServer = codeUrl === M.url;
|
|
552
|
+
const offered = [...(sameServer ? [M.chat] : []), ...(M.altCoders ?? [SHARED_ALT_CODE_MODEL])].filter(
|
|
529
553
|
(name, i, all) => name && name !== M.code && all.indexOf(name) === i,
|
|
530
554
|
);
|
|
555
|
+
// AN AGENT SERVER WITH A KEY NEEDS A ROW OF ITS OWN (#301). The API finds the agent's (and the
|
|
556
|
+
// embedder's) key by matching agentModelUrl to a row's URL — no row, no key. Only when the key
|
|
557
|
+
// exists: a keyless agent box needs no row, and one would add a server to /model for nothing.
|
|
558
|
+
// Explicit id, because two id-less ollama rows would both claim the implicit id "ollama".
|
|
559
|
+
const agentKeyed =
|
|
560
|
+
!sameServer && Array.isArray(M.keys) && M.keys.some((k) => k?.url === M.url && k?.key);
|
|
531
561
|
return [
|
|
532
562
|
{
|
|
533
563
|
kind: "ollama",
|
|
534
564
|
enabled: true,
|
|
535
|
-
url:
|
|
565
|
+
url: codeUrl,
|
|
536
566
|
defaultModel: M.code,
|
|
537
567
|
models: offered,
|
|
538
568
|
},
|
|
569
|
+
...(agentKeyed
|
|
570
|
+
? [{ id: AGENT_ROW_ID, kind: "ollama", enabled: true, url: M.url, defaultModel: M.chat, models: [] }]
|
|
571
|
+
: []),
|
|
539
572
|
{
|
|
540
573
|
kind: "openai",
|
|
541
574
|
enabled: true,
|
|
@@ -627,8 +660,14 @@ export function backfillCodingModels(existing = {}) {
|
|
|
627
660
|
* model names to a list that already exists; see its docstring for why that is not "overwriting
|
|
628
661
|
* a model the user has since chosen".
|
|
629
662
|
*/
|
|
630
|
-
export function settingsPatch({ existing = {}, models = CLIENT_EXPERT_MODELS } = {}) {
|
|
663
|
+
export function settingsPatch({ existing = {}, models = CLIENT_EXPERT_MODELS, skipModels = false } = {}) {
|
|
631
664
|
const patch = {};
|
|
665
|
+
// #301: "Skip for now" (or Esc) at setup — NO model field is written: not the shared box, not a
|
|
666
|
+
// half. The bar then says what is unset and /model sets it. Only the non-model defaults below.
|
|
667
|
+
if (skipModels) {
|
|
668
|
+
if (typeof existing.agentSaveFullResponse !== "boolean") patch.agentSaveFullResponse = true;
|
|
669
|
+
return patch;
|
|
670
|
+
}
|
|
632
671
|
// `models` is either the seeded shared-Ollama defaults (nobody was asked) or the box the user
|
|
633
672
|
// chose at setup (task #220). Everything below is written in terms of M, so the two paths
|
|
634
673
|
// cannot drift into configuring different fields.
|
|
@@ -638,7 +677,10 @@ export function settingsPatch({ existing = {}, models = CLIENT_EXPERT_MODELS } =
|
|
|
638
677
|
Boolean(existing.agentModelUrl?.trim()) ||
|
|
639
678
|
Boolean(existing.agentModel?.trim()) ||
|
|
640
679
|
Boolean(existing.agentModelName?.trim());
|
|
641
|
-
|
|
680
|
+
// A ROLE SKIPPED AT SETUP IS LEFT UNSET, not half-written (#301): an agent URL with no model
|
|
681
|
+
// name is the "default_model" sentinel on the wire, and a coder row with an empty model is a coder
|
|
682
|
+
// that answers nothing. Only the roles that were actually chosen are written.
|
|
683
|
+
if (!hasAgent && M.chat) {
|
|
642
684
|
patch.agentModelUrl = M.url;
|
|
643
685
|
const name = M.chat;
|
|
644
686
|
// agentModelNAME is the field that matters. With a direct agent URL the API reads
|
|
@@ -664,17 +706,17 @@ export function settingsPatch({ existing = {}, models = CLIENT_EXPERT_MODELS } =
|
|
|
664
706
|
(Array.isArray(existing.agentCodingBackends) && existing.agentCodingBackends.length > 0) ||
|
|
665
707
|
Boolean(existing.agentCodingModelUrl?.trim()) ||
|
|
666
708
|
Boolean(existing.agentCodingModelName?.trim());
|
|
667
|
-
if (!hasCoder) {
|
|
709
|
+
if (!hasCoder && M.code) {
|
|
668
710
|
patch.agentCodingBackends = codingBackendsForClientExpert(M);
|
|
669
711
|
patch.agentCodingDefaultKind = "ollama";
|
|
670
712
|
// Legacy mirror, so an older worker resolves the same coder.
|
|
671
713
|
patch.agentCodingModelKind = "ollama";
|
|
672
|
-
patch.agentCodingModelUrl = M.url;
|
|
714
|
+
patch.agentCodingModelUrl = M.codeUrl || M.url;
|
|
673
715
|
patch.agentCodingModelName = M.code;
|
|
674
716
|
// NO api key is stored. The default coder is kind "ollama", which the API does not require
|
|
675
717
|
// one for — and a placeholder here would be SENT to Kimi the moment someone switched with
|
|
676
718
|
// `/model`, failing at the provider with an auth error nobody expected.
|
|
677
|
-
} else {
|
|
719
|
+
} else if (hasCoder) {
|
|
678
720
|
// Already configured — so the seed above will never run again on this machine. Add any
|
|
679
721
|
// newly-offered shared-Ollama models to the list it was seeded with, without changing which
|
|
680
722
|
// coder it uses. See backfillCodingModels.
|
|
@@ -752,6 +794,8 @@ export async function seedSettings({
|
|
|
752
794
|
// question was skipped or declined. Passed straight to settingsPatch so there is exactly one
|
|
753
795
|
// place that decides what a Client Expert machine is configured with.
|
|
754
796
|
models = CLIENT_EXPERT_MODELS,
|
|
797
|
+
// #301: the model questions were skipped — write no model field at all.
|
|
798
|
+
skipModels = false,
|
|
755
799
|
} = {}) {
|
|
756
800
|
const { MongoClient } = loadMongoDriver(apiDir);
|
|
757
801
|
const client = new MongoClient(mongoUri, { serverSelectionTimeoutMS: 8000 });
|
|
@@ -759,7 +803,7 @@ export async function seedSettings({
|
|
|
759
803
|
try {
|
|
760
804
|
const col = client.db(dbName).collection("settings");
|
|
761
805
|
const existing = (await col.findOne({ userId })) ?? {};
|
|
762
|
-
const patch = settingsPatch({ existing, models });
|
|
806
|
+
const patch = settingsPatch({ existing, models, skipModels });
|
|
763
807
|
if (Object.keys(patch).length > 0) {
|
|
764
808
|
await col.updateOne(
|
|
765
809
|
{ userId },
|
|
@@ -773,6 +817,43 @@ export async function seedSettings({
|
|
|
773
817
|
}
|
|
774
818
|
}
|
|
775
819
|
|
|
820
|
+
/**
|
|
821
|
+
* Save the API keys typed at setup (#301), each bound to its server's row, through the local API
|
|
822
|
+
* — the same POST /coding-keys the terminal uses, so the first key for a server becomes its
|
|
823
|
+
* default by the API's own rule (#245) rather than by anything written here. Runs AFTER
|
|
824
|
+
* seedSettings: the API refuses a key for a row that does not exist yet.
|
|
825
|
+
*
|
|
826
|
+
* Returns [{ host, ok }]. Never throws and never prints the key: a key that failed to save is
|
|
827
|
+
* reported by host, and can be added again with ctrl+k in /model.
|
|
828
|
+
*/
|
|
829
|
+
export async function saveSetupKeys({ apiBase = LOCAL_API_BASE, workerKey, models, fetchImpl = fetch } = {}) {
|
|
830
|
+
const out = [];
|
|
831
|
+
for (const { url, key } of Array.isArray(models?.keys) ? models.keys : []) {
|
|
832
|
+
let host = url;
|
|
833
|
+
try {
|
|
834
|
+
host = new URL(url).host;
|
|
835
|
+
} catch {
|
|
836
|
+
/* keep the url */
|
|
837
|
+
}
|
|
838
|
+
const backendId = rowIdForKey(models, url);
|
|
839
|
+
if (!key || !backendId) continue;
|
|
840
|
+
let ok = false;
|
|
841
|
+
try {
|
|
842
|
+
const r = await fetchImpl(`${apiBase}/api/worker/coding-keys`, {
|
|
843
|
+
method: "POST",
|
|
844
|
+
headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
|
|
845
|
+
body: JSON.stringify({ name: host, apiKey: key, backendId }),
|
|
846
|
+
signal: AbortSignal.timeout(10000),
|
|
847
|
+
});
|
|
848
|
+
ok = r.ok;
|
|
849
|
+
} catch {
|
|
850
|
+
ok = false;
|
|
851
|
+
}
|
|
852
|
+
out.push({ host, ok });
|
|
853
|
+
}
|
|
854
|
+
return out;
|
|
855
|
+
}
|
|
856
|
+
|
|
776
857
|
/**
|
|
777
858
|
* THE NEW OLLAMA SERVER'S ROWS, MERGED INTO WHAT IS ALREADY THERE — never replacing it (#43).
|
|
778
859
|
*
|
package/client-expert-run.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
probeLocalApi,
|
|
30
30
|
writeWorkerEnv,
|
|
31
31
|
seedSettings,
|
|
32
|
+
saveSetupKeys,
|
|
32
33
|
modelsAlreadyConfigured,
|
|
33
34
|
CLIENT_EXPERT_MODELS,
|
|
34
35
|
LOCAL_API_PORT,
|
|
@@ -37,6 +38,9 @@ import {
|
|
|
37
38
|
LOCAL_MONGO_URI,
|
|
38
39
|
} from "./client-expert-api.mjs";
|
|
39
40
|
import { askOllamaSetup, localOllamaDefault } from "./ollama-setup.mjs";
|
|
41
|
+
import { SKIP_ALL, createHinter, modelSetupMenu, parseModelSetupChoice } from "./model-setup-ui.mjs";
|
|
42
|
+
import { presetFilesIn } from "./presets-transfer.mjs";
|
|
43
|
+
import { readdirSync, statSync, readFileSync } from "node:fs";
|
|
40
44
|
import { parseOllamaTags, formatSize } from "./agent-model-command.mjs";
|
|
41
45
|
import {
|
|
42
46
|
planSetup,
|
|
@@ -66,15 +70,46 @@ class SkipModelQuestion extends Error {}
|
|
|
66
70
|
* (this returns []), and those two get different messages because they need different fixes.
|
|
67
71
|
* A short timeout, because a box that has not answered in eight seconds is not going to.
|
|
68
72
|
*/
|
|
69
|
-
async function fetchOllamaTags(url) {
|
|
70
|
-
const res = await fetch(url, {
|
|
71
|
-
|
|
73
|
+
async function fetchOllamaTags(url, { apiKey = "" } = {}) {
|
|
74
|
+
const res = await fetch(url, {
|
|
75
|
+
signal: AbortSignal.timeout(8000),
|
|
76
|
+
// #301: a key typed for this server. Absent → the request is exactly what it always was.
|
|
77
|
+
...(apiKey ? { headers: { Authorization: `Bearer ${apiKey}` } } : {}),
|
|
78
|
+
});
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
// The status travels with the error: 401/403 is "needs a key", which the caller asks for.
|
|
81
|
+
const err = new Error(`HTTP ${res.status}`);
|
|
82
|
+
err.status = res.status;
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
72
85
|
// parseOllamaTags is tolerant by design — HTML from a proxy, an empty body and a shape from a
|
|
73
86
|
// future Ollama all become []. That is "reachable but not usable", which the caller reports as
|
|
74
87
|
// such rather than as a network failure.
|
|
75
88
|
return parseOllamaTags(await res.json().catch(() => null));
|
|
76
89
|
}
|
|
77
90
|
|
|
91
|
+
/** How many /save-preset files are in the home folder — the up-front question names the count. */
|
|
92
|
+
function countSavedPresets() {
|
|
93
|
+
try {
|
|
94
|
+
const home = homedir();
|
|
95
|
+
const entries = [];
|
|
96
|
+
for (const name of readdirSync(home)) {
|
|
97
|
+
if (!name.toLowerCase().endsWith(".json")) continue;
|
|
98
|
+
const file = join(home, name);
|
|
99
|
+
try {
|
|
100
|
+
const st = statSync(file);
|
|
101
|
+
if (!st.isFile() || st.size > 64 * 1024) continue; // a preset is tiny; never slurp a big file
|
|
102
|
+
entries.push({ name, text: readFileSync(file, "utf8"), mtimeMs: st.mtimeMs });
|
|
103
|
+
} catch {
|
|
104
|
+
/* unreadable — not a preset we can offer */
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return presetFilesIn(entries).length;
|
|
108
|
+
} catch {
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
78
113
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
79
114
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
80
115
|
// The brand accent, shared with the REPL — see accent.mjs.
|
|
@@ -339,7 +374,7 @@ export async function checkMongoConnection(uri) {
|
|
|
339
374
|
* Returns { uri, custom } to continue with, or { exit: true } when the user declines — the
|
|
340
375
|
* caller ends setup with the `gu clear` → `gu` hint.
|
|
341
376
|
*/
|
|
342
|
-
export async function askMongoCredentials(ask) {
|
|
377
|
+
export async function askMongoCredentials(ask, askSecret = null) {
|
|
343
378
|
const { host, port } = mongoHostPort(LOCAL_MONGO_URI);
|
|
344
379
|
console.log(dim(` MongoDB is answering at ${host}:${port}.`));
|
|
345
380
|
console.log(dim(" If that is a MongoDB this machine already had, it may have its OWN"));
|
|
@@ -352,7 +387,12 @@ export async function askMongoCredentials(ask) {
|
|
|
352
387
|
// continues: a typo found here costs ten seconds; found at the seed step it costs the run.
|
|
353
388
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
354
389
|
const username = String(await ask(" MongoDB username: ") ?? "").trim();
|
|
355
|
-
|
|
390
|
+
// MASKED when a masked reader is available (#300): a password typed in the clear stays in the
|
|
391
|
+
// scrollback, and in anything copied from it.
|
|
392
|
+
const rawPw = typeof askSecret === "function"
|
|
393
|
+
? await askSecret(" MongoDB password: ")
|
|
394
|
+
: await ask(" MongoDB password: ");
|
|
395
|
+
const password = rawPw === SKIP_ALL ? "" : String(rawPw ?? "");
|
|
356
396
|
const uri = buildMongoUri({ username, password, host, port });
|
|
357
397
|
process.stdout.write(dim(" checking the connection…"));
|
|
358
398
|
const verdict = await checkMongoConnection(uri);
|
|
@@ -589,7 +629,14 @@ export function markInterruptedOnExit(getState, stepId) {
|
|
|
589
629
|
};
|
|
590
630
|
}
|
|
591
631
|
|
|
592
|
-
export async function runClientExpertSetup({
|
|
632
|
+
export async function runClientExpertSetup({
|
|
633
|
+
ask,
|
|
634
|
+
// #301/#300: a MASKED line reader (API keys, the MongoDB password). Absent → no key is asked.
|
|
635
|
+
askSecret = null,
|
|
636
|
+
// #301: an Esc-aware reader for the model questions — Esc resolves it with SKIP_ALL. Only the
|
|
637
|
+
// model questions use it: Esc elsewhere (the MongoDB questions) must not mean "skip models".
|
|
638
|
+
askStep = ask,
|
|
639
|
+
}) {
|
|
593
640
|
const machine = await probeMachine();
|
|
594
641
|
const gate = clientExpertAvailable({ workerEnvExists: false, ...machine });
|
|
595
642
|
if (!gate.available) {
|
|
@@ -707,7 +754,7 @@ export async function runClientExpertSetup({ ask }) {
|
|
|
707
754
|
console.log(dim(" using the MongoDB credentials from the previous run"));
|
|
708
755
|
}
|
|
709
756
|
} else if (!process.env.GONEXT_LOCAL_MONGO_URI) {
|
|
710
|
-
const credsChoice = await askMongoCredentials(ask);
|
|
757
|
+
const credsChoice = await askMongoCredentials(ask, askSecret);
|
|
711
758
|
if (credsChoice.exit) {
|
|
712
759
|
console.log("\n " + dim("No working MongoDB credentials — setup stops here."));
|
|
713
760
|
console.log(dim(" To start over: `gu clear`, then `gu` again.\n"));
|
|
@@ -902,6 +949,27 @@ export async function runClientExpertSetup({ ask }) {
|
|
|
902
949
|
console.log(dim(" server this machine uses."));
|
|
903
950
|
throw new SkipModelQuestion();
|
|
904
951
|
}
|
|
952
|
+
// ASK ONCE, UP FRONT (#301): guided / load a preset / skip. Enter = guided, so today's flow is
|
|
953
|
+
// unchanged for anyone who just presses Enter; the other two are one digit away instead of an
|
|
954
|
+
// escape hunt through four questions.
|
|
955
|
+
const presets = countSavedPresets();
|
|
956
|
+
console.log("");
|
|
957
|
+
const [title, ...rows] = modelSetupMenu({ presets });
|
|
958
|
+
console.log(" " + bold(title));
|
|
959
|
+
for (const r of rows) console.log(dim(" " + r));
|
|
960
|
+
let path = null;
|
|
961
|
+
for (let t = 0; t < 3 && !path; t++) {
|
|
962
|
+
path = parseModelSetupChoice(await askStep(" choose [1]: "));
|
|
963
|
+
if (path === "preset" && presets === 0) {
|
|
964
|
+
console.log(dim(" No presets saved yet. Save one later with /save-preset — pick 1 or 3 for now."));
|
|
965
|
+
path = null;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
path = path ?? "guided";
|
|
969
|
+
if (path !== "guided") {
|
|
970
|
+
chosenModels = { skipped: true, keys: [], loadPreset: path === "preset" };
|
|
971
|
+
throw new SkipModelQuestion();
|
|
972
|
+
}
|
|
905
973
|
console.log("");
|
|
906
974
|
console.log(" " + bold("Which Ollama server should this machine use?"));
|
|
907
975
|
// THIS MACHINE FIRST, when it has one. Client Expert is the mode whose whole promise is
|
|
@@ -923,10 +991,12 @@ export async function runClientExpertSetup({ ask }) {
|
|
|
923
991
|
),
|
|
924
992
|
);
|
|
925
993
|
console.log(dim(" ollama.local, http://box:11434 — and pick from what it actually has."));
|
|
926
|
-
chosenModels = await askOllamaSetup(
|
|
994
|
+
chosenModels = await askOllamaSetup(askStep, {
|
|
927
995
|
defaultUrl,
|
|
928
996
|
fetchTags: fetchOllamaTags,
|
|
929
997
|
formatSize,
|
|
998
|
+
askSecret,
|
|
999
|
+
hint: createHinter(),
|
|
930
1000
|
// THE SHIPPED DEFAULT WINS WHEN THE BOX HAS IT. Without this a fresh machine falls back to
|
|
931
1001
|
// a size heuristic, and on a box with several similar builds that picks by size alone —
|
|
932
1002
|
// observed on ollama2, where gemma4-quadro:latest and gemma4-quadro-49k:latest are
|
|
@@ -939,18 +1009,38 @@ export async function runClientExpertSetup({ ask }) {
|
|
|
939
1009
|
// The skip is not a failure and says nothing extra; it has already printed its own line.
|
|
940
1010
|
if (!(e instanceof SkipModelQuestion)) {
|
|
941
1011
|
console.log(dim(` (could not ask: ${e.message} — using the shared Ollama)`));
|
|
1012
|
+
chosenModels = null;
|
|
942
1013
|
}
|
|
943
|
-
|
|
1014
|
+
// A SkipModelQuestion keeps whatever it set: undefined (already configured) or the skip.
|
|
1015
|
+
chosenModels = chosenModels ?? null;
|
|
944
1016
|
}
|
|
1017
|
+
// #301: skipped on purpose (up front, "s" at both servers, or Esc) → no model field is written.
|
|
1018
|
+
const skipModels = Boolean(chosenModels?.skipped);
|
|
1019
|
+
const loadPreset = Boolean(chosenModels?.loadPreset);
|
|
945
1020
|
|
|
946
1021
|
try {
|
|
947
1022
|
const written = await seedSettings({
|
|
948
1023
|
apiDir,
|
|
949
1024
|
mongoUri,
|
|
950
1025
|
userId: creds.userId,
|
|
951
|
-
...(chosenModels ? { models: chosenModels } : {}),
|
|
1026
|
+
...(chosenModels && !skipModels ? { models: chosenModels } : {}),
|
|
1027
|
+
skipModels,
|
|
952
1028
|
});
|
|
953
|
-
|
|
1029
|
+
// #301: the keys typed for the servers, saved through the API now that their rows exist.
|
|
1030
|
+
// Reported by host, never by value.
|
|
1031
|
+
if (!skipModels && chosenModels?.keys?.length) {
|
|
1032
|
+
for (const r of await saveSetupKeys({ workerKey: creds.workerKey, models: chosenModels })) {
|
|
1033
|
+
console.log(r.ok
|
|
1034
|
+
? " " + cyan("✓ API key stored") + dim(` for ${r.host} — the server's default key`)
|
|
1035
|
+
: " " + yellow("⚠ could not store the key") + dim(` for ${r.host} — add it in /model with ctrl+k`));
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
if (skipModels) {
|
|
1039
|
+
console.log("");
|
|
1040
|
+
console.log(" " + cyan("✓ Model setup skipped") + dim(loadPreset
|
|
1041
|
+
? " — the preset picker opens as soon as gu starts"
|
|
1042
|
+
: " — set them any time with /model (or /load-presets)"));
|
|
1043
|
+
} else if (Object.keys(written).length === 0) {
|
|
954
1044
|
console.log(" " + cyan("✓ Models and RAG") + dim(" — already configured, left alone"));
|
|
955
1045
|
} else {
|
|
956
1046
|
console.log(
|
|
@@ -997,5 +1087,7 @@ export async function runClientExpertSetup({ ask }) {
|
|
|
997
1087
|
dim("\n machine. The API restarts with `gu-cli api start`; its log is") +
|
|
998
1088
|
dim(`\n ${API_LOG_FILE}\n`)
|
|
999
1089
|
);
|
|
1000
|
-
|
|
1090
|
+
// #301: the caller opens /load-presets once the terminal exists — the picker, the plan and the
|
|
1091
|
+
// confirmation are that command's, not rebuilt here.
|
|
1092
|
+
return { complete: true, stoppedAt: null, ...(loadPreset ? { loadPreset: true } : {}) };
|
|
1001
1093
|
}
|