ltcai 4.5.1 → 4.6.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.
@@ -0,0 +1,596 @@
1
+ import * as React from "react";
2
+ import { CheckCircle2, ChevronRight, Cpu, Download, LockKeyhole, MonitorCog, Sparkles } from "lucide-react";
3
+ import { latticeApi } from "@/api/client";
4
+ import { LivingBrain } from "@/components/LivingBrain";
5
+ import { Badge } from "@/components/ui/badge";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Input } from "@/components/ui/input";
8
+ import { cn, asArray } from "@/lib/utils";
9
+
10
+ const FLOW_COMPLETE_KEY = "lattice.productFlow.complete";
11
+ const FLOW_USER_KEY = "lattice.productFlow.user";
12
+
13
+ type FlowStep = "login" | "analysis" | "recommend" | "install";
14
+ type ApiData = Record<string, unknown>;
15
+
16
+ type FlowAnalysis = {
17
+ setup?: ApiData | null;
18
+ models?: ApiData | null;
19
+ recommendations?: ApiData | null;
20
+ sysinfo?: ApiData | null;
21
+ };
22
+
23
+ type RecommendedModel = {
24
+ id: string;
25
+ loadId: string;
26
+ engine: string;
27
+ name: string;
28
+ shortName: string;
29
+ family: string;
30
+ size: string;
31
+ role: "best" | "faster" | "advanced";
32
+ reason: string;
33
+ supported: boolean;
34
+ downloadRequired: boolean;
35
+ };
36
+
37
+ type InstallStage = "idle" | "install" | "download" | "validate" | "load" | "done" | "error";
38
+
39
+ export function readProductFlowComplete() {
40
+ try {
41
+ return localStorage.getItem(FLOW_COMPLETE_KEY) === "true";
42
+ } catch {}
43
+ return false;
44
+ }
45
+
46
+ export function ProductFlow({ onComplete }: { onComplete: () => void }) {
47
+ const [step, setStep] = React.useState<FlowStep>("login");
48
+ const [analysis, setAnalysis] = React.useState<FlowAnalysis | null>(null);
49
+ const [analysisError, setAnalysisError] = React.useState<string | null>(null);
50
+ const [selected, setSelected] = React.useState<RecommendedModel | null>(null);
51
+
52
+ const recommendations = React.useMemo(() => buildRecommendations(analysis), [analysis]);
53
+
54
+ React.useEffect(() => {
55
+ if (step !== "analysis" || analysis) return;
56
+ let cancelled = false;
57
+ async function runAnalysis() {
58
+ setAnalysisError(null);
59
+ const [setup, recommendationsResult, models, sysinfo] = await Promise.all([
60
+ latticeApi.setupScan(),
61
+ latticeApi.modelRecommendations("local_mlx"),
62
+ latticeApi.models(),
63
+ latticeApi.sysinfo(),
64
+ ]);
65
+ if (cancelled) return;
66
+ setAnalysis({
67
+ setup: setup.ok ? setup.data as ApiData : null,
68
+ recommendations: recommendationsResult.ok ? recommendationsResult.data as ApiData : null,
69
+ models: models.ok ? models.data as ApiData : null,
70
+ sysinfo: sysinfo.ok ? sysinfo.data as ApiData : null,
71
+ });
72
+ if (!setup.ok && !recommendationsResult.ok && !models.ok) {
73
+ setAnalysisError("Lattice could not finish reading this computer. You can still continue with a safe default.");
74
+ }
75
+ }
76
+ void runAnalysis();
77
+ return () => { cancelled = true; };
78
+ }, [analysis, step]);
79
+
80
+ return (
81
+ <main className="product-flow-shell" aria-label="Lattice first run">
82
+ <div className="product-flow-orbit" aria-hidden="true" />
83
+ {step === "login" ? (
84
+ <LoginScreen onSuccess={() => setStep("analysis")} />
85
+ ) : null}
86
+ {step === "analysis" ? (
87
+ <AnalysisScreen
88
+ analysis={analysis}
89
+ error={analysisError}
90
+ onContinue={() => setStep("recommend")}
91
+ />
92
+ ) : null}
93
+ {step === "recommend" ? (
94
+ <RecommendationScreen
95
+ recommendations={recommendations}
96
+ onBack={() => setStep("analysis")}
97
+ onSelect={(model) => {
98
+ setSelected(model);
99
+ setStep("install");
100
+ }}
101
+ />
102
+ ) : null}
103
+ {step === "install" ? (
104
+ <InstallScreen
105
+ model={selected || recommendations[0] || fallbackModel()}
106
+ onBack={() => setStep("recommend")}
107
+ onComplete={() => {
108
+ try { localStorage.setItem(FLOW_COMPLETE_KEY, "true"); } catch {}
109
+ onComplete();
110
+ }}
111
+ />
112
+ ) : null}
113
+ </main>
114
+ );
115
+ }
116
+
117
+ function LoginScreen({ onSuccess }: { onSuccess: () => void }) {
118
+ const [email, setEmail] = React.useState(() => {
119
+ try {
120
+ const saved = localStorage.getItem(FLOW_USER_KEY);
121
+ return saved ? JSON.parse(saved).email || "you@local" : "you@local";
122
+ } catch {
123
+ return "you@local";
124
+ }
125
+ });
126
+ const [password, setPassword] = React.useState("");
127
+ const [name, setName] = React.useState("You");
128
+ const [busy, setBusy] = React.useState(false);
129
+ const [error, setError] = React.useState<string | null>(null);
130
+
131
+ async function submit(event: React.FormEvent) {
132
+ event.preventDefault();
133
+ setBusy(true);
134
+ setError(null);
135
+ const safePassword = password || "Lattice123";
136
+ let result = await latticeApi.login(email, safePassword);
137
+ if (!result.ok) {
138
+ const registered = await latticeApi.register({
139
+ email,
140
+ password: safePassword,
141
+ name: name || email.split("@")[0] || "You",
142
+ nickname: name || "You",
143
+ });
144
+ if (registered.ok) result = await latticeApi.login(email, safePassword);
145
+ }
146
+ if (!result.ok) {
147
+ const profile = await latticeApi.profile();
148
+ if (!profile.ok) {
149
+ setBusy(false);
150
+ setError("We could not open your local profile. Check your password or create the first local account.");
151
+ return;
152
+ }
153
+ }
154
+ try { localStorage.setItem(FLOW_USER_KEY, JSON.stringify({ email, name })); } catch {}
155
+ setBusy(false);
156
+ onSuccess();
157
+ }
158
+
159
+ return (
160
+ <section className="login-screen" aria-label="Login">
161
+ <div className="login-mark" aria-hidden="true"><LockKeyhole className="h-5 w-5" /></div>
162
+ <div className="login-card">
163
+ <div>
164
+ <div className="login-kicker">Lattice AI</div>
165
+ <h1>Enter your Brain.</h1>
166
+ <p>Your private workspace starts with a local profile.</p>
167
+ </div>
168
+ <form className="login-form" onSubmit={submit}>
169
+ <label>
170
+ <span>Name</span>
171
+ <Input value={name} onChange={(event) => setName(event.target.value)} autoComplete="name" />
172
+ </label>
173
+ <label>
174
+ <span>Email</span>
175
+ <Input value={email} onChange={(event) => setEmail(event.target.value)} type="email" autoComplete="email" />
176
+ </label>
177
+ <label>
178
+ <span>Password</span>
179
+ <Input value={password} onChange={(event) => setPassword(event.target.value)} type="password" autoComplete="current-password" placeholder="Use your local password" />
180
+ </label>
181
+ {error ? <div className="flow-error">{error}</div> : null}
182
+ <Button className="login-submit" type="submit" disabled={busy || !email.trim()}>
183
+ {busy ? "Opening" : "Continue"} <ChevronRight className="h-4 w-4" />
184
+ </Button>
185
+ </form>
186
+ </div>
187
+ </section>
188
+ );
189
+ }
190
+
191
+ function AnalysisScreen({
192
+ analysis,
193
+ error,
194
+ onContinue,
195
+ }: {
196
+ analysis: FlowAnalysis | null;
197
+ error: string | null;
198
+ onContinue: () => void;
199
+ }) {
200
+ const detected = buildDetectedFacts(analysis);
201
+ return (
202
+ <section className="flow-panel analysis-screen" aria-label="Environment Analysis">
203
+ <div className="flow-panel-head">
204
+ <div>
205
+ <div className="flow-kicker"><MonitorCog className="h-4 w-4" /> Environment Analysis</div>
206
+ <h1>Learning what your computer can do.</h1>
207
+ <p>Lattice checks the essentials, then recommends the best local Brain for this machine.</p>
208
+ </div>
209
+ <Badge variant={analysis ? "success" : "muted"}>{analysis ? "complete" : "analyzing"}</Badge>
210
+ </div>
211
+ <div className="analysis-grid">
212
+ {detected.map((item) => (
213
+ <div key={item.label} className="analysis-fact">
214
+ <span>{item.label}</span>
215
+ <strong>{item.value}</strong>
216
+ <small>{item.detail}</small>
217
+ </div>
218
+ ))}
219
+ </div>
220
+ <div className="recommendation-callout">
221
+ <Sparkles className="h-5 w-5" />
222
+ <div>
223
+ <strong>{analysis ? recommendedSummary(analysis) : "Recommendation is being prepared."}</strong>
224
+ <span>{analysis ? "You will choose from a short, ranked list next." : "This usually takes a moment."}</span>
225
+ </div>
226
+ </div>
227
+ {error ? <div className="flow-error">{error}</div> : null}
228
+ <div className="flow-actions">
229
+ <Button onClick={onContinue} disabled={!analysis && !error}>See recommended models</Button>
230
+ </div>
231
+ </section>
232
+ );
233
+ }
234
+
235
+ function RecommendationScreen({
236
+ recommendations,
237
+ onBack,
238
+ onSelect,
239
+ }: {
240
+ recommendations: RecommendedModel[];
241
+ onBack: () => void;
242
+ onSelect: (model: RecommendedModel) => void;
243
+ }) {
244
+ const items = recommendations.length ? recommendations : [fallbackModel()];
245
+ return (
246
+ <section className="flow-panel recommendation-screen" aria-label="Recommended Models">
247
+ <div className="flow-panel-head">
248
+ <div>
249
+ <div className="flow-kicker"><Cpu className="h-4 w-4" /> Recommended Models</div>
250
+ <h1>Recommended for your computer.</h1>
251
+ <p>A short list, ranked for this Mac. No catalog digging required.</p>
252
+ </div>
253
+ </div>
254
+ <div className="model-recommendation-list">
255
+ {items.slice(0, 3).map((model, index) => (
256
+ <button
257
+ key={`${model.role}-${model.id}`}
258
+ className={cn("model-recommendation-card", model.role)}
259
+ onClick={() => onSelect(model)}
260
+ disabled={!model.supported}
261
+ >
262
+ <span className="model-rank">{rankLabel(model.role, index)}</span>
263
+ <span>
264
+ <strong>{model.shortName}</strong>
265
+ <small>{model.reason}</small>
266
+ </span>
267
+ <Badge variant={model.supported ? "success" : "warning"}>{model.supported ? model.size || "ready" : "needs update"}</Badge>
268
+ </button>
269
+ ))}
270
+ </div>
271
+ <div className="flow-actions split">
272
+ <Button variant="ghost" onClick={onBack}>Back</Button>
273
+ <span>Choose one recommendation to continue.</span>
274
+ </div>
275
+ </section>
276
+ );
277
+ }
278
+
279
+ function InstallScreen({
280
+ model,
281
+ onBack,
282
+ onComplete,
283
+ }: {
284
+ model: RecommendedModel;
285
+ onBack: () => void;
286
+ onComplete: () => void;
287
+ }) {
288
+ const [busy, setBusy] = React.useState(false);
289
+ const [stage, setStage] = React.useState<InstallStage>("idle");
290
+ const [percent, setPercent] = React.useState(0);
291
+ const [message, setMessage] = React.useState("Ready when you are.");
292
+ const [error, setError] = React.useState<string | null>(null);
293
+
294
+ async function start() {
295
+ setBusy(true);
296
+ setError(null);
297
+ setStage("install");
298
+ setPercent(8);
299
+ setMessage("Preparing the Brain.");
300
+ const result = await latticeApi.streamModelPrepare(
301
+ { model: model.loadId, engine: model.engine || "local_mlx", allow_download: true },
302
+ {
303
+ onProgress: (event) => {
304
+ const nextStage = friendlyInstallStage(String(event.stage || ""));
305
+ setStage(nextStage);
306
+ setPercent(Number(event.percent || percentForStage(nextStage)));
307
+ setMessage(friendlyInstallMessage(event, nextStage));
308
+ },
309
+ onDone: () => {
310
+ setStage("done");
311
+ setPercent(100);
312
+ setMessage("Your Brain is ready.");
313
+ },
314
+ onError: (event) => {
315
+ setStage("error");
316
+ setError(consumerError(event));
317
+ },
318
+ },
319
+ );
320
+ setBusy(false);
321
+ if (result.ok) {
322
+ setStage("done");
323
+ setPercent(100);
324
+ setMessage("Your Brain is ready.");
325
+ window.setTimeout(onComplete, 700);
326
+ } else {
327
+ setStage("error");
328
+ setError(consumerError(result.data as ApiData));
329
+ }
330
+ }
331
+
332
+ return (
333
+ <section className="flow-panel install-screen" aria-label="Install and Load">
334
+ <div className="install-hero">
335
+ <LivingBrain activity={busy ? "thinking" : stage === "done" ? "listening" : "idle"} compact />
336
+ <div>
337
+ <div className="flow-kicker"><Download className="h-4 w-4" /> Install & Load</div>
338
+ <h1>{model.shortName}</h1>
339
+ <p>Lattice will install, download, validate, and load the selected Brain.</p>
340
+ </div>
341
+ </div>
342
+ <div className="install-steps">
343
+ {(["install", "download", "validate", "load"] as const).map((item) => (
344
+ <div key={item} className={cn("install-step", installStepState(stage, item))}>
345
+ <CheckCircle2 className="h-4 w-4" />
346
+ <span>{installLabel(item)}</span>
347
+ </div>
348
+ ))}
349
+ </div>
350
+ <div className="install-progress">
351
+ <div>
352
+ <strong>{message}</strong>
353
+ <span>{stage === "error" ? "We will explain what to try next." : `${Math.round(percent)}%`}</span>
354
+ </div>
355
+ <div className="install-bar"><span style={{ width: `${Math.max(0, Math.min(100, percent))}%` }} /></div>
356
+ </div>
357
+ {error ? <div className="flow-error">{error}</div> : null}
358
+ <div className="flow-actions split">
359
+ <Button variant="ghost" onClick={onBack} disabled={busy}>Back</Button>
360
+ <Button onClick={stage === "done" ? onComplete : start} disabled={busy || !model.supported}>
361
+ {stage === "done" ? "Enter Brain" : busy ? "Loading" : "Install & Load"}
362
+ </Button>
363
+ </div>
364
+ </section>
365
+ );
366
+ }
367
+
368
+ function buildDetectedFacts(analysis: FlowAnalysis | null) {
369
+ if (!analysis) {
370
+ return [
371
+ { label: "Computer", value: "Checking", detail: "Operating system and chip" },
372
+ { label: "Memory", value: "Checking", detail: "Available room for local thinking" },
373
+ { label: "Graphics", value: "Checking", detail: "Local acceleration support" },
374
+ { label: "Local Support", value: "Checking", detail: "Installed model helpers" },
375
+ { label: "Models", value: "Checking", detail: "Installed local Brains" },
376
+ ];
377
+ }
378
+ const setupEnv = asRecord(analysis.setup?.environment);
379
+ const recProfile = asRecord(analysis.recommendations?.profile);
380
+ const recs = asRecord(analysis.recommendations?.recommendations);
381
+ const models = asRecord(analysis.models);
382
+ const sysinfo = asRecord(analysis.sysinfo);
383
+ const profile = { ...setupEnv, ...recProfile };
384
+ const ramGb = Number(recs.ram_gb || Number(profile.ram_mb || 0) / 1024 || 0);
385
+ const appleSilicon = Boolean(recs.apple_silicon || String(profile.arch || "").includes("arm"));
386
+ const loadedModels = asArray(models.loaded);
387
+ const gpu = asRecord(profile.gpu);
388
+ const installedRuntimes = [
389
+ ...asArray(setupEnv.installed_runtimes),
390
+ ...asArray(profile.installed_runtimes),
391
+ ...asArray(recs.installed_runtimes),
392
+ ];
393
+ return [
394
+ {
395
+ label: "Computer",
396
+ value: appleSilicon ? "Apple Silicon Mac" : friendlyOs(profile.os),
397
+ detail: "Ready for local AI workspace use",
398
+ },
399
+ {
400
+ label: "Memory",
401
+ value: ramGb ? `${Math.round(ramGb)} GB` : "Detected",
402
+ detail: "Enough context for the recommended model",
403
+ },
404
+ {
405
+ label: "Graphics",
406
+ value: gpu.vendor || sysinfo.gpu_mem_gb ? "Local acceleration ready" : "Standard local mode",
407
+ detail: "Lattice will choose the best available path",
408
+ },
409
+ {
410
+ label: "Local Support",
411
+ value: installedRuntimes.length ? "Ready" : "Will be prepared",
412
+ detail: installedRuntimes.length ? "Installed model helpers detected" : "Lattice will add what is needed",
413
+ },
414
+ {
415
+ label: "Models",
416
+ value: loadedModels.length ? `${loadedModels.length} already installed` : "None installed yet",
417
+ detail: loadedModels.length ? "One can be loaded immediately" : "Lattice will guide the first install",
418
+ },
419
+ ];
420
+ }
421
+
422
+ function buildRecommendations(analysis: FlowAnalysis | null): RecommendedModel[] {
423
+ const models = asRecord(analysis?.models);
424
+ const modelRows = [
425
+ ...asArray<ApiData>(models.recommended),
426
+ ...asArray<ApiData>(models.catalog),
427
+ ];
428
+ const recommendationRoot = asRecord(analysis?.recommendations?.recommendations);
429
+ const recRows = asArray<ApiData>(recommendationRoot.models);
430
+ const topPick = asRecord(recommendationRoot.top_pick);
431
+ const merged = new Map<string, ApiData>();
432
+ for (const row of [...recRows, ...modelRows]) {
433
+ const id = String(row.id || row.model_id || row.recommended_load_id || "");
434
+ if (!id) continue;
435
+ merged.set(id, { ...(merged.get(id) || {}), ...row });
436
+ }
437
+ if (topPick.id && !merged.has(String(topPick.id))) merged.set(String(topPick.id), topPick);
438
+ const all = Array.from(merged.values()).map(toRecommendedModel).filter((item) => item.id);
439
+ const supported = all.filter((item) => item.supported);
440
+ const pool = supported.length ? supported : all;
441
+ const byName = (pattern: RegExp) => pool.find((item) => pattern.test(`${item.name} ${item.id}`));
442
+ const byId = (id?: unknown) => pool.find((item) => item.id === String(id));
443
+ const best = byId(topPick.id) || byName(/gemma.*12|12b/i) || pool[0];
444
+ const faster = pool.find((item) => item.id !== best?.id && /qwen|8b|7b/i.test(`${item.name} ${item.id}`)) || pool.find((item) => item.id !== best?.id);
445
+ const advanced = pool.find((item) => item.id !== best?.id && item.id !== faster?.id && /26b|32b|70b|advanced/i.test(`${item.name} ${item.id}`))
446
+ || pool.find((item) => item.id !== best?.id && item.id !== faster?.id);
447
+ return [
448
+ best ? { ...best, role: "best" as const, reason: "Best Experience" } : null,
449
+ faster ? { ...faster, role: "faster" as const, reason: "Faster" } : null,
450
+ advanced ? { ...advanced, role: "advanced" as const, reason: "Advanced" } : null,
451
+ ].filter(Boolean) as RecommendedModel[];
452
+ }
453
+
454
+ function toRecommendedModel(row: ApiData): RecommendedModel {
455
+ const compatibility = asRecord(row.runtime_compatibility);
456
+ const id = String(row.id || row.model_id || row.recommended_load_id || "");
457
+ const loadId = String(row.recommended_load_id || row.load_id || id);
458
+ const name = String(row.display_name || row.name || id || "Recommended Brain");
459
+ const supported = row.load_status !== "unsupported"
460
+ && row.load_status !== "runtime_update_needed"
461
+ && row.status !== "not_recommended"
462
+ && compatibility.supported !== false;
463
+ return {
464
+ id,
465
+ loadId,
466
+ engine: String(row.recommended_engine || row.engine || "local_mlx"),
467
+ name,
468
+ shortName: friendlyModelName(name || id),
469
+ family: friendlyModelName(String(row.family || name || "Local Brain")),
470
+ size: String(row.size || ""),
471
+ role: "best",
472
+ reason: String(row.reason || "Recommended"),
473
+ supported,
474
+ downloadRequired: Boolean(row.download_required),
475
+ };
476
+ }
477
+
478
+ function fallbackModel(): RecommendedModel {
479
+ return {
480
+ id: "mlx-community/Qwen3-VL-8B-Instruct-4bit",
481
+ loadId: "mlx-community/Qwen3-VL-8B-Instruct-4bit",
482
+ engine: "local_mlx",
483
+ name: "Qwen3-VL 8B",
484
+ shortName: "Qwen 3",
485
+ family: "Qwen 3",
486
+ size: "ready",
487
+ role: "best",
488
+ reason: "Best Experience",
489
+ supported: true,
490
+ downloadRequired: false,
491
+ };
492
+ }
493
+
494
+ function rankLabel(role: RecommendedModel["role"], index: number) {
495
+ if (role === "best") return "Best Experience";
496
+ if (role === "faster") return "Faster";
497
+ if (role === "advanced") return "Advanced";
498
+ return `Choice ${index + 1}`;
499
+ }
500
+
501
+ function recommendedSummary(analysis: FlowAnalysis) {
502
+ const recs = asRecord(analysis.recommendations?.recommendations);
503
+ const topPick = asRecord(recs.top_pick);
504
+ if (topPick.name || topPick.id) return `${friendlyModelName(String(topPick.name || topPick.id))} looks like the best fit.`;
505
+ return "A private local Brain is recommended for this computer.";
506
+ }
507
+
508
+ function friendlyModelName(value: string) {
509
+ return String(value || "Recommended Brain")
510
+ .replace(/^mlx-community\//i, "")
511
+ .replace(/[-_]?Instruct/gi, "")
512
+ .replace(/[-_]?4bit/gi, "")
513
+ .replace(/Qwen3[-_ ]?VL/gi, "Qwen 3")
514
+ .replace(/Qwen3/gi, "Qwen 3")
515
+ .replace(/Gemma[-_ ]?4/gi, "Gemma 4")
516
+ .replace(/A4B/gi, "")
517
+ .replace(/[-_]+/g, " ")
518
+ .replace(/\s+/g, " ")
519
+ .trim();
520
+ }
521
+
522
+ function friendlyOs(value: unknown) {
523
+ const text = String(value || "Computer");
524
+ if (/darwin|mac/i.test(text)) return "Mac";
525
+ if (/win/i.test(text)) return "Windows PC";
526
+ if (/linux/i.test(text)) return "Linux computer";
527
+ return "Computer";
528
+ }
529
+
530
+ function friendlyInstallStage(stage: string): InstallStage {
531
+ if (/download|pull|weights/i.test(stage)) return "download";
532
+ if (/smoke|validate|verify|test/i.test(stage)) return "validate";
533
+ if (/load|ready/i.test(stage)) return "load";
534
+ if (/done|complete/i.test(stage)) return "done";
535
+ return "install";
536
+ }
537
+
538
+ function percentForStage(stage: InstallStage) {
539
+ if (stage === "install") return 20;
540
+ if (stage === "download") return 55;
541
+ if (stage === "validate") return 82;
542
+ if (stage === "load") return 94;
543
+ if (stage === "done") return 100;
544
+ return 8;
545
+ }
546
+
547
+ function friendlyInstallMessage(event: ApiData, stage: InstallStage) {
548
+ const fallback = {
549
+ install: "Preparing the Brain.",
550
+ download: "Getting the model files.",
551
+ validate: "Checking that the Brain can answer.",
552
+ load: "Loading the Brain.",
553
+ done: "Your Brain is ready.",
554
+ idle: "Ready when you are.",
555
+ error: "Something needs attention.",
556
+ }[stage];
557
+ return cleanConsumerText(String(event.user_message || event.message || fallback));
558
+ }
559
+
560
+ function installLabel(stage: "install" | "download" | "validate" | "load") {
561
+ if (stage === "install") return "Install";
562
+ if (stage === "download") return "Download";
563
+ if (stage === "validate") return "Validate";
564
+ return "Load";
565
+ }
566
+
567
+ function installStepState(current: InstallStage, item: "install" | "download" | "validate" | "load") {
568
+ const order: InstallStage[] = ["idle", "install", "download", "validate", "load", "done"];
569
+ const currentIndex = order.indexOf(current);
570
+ const itemIndex = order.indexOf(item);
571
+ if (current === "error") return "is-error";
572
+ if (current === "done" || currentIndex > itemIndex) return "is-done";
573
+ if (current === item) return "is-active";
574
+ return "";
575
+ }
576
+
577
+ function consumerError(data: ApiData | unknown) {
578
+ const record = asRecord(data);
579
+ const guidance = asArray<string>(record.recovery_guidance).map(cleanConsumerText).filter(Boolean);
580
+ const message = cleanConsumerText(String(record.user_message || record.reason || record.error || "The selected model could not be loaded."));
581
+ return [message, ...guidance.slice(0, 2)].join(" ");
582
+ }
583
+
584
+ function cleanConsumerText(value: string) {
585
+ return String(value || "")
586
+ .replace(/gemma4_unified/gi, "this model format")
587
+ .replace(/mlx[-_ ]?vlm|mlx[-_ ]?lm|local_mlx|\bmlx\b|\bgguf\b|\bollama\b|huggingface|hugging face/gi, "local model support")
588
+ .replace(/runtime/gi, "model support")
589
+ .replace(/No module named ['\"][^'\"]+['\"]/gi, "A local support component is missing")
590
+ .replace(/\s+/g, " ")
591
+ .trim();
592
+ }
593
+
594
+ function asRecord(value: unknown): ApiData {
595
+ return value && typeof value === "object" && !Array.isArray(value) ? value as ApiData : {};
596
+ }