Spaces:
Running
Running
Deploy Next.js Query Bot as Docker Space
Browse files- .dockerignore +13 -0
- .env.example +7 -0
- .gitattributes +1 -0
- .gitignore +45 -0
- Dockerfile +46 -0
- app/admin/page.tsx +248 -0
- app/api/chat/route.ts +150 -0
- app/api/documents/[id]/route.ts +16 -0
- app/api/documents/route.ts +156 -0
- app/api/documents/upload/route.ts +40 -0
- app/api/qa/[id]/route.ts +74 -0
- app/api/qa/route.ts +59 -0
- app/favicon.ico +0 -0
- app/globals.css +438 -0
- app/layout.tsx +33 -0
- app/page.tsx +12 -0
- app/template.tsx +21 -0
- components/AdminHeader.tsx +30 -0
- components/Badge.tsx +31 -0
- components/Button.tsx +43 -0
- components/FileTypeIcon.tsx +40 -0
- components/FileUploadPanel.tsx +346 -0
- components/FloatingChatInput.tsx +119 -0
- components/HeroSection.tsx +334 -0
- components/MessageBubble.tsx +200 -0
- components/Navbar.tsx +116 -0
- components/QABuilderPanel.tsx +191 -0
- components/QACard.tsx +101 -0
- components/UploadedFileCard.tsx +100 -0
- eslint.config.mjs +18 -0
- hooks/use-mobile.ts +21 -0
- lib/cohere-config.ts +29 -0
- lib/cohere.ts +122 -0
- lib/file-meta.ts +26 -0
- lib/kb-data.ts +27 -0
- lib/kb-store.ts +291 -0
- lib/parsers.ts +66 -0
- lib/pdf-parse.d.ts +19 -0
- lib/use-chat-session.ts +84 -0
- next.config.ts +32 -0
- package-lock.json +0 -0
- package.json +35 -0
- postcss.config.mjs +7 -0
- public/documents-bg.gif +3 -0
- public/file.svg +1 -0
- public/globe.svg +1 -0
- public/next.svg +1 -0
- public/vercel.svg +1 -0
- public/window.svg +1 -0
- tsconfig.json +34 -0
.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 & 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&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
|
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 |
+
}
|