ltcai 3.6.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/README.md +39 -31
  2. package/docs/CHANGELOG.md +64 -0
  3. package/docs/REALTIME_COLLABORATION.md +3 -3
  4. package/docs/V3_FRONTEND.md +9 -8
  5. package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
  6. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +552 -0
  7. package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
  8. package/docs/kg-schema.md +51 -53
  9. package/docs/spec-vs-impl.md +10 -10
  10. package/kg_schema.py +2 -520
  11. package/knowledge_graph.py +37 -4629
  12. package/knowledge_graph_api.py +11 -127
  13. package/latticeai/__init__.py +1 -1
  14. package/latticeai/api/admin.py +16 -17
  15. package/latticeai/api/agents.py +20 -7
  16. package/latticeai/api/auth.py +46 -15
  17. package/latticeai/api/chat.py +112 -76
  18. package/latticeai/api/health.py +1 -1
  19. package/latticeai/api/hooks.py +1 -1
  20. package/latticeai/api/invitations.py +100 -0
  21. package/latticeai/api/knowledge_graph.py +139 -0
  22. package/latticeai/api/local_files.py +1 -1
  23. package/latticeai/api/mcp.py +23 -11
  24. package/latticeai/api/memory.py +1 -1
  25. package/latticeai/api/models.py +1 -1
  26. package/latticeai/api/network.py +81 -0
  27. package/latticeai/api/plugins.py +3 -6
  28. package/latticeai/api/realtime.py +5 -8
  29. package/latticeai/api/search.py +26 -2
  30. package/latticeai/api/security_dashboard.py +2 -3
  31. package/latticeai/api/setup.py +2 -2
  32. package/latticeai/api/static_routes.py +11 -16
  33. package/latticeai/api/tools.py +3 -0
  34. package/latticeai/api/ui_redirects.py +26 -0
  35. package/latticeai/api/workflow_designer.py +85 -6
  36. package/latticeai/api/workspace.py +93 -57
  37. package/latticeai/app_factory.py +1781 -0
  38. package/latticeai/brain/__init__.py +18 -0
  39. package/latticeai/brain/_kg_common.py +1123 -0
  40. package/latticeai/brain/context.py +213 -0
  41. package/latticeai/brain/conversations.py +236 -0
  42. package/latticeai/brain/discovery.py +1455 -0
  43. package/latticeai/brain/documents.py +218 -0
  44. package/latticeai/brain/identity.py +175 -0
  45. package/latticeai/brain/ingest.py +644 -0
  46. package/latticeai/brain/memory.py +102 -0
  47. package/latticeai/brain/network.py +205 -0
  48. package/latticeai/brain/projection.py +561 -0
  49. package/latticeai/brain/provenance.py +401 -0
  50. package/latticeai/brain/retrieval.py +1316 -0
  51. package/latticeai/brain/schema.py +640 -0
  52. package/latticeai/brain/store.py +216 -0
  53. package/latticeai/brain/write_master.py +225 -0
  54. package/latticeai/core/agent.py +31 -7
  55. package/latticeai/core/audit.py +0 -7
  56. package/latticeai/core/config.py +1 -1
  57. package/latticeai/core/context_builder.py +1 -2
  58. package/latticeai/core/enterprise.py +1 -1
  59. package/latticeai/core/graph_curator.py +2 -2
  60. package/latticeai/core/invitations.py +131 -0
  61. package/latticeai/core/marketplace.py +1 -1
  62. package/latticeai/core/mcp_registry.py +791 -0
  63. package/latticeai/core/model_compat.py +1 -1
  64. package/latticeai/core/model_resolution.py +0 -1
  65. package/latticeai/core/multi_agent.py +238 -4
  66. package/latticeai/core/policy.py +54 -0
  67. package/latticeai/core/realtime.py +65 -44
  68. package/latticeai/core/security.py +1 -1
  69. package/latticeai/core/sessions.py +66 -10
  70. package/latticeai/core/users.py +147 -0
  71. package/latticeai/core/workflow_engine.py +114 -2
  72. package/latticeai/core/workspace_os.py +477 -29
  73. package/latticeai/models/__init__.py +7 -0
  74. package/latticeai/models/router.py +779 -0
  75. package/latticeai/server_app.py +29 -1536
  76. package/latticeai/services/agent_runtime.py +243 -4
  77. package/latticeai/services/app_context.py +75 -14
  78. package/latticeai/services/ingestion.py +47 -0
  79. package/latticeai/services/kg_portability.py +33 -3
  80. package/latticeai/services/memory_service.py +39 -11
  81. package/latticeai/services/model_runtime.py +2 -5
  82. package/latticeai/services/platform_runtime.py +100 -23
  83. package/latticeai/services/run_executor.py +328 -0
  84. package/latticeai/services/search_service.py +17 -8
  85. package/latticeai/services/tool_dispatch.py +12 -2
  86. package/latticeai/services/triggers.py +241 -0
  87. package/latticeai/services/upload_service.py +37 -12
  88. package/latticeai/services/workspace_service.py +55 -16
  89. package/llm_router.py +29 -772
  90. package/ltcai_cli.py +1 -2
  91. package/mcp_registry.py +25 -788
  92. package/p_reinforce.py +124 -14
  93. package/package.json +10 -20
  94. package/scripts/bump_version.py +99 -0
  95. package/scripts/generate_diagrams.py +0 -1
  96. package/scripts/lint_v3.mjs +105 -18
  97. package/scripts/validate_release_artifacts.py +0 -1
  98. package/scripts/wheel_smoke.py +142 -0
  99. package/server.py +11 -7
  100. package/setup_wizard.py +1142 -0
  101. package/static/sw.js +81 -52
  102. package/static/v3/asset-manifest.json +33 -25
  103. package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
  104. package/static/v3/css/lattice.base.css +1 -1
  105. package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
  106. package/static/v3/css/lattice.components.css +1 -1
  107. package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
  108. package/static/v3/css/lattice.shell.css +1 -1
  109. package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
  110. package/static/v3/css/lattice.tokens.css +3 -0
  111. package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
  112. package/static/v3/css/lattice.views.css +2 -2
  113. package/static/v3/index.html +3 -4
  114. package/static/v3/js/{app.c541f955.js → app.c5c80c46.js} +1 -1
  115. package/static/v3/js/core/{api.33d6320e.js → api.ba0fbf14.js} +58 -1
  116. package/static/v3/js/core/api.js +57 -0
  117. package/static/v3/js/core/i18n.880e1fec.js +575 -0
  118. package/static/v3/js/core/i18n.js +575 -0
  119. package/static/v3/js/core/routes.37522821.js +101 -0
  120. package/static/v3/js/core/routes.js +71 -63
  121. package/static/v3/js/core/{shell.8c163e0e.js → shell.e3f6bbfa.js} +68 -39
  122. package/static/v3/js/core/shell.js +66 -37
  123. package/static/v3/js/core/{store.34ebd5e6.js → store.7b2aa044.js} +11 -1
  124. package/static/v3/js/core/store.js +11 -1
  125. package/static/v3/js/views/account.eff40715.js +143 -0
  126. package/static/v3/js/views/account.js +143 -0
  127. package/static/v3/js/views/activity.0d271ef9.js +67 -0
  128. package/static/v3/js/views/activity.js +67 -0
  129. package/static/v3/js/views/{admin-users.03bac88c.js → admin-users.f7ac7b43.js} +4 -6
  130. package/static/v3/js/views/admin-users.js +4 -6
  131. package/static/v3/js/views/{agents.014d0b74.js → agents.17c5288d.js} +35 -12
  132. package/static/v3/js/views/agents.js +35 -12
  133. package/static/v3/js/views/{chat.e6dd7dd0.js → chat.e250e2cc.js} +23 -0
  134. package/static/v3/js/views/chat.js +23 -0
  135. package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
  136. package/static/v3/js/views/graph-canvas.js +509 -0
  137. package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
  138. package/static/v3/js/views/hybrid-search.js +1 -2
  139. package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.4d09c537.js} +60 -44
  140. package/static/v3/js/views/knowledge-graph.js +60 -44
  141. package/static/v3/js/views/network.52a4f181.js +97 -0
  142. package/static/v3/js/views/network.js +97 -0
  143. package/static/v3/js/views/{planning.9ac3e313.js → planning.4876fd77.js} +26 -5
  144. package/static/v3/js/views/planning.js +26 -5
  145. package/static/v3/js/views/runs.b63b2afa.js +144 -0
  146. package/static/v3/js/views/runs.js +144 -0
  147. package/static/v3/js/views/{settings.8631fa5e.js → settings.b7140634.js} +7 -8
  148. package/static/v3/js/views/settings.js +7 -8
  149. package/static/v3/js/views/snapshots.6f5db095.js +135 -0
  150. package/static/v3/js/views/snapshots.js +135 -0
  151. package/static/v3/js/views/{workflows.26c57290.js → workflows.7752225a.js} +87 -2
  152. package/static/v3/js/views/workflows.js +87 -2
  153. package/static/v3/js/views/workspace-admin.c466029b.js +156 -0
  154. package/static/v3/js/views/workspace-admin.js +156 -0
  155. package/static/vendor/chart.umd.min.js +20 -0
  156. package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
  157. package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  158. package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  159. package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  160. package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  161. package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
  162. package/static/vendor/fonts/inter.css +44 -0
  163. package/static/vendor/icons/tabler-icons.min.css +4 -0
  164. package/static/vendor/icons/tabler-icons.woff2 +0 -0
  165. package/static/vendor/marked.min.js +69 -0
  166. package/telegram_bot.py +1 -2
  167. package/tools/commands.py +4 -2
  168. package/tools/computer.py +1 -1
  169. package/tools/documents.py +1 -3
  170. package/tools/filesystem.py +0 -4
  171. package/tools/knowledge.py +1 -3
  172. package/tools/network.py +1 -3
  173. package/codex_telegram_bot.py +0 -195
  174. package/docs/assets/v3.4.0/agent-run.png +0 -0
  175. package/docs/assets/v3.4.0/agents.png +0 -0
  176. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  177. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  178. package/docs/assets/v3.4.0/chat.png +0 -0
  179. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  180. package/docs/assets/v3.4.0/files.png +0 -0
  181. package/docs/assets/v3.4.0/home.png +0 -0
  182. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  183. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  184. package/docs/assets/v3.4.0/local-agent.png +0 -0
  185. package/docs/assets/v3.4.0/memory.png +0 -0
  186. package/docs/assets/v3.4.0/settings.png +0 -0
  187. package/docs/assets/v3.4.0/vision-input.png +0 -0
  188. package/docs/assets/v3.4.0/workflows.png +0 -0
  189. package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
  190. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  191. package/docs/assets/v3.4.1/local-agent.png +0 -0
  192. package/docs/images/admin-dashboard.png +0 -0
  193. package/docs/images/architecture.png +0 -0
  194. package/docs/images/enterprise.png +0 -0
  195. package/docs/images/graph.png +0 -0
  196. package/docs/images/hero.gif +0 -0
  197. package/docs/images/knowledge-graph.png +0 -0
  198. package/docs/images/lattice-ai-demo.gif +0 -0
  199. package/docs/images/lattice-ai-hero.png +0 -0
  200. package/docs/images/logo.svg +0 -33
  201. package/docs/images/mobile-responsive.png +0 -0
  202. package/docs/images/model-recommendation.png +0 -0
  203. package/docs/images/onboarding.png +0 -0
  204. package/docs/images/organization.png +0 -0
  205. package/docs/images/pipeline.png +0 -0
  206. package/docs/images/screenshot-admin.png +0 -0
  207. package/docs/images/screenshot-chat.png +0 -0
  208. package/docs/images/screenshot-graph.png +0 -0
  209. package/docs/images/skills.png +0 -0
  210. package/docs/images/workspace-dark.png +0 -0
  211. package/docs/images/workspace-light.png +0 -0
  212. package/docs/images/workspace.png +0 -0
  213. package/requirements.txt +0 -16
  214. package/static/account.html +0 -115
  215. package/static/activity.html +0 -73
  216. package/static/admin.html +0 -488
  217. package/static/agents.html +0 -139
  218. package/static/chat.html +0 -844
  219. package/static/css/reference/account.css +0 -439
  220. package/static/css/reference/admin.css +0 -610
  221. package/static/css/reference/base.css +0 -1661
  222. package/static/css/reference/chat.css +0 -4623
  223. package/static/css/reference/graph.css +0 -1016
  224. package/static/css/responsive.css +0 -861
  225. package/static/graph.html +0 -124
  226. package/static/platform.css +0 -104
  227. package/static/plugins.html +0 -136
  228. package/static/scripts/account.js +0 -238
  229. package/static/scripts/admin.js +0 -1614
  230. package/static/scripts/chat.js +0 -5081
  231. package/static/scripts/graph.js +0 -1804
  232. package/static/scripts/platform.js +0 -64
  233. package/static/scripts/ux.js +0 -167
  234. package/static/scripts/workspace.js +0 -948
  235. package/static/v3/js/core/routes.2ce3815a.js +0 -93
  236. package/static/workflows.html +0 -146
  237. package/static/workspace.css +0 -1121
  238. package/static/workspace.html +0 -357
