@getjack/jack 0.1.20 → 0.1.23
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/package.json +5 -2
- package/src/commands/clone.ts +5 -5
- package/src/commands/down.ts +44 -69
- package/src/commands/link.ts +9 -6
- package/src/commands/new.ts +55 -25
- package/src/commands/publish.ts +8 -3
- package/src/commands/secrets.ts +2 -1
- package/src/commands/services.ts +41 -15
- package/src/commands/update.ts +2 -2
- package/src/index.ts +43 -2
- package/src/lib/agent-integration.ts +217 -0
- package/src/lib/auth/login-flow.ts +2 -1
- package/src/lib/binding-validator.ts +2 -3
- package/src/lib/build-helper.ts +7 -1
- package/src/lib/hooks.ts +101 -21
- package/src/lib/managed-down.ts +40 -42
- package/src/lib/project-detection.ts +48 -21
- package/src/lib/project-operations.ts +38 -44
- package/src/lib/prompts.ts +16 -23
- package/src/lib/services/db-execute.ts +39 -0
- package/src/lib/services/sql-classifier.test.ts +2 -2
- package/src/lib/services/sql-classifier.ts +5 -4
- package/src/lib/version-check.ts +15 -10
- package/src/lib/zip-packager.ts +16 -0
- package/src/mcp/resources/index.ts +42 -2
- package/src/templates/index.ts +63 -3
- package/templates/CLAUDE.md +117 -53
- package/templates/ai-chat/.jack.json +29 -0
- package/templates/ai-chat/bun.lock +18 -0
- package/templates/ai-chat/package.json +14 -0
- package/templates/ai-chat/public/chat.js +149 -0
- package/templates/ai-chat/public/index.html +209 -0
- package/templates/ai-chat/src/index.ts +105 -0
- package/templates/ai-chat/wrangler.jsonc +12 -0
- package/templates/semantic-search/.jack.json +26 -0
- package/templates/semantic-search/bun.lock +18 -0
- package/templates/semantic-search/package.json +12 -0
- package/templates/semantic-search/public/app.js +120 -0
- package/templates/semantic-search/public/index.html +210 -0
- package/templates/semantic-search/schema.sql +5 -0
- package/templates/semantic-search/src/index.ts +144 -0
- package/templates/semantic-search/tsconfig.json +13 -0
- package/templates/semantic-search/wrangler.jsonc +27 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
interface Env {
|
|
2
|
+
AI: Ai;
|
|
3
|
+
ASSETS: Fetcher;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
interface ChatMessage {
|
|
7
|
+
role: "user" | "assistant" | "system";
|
|
8
|
+
content: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Rate limiting: 10 requests per minute per IP
|
|
12
|
+
const RATE_LIMIT = 10;
|
|
13
|
+
const RATE_WINDOW_MS = 60_000;
|
|
14
|
+
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
|
15
|
+
|
|
16
|
+
function checkRateLimit(ip: string): boolean {
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
const entry = rateLimitMap.get(ip);
|
|
19
|
+
|
|
20
|
+
if (!entry || now >= entry.resetAt) {
|
|
21
|
+
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_WINDOW_MS });
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (entry.count >= RATE_LIMIT) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
entry.count++;
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Clean up old entries periodically to prevent memory leaks
|
|
34
|
+
function cleanupRateLimitMap(): void {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
for (const [ip, entry] of rateLimitMap) {
|
|
37
|
+
if (now >= entry.resetAt) {
|
|
38
|
+
rateLimitMap.delete(ip);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default {
|
|
44
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
45
|
+
const url = new URL(request.url);
|
|
46
|
+
|
|
47
|
+
// Serve static assets for non-API routes
|
|
48
|
+
if (request.method === "GET" && !url.pathname.startsWith("/api")) {
|
|
49
|
+
return env.ASSETS.fetch(request);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// POST /api/chat - Streaming chat endpoint
|
|
53
|
+
if (request.method === "POST" && url.pathname === "/api/chat") {
|
|
54
|
+
const ip = request.headers.get("cf-connecting-ip") || "unknown";
|
|
55
|
+
|
|
56
|
+
// Check rate limit
|
|
57
|
+
if (!checkRateLimit(ip)) {
|
|
58
|
+
// Cleanup old entries occasionally
|
|
59
|
+
cleanupRateLimitMap();
|
|
60
|
+
return Response.json(
|
|
61
|
+
{ error: "Too many requests. Please wait a moment and try again." },
|
|
62
|
+
{ status: 429 },
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const body = (await request.json()) as { messages?: ChatMessage[] };
|
|
68
|
+
const messages = body.messages;
|
|
69
|
+
|
|
70
|
+
if (!messages || !Array.isArray(messages)) {
|
|
71
|
+
return Response.json(
|
|
72
|
+
{ error: "Invalid request. Please provide a messages array." },
|
|
73
|
+
{ status: 400 },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Stream response using SSE
|
|
78
|
+
const stream = await env.AI.run(
|
|
79
|
+
"@cf/mistral/mistral-7b-instruct-v0.1",
|
|
80
|
+
{
|
|
81
|
+
messages,
|
|
82
|
+
stream: true,
|
|
83
|
+
max_tokens: 1024,
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return new Response(stream, {
|
|
88
|
+
headers: {
|
|
89
|
+
"Content-Type": "text/event-stream",
|
|
90
|
+
"Cache-Control": "no-cache",
|
|
91
|
+
Connection: "keep-alive",
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error("Chat error:", err);
|
|
96
|
+
return Response.json(
|
|
97
|
+
{ error: "Something went wrong. Please try again." },
|
|
98
|
+
{ status: 500 },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return Response.json({ error: "Not found" }, { status: 404 });
|
|
104
|
+
},
|
|
105
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "semantic-search",
|
|
3
|
+
"description": "Semantic search with AI embeddings + Vectorize",
|
|
4
|
+
"secrets": [],
|
|
5
|
+
"capabilities": ["ai", "db"],
|
|
6
|
+
"intent": {
|
|
7
|
+
"keywords": ["rag", "semantic", "search", "embeddings", "vectorize", "vector", "similarity"],
|
|
8
|
+
"examples": ["RAG app", "document search", "semantic search", "knowledge base"]
|
|
9
|
+
},
|
|
10
|
+
"hooks": {
|
|
11
|
+
"postDeploy": [
|
|
12
|
+
{ "action": "clipboard", "text": "{{url}}", "message": "URL copied" },
|
|
13
|
+
{
|
|
14
|
+
"action": "box",
|
|
15
|
+
"title": "Semantic Search: {{name}}",
|
|
16
|
+
"lines": [
|
|
17
|
+
"{{url}}",
|
|
18
|
+
"",
|
|
19
|
+
"Open in browser to index documents and search!",
|
|
20
|
+
"",
|
|
21
|
+
"Note: Local dev requires 'wrangler dev --remote'"
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"configVersion": 1,
|
|
4
|
+
"workspaces": {
|
|
5
|
+
"": {
|
|
6
|
+
"name": "jack-template",
|
|
7
|
+
"devDependencies": {
|
|
8
|
+
"@cloudflare/workers-types": "^4.20241205.0",
|
|
9
|
+
"typescript": "^5.0.0",
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
"packages": {
|
|
14
|
+
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260120.0", "", {}, "sha512-B8pueG+a5S+mdK3z8oKu1ShcxloZ7qWb68IEyLLaepvdryIbNC7JVPcY0bWsjS56UQVKc5fnyRge3yZIwc9bxw=="],
|
|
15
|
+
|
|
16
|
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const docIdEl = document.getElementById("doc-id");
|
|
2
|
+
const docContentEl = document.getElementById("doc-content");
|
|
3
|
+
const indexBtn = document.getElementById("index-btn");
|
|
4
|
+
const indexStatus = document.getElementById("index-status");
|
|
5
|
+
const searchQueryEl = document.getElementById("search-query");
|
|
6
|
+
const searchBtn = document.getElementById("search-btn");
|
|
7
|
+
const resultsEl = document.getElementById("results");
|
|
8
|
+
|
|
9
|
+
// Index document
|
|
10
|
+
async function indexDocument() {
|
|
11
|
+
const id = docIdEl.value.trim();
|
|
12
|
+
const content = docContentEl.value.trim();
|
|
13
|
+
|
|
14
|
+
if (!id || !content) {
|
|
15
|
+
showStatus(indexStatus, "Please enter both ID and content", "error");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
indexBtn.disabled = true;
|
|
20
|
+
indexBtn.innerHTML = '<span class="loading"></span>Indexing...';
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch("/api/index", {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { "Content-Type": "application/json" },
|
|
26
|
+
body: JSON.stringify({ id, content }),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const data = await response.json();
|
|
30
|
+
|
|
31
|
+
if (response.ok) {
|
|
32
|
+
showStatus(indexStatus, `Document "${id}" indexed successfully!`, "success");
|
|
33
|
+
docIdEl.value = "";
|
|
34
|
+
docContentEl.value = "";
|
|
35
|
+
} else {
|
|
36
|
+
showStatus(indexStatus, data.error || "Failed to index document", "error");
|
|
37
|
+
}
|
|
38
|
+
} catch (err) {
|
|
39
|
+
showStatus(indexStatus, "Network error. Please try again.", "error");
|
|
40
|
+
} finally {
|
|
41
|
+
indexBtn.disabled = false;
|
|
42
|
+
indexBtn.textContent = "Index Document";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Search documents
|
|
47
|
+
async function searchDocuments() {
|
|
48
|
+
const query = searchQueryEl.value.trim();
|
|
49
|
+
|
|
50
|
+
if (!query) {
|
|
51
|
+
resultsEl.innerHTML = '<div class="no-results">Please enter a search query</div>';
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
searchBtn.disabled = true;
|
|
56
|
+
searchBtn.innerHTML = '<span class="loading"></span>Searching...';
|
|
57
|
+
resultsEl.innerHTML = "";
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const response = await fetch("/api/search", {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { "Content-Type": "application/json" },
|
|
63
|
+
body: JSON.stringify({ query, limit: 5 }),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const data = await response.json();
|
|
67
|
+
|
|
68
|
+
if (response.ok) {
|
|
69
|
+
if (data.results && data.results.length > 0) {
|
|
70
|
+
resultsEl.innerHTML = data.results
|
|
71
|
+
.map(
|
|
72
|
+
(result) => `
|
|
73
|
+
<div class="result">
|
|
74
|
+
<div class="result-header">
|
|
75
|
+
<span class="result-id">${escapeHtml(result.id)}</span>
|
|
76
|
+
<span class="result-score">Score: ${(result.score * 100).toFixed(1)}%</span>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="result-preview">${escapeHtml(result.metadata?.preview || "No preview available")}</div>
|
|
79
|
+
</div>
|
|
80
|
+
`,
|
|
81
|
+
)
|
|
82
|
+
.join("");
|
|
83
|
+
} else {
|
|
84
|
+
resultsEl.innerHTML = '<div class="no-results">No matching documents found</div>';
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
resultsEl.innerHTML = `<div class="no-results">${escapeHtml(data.error || "Search failed")}</div>`;
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
resultsEl.innerHTML = '<div class="no-results">Network error. Please try again.</div>';
|
|
91
|
+
} finally {
|
|
92
|
+
searchBtn.disabled = false;
|
|
93
|
+
searchBtn.textContent = "Search";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Helper functions
|
|
98
|
+
function showStatus(el, message, type) {
|
|
99
|
+
el.textContent = message;
|
|
100
|
+
el.className = `status ${type}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function escapeHtml(text) {
|
|
104
|
+
const div = document.createElement("div");
|
|
105
|
+
div.textContent = text;
|
|
106
|
+
return div.innerHTML;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Event listeners
|
|
110
|
+
indexBtn.addEventListener("click", indexDocument);
|
|
111
|
+
searchBtn.addEventListener("click", searchDocuments);
|
|
112
|
+
|
|
113
|
+
// Enter key support
|
|
114
|
+
docIdEl.addEventListener("keypress", (e) => {
|
|
115
|
+
if (e.key === "Enter") docContentEl.focus();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
searchQueryEl.addEventListener("keypress", (e) => {
|
|
119
|
+
if (e.key === "Enter") searchDocuments();
|
|
120
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Semantic Search</title>
|
|
7
|
+
<style>
|
|
8
|
+
* {
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
body {
|
|
13
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
14
|
+
line-height: 1.6;
|
|
15
|
+
max-width: 800px;
|
|
16
|
+
margin: 0 auto;
|
|
17
|
+
padding: 2rem 1rem;
|
|
18
|
+
background: #f5f5f5;
|
|
19
|
+
color: #333;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
h1 {
|
|
23
|
+
text-align: center;
|
|
24
|
+
color: #1a1a1a;
|
|
25
|
+
margin-bottom: 0.5rem;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.subtitle {
|
|
29
|
+
text-align: center;
|
|
30
|
+
color: #666;
|
|
31
|
+
margin-bottom: 2rem;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.section {
|
|
35
|
+
background: white;
|
|
36
|
+
border-radius: 12px;
|
|
37
|
+
padding: 1.5rem;
|
|
38
|
+
margin-bottom: 1.5rem;
|
|
39
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
h2 {
|
|
43
|
+
margin-top: 0;
|
|
44
|
+
color: #1a1a1a;
|
|
45
|
+
font-size: 1.25rem;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
label {
|
|
49
|
+
display: block;
|
|
50
|
+
margin-bottom: 0.5rem;
|
|
51
|
+
font-weight: 500;
|
|
52
|
+
color: #444;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
input[type="text"] {
|
|
56
|
+
width: 100%;
|
|
57
|
+
padding: 0.75rem;
|
|
58
|
+
border: 1px solid #ddd;
|
|
59
|
+
border-radius: 8px;
|
|
60
|
+
font-size: 1rem;
|
|
61
|
+
margin-bottom: 1rem;
|
|
62
|
+
transition: border-color 0.2s;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
input[type="text"]:focus {
|
|
66
|
+
outline: none;
|
|
67
|
+
border-color: #0066cc;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
textarea {
|
|
71
|
+
width: 100%;
|
|
72
|
+
padding: 0.75rem;
|
|
73
|
+
border: 1px solid #ddd;
|
|
74
|
+
border-radius: 8px;
|
|
75
|
+
font-size: 1rem;
|
|
76
|
+
min-height: 120px;
|
|
77
|
+
resize: vertical;
|
|
78
|
+
font-family: inherit;
|
|
79
|
+
margin-bottom: 1rem;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
textarea:focus {
|
|
83
|
+
outline: none;
|
|
84
|
+
border-color: #0066cc;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
button {
|
|
88
|
+
background: #0066cc;
|
|
89
|
+
color: white;
|
|
90
|
+
border: none;
|
|
91
|
+
padding: 0.75rem 1.5rem;
|
|
92
|
+
border-radius: 8px;
|
|
93
|
+
font-size: 1rem;
|
|
94
|
+
cursor: pointer;
|
|
95
|
+
transition: background 0.2s;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
button:hover {
|
|
99
|
+
background: #0052a3;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
button:disabled {
|
|
103
|
+
background: #999;
|
|
104
|
+
cursor: not-allowed;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.status {
|
|
108
|
+
margin-top: 1rem;
|
|
109
|
+
padding: 0.75rem;
|
|
110
|
+
border-radius: 8px;
|
|
111
|
+
display: none;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.status.success {
|
|
115
|
+
display: block;
|
|
116
|
+
background: #e6f4ea;
|
|
117
|
+
color: #1e7e34;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.status.error {
|
|
121
|
+
display: block;
|
|
122
|
+
background: #fce8e6;
|
|
123
|
+
color: #c5221f;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.results {
|
|
127
|
+
margin-top: 1rem;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.result {
|
|
131
|
+
background: #f8f9fa;
|
|
132
|
+
border-radius: 8px;
|
|
133
|
+
padding: 1rem;
|
|
134
|
+
margin-bottom: 0.75rem;
|
|
135
|
+
border-left: 4px solid #0066cc;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.result-header {
|
|
139
|
+
display: flex;
|
|
140
|
+
justify-content: space-between;
|
|
141
|
+
align-items: center;
|
|
142
|
+
margin-bottom: 0.5rem;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.result-id {
|
|
146
|
+
font-weight: 600;
|
|
147
|
+
color: #1a1a1a;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.result-score {
|
|
151
|
+
font-size: 0.85rem;
|
|
152
|
+
color: #666;
|
|
153
|
+
background: #e8e8e8;
|
|
154
|
+
padding: 0.25rem 0.5rem;
|
|
155
|
+
border-radius: 4px;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.result-preview {
|
|
159
|
+
color: #555;
|
|
160
|
+
font-size: 0.95rem;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.no-results {
|
|
164
|
+
text-align: center;
|
|
165
|
+
color: #666;
|
|
166
|
+
padding: 2rem;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.loading {
|
|
170
|
+
display: inline-block;
|
|
171
|
+
width: 16px;
|
|
172
|
+
height: 16px;
|
|
173
|
+
border: 2px solid #fff;
|
|
174
|
+
border-radius: 50%;
|
|
175
|
+
border-top-color: transparent;
|
|
176
|
+
animation: spin 0.8s linear infinite;
|
|
177
|
+
margin-right: 0.5rem;
|
|
178
|
+
vertical-align: middle;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
@keyframes spin {
|
|
182
|
+
to { transform: rotate(360deg); }
|
|
183
|
+
}
|
|
184
|
+
</style>
|
|
185
|
+
</head>
|
|
186
|
+
<body>
|
|
187
|
+
<h1>Semantic Search</h1>
|
|
188
|
+
<p class="subtitle">Index documents and search by meaning using AI embeddings</p>
|
|
189
|
+
|
|
190
|
+
<div class="section">
|
|
191
|
+
<h2>Index Document</h2>
|
|
192
|
+
<label for="doc-id">Document ID</label>
|
|
193
|
+
<input type="text" id="doc-id" placeholder="e.g., doc-001" />
|
|
194
|
+
<label for="doc-content">Content</label>
|
|
195
|
+
<textarea id="doc-content" placeholder="Enter the document content to index..."></textarea>
|
|
196
|
+
<button id="index-btn">Index Document</button>
|
|
197
|
+
<div id="index-status" class="status"></div>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div class="section">
|
|
201
|
+
<h2>Search</h2>
|
|
202
|
+
<label for="search-query">Search Query</label>
|
|
203
|
+
<input type="text" id="search-query" placeholder="Enter your search query..." />
|
|
204
|
+
<button id="search-btn">Search</button>
|
|
205
|
+
<div id="results" class="results"></div>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<script src="/app.js"></script>
|
|
209
|
+
</body>
|
|
210
|
+
</html>
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
interface Env {
|
|
2
|
+
AI: Ai;
|
|
3
|
+
VECTORS: VectorizeIndex;
|
|
4
|
+
DB: D1Database;
|
|
5
|
+
ASSETS: Fetcher;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Rate limiting: 10 requests per minute per IP
|
|
9
|
+
const RATE_LIMIT = 10;
|
|
10
|
+
const RATE_WINDOW_MS = 60_000;
|
|
11
|
+
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
|
12
|
+
|
|
13
|
+
function checkRateLimit(ip: string): boolean {
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
const entry = rateLimitMap.get(ip);
|
|
16
|
+
|
|
17
|
+
if (!entry || now > entry.resetAt) {
|
|
18
|
+
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_WINDOW_MS });
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (entry.count >= RATE_LIMIT) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
entry.count++;
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Extract embedding vector from AI response
|
|
32
|
+
* Handles union type from @cf/baai/bge-base-en-v1.5
|
|
33
|
+
*/
|
|
34
|
+
function getEmbeddingVector(response: Awaited<ReturnType<Ai["run"]>>): number[] | null {
|
|
35
|
+
if (
|
|
36
|
+
response &&
|
|
37
|
+
typeof response === "object" &&
|
|
38
|
+
"data" in response &&
|
|
39
|
+
Array.isArray(response.data) &&
|
|
40
|
+
response.data.length > 0
|
|
41
|
+
) {
|
|
42
|
+
return response.data[0] as number[];
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default {
|
|
48
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
49
|
+
const url = new URL(request.url);
|
|
50
|
+
|
|
51
|
+
// Serve static assets for non-API routes
|
|
52
|
+
if (request.method === "GET" && !url.pathname.startsWith("/api")) {
|
|
53
|
+
return env.ASSETS.fetch(request);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Rate limiting for API routes
|
|
57
|
+
const ip = request.headers.get("cf-connecting-ip") || "unknown";
|
|
58
|
+
if (!checkRateLimit(ip)) {
|
|
59
|
+
return Response.json({ error: "Too many requests. Please wait a moment." }, { status: 429 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// POST /api/index - Index a document
|
|
63
|
+
if (request.method === "POST" && url.pathname === "/api/index") {
|
|
64
|
+
try {
|
|
65
|
+
const body = (await request.json()) as {
|
|
66
|
+
id?: string;
|
|
67
|
+
content?: string;
|
|
68
|
+
};
|
|
69
|
+
const { id, content } = body;
|
|
70
|
+
|
|
71
|
+
if (!id || !content) {
|
|
72
|
+
return Response.json({ error: "Missing id or content" }, { status: 400 });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Generate embedding using free Cloudflare AI
|
|
76
|
+
const embedding = await env.AI.run("@cf/baai/bge-base-en-v1.5", {
|
|
77
|
+
text: content,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const embeddingVector = getEmbeddingVector(embedding);
|
|
81
|
+
if (!embeddingVector) {
|
|
82
|
+
return Response.json({ error: "Failed to generate embedding" }, { status: 500 });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Store in Vectorize
|
|
86
|
+
await env.VECTORS.insert([
|
|
87
|
+
{
|
|
88
|
+
id,
|
|
89
|
+
values: embeddingVector,
|
|
90
|
+
metadata: { preview: content.slice(0, 100) },
|
|
91
|
+
},
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
// Store full content in D1
|
|
95
|
+
await env.DB.prepare("INSERT OR REPLACE INTO documents (id, content) VALUES (?, ?)")
|
|
96
|
+
.bind(id, content)
|
|
97
|
+
.run();
|
|
98
|
+
|
|
99
|
+
return Response.json({ success: true, id });
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.error("Index error:", err);
|
|
102
|
+
return Response.json({ error: "Failed to index document" }, { status: 500 });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// POST /api/search - Semantic search
|
|
107
|
+
if (request.method === "POST" && url.pathname === "/api/search") {
|
|
108
|
+
try {
|
|
109
|
+
const body = (await request.json()) as {
|
|
110
|
+
query?: string;
|
|
111
|
+
limit?: number;
|
|
112
|
+
};
|
|
113
|
+
const { query, limit = 5 } = body;
|
|
114
|
+
|
|
115
|
+
if (!query) {
|
|
116
|
+
return Response.json({ error: "Missing query" }, { status: 400 });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Generate query embedding
|
|
120
|
+
const embedding = await env.AI.run("@cf/baai/bge-base-en-v1.5", {
|
|
121
|
+
text: query,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const embeddingVector = getEmbeddingVector(embedding);
|
|
125
|
+
if (!embeddingVector) {
|
|
126
|
+
return Response.json({ error: "Failed to generate embedding" }, { status: 500 });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Search Vectorize
|
|
130
|
+
const results = await env.VECTORS.query(embeddingVector, {
|
|
131
|
+
topK: limit,
|
|
132
|
+
returnMetadata: "all",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return Response.json({ results: results.matches });
|
|
136
|
+
} catch (err) {
|
|
137
|
+
console.error("Search error:", err);
|
|
138
|
+
return Response.json({ error: "Search failed" }, { status: 500 });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return Response.json({ error: "Not found" }, { status: 404 });
|
|
143
|
+
},
|
|
144
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ES2022",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"types": ["@cloudflare/workers-types"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*"]
|
|
13
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jack-template",
|
|
3
|
+
"main": "src/index.ts",
|
|
4
|
+
"compatibility_date": "2024-12-01",
|
|
5
|
+
"ai": {
|
|
6
|
+
"binding": "AI"
|
|
7
|
+
},
|
|
8
|
+
"vectorize": [
|
|
9
|
+
{
|
|
10
|
+
"binding": "VECTORS",
|
|
11
|
+
"index_name": "jack-template-vectors",
|
|
12
|
+
// preset: cloudflare = 768 dimensions, cosine metric
|
|
13
|
+
// matches @cf/baai/bge-base-en-v1.5 embedding model
|
|
14
|
+
"preset": "cloudflare"
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
"d1_databases": [
|
|
18
|
+
{
|
|
19
|
+
"binding": "DB",
|
|
20
|
+
"database_name": "jack-template-db"
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"assets": {
|
|
24
|
+
"directory": "public",
|
|
25
|
+
"binding": "ASSETS"
|
|
26
|
+
}
|
|
27
|
+
}
|