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.
Files changed (108) hide show
  1. package/README.md +37 -33
  2. package/docs/CHANGELOG.md +64 -0
  3. package/docs/REALTIME_COLLABORATION.md +3 -3
  4. package/docs/V3_FRONTEND.md +9 -8
  5. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +86 -43
  6. package/docs/kg-schema.md +6 -2
  7. package/docs/spec-vs-impl.md +10 -10
  8. package/kg_schema.py +2 -603
  9. package/knowledge_graph.py +37 -4958
  10. package/latticeai/__init__.py +1 -1
  11. package/latticeai/api/admin.py +15 -16
  12. package/latticeai/api/agents.py +13 -6
  13. package/latticeai/api/auth.py +19 -11
  14. package/latticeai/api/invitations.py +100 -0
  15. package/latticeai/api/knowledge_graph.py +4 -11
  16. package/latticeai/api/plugins.py +3 -6
  17. package/latticeai/api/realtime.py +4 -7
  18. package/latticeai/api/static_routes.py +9 -12
  19. package/latticeai/api/ui_redirects.py +26 -0
  20. package/latticeai/api/workflow_designer.py +39 -6
  21. package/latticeai/api/workspace.py +24 -10
  22. package/latticeai/app_factory.py +88 -17
  23. package/latticeai/brain/_kg_common.py +1123 -0
  24. package/latticeai/brain/discovery.py +1455 -0
  25. package/latticeai/brain/documents.py +218 -0
  26. package/latticeai/brain/ingest.py +644 -0
  27. package/latticeai/brain/projection.py +561 -0
  28. package/latticeai/brain/provenance.py +401 -0
  29. package/latticeai/brain/retrieval.py +1316 -0
  30. package/latticeai/brain/schema.py +640 -0
  31. package/latticeai/brain/store.py +216 -0
  32. package/latticeai/brain/write_master.py +225 -0
  33. package/latticeai/core/invitations.py +131 -0
  34. package/latticeai/core/marketplace.py +1 -1
  35. package/latticeai/core/multi_agent.py +1 -1
  36. package/latticeai/core/policy.py +54 -0
  37. package/latticeai/core/realtime.py +65 -44
  38. package/latticeai/core/sessions.py +31 -5
  39. package/latticeai/core/users.py +147 -0
  40. package/latticeai/core/workspace_os.py +420 -20
  41. package/latticeai/services/agent_runtime.py +242 -4
  42. package/latticeai/services/run_executor.py +328 -0
  43. package/latticeai/services/workspace_service.py +27 -19
  44. package/package.json +2 -14
  45. package/scripts/lint_v3.mjs +23 -0
  46. package/static/v3/asset-manifest.json +21 -14
  47. package/static/v3/js/{app.356e6452.js → app.c5c80c46.js} +1 -1
  48. package/static/v3/js/core/{api.7a308b89.js → api.ba0fbf14.js} +58 -1
  49. package/static/v3/js/core/api.js +57 -0
  50. package/static/v3/js/core/i18n.880e1fec.js +575 -0
  51. package/static/v3/js/core/i18n.js +575 -0
  52. package/static/v3/js/core/routes.37522821.js +101 -0
  53. package/static/v3/js/core/routes.js +71 -63
  54. package/static/v3/js/core/{shell.a1657f20.js → shell.e3f6bbfa.js} +67 -38
  55. package/static/v3/js/core/shell.js +65 -36
  56. package/static/v3/js/core/{store.204a08b2.js → store.7b2aa044.js} +10 -0
  57. package/static/v3/js/core/store.js +10 -0
  58. package/static/v3/js/views/account.eff40715.js +143 -0
  59. package/static/v3/js/views/account.js +143 -0
  60. package/static/v3/js/views/activity.0d271ef9.js +67 -0
  61. package/static/v3/js/views/activity.js +67 -0
  62. package/static/v3/js/views/{admin-users.03bac88c.js → admin-users.f7ac7b43.js} +4 -6
  63. package/static/v3/js/views/admin-users.js +4 -6
  64. package/static/v3/js/views/{agents.014d0b74.js → agents.17c5288d.js} +35 -12
  65. package/static/v3/js/views/agents.js +35 -12
  66. package/static/v3/js/views/{chat.e6dd7dd0.js → chat.e250e2cc.js} +23 -0
  67. package/static/v3/js/views/chat.js +23 -0
  68. package/static/v3/js/views/{knowledge-graph.5e40cbeb.js → knowledge-graph.4d09c537.js} +27 -7
  69. package/static/v3/js/views/knowledge-graph.js +27 -7
  70. package/static/v3/js/views/network.52a4f181.js +97 -0
  71. package/static/v3/js/views/network.js +97 -0
  72. package/static/v3/js/views/{planning.9ac3e313.js → planning.4876fd77.js} +26 -5
  73. package/static/v3/js/views/planning.js +26 -5
  74. package/static/v3/js/views/runs.b63b2afa.js +144 -0
  75. package/static/v3/js/views/runs.js +144 -0
  76. package/static/v3/js/views/{settings.8631fa5e.js → settings.b7140634.js} +7 -8
  77. package/static/v3/js/views/settings.js +7 -8
  78. package/static/v3/js/views/snapshots.6f5db095.js +135 -0
  79. package/static/v3/js/views/snapshots.js +135 -0
  80. package/static/v3/js/views/{workflows.26c57290.js → workflows.7752225a.js} +87 -2
  81. package/static/v3/js/views/workflows.js +87 -2
  82. package/static/v3/js/views/workspace-admin.c466029b.js +156 -0
  83. package/static/v3/js/views/workspace-admin.js +156 -0
  84. package/static/account.html +0 -113
  85. package/static/activity.html +0 -73
  86. package/static/admin.html +0 -486
  87. package/static/agents.html +0 -139
  88. package/static/chat.html +0 -841
  89. package/static/css/reference/account.css +0 -439
  90. package/static/css/reference/admin.css +0 -610
  91. package/static/css/reference/base.css +0 -1661
  92. package/static/css/reference/chat.css +0 -4623
  93. package/static/css/reference/graph.css +0 -1016
  94. package/static/css/responsive.css +0 -861
  95. package/static/graph.html +0 -122
  96. package/static/platform.css +0 -104
  97. package/static/plugins.html +0 -136
  98. package/static/scripts/account.js +0 -238
  99. package/static/scripts/admin.js +0 -1614
  100. package/static/scripts/chat.js +0 -5081
  101. package/static/scripts/graph.js +0 -1804
  102. package/static/scripts/platform.js +0 -64
  103. package/static/scripts/ux.js +0 -167
  104. package/static/scripts/workspace.js +0 -948
  105. package/static/v3/js/core/routes.7222343d.js +0 -93
  106. package/static/workflows.html +0 -146
  107. package/static/workspace.css +0 -1121
  108. package/static/workspace.html +0 -357
