plotlink-ows 0.1.15 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +185 -93
- package/app/db.ts +1 -1
- package/app/lib/paths.ts +0 -2
- package/app/lib/publish.ts +257 -44
- package/app/prisma/schema.prisma +0 -36
- package/app/routes/dashboard.ts +105 -57
- package/app/routes/publish.ts +107 -25
- package/app/routes/settings.ts +194 -0
- package/app/routes/stories.ts +223 -0
- package/app/routes/terminal.ts +258 -0
- package/app/routes/wallet.ts +40 -10
- package/app/server.ts +35 -81
- package/app/web/App.tsx +4 -6
- package/app/web/components/Dashboard.tsx +98 -79
- package/app/web/components/Layout.tsx +70 -103
- package/app/web/components/PreviewPanel.tsx +388 -0
- package/app/web/components/Settings.tsx +210 -67
- package/app/web/components/StoriesPage.tsx +270 -0
- package/app/web/components/StoryBrowser.tsx +161 -0
- package/app/web/components/TerminalPanel.tsx +428 -0
- package/app/web/components/WalletCard.tsx +14 -8
- package/app/web/dist/assets/index-BuOxhUWG.css +32 -0
- package/app/web/dist/assets/index-De8CpT47.js +129 -0
- package/app/web/dist/index.html +3 -3
- package/app/web/dist/plotlink-logo.svg +5 -0
- package/app/web/public/plotlink-logo.svg +5 -0
- package/app/web/styles.css +18 -0
- package/bin/plotlink-ows.js +18 -62
- package/package.json +15 -6
- package/scripts/fix-index-status.ts +93 -0
- package/app/lib/llm-client.ts +0 -265
- package/app/lib/writer-prompt.ts +0 -44
- package/app/routes/chat.ts +0 -135
- package/app/routes/config.ts +0 -210
- package/app/routes/oauth.ts +0 -150
- package/app/web/components/Chat.tsx +0 -272
- package/app/web/components/LLMSetup.tsx +0 -291
- package/app/web/components/Publish.tsx +0 -245
- package/app/web/dist/assets/index-C9kXlYO_.css +0 -2
- package/app/web/dist/assets/index-CJiiaLHs.js +0 -9
|
@@ -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
|
-
}
|
|
@@ -1,291 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect } from "react";
|
|
2
|
-
|
|
3
|
-
const API_BASE = "http://localhost:7777";
|
|
4
|
-
|
|
5
|
-
interface Provider {
|
|
6
|
-
id: string;
|
|
7
|
-
name: string;
|
|
8
|
-
envKey: string | null;
|
|
9
|
-
models: string[];
|
|
10
|
-
tag: string | null;
|
|
11
|
-
configured: boolean;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
type Step = "provider" | "auth" | "model" | "test" | "done";
|
|
15
|
-
|
|
16
|
-
export function LLMSetup({ token, onComplete }: { token: string; onComplete: () => void }) {
|
|
17
|
-
const [step, setStep] = useState<Step>("provider");
|
|
18
|
-
const [providers, setProviders] = useState<Provider[]>([]);
|
|
19
|
-
const [selected, setSelected] = useState<string>("");
|
|
20
|
-
const [model, setModel] = useState<string>("");
|
|
21
|
-
const [apiKey, setApiKey] = useState<string>("");
|
|
22
|
-
const [baseUrl, setBaseUrl] = useState<string>("http://localhost:11434");
|
|
23
|
-
const [testing, setTesting] = useState(false);
|
|
24
|
-
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
|
25
|
-
const [saving, setSaving] = useState(false);
|
|
26
|
-
const [error, setError] = useState<string | null>(null);
|
|
27
|
-
|
|
28
|
-
const authFetch = (url: string, opts?: RequestInit) =>
|
|
29
|
-
fetch(url, { ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
|
|
30
|
-
|
|
31
|
-
useEffect(() => {
|
|
32
|
-
authFetch(`${API_BASE}/api/config/llm/providers`)
|
|
33
|
-
.then((r) => r.json())
|
|
34
|
-
.then((data) => setProviders(data));
|
|
35
|
-
}, []);
|
|
36
|
-
|
|
37
|
-
const selectedProvider = providers.find((p) => p.id === selected);
|
|
38
|
-
|
|
39
|
-
const handleTest = async () => {
|
|
40
|
-
setTesting(true);
|
|
41
|
-
setTestResult(null);
|
|
42
|
-
try {
|
|
43
|
-
const res = await authFetch(`${API_BASE}/api/config/llm/test`, {
|
|
44
|
-
method: "POST",
|
|
45
|
-
body: JSON.stringify({ provider: selected, model, apiKey: apiKey || undefined, baseUrl: selected === "local" ? baseUrl : undefined }),
|
|
46
|
-
});
|
|
47
|
-
const data = await res.json();
|
|
48
|
-
setTestResult(data);
|
|
49
|
-
} catch {
|
|
50
|
-
setTestResult({ success: false, message: "Connection failed" });
|
|
51
|
-
}
|
|
52
|
-
setTesting(false);
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
const handleSave = async () => {
|
|
56
|
-
setSaving(true);
|
|
57
|
-
setError(null);
|
|
58
|
-
try {
|
|
59
|
-
const res = await authFetch(`${API_BASE}/api/config/llm`, {
|
|
60
|
-
method: "POST",
|
|
61
|
-
body: JSON.stringify({ provider: selected, model, apiKey: apiKey || undefined, baseUrl: selected === "local" ? baseUrl : undefined }),
|
|
62
|
-
});
|
|
63
|
-
const data = await res.json();
|
|
64
|
-
if (!res.ok) throw new Error(data.error || "Save failed");
|
|
65
|
-
setStep("done");
|
|
66
|
-
} catch (err: unknown) {
|
|
67
|
-
setError(err instanceof Error ? err.message : "Save failed");
|
|
68
|
-
}
|
|
69
|
-
setSaving(false);
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
return (
|
|
73
|
-
<div className="mx-auto max-w-lg p-6">
|
|
74
|
-
<h2 className="text-accent mb-1 text-lg font-bold">LLM Setup</h2>
|
|
75
|
-
<p className="text-muted mb-6 text-xs">connect your AI provider to power the writer agent</p>
|
|
76
|
-
|
|
77
|
-
{/* Step indicator */}
|
|
78
|
-
<div className="text-muted mb-6 flex gap-2 text-[10px] uppercase tracking-wider">
|
|
79
|
-
{(["provider", "auth", "model", "test", "done"] as Step[]).map((s) => (
|
|
80
|
-
<span key={s} className={step === s ? "text-accent" : ""}>{s}</span>
|
|
81
|
-
))}
|
|
82
|
-
</div>
|
|
83
|
-
|
|
84
|
-
{/* Provider selection */}
|
|
85
|
-
{step === "provider" && (
|
|
86
|
-
<div className="space-y-3">
|
|
87
|
-
{providers.map((p) => (
|
|
88
|
-
<button
|
|
89
|
-
key={p.id}
|
|
90
|
-
onClick={() => { setSelected(p.id); setModel(p.models[0] || ""); setStep(p.id === "local" ? "auth" : "auth"); }}
|
|
91
|
-
className={`border-border hover:border-accent w-full rounded border p-3 text-left transition-colors ${selected === p.id ? "border-accent" : ""}`}
|
|
92
|
-
>
|
|
93
|
-
<div className="flex items-center justify-between">
|
|
94
|
-
<span className="text-foreground text-sm font-medium">{p.name}</span>
|
|
95
|
-
<span className="flex gap-1.5">
|
|
96
|
-
{p.tag && <span className="text-accent border-accent/30 rounded border px-1.5 py-0.5 text-[9px]">{p.tag}</span>}
|
|
97
|
-
{p.configured && <span className="rounded border border-green-700/30 px-1.5 py-0.5 text-[9px] text-accent">configured</span>}
|
|
98
|
-
</span>
|
|
99
|
-
</div>
|
|
100
|
-
</button>
|
|
101
|
-
))}
|
|
102
|
-
</div>
|
|
103
|
-
)}
|
|
104
|
-
|
|
105
|
-
{/* Auth step */}
|
|
106
|
-
{step === "auth" && selectedProvider && (
|
|
107
|
-
<div className="space-y-4">
|
|
108
|
-
<button onClick={() => setStep("provider")} className="text-muted hover:text-foreground text-xs">← back</button>
|
|
109
|
-
<h3 className="text-foreground text-sm font-medium">{selectedProvider.name}</h3>
|
|
110
|
-
|
|
111
|
-
{selected === "local" ? (
|
|
112
|
-
<div className="space-y-3">
|
|
113
|
-
<div>
|
|
114
|
-
<label className="text-muted mb-1.5 block text-xs uppercase tracking-wider">Base URL</label>
|
|
115
|
-
<input
|
|
116
|
-
type="text"
|
|
117
|
-
value={baseUrl}
|
|
118
|
-
onChange={(e) => setBaseUrl(e.target.value)}
|
|
119
|
-
placeholder="http://localhost:11434"
|
|
120
|
-
className="bg-surface border-border text-foreground w-full rounded border px-3 py-2 text-sm outline-none focus:border-accent"
|
|
121
|
-
/>
|
|
122
|
-
</div>
|
|
123
|
-
<div>
|
|
124
|
-
<label className="text-muted mb-1.5 block text-xs uppercase tracking-wider">Model Name</label>
|
|
125
|
-
<input
|
|
126
|
-
type="text"
|
|
127
|
-
value={model}
|
|
128
|
-
onChange={(e) => setModel(e.target.value)}
|
|
129
|
-
placeholder="llama3.2"
|
|
130
|
-
className="bg-surface border-border text-foreground w-full rounded border px-3 py-2 text-sm outline-none focus:border-accent"
|
|
131
|
-
/>
|
|
132
|
-
</div>
|
|
133
|
-
</div>
|
|
134
|
-
) : (
|
|
135
|
-
<div className="space-y-4">
|
|
136
|
-
{/* OAuth option */}
|
|
137
|
-
{(selected === "anthropic" || selected === "openai") && (
|
|
138
|
-
<div>
|
|
139
|
-
<button
|
|
140
|
-
onClick={async () => {
|
|
141
|
-
try {
|
|
142
|
-
const res = await authFetch(`${API_BASE}/api/oauth/${selected}/start`);
|
|
143
|
-
const data = await res.json();
|
|
144
|
-
if (data.authUrl) {
|
|
145
|
-
window.open(data.authUrl, "oauth", "width=600,height=700");
|
|
146
|
-
// Poll for completion
|
|
147
|
-
const poll = setInterval(async () => {
|
|
148
|
-
const status = await authFetch(`${API_BASE}/api/oauth/${selected}/status`).then((r) => r.json());
|
|
149
|
-
if (status.complete) {
|
|
150
|
-
clearInterval(poll);
|
|
151
|
-
setStep("model");
|
|
152
|
-
}
|
|
153
|
-
}, 1500);
|
|
154
|
-
setTimeout(() => clearInterval(poll), 120000);
|
|
155
|
-
}
|
|
156
|
-
} catch { /* ignore */ }
|
|
157
|
-
}}
|
|
158
|
-
className="border-accent text-accent hover:bg-accent/10 w-full rounded border px-4 py-2 text-sm font-medium transition-colors"
|
|
159
|
-
>
|
|
160
|
-
connect with OAuth (recommended)
|
|
161
|
-
</button>
|
|
162
|
-
<div className="text-muted my-3 flex items-center gap-2 text-[10px]">
|
|
163
|
-
<div className="border-border flex-1 border-t" />
|
|
164
|
-
<span>or use API key</span>
|
|
165
|
-
<div className="border-border flex-1 border-t" />
|
|
166
|
-
</div>
|
|
167
|
-
</div>
|
|
168
|
-
)}
|
|
169
|
-
|
|
170
|
-
{/* API key input */}
|
|
171
|
-
<div>
|
|
172
|
-
<label className="text-muted mb-1.5 block text-xs uppercase tracking-wider">API Key</label>
|
|
173
|
-
<input
|
|
174
|
-
type="password"
|
|
175
|
-
value={apiKey}
|
|
176
|
-
onChange={(e) => setApiKey(e.target.value)}
|
|
177
|
-
placeholder={`paste your ${selectedProvider.name} API key`}
|
|
178
|
-
className="bg-surface border-border text-foreground w-full rounded border px-3 py-2 text-sm outline-none focus:border-accent"
|
|
179
|
-
/>
|
|
180
|
-
{selectedProvider.configured && (
|
|
181
|
-
<p className="text-muted mt-1.5 text-[10px]">key already saved — leave blank to keep current</p>
|
|
182
|
-
)}
|
|
183
|
-
</div>
|
|
184
|
-
</div>
|
|
185
|
-
)}
|
|
186
|
-
|
|
187
|
-
<button
|
|
188
|
-
onClick={() => setStep("model")}
|
|
189
|
-
disabled={selected === "local" ? !model.trim() : (!apiKey.trim() && !selectedProvider.configured)}
|
|
190
|
-
className="border-accent text-accent hover:bg-accent/10 disabled:opacity-40 w-full rounded border px-4 py-2 text-sm font-medium transition-colors"
|
|
191
|
-
>
|
|
192
|
-
next
|
|
193
|
-
</button>
|
|
194
|
-
</div>
|
|
195
|
-
)}
|
|
196
|
-
|
|
197
|
-
{/* Model selection */}
|
|
198
|
-
{step === "model" && selectedProvider && (
|
|
199
|
-
<div className="space-y-4">
|
|
200
|
-
<button onClick={() => setStep("auth")} className="text-muted hover:text-foreground text-xs">← back</button>
|
|
201
|
-
<h3 className="text-foreground text-sm font-medium">Select Model</h3>
|
|
202
|
-
|
|
203
|
-
{selected === "local" ? (
|
|
204
|
-
<p className="text-muted text-xs">Using: <span className="text-foreground font-medium">{model}</span></p>
|
|
205
|
-
) : (
|
|
206
|
-
<div className="space-y-2">
|
|
207
|
-
{selectedProvider.models.map((m) => (
|
|
208
|
-
<button
|
|
209
|
-
key={m}
|
|
210
|
-
onClick={() => setModel(m)}
|
|
211
|
-
className={`border-border w-full rounded border px-3 py-2 text-left text-sm transition-colors ${model === m ? "border-accent text-accent" : "text-foreground hover:border-accent/50"}`}
|
|
212
|
-
>
|
|
213
|
-
{m}
|
|
214
|
-
</button>
|
|
215
|
-
))}
|
|
216
|
-
</div>
|
|
217
|
-
)}
|
|
218
|
-
|
|
219
|
-
<button
|
|
220
|
-
onClick={() => setStep("test")}
|
|
221
|
-
disabled={!model}
|
|
222
|
-
className="border-accent text-accent hover:bg-accent/10 disabled:opacity-40 w-full rounded border px-4 py-2 text-sm font-medium transition-colors"
|
|
223
|
-
>
|
|
224
|
-
test connection
|
|
225
|
-
</button>
|
|
226
|
-
</div>
|
|
227
|
-
)}
|
|
228
|
-
|
|
229
|
-
{/* Test step */}
|
|
230
|
-
{step === "test" && (
|
|
231
|
-
<div className="space-y-4">
|
|
232
|
-
<button onClick={() => setStep("model")} className="text-muted hover:text-foreground text-xs">← back</button>
|
|
233
|
-
<h3 className="text-foreground text-sm font-medium">Test Connection</h3>
|
|
234
|
-
<p className="text-muted text-xs">{selectedProvider?.name} / {model}</p>
|
|
235
|
-
|
|
236
|
-
{!testResult && (
|
|
237
|
-
<button
|
|
238
|
-
onClick={handleTest}
|
|
239
|
-
disabled={testing}
|
|
240
|
-
className="border-accent text-accent hover:bg-accent/10 disabled:opacity-40 w-full rounded border px-4 py-2 text-sm font-medium transition-colors"
|
|
241
|
-
>
|
|
242
|
-
{testing ? "testing..." : "run test"}
|
|
243
|
-
</button>
|
|
244
|
-
)}
|
|
245
|
-
|
|
246
|
-
{testResult && (
|
|
247
|
-
<div className={`rounded border p-3 text-xs ${testResult.success ? "border-accent/30 text-accent" : "border-error/30 text-error"}`}>
|
|
248
|
-
{testResult.success ? "connected" : "failed"}: {testResult.message}
|
|
249
|
-
</div>
|
|
250
|
-
)}
|
|
251
|
-
|
|
252
|
-
{testResult?.success && (
|
|
253
|
-
<button
|
|
254
|
-
onClick={handleSave}
|
|
255
|
-
disabled={saving}
|
|
256
|
-
className="border-accent text-accent hover:bg-accent/10 disabled:opacity-40 w-full rounded border px-4 py-2 text-sm font-medium transition-colors"
|
|
257
|
-
>
|
|
258
|
-
{saving ? "saving..." : "save & continue"}
|
|
259
|
-
</button>
|
|
260
|
-
)}
|
|
261
|
-
|
|
262
|
-
{testResult && !testResult.success && (
|
|
263
|
-
<button
|
|
264
|
-
onClick={() => { setTestResult(null); setStep("auth"); }}
|
|
265
|
-
className="text-muted hover:text-foreground w-full py-2 text-xs transition-colors"
|
|
266
|
-
>
|
|
267
|
-
go back and fix
|
|
268
|
-
</button>
|
|
269
|
-
)}
|
|
270
|
-
|
|
271
|
-
{error && <p className="text-error text-xs">{error}</p>}
|
|
272
|
-
</div>
|
|
273
|
-
)}
|
|
274
|
-
|
|
275
|
-
{/* Done */}
|
|
276
|
-
{step === "done" && (
|
|
277
|
-
<div className="space-y-4 text-center">
|
|
278
|
-
<div className="text-accent text-2xl">✓</div>
|
|
279
|
-
<p className="text-foreground text-sm font-medium">LLM configured</p>
|
|
280
|
-
<p className="text-muted text-xs">{selectedProvider?.name} / {model}</p>
|
|
281
|
-
<button
|
|
282
|
-
onClick={onComplete}
|
|
283
|
-
className="border-accent text-accent hover:bg-accent/10 w-full rounded border px-4 py-2 text-sm font-medium transition-colors"
|
|
284
|
-
>
|
|
285
|
-
continue to wallet setup
|
|
286
|
-
</button>
|
|
287
|
-
</div>
|
|
288
|
-
)}
|
|
289
|
-
</div>
|
|
290
|
-
);
|
|
291
|
-
}
|