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
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""Server-side message catalog.
|
|
2
|
+
|
|
3
|
+
Every string the API hands a person — an ``HTTPException`` detail, a status
|
|
4
|
+
line, a refusal — used to be a literal at the raise site, and the literals were
|
|
5
|
+
written in whichever language the author happened to be thinking in. The result
|
|
6
|
+
was one API that answered ``"사용자를 찾을 수 없습니다."`` and, two files over,
|
|
7
|
+
``"Knowledge Graph ingestion is disabled."``, so *every* user read half the
|
|
8
|
+
product in a language they had not chosen.
|
|
9
|
+
|
|
10
|
+
The rules here are the same three the frontend catalog follows:
|
|
11
|
+
|
|
12
|
+
1. **One id per message.** The id is what code refers to; the wording is data.
|
|
13
|
+
2. **Both languages or neither.** ``test_messages.py`` fails on a key present
|
|
14
|
+
in one language and missing from the other, so a new message cannot ship
|
|
15
|
+
half-translated.
|
|
16
|
+
3. **The caller never picks the language.** It comes from the request, via
|
|
17
|
+
:func:`resolve_language`, so a message cannot be localized to whoever wrote
|
|
18
|
+
the endpoint.
|
|
19
|
+
|
|
20
|
+
Usage::
|
|
21
|
+
|
|
22
|
+
from latticeai.core.messages import http_error, resolve_language
|
|
23
|
+
|
|
24
|
+
lang = resolve_language(request)
|
|
25
|
+
raise http_error(404, "auth.user_not_found", lang)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import Any, Dict, Mapping, Optional
|
|
31
|
+
|
|
32
|
+
from fastapi import HTTPException
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"DEFAULT_LANGUAGE",
|
|
36
|
+
"SUPPORTED_LANGUAGES",
|
|
37
|
+
"LANGUAGE_HEADER",
|
|
38
|
+
"MESSAGES",
|
|
39
|
+
"http_error",
|
|
40
|
+
"resolve_language",
|
|
41
|
+
"translate",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
DEFAULT_LANGUAGE = "ko"
|
|
45
|
+
SUPPORTED_LANGUAGES = ("ko", "en")
|
|
46
|
+
|
|
47
|
+
#: Explicit override the frontend sends. Preferred over ``Accept-Language``
|
|
48
|
+
#: because the person picked this language *in the product*, whereas
|
|
49
|
+
#: ``Accept-Language`` is whatever their browser was installed with.
|
|
50
|
+
LANGUAGE_HEADER = "x-lattice-language"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
MESSAGES: Dict[str, Dict[str, str]] = {
|
|
54
|
+
# ── auth ────────────────────────────────────────────────────────────
|
|
55
|
+
"auth.password_too_weak": {
|
|
56
|
+
"ko": "비밀번호는 8자 이상이며 영문자와 숫자를 모두 포함해야 합니다.",
|
|
57
|
+
"en": "Your password needs at least 8 characters, including letters and numbers.",
|
|
58
|
+
},
|
|
59
|
+
"auth.invitation_required": {
|
|
60
|
+
"ko": "유효한 서명 초대 권한이 필요합니다.",
|
|
61
|
+
"en": "A valid signed invitation is required.",
|
|
62
|
+
},
|
|
63
|
+
"auth.registration_disabled": {
|
|
64
|
+
"ko": "회원가입이 비활성화되어 있습니다. 관리자에게 문의하세요.",
|
|
65
|
+
"en": "Sign-up is turned off. Ask an administrator to enable it.",
|
|
66
|
+
},
|
|
67
|
+
"auth.email_taken": {
|
|
68
|
+
"ko": "이미 존재하는 이메일입니다.",
|
|
69
|
+
"en": "That email address is already registered.",
|
|
70
|
+
},
|
|
71
|
+
"auth.bad_credentials": {
|
|
72
|
+
"ko": "이메일 또는 비밀번호가 틀렸습니다.",
|
|
73
|
+
"en": "That email address or password is not correct.",
|
|
74
|
+
},
|
|
75
|
+
"auth.account_disabled": {
|
|
76
|
+
"ko": "비활성화된 계정입니다.",
|
|
77
|
+
"en": "This account has been disabled.",
|
|
78
|
+
},
|
|
79
|
+
"auth.login_required": {
|
|
80
|
+
"ko": "인증이 필요합니다.",
|
|
81
|
+
"en": "You need to sign in first.",
|
|
82
|
+
},
|
|
83
|
+
"auth.user_not_found": {
|
|
84
|
+
"ko": "사용자를 찾을 수 없습니다.",
|
|
85
|
+
"en": "No such user.",
|
|
86
|
+
},
|
|
87
|
+
"auth.current_password_wrong": {
|
|
88
|
+
"ko": "현재 비밀번호가 틀렸습니다.",
|
|
89
|
+
"en": "Your current password is not correct.",
|
|
90
|
+
},
|
|
91
|
+
"auth.name_required": {
|
|
92
|
+
"ko": "이름을 입력해주세요.",
|
|
93
|
+
"en": "Please enter a name.",
|
|
94
|
+
},
|
|
95
|
+
"auth.nickname_required": {
|
|
96
|
+
"ko": "닉네임을 입력해주세요.",
|
|
97
|
+
"en": "Please enter a nickname.",
|
|
98
|
+
},
|
|
99
|
+
# ── SSO ─────────────────────────────────────────────────────────────
|
|
100
|
+
"sso.not_configured": {
|
|
101
|
+
"ko": "SSO가 설정되지 않았습니다.",
|
|
102
|
+
"en": "Single sign-on is not set up.",
|
|
103
|
+
},
|
|
104
|
+
"sso.invalid_state": {
|
|
105
|
+
"ko": "유효하지 않은 SSO 상태입니다.",
|
|
106
|
+
"en": "That sign-on request is no longer valid. Please start again.",
|
|
107
|
+
},
|
|
108
|
+
"sso.config_error": {
|
|
109
|
+
"ko": "SSO 설정 오류입니다.",
|
|
110
|
+
"en": "Single sign-on is misconfigured.",
|
|
111
|
+
},
|
|
112
|
+
"sso.no_id_token": {
|
|
113
|
+
"ko": "ID 토큰을 받지 못했습니다.",
|
|
114
|
+
"en": "The identity provider did not return an ID token.",
|
|
115
|
+
},
|
|
116
|
+
"sso.token_verification_failed": {
|
|
117
|
+
"ko": "SSO 토큰 검증에 실패했습니다.",
|
|
118
|
+
"en": "The sign-on token could not be verified.",
|
|
119
|
+
},
|
|
120
|
+
"sso.provider_verification_failed": {
|
|
121
|
+
"ko": "SSO 공급자 검증에 실패했습니다.",
|
|
122
|
+
"en": "The sign-on provider could not be verified.",
|
|
123
|
+
},
|
|
124
|
+
"sso.email_unavailable": {
|
|
125
|
+
"ko": "이메일을 확인할 수 없습니다.",
|
|
126
|
+
"en": "The identity provider did not share an email address.",
|
|
127
|
+
},
|
|
128
|
+
"sso.invitation_required": {
|
|
129
|
+
"ko": "신규 SSO 계정에는 유효한 서명 초대 권한이 필요합니다.",
|
|
130
|
+
"en": "New single sign-on accounts need a valid signed invitation.",
|
|
131
|
+
},
|
|
132
|
+
# ── admin ───────────────────────────────────────────────────────────
|
|
133
|
+
"admin.invalid_role": {
|
|
134
|
+
"ko": "role은 admin 또는 user만 가능합니다.",
|
|
135
|
+
"en": "Role must be either 'admin' or 'user'.",
|
|
136
|
+
},
|
|
137
|
+
"admin.cannot_disable_self": {
|
|
138
|
+
"ko": "자기 자신은 비활성화할 수 없습니다.",
|
|
139
|
+
"en": "You cannot disable your own account.",
|
|
140
|
+
},
|
|
141
|
+
"admin.cannot_delete_self": {
|
|
142
|
+
"ko": "자기 자신은 삭제할 수 없습니다.",
|
|
143
|
+
"en": "You cannot delete your own account.",
|
|
144
|
+
},
|
|
145
|
+
# ── capture / ingestion ─────────────────────────────────────────────
|
|
146
|
+
"capture.ingestion_disabled": {
|
|
147
|
+
"ko": "지식 그래프 수집이 꺼져 있습니다.",
|
|
148
|
+
"en": "Knowledge Graph ingestion is turned off.",
|
|
149
|
+
},
|
|
150
|
+
"capture.payload_too_large": {
|
|
151
|
+
"ko": "보낸 내용이 너무 큽니다.",
|
|
152
|
+
"en": "That capture is too large to accept.",
|
|
153
|
+
},
|
|
154
|
+
"capture.nothing_to_capture": {
|
|
155
|
+
"ko": "저장할 내용이 없습니다. 텍스트나 페이지 내용을 함께 보내주세요.",
|
|
156
|
+
"en": "Nothing to capture — send text, html, or a selection.",
|
|
157
|
+
},
|
|
158
|
+
# ── shared ──────────────────────────────────────────────────────────
|
|
159
|
+
"common.user_mismatch": {
|
|
160
|
+
"ko": "로그인한 사용자와 요청한 사용자가 다릅니다.",
|
|
161
|
+
"en": "user_email must match the authenticated user.",
|
|
162
|
+
},
|
|
163
|
+
"common.workspace_mismatch": {
|
|
164
|
+
"ko": "작업 공간 지정이 서로 다릅니다.",
|
|
165
|
+
"en": "Workspace selectors must match.",
|
|
166
|
+
},
|
|
167
|
+
"common.workspace_unreadable": {
|
|
168
|
+
"ko": "'{workspace}' 작업 공간을 읽을 권한이 없습니다.",
|
|
169
|
+
"en": "Workspace '{workspace}' is not readable.",
|
|
170
|
+
},
|
|
171
|
+
"common.file_not_found": {
|
|
172
|
+
"ko": "파일을 찾을 수 없습니다.",
|
|
173
|
+
"en": "File not found.",
|
|
174
|
+
},
|
|
175
|
+
"common.path_required": {
|
|
176
|
+
"ko": "경로를 입력해주세요.",
|
|
177
|
+
"en": "A path is required.",
|
|
178
|
+
},
|
|
179
|
+
"common.graph_disabled": {
|
|
180
|
+
"ko": "지식 그래프가 꺼져 있습니다.",
|
|
181
|
+
"en": "The Knowledge Graph is turned off.",
|
|
182
|
+
},
|
|
183
|
+
"common.graph_unavailable": {
|
|
184
|
+
"ko": "지식 그래프를 사용할 수 없습니다.",
|
|
185
|
+
"en": "The Knowledge Graph is not available.",
|
|
186
|
+
},
|
|
187
|
+
# ── chat ────────────────────────────────────────────────────────────
|
|
188
|
+
"chat.model_not_loaded": {
|
|
189
|
+
"ko": "'{model}' 모델이 아직 준비되지 않았습니다.",
|
|
190
|
+
"en": "Model '{model}' is not loaded.",
|
|
191
|
+
},
|
|
192
|
+
"chat.no_model_loaded": {
|
|
193
|
+
"ko": "불러온 모델이 없습니다. 모델을 먼저 준비해주세요.",
|
|
194
|
+
"en": "No model is loaded. Prepare a model first.",
|
|
195
|
+
},
|
|
196
|
+
"chat.conversation_not_found": {
|
|
197
|
+
"ko": "대화를 찾을 수 없습니다.",
|
|
198
|
+
"en": "That conversation no longer exists.",
|
|
199
|
+
},
|
|
200
|
+
"chat.file_name_collision": {
|
|
201
|
+
"ko": "'{name}' 이름의 파일이 너무 많습니다. 다른 이름을 지정해 주세요.",
|
|
202
|
+
"en": "Too many files are already named '{name}'. Please choose another name.",
|
|
203
|
+
},
|
|
204
|
+
"chat.file_generation_failed": {
|
|
205
|
+
"ko": "파일 내용을 만들지 못했습니다.",
|
|
206
|
+
"en": "The file content could not be generated.",
|
|
207
|
+
},
|
|
208
|
+
# ── memory / graph ──────────────────────────────────────────────────
|
|
209
|
+
"memory.unknown_source": {
|
|
210
|
+
"ko": "'{source}'는 알 수 없는 기억 출처입니다.",
|
|
211
|
+
"en": "Unknown memory source: {source}.",
|
|
212
|
+
},
|
|
213
|
+
"graph.node_id_required": {
|
|
214
|
+
"ko": "노드 식별자가 필요합니다.",
|
|
215
|
+
"en": "A node id is required.",
|
|
216
|
+
},
|
|
217
|
+
"graph.node_not_found": {
|
|
218
|
+
"ko": "노드를 찾을 수 없습니다.",
|
|
219
|
+
"en": "That node was not found.",
|
|
220
|
+
},
|
|
221
|
+
"graph.unsupported_type": {
|
|
222
|
+
"ko": "지원하는 종류: message, ai_response, note",
|
|
223
|
+
"en": "Supported types are: message, ai_response, note.",
|
|
224
|
+
},
|
|
225
|
+
# ── ingestion / folders ─────────────────────────────────────────────
|
|
226
|
+
"ingestion.job_not_found": {
|
|
227
|
+
"ko": "수집 작업을 찾을 수 없습니다.",
|
|
228
|
+
"en": "That ingestion job was not found.",
|
|
229
|
+
},
|
|
230
|
+
"ingestion.watch_unavailable": {
|
|
231
|
+
"ko": "폴더 감시 기능을 사용할 수 없습니다.",
|
|
232
|
+
"en": "The folder watch service is unavailable.",
|
|
233
|
+
},
|
|
234
|
+
"ingestion.watch_enable_failed": {
|
|
235
|
+
"ko": "폴더 감시를 켜지 못했습니다.",
|
|
236
|
+
"en": "Turning folder watch on did not succeed.",
|
|
237
|
+
},
|
|
238
|
+
"ingestion.watch_selector_required": {
|
|
239
|
+
"ko": "감시 항목 또는 경로가 필요합니다.",
|
|
240
|
+
"en": "A watch id or a path is required.",
|
|
241
|
+
},
|
|
242
|
+
"ingestion.watch_not_found": {
|
|
243
|
+
"ko": "감시 중인 폴더를 찾을 수 없습니다.",
|
|
244
|
+
"en": "That watched folder was not found.",
|
|
245
|
+
},
|
|
246
|
+
"portability.verification_failed": {
|
|
247
|
+
"ko": "보관 파일 검증에 실패했습니다.",
|
|
248
|
+
"en": "Archive verification failed.",
|
|
249
|
+
},
|
|
250
|
+
# ── review queue ────────────────────────────────────────────────────
|
|
251
|
+
"review.item_not_found": {
|
|
252
|
+
"ko": "검토 항목을 찾을 수 없습니다.",
|
|
253
|
+
"en": "That review item was not found.",
|
|
254
|
+
},
|
|
255
|
+
"review.cannot_approve_in_status": {
|
|
256
|
+
"ko": "'{status}' 상태의 검토 항목은 승인할 수 없습니다.",
|
|
257
|
+
"en": "A review item in status '{status}' cannot be approved.",
|
|
258
|
+
},
|
|
259
|
+
# ── projects ────────────────────────────────────────────────────────
|
|
260
|
+
"project.not_found": {
|
|
261
|
+
"ko": "프로젝트를 찾을 수 없습니다.",
|
|
262
|
+
"en": "That project was not found.",
|
|
263
|
+
},
|
|
264
|
+
# ── network boundary ────────────────────────────────────────────────
|
|
265
|
+
"boundary.policy_not_configured": {
|
|
266
|
+
"ko": "혼합 검색 정책 서비스가 설정되지 않았습니다.",
|
|
267
|
+
"en": "The hybrid policy service is not configured.",
|
|
268
|
+
},
|
|
269
|
+
# ── models ──────────────────────────────────────────────────────────
|
|
270
|
+
"models.other_user_credentials": {
|
|
271
|
+
"ko": "다른 사용자의 모델 자격 증명을 사용할 수 없습니다.",
|
|
272
|
+
"en": "You cannot use another user's model credentials.",
|
|
273
|
+
},
|
|
274
|
+
"models.download_consent_required": {
|
|
275
|
+
"ko": "모델 내려받기는 사용자가 직접 동의해야 시작됩니다.",
|
|
276
|
+
"en": "Model downloads require explicit consent (allow_download=true).",
|
|
277
|
+
},
|
|
278
|
+
"models.identifier_empty": {
|
|
279
|
+
"ko": "모델 식별자가 비어 있습니다.",
|
|
280
|
+
"en": "The model identifier is empty.",
|
|
281
|
+
},
|
|
282
|
+
"models.name_empty": {
|
|
283
|
+
"ko": "모델 이름이 비어 있습니다.",
|
|
284
|
+
"en": "The model name is empty.",
|
|
285
|
+
},
|
|
286
|
+
"models.ollama_missing": {
|
|
287
|
+
"ko": "Ollama가 설치되어 있지 않습니다.",
|
|
288
|
+
"en": "Ollama is not installed.",
|
|
289
|
+
},
|
|
290
|
+
"models.download_timeout": {
|
|
291
|
+
"ko": "모델 내려받기 시간이 초과되었습니다.",
|
|
292
|
+
"en": "The model download timed out.",
|
|
293
|
+
},
|
|
294
|
+
"models.pull_failed": {
|
|
295
|
+
"ko": "모델 내려받기에 실패했습니다.",
|
|
296
|
+
"en": "The model download failed.",
|
|
297
|
+
},
|
|
298
|
+
"models.download_not_automated": {
|
|
299
|
+
"ko": "{provider} 엔진의 모델 내려받기는 아직 자동화되지 않았습니다.",
|
|
300
|
+
"en": "Model downloads for the {provider} engine are not automated yet.",
|
|
301
|
+
},
|
|
302
|
+
"models.unknown_provider": {
|
|
303
|
+
"ko": "알 수 없는 제공자입니다.",
|
|
304
|
+
"en": "Unknown provider.",
|
|
305
|
+
},
|
|
306
|
+
"models.api_key_empty": {
|
|
307
|
+
"ko": "API 키가 비어 있습니다.",
|
|
308
|
+
"en": "The API key is empty.",
|
|
309
|
+
},
|
|
310
|
+
"models.other_user_api_key": {
|
|
311
|
+
"ko": "다른 사용자의 API 키를 설정할 권한이 없습니다.",
|
|
312
|
+
"en": "You are not allowed to set another user's API key.",
|
|
313
|
+
},
|
|
314
|
+
"models.sign_in_required": {
|
|
315
|
+
"ko": "사용자 확인이 필요합니다. 로그인 후 다시 시도하세요.",
|
|
316
|
+
"en": "Sign in first, then try again.",
|
|
317
|
+
},
|
|
318
|
+
# ── tools / files ───────────────────────────────────────────────────
|
|
319
|
+
"tools.pdf_render_failed": {
|
|
320
|
+
"ko": "PDF를 그리지 못했습니다: {reason}",
|
|
321
|
+
"en": "The PDF could not be rendered: {reason}",
|
|
322
|
+
},
|
|
323
|
+
"tools.path_outside_workspace": {
|
|
324
|
+
"ko": "경로가 작업 공간 밖입니다.",
|
|
325
|
+
"en": "That path is outside the workspace.",
|
|
326
|
+
},
|
|
327
|
+
"tools.directory_not_found": {
|
|
328
|
+
"ko": "디렉터리를 찾을 수 없습니다.",
|
|
329
|
+
"en": "That directory was not found.",
|
|
330
|
+
},
|
|
331
|
+
# ── MCP / setup ─────────────────────────────────────────────────────
|
|
332
|
+
"mcp.connector_not_found": {
|
|
333
|
+
"ko": "커넥터를 찾을 수 없습니다.",
|
|
334
|
+
"en": "That connector was not found.",
|
|
335
|
+
},
|
|
336
|
+
"mcp.name_required": {
|
|
337
|
+
"ko": "이름은 필수입니다.",
|
|
338
|
+
"en": "A name is required.",
|
|
339
|
+
},
|
|
340
|
+
"mcp.package_required": {
|
|
341
|
+
"ko": "패키지는 필수입니다.",
|
|
342
|
+
"en": "A package is required.",
|
|
343
|
+
},
|
|
344
|
+
"mcp.item_not_found": {
|
|
345
|
+
"ko": "항목을 찾을 수 없습니다.",
|
|
346
|
+
"en": "That item was not found.",
|
|
347
|
+
},
|
|
348
|
+
"mcp.unknown_id": {
|
|
349
|
+
"ko": "알 수 없는 MCP입니다: {mcp_id}",
|
|
350
|
+
"en": "Unknown MCP: {mcp_id}",
|
|
351
|
+
},
|
|
352
|
+
"setup.unknown_permission": {
|
|
353
|
+
"ko": "알 수 없는 권한 설정입니다.",
|
|
354
|
+
"en": "Unknown permission setting.",
|
|
355
|
+
},
|
|
356
|
+
"models.public_mode_blocks_local": {
|
|
357
|
+
"ko": (
|
|
358
|
+
"공개 모드에서는 로컬 MLX 모델을 불러올 수 없습니다. "
|
|
359
|
+
"openai:, openrouter:, groq:, together: 모델을 쓰거나 "
|
|
360
|
+
"LATTICEAI_ALLOW_LOCAL_MODELS=true 로 설정하세요."
|
|
361
|
+
),
|
|
362
|
+
"en": (
|
|
363
|
+
"Public mode does not load local MLX models. Use an openai:, "
|
|
364
|
+
"openrouter:, groq: or together: model, or set "
|
|
365
|
+
"LATTICEAI_ALLOW_LOCAL_MODELS=true."
|
|
366
|
+
),
|
|
367
|
+
},
|
|
368
|
+
"models.public_model_missing": {
|
|
369
|
+
"ko": (
|
|
370
|
+
"공개 모델이 준비되지 않았습니다. OPENAI_API_KEY 와 "
|
|
371
|
+
"LATTICEAI_PUBLIC_MODEL={model} 을 설정하거나, OpenAI 호환 모델로 "
|
|
372
|
+
"/models/load 를 호출하세요."
|
|
373
|
+
),
|
|
374
|
+
"en": (
|
|
375
|
+
"No public model is loaded. Set OPENAI_API_KEY and "
|
|
376
|
+
"LATTICEAI_PUBLIC_MODEL={model}, or call /models/load with an "
|
|
377
|
+
"OpenAI-compatible model."
|
|
378
|
+
),
|
|
379
|
+
},
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def resolve_language(request: Any, default: str = DEFAULT_LANGUAGE) -> str:
|
|
384
|
+
"""Language for this request: the product's choice, then the browser's.
|
|
385
|
+
|
|
386
|
+
Never raises and never returns an unsupported language — an endpoint that
|
|
387
|
+
cannot resolve a language must still be able to answer.
|
|
388
|
+
"""
|
|
389
|
+
headers: Optional[Mapping[str, str]] = getattr(request, "headers", None)
|
|
390
|
+
if headers is None:
|
|
391
|
+
return default
|
|
392
|
+
|
|
393
|
+
explicit = _normalize(headers.get(LANGUAGE_HEADER))
|
|
394
|
+
if explicit:
|
|
395
|
+
return explicit
|
|
396
|
+
|
|
397
|
+
# `Accept-Language: en-GB,en;q=0.9,ko;q=0.8` — take the first tag we
|
|
398
|
+
# support, honouring the order the browser sent (which is the priority
|
|
399
|
+
# order) rather than re-sorting by q-value, since the two agree in every
|
|
400
|
+
# real header and disagreeing ones are malformed anyway.
|
|
401
|
+
for part in (headers.get("accept-language") or "").split(","):
|
|
402
|
+
candidate = _normalize(part.split(";", 1)[0])
|
|
403
|
+
if candidate:
|
|
404
|
+
return candidate
|
|
405
|
+
return default
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _normalize(value: Optional[str]) -> Optional[str]:
|
|
409
|
+
"""``"en-GB"`` → ``"en"`` when supported, else None."""
|
|
410
|
+
if not value:
|
|
411
|
+
return None
|
|
412
|
+
tag = value.strip().lower().replace("_", "-")
|
|
413
|
+
if not tag:
|
|
414
|
+
return None
|
|
415
|
+
base = tag.split("-", 1)[0]
|
|
416
|
+
return base if base in SUPPORTED_LANGUAGES else None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def translate(key: str, language: str = DEFAULT_LANGUAGE, **params: Any) -> str:
|
|
420
|
+
"""Localized text for ``key``.
|
|
421
|
+
|
|
422
|
+
An unknown key returns the key itself rather than raising: a missing
|
|
423
|
+
message must not turn a 404 into a 500. The namespaced key is also
|
|
424
|
+
recognisable in a bug report, which a generic "an error occurred" is not.
|
|
425
|
+
"""
|
|
426
|
+
entry = MESSAGES.get(key)
|
|
427
|
+
if entry is None:
|
|
428
|
+
return key
|
|
429
|
+
text = entry.get(language) or entry.get(DEFAULT_LANGUAGE) or key
|
|
430
|
+
for name, value in params.items():
|
|
431
|
+
text = text.replace("{" + name + "}", str(value))
|
|
432
|
+
return text
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def http_error(
|
|
436
|
+
status_code: int,
|
|
437
|
+
key: str,
|
|
438
|
+
language: str = DEFAULT_LANGUAGE,
|
|
439
|
+
*,
|
|
440
|
+
headers: Optional[Dict[str, str]] = None,
|
|
441
|
+
**params: Any,
|
|
442
|
+
) -> HTTPException:
|
|
443
|
+
"""An ``HTTPException`` whose detail is localized for this request.
|
|
444
|
+
|
|
445
|
+
Returned, not raised, so call sites keep reading ``raise http_error(...)``
|
|
446
|
+
and the traceback still points at the endpoint.
|
|
447
|
+
"""
|
|
448
|
+
return HTTPException(
|
|
449
|
+
status_code=status_code,
|
|
450
|
+
detail=translate(key, language, **params),
|
|
451
|
+
headers=headers,
|
|
452
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Local Computer Memory: the opt-in that lets the Brain watch this machine.
|
|
2
|
+
|
|
3
|
+
Extracted from ``WorkspaceOSStore``. Off by default and impossible to enable
|
|
4
|
+
without a recorded consent — the check lives here so there is one place to read
|
|
5
|
+
when asking "what did this machine agree to observe".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from .timeutil import now_iso as _now
|
|
13
|
+
from .workspace_os_utils import _json_hash
|
|
14
|
+
|
|
15
|
+
__all__ = ["WorkspaceComputerMemory", "DEFAULT_COMPUTER_MEMORY_SCOPES"]
|
|
16
|
+
|
|
17
|
+
#: Folders the feature offers to watch when the person names none. Chosen to be
|
|
18
|
+
#: recognisable rather than exhaustive: the point of the list is that a person
|
|
19
|
+
#: reading the consent dialog knows what it means.
|
|
20
|
+
DEFAULT_COMPUTER_MEMORY_SCOPES = ("Downloads", "Documents", "Repositories")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WorkspaceComputerMemory:
|
|
24
|
+
"""Consent state and activity log for watching the local computer."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, store: Any) -> None:
|
|
27
|
+
self.store = store
|
|
28
|
+
|
|
29
|
+
def configure(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
enabled: bool,
|
|
33
|
+
approved_by: Optional[str],
|
|
34
|
+
consent: Optional[Dict[str, Any]] = None,
|
|
35
|
+
scopes: Optional[List[str]] = None,
|
|
36
|
+
) -> Dict[str, Any]:
|
|
37
|
+
consent = consent or {}
|
|
38
|
+
if enabled and not consent.get("approved"):
|
|
39
|
+
raise PermissionError("Local Computer Memory requires explicit approval.")
|
|
40
|
+
state = self.store.load_state()
|
|
41
|
+
config = state.setdefault("computer_memory", {})
|
|
42
|
+
config.update({
|
|
43
|
+
"enabled": bool(enabled),
|
|
44
|
+
"approved": bool(enabled),
|
|
45
|
+
"approved_at": _now() if enabled else config.get("approved_at"),
|
|
46
|
+
"approved_by": approved_by if enabled else config.get("approved_by"),
|
|
47
|
+
"scopes": scopes or config.get("scopes") or list(DEFAULT_COMPUTER_MEMORY_SCOPES),
|
|
48
|
+
"consent": consent,
|
|
49
|
+
})
|
|
50
|
+
state.setdefault("feature_flags", {})["local_computer_memory"] = bool(enabled)
|
|
51
|
+
self.store.save_state(state)
|
|
52
|
+
self.store.record_timeline_event(
|
|
53
|
+
"memory",
|
|
54
|
+
"computer_memory_configured",
|
|
55
|
+
{"enabled": bool(enabled), "approved_by": approved_by},
|
|
56
|
+
)
|
|
57
|
+
return config
|
|
58
|
+
|
|
59
|
+
def record_activity(self, activity: Dict[str, Any], graph: Any = None) -> Dict[str, Any]:
|
|
60
|
+
state = self.store.load_state()
|
|
61
|
+
config = state.setdefault("computer_memory", {})
|
|
62
|
+
if not config.get("enabled"):
|
|
63
|
+
return {"status": "ignored", "reason": "local computer memory is disabled"}
|
|
64
|
+
record = {
|
|
65
|
+
"id": f"activity-{_json_hash([activity, _now()])[:16]}",
|
|
66
|
+
"timestamp": _now(),
|
|
67
|
+
**activity,
|
|
68
|
+
}
|
|
69
|
+
config.setdefault("activities", []).append(record)
|
|
70
|
+
if graph is not None:
|
|
71
|
+
# A graph that refuses the event must not lose the activity: the
|
|
72
|
+
# record is kept either way and carries the reason it did not land.
|
|
73
|
+
try:
|
|
74
|
+
graph.ingest_event(
|
|
75
|
+
"ComputerActivity",
|
|
76
|
+
str(activity.get("summary") or activity.get("path") or "Computer activity")[:120],
|
|
77
|
+
source="workspace_os",
|
|
78
|
+
metadata=record,
|
|
79
|
+
)
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
record["graph_error"] = str(exc)
|
|
82
|
+
self.store.save_state(state)
|
|
83
|
+
self.store.record_timeline_event("memory", "computer_activity", {"activity_id": record["id"]})
|
|
84
|
+
return {"status": "ok", "activity": record}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Indexing dashboard and per-source watch control.
|
|
2
|
+
|
|
3
|
+
Extracted from ``WorkspaceOSStore`` so the store stays a façade over composed
|
|
4
|
+
managers rather than a class that also knows how a file watcher reports itself.
|
|
5
|
+
Everything here reads the graph and the watcher and writes only timeline
|
|
6
|
+
events, so it needs the store for ``record_timeline_event`` and nothing else.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
__all__ = ["WorkspaceIndexing"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WorkspaceIndexing:
|
|
17
|
+
"""Reads index state out of the graph; pauses, resumes, and removes sources."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, store: Any) -> None:
|
|
20
|
+
self.store = store
|
|
21
|
+
|
|
22
|
+
def build_dashboard(
|
|
23
|
+
self,
|
|
24
|
+
graph: Any,
|
|
25
|
+
watcher_status: Optional[Dict[str, Any]] = None,
|
|
26
|
+
) -> Dict[str, Any]:
|
|
27
|
+
if graph is None:
|
|
28
|
+
return {
|
|
29
|
+
"sources": [],
|
|
30
|
+
"watcher": watcher_status or {"available": False, "active": {}},
|
|
31
|
+
"totals": {"success": 0, "failed": 0, "nodes": 0, "edges": 0},
|
|
32
|
+
}
|
|
33
|
+
stats = graph.stats()
|
|
34
|
+
sources = graph.local_sources().get("sources", [])
|
|
35
|
+
watcher_status = watcher_status or {"available": False, "active": {}}
|
|
36
|
+
active = watcher_status.get("active", {})
|
|
37
|
+
dashboard_sources = []
|
|
38
|
+
total_success = 0
|
|
39
|
+
total_failed = 0
|
|
40
|
+
for source in sources:
|
|
41
|
+
file_status = source.get("file_status") or {}
|
|
42
|
+
success = int(file_status.get("indexed") or 0)
|
|
43
|
+
failed = sum(
|
|
44
|
+
int(file_status.get(key) or 0)
|
|
45
|
+
for key in ("failed", "inaccessible", "skipped_empty_text")
|
|
46
|
+
)
|
|
47
|
+
total_success += success
|
|
48
|
+
total_failed += failed
|
|
49
|
+
watch = active.get(source.get("id")) or {}
|
|
50
|
+
dashboard_sources.append({
|
|
51
|
+
"id": source.get("id"),
|
|
52
|
+
"label": source.get("label"),
|
|
53
|
+
"root_path": source.get("root_path"),
|
|
54
|
+
"status": source.get("status"),
|
|
55
|
+
"watch_enabled": bool(source.get("watch_enabled")),
|
|
56
|
+
"watch_active": source.get("id") in active,
|
|
57
|
+
"watch_status": watch,
|
|
58
|
+
"success_count": success,
|
|
59
|
+
"failure_count": failed,
|
|
60
|
+
"last_run_at": source.get("last_scanned_at") or source.get("updated_at"),
|
|
61
|
+
"file_status": file_status,
|
|
62
|
+
"include_ocr": bool(source.get("include_ocr")),
|
|
63
|
+
})
|
|
64
|
+
return {
|
|
65
|
+
"sources": dashboard_sources,
|
|
66
|
+
"watcher": watcher_status,
|
|
67
|
+
"totals": {
|
|
68
|
+
"success": total_success,
|
|
69
|
+
"failed": total_failed,
|
|
70
|
+
"nodes": sum(int(v or 0) for v in (stats.get("nodes") or {}).values()),
|
|
71
|
+
"edges": sum(int(v or 0) for v in (stats.get("edges") or {}).values()),
|
|
72
|
+
"local_sources": stats.get("local_sources", len(sources)),
|
|
73
|
+
},
|
|
74
|
+
"graph_stats": stats,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
def pause(self, graph: Any, source_id: str, watcher: Any = None) -> Dict[str, Any]:
|
|
78
|
+
result = graph.set_local_source_watch(source_id, False)
|
|
79
|
+
watch = watcher.stop_source(source_id) if watcher else {"stopped": False, "source_id": source_id}
|
|
80
|
+
self.store.record_timeline_event("graph", "indexing_paused", {"source_id": source_id})
|
|
81
|
+
return {"status": "ok", "source": result, "watch": watch}
|
|
82
|
+
|
|
83
|
+
def resume(self, graph: Any, source_id: str, watcher: Any = None) -> Dict[str, Any]:
|
|
84
|
+
result = graph.set_local_source_watch(source_id, True)
|
|
85
|
+
watch = {"watching": False, "source_id": source_id}
|
|
86
|
+
source = next(
|
|
87
|
+
(item for item in graph.local_sources().get("sources", []) if item.get("id") == source_id),
|
|
88
|
+
None,
|
|
89
|
+
)
|
|
90
|
+
if watcher and source:
|
|
91
|
+
watch = watcher.start_source(source)
|
|
92
|
+
self.store.record_timeline_event("graph", "indexing_resumed", {"source_id": source_id})
|
|
93
|
+
return {"status": "ok", "source": result, "watch": watch}
|
|
94
|
+
|
|
95
|
+
def remove_source(self, graph: Any, source_id: str, watcher: Any = None) -> Dict[str, Any]:
|
|
96
|
+
if watcher:
|
|
97
|
+
watcher.stop_source(source_id)
|
|
98
|
+
if not hasattr(graph, "remove_local_source"):
|
|
99
|
+
raise ValueError("graph store does not support removing local sources")
|
|
100
|
+
result = graph.remove_local_source(source_id)
|
|
101
|
+
self.store.record_timeline_event("graph", "indexing_removed", {"source_id": source_id})
|
|
102
|
+
return {"status": "ok", **result}
|