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