added chatinterface and fastapi endpoint
Browse files- .gitignore +31 -0
- api.py +183 -0
- frontend/.gitignore +41 -0
- frontend/README.md +36 -0
- frontend/app/favicon.ico +0 -0
- frontend/app/globals.css +141 -0
- frontend/app/icons/github_dark.svg +1 -0
- frontend/app/layout.tsx +38 -0
- frontend/app/learn-more/page.tsx +5 -0
- frontend/app/page.tsx +110 -0
- frontend/app/try-it-out/page.tsx +42 -0
- frontend/components.json +25 -0
- frontend/components/AIAssistantUI.jsx +409 -0
- frontend/components/Aurora.tsx +209 -0
- frontend/components/ChatPane.jsx +199 -0
- frontend/components/Composer.jsx +149 -0
- frontend/components/ConversationRow.jsx +131 -0
- frontend/components/CreateFolderModal.jsx +90 -0
- frontend/components/CreateTemplateModal.jsx +134 -0
- frontend/components/FolderRow.jsx +147 -0
- frontend/components/GhostIconButton.jsx +13 -0
- frontend/components/GradientText.tsx +91 -0
- frontend/components/Header.tsx +38 -0
- frontend/components/Header_Chatbot.jsx +52 -0
- frontend/components/Message.jsx +33 -0
- frontend/components/SearchModal.jsx +176 -0
- frontend/components/SettingsPopover.jsx +50 -0
- frontend/components/Sidebar.jsx +396 -0
- frontend/components/SidebarSection.jsx +38 -0
- frontend/components/TemplateRow.jsx +124 -0
- frontend/components/ThemeToggle.jsx +43 -0
- frontend/components/mockData.js +117 -0
- frontend/components/ui/github-button.tsx +296 -0
- frontend/components/ui/popover.tsx +31 -0
- frontend/components/ui/sidebar.tsx +188 -0
- frontend/components/utils.js +30 -0
- frontend/eslint.config.mjs +18 -0
- frontend/lib/utils.ts +6 -0
- frontend/next.config.ts +14 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +38 -0
- frontend/postcss.config.mjs +7 -0
- frontend/tsconfig.json +44 -0
- query_only.py +196 -0
- requirements.txt +94 -0
- retriever/retriever.py +27 -0
- startup.txt +14 -0
.gitignore
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python bytecode
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# Virtual environments
|
| 7 |
+
.venv/
|
| 8 |
+
venv/
|
| 9 |
+
env/
|
| 10 |
+
ENV/
|
| 11 |
+
|
| 12 |
+
# Environment and local secrets
|
| 13 |
+
.env
|
| 14 |
+
.env.*
|
| 15 |
+
!.env.example
|
| 16 |
+
|
| 17 |
+
# Build and packaging artifacts
|
| 18 |
+
build/
|
| 19 |
+
dist/
|
| 20 |
+
*.egg-info/
|
| 21 |
+
.eggs/
|
| 22 |
+
|
| 23 |
+
# Caches and tooling
|
| 24 |
+
.pytest_cache/
|
| 25 |
+
.mypy_cache/
|
| 26 |
+
.ruff_cache/
|
| 27 |
+
.ipynb_checkpoints/
|
| 28 |
+
|
| 29 |
+
# IDE/editor
|
| 30 |
+
.vscode/
|
| 31 |
+
.idea/
|
api.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
from fastapi import FastAPI, HTTPException
|
| 7 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
+
from pydantic import BaseModel, Field
|
| 9 |
+
|
| 10 |
+
from vector_db import get_pinecone_index
|
| 11 |
+
from retriever.retriever import HybridRetriever
|
| 12 |
+
from retriever.generator import RAGGenerator
|
| 13 |
+
from retriever.processor import ChunkProcessor
|
| 14 |
+
|
| 15 |
+
from models.llama_3_8b import Llama3_8B
|
| 16 |
+
from models.mistral_7b import Mistral_7b
|
| 17 |
+
from models.qwen_2_5 import Qwen2_5
|
| 18 |
+
from models.deepseek_v3 import DeepSeek_V3
|
| 19 |
+
from models.tiny_aya import TinyAya
|
| 20 |
+
|
| 21 |
+
# Reuse the same query-only helper for loading BM25 corpus from Pinecone metadata.
|
| 22 |
+
from query_only import _load_chunks_from_pinecone
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class PredictRequest(BaseModel):
|
| 26 |
+
query: str = Field(..., min_length=1, description="User query text")
|
| 27 |
+
model: str = Field(default="Llama-3-8B", description="Model name key")
|
| 28 |
+
top_k: int = Field(default=10, ge=1, le=50)
|
| 29 |
+
final_k: int = Field(default=5, ge=1, le=20)
|
| 30 |
+
mode: str = Field(default="hybrid", description="semantic | bm25 | hybrid")
|
| 31 |
+
rerank_strategy: str = Field(default="cross-encoder", description="cross-encoder | rrf | none")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class PredictResponse(BaseModel):
|
| 35 |
+
model: str
|
| 36 |
+
answer: str
|
| 37 |
+
contexts: list[str]
|
| 38 |
+
metrics: dict[str, float]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
app = FastAPI(title="RAG-AS3 API", version="0.1.0")
|
| 42 |
+
|
| 43 |
+
app.add_middleware(
|
| 44 |
+
CORSMiddleware,
|
| 45 |
+
allow_origins=["*"],
|
| 46 |
+
allow_credentials=True,
|
| 47 |
+
allow_methods=["*"],
|
| 48 |
+
allow_headers=["*"],
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
state: dict[str, Any] = {}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _build_models(hf_token: str) -> dict[str, Any]:
|
| 56 |
+
return {
|
| 57 |
+
"Llama-3-8B": Llama3_8B(token=hf_token),
|
| 58 |
+
"Mistral-7B": Mistral_7b(token=hf_token),
|
| 59 |
+
"Qwen-2.5": Qwen2_5(token=hf_token),
|
| 60 |
+
"DeepSeek-V3": DeepSeek_V3(token=hf_token),
|
| 61 |
+
"TinyAya": TinyAya(token=hf_token),
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _resolve_model(name: str, models: dict[str, Any]) -> tuple[str, Any]:
|
| 66 |
+
aliases = {
|
| 67 |
+
"llama": "Llama-3-8B",
|
| 68 |
+
"mistral": "Mistral-7B",
|
| 69 |
+
"qwen": "Qwen-2.5",
|
| 70 |
+
"deepseek": "DeepSeek-V3",
|
| 71 |
+
"tinyaya": "TinyAya",
|
| 72 |
+
}
|
| 73 |
+
model_key = aliases.get(name.lower(), name)
|
| 74 |
+
if model_key not in models:
|
| 75 |
+
allowed = ", ".join(models.keys())
|
| 76 |
+
raise HTTPException(status_code=400, detail=f"Unknown model '{name}'. Use one of: {allowed}")
|
| 77 |
+
return model_key, models[model_key]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@app.on_event("startup")
|
| 81 |
+
def startup_event() -> None:
|
| 82 |
+
load_dotenv()
|
| 83 |
+
|
| 84 |
+
hf_token = os.getenv("HF_TOKEN")
|
| 85 |
+
pinecone_api_key = os.getenv("PINECONE_API_KEY")
|
| 86 |
+
|
| 87 |
+
if not pinecone_api_key:
|
| 88 |
+
raise RuntimeError("PINECONE_API_KEY not found in environment variables")
|
| 89 |
+
if not hf_token:
|
| 90 |
+
raise RuntimeError("HF_TOKEN not found in environment variables")
|
| 91 |
+
|
| 92 |
+
index_name = "arxiv-index"
|
| 93 |
+
embed_model_name = "all-MiniLM-L6-v2"
|
| 94 |
+
|
| 95 |
+
startup_start = time.perf_counter()
|
| 96 |
+
|
| 97 |
+
index = get_pinecone_index(
|
| 98 |
+
api_key=pinecone_api_key,
|
| 99 |
+
index_name=index_name,
|
| 100 |
+
dimension=384,
|
| 101 |
+
metric="cosine",
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
chunks_start = time.perf_counter()
|
| 105 |
+
final_chunks = _load_chunks_from_pinecone(index)
|
| 106 |
+
chunk_load_time = time.perf_counter() - chunks_start
|
| 107 |
+
|
| 108 |
+
if not final_chunks:
|
| 109 |
+
raise RuntimeError("No chunks found in Pinecone metadata. Run indexing once before API mode.")
|
| 110 |
+
|
| 111 |
+
proc = ChunkProcessor(model_name=embed_model_name, verbose=False)
|
| 112 |
+
retriever = HybridRetriever(final_chunks, proc.encoder, verbose=False)
|
| 113 |
+
rag_engine = RAGGenerator()
|
| 114 |
+
models = _build_models(hf_token)
|
| 115 |
+
|
| 116 |
+
state["index"] = index
|
| 117 |
+
state["retriever"] = retriever
|
| 118 |
+
state["rag_engine"] = rag_engine
|
| 119 |
+
state["models"] = models
|
| 120 |
+
|
| 121 |
+
startup_time = time.perf_counter() - startup_start
|
| 122 |
+
print(
|
| 123 |
+
f"API startup complete | chunks={len(final_chunks)} | "
|
| 124 |
+
f"chunk_load={chunk_load_time:.3f}s | total={startup_time:.3f}s"
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@app.get("/health")
|
| 129 |
+
def health() -> dict[str, str]:
|
| 130 |
+
ready = all(k in state for k in ("index", "retriever", "rag_engine", "models"))
|
| 131 |
+
return {"status": "ok" if ready else "starting"}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@app.post("/predict", response_model=PredictResponse)
|
| 135 |
+
def predict(payload: PredictRequest) -> PredictResponse:
|
| 136 |
+
if not state:
|
| 137 |
+
raise HTTPException(status_code=503, detail="Service not initialized yet")
|
| 138 |
+
|
| 139 |
+
query = payload.query.strip()
|
| 140 |
+
if not query:
|
| 141 |
+
raise HTTPException(status_code=400, detail="Query cannot be empty")
|
| 142 |
+
|
| 143 |
+
total_start = time.perf_counter()
|
| 144 |
+
|
| 145 |
+
retriever: HybridRetriever = state["retriever"]
|
| 146 |
+
index = state["index"]
|
| 147 |
+
rag_engine: RAGGenerator = state["rag_engine"]
|
| 148 |
+
models: dict[str, Any] = state["models"]
|
| 149 |
+
|
| 150 |
+
model_name, model_instance = _resolve_model(payload.model, models)
|
| 151 |
+
|
| 152 |
+
retrieval_start = time.perf_counter()
|
| 153 |
+
contexts = retriever.search(
|
| 154 |
+
query,
|
| 155 |
+
index,
|
| 156 |
+
mode=payload.mode,
|
| 157 |
+
rerank_strategy=payload.rerank_strategy,
|
| 158 |
+
use_mmr=True,
|
| 159 |
+
top_k=payload.top_k,
|
| 160 |
+
final_k=payload.final_k,
|
| 161 |
+
verbose=False,
|
| 162 |
+
)
|
| 163 |
+
retrieval_time = time.perf_counter() - retrieval_start
|
| 164 |
+
|
| 165 |
+
if not contexts:
|
| 166 |
+
raise HTTPException(status_code=404, detail="No context chunks retrieved for this query")
|
| 167 |
+
|
| 168 |
+
generation_start = time.perf_counter()
|
| 169 |
+
answer = rag_engine.get_answer(model_instance, query, contexts, temperature=0.1)
|
| 170 |
+
generation_time = time.perf_counter() - generation_start
|
| 171 |
+
|
| 172 |
+
total_time = time.perf_counter() - total_start
|
| 173 |
+
|
| 174 |
+
return PredictResponse(
|
| 175 |
+
model=model_name,
|
| 176 |
+
answer=answer,
|
| 177 |
+
contexts=contexts,
|
| 178 |
+
metrics={
|
| 179 |
+
"retrieval_s": round(retrieval_time, 3),
|
| 180 |
+
"generation_s": round(generation_time, 3),
|
| 181 |
+
"total_s": round(total_time, 3),
|
| 182 |
+
},
|
| 183 |
+
)
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
frontend/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
| 2 |
+
|
| 3 |
+
## Getting Started
|
| 4 |
+
|
| 5 |
+
First, run the development server:
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
npm run dev
|
| 9 |
+
# or
|
| 10 |
+
yarn dev
|
| 11 |
+
# or
|
| 12 |
+
pnpm dev
|
| 13 |
+
# or
|
| 14 |
+
bun dev
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
| 18 |
+
|
| 19 |
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
| 20 |
+
|
| 21 |
+
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
| 22 |
+
|
| 23 |
+
## Learn More
|
| 24 |
+
|
| 25 |
+
To learn more about Next.js, take a look at the following resources:
|
| 26 |
+
|
| 27 |
+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
| 28 |
+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| 29 |
+
|
| 30 |
+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
| 31 |
+
|
| 32 |
+
## Deploy on Vercel
|
| 33 |
+
|
| 34 |
+
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
| 35 |
+
|
| 36 |
+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
frontend/app/favicon.ico
ADDED
|
|
frontend/app/globals.css
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
@custom-variant dark (&:is(.dark *));
|
| 5 |
+
|
| 6 |
+
@theme inline {
|
| 7 |
+
--color-background: var(--background);
|
| 8 |
+
--color-foreground: var(--foreground);
|
| 9 |
+
--font-sans: var(--font-geist-sans);
|
| 10 |
+
--font-mono: var(--font-geist-mono);
|
| 11 |
+
--color-sidebar-ring: var(--sidebar-ring);
|
| 12 |
+
--color-sidebar-border: var(--sidebar-border);
|
| 13 |
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
| 14 |
+
--color-sidebar-accent: var(--sidebar-accent);
|
| 15 |
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
| 16 |
+
--color-sidebar-primary: var(--sidebar-primary);
|
| 17 |
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
| 18 |
+
--color-sidebar: var(--sidebar);
|
| 19 |
+
--color-chart-5: var(--chart-5);
|
| 20 |
+
--color-chart-4: var(--chart-4);
|
| 21 |
+
--color-chart-3: var(--chart-3);
|
| 22 |
+
--color-chart-2: var(--chart-2);
|
| 23 |
+
--color-chart-1: var(--chart-1);
|
| 24 |
+
--color-ring: var(--ring);
|
| 25 |
+
--color-input: var(--input);
|
| 26 |
+
--color-border: var(--border);
|
| 27 |
+
--color-destructive: var(--destructive);
|
| 28 |
+
--color-accent-foreground: var(--accent-foreground);
|
| 29 |
+
--color-accent: var(--accent);
|
| 30 |
+
--color-muted-foreground: var(--muted-foreground);
|
| 31 |
+
--color-muted: var(--muted);
|
| 32 |
+
--color-secondary-foreground: var(--secondary-foreground);
|
| 33 |
+
--color-secondary: var(--secondary);
|
| 34 |
+
--color-primary-foreground: var(--primary-foreground);
|
| 35 |
+
--color-primary: var(--primary);
|
| 36 |
+
--color-popover-foreground: var(--popover-foreground);
|
| 37 |
+
--color-popover: var(--popover);
|
| 38 |
+
--color-card-foreground: var(--card-foreground);
|
| 39 |
+
--color-card: var(--card);
|
| 40 |
+
--radius-sm: calc(var(--radius) - 4px);
|
| 41 |
+
--radius-md: calc(var(--radius) - 2px);
|
| 42 |
+
--radius-lg: var(--radius);
|
| 43 |
+
--radius-xl: calc(var(--radius) + 4px);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
:root {
|
| 47 |
+
--radius: 0.625rem;
|
| 48 |
+
--card: oklch(1 0 0);
|
| 49 |
+
--card-foreground: oklch(0.129 0.042 264.695);
|
| 50 |
+
--popover: oklch(1 0 0);
|
| 51 |
+
--popover-foreground: oklch(0.129 0.042 264.695);
|
| 52 |
+
--primary: oklch(0.208 0.042 265.755);
|
| 53 |
+
--primary-foreground: oklch(0.984 0.003 247.858);
|
| 54 |
+
--secondary: oklch(0.968 0.007 247.896);
|
| 55 |
+
--secondary-foreground: oklch(0.208 0.042 265.755);
|
| 56 |
+
--muted: oklch(0.968 0.007 247.896);
|
| 57 |
+
--muted-foreground: oklch(0.554 0.046 257.417);
|
| 58 |
+
--accent: oklch(0.968 0.007 247.896);
|
| 59 |
+
--accent-foreground: oklch(0.208 0.042 265.755);
|
| 60 |
+
--destructive: oklch(0.577 0.245 27.325);
|
| 61 |
+
--border: oklch(0.929 0.013 255.508);
|
| 62 |
+
--input: oklch(0.929 0.013 255.508);
|
| 63 |
+
--ring: oklch(0.704 0.04 256.788);
|
| 64 |
+
--chart-1: oklch(0.646 0.222 41.116);
|
| 65 |
+
--chart-2: oklch(0.6 0.118 184.704);
|
| 66 |
+
--chart-3: oklch(0.398 0.07 227.392);
|
| 67 |
+
--chart-4: oklch(0.828 0.189 84.429);
|
| 68 |
+
--chart-5: oklch(0.769 0.188 70.08);
|
| 69 |
+
--sidebar: oklch(0.984 0.003 247.858);
|
| 70 |
+
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
| 71 |
+
--sidebar-primary: oklch(0.208 0.042 265.755);
|
| 72 |
+
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
| 73 |
+
--sidebar-accent: oklch(0.968 0.007 247.896);
|
| 74 |
+
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
| 75 |
+
--sidebar-border: oklch(0.929 0.013 255.508);
|
| 76 |
+
--sidebar-ring: oklch(0.704 0.04 256.788);
|
| 77 |
+
--background: oklch(1 0 0);
|
| 78 |
+
--foreground: oklch(0.129 0.042 264.695);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.dark {
|
| 82 |
+
--background: oklch(0.129 0.042 264.695);
|
| 83 |
+
--foreground: oklch(0.984 0.003 247.858);
|
| 84 |
+
--card: oklch(0.208 0.042 265.755);
|
| 85 |
+
--card-foreground: oklch(0.984 0.003 247.858);
|
| 86 |
+
--popover: oklch(0.208 0.042 265.755);
|
| 87 |
+
--popover-foreground: oklch(0.984 0.003 247.858);
|
| 88 |
+
--primary: oklch(0.929 0.013 255.508);
|
| 89 |
+
--primary-foreground: oklch(0.208 0.042 265.755);
|
| 90 |
+
--secondary: oklch(0.279 0.041 260.031);
|
| 91 |
+
--secondary-foreground: oklch(0.984 0.003 247.858);
|
| 92 |
+
--muted: oklch(0.279 0.041 260.031);
|
| 93 |
+
--muted-foreground: oklch(0.704 0.04 256.788);
|
| 94 |
+
--accent: oklch(0.279 0.041 260.031);
|
| 95 |
+
--accent-foreground: oklch(0.984 0.003 247.858);
|
| 96 |
+
--destructive: oklch(0.704 0.191 22.216);
|
| 97 |
+
--border: oklch(1 0 0 / 10%);
|
| 98 |
+
--input: oklch(1 0 0 / 15%);
|
| 99 |
+
--ring: oklch(0.551 0.027 264.364);
|
| 100 |
+
--chart-1: oklch(0.488 0.243 264.376);
|
| 101 |
+
--chart-2: oklch(0.696 0.17 162.48);
|
| 102 |
+
--chart-3: oklch(0.769 0.188 70.08);
|
| 103 |
+
--chart-4: oklch(0.627 0.265 303.9);
|
| 104 |
+
--chart-5: oklch(0.645 0.246 16.439);
|
| 105 |
+
--sidebar: oklch(0.208 0.042 265.755);
|
| 106 |
+
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
| 107 |
+
--sidebar-primary: oklch(0.488 0.243 264.376);
|
| 108 |
+
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
| 109 |
+
--sidebar-accent: oklch(0.279 0.041 260.031);
|
| 110 |
+
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
| 111 |
+
--sidebar-border: oklch(1 0 0 / 10%);
|
| 112 |
+
--sidebar-ring: oklch(0.551 0.027 264.364);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
@layer base {
|
| 116 |
+
* {
|
| 117 |
+
@apply border-border outline-ring/50;
|
| 118 |
+
}
|
| 119 |
+
body {
|
| 120 |
+
@apply bg-background text-foreground;
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
/*
|
| 124 |
+
Added these lines to animate the gradient text.
|
| 125 |
+
*/
|
| 126 |
+
|
| 127 |
+
@keyframes gradient {
|
| 128 |
+
0% {
|
| 129 |
+
background-position: 0% 50%;
|
| 130 |
+
}
|
| 131 |
+
50% {
|
| 132 |
+
background-position: 100% 50%;
|
| 133 |
+
}
|
| 134 |
+
100% {
|
| 135 |
+
background-position: 0% 50%;
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.animate-gradient {
|
| 140 |
+
animation: gradient 8s linear infinite;
|
| 141 |
+
}
|
frontend/app/icons/github_dark.svg
ADDED
|
|
frontend/app/layout.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: "Fluora Care",
|
| 17 |
+
description: "An MLops Project",
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
export default function RootLayout({
|
| 21 |
+
children,
|
| 22 |
+
}: Readonly<{
|
| 23 |
+
children: React.ReactNode;
|
| 24 |
+
}>) {
|
| 25 |
+
return (
|
| 26 |
+
<html lang="en">
|
| 27 |
+
{/*
|
| 28 |
+
Background color: bg-gray-950 (dark gray shade instead of pure black)
|
| 29 |
+
*/}
|
| 30 |
+
<body
|
| 31 |
+
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-gray-950 text-white`}
|
| 32 |
+
suppressHydrationWarning
|
| 33 |
+
>
|
| 34 |
+
{children}
|
| 35 |
+
</body>
|
| 36 |
+
</html>
|
| 37 |
+
);
|
| 38 |
+
}
|
frontend/app/learn-more/page.tsx
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import AIAssistantUI from "@/components/AIAssistantUI";
|
| 2 |
+
// Hello
|
| 3 |
+
export default function Page() {
|
| 4 |
+
return <AIAssistantUI />
|
| 5 |
+
}
|
frontend/app/page.tsx
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// File: app/page.tsx
|
| 2 |
+
|
| 3 |
+
'use client';
|
| 4 |
+
|
| 5 |
+
import Aurora from '@/components/Aurora';
|
| 6 |
+
import GradientText from '@/components/GradientText';
|
| 7 |
+
import Link from 'next/link';
|
| 8 |
+
import Header from '@/components/Header';
|
| 9 |
+
import { motion } from "motion/react";
|
| 10 |
+
|
| 11 |
+
export default function Home() {
|
| 12 |
+
const buttonClass = "bg-black/30 backdrop-blur-md border border-green-500/30 text-green-400 font-semibold py-4 px-12 rounded-full shadow-[0_0_15px_rgba(74,222,128,0.1)] hover:shadow-[0_0_30px_rgba(74,222,128,0.4)] hover:border-green-400 hover:scale-105 transition-all duration-300 ease-out min-w-[200px] flex justify-center items-center tracking-wide";
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<main className="relative flex min-h-screen flex-col items-center justify-start overflow-hidden bg-black selection:bg-green-500/30">
|
| 16 |
+
|
| 17 |
+
{/* Header Wrapper */}
|
| 18 |
+
<div className="w-full z-50">
|
| 19 |
+
<Header />
|
| 20 |
+
</div>
|
| 21 |
+
|
| 22 |
+
{/* Background Layer */}
|
| 23 |
+
<div className="absolute inset-0 z-0">
|
| 24 |
+
<Aurora
|
| 25 |
+
colorStops={["#1E4620", "#1A5D3B", "#2A9D8F"]}
|
| 26 |
+
blend={0.5}
|
| 27 |
+
amplitude={1.0}
|
| 28 |
+
speed={0.5}
|
| 29 |
+
/>
|
| 30 |
+
{/* Futuristic Grid Overlay */}
|
| 31 |
+
<div className="absolute inset-0 bg-[linear-gradient(to_right,#222_1px,transparent_1px),linear-gradient(to_bottom,#222_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)] opacity-20 pointer-events-none"></div>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
{/* Main Content Container - Aligned with max-width */}
|
| 35 |
+
<div className="relative z-10 flex flex-col items-center justify-center w-full max-w-7xl mx-auto px-6 pt-32 md:pt-48 text-center">
|
| 36 |
+
|
| 37 |
+
<motion.div
|
| 38 |
+
initial={{ opacity: 0, y: 20 }}
|
| 39 |
+
animate={{ opacity: 1, y: 0 }}
|
| 40 |
+
transition={{ duration: 0.8, ease: "easeOut" }}
|
| 41 |
+
>
|
| 42 |
+
<h1 className="mb-8 tracking-tighter">
|
| 43 |
+
<GradientText
|
| 44 |
+
colors={["#40ffaa", "#4079ff", "#40ffaa", "#4079ff", "#40ffaa"]}
|
| 45 |
+
animationSpeed={8}
|
| 46 |
+
showBorder={false}
|
| 47 |
+
className="text-6xl font-bold md:text-8xl lg:text-9xl drop-shadow-2xl"
|
| 48 |
+
>
|
| 49 |
+
Fluora Care
|
| 50 |
+
</GradientText>
|
| 51 |
+
</h1>
|
| 52 |
+
</motion.div>
|
| 53 |
+
|
| 54 |
+
<motion.p
|
| 55 |
+
initial={{ opacity: 0, y: 20 }}
|
| 56 |
+
animate={{ opacity: 1, y: 0 }}
|
| 57 |
+
transition={{ duration: 0.8, delay: 0.2, ease: "easeOut" }}
|
| 58 |
+
className="max-w-3xl text-xl md:text-2xl text-neutral-300 mb-12 leading-relaxed font-light"
|
| 59 |
+
>
|
| 60 |
+
Experience the future of botanical intelligence.
|
| 61 |
+
<br className="hidden md:block" />
|
| 62 |
+
Advanced computer vision for precise plant health diagnostics.
|
| 63 |
+
</motion.p>
|
| 64 |
+
|
| 65 |
+
<motion.div
|
| 66 |
+
initial={{ opacity: 0, y: 20 }}
|
| 67 |
+
animate={{ opacity: 1, y: 0 }}
|
| 68 |
+
transition={{ duration: 0.8, delay: 0.4, ease: "easeOut" }}
|
| 69 |
+
className="flex flex-wrap items-center justify-center gap-6"
|
| 70 |
+
>
|
| 71 |
+
<Link href="/try-it-out" className={buttonClass}>
|
| 72 |
+
Learn More
|
| 73 |
+
</Link>
|
| 74 |
+
<Link href="/learn-more" className={buttonClass}>
|
| 75 |
+
Try It Out
|
| 76 |
+
</Link>
|
| 77 |
+
</motion.div>
|
| 78 |
+
|
| 79 |
+
{/* Stats / Features Footer to fill space */}
|
| 80 |
+
<motion.div
|
| 81 |
+
initial={{ opacity: 0 }}
|
| 82 |
+
animate={{ opacity: 1 }}
|
| 83 |
+
transition={{ duration: 1, delay: 0.8 }}
|
| 84 |
+
className="mt-32 grid grid-cols-1 md:grid-cols-3 gap-12 text-center border-t border-neutral-800/50 pt-10 w-full max-w-4xl"
|
| 85 |
+
>
|
| 86 |
+
<div className="flex flex-col items-center group cursor-default">
|
| 87 |
+
<span className="text-4xl font-bold text-[#40ffaa] mb-2 drop-shadow-[0_0_10px_rgba(64,255,170,0.5)]">99.86%</span>
|
| 88 |
+
<span className="text-sm text-neutral-400 uppercase tracking-widest font-medium">Accuracy</span>
|
| 89 |
+
</div>
|
| 90 |
+
<div className="flex flex-col items-center group cursor-default">
|
| 91 |
+
<span className="text-4xl font-bold text-white mb-2 drop-shadow-[0_0_10px_rgba(255,255,255,0.5)]">29 Diseases</span>
|
| 92 |
+
<span className="text-sm text-neutral-400 uppercase tracking-widest font-medium">Detection</span>
|
| 93 |
+
</div>
|
| 94 |
+
<div className="flex flex-col items-center group cursor-default">
|
| 95 |
+
<GradientText
|
| 96 |
+
colors={["#40ffaa", "#ef4444", "#166534", "#4079ff", "#40ffaa"]}
|
| 97 |
+
animationSpeed={16}
|
| 98 |
+
showBorder={false}
|
| 99 |
+
className="text-4xl font-bold mb-2 drop-shadow-[0_0_10px_rgba(64,121,255,0.5)] rounded-lg px-4 py-2"
|
| 100 |
+
>
|
| 101 |
+
RAG Powered
|
| 102 |
+
</GradientText>
|
| 103 |
+
<span className="text-sm text-neutral-400 uppercase tracking-widest font-medium">Chatbot</span>
|
| 104 |
+
</div>
|
| 105 |
+
</motion.div>
|
| 106 |
+
|
| 107 |
+
</div>
|
| 108 |
+
</main>
|
| 109 |
+
);
|
| 110 |
+
}
|
frontend/app/try-it-out/page.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import Header from "@/components/Header";
|
| 3 |
+
|
| 4 |
+
export default function LearnMorePage() {
|
| 5 |
+
return (
|
| 6 |
+
<div className="min-h-screen bg-black text-white flex flex-col items-center justify-start overflow-hidden">
|
| 7 |
+
<div className="w-full z-50">
|
| 8 |
+
<Header />
|
| 9 |
+
</div>
|
| 10 |
+
|
| 11 |
+
<div className="flex-1 flex flex-col items-center justify-center p-4 w-full max-w-4xl mt-20">
|
| 12 |
+
<div className="text-center space-y-8">
|
| 13 |
+
<h1 className="text-5xl md:text-7xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-green-400 to-blue-500">
|
| 14 |
+
Coming Soon
|
| 15 |
+
</h1>
|
| 16 |
+
<p className="text-xl text-neutral-400 max-w-2xl mx-auto">
|
| 17 |
+
We are building a comprehensive guide to our botanical intelligence system.
|
| 18 |
+
Check back soon for detailed documentation, case studies, and technical deep dives.
|
| 19 |
+
</p>
|
| 20 |
+
|
| 21 |
+
<div className="p-8 border border-neutral-800 rounded-2xl bg-neutral-900/50 backdrop-blur-sm">
|
| 22 |
+
<p className="text-sm text-neutral-500 uppercase tracking-widest mb-4">What to expect</p>
|
| 23 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-left">
|
| 24 |
+
<div className="p-4 bg-neutral-900 rounded-lg">
|
| 25 |
+
<h3 className="text-green-400 font-semibold mb-2">Architecture</h3>
|
| 26 |
+
<p className="text-neutral-400 text-sm">Deep dive into our RAG + CNN hybrid model structure.</p>
|
| 27 |
+
</div>
|
| 28 |
+
<div className="p-4 bg-neutral-900 rounded-lg">
|
| 29 |
+
<h3 className="text-blue-400 font-semibold mb-2">Dataset</h3>
|
| 30 |
+
<p className="text-neutral-400 text-sm">Exploration of the 50k+ image dataset used for training.</p>
|
| 31 |
+
</div>
|
| 32 |
+
<div className="p-4 bg-neutral-900 rounded-lg">
|
| 33 |
+
<h3 className="text-purple-400 font-semibold mb-2">API Docs</h3>
|
| 34 |
+
<p className="text-neutral-400 text-sm">Full reference for integrating Flora Care into your apps.</p>
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
);
|
| 42 |
+
}
|
frontend/components.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "new-york",
|
| 4 |
+
"rsc": true,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "",
|
| 8 |
+
"css": "app/globals.css",
|
| 9 |
+
"baseColor": "slate",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"iconLibrary": "lucide",
|
| 14 |
+
"aliases": {
|
| 15 |
+
"components": "@/components",
|
| 16 |
+
"utils": "@/lib/utils",
|
| 17 |
+
"ui": "@/components/ui",
|
| 18 |
+
"lib": "@/lib",
|
| 19 |
+
"hooks": "@/hooks"
|
| 20 |
+
},
|
| 21 |
+
"registries": {
|
| 22 |
+
"@reui": "https://reui.io/r/{name}.json",
|
| 23 |
+
"@aceternity": "https://ui.aceternity.com/registry/{name}.json"
|
| 24 |
+
}
|
| 25 |
+
}
|
frontend/components/AIAssistantUI.jsx
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import React, { useEffect, useMemo, useRef, useState } from "react"
|
| 4 |
+
import { Calendar, LayoutGrid, MoreHorizontal } from "lucide-react"
|
| 5 |
+
import Sidebar from "./Sidebar"
|
| 6 |
+
import Header from "./Header_Chatbot"
|
| 7 |
+
import ChatPane from "./ChatPane"
|
| 8 |
+
import GhostIconButton from "./GhostIconButton"
|
| 9 |
+
import ThemeToggle from "./ThemeToggle"
|
| 10 |
+
import { INITIAL_CONVERSATIONS, INITIAL_TEMPLATES, INITIAL_FOLDERS } from "./mockData"
|
| 11 |
+
|
| 12 |
+
const MODEL_OPTIONS = ["Llama-3-8B", "Mistral-7B", "Qwen-2.5", "DeepSeek-V3", "TinyAya"]
|
| 13 |
+
|
| 14 |
+
export default function AIAssistantUI() {
|
| 15 |
+
const [theme, setTheme] = useState(() => {
|
| 16 |
+
const saved = typeof window !== "undefined" && localStorage.getItem("theme")
|
| 17 |
+
if (saved) return saved
|
| 18 |
+
if (typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches)
|
| 19 |
+
return "dark"
|
| 20 |
+
return "light"
|
| 21 |
+
})
|
| 22 |
+
|
| 23 |
+
useEffect(() => {
|
| 24 |
+
try {
|
| 25 |
+
if (theme === "dark") document.documentElement.classList.add("dark")
|
| 26 |
+
else document.documentElement.classList.remove("dark")
|
| 27 |
+
document.documentElement.setAttribute("data-theme", theme)
|
| 28 |
+
document.documentElement.style.colorScheme = theme
|
| 29 |
+
localStorage.setItem("theme", theme)
|
| 30 |
+
} catch {}
|
| 31 |
+
}, [theme])
|
| 32 |
+
|
| 33 |
+
useEffect(() => {
|
| 34 |
+
try {
|
| 35 |
+
const media = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)")
|
| 36 |
+
if (!media) return
|
| 37 |
+
const listener = (e) => {
|
| 38 |
+
const saved = localStorage.getItem("theme")
|
| 39 |
+
if (!saved) setTheme(e.matches ? "dark" : "light")
|
| 40 |
+
}
|
| 41 |
+
media.addEventListener("change", listener)
|
| 42 |
+
return () => media.removeEventListener("change", listener)
|
| 43 |
+
} catch {}
|
| 44 |
+
}, [])
|
| 45 |
+
|
| 46 |
+
const [sidebarOpen, setSidebarOpen] = useState(false)
|
| 47 |
+
const [collapsed, setCollapsed] = useState(() => {
|
| 48 |
+
try {
|
| 49 |
+
const raw = localStorage.getItem("sidebar-collapsed")
|
| 50 |
+
return raw ? JSON.parse(raw) : { pinned: true, recent: false, folders: true, templates: true }
|
| 51 |
+
} catch {
|
| 52 |
+
return { pinned: true, recent: false, folders: true, templates: true }
|
| 53 |
+
}
|
| 54 |
+
})
|
| 55 |
+
useEffect(() => {
|
| 56 |
+
try {
|
| 57 |
+
localStorage.setItem("sidebar-collapsed", JSON.stringify(collapsed))
|
| 58 |
+
} catch {}
|
| 59 |
+
}, [collapsed])
|
| 60 |
+
|
| 61 |
+
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
| 62 |
+
try {
|
| 63 |
+
const saved = localStorage.getItem("sidebar-collapsed-state")
|
| 64 |
+
return saved ? JSON.parse(saved) : false
|
| 65 |
+
} catch {
|
| 66 |
+
return false
|
| 67 |
+
}
|
| 68 |
+
})
|
| 69 |
+
|
| 70 |
+
useEffect(() => {
|
| 71 |
+
try {
|
| 72 |
+
localStorage.setItem("sidebar-collapsed-state", JSON.stringify(sidebarCollapsed))
|
| 73 |
+
} catch {}
|
| 74 |
+
}, [sidebarCollapsed])
|
| 75 |
+
|
| 76 |
+
const [conversations, setConversations] = useState(INITIAL_CONVERSATIONS)
|
| 77 |
+
const [selectedId, setSelectedId] = useState(null)
|
| 78 |
+
const [templates, setTemplates] = useState(INITIAL_TEMPLATES)
|
| 79 |
+
const [folders, setFolders] = useState(INITIAL_FOLDERS)
|
| 80 |
+
|
| 81 |
+
const [query, setQuery] = useState("")
|
| 82 |
+
const searchRef = useRef(null)
|
| 83 |
+
const [selectedModel, setSelectedModel] = useState("Llama-3-8B")
|
| 84 |
+
|
| 85 |
+
const [isThinking, setIsThinking] = useState(false)
|
| 86 |
+
const [thinkingConvId, setThinkingConvId] = useState(null)
|
| 87 |
+
|
| 88 |
+
useEffect(() => {
|
| 89 |
+
const onKey = (e) => {
|
| 90 |
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "n") {
|
| 91 |
+
e.preventDefault()
|
| 92 |
+
createNewChat()
|
| 93 |
+
}
|
| 94 |
+
if (!e.metaKey && !e.ctrlKey && e.key === "/") {
|
| 95 |
+
const tag = document.activeElement?.tagName?.toLowerCase()
|
| 96 |
+
if (tag !== "input" && tag !== "textarea") {
|
| 97 |
+
e.preventDefault()
|
| 98 |
+
searchRef.current?.focus()
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
if (e.key === "Escape" && sidebarOpen) setSidebarOpen(false)
|
| 102 |
+
}
|
| 103 |
+
window.addEventListener("keydown", onKey)
|
| 104 |
+
return () => window.removeEventListener("keydown", onKey)
|
| 105 |
+
}, [sidebarOpen, conversations])
|
| 106 |
+
|
| 107 |
+
const initialized = useRef(false)
|
| 108 |
+
|
| 109 |
+
useEffect(() => {
|
| 110 |
+
if (!initialized.current && !selectedId) {
|
| 111 |
+
initialized.current = true
|
| 112 |
+
createNewChat()
|
| 113 |
+
}
|
| 114 |
+
}, [])
|
| 115 |
+
|
| 116 |
+
const filtered = useMemo(() => {
|
| 117 |
+
if (!query.trim()) return conversations
|
| 118 |
+
const q = query.toLowerCase()
|
| 119 |
+
return conversations.filter((c) => c.title.toLowerCase().includes(q) || c.preview.toLowerCase().includes(q))
|
| 120 |
+
}, [conversations, query])
|
| 121 |
+
|
| 122 |
+
const pinned = filtered.filter((c) => c.pinned).sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
|
| 123 |
+
|
| 124 |
+
const recent = filtered
|
| 125 |
+
.filter((c) => !c.pinned)
|
| 126 |
+
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
|
| 127 |
+
.slice(0, 10)
|
| 128 |
+
|
| 129 |
+
const folderCounts = React.useMemo(() => {
|
| 130 |
+
const map = Object.fromEntries(folders.map((f) => [f.name, 0]))
|
| 131 |
+
for (const c of conversations) if (map[c.folder] != null) map[c.folder] += 1
|
| 132 |
+
return map
|
| 133 |
+
}, [conversations, folders])
|
| 134 |
+
|
| 135 |
+
function togglePin(id) {
|
| 136 |
+
setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, pinned: !c.pinned } : c)))
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function deleteChat(id) {
|
| 140 |
+
setConversations((prev) => prev.filter((c) => c.id !== id))
|
| 141 |
+
if (selectedId === id) {
|
| 142 |
+
setSelectedId(null)
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
function renameChat(id, nextTitle) {
|
| 147 |
+
const trimmed = (nextTitle || "").trim()
|
| 148 |
+
if (!trimmed) return
|
| 149 |
+
|
| 150 |
+
setConversations((prev) =>
|
| 151 |
+
prev.map((c) =>
|
| 152 |
+
c.id === id
|
| 153 |
+
? {
|
| 154 |
+
...c,
|
| 155 |
+
title: trimmed,
|
| 156 |
+
updatedAt: new Date().toISOString(),
|
| 157 |
+
}
|
| 158 |
+
: c,
|
| 159 |
+
),
|
| 160 |
+
)
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
function createNewChat() {
|
| 164 |
+
const id = Math.random().toString(36).slice(2)
|
| 165 |
+
const item = {
|
| 166 |
+
id,
|
| 167 |
+
title: "New Chat",
|
| 168 |
+
updatedAt: new Date().toISOString(),
|
| 169 |
+
messageCount: 1,
|
| 170 |
+
preview: "Ask a question about your indexed knowledge base...",
|
| 171 |
+
pinned: false,
|
| 172 |
+
folder: "Work Projects",
|
| 173 |
+
messages: [
|
| 174 |
+
{
|
| 175 |
+
id: Math.random().toString(36).slice(2),
|
| 176 |
+
role: "assistant",
|
| 177 |
+
content: "Hello! Ask me anything about your indexed documents and I will retrieve context before answering.",
|
| 178 |
+
createdAt: new Date().toISOString(),
|
| 179 |
+
}
|
| 180 |
+
],
|
| 181 |
+
}
|
| 182 |
+
setConversations((prev) => [item, ...prev])
|
| 183 |
+
setSelectedId(id)
|
| 184 |
+
setSidebarOpen(false)
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
function createFolder() {
|
| 188 |
+
const name = prompt("Folder name")
|
| 189 |
+
if (!name) return
|
| 190 |
+
if (folders.some((f) => f.name.toLowerCase() === name.toLowerCase())) return alert("Folder already exists.")
|
| 191 |
+
setFolders((prev) => [...prev, { id: Math.random().toString(36).slice(2), name }])
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
async function sendMessage(convId, content) {
|
| 195 |
+
if (!content.trim()) return
|
| 196 |
+
const now = new Date().toISOString()
|
| 197 |
+
const userMsg = { id: Math.random().toString(36).slice(2), role: "user", content, createdAt: now }
|
| 198 |
+
|
| 199 |
+
setConversations((prev) =>
|
| 200 |
+
prev.map((c) => {
|
| 201 |
+
if (c.id !== convId) return c
|
| 202 |
+
const msgs = [...(c.messages || []), userMsg]
|
| 203 |
+
return {
|
| 204 |
+
...c,
|
| 205 |
+
messages: msgs,
|
| 206 |
+
updatedAt: now,
|
| 207 |
+
messageCount: msgs.length,
|
| 208 |
+
preview: content.slice(0, 80),
|
| 209 |
+
}
|
| 210 |
+
}),
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
setIsThinking(true)
|
| 214 |
+
setThinkingConvId(convId)
|
| 215 |
+
|
| 216 |
+
const currentConvId = convId
|
| 217 |
+
|
| 218 |
+
// Prefer same-origin proxy to avoid browser CORS/network issues in development.
|
| 219 |
+
const primaryUrl = "/api/proxy"
|
| 220 |
+
const fallbackUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8000"
|
| 221 |
+
|
| 222 |
+
const fetchOptions = {
|
| 223 |
+
method: "POST",
|
| 224 |
+
headers: { "Content-Type": "application/json" },
|
| 225 |
+
body: JSON.stringify({
|
| 226 |
+
query: content,
|
| 227 |
+
model: selectedModel,
|
| 228 |
+
}),
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
try {
|
| 232 |
+
let res
|
| 233 |
+
|
| 234 |
+
try {
|
| 235 |
+
res = await fetch(`${primaryUrl}/predict`, fetchOptions)
|
| 236 |
+
} catch {}
|
| 237 |
+
|
| 238 |
+
if (!res || !res.ok) {
|
| 239 |
+
// Retry direct backend URL if proxy is not reachable.
|
| 240 |
+
res = await fetch(`${fallbackUrl}/predict`, fetchOptions)
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
if (!res.ok) {
|
| 244 |
+
const details = await res.text().catch(() => "")
|
| 245 |
+
throw new Error(`Prediction failed (${res.status}) ${details}`.trim())
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
const data = await res.json()
|
| 249 |
+
|
| 250 |
+
setConversations((prev) =>
|
| 251 |
+
prev.map((c) => {
|
| 252 |
+
if (c.id !== currentConvId) return c
|
| 253 |
+
const asstMsg = {
|
| 254 |
+
id: Math.random().toString(36).slice(2),
|
| 255 |
+
role: "assistant",
|
| 256 |
+
content: data.answer || "Sorry, I encountered an error.",
|
| 257 |
+
createdAt: new Date().toISOString(),
|
| 258 |
+
}
|
| 259 |
+
const msgs = [...(c.messages || []), asstMsg]
|
| 260 |
+
return {
|
| 261 |
+
...c,
|
| 262 |
+
messages: msgs,
|
| 263 |
+
updatedAt: new Date().toISOString(),
|
| 264 |
+
messageCount: msgs.length,
|
| 265 |
+
preview: (asstMsg.content || "").slice(0, 80),
|
| 266 |
+
}
|
| 267 |
+
}),
|
| 268 |
+
)
|
| 269 |
+
} catch (err) {
|
| 270 |
+
console.error("predict request failed:", err)
|
| 271 |
+
setConversations((prev) =>
|
| 272 |
+
prev.map((c) => {
|
| 273 |
+
if (c.id !== currentConvId) return c
|
| 274 |
+
const errorMsg = {
|
| 275 |
+
id: Math.random().toString(36).slice(2),
|
| 276 |
+
role: "assistant",
|
| 277 |
+
content: "Sorry, I could not reach the backend. Start FastAPI and verify frontend .env.local URLs, then restart Next.js dev server.",
|
| 278 |
+
createdAt: new Date().toISOString(),
|
| 279 |
+
}
|
| 280 |
+
const msgs = [...(c.messages || []), errorMsg]
|
| 281 |
+
return {
|
| 282 |
+
...c,
|
| 283 |
+
messages: msgs,
|
| 284 |
+
updatedAt: new Date().toISOString(),
|
| 285 |
+
messageCount: msgs.length,
|
| 286 |
+
preview: errorMsg.content.slice(0, 80),
|
| 287 |
+
}
|
| 288 |
+
}),
|
| 289 |
+
)
|
| 290 |
+
} finally {
|
| 291 |
+
setIsThinking(false)
|
| 292 |
+
setThinkingConvId(null)
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
function editMessage(convId, messageId, newContent) {
|
| 297 |
+
const now = new Date().toISOString()
|
| 298 |
+
setConversations((prev) =>
|
| 299 |
+
prev.map((c) => {
|
| 300 |
+
if (c.id !== convId) return c
|
| 301 |
+
const msgs = (c.messages || []).map((m) =>
|
| 302 |
+
m.id === messageId ? { ...m, content: newContent, editedAt: now } : m,
|
| 303 |
+
)
|
| 304 |
+
return {
|
| 305 |
+
...c,
|
| 306 |
+
messages: msgs,
|
| 307 |
+
preview: msgs[msgs.length - 1]?.content?.slice(0, 80) || c.preview,
|
| 308 |
+
}
|
| 309 |
+
}),
|
| 310 |
+
)
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
function resendMessage(convId, messageId) {
|
| 314 |
+
const conv = conversations.find((c) => c.id === convId)
|
| 315 |
+
const msg = conv?.messages?.find((m) => m.id === messageId)
|
| 316 |
+
if (!msg) return
|
| 317 |
+
sendMessage(convId, msg.content)
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
function pauseThinking() {
|
| 321 |
+
setIsThinking(false)
|
| 322 |
+
setThinkingConvId(null)
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
function handleUseTemplate(template) {
|
| 326 |
+
// This will be passed down to the Composer component
|
| 327 |
+
// The Composer will handle inserting the template content
|
| 328 |
+
if (composerRef.current) {
|
| 329 |
+
composerRef.current.insertTemplate(template.content)
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
const composerRef = useRef(null)
|
| 334 |
+
|
| 335 |
+
const selected = conversations.find((c) => c.id === selectedId) || null
|
| 336 |
+
|
| 337 |
+
return (
|
| 338 |
+
<div className="h-screen w-full bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
|
| 339 |
+
<div className="md:hidden sticky top-0 z-40 flex items-center gap-2 border-b border-zinc-200/60 bg-white/80 px-3 py-2 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70">
|
| 340 |
+
<div className="ml-1 flex items-center gap-2 text-sm font-semibold tracking-tight">
|
| 341 |
+
<span className="inline-flex h-4 w-4 items-center justify-center">✱</span> RAG Assistant
|
| 342 |
+
</div>
|
| 343 |
+
<div className="ml-auto flex items-center gap-2">
|
| 344 |
+
<GhostIconButton label="Schedule">
|
| 345 |
+
<Calendar className="h-4 w-4" />
|
| 346 |
+
</GhostIconButton>
|
| 347 |
+
<GhostIconButton label="Apps">
|
| 348 |
+
<LayoutGrid className="h-4 w-4" />
|
| 349 |
+
</GhostIconButton>
|
| 350 |
+
<GhostIconButton label="More">
|
| 351 |
+
<MoreHorizontal className="h-4 w-4" />
|
| 352 |
+
</GhostIconButton>
|
| 353 |
+
<ThemeToggle theme={theme} setTheme={setTheme} />
|
| 354 |
+
</div>
|
| 355 |
+
</div>
|
| 356 |
+
|
| 357 |
+
<div className="flex h-[calc(100vh-0px)] w-full">
|
| 358 |
+
<Sidebar
|
| 359 |
+
open={sidebarOpen}
|
| 360 |
+
onClose={() => setSidebarOpen(false)}
|
| 361 |
+
theme={theme}
|
| 362 |
+
setTheme={setTheme}
|
| 363 |
+
collapsed={collapsed}
|
| 364 |
+
setCollapsed={setCollapsed}
|
| 365 |
+
sidebarCollapsed={sidebarCollapsed}
|
| 366 |
+
setSidebarCollapsed={setSidebarCollapsed}
|
| 367 |
+
conversations={conversations}
|
| 368 |
+
pinned={pinned}
|
| 369 |
+
recent={recent}
|
| 370 |
+
folders={folders}
|
| 371 |
+
folderCounts={folderCounts}
|
| 372 |
+
selectedId={selectedId}
|
| 373 |
+
onSelect={(id) => setSelectedId(id)}
|
| 374 |
+
togglePin={togglePin}
|
| 375 |
+
deleteChat={deleteChat}
|
| 376 |
+
renameChat={renameChat}
|
| 377 |
+
query={query}
|
| 378 |
+
setQuery={setQuery}
|
| 379 |
+
searchRef={searchRef}
|
| 380 |
+
createFolder={createFolder}
|
| 381 |
+
createNewChat={createNewChat}
|
| 382 |
+
templates={templates}
|
| 383 |
+
setTemplates={setTemplates}
|
| 384 |
+
onUseTemplate={handleUseTemplate}
|
| 385 |
+
/>
|
| 386 |
+
|
| 387 |
+
<main className="relative flex min-w-0 flex-1 flex-col">
|
| 388 |
+
<Header
|
| 389 |
+
createNewChat={createNewChat}
|
| 390 |
+
sidebarCollapsed={sidebarCollapsed}
|
| 391 |
+
setSidebarOpen={setSidebarOpen}
|
| 392 |
+
selectedModel={selectedModel}
|
| 393 |
+
onModelChange={setSelectedModel}
|
| 394 |
+
modelOptions={MODEL_OPTIONS}
|
| 395 |
+
/>
|
| 396 |
+
<ChatPane
|
| 397 |
+
ref={composerRef}
|
| 398 |
+
conversation={selected}
|
| 399 |
+
onSend={(content) => selected && sendMessage(selected.id, content)}
|
| 400 |
+
onEditMessage={(messageId, newContent) => selected && editMessage(selected.id, messageId, newContent)}
|
| 401 |
+
onResendMessage={(messageId) => selected && resendMessage(selected.id, messageId)}
|
| 402 |
+
isThinking={isThinking && thinkingConvId === selected?.id}
|
| 403 |
+
onPauseThinking={pauseThinking}
|
| 404 |
+
/>
|
| 405 |
+
</main>
|
| 406 |
+
</div>
|
| 407 |
+
</div>
|
| 408 |
+
)
|
| 409 |
+
}
|
frontend/components/Aurora.tsx
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef } from 'react';
|
| 2 |
+
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl';
|
| 3 |
+
|
| 4 |
+
const VERT = `#version 300 es
|
| 5 |
+
in vec2 position;
|
| 6 |
+
void main() {
|
| 7 |
+
gl_Position = vec4(position, 0.0, 1.0);
|
| 8 |
+
}
|
| 9 |
+
`;
|
| 10 |
+
|
| 11 |
+
const FRAG = `#version 300 es
|
| 12 |
+
precision highp float;
|
| 13 |
+
|
| 14 |
+
uniform float uTime;
|
| 15 |
+
uniform float uAmplitude;
|
| 16 |
+
uniform vec3 uColorStops[3];
|
| 17 |
+
uniform vec2 uResolution;
|
| 18 |
+
uniform float uBlend;
|
| 19 |
+
|
| 20 |
+
out vec4 fragColor;
|
| 21 |
+
|
| 22 |
+
vec3 permute(vec3 x) {
|
| 23 |
+
return mod(((x * 34.0) + 1.0) * x, 289.0);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
float snoise(vec2 v){
|
| 27 |
+
const vec4 C = vec4(
|
| 28 |
+
0.211324865405187, 0.366025403784439,
|
| 29 |
+
-0.577350269189626, 0.024390243902439
|
| 30 |
+
);
|
| 31 |
+
vec2 i = floor(v + dot(v, C.yy));
|
| 32 |
+
vec2 x0 = v - i + dot(i, C.xx);
|
| 33 |
+
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
|
| 34 |
+
vec4 x12 = x0.xyxy + C.xxzz;
|
| 35 |
+
x12.xy -= i1;
|
| 36 |
+
i = mod(i, 289.0);
|
| 37 |
+
|
| 38 |
+
vec3 p = permute(
|
| 39 |
+
permute(i.y + vec3(0.0, i1.y, 1.0))
|
| 40 |
+
+ i.x + vec3(0.0, i1.x, 1.0)
|
| 41 |
+
);
|
| 42 |
+
|
| 43 |
+
vec3 m = max(
|
| 44 |
+
0.5 - vec3(
|
| 45 |
+
dot(x0, x0),
|
| 46 |
+
dot(x12.xy, x12.xy),
|
| 47 |
+
dot(x12.zw, x12.zw)
|
| 48 |
+
),
|
| 49 |
+
0.0
|
| 50 |
+
);
|
| 51 |
+
m = m * m;
|
| 52 |
+
m = m * m;
|
| 53 |
+
|
| 54 |
+
vec3 x = 2.0 * fract(p * C.www) - 1.0;
|
| 55 |
+
vec3 h = abs(x) - 0.5;
|
| 56 |
+
vec3 ox = floor(x + 0.5);
|
| 57 |
+
vec3 a0 = x - ox;
|
| 58 |
+
m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
|
| 59 |
+
|
| 60 |
+
vec3 g;
|
| 61 |
+
g.x = a0.x * x0.x + h.x * x0.y;
|
| 62 |
+
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
|
| 63 |
+
return 130.0 * dot(m, g);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
struct ColorStop {
|
| 67 |
+
vec3 color;
|
| 68 |
+
float position;
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
#define COLOR_RAMP(colors, factor, finalColor) { \
|
| 72 |
+
int index = 0; \
|
| 73 |
+
for (int i = 0; i < 2; i++) { \
|
| 74 |
+
ColorStop currentColor = colors[i]; \
|
| 75 |
+
bool isInBetween = currentColor.position <= factor; \
|
| 76 |
+
index = int(mix(float(index), float(i), float(isInBetween))); \
|
| 77 |
+
} \
|
| 78 |
+
ColorStop currentColor = colors[index]; \
|
| 79 |
+
ColorStop nextColor = colors[index + 1]; \
|
| 80 |
+
float range = nextColor.position - currentColor.position; \
|
| 81 |
+
float lerpFactor = (factor - currentColor.position) / range; \
|
| 82 |
+
finalColor = mix(currentColor.color, nextColor.color, lerpFactor); \
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
void main() {
|
| 86 |
+
vec2 uv = gl_FragCoord.xy / uResolution;
|
| 87 |
+
|
| 88 |
+
ColorStop colors[3];
|
| 89 |
+
colors[0] = ColorStop(uColorStops[0], 0.0);
|
| 90 |
+
colors[1] = ColorStop(uColorStops[1], 0.5);
|
| 91 |
+
colors[2] = ColorStop(uColorStops[2], 1.0);
|
| 92 |
+
|
| 93 |
+
vec3 rampColor;
|
| 94 |
+
COLOR_RAMP(colors, uv.x, rampColor);
|
| 95 |
+
|
| 96 |
+
float height = snoise(vec2(uv.x * 2.0 + uTime * 0.1, uTime * 0.25)) * 0.5 * uAmplitude;
|
| 97 |
+
height = exp(height);
|
| 98 |
+
height = (uv.y * 2.0 - height + 0.2);
|
| 99 |
+
float intensity = 0.6 * height;
|
| 100 |
+
|
| 101 |
+
float midPoint = 0.20;
|
| 102 |
+
float auroraAlpha = smoothstep(midPoint - uBlend * 0.5, midPoint + uBlend * 0.5, intensity);
|
| 103 |
+
|
| 104 |
+
vec3 auroraColor = intensity * rampColor;
|
| 105 |
+
|
| 106 |
+
fragColor = vec4(auroraColor * auroraAlpha, auroraAlpha);
|
| 107 |
+
}
|
| 108 |
+
`;
|
| 109 |
+
|
| 110 |
+
interface AuroraProps {
|
| 111 |
+
colorStops?: string[];
|
| 112 |
+
amplitude?: number;
|
| 113 |
+
blend?: number;
|
| 114 |
+
time?: number;
|
| 115 |
+
speed?: number;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
export default function Aurora(props: AuroraProps) {
|
| 119 |
+
const { colorStops = ['#5227FF', '#7cff67', '#5227FF'], amplitude = 1.0, blend = 0.5 } = props;
|
| 120 |
+
const propsRef = useRef<AuroraProps>(props);
|
| 121 |
+
propsRef.current = props;
|
| 122 |
+
|
| 123 |
+
const ctnDom = useRef<HTMLDivElement>(null);
|
| 124 |
+
|
| 125 |
+
useEffect(() => {
|
| 126 |
+
const ctn = ctnDom.current;
|
| 127 |
+
if (!ctn) return;
|
| 128 |
+
|
| 129 |
+
const renderer = new Renderer({
|
| 130 |
+
alpha: true,
|
| 131 |
+
premultipliedAlpha: true,
|
| 132 |
+
antialias: true
|
| 133 |
+
});
|
| 134 |
+
const gl = renderer.gl;
|
| 135 |
+
gl.clearColor(0, 0, 0, 0);
|
| 136 |
+
gl.enable(gl.BLEND);
|
| 137 |
+
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
| 138 |
+
gl.canvas.style.backgroundColor = 'transparent';
|
| 139 |
+
|
| 140 |
+
let program: Program | undefined;
|
| 141 |
+
|
| 142 |
+
function resize() {
|
| 143 |
+
if (!ctn) return;
|
| 144 |
+
const width = ctn.offsetWidth;
|
| 145 |
+
const height = ctn.offsetHeight;
|
| 146 |
+
renderer.setSize(width, height);
|
| 147 |
+
if (program) {
|
| 148 |
+
program.uniforms.uResolution.value = [width, height];
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
window.addEventListener('resize', resize);
|
| 152 |
+
|
| 153 |
+
const geometry = new Triangle(gl);
|
| 154 |
+
if (geometry.attributes.uv) {
|
| 155 |
+
delete geometry.attributes.uv;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
const colorStopsArray = colorStops.map(hex => {
|
| 159 |
+
const c = new Color(hex);
|
| 160 |
+
return [c.r, c.g, c.b];
|
| 161 |
+
});
|
| 162 |
+
|
| 163 |
+
program = new Program(gl, {
|
| 164 |
+
vertex: VERT,
|
| 165 |
+
fragment: FRAG,
|
| 166 |
+
uniforms: {
|
| 167 |
+
uTime: { value: 0 },
|
| 168 |
+
uAmplitude: { value: amplitude },
|
| 169 |
+
uColorStops: { value: colorStopsArray },
|
| 170 |
+
uResolution: { value: [ctn.offsetWidth, ctn.offsetHeight] },
|
| 171 |
+
uBlend: { value: blend }
|
| 172 |
+
}
|
| 173 |
+
});
|
| 174 |
+
|
| 175 |
+
const mesh = new Mesh(gl, { geometry, program });
|
| 176 |
+
ctn.appendChild(gl.canvas);
|
| 177 |
+
|
| 178 |
+
let animateId = 0;
|
| 179 |
+
const update = (t: number) => {
|
| 180 |
+
animateId = requestAnimationFrame(update);
|
| 181 |
+
const { time = t * 0.01, speed = 1.0 } = propsRef.current;
|
| 182 |
+
if (program) {
|
| 183 |
+
program.uniforms.uTime.value = time * speed * 0.1;
|
| 184 |
+
program.uniforms.uAmplitude.value = propsRef.current.amplitude ?? 1.0;
|
| 185 |
+
program.uniforms.uBlend.value = propsRef.current.blend ?? blend;
|
| 186 |
+
const stops = propsRef.current.colorStops ?? colorStops;
|
| 187 |
+
program.uniforms.uColorStops.value = stops.map((hex: string) => {
|
| 188 |
+
const c = new Color(hex);
|
| 189 |
+
return [c.r, c.g, c.b];
|
| 190 |
+
});
|
| 191 |
+
renderer.render({ scene: mesh });
|
| 192 |
+
}
|
| 193 |
+
};
|
| 194 |
+
animateId = requestAnimationFrame(update);
|
| 195 |
+
|
| 196 |
+
resize();
|
| 197 |
+
|
| 198 |
+
return () => {
|
| 199 |
+
cancelAnimationFrame(animateId);
|
| 200 |
+
window.removeEventListener('resize', resize);
|
| 201 |
+
if (ctn && gl.canvas.parentNode === ctn) {
|
| 202 |
+
ctn.removeChild(gl.canvas);
|
| 203 |
+
}
|
| 204 |
+
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
| 205 |
+
};
|
| 206 |
+
}, [amplitude]);
|
| 207 |
+
|
| 208 |
+
return <div ref={ctnDom} className="w-full h-full" />;
|
| 209 |
+
}
|
frontend/components/ChatPane.jsx
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, forwardRef, useImperativeHandle, useRef } from "react"
|
| 4 |
+
import { RefreshCw, Check, X, Square } from "lucide-react"
|
| 5 |
+
import Message from "./Message"
|
| 6 |
+
import Composer from "./Composer"
|
| 7 |
+
import { cls, timeAgo } from "./utils"
|
| 8 |
+
|
| 9 |
+
function ThinkingMessage({ onPause }) {
|
| 10 |
+
return (
|
| 11 |
+
<Message role="assistant">
|
| 12 |
+
<div className="flex items-center gap-3">
|
| 13 |
+
<div className="flex items-center gap-1">
|
| 14 |
+
<div className="h-2 w-2 animate-bounce rounded-full bg-zinc-400 [animation-delay:-0.3s]"></div>
|
| 15 |
+
<div className="h-2 w-2 animate-bounce rounded-full bg-zinc-400 [animation-delay:-0.15s]"></div>
|
| 16 |
+
<div className="h-2 w-2 animate-bounce rounded-full bg-zinc-400"></div>
|
| 17 |
+
</div>
|
| 18 |
+
<span className="text-sm text-zinc-500">RAG assistant is thinking...</span>
|
| 19 |
+
<button
|
| 20 |
+
onClick={onPause}
|
| 21 |
+
className="ml-auto inline-flex items-center gap-1 rounded-full border border-zinc-300 px-2 py-1 text-xs text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800"
|
| 22 |
+
>
|
| 23 |
+
<Square className="h-3 w-3" /> Pause
|
| 24 |
+
</button>
|
| 25 |
+
</div>
|
| 26 |
+
</Message>
|
| 27 |
+
)
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const ChatPane = forwardRef(function ChatPane(
|
| 31 |
+
{
|
| 32 |
+
conversation,
|
| 33 |
+
onSend,
|
| 34 |
+
onEditMessage,
|
| 35 |
+
onResendMessage,
|
| 36 |
+
isThinking,
|
| 37 |
+
onPauseThinking,
|
| 38 |
+
},
|
| 39 |
+
ref,
|
| 40 |
+
) {
|
| 41 |
+
const [editingId, setEditingId] = useState(null)
|
| 42 |
+
const [draft, setDraft] = useState("")
|
| 43 |
+
const [busy, setBusy] = useState(false)
|
| 44 |
+
const composerRef = useRef(null)
|
| 45 |
+
|
| 46 |
+
useImperativeHandle(
|
| 47 |
+
ref,
|
| 48 |
+
() => ({
|
| 49 |
+
insertTemplate: (templateContent) => {
|
| 50 |
+
composerRef.current?.insertTemplate(templateContent)
|
| 51 |
+
},
|
| 52 |
+
}),
|
| 53 |
+
[],
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
if (!conversation) {
|
| 57 |
+
return (
|
| 58 |
+
<div className="flex h-full flex-col items-center justify-center bg-zinc-50 dark:bg-zinc-950">
|
| 59 |
+
<div className="flex flex-col items-center gap-4">
|
| 60 |
+
<svg
|
| 61 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 62 |
+
width="200"
|
| 63 |
+
height="200"
|
| 64 |
+
viewBox="0 0 24 24"
|
| 65 |
+
fill="none"
|
| 66 |
+
stroke="currentColor"
|
| 67 |
+
strokeWidth="1.5"
|
| 68 |
+
strokeLinecap="round"
|
| 69 |
+
strokeLinejoin="round"
|
| 70 |
+
className="text-green-500/20 dark:text-green-400/20"
|
| 71 |
+
>
|
| 72 |
+
{/* Cute Potato Body */}
|
| 73 |
+
<path d="M12 4c-5.5 0-8.5 4-8.5 9c0 5.5 3.5 8.5 8.5 8.5c5.5 0 8.5-3.5 8.5-8.5c0-5.5-3-8.5-8.5-8.5z" />
|
| 74 |
+
{/* Eyes */}
|
| 75 |
+
<path d="M9 11h.01" strokeWidth="3" />
|
| 76 |
+
<path d="M15 11h.01" strokeWidth="3" />
|
| 77 |
+
{/* Smile */}
|
| 78 |
+
<path d="M10 14a2.5 2.5 0 0 0 4 0" />
|
| 79 |
+
{/* Blush/Cheeks (Optional details for cuteness) */}
|
| 80 |
+
<path d="M7.5 12.5a1 1 0 0 1 0 .5" opacity="0.5" />
|
| 81 |
+
<path d="M16.5 12.5a1 1 0 0 1 0 .5" opacity="0.5" />
|
| 82 |
+
</svg>
|
| 83 |
+
<p className="text-lg font-medium text-zinc-500 dark:text-zinc-400">
|
| 84 |
+
Select a chat to get started
|
| 85 |
+
</p>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
)
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
const tags = ["Certified", "Personalized", "Experienced", "Helpful"]
|
| 92 |
+
const messages = Array.isArray(conversation.messages) ? conversation.messages : []
|
| 93 |
+
const count = messages.length || conversation.messageCount || 0
|
| 94 |
+
|
| 95 |
+
function startEdit(m) {
|
| 96 |
+
setEditingId(m.id)
|
| 97 |
+
setDraft(m.content)
|
| 98 |
+
}
|
| 99 |
+
function cancelEdit() {
|
| 100 |
+
setEditingId(null)
|
| 101 |
+
setDraft("")
|
| 102 |
+
}
|
| 103 |
+
function saveEdit() {
|
| 104 |
+
if (!editingId) return
|
| 105 |
+
onEditMessage?.(editingId, draft)
|
| 106 |
+
cancelEdit()
|
| 107 |
+
}
|
| 108 |
+
function saveAndResend() {
|
| 109 |
+
if (!editingId) return
|
| 110 |
+
onEditMessage?.(editingId, draft)
|
| 111 |
+
onResendMessage?.(editingId)
|
| 112 |
+
cancelEdit()
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
return (
|
| 116 |
+
<div className="flex h-full min-h-0 flex-1 flex-col">
|
| 117 |
+
<div className="flex-1 space-y-5 overflow-y-auto px-4 py-6 sm:px-8">
|
| 118 |
+
<div className="mb-2 text-3xl font-serif tracking-tight sm:text-4xl md:text-5xl">
|
| 119 |
+
<span className="block leading-[1.05] font-sans text-2xl">{conversation.title}</span>
|
| 120 |
+
</div>
|
| 121 |
+
<div className="mb-4 text-sm text-zinc-500 dark:text-zinc-400">
|
| 122 |
+
Updated {timeAgo(conversation.updatedAt)} · {count} messages
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
<div className="mb-6 flex flex-wrap gap-2 border-b border-zinc-200 pb-5 dark:border-zinc-800">
|
| 126 |
+
{tags.map((t) => (
|
| 127 |
+
<span
|
| 128 |
+
key={t}
|
| 129 |
+
className="inline-flex items-center rounded-full border border-zinc-200 px-3 py-1 text-xs text-zinc-700 dark:border-zinc-800 dark:text-zinc-200"
|
| 130 |
+
>
|
| 131 |
+
{t}
|
| 132 |
+
</span>
|
| 133 |
+
))}
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
{messages.length === 0 ? (
|
| 137 |
+
<div className="rounded-xl border border-dashed border-zinc-300 p-6 text-sm text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
|
| 138 |
+
No messages yet. Say hello to start.
|
| 139 |
+
</div>
|
| 140 |
+
) : (
|
| 141 |
+
<>
|
| 142 |
+
{messages.map((m) => (
|
| 143 |
+
<div key={m.id} className="space-y-2">
|
| 144 |
+
{editingId === m.id ? (
|
| 145 |
+
<div className={cls("rounded-2xl border p-2", "border-zinc-200 dark:border-zinc-800")}>
|
| 146 |
+
<textarea
|
| 147 |
+
value={draft}
|
| 148 |
+
onChange={(e) => setDraft(e.target.value)}
|
| 149 |
+
className="w-full resize-y rounded-xl bg-transparent p-2 text-sm outline-none"
|
| 150 |
+
rows={3}
|
| 151 |
+
/>
|
| 152 |
+
<div className="mt-2 flex items-center gap-2">
|
| 153 |
+
<button
|
| 154 |
+
onClick={saveEdit}
|
| 155 |
+
className="inline-flex items-center gap-1 rounded-full bg-zinc-900 px-3 py-1.5 text-xs text-white dark:bg-white dark:text-zinc-900"
|
| 156 |
+
>
|
| 157 |
+
<Check className="h-3.5 w-3.5" /> Save
|
| 158 |
+
</button>
|
| 159 |
+
<button
|
| 160 |
+
onClick={saveAndResend}
|
| 161 |
+
className="inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-xs"
|
| 162 |
+
>
|
| 163 |
+
<RefreshCw className="h-3.5 w-3.5" /> Save & Resend
|
| 164 |
+
</button>
|
| 165 |
+
<button
|
| 166 |
+
onClick={cancelEdit}
|
| 167 |
+
className="inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs"
|
| 168 |
+
>
|
| 169 |
+
<X className="h-3.5 w-3.5" /> Cancel
|
| 170 |
+
</button>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
) : (
|
| 174 |
+
<Message role={m.role}>
|
| 175 |
+
<div className="whitespace-pre-wrap">{m.content}</div>
|
| 176 |
+
</Message>
|
| 177 |
+
)}
|
| 178 |
+
</div>
|
| 179 |
+
))}
|
| 180 |
+
{isThinking && <ThinkingMessage onPause={onPauseThinking} />}
|
| 181 |
+
</>
|
| 182 |
+
)}
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
<Composer
|
| 186 |
+
ref={composerRef}
|
| 187 |
+
onSend={async (text) => {
|
| 188 |
+
if (!text.trim()) return
|
| 189 |
+
setBusy(true)
|
| 190 |
+
await onSend?.(text)
|
| 191 |
+
setBusy(false)
|
| 192 |
+
}}
|
| 193 |
+
busy={busy}
|
| 194 |
+
/>
|
| 195 |
+
</div>
|
| 196 |
+
)
|
| 197 |
+
})
|
| 198 |
+
|
| 199 |
+
export default ChatPane
|
frontend/components/Composer.jsx
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useRef, useState, forwardRef, useImperativeHandle, useEffect } from "react"
|
| 4 |
+
import { Send, Loader2, Mic } from "lucide-react"
|
| 5 |
+
import { cls } from "./utils"
|
| 6 |
+
|
| 7 |
+
const Composer = forwardRef(function Composer({ onSend, busy }, ref) {
|
| 8 |
+
const [value, setValue] = useState("")
|
| 9 |
+
const [sending, setSending] = useState(false)
|
| 10 |
+
const [isFocused, setIsFocused] = useState(false)
|
| 11 |
+
const [lineCount, setLineCount] = useState(1)
|
| 12 |
+
const inputRef = useRef(null)
|
| 13 |
+
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
if (inputRef.current) {
|
| 16 |
+
const textarea = inputRef.current
|
| 17 |
+
const lineHeight = 18
|
| 18 |
+
const minHeight = 32
|
| 19 |
+
|
| 20 |
+
textarea.style.height = "auto"
|
| 21 |
+
const scrollHeight = textarea.scrollHeight
|
| 22 |
+
const calculatedLines = Math.max(1, Math.floor((scrollHeight - 16) / lineHeight))
|
| 23 |
+
|
| 24 |
+
setLineCount(calculatedLines)
|
| 25 |
+
|
| 26 |
+
if (calculatedLines <= 6) {
|
| 27 |
+
textarea.style.height = `${Math.max(minHeight, scrollHeight)}px`
|
| 28 |
+
textarea.style.overflowY = "hidden"
|
| 29 |
+
} else {
|
| 30 |
+
textarea.style.height = `${minHeight + 5 * lineHeight}px`
|
| 31 |
+
textarea.style.overflowY = "auto"
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
}, [value])
|
| 35 |
+
|
| 36 |
+
useImperativeHandle(
|
| 37 |
+
ref,
|
| 38 |
+
() => ({
|
| 39 |
+
insertTemplate: (templateContent) => {
|
| 40 |
+
setValue((prev) => {
|
| 41 |
+
const newValue = prev ? `${prev}\n\n${templateContent}` : templateContent
|
| 42 |
+
setTimeout(() => {
|
| 43 |
+
inputRef.current?.focus()
|
| 44 |
+
const length = newValue.length
|
| 45 |
+
inputRef.current?.setSelectionRange(length, length)
|
| 46 |
+
}, 0)
|
| 47 |
+
return newValue
|
| 48 |
+
})
|
| 49 |
+
},
|
| 50 |
+
focus: () => {
|
| 51 |
+
inputRef.current?.focus()
|
| 52 |
+
},
|
| 53 |
+
}),
|
| 54 |
+
[],
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
async function handleSend() {
|
| 58 |
+
if (!value.trim() || sending) return
|
| 59 |
+
const outgoingText = value
|
| 60 |
+
setValue("")
|
| 61 |
+
setSending(true)
|
| 62 |
+
try {
|
| 63 |
+
await onSend?.(outgoingText)
|
| 64 |
+
inputRef.current?.focus()
|
| 65 |
+
} catch (err) {
|
| 66 |
+
// Restore text so user can retry without retyping if request fails.
|
| 67 |
+
setValue(outgoingText)
|
| 68 |
+
throw err
|
| 69 |
+
} finally {
|
| 70 |
+
setSending(false)
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
return (
|
| 75 |
+
<div className="border-t border-zinc-200/60 p-4 dark:border-zinc-800">
|
| 76 |
+
<div
|
| 77 |
+
className={cls(
|
| 78 |
+
"mx-auto flex flex-col rounded-2xl border bg-white shadow-sm dark:bg-zinc-950 transition-all duration-200",
|
| 79 |
+
"max-w-3xl border-zinc-300 dark:border-zinc-700 p-2",
|
| 80 |
+
)}
|
| 81 |
+
>
|
| 82 |
+
<div className="flex-1 relative">
|
| 83 |
+
<textarea
|
| 84 |
+
ref={inputRef}
|
| 85 |
+
value={value}
|
| 86 |
+
onChange={(e) => setValue(e.target.value)}
|
| 87 |
+
onFocus={() => setIsFocused(true)}
|
| 88 |
+
onBlur={() => setIsFocused(false)}
|
| 89 |
+
placeholder="Ask a question about your documents..."
|
| 90 |
+
rows={1}
|
| 91 |
+
className={cls(
|
| 92 |
+
"w-full resize-none bg-transparent text-sm outline-none placeholder:text-zinc-400 transition-all duration-200",
|
| 93 |
+
"px-0 py-1.5 min-h-[32px] text-left",
|
| 94 |
+
)}
|
| 95 |
+
style={{
|
| 96 |
+
height: "auto",
|
| 97 |
+
overflowY: lineCount > 6 ? "auto" : "hidden",
|
| 98 |
+
}}
|
| 99 |
+
onKeyDown={(e) => {
|
| 100 |
+
if (e.key === "Enter" && !e.shiftKey) {
|
| 101 |
+
e.preventDefault()
|
| 102 |
+
handleSend()
|
| 103 |
+
}
|
| 104 |
+
}}
|
| 105 |
+
/>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
<div className="flex items-center justify-end mt-2 gap-3">
|
| 109 |
+
<div className="flex items-center gap-1 shrink-0">
|
| 110 |
+
<button
|
| 111 |
+
className="inline-flex items-center justify-center rounded-full p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-300 transition-colors"
|
| 112 |
+
title="Voice input"
|
| 113 |
+
>
|
| 114 |
+
<Mic className="h-4 w-4" />
|
| 115 |
+
</button>
|
| 116 |
+
<button
|
| 117 |
+
onClick={handleSend}
|
| 118 |
+
disabled={sending || busy || !value.trim()}
|
| 119 |
+
className={cls(
|
| 120 |
+
"inline-flex shrink-0 items-center gap-2 rounded-full bg-zinc-900 px-3 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:bg-white dark:text-zinc-900",
|
| 121 |
+
(sending || busy || !value.trim()) && "opacity-50 cursor-not-allowed",
|
| 122 |
+
)}
|
| 123 |
+
>
|
| 124 |
+
{sending || busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
| 125 |
+
</button>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
<div className="mx-auto mt-2 max-w-3xl px-1 text-[11px] text-zinc-500 dark:text-zinc-400">
|
| 131 |
+
Press{" "}
|
| 132 |
+
<kbd className="rounded border border-zinc-300 bg-zinc-50 px-1 dark:border-zinc-600 dark:bg-zinc-800">
|
| 133 |
+
Enter
|
| 134 |
+
</kbd>{" "}
|
| 135 |
+
to send ·{" "}
|
| 136 |
+
<kbd className="rounded border border-zinc-300 bg-zinc-50 px-1 dark:border-zinc-600 dark:bg-zinc-800">
|
| 137 |
+
Shift
|
| 138 |
+
</kbd>
|
| 139 |
+
+
|
| 140 |
+
<kbd className="rounded border border-zinc-300 bg-zinc-50 px-1 dark:border-zinc-600 dark:bg-zinc-800">
|
| 141 |
+
Enter
|
| 142 |
+
</kbd>{" "}
|
| 143 |
+
for newline
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
)
|
| 147 |
+
})
|
| 148 |
+
|
| 149 |
+
export default Composer
|
frontend/components/ConversationRow.jsx
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
import { useState } from "react"
|
| 3 |
+
import { Pencil, Star, Trash2 } from "lucide-react"
|
| 4 |
+
import { cls } from "./utils"
|
| 5 |
+
|
| 6 |
+
export default function ConversationRow({ data, active, onSelect, onTogglePin, onDelete, onRename, showMeta }) {
|
| 7 |
+
const [isRenaming, setIsRenaming] = useState(false)
|
| 8 |
+
const [draftTitle, setDraftTitle] = useState(data.title)
|
| 9 |
+
|
| 10 |
+
// This helper function handles keyboard accessibility.
|
| 11 |
+
// It allows the user to trigger the onSelect function by pressing Enter or Space.
|
| 12 |
+
const handleKeyDown = (e) => {
|
| 13 |
+
if (isRenaming) return
|
| 14 |
+
if (e.key === 'Enter' || e.key === ' ') {
|
| 15 |
+
// Prevent the default action (like scrolling the page on spacebar press)
|
| 16 |
+
e.preventDefault();
|
| 17 |
+
onSelect();
|
| 18 |
+
}
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
const beginRename = (e) => {
|
| 22 |
+
e.stopPropagation()
|
| 23 |
+
setDraftTitle(data.title)
|
| 24 |
+
setIsRenaming(true)
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const commitRename = () => {
|
| 28 |
+
const trimmed = draftTitle.trim()
|
| 29 |
+
if (trimmed && trimmed !== data.title) {
|
| 30 |
+
onRename?.(trimmed)
|
| 31 |
+
}
|
| 32 |
+
setIsRenaming(false)
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const cancelRename = () => {
|
| 36 |
+
setDraftTitle(data.title)
|
| 37 |
+
setIsRenaming(false)
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
return (
|
| 41 |
+
<div className="group relative">
|
| 42 |
+
{/* 1. Changed <button> to <div> */}
|
| 43 |
+
<div
|
| 44 |
+
onClick={onSelect}
|
| 45 |
+
// 2. Added accessibility attributes and keyboard handler
|
| 46 |
+
role="button"
|
| 47 |
+
tabIndex="0"
|
| 48 |
+
onKeyDown={handleKeyDown}
|
| 49 |
+
// 3. Added 'cursor-pointer' to the className for visual feedback
|
| 50 |
+
className={cls(
|
| 51 |
+
"-mx-1 flex w-[calc(100%+8px)] items-center gap-2 rounded-lg px-2 py-2 text-left cursor-pointer",
|
| 52 |
+
active
|
| 53 |
+
? "bg-zinc-100 text-zinc-900 dark:bg-zinc-800/60 dark:text-zinc-100"
|
| 54 |
+
: "hover:bg-zinc-100 dark:hover:bg-zinc-800",
|
| 55 |
+
)}
|
| 56 |
+
title={data.title}
|
| 57 |
+
>
|
| 58 |
+
<div className="min-w-0 flex-1">
|
| 59 |
+
<div className="flex items-center gap-2">
|
| 60 |
+
{isRenaming ? (
|
| 61 |
+
<input
|
| 62 |
+
autoFocus
|
| 63 |
+
value={draftTitle}
|
| 64 |
+
onChange={(e) => setDraftTitle(e.target.value)}
|
| 65 |
+
onClick={(e) => e.stopPropagation()}
|
| 66 |
+
onBlur={commitRename}
|
| 67 |
+
onKeyDown={(e) => {
|
| 68 |
+
e.stopPropagation()
|
| 69 |
+
if (e.key === "Enter") {
|
| 70 |
+
e.preventDefault()
|
| 71 |
+
commitRename()
|
| 72 |
+
}
|
| 73 |
+
if (e.key === "Escape") {
|
| 74 |
+
e.preventDefault()
|
| 75 |
+
cancelRename()
|
| 76 |
+
}
|
| 77 |
+
}}
|
| 78 |
+
className="w-full rounded-md border border-zinc-300 bg-white px-2 py-1 text-sm font-medium tracking-tight outline-none focus:border-blue-500 dark:border-zinc-700 dark:bg-zinc-900"
|
| 79 |
+
aria-label="Rename chat"
|
| 80 |
+
/>
|
| 81 |
+
) : (
|
| 82 |
+
<span className="truncate text-sm font-medium tracking-tight">{data.title}</span>
|
| 83 |
+
)}
|
| 84 |
+
</div>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
<div className="flex items-center gap-1">
|
| 88 |
+
{/* Rename Button */}
|
| 89 |
+
<button
|
| 90 |
+
onClick={beginRename}
|
| 91 |
+
title="Rename chat"
|
| 92 |
+
className="rounded-md p-1 text-zinc-500 opacity-0 transition group-hover:opacity-100 hover:bg-zinc-200/50 dark:text-zinc-300 dark:hover:bg-zinc-700/60"
|
| 93 |
+
aria-label="Rename conversation"
|
| 94 |
+
>
|
| 95 |
+
<Pencil className="h-4 w-4" />
|
| 96 |
+
</button>
|
| 97 |
+
|
| 98 |
+
{/* Delete Button */}
|
| 99 |
+
<button
|
| 100 |
+
onClick={(e) => {
|
| 101 |
+
e.stopPropagation()
|
| 102 |
+
onDelete()
|
| 103 |
+
}}
|
| 104 |
+
title="Delete chat"
|
| 105 |
+
className="rounded-md p-1 text-zinc-500 opacity-0 transition group-hover:opacity-100 hover:bg-red-100 hover:text-red-600 dark:text-zinc-400 dark:hover:bg-red-900/30 dark:hover:text-red-400"
|
| 106 |
+
aria-label="Delete conversation"
|
| 107 |
+
>
|
| 108 |
+
<Trash2 className="h-4 w-4" />
|
| 109 |
+
</button>
|
| 110 |
+
|
| 111 |
+
{/* Pin Button */}
|
| 112 |
+
<button
|
| 113 |
+
onClick={(e) => {
|
| 114 |
+
e.stopPropagation()
|
| 115 |
+
onTogglePin()
|
| 116 |
+
}}
|
| 117 |
+
title={data.pinned ? "Unpin" : "Pin"}
|
| 118 |
+
className="rounded-md p-1 text-zinc-500 opacity-0 transition group-hover:opacity-100 hover:bg-zinc-200/50 dark:text-zinc-300 dark:hover:bg-zinc-700/60"
|
| 119 |
+
aria-label={data.pinned ? "Unpin conversation" : "Pin conversation"}
|
| 120 |
+
>
|
| 121 |
+
{data.pinned ? (
|
| 122 |
+
<Star className="h-4 w-4 fill-zinc-800 text-zinc-800 dark:fill-zinc-200 dark:text-zinc-200" />
|
| 123 |
+
) : (
|
| 124 |
+
<Star className="h-4 w-4" />
|
| 125 |
+
)}
|
| 126 |
+
</button>
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
)
|
| 131 |
+
}
|
frontend/components/CreateFolderModal.jsx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
import { motion, AnimatePresence } from "framer-motion"
|
| 3 |
+
import { X, Lightbulb } from "lucide-react"
|
| 4 |
+
import { useState } from "react"
|
| 5 |
+
|
| 6 |
+
export default function CreateFolderModal({ isOpen, onClose, onCreateFolder }) {
|
| 7 |
+
const [folderName, setFolderName] = useState("")
|
| 8 |
+
|
| 9 |
+
const handleSubmit = (e) => {
|
| 10 |
+
e.preventDefault()
|
| 11 |
+
if (folderName.trim()) {
|
| 12 |
+
onCreateFolder(folderName.trim())
|
| 13 |
+
setFolderName("")
|
| 14 |
+
onClose()
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const handleCancel = () => {
|
| 19 |
+
setFolderName("")
|
| 20 |
+
onClose()
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<AnimatePresence>
|
| 25 |
+
{isOpen && (
|
| 26 |
+
<>
|
| 27 |
+
<motion.div
|
| 28 |
+
initial={{ opacity: 0 }}
|
| 29 |
+
animate={{ opacity: 1 }}
|
| 30 |
+
exit={{ opacity: 0 }}
|
| 31 |
+
className="fixed inset-0 z-50 bg-black/60"
|
| 32 |
+
onClick={handleCancel}
|
| 33 |
+
/>
|
| 34 |
+
<motion.div
|
| 35 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
| 36 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 37 |
+
exit={{ opacity: 0, scale: 0.95 }}
|
| 38 |
+
className="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-800 dark:bg-zinc-900"
|
| 39 |
+
>
|
| 40 |
+
<div className="flex items-center justify-between mb-4">
|
| 41 |
+
<h2 className="text-lg font-semibold">Folder name</h2>
|
| 42 |
+
<button onClick={handleCancel} className="rounded-lg p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
| 43 |
+
<X className="h-5 w-5" />
|
| 44 |
+
</button>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<form onSubmit={handleSubmit}>
|
| 48 |
+
<input
|
| 49 |
+
type="text"
|
| 50 |
+
value={folderName}
|
| 51 |
+
onChange={(e) => setFolderName(e.target.value)}
|
| 52 |
+
placeholder="E.g. Marketing Projects"
|
| 53 |
+
className="w-full rounded-lg border border-zinc-300 px-4 py-3 text-sm outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-zinc-700 dark:bg-zinc-800"
|
| 54 |
+
autoFocus
|
| 55 |
+
/>
|
| 56 |
+
|
| 57 |
+
<div className="mt-4 flex items-start gap-3 rounded-lg bg-zinc-50 p-4 dark:bg-zinc-800/50">
|
| 58 |
+
<Lightbulb className="h-5 w-5 text-zinc-500 mt-0.5 shrink-0" />
|
| 59 |
+
<div className="text-sm text-zinc-600 dark:text-zinc-400">
|
| 60 |
+
<div className="font-medium mb-1">What's a folder?</div>
|
| 61 |
+
<div>
|
| 62 |
+
Folders keep chats, files, and custom instructions in one place. Use them for ongoing work, or just
|
| 63 |
+
to keep things tidy.
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<div className="flex gap-3 mt-6">
|
| 69 |
+
<button
|
| 70 |
+
type="button"
|
| 71 |
+
onClick={handleCancel}
|
| 72 |
+
className="flex-1 rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800"
|
| 73 |
+
>
|
| 74 |
+
Cancel
|
| 75 |
+
</button>
|
| 76 |
+
<button
|
| 77 |
+
type="submit"
|
| 78 |
+
disabled={!folderName.trim()}
|
| 79 |
+
className="flex-1 rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100"
|
| 80 |
+
>
|
| 81 |
+
Create folder
|
| 82 |
+
</button>
|
| 83 |
+
</div>
|
| 84 |
+
</form>
|
| 85 |
+
</motion.div>
|
| 86 |
+
</>
|
| 87 |
+
)}
|
| 88 |
+
</AnimatePresence>
|
| 89 |
+
)
|
| 90 |
+
}
|
frontend/components/CreateTemplateModal.jsx
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
import { motion, AnimatePresence } from "framer-motion"
|
| 3 |
+
import { X, Lightbulb } from "lucide-react"
|
| 4 |
+
import { useState } from "react"
|
| 5 |
+
|
| 6 |
+
export default function CreateTemplateModal({ isOpen, onClose, onCreateTemplate, editingTemplate = null }) {
|
| 7 |
+
const [templateName, setTemplateName] = useState(editingTemplate?.name || "")
|
| 8 |
+
const [templateContent, setTemplateContent] = useState(editingTemplate?.content || "")
|
| 9 |
+
|
| 10 |
+
const isEditing = !!editingTemplate
|
| 11 |
+
|
| 12 |
+
const handleSubmit = (e) => {
|
| 13 |
+
e.preventDefault()
|
| 14 |
+
if (templateName.trim() && templateContent.trim()) {
|
| 15 |
+
const templateData = {
|
| 16 |
+
name: templateName.trim(),
|
| 17 |
+
content: templateContent.trim(),
|
| 18 |
+
snippet: templateContent.trim().slice(0, 100) + (templateContent.trim().length > 100 ? "..." : ""),
|
| 19 |
+
createdAt: editingTemplate?.createdAt || new Date().toISOString(),
|
| 20 |
+
updatedAt: new Date().toISOString(),
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
if (isEditing) {
|
| 24 |
+
onCreateTemplate({ ...templateData, id: editingTemplate.id })
|
| 25 |
+
} else {
|
| 26 |
+
onCreateTemplate(templateData)
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
handleCancel()
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const handleCancel = () => {
|
| 34 |
+
setTemplateName("")
|
| 35 |
+
setTemplateContent("")
|
| 36 |
+
onClose()
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// Update form when editingTemplate changes
|
| 40 |
+
useState(() => {
|
| 41 |
+
if (editingTemplate) {
|
| 42 |
+
setTemplateName(editingTemplate.name || "")
|
| 43 |
+
setTemplateContent(editingTemplate.content || "")
|
| 44 |
+
}
|
| 45 |
+
}, [editingTemplate])
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<AnimatePresence>
|
| 49 |
+
{isOpen && (
|
| 50 |
+
<>
|
| 51 |
+
<motion.div
|
| 52 |
+
initial={{ opacity: 0 }}
|
| 53 |
+
animate={{ opacity: 1 }}
|
| 54 |
+
exit={{ opacity: 0 }}
|
| 55 |
+
className="fixed inset-0 z-50 bg-black/60"
|
| 56 |
+
onClick={handleCancel}
|
| 57 |
+
/>
|
| 58 |
+
<motion.div
|
| 59 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
| 60 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 61 |
+
exit={{ opacity: 0, scale: 0.95 }}
|
| 62 |
+
className="fixed left-1/2 top-1/2 z-50 w-full max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-800 dark:bg-zinc-900"
|
| 63 |
+
>
|
| 64 |
+
<div className="flex items-center justify-between mb-4">
|
| 65 |
+
<h2 className="text-lg font-semibold">{isEditing ? "Edit Template" : "Create Template"}</h2>
|
| 66 |
+
<button onClick={handleCancel} className="rounded-lg p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
| 67 |
+
<X className="h-5 w-5" />
|
| 68 |
+
</button>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 72 |
+
<div>
|
| 73 |
+
<label htmlFor="templateName" className="block text-sm font-medium mb-2">
|
| 74 |
+
Template Name
|
| 75 |
+
</label>
|
| 76 |
+
<input
|
| 77 |
+
id="templateName"
|
| 78 |
+
type="text"
|
| 79 |
+
value={templateName}
|
| 80 |
+
onChange={(e) => setTemplateName(e.target.value)}
|
| 81 |
+
placeholder="E.g. Email Response, Code Review, Meeting Notes"
|
| 82 |
+
className="w-full rounded-lg border border-zinc-300 px-4 py-3 text-sm outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-zinc-700 dark:bg-zinc-800"
|
| 83 |
+
autoFocus
|
| 84 |
+
/>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
<div>
|
| 88 |
+
<label htmlFor="templateContent" className="block text-sm font-medium mb-2">
|
| 89 |
+
Template Content
|
| 90 |
+
</label>
|
| 91 |
+
<textarea
|
| 92 |
+
id="templateContent"
|
| 93 |
+
value={templateContent}
|
| 94 |
+
onChange={(e) => setTemplateContent(e.target.value)}
|
| 95 |
+
placeholder="Enter your template content here. This will be inserted into the chat when you use the template."
|
| 96 |
+
rows={8}
|
| 97 |
+
className="w-full rounded-lg border border-zinc-300 px-4 py-3 text-sm outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-zinc-700 dark:bg-zinc-800 resize-none"
|
| 98 |
+
/>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<div className="flex items-start gap-3 rounded-lg bg-zinc-50 p-4 dark:bg-zinc-800/50">
|
| 102 |
+
<Lightbulb className="h-5 w-5 text-zinc-500 mt-0.5 shrink-0" />
|
| 103 |
+
<div className="text-sm text-zinc-600 dark:text-zinc-400">
|
| 104 |
+
<div className="font-medium mb-1">Pro tip</div>
|
| 105 |
+
<div>
|
| 106 |
+
Templates are perfect for frequently used prompts, instructions, or conversation starters. They'll
|
| 107 |
+
be inserted directly into your chat input when selected.
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<div className="flex gap-3 pt-2">
|
| 113 |
+
<button
|
| 114 |
+
type="button"
|
| 115 |
+
onClick={handleCancel}
|
| 116 |
+
className="flex-1 rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800"
|
| 117 |
+
>
|
| 118 |
+
Cancel
|
| 119 |
+
</button>
|
| 120 |
+
<button
|
| 121 |
+
type="submit"
|
| 122 |
+
disabled={!templateName.trim() || !templateContent.trim()}
|
| 123 |
+
className="flex-1 rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100"
|
| 124 |
+
>
|
| 125 |
+
{isEditing ? "Update Template" : "Create Template"}
|
| 126 |
+
</button>
|
| 127 |
+
</div>
|
| 128 |
+
</form>
|
| 129 |
+
</motion.div>
|
| 130 |
+
</>
|
| 131 |
+
)}
|
| 132 |
+
</AnimatePresence>
|
| 133 |
+
)
|
| 134 |
+
}
|
frontend/components/FolderRow.jsx
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useRef, useEffect } from "react"
|
| 4 |
+
import { FolderIcon, ChevronRight, ChevronDown, MoreHorizontal } from "lucide-react"
|
| 5 |
+
import ConversationRow from "./ConversationRow"
|
| 6 |
+
import { motion, AnimatePresence } from "framer-motion"
|
| 7 |
+
|
| 8 |
+
export default function FolderRow({
|
| 9 |
+
name,
|
| 10 |
+
count,
|
| 11 |
+
conversations = [],
|
| 12 |
+
selectedId,
|
| 13 |
+
onSelect,
|
| 14 |
+
togglePin,
|
| 15 |
+
onDeleteFolder,
|
| 16 |
+
onRenameFolder,
|
| 17 |
+
}) {
|
| 18 |
+
const [isExpanded, setIsExpanded] = useState(false)
|
| 19 |
+
const [showMenu, setShowMenu] = useState(false)
|
| 20 |
+
const menuRef = useRef(null)
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
const handleClickOutside = (event) => {
|
| 24 |
+
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
| 25 |
+
setShowMenu(false)
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
if (showMenu) {
|
| 30 |
+
document.addEventListener("mousedown", handleClickOutside)
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
return () => {
|
| 34 |
+
document.removeEventListener("mousedown", handleClickOutside)
|
| 35 |
+
}
|
| 36 |
+
}, [showMenu])
|
| 37 |
+
|
| 38 |
+
const handleToggle = () => {
|
| 39 |
+
setIsExpanded(!isExpanded)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const handleRename = () => {
|
| 43 |
+
const newName = prompt(`Rename folder "${name}" to:`, name)
|
| 44 |
+
if (newName && newName.trim() && newName !== name) {
|
| 45 |
+
onRenameFolder?.(name, newName.trim())
|
| 46 |
+
}
|
| 47 |
+
setShowMenu(false)
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const handleDelete = () => {
|
| 51 |
+
if (
|
| 52 |
+
confirm(
|
| 53 |
+
`Are you sure you want to delete the folder "${name}"? This will move all conversations to the root level.`,
|
| 54 |
+
)
|
| 55 |
+
) {
|
| 56 |
+
onDeleteFolder?.(name)
|
| 57 |
+
}
|
| 58 |
+
setShowMenu(false)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
return (
|
| 62 |
+
<div className="group">
|
| 63 |
+
<div className="flex items-center justify-between rounded-lg px-2 py-2 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
| 64 |
+
<button onClick={handleToggle} className="flex items-center gap-2 flex-1 text-left">
|
| 65 |
+
{isExpanded ? (
|
| 66 |
+
<ChevronDown className="h-4 w-4 text-zinc-500" />
|
| 67 |
+
) : (
|
| 68 |
+
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
| 69 |
+
)}
|
| 70 |
+
<FolderIcon className="h-4 w-4" />
|
| 71 |
+
<span className="truncate">{name}</span>
|
| 72 |
+
</button>
|
| 73 |
+
|
| 74 |
+
<div className="flex items-center gap-1">
|
| 75 |
+
<span className="rounded-md bg-zinc-100 px-1.5 py-0.5 text-[11px] text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
|
| 76 |
+
{count}
|
| 77 |
+
</span>
|
| 78 |
+
|
| 79 |
+
<div className="relative" ref={menuRef}>
|
| 80 |
+
<button
|
| 81 |
+
onClick={(e) => {
|
| 82 |
+
e.stopPropagation()
|
| 83 |
+
setShowMenu(!showMenu)
|
| 84 |
+
}}
|
| 85 |
+
className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-opacity"
|
| 86 |
+
>
|
| 87 |
+
<MoreHorizontal className="h-3 w-3" />
|
| 88 |
+
</button>
|
| 89 |
+
|
| 90 |
+
<AnimatePresence>
|
| 91 |
+
{showMenu && (
|
| 92 |
+
<motion.div
|
| 93 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
| 94 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 95 |
+
exit={{ opacity: 0, scale: 0.95 }}
|
| 96 |
+
className="absolute right-0 top-full mt-1 w-32 rounded-lg border border-zinc-200 bg-white py-1 shadow-lg dark:border-zinc-800 dark:bg-zinc-900 z-[100]"
|
| 97 |
+
>
|
| 98 |
+
<button
|
| 99 |
+
onClick={handleRename}
|
| 100 |
+
className="w-full px-3 py-1.5 text-left text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
| 101 |
+
>
|
| 102 |
+
Rename
|
| 103 |
+
</button>
|
| 104 |
+
<button
|
| 105 |
+
onClick={handleDelete}
|
| 106 |
+
className="w-full px-3 py-1.5 text-left text-xs text-red-600 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
| 107 |
+
>
|
| 108 |
+
Delete
|
| 109 |
+
</button>
|
| 110 |
+
</motion.div>
|
| 111 |
+
)}
|
| 112 |
+
</AnimatePresence>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<AnimatePresence>
|
| 118 |
+
{isExpanded && (
|
| 119 |
+
<motion.div
|
| 120 |
+
initial={{ height: 0, opacity: 0 }}
|
| 121 |
+
animate={{ height: "auto", opacity: 1 }}
|
| 122 |
+
exit={{ height: 0, opacity: 0 }}
|
| 123 |
+
className="overflow-hidden"
|
| 124 |
+
>
|
| 125 |
+
<div className="ml-6 space-y-1 py-1">
|
| 126 |
+
{conversations.map((conversation) => (
|
| 127 |
+
<ConversationRow
|
| 128 |
+
key={conversation.id}
|
| 129 |
+
data={conversation}
|
| 130 |
+
active={conversation.id === selectedId}
|
| 131 |
+
onSelect={() => onSelect(conversation.id)}
|
| 132 |
+
onTogglePin={() => togglePin(conversation.id)}
|
| 133 |
+
showMeta
|
| 134 |
+
/>
|
| 135 |
+
))}
|
| 136 |
+
{conversations.length === 0 && (
|
| 137 |
+
<div className="px-2 py-2 text-xs text-zinc-500 dark:text-zinc-400">
|
| 138 |
+
No conversations in this folder
|
| 139 |
+
</div>
|
| 140 |
+
)}
|
| 141 |
+
</div>
|
| 142 |
+
</motion.div>
|
| 143 |
+
)}
|
| 144 |
+
</AnimatePresence>
|
| 145 |
+
</div>
|
| 146 |
+
)
|
| 147 |
+
}
|
frontend/components/GhostIconButton.jsx
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
|
| 3 |
+
export default function GhostIconButton({ label, children }) {
|
| 4 |
+
return (
|
| 5 |
+
<button
|
| 6 |
+
className="hidden rounded-full border border-zinc-200 bg-white/70 p-2 text-zinc-700 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 md:inline-flex dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-200"
|
| 7 |
+
aria-label={label}
|
| 8 |
+
title={label}
|
| 9 |
+
>
|
| 10 |
+
{children}
|
| 11 |
+
</button>
|
| 12 |
+
);
|
| 13 |
+
}
|
frontend/components/GradientText.tsx
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { ReactNode } from 'react';
|
| 2 |
+
import { cn } from '@/lib/utils';
|
| 3 |
+
|
| 4 |
+
interface GradientTextProps {
|
| 5 |
+
children: ReactNode;
|
| 6 |
+
className?: string;
|
| 7 |
+
colors?: string[];
|
| 8 |
+
animationSpeed?: number;
|
| 9 |
+
showBorder?: boolean;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export default function GradientText({
|
| 13 |
+
children,
|
| 14 |
+
className = '',
|
| 15 |
+
colors = ['#ffaa40', '#9c40ff', '#ffaa40'],
|
| 16 |
+
animationSpeed = 8,
|
| 17 |
+
showBorder = false
|
| 18 |
+
}: GradientTextProps) {
|
| 19 |
+
const gradientStyle = {
|
| 20 |
+
backgroundImage: `linear-gradient(to right, ${colors.join(', ')})`,
|
| 21 |
+
animationDuration: `${animationSpeed}s`
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<div
|
| 26 |
+
className={cn(
|
| 27 |
+
"relative mx-auto flex max-w-fit flex-row items-center justify-center rounded-[1.25rem] font-medium backdrop-blur transition-shadow duration-500 overflow-hidden cursor-pointer",
|
| 28 |
+
className
|
| 29 |
+
)}
|
| 30 |
+
>
|
| 31 |
+
{showBorder && (
|
| 32 |
+
<div
|
| 33 |
+
className="absolute inset-0 bg-cover z-0 pointer-events-none animate-gradient"
|
| 34 |
+
style={{
|
| 35 |
+
...gradientStyle,
|
| 36 |
+
backgroundSize: '300% 100%'
|
| 37 |
+
}}
|
| 38 |
+
>
|
| 39 |
+
<div
|
| 40 |
+
className={cn(
|
| 41 |
+
"absolute inset-0 bg-black rounded-[1.25rem] z-[-1]",
|
| 42 |
+
// Try to match the parent's roundedness if possible, but for now hardcoded or we need to extract it.
|
| 43 |
+
// Since showBorder is false for the case in question, this part is less critical right now.
|
| 44 |
+
// But to be safe, let's just keep it as is or maybe allow overriding via a prop?
|
| 45 |
+
// For now, I'll leave the inner rounded hardcoded as it matches the default outer.
|
| 46 |
+
// If the user overrides outer, inner might look weird if showBorder is true.
|
| 47 |
+
// But showBorder is false for "RAG Powered".
|
| 48 |
+
)}
|
| 49 |
+
style={{
|
| 50 |
+
width: 'calc(100% - 2px)',
|
| 51 |
+
height: 'calc(100% - 2px)',
|
| 52 |
+
left: '50%',
|
| 53 |
+
top: '50%',
|
| 54 |
+
transform: 'translate(-50%, -50%)'
|
| 55 |
+
}}
|
| 56 |
+
></div>
|
| 57 |
+
</div>
|
| 58 |
+
)}
|
| 59 |
+
<div
|
| 60 |
+
className="inline-block relative z-2 text-transparent bg-cover animate-gradient"
|
| 61 |
+
style={{
|
| 62 |
+
...gradientStyle,
|
| 63 |
+
backgroundClip: 'text',
|
| 64 |
+
WebkitBackgroundClip: 'text',
|
| 65 |
+
backgroundSize: '300% 100%'
|
| 66 |
+
}}
|
| 67 |
+
>
|
| 68 |
+
{children}
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// tailwind.config.js
|
| 75 |
+
// module.exports = {
|
| 76 |
+
// theme: {
|
| 77 |
+
// extend: {
|
| 78 |
+
// keyframes: {
|
| 79 |
+
// gradient: {
|
| 80 |
+
// '0%': { backgroundPosition: '0% 50%' },
|
| 81 |
+
// '50%': { backgroundPosition: '100% 50%' },
|
| 82 |
+
// '100%': { backgroundPosition: '0% 50%' },
|
| 83 |
+
// },
|
| 84 |
+
// },
|
| 85 |
+
// animation: {
|
| 86 |
+
// gradient: 'gradient 8s linear infinite'
|
| 87 |
+
// },
|
| 88 |
+
// },
|
| 89 |
+
// },
|
| 90 |
+
// plugins: [],
|
| 91 |
+
// };
|
frontend/components/Header.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Link from 'next/link';
|
| 2 |
+
import { GithubButton } from '@/components/ui/github-button'; // Importing your new component
|
| 3 |
+
import { IconLeaf } from '@tabler/icons-react';
|
| 4 |
+
|
| 5 |
+
export default function Header() {
|
| 6 |
+
return (
|
| 7 |
+
<header className="absolute top-0 left-0 z-20 w-full p-4 md:p-6">
|
| 8 |
+
<nav className="flex w-full items-center justify-between">
|
| 9 |
+
|
| 10 |
+
{/* Left Side -- Name of Project */}
|
| 11 |
+
<Link
|
| 12 |
+
href="/"
|
| 13 |
+
className="flex items-center gap-2 px-4 py-2 rounded-full bg-black border border-gray-700 text-white transition-all hover:bg-neutral-900 hover:border-gray-500 hover:scale-105"
|
| 14 |
+
>
|
| 15 |
+
<IconLeaf className="w-5 h-5 text-green-400" />
|
| 16 |
+
<span className="text-lg font-bold tracking-wider">RAG Assistant</span>
|
| 17 |
+
</Link>
|
| 18 |
+
|
| 19 |
+
{/* Right Side -- Github link */}
|
| 20 |
+
<GithubButton
|
| 21 |
+
// see https://reui.io/docs/github-button for more variables
|
| 22 |
+
initialStars={1}
|
| 23 |
+
label=""
|
| 24 |
+
targetStars={5}
|
| 25 |
+
|
| 26 |
+
repoUrl="https://github.com/Qar-Raz/mlops_project.git"
|
| 27 |
+
|
| 28 |
+
filled = {true}
|
| 29 |
+
animationDuration= {5}
|
| 30 |
+
roundStars={true}
|
| 31 |
+
// below line can be commented out for clear black button --@Qamar
|
| 32 |
+
className="bg-gray-900/50 border-gray-700 text-gray-200 hover:bg-gray-800/50 hover:border-gray-600"
|
| 33 |
+
/>
|
| 34 |
+
|
| 35 |
+
</nav>
|
| 36 |
+
</header>
|
| 37 |
+
);
|
| 38 |
+
}
|
frontend/components/Header_Chatbot.jsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
import { Menu } from "lucide-react"
|
| 3 |
+
import { useState, useEffect } from "react"
|
| 4 |
+
import Link from 'next/link';
|
| 5 |
+
import { IconLeaf } from '@tabler/icons-react';
|
| 6 |
+
|
| 7 |
+
export default function Header({
|
| 8 |
+
createNewChat,
|
| 9 |
+
sidebarCollapsed,
|
| 10 |
+
setSidebarOpen,
|
| 11 |
+
selectedModel,
|
| 12 |
+
onModelChange,
|
| 13 |
+
modelOptions = [],
|
| 14 |
+
}) {
|
| 15 |
+
const [isMounted, setIsMounted] = useState(false)
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
setIsMounted(true)
|
| 19 |
+
}, [])
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<div className="sticky top-0 z-30 flex items-center gap-2 border-b border-zinc-200/60 bg-white/80 px-4 py-3 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70">
|
| 23 |
+
{isMounted && sidebarCollapsed && (
|
| 24 |
+
<button
|
| 25 |
+
onClick={() => setSidebarOpen(true)}
|
| 26 |
+
className="md:hidden inline-flex items-center justify-center rounded-lg p-2 hover:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:hover:bg-zinc-800"
|
| 27 |
+
aria-label="Open sidebar"
|
| 28 |
+
>
|
| 29 |
+
<Menu className="h-5 w-5" />
|
| 30 |
+
</button>
|
| 31 |
+
)}
|
| 32 |
+
|
| 33 |
+
<div className="ml-auto flex items-center gap-2">
|
| 34 |
+
<label htmlFor="header-model-selector" className="sr-only">
|
| 35 |
+
Select model
|
| 36 |
+
</label>
|
| 37 |
+
<select
|
| 38 |
+
id="header-model-selector"
|
| 39 |
+
value={selectedModel || ""}
|
| 40 |
+
onChange={(e) => onModelChange?.(e.target.value)}
|
| 41 |
+
className="max-w-[180px] rounded-lg border border-zinc-300 bg-white px-2 py-1.5 text-xs text-zinc-700 outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200"
|
| 42 |
+
>
|
| 43 |
+
{modelOptions.map((model) => (
|
| 44 |
+
<option key={model} value={model}>
|
| 45 |
+
{model}
|
| 46 |
+
</option>
|
| 47 |
+
))}
|
| 48 |
+
</select>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
)
|
| 52 |
+
}
|
frontend/components/Message.jsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { cls } from "./utils"
|
| 2 |
+
|
| 3 |
+
export default function Message({ role, children }) {
|
| 4 |
+
const isUser = role === "user"
|
| 5 |
+
return (
|
| 6 |
+
<div className={cls("flex gap-3", isUser ? "justify-end" : "justify-start")}>
|
| 7 |
+
{!isUser && (
|
| 8 |
+
<div className="mt-0.5 grid h-7 w-7 place-items-center rounded-full border border-blue-500 bg-zinc-950 text-[10px] font-bold text-blue-400 shrink-0">
|
| 9 |
+
RA
|
| 10 |
+
</div>
|
| 11 |
+
)}
|
| 12 |
+
<div className="flex flex-col gap-2 max-w-[85%]">
|
| 13 |
+
{children && (
|
| 14 |
+
<div
|
| 15 |
+
className={cls(
|
| 16 |
+
"rounded-2xl px-3 py-2 text-sm shadow-sm",
|
| 17 |
+
isUser
|
| 18 |
+
? "bg-zinc-900 text-white dark:bg-white dark:text-zinc-900"
|
| 19 |
+
: "bg-white text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800",
|
| 20 |
+
)}
|
| 21 |
+
>
|
| 22 |
+
{children}
|
| 23 |
+
</div>
|
| 24 |
+
)}
|
| 25 |
+
</div>
|
| 26 |
+
{isUser && (
|
| 27 |
+
<div className="mt-0.5 grid h-7 w-7 place-items-center rounded-full bg-zinc-900 text-[10px] font-bold text-white dark:bg-white dark:text-zinc-900 shrink-0">
|
| 28 |
+
QR
|
| 29 |
+
</div>
|
| 30 |
+
)}
|
| 31 |
+
</div>
|
| 32 |
+
)
|
| 33 |
+
}
|
frontend/components/SearchModal.jsx
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
import { motion, AnimatePresence } from "framer-motion"
|
| 3 |
+
import { X, SearchIcon, Plus, Clock } from "lucide-react"
|
| 4 |
+
import { useState, useEffect, useMemo } from "react"
|
| 5 |
+
|
| 6 |
+
function getTimeGroup(dateString) {
|
| 7 |
+
const date = new Date(dateString)
|
| 8 |
+
const now = new Date()
|
| 9 |
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
| 10 |
+
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000)
|
| 11 |
+
const sevenDaysAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
|
| 12 |
+
|
| 13 |
+
if (date >= today) return "Today"
|
| 14 |
+
if (date >= yesterday) return "Yesterday"
|
| 15 |
+
if (date >= sevenDaysAgo) return "Previous 7 Days"
|
| 16 |
+
return "Older"
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export default function SearchModal({
|
| 20 |
+
isOpen,
|
| 21 |
+
onClose,
|
| 22 |
+
conversations,
|
| 23 |
+
selectedId,
|
| 24 |
+
onSelect,
|
| 25 |
+
togglePin,
|
| 26 |
+
createNewChat,
|
| 27 |
+
}) {
|
| 28 |
+
const [query, setQuery] = useState("")
|
| 29 |
+
|
| 30 |
+
const filteredConversations = useMemo(() => {
|
| 31 |
+
if (!query.trim()) return conversations
|
| 32 |
+
const q = query.toLowerCase()
|
| 33 |
+
return conversations.filter((c) => c.title.toLowerCase().includes(q) || c.preview.toLowerCase().includes(q))
|
| 34 |
+
}, [conversations, query])
|
| 35 |
+
|
| 36 |
+
const groupedConversations = useMemo(() => {
|
| 37 |
+
const groups = {
|
| 38 |
+
Today: [],
|
| 39 |
+
Yesterday: [],
|
| 40 |
+
"Previous 7 Days": [],
|
| 41 |
+
Older: [],
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
filteredConversations
|
| 45 |
+
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))
|
| 46 |
+
.forEach((conv) => {
|
| 47 |
+
const group = getTimeGroup(conv.updatedAt)
|
| 48 |
+
groups[group].push(conv)
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
return groups
|
| 52 |
+
}, [filteredConversations])
|
| 53 |
+
|
| 54 |
+
const handleClose = () => {
|
| 55 |
+
setQuery("")
|
| 56 |
+
onClose()
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
const handleNewChat = () => {
|
| 60 |
+
createNewChat()
|
| 61 |
+
handleClose()
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
const handleSelectConversation = (id) => {
|
| 65 |
+
onSelect(id)
|
| 66 |
+
handleClose()
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
useEffect(() => {
|
| 70 |
+
const handleEscape = (e) => {
|
| 71 |
+
if (e.key === "Escape") handleClose()
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
if (isOpen) {
|
| 75 |
+
document.addEventListener("keydown", handleEscape)
|
| 76 |
+
return () => document.removeEventListener("keydown", handleEscape)
|
| 77 |
+
}
|
| 78 |
+
}, [isOpen])
|
| 79 |
+
|
| 80 |
+
return (
|
| 81 |
+
<AnimatePresence>
|
| 82 |
+
{isOpen && (
|
| 83 |
+
<>
|
| 84 |
+
<motion.div
|
| 85 |
+
initial={{ opacity: 0 }}
|
| 86 |
+
animate={{ opacity: 1 }}
|
| 87 |
+
exit={{ opacity: 0 }}
|
| 88 |
+
className="fixed inset-0 z-50 bg-black/60"
|
| 89 |
+
onClick={handleClose}
|
| 90 |
+
/>
|
| 91 |
+
<motion.div
|
| 92 |
+
initial={{ opacity: 0, scale: 0.95, y: -20 }}
|
| 93 |
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
| 94 |
+
exit={{ opacity: 0, scale: 0.95, y: -20 }}
|
| 95 |
+
className="fixed left-1/2 top-[20%] z-50 w-full max-w-2xl -translate-x-1/2 rounded-2xl border border-zinc-200 bg-white shadow-2xl dark:border-zinc-800 dark:bg-zinc-900"
|
| 96 |
+
>
|
| 97 |
+
{/* Search Header */}
|
| 98 |
+
<div className="flex items-center gap-3 border-b border-zinc-200 p-4 dark:border-zinc-800">
|
| 99 |
+
<SearchIcon className="h-5 w-5 text-zinc-400" />
|
| 100 |
+
<input
|
| 101 |
+
type="text"
|
| 102 |
+
value={query}
|
| 103 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 104 |
+
placeholder="Search chats..."
|
| 105 |
+
className="flex-1 bg-transparent text-lg outline-none placeholder:text-zinc-400"
|
| 106 |
+
autoFocus
|
| 107 |
+
/>
|
| 108 |
+
<button onClick={handleClose} className="rounded-lg p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
| 109 |
+
<X className="h-5 w-5" />
|
| 110 |
+
</button>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
{/* Search Results */}
|
| 114 |
+
<div className="max-h-[60vh] overflow-y-auto">
|
| 115 |
+
{/* New Chat Option */}
|
| 116 |
+
<div className="border-b border-zinc-200 p-2 dark:border-zinc-800">
|
| 117 |
+
<button
|
| 118 |
+
onClick={handleNewChat}
|
| 119 |
+
className="flex w-full items-center gap-3 rounded-lg p-3 text-left hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
| 120 |
+
>
|
| 121 |
+
<Plus className="h-5 w-5 text-zinc-500" />
|
| 122 |
+
<span className="font-medium">New chat</span>
|
| 123 |
+
</button>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
{/* Conversation Groups */}
|
| 127 |
+
{Object.entries(groupedConversations).map(([groupName, convs]) => {
|
| 128 |
+
if (convs.length === 0) return null
|
| 129 |
+
|
| 130 |
+
return (
|
| 131 |
+
<div key={groupName} className="border-b border-zinc-200 p-2 last:border-b-0 dark:border-zinc-800">
|
| 132 |
+
<div className="px-3 py-2 text-xs font-medium text-zinc-500 dark:text-zinc-400">{groupName}</div>
|
| 133 |
+
<div className="space-y-1">
|
| 134 |
+
{convs.map((conv) => (
|
| 135 |
+
<button
|
| 136 |
+
key={conv.id}
|
| 137 |
+
onClick={() => handleSelectConversation(conv.id)}
|
| 138 |
+
className="flex w-full items-center gap-3 rounded-lg p-3 text-left hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
| 139 |
+
>
|
| 140 |
+
<Clock className="h-4 w-4 text-zinc-400 shrink-0" />
|
| 141 |
+
<div className="min-w-0 flex-1">
|
| 142 |
+
<div className="truncate font-medium">{conv.title}</div>
|
| 143 |
+
<div className="truncate text-sm text-zinc-500 dark:text-zinc-400">{conv.preview}</div>
|
| 144 |
+
</div>
|
| 145 |
+
</button>
|
| 146 |
+
))}
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
)
|
| 150 |
+
})}
|
| 151 |
+
|
| 152 |
+
{/* Empty State */}
|
| 153 |
+
{filteredConversations.length === 0 && query.trim() && (
|
| 154 |
+
<div className="p-8 text-center">
|
| 155 |
+
<SearchIcon className="mx-auto h-12 w-12 text-zinc-300 dark:text-zinc-600" />
|
| 156 |
+
<div className="mt-4 text-lg font-medium text-zinc-900 dark:text-zinc-100">No chats found</div>
|
| 157 |
+
<div className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
|
| 158 |
+
Try searching with different keywords
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
)}
|
| 162 |
+
|
| 163 |
+
{/* Default State - Show all conversations when no query */}
|
| 164 |
+
{!query.trim() && conversations.length === 0 && (
|
| 165 |
+
<div className="p-8 text-center">
|
| 166 |
+
<div className="text-lg font-medium text-zinc-900 dark:text-zinc-100">No conversations yet</div>
|
| 167 |
+
<div className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">Start a new chat to begin</div>
|
| 168 |
+
</div>
|
| 169 |
+
)}
|
| 170 |
+
</div>
|
| 171 |
+
</motion.div>
|
| 172 |
+
</>
|
| 173 |
+
)}
|
| 174 |
+
</AnimatePresence>
|
| 175 |
+
)
|
| 176 |
+
}
|
frontend/components/SettingsPopover.jsx
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
import { useState } from "react"
|
| 3 |
+
import { User, BookOpen, LogOut, ChevronRight } from "lucide-react"
|
| 4 |
+
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"
|
| 5 |
+
|
| 6 |
+
export default function SettingsPopover({ children }) {
|
| 7 |
+
const [open, setOpen] = useState(false)
|
| 8 |
+
|
| 9 |
+
return (
|
| 10 |
+
<Popover open={open} onOpenChange={setOpen}>
|
| 11 |
+
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
| 12 |
+
<PopoverContent className="w-80 p-0" align="start" side="top">
|
| 13 |
+
<div className="p-4">
|
| 14 |
+
<div className="text-sm text-zinc-600 dark:text-zinc-400 mb-3">j@gmail.com</div>
|
| 15 |
+
|
| 16 |
+
<div className="flex items-center gap-3 p-3 rounded-lg bg-zinc-50 dark:bg-zinc-800/50 mb-4">
|
| 17 |
+
<div className="flex items-center gap-2">
|
| 18 |
+
<User className="h-4 w-4" />
|
| 19 |
+
<span className="text-sm font-medium">Qamar Raza</span>
|
| 20 |
+
</div>
|
| 21 |
+
<div className="text-blue-500">
|
| 22 |
+
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
| 23 |
+
<path
|
| 24 |
+
fillRule="evenodd"
|
| 25 |
+
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
| 26 |
+
clipRule="evenodd"
|
| 27 |
+
/>
|
| 28 |
+
</svg>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
|
| 32 |
+
<div className="space-y-1">
|
| 33 |
+
<div className="text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-2">Settings</div>
|
| 34 |
+
|
| 35 |
+
<button className="flex items-center gap-3 w-full p-2 text-sm text-left hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg">
|
| 36 |
+
<BookOpen className="h-4 w-4" />
|
| 37 |
+
<span>Learn more</span>
|
| 38 |
+
<ChevronRight className="h-4 w-4 ml-auto" />
|
| 39 |
+
</button>
|
| 40 |
+
|
| 41 |
+
<button className="flex items-center gap-3 w-full p-2 text-sm text-left hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg">
|
| 42 |
+
<LogOut className="h-4 w-4" />
|
| 43 |
+
<span>Log out</span>
|
| 44 |
+
</button>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
</PopoverContent>
|
| 48 |
+
</Popover>
|
| 49 |
+
)
|
| 50 |
+
}
|
frontend/components/Sidebar.jsx
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
import { motion, AnimatePresence } from "framer-motion"
|
| 3 |
+
import Link from 'next/link';
|
| 4 |
+
import { IconLeaf } from '@tabler/icons-react';
|
| 5 |
+
import {
|
| 6 |
+
PanelLeftClose,
|
| 7 |
+
PanelLeftOpen,
|
| 8 |
+
SearchIcon,
|
| 9 |
+
Plus,
|
| 10 |
+
Star,
|
| 11 |
+
Clock,
|
| 12 |
+
FolderIcon,
|
| 13 |
+
Settings,
|
| 14 |
+
Asterisk,
|
| 15 |
+
} from "lucide-react"
|
| 16 |
+
import SidebarSection from "./SidebarSection"
|
| 17 |
+
import ConversationRow from "./ConversationRow"
|
| 18 |
+
import ThemeToggle from "./ThemeToggle"
|
| 19 |
+
import CreateFolderModal from "./CreateFolderModal"
|
| 20 |
+
import CreateTemplateModal from "./CreateTemplateModal"
|
| 21 |
+
import SearchModal from "./SearchModal"
|
| 22 |
+
import SettingsPopover from "./SettingsPopover"
|
| 23 |
+
import { cls } from "./utils"
|
| 24 |
+
import { useState, useEffect } from "react"
|
| 25 |
+
|
| 26 |
+
// A custom hook to safely check screen size on the client side.
|
| 27 |
+
// This is the standard way to avoid hydration errors with responsive design.
|
| 28 |
+
const useMediaQuery = (query) => {
|
| 29 |
+
const [matches, setMatches] = useState(false);
|
| 30 |
+
|
| 31 |
+
useEffect(() => {
|
| 32 |
+
// Set the initial value once the component mounts on the client
|
| 33 |
+
const media = window.matchMedia(query);
|
| 34 |
+
if (media.matches !== matches) {
|
| 35 |
+
setMatches(media.matches);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// Set up a listener for changes in window size
|
| 39 |
+
const listener = () => setMatches(media.matches);
|
| 40 |
+
window.addEventListener("resize", listener);
|
| 41 |
+
|
| 42 |
+
// Cleanup the listener when the component unmounts
|
| 43 |
+
return () => window.removeEventListener("resize", listener);
|
| 44 |
+
}, [matches, query]);
|
| 45 |
+
|
| 46 |
+
return matches;
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
export default function Sidebar({
|
| 51 |
+
open,
|
| 52 |
+
onClose,
|
| 53 |
+
theme,
|
| 54 |
+
setTheme,
|
| 55 |
+
collapsed,
|
| 56 |
+
setCollapsed,
|
| 57 |
+
conversations,
|
| 58 |
+
pinned,
|
| 59 |
+
recent,
|
| 60 |
+
folders,
|
| 61 |
+
folderCounts,
|
| 62 |
+
selectedId,
|
| 63 |
+
onSelect,
|
| 64 |
+
togglePin,
|
| 65 |
+
deleteChat,
|
| 66 |
+
renameChat,
|
| 67 |
+
query,
|
| 68 |
+
setQuery,
|
| 69 |
+
searchRef,
|
| 70 |
+
createFolder,
|
| 71 |
+
createNewChat,
|
| 72 |
+
templates = [],
|
| 73 |
+
setTemplates = () => {},
|
| 74 |
+
onUseTemplate = () => {},
|
| 75 |
+
sidebarCollapsed = false,
|
| 76 |
+
setSidebarCollapsed = () => {},
|
| 77 |
+
}) {
|
| 78 |
+
const [showCreateFolderModal, setShowCreateFolderModal] = useState(false)
|
| 79 |
+
const [showCreateTemplateModal, setShowCreateTemplateModal] = useState(false)
|
| 80 |
+
const [editingTemplate, setEditingTemplate] = useState(null)
|
| 81 |
+
const [showSearchModal, setShowSearchModal] = useState(false)
|
| 82 |
+
|
| 83 |
+
// Use the hook to determine if we are on a desktop-sized screen.
|
| 84 |
+
// 768px is the standard 'md' breakpoint in Tailwind.
|
| 85 |
+
const isDesktop = useMediaQuery("(min-width: 768px)");
|
| 86 |
+
|
| 87 |
+
const getConversationsByFolder = (folderName) => {
|
| 88 |
+
return conversations.filter((conv) => conv.folder === folderName)
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
const handleCreateFolder = (folderName) => {
|
| 92 |
+
createFolder(folderName)
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
const handleDeleteFolder = (folderName) => {
|
| 96 |
+
const updatedConversations = conversations.map((conv) =>
|
| 97 |
+
conv.folder === folderName ? { ...conv, folder: null } : conv,
|
| 98 |
+
)
|
| 99 |
+
console.log("Delete folder:", folderName, "Updated conversations:", updatedConversations)
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
const handleRenameFolder = (oldName, newName) => {
|
| 103 |
+
const updatedConversations = conversations.map((conv) =>
|
| 104 |
+
conv.folder === oldName ? { ...conv, folder: newName } : conv,
|
| 105 |
+
)
|
| 106 |
+
console.log("Rename folder:", oldName, "to", newName, "Updated conversations:", updatedConversations)
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
const handleCreateTemplate = (templateData) => {
|
| 110 |
+
if (editingTemplate) {
|
| 111 |
+
const updatedTemplates = templates.map((t) =>
|
| 112 |
+
t.id === editingTemplate.id ? { ...templateData, id: editingTemplate.id } : t,
|
| 113 |
+
)
|
| 114 |
+
setTemplates(updatedTemplates)
|
| 115 |
+
setEditingTemplate(null)
|
| 116 |
+
} else {
|
| 117 |
+
const newTemplate = {
|
| 118 |
+
...templateData,
|
| 119 |
+
id: Date.now().toString(),
|
| 120 |
+
}
|
| 121 |
+
setTemplates([...templates, newTemplate])
|
| 122 |
+
}
|
| 123 |
+
setShowCreateTemplateModal(false)
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
const handleEditTemplate = (template) => {
|
| 127 |
+
setEditingTemplate(template)
|
| 128 |
+
setShowCreateTemplateModal(true)
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
const handleRenameTemplate = (templateId, newName) => {
|
| 132 |
+
const updatedTemplates = templates.map((t) =>
|
| 133 |
+
t.id === templateId ? { ...t, name: newName, updatedAt: new Date().toISOString() } : t,
|
| 134 |
+
)
|
| 135 |
+
setTemplates(updatedTemplates)
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
const handleDeleteTemplate = (templateId) => {
|
| 139 |
+
const updatedTemplates = templates.filter((t) => t.id !== templateId)
|
| 140 |
+
setTemplates(updatedTemplates)
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
const handleUseTemplate = (template) => {
|
| 144 |
+
onUseTemplate(template)
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
if (isDesktop && sidebarCollapsed) {
|
| 148 |
+
return (
|
| 149 |
+
<motion.aside
|
| 150 |
+
initial={{ width: 320 }}
|
| 151 |
+
animate={{ width: 64 }}
|
| 152 |
+
transition={{ type: "spring", stiffness: 260, damping: 28 }}
|
| 153 |
+
className="z-50 flex h-full shrink-0 flex-col border-r border-zinc-200/60 bg-white dark:border-zinc-800 dark:bg-zinc-900"
|
| 154 |
+
>
|
| 155 |
+
<div className="flex items-center justify-center border-b border-zinc-200/60 px-3 py-3 dark:border-zinc-800">
|
| 156 |
+
<button
|
| 157 |
+
onClick={() => setSidebarCollapsed(false)}
|
| 158 |
+
className="rounded-xl p-2 hover:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:hover:bg-zinc-800"
|
| 159 |
+
aria-label="Open sidebar"
|
| 160 |
+
title="Open sidebar"
|
| 161 |
+
>
|
| 162 |
+
<PanelLeftOpen className="h-5 w-5" />
|
| 163 |
+
</button>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
<div className="flex flex-col items-center gap-4 pt-4">
|
| 167 |
+
<button
|
| 168 |
+
onClick={createNewChat}
|
| 169 |
+
className="rounded-xl p-2 hover:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:hover:bg-zinc-800"
|
| 170 |
+
title="New Chat"
|
| 171 |
+
>
|
| 172 |
+
<Plus className="h-5 w-5" />
|
| 173 |
+
</button>
|
| 174 |
+
|
| 175 |
+
<button
|
| 176 |
+
onClick={() => setShowSearchModal(true)}
|
| 177 |
+
className="rounded-xl p-2 hover:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:hover:bg-zinc-800"
|
| 178 |
+
title="Search"
|
| 179 |
+
>
|
| 180 |
+
<SearchIcon className="h-5 w-5" />
|
| 181 |
+
</button>
|
| 182 |
+
|
| 183 |
+
<button
|
| 184 |
+
className="rounded-xl p-2 hover:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:hover:bg-zinc-800"
|
| 185 |
+
title="Folders"
|
| 186 |
+
>
|
| 187 |
+
<FolderIcon className="h-5 w-5" />
|
| 188 |
+
</button>
|
| 189 |
+
|
| 190 |
+
<div className="mt-auto mb-4">
|
| 191 |
+
<SettingsPopover>
|
| 192 |
+
<button
|
| 193 |
+
className="rounded-xl p-2 hover:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:hover:bg-zinc-800"
|
| 194 |
+
title="Settings"
|
| 195 |
+
>
|
| 196 |
+
<Settings className="h-5 w-5" />
|
| 197 |
+
</button>
|
| 198 |
+
</SettingsPopover>
|
| 199 |
+
</div>
|
| 200 |
+
</div>
|
| 201 |
+
</motion.aside>
|
| 202 |
+
)
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
return (
|
| 206 |
+
<>
|
| 207 |
+
{/* The mobile overlay should only appear on mobile screens when the menu is open */}
|
| 208 |
+
<AnimatePresence>
|
| 209 |
+
{!isDesktop && open && (
|
| 210 |
+
<motion.div
|
| 211 |
+
key="overlay"
|
| 212 |
+
initial={{ opacity: 0 }}
|
| 213 |
+
animate={{ opacity: 0.5 }}
|
| 214 |
+
exit={{ opacity: 0 }}
|
| 215 |
+
className="fixed inset-0 z-40 bg-black/60 md:hidden"
|
| 216 |
+
onClick={onClose}
|
| 217 |
+
/>
|
| 218 |
+
)}
|
| 219 |
+
</AnimatePresence>
|
| 220 |
+
|
| 221 |
+
{/* The sidebar should appear if it's desktop OR if the mobile menu is open */}
|
| 222 |
+
<AnimatePresence>
|
| 223 |
+
{(isDesktop || open) && (
|
| 224 |
+
<motion.aside
|
| 225 |
+
key="sidebar"
|
| 226 |
+
initial={{ x: -340 }}
|
| 227 |
+
animate={{ x: 0 }}
|
| 228 |
+
exit={{ x: -340 }}
|
| 229 |
+
transition={{ type: "spring", stiffness: 260, damping: 28 }}
|
| 230 |
+
className={cls(
|
| 231 |
+
"z-50 flex h-full w-80 shrink-0 flex-col border-r border-zinc-200/60 bg-white dark:border-zinc-800 dark:bg-zinc-900",
|
| 232 |
+
"fixed inset-y-0 left-0 md:static md:translate-x-0",
|
| 233 |
+
)}
|
| 234 |
+
>
|
| 235 |
+
<div className="flex items-center gap-2 border-b border-zinc-200/60 px-3 py-3 dark:border-zinc-800">
|
| 236 |
+
<Link
|
| 237 |
+
href="/"
|
| 238 |
+
className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-black border border-gray-700 text-white transition-all hover:bg-neutral-900 hover:border-gray-500 hover:scale-105"
|
| 239 |
+
>
|
| 240 |
+
<IconLeaf className="w-4 h-4 text-green-400" />
|
| 241 |
+
<span className="text-sm font-bold tracking-wider">RAG Assistant</span>
|
| 242 |
+
</Link>
|
| 243 |
+
<div className="ml-auto flex items-center gap-1">
|
| 244 |
+
<button
|
| 245 |
+
onClick={() => setSidebarCollapsed(true)}
|
| 246 |
+
className="hidden md:block rounded-xl p-2 hover:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:hover:bg-zinc-800"
|
| 247 |
+
aria-label="Close sidebar"
|
| 248 |
+
title="Close sidebar"
|
| 249 |
+
>
|
| 250 |
+
<PanelLeftClose className="h-5 w-5" />
|
| 251 |
+
</button>
|
| 252 |
+
|
| 253 |
+
<button
|
| 254 |
+
onClick={onClose}
|
| 255 |
+
className="md:hidden rounded-xl p-2 hover:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:hover:bg-zinc-800"
|
| 256 |
+
aria-label="Close sidebar"
|
| 257 |
+
>
|
| 258 |
+
<PanelLeftClose className="h-5 w-5" />
|
| 259 |
+
</button>
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
<div className="px-3 pt-3">
|
| 264 |
+
<label htmlFor="search" className="sr-only">
|
| 265 |
+
Search conversations
|
| 266 |
+
</label>
|
| 267 |
+
<div className="relative">
|
| 268 |
+
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400" />
|
| 269 |
+
<input
|
| 270 |
+
id="search"
|
| 271 |
+
ref={searchRef}
|
| 272 |
+
type="text"
|
| 273 |
+
value={query}
|
| 274 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 275 |
+
placeholder="Search…"
|
| 276 |
+
onClick={() => setShowSearchModal(true)}
|
| 277 |
+
onFocus={() => setShowSearchModal(true)}
|
| 278 |
+
className="w-full rounded-full border border-zinc-200 bg-white py-2 pl-9 pr-3 text-sm outline-none ring-0 placeholder:text-zinc-400 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 dark:border-zinc-800 dark:bg-zinc-950/50"
|
| 279 |
+
/>
|
| 280 |
+
</div>
|
| 281 |
+
</div>
|
| 282 |
+
|
| 283 |
+
<div className="px-3 pt-3">
|
| 284 |
+
<button
|
| 285 |
+
onClick={createNewChat}
|
| 286 |
+
className="flex w-full items-center justify-center gap-2 rounded-full bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:bg-white dark:text-zinc-900"
|
| 287 |
+
>
|
| 288 |
+
<Plus className="h-4 w-4" /> Start New Chat
|
| 289 |
+
</button>
|
| 290 |
+
</div>
|
| 291 |
+
|
| 292 |
+
<nav className="mt-4 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-2 pb-4">
|
| 293 |
+
<SidebarSection
|
| 294 |
+
icon={<Star className="h-4 w-4" />}
|
| 295 |
+
title="PINNED CHATS" // Renamed from "PINNED CONVERSATIONS" to "PINNED CHATS"
|
| 296 |
+
collapsed={collapsed.pinned}
|
| 297 |
+
onToggle={() => setCollapsed((s) => ({ ...s, pinned: !s.pinned }))}
|
| 298 |
+
>
|
| 299 |
+
{pinned.length === 0 ? (
|
| 300 |
+
<div className="select-none rounded-lg border border-dashed border-zinc-200 px-3 py-3 text-center text-xs text-zinc-500 dark:border-zinc-800 dark:text-zinc-400">
|
| 301 |
+
Pin important threads for quick access.
|
| 302 |
+
</div>
|
| 303 |
+
) : (
|
| 304 |
+
pinned.map((c) => (
|
| 305 |
+
<ConversationRow
|
| 306 |
+
key={c.id}
|
| 307 |
+
data={c}
|
| 308 |
+
active={c.id === selectedId}
|
| 309 |
+
onSelect={() => onSelect(c.id)}
|
| 310 |
+
onTogglePin={() => togglePin(c.id)}
|
| 311 |
+
onDelete={() => deleteChat(c.id)}
|
| 312 |
+
onRename={(newTitle) => renameChat?.(c.id, newTitle)}
|
| 313 |
+
/>
|
| 314 |
+
))
|
| 315 |
+
)}
|
| 316 |
+
</SidebarSection>
|
| 317 |
+
|
| 318 |
+
<SidebarSection
|
| 319 |
+
icon={null}
|
| 320 |
+
title="CHATS"
|
| 321 |
+
collapsed={collapsed.recent}
|
| 322 |
+
onToggle={() => setCollapsed((s) => ({ ...s, recent: !s.recent }))}
|
| 323 |
+
>
|
| 324 |
+
{recent.length === 0 ? (
|
| 325 |
+
<div className="select-none rounded-lg border border-dashed border-zinc-200 px-3 py-3 text-center text-xs text-zinc-500 dark:border-zinc-800 dark:text-zinc-400">
|
| 326 |
+
No conversations yet. Start a new one!
|
| 327 |
+
</div>
|
| 328 |
+
) : (
|
| 329 |
+
recent.map((c) => (
|
| 330 |
+
<ConversationRow
|
| 331 |
+
key={c.id}
|
| 332 |
+
data={c}
|
| 333 |
+
active={c.id === selectedId}
|
| 334 |
+
onSelect={() => onSelect(c.id)}
|
| 335 |
+
onTogglePin={() => togglePin(c.id)}
|
| 336 |
+
onDelete={() => deleteChat(c.id)}
|
| 337 |
+
onRename={(newTitle) => renameChat?.(c.id, newTitle)}
|
| 338 |
+
showMeta
|
| 339 |
+
/>
|
| 340 |
+
))
|
| 341 |
+
)}
|
| 342 |
+
</SidebarSection>
|
| 343 |
+
</nav>
|
| 344 |
+
|
| 345 |
+
<div className="mt-auto border-t border-zinc-200/60 px-3 py-3 dark:border-zinc-800">
|
| 346 |
+
<div className="flex items-center gap-2">
|
| 347 |
+
<SettingsPopover>
|
| 348 |
+
<button className="inline-flex items-center gap-2 rounded-lg px-2 py-2 text-sm hover:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:hover:bg-zinc-800">
|
| 349 |
+
<Settings className="h-4 w-4" /> Settings
|
| 350 |
+
</button>
|
| 351 |
+
</SettingsPopover>
|
| 352 |
+
<div className="ml-auto">
|
| 353 |
+
<ThemeToggle theme={theme} setTheme={setTheme} />
|
| 354 |
+
</div>
|
| 355 |
+
</div>
|
| 356 |
+
<div className="mt-2 flex items-center gap-2 rounded-xl bg-zinc-50 p-2 dark:bg-zinc-800/60">
|
| 357 |
+
<div className="grid h-8 w-8 place-items-center rounded-full bg-zinc-900 text-xs font-bold text-white dark:bg-white dark:text-zinc-900">
|
| 358 |
+
QR
|
| 359 |
+
</div>
|
| 360 |
+
<div className="min-w-0">
|
| 361 |
+
<div className="truncate text-sm font-medium">Qamar Raza</div>
|
| 362 |
+
</div>
|
| 363 |
+
</div>
|
| 364 |
+
</div>
|
| 365 |
+
</motion.aside>
|
| 366 |
+
)}
|
| 367 |
+
</AnimatePresence>
|
| 368 |
+
|
| 369 |
+
<CreateFolderModal
|
| 370 |
+
isOpen={showCreateFolderModal}
|
| 371 |
+
onClose={() => setShowCreateFolderModal(false)}
|
| 372 |
+
onCreateFolder={handleCreateFolder}
|
| 373 |
+
/>
|
| 374 |
+
|
| 375 |
+
<CreateTemplateModal
|
| 376 |
+
isOpen={showCreateTemplateModal}
|
| 377 |
+
onClose={() => {
|
| 378 |
+
setShowCreateTemplateModal(false)
|
| 379 |
+
setEditingTemplate(null)
|
| 380 |
+
}}
|
| 381 |
+
onCreateTemplate={handleCreateTemplate}
|
| 382 |
+
editingTemplate={editingTemplate}
|
| 383 |
+
/>
|
| 384 |
+
|
| 385 |
+
<SearchModal
|
| 386 |
+
isOpen={showSearchModal}
|
| 387 |
+
onClose={() => setShowSearchModal(false)}
|
| 388 |
+
conversations={conversations}
|
| 389 |
+
selectedId={selectedId}
|
| 390 |
+
onSelect={onSelect}
|
| 391 |
+
togglePin={togglePin}
|
| 392 |
+
createNewChat={createNewChat}
|
| 393 |
+
/>
|
| 394 |
+
</>
|
| 395 |
+
)
|
| 396 |
+
}
|
frontend/components/SidebarSection.jsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { AnimatePresence, motion } from "framer-motion";
|
| 3 |
+
import { ChevronDown, ChevronRight } from "lucide-react";
|
| 4 |
+
|
| 5 |
+
export default function SidebarSection({ icon, title, children, collapsed, onToggle }) {
|
| 6 |
+
return (
|
| 7 |
+
<section>
|
| 8 |
+
<button
|
| 9 |
+
onClick={onToggle}
|
| 10 |
+
className="sticky top-0 z-10 -mx-2 mb-1 flex w-[calc(100%+16px)] items-center gap-2 border-y border-transparent bg-gradient-to-b from-white to-white/70 px-2 py-2 text-[11px] font-semibold tracking-wide text-zinc-500 backdrop-blur hover:text-zinc-700 dark:from-zinc-900 dark:to-zinc-900/70 dark:hover:text-zinc-300"
|
| 11 |
+
aria-expanded={!collapsed}
|
| 12 |
+
>
|
| 13 |
+
<span className="mr-1" aria-hidden>
|
| 14 |
+
{collapsed ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
| 15 |
+
</span>
|
| 16 |
+
<span className="flex items-center gap-2">
|
| 17 |
+
<span className="opacity-70" aria-hidden>
|
| 18 |
+
{icon}
|
| 19 |
+
</span>
|
| 20 |
+
{title}
|
| 21 |
+
</span>
|
| 22 |
+
</button>
|
| 23 |
+
<AnimatePresence initial={false}>
|
| 24 |
+
{!collapsed && (
|
| 25 |
+
<motion.div
|
| 26 |
+
initial={{ height: 0, opacity: 0 }}
|
| 27 |
+
animate={{ height: "auto", opacity: 1 }}
|
| 28 |
+
exit={{ height: 0, opacity: 0 }}
|
| 29 |
+
transition={{ duration: 0.18 }}
|
| 30 |
+
className="space-y-0.5"
|
| 31 |
+
>
|
| 32 |
+
{children}
|
| 33 |
+
</motion.div>
|
| 34 |
+
)}
|
| 35 |
+
</AnimatePresence>
|
| 36 |
+
</section>
|
| 37 |
+
);
|
| 38 |
+
}
|
frontend/components/TemplateRow.jsx
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useRef, useEffect } from "react"
|
| 4 |
+
import { FileText, MoreHorizontal, Copy, Edit3, Trash2 } from "lucide-react"
|
| 5 |
+
import { motion, AnimatePresence } from "framer-motion"
|
| 6 |
+
|
| 7 |
+
export default function TemplateRow({ template, onUseTemplate, onEditTemplate, onRenameTemplate, onDeleteTemplate }) {
|
| 8 |
+
const [showMenu, setShowMenu] = useState(false)
|
| 9 |
+
const menuRef = useRef(null)
|
| 10 |
+
|
| 11 |
+
useEffect(() => {
|
| 12 |
+
const handleClickOutside = (event) => {
|
| 13 |
+
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
| 14 |
+
setShowMenu(false)
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
if (showMenu) {
|
| 19 |
+
document.addEventListener("mousedown", handleClickOutside)
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
return () => {
|
| 23 |
+
document.removeEventListener("mousedown", handleClickOutside)
|
| 24 |
+
}
|
| 25 |
+
}, [showMenu])
|
| 26 |
+
|
| 27 |
+
const handleUse = () => {
|
| 28 |
+
onUseTemplate?.(template)
|
| 29 |
+
setShowMenu(false)
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
const handleEdit = () => {
|
| 33 |
+
onEditTemplate?.(template)
|
| 34 |
+
setShowMenu(false)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const handleRename = () => {
|
| 38 |
+
const newName = prompt(`Rename template "${template.name}" to:`, template.name)
|
| 39 |
+
if (newName && newName.trim() && newName !== template.name) {
|
| 40 |
+
onRenameTemplate?.(template.id, newName.trim())
|
| 41 |
+
}
|
| 42 |
+
setShowMenu(false)
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const handleDelete = () => {
|
| 46 |
+
if (confirm(`Are you sure you want to delete the template "${template.name}"?`)) {
|
| 47 |
+
onDeleteTemplate?.(template.id)
|
| 48 |
+
}
|
| 49 |
+
setShowMenu(false)
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<div className="group">
|
| 54 |
+
<div className="flex items-center justify-between rounded-lg px-2 py-2 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
| 55 |
+
<button
|
| 56 |
+
onClick={handleUse}
|
| 57 |
+
className="flex items-center gap-2 flex-1 text-left min-w-0"
|
| 58 |
+
title={`Use template: ${template.snippet}`}
|
| 59 |
+
>
|
| 60 |
+
<FileText className="h-4 w-4 text-zinc-500 shrink-0" />
|
| 61 |
+
<div className="min-w-0 flex-1">
|
| 62 |
+
<div className="truncate font-medium">{template.name}</div>
|
| 63 |
+
<div className="truncate text-xs text-zinc-500 dark:text-zinc-400">{template.snippet}</div>
|
| 64 |
+
</div>
|
| 65 |
+
</button>
|
| 66 |
+
|
| 67 |
+
<div className="flex items-center gap-1">
|
| 68 |
+
<span className="hidden group-hover:inline text-xs text-zinc-500 dark:text-zinc-400 px-1">Use</span>
|
| 69 |
+
|
| 70 |
+
<div className="relative" ref={menuRef}>
|
| 71 |
+
<button
|
| 72 |
+
onClick={(e) => {
|
| 73 |
+
e.stopPropagation()
|
| 74 |
+
setShowMenu(!showMenu)
|
| 75 |
+
}}
|
| 76 |
+
className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-opacity"
|
| 77 |
+
>
|
| 78 |
+
<MoreHorizontal className="h-3 w-3" />
|
| 79 |
+
</button>
|
| 80 |
+
|
| 81 |
+
<AnimatePresence>
|
| 82 |
+
{showMenu && (
|
| 83 |
+
<motion.div
|
| 84 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
| 85 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 86 |
+
exit={{ opacity: 0, scale: 0.95 }}
|
| 87 |
+
className="absolute right-0 top-full mt-1 w-36 rounded-lg border border-zinc-200 bg-white py-1 shadow-lg dark:border-zinc-800 dark:bg-zinc-900 z-[100]"
|
| 88 |
+
>
|
| 89 |
+
<button
|
| 90 |
+
onClick={handleUse}
|
| 91 |
+
className="w-full px-3 py-1.5 text-left text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800 flex items-center gap-2"
|
| 92 |
+
>
|
| 93 |
+
<Copy className="h-3 w-3" />
|
| 94 |
+
Use Template
|
| 95 |
+
</button>
|
| 96 |
+
<button
|
| 97 |
+
onClick={handleEdit}
|
| 98 |
+
className="w-full px-3 py-1.5 text-left text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800 flex items-center gap-2"
|
| 99 |
+
>
|
| 100 |
+
<Edit3 className="h-3 w-3" />
|
| 101 |
+
Edit
|
| 102 |
+
</button>
|
| 103 |
+
<button
|
| 104 |
+
onClick={handleRename}
|
| 105 |
+
className="w-full px-3 py-1.5 text-left text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
| 106 |
+
>
|
| 107 |
+
Rename
|
| 108 |
+
</button>
|
| 109 |
+
<button
|
| 110 |
+
onClick={handleDelete}
|
| 111 |
+
className="w-full px-3 py-1.5 text-left text-xs text-red-600 hover:bg-zinc-100 dark:hover:bg-zinc-800 flex items-center gap-2"
|
| 112 |
+
>
|
| 113 |
+
<Trash2 className="h-3 w-3" />
|
| 114 |
+
Delete
|
| 115 |
+
</button>
|
| 116 |
+
</motion.div>
|
| 117 |
+
)}
|
| 118 |
+
</AnimatePresence>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
)
|
| 124 |
+
}
|
frontend/components/ThemeToggle.jsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client" // This directive is important for using hooks
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from "react"; // 1. Import useState and useEffect
|
| 4 |
+
import { Sun, Moon } from "lucide-react";
|
| 5 |
+
|
| 6 |
+
export default function ThemeToggle({ theme, setTheme }) {
|
| 7 |
+
// 2. State to track if the component has mounted on the client
|
| 8 |
+
const [isMounted, setIsMounted] = useState(false);
|
| 9 |
+
|
| 10 |
+
// 3. This effect runs only once on the client after the initial render
|
| 11 |
+
useEffect(() => {
|
| 12 |
+
setIsMounted(true);
|
| 13 |
+
}, []);
|
| 14 |
+
|
| 15 |
+
// 4. A simple loading state to prevent a "layout jump"
|
| 16 |
+
// While isMounted is false, we render a placeholder with the same size.
|
| 17 |
+
if (!isMounted) {
|
| 18 |
+
return (
|
| 19 |
+
<button
|
| 20 |
+
className="inline-flex h-[34px] w-[34px] items-center gap-2 rounded-full border border-zinc-200 bg-white px-2.5 py-1.5 dark:border-zinc-800 dark:bg-zinc-950 sm:w-auto"
|
| 21 |
+
aria-label="Toggle theme"
|
| 22 |
+
title="Toggle theme"
|
| 23 |
+
disabled // Disable the button while it's not ready
|
| 24 |
+
>
|
| 25 |
+
<div className="h-4 w-4" /> {/* Placeholder for the icon */}
|
| 26 |
+
<span className="hidden sm:inline">...</span> {/* Placeholder for the text */}
|
| 27 |
+
</button>
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
// 5. Once mounted, render the actual component with the correct theme
|
| 32 |
+
return (
|
| 33 |
+
<button
|
| 34 |
+
className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-2.5 py-1.5 text-sm hover:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800"
|
| 35 |
+
onClick={() => setTheme((t) => (t === "dark" ? "light" : "dark"))}
|
| 36 |
+
aria-label="Toggle theme"
|
| 37 |
+
title="Toggle theme"
|
| 38 |
+
>
|
| 39 |
+
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
| 40 |
+
<span className="hidden sm:inline">{theme === "dark" ? "Light" : "Dark"}</span>
|
| 41 |
+
</button>
|
| 42 |
+
);
|
| 43 |
+
}
|
frontend/components/mockData.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { makeId } from "./utils"
|
| 2 |
+
|
| 3 |
+
export const INITIAL_CONVERSATIONS = []
|
| 4 |
+
|
| 5 |
+
export const INITIAL_TEMPLATES = [
|
| 6 |
+
{
|
| 7 |
+
id: "t1",
|
| 8 |
+
name: "Bug Report",
|
| 9 |
+
content: `**Bug Report**
|
| 10 |
+
|
| 11 |
+
**Description:**
|
| 12 |
+
Brief description of the issue
|
| 13 |
+
|
| 14 |
+
**Steps to Reproduce:**
|
| 15 |
+
1. Step one
|
| 16 |
+
2. Step two
|
| 17 |
+
3. Step three
|
| 18 |
+
|
| 19 |
+
**Expected Behavior:**
|
| 20 |
+
What should happen
|
| 21 |
+
|
| 22 |
+
**Actual Behavior:**
|
| 23 |
+
What actually happens
|
| 24 |
+
|
| 25 |
+
**Environment:**
|
| 26 |
+
- Browser/OS:
|
| 27 |
+
- Version:
|
| 28 |
+
- Additional context:`,
|
| 29 |
+
snippet: "Structured bug report template with steps to reproduce...",
|
| 30 |
+
createdAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
| 31 |
+
updatedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
id: "t2",
|
| 35 |
+
name: "Daily Standup",
|
| 36 |
+
content: `**Daily Standup Update**
|
| 37 |
+
|
| 38 |
+
**Yesterday:**
|
| 39 |
+
- Completed task A
|
| 40 |
+
- Made progress on task B
|
| 41 |
+
|
| 42 |
+
**Today:**
|
| 43 |
+
- Plan to work on task C
|
| 44 |
+
- Continue with task B
|
| 45 |
+
|
| 46 |
+
**Blockers:**
|
| 47 |
+
- None / List any blockers here
|
| 48 |
+
|
| 49 |
+
**Notes:**
|
| 50 |
+
Any additional context or updates`,
|
| 51 |
+
snippet: "Daily standup format with yesterday, today, and blockers...",
|
| 52 |
+
createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
| 53 |
+
updatedAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
id: "t3",
|
| 57 |
+
name: "Code Review",
|
| 58 |
+
content: `**Code Review Checklist**
|
| 59 |
+
|
| 60 |
+
**Scope:**
|
| 61 |
+
What changes are being reviewed
|
| 62 |
+
|
| 63 |
+
**Key Areas to Focus:**
|
| 64 |
+
- Logic correctness
|
| 65 |
+
- Performance implications
|
| 66 |
+
- Security considerations
|
| 67 |
+
- Test coverage
|
| 68 |
+
|
| 69 |
+
**Questions:**
|
| 70 |
+
- Any specific concerns?
|
| 71 |
+
- Performance impact?
|
| 72 |
+
- Breaking changes?
|
| 73 |
+
|
| 74 |
+
**Testing:**
|
| 75 |
+
- Unit tests added/updated?
|
| 76 |
+
- Manual testing completed?`,
|
| 77 |
+
snippet: "Comprehensive code review checklist and questions...",
|
| 78 |
+
createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
| 79 |
+
updatedAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
id: "t4",
|
| 83 |
+
name: "Meeting Notes",
|
| 84 |
+
content: `**Meeting Notes - [Meeting Title]**
|
| 85 |
+
|
| 86 |
+
**Date:** [Date]
|
| 87 |
+
**Attendees:** [List attendees]
|
| 88 |
+
|
| 89 |
+
**Agenda:**
|
| 90 |
+
1. Topic 1
|
| 91 |
+
2. Topic 2
|
| 92 |
+
3. Topic 3
|
| 93 |
+
|
| 94 |
+
**Key Decisions:**
|
| 95 |
+
- Decision 1
|
| 96 |
+
- Decision 2
|
| 97 |
+
|
| 98 |
+
**Action Items:**
|
| 99 |
+
- [ ] Task 1 - @person - Due: [date]
|
| 100 |
+
- [ ] Task 2 - @person - Due: [date]
|
| 101 |
+
|
| 102 |
+
**Next Steps:**
|
| 103 |
+
What happens next
|
| 104 |
+
|
| 105 |
+
**Notes:**
|
| 106 |
+
Additional context and discussion points`,
|
| 107 |
+
snippet: "Meeting notes template with agenda, decisions, and action items...",
|
| 108 |
+
createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
|
| 109 |
+
updatedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
|
| 110 |
+
},
|
| 111 |
+
]
|
| 112 |
+
|
| 113 |
+
export const INITIAL_FOLDERS = [
|
| 114 |
+
{ id: "f1", name: "Work Projects" },
|
| 115 |
+
{ id: "f2", name: "Personal" },
|
| 116 |
+
{ id: "f3", name: "Code Reviews" },
|
| 117 |
+
]
|
frontend/components/ui/github-button.tsx
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import React, { useCallback, useEffect, useState } from 'react';
|
| 4 |
+
import { cva, type VariantProps } from 'class-variance-authority';
|
| 5 |
+
import { Star } from 'lucide-react';
|
| 6 |
+
import { motion, useInView, type SpringOptions, type UseInViewOptions } from 'motion/react';
|
| 7 |
+
import { cn } from '@/lib/utils';
|
| 8 |
+
|
| 9 |
+
const githubButtonVariants = cva(
|
| 10 |
+
'cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu transition-transform duration-200 ease-out hover:scale-105 group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0',
|
| 11 |
+
{
|
| 12 |
+
variants: {
|
| 13 |
+
variant: {
|
| 14 |
+
default:
|
| 15 |
+
'bg-zinc-950 hover:bg-zinc-900 text-white border-gray-700 dark:bg-zinc-50 dark:border-gray-300 dark:text-zinc-950 dark:hover:bg-zinc-50',
|
| 16 |
+
outline: 'bg-background text-accent-foreground border border-input hover:bg-accent',
|
| 17 |
+
},
|
| 18 |
+
size: {
|
| 19 |
+
default: 'h-8.5 rounded-md px-3 gap-2 text-[0.8125rem] leading-none [&_svg]:size-4 gap-2',
|
| 20 |
+
sm: 'h-7 rounded-md px-2.5 gap-1.5 text-xs leading-none [&_svg]:size-3.5 gap-1.5',
|
| 21 |
+
lg: 'h-10 rounded-md px-4 gap-2.5 text-sm leading-none [&_svg]:size-5 gap-2.5',
|
| 22 |
+
},
|
| 23 |
+
},
|
| 24 |
+
defaultVariants: {
|
| 25 |
+
variant: 'default',
|
| 26 |
+
size: 'default',
|
| 27 |
+
},
|
| 28 |
+
},
|
| 29 |
+
);
|
| 30 |
+
|
| 31 |
+
interface GithubButtonProps extends React.ComponentProps<'button'>, VariantProps<typeof githubButtonVariants> {
|
| 32 |
+
/** Whether to round stars */
|
| 33 |
+
roundStars?: boolean;
|
| 34 |
+
/** Whether to show Github icon */
|
| 35 |
+
fixedWidth?: boolean;
|
| 36 |
+
/** Initial number of stars */
|
| 37 |
+
initialStars?: number;
|
| 38 |
+
/** Class for stars */
|
| 39 |
+
starsClass?: string;
|
| 40 |
+
/** Target number of stars to animate to */
|
| 41 |
+
targetStars?: number;
|
| 42 |
+
/** Animation duration in seconds */
|
| 43 |
+
animationDuration?: number;
|
| 44 |
+
/** Animation delay in seconds */
|
| 45 |
+
animationDelay?: number;
|
| 46 |
+
/** Whether to start animation automatically */
|
| 47 |
+
autoAnimate?: boolean;
|
| 48 |
+
/** Callback when animation completes */
|
| 49 |
+
onAnimationComplete?: () => void;
|
| 50 |
+
/** Whether to show Github icon */
|
| 51 |
+
showGithubIcon?: boolean;
|
| 52 |
+
/** Whether to show star icon */
|
| 53 |
+
showStarIcon?: boolean;
|
| 54 |
+
/** Whether to show separator */
|
| 55 |
+
separator?: boolean;
|
| 56 |
+
/** Whether stars should be filled */
|
| 57 |
+
filled?: boolean;
|
| 58 |
+
/** Repository URL for actual Github integration */
|
| 59 |
+
repoUrl?: string;
|
| 60 |
+
/** Button text label */
|
| 61 |
+
label?: string;
|
| 62 |
+
/** Use in-view detection to trigger animation */
|
| 63 |
+
useInViewTrigger?: boolean;
|
| 64 |
+
/** In-view options */
|
| 65 |
+
inViewOptions?: UseInViewOptions;
|
| 66 |
+
/** Spring transition options */
|
| 67 |
+
transition?: SpringOptions;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
function GithubButton({
|
| 71 |
+
initialStars = 0,
|
| 72 |
+
targetStars = 0,
|
| 73 |
+
starsClass = '',
|
| 74 |
+
fixedWidth = true,
|
| 75 |
+
animationDuration = 2,
|
| 76 |
+
animationDelay = 0,
|
| 77 |
+
autoAnimate = true,
|
| 78 |
+
className,
|
| 79 |
+
variant = 'default',
|
| 80 |
+
size = 'default',
|
| 81 |
+
showGithubIcon = true,
|
| 82 |
+
showStarIcon = true,
|
| 83 |
+
roundStars = false,
|
| 84 |
+
separator = false,
|
| 85 |
+
filled = false,
|
| 86 |
+
repoUrl,
|
| 87 |
+
onClick,
|
| 88 |
+
label = '',
|
| 89 |
+
useInViewTrigger = false,
|
| 90 |
+
inViewOptions = { once: true },
|
| 91 |
+
transition,
|
| 92 |
+
...props
|
| 93 |
+
}: GithubButtonProps) {
|
| 94 |
+
const [currentStars, setCurrentStars] = useState(initialStars);
|
| 95 |
+
const [isAnimating, setIsAnimating] = useState(false);
|
| 96 |
+
const [starProgress, setStarProgress] = useState(filled ? 100 : 0);
|
| 97 |
+
const [hasAnimated, setHasAnimated] = useState(false);
|
| 98 |
+
|
| 99 |
+
// Format number with units
|
| 100 |
+
const formatNumber = (num: number) => {
|
| 101 |
+
const units = ['k', 'M', 'B', 'T'];
|
| 102 |
+
|
| 103 |
+
if (roundStars && num >= 1000) {
|
| 104 |
+
let unitIndex = -1;
|
| 105 |
+
let value = num;
|
| 106 |
+
|
| 107 |
+
while (value >= 1000 && unitIndex < units.length - 1) {
|
| 108 |
+
value /= 1000;
|
| 109 |
+
unitIndex++;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// Format to 1 decimal place if needed, otherwise show whole number
|
| 113 |
+
const formatted = value % 1 === 0 ? value.toString() : value.toFixed(1);
|
| 114 |
+
return `${formatted}${units[unitIndex]}`;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
// Start animation
|
| 121 |
+
const startAnimation = useCallback(() => {
|
| 122 |
+
if (isAnimating || hasAnimated) return;
|
| 123 |
+
|
| 124 |
+
setIsAnimating(true);
|
| 125 |
+
const startTime = Date.now();
|
| 126 |
+
const startValue = 0; // Always start from 0 for number animation
|
| 127 |
+
const endValue = targetStars;
|
| 128 |
+
const duration = animationDuration * 1000;
|
| 129 |
+
|
| 130 |
+
const animate = () => {
|
| 131 |
+
const elapsed = Date.now() - startTime;
|
| 132 |
+
const progress = Math.min(elapsed / duration, 1);
|
| 133 |
+
|
| 134 |
+
// Easing function for smooth animation
|
| 135 |
+
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
| 136 |
+
|
| 137 |
+
// Update star count from 0 to target with more frequent updates
|
| 138 |
+
const newStars = Math.round(startValue + (endValue - startValue) * easeOutQuart);
|
| 139 |
+
setCurrentStars(newStars);
|
| 140 |
+
|
| 141 |
+
// Update star fill progress (0 to 100)
|
| 142 |
+
setStarProgress(progress * 100);
|
| 143 |
+
|
| 144 |
+
if (progress < 1) {
|
| 145 |
+
requestAnimationFrame(animate);
|
| 146 |
+
} else {
|
| 147 |
+
setCurrentStars(endValue);
|
| 148 |
+
setStarProgress(100);
|
| 149 |
+
setIsAnimating(false);
|
| 150 |
+
setHasAnimated(true);
|
| 151 |
+
}
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
setTimeout(() => {
|
| 155 |
+
requestAnimationFrame(animate);
|
| 156 |
+
}, animationDelay * 1000);
|
| 157 |
+
}, [isAnimating, hasAnimated, targetStars, animationDuration, animationDelay]);
|
| 158 |
+
|
| 159 |
+
// Use in-view detection if enabled
|
| 160 |
+
const ref = React.useRef(null);
|
| 161 |
+
const isInView = useInView(ref, inViewOptions);
|
| 162 |
+
|
| 163 |
+
// Reset animation state when targetStars changes
|
| 164 |
+
useEffect(() => {
|
| 165 |
+
setHasAnimated(false);
|
| 166 |
+
setCurrentStars(initialStars);
|
| 167 |
+
}, [targetStars, initialStars]);
|
| 168 |
+
|
| 169 |
+
// Auto-start animation or use in-view trigger
|
| 170 |
+
useEffect(() => {
|
| 171 |
+
if (useInViewTrigger) {
|
| 172 |
+
if (isInView && !hasAnimated) {
|
| 173 |
+
startAnimation();
|
| 174 |
+
}
|
| 175 |
+
} else if (autoAnimate && !hasAnimated) {
|
| 176 |
+
startAnimation();
|
| 177 |
+
}
|
| 178 |
+
}, [autoAnimate, useInViewTrigger, isInView, hasAnimated, startAnimation]);
|
| 179 |
+
|
| 180 |
+
const navigateToRepo = () => {
|
| 181 |
+
if (!repoUrl) {
|
| 182 |
+
return;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
// Next.js compatible navigation approach
|
| 186 |
+
try {
|
| 187 |
+
// Create a temporary anchor element for reliable navigation
|
| 188 |
+
const link = document.createElement('a');
|
| 189 |
+
link.href = repoUrl;
|
| 190 |
+
link.target = '_blank';
|
| 191 |
+
link.rel = 'noopener noreferrer';
|
| 192 |
+
|
| 193 |
+
// Temporarily add to DOM and click
|
| 194 |
+
document.body.appendChild(link);
|
| 195 |
+
link.click();
|
| 196 |
+
document.body.removeChild(link);
|
| 197 |
+
} catch {
|
| 198 |
+
// Fallback to window.open
|
| 199 |
+
try {
|
| 200 |
+
window.open(repoUrl, '_blank', 'noopener,noreferrer');
|
| 201 |
+
} catch {
|
| 202 |
+
// Final fallback
|
| 203 |
+
window.location.href = repoUrl;
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
};
|
| 207 |
+
|
| 208 |
+
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
| 209 |
+
if (onClick) {
|
| 210 |
+
onClick(event);
|
| 211 |
+
return;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
if (repoUrl) {
|
| 215 |
+
navigateToRepo();
|
| 216 |
+
} else if (!hasAnimated) {
|
| 217 |
+
startAnimation();
|
| 218 |
+
}
|
| 219 |
+
};
|
| 220 |
+
|
| 221 |
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
| 222 |
+
// Handle Enter and Space key presses for accessibility
|
| 223 |
+
if (event.key === 'Enter' || event.key === ' ') {
|
| 224 |
+
event.preventDefault();
|
| 225 |
+
|
| 226 |
+
if (repoUrl) {
|
| 227 |
+
navigateToRepo();
|
| 228 |
+
} else if (!hasAnimated) {
|
| 229 |
+
startAnimation();
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
};
|
| 233 |
+
|
| 234 |
+
return (
|
| 235 |
+
<button
|
| 236 |
+
ref={ref}
|
| 237 |
+
className={cn(githubButtonVariants({ variant, size, className }), separator && 'ps-0')}
|
| 238 |
+
onClick={handleClick}
|
| 239 |
+
onKeyDown={handleKeyDown}
|
| 240 |
+
role="button"
|
| 241 |
+
tabIndex={0}
|
| 242 |
+
aria-label={repoUrl ? `Star ${label} on GitHub` : label}
|
| 243 |
+
{...props}
|
| 244 |
+
>
|
| 245 |
+
{showGithubIcon && (
|
| 246 |
+
<div
|
| 247 |
+
className={cn(
|
| 248 |
+
'h-full relative flex items-center justify-center',
|
| 249 |
+
separator && 'w-9 bg-muted/60 border-e border-input',
|
| 250 |
+
)}
|
| 251 |
+
>
|
| 252 |
+
<svg role="img" viewBox="0 0 24 24" fill="currentColor">
|
| 253 |
+
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
| 254 |
+
</svg>
|
| 255 |
+
</div>
|
| 256 |
+
)}
|
| 257 |
+
|
| 258 |
+
{label && <span>{label}</span>}
|
| 259 |
+
|
| 260 |
+
{/* Animated Star Icon */}
|
| 261 |
+
{showStarIcon && (
|
| 262 |
+
<div className="relative inline-flex shrink-0">
|
| 263 |
+
<Star className="fill-muted-foreground text-muted-foreground" aria-hidden="true" />
|
| 264 |
+
<Star
|
| 265 |
+
className="absolute top-0 start-0 text-yellow-400 fill-yellow-400"
|
| 266 |
+
size={18}
|
| 267 |
+
aria-hidden="true"
|
| 268 |
+
style={{
|
| 269 |
+
clipPath: `inset(${100 - starProgress}% 0 0 0)`,
|
| 270 |
+
}}
|
| 271 |
+
/>
|
| 272 |
+
</div>
|
| 273 |
+
)}
|
| 274 |
+
|
| 275 |
+
{/* Animated Number Counter with Ticker Effect */}
|
| 276 |
+
<div className={cn('flex flex-col font-semibold relative overflow-hidden', starsClass)}>
|
| 277 |
+
<motion.div
|
| 278 |
+
animate={{ opacity: 1 }}
|
| 279 |
+
transition={{
|
| 280 |
+
type: 'spring',
|
| 281 |
+
stiffness: 300,
|
| 282 |
+
damping: 30,
|
| 283 |
+
...transition,
|
| 284 |
+
}}
|
| 285 |
+
className="tabular-nums"
|
| 286 |
+
>
|
| 287 |
+
<span>{currentStars > 0 && formatNumber(currentStars)}</span>
|
| 288 |
+
</motion.div>
|
| 289 |
+
{fixedWidth && <span className="opacity-0 h-0 overflow-hidden tabular-nums">{formatNumber(targetStars)}</span>}
|
| 290 |
+
</div>
|
| 291 |
+
</button>
|
| 292 |
+
);
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
export { GithubButton, githubButtonVariants };
|
| 296 |
+
export type { GithubButtonProps };
|
frontend/components/ui/popover.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import * as React from 'react'
|
| 4 |
+
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
| 5 |
+
|
| 6 |
+
import { cn } from '@/lib/utils'
|
| 7 |
+
|
| 8 |
+
const Popover = PopoverPrimitive.Root
|
| 9 |
+
|
| 10 |
+
const PopoverTrigger = PopoverPrimitive.Trigger
|
| 11 |
+
|
| 12 |
+
const PopoverContent = React.forwardRef<
|
| 13 |
+
React.ElementRef<typeof PopoverPrimitive.Content>,
|
| 14 |
+
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
| 15 |
+
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
| 16 |
+
<PopoverPrimitive.Portal>
|
| 17 |
+
<PopoverPrimitive.Content
|
| 18 |
+
ref={ref}
|
| 19 |
+
align={align}
|
| 20 |
+
sideOffset={sideOffset}
|
| 21 |
+
className={cn(
|
| 22 |
+
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
| 23 |
+
className,
|
| 24 |
+
)}
|
| 25 |
+
{...props}
|
| 26 |
+
/>
|
| 27 |
+
</PopoverPrimitive.Portal>
|
| 28 |
+
))
|
| 29 |
+
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
| 30 |
+
|
| 31 |
+
export { Popover, PopoverTrigger, PopoverContent }
|
frontend/components/ui/sidebar.tsx
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { cn } from "@/lib/utils";
|
| 3 |
+
import React, { useState, createContext, useContext } from "react";
|
| 4 |
+
import { AnimatePresence, motion } from "motion/react";
|
| 5 |
+
import { IconMenu2, IconX } from "@tabler/icons-react";
|
| 6 |
+
|
| 7 |
+
interface Links {
|
| 8 |
+
label: string;
|
| 9 |
+
href: string;
|
| 10 |
+
icon: React.JSX.Element | React.ReactNode;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
interface SidebarContextProps {
|
| 14 |
+
open: boolean;
|
| 15 |
+
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
| 16 |
+
animate: boolean;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const SidebarContext = createContext<SidebarContextProps | undefined>(
|
| 20 |
+
undefined
|
| 21 |
+
);
|
| 22 |
+
|
| 23 |
+
export const useSidebar = () => {
|
| 24 |
+
const context = useContext(SidebarContext);
|
| 25 |
+
if (!context) {
|
| 26 |
+
throw new Error("useSidebar must be used within a SidebarProvider");
|
| 27 |
+
}
|
| 28 |
+
return context;
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
export const SidebarProvider = ({
|
| 32 |
+
children,
|
| 33 |
+
open: openProp,
|
| 34 |
+
setOpen: setOpenProp,
|
| 35 |
+
animate = true,
|
| 36 |
+
}: {
|
| 37 |
+
children: React.ReactNode;
|
| 38 |
+
open?: boolean;
|
| 39 |
+
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
| 40 |
+
animate?: boolean;
|
| 41 |
+
}) => {
|
| 42 |
+
const [openState, setOpenState] = useState(false);
|
| 43 |
+
|
| 44 |
+
const open = openProp !== undefined ? openProp : openState;
|
| 45 |
+
const setOpen = setOpenProp !== undefined ? setOpenProp : setOpenState;
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<SidebarContext.Provider value={{ open, setOpen, animate: animate }}>
|
| 49 |
+
{children}
|
| 50 |
+
</SidebarContext.Provider>
|
| 51 |
+
);
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
export const Sidebar = ({
|
| 55 |
+
children,
|
| 56 |
+
open,
|
| 57 |
+
setOpen,
|
| 58 |
+
animate,
|
| 59 |
+
}: {
|
| 60 |
+
children: React.ReactNode;
|
| 61 |
+
open?: boolean;
|
| 62 |
+
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
| 63 |
+
animate?: boolean;
|
| 64 |
+
}) => {
|
| 65 |
+
return (
|
| 66 |
+
<SidebarProvider open={open} setOpen={setOpen} animate={animate}>
|
| 67 |
+
{children}
|
| 68 |
+
</SidebarProvider>
|
| 69 |
+
);
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
export const SidebarBody = (props: React.ComponentProps<typeof motion.div>) => {
|
| 73 |
+
return (
|
| 74 |
+
<>
|
| 75 |
+
<DesktopSidebar {...props} />
|
| 76 |
+
<MobileSidebar {...(props as React.ComponentProps<"div">)} />
|
| 77 |
+
</>
|
| 78 |
+
);
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
export const DesktopSidebar = ({
|
| 82 |
+
className,
|
| 83 |
+
children,
|
| 84 |
+
...props
|
| 85 |
+
}: React.ComponentProps<typeof motion.div>) => {
|
| 86 |
+
const { open, setOpen, animate } = useSidebar();
|
| 87 |
+
return (
|
| 88 |
+
<>
|
| 89 |
+
<motion.div
|
| 90 |
+
className={cn(
|
| 91 |
+
"h-full px-4 py-4 hidden md:flex md:flex-col bg-neutral-900 w-[175px] shrink-0",
|
| 92 |
+
className
|
| 93 |
+
)}
|
| 94 |
+
animate={{
|
| 95 |
+
width: animate ? (open ? "175px" : "60px") : "200px",
|
| 96 |
+
}}
|
| 97 |
+
onMouseEnter={() => setOpen(true)}
|
| 98 |
+
onMouseLeave={() => setOpen(false)}
|
| 99 |
+
{...props}
|
| 100 |
+
>
|
| 101 |
+
{children}
|
| 102 |
+
</motion.div>
|
| 103 |
+
</>
|
| 104 |
+
);
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
export const MobileSidebar = ({
|
| 108 |
+
className,
|
| 109 |
+
children,
|
| 110 |
+
...props
|
| 111 |
+
}: React.ComponentProps<"div">) => {
|
| 112 |
+
const { open, setOpen } = useSidebar();
|
| 113 |
+
return (
|
| 114 |
+
<>
|
| 115 |
+
<div
|
| 116 |
+
className={cn(
|
| 117 |
+
"h-10 px-4 py-4 flex flex-row md:hidden items-center justify-between bg-neutral-900 w-full"
|
| 118 |
+
)}
|
| 119 |
+
{...props}
|
| 120 |
+
>
|
| 121 |
+
<div className="flex justify-end z-20 w-full">
|
| 122 |
+
<IconMenu2
|
| 123 |
+
className="text-neutral-800 dark:text-neutral-200"
|
| 124 |
+
onClick={() => setOpen(!open)}
|
| 125 |
+
/>
|
| 126 |
+
</div>
|
| 127 |
+
<AnimatePresence>
|
| 128 |
+
{open && (
|
| 129 |
+
<motion.div
|
| 130 |
+
initial={{ x: "-100%", opacity: 0 }}
|
| 131 |
+
animate={{ x: 0, opacity: 1 }}
|
| 132 |
+
exit={{ x: "-100%", opacity: 0 }}
|
| 133 |
+
transition={{
|
| 134 |
+
duration: 0.3,
|
| 135 |
+
ease: "easeInOut",
|
| 136 |
+
}}
|
| 137 |
+
className={cn(
|
| 138 |
+
"fixed h-full w-full inset-0 bg-neutral-900 p-10 z-[100] flex flex-col justify-between",
|
| 139 |
+
className
|
| 140 |
+
)}
|
| 141 |
+
>
|
| 142 |
+
<div
|
| 143 |
+
className="absolute right-10 top-10 z-50 text-neutral-800 dark:text-neutral-200"
|
| 144 |
+
onClick={() => setOpen(!open)}
|
| 145 |
+
>
|
| 146 |
+
<IconX />
|
| 147 |
+
</div>
|
| 148 |
+
{children}
|
| 149 |
+
</motion.div>
|
| 150 |
+
)}
|
| 151 |
+
</AnimatePresence>
|
| 152 |
+
</div>
|
| 153 |
+
</>
|
| 154 |
+
);
|
| 155 |
+
};
|
| 156 |
+
|
| 157 |
+
export const SidebarLink = ({
|
| 158 |
+
link,
|
| 159 |
+
className,
|
| 160 |
+
...props
|
| 161 |
+
}: {
|
| 162 |
+
link: Links;
|
| 163 |
+
className?: string;
|
| 164 |
+
}) => {
|
| 165 |
+
const { open, animate } = useSidebar();
|
| 166 |
+
return (
|
| 167 |
+
<a
|
| 168 |
+
href={link.href}
|
| 169 |
+
className={cn(
|
| 170 |
+
"flex items-center justify-start gap-2 group/sidebar py-2",
|
| 171 |
+
className
|
| 172 |
+
)}
|
| 173 |
+
{...props}
|
| 174 |
+
>
|
| 175 |
+
{link.icon}
|
| 176 |
+
|
| 177 |
+
<motion.span
|
| 178 |
+
animate={{
|
| 179 |
+
display: animate ? (open ? "inline-block" : "none") : "inline-block",
|
| 180 |
+
opacity: animate ? (open ? 1 : 0) : 1,
|
| 181 |
+
}}
|
| 182 |
+
className="text-neutral-200 text-sm group-hover/sidebar:translate-x-1 transition duration-150 whitespace-pre inline-block !p-0 !m-0"
|
| 183 |
+
>
|
| 184 |
+
{link.label}
|
| 185 |
+
</motion.span>
|
| 186 |
+
</a>
|
| 187 |
+
);
|
| 188 |
+
};
|
frontend/components/utils.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const cls = (...c) => c.filter(Boolean).join(" ");
|
| 2 |
+
|
| 3 |
+
export function timeAgo(date) {
|
| 4 |
+
const d = typeof date === "string" ? new Date(date) : date;
|
| 5 |
+
const now = new Date();
|
| 6 |
+
const sec = Math.max(1, Math.floor((now - d) / 1000));
|
| 7 |
+
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
|
| 8 |
+
const ranges = [
|
| 9 |
+
[60, "seconds"], [3600, "minutes"], [86400, "hours"],
|
| 10 |
+
[604800, "days"], [2629800, "weeks"], [31557600, "months"],
|
| 11 |
+
];
|
| 12 |
+
let unit = "years";
|
| 13 |
+
let value = -Math.floor(sec / 31557600);
|
| 14 |
+
for (const [limit, u] of ranges) {
|
| 15 |
+
if (sec < limit) {
|
| 16 |
+
unit = u;
|
| 17 |
+
const div =
|
| 18 |
+
unit === "seconds" ? 1 :
|
| 19 |
+
limit / (unit === "minutes" ? 60 :
|
| 20 |
+
unit === "hours" ? 3600 :
|
| 21 |
+
unit === "days" ? 86400 :
|
| 22 |
+
unit === "weeks" ? 604800 : 2629800);
|
| 23 |
+
value = -Math.floor(sec / div);
|
| 24 |
+
break;
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
return rtf.format(value, /** @type {Intl.RelativeTimeFormatUnit} */ (unit));
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export const makeId = (p) => `${p}${Math.random().toString(36).slice(2, 10)}`;
|
frontend/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;
|
frontend/lib/utils.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { clsx, type ClassValue } from "clsx"
|
| 2 |
+
import { twMerge } from "tailwind-merge"
|
| 3 |
+
|
| 4 |
+
export function cn(...inputs: ClassValue[]) {
|
| 5 |
+
return twMerge(clsx(inputs))
|
| 6 |
+
}
|
frontend/next.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
async rewrites() {
|
| 5 |
+
return [
|
| 6 |
+
{
|
| 7 |
+
source: '/api/proxy/:path*',
|
| 8 |
+
destination: `${process.env.BACKEND_URL || 'http://4.144.73.42:8000'}/:path*`,
|
| 9 |
+
},
|
| 10 |
+
]
|
| 11 |
+
},
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
export default nextConfig;
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev -p 3001",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "eslint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@radix-ui/react-popover": "^1.1.15",
|
| 13 |
+
"@svgr/webpack": "^8.1.0",
|
| 14 |
+
"@tabler/icons-react": "^3.35.0",
|
| 15 |
+
"class-variance-authority": "^0.7.1",
|
| 16 |
+
"clsx": "^2.1.1",
|
| 17 |
+
"lucide-react": "^0.548.0",
|
| 18 |
+
"motion": "^12.23.24",
|
| 19 |
+
"next": "^16.0.7",
|
| 20 |
+
"ogl": "^1.0.11",
|
| 21 |
+
"react": "^19.2.1",
|
| 22 |
+
"react-dom": "^19.2.1",
|
| 23 |
+
"react-dropzone": "^14.3.8",
|
| 24 |
+
"react-icons": "^5.5.0",
|
| 25 |
+
"react-use-measure": "^2.1.7",
|
| 26 |
+
"tailwind-merge": "^3.3.1"
|
| 27 |
+
},
|
| 28 |
+
"devDependencies": {
|
| 29 |
+
"@tailwindcss/postcss": "^4",
|
| 30 |
+
"@types/node": "^20",
|
| 31 |
+
"@types/react": "^19",
|
| 32 |
+
"@types/react-dom": "^19",
|
| 33 |
+
"eslint": "^9",
|
| 34 |
+
"eslint-config-next": "16.0.0",
|
| 35 |
+
"tailwindcss": "^4",
|
| 36 |
+
"typescript": "^5"
|
| 37 |
+
}
|
| 38 |
+
}
|
frontend/postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2017",
|
| 4 |
+
"lib": [
|
| 5 |
+
"dom",
|
| 6 |
+
"dom.iterable",
|
| 7 |
+
"esnext"
|
| 8 |
+
],
|
| 9 |
+
"allowJs": true,
|
| 10 |
+
"skipLibCheck": true,
|
| 11 |
+
"strict": true,
|
| 12 |
+
"noEmit": true,
|
| 13 |
+
"esModuleInterop": true,
|
| 14 |
+
"module": "esnext",
|
| 15 |
+
"moduleResolution": "bundler",
|
| 16 |
+
"resolveJsonModule": true,
|
| 17 |
+
"isolatedModules": true,
|
| 18 |
+
"jsx": "react-jsx",
|
| 19 |
+
"incremental": true,
|
| 20 |
+
"plugins": [
|
| 21 |
+
{
|
| 22 |
+
"name": "next"
|
| 23 |
+
}
|
| 24 |
+
],
|
| 25 |
+
"paths": {
|
| 26 |
+
"@/*": [
|
| 27 |
+
"./*"
|
| 28 |
+
]
|
| 29 |
+
}
|
| 30 |
+
},
|
| 31 |
+
"include": [
|
| 32 |
+
"next-env.d.ts",
|
| 33 |
+
"**/*.ts",
|
| 34 |
+
"**/*.tsx",
|
| 35 |
+
".next/types/**/*.ts",
|
| 36 |
+
".next/dev/types/**/*.ts",
|
| 37 |
+
"**/*.mts",
|
| 38 |
+
".next\\dev/types/**/*.ts",
|
| 39 |
+
".next\\dev/types/**/*.ts"
|
| 40 |
+
],
|
| 41 |
+
"exclude": [
|
| 42 |
+
"node_modules"
|
| 43 |
+
]
|
| 44 |
+
}
|
query_only.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is for inference without actually embedding documents
|
| 2 |
+
# Main does embedding everytime, is redundant for querying.
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import time
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
|
| 12 |
+
from vector_db import get_pinecone_index
|
| 13 |
+
from retriever.retriever import HybridRetriever
|
| 14 |
+
from retriever.generator import RAGGenerator
|
| 15 |
+
from retriever.processor import ChunkProcessor
|
| 16 |
+
|
| 17 |
+
from models.llama_3_8b import Llama3_8B
|
| 18 |
+
from models.mistral_7b import Mistral_7b
|
| 19 |
+
from models.qwen_2_5 import Qwen2_5
|
| 20 |
+
from models.deepseek_v3 import DeepSeek_V3
|
| 21 |
+
from models.tiny_aya import TinyAya
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _to_dict_maybe(obj: Any) -> dict[str, Any]:
|
| 25 |
+
if isinstance(obj, dict):
|
| 26 |
+
return obj
|
| 27 |
+
if hasattr(obj, "to_dict"):
|
| 28 |
+
return obj.to_dict()
|
| 29 |
+
return {}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _list_namespaces(index) -> list[str]:
|
| 33 |
+
stats = index.describe_index_stats()
|
| 34 |
+
stats_dict = _to_dict_maybe(stats)
|
| 35 |
+
|
| 36 |
+
namespaces_obj = stats_dict.get("namespaces", {})
|
| 37 |
+
if not namespaces_obj and hasattr(stats, "namespaces"):
|
| 38 |
+
namespaces_obj = getattr(stats, "namespaces")
|
| 39 |
+
|
| 40 |
+
if isinstance(namespaces_obj, dict):
|
| 41 |
+
namespaces = list(namespaces_obj.keys())
|
| 42 |
+
else:
|
| 43 |
+
namespaces = []
|
| 44 |
+
|
| 45 |
+
# Pinecone default namespace can appear as empty string.
|
| 46 |
+
return namespaces if namespaces else [""]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _load_chunks_from_pinecone(index, batch_size: int = 100) -> list[dict[str, Any]]:
|
| 50 |
+
namespaces = _list_namespaces(index)
|
| 51 |
+
all_chunks: list[dict[str, Any]] = []
|
| 52 |
+
seen_ids = set()
|
| 53 |
+
|
| 54 |
+
print(f"Loading existing vectors from Pinecone namespaces: {namespaces}")
|
| 55 |
+
|
| 56 |
+
for namespace in namespaces:
|
| 57 |
+
namespace_arg = namespace if namespace else None
|
| 58 |
+
vector_count = 0
|
| 59 |
+
|
| 60 |
+
for id_batch in index.list(namespace=namespace_arg, limit=batch_size):
|
| 61 |
+
if not id_batch:
|
| 62 |
+
continue
|
| 63 |
+
|
| 64 |
+
fetched = index.fetch(ids=id_batch, namespace=namespace_arg)
|
| 65 |
+
vectors = getattr(fetched, "vectors", {})
|
| 66 |
+
|
| 67 |
+
for vector_id, vector in vectors.items():
|
| 68 |
+
if vector_id in seen_ids:
|
| 69 |
+
continue
|
| 70 |
+
seen_ids.add(vector_id)
|
| 71 |
+
|
| 72 |
+
metadata = getattr(vector, "metadata", None)
|
| 73 |
+
if metadata is None and isinstance(vector, dict):
|
| 74 |
+
metadata = vector.get("metadata", {})
|
| 75 |
+
|
| 76 |
+
if not isinstance(metadata, dict):
|
| 77 |
+
metadata = {}
|
| 78 |
+
|
| 79 |
+
text = metadata.get("text")
|
| 80 |
+
if not text:
|
| 81 |
+
continue
|
| 82 |
+
|
| 83 |
+
all_chunks.append(
|
| 84 |
+
{
|
| 85 |
+
"id": vector_id,
|
| 86 |
+
"metadata": {
|
| 87 |
+
"text": text,
|
| 88 |
+
"title": metadata.get("title", ""),
|
| 89 |
+
"url": metadata.get("url", ""),
|
| 90 |
+
},
|
| 91 |
+
}
|
| 92 |
+
)
|
| 93 |
+
vector_count += 1
|
| 94 |
+
|
| 95 |
+
ns_label = namespace if namespace else "<default>"
|
| 96 |
+
print(f"Loaded {vector_count} chunks from namespace {ns_label}")
|
| 97 |
+
|
| 98 |
+
print(f"Total loaded chunks for BM25 corpus: {len(all_chunks)}")
|
| 99 |
+
return all_chunks
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _build_models(hf_token: str) -> dict[str, Any]:
|
| 103 |
+
return {
|
| 104 |
+
"Llama-3-8B": Llama3_8B(token=hf_token),
|
| 105 |
+
"Mistral-7B": Mistral_7b(token=hf_token),
|
| 106 |
+
"Qwen-2.5": Qwen2_5(token=hf_token),
|
| 107 |
+
"DeepSeek-V3": DeepSeek_V3(token=hf_token),
|
| 108 |
+
"TinyAya": TinyAya(token=hf_token),
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def main() -> None:
|
| 113 |
+
pipeline_start = time.perf_counter()
|
| 114 |
+
load_dotenv()
|
| 115 |
+
|
| 116 |
+
hf_token = os.getenv("HF_TOKEN")
|
| 117 |
+
pinecone_api_key = os.getenv("PINECONE_API_KEY")
|
| 118 |
+
|
| 119 |
+
if not pinecone_api_key:
|
| 120 |
+
raise ValueError("PINECONE_API_KEY not found in environment variables")
|
| 121 |
+
if not hf_token:
|
| 122 |
+
raise ValueError("HF_TOKEN not found in environment variables")
|
| 123 |
+
|
| 124 |
+
# Configuration
|
| 125 |
+
# Query defined here
|
| 126 |
+
query = "How do transformers handle long sequences?"
|
| 127 |
+
index_name = "arxiv-index"
|
| 128 |
+
embed_model_name = "all-MiniLM-L6-v2"
|
| 129 |
+
|
| 130 |
+
index = get_pinecone_index(
|
| 131 |
+
api_key=pinecone_api_key,
|
| 132 |
+
index_name=index_name,
|
| 133 |
+
dimension=384,
|
| 134 |
+
metric="cosine",
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# Load text metadata from Pinecone once and use it as the BM25 corpus.
|
| 138 |
+
load_start = time.perf_counter()
|
| 139 |
+
final_chunks = _load_chunks_from_pinecone(index)
|
| 140 |
+
load_time = time.perf_counter() - load_start
|
| 141 |
+
print(f"Chunk load time: {load_time:.3f}s")
|
| 142 |
+
if not final_chunks:
|
| 143 |
+
raise ValueError(
|
| 144 |
+
"No chunks found in Pinecone index metadata. Run your indexing pipeline once before query-only mode."
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
# Using ChunkProcessor and HybridRetriever object
|
| 148 |
+
#using the same pattern as main.py
|
| 149 |
+
|
| 150 |
+
proc = ChunkProcessor(model_name=embed_model_name, verbose=True)
|
| 151 |
+
retriever = HybridRetriever(final_chunks, proc.encoder, verbose=True)
|
| 152 |
+
|
| 153 |
+
retrieval_start = time.perf_counter()
|
| 154 |
+
context_chunks = retriever.search(
|
| 155 |
+
query,
|
| 156 |
+
index,
|
| 157 |
+
mode="hybrid",
|
| 158 |
+
rerank_strategy="cross-encoder",
|
| 159 |
+
use_mmr=True,
|
| 160 |
+
top_k=10,
|
| 161 |
+
final_k=5,
|
| 162 |
+
)
|
| 163 |
+
retrieval_time = time.perf_counter() - retrieval_start
|
| 164 |
+
print(f"Retrieval call time: {retrieval_time:.3f}s")
|
| 165 |
+
|
| 166 |
+
if not context_chunks:
|
| 167 |
+
print("No context chunks retrieved. Check your query and index contents.")
|
| 168 |
+
return
|
| 169 |
+
|
| 170 |
+
#usign Raggenerator object to get answer from retrieved context
|
| 171 |
+
rag_engine = RAGGenerator()
|
| 172 |
+
models = _build_models(hf_token)
|
| 173 |
+
total_generation_time = 0.0
|
| 174 |
+
|
| 175 |
+
for name, model in models.items():
|
| 176 |
+
print(f"\n--- {name} ---")
|
| 177 |
+
try:
|
| 178 |
+
model_gen_start = time.perf_counter()
|
| 179 |
+
answer = rag_engine.get_answer(model, query, context_chunks, temperature=0.1)
|
| 180 |
+
model_gen_time = time.perf_counter() - model_gen_start
|
| 181 |
+
total_generation_time += model_gen_time
|
| 182 |
+
print(answer)
|
| 183 |
+
print(f"Generation time ({name}): {model_gen_time:.3f}s")
|
| 184 |
+
except Exception as exc:
|
| 185 |
+
print(f"Error: {exc}")
|
| 186 |
+
|
| 187 |
+
pipeline_time = time.perf_counter() - pipeline_start
|
| 188 |
+
print("\nTiming Summary:")
|
| 189 |
+
print(f" Chunk Load: {load_time:.3f}s")
|
| 190 |
+
print(f" Retrieval: {retrieval_time:.3f}s")
|
| 191 |
+
print(f" Generation (all models): {total_generation_time:.3f}s")
|
| 192 |
+
print(f" End-to-end: {pipeline_time:.3f}s")
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
if __name__ == "__main__":
|
| 196 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
aiohappyeyeballs==2.6.1
|
| 2 |
+
aiohttp==3.13.3
|
| 3 |
+
aiosignal==1.4.0
|
| 4 |
+
annotated-doc==0.0.4
|
| 5 |
+
annotated-types==0.7.0
|
| 6 |
+
anyio==4.12.1
|
| 7 |
+
arxiv==2.4.1
|
| 8 |
+
attrs==26.1.0
|
| 9 |
+
certifi==2026.2.25
|
| 10 |
+
charset-normalizer==3.4.6
|
| 11 |
+
click==8.3.1
|
| 12 |
+
colorama==0.4.6
|
| 13 |
+
dataclasses-json==0.6.7
|
| 14 |
+
feedparser==6.0.12
|
| 15 |
+
fastapi==0.121.1
|
| 16 |
+
filelock==3.25.2
|
| 17 |
+
frozenlist==1.8.0
|
| 18 |
+
fsspec==2026.2.0
|
| 19 |
+
greenlet==3.3.2
|
| 20 |
+
h11==0.16.0
|
| 21 |
+
hf-xet==1.4.2
|
| 22 |
+
httpcore==1.0.9
|
| 23 |
+
httpx==0.28.1
|
| 24 |
+
httpx-sse==0.4.3
|
| 25 |
+
huggingface_hub==1.7.2
|
| 26 |
+
idna==3.11
|
| 27 |
+
Jinja2==3.1.6
|
| 28 |
+
joblib==1.5.3
|
| 29 |
+
jsonpatch==1.33
|
| 30 |
+
jsonpointer==3.1.1
|
| 31 |
+
langchain-classic==1.0.3
|
| 32 |
+
langchain-community==0.4.1
|
| 33 |
+
langchain-core==1.2.21
|
| 34 |
+
langchain-experimental==0.4.1
|
| 35 |
+
langchain-huggingface==1.2.1
|
| 36 |
+
langchain-text-splitters==1.1.1
|
| 37 |
+
langsmith==0.7.22
|
| 38 |
+
markdown-it-py==4.0.0
|
| 39 |
+
MarkupSafe==3.0.3
|
| 40 |
+
marshmallow==3.26.2
|
| 41 |
+
mdurl==0.1.2
|
| 42 |
+
mpmath==1.3.0
|
| 43 |
+
multidict==6.7.1
|
| 44 |
+
mypy_extensions==1.1.0
|
| 45 |
+
networkx==3.6.1
|
| 46 |
+
nltk==3.9.4
|
| 47 |
+
numpy==2.4.3
|
| 48 |
+
orjson==3.11.7
|
| 49 |
+
packaging==24.2
|
| 50 |
+
pandas==3.0.1
|
| 51 |
+
pinecone==8.1.0
|
| 52 |
+
pinecone-plugin-assistant==3.0.2
|
| 53 |
+
pinecone-plugin-interface==0.0.7
|
| 54 |
+
propcache==0.4.1
|
| 55 |
+
pydantic==2.12.5
|
| 56 |
+
pydantic-settings==2.13.1
|
| 57 |
+
pydantic_core==2.41.5
|
| 58 |
+
Pygments==2.19.2
|
| 59 |
+
PyMuPDF==1.27.2.2
|
| 60 |
+
python-dateutil==2.9.0.post0
|
| 61 |
+
python-dotenv==1.2.2
|
| 62 |
+
PyYAML==6.0.3
|
| 63 |
+
rank-bm25==0.2.2
|
| 64 |
+
regex==2026.2.28
|
| 65 |
+
requests==2.32.5
|
| 66 |
+
requests-toolbelt==1.0.0
|
| 67 |
+
rich==14.3.3
|
| 68 |
+
safetensors==0.7.0
|
| 69 |
+
scikit-learn==1.8.0
|
| 70 |
+
scipy==1.17.1
|
| 71 |
+
sentence-transformers==5.3.0
|
| 72 |
+
setuptools==81.0.0
|
| 73 |
+
sgmllib3k==1.0.0
|
| 74 |
+
shellingham==1.5.4
|
| 75 |
+
six==1.17.0
|
| 76 |
+
SQLAlchemy==2.0.48
|
| 77 |
+
sympy==1.14.0
|
| 78 |
+
tenacity==9.1.4
|
| 79 |
+
threadpoolctl==3.6.0
|
| 80 |
+
tokenizers==0.22.2
|
| 81 |
+
torch==2.11.0
|
| 82 |
+
tqdm==4.67.3
|
| 83 |
+
transformers==5.3.0
|
| 84 |
+
typer==0.24.1
|
| 85 |
+
typing-inspect==0.9.0
|
| 86 |
+
typing-inspection==0.4.2
|
| 87 |
+
typing_extensions==4.15.0
|
| 88 |
+
tzdata==2025.3
|
| 89 |
+
urllib3==2.6.3
|
| 90 |
+
uvicorn==0.38.0
|
| 91 |
+
uuid_utils==0.14.1
|
| 92 |
+
xxhash==3.6.0
|
| 93 |
+
yarl==1.23.0
|
| 94 |
+
zstandard==0.25.0
|
retriever/retriever.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import numpy as np
|
|
|
|
| 2 |
from rank_bm25 import BM25Okapi
|
| 3 |
from sentence_transformers import CrossEncoder
|
| 4 |
from sklearn.metrics.pairwise import cosine_similarity
|
|
@@ -99,6 +100,11 @@ class HybridRetriever:
|
|
| 99 |
:param lambda_param: MMR trade-off between relevance (1.0) and diversity (0.0)
|
| 100 |
"""
|
| 101 |
should_print = verbose if verbose is not None else self.verbose
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
if should_print:
|
| 104 |
self._print_search_header(query, mode, rerank_strategy, top_k, final_k)
|
|
@@ -108,16 +114,23 @@ class HybridRetriever:
|
|
| 108 |
semantic_chunks, bm25_chunks = [], []
|
| 109 |
|
| 110 |
if mode in ["semantic", "hybrid"]:
|
|
|
|
| 111 |
query_vector, semantic_chunks = self._semantic_search(query, index, top_k)
|
|
|
|
| 112 |
if should_print:
|
| 113 |
self._print_candidates("Semantic Search", semantic_chunks)
|
|
|
|
| 114 |
|
| 115 |
if mode in ["bm25", "hybrid"]:
|
|
|
|
| 116 |
bm25_chunks = self._bm25_search(query, top_k)
|
|
|
|
| 117 |
if should_print:
|
| 118 |
self._print_candidates("BM25 Search", bm25_chunks)
|
|
|
|
| 119 |
|
| 120 |
# 2. Fuse / rerank
|
|
|
|
| 121 |
if rerank_strategy == "rrf":
|
| 122 |
candidates = self._rrf_score(semantic_chunks, bm25_chunks)[:final_k]
|
| 123 |
label = "RRF"
|
|
@@ -128,17 +141,23 @@ class HybridRetriever:
|
|
| 128 |
else: # "none"
|
| 129 |
candidates = list(dict.fromkeys(semantic_chunks + bm25_chunks))[:final_k]
|
| 130 |
label = "No Reranking"
|
|
|
|
| 131 |
|
| 132 |
# 3. MMR diversity filter (applied after reranking)
|
| 133 |
if use_mmr and candidates:
|
|
|
|
| 134 |
if query_vector is None:
|
| 135 |
query_vector = self.embed_model.encode(query)
|
| 136 |
candidates = self._maximal_marginal_relevance(query_vector, candidates,
|
| 137 |
lambda_param=lambda_param, top_k=3)
|
| 138 |
label += " + MMR"
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
if should_print:
|
| 141 |
self._print_final_results(candidates, label)
|
|
|
|
| 142 |
|
| 143 |
return candidates
|
| 144 |
|
|
@@ -165,3 +184,11 @@ class HybridRetriever:
|
|
| 165 |
preview = chunk[:150] + "..." if len(chunk) > 150 else chunk
|
| 166 |
print(f" [{i+1}] {preview}")
|
| 167 |
print("="*80)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import numpy as np
|
| 2 |
+
import time
|
| 3 |
from rank_bm25 import BM25Okapi
|
| 4 |
from sentence_transformers import CrossEncoder
|
| 5 |
from sklearn.metrics.pairwise import cosine_similarity
|
|
|
|
| 100 |
:param lambda_param: MMR trade-off between relevance (1.0) and diversity (0.0)
|
| 101 |
"""
|
| 102 |
should_print = verbose if verbose is not None else self.verbose
|
| 103 |
+
total_start = time.perf_counter()
|
| 104 |
+
semantic_time = 0.0
|
| 105 |
+
bm25_time = 0.0
|
| 106 |
+
rerank_time = 0.0
|
| 107 |
+
mmr_time = 0.0
|
| 108 |
|
| 109 |
if should_print:
|
| 110 |
self._print_search_header(query, mode, rerank_strategy, top_k, final_k)
|
|
|
|
| 114 |
semantic_chunks, bm25_chunks = [], []
|
| 115 |
|
| 116 |
if mode in ["semantic", "hybrid"]:
|
| 117 |
+
semantic_start = time.perf_counter()
|
| 118 |
query_vector, semantic_chunks = self._semantic_search(query, index, top_k)
|
| 119 |
+
semantic_time = time.perf_counter() - semantic_start
|
| 120 |
if should_print:
|
| 121 |
self._print_candidates("Semantic Search", semantic_chunks)
|
| 122 |
+
print(f"Semantic time: {semantic_time:.3f}s")
|
| 123 |
|
| 124 |
if mode in ["bm25", "hybrid"]:
|
| 125 |
+
bm25_start = time.perf_counter()
|
| 126 |
bm25_chunks = self._bm25_search(query, top_k)
|
| 127 |
+
bm25_time = time.perf_counter() - bm25_start
|
| 128 |
if should_print:
|
| 129 |
self._print_candidates("BM25 Search", bm25_chunks)
|
| 130 |
+
print(f"BM25 time: {bm25_time:.3f}s")
|
| 131 |
|
| 132 |
# 2. Fuse / rerank
|
| 133 |
+
rerank_start = time.perf_counter()
|
| 134 |
if rerank_strategy == "rrf":
|
| 135 |
candidates = self._rrf_score(semantic_chunks, bm25_chunks)[:final_k]
|
| 136 |
label = "RRF"
|
|
|
|
| 141 |
else: # "none"
|
| 142 |
candidates = list(dict.fromkeys(semantic_chunks + bm25_chunks))[:final_k]
|
| 143 |
label = "No Reranking"
|
| 144 |
+
rerank_time = time.perf_counter() - rerank_start
|
| 145 |
|
| 146 |
# 3. MMR diversity filter (applied after reranking)
|
| 147 |
if use_mmr and candidates:
|
| 148 |
+
mmr_start = time.perf_counter()
|
| 149 |
if query_vector is None:
|
| 150 |
query_vector = self.embed_model.encode(query)
|
| 151 |
candidates = self._maximal_marginal_relevance(query_vector, candidates,
|
| 152 |
lambda_param=lambda_param, top_k=3)
|
| 153 |
label += " + MMR"
|
| 154 |
+
mmr_time = time.perf_counter() - mmr_start
|
| 155 |
+
|
| 156 |
+
total_time = time.perf_counter() - total_start
|
| 157 |
|
| 158 |
if should_print:
|
| 159 |
self._print_final_results(candidates, label)
|
| 160 |
+
self._print_timing_summary(semantic_time, bm25_time, rerank_time, mmr_time, total_time)
|
| 161 |
|
| 162 |
return candidates
|
| 163 |
|
|
|
|
| 184 |
preview = chunk[:150] + "..." if len(chunk) > 150 else chunk
|
| 185 |
print(f" [{i+1}] {preview}")
|
| 186 |
print("="*80)
|
| 187 |
+
|
| 188 |
+
def _print_timing_summary(self, semantic_time, bm25_time, rerank_time, mmr_time, total_time):
|
| 189 |
+
print(" Retrieval Timing:")
|
| 190 |
+
print(f" Semantic: {semantic_time:.3f}s")
|
| 191 |
+
print(f" BM25: {bm25_time:.3f}s")
|
| 192 |
+
print(f" Rerank/Fusion: {rerank_time:.3f}s")
|
| 193 |
+
print(f" MMR: {mmr_time:.3f}s")
|
| 194 |
+
print(f" Total Retrieval: {total_time:.3f}s")
|
startup.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
1) Create virtual environment (from project root):
|
| 2 |
+
python -m venv .venv
|
| 3 |
+
|
| 4 |
+
2) Activate virtual environment in Git Bash:
|
| 5 |
+
source .venv/Scripts/activate
|
| 6 |
+
|
| 7 |
+
3) Install dependencies from requirements file:
|
| 8 |
+
pip install -r requirements.txt
|
| 9 |
+
|
| 10 |
+
4) Start FastAPI server with Uvicorn:
|
| 11 |
+
uvicorn api:app --reload --host 0.0.0.0 --port 8000
|
| 12 |
+
|
| 13 |
+
5) (Optional) Verify server is up:
|
| 14 |
+
Open: http://127.0.0.1:8000/health
|