ltcai 2.2.7 → 3.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 (122) hide show
  1. package/README.md +72 -34
  2. package/docs/CHANGELOG.md +119 -0
  3. package/docs/V3_BACKEND_ARCHITECTURE.md +138 -0
  4. package/docs/V3_FRONTEND.md +139 -0
  5. package/knowledge_graph.py +649 -21
  6. package/latticeai/__init__.py +1 -1
  7. package/latticeai/api/admin.py +47 -0
  8. package/latticeai/api/agents.py +54 -31
  9. package/latticeai/api/auth.py +5 -2
  10. package/latticeai/api/chat.py +10 -2
  11. package/latticeai/api/search.py +240 -0
  12. package/latticeai/api/static_routes.py +11 -2
  13. package/latticeai/core/config.py +18 -0
  14. package/latticeai/core/embedding_providers.py +625 -0
  15. package/latticeai/core/local_embeddings.py +86 -0
  16. package/latticeai/core/workspace_os.py +1 -1
  17. package/latticeai/server_app.py +65 -1
  18. package/latticeai/services/agent_runtime.py +245 -0
  19. package/latticeai/services/search_service.py +346 -0
  20. package/package.json +13 -6
  21. package/scripts/build_v3_assets.mjs +164 -0
  22. package/scripts/capture/README.md +28 -0
  23. package/scripts/capture/capture_enterprise.js +8 -0
  24. package/scripts/capture/capture_graph.js +8 -0
  25. package/scripts/capture/capture_onboarding.js +8 -0
  26. package/scripts/capture/capture_page.js +43 -0
  27. package/scripts/capture/capture_release_media.js +125 -0
  28. package/scripts/capture/capture_skills.js +8 -0
  29. package/scripts/capture/capture_workspace.js +8 -0
  30. package/scripts/generate_diagrams.py +513 -0
  31. package/scripts/lint_v3.mjs +33 -0
  32. package/scripts/release-0.3.1.sh +105 -0
  33. package/scripts/take_screenshots.js +69 -0
  34. package/scripts/validate_release_artifacts.py +167 -0
  35. package/static/account.html +9 -9
  36. package/static/activity.html +4 -4
  37. package/static/admin.html +8 -8
  38. package/static/agents.html +4 -4
  39. package/static/chat.html +10 -10
  40. package/static/css/reference/account.css +137 -1
  41. package/static/css/reference/chat.css +31 -37
  42. package/static/css/responsive.css +42 -0
  43. package/static/css/tokens.5a595671.css +260 -0
  44. package/static/css/tokens.css +125 -130
  45. package/static/graph.html +9 -9
  46. package/static/manifest.json +3 -3
  47. package/static/plugins.html +4 -4
  48. package/static/scripts/account.js +4 -4
  49. package/static/scripts/chat.js +40 -8
  50. package/static/scripts/workspace.js +78 -0
  51. package/static/sw.js +3 -1
  52. package/static/v3/asset-manifest.json +47 -0
  53. package/static/v3/css/lattice.base.css +128 -0
  54. package/static/v3/css/lattice.base.e4cdd05d.css +128 -0
  55. package/static/v3/css/lattice.components.011e988b.css +447 -0
  56. package/static/v3/css/lattice.components.css +447 -0
  57. package/static/v3/css/lattice.shell.4920f42d.css +407 -0
  58. package/static/v3/css/lattice.shell.css +407 -0
  59. package/static/v3/css/lattice.tokens.c597ff81.css +132 -0
  60. package/static/v3/css/lattice.tokens.css +132 -0
  61. package/static/v3/css/lattice.views.3ee19d4e.css +277 -0
  62. package/static/v3/css/lattice.views.css +277 -0
  63. package/static/v3/index.html +69 -0
  64. package/static/v3/js/app.46fb61d9.js +26 -0
  65. package/static/v3/js/app.js +26 -0
  66. package/static/v3/js/core/api.22a41d42.js +344 -0
  67. package/static/v3/js/core/api.js +344 -0
  68. package/static/v3/js/core/components.4c83e0a9.js +222 -0
  69. package/static/v3/js/core/components.js +222 -0
  70. package/static/v3/js/core/dom.a2773eb0.js +148 -0
  71. package/static/v3/js/core/dom.js +148 -0
  72. package/static/v3/js/core/router.584570f2.js +37 -0
  73. package/static/v3/js/core/router.js +37 -0
  74. package/static/v3/js/core/routes.f935dd50.js +78 -0
  75. package/static/v3/js/core/routes.js +78 -0
  76. package/static/v3/js/core/shell.1b6199d6.js +363 -0
  77. package/static/v3/js/core/shell.js +363 -0
  78. package/static/v3/js/core/store.34ebd5e6.js +113 -0
  79. package/static/v3/js/core/store.js +113 -0
  80. package/static/v3/js/views/admin-audit.660a1fb1.js +185 -0
  81. package/static/v3/js/views/admin-audit.js +185 -0
  82. package/static/v3/js/views/admin-permissions.a7ae5f09.js +177 -0
  83. package/static/v3/js/views/admin-permissions.js +177 -0
  84. package/static/v3/js/views/admin-policies.3658fd86.js +102 -0
  85. package/static/v3/js/views/admin-policies.js +102 -0
  86. package/static/v3/js/views/admin-private-vpc.7d342d36.js +135 -0
  87. package/static/v3/js/views/admin-private-vpc.js +135 -0
  88. package/static/v3/js/views/admin-security.07c66b72.js +180 -0
  89. package/static/v3/js/views/admin-security.js +180 -0
  90. package/static/v3/js/views/admin-users.03bac88c.js +168 -0
  91. package/static/v3/js/views/admin-users.js +168 -0
  92. package/static/v3/js/views/agents.14e48bdd.js +193 -0
  93. package/static/v3/js/views/agents.js +193 -0
  94. package/static/v3/js/views/chat.718144ce.js +449 -0
  95. package/static/v3/js/views/chat.js +449 -0
  96. package/static/v3/js/views/files.4935197e.js +186 -0
  97. package/static/v3/js/views/files.js +186 -0
  98. package/static/v3/js/views/home.cdde3b32.js +119 -0
  99. package/static/v3/js/views/home.js +119 -0
  100. package/static/v3/js/views/hybrid-search.b22b97e0.js +195 -0
  101. package/static/v3/js/views/hybrid-search.js +195 -0
  102. package/static/v3/js/views/knowledge-graph.a14ea7e7.js +237 -0
  103. package/static/v3/js/views/knowledge-graph.js +237 -0
  104. package/static/v3/js/views/models.a1ffa147.js +256 -0
  105. package/static/v3/js/views/models.js +256 -0
  106. package/static/v3/js/views/my-computer.1b2ff621.js +237 -0
  107. package/static/v3/js/views/my-computer.js +237 -0
  108. package/static/v3/js/views/pipeline.c522f1ce.js +157 -0
  109. package/static/v3/js/views/pipeline.js +157 -0
  110. package/static/v3/js/views/settings.4f777210.js +250 -0
  111. package/static/v3/js/views/settings.js +250 -0
  112. package/static/workflows.html +4 -4
  113. package/static/workspace.css +340 -2
  114. package/static/workspace.html +43 -24
  115. package/docs/images/tmp_frames/frame_00.png +0 -0
  116. package/docs/images/tmp_frames/frame_01.png +0 -0
  117. package/docs/images/tmp_frames/frame_02.png +0 -0
  118. package/docs/images/tmp_frames/frame_03.png +0 -0
  119. package/docs/images/tmp_frames/hero_00.png +0 -0
  120. package/docs/images/tmp_frames/hero_01.png +0 -0
  121. package/docs/images/tmp_frames/hero_02.png +0 -0
  122. package/docs/images/tmp_frames/hero_03.png +0 -0
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "2.2.7"
3
+ __version__ = "3.1.0"
@@ -109,6 +109,53 @@ def create_admin_router(
109
109
  report["graph"] = {"error": str(e)}
110
110
  return report
111
111
 
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
+ @router.get("/admin/roles")
123
+ async def admin_roles(request: Request):
124
+ _, users = require_admin(request)
125
+ counts: Dict[str, int] = defaultdict(int)
126
+ for email, user in users.items():
127
+ role = (get_user_role(email, users) or "user").lower()
128
+ 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))
133
+ 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))
136
+ ]
137
+ return {"roles": roles}
138
+
139
+ @router.get("/admin/policies")
140
+ async def admin_policies(request: Request):
141
+ require_admin(request)
142
+ # The real, enforced governance posture of a local-first deployment.
143
+ return {
144
+ "policies": [
145
+ {"id": "local_file_access", "label": "Local file access",
146
+ "value": "Approval-token gated (per path/user/action)", "enforced": True},
147
+ {"id": "package_install", "label": "Package install",
148
+ "value": "Admin-only with audit trail", "enforced": True},
149
+ {"id": "data_residency", "label": "Data residency",
150
+ "value": "Single-tenant local storage (~/.ltcai)", "enforced": True},
151
+ {"id": "model_egress", "label": "Model egress",
152
+ "value": "Local-only by default (no external inference in local mode)", "enforced": True},
153
+ {"id": "invite_gate", "label": "Invite gate",
154
+ "value": "Required for new accounts" if invite_gate_enabled else "Open registration",
155
+ "enforced": bool(invite_gate_enabled)},
156
+ ]
157
+ }
158
+
112
159
  @router.get("/vpc/status")
