larkup 0.1.14

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 (141) hide show
  1. package/.env +2 -0
  2. package/app/actions/vercel.ts +417 -0
  3. package/app/api/chat/route.ts +192 -0
  4. package/app/api/chat/status/route.ts +55 -0
  5. package/app/api/chat/suggestions/route.ts +115 -0
  6. package/app/api/config/route.ts +73 -0
  7. package/app/api/config/test/route.ts +67 -0
  8. package/app/api/config/test-embedding/route.ts +40 -0
  9. package/app/api/config/test-llm/route.ts +47 -0
  10. package/app/api/config/test-provider/route.ts +36 -0
  11. package/app/api/demo/route.ts +176 -0
  12. package/app/api/deploy/ssh/route.ts +340 -0
  13. package/app/api/documents/route.ts +90 -0
  14. package/app/api/firecrawl/local/route.ts +46 -0
  15. package/app/api/index/route.ts +113 -0
  16. package/app/api/jobs/[id]/route.ts +44 -0
  17. package/app/api/jobs/route.ts +116 -0
  18. package/app/api/parse-file/route.ts +39 -0
  19. package/app/api/search/google/route.ts +162 -0
  20. package/app/api/search/route.ts +29 -0
  21. package/app/api/server/download/route.ts +112 -0
  22. package/app/api/server/generate/route.ts +46 -0
  23. package/app/api/server/local/route.ts +42 -0
  24. package/app/api/servers/route.ts +137 -0
  25. package/app/api/vector-stores/install/route.ts +65 -0
  26. package/app/api/vector-stores/status/route.ts +26 -0
  27. package/app/chat/page.tsx +15 -0
  28. package/app/configure/page.tsx +53 -0
  29. package/app/data/page.tsx +15 -0
  30. package/app/demo/page.tsx +15 -0
  31. package/app/favicon.ico +0 -0
  32. package/app/globals.css +190 -0
  33. package/app/index-data/page.tsx +15 -0
  34. package/app/layout.tsx +51 -0
  35. package/app/page.tsx +34 -0
  36. package/app/server/page.tsx +22 -0
  37. package/app/simple/chat/page.tsx +17 -0
  38. package/app/simple/docs/page.tsx +21 -0
  39. package/app/simple/layout.tsx +9 -0
  40. package/app/simple/settings/page.tsx +627 -0
  41. package/app/template.tsx +16 -0
  42. package/app/themes.css +733 -0
  43. package/components/alerts/generic-alert.tsx +74 -0
  44. package/components/app-sidebar.tsx +187 -0
  45. package/components/app-topnav.tsx +195 -0
  46. package/components/chat/chat-settings-modal.tsx +209 -0
  47. package/components/chat/chat-workspace.tsx +348 -0
  48. package/components/chat/knowledge-base-result.tsx +97 -0
  49. package/components/chat/message-item.tsx +103 -0
  50. package/components/client-layout-wrapper.tsx +182 -0
  51. package/components/configure/configure-form.tsx +1683 -0
  52. package/components/configure/store-fields.tsx +152 -0
  53. package/components/data/corpus-panel.tsx +499 -0
  54. package/components/data/data-workspace.tsx +272 -0
  55. package/components/data/firecrawl-notice.tsx +236 -0
  56. package/components/data/jobs-panel.tsx +485 -0
  57. package/components/data/paste-panel.tsx +79 -0
  58. package/components/data/scrape-panel.tsx +964 -0
  59. package/components/data/upload-panel.tsx +714 -0
  60. package/components/demo/demo-workspace.tsx +419 -0
  61. package/components/global-settings.tsx +286 -0
  62. package/components/index/index-workspace.tsx +482 -0
  63. package/components/onboarding/simple-setup.tsx +702 -0
  64. package/components/onboarding/tech-setup.tsx +121 -0
  65. package/components/onboarding/welcome-screen.tsx +183 -0
  66. package/components/page-header.tsx +44 -0
  67. package/components/server/code-viewer.tsx +87 -0
  68. package/components/server/deploy-button.tsx +1390 -0
  69. package/components/server/server-workspace.tsx +392 -0
  70. package/components/simple/sdk-connect-dialog.tsx +201 -0
  71. package/components/simple/simple-index-button.tsx +83 -0
  72. package/components/simple/simple-sidebar.tsx +161 -0
  73. package/components/theme-customizer-provider.tsx +171 -0
  74. package/components/theme-switcher.tsx +445 -0
  75. package/components/ui/alert-dialog.tsx +187 -0
  76. package/components/ui/alert.tsx +76 -0
  77. package/components/ui/badge.tsx +52 -0
  78. package/components/ui/button.tsx +59 -0
  79. package/components/ui/card.tsx +103 -0
  80. package/components/ui/checkbox.tsx +28 -0
  81. package/components/ui/collapsible.tsx +21 -0
  82. package/components/ui/dialog.tsx +159 -0
  83. package/components/ui/dropdown-menu.tsx +281 -0
  84. package/components/ui/input.tsx +22 -0
  85. package/components/ui/label.tsx +20 -0
  86. package/components/ui/popover.tsx +90 -0
  87. package/components/ui/progress.tsx +83 -0
  88. package/components/ui/provider-icon.tsx +94 -0
  89. package/components/ui/scroll-area.tsx +55 -0
  90. package/components/ui/select.tsx +248 -0
  91. package/components/ui/separator.tsx +25 -0
  92. package/components/ui/sheet.tsx +138 -0
  93. package/components/ui/skeleton.tsx +13 -0
  94. package/components/ui/sonner.tsx +63 -0
  95. package/components/ui/switch.tsx +32 -0
  96. package/components/ui/table.tsx +116 -0
  97. package/components/ui/tabs.tsx +84 -0
  98. package/components/ui/textarea.tsx +18 -0
  99. package/components/ui/tooltip.tsx +66 -0
  100. package/components/workspace/delete-server-dialog.tsx +71 -0
  101. package/components/workspace/server-form-dialog.tsx +103 -0
  102. package/components/workspace/server-switcher.tsx +163 -0
  103. package/components/workspace/workspace-provider.tsx +177 -0
  104. package/components/workspace/workspace-top-bar.tsx +23 -0
  105. package/components.json +21 -0
  106. package/lib/error-formatter.ts +15 -0
  107. package/lib/utils.ts +6 -0
  108. package/next-env.d.ts +6 -0
  109. package/next.config.mjs +18 -0
  110. package/package.json +65 -0
  111. package/postcss.config.mjs +8 -0
  112. package/public/docker.png +0 -0
  113. package/public/favicon.ico +0 -0
  114. package/public/github.svg +3 -0
  115. package/public/hetzner.svg +1 -0
  116. package/public/icons/aws.svg +38 -0
  117. package/public/icons/azure.svg +1 -0
  118. package/public/icons/chroma.png +0 -0
  119. package/public/icons/cohere.svg +30 -0
  120. package/public/icons/digital-ocean.webp +0 -0
  121. package/public/icons/firecrawl2.png +0 -0
  122. package/public/icons/gcp.svg +1 -0
  123. package/public/icons/gemini.svg +1 -0
  124. package/public/icons/jina.svg +1 -0
  125. package/public/icons/lancedb2.png +0 -0
  126. package/public/icons/mistral.svg +469 -0
  127. package/public/icons/nomic.png +0 -0
  128. package/public/icons/openai.svg +1 -0
  129. package/public/icons/pgvector2.png +0 -0
  130. package/public/icons/pinecone.png +0 -0
  131. package/public/icons/qdrant.svg +35 -0
  132. package/public/icons/supabase.png +0 -0
  133. package/public/icons/vercel copy.svg +1 -0
  134. package/public/icons/vercel.svg +1 -0
  135. package/public/icons/voyage-light.png +0 -0
  136. package/public/icons/weaviate.webp +0 -0
  137. package/public/logo-light.png +0 -0
  138. package/public/logo.png +0 -0
  139. package/public/logo9.png +0 -0
  140. package/public/vercel.svg +1 -0
  141. package/tsconfig.json +33 -0
