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.
- package/dist/add.js +144 -84
- 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;
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
setTurns(next);
|
|
757
|
-
setDraft("");
|
|
774
|
+
const next = draft.trim();
|
|
775
|
+
if (!next || busy) return;
|
|
758
776
|
setBusy(true);
|
|
759
|
-
|
|
760
|
-
|
|
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
|
|
772
|
-
throw new Error(
|
|
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
|
|
778
|
-
|
|
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
|
|
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
|
-
|
|
807
|
+
patch({ text: assembled });
|
|
789
808
|
} else {
|
|
790
809
|
assembled += text.slice(0, nulIdx);
|
|
791
|
-
try {
|
|
792
|
-
|
|
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
|
-
//
|
|
797
|
-
|
|
798
|
-
|
|
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
|
|
807
|
-
<
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
<div
|
|
813
|
-
{turns.
|
|
814
|
-
<div
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
|
860
|
-
|
|
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
|
|
905
|
-
if (!
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|