ltcai 8.9.0 → 9.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.
- package/README.md +28 -39
- package/auto_setup.py +11 -62
- package/docs/CHANGELOG.md +76 -237
- package/docs/COMMUNITY_AND_PLUGINS.md +2 -2
- package/docs/DEVELOPMENT.md +8 -8
- package/docs/LEGACY_COMPATIBILITY.md +1 -1
- package/docs/ONBOARDING.md +2 -2
- package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +2 -2
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +4 -9
- package/lattice_brain/embeddings.py +38 -2
- package/lattice_brain/graph/_kg_common.py +27 -462
- package/lattice_brain/graph/_kg_constants.py +243 -0
- package/lattice_brain/graph/_kg_fsutil.py +297 -0
- package/lattice_brain/graph/discovery.py +0 -948
- package/lattice_brain/graph/discovery_index.py +972 -0
- package/lattice_brain/graph/retrieval.py +0 -570
- package/lattice_brain/graph/retrieval_docgen.py +210 -0
- package/lattice_brain/graph/retrieval_vector.py +460 -0
- package/lattice_brain/graph/store.py +6 -0
- package/lattice_brain/ingestion.py +68 -0
- package/lattice_brain/portability.py +1 -9
- package/lattice_brain/quality.py +98 -4
- package/lattice_brain/runtime/agent_runtime.py +166 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/utils.py +28 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +340 -407
- package/latticeai/api/chat_helpers.py +227 -0
- package/latticeai/api/computer_use.py +149 -31
- package/latticeai/api/marketplace.py +11 -0
- package/latticeai/api/permissions.py +3 -3
- package/latticeai/api/tools.py +1 -0
- package/latticeai/app_factory.py +82 -360
- package/latticeai/core/io_utils.py +37 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/local_embeddings.py +2 -4
- package/latticeai/core/marketplace.py +33 -2
- package/latticeai/core/mcp_catalog.py +450 -0
- package/latticeai/core/mcp_registry.py +2 -441
- package/latticeai/core/users.py +4 -9
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/core/workspace_os_utils.py +3 -17
- package/latticeai/integrations/telegram_bot.py +7 -2
- package/latticeai/models/model_providers.py +111 -0
- package/latticeai/models/router.py +58 -136
- package/latticeai/runtime/audit_runtime.py +27 -16
- package/latticeai/runtime/automation_runtime.py +9 -0
- package/latticeai/runtime/history_runtime.py +163 -0
- package/latticeai/runtime/namespace_runtime.py +173 -0
- package/latticeai/runtime/network_config_runtime.py +56 -0
- package/latticeai/runtime/sso_config_runtime.py +128 -0
- package/latticeai/runtime/user_key_runtime.py +106 -0
- package/latticeai/services/architecture_readiness.py +2 -2
- package/latticeai/services/memory_service.py +213 -0
- package/latticeai/services/platform_runtime.py +9 -1
- package/latticeai/services/product_readiness.py +11 -11
- package/latticeai/services/review_queue.py +64 -11
- package/latticeai/services/run_executor.py +21 -0
- package/latticeai/services/setup_detection.py +80 -0
- package/latticeai/services/tool_dispatch.py +1 -1
- package/package.json +1 -1
- package/setup_wizard.py +11 -55
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/{Act-fZokUnC0.js → Act-21lIXx2E.js} +1 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
- package/static/app/assets/{Capture-D5KV3Cu7.js → Capture-BA7Z2Q1u.js} +1 -1
- package/static/app/assets/{Library-C9kyFkSt.js → Library-bFMtyni3.js} +1 -1
- package/static/app/assets/System-K6krGCqn.js +1 -0
- package/static/app/assets/index-C4R3ws30.js +17 -0
- package/static/app/assets/index-ChSeOB02.css +2 -0
- package/static/app/assets/primitives-sQU3it5I.js +1 -0
- package/static/app/assets/{textarea-CD8UNKIy.js → textarea-DK3Fd_lR.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/css/tokens.css +4 -2
- package/static/sw.js +1 -1
- package/tools/local_files.py +6 -0
- package/latticeai/runtime/sso_runtime.py +0 -52
- package/static/app/assets/Brain-DtyuWubr.js +0 -321
- package/static/app/assets/System-VbChmX7r.js +0 -1
- package/static/app/assets/index-DCh5AoXt.css +0 -2
- package/static/app/assets/index-DPdcPoF0.js +0 -17
- package/static/app/assets/primitives-DFeanEV6.js +0 -1
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Pure chat helpers: language/intent detection, file-action parsing,
|
|
2
|
+
network-status formatting, workspace-scope extraction, and recent-context
|
|
3
|
+
assembly. Split out of the chat router module so create_chat_router keeps
|
|
4
|
+
only request wiring. chat.py re-imports every name, so external import
|
|
5
|
+
sites (tests, app_factory) are unaffected.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from typing import AsyncIterator, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from fastapi import Request
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def pair_user_history(history: List[Dict], user_email: str) -> List[Dict]:
|
|
18
|
+
"""Restrict history to one user's exchange.
|
|
19
|
+
|
|
20
|
+
Keeps the user's own messages plus assistant replies that directly follow
|
|
21
|
+
them. A bare role=="assistant" pass would leak every other user's replies
|
|
22
|
+
into this user's prompt context.
|
|
23
|
+
"""
|
|
24
|
+
paired: List[Dict] = []
|
|
25
|
+
include_next_assistant = False
|
|
26
|
+
for item in history:
|
|
27
|
+
if item.get("role") == "assistant":
|
|
28
|
+
if include_next_assistant:
|
|
29
|
+
paired.append(item)
|
|
30
|
+
include_next_assistant = False
|
|
31
|
+
elif item.get("user_email") == user_email:
|
|
32
|
+
paired.append(item)
|
|
33
|
+
include_next_assistant = True
|
|
34
|
+
else:
|
|
35
|
+
include_next_assistant = False
|
|
36
|
+
return paired
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def detect_language(text: str) -> str:
|
|
40
|
+
"""Detect language: 'ko' (Korean) or 'en' (English)."""
|
|
41
|
+
total = max(len(text), 1)
|
|
42
|
+
ko = sum(1 for c in text if '가' <= c <= '힣')
|
|
43
|
+
if ko / total > 0.05:
|
|
44
|
+
return "ko"
|
|
45
|
+
return "en"
|
|
46
|
+
|
|
47
|
+
_LANG_HINT = {
|
|
48
|
+
"ko": "Respond in Korean (한국어로 답변하세요).",
|
|
49
|
+
"en": "Respond in English.",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
def is_network_status_request(text: str) -> bool:
|
|
53
|
+
"""사용자가 현재 IP/네트워크 정보를 물었는지 감지합니다."""
|
|
54
|
+
t = (text or "").lower()
|
|
55
|
+
explicit_network = any(
|
|
56
|
+
phrase in t
|
|
57
|
+
for phrase in (
|
|
58
|
+
"ipconfig",
|
|
59
|
+
"ifconfig",
|
|
60
|
+
"network status",
|
|
61
|
+
"네트워크 상태",
|
|
62
|
+
"네트워크 확인",
|
|
63
|
+
"현재 네트워크",
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
current_ip = bool(
|
|
67
|
+
re.search(
|
|
68
|
+
r"(내|현재|지금|로컬|local|public|공인|외부|내부)\s*(ip|아이피)\s*(주소)?",
|
|
69
|
+
t,
|
|
70
|
+
)
|
|
71
|
+
or re.search(
|
|
72
|
+
r"(ip|아이피)\s*(주소)?\s*(확인|상태|알려줘|보여줘)",
|
|
73
|
+
t,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
return explicit_network or current_ip
|
|
77
|
+
|
|
78
|
+
def is_current_url_request(text: str) -> bool:
|
|
79
|
+
t = (text or "").lower()
|
|
80
|
+
explicit_url = any(phrase in t for phrase in ("현재 url", "current url", "page url", "페이지 url"))
|
|
81
|
+
current_page_link = bool(
|
|
82
|
+
re.search(r"(현재|지금|여기|이\s*페이지|브라우저|접속)\s*(페이지\s*)?(url|링크|주소)", t)
|
|
83
|
+
or re.search(r"(url|링크)\s*(알려줘|보여줘|확인)", t)
|
|
84
|
+
)
|
|
85
|
+
return explicit_url or current_page_link
|
|
86
|
+
|
|
87
|
+
def is_clear_command(text: str) -> bool:
|
|
88
|
+
return (text or "").strip().lower() in {"/clear", "/clear_all"}
|
|
89
|
+
|
|
90
|
+
# Path segments intentionally exclude spaces: allowing spaces let the target
|
|
91
|
+
# match swallow preceding words (e.g. "create a text file report.txt" resolved
|
|
92
|
+
# to the whole phrase as the path). Chat file targets are single tokens.
|
|
93
|
+
_FILE_TARGET_RE = re.compile(
|
|
94
|
+
r"(?<![\w.-])(?:[~./\\]?[\w.@()+-]+[\\/])*[\w.@()+-]+"
|
|
95
|
+
r"\.(?:py|js|jsx|ts|tsx|md|markdown|txt|json|yaml|yml|toml|html|css|csv|xml|pdf|docx|xlsx|pptx|sh|sql)",
|
|
96
|
+
re.IGNORECASE,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def is_file_action_request(text: str) -> bool:
|
|
101
|
+
"""Return True for chat requests that should execute file tools, not prose.
|
|
102
|
+
|
|
103
|
+
The Brain chat composer posts to ``/chat``. Without this gate, explicit
|
|
104
|
+
side-effect requests such as "create hello.md" are handled as plain model
|
|
105
|
+
generation, which commonly produces a code block instead of a real file.
|
|
106
|
+
Keep the gate narrow so normal Q&A and "how do I create a file?" stay in
|
|
107
|
+
ordinary chat.
|
|
108
|
+
"""
|
|
109
|
+
raw = (text or "").strip()
|
|
110
|
+
if not raw:
|
|
111
|
+
return False
|
|
112
|
+
lower = raw.lower()
|
|
113
|
+
|
|
114
|
+
if any(phrase in lower for phrase in (
|
|
115
|
+
"how to ", "how do i ", "방법", "어떻게", "예시", "sample", "example",
|
|
116
|
+
)) and not any(phrase in lower for phrase in (
|
|
117
|
+
"actually create", "real file", "실제로", "파일로", "저장해", "만들어",
|
|
118
|
+
)):
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
has_target = bool(_FILE_TARGET_RE.search(raw))
|
|
122
|
+
has_file_word = any(word in lower for word in (
|
|
123
|
+
"file", "파일", "문서", "artifact", "아티팩트", "save as", "저장",
|
|
124
|
+
))
|
|
125
|
+
has_action = any(word in lower for word in (
|
|
126
|
+
"create", "make", "write", "save", "generate", "edit", "update",
|
|
127
|
+
"만들", "생성", "작성", "저장", "수정", "써줘", "만들어줘",
|
|
128
|
+
))
|
|
129
|
+
|
|
130
|
+
if not has_action:
|
|
131
|
+
return False
|
|
132
|
+
if has_target and has_action:
|
|
133
|
+
return True
|
|
134
|
+
return has_file_word and has_action
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def file_action_target(text: str) -> Optional[str]:
|
|
138
|
+
"""Extract the first explicit workspace file target from a request."""
|
|
139
|
+
match = _FILE_TARGET_RE.search(text or "")
|
|
140
|
+
if not match:
|
|
141
|
+
return None
|
|
142
|
+
return match.group(0).strip().strip("`'\".,:;)]}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def inline_file_action_content(text: str) -> Optional[str]:
|
|
146
|
+
"""Extract short user-provided content for deterministic file writes."""
|
|
147
|
+
raw = (text or "").strip()
|
|
148
|
+
# Each pattern requires an explicit binder so ambiguous words like "text"
|
|
149
|
+
# ("create a text file report.txt") or a bare "with" ("report.md with a
|
|
150
|
+
# summary of X") do not get captured as literal file content.
|
|
151
|
+
patterns = [
|
|
152
|
+
r"(?:내용|본문)\s*(?:은|는|이에요|입니다)\s*(.+)$",
|
|
153
|
+
r"(?:내용|본문|content|body|text)\s*[:=]\s*(.+)$",
|
|
154
|
+
r"(?:content|body)\s+(?:is|as)\s+(.+)$",
|
|
155
|
+
r"(?:with the content|with content|containing)\s+(.+)$",
|
|
156
|
+
]
|
|
157
|
+
for pattern in patterns:
|
|
158
|
+
match = re.search(pattern, raw, flags=re.IGNORECASE | re.DOTALL)
|
|
159
|
+
if match:
|
|
160
|
+
content = match.group(1).strip()
|
|
161
|
+
return content.strip("`'\"")
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def strip_generated_file_content(text: str) -> str:
|
|
166
|
+
"""Remove common chat wrappers when a model is asked for file content only."""
|
|
167
|
+
content = (text or "").strip()
|
|
168
|
+
fenced = re.search(r"```(?:[\w.+-]+)?\s*(.*?)\s*```", content, flags=re.DOTALL)
|
|
169
|
+
if fenced:
|
|
170
|
+
content = fenced.group(1).strip()
|
|
171
|
+
return content
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def format_network_status(info: Dict) -> str:
|
|
175
|
+
lines = [
|
|
176
|
+
f"내부 IP: {info.get('local_ip') or '확인 안 됨'}",
|
|
177
|
+
f"외부 IP: {info.get('public_ip') or '확인 안 됨'}",
|
|
178
|
+
f"호스트명: {info.get('hostname') or '확인 안 됨'}",
|
|
179
|
+
]
|
|
180
|
+
local_ips = info.get("local_ips") or {}
|
|
181
|
+
if local_ips:
|
|
182
|
+
lines.extend(["", "인터페이스:"])
|
|
183
|
+
lines.extend(f"- {name}: {ip}" for name, ip in local_ips.items())
|
|
184
|
+
note = info.get("note")
|
|
185
|
+
if note:
|
|
186
|
+
lines.extend(["", note])
|
|
187
|
+
return "\n".join(lines)
|
|
188
|
+
|
|
189
|
+
def workspace_scope_from_request(request: Request) -> Optional[str]:
|
|
190
|
+
header = request.headers.get("X-Workspace-Id")
|
|
191
|
+
if header and header.strip():
|
|
192
|
+
return header.strip()
|
|
193
|
+
query = request.query_params.get("workspace_id")
|
|
194
|
+
return query.strip() if query and query.strip() else None
|
|
195
|
+
|
|
196
|
+
async def single_text_stream(text: str, model: str = "system") -> AsyncIterator[str]:
|
|
197
|
+
yield f"data: {json.dumps({'chunk': text, 'model': model}, ensure_ascii=False)}\n\n"
|
|
198
|
+
yield "data: [DONE]\n\n"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def build_recent_chat_context(
|
|
202
|
+
*,
|
|
203
|
+
get_history,
|
|
204
|
+
limit: int = 10,
|
|
205
|
+
include_image_missing_replies: bool = True,
|
|
206
|
+
user_email: Optional[str] = None,
|
|
207
|
+
conversation_id: Optional[str] = None,
|
|
208
|
+
) -> str:
|
|
209
|
+
history = get_history()
|
|
210
|
+
if conversation_id:
|
|
211
|
+
history = [item for item in history if item.get("conversation_id") == conversation_id]
|
|
212
|
+
if user_email:
|
|
213
|
+
history = pair_user_history(history, user_email)
|
|
214
|
+
history = history[-limit:]
|
|
215
|
+
lines = []
|
|
216
|
+
for item in history:
|
|
217
|
+
role = item.get("role", "user")
|
|
218
|
+
content = item.get("content", "")
|
|
219
|
+
if not include_image_missing_replies and role == "assistant":
|
|
220
|
+
if "이미지" in content and any(word in content for word in ["업로드", "제공", "올려"]):
|
|
221
|
+
continue
|
|
222
|
+
source = item.get("source")
|
|
223
|
+
label = role
|
|
224
|
+
if source:
|
|
225
|
+
label = f"{role} ({source})"
|
|
226
|
+
lines.append(f"{label}: {content}")
|
|
227
|
+
return "\n".join(lines)
|
|
@@ -12,7 +12,9 @@ from pydantic import BaseModel
|
|
|
12
12
|
|
|
13
13
|
from latticeai.core.agent import extract_action as _extract_agent_action
|
|
14
14
|
from lattice_brain.runtime.hooks import dispatch_tool
|
|
15
|
+
from latticeai.services.tool_dispatch import enforce_tool_policy
|
|
15
16
|
from tools import (
|
|
17
|
+
AGENT_ROOT,
|
|
16
18
|
ToolError,
|
|
17
19
|
computer_click,
|
|
18
20
|
computer_drag,
|
|
@@ -108,14 +110,64 @@ class CuDragRequest(BaseModel):
|
|
|
108
110
|
y2: int
|
|
109
111
|
|
|
110
112
|
|
|
111
|
-
def create_computer_use_router(
|
|
113
|
+
def create_computer_use_router(
|
|
114
|
+
*,
|
|
115
|
+
model_router,
|
|
116
|
+
require_user,
|
|
117
|
+
tool_response,
|
|
118
|
+
save_to_history,
|
|
119
|
+
hooks=None,
|
|
120
|
+
append_audit_event=None,
|
|
121
|
+
) -> APIRouter:
|
|
112
122
|
router = APIRouter()
|
|
113
123
|
|
|
114
|
-
def
|
|
124
|
+
def _audit_args(name: str, args: dict) -> dict:
|
|
125
|
+
"""Return audit-safe argument metadata without storing typed text."""
|
|
126
|
+
args = dict(args or {})
|
|
127
|
+
if name == "computer_type":
|
|
128
|
+
return {"text_length": len(str(args.get("text") or "")), "interval": args.get("interval")}
|
|
129
|
+
if name == "vision_analyze":
|
|
130
|
+
return {
|
|
131
|
+
"image_b64_length": len(str(args.get("image_b64") or "")),
|
|
132
|
+
"prompt_length": len(str(args.get("prompt") or "")),
|
|
133
|
+
}
|
|
134
|
+
return args
|
|
135
|
+
|
|
136
|
+
def _audit(name: str, args: dict, *, current_user: str, status: str, error: Optional[str] = None) -> None:
|
|
137
|
+
if append_audit_event is None:
|
|
138
|
+
return
|
|
139
|
+
payload = {
|
|
140
|
+
"user_email": current_user,
|
|
141
|
+
"source": "computer_use",
|
|
142
|
+
"tool": name,
|
|
143
|
+
"status": status,
|
|
144
|
+
"args": _audit_args(name, args),
|
|
145
|
+
}
|
|
146
|
+
if error:
|
|
147
|
+
payload["error"] = error[:500]
|
|
148
|
+
append_audit_event("computer_use_tool", **payload)
|
|
149
|
+
|
|
150
|
+
def _dispatch(name, args, fn, *, current_user: str):
|
|
115
151
|
# Run a computer-use action through the unified pre_tool/post_tool
|
|
116
|
-
# lifecycle
|
|
117
|
-
#
|
|
118
|
-
|
|
152
|
+
# lifecycle after the same ToolRegistry policy gate used by direct
|
|
153
|
+
# /tools execution. This keeps desktop control behind one governance
|
|
154
|
+
# boundary instead of relying on route-specific checks.
|
|
155
|
+
args = dict(args or {})
|
|
156
|
+
try:
|
|
157
|
+
enforce_tool_policy(name, args, current_user=current_user, source="computer_use")
|
|
158
|
+
except HTTPException as exc:
|
|
159
|
+
_audit(name, args, current_user=current_user, status="blocked", error=str(exc.detail))
|
|
160
|
+
raise
|
|
161
|
+
try:
|
|
162
|
+
result = dispatch_tool(hooks, name, args, fn, source="computer_use")
|
|
163
|
+
except (PermissionError, ToolError, KeyError, TypeError) as exc:
|
|
164
|
+
_audit(name, args, current_user=current_user, status="error", error=str(exc))
|
|
165
|
+
raise
|
|
166
|
+
_audit(name, args, current_user=current_user, status="ok")
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
def _response(result):
|
|
170
|
+
return {"status": "ok", "workspace": str(AGENT_ROOT), "result": result}
|
|
119
171
|
|
|
120
172
|
@router.get("/tools/chrome_status")
|
|
121
173
|
async def tools_chrome_status(request: Request):
|
|
@@ -129,9 +181,11 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
|
|
|
129
181
|
|
|
130
182
|
@router.get("/cu/status")
|
|
131
183
|
async def cu_status(request: Request):
|
|
132
|
-
require_user(request)
|
|
184
|
+
current_user = require_user(request)
|
|
133
185
|
try:
|
|
134
|
-
return _dispatch("computer_status", {}, computer_status)
|
|
186
|
+
return _dispatch("computer_status", {}, computer_status, current_user=current_user)
|
|
187
|
+
except HTTPException:
|
|
188
|
+
raise
|
|
135
189
|
except PermissionError as exc:
|
|
136
190
|
raise HTTPException(status_code=403, detail=str(exc))
|
|
137
191
|
except ToolError as exc:
|
|
@@ -139,9 +193,11 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
|
|
|
139
193
|
|
|
140
194
|
@router.get("/cu/screenshot")
|
|
141
195
|
async def cu_screenshot(request: Request):
|
|
142
|
-
require_user(request)
|
|
196
|
+
current_user = require_user(request)
|
|
143
197
|
try:
|
|
144
|
-
return _dispatch("computer_screenshot", {}, computer_screenshot)
|
|
198
|
+
return _dispatch("computer_screenshot", {}, computer_screenshot, current_user=current_user)
|
|
199
|
+
except HTTPException:
|
|
200
|
+
raise
|
|
145
201
|
except PermissionError as exc:
|
|
146
202
|
raise HTTPException(status_code=403, detail=str(exc))
|
|
147
203
|
except ToolError as exc:
|
|
@@ -149,47 +205,95 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
|
|
|
149
205
|
|
|
150
206
|
@router.post("/cu/open_app")
|
|
151
207
|
async def cu_open_app(req: CuOpenAppRequest, request: Request):
|
|
152
|
-
require_user(request)
|
|
153
|
-
|
|
208
|
+
current_user = require_user(request)
|
|
209
|
+
result = _dispatch(
|
|
210
|
+
"computer_open_app",
|
|
211
|
+
{"app": req.app},
|
|
212
|
+
lambda: computer_open_app(req.app),
|
|
213
|
+
current_user=current_user,
|
|
214
|
+
)
|
|
215
|
+
return _response(result)
|
|
154
216
|
|
|
155
217
|
@router.post("/cu/open_url")
|
|
156
218
|
async def cu_open_url(req: CuOpenUrlRequest, request: Request):
|
|
157
|
-
require_user(request)
|
|
158
|
-
|
|
219
|
+
current_user = require_user(request)
|
|
220
|
+
result = _dispatch(
|
|
221
|
+
"computer_open_url",
|
|
222
|
+
{"url": req.url, "app": req.app},
|
|
223
|
+
lambda: computer_open_url(req.url, req.app),
|
|
224
|
+
current_user=current_user,
|
|
225
|
+
)
|
|
226
|
+
return _response(result)
|
|
159
227
|
|
|
160
228
|
@router.post("/cu/click")
|
|
161
229
|
async def cu_click(req: CuClickRequest, request: Request):
|
|
162
|
-
require_user(request)
|
|
163
|
-
|
|
230
|
+
current_user = require_user(request)
|
|
231
|
+
result = _dispatch(
|
|
232
|
+
"computer_click",
|
|
233
|
+
{"x": req.x, "y": req.y, "button": req.button, "double": req.double},
|
|
234
|
+
lambda: computer_click(req.x, req.y, req.button, req.double),
|
|
235
|
+
current_user=current_user,
|
|
236
|
+
)
|
|
237
|
+
return _response(result)
|
|
164
238
|
|
|
165
239
|
@router.post("/cu/type")
|
|
166
240
|
async def cu_type(req: CuTypeRequest, request: Request):
|
|
167
|
-
require_user(request)
|
|
168
|
-
|
|
241
|
+
current_user = require_user(request)
|
|
242
|
+
result = _dispatch(
|
|
243
|
+
"computer_type",
|
|
244
|
+
{"text": req.text, "interval": req.interval},
|
|
245
|
+
lambda: computer_type(req.text, req.interval),
|
|
246
|
+
current_user=current_user,
|
|
247
|
+
)
|
|
248
|
+
return _response(result)
|
|
169
249
|
|
|
170
250
|
@router.post("/cu/key")
|
|
171
251
|
async def cu_key(req: CuKeyRequest, request: Request):
|
|
172
|
-
require_user(request)
|
|
173
|
-
|
|
252
|
+
current_user = require_user(request)
|
|
253
|
+
result = _dispatch(
|
|
254
|
+
"computer_key",
|
|
255
|
+
{"key": req.key},
|
|
256
|
+
lambda: computer_key(req.key),
|
|
257
|
+
current_user=current_user,
|
|
258
|
+
)
|
|
259
|
+
return _response(result)
|
|
174
260
|
|
|
175
261
|
@router.post("/cu/scroll")
|
|
176
262
|
async def cu_scroll(req: CuScrollRequest, request: Request):
|
|
177
|
-
require_user(request)
|
|
178
|
-
|
|
263
|
+
current_user = require_user(request)
|
|
264
|
+
result = _dispatch(
|
|
265
|
+
"computer_scroll",
|
|
266
|
+
{"x": req.x, "y": req.y, "direction": req.direction, "clicks": req.clicks},
|
|
267
|
+
lambda: computer_scroll(req.x, req.y, req.direction, req.clicks),
|
|
268
|
+
current_user=current_user,
|
|
269
|
+
)
|
|
270
|
+
return _response(result)
|
|
179
271
|
|
|
180
272
|
@router.post("/cu/move")
|
|
181
273
|
async def cu_move(req: CuMoveRequest, request: Request):
|
|
182
|
-
require_user(request)
|
|
183
|
-
|
|
274
|
+
current_user = require_user(request)
|
|
275
|
+
result = _dispatch(
|
|
276
|
+
"computer_move",
|
|
277
|
+
{"x": req.x, "y": req.y},
|
|
278
|
+
lambda: computer_move(req.x, req.y),
|
|
279
|
+
current_user=current_user,
|
|
280
|
+
)
|
|
281
|
+
return _response(result)
|
|
184
282
|
|
|
185
283
|
@router.post("/cu/drag")
|
|
186
284
|
async def cu_drag(req: CuDragRequest, request: Request):
|
|
187
|
-
require_user(request)
|
|
188
|
-
|
|
285
|
+
current_user = require_user(request)
|
|
286
|
+
result = _dispatch(
|
|
287
|
+
"computer_drag",
|
|
288
|
+
{"x1": req.x1, "y1": req.y1, "x2": req.x2, "y2": req.y2},
|
|
289
|
+
lambda: computer_drag(req.x1, req.y1, req.x2, req.y2),
|
|
290
|
+
current_user=current_user,
|
|
291
|
+
)
|
|
292
|
+
return _response(result)
|
|
189
293
|
|
|
190
294
|
@router.post("/cu/agent")
|
|
191
295
|
async def cu_agent(req: CuAgentRequest, request: Request):
|
|
192
|
-
require_user(request)
|
|
296
|
+
current_user = require_user(request)
|
|
193
297
|
|
|
194
298
|
async def _stream():
|
|
195
299
|
task_lower = (req.task or "").lower()
|
|
@@ -209,8 +313,12 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
|
|
|
209
313
|
"action",
|
|
210
314
|
{"step": 1, "action": "computer_open_url", "args": {"url": url, "app": "Google Chrome"}},
|
|
211
315
|
)
|
|
212
|
-
result = _dispatch(
|
|
213
|
-
|
|
316
|
+
result = _dispatch(
|
|
317
|
+
"computer_open_url",
|
|
318
|
+
{"url": url, "app": "Google Chrome"},
|
|
319
|
+
lambda: computer_open_url(url, "Google Chrome"),
|
|
320
|
+
current_user=current_user,
|
|
321
|
+
)
|
|
214
322
|
yield _send("result", {"step": 1, "action": "computer_open_url", "result": result})
|
|
215
323
|
message = f"Google Chrome에서 {url}을 열었습니다."
|
|
216
324
|
action_name = "computer_open_url"
|
|
@@ -219,14 +327,20 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
|
|
|
219
327
|
"action",
|
|
220
328
|
{"step": 1, "action": "computer_open_app", "args": {"app": "Google Chrome"}},
|
|
221
329
|
)
|
|
222
|
-
result = _dispatch(
|
|
223
|
-
|
|
330
|
+
result = _dispatch(
|
|
331
|
+
"computer_open_app",
|
|
332
|
+
{"app": "Google Chrome"},
|
|
333
|
+
lambda: computer_open_app("Google Chrome"),
|
|
334
|
+
current_user=current_user,
|
|
335
|
+
)
|
|
224
336
|
yield _send("result", {"step": 1, "action": "computer_open_app", "result": result})
|
|
225
337
|
message = "Google Chrome을 열었습니다."
|
|
226
338
|
action_name = "computer_open_app"
|
|
227
339
|
save_to_history("user", req.task, source="web", conversation_id=req.conversation_id)
|
|
228
340
|
save_to_history("assistant", message, source="web", conversation_id=req.conversation_id)
|
|
229
341
|
yield _send("final", {"message": message, "steps": [{"step": 1, "action": action_name, "result": result}]})
|
|
342
|
+
except HTTPException as exc:
|
|
343
|
+
yield _send("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc.detail)})
|
|
230
344
|
except (ToolError, PermissionError) as exc:
|
|
231
345
|
yield _send("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc)})
|
|
232
346
|
return
|
|
@@ -271,7 +385,7 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
|
|
|
271
385
|
|
|
272
386
|
yield _send("action", {"step": step + 1, "action": name, "args": args})
|
|
273
387
|
try:
|
|
274
|
-
result = _dispatch(name, args, lambda: execute_tool(name, args))
|
|
388
|
+
result = _dispatch(name, args, lambda: execute_tool(name, args), current_user=current_user)
|
|
275
389
|
if name == "computer_screenshot" and "screenshot_b64" in result:
|
|
276
390
|
last_screenshot_b64 = result["screenshot_b64"]
|
|
277
391
|
result_summary = {k: v for k, v in result.items() if k != "screenshot_b64"}
|
|
@@ -290,6 +404,10 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
|
|
|
290
404
|
last_screenshot_b64 = None
|
|
291
405
|
transcript.append({"step": step + 1, "action": name, "args": args, "result": result})
|
|
292
406
|
yield _send("result", {"step": step + 1, "action": name, "result": result})
|
|
407
|
+
except HTTPException as exc:
|
|
408
|
+
error_str = str(exc.detail)
|
|
409
|
+
transcript.append({"step": step + 1, "action": name, "args": args, "error": error_str})
|
|
410
|
+
yield _send("tool_error", {"step": step + 1, "action": name, "error": error_str})
|
|
293
411
|
except (ToolError, PermissionError, KeyError, TypeError) as exc:
|
|
294
412
|
error_str = str(exc)
|
|
295
413
|
transcript.append({"step": step + 1, "action": name, "args": args, "error": error_str})
|
|
@@ -91,4 +91,15 @@ def create_marketplace_router(
|
|
|
91
91
|
scope = gate_read(request)
|
|
92
92
|
return {"registry": store.list_template_registry(workspace_id=scope)}
|
|
93
93
|
|
|
94
|
+
@router.get("/marketplace/interop/bridges")
|
|
95
|
+
async def list_interop_bridges(request: Request):
|
|
96
|
+
require_user(request)
|
|
97
|
+
gate_read(request)
|
|
98
|
+
bridges = catalog.list_templates(kind="ingestion_bridge")["templates"]
|
|
99
|
+
return {
|
|
100
|
+
"bridges": bridges,
|
|
101
|
+
"total": len(bridges),
|
|
102
|
+
"pipeline": "unified-ingestion",
|
|
103
|
+
}
|
|
104
|
+
|
|
94
105
|
return router
|
|
@@ -216,9 +216,9 @@ class PermissionGateway:
|
|
|
216
216
|
now = time.time()
|
|
217
217
|
key = self.token_hash(token)
|
|
218
218
|
with self.local_approval_lock:
|
|
219
|
-
expired = [
|
|
220
|
-
for
|
|
221
|
-
self.local_approvals.pop(
|
|
219
|
+
expired = [approval_key for approval_key, value in self.local_approvals.items() if float(value.get("expires_at", 0)) < now]
|
|
220
|
+
for expired_key in expired:
|
|
221
|
+
self.local_approvals.pop(expired_key, None)
|
|
222
222
|
record = self.local_approvals.get(key)
|
|
223
223
|
if not record:
|
|
224
224
|
raise HTTPException(status_code=403, detail="파일 접근 승인이 만료되었거나 유효하지 않습니다.")
|