ltcai 0.1.3 → 0.1.8
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 +121 -0
- package/docs/OPERATIONS.md +149 -0
- package/knowledge_graph.py +802 -0
- package/ltcai_cli.py +45 -1
- package/package.json +15 -3
- package/requirements.txt +2 -0
- package/server.py +818 -39
- package/skills/SKILL_TEMPLATE.md +57 -0
- package/skills/code_review/SKILL.md +76 -0
- package/skills/data_analysis/SKILL.md +79 -0
- package/skills/file_edit/SKILL.md +68 -0
- package/skills/web_search/SKILL.md +74 -0
- package/static/account.html +74 -2
- package/static/admin.html +225 -6
- package/static/chat.html +886 -147
- package/static/graph.html +612 -0
- package/static/icons/apple-touch-icon.png +0 -0
- package/static/icons/favicon-32.png +0 -0
- package/static/icons/icon-192.png +0 -0
- package/static/icons/icon-512.png +0 -0
- package/static/manifest.json +35 -0
- package/static/sw.js +51 -0
- package/telegram_bot.py +631 -217
- package/tests/__init__.py +0 -0
- package/tests/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/test_api.py +94 -0
- package/tests/unit/__init__.py +0 -0
- package/tests/unit/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tests/unit/__pycache__/test_tools.cpython-314-pytest-9.0.3.pyc +0 -0
- package/tests/unit/test_tools.py +127 -0
- package/tools.py +169 -13
package/server.py
CHANGED
|
@@ -40,6 +40,7 @@ from pydantic import BaseModel
|
|
|
40
40
|
from PIL import Image
|
|
41
41
|
|
|
42
42
|
from llm_router import AsyncOpenAI, LLMRouter, OPENAI_COMPATIBLE_PROVIDERS, parse_model_ref, mx, normalize_branding
|
|
43
|
+
from knowledge_graph import KnowledgeGraphStore
|
|
43
44
|
from p_reinforce import BRAIN_DIR, PReinforceGardener
|
|
44
45
|
from setup import get_recommendations, install_stream, open_url, scan_environment
|
|
45
46
|
from telegram_bot import broadcast_web_chat
|
|
@@ -165,6 +166,7 @@ IS_PUBLIC_MODE = APP_MODE == "public"
|
|
|
165
166
|
DEFAULT_HOST = env_value("LATTICEAI_HOST", "127.0.0.1")
|
|
166
167
|
DEFAULT_PORT = int(env_value("LATTICEAI_PORT", "4825"))
|
|
167
168
|
ENABLE_TELEGRAM = env_bool("LATTICEAI_ENABLE_TELEGRAM", default=not IS_PUBLIC_MODE)
|
|
169
|
+
ENABLE_GRAPH = env_bool("LATTICEAI_ENABLE_GRAPH", default=True)
|
|
168
170
|
AUTOLOAD_MODELS = env_bool("LATTICEAI_AUTOLOAD_MODELS", default=IS_PUBLIC_MODE)
|
|
169
171
|
MODEL_IDLE_UNLOAD_SECONDS = int(env_value("LATTICEAI_MODEL_IDLE_UNLOAD_SECONDS", "0"))
|
|
170
172
|
ALLOW_LOCAL_MODELS = env_bool("LATTICEAI_ALLOW_LOCAL_MODELS", default=not IS_PUBLIC_MODE)
|
|
@@ -175,6 +177,31 @@ PUBLIC_MODEL = env_value("LATTICEAI_PUBLIC_MODEL", env_value("LATTICEAI_DEFAULT_
|
|
|
175
177
|
LOCAL_MODEL = env_value("LATTICEAI_LOCAL_MODEL", "mlx-community/gemma-4-26b-a4b-it-4bit")
|
|
176
178
|
LOCAL_DRAFT_MODEL = env_value("LATTICEAI_LOCAL_DRAFT_MODEL", "")
|
|
177
179
|
|
|
180
|
+
# ── SSO / OIDC config ─────────────────────────────────────────────────────────
|
|
181
|
+
SSO_DISCOVERY_URL = env_value("OIDC_DISCOVERY_URL", "")
|
|
182
|
+
SSO_CLIENT_ID = env_value("OIDC_CLIENT_ID", "")
|
|
183
|
+
SSO_CLIENT_SECRET = env_value("OIDC_CLIENT_SECRET", "")
|
|
184
|
+
SSO_REDIRECT_URI = env_value("OIDC_REDIRECT_URI", "http://localhost:4825/auth/sso/callback")
|
|
185
|
+
SSO_PROVIDER_NAME = env_value("OIDC_PROVIDER_NAME", "SSO")
|
|
186
|
+
_sso_discovery_cache: Optional[Dict] = None
|
|
187
|
+
_sso_states: Dict[str, float] = {} # state → timestamp (CSRF protection)
|
|
188
|
+
|
|
189
|
+
async def _get_sso_discovery() -> Optional[Dict]:
|
|
190
|
+
global _sso_discovery_cache
|
|
191
|
+
if _sso_discovery_cache:
|
|
192
|
+
return _sso_discovery_cache
|
|
193
|
+
if not SSO_DISCOVERY_URL:
|
|
194
|
+
return None
|
|
195
|
+
try:
|
|
196
|
+
import httpx as _httpx
|
|
197
|
+
async with _httpx.AsyncClient() as c:
|
|
198
|
+
r = await c.get(SSO_DISCOVERY_URL, timeout=10)
|
|
199
|
+
_sso_discovery_cache = r.json()
|
|
200
|
+
except Exception as e:
|
|
201
|
+
logging.warning("SSO discovery failed: %s", e)
|
|
202
|
+
return None
|
|
203
|
+
return _sso_discovery_cache
|
|
204
|
+
|
|
178
205
|
# ── Password hashing (stdlib scrypt, no extra deps) ────────────────────────────
|
|
179
206
|
def hash_password(password: str) -> str:
|
|
180
207
|
salt = secrets.token_hex(16)
|
|
@@ -199,27 +226,54 @@ def verify_and_migrate_password(email: str, plain: str, stored: str, users: Dict
|
|
|
199
226
|
return True
|
|
200
227
|
return False
|
|
201
228
|
|
|
202
|
-
# ── Session store (
|
|
229
|
+
# ── Session store (file-backed, survives restarts) ────────────────────────────
|
|
203
230
|
_SESSION_TTL = 60 * 60 * 24 * 7 # 7 days
|
|
204
|
-
|
|
231
|
+
_sessions_lock = threading.Lock()
|
|
232
|
+
|
|
233
|
+
def _sessions_file() -> Path:
|
|
234
|
+
return DATA_DIR / "sessions.json"
|
|
235
|
+
|
|
236
|
+
def _load_sessions() -> Dict[str, tuple]:
|
|
237
|
+
try:
|
|
238
|
+
f = _sessions_file()
|
|
239
|
+
if f.exists():
|
|
240
|
+
raw = json.loads(f.read_text())
|
|
241
|
+
return {k: tuple(v) for k, v in raw.items()}
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
return {}
|
|
245
|
+
|
|
246
|
+
def _persist_sessions(sessions: Dict[str, tuple]) -> None:
|
|
247
|
+
try:
|
|
248
|
+
_sessions_file().write_text(json.dumps({k: list(v) for k, v in sessions.items()}, ensure_ascii=False))
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
_sessions: Dict[str, tuple] = _load_sessions()
|
|
205
253
|
|
|
206
254
|
def create_session(email: str) -> str:
|
|
207
255
|
token = secrets.token_urlsafe(32)
|
|
208
|
-
|
|
256
|
+
with _sessions_lock:
|
|
257
|
+
_sessions[token] = (email, time.time())
|
|
258
|
+
_persist_sessions(_sessions)
|
|
209
259
|
return token
|
|
210
260
|
|
|
211
261
|
def get_session_email(token: str) -> Optional[str]:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
262
|
+
with _sessions_lock:
|
|
263
|
+
entry = _sessions.get(token)
|
|
264
|
+
if entry is None:
|
|
265
|
+
return None
|
|
266
|
+
email, created_at = entry
|
|
267
|
+
if time.time() - created_at > _SESSION_TTL:
|
|
268
|
+
_sessions.pop(token, None)
|
|
269
|
+
_persist_sessions(_sessions)
|
|
270
|
+
return None
|
|
271
|
+
return email
|
|
220
272
|
|
|
221
273
|
def invalidate_session(token: str) -> None:
|
|
222
|
-
|
|
274
|
+
with _sessions_lock:
|
|
275
|
+
_sessions.pop(token, None)
|
|
276
|
+
_persist_sessions(_sessions)
|
|
223
277
|
|
|
224
278
|
# ── User Management Logic ──────────────────────────────────────────────────
|
|
225
279
|
BASE_DIR = Path(__file__).resolve().parent
|
|
@@ -235,6 +289,12 @@ USERS_FILE = DATA_DIR / "users.json"
|
|
|
235
289
|
HISTORY_FILE = DATA_DIR / "chat_history.json"
|
|
236
290
|
VPC_FILE = DATA_DIR / "vpc_config.json"
|
|
237
291
|
MCP_FILE = DATA_DIR / "mcp_installs.json"
|
|
292
|
+
AUDIT_FILE = DATA_DIR / "audit_log.json"
|
|
293
|
+
KNOWLEDGE_GRAPH = KnowledgeGraphStore(DATA_DIR / "knowledge_graph.sqlite", DATA_DIR / "knowledge_graph_blobs") if ENABLE_GRAPH else None
|
|
294
|
+
|
|
295
|
+
def _require_graph():
|
|
296
|
+
if not ENABLE_GRAPH or KNOWLEDGE_GRAPH is None:
|
|
297
|
+
raise HTTPException(status_code=404, detail="Data Graph is disabled. Set LATTICEAI_ENABLE_GRAPH=true in .env to enable.")
|
|
238
298
|
|
|
239
299
|
class UserRegister(BaseModel):
|
|
240
300
|
email: str
|
|
@@ -267,6 +327,17 @@ class McpRecommendRequest(BaseModel):
|
|
|
267
327
|
class McpInstallRequest(BaseModel):
|
|
268
328
|
mcp_id: str
|
|
269
329
|
|
|
330
|
+
class KnowledgeGraphIngestRequest(BaseModel):
|
|
331
|
+
type: str
|
|
332
|
+
content: str = ""
|
|
333
|
+
role: Optional[str] = None
|
|
334
|
+
title: Optional[str] = None
|
|
335
|
+
source: Optional[str] = None
|
|
336
|
+
conversation_id: Optional[str] = None
|
|
337
|
+
user_email: Optional[str] = None
|
|
338
|
+
user_nickname: Optional[str] = None
|
|
339
|
+
metadata: Optional[Dict] = None
|
|
340
|
+
|
|
270
341
|
DEFAULT_VPC_CONFIG = {
|
|
271
342
|
"provider": "AWS",
|
|
272
343
|
"region": "ap-northeast-2",
|
|
@@ -675,6 +746,36 @@ def connector_info(mcp_id: str) -> Dict:
|
|
|
675
746
|
|
|
676
747
|
_history_lock = threading.Lock()
|
|
677
748
|
|
|
749
|
+
def get_audit_log() -> List[Dict]:
|
|
750
|
+
if not os.path.exists(AUDIT_FILE):
|
|
751
|
+
return []
|
|
752
|
+
try:
|
|
753
|
+
with open(AUDIT_FILE, "r", encoding="utf-8") as f:
|
|
754
|
+
data = json.load(f)
|
|
755
|
+
return data if isinstance(data, list) else []
|
|
756
|
+
except Exception as e:
|
|
757
|
+
logging.warning("get_audit_log failed: %s", e)
|
|
758
|
+
return []
|
|
759
|
+
|
|
760
|
+
def append_audit_event(event_type: str, **payload) -> None:
|
|
761
|
+
try:
|
|
762
|
+
event = {
|
|
763
|
+
"event_type": event_type,
|
|
764
|
+
"timestamp": datetime.now().isoformat(),
|
|
765
|
+
**payload,
|
|
766
|
+
}
|
|
767
|
+
with _history_lock:
|
|
768
|
+
events = get_audit_log()
|
|
769
|
+
events.append(event)
|
|
770
|
+
if len(events) > 5000:
|
|
771
|
+
events = events[-5000:]
|
|
772
|
+
tmp_path = str(AUDIT_FILE) + ".tmp"
|
|
773
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
774
|
+
json.dump(events, f, ensure_ascii=False, indent=2)
|
|
775
|
+
os.replace(tmp_path, AUDIT_FILE)
|
|
776
|
+
except Exception as e:
|
|
777
|
+
logging.warning("append_audit_event failed: %s", e)
|
|
778
|
+
|
|
678
779
|
def save_to_history(
|
|
679
780
|
role: str,
|
|
680
781
|
message: str,
|
|
@@ -696,6 +797,19 @@ def save_to_history(
|
|
|
696
797
|
item["source"] = source
|
|
697
798
|
if conversation_id:
|
|
698
799
|
item["conversation_id"] = conversation_id
|
|
800
|
+
sensitive = classify_sensitive_message(item, -1)
|
|
801
|
+
append_audit_event(
|
|
802
|
+
"chat_message",
|
|
803
|
+
role=role,
|
|
804
|
+
user_email=user_email,
|
|
805
|
+
user_nickname=user_nickname,
|
|
806
|
+
source=source,
|
|
807
|
+
conversation_id=conversation_id,
|
|
808
|
+
content_preview=sensitive.get("preview"),
|
|
809
|
+
content_chars=len(message or ""),
|
|
810
|
+
sensitivity=sensitive.get("sensitivity"),
|
|
811
|
+
sensitive_labels=sensitive.get("labels") or [],
|
|
812
|
+
)
|
|
699
813
|
with _history_lock:
|
|
700
814
|
history = []
|
|
701
815
|
if os.path.exists(HISTORY_FILE):
|
|
@@ -708,6 +822,19 @@ def save_to_history(
|
|
|
708
822
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
709
823
|
json.dump(history, f, ensure_ascii=False, indent=2)
|
|
710
824
|
os.replace(tmp_path, HISTORY_FILE)
|
|
825
|
+
try:
|
|
826
|
+
if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
827
|
+
KNOWLEDGE_GRAPH.ingest_message(
|
|
828
|
+
role,
|
|
829
|
+
message,
|
|
830
|
+
user_email=user_email,
|
|
831
|
+
user_nickname=user_nickname,
|
|
832
|
+
source=source,
|
|
833
|
+
conversation_id=conversation_id,
|
|
834
|
+
raw=item,
|
|
835
|
+
)
|
|
836
|
+
except Exception as graph_error:
|
|
837
|
+
logging.warning("knowledge graph message ingest failed: %s", graph_error)
|
|
711
838
|
except Exception as e:
|
|
712
839
|
logging.warning("save_to_history failed: %s", e)
|
|
713
840
|
|
|
@@ -922,11 +1049,11 @@ def require_user(request: Request) -> str:
|
|
|
922
1049
|
return email or ""
|
|
923
1050
|
|
|
924
1051
|
def require_admin(request: Request) -> tuple[str, Dict]:
|
|
1052
|
+
users = load_users()
|
|
925
1053
|
token = _extract_bearer_token(request)
|
|
926
1054
|
if token:
|
|
927
1055
|
email = get_session_email(token)
|
|
928
1056
|
if email:
|
|
929
|
-
users = load_users()
|
|
930
1057
|
if get_user_role(email, users) == "admin":
|
|
931
1058
|
return email, users
|
|
932
1059
|
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
|
|
@@ -1099,6 +1226,136 @@ def build_sensitivity_report(history: List[Dict]) -> Dict:
|
|
|
1099
1226
|
"compliance_fields": compliant_items[-30:],
|
|
1100
1227
|
}
|
|
1101
1228
|
|
|
1229
|
+
AUDIT_DELETE_EVENTS = {"conversation_delete", "history_delete", "user_delete"}
|
|
1230
|
+
|
|
1231
|
+
def _audit_user_bucket(email: Optional[str], nickname: Optional[str] = None, users: Optional[Dict] = None) -> Dict:
|
|
1232
|
+
user = (users or {}).get(email or "", {})
|
|
1233
|
+
return {
|
|
1234
|
+
"email": email or "Unknown",
|
|
1235
|
+
"nickname": nickname or user.get("nickname") or user.get("name") or email or "Unknown",
|
|
1236
|
+
"role": get_user_role(email, users or {}) if email else "unknown",
|
|
1237
|
+
"disabled": bool(user.get("disabled")) if user else False,
|
|
1238
|
+
"user_messages": 0,
|
|
1239
|
+
"assistant_messages": 0,
|
|
1240
|
+
"document_uploads": 0,
|
|
1241
|
+
"clear_events": 0,
|
|
1242
|
+
"delete_events": 0,
|
|
1243
|
+
"sensitive_events": 0,
|
|
1244
|
+
"high_sensitive_events": 0,
|
|
1245
|
+
"total_content_chars": 0,
|
|
1246
|
+
"last_activity_at": None,
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
def _public_audit_event(event: Dict) -> Dict:
|
|
1250
|
+
allowed = {
|
|
1251
|
+
"event_type",
|
|
1252
|
+
"timestamp",
|
|
1253
|
+
"role",
|
|
1254
|
+
"user_email",
|
|
1255
|
+
"user_nickname",
|
|
1256
|
+
"source",
|
|
1257
|
+
"conversation_id",
|
|
1258
|
+
"command",
|
|
1259
|
+
"scope",
|
|
1260
|
+
"target_email",
|
|
1261
|
+
"filename",
|
|
1262
|
+
"mime_type",
|
|
1263
|
+
"ext",
|
|
1264
|
+
"bytes",
|
|
1265
|
+
"extracted_chars",
|
|
1266
|
+
"graph_node",
|
|
1267
|
+
"keep_last",
|
|
1268
|
+
"removed",
|
|
1269
|
+
"kept",
|
|
1270
|
+
"started_at",
|
|
1271
|
+
"sensitivity",
|
|
1272
|
+
"sensitive_labels",
|
|
1273
|
+
"content_preview",
|
|
1274
|
+
"content_chars",
|
|
1275
|
+
}
|
|
1276
|
+
return {key: event.get(key) for key in allowed if key in event}
|
|
1277
|
+
|
|
1278
|
+
def build_admin_audit_report(users: Dict) -> Dict:
|
|
1279
|
+
events = get_audit_log()
|
|
1280
|
+
per_user: Dict[str, Dict] = {}
|
|
1281
|
+
|
|
1282
|
+
def ensure_user(email: Optional[str], nickname: Optional[str] = None) -> Dict:
|
|
1283
|
+
key = email or nickname or "Unknown"
|
|
1284
|
+
if key not in per_user:
|
|
1285
|
+
per_user[key] = _audit_user_bucket(email, nickname, users)
|
|
1286
|
+
elif nickname and per_user[key].get("nickname") in {"Unknown", email, None}:
|
|
1287
|
+
per_user[key]["nickname"] = nickname
|
|
1288
|
+
return per_user[key]
|
|
1289
|
+
|
|
1290
|
+
for email, user in users.items():
|
|
1291
|
+
ensure_user(email, user.get("nickname") or user.get("name"))
|
|
1292
|
+
|
|
1293
|
+
summary = {
|
|
1294
|
+
"total_events": len(events),
|
|
1295
|
+
"chat_events": 0,
|
|
1296
|
+
"user_messages": 0,
|
|
1297
|
+
"assistant_messages": 0,
|
|
1298
|
+
"document_uploads": 0,
|
|
1299
|
+
"clear_events": 0,
|
|
1300
|
+
"delete_events": 0,
|
|
1301
|
+
"sensitive_events": 0,
|
|
1302
|
+
"high_sensitive_events": 0,
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
sensitive_events = []
|
|
1306
|
+
deletion_events = []
|
|
1307
|
+
for event in events:
|
|
1308
|
+
event_type = event.get("event_type")
|
|
1309
|
+
email = event.get("user_email")
|
|
1310
|
+
user = ensure_user(email, event.get("user_nickname"))
|
|
1311
|
+
timestamp = event.get("timestamp")
|
|
1312
|
+
if timestamp and (not user["last_activity_at"] or timestamp > user["last_activity_at"]):
|
|
1313
|
+
user["last_activity_at"] = timestamp
|
|
1314
|
+
|
|
1315
|
+
user["total_content_chars"] += int(event.get("content_chars") or event.get("extracted_chars") or 0)
|
|
1316
|
+
sensitivity = event.get("sensitivity") or "none"
|
|
1317
|
+
labels = event.get("sensitive_labels") or []
|
|
1318
|
+
is_sensitive = sensitivity != "none" or bool(labels)
|
|
1319
|
+
|
|
1320
|
+
if event_type == "chat_message":
|
|
1321
|
+
summary["chat_events"] += 1
|
|
1322
|
+
if event.get("role") == "user":
|
|
1323
|
+
summary["user_messages"] += 1
|
|
1324
|
+
user["user_messages"] += 1
|
|
1325
|
+
elif event.get("role") == "assistant":
|
|
1326
|
+
summary["assistant_messages"] += 1
|
|
1327
|
+
user["assistant_messages"] += 1
|
|
1328
|
+
elif event_type == "document_upload":
|
|
1329
|
+
summary["document_uploads"] += 1
|
|
1330
|
+
user["document_uploads"] += 1
|
|
1331
|
+
elif event_type == "clear_command":
|
|
1332
|
+
summary["clear_events"] += 1
|
|
1333
|
+
user["clear_events"] += 1
|
|
1334
|
+
elif event_type in AUDIT_DELETE_EVENTS:
|
|
1335
|
+
summary["delete_events"] += 1
|
|
1336
|
+
user["delete_events"] += 1
|
|
1337
|
+
deletion_events.append(_public_audit_event(event))
|
|
1338
|
+
|
|
1339
|
+
if is_sensitive:
|
|
1340
|
+
summary["sensitive_events"] += 1
|
|
1341
|
+
user["sensitive_events"] += 1
|
|
1342
|
+
sensitive_events.append(_public_audit_event(event))
|
|
1343
|
+
if sensitivity == "high":
|
|
1344
|
+
summary["high_sensitive_events"] += 1
|
|
1345
|
+
user["high_sensitive_events"] += 1
|
|
1346
|
+
|
|
1347
|
+
return {
|
|
1348
|
+
"summary": summary,
|
|
1349
|
+
"per_user": sorted(
|
|
1350
|
+
per_user.values(),
|
|
1351
|
+
key=lambda item: (item.get("last_activity_at") or "", item.get("user_messages", 0) + item.get("assistant_messages", 0)),
|
|
1352
|
+
reverse=True,
|
|
1353
|
+
),
|
|
1354
|
+
"recent_events": [_public_audit_event(event) for event in events[-80:]][::-1],
|
|
1355
|
+
"sensitive_events": sensitive_events[-80:][::-1],
|
|
1356
|
+
"deletion_events": deletion_events[-80:][::-1],
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1102
1359
|
router = LLMRouter()
|
|
1103
1360
|
gardener = PReinforceGardener()
|
|
1104
1361
|
|
|
@@ -1187,11 +1444,16 @@ app.add_middleware(
|
|
|
1187
1444
|
allow_origins=CORS_ALLOWED_ORIGINS,
|
|
1188
1445
|
allow_methods=["*"],
|
|
1189
1446
|
allow_headers=["*"],
|
|
1447
|
+
allow_credentials=True,
|
|
1190
1448
|
)
|
|
1191
1449
|
|
|
1192
1450
|
# UI 파일이 담길 static 폴더 연결
|
|
1193
1451
|
STATIC_DIR.mkdir(parents=True, exist_ok=True)
|
|
1194
1452
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
1453
|
+
# PWA icons served at /icons/*
|
|
1454
|
+
_ICONS_DIR = STATIC_DIR / "icons"
|
|
1455
|
+
if _ICONS_DIR.exists():
|
|
1456
|
+
app.mount("/icons", StaticFiles(directory=str(_ICONS_DIR)), name="icons")
|
|
1195
1457
|
ensure_agent_root()
|
|
1196
1458
|
app.mount("/agent-files", StaticFiles(directory=str(AGENT_ROOT)), name="agent-files")
|
|
1197
1459
|
|
|
@@ -1232,6 +1494,79 @@ async def login(req: UserLogin):
|
|
|
1232
1494
|
response.set_cookie(key="session_token", value=token, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 7)
|
|
1233
1495
|
return response
|
|
1234
1496
|
|
|
1497
|
+
@app.get("/auth/sso/config")
|
|
1498
|
+
async def sso_config():
|
|
1499
|
+
enabled = bool(SSO_DISCOVERY_URL and SSO_CLIENT_ID and SSO_CLIENT_SECRET)
|
|
1500
|
+
return {"enabled": enabled, "provider_name": SSO_PROVIDER_NAME if enabled else ""}
|
|
1501
|
+
|
|
1502
|
+
@app.get("/auth/sso/login")
|
|
1503
|
+
async def sso_login():
|
|
1504
|
+
from urllib.parse import urlencode
|
|
1505
|
+
from fastapi.responses import RedirectResponse as _Redirect
|
|
1506
|
+
discovery = await _get_sso_discovery()
|
|
1507
|
+
if not discovery:
|
|
1508
|
+
raise HTTPException(status_code=503, detail="SSO가 설정되지 않았습니다.")
|
|
1509
|
+
state = secrets.token_urlsafe(16)
|
|
1510
|
+
_sso_states[state] = time.time()
|
|
1511
|
+
params = urlencode({
|
|
1512
|
+
"client_id": SSO_CLIENT_ID,
|
|
1513
|
+
"response_type": "code",
|
|
1514
|
+
"redirect_uri": SSO_REDIRECT_URI,
|
|
1515
|
+
"scope": "openid email profile",
|
|
1516
|
+
"state": state,
|
|
1517
|
+
})
|
|
1518
|
+
return _Redirect(f"{discovery['authorization_endpoint']}?{params}")
|
|
1519
|
+
|
|
1520
|
+
@app.get("/auth/sso/callback")
|
|
1521
|
+
async def sso_callback(code: str = "", state: str = "", error: str = ""):
|
|
1522
|
+
from fastapi.responses import RedirectResponse as _Redirect
|
|
1523
|
+
import base64 as _b64
|
|
1524
|
+
if error:
|
|
1525
|
+
return _Redirect(f"/?sso_error={error}")
|
|
1526
|
+
ts = _sso_states.pop(state, None)
|
|
1527
|
+
if ts is None or time.time() - ts > 300:
|
|
1528
|
+
raise HTTPException(status_code=400, detail="유효하지 않은 SSO 상태입니다.")
|
|
1529
|
+
discovery = await _get_sso_discovery()
|
|
1530
|
+
if not discovery:
|
|
1531
|
+
raise HTTPException(status_code=503, detail="SSO 설정 오류입니다.")
|
|
1532
|
+
import httpx as _httpx
|
|
1533
|
+
async with _httpx.AsyncClient() as c:
|
|
1534
|
+
r = await c.post(discovery["token_endpoint"], data={
|
|
1535
|
+
"grant_type": "authorization_code",
|
|
1536
|
+
"code": code,
|
|
1537
|
+
"redirect_uri": SSO_REDIRECT_URI,
|
|
1538
|
+
"client_id": SSO_CLIENT_ID,
|
|
1539
|
+
"client_secret": SSO_CLIENT_SECRET,
|
|
1540
|
+
}, headers={"Accept": "application/json"}, timeout=15)
|
|
1541
|
+
tokens = r.json()
|
|
1542
|
+
id_token = tokens.get("id_token")
|
|
1543
|
+
if not id_token:
|
|
1544
|
+
raise HTTPException(status_code=400, detail="ID 토큰을 받지 못했습니다.")
|
|
1545
|
+
# Decode JWT payload (no signature verification — trust IdP redirect)
|
|
1546
|
+
padded = id_token.split(".")[1] + "=="
|
|
1547
|
+
payload = json.loads(_b64.urlsafe_b64decode(padded))
|
|
1548
|
+
email = payload.get("email") or payload.get("preferred_username") or payload.get("upn") or ""
|
|
1549
|
+
if not email:
|
|
1550
|
+
raise HTTPException(status_code=400, detail="이메일을 확인할 수 없습니다.")
|
|
1551
|
+
users = load_users()
|
|
1552
|
+
if email not in users:
|
|
1553
|
+
is_first = len(users) == 0
|
|
1554
|
+
users[email] = {
|
|
1555
|
+
"password": "",
|
|
1556
|
+
"name": payload.get("name", email.split("@")[0]),
|
|
1557
|
+
"nickname": payload.get("given_name", email.split("@")[0]),
|
|
1558
|
+
"role": "admin" if is_first else "user",
|
|
1559
|
+
"disabled": False,
|
|
1560
|
+
"sso": True,
|
|
1561
|
+
}
|
|
1562
|
+
save_users(users)
|
|
1563
|
+
if users[email].get("disabled"):
|
|
1564
|
+
raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
|
|
1565
|
+
token = create_session(email)
|
|
1566
|
+
resp = _Redirect("/chat", status_code=302)
|
|
1567
|
+
resp.set_cookie("session_token", token, httponly=True, samesite="lax", max_age=_SESSION_TTL)
|
|
1568
|
+
return resp
|
|
1569
|
+
|
|
1235
1570
|
@app.post("/logout")
|
|
1236
1571
|
async def logout(request: Request):
|
|
1237
1572
|
token = _extract_bearer_token(request)
|
|
@@ -1343,6 +1678,17 @@ async def admin_sensitivity(request: Request):
|
|
|
1343
1678
|
require_admin(request)
|
|
1344
1679
|
return build_sensitivity_report(get_history())
|
|
1345
1680
|
|
|
1681
|
+
@app.get("/admin/audit")
|
|
1682
|
+
async def admin_audit(request: Request):
|
|
1683
|
+
_, users = require_admin(request)
|
|
1684
|
+
report = build_admin_audit_report(users)
|
|
1685
|
+
try:
|
|
1686
|
+
report["graph"] = KNOWLEDGE_GRAPH.stats() if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else {"disabled": True}
|
|
1687
|
+
except Exception as e:
|
|
1688
|
+
logging.warning("knowledge graph stats for audit failed: %s", e)
|
|
1689
|
+
report["graph"] = {"error": str(e)}
|
|
1690
|
+
return report
|
|
1691
|
+
|
|
1346
1692
|
@app.get("/vpc/status")
|
|
1347
1693
|
async def vpc_status(request: Request):
|
|
1348
1694
|
require_user(request)
|
|
@@ -1364,6 +1710,7 @@ async def admin_update_user(email: str, req: AdminUserUpdate, request: Request):
|
|
|
1364
1710
|
admin_email, users = require_admin(request)
|
|
1365
1711
|
if email not in users:
|
|
1366
1712
|
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
|
|
1713
|
+
before = public_user(email, users[email], users)
|
|
1367
1714
|
if req.role is not None:
|
|
1368
1715
|
if req.role not in {"admin", "user"}:
|
|
1369
1716
|
raise HTTPException(status_code=400, detail="role은 admin 또는 user만 가능합니다.")
|
|
@@ -1373,7 +1720,9 @@ async def admin_update_user(email: str, req: AdminUserUpdate, request: Request):
|
|
|
1373
1720
|
raise HTTPException(status_code=400, detail="자기 자신은 비활성화할 수 없습니다.")
|
|
1374
1721
|
users[email]["disabled"] = req.disabled
|
|
1375
1722
|
save_users(users)
|
|
1376
|
-
|
|
1723
|
+
after = public_user(email, users[email], users)
|
|
1724
|
+
append_audit_event("user_update", user_email=admin_email, target_email=email, before=before, after=after)
|
|
1725
|
+
return after
|
|
1377
1726
|
|
|
1378
1727
|
@app.delete("/admin/users/{email:path}")
|
|
1379
1728
|
async def admin_delete_user(email: str, request: Request):
|
|
@@ -1383,6 +1732,7 @@ async def admin_delete_user(email: str, request: Request):
|
|
|
1383
1732
|
if email not in users:
|
|
1384
1733
|
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
|
|
1385
1734
|
deleted = public_user(email, users[email], users)
|
|
1735
|
+
append_audit_event("user_delete", user_email=admin_email, target_email=email, deleted_user=deleted)
|
|
1386
1736
|
del users[email]
|
|
1387
1737
|
save_users(users)
|
|
1388
1738
|
return {"status": "ok", "deleted": deleted}
|
|
@@ -1390,7 +1740,7 @@ async def admin_delete_user(email: str, request: Request):
|
|
|
1390
1740
|
@app.get("/admin/invite-link")
|
|
1391
1741
|
async def admin_invite_link(request: Request):
|
|
1392
1742
|
require_admin(request)
|
|
1393
|
-
host = request.headers.get("host", f"localhost:{
|
|
1743
|
+
host = request.headers.get("host", f"localhost:{DEFAULT_PORT}")
|
|
1394
1744
|
scheme = "https" if request.headers.get("x-forwarded-proto") == "https" else "http"
|
|
1395
1745
|
if INVITE_GATE_ENABLED:
|
|
1396
1746
|
url = f"{scheme}://{host}/?code={INVITE_CODE}"
|
|
@@ -1431,6 +1781,30 @@ async def root(request: Request, code: Optional[str] = None, authorized: Optiona
|
|
|
1431
1781
|
""", status_code=403)
|
|
1432
1782
|
|
|
1433
1783
|
|
|
1784
|
+
@app.get("/account")
|
|
1785
|
+
async def account_page():
|
|
1786
|
+
"""Direct login/register page route used by logout and manual navigation."""
|
|
1787
|
+
return FileResponse(STATIC_DIR / "account.html")
|
|
1788
|
+
|
|
1789
|
+
|
|
1790
|
+
@app.get("/manifest.json")
|
|
1791
|
+
async def manifest():
|
|
1792
|
+
p = STATIC_DIR / "manifest.json"
|
|
1793
|
+
if not p.exists():
|
|
1794
|
+
raise HTTPException(status_code=404)
|
|
1795
|
+
return FileResponse(str(p), media_type="application/manifest+json")
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
@app.get("/sw.js")
|
|
1799
|
+
async def service_worker():
|
|
1800
|
+
p = STATIC_DIR / "sw.js"
|
|
1801
|
+
if not p.exists():
|
|
1802
|
+
raise HTTPException(status_code=404)
|
|
1803
|
+
resp = FileResponse(str(p), media_type="application/javascript")
|
|
1804
|
+
resp.headers["Service-Worker-Allowed"] = "/"
|
|
1805
|
+
return resp
|
|
1806
|
+
|
|
1807
|
+
|
|
1434
1808
|
@app.get("/chat")
|
|
1435
1809
|
async def chat_page(request: Request):
|
|
1436
1810
|
return FileResponse(STATIC_DIR / "chat.html")
|
|
@@ -1671,6 +2045,7 @@ ENGINE_MODEL_CATALOG = {
|
|
|
1671
2045
|
{"id": "mlx-community/gemma-4-e4b-4bit", "name": "Gemma 4 E4B Base", "family": "Gemma 4", "tag": "local-vlm", "size": "5.2GB", "pullable": True},
|
|
1672
2046
|
{"id": "mlx-community/gemma-4-e4b-it-4bit", "name": "Gemma 4 E4B Instruct", "family": "Gemma 4", "tag": "local-vlm", "size": "5.2GB", "pullable": True},
|
|
1673
2047
|
{"id": "mlx-community/gemma-4-26b-a4b-it-4bit", "name": "Gemma 4 26B A4B Instruct", "family": "Gemma 4", "tag": "local-vlm", "size": "Apple Silicon", "pullable": True},
|
|
2048
|
+
{"id": "Jiunsong/supergemma4-26b-abliterated-multimodal-mlx-4bit", "name": "SuperGemma4 26B Abliterated Multimodal", "family": "Gemma 4", "tag": "local-vlm", "size": "Apple Silicon", "pullable": True},
|
|
1674
2049
|
{"id": "mlx-community/Qwen2.5-Coder-3B-Instruct-4bit", "name": "Qwen 2.5 Coder 3B", "family": "Qwen 2.5 Coder", "tag": "local-coding", "size": "2.1GB", "pullable": True},
|
|
1675
2050
|
{"id": "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit", "name": "Qwen 2.5 Coder 7B", "family": "Qwen 2.5 Coder", "tag": "local-coding", "size": "4.3GB", "pullable": True},
|
|
1676
2051
|
{"id": "mlx-community/Qwen2.5-Coder-14B-Instruct-4bit", "name": "Qwen 2.5 Coder 14B", "family": "Qwen 2.5 Coder", "tag": "local-coding", "size": "8.5GB", "pullable": True},
|
|
@@ -1920,6 +2295,7 @@ def runtime_features() -> Dict:
|
|
|
1920
2295
|
"port": DEFAULT_PORT,
|
|
1921
2296
|
"data_dir": str(DATA_DIR),
|
|
1922
2297
|
"telegram_enabled": ENABLE_TELEGRAM,
|
|
2298
|
+
"graph_enabled": ENABLE_GRAPH,
|
|
1923
2299
|
"autoload_models": AUTOLOAD_MODELS,
|
|
1924
2300
|
"model_idle_unload_seconds": MODEL_IDLE_UNLOAD_SECONDS,
|
|
1925
2301
|
"model_memory_policy": router.model_memory_policy(),
|
|
@@ -2056,6 +2432,7 @@ async def health(request: Request):
|
|
|
2056
2432
|
|
|
2057
2433
|
|
|
2058
2434
|
@app.get("/mode")
|
|
2435
|
+
@app.get("/runtime_features")
|
|
2059
2436
|
async def mode():
|
|
2060
2437
|
return runtime_features()
|
|
2061
2438
|
|
|
@@ -2275,16 +2652,41 @@ async def chat(req: ChatRequest, request: Request):
|
|
|
2275
2652
|
|
|
2276
2653
|
if is_clear_command(req.message):
|
|
2277
2654
|
command = req.message.strip().lower()
|
|
2655
|
+
clear_scope = "all" if command == "/clear_all" else "conversation"
|
|
2656
|
+
if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
2657
|
+
try:
|
|
2658
|
+
KNOWLEDGE_GRAPH.ingest_event(
|
|
2659
|
+
"ClearEvent",
|
|
2660
|
+
f"{command} requested",
|
|
2661
|
+
user_email=effective_email,
|
|
2662
|
+
user_nickname=req.user_nickname,
|
|
2663
|
+
source=req.source or "web",
|
|
2664
|
+
conversation_id=req.conversation_id,
|
|
2665
|
+
metadata={"command": command, "scope": clear_scope},
|
|
2666
|
+
)
|
|
2667
|
+
except Exception as e:
|
|
2668
|
+
logging.warning("knowledge graph clear event ingest failed: %s", e)
|
|
2278
2669
|
if command == "/clear_all":
|
|
2279
2670
|
result = clear_history(0)
|
|
2280
|
-
answer = f"
|
|
2671
|
+
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 Data Graph/RAG 데이터는 유지됩니다."
|
|
2281
2672
|
else:
|
|
2282
2673
|
if req.conversation_id:
|
|
2283
2674
|
result = clear_conversation(req.conversation_id)
|
|
2284
|
-
answer = f"현재 대화방
|
|
2675
|
+
answer = f"현재 대화방 채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 Data Graph/RAG 데이터는 유지됩니다."
|
|
2285
2676
|
else:
|
|
2286
2677
|
result = clear_history(0)
|
|
2287
|
-
answer = f"
|
|
2678
|
+
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 Data Graph/RAG 데이터는 유지됩니다."
|
|
2679
|
+
append_audit_event(
|
|
2680
|
+
"clear_command",
|
|
2681
|
+
user_email=effective_email,
|
|
2682
|
+
user_nickname=req.user_nickname,
|
|
2683
|
+
source=req.source or "web",
|
|
2684
|
+
conversation_id=req.conversation_id,
|
|
2685
|
+
command=command,
|
|
2686
|
+
scope=clear_scope,
|
|
2687
|
+
removed=result.get("removed", 0),
|
|
2688
|
+
kept=result.get("kept", 0),
|
|
2689
|
+
)
|
|
2288
2690
|
if req.stream:
|
|
2289
2691
|
return StreamingResponse(
|
|
2290
2692
|
single_text_stream(answer),
|
|
@@ -2329,11 +2731,33 @@ async def chat(req: ChatRequest, request: Request):
|
|
|
2329
2731
|
except Exception as e:
|
|
2330
2732
|
logging.warning("Knowledge reinforcement skipped: %s", e)
|
|
2331
2733
|
|
|
2734
|
+
try:
|
|
2735
|
+
if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
2736
|
+
graph_context = KNOWLEDGE_GRAPH.context_for_query(req.message)
|
|
2737
|
+
if graph_context:
|
|
2738
|
+
context += f"\n\n[KNOWLEDGE GRAPH]\n{graph_context}"
|
|
2739
|
+
print("🕸️ Context reinforced with knowledge graph.")
|
|
2740
|
+
except Exception as e:
|
|
2741
|
+
logging.warning("Knowledge graph reinforcement skipped: %s", e)
|
|
2742
|
+
|
|
2332
2743
|
if req.image_data:
|
|
2333
2744
|
screenshot_context = extract_screenshot_context(req.image_data)
|
|
2334
2745
|
if screenshot_context:
|
|
2335
2746
|
context += f"\n\n{screenshot_context}"
|
|
2336
2747
|
|
|
2748
|
+
# 메시지 안에 절대 경로나 ~/... 경로가 있으면 자동으로 파일 읽어서 컨텍스트 주입
|
|
2749
|
+
_file_path_re = re.compile(r'(?:^|[\s\'\"(])((~|/[\w.])[^\s\'")\]]*)', re.MULTILINE)
|
|
2750
|
+
for _m in _file_path_re.finditer(req.message or ""):
|
|
2751
|
+
_fpath = _m.group(1).strip()
|
|
2752
|
+
try:
|
|
2753
|
+
_result = local_read(_fpath)
|
|
2754
|
+
_fcontent = _result.get("content", "")
|
|
2755
|
+
if _fcontent:
|
|
2756
|
+
context += f"\n\n[FILE: {_fpath}]\n```\n{_fcontent[:6000]}\n```"
|
|
2757
|
+
print(f"📂 Auto-injected file context: {_fpath}")
|
|
2758
|
+
except Exception:
|
|
2759
|
+
pass
|
|
2760
|
+
|
|
2337
2761
|
history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
2338
2762
|
save_to_history("user", history_message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
2339
2763
|
if req.source != "telegram":
|
|
@@ -2396,14 +2820,127 @@ async def fetch_history_conversation(conversation_id: str, request: Request):
|
|
|
2396
2820
|
@app.delete("/history/conversations/{conversation_id:path}")
|
|
2397
2821
|
async def delete_history_conversation(conversation_id: str, request: Request):
|
|
2398
2822
|
"""선택한 대화방의 메시지만 삭제합니다."""
|
|
2399
|
-
require_user(request)
|
|
2400
|
-
|
|
2823
|
+
email = require_user(request)
|
|
2824
|
+
result = clear_conversation(conversation_id, request.query_params.get("started_at"))
|
|
2825
|
+
append_audit_event(
|
|
2826
|
+
"conversation_delete",
|
|
2827
|
+
user_email=email,
|
|
2828
|
+
conversation_id=conversation_id,
|
|
2829
|
+
started_at=request.query_params.get("started_at"),
|
|
2830
|
+
removed=result.get("removed", 0),
|
|
2831
|
+
kept=result.get("kept", 0),
|
|
2832
|
+
)
|
|
2833
|
+
return result
|
|
2401
2834
|
|
|
2402
2835
|
|
|
2403
2836
|
@app.delete("/history")
|
|
2404
2837
|
async def delete_history(request: Request, keep_last: int = 0):
|
|
2838
|
+
email = require_user(request)
|
|
2839
|
+
result = clear_history(keep_last)
|
|
2840
|
+
append_audit_event(
|
|
2841
|
+
"history_delete",
|
|
2842
|
+
user_email=email,
|
|
2843
|
+
keep_last=keep_last,
|
|
2844
|
+
removed=result.get("removed", 0),
|
|
2845
|
+
kept=result.get("kept", 0),
|
|
2846
|
+
)
|
|
2847
|
+
return result
|
|
2848
|
+
|
|
2849
|
+
@app.get("/history/search")
|
|
2850
|
+
async def search_history(q: str, request: Request):
|
|
2851
|
+
"""키워드로 채팅 히스토리를 검색합니다."""
|
|
2852
|
+
require_user(request)
|
|
2853
|
+
if not q or not q.strip():
|
|
2854
|
+
return {"results": [], "query": q}
|
|
2855
|
+
q_lower = q.strip().lower()
|
|
2856
|
+
history = get_history()
|
|
2857
|
+
matches = [item for item in history if q_lower in (item.get("content") or "").lower()]
|
|
2858
|
+
grouped: Dict[str, Dict] = {}
|
|
2859
|
+
for item in matches:
|
|
2860
|
+
cid = item.get("conversation_id") or "legacy"
|
|
2861
|
+
if cid not in grouped:
|
|
2862
|
+
grouped[cid] = {"conversation_id": cid, "title": conversation_title(item), "messages": []}
|
|
2863
|
+
grouped[cid]["messages"].append(item)
|
|
2864
|
+
return {"results": list(grouped.values())[-30:], "query": q}
|
|
2865
|
+
|
|
2866
|
+
|
|
2867
|
+
@app.get("/graph")
|
|
2868
|
+
async def knowledge_graph_page(request: Request):
|
|
2869
|
+
"""Serve the interactive knowledge graph canvas UI."""
|
|
2870
|
+
_require_graph()
|
|
2871
|
+
require_user(request)
|
|
2872
|
+
return FileResponse(STATIC_DIR / "graph.html")
|
|
2873
|
+
|
|
2874
|
+
|
|
2875
|
+
@app.get("/knowledge-graph")
|
|
2876
|
+
async def knowledge_graph_legacy_page(request: Request):
|
|
2877
|
+
"""Backward-compatible route for the graph page."""
|
|
2878
|
+
_require_graph()
|
|
2879
|
+
require_user(request)
|
|
2880
|
+
return FileResponse(STATIC_DIR / "graph.html")
|
|
2881
|
+
|
|
2882
|
+
|
|
2883
|
+
@app.get("/knowledge-graph/stats")
|
|
2884
|
+
async def knowledge_graph_stats(request: Request):
|
|
2885
|
+
_require_graph()
|
|
2886
|
+
require_user(request)
|
|
2887
|
+
return KNOWLEDGE_GRAPH.stats()
|
|
2888
|
+
|
|
2889
|
+
|
|
2890
|
+
@app.get("/knowledge-graph/graph")
|
|
2891
|
+
async def knowledge_graph_data(request: Request, limit: int = 300):
|
|
2892
|
+
_require_graph()
|
|
2893
|
+
require_user(request)
|
|
2894
|
+
return KNOWLEDGE_GRAPH.graph(limit)
|
|
2895
|
+
|
|
2896
|
+
|
|
2897
|
+
@app.get("/knowledge-graph/search")
|
|
2898
|
+
async def knowledge_graph_search(q: str, request: Request, limit: int = 30):
|
|
2899
|
+
_require_graph()
|
|
2900
|
+
require_user(request)
|
|
2901
|
+
if not q or not q.strip():
|
|
2902
|
+
return {"query": q, "matches": []}
|
|
2903
|
+
return KNOWLEDGE_GRAPH.search(q, limit)
|
|
2904
|
+
|
|
2905
|
+
|
|
2906
|
+
@app.get("/knowledge-graph/context")
|
|
2907
|
+
async def knowledge_graph_context(q: str, request: Request, limit: int = 6):
|
|
2908
|
+
_require_graph()
|
|
2405
2909
|
require_user(request)
|
|
2406
|
-
return
|
|
2910
|
+
return {"query": q, "context": KNOWLEDGE_GRAPH.context_for_query(q, limit)}
|
|
2911
|
+
|
|
2912
|
+
|
|
2913
|
+
@app.get("/knowledge-graph/neighbors/{node_id:path}")
|
|
2914
|
+
async def knowledge_graph_neighbors(node_id: str, request: Request):
|
|
2915
|
+
_require_graph()
|
|
2916
|
+
require_user(request)
|
|
2917
|
+
if not node_id:
|
|
2918
|
+
raise HTTPException(status_code=400, detail="node_id required")
|
|
2919
|
+
return KNOWLEDGE_GRAPH.neighbors(node_id)
|
|
2920
|
+
|
|
2921
|
+
|
|
2922
|
+
@app.post("/knowledge-graph/ingest")
|
|
2923
|
+
async def knowledge_graph_ingest(req: KnowledgeGraphIngestRequest, request: Request):
|
|
2924
|
+
_require_graph()
|
|
2925
|
+
current_user = require_user(request)
|
|
2926
|
+
event_type = (req.type or "").strip().lower()
|
|
2927
|
+
if event_type not in {"message", "ai_response", "note"}:
|
|
2928
|
+
raise HTTPException(status_code=400, detail="지원하는 type: message, ai_response, note")
|
|
2929
|
+
role = req.role or ("assistant" if event_type == "ai_response" else "user")
|
|
2930
|
+
return KNOWLEDGE_GRAPH.ingest_message(
|
|
2931
|
+
role,
|
|
2932
|
+
req.content,
|
|
2933
|
+
user_email=req.user_email or current_user,
|
|
2934
|
+
user_nickname=req.user_nickname,
|
|
2935
|
+
source=req.source or "mcp",
|
|
2936
|
+
conversation_id=req.conversation_id,
|
|
2937
|
+
raw={
|
|
2938
|
+
"type": req.type,
|
|
2939
|
+
"title": req.title,
|
|
2940
|
+
"content": req.content,
|
|
2941
|
+
"metadata": req.metadata or {},
|
|
2942
|
+
},
|
|
2943
|
+
)
|
|
2407
2944
|
|
|
2408
2945
|
|
|
2409
2946
|
async def _stream_chat(req: ChatRequest, context: str = "", image_data: str = None) -> AsyncIterator[str]:
|
|
@@ -2430,7 +2967,9 @@ async def _stream_chat(req: ChatRequest, context: str = "", image_data: str = No
|
|
|
2430
2967
|
# ── Local Computer Agent ──────────────────────────────────────────────────────
|
|
2431
2968
|
|
|
2432
2969
|
AGENT_SYSTEM_PROMPT = """You are Lattice AI Agent, a local computer-use coding assistant.
|
|
2433
|
-
You
|
|
2970
|
+
You have full access to the local filesystem via local_list / local_read / local_write tools.
|
|
2971
|
+
Use read_file / write_file for paths inside the agent workspace (relative paths).
|
|
2972
|
+
Use local_read / local_write for any absolute path on the system (e.g. ~/Downloads, ~/Desktop).
|
|
2434
2973
|
|
|
2435
2974
|
Available actions:
|
|
2436
2975
|
- list_dir: {"action":"list_dir","args":{"path":"."}}
|
|
@@ -2445,6 +2984,7 @@ Available actions:
|
|
|
2445
2984
|
- create_xlsx: {"action":"create_xlsx","args":{"rows":[["A","B"],[1,2]],"filename":"spreadsheet.xlsx","sheet_name":"Sheet1"}}
|
|
2446
2985
|
- create_pptx: {"action":"create_pptx","args":{"title":"title","slides":[{"title":"Slide","bullets":["point"]}],"filename":"presentation.pptx"}}
|
|
2447
2986
|
- create_pdf: {"action":"create_pdf","args":{"title":"title","body":"paragraphs","filename":"document.pdf"}}
|
|
2987
|
+
- create_web_project: {"action":"create_web_project","args":{"path":"my_app","framework":"react","template":"vite"}} — scaffold a runnable web app project
|
|
2448
2988
|
- local_list: {"action":"local_list","args":{"path":"/Users/username/Downloads"}} — lists any local folder (UI will request user permission first)
|
|
2449
2989
|
- local_read: {"action":"local_read","args":{"path":"/Users/username/Documents/note.txt"}} — reads any local file (UI will request user permission first)
|
|
2450
2990
|
- local_write: {"action":"local_write","args":{"path":"/Users/username/Desktop/output.txt","content":"..."}} — writes any local file (UI will request user permission first)
|
|
@@ -2484,10 +3024,14 @@ Rules:
|
|
|
2484
3024
|
- Prefer simple, verifiable steps.
|
|
2485
3025
|
- Use inspect_html and preview_url for generated web UI.
|
|
2486
3026
|
- Use build_project when the user asks to build, compile, typecheck, or run a package build script.
|
|
2487
|
-
- Use deploy_project when the user asks to deploy, preview, or
|
|
3027
|
+
- Use deploy_project when the user asks to deploy, preview, release, or package installers (pkg/exe) and package.json defines that script (e.g. package, dist, make, build:pkg, build:exe).
|
|
3028
|
+
- If the user asks for app/service/web creation, prefer create_web_project first, then edit files with write_file/read_file and verify with build_project or run_command.
|
|
3029
|
+
- If the user asks for installer outputs (.pkg/.exe), set up packaging config (for example Electron/electron-builder or equivalent), create package scripts in package.json, then run deploy_project for installer scripts.
|
|
3030
|
+
- If .exe cannot be built on current OS/toolchain, still generate the full packaging config and scripts for Windows and report the exact missing prerequisite.
|
|
2488
3031
|
- Do not claim you cannot build or deploy. If a script, token, or platform config is missing, inspect the workspace and explain the exact missing piece.
|
|
2489
3032
|
- Use knowledge tools when the user asks to remember, search memory, or organize project context.
|
|
2490
3033
|
- Use run_command for local inspection, tests, and short development commands after files are written.
|
|
3034
|
+
- For data analysis tasks, read the provided files first (read_document/local_read), compute with run_command when needed, and return concrete findings plus output artifact paths when created.
|
|
2491
3035
|
- Use clear_history when the user asks to forget, clear, delete, reset, or speed up chat history.
|
|
2492
3036
|
- Git is read-only: status, diff, log, and show only. Never commit, push, pull, fetch, clone, reset, or checkout.
|
|
2493
3037
|
- If the user asks for something unsafe or outside the workspace, explain the limitation with final.
|
|
@@ -2495,13 +3039,73 @@ Rules:
|
|
|
2495
3039
|
"""
|
|
2496
3040
|
|
|
2497
3041
|
|
|
2498
|
-
_FILE_CREATE_ACTIONS = {"create_docx", "create_xlsx", "create_pptx", "create_pdf", "write_file"}
|
|
3042
|
+
_FILE_CREATE_ACTIONS = {"create_docx", "create_xlsx", "create_pptx", "create_pdf", "write_file", "create_web_project"}
|
|
3043
|
+
|
|
3044
|
+
# Harness risk level per tool action.
|
|
3045
|
+
# low — read-only, no side effects
|
|
3046
|
+
# medium — write/create files or knowledge entries
|
|
3047
|
+
# high — execute commands, control computer, write to arbitrary FS paths
|
|
3048
|
+
_TOOL_RISK: Dict[str, str] = {
|
|
3049
|
+
# read-only workspace tools
|
|
3050
|
+
"list_dir": "low", "workspace_tree": "low", "read_file": "low",
|
|
3051
|
+
"search_files": "low", "inspect_html": "low",
|
|
3052
|
+
# read-only local FS
|
|
3053
|
+
"local_list": "low", "local_read": "low",
|
|
3054
|
+
# read-only git
|
|
3055
|
+
"git_status": "low", "git_log": "low", "git_diff": "low", "git_show": "low",
|
|
3056
|
+
# read-only knowledge / computer
|
|
3057
|
+
"knowledge_search": "low", "knowledge_tree": "low",
|
|
3058
|
+
"obsidian_search": "low", "obsidian_tree": "low",
|
|
3059
|
+
"computer_screenshot": "low", "computer_status": "low",
|
|
3060
|
+
# write workspace
|
|
3061
|
+
"write_file": "medium", "create_web_project": "medium",
|
|
3062
|
+
"create_docx": "medium", "create_xlsx": "medium",
|
|
3063
|
+
"create_pptx": "medium", "create_pdf": "medium",
|
|
3064
|
+
# write knowledge
|
|
3065
|
+
"knowledge_save": "medium", "obsidian_save": "medium",
|
|
3066
|
+
# write local FS (arbitrary path — treated as medium; blocked from system roots below)
|
|
3067
|
+
"local_write": "medium",
|
|
3068
|
+
# preview
|
|
3069
|
+
"preview_url": "medium",
|
|
3070
|
+
# execute commands
|
|
3071
|
+
"run_command": "high",
|
|
3072
|
+
# computer control
|
|
3073
|
+
"computer_click": "high", "computer_type": "high", "computer_key": "high",
|
|
3074
|
+
"computer_scroll": "high", "computer_drag": "high", "computer_move": "high",
|
|
3075
|
+
"computer_open_app": "high", "computer_open_url": "high",
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
# Paths that local_write must never target (system-level protection)
|
|
3079
|
+
_LOCAL_WRITE_BLOCKED_PREFIXES = (
|
|
3080
|
+
"/etc/", "/usr/", "/bin/", "/sbin/", "/System/", "/private/etc/",
|
|
3081
|
+
"/Library/LaunchDaemons/", "/Library/LaunchAgents/",
|
|
3082
|
+
)
|
|
3083
|
+
|
|
3084
|
+
|
|
3085
|
+
def _agent_risk(action_name: str, args: dict) -> str:
|
|
3086
|
+
"""Return risk level for an action, upgrading local_write to 'high' for system paths."""
|
|
3087
|
+
risk = _TOOL_RISK.get(action_name, "medium")
|
|
3088
|
+
if action_name == "local_write":
|
|
3089
|
+
path = str(args.get("path", ""))
|
|
3090
|
+
if any(path.startswith(p) for p in _LOCAL_WRITE_BLOCKED_PREFIXES):
|
|
3091
|
+
risk = "high"
|
|
3092
|
+
return risk
|
|
3093
|
+
|
|
2499
3094
|
|
|
2500
3095
|
def _collect_created_files(transcript: list) -> list:
|
|
2501
3096
|
files = []
|
|
2502
3097
|
for step in transcript:
|
|
2503
3098
|
if step.get("action") in _FILE_CREATE_ACTIONS:
|
|
2504
3099
|
result = step.get("result", {})
|
|
3100
|
+
if isinstance(result.get("created_files"), list):
|
|
3101
|
+
for rel_path in result["created_files"]:
|
|
3102
|
+
files.append({
|
|
3103
|
+
"path": rel_path,
|
|
3104
|
+
"filename": Path(rel_path).name,
|
|
3105
|
+
"bytes": 0,
|
|
3106
|
+
"action": step["action"],
|
|
3107
|
+
})
|
|
3108
|
+
continue
|
|
2505
3109
|
path = result.get("path")
|
|
2506
3110
|
if path:
|
|
2507
3111
|
files.append({
|
|
@@ -2537,7 +3141,7 @@ def _extract_agent_action(raw: str) -> Dict:
|
|
|
2537
3141
|
@app.post("/agent")
|
|
2538
3142
|
async def agent(req: AgentRequest, request: Request):
|
|
2539
3143
|
"""Natural-language local agent loop for Telegram and future clients."""
|
|
2540
|
-
require_user(request)
|
|
3144
|
+
current_user = require_user(request)
|
|
2541
3145
|
if not router.current_model_id:
|
|
2542
3146
|
raise HTTPException(status_code=400, detail="No model loaded. Call /models/load first.")
|
|
2543
3147
|
|
|
@@ -2568,10 +3172,17 @@ async def agent(req: AgentRequest, request: Request):
|
|
|
2568
3172
|
action = _extract_agent_action(str(raw))
|
|
2569
3173
|
except ValueError as exc:
|
|
2570
3174
|
transcript.append({"step": step + 1, "action": "parse_error", "raw": str(raw), "error": str(exc)})
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
)
|
|
3175
|
+
message = "작업 계획을 안정적으로 해석하지 못해 자동 실행을 중단했습니다. 요청을 더 짧고 구체적으로 다시 시도해 주세요."
|
|
3176
|
+
save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id)
|
|
3177
|
+
save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id)
|
|
3178
|
+
created_files = _collect_created_files(transcript)
|
|
3179
|
+
return {
|
|
3180
|
+
"status": "ok",
|
|
3181
|
+
"response": message,
|
|
3182
|
+
"workspace": str(AGENT_ROOT),
|
|
3183
|
+
"steps": transcript,
|
|
3184
|
+
"created_files": created_files,
|
|
3185
|
+
}
|
|
2575
3186
|
|
|
2576
3187
|
name = action.get("action")
|
|
2577
3188
|
if name == "final":
|
|
@@ -2581,16 +3192,64 @@ async def agent(req: AgentRequest, request: Request):
|
|
|
2581
3192
|
created_files = _collect_created_files(transcript)
|
|
2582
3193
|
return {"status": "ok", "response": message, "workspace": str(AGENT_ROOT), "steps": transcript, "created_files": created_files}
|
|
2583
3194
|
|
|
3195
|
+
# Prevent repeated file/project creation loops with identical action+args.
|
|
3196
|
+
last_step = transcript[-1] if transcript else None
|
|
3197
|
+
current_args = action.get("args") or {}
|
|
3198
|
+
if (
|
|
3199
|
+
name in _FILE_CREATE_ACTIONS
|
|
3200
|
+
and last_step
|
|
3201
|
+
and last_step.get("action") == name
|
|
3202
|
+
and (last_step.get("args") or {}) == current_args
|
|
3203
|
+
and "result" in last_step
|
|
3204
|
+
):
|
|
3205
|
+
message = "요청한 파일 생성을 이미 완료해서 반복 실행을 중단했습니다."
|
|
3206
|
+
save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id)
|
|
3207
|
+
save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id)
|
|
3208
|
+
created_files = _collect_created_files(transcript)
|
|
3209
|
+
return {"status": "ok", "response": message, "workspace": str(AGENT_ROOT), "steps": transcript, "created_files": created_files}
|
|
3210
|
+
|
|
2584
3211
|
if name == "clear_history":
|
|
2585
|
-
result = clear_history(
|
|
2586
|
-
|
|
3212
|
+
result = clear_history(current_args.get("keep_last", 0))
|
|
3213
|
+
append_audit_event(
|
|
3214
|
+
"history_delete",
|
|
3215
|
+
user_email=current_user,
|
|
3216
|
+
source=req.source or "agent",
|
|
3217
|
+
keep_last=current_args.get("keep_last", 0),
|
|
3218
|
+
removed=result.get("removed", 0),
|
|
3219
|
+
kept=result.get("kept", 0),
|
|
3220
|
+
)
|
|
3221
|
+
transcript.append({"step": step + 1, "action": name, "args": current_args, "result": result})
|
|
2587
3222
|
continue
|
|
2588
3223
|
|
|
3224
|
+
risk = _agent_risk(name, current_args)
|
|
3225
|
+
|
|
3226
|
+
# Block system-path local_write even if the LLM tries it
|
|
3227
|
+
if name == "local_write":
|
|
3228
|
+
path = str(current_args.get("path", ""))
|
|
3229
|
+
if any(path.startswith(p) for p in _LOCAL_WRITE_BLOCKED_PREFIXES):
|
|
3230
|
+
transcript.append({
|
|
3231
|
+
"step": step + 1, "action": name, "args": current_args,
|
|
3232
|
+
"risk": "high", "error": f"BLOCKED: writing to system path is not allowed: {path}",
|
|
3233
|
+
})
|
|
3234
|
+
append_audit_event(
|
|
3235
|
+
"agent_blocked", user_email=current_user, source=req.source or "agent",
|
|
3236
|
+
action=name, path=path, reason="system_path",
|
|
3237
|
+
)
|
|
3238
|
+
continue
|
|
3239
|
+
|
|
3240
|
+
# Audit medium/high actions before execution
|
|
3241
|
+
if risk in ("medium", "high"):
|
|
3242
|
+
append_audit_event(
|
|
3243
|
+
"agent_exec", user_email=current_user, source=req.source or "agent",
|
|
3244
|
+
step=step + 1, action=name, risk=risk,
|
|
3245
|
+
args={k: v for k, v in (current_args or {}).items() if k != "content"},
|
|
3246
|
+
)
|
|
3247
|
+
|
|
2589
3248
|
try:
|
|
2590
|
-
result = execute_tool(name,
|
|
2591
|
-
transcript.append({"step": step + 1, "action": name, "args":
|
|
3249
|
+
result = execute_tool(name, current_args)
|
|
3250
|
+
transcript.append({"step": step + 1, "action": name, "args": current_args, "risk": risk, "result": result})
|
|
2592
3251
|
except (ToolError, KeyError, TypeError) as exc:
|
|
2593
|
-
transcript.append({"step": step + 1, "action": name, "args":
|
|
3252
|
+
transcript.append({"step": step + 1, "action": name, "args": current_args, "risk": risk, "error": str(exc)})
|
|
2594
3253
|
|
|
2595
3254
|
summary_context = (
|
|
2596
3255
|
f"{AGENT_SYSTEM_PROMPT}\n\n"
|
|
@@ -2657,8 +3316,17 @@ async def tools_search_files(req: ToolSearchFilesRequest, request: Request):
|
|
|
2657
3316
|
|
|
2658
3317
|
@app.post("/tools/clear_history")
|
|
2659
3318
|
async def tools_clear_history(req: ToolClearHistoryRequest, request: Request):
|
|
2660
|
-
require_user(request)
|
|
2661
|
-
|
|
3319
|
+
current_user = require_user(request)
|
|
3320
|
+
result = clear_history(req.keep_last)
|
|
3321
|
+
append_audit_event(
|
|
3322
|
+
"history_delete",
|
|
3323
|
+
user_email=current_user,
|
|
3324
|
+
source="tools",
|
|
3325
|
+
keep_last=req.keep_last,
|
|
3326
|
+
removed=result.get("removed", 0),
|
|
3327
|
+
kept=result.get("kept", 0),
|
|
3328
|
+
)
|
|
3329
|
+
return result
|
|
2662
3330
|
|
|
2663
3331
|
|
|
2664
3332
|
@app.post("/tools/inspect_html")
|
|
@@ -2697,6 +3365,36 @@ async def tools_create_pdf(req: ToolPdfRequest, request: Request):
|
|
|
2697
3365
|
return _tool_response(create_pdf, req.title, req.body, req.filename)
|
|
2698
3366
|
|
|
2699
3367
|
|
|
3368
|
+
@app.post("/tools/read_document")
|
|
3369
|
+
async def tools_read_document(req: ToolPathRequest, request: Request):
|
|
3370
|
+
require_user(request)
|
|
3371
|
+
return _tool_response(read_document, req.path)
|
|
3372
|
+
|
|
3373
|
+
|
|
3374
|
+
@app.get("/tools/pdf_pages")
|
|
3375
|
+
async def tools_pdf_pages(path: str, request: Request):
|
|
3376
|
+
"""Render PDF pages as base64 PNG images using PyMuPDF."""
|
|
3377
|
+
require_user(request)
|
|
3378
|
+
target = Path(path).expanduser().resolve()
|
|
3379
|
+
if not target.exists() or not target.is_file():
|
|
3380
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
3381
|
+
try:
|
|
3382
|
+
import fitz # PyMuPDF
|
|
3383
|
+
doc = fitz.open(str(target))
|
|
3384
|
+
pages = []
|
|
3385
|
+
for i, page in enumerate(doc):
|
|
3386
|
+
if i >= 20: # 최대 20페이지
|
|
3387
|
+
break
|
|
3388
|
+
mat = fitz.Matrix(1.5, 1.5) # 1.5x 해상도
|
|
3389
|
+
pix = page.get_pixmap(matrix=mat)
|
|
3390
|
+
b64 = base64.b64encode(pix.tobytes("png")).decode()
|
|
3391
|
+
pages.append({"page": i + 1, "b64": b64})
|
|
3392
|
+
doc.close()
|
|
3393
|
+
return {"total": len(doc), "pages": pages}
|
|
3394
|
+
except Exception as e:
|
|
3395
|
+
raise HTTPException(status_code=500, detail=f"PDF 렌더링 실패: {e}")
|
|
3396
|
+
|
|
3397
|
+
|
|
2700
3398
|
@app.get("/tools/download")
|
|
2701
3399
|
async def tools_download(path: str, request: Request):
|
|
2702
3400
|
"""Serve a generated file from agent workspace for download."""
|
|
@@ -2717,7 +3415,7 @@ async def tools_download(path: str, request: Request):
|
|
|
2717
3415
|
|
|
2718
3416
|
@app.post("/upload/document")
|
|
2719
3417
|
async def upload_document(request: Request, file: UploadFile = File(...)):
|
|
2720
|
-
require_user(request)
|
|
3418
|
+
current_user = require_user(request)
|
|
2721
3419
|
"""Upload a document and extract text (PDF, DOCX, XLSX, PPTX, TXT, MD, CSV)."""
|
|
2722
3420
|
suffix = Path(file.filename or "upload").suffix.lower()
|
|
2723
3421
|
allowed = {".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".csv"}
|
|
@@ -2731,6 +3429,47 @@ async def upload_document(request: Request, file: UploadFile = File(...)):
|
|
|
2731
3429
|
tmp_path = tmp.name
|
|
2732
3430
|
try:
|
|
2733
3431
|
result = read_document(tmp_path)
|
|
3432
|
+
sensitive = classify_sensitive_message(
|
|
3433
|
+
{
|
|
3434
|
+
"role": "document",
|
|
3435
|
+
"content": result.get("content") or result.get("preview") or "",
|
|
3436
|
+
"user_email": current_user,
|
|
3437
|
+
"timestamp": datetime.now().isoformat(),
|
|
3438
|
+
},
|
|
3439
|
+
-1,
|
|
3440
|
+
)
|
|
3441
|
+
try:
|
|
3442
|
+
if not (ENABLE_GRAPH and KNOWLEDGE_GRAPH):
|
|
3443
|
+
raise RuntimeError("graph disabled")
|
|
3444
|
+
graph_result = KNOWLEDGE_GRAPH.ingest_document(
|
|
3445
|
+
Path(tmp_path),
|
|
3446
|
+
original_filename=file.filename,
|
|
3447
|
+
mime_type=file.content_type,
|
|
3448
|
+
uploader=current_user,
|
|
3449
|
+
conversation_id=request.query_params.get("conversation_id"),
|
|
3450
|
+
extracted=result,
|
|
3451
|
+
)
|
|
3452
|
+
result["knowledge_graph"] = {
|
|
3453
|
+
"node_id": graph_result["node_id"],
|
|
3454
|
+
"sha256": graph_result["sha256"],
|
|
3455
|
+
}
|
|
3456
|
+
except Exception as graph_error:
|
|
3457
|
+
logging.warning("knowledge graph document ingest failed: %s", graph_error)
|
|
3458
|
+
result["knowledge_graph"] = {"error": str(graph_error)}
|
|
3459
|
+
append_audit_event(
|
|
3460
|
+
"document_upload",
|
|
3461
|
+
user_email=current_user,
|
|
3462
|
+
conversation_id=request.query_params.get("conversation_id"),
|
|
3463
|
+
filename=file.filename,
|
|
3464
|
+
mime_type=file.content_type,
|
|
3465
|
+
ext=suffix,
|
|
3466
|
+
bytes=len(contents),
|
|
3467
|
+
extracted_chars=result.get("chars"),
|
|
3468
|
+
graph_node=(result.get("knowledge_graph") or {}).get("node_id"),
|
|
3469
|
+
content_preview=sensitive.get("preview"),
|
|
3470
|
+
sensitivity=sensitive.get("sensitivity"),
|
|
3471
|
+
sensitive_labels=sensitive.get("labels") or [],
|
|
3472
|
+
)
|
|
2734
3473
|
except ToolError as exc:
|
|
2735
3474
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
2736
3475
|
finally:
|
|
@@ -2774,6 +3513,16 @@ async def local_read_endpoint(req: LocalAccessRequest, request: Request):
|
|
|
2774
3513
|
return _tool_response(local_read, req.path)
|
|
2775
3514
|
|
|
2776
3515
|
|
|
3516
|
+
@app.get("/local/serve")
|
|
3517
|
+
async def local_serve_file(path: str, request: Request):
|
|
3518
|
+
"""Serve a local file (images etc.) directly for browser preview."""
|
|
3519
|
+
require_user(request)
|
|
3520
|
+
target = Path(path).expanduser().resolve()
|
|
3521
|
+
if not target.exists() or not target.is_file():
|
|
3522
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
3523
|
+
return FileResponse(str(target))
|
|
3524
|
+
|
|
3525
|
+
|
|
2777
3526
|
@app.post("/local/write")
|
|
2778
3527
|
async def local_write_endpoint(req: LocalWriteRequest, request: Request):
|
|
2779
3528
|
require_user(request)
|
|
@@ -3169,6 +3918,10 @@ async def mcp_tools():
|
|
|
3169
3918
|
{"name": "knowledge_save", "description": "Save a note into the local knowledge garden."},
|
|
3170
3919
|
{"name": "knowledge_search", "description": "Search the local knowledge garden."},
|
|
3171
3920
|
{"name": "knowledge_tree", "description": "List local knowledge garden markdown files."},
|
|
3921
|
+
{"name": "knowledge_graph_ingest", "description": "Ingest a message, AI answer, or connector event into the SQLite knowledge graph."},
|
|
3922
|
+
{"name": "knowledge_graph_search", "description": "Search graph nodes, summaries, and JSON metadata."},
|
|
3923
|
+
{"name": "knowledge_graph_graph", "description": "Return Obsidian-style graph nodes and edges."},
|
|
3924
|
+
{"name": "knowledge_graph_context", "description": "Return compact graph-backed RAG context for a prompt."},
|
|
3172
3925
|
{"name": "obsidian_save", "description": "Save a note into the Obsidian-compatible memory vault."},
|
|
3173
3926
|
{"name": "obsidian_search", "description": "Search the Obsidian-compatible memory vault."},
|
|
3174
3927
|
{"name": "obsidian_tree", "description": "List Obsidian memory vault markdown files."},
|
|
@@ -3179,7 +3932,7 @@ async def mcp_tools():
|
|
|
3179
3932
|
{"name": "network_status", "description": "Get current local/private IP, public IP, hostname, and Wi-Fi info."},
|
|
3180
3933
|
{"name": "run_command", "description": "Run an allowlisted local command inside the workspace."},
|
|
3181
3934
|
{"name": "build_project", "description": "Run an allowlisted package.json build/compile/typecheck/test script."},
|
|
3182
|
-
{"name": "deploy_project", "description": "Run an allowlisted package.json deploy/preview/release script."},
|
|
3935
|
+
{"name": "deploy_project", "description": "Run an allowlisted package.json deploy/preview/release/package installer script (pkg/exe)."},
|
|
3183
3936
|
],
|
|
3184
3937
|
}
|
|
3185
3938
|
|
|
@@ -3211,7 +3964,33 @@ async def mcp_connector(mcp_id: str, request: Request):
|
|
|
3211
3964
|
|
|
3212
3965
|
@app.post("/mcp/call")
|
|
3213
3966
|
async def mcp_call(req: McpCallRequest, request: Request):
|
|
3214
|
-
require_user(request)
|
|
3967
|
+
current_user = require_user(request)
|
|
3968
|
+
args = req.args or {}
|
|
3969
|
+
if req.action == "knowledge_graph_ingest":
|
|
3970
|
+
_require_graph()
|
|
3971
|
+
return KNOWLEDGE_GRAPH.ingest_message(
|
|
3972
|
+
args.get("role") or ("assistant" if args.get("type") == "ai_response" else "user"),
|
|
3973
|
+
args.get("content") or "",
|
|
3974
|
+
user_email=args.get("user_email") or current_user,
|
|
3975
|
+
user_nickname=args.get("user_nickname"),
|
|
3976
|
+
source=args.get("source") or "mcp",
|
|
3977
|
+
conversation_id=args.get("conversation_id"),
|
|
3978
|
+
raw=args,
|
|
3979
|
+
)
|
|
3980
|
+
if req.action == "knowledge_graph_search":
|
|
3981
|
+
_require_graph()
|
|
3982
|
+
return KNOWLEDGE_GRAPH.search(args.get("query") or args.get("q") or "", args.get("limit", 30))
|
|
3983
|
+
if req.action == "knowledge_graph_graph":
|
|
3984
|
+
_require_graph()
|
|
3985
|
+
return KNOWLEDGE_GRAPH.graph(args.get("limit", 300))
|
|
3986
|
+
if req.action == "knowledge_graph_context":
|
|
3987
|
+
_require_graph()
|
|
3988
|
+
return {
|
|
3989
|
+
"context": KNOWLEDGE_GRAPH.context_for_query(
|
|
3990
|
+
args.get("query") or args.get("q") or "",
|
|
3991
|
+
args.get("limit", 6),
|
|
3992
|
+
)
|
|
3993
|
+
}
|
|
3215
3994
|
return _tool_response(execute_tool, req.action, req.args or {})
|
|
3216
3995
|
|
|
3217
3996
|
|