ltcai 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/docs/CHANGELOG.md +50 -0
- package/docs/EDITION_STRATEGY.md +56 -0
- package/docs/ENTERPRISE.md +78 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/core/enterprise.py +152 -0
- package/latticeai/core/workspace_os.py +409 -38
- package/latticeai/server_app.py +188 -8
- 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
package/latticeai/server_app.py
CHANGED
|
@@ -96,6 +96,11 @@ from latticeai.core.workspace_os import (
|
|
|
96
96
|
WorkspaceOSStore,
|
|
97
97
|
remove_skill_directory,
|
|
98
98
|
)
|
|
99
|
+
from latticeai.core.enterprise import (
|
|
100
|
+
EnterpriseCapability,
|
|
101
|
+
capability_registry,
|
|
102
|
+
detect_edition,
|
|
103
|
+
)
|
|
99
104
|
from latticeai.core.agent import (
|
|
100
105
|
AgentState,
|
|
101
106
|
AgentRunContext,
|
|
@@ -1507,6 +1512,29 @@ class WorkspaceVSCodeRequest(BaseModel):
|
|
|
1507
1512
|
prompt: str = ""
|
|
1508
1513
|
|
|
1509
1514
|
|
|
1515
|
+
class WorkspaceCreateRequest(BaseModel):
|
|
1516
|
+
name: str
|
|
1517
|
+
settings: Dict = {}
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
class WorkspaceUpdateRequest(BaseModel):
|
|
1521
|
+
name: Optional[str] = None
|
|
1522
|
+
settings: Optional[Dict] = None
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
class WorkspaceMemberRequest(BaseModel):
|
|
1526
|
+
user_id: str
|
|
1527
|
+
role: str = "member"
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
class WorkspaceMemberRoleRequest(BaseModel):
|
|
1531
|
+
role: str
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
class WorkspaceActivateRequest(BaseModel):
|
|
1535
|
+
workspace_id: str
|
|
1536
|
+
|
|
1537
|
+
|
|
1510
1538
|
class GardenRequest(BaseModel):
|
|
1511
1539
|
raw_data: str
|
|
1512
1540
|
category: Optional[str] = None # 10_Wiki / 00_Raw / Skills
|
|
@@ -1708,12 +1736,27 @@ def _workspace_graph():
|
|
|
1708
1736
|
return KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None
|
|
1709
1737
|
|
|
1710
1738
|
|
|
1739
|
+
def _workspace_scope(request: Request) -> Optional[str]:
|
|
1740
|
+
"""Resolve the active workspace for scoping from an optional header/query.
|
|
1741
|
+
|
|
1742
|
+
Returns ``None`` when unset so the store falls back to the active workspace
|
|
1743
|
+
(Personal by default), preserving pre-1.1 behaviour for legacy clients.
|
|
1744
|
+
"""
|
|
1745
|
+
header = request.headers.get("X-Workspace-Id")
|
|
1746
|
+
if header and header.strip():
|
|
1747
|
+
return header.strip()
|
|
1748
|
+
query = request.query_params.get("workspace_id")
|
|
1749
|
+
return query.strip() if query and query.strip() else None
|
|
1750
|
+
|
|
1751
|
+
|
|
1711
1752
|
@app.get("/workspace/os")
|
|
1712
1753
|
async def workspace_os_summary(request: Request):
|
|
1713
|
-
require_user(request)
|
|
1754
|
+
user = require_user(request)
|
|
1714
1755
|
summary = WORKSPACE_OS.summary()
|
|
1715
1756
|
summary["graph"] = _graph_stats_safe()
|
|
1716
1757
|
summary["models"] = _workspace_models_payload()
|
|
1758
|
+
summary["workspace_registry"] = WORKSPACE_OS.list_workspaces(user_id=user or None)
|
|
1759
|
+
summary["edition"] = capability_registry.describe()
|
|
1717
1760
|
return summary
|
|
1718
1761
|
|
|
1719
1762
|
|
|
@@ -1770,7 +1813,7 @@ async def workspace_onboarding_model_recommendations(request: Request):
|
|
|
1770
1813
|
@app.get("/workspace/traces")
|
|
1771
1814
|
async def workspace_traces(request: Request, conversation_id: Optional[str] = None, limit: int = 50):
|
|
1772
1815
|
require_user(request)
|
|
1773
|
-
return WORKSPACE_OS.list_traces(conversation_id=conversation_id, limit=limit)
|
|
1816
|
+
return WORKSPACE_OS.list_traces(conversation_id=conversation_id, limit=limit, workspace_id=_workspace_scope(request))
|
|
1774
1817
|
|
|
1775
1818
|
|
|
1776
1819
|
@app.get("/workspace/indexing")
|
|
@@ -1805,7 +1848,7 @@ async def workspace_indexing_remove(source_id: str, request: Request):
|
|
|
1805
1848
|
@app.get("/workspace/snapshots")
|
|
1806
1849
|
async def workspace_snapshots(request: Request):
|
|
1807
1850
|
require_user(request)
|
|
1808
|
-
return WORKSPACE_OS.list_snapshots()
|
|
1851
|
+
return WORKSPACE_OS.list_snapshots(workspace_id=_workspace_scope(request))
|
|
1809
1852
|
|
|
1810
1853
|
|
|
1811
1854
|
@app.post("/workspace/snapshots")
|
|
@@ -1817,6 +1860,7 @@ async def workspace_snapshot_create(req: WorkspaceSnapshotRequest, request: Requ
|
|
|
1817
1860
|
history=get_history(),
|
|
1818
1861
|
settings=_workspace_settings_payload(),
|
|
1819
1862
|
models=_workspace_models_payload(),
|
|
1863
|
+
workspace_id=_workspace_scope(request),
|
|
1820
1864
|
)
|
|
1821
1865
|
append_audit_event("workspace_snapshot", user_email=current_user, snapshot_id=result["snapshot"]["id"])
|
|
1822
1866
|
return result
|
|
@@ -1863,7 +1907,7 @@ async def workspace_snapshot_export(snapshot_id: str, request: Request):
|
|
|
1863
1907
|
@app.get("/workspace/time-machine")
|
|
1864
1908
|
async def workspace_time_machine(request: Request, limit: int = 100):
|
|
1865
1909
|
require_user(request)
|
|
1866
|
-
return WORKSPACE_OS.timeline(get_audit_log(), limit=limit)
|
|
1910
|
+
return WORKSPACE_OS.timeline(get_audit_log(), limit=limit, workspace_id=_workspace_scope(request))
|
|
1867
1911
|
|
|
1868
1912
|
|
|
1869
1913
|
@app.get("/workspace/time-machine/{snapshot_id}/{area}")
|
|
@@ -1878,13 +1922,13 @@ async def workspace_time_machine_view(snapshot_id: str, area: str, request: Requ
|
|
|
1878
1922
|
@app.get("/workspace/memories")
|
|
1879
1923
|
async def workspace_memories(request: Request, kind: Optional[str] = None):
|
|
1880
1924
|
current_user = require_user(request)
|
|
1881
|
-
return WORKSPACE_OS.list_memories(user_email=current_user or None, kind=kind)
|
|
1925
|
+
return WORKSPACE_OS.list_memories(user_email=current_user or None, kind=kind, workspace_id=_workspace_scope(request))
|
|
1882
1926
|
|
|
1883
1927
|
|
|
1884
1928
|
@app.get("/workspace/memories/search")
|
|
1885
1929
|
async def workspace_memory_search(q: str, request: Request, limit: int = 20):
|
|
1886
1930
|
current_user = require_user(request)
|
|
1887
|
-
return WORKSPACE_OS.search_memories(q, user_email=current_user or None, limit=limit)
|
|
1931
|
+
return WORKSPACE_OS.search_memories(q, user_email=current_user or None, limit=limit, workspace_id=_workspace_scope(request))
|
|
1888
1932
|
|
|
1889
1933
|
|
|
1890
1934
|
@app.post("/workspace/memories")
|
|
@@ -1899,6 +1943,7 @@ async def workspace_memory_upsert(req: WorkspaceMemoryRequest, request: Request)
|
|
|
1899
1943
|
metadata=req.metadata,
|
|
1900
1944
|
user_email=current_user or None,
|
|
1901
1945
|
graph=_workspace_graph(),
|
|
1946
|
+
workspace_id=_workspace_scope(request),
|
|
1902
1947
|
)
|
|
1903
1948
|
except ValueError as exc:
|
|
1904
1949
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
@@ -1917,7 +1962,7 @@ async def workspace_memory_delete(memory_id: str, request: Request):
|
|
|
1917
1962
|
@app.get("/workspace/agents")
|
|
1918
1963
|
async def workspace_agents(request: Request):
|
|
1919
1964
|
require_user(request)
|
|
1920
|
-
return WORKSPACE_OS.list_agents()
|
|
1965
|
+
return WORKSPACE_OS.list_agents(workspace_id=_workspace_scope(request))
|
|
1921
1966
|
|
|
1922
1967
|
|
|
1923
1968
|
@app.post("/workspace/agents/runs")
|
|
@@ -1932,6 +1977,7 @@ async def workspace_agent_run(req: WorkspaceAgentRunRequest, request: Request):
|
|
|
1932
1977
|
relationships=req.relationships,
|
|
1933
1978
|
user_email=current_user or None,
|
|
1934
1979
|
graph=_workspace_graph(),
|
|
1980
|
+
workspace_id=_workspace_scope(request),
|
|
1935
1981
|
)
|
|
1936
1982
|
return {"run": run}
|
|
1937
1983
|
|
|
@@ -1974,7 +2020,7 @@ async def workspace_computer_memory_activity(req: WorkspaceComputerActivityReque
|
|
|
1974
2020
|
@app.get("/workspace/workflows")
|
|
1975
2021
|
async def workspace_workflows(request: Request, q: str = ""):
|
|
1976
2022
|
require_user(request)
|
|
1977
|
-
return WORKSPACE_OS.list_workflows(query=q)
|
|
2023
|
+
return WORKSPACE_OS.list_workflows(query=q, workspace_id=_workspace_scope(request))
|
|
1978
2024
|
|
|
1979
2025
|
|
|
1980
2026
|
@app.post("/workspace/workflows")
|
|
@@ -1986,6 +2032,7 @@ async def workspace_workflow_create(req: WorkspaceWorkflowRequest, request: Requ
|
|
|
1986
2032
|
metadata=req.metadata,
|
|
1987
2033
|
user_email=current_user or None,
|
|
1988
2034
|
graph=_workspace_graph(),
|
|
2035
|
+
workspace_id=_workspace_scope(request),
|
|
1989
2036
|
)
|
|
1990
2037
|
return {"workflow": workflow}
|
|
1991
2038
|
|
|
@@ -2114,6 +2161,139 @@ async def workspace_vscode_send(req: WorkspaceVSCodeRequest, request: Request):
|
|
|
2114
2161
|
return {"status": "ok", "workflow": workflow}
|
|
2115
2162
|
|
|
2116
2163
|
|
|
2164
|
+
# ── Organization Workspaces, membership, roles, and edition seam ─────────────────
|
|
2165
|
+
|
|
2166
|
+
@app.get("/workspace/registry")
|
|
2167
|
+
async def workspace_registry(request: Request):
|
|
2168
|
+
user = require_user(request)
|
|
2169
|
+
return WORKSPACE_OS.list_workspaces(user_id=user or None)
|
|
2170
|
+
|
|
2171
|
+
|
|
2172
|
+
@app.get("/workspace/editions")
|
|
2173
|
+
async def workspace_editions(request: Request):
|
|
2174
|
+
require_user(request)
|
|
2175
|
+
return capability_registry.describe()
|
|
2176
|
+
|
|
2177
|
+
|
|
2178
|
+
@app.post("/workspace/activate")
|
|
2179
|
+
async def workspace_activate(req: WorkspaceActivateRequest, request: Request):
|
|
2180
|
+
user = require_user(request)
|
|
2181
|
+
try:
|
|
2182
|
+
return WORKSPACE_OS.set_active_workspace(req.workspace_id, user_id=user or None)
|
|
2183
|
+
except FileNotFoundError as exc:
|
|
2184
|
+
raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
|
|
2185
|
+
except PermissionError as exc:
|
|
2186
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
2187
|
+
|
|
2188
|
+
|
|
2189
|
+
@app.post("/workspace/orgs")
|
|
2190
|
+
async def workspace_org_create(req: WorkspaceCreateRequest, request: Request):
|
|
2191
|
+
user = require_user(request)
|
|
2192
|
+
try:
|
|
2193
|
+
workspace = WORKSPACE_OS.create_organization_workspace(
|
|
2194
|
+
name=req.name,
|
|
2195
|
+
owner_user_id=user or None,
|
|
2196
|
+
settings=req.settings,
|
|
2197
|
+
)
|
|
2198
|
+
except ValueError as exc:
|
|
2199
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
2200
|
+
append_audit_event("workspace_created", user_email=user, workspace_id=workspace["workspace_id"])
|
|
2201
|
+
return {"workspace": workspace}
|
|
2202
|
+
|
|
2203
|
+
|
|
2204
|
+
@app.get("/workspace/orgs/{workspace_id}")
|
|
2205
|
+
async def workspace_org_get(workspace_id: str, request: Request):
|
|
2206
|
+
user = require_user(request)
|
|
2207
|
+
try:
|
|
2208
|
+
return {"workspace": WORKSPACE_OS.get_workspace(workspace_id, user_id=user or None)}
|
|
2209
|
+
except FileNotFoundError as exc:
|
|
2210
|
+
raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
|
|
2211
|
+
|
|
2212
|
+
|
|
2213
|
+
@app.get("/workspace/orgs/{workspace_id}/summary")
|
|
2214
|
+
async def workspace_org_summary(workspace_id: str, request: Request):
|
|
2215
|
+
user = require_user(request)
|
|
2216
|
+
try:
|
|
2217
|
+
return WORKSPACE_OS.workspace_summary(workspace_id, user_id=user or None)
|
|
2218
|
+
except FileNotFoundError as exc:
|
|
2219
|
+
raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
|
|
2220
|
+
|
|
2221
|
+
|
|
2222
|
+
@app.patch("/workspace/orgs/{workspace_id}")
|
|
2223
|
+
async def workspace_org_update(workspace_id: str, req: WorkspaceUpdateRequest, request: Request):
|
|
2224
|
+
user = require_user(request)
|
|
2225
|
+
try:
|
|
2226
|
+
workspace = WORKSPACE_OS.update_workspace(workspace_id, name=req.name, settings=req.settings, actor=user or None)
|
|
2227
|
+
except FileNotFoundError as exc:
|
|
2228
|
+
raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
|
|
2229
|
+
except PermissionError as exc:
|
|
2230
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
2231
|
+
except ValueError as exc:
|
|
2232
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
2233
|
+
append_audit_event("workspace_updated", user_email=user, workspace_id=workspace_id)
|
|
2234
|
+
return {"workspace": workspace}
|
|
2235
|
+
|
|
2236
|
+
|
|
2237
|
+
@app.post("/workspace/orgs/{workspace_id}/archive")
|
|
2238
|
+
async def workspace_org_archive(workspace_id: str, request: Request):
|
|
2239
|
+
user = require_user(request)
|
|
2240
|
+
try:
|
|
2241
|
+
workspace = WORKSPACE_OS.archive_workspace(workspace_id, actor=user or None)
|
|
2242
|
+
except FileNotFoundError as exc:
|
|
2243
|
+
raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
|
|
2244
|
+
except PermissionError as exc:
|
|
2245
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
2246
|
+
except ValueError as exc:
|
|
2247
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
2248
|
+
append_audit_event("workspace_archived", user_email=user, workspace_id=workspace_id)
|
|
2249
|
+
return {"workspace": workspace}
|
|
2250
|
+
|
|
2251
|
+
|
|
2252
|
+
@app.post("/workspace/orgs/{workspace_id}/members")
|
|
2253
|
+
async def workspace_org_add_member(workspace_id: str, req: WorkspaceMemberRequest, request: Request):
|
|
2254
|
+
user = require_user(request)
|
|
2255
|
+
try:
|
|
2256
|
+
workspace = WORKSPACE_OS.add_member(workspace_id, user_id=req.user_id, role=req.role, actor=user or None)
|
|
2257
|
+
except FileNotFoundError as exc:
|
|
2258
|
+
raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
|
|
2259
|
+
except PermissionError as exc:
|
|
2260
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
2261
|
+
except ValueError as exc:
|
|
2262
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
2263
|
+
append_audit_event("workspace_member_added", user_email=user, workspace_id=workspace_id, member=req.user_id, role=req.role)
|
|
2264
|
+
return {"workspace": workspace}
|
|
2265
|
+
|
|
2266
|
+
|
|
2267
|
+
@app.patch("/workspace/orgs/{workspace_id}/members/{user_id}")
|
|
2268
|
+
async def workspace_org_update_member(workspace_id: str, user_id: str, req: WorkspaceMemberRoleRequest, request: Request):
|
|
2269
|
+
user = require_user(request)
|
|
2270
|
+
try:
|
|
2271
|
+
workspace = WORKSPACE_OS.update_member_role(workspace_id, user_id=user_id, role=req.role, actor=user or None)
|
|
2272
|
+
except FileNotFoundError as exc:
|
|
2273
|
+
raise HTTPException(status_code=404, detail=f"Not found: {exc}") from exc
|
|
2274
|
+
except PermissionError as exc:
|
|
2275
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
2276
|
+
except ValueError as exc:
|
|
2277
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
2278
|
+
append_audit_event("workspace_member_role_updated", user_email=user, workspace_id=workspace_id, member=user_id, role=req.role)
|
|
2279
|
+
return {"workspace": workspace}
|
|
2280
|
+
|
|
2281
|
+
|
|
2282
|
+
@app.delete("/workspace/orgs/{workspace_id}/members/{user_id}")
|
|
2283
|
+
async def workspace_org_remove_member(workspace_id: str, user_id: str, request: Request):
|
|
2284
|
+
user = require_user(request)
|
|
2285
|
+
try:
|
|
2286
|
+
workspace = WORKSPACE_OS.remove_member(workspace_id, user_id=user_id, actor=user or None)
|
|
2287
|
+
except FileNotFoundError as exc:
|
|
2288
|
+
raise HTTPException(status_code=404, detail=f"Not found: {exc}") from exc
|
|
2289
|
+
except PermissionError as exc:
|
|
2290
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
2291
|
+
except ValueError as exc:
|
|
2292
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
2293
|
+
append_audit_event("workspace_member_removed", user_email=user, workspace_id=workspace_id, member=user_id)
|
|
2294
|
+
return {"workspace": workspace}
|
|
2295
|
+
|
|
2296
|
+
|
|
2117
2297
|
# ── Health & Info ──────────────────────────────────────────────────────────────
|
|
2118
2298
|
|
|
2119
2299
|
ENGINE_INSTALLERS = {
|
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>
|