@@ -11,6 +11,7 @@ export async function render(ctx) {
11
11
  const { h, c } = ctx;
12
12
  const defsHost = h("div", c.loading({ lines: 3, block: true }));
13
13
  const runsHost = h("div", c.loading({ lines: 3 }));
14
+ const triggerHost = h("div", c.loading({ lines: 2 }));
14
15
  const defsSrc = h("span", c.sourceBadge("pending"));
15
16
  const runsSrc = h("span", c.sourceBadge("pending"));
16
17
 
@@ -21,6 +22,12 @@ export async function render(ctx) {
21
22
  sub: "Repeatable automation: a trigger fires an agent chain that calls tools, reads and writes memory, and produces a result.",
22
23
  }),
23
24
  stageLegend(ctx),
25
+ c.panel({
26
+ eyebrow: "Triggers",
27
+ title: "Trigger status",
28
+ sub: "Interval and brain-event triggers armed from saved workflow definitions.",
29
+ children: triggerHost,
30
+ }),
24
31
  h("section", c.sectionHead("Workflow definitions", defsSrc), defsHost),
25
32
  c.panel({
26
33
  head: h("div.lt3-row", { style: { "justify-content": "space-between", width: "100%" } },
@@ -30,6 +37,7 @@ export async function render(ctx) {
30
37
  );
31
38
 
32
39
  loadDefs();
40
+ loadTriggers();
33
41
  loadRuns();
34
42
  return root;
35
43
 
@@ -59,6 +67,7 @@ export async function render(ctx) {
59
67
  ),
60
68
  w.description ? h("p.lt3-muted", { style: { margin: 0, "font-size": "var(--lt3-text-sm)" } }, w.description) : null,
61
69
  h("div.lt3-cluster", (nodes.slice(0, 6)).map((n) => h("span.lt3-chip", c.icon(nodeIcon(n.type)), n.name || n.type))),
70
+ triggerControls(ctx2, w, nodes),
62
71
  h("div.lt3-row-2",
63
72
  h("button.lt3-btn.lt3-btn--primary.lt3-btn--sm", { on: { click: () => runDef(ctx2, w) } }, c.icon("player-play"), "Run"),
64
73
  h("span.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)" } }, `${triggers} trigger${triggers === 1 ? "" : "s"}`),
@@ -85,7 +94,13 @@ export async function render(ctx) {
85
94
  { key: "status", label: "Status", width: "1%", render: (r) => c.statePill(mapStatus(r.status)) },
86
95
  { key: "name", label: "Workflow", render: (r) => h("b", { style: { "font-size": "var(--lt3-text-sm)" } }, r.workflow_name || r.workflow_id || r.id) },
87
96
  { key: "when", label: "When", width: "1%", render: (r) => h("span.lt3-faint", { style: { "white-space": "nowrap", "font-size": "var(--lt3-text-2xs)" } }, fmtTime(r.created_at || r.started_at)) },
88
- { key: "act", label: "", width: "1%", render: (r) => h("button.lt3-btn.lt3-btn--ghost.lt3-btn--sm", { on: { click: () => replay(ctx, r) } }, c.icon("player-track-next"), "Replay") },
97
+ { key: "act", label: "", width: "1%", render: (r) => h("div.lt3-row-2",
98
+ isActiveStatus(r.status) ? h("button.lt3-btn.lt3-btn--danger.lt3-btn--sm", { on: { click: () => stop(ctx, r) } }, c.icon("player-stop"), "Stop") : null,
99
+ String(r.status || "").toLowerCase() === "awaiting_approval"
100
+ ? h("button.lt3-btn.lt3-btn--primary.lt3-btn--sm", { on: { click: () => decide(ctx, r, true) } }, c.icon("circle-check"), "Approve")
101
+ : null,
102
+ h("button.lt3-btn.lt3-btn--ghost.lt3-btn--sm", { on: { click: () => replay(ctx, r) } }, c.icon("player-track-next"), "Replay"),
103
+ ) },
89
104
  ],
90
105
  runs.slice(0, 20),
91
106
  ));
@@ -96,6 +111,71 @@ export async function render(ctx) {
96
111
  const res = await ctx2.api.workflowReplay(id);
97
112
  ctx2.toast(res && res.ok ? `Replay ready for ${id}` : "Replay unavailable", res && res.ok ? "ok" : "err");
98
113
  }