113
160
  async def vpc_status(request: Request):
114
161
  require_user(request)
@@ -40,11 +40,54 @@ def create_agents_router(
40
40
  append_audit_event: Callable[..., None],
41
41
  ui_file_response: Optional[Callable[[Path], Any]] = None,
42
42
  static_dir: Optional[Path] = None,
43
+ agent_runtime: Any = None,
43
44
  ) -> APIRouter:
44
45
  from latticeai.core.multi_agent import AGENT_ROLES, ROLE_AGENT_IDS
46
+ from latticeai.services.agent_runtime import AgentRuntime
47
+
48
+ # Single AgentRuntime boundary: the router (and via it, the frontend) talks
49
+ # to this façade instead of reaching into the orchestrator/store directly.
50
+ runtime = agent_runtime or AgentRuntime(
51
+ store=store,
52
+ orchestrator_factory=orchestrator_factory,
53
+ workspace_graph=workspace_graph,
54
+ append_audit_event=append_audit_event,
55
+ )
45
56
 
46
57
  router = APIRouter()
47
58
 
59
+ # ── AgentRuntime boundary endpoints ───────────────────────────────────
60
+ @router.get("/agents/api/runtime/status")
61
+ async def agent_runtime_status(request: Request):
62
+ require_user(request)
63
+ scope = gate_read(request)
64
+ return runtime.status(scope=scope)
65
+
66
+ @router.get("/agents/api/runtime/health")
67
+ async def agent_runtime_health(request: Request):
68
+ require_user(request)
69
+ return runtime.health()
70
+
71
+ @router.get("/agents/api/runtime/config")
72
+ async def agent_runtime_config(request: Request):
73
+ require_user(request)
74
+ return runtime.config()
75
+
76
+ @router.get("/agents/api/runs/{run_id}/events")
77
+ async def agent_run_events(run_id: str, request: Request):
78
+ require_user(request)
79
+ scope = gate_read(request)
80
+ try:
81
+ return runtime.events(run_id, scope=scope)
82
+ except FileNotFoundError as exc:
83
+ raise HTTPException(status_code=404, detail=f"Agent run not found: {run_id}") from exc
84
+
85
+ @router.post("/agents/api/runs/{run_id}/stop")
86
+ async def agent_run_stop(run_id: str, request: Request):
87
+ require_user(request)
88
+ scope = gate_write(request)
89
+ return runtime.stop(run_id, scope=scope)
90
+
48
91
  @router.get("/agents")
