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.
- package/README.md +22 -20
- package/docs/CHANGELOG.md +43 -0
- package/frontend/src/App.tsx +80 -0
- package/frontend/src/api/client.ts +9 -0
- package/frontend/src/components/AdminAccessGate.tsx +70 -0
- package/frontend/src/components/BrainConversation.tsx +232 -8
- package/frontend/src/components/FeedbackState.tsx +45 -0
- package/frontend/src/components/WorkspaceProfileSwitcher.tsx +176 -0
- package/frontend/src/components/onboarding/AnalysisScreen.tsx +20 -12
- package/frontend/src/components/onboarding/InstallScreen.tsx +55 -0
- package/frontend/src/components/onboarding/RecommendationScreen.tsx +55 -5
- package/frontend/src/components/onboarding/recommendationModel.ts +59 -2
- package/frontend/src/features/admin/AdminConsole.tsx +41 -1
- package/frontend/src/features/brain/BrainConversation.tsx +232 -19
- package/frontend/src/features/brain/BrainGraphLayer.tsx +258 -25
- package/frontend/src/features/brain/BrainHome.tsx +193 -1
- package/frontend/src/features/brain/brainData.ts +25 -1
- package/frontend/src/features/brain/graphLayout.ts +12 -1
- package/frontend/src/features/brain/types.ts +41 -0
- package/frontend/src/i18n.ts +328 -0
- package/frontend/src/store/appStore.ts +15 -0
- package/frontend/src/styles.css +1175 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/runtime/agent_runtime.py +51 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agents.py +12 -0
- package/latticeai/api/tools.py +14 -0
- package/latticeai/api/workspace.py +59 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/tool_registry.py +47 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/services/tool_dispatch.py +14 -0
- package/package.json +1 -1
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +28 -28
- package/static/app/assets/Act-Di4tRFWY.js +2 -0
- package/static/app/assets/{Act-DhLaHnrT.js.map → Act-Di4tRFWY.js.map} +1 -1
- package/static/app/assets/Brain-BZB3Gy9w.js +322 -0
- package/static/app/assets/Brain-BZB3Gy9w.js.map +1 -0
- package/static/app/assets/{Capture-CAKouj52.js → Capture-tNyYWxnh.js} +2 -2
- package/static/app/assets/{Capture-CAKouj52.js.map → Capture-tNyYWxnh.js.map} +1 -1
- package/static/app/assets/Library-DAtDDLdg.js +2 -0
- package/static/app/assets/{Library-B_hW4AwR.js.map → Library-DAtDDLdg.js.map} +1 -1
- package/static/app/assets/System-DEu0xNUc.js +2 -0
- package/static/app/assets/System-DEu0xNUc.js.map +1 -0
- package/static/app/assets/{index-DKFpn5Kn.css → index-Bi_bpigM.css} +1 -1
- package/static/app/assets/index-COuGp7_5.js +17 -0
- package/static/app/assets/index-COuGp7_5.js.map +1 -0
- package/static/app/assets/primitives-CdwcE--L.js +2 -0
- package/static/app/assets/primitives-CdwcE--L.js.map +1 -0
- package/static/app/assets/textarea-CqOdBPL1.js +2 -0
- package/static/app/assets/{textarea-DR2Tyy_6.js.map → textarea-CqOdBPL1.js.map} +1 -1
- package/static/app/index.html +2 -2
- package/static/app/assets/Act-DhLaHnrT.js +0 -2
- package/static/app/assets/Brain-DS7NULyY.js +0 -322
- package/static/app/assets/Brain-DS7NULyY.js.map +0 -1
- package/static/app/assets/Library-B_hW4AwR.js +0 -2
- package/static/app/assets/System-9OAoXWPz.js +0 -2
- package/static/app/assets/System-9OAoXWPz.js.map +0 -1
- package/static/app/assets/index-C-7aHabu.js +0 -17
- package/static/app/assets/index-C-7aHabu.js.map +0 -1
- package/static/app/assets/primitives-DcO2H3yh.js +0 -2
- package/static/app/assets/primitives-DcO2H3yh.js.map +0 -1
- 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-
|
|
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.
|
|
61
|
-
{ label: t(language, "flow.analysis.
|
|
62
|
-
{ label: t(language, "flow.analysis.
|
|
63
|
-
{ label: t(language, "flow.analysis.
|
|
64
|
-
{ label: t(language, "flow.analysis.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
27
|
-
{
|
|
28
|
-
|
|
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">
|
|
40
|
-
|
|
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
|
|
102
|
-
downloadSize
|
|
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,
|