114
+
115
+ async function stop(ctx2, r) {
116
+ const id = r.id || r.run_id;
117
+ const res = await ctx2.api.stopWorkflowRun(id);
118
+ ctx2.toast(res && res.ok ? `Stop requested for ${id}` : "Stop unavailable", res && res.ok ? "ok" : "err");
119
+ loadRuns();
120
+ }
121
+
122
+ async function decide(ctx2, r, approved) {
123
+ const id = r.id || r.run_id;
124
+ const res = await ctx2.api.resumeWorkflowRun(id, approved);
125
+ ctx2.toast(res && res.ok ? `Decision recorded for ${id}` : "Decision unavailable", res && res.ok ? "ok" : "err");
126
+ loadRuns();
127
+ }
128
+
129
+ async function loadTriggers() {
130
+ const res = await ctx.api.workflowTriggers();
131
+ const armed = Array.isArray(res.data?.armed) ? res.data.armed : [];
132
+ triggerHost.replaceChildren(
133
+ h("div.lt3-stack-3",
134
+ h("div.lt3-row-2", c.sourceBadge(res.source), c.statePill(res.data?.running ? "running" : "idle")),
135
+ armed.length ? c.table([
136
+ { key: "name", label: "Workflow", render: (r) => h("b", r.name || r.workflow_id) },
137
+ { key: "kind", label: "Trigger", width: "1%", render: (r) => c.pill(r.kind) },
138
+ { key: "last", label: "Last fired", width: "1%", render: (r) => fmtTime(r.last_fired_at ? Number(r.last_fired_at) * 1000 : null) },
139
+ { key: "events", label: "Recent", render: (r) => (r.recent_events || []).slice(-2).map((e) => e.type || e.trigger).join(", ") || "—" },
140
+ ], armed) : c.emptyState({ icon: "bolt-off", title: "No triggers armed", body: "Set a workflow trigger below to arm it." }),
141
+ ),
142
+ );
143
+ }
144
+
145
+ function triggerControls(ctx2, w, nodes) {
146
+ const trigger = nodes.find((n) => n.type === "trigger") || {};
147
+ const cfg = trigger.config || {};
148
+ const kind = h("select.lt3-select", { "aria-label": "Trigger type" },
149
+ ["manual", "interval", "brain_event"].map((value) => h("option", { value, selected: String(cfg.trigger || "manual") === value }, value)));
150
+ const seconds = h("input.lt3-input", { type: "number", min: "60", step: "60", value: String(cfg.interval_seconds || 300), "aria-label": "Interval seconds" });
151
+ const sourceType = h("input.lt3-input", { type: "text", value: cfg.source_type || "", placeholder: "source_type", "aria-label": "source_type" });
152
+ async function save() {
153
+ const updated = ensureTrigger(nodes, kind.value, Number(seconds.value) || 300, sourceType.value.trim());
154
+ const res = await ctx2.api.updateWorkflow(w.id, { nodes: updated, metadata: { trigger_updated_at: new Date().toISOString() } });
155
+ ctx2.toast(res && res.ok ? "Trigger saved" : "Trigger update unavailable", res && res.ok ? "ok" : "err");
156
+ if (res && res.ok) { loadDefs(); loadTriggers(); }
157
+ }
158
+ return h("div.lt3-stack-2",
159
+ h("div.lt3-eyebrow", "Trigger configuration"),
160
+ h("div.lt3-row-2", kind, seconds, sourceType, h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: save } }, c.icon("device-floppy"), "Save")),
161
+ );
162
+ }
163
+ }
164
+
165
+ function ensureTrigger(nodes, triggerKind, intervalSeconds, sourceType) {
166
+ const clone = (nodes || []).map((node) => ({ ...node, config: { ...(node.config || {}) } }));
167
+ let trigger = clone.find((n) => n.type === "trigger");
168
+ if (!trigger) {
169
+ trigger = { id: "trigger", type: "trigger", name: "Trigger", next: clone[0]?.id || "output", config: {} };
170
+ clone.unshift(trigger);
171
+ if (!clone.some((n) => n.id === "output")) clone.push({ id: "output", type: "output", name: "Output", config: {}, next: null });
172
+ }
173
+ trigger.config.trigger = triggerKind;
174
+ delete trigger.config.interval_seconds;
175
+ delete trigger.config.source_type;
176
+ if (triggerKind === "interval") trigger.config.interval_seconds = Math.max(60, intervalSeconds || 300);
177
+ if (triggerKind === "brain_event" && sourceType) trigger.config.source_type = sourceType;
178
+ return clone;
99
179
  }
100
180
 
