@tiens.nguyen/gonext-local-worker 1.0.188 → 1.0.189

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.
@@ -1815,6 +1815,7 @@ async function runAgentChatJob(job) {
1815
1815
 
1816
1816
  let inThink = false;
1817
1817
  let finalText = "";
1818
+ let answerStreamed = false; // an answer_stream delta already delivered the visible text
1818
1819
  // Raw model-stream tokens (event.type === "stream") are Python code + `Thought:`
1819
1820
  // prose + <code>/<|"|> fragments. The web Thought panel renders reasoning as
1820
1821
  // markdown, which turns `#`-prefixed code comments into <h1> headings and
@@ -1866,6 +1867,20 @@ async function runAgentChatJob(job) {
1866
1867
  enqueueText("<think>");
1867
1868
  }
1868
1869
  enqueueText(event.text + "\n\n");
1870
+ } else if (event.type === "answer_stream" && typeof event.text === "string") {
1871
+ // Live delta of the FINAL user-facing answer (the plain-chat fast path streams
1872
+ // its reply as it generates, instead of blocking for the whole thing) — this is
1873
+ // the actual answer, NOT reasoning, so it must stay OUTSIDE <think> (wrapping it
1874
+ // would hide it in the web's collapsed Thinking panel instead of the live main
1875
+ // answer bubble). Close any open think/stream fence first in case a prior step
1876
+ // left one dangling.
1877
+ closeStreamFence();
1878
+ if (inThink) {
1879
+ inThink = false;
1880
+ enqueueText("</think>");
1881
+ }
1882
+ enqueueText(event.text);
1883
+ answerStreamed = true;
1869
1884
  } else if (event.type === "final" && typeof event.text === "string") {
1870
1885
  closeStreamFence();
1871
1886
  if (inThink) {
@@ -1873,7 +1888,12 @@ async function runAgentChatJob(job) {
1873
1888
  enqueueText("</think>");
1874
1889
  }
1875
1890
  finalText = event.text;
1876
- enqueueText(event.text);
1891
+ // If the answer was already delivered live via answer_stream deltas, its text is
1892
+ // ALREADY in resultText — appending it again here would duplicate the whole reply
1893
+ // (streamed once, then dumped again in full). Only append when nothing streamed it.
1894
+ if (!answerStreamed) {
1895
+ enqueueText(event.text);
1896
+ }
1877
1897
  }
1878
1898
  }, pdfEnv);
1879
1899
 
package/gonext-repl.mjs CHANGED
@@ -54,10 +54,15 @@ const isCodeCloseLine = (line) => line.trim() === "</code>";
54
54
  // gonext-local-worker daemon's own terminal — swallow it here to keep the REPL terse.
55
55
  const isToolStepSummary = (line) => / \| → /.test(line);
56
56
  // The router's internal play-by-play ("Routing your request…", "→ Agent mode (needs
57
- // tools)", "→ Chat reply", "Choosing a tool…") is implementation detail, not something a
58
- // user needs — also visible in the daemon's own terminal. Swallowed; see routingAnnounced.
57
+ // tools)", "→ Chat reply", "Choosing a tool…", "Composing a reply…") is implementation
58
+ // detail, not something a user needs — also visible in the daemon's own terminal.
59
+ // Swallowed; see routingAnnounced. "Composing a reply…" specifically must be included so
60
+ // it doesn't print (undimmed, since plainReplyFlow is already true by then) right before
61
+ // the real streamed answer, where it would visually blend into the answer text.
59
62
  const isRoutingLine = (line) =>
60
- /^(Routing your request…|Choosing a tool…|→ Agent mode.*|→ Chat reply)$/.test(line.trim());
63
+ /^(Routing your request…|Choosing a tool…|Composing a reply…|→ Agent mode.*|→ Chat reply)$/.test(
64
+ line.trim()
65
+ );
61
66
 
62
67
  // Playful words for the local "thinking…" ticker, reused verbatim from the worker's
63
68
  // thinking_words.txt sibling (falls back to a tiny built-in list if it's missing).
