ltcai 4.6.1 → 4.7.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 +60 -37
- package/docs/CHANGELOG.md +61 -0
- package/docs/PRODUCT_DIRECTION_REVIEW.md +88 -0
- package/docs/V4_7_0_ADMIN_SEPARATION_REPORT.md +42 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +20 -19
- package/frontend/src/App.tsx +449 -6
- package/frontend/src/api/client.ts +2 -0
- package/frontend/src/components/ProductFlow.tsx +28 -5
- package/frontend/src/styles.css +620 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +86 -13
- package/lattice_brain/portability.py +82 -14
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +30 -4
- package/latticeai/api/chat.py +25 -11
- package/latticeai/app_factory.py +8 -2
- package/latticeai/core/audit.py +3 -2
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/package.json +1 -1
- package/scripts/launch-pts-grok.sh +56 -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 +5 -5
- package/static/app/assets/{index-7U86v70r.css → index-DFmuiJ6t.css} +1 -1
- package/static/app/assets/index-DwX3rNfA.js +16 -0
- package/static/app/assets/index-DwX3rNfA.js.map +1 -0
- package/static/app/index.html +2 -2
- package/static/app/assets/index-D1jAPQws.js +0 -16
- package/static/app/assets/index-D1jAPQws.js.map +0 -1
package/lattice_brain/archive.py
CHANGED
|
@@ -50,6 +50,10 @@ def _now() -> str:
|
|
|
50
50
|
return datetime.now(timezone.utc).isoformat()
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def _stamp() -> str:
|
|
54
|
+
return _now().replace(":", "").replace("-", "").replace(".", "")[:15]
|
|
55
|
+
|
|
56
|
+
|
|
53
57
|
def _derive_key(passphrase: str, salt: bytes) -> bytes:
|
|
54
58
|
if not passphrase:
|
|
55
59
|
raise ValueError("A passphrase is required for encrypted .latticebrain archives.")
|
|
@@ -93,6 +97,78 @@ def _assert_safe_member(name: str) -> PurePosixPath:
|
|
|
93
97
|
return path
|
|
94
98
|
|
|
95
99
|
|
|
100
|
+
def _pre_restore_backup_dir(anchor: Path) -> Path:
|
|
101
|
+
backup_dir = anchor.parent / f"{anchor.name}.pre-restore-{_stamp()}"
|
|
102
|
+
index = 1
|
|
103
|
+
while backup_dir.exists():
|
|
104
|
+
backup_dir = anchor.parent / f"{anchor.name}.pre-restore-{_stamp()}-{index}"
|
|
105
|
+
index += 1
|
|
106
|
+
backup_dir.mkdir(parents=True)
|
|
107
|
+
return backup_dir
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _sqlite_siblings(db_path: Path) -> tuple[Path, Path, Path]:
|
|
111
|
+
return (db_path, Path(str(db_path) + "-wal"), Path(str(db_path) + "-shm"))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _restore_sibling(path: Path, backup: Path) -> None:
|
|
115
|
+
if backup.exists():
|
|
116
|
+
shutil.copy2(backup, path)
|
|
117
|
+
elif path.exists():
|
|
118
|
+
path.unlink()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _replace_sqlite_atomically(src: Path, dest: Path, backup_dir: Path) -> None:
|
|
122
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
tmp = dest.parent / f".{dest.name}.restore-{_stamp()}-{os.getpid()}.tmp"
|
|
124
|
+
shutil.copyfile(src, tmp)
|
|
125
|
+
backups: dict[Path, Path] = {}
|
|
126
|
+
try:
|
|
127
|
+
for sibling in _sqlite_siblings(dest):
|
|
128
|
+
if sibling.exists():
|
|
129
|
+
backup = backup_dir / sibling.name
|
|
130
|
+
shutil.copy2(sibling, backup)
|
|
131
|
+
backups[sibling] = backup
|
|
132
|
+
for sibling in _sqlite_siblings(dest)[1:]:
|
|
133
|
+
if sibling.exists():
|
|
134
|
+
sibling.unlink()
|
|
135
|
+
os.replace(tmp, dest)
|
|
136
|
+
except Exception:
|
|
137
|
+
if tmp.exists():
|
|
138
|
+
tmp.unlink()
|
|
139
|
+
for sibling in _sqlite_siblings(dest):
|
|
140
|
+
_restore_sibling(sibling, backups.get(sibling, backup_dir / sibling.name))
|
|
141
|
+
raise
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _rollback_sqlite_from_backup(dest: Path, backup_dir: Path) -> None:
|
|
145
|
+
for sibling in _sqlite_siblings(dest):
|
|
146
|
+
_restore_sibling(sibling, backup_dir / sibling.name)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _replace_tree_with_backup(src: Optional[Path], dest: Path, backup_dir: Path) -> None:
|
|
150
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
staged = dest.parent / f".{dest.name}.restore-{_stamp()}-{os.getpid()}"
|
|
152
|
+
backup = backup_dir / dest.name
|
|
153
|
+
if src and src.exists():
|
|
154
|
+
shutil.copytree(src, staged)
|
|
155
|
+
else:
|
|
156
|
+
staged.mkdir(parents=True)
|
|
157
|
+
try:
|
|
158
|
+
if dest.exists():
|
|
159
|
+
shutil.copytree(dest, backup)
|
|
160
|
+
shutil.rmtree(dest)
|
|
161
|
+
os.replace(staged, dest)
|
|
162
|
+
except Exception:
|
|
163
|
+
if staged.exists():
|
|
164
|
+
shutil.rmtree(staged)
|
|
165
|
+
if dest.exists():
|
|
166
|
+
shutil.rmtree(dest)
|
|
167
|
+
if backup.exists():
|
|
168
|
+
shutil.copytree(backup, dest)
|
|
169
|
+
raise
|
|
170
|
+
|
|
171
|
+
|
|
96
172
|
@dataclass(frozen=True)
|
|
97
173
|
class BrainArchivePaths:
|
|
98
174
|
db_path: Path
|
|
@@ -397,19 +473,15 @@ class EncryptedBrainArchive:
|
|
|
397
473
|
db_src = out / "knowledge_graph.sqlite"
|
|
398
474
|
if not db_src.exists():
|
|
399
475
|
raise ValueError("Archive payload is missing knowledge_graph.sqlite.")
|
|
400
|
-
target.db_path
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
if blobs_src.exists():
|
|
410
|
-
shutil.copytree(blobs_src, target.blob_dir)
|
|
411
|
-
else:
|
|
412
|
-
target.blob_dir.mkdir(parents=True, exist_ok=True)
|
|
476
|
+
backup_dir = _pre_restore_backup_dir(target.db_path)
|
|
477
|
+
try:
|
|
478
|
+
_replace_sqlite_atomically(db_src, target.db_path, backup_dir)
|
|
479
|
+
blobs_src = out / "blobs"
|
|
480
|
+
if target.blob_dir:
|
|
481
|
+
_replace_tree_with_backup(blobs_src if blobs_src.exists() else None, target.blob_dir, backup_dir)
|
|
482
|
+
except Exception:
|
|
483
|
+
_rollback_sqlite_from_backup(target.db_path, backup_dir)
|
|
484
|
+
raise
|
|
413
485
|
if target.data_dir:
|
|
414
486
|
data_src = out / "data"
|
|
415
487
|
exports_src = out / "workspace_exports"
|
|
@@ -439,6 +511,7 @@ class EncryptedBrainArchive:
|
|
|
439
511
|
"path": str(target.db_path),
|
|
440
512
|
"encrypted": True,
|
|
441
513
|
"verified": True,
|
|
514
|
+
"pre_restore_backup": str(backup_dir),
|
|
442
515
|
"manifest": manifest,
|
|
443
516
|
}
|
|
444
517
|
|
|
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import hashlib
|
|
17
17
|
import json
|
|
18
|
+
import os
|
|
18
19
|
import shutil
|
|
19
20
|
import tempfile
|
|
20
21
|
import zipfile
|
|
@@ -57,6 +58,78 @@ def _safe_zip_names(names) -> None:
|
|
|
57
58
|
raise ValueError(f"Backup archive contains unsafe path: {name}")
|
|
58
59
|
|
|
59
60
|
|
|
61
|
+
def _pre_restore_backup_dir(anchor: Path) -> Path:
|
|
62
|
+
backup_dir = anchor.parent / f"{anchor.name}.pre-restore-{_stamp()}"
|
|
63
|
+
index = 1
|
|
64
|
+
while backup_dir.exists():
|
|
65
|
+
backup_dir = anchor.parent / f"{anchor.name}.pre-restore-{_stamp()}-{index}"
|
|
66
|
+
index += 1
|
|
67
|
+
backup_dir.mkdir(parents=True)
|
|
68
|
+
return backup_dir
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _sqlite_siblings(db_path: Path) -> tuple[Path, Path, Path]:
|
|
72
|
+
return (db_path, Path(str(db_path) + "-wal"), Path(str(db_path) + "-shm"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _restore_sibling(path: Path, backup: Path) -> None:
|
|
76
|
+
if backup.exists():
|
|
77
|
+
shutil.copy2(backup, path)
|
|
78
|
+
elif path.exists():
|
|
79
|
+
path.unlink()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _replace_sqlite_atomically(src: Path, dest: Path, backup_dir: Path) -> None:
|
|
83
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
tmp = dest.parent / f".{dest.name}.restore-{_stamp()}-{os.getpid()}.tmp"
|
|
85
|
+
shutil.copyfile(src, tmp)
|
|
86
|
+
backups: dict[Path, Path] = {}
|
|
87
|
+
try:
|
|
88
|
+
for sibling in _sqlite_siblings(dest):
|
|
89
|
+
if sibling.exists():
|
|
90
|
+
backup = backup_dir / sibling.name
|
|
91
|
+
shutil.copy2(sibling, backup)
|
|
92
|
+
backups[sibling] = backup
|
|
93
|
+
for sibling in _sqlite_siblings(dest)[1:]:
|
|
94
|
+
if sibling.exists():
|
|
95
|
+
sibling.unlink()
|
|
96
|
+
os.replace(tmp, dest)
|
|
97
|
+
except Exception:
|
|
98
|
+
if tmp.exists():
|
|
99
|
+
tmp.unlink()
|
|
100
|
+
for sibling in _sqlite_siblings(dest):
|
|
101
|
+
_restore_sibling(sibling, backups.get(sibling, backup_dir / sibling.name))
|
|
102
|
+
raise
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _rollback_sqlite_from_backup(dest: Path, backup_dir: Path) -> None:
|
|
106
|
+
for sibling in _sqlite_siblings(dest):
|
|
107
|
+
_restore_sibling(sibling, backup_dir / sibling.name)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _replace_tree_with_backup(src: Optional[Path], dest: Path, backup_dir: Path) -> None:
|
|
111
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
staged = dest.parent / f".{dest.name}.restore-{_stamp()}-{os.getpid()}"
|
|
113
|
+
backup = backup_dir / dest.name
|
|
114
|
+
if src and src.exists():
|
|
115
|
+
shutil.copytree(src, staged)
|
|
116
|
+
else:
|
|
117
|
+
staged.mkdir(parents=True)
|
|
118
|
+
try:
|
|
119
|
+
if dest.exists():
|
|
120
|
+
shutil.copytree(dest, backup)
|
|
121
|
+
shutil.rmtree(dest)
|
|
122
|
+
os.replace(staged, dest)
|
|
123
|
+
except Exception:
|
|
124
|
+
if staged.exists():
|
|
125
|
+
shutil.rmtree(staged)
|
|
126
|
+
if dest.exists():
|
|
127
|
+
shutil.rmtree(dest)
|
|
128
|
+
if backup.exists():
|
|
129
|
+
shutil.copytree(backup, dest)
|
|
130
|
+
raise
|
|
131
|
+
|
|
132
|
+
|
|
60
133
|
class KGPortabilityService:
|
|
61
134
|
def __init__(self, *, knowledge_graph: Any, data_dir, enable_graph: bool = True, device_identity: Any = None) -> None:
|
|
62
135
|
self._kg = knowledge_graph
|
|
@@ -204,24 +277,19 @@ class KGPortabilityService:
|
|
|
204
277
|
}
|
|
205
278
|
db_dest = Path(self._kg.db_path)
|
|
206
279
|
blob_dest = Path(self._kg.blob_dir)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if blob_src.exists():
|
|
216
|
-
if blob_dest.exists():
|
|
217
|
-
shutil.rmtree(blob_dest)
|
|
218
|
-
shutil.copytree(blob_src, blob_dest)
|
|
219
|
-
else:
|
|
220
|
-
blob_dest.mkdir(parents=True, exist_ok=True)
|
|
280
|
+
backup_dir = _pre_restore_backup_dir(db_dest)
|
|
281
|
+
try:
|
|
282
|
+
_replace_sqlite_atomically(db_src, db_dest, backup_dir)
|
|
283
|
+
blob_src = tmp / "blobs"
|
|
284
|
+
_replace_tree_with_backup(blob_src if blob_src.exists() else None, blob_dest, backup_dir)
|
|
285
|
+
except Exception:
|
|
286
|
+
_rollback_sqlite_from_backup(db_dest, backup_dir)
|
|
287
|
+
raise
|
|
221
288
|
stats = self._kg.stats()
|
|
222
289
|
return {
|
|
223
290
|
"restored": True,
|
|
224
291
|
"manifest": manifest,
|
|
292
|
+
"pre_restore_backup": str(backup_dir),
|
|
225
293
|
"nodes": sum(stats.get("nodes", {}).values()),
|
|
226
294
|
}
|
|
227
295
|
|
|
@@ -14,7 +14,7 @@ from datetime import datetime
|
|
|
14
14
|
from typing import Any, Callable, Dict, List, Optional
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
MULTI_AGENT_VERSION = "4.
|
|
17
|
+
MULTI_AGENT_VERSION = "4.7.0"
|
|
18
18
|
|
|
19
19
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
20
20
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
package/latticeai/__init__.py
CHANGED
package/latticeai/api/admin.py
CHANGED
|
@@ -7,6 +7,8 @@ from typing import Callable, Dict, List, Optional
|
|
|
7
7
|
from fastapi import APIRouter, HTTPException, Request
|
|
8
8
|
from pydantic import BaseModel
|
|
9
9
|
|
|
10
|
+
from latticeai.core.workspace_os import DEFAULT_WORKSPACE_ID
|
|
11
|
+
|
|
10
12
|
|
|
11
13
|
class AdminUserUpdate(BaseModel):
|
|
12
14
|
role: Optional[str] = None
|
|
@@ -42,6 +44,7 @@ def create_admin_router(
|
|
|
42
44
|
save_users: Callable,
|
|
43
45
|
get_user_role: Callable,
|
|
44
46
|
get_history: Callable,
|
|
47
|
+
get_audit_log: Callable,
|
|
45
48
|
public_user: Callable,
|
|
46
49
|
load_vpc_config: Callable,
|
|
47
50
|
save_vpc_config: Callable,
|
|
@@ -60,10 +63,33 @@ def create_admin_router(
|
|
|
60
63
|
) -> APIRouter:
|
|
61
64
|
router = APIRouter()
|
|
62
65
|
|
|
66
|
+
def _workspace_scope(request: Request) -> Optional[str]:
|
|
67
|
+
header = request.headers.get("X-Workspace-Id")
|
|
68
|
+
if header and header.strip():
|
|
69
|
+
return header.strip()
|
|
70
|
+
query = request.query_params.get("workspace_id")
|
|
71
|
+
return query.strip() if query and query.strip() else None
|
|
72
|
+
|
|
73
|
+
def _matches_scope(item: Dict[str, object], workspace_id: Optional[str]) -> bool:
|
|
74
|
+
if not workspace_id:
|
|
75
|
+
return True
|
|
76
|
+
item_scope = item.get("workspace_id")
|
|
77
|
+
if not item_scope and workspace_id == DEFAULT_WORKSPACE_ID:
|
|
78
|
+
return True
|
|
79
|
+
return str(item_scope or "") == str(workspace_id)
|
|
80
|
+
|
|
81
|
+
def _scoped_history(request: Request) -> List[Dict]:
|
|
82
|
+
scope = _workspace_scope(request)
|
|
83
|
+
return [item for item in get_history() if _matches_scope(item, scope)]
|
|
84
|
+
|
|
85
|
+
def _scoped_audit_log(request: Request) -> List[Dict]:
|
|
86
|
+
scope = _workspace_scope(request)
|
|
87
|
+
return [item for item in get_audit_log() if _matches_scope(item, scope)]
|
|
88
|
+
|
|
63
89
|
@router.get("/admin/summary")
|
|
64
90
|
async def admin_summary(request: Request):
|
|
65
91
|
_, users = require_admin(request)
|
|
66
|
-
history =
|
|
92
|
+
history = _scoped_history(request)
|
|
67
93
|
user_msgs = [i for i in history if i.get("role") == "user"]
|
|
68
94
|
asst_msgs = [i for i in history if i.get("role") == "assistant"]
|
|
69
95
|
return {
|
|
@@ -79,7 +105,7 @@ def create_admin_router(
|
|
|
79
105
|
@router.get("/admin/stats")
|
|
80
106
|
async def admin_stats(request: Request):
|
|
81
107
|
require_admin(request)
|
|
82
|
-
history =
|
|
108
|
+
history = _scoped_history(request)
|
|
83
109
|
daily: dict = defaultdict(lambda: {"user": 0, "assistant": 0})
|
|
84
110
|
for item in history:
|
|
85
111
|
ts = item.get("timestamp", "")
|
|
@@ -98,12 +124,12 @@ def create_admin_router(
|
|
|
98
124
|
@router.get("/admin/sensitivity")
|
|
99
125
|
async def admin_sensitivity(request: Request):
|
|
100
126
|
require_admin(request)
|
|
101
|
-
return build_sensitivity_report(
|
|
127
|
+
return build_sensitivity_report(_scoped_history(request))
|
|
102
128
|
|
|
103
129
|
@router.get("/admin/audit")
|
|
104
130
|
async def admin_audit(request: Request):
|
|
105
131
|
_, users = require_admin(request)
|
|
106
|
-
report = build_admin_audit_report(users)
|
|
132
|
+
report = build_admin_audit_report(users, _scoped_audit_log(request))
|
|
107
133
|
try:
|
|
108
134
|
report["graph"] = get_graph_stats() if enable_graph else {"disabled": True}
|
|
109
135
|
except Exception as e:
|
package/latticeai/api/chat.py
CHANGED
|
@@ -52,6 +52,7 @@ class AgentRequest(BaseModel):
|
|
|
52
52
|
temperature: float = 0.1
|
|
53
53
|
user_email: Optional[str] = None
|
|
54
54
|
user_nickname: Optional[str] = None
|
|
55
|
+
workspace_id: Optional[str] = None
|
|
55
56
|
planning_model: Optional[str] = None
|
|
56
57
|
executing_model: Optional[str] = None
|
|
57
58
|
reviewing_model: Optional[str] = None
|
|
@@ -136,6 +137,13 @@ def format_network_status(info: Dict) -> str:
|
|
|
136
137
|
lines.extend(["", note])
|
|
137
138
|
return "\n".join(lines)
|
|
138
139
|
|
|
140
|
+
def workspace_scope_from_request(request: Request) -> Optional[str]:
|
|
141
|
+
header = request.headers.get("X-Workspace-Id")
|
|
142
|
+
if header and header.strip():
|
|
143
|
+
return header.strip()
|
|
144
|
+
query = request.query_params.get("workspace_id")
|
|
145
|
+
return query.strip() if query and query.strip() else None
|
|
146
|
+
|
|
139
147
|
async def single_text_stream(text: str, model: str = "system") -> AsyncIterator[str]:
|
|
140
148
|
yield f"data: {json.dumps({'chunk': text, 'model': model}, ensure_ascii=False)}\n\n"
|
|
141
149
|
yield "data: [DONE]\n\n"
|
|
@@ -296,15 +304,20 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
296
304
|
)
|
|
297
305
|
effective_email = req.user_email or current_user or None
|
|
298
306
|
history_user = get_history_user(effective_email, req.user_nickname)
|
|
307
|
+
history_meta = {
|
|
308
|
+
"source": req.source or "web",
|
|
309
|
+
"conversation_id": req.conversation_id,
|
|
310
|
+
"workspace_id": workspace_scope_from_request(request),
|
|
311
|
+
}
|
|
299
312
|
|
|
300
313
|
if is_network_status_request(req.message):
|
|
301
314
|
history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
302
|
-
save_to_history("user", history_message,
|
|
315
|
+
save_to_history("user", history_message, **history_meta, **history_user)
|
|
303
316
|
try:
|
|
304
317
|
answer = format_network_status(network_status())
|
|
305
318
|
except ToolError as exc:
|
|
306
319
|
answer = f"네트워크 정보를 확인하지 못했습니다: {exc}"
|
|
307
|
-
save_to_history("assistant", answer,
|
|
320
|
+
save_to_history("assistant", answer, **history_meta, **history_user)
|
|
308
321
|
notify_chat_message("user", req.message, req.source)
|
|
309
322
|
notify_chat_message("assistant", answer, req.source)
|
|
310
323
|
if req.stream:
|
|
@@ -362,8 +375,8 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
362
375
|
|
|
363
376
|
if is_current_url_request(req.message) and req.client_url:
|
|
364
377
|
answer = f"현재 페이지 URL: {req.client_url}"
|
|
365
|
-
save_to_history("user", req.message,
|
|
366
|
-
save_to_history("assistant", answer,
|
|
378
|
+
save_to_history("user", req.message, **history_meta, **history_user)
|
|
379
|
+
save_to_history("assistant", answer, **history_meta, **history_user)
|
|
367
380
|
notify_chat_message("user", req.message, req.source)
|
|
368
381
|
notify_chat_message("assistant", answer, req.source)
|
|
369
382
|
if req.stream:
|
|
@@ -459,7 +472,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
459
472
|
trace_seed["context_assembly"] = context_trace
|
|
460
473
|
|
|
461
474
|
history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
462
|
-
save_to_history("user", history_message,
|
|
475
|
+
save_to_history("user", history_message, **history_meta, **history_user)
|
|
463
476
|
notify_chat_message("user", req.message, req.source)
|
|
464
477
|
|
|
465
478
|
if is_doc_gen and ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
@@ -488,7 +501,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
488
501
|
yield f"data: {json.dumps({'text': footnote}, ensure_ascii=False)}\n\n"
|
|
489
502
|
full_text += footnote
|
|
490
503
|
session.update(graph_md, full_text, req.conversation_id)
|
|
491
|
-
save_to_history("assistant", full_text,
|
|
504
|
+
save_to_history("assistant", full_text, **history_meta, **history_user)
|
|
492
505
|
trace_record = CHAT_SERVICE.record_trace(
|
|
493
506
|
question=req.message,
|
|
494
507
|
response=full_text,
|
|
@@ -513,7 +526,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
513
526
|
if footnote:
|
|
514
527
|
result += footnote
|
|
515
528
|
session.update(graph_md, result, req.conversation_id)
|
|
516
|
-
save_to_history("assistant", str(result),
|
|
529
|
+
save_to_history("assistant", str(result), **history_meta, **history_user)
|
|
517
530
|
trace_record = CHAT_SERVICE.record_trace(
|
|
518
531
|
question=req.message,
|
|
519
532
|
response=str(result),
|
|
@@ -549,7 +562,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
549
562
|
|
|
550
563
|
result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
|
|
551
564
|
|
|
552
|
-
save_to_history("assistant", str(result),
|
|
565
|
+
save_to_history("assistant", str(result), **history_meta, **history_user)
|
|
553
566
|
trace_record = CHAT_SERVICE.record_trace(
|
|
554
567
|
question=req.message,
|
|
555
568
|
response=str(result),
|
|
@@ -652,7 +665,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
652
665
|
full_response += str(clean_chunk)
|
|
653
666
|
yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
|
|
654
667
|
history_user = get_history_user(req.user_email, req.user_nickname)
|
|
655
|
-
save_to_history("assistant", full_response,
|
|
668
|
+
save_to_history("assistant", full_response, **history_meta, **history_user)
|
|
656
669
|
trace_record = CHAT_SERVICE.record_trace(
|
|
657
670
|
question=req.message,
|
|
658
671
|
response=full_response,
|
|
@@ -728,6 +741,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
728
741
|
"""
|
|
729
742
|
current_user = require_user(request)
|
|
730
743
|
enforce_rate_limit(current_user, "agent")
|
|
744
|
+
req.workspace_id = req.workspace_id or workspace_scope_from_request(request)
|
|
731
745
|
if not router.current_model_id:
|
|
732
746
|
raise HTTPException(status_code=400, detail="No model loaded. Call /models/load first.")
|
|
733
747
|
|
|
@@ -776,8 +790,8 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
776
790
|
asyncio.create_task(_AGENT_RUNTIME.memory_update(ctx, req, current_user))
|
|
777
791
|
|
|
778
792
|
message = ctx.final_message or "작업을 완료했습니다."
|
|
779
|
-
save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id)
|
|
780
|
-
save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id)
|
|
793
|
+
save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
|
|
794
|
+
save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
|
|
781
795
|
try:
|
|
782
796
|
WORKSPACE_OS.record_agent_run(
|
|
783
797
|
agent_id="agent:executor",
|
package/latticeai/app_factory.py
CHANGED
|
@@ -15,7 +15,7 @@ lazily via module ``__getattr__`` for backwards compatibility.
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
17
|
import threading
|
|
18
|
-
from typing import TYPE_CHECKING, Any, Dict, Optional
|
|
18
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
19
19
|
|
|
20
20
|
if TYPE_CHECKING: # imports for annotations only — keep module import light
|
|
21
21
|
from fastapi import FastAPI
|
|
@@ -704,6 +704,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
704
704
|
user_nickname: Optional[str] = None,
|
|
705
705
|
source: Optional[str] = None,
|
|
706
706
|
conversation_id: Optional[str] = None,
|
|
707
|
+
workspace_id: Optional[str] = None,
|
|
707
708
|
):
|
|
708
709
|
try:
|
|
709
710
|
message = redact_secret_text(message)
|
|
@@ -718,6 +719,8 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
718
719
|
item["source"] = source
|
|
719
720
|
if conversation_id:
|
|
720
721
|
item["conversation_id"] = conversation_id
|
|
722
|
+
if workspace_id:
|
|
723
|
+
item["workspace_id"] = workspace_id
|
|
721
724
|
sensitive = classify_sensitive_message(item, -1)
|
|
722
725
|
append_audit_event(
|
|
723
726
|
"chat_message",
|
|
@@ -726,6 +729,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
726
729
|
user_nickname=user_nickname,
|
|
727
730
|
source=source,
|
|
728
731
|
conversation_id=conversation_id,
|
|
732
|
+
workspace_id=workspace_id,
|
|
729
733
|
content_preview=sensitive.get("preview"),
|
|
730
734
|
content_chars=len(message or ""),
|
|
731
735
|
sensitivity=sensitive.get("sensitivity"),
|
|
@@ -1036,7 +1040,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
1036
1040
|
return _build_sensitivity_report(history)
|
|
1037
1041
|
|
|
1038
1042
|
# ── Admin audit report — delegated to latticeai.core.audit ───────────────────
|
|
1039
|
-
def build_admin_audit_report(users: Dict) -> Dict:
|
|
1043
|
+
def build_admin_audit_report(users: Dict, audit_events: Optional[List[Dict]] = None) -> Dict:
|
|
1040
1044
|
graph_stats = None
|
|
1041
1045
|
try:
|
|
1042
1046
|
if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
@@ -1047,6 +1051,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
1047
1051
|
AUDIT_FILE, users,
|
|
1048
1052
|
get_user_role=get_user_role,
|
|
1049
1053
|
graph_stats=graph_stats,
|
|
1054
|
+
audit_events=audit_events,
|
|
1050
1055
|
)
|
|
1051
1056
|
|
|
1052
1057
|
router = LLMRouter()
|
|
@@ -1268,6 +1273,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
1268
1273
|
require_admin=require_admin, require_user=require_user,
|
|
1269
1274
|
load_users=load_users, save_users=save_users,
|
|
1270
1275
|
get_user_role=get_user_role, get_history=get_history,
|
|
1276
|
+
get_audit_log=get_audit_log,
|
|
1271
1277
|
public_user=public_user, load_vpc_config=load_vpc_config,
|
|
1272
1278
|
save_vpc_config=save_vpc_config,
|
|
1273
1279
|
build_admin_audit_report=build_admin_audit_report,
|
package/latticeai/core/audit.py
CHANGED
|
@@ -141,8 +141,9 @@ def build_admin_audit_report(
|
|
|
141
141
|
*,
|
|
142
142
|
get_user_role: Callable[[str, Optional[Dict]], str],
|
|
143
143
|
graph_stats: Optional[Dict] = None,
|
|
144
|
+
audit_events: Optional[List[Dict]] = None,
|
|
144
145
|
) -> Dict:
|
|
145
|
-
events = get_audit_log(audit_file)
|
|
146
|
+
events = audit_events if audit_events is not None else get_audit_log(audit_file)
|
|
146
147
|
|
|
147
148
|
def _user_bucket(email: Optional[str], nickname: Optional[str] = None) -> Dict:
|
|
148
149
|
user = users.get(email or "", {})
|
|
@@ -234,7 +235,7 @@ def build_admin_audit_report(
|
|
|
234
235
|
def _public_audit_event(event: Dict) -> Dict:
|
|
235
236
|
allowed = {
|
|
236
237
|
"event_type", "timestamp", "role", "user_email", "user_nickname", "source",
|
|
237
|
-
"conversation_id", "command", "scope", "target_email", "filename", "mime_type",
|
|
238
|
+
"conversation_id", "workspace_id", "command", "scope", "target_email", "filename", "mime_type",
|
|
238
239
|
"ext", "bytes", "extracted_chars", "graph_node", "keep_last", "removed", "kept",
|
|
239
240
|
"started_at", "sensitivity", "sensitive_labels", "content_preview", "content_chars",
|
|
240
241
|
}
|
|
@@ -19,7 +19,7 @@ from pathlib import Path
|
|
|
19
19
|
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
WORKSPACE_OS_VERSION = "4.
|
|
22
|
+
WORKSPACE_OS_VERSION = "4.7.0"
|
|
23
23
|
|
|
24
24
|
# Workspace types separate single-user Personal workspaces from shared
|
|
25
25
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ltcai",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.7.0",
|
|
4
4
|
"description": "Lattice AI — local-first Living Brain workspace (conversation, durable memory, hybrid search, agents, advanced graph exploration, portable encrypted brain archives)",
|
|
5
5
|
"homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
|
|
6
6
|
"repository": {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/bin/zsh
|
|
2
|
+
# Launch a dedicated Grok TUI session wired to the "pts_grok" Discord bot.
|
|
3
|
+
# This lets you collaborate with pts_openclaw and pts_claudecode in the shared channel
|
|
4
|
+
# by having them @mention pts_grok (or you mentioning it).
|
|
5
|
+
#
|
|
6
|
+
# Prerequisites:
|
|
7
|
+
# 1. Create a brand new Discord Application + Bot in https://discord.com/developers/applications
|
|
8
|
+
# - Name the bot exactly "pts_grok" (for easy @mentions).
|
|
9
|
+
# - Enable "Message Content Intent" under Privileged Gateway Intents.
|
|
10
|
+
# - Generate / Reset the Bot token (copy it once).
|
|
11
|
+
# 2. Invite the new bot to your server using OAuth2 URL Generator (bot scope + View Channels,
|
|
12
|
+
# Send Messages, Send Messages in Threads, Read Message History, Attach Files, Add Reactions).
|
|
13
|
+
# 3. Put the token into ~/.grok/channels/discord-pts-grok/.env as DISCORD_BOT_TOKEN=...
|
|
14
|
+
# 4. After creating the bot, note its User ID (right-click the bot in Discord → Copy User ID, with Dev Mode on).
|
|
15
|
+
# Then add that ID to the other agents' botAllowFrom lists (and they will add yours).
|
|
16
|
+
#
|
|
17
|
+
# Usage:
|
|
18
|
+
# cd ~/Downloads/Lattice\ AI
|
|
19
|
+
# ./scripts/launch-pts-grok.sh
|
|
20
|
+
#
|
|
21
|
+
# In that new terminal session, the "discord" MCP tools will be bound to pts_grok.
|
|
22
|
+
# Mentioning pts_grok from pts_openclaw (or the human) in channel 1506662093309608026
|
|
23
|
+
# will be delivered here.
|
|
24
|
+
|
|
25
|
+
set -euo pipefail
|
|
26
|
+
|
|
27
|
+
export DISCORD_STATE_DIR="$HOME/.grok/channels/discord-pts-grok"
|
|
28
|
+
|
|
29
|
+
# Safety: ensure the secret file has sane permissions
|
|
30
|
+
chmod 600 "$DISCORD_STATE_DIR/.env" 2>/dev/null || true
|
|
31
|
+
|
|
32
|
+
# Make sure the token is present and looks real (not just empty or placeholder)
|
|
33
|
+
token_line=$(grep '^DISCORD_BOT_TOKEN=' "$DISCORD_STATE_DIR/.env" 2>/dev/null | tail -1 || true)
|
|
34
|
+
token_value="${token_line#DISCORD_BOT_TOKEN=}"
|
|
35
|
+
|
|
36
|
+
if [[ -z "$token_value" || ${#token_value} -lt 40 ]]; then
|
|
37
|
+
echo "ERROR: DISCORD_BOT_TOKEN is missing or too short in $DISCORD_STATE_DIR/.env"
|
|
38
|
+
echo "Edit it first with the real token from Discord Developer Portal → Your pts_grok bot → Reset Token."
|
|
39
|
+
echo "Example line: DISCORD_BOT_TOKEN=MTxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
|
40
|
+
exit 1
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
# Quick sanity so we don't launch with the placeholder comment
|
|
44
|
+
if echo "$token_value" | grep -qi 'PUT_YOUR\|MASKED\|edit me'; then
|
|
45
|
+
echo "ERROR: The token in $DISCORD_STATE_DIR/.env still contains placeholder text."
|
|
46
|
+
echo "Replace it with the actual bot token."
|
|
47
|
+
exit 1
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
cd "$(dirname "$0")/.."
|
|
51
|
+
|
|
52
|
+
echo "Launching Grok as pts_grok (state: $DISCORD_STATE_DIR)"
|
|
53
|
+
echo "Make sure the 'discord' MCP / plugin is enabled (use /mcps or /plugins in the TUI if needed)."
|
|
54
|
+
echo
|
|
55
|
+
|
|
56
|
+
exec "$HOME/.grok/bin/grok" "$@"
|
package/src-tauri/Cargo.lock
CHANGED
package/src-tauri/Cargo.toml
CHANGED