ltcai 9.0.0 → 9.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.
- package/README.md +80 -46
- package/auto_setup.py +7 -853
- package/desktop/electron/README.md +9 -0
- package/desktop/electron/main.cjs +5 -3
- package/docs/CHANGELOG.md +146 -2
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
- package/docs/DEVELOPMENT.md +53 -14
- package/docs/LEGACY_COMPATIBILITY.md +4 -4
- package/docs/ONBOARDING.md +10 -2
- package/docs/OPERATIONS.md +8 -4
- package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
- package/docs/TRUST_MODEL.md +5 -2
- package/docs/WHY_LATTICE.md +15 -10
- package/docs/WORKFLOW_DESIGNER.md +22 -0
- package/docs/kg-schema.md +13 -2
- package/docs/mcp-tools.md +17 -6
- package/docs/privacy.md +19 -3
- package/docs/public-deploy.md +32 -3
- package/docs/security-model.md +46 -11
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +1 -6
- package/lattice_brain/context.py +66 -9
- package/lattice_brain/graph/_kg_fsutil.py +1 -5
- package/lattice_brain/graph/discovery_index.py +174 -20
- package/lattice_brain/graph/documents.py +44 -9
- package/lattice_brain/graph/ingest.py +47 -20
- package/lattice_brain/graph/provenance.py +13 -6
- package/lattice_brain/graph/retrieval.py +140 -39
- package/lattice_brain/graph/retrieval_docgen.py +37 -4
- package/lattice_brain/ingestion.py +4 -7
- package/lattice_brain/portability.py +28 -14
- package/lattice_brain/runtime/agent_runtime.py +5 -9
- package/lattice_brain/runtime/hooks.py +30 -13
- package/lattice_brain/runtime/multi_agent.py +27 -8
- package/lattice_brain/runtime/statuses.py +10 -0
- package/lattice_brain/utils.py +20 -2
- package/lattice_brain/workflow.py +1 -5
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agent_registry.py +10 -8
- package/latticeai/api/agents.py +22 -3
- package/latticeai/api/auth.py +78 -16
- package/latticeai/api/browser.py +303 -34
- package/latticeai/api/chat.py +320 -850
- package/latticeai/api/chat_agent_http.py +356 -0
- package/latticeai/api/chat_contracts.py +54 -0
- package/latticeai/api/chat_documents.py +256 -0
- package/latticeai/api/chat_helpers.py +24 -1
- package/latticeai/api/chat_history.py +99 -0
- package/latticeai/api/chat_intents.py +302 -0
- package/latticeai/api/chat_stream.py +115 -0
- package/latticeai/api/computer_use.py +62 -9
- package/latticeai/api/health.py +9 -3
- package/latticeai/api/hooks.py +17 -6
- package/latticeai/api/knowledge_graph.py +78 -14
- package/latticeai/api/local_files.py +7 -2
- package/latticeai/api/mcp.py +102 -23
- package/latticeai/api/models.py +72 -33
- package/latticeai/api/network.py +5 -5
- package/latticeai/api/permissions.py +67 -26
- package/latticeai/api/plugins.py +21 -7
- package/latticeai/api/portability.py +5 -5
- package/latticeai/api/realtime.py +33 -5
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +123 -24
- package/latticeai/api/tools.py +96 -14
- package/latticeai/api/workflow_designer.py +37 -0
- package/latticeai/api/workspace.py +2 -1
- package/latticeai/app_factory.py +110 -52
- package/latticeai/core/agent.py +19 -9
- package/latticeai/core/agent_registry.py +1 -5
- package/latticeai/core/config.py +23 -3
- package/latticeai/core/context_builder.py +21 -3
- package/latticeai/core/invitations.py +1 -4
- package/latticeai/core/io_utils.py +9 -1
- package/latticeai/core/legacy_compatibility.py +24 -3
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/plugins.py +15 -0
- package/latticeai/core/policy.py +1 -1
- package/latticeai/core/realtime.py +38 -15
- package/latticeai/core/sessions.py +7 -2
- package/latticeai/core/timeutil.py +10 -0
- package/latticeai/core/tool_registry.py +90 -18
- package/latticeai/core/users.py +1 -5
- package/latticeai/core/workspace_graph_trace.py +31 -5
- package/latticeai/core/workspace_memory.py +3 -1
- package/latticeai/core/workspace_os.py +18 -7
- package/latticeai/core/workspace_os_utils.py +0 -5
- package/latticeai/core/workspace_permissions.py +2 -1
- package/latticeai/core/workspace_plugins.py +1 -1
- package/latticeai/core/workspace_runs.py +14 -5
- package/latticeai/core/workspace_skills.py +1 -1
- package/latticeai/core/workspace_snapshots.py +2 -1
- package/latticeai/core/workspace_timeline.py +2 -1
- package/latticeai/integrations/telegram_bot.py +94 -43
- package/latticeai/models/router.py +130 -36
- package/latticeai/runtime/access_runtime.py +62 -4
- package/latticeai/runtime/automation_runtime.py +13 -7
- package/latticeai/runtime/brain_runtime.py +19 -7
- package/latticeai/runtime/chat_wiring.py +2 -0
- package/latticeai/runtime/config_runtime.py +58 -26
- package/latticeai/runtime/context_runtime.py +16 -4
- package/latticeai/runtime/hooks_runtime.py +1 -1
- package/latticeai/runtime/model_wiring.py +33 -5
- package/latticeai/runtime/namespace_runtime.py +62 -72
- package/latticeai/runtime/platform_runtime_wiring.py +4 -3
- package/latticeai/runtime/review_wiring.py +1 -1
- package/latticeai/runtime/router_registration.py +34 -1
- package/latticeai/runtime/security_runtime.py +110 -13
- package/latticeai/runtime/stages.py +27 -0
- package/latticeai/server_app.py +5 -1
- package/latticeai/services/architecture_readiness.py +74 -5
- package/latticeai/services/brain_automation.py +3 -26
- package/latticeai/services/chat_service.py +205 -15
- package/latticeai/services/local_knowledge.py +423 -0
- package/latticeai/services/memory_service.py +55 -21
- package/latticeai/services/model_engines.py +48 -33
- package/latticeai/services/model_errors.py +17 -0
- package/latticeai/services/model_loading.py +28 -25
- package/latticeai/services/model_runtime.py +228 -162
- package/latticeai/services/p_reinforce.py +22 -3
- package/latticeai/services/platform_runtime.py +83 -23
- package/latticeai/services/product_readiness.py +19 -17
- package/latticeai/services/review_queue.py +12 -0
- package/latticeai/services/router_context.py +1 -0
- package/latticeai/services/run_executor.py +4 -9
- package/latticeai/services/search_service.py +203 -28
- package/latticeai/services/tool_dispatch.py +28 -5
- package/latticeai/services/triggers.py +53 -4
- package/latticeai/services/upload_service.py +13 -2
- package/latticeai/setup/__init__.py +25 -0
- package/latticeai/setup/auto_setup.py +857 -0
- package/latticeai/setup/wizard.py +1264 -0
- package/latticeai/tools/__init__.py +278 -0
- package/{tools → latticeai/tools}/commands.py +67 -7
- package/{tools → latticeai/tools}/computer.py +1 -1
- package/{tools → latticeai/tools}/documents.py +1 -1
- package/{tools → latticeai/tools}/filesystem.py +3 -3
- package/latticeai/tools/knowledge.py +178 -0
- package/{tools → latticeai/tools}/local_files.py +1 -1
- package/{tools → latticeai/tools}/network.py +0 -1
- package/local_knowledge_api.py +4 -342
- package/package.json +22 -2
- package/scripts/brain_quality_eval.py +3 -1
- package/scripts/bump_version.py +3 -0
- package/scripts/capture_release_evidence.mjs +180 -0
- package/scripts/check_current_release_docs.mjs +142 -0
- package/scripts/check_i18n_literals.mjs +107 -31
- package/scripts/check_openapi_drift.mjs +56 -0
- package/scripts/export_openapi.py +67 -7
- package/scripts/i18n_literal_allowlist.json +22 -24
- package/scripts/run_integration_tests.mjs +72 -10
- package/scripts/validate_release_artifacts.py +49 -7
- package/scripts/wheel_smoke.py +5 -0
- package/setup_wizard.py +3 -1260
- 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 +11 -11
- package/static/app/assets/Act-Bzz0bUyW.js +1 -0
- package/static/app/assets/Brain-Dj2J20YA.js +321 -0
- package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
- package/static/app/assets/Library-B03FP1Yx.js +1 -0
- package/static/app/assets/System-80lHW0Ux.js +1 -0
- package/static/app/assets/index-BiMofBTM.js +17 -0
- package/static/app/assets/index-Bmx9rzTc.css +2 -0
- package/static/app/assets/primitives-Q1A96_7v.js +1 -0
- package/static/app/assets/textarea-D13RtnTo.js +1 -0
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/tools/__init__.py +21 -269
- package/docs/CODE_REVIEW_2026-07-06.md +0 -764
- package/latticeai/runtime/app_context_runtime.py +0 -13
- package/latticeai/runtime/tail_wiring.py +0 -21
- package/scripts/com.pts.claudecode.discord.plist +0 -31
- package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
- package/scripts/start-pts-claudecode-discord.sh +0 -51
- package/static/app/assets/Act-21lIXx2E.js +0 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
- package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
- package/static/app/assets/Library-bFMtyni3.js +0 -1
- package/static/app/assets/System-K6krGCqn.js +0 -1
- package/static/app/assets/index-C4R3ws30.js +0 -17
- package/static/app/assets/index-ChSeOB02.css +0 -2
- package/static/app/assets/primitives-sQU3it5I.js +0 -1
- package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
- package/tools/knowledge.py +0 -95
|
@@ -5,11 +5,10 @@ import json
|
|
|
5
5
|
from typing import Any, Dict, List, Optional
|
|
6
6
|
|
|
7
7
|
from lattice_brain.runtime.contracts import run_record_contract, workflow_run_contract
|
|
8
|
+
from lattice_brain.runtime.statuses import RUN_ACTIVE_STATUSES, RUN_TERMINAL_STATUSES
|
|
8
9
|
|
|
9
|
-
from .
|
|
10
|
-
|
|
11
|
-
RUN_ACTIVE_STATUSES = {"queued", "running", "in_progress", "retrying", "cancelling"}
|
|
12
|
-
RUN_TERMINAL_STATUSES = {"ok", "retried_ok", "failed", "rejected", "cancelled", "interrupted", "partial"}
|
|
10
|
+
from .timeutil import now_iso as _now
|
|
11
|
+
from .workspace_os_utils import _json_hash, _listify
|
|
13
12
|
|
|
14
13
|
|
|
15
14
|
class WorkspaceRuns:
|
|
@@ -80,6 +79,7 @@ class WorkspaceRuns:
|
|
|
80
79
|
f"{agent_id} {status}",
|
|
81
80
|
user_email=user_email,
|
|
82
81
|
source="workspace_os",
|
|
82
|
+
workspace_id=resolved_workspace,
|
|
83
83
|
metadata={"run_id": run["id"], "agent_id": agent_id, "status": status, "mode": mode},
|
|
84
84
|
)
|
|
85
85
|
run["graph_node_id"] = ingested.get("node_id")
|
|
@@ -167,6 +167,7 @@ class WorkspaceRuns:
|
|
|
167
167
|
f"{run.get('agent_id')} {status}",
|
|
168
168
|
user_email=run.get("user_email"),
|
|
169
169
|
source="workspace_os",
|
|
170
|
+
workspace_id=resolved_workspace,
|
|
170
171
|
metadata={
|
|
171
172
|
"run_id": run_id,
|
|
172
173
|
"agent_id": run.get("agent_id"),
|
|
@@ -250,6 +251,7 @@ class WorkspaceRuns:
|
|
|
250
251
|
workflow["name"],
|
|
251
252
|
user_email=user_email,
|
|
252
253
|
source="workspace_os",
|
|
254
|
+
workspace_id=workflow["workspace_id"],
|
|
253
255
|
metadata={"workflow_id": workflow["id"], "steps": steps},
|
|
254
256
|
)
|
|
255
257
|
workflow["graph_node_id"] = ingested.get("node_id")
|
|
@@ -257,7 +259,12 @@ class WorkspaceRuns:
|
|
|
257
259
|
workflow["graph_error"] = str(exc)
|
|
258
260
|
state.setdefault("workflows", []).append(workflow)
|
|
259
261
|
self.save_state(state)
|
|
260
|
-
self.record_timeline_event(
|
|
262
|
+
self.record_timeline_event(
|
|
263
|
+
"workflow",
|
|
264
|
+
"workflow_created",
|
|
265
|
+
{"workflow_id": workflow["id"], "name": workflow["name"]},
|
|
266
|
+
workspace_id=workflow["workspace_id"],
|
|
267
|
+
)
|
|
261
268
|
return workflow
|
|
262
269
|
|
|
263
270
|
def record_workflow_run(
|
|
@@ -304,6 +311,7 @@ class WorkspaceRuns:
|
|
|
304
311
|
f"{run['name']} {status}",
|
|
305
312
|
user_email=user_email,
|
|
306
313
|
source="workspace_os",
|
|
314
|
+
workspace_id=resolved_workspace,
|
|
307
315
|
metadata={"run_id": run["id"], "workflow_id": workflow_id, "status": status, "mode": mode},
|
|
308
316
|
)
|
|
309
317
|
run["graph_node_id"] = ingested.get("node_id")
|
|
@@ -380,6 +388,7 @@ class WorkspaceRuns:
|
|
|
380
388
|
f"{run.get('name')} {status}",
|
|
381
389
|
user_email=run.get("user_email"),
|
|
382
390
|
source="workspace_os",
|
|
391
|
+
workspace_id=resolved_workspace,
|
|
383
392
|
metadata={
|
|
384
393
|
"run_id": run_id,
|
|
385
394
|
"workflow_id": workflow_id,
|
|
@@ -10,7 +10,8 @@ from datetime import datetime
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import Any, Dict, Iterable, Optional
|
|
12
12
|
|
|
13
|
-
from .
|
|
13
|
+
from .timeutil import now_iso as _now
|
|
14
|
+
from .workspace_os_utils import _atomic_write_json, _json_hash, _listify, _safe_slug
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
class WorkspaceSnapshots:
|
|
@@ -6,7 +6,8 @@ from __future__ import annotations
|
|
|
6
6
|
|
|
7
7
|
from typing import Any, Dict, Iterable, List, Optional
|
|
8
8
|
|
|
9
|
-
from .
|
|
9
|
+
from .timeutil import now_iso as _now
|
|
10
|
+
from .workspace_os_utils import _listify, _parse_iso
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
def _audit_category(event: Dict[str, Any]) -> str:
|
|
@@ -5,11 +5,11 @@ import base64
|
|
|
5
5
|
import os
|
|
6
6
|
import socket
|
|
7
7
|
import tempfile
|
|
8
|
-
import time
|
|
9
8
|
import zipfile
|
|
10
9
|
import json
|
|
11
10
|
from pathlib import Path
|
|
12
11
|
|
|
12
|
+
from latticeai.core.io_utils import atomic_write_json
|
|
13
13
|
from latticeai.core.logging_safety import install_sensitive_log_filter, safe_log_text
|
|
14
14
|
from latticeai.cli.runtime import _load_env_file
|
|
15
15
|
|
|
@@ -43,7 +43,7 @@ AGENT_WORKSPACE = Path(env_value("LATTICEAI_AGENT_ROOT", "agent_workspace"
|
|
|
43
43
|
# Pending plan approvals: context_id → (chat_id, executing_model, reviewing_model)
|
|
44
44
|
_bot_pending_plans: dict[str, dict] = {}
|
|
45
45
|
MAX_TELEGRAM_FILE_BYTES = 45 * 1024 * 1024
|
|
46
|
-
INVITE_CODE = env_value("LATTICEAI_INVITE_CODE"
|
|
46
|
+
INVITE_CODE = env_value("LATTICEAI_INVITE_CODE")
|
|
47
47
|
PUBLIC_WEB_URL = env_value("LATTICEAI_PUBLIC_URL")
|
|
48
48
|
DATA_DIR = Path(env_value("LATTICEAI_DATA_DIR", str(Path.home() / ".ltcai")))
|
|
49
49
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
@@ -55,45 +55,56 @@ logger = logging.getLogger(__name__)
|
|
|
55
55
|
# ── Server session auth ───────────────────────────────────────────────────────
|
|
56
56
|
|
|
57
57
|
def _get_server_session() -> str:
|
|
58
|
-
"""
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if not sessions_file.exists():
|
|
66
|
-
return ""
|
|
67
|
-
sessions = json.loads(sessions_file.read_text())
|
|
68
|
-
admin_emails: set[str] = set()
|
|
69
|
-
if users_file.exists():
|
|
70
|
-
users = json.loads(users_file.read_text())
|
|
71
|
-
admin_emails = {e for e, u in users.items() if u.get("role") == "admin" and not u.get("disabled")}
|
|
72
|
-
now = time.time()
|
|
73
|
-
# Pick the newest non-expired admin session
|
|
74
|
-
best_token, best_ts = "", 0.0
|
|
75
|
-
for token, entry in sessions.items():
|
|
76
|
-
if len(token) == 64 and all(ch in "0123456789abcdef" for ch in token.lower()):
|
|
77
|
-
continue
|
|
78
|
-
email, created_at = entry[0], float(entry[1])
|
|
79
|
-
if admin_emails and email not in admin_emails:
|
|
80
|
-
continue
|
|
81
|
-
if now - created_at > 7 * 86400:
|
|
82
|
-
continue
|
|
83
|
-
if created_at > best_ts:
|
|
84
|
-
best_token, best_ts = token, created_at
|
|
85
|
-
return best_token
|
|
86
|
-
except Exception:
|
|
87
|
-
return ""
|
|
58
|
+
"""Return only the explicitly provisioned bot-to-server token.
|
|
59
|
+
|
|
60
|
+
Web sessions are hashed at rest and belong to interactive users. Scanning
|
|
61
|
+
``sessions.json`` cannot recover a usable token and would couple the bot to
|
|
62
|
+
whichever administrator happened to be logged in most recently.
|
|
63
|
+
"""
|
|
64
|
+
return env_value("LATTICEAI_SERVER_SESSION_TOKEN").strip()
|
|
88
65
|
|
|
89
66
|
def _server_client(**kwargs) -> httpx.AsyncClient:
|
|
90
|
-
"""
|
|
67
|
+
"""Return a server client authenticated by an explicit bearer capability."""
|
|
91
68
|
token = _get_server_session()
|
|
92
|
-
|
|
93
|
-
|
|
69
|
+
if not token:
|
|
70
|
+
raise RuntimeError(
|
|
71
|
+
"LATTICEAI_SERVER_SESSION_TOKEN is required for Telegram server API calls."
|
|
72
|
+
)
|
|
73
|
+
headers = dict(kwargs.pop("headers", {}) or {})
|
|
74
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
75
|
+
return httpx.AsyncClient(headers=headers, **kwargs)
|
|
94
76
|
|
|
95
77
|
# ── Chat ID registry ─────────────────────────────────────────────────────────
|
|
96
78
|
|
|
79
|
+
def parse_allowed_chat_ids(raw: str) -> frozenset[int]:
|
|
80
|
+
"""Parse a comma-separated Telegram chat-id allowlist."""
|
|
81
|
+
allowed: set[int] = set()
|
|
82
|
+
for value in str(raw or "").split(","):
|
|
83
|
+
value = value.strip()
|
|
84
|
+
if not value:
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
allowed.add(int(value))
|
|
88
|
+
except ValueError:
|
|
89
|
+
logger.warning(
|
|
90
|
+
"Invalid Telegram chat id ignored in allowlist: %r",
|
|
91
|
+
safe_log_text(value),
|
|
92
|
+
)
|
|
93
|
+
return frozenset(allowed)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def allowed_chat_ids() -> frozenset[int]:
|
|
97
|
+
"""Return the configured allowlist; missing configuration denies all."""
|
|
98
|
+
return parse_allowed_chat_ids(env_value("LATTICEAI_TELEGRAM_ALLOWED_CHAT_IDS"))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_chat_allowed(chat_id) -> bool:
|
|
102
|
+
try:
|
|
103
|
+
return int(chat_id) in allowed_chat_ids()
|
|
104
|
+
except (TypeError, ValueError):
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
97
108
|
def load_chat_ids():
|
|
98
109
|
try:
|
|
99
110
|
if CHAT_IDS_FILE.exists():
|
|
@@ -105,19 +116,24 @@ def load_chat_ids():
|
|
|
105
116
|
|
|
106
117
|
def save_chat_ids(chat_ids):
|
|
107
118
|
try:
|
|
108
|
-
CHAT_IDS_FILE
|
|
109
|
-
json.dumps({"chat_ids": sorted(chat_ids)}, ensure_ascii=False, indent=2),
|
|
110
|
-
encoding="utf-8",
|
|
111
|
-
)
|
|
119
|
+
atomic_write_json(CHAT_IDS_FILE, {"chat_ids": sorted(chat_ids)})
|
|
112
120
|
except Exception as e:
|
|
113
121
|
logger.error("텔레그램 채팅 목록 저장 실패: %s", safe_log_text(e))
|
|
114
122
|
|
|
115
123
|
def register_chat_id(chat_id):
|
|
124
|
+
if not is_chat_allowed(chat_id):
|
|
125
|
+
logger.warning(
|
|
126
|
+
"허용되지 않은 텔레그램 채팅 등록 차단: %s",
|
|
127
|
+
safe_log_text(chat_id),
|
|
128
|
+
)
|
|
129
|
+
return False
|
|
130
|
+
chat_id = int(chat_id)
|
|
116
131
|
chat_ids = load_chat_ids()
|
|
117
132
|
if chat_id not in chat_ids:
|
|
118
133
|
chat_ids.add(chat_id)
|
|
119
134
|
save_chat_ids(chat_ids)
|
|
120
135
|
logger.info("텔레그램 웹 미러링 대상 등록: %s", chat_id)
|
|
136
|
+
return True
|
|
121
137
|
|
|
122
138
|
# ── Telegram API helpers ──────────────────────────────────────────────────────
|
|
123
139
|
|
|
@@ -205,7 +221,8 @@ def get_lan_ip():
|
|
|
205
221
|
def get_web_url():
|
|
206
222
|
if PUBLIC_WEB_URL:
|
|
207
223
|
return PUBLIC_WEB_URL.rstrip("/")
|
|
208
|
-
|
|
224
|
+
base = f"http://{get_lan_ip()}:{SERVER_PORT}/"
|
|
225
|
+
return f"{base}?code={INVITE_CODE}" if INVITE_CODE else base
|
|
209
226
|
|
|
210
227
|
def get_graph_url():
|
|
211
228
|
if PUBLIC_WEB_URL:
|
|
@@ -217,7 +234,10 @@ def get_graph_url():
|
|
|
217
234
|
async def broadcast_web_chat(role, text):
|
|
218
235
|
if not TOKEN:
|
|
219
236
|
return
|
|
220
|
-
|
|
237
|
+
# Old releases registered every sender. Intersect persisted recipients
|
|
238
|
+
# with the current allowlist so stale files cannot keep receiving mirrors.
|
|
239
|
+
allowed = allowed_chat_ids()
|
|
240
|
+
chat_ids = load_chat_ids() & set(allowed)
|
|
221
241
|
if not chat_ids:
|
|
222
242
|
return
|
|
223
243
|
label = "사용자" if role == "user" else "Lattice AI"
|
|
@@ -730,6 +750,14 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
|
|
|
730
750
|
if len(parts) != 3:
|
|
731
751
|
return
|
|
732
752
|
_, action, context_id = parts
|
|
753
|
+
pending = _bot_pending_plans.get(context_id)
|
|
754
|
+
|
|
755
|
+
# A callback is bound to the chat that received the plan. Even two allowed
|
|
756
|
+
# chats cannot approve or cancel each other's plan by replaying callback
|
|
757
|
+
# data.
|
|
758
|
+
if pending and int(pending.get("chat_id")) != int(chat_id):
|
|
759
|
+
await send_message(client, chat_id, "❌ 다른 채팅의 작업은 처리할 수 없습니다.")
|
|
760
|
+
return
|
|
733
761
|
pending = _bot_pending_plans.pop(context_id, None)
|
|
734
762
|
|
|
735
763
|
if action == "cancel" or not pending:
|
|
@@ -870,10 +898,17 @@ async def handle_command(client, chat_id, command: str, args: str):
|
|
|
870
898
|
# ── Callback query handler ────────────────────────────────────────────────────
|
|
871
899
|
|
|
872
900
|
async def handle_callback_query(client, callback_query):
|
|
873
|
-
cq_id
|
|
874
|
-
chat_id =
|
|
901
|
+
cq_id = callback_query.get("id", "")
|
|
902
|
+
chat_id = (
|
|
903
|
+
(callback_query.get("message") or {}).get("chat") or {}
|
|
904
|
+
).get("id")
|
|
875
905
|
data = callback_query.get("data", "")
|
|
876
906
|
|
|
907
|
+
if not is_chat_allowed(chat_id):
|
|
908
|
+
logger.warning("허용되지 않은 텔레그램 callback 차단: %s", safe_log_text(chat_id))
|
|
909
|
+
await answer_callback(client, cq_id, "허용되지 않은 채팅입니다.")
|
|
910
|
+
return
|
|
911
|
+
|
|
877
912
|
await answer_callback(client, cq_id)
|
|
878
913
|
|
|
879
914
|
if data == "cmd:status":
|
|
@@ -907,6 +942,16 @@ async def run_bot():
|
|
|
907
942
|
if not TOKEN:
|
|
908
943
|
logger.warning("LATTICEAI_TELEGRAM_BOT_TOKEN이 설정되지 않아 텔레그램 봇을 시작하지 않습니다.")
|
|
909
944
|
return
|
|
945
|
+
if not allowed_chat_ids():
|
|
946
|
+
logger.error(
|
|
947
|
+
"LATTICEAI_TELEGRAM_ALLOWED_CHAT_IDS가 없어 텔레그램 봇을 시작하지 않습니다."
|
|
948
|
+
)
|
|
949
|
+
return
|
|
950
|
+
if not _get_server_session():
|
|
951
|
+
logger.error(
|
|
952
|
+
"LATTICEAI_SERVER_SESSION_TOKEN이 없어 텔레그램 봇을 시작하지 않습니다."
|
|
953
|
+
)
|
|
954
|
+
return
|
|
910
955
|
|
|
911
956
|
logger.info("🚀 비동기 텔레그램 봇 모드 시작!")
|
|
912
957
|
last_update_id = None
|
|
@@ -942,6 +987,12 @@ async def run_bot():
|
|
|
942
987
|
|
|
943
988
|
msg = update["message"]
|
|
944
989
|
chat_id = msg["chat"]["id"]
|
|
990
|
+
if not is_chat_allowed(chat_id):
|
|
991
|
+
logger.warning(
|
|
992
|
+
"허용되지 않은 텔레그램 메시지 차단: %s",
|
|
993
|
+
safe_log_text(chat_id),
|
|
994
|
+
)
|
|
995
|
+
continue
|
|
945
996
|
register_chat_id(chat_id)
|
|
946
997
|
text = msg.get("text", "")
|
|
947
998
|
caption = msg.get("caption", "")
|
|
@@ -261,11 +261,13 @@ class LLMRouter:
|
|
|
261
261
|
|
|
262
262
|
@property
|
|
263
263
|
def current_model_id(self) -> Optional[str]:
|
|
264
|
-
|
|
264
|
+
with self._lock:
|
|
265
|
+
return self._current
|
|
265
266
|
|
|
266
267
|
@property
|
|
267
268
|
def loaded_model_ids(self) -> List[str]:
|
|
268
|
-
|
|
269
|
+
with self._lock:
|
|
270
|
+
return list(self._cache.keys())
|
|
269
271
|
|
|
270
272
|
def switch_model(self, model_id: str) -> None:
|
|
271
273
|
with self._lock:
|
|
@@ -302,11 +304,12 @@ class LLMRouter:
|
|
|
302
304
|
return unloaded
|
|
303
305
|
|
|
304
306
|
def model_memory_policy(self) -> Dict[str, object]:
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
307
|
+
with self._lock:
|
|
308
|
+
return {
|
|
309
|
+
"max_local_models": self._max_local_models,
|
|
310
|
+
"loaded_count": len(self._cache),
|
|
311
|
+
"last_used": dict(self._last_used),
|
|
312
|
+
}
|
|
310
313
|
|
|
311
314
|
def _touch(self, model_id: Optional[str] = None) -> None:
|
|
312
315
|
model_id = model_id or self._current
|
|
@@ -478,7 +481,8 @@ class LLMRouter:
|
|
|
478
481
|
return items
|
|
479
482
|
|
|
480
483
|
def _is_cloud_current(self) -> bool:
|
|
481
|
-
|
|
484
|
+
with self._lock:
|
|
485
|
+
return bool(self._current and isinstance(self._cache.get(self._current), CloudModel))
|
|
482
486
|
|
|
483
487
|
def _local_server_error_hint(self, cloud: CloudModel, error: Exception) -> str:
|
|
484
488
|
raw = str(error)
|
|
@@ -532,28 +536,62 @@ class LLMRouter:
|
|
|
532
536
|
loader_kind = str(cached[3]) if len(cached) > 3 else "mlx_vlm"
|
|
533
537
|
return model, tokenizer, draft_model, loader_kind
|
|
534
538
|
|
|
535
|
-
|
|
536
|
-
"""
|
|
537
|
-
if not model_id or model_id == self._current:
|
|
538
|
-
return await self.generate(message, context, max_tokens, temperature)
|
|
539
|
-
if model_id not in self._cache:
|
|
540
|
-
raise ValueError(f"Model '{model_id}' is not loaded. Load it first via /models/load.")
|
|
541
|
-
prev = self._current
|
|
542
|
-
self._current = model_id
|
|
543
|
-
try:
|
|
544
|
-
return await self.generate(message, context, max_tokens, temperature)
|
|
545
|
-
finally:
|
|
546
|
-
self._current = prev
|
|
539
|
+
def _model_snapshot(self, model_id: Optional[str] = None) -> tuple[Optional[str], object | None]:
|
|
540
|
+
"""Return an immutable request-scoped view of a loaded model.
|
|
547
541
|
|
|
548
|
-
|
|
549
|
-
|
|
542
|
+
Generation must never change ``_current``: that value is a UI/default
|
|
543
|
+
preference shared by every request. Capturing the cache entry while
|
|
544
|
+
holding the registry lock prevents concurrent requests from selecting
|
|
545
|
+
or restoring each other's models.
|
|
546
|
+
"""
|
|
547
|
+
with self._lock:
|
|
548
|
+
selected = model_id or self._current
|
|
549
|
+
if not selected:
|
|
550
|
+
return None, None
|
|
551
|
+
cached = self._cache.get(selected)
|
|
552
|
+
if cached is None:
|
|
553
|
+
raise ValueError(f"Model '{selected}' is not loaded. Load it first via /models/load.")
|
|
554
|
+
self._touch(selected)
|
|
555
|
+
return selected, cached
|
|
556
|
+
|
|
557
|
+
async def generate_as(
|
|
558
|
+
self,
|
|
559
|
+
model_id: str | None,
|
|
560
|
+
message: str,
|
|
561
|
+
context: Optional[str] = None,
|
|
562
|
+
max_tokens: int = 4096,
|
|
563
|
+
temperature: float = 0.2,
|
|
564
|
+
image_data: Optional[str] = None,
|
|
565
|
+
) -> str:
|
|
566
|
+
"""Generate with a request-scoped model without changing the default."""
|
|
567
|
+
_selected, cached = self._model_snapshot(model_id)
|
|
568
|
+
if cached is None:
|
|
550
569
|
return "No model."
|
|
551
|
-
self.
|
|
552
|
-
|
|
570
|
+
return await self._generate_cached(cached, message, context, max_tokens, temperature, image_data)
|
|
571
|
+
|
|
572
|
+
async def generate(
|
|
573
|
+
self,
|
|
574
|
+
message: str,
|
|
575
|
+
context: Optional[str] = None,
|
|
576
|
+
max_tokens: int = 4096,
|
|
577
|
+
temperature: float = 0.2,
|
|
578
|
+
image_data: Optional[str] = None,
|
|
579
|
+
) -> str:
|
|
580
|
+
return await self.generate_as(None, message, context, max_tokens, temperature, image_data)
|
|
581
|
+
|
|
582
|
+
async def _generate_cached(
|
|
583
|
+
self,
|
|
584
|
+
cached: object,
|
|
585
|
+
message: str,
|
|
586
|
+
context: Optional[str],
|
|
587
|
+
max_tokens: int,
|
|
588
|
+
temperature: float,
|
|
589
|
+
image_data: Optional[str],
|
|
590
|
+
) -> str:
|
|
553
591
|
if isinstance(cached, CloudModel):
|
|
554
592
|
return await self._cloud_generate(cached, message, context, max_tokens, temperature)
|
|
555
593
|
|
|
556
|
-
model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(
|
|
594
|
+
model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(cached)
|
|
557
595
|
use_vlm = loader_kind == "mlx_vlm"
|
|
558
596
|
prompt = (
|
|
559
597
|
self._build_vlm_prompt(model, tokenizer, message, context, 1 if image_data else 0)
|
|
@@ -596,18 +634,26 @@ class LLMRouter:
|
|
|
596
634
|
raise RuntimeError(self._local_server_error_hint(cloud, e)) from e
|
|
597
635
|
return normalize_branding(response.choices[0].message.content or "")
|
|
598
636
|
|
|
599
|
-
async def
|
|
600
|
-
|
|
637
|
+
async def stream_generate_as(
|
|
638
|
+
self,
|
|
639
|
+
model_id: str | None,
|
|
640
|
+
message: str,
|
|
641
|
+
context: Optional[str] = None,
|
|
642
|
+
max_tokens: int = 4096,
|
|
643
|
+
temperature: float = 0.2,
|
|
644
|
+
image_data: Optional[str] = None,
|
|
645
|
+
) -> AsyncIterator[str]:
|
|
646
|
+
"""Stream with a request-scoped model without changing the default."""
|
|
647
|
+
_selected, cached = self._model_snapshot(model_id)
|
|
648
|
+
if cached is None:
|
|
601
649
|
yield "No model."
|
|
602
650
|
return
|
|
603
|
-
self._touch()
|
|
604
|
-
cached = self._cache[self._current]
|
|
605
651
|
if isinstance(cached, CloudModel):
|
|
606
652
|
async for chunk in self._cloud_stream_generate(cached, message, context, max_tokens, temperature):
|
|
607
653
|
yield chunk
|
|
608
654
|
return
|
|
609
655
|
|
|
610
|
-
model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(
|
|
656
|
+
model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(cached)
|
|
611
657
|
use_vlm = loader_kind == "mlx_vlm"
|
|
612
658
|
prompt = (
|
|
613
659
|
self._build_vlm_prompt(model, tokenizer, message, context, 1 if image_data else 0)
|
|
@@ -643,6 +689,19 @@ class LLMRouter:
|
|
|
643
689
|
break
|
|
644
690
|
yield normalize_branding(chunk)
|
|
645
691
|
|
|
692
|
+
async def stream_generate(
|
|
693
|
+
self,
|
|
694
|
+
message: str,
|
|
695
|
+
context: Optional[str] = None,
|
|
696
|
+
max_tokens: int = 4096,
|
|
697
|
+
temperature: float = 0.2,
|
|
698
|
+
image_data: Optional[str] = None,
|
|
699
|
+
) -> AsyncIterator[str]:
|
|
700
|
+
async for chunk in self.stream_generate_as(
|
|
701
|
+
None, message, context, max_tokens, temperature, image_data
|
|
702
|
+
):
|
|
703
|
+
yield chunk
|
|
704
|
+
|
|
646
705
|
async def _cloud_stream_generate(self, cloud: CloudModel, message: str, context: Optional[str], max_tokens: int, temperature: float) -> AsyncIterator[str]:
|
|
647
706
|
system = SYSTEM_PROMPT
|
|
648
707
|
context = normalize_branding(context)
|
|
@@ -691,10 +750,27 @@ class LLMRouter:
|
|
|
691
750
|
temperature: float = 0.3,
|
|
692
751
|
) -> str:
|
|
693
752
|
"""Generate a document using a specialized system prompt with graph context."""
|
|
694
|
-
|
|
753
|
+
return await self.generate_document_as(
|
|
754
|
+
None,
|
|
755
|
+
message,
|
|
756
|
+
system_prompt,
|
|
757
|
+
max_tokens=max_tokens,
|
|
758
|
+
temperature=temperature,
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
async def generate_document_as(
|
|
762
|
+
self,
|
|
763
|
+
model_id: str | None,
|
|
764
|
+
message: str,
|
|
765
|
+
system_prompt: str,
|
|
766
|
+
*,
|
|
767
|
+
max_tokens: int = 8192,
|
|
768
|
+
temperature: float = 0.3,
|
|
769
|
+
) -> str:
|
|
770
|
+
"""Generate a document with a request-scoped model."""
|
|
771
|
+
_selected, cached = self._model_snapshot(model_id)
|
|
772
|
+
if cached is None:
|
|
695
773
|
return "No model loaded."
|
|
696
|
-
self._touch()
|
|
697
|
-
cached = self._cache[self._current]
|
|
698
774
|
|
|
699
775
|
if isinstance(cached, CloudModel):
|
|
700
776
|
return await self._cloud_generate_document(cached, message, system_prompt, max_tokens, temperature)
|
|
@@ -750,11 +826,29 @@ class LLMRouter:
|
|
|
750
826
|
temperature: float = 0.3,
|
|
751
827
|
) -> AsyncIterator[str]:
|
|
752
828
|
"""Stream document generation with specialized system prompt."""
|
|
753
|
-
|
|
829
|
+
async for chunk in self.stream_generate_document_as(
|
|
830
|
+
None,
|
|
831
|
+
message,
|
|
832
|
+
system_prompt,
|
|
833
|
+
max_tokens=max_tokens,
|
|
834
|
+
temperature=temperature,
|
|
835
|
+
):
|
|
836
|
+
yield chunk
|
|
837
|
+
|
|
838
|
+
async def stream_generate_document_as(
|
|
839
|
+
self,
|
|
840
|
+
model_id: str | None,
|
|
841
|
+
message: str,
|
|
842
|
+
system_prompt: str,
|
|
843
|
+
*,
|
|
844
|
+
max_tokens: int = 8192,
|
|
845
|
+
temperature: float = 0.3,
|
|
846
|
+
) -> AsyncIterator[str]:
|
|
847
|
+
"""Stream a document with a request-scoped model."""
|
|
848
|
+
_selected, cached = self._model_snapshot(model_id)
|
|
849
|
+
if cached is None:
|
|
754
850
|
yield "No model loaded."
|
|
755
851
|
return
|
|
756
|
-
self._touch()
|
|
757
|
-
cached = self._cache[self._current]
|
|
758
852
|
|
|
759
853
|
if isinstance(cached, CloudModel):
|
|
760
854
|
async for chunk in self._cloud_stream_document(cached, message, system_prompt, max_tokens, temperature):
|
|
@@ -20,9 +20,23 @@ def build_access_runtime(
|
|
|
20
20
|
from latticeai.core.policy import normalize_role, require_capability
|
|
21
21
|
from latticeai.core.users import normalize_email
|
|
22
22
|
|
|
23
|
+
# The historical loopback/no-auth profile represents its single human as
|
|
24
|
+
# an empty identity. Keep that storage/workspace compatibility contract,
|
|
25
|
+
# but project the identity as an owner at authorization boundaries. Never
|
|
26
|
+
# extend this trust to public or non-loopback bindings, even if an invalid
|
|
27
|
+
# caller constructs this runtime with ``require_auth=False`` directly.
|
|
28
|
+
externally_reachable = bool(
|
|
29
|
+
getattr(config, "is_public", False)
|
|
30
|
+
or getattr(config, "network_exposed", False)
|
|
31
|
+
)
|
|
32
|
+
trusted_local_owner = not require_auth and not externally_reachable
|
|
33
|
+
effective_require_auth = bool(require_auth or externally_reachable)
|
|
34
|
+
|
|
23
35
|
def get_user_role(email: str, users: Optional[Dict] = None) -> str:
|
|
24
36
|
users = users or load_users()
|
|
25
37
|
identity = str(email or "")
|
|
38
|
+
if trusted_local_owner and not identity:
|
|
39
|
+
return "owner"
|
|
26
40
|
normalized_email = normalize_email(identity)
|
|
27
41
|
user = users.get(normalized_email) or users.get(identity) or next(
|
|
28
42
|
(
|
|
@@ -46,25 +60,69 @@ def build_access_runtime(
|
|
|
46
60
|
return auth[7:].strip()
|
|
47
61
|
return request.cookies.get("session_token")
|
|
48
62
|
|
|
63
|
+
def active_session_email(identity: Optional[str], users: Dict) -> Optional[str]:
|
|
64
|
+
"""Resolve a session identity only while its account remains active."""
|
|
65
|
+
if not identity:
|
|
66
|
+
return None
|
|
67
|
+
raw_identity = str(identity)
|
|
68
|
+
normalized_email = normalize_email(raw_identity)
|
|
69
|
+
matched_key: Optional[str] = None
|
|
70
|
+
user = users.get(normalized_email)
|
|
71
|
+
if isinstance(user, dict):
|
|
72
|
+
matched_key = normalized_email
|
|
73
|
+
else:
|
|
74
|
+
user = users.get(raw_identity)
|
|
75
|
+
if isinstance(user, dict):
|
|
76
|
+
matched_key = raw_identity
|
|
77
|
+
else:
|
|
78
|
+
for key, item in users.items():
|
|
79
|
+
if isinstance(item, dict) and item.get("id") == raw_identity:
|
|
80
|
+
matched_key = str(key)
|
|
81
|
+
user = item
|
|
82
|
+
break
|
|
83
|
+
# A session for a deleted account, malformed account record, or an
|
|
84
|
+
# explicitly disabled account is invalid immediately. This check is
|
|
85
|
+
# intentionally performed on every request so stale bearer/cookie
|
|
86
|
+
# tokens cannot retain user or administrator access.
|
|
87
|
+
if not matched_key or not isinstance(user, dict) or bool(user.get("disabled", False)):
|
|
88
|
+
return None
|
|
89
|
+
return normalize_email(matched_key)
|
|
90
|
+
|
|
49
91
|
def get_current_user(request: request_type) -> Optional[str]:
|
|
50
92
|
token = extract_bearer_token(request)
|
|
51
93
|
if token:
|
|
52
|
-
|
|
94
|
+
try:
|
|
95
|
+
return active_session_email(get_session_email(token), load_users())
|
|
96
|
+
except Exception:
|
|
97
|
+
# Account-store failures must fail closed rather than turning a
|
|
98
|
+
# stale session into an authenticated identity.
|
|
99
|
+
return None
|
|
53
100
|
return None
|
|
54
101
|
|
|
55
102
|
def require_user(request: request_type) -> str:
|
|
56
103
|
email = get_current_user(request)
|
|
57
|
-
if
|
|
104
|
+
if email:
|
|
105
|
+
# Optional authentication remains meaningful in local mode: a
|
|
106
|
+
# valid session keeps its real account identity instead of being
|
|
107
|
+
# collapsed into the anonymous Local User fallback.
|
|
108
|
+
return email
|
|
109
|
+
if trusted_local_owner:
|
|
110
|
+
# A no-auth loopback caller is the trusted, anonymous local owner.
|
|
111
|
+
# Returning the legacy empty identity keeps ownerless workspaces,
|
|
112
|
+
# shared local vaults, and Local User profile behavior compatible;
|
|
113
|
+
# get_user_role() supplies the explicit owner authorization role.
|
|
114
|
+
return ""
|
|
115
|
+
if effective_require_auth and not email:
|
|
58
116
|
raise http_exception(status_code=401, detail="인증이 필요합니다.")
|
|
59
117
|
return email or ""
|
|
60
118
|
|
|
61
119
|
def require_admin(request: request_type) -> tuple[str, Dict]:
|
|
62
120
|
users = load_users()
|
|
63
|
-
if
|
|
121
|
+
if trusted_local_owner:
|
|
64
122
|
return "", users
|
|
65
123
|
token = extract_bearer_token(request)
|
|
66
124
|
if token:
|
|
67
|
-
email = get_session_email(token)
|
|
125
|
+
email = active_session_email(get_session_email(token), users)
|
|
68
126
|
if email:
|
|
69
127
|
role = get_user_role(email, users)
|
|
70
128
|
try:
|