ltcai 7.0.0 → 7.2.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.
Files changed (67) hide show
  1. package/README.md +22 -20
  2. package/docs/CHANGELOG.md +43 -0
  3. package/frontend/src/App.tsx +80 -0
  4. package/frontend/src/api/client.ts +9 -0
  5. package/frontend/src/components/AdminAccessGate.tsx +70 -0
  6. package/frontend/src/components/BrainConversation.tsx +232 -8
  7. package/frontend/src/components/FeedbackState.tsx +45 -0
  8. package/frontend/src/components/WorkspaceProfileSwitcher.tsx +176 -0
  9. package/frontend/src/components/onboarding/AnalysisScreen.tsx +20 -12
  10. package/frontend/src/components/onboarding/InstallScreen.tsx +55 -0
  11. package/frontend/src/components/onboarding/RecommendationScreen.tsx +55 -5
  12. package/frontend/src/components/onboarding/recommendationModel.ts +59 -2
  13. package/frontend/src/features/admin/AdminConsole.tsx +41 -1
  14. package/frontend/src/features/brain/BrainConversation.tsx +232 -19
  15. package/frontend/src/features/brain/BrainGraphLayer.tsx +258 -25
  16. package/frontend/src/features/brain/BrainHome.tsx +193 -1
  17. package/frontend/src/features/brain/brainData.ts +25 -1
  18. package/frontend/src/features/brain/graphLayout.ts +12 -1
  19. package/frontend/src/features/brain/types.ts +41 -0
  20. package/frontend/src/i18n.ts +328 -0
  21. package/frontend/src/store/appStore.ts +15 -0
  22. package/frontend/src/styles.css +1175 -0
  23. package/lattice_brain/__init__.py +1 -1
  24. package/lattice_brain/runtime/agent_runtime.py +51 -0
  25. package/lattice_brain/runtime/multi_agent.py +1 -1
  26. package/latticeai/__init__.py +1 -1
  27. package/latticeai/api/agents.py +12 -0
  28. package/latticeai/api/tools.py +14 -0
  29. package/latticeai/api/workspace.py +59 -0
  30. package/latticeai/core/marketplace.py +1 -1
  31. package/latticeai/core/tool_registry.py +47 -0
  32. package/latticeai/core/workspace_os.py +1 -1
  33. package/latticeai/services/tool_dispatch.py +14 -0
  34. package/package.json +1 -1
  35. package/src-tauri/Cargo.lock +1 -1
  36. package/src-tauri/Cargo.toml +1 -1
  37. package/src-tauri/tauri.conf.json +1 -1
  38. package/static/app/asset-manifest.json +28 -28
  39. package/static/app/assets/Act-Di4tRFWY.js +2 -0
  40. package/static/app/assets/{Act-DhLaHnrT.js.map → Act-Di4tRFWY.js.map} +1 -1
  41. package/static/app/assets/Brain-BZB3Gy9w.js +322 -0
  42. package/static/app/assets/Brain-BZB3Gy9w.js.map +1 -0
  43. package/static/app/assets/{Capture-CAKouj52.js → Capture-tNyYWxnh.js} +2 -2
  44. package/static/app/assets/{Capture-CAKouj52.js.map → Capture-tNyYWxnh.js.map} +1 -1
  45. package/static/app/assets/Library-DAtDDLdg.js +2 -0
  46. package/static/app/assets/{Library-B_hW4AwR.js.map → Library-DAtDDLdg.js.map} +1 -1
  47. package/static/app/assets/System-DEu0xNUc.js +2 -0
  48. package/static/app/assets/System-DEu0xNUc.js.map +1 -0
  49. package/static/app/assets/{index-DKFpn5Kn.css → index-Bi_bpigM.css} +1 -1
  50. package/static/app/assets/index-COuGp7_5.js +17 -0
  51. package/static/app/assets/index-COuGp7_5.js.map +1 -0
  52. package/static/app/assets/primitives-CdwcE--L.js +2 -0
  53. package/static/app/assets/primitives-CdwcE--L.js.map +1 -0
  54. package/static/app/assets/textarea-CqOdBPL1.js +2 -0
  55. package/static/app/assets/{textarea-DR2Tyy_6.js.map → textarea-CqOdBPL1.js.map} +1 -1
  56. package/static/app/index.html +2 -2
  57. package/static/app/assets/Act-DhLaHnrT.js +0 -2
  58. package/static/app/assets/Brain-DS7NULyY.js +0 -322
  59. package/static/app/assets/Brain-DS7NULyY.js.map +0 -1
  60. package/static/app/assets/Library-B_hW4AwR.js +0 -2
  61. package/static/app/assets/System-9OAoXWPz.js +0 -2
  62. package/static/app/assets/System-9OAoXWPz.js.map +0 -1
  63. package/static/app/assets/index-C-7aHabu.js +0 -17
  64. package/static/app/assets/index-C-7aHabu.js.map +0 -1
  65. package/static/app/assets/primitives-DcO2H3yh.js +0 -2
  66. package/static/app/assets/primitives-DcO2H3yh.js.map +0 -1
  67. package/static/app/assets/textarea-DR2Tyy_6.js +0 -2