@@ -0,0 +1,81 @@
1
+ """Brain Network API — device identity, peer pairing, knowledge exchange.
2
+
3
+ The /network/receive endpoint authenticates PEERS (signed device requests),
4
+ not user sessions; everything else requires a logged-in user, and pairing /
5
+ pushing are deliberate owner actions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+
12
+ from fastapi import APIRouter, HTTPException, Request
13
+ from pydantic import BaseModel
14
+
15
+
16
+ class PeerPairRequest(BaseModel):
17
+ name: str
18
+ base_url: str
19
+ public_key: str
20
+
21
+
22
+ class PeerPushRequest(BaseModel):
23
+ workspace_id: Optional[str] = None
24
+
25
+
26
+ def create_network_router(*, network, identity, require_user) -> APIRouter:
27
+ router = APIRouter()
28
+
29
+ @router.get("/network/identity")
30
+ async def network_identity(request: Request):
31
+ require_user(request)
32
+ return identity.describe()
33
+
34
+ @router.get("/network/peers")
35
+ async def network_peers(request: Request):
36
+ require_user(request)
37
+ return {"peers": network.list_peers()}
38
+
39
+ @router.post("/network/peers")
40
+ async def network_pair(req: PeerPairRequest, request: Request):
41
+ require_user(request)
42
+ try:
43
+ return {"status": "paired", "peer": network.add_peer(
44
+ name=req.name, base_url=req.base_url, public_key=req.public_key,
45
+ )}
46
+ except ValueError as exc:
47
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
48
+
49
+ @router.delete("/network/peers/{peer_id}")
50
+ async def network_unpair(peer_id: str, request: Request):
51
+ require_user(request)
52
+ try:
53
+ return network.remove_peer(peer_id)
54
+ except FileNotFoundError as exc:
55
+ raise HTTPException(status_code=404, detail=f"Unknown peer: {exc}") from exc
56
+
57
+ @router.post("/network/push/{peer_id}")
58
+ async def network_push(peer_id: str, req: PeerPushRequest, request: Request):
59
+ require_user(request)
60
+ try:
61
+ return network.push_to_peer(peer_id, workspace_id=req.workspace_id)
62
+ except FileNotFoundError as exc:
63
+ raise HTTPException(status_code=404, detail=f"Unknown peer: {exc}") from exc
64
+ except Exception as exc:
65
+ raise HTTPException(status_code=502, detail=f"Push failed: {exc}") from exc
66
+
67
+ @router.post("/network/receive")
68
+ async def network_receive(request: Request):
69
+ # Peer-authenticated: a paired device's signature replaces the session.
70
+ body = await request.body()
71
+ try:
72
+ return network.receive(dict(request.headers), body)
73
+ except PermissionError as exc:
74
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
75
+ except ValueError as exc:
76
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
77
+
78
+ return router
79
+
80
+
81
+ __all__ = ["create_network_router"]
@@ -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):
@@ -10,12 +10,14 @@ from __future__ import annotations
10
10
 
11
11
  import secrets
12
12
  from pathlib import Path
13
- from typing import Any, Callable, Dict, Optional, Set
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):
@@ -47,17 +47,41 @@ class IndexRebuildRequest(BaseModel):
47
47
  include_chunks: bool = True
48
48
 
49
49
 
50
+ class _ScopedSearchService:
51
+ """Injects the caller's workspace scope into every search call —
52
+ enforcement lives at this one chokepoint, not in each handler."""
53
+
54
+ _SCOPED = {"keyword_search", "vector_search", "graph_search", "hybrid_search"}
55
+
56
+ def __init__(self, service: SearchService, allowed):
57
+ self._service = service
58
+ self._allowed = allowed
59
+
60
+ def __getattr__(self, name):
61
+ attr = getattr(self._service, name)
62
+ if name in self._SCOPED:
63
+ def scoped(*args, **kwargs):
64
+ kwargs.setdefault("allowed_workspaces", self._allowed)
65
+ return attr(*args, **kwargs)
66
+ return scoped
67
+ return attr
68
+
69
+
50
70
  def create_search_router(
51
71
  *,
52
72
  service: SearchService,
53
73
  require_user: Callable[[Request], str],
54
74
  embedding_info: Optional[Callable[[], Dict[str, Any]]] = None,
75
+ allowed_workspaces_for: Optional[Callable[[Optional[str]], Any]] = None,
55
76
  ) -> APIRouter:
56
77
  router = APIRouter()
57
78
 
58
79
  def _guarded(request: Request) -> SearchService:
59
- require_user(request)
60
- return service
80
+ user = require_user(request)
81
+ allowed = None
82
+ if allowed_workspaces_for is not None and user:
83
+ allowed = allowed_workspaces_for(user)
84
+ return _ScopedSearchService(service, allowed)
61
85
 
62
86
  def _raise_graph_error(exc: Exception) -> None:
63
87
  raise HTTPException(status_code=404, detail=str(exc)) from exc
@@ -27,13 +27,12 @@ import io
27
27
  import json
28
28
  import logging
29
29
  import re
30
- import time
31
30
  from collections import defaultdict
32
31
  from datetime import datetime
33
- from typing import Any, Callable, Dict, List, Optional, Tuple
32
+ from typing import Any, Callable, Dict, List, Optional
34
33
 
35
34
  from fastapi import APIRouter, HTTPException, Query, Request
36
- from fastapi.responses import Response, StreamingResponse
35
+ from fastapi.responses import Response
37
36
  from pydantic import BaseModel
38
37
 
39
38
  from ..core import timezones
@@ -15,8 +15,8 @@ from auto_setup import (
15
15
  recommend as auto_setup_recommend,
16
16
  verify as auto_setup_verify,
17
17
  )
18
- from llm_router import parse_model_ref
19
- from setup import get_recommendations, install_stream, open_url, scan_environment
18
+ from latticeai.models.router import parse_model_ref
19
+ from setup_wizard import get_recommendations, install_stream, open_url, scan_environment
20
20
 
21
21
 
22
22
  def create_setup_router(*, model_router, require_user) -> APIRouter:
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import re
6
5
  import subprocess
7
6
  from dataclasses import dataclass
8
7
  from pathlib import Path
@@ -11,6 +10,8 @@ from typing import Callable, Optional
11
10
  from fastapi import APIRouter, Cookie, HTTPException, Request
12
11
  from fastapi.responses import FileResponse, HTMLResponse
13
12
 
13
+ from latticeai.api.ui_redirects import app_redirect
14
+
14
15
  def ui_file_response(path: Path) -> FileResponse:
15
16
  response = FileResponse(path)
16
17
  response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
@@ -45,20 +46,20 @@ def create_static_routes_router(
45
46
  async def root(request: Request, code: Optional[str] = None, authorized: Optional[str] = Cookie(None)):
46
47
  """로그인/회원가입 페이지. 초대 게이트 활성화 시 코드 검증 후 진입."""
47
48
  if not INVITE_GATE_ENABLED:
48
- return ui_file_response(STATIC_DIR / "account.html")
49
+ return app_redirect("account", request)
49
50
 
50
51
  # 1. 이미 쿠키로 인증된 경우
51
52
  if authorized == "true":
52
- return ui_file_response(STATIC_DIR / "account.html")
53
+ return app_redirect("account", request)
53
54
 
54
55
  # 2. 초대 코드가 일치하는 경우 (최초 진입)
55
56
  if code == INVITE_CODE:
56
- response = ui_file_response(STATIC_DIR / "account.html")
57
+ response = app_redirect("account", request)
57
58
  response.set_cookie(key="authorized", value="true", httponly=True, samesite="lax", max_age=60*60*24*7)
58
59
  return response
59
60
 
60
61
  # 3. 인증 실패 시 차단 화면
61
- return HTMLResponse(content=f"""
62
+ return HTMLResponse(content="""
62
63
  <body style="background:#0f1115; color:white; display:flex; flex-direction:column; align-items:center; justify-content:center; height:100vh; font-family:sans-serif;">
63
64
  <div style="background:#16191f; padding:40px; border-radius:24px; border:1px solid rgba(255,255,255,0.1); text-align:center; box-shadow: 0 20px 40px rgba(0,0,0,0.5);">
64
65
  <div style="font-size:48px; margin-bottom:20px;">🔒</div>
@@ -73,7 +74,7 @@ def create_static_routes_router(
73
74
  @api_router.get("/account")
74
75
  async def account_page():
75
76
  """Direct login/register page route used by logout and manual navigation."""
76
- return ui_file_response(STATIC_DIR / "account.html")
77
+ return app_redirect("account")
77
78
 
78
79
 
79
80
  @api_router.get("/manifest.json")
@@ -106,7 +107,7 @@ def create_static_routes_router(
106
107
 
107
108
  @api_router.get("/chat")
108
109
  async def chat_page(request: Request):
109
- return ui_file_response(STATIC_DIR / "chat.html")
110
+ return app_redirect("chat", request)
110
111
 
111
112
 
112
113
  @api_router.get("/app")
@@ -119,13 +120,8 @@ def create_static_routes_router(
119
120
 
120
121
 
121
122
  @api_router.get("/admin")
122
- async def admin_page():
123
- admin_path = STATIC_DIR / "admin.html"
124
- if not admin_path.exists():
125
- raise HTTPException(status_code=404, detail="Admin UI not found.")
126
- response = FileResponse(admin_path)
127
- response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
128
- return response
123
+ async def admin_page(request: Request):
124
+ return app_redirect("admin/users", request)
129
125
 
130
126
  # /workspace and /onboarding UI pages are served by the workspace router
131
127
  # (latticeai.api.workspace), included below after its dependencies are defined.
@@ -145,7 +141,7 @@ def create_static_routes_router(
145
141
  async def local_sysinfo(request: Request):
146
142
  """CPU / RAM / GPU(MLX) 사용량을 반환합니다."""
147
143
  require_user(request)
148
- import subprocess, re as _re
144
+ import re as _re
149
145
  result = {"cpu_pct": 0.0, "ram_pct": 0.0, "gpu_mem_pct": 0.0, "gpu_mem_gb": 0.0}
150
146
  try:
151
147
  # CPU
@@ -157,7 +153,6 @@ def create_static_routes_router(
157
153
  result["cpu_pct"] = round(float(m.group(1)) + float(m.group(2)), 1)
158
154
  # RAM
159
155
  vm_out = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=4).stdout
160
- page_size = 16384
161
156
  pages: dict = {}
162
157
  for line in vm_out.splitlines():
163
158
  for key in ["Pages free", "Pages active", "Pages inactive", "Pages wired down", "Pages occupied by compressor"]:
@@ -178,6 +178,7 @@ class ToolGitShowRequest(BaseModel):
178
178
  def create_tools_router(
179
179
  *,
180
180
  config,
181
+ ingestion_pipeline,
181
182
  data_dir: Path,
182
183
  static_dir: Path,
183
184
  model_router,
@@ -438,6 +439,7 @@ def create_tools_router(
438
439
  current_user=current_user,
439
440
  enable_graph=ENABLE_GRAPH,
440
441
  knowledge_graph=KNOWLEDGE_GRAPH,
442
+ ingestion_pipeline=ingestion_pipeline,
441
443
  bytes_match_extension=_bytes_match_extension,
442
444
  classify_sensitive_message=classify_sensitive_message,
443
445
  append_audit_event=append_audit_event,
@@ -588,6 +590,7 @@ def create_tools_router(
588
590
  tool_response=_tool_response,
589
591
  require_graph=_require_graph,
590
592
  knowledge_graph=KNOWLEDGE_GRAPH,
593
+ ingestion_pipeline=ingestion_pipeline,
591
594
  data_dir=DATA_DIR,
592
595
  ))
593
596
 
@@ -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
@@ -32,6 +34,10 @@ class WorkflowUpdateRequest(BaseModel):
32
34
  metadata: Optional[Dict[str, Any]] = None
33
35
 
34
36
 
37
+ class WorkflowResumeRequest(BaseModel):
38
+ approved: bool = True
39
+
40
+
35
41
  class WorkflowRunRequest(BaseModel):
36
42
  inputs: Dict[str, Any] = {}
37
43
 
@@ -58,6 +64,8 @@ def create_workflow_designer_router(
58
64
  ui_file_response: Optional[Callable[[Path], Any]] = None,
59
65
  static_dir: Optional[Path] = None,
60
66
  hooks: Any = None,
67
+ run_executor: Any = None,
68
+ trigger_service: Any = None,
61
69
  ) -> APIRouter:
62
70
  from latticeai.core.workflow_engine import (
63
71
  WorkflowEngine,
@@ -72,12 +80,7 @@ def create_workflow_designer_router(
72
80
  @router.get("/workflows")
73
81
  async def workflows_page(request: Request):
74
82
  require_user(request)
75
- if ui_file_response is None or static_dir is None:
76
- raise HTTPException(status_code=404, detail="Workflow Designer UI not available.")
77
- page = static_dir / "workflows.html"
78
- if not page.exists():
79
- raise HTTPException(status_code=404, detail="Workflow Designer UI not found.")
80
- return ui_file_response(page)
83
+ return app_redirect("workflows", request)
81
84
 
82
85
  @router.get("/workflows/api/definitions")
83
86
  async def list_definitions(request: Request, q: str = ""):
@@ -147,6 +150,16 @@ def create_workflow_designer_router(
147
150
  workflow = store.get_workflow(workflow_id, workspace_id=scope)
148
151
  except FileNotFoundError as exc:
149
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
150
163
  runners = build_runners(current_user or None, scope)
151
164
  engine = WorkflowEngine(runners, hooks=hooks)
152
165
  result = engine.run(workflow, inputs=req.inputs)
@@ -159,10 +172,69 @@ def create_workflow_designer_router(
159
172
  user_email=current_user or None,
160
173
  graph=workspace_graph(),
161
174
  workspace_id=scope,
175
+ mode="live",
176
+ pause={"node": result.paused_node, "pending": result.pending_approval,
177
+ "context": result.paused_context} if result.status == "awaiting_approval" else None,
162
178
  )
163
179
  append_audit_event("workflow_run", user_email=current_user, workflow_id=workflow_id, status=result.status)
164
180
  return {"run": run, "result": result.as_dict()}
165
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
+
199
+ @router.post("/workflows/api/runs/{run_id}/resume")
200
+ async def resume_run(run_id: str, req: WorkflowResumeRequest, request: Request):
201
+ """Decide a paused (awaiting_approval) run: approve → the paused node
202
+ executes and the run continues; deny → the run fails honestly."""
203
+ current_user = require_user(request)
204
+ scope = gate_write(request)
205
+ run_record = store.get_workflow_run(run_id, workspace_id=scope)
206
+ pause = run_record.get("pause") or {}
207
+ if run_record.get("status") != "awaiting_approval" or not pause.get("node"):
208
+ raise HTTPException(status_code=409, detail="run is not awaiting approval")
209
+ workflow = store.get_workflow(run_record.get("workflow_id"), workspace_id=scope)
210
+ runners = build_runners(current_user or None, scope)
211
+ engine = WorkflowEngine(runners, hooks=hooks)
212
+ result = engine.resume(
213
+ workflow,
214
+ paused_node=pause["node"],
215
+ paused_context=pause.get("context") or {},
216
+ approved=bool(req.approved),
217
+ prior_timeline=run_record.get("timeline") or [],
218
+ )
219
+ resumed = store.record_workflow_run(
220
+ workflow_id=run_record.get("workflow_id"),
221
+ name=run_record.get("name") or "workflow",
222
+ status=result.status,
223
+ timeline=result.timeline,
224
+ outputs=result.outputs,
225
+ user_email=current_user or None,
226
+ graph=workspace_graph(),
227
+ workspace_id=scope,
228
+ mode="live",
229
+ pause={"node": result.paused_node, "pending": result.pending_approval,
230
+ "context": result.paused_context} if result.status == "awaiting_approval" else None,
231
+ )
232
+ store.mark_workflow_run_resolved(run_id, resumed_run_id=resumed["id"],
233
+ approved=bool(req.approved), workspace_id=scope)
234
+ append_audit_event("workflow_run_resume", user_email=current_user,
235
+ run_id=run_id, approved=bool(req.approved), status=result.status)
236
+ return {"run": resumed, "result": result.as_dict(), "resumed_from": run_id}
237
+
166
238
  @router.get("/workflows/api/definitions/{workflow_id}/runs")
167
239
  async def list_runs(workflow_id: str, request: Request, limit: int = 50):
168
240
  require_user(request)
@@ -175,6 +247,13 @@ def create_workflow_designer_router(
175
247
  scope = gate_read(request)
176
248
  return store.list_workflow_runs(limit=limit, workspace_id=scope)
177
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
+
178
257
  @router.get("/workflows/api/runs/{run_id}/replay")
179
258
  async def workflow_run_replay(run_id: str, request: Request):
180
259
  require_user(request)