@@ -0,0 +1,340 @@
1
+ import { NextRequest } from "next/server";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { getActiveServer } from "@larkup/core/workspace";
5
+ import { NodeSSH } from "node-ssh";
6
+ import crypto from "crypto";
7
+
8
+ function getFilesRecursively(dir: string, baseDir: string): any[] {
9
+ let results: any[] = [];
10
+ if (!fs.existsSync(dir)) return results;
11
+ const list = fs.readdirSync(dir);
12
+ for (const file of list) {
13
+ if (file === "node_modules" || file === ".git" || file === "server.log") continue;
14
+ const filePath = path.join(dir, file);
15
+ const stat = fs.statSync(filePath);
16
+ if (stat && stat.isDirectory()) {
17
+ results = results.concat(getFilesRecursively(filePath, baseDir));
18
+ } else {
19
+ const data = fs.readFileSync(filePath);
20
+ const relativePath = path.relative(baseDir, filePath).replace(/\\/g, "/");
21
+ results.push({ file: relativePath, data: data.toString("utf8") });
22
+ }
23
+ }
24
+ return results;
25
+ }
26
+
27
+ export async function POST(req: NextRequest) {
28
+ const body = await req.json();
29
+ let { serverId, host, username, privateKeyOrPassword, envVars, newPassword: userNewPassword } = body;
30
+
31
+ const stream = new ReadableStream({
32
+ async start(controller) {
33
+ function sendEvent(data: any) {
34
+ try {
35
+ controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`));
36
+ } catch (e) {}
37
+ }
38
+ function log(msg: string) {
39
+ sendEvent({ type: "log", message: msg });
40
+ }
41
+ function done(result: any) {
42
+ sendEvent({ type: "done", ...result });
43
+ try { controller.close(); } catch (e) {}
44
+ }
45
+ function errorMsg(err: string) {
46
+ sendEvent({ type: "error", error: err });
47
+ try { controller.close(); } catch (e) {}
48
+ }
49
+
50
+ let activeId = serverId;
51
+ if (activeId === "default") {
52
+ const server = await getActiveServer();
53
+ if (server) activeId = server.id;
54
+ }
55
+
56
+ if (!host || !username || !privateKeyOrPassword.value) {
57
+ return errorMsg("Host, username, and credentials are required.");
58
+ }
59
+
60
+ try {
61
+ log(`Preparing deployment for server ${activeId}...`);
62
+ const cwd = process.cwd();
63
+ let config: any = null;
64
+ const configCandidates = [
65
+ path.join(cwd, ".larkup", "servers", activeId, "config.json"),
66
+ path.join(cwd, ".larkup", "config.json"),
67
+ ];
68
+ for (const cfgPath of configCandidates) {
69
+ if (fs.existsSync(cfgPath)) {
70
+ try {
71
+ config = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
72
+ break;
73
+ } catch {}
74
+ }
75
+ }
76
+
77
+ if (!config) {
78
+ return errorMsg("Configuration not found. Please save your settings first.");
79
+ }
80
+
81
+ const { generateServer } = await import("@larkup/core/generator/generate-server");
82
+ const generated = generateServer(config);
83
+
84
+ const filesToUpload: { localPath?: string; contents?: string; remotePath: string }[] = [];
85
+
86
+ for (const f of generated.files) {
87
+ filesToUpload.push({ contents: f.contents, remotePath: f.path });
88
+ }
89
+
90
+ const envLines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`);
91
+ const isLanceLocal = config.vectorStore === "lancedb" && config.storeConfig?.mode !== "cloud";
92
+ if (isLanceLocal) {
93
+ envLines.push("LANCEDB_PATH=./.larkup/lancedb");
94
+ }
95
+ filesToUpload.push({ contents: envLines.join("\n"), remotePath: ".env" });
96
+
97
+ if (isLanceLocal) {
98
+ const lancedbDir = path.join(cwd, ".larkup", "servers", activeId, "lancedb");
99
+ if (fs.existsSync(lancedbDir)) {
100
+ const lancedbFiles = getFilesRecursively(
101
+ lancedbDir,
102
+ path.join(cwd, ".larkup", "servers", activeId)
103
+ );
104
+ for (const f of lancedbFiles) {
105
+ filesToUpload.push({
106
+ localPath: path.join(cwd, ".larkup", "servers", activeId, f.file),
107
+ remotePath: f.file
108
+ });
109
+ }
110
+ }
111
+ }
112
+
113
+ let updatedPassword = userNewPassword || "";
114
+ let ssh = new NodeSSH();
115
+ log(`Connecting to ${username}@${host} via SSH...`);
116
+
117
+ const connectOpts = {
118
+ host,
119
+ username,
120
+ privateKey: privateKeyOrPassword.type === "key" ? privateKeyOrPassword.value : undefined,
121
+ password: privateKeyOrPassword.type === "password" ? privateKeyOrPassword.value : undefined,
122
+ readyTimeout: 20000,
123
+ tryKeyboard: true,
124
+ onKeyboardInteractive: (
125
+ _name: string,
126
+ _instructions: string,
127
+ _instructionsLang: string,
128
+ prompts: { prompt: string; echo: boolean }[],
129
+ finish: (responses: string[]) => void
130
+ ) => {
131
+ if (prompts.length > 0 && privateKeyOrPassword.type === "password") {
132
+ const answers = prompts.map((p) => {
133
+ return privateKeyOrPassword.value;
134
+ });
135
+ finish(answers);
136
+ } else {
137
+ finish([]);
138
+ }
139
+ }
140
+ };
141
+
142
+ await ssh.connect(connectOpts);
143
+ log(`Connected successfully.`);
144
+
145
+ // Helper to run commands and stream output
146
+ const runCommand = async (cmd: string, opts?: { cwd?: string; ignoreErrors?: boolean }) => {
147
+ log(`$ ${cmd}`);
148
+ const res = await ssh.execCommand(cmd, {
149
+ cwd: opts?.cwd,
150
+ onStdout: (chunk: Buffer) => log(chunk.toString('utf8').trimEnd()),
151
+ onStderr: (chunk: Buffer) => log(chunk.toString('utf8').trimEnd())
152
+ });
153
+ const combined = `${res.stdout} ${res.stderr}`.toLowerCase();
154
+ if (combined.includes("password change required") || combined.includes("password has expired")) {
155
+ throw new Error("PASSWORD_EXPIRED");
156
+ }
157
+ if (res.code !== 0 && !opts?.ignoreErrors) {
158
+ throw new Error(`Command failed (exit ${res.code}): ${cmd}\n${res.stderr}`);
159
+ }
160
+ return res;
161
+ };
162
+
163
+ const remoteDir = `/opt/buddyhere-rag-${activeId}`;
164
+
165
+ // ── Step 1: Test if the session works or if password change is needed ──
166
+ let needsPasswordChange = false;
167
+ try {
168
+ await runCommand("echo ok");
169
+ } catch (err: any) {
170
+ if (err.message === "PASSWORD_EXPIRED") {
171
+ needsPasswordChange = true;
172
+ } else {
173
+ throw err;
174
+ }
175
+ }
176
+
177
+ // ── Step 2: If password expired, handle password change ──
178
+ if (needsPasswordChange) {
179
+ // If the user hasn't provided a new password yet, ask the frontend
180
+ if (!updatedPassword) {
181
+ log(`⚠️ Password has expired! A new password is required.`);
182
+ sendEvent({ type: "password_change_required" });
183
+ try { controller.close(); } catch (e) {}
184
+ return;
185
+ }
186
+
187
+ // User provided a new password — change it via PTY
188
+ log(`⚠️ Password has expired! Changing password...`);
189
+
190
+ await new Promise<void>((resolve, reject) => {
191
+ const timeout = setTimeout(() => {
192
+ reject(new Error("Password change timed out after 30 seconds."));
193
+ }, 30000);
194
+
195
+ ssh.requestShell({ term: 'xterm' }).then(shell => {
196
+ let buffer = "";
197
+ let stage = 0; // 0=waiting for current, 1=waiting for new, 2=waiting for retype, 3=done
198
+
199
+ shell.on('data', (data: Buffer) => {
200
+ const text = data.toString('utf8');
201
+ buffer += text;
202
+ // Stream raw shell output to the UI (clean up control chars)
203
+ const clean = text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').trim();
204
+ if (clean) log(clean);
205
+
206
+ const lower = buffer.toLowerCase();
207
+
208
+ if (stage === 0) {
209
+ if ((lower.includes("current") || lower.includes("old")) && lower.includes("password")) {
210
+ shell.write(privateKeyOrPassword.value + "\n");
211
+ buffer = "";
212
+ stage = 1;
213
+ } else if (lower.includes("new") && lower.includes("password")) {
214
+ // Hetzner sometimes skips asking for the current password
215
+ shell.write(updatedPassword + "\n");
216
+ buffer = "";
217
+ stage = 2;
218
+ }
219
+ } else if (stage === 1 && lower.includes("new") && lower.includes("password")) {
220
+ shell.write(updatedPassword + "\n");
221
+ buffer = "";
222
+ stage = 2;
223
+ } else if (stage === 2 && (lower.includes("retype") || lower.includes("re-enter") || lower.includes("confirm") || (lower.includes("new") && lower.includes("password")))) {
224
+ shell.write(updatedPassword + "\n");
225
+ buffer = "";
226
+ stage = 3;
227
+ } else if (stage === 3 && (lower.includes("updated successfully") || lower.includes("password changed") || lower.includes("$") || lower.includes("#"))) {
228
+ clearTimeout(timeout);
229
+ shell.end();
230
+ resolve();
231
+ }
232
+ });
233
+
234
+ shell.on('close', () => {
235
+ clearTimeout(timeout);
236
+ resolve();
237
+ });
238
+ shell.on('error', (err: Error) => {
239
+ clearTimeout(timeout);
240
+ reject(err);
241
+ });
242
+ }).catch((err: Error) => {
243
+ clearTimeout(timeout);
244
+ reject(err);
245
+ });
246
+ });
247
+
248
+ log("✅ Password changed successfully.");
249
+ sendEvent({ type: "password_changed", newPassword: updatedPassword });
250
+
251
+ // Disconnect and reconnect with the new password
252
+ ssh.dispose();
253
+ log("Reconnecting with new password...");
254
+ ssh = new NodeSSH();
255
+ await ssh.connect({
256
+ ...connectOpts,
257
+ password: updatedPassword,
258
+ onKeyboardInteractive: (
259
+ _name: string,
260
+ _instructions: string,
261
+ _instructionsLang: string,
262
+ prompts: { prompt: string; echo: boolean }[],
263
+ finish: (responses: string[]) => void
264
+ ) => {
265
+ if (prompts.length > 0) {
266
+ finish(prompts.map(() => updatedPassword));
267
+ } else {
268
+ finish([]);
269
+ }
270
+ }
271
+ });
272
+ log("Reconnected successfully.");
273
+ }
274
+
275
+ // ── Step 3: Ensure Docker is installed ──
276
+ log("Checking for Docker...");
277
+ const dockerCheck = await runCommand("docker --version", { ignoreErrors: true });
278
+ if (dockerCheck.code !== 0) {
279
+ log("Docker not found. Installing Docker (this may take a few minutes)...");
280
+ await runCommand("curl -fsSL https://get.docker.com -o /tmp/get-docker.sh && sh /tmp/get-docker.sh");
281
+ }
282
+
283
+ // ── Step 4: Upload files ──
284
+ log(`Creating remote directory ${remoteDir}...`);
285
+ await runCommand(`mkdir -p ${remoteDir}`);
286
+
287
+ log(`Uploading ${filesToUpload.length} files...`);
288
+ for (const f of filesToUpload) {
289
+ const dir = path.posix.dirname(f.remotePath);
290
+ if (dir && dir !== ".") {
291
+ await runCommand(`mkdir -p ${remoteDir}/${dir}`, { ignoreErrors: true });
292
+ }
293
+
294
+ const target = `${remoteDir}/${f.remotePath}`;
295
+ if (f.localPath) {
296
+ log(` ↑ ${f.remotePath}`);
297
+ await ssh.putFile(f.localPath, target);
298
+ } else if (f.contents) {
299
+ log(` ↑ ${f.remotePath}`);
300
+ const tempPath = path.join("/tmp", `buddyhere-upload-${Date.now()}-${Math.random().toString(36).substring(7)}`);
301
+ fs.writeFileSync(tempPath, f.contents);
302
+ await ssh.putFile(tempPath, target);
303
+ fs.unlinkSync(tempPath);
304
+ }
305
+ }
306
+
307
+ // ── Step 5: Docker Compose ──
308
+ log(`Starting Docker containers...`);
309
+ await runCommand("docker compose down 2>/dev/null || true", { cwd: remoteDir, ignoreErrors: true });
310
+
311
+ const composeRes = await runCommand("docker compose up -d --build", { cwd: remoteDir, ignoreErrors: true });
312
+ if (composeRes.code !== 0) {
313
+ log("docker compose v2 failed, trying docker-compose v1...");
314
+ await runCommand("docker-compose up -d --build", { cwd: remoteDir });
315
+ }
316
+
317
+ ssh.dispose();
318
+ log(`🎉 Deployment completed successfully!`);
319
+
320
+ done({
321
+ success: true,
322
+ url: `http://${host}:8080`,
323
+ newPassword: updatedPassword || undefined,
324
+ });
325
+
326
+ } catch (error: any) {
327
+ console.error("SSH Deploy Error:", error);
328
+ errorMsg(error.message || "Failed to deploy via SSH.");
329
+ }
330
+ }
331
+ });
332
+
333
+ return new Response(stream, {
334
+ headers: {
335
+ "Content-Type": "text/event-stream",
336
+ "Cache-Control": "no-cache",
337
+ "Connection": "keep-alive",
338
+ },
339
+ });
340
+ }
@@ -0,0 +1,90 @@
1
+ import { NextResponse } from "next/server"
2
+ import {
3
+ addDocument,
4
+ clearDocuments,
5
+ corpusStats,
6
+ deleteDocument,
7
+ deleteDocuments,
8
+ readDocuments,
9
+ updateDocument,
10
+ } from "@larkup/core/documents-store"
11
+ import type { DocumentSource } from "@larkup/core/types"
12
+
13
+ export const runtime = "nodejs"
14
+ export const dynamic = "force-dynamic"
15
+
16
+ /** GET → the full corpus plus summary stats. */
17
+ export async function GET() {
18
+ const [documents, stats] = await Promise.all([readDocuments(), corpusStats()])
19
+ return NextResponse.json({ documents, stats })
20
+ }
21
+
22
+ /**
23
+ * POST → ingest pasted text or an uploaded file's contents.
24
+ * Body: { title, content, source: "paste" | "upload", url? }
25
+ */
26
+ export async function POST(req: Request) {
27
+ try {
28
+ const body = (await req.json()) as {
29
+ title?: string
30
+ content?: string
31
+ source?: DocumentSource
32
+ url?: string
33
+ metadata?: Record<string, any>
34
+ }
35
+ if (!body.content || !body.content.trim()) {
36
+ return NextResponse.json({ error: "Content is empty." }, { status: 400 })
37
+ }
38
+ const doc = await addDocument({
39
+ title: body.title ?? "Untitled",
40
+ content: body.content,
41
+ source: body.source === "upload" ? "upload" : "paste",
42
+ url: body.url,
43
+ metadata: body.metadata,
44
+ })
45
+ return NextResponse.json({ document: doc }, { status: 201 })
46
+ } catch (err) {
47
+ const message = err instanceof Error ? err.message : "Failed to add document."
48
+ return NextResponse.json({ error: message }, { status: 500 })
49
+ }
50
+ }
51
+
52
+ /**
53
+ * PATCH → edit a document in place.
54
+ * Body: { id, title?, content?, url? }
55
+ */
56
+ export async function PATCH(req: Request) {
57
+ let body: { id?: string; title?: string; content?: string; url?: string; metadata?: Record<string, any> }
58
+ try {
59
+ body = await req.json()
60
+ } catch {
61
+ return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 })
62
+ }
63
+ if (!body.id) {
64
+ return NextResponse.json({ error: "id is required." }, { status: 400 })
65
+ }
66
+ if (body.content !== undefined && !body.content.trim()) {
67
+ return NextResponse.json({ error: "Content is empty." }, { status: 400 })
68
+ }
69
+ const doc = await updateDocument(body.id, {
70
+ title: body.title,
71
+ content: body.content,
72
+ url: body.url,
73
+ metadata: body.metadata,
74
+ })
75
+ if (!doc) {
76
+ return NextResponse.json({ error: "Document not found." }, { status: 404 })
77
+ }
78
+ return NextResponse.json({ document: doc })
79
+ }
80
+
81
+ /** DELETE ?id=<id> removes one doc; DELETE ?ids=1,2 removes many; DELETE with no id clears the corpus. */
82
+ export async function DELETE(req: Request) {
83
+ const url = new URL(req.url)
84
+ const id = url.searchParams.get("id")
85
+ const ids = url.searchParams.get("ids")
86
+ if (id) await deleteDocument(id)
87
+ else if (ids) await deleteDocuments(ids.split(","))
88
+ else await clearDocuments()
89
+ return NextResponse.json({ ok: true })
90
+ }
@@ -0,0 +1,46 @@
1
+ import { NextResponse } from "next/server"
2
+ import {
3
+ checkDocker,
4
+ readLocalState,
5
+ refreshLocalStatus,
6
+ startLocal,
7
+ stopLocal,
8
+ } from "@larkup/scraper/local-runtime"
9
+
10
+ export const runtime = "nodejs"
11
+ export const dynamic = "force-dynamic"
12
+
13
+ /** GET → current local instance state + docker availability. */
14
+ export async function GET() {
15
+ const [state, docker] = await Promise.all([
16
+ refreshLocalStatus(),
17
+ checkDocker(),
18
+ ])
19
+ // Never leak the bearer token to the client; just report whether one exists.
20
+ const { apiKey, ...safe } = state
21
+ return NextResponse.json({
22
+ state: { ...safe, hasKey: Boolean(apiKey) },
23
+ docker,
24
+ })
25
+ }
26
+
27
+ /** POST { action: "start" | "stop" } → control the local Firecrawl container. */
28
+ export async function POST(req: Request) {
29
+ let action: string | undefined
30
+ try {
31
+ ;({ action } = (await req.json()) as { action?: string })
32
+ } catch {
33
+ return NextResponse.json({ error: "Invalid request body." }, { status: 400 })
34
+ }
35
+
36
+ if (action !== "start" && action !== "stop") {
37
+ return NextResponse.json(
38
+ { error: 'action must be "start" or "stop".' },
39
+ { status: 400 },
40
+ )
41
+ }
42
+
43
+ const state = action === "start" ? await startLocal() : await stopLocal()
44
+ const { apiKey, ...safe } = state
45
+ return NextResponse.json({ state: { ...safe, hasKey: Boolean(apiKey) } })
46
+ }
@@ -0,0 +1,113 @@
1
+ import { NextResponse } from "next/server"
2
+ import { readConfig } from "@larkup/core/config-store"
3
+ import { corpusStats } from "@larkup/core/documents-store"
4
+ import { isRunning, readRun } from "@larkup/core/index-store"
5
+ import { createRun, runIndexer } from "@larkup/core/indexing/indexer"
6
+ import { getEmbeddingModel } from "@larkup/core/embeddings/registry"
7
+ import type { RagConfig } from "@larkup/core/types"
8
+
9
+ export const dynamic = "force-dynamic"
10
+
11
+ /**
12
+ * Assess whether indexing can run with the current config + corpus.
13
+ * Returned to the UI so it can explain exactly what is missing before enabling
14
+ * the "Build index" button.
15
+ */
16
+ function assessReadiness(config: RagConfig, docCount: number) {
17
+ const blockers: string[] = []
18
+
19
+ const model = getEmbeddingModel(config.embeddingModelId)
20
+ if (!model) blockers.push("No embedding model is selected.")
21
+
22
+ if (docCount === 0)
23
+ blockers.push("The corpus is empty — load documents in the Data stage.")
24
+
25
+ // Pinecone needs a hosted index + key; LanceDB is local and always ready.
26
+ if (config.vectorStore === "pinecone") {
27
+ if (!config.storeConfig?.apiKey?.trim())
28
+ blockers.push("PINECONE_API_KEY is not set.")
29
+ if (!config.storeConfig?.indexName?.trim())
30
+ blockers.push("A Pinecone index name is required.")
31
+ }
32
+
33
+ return { ready: blockers.length === 0, blockers }
34
+ }
35
+
36
+ export async function GET() {
37
+ const [config, stats, run] = await Promise.all([
38
+ readConfig(),
39
+ corpusStats(),
40
+ readRun(),
41
+ ])
42
+ const { ready, blockers } = assessReadiness(config, stats.docCount)
43
+
44
+ let unindexedCount = 0
45
+ if (run?.status === "completed") {
46
+ const { readDocuments } = await import("@larkup/core/documents-store")
47
+ const docs = await readDocuments()
48
+ unindexedCount = docs.filter(d => d.createdAt > run.startedAt).length
49
+ } else {
50
+ unindexedCount = stats.docCount
51
+ }
52
+
53
+ return NextResponse.json({
54
+ run,
55
+ running: await isRunning(),
56
+ docCount: stats.docCount,
57
+ charCount: stats.charCount,
58
+ ready,
59
+ blockers,
60
+ unindexedCount,
61
+ config: {
62
+ embeddingModelId: config.embeddingModelId,
63
+ vectorStore: config.vectorStore,
64
+ indexType: config.indexType,
65
+ chunking: config.chunking,
66
+ },
67
+ })
68
+ }
69
+
70
+ export async function POST(req: Request) {
71
+ if (await isRunning()) {
72
+ return NextResponse.json(
73
+ { error: "An indexing run is already in progress." },
74
+ { status: 409 },
75
+ )
76
+ }
77
+
78
+ const [config, stats] = await Promise.all([readConfig(), corpusStats()])
79
+ const { ready, blockers } = assessReadiness(config, stats.docCount)
80
+ if (!ready) {
81
+ return NextResponse.json(
82
+ { error: blockers.join(" ") || "Indexing is not ready.", blockers },
83
+ { status: 400 },
84
+ )
85
+ }
86
+
87
+ let body: any = {}
88
+ try {
89
+ body = await req.json()
90
+ } catch {}
91
+
92
+ const incremental = body.incremental === true
93
+ const previousRun = await readRun()
94
+
95
+ const run = await createRun(config)
96
+ // Fire-and-forget: the request returns immediately and the UI polls progress.
97
+ void runIndexer(run.id, config, incremental ? previousRun : null)
98
+
99
+ return NextResponse.json({ run }, { status: 202 })
100
+ }
101
+
102
+ export async function DELETE() {
103
+ const { readRun, patchRun } = await import("@larkup/core/index-store")
104
+ const run = await readRun()
105
+ if (run && ["chunking", "embedding", "upserting"].includes(run.status)) {
106
+ await patchRun({
107
+ status: "failed",
108
+ error: "Cancelled by user.",
109
+ finishedAt: new Date().toISOString(),
110
+ })
111
+ }
112
+ return NextResponse.json({ success: true })
113
+ }
@@ -0,0 +1,44 @@
1
+ import { NextResponse } from "next/server"
2
+ import { deleteJob, getJob } from "@larkup/core/jobs-store"
3
+ import { cancelJob, syncJob } from "@larkup/scraper/job-runner"
4
+
5
+ export const runtime = "nodejs"
6
+ export const dynamic = "force-dynamic"
7
+
8
+ /**
9
+ * GET → advance the job one increment (pull newly-scraped pages, persist them)
10
+ * and return the latest state. The UI polls this on an interval, which is how a
11
+ * long-running crawl streams documents into the corpus.
12
+ */
13
+ export async function GET(
14
+ _req: Request,
15
+ { params }: { params: Promise<{ id: string }> },
16
+ ) {
17
+ const { id } = await params
18
+ const job = await syncJob(id)
19
+ if (!job) {
20
+ return NextResponse.json({ error: "Job not found." }, { status: 404 })
21
+ }
22
+ return NextResponse.json({ job })
23
+ }
24
+
25
+ /** DELETE → cancel a running job, or remove a finished one (?remove=1). */
26
+ export async function DELETE(
27
+ req: Request,
28
+ { params }: { params: Promise<{ id: string }> },
29
+ ) {
30
+ const { id } = await params
31
+ const remove = new URL(req.url).searchParams.get("remove")
32
+
33
+ if (remove) {
34
+ await deleteJob(id)
35
+ return NextResponse.json({ ok: true })
36
+ }
37
+
38
+ const existing = await getJob(id)
39
+ if (!existing) {
40
+ return NextResponse.json({ error: "Job not found." }, { status: 404 })
41
+ }
42
+ const job = await cancelJob(id)
43
+ return NextResponse.json({ job })
44
+ }