lightnode-sdk 0.3.2 → 0.4.1

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 CHANGED
@@ -370,3 +370,735 @@ export function addInference(opts = {}) {
370
370
  network,
371
371
  };
372
372
  }
373
+ // ---------------------------------------------------------------------------
374
+ // `lightnode add analytics-dashboard` - drop in a read-only network/worker
375
+ // analytics page that uses the SDK's getNetworkAnalytics + getModelStats +
376
+ // getWorkerStats. All reads, no wallet needed, no fees - so it composes onto
377
+ // any existing dApp.
378
+ // ---------------------------------------------------------------------------
379
+ const NEXTJS_DASHBOARD_PAGE = `// app/lightnode-analytics/page.tsx
380
+ // Generated by 'lightnode add analytics-dashboard'. See https://lightnode.app/build
381
+ import { LightNode, type NetworkId } from "lightnode-sdk";
382
+
383
+ export const revalidate = 30; // cache the SSR render for 30s
384
+
385
+ const NETWORK = (process.env.NEXT_PUBLIC_LIGHTCHAIN_NETWORK ?? "mainnet") as NetworkId;
386
+
387
+ export default async function LightNodeAnalyticsPage() {
388
+ const ln = new LightNode(NETWORK);
389
+ const [network, models, workers] = await Promise.all([
390
+ ln.getNetworkAnalytics(),
391
+ ln.getModelStats(),
392
+ ln.getWorkerStats(1000, 12),
393
+ ]);
394
+
395
+ return (
396
+ <main style={{ maxWidth: 1080, margin: "40px auto", padding: 24, fontFamily: "system-ui, sans-serif", color: "#111" }}>
397
+ <header style={{ marginBottom: 24 }}>
398
+ <h1 style={{ fontSize: 28, fontWeight: 600 }}>LightChain {NETWORK} - network analytics</h1>
399
+ <p style={{ color: "#555", marginTop: 6 }}>
400
+ Live read from the public worker subgraph + on-chain registration. Auto-refreshes every 30 seconds.
401
+ </p>
402
+ </header>
403
+
404
+ <section style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", marginBottom: 28 }}>
405
+ <Stat label="Completion" value={pct(network.completionRate)} />
406
+ <Stat label="Jobs" value={fmt(network.jobs)} />
407
+ <Stat label="Incomplete" value={fmt(network.incomplete)} />
408
+ <Stat label="Earnings (LCAI)" value={network.earnings.toFixed(2)} />
409
+ </section>
410
+
411
+ <section style={{ marginBottom: 32 }}>
412
+ <h2 style={{ fontSize: 18, fontWeight: 600, marginBottom: 12 }}>Per-model performance</h2>
413
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
414
+ <thead>
415
+ <tr style={{ textAlign: "left", borderBottom: "1px solid #ddd", color: "#666" }}>
416
+ <th style={{ padding: 8 }}>Model</th>
417
+ <th style={{ padding: 8 }}>Jobs</th>
418
+ <th style={{ padding: 8 }}>Completion</th>
419
+ <th style={{ padding: 8 }}>p50</th>
420
+ <th style={{ padding: 8 }}>p95</th>
421
+ <th style={{ padding: 8 }}>Earnings</th>
422
+ </tr>
423
+ </thead>
424
+ <tbody>
425
+ {models.map((m) => (
426
+ <tr key={m.modelId} style={{ borderBottom: "1px solid #f0f0f0" }}>
427
+ <td style={{ padding: 8, fontWeight: 500 }}>{m.name}</td>
428
+ <td style={{ padding: 8 }}>{fmt(m.total)}</td>
429
+ <td style={{ padding: 8 }}>{pct(m.completionRate)}</td>
430
+ <td style={{ padding: 8 }}>{m.p50 ?? "-"}s</td>
431
+ <td style={{ padding: 8 }}>{m.p95 ?? "-"}s</td>
432
+ <td style={{ padding: 8 }}>{m.earnings.toFixed(2)} LCAI</td>
433
+ </tr>
434
+ ))}
435
+ </tbody>
436
+ </table>
437
+ </section>
438
+
439
+ <section>
440
+ <h2 style={{ fontSize: 18, fontWeight: 600, marginBottom: 12 }}>Busiest workers (top 12)</h2>
441
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
442
+ <thead>
443
+ <tr style={{ textAlign: "left", borderBottom: "1px solid #ddd", color: "#666" }}>
444
+ <th style={{ padding: 8 }}>Worker</th>
445
+ <th style={{ padding: 8 }}>Jobs</th>
446
+ <th style={{ padding: 8 }}>Completion</th>
447
+ <th style={{ padding: 8 }}>Earnings</th>
448
+ </tr>
449
+ </thead>
450
+ <tbody>
451
+ {workers.map((w) => (
452
+ <tr key={w.address} style={{ borderBottom: "1px solid #f0f0f0" }}>
453
+ <td style={{ padding: 8, fontFamily: "monospace" }}>{short(w.address)}</td>
454
+ <td style={{ padding: 8 }}>{fmt(w.total)}</td>
455
+ <td style={{ padding: 8 }}>{pct(w.completionRate)}</td>
456
+ <td style={{ padding: 8 }}>{w.earnings.toFixed(3)} LCAI</td>
457
+ </tr>
458
+ ))}
459
+ </tbody>
460
+ </table>
461
+ </section>
462
+
463
+ <p style={{ marginTop: 28, color: "#888", fontSize: 12 }}>
464
+ Powered by the open-source <a href="https://www.npmjs.com/package/lightnode-sdk">lightnode-sdk</a>.
465
+ Same data the dashboard at lightnode.app uses; you can re-style or filter freely.
466
+ </p>
467
+ </main>
468
+ );
469
+ }
470
+
471
+ function Stat({ label, value }: { label: string; value: string }) {
472
+ return (
473
+ <div style={{ background: "#fafafa", border: "1px solid #eee", borderRadius: 12, padding: 16 }}>
474
+ <div style={{ fontSize: 11, color: "#888", textTransform: "uppercase", letterSpacing: 0.5 }}>{label}</div>
475
+ <div style={{ fontSize: 24, fontWeight: 600, marginTop: 4, fontVariantNumeric: "tabular-nums" }}>{value}</div>
476
+ </div>
477
+ );
478
+ }
479
+
480
+ function pct(r: number | null): string { return r == null ? "-" : \`\${Math.round(r * 100)}%\`; }
481
+ function fmt(n: number): string { return n.toLocaleString(); }
482
+ function short(a: string): string { return \`\${a.slice(0, 6)}…\${a.slice(-4)}\`; }
483
+ `;
484
+ const NODE_DASHBOARD_SCRIPT = `// lightnode-analytics.ts
485
+ // Generated by 'lightnode add analytics-dashboard'. Run with: tsx lightnode-analytics.ts
486
+ import { LightNode, type NetworkId } from "lightnode-sdk";
487
+
488
+ const NETWORK = (process.env.NETWORK ?? "mainnet") as NetworkId;
489
+ const ln = new LightNode(NETWORK);
490
+
491
+ const [network, models, workers] = await Promise.all([
492
+ ln.getNetworkAnalytics(),
493
+ ln.getModelStats(),
494
+ ln.getWorkerStats(1000, 10),
495
+ ]);
496
+
497
+ console.log(\`LightChain \${NETWORK} - network analytics\\n\`);
498
+ console.log(\`Completion : \${pct(network.completionRate)}\`);
499
+ console.log(\`Jobs : \${network.jobs.toLocaleString()}\`);
500
+ console.log(\`Incomplete : \${network.incomplete.toLocaleString()}\`);
501
+ console.log(\`Earnings : \${network.earnings.toFixed(2)} LCAI\\n\`);
502
+
503
+ console.log("Per-model performance:");
504
+ for (const m of models) {
505
+ console.log(\` \${m.name.padEnd(14)} jobs=\${String(m.total).padStart(5)} completion=\${pct(m.completionRate)} p50=\${m.p50 ?? "-"}s earnings=\${m.earnings.toFixed(3)} LCAI\`);
506
+ }
507
+
508
+ console.log("\\nTop 10 workers:");
509
+ for (const w of workers) {
510
+ console.log(\` \${short(w.address)} jobs=\${String(w.total).padStart(4)} completion=\${pct(w.completionRate)} earnings=\${w.earnings.toFixed(3)} LCAI\`);
511
+ }
512
+
513
+ function pct(r: number | null): string { return r == null ? "-" : \`\${Math.round(r * 100)}%\`; }
514
+ function short(a: string): string { return \`\${a.slice(0, 6)}…\${a.slice(-4)}\`; }
515
+ `;
516
+ export function addAnalyticsDashboard(opts = {}) {
517
+ const cwd = opts.cwd ?? process.cwd();
518
+ const network = opts.network ?? "mainnet";
519
+ const template = opts.template && opts.template !== "auto" ? opts.template : detectTemplate(cwd);
520
+ const force = !!opts.force;
521
+ const written = [];
522
+ if (template === "nextjs-api") {
523
+ written.push(writeFile(path.join(cwd, "app/lightnode-analytics/page.tsx"), NEXTJS_DASHBOARD_PAGE, force));
524
+ }
525
+ else {
526
+ // Hono and Node both get the CLI-style script; the SDK calls are pure
527
+ // server-side reads anyway and a custom Hono route is trivial to wrap.
528
+ written.push(writeFile(path.join(cwd, "lightnode-analytics.ts"), NODE_DASHBOARD_SCRIPT, force));
529
+ }
530
+ return { written, install: `npm install lightnode-sdk`, template, network };
531
+ }
532
+ // ---------------------------------------------------------------------------
533
+ // `lightnode add nft-mint-with-inference` - drop in a function that uses
534
+ // LightChain AI to generate NFT metadata from a prompt. The caller wires it
535
+ // into their existing mint flow; we don't pick a specific ERC-721 contract.
536
+ // ---------------------------------------------------------------------------
537
+ const NEXTJS_NFT_METADATA_ROUTE = `// app/api/nft-metadata/route.ts
538
+ // Generated by 'lightnode add nft-mint-with-inference'.
539
+ // Calls /api/inference (also added by 'lightnode add inference') to generate
540
+ // an NFT description from a short prompt, returns ERC-721-style metadata.
541
+ import { NextResponse } from "next/server";
542
+
543
+ export const runtime = "nodejs";
544
+ export const dynamic = "force-dynamic";
545
+
546
+ interface MintInput {
547
+ name?: string;
548
+ prompt?: string;
549
+ image?: string;
550
+ attributes?: Array<{ trait_type: string; value: string | number }>;
551
+ }
552
+
553
+ export async function POST(req: Request) {
554
+ const body = (await req.json().catch(() => ({}))) as MintInput;
555
+ const name = body.name?.trim();
556
+ const prompt = body.prompt?.trim();
557
+ if (!name || !prompt) return NextResponse.json({ error: "name and prompt are required" }, { status: 400 });
558
+
559
+ // Reuse the inference route added by 'lightnode add inference'. If you mounted
560
+ // it elsewhere, update this path. If you'd rather call the SDK directly here,
561
+ // copy the contents of app/api/inference/route.ts into this file.
562
+ const origin = new URL(req.url).origin;
563
+ const inference = await fetch(\`\${origin}/api/inference\`, {
564
+ method: "POST",
565
+ headers: { "Content-Type": "application/json" },
566
+ body: JSON.stringify({ prompt: \`Write a short, evocative 1-2 sentence description of an NFT titled "\${name}" with this concept: \${prompt}\` }),
567
+ }).then((r) => r.json()) as { answer?: string; txs?: Record<string, string>; error?: string };
568
+
569
+ if (!inference?.answer) {
570
+ return NextResponse.json({ error: inference?.error ?? "inference failed" }, { status: 502 });
571
+ }
572
+
573
+ return NextResponse.json({
574
+ name,
575
+ description: inference.answer.trim(),
576
+ image: body.image ?? null,
577
+ attributes: body.attributes ?? [],
578
+ // Provenance: the on-chain LightChain AI transactions that generated this metadata.
579
+ // Pin this whole object to IPFS and use the IPFS hash as your tokenURI.
580
+ lightchain_inference: inference.txs,
581
+ });
582
+ }
583
+ `;
584
+ const NEXTJS_NFT_MINT_CLIENT = `// app/nft-mint/page.tsx
585
+ // Generated by 'lightnode add nft-mint-with-inference'.
586
+ // Minimal client that takes a name + concept, generates AI metadata via the
587
+ // /api/nft-metadata route, and shows the result. Bring your own mint() call.
588
+ "use client";
589
+ import { useState } from "react";
590
+
591
+ interface Metadata { name: string; description: string; image: string | null; attributes: unknown[]; lightchain_inference?: Record<string, string> }
592
+
593
+ export default function NftMint() {
594
+ const [name, setName] = useState("Cosmic Wanderer");
595
+ const [prompt, setPrompt] = useState("an astronaut surfing on the edge of a black hole");
596
+ const [meta, setMeta] = useState<Metadata | null>(null);
597
+ const [busy, setBusy] = useState(false);
598
+
599
+ return (
600
+ <main style={{ maxWidth: 640, margin: "40px auto", padding: 20, fontFamily: "system-ui" }}>
601
+ <h1>Mint an NFT with AI metadata</h1>
602
+ <p style={{ color: "#666", fontSize: 14 }}>
603
+ The description is generated by LightChain AI inference. The transaction hashes are returned in the
604
+ metadata as on-chain provenance you can pin alongside the JSON.
605
+ </p>
606
+ <label style={{ display: "block", marginTop: 16, fontSize: 13 }}>NFT name</label>
607
+ <input value={name} onChange={(e) => setName(e.target.value)} style={{ width: "100%", padding: 10, fontSize: 14 }} />
608
+ <label style={{ display: "block", marginTop: 12, fontSize: 13 }}>Concept</label>
609
+ <textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} rows={2} style={{ width: "100%", padding: 10, fontSize: 14 }} />
610
+ <button
611
+ disabled={busy || !name || !prompt}
612
+ onClick={async () => {
613
+ setBusy(true); setMeta(null);
614
+ try {
615
+ const r = await fetch("/api/nft-metadata", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, prompt }) }).then((r) => r.json());
616
+ setMeta(r);
617
+ } finally { setBusy(false); }
618
+ }}
619
+ style={{ marginTop: 12, padding: "10px 20px", fontSize: 14 }}
620
+ >
621
+ {busy ? "Generating..." : "Generate metadata"}
622
+ </button>
623
+ {meta && (
624
+ <pre style={{ marginTop: 20, padding: 16, background: "#eee", whiteSpace: "pre-wrap", fontSize: 13 }}>
625
+ {JSON.stringify(meta, null, 2)}
626
+ </pre>
627
+ )}
628
+ {/* Wire your own mint(uri) call here. tokenURI = ipfs://<hash of meta> after pinning. */}
629
+ </main>
630
+ );
631
+ }
632
+ `;
633
+ const NODE_NFT_METADATA_SCRIPT = `// nft-metadata.ts
634
+ // Generated by 'lightnode add nft-mint-with-inference'.
635
+ // Use: tsx nft-metadata.ts "Cosmic Wanderer" "an astronaut surfing on the edge of a black hole"
636
+ //
637
+ // Calls LightChain AI inference directly, prints ERC-721-style metadata to stdout.
638
+ // Pipe to a file + pin to IPFS for the tokenURI in your mint contract.
639
+ import WS from "ws";
640
+ import { createPublicClient, createWalletClient, http, parseAbi, parseAbiItem, parseEther, type Log } from "viem";
641
+ import { privateKeyToAccount } from "viem/accounts";
642
+ import {
643
+ LightNode, prepareSession, submitPrompt, decryptResponse,
644
+ estimateJobFee, consumerGatewayUrl, JOB_REGISTRY_CONSUMER_ABI,
645
+ GatewayClient, type NetworkId,
646
+ } from "lightnode-sdk";
647
+
648
+ const NETWORK = (process.env.NETWORK ?? "testnet") as NetworkId;
649
+ const MODEL = process.env.MODEL ?? "llama3-8b";
650
+ const [, , NAME, ...promptArgs] = process.argv;
651
+ const CONCEPT = promptArgs.join(" ").trim();
652
+ if (!NAME || !CONCEPT) { console.error('usage: tsx nft-metadata.ts "NFT Name" "concept"'); process.exit(1); }
653
+ const PRIVATE_KEY = process.env.PRIVATE_KEY as \`0x\${string}\` | undefined;
654
+ if (!PRIVATE_KEY?.startsWith("0x") || PRIVATE_KEY.length !== 66) { console.error("set PRIVATE_KEY in .env"); process.exit(1); }
655
+
656
+ const ln = new LightNode(NETWORK);
657
+ const cfg = ln.network;
658
+ const acct = privateKeyToAccount(PRIVATE_KEY);
659
+ const chain = { id: cfg.chainId, name: cfg.label, nativeCurrency: { name: "LCAI", symbol: "LCAI", decimals: 18 }, rpcUrls: { default: { http: [cfg.rpc] } } };
660
+ const pub = createPublicClient({ transport: http(cfg.rpc), chain });
661
+ const wal = createWalletClient({ account: acct, transport: http(cfg.rpc), chain });
662
+ const abi = parseAbi(JOB_REGISTRY_CONSUMER_ABI);
663
+
664
+ const ch = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/challenge?address=\${acct.address}\`)).json() as { message?: string };
665
+ if (!ch.message) throw new Error("auth challenge failed");
666
+ const sig = await wal.signMessage({ message: ch.message });
667
+ const verify = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/verify\`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: ch.message, signature: sig }) })).json() as { token?: string };
668
+ if (!verify.token) throw new Error("auth verify failed");
669
+ const gateway = new GatewayClient({ network: NETWORK, bearer: verify.token });
670
+
671
+ const { sessionKey, createSessionArgs } = await prepareSession(gateway, MODEL);
672
+ const fee = await estimateJobFee(cfg, MODEL);
673
+ const createTx = await wal.writeContract({
674
+ address: cfg.jobRegistry as \`0x\${string}\`, abi, functionName: "createSession",
675
+ args: [createSessionArgs.paramsHash, createSessionArgs.worker, createSessionArgs.encWorkerKey, createSessionArgs.ephemeralPubKey, createSessionArgs.initState, createSessionArgs.expiry],
676
+ gas: 1_000_000n,
677
+ });
678
+ const createReceipt = await pub.waitForTransactionReceipt({ hash: createTx });
679
+ const sessionCreated = parseAbiItem("event SessionCreated(uint256 indexed sessionId, address indexed user, bytes32 indexed paramsHash, address worker, bytes encWorkerKey, bytes ephemeralPubKey)");
680
+ const sessionLog = (await pub.getLogs({ address: cfg.jobRegistry as \`0x\${string}\`, event: sessionCreated, blockHash: createReceipt.blockHash })).find((l) => l.transactionHash === createTx);
681
+ const sessionId = sessionLog?.args.sessionId;
682
+ if (!sessionId) throw new Error("SessionCreated missing");
683
+
684
+ let relayToken: string | undefined;
685
+ for (let i = 0; i < 30 && !relayToken; i++) {
686
+ const r = await gateway.getSessionToken(Number(sessionId));
687
+ if ("token" in r && r.token) relayToken = r.token; else await new Promise((res) => setTimeout(res, 1000));
688
+ }
689
+ if (!relayToken) throw new Error("relay token never became ready");
690
+ const ws = new WS(\`wss://relay.\${NETWORK}.lightchain.ai/ws?token=\${encodeURIComponent(relayToken)}\`);
691
+ const chunks: string[] = [];
692
+ await new Promise<void>((res, rej) => { ws.once("open", () => res()); ws.once("error", rej); });
693
+ ws.on("message", async (data: Buffer) => {
694
+ let f: { type?: string; payload?: string };
695
+ try { f = JSON.parse(data.toString("utf8")); } catch { return; }
696
+ if (!f.payload) return;
697
+ if (f.type === "chunk") { try { chunks.push(await decryptResponse(sessionKey, f.payload)); } catch {} }
698
+ else if (f.type === "complete" && chunks.length === 0) { try { chunks.push(await decryptResponse(sessionKey, f.payload)); } catch {} }
699
+ });
700
+
701
+ const PROMPT = \`Write a short, evocative 1-2 sentence description of an NFT titled "\${NAME}" with this concept: \${CONCEPT}\`;
702
+ const promptHash = await submitPrompt(gateway, sessionKey, PROMPT);
703
+ const submitTx = await wal.writeContract({
704
+ address: cfg.jobRegistry as \`0x\${string}\`, abi, functionName: "submitJob",
705
+ args: [sessionId, promptHash], value: parseEther(String(fee)), gas: 500_000n,
706
+ });
707
+ const submitReceipt = await pub.waitForTransactionReceipt({ hash: submitTx });
708
+ const jobSubmitted = parseAbiItem("event JobSubmitted(uint256 indexed jobId, uint256 indexed sessionId, address worker)");
709
+ const jobLog = (await pub.getLogs({ address: cfg.jobRegistry as \`0x\${string}\`, event: jobSubmitted, blockHash: submitReceipt.blockHash })).find((l) => l.transactionHash === submitTx);
710
+ const jobId = jobLog?.args.jobId;
711
+ if (!jobId) throw new Error("JobSubmitted missing");
712
+
713
+ const jobCompleted = parseAbiItem("event JobCompleted(uint256 indexed jobId, address indexed worker, bytes32 responseHash, bytes32 ciphertextHash)");
714
+ const deadline = Date.now() + 90_000;
715
+ let completed: Log | null = null;
716
+ while (!completed && Date.now() < deadline) {
717
+ await new Promise((res) => setTimeout(res, 3000));
718
+ const logs = await pub.getLogs({ address: cfg.jobRegistry as \`0x\${string}\`, event: jobCompleted, args: { jobId }, fromBlock: submitReceipt.blockNumber });
719
+ if (logs.length) completed = logs[0] as Log;
720
+ }
721
+ if (!completed) { console.error("worker stalled - re-run for a different worker"); process.exit(1); }
722
+ await new Promise((res) => setTimeout(res, 4000));
723
+ ws.close();
724
+
725
+ const description = chunks.join("").trim();
726
+ const metadata = {
727
+ name: NAME,
728
+ description,
729
+ image: null,
730
+ attributes: [] as unknown[],
731
+ lightchain_inference: {
732
+ createSession: createTx,
733
+ submitJob: submitTx,
734
+ jobCompleted: completed.transactionHash,
735
+ sessionId: sessionId.toString(),
736
+ jobId: jobId.toString(),
737
+ worker: createSessionArgs.worker,
738
+ },
739
+ };
740
+ console.log(JSON.stringify(metadata, null, 2));
741
+ process.exit(0);
742
+ `;
743
+ // ---------------------------------------------------------------------------
744
+ // `lightnode add chat` - drop in a chat-style UI that uses the SDK's high-
745
+ // level runInference() helper. Keeps conversation history client-side and
746
+ // formats every prior turn into the next prompt so the model has context.
747
+ // ---------------------------------------------------------------------------
748
+ const NEXTJS_CHAT_PAGE = `// app/chat/page.tsx
749
+ // Generated by 'lightnode add chat'.
750
+ "use client";
751
+ import { useState } from "react";
752
+
753
+ type Turn = { role: "user" | "assistant"; text: string; txs?: Record<string, string> };
754
+
755
+ export default function Chat() {
756
+ const [turns, setTurns] = useState<Turn[]>([]);
757
+ const [draft, setDraft] = useState("");
758
+ const [busy, setBusy] = useState(false);
759
+
760
+ async function send() {
761
+ if (!draft.trim()) return;
762
+ const next: Turn[] = [...turns, { role: "user", text: draft.trim() }];
763
+ setTurns(next);
764
+ setDraft("");
765
+ setBusy(true);
766
+ try {
767
+ // Concatenate prior turns so the model has the conversation context.
768
+ const prompt =
769
+ next.map((t) => (t.role === "user" ? \`User: \${t.text}\` : \`Assistant: \${t.text}\`)).join("\\n") +
770
+ "\\nAssistant:";
771
+ const r = await fetch("/api/inference", {
772
+ method: "POST",
773
+ headers: { "Content-Type": "application/json" },
774
+ body: JSON.stringify({ prompt }),
775
+ }).then((r) => r.json()) as { answer?: string; txs?: Record<string, string>; error?: string };
776
+ if (!r.answer) throw new Error(r.error ?? "no answer");
777
+ setTurns([...next, { role: "assistant", text: r.answer, txs: r.txs }]);
778
+ } catch (e) {
779
+ setTurns([...next, { role: "assistant", text: \`(error: \${(e as Error).message})\` }]);
780
+ } finally {
781
+ setBusy(false);
782
+ }
783
+ }
784
+
785
+ return (
786
+ <main style={{ maxWidth: 760, margin: "30px auto", padding: 20, fontFamily: "system-ui, sans-serif" }}>
787
+ <h1>LightChain AI - chat</h1>
788
+ <p style={{ color: "#666", fontSize: 13 }}>
789
+ Each turn pays ~0.02 LCAI on mainnet (free on testnet). Conversation history is sent with each turn so the
790
+ model has context.
791
+ </p>
792
+ <div style={{ marginTop: 20, display: "flex", flexDirection: "column", gap: 10 }}>
793
+ {turns.map((t, i) => (
794
+ <div
795
+ key={i}
796
+ style={{
797
+ padding: 14,
798
+ borderRadius: 12,
799
+ background: t.role === "user" ? "#e8f0ff" : "#f4f4f4",
800
+ alignSelf: t.role === "user" ? "flex-end" : "flex-start",
801
+ maxWidth: "85%",
802
+ whiteSpace: "pre-wrap",
803
+ lineHeight: 1.5,
804
+ }}
805
+ >
806
+ <div style={{ fontSize: 11, color: "#888", marginBottom: 4, textTransform: "uppercase", letterSpacing: 0.5 }}>
807
+ {t.role}
808
+ </div>
809
+ {t.text}
810
+ {t.txs && (
811
+ <div style={{ marginTop: 8, fontSize: 11, color: "#888", fontFamily: "monospace" }}>
812
+ jobCompleted: {t.txs.jobCompleted?.slice(0, 18)}…
813
+ </div>
814
+ )}
815
+ </div>
816
+ ))}
817
+ {busy && (
818
+ <div style={{ padding: 14, color: "#888", fontStyle: "italic" }}>
819
+ running encrypted inference…
820
+ </div>
821
+ )}
822
+ </div>
823
+ <div style={{ marginTop: 20, display: "flex", gap: 8 }}>
824
+ <input
825
+ value={draft}
826
+ onChange={(e) => setDraft(e.target.value)}
827
+ onKeyDown={(e) => e.key === "Enter" && !busy && send()}
828
+ placeholder="Type a message…"
829
+ style={{ flex: 1, padding: 12, fontSize: 14, borderRadius: 8, border: "1px solid #ccc" }}
830
+ />
831
+ <button
832
+ onClick={send}
833
+ disabled={busy || !draft.trim()}
834
+ style={{ padding: "10px 20px", fontSize: 14, borderRadius: 8 }}
835
+ >
836
+ Send
837
+ </button>
838
+ </div>
839
+ <p style={{ marginTop: 16, fontSize: 12, color: "#888" }}>
840
+ Make sure <code>/api/inference</code> is mounted - run <code>npx lightnode add inference</code> if you have
841
+ not. The route signs every call server-side with the PRIVATE_KEY in your .env.
842
+ </p>
843
+ </main>
844
+ );
845
+ }
846
+ `;
847
+ const NODE_CHAT_REPL = `// chat-repl.ts
848
+ // Generated by 'lightnode add chat'. Interactive chat REPL in your terminal.
849
+ // npm install lightnode-sdk viem ws
850
+ // tsx chat-repl.ts
851
+ import * as readline from "node:readline/promises";
852
+ import { stdin as input, stdout as output } from "node:process";
853
+ import WS from "ws";
854
+ import { createPublicClient, createWalletClient, http } from "viem";
855
+ import { privateKeyToAccount } from "viem/accounts";
856
+ import { LightNode, runInference, GatewayClient, consumerGatewayUrl, type NetworkId } from "lightnode-sdk";
857
+
858
+ const NETWORK = (process.env.NETWORK ?? "testnet") as NetworkId;
859
+ const MODEL = process.env.MODEL ?? "llama3-8b";
860
+ const PRIVATE_KEY = process.env.PRIVATE_KEY as \`0x\${string}\` | undefined;
861
+ if (!PRIVATE_KEY?.startsWith("0x") || PRIVATE_KEY.length !== 66) { console.error("set PRIVATE_KEY in .env"); process.exit(1); }
862
+
863
+ const ln = new LightNode(NETWORK);
864
+ const acct = privateKeyToAccount(PRIVATE_KEY);
865
+ const chain = { id: ln.network.chainId, name: ln.network.label, nativeCurrency: { name: "LCAI", symbol: "LCAI", decimals: 18 }, rpcUrls: { default: { http: [ln.network.rpc] } } };
866
+ const pub = createPublicClient({ transport: http(ln.network.rpc), chain });
867
+ const wal = createWalletClient({ account: acct, transport: http(ln.network.rpc), chain });
868
+
869
+ // One SIWE handshake per process; the JWT is reused across all turns.
870
+ const ch = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/challenge?address=\${acct.address}\`)).json() as { message?: string };
871
+ if (!ch.message) throw new Error("auth challenge failed");
872
+ const sig = await wal.signMessage({ message: ch.message });
873
+ const verify = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/verify\`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: ch.message, signature: sig }) })).json() as { token?: string };
874
+ if (!verify.token) throw new Error("auth verify failed");
875
+ const gateway = new GatewayClient({ network: NETWORK, bearer: verify.token });
876
+
877
+ const rl = readline.createInterface({ input, output });
878
+ const turns: { role: "user" | "assistant"; text: string }[] = [];
879
+ console.log(\`▶ chat on \${NETWORK}, model=\${MODEL}. Ctrl+C to exit.\\n\`);
880
+
881
+ while (true) {
882
+ const user = (await rl.question("> ")).trim();
883
+ if (!user) continue;
884
+ turns.push({ role: "user", text: user });
885
+ const prompt = turns.map((t) => (t.role === "user" ? \`User: \${t.text}\` : \`Assistant: \${t.text}\`)).join("\\n") + "\\nAssistant:";
886
+ try {
887
+ process.stdout.write(" ");
888
+ const { answer } = await runInference({
889
+ prompt, gateway, wallet: wal, publicClient: pub, network: ln.network,
890
+ model: MODEL, WebSocket: WS,
891
+ onChunk: (chunk) => process.stdout.write(chunk),
892
+ });
893
+ process.stdout.write("\\n\\n");
894
+ turns.push({ role: "assistant", text: answer });
895
+ } catch (e) {
896
+ console.log(\` (error: \${(e as Error).message})\`);
897
+ }
898
+ }
899
+ `;
900
+ // ---------------------------------------------------------------------------
901
+ // `lightnode add agent` - drop in a scheduled/loop inference scaffold. Good
902
+ // for daily summarizers, monitoring agents, cron jobs that run inference on a
903
+ // fixed cadence. For Next.js: a /api/agent route that Vercel Cron (or any
904
+ // cron-style trigger) can hit on schedule. For Node: a standalone script that
905
+ // runs inference on an interval until you kill it.
906
+ // ---------------------------------------------------------------------------
907
+ const NEXTJS_AGENT_ROUTE = `// app/api/agent/route.ts
908
+ // Generated by 'lightnode add agent'.
909
+ //
910
+ // Set up Vercel Cron in vercel.json:
911
+ // { "crons": [{ "path": "/api/agent", "schedule": "0 9 * * *" }] }
912
+ // That hits this route every day at 09:00 UTC.
913
+ //
914
+ // Auth your cron call: Vercel Cron sends a Bearer token in the Authorization
915
+ // header that you can verify here against CRON_SECRET. See:
916
+ // https://vercel.com/docs/cron-jobs/manage-cron-jobs#securing-cron-jobs
917
+ import { NextResponse } from "next/server";
918
+ import WS from "ws";
919
+ import { createPublicClient, createWalletClient, http } from "viem";
920
+ import { privateKeyToAccount } from "viem/accounts";
921
+ import { LightNode, GatewayClient, runInference, consumerGatewayUrl, type NetworkId } from "lightnode-sdk";
922
+
923
+ export const runtime = "nodejs";
924
+ export const dynamic = "force-dynamic";
925
+ export const maxDuration = 120;
926
+
927
+ const NETWORK = (process.env.NETWORK ?? "testnet") as NetworkId;
928
+ const MODEL = process.env.MODEL ?? "llama3-8b";
929
+ const TASK_PROMPT = process.env.AGENT_TASK ?? "Summarize today's news in 3 bullet points.";
930
+
931
+ export async function GET(req: Request) {
932
+ // Verify Vercel Cron sent this. Set CRON_SECRET in your Vercel env vars; the
933
+ // platform automatically injects it as the Bearer token on cron-fired requests.
934
+ const auth = req.headers.get("authorization");
935
+ if (process.env.CRON_SECRET && auth !== \`Bearer \${process.env.CRON_SECRET}\`) {
936
+ return NextResponse.json({ error: "unauthorized" }, { status: 401 });
937
+ }
938
+ if (!process.env.PRIVATE_KEY?.startsWith("0x")) {
939
+ return NextResponse.json({ error: "PRIVATE_KEY not configured" }, { status: 500 });
940
+ }
941
+
942
+ const ln = new LightNode(NETWORK);
943
+ const acct = privateKeyToAccount(process.env.PRIVATE_KEY as \`0x\${string}\`);
944
+ const chain = { id: ln.network.chainId, name: ln.network.label, nativeCurrency: { name: "LCAI", symbol: "LCAI", decimals: 18 }, rpcUrls: { default: { http: [ln.network.rpc] } } };
945
+ const publicClient = createPublicClient({ transport: http(ln.network.rpc), chain });
946
+ const wallet = createWalletClient({ account: acct, transport: http(ln.network.rpc), chain });
947
+
948
+ // SIWE -> JWT (one handshake per agent run).
949
+ const ch = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/challenge?address=\${acct.address}\`)).json() as { message?: string };
950
+ if (!ch.message) return NextResponse.json({ error: "auth challenge failed" }, { status: 502 });
951
+ const sig = await wallet.signMessage({ message: ch.message });
952
+ const verify = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/verify\`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: ch.message, signature: sig }) })).json() as { token?: string };
953
+ if (!verify.token) return NextResponse.json({ error: "auth verify failed" }, { status: 502 });
954
+ const gateway = new GatewayClient({ network: NETWORK, bearer: verify.token });
955
+
956
+ // ---- your agent's logic ------------------------------------------------
957
+ // Replace this block with whatever your scheduled task should do. By default
958
+ // it just runs a single inference call with the AGENT_TASK prompt and stores
959
+ // the result; you might fetch upstream data first, run multiple turns, post
960
+ // results to Slack/Discord/a DB, etc.
961
+ try {
962
+ const result = await runInference({
963
+ prompt: TASK_PROMPT,
964
+ gateway, wallet, publicClient, network: ln.network,
965
+ model: MODEL, WebSocket: WS, maxRetries: 2,
966
+ });
967
+ // TODO: persist result.answer somewhere durable (DB, S3, send to Slack, etc.).
968
+ return NextResponse.json({
969
+ ok: true,
970
+ answer: result.answer,
971
+ txs: result.txs,
972
+ jobId: result.jobId.toString(),
973
+ ranAt: new Date().toISOString(),
974
+ });
975
+ } catch (e) {
976
+ return NextResponse.json({ ok: false, error: (e as Error).message }, { status: 500 });
977
+ }
978
+ }
979
+ `;
980
+ const NEXTJS_AGENT_VERCEL_JSON = `{
981
+ "crons": [
982
+ {
983
+ "path": "/api/agent",
984
+ "schedule": "0 9 * * *"
985
+ }
986
+ ]
987
+ }
988
+ `;
989
+ const NODE_AGENT_SCRIPT = `// agent.ts
990
+ // Generated by 'lightnode add agent'. A long-running script that runs inference
991
+ // on a fixed cadence. Use it under pm2, systemd, a Docker container - anywhere
992
+ // you'd run a daemon.
993
+ // npm install lightnode-sdk viem ws
994
+ // AGENT_INTERVAL_MS=3600000 tsx agent.ts
995
+ import WS from "ws";
996
+ import { createPublicClient, createWalletClient, http } from "viem";
997
+ import { privateKeyToAccount } from "viem/accounts";
998
+ import { LightNode, GatewayClient, runInference, consumerGatewayUrl, isStalledWorker, type NetworkId } from "lightnode-sdk";
999
+
1000
+ const NETWORK = (process.env.NETWORK ?? "testnet") as NetworkId;
1001
+ const MODEL = process.env.MODEL ?? "llama3-8b";
1002
+ const TASK_PROMPT = process.env.AGENT_TASK ?? "Summarize today's news in 3 bullet points.";
1003
+ const INTERVAL_MS = Number(process.env.AGENT_INTERVAL_MS ?? 24 * 60 * 60 * 1000); // default daily
1004
+ const PRIVATE_KEY = process.env.PRIVATE_KEY as \`0x\${string}\` | undefined;
1005
+ if (!PRIVATE_KEY?.startsWith("0x") || PRIVATE_KEY.length !== 66) { console.error("set PRIVATE_KEY in .env"); process.exit(1); }
1006
+
1007
+ const ln = new LightNode(NETWORK);
1008
+ const acct = privateKeyToAccount(PRIVATE_KEY);
1009
+ const chain = { id: ln.network.chainId, name: ln.network.label, nativeCurrency: { name: "LCAI", symbol: "LCAI", decimals: 18 }, rpcUrls: { default: { http: [ln.network.rpc] } } };
1010
+ const publicClient = createPublicClient({ transport: http(ln.network.rpc), chain });
1011
+ const wallet = createWalletClient({ account: acct, transport: http(ln.network.rpc), chain });
1012
+
1013
+ // One SIWE handshake per process; refreshed lazily when the JWT expires.
1014
+ async function freshGateway() {
1015
+ const ch = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/challenge?address=\${acct.address}\`)).json() as { message?: string };
1016
+ if (!ch.message) throw new Error("auth challenge failed");
1017
+ const sig = await wallet.signMessage({ message: ch.message });
1018
+ const verify = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/verify\`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: ch.message, signature: sig }) })).json() as { token?: string };
1019
+ if (!verify.token) throw new Error("auth verify failed");
1020
+ return new GatewayClient({ network: NETWORK, bearer: verify.token });
1021
+ }
1022
+
1023
+ let gateway = await freshGateway();
1024
+ console.log(\`▶ agent ready on \${NETWORK}, interval=\${(INTERVAL_MS / 1000 / 60).toFixed(1)} min, task=\${TASK_PROMPT.slice(0, 60)}…\\n\`);
1025
+
1026
+ async function tick() {
1027
+ console.log(\`[\${new Date().toISOString()}] tick\`);
1028
+ try {
1029
+ const result = await runInference({
1030
+ prompt: TASK_PROMPT,
1031
+ gateway, wallet, publicClient, network: ln.network,
1032
+ model: MODEL, WebSocket: WS, maxRetries: 2,
1033
+ });
1034
+ // ---- your agent's output sink: write to a DB, post to Slack, etc. ----
1035
+ console.log(\`[\${new Date().toISOString()}] answer:\\n\${result.answer}\\n (createSession=\${result.txs.createSession.slice(0, 14)}…, jobCompleted=\${result.txs.jobCompleted.slice(0, 14)}…)\\n\`);
1036
+ } catch (e) {
1037
+ if (isStalledWorker(e)) console.error(\`[\${new Date().toISOString()}] all workers stalled this round; protocol refunds, skipping\`);
1038
+ else if ((e as Error).message.includes("auth")) { console.warn("JWT expired; re-authing"); gateway = await freshGateway(); }
1039
+ else console.error(\`[\${new Date().toISOString()}] tick failed:\`, (e as Error).message);
1040
+ }
1041
+ }
1042
+
1043
+ await tick(); // run once immediately
1044
+ setInterval(tick, INTERVAL_MS);
1045
+
1046
+ // Keep alive forever.
1047
+ process.on("SIGINT", () => { console.log("\\n▶ agent stopped"); process.exit(0); });
1048
+ `;
1049
+ export function addAgent(opts = {}) {
1050
+ const cwd = opts.cwd ?? process.cwd();
1051
+ const network = opts.network ?? "testnet";
1052
+ const template = opts.template && opts.template !== "auto" ? opts.template : detectTemplate(cwd);
1053
+ const force = !!opts.force;
1054
+ const written = [];
1055
+ if (template === "nextjs-api") {
1056
+ written.push(writeFile(path.join(cwd, "app/api/agent/route.ts"), NEXTJS_AGENT_ROUTE, force));
1057
+ // Only add vercel.json if there isn't one; merging is too fragile to do
1058
+ // blindly here (we'd risk clobbering the user's existing config).
1059
+ if (!fs.existsSync(path.join(cwd, "vercel.json"))) {
1060
+ written.push(writeFile(path.join(cwd, "vercel.json"), NEXTJS_AGENT_VERCEL_JSON, force));
1061
+ }
1062
+ }
1063
+ else {
1064
+ written.push(writeFile(path.join(cwd, "agent.ts"), NODE_AGENT_SCRIPT, force));
1065
+ }
1066
+ written.push(writeFile(path.join(cwd, ".env.example"), ENV_EXAMPLE(network), force));
1067
+ return { written, install: `npm install ${depsNeeded(template).join(" ")}`, template, network };
1068
+ }
1069
+ export function addChat(opts = {}) {
1070
+ const cwd = opts.cwd ?? process.cwd();
1071
+ const network = opts.network ?? "testnet";
1072
+ const template = opts.template && opts.template !== "auto" ? opts.template : detectTemplate(cwd);
1073
+ const force = !!opts.force;
1074
+ const written = [];
1075
+ if (template === "nextjs-api") {
1076
+ written.push(writeFile(path.join(cwd, "app/chat/page.tsx"), NEXTJS_CHAT_PAGE, force));
1077
+ }
1078
+ else {
1079
+ written.push(writeFile(path.join(cwd, "chat-repl.ts"), NODE_CHAT_REPL, force));
1080
+ }
1081
+ written.push(writeFile(path.join(cwd, ".env.example"), ENV_EXAMPLE(network), force));
1082
+ return { written, install: `npm install ${depsNeeded(template).join(" ")}`, template, network };
1083
+ }
1084
+ export function addNftMint(opts = {}) {
1085
+ const cwd = opts.cwd ?? process.cwd();
1086
+ const network = opts.network ?? "testnet";
1087
+ const template = opts.template && opts.template !== "auto" ? opts.template : detectTemplate(cwd);
1088
+ const force = !!opts.force;
1089
+ const written = [];
1090
+ if (template === "nextjs-api") {
1091
+ written.push(writeFile(path.join(cwd, "app/api/nft-metadata/route.ts"), NEXTJS_NFT_METADATA_ROUTE, force));
1092
+ written.push(writeFile(path.join(cwd, "app/nft-mint/page.tsx"), NEXTJS_NFT_MINT_CLIENT, force));
1093
+ }
1094
+ else {
1095
+ written.push(writeFile(path.join(cwd, "nft-metadata.ts"), NODE_NFT_METADATA_SCRIPT, force));
1096
+ }
1097
+ written.push(writeFile(path.join(cwd, ".env.example"), ENV_EXAMPLE(network), force));
1098
+ return {
1099
+ written,
1100
+ install: `npm install ${depsNeeded(template).join(" ")}`,
1101
+ template,
1102
+ network,
1103
+ };
1104
+ }