ltcai 6.2.0 → 6.3.1
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 +29 -17
- package/docs/CHANGELOG.md +66 -0
- package/frontend/src/components/onboarding/AnalysisScreen.tsx +127 -0
- package/frontend/src/components/onboarding/DownloadConsentPanel.tsx +29 -0
- package/frontend/src/components/onboarding/InstallScreen.tsx +208 -0
- package/frontend/src/components/onboarding/LanguageChooser.tsx +23 -0
- package/frontend/src/components/onboarding/LoginScreen.tsx +131 -0
- package/frontend/src/components/onboarding/ProductFlowScreens.tsx +13 -688
- package/frontend/src/components/onboarding/RecommendationScreen.tsx +59 -0
- package/frontend/src/components/onboarding/recommendationModel.ts +132 -0
- package/frontend/src/features/admin/AdminConsole.tsx +1 -1
- package/frontend/src/features/brain/BrainCarePanel.tsx +254 -0
- package/frontend/src/features/brain/BrainComposer.tsx +66 -0
- package/frontend/src/features/brain/BrainConversation.tsx +131 -0
- package/frontend/src/features/brain/BrainGraphLayer.tsx +132 -0
- package/frontend/src/features/brain/BrainHome.tsx +28 -795
- package/frontend/src/features/brain/BrainMemoryLayer.tsx +38 -0
- package/frontend/src/features/brain/BrainOverviewPanel.tsx +74 -0
- package/frontend/src/features/brain/BrainRelationshipLayer.tsx +43 -0
- package/frontend/src/features/brain/DepthEmergence.tsx +53 -0
- package/frontend/src/features/brain/types.ts +6 -6
- package/frontend/src/features/review/ReviewCard.tsx +14 -10
- package/frontend/src/features/review/ReviewInbox.tsx +24 -9
- package/frontend/src/i18n.ts +208 -0
- package/frontend/src/pages/Brain.tsx +63 -5
- package/frontend/src/pages/Capture.tsx +139 -43
- package/frontend/src/pages/Library.tsx +72 -6
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/app_factory.py +48 -106
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/access_runtime.py +97 -0
- package/latticeai/runtime/chat_wiring.py +119 -0
- package/latticeai/runtime/model_wiring.py +40 -0
- package/latticeai/runtime/platform_runtime_wiring.py +2 -3
- package/latticeai/runtime/review_wiring.py +37 -0
- package/latticeai/runtime/tail_wiring.py +21 -0
- package/package.json +2 -2
- package/scripts/check_i18n_literals.mjs +52 -0
- 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 +5 -5
- package/static/app/assets/index-Div5vMlq.css +2 -0
- package/static/app/assets/{index-D91Rz5--.js → index-t1jx1BR9.js} +2 -2
- package/static/app/assets/index-t1jx1BR9.js.map +1 -0
- package/static/app/index.html +2 -2
- package/static/app/assets/index-B2-1Gm0q.css +0 -2
- package/static/app/assets/index-D91Rz5--.js.map +0 -1
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Button } from "@/components/ui/button";
|
|
2
|
+
import { t, type Language } from "@/i18n";
|
|
3
|
+
import { useAppStore } from "@/store/appStore";
|
|
4
|
+
import { fallbackModel, type RecommendedModel } from "./recommendationModel";
|
|
5
|
+
|
|
6
|
+
export function RecommendationScreen({
|
|
7
|
+
recommendations,
|
|
8
|
+
onBack,
|
|
9
|
+
onSkipModel,
|
|
10
|
+
onSelect,
|
|
11
|
+
}: {
|
|
12
|
+
recommendations: RecommendedModel[];
|
|
13
|
+
onBack: () => void;
|
|
14
|
+
onSkipModel: () => void;
|
|
15
|
+
onSelect: (model: RecommendedModel) => void;
|
|
16
|
+
}) {
|
|
17
|
+
const language = useAppStore((state) => state.language);
|
|
18
|
+
const items = recommendations.length ? recommendations : [fallbackModel()];
|
|
19
|
+
return (
|
|
20
|
+
<div>
|
|
21
|
+
<div className="ritual-title">{t(language, "flow.recommend.title")}</div>
|
|
22
|
+
<div className="ritual-subtitle">{t(language, "flow.recommend.body")}</div>
|
|
23
|
+
|
|
24
|
+
<div className="ritual-model-list">
|
|
25
|
+
{items[0]?.supported ? (
|
|
26
|
+
<Button onClick={() => onSelect(items[0])} className="ritual-primary-model-button">
|
|
27
|
+
{t(language, "flow.recommend.primary")}
|
|
28
|
+
</Button>
|
|
29
|
+
) : null}
|
|
30
|
+
{items.slice(0, 3).map((model, index) => (
|
|
31
|
+
<button
|
|
32
|
+
key={`${model.role}-${model.id}`}
|
|
33
|
+
className="ritual-model-card"
|
|
34
|
+
onClick={() => model.supported && onSelect(model)}
|
|
35
|
+
disabled={!model.supported}
|
|
36
|
+
>
|
|
37
|
+
<div className="role">{rankLabel(model.role, index, language)}</div>
|
|
38
|
+
<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>}
|
|
41
|
+
</button>
|
|
42
|
+
))}
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div className="ritual-action-row">
|
|
46
|
+
<Button variant="ghost" onClick={onBack}>{t(language, "flow.recommend.back")}</Button>
|
|
47
|
+
<Button variant="outline" onClick={onSkipModel}>{t(language, "flow.recommend.skip")}</Button>
|
|
48
|
+
<div className="ritual-muted-hint">{t(language, "flow.recommend.hint")}</div>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function rankLabel(role: RecommendedModel["role"], index: number, language: Language) {
|
|
55
|
+
if (role === "best") return t(language, "flow.recommend.rank.best");
|
|
56
|
+
if (role === "faster") return t(language, "flow.recommend.rank.faster");
|
|
57
|
+
if (role === "advanced") return t(language, "flow.recommend.rank.advanced");
|
|
58
|
+
return t(language, "flow.recommend.rank.choice", { index: index + 1 });
|
|
59
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { asArray } from "@/lib/utils";
|
|
2
|
+
|
|
3
|
+
export type FlowStep = "login" | "analysis" | "recommend" | "install";
|
|
4
|
+
export type ApiData = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
export type FlowAnalysis = {
|
|
7
|
+
setup?: ApiData | null;
|
|
8
|
+
models?: ApiData | null;
|
|
9
|
+
recommendations?: ApiData | null;
|
|
10
|
+
sysinfo?: ApiData | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type RecommendedModel = {
|
|
14
|
+
id: string;
|
|
15
|
+
loadId: string;
|
|
16
|
+
engine: string;
|
|
17
|
+
name: string;
|
|
18
|
+
shortName: string;
|
|
19
|
+
family: string;
|
|
20
|
+
size: string;
|
|
21
|
+
role: "best" | "faster" | "advanced";
|
|
22
|
+
reason: string;
|
|
23
|
+
supported: boolean;
|
|
24
|
+
downloadRequired: boolean;
|
|
25
|
+
downloadSize: string;
|
|
26
|
+
storageLocation: string;
|
|
27
|
+
externalHost: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function buildRecommendations(analysis: FlowAnalysis | null): RecommendedModel[] {
|
|
31
|
+
const models = asRecord(analysis?.models);
|
|
32
|
+
const modelRows = [
|
|
33
|
+
...asArray<ApiData>(models.recommended),
|
|
34
|
+
...asArray<ApiData>(models.catalog),
|
|
35
|
+
];
|
|
36
|
+
const recommendationRoot = asRecord(analysis?.recommendations?.recommendations);
|
|
37
|
+
const recRows = asArray<ApiData>(recommendationRoot.models);
|
|
38
|
+
const topPick = asRecord(recommendationRoot.top_pick);
|
|
39
|
+
const merged = new Map<string, ApiData>();
|
|
40
|
+
for (const row of [...recRows, ...modelRows]) {
|
|
41
|
+
const id = String(row.id || row.model_id || row.recommended_load_id || "");
|
|
42
|
+
if (!id) continue;
|
|
43
|
+
merged.set(id, { ...(merged.get(id) || {}), ...row });
|
|
44
|
+
}
|
|
45
|
+
if (topPick.id && !merged.has(String(topPick.id))) merged.set(String(topPick.id), topPick);
|
|
46
|
+
const all = Array.from(merged.values()).map(toRecommendedModel).filter((item) => item.id);
|
|
47
|
+
const supported = all.filter((item) => item.supported);
|
|
48
|
+
const pool = supported.length ? supported : all;
|
|
49
|
+
const byName = (pattern: RegExp) => pool.find((item) => pattern.test(`${item.name} ${item.id}`));
|
|
50
|
+
const byId = (id?: unknown) => pool.find((item) => item.id === String(id));
|
|
51
|
+
const best = byId(topPick.id) || byName(/gemma.*12|12b/i) || pool[0];
|
|
52
|
+
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);
|
|
53
|
+
const advanced = pool.find((item) => item.id !== best?.id && item.id !== faster?.id && /26b|32b|70b|advanced/i.test(`${item.name} ${item.id}`))
|
|
54
|
+
|| pool.find((item) => item.id !== best?.id && item.id !== faster?.id);
|
|
55
|
+
return [
|
|
56
|
+
best ? { ...best, role: "best" as const, reason: best.reason || "best" } : null,
|
|
57
|
+
faster ? { ...faster, role: "faster" as const, reason: faster.reason || "faster" } : null,
|
|
58
|
+
advanced ? { ...advanced, role: "advanced" as const, reason: advanced.reason || "advanced" } : null,
|
|
59
|
+
].filter(Boolean) as RecommendedModel[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function fallbackModel(): RecommendedModel {
|
|
63
|
+
return {
|
|
64
|
+
id: "mlx-community/Qwen3-VL-8B-Instruct-4bit",
|
|
65
|
+
loadId: "mlx-community/Qwen3-VL-8B-Instruct-4bit",
|
|
66
|
+
engine: "local_mlx",
|
|
67
|
+
name: "Qwen3-VL 8B",
|
|
68
|
+
shortName: "Qwen 3",
|
|
69
|
+
family: "Qwen 3",
|
|
70
|
+
size: "",
|
|
71
|
+
role: "best",
|
|
72
|
+
reason: "best",
|
|
73
|
+
supported: true,
|
|
74
|
+
downloadRequired: false,
|
|
75
|
+
downloadSize: "",
|
|
76
|
+
storageLocation: "~/.latticeai/models",
|
|
77
|
+
externalHost: "",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toRecommendedModel(row: ApiData): RecommendedModel {
|
|
82
|
+
const compatibility = asRecord(row.runtime_compatibility);
|
|
83
|
+
const id = String(row.id || row.model_id || row.recommended_load_id || "");
|
|
84
|
+
const loadId = String(row.recommended_load_id || row.load_id || id);
|
|
85
|
+
const name = String(row.display_name || row.name || id || "recommended_brain");
|
|
86
|
+
const supported = row.load_status !== "unsupported"
|
|
87
|
+
&& row.load_status !== "runtime_update_needed"
|
|
88
|
+
&& row.status !== "not_recommended"
|
|
89
|
+
&& compatibility.supported !== false;
|
|
90
|
+
return {
|
|
91
|
+
id,
|
|
92
|
+
loadId,
|
|
93
|
+
engine: String(row.recommended_engine || row.engine || "local_mlx"),
|
|
94
|
+
name,
|
|
95
|
+
shortName: friendlyModelName(name || id),
|
|
96
|
+
family: friendlyModelName(String(row.family || name || "local_brain")),
|
|
97
|
+
size: String(row.size || ""),
|
|
98
|
+
role: "best",
|
|
99
|
+
reason: String(row.reason || ""),
|
|
100
|
+
supported,
|
|
101
|
+
downloadRequired: Boolean(row.download_required),
|
|
102
|
+
downloadSize: String(row.download_size || row.size || ""),
|
|
103
|
+
storageLocation: String(row.storage_location || row.local_path || "~/.latticeai/models"),
|
|
104
|
+
externalHost: externalHostLabel(row),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function externalHostLabel(row: ApiData) {
|
|
109
|
+
const raw = String(row.source_url || row.download_url || row.repository || row.provider || row.id || "");
|
|
110
|
+
if (!raw) return "";
|
|
111
|
+
if (/huggingface|hf\\.co|mlx-community/i.test(raw)) return "huggingface";
|
|
112
|
+
if (/ollama/i.test(raw)) return "ollama";
|
|
113
|
+
return raw.replace(/^https?:\/\//, "").split("/")[0] || raw;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function friendlyModelName(value: string) {
|
|
117
|
+
return String(value || "recommended_brain")
|
|
118
|
+
.replace(/^mlx-community\//i, "")
|
|
119
|
+
.replace(/[-_]?Instruct/gi, "")
|
|
120
|
+
.replace(/[-_]?4bit/gi, "")
|
|
121
|
+
.replace(/Qwen3[-_ ]?VL/gi, "Qwen 3")
|
|
122
|
+
.replace(/Qwen3/gi, "Qwen 3")
|
|
123
|
+
.replace(/Gemma[-_ ]?4/gi, "Gemma 4")
|
|
124
|
+
.replace(/A4B/gi, "")
|
|
125
|
+
.replace(/[-_]+/g, " ")
|
|
126
|
+
.replace(/\s+/g, " ")
|
|
127
|
+
.trim();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function asRecord(value: unknown): ApiData {
|
|
131
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as ApiData : {};
|
|
132
|
+
}
|
|
@@ -29,7 +29,7 @@ export function AdminConsole({ onBack }: { onBack: () => void }) {
|
|
|
29
29
|
const retention = (retentionQ.data?.data || {}) as ApiRecord;
|
|
30
30
|
|
|
31
31
|
return (
|
|
32
|
-
<main className="admin-console" aria-label=
|
|
32
|
+
<main className="admin-console" aria-label={t(language, "admin.aria.console")}>
|
|
33
33
|
<header className="admin-console-header">
|
|
34
34
|
<button className="admin-back-button" type="button" onClick={onBack}>
|
|
35
35
|
<ArrowLeft className="h-4 w-4" />
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { Archive, ChevronDown, DatabaseBackup, Download, Eye, RotateCcw, ShieldCheck } from "lucide-react";
|
|
4
|
+
import { latticeApi, type ApiResult } from "@/api/client";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { t, type Language } from "@/i18n";
|
|
7
|
+
import { isRecord, textValue } from "./brainData";
|
|
8
|
+
|
|
9
|
+
export function BrainCarePanel({ language }: { language: Language }) {
|
|
10
|
+
const qc = useQueryClient();
|
|
11
|
+
const [expanded, setExpanded] = React.useState(false);
|
|
12
|
+
const [archivePath, setArchivePath] = React.useState("");
|
|
13
|
+
const [passphrase, setPassphrase] = React.useState("");
|
|
14
|
+
const [passphraseConfirm, setPassphraseConfirm] = React.useState("");
|
|
15
|
+
const [latestResult, setLatestResult] = React.useState<ApiResult | null>(null);
|
|
16
|
+
const portabilityQ = useQuery({ queryKey: ["portability"], queryFn: latticeApi.graphPortability });
|
|
17
|
+
const backupHealthQ = useQuery({ queryKey: ["backupHealth"], queryFn: latticeApi.backupHealth });
|
|
18
|
+
const rememberResult = React.useCallback((result: ApiResult) => setLatestResult(result), []);
|
|
19
|
+
const pathProblem = archivePath.trim() ? archivePathProblem(archivePath.trim(), language) : "";
|
|
20
|
+
const passphraseProblem = passphrase.trim() ? passphraseStrengthProblem(passphrase, language) : "";
|
|
21
|
+
const passphrasesMatch = !passphrase || passphrase === passphraseConfirm;
|
|
22
|
+
const canArchive = Boolean(passphrase.trim()) && passphrasesMatch && !passphraseProblem;
|
|
23
|
+
const canUseExistingArchive = Boolean(archivePath.trim()) && !pathProblem;
|
|
24
|
+
|
|
25
|
+
const exportGraph = useCareMutation(() => latticeApi.graphExport(), undefined, rememberResult);
|
|
26
|
+
const backupGraph = useCareMutation(() => latticeApi.graphBackup(), () => {
|
|
27
|
+
void qc.invalidateQueries({ queryKey: ["backupHealth"] });
|
|
28
|
+
void qc.invalidateQueries({ queryKey: ["portability"] });
|
|
29
|
+
}, rememberResult);
|
|
30
|
+
const archiveBrain = useCareMutation(
|
|
31
|
+
() => latticeApi.brainArchive({ path: archivePath.trim() || null, passphrase }),
|
|
32
|
+
() => void qc.invalidateQueries({ queryKey: ["backupHealth"] }),
|
|
33
|
+
rememberResult,
|
|
34
|
+
);
|
|
35
|
+
const inspectArchive = useCareMutation(() => latticeApi.brainArchiveInspect({
|
|
36
|
+
path: archivePath.trim(),
|
|
37
|
+
passphrase: passphrase || null,
|
|
38
|
+
}), undefined, rememberResult);
|
|
39
|
+
const restorePreview = useCareMutation(() => latticeApi.brainArchiveRestore({
|
|
40
|
+
path: archivePath.trim(),
|
|
41
|
+
passphrase,
|
|
42
|
+
dry_run: true,
|
|
43
|
+
confirm: false,
|
|
44
|
+
}), undefined, rememberResult);
|
|
45
|
+
|
|
46
|
+
const portableFormat = portabilityLabel(portabilityQ.data?.data);
|
|
47
|
+
const backupStatus = backupHealthLabel(backupHealthQ.data?.data, language);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<section className={`brain-care-panel ${expanded ? "is-expanded" : "is-collapsed"}`} aria-label={t(language, "care.title")}>
|
|
51
|
+
<button
|
|
52
|
+
className="brain-care-summary"
|
|
53
|
+
type="button"
|
|
54
|
+
aria-expanded={expanded}
|
|
55
|
+
aria-controls="brain-care-details"
|
|
56
|
+
onClick={() => setExpanded((value) => !value)}
|
|
57
|
+
>
|
|
58
|
+
<span className="brain-care-summary-main">
|
|
59
|
+
<span><ShieldCheck className="h-3.5 w-3.5" /> {t(language, "care.title")}</span>
|
|
60
|
+
<strong>{t(language, "care.subtitle")}</strong>
|
|
61
|
+
</span>
|
|
62
|
+
<div className="brain-care-proof" aria-label={t(language, "care.ownershipModel")}>
|
|
63
|
+
<span>{t(language, "care.private")}</span>
|
|
64
|
+
<span>{portableFormat}</span>
|
|
65
|
+
<span>{backupStatus}</span>
|
|
66
|
+
</div>
|
|
67
|
+
<ChevronDown className="brain-care-toggle h-4 w-4" aria-hidden="true" />
|
|
68
|
+
</button>
|
|
69
|
+
|
|
70
|
+
{expanded ? (
|
|
71
|
+
<div id="brain-care-details" className="brain-care-details">
|
|
72
|
+
<div className="brain-care-actions">
|
|
73
|
+
<CareButton
|
|
74
|
+
icon={<Download className="h-3.5 w-3.5" />}
|
|
75
|
+
label={t(language, "care.export")}
|
|
76
|
+
detail={t(language, "care.export.detail")}
|
|
77
|
+
pendingLabel={t(language, "care.working")}
|
|
78
|
+
pending={exportGraph.isPending}
|
|
79
|
+
onClick={() => exportGraph.mutate()}
|
|
80
|
+
/>
|
|
81
|
+
<CareButton
|
|
82
|
+
icon={<DatabaseBackup className="h-3.5 w-3.5" />}
|
|
83
|
+
label={t(language, "care.backup")}
|
|
84
|
+
detail={t(language, "care.backup.detail")}
|
|
85
|
+
pendingLabel={t(language, "care.working")}
|
|
86
|
+
pending={backupGraph.isPending}
|
|
87
|
+
onClick={() => backupGraph.mutate()}
|
|
88
|
+
/>
|
|
89
|
+
<CareButton
|
|
90
|
+
icon={<Archive className="h-3.5 w-3.5" />}
|
|
91
|
+
label={t(language, "care.archive")}
|
|
92
|
+
detail={t(language, "care.archive.detail")}
|
|
93
|
+
pendingLabel={t(language, "care.working")}
|
|
94
|
+
pending={archiveBrain.isPending}
|
|
95
|
+
disabled={!canArchive}
|
|
96
|
+
onClick={() => archiveBrain.mutate()}
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<div className="brain-care-archive">
|
|
101
|
+
<input
|
|
102
|
+
value={archivePath}
|
|
103
|
+
onChange={(event) => setArchivePath(event.target.value)}
|
|
104
|
+
placeholder={t(language, "care.path.placeholder")}
|
|
105
|
+
aria-label={t(language, "care.path.label")}
|
|
106
|
+
/>
|
|
107
|
+
<input
|
|
108
|
+
type="password"
|
|
109
|
+
value={passphrase}
|
|
110
|
+
onChange={(event) => setPassphrase(event.target.value)}
|
|
111
|
+
placeholder={t(language, "care.passphrase.placeholder")}
|
|
112
|
+
aria-label={t(language, "care.passphrase.label")}
|
|
113
|
+
/>
|
|
114
|
+
<input
|
|
115
|
+
type="password"
|
|
116
|
+
value={passphraseConfirm}
|
|
117
|
+
onChange={(event) => setPassphraseConfirm(event.target.value)}
|
|
118
|
+
placeholder={t(language, "care.passphrase.confirmPlaceholder")}
|
|
119
|
+
aria-label={t(language, "care.passphrase.confirmLabel")}
|
|
120
|
+
/>
|
|
121
|
+
<div className="brain-care-guidance" role="status">
|
|
122
|
+
{pathProblem ? <span className="is-error">{pathProblem}</span> : archivePath.trim() ? <span>{t(language, "care.path.ready")}</span> : null}
|
|
123
|
+
{passphraseProblem ? <span className="is-error">{passphraseProblem}</span> : passphrase ? <span>{t(language, "care.passphrase.strong")}</span> : null}
|
|
124
|
+
{!passphrasesMatch ? <span className="is-error">{t(language, "care.passphrase.mismatch")}</span> : null}
|
|
125
|
+
</div>
|
|
126
|
+
<div className="brain-care-archive-actions">
|
|
127
|
+
<Button
|
|
128
|
+
variant="outline"
|
|
129
|
+
size="sm"
|
|
130
|
+
disabled={!canUseExistingArchive || inspectArchive.isPending}
|
|
131
|
+
onClick={() => inspectArchive.mutate()}
|
|
132
|
+
>
|
|
133
|
+
<Eye className="h-3.5 w-3.5" /> {t(language, "care.inspect")}
|
|
134
|
+
</Button>
|
|
135
|
+
<Button
|
|
136
|
+
variant="outline"
|
|
137
|
+
size="sm"
|
|
138
|
+
disabled={!canUseExistingArchive || !passphrase.trim() || !passphrasesMatch || restorePreview.isPending}
|
|
139
|
+
onClick={() => restorePreview.mutate()}
|
|
140
|
+
>
|
|
141
|
+
<RotateCcw className="h-3.5 w-3.5" /> {t(language, "care.restorePreview")}
|
|
142
|
+
</Button>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
{latestResult ? (
|
|
147
|
+
<div className={`brain-care-result ${latestResult.ok ? "is-ok" : "is-error"}`} role="status">
|
|
148
|
+
<CareResultSummary result={latestResult} language={language} />
|
|
149
|
+
</div>
|
|
150
|
+
) : (
|
|
151
|
+
<p className="brain-care-note">
|
|
152
|
+
{t(language, "care.note")}
|
|
153
|
+
</p>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
) : null}
|
|
157
|
+
</section>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function useCareMutation<T extends ApiResult>(
|
|
162
|
+
mutationFn: () => Promise<T>,
|
|
163
|
+
onSuccess?: () => void,
|
|
164
|
+
onResult?: (result: T) => void,
|
|
165
|
+
) {
|
|
166
|
+
return useMutation({
|
|
167
|
+
mutationFn,
|
|
168
|
+
onSuccess: (result) => {
|
|
169
|
+
onResult?.(result);
|
|
170
|
+
onSuccess?.();
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function CareButton({
|
|
176
|
+
icon,
|
|
177
|
+
label,
|
|
178
|
+
detail,
|
|
179
|
+
pendingLabel,
|
|
180
|
+
pending,
|
|
181
|
+
disabled,
|
|
182
|
+
onClick,
|
|
183
|
+
}: {
|
|
184
|
+
icon: React.ReactNode;
|
|
185
|
+
label: string;
|
|
186
|
+
detail: string;
|
|
187
|
+
pendingLabel: string;
|
|
188
|
+
pending?: boolean;
|
|
189
|
+
disabled?: boolean;
|
|
190
|
+
onClick: () => void;
|
|
191
|
+
}) {
|
|
192
|
+
return (
|
|
193
|
+
<button className="brain-care-button" type="button" disabled={disabled || pending} onClick={onClick}>
|
|
194
|
+
{icon}
|
|
195
|
+
<span>
|
|
196
|
+
<strong>{pending ? pendingLabel : label}</strong>
|
|
197
|
+
<small>{detail}</small>
|
|
198
|
+
</span>
|
|
199
|
+
</button>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function portabilityLabel(data: unknown) {
|
|
204
|
+
const record = isRecord(data) ? data : {};
|
|
205
|
+
return textValue(record, ["archive_format", "format", "graph_schema_version", "schema_version"], ".latticebrain");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function backupHealthLabel(data: unknown, language: Language) {
|
|
209
|
+
const record = isRecord(data) ? data : {};
|
|
210
|
+
const count = record.count || record.backups || record.available;
|
|
211
|
+
if (count !== undefined && count !== null && count !== "") return t(language, "care.backup.count", { count: String(count) });
|
|
212
|
+
return t(language, "care.backup.ready");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function CareResultSummary({ result, language }: { result: ApiResult; language: Language }) {
|
|
216
|
+
if (!result.ok) return <>{result.error || t(language, "care.result.error")}</>;
|
|
217
|
+
const data = isRecord(result.data) ? result.data : {};
|
|
218
|
+
const path = textValue(data, ["path", "archive_path", "backup_path", "export_path", "latest_backup"]);
|
|
219
|
+
const status = textValue(data, ["message", "status", "result"], t(language, "care.result.completed"));
|
|
220
|
+
const counts = {
|
|
221
|
+
memories: textValue(data, ["nodes", "total_nodes", "memories", "memory_count"]),
|
|
222
|
+
links: textValue(data, ["edges", "total_edges", "relationships", "relationship_count"]),
|
|
223
|
+
conversations: textValue(data, ["conversations", "conversation_count"]),
|
|
224
|
+
};
|
|
225
|
+
return (
|
|
226
|
+
<div className="brain-care-result-summary">
|
|
227
|
+
<strong>{status}</strong>
|
|
228
|
+
{path ? <span>{t(language, "care.result.path", { path })}</span> : null}
|
|
229
|
+
{counts.memories || counts.links || counts.conversations ? (
|
|
230
|
+
<span>
|
|
231
|
+
{t(language, "care.result.contents", {
|
|
232
|
+
memories: counts.memories || "0",
|
|
233
|
+
links: counts.links || "0",
|
|
234
|
+
conversations: counts.conversations || "0",
|
|
235
|
+
})}
|
|
236
|
+
</span>
|
|
237
|
+
) : null}
|
|
238
|
+
{data.dry_run === true ? <span>{t(language, "care.result.dryRun")}</span> : null}
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function archivePathProblem(path: string, language: Language) {
|
|
244
|
+
if (/[<>:"|?*]/.test(path)) return t(language, "care.path.invalidChars");
|
|
245
|
+
if (!/\.(latticebrain|zip|json)$/i.test(path)) return t(language, "care.path.extension");
|
|
246
|
+
return "";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function passphraseStrengthProblem(value: string, language: Language) {
|
|
250
|
+
if (value.length < 12) return t(language, "care.passphrase.tooShort");
|
|
251
|
+
const variety = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((pattern) => pattern.test(value)).length;
|
|
252
|
+
if (variety < 3) return t(language, "care.passphrase.tooSimple");
|
|
253
|
+
return "";
|
|
254
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { ImagePlus, Send } from "lucide-react";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { t, type Language } from "@/i18n";
|
|
5
|
+
|
|
6
|
+
export function BrainComposer({
|
|
7
|
+
language,
|
|
8
|
+
draft,
|
|
9
|
+
streaming,
|
|
10
|
+
imageData,
|
|
11
|
+
onDraftChange,
|
|
12
|
+
onImageDataChange,
|
|
13
|
+
onSend,
|
|
14
|
+
}: {
|
|
15
|
+
language: Language;
|
|
16
|
+
draft: string;
|
|
17
|
+
streaming: boolean;
|
|
18
|
+
imageData: string | null;
|
|
19
|
+
onDraftChange: (value: string) => void;
|
|
20
|
+
onImageDataChange: (value: string | null) => void;
|
|
21
|
+
onSend: () => void;
|
|
22
|
+
}) {
|
|
23
|
+
return (
|
|
24
|
+
<div className="brain-composer">
|
|
25
|
+
<textarea
|
|
26
|
+
value={draft}
|
|
27
|
+
onChange={(event) => onDraftChange(event.target.value)}
|
|
28
|
+
onKeyDown={(event) => {
|
|
29
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
onSend();
|
|
32
|
+
}
|
|
33
|
+
}}
|
|
34
|
+
placeholder={t(language, "brain.placeholder")}
|
|
35
|
+
/>
|
|
36
|
+
<div className="brain-composer-actions">
|
|
37
|
+
<label className="brain-image-input">
|
|
38
|
+
<ImagePlus className="h-3.5 w-3.5" />
|
|
39
|
+
<span>{t(language, "brain.image")}</span>
|
|
40
|
+
<input
|
|
41
|
+
type="file"
|
|
42
|
+
accept="image/*"
|
|
43
|
+
className="sr-only"
|
|
44
|
+
onChange={async (event) => {
|
|
45
|
+
const file = event.target.files?.[0];
|
|
46
|
+
if (file) onImageDataChange(await fileToDataUrl(file));
|
|
47
|
+
}}
|
|
48
|
+
/>
|
|
49
|
+
</label>
|
|
50
|
+
{imageData ? <span className="brain-quiet-success">{t(language, "brain.imageAttached")}</span> : null}
|
|
51
|
+
<Button onClick={onSend} disabled={!draft.trim() || streaming} className="rounded-full px-5">
|
|
52
|
+
<Send className="h-4 w-4" /> {t(language, "brain.send")}
|
|
53
|
+
</Button>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function fileToDataUrl(file: File) {
|
|
60
|
+
return new Promise<string>((resolve, reject) => {
|
|
61
|
+
const reader = new FileReader();
|
|
62
|
+
reader.onload = () => resolve(String(reader.result || ""));
|
|
63
|
+
reader.onerror = () => reject(reader.error);
|
|
64
|
+
reader.readAsDataURL(file);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { ShieldCheck } from "lucide-react";
|
|
3
|
+
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
|
4
|
+
import { t, type Language } from "@/i18n";
|
|
5
|
+
import type { BrainDepth, KnowledgeConcept, MemoryFragment, Message } from "./types";
|
|
6
|
+
import { BrainCarePanel } from "./BrainCarePanel";
|
|
7
|
+
import { BrainComposer } from "./BrainComposer";
|
|
8
|
+
import { BrainOverviewPanel } from "./BrainOverviewPanel";
|
|
9
|
+
|
|
10
|
+
export function BrainConversation({
|
|
11
|
+
language,
|
|
12
|
+
explorationDepth,
|
|
13
|
+
modelName,
|
|
14
|
+
messages,
|
|
15
|
+
starterPrompts,
|
|
16
|
+
memoryFeedback,
|
|
17
|
+
draft,
|
|
18
|
+
streaming,
|
|
19
|
+
imageData,
|
|
20
|
+
streamRef,
|
|
21
|
+
memories,
|
|
22
|
+
concepts,
|
|
23
|
+
onOpenDepth,
|
|
24
|
+
onDraftChange,
|
|
25
|
+
onImageDataChange,
|
|
26
|
+
onSend,
|
|
27
|
+
}: {
|
|
28
|
+
language: Language;
|
|
29
|
+
explorationDepth: BrainDepth;
|
|
30
|
+
modelName: string;
|
|
31
|
+
messages: Message[];
|
|
32
|
+
starterPrompts: string[];
|
|
33
|
+
memoryFeedback: string | null;
|
|
34
|
+
draft: string;
|
|
35
|
+
streaming: boolean;
|
|
36
|
+
imageData: string | null;
|
|
37
|
+
streamRef: React.RefObject<HTMLDivElement | null>;
|
|
38
|
+
memories: MemoryFragment[];
|
|
39
|
+
concepts: KnowledgeConcept[];
|
|
40
|
+
onOpenDepth: (depth: BrainDepth) => void;
|
|
41
|
+
onDraftChange: (value: string) => void;
|
|
42
|
+
onImageDataChange: (value: string | null) => void;
|
|
43
|
+
onSend: () => void;
|
|
44
|
+
}) {
|
|
45
|
+
return (
|
|
46
|
+
<section className="brain-conversation" aria-label={t(language, "brain.aria.conversation")}>
|
|
47
|
+
<div className="brain-conversation-header">
|
|
48
|
+
<div>
|
|
49
|
+
<h1>{t(language, "brain.title")}</h1>
|
|
50
|
+
<span>{t(language, `brain.depth.${explorationDepth}`)}</span>
|
|
51
|
+
</div>
|
|
52
|
+
<LanguageSwitcher compact />
|
|
53
|
+
<div className="brain-ownership-strip" aria-label={t(language, "brain.aria.ownership")}>
|
|
54
|
+
<span>{t(language, "brain.local")}</span>
|
|
55
|
+
<span>{t(language, "brain.portable")}</span>
|
|
56
|
+
<span>{t(language, "brain.private")}</span>
|
|
57
|
+
</div>
|
|
58
|
+
<div>{modelName}</div>
|
|
59
|
+
<button className="brain-admin-link" type="button" onClick={() => navigateHash("/admin")}>
|
|
60
|
+
<ShieldCheck className="h-3.5 w-3.5" />
|
|
61
|
+
{t(language, "brain.admin")}
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div ref={streamRef} className="brain-stream">
|
|
66
|
+
<BrainOverviewPanel
|
|
67
|
+
memories={memories}
|
|
68
|
+
concepts={concepts}
|
|
69
|
+
onOpenDepth={onOpenDepth}
|
|
70
|
+
/>
|
|
71
|
+
{messages.length === 0 ? (
|
|
72
|
+
<BrainEmptyState language={language} starterPrompts={starterPrompts} onDraftChange={onDraftChange} />
|
|
73
|
+
) : (
|
|
74
|
+
messages.map((message, index) => (
|
|
75
|
+
<div key={`${message.role}-${index}`} className={`brain-message ${message.role}`}>
|
|
76
|
+
<div className="brain-message-bubble">{message.content}</div>
|
|
77
|
+
</div>
|
|
78
|
+
))
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
{memoryFeedback ? <div className="brain-save-feedback" role="status">{memoryFeedback}</div> : null}
|
|
83
|
+
|
|
84
|
+
<BrainCarePanel language={language} />
|
|
85
|
+
|
|
86
|
+
<BrainComposer
|
|
87
|
+
language={language}
|
|
88
|
+
draft={draft}
|
|
89
|
+
streaming={streaming}
|
|
90
|
+
imageData={imageData}
|
|
91
|
+
onDraftChange={onDraftChange}
|
|
92
|
+
onImageDataChange={onImageDataChange}
|
|
93
|
+
onSend={onSend}
|
|
94
|
+
/>
|
|
95
|
+
</section>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function BrainEmptyState({
|
|
100
|
+
language,
|
|
101
|
+
starterPrompts,
|
|
102
|
+
onDraftChange,
|
|
103
|
+
}: {
|
|
104
|
+
language: Language;
|
|
105
|
+
starterPrompts: string[];
|
|
106
|
+
onDraftChange: (value: string) => void;
|
|
107
|
+
}) {
|
|
108
|
+
return (
|
|
109
|
+
<div className="mind-empty">
|
|
110
|
+
<div className="mind-empty-kicker">{t(language, "brain.empty.kicker")}</div>
|
|
111
|
+
<div className="mind-empty-title">{t(language, "brain.empty.title")}</div>
|
|
112
|
+
<p>{t(language, "brain.empty.body")}</p>
|
|
113
|
+
<div className="mind-empty-prompts" aria-label={t(language, "brain.aria.starterPrompts")}>
|
|
114
|
+
{starterPrompts.map((prompt) => (
|
|
115
|
+
<button key={prompt} type="button" onClick={() => onDraftChange(prompt)}>
|
|
116
|
+
{prompt}
|
|
117
|
+
</button>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
<div className="mind-empty-trail" aria-label={t(language, "brain.empty.trail.label")}>
|
|
121
|
+
<span>{t(language, "brain.empty.trail.save")}</span>
|
|
122
|
+
<span>{t(language, "brain.empty.trail.recall")}</span>
|
|
123
|
+
<span>{t(language, "brain.empty.trail.backup")}</span>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function navigateHash(route: string) {
|
|
130
|
+
window.location.hash = route;
|
|
131
|
+
}
|