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.
- package/README.md +49 -57
- package/app/db.ts +1 -1
- package/app/lib/paths.ts +0 -2
- package/app/lib/publish.ts +150 -39
- package/app/prisma/schema.prisma +0 -36
- package/app/routes/dashboard.ts +53 -56
- package/app/routes/publish.ts +55 -24
- package/app/routes/stories.ts +156 -0
- package/app/routes/terminal.ts +154 -0
- package/app/routes/wallet.ts +40 -10
- package/app/server.ts +29 -81
- package/app/web/App.tsx +4 -6
- package/app/web/components/Dashboard.tsx +15 -47
- package/app/web/components/Layout.tsx +70 -103
- package/app/web/components/PreviewPanel.tsx +149 -0
- package/app/web/components/Settings.tsx +3 -84
- package/app/web/components/StoriesPage.tsx +157 -0
- package/app/web/components/StoryBrowser.tsx +137 -0
- package/app/web/components/TerminalPanel.tsx +122 -0
- package/app/web/components/WalletCard.tsx +14 -8
- package/app/web/dist/assets/index-D5gfwaEX.css +32 -0
- package/app/web/dist/assets/index-pBt5Q_bN.js +117 -0
- package/app/web/dist/index.html +3 -3
- package/app/web/dist/plotlink-logo.svg +5 -0
- package/app/web/public/plotlink-logo.svg +5 -0
- package/bin/plotlink-ows.js +2 -1
- package/package.json +9 -5
- package/app/lib/llm-client.ts +0 -265
- package/app/lib/writer-prompt.ts +0 -44
- package/app/routes/chat.ts +0 -135
- package/app/routes/config.ts +0 -210
- package/app/routes/oauth.ts +0 -150
- package/app/web/components/Chat.tsx +0 -272
- package/app/web/components/LLMSetup.tsx +0 -291
- package/app/web/components/Publish.tsx +0 -245
- package/app/web/dist/assets/index-C9kXlYO_.css +0 -2
- package/app/web/dist/assets/index-CJiiaLHs.js +0 -9
package/app/routes/config.ts
DELETED
|
@@ -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 };
|
package/app/routes/oauth.ts
DELETED
|
@@ -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 };
|
|
@@ -1,272 +0,0 @@
|
|
|
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
|
-
}
|