@@ -271,7 +276,15 @@ async function runAgentTurn(history) {
271
276
  let statusShown = false; // a transient status line is currently on screen
272
277
  let warnedPending = false;
273
278
  let jobRunning = false; // only tick "thinking…" once the worker has claimed the job
279
+ let answerShownLive = false; // plain-reply content already printed — don't re-print it
274
280
  let routingAnnounced = false; // one friendly line replaces the whole routing play-by-play
281
+ // True once we've SPECIFICALLY seen "→ Chat reply" (the plain-reply/trivial-chat
282
+ // branch), as opposed to "→ Agent mode…" (the tool-use branch). Only the plain-reply
283
+ // path streams its answer via answer_stream with NO heartbeat/tool-summary/code lines
284
+ // ever interleaved — so only THERE is it safe to partial-flush trailing content live
285
+ // (see carryFlushedLen below); the agent path's code/tool-call lines must stay buffered
286
+ // until a full line is known, or we'd risk showing part of something we meant to hide.
287
+ let plainReplyFlow = false;
275
288
 
276
289
  // --- Live "thinking…" ticker ---------------------------------------------------
277
290
  // Between visible tokens (prompt-eval / awaiting the first token) we show ONE in-place
@@ -343,18 +356,25 @@ async function runAgentTurn(history) {
343
356
  // keep spacing; everything else commits dim to scrollback.
344
357
  let inCode = false;
345
358
  let lastWasBlank = true; // suppresses a leading blank line and collapses repeats
359
+ // Chars of the CURRENT trailing (newline-less) `carry` line already flushed live — lets
360
+ // a long plain-reply answer stream character-by-character instead of waiting for a "\n"
361
+ // that might not arrive until the very end (most short replies have none at all).
362
+ let carryFlushedLen = 0;
346
363
  const consume = (chunk) => {
347
364
  carry += stripThinkTags(chunk);
348
365
  let nl;
349
366
  while ((nl = carry.indexOf("\n")) >= 0) {
350
367
  const line = carry.slice(0, nl);
351
368
  carry = carry.slice(nl + 1);
369
+ const alreadyFlushed = carryFlushedLen;
370
+ carryFlushedLen = 0; // reset — `carry` now starts a fresh pending line
352
371
  if (isHeartbeatLine(line) || isFenceLine(line)) continue; // swallow, no trace
353
372
  if (isToolStepSummary(line)) { lastContentAt = Date.now(); continue; } // swallow
354
373
  if (isRoutingLine(line)) {
355
374
  // Replace the whole routing play-by-play with ONE friendly line, shown once
356
375
  // per turn — the raw labels are implementation detail (visible in the daemon's
357
376
  // own terminal if you need them).
377
+ if (line.trim() === "→ Chat reply") plainReplyFlow = true;
358
378
  if (!routingAnnounced) {
359
379
  routingAnnounced = true;
360
380
  onContent();
@@ -386,8 +406,24 @@ async function runAgentTurn(history) {
386
406
  }
387
407
  continue;
388
408
  }
389
- onContent();
390
- process.stdout.write(dim(line) + "\n");
409
+ if (alreadyFlushed === 0) onContent();
410
+ // Plain-reply content IS the answer (not background narration) print it in the
411
+ // terminal's default color, not dim, and remember it's already visible so the
412
+ // caller doesn't print the same text again once the turn completes.
413
+ const paint = plainReplyFlow ? (s) => s : dim;
414
+ process.stdout.write(paint(line.slice(alreadyFlushed)) + "\n");
415
+ if (plainReplyFlow) answerShownLive = true;
416
+ lastWasBlank = false;
417
+ }
418
+ // Plain-reply answers often have NO internal newline at all (a short one-sentence
419
+ // reply) and can take many seconds to generate — without this, nothing would print
420
+ // until the whole thing finally lands, right back to the "wait then dump" problem
421
+ // this was meant to fix. Only safe in the plain-reply flow (see plainReplyFlow above).
422
+ if (plainReplyFlow && !inCode && carry.length > carryFlushedLen) {
423
+ if (carryFlushedLen === 0) onContent();
424
+ process.stdout.write(carry.slice(carryFlushedLen)); // default color — this IS the answer
425
+ carryFlushedLen = carry.length;
426
+ answerShownLive = true;
391
427
  lastWasBlank = false;
392
428
  }
393
429
  // A non-empty remainder is a partial CONTENT line (heartbeats always arrive whole with
@@ -402,7 +438,7 @@ async function runAgentTurn(history) {
402
438
  process.stdout.write(
403
439
  dim("\n(stopped following — the job keeps running on the worker)\n")
404
440
  );
405
- return "";
441
+ return { text: "", shown: false };
406
442
  }
407
443
  const jr = await fetch(`${apiBase}/api/worker/jobs/${jobId}`, {
408
444
  headers: { "X-Worker-Key": workerKey },
@@ -412,10 +448,11 @@ async function runAgentTurn(history) {
412
448
  jobRunning = job.jobStatus === "running";
413
449
  const text = typeof job.resultText === "string" ? job.resultText : "";
414
450
  if (job.jobStatus === "completed") {
415
- // Drop the transient status line and any partial reasoning tail; the bright answer
416
- // prints right after via the caller.
451
+ // Drop the transient status line and any partial reasoning tail. If this was a
452
+ // plain reply, its text is ALREADY fully visible (streamed live above) — the
453
+ // caller must not print it again; `shown` tells it so.
417
454
  clearStatus();
418
- return answerFrom(text);
455
+ return { text: answerFrom(text), shown: answerShownLive };
419
456
  }
420
457
  if (text.length > shownChars) {
421
458
  consume(text.slice(shownChars));
@@ -512,10 +549,12 @@ async function main() {
512
549
  }
513
550
  history.push({ role: "user", content: line });
514
551
  try {
515
- const answer = await runAgentTurn(history);
552
+ const { text: answer, shown } = await runAgentTurn(history);
516
553
  if (answer) {
517
554
  history.push({ role: "assistant", content: answer });
518
- console.log(`\n\n${answer}\n`);
555
+ // A plain-reply answer already streamed live in the loop above — printing it
556
+ // again here would just duplicate it; a blank line is enough for spacing.
557
+ console.log(shown ? "" : `\n\n${answer}\n`);
519
558
  } else {
520
559
  history.pop(); // aborted/failed turn — don't poison the next request's history
521
560
  console.log(red("\n(no answer — run aborted or failed)\n"));
@@ -690,6 +690,43 @@ def _chat_create(client, **kwargs):
690
690
  raise
691
691
 
692
692
 
693
+ def _chat_create_stream(client, on_delta, **kwargs) -> str:
694
+ """Like _chat_create but STREAMS the completion: on_delta(text) is called for each
695
+ content piece AS IT ARRIVES (so the caller can emit it live), and the full assembled
696
+ text is returned once the stream ends. Applies the same mlx_lm model-not-found retry
697
+ as _chat_create — that failure always happens at/near the very first network
698
+ round-trip (before any token streams), so a clean retry from scratch never risks
699
+ re-emitting a partial answer twice."""
700
+ kwargs = dict(kwargs)
701
+ kwargs["stream"] = True
702
+
703
+ def _run(kw):
704
+ stream = client.chat.completions.create(**kw)
705
+ parts = []
706
+ for chunk in stream:
707
+ choices = getattr(chunk, "choices", None)
708
+ delta = getattr(choices[0], "delta", None) if choices else None
709
+ piece = getattr(delta, "content", None) if delta else None
710
+ if piece:
711
+ parts.append(piece)
712
+ on_delta(piece)
713
+ return "".join(parts)
714
+
715
+ try:
716
+ return _run(kwargs)
717
+ except Exception as e: # noqa: BLE001
718
+ if kwargs.get("model") != "default_model" and _is_model_not_found_err(e):
719
+ _log(f"model {kwargs.get('model')!r} not found on server; retrying as "
720
+ "'default_model' (mlx_lm preloaded model)")
721
+ try:
722
+ kwargs2 = dict(kwargs)
723
+ kwargs2["model"] = "default_model"
724
+ return _run(kwargs2)
725
+ except Exception: # noqa: BLE001
726
+ raise e # surface the clearer original error, not the retry's
727
+ raise
728
+
729
+
693
730
  def _route(task_text: str, base_url: str, api_key: str, model_id: str) -> bool:
694
731
  """Decide if the task needs the HTTP agent (True) or a plain chat reply (False).
695
732
 
@@ -787,9 +824,12 @@ def _summarize_result(task_text: str, agent_output: str,
787
824
  def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
788
825
  fallback_base_url: str = "", fallback_model_id: str = "") -> str:
789
826
  """Plain chat completion using the full conversation history — a SMALL prompt (no tool
790
- preamble), so it returns fast. If the primary model is unreachable (e.g. the local MLX
791
- server is down) and a distinct fallback model is given (the coding model), retry there
792
- so a greeting still gets answered instead of erroring."""
827
+ preamble), so it returns fast. STREAMS the answer live via {"type":"answer_stream"}
828
+ deltas as the model generates it (instead of blocking until the whole reply is ready
829
+ and delivering it in one burst) the worker renders these as the live-updating MAIN
830
+ answer, not the collapsible Thinking panel. If the primary model is unreachable (e.g.
831
+ the local MLX server is down) and a distinct fallback model is given (the coding
832
+ model), retry there so a greeting still gets answered instead of erroring."""
793
833
  _THINK_RE_LOCAL = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
794
834
  chat_messages = [{"role": "system", "content": "You are a helpful assistant."}]
795
835
  for m in messages:
@@ -812,20 +852,30 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
812
852
 
813
853
  last_err = None
814
854
  for i, (b, mid) in enumerate(targets):
855
+ emitted_here = False
856
+
857
+ def _track(piece):
858
+ nonlocal emitted_here
859
+ emitted_here = True
860
+ _emit({"type": "answer_stream", "text": piece})
861
+
815
862
  try:
816
863
  from openai import OpenAI
817
864
  client = OpenAI(base_url=b, api_key=api_key or "local",
818
865
  max_retries=0, timeout=60)
819
- resp = _chat_create(
820
- client,
866
+ return _chat_create_stream(
867
+ client, _track,
821
868
  model=mid,
822
869
  messages=chat_messages,
823
870
  temperature=0.7,
824
871
  max_tokens=512,
825
- )
826
- return (resp.choices[0].message.content or "").strip()
872
+ ).strip()
827
873
  except Exception as e: # noqa: BLE001
828
874
  last_err = e
875
+ if emitted_here:
876
+ # Part of THIS attempt's answer already streamed to the user — don't
877
+ # silently splice a DIFFERENT model's output after a partial one.
878
+ break
829
879
  if i + 1 < len(targets):
830
880
  nb, nmid = targets[i + 1] # the model we're about to try next
831
881
  _log(f"plain reply: {mid} @ {b} failed ({e}); "
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.188",
3
+ "version": "1.0.189",
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",