ltcai 0.6.0 → 1.0.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.
@@ -91,6 +91,11 @@ from latticeai.core.graph_curator import (
91
91
  mask_secrets as _curator_mask_secrets,
92
92
  )
93
93
  from latticeai.core.config import Config
94
+ from latticeai.core.workspace_os import (
95
+ WORKSPACE_OS_VERSION,
96
+ WorkspaceOSStore,
97
+ remove_skill_directory,
98
+ )
94
99
  from latticeai.core.agent import (
95
100
  AgentState,
96
101
  AgentRunContext,
@@ -243,6 +248,7 @@ async def single_text_stream(text: str, model: str = "system") -> AsyncIterator[
243
248
  # The module-level names below are kept as a compatibility surface for the rest
244
249
  # of server.py; all of them are now derived from a single CONFIG instance.
245
250
  CONFIG = Config.from_env()
251
+ APP_VERSION = WORKSPACE_OS_VERSION
246
252
 
247
253
  APP_MODE = CONFIG.app_mode
248
254
  IS_PUBLIC_MODE = CONFIG.is_public
@@ -345,6 +351,7 @@ AUDIT_FILE = DATA_DIR / "audit_log.json"
345
351
  SSO_FILE = DATA_DIR / "sso_config.json"
346
352
  KNOWLEDGE_GRAPH = KnowledgeGraphStore(DATA_DIR / "knowledge_graph.sqlite", DATA_DIR / "knowledge_graph_blobs") if ENABLE_GRAPH else None
347
353
  LOCAL_KG_WATCHER = LocalKnowledgeWatcher(lambda: KNOWLEDGE_GRAPH) if ENABLE_GRAPH else None
354
+ WORKSPACE_OS = WorkspaceOSStore(DATA_DIR)
348
355
 
349
356
  def _require_graph():
350
357
  if not ENABLE_GRAPH or KNOWLEDGE_GRAPH is None:
@@ -1125,7 +1132,7 @@ async def lifespan(app: FastAPI):
1125
1132
  except Exception:
1126
1133
  pass
1127
1134
 
1128
- app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version="0.6.0", lifespan=lifespan)
1135
+ app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version=APP_VERSION, lifespan=lifespan)
1129
1136
 
