plotlink-ows 0.1.14 → 0.1.18
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 +49 -57
- package/app/db.ts +1 -1
- package/app/lib/paths.ts +0 -2
- package/app/lib/publish.ts +150 -39
- package/app/prisma/schema.prisma +0 -36
- package/app/routes/dashboard.ts +53 -56
- package/app/routes/publish.ts +55 -24
- package/app/routes/stories.ts +156 -0
- package/app/routes/terminal.ts +154 -0
- package/app/routes/wallet.ts +40 -10
- package/app/server.ts +29 -81
- package/app/web/App.tsx +4 -6
- package/app/web/components/Dashboard.tsx +15 -47
- package/app/web/components/Layout.tsx +70 -103
- package/app/web/components/PreviewPanel.tsx +149 -0
- package/app/web/components/Settings.tsx +3 -84
- package/app/web/components/StoriesPage.tsx +157 -0
- package/app/web/components/StoryBrowser.tsx +137 -0
- package/app/web/components/TerminalPanel.tsx +122 -0
- package/app/web/components/WalletCard.tsx +14 -8
- package/app/web/dist/assets/index-D5gfwaEX.css +32 -0
- package/app/web/dist/assets/index-pBt5Q_bN.js +117 -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/bin/plotlink-ows.js +13 -12
- package/package.json +9 -5
- 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/routes/publish.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { streamSSE } from "hono/streaming";
|
|
3
|
-
import {
|
|
4
|
-
import { publishStoryline, getEthBalance, estimatePublishCost } from "../lib/publish";
|
|
3
|
+
import { publishStoryline, publishPlot, getEthBalance, estimatePublishCost } from "../lib/publish";
|
|
5
4
|
import { keccak256, toBytes } from "viem";
|
|
6
5
|
import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
|
|
7
6
|
|
|
@@ -64,41 +63,73 @@ publish.get("/preflight", async (c) => {
|
|
|
64
63
|
}
|
|
65
64
|
});
|
|
66
65
|
|
|
67
|
-
/** POST /api/publish
|
|
68
|
-
publish.post("
|
|
69
|
-
const
|
|
66
|
+
/** POST /api/publish/file — publish a story file on-chain (streams progress) */
|
|
67
|
+
publish.post("/file", async (c) => {
|
|
68
|
+
const body = await c.req.json<{
|
|
69
|
+
storyName: string;
|
|
70
|
+
fileName: string;
|
|
71
|
+
title: string;
|
|
72
|
+
content: string;
|
|
73
|
+
genre?: string;
|
|
74
|
+
storylineId?: number;
|
|
75
|
+
}>();
|
|
70
76
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
if (!body.title || !body.content) {
|
|
78
|
+
return c.json({ error: "title and content required" }, 400);
|
|
79
|
+
}
|
|
74
80
|
|
|
75
81
|
// Get wallet
|
|
76
|
-
|
|
82
|
+
let wallets;
|
|
83
|
+
try {
|
|
84
|
+
wallets = listAgentWallets();
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error("[publish/file] listAgentWallets error:", err);
|
|
87
|
+
return c.json({ error: `OWS wallet error: ${err instanceof Error ? err.message : String(err)}` }, 500);
|
|
88
|
+
}
|
|
77
89
|
const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
|
|
78
90
|
if (!wallet) return c.json({ error: "No OWS wallet" }, 400);
|
|
79
91
|
|
|
92
|
+
console.log("[publish/file] Starting publish for", body.storyName, body.fileName, "wallet:", wallet.name);
|
|
93
|
+
|
|
94
|
+
// Determine if this is genesis (createStoryline) or plot (chainPlot)
|
|
95
|
+
const isPlot = body.fileName.match(/^plot-\d+\.md$/);
|
|
96
|
+
|
|
80
97
|
return streamSSE(c, async (stream) => {
|
|
81
98
|
try {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
99
|
+
let result;
|
|
100
|
+
if (isPlot && body.storylineId) {
|
|
101
|
+
// Chain plot to existing storyline
|
|
102
|
+
result = await publishPlot(
|
|
103
|
+
wallet.name,
|
|
104
|
+
body.storylineId,
|
|
105
|
+
body.title,
|
|
106
|
+
body.content,
|
|
107
|
+
body.genre,
|
|
108
|
+
async (progress) => {
|
|
109
|
+
await stream.writeSSE({ data: JSON.stringify(progress) });
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
} else {
|
|
113
|
+
// Create new storyline (genesis or first file)
|
|
114
|
+
result = await publishStoryline(
|
|
115
|
+
wallet.name,
|
|
116
|
+
body.title,
|
|
117
|
+
body.content,
|
|
118
|
+
body.genre,
|
|
119
|
+
async (progress) => {
|
|
120
|
+
await stream.writeSSE({ data: JSON.stringify(progress) });
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
}
|
|
91
124
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
data: {
|
|
96
|
-
status: "published",
|
|
125
|
+
await stream.writeSSE({
|
|
126
|
+
data: JSON.stringify({
|
|
127
|
+
step: "done",
|
|
97
128
|
txHash: result.txHash,
|
|
98
129
|
storylineId: result.storylineId,
|
|
99
130
|
contentCid: result.contentCid,
|
|
100
131
|
gasCost: result.gasCost,
|
|
101
|
-
},
|
|
132
|
+
}),
|
|
102
133
|
});
|
|
103
134
|
} catch (err: unknown) {
|
|
104
135
|
const message = err instanceof Error ? err.message : "Publish failed";
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const STORIES_DIR = path.join(__dirname, "..", "..", "stories");
|
|
8
|
+
|
|
9
|
+
const stories = new Hono();
|
|
10
|
+
|
|
11
|
+
/** Sanitize path params to prevent directory traversal */
|
|
12
|
+
function safeName(name: string): string | null {
|
|
13
|
+
if (!name || name.includes("..") || name.includes("/") || name.includes("\\") || name.startsWith(".")) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return name;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface FileStatus {
|
|
20
|
+
file: string;
|
|
21
|
+
status: "published" | "pending" | "draft";
|
|
22
|
+
txHash?: string;
|
|
23
|
+
storylineId?: number;
|
|
24
|
+
contentCid?: string;
|
|
25
|
+
publishedAt?: string;
|
|
26
|
+
gasCost?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface StoryInfo {
|
|
30
|
+
name: string;
|
|
31
|
+
files: FileStatus[];
|
|
32
|
+
hasStructure: boolean;
|
|
33
|
+
hasGenesis: boolean;
|
|
34
|
+
plotCount: number;
|
|
35
|
+
publishedCount: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readPublishStatus(storyDir: string): Record<string, FileStatus> {
|
|
39
|
+
const statusFile = path.join(storyDir, ".publish-status.json");
|
|
40
|
+
try {
|
|
41
|
+
if (fs.existsSync(statusFile)) {
|
|
42
|
+
return JSON.parse(fs.readFileSync(statusFile, "utf-8"));
|
|
43
|
+
}
|
|
44
|
+
} catch { /* ignore */ }
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writePublishStatus(storyDir: string, status: Record<string, FileStatus>) {
|
|
49
|
+
const statusFile = path.join(storyDir, ".publish-status.json");
|
|
50
|
+
fs.writeFileSync(statusFile, JSON.stringify(status, null, 2) + "\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function scanStory(storyDir: string, name: string): StoryInfo {
|
|
54
|
+
const publishStatus = readPublishStatus(storyDir);
|
|
55
|
+
const entries = fs.readdirSync(storyDir).filter((f) => f.endsWith(".md"));
|
|
56
|
+
|
|
57
|
+
const files: FileStatus[] = entries.map((file) => {
|
|
58
|
+
const existing = publishStatus[file];
|
|
59
|
+
if (existing?.status === "published") {
|
|
60
|
+
return existing;
|
|
61
|
+
}
|
|
62
|
+
return { file, status: "pending" as const };
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const hasStructure = entries.includes("structure.md");
|
|
66
|
+
const hasGenesis = entries.includes("genesis.md");
|
|
67
|
+
const plotCount = entries.filter((f) => f.match(/^plot-\d+\.md$/)).length;
|
|
68
|
+
const publishedCount = files.filter((f) => f.status === "published").length;
|
|
69
|
+
|
|
70
|
+
return { name, files, hasStructure, hasGenesis, plotCount, publishedCount };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** GET /api/stories — list all stories */
|
|
74
|
+
stories.get("/", (c) => {
|
|
75
|
+
if (!fs.existsSync(STORIES_DIR)) {
|
|
76
|
+
return c.json({ stories: [] });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const dirs = fs.readdirSync(STORIES_DIR, { withFileTypes: true })
|
|
80
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith("."))
|
|
81
|
+
.map((d) => d.name)
|
|
82
|
+
.sort();
|
|
83
|
+
|
|
84
|
+
const result = dirs.map((name) => scanStory(path.join(STORIES_DIR, name), name));
|
|
85
|
+
return c.json({ stories: result });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/** GET /api/stories/:name — single story detail */
|
|
89
|
+
stories.get("/:name", (c) => {
|
|
90
|
+
const name = safeName(c.req.param("name"));
|
|
91
|
+
if (!name) return c.json({ error: "Invalid story name" }, 400);
|
|
92
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
93
|
+
|
|
94
|
+
if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
|
|
95
|
+
return c.json({ error: "Story not found" }, 404);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const info = scanStory(storyDir, name);
|
|
99
|
+
|
|
100
|
+
// Include file contents
|
|
101
|
+
const filesWithContent = info.files.map((f) => {
|
|
102
|
+
const filePath = path.join(storyDir, f.file);
|
|
103
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
104
|
+
return { ...f, content };
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return c.json({ ...info, files: filesWithContent });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** GET /api/stories/:name/:file — single file content */
|
|
111
|
+
stories.get("/:name/:file", (c) => {
|
|
112
|
+
const name = safeName(c.req.param("name"));
|
|
113
|
+
const file = safeName(c.req.param("file"));
|
|
114
|
+
if (!name || !file) return c.json({ error: "Invalid path" }, 400);
|
|
115
|
+
const filePath = path.join(STORIES_DIR, name, file);
|
|
116
|
+
|
|
117
|
+
if (!fs.existsSync(filePath)) {
|
|
118
|
+
return c.json({ error: "File not found" }, 404);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
122
|
+
const publishStatus = readPublishStatus(path.join(STORIES_DIR, name));
|
|
123
|
+
const status = publishStatus[file] || { file, status: "pending" };
|
|
124
|
+
|
|
125
|
+
return c.json({ ...status, content });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/** POST /api/stories/:name/:file/publish-status — update publish status after publishing */
|
|
129
|
+
stories.post("/:name/:file/publish-status", async (c) => {
|
|
130
|
+
const name = safeName(c.req.param("name"));
|
|
131
|
+
const file = safeName(c.req.param("file"));
|
|
132
|
+
if (!name || !file) return c.json({ error: "Invalid path" }, 400);
|
|
133
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
134
|
+
const body = await c.req.json<{
|
|
135
|
+
txHash: string;
|
|
136
|
+
storylineId?: number;
|
|
137
|
+
contentCid: string;
|
|
138
|
+
gasCost?: string;
|
|
139
|
+
}>();
|
|
140
|
+
|
|
141
|
+
const status = readPublishStatus(storyDir);
|
|
142
|
+
status[file] = {
|
|
143
|
+
file,
|
|
144
|
+
status: "published",
|
|
145
|
+
txHash: body.txHash,
|
|
146
|
+
storylineId: body.storylineId,
|
|
147
|
+
contentCid: body.contentCid,
|
|
148
|
+
gasCost: body.gasCost,
|
|
149
|
+
publishedAt: new Date().toISOString(),
|
|
150
|
+
};
|
|
151
|
+
writePublishStatus(storyDir, status);
|
|
152
|
+
|
|
153
|
+
return c.json({ ok: true });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
export { stories as storiesRoutes, readPublishStatus, STORIES_DIR };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import * as pty from "node-pty";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const STORIES_DIR = path.join(__dirname, "..", "..", "stories");
|
|
8
|
+
|
|
9
|
+
const terminal = new Hono();
|
|
10
|
+
|
|
11
|
+
// Active PTY sessions keyed by session ID
|
|
12
|
+
const ptySessions = new Map<
|
|
13
|
+
string,
|
|
14
|
+
{ term: pty.IPty; ws: WebSocket | null; state: "running" | "stopped" }
|
|
15
|
+
>();
|
|
16
|
+
|
|
17
|
+
/** POST /api/terminal/spawn — spawn Claude CLI in stories/ */
|
|
18
|
+
terminal.post("/spawn", (c) => {
|
|
19
|
+
const sessionId = "default";
|
|
20
|
+
|
|
21
|
+
const existing = ptySessions.get(sessionId);
|
|
22
|
+
if (existing?.term && existing.state === "running") {
|
|
23
|
+
return c.json({ ok: true, pid: existing.term.pid, reused: true });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const shell = process.env.SHELL || "/bin/zsh";
|
|
28
|
+
const term = pty.spawn(shell, ["-l", "-c", "claude"], {
|
|
29
|
+
name: "xterm-256color",
|
|
30
|
+
cols: 120,
|
|
31
|
+
rows: 30,
|
|
32
|
+
cwd: STORIES_DIR,
|
|
33
|
+
env: process.env as Record<string, string>,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
ptySessions.set(sessionId, { term, ws: null, state: "running" });
|
|
37
|
+
|
|
38
|
+
term.onExit(({ exitCode }) => {
|
|
39
|
+
const session = ptySessions.get(sessionId);
|
|
40
|
+
if (session?.term === term) {
|
|
41
|
+
session.state = "stopped";
|
|
42
|
+
if (session.ws && session.ws.readyState <= 1) {
|
|
43
|
+
session.ws.close(1000, `exited:${exitCode}`);
|
|
44
|
+
}
|
|
45
|
+
session.ws = null;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return c.json({ ok: true, pid: term.pid });
|
|
50
|
+
} catch (err: unknown) {
|
|
51
|
+
const message = err instanceof Error ? err.message : "Failed to spawn PTY";
|
|
52
|
+
return c.json({ ok: false, error: message }, 500);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** POST /api/terminal/stop — kill PTY */
|
|
57
|
+
terminal.post("/stop", (c) => {
|
|
58
|
+
const session = ptySessions.get("default");
|
|
59
|
+
if (session?.term && session.state === "running") {
|
|
60
|
+
session.term.kill();
|
|
61
|
+
session.state = "stopped";
|
|
62
|
+
return c.json({ ok: true });
|
|
63
|
+
}
|
|
64
|
+
return c.json({ ok: true, message: "not running" });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** GET /api/terminal/status */
|
|
68
|
+
terminal.get("/status", (c) => {
|
|
69
|
+
const session = ptySessions.get("default");
|
|
70
|
+
return c.json({
|
|
71
|
+
running: session?.state === "running",
|
|
72
|
+
pid: session?.term?.pid ?? null,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Attach a raw WebSocket to the PTY session.
|
|
78
|
+
* Called from server.ts WebSocket upgrade handler.
|
|
79
|
+
*/
|
|
80
|
+
export function attachTerminalWs(ws: WebSocket) {
|
|
81
|
+
const sessionId = "default";
|
|
82
|
+
let session = ptySessions.get(sessionId);
|
|
83
|
+
|
|
84
|
+
// Lazy spawn if no PTY exists
|
|
85
|
+
if (!session || session.state !== "running") {
|
|
86
|
+
try {
|
|
87
|
+
const shell = process.env.SHELL || "/bin/zsh";
|
|
88
|
+
const term = pty.spawn(shell, ["-l", "-c", "claude"], {
|
|
89
|
+
name: "xterm-256color",
|
|
90
|
+
cols: 120,
|
|
91
|
+
rows: 30,
|
|
92
|
+
cwd: STORIES_DIR,
|
|
93
|
+
env: process.env as Record<string, string>,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
session = { term, ws: null, state: "running" };
|
|
97
|
+
ptySessions.set(sessionId, session);
|
|
98
|
+
|
|
99
|
+
term.onExit(({ exitCode }) => {
|
|
100
|
+
const s = ptySessions.get(sessionId);
|
|
101
|
+
if (s?.term === term) {
|
|
102
|
+
s.state = "stopped";
|
|
103
|
+
if (s.ws && s.ws.readyState <= 1) {
|
|
104
|
+
s.ws.close(1000, `exited:${exitCode}`);
|
|
105
|
+
}
|
|
106
|
+
s.ws = null;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.error("PTY spawn failed:", err);
|
|
111
|
+
ws.close(1011, "pty-spawn-failed");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Replace previous WS
|
|
117
|
+
if (session.ws && session.ws !== ws && session.ws.readyState <= 1) {
|
|
118
|
+
session.ws.close(1000, "replaced");
|
|
119
|
+
}
|
|
120
|
+
session.ws = ws;
|
|
121
|
+
|
|
122
|
+
// PTY output → browser
|
|
123
|
+
const dataDisposable = session.term.onData((data: string) => {
|
|
124
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
125
|
+
ws.send(data);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Browser input → PTY
|
|
130
|
+
ws.addEventListener("message", (event: MessageEvent) => {
|
|
131
|
+
if (!session?.term || session.state !== "running") return;
|
|
132
|
+
const str = typeof event.data === "string" ? event.data : event.data.toString();
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(str);
|
|
135
|
+
if (parsed.type === "resize" && parsed.cols && parsed.rows) {
|
|
136
|
+
session.term.resize(parsed.cols, parsed.rows);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// Not JSON — raw input
|
|
141
|
+
}
|
|
142
|
+
session.term.write(str);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Cleanup on close (keep PTY running)
|
|
146
|
+
ws.addEventListener("close", () => {
|
|
147
|
+
dataDisposable.dispose();
|
|
148
|
+
if (session?.ws === ws) {
|
|
149
|
+
session.ws = null;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export { terminal as terminalRoutes };
|
package/app/routes/wallet.ts
CHANGED
|
@@ -34,22 +34,50 @@ wallet.get("/", async (c) => {
|
|
|
34
34
|
|
|
35
35
|
const address = getBaseAddress(plotlinkWallet);
|
|
36
36
|
|
|
37
|
-
// Fetch
|
|
37
|
+
// Fetch balances on Base via RPC
|
|
38
|
+
let ethBalance = "0";
|
|
38
39
|
let usdcBalance = "0";
|
|
40
|
+
let plotBalance = "0";
|
|
41
|
+
const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
|
|
42
|
+
|
|
39
43
|
if (address) {
|
|
44
|
+
const addrPadded = "000000000000000000000000" + address.slice(2).toLowerCase();
|
|
45
|
+
const balanceOfSig = "0x70a08231" + addrPadded;
|
|
46
|
+
|
|
40
47
|
try {
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
// ETH balance
|
|
49
|
+
const ethRes = await fetch(rpcUrl, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "Content-Type": "application/json" },
|
|
52
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBalance", params: [address, "latest"] }),
|
|
53
|
+
});
|
|
54
|
+
const ethData = await ethRes.json() as { result?: string };
|
|
55
|
+
if (ethData.result && ethData.result !== "0x" && ethData.result !== "0x0") {
|
|
56
|
+
ethBalance = (Number(BigInt(ethData.result)) / 1e18).toFixed(6);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// USDC balance (6 decimals)
|
|
60
|
+
const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
|
|
61
|
+
const usdcRes = await fetch(rpcUrl, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "Content-Type": "application/json" },
|
|
64
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "eth_call", params: [{ to: USDC_BASE, data: balanceOfSig }, "latest"] }),
|
|
65
|
+
});
|
|
66
|
+
const usdcData = await usdcRes.json() as { result?: string };
|
|
67
|
+
if (usdcData.result && usdcData.result !== "0x") {
|
|
68
|
+
usdcBalance = (Number(BigInt(usdcData.result)) / 1e6).toFixed(2);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// PLOT balance (18 decimals)
|
|
72
|
+
const PLOT = "0x4F567DACBF9D15A6acBe4A47FC2Ade0719Fb63C4";
|
|
73
|
+
const plotRes = await fetch(rpcUrl, {
|
|
45
74
|
method: "POST",
|
|
46
75
|
headers: { "Content-Type": "application/json" },
|
|
47
|
-
body: JSON.stringify({ jsonrpc: "2.0", id:
|
|
76
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "eth_call", params: [{ to: PLOT, data: balanceOfSig }, "latest"] }),
|
|
48
77
|
});
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
usdcBalance = (Number(raw) / 1e6).toFixed(2); // USDC has 6 decimals
|
|
78
|
+
const plotData = await plotRes.json() as { result?: string };
|
|
79
|
+
if (plotData.result && plotData.result !== "0x") {
|
|
80
|
+
plotBalance = (Number(BigInt(plotData.result)) / 1e18).toFixed(4);
|
|
53
81
|
}
|
|
54
82
|
} catch { /* balance fetch best-effort */ }
|
|
55
83
|
}
|
|
@@ -59,7 +87,9 @@ wallet.get("/", async (c) => {
|
|
|
59
87
|
walletId: plotlinkWallet.id,
|
|
60
88
|
name: plotlinkWallet.name,
|
|
61
89
|
address,
|
|
90
|
+
ethBalance,
|
|
62
91
|
usdcBalance,
|
|
92
|
+
plotBalance,
|
|
63
93
|
accounts: plotlinkWallet.accounts,
|
|
64
94
|
});
|
|
65
95
|
} catch (err: unknown) {
|
package/app/server.ts
CHANGED
|
@@ -10,15 +10,13 @@ dotenv.config({ path: ENV_FILE });
|
|
|
10
10
|
import { Hono } from "hono";
|
|
11
11
|
import { cors } from "hono/cors";
|
|
12
12
|
import { serve } from "@hono/node-server";
|
|
13
|
-
import { createNodeWebSocket } from "@hono/node-ws";
|
|
14
13
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
15
14
|
import { authRoutes, requireAuth } from "./routes/auth";
|
|
16
|
-
import { configRoutes } from "./routes/config";
|
|
17
15
|
import { walletRoutes } from "./routes/wallet";
|
|
18
|
-
import { oauthRoutes } from "./routes/oauth";
|
|
19
|
-
import { chatRoutes } from "./routes/chat";
|
|
20
16
|
import { publishRoutes } from "./routes/publish";
|
|
21
17
|
import { dashboardRoutes } from "./routes/dashboard";
|
|
18
|
+
import { terminalRoutes, attachTerminalWs } from "./routes/terminal";
|
|
19
|
+
import { storiesRoutes } from "./routes/stories";
|
|
22
20
|
import { initDb } from "./db";
|
|
23
21
|
import { execSync } from "child_process";
|
|
24
22
|
import fs from "fs";
|
|
@@ -26,98 +24,26 @@ import fs from "fs";
|
|
|
26
24
|
const __dirname = __dirnamePre;
|
|
27
25
|
|
|
28
26
|
const app = new Hono();
|
|
29
|
-
const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app });
|
|
30
|
-
|
|
31
27
|
// CORS for local dev
|
|
32
28
|
app.use("/*", cors({ origin: "http://localhost:5173", credentials: true }));
|
|
33
29
|
|
|
34
30
|
// API routes
|
|
35
31
|
app.route("/api/auth", authRoutes);
|
|
36
32
|
// Protected routes
|
|
37
|
-
app.use("/api/config/*", requireAuth);
|
|
38
33
|
app.use("/api/wallet/*", requireAuth);
|
|
39
|
-
// OAuth: protect start/status but NOT callback (provider redirects without auth)
|
|
40
|
-
app.use("/api/oauth/:provider/start", requireAuth);
|
|
41
|
-
app.use("/api/oauth/:provider/status", requireAuth);
|
|
42
|
-
app.route("/api/config", configRoutes);
|
|
43
34
|
app.route("/api/wallet", walletRoutes);
|
|
44
|
-
app.route("/api/oauth", oauthRoutes);
|
|
45
|
-
app.use("/api/chat/*", requireAuth);
|
|
46
35
|
app.use("/api/publish/*", requireAuth);
|
|
47
|
-
app.route("/api/chat", chatRoutes);
|
|
48
36
|
app.route("/api/publish", publishRoutes);
|
|
49
37
|
app.use("/api/dashboard/*", requireAuth);
|
|
50
38
|
app.route("/api/dashboard", dashboardRoutes);
|
|
39
|
+
app.use("/api/terminal/*", requireAuth);
|
|
40
|
+
app.route("/api/terminal", terminalRoutes);
|
|
41
|
+
app.use("/api/stories/*", requireAuth);
|
|
42
|
+
app.route("/api/stories", storiesRoutes);
|
|
51
43
|
|
|
52
44
|
// Health check
|
|
53
45
|
app.get("/api/health", (c) => c.json({ status: "ok" }));
|
|
54
46
|
|
|
55
|
-
// WebSocket chat endpoint (auth via query param since WS can't send headers)
|
|
56
|
-
app.get(
|
|
57
|
-
"/ws/chat",
|
|
58
|
-
upgradeWebSocket((c) => {
|
|
59
|
-
const wsToken = new URL(c.req.url).searchParams.get("token");
|
|
60
|
-
let authenticated = false;
|
|
61
|
-
|
|
62
|
-
return {
|
|
63
|
-
async onOpen(_event, ws) {
|
|
64
|
-
if (!wsToken) { ws.close(4001, "Missing token"); return; }
|
|
65
|
-
const { db } = await import("./db");
|
|
66
|
-
const session = await db.session.findUnique({ where: { token: wsToken } });
|
|
67
|
-
if (!session || session.expiresAt < new Date()) { ws.close(4001, "Invalid token"); return; }
|
|
68
|
-
authenticated = true;
|
|
69
|
-
},
|
|
70
|
-
async onMessage(event, ws) {
|
|
71
|
-
if (!authenticated) { ws.close(4001, "Not authenticated"); return; }
|
|
72
|
-
try {
|
|
73
|
-
const data = JSON.parse(String(event.data));
|
|
74
|
-
if (data.type === "message" && data.sessionId && data.content) {
|
|
75
|
-
const { db } = await import("./db");
|
|
76
|
-
const { streamChat } = await import("./lib/llm-client");
|
|
77
|
-
const { WRITER_SYSTEM_PROMPT } = await import("./lib/writer-prompt");
|
|
78
|
-
|
|
79
|
-
// Save user message
|
|
80
|
-
await db.message.create({
|
|
81
|
-
data: { sessionId: data.sessionId, role: "user", content: data.content },
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
// Build context
|
|
85
|
-
const messages = await db.message.findMany({
|
|
86
|
-
where: { sessionId: data.sessionId },
|
|
87
|
-
orderBy: { createdAt: "asc" },
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
const chatMessages = [
|
|
91
|
-
{ role: "system" as const, content: WRITER_SYSTEM_PROMPT },
|
|
92
|
-
...messages.map((m: { role: string; content: string }) => ({
|
|
93
|
-
role: m.role as "user" | "assistant" | "system",
|
|
94
|
-
content: m.content,
|
|
95
|
-
})),
|
|
96
|
-
];
|
|
97
|
-
|
|
98
|
-
// Stream response
|
|
99
|
-
let fullResponse = "";
|
|
100
|
-
for await (const chunk of streamChat(chatMessages)) {
|
|
101
|
-
fullResponse += chunk;
|
|
102
|
-
ws.send(JSON.stringify({ type: "chunk", content: chunk }));
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Save assistant message
|
|
106
|
-
await db.message.create({
|
|
107
|
-
data: { sessionId: data.sessionId, role: "assistant", content: fullResponse },
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
ws.send(JSON.stringify({ type: "done" }));
|
|
111
|
-
}
|
|
112
|
-
} catch (err: unknown) {
|
|
113
|
-
const message = err instanceof Error ? err.message : "WebSocket error";
|
|
114
|
-
ws.send(JSON.stringify({ type: "error", message }));
|
|
115
|
-
}
|
|
116
|
-
},
|
|
117
|
-
};
|
|
118
|
-
}),
|
|
119
|
-
);
|
|
120
|
-
|
|
121
47
|
// In production, serve the built frontend
|
|
122
48
|
const distPath = path.join(__dirname, "web", "dist");
|
|
123
49
|
if (fs.existsSync(distPath)) {
|
|
@@ -143,12 +69,34 @@ async function start() {
|
|
|
143
69
|
// Initialize database connection
|
|
144
70
|
await initDb();
|
|
145
71
|
|
|
72
|
+
// Ensure stories directory exists
|
|
73
|
+
const storiesDir = path.join(__dirname, "..", "stories");
|
|
74
|
+
if (!fs.existsSync(storiesDir)) fs.mkdirSync(storiesDir, { recursive: true });
|
|
75
|
+
|
|
146
76
|
const port = Number(process.env.APP_PORT) || 7777;
|
|
147
77
|
const server = serve({ fetch: app.fetch, port }, (info) => {
|
|
148
78
|
console.log(`\n PlotLink OWS running at http://localhost:${info.port}\n`);
|
|
149
79
|
});
|
|
150
80
|
|
|
151
|
-
|
|
81
|
+
// Terminal WebSocket: raw WS on /ws/terminal (bypasses Hono for raw PTY relay)
|
|
82
|
+
const { WebSocketServer } = await import("ws");
|
|
83
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
84
|
+
// server from serve() IS an http.Server
|
|
85
|
+
(server as any).on("upgrade", (req: any, socket: any, head: any) => {
|
|
86
|
+
const url = new URL(req.url || "", `http://localhost:${port}`);
|
|
87
|
+
if (url.pathname === "/ws/terminal") {
|
|
88
|
+
// Auth check: verify token from query params
|
|
89
|
+
const wsToken = url.searchParams.get("token");
|
|
90
|
+
if (!wsToken) { socket.destroy(); return; }
|
|
91
|
+
import("./db").then(async ({ db }) => {
|
|
92
|
+
const session = await db.session.findUnique({ where: { token: wsToken } });
|
|
93
|
+
if (!session || session.expiresAt < new Date()) { socket.destroy(); return; }
|
|
94
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
95
|
+
attachTerminalWs(ws as unknown as WebSocket);
|
|
96
|
+
});
|
|
97
|
+
}).catch(() => socket.destroy());
|
|
98
|
+
}
|
|
99
|
+
});
|
|
152
100
|
}
|
|
153
101
|
|
|
154
102
|
start().catch(console.error);
|
package/app/web/App.tsx
CHANGED
|
@@ -3,8 +3,6 @@ import { Login } from "./components/Login";
|
|
|
3
3
|
import { Setup } from "./components/Setup";
|
|
4
4
|
import { Layout } from "./components/Layout";
|
|
5
5
|
|
|
6
|
-
const API_BASE = "http://localhost:7777";
|
|
7
|
-
|
|
8
6
|
export function App() {
|
|
9
7
|
const [token, setToken] = useState<string | null>(() =>
|
|
10
8
|
localStorage.getItem("ows-token"),
|
|
@@ -14,7 +12,7 @@ export function App() {
|
|
|
14
12
|
|
|
15
13
|
useEffect(() => {
|
|
16
14
|
// Check if passphrase is configured (first-run detection)
|
|
17
|
-
fetch(
|
|
15
|
+
fetch(`/api/auth/status`)
|
|
18
16
|
.then((r) => r.json())
|
|
19
17
|
.then((d) => setConfigured(d.configured))
|
|
20
18
|
.catch(() => setConfigured(null));
|
|
@@ -26,7 +24,7 @@ export function App() {
|
|
|
26
24
|
setChecking(false);
|
|
27
25
|
return;
|
|
28
26
|
}
|
|
29
|
-
fetch(
|
|
27
|
+
fetch(`/api/auth/verify`, {
|
|
30
28
|
headers: { Authorization: `Bearer ${token}` },
|
|
31
29
|
})
|
|
32
30
|
.then((r) => {
|
|
@@ -44,7 +42,7 @@ export function App() {
|
|
|
44
42
|
|
|
45
43
|
const handleLogin = async (passphrase: string): Promise<string | null> => {
|
|
46
44
|
try {
|
|
47
|
-
const res = await fetch(
|
|
45
|
+
const res = await fetch(`/api/auth/login`, {
|
|
48
46
|
method: "POST",
|
|
49
47
|
headers: { "Content-Type": "application/json" },
|
|
50
48
|
body: JSON.stringify({ passphrase }),
|
|
@@ -61,7 +59,7 @@ export function App() {
|
|
|
61
59
|
|
|
62
60
|
const handleSetup = async (passphrase: string): Promise<string | null> => {
|
|
63
61
|
try {
|
|
64
|
-
const res = await fetch(
|
|
62
|
+
const res = await fetch(`/api/auth/setup`, {
|
|
65
63
|
method: "POST",
|
|
66
64
|
headers: { "Content-Type": "application/json" },
|
|
67
65
|
body: JSON.stringify({ passphrase }),
|