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
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import * as pty from "node-pty";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { randomUUID } from "crypto";
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const STORIES_DIR = path.join(__dirname, "..", "..", "stories");
|
|
10
|
+
const MAX_SESSIONS = 5;
|
|
11
|
+
const SESSION_FILE = path.join(__dirname, "..", "..", "data", "terminal-sessions.json");
|
|
12
|
+
|
|
13
|
+
const terminal = new Hono();
|
|
14
|
+
|
|
15
|
+
// Active PTY sessions keyed by story name
|
|
16
|
+
const ptySessions = new Map<
|
|
17
|
+
string,
|
|
18
|
+
{ term: pty.IPty; ws: WebSocket | null; state: "running" | "stopped"; sessionId: string }
|
|
19
|
+
>();
|
|
20
|
+
|
|
21
|
+
function safeName(name: string): string | null {
|
|
22
|
+
if (!name || name.includes("..") || name.includes("/") || name.includes("\\") || name.startsWith(".")) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return name;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Load stored session UUIDs from disk */
|
|
29
|
+
function loadSessionMap(): Record<string, string> {
|
|
30
|
+
try {
|
|
31
|
+
if (fs.existsSync(SESSION_FILE)) {
|
|
32
|
+
return JSON.parse(fs.readFileSync(SESSION_FILE, "utf-8"));
|
|
33
|
+
}
|
|
34
|
+
} catch { /* ignore */ }
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Save session UUIDs to disk */
|
|
39
|
+
function saveSessionMap(map: Record<string, string>) {
|
|
40
|
+
try {
|
|
41
|
+
const dir = path.dirname(SESSION_FILE);
|
|
42
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
43
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify(map, null, 2) + "\n");
|
|
44
|
+
} catch { /* ignore */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function spawnPty(storyName: string, opts?: { sessionId?: string; resume?: boolean }) {
|
|
48
|
+
const storyDir = path.join(STORIES_DIR, storyName);
|
|
49
|
+
if (!fs.existsSync(storyDir)) fs.mkdirSync(storyDir, { recursive: true });
|
|
50
|
+
const shell = process.env.SHELL || "/bin/zsh";
|
|
51
|
+
|
|
52
|
+
// Determine session ID
|
|
53
|
+
const sessionMap = loadSessionMap();
|
|
54
|
+
let sessionId: string;
|
|
55
|
+
|
|
56
|
+
// Build Claude CLI command with session flags
|
|
57
|
+
// Note: no --cwd flag — Claude CLI uses process cwd, set via pty.spawn({ cwd: storyDir })
|
|
58
|
+
let claudeCmd = "claude";
|
|
59
|
+
if (opts?.resume && sessionMap[storyName]) {
|
|
60
|
+
// Resume: reuse stored session
|
|
61
|
+
sessionId = sessionMap[storyName];
|
|
62
|
+
claudeCmd += ` --resume "${sessionId}"`;
|
|
63
|
+
} else {
|
|
64
|
+
// Fresh: always generate new UUID
|
|
65
|
+
sessionId = opts?.sessionId || randomUUID();
|
|
66
|
+
claudeCmd += ` --session-id "${sessionId}"`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const term = pty.spawn(shell, ["-l", "-c", claudeCmd], {
|
|
70
|
+
name: "xterm-256color",
|
|
71
|
+
cols: 120,
|
|
72
|
+
rows: 30,
|
|
73
|
+
cwd: storyDir,
|
|
74
|
+
env: process.env as Record<string, string>,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Persist session ID
|
|
78
|
+
sessionMap[storyName] = sessionId;
|
|
79
|
+
saveSessionMap(sessionMap);
|
|
80
|
+
|
|
81
|
+
const isResume = !!opts?.resume;
|
|
82
|
+
const spawnTime = Date.now();
|
|
83
|
+
const session = { term, ws: null as WebSocket | null, state: "running" as const, sessionId };
|
|
84
|
+
ptySessions.set(storyName, session);
|
|
85
|
+
|
|
86
|
+
term.onExit(({ exitCode }) => {
|
|
87
|
+
const s = ptySessions.get(storyName);
|
|
88
|
+
if (s?.term !== term) return;
|
|
89
|
+
|
|
90
|
+
// If a resumed session exits quickly (< 5s), signal client to auto-reconnect fresh
|
|
91
|
+
const elapsed = Date.now() - spawnTime;
|
|
92
|
+
if (isResume && elapsed < 5000 && exitCode !== 0) {
|
|
93
|
+
console.log(`Resume for "${storyName}" failed (exit ${exitCode} in ${elapsed}ms), signaling fresh fallback`);
|
|
94
|
+
ptySessions.delete(storyName);
|
|
95
|
+
if (s.ws && s.ws.readyState <= 1) {
|
|
96
|
+
// Close code 4000 = resume-failed, client should auto-reconnect fresh
|
|
97
|
+
s.ws.close(4000, "resume-failed");
|
|
98
|
+
}
|
|
99
|
+
s.ws = null;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
s.state = "stopped";
|
|
104
|
+
if (s.ws && s.ws.readyState <= 1) {
|
|
105
|
+
s.ws.close(1000, `exited:${exitCode}`);
|
|
106
|
+
}
|
|
107
|
+
s.ws = null;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return session;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** POST /api/terminal/spawn — spawn Claude CLI for a story */
|
|
114
|
+
terminal.post("/spawn", async (c) => {
|
|
115
|
+
const body = await c.req.json<{ storyName?: string; resume?: boolean }>().catch(() => ({}));
|
|
116
|
+
const storyName = safeName(body.storyName || "default");
|
|
117
|
+
if (!storyName) return c.json({ error: "Invalid story name" }, 400);
|
|
118
|
+
|
|
119
|
+
const existing = ptySessions.get(storyName);
|
|
120
|
+
if (existing?.term && existing.state === "running") {
|
|
121
|
+
return c.json({ ok: true, pid: existing.term.pid, storyName, sessionId: existing.sessionId, reused: true });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Enforce max concurrent sessions
|
|
125
|
+
const running = [...ptySessions.values()].filter((s) => s.state === "running").length;
|
|
126
|
+
if (running >= MAX_SESSIONS) {
|
|
127
|
+
return c.json({ error: `Max ${MAX_SESSIONS} concurrent sessions`, ok: false }, 429);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const session = spawnPty(storyName, { resume: body.resume });
|
|
132
|
+
return c.json({ ok: true, pid: session.term.pid, storyName, sessionId: session.sessionId });
|
|
133
|
+
} catch (err: unknown) {
|
|
134
|
+
const message = err instanceof Error ? err.message : "Failed to spawn PTY";
|
|
135
|
+
return c.json({ ok: false, error: message }, 500);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/** GET /api/terminal/session/:storyName — get stored session ID for a story */
|
|
140
|
+
terminal.get("/session/:storyName", (c) => {
|
|
141
|
+
const storyName = safeName(c.req.param("storyName"));
|
|
142
|
+
if (!storyName) return c.json({ error: "Invalid story name" }, 400);
|
|
143
|
+
|
|
144
|
+
const sessionMap = loadSessionMap();
|
|
145
|
+
const sessionId = sessionMap[storyName] || null;
|
|
146
|
+
const active = ptySessions.get(storyName);
|
|
147
|
+
|
|
148
|
+
return c.json({
|
|
149
|
+
sessionId,
|
|
150
|
+
running: !!active && active.state === "running",
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/** DELETE /api/terminal/:storyName — kill a story's PTY */
|
|
155
|
+
terminal.delete("/:storyName", (c) => {
|
|
156
|
+
const storyName = safeName(c.req.param("storyName"));
|
|
157
|
+
if (!storyName) return c.json({ error: "Invalid story name" }, 400);
|
|
158
|
+
|
|
159
|
+
const session = ptySessions.get(storyName);
|
|
160
|
+
if (session?.term && session.state === "running") {
|
|
161
|
+
session.term.kill();
|
|
162
|
+
session.state = "stopped";
|
|
163
|
+
ptySessions.delete(storyName);
|
|
164
|
+
return c.json({ ok: true });
|
|
165
|
+
}
|
|
166
|
+
ptySessions.delete(storyName);
|
|
167
|
+
return c.json({ ok: true, message: "not running" });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/** POST /api/terminal/stop — kill PTY (legacy, kills default) */
|
|
171
|
+
terminal.post("/stop", (c) => {
|
|
172
|
+
const session = ptySessions.get("default");
|
|
173
|
+
if (session?.term && session.state === "running") {
|
|
174
|
+
session.term.kill();
|
|
175
|
+
session.state = "stopped";
|
|
176
|
+
return c.json({ ok: true });
|
|
177
|
+
}
|
|
178
|
+
return c.json({ ok: true, message: "not running" });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/** GET /api/terminal/status — list all sessions */
|
|
182
|
+
terminal.get("/status", (c) => {
|
|
183
|
+
const sessions: Record<string, { running: boolean; pid: number | null; sessionId: string }> = {};
|
|
184
|
+
for (const [name, session] of ptySessions) {
|
|
185
|
+
sessions[name] = {
|
|
186
|
+
running: session.state === "running",
|
|
187
|
+
pid: session.term?.pid ?? null,
|
|
188
|
+
sessionId: session.sessionId,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return c.json({ sessions });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Attach a raw WebSocket to a story's PTY session.
|
|
196
|
+
* Called from server.ts WebSocket upgrade handler.
|
|
197
|
+
*/
|
|
198
|
+
export function attachTerminalWs(ws: WebSocket, storyName?: string, resume?: boolean) {
|
|
199
|
+
const name = storyName && safeName(storyName) ? storyName : "default";
|
|
200
|
+
let session = ptySessions.get(name);
|
|
201
|
+
|
|
202
|
+
// Lazy spawn if no PTY exists
|
|
203
|
+
if (!session || session.state !== "running") {
|
|
204
|
+
// Enforce max concurrent sessions
|
|
205
|
+
const running = [...ptySessions.values()].filter((s) => s.state === "running").length;
|
|
206
|
+
if (running >= MAX_SESSIONS) {
|
|
207
|
+
ws.close(1013, "max-sessions");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
session = spawnPty(name, { resume });
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.error("PTY spawn failed:", err);
|
|
215
|
+
ws.close(1011, "pty-spawn-failed");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Replace previous WS
|
|
221
|
+
if (session.ws && session.ws !== ws && session.ws.readyState <= 1) {
|
|
222
|
+
session.ws.close(1000, "replaced");
|
|
223
|
+
}
|
|
224
|
+
session.ws = ws;
|
|
225
|
+
|
|
226
|
+
// PTY output → browser
|
|
227
|
+
const dataDisposable = session.term.onData((data: string) => {
|
|
228
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
229
|
+
ws.send(data);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Browser input → PTY
|
|
234
|
+
ws.addEventListener("message", (event: MessageEvent) => {
|
|
235
|
+
if (!session?.term || session.state !== "running") return;
|
|
236
|
+
const str = typeof event.data === "string" ? event.data : event.data.toString();
|
|
237
|
+
try {
|
|
238
|
+
const parsed = JSON.parse(str);
|
|
239
|
+
if (parsed.type === "resize" && parsed.cols && parsed.rows) {
|
|
240
|
+
session.term.resize(parsed.cols, parsed.rows);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
// Not JSON — raw input
|
|
245
|
+
}
|
|
246
|
+
session.term.write(str);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Cleanup on close (keep PTY running)
|
|
250
|
+
ws.addEventListener("close", () => {
|
|
251
|
+
dataDisposable.dispose();
|
|
252
|
+
if (session?.ws === ws) {
|
|
253
|
+
session.ws = null;
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
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,14 @@ 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";
|
|
20
|
+
import { settingsRoutes } from "./routes/settings";
|
|
22
21
|
import { initDb } from "./db";
|
|
23
22
|
import { execSync } from "child_process";
|
|
24
23
|
import fs from "fs";
|
|
@@ -26,98 +25,28 @@ import fs from "fs";
|
|
|
26
25
|
const __dirname = __dirnamePre;
|
|
27
26
|
|
|
28
27
|
const app = new Hono();
|
|
29
|
-
const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app });
|
|
30
|
-
|
|
31
28
|
// CORS for local dev
|
|
32
29
|
app.use("/*", cors({ origin: "http://localhost:5173", credentials: true }));
|
|
33
30
|
|
|
34
31
|
// API routes
|
|
35
32
|
app.route("/api/auth", authRoutes);
|
|
36
33
|
// Protected routes
|
|
37
|
-
app.use("/api/config/*", requireAuth);
|
|
38
34
|
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
35
|
app.route("/api/wallet", walletRoutes);
|
|
44
|
-
app.route("/api/oauth", oauthRoutes);
|
|
45
|
-
app.use("/api/chat/*", requireAuth);
|
|
46
36
|
app.use("/api/publish/*", requireAuth);
|
|
47
|
-
app.route("/api/chat", chatRoutes);
|
|
48
37
|
app.route("/api/publish", publishRoutes);
|
|
49
38
|
app.use("/api/dashboard/*", requireAuth);
|
|
50
39
|
app.route("/api/dashboard", dashboardRoutes);
|
|
40
|
+
app.use("/api/terminal/*", requireAuth);
|
|
41
|
+
app.route("/api/terminal", terminalRoutes);
|
|
42
|
+
app.use("/api/stories/*", requireAuth);
|
|
43
|
+
app.route("/api/stories", storiesRoutes);
|
|
44
|
+
app.use("/api/settings/*", requireAuth);
|
|
45
|
+
app.route("/api/settings", settingsRoutes);
|
|
51
46
|
|
|
52
47
|
// Health check
|
|
53
48
|
app.get("/api/health", (c) => c.json({ status: "ok" }));
|
|
54
49
|
|
|
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
50
|
// In production, serve the built frontend
|
|
122
51
|
const distPath = path.join(__dirname, "web", "dist");
|
|
123
52
|
if (fs.existsSync(distPath)) {
|
|
@@ -143,12 +72,37 @@ async function start() {
|
|
|
143
72
|
// Initialize database connection
|
|
144
73
|
await initDb();
|
|
145
74
|
|
|
75
|
+
// Ensure stories directory exists
|
|
76
|
+
const storiesDir = path.join(__dirname, "..", "stories");
|
|
77
|
+
if (!fs.existsSync(storiesDir)) fs.mkdirSync(storiesDir, { recursive: true });
|
|
78
|
+
|
|
146
79
|
const port = Number(process.env.APP_PORT) || 7777;
|
|
147
80
|
const server = serve({ fetch: app.fetch, port }, (info) => {
|
|
148
81
|
console.log(`\n PlotLink OWS running at http://localhost:${info.port}\n`);
|
|
149
82
|
});
|
|
150
83
|
|
|
151
|
-
|
|
84
|
+
// Terminal WebSocket: raw WS on /ws/terminal (bypasses Hono for raw PTY relay)
|
|
85
|
+
const { WebSocketServer } = await import("ws");
|
|
86
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
87
|
+
// server from serve() IS an http.Server
|
|
88
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
89
|
+
(server as any).on("upgrade", (req: any, socket: any, head: any) => {
|
|
90
|
+
const url = new URL(req.url || "", `http://localhost:${port}`);
|
|
91
|
+
if (url.pathname === "/ws/terminal") {
|
|
92
|
+
// Auth check: verify token from query params
|
|
93
|
+
const wsToken = url.searchParams.get("token");
|
|
94
|
+
if (!wsToken) { socket.destroy(); return; }
|
|
95
|
+
import("./db").then(async ({ db }) => {
|
|
96
|
+
const session = await db.session.findUnique({ where: { token: wsToken } });
|
|
97
|
+
if (!session || session.expiresAt < new Date()) { socket.destroy(); return; }
|
|
98
|
+
const story = url.searchParams.get("story") || undefined;
|
|
99
|
+
const resume = url.searchParams.get("resume") === "true";
|
|
100
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
101
|
+
attachTerminalWs(ws as unknown as WebSocket, story, resume);
|
|
102
|
+
});
|
|
103
|
+
}).catch(() => socket.destroy());
|
|
104
|
+
}
|
|
105
|
+
});
|
|
152
106
|
}
|
|
153
107
|
|
|
154
108
|
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 }),
|