ltcai 1.3.0 → 1.5.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.
Files changed (44) hide show
  1. package/README.md +105 -79
  2. package/docs/CHANGELOG.md +109 -0
  3. package/docs/images/architecture.png +0 -0
  4. package/docs/images/graph.png +0 -0
  5. package/docs/images/hero.gif +0 -0
  6. package/docs/images/model-recommendation.png +0 -0
  7. package/docs/images/onboarding.png +0 -0
  8. package/docs/images/organization.png +0 -0
  9. package/docs/images/skills.png +0 -0
  10. package/docs/images/tmp_frames/frame_00.png +0 -0
  11. package/docs/images/tmp_frames/frame_01.png +0 -0
  12. package/docs/images/tmp_frames/frame_02.png +0 -0
  13. package/docs/images/tmp_frames/frame_03.png +0 -0
  14. package/docs/images/workspace.png +0 -0
  15. package/latticeai/__init__.py +1 -1
  16. package/latticeai/api/admin.py +17 -0
  17. package/latticeai/api/chat.py +786 -0
  18. package/latticeai/api/computer_use.py +294 -0
  19. package/latticeai/api/deps.py +15 -0
  20. package/latticeai/api/garden.py +34 -0
  21. package/latticeai/api/local_files.py +125 -0
  22. package/latticeai/api/models.py +16 -0
  23. package/latticeai/api/permissions.py +331 -0
  24. package/latticeai/api/setup.py +158 -0
  25. package/latticeai/api/static_routes.py +166 -0
  26. package/latticeai/api/tools.py +579 -0
  27. package/latticeai/api/workspace.py +11 -0
  28. package/latticeai/core/enterprise_admin.py +158 -0
  29. package/latticeai/core/workspace_os.py +1 -1
  30. package/latticeai/server_app.py +223 -4301
  31. package/latticeai/services/app_context.py +27 -0
  32. package/latticeai/services/model_catalog.py +289 -0
  33. package/latticeai/services/model_recommendation.py +183 -0
  34. package/latticeai/services/model_runtime.py +1721 -0
  35. package/latticeai/services/tool_dispatch.py +135 -0
  36. package/latticeai/services/upload_service.py +99 -0
  37. package/package.json +3 -3
  38. package/skills/SKILL_TEMPLATE.md +1 -1
  39. package/skills/code_review/SKILL.md +1 -1
  40. package/skills/data_analysis/SKILL.md +1 -1
  41. package/skills/file_edit/SKILL.md +1 -1
  42. package/skills/summarize_document/SKILL.md +1 -1
  43. package/skills/web_search/SKILL.md +1 -1
  44. package/static/scripts/chat.js +45 -0
