plotlink-ows 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +151 -0
  3. package/app/db.ts +8 -0
  4. package/app/lib/llm-client.ts +265 -0
  5. package/app/lib/paths.ts +11 -0
  6. package/app/lib/publish.ts +204 -0
  7. package/app/lib/writer-prompt.ts +44 -0
  8. package/app/node_modules/.prisma/local-client/client.d.ts +1 -0
  9. package/app/node_modules/.prisma/local-client/client.js +5 -0
  10. package/app/node_modules/.prisma/local-client/default.d.ts +1 -0
  11. package/app/node_modules/.prisma/local-client/default.js +5 -0
  12. package/app/node_modules/.prisma/local-client/edge.d.ts +1 -0
  13. package/app/node_modules/.prisma/local-client/edge.js +184 -0
  14. package/app/node_modules/.prisma/local-client/index-browser.js +173 -0
  15. package/app/node_modules/.prisma/local-client/index.d.ts +3304 -0
  16. package/app/node_modules/.prisma/local-client/index.js +207 -0
  17. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  18. package/app/node_modules/.prisma/local-client/package.json +183 -0
  19. package/app/node_modules/.prisma/local-client/query_engine_bg.js +2 -0
  20. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  21. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +35 -0
  22. package/app/node_modules/.prisma/local-client/runtime/edge.js +35 -0
  23. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +370 -0
  24. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +17 -0
  25. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +3982 -0
  26. package/app/node_modules/.prisma/local-client/runtime/library.js +147 -0
  27. package/app/node_modules/.prisma/local-client/runtime/react-native.js +84 -0
  28. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +85 -0
  29. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +38 -0
  30. package/app/node_modules/.prisma/local-client/schema.prisma +21 -0
  31. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +5 -0
  32. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +5 -0
  33. package/app/node_modules/.prisma/local-client/wasm.d.ts +1 -0
  34. package/app/node_modules/.prisma/local-client/wasm.js +191 -0
  35. package/app/prisma/schema.prisma +57 -0
  36. package/app/routes/auth.ts +173 -0
  37. package/app/routes/chat.ts +135 -0
  38. package/app/routes/config.ts +210 -0
  39. package/app/routes/dashboard.ts +186 -0
  40. package/app/routes/oauth.ts +150 -0
  41. package/app/routes/publish.ts +112 -0
  42. package/app/routes/wallet.ts +99 -0
  43. package/app/server.ts +154 -0
  44. package/app/vite.config.ts +19 -0
  45. package/app/web/App.tsx +102 -0
  46. package/app/web/components/Chat.tsx +272 -0
  47. package/app/web/components/Dashboard.tsx +222 -0
  48. package/app/web/components/LLMSetup.tsx +291 -0
  49. package/app/web/components/Layout.tsx +235 -0
  50. package/app/web/components/Login.tsx +62 -0
  51. package/app/web/components/Publish.tsx +245 -0
  52. package/app/web/components/Settings.tsx +175 -0
  53. package/app/web/components/Setup.tsx +84 -0
  54. package/app/web/components/WalletCard.tsx +117 -0
  55. package/app/web/dist/assets/index-C9kXlYO_.css +2 -0
  56. package/app/web/dist/assets/index-CJiiaLHs.js +9 -0
  57. package/app/web/dist/index.html +16 -0
  58. package/app/web/index.html +15 -0
  59. package/app/web/main.tsx +10 -0
  60. package/app/web/plotlink-logo.svg +5 -0
  61. package/app/web/styles.css +51 -0
  62. package/bin/plotlink-ows.js +394 -0
  63. package/lib/ows/index.ts +3 -0
  64. package/lib/ows/policy.ts +68 -0
  65. package/lib/ows/types.ts +14 -0
  66. package/lib/ows/wallet.ts +70 -0
  67. package/package.json +79 -0
  68. package/packages/cli/node_modules/commander/LICENSE +22 -0
  69. package/packages/cli/node_modules/commander/Readme.md +1149 -0
  70. package/packages/cli/node_modules/commander/esm.mjs +16 -0
  71. package/packages/cli/node_modules/commander/index.js +24 -0
  72. package/packages/cli/node_modules/commander/lib/argument.js +149 -0
  73. package/packages/cli/node_modules/commander/lib/command.js +2662 -0
  74. package/packages/cli/node_modules/commander/lib/error.js +39 -0
  75. package/packages/cli/node_modules/commander/lib/help.js +709 -0
  76. package/packages/cli/node_modules/commander/lib/option.js +367 -0
  77. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +101 -0
  78. package/packages/cli/node_modules/commander/package-support.json +16 -0
  79. package/packages/cli/node_modules/commander/package.json +82 -0
  80. package/packages/cli/node_modules/commander/typings/esm.d.mts +3 -0
  81. package/packages/cli/node_modules/commander/typings/index.d.ts +1045 -0
  82. package/packages/cli/node_modules/resolve-from/index.d.ts +31 -0
  83. package/packages/cli/node_modules/resolve-from/index.js +47 -0
  84. package/packages/cli/node_modules/resolve-from/license +9 -0
  85. package/packages/cli/node_modules/resolve-from/package.json +36 -0
  86. package/packages/cli/node_modules/resolve-from/readme.md +72 -0
  87. package/packages/cli/node_modules/tsup/LICENSE +21 -0
  88. package/packages/cli/node_modules/tsup/README.md +75 -0
  89. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +13 -0
  90. package/packages/cli/node_modules/tsup/assets/esm_shims.js +9 -0
  91. package/packages/cli/node_modules/tsup/assets/package.json +3 -0
  92. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +153 -0
  93. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +42 -0
  94. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +6 -0
  95. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +352 -0
  96. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +203 -0
  97. package/packages/cli/node_modules/tsup/dist/cli-default.js +12 -0
  98. package/packages/cli/node_modules/tsup/dist/cli-main.js +8 -0
  99. package/packages/cli/node_modules/tsup/dist/cli-node.js +14 -0
  100. package/packages/cli/node_modules/tsup/dist/index.d.ts +511 -0
  101. package/packages/cli/node_modules/tsup/dist/index.js +1711 -0
  102. package/packages/cli/node_modules/tsup/dist/rollup.js +6949 -0
  103. package/packages/cli/node_modules/tsup/package.json +99 -0
  104. package/packages/cli/node_modules/tsup/schema.json +362 -0
  105. package/packages/cli/package.json +35 -0
  106. package/packages/cli/src/commands/agent-register.ts +77 -0
  107. package/packages/cli/src/commands/chain.ts +29 -0
  108. package/packages/cli/src/commands/claim.ts +70 -0
  109. package/packages/cli/src/commands/create.ts +34 -0
  110. package/packages/cli/src/commands/status.ts +201 -0
  111. package/packages/cli/src/config.ts +103 -0
  112. package/packages/cli/src/index.ts +21 -0
  113. package/packages/cli/src/sdk/abi.ts +222 -0
  114. package/packages/cli/src/sdk/client.ts +713 -0
  115. package/packages/cli/src/sdk/constants.ts +56 -0
  116. package/packages/cli/src/sdk/index.ts +46 -0
  117. package/packages/cli/src/sdk/ipfs.ts +88 -0
  118. package/packages/cli/src/sdk.ts +36 -0
  119. package/packages/cli/tsconfig.json +20 -0
  120. package/packages/cli/tsup.config.ts +14 -0
  121. package/public/.well-known/farcaster.json +38 -0
  122. package/public/basescan-icon.svg +4 -0
  123. package/public/embed-image.png +0 -0
  124. package/public/favicon.png +0 -0
  125. package/public/hunt-token.svg +11 -0
  126. package/public/icon-192.png +0 -0
  127. package/public/icon.png +0 -0
  128. package/public/manifest.json +26 -0
  129. package/public/mc-icon-light.svg +12 -0
  130. package/public/og-image.png +0 -0
  131. package/public/plotlink-logo-symbol.svg +5 -0
  132. package/public/plotlink-logo.svg +5 -0
  133. package/public/screenshot-1.png +0 -0
  134. package/public/screenshot-2.png +0 -0
  135. package/public/screenshot-3.png +0 -0
  136. package/public/splash.png +0 -0
  137. package/public/wide-banner.png +0 -0
  138. package/scripts/backfill-trade-prices.ts +97 -0
  139. package/scripts/backfill-usd-rates.ts +220 -0
  140. package/scripts/e2e-verify.ts +1100 -0
  141. package/scripts/ows-smoke-test.ts +37 -0
  142. package/scripts/score-users.mjs +203 -0
