ltcai 0.1.3 → 0.1.8
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 +121 -0
- package/docs/OPERATIONS.md +149 -0
- package/knowledge_graph.py +802 -0
- package/ltcai_cli.py +45 -1
- package/package.json +15 -3
- package/requirements.txt +2 -0
- package/server.py +818 -39
- package/skills/SKILL_TEMPLATE.md +57 -0
- package/skills/code_review/SKILL.md +76 -0
- package/skills/data_analysis/SKILL.md +79 -0
- package/skills/file_edit/SKILL.md +68 -0
- package/skills/web_search/SKILL.md +74 -0
- package/static/account.html +74 -2
- package/static/admin.html +225 -6
- package/static/chat.html +886 -147
- package/static/graph.html +612 -0
- package/static/icons/apple-touch-icon.png +0 -0
- package/static/icons/favicon-32.png +0 -0
- package/static/icons/icon-192.png +0 -0
- package/static/icons/icon-512.png +0 -0
- package/static/manifest.json +35 -0
- package/static/sw.js +51 -0
- package/telegram_bot.py +631 -217
- package/tests/__init__.py +0 -0
- package/tests/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/test_api.py +94 -0
- package/tests/unit/__init__.py +0 -0
- package/tests/unit/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tests/unit/__pycache__/test_tools.cpython-314-pytest-9.0.3.pyc +0 -0
- package/tests/unit/test_tools.py +127 -0
- package/tools.py +169 -13
|
File without changes
|
|
Binary file
|
|
File without changes
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Integration tests for FastAPI endpoints.
|
|
2
|
+
|
|
3
|
+
Run against a live server: pytest tests/integration/ --base-url http://localhost:8899
|
|
4
|
+
Default base URL falls back to http://localhost:8899 if flag not provided.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import pytest
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
BASE_URL = os.environ.get("LTCAI_TEST_BASE_URL", "http://localhost:8899")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _session_cookie() -> dict:
|
|
14
|
+
"""Return session cookie if LTCAI_TEST_SESSION env var is set, else empty dict."""
|
|
15
|
+
sid = os.environ.get("LTCAI_TEST_SESSION", "")
|
|
16
|
+
return {"session_id": sid} if sid else {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture(scope="session")
|
|
20
|
+
def client():
|
|
21
|
+
with httpx.Client(base_url=BASE_URL, cookies=_session_cookie(), timeout=15) as c:
|
|
22
|
+
yield c
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Health / Info
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def test_health(client):
|
|
30
|
+
r = client.get("/health")
|
|
31
|
+
assert r.status_code == 200
|
|
32
|
+
data = r.json()
|
|
33
|
+
assert "status" in data
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_mode_endpoint(client):
|
|
37
|
+
r = client.get("/mode")
|
|
38
|
+
assert r.status_code == 200
|
|
39
|
+
data = r.json()
|
|
40
|
+
assert "model" in data or "mode" in data
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_runtime_features(client):
|
|
44
|
+
r = client.get("/runtime_features")
|
|
45
|
+
assert r.status_code == 200
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Local filesystem endpoints
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def test_local_list_home(client):
|
|
53
|
+
r = client.get("/local/list", params={"path": "~"})
|
|
54
|
+
# 200 if authenticated, 401/403 if no session
|
|
55
|
+
assert r.status_code in (200, 401, 403)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_local_list_requires_auth(client):
|
|
59
|
+
"""Without a session cookie the endpoint must reject the request."""
|
|
60
|
+
r = httpx.get(f"{BASE_URL}/local/list", params={"path": "~"}, timeout=10)
|
|
61
|
+
assert r.status_code in (401, 403)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_local_serve_missing_file(client):
|
|
65
|
+
r = client.get("/local/serve", params={"path": "/nonexistent_lattice_ai_test_xyz.txt"})
|
|
66
|
+
assert r.status_code in (400, 401, 403, 404)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# /tools/read_document
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def test_read_document_missing(client):
|
|
74
|
+
r = client.post("/tools/read_document", json={"path": "/nonexistent_lattice_ai_doc.txt"})
|
|
75
|
+
assert r.status_code in (400, 401, 403, 404, 422)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# /chat — smoke test (no model required)
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def test_chat_requires_message(client):
|
|
83
|
+
r = client.post("/chat", json={})
|
|
84
|
+
# Missing message → 422 validation error or 401 unauth
|
|
85
|
+
assert r.status_code in (401, 403, 422)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# /agent — smoke test
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def test_agent_requires_task(client):
|
|
93
|
+
r = client.post("/agent", json={})
|
|
94
|
+
assert r.status_code in (401, 403, 422)
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Unit tests for tools.py core functions."""
|
|
2
|
+
import sys
|
|
3
|
+
import pytest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
7
|
+
|
|
8
|
+
from tools import local_list, local_read, local_write, read_document, ToolError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# local_list
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
def test_local_list_returns_items(tmp_path):
|
|
16
|
+
(tmp_path / "a.txt").write_text("hello")
|
|
17
|
+
(tmp_path / "sub").mkdir()
|
|
18
|
+
result = local_list(str(tmp_path))
|
|
19
|
+
names = [i["name"] for i in result["items"]]
|
|
20
|
+
assert "a.txt" in names
|
|
21
|
+
assert "sub" in names
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_local_list_dirs_before_files(tmp_path):
|
|
25
|
+
(tmp_path / "z_file.txt").write_text("x")
|
|
26
|
+
(tmp_path / "a_dir").mkdir()
|
|
27
|
+
result = local_list(str(tmp_path))
|
|
28
|
+
types = [i["type"] for i in result["items"]]
|
|
29
|
+
assert types[0] == "directory"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_local_list_nonexistent_raises(tmp_path):
|
|
33
|
+
with pytest.raises(ToolError):
|
|
34
|
+
local_list(str(tmp_path / "does_not_exist"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_local_list_file_raises(tmp_path):
|
|
38
|
+
f = tmp_path / "file.txt"
|
|
39
|
+
f.write_text("x")
|
|
40
|
+
with pytest.raises(ToolError):
|
|
41
|
+
local_list(str(f))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# local_read
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def test_local_read_text_file(tmp_path):
|
|
49
|
+
f = tmp_path / "hello.txt"
|
|
50
|
+
f.write_text("hello world")
|
|
51
|
+
result = local_read(str(f))
|
|
52
|
+
assert "hello world" in result["content"]
|
|
53
|
+
assert result["path"] == str(f)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_local_read_missing_file_raises(tmp_path):
|
|
57
|
+
with pytest.raises(ToolError):
|
|
58
|
+
local_read(str(tmp_path / "missing.txt"))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_local_read_tilde_expansion(tmp_path, monkeypatch):
|
|
62
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
63
|
+
f = tmp_path / "testfile.txt"
|
|
64
|
+
f.write_text("tilde test")
|
|
65
|
+
result = local_read("~/testfile.txt")
|
|
66
|
+
assert "tilde test" in result["content"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_local_read_returns_size(tmp_path):
|
|
70
|
+
f = tmp_path / "sized.txt"
|
|
71
|
+
f.write_text("abc")
|
|
72
|
+
result = local_read(str(f))
|
|
73
|
+
assert result["size"] == 3
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# local_write
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def test_local_write_creates_file(tmp_path):
|
|
81
|
+
target = tmp_path / "out.txt"
|
|
82
|
+
result = local_write(str(target), "new content")
|
|
83
|
+
assert target.read_text() == "new content"
|
|
84
|
+
assert "bytes" in result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_local_write_overwrites_file(tmp_path):
|
|
88
|
+
target = tmp_path / "out.txt"
|
|
89
|
+
target.write_text("old")
|
|
90
|
+
local_write(str(target), "new")
|
|
91
|
+
assert target.read_text() == "new"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_local_write_creates_parent_dirs(tmp_path):
|
|
95
|
+
target = tmp_path / "deep" / "nested" / "file.txt"
|
|
96
|
+
local_write(str(target), "deep write")
|
|
97
|
+
assert target.exists()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_local_write_returns_path(tmp_path):
|
|
101
|
+
target = tmp_path / "x.txt"
|
|
102
|
+
result = local_write(str(target), "hi")
|
|
103
|
+
assert result["path"] == str(target)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# read_document
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def test_read_document_plain_text(tmp_path):
|
|
111
|
+
f = tmp_path / "doc.txt"
|
|
112
|
+
f.write_text("plain text content")
|
|
113
|
+
result = read_document(str(f))
|
|
114
|
+
assert "plain text content" in result.get("text", "") or "plain text content" in str(result)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_read_document_missing_file_raises(tmp_path):
|
|
118
|
+
with pytest.raises(ToolError):
|
|
119
|
+
read_document(str(tmp_path / "missing.pdf"))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_read_document_csv(tmp_path):
|
|
123
|
+
f = tmp_path / "data.csv"
|
|
124
|
+
f.write_text("col1,col2\n1,2\n3,4\n")
|
|
125
|
+
result = read_document(str(f))
|
|
126
|
+
# should not raise; returns some text content
|
|
127
|
+
assert result is not None
|
package/tools.py
CHANGED
|
@@ -8,6 +8,7 @@ workspace.
|
|
|
8
8
|
|
|
9
9
|
import base64
|
|
10
10
|
import os
|
|
11
|
+
import platform
|
|
11
12
|
import re
|
|
12
13
|
import shlex
|
|
13
14
|
import socket
|
|
@@ -18,6 +19,8 @@ from html.parser import HTMLParser
|
|
|
18
19
|
from pathlib import Path
|
|
19
20
|
from typing import Any, Dict, List, Optional
|
|
20
21
|
|
|
22
|
+
_PLATFORM = platform.system() # "Darwin" | "Windows" | "Linux"
|
|
23
|
+
|
|
21
24
|
from p_reinforce import BRAIN_DIR, STRUCTURE
|
|
22
25
|
|
|
23
26
|
# ── Computer Use ──────────────────────────────────────────────────────────────
|
|
@@ -43,12 +46,19 @@ def computer_screenshot() -> Dict[str, Any]:
|
|
|
43
46
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp_file:
|
|
44
47
|
tmp = tmp_file.name
|
|
45
48
|
try:
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
if _PLATFORM == "Darwin":
|
|
50
|
+
r = subprocess.run(
|
|
51
|
+
["screencapture", "-x", "-t", "png", tmp],
|
|
52
|
+
capture_output=True, timeout=10, check=False,
|
|
53
|
+
)
|
|
54
|
+
if r.returncode != 0:
|
|
55
|
+
raise ToolError(f"screencapture 실패: {r.stderr.decode()}")
|
|
56
|
+
elif _CU_AVAILABLE:
|
|
57
|
+
# Windows / Linux: use pyautogui screenshot
|
|
58
|
+
screenshot = _pyautogui.screenshot()
|
|
59
|
+
screenshot.save(tmp)
|
|
60
|
+
else:
|
|
61
|
+
raise ToolError("스크린샷 불가: macOS 전용 screencapture 또는 pyautogui 필요")
|
|
52
62
|
with open(tmp, "rb") as f:
|
|
53
63
|
b64 = base64.b64encode(f.read()).decode()
|
|
54
64
|
size = os.path.getsize(tmp)
|
|
@@ -69,11 +79,17 @@ def computer_screenshot() -> Dict[str, Any]:
|
|
|
69
79
|
|
|
70
80
|
|
|
71
81
|
def computer_open_app(app: str = "Google Chrome") -> Dict[str, Any]:
|
|
72
|
-
"""
|
|
82
|
+
"""앱을 실행하거나 앞으로 가져옵니다 (macOS/Windows/Linux)."""
|
|
73
83
|
app = str(app or "Google Chrome").strip()
|
|
74
84
|
if not app:
|
|
75
85
|
raise ToolError("앱 이름이 필요합니다.")
|
|
76
|
-
|
|
86
|
+
if _PLATFORM == "Darwin":
|
|
87
|
+
cmd = ["open", "-a", app]
|
|
88
|
+
elif _PLATFORM == "Windows":
|
|
89
|
+
cmd = ["cmd", "/c", "start", "", app]
|
|
90
|
+
else:
|
|
91
|
+
cmd = ["xdg-open", app]
|
|
92
|
+
r = subprocess.run(cmd, capture_output=True, timeout=10, check=False)
|
|
77
93
|
if r.returncode != 0:
|
|
78
94
|
err = r.stderr.decode("utf-8", errors="replace").strip()
|
|
79
95
|
raise ToolError(f"앱 열기 실패: {err or app}")
|
|
@@ -81,18 +97,24 @@ def computer_open_app(app: str = "Google Chrome") -> Dict[str, Any]:
|
|
|
81
97
|
|
|
82
98
|
|
|
83
99
|
def computer_open_url(url: str, app: str = "Google Chrome") -> Dict[str, Any]:
|
|
84
|
-
"""URL을
|
|
100
|
+
"""URL을 브라우저로 엽니다 (macOS/Windows/Linux)."""
|
|
85
101
|
url = str(url or "").strip()
|
|
86
|
-
app = str(app or "
|
|
102
|
+
app = str(app or "").strip()
|
|
87
103
|
if not url:
|
|
88
104
|
raise ToolError("URL이 필요합니다.")
|
|
89
105
|
if "://" not in url and not url.startswith(("localhost", "127.0.0.1")):
|
|
90
106
|
url = "https://" + url
|
|
91
|
-
|
|
107
|
+
if _PLATFORM == "Darwin":
|
|
108
|
+
cmd = ["open", "-a", app, url] if app else ["open", url]
|
|
109
|
+
elif _PLATFORM == "Windows":
|
|
110
|
+
cmd = ["cmd", "/c", "start", "", url]
|
|
111
|
+
else:
|
|
112
|
+
cmd = ["xdg-open", url]
|
|
113
|
+
r = subprocess.run(cmd, capture_output=True, timeout=10, check=False)
|
|
92
114
|
if r.returncode != 0:
|
|
93
115
|
err = r.stderr.decode("utf-8", errors="replace").strip()
|
|
94
116
|
raise ToolError(f"URL 열기 실패: {err or url}")
|
|
95
|
-
return {"action": "open_url", "app": app, "url": url}
|
|
117
|
+
return {"action": "open_url", "app": app or "default", "url": url}
|
|
96
118
|
|
|
97
119
|
|
|
98
120
|
def computer_click(x: int, y: int, button: str = "left", double: bool = False) -> Dict[str, Any]:
|
|
@@ -216,7 +238,19 @@ ALLOWED_COMMANDS = {
|
|
|
216
238
|
}
|
|
217
239
|
|
|
218
240
|
BUILD_SCRIPT_NAMES = {"build", "compile", "typecheck", "test"}
|
|
219
|
-
DEPLOY_SCRIPT_NAMES = {
|
|
241
|
+
DEPLOY_SCRIPT_NAMES = {
|
|
242
|
+
"deploy",
|
|
243
|
+
"preview",
|
|
244
|
+
"release",
|
|
245
|
+
"package",
|
|
246
|
+
"dist",
|
|
247
|
+
"make",
|
|
248
|
+
"build:installer",
|
|
249
|
+
"build:pkg",
|
|
250
|
+
"build:exe",
|
|
251
|
+
"package:mac",
|
|
252
|
+
"package:win",
|
|
253
|
+
}
|
|
220
254
|
|
|
221
255
|
ALLOWED_GIT_SUBCOMMANDS = {"status", "diff", "log", "show"}
|
|
222
256
|
|
|
@@ -443,6 +477,126 @@ def preview_url(path: str = "index.html") -> Dict[str, Any]:
|
|
|
443
477
|
}
|
|
444
478
|
|
|
445
479
|
|
|
480
|
+
def create_web_project(path: str, framework: str = "react", template: str = "vite") -> Dict[str, Any]:
|
|
481
|
+
framework = str(framework or "").strip().lower()
|
|
482
|
+
template = str(template or "").strip().lower()
|
|
483
|
+
if framework != "react" or template != "vite":
|
|
484
|
+
raise ToolError("Only React + Vite template is currently supported.")
|
|
485
|
+
if not path:
|
|
486
|
+
raise ToolError("Project path is required.")
|
|
487
|
+
|
|
488
|
+
root = _resolve_path(path)
|
|
489
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
490
|
+
|
|
491
|
+
files = {
|
|
492
|
+
"package.json": json.dumps(
|
|
493
|
+
{
|
|
494
|
+
"name": Path(path).name.replace(" ", "-").lower() or "vite-react-app",
|
|
495
|
+
"private": True,
|
|
496
|
+
"version": "0.0.0",
|
|
497
|
+
"type": "module",
|
|
498
|
+
"scripts": {
|
|
499
|
+
"dev": "vite",
|
|
500
|
+
"build": "vite build",
|
|
501
|
+
"preview": "vite preview",
|
|
502
|
+
},
|
|
503
|
+
"dependencies": {
|
|
504
|
+
"react": "^18.3.1",
|
|
505
|
+
"react-dom": "^18.3.1",
|
|
506
|
+
},
|
|
507
|
+
"devDependencies": {
|
|
508
|
+
"@vitejs/plugin-react": "^4.3.1",
|
|
509
|
+
"vite": "^5.4.0",
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
ensure_ascii=False,
|
|
513
|
+
indent=2,
|
|
514
|
+
) + "\n",
|
|
515
|
+
"index.html": """<!doctype html>
|
|
516
|
+
<html lang="en">
|
|
517
|
+
<head>
|
|
518
|
+
<meta charset="UTF-8" />
|
|
519
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
520
|
+
<title>Vite React App</title>
|
|
521
|
+
</head>
|
|
522
|
+
<body>
|
|
523
|
+
<div id="root"></div>
|
|
524
|
+
<script type="module" src="/src/main.jsx"></script>
|
|
525
|
+
</body>
|
|
526
|
+
</html>
|
|
527
|
+
""",
|
|
528
|
+
"vite.config.js": """import { defineConfig } from 'vite'
|
|
529
|
+
import react from '@vitejs/plugin-react'
|
|
530
|
+
|
|
531
|
+
export default defineConfig({
|
|
532
|
+
plugins: [react()],
|
|
533
|
+
})
|
|
534
|
+
""",
|
|
535
|
+
"README.md": """# Vite React App
|
|
536
|
+
|
|
537
|
+
## Run
|
|
538
|
+
|
|
539
|
+
```bash
|
|
540
|
+
npm install
|
|
541
|
+
npm run dev
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
## Build
|
|
545
|
+
|
|
546
|
+
```bash
|
|
547
|
+
npm run build
|
|
548
|
+
npm run preview
|
|
549
|
+
```
|
|
550
|
+
""",
|
|
551
|
+
"src/main.jsx": """import React from 'react'
|
|
552
|
+
import ReactDOM from 'react-dom/client'
|
|
553
|
+
import App from './App.jsx'
|
|
554
|
+
import './index.css'
|
|
555
|
+
|
|
556
|
+
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
557
|
+
<React.StrictMode>
|
|
558
|
+
<App />
|
|
559
|
+
</React.StrictMode>,
|
|
560
|
+
)
|
|
561
|
+
""",
|
|
562
|
+
"src/App.jsx": """import { useState } from 'react'
|
|
563
|
+
|
|
564
|
+
export default function App() {
|
|
565
|
+
const [count, setCount] = useState(0)
|
|
566
|
+
return (
|
|
567
|
+
<main style={{ maxWidth: 680, margin: '48px auto', fontFamily: 'system-ui, sans-serif' }}>
|
|
568
|
+
<h1>Vite + React</h1>
|
|
569
|
+
<p>Starter generated by Lattice AI agent.</p>
|
|
570
|
+
<button onClick={() => setCount((c) => c + 1)}>count is {count}</button>
|
|
571
|
+
</main>
|
|
572
|
+
)
|
|
573
|
+
}
|
|
574
|
+
""",
|
|
575
|
+
"src/index.css": """* { box-sizing: border-box; }
|
|
576
|
+
body { margin: 0; background: #f6f7fb; color: #111; }
|
|
577
|
+
button { padding: 10px 14px; border-radius: 10px; border: 1px solid #d6d6d6; background: #fff; cursor: pointer; }
|
|
578
|
+
""",
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
created: List[str] = []
|
|
582
|
+
total_bytes = 0
|
|
583
|
+
for rel_path, content in files.items():
|
|
584
|
+
target = (root / rel_path).resolve()
|
|
585
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
586
|
+
target.write_text(content, encoding="utf-8")
|
|
587
|
+
created.append(_relative(target))
|
|
588
|
+
total_bytes += target.stat().st_size
|
|
589
|
+
|
|
590
|
+
return {
|
|
591
|
+
"path": _relative(root),
|
|
592
|
+
"framework": framework,
|
|
593
|
+
"template": template,
|
|
594
|
+
"created_files": created,
|
|
595
|
+
"file_count": len(created),
|
|
596
|
+
"bytes": total_bytes,
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
|
|
446
600
|
def _safe_filename(name: str, suffix: str) -> str:
|
|
447
601
|
base = Path(name or f"artifact{suffix}").name
|
|
448
602
|
if not base.lower().endswith(suffix):
|
|
@@ -1075,6 +1229,8 @@ def execute_tool(action: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1075
1229
|
return create_pptx(args.get("title", ""), slides, args.get("filename", "presentation.pptx"))
|
|
1076
1230
|
if action == "create_pdf":
|
|
1077
1231
|
return create_pdf(args.get("title", ""), args.get("body", ""), args.get("filename", "document.pdf"))
|
|
1232
|
+
if action == "create_web_project":
|
|
1233
|
+
return create_web_project(args.get("path", ""), args.get("framework", "react"), args.get("template", "vite"))
|
|
1078
1234
|
if action == "local_list":
|
|
1079
1235
|
return local_list(args["path"])
|
|
1080
1236
|
if action == "local_read":
|