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.
Files changed (51) hide show
  1. package/README.md +29 -17
  2. package/docs/CHANGELOG.md +66 -0
  3. package/frontend/src/components/onboarding/AnalysisScreen.tsx +127 -0
  4. package/frontend/src/components/onboarding/DownloadConsentPanel.tsx +29 -0
  5. package/frontend/src/components/onboarding/InstallScreen.tsx +208 -0
  6. package/frontend/src/components/onboarding/LanguageChooser.tsx +23 -0
  7. package/frontend/src/components/onboarding/LoginScreen.tsx +131 -0
  8. package/frontend/src/components/onboarding/ProductFlowScreens.tsx +13 -688
  9. package/frontend/src/components/onboarding/RecommendationScreen.tsx +59 -0
  10. package/frontend/src/components/onboarding/recommendationModel.ts +132 -0
  11. package/frontend/src/features/admin/AdminConsole.tsx +1 -1
  12. package/frontend/src/features/brain/BrainCarePanel.tsx +254 -0
  13. package/frontend/src/features/brain/BrainComposer.tsx +66 -0
  14. package/frontend/src/features/brain/BrainConversation.tsx +131 -0
  15. package/frontend/src/features/brain/BrainGraphLayer.tsx +132 -0
  16. package/frontend/src/features/brain/BrainHome.tsx +28 -795
  17. package/frontend/src/features/brain/BrainMemoryLayer.tsx +38 -0
  18. package/frontend/src/features/brain/BrainOverviewPanel.tsx +74 -0
  19. package/frontend/src/features/brain/BrainRelationshipLayer.tsx +43 -0
  20. package/frontend/src/features/brain/DepthEmergence.tsx +53 -0
  21. package/frontend/src/features/brain/types.ts +6 -6
  22. package/frontend/src/features/review/ReviewCard.tsx +14 -10
  23. package/frontend/src/features/review/ReviewInbox.tsx +24 -9
  24. package/frontend/src/i18n.ts +208 -0
  25. package/frontend/src/pages/Brain.tsx +63 -5
  26. package/frontend/src/pages/Capture.tsx +139 -43
  27. package/frontend/src/pages/Library.tsx +72 -6
  28. package/lattice_brain/__init__.py +1 -1
  29. package/lattice_brain/runtime/multi_agent.py +1 -1
  30. package/latticeai/__init__.py +1 -1
  31. package/latticeai/app_factory.py +48 -106
  32. package/latticeai/core/marketplace.py +1 -1
  33. package/latticeai/core/workspace_os.py +1 -1
  34. package/latticeai/runtime/access_runtime.py +97 -0
  35. package/latticeai/runtime/chat_wiring.py +119 -0
  36. package/latticeai/runtime/model_wiring.py +40 -0
  37. package/latticeai/runtime/platform_runtime_wiring.py +2 -3
  38. package/latticeai/runtime/review_wiring.py +37 -0
  39. package/latticeai/runtime/tail_wiring.py +21 -0
  40. package/package.json +2 -2
  41. package/scripts/check_i18n_literals.mjs +52 -0
  42. package/src-tauri/Cargo.lock +1 -1
  43. package/src-tauri/Cargo.toml +1 -1
  44. package/src-tauri/tauri.conf.json +1 -1
  45. package/static/app/asset-manifest.json +5 -5
  46. package/static/app/assets/index-Div5vMlq.css +2 -0
  47. package/static/app/assets/{index-D91Rz5--.js → index-t1jx1BR9.js} +2 -2
  48. package/static/app/assets/index-t1jx1BR9.js.map +1 -0
  49. package/static/app/index.html +2 -2
  50. package/static/app/assets/index-B2-1Gm0q.css +0 -2
  51. 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="Lattice Admin">
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
+ }