ltcai 1.1.0 β 1.3.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 +31 -0
- package/docs/CHANGELOG.md +90 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/health.py +45 -0
- package/latticeai/api/mcp.py +386 -0
- package/latticeai/api/models.py +307 -0
- package/latticeai/api/workspace.py +748 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +117 -1321
- package/latticeai/services/__init__.py +6 -0
- package/latticeai/services/chat_service.py +53 -0
- package/latticeai/services/model_service.py +51 -0
- package/latticeai/services/workspace_service.py +117 -0
- package/package.json +1 -1
package/latticeai/server_app.py
CHANGED
|
@@ -101,6 +101,13 @@ from latticeai.core.enterprise import (
|
|
|
101
101
|
capability_registry,
|
|
102
102
|
detect_edition,
|
|
103
103
|
)
|
|
104
|
+
from latticeai.services.workspace_service import WorkspaceService
|
|
105
|
+
from latticeai.services.model_service import ModelService
|
|
106
|
+
from latticeai.services.chat_service import ChatService
|
|
107
|
+
from latticeai.api.workspace import create_workspace_router
|
|
108
|
+
from latticeai.api.health import create_health_router
|
|
109
|
+
from latticeai.api.models import create_models_router
|
|
110
|
+
from latticeai.api.mcp import create_mcp_router
|
|
104
111
|
from latticeai.core.agent import (
|
|
105
112
|
AgentState,
|
|
106
113
|
AgentRunContext,
|
|
@@ -357,6 +364,9 @@ SSO_FILE = DATA_DIR / "sso_config.json"
|
|
|
357
364
|
KNOWLEDGE_GRAPH = KnowledgeGraphStore(DATA_DIR / "knowledge_graph.sqlite", DATA_DIR / "knowledge_graph_blobs") if ENABLE_GRAPH else None
|
|
358
365
|
LOCAL_KG_WATCHER = LocalKnowledgeWatcher(lambda: KNOWLEDGE_GRAPH) if ENABLE_GRAPH else None
|
|
359
366
|
WORKSPACE_OS = WorkspaceOSStore(DATA_DIR)
|
|
367
|
+
# Service layer (latticeai.services) wraps the store with scope/permission
|
|
368
|
+
# guardrails; routers and the app assembly share this single instance.
|
|
369
|
+
WORKSPACE_SERVICE = WorkspaceService(WORKSPACE_OS)
|
|
360
370
|
|
|
361
371
|
def _require_graph():
|
|
362
372
|
if not ENABLE_GRAPH or KNOWLEDGE_GRAPH is None:
|
|
@@ -455,25 +465,7 @@ def save_sso_config(update: Dict[str, object]) -> Dict[str, object]:
|
|
|
455
465
|
_sso_discovery_cache_url = ""
|
|
456
466
|
return current
|
|
457
467
|
|
|
458
|
-
|
|
459
|
-
query: str
|
|
460
|
-
limit: int = 5
|
|
461
|
-
|
|
462
|
-
class McpInstallRequest(BaseModel):
|
|
463
|
-
mcp_id: str
|
|
464
|
-
|
|
465
|
-
class McpCustomRequest(BaseModel):
|
|
466
|
-
name: str
|
|
467
|
-
package: str
|
|
468
|
-
description: str = ""
|
|
469
|
-
category: str = "custom"
|
|
470
|
-
icon: str = "π"
|
|
471
|
-
env_vars: List[Dict] = []
|
|
472
|
-
|
|
473
|
-
class SkillInstallRequest(BaseModel):
|
|
474
|
-
plugin: str
|
|
475
|
-
skill: str
|
|
476
|
-
|
|
468
|
+
# MCP/skill request models moved to latticeai.api.mcp (v1.3.0).
|
|
477
469
|
DEFAULT_VPC_CONFIG = {
|
|
478
470
|
"provider": "AWS",
|
|
479
471
|
"region": "ap-northeast-2",
|
|
@@ -727,6 +719,10 @@ def get_history():
|
|
|
727
719
|
logging.warning("get_history failed: %s", e)
|
|
728
720
|
return []
|
|
729
721
|
|
|
722
|
+
# Chat service seam: behaviour-preserving faΓ§ade for history access and
|
|
723
|
+
# Workspace-OS answer-trace recording used by the (unchanged) streaming chat path.
|
|
724
|
+
CHAT_SERVICE = ChatService(store=WORKSPACE_OS, get_history=get_history)
|
|
725
|
+
|
|
730
726
|
def conversation_title(item: Dict) -> str:
|
|
731
727
|
content = str(item.get("content") or "").strip()
|
|
732
728
|
content = re.sub(r"\s+", " ", content)
|
|
@@ -1317,22 +1313,8 @@ async def admin_page():
|
|
|
1317
1313
|
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
1318
1314
|
return response
|
|
1319
1315
|
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
require_user(request)
|
|
1323
|
-
workspace_path = STATIC_DIR / "workspace.html"
|
|
1324
|
-
if not workspace_path.exists():
|
|
1325
|
-
raise HTTPException(status_code=404, detail="Workspace OS UI not found.")
|
|
1326
|
-
return ui_file_response(workspace_path)
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
@app.get("/onboarding")
|
|
1330
|
-
async def onboarding_page(request: Request):
|
|
1331
|
-
require_user(request)
|
|
1332
|
-
workspace_path = STATIC_DIR / "workspace.html"
|
|
1333
|
-
if not workspace_path.exists():
|
|
1334
|
-
raise HTTPException(status_code=404, detail="Workspace OS UI not found.")
|
|
1335
|
-
return ui_file_response(workspace_path)
|
|
1316
|
+
# /workspace and /onboarding UI pages are served by the workspace router
|
|
1317
|
+
# (latticeai.api.workspace), included below after its dependencies are defined.
|
|
1336
1318
|
|
|
1337
1319
|
@app.get("/status")
|
|
1338
1320
|
async def status():
|
|
@@ -1406,133 +1388,9 @@ class ChatRequest(BaseModel):
|
|
|
1406
1388
|
image_data: Optional[str] = None # Base64 μ΄λ―Έμ§ λ°μ΄ν° (VLMμ©)
|
|
1407
1389
|
|
|
1408
1390
|
|
|
1409
|
-
|
|
1410
|
-
model_id: str # HuggingFace repo id λλ λ‘컬 κ²½λ‘
|
|
1411
|
-
adapter_path: Optional[str] = None # LoRA adapter (μ ν)
|
|
1412
|
-
draft_model_id: Optional[str] = None # Speculative decoding draft model (μ ν)
|
|
1413
|
-
engine: Optional[str] = None
|
|
1414
|
-
user_email: Optional[str] = None
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
class InstallEngineRequest(BaseModel):
|
|
1418
|
-
engine: str
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
class SetApiKeyRequest(BaseModel):
|
|
1422
|
-
provider: str
|
|
1423
|
-
key: str
|
|
1424
|
-
user_email: Optional[str] = None
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
class PullModelRequest(BaseModel):
|
|
1428
|
-
model: str
|
|
1429
|
-
|
|
1430
|
-
class PrepareModelRequest(BaseModel):
|
|
1431
|
-
model: str
|
|
1432
|
-
engine: Optional[str] = None
|
|
1433
|
-
user_email: Optional[str] = None
|
|
1434
|
-
|
|
1435
|
-
class VerifyCloudRequest(BaseModel):
|
|
1436
|
-
force: bool = False
|
|
1437
|
-
provider: Optional[str] = None
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
class WorkspaceOnboardingStepRequest(BaseModel):
|
|
1441
|
-
step: str
|
|
1442
|
-
status: str = "complete"
|
|
1443
|
-
data: Dict = {}
|
|
1444
|
-
error: str = ""
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
class WorkspaceOnboardingCompleteRequest(BaseModel):
|
|
1448
|
-
data: Dict = {}
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
class WorkspaceSnapshotRequest(BaseModel):
|
|
1452
|
-
name: str = "Workspace snapshot"
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
class WorkspaceSnapshotCompareRequest(BaseModel):
|
|
1456
|
-
before_id: str
|
|
1457
|
-
after_id: str
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
class WorkspaceMemoryRequest(BaseModel):
|
|
1461
|
-
kind: str
|
|
1462
|
-
content: str
|
|
1463
|
-
tags: List[str] = []
|
|
1464
|
-
memory_id: Optional[str] = None
|
|
1465
|
-
metadata: Dict = {}
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
class WorkspaceAgentRunRequest(BaseModel):
|
|
1469
|
-
agent_id: str = "agent:executor"
|
|
1470
|
-
status: str = "ok"
|
|
1471
|
-
input: str = ""
|
|
1472
|
-
output: str = ""
|
|
1473
|
-
timeline: List[Dict] = []
|
|
1474
|
-
relationships: List[str] = []
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
class WorkspaceWorkflowRequest(BaseModel):
|
|
1478
|
-
name: str
|
|
1479
|
-
steps: List[Dict] = []
|
|
1480
|
-
metadata: Dict = {}
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
class WorkspaceWorkflowEventRequest(BaseModel):
|
|
1484
|
-
event_type: str
|
|
1485
|
-
payload: Dict = {}
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
class WorkspaceComputerMemoryRequest(BaseModel):
|
|
1489
|
-
enabled: bool = False
|
|
1490
|
-
consent: Dict = {}
|
|
1491
|
-
scopes: List[str] = []
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
class WorkspaceComputerActivityRequest(BaseModel):
|
|
1495
|
-
activity: Dict = {}
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
class WorkspaceSkillActionRequest(BaseModel):
|
|
1499
|
-
skill: str
|
|
1500
|
-
plugin: Optional[str] = None
|
|
1501
|
-
enabled: Optional[bool] = None
|
|
1502
|
-
version: Optional[str] = None
|
|
1503
|
-
metadata: Dict = {}
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
class WorkspaceVSCodeRequest(BaseModel):
|
|
1507
|
-
action: str
|
|
1508
|
-
file_path: Optional[str] = None
|
|
1509
|
-
language: Optional[str] = None
|
|
1510
|
-
content: str = ""
|
|
1511
|
-
selection: str = ""
|
|
1512
|
-
prompt: str = ""
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
class WorkspaceCreateRequest(BaseModel):
|
|
1516
|
-
name: str
|
|
1517
|
-
settings: Dict = {}
|
|
1391
|
+
# Model/engine request models moved to latticeai.api.models (v1.3.0).
|
|
1518
1392
|
|
|
1519
|
-
|
|
1520
|
-
class WorkspaceUpdateRequest(BaseModel):
|
|
1521
|
-
name: Optional[str] = None
|
|
1522
|
-
settings: Optional[Dict] = None
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
class WorkspaceMemberRequest(BaseModel):
|
|
1526
|
-
user_id: str
|
|
1527
|
-
role: str = "member"
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
class WorkspaceMemberRoleRequest(BaseModel):
|
|
1531
|
-
role: str
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
class WorkspaceActivateRequest(BaseModel):
|
|
1535
|
-
workspace_id: str
|
|
1393
|
+
# Workspace request models moved to latticeai.api.workspace (v1.2.0 modularization).
|
|
1536
1394
|
|
|
1537
1395
|
|
|
1538
1396
|
class GardenRequest(BaseModel):
|
|
@@ -1687,10 +1545,6 @@ class LocalWriteRequest(BaseModel):
|
|
|
1687
1545
|
approval_token: Optional[str] = None
|
|
1688
1546
|
|
|
1689
1547
|
|
|
1690
|
-
class McpCallRequest(BaseModel):
|
|
1691
|
-
action: str
|
|
1692
|
-
args: Dict = {}
|
|
1693
|
-
|
|
1694
1548
|
|
|
1695
1549
|
class ToolGitDiffRequest(BaseModel):
|
|
1696
1550
|
path: Optional[str] = None
|
|
@@ -1736,562 +1590,37 @@ def _workspace_graph():
|
|
|
1736
1590
|
return KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None
|
|
1737
1591
|
|
|
1738
1592
|
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
async def workspace_onboarding_step(req: WorkspaceOnboardingStepRequest, request: Request):
|
|
1771
|
-
current_user = require_user(request)
|
|
1772
|
-
return WORKSPACE_OS.update_onboarding_step(
|
|
1773
|
-
req.step,
|
|
1774
|
-
status=req.status,
|
|
1775
|
-
data=req.data,
|
|
1776
|
-
error=req.error,
|
|
1777
|
-
user_email=current_user or None,
|
|
1778
|
-
)
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
@app.post("/workspace/onboarding/complete")
|
|
1782
|
-
async def workspace_onboarding_complete(req: WorkspaceOnboardingCompleteRequest, request: Request):
|
|
1783
|
-
current_user = require_user(request)
|
|
1784
|
-
append_audit_event("onboarding_complete", user_email=current_user, platform="AI Workspace OS")
|
|
1785
|
-
return WORKSPACE_OS.complete_onboarding(req.data, user_email=current_user or None)
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
@app.get("/workspace/onboarding/hardware")
|
|
1789
|
-
async def workspace_onboarding_hardware(request: Request):
|
|
1790
|
-
require_user(request)
|
|
1791
|
-
env = await asyncio.to_thread(scan_environment)
|
|
1792
|
-
sysinfo = await local_sysinfo(request)
|
|
1793
|
-
payload = {"environment": env, "sysinfo": sysinfo, "scanned_at": datetime.now().isoformat()}
|
|
1794
|
-
WORKSPACE_OS.update_onboarding_step("hardware", status="complete", data=payload, user_email=get_current_user(request))
|
|
1795
|
-
return payload
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
@app.get("/workspace/onboarding/model-recommendations")
|
|
1799
|
-
async def workspace_onboarding_model_recommendations(request: Request):
|
|
1800
|
-
require_user(request)
|
|
1801
|
-
env = await asyncio.to_thread(scan_environment)
|
|
1802
|
-
recommendations = get_recommendations(env)
|
|
1803
|
-
payload = {
|
|
1804
|
-
"environment": env,
|
|
1805
|
-
"recommendations": recommendations,
|
|
1806
|
-
"default_local_model": LOCAL_MODEL,
|
|
1807
|
-
"default_public_model": PUBLIC_MODEL,
|
|
1808
|
-
}
|
|
1809
|
-
WORKSPACE_OS.update_onboarding_step("model_recommendation", status="complete", data=payload, user_email=get_current_user(request))
|
|
1810
|
-
return payload
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
@app.get("/workspace/traces")
|
|
1814
|
-
async def workspace_traces(request: Request, conversation_id: Optional[str] = None, limit: int = 50):
|
|
1815
|
-
require_user(request)
|
|
1816
|
-
return WORKSPACE_OS.list_traces(conversation_id=conversation_id, limit=limit, workspace_id=_workspace_scope(request))
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
@app.get("/workspace/indexing")
|
|
1820
|
-
async def workspace_indexing_dashboard(request: Request):
|
|
1821
|
-
require_user(request)
|
|
1822
|
-
graph = _workspace_graph()
|
|
1823
|
-
watcher_status = LOCAL_KG_WATCHER.status() if LOCAL_KG_WATCHER else {"available": False, "active": {}}
|
|
1824
|
-
return WORKSPACE_OS.build_indexing_dashboard(graph, watcher_status)
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
@app.post("/workspace/indexing/{source_id}/pause")
|
|
1828
|
-
async def workspace_indexing_pause(source_id: str, request: Request):
|
|
1829
|
-
require_user(request)
|
|
1830
|
-
_require_graph()
|
|
1831
|
-
return WORKSPACE_OS.pause_indexing(KNOWLEDGE_GRAPH, source_id, LOCAL_KG_WATCHER)
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
@app.post("/workspace/indexing/{source_id}/resume")
|
|
1835
|
-
async def workspace_indexing_resume(source_id: str, request: Request):
|
|
1836
|
-
require_user(request)
|
|
1837
|
-
_require_graph()
|
|
1838
|
-
return WORKSPACE_OS.resume_indexing(KNOWLEDGE_GRAPH, source_id, LOCAL_KG_WATCHER)
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
@app.post("/workspace/indexing/{source_id}/remove")
|
|
1842
|
-
async def workspace_indexing_remove(source_id: str, request: Request):
|
|
1843
|
-
require_user(request)
|
|
1844
|
-
_require_graph()
|
|
1845
|
-
return WORKSPACE_OS.remove_index_source(KNOWLEDGE_GRAPH, source_id, LOCAL_KG_WATCHER)
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
@app.get("/workspace/snapshots")
|
|
1849
|
-
async def workspace_snapshots(request: Request):
|
|
1850
|
-
require_user(request)
|
|
1851
|
-
return WORKSPACE_OS.list_snapshots(workspace_id=_workspace_scope(request))
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
@app.post("/workspace/snapshots")
|
|
1855
|
-
async def workspace_snapshot_create(req: WorkspaceSnapshotRequest, request: Request):
|
|
1856
|
-
current_user = require_user(request)
|
|
1857
|
-
result = WORKSPACE_OS.create_snapshot(
|
|
1858
|
-
name=req.name,
|
|
1859
|
-
graph=_workspace_graph(),
|
|
1860
|
-
history=get_history(),
|
|
1861
|
-
settings=_workspace_settings_payload(),
|
|
1862
|
-
models=_workspace_models_payload(),
|
|
1863
|
-
workspace_id=_workspace_scope(request),
|
|
1864
|
-
)
|
|
1865
|
-
append_audit_event("workspace_snapshot", user_email=current_user, snapshot_id=result["snapshot"]["id"])
|
|
1866
|
-
return result
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
@app.post("/workspace/snapshots/compare")
|
|
1870
|
-
async def workspace_snapshot_compare(req: WorkspaceSnapshotCompareRequest, request: Request):
|
|
1871
|
-
require_user(request)
|
|
1872
|
-
try:
|
|
1873
|
-
return WORKSPACE_OS.compare_snapshots(req.before_id, req.after_id)
|
|
1874
|
-
except FileNotFoundError as exc:
|
|
1875
|
-
raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
@app.get("/workspace/snapshots/{snapshot_id}")
|
|
1879
|
-
async def workspace_snapshot_get(snapshot_id: str, request: Request):
|
|
1880
|
-
require_user(request)
|
|
1881
|
-
try:
|
|
1882
|
-
return WORKSPACE_OS.get_snapshot(snapshot_id)
|
|
1883
|
-
except FileNotFoundError as exc:
|
|
1884
|
-
raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
@app.get("/workspace/snapshots/{snapshot_id}/{area}")
|
|
1888
|
-
async def workspace_snapshot_area(snapshot_id: str, area: str, request: Request):
|
|
1889
|
-
require_user(request)
|
|
1890
|
-
try:
|
|
1891
|
-
return WORKSPACE_OS.snapshot_view(snapshot_id, area)
|
|
1892
|
-
except FileNotFoundError as exc:
|
|
1893
|
-
raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
@app.post("/workspace/snapshots/{snapshot_id}/export")
|
|
1897
|
-
async def workspace_snapshot_export(snapshot_id: str, request: Request):
|
|
1898
|
-
current_user = require_user(request)
|
|
1899
|
-
try:
|
|
1900
|
-
result = WORKSPACE_OS.export_snapshot(snapshot_id)
|
|
1901
|
-
except FileNotFoundError as exc:
|
|
1902
|
-
raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
|
|
1903
|
-
append_audit_event("workspace_snapshot_export", user_email=current_user, snapshot_id=snapshot_id, path=result.get("export_path"))
|
|
1904
|
-
return result
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
@app.get("/workspace/time-machine")
|
|
1908
|
-
async def workspace_time_machine(request: Request, limit: int = 100):
|
|
1909
|
-
require_user(request)
|
|
1910
|
-
return WORKSPACE_OS.timeline(get_audit_log(), limit=limit, workspace_id=_workspace_scope(request))
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
@app.get("/workspace/time-machine/{snapshot_id}/{area}")
|
|
1914
|
-
async def workspace_time_machine_view(snapshot_id: str, area: str, request: Request):
|
|
1915
|
-
require_user(request)
|
|
1916
|
-
try:
|
|
1917
|
-
return WORKSPACE_OS.snapshot_view(snapshot_id, area)
|
|
1918
|
-
except FileNotFoundError as exc:
|
|
1919
|
-
raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
@app.get("/workspace/memories")
|
|
1923
|
-
async def workspace_memories(request: Request, kind: Optional[str] = None):
|
|
1924
|
-
current_user = require_user(request)
|
|
1925
|
-
return WORKSPACE_OS.list_memories(user_email=current_user or None, kind=kind, workspace_id=_workspace_scope(request))
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
@app.get("/workspace/memories/search")
|
|
1929
|
-
async def workspace_memory_search(q: str, request: Request, limit: int = 20):
|
|
1930
|
-
current_user = require_user(request)
|
|
1931
|
-
return WORKSPACE_OS.search_memories(q, user_email=current_user or None, limit=limit, workspace_id=_workspace_scope(request))
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
@app.post("/workspace/memories")
|
|
1935
|
-
async def workspace_memory_upsert(req: WorkspaceMemoryRequest, request: Request):
|
|
1936
|
-
current_user = require_user(request)
|
|
1937
|
-
try:
|
|
1938
|
-
record = WORKSPACE_OS.upsert_memory(
|
|
1939
|
-
kind=req.kind,
|
|
1940
|
-
content=req.content,
|
|
1941
|
-
tags=req.tags,
|
|
1942
|
-
memory_id=req.memory_id,
|
|
1943
|
-
metadata=req.metadata,
|
|
1944
|
-
user_email=current_user or None,
|
|
1945
|
-
graph=_workspace_graph(),
|
|
1946
|
-
workspace_id=_workspace_scope(request),
|
|
1947
|
-
)
|
|
1948
|
-
except ValueError as exc:
|
|
1949
|
-
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
1950
|
-
return {"memory": record}
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
@app.delete("/workspace/memories/{memory_id}")
|
|
1954
|
-
async def workspace_memory_delete(memory_id: str, request: Request):
|
|
1955
|
-
require_user(request)
|
|
1956
|
-
try:
|
|
1957
|
-
return WORKSPACE_OS.delete_memory(memory_id)
|
|
1958
|
-
except FileNotFoundError as exc:
|
|
1959
|
-
raise HTTPException(status_code=404, detail=f"Memory not found: {exc}") from exc
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
@app.get("/workspace/agents")
|
|
1963
|
-
async def workspace_agents(request: Request):
|
|
1964
|
-
require_user(request)
|
|
1965
|
-
return WORKSPACE_OS.list_agents(workspace_id=_workspace_scope(request))
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
@app.post("/workspace/agents/runs")
|
|
1969
|
-
async def workspace_agent_run(req: WorkspaceAgentRunRequest, request: Request):
|
|
1970
|
-
current_user = require_user(request)
|
|
1971
|
-
run = WORKSPACE_OS.record_agent_run(
|
|
1972
|
-
agent_id=req.agent_id,
|
|
1973
|
-
status=req.status,
|
|
1974
|
-
input_text=req.input,
|
|
1975
|
-
output_text=req.output,
|
|
1976
|
-
timeline=req.timeline,
|
|
1977
|
-
relationships=req.relationships,
|
|
1978
|
-
user_email=current_user or None,
|
|
1979
|
-
graph=_workspace_graph(),
|
|
1980
|
-
workspace_id=_workspace_scope(request),
|
|
1981
|
-
)
|
|
1982
|
-
return {"run": run}
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
@app.get("/workspace/relationships/{node_id:path}")
|
|
1986
|
-
async def workspace_relationships(node_id: str, request: Request, target_id: Optional[str] = None):
|
|
1987
|
-
require_user(request)
|
|
1988
|
-
_require_graph()
|
|
1989
|
-
return WORKSPACE_OS.relationship_explorer(KNOWLEDGE_GRAPH, node_id, target_id=target_id)
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
@app.get("/workspace/computer-memory")
|
|
1993
|
-
async def workspace_computer_memory(request: Request):
|
|
1994
|
-
require_user(request)
|
|
1995
|
-
return WORKSPACE_OS.load_state().get("computer_memory")
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
@app.post("/workspace/computer-memory")
|
|
1999
|
-
async def workspace_computer_memory_config(req: WorkspaceComputerMemoryRequest, request: Request):
|
|
2000
|
-
current_user = require_user(request)
|
|
2001
|
-
try:
|
|
2002
|
-
config = WORKSPACE_OS.configure_computer_memory(
|
|
2003
|
-
enabled=req.enabled,
|
|
2004
|
-
approved_by=current_user or None,
|
|
2005
|
-
consent=req.consent,
|
|
2006
|
-
scopes=req.scopes or None,
|
|
2007
|
-
)
|
|
2008
|
-
except PermissionError as exc:
|
|
2009
|
-
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
2010
|
-
append_audit_event("computer_memory_config", user_email=current_user, enabled=req.enabled)
|
|
2011
|
-
return {"computer_memory": config}
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
@app.post("/workspace/computer-memory/activity")
|
|
2015
|
-
async def workspace_computer_memory_activity(req: WorkspaceComputerActivityRequest, request: Request):
|
|
2016
|
-
require_user(request)
|
|
2017
|
-
return WORKSPACE_OS.record_computer_activity(req.activity, graph=_workspace_graph())
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
@app.get("/workspace/workflows")
|
|
2021
|
-
async def workspace_workflows(request: Request, q: str = ""):
|
|
2022
|
-
require_user(request)
|
|
2023
|
-
return WORKSPACE_OS.list_workflows(query=q, workspace_id=_workspace_scope(request))
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
@app.post("/workspace/workflows")
|
|
2027
|
-
async def workspace_workflow_create(req: WorkspaceWorkflowRequest, request: Request):
|
|
2028
|
-
current_user = require_user(request)
|
|
2029
|
-
workflow = WORKSPACE_OS.create_workflow(
|
|
2030
|
-
name=req.name,
|
|
2031
|
-
steps=req.steps,
|
|
2032
|
-
metadata=req.metadata,
|
|
2033
|
-
user_email=current_user or None,
|
|
2034
|
-
graph=_workspace_graph(),
|
|
2035
|
-
workspace_id=_workspace_scope(request),
|
|
2036
|
-
)
|
|
2037
|
-
return {"workflow": workflow}
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
@app.post("/workspace/workflows/{workflow_id}/events")
|
|
2041
|
-
async def workspace_workflow_event(workflow_id: str, req: WorkspaceWorkflowEventRequest, request: Request):
|
|
2042
|
-
require_user(request)
|
|
2043
|
-
try:
|
|
2044
|
-
return {"workflow": WORKSPACE_OS.record_workflow_event(workflow_id, req.event_type, req.payload)}
|
|
2045
|
-
except FileNotFoundError as exc:
|
|
2046
|
-
raise HTTPException(status_code=404, detail=f"Workflow not found: {exc}") from exc
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
@app.get("/workspace/skills")
|
|
2050
|
-
async def workspace_skills(request: Request):
|
|
2051
|
-
require_user(request)
|
|
2052
|
-
marketplace = []
|
|
2053
|
-
try:
|
|
2054
|
-
marketplace = await _fetch_skills_marketplace()
|
|
2055
|
-
except Exception as exc:
|
|
2056
|
-
logging.warning("workspace skills marketplace unavailable: %s", exc)
|
|
2057
|
-
return WORKSPACE_OS.list_skill_registry(SKILLS_DIR, marketplace)
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
@app.post("/workspace/skills/install")
|
|
2061
|
-
async def workspace_skill_install(req: WorkspaceSkillActionRequest, request: Request):
|
|
2062
|
-
admin_email, _ = require_admin(request)
|
|
2063
|
-
if req.plugin:
|
|
2064
|
-
result = await install_skill(req.plugin, req.skill)
|
|
2065
|
-
else:
|
|
2066
|
-
result = {"status": "recorded", "skill": req.skill}
|
|
2067
|
-
entry = WORKSPACE_OS.mark_skill_installed(req.skill, version=req.version or "local", metadata={"install_result": result, **req.metadata})
|
|
2068
|
-
append_audit_event("skill_install", user_email=admin_email, plugin=req.plugin, skill=req.skill, workspace_os=True)
|
|
2069
|
-
return {"skill": entry, "install": result}
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
@app.post("/workspace/skills/uninstall")
|
|
2073
|
-
async def workspace_skill_uninstall(req: WorkspaceSkillActionRequest, request: Request):
|
|
2074
|
-
admin_email, _ = require_admin(request)
|
|
2075
|
-
removal = remove_skill_directory(SKILLS_DIR, req.skill)
|
|
2076
|
-
entry = WORKSPACE_OS.mark_skill_uninstalled(req.skill)
|
|
2077
|
-
append_audit_event("skill_uninstall", user_email=admin_email, skill=req.skill, workspace_os=True)
|
|
2078
|
-
return {"skill": entry, "removal": removal}
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
@app.post("/workspace/skills/enable")
|
|
2082
|
-
async def workspace_skill_enable(req: WorkspaceSkillActionRequest, request: Request):
|
|
2083
|
-
require_user(request)
|
|
2084
|
-
return {"skill": WORKSPACE_OS.set_skill_enabled(req.skill, True)}
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
@app.post("/workspace/skills/disable")
|
|
2088
|
-
async def workspace_skill_disable(req: WorkspaceSkillActionRequest, request: Request):
|
|
2089
|
-
require_user(request)
|
|
2090
|
-
return {"skill": WORKSPACE_OS.set_skill_enabled(req.skill, False)}
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
@app.post("/workspace/skills/update")
|
|
2094
|
-
async def workspace_skill_update(req: WorkspaceSkillActionRequest, request: Request):
|
|
2095
|
-
admin_email, _ = require_admin(request)
|
|
2096
|
-
if req.plugin:
|
|
2097
|
-
result = await install_skill(req.plugin, req.skill)
|
|
2098
|
-
else:
|
|
2099
|
-
result = {"status": "version_recorded", "skill": req.skill}
|
|
2100
|
-
entry = WORKSPACE_OS.mark_skill_installed(req.skill, version=req.version or "latest", metadata={"update_result": result, **req.metadata})
|
|
2101
|
-
append_audit_event("skill_update", user_email=admin_email, plugin=req.plugin, skill=req.skill, workspace_os=True)
|
|
2102
|
-
return {"skill": entry, "update": result}
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
@app.get("/workspace/audit-timeline")
|
|
2106
|
-
async def workspace_audit_timeline(
|
|
2107
|
-
request: Request,
|
|
2108
|
-
user: Optional[str] = None,
|
|
2109
|
-
event_type: Optional[str] = None,
|
|
2110
|
-
model: Optional[str] = None,
|
|
2111
|
-
since: Optional[str] = None,
|
|
2112
|
-
until: Optional[str] = None,
|
|
2113
|
-
limit: int = 100,
|
|
2114
|
-
):
|
|
2115
|
-
require_admin(request)
|
|
2116
|
-
return WORKSPACE_OS.filter_audit_timeline(
|
|
2117
|
-
get_audit_log(),
|
|
2118
|
-
user=user,
|
|
2119
|
-
event_type=event_type,
|
|
2120
|
-
model=model,
|
|
2121
|
-
since=since,
|
|
2122
|
-
until=until,
|
|
2123
|
-
limit=limit,
|
|
2124
|
-
)
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
@app.post("/workspace/vscode/send")
|
|
2128
|
-
async def workspace_vscode_send(req: WorkspaceVSCodeRequest, request: Request):
|
|
2129
|
-
current_user = require_user(request)
|
|
2130
|
-
content = req.selection or req.content or req.prompt
|
|
2131
|
-
workflow = WORKSPACE_OS.create_workflow(
|
|
2132
|
-
name=f"VS Code: {req.action}",
|
|
2133
|
-
steps=[
|
|
2134
|
-
{"action": req.action, "file_path": req.file_path, "language": req.language},
|
|
2135
|
-
{"action": "send_to_lattice", "chars": len(content or "")},
|
|
2136
|
-
],
|
|
2137
|
-
metadata={
|
|
2138
|
-
"file_path": req.file_path,
|
|
2139
|
-
"language": req.language,
|
|
2140
|
-
"content_preview": redact_secret_text(content or "")[:500],
|
|
2141
|
-
},
|
|
2142
|
-
user_email=current_user or None,
|
|
2143
|
-
graph=_workspace_graph(),
|
|
2144
|
-
)
|
|
2145
|
-
if _workspace_graph() is not None and content:
|
|
2146
|
-
try:
|
|
2147
|
-
_workspace_graph().ingest_event(
|
|
2148
|
-
"VSCodeWorkflow",
|
|
2149
|
-
req.action,
|
|
2150
|
-
user_email=current_user or None,
|
|
2151
|
-
source="vscode",
|
|
2152
|
-
metadata={
|
|
2153
|
-
"file_path": req.file_path,
|
|
2154
|
-
"language": req.language,
|
|
2155
|
-
"chars": len(content),
|
|
2156
|
-
"workflow_id": workflow["id"],
|
|
2157
|
-
},
|
|
2158
|
-
)
|
|
2159
|
-
except Exception as exc:
|
|
2160
|
-
logging.warning("vscode workflow graph ingest failed: %s", exc)
|
|
2161
|
-
return {"status": "ok", "workflow": workflow}
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
# ββ Organization Workspaces, membership, roles, and edition seam βββββββββββββββββ
|
|
2165
|
-
|
|
2166
|
-
@app.get("/workspace/registry")
|
|
2167
|
-
async def workspace_registry(request: Request):
|
|
2168
|
-
user = require_user(request)
|
|
2169
|
-
return WORKSPACE_OS.list_workspaces(user_id=user or None)
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
@app.get("/workspace/editions")
|
|
2173
|
-
async def workspace_editions(request: Request):
|
|
2174
|
-
require_user(request)
|
|
2175
|
-
return capability_registry.describe()
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
@app.post("/workspace/activate")
|
|
2179
|
-
async def workspace_activate(req: WorkspaceActivateRequest, request: Request):
|
|
2180
|
-
user = require_user(request)
|
|
2181
|
-
try:
|
|
2182
|
-
return WORKSPACE_OS.set_active_workspace(req.workspace_id, user_id=user or None)
|
|
2183
|
-
except FileNotFoundError as exc:
|
|
2184
|
-
raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
|
|
2185
|
-
except PermissionError as exc:
|
|
2186
|
-
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
@app.post("/workspace/orgs")
|
|
2190
|
-
async def workspace_org_create(req: WorkspaceCreateRequest, request: Request):
|
|
2191
|
-
user = require_user(request)
|
|
2192
|
-
try:
|
|
2193
|
-
workspace = WORKSPACE_OS.create_organization_workspace(
|
|
2194
|
-
name=req.name,
|
|
2195
|
-
owner_user_id=user or None,
|
|
2196
|
-
settings=req.settings,
|
|
2197
|
-
)
|
|
2198
|
-
except ValueError as exc:
|
|
2199
|
-
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
2200
|
-
append_audit_event("workspace_created", user_email=user, workspace_id=workspace["workspace_id"])
|
|
2201
|
-
return {"workspace": workspace}
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
@app.get("/workspace/orgs/{workspace_id}")
|
|
2205
|
-
async def workspace_org_get(workspace_id: str, request: Request):
|
|
2206
|
-
user = require_user(request)
|
|
2207
|
-
try:
|
|
2208
|
-
return {"workspace": WORKSPACE_OS.get_workspace(workspace_id, user_id=user or None)}
|
|
2209
|
-
except FileNotFoundError as exc:
|
|
2210
|
-
raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
@app.get("/workspace/orgs/{workspace_id}/summary")
|
|
2214
|
-
async def workspace_org_summary(workspace_id: str, request: Request):
|
|
2215
|
-
user = require_user(request)
|
|
2216
|
-
try:
|
|
2217
|
-
return WORKSPACE_OS.workspace_summary(workspace_id, user_id=user or None)
|
|
2218
|
-
except FileNotFoundError as exc:
|
|
2219
|
-
raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
@app.patch("/workspace/orgs/{workspace_id}")
|
|
2223
|
-
async def workspace_org_update(workspace_id: str, req: WorkspaceUpdateRequest, request: Request):
|
|
2224
|
-
user = require_user(request)
|
|
2225
|
-
try:
|
|
2226
|
-
workspace = WORKSPACE_OS.update_workspace(workspace_id, name=req.name, settings=req.settings, actor=user or None)
|
|
2227
|
-
except FileNotFoundError as exc:
|
|
2228
|
-
raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
|
|
2229
|
-
except PermissionError as exc:
|
|
2230
|
-
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
2231
|
-
except ValueError as exc:
|
|
2232
|
-
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
2233
|
-
append_audit_event("workspace_updated", user_email=user, workspace_id=workspace_id)
|
|
2234
|
-
return {"workspace": workspace}
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
@app.post("/workspace/orgs/{workspace_id}/archive")
|
|
2238
|
-
async def workspace_org_archive(workspace_id: str, request: Request):
|
|
2239
|
-
user = require_user(request)
|
|
2240
|
-
try:
|
|
2241
|
-
workspace = WORKSPACE_OS.archive_workspace(workspace_id, actor=user or None)
|
|
2242
|
-
except FileNotFoundError as exc:
|
|
2243
|
-
raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
|
|
2244
|
-
except PermissionError as exc:
|
|
2245
|
-
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
2246
|
-
except ValueError as exc:
|
|
2247
|
-
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
2248
|
-
append_audit_event("workspace_archived", user_email=user, workspace_id=workspace_id)
|
|
2249
|
-
return {"workspace": workspace}
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
@app.post("/workspace/orgs/{workspace_id}/members")
|
|
2253
|
-
async def workspace_org_add_member(workspace_id: str, req: WorkspaceMemberRequest, request: Request):
|
|
2254
|
-
user = require_user(request)
|
|
2255
|
-
try:
|
|
2256
|
-
workspace = WORKSPACE_OS.add_member(workspace_id, user_id=req.user_id, role=req.role, actor=user or None)
|
|
2257
|
-
except FileNotFoundError as exc:
|
|
2258
|
-
raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
|
|
2259
|
-
except PermissionError as exc:
|
|
2260
|
-
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
2261
|
-
except ValueError as exc:
|
|
2262
|
-
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
2263
|
-
append_audit_event("workspace_member_added", user_email=user, workspace_id=workspace_id, member=req.user_id, role=req.role)
|
|
2264
|
-
return {"workspace": workspace}
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
@app.patch("/workspace/orgs/{workspace_id}/members/{user_id}")
|
|
2268
|
-
async def workspace_org_update_member(workspace_id: str, user_id: str, req: WorkspaceMemberRoleRequest, request: Request):
|
|
2269
|
-
user = require_user(request)
|
|
2270
|
-
try:
|
|
2271
|
-
workspace = WORKSPACE_OS.update_member_role(workspace_id, user_id=user_id, role=req.role, actor=user or None)
|
|
2272
|
-
except FileNotFoundError as exc:
|
|
2273
|
-
raise HTTPException(status_code=404, detail=f"Not found: {exc}") from exc
|
|
2274
|
-
except PermissionError as exc:
|
|
2275
|
-
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
2276
|
-
except ValueError as exc:
|
|
2277
|
-
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
2278
|
-
append_audit_event("workspace_member_role_updated", user_email=user, workspace_id=workspace_id, member=user_id, role=req.role)
|
|
2279
|
-
return {"workspace": workspace}
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
@app.delete("/workspace/orgs/{workspace_id}/members/{user_id}")
|
|
2283
|
-
async def workspace_org_remove_member(workspace_id: str, user_id: str, request: Request):
|
|
2284
|
-
user = require_user(request)
|
|
2285
|
-
try:
|
|
2286
|
-
workspace = WORKSPACE_OS.remove_member(workspace_id, user_id=user_id, actor=user or None)
|
|
2287
|
-
except FileNotFoundError as exc:
|
|
2288
|
-
raise HTTPException(status_code=404, detail=f"Not found: {exc}") from exc
|
|
2289
|
-
except PermissionError as exc:
|
|
2290
|
-
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
2291
|
-
except ValueError as exc:
|
|
2292
|
-
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
2293
|
-
append_audit_event("workspace_member_removed", user_email=user, workspace_id=workspace_id, member=user_id)
|
|
2294
|
-
return {"workspace": workspace}
|
|
1593
|
+
# ββ Workspace OS + Organization router (latticeai.api.workspace, v1.2.0) ββββββ
|
|
1594
|
+
app.include_router(create_workspace_router(
|
|
1595
|
+
service=WORKSPACE_SERVICE,
|
|
1596
|
+
require_user=require_user,
|
|
1597
|
+
require_admin=require_admin,
|
|
1598
|
+
get_current_user=get_current_user,
|
|
1599
|
+
append_audit_event=append_audit_event,
|
|
1600
|
+
graph_stats=_graph_stats_safe,
|
|
1601
|
+
workspace_models=_workspace_models_payload,
|
|
1602
|
+
workspace_settings=_workspace_settings_payload,
|
|
1603
|
+
get_history=get_history,
|
|
1604
|
+
get_audit_log=get_audit_log,
|
|
1605
|
+
require_graph=_require_graph,
|
|
1606
|
+
workspace_graph=_workspace_graph,
|
|
1607
|
+
knowledge_graph=KNOWLEDGE_GRAPH,
|
|
1608
|
+
local_kg_watcher=LOCAL_KG_WATCHER,
|
|
1609
|
+
load_users=load_users,
|
|
1610
|
+
scan_environment=scan_environment,
|
|
1611
|
+
local_sysinfo=local_sysinfo,
|
|
1612
|
+
get_recommendations=get_recommendations,
|
|
1613
|
+
fetch_skills_marketplace=_fetch_skills_marketplace,
|
|
1614
|
+
install_skill=install_skill,
|
|
1615
|
+
remove_skill_directory=remove_skill_directory,
|
|
1616
|
+
redact_secret_text=redact_secret_text,
|
|
1617
|
+
skills_dir=SKILLS_DIR,
|
|
1618
|
+
capability_registry=capability_registry,
|
|
1619
|
+
ui_file_response=ui_file_response,
|
|
1620
|
+
static_dir=STATIC_DIR,
|
|
1621
|
+
local_model=LOCAL_MODEL,
|
|
1622
|
+
public_model=PUBLIC_MODEL,
|
|
1623
|
+
))
|
|
2295
1624
|
|
|
2296
1625
|
|
|
2297
1626
|
# ββ Health & Info ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -4182,284 +3511,51 @@ async def verify_cloud_models(force: bool = False, provider_filter: Optional[str
|
|
|
4182
3511
|
results[model_ref] = record
|
|
4183
3512
|
return results
|
|
4184
3513
|
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
"features": runtime_features(),
|
|
4202
|
-
"providers": router.detected_cloud_models(),
|
|
4203
|
-
"engines": engines,
|
|
4204
|
-
}
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
@app.get("/mode")
|
|
4208
|
-
@app.get("/runtime_features")
|
|
4209
|
-
async def mode():
|
|
4210
|
-
return runtime_features()
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
@app.get("/engines")
|
|
4214
|
-
async def engines():
|
|
4215
|
-
return {"engines": await asyncio.to_thread(engine_status), "current": router.current_model_id}
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
@app.post("/engines/install")
|
|
4219
|
-
async def engines_install(req: InstallEngineRequest, request: Request):
|
|
4220
|
-
require_user(request)
|
|
4221
|
-
return install_engine(req.engine)
|
|
4222
|
-
|
|
4223
|
-
@app.post("/engines/verify-cloud")
|
|
4224
|
-
async def engines_verify_cloud(req: VerifyCloudRequest, request: Request):
|
|
4225
|
-
require_user(request)
|
|
4226
|
-
results = await verify_cloud_models(force=req.force, provider_filter=req.provider)
|
|
4227
|
-
return {"verified": results, "ttl_seconds": CLOUD_VERIFY_TTL_SECONDS}
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
@app.post("/engines/pull-model")
|
|
4231
|
-
async def pull_ollama_model(req: PullModelRequest, request: Request):
|
|
4232
|
-
require_user(request)
|
|
4233
|
-
model_ref = normalize_local_model_request(req.model, None)
|
|
4234
|
-
if not model_ref:
|
|
4235
|
-
raise HTTPException(status_code=400, detail="λͺ¨λΈ μλ³μκ° λΉμ΄ μμ΅λλ€.")
|
|
4236
|
-
|
|
4237
|
-
if ":" in model_ref and model_ref.split(":", 1)[0].strip().lower() in {"ollama", "vllm", "lmstudio", "llamacpp", "local_mlx", "mlx"}:
|
|
4238
|
-
provider, model_name = model_ref.split(":", 1)
|
|
4239
|
-
provider = provider.strip().lower()
|
|
4240
|
-
model_name = model_name.strip()
|
|
4241
|
-
else:
|
|
4242
|
-
provider, model_name = "local_mlx", model_ref
|
|
4243
|
-
|
|
4244
|
-
if not model_name:
|
|
4245
|
-
raise HTTPException(status_code=400, detail="λͺ¨λΈ μ΄λ¦μ΄ λΉμ΄ μμ΅λλ€.")
|
|
4246
|
-
|
|
4247
|
-
if provider == "ollama":
|
|
4248
|
-
ensure_ollama_server()
|
|
4249
|
-
ollama = local_binary("ollama")
|
|
4250
|
-
if not ollama:
|
|
4251
|
-
raise HTTPException(status_code=400, detail="Ollamaκ° μ€μΉλμ§ μμμ΅λλ€.")
|
|
4252
|
-
try:
|
|
4253
|
-
completed = subprocess.run(
|
|
4254
|
-
[ollama, "pull", model_name],
|
|
4255
|
-
capture_output=True, text=True, timeout=900, check=False,
|
|
4256
|
-
)
|
|
4257
|
-
except subprocess.TimeoutExpired:
|
|
4258
|
-
raise HTTPException(status_code=408, detail="λͺ¨λΈ λ€μ΄λ‘λ μκ°μ΄ μ΄κ³Όλμμ΅λλ€.")
|
|
4259
|
-
if completed.returncode != 0:
|
|
4260
|
-
raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or "pull μ€ν¨")
|
|
4261
|
-
return {"provider": provider, "model": model_name, "returncode": completed.returncode}
|
|
4262
|
-
|
|
4263
|
-
if provider == "lmstudio":
|
|
4264
|
-
raise HTTPException(
|
|
4265
|
-
status_code=400,
|
|
4266
|
-
detail=(
|
|
4267
|
-
"LM Studio λͺ¨λΈμ Latticeμμ Hugging Faceλ‘ pullνμ§ μμ΅λλ€. "
|
|
4268
|
-
"LM Studio μ±μμ λͺ¨λΈμ λ€μ΄λ‘λνκ³ Local Serverλ₯Ό μΌ λ€ λͺ¨λΈμ λ‘λνμΈμ. "
|
|
4269
|
-
"κ·Έλ¬λ©΄ λͺ¨λΈ μ νμ°½μ μ€μ /v1/models νλͺ©μ΄ νμλ©λλ€."
|
|
4270
|
-
),
|
|
4271
|
-
)
|
|
4272
|
-
|
|
4273
|
-
if provider in {"vllm", "llamacpp", "local_mlx", "mlx"}:
|
|
4274
|
-
download_provider = "local_mlx" if provider == "mlx" else provider
|
|
4275
|
-
result = download_hf_model(model_name, download_provider)
|
|
4276
|
-
return {"provider": provider, "model": model_name, "returncode": 0, **result}
|
|
4277
|
-
|
|
4278
|
-
raise HTTPException(status_code=400, detail=f"{provider} μμ§ λͺ¨λΈ λ€μ΄λ‘λλ μμ§ μλνλμ§ μμμ΅λλ€.")
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
@app.post("/engines/prepare-model")
|
|
4282
|
-
async def engines_prepare_model(req: PrepareModelRequest, request: Request):
|
|
4283
|
-
require_user(request)
|
|
4284
|
-
return await prepare_and_load_model(
|
|
4285
|
-
req.model,
|
|
4286
|
-
request,
|
|
4287
|
-
engine=req.engine,
|
|
4288
|
-
user_email=req.user_email,
|
|
4289
|
-
)
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
@app.post("/engines/prepare-model/stream")
|
|
4293
|
-
async def engines_prepare_model_stream(req: PrepareModelRequest, request: Request):
|
|
4294
|
-
require_user(request)
|
|
4295
|
-
|
|
4296
|
-
async def event_stream():
|
|
4297
|
-
try:
|
|
4298
|
-
async for chunk in prepare_and_load_model_stream(
|
|
4299
|
-
req.model,
|
|
4300
|
-
request,
|
|
4301
|
-
engine=req.engine,
|
|
4302
|
-
user_email=req.user_email,
|
|
4303
|
-
):
|
|
4304
|
-
yield chunk
|
|
4305
|
-
except HTTPException as exc:
|
|
4306
|
-
yield sse_event("error", {
|
|
4307
|
-
"status_code": exc.status_code,
|
|
4308
|
-
"detail": exc.detail or "λͺ¨λΈ μ€λΉμ μ€ν¨νμ΅λλ€.",
|
|
4309
|
-
})
|
|
4310
|
-
except Exception as exc:
|
|
4311
|
-
logging.exception("model prepare stream failed")
|
|
4312
|
-
yield sse_event("error", {
|
|
4313
|
-
"status_code": 500,
|
|
4314
|
-
"detail": str(exc)[-1000:] or "λͺ¨λΈ μ€λΉμ μ€ν¨νμ΅λλ€.",
|
|
4315
|
-
})
|
|
4316
|
-
|
|
4317
|
-
return StreamingResponse(
|
|
4318
|
-
event_stream(),
|
|
4319
|
-
media_type="text/event-stream",
|
|
4320
|
-
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
4321
|
-
)
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
@app.post("/setup/set-api-key")
|
|
4325
|
-
async def set_api_key(req: SetApiKeyRequest, request: Request):
|
|
4326
|
-
from llm_router import OPENAI_COMPATIBLE_PROVIDERS
|
|
4327
|
-
config = OPENAI_COMPATIBLE_PROVIDERS.get(req.provider)
|
|
4328
|
-
if not config:
|
|
4329
|
-
raise HTTPException(status_code=400, detail="μ μ μλ νλ‘λ°μ΄λμ
λλ€.")
|
|
4330
|
-
if not req.key.strip():
|
|
4331
|
-
raise HTTPException(status_code=400, detail="API ν€κ° λΉμ΄μμ΅λλ€.")
|
|
4332
|
-
current_user = get_current_user(request)
|
|
4333
|
-
if REQUIRE_AUTH and not current_user:
|
|
4334
|
-
raise HTTPException(status_code=401, detail="μΈμ¦μ΄ νμν©λλ€.")
|
|
4335
|
-
# req.user_email μ ν΅ν ν κ³μ μμ‘°λ₯Ό λ°©μ§: κ΄λ¦¬μκ° μλλ©΄ λ³ΈμΈ μ΄λ©μΌλ§ νμ©
|
|
4336
|
-
if req.user_email and req.user_email != current_user:
|
|
4337
|
-
users = load_users()
|
|
4338
|
-
if get_user_role(current_user or "", users) != "admin":
|
|
4339
|
-
raise HTTPException(status_code=403, detail="λ€λ₯Έ μ¬μ©μμ API ν€λ₯Ό μ€μ ν κΆνμ΄ μμ΅λλ€.")
|
|
4340
|
-
target_email = (req.user_email or current_user or "").strip()
|
|
4341
|
-
if not target_email:
|
|
4342
|
-
raise HTTPException(status_code=400, detail="μ¬μ©μ μλ³μ΄ νμν©λλ€. λ‘κ·ΈμΈ ν λ€μ μλνμΈμ.")
|
|
4343
|
-
set_user_api_key(target_email, req.provider, req.key.strip())
|
|
4344
|
-
return {"ok": True, "provider": req.provider, "user_email": target_email, "scope": "user"}
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
def _recommended_with_engine_options(items: List[Dict[str, object]]) -> List[Dict[str, object]]:
|
|
4348
|
-
"""νΌλλ°± #1: μΆμ² λͺ¨λΈμ μμ§λ³ μ νμ§(engine_options)λ₯Ό λΆμ¬ λ΄λ €μ€λ€.
|
|
4349
|
-
|
|
4350
|
-
νλ‘ νΈμμ μΆμ² μΉ΄λλ₯Ό λλ₯΄λ μκ° μ΄λ μμ§/μ€μ λͺ¨λΈλ‘ λ€μ΄λ‘λ/λ‘λν μ§κ°
|
|
4351
|
-
μ΄λ―Έ νμ λλλ‘ νλ€.
|
|
4352
|
-
"""
|
|
4353
|
-
out: List[Dict[str, object]] = []
|
|
4354
|
-
for item in items:
|
|
4355
|
-
base = {
|
|
4356
|
-
"id": item["id"],
|
|
4357
|
-
"name": item["name"],
|
|
4358
|
-
"tag": item["tag"],
|
|
4359
|
-
"size": item["size"],
|
|
4360
|
-
"display_name": item.get("name") or item.get("id"),
|
|
4361
|
-
}
|
|
4362
|
-
short_id = str(item["id"]).lower()
|
|
4363
|
-
aliases = MODEL_ENGINE_ALIASES.get(short_id) or {}
|
|
4364
|
-
options: List[Dict[str, str]] = []
|
|
4365
|
-
for engine_name in ("local_mlx", "ollama", "lmstudio", "llamacpp", "vllm"):
|
|
4366
|
-
real = aliases.get(engine_name)
|
|
4367
|
-
if not real:
|
|
4368
|
-
continue
|
|
4369
|
-
options.append({
|
|
4370
|
-
"engine": engine_name,
|
|
4371
|
-
"model_id": real,
|
|
4372
|
-
"load_id": real if engine_name == "local_mlx" else f"{engine_name}:{real}",
|
|
4373
|
-
})
|
|
4374
|
-
# μ΄λ μμ§λ aliasκ° μμΌλ©΄ local_mlx μΉ΄νλ‘κ·Έ μ체λ₯Ό μ¬μ©νλ€.
|
|
4375
|
-
if not options:
|
|
4376
|
-
options.append({
|
|
4377
|
-
"engine": "local_mlx",
|
|
4378
|
-
"model_id": item["id"],
|
|
4379
|
-
"load_id": item["id"],
|
|
4380
|
-
})
|
|
4381
|
-
base["engine_options"] = options
|
|
4382
|
-
base["recommended_engine"] = options[0]["engine"]
|
|
4383
|
-
out.append(base)
|
|
4384
|
-
return out
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
@app.get("/models")
|
|
4388
|
-
async def list_models():
|
|
4389
|
-
"""HuggingFace μΆμ² λͺ¨λΈ λͺ©λ‘ λ° λ‘λ μν λ°ν"""
|
|
4390
|
-
recommended = _recommended_with_engine_options(
|
|
4391
|
-
list(filter_lower_family_versions(ENGINE_MODEL_CATALOG.get("local_mlx", [])))
|
|
4392
|
-
)
|
|
4393
|
-
return {
|
|
4394
|
-
"recommended": recommended,
|
|
4395
|
-
"cloud": router.detected_cloud_models(),
|
|
4396
|
-
"engines": await asyncio.to_thread(engine_status),
|
|
4397
|
-
"loaded": router.loaded_model_ids,
|
|
4398
|
-
"current": router.current_model_id,
|
|
4399
|
-
"compat_profiles": _list_compat_profiles(),
|
|
4400
|
-
}
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
@app.get("/models/compat-profiles")
|
|
4404
|
-
async def list_model_compat_profiles(request: Request):
|
|
4405
|
-
"""νΌλλ°± #3: Model Compatibility Layer μΊμ μνλ₯Ό μ‘°ννλ€."""
|
|
4406
|
-
require_user(request)
|
|
4407
|
-
return {"profiles": _list_compat_profiles()}
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
# ββ Model Management βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
4411
|
-
|
|
4412
|
-
@app.post("/models/load")
|
|
4413
|
-
async def load_model(req: LoadModelRequest, request: Request):
|
|
4414
|
-
"""λͺ¨λΈ λ‘λ (μ΄λ―Έ λ‘λλμΌλ©΄ μΊμμμ μ¦μ λ°ν)"""
|
|
4415
|
-
try:
|
|
4416
|
-
model_id = req.model_id
|
|
4417
|
-
requested_engine = req.engine or (model_id.split(":", 1)[0] if ":" in model_id else "local_mlx")
|
|
4418
|
-
if IS_PUBLIC_MODE and not ALLOW_LOCAL_MODELS and requested_engine in {"local_mlx", "mlx"}:
|
|
4419
|
-
raise HTTPException(
|
|
4420
|
-
status_code=400,
|
|
4421
|
-
detail="Public mode blocks local MLX model loading. Use openai:, openrouter:, groq:, together:, or set LATTICEAI_ALLOW_LOCAL_MODELS=true.",
|
|
4422
|
-
)
|
|
4423
|
-
return await prepare_and_load_model(
|
|
4424
|
-
model_id,
|
|
4425
|
-
request,
|
|
4426
|
-
engine=req.engine,
|
|
4427
|
-
user_email=req.user_email,
|
|
4428
|
-
adapter_path=req.adapter_path,
|
|
4429
|
-
draft_model_id=req.draft_model_id,
|
|
4430
|
-
)
|
|
4431
|
-
except HTTPException:
|
|
4432
|
-
raise
|
|
4433
|
-
except Exception as e:
|
|
4434
|
-
raise HTTPException(status_code=500, detail=str(e))
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
@app.post("/models/switch/{model_id:path}")
|
|
4438
|
-
async def switch_model(model_id: str, request: Request):
|
|
4439
|
-
"""μ΄λ―Έ λ‘λλ λͺ¨λΈ μ€ νμ± λͺ¨λΈ μ ν (μ¦μ, μ¬λ‘λ μμ)"""
|
|
4440
|
-
require_user(request)
|
|
4441
|
-
try:
|
|
4442
|
-
router.switch_model(model_id)
|
|
4443
|
-
return {"status": "ok", "current": router.current_model_id}
|
|
4444
|
-
except KeyError:
|
|
4445
|
-
raise HTTPException(status_code=404, detail=f"Model '{model_id}' not loaded. Call /models/load first.")
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
@app.delete("/models/unload/{model_id:path}")
|
|
4449
|
-
async def unload_model(model_id: str, request: Request):
|
|
4450
|
-
"""λͺ¨λΈ μΈλ‘λ β λ©λͺ¨λ¦¬ ν΄μ """
|
|
4451
|
-
require_user(request)
|
|
4452
|
-
router.unload_model(model_id)
|
|
4453
|
-
return {"status": "ok", "unloaded": model_id}
|
|
3514
|
+
# ββ Health / status / engine-summary router (latticeai.api.health, v1.2.0) βββ
|
|
3515
|
+
# /health, /mode, /runtime_features, /engines(GET) now live in the health router.
|
|
3516
|
+
# Heavier engine mutation endpoints remain below in server_app.
|
|
3517
|
+
MODEL_SERVICE = ModelService(
|
|
3518
|
+
model_router=router,
|
|
3519
|
+
runtime_features=runtime_features,
|
|
3520
|
+
is_public=IS_PUBLIC_MODE,
|
|
3521
|
+
)
|
|
3522
|
+
app.include_router(create_health_router(
|
|
3523
|
+
model_service=MODEL_SERVICE,
|
|
3524
|
+
engine_status=engine_status,
|
|
3525
|
+
get_current_user=get_current_user,
|
|
3526
|
+
require_auth=REQUIRE_AUTH,
|
|
3527
|
+
app_version=APP_VERSION,
|
|
3528
|
+
app_mode=APP_MODE,
|
|
3529
|
+
))
|
|
4454
3530
|
|
|
4455
3531
|
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
require_user
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
3532
|
+
# ββ Model / Engine router (latticeai.api.models, v1.3.0) βββββββββββββββββββββ
|
|
3533
|
+
app.include_router(create_models_router(
|
|
3534
|
+
model_router=router,
|
|
3535
|
+
require_user=require_user,
|
|
3536
|
+
get_current_user=get_current_user,
|
|
3537
|
+
load_users=load_users,
|
|
3538
|
+
get_user_role=get_user_role,
|
|
3539
|
+
install_engine=install_engine,
|
|
3540
|
+
verify_cloud_models=verify_cloud_models,
|
|
3541
|
+
normalize_local_model_request=normalize_local_model_request,
|
|
3542
|
+
download_hf_model=download_hf_model,
|
|
3543
|
+
prepare_and_load_model=prepare_and_load_model,
|
|
3544
|
+
prepare_and_load_model_stream=prepare_and_load_model_stream,
|
|
3545
|
+
sse_event=sse_event,
|
|
3546
|
+
ensure_ollama_server=ensure_ollama_server,
|
|
3547
|
+
local_binary=local_binary,
|
|
3548
|
+
engine_status=engine_status,
|
|
3549
|
+
filter_lower_family_versions=filter_lower_family_versions,
|
|
3550
|
+
list_compat_profiles=_list_compat_profiles,
|
|
3551
|
+
set_user_api_key=set_user_api_key,
|
|
3552
|
+
engine_model_catalog=ENGINE_MODEL_CATALOG,
|
|
3553
|
+
model_engine_aliases=MODEL_ENGINE_ALIASES,
|
|
3554
|
+
cloud_verify_ttl_seconds=CLOUD_VERIFY_TTL_SECONDS,
|
|
3555
|
+
is_public_mode=IS_PUBLIC_MODE,
|
|
3556
|
+
allow_local_models=ALLOW_LOCAL_MODELS,
|
|
3557
|
+
require_auth=REQUIRE_AUTH,
|
|
3558
|
+
))
|
|
4463
3559
|
|
|
4464
3560
|
|
|
4465
3561
|
# ββ Chat / Completion ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -4615,7 +3711,7 @@ async def chat(req: ChatRequest, request: Request):
|
|
|
4615
3711
|
except Exception:
|
|
4616
3712
|
pass
|
|
4617
3713
|
|
|
4618
|
-
trace_seed =
|
|
3714
|
+
trace_seed = CHAT_SERVICE.build_graph_trace(
|
|
4619
3715
|
req.message,
|
|
4620
3716
|
KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
|
|
4621
3717
|
context,
|
|
@@ -4653,7 +3749,7 @@ async def chat(req: ChatRequest, request: Request):
|
|
|
4653
3749
|
full_text += footnote
|
|
4654
3750
|
session.update(graph_md, full_text, req.conversation_id)
|
|
4655
3751
|
save_to_history("assistant", full_text, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
4656
|
-
trace_record =
|
|
3752
|
+
trace_record = CHAT_SERVICE.record_trace(
|
|
4657
3753
|
question=req.message,
|
|
4658
3754
|
response=full_text,
|
|
4659
3755
|
conversation_id=req.conversation_id,
|
|
@@ -4679,7 +3775,7 @@ async def chat(req: ChatRequest, request: Request):
|
|
|
4679
3775
|
result += footnote
|
|
4680
3776
|
session.update(graph_md, result, req.conversation_id)
|
|
4681
3777
|
save_to_history("assistant", str(result), source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
4682
|
-
trace_record =
|
|
3778
|
+
trace_record = CHAT_SERVICE.record_trace(
|
|
4683
3779
|
question=req.message,
|
|
4684
3780
|
response=str(result),
|
|
4685
3781
|
conversation_id=req.conversation_id,
|
|
@@ -4716,7 +3812,7 @@ async def chat(req: ChatRequest, request: Request):
|
|
|
4716
3812
|
result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
|
|
4717
3813
|
|
|
4718
3814
|
save_to_history("assistant", str(result), source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
4719
|
-
trace_record =
|
|
3815
|
+
trace_record = CHAT_SERVICE.record_trace(
|
|
4720
3816
|
question=req.message,
|
|
4721
3817
|
response=str(result),
|
|
4722
3818
|
conversation_id=req.conversation_id,
|
|
@@ -4820,12 +3916,12 @@ async def _stream_chat(
|
|
|
4820
3916
|
yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
|
|
4821
3917
|
history_user = get_history_user(req.user_email, req.user_nickname)
|
|
4822
3918
|
save_to_history("assistant", full_response, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
4823
|
-
trace_record =
|
|
3919
|
+
trace_record = CHAT_SERVICE.record_trace(
|
|
4824
3920
|
question=req.message,
|
|
4825
3921
|
response=full_response,
|
|
4826
3922
|
conversation_id=req.conversation_id,
|
|
4827
3923
|
user_email=effective_email or req.user_email,
|
|
4828
|
-
trace=trace_seed or
|
|
3924
|
+
trace=trace_seed or CHAT_SERVICE.build_graph_trace(
|
|
4829
3925
|
req.message,
|
|
4830
3926
|
KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
|
|
4831
3927
|
context,
|
|
@@ -6103,324 +5199,24 @@ async def tools_permissions(request: Request):
|
|
|
6103
5199
|
return {"status": "ok", "permissions": list_tool_permissions()}
|
|
6104
5200
|
|
|
6105
5201
|
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
|
|
6124
|
-
"rollback": policy["rollback"],
|
|
6125
|
-
},
|
|
6126
|
-
})
|
|
6127
|
-
return {
|
|
6128
|
-
"status": "ok",
|
|
6129
|
-
"workspace": str(AGENT_ROOT),
|
|
6130
|
-
"installed_mcps": [mcp_public_item(item, installed) for item in registry],
|
|
6131
|
-
"tools": tools,
|
|
6132
|
-
}
|
|
6133
|
-
|
|
6134
|
-
|
|
6135
|
-
@app.post("/mcp/recommend")
|
|
6136
|
-
async def mcp_recommend(req: McpRecommendRequest, request: Request):
|
|
6137
|
-
require_user(request)
|
|
6138
|
-
return {"recommendations": await recommend_mcps(req.query, req.limit)}
|
|
6139
|
-
|
|
6140
|
-
|
|
6141
|
-
@app.post("/mcp/install")
|
|
6142
|
-
async def mcp_install(req: McpInstallRequest, request: Request):
|
|
6143
|
-
admin_email, _ = require_admin(request)
|
|
6144
|
-
append_audit_event("mcp_install", user_email=admin_email, mcp_id=req.mcp_id)
|
|
6145
|
-
return await install_mcp(req.mcp_id)
|
|
6146
|
-
|
|
6147
|
-
|
|
6148
|
-
@app.get("/mcp/installed")
|
|
6149
|
-
async def mcp_installed(request: Request):
|
|
6150
|
-
require_user(request)
|
|
6151
|
-
installed = load_mcp_installs().get("installed", {})
|
|
6152
|
-
registry = await _get_combined_registry()
|
|
6153
|
-
return {"installed": [mcp_public_item(item, installed) for item in registry]}
|
|
6154
|
-
|
|
6155
|
-
|
|
6156
|
-
@app.get("/mcp/connectors/{mcp_id}")
|
|
6157
|
-
async def mcp_connector(mcp_id: str, request: Request):
|
|
6158
|
-
require_user(request)
|
|
6159
|
-
registry = await _get_combined_registry()
|
|
6160
|
-
item = next((e for e in registry if e["id"] == mcp_id), None)
|
|
6161
|
-
if not item or item.get("install_mode") != "connector":
|
|
6162
|
-
raise HTTPException(status_code=404, detail="컀λ₯ν°λ₯Ό μ°Ύμ μ μμ΅λλ€.")
|
|
6163
|
-
installed = load_mcp_installs().get("installed", {})
|
|
6164
|
-
public = mcp_public_item(item, installed)
|
|
6165
|
-
public["instructions"] = [
|
|
6166
|
-
"Codex λλ ChatGPT μ±μ Connectors μ€μ μ μ½λλ€.",
|
|
6167
|
-
f"{item['name']} νλͺ©μ μ ννκ³ κ³μ μ μΈμ¦ν©λλ€.",
|
|
6168
|
-
"μΈμ¦ ν Lattice AIμμ μ΄ MCPλ₯Ό λ€μ νμ±ννλ©΄ μμ
μ μ¬μ©ν μ μμ΅λλ€.",
|
|
6169
|
-
]
|
|
6170
|
-
return public
|
|
6171
|
-
|
|
6172
|
-
|
|
6173
|
-
@app.post("/mcp/registry/refresh")
|
|
6174
|
-
async def mcp_registry_refresh(request: Request):
|
|
6175
|
-
require_user(request)
|
|
6176
|
-
mcp_registry._REMOTE_REGISTRY_FETCHED_AT = None
|
|
6177
|
-
registry = await _get_combined_registry()
|
|
6178
|
-
return {"status": "ok", "total": len(registry), "remote": len(mcp_registry._REMOTE_REGISTRY_CACHE)}
|
|
6179
|
-
|
|
6180
|
-
|
|
6181
|
-
@app.get("/mcp/claude-code-servers")
|
|
6182
|
-
async def mcp_claude_code_servers(request: Request):
|
|
6183
|
-
"""Read ~/.claude/settings.json mcpServers and return them as Lattice MCP items."""
|
|
6184
|
-
require_user(request)
|
|
6185
|
-
settings_path = Path.home() / ".claude" / "settings.json"
|
|
6186
|
-
if not settings_path.exists():
|
|
6187
|
-
return {"servers": []}
|
|
6188
|
-
try:
|
|
6189
|
-
with open(settings_path, "r", encoding="utf-8") as f:
|
|
6190
|
-
settings = json.load(f)
|
|
6191
|
-
mcp_servers = settings.get("mcpServers", {})
|
|
6192
|
-
servers = []
|
|
6193
|
-
for name, cfg in mcp_servers.items():
|
|
6194
|
-
cmd = cfg.get("command", "")
|
|
6195
|
-
args = cfg.get("args", [])
|
|
6196
|
-
package = " ".join([cmd] + args) if args else cmd
|
|
6197
|
-
env = cfg.get("env", {})
|
|
6198
|
-
env_vars = [{"name": k, "value": v} for k, v in env.items()]
|
|
6199
|
-
servers.append({
|
|
6200
|
-
"id": f"claude-code:{name}",
|
|
6201
|
-
"name": name,
|
|
6202
|
-
"description": f"Claude Code MCP: {package}",
|
|
6203
|
-
"package": package,
|
|
6204
|
-
"icon": "π€",
|
|
6205
|
-
"category": "Claude Code",
|
|
6206
|
-
"source": "claude-code",
|
|
6207
|
-
"installed": True,
|
|
6208
|
-
"env_vars": env_vars,
|
|
6209
|
-
})
|
|
6210
|
-
return {"servers": servers}
|
|
6211
|
-
except Exception as e:
|
|
6212
|
-
logging.warning("mcp_claude_code_servers failed: %s", e)
|
|
6213
|
-
return {"servers": []}
|
|
6214
|
-
|
|
6215
|
-
|
|
6216
|
-
_CUSTOM_MCP_FILE = DATA_DIR / "custom_mcps.json"
|
|
6217
|
-
|
|
6218
|
-
def _load_custom_mcps() -> List[Dict]:
|
|
6219
|
-
if not _CUSTOM_MCP_FILE.exists():
|
|
6220
|
-
return []
|
|
6221
|
-
try:
|
|
6222
|
-
with open(_CUSTOM_MCP_FILE, "r", encoding="utf-8") as f:
|
|
6223
|
-
return json.load(f)
|
|
6224
|
-
except Exception:
|
|
6225
|
-
return []
|
|
6226
|
-
|
|
6227
|
-
def _save_custom_mcps(items: List[Dict]):
|
|
6228
|
-
with open(_CUSTOM_MCP_FILE, "w", encoding="utf-8") as f:
|
|
6229
|
-
json.dump(items, f, ensure_ascii=False, indent=2)
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
@app.get("/mcp/custom")
|
|
6233
|
-
async def mcp_custom_list(request: Request):
|
|
6234
|
-
"""Return user-added custom MCP entries."""
|
|
6235
|
-
require_user(request)
|
|
6236
|
-
return {"custom": _load_custom_mcps()}
|
|
6237
|
-
|
|
6238
|
-
|
|
6239
|
-
@app.post("/mcp/custom")
|
|
6240
|
-
async def mcp_custom_add(req: McpCustomRequest, request: Request):
|
|
6241
|
-
"""Save a custom MCP entry (admin-only)."""
|
|
6242
|
-
admin_email, _ = require_admin(request)
|
|
6243
|
-
append_audit_event("mcp_custom_add", user_email=admin_email, name=req.name, package=req.package)
|
|
6244
|
-
if not req.name.strip():
|
|
6245
|
-
raise HTTPException(status_code=400, detail="nameμ νμμ
λλ€.")
|
|
6246
|
-
if not req.package.strip():
|
|
6247
|
-
raise HTTPException(status_code=400, detail="packageλ νμμ
λλ€.")
|
|
6248
|
-
items = _load_custom_mcps()
|
|
6249
|
-
entry = {
|
|
6250
|
-
"id": f"custom:{req.name.strip().lower().replace(' ', '-')}",
|
|
6251
|
-
"name": req.name.strip(),
|
|
6252
|
-
"package": req.package.strip(),
|
|
6253
|
-
"description": req.description.strip(),
|
|
6254
|
-
"category": req.category or "custom",
|
|
6255
|
-
"icon": req.icon or "π",
|
|
6256
|
-
"env_vars": req.env_vars or [],
|
|
6257
|
-
"install_mode": "npm",
|
|
6258
|
-
"source": "custom",
|
|
6259
|
-
"installed": False,
|
|
6260
|
-
"added_at": datetime.now().isoformat(),
|
|
6261
|
-
}
|
|
6262
|
-
# overwrite if same id
|
|
6263
|
-
items = [e for e in items if e["id"] != entry["id"]]
|
|
6264
|
-
items.append(entry)
|
|
6265
|
-
_save_custom_mcps(items)
|
|
6266
|
-
return {"status": "ok", "entry": entry}
|
|
6267
|
-
|
|
6268
|
-
|
|
6269
|
-
@app.delete("/mcp/custom/{mcp_id:path}")
|
|
6270
|
-
async def mcp_custom_delete(mcp_id: str, request: Request):
|
|
6271
|
-
"""Remove a custom MCP entry."""
|
|
6272
|
-
require_admin(request)
|
|
6273
|
-
items = _load_custom_mcps()
|
|
6274
|
-
before = len(items)
|
|
6275
|
-
items = [e for e in items if e["id"] != mcp_id]
|
|
6276
|
-
if len(items) == before:
|
|
6277
|
-
raise HTTPException(status_code=404, detail="νλͺ©μ μ°Ύμ μ μμ΅λλ€.")
|
|
6278
|
-
_save_custom_mcps(items)
|
|
6279
|
-
return {"status": "ok"}
|
|
6280
|
-
|
|
6281
|
-
|
|
6282
|
-
# ββ Skills & Plugin Directory endpoints βββββββββββββββββββββββββββββββββββββββ
|
|
6283
|
-
|
|
6284
|
-
@app.get("/skills/marketplace")
|
|
6285
|
-
async def skills_marketplace(request: Request, category: Optional[str] = None, author: Optional[str] = None):
|
|
6286
|
-
"""Skills λ§μΌνλ μ΄μ€ (Anthropic Apache-2.0 + κ²μ¦λ μλνν° MIT/Apache-2.0)"""
|
|
6287
|
-
require_user(request)
|
|
6288
|
-
skills = await _fetch_skills_marketplace()
|
|
6289
|
-
installed_names = {d.name for d in SKILLS_DIR.iterdir() if d.is_dir()} if SKILLS_DIR.exists() else set()
|
|
6290
|
-
filtered = skills
|
|
6291
|
-
if category:
|
|
6292
|
-
filtered = [s for s in filtered if s.get("category", "").lower() == category.lower()]
|
|
6293
|
-
if author:
|
|
6294
|
-
filtered = [s for s in filtered if s.get("author", "").lower() == author.lower()]
|
|
6295
|
-
return {
|
|
6296
|
-
"skills": [{**s, "installed": s["skill"] in installed_names} for s in filtered],
|
|
6297
|
-
"total": len(filtered),
|
|
6298
|
-
"authors": sorted({s["author"] for s in skills}),
|
|
6299
|
-
"categories": sorted({s["category"] for s in skills}),
|
|
6300
|
-
}
|
|
6301
|
-
|
|
6302
|
-
|
|
6303
|
-
@app.post("/skills/install")
|
|
6304
|
-
async def skills_install(req: SkillInstallRequest, request: Request):
|
|
6305
|
-
"""skillμ λ‘컬 skills λλ ν°λ¦¬μ μ€μΉ (Apache-2.0 / MIT, κ΄λ¦¬μ μ μ©)"""
|
|
6306
|
-
admin_email, _ = require_admin(request)
|
|
6307
|
-
append_audit_event("skill_install", user_email=admin_email, plugin=req.plugin, skill=req.skill)
|
|
6308
|
-
return await install_skill(req.plugin, req.skill)
|
|
6309
|
-
|
|
6310
|
-
|
|
6311
|
-
@app.get("/skills/list")
|
|
6312
|
-
async def skills_list(request: Request):
|
|
6313
|
-
"""λ‘컬μ μ€μΉλ skills λͺ©λ‘"""
|
|
6314
|
-
require_user(request)
|
|
6315
|
-
if not SKILLS_DIR.exists():
|
|
6316
|
-
return {"skills": []}
|
|
6317
|
-
skills = []
|
|
6318
|
-
for skill_dir in sorted(SKILLS_DIR.iterdir()):
|
|
6319
|
-
if not skill_dir.is_dir():
|
|
6320
|
-
continue
|
|
6321
|
-
skill_md = skill_dir / "SKILL.md"
|
|
6322
|
-
if not skill_md.exists():
|
|
6323
|
-
continue
|
|
6324
|
-
lines = skill_md.read_text(encoding="utf-8").splitlines()
|
|
6325
|
-
desc = next((l.split(":", 1)[1].strip() for l in lines if l.startswith("description:")), "")
|
|
6326
|
-
comment = lines[0] if lines else ""
|
|
6327
|
-
if "anthropics/claude-plugins-official" in comment:
|
|
6328
|
-
source = "anthropic"
|
|
6329
|
-
elif "Source:" in comment:
|
|
6330
|
-
source = "third-party"
|
|
6331
|
-
else:
|
|
6332
|
-
source = "local"
|
|
6333
|
-
skills.append({"name": skill_dir.name, "description": desc, "source": source})
|
|
6334
|
-
return {"skills": skills, "total": len(skills)}
|
|
6335
|
-
|
|
6336
|
-
|
|
6337
|
-
@app.post("/skills/marketplace/refresh")
|
|
6338
|
-
async def skills_marketplace_refresh(request: Request):
|
|
6339
|
-
"""Skills λ§μΌνλ μ΄μ€ μΊμ κ°μ κ°±μ """
|
|
6340
|
-
require_user(request)
|
|
6341
|
-
mcp_registry._SKILLS_MARKETPLACE_FETCHED_AT = None
|
|
6342
|
-
skills = await _fetch_skills_marketplace()
|
|
6343
|
-
by_author = {}
|
|
6344
|
-
for s in skills:
|
|
6345
|
-
by_author[s["author"]] = by_author.get(s["author"], 0) + 1
|
|
6346
|
-
return {"status": "ok", "total": len(skills), "by_author": by_author}
|
|
6347
|
-
|
|
6348
|
-
|
|
6349
|
-
@app.get("/plugins/directory")
|
|
6350
|
-
async def plugins_directory(
|
|
6351
|
-
request: Request,
|
|
6352
|
-
category: Optional[str] = None,
|
|
6353
|
-
license: Optional[str] = None,
|
|
6354
|
-
q: Optional[str] = None,
|
|
6355
|
-
):
|
|
6356
|
-
"""μ€νμμ€ νλ¬κ·ΈμΈ λλ ν°λ¦¬ (Apache-2.0 / MIT / MIT-0, λ°νμ fetch)"""
|
|
6357
|
-
require_user(request)
|
|
6358
|
-
plugins = await _fetch_plugin_directory()
|
|
6359
|
-
filtered = plugins
|
|
6360
|
-
if category:
|
|
6361
|
-
filtered = [p for p in filtered if p.get("category", "").lower() == category.lower()]
|
|
6362
|
-
if license:
|
|
6363
|
-
filtered = [p for p in filtered if p.get("license", "").lower() == license.lower()]
|
|
6364
|
-
if q:
|
|
6365
|
-
q_lower = q.lower()
|
|
6366
|
-
filtered = [
|
|
6367
|
-
p for p in filtered
|
|
6368
|
-
if q_lower in p.get("name", "").lower()
|
|
6369
|
-
or q_lower in p.get("description", "").lower()
|
|
6370
|
-
or q_lower in p.get("author", "").lower()
|
|
6371
|
-
]
|
|
6372
|
-
return {
|
|
6373
|
-
"plugins": filtered,
|
|
6374
|
-
"total": len(filtered),
|
|
6375
|
-
"categories": sorted({p["category"] for p in plugins if p.get("category")}),
|
|
6376
|
-
"licenses": sorted({p["license"] for p in plugins if p.get("license")}),
|
|
6377
|
-
}
|
|
6378
|
-
|
|
6379
|
-
|
|
6380
|
-
@app.post("/plugins/directory/refresh")
|
|
6381
|
-
async def plugins_directory_refresh(request: Request):
|
|
6382
|
-
"""νλ¬κ·ΈμΈ λλ ν°λ¦¬ μΊμ κ°μ κ°±μ """
|
|
6383
|
-
require_user(request)
|
|
6384
|
-
mcp_registry._PLUGIN_DIRECTORY_FETCHED_AT = None
|
|
6385
|
-
plugins = await _fetch_plugin_directory()
|
|
6386
|
-
by_license = {}
|
|
6387
|
-
for p in plugins:
|
|
6388
|
-
lic = p.get("license", "unknown")
|
|
6389
|
-
by_license[lic] = by_license.get(lic, 0) + 1
|
|
6390
|
-
return {"status": "ok", "total": len(plugins), "by_license": by_license}
|
|
6391
|
-
|
|
6392
|
-
|
|
6393
|
-
@app.post("/mcp/call")
|
|
6394
|
-
async def mcp_call(req: McpCallRequest, request: Request):
|
|
6395
|
-
current_user = require_user(request)
|
|
6396
|
-
args = req.args or {}
|
|
6397
|
-
if req.action == "knowledge_graph_ingest":
|
|
6398
|
-
_require_graph()
|
|
6399
|
-
return KNOWLEDGE_GRAPH.ingest_message(
|
|
6400
|
-
args.get("role") or ("assistant" if args.get("type") == "ai_response" else "user"),
|
|
6401
|
-
args.get("content") or "",
|
|
6402
|
-
user_email=args.get("user_email") or current_user,
|
|
6403
|
-
user_nickname=args.get("user_nickname"),
|
|
6404
|
-
source=args.get("source") or "mcp",
|
|
6405
|
-
conversation_id=args.get("conversation_id"),
|
|
6406
|
-
raw=args,
|
|
6407
|
-
)
|
|
6408
|
-
if req.action == "knowledge_graph_search":
|
|
6409
|
-
_require_graph()
|
|
6410
|
-
return KNOWLEDGE_GRAPH.search(args.get("query") or args.get("q") or "", args.get("limit", 30))
|
|
6411
|
-
if req.action == "knowledge_graph_graph":
|
|
6412
|
-
_require_graph()
|
|
6413
|
-
return KNOWLEDGE_GRAPH.graph(args.get("limit", 300))
|
|
6414
|
-
if req.action == "knowledge_graph_context":
|
|
6415
|
-
_require_graph()
|
|
6416
|
-
return {
|
|
6417
|
-
"context": KNOWLEDGE_GRAPH.context_for_query(
|
|
6418
|
-
args.get("query") or args.get("q") or "",
|
|
6419
|
-
args.get("limit", 6),
|
|
6420
|
-
)
|
|
6421
|
-
}
|
|
6422
|
-
_check_tool_role(req.action, current_user)
|
|
6423
|
-
return _tool_response(execute_tool, req.action, req.args or {})
|
|
5202
|
+
# ββ MCP / skills / plugins router (latticeai.api.mcp, v1.3.0) ββββββββββββββββ
|
|
5203
|
+
app.include_router(create_mcp_router(
|
|
5204
|
+
require_user=require_user,
|
|
5205
|
+
require_admin=require_admin,
|
|
5206
|
+
append_audit_event=append_audit_event,
|
|
5207
|
+
load_mcp_installs=load_mcp_installs,
|
|
5208
|
+
recommend_mcps=recommend_mcps,
|
|
5209
|
+
install_mcp=install_mcp,
|
|
5210
|
+
mcp_public_item=mcp_public_item,
|
|
5211
|
+
get_tool_permission=get_tool_permission,
|
|
5212
|
+
tool_governance=TOOL_GOVERNANCE,
|
|
5213
|
+
tool_governance_default=_TOOL_GOVERNANCE_DEFAULT,
|
|
5214
|
+
check_tool_role=_check_tool_role,
|
|
5215
|
+
tool_response=_tool_response,
|
|
5216
|
+
require_graph=_require_graph,
|
|
5217
|
+
knowledge_graph=KNOWLEDGE_GRAPH,
|
|
5218
|
+
data_dir=DATA_DIR,
|
|
5219
|
+
))
|
|
6424
5220
|
|
|
6425
5221
|
|
|
6426
5222
|
# ββ P-Reinforce Knowledge Gardener ββββββββββββββββββββββββββββββββββββββββββββ
|