okengine 0.6.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/AGENTS.md +2 -2
- package/README.md +98 -71
- package/package.json +4 -4
- package/site/content/docs/console/clock.mdx +25 -5
- package/site/content/docs/console/signals.mdx +13 -4
- package/site/content/docs/elements/ai.mdx +29 -2
- package/site/content/docs/elements/channel.mdx +90 -20
- package/site/content/docs/elements/clock.mdx +56 -24
- package/site/content/docs/elements/flow.mdx +2 -0
- package/site/content/docs/elements/gate.mdx +8 -1
- package/site/content/docs/elements/signal.mdx +173 -29
- package/site/content/docs/elements/store.mdx +464 -73
- package/site/content/docs/elements/vault.mdx +15 -5
- package/site/content/docs/get-started/basic-usage.mdx +13 -6
- package/site/content/docs/get-started/installation.mdx +49 -13
- package/site/content/docs/get-started/introduction.mdx +1 -1
- package/site/content/docs/reference/cli.md +30 -2
- package/site/content/docs/reference/configuration.mdx +18 -17
- package/site/content/docs/reference/environment-variables.mdx +44 -18
- package/site/content/docs/reference/errors.mdx +12 -11
- package/site/content/docs/reference/fx.mdx +15 -13
- package/site/content/docs/reference/security.md +3 -1
- package/src/cli/ai-setup/ai-setup.test.ts +144 -0
- package/src/cli/ai-setup/apply.ts +214 -0
- package/src/cli/ai-setup/catalog.ts +263 -0
- package/src/cli/ai-setup/detect-ollama.ts +166 -0
- package/src/cli/ai-setup/index.ts +228 -0
- package/src/cli/ai-setup/prompts.ts +649 -0
- package/src/cli/ai-setup/recommend.test.ts +100 -0
- package/src/cli/ai-setup/recommend.ts +203 -0
- package/src/cli/ai.ts +38 -0
- package/src/cli/db-auto-push.test.ts +6 -1
- package/src/cli/db-auto-push.ts +8 -3
- package/src/cli/dev-db-push.test.ts +59 -0
- package/src/cli/dev.test.ts +40 -0
- package/src/cli/dev.ts +55 -1
- package/src/cli/docker-clean.test.ts +179 -0
- package/src/cli/docker-clean.ts +406 -0
- package/src/cli/docker.ts +16 -4
- package/src/cli/ensure-drizzle-config.ts +1 -1
- package/src/cli/hero-meta.test.ts +6 -5
- package/src/cli/hero-meta.ts +7 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/load-config.ts +4 -4
- package/src/cli/openbao-bootstrap.test.ts +1 -0
- package/src/cli/openbao-bootstrap.ts +9 -2
- package/src/cli/openbao-restart.integration.test.ts +106 -97
- package/src/cli/registry.ts +79 -1
- package/src/client/live-gap.test.ts +35 -0
- package/src/client/transport.test.ts +21 -0
- package/src/client/transport.ts +27 -5
- package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
- package/src/config/define-config.test.ts +6 -6
- package/src/config/index.ts +1 -1
- package/src/console/server/console.test.ts +45 -0
- package/src/console/server/flows.ts +39 -9
- package/src/console/server/i18n.ts +41 -0
- package/src/console/server/vault.ts +12 -38
- package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
- package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
- package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
- package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
- package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
- package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
- package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
- package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
- package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
- package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
- package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
- package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
- package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
- package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
- package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
- package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
- package/src/console/ui/dist/index.html +1 -1
- package/src/console/ui/shell/components/ui.tsx +5 -1
- package/src/console/ui/shell/setup/Wizard.tsx +26 -5
- package/src/docker/cleanup.test.ts +193 -0
- package/src/docker/cleanup.ts +355 -0
- package/src/docker/docker.test.ts +4 -0
- package/src/docker/dockerfile.integration.test.ts +126 -119
- package/src/docker/index.ts +19 -0
- package/src/docker/recipes/ollama.ts +10 -5
- package/src/docker/stack.integration.test.ts +118 -102
- package/src/drivers/ai-ollama-tools.integration.test.ts +8 -6
- package/src/drivers/ai-ollama.integration.test.ts +3 -19
- package/src/drivers/ai-ollama.ts +45 -2
- package/src/drivers/ai-providers.test.ts +31 -0
- package/src/drivers/channel-fcm.ts +49 -53
- package/src/drivers/channel-msegat.ts +61 -0
- package/src/drivers/channel-sently-map.ts +57 -0
- package/src/drivers/channel-sently.test.ts +99 -0
- package/src/drivers/channel-sndr.ts +28 -0
- package/src/drivers/channel-taqnyat.ts +57 -0
- package/src/drivers/channel-types.ts +79 -2
- package/src/drivers/channel-unifonic.ts +26 -43
- package/src/drivers/channel-wa-cloud.ts +33 -47
- package/src/drivers/channel-webpush.ts +39 -239
- package/src/drivers/index.ts +7 -0
- package/src/drivers/signal-engine.ts +98 -25
- package/src/drivers/signal-nats.ts +4 -4
- package/src/drivers/signal-postgres.ts +158 -64
- package/src/drivers/signal-redis.ts +4 -4
- package/src/drivers/signal-types.ts +61 -2
- package/src/drivers/vault-driver-removal.test.ts +5 -1
- package/src/elements/channel/costs.test.ts +2 -2
- package/src/elements/channel/costs.ts +14 -2
- package/src/elements/channel/mime.ts +11 -0
- package/src/elements/channel/runtime.ts +94 -0
- package/src/elements/channel/sndr-webhooks.test.ts +26 -0
- package/src/elements/channel.ts +10 -1
- package/src/elements/clock/chaos-child.ts +150 -0
- package/src/elements/clock/chaos.test.ts +204 -0
- package/src/elements/clock/reconcile.ts +110 -0
- package/src/elements/clock/runtime.ts +11 -0
- package/src/elements/clock.test.ts +164 -2
- package/src/elements/clock.ts +1 -0
- package/src/elements/gate/runtime.ts +6 -0
- package/src/elements/index.ts +9 -0
- package/src/elements/signal/chaos-child.ts +41 -4
- package/src/elements/signal/declare.ts +4 -1
- package/src/elements/signal/delivery-modes.test.ts +179 -0
- package/src/elements/signal/key-ordering.test.ts +306 -0
- package/src/elements/signal/lease-reclaim.test.ts +123 -0
- package/src/elements/signal/optional-emit.test.ts +76 -0
- package/src/elements/signal/order-lifecycle.test.ts +130 -0
- package/src/elements/signal/orphan-messages.test.ts +152 -0
- package/src/elements/signal/runtime.ts +11 -4
- package/src/elements/signal/schema-emit.test.ts +107 -0
- package/src/elements/signal.test.ts +89 -5
- package/src/elements/store/files-fx.ts +104 -0
- package/src/elements/store/files-image.test.ts +251 -0
- package/src/elements/store/files-image.ts +494 -0
- package/src/elements/store/runtime.ts +60 -0
- package/src/elements/store.ts +24 -0
- package/src/elements/vault/boot-chain.ts +150 -0
- package/src/elements/vault/runtime.ts +8 -0
- package/src/kernel/app.ts +3 -1
- package/src/kernel/boot-bind/channel.test.ts +68 -3
- package/src/kernel/boot-bind/channel.ts +93 -2
- package/src/kernel/boot-bind/clock.ts +52 -7
- package/src/kernel/boot-bind/gate.ts +71 -3
- package/src/kernel/boot-bind/honor-config.test.ts +247 -0
- package/src/kernel/boot-bind/signal.ts +64 -7
- package/src/kernel/boot-bind/vault.ts +27 -8
- package/src/kernel/boot.ts +10 -2
- package/src/kernel/errors.ts +8 -2
- package/src/kernel/fx.test.ts +13 -0
- package/src/kernel/fx.ts +27 -4
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +10 -4
- package/src/release/exports.test.ts +26 -0
- package/src/release/exports.ts +64 -5
- package/src/release/index.ts +5 -0
- package/src/release/measure.exports.test.ts +13 -1
- package/src/release/measure.ts +76 -13
- package/src/release/official-plugins.ts +46 -0
- package/src/release/readme.test.ts +30 -2
- package/src/runtime/dev-request-log.test.ts +20 -1
- package/src/runtime/dev-request-log.ts +38 -0
- package/src/term.test.ts +15 -0
- package/src/term.ts +8 -3
- package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive Clack prompts for `oke ai setup`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { isCancel, note, select, text } from "@clack/prompts";
|
|
6
|
+
import {
|
|
7
|
+
CHAT_MODELS,
|
|
8
|
+
CLOUD_PROVIDERS,
|
|
9
|
+
EMBED_MODELS,
|
|
10
|
+
VISION_MODELS,
|
|
11
|
+
cloudChatModels,
|
|
12
|
+
recommendCloudChat,
|
|
13
|
+
recommendForRole,
|
|
14
|
+
type CatalogModel,
|
|
15
|
+
type CloudModel,
|
|
16
|
+
} from "./catalog.ts";
|
|
17
|
+
import type { AiSetupApplyInput } from "./apply.ts";
|
|
18
|
+
import {
|
|
19
|
+
detectOllama,
|
|
20
|
+
detectTotalRamGb,
|
|
21
|
+
isInstalled,
|
|
22
|
+
type OllamaDetectResult,
|
|
23
|
+
} from "./detect-ollama.ts";
|
|
24
|
+
import {
|
|
25
|
+
OS_HEADROOM_GB,
|
|
26
|
+
fittingChatModels,
|
|
27
|
+
formatMachineSummary,
|
|
28
|
+
isTightFit,
|
|
29
|
+
modelFitsComfortably,
|
|
30
|
+
modelFitsOnMachine,
|
|
31
|
+
recommendChatForNeeds,
|
|
32
|
+
recommendVisionForNeeds,
|
|
33
|
+
type AiNeeds,
|
|
34
|
+
type AiPriority,
|
|
35
|
+
type AiUseCase,
|
|
36
|
+
} from "./recommend.ts";
|
|
37
|
+
|
|
38
|
+
/** Provider menu value. */
|
|
39
|
+
export type AiSetupProvider =
|
|
40
|
+
| "ollama"
|
|
41
|
+
| "openai"
|
|
42
|
+
| "anthropic"
|
|
43
|
+
| "gemini"
|
|
44
|
+
| "lmstudio"
|
|
45
|
+
| "openrouter"
|
|
46
|
+
| "custom";
|
|
47
|
+
|
|
48
|
+
/** Sentinel for ← Back in AI setup selects. */
|
|
49
|
+
const BACK = "__back__" as const;
|
|
50
|
+
type Back = typeof BACK;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run the interactive AI setup prompts.
|
|
54
|
+
*
|
|
55
|
+
* @param options - Preselected provider / seams
|
|
56
|
+
* @returns Apply input or null on cancel
|
|
57
|
+
*/
|
|
58
|
+
export async function askAiSetup(
|
|
59
|
+
options: {
|
|
60
|
+
readonly provider?: string;
|
|
61
|
+
readonly detect?: () => Promise<OllamaDetectResult>;
|
|
62
|
+
readonly ramGb?: number | null;
|
|
63
|
+
} = {},
|
|
64
|
+
): Promise<AiSetupApplyInput | null> {
|
|
65
|
+
// Treat mock as "ask again" — create-oke may pass mock when only docker is Ollama.
|
|
66
|
+
const rawProvider = options.provider?.trim() ?? "";
|
|
67
|
+
let provider: AiSetupProvider | undefined =
|
|
68
|
+
rawProvider === "" || rawProvider === "mock" ? undefined : (rawProvider as AiSetupProvider);
|
|
69
|
+
|
|
70
|
+
if (!provider) {
|
|
71
|
+
const value = await select({
|
|
72
|
+
message: "AI Provider",
|
|
73
|
+
options: [
|
|
74
|
+
{
|
|
75
|
+
value: "ollama",
|
|
76
|
+
label: "◎ Ollama (Local)",
|
|
77
|
+
hint: "detect models · recommend for your RAM",
|
|
78
|
+
},
|
|
79
|
+
{ value: "openai", label: "◈ OpenAI" },
|
|
80
|
+
{ value: "anthropic", label: "◉ Anthropic" },
|
|
81
|
+
{ value: "gemini", label: "◇ Gemini", hint: "OpenAI-compatible proxy URL" },
|
|
82
|
+
{ value: "lmstudio", label: "▣ LM Studio" },
|
|
83
|
+
{ value: "openrouter", label: "⇄ OpenRouter" },
|
|
84
|
+
{ value: "custom", label: "⋯ Custom OpenAI Compatible" },
|
|
85
|
+
],
|
|
86
|
+
});
|
|
87
|
+
if (isCancel(value)) return null;
|
|
88
|
+
provider = String(value) as AiSetupProvider;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (provider === "ollama") {
|
|
92
|
+
return askOllamaPath({
|
|
93
|
+
detect: options.detect ?? detectOllama,
|
|
94
|
+
ramGb: options.ramGb === undefined ? detectTotalRamGb() : options.ramGb,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return askCloudPath(provider);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Ollama path — needs quiz → detect panel → pick mode (with ← Back).
|
|
103
|
+
*
|
|
104
|
+
* @param options - Detect / RAM
|
|
105
|
+
*/
|
|
106
|
+
async function askOllamaPath(options: {
|
|
107
|
+
readonly detect: () => Promise<OllamaDetectResult>;
|
|
108
|
+
readonly ramGb: number | null;
|
|
109
|
+
}): Promise<AiSetupApplyInput | null> {
|
|
110
|
+
const detected = await options.detect();
|
|
111
|
+
const embedRec = recommendForRole("embed");
|
|
112
|
+
|
|
113
|
+
// Quiz → panel → mode; ← Back from mode re-asks the quiz (same detect).
|
|
114
|
+
for (;;) {
|
|
115
|
+
const needs = await askAiNeedsQuiz();
|
|
116
|
+
if (needs === null) return null;
|
|
117
|
+
|
|
118
|
+
const recommended = recommendChatForNeeds(options.ramGb, needs);
|
|
119
|
+
const visionRec = recommendVisionForNeeds(options.ramGb, needs.wantVision);
|
|
120
|
+
const fitting = fittingChatModels(options.ramGb);
|
|
121
|
+
const tooLarge = CHAT_MODELS.filter((m) => !fitting.some((f) => f.id === m.id));
|
|
122
|
+
const comfortable = fitting.filter((m) => modelFitsComfortably(m, options.ramGb));
|
|
123
|
+
const tight = fitting.filter((m) => isTightFit(m, options.ramGb));
|
|
124
|
+
|
|
125
|
+
const panel = [
|
|
126
|
+
formatMachineSummary(options.ramGb, recommended),
|
|
127
|
+
"",
|
|
128
|
+
"Detected local models",
|
|
129
|
+
...(detected.curatedInstalled.length > 0
|
|
130
|
+
? detected.curatedInstalled.map((m) => `✓ ${m.id}`)
|
|
131
|
+
: ["(none from the curated list)"]),
|
|
132
|
+
"",
|
|
133
|
+
"Fits comfortably (leaves RAM for OS/IDE)",
|
|
134
|
+
...(comfortable.length > 0
|
|
135
|
+
? comfortable.map((m) => {
|
|
136
|
+
const star = m.id === recommended.id ? " ⭐ for you" : "";
|
|
137
|
+
const have = isInstalled(m.id, detected.installed) ? " · installed" : "";
|
|
138
|
+
return `• ${m.id} — ≈${m.ramGb}GB-class${star}${have}`;
|
|
139
|
+
})
|
|
140
|
+
: ["(none — see tight fits below)"]),
|
|
141
|
+
];
|
|
142
|
+
if (tight.length > 0) {
|
|
143
|
+
panel.push(
|
|
144
|
+
"",
|
|
145
|
+
"Fits the tier but tight (little headroom)",
|
|
146
|
+
...tight.map((m) => {
|
|
147
|
+
const star = m.id === recommended.id ? " ⭐ for you" : "";
|
|
148
|
+
return `• ${m.id} — ≈${m.ramGb}GB-class${star}`;
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (tooLarge.length > 0) {
|
|
153
|
+
panel.push(
|
|
154
|
+
"",
|
|
155
|
+
`Too large for ~${options.ramGb ?? "?"}GB (needs a bigger machine tier)`,
|
|
156
|
+
...tooLarge.map((m) => `• ${m.id} — ≈${m.ramGb}GB-class`),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
panel.push(
|
|
160
|
+
"",
|
|
161
|
+
`Tip: Ollama download size ≪ machine RAM · keep ~${OS_HEADROOM_GB}GB free while running.`,
|
|
162
|
+
);
|
|
163
|
+
if (!detected.available) {
|
|
164
|
+
panel.push("", "Ollama CLI/server not detected — https://ollama.com");
|
|
165
|
+
}
|
|
166
|
+
note(panel.join("\n"), "Ollama");
|
|
167
|
+
|
|
168
|
+
// Inner mode loop — ← Back here returns to the quiz; Back from confirm stays here.
|
|
169
|
+
for (;;) {
|
|
170
|
+
const mode = await selectWithBack(
|
|
171
|
+
"How do you want to pick models?",
|
|
172
|
+
[
|
|
173
|
+
{
|
|
174
|
+
value: "recommended",
|
|
175
|
+
label: "⭐ Use recommended for my answers",
|
|
176
|
+
hint: `${recommended.label} · ≈${recommended.ramGb}GB-class (not your full ${options.ramGb ?? "?"}GB RAM)`,
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
value: "installed",
|
|
180
|
+
label: "✓ Use installed models",
|
|
181
|
+
hint: detected.curatedInstalled.length === 0 ? "none detected" : "no download",
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
value: "manual",
|
|
185
|
+
label: "◆ Select manually",
|
|
186
|
+
hint: "curated list · fit / tight labeled",
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
detected.curatedInstalled.some((m) => m.id === recommended.id)
|
|
190
|
+
? "installed"
|
|
191
|
+
: "recommended",
|
|
192
|
+
true,
|
|
193
|
+
);
|
|
194
|
+
if (mode === null) return null;
|
|
195
|
+
if (mode === BACK) break; // outer loop → re-ask quiz
|
|
196
|
+
|
|
197
|
+
if (mode === "recommended") {
|
|
198
|
+
const pull = [recommended.id, ...(visionRec ? [visionRec.id] : []), embedRec.id].filter(
|
|
199
|
+
(id) => detected.available && !isInstalled(id, detected.installed),
|
|
200
|
+
);
|
|
201
|
+
if (pull.length > 0 && detected.available) {
|
|
202
|
+
const doPull = await selectWithBack(
|
|
203
|
+
`Download ${pull.join(", ")}?`,
|
|
204
|
+
[
|
|
205
|
+
{
|
|
206
|
+
value: "yes",
|
|
207
|
+
label: "✓ Yes, download",
|
|
208
|
+
hint: `${recommended.label} · ≈${recommended.ramGb}GB-class machine (pull ≪ RAM)`,
|
|
209
|
+
},
|
|
210
|
+
{ value: "no", label: "✗ No, write ids only", hint: "pull later yourself" },
|
|
211
|
+
],
|
|
212
|
+
"yes",
|
|
213
|
+
true,
|
|
214
|
+
);
|
|
215
|
+
if (doPull === null) return null;
|
|
216
|
+
if (doPull === BACK) continue;
|
|
217
|
+
if (doPull === "yes") await pullModels(pull);
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
driver: "ollama",
|
|
221
|
+
baseUrl: detected.baseUrl,
|
|
222
|
+
chatModel: recommended.id,
|
|
223
|
+
visionModel: visionRec?.id ?? null,
|
|
224
|
+
embedModel: embedRec.id,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (mode === "installed") {
|
|
229
|
+
if (detected.curatedInstalled.length === 0) {
|
|
230
|
+
note("No curated models installed — try recommended or manual.", "Ollama");
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const chat =
|
|
234
|
+
detected.curatedInstalled.find(
|
|
235
|
+
(m) => m.role === "chat" && modelFitsComfortably(m, options.ramGb),
|
|
236
|
+
) ??
|
|
237
|
+
detected.curatedInstalled.find(
|
|
238
|
+
(m) => m.role === "chat" && modelFitsOnMachine(m, options.ramGb),
|
|
239
|
+
) ??
|
|
240
|
+
detected.curatedInstalled.find((m) => m.role === "chat") ??
|
|
241
|
+
detected.curatedInstalled[0]!;
|
|
242
|
+
const vision = needs.wantVision
|
|
243
|
+
? (detected.curatedInstalled.find((m) => m.role === "vision" && m.id !== chat.id) ??
|
|
244
|
+
visionRec)
|
|
245
|
+
: null;
|
|
246
|
+
const embed = detected.curatedInstalled.find((m) => m.role === "embed") ?? embedRec;
|
|
247
|
+
return {
|
|
248
|
+
driver: "ollama",
|
|
249
|
+
baseUrl: detected.baseUrl,
|
|
250
|
+
chatModel: chat.id,
|
|
251
|
+
visionModel: vision?.id ?? null,
|
|
252
|
+
embedModel: embed.id,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const manual = await askManualOllama(detected, recommended, needs, options.ramGb, true);
|
|
257
|
+
if (manual === null) return null;
|
|
258
|
+
if (manual === BACK) continue;
|
|
259
|
+
return manual;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Short quiz — use case + priority + vision.
|
|
266
|
+
*
|
|
267
|
+
* @returns Needs or null on cancel
|
|
268
|
+
*/
|
|
269
|
+
async function askAiNeedsQuiz(): Promise<AiNeeds | null> {
|
|
270
|
+
const use = await selectWithBack(
|
|
271
|
+
"What will you use local AI for most?",
|
|
272
|
+
[
|
|
273
|
+
{ value: "coding", label: "◆ Coding & agents", hint: "Qwen-family tends to win" },
|
|
274
|
+
{ value: "general", label: "◎ General chat", hint: "Gemma / Llama" },
|
|
275
|
+
{ value: "reasoning", label: "◉ Reasoning", hint: "DeepSeek R1-class" },
|
|
276
|
+
{ value: "balanced", label: "★ Not sure — balanced", hint: "safe default for most devs" },
|
|
277
|
+
],
|
|
278
|
+
"balanced",
|
|
279
|
+
false,
|
|
280
|
+
);
|
|
281
|
+
if (use === null || use === BACK) return null;
|
|
282
|
+
|
|
283
|
+
const priority = await selectWithBack(
|
|
284
|
+
"Speed or quality?",
|
|
285
|
+
[
|
|
286
|
+
{ value: "speed", label: "⚡ Speed", hint: "smaller model · 8GB-class" },
|
|
287
|
+
{ value: "balanced", label: "★ Balanced", hint: "best fit for 8–16GB machines" },
|
|
288
|
+
{ value: "quality", label: "◆ Quality", hint: "largest that still leaves RAM free" },
|
|
289
|
+
],
|
|
290
|
+
"balanced",
|
|
291
|
+
true,
|
|
292
|
+
);
|
|
293
|
+
if (priority === null) return null;
|
|
294
|
+
if (priority === BACK) return askAiNeedsQuiz();
|
|
295
|
+
|
|
296
|
+
const vision = await selectWithBack(
|
|
297
|
+
"Need a vision model (images)?",
|
|
298
|
+
[
|
|
299
|
+
{ value: "yes", label: "✓ Yes", hint: "adds a small VL model if RAM allows" },
|
|
300
|
+
{ value: "no", label: "✗ No", hint: "chat + embed only" },
|
|
301
|
+
],
|
|
302
|
+
"no",
|
|
303
|
+
true,
|
|
304
|
+
);
|
|
305
|
+
if (vision === null) return null;
|
|
306
|
+
if (vision === BACK) return askAiNeedsQuiz();
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
useCase: use as AiUseCase,
|
|
310
|
+
priority: priority as AiPriority,
|
|
311
|
+
wantVision: vision === "yes",
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Manual chat → vision → embed (models that fit listed first).
|
|
317
|
+
*
|
|
318
|
+
* @param detected - Detect result
|
|
319
|
+
* @param recommended - Recommended chat
|
|
320
|
+
* @param needs - Quiz answers (vision optional)
|
|
321
|
+
* @param ramGb - Machine RAM
|
|
322
|
+
* @param allowBack - Offer ← Back to mode menu
|
|
323
|
+
*/
|
|
324
|
+
async function askManualOllama(
|
|
325
|
+
detected: OllamaDetectResult,
|
|
326
|
+
recommended: CatalogModel,
|
|
327
|
+
needs: AiNeeds,
|
|
328
|
+
ramGb: number | null,
|
|
329
|
+
allowBack: boolean,
|
|
330
|
+
): Promise<AiSetupApplyInput | Back | null> {
|
|
331
|
+
const ordered = [
|
|
332
|
+
...fittingChatModels(ramGb),
|
|
333
|
+
...CHAT_MODELS.filter((m) => !modelFitsOnMachine(m, ramGb)),
|
|
334
|
+
];
|
|
335
|
+
const seen = new Set<string>();
|
|
336
|
+
const chatList = ordered.filter((m) => {
|
|
337
|
+
if (seen.has(m.id)) return false;
|
|
338
|
+
seen.add(m.id);
|
|
339
|
+
return true;
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
let chat = recommended.id;
|
|
343
|
+
let vision: string | null | undefined = needs.wantVision
|
|
344
|
+
? (recommendVisionForNeeds(ramGb, true)?.id ?? recommendForRole("vision").id)
|
|
345
|
+
: null;
|
|
346
|
+
|
|
347
|
+
while (true) {
|
|
348
|
+
const chatPick = await pickModel(
|
|
349
|
+
"Select Chat Model",
|
|
350
|
+
chatList,
|
|
351
|
+
chat,
|
|
352
|
+
false,
|
|
353
|
+
false,
|
|
354
|
+
allowBack,
|
|
355
|
+
ramGb,
|
|
356
|
+
);
|
|
357
|
+
if (chatPick === null) return null;
|
|
358
|
+
if (chatPick === BACK) return BACK;
|
|
359
|
+
if (typeof chatPick !== "string") continue;
|
|
360
|
+
chat = chatPick;
|
|
361
|
+
|
|
362
|
+
if (needs.wantVision) {
|
|
363
|
+
const visionPick = await pickModel(
|
|
364
|
+
"Vision Model",
|
|
365
|
+
VISION_MODELS,
|
|
366
|
+
vision ?? recommendForRole("vision").id,
|
|
367
|
+
false,
|
|
368
|
+
true,
|
|
369
|
+
true,
|
|
370
|
+
ramGb,
|
|
371
|
+
);
|
|
372
|
+
if (visionPick === null) return null;
|
|
373
|
+
if (visionPick === BACK) continue;
|
|
374
|
+
vision = visionPick === undefined ? null : visionPick;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const embedPick = await pickModel(
|
|
378
|
+
"Embedding Model",
|
|
379
|
+
EMBED_MODELS,
|
|
380
|
+
recommendForRole("embed").id,
|
|
381
|
+
false,
|
|
382
|
+
false,
|
|
383
|
+
true,
|
|
384
|
+
ramGb,
|
|
385
|
+
);
|
|
386
|
+
if (embedPick === null) return null;
|
|
387
|
+
if (embedPick === BACK) continue;
|
|
388
|
+
if (typeof embedPick !== "string") continue;
|
|
389
|
+
|
|
390
|
+
const needed = [chat, ...(typeof vision === "string" ? [vision] : []), embedPick].filter(
|
|
391
|
+
(id) => detected.available && !isInstalled(id, detected.installed),
|
|
392
|
+
);
|
|
393
|
+
if (needed.length > 0 && detected.available) {
|
|
394
|
+
const doPull = await selectWithBack(
|
|
395
|
+
`Download missing (${needed.join(", ")})?`,
|
|
396
|
+
[
|
|
397
|
+
{ value: "yes", label: "✓ Yes", hint: "pull size ≪ machine RAM" },
|
|
398
|
+
{ value: "no", label: "✗ No" },
|
|
399
|
+
],
|
|
400
|
+
"yes",
|
|
401
|
+
true,
|
|
402
|
+
);
|
|
403
|
+
if (doPull === null) return null;
|
|
404
|
+
if (doPull === BACK) continue;
|
|
405
|
+
if (doPull === "yes") await pullModels(needed);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
driver: "ollama",
|
|
410
|
+
baseUrl: detected.baseUrl,
|
|
411
|
+
chatModel: chat,
|
|
412
|
+
visionModel: typeof vision === "string" ? vision : null,
|
|
413
|
+
embedModel: embedPick,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Select with optional trailing ← Back.
|
|
420
|
+
*/
|
|
421
|
+
async function selectWithBack(
|
|
422
|
+
message: string,
|
|
423
|
+
options: readonly { value: string; label: string; hint?: string }[],
|
|
424
|
+
initialValue: string,
|
|
425
|
+
allowBack: boolean,
|
|
426
|
+
): Promise<string | Back | null> {
|
|
427
|
+
const list = [...options, ...(allowBack ? [{ value: BACK, label: "← Back" }] : [])];
|
|
428
|
+
const value = await select({ message, options: list, initialValue });
|
|
429
|
+
if (isCancel(value)) return null;
|
|
430
|
+
const picked = String(value);
|
|
431
|
+
if (picked === BACK) return BACK;
|
|
432
|
+
return picked;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Cloud / openai-compatible path — curated models + Other (type id).
|
|
437
|
+
*
|
|
438
|
+
* @param provider - Menu provider
|
|
439
|
+
*/
|
|
440
|
+
async function askCloudPath(provider: AiSetupProvider): Promise<AiSetupApplyInput | null> {
|
|
441
|
+
const meta = CLOUD_PROVIDERS.find((p) => p.value === provider);
|
|
442
|
+
const driver = meta?.driver ?? "openai-compatible";
|
|
443
|
+
|
|
444
|
+
let baseUrl: string | undefined = meta?.baseUrl;
|
|
445
|
+
if (provider === "custom" || provider === "gemini" || !baseUrl) {
|
|
446
|
+
const urlValue = await text({
|
|
447
|
+
message:
|
|
448
|
+
provider === "gemini"
|
|
449
|
+
? "OpenAI-compatible base URL for Gemini"
|
|
450
|
+
: "OpenAI-compatible base URL",
|
|
451
|
+
placeholder: baseUrl ?? "https://api.example.com/v1",
|
|
452
|
+
initialValue: baseUrl ?? "",
|
|
453
|
+
});
|
|
454
|
+
if (isCancel(urlValue)) return null;
|
|
455
|
+
const trimmed = String(urlValue).trim();
|
|
456
|
+
baseUrl = trimmed.length > 0 ? trimmed : undefined;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const models = cloudChatModels(provider);
|
|
460
|
+
const chat = await pickCloudModel("Select Chat Model", models, recommendCloudChat(provider));
|
|
461
|
+
if (chat === null) return null;
|
|
462
|
+
|
|
463
|
+
const apiKeyEnv = driver === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
driver,
|
|
467
|
+
...(baseUrl !== undefined ? { baseUrl } : {}),
|
|
468
|
+
chatModel: chat,
|
|
469
|
+
visionModel: null,
|
|
470
|
+
embedModel: null,
|
|
471
|
+
apiKeyEnv,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Format a catalog row — for Ollama, annotate fit vs too-large.
|
|
477
|
+
*
|
|
478
|
+
* @param m - Catalog / cloud model
|
|
479
|
+
* @param ramGb - Machine RAM (ollama only)
|
|
480
|
+
* @param recommendedId - Id marked "for you"
|
|
481
|
+
*/
|
|
482
|
+
function catalogOption(
|
|
483
|
+
m: CatalogModel | CloudModel,
|
|
484
|
+
ramGb: number | null = null,
|
|
485
|
+
recommendedId?: string,
|
|
486
|
+
): { value: string; label: string; hint: string } {
|
|
487
|
+
const isRec = m.recommended || m.id === recommendedId;
|
|
488
|
+
if ("ramGb" in m && typeof m.ramGb === "number") {
|
|
489
|
+
const onMachine = modelFitsOnMachine(m, ramGb);
|
|
490
|
+
const comfortable = modelFitsComfortably(m, ramGb);
|
|
491
|
+
const fitLabel = !onMachine
|
|
492
|
+
? "too large for this machine"
|
|
493
|
+
: comfortable
|
|
494
|
+
? "fits comfortably"
|
|
495
|
+
: "tight fit";
|
|
496
|
+
return {
|
|
497
|
+
value: m.id,
|
|
498
|
+
label: isRec ? `⭐ ${m.label} (for you)` : ` ${m.label}`,
|
|
499
|
+
hint: `${m.hint} · ${fitLabel}`,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
value: m.id,
|
|
504
|
+
label: isRec ? `⭐ ${m.label} (Recommended)` : ` ${m.label}`,
|
|
505
|
+
hint: m.hint,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const OTHER_MODEL = "__other__";
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Ask for a custom model id when the user picks Other.
|
|
513
|
+
*
|
|
514
|
+
* @param placeholder - Example id
|
|
515
|
+
*/
|
|
516
|
+
async function askOtherModelId(placeholder: string): Promise<string | null> {
|
|
517
|
+
const value = await text({
|
|
518
|
+
message: "Model id",
|
|
519
|
+
placeholder,
|
|
520
|
+
initialValue: "",
|
|
521
|
+
validate: (v) => {
|
|
522
|
+
if (!v?.trim()) return "Model id is required";
|
|
523
|
+
return undefined;
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
if (isCancel(value)) return null;
|
|
527
|
+
return String(value).trim();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Pick from a curated cloud list + Other (type any id).
|
|
532
|
+
*
|
|
533
|
+
* @param message - Prompt
|
|
534
|
+
* @param models - Curated list
|
|
535
|
+
* @param initial - Default id
|
|
536
|
+
*/
|
|
537
|
+
async function pickCloudModel(
|
|
538
|
+
message: string,
|
|
539
|
+
models: readonly CloudModel[],
|
|
540
|
+
initial: string,
|
|
541
|
+
): Promise<string | null> {
|
|
542
|
+
const options: { value: string; label: string; hint?: string }[] = [
|
|
543
|
+
...models.map((m) => catalogOption(m)),
|
|
544
|
+
{
|
|
545
|
+
value: OTHER_MODEL,
|
|
546
|
+
label: "⋯ Other…",
|
|
547
|
+
hint: "type any model id",
|
|
548
|
+
},
|
|
549
|
+
];
|
|
550
|
+
const initialValue = models.some((m) => m.id === initial) ? initial : OTHER_MODEL;
|
|
551
|
+
const value = await select({
|
|
552
|
+
message,
|
|
553
|
+
options,
|
|
554
|
+
initialValue,
|
|
555
|
+
});
|
|
556
|
+
if (isCancel(value)) return null;
|
|
557
|
+
const picked = String(value);
|
|
558
|
+
if (picked === OTHER_MODEL) {
|
|
559
|
+
return askOtherModelId(initial || "model-id");
|
|
560
|
+
}
|
|
561
|
+
return picked;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Pick from a curated list, with optional Show all + Skip + Other + Back.
|
|
566
|
+
*
|
|
567
|
+
* @returns model id, null cancel, undefined skip, BACK
|
|
568
|
+
*/
|
|
569
|
+
async function pickModel(
|
|
570
|
+
message: string,
|
|
571
|
+
models: readonly CatalogModel[],
|
|
572
|
+
initial: string,
|
|
573
|
+
showAllHint: boolean,
|
|
574
|
+
allowSkip = false,
|
|
575
|
+
allowBack = false,
|
|
576
|
+
ramGb: number | null = null,
|
|
577
|
+
): Promise<string | Back | null | undefined> {
|
|
578
|
+
const short = models.slice(0, 5);
|
|
579
|
+
const options: { value: string; label: string; hint?: string }[] = short.map((m) =>
|
|
580
|
+
catalogOption(m, ramGb, initial),
|
|
581
|
+
);
|
|
582
|
+
if (showAllHint && models.length > short.length) {
|
|
583
|
+
options.push({ value: "__all__", label: "⋯ Show all models", hint: "full curated catalog" });
|
|
584
|
+
}
|
|
585
|
+
options.push({
|
|
586
|
+
value: OTHER_MODEL,
|
|
587
|
+
label: "⋯ Other…",
|
|
588
|
+
hint: "type any model id",
|
|
589
|
+
});
|
|
590
|
+
if (allowSkip) {
|
|
591
|
+
options.push({ value: "__skip__", label: "⊘ Skip", hint: "no vision model" });
|
|
592
|
+
}
|
|
593
|
+
if (allowBack) {
|
|
594
|
+
options.push({ value: BACK, label: "← Back" });
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const value = await select({
|
|
598
|
+
message,
|
|
599
|
+
options,
|
|
600
|
+
initialValue: models.some((m) => m.id === initial) ? initial : short[0]?.id,
|
|
601
|
+
});
|
|
602
|
+
if (isCancel(value)) return null;
|
|
603
|
+
const picked = String(value);
|
|
604
|
+
if (picked === BACK) return BACK;
|
|
605
|
+
if (picked === "__skip__") return undefined;
|
|
606
|
+
if (picked === OTHER_MODEL) {
|
|
607
|
+
return askOtherModelId(initial);
|
|
608
|
+
}
|
|
609
|
+
if (picked === "__all__") {
|
|
610
|
+
const allOptions = [
|
|
611
|
+
...models.map((m) => catalogOption(m, ramGb, initial)),
|
|
612
|
+
{ value: OTHER_MODEL, label: "⋯ Other…", hint: "type any model id" },
|
|
613
|
+
...(allowSkip ? [{ value: "__skip__", label: "⊘ Skip", hint: "no vision model" }] : []),
|
|
614
|
+
...(allowBack ? [{ value: BACK, label: "← Back" }] : []),
|
|
615
|
+
];
|
|
616
|
+
const allValue = await select({
|
|
617
|
+
message,
|
|
618
|
+
options: allOptions,
|
|
619
|
+
initialValue: initial,
|
|
620
|
+
});
|
|
621
|
+
if (isCancel(allValue)) return null;
|
|
622
|
+
const allPicked = String(allValue);
|
|
623
|
+
if (allPicked === BACK) return BACK;
|
|
624
|
+
if (allPicked === "__skip__") return undefined;
|
|
625
|
+
if (allPicked === OTHER_MODEL) return askOtherModelId(initial);
|
|
626
|
+
return allPicked;
|
|
627
|
+
}
|
|
628
|
+
return picked;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Run `ollama pull` for each model with a spinner-friendly console log.
|
|
633
|
+
*
|
|
634
|
+
* @param models - Model ids
|
|
635
|
+
*/
|
|
636
|
+
async function pullModels(models: readonly string[]): Promise<void> {
|
|
637
|
+
for (const id of models) {
|
|
638
|
+
console.log(`ollama pull ${id}…`);
|
|
639
|
+
const proc = Bun.spawn(["ollama", "pull", id], {
|
|
640
|
+
stdout: "inherit",
|
|
641
|
+
stderr: "inherit",
|
|
642
|
+
stdin: "inherit",
|
|
643
|
+
});
|
|
644
|
+
const code = await proc.exited;
|
|
645
|
+
if (code !== 0) {
|
|
646
|
+
console.error(`oke ai setup: ollama pull ${id} failed (continuing)`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|