@tiens.nguyen/gonext-local-worker 1.0.92 → 1.0.94
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-local-worker.mjs +4 -0
- package/gonext_agent_chat.py +219 -2
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -1380,6 +1380,10 @@ async function runAgentChatJob(job) {
|
|
|
1380
1380
|
codingModelId: payload?.codingModelId ?? "",
|
|
1381
1381
|
tools: payload?.tools ?? ["http_request"],
|
|
1382
1382
|
maxSteps: payload?.maxSteps ?? 5,
|
|
1383
|
+
// For the create_pdf tool: the worker key lets the Python tool ask the API
|
|
1384
|
+
// to presign an S3 upload, so no AWS creds ever live on the worker machine.
|
|
1385
|
+
apiBaseURL: apiBase,
|
|
1386
|
+
workerKey,
|
|
1383
1387
|
});
|
|
1384
1388
|
const timeoutMs = 300_000; // 5 min max for an agent run
|
|
1385
1389
|
|
package/gonext_agent_chat.py
CHANGED
|
@@ -23,6 +23,7 @@ import contextlib
|
|
|
23
23
|
import json
|
|
24
24
|
import re
|
|
25
25
|
import sys
|
|
26
|
+
import traceback
|
|
26
27
|
import urllib.request
|
|
27
28
|
import urllib.error
|
|
28
29
|
|
|
@@ -230,6 +231,7 @@ _AGENT_KEYWORDS = re.compile(
|
|
|
230
231
|
r"|download|scrape|crawl"
|
|
231
232
|
r"|search|find|look\s*up|lookup|weather|news|latest|current|today|tonight"
|
|
232
233
|
r"|date|time|what\s+day|what\s+time"
|
|
234
|
+
r"|pdf|\.pdf|create\s+a\s+pdf|generate\s+a\s+pdf|make\s+a\s+pdf|export.*pdf|make\s+a\s+document"
|
|
233
235
|
r")\b",
|
|
234
236
|
re.IGNORECASE,
|
|
235
237
|
)
|
|
@@ -358,6 +360,152 @@ def _strip_tool_tags(text: str) -> str:
|
|
|
358
360
|
return "\n".join(out).strip()
|
|
359
361
|
|
|
360
362
|
|
|
363
|
+
_PDF_INSTALL_HINT = (
|
|
364
|
+
"PDF engine not installed on the worker. Install it with:\n"
|
|
365
|
+
" python3 -m pip install xhtml2pdf markdown\n"
|
|
366
|
+
"then restart the worker. (See the Instructions page in the web app.)"
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _render_pdf_bytes(markdown_text: str, title: str = "") -> bytes:
|
|
371
|
+
"""Render Markdown text to PDF bytes using pure-Python xhtml2pdf (no system deps).
|
|
372
|
+
|
|
373
|
+
Raises RuntimeError with an install hint if the engine isn't available, or on a
|
|
374
|
+
render failure. The model is expected to pass already-formatted Markdown.
|
|
375
|
+
"""
|
|
376
|
+
try:
|
|
377
|
+
import markdown as _md
|
|
378
|
+
from xhtml2pdf import pisa as _pisa
|
|
379
|
+
except Exception: # noqa: BLE001 — missing optional dependency
|
|
380
|
+
raise RuntimeError(_PDF_INSTALL_HINT)
|
|
381
|
+
|
|
382
|
+
body_html = _md.markdown(
|
|
383
|
+
markdown_text or "",
|
|
384
|
+
extensions=["extra", "sane_lists", "tables", "nl2br"],
|
|
385
|
+
)
|
|
386
|
+
safe_title = (title or "Document").strip()
|
|
387
|
+
html = (
|
|
388
|
+
"<html><head><meta charset='utf-8'><style>"
|
|
389
|
+
"@page { size: A4; margin: 2cm; }"
|
|
390
|
+
"body { font-family: Helvetica, Arial, sans-serif; font-size: 11pt; line-height: 1.5; color: #18181b; }"
|
|
391
|
+
"h1 { font-size: 20pt; margin: 0 0 12pt; }"
|
|
392
|
+
"h2 { font-size: 15pt; margin: 16pt 0 8pt; }"
|
|
393
|
+
"h3 { font-size: 12pt; margin: 12pt 0 6pt; }"
|
|
394
|
+
"code, pre { font-family: Courier, monospace; background: #f4f4f5; }"
|
|
395
|
+
"pre { padding: 8pt; }"
|
|
396
|
+
"table { border-collapse: collapse; width: 100%; }"
|
|
397
|
+
"th, td { border: 1px solid #d4d4d8; padding: 4pt 6pt; text-align: left; }"
|
|
398
|
+
"</style></head><body>"
|
|
399
|
+
f"{body_html}"
|
|
400
|
+
"</body></html>"
|
|
401
|
+
)
|
|
402
|
+
from io import BytesIO as _BytesIO
|
|
403
|
+
out = _BytesIO()
|
|
404
|
+
result = _pisa.CreatePDF(src=html, dest=out, encoding="utf-8")
|
|
405
|
+
if getattr(result, "err", 1):
|
|
406
|
+
raise RuntimeError("PDF rendering failed (xhtml2pdf reported an error).")
|
|
407
|
+
return out.getvalue()
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _pdf_upload_via_api(api_base: str, worker_key: str, file_name: str,
|
|
411
|
+
pdf_bytes: bytes) -> str:
|
|
412
|
+
"""Ask the API to presign an S3 upload, PUT the PDF bytes, return the download URL.
|
|
413
|
+
|
|
414
|
+
Keeps all AWS credentials on the API/Lambda — the worker only holds its worker key.
|
|
415
|
+
"""
|
|
416
|
+
import urllib.request as _u
|
|
417
|
+
import urllib.error as _ue
|
|
418
|
+
|
|
419
|
+
base = (api_base or "").rstrip("/")
|
|
420
|
+
if not base or not worker_key:
|
|
421
|
+
raise RuntimeError("PDF upload is not available: worker API base/key missing.")
|
|
422
|
+
|
|
423
|
+
# 1) Presign.
|
|
424
|
+
req = _u.Request(
|
|
425
|
+
f"{base}/api/worker/pdf-upload-url",
|
|
426
|
+
data=json.dumps({"fileName": file_name}).encode("utf-8"),
|
|
427
|
+
headers={
|
|
428
|
+
"Content-Type": "application/json",
|
|
429
|
+
"X-Worker-Key": worker_key,
|
|
430
|
+
},
|
|
431
|
+
method="POST",
|
|
432
|
+
)
|
|
433
|
+
try:
|
|
434
|
+
with _u.urlopen(req, timeout=30) as resp:
|
|
435
|
+
ref = json.loads(resp.read().decode("utf-8"))
|
|
436
|
+
except _ue.HTTPError as e: # noqa: PERF203
|
|
437
|
+
detail = e.read().decode("utf-8", "replace")[:300]
|
|
438
|
+
raise RuntimeError(f"Could not get a PDF upload URL (HTTP {e.code}): {detail}")
|
|
439
|
+
except _ue.URLError as e:
|
|
440
|
+
raise RuntimeError(
|
|
441
|
+
f"Could not reach the API at {base} to presign the upload "
|
|
442
|
+
f"({getattr(e, 'reason', e)}). Is the worker API deployed/online?"
|
|
443
|
+
)
|
|
444
|
+
put_url = ref.get("putUrl")
|
|
445
|
+
get_url = ref.get("getUrl")
|
|
446
|
+
if not put_url or not get_url:
|
|
447
|
+
raise RuntimeError("PDF upload URL response was incomplete.")
|
|
448
|
+
|
|
449
|
+
# 2) Upload the bytes to the presigned PUT URL.
|
|
450
|
+
put_req = _u.Request(
|
|
451
|
+
put_url, data=pdf_bytes,
|
|
452
|
+
headers={"Content-Type": "application/pdf"},
|
|
453
|
+
method="PUT",
|
|
454
|
+
)
|
|
455
|
+
try:
|
|
456
|
+
with _u.urlopen(put_req, timeout=60) as up:
|
|
457
|
+
if up.status not in (200, 201, 204):
|
|
458
|
+
raise RuntimeError(f"S3 upload failed (HTTP {up.status}).")
|
|
459
|
+
except _ue.HTTPError as e: # noqa: PERF203
|
|
460
|
+
detail = e.read().decode("utf-8", "replace")[:200]
|
|
461
|
+
raise RuntimeError(f"S3 upload failed (HTTP {e.code}): {detail}")
|
|
462
|
+
except _ue.URLError as e:
|
|
463
|
+
raise RuntimeError(f"S3 upload could not connect ({getattr(e, 'reason', e)}).")
|
|
464
|
+
return get_url
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _format_text_for_pdf(text: str, title: str, base_url: str, api_key: str,
|
|
468
|
+
model_id: str) -> str:
|
|
469
|
+
"""Use the model to clean/structure raw text into well-formed Markdown for the PDF.
|
|
470
|
+
|
|
471
|
+
Falls back to the original text (lightly wrapped) if the model call fails, so a
|
|
472
|
+
PDF is still produced.
|
|
473
|
+
"""
|
|
474
|
+
fallback = text or ""
|
|
475
|
+
if title and not fallback.lstrip().startswith("#"):
|
|
476
|
+
fallback = f"# {title}\n\n{fallback}"
|
|
477
|
+
try:
|
|
478
|
+
from openai import OpenAI
|
|
479
|
+
client = OpenAI(base_url=base_url, api_key=api_key or "local",
|
|
480
|
+
max_retries=0, timeout=60)
|
|
481
|
+
resp = client.chat.completions.create(
|
|
482
|
+
model=model_id,
|
|
483
|
+
messages=[
|
|
484
|
+
{"role": "system", "content": (
|
|
485
|
+
"You format raw text/data into clean Markdown for a PDF document. "
|
|
486
|
+
"Add a single top-level '# Title', sensible headings, bullet lists, "
|
|
487
|
+
"and tables where appropriate. Do NOT invent facts, do NOT add "
|
|
488
|
+
"commentary, and preserve all numbers and wording. Output ONLY the "
|
|
489
|
+
"Markdown — no code fences, no explanations."
|
|
490
|
+
)},
|
|
491
|
+
{"role": "user", "content": (
|
|
492
|
+
(f"Title: {title}\n\n" if title else "")
|
|
493
|
+
+ f"Content to format:\n{text}"
|
|
494
|
+
)},
|
|
495
|
+
],
|
|
496
|
+
max_tokens=1500,
|
|
497
|
+
temperature=0.2,
|
|
498
|
+
)
|
|
499
|
+
out = (resp.choices[0].message.content or "").strip()
|
|
500
|
+
# Strip accidental ```markdown fences.
|
|
501
|
+
if out.startswith("```"):
|
|
502
|
+
out = re.sub(r"^```[a-zA-Z]*\n?|\n?```$", "", out).strip()
|
|
503
|
+
return out or fallback
|
|
504
|
+
except Exception as e: # noqa: BLE001
|
|
505
|
+
_log(f"pdf format error: {e} — using raw text")
|
|
506
|
+
return fallback
|
|
507
|
+
|
|
508
|
+
|
|
361
509
|
def run_agent_chat(cfg):
|
|
362
510
|
try:
|
|
363
511
|
from smolagents import CodeAgent, OpenAIServerModel, tool
|
|
@@ -369,6 +517,9 @@ def run_agent_chat(cfg):
|
|
|
369
517
|
agent_base_url = cfg.get("agentBaseURL") or ""
|
|
370
518
|
agent_api_key = cfg.get("agentApiKey") or "local"
|
|
371
519
|
agent_model_id = cfg.get("agentModelId") or ""
|
|
520
|
+
# For the create_pdf tool: API base + worker key to request a presigned S3 upload.
|
|
521
|
+
pdf_api_base = (cfg.get("apiBaseURL") or "").strip()
|
|
522
|
+
pdf_worker_key = (cfg.get("workerKey") or "").strip()
|
|
372
523
|
# Optional dedicated coding/reasoning model for the CodeAgent's tool-use loop.
|
|
373
524
|
# Routing, plain replies and summarization stay on the chat model (better at
|
|
374
525
|
# natural language); the code model only drives http_request reasoning.
|
|
@@ -492,13 +643,15 @@ def run_agent_chat(cfg):
|
|
|
492
643
|
"YOU HAVE EXACTLY ONE TURN. Read the TASK above. In a single code block, call "
|
|
493
644
|
"the ONE tool that fits THAT task, then pass its result to final_answer(). "
|
|
494
645
|
"Do not plan multiple steps.\n\n"
|
|
495
|
-
"You have
|
|
646
|
+
"You have FOUR tools:\n"
|
|
496
647
|
" 1. http_request(method, url, headers='', body='', username='', password='') — "
|
|
497
648
|
"call a SPECIFIC known API/URL.\n"
|
|
498
649
|
" 2. web_search(query) — look up facts about a person, place, thing, or topic "
|
|
499
650
|
"when you do NOT already have a real URL. Returns a summary + source.\n"
|
|
500
651
|
f" 3. get_current_datetime(timezone='') — current date/time ONLY (now: {now_str}). "
|
|
501
652
|
"Use this ONLY when the task explicitly asks for the date or time.\n"
|
|
653
|
+
" 4. create_pdf(text, title='') — make a PDF document from text/data and return a "
|
|
654
|
+
"download link. Use ONLY when the task asks to create/make/generate/export a PDF.\n"
|
|
502
655
|
"\n"
|
|
503
656
|
"http_request RETURN FORMAT: 'HTTP 200\\n{body}' — first line is 'HTTP <code>', body follows.\n"
|
|
504
657
|
"\n"
|
|
@@ -516,6 +669,7 @@ def run_agent_chat(cfg):
|
|
|
516
669
|
"- ONLY a date/time question (e.g. 'what is the date today') -> get_current_datetime().\n"
|
|
517
670
|
"- 'who is' / 'what is' / 'tell me about' / a person / place / topic / general "
|
|
518
671
|
"knowledge -> web_search(query).\n"
|
|
672
|
+
"- 'create/make/generate/export a PDF' of some text/data -> create_pdf(text, title).\n"
|
|
519
673
|
"- A specific known API/URL was given -> http_request().\n"
|
|
520
674
|
"\n"
|
|
521
675
|
"RULES:\n"
|
|
@@ -642,6 +796,69 @@ def run_agent_chat(cfg):
|
|
|
642
796
|
_last_obs["text"] = result
|
|
643
797
|
return result
|
|
644
798
|
|
|
799
|
+
@tool
|
|
800
|
+
def create_pdf(text: str, title: str = "") -> str:
|
|
801
|
+
"""Create a PDF document from text/data and return a download link.
|
|
802
|
+
|
|
803
|
+
Use this ONLY when the user explicitly asks to make/create/generate/export a PDF.
|
|
804
|
+
The text is first cleaned into well-formed Markdown, then rendered to a PDF on the
|
|
805
|
+
worker and uploaded to cloud storage; the returned message contains the download URL.
|
|
806
|
+
|
|
807
|
+
Args:
|
|
808
|
+
text: The content to put in the PDF (raw text, notes, or data).
|
|
809
|
+
title: Optional document title shown at the top and used in the file name.
|
|
810
|
+
"""
|
|
811
|
+
doc_title = (title or "").strip() or "Document"
|
|
812
|
+
# 1) Format the raw text into clean Markdown (extra model call, intentional).
|
|
813
|
+
_emit({"type": "step", "text": "Formatting document…"})
|
|
814
|
+
markdown_text = _format_text_for_pdf(
|
|
815
|
+
text or "", doc_title, coding_base_url, agent_api_key, coding_model_id
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
# 2) Render the Markdown to PDF bytes locally (pure-Python xhtml2pdf).
|
|
819
|
+
# Catch EVERYTHING (xhtml2pdf can raise arbitrary errors on exotic glyphs /
|
|
820
|
+
# emoji), so the tool always returns a string and never breaks the single shot.
|
|
821
|
+
_emit({"type": "step", "text": "Rendering PDF…"})
|
|
822
|
+
try:
|
|
823
|
+
pdf_bytes = _render_pdf_bytes(markdown_text, doc_title)
|
|
824
|
+
except RuntimeError as e:
|
|
825
|
+
# Engine missing or a clean render failure — surface the message as-is.
|
|
826
|
+
msg = str(e)
|
|
827
|
+
_log(f"create_pdf render error: {msg[:200]}")
|
|
828
|
+
_last_obs["text"] = msg
|
|
829
|
+
return msg
|
|
830
|
+
except Exception as e: # noqa: BLE001 — unexpected render crash
|
|
831
|
+
_log(f"create_pdf render crash: {type(e).__name__}: {e!r}\n{traceback.format_exc()}")
|
|
832
|
+
msg = (
|
|
833
|
+
"Sorry — I couldn't render that text into a PDF "
|
|
834
|
+
f"({type(e).__name__}: {str(e)[:160]}). "
|
|
835
|
+
"Try simpler text without unusual symbols/emoji."
|
|
836
|
+
)
|
|
837
|
+
_last_obs["text"] = msg
|
|
838
|
+
return msg
|
|
839
|
+
|
|
840
|
+
# 3) Upload via the API-presigned PUT (no AWS creds on the worker).
|
|
841
|
+
_emit({"type": "step", "text": "Uploading PDF…"})
|
|
842
|
+
file_name = f"{doc_title}.pdf"
|
|
843
|
+
try:
|
|
844
|
+
download_url = _pdf_upload_via_api(
|
|
845
|
+
pdf_api_base, pdf_worker_key, file_name, pdf_bytes
|
|
846
|
+
)
|
|
847
|
+
except Exception as e: # noqa: BLE001 — network / API errors must not crash the tool
|
|
848
|
+
_log(f"create_pdf upload error: {type(e).__name__}: {e!r}\n{traceback.format_exc()}")
|
|
849
|
+
msg = f"PDF was created but could not be uploaded: {str(e)[:200]}"
|
|
850
|
+
_last_obs["text"] = msg
|
|
851
|
+
return msg
|
|
852
|
+
|
|
853
|
+
out = (
|
|
854
|
+
f"✅ Your PDF \"{doc_title}\" is ready.\n"
|
|
855
|
+
f"Download it here (link valid for a limited time):\n{download_url}"
|
|
856
|
+
)
|
|
857
|
+
_emit({"type": "step", "text": f"PDF ready → {doc_title}.pdf"})
|
|
858
|
+
_log(f"create_pdf ok title={doc_title!r} bytes={len(pdf_bytes)}")
|
|
859
|
+
_last_obs["text"] = out
|
|
860
|
+
return out
|
|
861
|
+
|
|
645
862
|
def step_callback(step_log):
|
|
646
863
|
step_num = getattr(step_log, "step_number", "?")
|
|
647
864
|
|
|
@@ -723,7 +940,7 @@ def run_agent_chat(cfg):
|
|
|
723
940
|
api_key=agent_api_key,
|
|
724
941
|
)
|
|
725
942
|
agent = _SingleShotAgent(
|
|
726
|
-
tools=[http_request, web_search, get_current_datetime],
|
|
943
|
+
tools=[http_request, web_search, get_current_datetime, create_pdf],
|
|
727
944
|
model=model,
|
|
728
945
|
max_steps=max_steps,
|
|
729
946
|
step_callbacks=[step_callback],
|
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.94",
|
|
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",
|