plotlink-ows 0.1.15 → 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.
Files changed (37) hide show
  1. package/README.md +49 -57
  2. package/app/db.ts +1 -1
  3. package/app/lib/paths.ts +0 -2
  4. package/app/lib/publish.ts +150 -39
  5. package/app/prisma/schema.prisma +0 -36
  6. package/app/routes/dashboard.ts +53 -56
  7. package/app/routes/publish.ts +55 -24
  8. package/app/routes/stories.ts +156 -0
  9. package/app/routes/terminal.ts +154 -0
  10. package/app/routes/wallet.ts +40 -10
  11. package/app/server.ts +29 -81
  12. package/app/web/App.tsx +4 -6
  13. package/app/web/components/Dashboard.tsx +15 -47
  14. package/app/web/components/Layout.tsx +70 -103
  15. package/app/web/components/PreviewPanel.tsx +149 -0
  16. package/app/web/components/Settings.tsx +3 -84
  17. package/app/web/components/StoriesPage.tsx +157 -0
  18. package/app/web/components/StoryBrowser.tsx +137 -0
  19. package/app/web/components/TerminalPanel.tsx +122 -0
  20. package/app/web/components/WalletCard.tsx +14 -8
  21. package/app/web/dist/assets/index-D5gfwaEX.css +32 -0
  22. package/app/web/dist/assets/index-pBt5Q_bN.js +117 -0
  23. package/app/web/dist/index.html +3 -3
  24. package/app/web/dist/plotlink-logo.svg +5 -0
  25. package/app/web/public/plotlink-logo.svg +5 -0
  26. package/bin/plotlink-ows.js +2 -1
  27. package/package.json +9 -5
  28. package/app/lib/llm-client.ts +0 -265
  29. package/app/lib/writer-prompt.ts +0 -44
  30. package/app/routes/chat.ts +0 -135
  31. package/app/routes/config.ts +0 -210
  32. package/app/routes/oauth.ts +0 -150
  33. package/app/web/components/Chat.tsx +0 -272
  34. package/app/web/components/LLMSetup.tsx +0 -291
  35. package/app/web/components/Publish.tsx +0 -245
  36. package/app/web/dist/assets/index-C9kXlYO_.css +0 -2
  37. package/app/web/dist/assets/index-CJiiaLHs.js +0 -9
