Ramail Khan commited on
Commit
5652550
·
unverified ·
2 Parent(s): 6d56aa102e0da8

Merge pull request #2 from ramailkk/qamar/frontend-integration

Browse files
Files changed (48) hide show
  1. .gitignore +31 -0
  2. api.py +200 -0
  3. frontend/.gitignore +41 -0
  4. frontend/README.md +36 -0
  5. frontend/app/favicon.ico +0 -0
  6. frontend/app/globals.css +141 -0
  7. frontend/app/icons/github_dark.svg +1 -0
  8. frontend/app/layout.tsx +38 -0
  9. frontend/app/learn-more/page.tsx +5 -0
  10. frontend/app/page.tsx +110 -0
  11. frontend/app/try-it-out/page.tsx +42 -0
  12. frontend/components.json +25 -0
  13. frontend/components/AIAssistantUI.jsx +409 -0
  14. frontend/components/Aurora.tsx +209 -0
  15. frontend/components/ChatPane.jsx +199 -0
  16. frontend/components/Composer.jsx +149 -0
  17. frontend/components/ConversationRow.jsx +131 -0
  18. frontend/components/CreateFolderModal.jsx +90 -0
  19. frontend/components/CreateTemplateModal.jsx +134 -0
  20. frontend/components/FolderRow.jsx +147 -0
  21. frontend/components/GhostIconButton.jsx +13 -0
  22. frontend/components/GradientText.tsx +91 -0
  23. frontend/components/Header.tsx +38 -0
  24. frontend/components/Header_Chatbot.jsx +52 -0
  25. frontend/components/Message.jsx +33 -0
  26. frontend/components/SearchModal.jsx +176 -0
  27. frontend/components/SettingsPopover.jsx +50 -0
  28. frontend/components/Sidebar.jsx +396 -0
  29. frontend/components/SidebarSection.jsx +38 -0
  30. frontend/components/TemplateRow.jsx +124 -0
  31. frontend/components/ThemeToggle.jsx +43 -0
  32. frontend/components/mockData.js +117 -0
  33. frontend/components/ui/github-button.tsx +296 -0
  34. frontend/components/ui/popover.tsx +31 -0
  35. frontend/components/ui/sidebar.tsx +188 -0
  36. frontend/components/utils.js +30 -0
  37. frontend/eslint.config.mjs +18 -0
  38. frontend/lib/utils.ts +6 -0
  39. frontend/next.config.ts +14 -0
  40. frontend/package-lock.json +0 -0
  41. frontend/package.json +38 -0
  42. frontend/postcss.config.mjs +7 -0
  43. frontend/tsconfig.json +44 -0
  44. main.py +1 -0
  45. query_only.py +195 -0
  46. requirements.txt +94 -0
  47. retriever/retriever.py +27 -0
  48. startup.txt +14 -0
