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.
- package/.env +2 -0
- package/app/actions/vercel.ts +417 -0
- package/app/api/chat/route.ts +192 -0
- package/app/api/chat/status/route.ts +55 -0
- package/app/api/chat/suggestions/route.ts +115 -0
- package/app/api/config/route.ts +73 -0
- package/app/api/config/test/route.ts +67 -0
- package/app/api/config/test-embedding/route.ts +40 -0
- package/app/api/config/test-llm/route.ts +47 -0
- package/app/api/config/test-provider/route.ts +36 -0
- package/app/api/demo/route.ts +176 -0
- package/app/api/deploy/ssh/route.ts +340 -0
- package/app/api/documents/route.ts +90 -0
- package/app/api/firecrawl/local/route.ts +46 -0
- package/app/api/index/route.ts +113 -0
- package/app/api/jobs/[id]/route.ts +44 -0
- package/app/api/jobs/route.ts +116 -0
- package/app/api/parse-file/route.ts +39 -0
- package/app/api/search/google/route.ts +162 -0
- package/app/api/search/route.ts +29 -0
- package/app/api/server/download/route.ts +112 -0
- package/app/api/server/generate/route.ts +46 -0
- package/app/api/server/local/route.ts +42 -0
- package/app/api/servers/route.ts +137 -0
- package/app/api/vector-stores/install/route.ts +65 -0
- package/app/api/vector-stores/status/route.ts +26 -0
- package/app/chat/page.tsx +15 -0
- package/app/configure/page.tsx +53 -0
- package/app/data/page.tsx +15 -0
- package/app/demo/page.tsx +15 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +190 -0
- package/app/index-data/page.tsx +15 -0
- package/app/layout.tsx +51 -0
- package/app/page.tsx +34 -0
- package/app/server/page.tsx +22 -0
- package/app/simple/chat/page.tsx +17 -0
- package/app/simple/docs/page.tsx +21 -0
- package/app/simple/layout.tsx +9 -0
- package/app/simple/settings/page.tsx +627 -0
- package/app/template.tsx +16 -0
- package/app/themes.css +733 -0
- package/components/alerts/generic-alert.tsx +74 -0
- package/components/app-sidebar.tsx +187 -0
- package/components/app-topnav.tsx +195 -0
- package/components/chat/chat-settings-modal.tsx +209 -0
- package/components/chat/chat-workspace.tsx +348 -0
- package/components/chat/knowledge-base-result.tsx +97 -0
- package/components/chat/message-item.tsx +103 -0
- package/components/client-layout-wrapper.tsx +182 -0
- package/components/configure/configure-form.tsx +1683 -0
- package/components/configure/store-fields.tsx +152 -0
- package/components/data/corpus-panel.tsx +499 -0
- package/components/data/data-workspace.tsx +272 -0
- package/components/data/firecrawl-notice.tsx +236 -0
- package/components/data/jobs-panel.tsx +485 -0
- package/components/data/paste-panel.tsx +79 -0
- package/components/data/scrape-panel.tsx +964 -0
- package/components/data/upload-panel.tsx +714 -0
- package/components/demo/demo-workspace.tsx +419 -0
- package/components/global-settings.tsx +286 -0
- package/components/index/index-workspace.tsx +482 -0
- package/components/onboarding/simple-setup.tsx +702 -0
- package/components/onboarding/tech-setup.tsx +121 -0
- package/components/onboarding/welcome-screen.tsx +183 -0
- package/components/page-header.tsx +44 -0
- package/components/server/code-viewer.tsx +87 -0
- package/components/server/deploy-button.tsx +1390 -0
- package/components/server/server-workspace.tsx +392 -0
- package/components/simple/sdk-connect-dialog.tsx +201 -0
- package/components/simple/simple-index-button.tsx +83 -0
- package/components/simple/simple-sidebar.tsx +161 -0
- package/components/theme-customizer-provider.tsx +171 -0
- package/components/theme-switcher.tsx +445 -0
- package/components/ui/alert-dialog.tsx +187 -0
- package/components/ui/alert.tsx +76 -0
- package/components/ui/badge.tsx +52 -0
- package/components/ui/button.tsx +59 -0
- package/components/ui/card.tsx +103 -0
- package/components/ui/checkbox.tsx +28 -0
- package/components/ui/collapsible.tsx +21 -0
- package/components/ui/dialog.tsx +159 -0
- package/components/ui/dropdown-menu.tsx +281 -0
- package/components/ui/input.tsx +22 -0
- package/components/ui/label.tsx +20 -0
- package/components/ui/popover.tsx +90 -0
- package/components/ui/progress.tsx +83 -0
- package/components/ui/provider-icon.tsx +94 -0
- package/components/ui/scroll-area.tsx +55 -0
- package/components/ui/select.tsx +248 -0
- package/components/ui/separator.tsx +25 -0
- package/components/ui/sheet.tsx +138 -0
- package/components/ui/skeleton.tsx +13 -0
- package/components/ui/sonner.tsx +63 -0
- package/components/ui/switch.tsx +32 -0
- package/components/ui/table.tsx +116 -0
- package/components/ui/tabs.tsx +84 -0
- package/components/ui/textarea.tsx +18 -0
- package/components/ui/tooltip.tsx +66 -0
- package/components/workspace/delete-server-dialog.tsx +71 -0
- package/components/workspace/server-form-dialog.tsx +103 -0
- package/components/workspace/server-switcher.tsx +163 -0
- package/components/workspace/workspace-provider.tsx +177 -0
- package/components/workspace/workspace-top-bar.tsx +23 -0
- package/components.json +21 -0
- package/lib/error-formatter.ts +15 -0
- package/lib/utils.ts +6 -0
- package/next-env.d.ts +6 -0
- package/next.config.mjs +18 -0
- package/package.json +65 -0
- package/postcss.config.mjs +8 -0
- package/public/docker.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/github.svg +3 -0
- package/public/hetzner.svg +1 -0
- package/public/icons/aws.svg +38 -0
- package/public/icons/azure.svg +1 -0
- package/public/icons/chroma.png +0 -0
- package/public/icons/cohere.svg +30 -0
- package/public/icons/digital-ocean.webp +0 -0
- package/public/icons/firecrawl2.png +0 -0
- package/public/icons/gcp.svg +1 -0
- package/public/icons/gemini.svg +1 -0
- package/public/icons/jina.svg +1 -0
- package/public/icons/lancedb2.png +0 -0
- package/public/icons/mistral.svg +469 -0
- package/public/icons/nomic.png +0 -0
- package/public/icons/openai.svg +1 -0
- package/public/icons/pgvector2.png +0 -0
- package/public/icons/pinecone.png +0 -0
- package/public/icons/qdrant.svg +35 -0
- package/public/icons/supabase.png +0 -0
- package/public/icons/vercel copy.svg +1 -0
- package/public/icons/vercel.svg +1 -0
- package/public/icons/voyage-light.png +0 -0
- package/public/icons/weaviate.webp +0 -0
- package/public/logo-light.png +0 -0
- package/public/logo.png +0 -0
- package/public/logo9.png +0 -0
- package/public/vercel.svg +1 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { NextResponse } from "next/server"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
import { readJobs, saveJob } from "@larkup/core/jobs-store"
|
|
4
|
+
import { syncJob } from "@larkup/scraper/job-runner"
|
|
5
|
+
import { isFirecrawlConfigured } from "@larkup/scraper/firecrawl"
|
|
6
|
+
import { readDocuments } from "@larkup/core/documents-store"
|
|
7
|
+
import type { CrawlJob, CrawlScope, CrawlTarget } from "@larkup/core/types"
|
|
8
|
+
|
|
9
|
+
export const runtime = "nodejs"
|
|
10
|
+
export const dynamic = "force-dynamic"
|
|
11
|
+
|
|
12
|
+
/** GET → all crawl jobs, newest first. */
|
|
13
|
+
export async function GET() {
|
|
14
|
+
const jobs = await readJobs()
|
|
15
|
+
return NextResponse.json({ jobs, configured: await isFirecrawlConfigured() })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* POST → start a new ETL job.
|
|
20
|
+
* Body: { keywords, targets: [{ url, scope }], pageLimit }
|
|
21
|
+
*/
|
|
22
|
+
export async function POST(req: Request) {
|
|
23
|
+
if (!(await isFirecrawlConfigured())) {
|
|
24
|
+
return NextResponse.json(
|
|
25
|
+
{
|
|
26
|
+
error:
|
|
27
|
+
"No Firecrawl available. Launch a local instance or set FIRECRAWL_API_KEY to run scraping jobs.",
|
|
28
|
+
},
|
|
29
|
+
{ status: 401 },
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let body: {
|
|
34
|
+
keywords?: string
|
|
35
|
+
targets?: Array<{ url?: string; scope?: CrawlScope }>
|
|
36
|
+
pageLimit?: number
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
body = await req.json()
|
|
40
|
+
} catch {
|
|
41
|
+
return NextResponse.json({ error: "Invalid request body." }, { status: 400 })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const docs = await readDocuments()
|
|
45
|
+
const existingUrls = new Set(docs.filter(d => d.url).map(d => d.url!))
|
|
46
|
+
|
|
47
|
+
const baseMap = new Map<string, CrawlTarget>()
|
|
48
|
+
const pathMap = new Map<string, CrawlTarget>()
|
|
49
|
+
|
|
50
|
+
;(body.targets ?? []).forEach((t) => {
|
|
51
|
+
if (!t.url || !/^https?:\/\//i.test(t.url)) return
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const u = new URL(t.url)
|
|
55
|
+
const origin = u.origin
|
|
56
|
+
|
|
57
|
+
// Add base if not existing
|
|
58
|
+
if (!existingUrls.has(origin) && !existingUrls.has(origin + '/')) {
|
|
59
|
+
baseMap.set(origin, {
|
|
60
|
+
url: origin,
|
|
61
|
+
scope: t.scope === "domain" ? "domain" : "page",
|
|
62
|
+
status: "queued",
|
|
63
|
+
pagesCrawled: 0,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Add specific path if not origin and not existing
|
|
68
|
+
if (t.url !== origin && t.url !== origin + '/') {
|
|
69
|
+
if (!existingUrls.has(t.url)) {
|
|
70
|
+
pathMap.set(t.url, {
|
|
71
|
+
url: t.url,
|
|
72
|
+
scope: t.scope === "domain" ? "domain" : "page",
|
|
73
|
+
status: "queued",
|
|
74
|
+
pagesCrawled: 0,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Fallback for weird URLs
|
|
80
|
+
if (!existingUrls.has(t.url)) {
|
|
81
|
+
pathMap.set(t.url, {
|
|
82
|
+
url: t.url,
|
|
83
|
+
scope: t.scope === "domain" ? "domain" : "page",
|
|
84
|
+
status: "queued",
|
|
85
|
+
pagesCrawled: 0,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const cleaned = [...Array.from(baseMap.values()), ...Array.from(pathMap.values())]
|
|
92
|
+
|
|
93
|
+
if (cleaned.length === 0) {
|
|
94
|
+
return NextResponse.json(
|
|
95
|
+
{ error: "Select at least one valid new URL to scrape." },
|
|
96
|
+
{ status: 400 },
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const job: CrawlJob = {
|
|
101
|
+
id: randomUUID(),
|
|
102
|
+
keywords: body.keywords?.trim() || cleaned[0].url,
|
|
103
|
+
targets: cleaned,
|
|
104
|
+
status: "queued",
|
|
105
|
+
pageLimit: Math.min(Math.max(body.pageLimit ?? 2000, 1), 2000),
|
|
106
|
+
pagesCrawled: 0,
|
|
107
|
+
docCount: 0,
|
|
108
|
+
createdAt: new Date().toISOString(),
|
|
109
|
+
updatedAt: new Date().toISOString(),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await saveJob(job)
|
|
113
|
+
// Kick the job once so domain crawls start and single pages scrape immediately.
|
|
114
|
+
const advanced = await syncJob(job.id)
|
|
115
|
+
return NextResponse.json({ job: advanced ?? job }, { status: 201 })
|
|
116
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
// @ts-expect-error -- import from lib to avoid pdf-parse's test-file-loading bug
|
|
3
|
+
import pdfParse from "pdf-parse/lib/pdf-parse.js";
|
|
4
|
+
import mammoth from "mammoth";
|
|
5
|
+
|
|
6
|
+
export const runtime = "nodejs";
|
|
7
|
+
export const dynamic = "force-dynamic";
|
|
8
|
+
|
|
9
|
+
export async function POST(req: Request) {
|
|
10
|
+
try {
|
|
11
|
+
const formData = await req.formData();
|
|
12
|
+
const file = formData.get("file") as File | null;
|
|
13
|
+
|
|
14
|
+
if (!file) {
|
|
15
|
+
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
19
|
+
let text = "";
|
|
20
|
+
|
|
21
|
+
if (file.type === "application/pdf" || file.name.endsWith(".pdf")) {
|
|
22
|
+
const data = await pdfParse(buffer);
|
|
23
|
+
text = data.text;
|
|
24
|
+
} else if (
|
|
25
|
+
file.type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
|
|
26
|
+
file.name.endsWith(".docx")
|
|
27
|
+
) {
|
|
28
|
+
const result = await mammoth.extractRawText({ buffer });
|
|
29
|
+
text = result.value;
|
|
30
|
+
} else {
|
|
31
|
+
return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return NextResponse.json({ text });
|
|
35
|
+
} catch (err) {
|
|
36
|
+
const message = err instanceof Error ? err.message : "Failed to parse file.";
|
|
37
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { readConfig } from "@larkup/core/config-store";
|
|
3
|
+
|
|
4
|
+
export const runtime = "nodejs";
|
|
5
|
+
export const dynamic = "force-dynamic";
|
|
6
|
+
|
|
7
|
+
export interface SerperSearchItem {
|
|
8
|
+
url: string;
|
|
9
|
+
title: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SerperSearchResponse {
|
|
14
|
+
items: SerperSearchItem[];
|
|
15
|
+
totalResults: number;
|
|
16
|
+
currentPage: number;
|
|
17
|
+
totalPages: number;
|
|
18
|
+
hasMore: boolean;
|
|
19
|
+
query: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* POST { query, page } → paginated Serper.dev Google Search results with total count.
|
|
24
|
+
*
|
|
25
|
+
* Uses the Serper.dev API: https://serper.dev
|
|
26
|
+
* Much simpler than Google CSE — only one env var needed.
|
|
27
|
+
*
|
|
28
|
+
* Required env var:
|
|
29
|
+
* SERPER_API_KEY – from https://serper.dev → API Key tab
|
|
30
|
+
*
|
|
31
|
+
* Serper returns up to 100 results (10 per page, page 1–10).
|
|
32
|
+
*/
|
|
33
|
+
export async function POST(req: Request) {
|
|
34
|
+
const config = await readConfig();
|
|
35
|
+
const apiKey = config.serperApiKey;
|
|
36
|
+
console.log({ apiKey });
|
|
37
|
+
|
|
38
|
+
if (!apiKey) {
|
|
39
|
+
return NextResponse.json(
|
|
40
|
+
{
|
|
41
|
+
error:
|
|
42
|
+
"Serper search is not configured. Set SERPER_API_KEY in your environment.",
|
|
43
|
+
configured: false,
|
|
44
|
+
},
|
|
45
|
+
{ status: 503 },
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let body: { query?: string; page?: number };
|
|
50
|
+
try {
|
|
51
|
+
body = await req.json();
|
|
52
|
+
} catch {
|
|
53
|
+
return NextResponse.json(
|
|
54
|
+
{ error: "Invalid request body." },
|
|
55
|
+
{ status: 400 },
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { query, page = 1 } = body;
|
|
60
|
+
if (!query?.trim()) {
|
|
61
|
+
return NextResponse.json(
|
|
62
|
+
{ error: "A search query is required." },
|
|
63
|
+
{ status: 400 },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const clampedPage = Math.max(1, Math.min(page, 10));
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch("https://google.serper.dev/search", {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: {
|
|
73
|
+
"X-API-KEY": apiKey,
|
|
74
|
+
"Content-Type": "application/json",
|
|
75
|
+
},
|
|
76
|
+
body: JSON.stringify({
|
|
77
|
+
q: query.trim(),
|
|
78
|
+
num: 10,
|
|
79
|
+
page: clampedPage,
|
|
80
|
+
}),
|
|
81
|
+
cache: "no-store",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const json = (await res.json()) as {
|
|
85
|
+
error?: string;
|
|
86
|
+
/** Serper free plan does NOT include searchInformation — we derive pagination from organic count. */
|
|
87
|
+
searchInformation?: {
|
|
88
|
+
totalResults?: number | string;
|
|
89
|
+
formattedTotalResults?: string;
|
|
90
|
+
timeTakenDisplayed?: number;
|
|
91
|
+
};
|
|
92
|
+
organic?: Array<{
|
|
93
|
+
link?: string;
|
|
94
|
+
title?: string;
|
|
95
|
+
snippet?: string;
|
|
96
|
+
position?: number;
|
|
97
|
+
}>;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
if (!res.ok || json.error) {
|
|
101
|
+
const msg = json.error ?? `Serper API error (${res.status})`;
|
|
102
|
+
return NextResponse.json({ error: msg }, { status: res.status });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const items: SerperSearchItem[] = (json.organic ?? [])
|
|
106
|
+
.filter((r) => r.link)
|
|
107
|
+
.map((r) => ({
|
|
108
|
+
url: r.link!,
|
|
109
|
+
title: r.title ?? r.link!,
|
|
110
|
+
description: r.snippet,
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
// Serper's free plan omits searchInformation.totalResults.
|
|
114
|
+
// We derive pagination heuristically:
|
|
115
|
+
// • A full page (10 results) means there are likely more pages.
|
|
116
|
+
// • We cap at 10 pages (100 results) — Serper's max accessible window.
|
|
117
|
+
// • If fewer than 10 results returned, this is the last page.
|
|
118
|
+
const gotFullPage = items.length >= 10;
|
|
119
|
+
const hasMore = gotFullPage && clampedPage < 10;
|
|
120
|
+
|
|
121
|
+
// Derive a synthetic total: current page offset + items on this page,
|
|
122
|
+
// plus "+?" if we believe there are more pages.
|
|
123
|
+
const minTotal = (clampedPage - 1) * 10 + items.length;
|
|
124
|
+
// If searchInformation is present (cloud/enterprise plan), use it.
|
|
125
|
+
const siTotal = json.searchInformation?.totalResults;
|
|
126
|
+
const rawTotal =
|
|
127
|
+
siTotal != null
|
|
128
|
+
? typeof siTotal === "string"
|
|
129
|
+
? parseInt(siTotal, 10) || minTotal
|
|
130
|
+
: siTotal
|
|
131
|
+
: minTotal;
|
|
132
|
+
|
|
133
|
+
// Total pages: if we know the real count, cap at 10; otherwise estimate.
|
|
134
|
+
const totalPages =
|
|
135
|
+
siTotal != null
|
|
136
|
+
? Math.min(Math.max(1, Math.ceil(Math.min(rawTotal, 100) / 10)), 10)
|
|
137
|
+
: hasMore
|
|
138
|
+
? 10 // unknown upper bound — show up to 10 pages
|
|
139
|
+
: clampedPage; // last page reached
|
|
140
|
+
|
|
141
|
+
return NextResponse.json({
|
|
142
|
+
items,
|
|
143
|
+
totalResults: rawTotal,
|
|
144
|
+
// Pass along whether totalResults is an estimate
|
|
145
|
+
totalResultsIsEstimate: siTotal == null,
|
|
146
|
+
currentPage: clampedPage,
|
|
147
|
+
totalPages,
|
|
148
|
+
hasMore,
|
|
149
|
+
query: query.trim(),
|
|
150
|
+
} satisfies SerperSearchResponse & { totalResultsIsEstimate: boolean });
|
|
151
|
+
} catch (err) {
|
|
152
|
+
const msg = err instanceof Error ? err.message : "Serper search failed.";
|
|
153
|
+
return NextResponse.json({ error: msg }, { status: 500 });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** GET → whether Serper search is configured. */
|
|
158
|
+
export async function GET() {
|
|
159
|
+
const config = await readConfig();
|
|
160
|
+
const configured = Boolean(config.serperApiKey);
|
|
161
|
+
return NextResponse.json({ configured });
|
|
162
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NextResponse } from "next/server"
|
|
2
|
+
import { searchWeb, isFirecrawlConfigured, FirecrawlError } from "@larkup/scraper/firecrawl"
|
|
3
|
+
|
|
4
|
+
export const runtime = "nodejs"
|
|
5
|
+
export const dynamic = "force-dynamic"
|
|
6
|
+
|
|
7
|
+
/** GET → report whether the scraper is configured (drives the setup notice). */
|
|
8
|
+
export async function GET() {
|
|
9
|
+
return NextResponse.json({ configured: await isFirecrawlConfigured() })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** POST { query, limit } → keyword web search returning candidate URLs. */
|
|
13
|
+
export async function POST(req: Request) {
|
|
14
|
+
try {
|
|
15
|
+
const { query, limit } = (await req.json()) as {
|
|
16
|
+
query?: string
|
|
17
|
+
limit?: number
|
|
18
|
+
}
|
|
19
|
+
if (!query || !query.trim()) {
|
|
20
|
+
return NextResponse.json({ error: "A search query is required." }, { status: 400 })
|
|
21
|
+
}
|
|
22
|
+
const results = await searchWeb(query.trim(), Math.min(limit ?? 10, 25))
|
|
23
|
+
return NextResponse.json({ results })
|
|
24
|
+
} catch (err) {
|
|
25
|
+
const status = err instanceof FirecrawlError ? err.status ?? 500 : 500
|
|
26
|
+
const message = err instanceof Error ? err.message : "Search failed."
|
|
27
|
+
return NextResponse.json({ error: message }, { status })
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import JSZip from "jszip";
|
|
5
|
+
import { getActiveServer } from "@larkup/core/workspace";
|
|
6
|
+
|
|
7
|
+
function getFilesRecursively(dir: string, baseDir: string): any[] {
|
|
8
|
+
let results: any[] = [];
|
|
9
|
+
if (!fs.existsSync(dir)) return results;
|
|
10
|
+
const list = fs.readdirSync(dir);
|
|
11
|
+
for (const file of list) {
|
|
12
|
+
if (file === "node_modules" || file === ".git" || file === "server.log")
|
|
13
|
+
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
|
+
results.push({
|
|
20
|
+
file: path.relative(baseDir, filePath).replace(/\\/g, "/"),
|
|
21
|
+
fullPath: filePath,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return results;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function GET(req: NextRequest) {
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(req.url);
|
|
31
|
+
const serverId = url.searchParams.get("serverId") || "default";
|
|
32
|
+
|
|
33
|
+
let activeId = serverId;
|
|
34
|
+
if (activeId === "default") {
|
|
35
|
+
const server = await getActiveServer();
|
|
36
|
+
if (server) activeId = server.id;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const cwd = process.cwd();
|
|
40
|
+
let config: any = null;
|
|
41
|
+
const configCandidates = [
|
|
42
|
+
path.join(cwd, ".larkup", "servers", activeId, "config.json"),
|
|
43
|
+
path.join(cwd, ".larkup", "config.json"),
|
|
44
|
+
];
|
|
45
|
+
for (const cfgPath of configCandidates) {
|
|
46
|
+
if (fs.existsSync(cfgPath)) {
|
|
47
|
+
try {
|
|
48
|
+
config = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
|
|
49
|
+
break;
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!config) {
|
|
55
|
+
return NextResponse.json({ error: "Configuration not found." }, { status: 404 });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { generateServer } = await import("@larkup/core/generator/generate-server");
|
|
59
|
+
const generated = generateServer(config);
|
|
60
|
+
|
|
61
|
+
const zip = new JSZip();
|
|
62
|
+
|
|
63
|
+
// Add generated files
|
|
64
|
+
for (const f of generated.files) {
|
|
65
|
+
zip.file(f.path, f.contents);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Add .env.example as .env
|
|
69
|
+
const envExample = generated.files.find(f => f.path === ".env.example");
|
|
70
|
+
if (envExample) {
|
|
71
|
+
zip.file(".env", envExample.contents);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Add LanceDB data if local
|
|
75
|
+
const isLanceLocal = config.vectorStore === "lancedb" && config.storeConfig?.mode !== "cloud";
|
|
76
|
+
if (isLanceLocal) {
|
|
77
|
+
const lancedbDir = path.join(cwd, ".larkup", "servers", activeId, "lancedb");
|
|
78
|
+
if (fs.existsSync(lancedbDir)) {
|
|
79
|
+
const lancedbFiles = getFilesRecursively(
|
|
80
|
+
lancedbDir,
|
|
81
|
+
path.join(cwd, ".larkup", "servers", activeId)
|
|
82
|
+
);
|
|
83
|
+
for (const f of lancedbFiles) {
|
|
84
|
+
zip.file(f.file, fs.readFileSync(f.fullPath));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Add CI/CD templates based on provider if provided
|
|
90
|
+
const provider = url.searchParams.get("provider");
|
|
91
|
+
if (provider === "azure-container-apps") {
|
|
92
|
+
zip.file("DEPLOY_GUIDE.md", "# Azure Container Apps Deployment Guide\n\n1. Install Azure CLI.\n2. Run `az containerapp up --source . --name my-rag-server`.");
|
|
93
|
+
} else if (provider === "aws-app-runner") {
|
|
94
|
+
zip.file("DEPLOY_GUIDE.md", "# AWS App Runner Deployment Guide\n\n1. Push this folder to a GitHub repository.\n2. Connect the repository in AWS App Runner.\n3. Provide the Build Command: `npm install` and Start Command: `npm start`.");
|
|
95
|
+
} else if (provider === "gcp-cloud-run") {
|
|
96
|
+
zip.file("DEPLOY_GUIDE.md", "# GCP Cloud Run Deployment Guide\n\n1. Install gcloud CLI.\n2. Run `gcloud run deploy my-rag-server --source .`.");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const buffer = await zip.generateAsync({ type: "nodebuffer" });
|
|
100
|
+
|
|
101
|
+
return new NextResponse(buffer, {
|
|
102
|
+
headers: {
|
|
103
|
+
"Content-Type": "application/zip",
|
|
104
|
+
"Content-Disposition": `attachment; filename="${config.projectName || "rag-server"}.zip"`,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
} catch (error: any) {
|
|
109
|
+
console.error("ZIP Generation Error:", error);
|
|
110
|
+
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { NextResponse } from "next/server"
|
|
2
|
+
import JSZip from "jszip"
|
|
3
|
+
import { readConfig } from "@larkup/core/config-store"
|
|
4
|
+
import { generateServer } from "@larkup/core/generator/generate-server"
|
|
5
|
+
import { getActiveServer } from "@larkup/core/workspace"
|
|
6
|
+
|
|
7
|
+
export const dynamic = "force-dynamic"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Phase 4 — server generation.
|
|
11
|
+
*
|
|
12
|
+
* GET /api/server/generate -> JSON manifest (files + deps + env)
|
|
13
|
+
* GET /api/server/generate?download=1 -> a .zip of the generated server
|
|
14
|
+
*
|
|
15
|
+
* The generator reads the saved RagConfig and emits a standalone, dependency-
|
|
16
|
+
* minimal RAG server tailored to the selected vector store.
|
|
17
|
+
*/
|
|
18
|
+
export async function GET(req: Request) {
|
|
19
|
+
const config = await readConfig()
|
|
20
|
+
const server = generateServer(config)
|
|
21
|
+
const activeServer = await getActiveServer()
|
|
22
|
+
const serverId = activeServer?.id ?? "default"
|
|
23
|
+
|
|
24
|
+
const url = new URL(req.url)
|
|
25
|
+
if (url.searchParams.get("download") === "1") {
|
|
26
|
+
const zip = new JSZip()
|
|
27
|
+
const root = server.projectName || "rag-server"
|
|
28
|
+
for (const file of server.files) {
|
|
29
|
+
if (file.encoding === "base64") {
|
|
30
|
+
zip.file(`${root}/${file.path}`, file.contents, { base64: true })
|
|
31
|
+
} else {
|
|
32
|
+
zip.file(`${root}/${file.path}`, file.contents)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const buf = await zip.generateAsync({ type: "nodebuffer" })
|
|
36
|
+
return new NextResponse(new Uint8Array(buf), {
|
|
37
|
+
headers: {
|
|
38
|
+
"Content-Type": "application/zip",
|
|
39
|
+
"Content-Disposition": `attachment; filename="${root}.zip"`,
|
|
40
|
+
"Cache-Control": "no-store",
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return NextResponse.json({ config, server, serverId })
|
|
46
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { NextResponse } from "next/server"
|
|
2
|
+
import { readConfig } from "@larkup/core/config-store"
|
|
3
|
+
import {
|
|
4
|
+
readServerState,
|
|
5
|
+
refreshServerStatus,
|
|
6
|
+
startServer,
|
|
7
|
+
stopServer,
|
|
8
|
+
} from "@larkup/core/generator/server-runtime"
|
|
9
|
+
|
|
10
|
+
export const dynamic = "force-dynamic"
|
|
11
|
+
export const maxDuration = 300
|
|
12
|
+
|
|
13
|
+
/** GET — current local server status (re-checked against the live process). */
|
|
14
|
+
export async function GET() {
|
|
15
|
+
const state = await refreshServerStatus()
|
|
16
|
+
return NextResponse.json({ state })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** POST { action: "start" | "stop" } — control the local generated server. */
|
|
20
|
+
export async function POST(req: Request) {
|
|
21
|
+
let action = "start"
|
|
22
|
+
let serverApiKey = ""
|
|
23
|
+
try {
|
|
24
|
+
const body = await req.json()
|
|
25
|
+
if (body?.action) action = String(body.action)
|
|
26
|
+
if (body?.serverApiKey) serverApiKey = String(body.serverApiKey)
|
|
27
|
+
} catch {
|
|
28
|
+
// default to start
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (action === "stop") {
|
|
32
|
+
return NextResponse.json({ state: await stopServer() })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (action === "status") {
|
|
36
|
+
return NextResponse.json({ state: await readServerState() })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const config = await readConfig()
|
|
40
|
+
const state = await startServer(config, serverApiKey)
|
|
41
|
+
return NextResponse.json({ state })
|
|
42
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { NextResponse } from "next/server"
|
|
2
|
+
import {
|
|
3
|
+
createServer,
|
|
4
|
+
deleteServer,
|
|
5
|
+
getWorkspace,
|
|
6
|
+
renameServer,
|
|
7
|
+
runWithServer,
|
|
8
|
+
setActiveServer,
|
|
9
|
+
setUsername,
|
|
10
|
+
setMode,
|
|
11
|
+
type ServerMeta,
|
|
12
|
+
type WorkspaceMode,
|
|
13
|
+
} from "@larkup/core/workspace"
|
|
14
|
+
import { corpusStats } from "@larkup/core/documents-store"
|
|
15
|
+
import { readRun } from "@larkup/core/index-store"
|
|
16
|
+
import { readServerState } from "@larkup/core/generator/server-runtime"
|
|
17
|
+
|
|
18
|
+
export const runtime = "nodejs"
|
|
19
|
+
export const dynamic = "force-dynamic"
|
|
20
|
+
|
|
21
|
+
/** A server entry enriched with lightweight, per-server status for the UI. */
|
|
22
|
+
async function decorate(server: ServerMeta) {
|
|
23
|
+
return runWithServer(server.id, async () => {
|
|
24
|
+
const [stats, run, state] = await Promise.all([
|
|
25
|
+
corpusStats(),
|
|
26
|
+
readRun(),
|
|
27
|
+
readServerState(),
|
|
28
|
+
])
|
|
29
|
+
return {
|
|
30
|
+
...server,
|
|
31
|
+
docCount: stats.docCount,
|
|
32
|
+
indexed: run?.status === "completed" && (run.totalChunks ?? 0) > 0,
|
|
33
|
+
running: state.running,
|
|
34
|
+
endpoint: state.endpoint,
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** GET → the full workspace: username, active server, and decorated servers. */
|
|
40
|
+
export async function GET() {
|
|
41
|
+
const ws = await getWorkspace()
|
|
42
|
+
const servers = await Promise.all(ws.servers.map(decorate))
|
|
43
|
+
return NextResponse.json({
|
|
44
|
+
username: ws.username,
|
|
45
|
+
activeServerId: ws.activeServerId,
|
|
46
|
+
servers,
|
|
47
|
+
mode: ws.mode,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** POST { name } → create a new server (and make it active). */
|
|
52
|
+
export async function POST(req: Request) {
|
|
53
|
+
let body: { name?: string } = {}
|
|
54
|
+
try {
|
|
55
|
+
body = await req.json()
|
|
56
|
+
} catch {
|
|
57
|
+
// empty body is fine — server gets a default name
|
|
58
|
+
}
|
|
59
|
+
const { server } = await createServer(body.name ?? "Untitled server")
|
|
60
|
+
return NextResponse.json({ server }, { status: 201 })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* PATCH → workspace mutations.
|
|
65
|
+
* Body: { action: "activate" | "rename" | "setUsername", serverId?, name?, username? }
|
|
66
|
+
*/
|
|
67
|
+
export async function PATCH(req: Request) {
|
|
68
|
+
let body: {
|
|
69
|
+
action?: string
|
|
70
|
+
serverId?: string
|
|
71
|
+
name?: string
|
|
72
|
+
username?: string
|
|
73
|
+
mode?: WorkspaceMode
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
body = await req.json()
|
|
77
|
+
} catch {
|
|
78
|
+
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
switch (body.action) {
|
|
82
|
+
case "activate": {
|
|
83
|
+
if (!body.serverId) {
|
|
84
|
+
return NextResponse.json(
|
|
85
|
+
{ error: "serverId is required." },
|
|
86
|
+
{ status: 400 },
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
return NextResponse.json({ workspace: await setActiveServer(body.serverId) })
|
|
90
|
+
}
|
|
91
|
+
case "rename": {
|
|
92
|
+
if (!body.serverId || !body.name?.trim()) {
|
|
93
|
+
return NextResponse.json(
|
|
94
|
+
{ error: "serverId and name are required." },
|
|
95
|
+
{ status: 400 },
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
return NextResponse.json({
|
|
99
|
+
workspace: await renameServer(body.serverId, body.name),
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
case "setUsername": {
|
|
103
|
+
return NextResponse.json({
|
|
104
|
+
workspace: await setUsername(body.username ?? ""),
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
case "setMode": {
|
|
108
|
+
if (!body.mode || !(["tech", "simple"] as string[]).includes(body.mode)) {
|
|
109
|
+
return NextResponse.json(
|
|
110
|
+
{ error: "mode must be 'tech' or 'simple'." },
|
|
111
|
+
{ status: 400 },
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
return NextResponse.json({
|
|
115
|
+
workspace: await setMode(body.mode),
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
default:
|
|
119
|
+
return NextResponse.json({ error: "Unknown action." }, { status: 400 })
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** DELETE ?id=<id> — stop the server's instance, then remove all its data. */
|
|
124
|
+
export async function DELETE(req: Request) {
|
|
125
|
+
const id = new URL(req.url).searchParams.get("id")
|
|
126
|
+
if (!id) {
|
|
127
|
+
return NextResponse.json({ error: "id is required." }, { status: 400 })
|
|
128
|
+
}
|
|
129
|
+
// Stop any running instance in the target server's context first.
|
|
130
|
+
try {
|
|
131
|
+
const { stopServer } = await import("@larkup/core/generator/server-runtime")
|
|
132
|
+
await runWithServer(id, () => stopServer())
|
|
133
|
+
} catch {
|
|
134
|
+
// best effort — still remove the data
|
|
135
|
+
}
|
|
136
|
+
return NextResponse.json({ workspace: await deleteServer(id) })
|
|
137
|
+
}
|