1130
1137
  CORS_ALLOWED_ORIGINS = [
1131
1138
  f"http://localhost:{DEFAULT_PORT}",
@@ -1305,6 +1312,23 @@ async def admin_page():
1305
1312
  response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
1306
1313
  return response
1307
1314
 
1315
+ @app.get("/workspace")
1316
+ async def workspace_page(request: Request):
1317
+ require_user(request)
1318
+ workspace_path = STATIC_DIR / "workspace.html"
1319
+ if not workspace_path.exists():
1320
+ raise HTTPException(status_code=404, detail="Workspace OS UI not found.")
1321
+ return ui_file_response(workspace_path)
1322
+
1323
+
1324
+ @app.get("/onboarding")
1325
+ async def onboarding_page(request: Request):
1326
+ require_user(request)
1327
+ workspace_path = STATIC_DIR / "workspace.html"
1328
+ if not workspace_path.exists():
1329
+ raise HTTPException(status_code=404, detail="Workspace OS UI not found.")
1330
+ return ui_file_response(workspace_path)
1331
+
1308
1332
  @app.get("/status")
1309
1333
  async def status():
1310
1334
  """서버 상태 및 현재 로드된 모델 정보를 반환합니다."""
@@ -1408,6 +1432,81 @@ class VerifyCloudRequest(BaseModel):
1408
1432
  provider: Optional[str] = None
1409
1433
 
1410
1434
 
1435
+ class WorkspaceOnboardingStepRequest(BaseModel):
1436
+ step: str
1437
+ status: str = "complete"
1438
+ data: Dict = {}
1439
+ error: str = ""
1440
+
1441
+
1442
+ class WorkspaceOnboardingCompleteRequest(BaseModel):
1443
+ data: Dict = {}
1444
+
1445
+
1446
+ class WorkspaceSnapshotRequest(BaseModel):
1447
+ name: str = "Workspace snapshot"
1448
+
1449
+
1450
+ class WorkspaceSnapshotCompareRequest(BaseModel):
1451
+ before_id: str
1452
+ after_id: str
1453
+
1454
+
1455
+ class WorkspaceMemoryRequest(BaseModel):
1456
+ kind: str
1457
+ content: str
1458
+ tags: List[str] = []
1459
+ memory_id: Optional[str] = None
1460
+ metadata: Dict = {}
1461
+
1462
+
1463
+ class WorkspaceAgentRunRequest(BaseModel):
1464
+ agent_id: str = "agent:executor"
1465
+ status: str = "ok"
1466
+ input: str = ""
1467
+ output: str = ""
1468
+ timeline: List[Dict] = []
1469
+ relationships: List[str] = []
1470
+
1471
+
1472
+ class WorkspaceWorkflowRequest(BaseModel):
1473
+ name: str
1474
+ steps: List[Dict] = []
1475
+ metadata: Dict = {}
1476
+
1477
+
1478
+ class WorkspaceWorkflowEventRequest(BaseModel):
1479
+ event_type: str
1480
+ payload: Dict = {}
1481
+
1482
+
1483
+ class WorkspaceComputerMemoryRequest(BaseModel):
1484
+ enabled: bool = False
1485
+ consent: Dict = {}
1486
+ scopes: List[str] = []
1487
+
1488
+
1489
+ class WorkspaceComputerActivityRequest(BaseModel):
1490
+ activity: Dict = {}
1491
+
1492
+
1493
+ class WorkspaceSkillActionRequest(BaseModel):
1494
+ skill: str
1495
+ plugin: Optional[str] = None
1496
+ enabled: Optional[bool] = None
1497
+ version: Optional[str] = None
1498
+ metadata: Dict = {}
1499
+
1500
+
1501
+ class WorkspaceVSCodeRequest(BaseModel):
1502
+ action: str
1503
+ file_path: Optional[str] = None
1504
+ language: Optional[str] = None
1505
+ content: str = ""
1506
+ selection: str = ""
1507
+ prompt: str = ""
1508
+
1509
+
1411
1510
  class GardenRequest(BaseModel):
1412
1511
  raw_data: str
1413
1512
  category: Optional[str] = None # 10_Wiki / 00_Raw / Skills
@@ -1580,6 +1679,441 @@ class ToolGitShowRequest(BaseModel):
1580
1679
  cwd: Optional[str] = "."
1581
1680
 
1582
1681
 
1682
+ # ── Workspace OS 1.0 API ─────────────────────────────────────────────────────
1683
+
1684
+ def _workspace_settings_payload() -> Dict:
1685
+ return {
1686
+ "mode": APP_MODE,
1687
+ "host": DEFAULT_HOST,
1688
+ "port": DEFAULT_PORT,
1689
+ "require_auth": REQUIRE_AUTH,
1690
+ "enable_graph": ENABLE_GRAPH,
1691
+ "allow_local_models": ALLOW_LOCAL_MODELS,
1692
+ "static_dir": str(STATIC_DIR),
1693
+ "data_dir": str(DATA_DIR),
1694
+ }
1695
+
1696
+
1697
+ def _workspace_models_payload() -> Dict:
1698
+ return {
1699
+ "current_model": router.current_model_id,
1700
+ "loaded_models": router.loaded_model_ids,
1701
+ "public_model": PUBLIC_MODEL,
1702
+ "local_model": LOCAL_MODEL,
1703
+ "local_draft_model": LOCAL_DRAFT_MODEL,
1704
+ }
1705
+
1706
+
1707
+ def _workspace_graph():
1708
+ return KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None
1709
+
1710
+
1711
+ @app.get("/workspace/os")
1712
+ async def workspace_os_summary(request: Request):
1713
+ require_user(request)
1714
+ summary = WORKSPACE_OS.summary()
1715
+ summary["graph"] = _graph_stats_safe()
1716
+ summary["models"] = _workspace_models_payload()
1717
+ return summary
1718
+
1719
+
1720
+ @app.get("/workspace/onboarding/status")
1721
+ async def workspace_onboarding_status(request: Request):
1722
+ require_user(request)
1723
+ return WORKSPACE_OS.onboarding_status(load_users(), _graph_stats_safe())
1724
+
1725
+
1726
+ @app.post("/workspace/onboarding/step")
1727
+ async def workspace_onboarding_step(req: WorkspaceOnboardingStepRequest, request: Request):
1728
+ current_user = require_user(request)
1729
+ return WORKSPACE_OS.update_onboarding_step(
1730
+ req.step,
1731
+ status=req.status,
1732
+ data=req.data,
1733
+ error=req.error,
1734
+ user_email=current_user or None,
1735
+ )
1736
+
1737
+
1738
+ @app.post("/workspace/onboarding/complete")
1739
+ async def workspace_onboarding_complete(req: WorkspaceOnboardingCompleteRequest, request: Request):
1740
+ current_user = require_user(request)
1741
+ append_audit_event("onboarding_complete", user_email=current_user, platform="AI Workspace OS")
1742
+ return WORKSPACE_OS.complete_onboarding(req.data, user_email=current_user or None)
1743
+
1744
+
1745
+ @app.get("/workspace/onboarding/hardware")
1746
+ async def workspace_onboarding_hardware(request: Request):
1747
+ require_user(request)
1748
+ env = await asyncio.to_thread(scan_environment)
1749
+ sysinfo = await local_sysinfo(request)
1750
+ payload = {"environment": env, "sysinfo": sysinfo, "scanned_at": datetime.now().isoformat()}
1751
+ WORKSPACE_OS.update_onboarding_step("hardware", status="complete", data=payload, user_email=get_current_user(request))
1752
+ return payload
1753
+
1754
+
1755
+ @app.get("/workspace/onboarding/model-recommendations")
1756
+ async def workspace_onboarding_model_recommendations(request: Request):
1757
+ require_user(request)
1758
+ env = await asyncio.to_thread(scan_environment)
1759
+ recommendations = get_recommendations(env)
1760
+ payload = {
1761
+ "environment": env,
1762
+ "recommendations": recommendations,
1763
+ "default_local_model": LOCAL_MODEL,
1764
+ "default_public_model": PUBLIC_MODEL,
1765
+ }
1766
+ WORKSPACE_OS.update_onboarding_step("model_recommendation", status="complete", data=payload, user_email=get_current_user(request))
1767
+ return payload
1768
+
1769
+
1770
+ @app.get("/workspace/traces")
1771
+ async def workspace_traces(request: Request, conversation_id: Optional[str] = None, limit: int = 50):
1772
+ require_user(request)
1773
+ return WORKSPACE_OS.list_traces(conversation_id=conversation_id, limit=limit)
1774
+
1775
+
1776
+ @app.get("/workspace/indexing")
1777
+ async def workspace_indexing_dashboard(request: Request):
1778
+ require_user(request)
1779
+ graph = _workspace_graph()
1780
+ watcher_status = LOCAL_KG_WATCHER.status() if LOCAL_KG_WATCHER else {"available": False, "active": {}}
1781
+ return WORKSPACE_OS.build_indexing_dashboard(graph, watcher_status)
1782
+
1783
+
1784
+ @app.post("/workspace/indexing/{source_id}/pause")
1785
+ async def workspace_indexing_pause(source_id: str, request: Request):
1786
+ require_user(request)
1787
+ _require_graph()
1788
+ return WORKSPACE_OS.pause_indexing(KNOWLEDGE_GRAPH, source_id, LOCAL_KG_WATCHER)
1789
+
1790
+
1791
+ @app.post("/workspace/indexing/{source_id}/resume")
1792
+ async def workspace_indexing_resume(source_id: str, request: Request):
1793
+ require_user(request)
1794
+ _require_graph()
1795
+ return WORKSPACE_OS.resume_indexing(KNOWLEDGE_GRAPH, source_id, LOCAL_KG_WATCHER)
1796
+
1797
+
1798
+ @app.post("/workspace/indexing/{source_id}/remove")
1799
+ async def workspace_indexing_remove(source_id: str, request: Request):
1800
+ require_user(request)
1801
+ _require_graph()
1802
+ return WORKSPACE_OS.remove_index_source(KNOWLEDGE_GRAPH, source_id, LOCAL_KG_WATCHER)
1803
+
1804
+
1805
+ @app.get("/workspace/snapshots")
1806
+ async def workspace_snapshots(request: Request):
1807
+ require_user(request)
1808
+ return WORKSPACE_OS.list_snapshots()
1809
+
1810
+
1811
+ @app.post("/workspace/snapshots")
1812
+ async def workspace_snapshot_create(req: WorkspaceSnapshotRequest, request: Request):
1813
+ current_user = require_user(request)
1814
+ result = WORKSPACE_OS.create_snapshot(
1815
+ name=req.name,
1816
+ graph=_workspace_graph(),
1817
+ history=get_history(),
1818
+ settings=_workspace_settings_payload(),
1819
+ models=_workspace_models_payload(),
1820
+ )
1821
+ append_audit_event("workspace_snapshot", user_email=current_user, snapshot_id=result["snapshot"]["id"])
1822
+ return result
1823
+
1824
+
1825
+ @app.post("/workspace/snapshots/compare")
1826
+ async def workspace_snapshot_compare(req: WorkspaceSnapshotCompareRequest, request: Request):
1827
+ require_user(request)
1828
+ try:
1829
+ return WORKSPACE_OS.compare_snapshots(req.before_id, req.after_id)
1830
+ except FileNotFoundError as exc:
1831
+ raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
1832
+
1833
+
1834
+ @app.get("/workspace/snapshots/{snapshot_id}")
1835
+ async def workspace_snapshot_get(snapshot_id: str, request: Request):
1836
+ require_user(request)
1837
+ try:
1838
+ return WORKSPACE_OS.get_snapshot(snapshot_id)
1839
+ except FileNotFoundError as exc:
1840
+ raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
1841
+
1842
+
1843
+ @app.get("/workspace/snapshots/{snapshot_id}/{area}")
1844
+ async def workspace_snapshot_area(snapshot_id: str, area: str, request: Request):
1845
+ require_user(request)
1846
+ try:
1847
+ return WORKSPACE_OS.snapshot_view(snapshot_id, area)
1848
+ except FileNotFoundError as exc:
1849
+ raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
1850
+
1851
+
1852
+ @app.post("/workspace/snapshots/{snapshot_id}/export")
1853
+ async def workspace_snapshot_export(snapshot_id: str, request: Request):
1854
+ current_user = require_user(request)
1855
+ try:
1856
+ result = WORKSPACE_OS.export_snapshot(snapshot_id)
1857
+ except FileNotFoundError as exc:
1858
+ raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
1859
+ append_audit_event("workspace_snapshot_export", user_email=current_user, snapshot_id=snapshot_id, path=result.get("export_path"))
1860
+ return result
1861
+
1862
+
1863
+ @app.get("/workspace/time-machine")
1864
+ async def workspace_time_machine(request: Request, limit: int = 100):
1865
+ require_user(request)
1866
+ return WORKSPACE_OS.timeline(get_audit_log(), limit=limit)
1867
+
1868
+
1869
+ @app.get("/workspace/time-machine/{snapshot_id}/{area}")
1870
+ async def workspace_time_machine_view(snapshot_id: str, area: str, request: Request):
1871
+ require_user(request)
1872
+ try:
1873
+ return WORKSPACE_OS.snapshot_view(snapshot_id, area)
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/memories")
1879
+ async def workspace_memories(request: Request, kind: Optional[str] = None):
1880
+ current_user = require_user(request)
1881
+ return WORKSPACE_OS.list_memories(user_email=current_user or None, kind=kind)
1882
+
1883
+
1884
+ @app.get("/workspace/memories/search")
1885
+ async def workspace_memory_search(q: str, request: Request, limit: int = 20):
1886
+ current_user = require_user(request)
1887
+ return WORKSPACE_OS.search_memories(q, user_email=current_user or None, limit=limit)
1888
+
1889
+
1890
+ @app.post("/workspace/memories")
1891
+ async def workspace_memory_upsert(req: WorkspaceMemoryRequest, request: Request):
1892
+ current_user = require_user(request)
1893
+ try:
1894
+ record = WORKSPACE_OS.upsert_memory(
1895
+ kind=req.kind,
1896
+ content=req.content,
1897
+ tags=req.tags,
1898
+ memory_id=req.memory_id,
1899
+ metadata=req.metadata,
1900
+ user_email=current_user or None,
1901
+ graph=_workspace_graph(),
1902
+ )
1903
+ except ValueError as exc:
1904
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
1905
+ return {"memory": record}
1906
+
1907
+
1908
+ @app.delete("/workspace/memories/{memory_id}")
1909
+ async def workspace_memory_delete(memory_id: str, request: Request):
1910
+ require_user(request)
1911
+ try:
1912
+ return WORKSPACE_OS.delete_memory(memory_id)
1913
+ except FileNotFoundError as exc:
1914
+ raise HTTPException(status_code=404, detail=f"Memory not found: {exc}") from exc
1915
+
1916
+
1917
+ @app.get("/workspace/agents")
1918
+ async def workspace_agents(request: Request):
1919
+ require_user(request)
1920
+ return WORKSPACE_OS.list_agents()
1921
+
1922
+
1923
+ @app.post("/workspace/agents/runs")
1924
+ async def workspace_agent_run(req: WorkspaceAgentRunRequest, request: Request):
1925
+ current_user = require_user(request)
1926
+ run = WORKSPACE_OS.record_agent_run(
1927
+ agent_id=req.agent_id,
1928
+ status=req.status,
1929
+ input_text=req.input,
1930
+ output_text=req.output,
1931
+ timeline=req.timeline,
1932
+ relationships=req.relationships,
1933
+ user_email=current_user or None,
1934
+ graph=_workspace_graph(),
1935
+ )
1936
+ return {"run": run}
1937
+
1938
+
1939
+ @app.get("/workspace/relationships/{node_id:path}")
1940
+ async def workspace_relationships(node_id: str, request: Request, target_id: Optional[str] = None):
1941
+ require_user(request)
1942
+ _require_graph()
1943
+ return WORKSPACE_OS.relationship_explorer(KNOWLEDGE_GRAPH, node_id, target_id=target_id)
1944
+
1945
+
1946
+ @app.get("/workspace/computer-memory")
1947
+ async def workspace_computer_memory(request: Request):
1948
+ require_user(request)
1949
+ return WORKSPACE_OS.load_state().get("computer_memory")
1950
+
1951
+
1952
+ @app.post("/workspace/computer-memory")
1953
+ async def workspace_computer_memory_config(req: WorkspaceComputerMemoryRequest, request: Request):
1954
+ current_user = require_user(request)
1955
+ try:
1956
+ config = WORKSPACE_OS.configure_computer_memory(
1957
+ enabled=req.enabled,
1958
+ approved_by=current_user or None,
1959
+ consent=req.consent,
1960
+ scopes=req.scopes or None,
1961
+ )
1962
+ except PermissionError as exc:
1963
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
1964
+ append_audit_event("computer_memory_config", user_email=current_user, enabled=req.enabled)
1965
+ return {"computer_memory": config}
1966
+
1967
+
1968
+ @app.post("/workspace/computer-memory/activity")
1969
+ async def workspace_computer_memory_activity(req: WorkspaceComputerActivityRequest, request: Request):
1970
+ require_user(request)
1971
+ return WORKSPACE_OS.record_computer_activity(req.activity, graph=_workspace_graph())
1972
+
1973
+
1974
+ @app.get("/workspace/workflows")
1975
+ async def workspace_workflows(request: Request, q: str = ""):
1976
+ require_user(request)
1977
+ return WORKSPACE_OS.list_workflows(query=q)
1978
+
1979
+
1980
+ @app.post("/workspace/workflows")
1981
+ async def workspace_workflow_create(req: WorkspaceWorkflowRequest, request: Request):
1982
+ current_user = require_user(request)
1983
+ workflow = WORKSPACE_OS.create_workflow(
1984
+ name=req.name,
1985
+ steps=req.steps,
1986
+ metadata=req.metadata,
1987
+ user_email=current_user or None,
1988
+ graph=_workspace_graph(),
1989
+ )
1990
+ return {"workflow": workflow}
1991
+
1992
+
1993
+ @app.post("/workspace/workflows/{workflow_id}/events")
1994
+ async def workspace_workflow_event(workflow_id: str, req: WorkspaceWorkflowEventRequest, request: Request):
1995
+ require_user(request)
1996
+ try:
1997
+ return {"workflow": WORKSPACE_OS.record_workflow_event(workflow_id, req.event_type, req.payload)}
1998
+ except FileNotFoundError as exc:
1999
+ raise HTTPException(status_code=404, detail=f"Workflow not found: {exc}") from exc
2000
+
2001
+
2002
+ @app.get("/workspace/skills")
2003
+ async def workspace_skills(request: Request):
2004
+ require_user(request)
2005
+ marketplace = []
2006
+ try:
2007
+ marketplace = await _fetch_skills_marketplace()
2008
+ except Exception as exc:
2009
+ logging.warning("workspace skills marketplace unavailable: %s", exc)
2010
+ return WORKSPACE_OS.list_skill_registry(SKILLS_DIR, marketplace)
2011
+
2012
+
2013
+ @app.post("/workspace/skills/install")
2014
+ async def workspace_skill_install(req: WorkspaceSkillActionRequest, request: Request):
2015
+ admin_email, _ = require_admin(request)
2016
+ if req.plugin:
2017
+ result = await install_skill(req.plugin, req.skill)
2018
+ else:
2019
+ result = {"status": "recorded", "skill": req.skill}
2020
+ entry = WORKSPACE_OS.mark_skill_installed(req.skill, version=req.version or "local", metadata={"install_result": result, **req.metadata})
2021
+ append_audit_event("skill_install", user_email=admin_email, plugin=req.plugin, skill=req.skill, workspace_os=True)
2022
+ return {"skill": entry, "install": result}
2023
+
2024
+
2025
+ @app.post("/workspace/skills/uninstall")
2026
+ async def workspace_skill_uninstall(req: WorkspaceSkillActionRequest, request: Request):
2027
+ admin_email, _ = require_admin(request)
2028
+ removal = remove_skill_directory(SKILLS_DIR, req.skill)
2029
+ entry = WORKSPACE_OS.mark_skill_uninstalled(req.skill)
2030
+ append_audit_event("skill_uninstall", user_email=admin_email, skill=req.skill, workspace_os=True)
2031
+ return {"skill": entry, "removal": removal}
2032
+
2033
+
2034
+ @app.post("/workspace/skills/enable")
2035
+ async def workspace_skill_enable(req: WorkspaceSkillActionRequest, request: Request):
2036
+ require_user(request)
2037
+ return {"skill": WORKSPACE_OS.set_skill_enabled(req.skill, True)}
2038
+
2039
+
2040
+ @app.post("/workspace/skills/disable")
2041
+ async def workspace_skill_disable(req: WorkspaceSkillActionRequest, request: Request):
2042
+ require_user(request)
2043
+ return {"skill": WORKSPACE_OS.set_skill_enabled(req.skill, False)}
2044
+
2045
+
2046
+ @app.post("/workspace/skills/update")
2047
+ async def workspace_skill_update(req: WorkspaceSkillActionRequest, request: Request):
2048
+ admin_email, _ = require_admin(request)
2049
+ if req.plugin:
2050
+ result = await install_skill(req.plugin, req.skill)
2051
+ else:
2052
+ result = {"status": "version_recorded", "skill": req.skill}
2053
+ entry = WORKSPACE_OS.mark_skill_installed(req.skill, version=req.version or "latest", metadata={"update_result": result, **req.metadata})
2054
+ append_audit_event("skill_update", user_email=admin_email, plugin=req.plugin, skill=req.skill, workspace_os=True)
2055
+ return {"skill": entry, "update": result}
2056
+
2057
+
2058
+ @app.get("/workspace/audit-timeline")
2059
+ async def workspace_audit_timeline(
2060
+ request: Request,
2061
+ user: Optional[str] = None,
2062
+ event_type: Optional[str] = None,
2063
+ model: Optional[str] = None,
2064
+ since: Optional[str] = None,
2065
+ until: Optional[str] = None,
2066
+ limit: int = 100,
2067
+ ):
2068
+ require_admin(request)
2069
+ return WORKSPACE_OS.filter_audit_timeline(
2070
+ get_audit_log(),
2071
+ user=user,
2072
+ event_type=event_type,
2073
+ model=model,
2074
+ since=since,
2075
+ until=until,
2076
+ limit=limit,
2077
+ )
2078
+
2079
+
2080
+ @app.post("/workspace/vscode/send")
2081
+ async def workspace_vscode_send(req: WorkspaceVSCodeRequest, request: Request):
2082
+ current_user = require_user(request)
2083
+ content = req.selection or req.content or req.prompt
2084
+ workflow = WORKSPACE_OS.create_workflow(
2085
+ name=f"VS Code: {req.action}",
2086
+ steps=[
2087
+ {"action": req.action, "file_path": req.file_path, "language": req.language},
2088
+ {"action": "send_to_lattice", "chars": len(content or "")},
2089
+ ],
2090
+ metadata={
2091
+ "file_path": req.file_path,
2092
+ "language": req.language,
2093
+ "content_preview": redact_secret_text(content or "")[:500],
2094
+ },
2095
+ user_email=current_user or None,
2096
+ graph=_workspace_graph(),
2097
+ )
2098
+ if _workspace_graph() is not None and content:
2099
+ try:
2100
+ _workspace_graph().ingest_event(
2101
+ "VSCodeWorkflow",
2102
+ req.action,
2103
+ user_email=current_user or None,
2104
+ source="vscode",
2105
+ metadata={
2106
+ "file_path": req.file_path,
2107
+ "language": req.language,
2108
+ "chars": len(content),
2109
+ "workflow_id": workflow["id"],
2110
+ },
2111
+ )
2112
+ except Exception as exc:
2113
+ logging.warning("vscode workflow graph ingest failed: %s", exc)
2114
+ return {"status": "ok", "workflow": workflow}
2115
+
2116
+
1583
2117
  # ── Health & Info ──────────────────────────────────────────────────────────────
1584
2118
 
1585
2119
  ENGINE_INSTALLERS = {
@@ -3470,7 +4004,12 @@ async def verify_cloud_models(force: bool = False, provider_filter: Optional[str
3470
4004
 
3471
4005
  @app.get("/health")
3472
4006
  async def health(request: Request):
3473
- base = {"status": "ok", "version": "0.6.0", "mode": APP_MODE}
4007
+ base = {
4008
+ "status": "ok",
4009
+ "version": APP_VERSION,
4010
+ "mode": APP_MODE,
4011
+ "platform": "AI Workspace OS",
4012
+ }
3474
4013
  if not get_current_user(request) and REQUIRE_AUTH:
3475
4014
  return base
3476
4015
  engines = await asyncio.to_thread(engine_status)
@@ -3896,6 +4435,12 @@ async def chat(req: ChatRequest, request: Request):
3896
4435
  except Exception:
3897
4436
  pass
3898
4437
 
4438
+ trace_seed = WORKSPACE_OS.build_graph_trace(
4439
+ req.message,
4440
+ KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
4441
+ context,
4442
+ )
4443
+
3899
4444
  history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
3900
4445
  save_to_history("user", history_message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
3901
4446
  if req.source != "telegram":
@@ -3928,8 +4473,16 @@ async def chat(req: ChatRequest, request: Request):
3928
4473
  full_text += footnote
3929
4474
  session.update(graph_md, full_text, req.conversation_id)
3930
4475
  save_to_history("assistant", full_text, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
4476
+ trace_record = WORKSPACE_OS.record_trace(
4477
+ question=req.message,
4478
+ response=full_text,
4479
+ conversation_id=req.conversation_id,
4480
+ user_email=effective_email,
4481
+ trace=trace_seed,
4482
+ )
3931
4483
  if req.source != "telegram":
3932
4484
  asyncio.create_task(broadcast_web_chat("assistant", full_text))
4485
+ yield f"data: {json.dumps({'text': '', 'trace_id': trace_record['id'], 'trace': trace_record}, ensure_ascii=False)}\n\n"
3933
4486
  yield "data: [DONE]\n\n"
3934
4487
  return StreamingResponse(
3935
4488
  _stream_doc_gen(),
@@ -3946,9 +4499,16 @@ async def chat(req: ChatRequest, request: Request):
3946
4499
  result += footnote
3947
4500
  session.update(graph_md, result, req.conversation_id)
3948
4501
  save_to_history("assistant", str(result), source=req.source or "web", conversation_id=req.conversation_id, **history_user)
4502
+ trace_record = WORKSPACE_OS.record_trace(
4503
+ question=req.message,
4504
+ response=str(result),
4505
+ conversation_id=req.conversation_id,
4506
+ user_email=effective_email,
4507
+ trace=trace_seed,
4508
+ )
3949
4509
  if req.source != "telegram":
3950
4510
  asyncio.create_task(broadcast_web_chat("assistant", str(result)))
3951
- return JSONResponse(content={"response": str(result)})
4511
+ return JSONResponse(content={"response": str(result), "trace_id": trace_record["id"], "trace": trace_record})
3952
4512
 
3953
4513
  if req.stream:
3954
4514
  recent_context = build_recent_chat_context(user_email=effective_email, conversation_id=req.conversation_id)
@@ -3956,7 +4516,7 @@ async def chat(req: ChatRequest, request: Request):
3956
4516
  if recent_context:
3957
4517
  stream_context = f"[RECENT CONVERSATION]\n{recent_context}\n\n{context}".strip()
3958
4518
  return StreamingResponse(
3959
- _stream_chat(req, stream_context, req.image_data),
4519
+ _stream_chat(req, stream_context, req.image_data, trace_seed=trace_seed, effective_email=effective_email),
3960
4520
  media_type="text/event-stream",
3961
4521
  headers={"X-Model": router.current_model_id},
3962
4522
  )
@@ -3976,10 +4536,17 @@ async def chat(req: ChatRequest, request: Request):
3976
4536
  result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
3977
4537
 
3978
4538
  save_to_history("assistant", str(result), source=req.source or "web", conversation_id=req.conversation_id, **history_user)
4539
+ trace_record = WORKSPACE_OS.record_trace(
4540
+ question=req.message,
4541
+ response=str(result),
4542
+ conversation_id=req.conversation_id,
4543
+ user_email=effective_email,
4544
+ trace=trace_seed,
4545
+ )
3979
4546
  if req.source != "telegram":
3980
4547
  asyncio.create_task(broadcast_web_chat("assistant", str(result)))
3981
4548
 
3982
- return JSONResponse(content={"response": str(result)})
4549
+ return JSONResponse(content={"response": str(result), "trace_id": trace_record["id"], "trace": trace_record})
3983
4550
 
3984
4551
 
3985
4552
  @app.get("/history")
@@ -4050,7 +4617,14 @@ async def search_history(q: str, request: Request):
4050
4617
  grouped[cid]["messages"].append(item)
4051
4618
  return {"results": list(grouped.values())[-30:], "query": q}
4052
4619
 
4053
- async def _stream_chat(req: ChatRequest, context: str = "", image_data: str = None) -> AsyncIterator[str]:
4620
+ async def _stream_chat(
4621
+ req: ChatRequest,
4622
+ context: str = "",
4623
+ image_data: str = None,
4624
+ *,
4625
+ trace_seed: Optional[Dict] = None,
4626
+ effective_email: Optional[str] = None,
4627
+ ) -> AsyncIterator[str]:
4054
4628
  full_response = ""
4055
4629
  async for chunk in router.stream_generate(req.message, context, req.max_tokens, req.temperature, image_data):
4056
4630
  clean_chunk = chunk
@@ -4066,8 +4640,20 @@ async def _stream_chat(req: ChatRequest, context: str = "", image_data: str = No
4066
4640
  yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
4067
4641
  history_user = get_history_user(req.user_email, req.user_nickname)
4068
4642
  save_to_history("assistant", full_response, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
4643
+ trace_record = WORKSPACE_OS.record_trace(
4644
+ question=req.message,
4645
+ response=full_response,
4646
+ conversation_id=req.conversation_id,
4647
+ user_email=effective_email or req.user_email,
4648
+ trace=trace_seed or WORKSPACE_OS.build_graph_trace(
4649
+ req.message,
4650
+ KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
4651
+ context,
4652
+ ),
4653
+ )
4069
4654
  if req.source != "telegram":
4070
4655
  asyncio.create_task(broadcast_web_chat("assistant", full_response))
4656
+ yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'trace_id': trace_record['id'], 'trace': trace_record}, ensure_ascii=False)}\n\n"
4071
4657
  yield "data: [DONE]\n\n"
4072
4658
 
4073
4659
 
@@ -4277,6 +4863,19 @@ async def _agent_finish(
4277
4863
  message = ctx.final_message or "작업을 완료했습니다."
4278
4864
  save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id)
4279
4865
  save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id)
4866
+ try:
4867
+ WORKSPACE_OS.record_agent_run(
4868
+ agent_id="agent:executor",
4869
+ status="ok" if ctx.state == AgentState.DONE else "failed",
4870
+ input_text=req.message,
4871
+ output_text=message,
4872
+ user_email=current_user or None,
4873
+ timeline=ctx.transcript,
4874
+ relationships=["agent:planner", "agent:reviewer"],
4875
+ graph=_workspace_graph(),
4876
+ )
4877
+ except Exception as exc:
4878
+ logging.warning("workspace agent run record failed: %s", exc)
4280
4879
  created_files = _collect_created_files(ctx.transcript)
4281
4880
  return {
4282
4881
  "status": "ok" if ctx.state == AgentState.DONE else "failed",