ltcai 6.1.0 → 6.3.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 +36 -38
- package/docs/CHANGELOG.md +75 -1
- package/frontend/src/App.tsx +3 -1286
- package/frontend/src/components/LanguageSwitcher.tsx +23 -0
- package/frontend/src/components/ProductFlow.tsx +32 -669
- 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 -0
- 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 +294 -0
- 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 +232 -0
- 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/brainData.ts +98 -0
- package/frontend/src/features/brain/graphLayout.ts +26 -0
- package/frontend/src/features/brain/types.ts +44 -0
- package/frontend/src/features/review/ReviewCard.tsx +3 -1
- package/frontend/src/features/review/ReviewInbox.tsx +11 -1
- package/frontend/src/i18n.ts +290 -0
- package/frontend/src/pages/Brain.tsx +63 -5
- package/frontend/src/pages/Capture.tsx +102 -13
- package/frontend/src/pages/Library.tsx +72 -6
- package/frontend/src/styles.css +220 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/tools.py +50 -23
- package/latticeai/app_factory.py +59 -76
- package/latticeai/cli/entrypoint.py +283 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/integrations/__init__.py +0 -0
- package/latticeai/integrations/telegram_bot.py +1009 -0
- package/latticeai/runtime/chat_wiring.py +119 -0
- package/latticeai/runtime/lifespan_runtime.py +1 -1
- package/latticeai/runtime/model_wiring.py +40 -0
- package/latticeai/runtime/platform_runtime_wiring.py +86 -0
- package/latticeai/runtime/review_wiring.py +37 -0
- package/latticeai/runtime/router_registration.py +49 -30
- package/latticeai/runtime/tail_wiring.py +21 -0
- package/latticeai/services/p_reinforce.py +258 -0
- package/latticeai/services/router_context.py +52 -0
- package/ltcai_cli.py +7 -279
- package/p_reinforce.py +4 -255
- package/package.json +3 -2
- package/scripts/check_i18n_literals.mjs +52 -0
- package/scripts/release_smoke.py +133 -0
- package/scripts/wheel_smoke.py +4 -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-D76dWuQk.js +16 -0
- package/static/app/assets/index-D76dWuQk.js.map +1 -0
- package/static/app/assets/index-Div5vMlq.css +2 -0
- package/static/app/index.html +2 -2
- package/telegram_bot.py +9 -1002
- package/static/app/assets/index-B744yblP.css +0 -2
- package/static/app/assets/index-DYaUKNfl.js +0 -16
- package/static/app/assets/index-DYaUKNfl.js.map +0 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { Activity, ArrowLeft, ListFilter, RotateCcw, Search, ServerCog, ShieldCheck, Users } from "lucide-react";
|
|
4
|
+
import { latticeApi, type AdminAuditFilters, type ApiResult } from "@/api/client";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
|
7
|
+
import { useAppStore } from "@/store/appStore";
|
|
8
|
+
import { asArray } from "@/lib/utils";
|
|
9
|
+
import { t } from "@/i18n";
|
|
10
|
+
|
|
11
|
+
type ApiRecord = Record<string, unknown>;
|
|
12
|
+
type AdminFilterState = Required<Pick<AdminAuditFilters, "q" | "actor" | "action" | "severity">> & { limit: number };
|
|
13
|
+
|
|
14
|
+
export function AdminConsole({ onBack }: { onBack: () => void }) {
|
|
15
|
+
const qc = useQueryClient();
|
|
16
|
+
const language = useAppStore((state) => state.language);
|
|
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);
|
|
19
|
+
const rebuildIndex = useMutation({
|
|
20
|
+
mutationFn: latticeApi.rebuildIndex,
|
|
21
|
+
onSuccess: () => void qc.invalidateQueries({ queryKey: ["indexStatus"] }),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const users = asArray(usersQ.data?.data);
|
|
25
|
+
const auditEvents = asArray((auditQ.data?.data as ApiRecord | undefined)?.recent_events);
|
|
26
|
+
const securityEvents = asArray((securityEventsQ.data?.data as ApiRecord | undefined)?.events);
|
|
27
|
+
const policies = asArray((policiesQ.data?.data as ApiRecord | undefined)?.policies);
|
|
28
|
+
const roles = asArray((rolesQ.data?.data as ApiRecord | undefined)?.roles);
|
|
29
|
+
const retention = (retentionQ.data?.data || {}) as ApiRecord;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<main className="admin-console" aria-label={t(language, "admin.aria.console")}>
|
|
33
|
+
<header className="admin-console-header">
|
|
34
|
+
<button className="admin-back-button" type="button" onClick={onBack}>
|
|
35
|
+
<ArrowLeft className="h-4 w-4" />
|
|
36
|
+
{t(language, "admin.back")}
|
|
37
|
+
</button>
|
|
38
|
+
<div>
|
|
39
|
+
<span>{t(language, "admin.kicker")}</span>
|
|
40
|
+
<h1>{t(language, "admin.title")}</h1>
|
|
41
|
+
<p>{t(language, "admin.body")}</p>
|
|
42
|
+
</div>
|
|
43
|
+
<LanguageSwitcher compact />
|
|
44
|
+
</header>
|
|
45
|
+
|
|
46
|
+
<section className="admin-metrics" aria-label={t(language, "admin.overview")}>
|
|
47
|
+
<AdminMetric icon={<Users className="h-4 w-4" />} label={t(language, "admin.metric.users")} value={String(users.length)} detail={sourceLabel(usersQ.data, language)} />
|
|
48
|
+
<AdminMetric
|
|
49
|
+
icon={<Activity className="h-4 w-4" />}
|
|
50
|
+
label={t(language, "admin.metric.logs")}
|
|
51
|
+
value={String(auditEvents.length + securityEvents.length)}
|
|
52
|
+
detail={sourceLabel(auditQ.data, language)}
|
|
53
|
+
/>
|
|
54
|
+
<AdminMetric
|
|
55
|
+
icon={<ShieldCheck className="h-4 w-4" />}
|
|
56
|
+
label={t(language, "admin.metric.security")}
|
|
57
|
+
value={adminStatusLabel(securityQ.data?.data, "status") || (securityQ.data?.ok ? t(language, "admin.status.ready") : t(language, "admin.status.unavailable"))}
|
|
58
|
+
detail={sourceLabel(securityQ.data, language)}
|
|
59
|
+
/>
|
|
60
|
+
<AdminMetric
|
|
61
|
+
icon={<ServerCog className="h-4 w-4" />}
|
|
62
|
+
label={t(language, "admin.metric.index")}
|
|
63
|
+
value={adminStatusLabel(indexQ.data?.data, "status") || (indexQ.data?.ok ? t(language, "admin.status.indexed") : t(language, "admin.status.unknown"))}
|
|
64
|
+
detail={indexDetail(indexQ.data?.data, language)}
|
|
65
|
+
/>
|
|
66
|
+
</section>
|
|
67
|
+
|
|
68
|
+
<section className="admin-grid">
|
|
69
|
+
<AdminPanel title={t(language, "admin.panel.users")} eyebrow={t(language, "admin.panel.people")}>
|
|
70
|
+
<AdminList
|
|
71
|
+
items={users.slice(0, 8)}
|
|
72
|
+
empty={t(language, "admin.empty.users")}
|
|
73
|
+
render={(item) => {
|
|
74
|
+
const user = item as ApiRecord;
|
|
75
|
+
return (
|
|
76
|
+
<>
|
|
77
|
+
<strong>{stringValue(user.name || user.email || user.id, t(language, "admin.fallback.localUser"))}</strong>
|
|
78
|
+
<span>{stringValue(user.role || user.status || user.workspace_id, t(language, "admin.fallback.member"))}</span>
|
|
79
|
+
</>
|
|
80
|
+
);
|
|
81
|
+
}}
|
|
82
|
+
/>
|
|
83
|
+
</AdminPanel>
|
|
84
|
+
|
|
85
|
+
<AdminPanel title={t(language, "admin.panel.roles")} eyebrow={t(language, "admin.panel.access")}>
|
|
86
|
+
<AdminList
|
|
87
|
+
items={roles.slice(0, 6)}
|
|
88
|
+
empty={t(language, "admin.empty.roles")}
|
|
89
|
+
render={(item) => {
|
|
90
|
+
const role = item as ApiRecord;
|
|
91
|
+
return (
|
|
92
|
+
<>
|
|
93
|
+
<strong>{stringValue(role.role, t(language, "admin.fallback.role"))} · {stringValue(role.members, "0")} {t(language, "admin.metric.users")}</strong>
|
|
94
|
+
<span>{asArray(role.caps).slice(0, 4).map((cap) => stringValue(cap, "")).filter(Boolean).join(", ") || t(language, "admin.fallback.noCaps")}</span>
|
|
95
|
+
</>
|
|
96
|
+
);
|
|
97
|
+
}}
|
|
98
|
+
/>
|
|
99
|
+
</AdminPanel>
|
|
100
|
+
|
|
101
|
+
<AdminPanel title={t(language, "admin.panel.logs")} eyebrow={t(language, "admin.panel.audit")}>
|
|
102
|
+
<AdminLogFilters language={language} filters={filters} onChange={setFilters} matched={(auditQ.data?.data as ApiRecord | undefined)?.filters as ApiRecord | undefined} />
|
|
103
|
+
<AdminList
|
|
104
|
+
items={auditEvents.slice(0, 8)}
|
|
105
|
+
empty={t(language, "admin.empty.audit")}
|
|
106
|
+
render={(item) => renderLogRow(item as ApiRecord, language)}
|
|
107
|
+
/>
|
|
108
|
+
</AdminPanel>
|
|
109
|
+
|
|
110
|
+
<AdminPanel title={t(language, "admin.panel.securityEvents")} eyebrow={t(language, "admin.panel.protection")}>
|
|
111
|
+
<AdminList
|
|
112
|
+
items={securityEvents.slice(0, 8)}
|
|
113
|
+
empty={t(language, "admin.empty.security")}
|
|
114
|
+
render={(item) => renderLogRow(item as ApiRecord, language)}
|
|
115
|
+
/>
|
|
116
|
+
</AdminPanel>
|
|
117
|
+
|
|
118
|
+
<AdminPanel title={t(language, "admin.panel.brainOps")} eyebrow={t(language, "admin.panel.maintenance")}>
|
|
119
|
+
<div className="admin-operation">
|
|
120
|
+
<div>
|
|
121
|
+
<strong>{indexDetail(indexQ.data?.data, language)}</strong>
|
|
122
|
+
<span>{summaryText(summaryQ.data?.data) || summaryText(statsQ.data?.data) || t(language, "admin.brain.summaryFallback")}</span>
|
|
123
|
+
</div>
|
|
124
|
+
<Button variant="outline" size="sm" disabled={rebuildIndex.isPending} onClick={() => rebuildIndex.mutate()}>
|
|
125
|
+
<RotateCcw className="h-3.5 w-3.5" />
|
|
126
|
+
{rebuildIndex.isPending ? t(language, "admin.brain.rebuilding") : t(language, "admin.brain.rebuild")}
|
|
127
|
+
</Button>
|
|
128
|
+
</div>
|
|
129
|
+
<div className="admin-policy-strip">
|
|
130
|
+
{policies.slice(0, 5).map((item, index) => {
|
|
131
|
+
const policy = item as ApiRecord;
|
|
132
|
+
return <span key={`${stringValue(policy.id || policy.name, "policy")}-${index}`}>{stringValue(policy.label || policy.name || policy.id, t(language, "admin.policy.fallback"))}</span>;
|
|
133
|
+
})}
|
|
134
|
+
{!policies.length ? <span>{t(language, "admin.policy.quiet")}</span> : null}
|
|
135
|
+
</div>
|
|
136
|
+
<div className="admin-retention">
|
|
137
|
+
<strong>{t(language, "admin.retention.days", { days: stringValue(retention.retention_days, "90") })}</strong>
|
|
138
|
+
<span>{t(language, "admin.retention.detail", { events: stringValue(retention.retained_events, "0"), candidates: stringValue(retention.prune_candidates, "0") })}</span>
|
|
139
|
+
</div>
|
|
140
|
+
</AdminPanel>
|
|
141
|
+
|
|
142
|
+
</section>
|
|
143
|
+
</main>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function useAdminConsoleData(filters: AdminFilterState) {
|
|
148
|
+
const auditFilters = React.useMemo<AdminAuditFilters>(() => ({
|
|
149
|
+
q: filters.q || undefined,
|
|
150
|
+
actor: filters.actor || undefined,
|
|
151
|
+
action: filters.action || undefined,
|
|
152
|
+
severity: filters.severity || undefined,
|
|
153
|
+
limit: filters.limit,
|
|
154
|
+
}), [filters]);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
summaryQ: useQuery({ queryKey: ["adminSummary"], queryFn: latticeApi.adminSummary }),
|
|
158
|
+
statsQ: useQuery({ queryKey: ["adminStats"], queryFn: latticeApi.adminStats }),
|
|
159
|
+
usersQ: useQuery({ queryKey: ["adminUsers"], queryFn: latticeApi.adminUsers }),
|
|
160
|
+
auditQ: useQuery({ queryKey: ["adminAudit", auditFilters], queryFn: () => latticeApi.adminAudit(auditFilters) }),
|
|
161
|
+
securityQ: useQuery({ queryKey: ["adminSecurity"], queryFn: latticeApi.adminSecurity }),
|
|
162
|
+
securityEventsQ: useQuery({ queryKey: ["adminSecurityEvents"], queryFn: () => latticeApi.adminSecurityEvents(50) }),
|
|
163
|
+
policiesQ: useQuery({ queryKey: ["adminPolicies"], queryFn: latticeApi.adminPolicies }),
|
|
164
|
+
rolesQ: useQuery({ queryKey: ["adminRoles"], queryFn: latticeApi.adminRoles }),
|
|
165
|
+
retentionQ: useQuery({ queryKey: ["adminLogRetention"], queryFn: latticeApi.adminLogRetention }),
|
|
166
|
+
indexQ: useQuery({ queryKey: ["indexStatus"], queryFn: latticeApi.indexStatus }),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function AdminLogFilters({
|
|
171
|
+
filters,
|
|
172
|
+
matched,
|
|
173
|
+
onChange,
|
|
174
|
+
language,
|
|
175
|
+
}: {
|
|
176
|
+
filters: AdminFilterState;
|
|
177
|
+
matched?: ApiRecord;
|
|
178
|
+
onChange: React.Dispatch<React.SetStateAction<AdminFilterState>>;
|
|
179
|
+
language: "ko" | "en";
|
|
180
|
+
}) {
|
|
181
|
+
return (
|
|
182
|
+
<div className="admin-log-filters" aria-label={t(language, "admin.filters.label")}>
|
|
183
|
+
<label>
|
|
184
|
+
<Search className="h-3.5 w-3.5" />
|
|
185
|
+
<input
|
|
186
|
+
value={filters.q}
|
|
187
|
+
onChange={(event) => onChange((current) => ({ ...current, q: event.target.value }))}
|
|
188
|
+
placeholder={t(language, "admin.filters.search")}
|
|
189
|
+
aria-label={t(language, "admin.filters.searchAria")}
|
|
190
|
+
/>
|
|
191
|
+
</label>
|
|
192
|
+
<label>
|
|
193
|
+
<ListFilter className="h-3.5 w-3.5" />
|
|
194
|
+
<select
|
|
195
|
+
value={filters.severity}
|
|
196
|
+
onChange={(event) => onChange((current) => ({ ...current, severity: event.target.value }))}
|
|
197
|
+
aria-label={t(language, "admin.filters.severityAria")}
|
|
198
|
+
>
|
|
199
|
+
<option value="">{t(language, "admin.filters.all")}</option>
|
|
200
|
+
<option value="informational">{t(language, "admin.filters.informational")}</option>
|
|
201
|
+
<option value="notice">{t(language, "admin.filters.notice")}</option>
|
|
202
|
+
<option value="warning">{t(language, "admin.filters.warning")}</option>
|
|
203
|
+
<option value="high">{t(language, "admin.filters.high")}</option>
|
|
204
|
+
</select>
|
|
205
|
+
</label>
|
|
206
|
+
<span>{t(language, "admin.filters.matched", { count: stringValue(matched?.matched_events, "0") })}</span>
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function AdminMetric({ icon, label, value, detail }: { icon: React.ReactNode; label: string; value: string; detail: string }) {
|
|
212
|
+
return (
|
|
213
|
+
<div className="admin-metric">
|
|
214
|
+
<div>{icon}</div>
|
|
215
|
+
<span>{label}</span>
|
|
216
|
+
<strong>{value}</strong>
|
|
217
|
+
<small>{detail}</small>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function AdminPanel({ eyebrow, title, children }: { eyebrow: string; title: string; children: React.ReactNode }) {
|
|
223
|
+
return (
|
|
224
|
+
<section className="admin-panel">
|
|
225
|
+
<div className="admin-panel-head">
|
|
226
|
+
<span>{eyebrow}</span>
|
|
227
|
+
<h2>{title}</h2>
|
|
228
|
+
</div>
|
|
229
|
+
{children}
|
|
230
|
+
</section>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function AdminList({ items, empty, render }: { items: unknown[]; empty: string; render: (item: unknown) => React.ReactNode }) {
|
|
235
|
+
if (!items.length) return <div className="admin-empty">{empty}</div>;
|
|
236
|
+
return <div className="admin-list">{items.map((item, index) => <div key={index} className="admin-list-row">{render(item)}</div>)}</div>;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function renderLogRow(event: ApiRecord, language: "ko" | "en") {
|
|
240
|
+
const action = stringValue(event.action || event.event || event.type || event.name, t(language, "admin.log.event"));
|
|
241
|
+
const actor = stringValue(event.actor || event.user || event.user_id || event.workspace_id, t(language, "admin.log.system"));
|
|
242
|
+
const when = stringValue(event.timestamp || event.time || event.created_at || event.ts, t(language, "admin.log.recently"));
|
|
243
|
+
return (
|
|
244
|
+
<>
|
|
245
|
+
<strong>{action}</strong>
|
|
246
|
+
<span>{actor} · {when}</span>
|
|
247
|
+
</>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function sourceLabel(result: ApiResult<unknown> | undefined, language: "ko" | "en") {
|
|
252
|
+
if (!result) return t(language, "admin.source.loading");
|
|
253
|
+
return result.ok ? t(language, "admin.source.live") : result.error || t(language, "admin.status.unavailable");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function adminStatusLabel(data: unknown, key: string) {
|
|
257
|
+
const record = isRecord(data) ? data : {};
|
|
258
|
+
return textValue(record, [key, "health", "state", "overall_status"]);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function indexDetail(data: unknown, language: "ko" | "en") {
|
|
262
|
+
const record = isRecord(data) ? data : {};
|
|
263
|
+
const docs = record.documents ?? record.document_count ?? record.docs;
|
|
264
|
+
const chunks = record.chunks ?? record.chunk_count ?? record.vectors;
|
|
265
|
+
if (docs !== undefined || chunks !== undefined) {
|
|
266
|
+
return `${stringValue(docs, "0")} docs · ${stringValue(chunks, "0")} chunks`;
|
|
267
|
+
}
|
|
268
|
+
return textValue(record, ["message", "detail", "status"], t(language, "admin.index.ready"));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function summaryText(data: unknown) {
|
|
272
|
+
const record = isRecord(data) ? data : {};
|
|
273
|
+
return textValue(record, ["summary", "message", "status", "detail"]);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function stringValue(value: unknown, fallback = "") {
|
|
277
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
278
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
279
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
280
|
+
return fallback;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isRecord(value: unknown): value is ApiRecord {
|
|
284
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function textValue(record: ApiRecord, keys: string[], fallback = "") {
|
|
288
|
+
for (const key of keys) {
|
|
289
|
+
const value = record[key];
|
|
290
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
291
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
292
|
+
}
|
|
293
|
+
return fallback;
|
|
294
|
+
}
|
|
@@ -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
|
+
}
|