@@ -0,0 +1,222 @@
1
+ import React, { useState, useEffect } from "react";
2
+
3
+ const API_BASE = "http://localhost:7777";
4
+
5
+ interface WalletInfo {
6
+ address: string;
7
+ ethBalance: string;
8
+ ethFormatted: string;
9
+ usdcBalance: string;
10
+ }
11
+
12
+ interface Story {
13
+ id: string;
14
+ title: string;
15
+ genre: string | null;
16
+ status: string;
17
+ txHash?: string | null;
18
+ storylineId?: number | null;
19
+ gasCostEth?: string | null;
20
+ gasCostUsd?: string | null;
21
+ createdAt: string;
22
+ updatedAt?: string;
23
+ }
24
+
25
+ interface DashboardData {
26
+ wallet: WalletInfo | null;
27
+ costs: { totalGasCostEth: string; totalCostUsd: string; ethUsdPrice: number; storiesPublished: number };
28
+ royalties: { earned: string; claimed: string; unclaimed: string; token: string };
29
+ pnl: { totalCostsEth: string; totalCostsUsd: string; totalRoyaltiesPlot: string; totalRoyaltiesUsd: string; netPnlUsd: string; plotUsdPrice: string };
30
+ stories: {
31
+ published: Story[];
32
+ drafts: Story[];
33
+ totalPublished: number;
34
+ totalDrafts: number;
35
+ };
36
+ sessions: { total: number; totalMessages: number };
37
+ }
38
+
39
+ export function Dashboard({ token }: { token: string }) {
40
+ const [data, setData] = useState<DashboardData | null>(null);
41
+ const [deleting, setDeleting] = useState<string | null>(null);
42
+
43
+ const authFetch = (url: string, opts?: RequestInit) =>
44
+ fetch(url, { ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
45
+
46
+ const loadDashboard = () => {
47
+ authFetch(`${API_BASE}/api/dashboard`)
48
+ .then((r) => r.json())
49
+ .then(setData);
50
+ };
51
+
52
+ useEffect(() => { loadDashboard(); }, []);
53
+
54
+ const handleDelete = async (id: string) => {
55
+ setDeleting(id);
56
+ await authFetch(`${API_BASE}/api/dashboard/drafts/${id}`, { method: "DELETE" });
57
+ loadDashboard();
58
+ setDeleting(null);
59
+ };
60
+
61
+ const truncate = (addr: string) => `${addr.slice(0, 6)}...${addr.slice(-4)}`;
62
+ const formatDate = (d: string) => new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
63
+
64
+ if (!data) {
65
+ return (
66
+ <div className="flex h-full items-center justify-center">
67
+ <span className="text-muted text-sm">loading dashboard...</span>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ return (
73
+ <div className="mx-auto max-w-2xl space-y-6 p-6">
74
+ <h2 className="text-accent text-lg font-bold">Writer Dashboard</h2>
75
+
76
+ {/* Stats overview */}
77
+ <div className="grid grid-cols-4 gap-3">
78
+ <div className="border-border rounded border p-3 text-center">
79
+ <div className="text-accent text-lg font-bold">{data.stories.totalPublished}</div>
80
+ <div className="text-muted text-[10px] uppercase tracking-wider">published</div>
81
+ </div>
82
+ <div className="border-border rounded border p-3 text-center">
83
+ <div className="text-foreground text-lg font-bold">{data.stories.totalDrafts}</div>
84
+ <div className="text-muted text-[10px] uppercase tracking-wider">drafts</div>
85
+ </div>
86
+ <div className="border-border rounded border p-3 text-center">
87
+ <div className="text-foreground text-lg font-bold">{data.sessions.total}</div>
88
+ <div className="text-muted text-[10px] uppercase tracking-wider">sessions</div>
89
+ </div>
90
+ <div className="border-border rounded border p-3 text-center">
91
+ <div className="text-foreground text-lg font-bold">{data.sessions.totalMessages}</div>
92
+ <div className="text-muted text-[10px] uppercase tracking-wider">messages</div>
93
+ </div>
94
+ </div>
95
+
96
+ {/* Wallet overview */}
97
+ {data.wallet && (
98
+ <div className="border-border rounded border p-4">
99
+ <h3 className="text-accent mb-3 text-xs font-bold uppercase tracking-wider">Wallet</h3>
100
+ <div className="space-y-1.5">
101
+ <div className="flex justify-between text-xs">
102
+ <span className="text-muted">Address</span>
103
+ <code className="text-foreground font-mono text-[10px]">{truncate(data.wallet.address)}</code>
104
+ </div>
105
+ <div className="flex justify-between text-xs">
106
+ <span className="text-muted">ETH Balance</span>
107
+ <span className="text-foreground">{data.wallet.ethFormatted} ETH</span>
108
+ </div>
109
+ <div className="flex justify-between text-xs">
110
+ <span className="text-muted">USDC Balance</span>
111
+ <span className="text-foreground">${data.wallet.usdcBalance}</span>
112
+ </div>
113
+ </div>
114
+ </div>
115
+ )}
116
+
117
+ {/* P&L */}
118
+ <div className="border-border rounded border p-4">
119
+ <h3 className="text-accent mb-3 text-xs font-bold uppercase tracking-wider">Profit & Loss</h3>
120
+ <div className="space-y-1.5">
121
+ <div className="flex justify-between text-xs">
122
+ <span className="text-muted">Total costs (gas)</span>
123
+ <span className="text-error">-{data.pnl.totalCostsEth} ETH (~${data.pnl.totalCostsUsd})</span>
124
+ </div>
125
+ <div className="flex justify-between text-xs">
126
+ <span className="text-muted">Royalties earned</span>
127
+ <span className="text-accent">+{data.pnl.totalRoyaltiesPlot} PLOT</span>
128
+ </div>
129
+ <div className="flex justify-between text-xs">
130
+ <span className="text-muted">Unclaimed royalties</span>
131
+ <span className="text-foreground">{data.royalties.unclaimed} PLOT</span>
132
+ </div>
133
+ <div className="border-border flex justify-between border-t pt-1.5 text-xs font-medium">
134
+ <span className="text-muted">Net P&L (USD)</span>
135
+ <span className={parseFloat(data.pnl.netPnlUsd) >= 0 ? "text-accent" : "text-error"}>
136
+ {parseFloat(data.pnl.netPnlUsd) >= 0 ? "+" : ""}${data.pnl.netPnlUsd}
137
+ </span>
138
+ </div>
139
+ <div className="flex justify-between text-xs">
140
+ <span className="text-muted">Stories published</span>
141
+ <span className="text-foreground">{data.costs.storiesPublished}</span>
142
+ </div>
143
+ </div>
144
+ </div>
145
+
146
+ {/* Published stories */}
147
+ <div className="border-border rounded border p-4">
148
+ <h3 className="text-accent mb-3 text-xs font-bold uppercase tracking-wider">Published Stories</h3>
149
+ {data.stories.published.length === 0 ? (
150
+ <p className="text-muted text-xs">no published stories yet</p>
151
+ ) : (
152
+ <div className="space-y-2">
153
+ {data.stories.published.map((story) => (
154
+ <div key={story.id} className="bg-surface rounded p-3">
155
+ <div className="flex items-center justify-between">
156
+ <div>
157
+ <span className="text-foreground text-sm font-medium">{story.title}</span>
158
+ {story.genre && <span className="text-accent ml-2 text-[10px]">{story.genre}</span>}
159
+ </div>
160
+ <div className="flex items-center gap-2">
161
+ <span className="rounded border border-green-700/30 px-1.5 py-0.5 text-[9px] text-accent">published</span>
162
+ {story.storylineId ? (
163
+ <a
164
+ href={`https://plotlink.xyz/story/${story.storylineId}`}
165
+ target="_blank"
166
+ rel="noopener noreferrer"
167
+ className="text-accent text-[10px] underline"
168
+ >
169
+ view
170
+ </a>
171
+ ) : (
172
+ <a href="https://plotlink.xyz" target="_blank" rel="noopener noreferrer" className="text-accent text-[10px] underline">plotlink.xyz</a>
173
+ )}
174
+ </div>
175
+ </div>
176
+ <div className="mt-1 flex items-center gap-3 text-[10px]">
177
+ <span className="text-muted">{formatDate(story.createdAt)}</span>
178
+ {story.txHash && (
179
+ <a href={`https://basescan.org/tx/${story.txHash}`} target="_blank" rel="noopener noreferrer" className="text-muted hover:text-accent font-mono">
180
+ tx:{story.txHash.slice(0, 10)}...
181
+ </a>
182
+ )}
183
+ {story.gasCostEth && <span className="text-muted">{story.gasCostEth} ETH{story.gasCostUsd ? ` (~$${story.gasCostUsd})` : ""}</span>}
184
+ </div>
185
+ </div>
186
+ ))}
187
+ </div>
188
+ )}
189
+ </div>
190
+
191
+ {/* Draft stories */}
192
+ <div className="border-border rounded border p-4">
193
+ <h3 className="text-accent mb-3 text-xs font-bold uppercase tracking-wider">Drafts</h3>
194
+ {data.stories.drafts.length === 0 ? (
195
+ <p className="text-muted text-xs">no drafts — start writing from the chat</p>
196
+ ) : (
197
+ <div className="space-y-2">
198
+ {data.stories.drafts.map((draft) => (
199
+ <div key={draft.id} className="bg-surface flex items-center justify-between rounded p-3">
200
+ <div>
201
+ <span className="text-foreground text-sm font-medium">{draft.title}</span>
202
+ {draft.genre && <span className="text-accent ml-2 text-[10px]">{draft.genre}</span>}
203
+ <div className="text-muted text-[10px]">{formatDate(draft.createdAt)}</div>
204
+ </div>
205
+ <div className="flex items-center gap-2">
206
+ <span className="border-border rounded border px-1.5 py-0.5 text-[9px] text-muted">{draft.status}</span>
207
+ <button
208
+ onClick={() => handleDelete(draft.id)}
209
+ disabled={deleting === draft.id}
210
+ className="text-muted hover:text-error text-[10px] transition-colors"
211
+ >
212
+ {deleting === draft.id ? "..." : "delete"}
213
+ </button>
214
+ </div>
215
+ </div>
216
+ ))}
217
+ </div>
218
+ )}
219
+ </div>
220
+ </div>
221
+ );
222
+ }
@@ -0,0 +1,291 @@
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">&larr; 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">&larr; 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">&larr; 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">&#x2713;</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
+ }