101
181
  function normalizeDefs(data) {
@@ -118,9 +198,14 @@ function mapStatus(s) {
118
198
  const v = String(s || "").toLowerCase();
119
199
  if (v === "ok" || v === "completed" || v === "success") return "ready";
120
200
  if (v === "failed" || v === "error") return "failed";
121
- if (v === "running") return "active";
201
+ if (v === "running" || v === "queued" || v === "cancelling") return "active";
202
+ if (v === "awaiting_approval") return "pending";
203
+ if (v === "cancelled" || v === "interrupted") return "warn";
122
204
  return v || "idle";
123
205
  }
206
+ function isActiveStatus(status) {
207
+ return ["running", "queued", "in_progress", "cancelling"].includes(String(status || "").toLowerCase());
208
+ }
124
209
  function fmtTime(ts) {
125
210
  if (!ts) return "—";
126
211
  try { const d = new Date(ts); return Number.isNaN(d.getTime()) ? String(ts) : d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); }
@@ -0,0 +1,156 @@
1
+ import { t } from "../core/i18n.880e1fec.js";
2
+
3
+ export async function render(ctx) {
4
+ const { h, icon, api, store, c, toast } = ctx;
5
+ const host = h("div.lt3-stack-6", c.loading({ lines: 5, block: true }));
6
+
7
+ async function load() {
8
+ const [registry, invites] = await Promise.all([api.workspaceRegistry(), api.invitations()]);
9
+ const data = registry.data || {};
10
+ const workspaces = Array.isArray(data.workspaces) ? data.workspaces : [];
11
+ store.setWorkspaces(workspaces.length ? workspaces : store.get().workspaces);
12
+ host.replaceChildren(
13
+ c.viewHeader({
14
+ eyebrow: t("workspace.eyebrow"),
15
+ title: t("workspace.title"),
16
+ sub: t("workspace.sub"),
17
+ actions: [c.sourceBadge(registry.source)],
18
+ }),
19
+ createOrgPanel(),
20
+ workspaceGrid(workspaces, data),
21
+ invitationsPanel(invites),
22
+ );
23
+ }
24
+
25
+ function createOrgPanel() {
26
+ const name = h("input.lt3-input", { type: "text", "aria-label": t("workspace.orgName"), placeholder: t("workspace.orgName") });
27
+ return c.panel({
28
+ title: t("workspace.createOrg"),
29
+ children: h("div.lt3-row-2",
30
+ name,
31
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: async () => {
32
+ if (!name.value.trim()) return;
33
+ const res = await api.createOrg(name.value.trim());
34
+ toast(resultText(res, t("workspace.createOrg")), res.ok ? "ok" : "err");
35
+ if (res.ok) { name.value = ""; load(); }
36
+ } } }, icon("plus"), t("workspace.createOrg")),
37
+ ),
38
+ });
39
+ }
40
+
41
+ function workspaceGrid(workspaces, registry) {
42
+ if (!workspaces.length) {
43
+ return c.emptyState({ icon: "building-community", title: t("workspace.title"), body: t("common.unavailable") });
44
+ }
45
+ return h("div.lt3-grid-auto", workspaces.map((ws) => workspaceCard(ws, registry)));
46
+ }
47
+
48
+ function workspaceCard(ws, registry) {
49
+ const roleOptions = registry.roles || ["owner", "admin", "member", "viewer"];
50
+ const members = Array.isArray(ws.members) ? ws.members : [];
51
+ const userId = h("input.lt3-input", { type: "text", placeholder: t("workspace.userId"), "aria-label": t("workspace.userId") });
52
+ const role = h("select.lt3-select", { "aria-label": t("common.role") }, roleOptions.map((r) => h("option", { value: r }, roleLabel(r))));
53
+ return c.card(h("div.lt3-stack-4",
54
+ h("div.lt3-row", { style: { "justify-content": "space-between", "align-items": "flex-start", gap: "var(--lt3-space-3)" } },
55
+ h("div",
56
+ h("b", ws.name || ws.workspace_id),
57
+ h("div.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)", "font-family": "var(--lt3-font-mono)" } }, ws.workspace_id),
58
+ ),
59
+ c.pill(roleLabel(ws.your_role || "member"), "info"),
60
+ ),
61
+ h("dl.lt3-keyval",
62
+ h("dt", t("common.type")), h("dd", ws.type || "—"),
63
+ h("dt", t("common.status")), h("dd", c.statePill(ws.status || "active")),
64
+ h("dt", t("workspace.members")), h("dd", String(ws.member_count ?? members.length)),
65
+ ),
66
+ h("div.lt3-row-2",
67
+ h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: () => activate(ws.workspace_id) } }, icon("selector"), t("workspace.activate")),
68
+ ws.type === "organization" ? h("button.lt3-btn.lt3-btn--ghost.lt3-btn--sm", { on: { click: () => archive(ws.workspace_id) } }, icon("archive"), t("workspace.archive")) : null,
69
+ ),
70
+ ws.type === "organization" ? h("div.lt3-stack-3",
71
+ h("div.lt3-eyebrow", t("workspace.members")),
72
+ members.length ? c.table([
73
+ { key: "user", label: t("workspace.userId"), render: (m) => h("span.lt3-mono", m.user_id || "—") },
74
+ { key: "role", label: t("common.role"), width: "1%", render: (m) => c.pill(roleLabel(m.role)) },
75
+ { key: "act", label: "", width: "1%", render: (m) => h("button.lt3-iconbtn.lt3-iconbtn--sm", { "aria-label": t("common.cancel"), on: { click: () => removeMember(ws.workspace_id, m.user_id) } }, icon("trash")) },
76
+ ], members) : c.emptyState({ icon: "users", title: t("workspace.members"), body: t("common.none") }),
77
+ h("div.lt3-row-2", userId, role, h("button.lt3-btn.lt3-btn--primary.lt3-btn--sm", { on: { click: () => addMember(ws.workspace_id, userId.value.trim(), role.value) } }, icon("user-plus"), t("workspace.addMember"))),
78
+ ) : null,
79
+ ), { interactive: false });
80
+ }
81
+
82
+ function invitationsPanel(invites) {
83
+ const rows = Array.isArray(invites.data?.invitations) ? invites.data.invitations : [];
84
+ const email = h("input.lt3-input", { type: "email", placeholder: t("workspace.inviteEmail"), "aria-label": t("workspace.inviteEmail") });
85
+ const workspace = h("input.lt3-input", { type: "text", placeholder: "workspace_id", "aria-label": "workspace_id", value: store.get().workspaceId || "personal" });
86
+ const role = h("select.lt3-select", { "aria-label": t("common.role") },
87
+ ["member", "viewer", "admin"].map((r) => h("option", { value: r }, roleLabel(r))));
88
+ const token = h("input.lt3-input", { type: "text", placeholder: t("workspace.inviteToken"), "aria-label": t("workspace.inviteToken") });
89
+
90
+ return c.panel({
91
+ title: t("workspace.invitations"),
92
+ actions: [c.sourceBadge(invites.source)],
93
+ children: h("div.lt3-stack-4",
94
+ h("div.lt3-grid-2",
95
+ h("div.lt3-field", h("label.lt3-label", t("workspace.inviteEmail")), email),
96
+ h("div.lt3-field", h("label.lt3-label", "workspace_id"), workspace),
97
+ ),
98
+ h("div.lt3-row-2", role, h("button.lt3-btn.lt3-btn--primary", { on: { click: () => createInvite(email.value.trim(), workspace.value.trim(), role.value) } }, icon("mail-plus"), t("workspace.invitations"))),
99
+ rows.length ? c.table([
100
+ { key: "email", label: t("account.email"), render: (r) => r.email || "—" },
101
+ { key: "role", label: t("common.role"), width: "1%", render: (r) => c.pill(roleLabel(r.role)) },
102
+ { key: "token", label: t("workspace.inviteToken"), render: (r) => h("span.lt3-mono", r.token || r.id || "—") },
103
+ { key: "status", label: t("common.status"), width: "1%", render: (r) => c.statePill(r.status || (r.accepted_at ? "ready" : "pending")) },
104
+ ], rows.slice(0, 20)) : c.emptyState({ icon: "mail", title: t("workspace.invitations"), body: t("common.none") }),
105
+ h("hr.lt3-divider"),
106
+ h("div.lt3-row-2", token, h("button.lt3-btn.lt3-btn--ghost", { on: { click: () => acceptInvite(token.value.trim()) } }, icon("circle-check"), t("workspace.acceptInvite"))),
107
+ ),
108
+ });
109
+ }
110
+
111
+ async function activate(workspace_id) {
112
+ const res = await api.activateWorkspace(workspace_id);
113
+ toast(resultText(res, t("workspace.activated")), res.ok ? "ok" : "err");
114
+ if (res.ok) { store.setWorkspace(workspace_id); load(); }
115
+ }
116
+ async function archive(workspace_id) {
117
+ const res = await api.archiveWorkspace(workspace_id);
118
+ toast(resultText(res, t("workspace.archived")), res.ok ? "ok" : "err");
119
+ if (res.ok) load();
120
+ }
121
+ async function addMember(workspace_id, user_id, role) {
122
+ if (!user_id) return;
123
+ const res = await api.addWorkspaceMember(workspace_id, user_id, role);
124
+ toast(resultText(res, t("workspace.memberAdded")), res.ok ? "ok" : "err");
125
+ if (res.ok) load();
126
+ }
127
+ async function removeMember(workspace_id, user_id) {
128
+ const res = await api.removeWorkspaceMember(workspace_id, user_id);
129
+ toast(resultText(res, t("workspace.memberAdded")), res.ok ? "ok" : "err");
130
+ if (res.ok) load();
131
+ }
132
+ async function createInvite(email, workspace_id, role) {
133
+ const res = await api.createInvitation({ email: email || null, workspace_id: workspace_id || null, role, expires_hours: 168 });
134
+ toast(resultText(res, t("workspace.inviteCreated")), res.ok ? "ok" : "err");
135
+ if (res.ok) load();
136
+ }
137
+ async function acceptInvite(token) {
138
+ if (!token) return;
139
+ const res = await api.acceptInvitation(token);
140
+ toast(resultText(res, t("workspace.inviteAccepted")), res.ok ? "ok" : "err");
141
+ if (res.ok) load();
142
+ }
143
+
144
+ await load();
145
+ return host;
146
+ }
147
+
148
+ function roleLabel(role) {
149
+ return t(`common.${String(role || "member")}`) || String(role || "member");
150
+ }
151
+
152
+ function resultText(res, okText) {
153
+ if (res && res.ok) return okText;
154
+ const data = (res && res.data) || {};
155
+ return String(data.detail || data.error || res?.error || t("common.unavailable"));
156
+ }
@@ -0,0 +1,156 @@
1
+ import { t } from "../core/i18n.js";
2
+
3
+ export async function render(ctx) {
4
+ const { h, icon, api, store, c, toast } = ctx;
5
+ const host = h("div.lt3-stack-6", c.loading({ lines: 5, block: true }));
6
+
7
+ async function load() {
8
+ const [registry, invites] = await Promise.all([api.workspaceRegistry(), api.invitations()]);
9
+ const data = registry.data || {};
10
+ const workspaces = Array.isArray(data.workspaces) ? data.workspaces : [];
11
+ store.setWorkspaces(workspaces.length ? workspaces : store.get().workspaces);
12
+ host.replaceChildren(
13
+ c.viewHeader({
14
+ eyebrow: t("workspace.eyebrow"),
15
+ title: t("workspace.title"),
16
+ sub: t("workspace.sub"),
17
+ actions: [c.sourceBadge(registry.source)],
18
+ }),
19
+ createOrgPanel(),
20
+ workspaceGrid(workspaces, data),
21
+ invitationsPanel(invites),
22
+ );
23
+ }
24
+
25
+ function createOrgPanel() {
26
+ const name = h("input.lt3-input", { type: "text", "aria-label": t("workspace.orgName"), placeholder: t("workspace.orgName") });
27
+ return c.panel({
28
+ title: t("workspace.createOrg"),
29
+ children: h("div.lt3-row-2",
30
+ name,
31
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: async () => {
32
+ if (!name.value.trim()) return;
33
+ const res = await api.createOrg(name.value.trim());
34
+ toast(resultText(res, t("workspace.createOrg")), res.ok ? "ok" : "err");
35
+ if (res.ok) { name.value = ""; load(); }
36
+ } } }, icon("plus"), t("workspace.createOrg")),
37
+ ),
38
+ });
39
+ }
40
+
41
+ function workspaceGrid(workspaces, registry) {
42
+ if (!workspaces.length) {
43
+ return c.emptyState({ icon: "building-community", title: t("workspace.title"), body: t("common.unavailable") });
44
+ }
45
+ return h("div.lt3-grid-auto", workspaces.map((ws) => workspaceCard(ws, registry)));
46
+ }
47
+
48
+ function workspaceCard(ws, registry) {
49
+ const roleOptions = registry.roles || ["owner", "admin", "member", "viewer"];
50
+ const members = Array.isArray(ws.members) ? ws.members : [];
51
+ const userId = h("input.lt3-input", { type: "text", placeholder: t("workspace.userId"), "aria-label": t("workspace.userId") });
52
+ const role = h("select.lt3-select", { "aria-label": t("common.role") }, roleOptions.map((r) => h("option", { value: r }, roleLabel(r))));
53
+ return c.card(h("div.lt3-stack-4",
54
+ h("div.lt3-row", { style: { "justify-content": "space-between", "align-items": "flex-start", gap: "var(--lt3-space-3)" } },
55
+ h("div",
56
+ h("b", ws.name || ws.workspace_id),
57
+ h("div.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)", "font-family": "var(--lt3-font-mono)" } }, ws.workspace_id),
58
+ ),
59
+ c.pill(roleLabel(ws.your_role || "member"), "info"),
60
+ ),
61
+ h("dl.lt3-keyval",
62
+ h("dt", t("common.type")), h("dd", ws.type || "—"),
63
+ h("dt", t("common.status")), h("dd", c.statePill(ws.status || "active")),
64
+ h("dt", t("workspace.members")), h("dd", String(ws.member_count ?? members.length)),
65
+ ),
66
+ h("div.lt3-row-2",
67
+ h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: () => activate(ws.workspace_id) } }, icon("selector"), t("workspace.activate")),
68
+ ws.type === "organization" ? h("button.lt3-btn.lt3-btn--ghost.lt3-btn--sm", { on: { click: () => archive(ws.workspace_id) } }, icon("archive"), t("workspace.archive")) : null,
69
+ ),
70
+ ws.type === "organization" ? h("div.lt3-stack-3",
71
+ h("div.lt3-eyebrow", t("workspace.members")),
72
+ members.length ? c.table([
73
+ { key: "user", label: t("workspace.userId"), render: (m) => h("span.lt3-mono", m.user_id || "—") },
74
+ { key: "role", label: t("common.role"), width: "1%", render: (m) => c.pill(roleLabel(m.role)) },
75
+ { key: "act", label: "", width: "1%", render: (m) => h("button.lt3-iconbtn.lt3-iconbtn--sm", { "aria-label": t("common.cancel"), on: { click: () => removeMember(ws.workspace_id, m.user_id) } }, icon("trash")) },
76
+ ], members) : c.emptyState({ icon: "users", title: t("workspace.members"), body: t("common.none") }),
77
+ h("div.lt3-row-2", userId, role, h("button.lt3-btn.lt3-btn--primary.lt3-btn--sm", { on: { click: () => addMember(ws.workspace_id, userId.value.trim(), role.value) } }, icon("user-plus"), t("workspace.addMember"))),
78
+ ) : null,
79
+ ), { interactive: false });
80
+ }
81
+
82
+ function invitationsPanel(invites) {
83
+ const rows = Array.isArray(invites.data?.invitations) ? invites.data.invitations : [];
84
+ const email = h("input.lt3-input", { type: "email", placeholder: t("workspace.inviteEmail"), "aria-label": t("workspace.inviteEmail") });
85
+ const workspace = h("input.lt3-input", { type: "text", placeholder: "workspace_id", "aria-label": "workspace_id", value: store.get().workspaceId || "personal" });
86
+ const role = h("select.lt3-select", { "aria-label": t("common.role") },
87
+ ["member", "viewer", "admin"].map((r) => h("option", { value: r }, roleLabel(r))));
88
+ const token = h("input.lt3-input", { type: "text", placeholder: t("workspace.inviteToken"), "aria-label": t("workspace.inviteToken") });
89
+
90
+ return c.panel({
91
+ title: t("workspace.invitations"),
92
+ actions: [c.sourceBadge(invites.source)],
93
+ children: h("div.lt3-stack-4",
94
+ h("div.lt3-grid-2",
95
+ h("div.lt3-field", h("label.lt3-label", t("workspace.inviteEmail")), email),
96
+ h("div.lt3-field", h("label.lt3-label", "workspace_id"), workspace),
97
+ ),
98
+ h("div.lt3-row-2", role, h("button.lt3-btn.lt3-btn--primary", { on: { click: () => createInvite(email.value.trim(), workspace.value.trim(), role.value) } }, icon("mail-plus"), t("workspace.invitations"))),
99
+ rows.length ? c.table([
100
+ { key: "email", label: t("account.email"), render: (r) => r.email || "—" },
101
+ { key: "role", label: t("common.role"), width: "1%", render: (r) => c.pill(roleLabel(r.role)) },
102
+ { key: "token", label: t("workspace.inviteToken"), render: (r) => h("span.lt3-mono", r.token || r.id || "—") },
103
+ { key: "status", label: t("common.status"), width: "1%", render: (r) => c.statePill(r.status || (r.accepted_at ? "ready" : "pending")) },
104
+ ], rows.slice(0, 20)) : c.emptyState({ icon: "mail", title: t("workspace.invitations"), body: t("common.none") }),
105
+ h("hr.lt3-divider"),
106
+ h("div.lt3-row-2", token, h("button.lt3-btn.lt3-btn--ghost", { on: { click: () => acceptInvite(token.value.trim()) } }, icon("circle-check"), t("workspace.acceptInvite"))),
107
+ ),
108
+ });
109
+ }
110
+
111
+ async function activate(workspace_id) {
112
+ const res = await api.activateWorkspace(workspace_id);
113
+ toast(resultText(res, t("workspace.activated")), res.ok ? "ok" : "err");
114
+ if (res.ok) { store.setWorkspace(workspace_id); load(); }
115
+ }
116
+ async function archive(workspace_id) {
117
+ const res = await api.archiveWorkspace(workspace_id);
118
+ toast(resultText(res, t("workspace.archived")), res.ok ? "ok" : "err");
119
+ if (res.ok) load();
120
+ }
121
+ async function addMember(workspace_id, user_id, role) {
122
+ if (!user_id) return;
123
+ const res = await api.addWorkspaceMember(workspace_id, user_id, role);
124
+ toast(resultText(res, t("workspace.memberAdded")), res.ok ? "ok" : "err");
125
+ if (res.ok) load();
126
+ }
127
+ async function removeMember(workspace_id, user_id) {
128
+ const res = await api.removeWorkspaceMember(workspace_id, user_id);
129
+ toast(resultText(res, t("workspace.memberAdded")), res.ok ? "ok" : "err");
130
+ if (res.ok) load();
131
+ }
132
+ async function createInvite(email, workspace_id, role) {
133
+ const res = await api.createInvitation({ email: email || null, workspace_id: workspace_id || null, role, expires_hours: 168 });
134
+ toast(resultText(res, t("workspace.inviteCreated")), res.ok ? "ok" : "err");
135
+ if (res.ok) load();
136
+ }
137
+ async function acceptInvite(token) {
138
+ if (!token) return;
139
+ const res = await api.acceptInvitation(token);
140
+ toast(resultText(res, t("workspace.inviteAccepted")), res.ok ? "ok" : "err");
141
+ if (res.ok) load();
142
+ }
143
+
144
+ await load();
145
+ return host;
146
+ }
147
+
148
+ function roleLabel(role) {
149
+ return t(`common.${String(role || "member")}`) || String(role || "member");
150
+ }
151
+
152
+ function resultText(res, okText) {
153
+ if (res && res.ok) return okText;
154
+ const data = (res && res.data) || {};
155
+ return String(data.detail || data.error || res?.error || t("common.unavailable"));
156
+ }
@@ -1,113 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="ko">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
6
- <title>Lattice AI</title>
7
- <script src="/static/scripts/ux.js"></script>
8
- <link rel="manifest" href="/manifest.json">
9
- <meta name="theme-color" content="#f3ecff">
10
- <meta name="apple-mobile-web-app-capable" content="yes">
11
- <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
12
- <link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png">
13
- <link rel="stylesheet" href="/static/vendor/fonts/inter.css">
14
- <link rel="stylesheet" href="/static/vendor/icons/tabler-icons.min.css">
15
- <link rel="stylesheet" href="/static/css/tokens.css">
16
- <link rel="stylesheet" href="/static/css/reference/base.css">
17
- <link rel="stylesheet" href="/static/css/reference/account.css">
18
- <link rel="stylesheet" href="/static/css/reference/admin.css">
19
- <link rel="stylesheet" href="/static/css/reference/graph.css">
20
- <link rel="stylesheet" href="/static/css/reference/chat.css">
21
- <link rel="stylesheet" href="/static/css/responsive.css">
22
- </head>
23
- <body class="lattice-ref-auth">
24
- <div class="orb orb-1"></div>
25
- <div class="orb orb-2"></div>
26
- <div class="auth-titlebar" aria-hidden="true">
27
- <div class="auth-window-brand"><i class="ti ti-cube-3d-sphere"></i><span>Lattice AI</span></div>
28
- <div class="auth-window-controls"><span></span><span></span><span></span></div>
29
- </div>
30
- <div class="auth-wave auth-wave-left" aria-hidden="true"></div>
31
- <div class="auth-wave auth-wave-right" aria-hidden="true"></div>
32
- <div class="auth-network" aria-hidden="true">
33
- <span></span><span></span><span></span><span></span><span></span><span></span>
34
- </div>
35
-
36
- <div class="login-shell">
37
- <div class="brand-preview" aria-hidden="true">
38
- <div class="preview-node n1"></div>
39
- <div class="preview-node n2"></div>
40
- <div class="preview-node n3"></div>
41
- <div class="preview-node n4"></div>
42
- <div class="preview-line l1"></div>
43
- <div class="preview-line l2"></div>
44
- <div class="preview-line l3"></div>
45
- </div>
46
- <div class="card">
47
- <div class="lang-wrap">
48
- <button class="lang-btn" id="lang-btn" onclick="toggleLang()">🌐 Language</button>
49
- <div class="lang-menu" id="lang-menu">
50
- <div class="lang-opt" id="opt-ko" onclick="setLang('ko')">🇰🇷 한국어</div>
51
- <div class="lang-opt" id="opt-en" onclick="setLang('en')">🇺🇸 English</div>
52
- </div>
53
- </div>
54
- <!-- Login form -->
55
- <div id="login-section">
56
- <div class="hero-logo">
57
- <div class="hero-logo-mark"><i class="ti ti-cube-3d-sphere"></i></div>
58
- <h2 class="title" id="login-title">Lattice AI</h2>
59
- </div>
60
- <p class="subtitle" id="login-sub">내 PC에서 시작하는<br>개인 AI 워크스페이스</p>
61
- <label class="auth-field">
62
- <i class="ti ti-mail"></i>
63
- <input class="input" type="email" id="login-email" placeholder="이메일 주소">
64
- </label>
65
- <label class="auth-field">
66
- <i class="ti ti-lock"></i>
67
- <input class="input" type="password" id="login-pw" placeholder="비밀번호" onkeydown="if(event.key==='Enter')doLogin()">
68
- <button type="button" class="field-eye" onclick="togglePasswordVisibility()" title="비밀번호 보기"><i class="ti ti-eye"></i></button>
69
- </label>
70
- <div class="msg" id="login-msg"></div>
71
- <button class="submit" id="login-btn" onclick="doLogin()" data-ko="로그인" data-en="Log in">로그인</button>
72
- <button class="register-cta" id="go-register-link" onclick="showSection('register');return false;">회원가입</button>
73
- <div id="sso-section">
74
- <div class="sso-divider" id="sso-divider-text">조직 계정으로 로그인</div>
75
- <button class="sso-btn sso-ms" onclick="doSSOLogin('microsoft')">
76
- <span class="ms-logo" aria-hidden="true"><b></b><b></b><b></b><b></b></span>
77
- <span id="sso-ms-label">Microsoft Entra ID로 계속하기</span>
78
- </button>
79
- <button class="sso-btn sso-okta" onclick="doSSOLogin('okta')">
80
- <span class="okta-logo" aria-hidden="true"></span>
81
- <span id="sso-okta-label">Okta SSO로 계속하기</span>
82
- </button>
83
- </div>
84
- <button class="local-start" onclick="showSection('register');return false;"><i class="ti ti-device-desktop"></i> <span id="local-start-label">로컬 계정으로 시작</span></button>
85
- </div>
86
-
87
- <!-- Register form -->
88
- <div id="register-section" style="display:none;">
89
- <div class="logo"><i class="ti ti-user-plus"></i></div>
90
- <h2 class="title" id="reg-title">계정 만들기</h2>
91
- <p class="subtitle" id="reg-sub">Lattice AI 워크스페이스에 참여하세요</p>
92
- <input class="input" type="email" id="reg-email" placeholder="이메일 주소">
93
- <input class="input" type="password" id="reg-pw" placeholder="비밀번호 (4자 이상)">
94
- <input class="input" type="password" id="reg-pw2" placeholder="비밀번호 확인">
95
- <input class="input" type="text" id="reg-name" placeholder="이름">
96
- <input class="input" type="text" id="reg-nick" placeholder="닉네임">
97
- <div class="msg" id="reg-msg"></div>
98
- <button class="submit" id="reg-btn" onclick="doRegister()">가입하기</button>
99
- <p class="switch" id="reg-switch">
100
- <span id="have-account-text">이미 계정이 있나요?</span>
101
- <a href="#" onclick="showSection('login');return false;" id="go-login-link">로그인</a>
102
- </p>
103
- </div>
104
- </div>
105
- </div>
106
- <footer class="auth-footer">
107
- <a href="#" onclick="return false;" id="help-link">도움말</a>
108
- <a href="#" onclick="return false;" id="privacy-link">개인정보 처리방침</a>
109
- </footer>
110
-
111
- <script src="/static/scripts/account.js"></script>
112
- </body>
113
- </html>
@@ -1,73 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
6
- <title>Realtime Activity — Lattice AI</title>
7
- <script src="/static/scripts/ux.js"></script>
8
- <link rel="stylesheet" href="/static/css/tokens.css" />
9
- <link rel="stylesheet" href="/static/platform.css" />
10
- <link rel="stylesheet" href="/static/css/responsive.css" />
11
- </head>
12
- <body>
13
- <main>
14
- <h1>Realtime Activity</h1>
15
- <p class="sub" id="sub">Live presence + activity feed across workspace, graph, agent, and workflow events (SSE).</p>
16
-
17
- <div class="row">
18
- <span class="badge" id="connBadge">connecting…</span>
19
- <span class="badge" id="presenceBadge">presence: 0</span>
20
- <div class="spacer"></div>
21
- <button class="ghost" id="clearBtn">Clear view</button>
22
- </div>
23
-
24
- <div class="section">
25
- <h3>Live feed</h3>
26
- <div id="feed"><div class="empty">Waiting for events…</div></div>
27
- </div>
28
- </main>
29
-
30
- <script type="module">
31
- import { mountHeader, api, escapeHtml, badge } from "/static/scripts/platform.js";
32
- mountHeader("/activity");
33
-
34
- const feed = document.getElementById("feed");
35
- const seen = new Set();
36
-
37
- function renderEvent(ev) {
38
- if (ev.seq && seen.has(ev.seq)) return;
39
- if (ev.seq) seen.add(ev.seq);
40
- if (feed.querySelector(".empty")) feed.innerHTML = "";
41
- const node = document.createElement("div");
42
- node.className = "timeline-item";
43
- node.innerHTML = `<div class="row">${badge(ev.area || "event")} <strong>${escapeHtml(ev.event_type || "")}</strong>
44
- <span class="t-meta">${escapeHtml(ev.workspace_id || "personal")} · ${escapeHtml(ev.received_at || ev.timestamp || "")}</span></div>
45
- <pre style="max-height:160px">${escapeHtml(JSON.stringify(ev.payload || {}, null, 2))}</pre>`;
46
- feed.prepend(node);
47
- while (feed.children.length > 100) feed.lastChild.remove();
48
- }
49
-
50
- async function refreshPresence() {
51
- try {
52
- const data = await api("/realtime/presence");
53
- document.getElementById("presenceBadge").textContent = `presence: ${data.presence.length}`;
54
- document.getElementById("sub").textContent =
55
- `Transport: ${data.stats.transport.toUpperCase()} · subscribers: ${data.stats.subscribers} · feed: ${data.stats.feed_size}`;
56
- } catch {}
57
- }
58
-
59
- document.getElementById("clearBtn").addEventListener("click", () => { feed.innerHTML = `<div class="empty">Cleared.</div>`; });
60
-
61
- // Seed with recent feed, then connect to the SSE stream.
62
- api("/realtime/feed?limit=30").then((d) => (d.events || []).reverse().forEach(renderEvent)).catch(() => {});
63
-
64
- const es = new EventSource("/realtime/stream");
65
- es.onopen = () => { document.getElementById("connBadge").textContent = "● live"; document.getElementById("connBadge").className = "badge ok"; };
66
- es.onerror = () => { document.getElementById("connBadge").textContent = "○ reconnecting"; document.getElementById("connBadge").className = "badge warn"; };
67
- es.onmessage = (e) => { try { renderEvent(JSON.parse(e.data)); } catch {} };
68
-
69
- refreshPresence();
70
- setInterval(refreshPresence, 8000);
71
- </script>
72
- </body>
73
- </html>