ltcai 4.0.0 → 4.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 +42 -33
- package/desktop/electron/main.cjs +44 -0
- package/docs/CHANGELOG.md +106 -0
- package/docs/REALTIME_COLLABORATION.md +3 -3
- package/docs/V3_FRONTEND.md +9 -8
- package/docs/V4_1_FRONTEND_ARCHITECTURE_REVIEW.md +65 -0
- package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +70 -0
- package/docs/V4_1_VALIDATION_REPORT.md +47 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +95 -45
- package/docs/kg-schema.md +6 -2
- package/docs/spec-vs-impl.md +10 -10
- package/frontend/index.html +24 -0
- package/frontend/openapi.json +14190 -0
- package/frontend/src/App.tsx +184 -0
- package/frontend/src/api/client.ts +317 -0
- package/frontend/src/api/openapi.ts +16637 -0
- package/frontend/src/components/primitives.tsx +204 -0
- package/frontend/src/components/ui/badge.tsx +27 -0
- package/frontend/src/components/ui/button.tsx +37 -0
- package/frontend/src/components/ui/card.tsx +22 -0
- package/frontend/src/components/ui/input.tsx +16 -0
- package/frontend/src/components/ui/textarea.tsx +16 -0
- package/frontend/src/lib/utils.ts +33 -0
- package/frontend/src/main.tsx +23 -0
- package/frontend/src/pages/Act.tsx +245 -0
- package/frontend/src/pages/Ask.tsx +200 -0
- package/frontend/src/pages/Brain.tsx +267 -0
- package/frontend/src/pages/Capture.tsx +158 -0
- package/frontend/src/pages/Library.tsx +187 -0
- package/frontend/src/pages/System.tsx +344 -0
- package/frontend/src/routes.ts +85 -0
- package/frontend/src/store/appStore.ts +54 -0
- package/frontend/src/styles.css +107 -0
- package/kg_schema.py +2 -603
- package/knowledge_graph.py +37 -4958
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +15 -16
- package/latticeai/api/agents.py +13 -6
- package/latticeai/api/auth.py +19 -11
- package/latticeai/api/invitations.py +100 -0
- package/latticeai/api/knowledge_graph.py +4 -11
- package/latticeai/api/plugins.py +3 -6
- package/latticeai/api/realtime.py +4 -7
- package/latticeai/api/setup.py +5 -4
- package/latticeai/api/static_routes.py +13 -16
- package/latticeai/api/ui_redirects.py +26 -0
- package/latticeai/api/workflow_designer.py +39 -6
- package/latticeai/api/workspace.py +24 -10
- package/latticeai/app_factory.py +88 -17
- package/latticeai/brain/_kg_common.py +1123 -0
- package/latticeai/brain/discovery.py +1455 -0
- package/latticeai/brain/documents.py +218 -0
- package/latticeai/brain/ingest.py +644 -0
- package/latticeai/brain/projection.py +561 -0
- package/latticeai/brain/provenance.py +401 -0
- package/latticeai/brain/retrieval.py +1316 -0
- package/latticeai/brain/schema.py +640 -0
- package/latticeai/brain/store.py +216 -0
- package/latticeai/brain/write_master.py +225 -0
- package/latticeai/core/invitations.py +131 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/policy.py +54 -0
- package/latticeai/core/realtime.py +65 -44
- package/latticeai/core/sessions.py +31 -5
- package/latticeai/core/users.py +147 -0
- package/latticeai/core/workspace_os.py +420 -20
- package/latticeai/services/agent_runtime.py +242 -4
- package/latticeai/services/run_executor.py +328 -0
- package/latticeai/services/workspace_service.py +27 -19
- package/package.json +54 -27
- package/scripts/build_frontend_assets.mjs +38 -0
- package/scripts/bump_version.py +1 -1
- package/scripts/export_openapi.py +31 -0
- package/scripts/lint_frontend.mjs +86 -0
- package/scripts/run_python.mjs +47 -0
- package/src-tauri/Cargo.lock +4833 -0
- package/src-tauri/Cargo.toml +19 -0
- package/src-tauri/build.rs +3 -0
- package/src-tauri/capabilities/default.json +7 -0
- package/src-tauri/src/main.rs +78 -0
- package/src-tauri/tauri.conf.json +36 -0
- package/static/app/asset-manifest.json +32 -0
- package/static/app/assets/core-CwxXejkd.js +2 -0
- package/static/app/assets/core-CwxXejkd.js.map +1 -0
- package/static/app/assets/index-CJRAzNnf.js +333 -0
- package/static/app/assets/index-CJRAzNnf.js.map +1 -0
- package/static/app/assets/index-CSwBBgf4.css +2 -0
- package/static/app/index.html +25 -0
- package/static/manifest.json +2 -2
- package/static/sw.js +4 -4
- package/scripts/build_v3_assets.mjs +0 -170
- package/scripts/lint_v3.mjs +0 -97
- package/static/account.html +0 -113
- package/static/activity.html +0 -73
- package/static/admin.html +0 -486
- package/static/agents.html +0 -139
- package/static/chat.html +0 -841
- package/static/css/reference/account.css +0 -439
- package/static/css/reference/admin.css +0 -610
- package/static/css/reference/base.css +0 -1661
- package/static/css/reference/chat.css +0 -4623
- package/static/css/reference/graph.css +0 -1016
- package/static/css/responsive.css +0 -861
- package/static/graph.html +0 -122
- package/static/platform.css +0 -104
- package/static/plugins.html +0 -136
- package/static/scripts/account.js +0 -238
- package/static/scripts/admin.js +0 -1614
- package/static/scripts/chat.js +0 -5081
- package/static/scripts/graph.js +0 -1804
- package/static/scripts/platform.js +0 -64
- package/static/scripts/ux.js +0 -167
- package/static/scripts/workspace.js +0 -948
- package/static/v3/asset-manifest.json +0 -56
- package/static/v3/css/lattice.base.49deefb5.css +0 -128
- package/static/v3/css/lattice.base.css +0 -128
- package/static/v3/css/lattice.components.cde18231.css +0 -472
- package/static/v3/css/lattice.components.css +0 -472
- package/static/v3/css/lattice.shell.29d36d85.css +0 -452
- package/static/v3/css/lattice.shell.css +0 -452
- package/static/v3/css/lattice.tokens.304cbc40.css +0 -135
- package/static/v3/css/lattice.tokens.css +0 -135
- package/static/v3/css/lattice.views.0a18b6c5.css +0 -360
- package/static/v3/css/lattice.views.css +0 -360
- package/static/v3/index.html +0 -68
- package/static/v3/js/app.356e6452.js +0 -26
- package/static/v3/js/app.js +0 -26
- package/static/v3/js/core/api.7a308b89.js +0 -568
- package/static/v3/js/core/api.js +0 -568
- package/static/v3/js/core/components.f25b3b93.js +0 -230
- package/static/v3/js/core/components.js +0 -230
- package/static/v3/js/core/dom.a2773eb0.js +0 -148
- package/static/v3/js/core/dom.js +0 -148
- package/static/v3/js/core/router.584570f2.js +0 -37
- package/static/v3/js/core/router.js +0 -37
- package/static/v3/js/core/routes.7222343d.js +0 -93
- package/static/v3/js/core/routes.js +0 -93
- package/static/v3/js/core/shell.a1657f20.js +0 -391
- package/static/v3/js/core/shell.js +0 -391
- package/static/v3/js/core/store.204a08b2.js +0 -113
- package/static/v3/js/core/store.js +0 -113
- package/static/v3/js/views/admin-audit.660a1fb1.js +0 -185
- package/static/v3/js/views/admin-audit.js +0 -185
- package/static/v3/js/views/admin-permissions.a7ae5f09.js +0 -177
- package/static/v3/js/views/admin-permissions.js +0 -177
- package/static/v3/js/views/admin-policies.3658fd86.js +0 -102
- package/static/v3/js/views/admin-policies.js +0 -102
- package/static/v3/js/views/admin-private-vpc.7d342d36.js +0 -135
- package/static/v3/js/views/admin-private-vpc.js +0 -135
- package/static/v3/js/views/admin-security.07c66b72.js +0 -180
- package/static/v3/js/views/admin-security.js +0 -180
- package/static/v3/js/views/admin-users.03bac88c.js +0 -168
- package/static/v3/js/views/admin-users.js +0 -168
- package/static/v3/js/views/agents.014d0b74.js +0 -541
- package/static/v3/js/views/agents.js +0 -541
- package/static/v3/js/views/chat.e6dd7dd0.js +0 -601
- package/static/v3/js/views/chat.js +0 -601
- package/static/v3/js/views/files.adad14c1.js +0 -365
- package/static/v3/js/views/files.js +0 -365
- package/static/v3/js/views/graph-canvas.17c15d65.js +0 -509
- package/static/v3/js/views/graph-canvas.js +0 -509
- package/static/v3/js/views/home.24f8b8ae.js +0 -200
- package/static/v3/js/views/home.js +0 -200
- package/static/v3/js/views/hooks.37895880.js +0 -220
- package/static/v3/js/views/hooks.js +0 -220
- package/static/v3/js/views/hybrid-search.2fb63ed9.js +0 -194
- package/static/v3/js/views/hybrid-search.js +0 -194
- package/static/v3/js/views/knowledge-graph.5e40cbeb.js +0 -509
- package/static/v3/js/views/knowledge-graph.js +0 -509
- package/static/v3/js/views/marketplace.ab0583d4.js +0 -141
- package/static/v3/js/views/marketplace.js +0 -141
- package/static/v3/js/views/mcp.99b5c6a7.js +0 -114
- package/static/v3/js/views/mcp.js +0 -114
- package/static/v3/js/views/memory.4ebdf474.js +0 -147
- package/static/v3/js/views/memory.js +0 -147
- package/static/v3/js/views/models.a1ffa147.js +0 -256
- package/static/v3/js/views/models.js +0 -256
- package/static/v3/js/views/my-computer.d9d9ae1c.js +0 -463
- package/static/v3/js/views/my-computer.js +0 -463
- package/static/v3/js/views/pipeline.c522f1ce.js +0 -157
- package/static/v3/js/views/pipeline.js +0 -157
- package/static/v3/js/views/planning.9ac3e313.js +0 -153
- package/static/v3/js/views/planning.js +0 -153
- package/static/v3/js/views/settings.8631fa5e.js +0 -318
- package/static/v3/js/views/settings.js +0 -318
- package/static/v3/js/views/skills.c6c2f965.js +0 -109
- package/static/v3/js/views/skills.js +0 -109
- package/static/v3/js/views/tools.e4f11276.js +0 -108
- package/static/v3/js/views/tools.js +0 -108
- package/static/v3/js/views/workflows.26c57290.js +0 -128
- package/static/v3/js/views/workflows.js +0 -128
- package/static/workflows.html +0 -146
- package/static/workspace.css +0 -1121
- package/static/workspace.html +0 -357
package/latticeai/__init__.py
CHANGED
package/latticeai/api/admin.py
CHANGED
|
@@ -55,6 +55,7 @@ def create_admin_router(
|
|
|
55
55
|
invite_code: str,
|
|
56
56
|
invite_gate_enabled: bool,
|
|
57
57
|
default_port: int,
|
|
58
|
+
policy_matrix: Optional[Callable[[], List[Dict[str, object]]]] = None,
|
|
58
59
|
) -> APIRouter:
|
|
59
60
|
router = APIRouter()
|
|
60
61
|
|
|
@@ -109,16 +110,6 @@ def create_admin_router(
|
|
|
109
110
|
report["graph"] = {"error": str(e)}
|
|
110
111
|
return report
|
|
111
112
|
|
|
112
|
-
# Canonical RBAC capability map — which product areas each role can reach.
|
|
113
|
-
# This is the real access policy the app enforces, not sample data.
|
|
114
|
-
_ROLE_CAPS = {
|
|
115
|
-
"owner": ["all"],
|
|
116
|
-
"admin": ["users", "policies", "audit", "security", "chat", "search", "files", "pipeline"],
|
|
117
|
-
"member": ["chat", "search", "files", "pipeline"],
|
|
118
|
-
"user": ["chat", "search", "files", "pipeline"],
|
|
119
|
-
"viewer": ["chat", "search"],
|
|
120
|
-
}
|
|
121
|
-
|
|
122
113
|
@router.get("/admin/roles")
|
|
123
114
|
async def admin_roles(request: Request):
|
|
124
115
|
_, users = require_admin(request)
|
|
@@ -126,13 +117,21 @@ def create_admin_router(
|
|
|
126
117
|
for email, user in users.items():
|
|
127
118
|
role = (get_user_role(email, users) or "user").lower()
|
|
128
119
|
counts[role] += 1
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
120
|
+
matrix = policy_matrix() if policy_matrix else [
|
|
121
|
+
{"role": "admin", "caps": ["all"]},
|
|
122
|
+
{"role": "user", "caps": ["chat", "search"]},
|
|
123
|
+
]
|
|
124
|
+
policy_caps = {
|
|
125
|
+
str(item.get("role") or "user"): list(item.get("caps") or [])
|
|
126
|
+
for item in matrix
|
|
127
|
+
if isinstance(item, dict)
|
|
128
|
+
}
|
|
129
|
+
for role in policy_caps:
|
|
130
|
+
counts.setdefault(role, 0)
|
|
131
|
+
order = {"owner": 0, "admin": 1, "member": 2, "user": 3, "viewer": 4}
|
|
133
132
|
roles = [
|
|
134
|
-
{"role": role, "members": counts.get(role, 0), "caps":
|
|
135
|
-
for role in sorted(counts, key=lambda r: (r
|
|
133
|
+
{"role": role, "members": counts.get(role, 0), "caps": policy_caps.get(role, [])}
|
|
134
|
+
for role in sorted(counts, key=lambda r: (order.get(r, 99), r))
|
|
136
135
|
]
|
|
137
136
|
return {"roles": roles}
|
|
138
137
|
|
package/latticeai/api/agents.py
CHANGED
|
@@ -14,6 +14,8 @@ from typing import Any, Callable, Dict, List, Optional
|
|
|
14
14
|
from fastapi import APIRouter, HTTPException, Request
|
|
15
15
|
from pydantic import BaseModel
|
|
16
16
|
|
|
17
|
+
from latticeai.api.ui_redirects import app_redirect
|
|
18
|
+
|
|
17
19
|
|
|
18
20
|
class AgentRunRequest(BaseModel):
|
|
19
21
|
goal: str
|
|
@@ -41,6 +43,7 @@ def create_agents_router(
|
|
|
41
43
|
ui_file_response: Optional[Callable[[Path], Any]] = None,
|
|
42
44
|
static_dir: Optional[Path] = None,
|
|
43
45
|
agent_runtime: Any = None,
|
|
46
|
+
run_executor: Any = None,
|
|
44
47
|
) -> APIRouter:
|
|
45
48
|
from latticeai.core.multi_agent import AGENT_ROLES, ROLE_AGENT_IDS
|
|
46
49
|
from latticeai.services.agent_runtime import AgentRuntime
|
|
@@ -91,12 +94,7 @@ def create_agents_router(
|
|
|
91
94
|
@router.get("/agents")
|
|
92
95
|
async def agents_page(request: Request):
|
|
93
96
|
require_user(request)
|
|
94
|
-
|
|
95
|
-
raise HTTPException(status_code=404, detail="Multi-Agent UI not available.")
|
|
96
|
-
page = static_dir / "agents.html"
|
|
97
|
-
if not page.exists():
|
|
98
|
-
raise HTTPException(status_code=404, detail="Multi-Agent UI not found.")
|
|
99
|
-
return ui_file_response(page)
|
|
97
|
+
return app_redirect("agents", request)
|
|
100
98
|
|
|
101
99
|
@router.get("/agents/api/roles")
|
|
102
100
|
async def agent_roles(request: Request):
|
|
@@ -163,6 +161,15 @@ def create_agents_router(
|
|
|
163
161
|
current_user = require_user(request)
|
|
164
162
|
scope = gate_write(request)
|
|
165
163
|
try:
|
|
164
|
+
if run_executor is not None:
|
|
165
|
+
return await run_executor.start_agent(
|
|
166
|
+
req.goal,
|
|
167
|
+
user_email=current_user or None,
|
|
168
|
+
scope=scope,
|
|
169
|
+
roles=req.roles or None,
|
|
170
|
+
inputs=req.inputs,
|
|
171
|
+
max_retries=req.max_retries,
|
|
172
|
+
)
|
|
166
173
|
# Worker thread: an LLM-backed run blocks on model generation and
|
|
167
174
|
# must not stall the event loop (the sync model bridge also
|
|
168
175
|
# requires a loop-free thread).
|
package/latticeai/api/auth.py
CHANGED
|
@@ -12,6 +12,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|
|
12
12
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
13
13
|
from pydantic import BaseModel
|
|
14
14
|
|
|
15
|
+
from latticeai.core.users import normalize_email
|
|
15
16
|
from latticeai.core.oidc import (
|
|
16
17
|
OIDCValidationError,
|
|
17
18
|
fetch_jwks as _default_fetch_jwks,
|
|
@@ -66,6 +67,7 @@ def create_auth_router(
|
|
|
66
67
|
open_registration: bool,
|
|
67
68
|
session_ttl: int,
|
|
68
69
|
require_auth: bool = True,
|
|
70
|
+
ensure_identity: Optional[Callable[[str, Dict], None]] = None,
|
|
69
71
|
verify_id_token: Callable[..., Dict] = _default_verify_id_token,
|
|
70
72
|
fetch_jwks: Callable[[str], Awaitable[Dict]] = _default_fetch_jwks,
|
|
71
73
|
) -> APIRouter:
|
|
@@ -87,17 +89,20 @@ def create_auth_router(
|
|
|
87
89
|
if not open_registration:
|
|
88
90
|
raise HTTPException(status_code=403, detail="회원가입이 비활성화되어 있습니다. 관리자에게 문의하세요.")
|
|
89
91
|
_enforce_password_policy(req.password)
|
|
92
|
+
email = normalize_email(req.email)
|
|
90
93
|
users = load_users()
|
|
91
|
-
if
|
|
94
|
+
if email in users:
|
|
92
95
|
raise HTTPException(status_code=400, detail="이미 존재하는 이메일입니다.")
|
|
93
96
|
role = "admin" if not users else "user"
|
|
94
|
-
users[
|
|
97
|
+
users[email] = {
|
|
95
98
|
"password": hash_password(req.password),
|
|
96
99
|
"name": req.name,
|
|
97
100
|
"nickname": req.nickname,
|
|
98
101
|
"role": role,
|
|
99
102
|
"disabled": False,
|
|
100
103
|
}
|
|
104
|
+
if ensure_identity is not None:
|
|
105
|
+
ensure_identity(email, users[email])
|
|
101
106
|
save_users(users)
|
|
102
107
|
msg = "회원가입 성공! 첫 번째 사용자로 관리자 권한이 부여되었습니다." if role == "admin" else "회원가입 성공!"
|
|
103
108
|
return {"status": "ok", "message": msg, "role": role}
|
|
@@ -105,19 +110,20 @@ def create_auth_router(
|
|
|
105
110
|
@router.post("/login")
|
|
106
111
|
async def login(req: UserLogin, request: Request):
|
|
107
112
|
check_ip_rate_limit(client_ip(request), "login", max_calls=10, window_secs=300)
|
|
113
|
+
email = normalize_email(req.email)
|
|
108
114
|
users = load_users()
|
|
109
|
-
user = users.get(
|
|
110
|
-
if not user or not verify_and_migrate(
|
|
115
|
+
user = users.get(email)
|
|
116
|
+
if not user or not verify_and_migrate(email, req.password, user.get("password", ""), users):
|
|
111
117
|
raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 틀렸습니다.")
|
|
112
118
|
if user.get("disabled"):
|
|
113
119
|
raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
|
|
114
|
-
role = get_user_role(
|
|
115
|
-
token = create_session(
|
|
120
|
+
role = get_user_role(email, users)
|
|
121
|
+
token = create_session(email)
|
|
116
122
|
response = JSONResponse(content={
|
|
117
123
|
"status": "ok",
|
|
118
124
|
"nickname": user["nickname"],
|
|
119
125
|
"name": user["name"],
|
|
120
|
-
"email":
|
|
126
|
+
"email": email,
|
|
121
127
|
"role": role,
|
|
122
128
|
"is_admin": role == "admin",
|
|
123
129
|
})
|
|
@@ -202,7 +208,7 @@ def create_auth_router(
|
|
|
202
208
|
except Exception as exc: # discovery/JWKS fetch failure → fail closed
|
|
203
209
|
logging.warning("SSO token validation error: %s", exc)
|
|
204
210
|
raise HTTPException(status_code=502, detail="SSO 공급자 검증에 실패했습니다.")
|
|
205
|
-
email = payload.get("email") or payload.get("preferred_username") or payload.get("upn") or ""
|
|
211
|
+
email = normalize_email(payload.get("email") or payload.get("preferred_username") or payload.get("upn") or "")
|
|
206
212
|
if not email:
|
|
207
213
|
raise HTTPException(status_code=400, detail="이메일을 확인할 수 없습니다.")
|
|
208
214
|
users = load_users()
|
|
@@ -216,6 +222,8 @@ def create_auth_router(
|
|
|
216
222
|
"disabled": False,
|
|
217
223
|
"sso": True,
|
|
218
224
|
}
|
|
225
|
+
if ensure_identity is not None:
|
|
226
|
+
ensure_identity(email, users[email])
|
|
219
227
|
save_users(users)
|
|
220
228
|
if users[email].get("disabled"):
|
|
221
229
|
raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
|
|
@@ -235,7 +243,7 @@ def create_auth_router(
|
|
|
235
243
|
|
|
236
244
|
@router.post("/account/change-password")
|
|
237
245
|
async def change_password(req: ChangePasswordRequest, request: Request):
|
|
238
|
-
email = require_user(request)
|
|
246
|
+
email = normalize_email(require_user(request))
|
|
239
247
|
if not email:
|
|
240
248
|
raise HTTPException(status_code=401, detail="인증이 필요합니다.")
|
|
241
249
|
_enforce_password_policy(req.new_password)
|
|
@@ -251,7 +259,7 @@ def create_auth_router(
|
|
|
251
259
|
|
|
252
260
|
@router.patch("/account/profile")
|
|
253
261
|
async def update_profile(req: UpdateProfileRequest, request: Request):
|
|
254
|
-
email = require_user(request)
|
|
262
|
+
email = normalize_email(require_user(request))
|
|
255
263
|
if not email:
|
|
256
264
|
raise HTTPException(status_code=401, detail="인증이 필요합니다.")
|
|
257
265
|
if req.name is not None and not req.name.strip():
|
|
@@ -271,7 +279,7 @@ def create_auth_router(
|
|
|
271
279
|
|
|
272
280
|
@router.get("/account/profile")
|
|
273
281
|
async def get_profile(request: Request):
|
|
274
|
-
email = require_user(request)
|
|
282
|
+
email = normalize_email(require_user(request))
|
|
275
283
|
if not email:
|
|
276
284
|
if require_auth:
|
|
277
285
|
raise HTTPException(status_code=401, detail="인증이 필요합니다.")
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Invitation API: create, list, and accept workspace invitations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InvitationCreateRequest(BaseModel):
|
|
12
|
+
email: Optional[str] = None
|
|
13
|
+
workspace_id: Optional[str] = None
|
|
14
|
+
role: str = "member"
|
|
15
|
+
expires_hours: int = 168
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_invitations_router(
|
|
19
|
+
*,
|
|
20
|
+
invitation_store,
|
|
21
|
+
workspace_service,
|
|
22
|
+
require_admin: Callable,
|
|
23
|
+
require_user: Callable[[Request], str],
|
|
24
|
+
user_id_for_email: Callable[[Optional[str]], Optional[str]],
|
|
25
|
+
append_audit_event: Callable[..., None],
|
|
26
|
+
) -> APIRouter:
|
|
27
|
+
router = APIRouter()
|
|
28
|
+
|
|
29
|
+
@router.get("/invitations")
|
|
30
|
+
async def list_invitations(request: Request):
|
|
31
|
+
require_admin(request)
|
|
32
|
+
return {"invitations": invitation_store.list()}
|
|
33
|
+
|
|
34
|
+
@router.post("/invitations")
|
|
35
|
+
async def create_invitation(req: InvitationCreateRequest, request: Request):
|
|
36
|
+
admin_email, _ = require_admin(request)
|
|
37
|
+
actor_id = user_id_for_email(admin_email)
|
|
38
|
+
if req.workspace_id:
|
|
39
|
+
try:
|
|
40
|
+
workspace_service.store._require_permission(
|
|
41
|
+
workspace_service.store._load_org(workspace_service.store.load_state(), req.workspace_id),
|
|
42
|
+
actor_id,
|
|
43
|
+
"manage_members",
|
|
44
|
+
)
|
|
45
|
+
except FileNotFoundError as exc:
|
|
46
|
+
raise HTTPException(status_code=404, detail=f"Workspace not found: {req.workspace_id}") from exc
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
49
|
+
except PermissionError as exc:
|
|
50
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
51
|
+
if req.role not in {"owner", "admin", "member", "viewer"}:
|
|
52
|
+
raise HTTPException(status_code=400, detail="unknown invitation role")
|
|
53
|
+
invitation = invitation_store.create(
|
|
54
|
+
email=req.email,
|
|
55
|
+
workspace_id=req.workspace_id,
|
|
56
|
+
role=req.role,
|
|
57
|
+
created_by=actor_id,
|
|
58
|
+
expires_hours=req.expires_hours,
|
|
59
|
+
)
|
|
60
|
+
append_audit_event(
|
|
61
|
+
"invitation_created",
|
|
62
|
+
user_email=admin_email,
|
|
63
|
+
invitation_id=invitation.get("id"),
|
|
64
|
+
workspace_id=req.workspace_id,
|
|
65
|
+
role=req.role,
|
|
66
|
+
)
|
|
67
|
+
return {"invitation": invitation}
|
|
68
|
+
|
|
69
|
+
@router.post("/invitations/{token}/accept")
|
|
70
|
+
async def accept_invitation(token: str, request: Request):
|
|
71
|
+
email = require_user(request)
|
|
72
|
+
user_id = user_id_for_email(email)
|
|
73
|
+
if not user_id:
|
|
74
|
+
raise HTTPException(status_code=401, detail="Authentication required")
|
|
75
|
+
try:
|
|
76
|
+
invitation = invitation_store.accept(token, accepted_by=user_id, email=email or None)
|
|
77
|
+
except FileNotFoundError as exc:
|
|
78
|
+
raise HTTPException(status_code=404, detail="Invitation not found") from exc
|
|
79
|
+
except PermissionError as exc:
|
|
80
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
81
|
+
workspace_id = invitation.get("workspace_id")
|
|
82
|
+
if workspace_id:
|
|
83
|
+
try:
|
|
84
|
+
workspace_service.add_member(
|
|
85
|
+
workspace_id,
|
|
86
|
+
user_id=user_id,
|
|
87
|
+
role=invitation.get("role") or "member",
|
|
88
|
+
actor=invitation.get("created_by"),
|
|
89
|
+
)
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
92
|
+
append_audit_event(
|
|
93
|
+
"invitation_accepted",
|
|
94
|
+
user_email=email,
|
|
95
|
+
invitation_id=invitation.get("id"),
|
|
96
|
+
workspace_id=workspace_id,
|
|
97
|
+
)
|
|
98
|
+
return {"invitation": invitation}
|
|
99
|
+
|
|
100
|
+
return router
|
|
@@ -10,9 +10,10 @@ from pathlib import Path
|
|
|
10
10
|
from typing import Any, Callable, Dict, Optional
|
|
11
11
|
|
|
12
12
|
from fastapi import APIRouter, HTTPException, Request
|
|
13
|
-
from fastapi.responses import FileResponse
|
|
14
13
|
from pydantic import BaseModel
|
|
15
14
|
|
|
15
|
+
from latticeai.api.ui_redirects import app_redirect
|
|
16
|
+
|
|
16
17
|
|
|
17
18
|
class KnowledgeGraphIngestRequest(BaseModel):
|
|
18
19
|
type: str
|
|
@@ -44,22 +45,14 @@ def create_knowledge_graph_router(
|
|
|
44
45
|
"""Serve the interactive knowledge graph canvas UI."""
|
|
45
46
|
graph()
|
|
46
47
|
require_user(request)
|
|
47
|
-
|
|
48
|
-
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
49
|
-
response.headers["Pragma"] = "no-cache"
|
|
50
|
-
response.headers["Expires"] = "0"
|
|
51
|
-
return response
|
|
48
|
+
return app_redirect("knowledge-graph", request)
|
|
52
49
|
|
|
53
50
|
@router.get("/knowledge-graph")
|
|
54
51
|
async def knowledge_graph_legacy_page(request: Request):
|
|
55
52
|
"""Backward-compatible route for the graph page."""
|
|
56
53
|
graph()
|
|
57
54
|
require_user(request)
|
|
58
|
-
|
|
59
|
-
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
60
|
-
response.headers["Pragma"] = "no-cache"
|
|
61
|
-
response.headers["Expires"] = "0"
|
|
62
|
-
return response
|
|
55
|
+
return app_redirect("knowledge-graph", request)
|
|
63
56
|
|
|
64
57
|
@router.post("/knowledge-graph/curate")
|
|
65
58
|
async def knowledge_graph_curate(request: Request):
|
package/latticeai/api/plugins.py
CHANGED
|
@@ -15,6 +15,8 @@ from typing import Any, Callable, Dict, Optional
|
|
|
15
15
|
from fastapi import APIRouter, HTTPException, Request
|
|
16
16
|
from pydantic import BaseModel
|
|
17
17
|
|
|
18
|
+
from latticeai.api.ui_redirects import app_redirect
|
|
19
|
+
|
|
18
20
|
|
|
19
21
|
class PluginActionRequest(BaseModel):
|
|
20
22
|
plugin_id: str
|
|
@@ -50,12 +52,7 @@ def create_plugins_router(
|
|
|
50
52
|
@router.get("/plugins/sdk")
|
|
51
53
|
async def plugins_sdk_page(request: Request):
|
|
52
54
|
require_user(request)
|
|
53
|
-
|
|
54
|
-
raise HTTPException(status_code=404, detail="Plugin SDK UI not available.")
|
|
55
|
-
page = static_dir / "plugins.html"
|
|
56
|
-
if not page.exists():
|
|
57
|
-
raise HTTPException(status_code=404, detail="Plugin SDK UI not found.")
|
|
58
|
-
return ui_file_response(page)
|
|
55
|
+
return app_redirect("marketplace", request)
|
|
59
56
|
|
|
60
57
|
@router.get("/plugins/registry")
|
|
61
58
|
async def plugins_registry(request: Request):
|
|
@@ -12,10 +12,12 @@ import secrets
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Any, Callable, Optional, Set
|
|
14
14
|
|
|
15
|
-
from fastapi import APIRouter,
|
|
15
|
+
from fastapi import APIRouter, Request
|
|
16
16
|
from fastapi.responses import StreamingResponse
|
|
17
17
|
from pydantic import BaseModel
|
|
18
18
|
|
|
19
|
+
from latticeai.api.ui_redirects import app_redirect
|
|
20
|
+
|
|
19
21
|
|
|
20
22
|
class PresenceRequest(BaseModel):
|
|
21
23
|
client_id: Optional[str] = None
|
|
@@ -36,12 +38,7 @@ def create_realtime_router(
|
|
|
36
38
|
@router.get("/activity")
|
|
37
39
|
async def activity_page(request: Request):
|
|
38
40
|
require_user(request)
|
|
39
|
-
|
|
40
|
-
raise HTTPException(status_code=404, detail="Activity UI not available.")
|
|
41
|
-
page = static_dir / "activity.html"
|
|
42
|
-
if not page.exists():
|
|
43
|
-
raise HTTPException(status_code=404, detail="Activity UI not found.")
|
|
44
|
-
return ui_file_response(page)
|
|
41
|
+
return app_redirect("activity", request)
|
|
45
42
|
|
|
46
43
|
@router.get("/realtime/stream")
|
|
47
44
|
async def realtime_stream(request: Request):
|
package/latticeai/api/setup.py
CHANGED
|
@@ -19,15 +19,16 @@ from latticeai.models.router import parse_model_ref
|
|
|
19
19
|
from setup_wizard import get_recommendations, install_stream, open_url, scan_environment
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
class SetupInstallRequest(BaseModel):
|
|
23
|
+
items: List[Dict]
|
|
24
|
+
|
|
25
|
+
|
|
22
26
|
def create_setup_router(*, model_router, require_user) -> APIRouter:
|
|
23
27
|
api_router = APIRouter()
|
|
24
28
|
router = model_router
|
|
25
29
|
|
|
26
30
|
# ── Setup Wizard ─────────────────────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
class SetupInstallRequest(BaseModel):
|
|
29
|
-
items: List[Dict]
|
|
30
|
-
|
|
31
|
+
|
|
31
32
|
def setup_auto_state() -> Dict[str, object]:
|
|
32
33
|
"""Return the PPT-aligned zero-config setup state used by setup UI/API."""
|
|
33
34
|
profile = auto_setup_probe()
|
|
@@ -10,6 +10,8 @@ from typing import Callable, Optional
|
|
|
10
10
|
from fastapi import APIRouter, Cookie, HTTPException, Request
|
|
11
11
|
from fastapi.responses import FileResponse, HTMLResponse
|
|
12
12
|
|
|
13
|
+
from latticeai.api.ui_redirects import app_redirect
|
|
14
|
+
|
|
13
15
|
def ui_file_response(path: Path) -> FileResponse:
|
|
14
16
|
response = FileResponse(path)
|
|
15
17
|
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
@@ -44,15 +46,15 @@ def create_static_routes_router(
|
|
|
44
46
|
async def root(request: Request, code: Optional[str] = None, authorized: Optional[str] = Cookie(None)):
|
|
45
47
|
"""로그인/회원가입 페이지. 초대 게이트 활성화 시 코드 검증 후 진입."""
|
|
46
48
|
if not INVITE_GATE_ENABLED:
|
|
47
|
-
return
|
|
49
|
+
return app_redirect("account", request)
|
|
48
50
|
|
|
49
51
|
# 1. 이미 쿠키로 인증된 경우
|
|
50
52
|
if authorized == "true":
|
|
51
|
-
return
|
|
53
|
+
return app_redirect("account", request)
|
|
52
54
|
|
|
53
55
|
# 2. 초대 코드가 일치하는 경우 (최초 진입)
|
|
54
56
|
if code == INVITE_CODE:
|
|
55
|
-
response =
|
|
57
|
+
response = app_redirect("account", request)
|
|
56
58
|
response.set_cookie(key="authorized", value="true", httponly=True, samesite="lax", max_age=60*60*24*7)
|
|
57
59
|
return response
|
|
58
60
|
|
|
@@ -72,7 +74,7 @@ def create_static_routes_router(
|
|
|
72
74
|
@api_router.get("/account")
|
|
73
75
|
async def account_page():
|
|
74
76
|
"""Direct login/register page route used by logout and manual navigation."""
|
|
75
|
-
return
|
|
77
|
+
return app_redirect("account")
|
|
76
78
|
|
|
77
79
|
|
|
78
80
|
@api_router.get("/manifest.json")
|
|
@@ -82,7 +84,7 @@ def create_static_routes_router(
|
|
|
82
84
|
raise HTTPException(status_code=404)
|
|
83
85
|
return FileResponse(str(p), media_type="application/manifest+json")
|
|
84
86
|
|
|
85
|
-
@api_router.api_route("/favicon.ico", methods=["GET", "HEAD"])
|
|
87
|
+
@api_router.api_route("/favicon.ico", methods=["GET", "HEAD"], include_in_schema=False)
|
|
86
88
|
async def favicon():
|
|
87
89
|
ico = STATIC_DIR / "favicon.ico"
|
|
88
90
|
png = STATIC_DIR / "icons" / "favicon-32.png"
|
|
@@ -105,26 +107,21 @@ def create_static_routes_router(
|
|
|
105
107
|
|
|
106
108
|
@api_router.get("/chat")
|
|
107
109
|
async def chat_page(request: Request):
|
|
108
|
-
return
|
|
110
|
+
return app_redirect("chat", request)
|
|
109
111
|
|
|
110
112
|
|
|
111
113
|
@api_router.get("/app")
|
|
112
114
|
async def app_shell(request: Request):
|
|
113
|
-
"""
|
|
114
|
-
page = STATIC_DIR / "
|
|
115
|
+
"""React desktop single-page workspace shell."""
|
|
116
|
+
page = STATIC_DIR / "app" / "index.html"
|
|
115
117
|
if not page.exists():
|
|
116
|
-
raise HTTPException(status_code=404, detail="
|
|
118
|
+
raise HTTPException(status_code=404, detail="React shell not found.")
|
|
117
119
|
return ui_file_response(page)
|
|
118
120
|
|
|
119
121
|
|
|
120
122
|
@api_router.get("/admin")
|
|
121
|
-
async def admin_page():
|
|
122
|
-
|
|
123
|
-
if not admin_path.exists():
|
|
124
|
-
raise HTTPException(status_code=404, detail="Admin UI not found.")
|
|
125
|
-
response = FileResponse(admin_path)
|
|
126
|
-
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
127
|
-
return response
|
|
123
|
+
async def admin_page(request: Request):
|
|
124
|
+
return app_redirect("admin/users", request)
|
|
128
125
|
|
|
129
126
|
# /workspace and /onboarding UI pages are served by the workspace router
|
|
130
127
|
# (latticeai.api.workspace), included below after its dependencies are defined.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Compatibility redirects from retired legacy pages into the v4 SPA."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import Request
|
|
8
|
+
from fastapi.responses import RedirectResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def app_redirect(fragment: str, request: Optional[Request] = None) -> RedirectResponse:
|
|
12
|
+
"""Redirect a legacy GET route to the equivalent /app hash route.
|
|
13
|
+
|
|
14
|
+
Existing browser bookmarks keep working while the legacy HTML/JS/CSS pages
|
|
15
|
+
are removed from the shipped artifact. Query strings are preserved after the
|
|
16
|
+
hash so SPA route params remain addressable.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
frag = fragment.strip("/")
|
|
20
|
+
query = ""
|
|
21
|
+
if request is not None and request.url.query:
|
|
22
|
+
query = f"?{request.url.query}"
|
|
23
|
+
return RedirectResponse(url=f"/app#/{frag}{query}", status_code=308)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = ["app_redirect"]
|
|
@@ -19,6 +19,8 @@ from typing import Any, Callable, Dict, List, Optional
|
|
|
19
19
|
from fastapi import APIRouter, HTTPException, Request
|
|
20
20
|
from pydantic import BaseModel
|
|
21
21
|
|
|
22
|
+
from latticeai.api.ui_redirects import app_redirect
|
|
23
|
+
|
|
22
24
|
|
|
23
25
|
class WorkflowDefinitionRequest(BaseModel):
|
|
24
26
|
name: str
|
|
@@ -62,6 +64,8 @@ def create_workflow_designer_router(
|
|
|
62
64
|
ui_file_response: Optional[Callable[[Path], Any]] = None,
|
|
63
65
|
static_dir: Optional[Path] = None,
|
|
64
66
|
hooks: Any = None,
|
|
67
|
+
run_executor: Any = None,
|
|
68
|
+
trigger_service: Any = None,
|
|
65
69
|
) -> APIRouter:
|
|
66
70
|
from latticeai.core.workflow_engine import (
|
|
67
71
|
WorkflowEngine,
|
|
@@ -76,12 +80,7 @@ def create_workflow_designer_router(
|
|
|
76
80
|
@router.get("/workflows")
|
|
77
81
|
async def workflows_page(request: Request):
|
|
78
82
|
require_user(request)
|
|
79
|
-
|
|
80
|
-
raise HTTPException(status_code=404, detail="Workflow Designer UI not available.")
|
|
81
|
-
page = static_dir / "workflows.html"
|
|
82
|
-
if not page.exists():
|
|
83
|
-
raise HTTPException(status_code=404, detail="Workflow Designer UI not found.")
|
|
84
|
-
return ui_file_response(page)
|
|
83
|
+
return app_redirect("workflows", request)
|
|
85
84
|
|
|
86
85
|
@router.get("/workflows/api/definitions")
|
|
87
86
|
async def list_definitions(request: Request, q: str = ""):
|
|
@@ -151,6 +150,16 @@ def create_workflow_designer_router(
|
|
|
151
150
|
workflow = store.get_workflow(workflow_id, workspace_id=scope)
|
|
152
151
|
except FileNotFoundError as exc:
|
|
153
152
|
raise HTTPException(status_code=404, detail=f"Workflow not found: {exc}") from exc
|
|
153
|
+
if run_executor is not None:
|
|
154
|
+
result = await run_executor.start_workflow(
|
|
155
|
+
workflow,
|
|
156
|
+
workflow_id=workflow_id,
|
|
157
|
+
user_email=current_user or None,
|
|
158
|
+
scope=scope,
|
|
159
|
+
inputs=req.inputs,
|
|
160
|
+
)
|
|
161
|
+
append_audit_event("workflow_run_queued", user_email=current_user, workflow_id=workflow_id, status="queued")
|
|
162
|
+
return result
|
|
154
163
|
runners = build_runners(current_user or None, scope)
|
|
155
164
|
engine = WorkflowEngine(runners, hooks=hooks)
|
|
156
165
|
result = engine.run(workflow, inputs=req.inputs)
|
|
@@ -170,6 +179,23 @@ def create_workflow_designer_router(
|
|
|
170
179
|
append_audit_event("workflow_run", user_email=current_user, workflow_id=workflow_id, status=result.status)
|
|
171
180
|
return {"run": run, "result": result.as_dict()}
|
|
172
181
|
|
|
182
|
+
@router.post("/workflows/api/runs/{run_id}/stop")
|
|
183
|
+
async def stop_run(run_id: str, request: Request):
|
|
184
|
+
require_user(request)
|
|
185
|
+
scope = gate_write(request)
|
|
186
|
+
if run_executor is None:
|
|
187
|
+
try:
|
|
188
|
+
run = store.get_workflow_run(run_id, workspace_id=scope)
|
|
189
|
+
except FileNotFoundError as exc:
|
|
190
|
+
raise HTTPException(status_code=404, detail=f"Workflow run not found: {run_id}") from exc
|
|
191
|
+
return {
|
|
192
|
+
"stopped": False,
|
|
193
|
+
"reason": "asynchronous cancellation is not supported by the synchronous runtime",
|
|
194
|
+
"run_id": run_id,
|
|
195
|
+
"status": run.get("status"),
|
|
196
|
+
}
|
|
197
|
+
return run_executor.cancel(run_id, kind="workflow", scope=scope)
|
|
198
|
+
|
|
173
199
|
@router.post("/workflows/api/runs/{run_id}/resume")
|
|
174
200
|
async def resume_run(run_id: str, req: WorkflowResumeRequest, request: Request):
|
|
175
201
|
"""Decide a paused (awaiting_approval) run: approve → the paused node
|
|
@@ -221,6 +247,13 @@ def create_workflow_designer_router(
|
|
|
221
247
|
scope = gate_read(request)
|
|
222
248
|
return store.list_workflow_runs(limit=limit, workspace_id=scope)
|
|
223
249
|
|
|
250
|
+
@router.get("/workflows/api/triggers")
|
|
251
|
+
async def trigger_status(request: Request):
|
|
252
|
+
require_user(request)
|
|
253
|
+
if trigger_service is None:
|
|
254
|
+
return {"running": False, "tick_seconds": None, "armed": []}
|
|
255
|
+
return trigger_service.describe()
|
|
256
|
+
|
|
224
257
|
@router.get("/workflows/api/runs/{run_id}/replay")
|
|
225
258
|
async def workflow_run_replay(run_id: str, request: Request):
|
|
226
259
|
require_user(request)
|