.gitignore ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # python specific ignores
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,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Fastapi endpoints defined here
2
+ import os
3
+ import time
4
+ from typing import Any
5
+
6
+ from dotenv import load_dotenv
7
+ from fastapi import FastAPI, HTTPException
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from pydantic import BaseModel, Field
10
+
11
+ from vector_db import get_pinecone_index
12
+ from retriever.retriever import HybridRetriever
13
+ from retriever.generator import RAGGenerator
14
+ from retriever.processor import ChunkProcessor
15
+
16
+ from models.llama_3_8b import Llama3_8B
17
+ from models.mistral_7b import Mistral_7b
18
+ from models.qwen_2_5 import Qwen2_5
19
+ from models.deepseek_v3 import DeepSeek_V3
20
+ from models.tiny_aya import TinyAya
21
+
22
+ # Reuse the same query-only helper for loading BM25 corpus from Pinecone metadata.
23
+ from query_only import _load_chunks_from_pinecone
24
+
25
+
26
+ class PredictRequest(BaseModel):
27
+ query: str = Field(..., min_length=1, description="User query text")
28
+ model: str = Field(default="Llama-3-8B", description="Model name key")
29
+ top_k: int = Field(default=10, ge=1, le=50)
30
+ final_k: int = Field(default=5, ge=1, le=20)
31
+ mode: str = Field(default="hybrid", description="semantic | bm25 | hybrid")
32
+ rerank_strategy: str = Field(default="cross-encoder", description="cross-encoder | rrf | none")
33
+
34
+
35
+ class PredictResponse(BaseModel):
36
+ model: str
37
+ answer: str
38
+ contexts: list[str]
39
+ metrics: dict[str, float]
40
+
41
+
42
+
43
+ # Fastapi setup
44
+ # Fastapi allows us to define python based endpoint
45
+ # That is called from the react based frontend
46
+
47
+ app = FastAPI(title="RAG-AS3 API", version="0.1.0")
48
+
49
+ app.add_middleware(
50
+ CORSMiddleware,
51
+ allow_origins=["*"],
52
+ allow_credentials=True,
53
+ allow_methods=["*"],
54
+ allow_headers=["*"],
55
+ )
56
+
57
+
58
+ state: dict[str, Any] = {}
59
+
60
+
61
+ def _build_models(hf_token: str) -> dict[str, Any]:
62
+ return {
63
+ "Llama-3-8B": Llama3_8B(token=hf_token),
64
+ "Mistral-7B": Mistral_7b(token=hf_token),
65
+ "Qwen-2.5": Qwen2_5(token=hf_token),
66
+ "DeepSeek-V3": DeepSeek_V3(token=hf_token),
67
+ "TinyAya": TinyAya(token=hf_token),
68
+ }
69
+
70
+
71
+ def _resolve_model(name: str, models: dict[str, Any]) -> tuple[str, Any]:
72
+ aliases = {
73
+ "llama": "Llama-3-8B",
74
+ "mistral": "Mistral-7B",
75
+ "qwen": "Qwen-2.5",
76
+ "deepseek": "DeepSeek-V3",
77
+ "tinyaya": "TinyAya",
78
+ }
79
+ model_key = aliases.get(name.lower(), name)
80
+ if model_key not in models:
81
+ allowed = ", ".join(models.keys())
82
+ raise HTTPException(status_code=400, detail=f"Unknown model '{name}'. Use one of: {allowed}")
83
+ return model_key, models[model_key]
84
+
85
+
86
+ # On startup most of the time is spent loading chunks from pinecone
87
+ # This is done because bm25 needs the enture corpus in memory
88
+ # we want to avoid loading it on every query, so loading it at startup is better
89
+
90
+ # COuld improve this as not ideal to load entire corpus in memory
91
+ # currently it wont scale well
92
+
93
+ @app.on_event("startup")
94
+ def startup_event() -> None:
95
+ load_dotenv()
96
+
97
+ hf_token = os.getenv("HF_TOKEN")
98
+ pinecone_api_key = os.getenv("PINECONE_API_KEY")
99
+
100
+ if not pinecone_api_key:
101
+ raise RuntimeError("PINECONE_API_KEY not found in environment variables")
102
+ if not hf_token:
103
+ raise RuntimeError("HF_TOKEN not found in environment variables")
104
+
105
+ index_name = "arxiv-index"
106
+ embed_model_name = "all-MiniLM-L6-v2"
107
+
108
+ startup_start = time.perf_counter()
109
+
110
+ index = get_pinecone_index(
111
+ api_key=pinecone_api_key,
112
+ index_name=index_name,
113
+ dimension=384,
114
+ metric="cosine",
115
+ )
116
+
117
+ chunks_start = time.perf_counter()
118
+ final_chunks = _load_chunks_from_pinecone(index)
119
+ chunk_load_time = time.perf_counter() - chunks_start
120
+
121
+ if not final_chunks:
122
+ raise RuntimeError("No chunks found in Pinecone metadata. Run indexing once before API mode.")
123
+
124
+ proc = ChunkProcessor(model_name=embed_model_name, verbose=False)
125
+ retriever = HybridRetriever(final_chunks, proc.encoder, verbose=False)
126
+ rag_engine = RAGGenerator()
127
+ models = _build_models(hf_token)
128
+
129
+ state["index"] = index
130
+ state["retriever"] = retriever
131
+ state["rag_engine"] = rag_engine
132
+ state["models"] = models
133
+
134
+ startup_time = time.perf_counter() - startup_start
135
+ print(
136
+ f"API startup complete | chunks={len(final_chunks)} | "
137
+ f"chunk_load={chunk_load_time:.3f}s | total={startup_time:.3f}s"
138
+ )
139
+
140
+
141
+ @app.get("/health")
142
+ def health() -> dict[str, str]:
143
+ ready = all(k in state for k in ("index", "retriever", "rag_engine", "models"))
144
+ return {"status": "ok" if ready else "starting"}
145
+
146
+
147
+
148
+ # Predict endpoint that takes a query and returns an answer along with contexts and metrics
149
+ # is called from the frontend when user clicks submits
150
+ # Also resolves model based on user selection
151
+ @app.post("/predict", response_model=PredictResponse)
152
+ def predict(payload: PredictRequest) -> PredictResponse:
153
+ if not state:
154
+ raise HTTPException(status_code=503, detail="Service not initialized yet")
155
+
156
+ query = payload.query.strip()
157
+ if not query:
158
+ raise HTTPException(status_code=400, detail="Query cannot be empty")
159
+
160
+ total_start = time.perf_counter()
161
+
162
+ retriever: HybridRetriever = state["retriever"]
163
+ index = state["index"]
164
+ rag_engine: RAGGenerator = state["rag_engine"]
165
+ models: dict[str, Any] = state["models"]
166
+
167
+ model_name, model_instance = _resolve_model(payload.model, models)
168
+
169
+ retrieval_start = time.perf_counter()
170
+ contexts = retriever.search(
171
+ query,
172
+ index,
173
+ mode=payload.mode,
174
+ rerank_strategy=payload.rerank_strategy,
175
+ use_mmr=True,
176
+ top_k=payload.top_k,
177
+ final_k=payload.final_k,
178
+ verbose=False,
179
+ )
180
+ retrieval_time = time.perf_counter() - retrieval_start
181
+
182
+ if not contexts:
183
+ raise HTTPException(status_code=404, detail="No context chunks retrieved for this query")
184
+
185
+ generation_start = time.perf_counter()
186
+ answer = rag_engine.get_answer(model_instance, query, contexts, temperature=0.1)
187
+ generation_time = time.perf_counter() - generation_start
188
+
189
+ total_time = time.perf_counter() - total_start
190
+
191
+ return PredictResponse(
192
+ model=model_name,
193
+ answer=answer,
194
+ contexts=contexts,
195
+ metrics={
196
+ "retrieval_s": round(retrieval_time, 3),
197
+ "generation_s": round(generation_time, 3),
198
+ "total_s": round(total_time, 3),
199
+ },
200
+ )
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
+ }
main.py CHANGED
@@ -19,6 +19,7 @@ def main():
19
 
