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.
Files changed (195) hide show
  1. package/README.md +42 -33
  2. package/desktop/electron/main.cjs +44 -0
  3. package/docs/CHANGELOG.md +106 -0
  4. package/docs/REALTIME_COLLABORATION.md +3 -3
  5. package/docs/V3_FRONTEND.md +9 -8
  6. package/docs/V4_1_FRONTEND_ARCHITECTURE_REVIEW.md +65 -0
  7. package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +70 -0
  8. package/docs/V4_1_VALIDATION_REPORT.md +47 -0
  9. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +95 -45
  10. package/docs/kg-schema.md +6 -2
  11. package/docs/spec-vs-impl.md +10 -10
  12. package/frontend/index.html +24 -0
  13. package/frontend/openapi.json +14190 -0
  14. package/frontend/src/App.tsx +184 -0
  15. package/frontend/src/api/client.ts +317 -0
  16. package/frontend/src/api/openapi.ts +16637 -0
  17. package/frontend/src/components/primitives.tsx +204 -0
  18. package/frontend/src/components/ui/badge.tsx +27 -0
  19. package/frontend/src/components/ui/button.tsx +37 -0
  20. package/frontend/src/components/ui/card.tsx +22 -0
  21. package/frontend/src/components/ui/input.tsx +16 -0
  22. package/frontend/src/components/ui/textarea.tsx +16 -0
  23. package/frontend/src/lib/utils.ts +33 -0
  24. package/frontend/src/main.tsx +23 -0
  25. package/frontend/src/pages/Act.tsx +245 -0
  26. package/frontend/src/pages/Ask.tsx +200 -0
  27. package/frontend/src/pages/Brain.tsx +267 -0
  28. package/frontend/src/pages/Capture.tsx +158 -0
  29. package/frontend/src/pages/Library.tsx +187 -0
  30. package/frontend/src/pages/System.tsx +344 -0
  31. package/frontend/src/routes.ts +85 -0
  32. package/frontend/src/store/appStore.ts +54 -0
  33. package/frontend/src/styles.css +107 -0
  34. package/kg_schema.py +2 -603
  35. package/knowledge_graph.py +37 -4958
  36. package/latticeai/__init__.py +1 -1
  37. package/latticeai/api/admin.py +15 -16
  38. package/latticeai/api/agents.py +13 -6
  39. package/latticeai/api/auth.py +19 -11
  40. package/latticeai/api/invitations.py +100 -0
  41. package/latticeai/api/knowledge_graph.py +4 -11
  42. package/latticeai/api/plugins.py +3 -6
  43. package/latticeai/api/realtime.py +4 -7
  44. package/latticeai/api/setup.py +5 -4
  45. package/latticeai/api/static_routes.py +13 -16
  46. package/latticeai/api/ui_redirects.py +26 -0
  47. package/latticeai/api/workflow_designer.py +39 -6
  48. package/latticeai/api/workspace.py +24 -10
  49. package/latticeai/app_factory.py +88 -17
  50. package/latticeai/brain/_kg_common.py +1123 -0
  51. package/latticeai/brain/discovery.py +1455 -0
  52. package/latticeai/brain/documents.py +218 -0
  53. package/latticeai/brain/ingest.py +644 -0
  54. package/latticeai/brain/projection.py +561 -0
  55. package/latticeai/brain/provenance.py +401 -0
  56. package/latticeai/brain/retrieval.py +1316 -0
  57. package/latticeai/brain/schema.py +640 -0
  58. package/latticeai/brain/store.py +216 -0
  59. package/latticeai/brain/write_master.py +225 -0
  60. package/latticeai/core/invitations.py +131 -0
  61. package/latticeai/core/marketplace.py +1 -1
  62. package/latticeai/core/multi_agent.py +1 -1
  63. package/latticeai/core/policy.py +54 -0
  64. package/latticeai/core/realtime.py +65 -44
  65. package/latticeai/core/sessions.py +31 -5
  66. package/latticeai/core/users.py +147 -0
  67. package/latticeai/core/workspace_os.py +420 -20
  68. package/latticeai/services/agent_runtime.py +242 -4
  69. package/latticeai/services/run_executor.py +328 -0
  70. package/latticeai/services/workspace_service.py +27 -19
  71. package/package.json +54 -27
  72. package/scripts/build_frontend_assets.mjs +38 -0
  73. package/scripts/bump_version.py +1 -1
  74. package/scripts/export_openapi.py +31 -0
  75. package/scripts/lint_frontend.mjs +86 -0
  76. package/scripts/run_python.mjs +47 -0
  77. package/src-tauri/Cargo.lock +4833 -0
  78. package/src-tauri/Cargo.toml +19 -0
  79. package/src-tauri/build.rs +3 -0
  80. package/src-tauri/capabilities/default.json +7 -0
  81. package/src-tauri/src/main.rs +78 -0
  82. package/src-tauri/tauri.conf.json +36 -0
  83. package/static/app/asset-manifest.json +32 -0
  84. package/static/app/assets/core-CwxXejkd.js +2 -0
  85. package/static/app/assets/core-CwxXejkd.js.map +1 -0
  86. package/static/app/assets/index-CJRAzNnf.js +333 -0
  87. package/static/app/assets/index-CJRAzNnf.js.map +1 -0
  88. package/static/app/assets/index-CSwBBgf4.css +2 -0
  89. package/static/app/index.html +25 -0
  90. package/static/manifest.json +2 -2
  91. package/static/sw.js +4 -4
  92. package/scripts/build_v3_assets.mjs +0 -170
  93. package/scripts/lint_v3.mjs +0 -97
  94. package/static/account.html +0 -113
  95. package/static/activity.html +0 -73
  96. package/static/admin.html +0 -486
  97. package/static/agents.html +0 -139
  98. package/static/chat.html +0 -841
  99. package/static/css/reference/account.css +0 -439
  100. package/static/css/reference/admin.css +0 -610
  101. package/static/css/reference/base.css +0 -1661
  102. package/static/css/reference/chat.css +0 -4623
  103. package/static/css/reference/graph.css +0 -1016
  104. package/static/css/responsive.css +0 -861
  105. package/static/graph.html +0 -122
  106. package/static/platform.css +0 -104
  107. package/static/plugins.html +0 -136
  108. package/static/scripts/account.js +0 -238
  109. package/static/scripts/admin.js +0 -1614
  110. package/static/scripts/chat.js +0 -5081
  111. package/static/scripts/graph.js +0 -1804
  112. package/static/scripts/platform.js +0 -64
  113. package/static/scripts/ux.js +0 -167
  114. package/static/scripts/workspace.js +0 -948
  115. package/static/v3/asset-manifest.json +0 -56
  116. package/static/v3/css/lattice.base.49deefb5.css +0 -128
  117. package/static/v3/css/lattice.base.css +0 -128
  118. package/static/v3/css/lattice.components.cde18231.css +0 -472
  119. package/static/v3/css/lattice.components.css +0 -472
  120. package/static/v3/css/lattice.shell.29d36d85.css +0 -452
  121. package/static/v3/css/lattice.shell.css +0 -452
  122. package/static/v3/css/lattice.tokens.304cbc40.css +0 -135
  123. package/static/v3/css/lattice.tokens.css +0 -135
  124. package/static/v3/css/lattice.views.0a18b6c5.css +0 -360
  125. package/static/v3/css/lattice.views.css +0 -360
  126. package/static/v3/index.html +0 -68
  127. package/static/v3/js/app.356e6452.js +0 -26
  128. package/static/v3/js/app.js +0 -26
  129. package/static/v3/js/core/api.7a308b89.js +0 -568
  130. package/static/v3/js/core/api.js +0 -568
  131. package/static/v3/js/core/components.f25b3b93.js +0 -230
  132. package/static/v3/js/core/components.js +0 -230
  133. package/static/v3/js/core/dom.a2773eb0.js +0 -148
  134. package/static/v3/js/core/dom.js +0 -148
  135. package/static/v3/js/core/router.584570f2.js +0 -37
  136. package/static/v3/js/core/router.js +0 -37
  137. package/static/v3/js/core/routes.7222343d.js +0 -93
  138. package/static/v3/js/core/routes.js +0 -93
  139. package/static/v3/js/core/shell.a1657f20.js +0 -391
  140. package/static/v3/js/core/shell.js +0 -391
  141. package/static/v3/js/core/store.204a08b2.js +0 -113
  142. package/static/v3/js/core/store.js +0 -113
  143. package/static/v3/js/views/admin-audit.660a1fb1.js +0 -185
  144. package/static/v3/js/views/admin-audit.js +0 -185
  145. package/static/v3/js/views/admin-permissions.a7ae5f09.js +0 -177
  146. package/static/v3/js/views/admin-permissions.js +0 -177
  147. package/static/v3/js/views/admin-policies.3658fd86.js +0 -102
  148. package/static/v3/js/views/admin-policies.js +0 -102
  149. package/static/v3/js/views/admin-private-vpc.7d342d36.js +0 -135
  150. package/static/v3/js/views/admin-private-vpc.js +0 -135
  151. package/static/v3/js/views/admin-security.07c66b72.js +0 -180
  152. package/static/v3/js/views/admin-security.js +0 -180
  153. package/static/v3/js/views/admin-users.03bac88c.js +0 -168
  154. package/static/v3/js/views/admin-users.js +0 -168
  155. package/static/v3/js/views/agents.014d0b74.js +0 -541
  156. package/static/v3/js/views/agents.js +0 -541
  157. package/static/v3/js/views/chat.e6dd7dd0.js +0 -601
  158. package/static/v3/js/views/chat.js +0 -601
  159. package/static/v3/js/views/files.adad14c1.js +0 -365
  160. package/static/v3/js/views/files.js +0 -365
  161. package/static/v3/js/views/graph-canvas.17c15d65.js +0 -509
  162. package/static/v3/js/views/graph-canvas.js +0 -509
  163. package/static/v3/js/views/home.24f8b8ae.js +0 -200
  164. package/static/v3/js/views/home.js +0 -200
  165. package/static/v3/js/views/hooks.37895880.js +0 -220
  166. package/static/v3/js/views/hooks.js +0 -220
  167. package/static/v3/js/views/hybrid-search.2fb63ed9.js +0 -194
  168. package/static/v3/js/views/hybrid-search.js +0 -194
  169. package/static/v3/js/views/knowledge-graph.5e40cbeb.js +0 -509
  170. package/static/v3/js/views/knowledge-graph.js +0 -509
  171. package/static/v3/js/views/marketplace.ab0583d4.js +0 -141
  172. package/static/v3/js/views/marketplace.js +0 -141
  173. package/static/v3/js/views/mcp.99b5c6a7.js +0 -114
  174. package/static/v3/js/views/mcp.js +0 -114
  175. package/static/v3/js/views/memory.4ebdf474.js +0 -147
  176. package/static/v3/js/views/memory.js +0 -147
  177. package/static/v3/js/views/models.a1ffa147.js +0 -256
  178. package/static/v3/js/views/models.js +0 -256
  179. package/static/v3/js/views/my-computer.d9d9ae1c.js +0 -463
  180. package/static/v3/js/views/my-computer.js +0 -463
  181. package/static/v3/js/views/pipeline.c522f1ce.js +0 -157
  182. package/static/v3/js/views/pipeline.js +0 -157
  183. package/static/v3/js/views/planning.9ac3e313.js +0 -153
  184. package/static/v3/js/views/planning.js +0 -153
  185. package/static/v3/js/views/settings.8631fa5e.js +0 -318
  186. package/static/v3/js/views/settings.js +0 -318
  187. package/static/v3/js/views/skills.c6c2f965.js +0 -109
  188. package/static/v3/js/views/skills.js +0 -109
  189. package/static/v3/js/views/tools.e4f11276.js +0 -108
  190. package/static/v3/js/views/tools.js +0 -108
  191. package/static/v3/js/views/workflows.26c57290.js +0 -128
  192. package/static/v3/js/views/workflows.js +0 -128
  193. package/static/workflows.html +0 -146
  194. package/static/workspace.css +0 -1121
  195. package/static/workspace.html +0 -357
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "4.0.0"
3
+ __version__ = "4.1.0"
@@ -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
- # Always surface the two RBAC tiers the app actually distinguishes so the
130
- # matrix is meaningful even before extra users exist.
131
- for base in ("admin", "user"):
132
- counts.setdefault(base, counts.get(base, 0))
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": _ROLE_CAPS.get(role, ["chat", "search"])}
135
- for role in sorted(counts, key=lambda r: (r != "owner", r != "admin", 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
 
@@ -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
- if ui_file_response is None or static_dir is None:
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).
@@ -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 req.email in users:
94
+ if email in users:
92
95
  raise HTTPException(status_code=400, detail="이미 존재하는 이메일입니다.")
93
96
  role = "admin" if not users else "user"
94
- users[req.email] = {
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(req.email)
110
- if not user or not verify_and_migrate(req.email, req.password, user.get("password", ""), users):
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(req.email, users)
115
- token = create_session(req.email)
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": req.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
- response = FileResponse(static_dir / "graph.html")
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
- response = FileResponse(static_dir / "graph.html")
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):
@@ -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
- if ui_file_response is None or static_dir is None:
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, HTTPException, Request
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
- if ui_file_response is None or static_dir is None:
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):
@@ -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 ui_file_response(STATIC_DIR / "account.html")
49
+ return app_redirect("account", request)
48
50
 
49
51
  # 1. 이미 쿠키로 인증된 경우
50
52
  if authorized == "true":
51
- return ui_file_response(STATIC_DIR / "account.html")
53
+ return app_redirect("account", request)
52
54
 
53
55
  # 2. 초대 코드가 일치하는 경우 (최초 진입)
54
56
  if code == INVITE_CODE:
55
- response = ui_file_response(STATIC_DIR / "account.html")
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 ui_file_response(STATIC_DIR / "account.html")
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 ui_file_response(STATIC_DIR / "chat.html")
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
- """v3 single-page workspace shell (token-native design system)."""
114
- page = STATIC_DIR / "v3" / "index.html"
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="v3 shell not found.")
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
- admin_path = STATIC_DIR / "admin.html"
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
- if ui_file_response is None or static_dir is None:
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)