ltcai 2.2.7 → 3.1.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 +72 -34
- package/docs/CHANGELOG.md +119 -0
- package/docs/V3_BACKEND_ARCHITECTURE.md +138 -0
- package/docs/V3_FRONTEND.md +139 -0
- package/knowledge_graph.py +649 -21
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +47 -0
- package/latticeai/api/agents.py +54 -31
- package/latticeai/api/auth.py +5 -2
- package/latticeai/api/chat.py +10 -2
- package/latticeai/api/search.py +240 -0
- package/latticeai/api/static_routes.py +11 -2
- package/latticeai/core/config.py +18 -0
- package/latticeai/core/embedding_providers.py +625 -0
- package/latticeai/core/local_embeddings.py +86 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +65 -1
- package/latticeai/services/agent_runtime.py +245 -0
- package/latticeai/services/search_service.py +346 -0
- package/package.json +13 -6
- package/scripts/build_v3_assets.mjs +164 -0
- package/scripts/capture/README.md +28 -0
- package/scripts/capture/capture_enterprise.js +8 -0
- package/scripts/capture/capture_graph.js +8 -0
- package/scripts/capture/capture_onboarding.js +8 -0
- package/scripts/capture/capture_page.js +43 -0
- package/scripts/capture/capture_release_media.js +125 -0
- package/scripts/capture/capture_skills.js +8 -0
- package/scripts/capture/capture_workspace.js +8 -0
- package/scripts/generate_diagrams.py +513 -0
- package/scripts/lint_v3.mjs +33 -0
- package/scripts/release-0.3.1.sh +105 -0
- package/scripts/take_screenshots.js +69 -0
- package/scripts/validate_release_artifacts.py +167 -0
- package/static/account.html +9 -9
- package/static/activity.html +4 -4
- package/static/admin.html +8 -8
- package/static/agents.html +4 -4
- package/static/chat.html +10 -10
- package/static/css/reference/account.css +137 -1
- package/static/css/reference/chat.css +31 -37
- package/static/css/responsive.css +42 -0
- package/static/css/tokens.5a595671.css +260 -0
- package/static/css/tokens.css +125 -130
- package/static/graph.html +9 -9
- package/static/manifest.json +3 -3
- package/static/plugins.html +4 -4
- package/static/scripts/account.js +4 -4
- package/static/scripts/chat.js +40 -8
- package/static/scripts/workspace.js +78 -0
- package/static/sw.js +3 -1
- package/static/v3/asset-manifest.json +47 -0
- package/static/v3/css/lattice.base.css +128 -0
- package/static/v3/css/lattice.base.e4cdd05d.css +128 -0
- package/static/v3/css/lattice.components.011e988b.css +447 -0
- package/static/v3/css/lattice.components.css +447 -0
- package/static/v3/css/lattice.shell.4920f42d.css +407 -0
- package/static/v3/css/lattice.shell.css +407 -0
- package/static/v3/css/lattice.tokens.c597ff81.css +132 -0
- package/static/v3/css/lattice.tokens.css +132 -0
- package/static/v3/css/lattice.views.3ee19d4e.css +277 -0
- package/static/v3/css/lattice.views.css +277 -0
- package/static/v3/index.html +69 -0
- package/static/v3/js/app.46fb61d9.js +26 -0
- package/static/v3/js/app.js +26 -0
- package/static/v3/js/core/api.22a41d42.js +344 -0
- package/static/v3/js/core/api.js +344 -0
- package/static/v3/js/core/components.4c83e0a9.js +222 -0
- package/static/v3/js/core/components.js +222 -0
- package/static/v3/js/core/dom.a2773eb0.js +148 -0
- package/static/v3/js/core/dom.js +148 -0
- package/static/v3/js/core/router.584570f2.js +37 -0
- package/static/v3/js/core/router.js +37 -0
- package/static/v3/js/core/routes.f935dd50.js +78 -0
- package/static/v3/js/core/routes.js +78 -0
- package/static/v3/js/core/shell.1b6199d6.js +363 -0
- package/static/v3/js/core/shell.js +363 -0
- package/static/v3/js/core/store.34ebd5e6.js +113 -0
- package/static/v3/js/core/store.js +113 -0
- package/static/v3/js/views/admin-audit.660a1fb1.js +185 -0
- package/static/v3/js/views/admin-audit.js +185 -0
- package/static/v3/js/views/admin-permissions.a7ae5f09.js +177 -0
- package/static/v3/js/views/admin-permissions.js +177 -0
- package/static/v3/js/views/admin-policies.3658fd86.js +102 -0
- package/static/v3/js/views/admin-policies.js +102 -0
- package/static/v3/js/views/admin-private-vpc.7d342d36.js +135 -0
- package/static/v3/js/views/admin-private-vpc.js +135 -0
- package/static/v3/js/views/admin-security.07c66b72.js +180 -0
- package/static/v3/js/views/admin-security.js +180 -0
- package/static/v3/js/views/admin-users.03bac88c.js +168 -0
- package/static/v3/js/views/admin-users.js +168 -0
- package/static/v3/js/views/agents.14e48bdd.js +193 -0
- package/static/v3/js/views/agents.js +193 -0
- package/static/v3/js/views/chat.718144ce.js +449 -0
- package/static/v3/js/views/chat.js +449 -0
- package/static/v3/js/views/files.4935197e.js +186 -0
- package/static/v3/js/views/files.js +186 -0
- package/static/v3/js/views/home.cdde3b32.js +119 -0
- package/static/v3/js/views/home.js +119 -0
- package/static/v3/js/views/hybrid-search.b22b97e0.js +195 -0
- package/static/v3/js/views/hybrid-search.js +195 -0
- package/static/v3/js/views/knowledge-graph.a14ea7e7.js +237 -0
- package/static/v3/js/views/knowledge-graph.js +237 -0
- package/static/v3/js/views/models.a1ffa147.js +256 -0
- package/static/v3/js/views/models.js +256 -0
- package/static/v3/js/views/my-computer.1b2ff621.js +237 -0
- package/static/v3/js/views/my-computer.js +237 -0
- package/static/v3/js/views/pipeline.c522f1ce.js +157 -0
- package/static/v3/js/views/pipeline.js +157 -0
- package/static/v3/js/views/settings.4f777210.js +250 -0
- package/static/v3/js/views/settings.js +250 -0
- package/static/workflows.html +4 -4
- package/static/workspace.css +340 -2
- package/static/workspace.html +43 -24
- package/docs/images/tmp_frames/frame_00.png +0 -0
- package/docs/images/tmp_frames/frame_01.png +0 -0
- package/docs/images/tmp_frames/frame_02.png +0 -0
- package/docs/images/tmp_frames/frame_03.png +0 -0
- package/docs/images/tmp_frames/hero_00.png +0 -0
- package/docs/images/tmp_frames/hero_01.png +0 -0
- package/docs/images/tmp_frames/hero_02.png +0 -0
- package/docs/images/tmp_frames/hero_03.png +0 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/* ============================================================================
|
|
2
|
+
* View: Files — connected sources & indexed documents.
|
|
3
|
+
* Lists the sources the workspace has indexed, with a human-readable status
|
|
4
|
+
* roll-up. Data comes from /workspace/indexing (live); when indexing is
|
|
5
|
+
* unavailable, the table renders an empty unavailable state.
|
|
6
|
+
*
|
|
7
|
+
* View contract (shared by all views):
|
|
8
|
+
* export async function render(ctx) -> single DOM node
|
|
9
|
+
* ctx = { h, icon, api, store, c, route, params, navigate, toast }
|
|
10
|
+
* ========================================================================== */
|
|
11
|
+
|
|
12
|
+
import { timeAgo } from "../core/dom.js";
|
|
13
|
+
|
|
14
|
+
/** Tabler glyph per file kind — keeps the table scannable. */
|
|
15
|
+
const KIND_ICON = {
|
|
16
|
+
markdown: "file-text",
|
|
17
|
+
config: "settings",
|
|
18
|
+
image: "photo",
|
|
19
|
+
data: "table",
|
|
20
|
+
default: "file",
|
|
21
|
+
};
|
|
22
|
+
const iconForKind = (k) => KIND_ICON[k] || KIND_ICON.default;
|
|
23
|
+
|
|
24
|
+
/** Bytes → compact human string (1.0 KB / 4.7 KB / 180 KB / 1.2 MB). */
|
|
25
|
+
function humanSize(bytes) {
|
|
26
|
+
if (bytes === null || bytes === undefined || bytes === "") return "—";
|
|
27
|
+
const n = Number(bytes);
|
|
28
|
+
if (!Number.isFinite(n) || n < 0) return "—";
|
|
29
|
+
if (n < 1024) return `${n} B`;
|
|
30
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
31
|
+
let v = n / 1024, i = 0;
|
|
32
|
+
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
|
33
|
+
return `${v.toFixed(v >= 100 || Number.isInteger(v) ? 0 : 1)} ${units[i]}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Live shape is {sources:[...]}; legacy {files:[...]} payloads normalize too. */
|
|
37
|
+
function normalize(data) {
|
|
38
|
+
if (data && Array.isArray(data.sources)) {
|
|
39
|
+
return data.sources.map((source) => ({
|
|
40
|
+
name: source.label || source.id || "local source",
|
|
41
|
+
kind: "default",
|
|
42
|
+
size: null,
|
|
43
|
+
path: source.root_path || source.id || "",
|
|
44
|
+
indexed: Number(source.success_count || 0) > 0,
|
|
45
|
+
updated: source.last_run_at || source.updated_at || null,
|
|
46
|
+
count: Number(source.success_count || 0),
|
|
47
|
+
status: source.status || (source.watch_active ? "watching" : "idle"),
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
const list = Array.isArray(data) ? data : (data && Array.isArray(data.files) ? data.files : null);
|
|
51
|
+
if (!list) return null;
|
|
52
|
+
return list.map((f) => ({
|
|
53
|
+
name: f.name || (f.path ? String(f.path).split("/").pop() : "untitled"),
|
|
54
|
+
kind: f.kind || "default",
|
|
55
|
+
size: Number(f.size) || 0,
|
|
56
|
+
path: f.path || f.name || "",
|
|
57
|
+
indexed: f.indexed === true,
|
|
58
|
+
updated: f.updated || f.modified || f.mtime || null,
|
|
59
|
+
count: Number(f.count || 0),
|
|
60
|
+
status: f.status || null,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function render(ctx) {
|
|
65
|
+
const { h, icon, api, c, navigate, toast } = ctx;
|
|
66
|
+
|
|
67
|
+
// Folder connection/watch needs the desktop local-agent connector, which is
|
|
68
|
+
// not enabled in this build. Say so plainly rather than implying it's coming.
|
|
69
|
+
const unavailableToast = () =>
|
|
70
|
+
toast("Connecting a folder requires the Lattice desktop local agent — not available in this build.", "warn");
|
|
71
|
+
|
|
72
|
+
const statHost = h("div.lt3-statrow", c.loading({ lines: 1 }));
|
|
73
|
+
const srcSlot = h("span", c.sourceBadge("pending"));
|
|
74
|
+
const tableHost = h("div", c.loading({ lines: 4 }));
|
|
75
|
+
|
|
76
|
+
const root = h("div.lt3-stack-6",
|
|
77
|
+
c.viewHeader({
|
|
78
|
+
eyebrow: "Data",
|
|
79
|
+
title: "Files",
|
|
80
|
+
sub: "Connected sources and the documents Lattice has indexed for retrieval. Everything stays on this machine.",
|
|
81
|
+
actions: [
|
|
82
|
+
h("button.lt3-btn.lt3-btn--ghost", { on: { click: () => navigate("knowledge-graph") } }, icon("chart-dots-3"), "View graph"),
|
|
83
|
+
h("button.lt3-btn.lt3-btn--ghost", { title: "Requires the desktop local agent (not in this build)", on: { click: unavailableToast } }, icon("folder-plus"), "Connect folder"),
|
|
84
|
+
],
|
|
85
|
+
}),
|
|
86
|
+
statHost,
|
|
87
|
+
h("div.lt3-drop",
|
|
88
|
+
h("div.lt3-pillar__icon", icon("cloud-upload")),
|
|
89
|
+
h("div",
|
|
90
|
+
h("div", { style: { "font-weight": "var(--lt3-weight-semi)" } }, "Drag files or connect a folder"),
|
|
91
|
+
h("p.lt3-faint", { style: { "font-size": "var(--lt3-text-sm)", "margin-top": "var(--lt3-space-1)" } },
|
|
92
|
+
"Lattice watches the source, chunks it, embeds it, and links it into the knowledge graph."),
|
|
93
|
+
),
|
|
94
|
+
h("button.lt3-btn.lt3-btn--ghost", { title: "Requires the desktop local agent (not in this build)", on: { click: unavailableToast } }, icon("folder-plus"), "Choose folder"),
|
|
95
|
+
),
|
|
96
|
+
c.panel({
|
|
97
|
+
head: h("div.lt3-row", { style: { "justify-content": "space-between", "align-items": "flex-start", width: "100%" } },
|
|
98
|
+
h("div",
|
|
99
|
+
h("div.lt3-eyebrow", "Index"),
|
|
100
|
+
h("h3.lt3-panel__title", "Indexed documents"),
|
|
101
|
+
),
|
|
102
|
+
srcSlot,
|
|
103
|
+
),
|
|
104
|
+
children: tableHost,
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
hydrate(ctx, { statHost, srcSlot, tableHost });
|
|
109
|
+
return root;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function hydrate(ctx, slots) {
|
|
113
|
+
const { h, icon, api, c, toast } = ctx;
|
|
114
|
+
const { statHost, srcSlot, tableHost } = slots;
|
|
115
|
+
|
|
116
|
+
const probe = await api.get("/workspace/indexing", { sources: [], totals: {} });
|
|
117
|
+
const liveFiles = probe.ok && probe.data ? normalize(probe.data) : null;
|
|
118
|
+
const source = probe.source || (liveFiles ? "live" : "unavailable");
|
|
119
|
+
const files = liveFiles || [];
|
|
120
|
+
srcSlot.replaceChildren(c.sourceBadge(source));
|
|
121
|
+
|
|
122
|
+
// ── Stat roll-up ──────────────────────────────────────────────────────────
|
|
123
|
+
const indexedCount = files.filter((f) => f.indexed).length;
|
|
124
|
+
const sourceCount = new Set(
|
|
125
|
+
files.map((f) => (f.path.includes("/") ? f.path.split("/")[0] : "root")),
|
|
126
|
+
).size;
|
|
127
|
+
const totalBytes = files.reduce((sum, f) => sum + (f.size || 0), 0);
|
|
128
|
+
statHost.replaceChildren(
|
|
129
|
+
c.stat({ label: "Total files", value: c.fmtNum(files.length), icon: "files" }),
|
|
130
|
+
c.stat({ label: "Indexed", value: c.fmtNum(indexedCount), icon: "circle-check" }),
|
|
131
|
+
c.stat({ label: "Sources", value: c.fmtNum(sourceCount), icon: "database" }),
|
|
132
|
+
c.stat({ label: "Total size", value: humanSize(totalBytes), icon: "weight" }),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// ── Empty state ─────────────────────────────────────────────────────────────
|
|
136
|
+
if (!files.length) {
|
|
137
|
+
tableHost.replaceChildren(c.emptyState({
|
|
138
|
+
icon: "folder-off",
|
|
139
|
+
title: "No documents indexed yet",
|
|
140
|
+
body: "Connect a folder and Lattice will index it for hybrid retrieval.",
|
|
141
|
+
action: h("button.lt3-btn.lt3-btn--ghost.lt3-btn--sm",
|
|
142
|
+
{ title: "Requires the desktop local agent (not in this build)",
|
|
143
|
+
on: { click: () => toast("Connecting a folder requires the Lattice desktop local agent — not available in this build.", "warn") } },
|
|
144
|
+
icon("folder-plus"), "Connect folder"),
|
|
145
|
+
}));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Table ───────────────────────────────────────────────────────────────────
|
|
150
|
+
const columns = [
|
|
151
|
+
{
|
|
152
|
+
key: "name", label: "Name",
|
|
153
|
+
render: (row) => h("div.lt3-row-2",
|
|
154
|
+
h("span.lt3-filerow__icon", icon(iconForKind(row.kind))),
|
|
155
|
+
h("span", { style: { "font-weight": "var(--lt3-weight-medium)" } }, row.name),
|
|
156
|
+
),
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
key: "path", label: "Path", width: "30%",
|
|
160
|
+
render: (row) => h("span.lt3-mono.lt3-faint", row.path || "—"),
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
key: "count", label: "Indexed", width: "92px",
|
|
164
|
+
render: (row) => h("span.lt3-mono", row.count ? c.fmtNum(row.count) : humanSize(row.size)),
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
key: "status", label: "Status", width: "120px",
|
|
168
|
+
render: (row) => c.statePill(row.indexed ? "indexed" : (row.status || "pending")),
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
key: "updated", label: "Updated", width: "104px",
|
|
172
|
+
render: (row) => h("span.lt3-faint", { style: { "font-size": "var(--lt3-text-xs)" } },
|
|
173
|
+
row.updated ? timeAgo(row.updated) : "—"),
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
key: "_actions", label: "", width: "44px",
|
|
177
|
+
render: (row) => h("button.lt3-iconbtn.lt3-iconbtn--sm", {
|
|
178
|
+
"aria-label": `Actions for ${row.name}`,
|
|
179
|
+
title: "Requires the desktop local agent (not in this build)",
|
|
180
|
+
on: { click: () => toast(`Per-file actions require the Lattice desktop local agent — not available in this build.`, "warn") },
|
|
181
|
+
}, icon("dots-vertical")),
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
tableHost.replaceChildren(c.table(columns, files));
|
|
186
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/* ============================================================================
|
|
2
|
+
* View: Home — the workspace command center.
|
|
3
|
+
* Leads with the product identity (the retrieval lattice: Knowledge Graph +
|
|
4
|
+
* Vector Index + Hybrid Search) and routes into every primary area.
|
|
5
|
+
*
|
|
6
|
+
* View contract (shared by all views):
|
|
7
|
+
* export async function render(ctx) -> single DOM node
|
|
8
|
+
* ctx = { h, icon, api, store, c, route, params, navigate, toast }
|
|
9
|
+
* ========================================================================== */
|
|
10
|
+
|
|
11
|
+
export async function render(ctx) {
|
|
12
|
+
const { h, icon, api, store, c, navigate } = ctx;
|
|
13
|
+
const ws = store.activeWorkspace();
|
|
14
|
+
|
|
15
|
+
const root = h("div.lt3-stack-6",
|
|
16
|
+
c.viewHeader({
|
|
17
|
+
eyebrow: "Local-first AI workspace",
|
|
18
|
+
title: `Welcome to ${ws.name}`,
|
|
19
|
+
sub: "Everything you index stays on this machine. Ask questions, explore the graph, and fuse structure with semantics — no data leaves your computer.",
|
|
20
|
+
actions: [
|
|
21
|
+
h("button.lt3-btn.lt3-btn--ghost", { on: { click: () => navigate("hybrid-search") } }, icon("arrows-join"), "Hybrid search"),
|
|
22
|
+
h("button.lt3-btn.lt3-btn--primary", { on: { click: () => navigate("chat", { new: "1" }) } }, icon("message-plus"), "New chat"),
|
|
23
|
+
],
|
|
24
|
+
}),
|
|
25
|
+
buildHero(ctx),
|
|
26
|
+
h("section",
|
|
27
|
+
c.sectionHead("Retrieval lattice", h("span", { id: "home-idx-src" }, c.sourceBadge("pending"))),
|
|
28
|
+
h("div", { id: "home-pillars" }, c.loading({ lines: 2, block: true })),
|
|
29
|
+
),
|
|
30
|
+
h("section",
|
|
31
|
+
c.sectionHead("Jump back in"),
|
|
32
|
+
buildQuickGrid(ctx),
|
|
33
|
+
),
|
|
34
|
+
h("div.lt3-grid-2",
|
|
35
|
+
c.panel({ eyebrow: "Index", title: "Connected sources", children: h("div", { id: "home-sources" }, c.loading({ lines: 3 })) }),
|
|
36
|
+
c.panel({ eyebrow: "Workspace", title: "At a glance", children: h("div", { id: "home-stats" }, c.loading({ lines: 3 })) }),
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
hydrate(ctx, root);
|
|
41
|
+
return root;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildHero({ h, icon, navigate }) {
|
|
45
|
+
return h("div.lt3-hero",
|
|
46
|
+
h("div.lt3-eyebrow.lt3-hero__eyebrow", icon("sparkles"), "Knowledge Graph · Vector Index · Hybrid Search"),
|
|
47
|
+
h("h2.lt3-hero__title", "One workspace. Three ways to recall everything."),
|
|
48
|
+
h("p.lt3-hero__sub", "Lattice builds a knowledge graph and a vector field from your files, then fuses them so every answer is grounded in both structure and meaning."),
|
|
49
|
+
h("div.lt3-hero__actions",
|
|
50
|
+
h("button.lt3-btn.lt3-btn--primary.lt3-btn--lg", { on: { click: () => navigate("knowledge-graph") } }, icon("chart-dots-3"), "Explore the graph"),
|
|
51
|
+
h("button.lt3-btn.lt3-btn--ghost.lt3-btn--lg", { on: { click: () => navigate("files") } }, icon("folder-plus"), "Connect files"),
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const QUICK = [
|
|
57
|
+
{ key: "knowledge-graph", icon: "chart-dots-3", title: "Knowledge Graph", desc: "Browse entities and relations." },
|
|
58
|
+
{ key: "hybrid-search", icon: "arrows-join", title: "Hybrid Search", desc: "Fuse graph + vector recall." },
|
|
59
|
+
{ key: "chat", icon: "message-2", title: "Chat", desc: "Grounded conversation." },
|
|
60
|
+
{ key: "files", icon: "folders", title: "Files", desc: "Sources and indexing." },
|
|
61
|
+
{ key: "pipeline", icon: "git-branch", title: "Pipeline", desc: "Ingest and embed flows." },
|
|
62
|
+
{ key: "models", icon: "cpu", title: "Models", desc: "Local MLX runtime." },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
function buildQuickGrid({ h, icon, navigate }) {
|
|
66
|
+
return h("div.lt3-quickgrid",
|
|
67
|
+
QUICK.map((q) => h("button.lt3-quick", { style: { "text-align": "left" }, on: { click: () => navigate(q.key) } },
|
|
68
|
+
h("div.lt3-quick__icon", icon(q.icon)),
|
|
69
|
+
h("div.lt3-quick__title", q.title),
|
|
70
|
+
h("div.lt3-quick__desc", q.desc),
|
|
71
|
+
)),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function hydrate(ctx, root) {
|
|
76
|
+
const { h, icon, api, store, c } = ctx;
|
|
77
|
+
const numFmt = c.fmtNum;
|
|
78
|
+
|
|
79
|
+
// Index status → pillars + sources + topbar chip.
|
|
80
|
+
const idx = store.get().indexStatus
|
|
81
|
+
? { data: store.get().indexStatus, source: "live" }
|
|
82
|
+
: await api.indexStatus().then((r) => { store.setIndexStatus(r.data); return r; });
|
|
83
|
+
|
|
84
|
+
root.querySelector("#home-idx-src")?.replaceChildren(c.sourceBadge(idx.source));
|
|
85
|
+
root.querySelector("#home-pillars")?.replaceChildren(c.pillars(idx.data));
|
|
86
|
+
|
|
87
|
+
const sources = (idx.data && idx.data.sources) || [];
|
|
88
|
+
const srcHost = root.querySelector("#home-sources");
|
|
89
|
+
if (srcHost) {
|
|
90
|
+
srcHost.replaceChildren(
|
|
91
|
+
sources.length
|
|
92
|
+
? h("div.lt3-stack-3", sources.map((s) => h("div.lt3-stack-2",
|
|
93
|
+
h("div.lt3-row", { style: { "justify-content": "space-between" } },
|
|
94
|
+
h("div.lt3-row-2", icon("database"), h("b", { style: { "font-size": "var(--lt3-text-sm)" } }, s.label)),
|
|
95
|
+
c.statePill(s.state),
|
|
96
|
+
),
|
|
97
|
+
c.meter(s.progress ?? (s.state === "indexed" ? 1 : 0.5), s.state === "indexing" ? "warn" : "vector"),
|
|
98
|
+
h("div.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)" } }, `${numFmt(s.files)} files`),
|
|
99
|
+
)))
|
|
100
|
+
: c.emptyState({ icon: "database-off", title: "No sources connected", body: "Connect a folder to start indexing." }),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Workspace counts.
|
|
105
|
+
const os = await api.workspaceOs();
|
|
106
|
+
const counts = (os.data && os.data.counts) || {};
|
|
107
|
+
const statHost = root.querySelector("#home-stats");
|
|
108
|
+
if (statHost) {
|
|
109
|
+
statHost.replaceChildren(
|
|
110
|
+
h("div.lt3-statrow",
|
|
111
|
+
c.stat({ label: "Memories", value: numFmt(counts.memories), icon: "brain" }),
|
|
112
|
+
c.stat({ label: "Traces", value: numFmt(counts.traces), icon: "route" }),
|
|
113
|
+
c.stat({ label: "Workflows", value: numFmt(counts.workflows), icon: "git-branch" }),
|
|
114
|
+
c.stat({ label: "Skills", value: numFmt(counts.skills), icon: "puzzle" }),
|
|
115
|
+
),
|
|
116
|
+
h("div", { style: { "margin-top": "var(--lt3-space-3)" } }, c.sourceBadge(os.source)),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/* ============================================================================
|
|
2
|
+
* View: Home — the workspace command center.
|
|
3
|
+
* Leads with the product identity (the retrieval lattice: Knowledge Graph +
|
|
4
|
+
* Vector Index + Hybrid Search) and routes into every primary area.
|
|
5
|
+
*
|
|
6
|
+
* View contract (shared by all views):
|
|
7
|
+
* export async function render(ctx) -> single DOM node
|
|
8
|
+
* ctx = { h, icon, api, store, c, route, params, navigate, toast }
|
|
9
|
+
* ========================================================================== */
|
|
10
|
+
|
|
11
|
+
export async function render(ctx) {
|
|
12
|
+
const { h, icon, api, store, c, navigate } = ctx;
|
|
13
|
+
const ws = store.activeWorkspace();
|
|
14
|
+
|
|
15
|
+
const root = h("div.lt3-stack-6",
|
|
16
|
+
c.viewHeader({
|
|
17
|
+
eyebrow: "Local-first AI workspace",
|
|
18
|
+
title: `Welcome to ${ws.name}`,
|
|
19
|
+
sub: "Everything you index stays on this machine. Ask questions, explore the graph, and fuse structure with semantics — no data leaves your computer.",
|
|
20
|
+
actions: [
|
|
21
|
+
h("button.lt3-btn.lt3-btn--ghost", { on: { click: () => navigate("hybrid-search") } }, icon("arrows-join"), "Hybrid search"),
|
|
22
|
+
h("button.lt3-btn.lt3-btn--primary", { on: { click: () => navigate("chat", { new: "1" }) } }, icon("message-plus"), "New chat"),
|
|
23
|
+
],
|
|
24
|
+
}),
|
|
25
|
+
buildHero(ctx),
|
|
26
|
+
h("section",
|
|
27
|
+
c.sectionHead("Retrieval lattice", h("span", { id: "home-idx-src" }, c.sourceBadge("pending"))),
|
|
28
|
+
h("div", { id: "home-pillars" }, c.loading({ lines: 2, block: true })),
|
|
29
|
+
),
|
|
30
|
+
h("section",
|
|
31
|
+
c.sectionHead("Jump back in"),
|
|
32
|
+
buildQuickGrid(ctx),
|
|
33
|
+
),
|
|
34
|
+
h("div.lt3-grid-2",
|
|
35
|
+
c.panel({ eyebrow: "Index", title: "Connected sources", children: h("div", { id: "home-sources" }, c.loading({ lines: 3 })) }),
|
|
36
|
+
c.panel({ eyebrow: "Workspace", title: "At a glance", children: h("div", { id: "home-stats" }, c.loading({ lines: 3 })) }),
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
hydrate(ctx, root);
|
|
41
|
+
return root;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildHero({ h, icon, navigate }) {
|
|
45
|
+
return h("div.lt3-hero",
|
|
46
|
+
h("div.lt3-eyebrow.lt3-hero__eyebrow", icon("sparkles"), "Knowledge Graph · Vector Index · Hybrid Search"),
|
|
47
|
+
h("h2.lt3-hero__title", "One workspace. Three ways to recall everything."),
|
|
48
|
+
h("p.lt3-hero__sub", "Lattice builds a knowledge graph and a vector field from your files, then fuses them so every answer is grounded in both structure and meaning."),
|
|
49
|
+
h("div.lt3-hero__actions",
|
|
50
|
+
h("button.lt3-btn.lt3-btn--primary.lt3-btn--lg", { on: { click: () => navigate("knowledge-graph") } }, icon("chart-dots-3"), "Explore the graph"),
|
|
51
|
+
h("button.lt3-btn.lt3-btn--ghost.lt3-btn--lg", { on: { click: () => navigate("files") } }, icon("folder-plus"), "Connect files"),
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const QUICK = [
|
|
57
|
+
{ key: "knowledge-graph", icon: "chart-dots-3", title: "Knowledge Graph", desc: "Browse entities and relations." },
|
|
58
|
+
{ key: "hybrid-search", icon: "arrows-join", title: "Hybrid Search", desc: "Fuse graph + vector recall." },
|
|
59
|
+
{ key: "chat", icon: "message-2", title: "Chat", desc: "Grounded conversation." },
|
|
60
|
+
{ key: "files", icon: "folders", title: "Files", desc: "Sources and indexing." },
|
|
61
|
+
{ key: "pipeline", icon: "git-branch", title: "Pipeline", desc: "Ingest and embed flows." },
|
|
62
|
+
{ key: "models", icon: "cpu", title: "Models", desc: "Local MLX runtime." },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
function buildQuickGrid({ h, icon, navigate }) {
|
|
66
|
+
return h("div.lt3-quickgrid",
|
|
67
|
+
QUICK.map((q) => h("button.lt3-quick", { style: { "text-align": "left" }, on: { click: () => navigate(q.key) } },
|
|
68
|
+
h("div.lt3-quick__icon", icon(q.icon)),
|
|
69
|
+
h("div.lt3-quick__title", q.title),
|
|
70
|
+
h("div.lt3-quick__desc", q.desc),
|
|
71
|
+
)),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function hydrate(ctx, root) {
|
|
76
|
+
const { h, icon, api, store, c } = ctx;
|
|
77
|
+
const numFmt = c.fmtNum;
|
|
78
|
+
|
|
79
|
+
// Index status → pillars + sources + topbar chip.
|
|
80
|
+
const idx = store.get().indexStatus
|
|
81
|
+
? { data: store.get().indexStatus, source: "live" }
|
|
82
|
+
: await api.indexStatus().then((r) => { store.setIndexStatus(r.data); return r; });
|
|
83
|
+
|
|
84
|
+
root.querySelector("#home-idx-src")?.replaceChildren(c.sourceBadge(idx.source));
|
|
85
|
+
root.querySelector("#home-pillars")?.replaceChildren(c.pillars(idx.data));
|
|
86
|
+
|
|
87
|
+
const sources = (idx.data && idx.data.sources) || [];
|
|
88
|
+
const srcHost = root.querySelector("#home-sources");
|
|
89
|
+
if (srcHost) {
|
|
90
|
+
srcHost.replaceChildren(
|
|
91
|
+
sources.length
|
|
92
|
+
? h("div.lt3-stack-3", sources.map((s) => h("div.lt3-stack-2",
|
|
93
|
+
h("div.lt3-row", { style: { "justify-content": "space-between" } },
|
|
94
|
+
h("div.lt3-row-2", icon("database"), h("b", { style: { "font-size": "var(--lt3-text-sm)" } }, s.label)),
|
|
95
|
+
c.statePill(s.state),
|
|
96
|
+
),
|
|
97
|
+
c.meter(s.progress ?? (s.state === "indexed" ? 1 : 0.5), s.state === "indexing" ? "warn" : "vector"),
|
|
98
|
+
h("div.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)" } }, `${numFmt(s.files)} files`),
|
|
99
|
+
)))
|
|
100
|
+
: c.emptyState({ icon: "database-off", title: "No sources connected", body: "Connect a folder to start indexing." }),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Workspace counts.
|
|
105
|
+
const os = await api.workspaceOs();
|
|
106
|
+
const counts = (os.data && os.data.counts) || {};
|
|
107
|
+
const statHost = root.querySelector("#home-stats");
|
|
108
|
+
if (statHost) {
|
|
109
|
+
statHost.replaceChildren(
|
|
110
|
+
h("div.lt3-statrow",
|
|
111
|
+
c.stat({ label: "Memories", value: numFmt(counts.memories), icon: "brain" }),
|
|
112
|
+
c.stat({ label: "Traces", value: numFmt(counts.traces), icon: "route" }),
|
|
113
|
+
c.stat({ label: "Workflows", value: numFmt(counts.workflows), icon: "git-branch" }),
|
|
114
|
+
c.stat({ label: "Skills", value: numFmt(counts.skills), icon: "puzzle" }),
|
|
115
|
+
),
|
|
116
|
+
h("div", { style: { "margin-top": "var(--lt3-space-3)" } }, c.sourceBadge(os.source)),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/* ============================================================================
|
|
2
|
+
* View: Hybrid Search — the fused-retrieval surface (headline capability).
|
|
3
|
+
* Runs api.hybridSearch(query, {weights}) and shows, per result, how keyword,
|
|
4
|
+
* local vector and graph signals combine into the fused score. Missing
|
|
5
|
+
* endpoints render an unavailable state.
|
|
6
|
+
* ========================================================================== */
|
|
7
|
+
|
|
8
|
+
const MODES = [
|
|
9
|
+
{ key: "hybrid", label: "Hybrid" },
|
|
10
|
+
{ key: "vector", label: "Vector" },
|
|
11
|
+
{ key: "graph", label: "Graph" },
|
|
12
|
+
{ key: "keyword", label: "Keyword" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const MODE_WEIGHTS = {
|
|
16
|
+
hybrid: { keyword: 0.35, vector: 0.40, graph: 0.25 },
|
|
17
|
+
vector: { keyword: 0, vector: 1, graph: 0 },
|
|
18
|
+
graph: { keyword: 0, vector: 0, graph: 1 },
|
|
19
|
+
keyword: { keyword: 1, vector: 0, graph: 0 },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const EXAMPLES = ["retrieval design", "vector index config", "rank fusion", "graph adjacency"];
|
|
23
|
+
|
|
24
|
+
const SIGNALS = [
|
|
25
|
+
{ key: "vector", label: "Vector", variant: "vector", icon: "grid-dots", desc: "Local vector similarity from the configured embedding index." },
|
|
26
|
+
{ key: "keyword", label: "Keyword", variant: "", icon: "abc", desc: "Lexical overlap — exact terms and phrases." },
|
|
27
|
+
{ key: "graph", label: "Graph", variant: "graph", icon: "chart-dots-3", desc: "Structural proximity in the knowledge graph." },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export async function render(ctx) {
|
|
31
|
+
const { h, icon, api, store, c } = ctx;
|
|
32
|
+
|
|
33
|
+
const state = { query: "", mode: "hybrid", source: "pending" };
|
|
34
|
+
|
|
35
|
+
let activeWeights = MODE_WEIGHTS.hybrid;
|
|
36
|
+
api.indexStatus().then((r) => { if (r.data) store.setIndexStatus(r.data); });
|
|
37
|
+
|
|
38
|
+
const input = h("input", {
|
|
39
|
+
type: "text", placeholder: "Search your workspace…", "aria-label": "Search query",
|
|
40
|
+
on: { keydown: (e) => { if (e.key === "Enter") run(input.value); } },
|
|
41
|
+
});
|
|
42
|
+
const weightPill = h("span", c.pill(weightLabel(activeWeights), "info"));
|
|
43
|
+
const srcSlot = h("span", c.sourceBadge("pending"));
|
|
44
|
+
const resultsHost = h("div.lt3-stack-6", introBlock());
|
|
45
|
+
|
|
46
|
+
const seg = h("div.lt3-fusion", { role: "tablist", "aria-label": "Fusion mode" },
|
|
47
|
+
MODES.map((m) => h("button", {
|
|
48
|
+
type: "button", role: "tab",
|
|
49
|
+
dataset: { active: String(m.key === state.mode) },
|
|
50
|
+
"aria-selected": String(m.key === state.mode),
|
|
51
|
+
on: { click: () => { state.mode = m.key; syncSeg(); if (state.query) run(state.query); } },
|
|
52
|
+
}, m.label)),
|
|
53
|
+
);
|
|
54
|
+
function syncSeg() {
|
|
55
|
+
seg.querySelectorAll("button").forEach((b, i) => {
|
|
56
|
+
const on = MODES[i].key === state.mode;
|
|
57
|
+
b.dataset.active = String(on); b.setAttribute("aria-selected", String(on));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const root = h("div.lt3-stack-6",
|
|
62
|
+
c.viewHeader({
|
|
63
|
+
eyebrow: "Retrieval · fusion",
|
|
64
|
+
title: "Hybrid Search",
|
|
65
|
+
sub: "Fuse keyword recall, local vector similarity, and knowledge-graph structure. Each result shows the contributing signals behind its rank.",
|
|
66
|
+
actions: [srcSlot],
|
|
67
|
+
}),
|
|
68
|
+
h("section.lt3-search-hero",
|
|
69
|
+
h("div.lt3-row-2", { style: { "align-items": "stretch" } },
|
|
70
|
+
h("div.lt3-search", { style: { flex: "1", height: "46px" } }, icon("search"), input),
|
|
71
|
+
h("button.lt3-btn.lt3-btn--primary.lt3-btn--lg", { on: { click: () => run(input.value) } }, icon("arrows-join"), "Search"),
|
|
72
|
+
),
|
|
73
|
+
h("div.lt3-row", { style: { "justify-content": "space-between", "flex-wrap": "wrap", gap: "var(--lt3-space-3)" } },
|
|
74
|
+
seg,
|
|
75
|
+
h("div.lt3-row-2", h("span.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)" } }, "Weights"), weightPill),
|
|
76
|
+
),
|
|
77
|
+
h("div.lt3-cluster",
|
|
78
|
+
h("span.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)" } }, "Try"),
|
|
79
|
+
EXAMPLES.map((q) => h("button.lt3-chip", { type: "button", on: { click: () => run(q) } }, icon("search"), q)),
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
resultsHost,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
/* ── search flow ───────────────────────────────────────────────────────── */
|
|
86
|
+
async function run(rawQuery) {
|
|
87
|
+
const q = String(rawQuery || "").trim();
|
|
88
|
+
if (!q) { input.focus(); return; }
|
|
89
|
+
state.query = q;
|
|
90
|
+
if (input.value !== q) input.value = q;
|
|
91
|
+
resultsHost.replaceChildren(
|
|
92
|
+
c.sectionHead(`Results for “${q}”`, srcSlot.cloneNode(true)),
|
|
93
|
+
c.loading({ lines: 4 }),
|
|
94
|
+
);
|
|
95
|
+
activeWeights = MODE_WEIGHTS[state.mode] || MODE_WEIGHTS.hybrid;
|
|
96
|
+
weightPill.replaceChildren(c.pill(weightLabel(activeWeights), "info"));
|
|
97
|
+
const res = await api.hybridSearch(q, { weights: activeWeights });
|
|
98
|
+
if (res.weights) {
|
|
99
|
+
activeWeights = res.weights;
|
|
100
|
+
weightPill.replaceChildren(c.pill(weightLabel(activeWeights), "info"));
|
|
101
|
+
}
|
|
102
|
+
state.source = res.source;
|
|
103
|
+
srcSlot.replaceChildren(c.sourceBadge(res.source));
|
|
104
|
+
renderResults(res);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function renderResults(res) {
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
resultsHost.replaceChildren(c.errorState(res.error || "Search failed", () => run(state.query)));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const rows = Array.isArray(res.data) ? res.data : [];
|
|
113
|
+
if (!rows.length) {
|
|
114
|
+
resultsHost.replaceChildren(
|
|
115
|
+
c.sectionHead(`No results for “${state.query}”`, c.sourceBadge(res.source)),
|
|
116
|
+
c.emptyState({ icon: "search-off", title: "Nothing matched", body: "Try broader terms, or switch the fusion mode above." }),
|
|
117
|
+
);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
resultsHost.replaceChildren(
|
|
121
|
+
c.sectionHead(
|
|
122
|
+
`${rows.length} ${rows.length === 1 ? "result" : "results"}`,
|
|
123
|
+
c.pill(MODES.find((m) => m.key === state.mode).label, "info"),
|
|
124
|
+
c.sourceBadge(res.source),
|
|
125
|
+
),
|
|
126
|
+
h("div.lt3-stack-3", rows.map((r) => resultCard(r))),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function resultCard(r) {
|
|
131
|
+
const score = typeof r.score === "number" ? r.score : (0.5 * (r.vector || 0) + 0.2 * (r.lexical || 0) + 0.3 * (r.graph || 0));
|
|
132
|
+
return h("article.lt3-result",
|
|
133
|
+
h("div.lt3-result__top",
|
|
134
|
+
h("div.lt3-result__title", { style: { flex: "1", "min-width": "0" } }, String(r.title || "Untitled")),
|
|
135
|
+
c.pill(`${(score).toFixed(2)} score`, "info", { dot: true }),
|
|
136
|
+
),
|
|
137
|
+
h("div.lt3-faint.lt3-mono", { style: { "font-size": "var(--lt3-text-2xs)" } }, String(r.path || "")),
|
|
138
|
+
r.snippet && h("p.lt3-result__snippet", String(r.snippet)),
|
|
139
|
+
h("div.lt3-result__scores",
|
|
140
|
+
scoreBlock("Vector", r.vector, "vector"),
|
|
141
|
+
scoreBlock("Keyword", r.lexical, ""),
|
|
142
|
+
scoreBlock("Graph", r.graph, "graph"),
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function scoreBlock(label, value, variant) {
|
|
148
|
+
const v = Number(value) || 0;
|
|
149
|
+
return h("div.lt3-score",
|
|
150
|
+
h("div.lt3-score__row", h("span", label), h("b", v.toFixed(2))),
|
|
151
|
+
c.meter(v, variant),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* ── pre-search intro ──────────────────────────────────────────────────── */
|
|
156
|
+
function introBlock() {
|
|
157
|
+
return h("div.lt3-stack-6",
|
|
158
|
+
c.emptyState({
|
|
159
|
+
icon: "arrows-join",
|
|
160
|
+
title: "Search across structure and vector signals",
|
|
161
|
+
body: "Enter a query above. Results show keyword, local vector, and graph scores before fusion.",
|
|
162
|
+
}),
|
|
163
|
+
h("section",
|
|
164
|
+
c.sectionHead("How fusion scores a match"),
|
|
165
|
+
h("div.lt3-grid-3",
|
|
166
|
+
SIGNALS.map((s) => signalCard(s)),
|
|
167
|
+
),
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function signalCard(s) {
|
|
173
|
+
const tint = s.variant === "vector" ? "var(--lt3-pillar-vector)"
|
|
174
|
+
: s.variant === "graph" ? "var(--lt3-pillar-graph)"
|
|
175
|
+
: "var(--accent-3)";
|
|
176
|
+
return c.card(
|
|
177
|
+
h("div.lt3-stack-3",
|
|
178
|
+
h("div.lt3-row-2",
|
|
179
|
+
h("span.lt3-result__title", { style: { display: "grid", "place-items": "center", width: "32px", height: "32px", "border-radius": "var(--lt3-radius-sm)", background: `color-mix(in srgb, ${tint} 16%, transparent)`, color: tint } }, icon(s.icon)),
|
|
180
|
+
h("b", s.label),
|
|
181
|
+
),
|
|
182
|
+
h("p.lt3-muted", { style: { "font-size": "var(--lt3-text-sm)" } }, s.desc),
|
|
183
|
+
c.meter(s.key === "graph" ? 0.85 : s.key === "vector" ? 0.7 : 0.55, s.variant),
|
|
184
|
+
),
|
|
185
|
+
{ flat: true },
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return root;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function weightLabel(weights) {
|
|
193
|
+
const w = { ...MODE_WEIGHTS.hybrid, ...(weights || {}) };
|
|
194
|
+
return `K ${Number(w.keyword || 0).toFixed(2)} · V ${Number(w.vector || 0).toFixed(2)} · G ${Number(w.graph || 0).toFixed(2)}`;
|
|
195
|
+
}
|