@tiens.nguyen/gonext-local-worker 1.0.271 → 1.0.272
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/gonext_agent_chat.py +169 -1
- package/package.json +1 -1
package/gonext_agent_chat.py
CHANGED
|
@@ -1487,6 +1487,74 @@ def _pdf_upload_via_api(api_base: str, worker_key: str, file_name: str,
|
|
|
1487
1487
|
return get_url
|
|
1488
1488
|
|
|
1489
1489
|
|
|
1490
|
+
# Generic download support (task #102) — deliver ANY workspace file/folder as a download.
|
|
1491
|
+
_DOWNLOAD_MAX_BYTES = 100 * 1024 * 1024 # 100 MB cap on what we'll upload for download
|
|
1492
|
+
_DOWNLOAD_CONTENT_TYPES = {
|
|
1493
|
+
"pdf": "application/pdf", "zip": "application/zip", "json": "application/json",
|
|
1494
|
+
"csv": "text/csv", "txt": "text/plain", "md": "text/markdown", "html": "text/html",
|
|
1495
|
+
"htm": "text/html", "xml": "application/xml", "yaml": "application/x-yaml",
|
|
1496
|
+
"yml": "application/x-yaml", "svg": "image/svg+xml", "png": "image/png",
|
|
1497
|
+
"jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif", "sql": "application/sql",
|
|
1498
|
+
"tar": "application/x-tar", "gz": "application/gzip", "tgz": "application/gzip",
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
def _content_type_for(name: str) -> str:
|
|
1503
|
+
"""Content-type by filename extension for create_download (matches the API's map)."""
|
|
1504
|
+
ext = ""
|
|
1505
|
+
if "." in (name or ""):
|
|
1506
|
+
ext = name.rsplit(".", 1)[-1].strip().lower()
|
|
1507
|
+
return _DOWNLOAD_CONTENT_TYPES.get(ext, "application/octet-stream")
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
def _artifact_upload_via_api(api_base: str, worker_key: str, file_name: str,
|
|
1511
|
+
data: bytes, content_type: str = "") -> str:
|
|
1512
|
+
"""Presign a GENERIC S3 upload (any file type), PUT the bytes, return the download URL.
|
|
1513
|
+
Like _pdf_upload_via_api but preserves the real extension + Content-Type. The PUT
|
|
1514
|
+
Content-Type MUST match what the presign signed, so we send content_type in the presign
|
|
1515
|
+
request and PUT with the value the API echoes back (task #102)."""
|
|
1516
|
+
import urllib.error as _ue
|
|
1517
|
+
import urllib.request as _u
|
|
1518
|
+
|
|
1519
|
+
ctx = _ssl_context()
|
|
1520
|
+
base = (api_base or "").rstrip("/")
|
|
1521
|
+
if not base or not worker_key:
|
|
1522
|
+
raise RuntimeError("Download is not available: worker API base/key missing.")
|
|
1523
|
+
req = _u.Request(
|
|
1524
|
+
f"{base}/api/worker/artifact-upload-url",
|
|
1525
|
+
data=json.dumps({"fileName": file_name, "contentType": content_type}).encode("utf-8"),
|
|
1526
|
+
headers={"Content-Type": "application/json", "X-Worker-Key": worker_key},
|
|
1527
|
+
method="POST",
|
|
1528
|
+
)
|
|
1529
|
+
try:
|
|
1530
|
+
with _u.urlopen(req, timeout=30, context=ctx) as resp:
|
|
1531
|
+
ref = json.loads(resp.read().decode("utf-8"))
|
|
1532
|
+
except _ue.HTTPError as e: # noqa: PERF203
|
|
1533
|
+
detail = e.read().decode("utf-8", "replace")[:300]
|
|
1534
|
+
raise RuntimeError(f"Could not get an upload URL (HTTP {e.code}): {detail}")
|
|
1535
|
+
except _ue.URLError as e:
|
|
1536
|
+
raise RuntimeError(
|
|
1537
|
+
f"Could not reach the API at {base} to presign the upload "
|
|
1538
|
+
f"({getattr(e, 'reason', e)}). Is the worker API deployed/online?"
|
|
1539
|
+
)
|
|
1540
|
+
put_url = ref.get("putUrl")
|
|
1541
|
+
get_url = ref.get("getUrl")
|
|
1542
|
+
if not put_url or not get_url:
|
|
1543
|
+
raise RuntimeError("Upload URL response was incomplete.")
|
|
1544
|
+
put_ct = ref.get("contentType") or content_type or "application/octet-stream"
|
|
1545
|
+
put_req = _u.Request(put_url, data=data, headers={"Content-Type": put_ct}, method="PUT")
|
|
1546
|
+
try:
|
|
1547
|
+
with _u.urlopen(put_req, timeout=120, context=ctx) as up:
|
|
1548
|
+
if up.status not in (200, 201, 204):
|
|
1549
|
+
raise RuntimeError(f"S3 upload failed (HTTP {up.status}).")
|
|
1550
|
+
except _ue.HTTPError as e: # noqa: PERF203
|
|
1551
|
+
detail = e.read().decode("utf-8", "replace")[:200]
|
|
1552
|
+
raise RuntimeError(f"S3 upload failed (HTTP {e.code}): {detail}")
|
|
1553
|
+
except _ue.URLError as e:
|
|
1554
|
+
raise RuntimeError(f"S3 upload could not connect ({getattr(e, 'reason', e)}).")
|
|
1555
|
+
return get_url
|
|
1556
|
+
|
|
1557
|
+
|
|
1490
1558
|
def _format_text_for_pdf(text: str, title: str, base_url: str, api_key: str,
|
|
1491
1559
|
model_id: str, instruction: str = "") -> str:
|
|
1492
1560
|
"""Use the model to clean/structure raw text into well-formed Markdown for the PDF.
|
|
@@ -4094,6 +4162,106 @@ def run_agent_chat(cfg):
|
|
|
4094
4162
|
_last_obs["text"] = out
|
|
4095
4163
|
return out
|
|
4096
4164
|
|
|
4165
|
+
@tool
|
|
4166
|
+
def create_download(path: str, name: str = "") -> str:
|
|
4167
|
+
"""Package a workspace FILE or FOLDER into a downloadable file and return a download link. A FOLDER is zipped (skipping node_modules/.git/build/dist); a single FILE is offered as-is. Use this to hand the user a ZIP of a project you scaffolded, or to let them download any file you made with create_file (csv, json, a report, …).
|
|
4168
|
+
|
|
4169
|
+
Args:
|
|
4170
|
+
path: the file or folder to package (a workspace path).
|
|
4171
|
+
name: optional download name (e.g. "my-app.zip"); defaults to the file/folder name.
|
|
4172
|
+
"""
|
|
4173
|
+
import os
|
|
4174
|
+
if not pdf_api_base or not pdf_worker_key:
|
|
4175
|
+
msg = "Error: download isn't available (the worker API base/key isn't configured)."
|
|
4176
|
+
_last_obs["text"] = msg
|
|
4177
|
+
return msg
|
|
4178
|
+
try:
|
|
4179
|
+
src = _rag_read_allowed((path or "").strip())
|
|
4180
|
+
except Exception as e: # noqa: BLE001
|
|
4181
|
+
msg = f"Error: can't access that path ({e})."
|
|
4182
|
+
_last_obs["text"] = msg
|
|
4183
|
+
return msg
|
|
4184
|
+
if not os.path.exists(src):
|
|
4185
|
+
msg = f"Error: nothing exists at {path}."
|
|
4186
|
+
_last_obs["text"] = msg
|
|
4187
|
+
return msg
|
|
4188
|
+
try:
|
|
4189
|
+
import tempfile
|
|
4190
|
+
import zipfile
|
|
4191
|
+
if os.path.isdir(src):
|
|
4192
|
+
base = os.path.basename(src.rstrip("/\\")) or "archive"
|
|
4193
|
+
file_name = (name or f"{base}.zip").strip()
|
|
4194
|
+
if not file_name.lower().endswith(".zip"):
|
|
4195
|
+
file_name += ".zip"
|
|
4196
|
+
_emit({"type": "step", "text": f"Zipping {base}…"})
|
|
4197
|
+
tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
|
|
4198
|
+
tmp.close()
|
|
4199
|
+
total = 0
|
|
4200
|
+
files = 0
|
|
4201
|
+
try:
|
|
4202
|
+
with zipfile.ZipFile(tmp.name, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
4203
|
+
for root, dirs, filenames in os.walk(src):
|
|
4204
|
+
dirs[:] = [
|
|
4205
|
+
d for d in dirs
|
|
4206
|
+
if d not in _RAG_SKIP_DIRS and not d.startswith(".")
|
|
4207
|
+
]
|
|
4208
|
+
for fn in filenames:
|
|
4209
|
+
fp = os.path.join(root, fn)
|
|
4210
|
+
try:
|
|
4211
|
+
total += os.path.getsize(fp)
|
|
4212
|
+
except OSError:
|
|
4213
|
+
continue
|
|
4214
|
+
if total > _DOWNLOAD_MAX_BYTES:
|
|
4215
|
+
raise RuntimeError(
|
|
4216
|
+
f"folder is too large to zip "
|
|
4217
|
+
f"(> {_DOWNLOAD_MAX_BYTES // (1024 * 1024)} MB)"
|
|
4218
|
+
)
|
|
4219
|
+
zf.write(fp, os.path.join(base, os.path.relpath(fp, src)))
|
|
4220
|
+
files += 1
|
|
4221
|
+
if files == 0:
|
|
4222
|
+
msg = (f"Nothing to zip in {path} "
|
|
4223
|
+
"(no files after skipping node_modules/.git/build/dist).")
|
|
4224
|
+
_last_obs["text"] = msg
|
|
4225
|
+
return msg
|
|
4226
|
+
with open(tmp.name, "rb") as fh:
|
|
4227
|
+
data = fh.read()
|
|
4228
|
+
finally:
|
|
4229
|
+
try:
|
|
4230
|
+
os.unlink(tmp.name)
|
|
4231
|
+
except OSError:
|
|
4232
|
+
pass
|
|
4233
|
+
content_type = "application/zip"
|
|
4234
|
+
summary = f"{files} file(s)"
|
|
4235
|
+
else:
|
|
4236
|
+
size = os.path.getsize(src)
|
|
4237
|
+
if size > _DOWNLOAD_MAX_BYTES:
|
|
4238
|
+
msg = (f"Error: {os.path.basename(src)} is too large to share "
|
|
4239
|
+
f"(> {_DOWNLOAD_MAX_BYTES // (1024 * 1024)} MB).")
|
|
4240
|
+
_last_obs["text"] = msg
|
|
4241
|
+
return msg
|
|
4242
|
+
file_name = (name or os.path.basename(src)).strip() or "file"
|
|
4243
|
+
with open(src, "rb") as fh:
|
|
4244
|
+
data = fh.read()
|
|
4245
|
+
content_type = _content_type_for(file_name)
|
|
4246
|
+
summary = f"{size} bytes"
|
|
4247
|
+
_emit({"type": "step", "text": "Uploading…"})
|
|
4248
|
+
url = _artifact_upload_via_api(
|
|
4249
|
+
pdf_api_base, pdf_worker_key, file_name, data, content_type
|
|
4250
|
+
)
|
|
4251
|
+
out = (
|
|
4252
|
+
f"✅ \"{file_name}\" is ready ({summary}).\n"
|
|
4253
|
+
f"Download it here (link valid for a limited time):\n{url}"
|
|
4254
|
+
)
|
|
4255
|
+
_emit({"type": "step", "text": f"Download ready → {file_name}"})
|
|
4256
|
+
_log(f"create_download ok name={file_name!r} bytes={len(data)}")
|
|
4257
|
+
_last_obs["text"] = out
|
|
4258
|
+
return out
|
|
4259
|
+
except Exception as e: # noqa: BLE001
|
|
4260
|
+
_log(f"create_download error: {type(e).__name__}: {e!r}")
|
|
4261
|
+
msg = f"Error: couldn't create the download ({str(e)[:200]})."
|
|
4262
|
+
_last_obs["text"] = msg
|
|
4263
|
+
return msg
|
|
4264
|
+
|
|
4097
4265
|
# Growing record of this turn's steps, persisted to disk after each one via
|
|
4098
4266
|
# _write_turn_checkpoint (inside step_callback below) — see _load_and_clear_turn_
|
|
4099
4267
|
# checkpoint above for how a crashed/interrupted turn gets recovered on the next run.
|
|
@@ -5845,7 +6013,7 @@ def run_agent_chat(cfg):
|
|
|
5845
6013
|
return msg
|
|
5846
6014
|
|
|
5847
6015
|
agent_tools = [http_request, web_search, fetch_url, calculate,
|
|
5848
|
-
get_current_datetime, create_pdf]
|
|
6016
|
+
get_current_datetime, create_pdf, create_download]
|
|
5849
6017
|
# Only register the PDF reader / email / RAG tools when available — the model
|
|
5850
6018
|
# never sees a tool it can't run, since the task-hint's tool list (filled in just
|
|
5851
6019
|
# below) is generated FROM this exact list, so it's always in sync by construction.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gonext-local-worker",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.272",
|
|
4
4
|
"description": "Polls GoNext cloud API for async local LLM jobs and runs them against Ollama/OpenAI-compatible servers on this Mac",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|