@@ -0,0 +1,294 @@
1
+ """Computer-use routes and the desktop-control agent loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Optional
8
+
9
+ from fastapi import APIRouter, HTTPException, Request
10
+ from fastapi.responses import StreamingResponse
11
+ from pydantic import BaseModel
12
+
13
+ from latticeai.core.agent import extract_action as _extract_agent_action
14
+ from tools import (
15
+ ToolError,
16
+ computer_click,
17
+ computer_drag,
18
+ computer_key,
19
+ computer_move,
20
+ computer_open_app,
21
+ computer_open_url,
22
+ computer_screenshot,
23
+ computer_scroll,
24
+ computer_status,
25
+ computer_type,
26
+ desktop_bridge_status,
27
+ execute_tool,
28
+ )
29
+
30
+
31
+ CU_SYSTEM_PROMPT = """You are Lattice AI desktop-control agent. You control the Mac desktop using tools.
32
+ Prefer non-visual direct actions when possible. Use screenshots only when you must inspect visible UI state or choose screen coordinates.
33
+
34
+ Available actions:
35
+ - computer_screenshot: {"action":"computer_screenshot","args":{}} — capture screen, returns screenshot_b64
36
+ - computer_open_app: {"action":"computer_open_app","args":{"app":"Google Chrome"}} — open or focus a Mac app
37
+ - computer_open_url: {"action":"computer_open_url","args":{"url":"https://example.com","app":"Google Chrome"}} — open URL in app
38
+ - computer_click: {"action":"computer_click","args":{"x":500,"y":300,"button":"left","double":false}}
39
+ - computer_type: {"action":"computer_type","args":{"text":"hello world","interval":0.04}}
40
+ - computer_key: {"action":"computer_key","args":{"key":"return"}} — keys: return, escape, tab, space, command+c, etc.
41
+ - computer_scroll: {"action":"computer_scroll","args":{"x":500,"y":300,"direction":"down","clicks":3}}
42
+ - computer_move: {"action":"computer_move","args":{"x":500,"y":300}}
43
+ - computer_drag: {"action":"computer_drag","args":{"x1":100,"y1":100,"x2":500,"y2":500}}
44
+ - final: {"action":"final","message":"Korean summary of what was accomplished"}
45
+
46
+ Rules:
47
+ - Respond with exactly ONE JSON object. No markdown, no extra text.
48
+ - Do not take screenshots for simple app launch, URL opening, keyboard shortcuts, or non-visual tasks.
49
+ - Take a screenshot before coordinate-based clicks/drags or when the task explicitly asks you to inspect the screen.
50
+ - After coordinate-based clicking or typing into an unknown focused field, take a screenshot only if verification is necessary.
51
+ - Use coordinates relative to the screen (0,0 is top-left).
52
+ - If a UI element is not visible, scroll or search for it first.
53
+ - macOS Accessibility permission required for mouse/keyboard control.
54
+ """
55
+
56
+
57
+ class CuAgentRequest(BaseModel):
58
+ task: str
59
+ conversation_id: Optional[str] = None
60
+ max_steps: int = 15
61
+ temperature: float = 0.1
62
+
63
+
64
+ class CuClickRequest(BaseModel):
65
+ x: int
66
+ y: int
67
+ button: str = "left"
68
+ double: bool = False
69
+
70
+
71
+ class CuOpenAppRequest(BaseModel):
72
+ app: str = "Google Chrome"
73
+
74
+
75
+ class CuOpenUrlRequest(BaseModel):
76
+ url: str
77
+ app: str = "Google Chrome"
78
+
79
+
80
+ class CuTypeRequest(BaseModel):
81
+ text: str
82
+ interval: float = 0.04
83
+
84
+
85
+ class CuKeyRequest(BaseModel):
86
+ key: str
87
+
88
+
89
+ class CuScrollRequest(BaseModel):
90
+ x: int
91
+ y: int
92
+ direction: str = "down"
93
+ clicks: int = 3
94
+
95
+
96
+ class CuMoveRequest(BaseModel):
97
+ x: int
98
+ y: int
99
+
100
+
101
+ class CuDragRequest(BaseModel):
102
+ x1: int
103
+ y1: int
104
+ x2: int
105
+ y2: int
106
+
107
+
108
+ def create_computer_use_router(*, model_router, require_user, tool_response, save_to_history) -> APIRouter:
109
+ router = APIRouter()
110
+
111
+ @router.get("/tools/chrome_status")
112
+ async def tools_chrome_status(request: Request):
113
+ require_user(request)
114
+ return tool_response(desktop_bridge_status)
115
+
116
+ @router.get("/tools/computer_use_status")
117
+ async def tools_computer_use_status(request: Request):
118
+ require_user(request)
119
+ return tool_response(computer_status)
120
+
121
+ @router.get("/cu/status")
122
+ async def cu_status(request: Request):
123
+ require_user(request)
124
+ try:
125
+ return computer_status()
126
+ except ToolError as exc:
127
+ raise HTTPException(status_code=400, detail=str(exc))
128
+
129
+ @router.get("/cu/screenshot")
130
+ async def cu_screenshot(request: Request):
131
+ require_user(request)
132
+ try:
133
+ return computer_screenshot()
134
+ except ToolError as exc:
135
+ raise HTTPException(status_code=400, detail=str(exc))
136
+
137
+ @router.post("/cu/open_app")
138
+ async def cu_open_app(req: CuOpenAppRequest, request: Request):
139
+ require_user(request)
140
+ return tool_response(computer_open_app, req.app)
141
+
142
+ @router.post("/cu/open_url")
143
+ async def cu_open_url(req: CuOpenUrlRequest, request: Request):
144
+ require_user(request)
145
+ return tool_response(computer_open_url, req.url, req.app)
146
+
147
+ @router.post("/cu/click")
148
+ async def cu_click(req: CuClickRequest, request: Request):
149
+ require_user(request)
150
+ return tool_response(computer_click, req.x, req.y, req.button, req.double)
151
+
152
+ @router.post("/cu/type")
153
+ async def cu_type(req: CuTypeRequest, request: Request):
154
+ require_user(request)
155
+ return tool_response(computer_type, req.text, req.interval)
156
+
157
+ @router.post("/cu/key")
158
+ async def cu_key(req: CuKeyRequest, request: Request):
159
+ require_user(request)
160
+ return tool_response(computer_key, req.key)
161
+
162
+ @router.post("/cu/scroll")
163
+ async def cu_scroll(req: CuScrollRequest, request: Request):
164
+ require_user(request)
165
+ return tool_response(computer_scroll, req.x, req.y, req.direction, req.clicks)
166
+
167
+ @router.post("/cu/move")
168
+ async def cu_move(req: CuMoveRequest, request: Request):
169
+ require_user(request)
170
+ return tool_response(computer_move, req.x, req.y)
171
+
172
+ @router.post("/cu/drag")
173
+ async def cu_drag(req: CuDragRequest, request: Request):
174
+ require_user(request)
175
+ return tool_response(computer_drag, req.x1, req.y1, req.x2, req.y2)
176
+
177
+ @router.post("/cu/agent")
178
+ async def cu_agent(req: CuAgentRequest, request: Request):
179
+ require_user(request)
180
+
181
+ async def _stream():
182
+ task_lower = (req.task or "").lower()
183
+ url_match = re.search(r"(https?://[^\s]+|localhost:\d+[^\s]*|127\.0\.0\.1:\d+[^\s]*)", req.task or "")
184
+
185
+ def _send(event: str, data: dict) -> str:
186
+ return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
187
+
188
+ if ("chrome" in task_lower or "크롬" in task_lower) and any(
189
+ word in task_lower for word in ["open", "열", "켜", "실행", "띄"]
190
+ ):
191
+ yield _send("start", {"task": req.task, "max_steps": 1})
192
+ try:
193
+ if url_match:
194
+ url = url_match.group(1)
195
+ yield _send(
196
+ "action",
197
+ {"step": 1, "action": "computer_open_url", "args": {"url": url, "app": "Google Chrome"}},
198
+ )
199
+ result = computer_open_url(url, "Google Chrome")
200
+ yield _send("result", {"step": 1, "action": "computer_open_url", "result": result})
201
+ message = f"Google Chrome에서 {url}을 열었습니다."
202
+ action_name = "computer_open_url"
203
+ else:
204
+ yield _send(
205
+ "action",
206
+ {"step": 1, "action": "computer_open_app", "args": {"app": "Google Chrome"}},
207
+ )
208
+ result = computer_open_app("Google Chrome")
209
+ yield _send("result", {"step": 1, "action": "computer_open_app", "result": result})
210
+ message = "Google Chrome을 열었습니다."
211
+ action_name = "computer_open_app"
212
+ save_to_history("user", req.task, source="web", conversation_id=req.conversation_id)
213
+ save_to_history("assistant", message, source="web", conversation_id=req.conversation_id)
214
+ yield _send("final", {"message": message, "steps": [{"step": 1, "action": action_name, "result": result}]})
215
+ except ToolError as exc:
216
+ yield _send("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc)})
217
+ return
218
+
219
+ if not model_router.current_model_id:
220
+ yield _send("error", {"error": "No model loaded."})
221
+ return
222
+
223
+ transcript = []
224
+ last_screenshot_b64: Optional[str] = None
225
+ max_steps = max(1, min(req.max_steps, 20))
226
+ yield _send("start", {"task": req.task, "max_steps": max_steps})
227
+
228
+ for step in range(max_steps):
229
+ context = (
230
+ f"{CU_SYSTEM_PROMPT}\n\n"
231
+ f"Task: {req.task}\n\n"
232
+ f"Steps completed so far:\n{json.dumps(transcript, ensure_ascii=False, indent=2)}"
233
+ )
234
+ raw = await model_router.generate(
235
+ message="Choose the next computer use action.",
236
+ context=context,
237
+ image_data=last_screenshot_b64,
238
+ max_tokens=1024,
239
+ temperature=req.temperature,
240
+ )
241
+
242
+ try:
243
+ action = _extract_agent_action(str(raw))
244
+ except ValueError as exc:
245
+ yield _send("error", {"step": step + 1, "error": str(exc), "raw": str(raw)})
246
+ break
247
+
248
+ name = action.get("action")
249
+ args = action.get("args") or {}
250
+ if name == "final":
251
+ message = action.get("message", "작업을 완료했습니다.")
252
+ save_to_history("user", req.task, source="web", conversation_id=req.conversation_id)
253
+ save_to_history("assistant", message, source="web", conversation_id=req.conversation_id)
254
+ yield _send("final", {"message": message, "steps": transcript})
255
+ return
256
+
257
+ yield _send("action", {"step": step + 1, "action": name, "args": args})
258
+ try:
259
+ result = execute_tool(name, args)
260
+ if name == "computer_screenshot" and "screenshot_b64" in result:
261
+ last_screenshot_b64 = result["screenshot_b64"]
262
+ result_summary = {k: v for k, v in result.items() if k != "screenshot_b64"}
263
+ result_summary["screenshot_captured"] = True
264
+ transcript.append({"step": step + 1, "action": name, "args": args, "result": result_summary})
265
+ yield _send(
266
+ "screenshot",
267
+ {
268
+ "step": step + 1,
269
+ "screenshot_b64": last_screenshot_b64,
270
+ "width": result.get("screen_width"),
271
+ "height": result.get("screen_height"),
272
+ },
273
+ )
274
+ else:
275
+ last_screenshot_b64 = None
276
+ transcript.append({"step": step + 1, "action": name, "args": args, "result": result})
277
+ yield _send("result", {"step": step + 1, "action": name, "result": result})
278
+ except (ToolError, KeyError, TypeError) as exc:
279
+ error_str = str(exc)
280
+ transcript.append({"step": step + 1, "action": name, "args": args, "error": error_str})
281
+ yield _send("tool_error", {"step": step + 1, "action": name, "error": error_str})
282
+
283
+ yield _send("done", {"steps": len(transcript), "transcript": transcript})
284
+
285
+ return StreamingResponse(
286
+ _stream(),
287
+ media_type="text/event-stream",
288
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
289
+ )
290
+
291
+ return router
292
+
293
+
294
+ __all__ = ["create_computer_use_router"]
@@ -0,0 +1,15 @@
1
+ """Shared API dependency type aliases.
2
+
3
+ Routers receive concrete callables from ``server_app`` at assembly time. Keeping
4
+ the aliases here avoids app imports inside router modules and gives future
5
+ router splits a single dependency vocabulary.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Callable, Dict
11
+
12
+ RequireUser = Callable[[Any], str]
13
+ RequireAdmin = Callable[[Any], tuple[str, Dict]]
14
+ AuditAppender = Callable[..., None]
15
+
@@ -0,0 +1,34 @@
1
+ """P-Reinforce garden routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from fastapi import APIRouter, Request
8
+ from pydantic import BaseModel
9
+
10
+
11
+ class GardenRequest(BaseModel):
12
+ raw_data: str
13
+ category: Optional[str] = None
14
+
15
+
16
+ def create_garden_router(*, gardener, require_user) -> APIRouter:
17
+ api_router = APIRouter()
18
+
19
+ # ── P-Reinforce Knowledge Gardener ────────────────────────────────────────────
20
+
21
+ @api_router.post("/garden")
22
+ async def garden(req: GardenRequest, request: Request):
23
+ """Raw 데이터를 P-Reinforce 구조로 자동 분류·저장"""
24
+ require_user(request)
25
+ result = await gardener.process(req.raw_data, req.category)
26
+ return result
27
+
28
+
29
+ @api_router.get("/garden/tree")
30
+ async def garden_tree(request: Request):
31
+ """지식 정원 파일트리 반환"""
32
+ require_user(request)
33
+ return gardener.get_tree()
34
+ return api_router
@@ -0,0 +1,125 @@
1
+ """Local file access and local knowledge graph routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from fastapi import APIRouter, HTTPException, Request
9
+ from fastapi.responses import FileResponse
10
+ from pydantic import BaseModel
11
+
12
+ from knowledge_graph_api import create_knowledge_graph_router
13
+ from local_knowledge_api import create_local_knowledge_router
14
+ from tools import local_list, local_read, local_write
15
+
16
+
17
+ class LocalAccessRequest(BaseModel):
18
+ path: str
19
+ approved: bool = False
20
+ approval_token: Optional[str] = None
21
+
22
+
23
+ class LocalWriteRequest(BaseModel):
24
+ path: str
25
+ content: str
26
+ approved: bool = False
27
+ approval_token: Optional[str] = None
28
+
29
+
30
+ def create_local_files_router(
31
+ *,
32
+ require_user,
33
+ tool_response,
34
+ permission_gateway,
35
+ knowledge_graph,
36
+ require_graph,
37
+ static_dir: Path,
38
+ local_kg_watcher,
39
+ ) -> APIRouter:
40
+ router = APIRouter()
41
+
42
+ @router.post("/local/list")
43
+ async def local_list_endpoint(req: LocalAccessRequest, request: Request):
44
+ current_user = permission_gateway.require_local_user(request)
45
+ if not req.approved:
46
+ return permission_gateway.local_permission_response(req.path, "list", current_user)
47
+ permission_gateway.require_local_approval(
48
+ token=req.approval_token,
49
+ path=req.path,
50
+ action="list",
51
+ user_email=current_user,
52
+ )
53
+ return tool_response(local_list, req.path)
54
+
55
+ @router.get("/local/list")
56
+ async def local_list_get_endpoint(path: str, request: Request):
57
+ current_user = permission_gateway.require_local_user(request)
58
+ return permission_gateway.local_permission_response(path, "list", current_user)
59
+
60
+ @router.post("/local/read")
61
+ async def local_read_endpoint(req: LocalAccessRequest, request: Request):
62
+ current_user = permission_gateway.require_local_user(request)
63
+ if not req.approved:
64
+ return permission_gateway.local_permission_response(req.path, "read", current_user)
65
+ permission_gateway.require_local_approval(
66
+ token=req.approval_token,
67
+ path=req.path,
68
+ action="read",
69
+ user_email=current_user,
70
+ )
71
+ return tool_response(local_read, req.path)
72
+
73
+ @router.get("/local/serve")
74
+ async def local_serve_file(path: str, request: Request, approval_token: Optional[str] = None):
75
+ current_user = permission_gateway.require_local_user(request)
76
+ permission_gateway.require_local_approval(
77
+ token=approval_token,
78
+ path=path,
79
+ action="read",
80
+ user_email=current_user,
81
+ )
82
+ target = Path(path).expanduser().resolve()
83
+ if not target.exists() or not target.is_file():
84
+ raise HTTPException(status_code=404, detail="File not found")
85
+ return FileResponse(str(target))
86
+
87
+ @router.post("/local/write")
88
+ async def local_write_endpoint(req: LocalWriteRequest, request: Request):
89
+ current_user = permission_gateway.require_local_user(request)
90
+ if not req.approved:
91
+ return permission_gateway.local_permission_response(req.path, "write", current_user, req.content)
92
+ permission_gateway.require_local_approval(
93
+ token=req.approval_token,
94
+ path=req.path,
95
+ action="write",
96
+ user_email=current_user,
97
+ content=req.content,
98
+ )
99
+ return tool_response(local_write, req.path, req.content)
100
+
101
+ router.include_router(
102
+ create_knowledge_graph_router(
103
+ get_graph=lambda: knowledge_graph,
104
+ require_graph=require_graph,
105
+ require_user=require_user,
106
+ static_dir=static_dir,
107
+ )
108
+ )
109
+
110
+ router.include_router(
111
+ create_local_knowledge_router(
112
+ get_graph=lambda: knowledge_graph,
113
+ require_graph=require_graph,
114
+ require_user=require_user,
115
+ require_local_user=permission_gateway.require_local_user,
116
+ local_permission_response=permission_gateway.local_permission_response,
117
+ require_local_approval=permission_gateway.require_local_approval,
118
+ watcher=local_kg_watcher,
119
+ )
120
+ )
121
+
122
+ return router
123
+
124
+
125
+ __all__ = ["LocalAccessRequest", "LocalWriteRequest", "create_local_files_router"]
@@ -304,4 +304,20 @@ def create_models_router(
304
304
  _router.unload_all()
305
305
  return {"status": "ok", "unloaded": unloaded}
306
306
 
307
+ @router.get("/models/recommendations")
308
+ async def model_recommendations(request: Request, engine: str = "local_mlx"):
309
+ """Hardware-aware tri-state model recommendation for this machine.
310
+
311
+ Detects the system profile (OS/RAM/CPU/GPU/disk) and classifies the
312
+ ``engine`` catalog into recommended / compatible / not_recommended,
313
+ grouped by family. Used by the onboarding and model-picker UIs.
314
+ """
315
+ require_user(request)
316
+ from auto_setup import probe as auto_setup_probe
317
+ from latticeai.services.model_recommendation import recommend_catalog
318
+
319
+ profile = await asyncio.to_thread(lambda: auto_setup_probe().to_json())
320
+ catalog = recommend_catalog(profile, engine=engine)
321
+ return {"profile": profile, "recommendations": catalog}
322
+
307
323
  return router