49
92
  async def agents_page(request: Request):
50
93
  require_user(request)
@@ -119,36 +162,16 @@ def create_agents_router(
119
162
  async def agent_run(req: AgentRunRequest, request: Request):
120
163
  current_user = require_user(request)
121
164
  scope = gate_write(request)
122
- if not str(req.goal or "").strip():
123
- raise HTTPException(status_code=400, detail="goal is required")
124
- orchestrator = orchestrator_factory(current_user or None, scope)
125
- result = orchestrator.run(
126
- req.goal,
127
- user_email=current_user or None,
128
- workspace_id=scope,
129
- inputs=req.inputs,
130
- roles=req.roles or None,
131
- max_retries=max(0, min(int(req.max_retries or 0), 5)),
132
- )
133
- run = store.record_agent_run(
134
- agent_id=result.agent_id,
135
- status=result.status,
136
- input_text=req.goal,
137
- output_text=result.output,
138
- timeline=result.timeline,
139
- relationships=[ROLE_AGENT_IDS.get(r, f"agent:{r}") for r in result.roles_run],
140
- handoffs=result.handoffs,
141
- context_packets=result.context_packets,
142
- plan=result.plan,
143
- plan_review=result.plan_review,
144
- review_history=result.review_history,
145
- retry_history=result.retry_history,
146
- memory_snapshots=result.memory_snapshots,
147
- user_email=current_user or None,
148
- graph=workspace_graph(),
149
- workspace_id=scope,
150
- )
151
- append_audit_event("multi_agent_run", user_email=current_user, agent_id=result.agent_id, status=result.status, retries=result.retries)
152
- return {"run": run, "result": result.as_dict()}
165
+ try:
166
+ return runtime.start(
167
+ req.goal,
168
+ user_email=current_user or None,
169
+ scope=scope,
170
+ roles=req.roles or None,
171
+ inputs=req.inputs,
172
+ max_retries=req.max_retries,
173
+ )
174
+ except ValueError as exc:
175
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
153
176
 
154
177
  return router
@@ -57,6 +57,7 @@ def create_auth_router(
57
57
  public_sso_config: Callable[..., Dict],
58
58
  open_registration: bool,
59
59
  session_ttl: int,
60
+ require_auth: bool = True,
60
61
  ) -> APIRouter:
61
62
  router = APIRouter()
62
63
 
@@ -167,7 +168,7 @@ def create_auth_router(
167
168
  if users[email].get("disabled"):
168
169
  raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
169
170
  token = create_session(email)
170
- resp = RedirectResponse("/chat", status_code=302)
171
+ resp = RedirectResponse("/app", status_code=302)
171
172
  resp.set_cookie("session_token", token, httponly=True, samesite="lax", max_age=session_ttl)
172
173
  return resp
173
174
 
@@ -221,7 +222,9 @@ def create_auth_router(
221
222
  async def get_profile(request: Request):
222
223
  email = require_user(request)
223
224
  if not email:
224
- raise HTTPException(status_code=401, detail="인증이 필요합니다.")
225
+ if require_auth:
226
+ raise HTTPException(status_code=401, detail="인증이 필요합니다.")
227
+ return {"email": "", "name": "Local User", "nickname": "You", "role": "admin", "is_admin": True}
225
228
  users = load_users()
226
229
  user = users.get(email)
227
230
  if not user:
@@ -342,9 +342,17 @@ def create_chat_router(
342
342
 
343
343
  if not router.current_model_id:
344
344
  detail = "No model loaded. Call /models/load first."
345
- if IS_PUBLIC_MODE:
345
+ if CONFIG.is_public:
346
346
  detail = f"No public model loaded. Set OPENAI_API_KEY and LATTICEAI_PUBLIC_MODEL={PUBLIC_MODEL}, or call /models/load with an OpenAI-compatible model."
347
- raise HTTPException(status_code=400, detail=detail)
347
+ return JSONResponse(
348
+ status_code=400,
349
+ content={
350
+ "error": "no_model_loaded",
351
+ "detail": detail,
352
+ "message": detail,
353
+ "action": "load_model",
354
+ },
355
+ )
348
356
 
349
357
  if req.model and req.model != router.current_model_id:
350
358
  if req.model not in router.loaded_model_ids:
@@ -0,0 +1,240 @@
1
+ """v3 knowledge graph, vector, and hybrid search API contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, Dict, Optional
6
+
7
+ from fastapi import APIRouter, HTTPException, Request
8
+ from pydantic import BaseModel, Field
9
+
10
+ from latticeai.core.embedding_providers import embedding_provider_profiles
11
+ from latticeai.services.search_service import DEFAULT_HYBRID_WEIGHTS, SearchService
12
+
13
+
14
+ class SearchRequest(BaseModel):
15
+ query: str = Field(..., min_length=1)
16
+ limit: int = 30
17
+
18
+
19
+ class VectorSearchRequest(SearchRequest):
20
+ min_score: float = 0.0
21
+
22
+
23
+ class HybridSearchRequest(SearchRequest):
24
+ keyword_limit: int = 30
25
+ vector_limit: int = 30
26
+ graph_limit: int = 30
27
+ weights: Optional[Dict[str, float]] = None
28
+
29
+
30
+ class GraphNodeRequest(BaseModel):
31
+ node_id: str = Field(..., min_length=1)
32
+ include_neighbors: bool = True
33
+ depth: int = 1
34
+ limit: int = 100
35
+
36
+
37
+ class RelationshipSearchRequest(BaseModel):
38
+ query: str = ""
39
+ node_id: str = ""
40
+ relationship_type: str = ""
41
+ limit: int = 30
42
+
43
+
44
+ class IndexRebuildRequest(BaseModel):
45
+ full: bool = False
46
+ include_nodes: bool = True
47
+ include_chunks: bool = True
48
+
49
+
50
+ def create_search_router(
51
+ *,
52
+ service: SearchService,
53
+ require_user: Callable[[Request], str],
54
+ embedding_info: Optional[Callable[[], Dict[str, Any]]] = None,
55
+ ) -> APIRouter:
56
+ router = APIRouter()
57
+
58
+ def _guarded(request: Request) -> SearchService:
59
+ require_user(request)
60
+ return service
61
+
62
+ def _raise_graph_error(exc: Exception) -> None:
63
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
64
+
65
+ @router.post("/api/search/hybrid")
66
+ async def hybrid_search(req: HybridSearchRequest, request: Request) -> Dict[str, Any]:
67
+ try:
68
+ return _guarded(request).hybrid_search(
69
+ req.query,
70
+ limit=req.limit,
71
+ keyword_limit=req.keyword_limit,
72
+ vector_limit=req.vector_limit,
73
+ graph_limit=req.graph_limit,
74
+ weights=req.weights or DEFAULT_HYBRID_WEIGHTS,
75
+ )
76
+ except ValueError as exc:
77
+ _raise_graph_error(exc)
78
+
79
+ @router.get("/api/search/hybrid")
80
+ async def hybrid_search_get(q: str, request: Request, limit: int = 30) -> Dict[str, Any]:
81
+ try:
82
+ return _guarded(request).hybrid_search(q, limit=limit)
83
+ except ValueError as exc:
84
+ _raise_graph_error(exc)
85
+
86
+ @router.post("/api/search/keyword")
87
+ async def keyword_search(req: SearchRequest, request: Request) -> Dict[str, Any]:
88
+ try:
89
+ return _guarded(request).keyword_search(req.query, limit=req.limit)
90
+ except ValueError as exc:
91
+ _raise_graph_error(exc)
92
+
93
+ @router.get("/api/search/keyword")
94
+ async def keyword_search_get(q: str, request: Request, limit: int = 30) -> Dict[str, Any]:
95
+ try:
96
+ return _guarded(request).keyword_search(q, limit=limit)
97
+ except ValueError as exc:
98
+ _raise_graph_error(exc)
99
+
100
+ @router.post("/api/search/vector")
101
+ async def vector_search(req: VectorSearchRequest, request: Request) -> Dict[str, Any]:
102
+ try:
103
+ return _guarded(request).vector_search(req.query, limit=req.limit, min_score=req.min_score)
104
+ except ValueError as exc:
105
+ _raise_graph_error(exc)
106
+
107
+ @router.get("/api/search/vector")
108
+ async def vector_search_get(
109
+ q: str,
110
+ request: Request,
111
+ limit: int = 30,
112
+ min_score: float = 0.0,
113
+ ) -> Dict[str, Any]:
114
+ try:
115
+ return _guarded(request).vector_search(q, limit=limit, min_score=min_score)
116
+ except ValueError as exc:
117
+ _raise_graph_error(exc)
118
+
119
+ @router.get("/api/graph")
120
+ async def graph(request: Request, limit: int = 300) -> Dict[str, Any]:
121
+ try:
122
+ return _guarded(request).graph(limit=limit)
123
+ except ValueError as exc:
124
+ _raise_graph_error(exc)
125
+
126
+ @router.get("/api/graph/node")
127
+ async def graph_node(
128
+ node_id: str,
129
+ request: Request,
130
+ include_neighbors: bool = True,
131
+ depth: int = 1,
132
+ limit: int = 100,
133
+ ) -> Dict[str, Any]:
134
+ try:
135
+ return _guarded(request).node(
136
+ node_id,
137
+ include_neighbors=include_neighbors,
138
+ depth=depth,
139
+ limit=limit,
140
+ )
141
+ except ValueError as exc:
142
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
143
+
144
+ @router.post("/api/graph/node")
145
+ async def graph_node_post(req: GraphNodeRequest, request: Request) -> Dict[str, Any]:
146
+ try:
147
+ return _guarded(request).node(
148
+ req.node_id,
149
+ include_neighbors=req.include_neighbors,
150
+ depth=req.depth,
151
+ limit=req.limit,
152
+ )
153
+ except ValueError as exc:
154
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
155
+
156
+ @router.get("/api/graph/relationship")
157
+ async def graph_relationship(
158
+ request: Request,
159
+ q: str = "",
160
+ node_id: str = "",
161
+ relationship_type: str = "",
162
+ limit: int = 30,
163
+ ) -> Dict[str, Any]:
164
+ try:
165
+ return _guarded(request).relationships(
166
+ query=q,
167
+ node_id=node_id,
168
+ relationship_type=relationship_type,
169
+ limit=limit,
170
+ )
171
+ except ValueError as exc:
172
+ _raise_graph_error(exc)
173
+
174
+ @router.post("/api/graph/relationship")
175
+ async def graph_relationship_post(req: RelationshipSearchRequest, request: Request) -> Dict[str, Any]:
176
+ try:
177
+ return _guarded(request).relationships(
178
+ query=req.query,
179
+ node_id=req.node_id,
180
+ relationship_type=req.relationship_type,
181
+ limit=req.limit,
182
+ )
183
+ except ValueError as exc:
184
+ _raise_graph_error(exc)
185
+
186
+ @router.get("/api/index/status")
187
+ async def index_status(request: Request) -> Dict[str, Any]:
188
+ try:
189
+ return _guarded(request).index_status()
190
+ except ValueError as exc:
191
+ _raise_graph_error(exc)
192
+
193
+ @router.post("/api/index/rebuild")
194
+ async def index_rebuild(req: IndexRebuildRequest, request: Request) -> Dict[str, Any]:
195
+ try:
196
+ return _guarded(request).rebuild_index(
197
+ full=req.full,
198
+ include_nodes=req.include_nodes,
199
+ include_chunks=req.include_chunks,
200
+ )
201
+ except ValueError as exc:
202
+ _raise_graph_error(exc)
203
+
204
+ @router.get("/api/embeddings/status")
205
+ async def embeddings_status(request: Request, refresh: bool = False) -> Dict[str, Any]:
206
+ require_user(request)
207
+ resolved = embedding_info() if embedding_info else {}
208
+ try:
209
+ return service.embeddings_status(resolved=resolved, refresh=refresh)
210
+ except ValueError as exc:
211
+ _raise_graph_error(exc)
212
+
213
+ @router.get("/api/embeddings/providers")
214
+ async def embeddings_providers(request: Request) -> Dict[str, Any]:
215
+ require_user(request)
216
+ resolved = embedding_info() if embedding_info else {}
217
+ profiles = resolved.get("profiles") or embedding_provider_profiles()
218
+ return {
219
+ "active": resolved.get("active_provider"),
220
+ "requested": resolved.get("requested_provider"),
221
+ "profile": resolved.get("profile") or "",
222
+ "profiles": profiles,
223
+ "providers": [
224
+ {"id": "hash", "label": "Local hash (fallback)", "grade": "fallback",
225
+ "requires": [], "detail": "Deterministic offline vectors — always available."},
226
+ {"id": "mlx", "label": "MLX (Apple Silicon)", "grade": "production",
227
+ "requires": ["LATTICEAI_EMBEDDING_MODEL"], "detail": "Local embedding model via MLX."},
228
+ {"id": "ollama", "label": "Ollama", "grade": "production",
229
+ "requires": ["LATTICEAI_EMBEDDING_MODEL", "LATTICEAI_EMBEDDING_BASE_URL"],
230
+ "detail": "Local/remote Ollama embedding server."},
231
+ {"id": "openai", "label": "OpenAI-compatible", "grade": "production",
232
+ "requires": ["LATTICEAI_EMBEDDING_MODEL", "LATTICEAI_EMBEDDING_BASE_URL", "LATTICEAI_EMBEDDING_API_KEY"],
233
+ "detail": "Any /v1/embeddings endpoint (OpenAI, LM Studio, vLLM, …)."},
234
+ {"id": "custom", "label": "Custom callable", "grade": "production",
235
+ "requires": ["LATTICEAI_EMBEDDING_CUSTOM_TARGET"],
236
+ "detail": "User-supplied module:callable returning vectors."},
237
+ ],
238
+ }
239
+
240
+ return router
@@ -107,8 +107,17 @@ def create_static_routes_router(
107
107
  @api_router.get("/chat")
108
108
  async def chat_page(request: Request):
109
109
  return ui_file_response(STATIC_DIR / "chat.html")
110
-
111
-
110
+
111
+
112
+ @api_router.get("/app")
113
+ async def app_shell(request: Request):
114
+ """v3 single-page workspace shell (token-native design system)."""
115
+ page = STATIC_DIR / "v3" / "index.html"
116
+ if not page.exists():
117
+ raise HTTPException(status_code=404, detail="v3 shell not found.")
118
+ return ui_file_response(page)
119
+
120
+
112
121
  @api_router.get("/admin")
113
122
  async def admin_page():
114
123
  admin_path = STATIC_DIR / "admin.html"
@@ -93,6 +93,16 @@ class Config:
93
93
  local_draft_model: str
94
94
  auto_read_chat_paths: bool
95
95
 
96
+ # ── embeddings (retrieval vector signal) ────────────────────────
97
+ embedding_provider: str
98
+ embedding_profile: str
99
+ embedding_model: str
100
+ embedding_base_url: str
101
+ embedding_api_key: str
102
+ embedding_dim: int
103
+ embedding_timeout: int
104
+ embedding_custom_target: str
105
+
96
106
  # ── SSO / OIDC ──────────────────────────────────────────────────
97
107
  sso_discovery_url: str
98
108
  sso_client_id: str
@@ -164,6 +174,14 @@ class Config:
164
174
  local_model=local_model,
165
175
  local_draft_model=_value(env, "LATTICEAI_LOCAL_DRAFT_MODEL", ""),
166
176
  auto_read_chat_paths=_bool(env, "LATTICEAI_AUTO_READ_CHAT_PATHS", default=False),
177
+ embedding_provider=_value(env, "LATTICEAI_EMBEDDING_PROVIDER", "hash").strip().lower(),
178
+ embedding_profile=_value(env, "LATTICEAI_EMBEDDING_PROFILE", "").strip().lower(),
179
+ embedding_model=_value(env, "LATTICEAI_EMBEDDING_MODEL", ""),
180
+ embedding_base_url=_value(env, "LATTICEAI_EMBEDDING_BASE_URL", ""),
181
+ embedding_api_key=_value(env, "LATTICEAI_EMBEDDING_API_KEY", ""),
182
+ embedding_dim=_int(env, "LATTICEAI_VECTOR_DIM", 0),
183
+ embedding_timeout=_int(env, "LATTICEAI_EMBEDDING_TIMEOUT", 30),
184
+ embedding_custom_target=_value(env, "LATTICEAI_EMBEDDING_CUSTOM_TARGET", ""),
167
185
  sso_discovery_url=_value(env, "OIDC_DISCOVERY_URL", ""),
168
186
  sso_client_id=_value(env, "OIDC_CLIENT_ID", ""),
169
187
  sso_client_secret=_value(env, "OIDC_CLIENT_SECRET", ""),