Reubencf commited on
Commit
ff0e173
·
1 Parent(s): 511f68e

Deploy Next.js Query Bot as Docker Space

Browse files
.dockerignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .next
3
+ .git
4
+ data
5
+ .env
6
+ .env.*
7
+ !.env.example
8
+ npm-debug.log*
9
+ *.tsbuildinfo
10
+ .vercel
11
+ README.md
12
+ Dockerfile
13
+ .dockerignore
.env.example ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Cohere API key - used server-side only (RAG embeddings, rerank, and chat).
2
+ # Get one at https://dashboard.cohere.com/api-keys
3
+ COHERE_API_KEY=your_cohere_api_key_here
4
+
5
+ # Vercel Blob read/write token - required in Vercel for persistent kb.json storage.
6
+ # In local development this app falls back to data/kb.json when this is omitted.
7
+ BLOB_READ_WRITE_TOKEN=your_vercel_blob_read_write_token_here
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.gif filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+
16
+ # next.js
17
+ /.next/
18
+ /out/
19
+
20
+ # production
21
+ /build
22
+
23
+ # misc
24
+ .DS_Store
25
+ *.pem
26
+
27
+ # debug
28
+ npm-debug.log*
29
+ yarn-debug.log*
30
+ yarn-error.log*
31
+ .pnpm-debug.log*
32
+
33
+ # env files (can opt-in for committing if needed)
34
+ .env*
35
+ !.env.example
36
+
37
+ # local knowledge base store (parsed text + embeddings)
38
+ /data
39
+
40
+ # vercel
41
+ .vercel
42
+
43
+ # typescript
44
+ *.tsbuildinfo
45
+ next-env.d.ts
Dockerfile ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1
2
+ # Hugging Face Space (Docker SDK) image for the Next.js Query Bot.
3
+ # Multi-stage build producing a small standalone runtime that listens on :7860.
4
+
5
+ FROM node:22-slim AS base
6
+ ENV NEXT_TELEMETRY_DISABLED=1
7
+
8
+ # --- Install dependencies (cached on lockfile changes) ----------------------
9
+ FROM base AS deps
10
+ WORKDIR /app
11
+ COPY package.json package-lock.json ./
12
+ RUN npm ci
13
+
14
+ # --- Build the Next.js app --------------------------------------------------
15
+ FROM base AS builder
16
+ WORKDIR /app
17
+ COPY --from=deps /app/node_modules ./node_modules
18
+ COPY . .
19
+ # COHERE_API_KEY / BLOB_READ_WRITE_TOKEN are read at runtime, not build time,
20
+ # so the build needs no secrets.
21
+ RUN npm run build
22
+
23
+ # --- Production runtime ------------------------------------------------------
24
+ FROM base AS runner
25
+ WORKDIR /app
26
+ ENV NODE_ENV=production
27
+ # Hugging Face Spaces expect the app on port 7860, bound to all interfaces.
28
+ ENV PORT=7860
29
+ ENV HOSTNAME=0.0.0.0
30
+
31
+ # Run as the non-root user Hugging Face provisions (uid 1000).
32
+ RUN useradd -m -u 1000 user
33
+
34
+ # Standalone output bundles only the files the server actually needs.
35
+ COPY --from=builder --chown=user:user /app/public ./public
36
+ COPY --from=builder --chown=user:user /app/.next/standalone ./
37
+ COPY --from=builder --chown=user:user /app/.next/static ./.next/static
38
+
39
+ # Writable knowledge-base store for the local fallback (used when no Vercel
40
+ # Blob token is set). Note: a Space's filesystem is ephemeral unless you
41
+ # attach persistent storage, so uploads reset on rebuild/restart.
42
+ RUN mkdir -p /app/data && chown -R user:user /app/data
43
+
44
+ USER user
45
+ EXPOSE 7860
46
+ CMD ["node", "server.js"]
app/admin/page.tsx ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { upload } from '@vercel/blob/client';
5
+ import AdminHeader from '@/components/AdminHeader';
6
+ import FileUploadPanel from '@/components/FileUploadPanel';
7
+ import QABuilderPanel from '@/components/QABuilderPanel';
8
+ import { KBFile, KBPair } from '@/lib/kb-data';
9
+
10
+ const DIRECT_UPLOAD_LIMIT_BYTES = 3.5 * 1024 * 1024;
11
+
12
+ function makeUploadPath(file: File): string {
13
+ const safeName = file.name
14
+ .replace(/[/\\?%*:|"<>]/g, '-')
15
+ .replace(/\s+/g, ' ')
16
+ .trim();
17
+ return `uploads/${Date.now()}-${safeName}`;
18
+ }
19
+
20
+ async function readJsonResponse(res: Response) {
21
+ const text = await res.text();
22
+ try {
23
+ return text ? JSON.parse(text) : {};
24
+ } catch {
25
+ throw new Error(text || `Request failed (${res.status})`);
26
+ }
27
+ }
28
+
29
+ export default function AdminPage() {
30
+ const [files, setFiles] = React.useState<KBFile[]>([]);
31
+ const [qaList, setQaList] = React.useState<KBPair[]>([]);
32
+ const [lastUpdated, setLastUpdated] = React.useState<string>('Just now');
33
+
34
+ // Ids currently being deleted, so the cards can show a "Removing…" state.
35
+ const [deletingFileIds, setDeletingFileIds] = React.useState<Set<string>>(new Set());
36
+ const [deletingQaIds, setDeletingQaIds] = React.useState<Set<string>>(new Set());
37
+
38
+ // Load the knowledge base from the server on mount.
39
+ React.useEffect(() => {
40
+ (async () => {
41
+ try {
42
+ const [filesRes, qaRes] = await Promise.all([
43
+ fetch('/api/documents'),
44
+ fetch('/api/qa'),
45
+ ]);
46
+ const [filesData, qaData] = await Promise.all(
47
+ [filesRes, qaRes].map(async (res) => {
48
+ const data = await readJsonResponse(res);
49
+ if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
50
+ return data;
51
+ })
52
+ );
53
+ setFiles(filesData.files ?? []);
54
+ setQaList(qaData.qa ?? []);
55
+ } catch (e) {
56
+ console.error('Failed to load knowledge base', e);
57
+ }
58
+ const now = new Date();
59
+ setLastUpdated(now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
60
+ })();
61
+ }, []);
62
+
63
+ // Update Last Updated Timestamp helper
64
+ const triggerUpdateTimestamp = () => {
65
+ const now = new Date();
66
+ setLastUpdated(now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
67
+ };
68
+
69
+ // 1. Files Upload and Management — parse + embed happens server-side.
70
+ const handleUploadFile = async (file: File) => {
71
+ // Let failures reject so the upload panel can surface an error state.
72
+ let res: Response;
73
+
74
+ if (file.size > DIRECT_UPLOAD_LIMIT_BYTES) {
75
+ const blob = await upload(makeUploadPath(file), file, {
76
+ access: 'private',
77
+ handleUploadUrl: '/api/documents/upload',
78
+ multipart: true,
79
+ contentType: file.type || 'application/octet-stream',
80
+ });
81
+
82
+ res = await fetch('/api/documents', {
83
+ method: 'POST',
84
+ headers: { 'Content-Type': 'application/json' },
85
+ body: JSON.stringify({
86
+ blobPathname: blob.pathname,
87
+ name: file.name,
88
+ size: file.size,
89
+ }),
90
+ });
91
+ } else {
92
+ const form = new FormData();
93
+ form.append('file', file);
94
+ res = await fetch('/api/documents', { method: 'POST', body: form });
95
+ }
96
+
97
+ const data = await readJsonResponse(res);
98
+ if (!res.ok) throw new Error(data.error || `Upload failed (${res.status})`);
99
+ if (!data.file) throw new Error('Upload returned no file');
100
+ setFiles((prev) => {
101
+ const others = prev.filter((f) => f.id !== data.file.id);
102
+ return [...others, data.file];
103
+ });
104
+ triggerUpdateTimestamp();
105
+ };
106
+
107
+ const handleDeleteFile = async (id: string) => {
108
+ setDeletingFileIds((prev) => new Set(prev).add(id));
109
+ try {
110
+ const res = await fetch(`/api/documents/${id}`, { method: 'DELETE' });
111
+ if (!res.ok) throw new Error(`Delete failed (${res.status})`);
112
+ setFiles((prev) => prev.filter((f) => f.id !== id));
113
+ triggerUpdateTimestamp();
114
+ } catch (e) {
115
+ console.error('Delete failed', e);
116
+ } finally {
117
+ setDeletingFileIds((prev) => {
118
+ const next = new Set(prev);
119
+ next.delete(id);
120
+ return next;
121
+ });
122
+ }
123
+ };
124
+
125
+ // 2. Custom Q&A Management
126
+ const handleAddQA = async (question: string, answer: string, category: string, prioritize: boolean) => {
127
+ try {
128
+ const res = await fetch('/api/qa', {
129
+ method: 'POST',
130
+ headers: { 'Content-Type': 'application/json' },
131
+ body: JSON.stringify({ question, answer, category, prioritize }),
132
+ });
133
+ const data = await res.json();
134
+ if (data.qa) {
135
+ setQaList((prev) => [...prev, data.qa]);
136
+ triggerUpdateTimestamp();
137
+ }
138
+ } catch (e) {
139
+ console.error('Add Q&A failed', e);
140
+ }
141
+ };
142
+
143
+ const handleUpdateQA = async (id: string, question: string, answer: string, category: string, prioritize: boolean) => {
144
+ try {
145
+ const res = await fetch(`/api/qa/${id}`, {
146
+ method: 'PUT',
147
+ headers: { 'Content-Type': 'application/json' },
148
+ body: JSON.stringify({ question, answer, category, prioritize }),
149
+ });
150
+ const data = await res.json();
151
+ if (data.qa) {
152
+ setQaList((prev) => prev.map((qa) => (qa.id === id ? data.qa : qa)));
153
+ triggerUpdateTimestamp();
154
+ }
155
+ } catch (e) {
156
+ console.error('Update Q&A failed', e);
157
+ }
158
+ };
159
+
160
+ const handleDeleteQA = async (id: string) => {
161
+ setDeletingQaIds((prev) => new Set(prev).add(id));
162
+ try {
163
+ const res = await fetch(`/api/qa/${id}`, { method: 'DELETE' });
164
+ if (!res.ok) throw new Error(`Delete failed (${res.status})`);
165
+ setQaList((prev) => prev.filter((qa) => qa.id !== id));
166
+ triggerUpdateTimestamp();
167
+ } catch (e) {
168
+ console.error('Delete Q&A failed', e);
169
+ } finally {
170
+ setDeletingQaIds((prev) => {
171
+ const next = new Set(prev);
172
+ next.delete(id);
173
+ return next;
174
+ });
175
+ }
176
+ };
177
+
178
+ // Calculating counters
179
+ const totalFiles = files.length;
180
+ const readyFilesCount = files.filter((f) => f.status === 'Ready').length;
181
+ const totalQAs = qaList.length;
182
+
183
+ return (
184
+ <main className="min-h-screen bg-black/95 text-white pt-10 pb-16 relative overflow-hidden font-sans">
185
+ {/* Decorative dark aurora hints behind the panels */}
186
+ <div className="absolute inset-0 z-0 opacity-40 pointer-events-none aurora-bg" />
187
+ <div className="absolute inset-0 bg-black/60 pointer-events-none z-0" />
188
+
189
+ {/* Main Container */}
190
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
191
+
192
+ {/* Admin header */}
193
+ <AdminHeader />
194
+
195
+ {/* Core Workspace Panels split side-by-side or stacked on mobile */}
196
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start mt-6">
197
+
198
+ {/* Left column: File loader + its index metrics */}
199
+ <div className="flex flex-col gap-8">
200
+ <section className="bg-[#141414]/80 backdrop-blur-md border border-white/10 rounded-3xl p-6 sm:p-8 shadow-2xl hover:border-white/15 transition-all duration-300">
201
+ <FileUploadPanel
202
+ files={files}
203
+ onUpload={handleUploadFile}
204
+ onDelete={handleDeleteFile}
205
+ deletingIds={deletingFileIds}
206
+ />
207
+ </section>
208
+
209
+ {/* Index metrics — sits directly below Upload documents */}
210
+ <div className="bg-[#141414]/80 backdrop-blur-md border border-white/10 rounded-2xl p-5 flex flex-wrap gap-6 sm:gap-10 items-center justify-between shadow-2xl">
211
+ <div className="flex items-center gap-6 sm:gap-10">
212
+ <div>
213
+ <p className="text-[10px] font-bold text-white/50 uppercase tracking-wider">Total Documents</p>
214
+ <p className="text-2xl font-semibold text-white mt-0.5 tracking-tight">{totalFiles}</p>
215
+ </div>
216
+ <div>
217
+ <p className="text-[10px] font-bold text-white/50 uppercase tracking-wider">Indexed (Ready)</p>
218
+ <p className="text-2xl font-semibold text-emerald-400 mt-0.5 tracking-tight">{readyFilesCount}</p>
219
+ </div>
220
+ <div>
221
+ <p className="text-[10px] font-bold text-white/50 uppercase tracking-wider">Custom Q&As</p>
222
+ <p className="text-2xl font-semibold text-white mt-0.5 tracking-tight">{totalQAs}</p>
223
+ </div>
224
+ </div>
225
+ <div className="text-left">
226
+ <p className="text-[10px] font-bold text-white/50 uppercase tracking-wider">Last Sync</p>
227
+ <p className="text-sm font-medium text-white/70 mt-0.5">Updated {lastUpdated}</p>
228
+ </div>
229
+ </div>
230
+ </div>
231
+
232
+ {/* Right panel: Custom Q&A compiler */}
233
+ <section className="bg-[#141414]/80 backdrop-blur-md border border-white/10 rounded-3xl p-6 sm:p-8 shadow-2xl hover:border-white/15 transition-all duration-300">
234
+ <QABuilderPanel
235
+ qaList={qaList}
236
+ onAdd={handleAddQA}
237
+ onUpdate={handleUpdateQA}
238
+ onDelete={handleDeleteQA}
239
+ deletingIds={deletingQaIds}
240
+ />
241
+ </section>
242
+
243
+ </div>
244
+
245
+ </div>
246
+ </main>
247
+ );
248
+ }
app/api/chat/route.ts ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { getAllChunksWithMeta } from '@/lib/kb-store';
3
+ import {
4
+ embedQuery,
5
+ rerank,
6
+ chatWithDocuments,
7
+ cosineSimilarity,
8
+ } from '@/lib/cohere';
9
+ import {
10
+ RETRIEVE_TOP_K,
11
+ CONTEXT_TOP_N,
12
+ PER_SOURCE_CAP,
13
+ QA_PRIORITY_BOOST,
14
+ } from '@/lib/cohere-config';
15
+
16
+ /**
17
+ * Pick the final context chunks from a relevance-ranked list while guaranteeing
18
+ * that multiple source documents are represented. Without this, a question that
19
+ * spans two documents ("what is X and Y") gets answered from whichever document
20
+ * dominates the ranking, because every top slot is filled from it.
21
+ *
22
+ * Strategy: round-robin across documents (one chunk per document per pass, in
23
+ * relevance order) so each relevant document contributes its best chunks first,
24
+ * capped per source and limited to `limit` chunks total.
25
+ */
26
+ function diversifyBySource<T extends { sourceName: string }>(
27
+ ranked: T[],
28
+ limit: number,
29
+ perSourceCap: number
30
+ ): T[] {
31
+ const bySource = new Map<string, T[]>();
32
+ for (const chunk of ranked) {
33
+ const arr = bySource.get(chunk.sourceName);
34
+ if (arr) arr.push(chunk);
35
+ else bySource.set(chunk.sourceName, [chunk]);
36
+ }
37
+
38
+ const used = new Map<string, number>();
39
+ const result: T[] = [];
40
+ let progressed = true;
41
+ while (result.length < limit && progressed) {
42
+ progressed = false;
43
+ for (const [name, chunks] of bySource) {
44
+ if ((used.get(name) ?? 0) >= perSourceCap) continue;
45
+ const next = chunks.shift();
46
+ if (!next) continue;
47
+ result.push(next);
48
+ used.set(name, (used.get(name) ?? 0) + 1);
49
+ progressed = true;
50
+ if (result.length >= limit) break;
51
+ }
52
+ }
53
+ return result;
54
+ }
55
+
56
+ export const runtime = 'nodejs';
57
+ export const dynamic = 'force-dynamic';
58
+
59
+ const EMPTY_KB_REPLY =
60
+ "I couldn't find any documents or custom Q&A answers in your knowledge base yet. Head to the **Admin Dashboard** to upload documents (PDF, Word, Excel) or add custom Q&A pairs, and I'll be able to answer grounded questions.";
61
+
62
+ type Source = { name: string; type: string };
63
+
64
+ export async function POST(request: Request) {
65
+ let body: { query?: string };
66
+ try {
67
+ body = await request.json();
68
+ } catch {
69
+ return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 });
70
+ }
71
+
72
+ const query = body.query?.trim();
73
+ if (!query) {
74
+ return NextResponse.json({ error: 'A query is required.' }, { status: 400 });
75
+ }
76
+
77
+ try {
78
+ const candidates = await getAllChunksWithMeta();
79
+
80
+ // Empty knowledge base — friendly fallback, no model call needed.
81
+ if (candidates.length === 0) {
82
+ return NextResponse.json({ text: EMPTY_KB_REPLY, sources: [] });
83
+ }
84
+
85
+ // 1. Embed the query and score candidates by cosine similarity.
86
+ const qVec = await embedQuery(query);
87
+ const scored = candidates
88
+ .map((c) => {
89
+ const base = cosineSimilarity(qVec, c.embedding);
90
+ return { c, score: c.prioritize ? base + QA_PRIORITY_BOOST : base };
91
+ })
92
+ .sort((a, b) => b.score - a.score)
93
+ .slice(0, RETRIEVE_TOP_K)
94
+ .map((s) => s.c);
95
+
96
+ // 2. Rerank the whole candidate pool for precision (best-first).
97
+ const reranked = await rerank(query, scored.map((c) => c.text));
98
+ const rankedChunks =
99
+ reranked.length > 0 ? reranked.map((r) => scored[r.index]) : scored;
100
+
101
+ // 3. Select the final context, balancing relevance with document coverage so
102
+ // multi-document questions are answered from every relevant document.
103
+ const finalDocs = diversifyBySource(rankedChunks, CONTEXT_TOP_N, PER_SOURCE_CAP);
104
+
105
+ // 4. Build Cohere documents + an id -> source map for citation resolution.
106
+ const idToSource = new Map<string, Source>();
107
+ const documents = finalDocs.map((c, i) => {
108
+ const id = String(i);
109
+ idToSource.set(id, { name: c.sourceName, type: c.sourceType });
110
+ return { id, data: { title: c.sourceName, text: c.text } };
111
+ });
112
+
113
+ // 5. Generate the grounded answer with citations.
114
+ const { text, citations } = await chatWithDocuments(query, documents);
115
+
116
+ // 6. Resolve cited document ids back to UI sources, deduped by name.
117
+ const seen = new Set<string>();
118
+ const sources: Source[] = [];
119
+ for (const citation of citations) {
120
+ for (const src of citation.sources ?? []) {
121
+ const id = src.id;
122
+ if (!id) continue;
123
+ const mapped = idToSource.get(id);
124
+ if (mapped && !seen.has(mapped.name)) {
125
+ seen.add(mapped.name);
126
+ sources.push(mapped);
127
+ }
128
+ }
129
+ }
130
+
131
+ // If the model produced no citations, fall back to the top reranked sources.
132
+ if (sources.length === 0) {
133
+ for (const c of finalDocs.slice(0, 2)) {
134
+ if (!seen.has(c.sourceName)) {
135
+ seen.add(c.sourceName);
136
+ sources.push({ name: c.sourceName, type: c.sourceType });
137
+ }
138
+ }
139
+ }
140
+
141
+ return NextResponse.json({ text, sources });
142
+ } catch (err) {
143
+ const message = err instanceof Error ? err.message : 'Something went wrong.';
144
+ console.error('Chat error:', message);
145
+ return NextResponse.json(
146
+ { error: 'Failed to generate a response. ' + message },
147
+ { status: 500 }
148
+ );
149
+ }
150
+ }
app/api/documents/[id]/route.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { deleteFileRecord } from '@/lib/kb-store';
3
+
4
+ export const runtime = 'nodejs';
5
+
6
+ export async function DELETE(
7
+ _request: Request,
8
+ ctx: { params: Promise<{ id: string }> }
9
+ ) {
10
+ const { id } = await ctx.params;
11
+ const removed = await deleteFileRecord(id);
12
+ if (!removed) {
13
+ return NextResponse.json({ error: 'File not found.' }, { status: 404 });
14
+ }
15
+ return new NextResponse(null, { status: 204 });
16
+ }
app/api/documents/route.ts ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import {
3
+ readStore,
4
+ addFileRecord,
5
+ updateFileRecord,
6
+ toPublicFile,
7
+ type KBFileRecord,
8
+ type Chunk,
9
+ } from '@/lib/kb-store';
10
+ import {
11
+ getFileType,
12
+ formatSize,
13
+ } from '@/lib/file-meta';
14
+
15
+ // pdf-parse / mammoth / xlsx require Node, not the edge runtime.
16
+ export const runtime = 'nodejs';
17
+ export const dynamic = 'force-dynamic';
18
+ export const maxDuration = 60;
19
+
20
+ function makeId() {
21
+ return `file-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
22
+ }
23
+
24
+ async function streamToBuffer(stream: ReadableStream<Uint8Array>): Promise<Buffer> {
25
+ const arrayBuffer = await new Response(stream).arrayBuffer();
26
+ return Buffer.from(arrayBuffer);
27
+ }
28
+
29
+ async function createFileRecord(
30
+ fileName: string,
31
+ fileSize: number,
32
+ buffer: Buffer
33
+ ): Promise<NextResponse> {
34
+ const type = getFileType(fileName);
35
+ if (!type) {
36
+ return NextResponse.json(
37
+ { error: 'Unsupported file type. Use PDF, DOCX, XLSX, XLS, or CSV.' },
38
+ { status: 400 }
39
+ );
40
+ }
41
+
42
+ const record: KBFileRecord = {
43
+ id: makeId(),
44
+ name: fileName,
45
+ type,
46
+ size: formatSize(fileSize),
47
+ status: 'Processing',
48
+ uploadedAt: new Date().toISOString(),
49
+ chunks: [],
50
+ };
51
+ await addFileRecord(record);
52
+
53
+ try {
54
+ const [{ extractText, chunkText }, { embedDocuments }] = await Promise.all([
55
+ import('@/lib/parsers'),
56
+ import('@/lib/cohere'),
57
+ ]);
58
+ const text = await extractText(buffer, type);
59
+ const chunkTexts = chunkText(text);
60
+
61
+ if (chunkTexts.length === 0) {
62
+ const updated = await updateFileRecord(record.id, {
63
+ status: 'Failed',
64
+ error: 'No extractable text found in the document.',
65
+ });
66
+ return NextResponse.json({ file: toPublicFile(updated ?? record) }, { status: 422 });
67
+ }
68
+
69
+ const embeddings = await embedDocuments(chunkTexts);
70
+ const chunks: Chunk[] = chunkTexts.map((t, i) => ({
71
+ id: `${record.id}-c${i}`,
72
+ text: t,
73
+ embedding: embeddings[i] ?? [],
74
+ }));
75
+
76
+ const updated = await updateFileRecord(record.id, { status: 'Ready', chunks });
77
+ return NextResponse.json({ file: toPublicFile(updated ?? record) });
78
+ } catch (err) {
79
+ const message = err instanceof Error ? err.message : 'Processing failed.';
80
+ const updated = await updateFileRecord(record.id, { status: 'Failed', error: message });
81
+ return NextResponse.json(
82
+ { file: toPublicFile(updated ?? record), error: message },
83
+ { status: 500 }
84
+ );
85
+ }
86
+ }
87
+
88
+ export async function GET() {
89
+ try {
90
+ const store = await readStore();
91
+ return NextResponse.json({ files: store.files.map(toPublicFile) });
92
+ } catch (err) {
93
+ const message = err instanceof Error ? err.message : 'Failed to load documents.';
94
+ return NextResponse.json({ error: message, files: [] }, { status: 500 });
95
+ }
96
+ }
97
+
98
+ export async function POST(request: Request) {
99
+ const contentType = request.headers.get('content-type') ?? '';
100
+
101
+ if (contentType.includes('application/json')) {
102
+ let body: { blobPathname?: string; name?: string; size?: number };
103
+ try {
104
+ body = await request.json();
105
+ } catch {
106
+ return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 });
107
+ }
108
+
109
+ const blobPathname = body.blobPathname?.trim();
110
+ const fileName = body.name?.trim();
111
+ if (!blobPathname || !fileName) {
112
+ return NextResponse.json(
113
+ { error: 'blobPathname and name are required.' },
114
+ { status: 400 }
115
+ );
116
+ }
117
+
118
+ try {
119
+ const { del, get } = await import('@vercel/blob');
120
+ const blob = await get(blobPathname, { access: 'private', useCache: false });
121
+ if (!blob || blob.statusCode !== 200) {
122
+ return NextResponse.json({ error: 'Uploaded blob was not found.' }, { status: 404 });
123
+ }
124
+
125
+ const buffer = await streamToBuffer(blob.stream);
126
+ const response = await createFileRecord(fileName, body.size ?? blob.blob.size, buffer);
127
+
128
+ // The raw upload is only a handoff object. The indexed KB is stored separately.
129
+ await del(blobPathname).catch(() => undefined);
130
+ return response;
131
+ } catch (err) {
132
+ const message = err instanceof Error ? err.message : 'Blob processing failed.';
133
+ return NextResponse.json({ error: message }, { status: 500 });
134
+ }
135
+ }
136
+
137
+ let form: FormData;
138
+ try {
139
+ form = await request.formData();
140
+ } catch {
141
+ return NextResponse.json({ error: 'Expected multipart/form-data.' }, { status: 400 });
142
+ }
143
+
144
+ const file = form.get('file');
145
+ if (!(file instanceof File)) {
146
+ return NextResponse.json({ error: 'No file provided.' }, { status: 400 });
147
+ }
148
+
149
+ try {
150
+ const buffer = Buffer.from(await file.arrayBuffer());
151
+ return createFileRecord(file.name, file.size, buffer);
152
+ } catch (err) {
153
+ const message = err instanceof Error ? err.message : 'Processing failed.';
154
+ return NextResponse.json({ error: message }, { status: 500 });
155
+ }
156
+ }
app/api/documents/upload/route.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { handleUpload, type HandleUploadBody } from '@vercel/blob/client';
2
+ import { NextResponse } from 'next/server';
3
+ import { getFileType } from '@/lib/file-meta';
4
+
5
+ export const runtime = 'nodejs';
6
+ export const dynamic = 'force-dynamic';
7
+
8
+ const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
9
+
10
+ export async function POST(request: Request) {
11
+ let body: HandleUploadBody;
12
+ try {
13
+ body = (await request.json()) as HandleUploadBody;
14
+ } catch {
15
+ return NextResponse.json({ error: 'Invalid upload request.' }, { status: 400 });
16
+ }
17
+
18
+ try {
19
+ const response = await handleUpload({
20
+ body,
21
+ request,
22
+ onBeforeGenerateToken: async (pathname) => {
23
+ const fileName = pathname.split('/').pop() ?? pathname;
24
+ if (!getFileType(fileName)) {
25
+ throw new Error('Unsupported file type. Use PDF, DOCX, XLSX, XLS, or CSV.');
26
+ }
27
+
28
+ return {
29
+ addRandomSuffix: true,
30
+ maximumSizeInBytes: MAX_UPLOAD_BYTES,
31
+ };
32
+ },
33
+ });
34
+
35
+ return NextResponse.json(response);
36
+ } catch (err) {
37
+ const message = err instanceof Error ? err.message : 'Upload token generation failed.';
38
+ return NextResponse.json({ error: message }, { status: 400 });
39
+ }
40
+ }
app/api/qa/[id]/route.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { readStore, updateQA, deleteQA, toPublicQA } from '@/lib/kb-store';
3
+
4
+ export const runtime = 'nodejs';
5
+
6
+ function qaEmbeddingText(question: string, answer: string): string {
7
+ return `Q: ${question}\nA: ${answer}`;
8
+ }
9
+
10
+ export async function PUT(
11
+ request: Request,
12
+ ctx: { params: Promise<{ id: string }> }
13
+ ) {
14
+ const { id } = await ctx.params;
15
+
16
+ let body: {
17
+ question?: string;
18
+ answer?: string;
19
+ category?: string;
20
+ prioritize?: boolean;
21
+ };
22
+ try {
23
+ body = await request.json();
24
+ } catch {
25
+ return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 });
26
+ }
27
+
28
+ const question = body.question?.trim();
29
+ const answer = body.answer?.trim();
30
+ if (!question || !answer) {
31
+ return NextResponse.json(
32
+ { error: 'Both question and answer are required.' },
33
+ { status: 400 }
34
+ );
35
+ }
36
+
37
+ const store = await readStore();
38
+ const existing = store.qa.find((q) => q.id === id);
39
+ if (!existing) {
40
+ return NextResponse.json({ error: 'Q&A not found.' }, { status: 404 });
41
+ }
42
+
43
+ // Re-embed only if the question or answer text changed.
44
+ const textChanged =
45
+ existing.question !== question || existing.answer !== answer;
46
+ const { embedDocuments } = textChanged
47
+ ? await import('@/lib/cohere')
48
+ : { embedDocuments: null };
49
+ const embedding = textChanged
50
+ ? (await embedDocuments!([qaEmbeddingText(question, answer)]))[0] ?? existing.embedding
51
+ : existing.embedding;
52
+
53
+ const updated = await updateQA(id, {
54
+ question,
55
+ answer,
56
+ category: body.category?.trim() || 'General',
57
+ prioritize: Boolean(body.prioritize),
58
+ embedding,
59
+ });
60
+
61
+ return NextResponse.json({ qa: toPublicQA(updated!) });
62
+ }
63
+
64
+ export async function DELETE(
65
+ _request: Request,
66
+ ctx: { params: Promise<{ id: string }> }
67
+ ) {
68
+ const { id } = await ctx.params;
69
+ const removed = await deleteQA(id);
70
+ if (!removed) {
71
+ return NextResponse.json({ error: 'Q&A not found.' }, { status: 404 });
72
+ }
73
+ return new NextResponse(null, { status: 204 });
74
+ }
app/api/qa/route.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { readStore, addQA, toPublicQA, type QARecord } from '@/lib/kb-store';
3
+
4
+ export const runtime = 'nodejs';
5
+ export const dynamic = 'force-dynamic';
6
+
7
+ function qaEmbeddingText(question: string, answer: string): string {
8
+ return `Q: ${question}\nA: ${answer}`;
9
+ }
10
+
11
+ // GET — public list of Q&A pairs (no embeddings).
12
+ export async function GET() {
13
+ try {
14
+ const store = await readStore();
15
+ return NextResponse.json({ qa: store.qa.map(toPublicQA) });
16
+ } catch (err) {
17
+ const message = err instanceof Error ? err.message : 'Failed to load Q&A pairs.';
18
+ return NextResponse.json({ error: message, qa: [] }, { status: 500 });
19
+ }
20
+ }
21
+
22
+ // POST — create a Q&A pair, embed it for retrieval.
23
+ export async function POST(request: Request) {
24
+ let body: {
25
+ question?: string;
26
+ answer?: string;
27
+ category?: string;
28
+ prioritize?: boolean;
29
+ };
30
+ try {
31
+ body = await request.json();
32
+ } catch {
33
+ return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 });
34
+ }
35
+
36
+ const question = body.question?.trim();
37
+ const answer = body.answer?.trim();
38
+ if (!question || !answer) {
39
+ return NextResponse.json(
40
+ { error: 'Both question and answer are required.' },
41
+ { status: 400 }
42
+ );
43
+ }
44
+
45
+ const { embedDocuments } = await import('@/lib/cohere');
46
+ const [embedding] = await embedDocuments([qaEmbeddingText(question, answer)]);
47
+
48
+ const record: QARecord = {
49
+ id: `qa-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
50
+ question,
51
+ answer,
52
+ category: body.category?.trim() || 'General',
53
+ prioritize: Boolean(body.prioritize),
54
+ embedding: embedding ?? [],
55
+ };
56
+ await addQA(record);
57
+
58
+ return NextResponse.json({ qa: toPublicQA(record) });
59
+ }
app/favicon.ico ADDED
app/globals.css ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ :root {
4
+ --background: #ffffff;
5
+ --foreground: #171717;
6
+ }
7
+
8
+ @theme inline {
9
+ --color-background: var(--background);
10
+ --color-foreground: var(--foreground);
11
+ --font-sans: var(--font-geist-sans);
12
+ --font-mono: var(--font-geist-mono);
13
+ }
14
+
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --background: #0a0a0a;
18
+ --foreground: #ededed;
19
+ }
20
+ }
21
+
22
+ body {
23
+ color: var(--foreground);
24
+ font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
25
+ -webkit-font-smoothing: antialiased;
26
+ -moz-osx-font-smoothing: grayscale;
27
+ text-rendering: optimizeLegibility;
28
+ font-feature-settings: "liga" 1, "calt" 1;
29
+ }
30
+
31
+ /* Scrollable but with no visible scrollbar (used by the chat thread). */
32
+ .no-scrollbar {
33
+ -ms-overflow-style: none;
34
+ scrollbar-width: none;
35
+ }
36
+ .no-scrollbar::-webkit-scrollbar {
37
+ display: none;
38
+ }
39
+
40
+ /* ===== Query Bot Custom Animations & Styles ===== */
41
+
42
+ @keyframes float-top-right {
43
+ 0% {
44
+ transform: translate(0, 0) scale(1);
45
+ opacity: 0.55;
46
+ }
47
+
48
+ 50% {
49
+ transform: translate(-4%, 6%) scale(1.12);
50
+ opacity: 0.7;
51
+ }
52
+
53
+ 100% {
54
+ transform: translate(3%, -3%) scale(0.92);
55
+ opacity: 0.45;
56
+ }
57
+ }
58
+
59
+ @keyframes float-bottom-left {
60
+ 0% {
61
+ transform: translate(0, 0) scale(1);
62
+ opacity: 0.5;
63
+ }
64
+
65
+ 50% {
66
+ transform: translate(5%, -8%) scale(1.08);
67
+ opacity: 0.65;
68
+ }
69
+
70
+ 100% {
71
+ transform: translate(-3%, 3%) scale(0.95);
72
+ opacity: 0.45;
73
+ }
74
+ }
75
+
76
+ @keyframes float-bottom-right {
77
+ 0% {
78
+ transform: translate(0, 0) scale(1);
79
+ opacity: 0.45;
80
+ }
81
+
82
+ 50% {
83
+ transform: translate(-8%, -5%) scale(1.15);
84
+ opacity: 0.6;
85
+ }
86
+
87
+ 100% {
88
+ transform: translate(3%, 2%) scale(0.9);
89
+ opacity: 0.35;
90
+ }
91
+ }
92
+
93
+ .animate-ambient-tr {
94
+ animation: float-top-right 16s ease-in-out infinite alternate;
95
+ }
96
+
97
+ .animate-ambient-bl {
98
+ animation: float-bottom-left 18s ease-in-out infinite alternate;
99
+ }
100
+
101
+ .animate-ambient-br {
102
+ animation: float-bottom-right 14s ease-in-out infinite alternate;
103
+ }
104
+
105
+ /* Typography styles for premium SaaS feeling */
106
+ .font-sans-geist {
107
+ font-family: var(--font-geist-sans), sans-serif;
108
+ }
109
+
110
+ .gif-clipped-word {
111
+ background-image: url("https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExcTIyN2RwbGdkc2w4YWxva3pudHl6M3BtdXV0eW00ZDMzMDdhMzExMSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/irCR68lB8tZDy8GKb0/giphy.gif");
112
+ background-size: cover;
113
+ background-position: center;
114
+ background-repeat: repeat;
115
+ color: transparent;
116
+ -webkit-background-clip: text;
117
+ background-clip: text;
118
+ -webkit-text-fill-color: transparent;
119
+ position: relative;
120
+ display: inline-block;
121
+ font-weight: 700;
122
+ padding-right: 0.15em;
123
+ /* Prevents italic character clipping on the right edge */
124
+ }
125
+
126
+ /* ===== Aurora glass cards — vibrant gradient glow bleeding up from the
127
+ bottom of each card. Set --aurora-1 / --aurora-2 per card to recolor. ===== */
128
+ .aurora-card {
129
+ position: relative;
130
+ overflow: hidden;
131
+ isolation: isolate;
132
+ }
133
+
134
+ .aurora-card::after {
135
+ content: "";
136
+ position: absolute;
137
+ left: -15%;
138
+ right: -15%;
139
+ bottom: -55%;
140
+ height: 110%;
141
+ z-index: -1;
142
+ pointer-events: none;
143
+ background:
144
+ radial-gradient(ellipse 55% 100% at 35% 100%, var(--aurora-1, rgba(139, 92, 246, 0.55)) 0%, transparent 68%),
145
+ radial-gradient(ellipse 55% 100% at 70% 100%, var(--aurora-2, rgba(6, 182, 212, 0.5)) 0%, transparent 68%);
146
+ filter: blur(26px);
147
+ opacity: 0.5;
148
+ transform: translateY(8%);
149
+ transition: opacity 0.45s ease, transform 0.45s ease;
150
+ }
151
+
152
+ .aurora-card:hover::after {
153
+ opacity: 0.9;
154
+ transform: translateY(0);
155
+ }
156
+
157
+ @media (prefers-reduced-motion: reduce) {
158
+ .aurora-card::after {
159
+ transition: none;
160
+ }
161
+ }
162
+
163
+ /* Animated GIF fill — same asset used by the "documents" hero word, reused
164
+ as a button background. Paused by default (single still frame), and it
165
+ plays only while the user hovers the button. */
166
+ .gif-bg {
167
+ background-image: url("https://media3.giphy.com/media/irCR68lB8tZDy8GKb0/giphy_s.gif");
168
+ background-size: cover;
169
+ background-position: center;
170
+ background-repeat: no-repeat;
171
+ }
172
+
173
+ /* On hover, swap to the animated GIF so it plays from the first frame. */
174
+ .gif-bg:hover {
175
+ background-image: url("https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExcTIyN2RwbGdkc2w4YWxva3pudHl6M3BtdXV0eW00ZDMzMDdhMzExMSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/irCR68lB8tZDy8GKb0/giphy.gif");
176
+ }
177
+
178
+ /* While pressed, drop the GIF entirely so the button reads as a transparent
179
+ (ghost) button on click. */
180
+ .gif-bg:active {
181
+ background-image: none;
182
+ background-color: transparent;
183
+ }
184
+
185
+ @media (prefers-reduced-motion: reduce) {
186
+ .gif-bg {
187
+ background-image: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #06b6d4 100%) !important;
188
+ }
189
+ }
190
+
191
+ /* ===== Radial glow emanating from the center boundary between white/dark halves ===== */
192
+ .hero-radial-glow {
193
+ position: absolute;
194
+ inset: 0;
195
+ pointer-events: none;
196
+ z-index: 1;
197
+ background:
198
+ /* Primary large glow – soft indigo/violet */
199
+ radial-gradient(ellipse 70% 55% at 50% 0%, rgba(99, 102, 241, 0.35) 0%, rgba(79, 70, 229, 0.15) 30%, transparent 70%),
200
+ /* Secondary wider cyan tint */
201
+ radial-gradient(ellipse 90% 45% at 50% 0%, rgba(6, 182, 212, 0.18) 0%, rgba(20, 184, 166, 0.06) 40%, transparent 75%),
202
+ /* Tight bright core */
203
+ radial-gradient(ellipse 40% 30% at 50% 0%, rgba(168, 85, 247, 0.25) 0%, transparent 60%);
204
+ }
205
+
206
+ /* The redesigned glowing horizon */
207
+ .hero-bloom {
208
+ /* Brand color variables — luminous indigo/periwinkle.
209
+ The inner basin is a soft periwinkle (not near-white), so the bottom of
210
+ the screen fills with gentle colour instead of reading as white space. */
211
+ --bloom-inner-start: #c7d2fe;
212
+ --bloom-inner-mid: #818cf8;
213
+ --bloom-arch-core: #4f46e5;
214
+ --bloom-arch-dark: #3730a3;
215
+
216
+ /* Cursor influence — driven from JS, defaults keep the bloom centered */
217
+ --bloom-mx: 0;
218
+ --bloom-my: 0;
219
+
220
+ /* Extra downward shift of the gradient centre. 0% on the landing hero;
221
+ bumped up in chat mode (.is-chatting) so the arch finishes just above
222
+ the chat input instead of rising into the middle of the screen. */
223
+ --bloom-push: 0%;
224
+
225
+ position: absolute;
226
+ /* Overhang past the viewport bottom so the GSAP breathing translate (y: -8)
227
+ never lifts the layer enough to reveal a white strip at the bottom edge. */
228
+ top: 0;
229
+ left: 0;
230
+ right: 0;
231
+ bottom: -12%;
232
+ overflow: hidden;
233
+ pointer-events: none;
234
+ z-index: 1;
235
+ /* Origin at the bottom so the GSAP entrance grows upward from the floor */
236
+ transform-origin: bottom center;
237
+
238
+ /* Entrance + breathing are driven by GSAP (see HeroSection.tsx).
239
+ The two pseudo-element layers still drift via CSS for the liquid effect. */
240
+ }
241
+
242
+ /* In chat mode, push the glow down so it sits just above the input. */
243
+ .hero-bloom.is-chatting {
244
+ --bloom-push: 14%;
245
+ }
246
+
247
+ .hero-bloom::before,
248
+ .hero-bloom::after {
249
+ content: "";
250
+ position: absolute;
251
+ inset: -10%;
252
+ transform-origin: bottom center;
253
+ pointer-events: none;
254
+ }
255
+
256
+ /* --- LIGHT LAYER A (Slightly left-biased) --- */
257
+ /* The gradient center is nudged by the cursor (--bloom-mx/--bloom-my) so the
258
+ bright core of the arch leans toward wherever the mouse is. */
259
+ .hero-bloom::before {
260
+ background:
261
+ radial-gradient(ellipse 80% 60% at calc(48% + 5% * var(--bloom-mx)) calc(113% + 3% * var(--bloom-my) + var(--bloom-push)),
262
+ transparent 83%,
263
+ rgba(255, 255, 255, 0.8) 88%,
264
+ transparent 93%),
265
+ radial-gradient(ellipse 80% 60% at calc(48% + 5% * var(--bloom-mx)) calc(113% + 3% * var(--bloom-my) + var(--bloom-push)),
266
+ var(--bloom-inner-start) 0%,
267
+ var(--bloom-inner-mid) 45%,
268
+ var(--bloom-arch-core) 72%,
269
+ var(--bloom-arch-dark) 85%,
270
+ transparent 92%);
271
+
272
+ filter: blur(8px);
273
+ animation: bloom-drift-left 11s ease-in-out infinite alternate;
274
+ }
275
+
276
+ /* --- LIGHT LAYER B (Slightly right-biased, opposite cursor lean for depth) --- */
277
+ .hero-bloom::after {
278
+ background:
279
+ radial-gradient(ellipse 82% 62% at calc(52% + 8% * var(--bloom-mx)) calc(116% + 4% * var(--bloom-my) + var(--bloom-push)),
280
+ transparent 83%,
281
+ rgba(255, 255, 255, 0.6) 88%,
282
+ transparent 93%),
283
+ radial-gradient(ellipse 82% 62% at calc(52% + 8% * var(--bloom-mx)) calc(116% + 4% * var(--bloom-my) + var(--bloom-push)),
284
+ var(--bloom-inner-start) 0%,
285
+ var(--bloom-inner-mid) 42%,
286
+ var(--bloom-arch-core) 70%,
287
+ var(--bloom-arch-dark) 84%,
288
+ transparent 92%);
289
+
290
+ filter: blur(10px);
291
+ mix-blend-mode: multiply;
292
+ animation: bloom-drift-right 15s ease-in-out infinite alternate;
293
+ }
294
+
295
+ /* White wash behind the headline that melts smoothly into the dark — no hard
296
+ seam. Solid white up top, fading fully transparent before the bloom. */
297
+ .hero-top-wash {
298
+ position: absolute;
299
+ top: 0;
300
+ left: 0;
301
+ right: 0;
302
+ height: 64%;
303
+ z-index: 2;
304
+ pointer-events: none;
305
+ background: linear-gradient(
306
+ to bottom,
307
+ #ffffff 0%,
308
+ #ffffff 56%,
309
+ rgba(255, 255, 255, 0.85) 72%,
310
+ rgba(255, 255, 255, 0.4) 86%,
311
+ rgba(255, 255, 255, 0) 100%
312
+ );
313
+ }
314
+
315
+ @keyframes bloom-drift-left {
316
+ 0% {
317
+ transform: translate(-1.5%, 1%) scale(0.97);
318
+ filter: blur(8px) brightness(0.95);
319
+ }
320
+ 100% {
321
+ transform: translate(1.5%, -1%) scale(1.03);
322
+ filter: blur(11px) brightness(1.05);
323
+ }
324
+ }
325
+
326
+ @keyframes bloom-drift-right {
327
+ 0% {
328
+ transform: translate(1.5%, -0.5%) scale(1.02);
329
+ filter: blur(11px) brightness(1.03);
330
+ }
331
+ 100% {
332
+ transform: translate(-1.5%, 0.5%) scale(0.98);
333
+ filter: blur(8px) brightness(0.97);
334
+ }
335
+ }
336
+
337
+ @media (prefers-reduced-motion: reduce) {
338
+ .hero-bloom {
339
+ animation: none;
340
+ }
341
+ .hero-bloom::before,
342
+ .hero-bloom::after {
343
+ animation: none;
344
+ transform: none;
345
+ filter: blur(8px);
346
+ }
347
+ }
348
+
349
+ /* ===== Chat page ambient glow ===== */
350
+ .chat-page-glow {
351
+ position: absolute;
352
+ inset: 0;
353
+ pointer-events: none;
354
+ z-index: 0;
355
+ background:
356
+ /* Center ambient glow */
357
+ radial-gradient(ellipse 60% 50% at 50% 30%, rgba(99, 102, 241, 0.12) 0%, transparent 70%),
358
+ /* Soft violet accent */
359
+ radial-gradient(ellipse 50% 40% at 60% 60%, rgba(139, 92, 246, 0.08) 0%, transparent 65%),
360
+ /* Subtle cyan tint */
361
+ radial-gradient(ellipse 45% 35% at 35% 50%, rgba(6, 182, 212, 0.06) 0%, transparent 60%);
362
+ }
363
+
364
+
365
+ @keyframes gradientShift {
366
+ 0% {
367
+ background-position: 0% 50%;
368
+ }
369
+
370
+ 50% {
371
+ background-position: 100% 50%;
372
+ }
373
+
374
+ 100% {
375
+ background-position: 0% 50%;
376
+ }
377
+ }
378
+
379
+ /* Graceful fallback for browsers or users who preferred reduced motion */
380
+ @media (prefers-reduced-motion: reduce) {
381
+ .gif-clipped-word {
382
+ background-image: linear-gradient(135deg, #ffffff 0%, #cbd5e1 100%) !important;
383
+ background-size: 100% 100% !important;
384
+ animation: none !important;
385
+ }
386
+ }
387
+
388
+ .aurora-bg {
389
+ position: absolute;
390
+ inset: 0;
391
+ overflow: hidden;
392
+ /* No background-color here – parent already sets it */
393
+ background-image:
394
+ /* Left side soft glow - Modern Teal/Cyan */
395
+ radial-gradient(ellipse 60% 60% at -10% 30%, rgba(20, 184, 166, 0.16) 0%, rgba(13, 148, 136, 0.04) 50%, transparent 90%),
396
+
397
+ /* Right side soft glow - Sophisticated Violet/Purple */
398
+ radial-gradient(ellipse 60% 60% at 110% 40%, rgba(168, 85, 247, 0.15) 0%, rgba(139, 92, 246, 0.04) 50%, transparent 90%),
399
+
400
+ /* Center bottom soft blue ambient light */
401
+ radial-gradient(ellipse 70% 50% at 50% 100%, rgba(59, 130, 246, 0.14) 0%, rgba(29, 78, 216, 0.02) 60%, transparent 100%);
402
+ }
403
+
404
+ .aurora-bg::before {
405
+ content: "";
406
+ position: absolute;
407
+ inset: -15%;
408
+ background:
409
+ /* Floating cyan mist */
410
+ radial-gradient(ellipse 40% 40% at 30% 45%, rgba(6, 182, 212, 0.18) 0%, transparent 70%),
411
+ /* Floating violet mist */
412
+ radial-gradient(ellipse 40% 40% at 70% 55%, rgba(139, 92, 246, 0.16) 0%, transparent 70%),
413
+ /* Floating blue deep ambient */
414
+ radial-gradient(ellipse 45% 45% at 50% 75%, rgba(59, 130, 246, 0.12) 0%, transparent 75%);
415
+ filter: blur(60px);
416
+ animation: auroraFloat 20s ease-in-out infinite alternate;
417
+ }
418
+
419
+ .aurora-bg::after {
420
+ content: "";
421
+ position: absolute;
422
+ inset: 0;
423
+ opacity: 0.18;
424
+ pointer-events: none;
425
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.35'/%3E%3C/svg%3E");
426
+ }
427
+
428
+ @keyframes auroraFloat {
429
+ from {
430
+ transform: translate3d(-2%, 1%, 0) scale(1);
431
+ opacity: 0.75;
432
+ }
433
+
434
+ to {
435
+ transform: translate3d(2%, -2%, 0) scale(1.08);
436
+ opacity: 1;
437
+ }
438
+ }
app/layout.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Geist, Geist_Mono } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const geistSans = Geist({
6
+ variable: "--font-geist-sans",
7
+ subsets: ["latin"],
8
+ });
9
+
10
+ const geistMono = Geist_Mono({
11
+ variable: "--font-geist-mono",
12
+ subsets: ["latin"],
13
+ });
14
+
15
+ export const metadata: Metadata = {
16
+ title: "Query Bot",
17
+ description: "Chat with your documents - A modern company knowledge-base Q&A platform.",
18
+ };
19
+
20
+ export default function RootLayout({
21
+ children,
22
+ }: Readonly<{
23
+ children: React.ReactNode;
24
+ }>) {
25
+ return (
26
+ <html
27
+ lang="en"
28
+ className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
29
+ >
30
+ <body className="min-h-full flex flex-col font-sans" suppressHydrationWarning>{children}</body>
31
+ </html>
32
+ );
33
+ }
app/page.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import HeroSection from '@/components/HeroSection';
5
+
6
+ export default function Home() {
7
+ return (
8
+ <main className="min-h-screen">
9
+ <HeroSection />
10
+ </main>
11
+ );
12
+ }
app/template.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { motion } from 'motion/react';
5
+
6
+ /**
7
+ * App-level template re-mounts on every route change, so animating its mount
8
+ * gives a smooth cross-route transition (e.g. / → /chat) without needing the
9
+ * experimental React <ViewTransition> component.
10
+ */
11
+ export default function Template({ children }: { children: React.ReactNode }) {
12
+ return (
13
+ <motion.div
14
+ initial={{ opacity: 0, y: 12 }}
15
+ animate={{ opacity: 1, y: 0 }}
16
+ transition={{ duration: 0.45, ease: [0.16, 1, 0.3, 1] }}
17
+ >
18
+ {children}
19
+ </motion.div>
20
+ );
21
+ }
components/AdminHeader.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import Link from 'next/link';
5
+
6
+ export default function AdminHeader() {
7
+ return (
8
+ <div className="w-full pb-6 mb-8 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
9
+ {/* Left Side: Back to home and Dashboard title */}
10
+ <div className="flex items-center gap-4">
11
+ <Link
12
+ href="/"
13
+ className="inline-flex items-center justify-center h-9 px-4 rounded-full text-xs font-semibold text-white bg-gradient-to-r from-cyan-500 to-blue-600 shadow-lg shadow-blue-500/25 hover:brightness-110 transition-all duration-150 active:scale-95 cursor-pointer"
14
+ >
15
+ ← Home
16
+ </Link>
17
+ <h1 className="text-xl font-bold text-white tracking-tight">Knowledge Base Admin</h1>
18
+ </div>
19
+
20
+ {/* Changes save and publish automatically — no manual publish step. */}
21
+ <div className="flex items-center gap-2 text-xs font-medium text-white/50">
22
+ <span className="relative flex h-2 w-2">
23
+ <span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75 animate-ping" />
24
+ <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400" />
25
+ </span>
26
+ Auto-saved &amp; published
27
+ </div>
28
+ </div>
29
+ );
30
+ }
components/Badge.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from 'react';
2
+
3
+ export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
4
+ variant?: 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'purple' | 'neutral';
5
+ }
6
+
7
+ export default function Badge({
8
+ children,
9
+ className = '',
10
+ variant = 'neutral',
11
+ ...props
12
+ }: BadgeProps) {
13
+ const baseStyle = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border transition-all duration-150';
14
+
15
+ const variants = {
16
+ neutral: 'bg-neutral-50 text-neutral-600 border-neutral-200',
17
+ primary: 'bg-neutral-900 text-white border-neutral-900',
18
+ secondary: 'bg-neutral-100 text-neutral-700 border-neutral-200',
19
+ info: 'bg-blue-50 text-blue-600 border-blue-100',
20
+ success: 'bg-emerald-50 text-emerald-700 border-emerald-100',
21
+ warning: 'bg-amber-50 text-amber-700 border-amber-100',
22
+ danger: 'bg-red-50 text-red-700 border-red-100',
23
+ purple: 'bg-purple-50 text-purple-700 border-purple-100',
24
+ };
25
+
26
+ return (
27
+ <span className={`${baseStyle} ${variants[variant]} ${className}`} {...props}>
28
+ {children}
29
+ </span>
30
+ );
31
+ }
components/Button.tsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from 'react';
2
+
3
+ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
4
+ variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
5
+ size?: 'sm' | 'md' | 'lg' | 'icon';
6
+ }
7
+
8
+ export default function Button({
9
+ children,
10
+ className = '',
11
+ variant = 'primary',
12
+ size = 'md',
13
+ type = 'button',
14
+ ...props
15
+ }: ButtonProps) {
16
+ // Styles for different variants
17
+ const baseStyle = 'inline-flex items-center justify-center font-medium transition-all duration-200 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900 disabled:opacity-50 disabled:pointer-events-none active:scale-[0.98] cursor-pointer';
18
+
19
+ const variants = {
20
+ primary: 'bg-neutral-900 text-white hover:bg-neutral-800 border border-neutral-900 shadow-sm',
21
+ secondary: 'bg-neutral-100 text-neutral-800 hover:bg-neutral-200 border border-transparent',
22
+ outline: 'bg-white border border-neutral-200 text-neutral-700 hover:bg-neutral-50 hover:border-neutral-300',
23
+ ghost: 'text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900 border border-transparent',
24
+ danger: 'bg-red-50 text-red-600 border border-red-200 hover:bg-red-100 active:bg-red-200',
25
+ };
26
+
27
+ const sizes = {
28
+ sm: 'px-3 py-1.5 text-xs font-semibold',
29
+ md: 'px-4 py-2.5 text-sm',
30
+ lg: 'px-6 py-3.5 text-base',
31
+ icon: 'p-2',
32
+ };
33
+
34
+ return (
35
+ <button
36
+ type={type}
37
+ className={`${baseStyle} ${variants[variant]} ${sizes[size]} ${className}`}
38
+ {...props}
39
+ >
40
+ {children}
41
+ </button>
42
+ );
43
+ }
components/FileTypeIcon.tsx ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from 'react';
2
+ import Image from 'next/image';
3
+
4
+ // Colored brand logos from icons8 — these are multicolor icons, so the
5
+ // `color` query param is intentionally omitted (it has no effect on them).
6
+ const ICON_SOURCES: Record<string, string> = {
7
+ PDF: 'https://img.icons8.com/?size=100&id=l0vjMqIboTRs&format=png',
8
+ DOC: 'https://img.icons8.com/?size=100&id=EqxMzyq5jqdz&format=png',
9
+ DOCX: 'https://img.icons8.com/?size=100&id=EqxMzyq5jqdz&format=png',
10
+ EXCEL: 'https://img.icons8.com/?size=100&id=13654&format=png',
11
+ XLS: 'https://img.icons8.com/?size=100&id=13654&format=png',
12
+ XLSX: 'https://img.icons8.com/?size=100&id=13654&format=png',
13
+ CSV: 'https://img.icons8.com/?size=100&id=13654&format=png',
14
+ };
15
+
16
+ interface FileTypeIconProps {
17
+ type: string;
18
+ size?: number;
19
+ className?: string;
20
+ }
21
+
22
+ /**
23
+ * Renders the icons8 brand logo for a known document type (PDF, Word,
24
+ * Excel/CSV). Returns `null` for anything else so callers can fall back
25
+ * to their own glyph (e.g. custom Q&A sources).
26
+ */
27
+ export default function FileTypeIcon({ type, size = 24, className = '' }: FileTypeIconProps) {
28
+ const src = ICON_SOURCES[type.toUpperCase()];
29
+ if (!src) return null;
30
+
31
+ return (
32
+ <Image
33
+ src={src}
34
+ alt={`${type} document`}
35
+ width={size}
36
+ height={size}
37
+ className={className}
38
+ />
39
+ );
40
+ }
components/FileUploadPanel.tsx ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import UploadedFileCard from './UploadedFileCard';
5
+ import FileTypeIcon from './FileTypeIcon';
6
+ import { KBFile } from '@/lib/kb-data';
7
+
8
+ interface FileUploadPanelProps {
9
+ files: KBFile[];
10
+ onUpload: (file: File) => Promise<void>;
11
+ onDelete: (id: string) => void;
12
+ deletingIds: Set<string>;
13
+ }
14
+
15
+ type UploadStatus = 'uploading' | 'done' | 'error';
16
+
17
+ interface PendingUpload {
18
+ id: string;
19
+ name: string;
20
+ file: File;
21
+ progress: number;
22
+ status: UploadStatus;
23
+ }
24
+
25
+ let uploadSeq = 0;
26
+
27
+ export default function FileUploadPanel({ files, onUpload, onDelete, deletingIds }: FileUploadPanelProps) {
28
+ const [isDragOver, setIsDragOver] = React.useState(false);
29
+ const [uploads, setUploads] = React.useState<PendingUpload[]>([]);
30
+ const fileInputRef = React.useRef<HTMLInputElement>(null);
31
+
32
+ const dismissUpload = (id: string) =>
33
+ setUploads((prev) => prev.filter((u) => u.id !== id));
34
+
35
+ // Upload a real file: parsing + embedding happens server-side, so we show an
36
+ // indeterminate progress bar that creeps toward 90% until the POST resolves.
37
+ const startUpload = (file: File) => {
38
+ const fileId = `up-${uploadSeq++}`;
39
+
40
+ setUploads((prev) => [
41
+ ...prev,
42
+ { id: fileId, name: file.name, file, progress: 8, status: 'uploading' },
43
+ ]);
44
+
45
+ let prog = 8;
46
+ const interval = setInterval(() => {
47
+ prog = Math.min(90, prog + Math.floor(Math.random() * 8) + 4);
48
+ setUploads((prev) =>
49
+ prev.map((u) => (u.id === fileId && u.status === 'uploading' ? { ...u, progress: prog } : u))
50
+ );
51
+ }, 300);
52
+
53
+ const succeed = () => {
54
+ clearInterval(interval);
55
+ // Don't show a separate "file uploaded" card. The file is already added
56
+ // to the knowledge-base list below, so just remove the progress toast and
57
+ // let the file card itself signal completion.
58
+ dismissUpload(fileId);
59
+ };
60
+
61
+ const fail = () => {
62
+ clearInterval(interval);
63
+ setUploads((prev) =>
64
+ prev.map((u) => (u.id === fileId ? { ...u, status: 'error' } : u))
65
+ );
66
+ };
67
+
68
+ Promise.resolve(onUpload(file)).then(succeed, fail);
69
+ };
70
+
71
+ const retryUpload = (u: PendingUpload) => {
72
+ dismissUpload(u.id);
73
+ startUpload(u.file);
74
+ };
75
+
76
+ const handleDragOver = (e: React.DragEvent) => {
77
+ e.preventDefault();
78
+ setIsDragOver(true);
79
+ };
80
+
81
+ const handleDragLeave = (e: React.DragEvent) => {
82
+ e.preventDefault();
83
+ setIsDragOver(false);
84
+ };
85
+
86
+ const handleDrop = (e: React.DragEvent) => {
87
+ e.preventDefault();
88
+ setIsDragOver(false);
89
+
90
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
91
+ const droppedFiles = Array.from(e.dataTransfer.files);
92
+ const eligibleFiles = droppedFiles.filter(f => {
93
+ const ext = f.name.split('.').pop()?.toLowerCase() || '';
94
+ return ['pdf', 'docx', 'doc', 'xlsx', 'xls', 'csv'].includes(ext);
95
+ });
96
+
97
+ if (eligibleFiles.length > 0) {
98
+ eligibleFiles.forEach((f) => startUpload(f));
99
+ } else {
100
+ alert('Please drop accepted file types (PDF, DOCX, XLSX, XLS, CSV).');
101
+ }
102
+ }
103
+ };
104
+
105
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
106
+ if (e.target.files && e.target.files.length > 0) {
107
+ const selected = Array.from(e.target.files);
108
+ selected.forEach((f) => startUpload(f));
109
+ // Reset input value to allow uploading same file again if deleted
110
+ if (fileInputRef.current) fileInputRef.current.value = '';
111
+ }
112
+ };
113
+
114
+ const triggerBrowse = () => {
115
+ fileInputRef.current?.click();
116
+ };
117
+
118
+ return (
119
+ <div className="flex flex-col h-full">
120
+ {/* Panel Headers */}
121
+ <div>
122
+ <h2 className="text-xl font-semibold text-white tracking-tight">Upload documents</h2>
123
+ <p className="text-sm text-white/50 mt-1 leading-relaxed">
124
+ Drag and drop PDFs, Word documents, and spreadsheets into your knowledge base.
125
+ </p>
126
+ </div>
127
+
128
+ {/* Hidden File Input */}
129
+ <input
130
+ ref={fileInputRef}
131
+ type="file"
132
+ multiple
133
+ accept=".pdf,.docx,.doc,.xlsx,.xls,.csv"
134
+ className="hidden"
135
+ onChange={handleFileChange}
136
+ />
137
+
138
+ {/* Drop Zone Box */}
139
+ <div
140
+ onDragOver={handleDragOver}
141
+ onDragLeave={handleDragLeave}
142
+ onDrop={handleDrop}
143
+ className={`mt-6 border-2 border-dashed rounded-2xl flex flex-col items-center justify-center p-8 text-center transition-all duration-300 ${
144
+ isDragOver
145
+ ? 'border-white/40 bg-white/5 hover:border-white/50'
146
+ : 'border-white/10 bg-black/20 hover:border-white/20'
147
+ }`}
148
+ >
149
+ {/* Document Icons – fanned-out brand logos */}
150
+ <div className="flex items-end justify-center mb-5">
151
+ <FileTypeIcon
152
+ type="PDF"
153
+ size={44}
154
+ className="-rotate-12 translate-y-1 drop-shadow-xl transition-transform duration-300"
155
+ />
156
+ <FileTypeIcon
157
+ type="DOCX"
158
+ size={54}
159
+ className="-mx-2 z-10 drop-shadow-2xl transition-transform duration-300"
160
+ />
161
+ <FileTypeIcon
162
+ type="EXCEL"
163
+ size={44}
164
+ className="rotate-12 translate-y-1 drop-shadow-xl transition-transform duration-300"
165
+ />
166
+ </div>
167
+
168
+ <p className="text-sm font-medium text-white/90">Drop your files here</p>
169
+ <p className="text-xs text-white/40 mt-1">PDF, DOCX, XLSX, XLS, or CSV up to 25MB</p>
170
+
171
+ <button
172
+ type="button"
173
+ onClick={triggerBrowse}
174
+ className="mt-5 inline-flex items-center justify-center h-9 px-4 rounded-full text-xs font-semibold bg-white/5 border border-white/10 text-white hover:bg-white/10 transition-colors duration-150 active:scale-[0.98] cursor-pointer"
175
+ >
176
+ Browse files
177
+ </button>
178
+ </div>
179
+
180
+ {/* Files List Heading */}
181
+ <div className="mt-8">
182
+ <h3 className="text-xs font-bold text-white/40 uppercase tracking-wider">
183
+ Knowledge Base (Files)
184
+ </h3>
185
+
186
+ {/* Upload status toasts (uploading / done / error) */}
187
+ {uploads.length > 0 && (
188
+ <div className="mt-3 space-y-2.5">
189
+ {uploads.map((u) => (
190
+ <UploadToast
191
+ key={u.id}
192
+ upload={u}
193
+ onCancel={() => dismissUpload(u.id)}
194
+ onRetry={() => retryUpload(u)}
195
+ />
196
+ ))}
197
+ </div>
198
+ )}
199
+
200
+ {/* Stable List of Files */}
201
+ {files.length === 0 && uploads.length === 0 ? (
202
+ <div className="mt-3 p-8 border border-white/5 rounded-2xl bg-black/20 text-center text-xs text-white/30">
203
+ No documents uploaded yet. Place your documents here to augment AI knowledge.
204
+ </div>
205
+ ) : (
206
+ <div className="mt-3 space-y-2.5 max-h-[300px] overflow-y-auto pr-1">
207
+ {files.map((file) => (
208
+ <UploadedFileCard
209
+ key={file.id}
210
+ file={file}
211
+ onDelete={onDelete}
212
+ isDeleting={deletingIds.has(file.id)}
213
+ />
214
+ ))}
215
+ </div>
216
+ )}
217
+ </div>
218
+
219
+ {/* Bottom informational footline */}
220
+ <p className="text-[11px] text-white/30 mt-auto pt-6 leading-normal flex items-center gap-1.5">
221
+ <svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
222
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
223
+ </svg>
224
+ Documents are indexed for AI search after upload.
225
+ </p>
226
+ </div>
227
+ );
228
+ }
229
+
230
+ const TOAST_CONFIG: Record<
231
+ UploadStatus,
232
+ { glow: string; ring: string; bar: string; title: string; desc: string; icon: React.ReactNode }
233
+ > = {
234
+ uploading: {
235
+ glow: 'radial-gradient(circle, rgba(99,102,241,0.6) 0%, rgba(59,130,246,0.25) 45%, transparent 72%)',
236
+ ring: 'border-indigo-400/60 text-indigo-300',
237
+ bar: 'from-indigo-400 to-violet-400',
238
+ title: 'Just a minute…',
239
+ desc: 'Your file is uploading right now. Hang tight while we finish.',
240
+ icon: (
241
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
242
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m0 0l5-5m-5 5l-5-5" />
243
+ </svg>
244
+ ),
245
+ },
246
+ done: {
247
+ glow: 'radial-gradient(circle, rgba(16,185,129,0.55) 0%, rgba(20,184,166,0.2) 45%, transparent 72%)',
248
+ ring: 'border-emerald-400/60 text-emerald-300',
249
+ bar: 'from-emerald-400 to-teal-400',
250
+ title: 'Your file was uploaded!',
251
+ desc: 'Added to your knowledge base and queued for AI indexing.',
252
+ icon: (
253
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
254
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
255
+ </svg>
256
+ ),
257
+ },
258
+ error: {
259
+ glow: 'radial-gradient(circle, rgba(244,63,94,0.55) 0%, rgba(225,29,72,0.2) 45%, transparent 72%)',
260
+ ring: 'border-rose-400/60 text-rose-300',
261
+ bar: 'from-rose-400 to-pink-400',
262
+ title: 'We are so sorry!',
263
+ desc: "There was an error and your file couldn't be uploaded. Try again?",
264
+ icon: (
265
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
266
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12M18 6L6 18" />
267
+ </svg>
268
+ ),
269
+ },
270
+ };
271
+
272
+ interface UploadToastProps {
273
+ upload: PendingUpload;
274
+ onCancel: () => void;
275
+ onRetry: () => void;
276
+ }
277
+
278
+ function UploadToast({ upload, onCancel, onRetry }: UploadToastProps) {
279
+ const { name, progress, status } = upload;
280
+ const cfg = TOAST_CONFIG[status];
281
+ const btn =
282
+ 'inline-flex items-center justify-center h-8 px-4 rounded-lg text-xs font-semibold bg-white/[0.06] border border-white/10 text-white hover:bg-white/10 transition-colors duration-150 cursor-pointer';
283
+
284
+ return (
285
+ <div className="relative overflow-hidden rounded-2xl border border-white/10 bg-[#161616]/90 backdrop-blur-md p-4">
286
+ {/* Soft corner glow tinted by status */}
287
+ <div
288
+ className="pointer-events-none absolute -top-12 -right-10 h-32 w-52 blur-3xl opacity-70"
289
+ style={{ background: cfg.glow }}
290
+ />
291
+
292
+ <div className="relative flex gap-3.5">
293
+ {/* Status icon ring */}
294
+ <div className={`mt-0.5 h-9 w-9 flex-shrink-0 rounded-full border-2 flex items-center justify-center ${cfg.ring} ${status === 'uploading' ? 'animate-pulse' : ''}`}>
295
+ {cfg.icon}
296
+ </div>
297
+
298
+ <div className="flex-1 min-w-0">
299
+ <p className="text-sm font-bold text-white leading-tight">{cfg.title}</p>
300
+ <p className="text-xs text-white/50 mt-1 leading-relaxed">{cfg.desc}</p>
301
+ <p className="text-[11px] text-white/40 mt-1 truncate font-mono" title={name}>
302
+ {name}
303
+ </p>
304
+
305
+ {status === 'uploading' && (
306
+ <div className="mt-3 flex items-end gap-3">
307
+ <div className="flex-1">
308
+ <div className="flex justify-end mb-1">
309
+ <span className="text-[11px] font-semibold text-white/70 font-mono">{progress}%</span>
310
+ </div>
311
+ <div className="w-full h-1.5 rounded-full bg-white/10 overflow-hidden">
312
+ <div
313
+ className={`h-full rounded-full bg-gradient-to-r ${cfg.bar} transition-all duration-200`}
314
+ style={{ width: `${progress}%` }}
315
+ />
316
+ </div>
317
+ </div>
318
+ <button onClick={onCancel} className={btn}>
319
+ Cancel
320
+ </button>
321
+ </div>
322
+ )}
323
+
324
+ {status === 'error' && (
325
+ <div className="mt-3 flex items-center gap-2">
326
+ <button onClick={onRetry} className={btn}>
327
+ Retry
328
+ </button>
329
+ <button onClick={onCancel} className={btn}>
330
+ Cancel
331
+ </button>
332
+ </div>
333
+ )}
334
+
335
+ {status === 'done' && (
336
+ <div className="mt-3">
337
+ <button onClick={onCancel} className={btn}>
338
+ Done
339
+ </button>
340
+ </div>
341
+ )}
342
+ </div>
343
+ </div>
344
+ </div>
345
+ );
346
+ }
components/FloatingChatInput.tsx ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+
6
+ const PLACEHOLDERS = [
7
+ "Ask your documents a question...",
8
+ "Summarize the Q3 financial report...",
9
+ "Extract action items from the meeting notes...",
10
+ "Find clauses about termination...",
11
+ ];
12
+
13
+ interface FloatingChatInputProps {
14
+ /**
15
+ * When provided, submitting calls this instead of navigating to /chat —
16
+ * used by the homepage to run the conversation inline. Without it, the
17
+ * input falls back to routing to the dedicated /chat page.
18
+ */
19
+ onSubmitText?: (text: string) => void;
20
+ disabled?: boolean;
21
+ }
22
+
23
+ export default function FloatingChatInput({ onSubmitText, disabled }: FloatingChatInputProps) {
24
+ const router = useRouter();
25
+ const [val, setVal] = React.useState('');
26
+ const [phText, setPhText] = React.useState('');
27
+ const [phIndex, setPhIndex] = React.useState(0);
28
+ const [isDeleting, setIsDeleting] = React.useState(false);
29
+
30
+ React.useEffect(() => {
31
+ const currentString = PLACEHOLDERS[phIndex];
32
+ let timeout: NodeJS.Timeout;
33
+
34
+ if (isDeleting) {
35
+ timeout = setTimeout(() => {
36
+ setPhText(currentString.substring(0, phText.length - 1));
37
+ if (phText.length <= 1) {
38
+ setIsDeleting(false);
39
+ setPhIndex((prev) => (prev + 1) % PLACEHOLDERS.length);
40
+ }
41
+ }, 40);
42
+ } else {
43
+ timeout = setTimeout(() => {
44
+ setPhText(currentString.substring(0, phText.length + 1));
45
+ if (phText.length === currentString.length) {
46
+ timeout = setTimeout(() => {
47
+ setIsDeleting(true);
48
+ }, 2500);
49
+ }
50
+ }, 60);
51
+ }
52
+
53
+ return () => clearTimeout(timeout);
54
+ }, [phText, isDeleting, phIndex]);
55
+
56
+ const handleSubmit = (e: React.FormEvent) => {
57
+ e.preventDefault();
58
+ const text = val.trim();
59
+ if (!text) return;
60
+ if (onSubmitText) {
61
+ onSubmitText(text);
62
+ setVal('');
63
+ } else {
64
+ router.push(`/chat?q=${encodeURIComponent(text)}`);
65
+ }
66
+ };
67
+
68
+ const handleKeydown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
69
+ if (e.key === 'Enter' && !e.shiftKey) {
70
+ e.preventDefault();
71
+ handleSubmit(e as unknown as React.FormEvent);
72
+ }
73
+ };
74
+
75
+ return (
76
+ <div className="w-full max-w-3xl mx-auto px-4 mt-8">
77
+ <form
78
+ onSubmit={handleSubmit}
79
+ className="flex flex-col relative bg-[#262626]/90 backdrop-blur-2xl border border-white/5 rounded-3xl shadow-[0_12px_50px_-6px_rgba(0,0,0,0.3)] focus-within:shadow-[0_16px_60px_-4px_rgba(0,0,0,0.5)] focus-within:border-white/15 hover:border-white/10 transition-all duration-300 p-3 min-h-[140px] max-w-[720px] mx-auto"
80
+ >
81
+ {/* Input Area */}
82
+ <div className="flex-1 px-3 pt-2 pb-2">
83
+ <textarea
84
+ value={val}
85
+ onChange={(e) => setVal(e.target.value)}
86
+ onKeyDown={handleKeydown}
87
+ placeholder={phText}
88
+ disabled={disabled}
89
+ className="w-full h-full min-h-[60px] bg-transparent text-white placeholder-neutral-400 focus:outline-none text-base resize-none font-sans disabled:opacity-60"
90
+ />
91
+ </div>
92
+
93
+ {/* Bottom Actions Row */}
94
+ <div className="flex items-center justify-end px-2 pt-2 border-t border-transparent">
95
+ {/* Right Actions (Submit) */}
96
+ <button
97
+ type="submit"
98
+ disabled={!val.trim() || disabled}
99
+ className={`flex-shrink-0 h-9 w-9 rounded-full flex items-center justify-center transition-all duration-200 shadow-sm ${
100
+ val.trim() && !disabled
101
+ ? 'bg-white text-black hover:scale-105 active:scale-95 cursor-pointer'
102
+ : 'bg-white/10 text-white/30 cursor-not-allowed'
103
+ }`}
104
+ >
105
+ <svg
106
+ className="w-5 h-5"
107
+ fill="none"
108
+ viewBox="0 0 24 24"
109
+ stroke="currentColor"
110
+ strokeWidth={1.8}
111
+ >
112
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 19V5m0 0l-6 6m6-6l6 6" />
113
+ </svg>
114
+ </button>
115
+ </div>
116
+ </form>
117
+ </div>
118
+ );
119
+ }
components/HeroSection.tsx ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import Link from 'next/link';
5
+ import { motion, AnimatePresence } from 'motion/react';
6
+ import gsap from 'gsap';
7
+ import FloatingChatInput from './FloatingChatInput';
8
+ import MessageBubble from './MessageBubble';
9
+ import { useChatSession } from '@/lib/use-chat-session';
10
+
11
+ export default function HeroSection() {
12
+ const bloomRef = React.useRef<HTMLDivElement>(null);
13
+ const threadRef = React.useRef<HTMLDivElement>(null);
14
+ const outsideTouchYRef = React.useRef<number | null>(null);
15
+
16
+ // Inline conversation — runs the chat right here on `/` instead of routing
17
+ // away to the dedicated /chat page.
18
+ const { messages, isTyping, sendMessage, reset } = useChatSession();
19
+ const active = messages.length > 0 || isTyping;
20
+
21
+ // Follow-up input shown inside the chat view (separate from the big hero input).
22
+ const [chatInput, setChatInput] = React.useState('');
23
+ const handleChatSubmit = (e: React.FormEvent) => {
24
+ e.preventDefault();
25
+ const text = chatInput.trim();
26
+ if (!text || isTyping) return;
27
+ setChatInput('');
28
+ sendMessage(text);
29
+ };
30
+
31
+ // Auto-scroll the *thread container only* (not the window) to the latest
32
+ // message. Using scrollIntoView here would scroll the whole page and push
33
+ // the header off-screen.
34
+ React.useEffect(() => {
35
+ const el = threadRef.current;
36
+ if (active && el) {
37
+ el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
38
+ }
39
+ }, [messages, isTyping, active]);
40
+
41
+ const handleChatWheel = React.useCallback(
42
+ (e: React.WheelEvent<HTMLElement>) => {
43
+ const el = threadRef.current;
44
+ if (!active || !el || el.contains(e.target as Node)) return;
45
+ if (el.scrollHeight <= el.clientHeight) return;
46
+
47
+ el.scrollTop += e.deltaY;
48
+ e.preventDefault();
49
+ },
50
+ [active]
51
+ );
52
+
53
+ const handleChatTouchStart = React.useCallback(
54
+ (e: React.TouchEvent<HTMLElement>) => {
55
+ const el = threadRef.current;
56
+ if (!active || !el || el.contains(e.target as Node)) {
57
+ outsideTouchYRef.current = null;
58
+ return;
59
+ }
60
+
61
+ outsideTouchYRef.current = e.touches[0]?.clientY ?? null;
62
+ },
63
+ [active]
64
+ );
65
+
66
+ const handleChatTouchMove = React.useCallback((e: React.TouchEvent<HTMLElement>) => {
67
+ const el = threadRef.current;
68
+ const previousY = outsideTouchYRef.current;
69
+ const currentY = e.touches[0]?.clientY;
70
+ if (!el || previousY === null || currentY === undefined) return;
71
+ if (el.scrollHeight <= el.clientHeight) return;
72
+
73
+ el.scrollTop += previousY - currentY;
74
+ outsideTouchYRef.current = currentY;
75
+ e.preventDefault();
76
+ }, []);
77
+
78
+ // GSAP-driven entrance + breathing on the bloom container.
79
+ // The two pseudo-element layers keep drifting via CSS, so this composes on
80
+ // top of the liquid effect rather than replacing it.
81
+ React.useEffect(() => {
82
+ const bloom = bloomRef.current;
83
+ if (!bloom) return;
84
+
85
+ const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
86
+ if (reduced) {
87
+ // Skip animation; just reveal it in its resting state.
88
+ gsap.set(bloom, { opacity: 1, clearProps: 'transform,filter' });
89
+ return;
90
+ }
91
+
92
+ const ctx = gsap.context(() => {
93
+ const tl = gsap.timeline();
94
+
95
+ // 1. Cinematic bottom-to-top power-up on load.
96
+ tl.fromTo(
97
+ bloom,
98
+ {
99
+ scaleY: 0.1,
100
+ scaleX: 0.85,
101
+ y: 100,
102
+ opacity: 0,
103
+ filter: 'blur(20px) brightness(0.4)',
104
+ },
105
+ {
106
+ scaleY: 1,
107
+ scaleX: 1,
108
+ y: 0,
109
+ opacity: 1,
110
+ filter: 'blur(0px) brightness(1)',
111
+ duration: 2.4,
112
+ ease: 'power4.out',
113
+ }
114
+ );
115
+
116
+ // 2. Organic breathing loop once the entrance settles.
117
+ tl.to(bloom, {
118
+ scaleY: 1.02,
119
+ scaleX: 1.01,
120
+ y: -8,
121
+ filter: 'blur(0px) brightness(1.04)',
122
+ duration: 6,
123
+ ease: 'sine.inOut',
124
+ repeat: -1,
125
+ yoyo: true,
126
+ });
127
+ }, bloom);
128
+
129
+ return () => ctx.revert();
130
+ }, []);
131
+
132
+ // Drive CSS variables so the gradient's bright core follows the cursor.
133
+ // (We set variables, not transforms, so this never fights the GSAP tweens.)
134
+ React.useEffect(() => {
135
+ const handleMouseMove = (e: MouseEvent) => {
136
+ const bloom = bloomRef.current;
137
+ if (!bloom) return;
138
+ const x = e.clientX / window.innerWidth - 0.5;
139
+ const y = e.clientY / window.innerHeight - 0.5;
140
+ bloom.style.setProperty('--bloom-mx', String(x));
141
+ bloom.style.setProperty('--bloom-my', String(y));
142
+ };
143
+
144
+ window.addEventListener('mousemove', handleMouseMove);
145
+ return () => window.removeEventListener('mousemove', handleMouseMove);
146
+ }, []);
147
+
148
+ return (
149
+ <section
150
+ id="hero"
151
+ onWheel={active ? handleChatWheel : undefined}
152
+ onTouchStart={active ? handleChatTouchStart : undefined}
153
+ onTouchMove={active ? handleChatTouchMove : undefined}
154
+ onTouchEnd={active ? () => { outsideTouchYRef.current = null; } : undefined}
155
+ className={`relative min-h-screen flex flex-col items-center overflow-hidden transition-colors duration-700 ${
156
+ active ? 'bg-white' : ''
157
+ }`}
158
+ >
159
+ {/* Radial multi-gradient bloom rising from the bottom-center.
160
+ Starts hidden; GSAP fades/sweeps it in (see effect above).
161
+ In chat mode it drops lower to sit just above the input. */}
162
+ <div
163
+ ref={bloomRef}
164
+ className={`hero-bloom ${active ? 'is-chatting' : ''}`}
165
+ style={{ opacity: 0 }}
166
+ />
167
+
168
+ {/* White wash behind the headline — only while in landing (idle) mode. */}
169
+ {!active && <div className="hero-top-wash" />}
170
+
171
+ <AnimatePresence mode="wait" initial={false}>
172
+ {active ? (
173
+ /* ─────────────── Inline chat view ─────────────── */
174
+ <motion.div
175
+ key="chat"
176
+ initial={{ opacity: 0 }}
177
+ animate={{ opacity: 1 }}
178
+ exit={{ opacity: 0 }}
179
+ transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
180
+ className="relative z-10 w-full max-w-3xl mx-auto flex flex-col h-screen px-4 sm:px-6"
181
+ >
182
+ {/* Minimalist navbar — controls only, no logo */}
183
+ <header className="flex items-center justify-end gap-1.5 py-4 select-none flex-shrink-0">
184
+ {/* New chat — subtle text+icon button */}
185
+ <button
186
+ onClick={reset}
187
+ className="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 transition-colors duration-150 cursor-pointer"
188
+ >
189
+ <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
190
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
191
+ </svg>
192
+ New chat
193
+ </button>
194
+ {/* Admin — subtle outlined pill */}
195
+ <Link
196
+ href="/admin"
197
+ className="inline-flex items-center rounded-full border border-neutral-200 bg-white/60 backdrop-blur px-3.5 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-100 hover:border-neutral-300 transition-all duration-150"
198
+ >
199
+ Admin
200
+ </Link>
201
+ </header>
202
+
203
+ {/* Thread — only this scrolls. The inner wrapper is min-h-full with
204
+ justify-end so a few messages rest just above the input, while a
205
+ long conversation still scrolls naturally from the top. */}
206
+ <div ref={threadRef} className="flex-1 overflow-y-auto no-scrollbar px-1 min-h-0">
207
+ <div className="min-h-full flex flex-col justify-end py-8">
208
+ {messages.map((item) => (
209
+ <MessageBubble key={item.id} message={item} />
210
+ ))}
211
+
212
+ {/* Thinking indicator — minimal, document-style (no avatar/bubble) */}
213
+ {isTyping && (
214
+ <motion.div
215
+ initial={{ opacity: 0, y: 6 }}
216
+ animate={{ opacity: 1, y: 0 }}
217
+ className="w-full mb-10 flex items-center gap-2 text-[15px] text-neutral-400"
218
+ >
219
+ <span>Searching your knowledge base</span>
220
+ <span className="flex items-center gap-1">
221
+ <span className="h-1.5 w-1.5 bg-neutral-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
222
+ <span className="h-1.5 w-1.5 bg-neutral-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
223
+ <span className="h-1.5 w-1.5 bg-neutral-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
224
+ </span>
225
+ </motion.div>
226
+ )}
227
+ </div>
228
+ </div>
229
+
230
+ {/* Follow-up input pinned to the bottom — dark, matching the home input */}
231
+ <div className="flex-shrink-0 pb-5 pt-2">
232
+ <form
233
+ onSubmit={handleChatSubmit}
234
+ className="relative flex items-center gap-2 rounded-full bg-[#262626] border border-white/10 shadow-xl shadow-black/25 pl-5 pr-2 py-2 focus-within:border-white/20 transition-all duration-200"
235
+ >
236
+ <input
237
+ type="text"
238
+ value={chatInput}
239
+ onChange={(e) => setChatInput(e.target.value)}
240
+ disabled={isTyping}
241
+ placeholder="Ask a follow-up question..."
242
+ className="flex-1 bg-transparent text-[15px] text-white placeholder-white/40 focus:outline-none py-2 disabled:opacity-60"
243
+ />
244
+ <button
245
+ type="submit"
246
+ disabled={!chatInput.trim() || isTyping}
247
+ aria-label="Send message"
248
+ className={`flex-shrink-0 h-10 w-10 rounded-full flex items-center justify-center transition-all duration-200 ${
249
+ chatInput.trim() && !isTyping
250
+ ? 'bg-white text-black hover:scale-105 active:scale-95 cursor-pointer'
251
+ : 'bg-white/10 text-white/30 cursor-not-allowed'
252
+ }`}
253
+ >
254
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
255
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 19V5m0 0l-6 6m6-6l6 6" />
256
+ </svg>
257
+ </button>
258
+ </form>
259
+ <p className="text-[10px] text-white/45 text-center mt-2.5">
260
+ Query Bot prioritizes your custom Q&amp;A over document matches and cites its sources.
261
+ </p>
262
+ </div>
263
+ </motion.div>
264
+ ) : (
265
+ /* ─────────────── Landing (idle) view ─────────────── */
266
+ <motion.div
267
+ key="hero"
268
+ initial={{ opacity: 0 }}
269
+ animate={{ opacity: 1 }}
270
+ exit={{ opacity: 0 }}
271
+ transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
272
+ className="relative z-10 w-full flex flex-col items-center"
273
+ >
274
+ {/* ── Headline content (sits on the white wash) ── */}
275
+ <div className="w-full flex flex-col items-center pt-28 px-6">
276
+ {/* Large Headline */}
277
+ <motion.h1
278
+ initial={{ opacity: 0, y: 15 }}
279
+ animate={{ opacity: 1, y: 0 }}
280
+ transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] }}
281
+ className="text-[40px] sm:text-5xl md:text-6xl lg:text-7xl font-sans font-bold tracking-tight text-neutral-900 leading-[1.05] max-w-3xl text-center"
282
+ >
283
+ Chat with your{' '}
284
+ <span className="gif-clipped-word italic select-all">documents</span>
285
+ </motion.h1>
286
+
287
+ {/* Subtitle */}
288
+ <motion.p
289
+ initial={{ opacity: 0, y: 15 }}
290
+ animate={{ opacity: 1, y: 0 }}
291
+ transition={{ duration: 0.8, delay: 0.12, ease: [0.16, 1, 0.3, 1] }}
292
+ className="mt-6 text-[15px] sm:text-base md:text-lg text-neutral-500 max-w-2xl font-normal leading-relaxed mx-auto text-center"
293
+ >
294
+ Upload PDFs, Word docs, and spreadsheets. Add custom Q&A. Get fast, grounded answers with sources.
295
+ </motion.p>
296
+
297
+ {/* Premium Dual CTA Pill Container */}
298
+ <motion.div
299
+ initial={{ opacity: 0, y: 15 }}
300
+ animate={{ opacity: 1, y: 0 }}
301
+ transition={{ duration: 0.8, delay: 0.24, ease: [0.16, 1, 0.3, 1] }}
302
+ className="mt-8 flex flex-col items-center gap-3 w-full"
303
+ >
304
+ <div className="flex items-center justify-between bg-neutral-100 backdrop-blur-md pl-5 pr-1 py-1 rounded-full border border-neutral-200 shadow-xs max-w-md w-full sm:w-auto gap-4">
305
+ <span className="text-xs sm:text-[13px] font-sans font-normal text-neutral-500 tracking-tight text-left">
306
+ Build your knowledge base
307
+ </span>
308
+ <Link href="/admin" className="flex-shrink-0">
309
+ <button className="bg-neutral-900 hover:bg-neutral-800 text-white font-sans font-semibold text-xs px-4.5 py-2 rounded-full transition-all duration-150 active:scale-95 shadow-2xl cursor-pointer">
310
+ Open Admin
311
+ </button>
312
+ </Link>
313
+ </div>
314
+ </motion.div>
315
+ </div>
316
+
317
+ {/* ── Floating AI Chat Input (sits in the dark, above the bloom) ── */}
318
+ <motion.div
319
+ initial={{ opacity: 0, y: 25 }}
320
+ animate={{ opacity: 1, y: 0 }}
321
+ transition={{ duration: 0.8, delay: 0.36, ease: [0.16, 1, 0.3, 1] }}
322
+ className="relative z-20 w-full max-w-4xl px-6 mt-12"
323
+ >
324
+ <FloatingChatInput onSubmitText={sendMessage} />
325
+ </motion.div>
326
+
327
+ {/* Spacer so the bloom has room to breathe below the input */}
328
+ <div className="min-h-[18vh]" />
329
+ </motion.div>
330
+ )}
331
+ </AnimatePresence>
332
+ </section>
333
+ );
334
+ }
components/MessageBubble.tsx ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { motion } from 'motion/react';
4
+ import ReactMarkdown from 'react-markdown';
5
+ import remarkGfm from 'remark-gfm';
6
+ import FileTypeIcon from './FileTypeIcon';
7
+ import Badge from './Badge';
8
+ import { ChatMessage } from '@/lib/kb-data';
9
+
10
+ interface SourceCardProps {
11
+ name: string;
12
+ type: string;
13
+ }
14
+
15
+ function SourceCard({ name, type }: SourceCardProps) {
16
+ const isFileType = ['PDF', 'DOC', 'DOCX', 'EXCEL', 'XLS', 'XLSX', 'CSV'].includes(
17
+ type.toUpperCase()
18
+ );
19
+
20
+ const getBadgeColor = (): 'danger' | 'success' | 'info' | 'purple' | 'neutral' => {
21
+ switch (type.toUpperCase()) {
22
+ case 'PDF':
23
+ return 'danger';
24
+ case 'EXCEL':
25
+ case 'XLS':
26
+ return 'success';
27
+ case 'DOCX':
28
+ return 'info';
29
+ case 'Q&A':
30
+ return 'purple';
31
+ default:
32
+ return 'neutral';
33
+ }
34
+ };
35
+
36
+ return (
37
+ <div className="inline-flex items-center gap-2 px-3 py-1.5 bg-white hover:bg-neutral-50 border border-neutral-200 rounded-full text-xs text-neutral-600 shadow-sm transition-colors duration-200 cursor-default">
38
+ <span className="flex-shrink-0 flex items-center justify-center">
39
+ {isFileType ? (
40
+ <FileTypeIcon type={type} size={16} className="block" />
41
+ ) : (
42
+ <span className="text-[13px] text-violet-500">*</span>
43
+ )}
44
+ </span>
45
+ <span className="font-medium truncate max-w-[150px]" title={name}>
46
+ {name}
47
+ </span>
48
+ <Badge variant={getBadgeColor()} className="px-1.5 py-0 text-[8px] font-bold">
49
+ {type}
50
+ </Badge>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ interface MessageBubbleProps {
56
+ message: ChatMessage;
57
+ }
58
+
59
+ function MarkdownContent({ text, compact = false }: { text: string; compact?: boolean }) {
60
+ return (
61
+ <ReactMarkdown
62
+ remarkPlugins={[remarkGfm]}
63
+ components={{
64
+ h1: ({ children }) => (
65
+ <h1 className="mt-6 first:mt-0 mb-2 text-xl font-bold tracking-tight text-neutral-950">
66
+ {children}
67
+ </h1>
68
+ ),
69
+ h2: ({ children }) => (
70
+ <h2 className="mt-5 first:mt-0 mb-2 text-lg font-bold tracking-tight text-neutral-950">
71
+ {children}
72
+ </h2>
73
+ ),
74
+ h3: ({ children }) => (
75
+ <h3 className="mt-5 first:mt-0 mb-1 text-[13px] font-bold uppercase tracking-wide text-neutral-900">
76
+ {children}
77
+ </h3>
78
+ ),
79
+ p: ({ children }) => (
80
+ <p className={`${compact ? 'mt-1 leading-6' : 'mt-3 leading-7'} first:mt-0 text-[15px] text-neutral-700`}>
81
+ {children}
82
+ </p>
83
+ ),
84
+ strong: ({ children }) => (
85
+ <strong className="font-semibold text-neutral-900">{children}</strong>
86
+ ),
87
+ em: ({ children }) => <em className="italic text-neutral-800">{children}</em>,
88
+ a: ({ href, children }) => (
89
+ <a
90
+ href={href}
91
+ target="_blank"
92
+ rel="noreferrer"
93
+ className="font-medium text-violet-700 underline decoration-violet-300 underline-offset-2 hover:text-violet-900"
94
+ >
95
+ {children}
96
+ </a>
97
+ ),
98
+ ul: ({ children }) => (
99
+ <ul className={`${compact ? 'mt-2' : 'mt-3'} space-y-1.5 pl-5 text-[15px] text-neutral-700 list-disc`}>
100
+ {children}
101
+ </ul>
102
+ ),
103
+ ol: ({ children }) => (
104
+ <ol className={`${compact ? 'mt-2' : 'mt-3'} space-y-1.5 pl-5 text-[15px] text-neutral-700 list-decimal`}>
105
+ {children}
106
+ </ol>
107
+ ),
108
+ li: ({ children }) => (
109
+ <li className="leading-7 marker:text-violet-400">{children}</li>
110
+ ),
111
+ blockquote: ({ children }) => (
112
+ <blockquote className="mt-4 border-l-2 border-violet-300 pl-4 text-neutral-600">
113
+ {children}
114
+ </blockquote>
115
+ ),
116
+ code: ({ className, children, ...props }) => {
117
+ const isBlock = className?.startsWith('language-');
118
+
119
+ if (isBlock) {
120
+ return (
121
+ <code className={`block overflow-x-auto whitespace-pre p-4 text-[13px] ${className ?? ''}`} {...props}>
122
+ {children}
123
+ </code>
124
+ );
125
+ }
126
+
127
+ return (
128
+ <code
129
+ className="rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 font-mono text-[13px] font-medium text-violet-700"
130
+ {...props}
131
+ >
132
+ {children}
133
+ </code>
134
+ );
135
+ },
136
+ pre: ({ children }) => (
137
+ <pre className="mt-4 overflow-x-auto rounded-xl border border-neutral-200 bg-neutral-950 text-neutral-50 shadow-sm">
138
+ {children}
139
+ </pre>
140
+ ),
141
+ table: ({ children }) => (
142
+ <div className="mt-4 overflow-x-auto rounded-xl border border-neutral-200">
143
+ <table className="min-w-full border-collapse text-left text-sm text-neutral-700">
144
+ {children}
145
+ </table>
146
+ </div>
147
+ ),
148
+ th: ({ children }) => (
149
+ <th className="border-b border-neutral-200 bg-neutral-50 px-3 py-2 font-semibold text-neutral-900">
150
+ {children}
151
+ </th>
152
+ ),
153
+ td: ({ children }) => (
154
+ <td className="border-t border-neutral-100 px-3 py-2 align-top">{children}</td>
155
+ ),
156
+ hr: () => <hr className="my-5 border-neutral-200" />,
157
+ }}
158
+ >
159
+ {text}
160
+ </ReactMarkdown>
161
+ );
162
+ }
163
+
164
+ export default function MessageBubble({ message }: MessageBubbleProps) {
165
+ const isUser = message.sender === 'user';
166
+
167
+ if (isUser) {
168
+ return (
169
+ <motion.div
170
+ initial={{ opacity: 0, y: 8 }}
171
+ animate={{ opacity: 1, y: 0 }}
172
+ transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
173
+ className="flex w-full justify-end mb-6"
174
+ >
175
+ <div className="max-w-[80%] rounded-2xl rounded-tr-md bg-neutral-100 border border-neutral-200/80 px-4 py-2.5">
176
+ <MarkdownContent text={message.text} compact />
177
+ </div>
178
+ </motion.div>
179
+ );
180
+ }
181
+
182
+ return (
183
+ <motion.div
184
+ initial={{ opacity: 0, y: 8 }}
185
+ animate={{ opacity: 1, y: 0 }}
186
+ transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
187
+ className="w-full mb-10"
188
+ >
189
+ <MarkdownContent text={message.text} />
190
+
191
+ {message.sources && message.sources.length > 0 && (
192
+ <div className="flex flex-wrap gap-1.5 mt-4">
193
+ {message.sources.map((src, idx) => (
194
+ <SourceCard key={idx} name={src.name} type={src.type} />
195
+ ))}
196
+ </div>
197
+ )}
198
+ </motion.div>
199
+ );
200
+ }
components/Navbar.tsx ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import Link from 'next/link';
5
+ import { usePathname } from 'next/navigation';
6
+
7
+ export default function Navbar() {
8
+ const pathname = usePathname();
9
+ const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
10
+
11
+ const navLinks = [
12
+ { name: 'Admin', href: '/admin' },
13
+ { name: 'Chat', href: '/' },
14
+ { name: 'Docs', href: '#' },
15
+ ];
16
+
17
+ return (
18
+ <nav className="fixed top-0 left-0 right-0 z-50 bg-white/40 backdrop-blur-md border-b border-neutral-200/30">
19
+ <div className="max-w-7xl mx-auto px-6 sm:px-8">
20
+ <div className="flex justify-between h-14 items-center">
21
+ {/* Logo Section */}
22
+ <div className="flex-shrink-0">
23
+ <Link href="/" className="group flex items-center">
24
+ <span className="font-sans font-semibold text-base sm:text-lg tracking-tight text-neutral-950 transition-colors duration-150">
25
+ Query Bot
26
+ </span>
27
+ </Link>
28
+ </div>
29
+
30
+ {/* Desktop Navigation Links */}
31
+ <div className="hidden md:flex items-center gap-8">
32
+ {navLinks.map((link) => {
33
+ const isActive = pathname === link.href;
34
+ return (
35
+ <Link
36
+ key={link.name}
37
+ href={link.href}
38
+ className={`text-xs font-medium tracking-wide transition-colors duration-150 uppercase ${
39
+ isActive
40
+ ? 'text-neutral-950 font-semibold'
41
+ : 'text-neutral-500 hover:text-neutral-950'
42
+ }`}
43
+ >
44
+ {link.name}
45
+ </Link>
46
+ );
47
+ })}
48
+ </div>
49
+
50
+ {/* CTA Button */}
51
+ <div className="hidden md:flex items-center">
52
+ <Link href="/">
53
+ <button className="h-8 px-4 rounded-full bg-neutral-950 hover:bg-neutral-800 text-white text-[11px] font-semibold tracking-wide transition-all duration-150 shadow-3xs cursor-pointer">
54
+ Get Started
55
+ </button>
56
+ </Link>
57
+ </div>
58
+
59
+ {/* Mobile hamburger menu button */}
60
+ <div className="md:hidden flex items-center">
61
+ <button
62
+ onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
63
+ type="button"
64
+ className="inline-flex items-center justify-center p-1.5 rounded-md text-neutral-500 hover:text-neutral-950 focus:outline-none"
65
+ aria-controls="mobile-menu"
66
+ aria-expanded="false"
67
+ >
68
+ <span className="sr-only">Open main menu</span>
69
+ {mobileMenuOpen ? (
70
+ <svg className="block h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
71
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
72
+ </svg>
73
+ ) : (
74
+ <svg className="block h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
75
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
76
+ </svg>
77
+ )}
78
+ </button>
79
+ </div>
80
+ </div>
81
+ </div>
82
+
83
+ {/* Mobile Menu dropdown */}
84
+ {mobileMenuOpen && (
85
+ <div className="md:hidden border-b border-neutral-200/30 bg-white/95 backdrop-blur-md" id="mobile-menu">
86
+ <div className="px-4 pt-2 pb-4 space-y-1">
87
+ {navLinks.map((link) => {
88
+ const isActive = pathname === link.href;
89
+ return (
90
+ <Link
91
+ key={link.name}
92
+ href={link.href}
93
+ onClick={() => setMobileMenuOpen(false)}
94
+ className={`block px-3 py-2 rounded-lg text-xs font-semibold tracking-wide uppercase ${
95
+ isActive
96
+ ? 'bg-neutral-50 text-neutral-950'
97
+ : 'text-neutral-600 hover:bg-neutral-50/50 hover:text-neutral-950'
98
+ }`}
99
+ >
100
+ {link.name}
101
+ </Link>
102
+ );
103
+ })}
104
+ <div className="pt-3 px-3">
105
+ <Link href="/" onClick={() => setMobileMenuOpen(false)}>
106
+ <button className="w-full h-9 rounded-full bg-neutral-950 text-white text-xs font-semibold tracking-wide">
107
+ Get Started
108
+ </button>
109
+ </Link>
110
+ </div>
111
+ </div>
112
+ </div>
113
+ )}
114
+ </nav>
115
+ );
116
+ }
components/QABuilderPanel.tsx ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import QACard from './QACard';
5
+ import { KBPair } from '@/lib/kb-data';
6
+
7
+ interface QABuilderPanelProps {
8
+ qaList: KBPair[];
9
+ onAdd: (q: string, a: string, cat: string, prio: boolean) => void;
10
+ onUpdate: (id: string, q: string, a: string, cat: string, prio: boolean) => void;
11
+ onDelete: (id: string) => void;
12
+ deletingIds: Set<string>;
13
+ }
14
+
15
+ export default function QABuilderPanel({ qaList, onAdd, onUpdate, onDelete, deletingIds }: QABuilderPanelProps) {
16
+ const [question, setQuestion] = React.useState('');
17
+ const [answer, setAnswer] = React.useState('');
18
+ const [category, setCategory] = React.useState('General');
19
+
20
+ // Track active edit mode
21
+ const [editingId, setEditingId] = React.useState<string | null>(null);
22
+
23
+ const categories = ['General', 'HR', 'Support', 'Pricing', 'Technical'];
24
+
25
+ // Vibrant per-category gradients used when a chip is selected.
26
+ const categoryGradients: Record<string, string> = {
27
+ General: 'from-violet-500 to-indigo-500',
28
+ HR: 'from-pink-500 to-fuchsia-500',
29
+ Support: 'from-blue-500 to-cyan-500',
30
+ Pricing: 'from-emerald-500 to-teal-500',
31
+ Technical: 'from-orange-500 to-rose-500',
32
+ };
33
+
34
+ const handleSubmit = (e: React.FormEvent) => {
35
+ e.preventDefault();
36
+ if (!question.trim() || !answer.trim()) return;
37
+
38
+ if (editingId) {
39
+ onUpdate(editingId, question.trim(), answer.trim(), category.trim(), false);
40
+ setEditingId(null);
41
+ } else {
42
+ onAdd(question.trim(), answer.trim(), category.trim(), false);
43
+ }
44
+
45
+ // Reset fields
46
+ setQuestion('');
47
+ setAnswer('');
48
+ setCategory('General');
49
+ };
50
+
51
+ const handleEditInit = (qa: KBPair) => {
52
+ setEditingId(qa.id);
53
+ setQuestion(qa.question);
54
+ setAnswer(qa.answer);
55
+ setCategory(qa.category || 'General');
56
+ };
57
+
58
+ const handleCancelEdit = () => {
59
+ setEditingId(null);
60
+ setQuestion('');
61
+ setAnswer('');
62
+ setCategory('General');
63
+ };
64
+
65
+ return (
66
+ <div className="flex flex-col h-full">
67
+ {/* Panel Headers */}
68
+ <div>
69
+ <h2 className="text-xl font-semibold text-white tracking-tight">Custom Q&A</h2>
70
+ <p className="text-sm text-white/50 mt-1 leading-relaxed">
71
+ Add direct answers for important questions your users may ask.
72
+ </p>
73
+ </div>
74
+
75
+ {/* Main Q&A Form */}
76
+ <form onSubmit={handleSubmit} className="mt-6 bg-black/20 border border-white/10 p-5 rounded-2xl flex flex-col gap-4">
77
+ {editingId && (
78
+ <div className="flex items-center justify-between bg-white text-black px-3.5 py-1.5 rounded-xl text-xs font-medium border border-white/20">
79
+ <span className="flex items-center gap-1.5 animate-pulse">
80
+ <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
81
+ Editing Q&A Pair
82
+ </span>
83
+ <button
84
+ type="button"
85
+ onClick={handleCancelEdit}
86
+ className="text-neutral-500 hover:text-black transition-colors duration-150 underline decoration-dotted text-[11px] cursor-pointer"
87
+ >
88
+ Cancel Edit
89
+ </button>
90
+ </div>
91
+ )}
92
+
93
+ {/* Question Input */}
94
+ <div>
95
+ <label htmlFor="question-input" className="block text-xs font-bold text-white/50 uppercase tracking-wider mb-2">
96
+ Question
97
+ </label>
98
+ <input
99
+ id="question-input"
100
+ type="text"
101
+ required
102
+ value={question}
103
+ onChange={(e) => setQuestion(e.target.value)}
104
+ placeholder="e.g., What are standard work hours?"
105
+ className="w-full bg-white/5 border border-white/10 rounded-xl px-3.5 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/30 placeholder:text-white/30"
106
+ />
107
+ </div>
108
+
109
+ {/* Answer Text Area */}
110
+ <div>
111
+ <label htmlFor="answer-textarea" className="block text-xs font-bold text-white/50 uppercase tracking-wider mb-2">
112
+ Direct Answer
113
+ </label>
114
+ <textarea
115
+ id="answer-textarea"
116
+ required
117
+ rows={3}
118
+ value={answer}
119
+ onChange={(e) => setAnswer(e.target.value)}
120
+ placeholder="Provide a clear, detailed answer..."
121
+ className="w-full bg-white/5 border border-white/10 rounded-xl px-3.5 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/30 placeholder:text-white/30 resize-none"
122
+ />
123
+ </div>
124
+
125
+ {/* Category + Suggestion Chips */}
126
+ <div>
127
+ <label className="block text-xs font-bold text-white/50 uppercase tracking-wider mb-2">
128
+ Category
129
+ </label>
130
+ <div className="flex flex-wrap gap-2">
131
+ {categories.map((cat) => {
132
+ const acts = category === cat;
133
+ return (
134
+ <button
135
+ key={cat}
136
+ type="button"
137
+ onClick={() => setCategory(cat)}
138
+ className={`px-3.5 py-1.5 rounded-full text-xs font-semibold border transition-all duration-100 cursor-pointer active:scale-95 ${
139
+ acts
140
+ ? `bg-gradient-to-r ${categoryGradients[cat]} text-white border-transparent shadow-lg shadow-black/30`
141
+ : 'bg-white/5 text-white/60 border-white/10 hover:bg-white/10 hover:border-white/20 hover:text-white'
142
+ }`}
143
+ >
144
+ {cat}
145
+ </button>
146
+ );
147
+ })}
148
+ </div>
149
+ </div>
150
+
151
+ {/* Submit — animated GIF background matching the hero "documents" word */}
152
+ <button
153
+ type="submit"
154
+ className="gif-bg group relative mt-2 w-full h-11 inline-flex items-center justify-center overflow-hidden rounded-full text-sm font-bold tracking-wide cursor-pointer ring-1 ring-white/20 transition-all duration-200 hover:ring-white/40 active:ring-white/50 active:scale-[0.98]"
155
+ >
156
+ {/* Dark scrim keeps the label legible over the moving GIF; clears on
157
+ press so the button turns transparent on click. */}
158
+ <span className="absolute inset-0 bg-black/35 group-hover:bg-black/20 group-active:bg-transparent transition-colors duration-200" />
159
+ <span className="relative text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.7)]">
160
+ {editingId ? 'Update Q&A' : 'Add Q&A'}
161
+ </span>
162
+ </button>
163
+ </form>
164
+
165
+ {/* Custom Q&A list container */}
166
+ <div className="mt-8">
167
+ <h3 className="text-xs font-bold text-white/40 uppercase tracking-wider mb-3">
168
+ Configured Pairings ({qaList.length})
169
+ </h3>
170
+
171
+ {qaList.length === 0 ? (
172
+ <div className="p-8 border border-white/5 rounded-2xl bg-black/20 text-center text-xs text-white/30 select-none">
173
+ No Q&A pairs configured yet. Add them above to bypass search algorithms.
174
+ </div>
175
+ ) : (
176
+ <div className="space-y-3 max-h-[350px] overflow-y-auto pr-1">
177
+ {qaList.map((qa) => (
178
+ <QACard
179
+ key={qa.id}
180
+ qa={qa}
181
+ onEdit={handleEditInit}
182
+ onDelete={onDelete}
183
+ isDeleting={deletingIds.has(qa.id)}
184
+ />
185
+ ))}
186
+ </div>
187
+ )}
188
+ </div>
189
+ </div>
190
+ );
191
+ }
components/QACard.tsx ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { KBPair } from '@/lib/kb-data';
5
+
6
+ interface QACardProps {
7
+ qa: KBPair;
8
+ onEdit: (qa: KBPair) => void;
9
+ onDelete: (id: string) => void;
10
+ isDeleting?: boolean;
11
+ }
12
+
13
+ function Spinner({ className = '' }: { className?: string }) {
14
+ return (
15
+ <svg className={`animate-spin ${className}`} viewBox="0 0 24 24" fill="none">
16
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
17
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
18
+ </svg>
19
+ );
20
+ }
21
+
22
+ // Vibrant per-category gradient used for the category chip.
23
+ const CATEGORY_GRADIENT: Record<string, string> = {
24
+ General: 'from-violet-500 to-indigo-500',
25
+ HR: 'from-pink-500 to-fuchsia-500',
26
+ Support: 'from-blue-500 to-cyan-500',
27
+ Pricing: 'from-emerald-500 to-teal-500',
28
+ Technical: 'from-orange-500 to-rose-500',
29
+ };
30
+
31
+ // Per-category aurora glow colors bleeding up from the bottom of the card.
32
+ const CATEGORY_AURORA: Record<string, React.CSSProperties> = {
33
+ General: { ['--aurora-1' as string]: 'rgba(168,85,247,0.5)', ['--aurora-2' as string]: 'rgba(99,102,241,0.45)' },
34
+ HR: { ['--aurora-1' as string]: 'rgba(236,72,153,0.5)', ['--aurora-2' as string]: 'rgba(168,85,247,0.4)' },
35
+ Support: { ['--aurora-1' as string]: 'rgba(59,130,246,0.5)', ['--aurora-2' as string]: 'rgba(34,211,238,0.45)' },
36
+ Pricing: { ['--aurora-1' as string]: 'rgba(16,185,129,0.5)', ['--aurora-2' as string]: 'rgba(132,204,22,0.4)' },
37
+ Technical: { ['--aurora-1' as string]: 'rgba(249,115,22,0.5)', ['--aurora-2' as string]: 'rgba(244,63,94,0.4)' },
38
+ };
39
+
40
+ export default function QACard({ qa, onEdit, onDelete, isDeleting = false }: QACardProps) {
41
+ const aurora = CATEGORY_AURORA[qa.category] ?? CATEGORY_AURORA.General;
42
+ const gradient = CATEGORY_GRADIENT[qa.category] ?? CATEGORY_GRADIENT.General;
43
+ return (
44
+ <div
45
+ style={aurora}
46
+ className={`aurora-card p-4 bg-white/[0.04] backdrop-blur-sm border border-white/10 rounded-2xl transition-all duration-300 select-none ${
47
+ isDeleting ? 'opacity-50' : 'hover:border-white/20'
48
+ }`}
49
+ >
50
+ <div className="flex flex-col gap-2">
51
+ {/* badges line */}
52
+ <div className="flex items-center justify-between">
53
+ <div className="flex items-center gap-1.5 flex-wrap">
54
+ <span className={`inline-flex items-center rounded-full bg-gradient-to-r ${gradient} px-2 py-0.5 text-[9px] font-bold uppercase tracking-wide text-white shadow-sm`}>
55
+ {qa.category || 'General'}
56
+ </span>
57
+ </div>
58
+
59
+ {/* edit + delete buttons (or a Removing… indicator) */}
60
+ {isDeleting ? (
61
+ <span className="inline-flex items-center gap-2 text-[11px] font-medium text-red-300/90">
62
+ <Spinner className="w-3.5 h-3.5" />
63
+ Removing…
64
+ </span>
65
+ ) : (
66
+ <div className="flex items-center gap-1">
67
+ <button
68
+ onClick={() => onEdit(qa)}
69
+ title="Edit Q&A Pair"
70
+ className="h-8 w-8 rounded-lg flex items-center justify-center text-white/40 hover:text-white hover:bg-white/10 border border-transparent transition-all duration-200 cursor-pointer"
71
+ >
72
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
73
+ <path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
74
+ </svg>
75
+ </button>
76
+ <button
77
+ onClick={() => onDelete(qa.id)}
78
+ title="Delete Q&A Pair"
79
+ className="h-8 w-8 rounded-lg flex items-center justify-center text-white/30 hover:text-red-400 hover:bg-red-500/20 border border-transparent transition-all duration-200 cursor-pointer"
80
+ >
81
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
82
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
83
+ </svg>
84
+ </button>
85
+ </div>
86
+ )}
87
+ </div>
88
+
89
+ {/* Question text */}
90
+ <h4 className="text-sm font-semibold text-white/90 tracking-tight leading-snug">
91
+ {qa.question}
92
+ </h4>
93
+
94
+ {/* Answer text */}
95
+ <p className="text-xs text-white/60 line-clamp-2 leading-relaxed">
96
+ {qa.answer}
97
+ </p>
98
+ </div>
99
+ </div>
100
+ );
101
+ }
components/UploadedFileCard.tsx ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import Badge from './Badge';
5
+ import FileTypeIcon from './FileTypeIcon';
6
+ import { KBFile } from '@/lib/kb-data';
7
+
8
+ interface UploadedFileCardProps {
9
+ file: KBFile;
10
+ onDelete: (id: string) => void;
11
+ isDeleting?: boolean;
12
+ }
13
+
14
+ function Spinner({ className = '' }: { className?: string }) {
15
+ return (
16
+ <svg className={`animate-spin ${className}`} viewBox="0 0 24 24" fill="none">
17
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
18
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
19
+ </svg>
20
+ );
21
+ }
22
+
23
+ // Per-type aurora glow colors that bleed up from the bottom of the card.
24
+ const AURORA: Record<string, React.CSSProperties> = {
25
+ PDF: { ['--aurora-1' as string]: 'rgba(244,63,94,0.55)', ['--aurora-2' as string]: 'rgba(249,115,22,0.45)' },
26
+ DOCX: { ['--aurora-1' as string]: 'rgba(59,130,246,0.55)', ['--aurora-2' as string]: 'rgba(34,211,238,0.45)' },
27
+ EXCEL: { ['--aurora-1' as string]: 'rgba(16,185,129,0.55)', ['--aurora-2' as string]: 'rgba(132,204,22,0.4)' },
28
+ CSV: { ['--aurora-1' as string]: 'rgba(168,85,247,0.55)', ['--aurora-2' as string]: 'rgba(99,102,241,0.45)' },
29
+ };
30
+
31
+ export default function UploadedFileCard({ file, onDelete, isDeleting = false }: UploadedFileCardProps) {
32
+ // Brand logo for the file type, sitting on a subtle dark tile to match
33
+ // the admin theme.
34
+ const renderIcon = () => (
35
+ <div className="h-10 w-10 rounded-xl bg-white/5 border border-white/10 flex items-center justify-center flex-shrink-0">
36
+ <FileTypeIcon type={file.type} size={22} />
37
+ </div>
38
+ );
39
+
40
+ const statusVariants: Record<string, 'success' | 'warning' | 'danger'> = {
41
+ Ready: 'success',
42
+ Processing: 'warning',
43
+ Failed: 'danger',
44
+ };
45
+
46
+ return (
47
+ <div
48
+ style={AURORA[file.type] ?? AURORA.CSV}
49
+ className={`aurora-card flex items-center justify-between p-4 bg-white/[0.04] backdrop-blur-sm border border-white/10 rounded-2xl transition-all duration-300 select-none ${
50
+ isDeleting ? 'opacity-50' : 'hover:border-white/20'
51
+ }`}
52
+ >
53
+ {/* File Metainfo */}
54
+ <div className="flex items-center gap-3 min-w-0 pr-4">
55
+ {renderIcon()}
56
+ <div className="min-w-0">
57
+ <p className="text-sm font-medium text-white truncate" title={file.name}>
58
+ {file.name}
59
+ </p>
60
+ <div className="flex items-center gap-2 mt-1">
61
+ <span className="text-[11px] text-white/50 font-mono">{file.size}</span>
62
+ <span className="text-white/30 text-[10px]">•</span>
63
+ <Badge variant={file.type === 'PDF' ? 'danger' : file.type === 'EXCEL' ? 'success' : file.type === 'DOCX' ? 'info' : 'purple'} className="py-0 px-1.5 text-[9px] bg-white/10 text-white border-white/10">
64
+ {file.type}
65
+ </Badge>
66
+ </div>
67
+ </div>
68
+ </div>
69
+
70
+ {/* Delete and Status */}
71
+ <div className="flex items-center gap-3 flex-shrink-0">
72
+ {isDeleting ? (
73
+ <span className="inline-flex items-center gap-2 text-[11px] font-medium text-red-300/90">
74
+ <Spinner className="w-3.5 h-3.5" />
75
+ Removing…
76
+ </span>
77
+ ) : (
78
+ <>
79
+ <Badge
80
+ variant={statusVariants[file.status] || 'neutral'}
81
+ className={`text-[10px] py-0.5 px-2 bg-white/10 text-white border-white/10 ${file.status === 'Processing' ? 'animate-pulse' : ''}`}
82
+ >
83
+ {file.status}
84
+ </Badge>
85
+
86
+ <button
87
+ onClick={() => onDelete(file.id)}
88
+ aria-label="Delete document"
89
+ className="h-8 w-8 rounded-lg flex items-center justify-center text-white/40 hover:text-red-400 hover:bg-red-500/20 hover:border-red-500/30 transition-colors duration-200 border border-transparent cursor-pointer"
90
+ >
91
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
92
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
93
+ </svg>
94
+ </button>
95
+ </>
96
+ )}
97
+ </div>
98
+ </div>
99
+ );
100
+ }
eslint.config.mjs ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
hooks/use-mobile.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+
3
+ const MOBILE_BREAKPOINT = 768
4
+
5
+ export function useIsMobile() {
6
+ const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
7
+
8
+ React.useEffect(() => {
9
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10
+ const onChange = () => {
11
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12
+ }
13
+ mql.addEventListener("change", onChange)
14
+ Promise.resolve().then(() => {
15
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
16
+ })
17
+ return () => mql.removeEventListener("change", onChange)
18
+ }, [])
19
+
20
+ return !!isMobile
21
+ }
lib/cohere-config.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Central, editable configuration for the Cohere RAG pipeline.
2
+ // Swap these model names if your Cohere account exposes different versions.
3
+
4
+ export const EMBED_MODEL = 'embed-v4.0';
5
+ export const RERANK_MODEL = 'rerank-v4.0-pro';
6
+ export const CHAT_MODEL = 'command-a-plus-05-2026';
7
+
8
+ // Chunking: Cohere recommends ~400-word chunks for best retrieval performance.
9
+ export const CHUNK_WORDS = 400;
10
+ export const CHUNK_OVERLAP_WORDS = 50;
11
+
12
+ // Retrieval tuning.
13
+ // A wide cosine pool so a blended multi-topic query ("X and Y") still pulls in
14
+ // the weaker topic's chunks before reranking — for most small KBs this is
15
+ // effectively "rerank everything", which is the most robust option.
16
+ export const RETRIEVE_TOP_K = 120; // candidates pulled by cosine similarity before rerank
17
+ export const CONTEXT_TOP_N = 12; // chunks handed to the chat model after rerank + diversification
18
+ export const PER_SOURCE_CAP = 5; // max chunks from any single document in the final context
19
+ export const QA_PRIORITY_BOOST = 0.15; // cosine-score bonus for prioritized Q&A pairs
20
+ export const EMBED_BATCH = 96; // max texts per embed request
21
+
22
+ export const CHAT_SYSTEM_PROMPT = [
23
+ 'You are Query Bot, a helpful knowledge-base assistant.',
24
+ 'Answer only from the supplied documents and custom Q&A context.',
25
+ 'Keep responses accurate, relevant, and conversational.',
26
+ 'If the context is insufficient or does not answer the question, say that clearly and ask for the missing document or detail.',
27
+ 'Do not invent facts, numbers, citations, policies, names, or conclusions that are not supported by the context.',
28
+ 'When useful, mention the source context briefly in natural language.',
29
+ ].join(' ');
lib/cohere.ts ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CohereClientV2, Cohere } from 'cohere-ai';
2
+ import {
3
+ EMBED_MODEL,
4
+ RERANK_MODEL,
5
+ CHAT_MODEL,
6
+ CHAT_SYSTEM_PROMPT,
7
+ EMBED_BATCH,
8
+ } from './cohere-config';
9
+
10
+ // The only module that talks to Cohere. The API key is read server-side only
11
+ // and must never be imported into a client component.
12
+
13
+ let client: CohereClientV2 | null = null;
14
+
15
+ function getClient(): CohereClientV2 {
16
+ const token = process.env.COHERE_API_KEY;
17
+ if (!token) {
18
+ throw new Error(
19
+ 'COHERE_API_KEY is not set. Add it to .env.local (see .env.example).'
20
+ );
21
+ }
22
+ if (!client) {
23
+ client = new CohereClientV2({ token });
24
+ }
25
+ return client;
26
+ }
27
+
28
+ /** Embed documents (for the knowledge base) in batches. Returns one float vector per text. */
29
+ export async function embedDocuments(texts: string[]): Promise<number[][]> {
30
+ if (texts.length === 0) return [];
31
+ const co = getClient();
32
+ const out: number[][] = [];
33
+ for (let i = 0; i < texts.length; i += EMBED_BATCH) {
34
+ const batch = texts.slice(i, i + EMBED_BATCH);
35
+ const res = await co.embed({
36
+ model: EMBED_MODEL,
37
+ inputType: 'search_document',
38
+ embeddingTypes: ['float'],
39
+ texts: batch,
40
+ });
41
+ const floats = res.embeddings.float ?? [];
42
+ out.push(...floats);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** Embed a single user query for retrieval. */
48
+ export async function embedQuery(text: string): Promise<number[]> {
49
+ const co = getClient();
50
+ const res = await co.embed({
51
+ model: EMBED_MODEL,
52
+ inputType: 'search_query',
53
+ embeddingTypes: ['float'],
54
+ texts: [text],
55
+ });
56
+ const vec = res.embeddings.float?.[0];
57
+ if (!vec) throw new Error('Cohere returned no query embedding.');
58
+ return vec;
59
+ }
60
+
61
+ /**
62
+ * Rerank candidate document texts against the query. Returns index + relevance,
63
+ * best first. By default returns *all* candidates ranked, so the caller can
64
+ * apply its own selection (e.g. per-source diversification) on the full pool.
65
+ */
66
+ export async function rerank(
67
+ query: string,
68
+ documents: string[],
69
+ topN: number = documents.length
70
+ ): Promise<{ index: number; relevanceScore: number }[]> {
71
+ if (documents.length === 0) return [];
72
+ const co = getClient();
73
+ const res = await co.rerank({
74
+ model: RERANK_MODEL,
75
+ query,
76
+ documents,
77
+ topN: Math.min(topN, documents.length),
78
+ });
79
+ return res.results.map((r) => ({
80
+ index: r.index,
81
+ relevanceScore: r.relevanceScore,
82
+ }));
83
+ }
84
+
85
+ /** Grounded chat: pass retrieved documents and get an answer plus fine-grained citations. */
86
+ export async function chatWithDocuments(
87
+ query: string,
88
+ documents: Cohere.Document[]
89
+ ): Promise<{ text: string; citations: Cohere.Citation[] }> {
90
+ const co = getClient();
91
+ const res = await co.chat({
92
+ model: CHAT_MODEL,
93
+ messages: [
94
+ { role: 'system', content: CHAT_SYSTEM_PROMPT },
95
+ { role: 'user', content: query },
96
+ ],
97
+ documents,
98
+ });
99
+
100
+ const text =
101
+ res.message.content
102
+ ?.filter((c): c is { type: 'text'; text: string } => c.type === 'text')
103
+ .map((c) => c.text)
104
+ .join('') ?? '';
105
+
106
+ return { text, citations: res.message.citations ?? [] };
107
+ }
108
+
109
+ /** Cosine similarity between two equal-length vectors. */
110
+ export function cosineSimilarity(a: number[], b: number[]): number {
111
+ let dot = 0;
112
+ let normA = 0;
113
+ let normB = 0;
114
+ const len = Math.min(a.length, b.length);
115
+ for (let i = 0; i < len; i++) {
116
+ dot += a[i] * b[i];
117
+ normA += a[i] * a[i];
118
+ normB += b[i] * b[i];
119
+ }
120
+ if (normA === 0 || normB === 0) return 0;
121
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
122
+ }
lib/file-meta.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { KBFile } from './kb-data';
2
+
3
+ export type FileType = KBFile['type'];
4
+
5
+ const EXT_TO_TYPE: Record<string, FileType> = {
6
+ pdf: 'PDF',
7
+ doc: 'DOCX',
8
+ docx: 'DOCX',
9
+ xls: 'EXCEL',
10
+ xlsx: 'EXCEL',
11
+ csv: 'CSV',
12
+ };
13
+
14
+ export const ACCEPTED_EXTENSIONS = Object.keys(EXT_TO_TYPE);
15
+
16
+ /** Map a filename to one of the KBFile.type labels. Returns null if unsupported. */
17
+ export function getFileType(fileName: string): FileType | null {
18
+ const ext = fileName.split('.').pop()?.toLowerCase() ?? '';
19
+ return EXT_TO_TYPE[ext] ?? null;
20
+ }
21
+
22
+ /** Human-readable size string matching the original FileUploadPanel formatting. */
23
+ export function formatSize(bytes: number): string {
24
+ const mb = bytes / (1024 * 1024);
25
+ return mb < 1 ? `${Math.round(bytes / 1024)} KB` : `${mb.toFixed(1)} MB`;
26
+ }
lib/kb-data.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Shared types for the knowledge base UI. The source of truth lives server-side
2
+ // (see lib/kb-store.ts) and is accessed by the client through the /api routes.
3
+
4
+ export interface KBFile {
5
+ id: string;
6
+ name: string;
7
+ type: 'PDF' | 'DOCX' | 'EXCEL' | 'CSV';
8
+ size: string;
9
+ status: 'Processing' | 'Ready' | 'Failed';
10
+ uploadedAt: string;
11
+ }
12
+
13
+ export interface KBPair {
14
+ id: string;
15
+ question: string;
16
+ answer: string;
17
+ category: string;
18
+ prioritize: boolean;
19
+ }
20
+
21
+ export interface ChatMessage {
22
+ id: string;
23
+ sender: 'user' | 'ai';
24
+ text: string;
25
+ timestamp: string;
26
+ sources?: Array<{ name: string; type: string }>;
27
+ }
lib/kb-store.ts ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { KBFile, KBPair } from './kb-data';
4
+
5
+ // Server-side source of truth for the knowledge base.
6
+ // In Vercel production this is persisted as a private Vercel Blob object.
7
+ // Local development without Blob credentials falls back to data/kb.json.
8
+
9
+ const DATA_DIR = path.join(process.cwd(), 'data');
10
+ const STORE_PATH = path.join(DATA_DIR, 'kb.json');
11
+ const TMP_PATH = path.join(DATA_DIR, 'kb.json.tmp');
12
+ const BLOB_STORE_PATH = 'query-bot/kb.json';
13
+
14
+ export interface Chunk {
15
+ id: string;
16
+ text: string;
17
+ embedding: number[];
18
+ }
19
+
20
+ export interface KBFileRecord {
21
+ id: string;
22
+ name: string;
23
+ type: KBFile['type'];
24
+ size: string;
25
+ status: KBFile['status'];
26
+ uploadedAt: string;
27
+ error?: string;
28
+ chunks: Chunk[];
29
+ }
30
+
31
+ export interface QARecord extends KBPair {
32
+ embedding: number[];
33
+ }
34
+
35
+ export interface KBStore {
36
+ version: 1;
37
+ files: KBFileRecord[];
38
+ qa: QARecord[];
39
+ }
40
+
41
+ // Uniform retrieval candidate produced from both files and Q&A pairs.
42
+ export interface RetrievalCandidate {
43
+ kind: 'file' | 'qa';
44
+ sourceName: string;
45
+ sourceType: string;
46
+ text: string;
47
+ embedding: number[];
48
+ prioritize: boolean;
49
+ }
50
+
51
+ const EMPTY_STORE: KBStore = { version: 1, files: [], qa: [] };
52
+
53
+ function hasBlobConfig(): boolean {
54
+ return Boolean(
55
+ process.env.BLOB_READ_WRITE_TOKEN ||
56
+ (process.env.VERCEL_OIDC_TOKEN && process.env.BLOB_STORE_ID)
57
+ );
58
+ }
59
+
60
+ function shouldUseBlob(): boolean {
61
+ if (hasBlobConfig()) return true;
62
+ return process.env.VERCEL === '1';
63
+ }
64
+
65
+ function normalizeStore(parsed: Partial<KBStore>): KBStore {
66
+ return {
67
+ version: 1,
68
+ files: parsed.files ?? [],
69
+ qa: parsed.qa ?? [],
70
+ };
71
+ }
72
+
73
+ // --- In-process write lock -------------------------------------------------
74
+ // Chains every read-modify-write so concurrent route invocations in the same
75
+ // Node process don't interleave and clobber each other. Cross-instance writes
76
+ // still need a database/vector store if this grows beyond a small admin tool.
77
+ let queue: Promise<unknown> = Promise.resolve();
78
+
79
+ function withLock<T>(fn: () => Promise<T>): Promise<T> {
80
+ const run = queue.then(fn, fn);
81
+ // Keep the chain alive even if a step rejects.
82
+ queue = run.then(
83
+ () => undefined,
84
+ () => undefined
85
+ );
86
+ return run;
87
+ }
88
+
89
+ async function ensureDataDir(): Promise<void> {
90
+ await fs.mkdir(DATA_DIR, { recursive: true });
91
+ }
92
+
93
+ export async function readStore(): Promise<KBStore> {
94
+ if (shouldUseBlob()) {
95
+ return readBlobStore();
96
+ }
97
+
98
+ return readLocalStore();
99
+ }
100
+
101
+ async function readBlobStore(): Promise<KBStore> {
102
+ if (!hasBlobConfig()) {
103
+ throw new Error('Vercel Blob is not configured. Add BLOB_READ_WRITE_TOKEN in Vercel.');
104
+ }
105
+
106
+ try {
107
+ const { get } = await import('@vercel/blob');
108
+ const blob = await get(BLOB_STORE_PATH, { access: 'private', useCache: false });
109
+ if (!blob || blob.statusCode !== 200) {
110
+ return process.env.VERCEL === '1' ? { ...EMPTY_STORE } : readLocalStore();
111
+ }
112
+
113
+ const raw = await new Response(blob.stream).text();
114
+ return normalizeStore(JSON.parse(raw) as Partial<KBStore>);
115
+ } catch (err) {
116
+ if (err instanceof Error && err.name === 'BlobNotFoundError') {
117
+ return process.env.VERCEL === '1' ? { ...EMPTY_STORE } : readLocalStore();
118
+ }
119
+ if (err instanceof SyntaxError) {
120
+ console.error('Failed to parse Blob kb.json, starting empty:', err);
121
+ return { ...EMPTY_STORE };
122
+ }
123
+ throw err;
124
+ }
125
+ }
126
+
127
+ async function readLocalStore(): Promise<KBStore> {
128
+ try {
129
+ const raw = await fs.readFile(STORE_PATH, 'utf8');
130
+ const parsed = JSON.parse(raw) as Partial<KBStore>;
131
+ return normalizeStore(parsed);
132
+ } catch (err) {
133
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
134
+ return { ...EMPTY_STORE };
135
+ }
136
+ // Corrupt JSON or other read error: fail safe to an empty store rather than crash.
137
+ console.error('Failed to read kb.json, starting empty:', err);
138
+ return { ...EMPTY_STORE };
139
+ }
140
+ }
141
+
142
+ async function writeStore(store: KBStore): Promise<void> {
143
+ if (shouldUseBlob()) {
144
+ if (!hasBlobConfig()) {
145
+ throw new Error('Vercel Blob is not configured. Add BLOB_READ_WRITE_TOKEN in Vercel.');
146
+ }
147
+
148
+ const { put } = await import('@vercel/blob');
149
+ await put(BLOB_STORE_PATH, JSON.stringify(store), {
150
+ access: 'private',
151
+ addRandomSuffix: false,
152
+ allowOverwrite: true,
153
+ cacheControlMaxAge: 60,
154
+ contentType: 'application/json',
155
+ });
156
+ return;
157
+ }
158
+
159
+ await ensureDataDir();
160
+ // Atomic write: write to a temp file, then rename over the target.
161
+ await fs.writeFile(TMP_PATH, JSON.stringify(store), 'utf8');
162
+ await fs.rename(TMP_PATH, STORE_PATH);
163
+ }
164
+
165
+ // --- Public projections (strip embeddings/chunks before sending to client) --
166
+
167
+ export function toPublicFile(rec: KBFileRecord): KBFile {
168
+ return {
169
+ id: rec.id,
170
+ name: rec.name,
171
+ type: rec.type,
172
+ size: rec.size,
173
+ status: rec.status,
174
+ uploadedAt: rec.uploadedAt,
175
+ };
176
+ }
177
+
178
+ export function toPublicQA(rec: QARecord): KBPair {
179
+ return {
180
+ id: rec.id,
181
+ question: rec.question,
182
+ answer: rec.answer,
183
+ category: rec.category,
184
+ prioritize: rec.prioritize,
185
+ };
186
+ }
187
+
188
+ // --- File record mutations -------------------------------------------------
189
+
190
+ export function addFileRecord(rec: KBFileRecord): Promise<void> {
191
+ return withLock(async () => {
192
+ const store = await readStore();
193
+ store.files.push(rec);
194
+ await writeStore(store);
195
+ });
196
+ }
197
+
198
+ export function updateFileRecord(
199
+ id: string,
200
+ patch: Partial<KBFileRecord>
201
+ ): Promise<KBFileRecord | null> {
202
+ return withLock(async () => {
203
+ const store = await readStore();
204
+ const idx = store.files.findIndex((f) => f.id === id);
205
+ if (idx === -1) return null;
206
+ store.files[idx] = { ...store.files[idx], ...patch };
207
+ await writeStore(store);
208
+ return store.files[idx];
209
+ });
210
+ }
211
+
212
+ export function deleteFileRecord(id: string): Promise<boolean> {
213
+ return withLock(async () => {
214
+ const store = await readStore();
215
+ const before = store.files.length;
216
+ store.files = store.files.filter((f) => f.id !== id);
217
+ if (store.files.length === before) return false;
218
+ await writeStore(store);
219
+ return true;
220
+ });
221
+ }
222
+
223
+ // --- Q&A mutations ---------------------------------------------------------
224
+
225
+ export function addQA(rec: QARecord): Promise<void> {
226
+ return withLock(async () => {
227
+ const store = await readStore();
228
+ store.qa.push(rec);
229
+ await writeStore(store);
230
+ });
231
+ }
232
+
233
+ export function updateQA(
234
+ id: string,
235
+ patch: Partial<QARecord>
236
+ ): Promise<QARecord | null> {
237
+ return withLock(async () => {
238
+ const store = await readStore();
239
+ const idx = store.qa.findIndex((q) => q.id === id);
240
+ if (idx === -1) return null;
241
+ store.qa[idx] = { ...store.qa[idx], ...patch };
242
+ await writeStore(store);
243
+ return store.qa[idx];
244
+ });
245
+ }
246
+
247
+ export function deleteQA(id: string): Promise<boolean> {
248
+ return withLock(async () => {
249
+ const store = await readStore();
250
+ const before = store.qa.length;
251
+ store.qa = store.qa.filter((q) => q.id !== id);
252
+ if (store.qa.length === before) return false;
253
+ await writeStore(store);
254
+ return true;
255
+ });
256
+ }
257
+
258
+ // --- Retrieval -------------------------------------------------------------
259
+
260
+ /** Flatten every Ready file's chunks plus every Q&A pair into uniform candidates. */
261
+ export async function getAllChunksWithMeta(): Promise<RetrievalCandidate[]> {
262
+ const store = await readStore();
263
+ const candidates: RetrievalCandidate[] = [];
264
+
265
+ for (const file of store.files) {
266
+ if (file.status !== 'Ready') continue;
267
+ for (const chunk of file.chunks) {
268
+ candidates.push({
269
+ kind: 'file',
270
+ sourceName: file.name,
271
+ sourceType: file.type,
272
+ text: chunk.text,
273
+ embedding: chunk.embedding,
274
+ prioritize: false,
275
+ });
276
+ }
277
+ }
278
+
279
+ for (const qa of store.qa) {
280
+ candidates.push({
281
+ kind: 'qa',
282
+ sourceName: `Custom Q&A: ${qa.category}`,
283
+ sourceType: 'Q&A',
284
+ text: `Q: ${qa.question}\nA: ${qa.answer}`,
285
+ embedding: qa.embedding,
286
+ prioritize: qa.prioritize,
287
+ });
288
+ }
289
+
290
+ return candidates;
291
+ }
lib/parsers.ts ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pdf from 'pdf-parse';
2
+ import mammoth from 'mammoth';
3
+ import * as XLSX from 'xlsx';
4
+ import { CHUNK_WORDS, CHUNK_OVERLAP_WORDS } from './cohere-config';
5
+ import type { FileType } from './file-meta';
6
+
7
+ export { ACCEPTED_EXTENSIONS, formatSize, getFileType } from './file-meta';
8
+
9
+ // Document parsing (server-side only; these libraries require the Node runtime).
10
+
11
+ async function extractPdf(buffer: Buffer): Promise<string> {
12
+ const data = await pdf(buffer);
13
+ return data.text;
14
+ }
15
+
16
+ async function extractDocx(buffer: Buffer): Promise<string> {
17
+ const { value } = await mammoth.extractRawText({ buffer });
18
+ return value;
19
+ }
20
+
21
+ function extractSpreadsheet(buffer: Buffer): string {
22
+ const wb = XLSX.read(buffer, { type: 'buffer' });
23
+ const blocks: string[] = [];
24
+ for (const sheetName of wb.SheetNames) {
25
+ const sheet = wb.Sheets[sheetName];
26
+ const csv = XLSX.utils.sheet_to_csv(sheet);
27
+ if (csv.trim()) {
28
+ blocks.push(`Sheet: ${sheetName}\n${csv}`);
29
+ }
30
+ }
31
+ return blocks.join('\n\n');
32
+ }
33
+
34
+ /** Extract plain text from an uploaded file buffer based on its type. */
35
+ export async function extractText(
36
+ buffer: Buffer,
37
+ type: FileType
38
+ ): Promise<string> {
39
+ switch (type) {
40
+ case 'PDF':
41
+ return extractPdf(buffer);
42
+ case 'DOCX':
43
+ return extractDocx(buffer);
44
+ case 'EXCEL':
45
+ case 'CSV':
46
+ return extractSpreadsheet(buffer);
47
+ default:
48
+ return '';
49
+ }
50
+ }
51
+
52
+ /** Split text into ~CHUNK_WORDS-word windows with CHUNK_OVERLAP_WORDS overlap. */
53
+ export function chunkText(text: string): string[] {
54
+ const words = text.split(/\s+/).filter(Boolean);
55
+ if (words.length === 0) return [];
56
+
57
+ const chunks: string[] = [];
58
+ const step = Math.max(1, CHUNK_WORDS - CHUNK_OVERLAP_WORDS);
59
+ for (let i = 0; i < words.length; i += step) {
60
+ const slice = words.slice(i, i + CHUNK_WORDS);
61
+ const chunk = slice.join(' ').trim();
62
+ if (chunk) chunks.push(chunk);
63
+ if (i + CHUNK_WORDS >= words.length) break;
64
+ }
65
+ return chunks;
66
+ }
lib/pdf-parse.d.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ declare module 'pdf-parse' {
2
+ interface PDFData {
3
+ /** Number of pages */
4
+ numpages: number;
5
+ /** Number of rendered pages */
6
+ numrender: number;
7
+ /** PDF info */
8
+ info: Record<string, unknown>;
9
+ /** PDF metadata */
10
+ metadata: unknown;
11
+ /** PDF.js version */
12
+ version: string;
13
+ /** All text content concatenated */
14
+ text: string;
15
+ }
16
+
17
+ function pdfParse(dataBuffer: Buffer, options?: Record<string, unknown>): Promise<PDFData>;
18
+ export = pdfParse;
19
+ }
lib/use-chat-session.ts ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { ChatMessage } from '@/lib/kb-data';
5
+
6
+ /**
7
+ * Shared chat session logic (messages + RAG request handling).
8
+ *
9
+ * Extracted so the same conversation engine drives both the dedicated
10
+ * `/chat` page and the inline chat on the homepage (`/`).
11
+ */
12
+ export function useChatSession() {
13
+ const [messages, setMessages] = React.useState<ChatMessage[]>([]);
14
+ const [isTyping, setIsTyping] = React.useState(false);
15
+
16
+ // Monotonic counter guarantees unique keys even within the same millisecond.
17
+ const idCounter = React.useRef(0);
18
+ const makeId = React.useCallback(
19
+ (role: 'user' | 'ai') => `m-${Date.now()}-${idCounter.current++}-${role}`,
20
+ []
21
+ );
22
+
23
+ const sendMessage = React.useCallback(
24
+ async (textToSend: string) => {
25
+ if (!textToSend.trim()) return;
26
+
27
+ const userMsg: ChatMessage = {
28
+ id: makeId('user'),
29
+ sender: 'user',
30
+ text: textToSend,
31
+ timestamp: new Date().toLocaleTimeString(),
32
+ };
33
+
34
+ setMessages((prev) => [...prev, userMsg]);
35
+ setIsTyping(true);
36
+
37
+ // Query the RAG pipeline: retrieve + rerank + grounded answer with citations.
38
+ try {
39
+ const res = await fetch('/api/chat', {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json' },
42
+ body: JSON.stringify({ query: textToSend }),
43
+ });
44
+
45
+ if (!res.ok) {
46
+ const err = await res.json().catch(() => ({}));
47
+ throw new Error(err.error || `Request failed (${res.status})`);
48
+ }
49
+
50
+ const data: { text: string; sources?: Array<{ name: string; type: string }> } =
51
+ await res.json();
52
+
53
+ const aiMsg: ChatMessage = {
54
+ id: makeId('ai'),
55
+ sender: 'ai',
56
+ text: data.text,
57
+ timestamp: new Date().toLocaleTimeString(),
58
+ sources: data.sources,
59
+ };
60
+
61
+ setMessages((prev) => [...prev, aiMsg]);
62
+ } catch {
63
+ const aiMsg: ChatMessage = {
64
+ id: makeId('ai'),
65
+ sender: 'ai',
66
+ text:
67
+ "Sorry — I ran into a problem reaching the knowledge base. Please make sure your Cohere API key is configured and try again.",
68
+ timestamp: new Date().toLocaleTimeString(),
69
+ };
70
+ setMessages((prev) => [...prev, aiMsg]);
71
+ } finally {
72
+ setIsTyping(false);
73
+ }
74
+ },
75
+ [makeId]
76
+ );
77
+
78
+ const reset = React.useCallback(() => {
79
+ setMessages([]);
80
+ setIsTyping(false);
81
+ }, []);
82
+
83
+ return { messages, isTyping, sendMessage, reset };
84
+ }
next.config.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ // Emit a self-contained server bundle (.next/standalone) so the Docker
5
+ // image used by the Hugging Face Space stays small and runs with `node server.js`.
6
+ output: 'standalone',
7
+ // Pin the workspace root to this project. A stray lockfile higher up the
8
+ // drive (E:\pnpm-lock.yaml) otherwise makes Next infer E:\ as the root and
9
+ // nest the standalone output under a query-bot/ subdir, breaking the image.
10
+ outputFileTracingRoot: __dirname,
11
+ turbopack: {
12
+ root: __dirname,
13
+ },
14
+ transpilePackages: ['motion'],
15
+ // Keep these Node-only parsers/SDK out of the server bundle so their
16
+ // dynamic requires (pdf.js worker, native bits) resolve at runtime.
17
+ serverExternalPackages: ['pdf-parse', 'mammoth', 'xlsx', 'cohere-ai'],
18
+ // Allow the brand file-type icons served from icons8 to flow through
19
+ // the next/image optimizer. The query string carries the icon id, so
20
+ // `search` is omitted to permit it.
21
+ images: {
22
+ remotePatterns: [
23
+ {
24
+ protocol: 'https',
25
+ hostname: 'img.icons8.com',
26
+ pathname: '/**',
27
+ },
28
+ ],
29
+ },
30
+ };
31
+
32
+ export default nextConfig;
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "query-bot",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "eslint"
10
+ },
11
+ "dependencies": {
12
+ "@vercel/blob": "^2.4.0",
13
+ "cohere-ai": "^8.0.0",
14
+ "gsap": "^3.15.0",
15
+ "mammoth": "^1.12.0",
16
+ "motion": "^12.40.0",
17
+ "next": "16.2.6",
18
+ "pdf-parse": "1.1.1",
19
+ "react": "19.2.4",
20
+ "react-dom": "19.2.4",
21
+ "react-markdown": "^10.1.0",
22
+ "remark-gfm": "^4.0.1",
23
+ "xlsx": "^0.18.5"
24
+ },
25
+ "devDependencies": {
26
+ "@tailwindcss/postcss": "^4",
27
+ "@types/node": "^20",
28
+ "@types/react": "^19",
29
+ "@types/react-dom": "^19",
30
+ "eslint": "^9",
31
+ "eslint-config-next": "16.2.6",
32
+ "tailwindcss": "^4",
33
+ "typescript": "^5"
34
+ }
35
+ }
postcss.config.mjs ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
public/documents-bg.gif ADDED

Git LFS Details

  • SHA256: 60dd1334577a3f328766e09f89e943f93bd735a173e3b78f8acd069a714922cc
  • Pointer size: 132 Bytes
  • Size of remote file: 3.88 MB
public/file.svg ADDED
public/globe.svg ADDED
public/next.svg ADDED
public/vercel.svg ADDED
public/window.svg ADDED
tsconfig.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./*"]
23
+ }
24
+ },
25
+ "include": [
26
+ "next-env.d.ts",
27
+ "**/*.ts",
28
+ "**/*.tsx",
29
+ ".next/types/**/*.ts",
30
+ ".next/dev/types/**/*.ts",
31
+ "**/*.mts"
32
+ ],
33
+ "exclude": ["node_modules"]
34
+ }