20
  # ------------------------------------------------------------------
21
  # 0. Configuration
 
22
  # ------------------------------------------------------------------
23
  hf_token = os.getenv("HF_TOKEN")
24
  pinecone_api_key = os.getenv("PINECONE_API_KEY")
 
19
 
20
  # ------------------------------------------------------------------
21
  # 0. Configuration
22
+ # Query defined here
23
  # ------------------------------------------------------------------
24
  hf_token = os.getenv("HF_TOKEN")
25
  pinecone_api_key = os.getenv("PINECONE_API_KEY")
query_only.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This file is for inference without actually embedding documents
2
+ # Main does embedding everytime, is redundant for querying.
3
+ # made this just to test querying part --@Qamar
4
+
5
+ import os
6
+ import time
7
+ from typing import Any
8
+
9
+ from dotenv import load_dotenv
10
+
11
+ from vector_db import get_pinecone_index
12
+ from retriever.retriever import HybridRetriever
13
+ from retriever.generator import RAGGenerator
14
+ from retriever.processor import ChunkProcessor
15
+
16
+ from models.llama_3_8b import Llama3_8B
17
+ from models.mistral_7b import Mistral_7b
18
+ from models.qwen_2_5 import Qwen2_5
19
+ from models.deepseek_v3 import DeepSeek_V3
20
+ from models.tiny_aya import TinyAya
21
+
22
+
23
+ def _to_dict_maybe(obj: Any) -> dict[str, Any]:
24
+ if isinstance(obj, dict):
25
+ return obj
26
+ if hasattr(obj, "to_dict"):
27
+ return obj.to_dict()
28
+ return {}
29
+
30
+
31
+ def _list_namespaces(index) -> list[str]:
32
+ stats = index.describe_index_stats()
33
+ stats_dict = _to_dict_maybe(stats)
34
+
35
+ namespaces_obj = stats_dict.get("namespaces", {})
36
+ if not namespaces_obj and hasattr(stats, "namespaces"):
37
+ namespaces_obj = getattr(stats, "namespaces")
38
+
39
+ if isinstance(namespaces_obj, dict):
40
+ namespaces = list(namespaces_obj.keys())
41
+ else:
42
+ namespaces = []
43
+
44
+ # Pinecone default namespace can appear as empty string.
45
+ return namespaces if namespaces else [""]
46
+
47
+
48
+ def _load_chunks_from_pinecone(index, batch_size: int = 100) -> list[dict[str, Any]]:
49
+ namespaces = _list_namespaces(index)
50
+ all_chunks: list[dict[str, Any]] = []
51
+ seen_ids = set()
52
+
53
+ print(f"Loading existing vectors from Pinecone namespaces: {namespaces}")
54
+
55
+ for namespace in namespaces:
56
+ namespace_arg = namespace if namespace else None
57
+ vector_count = 0
58
+
59
+ for id_batch in index.list(namespace=namespace_arg, limit=batch_size):
60
+ if not id_batch:
61
+ continue
62
+
63
+ fetched = index.fetch(ids=id_batch, namespace=namespace_arg)
64
+ vectors = getattr(fetched, "vectors", {})
65
+
66
+ for vector_id, vector in vectors.items():
67
+ if vector_id in seen_ids:
68
+ continue
69
+ seen_ids.add(vector_id)
70
+
71
+ metadata = getattr(vector, "metadata", None)
72
+ if metadata is None and isinstance(vector, dict):
73
+ metadata = vector.get("metadata", {})
74
+
75
+ if not isinstance(metadata, dict):
76
+ metadata = {}
77
+
78
+ text = metadata.get("text")
79
+ if not text:
80
+ continue
81
+
82
+ all_chunks.append(
83
+ {
84
+ "id": vector_id,
85
+ "metadata": {
86
+ "text": text,
87
+ "title": metadata.get("title", ""),
88
+ "url": metadata.get("url", ""),
89
+ },
90
+ }
91
+ )
92
+ vector_count += 1
93
+
94
+ ns_label = namespace if namespace else "<default>"
95
+ print(f"Loaded {vector_count} chunks from namespace {ns_label}")
96
+
97
+ print(f"Total loaded chunks for BM25 corpus: {len(all_chunks)}")
98
+ return all_chunks
99
+
100
+
101
+ def _build_models(hf_token: str) -> dict[str, Any]:
102
+ return {
103
+ "Llama-3-8B": Llama3_8B(token=hf_token),
104
+ "Mistral-7B": Mistral_7b(token=hf_token),
105
+ "Qwen-2.5": Qwen2_5(token=hf_token),
106
+ "DeepSeek-V3": DeepSeek_V3(token=hf_token),
107
+ "TinyAya": TinyAya(token=hf_token),
108
+ }
109
+
110
+
111
+ def main() -> None:
112
+ pipeline_start = time.perf_counter()
113
+ load_dotenv()
114
+
115
+ hf_token = os.getenv("HF_TOKEN")
116
+ pinecone_api_key = os.getenv("PINECONE_API_KEY")
117
+
118
+ if not pinecone_api_key:
119
+ raise ValueError("PINECONE_API_KEY not found in environment variables")
120
+ if not hf_token:
121
+ raise ValueError("HF_TOKEN not found in environment variables")
122
+
123
+ # Configuration
124
+ # Query defined here
125
+ query = "How do transformers handle long sequences?"
126
+ index_name = "arxiv-index"
127
+ embed_model_name = "all-MiniLM-L6-v2"
128
+
129
+ index = get_pinecone_index(
130
+ api_key=pinecone_api_key,
131
+ index_name=index_name,
132
+ dimension=384,
133
+ metric="cosine",
134
+ )
135
+
136
+ # Load text metadata from Pinecone once and use it as the BM25 corpus.
137
+ load_start = time.perf_counter()
138
+ final_chunks = _load_chunks_from_pinecone(index)
139
+ load_time = time.perf_counter() - load_start
140
+ print(f"Chunk load time: {load_time:.3f}s")
141
+ if not final_chunks:
142
+ raise ValueError(
143
+ "No chunks found in Pinecone index metadata. Run your indexing pipeline once before query-only mode."
144
+ )
145
+
146
+ # Using ChunkProcessor and HybridRetriever object
147
+ #using the same pattern as main.py
148
+
149
+ proc = ChunkProcessor(model_name=embed_model_name, verbose=True)
150
+ retriever = HybridRetriever(final_chunks, proc.encoder, verbose=True)
151
+
152
+ retrieval_start = time.perf_counter()
153
+ context_chunks = retriever.search(
154
+ query,
155
+ index,
156
+ mode="hybrid",
157
+ rerank_strategy="cross-encoder",
158
+ use_mmr=True,
159
+ top_k=10,
160
+ final_k=5,
161
+ )
162
+ retrieval_time = time.perf_counter() - retrieval_start
163
+ print(f"Retrieval call time: {retrieval_time:.3f}s")
164
+
165
+ if not context_chunks:
166
+ print("No context chunks retrieved. Check your query and index contents.")
167
+ return
168
+
169
+ #usign Raggenerator object to get answer from retrieved context
170
+ rag_engine = RAGGenerator()
171
+ models = _build_models(hf_token)
172
+ total_generation_time = 0.0
173
+
174
+ for name, model in models.items():
175
+ print(f"\n--- {name} ---")
176
+ try:
177
+ model_gen_start = time.perf_counter()
178
+ answer = rag_engine.get_answer(model, query, context_chunks, temperature=0.1)
179
+ model_gen_time = time.perf_counter() - model_gen_start
180
+ total_generation_time += model_gen_time
181
+ print(answer)
182
+ print(f"Generation time ({name}): {model_gen_time:.3f}s")
183
+ except Exception as exc:
184
+ print(f"Error: {exc}")
185
+
186
+ pipeline_time = time.perf_counter() - pipeline_start
187
+ print("\nTiming Summary:")
188
+ print(f" Chunk Load: {load_time:.3f}s")
189
+ print(f" Retrieval: {retrieval_time:.3f}s")
190
+ print(f" Generation (all models): {total_generation_time:.3f}s")
191
+ print(f" End-to-end: {pipeline_time:.3f}s")
192
+
193
+
194
+ if __name__ == "__main__":
195
+ 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