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.
Files changed (40) hide show
  1. package/README.md +185 -93
  2. package/app/db.ts +1 -1
  3. package/app/lib/paths.ts +0 -2
  4. package/app/lib/publish.ts +257 -44
  5. package/app/prisma/schema.prisma +0 -36
  6. package/app/routes/dashboard.ts +105 -57
  7. package/app/routes/publish.ts +107 -25
  8. package/app/routes/settings.ts +194 -0
  9. package/app/routes/stories.ts +223 -0
  10. package/app/routes/terminal.ts +258 -0
  11. package/app/routes/wallet.ts +40 -10
  12. package/app/server.ts +35 -81
  13. package/app/web/App.tsx +4 -6
  14. package/app/web/components/Dashboard.tsx +98 -79
  15. package/app/web/components/Layout.tsx +70 -103
  16. package/app/web/components/PreviewPanel.tsx +388 -0
  17. package/app/web/components/Settings.tsx +210 -67
  18. package/app/web/components/StoriesPage.tsx +270 -0
  19. package/app/web/components/StoryBrowser.tsx +161 -0
  20. package/app/web/components/TerminalPanel.tsx +428 -0
  21. package/app/web/components/WalletCard.tsx +14 -8
  22. package/app/web/dist/assets/index-BuOxhUWG.css +32 -0
  23. package/app/web/dist/assets/index-De8CpT47.js +129 -0
  24. package/app/web/dist/index.html +3 -3
  25. package/app/web/dist/plotlink-logo.svg +5 -0
  26. package/app/web/public/plotlink-logo.svg +5 -0
  27. package/app/web/styles.css +18 -0
  28. package/bin/plotlink-ows.js +18 -62
  29. package/package.json +15 -6
  30. package/scripts/fix-index-status.ts +93 -0
  31. package/app/lib/llm-client.ts +0 -265
  32. package/app/lib/writer-prompt.ts +0 -44
  33. package/app/routes/chat.ts +0 -135
  34. package/app/routes/config.ts +0 -210
  35. package/app/routes/oauth.ts +0 -150
  36. package/app/web/components/Chat.tsx +0 -272
  37. package/app/web/components/LLMSetup.tsx +0 -291
  38. package/app/web/components/Publish.tsx +0 -245
  39. package/app/web/dist/assets/index-C9kXlYO_.css +0 -2
  40. package/app/web/dist/assets/index-CJiiaLHs.js +0 -9
