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/README.md +34 -2
- package/dist/add.d.ts +24 -0
- package/dist/add.js +732 -0
- package/dist/cli.js +64 -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/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
|
+
}
|