ltcai 10.7.0 → 10.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -40
- package/docs/CHANGELOG.md +68 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/retrieval_vector.py +69 -33
- package/lattice_brain/runtime/multi_agent.py +96 -15
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +9 -6
- package/latticeai/api/auth.py +37 -38
- package/latticeai/api/browser.py +7 -6
- package/latticeai/api/chat.py +9 -6
- package/latticeai/api/chat_agent_http.py +2 -1
- package/latticeai/api/chat_history.py +4 -2
- package/latticeai/api/chat_intents.py +14 -14
- package/latticeai/api/knowledge_graph.py +5 -4
- package/latticeai/api/local_files.py +25 -15
- package/latticeai/api/mcp.py +15 -11
- package/latticeai/api/memory.py +2 -1
- package/latticeai/api/models.py +39 -24
- package/latticeai/api/network_boundary.py +9 -4
- package/latticeai/api/portability.py +27 -19
- package/latticeai/api/project_sessions.py +7 -5
- package/latticeai/api/review_queue.py +12 -7
- package/latticeai/api/setup.py +10 -11
- package/latticeai/api/static_routes.py +62 -46
- package/latticeai/api/tools.py +7 -6
- package/latticeai/core/file_generation.py +141 -8
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/mcp_registry.py +20 -4
- package/latticeai/core/messages.py +452 -0
- package/latticeai/core/workspace_computer_memory.py +84 -0
- package/latticeai/core/workspace_indexing.py +102 -0
- package/latticeai/core/workspace_onboarding.py +104 -0
- package/latticeai/core/workspace_os.py +24 -207
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_relationships.py +99 -0
- package/latticeai/integrations/telegram_bot.py +20 -18
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/model_loading.py +63 -27
- package/latticeai/services/product_readiness.py +1 -1
- package/package.json +3 -2
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_screenshot_pixel_delta.py +92 -10
- package/scripts/check_server_i18n.mjs +96 -0
- package/scripts/release_screen_claims.json +46 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -37
- package/static/app/assets/{Act-DIbkoJqs.js → Act-CS9IeqUX.js} +1 -1
- package/static/app/assets/{AdminConsole-Cbi6Jre6.js → AdminConsole-3UkIEWGA.js} +1 -1
- package/static/app/assets/{Brain-BPfuXZ71.js → Brain-B22EmNqS.js} +1 -1
- package/static/app/assets/{BrainHome-87XaAS4V.js → BrainHome-CMDqgJF4.js} +2 -2
- package/static/app/assets/{BrainSignals-ExpcdiUi.js → BrainSignals-BeE8RJo3.js} +1 -1
- package/static/app/assets/{Capture-Bxe4WiVX.js → Capture-ZX9bQh68.js} +1 -1
- package/static/app/assets/{CommandPalette-B1PV5Yd4.js → CommandPalette-86m4FCcN.js} +1 -1
- package/static/app/assets/{Library-BRlJJmwZ.js → Library-Bhz5LUca.js} +1 -1
- package/static/app/assets/{LivingBrain-D61qG3WY.js → LivingBrain-BSa0wpFG.js} +1 -1
- package/static/app/assets/ProductFlow-IP4Q-5aQ.js +1 -0
- package/static/app/assets/{ReviewCard-BoiWv0Y1.js → ReviewCard-BepjSDpN.js} +1 -1
- package/static/app/assets/{System-Ci0HEtTJ.js → System-Bwx1h_jT.js} +1 -1
- package/static/app/assets/arrow-left-ig7AZU8B.js +1 -0
- package/static/app/assets/{bot-BaPt4PPn.js → bot-DQj0-LkM.js} +1 -1
- package/static/app/assets/{brain-BSkWaj3i.js → brain-D8OEwmVj.js} +1 -1
- package/static/app/assets/{button-CKwtO1O0.js → button-CmTknyAP.js} +1 -1
- package/static/app/assets/{circle-pause-CiUc5RRk.js → circle-pause-yTCWRziJ.js} +1 -1
- package/static/app/assets/{circle-play-BaTDxzR9.js → circle-play-Ccrva84R.js} +1 -1
- package/static/app/assets/{cpu-Dm1gHuP1.js → cpu-CbJqWTlS.js} +1 -1
- package/static/app/assets/{download-C32oGU-C.js → download-CkSzbzU-.js} +1 -1
- package/static/app/assets/{folder-open-Ch2jLVdD.js → folder-open-CKyjQ4PU.js} +1 -1
- package/static/app/assets/{hard-drive-BBjA5st8.js → hard-drive-DAzk9um0.js} +1 -1
- package/static/app/assets/index-BfD-jhA9.css +2 -0
- package/static/app/assets/{index-J6h01X78.js → index-CxOcwsHV.js} +3 -3
- package/static/app/assets/{input-y6ABdKWY.js → input-DcMETmZ7.js} +1 -1
- package/static/app/assets/{permissionCopy-BEtc0Ihd.js → permissionCopy-BVf13_25.js} +1 -1
- package/static/app/assets/{primitives-GSFXF2Pd.js → primitives-CQV9Q2YM.js} +1 -1
- package/static/app/assets/search-CXGASMMH.js +1 -0
- package/static/app/assets/{share-2-BgGq2A63.js → share-2-COWCHNZm.js} +1 -1
- package/static/app/assets/{shield-alert-BYA491cj.js → shield-alert-BDrvilyK.js} +1 -1
- package/static/app/assets/{textarea-BAcim3fs.js → textarea-rUmsc8cP.js} +1 -1
- package/static/app/assets/{useFocusTrap-qLg24jiv.js → useFocusTrap-Bi5UY_8v.js} +1 -1
- package/static/app/assets/{useQuery-xkAu9eDX.js → useQuery-C-AicB-3.js} +1 -1
- package/static/app/assets/{utils-CogdofAA.js → utils-BMwWg78e.js} +1 -1
- package/static/app/assets/{workspace-BMvfx9_R.js → workspace-Y93tls8P.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/ProductFlow-BxjxI8Aa.js +0 -1
- package/static/app/assets/arrow-left-DArdBUxd.js +0 -1
- package/static/app/assets/index-BV7Yf6Eq.css +0 -2
- package/static/app/assets/search-CaIZKyo_.js +0 -1
package/latticeai/api/auth.py
CHANGED
|
@@ -9,10 +9,11 @@ from dataclasses import dataclass
|
|
|
9
9
|
from typing import Any, Awaitable, Callable, Dict, Optional
|
|
10
10
|
from urllib.parse import urlencode
|
|
11
11
|
|
|
12
|
-
from fastapi import APIRouter,
|
|
12
|
+
from fastapi import APIRouter, Request
|
|
13
13
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
14
14
|
from pydantic import BaseModel
|
|
15
15
|
|
|
16
|
+
from latticeai.core.messages import DEFAULT_LANGUAGE, http_error, resolve_language
|
|
16
17
|
from latticeai.core.oidc import (
|
|
17
18
|
OIDCValidationError,
|
|
18
19
|
)
|
|
@@ -92,18 +93,16 @@ def create_auth_router(
|
|
|
92
93
|
) -> APIRouter:
|
|
93
94
|
router = APIRouter()
|
|
94
95
|
|
|
95
|
-
def _enforce_password_policy(password: str) -> None:
|
|
96
|
+
def _enforce_password_policy(password: str, language: str = DEFAULT_LANGUAGE) -> None:
|
|
96
97
|
# Real policy (v4): length >= 8 with letters AND digits. A 4-char
|
|
97
98
|
# minimum was not a policy.
|
|
98
99
|
pw = str(password or "")
|
|
99
100
|
if len(pw) < 8 or not any(c.isalpha() for c in pw) or not any(c.isdigit() for c in pw):
|
|
100
|
-
raise
|
|
101
|
-
status_code=400,
|
|
102
|
-
detail="비밀번호는 8자 이상이며 영문자와 숫자를 모두 포함해야 합니다.",
|
|
103
|
-
)
|
|
101
|
+
raise http_error(400, "auth.password_too_weak", language)
|
|
104
102
|
|
|
105
103
|
@router.post("/register")
|
|
106
104
|
async def register(req: UserRegister, request: Request):
|
|
105
|
+
lang = resolve_language(request)
|
|
107
106
|
check_ip_rate_limit(client_ip(request), "register", max_calls=5, window_secs=3600)
|
|
108
107
|
invite_claim = bool(
|
|
109
108
|
invite_gate_enabled
|
|
@@ -111,19 +110,16 @@ def create_auth_router(
|
|
|
111
110
|
and invite_authorized(request)
|
|
112
111
|
)
|
|
113
112
|
if invite_gate_enabled and not invite_claim:
|
|
114
|
-
raise
|
|
115
|
-
status_code=403,
|
|
116
|
-
detail="유효한 서명 초대 권한이 필요합니다.",
|
|
117
|
-
)
|
|
113
|
+
raise http_error(403, "auth.invitation_required", lang)
|
|
118
114
|
# Closed public registration stays closed unless the operator enabled
|
|
119
115
|
# the invite gate and this exact request carries a valid signed claim.
|
|
120
116
|
if not open_registration and not invite_claim:
|
|
121
|
-
raise
|
|
122
|
-
_enforce_password_policy(req.password)
|
|
117
|
+
raise http_error(403, "auth.registration_disabled", lang)
|
|
118
|
+
_enforce_password_policy(req.password, lang)
|
|
123
119
|
email = normalize_email(req.email)
|
|
124
120
|
users = load_users()
|
|
125
121
|
if email in users:
|
|
126
|
-
raise
|
|
122
|
+
raise http_error(400, "auth.email_taken", lang)
|
|
127
123
|
role = "admin" if not users else "user"
|
|
128
124
|
users[email] = {
|
|
129
125
|
"password": hash_password(req.password),
|
|
@@ -140,14 +136,15 @@ def create_auth_router(
|
|
|
140
136
|
|
|
141
137
|
@router.post("/login")
|
|
142
138
|
async def login(req: UserLogin, request: Request):
|
|
139
|
+
lang = resolve_language(request)
|
|
143
140
|
check_ip_rate_limit(client_ip(request), "login", max_calls=10, window_secs=300)
|
|
144
141
|
email = normalize_email(req.email)
|
|
145
142
|
users = load_users()
|
|
146
143
|
user = users.get(email)
|
|
147
144
|
if not user or not verify_and_migrate(email, req.password, user.get("password", ""), users):
|
|
148
|
-
raise
|
|
145
|
+
raise http_error(401, "auth.bad_credentials", lang)
|
|
149
146
|
if user.get("disabled"):
|
|
150
|
-
raise
|
|
147
|
+
raise http_error(403, "auth.account_disabled", lang)
|
|
151
148
|
role = get_user_role(email, users)
|
|
152
149
|
token = create_session(email)
|
|
153
150
|
response = JSONResponse(content={
|
|
@@ -174,10 +171,11 @@ def create_auth_router(
|
|
|
174
171
|
|
|
175
172
|
@router.get("/auth/sso/login")
|
|
176
173
|
async def sso_login(request: Request):
|
|
174
|
+
lang = resolve_language(request)
|
|
177
175
|
settings = get_sso_settings()
|
|
178
176
|
discovery = await get_sso_discovery()
|
|
179
177
|
if not settings.get("enabled") or not discovery:
|
|
180
|
-
raise
|
|
178
|
+
raise http_error(503, "sso.not_configured", lang)
|
|
181
179
|
state = secrets.token_urlsafe(16)
|
|
182
180
|
nonce = secrets.token_urlsafe(16)
|
|
183
181
|
# PKCE (S256): bind the token exchange to this login, so an
|
|
@@ -214,16 +212,17 @@ def create_auth_router(
|
|
|
214
212
|
return RedirectResponse(f"{discovery['authorization_endpoint']}?{params}")
|
|
215
213
|
|
|
216
214
|
@router.get("/auth/sso/callback")
|
|
217
|
-
async def sso_callback(code: str = "", state: str = "", error: str = ""):
|
|
215
|
+
async def sso_callback(request: Request, code: str = "", state: str = "", error: str = ""):
|
|
216
|
+
lang = resolve_language(request)
|
|
218
217
|
entry = _sso_states.pop(state, None)
|
|
219
218
|
if entry is None or time.time() - entry.issued_at > 300:
|
|
220
|
-
raise
|
|
219
|
+
raise http_error(400, "sso.invalid_state", lang)
|
|
221
220
|
if error:
|
|
222
221
|
return RedirectResponse(f"/?{urlencode({'sso_error': error})}")
|
|
223
222
|
settings = get_sso_settings()
|
|
224
223
|
discovery = await get_sso_discovery()
|
|
225
224
|
if not settings.get("enabled") or not discovery:
|
|
226
|
-
raise
|
|
225
|
+
raise http_error(503, "sso.config_error", lang)
|
|
227
226
|
import httpx as _httpx
|
|
228
227
|
async with _httpx.AsyncClient() as c:
|
|
229
228
|
r = await c.post(discovery["token_endpoint"], data={
|
|
@@ -237,7 +236,7 @@ def create_auth_router(
|
|
|
237
236
|
tokens = r.json()
|
|
238
237
|
id_token = tokens.get("id_token")
|
|
239
238
|
if not id_token:
|
|
240
|
-
raise
|
|
239
|
+
raise http_error(400, "sso.no_id_token", lang)
|
|
241
240
|
# Never trust a decoded JWT payload: verify signature (against the
|
|
242
241
|
# provider JWKS), issuer, audience, expiry and the login nonce before
|
|
243
242
|
# using any claim. Any failure is fail-closed (401).
|
|
@@ -253,20 +252,17 @@ def create_auth_router(
|
|
|
253
252
|
)
|
|
254
253
|
except OIDCValidationError as exc:
|
|
255
254
|
logging.warning("SSO ID token rejected: %s", exc)
|
|
256
|
-
raise
|
|
255
|
+
raise http_error(401, "sso.token_verification_failed", lang)
|
|
257
256
|
except Exception as exc: # discovery/JWKS fetch failure → fail closed
|
|
258
257
|
logging.warning("SSO token validation error: %s", exc)
|
|
259
|
-
raise
|
|
258
|
+
raise http_error(502, "sso.provider_verification_failed", lang)
|
|
260
259
|
email = normalize_email(payload.get("email") or payload.get("preferred_username") or payload.get("upn") or "")
|
|
261
260
|
if not email:
|
|
262
|
-
raise
|
|
261
|
+
raise http_error(400, "sso.email_unavailable", lang)
|
|
263
262
|
users = load_users()
|
|
264
263
|
if email not in users:
|
|
265
264
|
if invite_gate_enabled and not entry.invite_authorized:
|
|
266
|
-
raise
|
|
267
|
-
status_code=403,
|
|
268
|
-
detail="신규 SSO 계정에는 유효한 서명 초대 권한이 필요합니다.",
|
|
269
|
-
)
|
|
265
|
+
raise http_error(403, "sso.invitation_required", lang)
|
|
270
266
|
is_first = len(users) == 0
|
|
271
267
|
users[email] = {
|
|
272
268
|
"password": "",
|
|
@@ -280,7 +276,7 @@ def create_auth_router(
|
|
|
280
276
|
ensure_identity(email, users[email])
|
|
281
277
|
save_users(users)
|
|
282
278
|
if users[email].get("disabled"):
|
|
283
|
-
raise
|
|
279
|
+
raise http_error(403, "auth.account_disabled", lang)
|
|
284
280
|
token = create_session(email)
|
|
285
281
|
resp = RedirectResponse("/app", status_code=302)
|
|
286
282
|
resp.set_cookie(
|
|
@@ -309,33 +305,35 @@ def create_auth_router(
|
|
|
309
305
|
|
|
310
306
|
@router.post("/account/change-password")
|
|
311
307
|
async def change_password(req: ChangePasswordRequest, request: Request):
|
|
308
|
+
lang = resolve_language(request)
|
|
312
309
|
email = normalize_email(require_user(request))
|
|
313
310
|
if not email:
|
|
314
|
-
raise
|
|
315
|
-
_enforce_password_policy(req.new_password)
|
|
311
|
+
raise http_error(401, "auth.login_required", lang)
|
|
312
|
+
_enforce_password_policy(req.new_password, lang)
|
|
316
313
|
users = load_users()
|
|
317
314
|
user = users.get(email)
|
|
318
315
|
if not user:
|
|
319
|
-
raise
|
|
316
|
+
raise http_error(404, "auth.user_not_found", lang)
|
|
320
317
|
if not verify_and_migrate(email, req.current_password, user.get("password", ""), users):
|
|
321
|
-
raise
|
|
318
|
+
raise http_error(401, "auth.current_password_wrong", lang)
|
|
322
319
|
users[email]["password"] = hash_password(req.new_password)
|
|
323
320
|
save_users(users)
|
|
324
321
|
return {"status": "ok", "message": "비밀번호가 변경되었습니다."}
|
|
325
322
|
|
|
326
323
|
@router.patch("/account/profile")
|
|
327
324
|
async def update_profile(req: UpdateProfileRequest, request: Request):
|
|
325
|
+
lang = resolve_language(request)
|
|
328
326
|
email = normalize_email(require_user(request))
|
|
329
327
|
if not email:
|
|
330
|
-
raise
|
|
328
|
+
raise http_error(401, "auth.login_required", lang)
|
|
331
329
|
if req.name is not None and not req.name.strip():
|
|
332
|
-
raise
|
|
330
|
+
raise http_error(400, "auth.name_required", lang)
|
|
333
331
|
if req.nickname is not None and not req.nickname.strip():
|
|
334
|
-
raise
|
|
332
|
+
raise http_error(400, "auth.nickname_required", lang)
|
|
335
333
|
users = load_users()
|
|
336
334
|
user = users.get(email)
|
|
337
335
|
if not user:
|
|
338
|
-
raise
|
|
336
|
+
raise http_error(404, "auth.user_not_found", lang)
|
|
339
337
|
if req.name is not None:
|
|
340
338
|
users[email]["name"] = req.name.strip()
|
|
341
339
|
if req.nickname is not None:
|
|
@@ -345,15 +343,16 @@ def create_auth_router(
|
|
|
345
343
|
|
|
346
344
|
@router.get("/account/profile")
|
|
347
345
|
async def get_profile(request: Request):
|
|
346
|
+
lang = resolve_language(request)
|
|
348
347
|
email = normalize_email(require_user(request))
|
|
349
348
|
if not email:
|
|
350
349
|
if require_auth:
|
|
351
|
-
raise
|
|
350
|
+
raise http_error(401, "auth.login_required", lang)
|
|
352
351
|
return {"email": "", "name": "Local User", "nickname": "You", "role": "admin", "is_admin": True}
|
|
353
352
|
users = load_users()
|
|
354
353
|
user = users.get(email)
|
|
355
354
|
if not user:
|
|
356
|
-
raise
|
|
355
|
+
raise http_error(404, "auth.user_not_found", lang)
|
|
357
356
|
role = get_user_role(email, users)
|
|
358
357
|
return {"email": email, "name": user.get("name", ""), "nickname": user.get("nickname", ""),
|
|
359
358
|
"role": role, "is_admin": role == "admin"}
|
package/latticeai/api/browser.py
CHANGED
|
@@ -29,6 +29,7 @@ from pydantic import BaseModel
|
|
|
29
29
|
from lattice_brain.ingestion import IngestionItem, capture_quality_verdict
|
|
30
30
|
from latticeai import __version__
|
|
31
31
|
from latticeai.api.workspace_scope import resolve_workspace_scope
|
|
32
|
+
from latticeai.core.messages import DEFAULT_LANGUAGE, http_error, resolve_language
|
|
32
33
|
from latticeai.core.quiet import quiet
|
|
33
34
|
|
|
34
35
|
MAX_TAB_BYTES = 4 * 1024 * 1024 # 4 MB per captured tab payload
|
|
@@ -395,9 +396,9 @@ def create_browser_router(
|
|
|
395
396
|
router = APIRouter()
|
|
396
397
|
_fetch = fetch_url or _default_fetch_url
|
|
397
398
|
|
|
398
|
-
def _require_pipeline():
|
|
399
|
+
def _require_pipeline(language: str = DEFAULT_LANGUAGE):
|
|
399
400
|
if pipeline is None or not pipeline.available():
|
|
400
|
-
raise
|
|
401
|
+
raise http_error(503, "capture.ingestion_disabled", language)
|
|
401
402
|
|
|
402
403
|
def _write_workspace(request: Request, body_workspace: Optional[str], user: str) -> Optional[str]:
|
|
403
404
|
return resolve_workspace_scope(
|
|
@@ -412,7 +413,7 @@ def create_browser_router(
|
|
|
412
413
|
async def read_url(req: ReadUrlRequest, request: Request):
|
|
413
414
|
"""Fetch a public URL locally and ingest it as a web_url source."""
|
|
414
415
|
user = require_user(request)
|
|
415
|
-
_require_pipeline()
|
|
416
|
+
_require_pipeline(resolve_language(request))
|
|
416
417
|
workspace_id = _write_workspace(request, req.workspace_id, user)
|
|
417
418
|
url = _validate_http_url(req.url)
|
|
418
419
|
try:
|
|
@@ -443,7 +444,7 @@ def create_browser_router(
|
|
|
443
444
|
async def ingest_current_tab(req: IngestTabRequest, request: Request):
|
|
444
445
|
"""Ingest a payload captured from the local browser extension."""
|
|
445
446
|
user = require_user(request)
|
|
446
|
-
_require_pipeline()
|
|
447
|
+
_require_pipeline(resolve_language(request))
|
|
447
448
|
workspace_id = _write_workspace(request, req.workspace_id, user)
|
|
448
449
|
url = _validate_http_url(req.url)
|
|
449
450
|
# Bound the entire capture, not merely each field independently. The
|
|
@@ -462,14 +463,14 @@ def create_browser_router(
|
|
|
462
463
|
if value
|
|
463
464
|
)
|
|
464
465
|
if captured_bytes > max_tab_bytes:
|
|
465
|
-
raise
|
|
466
|
+
raise http_error(413, "capture.payload_too_large", resolve_language(request))
|
|
466
467
|
text = (req.text or "").strip()
|
|
467
468
|
if not text and req.html:
|
|
468
469
|
_title, text = extract_readable_text(req.html)
|
|
469
470
|
if not text:
|
|
470
471
|
text = (req.selected_text or "").strip()
|
|
471
472
|
if not text:
|
|
472
|
-
raise
|
|
473
|
+
raise http_error(400, "capture.nothing_to_capture", resolve_language(request))
|
|
473
474
|
res = pipeline.ingest(
|
|
474
475
|
IngestionItem(
|
|
475
476
|
source_type="browser_tab",
|
package/latticeai/api/chat.py
CHANGED
|
@@ -51,6 +51,7 @@ from latticeai.api.chat_hybrid import (
|
|
|
51
51
|
)
|
|
52
52
|
from latticeai.api.chat_intents import ChatIntentController
|
|
53
53
|
from latticeai.api.chat_stream import stream_chat
|
|
54
|
+
from latticeai.core.messages import DEFAULT_LANGUAGE, resolve_language, translate
|
|
54
55
|
from latticeai.core.project_sessions import ProjectSessionStore
|
|
55
56
|
from latticeai.core.run_store import AgentRunStore
|
|
56
57
|
from latticeai.services.app_context import AppContext
|
|
@@ -122,12 +123,13 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
122
123
|
def authenticated_identity(
|
|
123
124
|
current_user: str,
|
|
124
125
|
claimed_email: Optional[str],
|
|
126
|
+
language: str = DEFAULT_LANGUAGE,
|
|
125
127
|
) -> Optional[str]:
|
|
126
128
|
if current_user and claimed_email:
|
|
127
129
|
if current_user.strip().lower() != claimed_email.strip().lower():
|
|
128
130
|
raise HTTPException(
|
|
129
131
|
status_code=403,
|
|
130
|
-
detail="
|
|
132
|
+
detail=translate("common.user_mismatch", language),
|
|
131
133
|
)
|
|
132
134
|
return current_user or claimed_email or None
|
|
133
135
|
|
|
@@ -239,12 +241,12 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
239
241
|
notify=notify_chat_message,
|
|
240
242
|
)
|
|
241
243
|
|
|
242
|
-
def request_model(model_id: Optional[str]) -> Optional[str]:
|
|
244
|
+
def request_model(model_id: Optional[str], language: str = DEFAULT_LANGUAGE) -> Optional[str]:
|
|
243
245
|
selected = model_id or model_router.current_model_id
|
|
244
246
|
if model_id and model_id not in model_router.loaded_model_ids:
|
|
245
247
|
raise HTTPException(
|
|
246
248
|
status_code=404,
|
|
247
|
-
detail=
|
|
249
|
+
detail=translate("chat.model_not_loaded", language, model=model_id),
|
|
248
250
|
)
|
|
249
251
|
return selected
|
|
250
252
|
|
|
@@ -258,7 +260,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
258
260
|
len(req.image_data) if req.image_data else 0,
|
|
259
261
|
len(req.message or ""),
|
|
260
262
|
)
|
|
261
|
-
effective_email = authenticated_identity(current_user, req.user_email)
|
|
263
|
+
effective_email = authenticated_identity(current_user, req.user_email, resolve_language(request))
|
|
262
264
|
workspace_id = write_workspace(
|
|
263
265
|
workspace_scope_from_request(request),
|
|
264
266
|
current_user,
|
|
@@ -293,7 +295,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
293
295
|
history_user=history_user,
|
|
294
296
|
)
|
|
295
297
|
|
|
296
|
-
selected_model_id = request_model(req.model)
|
|
298
|
+
selected_model_id = request_model(req.model, resolve_language(request))
|
|
297
299
|
file_intent = is_file_action_request(req.message)
|
|
298
300
|
if file_intent and context.funnel_metrics is not None:
|
|
299
301
|
context.funnel_metrics.increment("file_requests")
|
|
@@ -303,11 +305,12 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
303
305
|
model_id=selected_model_id,
|
|
304
306
|
effective_email=effective_email,
|
|
305
307
|
workspace_id=workspace_id,
|
|
308
|
+
language=resolve_language(request),
|
|
306
309
|
)
|
|
307
310
|
if direct_response is not None:
|
|
308
311
|
return direct_response
|
|
309
312
|
if not selected_model_id:
|
|
310
|
-
return intent_controller.no_model_response()
|
|
313
|
+
return intent_controller.no_model_response(resolve_language(request))
|
|
311
314
|
if file_intent:
|
|
312
315
|
return await intent_controller.route_file_to_agent(
|
|
313
316
|
req,
|
|
@@ -29,6 +29,7 @@ from latticeai.api.chat_helpers import (
|
|
|
29
29
|
)
|
|
30
30
|
from latticeai.api.chat_stream import agent_live_stream
|
|
31
31
|
from latticeai.core.agent import AgentRunContext, AgentState, normalize_plan
|
|
32
|
+
from latticeai.core.messages import resolve_language
|
|
32
33
|
from latticeai.core.quiet import quiet
|
|
33
34
|
from latticeai.core.run_explain import explain_run
|
|
34
35
|
|
|
@@ -266,7 +267,7 @@ class AgentHTTPController:
|
|
|
266
267
|
"""
|
|
267
268
|
current_user = self.require_user(request)
|
|
268
269
|
self.enforce_rate_limit(current_user, "agent")
|
|
269
|
-
effective_email = self.authenticated_identity(current_user, req.user_email)
|
|
270
|
+
effective_email = self.authenticated_identity(current_user, req.user_email, resolve_language(request))
|
|
270
271
|
header_workspace = workspace_scope_from_request(request)
|
|
271
272
|
if req.workspace_id and header_workspace and req.workspace_id != header_workspace:
|
|
272
273
|
raise HTTPException(
|
|
@@ -9,7 +9,9 @@ from __future__ import annotations
|
|
|
9
9
|
from dataclasses import dataclass
|
|
10
10
|
from typing import Any, Callable, Dict
|
|
11
11
|
|
|
12
|
-
from fastapi import APIRouter,
|
|
12
|
+
from fastapi import APIRouter, Request
|
|
13
|
+
|
|
14
|
+
from latticeai.core.messages import http_error, resolve_language
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
@dataclass(frozen=True)
|
|
@@ -48,7 +50,7 @@ def register_history_routes(router: APIRouter, deps: HistoryRouteDependencies) -
|
|
|
48
50
|
**deps.scope_for_user(current_user),
|
|
49
51
|
)
|
|
50
52
|
if not messages:
|
|
51
|
-
raise
|
|
53
|
+
raise http_error(404, "chat.conversation_not_found", resolve_language(request))
|
|
52
54
|
return {"id": conversation_id, "messages": messages}
|
|
53
55
|
|
|
54
56
|
@router.delete("/history/conversations/{conversation_id:path}")
|
|
@@ -31,9 +31,10 @@ from latticeai.core.file_generation import (
|
|
|
31
31
|
repair_bundle_references,
|
|
32
32
|
validate_project_bundle,
|
|
33
33
|
)
|
|
34
|
+
from latticeai.core.messages import DEFAULT_LANGUAGE, translate
|
|
34
35
|
|
|
35
36
|
|
|
36
|
-
def next_available_path(root: Path, target: str) -> str:
|
|
37
|
+
def next_available_path(root: Path, target: str, language: str = DEFAULT_LANGUAGE) -> str:
|
|
37
38
|
"""Return ``target`` unchanged, or a ``name_2.ext``-style variant when the
|
|
38
39
|
file already exists in the workspace.
|
|
39
40
|
|
|
@@ -54,7 +55,7 @@ def next_available_path(root: Path, target: str) -> str:
|
|
|
54
55
|
return str(Path(target).with_name(variant.name))
|
|
55
56
|
raise HTTPException(
|
|
56
57
|
status_code=409,
|
|
57
|
-
detail=
|
|
58
|
+
detail=translate("chat.file_name_collision", language, name=target),
|
|
58
59
|
)
|
|
59
60
|
|
|
60
61
|
|
|
@@ -118,14 +119,10 @@ class ChatIntentController:
|
|
|
118
119
|
except Exception as exc: # noqa: BLE001 — metrics must never break chat
|
|
119
120
|
logging.warning("funnel metrics increment failed: %s", exc)
|
|
120
121
|
|
|
121
|
-
def no_model_response(self) -> JSONResponse:
|
|
122
|
-
detail = "
|
|
122
|
+
def no_model_response(self, language: str = DEFAULT_LANGUAGE) -> JSONResponse:
|
|
123
|
+
detail = translate("chat.no_model_loaded", language)
|
|
123
124
|
if self.config.is_public:
|
|
124
|
-
detail = (
|
|
125
|
-
"No public model loaded. Set OPENAI_API_KEY and "
|
|
126
|
-
f"LATTICEAI_PUBLIC_MODEL={self.public_model}, or call /models/load "
|
|
127
|
-
"with an OpenAI-compatible model."
|
|
128
|
-
)
|
|
125
|
+
detail = translate("models.public_model_missing", language, model=self.public_model)
|
|
129
126
|
return JSONResponse(
|
|
130
127
|
status_code=400,
|
|
131
128
|
content={
|
|
@@ -249,6 +246,7 @@ class ChatIntentController:
|
|
|
249
246
|
model_id: Optional[str],
|
|
250
247
|
effective_email: Optional[str] = None,
|
|
251
248
|
workspace_id: Optional[str] = None,
|
|
249
|
+
language: str = DEFAULT_LANGUAGE,
|
|
252
250
|
):
|
|
253
251
|
# An explicit path ("report.txt") wins; otherwise infer one from an
|
|
254
252
|
# explicit type keyword ("html 파일 만들어줘") so weak models never
|
|
@@ -266,19 +264,20 @@ class ChatIntentController:
|
|
|
266
264
|
model_id=model_id,
|
|
267
265
|
effective_email=effective_email,
|
|
268
266
|
workspace_id=workspace_id,
|
|
267
|
+
language=language,
|
|
269
268
|
)
|
|
270
269
|
target_path = explicit_target or infer_file_target(req.message)
|
|
271
270
|
if not target_path:
|
|
272
271
|
return None
|
|
273
272
|
# Never silently overwrite: an existing target gets a _2/_3 suffix so
|
|
274
273
|
# regeneration is safe and repeatable without the proposal flow.
|
|
275
|
-
deduped_path = next_available_path(self.agent_root, target_path)
|
|
274
|
+
deduped_path = next_available_path(self.agent_root, target_path, language)
|
|
276
275
|
renamed = deduped_path != target_path
|
|
277
276
|
target_path = deduped_path
|
|
278
277
|
content = inline_file_action_content(req.message)
|
|
279
278
|
generation_meta: Optional[Dict[str, Any]] = None
|
|
280
279
|
if content is None and not model_id:
|
|
281
|
-
return self.no_model_response()
|
|
280
|
+
return self.no_model_response(language)
|
|
282
281
|
if content is None and model_id:
|
|
283
282
|
# Model-agnostic pipeline: strict extension-aware prompt →
|
|
284
283
|
# extraction → validation → one corrective retry → deterministic
|
|
@@ -302,7 +301,7 @@ class ChatIntentController:
|
|
|
302
301
|
if content is None:
|
|
303
302
|
raise HTTPException(
|
|
304
303
|
status_code=400,
|
|
305
|
-
detail="
|
|
304
|
+
detail=translate("chat.file_generation_failed", language),
|
|
306
305
|
)
|
|
307
306
|
try:
|
|
308
307
|
result = self.execute_tool(
|
|
@@ -395,6 +394,7 @@ class ChatIntentController:
|
|
|
395
394
|
model_id: Optional[str],
|
|
396
395
|
effective_email: Optional[str] = None,
|
|
397
396
|
workspace_id: Optional[str] = None,
|
|
397
|
+
language: str = DEFAULT_LANGUAGE,
|
|
398
398
|
):
|
|
399
399
|
"""Artifact Loop: manifest → per-file generate/validate → bundle write.
|
|
400
400
|
|
|
@@ -405,8 +405,8 @@ class ChatIntentController:
|
|
|
405
405
|
standard ``artifacts[]`` contract, and downloadable as a zip.
|
|
406
406
|
"""
|
|
407
407
|
if not model_id:
|
|
408
|
-
return self.no_model_response()
|
|
409
|
-
project_dir = next_available_path(self.agent_root, manifest["name"])
|
|
408
|
+
return self.no_model_response(language)
|
|
409
|
+
project_dir = next_available_path(self.agent_root, manifest["name"], language)
|
|
410
410
|
bundle_files = [str(f["path"]) for f in manifest["files"]]
|
|
411
411
|
|
|
412
412
|
async def _generate(context: str) -> str:
|
|
@@ -19,6 +19,7 @@ from latticeai.api.workspace_scope import (
|
|
|
19
19
|
resolve_workspace_scope,
|
|
20
20
|
workspace_scope_from_request,
|
|
21
21
|
)
|
|
22
|
+
from latticeai.core.messages import http_error, resolve_language
|
|
22
23
|
from latticeai.core.quiet import quiet
|
|
23
24
|
|
|
24
25
|
|
|
@@ -456,9 +457,9 @@ def create_knowledge_graph_router(
|
|
|
456
457
|
async def knowledge_graph_neighbors(node_id: str, request: Request):
|
|
457
458
|
kg, allowed = _scoped(request)
|
|
458
459
|
if not node_id:
|
|
459
|
-
raise
|
|
460
|
+
raise http_error(400, "graph.node_id_required", resolve_language(request))
|
|
460
461
|
if allowed is not None and not _filter_scoped(kg, [{"id": node_id}], allowed):
|
|
461
|
-
raise
|
|
462
|
+
raise http_error(404, "graph.node_not_found", resolve_language(request))
|
|
462
463
|
payload = kg.neighbors(node_id)
|
|
463
464
|
if allowed is not None:
|
|
464
465
|
neighbors = _filter_scoped(kg, payload.get("neighbors", []), allowed)
|
|
@@ -476,13 +477,13 @@ def create_knowledge_graph_router(
|
|
|
476
477
|
current_user = require_user(request)
|
|
477
478
|
if current_user and req.user_email:
|
|
478
479
|
if current_user.strip().lower() != req.user_email.strip().lower():
|
|
479
|
-
raise
|
|
480
|
+
raise http_error(403, "common.user_mismatch", resolve_language(request))
|
|
480
481
|
effective_user = current_user or req.user_email or None
|
|
481
482
|
kg = graph()
|
|
482
483
|
workspace_id = _write_workspace(request, current_user)
|
|
483
484
|
event_type = (req.type or "").strip().lower()
|
|
484
485
|
if event_type not in {"message", "ai_response", "note"}:
|
|
485
|
-
raise
|
|
486
|
+
raise http_error(400, "graph.unsupported_type", resolve_language(request))
|
|
486
487
|
role = req.role or ("assistant" if event_type == "ai_response" else "user")
|
|
487
488
|
if ingestion_pipeline is not None:
|
|
488
489
|
source_type = "chat_message" if event_type in {"message", "ai_response"} else "note"
|
|
@@ -15,6 +15,12 @@ from fastapi.responses import FileResponse
|
|
|
15
15
|
from pydantic import BaseModel
|
|
16
16
|
|
|
17
17
|
from latticeai.api.knowledge_graph import create_knowledge_graph_router
|
|
18
|
+
from latticeai.core.messages import (
|
|
19
|
+
DEFAULT_LANGUAGE,
|
|
20
|
+
http_error,
|
|
21
|
+
resolve_language,
|
|
22
|
+
translate,
|
|
23
|
+
)
|
|
18
24
|
from latticeai.services.local_knowledge import create_local_knowledge_router
|
|
19
25
|
from latticeai.tools import local_list, local_read, local_write
|
|
20
26
|
|
|
@@ -234,20 +240,20 @@ def create_local_files_router(
|
|
|
234
240
|
)
|
|
235
241
|
target = Path(path).expanduser().resolve()
|
|
236
242
|
if not target.exists() or not target.is_file():
|
|
237
|
-
raise
|
|
243
|
+
raise http_error(404, "common.file_not_found", resolve_language(request))
|
|
238
244
|
return FileResponse(str(target))
|
|
239
245
|
|
|
240
246
|
# ── v9.8.0 ingestion jobs API (frozen paths — consumed by the frontend) ───
|
|
241
247
|
def _require_pipeline():
|
|
242
248
|
if ingestion_pipeline is None or not ingestion_pipeline.available():
|
|
243
|
-
raise
|
|
249
|
+
raise http_error(503, "capture.ingestion_disabled", DEFAULT_LANGUAGE)
|
|
244
250
|
|
|
245
251
|
def _ingestion_write_workspace(request: Request, body_workspace: Optional[str], user: str) -> Optional[str]:
|
|
246
252
|
header = request.headers.get("X-Workspace-Id")
|
|
247
253
|
header = header.strip() if header and header.strip() else None
|
|
248
254
|
supplied = [value for value in (body_workspace, header) if value]
|
|
249
255
|
if len(set(supplied)) > 1:
|
|
250
|
-
raise
|
|
256
|
+
raise http_error(403, "common.workspace_mismatch", resolve_language(request))
|
|
251
257
|
requested = supplied[0] if supplied else None
|
|
252
258
|
if workspace_service is None:
|
|
253
259
|
return requested
|
|
@@ -271,7 +277,7 @@ def create_local_files_router(
|
|
|
271
277
|
_require_pipeline()
|
|
272
278
|
job = ingestion_pipeline.get_background_job(job_id)
|
|
273
279
|
if job is None:
|
|
274
|
-
raise
|
|
280
|
+
raise http_error(404, "ingestion.job_not_found", resolve_language(request))
|
|
275
281
|
return job.as_dict()
|
|
276
282
|
|
|
277
283
|
@router.post("/api/ingestion/jobs/{job_id}/resume")
|
|
@@ -281,7 +287,7 @@ def create_local_files_router(
|
|
|
281
287
|
_require_pipeline()
|
|
282
288
|
job = ingestion_pipeline.get_background_job(job_id)
|
|
283
289
|
if job is None:
|
|
284
|
-
raise
|
|
290
|
+
raise http_error(404, "ingestion.job_not_found", resolve_language(request))
|
|
285
291
|
if job.status == "running":
|
|
286
292
|
return {"status": "already_running", "job_id": job_id, "job": job.as_dict()}
|
|
287
293
|
remaining = len(job.remaining_indices())
|
|
@@ -312,7 +318,7 @@ def create_local_files_router(
|
|
|
312
318
|
workspace_id = _ingestion_write_workspace(request, req.workspace_id, current_user)
|
|
313
319
|
path = (req.path or "").strip()
|
|
314
320
|
if not path:
|
|
315
|
-
raise
|
|
321
|
+
raise http_error(400, "common.path_required", resolve_language(request))
|
|
316
322
|
if not req.approved:
|
|
317
323
|
return permission_gateway.local_permission_response(path, "read", current_user)
|
|
318
324
|
permission_gateway.require_local_approval(
|
|
@@ -339,16 +345,16 @@ def create_local_files_router(
|
|
|
339
345
|
return summary
|
|
340
346
|
|
|
341
347
|
# ── folder watch mode: opt-in, off by default (backlog #8) ────────────────
|
|
342
|
-
def _require_folder_watch():
|
|
348
|
+
def _require_folder_watch(request: Request):
|
|
343
349
|
_require_pipeline()
|
|
344
350
|
if folder_watch is None:
|
|
345
|
-
raise
|
|
351
|
+
raise http_error(503, "ingestion.watch_unavailable", resolve_language(request))
|
|
346
352
|
|
|
347
353
|
@router.get("/api/ingestion/watch")
|
|
348
354
|
async def folder_watch_status(request: Request):
|
|
349
355
|
"""Watch-mode status: stored opt-ins, poller state, last scan results."""
|
|
350
356
|
require_user(request)
|
|
351
|
-
_require_folder_watch()
|
|
357
|
+
_require_folder_watch(request)
|
|
352
358
|
return folder_watch.status()
|
|
353
359
|
|
|
354
360
|
@router.post("/api/ingestion/watch")
|
|
@@ -361,11 +367,11 @@ def create_local_files_router(
|
|
|
361
367
|
watch owner's workspace scope).
|
|
362
368
|
"""
|
|
363
369
|
current_user = permission_gateway.require_local_user(request)
|
|
364
|
-
_require_folder_watch()
|
|
370
|
+
_require_folder_watch(request)
|
|
365
371
|
workspace_id = _ingestion_write_workspace(request, req.workspace_id, current_user)
|
|
366
372
|
path = (req.path or "").strip()
|
|
367
373
|
if not path:
|
|
368
|
-
raise
|
|
374
|
+
raise http_error(400, "common.path_required", resolve_language(request))
|
|
369
375
|
if not req.approved:
|
|
370
376
|
return permission_gateway.local_permission_response(path, "read", current_user)
|
|
371
377
|
permission_gateway.require_local_approval(
|
|
@@ -381,7 +387,11 @@ def create_local_files_router(
|
|
|
381
387
|
recursive=req.recursive,
|
|
382
388
|
)
|
|
383
389
|
if result.get("status") != "ok":
|
|
384
|
-
raise HTTPException(
|
|
390
|
+
raise HTTPException(
|
|
391
|
+
status_code=400,
|
|
392
|
+
detail=result.get("detail")
|
|
393
|
+
or translate("ingestion.watch_enable_failed", resolve_language(request)),
|
|
394
|
+
)
|
|
385
395
|
return result
|
|
386
396
|
|
|
387
397
|
@router.delete("/api/ingestion/watch")
|
|
@@ -392,12 +402,12 @@ def create_local_files_router(
|
|
|
392
402
|
):
|
|
393
403
|
"""Opt back out of watch mode (removes the stored consent record)."""
|
|
394
404
|
require_user(request)
|
|
395
|
-
_require_folder_watch()
|
|
405
|
+
_require_folder_watch(request)
|
|
396
406
|
if not watch_id and not path:
|
|
397
|
-
raise
|
|
407
|
+
raise http_error(400, "ingestion.watch_selector_required", resolve_language(request))
|
|
398
408
|
result = folder_watch.disable(watch_id=watch_id, path=path)
|
|
399
409
|
if result.get("status") == "not_found":
|
|
400
|
-
raise
|
|
410
|
+
raise http_error(404, "ingestion.watch_not_found", resolve_language(request))
|
|
401
411
|
return result
|
|
402
412
|
|
|
403
413
|
@router.post("/local/write")
|