@@ -6,9 +6,9 @@
6
6
  <title>PlotLink OWS — Local Writer</title>
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
- <link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
10
- <script type="module" crossorigin src="/assets/index-CJiiaLHs.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-C9kXlYO_.css">
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Lora:wght@400;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
10
+ <script type="module" crossorigin src="/assets/index-pBt5Q_bN.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-D5gfwaEX.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="root"></div>
@@ -0,0 +1,5 @@
1
+ <svg width="300" height="364" viewBox="0 0 300 364" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M47.3684 292.105H300V0H47.3684C21.2479 0 0 21.2479 0 47.3684V319.089C0 343.39 21.2479 363.158 47.3684 363.158H300V347.368H47.3684C30.2529 347.368 15.7895 334.416 15.7895 319.089C15.7895 303.947 29.6669 292.105 47.3684 292.105Z" fill="#8B4513"/>
3
+ <path d="M129.746 186.622C127.559 186.713 125.553 186.804 123.73 186.895C121.999 186.895 120.312 186.895 118.672 186.895C117.122 186.895 115.573 186.895 114.023 186.895C112.565 186.895 111.016 186.85 109.375 186.758V211.505C109.375 216.609 109.284 221.531 109.102 226.27C108.919 230.919 108.646 234.428 108.281 236.798L132.07 235.977V257.579H48.9453V239.122C52.0443 238.939 54.8242 238.666 57.2852 238.301C59.8372 237.846 61.9336 236.843 63.5742 235.294C65.306 233.653 66.6276 231.238 67.5391 228.048C68.4505 224.857 68.9062 220.437 68.9062 214.786V107.462C68.9062 104.91 68.9062 102.403 68.9062 99.9421C68.9974 97.4811 69.0885 95.1569 69.1797 92.9694C69.2708 90.6908 69.362 88.64 69.4531 86.8171C69.6354 84.9942 69.8177 83.4903 70 82.3054L48.9453 83.1257V61.5241L128.652 60.9772C140.775 60.8861 151.758 62.0254 161.602 64.3952C171.536 66.765 180.013 70.4564 187.031 75.4694C194.049 80.3913 199.473 86.7259 203.301 94.4733C207.129 102.221 209.043 111.518 209.043 122.364C209.134 130.202 207.585 137.859 204.395 145.333C201.204 152.716 196.328 159.415 189.766 165.43C183.294 171.446 175.091 176.368 165.156 180.196C155.221 184.024 143.418 186.166 129.746 186.622ZM121.953 166.661C129.609 166.661 136.217 165.704 141.777 163.79C147.337 161.785 151.895 158.868 155.449 155.04C159.004 151.212 161.602 146.518 163.242 140.958C164.974 135.398 165.84 129.063 165.84 121.954C165.84 115.756 165.247 110.424 164.062 105.958C162.878 101.492 161.283 97.7546 159.277 94.7468C157.272 91.6478 154.948 89.1869 152.305 87.364C149.753 85.4499 147.064 84.0371 144.238 83.1257C141.504 82.1231 138.724 81.485 135.898 81.2116C133.164 80.847 130.612 80.6647 128.242 80.6647C125.234 80.6647 122.546 80.8926 120.176 81.3483C117.897 81.8041 115.938 82.8067 114.297 84.3561C112.747 85.9056 111.517 88.1387 110.605 91.0554C109.785 93.972 109.375 97.8913 109.375 102.813V165.841C111.38 166.023 113.431 166.205 115.527 166.387C117.624 166.57 119.766 166.661 121.953 166.661Z" fill="white"/>
4
+ <path d="M215.98 100.579V93.9871C217.087 93.922 218.08 93.8243 218.959 93.6941C219.87 93.5313 220.619 93.1733 221.205 92.6199C221.824 92.0339 222.296 91.1713 222.621 90.032C222.947 88.8927 223.109 87.3139 223.109 85.2957V46.9656C223.109 46.0541 223.109 45.1589 223.109 44.28C223.142 43.4011 223.174 42.571 223.207 41.7898C223.24 40.976 223.272 40.2436 223.305 39.5925C223.37 38.9415 223.435 38.4044 223.5 37.9812L215.98 38.2742V30.5593H244.691V37.1511C243.552 37.2162 242.543 37.3302 241.664 37.4929C240.785 37.6557 240.036 38.0138 239.418 38.5671C238.832 39.1205 238.376 39.9669 238.051 41.1062C237.725 42.2455 237.562 43.8243 237.562 45.8425V84.1726C237.562 85.0841 237.546 85.9792 237.514 86.8582C237.514 87.7045 237.497 88.5183 237.465 89.2996C237.432 90.0808 237.383 90.797 237.318 91.448C237.286 92.0665 237.237 92.5873 237.172 93.0105L250.6 92.9617C254.538 92.9617 257.631 91.6108 259.877 88.9089C262.123 86.1746 263.246 82.073 263.246 76.6042H269.887L269.301 100.579H215.98Z" fill="white"/>
5
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg width="300" height="364" viewBox="0 0 300 364" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M47.3684 292.105H300V0H47.3684C21.2479 0 0 21.2479 0 47.3684V319.089C0 343.39 21.2479 363.158 47.3684 363.158H300V347.368H47.3684C30.2529 347.368 15.7895 334.416 15.7895 319.089C15.7895 303.947 29.6669 292.105 47.3684 292.105Z" fill="#8B4513"/>
3
+ <path d="M129.746 186.622C127.559 186.713 125.553 186.804 123.73 186.895C121.999 186.895 120.312 186.895 118.672 186.895C117.122 186.895 115.573 186.895 114.023 186.895C112.565 186.895 111.016 186.85 109.375 186.758V211.505C109.375 216.609 109.284 221.531 109.102 226.27C108.919 230.919 108.646 234.428 108.281 236.798L132.07 235.977V257.579H48.9453V239.122C52.0443 238.939 54.8242 238.666 57.2852 238.301C59.8372 237.846 61.9336 236.843 63.5742 235.294C65.306 233.653 66.6276 231.238 67.5391 228.048C68.4505 224.857 68.9062 220.437 68.9062 214.786V107.462C68.9062 104.91 68.9062 102.403 68.9062 99.9421C68.9974 97.4811 69.0885 95.1569 69.1797 92.9694C69.2708 90.6908 69.362 88.64 69.4531 86.8171C69.6354 84.9942 69.8177 83.4903 70 82.3054L48.9453 83.1257V61.5241L128.652 60.9772C140.775 60.8861 151.758 62.0254 161.602 64.3952C171.536 66.765 180.013 70.4564 187.031 75.4694C194.049 80.3913 199.473 86.7259 203.301 94.4733C207.129 102.221 209.043 111.518 209.043 122.364C209.134 130.202 207.585 137.859 204.395 145.333C201.204 152.716 196.328 159.415 189.766 165.43C183.294 171.446 175.091 176.368 165.156 180.196C155.221 184.024 143.418 186.166 129.746 186.622ZM121.953 166.661C129.609 166.661 136.217 165.704 141.777 163.79C147.337 161.785 151.895 158.868 155.449 155.04C159.004 151.212 161.602 146.518 163.242 140.958C164.974 135.398 165.84 129.063 165.84 121.954C165.84 115.756 165.247 110.424 164.062 105.958C162.878 101.492 161.283 97.7546 159.277 94.7468C157.272 91.6478 154.948 89.1869 152.305 87.364C149.753 85.4499 147.064 84.0371 144.238 83.1257C141.504 82.1231 138.724 81.485 135.898 81.2116C133.164 80.847 130.612 80.6647 128.242 80.6647C125.234 80.6647 122.546 80.8926 120.176 81.3483C117.897 81.8041 115.938 82.8067 114.297 84.3561C112.747 85.9056 111.517 88.1387 110.605 91.0554C109.785 93.972 109.375 97.8913 109.375 102.813V165.841C111.38 166.023 113.431 166.205 115.527 166.387C117.624 166.57 119.766 166.661 121.953 166.661Z" fill="white"/>
4
+ <path d="M215.98 100.579V93.9871C217.087 93.922 218.08 93.8243 218.959 93.6941C219.87 93.5313 220.619 93.1733 221.205 92.6199C221.824 92.0339 222.296 91.1713 222.621 90.032C222.947 88.8927 223.109 87.3139 223.109 85.2957V46.9656C223.109 46.0541 223.109 45.1589 223.109 44.28C223.142 43.4011 223.174 42.571 223.207 41.7898C223.24 40.976 223.272 40.2436 223.305 39.5925C223.37 38.9415 223.435 38.4044 223.5 37.9812L215.98 38.2742V30.5593H244.691V37.1511C243.552 37.2162 242.543 37.3302 241.664 37.4929C240.785 37.6557 240.036 38.0138 239.418 38.5671C238.832 39.1205 238.376 39.9669 238.051 41.1062C237.725 42.2455 237.562 43.8243 237.562 45.8425V84.1726C237.562 85.0841 237.546 85.9792 237.514 86.8582C237.514 87.7045 237.497 88.5183 237.465 89.2996C237.432 90.0808 237.383 90.797 237.318 91.448C237.286 92.0665 237.237 92.5873 237.172 93.0105L250.6 92.9617C254.538 92.9617 257.631 91.6108 259.877 88.9089C262.123 86.1746 263.246 82.073 263.246 76.6042H269.887L269.301 100.579H215.98Z" fill="white"/>
5
+ </svg>
@@ -35,6 +35,7 @@ function writeConfig(cfg) {
35
35
  }