@@ -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 };
@@ -1,210 +0,0 @@
1
- import { Hono } from "hono";
2
- import fs from "fs";
3
- import { AGENT_CONFIG_FILE, ENV_FILE } from "../lib/paths";
4
-
5
- const configPath = AGENT_CONFIG_FILE;
6
- const envPath = ENV_FILE;
7
-
8
- const config = new Hono();
9
-
10
- /** Provider catalog with metadata */
11
- const PROVIDERS = [
12
- { id: "anthropic", name: "Anthropic", envKeys: ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"], models: ["claude-sonnet-4-6", "claude-haiku-4-5-20251001", "claude-opus-4-6"], tag: "recommended" },
13
- { id: "openai", name: "OpenAI", envKeys: ["OPENAI_API_KEY", "OPENAI_OAUTH_TOKEN"], models: ["gpt-4.1", "gpt-4.1-mini", "o3-mini"], tag: null },
14
- { id: "gemini", name: "Google Gemini", envKeys: ["GEMINI_API_KEY"], models: ["gemini-2.5-flash", "gemini-2.5-pro"], tag: null },
15
- { id: "local", name: "Local (Ollama/LM Studio)", envKeys: [], models: [], tag: "free" },
16
- ];
17
-
18
- /** Check if any env key for a provider is set */
19
- function isProviderConfigured(p: typeof PROVIDERS[number]): boolean {
20
- return p.envKeys.some((k) => !!process.env[k]);
21
- }
22
-
23
- /** Get the active credential for a provider */
24
- function getProviderCredential(p: typeof PROVIDERS[number]): string | null {
25
- for (const k of p.envKeys) {
26
- if (process.env[k]) return process.env[k]!;
27
- }
28
- return null;
29
- }
30
-
31
- function readConfig(): Record<string, unknown> {
32
- try {
33
- if (fs.existsSync(configPath)) {
34
- return JSON.parse(fs.readFileSync(configPath, "utf-8"));
35
- }
36
- } catch { /* ignore */ }
37
- return {};
38
- }
39
-
40
- function writeConfig(cfg: Record<string, unknown>) {
41
- fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n");
42
- }
43
-
44
- function writeEnvVar(key: string, value: string) {
45
- if (fs.existsSync(envPath)) {
46
- const content = fs.readFileSync(envPath, "utf-8");
47
- const regex = new RegExp(`^${key}=.*$`, "m");
48
- if (regex.test(content)) {
49
- fs.writeFileSync(envPath, content.replace(regex, `${key}=${value}`));
50
- } else {
51
- fs.appendFileSync(envPath, `\n${key}=${value}\n`);
52
- }
53
- } else {
54
- fs.writeFileSync(envPath, `${key}=${value}\n`);
55
- }
56
- process.env[key] = value;
57
- }
58
-
59
- /** GET /api/config/llm — current LLM config */
60
- config.get("/llm", (c) => {
61
- const cfg = readConfig() as { llm?: Record<string, unknown> };
62
- const llm = cfg.llm || {};
63
-
64
- // Check which providers are configured
65
- const configured = PROVIDERS.filter((p) => {
66
- if (p.id === "local") return !!(llm as Record<string, unknown>).local;
67
- return isProviderConfigured(p);
68
- }).map((p) => p.id);
69
-
70
- return c.json({ llm, configured });
71
- });
72
-
73
- /** GET /api/config/llm/providers — provider catalog */
74
- config.get("/llm/providers", (c) => {
75
- return c.json(
76
- PROVIDERS.map((p) => ({
77
- ...p,
78
- configured: isProviderConfigured(p),
79
- })),
80
- );
81
- });
82
-
83
- /** POST /api/config/llm — save LLM config */
84
- config.post("/llm", async (c) => {
85
- const body = await c.req.json<{
86
- provider: string;
87
- model: string;
88
- apiKey?: string;
89
- baseUrl?: string;
90
- apiType?: string;
91
- spendCap?: number;
92
- }>();
93
-
94
- if (!body.provider || !body.model) {
95
- return c.json({ error: "provider and model required" }, 400);
96
- }
97
-
98
- const provider = PROVIDERS.find((p) => p.id === body.provider);
99
- if (!provider) return c.json({ error: "Unknown provider" }, 400);
100
-
101
- // Save API key to .env if provided (use first envKey as the primary)
102
- if (body.apiKey && provider.envKeys.length > 0) {
103
- writeEnvVar(provider.envKeys[0], body.apiKey);
104
- }
105
-
106
- // Build config
107
- const cfg = readConfig();
108
- const llmConfig: Record<string, unknown> = (cfg.llm as Record<string, unknown>) || {};
109
-
110
- if (body.provider === "local") {
111
- llmConfig.local = {
112
- baseUrl: body.baseUrl || "http://localhost:11434",
113
- apiType: body.apiType || "ollama",
114
- model: body.model,
115
- };
116
- } else {
117
- // Find which env key is active (API key or OAuth token)
118
- const activeEnvKey = provider.envKeys.find((k) => !!process.env[k]) || provider.envKeys[0];
119
- llmConfig[body.provider] = {
120
- apiKey: activeEnvKey ? `env:${activeEnvKey}` : undefined,
121
- model: body.model,
122
- };
123
- }
124
-
125
- llmConfig.activeProvider = body.provider;
126
- llmConfig.activeModel = body.model;
127
-
128
- cfg.llm = llmConfig;
129
-
130
- // Persist spending cap if provided
131
- if (body.spendCap !== undefined) {
132
- (cfg as Record<string, unknown>).spendCap = body.spendCap;
133
- }
134
-
135
- writeConfig(cfg);
136
-
137
- return c.json({ success: true });
138
- });
139
-
140
- /** POST /api/config/llm/test — test LLM connection */
141
- config.post("/llm/test", async (c) => {
142
- const body = await c.req.json<{
143
- provider: string;
144
- model: string;
145
- apiKey?: string;
146
- baseUrl?: string;
147
- }>();
148
-
149
- try {
150
- if (body.provider === "local") {
151
- const baseUrl = body.baseUrl || "http://localhost:11434";
152
- const res = await fetch(`${baseUrl}/api/tags`);
153
- if (!res.ok) throw new Error(`Local server returned ${res.status}`);
154
- return c.json({ success: true, message: "Connected to local model server" });
155
- }
156
-
157
- // For cloud providers, do a minimal test
158
- const provider = PROVIDERS.find((p) => p.id === body.provider);
159
- const apiKey = body.apiKey || (provider ? getProviderCredential(provider) : null);
160
-
161
- if (!apiKey) {
162
- return c.json({ success: false, message: "No API key configured" }, 400);
163
- }
164
-
165
- // Test with a minimal request based on provider
166
- if (body.provider === "anthropic") {
167
- const res = await fetch("https://api.anthropic.com/v1/messages", {
168
- method: "POST",
169
- headers: {
170
- "x-api-key": apiKey,
171
- "anthropic-version": "2023-06-01",
172
- "content-type": "application/json",
173
- },
174
- body: JSON.stringify({
175
- model: body.model,
176
- max_tokens: 1,
177
- messages: [{ role: "user", content: "hi" }],
178
- }),
179
- });
180
- if (!res.ok) {
181
- const err = await res.json().catch(() => ({}));
182
- throw new Error((err as Record<string, unknown>).error?.toString() || `HTTP ${res.status}`);
183
- }
184
- } else if (body.provider === "openai") {
185
- const res = await fetch("https://api.openai.com/v1/chat/completions", {
186
- method: "POST",
187
- headers: { Authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
188
- body: JSON.stringify({ model: body.model, max_tokens: 1, messages: [{ role: "user", content: "hi" }] }),
189
- });
190
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
191
- } else if (body.provider === "gemini") {
192
- const res = await fetch(
193
- `https://generativelanguage.googleapis.com/v1beta/models/${body.model}:generateContent?key=${apiKey}`,
194
- {
195
- method: "POST",
196
- headers: { "content-type": "application/json" },
197
- body: JSON.stringify({ contents: [{ parts: [{ text: "hi" }] }], generationConfig: { maxOutputTokens: 1 } }),
198
- },
199
- );
200
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
201
- }
202
-
203
- return c.json({ success: true, message: "Connection verified" });
204
- } catch (err: unknown) {
205
- const message = err instanceof Error ? err.message : "Connection failed";
206
- return c.json({ success: false, message }, 400);
207
- }
208
- });
209
-
210
- export { config as configRoutes };
@@ -1,150 +0,0 @@
1
- import { Hono } from "hono";
2
- import { randomBytes, createHash } from "crypto";
3
- import fs from "fs";
4
- import { ENV_FILE } from "../lib/paths";
5
-
6
- const envPath = ENV_FILE;
7
-
8
- const oauth = new Hono();
9
-
10
- // OAuth state store (in-memory, keyed by state param)
11
- const pendingFlows = new Map<string, { provider: string; codeVerifier: string; status: "pending" | "complete"; token?: string }>();
12
-
13
- const OAUTH_CONFIGS: Record<string, { authUrl: string; tokenUrl: string; clientId: string; envKey: string }> = {
14
- anthropic: {
15
- authUrl: "https://console.anthropic.com/oauth/authorize",
16
- tokenUrl: "https://console.anthropic.com/oauth/token",
17
- clientId: "plotlink-ows-local",
18
- envKey: "ANTHROPIC_OAUTH_TOKEN",
19
- },
20
- openai: {
21
- authUrl: "https://platform.openai.com/oauth/authorize",
22
- tokenUrl: "https://platform.openai.com/oauth/token",
23
- clientId: "plotlink-ows-local",
24
- envKey: "OPENAI_OAUTH_TOKEN",
25
- },
26
- };
27
-
28
- function writeEnvVar(key: string, value: string) {
29
- if (fs.existsSync(envPath)) {
30
- const content = fs.readFileSync(envPath, "utf-8");
31
- const regex = new RegExp(`^${key}=.*$`, "m");
32
- if (regex.test(content)) {
33
- fs.writeFileSync(envPath, content.replace(regex, `${key}=${value}`));
34
- } else {
35
- fs.appendFileSync(envPath, `\n${key}=${value}\n`);
36
- }
37
- } else {
38
- fs.writeFileSync(envPath, `${key}=${value}\n`);
39
- }
40
- process.env[key] = value;
41
- }
42
-
43
- /** GET /api/oauth/:provider/start — initiate OAuth PKCE flow */
44
- oauth.get("/:provider/start", (c) => {
45
- const provider = c.req.param("provider");
46
- const config = OAUTH_CONFIGS[provider];
47
- if (!config) return c.json({ error: "Unsupported OAuth provider" }, 400);
48
-
49
- const state = randomBytes(16).toString("hex");
50
- const codeVerifier = randomBytes(32).toString("base64url");
51
-
52
- pendingFlows.set(state, { provider, codeVerifier, status: "pending" });
53
-
54
- // Compute S256 code_challenge from verifier
55
- const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
56
-
57
- // Build authorization URL with PKCE
58
- const params = new URLSearchParams({
59
- client_id: config.clientId,
60
- response_type: "code",
61
- redirect_uri: "http://localhost:7777/api/oauth/callback",
62
- state,
63
- code_challenge: codeChallenge,
64
- code_challenge_method: "S256",
65
- scope: "api",
66
- });
67
-
68
- const authUrl = `${config.authUrl}?${params}`;
69
-
70
- return c.json({ authUrl, state });
71
- });
72
-
73
- /** GET /api/oauth/callback — OAuth redirect handler */
74
- oauth.get("/callback", async (c) => {
75
- const code = c.req.query("code");
76
- const state = c.req.query("state");
77
- const error = c.req.query("error");
78
-
79
- if (error) {
80
- return c.html(`<html><body><h2>OAuth Error</h2><p>${error}</p><script>window.close()</script></body></html>`);
81
- }
82
-
83
- if (!code || !state) {
84
- return c.html(`<html><body><h2>Missing parameters</h2><script>window.close()</script></body></html>`);
85
- }
86
-
87
- const flow = pendingFlows.get(state);
88
- if (!flow) {
89
- return c.html(`<html><body><h2>Invalid state</h2><script>window.close()</script></body></html>`);
90
- }
91
-
92
- const config = OAUTH_CONFIGS[flow.provider];
93
- if (!config) {
94
- return c.html(`<html><body><h2>Unknown provider</h2><script>window.close()</script></body></html>`);
95
- }
96
-
97
- try {
98
- // Exchange code for token
99
- const res = await fetch(config.tokenUrl, {
100
- method: "POST",
101
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
102
- body: new URLSearchParams({
103
- grant_type: "authorization_code",
104
- code,
105
- redirect_uri: "http://localhost:7777/api/oauth/callback",
106
- client_id: config.clientId,
107
- code_verifier: flow.codeVerifier,
108
- }),
109
- });
110
-
111
- const data = await res.json() as Record<string, unknown>;
112
-
113
- if (!res.ok) {
114
- throw new Error((data.error_description || data.error || "Token exchange failed") as string);
115
- }
116
-
117
- const accessToken = data.access_token as string;
118
- writeEnvVar(config.envKey, accessToken);
119
- flow.status = "complete";
120
- flow.token = accessToken;
121
-
122
- return c.html(`<html><body><h2>Connected!</h2><p>You can close this window.</p><script>window.close()</script></body></html>`);
123
- } catch (err: unknown) {
124
- const message = err instanceof Error ? err.message : "Token exchange failed";
125
- return c.html(`<html><body><h2>Error</h2><p>${message}</p><script>window.close()</script></body></html>`);
126
- }
127
- });
128
-
129
- /** GET /api/oauth/:provider/status — poll for OAuth completion */
130
- oauth.get("/:provider/status", (c) => {
131
- const provider = c.req.param("provider");
132
- const config = OAUTH_CONFIGS[provider];
133
- if (!config) return c.json({ error: "Unsupported provider" }, 400);
134
-
135
- // Check if token is already in env
136
- if (process.env[config.envKey]) {
137
- return c.json({ complete: true });
138
- }
139
-
140
- // Check pending flows
141
- for (const [, flow] of pendingFlows) {
142
- if (flow.provider === provider && flow.status === "complete") {
143
- return c.json({ complete: true });
144
- }
145
- }
146
-
147
- return c.json({ complete: false });
148
- });
149
-
150
- export { oauth as oauthRoutes };