plotlink-ows 0.1.15 → 1.0.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 +185 -93
- package/app/db.ts +1 -1
- package/app/lib/paths.ts +0 -2
- package/app/lib/publish.ts +257 -44
- package/app/prisma/schema.prisma +0 -36
- package/app/routes/dashboard.ts +105 -57
- package/app/routes/publish.ts +107 -25
- package/app/routes/settings.ts +194 -0
- package/app/routes/stories.ts +223 -0
- package/app/routes/terminal.ts +258 -0
- package/app/routes/wallet.ts +40 -10
- package/app/server.ts +35 -81
- package/app/web/App.tsx +4 -6
- package/app/web/components/Dashboard.tsx +98 -79
- package/app/web/components/Layout.tsx +70 -103
- package/app/web/components/PreviewPanel.tsx +388 -0
- package/app/web/components/Settings.tsx +210 -67
- package/app/web/components/StoriesPage.tsx +270 -0
- package/app/web/components/StoryBrowser.tsx +161 -0
- package/app/web/components/TerminalPanel.tsx +428 -0
- package/app/web/components/WalletCard.tsx +14 -8
- package/app/web/dist/assets/index-BuOxhUWG.css +32 -0
- package/app/web/dist/assets/index-De8CpT47.js +129 -0
- package/app/web/dist/index.html +3 -3
- package/app/web/dist/plotlink-logo.svg +5 -0
- package/app/web/public/plotlink-logo.svg +5 -0
- package/app/web/styles.css +18 -0
- package/bin/plotlink-ows.js +18 -62
- package/package.json +15 -6
- package/scripts/fix-index-status.ts +93 -0
- package/app/lib/llm-client.ts +0 -265
- package/app/lib/writer-prompt.ts +0 -44
- package/app/routes/chat.ts +0 -135
- package/app/routes/config.ts +0 -210
- package/app/routes/oauth.ts +0 -150
- package/app/web/components/Chat.tsx +0 -272
- package/app/web/components/LLMSetup.tsx +0 -291
- package/app/web/components/Publish.tsx +0 -245
- package/app/web/dist/assets/index-C9kXlYO_.css +0 -2
- package/app/web/dist/assets/index-CJiiaLHs.js +0 -9
package/app/lib/publish.ts
CHANGED
|
@@ -1,38 +1,67 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PlotLink publish flow — uploads content to IPFS and publishes on-chain via OWS wallet.
|
|
3
3
|
*/
|
|
4
|
-
import { createPublicClient,
|
|
4
|
+
import { createPublicClient, createWalletClient, http, keccak256, toBytes, decodeEventLog, serializeTransaction, type Hex } from "viem";
|
|
5
5
|
import { base } from "viem/chains";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
6
|
+
import { toAccount } from "viem/accounts";
|
|
7
|
+
import { storyFactoryAbi, mcv2BondAbi } from "../../packages/cli/src/sdk/abi";
|
|
8
|
+
import {
|
|
9
|
+
signTransaction as owsSignTx,
|
|
10
|
+
signMessage as owsSignMsg,
|
|
11
|
+
} from "@open-wallet-standard/core";
|
|
9
12
|
|
|
10
13
|
// Contract addresses (Base mainnet)
|
|
11
14
|
const STORY_FACTORY = "0x9D2AE1E99D0A6300bfcCF41A82260374e38744Cf" as const;
|
|
12
15
|
const MCV2_BOND = "0xc5a076cad94176c2996B32d8466Be1cE757FAa27" as const;
|
|
13
16
|
|
|
17
|
+
const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
|
|
14
18
|
const publicClient = createPublicClient({
|
|
15
19
|
chain: base,
|
|
16
|
-
transport: http(
|
|
20
|
+
transport: http(rpcUrl),
|
|
17
21
|
});
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
/** Parse OWS signature into viem-compatible r,s,v */
|
|
24
|
+
function parseEvmSignature(sigHex: string, recoveryId?: number): { r: Hex; s: Hex; v: bigint } {
|
|
25
|
+
const sig = sigHex.startsWith("0x") ? sigHex.slice(2) : sigHex;
|
|
26
|
+
const r = `0x${sig.slice(0, 64)}` as Hex;
|
|
27
|
+
const s = `0x${sig.slice(64, 128)}` as Hex;
|
|
28
|
+
// recovery id from OWS or from the last byte of signature
|
|
29
|
+
const v = recoveryId !== undefined ? BigInt(recoveryId + 27) : BigInt(parseInt(sig.slice(128, 130), 16));
|
|
30
|
+
return { r, s, v };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Create a viem-compatible account backed by OWS wallet (same pattern as claw-on-chain) */
|
|
34
|
+
export function createOwsAccount(walletName: string, address: `0x${string}`) {
|
|
35
|
+
const passphrase = process.env.OWS_PASSPHRASE;
|
|
36
|
+
return toAccount({
|
|
37
|
+
address,
|
|
38
|
+
signMessage: async ({ message }) => {
|
|
39
|
+
const msg = typeof message === "string" ? message : typeof message.raw === "string" ? message.raw : Buffer.from(message.raw).toString("hex");
|
|
40
|
+
const result = owsSignMsg(walletName, "eip155:8453", msg, passphrase);
|
|
41
|
+
return (result.signature.startsWith("0x") ? result.signature : `0x${result.signature}`) as Hex;
|
|
42
|
+
},
|
|
43
|
+
signTransaction: async (tx) => {
|
|
44
|
+
const unsigned = serializeTransaction(tx);
|
|
45
|
+
const hexWithout0x = unsigned.startsWith("0x") ? unsigned.slice(2) : unsigned;
|
|
46
|
+
const result = owsSignTx(walletName, "eip155:8453", hexWithout0x, passphrase);
|
|
47
|
+
const sig = parseEvmSignature(result.signature, result.recoveryId);
|
|
48
|
+
return serializeTransaction(tx, sig);
|
|
49
|
+
},
|
|
50
|
+
signTypedData: async () => { throw new Error("signTypedData not implemented"); },
|
|
51
|
+
});
|
|
25
52
|
}
|
|
26
53
|
|
|
27
54
|
export interface PublishResult {
|
|
28
55
|
txHash: string;
|
|
29
56
|
contentCid: string;
|
|
30
57
|
storylineId?: number;
|
|
58
|
+
plotIndex?: number;
|
|
31
59
|
gasCost?: string;
|
|
60
|
+
indexError?: string;
|
|
32
61
|
}
|
|
33
62
|
|
|
34
63
|
export interface PublishProgress {
|
|
35
|
-
step: "uploading" | "estimating" | "signing" | "broadcasting" | "confirming" | "done" | "error";
|
|
64
|
+
step: "uploading" | "estimating" | "signing" | "broadcasting" | "confirming" | "indexing" | "done" | "error";
|
|
36
65
|
message: string;
|
|
37
66
|
txHash?: string;
|
|
38
67
|
contentCid?: string;
|
|
@@ -40,21 +69,98 @@ export interface PublishProgress {
|
|
|
40
69
|
error?: string;
|
|
41
70
|
}
|
|
42
71
|
|
|
72
|
+
// Indexing retry tuning (per #103 RCA — combo A+B):
|
|
73
|
+
// - 8s initial delay lets the on-chain tx propagate to plotlink.xyz's RPC
|
|
74
|
+
// and gives Filebase → public IPFS gateway propagation a head start.
|
|
75
|
+
// - 10 attempts × 30s interval ≈ 4.5 min total, fitting inside plotlink.xyz's
|
|
76
|
+
// 5-min indexable window so users don't escalate to a full Retry Publish
|
|
77
|
+
// (which would mint another chainPlot tx and inflate the on-chain index).
|
|
78
|
+
const INDEX_INITIAL_DELAY_MS = 8_000;
|
|
79
|
+
const INDEX_RETRY_ATTEMPTS = 10;
|
|
80
|
+
const INDEX_RETRY_INTERVAL_MS = 30_000;
|
|
81
|
+
|
|
43
82
|
/**
|
|
44
|
-
*
|
|
83
|
+
* POST to plotlink.xyz's indexer with an initial delay and a retry loop.
|
|
84
|
+
* Streams "Indexing… (attempt N/M)" progress so the publish flow surfaces
|
|
85
|
+
* the in-progress state instead of an immediate failure.
|
|
86
|
+
*
|
|
87
|
+
* Returns undefined on success, or the final error message after all
|
|
88
|
+
* attempts have failed.
|
|
45
89
|
*/
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
90
|
+
async function indexWithDelayAndRetry(
|
|
91
|
+
endpoint: "plot" | "storyline",
|
|
92
|
+
body: Record<string, unknown>,
|
|
93
|
+
onProgress: (progress: PublishProgress) => void,
|
|
94
|
+
txHash: string,
|
|
95
|
+
contentCid: string,
|
|
96
|
+
): Promise<string | undefined> {
|
|
97
|
+
const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
|
|
98
|
+
const url = `${PLOTLINK_URL}/api/index/${endpoint}`;
|
|
99
|
+
|
|
100
|
+
onProgress({
|
|
101
|
+
step: "indexing",
|
|
102
|
+
message: `Indexing… waiting ${INDEX_INITIAL_DELAY_MS / 1000}s for on-chain propagation`,
|
|
103
|
+
txHash,
|
|
104
|
+
contentCid,
|
|
105
|
+
});
|
|
106
|
+
await new Promise((r) => setTimeout(r, INDEX_INITIAL_DELAY_MS));
|
|
107
|
+
|
|
108
|
+
let lastError: string | undefined;
|
|
109
|
+
for (let attempt = 1; attempt <= INDEX_RETRY_ATTEMPTS; attempt++) {
|
|
110
|
+
onProgress({
|
|
111
|
+
step: "indexing",
|
|
112
|
+
message: `Indexing… (attempt ${attempt}/${INDEX_RETRY_ATTEMPTS})`,
|
|
113
|
+
txHash,
|
|
114
|
+
contentCid,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const indexRes = await fetch(url, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { "Content-Type": "application/json" },
|
|
121
|
+
body: JSON.stringify(body),
|
|
122
|
+
});
|
|
123
|
+
const indexBody = await indexRes.json().catch(() => ({})) as Record<string, string>;
|
|
124
|
+
if (indexRes.ok && !indexBody.error) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
lastError = indexBody.error || `Indexing failed: HTTP ${indexRes.status}`;
|
|
128
|
+
} catch (err) {
|
|
129
|
+
lastError = err instanceof Error ? err.message : "Indexing request failed";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (attempt < INDEX_RETRY_ATTEMPTS) {
|
|
133
|
+
await new Promise((r) => setTimeout(r, INDEX_RETRY_INTERVAL_MS));
|
|
134
|
+
}
|
|
50
135
|
}
|
|
51
136
|
|
|
137
|
+
console.error(`Indexing failed for tx ${txHash} after ${INDEX_RETRY_ATTEMPTS} attempts:`, lastError);
|
|
138
|
+
return lastError;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Upload story content to IPFS via PlotLink's API (plotlink.xyz/api/upload).
|
|
143
|
+
* PlotLink handles Filebase credentials server-side.
|
|
144
|
+
*/
|
|
145
|
+
export async function uploadToIPFS(content: string, title: string, genre?: string): Promise<string> {
|
|
146
|
+
const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
|
|
52
147
|
const metadata = JSON.stringify({ title, genre, content });
|
|
53
148
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40);
|
|
54
149
|
const key = `plotlink/storylines/${Date.now()}-${slug}.json`;
|
|
55
150
|
|
|
56
|
-
const
|
|
57
|
-
|
|
151
|
+
const res = await fetch(`${PLOTLINK_URL}/api/upload`, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: { "Content-Type": "application/json" },
|
|
154
|
+
body: JSON.stringify({ content: metadata, key }),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!res.ok) {
|
|
158
|
+
const err = await res.json().catch(() => ({})) as Record<string, string>;
|
|
159
|
+
throw new Error(err.error || `Upload failed: HTTP ${res.status}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const data = await res.json() as { cid: string };
|
|
163
|
+
return data.cid;
|
|
58
164
|
}
|
|
59
165
|
|
|
60
166
|
/**
|
|
@@ -85,7 +191,7 @@ export async function estimatePublishCost(
|
|
|
85
191
|
to: STORY_FACTORY,
|
|
86
192
|
value: creationFee,
|
|
87
193
|
data: encodeFunctionData({
|
|
88
|
-
abi:
|
|
194
|
+
abi: storyFactoryAbi,
|
|
89
195
|
functionName: "createStoryline",
|
|
90
196
|
args: [title, contentCid, contentHash, true],
|
|
91
197
|
}),
|
|
@@ -110,9 +216,9 @@ export async function getEthBalance(address: string): Promise<bigint> {
|
|
|
110
216
|
}
|
|
111
217
|
|
|
112
218
|
/**
|
|
113
|
-
* Wait for
|
|
219
|
+
* Wait for tx confirmation and compute gas cost.
|
|
114
220
|
*/
|
|
115
|
-
async function
|
|
221
|
+
async function waitForReceipt(txHash: string) {
|
|
116
222
|
const receipt = await publicClient.waitForTransactionReceipt({
|
|
117
223
|
hash: txHash as `0x${string}`,
|
|
118
224
|
});
|
|
@@ -123,8 +229,6 @@ async function waitForConfirmation(txHash: string): Promise<{ storylineId: numbe
|
|
|
123
229
|
|
|
124
230
|
// Compute actual total cost: gasUsed * effectiveGasPrice + tx value (creation fee)
|
|
125
231
|
const gasOnly = receipt.gasUsed * receipt.effectiveGasPrice;
|
|
126
|
-
const txValue = receipt.logs.length > 0 ? BigInt(0) : BigInt(0); // value is in the tx itself
|
|
127
|
-
// Include creation fee from tx value — read from the original transaction
|
|
128
232
|
let creationFeeUsed = BigInt(0);
|
|
129
233
|
try {
|
|
130
234
|
const tx = await publicClient.getTransaction({ hash: txHash as `0x${string}` });
|
|
@@ -132,11 +236,19 @@ async function waitForConfirmation(txHash: string): Promise<{ storylineId: numbe
|
|
|
132
236
|
} catch { /* best effort */ }
|
|
133
237
|
const gasCost = (gasOnly + creationFeeUsed).toString();
|
|
134
238
|
|
|
135
|
-
|
|
239
|
+
return { receipt, gasCost };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Wait for storyline creation confirmation — decodes StorylineCreated event.
|
|
244
|
+
*/
|
|
245
|
+
async function waitForStorylineConfirmation(txHash: string): Promise<{ storylineId: number; gasCost: string }> {
|
|
246
|
+
const { receipt, gasCost } = await waitForReceipt(txHash);
|
|
247
|
+
|
|
136
248
|
for (const log of receipt.logs) {
|
|
137
249
|
try {
|
|
138
250
|
const decoded = decodeEventLog({
|
|
139
|
-
abi:
|
|
251
|
+
abi: storyFactoryAbi,
|
|
140
252
|
data: log.data,
|
|
141
253
|
topics: log.topics,
|
|
142
254
|
});
|
|
@@ -148,6 +260,30 @@ async function waitForConfirmation(txHash: string): Promise<{ storylineId: numbe
|
|
|
148
260
|
throw new Error("Transaction succeeded but StorylineCreated event not found");
|
|
149
261
|
}
|
|
150
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Wait for plot chain confirmation — decodes PlotChained event.
|
|
265
|
+
*/
|
|
266
|
+
async function waitForPlotConfirmation(txHash: string): Promise<{ plotIndex: number; gasCost: string }> {
|
|
267
|
+
const { receipt, gasCost } = await waitForReceipt(txHash);
|
|
268
|
+
|
|
269
|
+
for (const log of receipt.logs) {
|
|
270
|
+
try {
|
|
271
|
+
const decoded = decodeEventLog({
|
|
272
|
+
abi: storyFactoryAbi,
|
|
273
|
+
data: log.data,
|
|
274
|
+
topics: log.topics,
|
|
275
|
+
});
|
|
276
|
+
if (decoded.eventName === "PlotChained") {
|
|
277
|
+
// plotIndex is 0-based: genesis=0, plot-01=1, plot-02=2, etc.
|
|
278
|
+
// This matches plotlink.xyz URL convention: /story/{id}/{plotIndex}
|
|
279
|
+
return { plotIndex: Number((decoded.args as { plotIndex: bigint }).plotIndex), gasCost };
|
|
280
|
+
}
|
|
281
|
+
} catch { /* not our event */ }
|
|
282
|
+
}
|
|
283
|
+
// If we can't find PlotChained but receipt succeeded, still return (best effort)
|
|
284
|
+
return { plotIndex: -1, gasCost };
|
|
285
|
+
}
|
|
286
|
+
|
|
151
287
|
/**
|
|
152
288
|
* Publish a new storyline to PlotLink on-chain.
|
|
153
289
|
*/
|
|
@@ -168,37 +304,114 @@ export async function publishStoryline(
|
|
|
168
304
|
onProgress({ step: "estimating", message: "Fetching creation fee and estimating gas..." });
|
|
169
305
|
const creationFee = await getCreationFee();
|
|
170
306
|
|
|
171
|
-
// Step 3:
|
|
172
|
-
|
|
173
|
-
|
|
307
|
+
// Step 3: Create OWS-backed viem wallet client
|
|
308
|
+
onProgress({ step: "signing", message: "Signing transaction with OWS wallet..." });
|
|
309
|
+
const { listAgentWallets, getBaseAddress } = await import("../../lib/ows/wallet");
|
|
310
|
+
const wallets = listAgentWallets();
|
|
311
|
+
const owsWallet = wallets.find((w) => w.name === walletName);
|
|
312
|
+
if (!owsWallet) throw new Error("OWS wallet not found");
|
|
313
|
+
const address = getBaseAddress(owsWallet);
|
|
314
|
+
if (!address) throw new Error("No EVM address on wallet");
|
|
315
|
+
|
|
316
|
+
const account = createOwsAccount(walletName, address as `0x${string}`);
|
|
317
|
+
const walletClient = createWalletClient({ account, chain: base, transport: http(rpcUrl) });
|
|
318
|
+
|
|
319
|
+
// Step 4: Write contract via viem (handles signing + broadcasting)
|
|
320
|
+
onProgress({ step: "broadcasting", message: "Broadcasting transaction..." });
|
|
321
|
+
const txHash = await walletClient.writeContract({
|
|
322
|
+
address: STORY_FACTORY,
|
|
323
|
+
abi: storyFactoryAbi,
|
|
174
324
|
functionName: "createStoryline",
|
|
175
325
|
args: [title, contentCid, contentHash, true],
|
|
326
|
+
value: creationFee,
|
|
176
327
|
});
|
|
177
328
|
|
|
178
|
-
// Step
|
|
179
|
-
onProgress({ step: "
|
|
180
|
-
const
|
|
329
|
+
// Step 5: Wait for confirmation and decode storylineId
|
|
330
|
+
onProgress({ step: "confirming", message: "Waiting for confirmation...", txHash, contentCid });
|
|
331
|
+
const confirmation = await waitForStorylineConfirmation(txHash);
|
|
181
332
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
333
|
+
// Index on PlotLink with delay + retry (per #103 RCA — combo A+B).
|
|
334
|
+
// Streams "Indexing…" progress so the user does not escalate to Retry Publish.
|
|
335
|
+
const indexError = await indexWithDelayAndRetry(
|
|
336
|
+
"storyline",
|
|
337
|
+
{ txHash, content, genre },
|
|
338
|
+
onProgress,
|
|
339
|
+
txHash,
|
|
340
|
+
contentCid,
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
onProgress({
|
|
344
|
+
step: "done",
|
|
345
|
+
message: `Published! Storyline #${confirmation.storylineId}`,
|
|
346
|
+
txHash,
|
|
347
|
+
contentCid,
|
|
348
|
+
storylineId: confirmation.storylineId,
|
|
186
349
|
});
|
|
187
350
|
|
|
351
|
+
return { txHash, contentCid, storylineId: confirmation.storylineId, gasCost: confirmation.gasCost, indexError };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Chain a new plot to an existing storyline on-chain.
|
|
356
|
+
*/
|
|
357
|
+
export async function publishPlot(
|
|
358
|
+
walletName: string,
|
|
359
|
+
storylineId: number,
|
|
360
|
+
title: string,
|
|
361
|
+
content: string,
|
|
362
|
+
genre: string | undefined,
|
|
363
|
+
onProgress: (progress: PublishProgress) => void,
|
|
364
|
+
): Promise<PublishResult> {
|
|
365
|
+
// Step 1: Upload to IPFS
|
|
366
|
+
onProgress({ step: "uploading", message: "Uploading plot to IPFS..." });
|
|
367
|
+
const contentCid = await uploadToIPFS(content, title, genre);
|
|
368
|
+
|
|
369
|
+
// Step 2: Compute content hash
|
|
370
|
+
const contentHash = keccak256(toBytes(content));
|
|
371
|
+
|
|
372
|
+
// Step 3: Create OWS-backed viem wallet client
|
|
373
|
+
onProgress({ step: "signing", message: "Signing transaction with OWS wallet..." });
|
|
374
|
+
const { listAgentWallets, getBaseAddress } = await import("../../lib/ows/wallet");
|
|
375
|
+
const wallets = listAgentWallets();
|
|
376
|
+
const owsWallet = wallets.find((w) => w.name === walletName);
|
|
377
|
+
if (!owsWallet) throw new Error("OWS wallet not found");
|
|
378
|
+
const address = getBaseAddress(owsWallet);
|
|
379
|
+
if (!address) throw new Error("No EVM address on wallet");
|
|
380
|
+
|
|
381
|
+
const account = createOwsAccount(walletName, address as `0x${string}`);
|
|
382
|
+
const walletClient = createWalletClient({ account, chain: base, transport: http(rpcUrl) });
|
|
383
|
+
|
|
384
|
+
// Step 4: Write contract via viem
|
|
188
385
|
onProgress({ step: "broadcasting", message: "Broadcasting transaction..." });
|
|
189
|
-
const
|
|
386
|
+
const txHash = await walletClient.writeContract({
|
|
387
|
+
address: STORY_FACTORY,
|
|
388
|
+
abi: storyFactoryAbi,
|
|
389
|
+
functionName: "chainPlot",
|
|
390
|
+
args: [BigInt(storylineId), title, contentCid, contentHash],
|
|
391
|
+
});
|
|
190
392
|
|
|
191
|
-
// Step 5: Wait for confirmation
|
|
192
|
-
onProgress({ step: "confirming", message: "Waiting for confirmation...", txHash
|
|
193
|
-
const confirmation = await
|
|
393
|
+
// Step 5: Wait for plot confirmation
|
|
394
|
+
onProgress({ step: "confirming", message: "Waiting for confirmation...", txHash, contentCid });
|
|
395
|
+
const confirmation = await waitForPlotConfirmation(txHash);
|
|
396
|
+
|
|
397
|
+
// Index on PlotLink with delay + retry (per #103 RCA — combo A+B).
|
|
398
|
+
// Pass content as fallback because IPFS stores JSON metadata wrapper,
|
|
399
|
+
// but on-chain hash is keccak256 of raw content only.
|
|
400
|
+
const indexError = await indexWithDelayAndRetry(
|
|
401
|
+
"plot",
|
|
402
|
+
{ txHash, content },
|
|
403
|
+
onProgress,
|
|
404
|
+
txHash,
|
|
405
|
+
contentCid,
|
|
406
|
+
);
|
|
194
407
|
|
|
195
408
|
onProgress({
|
|
196
409
|
step: "done",
|
|
197
|
-
message: `
|
|
198
|
-
txHash
|
|
410
|
+
message: `Plot chained to storyline #${storylineId}`,
|
|
411
|
+
txHash,
|
|
199
412
|
contentCid,
|
|
200
|
-
storylineId
|
|
413
|
+
storylineId,
|
|
201
414
|
});
|
|
202
415
|
|
|
203
|
-
return { txHash
|
|
416
|
+
return { txHash, contentCid, storylineId, plotIndex: confirmation.plotIndex >= 0 ? confirmation.plotIndex : undefined, gasCost: confirmation.gasCost, indexError };
|
|
204
417
|
}
|
package/app/prisma/schema.prisma
CHANGED
|
@@ -19,39 +19,3 @@ model Setting {
|
|
|
19
19
|
key String @id
|
|
20
20
|
value String
|
|
21
21
|
}
|
|
22
|
-
|
|
23
|
-
model StorySession {
|
|
24
|
-
id String @id @default(cuid())
|
|
25
|
-
title String @default("Untitled Story")
|
|
26
|
-
genre String?
|
|
27
|
-
status String @default("active") // active, finalized, archived
|
|
28
|
-
createdAt DateTime @default(now())
|
|
29
|
-
updatedAt DateTime @updatedAt
|
|
30
|
-
messages Message[]
|
|
31
|
-
drafts Draft[]
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
model Message {
|
|
35
|
-
id String @id @default(cuid())
|
|
36
|
-
sessionId String
|
|
37
|
-
session StorySession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
|
38
|
-
role String // user, assistant, system
|
|
39
|
-
content String
|
|
40
|
-
createdAt DateTime @default(now())
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
model Draft {
|
|
44
|
-
id String @id @default(cuid())
|
|
45
|
-
sessionId String
|
|
46
|
-
session StorySession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
|
47
|
-
title String
|
|
48
|
-
content String
|
|
49
|
-
genre String?
|
|
50
|
-
status String @default("draft") // draft, ready, published
|
|
51
|
-
txHash String?
|
|
52
|
-
storylineId Int?
|
|
53
|
-
contentCid String?
|
|
54
|
-
gasCost String? // ETH cost in wei
|
|
55
|
-
createdAt DateTime @default(now())
|
|
56
|
-
updatedAt DateTime @updatedAt
|
|
57
|
-
}
|
package/app/routes/dashboard.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { createPublicClient, http } from "viem";
|
|
3
3
|
import { base } from "viem/chains";
|
|
4
|
-
import
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
5
6
|
import { getEthBalance } from "../lib/publish";
|
|
6
7
|
import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
|
|
7
8
|
import { mcv2BondAbi } from "../../packages/cli/src/sdk/abi";
|
|
9
|
+
import { STORIES_DIR, readPublishStatus } from "./stories";
|
|
8
10
|
|
|
9
11
|
const MCV2_BOND = "0xc5a076cad94176c2996B32d8466Be1cE757FAa27" as const;
|
|
10
12
|
// Reserve token for PlotLink bonding curves (PLOT token on Base mainnet)
|
|
@@ -19,10 +21,63 @@ const dashboard = new Hono();
|
|
|
19
21
|
|
|
20
22
|
/** GET /api/dashboard — writer dashboard data */
|
|
21
23
|
dashboard.get("/", async (c) => {
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// Scan stories/ for publish status
|
|
25
|
+
interface PublishedFile {
|
|
26
|
+
storyName: string;
|
|
27
|
+
file: string;
|
|
28
|
+
storyTitle: string;
|
|
29
|
+
storyGenre: string | null;
|
|
30
|
+
plotCount: number;
|
|
31
|
+
status?: string;
|
|
32
|
+
txHash?: string;
|
|
33
|
+
storylineId?: number;
|
|
34
|
+
contentCid?: string;
|
|
35
|
+
publishedAt?: string;
|
|
36
|
+
gasCost?: string;
|
|
37
|
+
}
|
|
38
|
+
const publishedFiles: PublishedFile[] = [];
|
|
39
|
+
let totalFiles = 0;
|
|
40
|
+
let totalStories = 0;
|
|
41
|
+
|
|
42
|
+
if (fs.existsSync(STORIES_DIR)) {
|
|
43
|
+
const dirs = fs.readdirSync(STORIES_DIR, { withFileTypes: true })
|
|
44
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith(".") && d.name !== "_example");
|
|
45
|
+
totalStories = dirs.length;
|
|
46
|
+
|
|
47
|
+
for (const dir of dirs) {
|
|
48
|
+
const storyDir = path.join(STORIES_DIR, dir.name);
|
|
49
|
+
const status = readPublishStatus(storyDir);
|
|
50
|
+
const mdFiles = fs.readdirSync(storyDir).filter((f) => f.endsWith(".md"));
|
|
51
|
+
totalFiles += mdFiles.length;
|
|
52
|
+
|
|
53
|
+
// Read story title and genre from structure.md or genesis.md
|
|
54
|
+
let storyTitle = dir.name;
|
|
55
|
+
let storyGenre: string | null = null;
|
|
56
|
+
try {
|
|
57
|
+
const structPath = path.join(storyDir, "structure.md");
|
|
58
|
+
const genesisPath = path.join(storyDir, "genesis.md");
|
|
59
|
+
if (fs.existsSync(structPath)) {
|
|
60
|
+
const content = fs.readFileSync(structPath, "utf-8");
|
|
61
|
+
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
62
|
+
if (titleMatch) storyTitle = titleMatch[1];
|
|
63
|
+
const genreMatch = content.match(/genre[:\s]+(.+)/i);
|
|
64
|
+
if (genreMatch) storyGenre = genreMatch[1].trim();
|
|
65
|
+
} else if (fs.existsSync(genesisPath)) {
|
|
66
|
+
const content = fs.readFileSync(genesisPath, "utf-8");
|
|
67
|
+
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
68
|
+
if (titleMatch) storyTitle = titleMatch[1];
|
|
69
|
+
}
|
|
70
|
+
} catch { /* best effort */ }
|
|
71
|
+
|
|
72
|
+
const plotCount = mdFiles.filter((f) => /^plot-\d+\.md$/.test(f)).length;
|
|
73
|
+
|
|
74
|
+
for (const [file, info] of Object.entries(status)) {
|
|
75
|
+
if (info.status === "published" || info.status === "published-not-indexed") {
|
|
76
|
+
publishedFiles.push({ storyName: dir.name, file, storyTitle, storyGenre, plotCount, ...info });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
26
81
|
|
|
27
82
|
// Get wallet info
|
|
28
83
|
let walletInfo = null;
|
|
@@ -61,13 +116,9 @@ dashboard.get("/", async (c) => {
|
|
|
61
116
|
}
|
|
62
117
|
} catch { /* wallet not available */ }
|
|
63
118
|
|
|
64
|
-
//
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// Compute total costs
|
|
69
|
-
const totalGasCostWei = published.reduce((sum, d) => {
|
|
70
|
-
if (d.gasCost) return sum + BigInt(d.gasCost);
|
|
119
|
+
// Compute total costs from published files
|
|
120
|
+
const totalGasCostWei = publishedFiles.reduce((sum, f) => {
|
|
121
|
+
if (f.gasCost) return sum + BigInt(f.gasCost);
|
|
71
122
|
return sum;
|
|
72
123
|
}, BigInt(0));
|
|
73
124
|
const totalGasCostEth = (Number(totalGasCostWei) / 1e18).toFixed(6);
|
|
@@ -111,49 +162,61 @@ dashboard.get("/", async (c) => {
|
|
|
111
162
|
const totalRoyaltiesUsd = parseFloat(royaltiesEarned) * plotUsdPrice;
|
|
112
163
|
const netPnlUsd = totalRoyaltiesUsd - totalCostUsd;
|
|
113
164
|
|
|
114
|
-
// Session stats
|
|
115
|
-
const sessions = await db.storySession.findMany({
|
|
116
|
-
include: { _count: { select: { messages: true } } },
|
|
117
|
-
});
|
|
118
|
-
|
|
119
165
|
return c.json({
|
|
120
166
|
wallet: walletInfo,
|
|
121
167
|
stories: {
|
|
122
|
-
published:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
168
|
+
published: (() => {
|
|
169
|
+
// Group by storylineId when present, fall back to storyName
|
|
170
|
+
const grouped = new Map<string, typeof publishedFiles>();
|
|
171
|
+
for (const f of publishedFiles) {
|
|
172
|
+
const key = f.storylineId ? `sid:${f.storylineId}` : `name:${f.storyName}`;
|
|
173
|
+
const group = grouped.get(key) || [];
|
|
174
|
+
group.push(f);
|
|
175
|
+
grouped.set(key, group);
|
|
176
|
+
}
|
|
177
|
+
return [...grouped.entries()].map(([, files]) => {
|
|
178
|
+
const first = files[0];
|
|
179
|
+
const totalGas = files.reduce((sum, f) => f.gasCost ? sum + BigInt(f.gasCost) : sum, BigInt(0));
|
|
180
|
+
const latestDate = files.reduce((latest, f) =>
|
|
181
|
+
f.publishedAt && (!latest || f.publishedAt > latest) ? f.publishedAt : latest, null as string | null);
|
|
182
|
+
const hasNotIndexed = files.some((f) => f.status === "published-not-indexed");
|
|
183
|
+
return {
|
|
184
|
+
id: first.storylineId ? `sid:${first.storylineId}` : first.storyName,
|
|
185
|
+
title: first.storyTitle,
|
|
186
|
+
genre: first.storyGenre,
|
|
187
|
+
storyName: first.storyName,
|
|
188
|
+
storylineId: first.storylineId,
|
|
189
|
+
plotCount: first.plotCount,
|
|
190
|
+
publishedFiles: files.length,
|
|
191
|
+
hasNotIndexed,
|
|
192
|
+
totalGasCostEth: totalGas > 0 ? (Number(totalGas) / 1e18).toFixed(6) : null,
|
|
193
|
+
totalGasCostUsd: totalGas > 0 && ethUsdPrice ? ((Number(totalGas) / 1e18) * ethUsdPrice).toFixed(2) : null,
|
|
194
|
+
latestPublishedAt: latestDate,
|
|
195
|
+
files: files.map((f) => ({
|
|
196
|
+
file: f.file,
|
|
197
|
+
status: f.status || "published",
|
|
198
|
+
txHash: f.txHash,
|
|
199
|
+
gasCostEth: f.gasCost ? (Number(BigInt(f.gasCost)) / 1e18).toFixed(6) : null,
|
|
200
|
+
publishedAt: f.publishedAt || null,
|
|
201
|
+
})),
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
})(),
|
|
205
|
+
totalPublished: publishedFiles.length,
|
|
206
|
+
totalStories,
|
|
207
|
+
totalFiles,
|
|
208
|
+
pendingFiles: totalFiles - publishedFiles.length,
|
|
145
209
|
},
|
|
146
210
|
costs: {
|
|
147
211
|
totalGasCostWei: totalGasCostWei.toString(),
|
|
148
212
|
totalGasCostEth,
|
|
149
213
|
totalCostUsd: totalCostUsd.toFixed(2),
|
|
150
214
|
ethUsdPrice,
|
|
151
|
-
storiesPublished:
|
|
215
|
+
storiesPublished: publishedFiles.length,
|
|
152
216
|
},
|
|
153
217
|
royalties: {
|
|
154
218
|
earned: royaltiesEarned,
|
|
155
219
|
claimed: royaltiesClaimed,
|
|
156
|
-
// unclaimed = earned - claimed (already correct since earned = unclaimed + claimed)
|
|
157
220
|
unclaimed: (parseFloat(royaltiesEarned) - parseFloat(royaltiesClaimed)).toFixed(6),
|
|
158
221
|
token: "PLOT",
|
|
159
222
|
},
|
|
@@ -165,22 +228,7 @@ dashboard.get("/", async (c) => {
|
|
|
165
228
|
netPnlUsd: netPnlUsd.toFixed(2),
|
|
166
229
|
plotUsdPrice: plotUsdPrice.toFixed(4),
|
|
167
230
|
},
|
|
168
|
-
sessions: {
|
|
169
|
-
total: sessions.length,
|
|
170
|
-
totalMessages: sessions.reduce((sum, s) => sum + s._count.messages, 0),
|
|
171
|
-
},
|
|
172
231
|
});
|
|
173
232
|
});
|
|
174
233
|
|
|
175
|
-
/** DELETE /api/dashboard/drafts/:id — delete a draft */
|
|
176
|
-
dashboard.delete("/drafts/:id", async (c) => {
|
|
177
|
-
const id = c.req.param("id");
|
|
178
|
-
try {
|
|
179
|
-
await db.draft.delete({ where: { id } });
|
|
180
|
-
return c.json({ success: true });
|
|
181
|
-
} catch {
|
|
182
|
-
return c.json({ error: "Draft not found" }, 404);
|
|
183
|
-
}
|
|
184
|
-
});
|
|
185
|
-
|
|
186
234
|
export { dashboard as dashboardRoutes };
|