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.
- package/README.md +105 -79
- package/docs/CHANGELOG.md +109 -0
- package/docs/images/architecture.png +0 -0
- package/docs/images/graph.png +0 -0
- package/docs/images/hero.gif +0 -0
- package/docs/images/model-recommendation.png +0 -0
- package/docs/images/onboarding.png +0 -0
- package/docs/images/organization.png +0 -0
- package/docs/images/skills.png +0 -0
- package/docs/images/tmp_frames/frame_00.png +0 -0
- package/docs/images/tmp_frames/frame_01.png +0 -0
- package/docs/images/tmp_frames/frame_02.png +0 -0
- package/docs/images/tmp_frames/frame_03.png +0 -0
- package/docs/images/workspace.png +0 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +17 -0
- package/latticeai/api/chat.py +786 -0
- package/latticeai/api/computer_use.py +294 -0
- package/latticeai/api/deps.py +15 -0
- package/latticeai/api/garden.py +34 -0
- package/latticeai/api/local_files.py +125 -0
- package/latticeai/api/models.py +16 -0
- package/latticeai/api/permissions.py +331 -0
- package/latticeai/api/setup.py +158 -0
- package/latticeai/api/static_routes.py +166 -0
- package/latticeai/api/tools.py +579 -0
- package/latticeai/api/workspace.py +11 -0
- package/latticeai/core/enterprise_admin.py +158 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +223 -4301
- package/latticeai/services/app_context.py +27 -0
- package/latticeai/services/model_catalog.py +289 -0
- package/latticeai/services/model_recommendation.py +183 -0
- package/latticeai/services/model_runtime.py +1721 -0
- package/latticeai/services/tool_dispatch.py +135 -0
- package/latticeai/services/upload_service.py +99 -0
- package/package.json +3 -3
- package/skills/SKILL_TEMPLATE.md +1 -1
- package/skills/code_review/SKILL.md +1 -1
- package/skills/data_analysis/SKILL.md +1 -1
- package/skills/file_edit/SKILL.md +1 -1
- package/skills/summarize_document/SKILL.md +1 -1
- package/skills/web_search/SKILL.md +1 -1
- 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"]
|
package/latticeai/api/models.py
CHANGED
|
@@ -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
|