@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.
@@ -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.271",
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",