ltcai 4.0.0 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -33
- package/docs/CHANGELOG.md +64 -0
- package/docs/REALTIME_COLLABORATION.md +3 -3
- package/docs/V3_FRONTEND.md +9 -8
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +86 -43
- package/docs/kg-schema.md +6 -2
- package/docs/spec-vs-impl.md +10 -10
- package/kg_schema.py +2 -603
- package/knowledge_graph.py +37 -4958
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +15 -16
- package/latticeai/api/agents.py +13 -6
- package/latticeai/api/auth.py +19 -11
- package/latticeai/api/invitations.py +100 -0
- package/latticeai/api/knowledge_graph.py +4 -11
- package/latticeai/api/plugins.py +3 -6
- package/latticeai/api/realtime.py +4 -7
- package/latticeai/api/static_routes.py +9 -12
- package/latticeai/api/ui_redirects.py +26 -0
- package/latticeai/api/workflow_designer.py +39 -6
- package/latticeai/api/workspace.py +24 -10
- package/latticeai/app_factory.py +88 -17
- package/latticeai/brain/_kg_common.py +1123 -0
- package/latticeai/brain/discovery.py +1455 -0
- package/latticeai/brain/documents.py +218 -0
- package/latticeai/brain/ingest.py +644 -0
- package/latticeai/brain/projection.py +561 -0
- package/latticeai/brain/provenance.py +401 -0
- package/latticeai/brain/retrieval.py +1316 -0
- package/latticeai/brain/schema.py +640 -0
- package/latticeai/brain/store.py +216 -0
- package/latticeai/brain/write_master.py +225 -0
- package/latticeai/core/invitations.py +131 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/policy.py +54 -0
- package/latticeai/core/realtime.py +65 -44
- package/latticeai/core/sessions.py +31 -5
- package/latticeai/core/users.py +147 -0
- package/latticeai/core/workspace_os.py +420 -20
- package/latticeai/services/agent_runtime.py +242 -4
- package/latticeai/services/run_executor.py +328 -0
- package/latticeai/services/workspace_service.py +27 -19
- package/package.json +2 -14
- package/scripts/lint_v3.mjs +23 -0
- package/static/v3/asset-manifest.json +21 -14
- package/static/v3/js/{app.356e6452.js → app.c5c80c46.js} +1 -1
- package/static/v3/js/core/{api.7a308b89.js → api.ba0fbf14.js} +58 -1
- package/static/v3/js/core/api.js +57 -0
- package/static/v3/js/core/i18n.880e1fec.js +575 -0
- package/static/v3/js/core/i18n.js +575 -0
- package/static/v3/js/core/routes.37522821.js +101 -0
- package/static/v3/js/core/routes.js +71 -63
- package/static/v3/js/core/{shell.a1657f20.js → shell.e3f6bbfa.js} +67 -38
- package/static/v3/js/core/shell.js +65 -36
- package/static/v3/js/core/{store.204a08b2.js → store.7b2aa044.js} +10 -0
- package/static/v3/js/core/store.js +10 -0
- package/static/v3/js/views/account.eff40715.js +143 -0
- package/static/v3/js/views/account.js +143 -0
- package/static/v3/js/views/activity.0d271ef9.js +67 -0
- package/static/v3/js/views/activity.js +67 -0
- package/static/v3/js/views/{admin-users.03bac88c.js → admin-users.f7ac7b43.js} +4 -6
- package/static/v3/js/views/admin-users.js +4 -6
- package/static/v3/js/views/{agents.014d0b74.js → agents.17c5288d.js} +35 -12
- package/static/v3/js/views/agents.js +35 -12
- package/static/v3/js/views/{chat.e6dd7dd0.js → chat.e250e2cc.js} +23 -0
- package/static/v3/js/views/chat.js +23 -0
- package/static/v3/js/views/{knowledge-graph.5e40cbeb.js → knowledge-graph.4d09c537.js} +27 -7
- package/static/v3/js/views/knowledge-graph.js +27 -7
- package/static/v3/js/views/network.52a4f181.js +97 -0
- package/static/v3/js/views/network.js +97 -0
- package/static/v3/js/views/{planning.9ac3e313.js → planning.4876fd77.js} +26 -5
- package/static/v3/js/views/planning.js +26 -5
- package/static/v3/js/views/runs.b63b2afa.js +144 -0
- package/static/v3/js/views/runs.js +144 -0
- package/static/v3/js/views/{settings.8631fa5e.js → settings.b7140634.js} +7 -8
- package/static/v3/js/views/settings.js +7 -8
- package/static/v3/js/views/snapshots.6f5db095.js +135 -0
- package/static/v3/js/views/snapshots.js +135 -0
- package/static/v3/js/views/{workflows.26c57290.js → workflows.7752225a.js} +87 -2
- package/static/v3/js/views/workflows.js +87 -2
- package/static/v3/js/views/workspace-admin.c466029b.js +156 -0
- package/static/v3/js/views/workspace-admin.js +156 -0
- package/static/account.html +0 -113
- package/static/activity.html +0 -73
- package/static/admin.html +0 -486
- package/static/agents.html +0 -139
- package/static/chat.html +0 -841
- package/static/css/reference/account.css +0 -439
- package/static/css/reference/admin.css +0 -610
- package/static/css/reference/base.css +0 -1661
- package/static/css/reference/chat.css +0 -4623
- package/static/css/reference/graph.css +0 -1016
- package/static/css/responsive.css +0 -861
- package/static/graph.html +0 -122
- package/static/platform.css +0 -104
- package/static/plugins.html +0 -136
- package/static/scripts/account.js +0 -238
- package/static/scripts/admin.js +0 -1614
- package/static/scripts/chat.js +0 -5081
- package/static/scripts/graph.js +0 -1804
- package/static/scripts/platform.js +0 -64
- package/static/scripts/ux.js +0 -167
- package/static/scripts/workspace.js +0 -948
- package/static/v3/js/core/routes.7222343d.js +0 -93
- package/static/workflows.html +0 -146
- package/static/workspace.css +0 -1121
- package/static/workspace.html +0 -357
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { t } from "../core/i18n.880e1fec.js";
|
|
2
|
+
|
|
3
|
+
export async function render(ctx) {
|
|
4
|
+
const { h, api, c } = ctx;
|
|
5
|
+
const feedHost = h("div", c.loading({ lines: 4 }));
|
|
6
|
+
const presenceHost = h("div", c.loading({ lines: 2 }));
|
|
7
|
+
const timelineHost = h("div", c.loading({ lines: 4 }));
|
|
8
|
+
|
|
9
|
+
const root = h("div.lt3-stack-6",
|
|
10
|
+
c.viewHeader({ eyebrow: t("activity.eyebrow"), title: t("activity.title"), sub: t("activity.sub") }),
|
|
11
|
+
c.panel({ title: t("activity.feed"), children: feedHost }),
|
|
12
|
+
c.panel({ title: t("activity.presence"), children: presenceHost }),
|
|
13
|
+
c.panel({ title: t("activity.timeMachine"), children: timelineHost }),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
await load();
|
|
17
|
+
wireLiveFeed();
|
|
18
|
+
return root;
|
|
19
|
+
|
|
20
|
+
async function load() {
|
|
21
|
+
const [feed, presence, timeline] = await Promise.all([api.realtimeFeed(80), api.presence(), api.timeMachine(80)]);
|
|
22
|
+
feedHost.replaceChildren(listEvents(ctx, feed.data?.events || [], feed.source));
|
|
23
|
+
presenceHost.replaceChildren(listPresence(ctx, presence.data?.presence || [], presence.source));
|
|
24
|
+
timelineHost.replaceChildren(listEvents(ctx, timeline.data?.events || [], timeline.source));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function wireLiveFeed() {
|
|
28
|
+
if (!window.EventSource) return;
|
|
29
|
+
try {
|
|
30
|
+
const stream = new EventSource("/realtime/stream");
|
|
31
|
+
stream.onmessage = () => load();
|
|
32
|
+
setTimeout(() => stream.close(), 120000);
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function listEvents(ctx, events, source) {
|
|
38
|
+
const { h, c } = ctx;
|
|
39
|
+
return h("div.lt3-stack-3",
|
|
40
|
+
h("div.lt3-row-2", c.sourceBadge(source)),
|
|
41
|
+
events.length ? c.table([
|
|
42
|
+
{ key: "event", label: t("common.status"), render: (e) => h("div", h("b", e.event_type || e.type || e.area || "event"), h("div.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)" } }, e.payload?.run_id || e.payload?.workflow_id || e.id || "")) },
|
|
43
|
+
{ key: "area", label: t("common.type"), width: "1%", render: (e) => c.pill(e.area || e.kind || "system") },
|
|
44
|
+
{ key: "when", label: t("common.created"), width: "1%", render: (e) => h("span.lt3-faint", { style: { "white-space": "nowrap" } }, fmt(e.timestamp || e.created_at || e.at)) },
|
|
45
|
+
], events.slice(0, 50)) : c.emptyState({ icon: "activity", title: t("activity.feed"), body: t("common.none") }),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function listPresence(ctx, rows, source) {
|
|
50
|
+
const { h, c } = ctx;
|
|
51
|
+
return h("div.lt3-stack-3",
|
|
52
|
+
h("div.lt3-row-2", c.sourceBadge(source)),
|
|
53
|
+
rows.length ? c.table([
|
|
54
|
+
{ key: "user", label: t("account.email"), render: (p) => p.user || p.email || p.client_id || "local" },
|
|
55
|
+
{ key: "workspace", label: "workspace_id", render: (p) => h("span.lt3-mono", p.workspace_id || "personal") },
|
|
56
|
+
{ key: "when", label: t("common.updated"), width: "1%", render: (p) => fmt(p.last_seen || p.joined_at) },
|
|
57
|
+
], rows) : c.emptyState({ icon: "users", title: t("activity.presence"), body: t("common.none") }),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fmt(ts) {
|
|
62
|
+
if (!ts) return "—";
|
|
63
|
+
try {
|
|
64
|
+
const d = typeof ts === "number" ? new Date(ts * 1000) : new Date(ts);
|
|
65
|
+
return Number.isNaN(d.getTime()) ? String(ts) : d.toLocaleString();
|
|
66
|
+
} catch { return String(ts); }
|
|
67
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { t } from "../core/i18n.js";
|
|
2
|
+
|
|
3
|
+
export async function render(ctx) {
|
|
4
|
+
const { h, api, c } = ctx;
|
|
5
|
+
const feedHost = h("div", c.loading({ lines: 4 }));
|
|
6
|
+
const presenceHost = h("div", c.loading({ lines: 2 }));
|
|
7
|
+
const timelineHost = h("div", c.loading({ lines: 4 }));
|
|
8
|
+
|
|
9
|
+
const root = h("div.lt3-stack-6",
|
|
10
|
+
c.viewHeader({ eyebrow: t("activity.eyebrow"), title: t("activity.title"), sub: t("activity.sub") }),
|
|
11
|
+
c.panel({ title: t("activity.feed"), children: feedHost }),
|
|
12
|
+
c.panel({ title: t("activity.presence"), children: presenceHost }),
|
|
13
|
+
c.panel({ title: t("activity.timeMachine"), children: timelineHost }),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
await load();
|
|
17
|
+
wireLiveFeed();
|
|
18
|
+
return root;
|
|
19
|
+
|
|
20
|
+
async function load() {
|
|
21
|
+
const [feed, presence, timeline] = await Promise.all([api.realtimeFeed(80), api.presence(), api.timeMachine(80)]);
|
|
22
|
+
feedHost.replaceChildren(listEvents(ctx, feed.data?.events || [], feed.source));
|
|
23
|
+
presenceHost.replaceChildren(listPresence(ctx, presence.data?.presence || [], presence.source));
|
|
24
|
+
timelineHost.replaceChildren(listEvents(ctx, timeline.data?.events || [], timeline.source));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function wireLiveFeed() {
|
|
28
|
+
if (!window.EventSource) return;
|
|
29
|
+
try {
|
|
30
|
+
const stream = new EventSource("/realtime/stream");
|
|
31
|
+
stream.onmessage = () => load();
|
|
32
|
+
setTimeout(() => stream.close(), 120000);
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function listEvents(ctx, events, source) {
|
|
38
|
+
const { h, c } = ctx;
|
|
39
|
+
return h("div.lt3-stack-3",
|
|
40
|
+
h("div.lt3-row-2", c.sourceBadge(source)),
|
|
41
|
+
events.length ? c.table([
|
|
42
|
+
{ key: "event", label: t("common.status"), render: (e) => h("div", h("b", e.event_type || e.type || e.area || "event"), h("div.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)" } }, e.payload?.run_id || e.payload?.workflow_id || e.id || "")) },
|
|
43
|
+
{ key: "area", label: t("common.type"), width: "1%", render: (e) => c.pill(e.area || e.kind || "system") },
|
|
44
|
+
{ key: "when", label: t("common.created"), width: "1%", render: (e) => h("span.lt3-faint", { style: { "white-space": "nowrap" } }, fmt(e.timestamp || e.created_at || e.at)) },
|
|
45
|
+
], events.slice(0, 50)) : c.emptyState({ icon: "activity", title: t("activity.feed"), body: t("common.none") }),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function listPresence(ctx, rows, source) {
|
|
50
|
+
const { h, c } = ctx;
|
|
51
|
+
return h("div.lt3-stack-3",
|
|
52
|
+
h("div.lt3-row-2", c.sourceBadge(source)),
|
|
53
|
+
rows.length ? c.table([
|
|
54
|
+
{ key: "user", label: t("account.email"), render: (p) => p.user || p.email || p.client_id || "local" },
|
|
55
|
+
{ key: "workspace", label: "workspace_id", render: (p) => h("span.lt3-mono", p.workspace_id || "personal") },
|
|
56
|
+
{ key: "when", label: t("common.updated"), width: "1%", render: (p) => fmt(p.last_seen || p.joined_at) },
|
|
57
|
+
], rows) : c.emptyState({ icon: "users", title: t("activity.presence"), body: t("common.none") }),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fmt(ts) {
|
|
62
|
+
if (!ts) return "—";
|
|
63
|
+
try {
|
|
64
|
+
const d = typeof ts === "number" ? new Date(ts * 1000) : new Date(ts);
|
|
65
|
+
return Number.isNaN(d.getTime()) ? String(ts) : d.toLocaleString();
|
|
66
|
+
} catch { return String(ts); }
|
|
67
|
+
}
|
|
@@ -8,10 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { timeAgo } from "../core/dom.a2773eb0.js";
|
|
10
10
|
|
|
11
|
-
const UNAVAILABLE = "not available from this read-only users view.";
|
|
12
|
-
|
|
13
11
|
export async function render(ctx) {
|
|
14
|
-
const { h, icon, api, c, toast } = ctx;
|
|
12
|
+
const { h, icon, api, c, toast, navigate } = ctx;
|
|
15
13
|
|
|
16
14
|
const statHost = h("div.lt3-statrow", c.loading({ lines: 1 }));
|
|
17
15
|
const tableHost = h("div", c.loading({ lines: 4 }));
|
|
@@ -24,7 +22,7 @@ export async function render(ctx) {
|
|
|
24
22
|
sub: "Workspace members and access.",
|
|
25
23
|
actions: [
|
|
26
24
|
h("button.lt3-btn.lt3-btn--primary",
|
|
27
|
-
{ on: { click: () =>
|
|
25
|
+
{ on: { click: () => navigate("workspace-admin") } },
|
|
28
26
|
icon("user-plus"), "Invite user"),
|
|
29
27
|
],
|
|
30
28
|
}),
|
|
@@ -67,7 +65,7 @@ export async function render(ctx) {
|
|
|
67
65
|
title: "No members yet",
|
|
68
66
|
body: "Invite teammates to give them access to this workspace.",
|
|
69
67
|
action: h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm",
|
|
70
|
-
{ on: { click: () =>
|
|
68
|
+
{ on: { click: () => navigate("workspace-admin") } },
|
|
71
69
|
icon("user-plus"), "Invite user"),
|
|
72
70
|
}));
|
|
73
71
|
return;
|
|
@@ -105,7 +103,7 @@ export async function render(ctx) {
|
|
|
105
103
|
render: (r) => h("button.lt3-iconbtn.lt3-iconbtn--sm",
|
|
106
104
|
{
|
|
107
105
|
"aria-label": `Manage ${r.nickname || r.email}`,
|
|
108
|
-
on: { click: () => toast(`Manage ${r.nickname || r.email}
|
|
106
|
+
on: { click: () => toast(`Manage ${r.nickname || r.email} in Workspaces`, "info") },
|
|
109
107
|
},
|
|
110
108
|
icon("dots-vertical")),
|
|
111
109
|
},
|
|
@@ -8,10 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { timeAgo } from "../core/dom.js";
|
|
10
10
|
|
|
11
|
-
const UNAVAILABLE = "not available from this read-only users view.";
|
|
12
|
-
|
|
13
11
|
export async function render(ctx) {
|
|
14
|
-
const { h, icon, api, c, toast } = ctx;
|
|
12
|
+
const { h, icon, api, c, toast, navigate } = ctx;
|
|
15
13
|
|
|
16
14
|
const statHost = h("div.lt3-statrow", c.loading({ lines: 1 }));
|
|
17
15
|
const tableHost = h("div", c.loading({ lines: 4 }));
|
|
@@ -24,7 +22,7 @@ export async function render(ctx) {
|
|
|
24
22
|
sub: "Workspace members and access.",
|
|
25
23
|
actions: [
|
|
26
24
|
h("button.lt3-btn.lt3-btn--primary",
|
|
27
|
-
{ on: { click: () =>
|
|
25
|
+
{ on: { click: () => navigate("workspace-admin") } },
|
|
28
26
|
icon("user-plus"), "Invite user"),
|
|
29
27
|
],
|
|
30
28
|
}),
|
|
@@ -67,7 +65,7 @@ export async function render(ctx) {
|
|
|
67
65
|
title: "No members yet",
|
|
68
66
|
body: "Invite teammates to give them access to this workspace.",
|
|
69
67
|
action: h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm",
|
|
70
|
-
{ on: { click: () =>
|
|
68
|
+
{ on: { click: () => navigate("workspace-admin") } },
|
|
71
69
|
icon("user-plus"), "Invite user"),
|
|
72
70
|
}));
|
|
73
71
|
return;
|
|
@@ -105,7 +103,7 @@ export async function render(ctx) {
|
|
|
105
103
|
render: (r) => h("button.lt3-iconbtn.lt3-iconbtn--sm",
|
|
106
104
|
{
|
|
107
105
|
"aria-label": `Manage ${r.nickname || r.email}`,
|
|
108
|
-
on: { click: () => toast(`Manage ${r.nickname || r.email}
|
|
106
|
+
on: { click: () => toast(`Manage ${r.nickname || r.email} in Workspaces`, "info") },
|
|
109
107
|
},
|
|
110
108
|
icon("dots-vertical")),
|
|
111
109
|
},
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* role roster enriched with real run counts, the live recent-runs ledger, and
|
|
5
5
|
* runtime health. Reports unavailable state when the runtime is unreachable.
|
|
6
6
|
* Also drives runs directly: a goal + role selection → POST /agents/api/run →
|
|
7
|
-
* a
|
|
7
|
+
* a durable async run, live logs, final status/output, queue/status, and stop.
|
|
8
8
|
* ========================================================================== */
|
|
9
9
|
|
|
10
10
|
import { timeAgo } from "../core/dom.a2773eb0.js";
|
|
@@ -37,7 +37,7 @@ export async function render(ctx) {
|
|
|
37
37
|
h("div",
|
|
38
38
|
h("div.lt3-eyebrow", "Run"),
|
|
39
39
|
h("h3.lt3-panel__title", "Run agents"),
|
|
40
|
-
h("p.lt3-panel__sub", "Give the pipeline a goal. Planner → executor → reviewer run
|
|
40
|
+
h("p.lt3-panel__sub", "Give the pipeline a goal. Planner → executor → reviewer run locally with durable progress and cooperative cancellation."),
|
|
41
41
|
),
|
|
42
42
|
runSrc,
|
|
43
43
|
),
|
|
@@ -134,7 +134,7 @@ function makeRunConsole(ctx, hosts) {
|
|
|
134
134
|
if (!roles.length) { ctx.toast("Select at least one role", "info"); return; }
|
|
135
135
|
|
|
136
136
|
runBtn.disabled = true;
|
|
137
|
-
runBtn.replaceChildren(c.icon("loader-2"), "
|
|
137
|
+
runBtn.replaceChildren(c.icon("loader-2"), "Starting…");
|
|
138
138
|
runSrc.replaceChildren(c.sourceBadge("pending"));
|
|
139
139
|
logsHost.replaceChildren(h("div", { style: { "margin-top": "var(--lt3-space-3)" } }, c.loading({ lines: 4 })));
|
|
140
140
|
|
|
@@ -157,18 +157,40 @@ function makeRunConsole(ctx, hosts) {
|
|
|
157
157
|
const run = data.run || {};
|
|
158
158
|
const result = data.result || {};
|
|
159
159
|
logsHost.replaceChildren(renderRunResult(run, result));
|
|
160
|
-
|
|
160
|
+
if (data.accepted && (run.id || run.run_id)) {
|
|
161
|
+
ctx.toast("Run queued", "ok");
|
|
162
|
+
pollRun(run.id || run.run_id);
|
|
163
|
+
} else {
|
|
164
|
+
ctx.toast(`Run ${mapStatus(result.status) === "failed" ? "completed with failure" : "complete"}`, mapStatus(result.status) === "failed" ? "warn" : "ok");
|
|
165
|
+
}
|
|
161
166
|
|
|
162
167
|
// Refresh runtime so queue/total/recent-runs reflect this run.
|
|
163
168
|
hydrate();
|
|
164
169
|
}
|
|
165
170
|
|
|
171
|
+
async function pollRun(runId) {
|
|
172
|
+
for (let i = 0; i < 80; i += 1) {
|
|
173
|
+
await sleep(i < 10 ? 400 : 1200);
|
|
174
|
+
const res = await ctx.api.agentRunDetail(runId);
|
|
175
|
+
const data = (res && res.data) || {};
|
|
176
|
+
if (!res || !res.ok) return;
|
|
177
|
+
const run = data.run || {};
|
|
178
|
+
logsHost.replaceChildren(renderRunResult(run, run));
|
|
179
|
+
hydrate();
|
|
180
|
+
if (!isActiveStatus(run.status)) {
|
|
181
|
+
const mapped = mapStatus(run.status);
|
|
182
|
+
ctx.toast(`Run ${mapped === "failed" ? "completed with failure" : "finished"}`, mapped === "failed" ? "warn" : "ok");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
166
188
|
/* ── Render a run's result as logs + summary ───────────────────────────── */
|
|
167
189
|
function renderRunResult(run, result) {
|
|
168
190
|
const runId = run.id || run.run_id || result.run_id || result.id;
|
|
169
191
|
const status = mapStatus(result.status || run.status);
|
|
170
|
-
const timeline = Array.isArray(result.timeline) ? result.timeline : [];
|
|
171
|
-
const output = result.output != null ? String(result.output) : "";
|
|
192
|
+
const timeline = Array.isArray(result.timeline) ? result.timeline : (Array.isArray(run.timeline) ? run.timeline : []);
|
|
193
|
+
const output = result.output != null ? String(result.output) : String(run.output_preview || "");
|
|
172
194
|
const retries = Number(result.retries) || 0;
|
|
173
195
|
const active = isActiveStatus(result.status || run.status);
|
|
174
196
|
|
|
@@ -183,9 +205,6 @@ function makeRunConsole(ctx, hosts) {
|
|
|
183
205
|
),
|
|
184
206
|
runId
|
|
185
207
|
? h("button.lt3-btn.lt3-btn--danger.lt3-btn--sm", {
|
|
186
|
-
// The synchronous runtime finishes inline; Stop is offered but
|
|
187
|
-
// reports honestly (stopped:false + reason) when there's nothing
|
|
188
|
-
// left to interrupt.
|
|
189
208
|
title: active ? "Stop this run" : "This run has already finished",
|
|
190
209
|
on: { click: (e) => stopRun(runId, e.currentTarget) },
|
|
191
210
|
}, c.icon("player-stop"), "Stop")
|
|
@@ -242,7 +261,6 @@ function makeRunConsole(ctx, hosts) {
|
|
|
242
261
|
return;
|
|
243
262
|
}
|
|
244
263
|
if (data.stopped === false || data.stopped == null) {
|
|
245
|
-
// The synchronous runtime cannot interrupt an already-finished run.
|
|
246
264
|
ctx.toast(String(data.reason || "Run already finished — nothing to stop"), "warn");
|
|
247
265
|
} else {
|
|
248
266
|
ctx.toast("Run stopped", "ok");
|
|
@@ -510,16 +528,21 @@ function mapStatus(status) {
|
|
|
510
528
|
const s = String(status || "").toLowerCase();
|
|
511
529
|
if (s === "ok" || s === "retried_ok") return "ready";
|
|
512
530
|
if (s === "failed" || s === "rejected") return "failed";
|
|
513
|
-
if (s === "running" || s === "in_progress") return "active";
|
|
531
|
+
if (s === "running" || s === "in_progress" || s === "queued" || s === "cancelling") return "active";
|
|
532
|
+
if (s === "cancelled" || s === "interrupted") return "warn";
|
|
514
533
|
return s || "idle";
|
|
515
534
|
}
|
|
516
535
|
|
|
517
536
|
// An active run is one that could (in principle) still be stopped.
|
|
518
|
-
const ACTIVE_STATES = new Set(["running", "in_progress", "queued", "pending", "active"]);
|
|
537
|
+
const ACTIVE_STATES = new Set(["running", "in_progress", "queued", "pending", "active", "cancelling"]);
|
|
519
538
|
function isActiveStatus(status) {
|
|
520
539
|
return ACTIVE_STATES.has(String(status || "").toLowerCase());
|
|
521
540
|
}
|
|
522
541
|
|
|
542
|
+
function sleep(ms) {
|
|
543
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
544
|
+
}
|
|
545
|
+
|
|
523
546
|
function runNote(r) {
|
|
524
547
|
const out = String(r.output || r.input || "").trim();
|
|
525
548
|
if (out) return out.length > 96 ? out.slice(0, 96) + "…" : out;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* role roster enriched with real run counts, the live recent-runs ledger, and
|
|
5
5
|
* runtime health. Reports unavailable state when the runtime is unreachable.
|
|
6
6
|
* Also drives runs directly: a goal + role selection → POST /agents/api/run →
|
|
7
|
-
* a
|
|
7
|
+
* a durable async run, live logs, final status/output, queue/status, and stop.
|
|
8
8
|
* ========================================================================== */
|
|
9
9
|
|
|
10
10
|
import { timeAgo } from "../core/dom.js";
|
|
@@ -37,7 +37,7 @@ export async function render(ctx) {
|
|
|
37
37
|
h("div",
|
|
38
38
|
h("div.lt3-eyebrow", "Run"),
|
|
39
39
|
h("h3.lt3-panel__title", "Run agents"),
|
|
40
|
-
h("p.lt3-panel__sub", "Give the pipeline a goal. Planner → executor → reviewer run
|
|
40
|
+
h("p.lt3-panel__sub", "Give the pipeline a goal. Planner → executor → reviewer run locally with durable progress and cooperative cancellation."),
|
|
41
41
|
),
|
|
42
42
|
runSrc,
|
|
43
43
|
),
|
|
@@ -134,7 +134,7 @@ function makeRunConsole(ctx, hosts) {
|
|
|
134
134
|
if (!roles.length) { ctx.toast("Select at least one role", "info"); return; }
|
|
135
135
|
|
|
136
136
|
runBtn.disabled = true;
|
|
137
|
-
runBtn.replaceChildren(c.icon("loader-2"), "
|
|
137
|
+
runBtn.replaceChildren(c.icon("loader-2"), "Starting…");
|
|
138
138
|
runSrc.replaceChildren(c.sourceBadge("pending"));
|
|
139
139
|
logsHost.replaceChildren(h("div", { style: { "margin-top": "var(--lt3-space-3)" } }, c.loading({ lines: 4 })));
|
|
140
140
|
|
|
@@ -157,18 +157,40 @@ function makeRunConsole(ctx, hosts) {
|
|
|
157
157
|
const run = data.run || {};
|
|
158
158
|
const result = data.result || {};
|
|
159
159
|
logsHost.replaceChildren(renderRunResult(run, result));
|
|
160
|
-
|
|
160
|
+
if (data.accepted && (run.id || run.run_id)) {
|
|
161
|
+
ctx.toast("Run queued", "ok");
|
|
162
|
+
pollRun(run.id || run.run_id);
|
|
163
|
+
} else {
|
|
164
|
+
ctx.toast(`Run ${mapStatus(result.status) === "failed" ? "completed with failure" : "complete"}`, mapStatus(result.status) === "failed" ? "warn" : "ok");
|
|
165
|
+
}
|
|
161
166
|
|
|
162
167
|
// Refresh runtime so queue/total/recent-runs reflect this run.
|
|
163
168
|
hydrate();
|
|
164
169
|
}
|
|
165
170
|
|
|
171
|
+
async function pollRun(runId) {
|
|
172
|
+
for (let i = 0; i < 80; i += 1) {
|
|
173
|
+
await sleep(i < 10 ? 400 : 1200);
|
|
174
|
+
const res = await ctx.api.agentRunDetail(runId);
|
|
175
|
+
const data = (res && res.data) || {};
|
|
176
|
+
if (!res || !res.ok) return;
|
|
177
|
+
const run = data.run || {};
|
|
178
|
+
logsHost.replaceChildren(renderRunResult(run, run));
|
|
179
|
+
hydrate();
|
|
180
|
+
if (!isActiveStatus(run.status)) {
|
|
181
|
+
const mapped = mapStatus(run.status);
|
|
182
|
+
ctx.toast(`Run ${mapped === "failed" ? "completed with failure" : "finished"}`, mapped === "failed" ? "warn" : "ok");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
166
188
|
/* ── Render a run's result as logs + summary ───────────────────────────── */
|
|
167
189
|
function renderRunResult(run, result) {
|
|
168
190
|
const runId = run.id || run.run_id || result.run_id || result.id;
|
|
169
191
|
const status = mapStatus(result.status || run.status);
|
|
170
|
-
const timeline = Array.isArray(result.timeline) ? result.timeline : [];
|
|
171
|
-
const output = result.output != null ? String(result.output) : "";
|
|
192
|
+
const timeline = Array.isArray(result.timeline) ? result.timeline : (Array.isArray(run.timeline) ? run.timeline : []);
|
|
193
|
+
const output = result.output != null ? String(result.output) : String(run.output_preview || "");
|
|
172
194
|
const retries = Number(result.retries) || 0;
|
|
173
195
|
const active = isActiveStatus(result.status || run.status);
|
|
174
196
|
|
|
@@ -183,9 +205,6 @@ function makeRunConsole(ctx, hosts) {
|
|
|
183
205
|
),
|
|
184
206
|
runId
|
|
185
207
|
? h("button.lt3-btn.lt3-btn--danger.lt3-btn--sm", {
|
|
186
|
-
// The synchronous runtime finishes inline; Stop is offered but
|
|
187
|
-
// reports honestly (stopped:false + reason) when there's nothing
|
|
188
|
-
// left to interrupt.
|
|
189
208
|
title: active ? "Stop this run" : "This run has already finished",
|
|
190
209
|
on: { click: (e) => stopRun(runId, e.currentTarget) },
|
|
191
210
|
}, c.icon("player-stop"), "Stop")
|
|
@@ -242,7 +261,6 @@ function makeRunConsole(ctx, hosts) {
|
|
|
242
261
|
return;
|
|
243
262
|
}
|
|
244
263
|
if (data.stopped === false || data.stopped == null) {
|
|
245
|
-
// The synchronous runtime cannot interrupt an already-finished run.
|
|
246
264
|
ctx.toast(String(data.reason || "Run already finished — nothing to stop"), "warn");
|
|
247
265
|
} else {
|
|
248
266
|
ctx.toast("Run stopped", "ok");
|
|
@@ -510,16 +528,21 @@ function mapStatus(status) {
|
|
|
510
528
|
const s = String(status || "").toLowerCase();
|
|
511
529
|
if (s === "ok" || s === "retried_ok") return "ready";
|
|
512
530
|
if (s === "failed" || s === "rejected") return "failed";
|
|
513
|
-
if (s === "running" || s === "in_progress") return "active";
|
|
531
|
+
if (s === "running" || s === "in_progress" || s === "queued" || s === "cancelling") return "active";
|
|
532
|
+
if (s === "cancelled" || s === "interrupted") return "warn";
|
|
514
533
|
return s || "idle";
|
|
515
534
|
}
|
|
516
535
|
|
|
517
536
|
// An active run is one that could (in principle) still be stopped.
|
|
518
|
-
const ACTIVE_STATES = new Set(["running", "in_progress", "queued", "pending", "active"]);
|
|
537
|
+
const ACTIVE_STATES = new Set(["running", "in_progress", "queued", "pending", "active", "cancelling"]);
|
|
519
538
|
function isActiveStatus(status) {
|
|
520
539
|
return ACTIVE_STATES.has(String(status || "").toLowerCase());
|
|
521
540
|
}
|
|
522
541
|
|
|
542
|
+
function sleep(ms) {
|
|
543
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
544
|
+
}
|
|
545
|
+
|
|
523
546
|
function runNote(r) {
|
|
524
547
|
const out = String(r.output || r.input || "").trim();
|
|
525
548
|
if (out) return out.length > 96 ? out.slice(0, 96) + "…" : out;
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* ========================================================================== */
|
|
13
13
|
|
|
14
14
|
import { timeAgo } from "../core/dom.a2773eb0.js";
|
|
15
|
+
import { t } from "../core/i18n.880e1fec.js";
|
|
15
16
|
|
|
16
17
|
export const layout = "flush";
|
|
17
18
|
|
|
@@ -520,10 +521,32 @@ export async function render(ctx) {
|
|
|
520
521
|
? fileRefs.slice(0, 6).map((p) => ctxItem("var(--faint)", p, null))
|
|
521
522
|
: [ctxEmpty("No file references yet")]),
|
|
522
523
|
|
|
524
|
+
ctxSection(t("chat.whyContext"), "route",
|
|
525
|
+
traceItems(state.lastTrace, q)),
|
|
526
|
+
|
|
523
527
|
h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm.lt3-btn--block", { on: { click: () => navigate("hybrid-search", q ? { q } : undefined) } }, icon("arrows-join"), "Open Hybrid Search"),
|
|
524
528
|
);
|
|
525
529
|
}
|
|
526
530
|
|
|
531
|
+
function traceItems(trace, query) {
|
|
532
|
+
if (!trace) return [ctxEmpty(t("chat.traceUnavailable"))];
|
|
533
|
+
const items = [];
|
|
534
|
+
const meta = trace.retrieval_metadata || {};
|
|
535
|
+
items.push(ctxItem("var(--lt3-pillar-hybrid)", t("chat.traceQuestion"), trace.question || query || "—"));
|
|
536
|
+
if (trace.confidence != null) items.push(ctxItem("var(--lt3-pillar-vector)", t("chat.traceConfidence"), Number(trace.confidence).toFixed(2)));
|
|
537
|
+
if (meta.mode || meta.strategy) items.push(ctxItem("var(--lt3-pillar-hybrid)", "retrieval", meta.mode || meta.strategy));
|
|
538
|
+
if (Array.isArray(trace.graph_nodes) && trace.graph_nodes.length) {
|
|
539
|
+
items.push(ctxItem("var(--lt3-pillar-graph)", t("chat.traceReasons"), `${trace.graph_nodes.length} graph node(s)`));
|
|
540
|
+
}
|
|
541
|
+
if (Array.isArray(trace.vector_matches) && trace.vector_matches.length) {
|
|
542
|
+
items.push(ctxItem("var(--lt3-pillar-vector)", "vector", `${trace.vector_matches.length} match(es)`));
|
|
543
|
+
}
|
|
544
|
+
if (Array.isArray(trace.source_files) && trace.source_files.length) {
|
|
545
|
+
items.push(ctxItem("var(--faint)", "files", `${trace.source_files.length} source file(s)`));
|
|
546
|
+
}
|
|
547
|
+
return items.length ? items : [ctxEmpty(t("chat.traceUnavailable"))];
|
|
548
|
+
}
|
|
549
|
+
|
|
527
550
|
function ctxSection(title, icn, children) {
|
|
528
551
|
return h("section",
|
|
529
552
|
h("div.lt3-ctx-sec__title", icon(icn), title),
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* ========================================================================== */
|
|
13
13
|
|
|
14
14
|
import { timeAgo } from "../core/dom.js";
|
|
15
|
+
import { t } from "../core/i18n.js";
|
|
15
16
|
|
|
16
17
|
export const layout = "flush";
|
|
17
18
|
|
|
@@ -520,10 +521,32 @@ export async function render(ctx) {
|
|
|
520
521
|
? fileRefs.slice(0, 6).map((p) => ctxItem("var(--faint)", p, null))
|
|
521
522
|
: [ctxEmpty("No file references yet")]),
|
|
522
523
|
|
|
524
|
+
ctxSection(t("chat.whyContext"), "route",
|
|
525
|
+
traceItems(state.lastTrace, q)),
|
|
526
|
+
|
|
523
527
|
h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm.lt3-btn--block", { on: { click: () => navigate("hybrid-search", q ? { q } : undefined) } }, icon("arrows-join"), "Open Hybrid Search"),
|
|
524
528
|
);
|
|
525
529
|
}
|
|
526
530
|
|
|
531
|
+
function traceItems(trace, query) {
|
|
532
|
+
if (!trace) return [ctxEmpty(t("chat.traceUnavailable"))];
|
|
533
|
+
const items = [];
|
|
534
|
+
const meta = trace.retrieval_metadata || {};
|
|
535
|
+
items.push(ctxItem("var(--lt3-pillar-hybrid)", t("chat.traceQuestion"), trace.question || query || "—"));
|
|
536
|
+
if (trace.confidence != null) items.push(ctxItem("var(--lt3-pillar-vector)", t("chat.traceConfidence"), Number(trace.confidence).toFixed(2)));
|
|
537
|
+
if (meta.mode || meta.strategy) items.push(ctxItem("var(--lt3-pillar-hybrid)", "retrieval", meta.mode || meta.strategy));
|
|
538
|
+
if (Array.isArray(trace.graph_nodes) && trace.graph_nodes.length) {
|
|
539
|
+
items.push(ctxItem("var(--lt3-pillar-graph)", t("chat.traceReasons"), `${trace.graph_nodes.length} graph node(s)`));
|
|
540
|
+
}
|
|
541
|
+
if (Array.isArray(trace.vector_matches) && trace.vector_matches.length) {
|
|
542
|
+
items.push(ctxItem("var(--lt3-pillar-vector)", "vector", `${trace.vector_matches.length} match(es)`));
|
|
543
|
+
}
|
|
544
|
+
if (Array.isArray(trace.source_files) && trace.source_files.length) {
|
|
545
|
+
items.push(ctxItem("var(--faint)", "files", `${trace.source_files.length} source file(s)`));
|
|
546
|
+
}
|
|
547
|
+
return items.length ? items : [ctxEmpty(t("chat.traceUnavailable"))];
|
|
548
|
+
}
|
|
549
|
+
|
|
527
550
|
function ctxSection(title, icn, children) {
|
|
528
551
|
return h("section",
|
|
529
552
|
h("div.lt3-ctx-sec__title", icon(icn), title),
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { escapeHtml } from "../core/dom.a2773eb0.js";
|
|
11
11
|
import { createGraphCanvas } from "./graph-canvas.17c15d65.js";
|
|
12
|
+
import { t } from "../core/i18n.880e1fec.js";
|
|
12
13
|
|
|
13
14
|
const TYPE_COLOR = {
|
|
14
15
|
Topic: "var(--lt3-pillar-graph)",
|
|
@@ -247,21 +248,25 @@ function buildExplore(ctx) {
|
|
|
247
248
|
async function renderStatus(ctx, host) {
|
|
248
249
|
const { h, icon, api, c } = ctx;
|
|
249
250
|
host.replaceChildren(c.loading({ lines: 3 }));
|
|
250
|
-
const [port, gs, idx] = await Promise.all([api.kgPortability(), api.graphStats(), api.indexStatus()]);
|
|
251
|
+
const [port, gs, idx, coverage] = await Promise.all([api.kgPortability(), api.graphStats(), api.indexStatus(), api.kgProvenanceCoverage()]);
|
|
251
252
|
const p = port.data || {};
|
|
252
253
|
const prov = p.provenance || {};
|
|
254
|
+
const cov = coverage.data || {};
|
|
253
255
|
const nodes = sumCounts((gs.data && gs.data.nodes) || {});
|
|
254
256
|
const edges = sumCounts((gs.data && gs.data.edges) || {});
|
|
255
257
|
const pipelines = (idx.data && idx.data.pipelines) || {};
|
|
258
|
+
const ratio = Number(cov.coverage_ratio ?? cov.ratio ?? 0);
|
|
259
|
+
const covered = Number(cov.nodes_with_provenance ?? cov.covered_nodes ?? 0);
|
|
256
260
|
|
|
257
261
|
host.replaceChildren(
|
|
258
|
-
h("div.lt3-row-2", c.sourceBadge(port.source), h("span.lt3-muted", { style: { "font-size": "var(--lt3-text-sm)" } },
|
|
262
|
+
h("div.lt3-row-2", c.sourceBadge(port.source === "live" || coverage.source === "live" ? "live" : port.source), h("span.lt3-muted", { style: { "font-size": "var(--lt3-text-sm)" } },
|
|
259
263
|
p.graph_schema_version != null ? `Schema v${p.graph_schema_version} · embed dim ${p.embed_dim ?? "—"}` : "Knowledge Graph status")),
|
|
260
264
|
h("div.lt3-statrow",
|
|
261
265
|
c.stat({ label: "Entities", value: c.fmtNum(nodes), icon: "circles" }),
|
|
262
266
|
c.stat({ label: "Relations", value: c.fmtNum(edges), icon: "vector-triangle" }),
|
|
263
267
|
c.stat({ label: "Ingested items", value: c.fmtNum(prov.total || 0), icon: "package-import" }),
|
|
264
268
|
c.stat({ label: "Embedded (RAG-ready)", value: c.fmtNum(prov.embedded || 0), icon: "vector" }),
|
|
269
|
+
c.stat({ label: t("kg.provenanceCoverage"), value: `${Math.round(ratio * 100)}%`, icon: "shield-check", delta: `${covered}/${cov.total_nodes ?? nodes}` }),
|
|
265
270
|
),
|
|
266
271
|
c.card(
|
|
267
272
|
h("div.lt3-stack-3",
|
|
@@ -271,6 +276,14 @@ async function renderStatus(ctx, host) {
|
|
|
271
276
|
pipelineRow(ctx, "Hybrid retrieval", pipelines.hybrid),
|
|
272
277
|
),
|
|
273
278
|
),
|
|
279
|
+
c.card(h("div.lt3-stack-3",
|
|
280
|
+
h("div.lt3-eyebrow", t("kg.provenanceCoverage")),
|
|
281
|
+
h("dl.lt3-keyval",
|
|
282
|
+
h("dt", t("kg.coveredNodes")), h("dd", `${covered}/${cov.total_nodes ?? nodes}`),
|
|
283
|
+
h("dt", t("kg.sourceTypes")), h("dd", compactCounts(cov.provenance_by_source_type || cov.by_source_type || {})),
|
|
284
|
+
h("dt", t("kg.uncoveredTypes")), h("dd", compactCounts(cov.uncovered_by_type || {})),
|
|
285
|
+
),
|
|
286
|
+
)),
|
|
274
287
|
prov.last_ingested_at
|
|
275
288
|
? h("p.lt3-muted", { style: { "font-size": "var(--lt3-text-sm)" } }, `Last ingestion: ${fmtWhen(prov.last_ingested_at)} · ${prov.duplicates || 0} duplicate(s) linked, not re-stored.`)
|
|
276
289
|
: c.emptyState({ icon: "package-import", title: "Nothing ingested yet", body: "Add files or capture a page to populate the graph." }),
|
|
@@ -327,18 +340,18 @@ function buildCapture(ctx) {
|
|
|
327
340
|
|
|
328
341
|
async function run() {
|
|
329
342
|
const url = (input.value || "").trim();
|
|
330
|
-
if (!url) { result.replaceChildren(c.banner(
|
|
343
|
+
if (!url) { result.replaceChildren(c.banner("Enter a URL first.", "warn", "alert-triangle")); return; }
|
|
331
344
|
result.replaceChildren(c.loading({ lines: 1 }));
|
|
332
345
|
const res = await api.browserReadUrl(url);
|
|
333
346
|
const d = res.data || {};
|
|
334
347
|
if (res.ok && d.status === "ok") {
|
|
335
|
-
result.replaceChildren(c.banner(
|
|
348
|
+
result.replaceChildren(c.banner(`Added to your Knowledge Graph${d.duplicate ? " (already present — linked)" : ""}. ${d.chunk_count || 0} chunk(s) indexed.`, "ok", "circle-check"));
|
|
336
349
|
ctx.toast && ctx.toast("Page added to Knowledge Graph");
|
|
337
350
|
} else if (d.status === "empty") {
|
|
338
|
-
result.replaceChildren(c.banner(
|
|
351
|
+
result.replaceChildren(c.banner("No readable text was found on that page.", "warn", "alert-triangle"));
|
|
339
352
|
} else {
|
|
340
353
|
const detail = d.detail || (res.status === 422 ? "The page is blocked or login-required." : "Could not read that URL.");
|
|
341
|
-
result.replaceChildren(c.banner(
|
|
354
|
+
result.replaceChildren(c.banner(detail, "err", "alert-triangle"));
|
|
342
355
|
}
|
|
343
356
|
}
|
|
344
357
|
|
|
@@ -366,7 +379,9 @@ async function renderPortability(ctx, host) {
|
|
|
366
379
|
const port = await api.kgPortability();
|
|
367
380
|
const status = h("div");
|
|
368
381
|
|
|
369
|
-
function note(tone, text) {
|
|
382
|
+
function note(tone, text) {
|
|
383
|
+
status.replaceChildren(c.banner(text, tone, tone === "err" ? "alert-triangle" : tone === "ok" ? "circle-check" : "info-circle"));
|
|
384
|
+
}
|
|
370
385
|
|
|
371
386
|
async function doExport() {
|
|
372
387
|
note("info", "Exporting…");
|
|
@@ -439,6 +454,11 @@ function sumCounts(obj) {
|
|
|
439
454
|
return Object.values(obj || {}).reduce((a, b) => a + (Number(b) || 0), 0);
|
|
440
455
|
}
|
|
441
456
|
|
|
457
|
+
function compactCounts(obj) {
|
|
458
|
+
const entries = Object.entries(obj || {});
|
|
459
|
+
return entries.length ? entries.map(([k, v]) => `${k}: ${v}`).join(" · ") : "—";
|
|
460
|
+
}
|
|
461
|
+
|
|
442
462
|
function prettySource(k) {
|
|
443
463
|
return ({ web_url: "Web URL", browser_tab: "Browser tab", file: "Files", local_file: "Local files",
|
|
444
464
|
note: "Notes", text: "Text", markdown: "Markdown", code: "Code", upload: "Uploads" })[k] || k;
|