plotlink-ows 0.1.13
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/LICENSE +21 -0
- package/README.md +151 -0
- package/app/db.ts +8 -0
- package/app/lib/llm-client.ts +265 -0
- package/app/lib/paths.ts +11 -0
- package/app/lib/publish.ts +204 -0
- package/app/lib/writer-prompt.ts +44 -0
- package/app/node_modules/.prisma/local-client/client.d.ts +1 -0
- package/app/node_modules/.prisma/local-client/client.js +5 -0
- package/app/node_modules/.prisma/local-client/default.d.ts +1 -0
- package/app/node_modules/.prisma/local-client/default.js +5 -0
- package/app/node_modules/.prisma/local-client/edge.d.ts +1 -0
- package/app/node_modules/.prisma/local-client/edge.js +184 -0
- package/app/node_modules/.prisma/local-client/index-browser.js +173 -0
- package/app/node_modules/.prisma/local-client/index.d.ts +3304 -0
- package/app/node_modules/.prisma/local-client/index.js +207 -0
- package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
- package/app/node_modules/.prisma/local-client/package.json +183 -0
- package/app/node_modules/.prisma/local-client/query_engine_bg.js +2 -0
- package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
- package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +35 -0
- package/app/node_modules/.prisma/local-client/runtime/edge.js +35 -0
- package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +370 -0
- package/app/node_modules/.prisma/local-client/runtime/index-browser.js +17 -0
- package/app/node_modules/.prisma/local-client/runtime/library.d.ts +3982 -0
- package/app/node_modules/.prisma/local-client/runtime/library.js +147 -0
- package/app/node_modules/.prisma/local-client/runtime/react-native.js +84 -0
- package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +85 -0
- package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +38 -0
- package/app/node_modules/.prisma/local-client/schema.prisma +21 -0
- package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +5 -0
- package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +5 -0
- package/app/node_modules/.prisma/local-client/wasm.d.ts +1 -0
- package/app/node_modules/.prisma/local-client/wasm.js +191 -0
- package/app/prisma/schema.prisma +57 -0
- package/app/routes/auth.ts +173 -0
- package/app/routes/chat.ts +135 -0
- package/app/routes/config.ts +210 -0
- package/app/routes/dashboard.ts +186 -0
- package/app/routes/oauth.ts +150 -0
- package/app/routes/publish.ts +112 -0
- package/app/routes/wallet.ts +99 -0
- package/app/server.ts +154 -0
- package/app/vite.config.ts +19 -0
- package/app/web/App.tsx +102 -0
- package/app/web/components/Chat.tsx +272 -0
- package/app/web/components/Dashboard.tsx +222 -0
- package/app/web/components/LLMSetup.tsx +291 -0
- package/app/web/components/Layout.tsx +235 -0
- package/app/web/components/Login.tsx +62 -0
- package/app/web/components/Publish.tsx +245 -0
- package/app/web/components/Settings.tsx +175 -0
- package/app/web/components/Setup.tsx +84 -0
- package/app/web/components/WalletCard.tsx +117 -0
- package/app/web/dist/assets/index-C9kXlYO_.css +2 -0
- package/app/web/dist/assets/index-CJiiaLHs.js +9 -0
- package/app/web/dist/index.html +16 -0
- package/app/web/index.html +15 -0
- package/app/web/main.tsx +10 -0
- package/app/web/plotlink-logo.svg +5 -0
- package/app/web/styles.css +51 -0
- package/bin/plotlink-ows.js +394 -0
- package/lib/ows/index.ts +3 -0
- package/lib/ows/policy.ts +68 -0
- package/lib/ows/types.ts +14 -0
- package/lib/ows/wallet.ts +70 -0
- package/package.json +79 -0
- package/packages/cli/node_modules/commander/LICENSE +22 -0
- package/packages/cli/node_modules/commander/Readme.md +1149 -0
- package/packages/cli/node_modules/commander/esm.mjs +16 -0
- package/packages/cli/node_modules/commander/index.js +24 -0
- package/packages/cli/node_modules/commander/lib/argument.js +149 -0
- package/packages/cli/node_modules/commander/lib/command.js +2662 -0
- package/packages/cli/node_modules/commander/lib/error.js +39 -0
- package/packages/cli/node_modules/commander/lib/help.js +709 -0
- package/packages/cli/node_modules/commander/lib/option.js +367 -0
- package/packages/cli/node_modules/commander/lib/suggestSimilar.js +101 -0
- package/packages/cli/node_modules/commander/package-support.json +16 -0
- package/packages/cli/node_modules/commander/package.json +82 -0
- package/packages/cli/node_modules/commander/typings/esm.d.mts +3 -0
- package/packages/cli/node_modules/commander/typings/index.d.ts +1045 -0
- package/packages/cli/node_modules/resolve-from/index.d.ts +31 -0
- package/packages/cli/node_modules/resolve-from/index.js +47 -0
- package/packages/cli/node_modules/resolve-from/license +9 -0
- package/packages/cli/node_modules/resolve-from/package.json +36 -0
- package/packages/cli/node_modules/resolve-from/readme.md +72 -0
- package/packages/cli/node_modules/tsup/LICENSE +21 -0
- package/packages/cli/node_modules/tsup/README.md +75 -0
- package/packages/cli/node_modules/tsup/assets/cjs_shims.js +13 -0
- package/packages/cli/node_modules/tsup/assets/esm_shims.js +9 -0
- package/packages/cli/node_modules/tsup/assets/package.json +3 -0
- package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +153 -0
- package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +42 -0
- package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +6 -0
- package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +352 -0
- package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +203 -0
- package/packages/cli/node_modules/tsup/dist/cli-default.js +12 -0
- package/packages/cli/node_modules/tsup/dist/cli-main.js +8 -0
- package/packages/cli/node_modules/tsup/dist/cli-node.js +14 -0
- package/packages/cli/node_modules/tsup/dist/index.d.ts +511 -0
- package/packages/cli/node_modules/tsup/dist/index.js +1711 -0
- package/packages/cli/node_modules/tsup/dist/rollup.js +6949 -0
- package/packages/cli/node_modules/tsup/package.json +99 -0
- package/packages/cli/node_modules/tsup/schema.json +362 -0
- package/packages/cli/package.json +35 -0
- package/packages/cli/src/commands/agent-register.ts +77 -0
- package/packages/cli/src/commands/chain.ts +29 -0
- package/packages/cli/src/commands/claim.ts +70 -0
- package/packages/cli/src/commands/create.ts +34 -0
- package/packages/cli/src/commands/status.ts +201 -0
- package/packages/cli/src/config.ts +103 -0
- package/packages/cli/src/index.ts +21 -0
- package/packages/cli/src/sdk/abi.ts +222 -0
- package/packages/cli/src/sdk/client.ts +713 -0
- package/packages/cli/src/sdk/constants.ts +56 -0
- package/packages/cli/src/sdk/index.ts +46 -0
- package/packages/cli/src/sdk/ipfs.ts +88 -0
- package/packages/cli/src/sdk.ts +36 -0
- package/packages/cli/tsconfig.json +20 -0
- package/packages/cli/tsup.config.ts +14 -0
- package/public/.well-known/farcaster.json +38 -0
- package/public/basescan-icon.svg +4 -0
- package/public/embed-image.png +0 -0
- package/public/favicon.png +0 -0
- package/public/hunt-token.svg +11 -0
- package/public/icon-192.png +0 -0
- package/public/icon.png +0 -0
- package/public/manifest.json +26 -0
- package/public/mc-icon-light.svg +12 -0
- package/public/og-image.png +0 -0
- package/public/plotlink-logo-symbol.svg +5 -0
- package/public/plotlink-logo.svg +5 -0
- package/public/screenshot-1.png +0 -0
- package/public/screenshot-2.png +0 -0
- package/public/screenshot-3.png +0 -0
- package/public/splash.png +0 -0
- package/public/wide-banner.png +0 -0
- package/scripts/backfill-trade-prices.ts +97 -0
- package/scripts/backfill-usd-rates.ts +220 -0
- package/scripts/e2e-verify.ts +1100 -0
- package/scripts/ows-smoke-test.ts +37 -0
- package/scripts/score-users.mjs +203 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { ENV_FILE } from "../lib/paths";
|
|
4
|
+
|
|
5
|
+
const envPath = ENV_FILE;
|
|
6
|
+
|
|
7
|
+
const wallet = new Hono();
|
|
8
|
+
|
|
9
|
+
function readEnvPassphrase(): string | null {
|
|
10
|
+
if (process.env.OWS_PASSPHRASE) return process.env.OWS_PASSPHRASE;
|
|
11
|
+
try {
|
|
12
|
+
if (fs.existsSync(envPath)) {
|
|
13
|
+
const content = fs.readFileSync(envPath, "utf-8");
|
|
14
|
+
const match = content.match(/^OWS_PASSPHRASE=(.+)$/m);
|
|
15
|
+
if (match) return match[1].trim();
|
|
16
|
+
}
|
|
17
|
+
} catch { /* ignore */ }
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** GET /api/wallet — get wallet info */
|
|
22
|
+
wallet.get("/", async (c) => {
|
|
23
|
+
try {
|
|
24
|
+
const { getAgentWallet, getBaseAddress } = await import("../../lib/ows/wallet");
|
|
25
|
+
|
|
26
|
+
// Try to find existing wallet
|
|
27
|
+
const { listAgentWallets } = await import("../../lib/ows/wallet");
|
|
28
|
+
const wallets = listAgentWallets();
|
|
29
|
+
const plotlinkWallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
|
|
30
|
+
|
|
31
|
+
if (!plotlinkWallet) {
|
|
32
|
+
return c.json({ exists: false });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const address = getBaseAddress(plotlinkWallet);
|
|
36
|
+
|
|
37
|
+
// Fetch USDC balance on Base via RPC
|
|
38
|
+
let usdcBalance = "0";
|
|
39
|
+
if (address) {
|
|
40
|
+
try {
|
|
41
|
+
const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base mainnet
|
|
42
|
+
const balanceOfSig = "0x70a08231000000000000000000000000" + address.slice(2).toLowerCase();
|
|
43
|
+
const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
|
|
44
|
+
const res = await fetch(rpcUrl, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: { "Content-Type": "application/json" },
|
|
47
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_call", params: [{ to: USDC_BASE, data: balanceOfSig }, "latest"] }),
|
|
48
|
+
});
|
|
49
|
+
const data = await res.json() as { result?: string };
|
|
50
|
+
if (data.result && data.result !== "0x") {
|
|
51
|
+
const raw = BigInt(data.result);
|
|
52
|
+
usdcBalance = (Number(raw) / 1e6).toFixed(2); // USDC has 6 decimals
|
|
53
|
+
}
|
|
54
|
+
} catch { /* balance fetch best-effort */ }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return c.json({
|
|
58
|
+
exists: true,
|
|
59
|
+
walletId: plotlinkWallet.id,
|
|
60
|
+
name: plotlinkWallet.name,
|
|
61
|
+
address,
|
|
62
|
+
usdcBalance,
|
|
63
|
+
accounts: plotlinkWallet.accounts,
|
|
64
|
+
});
|
|
65
|
+
} catch (err: unknown) {
|
|
66
|
+
const message = err instanceof Error ? err.message : "Failed to get wallet";
|
|
67
|
+
return c.json({ exists: false, error: message });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** POST /api/wallet/create — create OWS wallet */
|
|
72
|
+
wallet.post("/create", async (c) => {
|
|
73
|
+
try {
|
|
74
|
+
const passphrase = readEnvPassphrase();
|
|
75
|
+
if (!passphrase) {
|
|
76
|
+
return c.json({ error: "Passphrase not configured" }, 400);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { createAgentWallet, getBaseAddress, listAgentWallets } = await import("../../lib/ows/wallet");
|
|
80
|
+
|
|
81
|
+
// Check if wallet already exists
|
|
82
|
+
const wallets = listAgentWallets();
|
|
83
|
+
const existing = wallets.find((w) => w.name.startsWith("plotlink-writer"));
|
|
84
|
+
if (existing) {
|
|
85
|
+
const address = getBaseAddress(existing);
|
|
86
|
+
return c.json({ walletId: existing.id, address, alreadyExisted: true });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const wallet = createAgentWallet("plotlink-writer", passphrase);
|
|
90
|
+
const address = getBaseAddress(wallet);
|
|
91
|
+
|
|
92
|
+
return c.json({ walletId: wallet.id, address, alreadyExisted: false });
|
|
93
|
+
} catch (err: unknown) {
|
|
94
|
+
const message = err instanceof Error ? err.message : "Wallet creation failed";
|
|
95
|
+
return c.json({ error: message }, 500);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export { wallet as walletRoutes };
|
package/app/server.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import dotenv from "dotenv";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { ENV_FILE } from "./lib/paths";
|
|
5
|
+
|
|
6
|
+
// Load .env from ~/.plotlink-ows/ before anything else
|
|
7
|
+
const __dirnamePre = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
dotenv.config({ path: ENV_FILE });
|
|
9
|
+
|
|
10
|
+
import { Hono } from "hono";
|
|
11
|
+
import { cors } from "hono/cors";
|
|
12
|
+
import { serve } from "@hono/node-server";
|
|
13
|
+
import { createNodeWebSocket } from "@hono/node-ws";
|
|
14
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
15
|
+
import { authRoutes, requireAuth } from "./routes/auth";
|
|
16
|
+
import { configRoutes } from "./routes/config";
|
|
17
|
+
import { walletRoutes } from "./routes/wallet";
|
|
18
|
+
import { oauthRoutes } from "./routes/oauth";
|
|
19
|
+
import { chatRoutes } from "./routes/chat";
|
|
20
|
+
import { publishRoutes } from "./routes/publish";
|
|
21
|
+
import { dashboardRoutes } from "./routes/dashboard";
|
|
22
|
+
import { initDb } from "./db";
|
|
23
|
+
import { execSync } from "child_process";
|
|
24
|
+
import fs from "fs";
|
|
25
|
+
|
|
26
|
+
const __dirname = __dirnamePre;
|
|
27
|
+
|
|
28
|
+
const app = new Hono();
|
|
29
|
+
const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app });
|
|
30
|
+
|
|
31
|
+
// CORS for local dev
|
|
32
|
+
app.use("/*", cors({ origin: "http://localhost:5173", credentials: true }));
|
|
33
|
+
|
|
34
|
+
// API routes
|
|
35
|
+
app.route("/api/auth", authRoutes);
|
|
36
|
+
// Protected routes
|
|
37
|
+
app.use("/api/config/*", requireAuth);
|
|
38
|
+
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
|
+
app.route("/api/wallet", walletRoutes);
|
|
44
|
+
app.route("/api/oauth", oauthRoutes);
|
|
45
|
+
app.use("/api/chat/*", requireAuth);
|
|
46
|
+
app.use("/api/publish/*", requireAuth);
|
|
47
|
+
app.route("/api/chat", chatRoutes);
|
|
48
|
+
app.route("/api/publish", publishRoutes);
|
|
49
|
+
app.use("/api/dashboard/*", requireAuth);
|
|
50
|
+
app.route("/api/dashboard", dashboardRoutes);
|
|
51
|
+
|
|
52
|
+
// Health check
|
|
53
|
+
app.get("/api/health", (c) => c.json({ status: "ok" }));
|
|
54
|
+
|
|
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
|
+
// In production, serve the built frontend
|
|
122
|
+
const distPath = path.join(__dirname, "web", "dist");
|
|
123
|
+
if (fs.existsSync(distPath)) {
|
|
124
|
+
app.use("/*", serveStatic({ root: "./app/web/dist" }));
|
|
125
|
+
app.get("*", (c) => {
|
|
126
|
+
const html = fs.readFileSync(path.join(distPath, "index.html"), "utf-8");
|
|
127
|
+
return c.html(html);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function start() {
|
|
132
|
+
// Ensure data directory exists
|
|
133
|
+
const dataDir = path.join(__dirname, "..", "data");
|
|
134
|
+
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
135
|
+
|
|
136
|
+
// Run Prisma db push to ensure schema is up to date
|
|
137
|
+
const schemaPath = path.join(__dirname, "prisma", "schema.prisma");
|
|
138
|
+
execSync(`npx prisma db push --schema ${schemaPath} --skip-generate`, {
|
|
139
|
+
stdio: "inherit",
|
|
140
|
+
env: { ...process.env, DATABASE_URL: `file:${path.join(dataDir, "local.db")}` },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Initialize database connection
|
|
144
|
+
await initDb();
|
|
145
|
+
|
|
146
|
+
const port = Number(process.env.APP_PORT) || 7777;
|
|
147
|
+
const server = serve({ fetch: app.fetch, port }, (info) => {
|
|
148
|
+
console.log(`\n PlotLink OWS running at http://localhost:${info.port}\n`);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
injectWebSocket(server);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
start().catch(console.error);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import react from "@vitejs/plugin-react";
|
|
3
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
root: "app/web",
|
|
7
|
+
plugins: [react(), tailwindcss()],
|
|
8
|
+
server: {
|
|
9
|
+
port: 5173,
|
|
10
|
+
proxy: {
|
|
11
|
+
"/api": "http://localhost:7777",
|
|
12
|
+
"/ws": { target: "ws://localhost:7777", ws: true },
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
build: {
|
|
16
|
+
outDir: "dist",
|
|
17
|
+
emptyOutDir: true,
|
|
18
|
+
},
|
|
19
|
+
});
|
package/app/web/App.tsx
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { Login } from "./components/Login";
|
|
3
|
+
import { Setup } from "./components/Setup";
|
|
4
|
+
import { Layout } from "./components/Layout";
|
|
5
|
+
|
|
6
|
+
const API_BASE = "http://localhost:7777";
|
|
7
|
+
|
|
8
|
+
export function App() {
|
|
9
|
+
const [token, setToken] = useState<string | null>(() =>
|
|
10
|
+
localStorage.getItem("ows-token"),
|
|
11
|
+
);
|
|
12
|
+
const [configured, setConfigured] = useState<boolean | null>(null);
|
|
13
|
+
const [checking, setChecking] = useState(true);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
// Check if passphrase is configured (first-run detection)
|
|
17
|
+
fetch(`${API_BASE}/api/auth/status`)
|
|
18
|
+
.then((r) => r.json())
|
|
19
|
+
.then((d) => setConfigured(d.configured))
|
|
20
|
+
.catch(() => setConfigured(null));
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!token) {
|
|
25
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync guard before async work
|
|
26
|
+
setChecking(false);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
fetch(`${API_BASE}/api/auth/verify`, {
|
|
30
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
31
|
+
})
|
|
32
|
+
.then((r) => {
|
|
33
|
+
if (!r.ok) {
|
|
34
|
+
localStorage.removeItem("ows-token");
|
|
35
|
+
setToken(null);
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
.catch(() => {
|
|
39
|
+
localStorage.removeItem("ows-token");
|
|
40
|
+
setToken(null);
|
|
41
|
+
})
|
|
42
|
+
.finally(() => setChecking(false));
|
|
43
|
+
}, [token]);
|
|
44
|
+
|
|
45
|
+
const handleLogin = async (passphrase: string): Promise<string | null> => {
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: { "Content-Type": "application/json" },
|
|
50
|
+
body: JSON.stringify({ passphrase }),
|
|
51
|
+
});
|
|
52
|
+
const data = await res.json();
|
|
53
|
+
if (!res.ok) return data.error || "Login failed";
|
|
54
|
+
localStorage.setItem("ows-token", data.token);
|
|
55
|
+
setToken(data.token);
|
|
56
|
+
return null;
|
|
57
|
+
} catch {
|
|
58
|
+
return "Cannot connect to server";
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const handleSetup = async (passphrase: string): Promise<string | null> => {
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(`${API_BASE}/api/auth/setup`, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { "Content-Type": "application/json" },
|
|
67
|
+
body: JSON.stringify({ passphrase }),
|
|
68
|
+
});
|
|
69
|
+
const data = await res.json();
|
|
70
|
+
if (!res.ok) return data.error || "Setup failed";
|
|
71
|
+
localStorage.setItem("ows-token", data.token);
|
|
72
|
+
setToken(data.token);
|
|
73
|
+
setConfigured(true);
|
|
74
|
+
return null;
|
|
75
|
+
} catch {
|
|
76
|
+
return "Cannot connect to server";
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const handleLogout = () => {
|
|
81
|
+
localStorage.removeItem("ows-token");
|
|
82
|
+
setToken(null);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (checking || configured === null) {
|
|
86
|
+
return (
|
|
87
|
+
<div className="flex h-screen items-center justify-center">
|
|
88
|
+
<span className="text-muted text-sm">connecting...</span>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!configured) {
|
|
94
|
+
return <Setup onSetup={handleSetup} />;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!token) {
|
|
98
|
+
return <Login onLogin={handleLogin} />;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return <Layout token={token} onLogout={handleLogout} />;
|
|
102
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
2
|
+
import Markdown from "react-markdown";
|
|
3
|
+
|
|
4
|
+
const API_BASE = "http://localhost:7777";
|
|
5
|
+
|
|
6
|
+
interface Message {
|
|
7
|
+
id: string;
|
|
8
|
+
role: "user" | "assistant" | "system";
|
|
9
|
+
content: string;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface StorySession {
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
genre: string | null;
|
|
17
|
+
status: string;
|
|
18
|
+
messages: Message[];
|
|
19
|
+
drafts: Array<{ id: string; title: string; content: string; genre: string | null; status: string }>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function Chat({ token }: { token: string }) {
|
|
23
|
+
const [sessions, setSessions] = useState<Array<{ id: string; title: string; status: string; _count: { messages: number } }>>([]);
|
|
24
|
+
const [activeSession, setActiveSession] = useState<StorySession | null>(null);
|
|
25
|
+
const [input, setInput] = useState("");
|
|
26
|
+
const [streaming, setStreaming] = useState(false);
|
|
27
|
+
const [streamContent, setStreamContent] = useState("");
|
|
28
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
|
|
30
|
+
const authFetch = (url: string, opts?: RequestInit) =>
|
|
31
|
+
fetch(url, { ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
|
|
32
|
+
|
|
33
|
+
const loadSessions = () => {
|
|
34
|
+
authFetch(`${API_BASE}/api/chat/sessions`)
|
|
35
|
+
.then((r) => r.json())
|
|
36
|
+
.then((data) => setSessions(data));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const loadSession = (id: string) => {
|
|
40
|
+
authFetch(`${API_BASE}/api/chat/sessions/${id}`)
|
|
41
|
+
.then((r) => r.json())
|
|
42
|
+
.then((data) => setActiveSession(data));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
useEffect(() => { loadSessions(); }, []);
|
|
46
|
+
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [activeSession?.messages, streamContent]);
|
|
47
|
+
|
|
48
|
+
const createSession = async () => {
|
|
49
|
+
const res = await authFetch(`${API_BASE}/api/chat/sessions`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
body: JSON.stringify({}),
|
|
52
|
+
});
|
|
53
|
+
const session = await res.json();
|
|
54
|
+
loadSessions();
|
|
55
|
+
loadSession(session.id);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const sendMessage = async () => {
|
|
59
|
+
if (!input.trim() || !activeSession || streaming) return;
|
|
60
|
+
const content = input.trim();
|
|
61
|
+
setInput("");
|
|
62
|
+
setStreaming(true);
|
|
63
|
+
setStreamContent("");
|
|
64
|
+
|
|
65
|
+
// Optimistically add user message
|
|
66
|
+
setActiveSession((prev) => prev ? {
|
|
67
|
+
...prev,
|
|
68
|
+
messages: [...prev.messages, { id: "temp", role: "user", content, createdAt: new Date().toISOString() }],
|
|
69
|
+
} : null);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
// Use WebSocket for streaming
|
|
73
|
+
const ws = new WebSocket(`ws://localhost:7777/ws/chat?token=${encodeURIComponent(token)}`);
|
|
74
|
+
let fullContent = "";
|
|
75
|
+
|
|
76
|
+
await new Promise<void>((resolve, reject) => {
|
|
77
|
+
ws.onopen = () => {
|
|
78
|
+
ws.send(JSON.stringify({ type: "message", sessionId: activeSession.id, content }));
|
|
79
|
+
};
|
|
80
|
+
ws.onmessage = (event) => {
|
|
81
|
+
try {
|
|
82
|
+
const data = JSON.parse(event.data);
|
|
83
|
+
if (data.type === "chunk") {
|
|
84
|
+
fullContent += data.content;
|
|
85
|
+
setStreamContent(fullContent);
|
|
86
|
+
} else if (data.type === "done") {
|
|
87
|
+
ws.close();
|
|
88
|
+
resolve();
|
|
89
|
+
} else if (data.type === "error") {
|
|
90
|
+
ws.close();
|
|
91
|
+
reject(new Error(data.message));
|
|
92
|
+
}
|
|
93
|
+
} catch { /* ignore */ }
|
|
94
|
+
};
|
|
95
|
+
ws.onerror = () => reject(new Error("WebSocket error"));
|
|
96
|
+
ws.onclose = () => resolve();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Reload session to get persisted messages
|
|
100
|
+
loadSession(activeSession.id);
|
|
101
|
+
loadSessions();
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error("Send error:", err);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
setStreaming(false);
|
|
107
|
+
setStreamContent("");
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleFinalize = async () => {
|
|
111
|
+
if (!activeSession) return;
|
|
112
|
+
const lastAssistant = [...activeSession.messages].reverse().find((m) => m.role === "assistant");
|
|
113
|
+
if (!lastAssistant) return;
|
|
114
|
+
|
|
115
|
+
// Try to extract title and content from the formatted output
|
|
116
|
+
const content = lastAssistant.content;
|
|
117
|
+
const titleMatch = content.match(/TITLE:\s*(.+)/);
|
|
118
|
+
const genreMatch = content.match(/GENRE:\s*(.+)/);
|
|
119
|
+
const storyMatch = content.match(/---\n([\s\S]+)$/);
|
|
120
|
+
|
|
121
|
+
const title = titleMatch?.[1]?.trim() || activeSession.title;
|
|
122
|
+
const genre = genreMatch?.[1]?.trim() || activeSession.genre;
|
|
123
|
+
const storyContent = storyMatch?.[1]?.trim() || content;
|
|
124
|
+
|
|
125
|
+
await authFetch(`${API_BASE}/api/chat/sessions/${activeSession.id}/finalize`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
body: JSON.stringify({ title, content: storyContent, genre }),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
loadSession(activeSession.id);
|
|
131
|
+
loadSessions();
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className="flex h-full">
|
|
136
|
+
{/* Sidebar — session list */}
|
|
137
|
+
<div className="border-border w-56 shrink-0 border-r">
|
|
138
|
+
<div className="p-3">
|
|
139
|
+
<button
|
|
140
|
+
onClick={createSession}
|
|
141
|
+
className="border-accent text-accent hover:bg-accent/10 w-full rounded border px-3 py-1.5 text-xs font-medium transition-colors"
|
|
142
|
+
>
|
|
143
|
+
+ new story
|
|
144
|
+
</button>
|
|
145
|
+
</div>
|
|
146
|
+
<div className="space-y-0.5 px-2">
|
|
147
|
+
{sessions.map((s) => (
|
|
148
|
+
<button
|
|
149
|
+
key={s.id}
|
|
150
|
+
onClick={() => loadSession(s.id)}
|
|
151
|
+
className={`w-full rounded px-2 py-1.5 text-left text-xs truncate transition-colors ${
|
|
152
|
+
activeSession?.id === s.id ? "bg-surface text-accent" : "text-muted hover:text-foreground"
|
|
153
|
+
}`}
|
|
154
|
+
>
|
|
155
|
+
{s.title}
|
|
156
|
+
</button>
|
|
157
|
+
))}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
{/* Main chat area */}
|
|
162
|
+
<div className="flex flex-1 flex-col">
|
|
163
|
+
{!activeSession ? (
|
|
164
|
+
<div className="flex flex-1 items-center justify-center">
|
|
165
|
+
<div className="text-center">
|
|
166
|
+
<p className="text-muted text-sm">select or create a story session</p>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
) : (
|
|
170
|
+
<>
|
|
171
|
+
{/* Chat header */}
|
|
172
|
+
<div className="border-border flex items-center justify-between border-b px-4 py-2">
|
|
173
|
+
<div>
|
|
174
|
+
<h3 className="text-foreground text-sm font-medium">{activeSession.title}</h3>
|
|
175
|
+
<span className="text-muted text-[10px]">{activeSession.genre || "no genre"} · {activeSession.status}</span>
|
|
176
|
+
</div>
|
|
177
|
+
{activeSession.status === "active" && activeSession.messages.length > 0 && (
|
|
178
|
+
<button
|
|
179
|
+
onClick={handleFinalize}
|
|
180
|
+
className="border-accent text-accent hover:bg-accent/10 rounded border px-3 py-1 text-[10px] font-medium transition-colors"
|
|
181
|
+
>
|
|
182
|
+
finalize draft
|
|
183
|
+
</button>
|
|
184
|
+
)}
|
|
185
|
+
{activeSession.drafts?.length > 0 && (
|
|
186
|
+
<span className="rounded border border-green-700/30 px-2 py-0.5 text-[10px] text-accent">
|
|
187
|
+
draft ready
|
|
188
|
+
</span>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
{/* Messages */}
|
|
193
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
194
|
+
{activeSession.messages.map((msg) => (
|
|
195
|
+
<div key={msg.id} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
|
196
|
+
<div className={`max-w-[80%] rounded px-3 py-2 text-sm ${
|
|
197
|
+
msg.role === "user"
|
|
198
|
+
? "bg-accent/10 text-foreground"
|
|
199
|
+
: "bg-surface text-foreground border border-border"
|
|
200
|
+
}`}>
|
|
201
|
+
<div className="prose prose-xs max-w-none text-xs leading-relaxed"><Markdown>{msg.content}</Markdown></div>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
))}
|
|
205
|
+
|
|
206
|
+
{/* Streaming response */}
|
|
207
|
+
{streaming && streamContent && (
|
|
208
|
+
<div className="flex justify-start">
|
|
209
|
+
<div className="bg-surface border-border max-w-[80%] rounded border px-3 py-2">
|
|
210
|
+
<div className="prose prose-xs max-w-none text-xs leading-relaxed"><Markdown>{streamContent}</Markdown></div>
|
|
211
|
+
<span className="text-accent animate-pulse">▌</span>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
|
|
216
|
+
{streaming && !streamContent && (
|
|
217
|
+
<div className="flex justify-start">
|
|
218
|
+
<div className="text-muted text-xs">thinking...</div>
|
|
219
|
+
</div>
|
|
220
|
+
)}
|
|
221
|
+
|
|
222
|
+
<div ref={messagesEndRef} />
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{/* Input */}
|
|
226
|
+
<div className="border-border border-t p-3">
|
|
227
|
+
<div className="flex gap-2">
|
|
228
|
+
<input
|
|
229
|
+
type="text"
|
|
230
|
+
value={input}
|
|
231
|
+
onChange={(e) => setInput(e.target.value)}
|
|
232
|
+
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); } }}
|
|
233
|
+
placeholder="describe your story idea..."
|
|
234
|
+
disabled={streaming || activeSession.status !== "active"}
|
|
235
|
+
className="bg-surface border-border text-foreground placeholder:text-muted/50 flex-1 rounded border px-3 py-2 text-sm outline-none focus:border-accent disabled:opacity-40"
|
|
236
|
+
/>
|
|
237
|
+
<button
|
|
238
|
+
onClick={sendMessage}
|
|
239
|
+
disabled={streaming || !input.trim() || activeSession.status !== "active"}
|
|
240
|
+
className="border-accent text-accent hover:bg-accent/10 disabled:opacity-40 rounded border px-4 py-2 text-sm font-medium transition-colors"
|
|
241
|
+
>
|
|
242
|
+
send
|
|
243
|
+
</button>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
</>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
{/* Draft preview panel */}
|
|
251
|
+
{activeSession?.drafts && activeSession.drafts.length > 0 && (
|
|
252
|
+
<div className="border-border w-80 shrink-0 overflow-y-auto border-l p-4">
|
|
253
|
+
<h3 className="text-accent mb-3 text-xs font-bold uppercase tracking-wider">Draft Preview</h3>
|
|
254
|
+
{activeSession.drafts.map((draft) => (
|
|
255
|
+
<div key={draft.id} className="border-border space-y-2 rounded border p-3">
|
|
256
|
+
<div className="flex items-center justify-between">
|
|
257
|
+
<h4 className="text-foreground text-sm font-medium">{draft.title}</h4>
|
|
258
|
+
<span className="rounded border border-green-700/30 px-1.5 py-0.5 text-[9px] text-accent">{draft.status}</span>
|
|
259
|
+
</div>
|
|
260
|
+
{draft.genre && <span className="text-accent text-[10px]">{draft.genre}</span>}
|
|
261
|
+
<div className="bg-surface max-h-[60vh] overflow-y-auto rounded p-3">
|
|
262
|
+
<div className="prose prose-xs max-w-none text-xs leading-relaxed">
|
|
263
|
+
<Markdown>{draft.content}</Markdown>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
))}
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
}
|