lightnode-sdk 0.10.4 → 0.10.5

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.
Files changed (2) hide show
  1. package/dist/add.js +115 -79
  2. package/package.json +1 -1
package/dist/add.js CHANGED
@@ -739,126 +739,159 @@ process.exit(0);
739
739
  // formats every prior turn into the next prompt so the model has context.
740
740
  // ---------------------------------------------------------------------------
741
741
  const NEXTJS_CHAT_PAGE = `// app/chat/page.tsx
742
- // Generated by 'lightnode add chat'.
742
+ // Generated by 'lightnode add chat'. Server-paid: your funded PRIVATE_KEY (in
743
+ // .env) pays each turn; the streaming /api/inference route runs the inference.
743
744
  "use client";
744
- import { useState } from "react";
745
+ import { useEffect, useRef, useState } from "react";
746
+ import { Streamdown } from "streamdown";
747
+ import { LcaiMark } from "@/components/lcai-mark";
745
748
 
746
- type Turn = { role: "user" | "assistant"; text: string; txs?: Record<string, string> };
749
+ type Turn = { role: "user" | "assistant"; text: string; streaming?: boolean; jobCompleted?: string };
747
750
 
748
751
  export default function Chat() {
749
752
  const [turns, setTurns] = useState<Turn[]>([]);
750
753
  const [draft, setDraft] = useState("");
751
754
  const [busy, setBusy] = useState(false);
755
+ const [err, setErr] = useState<string | null>(null);
756
+ const endRef = useRef<HTMLDivElement>(null);
757
+
758
+ useEffect(() => {
759
+ endRef.current?.scrollIntoView({ behavior: busy ? "auto" : "smooth", block: "nearest" });
760
+ }, [turns, busy]);
752
761
 
753
762
  async function send() {
754
- if (!draft.trim()) return;
755
- const next: Turn[] = [...turns, { role: "user", text: draft.trim() }];
756
- setTurns(next);
757
- setDraft("");
763
+ const next = draft.trim();
764
+ if (!next || busy) return;
758
765
  setBusy(true);
759
- // Reserve the assistant bubble immediately so tokens can stream into it.
760
- setTurns([...next, { role: "assistant", text: "" }]);
766
+ setErr(null);
767
+ const history: Turn[] = [...turns, { role: "user", text: next }];
768
+ setTurns([...history, { role: "assistant", text: "", streaming: true }]);
769
+ setDraft("");
770
+ const patch = (p: Partial<Turn>) =>
771
+ setTurns((prev) => prev.map((t, i) => (i === prev.length - 1 ? { ...t, ...p } : t)));
761
772
  try {
762
- const prompt =
763
- next.map((t) => (t.role === "user" ? \`User: \${t.text}\` : \`Assistant: \${t.text}\`)).join("\\n") +
764
- "\\nAssistant:";
773
+ const prompt = history.map((t) => (t.role === "user" ? "User: " : "Assistant: ") + t.text).join("\\n") + "\\nAssistant:";
765
774
  const r = await fetch("/api/inference", {
766
775
  method: "POST",
767
776
  headers: { "Content-Type": "application/json" },
768
777
  body: JSON.stringify({ prompt, stream: true }),
769
778
  });
770
779
  if (!r.ok || !r.body) {
771
- const err = await r.text().catch(() => "");
772
- throw new Error(\`route returned \${r.status}: \${err.slice(0, 200)}\`);
780
+ const raw = await r.text().catch(() => "");
781
+ if (/PRIVATE_KEY/i.test(raw)) throw new Error("No PRIVATE_KEY yet. Put a funded mainnet key in .env, then restart 'npm run dev'.");
782
+ throw new Error("route returned " + r.status + (raw ? ": " + raw.slice(0, 160) : ""));
773
783
  }
774
784
  const reader = r.body.getReader();
775
785
  const decoder = new TextDecoder();
776
786
  let assembled = "";
777
- let txs: Record<string, string> | undefined;
778
- while (true) {
787
+ let jobCompleted: string | undefined;
788
+ for (;;) {
779
789
  const { done, value } = await reader.read();
780
790
  if (done) break;
781
791
  const text = decoder.decode(value, { stream: true });
782
- // The route ends the stream with one line of JSON on a new \\u0000
783
- // delimiter (a NUL byte we put between the streamed prose and the
784
- // metadata). Anything before is body; anything after is metadata.
792
+ // The route appends one JSON line of metadata after a NUL delimiter.
785
793
  const nulIdx = text.indexOf("\\u0000");
786
794
  if (nulIdx === -1) {
787
795
  assembled += text;
788
- setTurns((prev) => prev.map((t, i) => i === prev.length - 1 ? { ...t, text: assembled } : t));
796
+ patch({ text: assembled });
789
797
  } else {
790
798
  assembled += text.slice(0, nulIdx);
791
- try { txs = JSON.parse(text.slice(nulIdx + 1)) as Record<string, string>; } catch { /* ignore */ }
792
- setTurns((prev) => prev.map((t, i) => i === prev.length - 1 ? { ...t, text: assembled, txs } : t));
799
+ try { jobCompleted = (JSON.parse(text.slice(nulIdx + 1)) as { jobCompleted?: string }).jobCompleted; } catch { /* ignore */ }
800
+ patch({ text: assembled, jobCompleted });
793
801
  }
794
802
  }
803
+ patch({ streaming: false, jobCompleted });
795
804
  } catch (e) {
796
- // Replace the empty assistant bubble with the error.
797
- setTurns((prev) => prev.map((t, i) =>
798
- i === prev.length - 1 ? { ...t, text: \`(error: \${(e as Error).message})\` } : t
799
- ));
805
+ setTurns(history); // drop the empty assistant bubble; show the error below
806
+ setDraft(next);
807
+ setErr((e as Error).message);
800
808
  } finally {
801
809
  setBusy(false);
802
810
  }
803
811
  }
804
812
 
805
813
  return (
806
- <main style={{ maxWidth: 760, margin: "30px auto", padding: 20, fontFamily: "system-ui, sans-serif" }}>
807
- <h1>LightChain AI - chat</h1>
808
- <p style={{ color: "#666", fontSize: 13 }}>
809
- Each turn pays ~0.02 LCAI on mainnet (free on testnet). Conversation history is sent with each turn so the
810
- model has context.
811
- </p>
812
- <div style={{ marginTop: 20, display: "flex", flexDirection: "column", gap: 10 }}>
813
- {turns.map((t, i) => (
814
- <div
815
- key={i}
816
- style={{
817
- padding: 14,
818
- borderRadius: 12,
819
- background: t.role === "user" ? "#e8f0ff" : "#f4f4f4",
820
- alignSelf: t.role === "user" ? "flex-end" : "flex-start",
821
- maxWidth: "85%",
822
- whiteSpace: "pre-wrap",
823
- lineHeight: 1.5,
824
- }}
825
- >
826
- <div style={{ fontSize: 11, color: "#888", marginBottom: 4, textTransform: "uppercase", letterSpacing: 0.5 }}>
827
- {t.role}
828
- </div>
829
- {t.text}
830
- {t.txs && (
831
- <div style={{ marginTop: 8, fontSize: 11, color: "#888", fontFamily: "monospace" }}>
832
- jobCompleted: {t.txs.jobCompleted?.slice(0, 18)}…
833
- </div>
834
- )}
835
- </div>
836
- ))}
837
- {busy && (
838
- <div style={{ padding: 14, color: "#888", fontStyle: "italic" }}>
839
- running encrypted inference…
814
+ <main className="mx-auto flex min-h-screen w-full max-w-2xl flex-col px-4 py-6">
815
+ <header className="border-b border-border pb-4">
816
+ <h1 className="font-semibold text-foreground">Chat</h1>
817
+ <p className="text-xs text-muted-foreground">Server-paid - your funded wallet covers each turn (~0.02 LCAI on mainnet, free on testnet).</p>
818
+ </header>
819
+
820
+ <div className="flex flex-1 flex-col gap-6 overflow-y-auto py-6">
821
+ {turns.length === 0 ? (
822
+ <div className="flex flex-1 flex-col items-center justify-center gap-4 text-center">
823
+ <LcaiMark className="size-12" />
824
+ <h2 className="text-3xl font-medium tracking-tight text-foreground">
825
+ Start talking with <span className="text-gradient">AI Chat</span>
826
+ </h2>
827
+ <p className="max-w-sm text-sm text-muted-foreground">
828
+ Your server signs each turn with the PRIVATE_KEY in .env, so visitors never need a wallet.
829
+ </p>
840
830
  </div>
831
+ ) : (
832
+ turns.map((t, i) =>
833
+ t.role === "user" ? (
834
+ <div key={i} className="flex justify-end">
835
+ <div className="w-fit max-w-[85%] whitespace-pre-wrap break-words rounded-2xl bg-surface-base-faint px-4 py-2.5 text-sm text-foreground">
836
+ {t.text}
837
+ </div>
838
+ </div>
839
+ ) : (
840
+ <div key={i} className="flex gap-3">
841
+ <LcaiMark className="mt-0.5 size-7 shrink-0" />
842
+ <div className="flex min-w-0 flex-1 flex-col gap-2">
843
+ {t.text ? (
844
+ t.streaming ? (
845
+ <div className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground">{t.text}</div>
846
+ ) : (
847
+ <div className="max-w-none text-sm leading-relaxed text-foreground [&_*:first-child]:mt-0 [&_*:last-child]:mb-0">
848
+ <Streamdown>{t.text}</Streamdown>
849
+ </div>
850
+ )
851
+ ) : (
852
+ <div className="animate-pulse-dot pt-1 text-sm text-muted-foreground">Thinking...</div>
853
+ )}
854
+ {t.jobCompleted ? (
855
+ <div className="text-[11px] text-muted-foreground">committed on-chain · <code className="font-mono">{t.jobCompleted.slice(0, 12)}…</code></div>
856
+ ) : null}
857
+ </div>
858
+ </div>
859
+ )
860
+ )
841
861
  )}
862
+ <div ref={endRef} />
842
863
  </div>
843
- <div style={{ marginTop: 20, display: "flex", gap: 8 }}>
844
- <input
845
- value={draft}
846
- onChange={(e) => setDraft(e.target.value)}
847
- onKeyDown={(e) => e.key === "Enter" && !busy && send()}
848
- placeholder="Type a message…"
849
- style={{ flex: 1, padding: 12, fontSize: 14, borderRadius: 8, border: "1px solid #ccc" }}
850
- />
851
- <button
852
- onClick={send}
853
- disabled={busy || !draft.trim()}
854
- style={{ padding: "10px 20px", fontSize: 14, borderRadius: 8 }}
855
- >
856
- Send
857
- </button>
864
+
865
+ {err ? (
866
+ <p className="mb-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{err}</p>
867
+ ) : null}
868
+
869
+ <div className="rounded-2xl border border-border bg-card p-3">
870
+ <div className="flex items-start gap-2">
871
+ <LcaiMark className="mt-2 size-5 shrink-0" />
872
+ <textarea
873
+ value={draft}
874
+ onChange={(e) => setDraft(e.target.value)}
875
+ onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }}
876
+ rows={1}
877
+ placeholder="Send a message..."
878
+ className="max-h-40 min-h-[44px] w-full resize-none bg-transparent px-2 py-2 text-sm text-foreground outline-none placeholder:text-muted-foreground"
879
+ />
880
+ </div>
881
+ <div className="mt-1 flex justify-end">
882
+ <button
883
+ type="button"
884
+ onClick={() => send()}
885
+ disabled={busy || !draft.trim()}
886
+ className="flex size-9 items-center justify-center rounded-full bg-gradient-primary text-white transition hover:brightness-110 disabled:cursor-not-allowed disabled:bg-none disabled:bg-muted disabled:text-muted-foreground"
887
+ aria-label="Send"
888
+ >
889
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 19V5M5 12l7-7 7 7" /></svg>
890
+ </button>
891
+ </div>
858
892
  </div>
859
- <p style={{ marginTop: 16, fontSize: 12, color: "#888" }}>
860
- Your server signs every call with the PRIVATE_KEY in <code>.env</code>. Cost per turn is paid from that wallet.
861
- See <code>LIGHTNODE-HOSTING.md</code> for picking a host that handles 60-90s function calls.
893
+ <p className="mt-3 text-[11px] text-muted-foreground">
894
+ Each call is signed server-side with the PRIVATE_KEY in <code className="font-mono">.env</code>. See <code className="font-mono">LIGHTNODE-HOSTING.md</code> for a host that handles 60-90s calls.
862
895
  </p>
863
896
  </main>
864
897
  );
@@ -2000,6 +2033,7 @@ export function addChat(opts = {}) {
2000
2033
  // can run the whole stack locally (or anywhere Docker runs) with one
2001
2034
  // command. No external host signup, no function-timeout fights.
2002
2035
  written.push(writeFile(path.join(cwd, "app/chat/page.tsx"), NEXTJS_CHAT_PAGE, force));
2036
+ written.push(writeFile(path.join(cwd, "components/lcai-mark.tsx"), LCAI_MARK_FILE, force));
2003
2037
  written.push(writeFile(path.join(cwd, "app/api/inference/route.ts"), NEXTJS_INFERENCE_STREAM_ROUTE, force));
2004
2038
  written.push(writeFile(path.join(cwd, "LIGHTNODE-HOSTING.md"), HOSTING_GUIDE, force));
2005
2039
  written.push(writeFile(path.join(cwd, "Dockerfile"), NEXTJS_DOCKERFILE, force));
@@ -2010,7 +2044,9 @@ export function addChat(opts = {}) {
2010
2044
  written.push(writeFile(path.join(cwd, "chat-repl.ts"), NODE_CHAT_REPL, force));
2011
2045
  }
2012
2046
  written.push(writeFile(path.join(cwd, ".env.example"), ENV_EXAMPLE(network), force));
2013
- return { written, install: installLine(template), template, network };
2047
+ // The nextjs chat page renders markdown with streamdown; the node REPL doesn't.
2048
+ const install = template === "nextjs-api" ? installLine(template) + " streamdown" : installLine(template);
2049
+ return { written, install, template, network };
2014
2050
  }
2015
2051
  /**
2016
2052
  * `lightnode add chat-web3` - the user-pays counterpart to addChat.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lightnode-sdk",
3
- "version": "0.10.4",
3
+ "version": "0.10.5",
4
4
  "description": "Read-only TypeScript client for LightChain AI: workers, jobs, models, on-chain registration, and per-model network analytics. Independent, community-built (not an official LightChain package).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",