@@ -0,0 +1,45 @@
1
+ import * as React from "react";
2
+ import { AlertTriangle, Inbox, RotateCcw } from "lucide-react";
3
+ import { t, type Language } from "@/i18n";
4
+
5
+ export type FeedbackTone = "empty" | "error";
6
+
7
+ /**
8
+ * Shared empty / error feedback surface so every view tells the user what just
9
+ * happened and what to do next, instead of a blank or silent failure.
10
+ */
11
+ export function FeedbackState({
12
+ tone,
13
+ language,
14
+ title,
15
+ body,
16
+ actionLabel,
17
+ onAction,
18
+ }: {
19
+ tone: FeedbackTone;
20
+ language: Language;
21
+ title: string;
22
+ body?: string;
23
+ actionLabel?: string;
24
+ onAction?: () => void;
25
+ }) {
26
+ const isError = tone === "error";
27
+ const resolvedActionLabel = actionLabel || (isError ? t(language, "feedback.retry") : undefined);
28
+ return (
29
+ <div className={`feedback-state is-${tone}`} role={isError ? "alert" : "status"}>
30
+ <span className="feedback-state-icon" aria-hidden="true">
31
+ {isError ? <AlertTriangle className="h-4 w-4" /> : <Inbox className="h-4 w-4" />}
32
+ </span>
33
+ <div className="feedback-state-body">
34
+ <strong>{title}</strong>
35
+ {body ? <span>{body}</span> : null}
36
+ </div>
37
+ {resolvedActionLabel && onAction ? (
38
+ <button type="button" className="feedback-state-action" onClick={onAction}>
39
+ {isError ? <RotateCcw className="h-3.5 w-3.5" /> : null}
40
+ {resolvedActionLabel}
41
+ </button>
42
+ ) : null}
43
+ </div>
44
+ );
45
+ }
@@ -0,0 +1,176 @@
1
+ import * as React from "react";
2
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { Check, ChevronDown, UserCircle, Building2, ArrowRightLeft } from "lucide-react";
4
+ import { latticeApi } from "@/api/client";
5
+ import { t, type Language } from "@/i18n";
6
+ import { useAppStore } from "@/store/appStore";
7
+ import { asArray } from "@/lib/utils";
8
+
9
+ type WorkspaceRow = Record<string, unknown>;
10
+
11
+ function workspaceId(row: WorkspaceRow): string {
12
+ return String(row.workspace_id || row.id || "");
13
+ }
14
+
15
+ function workspaceName(language: Language, row: WorkspaceRow): string {
16
+ const name = row.name;
17
+ if (typeof name === "string" && name.trim()) return name;
18
+ const id = workspaceId(row);
19
+ return id || t(language, "shell.workspace.personal");
20
+ }
21
+
22
+ function ownerEmail(profile: unknown): string | null {
23
+ if (!profile || typeof profile !== "object") return null;
24
+ const record = profile as Record<string, unknown>;
25
+ const candidate = record.email || record.owner_email || record.username || record.name;
26
+ return typeof candidate === "string" && candidate.trim() ? candidate : null;
27
+ }
28
+
29
+ /**
30
+ * Discoverable workspace + profile switcher for the Brain shell.
31
+ * Surfaces the active workspace and signed-in owner, and lets the user
32
+ * switch workspace or jump to account/workspace settings without hunting
33
+ * through the Settings tabs.
34
+ */
35
+ export function WorkspaceProfileSwitcher({ language }: { language: Language }) {
36
+ const qc = useQueryClient();
37
+ const workspaceIdState = useAppStore((state) => state.workspaceId);
38
+ const setWorkspaceId = useAppStore((state) => state.setWorkspaceId);
39
+ const [open, setOpen] = React.useState(false);
40
+ const containerRef = React.useRef<HTMLDivElement | null>(null);
41
+
42
+ const profile = useQuery({ queryKey: ["profile"], queryFn: latticeApi.profile });
43
+ const registry = useQuery({ queryKey: ["workspaceRegistry"], queryFn: latticeApi.workspaceRegistry });
44
+
45
+ const workspaces = asArray<WorkspaceRow>(
46
+ (registry.data?.data as Record<string, unknown> | undefined)?.workspaces,
47
+ );
48
+ const owner = ownerEmail(profile.data?.data);
49
+
50
+ const activeWorkspace = workspaces.find((row) => workspaceId(row) === workspaceIdState);
51
+ const activeLabel = activeWorkspace
52
+ ? workspaceName(language, activeWorkspace)
53
+ : workspaceIdState || t(language, "shell.workspace.personal");
54
+
55
+ React.useEffect(() => {
56
+ if (!open) return;
57
+ const onPointer = (event: MouseEvent) => {
58
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
59
+ setOpen(false);
60
+ }
61
+ };
62
+ const onKey = (event: KeyboardEvent) => {
63
+ if (event.key === "Escape") setOpen(false);
64
+ };
65
+ window.addEventListener("mousedown", onPointer);
66
+ window.addEventListener("keydown", onKey);
67
+ return () => {
68
+ window.removeEventListener("mousedown", onPointer);
69
+ window.removeEventListener("keydown", onKey);
70
+ };
71
+ }, [open]);
72
+
73
+ const handleSwitch = (id: string) => {
74
+ if (id && id !== workspaceIdState) {
75
+ setWorkspaceId(id);
76
+ // Workspace scoping changes server responses; drop cached views.
77
+ qc.invalidateQueries();
78
+ }
79
+ setOpen(false);
80
+ };
81
+
82
+ return (
83
+ <div className="workspace-profile-switcher" ref={containerRef}>
84
+ <button
85
+ type="button"
86
+ className="workspace-profile-trigger"
87
+ aria-haspopup="dialog"
88
+ aria-expanded={open}
89
+ aria-label={t(language, "shell.workspace.label")}
90
+ onClick={() => setOpen((value) => !value)}
91
+ >
92
+ <Building2 className="h-3.5 w-3.5" aria-hidden="true" />
93
+ <span className="workspace-profile-trigger-text">
94
+ <span className="workspace-profile-trigger-name">{activeLabel}</span>
95
+ <span className="workspace-profile-trigger-owner">{owner || t(language, "shell.profile.signedOut")}</span>
96
+ </span>
97
+ <ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
98
+ </button>
99
+
100
+ {open ? (
101
+ <div className="workspace-profile-popover" role="dialog" aria-label={t(language, "shell.workspace.label")}>
102
+ <section className="workspace-profile-section" aria-label={t(language, "shell.profile.label")}>
103
+ <div className="workspace-profile-section-head">
104
+ <UserCircle className="h-3.5 w-3.5" aria-hidden="true" />
105
+ <span>{t(language, "shell.profile.label")}</span>
106
+ </div>
107
+ <div className="workspace-profile-owner-row">
108
+ <span className="workspace-profile-owner-label">{t(language, "shell.profile.owner")}</span>
109
+ <span className="workspace-profile-owner-value">{owner || t(language, "shell.profile.signedOut")}</span>
110
+ </div>
111
+ <button
112
+ type="button"
113
+ className="workspace-profile-link"
114
+ onClick={() => {
115
+ setOpen(false);
116
+ window.location.hash = "/account";
117
+ }}
118
+ >
119
+ {t(language, "shell.profile.manage")}
120
+ </button>
121
+ </section>
122
+
123
+ <section className="workspace-profile-section" aria-label={t(language, "shell.workspace.current")}>
124
+ <div className="workspace-profile-section-head">
125
+ <Building2 className="h-3.5 w-3.5" aria-hidden="true" />
126
+ <span>{t(language, "shell.workspace.current")}</span>
127
+ </div>
128
+ {workspaces.length === 0 ? (
129
+ <p className="workspace-profile-empty">{t(language, "shell.workspace.empty")}</p>
130
+ ) : (
131
+ <ul className="workspace-profile-list">
132
+ {workspaces.map((row) => {
133
+ const id = workspaceId(row);
134
+ const isActive = id === workspaceIdState;
135
+ return (
136
+ <li key={id}>
137
+ <button
138
+ type="button"
139
+ className={`workspace-profile-item ${isActive ? "is-active" : ""}`}
140
+ aria-current={isActive ? "true" : undefined}
141
+ onClick={() => handleSwitch(id)}
142
+ >
143
+ <span className="workspace-profile-item-name">{workspaceName(language, row)}</span>
144
+ {isActive ? (
145
+ <span className="workspace-profile-item-flag">
146
+ <Check className="h-3 w-3" aria-hidden="true" />
147
+ {t(language, "shell.workspace.active")}
148
+ </span>
149
+ ) : (
150
+ <span className="workspace-profile-item-switch">
151
+ <ArrowRightLeft className="h-3 w-3" aria-hidden="true" />
152
+ {t(language, "shell.workspace.switch")}
153
+ </span>
154
+ )}
155
+ </button>
156
+ </li>
157
+ );
158
+ })}
159
+ </ul>
160
+ )}
161
+ <button
162
+ type="button"
163
+ className="workspace-profile-link"
164
+ onClick={() => {
165
+ setOpen(false);
166
+ window.location.hash = "/workspace-admin";
167
+ }}
168
+ >
169
+ {t(language, "shell.workspace.manageSpaces")}
170
+ </button>
171
+ </section>
172
+ </div>
173
+ ) : null}
174
+ </div>
175
+ );
176
+ }
@@ -23,8 +23,11 @@ export function AnalysisScreen({
23
23
 
24
24
  <div className="ritual-fact-grid">
25
25
  {detected.map((item, idx) => (
26
- <div key={idx} className="ritual-fact">
27
- <div className="ritual-fact-label">{item.label}</div>
26
+ <div key={idx} className="ritual-fact ritual-fact--visual">
27
+ <div className="ritual-fact-head">
28
+ <span className="ritual-hardware-icon" aria-hidden="true">{item.icon}</span>
29
+ <div className="ritual-fact-label">{item.label}</div>
30
+ </div>
28
31
  <div className="ritual-fact-value">{item.value}</div>
29
32
  <div className="ritual-fact-detail">{item.detail}</div>
30
33
  </div>
@@ -57,11 +60,11 @@ export function AnalysisScreen({
57
60
  function buildDetectedFacts(analysis: FlowAnalysis | null, language: Language) {
58
61
  if (!analysis) {
59
62
  return [
60
- { label: t(language, "flow.analysis.fact.computer"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.computerDetail") },
61
- { label: t(language, "flow.analysis.fact.memory"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.memoryDetail") },
62
- { label: t(language, "flow.analysis.fact.graphics"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.graphicsDetail") },
63
- { label: t(language, "flow.analysis.fact.support"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.supportDetail") },
64
- { label: t(language, "flow.analysis.fact.models"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.modelsDetail") },
63
+ { icon: t(language, "flow.analysis.hardware.icon.chip"), label: t(language, "flow.analysis.hardware.label.chip"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.computerDetail") },
64
+ { icon: t(language, "flow.analysis.hardware.icon.ram"), label: t(language, "flow.analysis.hardware.label.ram"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.memoryDetail") },
65
+ { icon: t(language, "flow.analysis.hardware.icon.gpu"), label: t(language, "flow.analysis.hardware.label.gpu"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.graphicsDetail") },
66
+ { icon: t(language, "flow.analysis.hardware.icon.support"), label: t(language, "flow.analysis.hardware.label.support"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.supportDetail") },
67
+ { icon: t(language, "flow.analysis.hardware.icon.models"), label: t(language, "flow.analysis.hardware.label.models"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.modelsDetail") },
65
68
  ];
66
69
  }
67
70
  const setupEnv = asRecord(analysis.setup?.environment);
@@ -81,27 +84,32 @@ function buildDetectedFacts(analysis: FlowAnalysis | null, language: Language) {
81
84
  ];
82
85
  return [
83
86
  {
84
- label: t(language, "flow.analysis.fact.computer"),
87
+ icon: t(language, "flow.analysis.hardware.icon.chip"),
88
+ label: t(language, "flow.analysis.hardware.label.chip"),
85
89
  value: appleSilicon ? t(language, "flow.analysis.apple") : friendlyOs(profile.os, language),
86
90
  detail: t(language, "flow.analysis.readyDetail"),
87
91
  },
88
92
  {
89
- label: t(language, "flow.analysis.fact.memory"),
93
+ icon: t(language, "flow.analysis.hardware.icon.ram"),
94
+ label: t(language, "flow.analysis.hardware.label.ram"),
90
95
  value: ramGb ? `${Math.round(ramGb)} GB` : t(language, "flow.analysis.detected"),
91
96
  detail: t(language, "flow.analysis.memoryReadyDetail"),
92
97
  },
93
98
  {
94
- label: t(language, "flow.analysis.fact.graphics"),
99
+ icon: t(language, "flow.analysis.hardware.icon.gpu"),
100
+ label: t(language, "flow.analysis.hardware.label.gpu"),
95
101
  value: gpu.vendor || sysinfo.gpu_mem_gb ? t(language, "flow.analysis.localReady") : t(language, "flow.analysis.standardLocal"),
96
102
  detail: t(language, "flow.analysis.graphicsReadyDetail"),
97
103
  },
98
104
  {
99
- label: t(language, "flow.analysis.fact.support"),
105
+ icon: t(language, "flow.analysis.hardware.icon.support"),
106
+ label: t(language, "flow.analysis.hardware.label.support"),
100
107
  value: installedRuntimes.length ? t(language, "flow.analysis.supportReady") : t(language, "flow.analysis.supportInstall"),
101
108
  detail: installedRuntimes.length ? t(language, "flow.analysis.supportReadyDetail") : t(language, "flow.analysis.supportInstallDetail"),
102
109
  },
103
110
  {
104
- label: t(language, "flow.analysis.fact.models"),
111
+ icon: t(language, "flow.analysis.hardware.icon.models"),
112
+ label: t(language, "flow.analysis.hardware.label.models"),
105
113
  value: loadedModels.length ? t(language, "flow.analysis.modelsInstalled", { count: loadedModels.length }) : t(language, "flow.analysis.noModels"),
106
114
  detail: loadedModels.length ? t(language, "flow.analysis.modelsReadyDetail") : t(language, "flow.analysis.modelsInstallDetail"),
107
115
  },
@@ -81,6 +81,20 @@ export function InstallScreen({
81
81
  {t(language, "flow.install.body")}
82
82
  </div>
83
83
 
84
+ <section className="ritual-card ritual-expected-card" aria-label={t(language, "flow.install.expectedTitle")}>
85
+ <div className="ritual-fact-label">{t(language, "flow.install.expectedTitle")}</div>
86
+ <div className="ritual-time-estimate ritual-expected-line">{expectedLine(model, language)}</div>
87
+ <ol className="ritual-timeline">
88
+ {timelineSteps(model, language).map((step) => (
89
+ <li key={step.key} className="ritual-timeline-step">
90
+ <span className="ritual-timeline-name">{step.name}</span>
91
+ <span className="ritual-timeline-time">{step.time}</span>
92
+ </li>
93
+ ))}
94
+ </ol>
95
+ <div className="ritual-muted-hint">{t(language, "flow.install.expectedCompletion")}</div>
96
+ </section>
97
+
84
98
  <div className="ritual-install-brain">
85
99
  <LivingBrain
86
100
  state={brainStateForStage}
@@ -141,6 +155,47 @@ export function InstallScreen({
141
155
  );
142
156
  }
143
157
 
158
+ function expectedLine(model: RecommendedModel, language: Language) {
159
+ const response = model.estimatedFirstResponseSeconds;
160
+ if (!model.downloadRequired || model.estimatedDownloadMinutes === 0) {
161
+ return t(language, "flow.install.expected.ready", { response });
162
+ }
163
+ if (model.estimatedDownloadMinutes === null) {
164
+ return t(language, "flow.install.expected.unknown", { response });
165
+ }
166
+ return t(language, "flow.install.expected", {
167
+ download: t(language, "flow.recommend.minutes", { count: model.estimatedDownloadMinutes }),
168
+ response,
169
+ });
170
+ }
171
+
172
+ function timelineSteps(model: RecommendedModel, language: Language) {
173
+ const downloadTime = model.downloadRequired && model.estimatedDownloadMinutes
174
+ ? t(language, "flow.install.timelineApprox", {
175
+ time: t(language, "flow.recommend.minutes", { count: model.estimatedDownloadMinutes }),
176
+ })
177
+ : t(language, "flow.install.timelineQuick");
178
+ return [
179
+ {
180
+ key: "download",
181
+ name: t(language, "flow.install.timelineStep.download"),
182
+ time: downloadTime,
183
+ },
184
+ {
185
+ key: "validate",
186
+ name: t(language, "flow.install.timelineStep.validate"),
187
+ time: t(language, "flow.install.timelineQuick"),
188
+ },
189
+ {
190
+ key: "load",
191
+ name: t(language, "flow.install.timelineStep.load"),
192
+ time: t(language, "flow.install.timelineApprox", {
193
+ time: `${model.estimatedFirstResponseSeconds}s`,
194
+ }),
195
+ },
196
+ ];
197
+ }
198
+
144
199
  function friendlyInstallStage(stage: string): InstallStage {
145
200
  if (/download|pull|weights/i.test(stage)) return "download";
146
201
  if (/smoke|validate|verify|test/i.test(stage)) return "validate";
@@ -23,9 +23,13 @@ export function RecommendationScreen({
23
23
 
24
24
  <div className="ritual-model-list">
25
25
  {items[0]?.supported ? (
26
- <Button onClick={() => onSelect(items[0])} className="ritual-primary-model-button">
27
- {t(language, "flow.recommend.primary")}
28
- </Button>
26
+ <div className="ritual-primary-cta">
27
+ <Button onClick={() => onSelect(items[0])} className="ritual-primary-model-button">
28
+ {t(language, "flow.recommend.primary")}
29
+ </Button>
30
+ <div className="ritual-time-estimate ritual-primary-note">{primaryNote(items[0], language)}</div>
31
+ <div className="ritual-muted-hint ritual-next-hint">{t(language, "flow.recommend.nextHint")}</div>
32
+ </div>
29
33
  ) : null}
30
34
  {items.slice(0, 3).map((model, index) => (
31
35
  <button
@@ -36,10 +40,23 @@ export function RecommendationScreen({
36
40
  >
37
41
  <div className="role">{rankLabel(model.role, index, language)}</div>
38
42
  <div className="name">{model.shortName}</div>
39
- <div className="reason">{model.reason} · {model.size || t(language, "flow.recommend.sizeReady")}</div>
40
- {!model.supported && <div className="ritual-model-warning">{t(language, "flow.recommend.unsupported")}</div>}
43
+ <div className="reason">
44
+ {model.reason} · {model.size || t(language, "flow.recommend.sizeReady")}
45
+ {comparisonLabel(model.role, language) ? (
46
+ <span className="ritual-model-comparison"> {comparisonLabel(model.role, language)}</span>
47
+ ) : null}
48
+ </div>
49
+ <div className="ritual-model-stats">
50
+ <span className="ritual-time-estimate">{timeEstimate(model, language)}</span>
51
+ </div>
52
+ {model.supported ? (
53
+ <span className="ritual-model-choose">{t(language, "flow.recommend.choose")}</span>
54
+ ) : (
55
+ <div className="ritual-model-warning">{t(language, "flow.recommend.unsupported")}</div>
56
+ )}
41
57
  </button>
42
58
  ))}
59
+ <div className="ritual-time-note">{t(language, "flow.recommend.timeNote")}</div>
43
60
  </div>
44
61
 
45
62
  <div className="ritual-action-row">
@@ -51,6 +68,39 @@ export function RecommendationScreen({
51
68
  );
52
69
  }
53
70
 
71
+ function formatMinutes(minutes: number, language: Language) {
72
+ return t(language, "flow.recommend.minutes", { count: minutes });
73
+ }
74
+
75
+ function timeEstimate(model: RecommendedModel, language: Language) {
76
+ const response = model.estimatedFirstResponseSeconds;
77
+ if (!model.downloadRequired || model.estimatedDownloadMinutes === 0) {
78
+ return t(language, "flow.recommend.timeEstimate.ready", { response });
79
+ }
80
+ if (model.estimatedDownloadMinutes === null) {
81
+ return t(language, "flow.recommend.timeEstimate.unknown", { response });
82
+ }
83
+ return t(language, "flow.recommend.timeEstimate", {
84
+ download: formatMinutes(model.estimatedDownloadMinutes, language),
85
+ response,
86
+ });
87
+ }
88
+
89
+ function primaryNote(model: RecommendedModel, language: Language) {
90
+ if (!model.downloadRequired || model.estimatedDownloadMinutes === 0 || model.estimatedDownloadMinutes === null) {
91
+ return t(language, "flow.recommend.primaryNote.unknown");
92
+ }
93
+ return t(language, "flow.recommend.primaryNote", {
94
+ time: formatMinutes(model.estimatedDownloadMinutes, language),
95
+ });
96
+ }
97
+
98
+ function comparisonLabel(role: RecommendedModel["role"], language: Language) {
99
+ if (role === "faster") return t(language, "flow.recommend.comparison.faster");
100
+ if (role === "advanced") return t(language, "flow.recommend.comparison.advanced");
101
+ return "";
102
+ }
103
+
54
104
  function rankLabel(role: RecommendedModel["role"], index: number, language: Language) {
55
105
  if (role === "best") return t(language, "flow.recommend.rank.best");
56
106
  if (role === "faster") return t(language, "flow.recommend.rank.faster");
@@ -25,6 +25,12 @@ export type RecommendedModel = {
25
25
  downloadSize: string;
26
26
  storageLocation: string;
27
27
  externalHost: string;
28
+ /** Estimated download time in minutes. 0 when nothing needs downloading; null when size is unknown. */
29
+ estimatedDownloadMinutes: number | null;
30
+ /** Estimated seconds to first response after the model is loaded. */
31
+ estimatedFirstResponseSeconds: number;
32
+ /** Rough parameter scale (e.g. 8, 12, 70) parsed from the model name; null when unknown. */
33
+ parameterBillions: number | null;
28
34
  };
29
35
 
30
36
  export function buildRecommendations(analysis: FlowAnalysis | null): RecommendedModel[] {
@@ -75,6 +81,9 @@ export function fallbackModel(): RecommendedModel {
75
81
  downloadSize: "",
76
82
  storageLocation: "~/.latticeai/models",
77
83
  externalHost: "",
84
+ estimatedDownloadMinutes: 0,
85
+ estimatedFirstResponseSeconds: 5,
86
+ parameterBillions: 8,
78
87
  };
79
88
  }
80
89
 
@@ -87,6 +96,9 @@ function toRecommendedModel(row: ApiData): RecommendedModel {
87
96
  && row.load_status !== "runtime_update_needed"
88
97
  && row.status !== "not_recommended"
89
98
  && compatibility.supported !== false;
99
+ const downloadRequired = Boolean(row.download_required);
100
+ const downloadSize = String(row.download_size || row.size || "");
101
+ const parameterBillions = parseParameterBillions(`${name} ${id}`);
90
102
  return {
91
103
  id,
92
104
  loadId,
@@ -98,13 +110,58 @@ function toRecommendedModel(row: ApiData): RecommendedModel {
98
110
  role: "best",
99
111
  reason: String(row.reason || ""),
100
112
  supported,
101
- downloadRequired: Boolean(row.download_required),
102
- downloadSize: String(row.download_size || row.size || ""),
113
+ downloadRequired,
114
+ downloadSize,
103
115
  storageLocation: String(row.storage_location || row.local_path || "~/.latticeai/models"),
104
116
  externalHost: externalHostLabel(row),
117
+ estimatedDownloadMinutes: downloadRequired ? estimateDownloadMinutes(downloadSize) : 0,
118
+ estimatedFirstResponseSeconds: estimateFirstResponseSeconds(parameterBillions),
119
+ parameterBillions,
105
120
  };
106
121
  }
107
122
 
123
+ /** Parse a download size string like "8GB", "8.5 GB", "512MB" into megabytes; null when unparseable. */
124
+ export function parseDownloadMegabytes(value: string): number | null {
125
+ const match = String(value || "").match(/([\d.]+)\s*(t|g|m)?b?/i);
126
+ if (!match) return null;
127
+ const amount = Number(match[1]);
128
+ if (!Number.isFinite(amount) || amount <= 0) return null;
129
+ const unit = (match[2] || "g").toLowerCase();
130
+ if (unit === "t") return amount * 1024 * 1024;
131
+ if (unit === "m") return amount;
132
+ return amount * 1024; // gigabytes (default assumption for model weights)
133
+ }
134
+
135
+ /**
136
+ * Estimate download minutes from a size string, assuming a conservative ~15 Mbps
137
+ * (≈1.875 MB/s) average home connection. Returns null when the size is unknown so
138
+ * the UI can fall back to a "time unknown" message instead of showing 0.
139
+ */
140
+ export function estimateDownloadMinutes(downloadSize: string): number | null {
141
+ const megabytes = parseDownloadMegabytes(downloadSize);
142
+ if (megabytes === null) return null;
143
+ const megabytesPerSecond = 15 / 8; // 15 Mbps
144
+ const minutes = megabytes / megabytesPerSecond / 60;
145
+ return Math.max(1, Math.round(minutes));
146
+ }
147
+
148
+ /** Pull a rough parameter scale (billions) out of a model name like "Gemma 12B" or "qwen-8b". */
149
+ export function parseParameterBillions(text: string): number | null {
150
+ const match = String(text || "").match(/(\d{1,3}(?:\.\d)?)\s*b\b/i);
151
+ if (!match) return null;
152
+ const value = Number(match[1]);
153
+ return Number.isFinite(value) && value > 0 ? value : null;
154
+ }
155
+
156
+ /** Estimate seconds to first response from model scale: 8B→5s, 12B→10s, 70B→30s. */
157
+ export function estimateFirstResponseSeconds(parameterBillions: number | null): number {
158
+ if (parameterBillions === null) return 8;
159
+ if (parameterBillions <= 9) return 5;
160
+ if (parameterBillions <= 16) return 10;
161
+ if (parameterBillions <= 40) return 18;
162
+ return 30;
163
+ }
164
+
108
165
  function externalHostLabel(row: ApiData) {
109
166
  const raw = String(row.source_url || row.download_url || row.repository || row.provider || row.id || "");
110
167
  if (!raw) return "";
@@ -15,7 +15,7 @@ export function AdminConsole({ onBack }: { onBack: () => void }) {
15
15
  const qc = useQueryClient();
16
16
  const language = useAppStore((state) => state.language);
17
17
  const [filters, setFilters] = React.useState<AdminFilterState>({ q: "", actor: "", action: "", severity: "", limit: 50 });
18
- const { summaryQ, statsQ, usersQ, auditQ, securityQ, securityEventsQ, policiesQ, rolesQ, retentionQ, indexQ } = useAdminConsoleData(filters);
18
+ const { summaryQ, statsQ, usersQ, auditQ, securityQ, securityEventsQ, policiesQ, rolesQ, retentionQ, indexQ, agentRuntimeQ, toolRegistryQ } = useAdminConsoleData(filters);
19
19
  const rebuildIndex = useMutation({
20
20
  mutationFn: latticeApi.rebuildIndex,
21
21
  onSuccess: () => void qc.invalidateQueries({ queryKey: ["indexStatus"] }),
@@ -139,6 +139,10 @@ export function AdminConsole({ onBack }: { onBack: () => void }) {
139
139
  </div>
140
140
  </AdminPanel>
141
141
 
142
+ <AdminPanel title={t(language, "admin.panel.runtimeTrust")} eyebrow={t(language, "admin.panel.contracts")}>
143
+ <RuntimeTrustPanel runtime={agentRuntimeQ.data?.data as ApiRecord | undefined} registry={toolRegistryQ.data?.data as ApiRecord | undefined} language={language} />
144
+ </AdminPanel>
145
+
142
146
  </section>
143
147
  </main>
144
148
  );
@@ -164,9 +168,45 @@ function useAdminConsoleData(filters: AdminFilterState) {
164
168
  rolesQ: useQuery({ queryKey: ["adminRoles"], queryFn: latticeApi.adminRoles }),
165
169
  retentionQ: useQuery({ queryKey: ["adminLogRetention"], queryFn: latticeApi.adminLogRetention }),
166
170
  indexQ: useQuery({ queryKey: ["indexStatus"], queryFn: latticeApi.indexStatus }),
171
+ agentRuntimeQ: useQuery({ queryKey: ["agentRuntime"], queryFn: latticeApi.agentRuntime }),
172
+ toolRegistryQ: useQuery({ queryKey: ["toolRegistryDiagnostics"], queryFn: latticeApi.toolRegistryDiagnostics }),
167
173
  };
168
174
  }
169
175
 
176
+ function RuntimeTrustPanel({ runtime, registry, language }: { runtime?: ApiRecord; registry?: ApiRecord; language: "ko" | "en" }) {
177
+ const runtimeInfo = (runtime?.runtime || {}) as ApiRecord;
178
+ const health = (runtime?.health || {}) as ApiRecord;
179
+ const diagnostics = (registry?.diagnostics || {}) as ApiRecord;
180
+ const ready = Boolean(runtimeInfo.ready);
181
+ const registryReady = Boolean(diagnostics.ready);
182
+ const blocking = stringValue(runtimeInfo.unavailable_reason, ready ? t(language, "admin.runtime.readyDetail") : t(language, "admin.runtime.blockedFallback"));
183
+ return (
184
+ <div className="admin-runtime-trust">
185
+ <div className="admin-runtime-row">
186
+ <strong>{t(language, "admin.runtime.agent")}</strong>
187
+ <span className={ready ? "is-ok" : "is-warn"}>{ready ? t(language, "admin.status.ready") : t(language, "admin.status.unavailable")}</span>
188
+ <small>{blocking}</small>
189
+ </div>
190
+ <div className="admin-runtime-row">
191
+ <strong>{t(language, "admin.runtime.tools")}</strong>
192
+ <span className={registryReady ? "is-ok" : "is-warn"}>{registryReady ? t(language, "admin.runtime.aligned") : t(language, "admin.runtime.drift")}</span>
193
+ <small>
194
+ {t(language, "admin.runtime.toolCounts", {
195
+ registered: stringValue(diagnostics.registered_tools, "0"),
196
+ governed: stringValue(diagnostics.governed_tools, "0"),
197
+ described: stringValue(diagnostics.described_tools, "0"),
198
+ })}
199
+ </small>
200
+ </div>
201
+ <div className="admin-policy-strip">
202
+ <span>{t(language, "admin.runtime.mode", { mode: stringValue(runtimeInfo.mode, "unknown") })}</span>
203
+ <span>{t(language, "admin.runtime.execution", { mode: stringValue(runtimeInfo.execution_mode, "unknown") })}</span>
204
+ <span>{t(language, "admin.runtime.health", { status: stringValue(health.status, "unknown") })}</span>
205
+ </div>
206
+ </div>
207
+ );
208
+ }
209
+
170
210
  function AdminLogFilters({
171
211
  filters,
172
212
  matched,