ltcai 1.0.1 → 1.2.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 +34 -0
- package/docs/CHANGELOG.md +99 -0
- package/docs/EDITION_STRATEGY.md +56 -0
- package/docs/ENTERPRISE.md +78 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/health.py +45 -0
- package/latticeai/api/workspace.py +748 -0
- package/latticeai/core/enterprise.py +152 -0
- package/latticeai/core/workspace_os.py +409 -38
- package/latticeai/server_app.py +73 -530
- package/latticeai/services/__init__.py +6 -0
- package/latticeai/services/chat_service.py +53 -0
- package/latticeai/services/model_service.py +51 -0
- package/latticeai/services/workspace_service.py +117 -0
- package/package.json +1 -1
- package/static/scripts/workspace.js +149 -0
- package/static/sw.js +1 -1
- package/static/workspace.css +31 -0
- package/static/workspace.html +41 -2
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Service layer extracted from server_app.py.
|
|
2
|
+
|
|
3
|
+
Services wrap stores and business logic with no dependency on the FastAPI app
|
|
4
|
+
object, so API routers (latticeai.api.*) and the app assembly
|
|
5
|
+
(latticeai.server_app) can import them without creating import cycles.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Chat / session service seam.
|
|
2
|
+
|
|
3
|
+
The streaming chat path in ``server_app`` is intentionally left in place — its
|
|
4
|
+
generator and SSE behaviour are sensitive. This service provides a stable seam
|
|
5
|
+
for the *bookkeeping* around answers (conversation history access and
|
|
6
|
+
Workspace-OS answer-trace recording) so those concerns are named and wrapped
|
|
7
|
+
rather than reaching into the store directly from the streaming handler.
|
|
8
|
+
|
|
9
|
+
It is a behaviour-preserving façade: methods forward to the injected history
|
|
10
|
+
accessor and :class:`WorkspaceOSStore`, so wiring the chat path through it
|
|
11
|
+
cannot change streaming output.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from latticeai.core.workspace_os import WorkspaceOSStore
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ChatService:
|
|
22
|
+
def __init__(self, *, store: WorkspaceOSStore, get_history: Callable[[], List[Dict[str, Any]]]):
|
|
23
|
+
self._store = store
|
|
24
|
+
self._get_history = get_history
|
|
25
|
+
|
|
26
|
+
# ── conversation history ─────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
def history(self) -> List[Dict[str, Any]]:
|
|
29
|
+
return self._get_history()
|
|
30
|
+
|
|
31
|
+
# ── answer-trace recording (Graph RAG) ───────────────────────────────
|
|
32
|
+
|
|
33
|
+
def build_graph_trace(self, question: str, graph: Any, context: str = "", *, limit: int = 8) -> Dict[str, Any]:
|
|
34
|
+
return self._store.build_graph_trace(question, graph, context, limit=limit)
|
|
35
|
+
|
|
36
|
+
def record_trace(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
question: str,
|
|
40
|
+
response: str,
|
|
41
|
+
conversation_id: Optional[str],
|
|
42
|
+
user_email: Optional[str],
|
|
43
|
+
trace: Dict[str, Any],
|
|
44
|
+
workspace_id: Optional[str] = None,
|
|
45
|
+
) -> Dict[str, Any]:
|
|
46
|
+
return self._store.record_trace(
|
|
47
|
+
question=question,
|
|
48
|
+
response=response,
|
|
49
|
+
conversation_id=conversation_id,
|
|
50
|
+
user_email=user_email,
|
|
51
|
+
trace=trace,
|
|
52
|
+
workspace_id=workspace_id,
|
|
53
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Model / engine state helpers used by the health and model routers.
|
|
2
|
+
|
|
3
|
+
Pure payload builders with no FastAPI dependency. The model runtime (LLMRouter),
|
|
4
|
+
``engine_status``, and ``runtime_features`` remain owned by ``server_app``; this
|
|
5
|
+
module just assembles the response shapes so the health/model routers stay thin
|
|
6
|
+
and the summaries live in one place.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Callable, Dict, List
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ModelService:
|
|
15
|
+
"""Assembles health/engine summary payloads from injected runtime pieces."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
model_router: Any,
|
|
21
|
+
runtime_features: Callable[[], Dict[str, Any]],
|
|
22
|
+
is_public: bool,
|
|
23
|
+
):
|
|
24
|
+
self._router = model_router
|
|
25
|
+
self._runtime_features = runtime_features
|
|
26
|
+
self._is_public = is_public
|
|
27
|
+
|
|
28
|
+
def runtime(self) -> Dict[str, Any]:
|
|
29
|
+
return self._runtime_features()
|
|
30
|
+
|
|
31
|
+
def health_base(self, *, version: str, mode: str) -> Dict[str, Any]:
|
|
32
|
+
return {
|
|
33
|
+
"status": "ok",
|
|
34
|
+
"version": version,
|
|
35
|
+
"mode": mode,
|
|
36
|
+
"platform": "AI Workspace OS",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
def health_full(self, base: Dict[str, Any], engines: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
**base,
|
|
42
|
+
"current_model": self._router.current_model_id,
|
|
43
|
+
"loaded_models": self._router.loaded_model_ids,
|
|
44
|
+
"device": "Apple Silicon MLX" if not self._is_public else "Public cloud/API runtime",
|
|
45
|
+
"features": self._runtime_features(),
|
|
46
|
+
"providers": self._router.detected_cloud_models(),
|
|
47
|
+
"engines": engines,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def engines_payload(self, engines: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
51
|
+
return {"engines": engines, "current": self._router.current_model_id}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Workspace service layer: scope resolution, permission guardrails, and a
|
|
2
|
+
single seam in front of :class:`WorkspaceOSStore`.
|
|
3
|
+
|
|
4
|
+
This module centralizes the workspace_id resolution and role/permission checks
|
|
5
|
+
that were previously scattered across the FastAPI handlers. It depends only on
|
|
6
|
+
``workspace_os`` (and indirectly ``enterprise``), never on the FastAPI app, so
|
|
7
|
+
both the app assembly and the API routers can import it freely.
|
|
8
|
+
|
|
9
|
+
Guardrail summary (v1.2.0):
|
|
10
|
+
|
|
11
|
+
* **Explicit workspace targeting is gated.** When a caller names a workspace
|
|
12
|
+
(``X-Workspace-Id`` header / ``workspace_id``), reads require ``read`` and
|
|
13
|
+
writes require ``write`` on that workspace; non-members are rejected.
|
|
14
|
+
* **Backward compatible default.** With no workspace named, the scope resolves
|
|
15
|
+
to the *active* workspace (Personal by default). Pre-1.1 clients that never
|
|
16
|
+
send a header keep operating on Personal data exactly as before.
|
|
17
|
+
* **Personal workspace** always grants its single local user owner rights.
|
|
18
|
+
* **No-auth local mode** keeps the owner fallback for *ownerless* org
|
|
19
|
+
workspaces (the anonymous local user owns what they create), but a *named*
|
|
20
|
+
stranger never bypasses membership.
|
|
21
|
+
* **Graph and installed Skills are intentionally machine-global shared state**
|
|
22
|
+
(the local knowledge graph and on-disk skills are not partitioned per
|
|
23
|
+
workspace). Skill enable/disable and other actions still record
|
|
24
|
+
workspace-scoped timeline events via the active workspace.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import Any, Dict, Optional
|
|
30
|
+
|
|
31
|
+
from latticeai.core.workspace_os import WorkspaceOSStore
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WorkspaceService:
|
|
35
|
+
"""Permission-aware façade over :class:`WorkspaceOSStore`."""
|
|
36
|
+
|
|
37
|
+
# Graph / installed-skill state that is shared machine-wide rather than
|
|
38
|
+
# partitioned per workspace. Surfaced so the UI / docs can be explicit.
|
|
39
|
+
SHARED_GLOBAL_AREAS = ("graph", "skills")
|
|
40
|
+
|
|
41
|
+
def __init__(self, store: WorkspaceOSStore):
|
|
42
|
+
self.store = store
|
|
43
|
+
|
|
44
|
+
# ── scope resolution + gating ────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
def _ensure_permission(self, workspace_id: str, user_id: Optional[str], permission: str) -> None:
|
|
47
|
+
if not self.store.has_permission(workspace_id, user_id, permission):
|
|
48
|
+
raise PermissionError(
|
|
49
|
+
f"'{user_id or 'anonymous'}' lacks '{permission}' on workspace '{workspace_id}'"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def resolve_read_scope(self, requested: Optional[str], user_id: Optional[str]) -> str:
|
|
53
|
+
"""Resolve + authorize the workspace a read should target.
|
|
54
|
+
|
|
55
|
+
``None`` falls back to the active workspace (Personal by default), which
|
|
56
|
+
preserves pre-1.1 behaviour. An explicitly named workspace is gated on
|
|
57
|
+
``read`` so non-members cannot read organization data.
|
|
58
|
+
"""
|
|
59
|
+
workspace_id = requested or self.store._active_workspace_id()
|
|
60
|
+
self._ensure_permission(workspace_id, user_id, "read")
|
|
61
|
+
return workspace_id
|
|
62
|
+
|
|
63
|
+
def resolve_write_scope(self, requested: Optional[str], user_id: Optional[str]) -> str:
|
|
64
|
+
"""Resolve + authorize the workspace a write should target (gated on ``write``)."""
|
|
65
|
+
workspace_id = requested or self.store._active_workspace_id()
|
|
66
|
+
self._ensure_permission(workspace_id, user_id, "write")
|
|
67
|
+
return workspace_id
|
|
68
|
+
|
|
69
|
+
def can_read(self, workspace_id: str, user_id: Optional[str]) -> bool:
|
|
70
|
+
return self.store.has_permission(workspace_id, user_id, "read")
|
|
71
|
+
|
|
72
|
+
def can_write(self, workspace_id: str, user_id: Optional[str]) -> bool:
|
|
73
|
+
return self.store.has_permission(workspace_id, user_id, "write")
|
|
74
|
+
|
|
75
|
+
# ── workspace registry / summary ─────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def summary(self, user_id: Optional[str]) -> Dict[str, Any]:
|
|
78
|
+
data = self.store.summary()
|
|
79
|
+
data["workspace_registry"] = self.store.list_workspaces(user_id=user_id)
|
|
80
|
+
data["shared_global_areas"] = list(self.SHARED_GLOBAL_AREAS)
|
|
81
|
+
return data
|
|
82
|
+
|
|
83
|
+
def list_workspaces(self, user_id: Optional[str]) -> Dict[str, Any]:
|
|
84
|
+
return self.store.list_workspaces(user_id=user_id)
|
|
85
|
+
|
|
86
|
+
def get_workspace(self, workspace_id: str, user_id: Optional[str]) -> Dict[str, Any]:
|
|
87
|
+
# Reading workspace metadata requires read access to that workspace.
|
|
88
|
+
self._ensure_permission(workspace_id, user_id, "read")
|
|
89
|
+
return self.store.get_workspace(workspace_id, user_id=user_id)
|
|
90
|
+
|
|
91
|
+
def workspace_summary(self, workspace_id: str, user_id: Optional[str]) -> Dict[str, Any]:
|
|
92
|
+
self._ensure_permission(workspace_id, user_id, "read")
|
|
93
|
+
return self.store.workspace_summary(workspace_id, user_id=user_id)
|
|
94
|
+
|
|
95
|
+
# ── organization workspace management (delegates with actor) ─────────
|
|
96
|
+
|
|
97
|
+
def create_organization_workspace(self, *, name: str, owner_user_id: Optional[str], settings: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
98
|
+
return self.store.create_organization_workspace(name=name, owner_user_id=owner_user_id, settings=settings)
|
|
99
|
+
|
|
100
|
+
def update_workspace(self, workspace_id: str, *, name=None, settings=None, actor=None) -> Dict[str, Any]:
|
|
101
|
+
return self.store.update_workspace(workspace_id, name=name, settings=settings, actor=actor)
|
|
102
|
+
|
|
103
|
+
def archive_workspace(self, workspace_id: str, *, actor=None) -> Dict[str, Any]:
|
|
104
|
+
return self.store.archive_workspace(workspace_id, actor=actor)
|
|
105
|
+
|
|
106
|
+
def add_member(self, workspace_id: str, *, user_id: str, role: str = "member", actor=None) -> Dict[str, Any]:
|
|
107
|
+
return self.store.add_member(workspace_id, user_id=user_id, role=role, actor=actor)
|
|
108
|
+
|
|
109
|
+
def update_member_role(self, workspace_id: str, *, user_id: str, role: str, actor=None) -> Dict[str, Any]:
|
|
110
|
+
return self.store.update_member_role(workspace_id, user_id=user_id, role=role, actor=actor)
|
|
111
|
+
|
|
112
|
+
def remove_member(self, workspace_id: str, *, user_id: str, actor=None) -> Dict[str, Any]:
|
|
113
|
+
return self.store.remove_member(workspace_id, user_id=user_id, actor=actor)
|
|
114
|
+
|
|
115
|
+
def set_active_workspace(self, workspace_id: str, user_id: Optional[str]) -> Dict[str, Any]:
|
|
116
|
+
# Membership is enforced inside the store for organization workspaces.
|
|
117
|
+
return self.store.set_active_workspace(workspace_id, user_id=user_id)
|
package/package.json
CHANGED
|
@@ -3,6 +3,9 @@ const API_BASE = window.location.protocol === "file:" ? "http://localhost:4825"
|
|
|
3
3
|
const state = {
|
|
4
4
|
os: null,
|
|
5
5
|
snapshots: [],
|
|
6
|
+
activeWorkspace: null,
|
|
7
|
+
registry: null,
|
|
8
|
+
managingWorkspace: null,
|
|
6
9
|
};
|
|
7
10
|
|
|
8
11
|
function $(id) {
|
|
@@ -21,6 +24,7 @@ function escapeHtml(value) {
|
|
|
21
24
|
async function api(path, options = {}) {
|
|
22
25
|
const headers = { ...(options.headers || {}) };
|
|
23
26
|
if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
|
|
27
|
+
if (state.activeWorkspace && !headers["X-Workspace-Id"]) headers["X-Workspace-Id"] = state.activeWorkspace;
|
|
24
28
|
const response = await fetch(API_BASE + path, { credentials: "include", ...options, headers });
|
|
25
29
|
const text = await response.text();
|
|
26
30
|
let data = {};
|
|
@@ -226,6 +230,103 @@ function renderTimeline(payload) {
|
|
|
226
230
|
`).join("") : `<div class="timeline-item"><div class="meta-line">No timeline events.</div></div>`;
|
|
227
231
|
}
|
|
228
232
|
|
|
233
|
+
function renderWorkspaceRegistry(registry, edition) {
|
|
234
|
+
state.registry = registry;
|
|
235
|
+
if (!state.activeWorkspace) state.activeWorkspace = registry.active_workspace || "personal";
|
|
236
|
+
const workspaces = registry.workspaces || [];
|
|
237
|
+
const select = $("workspace-select");
|
|
238
|
+
if (select) {
|
|
239
|
+
select.innerHTML = workspaces.map((ws) => `
|
|
240
|
+
<option value="${escapeHtml(ws.workspace_id)}" ${ws.workspace_id === state.activeWorkspace ? "selected" : ""}>
|
|
241
|
+
${escapeHtml(ws.name)}${ws.type === "organization" ? " (org)" : ""}${ws.status === "archived" ? " · archived" : ""}
|
|
242
|
+
</option>
|
|
243
|
+
`).join("");
|
|
244
|
+
}
|
|
245
|
+
const active = workspaces.find((ws) => ws.workspace_id === state.activeWorkspace);
|
|
246
|
+
const rolePill = $("workspace-role");
|
|
247
|
+
if (rolePill) rolePill.textContent = active ? (active.your_role || "—") : "";
|
|
248
|
+
if (edition) {
|
|
249
|
+
const pill = $("edition-pill");
|
|
250
|
+
if (pill) pill.textContent = edition.edition || "community";
|
|
251
|
+
}
|
|
252
|
+
const list = $("workspace-list");
|
|
253
|
+
if (list) {
|
|
254
|
+
list.innerHTML = workspaces.length ? workspaces.map((ws) => `
|
|
255
|
+
<div class="list-item">
|
|
256
|
+
<div class="list-title">
|
|
257
|
+
<span>${escapeHtml(ws.name)}</span>
|
|
258
|
+
<span class="status-pill">${escapeHtml(ws.type)}</span>
|
|
259
|
+
</div>
|
|
260
|
+
<div class="meta-line">id: ${escapeHtml(ws.workspace_id)} · members: ${escapeHtml(ws.member_count)} · role: ${escapeHtml(ws.your_role || "—")} · ${escapeHtml(ws.status || "active")}</div>
|
|
261
|
+
<div class="item-actions">
|
|
262
|
+
${ws.workspace_id === state.activeWorkspace ? `<span class="status-pill">active</span>` : `<button class="small-action" data-ws-action="activate" data-ws="${escapeHtml(ws.workspace_id)}"><i class="ti ti-switch-horizontal"></i>Switch</button>`}
|
|
263
|
+
${ws.type === "organization" ? `<button class="small-action" data-ws-action="members" data-ws="${escapeHtml(ws.workspace_id)}"><i class="ti ti-users"></i>Members</button>` : ""}
|
|
264
|
+
${ws.type === "organization" && ws.status !== "archived" ? `<button class="small-action" data-ws-action="archive" data-ws="${escapeHtml(ws.workspace_id)}"><i class="ti ti-archive"></i>Archive</button>` : ""}
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
`).join("") : `<div class="list-item"><div class="meta-line">No workspaces.</div></div>`;
|
|
268
|
+
}
|
|
269
|
+
renderMembers(workspaces);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function renderMembers(workspaces) {
|
|
273
|
+
const panel = $("member-panel");
|
|
274
|
+
if (!panel) return;
|
|
275
|
+
if (!state.managingWorkspace) {
|
|
276
|
+
panel.hidden = true;
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const ws = (workspaces || []).find((item) => item.workspace_id === state.managingWorkspace);
|
|
280
|
+
if (!ws || ws.type !== "organization") {
|
|
281
|
+
panel.hidden = true;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
panel.hidden = false;
|
|
285
|
+
const title = $("member-panel-title");
|
|
286
|
+
if (title) title.textContent = `Members — ${ws.name}`;
|
|
287
|
+
const members = ws.members || [];
|
|
288
|
+
$("member-list").innerHTML = members.length ? members.map((m) => `
|
|
289
|
+
<div class="list-item">
|
|
290
|
+
<div class="list-title"><span>${escapeHtml(m.user_id)}</span><span class="status-pill">${escapeHtml(m.role)}</span></div>
|
|
291
|
+
<div class="item-actions">
|
|
292
|
+
<select class="small-action" data-member-role="${escapeHtml(m.user_id)}" data-ws="${escapeHtml(ws.workspace_id)}">
|
|
293
|
+
${["owner", "admin", "member", "viewer"].map((r) => `<option value="${r}" ${m.role === r ? "selected" : ""}>${r}</option>`).join("")}
|
|
294
|
+
</select>
|
|
295
|
+
<button class="small-action" data-member-remove="${escapeHtml(m.user_id)}" data-ws="${escapeHtml(ws.workspace_id)}"><i class="ti ti-user-minus"></i>Remove</button>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
`).join("") : `<div class="list-item"><div class="meta-line">No members yet.</div></div>`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function activateWorkspace(workspaceId) {
|
|
302
|
+
await api("/workspace/activate", { method: "POST", body: JSON.stringify({ workspace_id: workspaceId }) });
|
|
303
|
+
state.activeWorkspace = workspaceId;
|
|
304
|
+
toast(`Switched to ${workspaceId}`);
|
|
305
|
+
await refreshAll();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function createOrg() {
|
|
309
|
+
const name = ($("org-name").value || "").trim();
|
|
310
|
+
if (!name) return;
|
|
311
|
+
const result = await api("/workspace/orgs", { method: "POST", body: JSON.stringify({ name, settings: {} }) });
|
|
312
|
+
$("org-name").value = "";
|
|
313
|
+
toast(`Created ${result.workspace.workspace_id}`);
|
|
314
|
+
state.managingWorkspace = result.workspace.workspace_id;
|
|
315
|
+
await refreshAll();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function addMember(workspaceId) {
|
|
319
|
+
const userId = ($("member-user").value || "").trim();
|
|
320
|
+
if (!userId) return;
|
|
321
|
+
await api(`/workspace/orgs/${encodeURIComponent(workspaceId)}/members`, {
|
|
322
|
+
method: "POST",
|
|
323
|
+
body: JSON.stringify({ user_id: userId, role: $("member-role").value }),
|
|
324
|
+
});
|
|
325
|
+
$("member-user").value = "";
|
|
326
|
+
toast(`Added ${userId}`);
|
|
327
|
+
await refreshAll();
|
|
328
|
+
}
|
|
329
|
+
|
|
229
330
|
async function refreshAll() {
|
|
230
331
|
const [os, onboarding, traces, indexing, snapshots, memories, computerMemory, agents, workflows, skills, timeline] = await Promise.all([
|
|
231
332
|
api("/workspace/os"),
|
|
@@ -242,6 +343,7 @@ async function refreshAll() {
|
|
|
242
343
|
]);
|
|
243
344
|
state.os = os;
|
|
244
345
|
renderMetrics(os);
|
|
346
|
+
if (os.workspace_registry) renderWorkspaceRegistry(os.workspace_registry, os.edition);
|
|
245
347
|
renderOnboarding(onboarding);
|
|
246
348
|
renderTraces(traces);
|
|
247
349
|
renderIndexing(indexing);
|
|
@@ -357,6 +459,47 @@ document.addEventListener("click", async (event) => {
|
|
|
357
459
|
});
|
|
358
460
|
toast(`Skill ${skillBtn.dataset.skillAction}`);
|
|
359
461
|
await refreshAll();
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const wsBtn = event.target.closest("[data-ws-action]");
|
|
466
|
+
if (wsBtn) {
|
|
467
|
+
const action = wsBtn.dataset.wsAction;
|
|
468
|
+
const ws = wsBtn.dataset.ws;
|
|
469
|
+
if (action === "activate") {
|
|
470
|
+
await activateWorkspace(ws);
|
|
471
|
+
} else if (action === "members") {
|
|
472
|
+
state.managingWorkspace = ws;
|
|
473
|
+
if (state.registry) renderMembers(state.registry.workspaces);
|
|
474
|
+
} else if (action === "archive") {
|
|
475
|
+
await api(`/workspace/orgs/${encodeURIComponent(ws)}/archive`, { method: "POST" });
|
|
476
|
+
toast(`Archived ${ws}`);
|
|
477
|
+
await refreshAll();
|
|
478
|
+
}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const removeBtn = event.target.closest("[data-member-remove]");
|
|
483
|
+
if (removeBtn) {
|
|
484
|
+
await api(`/workspace/orgs/${encodeURIComponent(removeBtn.dataset.ws)}/members/${encodeURIComponent(removeBtn.dataset.memberRemove)}`, { method: "DELETE" });
|
|
485
|
+
toast("Member removed");
|
|
486
|
+
await refreshAll();
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
document.addEventListener("change", async (event) => {
|
|
491
|
+
const roleSelect = event.target.closest("[data-member-role]");
|
|
492
|
+
if (roleSelect) {
|
|
493
|
+
try {
|
|
494
|
+
await api(`/workspace/orgs/${encodeURIComponent(roleSelect.dataset.ws)}/members/${encodeURIComponent(roleSelect.dataset.memberRole)}`, {
|
|
495
|
+
method: "PATCH",
|
|
496
|
+
body: JSON.stringify({ role: roleSelect.value }),
|
|
497
|
+
});
|
|
498
|
+
toast("Role updated");
|
|
499
|
+
await refreshAll();
|
|
500
|
+
} catch (err) {
|
|
501
|
+
toast(err.message);
|
|
502
|
+
}
|
|
360
503
|
}
|
|
361
504
|
});
|
|
362
505
|
|
|
@@ -378,5 +521,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
378
521
|
}));
|
|
379
522
|
$("create-demo-workflow").addEventListener("click", () => createDemoWorkflow().catch((err) => toast(err.message)));
|
|
380
523
|
$("reload-skills").addEventListener("click", () => refreshAll().catch((err) => toast(err.message)));
|
|
524
|
+
$("workspace-select").addEventListener("change", (event) => activateWorkspace(event.target.value).catch((err) => toast(err.message)));
|
|
525
|
+
$("create-org").addEventListener("click", () => createOrg().catch((err) => toast(err.message)));
|
|
526
|
+
$("new-org-btn").addEventListener("click", () => $("org-name").focus());
|
|
527
|
+
$("add-member").addEventListener("click", () => {
|
|
528
|
+
if (state.managingWorkspace) addMember(state.managingWorkspace).catch((err) => toast(err.message));
|
|
529
|
+
});
|
|
381
530
|
refreshAll().catch((err) => toast(err.message));
|
|
382
531
|
});
|
package/static/sw.js
CHANGED
package/static/workspace.css
CHANGED
|
@@ -513,3 +513,34 @@ textarea {
|
|
|
513
513
|
grid-template-columns: 1fr;
|
|
514
514
|
}
|
|
515
515
|
}
|
|
516
|
+
|
|
517
|
+
/* Workspace switcher + organization management (v1.1.0) */
|
|
518
|
+
.workspace-switcher {
|
|
519
|
+
display: inline-flex;
|
|
520
|
+
align-items: center;
|
|
521
|
+
gap: 8px;
|
|
522
|
+
padding: 6px 10px;
|
|
523
|
+
border-radius: 10px;
|
|
524
|
+
background: rgba(255, 255, 255, 0.06);
|
|
525
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
526
|
+
}
|
|
527
|
+
.workspace-switcher select {
|
|
528
|
+
background: transparent;
|
|
529
|
+
border: none;
|
|
530
|
+
color: inherit;
|
|
531
|
+
font: inherit;
|
|
532
|
+
font-weight: 600;
|
|
533
|
+
max-width: 220px;
|
|
534
|
+
}
|
|
535
|
+
.workspace-role-pill {
|
|
536
|
+
font-size: 11px;
|
|
537
|
+
text-transform: uppercase;
|
|
538
|
+
letter-spacing: 0.04em;
|
|
539
|
+
opacity: 0.75;
|
|
540
|
+
}
|
|
541
|
+
#member-panel {
|
|
542
|
+
margin-top: 16px;
|
|
543
|
+
}
|
|
544
|
+
#org-create-form {
|
|
545
|
+
margin-bottom: 12px;
|
|
546
|
+
}
|
package/static/workspace.html
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png">
|
|
9
9
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap">
|
|
10
10
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
|
|
11
|
-
<link rel="stylesheet" href="/static/workspace.css?v=1.
|
|
11
|
+
<link rel="stylesheet" href="/static/workspace.css?v=1.1.0">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<div class="workspace-shell">
|
|
@@ -41,6 +41,12 @@
|
|
|
41
41
|
<h1>Workspace Command Center</h1>
|
|
42
42
|
</div>
|
|
43
43
|
<div class="top-actions">
|
|
44
|
+
<div class="workspace-switcher" title="Active workspace">
|
|
45
|
+
<i class="ti ti-building-community"></i>
|
|
46
|
+
<select id="workspace-select" aria-label="Active workspace"></select>
|
|
47
|
+
<span class="workspace-role-pill" id="workspace-role"></span>
|
|
48
|
+
</div>
|
|
49
|
+
<button class="icon-action" id="new-org-btn" title="New organization workspace"><i class="ti ti-plus"></i></button>
|
|
44
50
|
<button class="icon-action" id="refresh-btn" title="Refresh"><i class="ti ti-refresh"></i></button>
|
|
45
51
|
<button class="primary-action" id="snapshot-now"><i class="ti ti-device-floppy"></i><span>Snapshot</span></button>
|
|
46
52
|
</div>
|
|
@@ -48,6 +54,39 @@
|
|
|
48
54
|
|
|
49
55
|
<section class="metric-grid" id="metric-grid"></section>
|
|
50
56
|
|
|
57
|
+
<section class="workspace-band" id="organization">
|
|
58
|
+
<div class="section-head">
|
|
59
|
+
<div>
|
|
60
|
+
<div class="eyebrow">Workspaces</div>
|
|
61
|
+
<h2>Personal & Organization</h2>
|
|
62
|
+
</div>
|
|
63
|
+
<span class="status-pill" id="edition-pill">community</span>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="inline-form" id="org-create-form">
|
|
66
|
+
<input id="org-name" placeholder="New organization workspace name">
|
|
67
|
+
<button class="secondary-action" id="create-org"><i class="ti ti-building-plus"></i><span>Create Org</span></button>
|
|
68
|
+
</div>
|
|
69
|
+
<div id="workspace-list" class="list-stack"></div>
|
|
70
|
+
<div class="workspace-panel" id="member-panel" hidden>
|
|
71
|
+
<div class="section-head">
|
|
72
|
+
<div>
|
|
73
|
+
<div class="eyebrow">Members</div>
|
|
74
|
+
<h2 id="member-panel-title">Manage Members</h2>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="inline-form split">
|
|
78
|
+
<input id="member-user" placeholder="user@example.com">
|
|
79
|
+
<select id="member-role">
|
|
80
|
+
<option value="admin">admin</option>
|
|
81
|
+
<option value="member" selected>member</option>
|
|
82
|
+
<option value="viewer">viewer</option>
|
|
83
|
+
</select>
|
|
84
|
+
<button class="secondary-action" id="add-member"><i class="ti ti-user-plus"></i><span>Add</span></button>
|
|
85
|
+
</div>
|
|
86
|
+
<div id="member-list" class="list-stack"></div>
|
|
87
|
+
</div>
|
|
88
|
+
</section>
|
|
89
|
+
|
|
51
90
|
<section class="workspace-band onboarding-band" id="onboarding">
|
|
52
91
|
<div class="section-head">
|
|
53
92
|
<div>
|
|
@@ -194,6 +233,6 @@
|
|
|
194
233
|
</div>
|
|
195
234
|
|
|
196
235
|
<div class="toast" id="toast"></div>
|
|
197
|
-
<script src="/static/scripts/workspace.js?v=1.
|
|
236
|
+
<script src="/static/scripts/workspace.js?v=1.1.0"></script>
|
|
198
237
|
</body>
|
|
199
238
|
</html>
|