lightnode-sdk 0.10.4 → 0.10.6

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 +144 -84
  2. package/package.json +1 -1
package/dist/add.js CHANGED
@@ -738,127 +738,171 @@ process.exit(0);
738
738
  // level runInference() helper. Keeps conversation history client-side and
739
739
  // formats every prior turn into the next prompt so the model has context.
740
740
  // ---------------------------------------------------------------------------
741
+ // Default grounding sent ahead of every turn. The underlying models (llama3)
742
+ // have no training knowledge of this specific project, so without grounding a
743
+ // small model confidently invents details (e.g. "Lightchain is a Litecoin
744
+ // fork" or "an IoT blockchain"). This identity + anti-hallucination prompt
745
+ // stops the worst drift; edit it to add your real facts. The SDK does NOT have
746
+ // a separate `system` channel - this text is prepended to the prompt itself.
747
+ const CHAT_SYSTEM_PROMPT = `You are the assistant for Lightchain AI, a decentralized AI inference network: open models (such as llama3-8b and llama3-70b) run across independent worker nodes, and every request is paid for on-chain with the network's native token, LCAI.
748
+
749
+ Important: in this app "Lightchain", "LightChain", and "Lightchain AI" always refer to THIS project, the decentralized AI inference network. It is NOT Litecoin, NOT a Litecoin fork, NOT an IoT blockchain, and NOT any other similarly named project. Never describe it that way.
750
+
751
+ If you are not certain of a specific fact about Lightchain AI, say you are not sure instead of inventing details. Keep answers clear and concise.`;
741
752
  const NEXTJS_CHAT_PAGE = `// app/chat/page.tsx
742
- // Generated by 'lightnode add chat'.
753
+ // Generated by 'lightnode add chat'. Server-paid: your funded PRIVATE_KEY (in
754
+ // .env) pays each turn; the streaming /api/inference route runs the inference.
743
755
  "use client";
744
- import { useState } from "react";
756
+ import { useEffect, useRef, useState } from "react";
757
+ import { Streamdown } from "streamdown";
758
+ import { LcaiMark } from "@/components/lcai-mark";
745
759
 
746
- type Turn = { role: "user" | "assistant"; text: string; txs?: Record<string, string> };
760
+ type Turn = { role: "user" | "assistant"; text: string; streaming?: boolean; jobCompleted?: string };
747
761
 
748
762
  export default function Chat() {
749
763
  const [turns, setTurns] = useState<Turn[]>([]);
750
764
  const [draft, setDraft] = useState("");
751
765
  const [busy, setBusy] = useState(false);
766
+ const [err, setErr] = useState<string | null>(null);
767
+ const endRef = useRef<HTMLDivElement>(null);
768
+
769
+ useEffect(() => {
770
+ endRef.current?.scrollIntoView({ behavior: busy ? "auto" : "smooth", block: "nearest" });
771
+ }, [turns, busy]);
752
772
 
753
773
  async function send() {
754
- if (!draft.trim()) return;
755
- const next: Turn[] = [...turns, { role: "user", text: draft.trim() }];
756
- setTurns(next);
757
- setDraft("");
774
+ const next = draft.trim();
775
+ if (!next || busy) return;
758
776
  setBusy(true);
759
- // Reserve the assistant bubble immediately so tokens can stream into it.
760
- setTurns([...next, { role: "assistant", text: "" }]);
777
+ setErr(null);
778
+ const history: Turn[] = [...turns, { role: "user", text: next }];
779
+ setTurns([...history, { role: "assistant", text: "", streaming: true }]);
780
+ setDraft("");
781
+ const patch = (p: Partial<Turn>) =>
782
+ setTurns((prev) => prev.map((t, i) => (i === prev.length - 1 ? { ...t, ...p } : t)));
761
783
  try {
762
- const prompt =
763
- next.map((t) => (t.role === "user" ? \`User: \${t.text}\` : \`Assistant: \${t.text}\`)).join("\\n") +
764
- "\\nAssistant:";
784
+ const prompt = history.map((t) => (t.role === "user" ? "User: " : "Assistant: ") + t.text).join("\\n") + "\\nAssistant:";
765
785
  const r = await fetch("/api/inference", {
766
786
  method: "POST",
767
787
  headers: { "Content-Type": "application/json" },
768
788
  body: JSON.stringify({ prompt, stream: true }),
769
789
  });
770
790
  if (!r.ok || !r.body) {
771
- const err = await r.text().catch(() => "");
772
- throw new Error(\`route returned \${r.status}: \${err.slice(0, 200)}\`);
791
+ const raw = await r.text().catch(() => "");
792
+ 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'.");
793
+ throw new Error("route returned " + r.status + (raw ? ": " + raw.slice(0, 160) : ""));
773
794
  }
774
795
  const reader = r.body.getReader();
775
796
  const decoder = new TextDecoder();
776
797
  let assembled = "";
777
- let txs: Record<string, string> | undefined;
778
- while (true) {
798
+ let jobCompleted: string | undefined;
799
+ for (;;) {
779
800
  const { done, value } = await reader.read();
780
801
  if (done) break;
781
802
  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.
803
+ // The route appends one JSON line of metadata after a NUL delimiter.
785
804
  const nulIdx = text.indexOf("\\u0000");
786
805
  if (nulIdx === -1) {
787
806
  assembled += text;
788
- setTurns((prev) => prev.map((t, i) => i === prev.length - 1 ? { ...t, text: assembled } : t));
807
+ patch({ text: assembled });
789
808
  } else {
790
809
  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));
810
+ try { jobCompleted = (JSON.parse(text.slice(nulIdx + 1)) as { jobCompleted?: string }).jobCompleted; } catch { /* ignore */ }
811
+ patch({ text: assembled, jobCompleted });
793
812
  }
794
813
  }
814
+ patch({ streaming: false, jobCompleted });
795
815
  } 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
- ));
816
+ setTurns(history); // drop the empty assistant bubble; show the error below
817
+ setDraft(next);
818
+ setErr((e as Error).message);
800
819
  } finally {
801
820
  setBusy(false);
802
821
  }
803
822
  }
804
823
 
805
824
  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…
825
+ <main className="mx-auto flex min-h-screen w-full max-w-2xl flex-col px-4 py-6">
826
+ <header className="border-b border-border pb-4">
827
+ <h1 className="font-semibold text-foreground">Chat</h1>
828
+ <p className="text-xs text-muted-foreground">Server-paid - your funded wallet covers each turn (~0.02 LCAI on mainnet, free on testnet).</p>
829
+ </header>
830
+
831
+ <div className="flex flex-1 flex-col gap-6 overflow-y-auto py-6">
832
+ {turns.length === 0 ? (
833
+ <div className="flex flex-1 flex-col items-center justify-center gap-4 text-center">
834
+ <LcaiMark className="size-12" />
835
+ <h2 className="text-3xl font-medium tracking-tight text-foreground">
836
+ Start talking with <span className="text-gradient">AI Chat</span>
837
+ </h2>
838
+ <p className="max-w-sm text-sm text-muted-foreground">
839
+ Your server signs each turn with the PRIVATE_KEY in .env, so visitors never need a wallet.
840
+ </p>
840
841
  </div>
842
+ ) : (
843
+ turns.map((t, i) =>
844
+ t.role === "user" ? (
845
+ <div key={i} className="flex justify-end">
846
+ <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">
847
+ {t.text}
848
+ </div>
849
+ </div>
850
+ ) : (
851
+ <div key={i} className="flex gap-3">
852
+ <LcaiMark className="mt-0.5 size-7 shrink-0" />
853
+ <div className="flex min-w-0 flex-1 flex-col gap-2">
854
+ {t.text ? (
855
+ t.streaming ? (
856
+ <div className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground">{t.text}</div>
857
+ ) : (
858
+ <div className="max-w-none text-sm leading-relaxed text-foreground [&_*:first-child]:mt-0 [&_*:last-child]:mb-0">
859
+ <Streamdown>{t.text}</Streamdown>
860
+ </div>
861
+ )
862
+ ) : (
863
+ <div className="animate-pulse-dot pt-1 text-sm text-muted-foreground">Thinking...</div>
864
+ )}
865
+ {t.jobCompleted ? (
866
+ <div className="text-[11px] text-muted-foreground">committed on-chain · <code className="font-mono">{t.jobCompleted.slice(0, 12)}…</code></div>
867
+ ) : null}
868
+ </div>
869
+ </div>
870
+ )
871
+ )
841
872
  )}
873
+ <div ref={endRef} />
842
874
  </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>
875
+
876
+ {err ? (
877
+ <p className="mb-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{err}</p>
878
+ ) : null}
879
+
880
+ <div className="rounded-2xl border border-border bg-card p-3">
881
+ <div className="flex items-start gap-2">
882
+ <LcaiMark className="mt-2 size-5 shrink-0" />
883
+ <textarea
884
+ value={draft}
885
+ onChange={(e) => setDraft(e.target.value)}
886
+ onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }}
887
+ rows={1}
888
+ placeholder="Send a message..."
889
+ 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"
890
+ />
891
+ </div>
892
+ <div className="mt-1 flex justify-end">
893
+ <button
894
+ type="button"
895
+ onClick={() => send()}
896
+ disabled={busy || !draft.trim()}
897
+ 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"
898
+ aria-label="Send"
899
+ >
900
+ <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>
901
+ </button>
902
+ </div>
858
903
  </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.
904
+ <p className="mt-3 text-[11px] text-muted-foreground">
905
+ 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
906
  </p>
863
907
  </main>
864
908
  );
@@ -896,19 +940,28 @@ export const maxDuration = 120;
896
940
  const NETWORK = (process.env.NETWORK ?? "testnet") as "mainnet" | "testnet";
897
941
  const MODEL = process.env.MODEL ?? "llama3-8b";
898
942
 
943
+ // Grounding prepended to every turn so the model stops guessing what this
944
+ // project is. Edit it to add your real facts. (The SDK has no separate
945
+ // 'system' channel, so we fold it into the prompt below.)
946
+ const DEFAULT_SYSTEM = ${JSON.stringify(CHAT_SYSTEM_PROMPT)};
947
+
899
948
  export async function POST(req: Request) {
900
949
  if (!process.env.PRIVATE_KEY?.startsWith("0x")) {
901
950
  return NextResponse.json({ error: "PRIVATE_KEY not configured" }, { status: 500 });
902
951
  }
903
952
  const body = (await req.json().catch(() => ({}))) as { prompt?: string; system?: string; stream?: boolean };
904
- const prompt = body.prompt?.trim();
905
- if (!prompt) return NextResponse.json({ error: "prompt is required" }, { status: 400 });
953
+ const userPrompt = body.prompt?.trim();
954
+ if (!userPrompt) return NextResponse.json({ error: "prompt is required" }, { status: 400 });
955
+
956
+ // The SDK encrypts only the prompt - there is no separate 'system' channel -
957
+ // so fold the grounding (or a caller override) into the front of the prompt.
958
+ const system = body.system?.trim() || DEFAULT_SYSTEM;
959
+ const prompt = system ? \`\${system}\\n\\n\${userPrompt}\` : userPrompt;
906
960
 
907
961
  const args = {
908
962
  network: NETWORK,
909
963
  privateKey: process.env.PRIVATE_KEY as \`0x\${string}\`,
910
964
  model: MODEL,
911
- system: body.system?.trim() || undefined,
912
965
  prompt,
913
966
  };
914
967
 
@@ -983,6 +1036,11 @@ type Turn = {
983
1036
  const MODELS = ["llama3-8b", "llama3-70b"] as const;
984
1037
  type ModelId = (typeof MODELS)[number];
985
1038
 
1039
+ // Grounding prepended to every turn. The models have no training knowledge of
1040
+ // this specific project, so without it a small model invents details (e.g.
1041
+ // "Lightchain is a Litecoin fork"). Edit this to add your real facts.
1042
+ const CHAT_SYSTEM_PROMPT = ${JSON.stringify(CHAT_SYSTEM_PROMPT)};
1043
+
986
1044
  export default function ChatWeb3() {
987
1045
  const { address, chain } = useAccount();
988
1046
  const network: "mainnet" | "testnet" | null =
@@ -1104,8 +1162,7 @@ export default function ChatWeb3() {
1104
1162
  setTurns([...history, { role: "user", text: next }, { role: "assistant", text: "", streaming: true }]);
1105
1163
  setInput("");
1106
1164
  try {
1107
- const system = "You are a concise assistant. Reply in one or two short sentences.";
1108
- const prompt = composePrompt(history, next, system);
1165
+ const prompt = composePrompt(history, next, CHAT_SYSTEM_PROMPT);
1109
1166
  const onChunk = (_chunk: string, totalSoFar: string) => {
1110
1167
  setBusyStage("");
1111
1168
  patchLastAssistant({ text: totalSoFar });
@@ -2000,6 +2057,7 @@ export function addChat(opts = {}) {
2000
2057
  // can run the whole stack locally (or anywhere Docker runs) with one
2001
2058
  // command. No external host signup, no function-timeout fights.
2002
2059
  written.push(writeFile(path.join(cwd, "app/chat/page.tsx"), NEXTJS_CHAT_PAGE, force));
2060
+ written.push(writeFile(path.join(cwd, "components/lcai-mark.tsx"), LCAI_MARK_FILE, force));
2003
2061
  written.push(writeFile(path.join(cwd, "app/api/inference/route.ts"), NEXTJS_INFERENCE_STREAM_ROUTE, force));
2004
2062
  written.push(writeFile(path.join(cwd, "LIGHTNODE-HOSTING.md"), HOSTING_GUIDE, force));
2005
2063
  written.push(writeFile(path.join(cwd, "Dockerfile"), NEXTJS_DOCKERFILE, force));
@@ -2010,7 +2068,9 @@ export function addChat(opts = {}) {
2010
2068
  written.push(writeFile(path.join(cwd, "chat-repl.ts"), NODE_CHAT_REPL, force));
2011
2069
  }
2012
2070
  written.push(writeFile(path.join(cwd, ".env.example"), ENV_EXAMPLE(network), force));
2013
- return { written, install: installLine(template), template, network };
2071
+ // The nextjs chat page renders markdown with streamdown; the node REPL doesn't.
2072
+ const install = template === "nextjs-api" ? installLine(template) + " streamdown" : installLine(template);
2073
+ return { written, install, template, network };
2014
2074
  }
2015
2075
  /**
2016
2076
  * `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.6",
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",