lightnode-sdk 0.3.2 → 0.4.0
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/README.md +34 -2
- package/dist/add.d.ts +18 -0
- package/dist/add.js +563 -0
- package/dist/cli.js +54 -14
- package/dist/errors.d.ts +55 -0
- package/dist/errors.js +64 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +5 -2
- package/dist/inference.d.ts +130 -0
- package/dist/inference.js +293 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,8 +81,12 @@ npx lightnode fee llama3-8b # on-chain job fee
|
|
|
81
81
|
npx lightnode analytics --csv # per-model performance (CSV)
|
|
82
82
|
npx lightnode reliability --csv # per-worker reliability (CSV)
|
|
83
83
|
|
|
84
|
-
#
|
|
85
|
-
npx lightnode add inference
|
|
84
|
+
# Patch an existing project (auto-detects Next.js, Hono, or Node):
|
|
85
|
+
npx lightnode add inference # encrypted inference route/script
|
|
86
|
+
npx lightnode add chat # chat-style UI with conversation history
|
|
87
|
+
npx lightnode add analytics-dashboard # read-only network + worker analytics page
|
|
88
|
+
npx lightnode add nft-mint-with-inference # AI-generated NFT metadata with on-chain provenance
|
|
89
|
+
# All `add` commands accept [--template auto|nextjs-api|hono|node] [--net testnet|mainnet] [--force]
|
|
86
90
|
|
|
87
91
|
# Or scaffold a brand-new project:
|
|
88
92
|
npm create lightnode-app my-app
|
|
@@ -90,6 +94,34 @@ npm create lightnode-app my-app
|
|
|
90
94
|
|
|
91
95
|
## Submitting inference
|
|
92
96
|
|
|
97
|
+
**Easy mode (`runInference` — v0.4+):** one async call drives the whole protocol —
|
|
98
|
+
SIWE → prepareSession → on-chain createSession → relay WS → encrypt + upload prompt
|
|
99
|
+
→ on-chain submitJob → decrypt streamed chunks → wait for `JobCompleted` →
|
|
100
|
+
return the assembled answer + three tx hashes. Built-in retry on `StalledWorkerError`.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { LightNode, GatewayClient, runInference } from "lightnode-sdk";
|
|
104
|
+
import WS from "ws"; // omit in the browser
|
|
105
|
+
|
|
106
|
+
const ln = new LightNode("testnet");
|
|
107
|
+
const gateway = new GatewayClient({ network: "testnet", bearer: await getJwt() });
|
|
108
|
+
const { answer, txs } = await runInference({
|
|
109
|
+
prompt: "Reply with a one-sentence fun fact about the ocean.",
|
|
110
|
+
gateway, wallet, publicClient, network: ln.network,
|
|
111
|
+
WebSocket: WS,
|
|
112
|
+
onChunk: (chunk) => process.stdout.write(chunk), // live streaming
|
|
113
|
+
maxRetries: 2, // auto-retry on stall
|
|
114
|
+
});
|
|
115
|
+
console.log("\n", txs); // { createSession, submitJob, jobCompleted }
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The lower-level helpers (`prepareSession`, `submitPrompt`, `decryptResponse`,
|
|
119
|
+
the typed errors `StalledWorkerError` / `OnChainRevertError` / `GatewayAuthError`
|
|
120
|
+
/ `RelayTokenTimeoutError`) stay exported for builders who want a different
|
|
121
|
+
retry policy or to reuse a session across prompts.
|
|
122
|
+
|
|
123
|
+
### Manual mode (the full surface)
|
|
124
|
+
|
|
93
125
|
`v0.3+` ships the encrypted inference-submit flow end to end. Wire-compatible with
|
|
94
126
|
the reference client [`lcai-chat-v2`](https://github.com/lightchain-protocol/lcai-chat-v2)
|
|
95
127
|
(same ECDH-P256 + AES-256-GCM, same gateway endpoints, same `JobRegistry` calls).
|
package/dist/add.d.ts
CHANGED
|
@@ -30,4 +30,22 @@ export declare function addInference(opts?: AddOpts): {
|
|
|
30
30
|
template: Template;
|
|
31
31
|
network: Network;
|
|
32
32
|
};
|
|
33
|
+
export declare function addAnalyticsDashboard(opts?: AddOpts): {
|
|
34
|
+
written: WrittenFile[];
|
|
35
|
+
install: string;
|
|
36
|
+
template: Template;
|
|
37
|
+
network: Network;
|
|
38
|
+
};
|
|
39
|
+
export declare function addChat(opts?: AddOpts): {
|
|
40
|
+
written: WrittenFile[];
|
|
41
|
+
install: string;
|
|
42
|
+
template: Template;
|
|
43
|
+
network: Network;
|
|
44
|
+
};
|
|
45
|
+
export declare function addNftMint(opts?: AddOpts): {
|
|
46
|
+
written: WrittenFile[];
|
|
47
|
+
install: string;
|
|
48
|
+
template: Template;
|
|
49
|
+
network: Network;
|
|
50
|
+
};
|
|
33
51
|
export {};
|
package/dist/add.js
CHANGED
|
@@ -370,3 +370,566 @@ 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
|
+
export function addChat(opts = {}) {
|
|
901
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
902
|
+
const network = opts.network ?? "testnet";
|
|
903
|
+
const template = opts.template && opts.template !== "auto" ? opts.template : detectTemplate(cwd);
|
|
904
|
+
const force = !!opts.force;
|
|
905
|
+
const written = [];
|
|
906
|
+
if (template === "nextjs-api") {
|
|
907
|
+
written.push(writeFile(path.join(cwd, "app/chat/page.tsx"), NEXTJS_CHAT_PAGE, force));
|
|
908
|
+
}
|
|
909
|
+
else {
|
|
910
|
+
written.push(writeFile(path.join(cwd, "chat-repl.ts"), NODE_CHAT_REPL, force));
|
|
911
|
+
}
|
|
912
|
+
written.push(writeFile(path.join(cwd, ".env.example"), ENV_EXAMPLE(network), force));
|
|
913
|
+
return { written, install: `npm install ${depsNeeded(template).join(" ")}`, template, network };
|
|
914
|
+
}
|
|
915
|
+
export function addNftMint(opts = {}) {
|
|
916
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
917
|
+
const network = opts.network ?? "testnet";
|
|
918
|
+
const template = opts.template && opts.template !== "auto" ? opts.template : detectTemplate(cwd);
|
|
919
|
+
const force = !!opts.force;
|
|
920
|
+
const written = [];
|
|
921
|
+
if (template === "nextjs-api") {
|
|
922
|
+
written.push(writeFile(path.join(cwd, "app/api/nft-metadata/route.ts"), NEXTJS_NFT_METADATA_ROUTE, force));
|
|
923
|
+
written.push(writeFile(path.join(cwd, "app/nft-mint/page.tsx"), NEXTJS_NFT_MINT_CLIENT, force));
|
|
924
|
+
}
|
|
925
|
+
else {
|
|
926
|
+
written.push(writeFile(path.join(cwd, "nft-metadata.ts"), NODE_NFT_METADATA_SCRIPT, force));
|
|
927
|
+
}
|
|
928
|
+
written.push(writeFile(path.join(cwd, ".env.example"), ENV_EXAMPLE(network), force));
|
|
929
|
+
return {
|
|
930
|
+
written,
|
|
931
|
+
install: `npm install ${depsNeeded(template).join(" ")}`,
|
|
932
|
+
template,
|
|
933
|
+
network,
|
|
934
|
+
};
|
|
935
|
+
}
|