36
36
 
37
37
  function writeEnvVar(key, value) {
38
+ ensureConfigDir();
38
39
  const line = `${key}=${value}`;
39
40
  if (fs.existsSync(ENV_FILE)) {
40
41
  const content = fs.readFileSync(ENV_FILE, "utf-8");
@@ -197,7 +198,7 @@ async function cmdInit() {
197
198
  log(`Config: ${CONFIG_FILE}`);
198
199
  log("");
199
200
  log('Run \x1b[1mnpx plotlink-ows\x1b[0m to start the app.');
200
- log("You'll connect your LLM (Anthropic, OpenAI, Gemini) via the Web UI.");
201
+ log("Then run \x1b[1mclaude\x1b[0m in the terminal to start writing stories.");
201
202
  log("");
202
203
 
203
204
  process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plotlink-ows",
3
- "version": "0.1.15",
3
+ "version": "0.1.18",
4
4
  "bin": {
5
5
  "plotlink-ows": "./bin/plotlink-ows.js"
6
6
  },
@@ -34,16 +34,21 @@
34
34
  "@farcaster/miniapp-sdk": "^0.3.0",
35
35
  "@farcaster/miniapp-wagmi-connector": "^2.0.0",
36
36
  "@hono/node-server": "^1.19.12",
37
- "@hono/node-ws": "^1.3.0",
38
37
  "@open-wallet-standard/core": "^1.2.4",
39
38
  "@prisma/client": "^6.19.3",
40
39
  "@rainbow-me/rainbowkit": "^2.2.10",
41
40
  "@supabase/supabase-js": "^2.99.1",
41
+ "@tailwindcss/vite": "^4.2.2",
42
42
  "@tanstack/react-query": "^5.90.21",
43
+ "@types/ws": "^8.18.1",
43
44
  "@vercel/analytics": "^2.0.1",
45
+ "@vitejs/plugin-react": "^4.7.0",
46
+ "@xterm/addon-fit": "^0.11.0",
47
+ "@xterm/xterm": "^6.0.0",
44
48
  "dotenv": "^17.4.0",
45
49
  "hono": "^4.12.10",
46
50
  "next": "16.1.6",
51
+ "node-pty": "^1.2.0-beta.12",
47
52
  "ox": "^0.14.8",
48
53
  "prisma": "^6.19.3",
49
54
  "react": "19.2.3",
@@ -56,9 +61,8 @@
56
61
  "tsx": "^4.21.0",
57
62
  "viem": "^2.47.2",
58
63
  "vite": "^6.4.1",
59
- "@vitejs/plugin-react": "^4.7.0",
60
- "@tailwindcss/vite": "^4.2.2",
61
- "wagmi": "^2.19.5"
64
+ "wagmi": "^2.19.5",
65
+ "ws": "^8.20.0"
62
66
  },
63
67
  "devDependencies": {
64
68
  "@playwright/test": "^1.58.2",
@@ -1,265 +0,0 @@
1
- import fs from "fs";
2
- import { AGENT_CONFIG_FILE } from "./paths";
3
-
4
- const configPath = AGENT_CONFIG_FILE;
5
-
6
- interface LLMConfig {
7
- activeProvider?: string;
8
- activeModel?: string;
9
- local?: { baseUrl: string; model: string; apiType?: string };
10
- [key: string]: unknown;
11
- }
12
-
13
- function readLLMConfig(): LLMConfig {
14
- try {
15
- if (fs.existsSync(configPath)) {
16
- const cfg = JSON.parse(fs.readFileSync(configPath, "utf-8"));
17
- return (cfg.llm as LLMConfig) || {};
18
- }
19
- } catch { /* ignore */ }
20
- return {};
21
- }
22
-
23
- function getCredential(provider: string): string | null {
24
- const keyMap: Record<string, string[]> = {
25
- anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"],
26
- openai: ["OPENAI_API_KEY", "OPENAI_OAUTH_TOKEN"],
27
- gemini: ["GEMINI_API_KEY"],
28
- };
29
- for (const key of keyMap[provider] || []) {
30
- if (process.env[key]) return process.env[key]!;
31
- }
32
- return null;
33
- }
34
-
35
- export interface ChatMessage {
36
- role: "user" | "assistant" | "system";
37
- content: string;
38
- }
39
-
40
- /**
41
- * Stream a chat completion from the configured LLM provider.
42
- * Yields text chunks as they arrive.
43
- */
44
- export async function* streamChat(messages: ChatMessage[]): AsyncGenerator<string> {
45
- const config = readLLMConfig();
46
- const provider = config.activeProvider;
47
- const model = config.activeModel;
48
-
49
- if (!provider || !model) {
50
- yield "Error: No LLM provider configured. Go to Settings → LLM to set up.";
51
- return;
52
- }
53
-
54
- if (provider === "anthropic") {
55
- yield* streamAnthropic(messages, model);
56
- } else if (provider === "openai") {
57
- yield* streamOpenAI(messages, model);
58
- } else if (provider === "gemini") {
59
- yield* streamGemini(messages, model);
60
- } else if (provider === "local") {
61
- const localConfig = config.local;
62
- if (!localConfig) { yield "Error: Local model not configured."; return; }
63
- yield* streamLocal(messages, localConfig.baseUrl, localConfig.model, localConfig.apiType);
64
- } else {
65
- yield `Error: Unknown provider "${provider}".`;
66
- }
67
- }
68
-
69
- async function* streamAnthropic(messages: ChatMessage[], model: string): AsyncGenerator<string> {
70
- const apiKey = getCredential("anthropic");
71
- if (!apiKey) { yield "Error: No Anthropic API key configured."; return; }
72
-
73
- const systemMsg = messages.find((m) => m.role === "system");
74
- const nonSystem = messages.filter((m) => m.role !== "system");
75
-
76
- const res = await fetch("https://api.anthropic.com/v1/messages", {
77
- method: "POST",
78
- headers: {
79
- "x-api-key": apiKey,
80
- "anthropic-version": "2023-06-01",
81
- "content-type": "application/json",
82
- },
83
- body: JSON.stringify({
84
- model,
85
- max_tokens: 4096,
86
- stream: true,
87
- ...(systemMsg && { system: systemMsg.content }),
88
- messages: nonSystem.map((m) => ({ role: m.role, content: m.content })),
89
- }),
90
- });
91
-
92
- if (!res.ok) {
93
- const err = await res.text();
94
- yield `Error: Anthropic API ${res.status} — ${err}`;
95
- return;
96
- }
97
-
98
- const reader = res.body?.getReader();
99
- if (!reader) return;
100
- const decoder = new TextDecoder();
101
- let buffer = "";
102
-
103
- while (true) {
104
- const { done, value } = await reader.read();
105
- if (done) break;
106
- buffer += decoder.decode(value, { stream: true });
107
-
108
- const lines = buffer.split("\n");
109
- buffer = lines.pop() || "";
110
-
111
- for (const line of lines) {
112
- if (line.startsWith("data: ")) {
113
- const data = line.slice(6);
114
- if (data === "[DONE]") return;
115
- try {
116
- const parsed = JSON.parse(data);
117
- if (parsed.type === "content_block_delta" && parsed.delta?.text) {
118
- yield parsed.delta.text;
119
- }
120
- } catch { /* ignore parse errors */ }
121
- }
122
- }
123
- }
124
- }
125
-
126
- async function* streamOpenAI(messages: ChatMessage[], model: string): AsyncGenerator<string> {
127
- const apiKey = getCredential("openai");
128
- if (!apiKey) { yield "Error: No OpenAI API key configured."; return; }
129
-
130
- const res = await fetch("https://api.openai.com/v1/chat/completions", {
131
- method: "POST",
132
- headers: { Authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
133
- body: JSON.stringify({ model, stream: true, messages }),
134
- });
135
-
136
- if (!res.ok) {
137
- yield `Error: OpenAI API ${res.status}`;
138
- return;
139
- }
140
-
141
- yield* parseSSEStream(res);
142
- }
143
-
144
- async function* streamGemini(messages: ChatMessage[], model: string): AsyncGenerator<string> {
145
- const apiKey = getCredential("gemini");
146
- if (!apiKey) { yield "Error: No Gemini API key configured."; return; }
147
-
148
- const contents = messages
149
- .filter((m) => m.role !== "system")
150
- .map((m) => ({
151
- role: m.role === "assistant" ? "model" : "user",
152
- parts: [{ text: m.content }],
153
- }));
154
-
155
- const systemInstruction = messages.find((m) => m.role === "system");
156
-
157
- // Use streamGenerateContent for streaming
158
- const res = await fetch(
159
- `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse&key=${apiKey}`,
160
- {
161
- method: "POST",
162
- headers: { "content-type": "application/json" },
163
- body: JSON.stringify({
164
- contents,
165
- ...(systemInstruction && { systemInstruction: { parts: [{ text: systemInstruction.content }] } }),
166
- generationConfig: { maxOutputTokens: 4096 },
167
- }),
168
- },
169
- );
170
-
171
- if (!res.ok) {
172
- yield `Error: Gemini API ${res.status}`;
173
- return;
174
- }
175
-
176
- const reader = res.body?.getReader();
177
- if (!reader) return;
178
- const decoder = new TextDecoder();
179
- let buffer = "";
180
-
181
- while (true) {
182
- const { done, value } = await reader.read();
183
- if (done) break;
184
- buffer += decoder.decode(value, { stream: true });
185
- const lines = buffer.split("\n");
186
- buffer = lines.pop() || "";
187
-
188
- for (const line of lines) {
189
- if (line.startsWith("data: ")) {
190
- try {
191
- const parsed = JSON.parse(line.slice(6));
192
- const text = parsed.candidates?.[0]?.content?.parts?.[0]?.text;
193
- if (text) yield text;
194
- } catch { /* ignore */ }
195
- }
196
- }
197
- }
198
- }
199
-
200
- async function* streamLocal(messages: ChatMessage[], baseUrl: string, model: string, apiType?: string): AsyncGenerator<string> {
201
- if (apiType === "ollama") {
202
- const res = await fetch(`${baseUrl}/api/chat`, {
203
- method: "POST",
204
- headers: { "content-type": "application/json" },
205
- body: JSON.stringify({ model, messages, stream: true }),
206
- });
207
- if (!res.ok) { yield `Error: Local model ${res.status}`; return; }
208
-
209
- const reader = res.body?.getReader();
210
- if (!reader) return;
211
- const decoder = new TextDecoder();
212
- let buffer = "";
213
-
214
- while (true) {
215
- const { done, value } = await reader.read();
216
- if (done) break;
217
- buffer += decoder.decode(value, { stream: true });
218
- const lines = buffer.split("\n");
219
- buffer = lines.pop() || "";
220
- for (const line of lines) {
221
- if (!line.trim()) continue;
222
- try {
223
- const parsed = JSON.parse(line);
224
- if (parsed.message?.content) yield parsed.message.content;
225
- } catch { /* ignore */ }
226
- }
227
- }
228
- } else {
229
- // OpenAI-compatible (LM Studio, etc.)
230
- const res = await fetch(`${baseUrl}/v1/chat/completions`, {
231
- method: "POST",
232
- headers: { "content-type": "application/json" },
233
- body: JSON.stringify({ model, stream: true, messages }),
234
- });
235
- if (!res.ok) { yield `Error: Local model ${res.status}`; return; }
236
- yield* parseSSEStream(res);
237
- }
238
- }
239
-
240
- async function* parseSSEStream(res: Response): AsyncGenerator<string> {
241
- const reader = res.body?.getReader();
242
- if (!reader) return;
243
- const decoder = new TextDecoder();
244
- let buffer = "";
245
-
246
- while (true) {
247
- const { done, value } = await reader.read();
248
- if (done) break;
249
- buffer += decoder.decode(value, { stream: true });
250
- const lines = buffer.split("\n");
251
- buffer = lines.pop() || "";
252
-
253
- for (const line of lines) {
254
- if (line.startsWith("data: ")) {
255
- const data = line.slice(6);
256
- if (data === "[DONE]") return;
257
- try {
258
- const parsed = JSON.parse(data);
259
- const content = parsed.choices?.[0]?.delta?.content;
260
- if (content) yield content;
261
- } catch { /* ignore */ }
262
- }
263
- }
264
- }
265
- }
@@ -1,44 +0,0 @@
1
- export const WRITER_SYSTEM_PROMPT = `You are PlotLink Writer, a collaborative fiction writing assistant.
2
-
3
- ## Role
4
- You help users brainstorm, outline, draft, and refine original fiction stories for the PlotLink platform.
5
-
6
- ## Capabilities
7
- - Brainstorm story ideas, themes, and premises
8
- - Suggest titles, genres, and story structures
9
- - Write chapters/plots with vivid prose, dialogue, and pacing
10
- - Refine and edit drafts based on user feedback
11
- - Maintain continuity across multiple plots in a storyline
12
-
13
- ## Workflow
14
- 1. **Discuss** — Talk with the user about their story idea
15
- 2. **Outline** — Propose a story structure (title, genre, key beats)
16
- 3. **Draft** — Write the story content
17
- 4. **Refine** — Edit based on feedback
18
- 5. **Finalize** — When the user is happy, present the final version
19
-
20
- ## PlotLink Story Structure
21
- - A **Storyline** is the overarching narrative (has a title, genre, and token)
22
- - Each **Plot** is a chapter/episode in the storyline
23
- - The first plot is the **Genesis** — it establishes the story
24
- - Subsequent plots continue the narrative
25
-
26
- ## Output Format
27
- When presenting a finalized story, use this format:
28
- \`\`\`
29
- TITLE: [Story Title]
30
- GENRE: [Genre]
31
- ---
32
- [Story content here]
33
- \`\`\`
34
-
35
- ## Available Genres
36
- Action, Adventure, Comedy, Drama, Fantasy, Historical, Horror, Mystery, Romance, Sci-Fi, Thriller, Literary Fiction, Slice of Life
37
-
38
- ## Guidelines
39
- - Write engaging, original fiction with strong voice
40
- - Use vivid sensory details and natural dialogue
41
- - Keep plots between 500-2000 words for readability
42
- - Be responsive to the user's creative direction
43
- - Ask clarifying questions when the idea needs development
44
- - When the user says "finalize" or "looks good", present the final version in the output format above`;
@@ -1,135 +0,0 @@
1
- import { Hono } from "hono";
2
- import { streamSSE } from "hono/streaming";
3
- import { db } from "../db";
4
- import { streamChat, type ChatMessage } from "../lib/llm-client";
5
- import { WRITER_SYSTEM_PROMPT } from "../lib/writer-prompt";
6
-
7
- const chat = new Hono();
8
-
9
- /** POST /api/chat/sessions — create a new story session */
10
- chat.post("/sessions", async (c) => {
11
- const body = await c.req.json<{ title?: string; genre?: string }>();
12
- const session = await db.storySession.create({
13
- data: { title: body.title || "Untitled Story", genre: body.genre || null },
14
- });
15
- return c.json(session);
16
- });
17
-
18
- /** GET /api/chat/sessions — list all sessions */
19
- chat.get("/sessions", async (c) => {
20
- const sessions = await db.storySession.findMany({
21
- orderBy: { updatedAt: "desc" },
22
- include: { _count: { select: { messages: true, drafts: true } } },
23
- });
24
- return c.json(sessions);
25
- });
26
-
27
- /** GET /api/chat/sessions/:id — get session with messages */
28
- chat.get("/sessions/:id", async (c) => {
29
- const id = c.req.param("id");
30
- const session = await db.storySession.findUnique({
31
- where: { id },
32
- include: { messages: { orderBy: { createdAt: "asc" } }, drafts: { orderBy: { createdAt: "desc" } } },
33
- });
34
- if (!session) return c.json({ error: "Session not found" }, 404);
35
- return c.json(session);
36
- });
37
-
38
- /** DELETE /api/chat/sessions/:id — delete a session */
39
- chat.delete("/sessions/:id", async (c) => {
40
- const id = c.req.param("id");
41
- await db.storySession.delete({ where: { id } });
42
- return c.json({ success: true });
43
- });
44
-
45
- /** POST /api/chat/sessions/:id/send — send a message and stream AI response */
46
- chat.post("/sessions/:id/send", async (c) => {
47
- const id = c.req.param("id");
48
- const body = await c.req.json<{ content: string }>();
49
-
50
- if (!body.content?.trim()) {
51
- return c.json({ error: "Message content required" }, 400);
52
- }
53
-
54
- // Save user message
55
- await db.message.create({
56
- data: { sessionId: id, role: "user", content: body.content },
57
- });
58
-
59
- // Build context from conversation history
60
- const messages = await db.message.findMany({
61
- where: { sessionId: id },
62
- orderBy: { createdAt: "asc" },
63
- });
64
-
65
- const chatMessages: ChatMessage[] = [
66
- { role: "system", content: WRITER_SYSTEM_PROMPT },
67
- ...messages.map((m) => ({ role: m.role as ChatMessage["role"], content: m.content })),
68
- ];
69
-
70
- // Stream response via SSE
71
- return streamSSE(c, async (stream) => {
72
- let fullResponse = "";
73
-
74
- try {
75
- for await (const chunk of streamChat(chatMessages)) {
76
- fullResponse += chunk;
77
- await stream.writeSSE({ data: JSON.stringify({ type: "chunk", content: chunk }) });
78
- }
79
-
80
- // Save assistant message
81
- await db.message.create({
82
- data: { sessionId: id, role: "assistant", content: fullResponse },
83
- });
84
-
85
- // Update session title from first exchange if still "Untitled Story"
86
- const session = await db.storySession.findUnique({ where: { id } });
87
- if (session?.title === "Untitled Story" && messages.length <= 2) {
88
- const title = body.content.slice(0, 60) + (body.content.length > 60 ? "..." : "");
89
- await db.storySession.update({ where: { id }, data: { title } });
90
- }
91
-
92
- await stream.writeSSE({ data: JSON.stringify({ type: "done", messageId: fullResponse.slice(0, 20) }) });
93
- } catch (err: unknown) {
94
- const message = err instanceof Error ? err.message : "Stream error";
95
- await stream.writeSSE({ data: JSON.stringify({ type: "error", message }) });
96
- }
97
- });
98
- });
99
-
100
- /** POST /api/chat/sessions/:id/finalize — create a draft from conversation */
101
- chat.post("/sessions/:id/finalize", async (c) => {
102
- const id = c.req.param("id");
103
- const body = await c.req.json<{ title: string; content: string; genre?: string }>();
104
-
105
- if (!body.title || !body.content) {
106
- return c.json({ error: "Title and content required" }, 400);
107
- }
108
-
109
- const draft = await db.draft.create({
110
- data: {
111
- sessionId: id,
112
- title: body.title,
113
- content: body.content,
114
- genre: body.genre || null,
115
- status: "ready",
116
- },
117
- });
118
-
119
- await db.storySession.update({
120
- where: { id },
121
- data: { status: "finalized" },
122
- });
123
-
124
- return c.json(draft);
125
- });
126
-
127
- /** GET /api/chat/drafts — list all drafts */
128
- chat.get("/drafts", async (c) => {
129
- const drafts = await db.draft.findMany({
130
- orderBy: { createdAt: "desc" },
131
- });
132
- return c.json(drafts);
133
- });
134
-
135
- export { chat as chatRoutes };