Benaah commited on
Commit
6a059d3
·
verified ·
1 Parent(s): 99cd274

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. Dockerfile +50 -0
  3. README.md +26 -10
  4. components.json +22 -0
  5. jsconfig.json +10 -0
  6. next.config.ts +18 -0
  7. package-lock.json +0 -0
  8. package.json +60 -0
  9. postcss.config.mjs +7 -0
  10. public/.nojekyll +0 -0
  11. public/file.svg +1 -0
  12. public/globe.svg +1 -0
  13. public/logo.png +0 -0
  14. public/next.svg +1 -0
  15. public/vercel.svg +1 -0
  16. public/window.svg +1 -0
  17. src/app/admin/agent-monitoring/page.tsx +17 -0
  18. src/app/admin/analytics/page.tsx +364 -0
  19. src/app/admin/blog/page.tsx +1007 -0
  20. src/app/admin/knowledge/page.tsx +279 -0
  21. src/app/admin/models/page.tsx +327 -0
  22. src/app/admin/page.tsx +1144 -0
  23. src/app/admin/rag/page.tsx +344 -0
  24. src/app/admin/settings/page.tsx +303 -0
  25. src/app/admin/training/page.tsx +273 -0
  26. src/app/admin/users/page.tsx +711 -0
  27. src/app/api/admin/agent-metrics/route.ts +37 -0
  28. src/app/api/admin/query-logs/route.ts +41 -0
  29. src/app/api/admin/retrain/route.ts +38 -0
  30. src/app/api/admin/review-queue/[id]/approve/route.ts +45 -0
  31. src/app/api/admin/review-queue/[id]/reject/route.ts +47 -0
  32. src/app/api/admin/review-queue/route.ts +37 -0
  33. src/app/api/cache/admin/analytics/route.ts +48 -0
  34. src/app/api/cache/admin/config/route.ts +63 -0
  35. src/app/api/cache/admin/database-storage/route.ts +48 -0
  36. src/app/api/cache/admin/databases/route.ts +48 -0
  37. src/app/api/cache/admin/documents/route.ts +51 -0
  38. src/app/api/cache/admin/stats/route.ts +48 -0
  39. src/app/api/cache/admin/users/route.ts +56 -0
  40. src/app/api/cache/sessions/[sessionId]/messages/route.ts +79 -0
  41. src/app/api/cache/sessions/route.ts +73 -0
  42. src/app/api/livekit-token/route.ts +133 -0
  43. src/app/auth/forgot-password/page.tsx +342 -0
  44. src/app/auth/reset-password/page.tsx +221 -0
  45. src/app/auth/signin/page.tsx +104 -0
  46. src/app/auth/signup/page.tsx +255 -0
  47. src/app/blog/[slug]/page.tsx +243 -0
  48. src/app/blog/page.tsx +395 -0
  49. src/app/chat/page.tsx +145 -0
  50. src/app/developers/constants.ts +158 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ src/app/favicon.ico filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AmaniQuery Frontend - Hugging Face Spaces Dockerfile
2
+ # Next.js frontend
3
+
4
+ # Build stage
5
+ FROM node:20-alpine AS builder
6
+
7
+ WORKDIR /app
8
+
9
+ # Environment for build
10
+ ENV NEXT_TELEMETRY_DISABLED=1
11
+ ENV NODE_ENV=production
12
+
13
+ # Copy package files
14
+ COPY package*.json ./
15
+
16
+ # Install dependencies
17
+ RUN npm ci
18
+
19
+ # Copy source
20
+ COPY . .
21
+
22
+ # Build Next.js app
23
+ RUN npm run build
24
+
25
+ # Production stage
26
+ FROM node:20-alpine AS runner
27
+
28
+ WORKDIR /app
29
+
30
+ ENV NODE_ENV=production
31
+ ENV NEXT_TELEMETRY_DISABLED=1
32
+ ENV PORT=3000
33
+
34
+ # Create non-root user
35
+ RUN addgroup --system --gid 1001 nodejs
36
+ RUN adduser --system --uid 1001 nextjs
37
+
38
+ # Copy built assets
39
+ COPY --from=builder /app/public ./public
40
+ COPY --from=builder /app/.next/standalone ./
41
+ COPY --from=builder /app/.next/static ./.next/static
42
+
43
+ USER nextjs
44
+
45
+ # HF Spaces PORT
46
+ EXPOSE 3000
47
+
48
+ ENV HOSTNAME="0.0.0.0"
49
+
50
+ CMD ["node", "server.js"]
README.md CHANGED
@@ -1,10 +1,26 @@
1
- ---
2
- title: Amaniquery Frontend
3
- emoji: 📈
4
- colorFrom: yellow
5
- colorTo: indigo
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: AmaniQuery
3
+ emoji: 🔍
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 3000
8
+ pinned: true
9
+ ---
10
+
11
+ # AmaniQuery Frontend
12
+
13
+ AI-Powered Legal & Policy Research Assistant for Kenya.
14
+
15
+ ## Features
16
+
17
+ - 🔍 Natural language search for Kenyan laws and bills
18
+ - 💬 Chat with AI about legal documents
19
+ - 🎤 Voice interface for queries
20
+ - 📊 Research reports and analysis
21
+ - 🔐 User authentication and saved chats
22
+
23
+ ## API Backend
24
+
25
+ This frontend connects to:
26
+ - Backend API: `https://amaniquery-backend.hf.space`
components.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "tailwind.config.js",
8
+ "css": "src/app/globals.css",
9
+ "baseColor": "zinc",
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
+ }
jsconfig.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "baseUrl": ".",
4
+ "paths": {
5
+ "@/*": ["./src/*"]
6
+ }
7
+ },
8
+ "include": ["src/**/*"]
9
+ }
10
+
next.config.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { NextConfig } from "next";
2
+
3
+
4
+ const nextConfig: NextConfig = {
5
+ output: 'standalone',
6
+ images: {
7
+ remotePatterns: [
8
+ {
9
+ protocol: 'https',
10
+ hostname: 'res.cloudinary.com',
11
+ pathname: '/**',
12
+ },
13
+ ],
14
+ },
15
+
16
+ };
17
+
18
+ export default nextConfig;
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "version": "0.3.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build --webpack",
8
+ "start": "next start",
9
+ "lint": "eslint"
10
+ },
11
+ "dependencies": {
12
+ "@radix-ui/react-checkbox": "^1.3.3",
13
+ "@radix-ui/react-dialog": "^1.1.15",
14
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
15
+ "@radix-ui/react-label": "^2.1.8",
16
+ "@radix-ui/react-popover": "^1.1.15",
17
+ "@radix-ui/react-progress": "^1.1.8",
18
+ "@radix-ui/react-scroll-area": "^1.2.10",
19
+ "@radix-ui/react-select": "^2.2.6",
20
+ "@radix-ui/react-slot": "^1.2.4",
21
+ "@radix-ui/react-switch": "^1.2.6",
22
+ "@radix-ui/react-tabs": "^1.1.13",
23
+ "@radix-ui/react-tooltip": "^1.2.8",
24
+ "@tailwindcss/postcss": "^4.1.17",
25
+ "@upstash/redis": "^1.35.7",
26
+ "@vercel/analytics": "^1.6.1",
27
+ "@vercel/speed-insights": "^1.2.0",
28
+ "class-variance-authority": "^0.7.1",
29
+ "clsx": "^2.1.1",
30
+ "framer-motion": "^12.23.26",
31
+ "livekit-client": "^2.16.0",
32
+ "livekit-server-sdk": "^2.14.2",
33
+ "lucide-react": "^0.554.0",
34
+ "next": "16.0.10",
35
+ "next-themes": "^0.4.6",
36
+ "react": "19.2.3",
37
+ "react-diff-viewer-continued": "^3.4.0",
38
+ "react-dom": "19.2.3",
39
+ "react-markdown": "^10.1.0",
40
+ "rehype-highlight": "^7.0.2",
41
+ "rehype-raw": "^7.0.0",
42
+ "remark-gfm": "^4.0.1",
43
+ "sonner": "^2.0.7",
44
+ "tailwind-merge": "^3.4.0",
45
+ "tailwindcss": "^4.1.17"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22",
49
+ "@types/react": "^19",
50
+ "@types/react-dom": "^19",
51
+ "autoprefixer": "^10.4.20",
52
+ "baseline-browser-mapping": "^2.9.4",
53
+ "eslint": "^9",
54
+ "eslint-config-next": "16.0.10",
55
+ "postcss": "^8.4.49",
56
+ "tailwindcss": "^4.1.17",
57
+ "tw-animate-css": "^1.4.0",
58
+ "typescript": "^5"
59
+ }
60
+ }
postcss.config.mjs ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ const config = {
2
+ plugins: {
3
+ '@tailwindcss/postcss': {},
4
+ },
5
+ };
6
+
7
+ export default config;
public/.nojekyll ADDED
File without changes
public/file.svg ADDED
public/globe.svg ADDED
public/logo.png ADDED
public/next.svg ADDED
public/vercel.svg ADDED
public/window.svg ADDED
src/app/admin/agent-monitoring/page.tsx ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { AgentMonitoring } from "@/components/AgentMonitoring"
4
+ import { AdminSidebar } from "@/components/admin-sidebar"
5
+
6
+ export default function AgentMonitoringPage() {
7
+ return (
8
+ <>
9
+ <AdminSidebar />
10
+ <div className="ml-0 md:ml-5 transition-all duration-300">
11
+ <div className="container mx-auto py-6 px-4">
12
+ <AgentMonitoring />
13
+ </div>
14
+ </div>
15
+ </>
16
+ )
17
+ }
src/app/admin/analytics/page.tsx ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useState, useEffect } from "react"
4
+ import { useRouter } from "next/navigation"
5
+ import { useAuth } from "@/lib/auth-context"
6
+ import { AdminSidebar } from "@/components/admin-sidebar"
7
+ import { ThemeToggle } from "@/components/theme-toggle"
8
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
9
+ import { Badge } from "@/components/ui/badge"
10
+ import { toast } from "sonner"
11
+ import {
12
+ Activity,
13
+ Users,
14
+ BarChart3,
15
+ Loader2,
16
+ Globe,
17
+ Key,
18
+ } from "lucide-react"
19
+
20
+ const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
21
+
22
+ interface AnalyticsData {
23
+ total_users: number
24
+ total_integrations: number
25
+ total_api_keys: number
26
+ total_requests_today: number
27
+ total_requests_this_week: number
28
+ total_requests_this_month: number
29
+ total_cost_today: number
30
+ total_cost_this_week: number
31
+ total_cost_this_month: number
32
+ top_users: Array<{ user_id: string; email: string; request_count: number }>
33
+ top_integrations: Array<{ integration_id: string; name: string; request_count: number }>
34
+ top_endpoints: Array<{ endpoint: string; request_count: number }>
35
+ requests_over_time: Array<{ date: string; count: number }>
36
+ }
37
+
38
+ function FeedbackTrainingMetrics() {
39
+ const [metrics, setMetrics] = useState<any>(null)
40
+ const [loading, setLoading] = useState(true)
41
+
42
+ useEffect(() => {
43
+ const fetchMetrics = async () => {
44
+ try {
45
+ const [feedbackRes, trainingRes, clusterRes] = await Promise.all([
46
+ fetch("/api/v1/feedback/analytics"),
47
+ fetch("/api/v1/finetuning/stats"),
48
+ fetch("/api/v1/clusters/stats")
49
+ ])
50
+
51
+ const data = {
52
+ feedback: feedbackRes.ok ? await feedbackRes.json() : null,
53
+ training: trainingRes.ok ? await trainingRes.json() : null,
54
+ clusters: clusterRes.ok ? await clusterRes.json() : null
55
+ }
56
+
57
+ setMetrics(data)
58
+ } catch (error) {
59
+ console.error("Failed to fetch continuous learning metrics:", error)
60
+ } finally {
61
+ setLoading(false)
62
+ }
63
+ }
64
+
65
+ fetchMetrics()
66
+ }, [])
67
+
68
+ if (loading) {
69
+ return (
70
+ <div className="flex justify-center py-6">
71
+ <Loader2 className="w-6 h-6 animate-spin" />
72
+ </div>
73
+ )
74
+ }
75
+
76
+ if (!metrics) return null
77
+
78
+ return (
79
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
80
+ <Card className="border-l-4 border-l-blue-500">
81
+ <CardHeader className="pb-2">
82
+ <CardTitle className="text-sm font-medium">Total Feedback</CardTitle>
83
+ </CardHeader>
84
+ <CardContent>
85
+ <div className="text-2xl font-bold">
86
+ {metrics.feedback?.feedback_distribution?.total || 0}
87
+ </div>
88
+ <p className="text-xs text-muted-foreground">
89
+ {metrics.feedback?.feedback_distribution?.positive_rate?.toFixed(1)}% positive
90
+ </p>
91
+ </CardContent>
92
+ </Card>
93
+
94
+ <Card className="border-l-4 border-l-green-500">
95
+ <CardHeader className="pb-2">
96
+ <CardTitle className="text-sm font-medium">Training Ready</CardTitle>
97
+ </CardHeader>
98
+ <CardContent>
99
+ <div className="text-2xl font-bold">
100
+ {metrics.training?.kept_for_training || 0}
101
+ </div>
102
+ <p className="text-xs text-muted-foreground">
103
+ Avg: {metrics.training?.average_score?.toFixed(2) || "N/A"}
104
+ </p>
105
+ </CardContent>
106
+ </Card>
107
+
108
+ <Card className="border-l-4 border-l-orange-500">
109
+ <CardHeader className="pb-2">
110
+ <CardTitle className="text-sm font-medium">Pending Export</CardTitle>
111
+ </CardHeader>
112
+ <CardContent>
113
+ <div className="text-2xl font-bold">
114
+ {metrics.training?.awaiting_export || 0}
115
+ </div>
116
+ <p className="text-xs text-muted-foreground">Ready for fine-tuning</p>
117
+ </CardContent>
118
+ </Card>
119
+
120
+ <Card className="border-l-4 border-l-purple-500">
121
+ <CardHeader className="pb-2">
122
+ <CardTitle className="text-sm font-medium">Task Clusters</CardTitle>
123
+ </CardHeader>
124
+ <CardContent>
125
+ <div className="text-2xl font-bold">
126
+ {metrics.clusters?.active_clusters || 0}
127
+ </div>
128
+ <p className="text-xs text-muted-foreground">
129
+ {metrics.clusters?.total_queries_classified || 0} queries classified
130
+ </p>
131
+ </CardContent>
132
+ </Card>
133
+ </div>
134
+ )
135
+ }
136
+
137
+
138
+ export default function AdminAnalyticsPage() {
139
+ const { isAuthenticated, isAdmin, loading } = useAuth()
140
+ const router = useRouter()
141
+ const [analytics, setAnalytics] = useState<AnalyticsData | null>(null)
142
+ const [loadingAnalytics, setLoadingAnalytics] = useState(true)
143
+
144
+ useEffect(() => {
145
+ if (!loading && !isAuthenticated) {
146
+ router.push("/auth/signin?redirect=/admin/analytics")
147
+ } else if (!loading && isAuthenticated && !isAdmin) {
148
+ router.push("/chat")
149
+ }
150
+ }, [isAuthenticated, isAdmin, loading, router])
151
+
152
+ useEffect(() => {
153
+ if (isAuthenticated && isAdmin) {
154
+ fetchAnalytics()
155
+ }
156
+ }, [isAuthenticated, isAdmin])
157
+
158
+ const fetchAnalytics = async () => {
159
+ setLoadingAnalytics(true)
160
+ try {
161
+ const sessionToken = localStorage.getItem("session_token")
162
+ const response = await fetch(`/api/cache/admin/analytics`, {
163
+ headers: {
164
+ "X-Session-Token": sessionToken || "",
165
+ },
166
+ })
167
+
168
+ if (response.ok) {
169
+ const data = await response.json()
170
+ setAnalytics(data)
171
+ const cacheStatus = response.headers.get("X-Cache")
172
+ if (cacheStatus) console.log(`Analytics cache: ${cacheStatus}`)
173
+ } else {
174
+ toast.error("Failed to fetch analytics")
175
+ }
176
+ } catch {
177
+ toast.error("Failed to fetch analytics")
178
+ } finally {
179
+ setLoadingAnalytics(false)
180
+ }
181
+ }
182
+
183
+ if (loading || !isAuthenticated || !isAdmin) {
184
+ return (
185
+ <div className="min-h-screen flex items-center justify-center">
186
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
187
+ </div>
188
+ )
189
+ }
190
+
191
+ return (
192
+ <div className="min-h-screen bg-background flex">
193
+ <AdminSidebar />
194
+ <div className="flex-1 ml-0 md:ml-[20px] p-4 md:p-6">
195
+ <div className="absolute top-4 right-4 z-10">
196
+ <ThemeToggle />
197
+ </div>
198
+ <div className="max-w-7xl mx-auto space-y-6">
199
+ <div>
200
+ <h1 className="text-3xl font-bold flex items-center gap-2">
201
+ <BarChart3 className="w-8 h-8" />
202
+ Analytics Dashboard
203
+ </h1>
204
+ <p className="text-muted-foreground">System usage and performance metrics</p>
205
+ </div>
206
+
207
+ {loadingAnalytics ? (
208
+ <div className="flex items-center justify-center py-12">
209
+ <Loader2 className="w-6 h-6 animate-spin text-primary" />
210
+ </div>
211
+ ) : analytics ? (
212
+ <>
213
+ {/* AmaniQ Continuous Learning Metrics */}
214
+ <div className="mb-6">
215
+ <h2 className="text-xl font-semibold mb-4">AmaniQ Continuous Learning</h2>
216
+ <FeedbackTrainingMetrics />
217
+ </div>
218
+
219
+ {/* Original System Analytics */}
220
+ {/* Overview Stats */}
221
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
222
+ <Card>
223
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
224
+ <CardTitle className="text-sm font-medium">Total Users</CardTitle>
225
+ <Users className="h-4 w-4 text-muted-foreground" />
226
+ </CardHeader>
227
+ <CardContent>
228
+ <div className="text-2xl font-bold">{analytics.total_users}</div>
229
+ </CardContent>
230
+ </Card>
231
+
232
+ <Card>
233
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
234
+ <CardTitle className="text-sm font-medium">Integrations</CardTitle>
235
+ <Globe className="h-4 w-4 text-muted-foreground" />
236
+ </CardHeader>
237
+ <CardContent>
238
+ <div className="text-2xl font-bold">{analytics.total_integrations}</div>
239
+ </CardContent>
240
+ </Card>
241
+
242
+ <Card>
243
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
244
+ <CardTitle className="text-sm font-medium">API Keys</CardTitle>
245
+ <Key className="h-4 w-4 text-muted-foreground" />
246
+ </CardHeader>
247
+ <CardContent>
248
+ <div className="text-2xl font-bold">{analytics.total_api_keys}</div>
249
+ </CardContent>
250
+ </Card>
251
+
252
+ <Card>
253
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
254
+ <CardTitle className="text-sm font-medium">Requests Today</CardTitle>
255
+ <Activity className="h-4 w-4 text-muted-foreground" />
256
+ </CardHeader>
257
+ <CardContent>
258
+ <div className="text-2xl font-bold">{analytics.total_requests_today}</div>
259
+ </CardContent>
260
+ </Card>
261
+ </div>
262
+
263
+ {/* Request Stats */}
264
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
265
+ <Card>
266
+ <CardHeader>
267
+ <CardTitle className="text-sm font-medium">This Week</CardTitle>
268
+ </CardHeader>
269
+ <CardContent>
270
+ <div className="text-2xl font-bold">{analytics.total_requests_this_week}</div>
271
+ <p className="text-xs text-muted-foreground">Requests</p>
272
+ </CardContent>
273
+ </Card>
274
+
275
+ <Card>
276
+ <CardHeader>
277
+ <CardTitle className="text-sm font-medium">This Month</CardTitle>
278
+ </CardHeader>
279
+ <CardContent>
280
+ <div className="text-2xl font-bold">{analytics.total_requests_this_month}</div>
281
+ <p className="text-xs text-muted-foreground">Requests</p>
282
+ </CardContent>
283
+ </Card>
284
+
285
+ <Card>
286
+ <CardHeader>
287
+ <CardTitle className="text-sm font-medium">Cost This Month</CardTitle>
288
+ </CardHeader>
289
+ <CardContent>
290
+ <div className="text-2xl font-bold">
291
+ ${analytics.total_cost_this_month.toFixed(2)}
292
+ </div>
293
+ <p className="text-xs text-muted-foreground">Total cost</p>
294
+ </CardContent>
295
+ </Card>
296
+ </div>
297
+
298
+ {/* Top Users */}
299
+ {analytics.top_users && analytics.top_users.length > 0 && (
300
+ <Card>
301
+ <CardHeader>
302
+ <CardTitle>Top Users</CardTitle>
303
+ </CardHeader>
304
+ <CardContent>
305
+ <div className="space-y-2">
306
+ {analytics.top_users.slice(0, 10).map((user, index) => (
307
+ <div
308
+ key={user.user_id}
309
+ className="flex items-center justify-between p-2 rounded-lg hover:bg-accent"
310
+ >
311
+ <div className="flex items-center gap-3">
312
+ <Badge variant="outline">{index + 1}</Badge>
313
+ <span className="font-medium">{user.email}</span>
314
+ </div>
315
+ <span className="text-sm text-muted-foreground">
316
+ {user.request_count} requests
317
+ </span>
318
+ </div>
319
+ ))}
320
+ </div>
321
+ </CardContent>
322
+ </Card>
323
+ )}
324
+
325
+ {/* Top Endpoints */}
326
+ {analytics.top_endpoints && analytics.top_endpoints.length > 0 && (
327
+ <Card>
328
+ <CardHeader>
329
+ <CardTitle>Top Endpoints</CardTitle>
330
+ </CardHeader>
331
+ <CardContent>
332
+ <div className="space-y-2">
333
+ {analytics.top_endpoints.slice(0, 10).map((endpoint, index) => (
334
+ <div
335
+ key={endpoint.endpoint}
336
+ className="flex items-center justify-between p-2 rounded-lg hover:bg-accent"
337
+ >
338
+ <div className="flex items-center gap-3">
339
+ <Badge variant="outline">{index + 1}</Badge>
340
+ <code className="text-sm font-mono">{endpoint.endpoint}</code>
341
+ </div>
342
+ <span className="text-sm text-muted-foreground">
343
+ {endpoint.request_count} requests
344
+ </span>
345
+ </div>
346
+ ))}
347
+ </div>
348
+ </CardContent>
349
+ </Card>
350
+ )}
351
+ </>
352
+ ) : (
353
+ <Card>
354
+ <CardContent className="py-12 text-center text-muted-foreground">
355
+ No analytics data available
356
+ </CardContent>
357
+ </Card>
358
+ )}
359
+ </div>
360
+ </div>
361
+ </div>
362
+ )
363
+ }
364
+
src/app/admin/blog/page.tsx ADDED
@@ -0,0 +1,1007 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useState, useEffect } from "react"
4
+ import { useRouter } from "next/navigation"
5
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
6
+ import { Button } from "@/components/ui/button"
7
+ import { Badge } from "@/components/ui/badge"
8
+ import { Input } from "@/components/ui/input"
9
+ import { Textarea } from "@/components/ui/textarea"
10
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
11
+ import { AdminSidebar } from "@/components/admin-sidebar"
12
+ import { ThemeToggle } from "@/components/theme-toggle"
13
+ import { BlogEditor } from "@/components/blog-editor"
14
+ import { useAuth } from "@/lib/auth-context"
15
+ import {
16
+ Plus,
17
+ Edit,
18
+ Trash2,
19
+ Eye,
20
+ Save,
21
+ X,
22
+ Upload,
23
+ FileText,
24
+ Tag,
25
+ Folder,
26
+ } from "lucide-react"
27
+ import Link from "next/link"
28
+
29
+ const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
30
+
31
+ const getAuthHeaders = (): Record<string, string> => {
32
+ const token = localStorage.getItem("session_token")
33
+ const headers: Record<string, string> = {}
34
+ if (token) {
35
+ headers["X-Session-Token"] = token
36
+ }
37
+ return headers
38
+ }
39
+
40
+ /**
41
+ * Removes keys with empty string values from the payload.
42
+ * This ensures optional fields like slug and featured_image_url
43
+ * are not sent as empty strings, which would fail backend validation.
44
+ */
45
+ const cleanPostPayload = (payload: Record<string, unknown>): Record<string, unknown> => {
46
+ const cleaned: Record<string, unknown> = {}
47
+ for (const [key, value] of Object.entries(payload)) {
48
+ // Keep the key if it's not an empty string
49
+ // Arrays are always included (even if empty)
50
+ if (value !== "" || Array.isArray(value)) {
51
+ cleaned[key] = value
52
+ }
53
+ }
54
+ return cleaned
55
+ }
56
+
57
+ interface BlogPost {
58
+ id: string
59
+ title: string
60
+ slug: string
61
+ markdown_content?: string
62
+ html_content?: string
63
+ excerpt?: string
64
+ post_type: string
65
+ status: string
66
+ featured_image_url?: string
67
+ author: {
68
+ id: string
69
+ name?: string
70
+ email: string
71
+ }
72
+ categories: Array<{ id: string; name: string; slug: string }>
73
+ tags: Array<{ id: string; name: string; slug: string }>
74
+ published_at?: string
75
+ created_at: string
76
+ }
77
+
78
+ interface Category {
79
+ id: string
80
+ name: string
81
+ slug: string
82
+ description?: string
83
+ }
84
+
85
+ interface Tag {
86
+ id: string
87
+ name: string
88
+ slug: string
89
+ }
90
+
91
+ export default function AdminBlogPage() {
92
+ const { isAuthenticated, isAdmin, loading } = useAuth()
93
+ const router = useRouter()
94
+ const [posts, setPosts] = useState<BlogPost[]>([])
95
+ const [categories, setCategories] = useState<Category[]>([])
96
+ const [tags, setTags] = useState<Tag[]>([])
97
+ const [loadingData, setLoadingData] = useState(true)
98
+ const [activeTab, setActiveTab] = useState<"posts" | "categories" | "tags">("posts")
99
+ const [editingPost, setEditingPost] = useState<BlogPost | null>(null)
100
+ const [editingCategory, setEditingCategory] = useState<Category | null>(null)
101
+ const [editingTag, setEditingTag] = useState<Tag | null>(null)
102
+ const [showPostForm, setShowPostForm] = useState(false)
103
+ const [postSubmitting, setPostSubmitting] = useState(false) // loading state for create/update
104
+
105
+ // Post form state
106
+ const [postForm, setPostForm] = useState({
107
+ title: "",
108
+ slug: "",
109
+ markdown_content: "",
110
+ html_content: "",
111
+ excerpt: "",
112
+ post_type: "news",
113
+ status: "draft",
114
+ featured_image_url: "",
115
+ category_ids: [] as string[],
116
+ tag_ids: [] as string[],
117
+ })
118
+
119
+ // Category form state
120
+ const [categoryForm, setCategoryForm] = useState({
121
+ name: "",
122
+ slug: "",
123
+ description: "",
124
+ })
125
+
126
+ // Tag form state
127
+ const [tagForm, setTagForm] = useState({
128
+ name: "",
129
+ slug: "",
130
+ })
131
+
132
+ useEffect(() => {
133
+ if (!loading && !isAuthenticated) {
134
+ router.push("/auth/signin?redirect=/admin/blog")
135
+ } else if (!loading && isAuthenticated && !isAdmin) {
136
+ router.push("/chat")
137
+ }
138
+ }, [isAuthenticated, isAdmin, loading, router])
139
+
140
+ useEffect(() => {
141
+ if (isAuthenticated && isAdmin) {
142
+ fetchPosts()
143
+ fetchCategories()
144
+ fetchTags()
145
+ }
146
+ }, [isAuthenticated, isAdmin])
147
+
148
+ const fetchPosts = async () => {
149
+ try {
150
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts?page=1&page_size=100`, {
151
+ headers: getAuthHeaders(),
152
+ })
153
+ if (response.ok) {
154
+ const data = await response.json()
155
+ setPosts(data.posts || [])
156
+ }
157
+ } catch (error) {
158
+ console.error("Failed to fetch posts:", error)
159
+ } finally {
160
+ setLoadingData(false)
161
+ }
162
+ }
163
+
164
+ const fetchCategories = async () => {
165
+ try {
166
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/categories`)
167
+ if (response.ok) {
168
+ const data = await response.json()
169
+ setCategories(data)
170
+ }
171
+ } catch (error) {
172
+ console.error("Failed to fetch categories:", error)
173
+ }
174
+ }
175
+
176
+ const fetchTags = async () => {
177
+ try {
178
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/tags`)
179
+ if (response.ok) {
180
+ const data = await response.json()
181
+ setTags(data)
182
+ }
183
+ } catch (error) {
184
+ console.error("Failed to fetch tags:", error)
185
+ }
186
+ }
187
+
188
+ const handleCreatePost = async () => {
189
+ setPostSubmitting(true)
190
+ try {
191
+ const cleanedPayload = cleanPostPayload(postForm)
192
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts`, {
193
+ method: "POST",
194
+ headers: {
195
+ "Content-Type": "application/json",
196
+ ...getAuthHeaders(),
197
+ },
198
+ body: JSON.stringify(cleanedPayload),
199
+ })
200
+ if (response.ok) {
201
+ await fetchPosts()
202
+ resetPostForm()
203
+ setShowPostForm(false)
204
+ } else {
205
+ const error = await response.json()
206
+ console.error('Create post error response:', error)
207
+ alert(`Failed to create post: ${error.detail || "Unknown error"}`)
208
+ }
209
+ } catch (error) {
210
+ console.error("Failed to create post:", error)
211
+ alert("Failed to create post")
212
+ } finally {
213
+ setPostSubmitting(false)
214
+ }
215
+ }
216
+
217
+ const handleUpdatePost = async () => {
218
+ if (!editingPost) return
219
+ try {
220
+ const cleanedPayload = cleanPostPayload(postForm)
221
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts/${editingPost.id}`, {
222
+ method: "PUT",
223
+ headers: {
224
+ "Content-Type": "application/json",
225
+ ...getAuthHeaders(),
226
+ },
227
+ body: JSON.stringify(cleanedPayload),
228
+ })
229
+ if (response.ok) {
230
+ await fetchPosts()
231
+ setEditingPost(null)
232
+ resetPostForm()
233
+ } else {
234
+ const error = await response.json()
235
+ alert(`Failed to update post: ${error.detail || "Unknown error"}`)
236
+ }
237
+ } catch (error) {
238
+ console.error("Failed to update post:", error)
239
+ alert("Failed to update post")
240
+ }
241
+ }
242
+
243
+ const handleDeletePost = async (postId: string) => {
244
+ if (!confirm("Are you sure you want to delete this post?")) return
245
+ try {
246
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts/${postId}`, {
247
+ method: "DELETE",
248
+ headers: getAuthHeaders(),
249
+ })
250
+ if (response.ok) {
251
+ await fetchPosts()
252
+ } else {
253
+ alert("Failed to delete post")
254
+ }
255
+ } catch (error) {
256
+ console.error("Failed to delete post:", error)
257
+ alert("Failed to delete post")
258
+ }
259
+ }
260
+
261
+ const handleUploadFeaturedImage = async (postId: string, file: File) => {
262
+ const formData = new FormData()
263
+ formData.append("file", file)
264
+ try {
265
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts/${postId}/featured-image`, {
266
+ method: "POST",
267
+ headers: getAuthHeaders(),
268
+ body: formData,
269
+ })
270
+ if (response.ok) {
271
+ const data = await response.json()
272
+ setPostForm({ ...postForm, featured_image_url: data.featured_image_url })
273
+ await fetchPosts()
274
+ } else {
275
+ alert("Failed to upload image")
276
+ }
277
+ } catch (error) {
278
+ console.error("Failed to upload image:", error)
279
+ alert("Failed to upload image")
280
+ }
281
+ }
282
+
283
+ const resetPostForm = () => {
284
+ setPostForm({
285
+ title: "",
286
+ slug: "",
287
+ markdown_content: "",
288
+ html_content: "",
289
+ excerpt: "",
290
+ post_type: "news",
291
+ status: "draft",
292
+ featured_image_url: "",
293
+ category_ids: [],
294
+ tag_ids: [],
295
+ })
296
+ }
297
+
298
+ const startEditPost = (post: BlogPost) => {
299
+ setEditingPost(post)
300
+ setPostForm({
301
+ title: post.title,
302
+ slug: post.slug,
303
+ markdown_content: post.markdown_content || "",
304
+ html_content: post.html_content || "",
305
+ excerpt: post.excerpt || "",
306
+ post_type: post.post_type,
307
+ status: post.status,
308
+ featured_image_url: post.featured_image_url || "",
309
+ category_ids: post.categories.map((c) => c.id),
310
+ tag_ids: post.tags.map((t) => t.id),
311
+ })
312
+ setShowPostForm(true)
313
+ }
314
+
315
+ const handleCreateCategory = async () => {
316
+ try {
317
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/categories`, {
318
+ method: "POST",
319
+ headers: {
320
+ "Content-Type": "application/json",
321
+ ...getAuthHeaders(),
322
+ },
323
+ body: JSON.stringify(categoryForm),
324
+ })
325
+ if (response.ok) {
326
+ await fetchCategories()
327
+ setCategoryForm({ name: "", slug: "", description: "" })
328
+ setEditingCategory(null)
329
+ } else {
330
+ const error = await response.json()
331
+ alert(`Failed to create category: ${error.detail || "Unknown error"}`)
332
+ }
333
+ } catch (error) {
334
+ console.error("Failed to create category:", error)
335
+ alert("Failed to create category")
336
+ }
337
+ }
338
+
339
+ const handleUpdateCategory = async () => {
340
+ if (!editingCategory) return
341
+ try {
342
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/categories/${editingCategory.id}`, {
343
+ method: "PUT",
344
+ headers: {
345
+ "Content-Type": "application/json",
346
+ ...getAuthHeaders(),
347
+ },
348
+ body: JSON.stringify(categoryForm),
349
+ })
350
+ if (response.ok) {
351
+ await fetchCategories()
352
+ setEditingCategory(null)
353
+ setCategoryForm({ name: "", slug: "", description: "" })
354
+ } else {
355
+ const error = await response.json()
356
+ alert(`Failed to update category: ${error.detail || "Unknown error"}`)
357
+ }
358
+ } catch (error) {
359
+ console.error("Failed to update category:", error)
360
+ alert("Failed to update category")
361
+ }
362
+ }
363
+
364
+ const handleDeleteCategory = async (categoryId: string) => {
365
+ if (!confirm("Are you sure you want to delete this category?")) return
366
+ try {
367
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/categories/${categoryId}`, {
368
+ method: "DELETE",
369
+ headers: getAuthHeaders(),
370
+ })
371
+ if (response.ok) {
372
+ await fetchCategories()
373
+ } else {
374
+ alert("Failed to delete category")
375
+ }
376
+ } catch (error) {
377
+ console.error("Failed to delete category:", error)
378
+ alert("Failed to delete category")
379
+ }
380
+ }
381
+
382
+ const handleCreateTag = async () => {
383
+ try {
384
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/tags`, {
385
+ method: "POST",
386
+ headers: {
387
+ "Content-Type": "application/json",
388
+ ...getAuthHeaders(),
389
+ },
390
+ body: JSON.stringify(tagForm),
391
+ })
392
+ if (response.ok) {
393
+ await fetchTags()
394
+ setTagForm({ name: "", slug: "" })
395
+ setEditingTag(null)
396
+ } else {
397
+ const error = await response.json()
398
+ alert(`Failed to create tag: ${error.detail || "Unknown error"}`)
399
+ }
400
+ } catch (error) {
401
+ console.error("Failed to create tag:", error)
402
+ alert("Failed to create tag")
403
+ }
404
+ }
405
+
406
+ const handleUpdateTag = async () => {
407
+ if (!editingTag) return
408
+ try {
409
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/tags/${editingTag.id}`, {
410
+ method: "PUT",
411
+ headers: {
412
+ "Content-Type": "application/json",
413
+ ...getAuthHeaders(),
414
+ },
415
+ body: JSON.stringify(tagForm),
416
+ })
417
+ if (response.ok) {
418
+ await fetchTags()
419
+ setEditingTag(null)
420
+ setTagForm({ name: "", slug: "" })
421
+ } else {
422
+ const error = await response.json()
423
+ alert(`Failed to update tag: ${error.detail || "Unknown error"}`)
424
+ }
425
+ } catch (error) {
426
+ console.error("Failed to update tag:", error)
427
+ alert("Failed to update tag")
428
+ }
429
+ }
430
+
431
+ const handleDeleteTag = async (tagId: string) => {
432
+ if (!confirm("Are you sure you want to delete this tag?")) return
433
+ try {
434
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/tags/${tagId}`, {
435
+ method: "DELETE",
436
+ headers: getAuthHeaders(),
437
+ })
438
+ if (response.ok) {
439
+ await fetchTags()
440
+ } else {
441
+ alert("Failed to delete tag")
442
+ }
443
+ } catch (error) {
444
+ console.error("Failed to delete tag:", error)
445
+ alert("Failed to delete tag")
446
+ }
447
+ }
448
+
449
+ if (loading || !isAuthenticated || !isAdmin) {
450
+ return (
451
+ <div className="min-h-screen flex items-center justify-center">
452
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
453
+ </div>
454
+ )
455
+ }
456
+
457
+ return (
458
+ <div className="min-h-screen bg-background flex">
459
+ <AdminSidebar />
460
+ <div className="flex-1 ml-0 md:ml-[20px] p-4 md:p-6">
461
+ <div className="absolute top-4 right-4 z-10">
462
+ <ThemeToggle />
463
+ </div>
464
+ <div className="max-w-7xl mx-auto space-y-6">
465
+ <div className="flex items-center justify-between">
466
+ <div>
467
+ <h1 className="text-3xl font-bold">Blog Management</h1>
468
+ <p className="text-muted-foreground">Manage blog posts, categories, and tags</p>
469
+ </div>
470
+ <Link href="/blog">
471
+ <Button variant="outline">
472
+ <Eye className="w-4 h-4 mr-2" />
473
+ View Public Blog
474
+ </Button>
475
+ </Link>
476
+ </div>
477
+
478
+ {/* Tabs */}
479
+ <div className="flex gap-2 border-b">
480
+ <button
481
+ onClick={() => setActiveTab("posts")}
482
+ className={`px-4 py-2 font-medium ${
483
+ activeTab === "posts"
484
+ ? "border-b-2 border-primary text-primary"
485
+ : "text-muted-foreground"
486
+ }`}
487
+ >
488
+ <FileText className="w-4 h-4 inline mr-2" />
489
+ Posts
490
+ </button>
491
+ <button
492
+ onClick={() => setActiveTab("categories")}
493
+ className={`px-4 py-2 font-medium ${
494
+ activeTab === "categories"
495
+ ? "border-b-2 border-primary text-primary"
496
+ : "text-muted-foreground"
497
+ }`}
498
+ >
499
+ <Folder className="w-4 h-4 inline mr-2" />
500
+ Categories
501
+ </button>
502
+ <button
503
+ onClick={() => setActiveTab("tags")}
504
+ className={`px-4 py-2 font-medium ${
505
+ activeTab === "tags"
506
+ ? "border-b-2 border-primary text-primary"
507
+ : "text-muted-foreground"
508
+ }`}
509
+ >
510
+ <Tag className="w-4 h-4 inline mr-2" />
511
+ Tags
512
+ </button>
513
+ </div>
514
+
515
+ {/* Posts Tab */}
516
+ {activeTab === "posts" && (
517
+ <div className="space-y-4">
518
+ {!showPostForm ? (
519
+ <>
520
+ <div className="flex justify-end">
521
+ <Button onClick={() => setShowPostForm(true)}>
522
+ <Plus className="w-4 h-4 mr-2" />
523
+ New Post
524
+ </Button>
525
+ </div>
526
+ <Card>
527
+ <CardContent className="p-0">
528
+ <Table>
529
+ <TableHeader>
530
+ <TableRow>
531
+ <TableHead>Title</TableHead>
532
+ <TableHead>Type</TableHead>
533
+ <TableHead>Status</TableHead>
534
+ <TableHead>Author</TableHead>
535
+ <TableHead>Published</TableHead>
536
+ <TableHead>Actions</TableHead>
537
+ </TableRow>
538
+ </TableHeader>
539
+ <TableBody>
540
+ {posts.map((post) => (
541
+ <TableRow key={post.id}>
542
+ <TableCell className="font-medium">{post.title}</TableCell>
543
+ <TableCell>
544
+ <Badge variant="outline">{post.post_type}</Badge>
545
+ </TableCell>
546
+ <TableCell>
547
+ <Badge
548
+ variant={post.status === "published" ? "default" : "secondary"}
549
+ >
550
+ {post.status}
551
+ </Badge>
552
+ </TableCell>
553
+ <TableCell>{post.author.name || post.author.email}</TableCell>
554
+ <TableCell>
555
+ {post.published_at
556
+ ? new Date(post.published_at).toLocaleDateString()
557
+ : "-"}
558
+ </TableCell>
559
+ <TableCell>
560
+ <div className="flex gap-2">
561
+ <Button
562
+ variant="ghost"
563
+ size="sm"
564
+ onClick={() => startEditPost(post)}
565
+ >
566
+ <Edit className="w-4 h-4" />
567
+ </Button>
568
+ <Link href={`/blog/${post.slug}`} target="_blank">
569
+ <Button variant="ghost" size="sm">
570
+ <Eye className="w-4 h-4" />
571
+ </Button>
572
+ </Link>
573
+ <Button
574
+ variant="ghost"
575
+ size="sm"
576
+ onClick={() => handleDeletePost(post.id)}
577
+ >
578
+ <Trash2 className="w-4 h-4 text-destructive" />
579
+ </Button>
580
+ </div>
581
+ </TableCell>
582
+ </TableRow>
583
+ ))}
584
+ </TableBody>
585
+ </Table>
586
+ </CardContent>
587
+ </Card>
588
+ </>
589
+ ) : (
590
+ <Card>
591
+ <CardHeader>
592
+ <div className="flex items-center justify-between">
593
+ <CardTitle>
594
+ {editingPost ? "Edit Post" : "Create New Post"}
595
+ </CardTitle>
596
+ <Button variant="ghost" onClick={() => {
597
+ setShowPostForm(false)
598
+ setEditingPost(null)
599
+ resetPostForm()
600
+ }}>
601
+ <X className="w-4 h-4" />
602
+ </Button>
603
+ </div>
604
+ </CardHeader>
605
+ <CardContent className="space-y-4">
606
+ <div className="grid grid-cols-2 gap-4">
607
+ <div>
608
+ <label className="text-sm font-medium mb-2 block">Title</label>
609
+ <Input
610
+ value={postForm.title}
611
+ onChange={(e) => setPostForm({ ...postForm, title: e.target.value })}
612
+ placeholder="Post title"
613
+ />
614
+ </div>
615
+ <div>
616
+ <label className="text-sm font-medium mb-2 block">Slug</label>
617
+ <Input
618
+ value={postForm.slug}
619
+ onChange={(e) => setPostForm({ ...postForm, slug: e.target.value })}
620
+ placeholder="url-friendly-slug"
621
+ />
622
+ </div>
623
+ </div>
624
+ <div className="grid grid-cols-2 gap-4">
625
+ <div>
626
+ <label className="text-sm font-medium mb-2 block">Post Type</label>
627
+ <select
628
+ value={postForm.post_type}
629
+ title="Post Type"
630
+ onChange={(e) => setPostForm({ ...postForm, post_type: e.target.value })}
631
+ className="w-full px-3 py-2 border rounded-md"
632
+ >
633
+ <option value="news">News</option>
634
+ <option value="announcement">Announcement</option>
635
+ <option value="update">Update</option>
636
+ </select>
637
+ </div>
638
+ <div>
639
+ <label className="text-sm font-medium mb-2 block">Status</label>
640
+ <select
641
+ value={postForm.status}
642
+ title="Status"
643
+ onChange={(e) => setPostForm({ ...postForm, status: e.target.value })}
644
+ className="w-full px-3 py-2 border rounded-md"
645
+ >
646
+ <option value="draft">Draft</option>
647
+ <option value="published">Published</option>
648
+ </select>
649
+ </div>
650
+ </div>
651
+ <div>
652
+ <label className="text-sm font-medium mb-2 block">Excerpt</label>
653
+ <Textarea
654
+ value={postForm.excerpt}
655
+ onChange={(e) => setPostForm({ ...postForm, excerpt: e.target.value })}
656
+ placeholder="Short excerpt..."
657
+ rows={2}
658
+ />
659
+ </div>
660
+ <div>
661
+ <label className="text-sm font-medium mb-2 block">Featured Image URL</label>
662
+ <div className="flex gap-2">
663
+ <Input
664
+ value={postForm.featured_image_url}
665
+ onChange={(e) =>
666
+ setPostForm({ ...postForm, featured_image_url: e.target.value })
667
+ }
668
+ placeholder="Image URL or upload file"
669
+ />
670
+ {editingPost && (
671
+ <input
672
+ type="file"
673
+ title="Upload Featured Image"
674
+ accept="image/*"
675
+ onChange={(e) => {
676
+ const file = e.target.files?.[0]
677
+ if (file && editingPost) {
678
+ handleUploadFeaturedImage(editingPost.id, file)
679
+ }
680
+ }}
681
+ className="hidden"
682
+ id="image-upload"
683
+ />
684
+ )}
685
+ {editingPost && (
686
+ <Button
687
+ variant="outline"
688
+ onClick={() => document.getElementById("image-upload")?.click()}
689
+ >
690
+ <Upload className="w-4 h-4" />
691
+ </Button>
692
+ )}
693
+ </div>
694
+ </div>
695
+ <BlogEditor
696
+ markdownContent={postForm.markdown_content}
697
+ htmlContent={postForm.html_content}
698
+ onMarkdownChange={(value) =>
699
+ setPostForm({ ...postForm, markdown_content: value })
700
+ }
701
+ onHtmlChange={(value) =>
702
+ setPostForm({ ...postForm, html_content: value })
703
+ }
704
+ />
705
+ <div>
706
+ <label className="text-sm font-medium mb-2 block">Categories</label>
707
+ <div className="flex flex-wrap gap-2">
708
+ {categories.map((category) => (
709
+ <Badge
710
+ key={category.id}
711
+ variant={
712
+ postForm.category_ids.includes(category.id) ? "default" : "outline"
713
+ }
714
+ className="cursor-pointer"
715
+ onClick={() => {
716
+ const newIds = postForm.category_ids.includes(category.id)
717
+ ? postForm.category_ids.filter((id) => id !== category.id)
718
+ : [...postForm.category_ids, category.id]
719
+ setPostForm({ ...postForm, category_ids: newIds })
720
+ }}
721
+ >
722
+ {category.name}
723
+ </Badge>
724
+ ))}
725
+ </div>
726
+ </div>
727
+ <div>
728
+ <label className="text-sm font-medium mb-2 block">Tags</label>
729
+ <div className="flex flex-wrap gap-2">
730
+ {tags.map((tag) => (
731
+ <Badge
732
+ key={tag.id}
733
+ variant={postForm.tag_ids.includes(tag.id) ? "default" : "outline"}
734
+ className="cursor-pointer"
735
+ onClick={() => {
736
+ const newIds = postForm.tag_ids.includes(tag.id)
737
+ ? postForm.tag_ids.filter((id) => id !== tag.id)
738
+ : [...postForm.tag_ids, tag.id]
739
+ setPostForm({ ...postForm, tag_ids: newIds })
740
+ }}
741
+ >
742
+ {tag.name}
743
+ </Badge>
744
+ ))}
745
+ </div>
746
+ </div>
747
+ <div className="flex justify-end gap-2">
748
+ <Button
749
+ variant="outline"
750
+ onClick={() => {
751
+ setShowPostForm(false)
752
+ setEditingPost(null)
753
+ resetPostForm()
754
+ }}
755
+ >
756
+ Cancel
757
+ </Button>
758
+ <Button
759
+ onClick={editingPost ? handleUpdatePost : handleCreatePost}
760
+ disabled={postSubmitting}
761
+ >
762
+ {postSubmitting ? (
763
+ <span className="flex items-center">
764
+ <svg className="animate-spin h-4 w-4 mr-2 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
765
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
766
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4l3-3-3-3v4a8 8 0 00-8 8z"></path>
767
+ </svg>
768
+ {editingPost ? "Updating..." : "Creating..."}
769
+ </span>
770
+ ) : (
771
+ <>
772
+ <Save className="w-4 h-4 mr-2" />
773
+ {editingPost ? "Update" : "Create"} Post
774
+ </>
775
+ )}
776
+ </Button>
777
+ </div>
778
+ </CardContent>
779
+ </Card>
780
+ )}
781
+ </div>
782
+ )}
783
+
784
+ {/* Categories Tab */}
785
+ {activeTab === "categories" && (
786
+ <div className="space-y-4">
787
+ <div className="flex justify-end">
788
+ <Button
789
+ onClick={() => {
790
+ setEditingCategory(null)
791
+ setCategoryForm({ name: "", slug: "", description: "" })
792
+ }}
793
+ >
794
+ <Plus className="w-4 h-4 mr-2" />
795
+ New Category
796
+ </Button>
797
+ </div>
798
+ {(editingCategory || (!editingCategory && categoryForm.name)) && (
799
+ <Card>
800
+ <CardHeader>
801
+ <CardTitle>
802
+ {editingCategory ? "Edit Category" : "Create Category"}
803
+ </CardTitle>
804
+ </CardHeader>
805
+ <CardContent className="space-y-4">
806
+ <div>
807
+ <label className="text-sm font-medium mb-2 block">Name</label>
808
+ <Input
809
+ value={categoryForm.name}
810
+ onChange={(e) => setCategoryForm({ ...categoryForm, name: e.target.value })}
811
+ placeholder="Category name"
812
+ />
813
+ </div>
814
+ <div>
815
+ <label className="text-sm font-medium mb-2 block">Slug</label>
816
+ <Input
817
+ value={categoryForm.slug}
818
+ onChange={(e) => setCategoryForm({ ...categoryForm, slug: e.target.value })}
819
+ placeholder="category-slug"
820
+ />
821
+ </div>
822
+ <div>
823
+ <label className="text-sm font-medium mb-2 block">Description</label>
824
+ <Textarea
825
+ value={categoryForm.description}
826
+ onChange={(e) =>
827
+ setCategoryForm({ ...categoryForm, description: e.target.value })
828
+ }
829
+ placeholder="Category description"
830
+ rows={3}
831
+ />
832
+ </div>
833
+ <div className="flex justify-end gap-2">
834
+ <Button
835
+ variant="outline"
836
+ onClick={() => {
837
+ setEditingCategory(null)
838
+ setCategoryForm({ name: "", slug: "", description: "" })
839
+ }}
840
+ >
841
+ Cancel
842
+ </Button>
843
+ <Button
844
+ onClick={editingCategory ? handleUpdateCategory : handleCreateCategory}
845
+ >
846
+ <Save className="w-4 h-4 mr-2" />
847
+ {editingCategory ? "Update" : "Create"}
848
+ </Button>
849
+ </div>
850
+ </CardContent>
851
+ </Card>
852
+ )}
853
+ <Card>
854
+ <CardContent className="p-0">
855
+ <Table>
856
+ <TableHeader>
857
+ <TableRow>
858
+ <TableHead>Name</TableHead>
859
+ <TableHead>Slug</TableHead>
860
+ <TableHead>Description</TableHead>
861
+ <TableHead>Actions</TableHead>
862
+ </TableRow>
863
+ </TableHeader>
864
+ <TableBody>
865
+ {categories.map((category) => (
866
+ <TableRow key={category.id}>
867
+ <TableCell className="font-medium">{category.name}</TableCell>
868
+ <TableCell>{category.slug}</TableCell>
869
+ <TableCell>{category.description || "-"}</TableCell>
870
+ <TableCell>
871
+ <div className="flex gap-2">
872
+ <Button
873
+ variant="ghost"
874
+ size="sm"
875
+ onClick={() => {
876
+ setEditingCategory(category)
877
+ setCategoryForm({
878
+ name: category.name,
879
+ slug: category.slug,
880
+ description: category.description || "",
881
+ })
882
+ }}
883
+ >
884
+ <Edit className="w-4 h-4" />
885
+ </Button>
886
+ <Button
887
+ variant="ghost"
888
+ size="sm"
889
+ onClick={() => handleDeleteCategory(category.id)}
890
+ >
891
+ <Trash2 className="w-4 h-4 text-destructive" />
892
+ </Button>
893
+ </div>
894
+ </TableCell>
895
+ </TableRow>
896
+ ))}
897
+ </TableBody>
898
+ </Table>
899
+ </CardContent>
900
+ </Card>
901
+ </div>
902
+ )}
903
+
904
+ {/* Tags Tab */}
905
+ {activeTab === "tags" && (
906
+ <div className="space-y-4">
907
+ <div className="flex justify-end">
908
+ <Button
909
+ onClick={() => {
910
+ setEditingTag(null)
911
+ setTagForm({ name: "", slug: "" })
912
+ }}
913
+ >
914
+ <Plus className="w-4 h-4 mr-2" />
915
+ New Tag
916
+ </Button>
917
+ </div>
918
+ {(editingTag || (!editingTag && tagForm.name)) && (
919
+ <Card>
920
+ <CardHeader>
921
+ <CardTitle>{editingTag ? "Edit Tag" : "Create Tag"}</CardTitle>
922
+ </CardHeader>
923
+ <CardContent className="space-y-4">
924
+ <div>
925
+ <label className="text-sm font-medium mb-2 block">Name</label>
926
+ <Input
927
+ value={tagForm.name}
928
+ onChange={(e) => setTagForm({ ...tagForm, name: e.target.value })}
929
+ placeholder="Tag name"
930
+ />
931
+ </div>
932
+ <div>
933
+ <label className="text-sm font-medium mb-2 block">Slug</label>
934
+ <Input
935
+ value={tagForm.slug}
936
+ onChange={(e) => setTagForm({ ...tagForm, slug: e.target.value })}
937
+ placeholder="tag-slug"
938
+ />
939
+ </div>
940
+ <div className="flex justify-end gap-2">
941
+ <Button
942
+ variant="outline"
943
+ onClick={() => {
944
+ setEditingTag(null)
945
+ setTagForm({ name: "", slug: "" })
946
+ }}
947
+ >
948
+ Cancel
949
+ </Button>
950
+ <Button onClick={editingTag ? handleUpdateTag : handleCreateTag}>
951
+ <Save className="w-4 h-4 mr-2" />
952
+ {editingTag ? "Update" : "Create"}
953
+ </Button>
954
+ </div>
955
+ </CardContent>
956
+ </Card>
957
+ )}
958
+ <Card>
959
+ <CardContent className="p-0">
960
+ <Table>
961
+ <TableHeader>
962
+ <TableRow>
963
+ <TableHead>Name</TableHead>
964
+ <TableHead>Slug</TableHead>
965
+ <TableHead>Actions</TableHead>
966
+ </TableRow>
967
+ </TableHeader>
968
+ <TableBody>
969
+ {tags.map((tag) => (
970
+ <TableRow key={tag.id}>
971
+ <TableCell className="font-medium">{tag.name}</TableCell>
972
+ <TableCell>{tag.slug}</TableCell>
973
+ <TableCell>
974
+ <div className="flex gap-2">
975
+ <Button
976
+ variant="ghost"
977
+ size="sm"
978
+ onClick={() => {
979
+ setEditingTag(tag)
980
+ setTagForm({ name: tag.name, slug: tag.slug })
981
+ }}
982
+ >
983
+ <Edit className="w-4 h-4" />
984
+ </Button>
985
+ <Button
986
+ variant="ghost"
987
+ size="sm"
988
+ onClick={() => handleDeleteTag(tag.id)}
989
+ >
990
+ <Trash2 className="w-4 h-4 text-destructive" />
991
+ </Button>
992
+ </div>
993
+ </TableCell>
994
+ </TableRow>
995
+ ))}
996
+ </TableBody>
997
+ </Table>
998
+ </CardContent>
999
+ </Card>
1000
+ </div>
1001
+ )}
1002
+ </div>
1003
+ </div>
1004
+ </div>
1005
+ )
1006
+ }
1007
+
src/app/admin/knowledge/page.tsx ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ /**
4
+ * Knowledge Base Settings - Admin Page
5
+ *
6
+ * WeKnora-style knowledge base management for admins
7
+ */
8
+
9
+ import { useState } from "react"
10
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
11
+ import { Button } from "@/components/ui/button"
12
+ import { Badge } from "@/components/ui/badge"
13
+ import { Input } from "@/components/ui/input"
14
+ import { Label } from "@/components/ui/label"
15
+ import { Textarea } from "@/components/ui/textarea"
16
+ import {
17
+ Table,
18
+ TableBody,
19
+ TableCell,
20
+ TableHead,
21
+ TableHeader,
22
+ TableRow,
23
+ } from "@/components/ui/table"
24
+ import {
25
+ Dialog,
26
+ DialogContent,
27
+ DialogDescription,
28
+ DialogFooter,
29
+ DialogHeader,
30
+ DialogTitle,
31
+ DialogTrigger,
32
+ } from "@/components/ui/dialog"
33
+ import {
34
+ Database,
35
+ Plus,
36
+ Upload,
37
+ RefreshCw,
38
+ Trash2,
39
+ Edit,
40
+ FileText,
41
+ Scale,
42
+ Newspaper,
43
+ CheckCircle,
44
+ XCircle,
45
+ Clock,
46
+ Settings,
47
+ Search
48
+ } from "lucide-react"
49
+
50
+
51
+ interface KnowledgeBase {
52
+ id: string
53
+ name: string
54
+ description: string
55
+ type: 'legal' | 'news' | 'general' | 'custom'
56
+ documentCount: number
57
+ lastUpdated: string
58
+ status: 'active' | 'indexing' | 'error'
59
+ chunkSize: number
60
+ embeddingModel: string
61
+ }
62
+
63
+ const MOCK_KNOWLEDGE_BASES: KnowledgeBase[] = [
64
+ {
65
+ id: "kenya_law",
66
+ name: "Kenya Law",
67
+ description: "Constitution, Acts, Bills, Case Law from Kenya Law Reports",
68
+ type: "legal",
69
+ documentCount: 5420,
70
+ lastUpdated: "2025-12-14T10:30:00Z",
71
+ status: "active",
72
+ chunkSize: 512,
73
+ embeddingModel: "text-embedding-3-small"
74
+ },
75
+ {
76
+ id: "kenya_news",
77
+ name: "Kenya News",
78
+ description: "Current affairs from major Kenyan news outlets",
79
+ type: "news",
80
+ documentCount: 12350,
81
+ lastUpdated: "2025-12-15T08:00:00Z",
82
+ status: "active",
83
+ chunkSize: 256,
84
+ embeddingModel: "text-embedding-3-small"
85
+ },
86
+ {
87
+ id: "parliament",
88
+ name: "Parliament Records",
89
+ description: "Bills, Hansard, Committee Reports",
90
+ type: "legal",
91
+ documentCount: 2100,
92
+ lastUpdated: "2025-12-13T14:20:00Z",
93
+ status: "indexing",
94
+ chunkSize: 512,
95
+ embeddingModel: "text-embedding-3-small"
96
+ }
97
+ ]
98
+
99
+ export default function KnowledgeBaseSettingsPage() {
100
+ const [knowledgeBases, setKnowledgeBases] = useState(MOCK_KNOWLEDGE_BASES)
101
+ const [selectedKB, setSelectedKB] = useState<KnowledgeBase | null>(null)
102
+ const [isCreating, setIsCreating] = useState(false)
103
+
104
+ const getTypeIcon = (type: string) => {
105
+ switch (type) {
106
+ case 'legal': return <Scale className="h-4 w-4" />
107
+ case 'news': return <Newspaper className="h-4 w-4" />
108
+ default: return <Database className="h-4 w-4" />
109
+ }
110
+ }
111
+
112
+ const getStatusBadge = (status: string) => {
113
+ switch (status) {
114
+ case 'active':
115
+ return <Badge variant="default" className="bg-green-500"><CheckCircle className="h-3 w-3 mr-1" />Active</Badge>
116
+ case 'indexing':
117
+ return <Badge variant="secondary"><Clock className="h-3 w-3 mr-1 animate-spin" />Indexing</Badge>
118
+ case 'error':
119
+ return <Badge variant="destructive"><XCircle className="h-3 w-3 mr-1" />Error</Badge>
120
+ default:
121
+ return <Badge variant="outline">{status}</Badge>
122
+ }
123
+ }
124
+
125
+ return (
126
+ <div className="container mx-auto py-6 space-y-6">
127
+ <div className="flex items-center justify-between">
128
+ <div>
129
+ <h1 className="text-3xl font-bold">Knowledge Base Management</h1>
130
+ <p className="text-muted-foreground">Manage your document collections and search indices</p>
131
+ </div>
132
+ <Dialog open={isCreating} onOpenChange={setIsCreating}>
133
+ <DialogTrigger asChild>
134
+ <Button>
135
+ <Plus className="h-4 w-4 mr-2" />
136
+ New Knowledge Base
137
+ </Button>
138
+ </DialogTrigger>
139
+ <DialogContent className="max-w-2xl">
140
+ <DialogHeader>
141
+ <DialogTitle>Create Knowledge Base</DialogTitle>
142
+ <DialogDescription>
143
+ Create a new knowledge base for document retrieval
144
+ </DialogDescription>
145
+ </DialogHeader>
146
+ <div className="space-y-4 py-4">
147
+ <div className="grid grid-cols-2 gap-4">
148
+ <div className="space-y-2">
149
+ <Label htmlFor="name">Name</Label>
150
+ <Input id="name" placeholder="e.g., Kenya Law" />
151
+ </div>
152
+ <div className="space-y-2">
153
+ <Label htmlFor="type">Type</Label>
154
+ <select className="w-full p-2 border rounded-md">
155
+ <option value="legal">Legal</option>
156
+ <option value="news">News</option>
157
+ <option value="general">General</option>
158
+ <option value="custom">Custom</option>
159
+ </select>
160
+ </div>
161
+ </div>
162
+ <div className="space-y-2">
163
+ <Label htmlFor="description">Description</Label>
164
+ <Textarea id="description" placeholder="Describe this knowledge base..." />
165
+ </div>
166
+ <div className="grid grid-cols-2 gap-4">
167
+ <div className="space-y-2">
168
+ <Label htmlFor="chunkSize">Chunk Size</Label>
169
+ <Input id="chunkSize" type="number" defaultValue={512} />
170
+ </div>
171
+ <div className="space-y-2">
172
+ <Label htmlFor="embeddingModel">Embedding Model</Label>
173
+ <select className="w-full p-2 border rounded-md">
174
+ <option value="text-embedding-3-small">text-embedding-3-small</option>
175
+ <option value="text-embedding-3-large">text-embedding-3-large</option>
176
+ <option value="text-embedding-ada-002">text-embedding-ada-002</option>
177
+ </select>
178
+ </div>
179
+ </div>
180
+ </div>
181
+ <DialogFooter>
182
+ <Button variant="outline" onClick={() => setIsCreating(false)}>Cancel</Button>
183
+ <Button onClick={() => setIsCreating(false)}>Create</Button>
184
+ </DialogFooter>
185
+ </DialogContent>
186
+ </Dialog>
187
+ </div>
188
+
189
+ <div className="grid gap-6 md:grid-cols-3">
190
+ <Card>
191
+ <CardHeader className="pb-2">
192
+ <CardTitle className="text-sm font-medium">Total Knowledge Bases</CardTitle>
193
+ </CardHeader>
194
+ <CardContent>
195
+ <div className="text-2xl font-bold">{knowledgeBases.length}</div>
196
+ </CardContent>
197
+ </Card>
198
+ <Card>
199
+ <CardHeader className="pb-2">
200
+ <CardTitle className="text-sm font-medium">Total Documents</CardTitle>
201
+ </CardHeader>
202
+ <CardContent>
203
+ <div className="text-2xl font-bold">
204
+ {knowledgeBases.reduce((sum, kb) => sum + kb.documentCount, 0).toLocaleString()}
205
+ </div>
206
+ </CardContent>
207
+ </Card>
208
+ <Card>
209
+ <CardHeader className="pb-2">
210
+ <CardTitle className="text-sm font-medium">Active Indices</CardTitle>
211
+ </CardHeader>
212
+ <CardContent>
213
+ <div className="text-2xl font-bold">
214
+ {knowledgeBases.filter(kb => kb.status === 'active').length}
215
+ </div>
216
+ </CardContent>
217
+ </Card>
218
+ </div>
219
+
220
+ <Card>
221
+ <CardHeader>
222
+ <CardTitle>Knowledge Bases</CardTitle>
223
+ <CardDescription>Manage document collections and search indices</CardDescription>
224
+ </CardHeader>
225
+ <CardContent>
226
+ <Table>
227
+ <TableHeader>
228
+ <TableRow>
229
+ <TableHead>Name</TableHead>
230
+ <TableHead>Type</TableHead>
231
+ <TableHead>Documents</TableHead>
232
+ <TableHead>Status</TableHead>
233
+ <TableHead>Last Updated</TableHead>
234
+ <TableHead className="text-right">Actions</TableHead>
235
+ </TableRow>
236
+ </TableHeader>
237
+ <TableBody>
238
+ {knowledgeBases.map((kb) => (
239
+ <TableRow key={kb.id}>
240
+ <TableCell>
241
+ <div className="flex items-center gap-2">
242
+ {getTypeIcon(kb.type)}
243
+ <div>
244
+ <div className="font-medium">{kb.name}</div>
245
+ <div className="text-xs text-muted-foreground">{kb.description}</div>
246
+ </div>
247
+ </div>
248
+ </TableCell>
249
+ <TableCell>
250
+ <Badge variant="outline">{kb.type}</Badge>
251
+ </TableCell>
252
+ <TableCell>{kb.documentCount.toLocaleString()}</TableCell>
253
+ <TableCell>{getStatusBadge(kb.status)}</TableCell>
254
+ <TableCell>{new Date(kb.lastUpdated).toLocaleDateString()}</TableCell>
255
+ <TableCell className="text-right">
256
+ <div className="flex justify-end gap-2">
257
+ <Button variant="ghost" size="icon">
258
+ <Upload className="h-4 w-4" />
259
+ </Button>
260
+ <Button variant="ghost" size="icon">
261
+ <RefreshCw className="h-4 w-4" />
262
+ </Button>
263
+ <Button variant="ghost" size="icon">
264
+ <Settings className="h-4 w-4" />
265
+ </Button>
266
+ <Button variant="ghost" size="icon">
267
+ <Trash2 className="h-4 w-4 text-red-500" />
268
+ </Button>
269
+ </div>
270
+ </TableCell>
271
+ </TableRow>
272
+ ))}
273
+ </TableBody>
274
+ </Table>
275
+ </CardContent>
276
+ </Card>
277
+ </div>
278
+ )
279
+ }
src/app/admin/models/page.tsx ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ /**
4
+ * Model Settings - Admin Page
5
+ *
6
+ * WeKnora-style AI model configuration for admins
7
+ */
8
+
9
+ import { useState, useEffect, ReactNode } from "react"
10
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
11
+ import { Button } from "@/components/ui/button"
12
+ import { Badge } from "@/components/ui/badge"
13
+ import { Input } from "@/components/ui/input"
14
+ import { Label } from "@/components/ui/label"
15
+ import { Switch } from "@/components/ui/switch"
16
+ import { Slider } from "@/components/ui/slider"
17
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
18
+ import {
19
+ Table,
20
+ TableBody,
21
+ TableCell,
22
+ TableHead,
23
+ TableHeader,
24
+ TableRow,
25
+ } from "@/components/ui/table"
26
+ import {
27
+ Dialog,
28
+ DialogContent,
29
+ DialogDescription,
30
+ DialogFooter,
31
+ DialogHeader,
32
+ DialogTitle,
33
+ DialogTrigger,
34
+ } from "@/components/ui/dialog"
35
+ import {
36
+ Brain,
37
+ Sparkles,
38
+ Zap,
39
+ Rocket,
40
+ Plus,
41
+ Settings,
42
+ CheckCircle,
43
+ XCircle,
44
+ Key,
45
+ RefreshCw,
46
+ Loader2
47
+ } from "lucide-react"
48
+
49
+ interface ModelConfig {
50
+ apiKey?: string
51
+ maxTokens?: number
52
+ topP?: number
53
+ isDefault?: boolean
54
+ id: string
55
+ name: string
56
+ provider: string
57
+ enabled: boolean
58
+ is_default?: boolean
59
+ status: string
60
+ temperature: number
61
+ max_tokens?: number
62
+ top_p?: number
63
+ }
64
+
65
+ const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
66
+
67
+ export default function ModelSettingsPage() {
68
+ const [models, setModels] = useState<ModelConfig[]>([])
69
+ const [loading, setLoading] = useState(true)
70
+ const [error, setError] = useState<string | null>(null)
71
+ const [selectedModel, setSelectedModel] = useState<ModelConfig | null>(null)
72
+
73
+ useEffect(() => {
74
+ fetchModels()
75
+ }, [])
76
+
77
+ const fetchModels = async () => {
78
+ try {
79
+ setLoading(true)
80
+ const response = await fetch(`${API_URL}/api/v1/admin/models`)
81
+ if (!response.ok) throw new Error("Failed to fetch models")
82
+ const data = await response.json()
83
+ setModels(data)
84
+ } catch (err) {
85
+ setError(err instanceof Error ? err.message : "Failed to load models")
86
+ // Fallback to default models
87
+ setModels([
88
+ {
89
+ id: "moonshot-v1-8k", name: "Moonshot V1", provider: "Moonshot", enabled: true, is_default: true, status: "connected", temperature: 0.7, max_tokens: 4096,
90
+ apiKey: undefined,
91
+ maxTokens: undefined,
92
+ topP: undefined,
93
+ isDefault: undefined
94
+ },
95
+ {
96
+ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", provider: "Google", enabled: true, is_default: false, status: "connected", temperature: 0.7, max_tokens: 4096,
97
+ apiKey: undefined,
98
+ maxTokens: undefined,
99
+ topP: undefined,
100
+ isDefault: undefined
101
+ },
102
+ {
103
+ id: "gemini-1.5-pro", name: "Gemini 1.5 Pro", provider: "Google", enabled: true, is_default: false, status: "connected", temperature: 0.7, max_tokens: 8192,
104
+ apiKey: undefined,
105
+ maxTokens: undefined,
106
+ topP: undefined,
107
+ isDefault: undefined
108
+ },
109
+ {
110
+ id: "gpt-4o-mini", name: "GPT-4o Mini", provider: "OpenAI", enabled: false, is_default: false, status: "unconfigured", temperature: 0.7, max_tokens: 4096,
111
+ apiKey: undefined,
112
+ maxTokens: undefined,
113
+ topP: undefined,
114
+ isDefault: undefined
115
+ },
116
+ {
117
+ id: "claude-3.5-sonnet", name: "Claude 3.5 Sonnet", provider: "Anthropic", enabled: false, is_default: false, status: "unconfigured", temperature: 0.7, max_tokens: 4096,
118
+ apiKey: undefined,
119
+ maxTokens: undefined,
120
+ topP: undefined,
121
+ isDefault: undefined
122
+ }
123
+ ])
124
+ } finally {
125
+ setLoading(false)
126
+ }
127
+ }
128
+
129
+ const getProviderIcon = (provider: string) => {
130
+ switch (provider) {
131
+ case 'Google': return <Sparkles className="h-4 w-4" />
132
+ case 'Moonshot': return <Zap className="h-4 w-4" />
133
+ case 'OpenAI': return <Brain className="h-4 w-4" />
134
+ case 'Anthropic': return <Rocket className="h-4 w-4" />
135
+ default: return <Brain className="h-4 w-4" />
136
+ }
137
+ }
138
+
139
+ const getStatusBadge = (status: string) => {
140
+ switch (status) {
141
+ case 'connected':
142
+ return <Badge variant="default" className="bg-green-500"><CheckCircle className="h-3 w-3 mr-1" />Connected</Badge>
143
+ case 'error':
144
+ return <Badge variant="destructive"><XCircle className="h-3 w-3 mr-1" />Error</Badge>
145
+ case 'unconfigured':
146
+ return <Badge variant="secondary"><Key className="h-3 w-3 mr-1" />Unconfigured</Badge>
147
+ default:
148
+ return <Badge variant="outline">{status}</Badge>
149
+ }
150
+ }
151
+
152
+ const updateModel = (id: string, updates: Partial<ModelConfig>) => {
153
+ setModels(prev => prev.map(m => m.id === id ? { ...m, ...updates } : m))
154
+ }
155
+
156
+ const setAsDefault = (id: string) => {
157
+ setModels(prev => prev.map(m => ({ ...m, isDefault: m.id === id })))
158
+ }
159
+
160
+ return (
161
+ <div className="container mx-auto py-6 space-y-6">
162
+ <div className="flex items-center justify-between">
163
+ <div>
164
+ <h1 className="text-3xl font-bold">Model Settings</h1>
165
+ <p className="text-muted-foreground">Configure AI models and their parameters</p>
166
+ </div>
167
+ <Button variant="outline">
168
+ <RefreshCw className="h-4 w-4 mr-2" />
169
+ Test All Connections
170
+ </Button>
171
+ </div>
172
+
173
+ <div className="grid gap-6 md:grid-cols-4">
174
+ <Card>
175
+ <CardHeader className="pb-2">
176
+ <CardTitle className="text-sm font-medium">Configured Models</CardTitle>
177
+ </CardHeader>
178
+ <CardContent>
179
+ <div className="text-2xl font-bold">{models.filter(m => m.status === 'connected').length}</div>
180
+ </CardContent>
181
+ </Card>
182
+ <Card>
183
+ <CardHeader className="pb-2">
184
+ <CardTitle className="text-sm font-medium">Enabled Models</CardTitle>
185
+ </CardHeader>
186
+ <CardContent>
187
+ <div className="text-2xl font-bold">{models.filter(m => m.enabled).length}</div>
188
+ </CardContent>
189
+ </Card>
190
+ <Card>
191
+ <CardHeader className="pb-2">
192
+ <CardTitle className="text-sm font-medium">Default Model</CardTitle>
193
+ </CardHeader>
194
+ <CardContent>
195
+ <div className="text-lg font-bold">{models.find(m => m.isDefault)?.name || 'None'}</div>
196
+ </CardContent>
197
+ </Card>
198
+ <Card>
199
+ <CardHeader className="pb-2">
200
+ <CardTitle className="text-sm font-medium">Providers</CardTitle>
201
+ </CardHeader>
202
+ <CardContent>
203
+ <div className="text-2xl font-bold">{new Set(models.map(m => m.provider)).size}</div>
204
+ </CardContent>
205
+ </Card>
206
+ </div>
207
+
208
+ <Card>
209
+ <CardHeader>
210
+ <CardTitle>AI Models</CardTitle>
211
+ <CardDescription>Configure model access and parameters</CardDescription>
212
+ </CardHeader>
213
+ <CardContent>
214
+ <Table>
215
+ <TableHeader>
216
+ <TableRow>
217
+ <TableHead>Model</TableHead>
218
+ <TableHead>Provider</TableHead>
219
+ <TableHead>Status</TableHead>
220
+ <TableHead>Enabled</TableHead>
221
+ <TableHead>Default</TableHead>
222
+ <TableHead className="text-right">Actions</TableHead>
223
+ </TableRow>
224
+ </TableHeader>
225
+ <TableBody>
226
+ {models.map((model) => (
227
+ <TableRow key={model.id}>
228
+ <TableCell>
229
+ <div className="flex items-center gap-2">
230
+ {getProviderIcon(model.provider)}
231
+ <div>
232
+ <div className="font-medium">{model.name}</div>
233
+ <div className="text-xs text-muted-foreground font-mono">{model.id}</div>
234
+ </div>
235
+ </div>
236
+ </TableCell>
237
+ <TableCell>
238
+ <Badge variant="outline">{model.provider}</Badge>
239
+ </TableCell>
240
+ <TableCell>{getStatusBadge(model.status)}</TableCell>
241
+ <TableCell>
242
+ <Switch
243
+ checked={model.enabled}
244
+ onCheckedChange={(checked: any) => updateModel(model.id, { enabled: checked })}
245
+ disabled={model.status !== 'connected'}
246
+ />
247
+ </TableCell>
248
+ <TableCell>
249
+ <Switch
250
+ checked={model.isDefault ?? model.is_default ?? false}
251
+ onCheckedChange={() => setAsDefault(model.id)}
252
+ disabled={!model.enabled}
253
+ />
254
+ </TableCell>
255
+ <TableCell className="text-right">
256
+ <div className="flex justify-end gap-2">
257
+ <Dialog>
258
+ <DialogTrigger asChild>
259
+ <Button
260
+ variant="ghost"
261
+ size="icon"
262
+ onClick={() => setSelectedModel(model)}
263
+ >
264
+ <Settings className="h-4 w-4" />
265
+ </Button>
266
+ </DialogTrigger>
267
+ <DialogContent className="max-w-lg">
268
+ <DialogHeader>
269
+ <DialogTitle>Configure {model.name}</DialogTitle>
270
+ <DialogDescription>
271
+ Adjust model parameters and API settings
272
+ </DialogDescription>
273
+ </DialogHeader>
274
+ <div className="space-y-4 py-4">
275
+ <div className="space-y-2">
276
+ <Label htmlFor="apiKey">API Key</Label>
277
+ <Input
278
+ id="apiKey"
279
+ type="password"
280
+ placeholder="Enter API key..."
281
+ defaultValue={model.apiKey}
282
+ />
283
+ </div>
284
+ <div className="space-y-2">
285
+ <Label>Temperature: {model.temperature}</Label>
286
+ <Slider
287
+ defaultValue={[model.temperature]}
288
+ max={1}
289
+ step={0.1}
290
+ onValueChange={(v) => updateModel(model.id, { temperature: v[0] })}
291
+ />
292
+ </div>
293
+ <div className="space-y-2">
294
+ <Label>Max Tokens: {model.maxTokens ?? model.max_tokens ?? 4096}</Label>
295
+ <Slider
296
+ defaultValue={[model.maxTokens ?? model.max_tokens ?? 4096]}
297
+ max={16384}
298
+ step={256}
299
+ onValueChange={(v) => updateModel(model.id, { maxTokens: v[0] })}
300
+ />
301
+ </div>
302
+ <div className="space-y-2">
303
+ <Label>Top P: {model.topP ?? model.top_p ?? 0.7}</Label>
304
+ <Slider
305
+ defaultValue={[model.topP ?? model.top_p ?? 0.7]}
306
+ max={1}
307
+ step={0.05}
308
+ onValueChange={(v) => updateModel(model.id, { topP: v[0] })}
309
+ />
310
+ </div>
311
+ </div>
312
+ <DialogFooter>
313
+ <Button>Save Changes</Button>
314
+ </DialogFooter>
315
+ </DialogContent>
316
+ </Dialog>
317
+ </div>
318
+ </TableCell>
319
+ </TableRow>
320
+ ))}
321
+ </TableBody>
322
+ </Table>
323
+ </CardContent>
324
+ </Card>
325
+ </div>
326
+ )
327
+ }
src/app/admin/page.tsx ADDED
@@ -0,0 +1,1144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import React, { useState, useEffect, useCallback, useRef } from "react"
4
+ import { useRouter } from "next/navigation"
5
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
6
+ import { Button } from "@/components/ui/button"
7
+ import { Badge } from "@/components/ui/badge"
8
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
9
+ import { Input } from "@/components/ui/input"
10
+ import { AdminSidebar } from "@/components/admin-sidebar"
11
+ import { ThemeToggle } from "@/components/theme-toggle"
12
+ import { useAuth } from "@/lib/auth-context"
13
+ import {
14
+ Database,
15
+ Activity,
16
+ Clock,
17
+ CheckCircle,
18
+ XCircle,
19
+ Play,
20
+ Search,
21
+ BarChart3,
22
+ FileText,
23
+ Globe,
24
+ RefreshCw,
25
+ Square
26
+ } from "lucide-react"
27
+ import Link from "next/link"
28
+
29
+ const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
30
+
31
+ // Helper function to get auth headers
32
+ const getAuthHeaders = (): Record<string, string> => {
33
+ const token = localStorage.getItem("session_token")
34
+ const headers: Record<string, string> = {}
35
+ if (token) {
36
+ headers["X-Session-Token"] = token
37
+ }
38
+ return headers
39
+ }
40
+
41
+ interface Stats {
42
+ total_chunks: number
43
+ categories: Record<string, number>
44
+ sources: string[]
45
+ }
46
+
47
+ interface Health {
48
+ status: string
49
+ database_chunks: number
50
+ embedding_model: string
51
+ llm_provider: string
52
+ }
53
+
54
+ interface Crawler {
55
+ status: "idle" | "running" | "failed"
56
+ last_run: string | null
57
+ logs: string[]
58
+ pid?: number
59
+ start_time?: string
60
+ }
61
+ interface CommandResult {
62
+ command: string
63
+ exit_code: number
64
+ stdout: string
65
+ stderr: string
66
+ }
67
+
68
+ interface Document {
69
+ id: string
70
+ content: string
71
+ metadata: {
72
+ title: string
73
+ url: string
74
+ source: string
75
+ category: string
76
+ date: string
77
+ author?: string
78
+ sentiment_polarity?: number
79
+ sentiment_label?: string
80
+ }
81
+ score: number
82
+ }
83
+
84
+ interface Database {
85
+ name: string
86
+ type: string
87
+ status: string
88
+ total_chunks: number
89
+ categories: Record<string, number>
90
+ persist_directory: string
91
+ elasticsearch_docs: number
92
+ elasticsearch_enabled: boolean
93
+ }
94
+
95
+ interface DatabaseStorageStats {
96
+ raw_documents: {
97
+ total: number
98
+ processed: number
99
+ unprocessed: number
100
+ categories: Record<string, number>
101
+ }
102
+ processed_chunks: {
103
+ total: number
104
+ categories: Record<string, number>
105
+ }
106
+ }
107
+
108
+ interface ConfigData {
109
+ [key: string]: {
110
+ has_value: boolean
111
+ description?: string
112
+ }
113
+ }
114
+
115
+ interface DatabaseStats {
116
+ total_databases: number
117
+ active_databases: number
118
+ databases: Database[]
119
+ }
120
+
121
+ export default function AdminDashboard() {
122
+ const { isAuthenticated, isAdmin, loading } = useAuth()
123
+ const router = useRouter()
124
+ const [stats, setStats] = useState<Stats | null>(null)
125
+ const [health, setHealth] = useState<Health | null>(null)
126
+ const [crawlers, setCrawlers] = useState<Record<string, Crawler>>({})
127
+ const [documents, setDocuments] = useState<Document[]>([])
128
+ const [searchQuery, setSearchQuery] = useState("")
129
+ const [command, setCommand] = useState("")
130
+ const [commandHistory, setCommandHistory] = useState<CommandResult[]>([])
131
+ const [isExecuting, setIsExecuting] = useState(false)
132
+ const [configs, setConfigs] = useState<ConfigData>({})
133
+ const [newConfigKey, setNewConfigKey] = useState("")
134
+ const [newConfigValue, setNewConfigValue] = useState("")
135
+ const [newConfigDescription, setNewConfigDescription] = useState("")
136
+ const [databaseStats, setDatabaseStats] = useState<DatabaseStats | null>(null)
137
+ const [databaseStorageStats, setDatabaseStorageStats] = useState<DatabaseStorageStats | null>(null)
138
+ const [selectedCrawler, setSelectedCrawler] = useState<string | null>(null)
139
+ const [isRefreshing, setIsRefreshing] = useState(false)
140
+ const isRefreshingRef = useRef(false)
141
+ const intervalRef = useRef<NodeJS.Timeout | null>(null)
142
+ const fetchCrawlersRef = useRef<(() => Promise<void>) | null>(null)
143
+ const intervalSetupRef = useRef(false)
144
+
145
+ useEffect(() => {
146
+ if (!loading && !isAuthenticated) {
147
+ router.push("/auth/signin?redirect=/admin")
148
+ } else if (!loading && isAuthenticated && !isAdmin) {
149
+ router.push("/chat")
150
+ }
151
+ }, [isAuthenticated, isAdmin, loading, router])
152
+
153
+ const fetchStats = async () => {
154
+ try {
155
+ const response = await fetch(`/api/cache/admin/stats`, {
156
+ headers: getAuthHeaders()
157
+ })
158
+ if (response.ok) {
159
+ const data = await response.json()
160
+ setStats(data)
161
+ }
162
+ } catch (error) {
163
+ console.error("Failed to fetch stats:", error)
164
+ }
165
+ }
166
+
167
+ const fetchHealth = async () => {
168
+ try {
169
+ const response = await fetch(`${API_BASE_URL}/health`)
170
+ if (response.ok) {
171
+ const data = await response.json()
172
+ setHealth(data)
173
+ }
174
+ } catch (error) {
175
+ console.error("Failed to fetch health:", error)
176
+ }
177
+ }
178
+
179
+ // Create a stable fetch function that uses refs for auth state
180
+ const isAuthenticatedRef = useRef(isAuthenticated)
181
+ const isAdminRef = useRef(isAdmin)
182
+
183
+ // Update refs when values change
184
+ useEffect(() => {
185
+ isAuthenticatedRef.current = isAuthenticated
186
+ isAdminRef.current = isAdmin
187
+ }, [isAuthenticated, isAdmin])
188
+
189
+ const fetchCrawlers = useCallback(async () => {
190
+ // Only fetch if authenticated and admin (check refs for latest values)
191
+ if (!isAuthenticatedRef.current || !isAdminRef.current) {
192
+ return
193
+ }
194
+
195
+ // Prevent concurrent fetches
196
+ if (isRefreshingRef.current) {
197
+ return
198
+ }
199
+
200
+ try {
201
+ isRefreshingRef.current = true
202
+ setIsRefreshing(true)
203
+ const response = await fetch(`${API_BASE_URL}/api/admin/crawlers`, {
204
+ headers: {
205
+ "Content-Type": "application/json",
206
+ ...getAuthHeaders()
207
+ },
208
+ })
209
+ if (response.ok) {
210
+ const data = await response.json()
211
+ setCrawlers(data.crawlers || {})
212
+ } else {
213
+ console.error("Failed to fetch crawlers:", response.status)
214
+ setCrawlers({})
215
+ }
216
+ } catch (error) {
217
+ console.error("Failed to fetch crawlers:", error)
218
+ setCrawlers({})
219
+ } finally {
220
+ isRefreshingRef.current = false
221
+ setIsRefreshing(false)
222
+ }
223
+ }, []) // No dependencies - uses refs for auth state
224
+
225
+ // Update the ref whenever fetchCrawlers changes (though it shouldn't change now)
226
+ useEffect(() => {
227
+ fetchCrawlersRef.current = fetchCrawlers
228
+ }, [fetchCrawlers])
229
+
230
+ const fetchConfigs = async () => {
231
+ try {
232
+ const response = await fetch(`/api/cache/admin/config`, {
233
+ headers: {
234
+ "Content-Type": "application/json",
235
+ ...getAuthHeaders()
236
+ },
237
+ })
238
+ if (response.ok) {
239
+ const data = await response.json()
240
+ setConfigs(data)
241
+ }
242
+ } catch (error) {
243
+ console.error("Failed to fetch configs:", error)
244
+ }
245
+ }
246
+
247
+ const fetchDatabaseStats = async () => {
248
+ try {
249
+ const response = await fetch(`/api/cache/admin/databases`, {
250
+ headers: {
251
+ "Content-Type": "application/json",
252
+ ...getAuthHeaders()
253
+ },
254
+ })
255
+ if (response.ok) {
256
+ const data = await response.json()
257
+ setDatabaseStats(data)
258
+ }
259
+ } catch (error) {
260
+ console.error("Failed to fetch database stats:", error)
261
+ }
262
+ }
263
+
264
+ const fetchDatabaseStorageStats = async () => {
265
+ try {
266
+ const response = await fetch(`/api/cache/admin/database-storage`, {
267
+ headers: {
268
+ "Content-Type": "application/json",
269
+ ...getAuthHeaders()
270
+ },
271
+ })
272
+ if (response.ok) {
273
+ const data = await response.json()
274
+ setDatabaseStorageStats(data)
275
+ }
276
+ } catch (error) {
277
+ console.error("Failed to fetch database storage stats:", error)
278
+ }
279
+ }
280
+
281
+ const searchDocuments = async () => {
282
+ try {
283
+ const params = new URLSearchParams()
284
+ if (searchQuery) params.append("query", searchQuery)
285
+ params.append("limit", "100")
286
+
287
+ const response = await fetch(`/api/cache/admin/documents?${params}`, {
288
+ headers: {
289
+ "Content-Type": "application/json",
290
+ ...getAuthHeaders()
291
+ },
292
+ })
293
+ if (response.ok) {
294
+ const data = await response.json()
295
+ setDocuments(data.documents)
296
+ }
297
+ } catch (error) {
298
+ console.error("Failed to search documents:", error)
299
+ }
300
+ }
301
+
302
+ const executeCommand = async () => {
303
+ if (!command.trim()) return
304
+
305
+ setIsExecuting(true)
306
+ try {
307
+ const response = await fetch(`${API_BASE_URL}/admin/execute`, {
308
+ method: "POST",
309
+ headers: {
310
+ "Content-Type": "application/json",
311
+ ...getAuthHeaders()
312
+ },
313
+ body: JSON.stringify({ command }),
314
+ })
315
+
316
+ if (response.ok) {
317
+ const result: CommandResult = await response.json()
318
+ setCommandHistory(prev => [result, ...prev.slice(0, 9)]) // Keep last 10
319
+ setCommand("")
320
+ }
321
+ } catch (error) {
322
+ console.error("Failed to execute command:", error)
323
+ } finally {
324
+ setIsExecuting(false)
325
+ }
326
+ }
327
+
328
+ // Initial data fetch - only runs when auth state stabilizes
329
+ useEffect(() => {
330
+ // Only fetch data if authenticated and admin
331
+ if (!isAuthenticated || !isAdmin || loading) {
332
+ return
333
+ }
334
+
335
+ fetchStats()
336
+ fetchHealth()
337
+ fetchCrawlers() // Now stable, can be called directly
338
+ fetchConfigs()
339
+ fetchDatabaseStats()
340
+ fetchDatabaseStorageStats()
341
+ // eslint-disable-next-line react-hooks/exhaustive-deps
342
+ }, [isAuthenticated, isAdmin, loading])
343
+
344
+ // Separate effect for interval - only runs once when component mounts and auth is ready
345
+ useEffect(() => {
346
+ // Only set up interval if authenticated and admin
347
+ if (!isAuthenticated || !isAdmin || loading) {
348
+ // Clear interval if auth state changes
349
+ if (intervalRef.current) {
350
+ clearInterval(intervalRef.current)
351
+ intervalRef.current = null
352
+ intervalSetupRef.current = false
353
+ }
354
+ return
355
+ }
356
+
357
+ // Prevent multiple interval setups
358
+ if (intervalSetupRef.current && intervalRef.current) {
359
+ return
360
+ }
361
+
362
+ // Clear any existing interval before creating a new one (safety check)
363
+ if (intervalRef.current) {
364
+ clearInterval(intervalRef.current)
365
+ intervalRef.current = null
366
+ }
367
+
368
+ // Auto-refresh crawler status every 10 seconds
369
+ intervalRef.current = setInterval(() => {
370
+ // Use the ref to get the latest fetchCrawlers function
371
+ if (fetchCrawlersRef.current) {
372
+ fetchCrawlersRef.current()
373
+ }
374
+ }, 10000)
375
+
376
+ intervalSetupRef.current = true
377
+
378
+ return () => {
379
+ if (intervalRef.current) {
380
+ clearInterval(intervalRef.current)
381
+ intervalRef.current = null
382
+ intervalSetupRef.current = false
383
+ }
384
+ }
385
+ }, [isAuthenticated, isAdmin, loading])
386
+
387
+ const runCrawler = async (crawlerName: string) => {
388
+ try {
389
+ const response = await fetch(`${API_BASE_URL}/api/admin/crawlers/${crawlerName}/start`, {
390
+ method: "POST",
391
+ headers: {
392
+ "Content-Type": "application/json",
393
+ ...getAuthHeaders()
394
+ },
395
+ })
396
+
397
+ if (response.ok) {
398
+ const result = await response.json().catch(() => ({}))
399
+ // Refresh crawler status after a short delay
400
+ setTimeout(() => {
401
+ fetchCrawlers()
402
+ }, 1000)
403
+ console.log("Crawler started successfully:", result)
404
+ } else {
405
+ let errorMessage = `Failed to start crawler: ${response.status} ${response.statusText}`
406
+ try {
407
+ const errorData = await response.json()
408
+ errorMessage = errorData.detail || errorData.message || errorMessage
409
+ } catch {
410
+ // If JSON parsing fails, use the default message
411
+ }
412
+ console.error("Failed to start crawler:", errorMessage)
413
+ alert(errorMessage)
414
+ }
415
+ } catch (error) {
416
+ const errorMessage = error instanceof Error ? error.message : String(error)
417
+ console.error("Failed to start crawler:", errorMessage)
418
+ alert(`Failed to start crawler: ${errorMessage}`)
419
+ }
420
+ }
421
+
422
+ const stopCrawler = async (crawlerName: string) => {
423
+ try {
424
+ const response = await fetch(`${API_BASE_URL}/admin/crawlers/${crawlerName}/stop`, {
425
+ method: "POST",
426
+ headers: {
427
+ "Content-Type": "application/json",
428
+ ...getAuthHeaders()
429
+ },
430
+ })
431
+ if (response.ok) {
432
+ setTimeout(() => {
433
+ fetchCrawlers()
434
+ }, 1000)
435
+ } else {
436
+ let errorMessage = `Failed to stop crawler: ${response.status} ${response.statusText}`
437
+ try {
438
+ const errorData = await response.json()
439
+ errorMessage = errorData.detail || errorData.message || errorMessage
440
+ } catch {
441
+ // If JSON parsing fails, use the default message
442
+ }
443
+ console.error("Failed to stop crawler:", errorMessage)
444
+ alert(errorMessage)
445
+ }
446
+ } catch (error) {
447
+ const errorMessage = error instanceof Error ? error.message : String(error)
448
+ console.error("Failed to stop crawler:", errorMessage)
449
+ alert(`Failed to stop crawler: ${errorMessage}`)
450
+ }
451
+ }
452
+
453
+ const viewCrawlerLogs = (crawlerName: string) => {
454
+ setSelectedCrawler(selectedCrawler === crawlerName ? null : crawlerName)
455
+ }
456
+
457
+ const setConfig = async () => {
458
+ if (!newConfigKey.trim() || !newConfigValue.trim()) return
459
+
460
+ try {
461
+ const response = await fetch(`${API_BASE_URL}/admin/config`, {
462
+ method: "POST",
463
+ headers: {
464
+ "Content-Type": "application/json",
465
+ ...getAuthHeaders()
466
+ },
467
+ body: JSON.stringify({
468
+ key: newConfigKey,
469
+ value: newConfigValue,
470
+ description: newConfigDescription
471
+ }),
472
+ })
473
+ if (response.ok) {
474
+ // Invalidate cache
475
+ await fetch(`/api/cache/admin/config`, { method: "DELETE" })
476
+
477
+ setNewConfigKey("")
478
+ setNewConfigValue("")
479
+ setNewConfigDescription("")
480
+ fetchConfigs()
481
+ }
482
+ } catch (error) {
483
+ console.error("Failed to set config:", error)
484
+ }
485
+ }
486
+
487
+ const deleteConfig = async (key: string) => {
488
+ try {
489
+ const response = await fetch(`${API_BASE_URL}/admin/config/${key}`, {
490
+ method: "DELETE",
491
+ headers: {
492
+ "Content-Type": "application/json",
493
+ ...getAuthHeaders()
494
+ },
495
+ })
496
+ if (response.ok) {
497
+ // Invalidate cache
498
+ await fetch(`/api/cache/admin/config`, { method: "DELETE" })
499
+ fetchConfigs()
500
+ }
501
+ } catch (error) {
502
+ console.error("Failed to delete config:", error)
503
+ }
504
+ }
505
+
506
+ const getStatusColor = (status: string) => {
507
+ switch (status) {
508
+ case "healthy": return "text-green-600"
509
+ case "running": return "text-blue-600"
510
+ case "failed": return "text-red-600"
511
+ default: return "text-gray-600"
512
+ }
513
+ }
514
+
515
+ const getStatusIcon = (status: string) => {
516
+ switch (status) {
517
+ case "healthy": return <CheckCircle className="w-4 h-4" />
518
+ case "running": return <Activity className="w-4 h-4" />
519
+ case "failed": return <XCircle className="w-4 h-4" />
520
+ default: return <Clock className="w-4 h-4" />
521
+ }
522
+ }
523
+
524
+ if (loading) {
525
+ return (
526
+ <div className="min-h-screen flex items-center justify-center">
527
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
528
+ </div>
529
+ )
530
+ }
531
+
532
+ if (!isAuthenticated || !isAdmin) {
533
+ return null
534
+ }
535
+
536
+ return (
537
+ <div className="min-h-screen bg-background flex">
538
+ <AdminSidebar />
539
+ <div className="flex-1 ml-0 md:ml-[20px] p-4 md:p-6">
540
+ <div className="absolute top-4 right-4 z-10">
541
+ <ThemeToggle />
542
+ </div>
543
+ <div className="max-w-7xl mx-auto space-y-4 md:space-y-6">
544
+ {/* Header */}
545
+ <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
546
+ <div>
547
+ <h1 className="text-2xl md:text-3xl font-bold">Admin Dashboard</h1>
548
+ <p className="text-sm md:text-base text-muted-foreground">Control Panel for AmaniQuery System</p>
549
+ </div>
550
+ <Link href="/">
551
+ <Button variant="outline" className="w-full sm:w-auto min-h-[44px]">
552
+ <Globe className="w-4 h-4 mr-2" />
553
+ Back to Home
554
+ </Button>
555
+ </Link>
556
+ </div>
557
+
558
+ {/* Stats Cards */}
559
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
560
+ <Card>
561
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
562
+ <CardTitle className="text-sm font-medium">Total Documents</CardTitle>
563
+ <FileText className="h-4 w-4 text-muted-foreground flex-shrink-0" />
564
+ </CardHeader>
565
+ <CardContent>
566
+ <div className="text-xl md:text-2xl font-bold">
567
+ {stats?.total_chunks || health?.database_chunks || 0}
568
+ </div>
569
+ <p className="text-xs text-muted-foreground">
570
+ Vector database chunks
571
+ </p>
572
+ </CardContent>
573
+ </Card>
574
+
575
+ <Card>
576
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
577
+ <CardTitle className="text-sm font-medium">Vector DB Status</CardTitle>
578
+ <Database className="h-4 w-4 text-muted-foreground flex-shrink-0" />
579
+ </CardHeader>
580
+ <CardContent>
581
+ <div className="flex items-center space-x-2">
582
+ {getStatusIcon(health?.status || "unknown")}
583
+ <span className={`text-sm font-medium ${getStatusColor(health?.status || "unknown")}`}>
584
+ {health?.status || "Unknown"}
585
+ </span>
586
+ </div>
587
+ <p className="text-xs text-muted-foreground">
588
+ {health?.embedding_model || "Loading..."}
589
+ </p>
590
+ </CardContent>
591
+ </Card>
592
+
593
+ <Card>
594
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
595
+ <CardTitle className="text-sm font-medium">System Status</CardTitle>
596
+ <Clock className="h-4 w-4 text-muted-foreground flex-shrink-0" />
597
+ </CardHeader>
598
+ <CardContent>
599
+ <div className="text-lg md:text-2xl font-bold">
600
+ Active
601
+ </div>
602
+ <p className="text-xs text-muted-foreground">
603
+ System operational
604
+ </p>
605
+ </CardContent>
606
+ </Card>
607
+
608
+ <Card>
609
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
610
+ <CardTitle className="text-sm font-medium">API Health</CardTitle>
611
+ <Activity className="h-4 w-4 text-muted-foreground flex-shrink-0" />
612
+ </CardHeader>
613
+ <CardContent>
614
+ <div className="flex items-center space-x-2">
615
+ {health ? (
616
+ <>
617
+ <CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0" />
618
+ <span className={`text-sm font-medium ${getStatusColor("healthy")}`}>Online</span>
619
+ </>
620
+ ) : (
621
+ <>
622
+ <XCircle className="w-4 h-4 text-red-600 flex-shrink-0" />
623
+ <span className={`text-sm font-medium ${getStatusColor("failed")}`}>Offline</span>
624
+ </>
625
+ )}
626
+ </div>
627
+ <p className="text-xs text-muted-foreground">
628
+ {health?.llm_provider || "Checking..."}
629
+ </p>
630
+ </CardContent>
631
+ </Card>
632
+ </div>
633
+
634
+ {/* Crawler Management */}
635
+ <Card>
636
+ <CardHeader>
637
+ <div className="flex items-center justify-between">
638
+ <CardTitle className="flex items-center text-lg md:text-xl">
639
+ <BarChart3 className="w-5 h-5 mr-2" />
640
+ Crawler Management
641
+ </CardTitle>
642
+ <Button
643
+ size="sm"
644
+ variant="outline"
645
+ onClick={fetchCrawlers}
646
+ disabled={isRefreshing}
647
+ className="h-8"
648
+ >
649
+ <RefreshCw className={`w-4 h-4 mr-1 ${isRefreshing ? "animate-spin" : ""}`} />
650
+ {isRefreshing ? "Refreshing..." : "Refresh"}
651
+ </Button>
652
+ </div>
653
+ </CardHeader>
654
+ <CardContent>
655
+ <div className="overflow-x-auto">
656
+ <Table>
657
+ <TableHeader>
658
+ <TableRow>
659
+ <TableHead className="min-w-[120px]">Crawler</TableHead>
660
+ <TableHead className="min-w-[80px]">Status</TableHead>
661
+ <TableHead className="min-w-[120px]">Last Run</TableHead>
662
+ <TableHead className="min-w-[80px]">PID</TableHead>
663
+ <TableHead className="min-w-[150px]">Actions</TableHead>
664
+ </TableRow>
665
+ </TableHeader>
666
+ <TableBody>
667
+ {crawlers && Object.keys(crawlers).length > 0 ? (
668
+ Object.entries(crawlers).map(([name, crawler]) => (
669
+ <React.Fragment key={name}>
670
+ <TableRow>
671
+ <TableCell className="font-medium capitalize text-sm md:text-base">
672
+ {name.replace(/_/g, ' ')}
673
+ </TableCell>
674
+ <TableCell>
675
+ <Badge variant={
676
+ crawler.status === "running" ? "default" :
677
+ crawler.status === "failed" ? "destructive" : "secondary"
678
+ } className="text-xs">
679
+ {crawler.status}
680
+ </Badge>
681
+ </TableCell>
682
+ <TableCell className="text-xs md:text-sm">
683
+ {crawler.last_run ? new Date(crawler.last_run).toLocaleString() : "Never"}
684
+ </TableCell>
685
+ <TableCell className="text-xs md:text-sm">
686
+ {crawler.pid || "-"}
687
+ </TableCell>
688
+ <TableCell>
689
+ <div className="flex space-x-1 md:space-x-2">
690
+ <Button
691
+ size="sm"
692
+ onClick={() => runCrawler(name)}
693
+ disabled={crawler.status === "running"}
694
+ className="h-8 px-2 md:px-3 text-xs md:text-sm min-w-[60px]"
695
+ >
696
+ <Play className="w-3 h-3 mr-1" />
697
+ Run
698
+ </Button>
699
+ {crawler.status === "running" && (
700
+ <Button
701
+ size="sm"
702
+ variant="destructive"
703
+ onClick={() => stopCrawler(name)}
704
+ className="h-8 px-2 md:px-3 text-xs md:text-sm min-w-[60px]"
705
+ >
706
+ <Square className="w-3 h-3 mr-1" />
707
+ Stop
708
+ </Button>
709
+ )}
710
+ <Button
711
+ size="sm"
712
+ variant="outline"
713
+ onClick={() => viewCrawlerLogs(name)}
714
+ className="h-8 px-2 md:px-3 text-xs md:text-sm"
715
+ >
716
+ {selectedCrawler === name ? "Hide" : "Logs"}
717
+ </Button>
718
+ </div>
719
+ </TableCell>
720
+ </TableRow>
721
+ {selectedCrawler === name && crawler.logs && crawler.logs.length > 0 && (
722
+ <TableRow>
723
+ <TableCell colSpan={5} className="bg-muted/50">
724
+ <div className="max-h-48 overflow-y-auto p-2">
725
+ <div className="text-xs font-mono space-y-1">
726
+ {crawler.logs.slice(-20).map((log, idx) => (
727
+ <div key={idx} className="text-muted-foreground">
728
+ {log}
729
+ </div>
730
+ ))}
731
+ </div>
732
+ </div>
733
+ </TableCell>
734
+ </TableRow>
735
+ )}
736
+ </React.Fragment>
737
+ ))
738
+ ) : (
739
+ <TableRow>
740
+ <TableCell colSpan={5} className="text-center text-muted-foreground py-6 md:py-8 text-sm">
741
+ No crawlers available or loading...
742
+ </TableCell>
743
+ </TableRow>
744
+ )}
745
+ </TableBody>
746
+ </Table>
747
+ </div>
748
+ </CardContent>
749
+ </Card>
750
+
751
+ {/* Database Management */}
752
+ <Card>
753
+ <CardHeader>
754
+ <CardTitle className="flex items-center text-lg md:text-xl">
755
+ <Database className="w-5 h-5 mr-2" />
756
+ Database Management
757
+ </CardTitle>
758
+ </CardHeader>
759
+ <CardContent>
760
+ <div className="space-y-4">
761
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
762
+ <div className="text-center">
763
+ <div className="text-2xl font-bold text-blue-600">
764
+ {databaseStats?.total_databases || 0}
765
+ </div>
766
+ <p className="text-sm text-muted-foreground">Total Databases</p>
767
+ </div>
768
+ <div className="text-center">
769
+ <div className="text-2xl font-bold text-green-600">
770
+ {databaseStats?.active_databases || 0}
771
+ </div>
772
+ <p className="text-sm text-muted-foreground">Active Databases</p>
773
+ </div>
774
+ <div className="text-center">
775
+ <div className="text-2xl font-bold text-purple-600">
776
+ {databaseStats?.databases?.reduce((sum, db) => sum + db.total_chunks, 0) || 0}
777
+ </div>
778
+ <p className="text-sm text-muted-foreground">Total Chunks</p>
779
+ </div>
780
+ </div>
781
+
782
+ <div className="space-y-3">
783
+ {databaseStats?.databases?.map((db) => (
784
+ <Card key={`${db.name}-${db.type}`} className="bg-muted/50">
785
+ <CardContent className="p-4">
786
+ <div className="flex items-center justify-between mb-3">
787
+ <div className="flex items-center space-x-3">
788
+ <div className={`w-3 h-3 rounded-full ${
789
+ db.status === 'active' ? 'bg-green-500' : 'bg-gray-400'
790
+ }`} />
791
+ <div>
792
+ <h4 className="font-medium capitalize">{db.name}</h4>
793
+ <p className="text-sm text-muted-foreground capitalize">{db.type} Database</p>
794
+ </div>
795
+ </div>
796
+ <Badge variant={db.status === 'active' ? 'secondary' : 'outline'}>
797
+ {db.status}
798
+ </Badge>
799
+ </div>
800
+
801
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
802
+ <div>
803
+ <span className="text-muted-foreground">Chunks:</span>
804
+ <div className="font-medium">{db.total_chunks.toLocaleString()}</div>
805
+ </div>
806
+ {db.elasticsearch_enabled && (
807
+ <div>
808
+ <span className="text-muted-foreground">ES Docs:</span>
809
+ <div className="font-medium">{db.elasticsearch_docs.toLocaleString()}</div>
810
+ </div>
811
+ )}
812
+ <div>
813
+ <span className="text-muted-foreground">Categories:</span>
814
+ <div className="font-medium">{Object.keys(db.categories).length}</div>
815
+ </div>
816
+ {db.persist_directory && (
817
+ <div>
818
+ <span className="text-muted-foreground">Storage:</span>
819
+ <div className="font-medium text-xs truncate max-w-24" title={db.persist_directory}>
820
+ {db.persist_directory.split('/').pop() || 'Local'}
821
+ </div>
822
+ </div>
823
+ )}
824
+ </div>
825
+
826
+ {Object.keys(db.categories).length > 0 && (
827
+ <div className="mt-3 pt-3 border-t">
828
+ <div className="text-sm text-muted-foreground mb-2">Top Categories:</div>
829
+ <div className="flex flex-wrap gap-1">
830
+ {Object.entries(db.categories)
831
+ .sort(([,a], [,b]) => b - a)
832
+ .slice(0, 5)
833
+ .map(([category, count]) => (
834
+ <Badge key={category} variant="outline" className="text-xs">
835
+ {category}: {count}
836
+ </Badge>
837
+ ))}
838
+ </div>
839
+ </div>
840
+ )}
841
+ </CardContent>
842
+ </Card>
843
+ ))}
844
+ </div>
845
+ </div>
846
+ </CardContent>
847
+ </Card>
848
+
849
+ {/* Command Shell */}
850
+ <Card>
851
+ <CardHeader>
852
+ <CardTitle className="flex items-center text-lg md:text-xl">
853
+ <Activity className="w-5 h-5 mr-2" />
854
+ Command Shell
855
+ </CardTitle>
856
+ </CardHeader>
857
+ <CardContent>
858
+ <div className="space-y-3 md:space-y-4">
859
+ <div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2">
860
+ <Input
861
+ placeholder="Enter command to execute..."
862
+ value={command}
863
+ onChange={(e) => setCommand(e.target.value)}
864
+ onKeyPress={(e) => e.key === 'Enter' && executeCommand()}
865
+ className="flex-1 font-mono text-sm md:text-base h-10 md:h-11"
866
+ disabled={isExecuting}
867
+ />
868
+ <Button
869
+ onClick={executeCommand}
870
+ disabled={isExecuting || !command.trim()}
871
+ className="w-full sm:w-auto min-h-[44px] px-4 md:px-6"
872
+ >
873
+ {isExecuting ? "Running..." : "Execute"}
874
+ </Button>
875
+ </div>
876
+
877
+ <div className="space-y-2 max-h-64 md:max-h-96 overflow-y-auto">
878
+ {commandHistory.map((result, index) => (
879
+ <Card key={index} className="bg-muted">
880
+ <CardContent className="p-3 md:p-4">
881
+ <div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-2 mb-2">
882
+ <span className="font-mono text-xs md:text-sm text-muted-foreground">$</span>
883
+ <code className="font-mono text-xs md:text-sm flex-1 break-all">{result.command}</code>
884
+ <Badge variant={result.exit_code === 0 ? "secondary" : "destructive"} className="text-xs w-fit">
885
+ Exit: {result.exit_code}
886
+ </Badge>
887
+ </div>
888
+ {result.stdout && (
889
+ <pre className="text-xs font-mono bg-background p-2 rounded border overflow-x-auto whitespace-pre-wrap">
890
+ {result.stdout}
891
+ </pre>
892
+ )}
893
+ {result.stderr && (
894
+ <pre className="text-xs font-mono bg-destructive/10 text-destructive p-2 rounded border overflow-x-auto whitespace-pre-wrap">
895
+ {result.stderr}
896
+ </pre>
897
+ )}
898
+ </CardContent>
899
+ </Card>
900
+ ))}
901
+ </div>
902
+ </div>
903
+ </CardContent>
904
+ </Card>
905
+
906
+ {/* Data Explorer */}
907
+ <Card>
908
+ <CardHeader>
909
+ <CardTitle className="flex items-center text-lg md:text-xl">
910
+ <Search className="w-5 h-5 mr-2" />
911
+ Data Explorer
912
+ </CardTitle>
913
+ </CardHeader>
914
+ <CardContent>
915
+ <div className="space-y-3 md:space-y-4">
916
+ <div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2">
917
+ <Input
918
+ placeholder="Search documents by keyword, source, or date..."
919
+ value={searchQuery}
920
+ onChange={(e) => setSearchQuery(e.target.value)}
921
+ onKeyPress={(e) => e.key === 'Enter' && searchDocuments()}
922
+ className="flex-1 h-10 md:h-11 text-sm md:text-base"
923
+ />
924
+ <Button
925
+ onClick={searchDocuments}
926
+ className="w-full sm:w-auto min-h-[44px] px-4 md:px-6"
927
+ >
928
+ <Search className="w-4 h-4 mr-2" />
929
+ Search
930
+ </Button>
931
+ </div>
932
+
933
+ {documents.length > 0 && (
934
+ <div className="space-y-2">
935
+ <p className="text-sm text-muted-foreground">
936
+ Found {documents.length} documents
937
+ </p>
938
+ <div className="max-h-64 md:max-h-96 overflow-y-auto space-y-2">
939
+ {documents.slice(0, 20).map((doc) => (
940
+ <Card key={doc.id} className="cursor-pointer hover:bg-muted/50">
941
+ <CardContent className="p-3 md:p-4">
942
+ <div className="flex flex-col sm:flex-row sm:justify-between sm:items-start mb-2 gap-2">
943
+ <h4 className="font-medium text-sm md:text-base line-clamp-1">
944
+ {doc.metadata.title || "Untitled"}
945
+ </h4>
946
+ <Badge variant="outline" className="text-xs w-fit flex-shrink-0">
947
+ {doc.metadata.category}
948
+ </Badge>
949
+ </div>
950
+ <p className="text-xs md:text-sm text-muted-foreground mb-2 line-clamp-2">
951
+ {doc.content.substring(0, 200)}...
952
+ </p>
953
+ <div className="flex flex-col sm:flex-row sm:justify-between text-xs text-muted-foreground gap-1">
954
+ <span className="truncate">{doc.metadata.source}</span>
955
+ <span className="flex-shrink-0">{doc.metadata.date}</span>
956
+ </div>
957
+ </CardContent>
958
+ </Card>
959
+ ))}
960
+ </div>
961
+ </div>
962
+ )}
963
+
964
+ <div className="text-xs md:text-sm text-muted-foreground bg-muted/50 p-3 rounded-lg">
965
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
966
+ <div><strong>Categories:</strong> {stats ? Object.keys(stats.categories).join(", ") : "Loading..."}</div>
967
+ <div><strong>Sources:</strong> {stats ? stats.sources.join(", ") : "Loading..."}</div>
968
+ </div>
969
+ </div>
970
+ </div>
971
+ </CardContent>
972
+ </Card>
973
+
974
+ {/* Database Storage */}
975
+ <Card>
976
+ <CardHeader>
977
+ <CardTitle className="flex items-center text-lg md:text-xl">
978
+ <Database className="w-5 h-5 mr-2" />
979
+ Database Storage
980
+ </CardTitle>
981
+ </CardHeader>
982
+ <CardContent>
983
+ <div className="space-y-4">
984
+ {databaseStorageStats ? (
985
+ <>
986
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
987
+ <div className="text-center">
988
+ <div className="text-2xl font-bold text-blue-600">
989
+ {databaseStorageStats.raw_documents.total}
990
+ </div>
991
+ <p className="text-sm text-muted-foreground">Raw Documents</p>
992
+ <div className="text-xs text-green-600">
993
+ {databaseStorageStats.raw_documents.processed} processed
994
+ </div>
995
+ <div className="text-xs text-orange-600">
996
+ {databaseStorageStats.raw_documents.unprocessed} unprocessed
997
+ </div>
998
+ </div>
999
+ <div className="text-center">
1000
+ <div className="text-2xl font-bold text-purple-600">
1001
+ {databaseStorageStats.processed_chunks.total}
1002
+ </div>
1003
+ <p className="text-sm text-muted-foreground">Processed Chunks</p>
1004
+ </div>
1005
+ </div>
1006
+
1007
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
1008
+ <div>
1009
+ <h4 className="font-medium mb-2">Raw Documents by Category</h4>
1010
+ <div className="space-y-1">
1011
+ {Object.entries(databaseStorageStats.raw_documents.categories).map(([category, count]) => (
1012
+ <div key={category} className="flex justify-between text-sm">
1013
+ <span className="truncate">{category}</span>
1014
+ <Badge variant="outline" className="text-xs">{count}</Badge>
1015
+ </div>
1016
+ ))}
1017
+ </div>
1018
+ </div>
1019
+ <div>
1020
+ <h4 className="font-medium mb-2">Processed Chunks by Category</h4>
1021
+ <div className="space-y-1">
1022
+ {Object.entries(databaseStorageStats.processed_chunks.categories).map(([category, count]) => (
1023
+ <div key={category} className="flex justify-between text-sm">
1024
+ <span className="truncate">{category}</span>
1025
+ <Badge variant="outline" className="text-xs">{count}</Badge>
1026
+ </div>
1027
+ ))}
1028
+ </div>
1029
+ </div>
1030
+ </div>
1031
+ </>
1032
+ ) : (
1033
+ <div className="text-center text-muted-foreground py-8">
1034
+ Database storage stats not available
1035
+ </div>
1036
+ )}
1037
+ </div>
1038
+ </CardContent>
1039
+ </Card>
1040
+
1041
+ {/* Configuration Management */}
1042
+ <Card>
1043
+ <CardHeader>
1044
+ <CardTitle className="flex items-center text-lg md:text-xl">
1045
+ <Database className="w-5 h-5 mr-2" />
1046
+ Configuration Management
1047
+ </CardTitle>
1048
+ </CardHeader>
1049
+ <CardContent>
1050
+ <div className="space-y-4">
1051
+ {/* Add New Config */}
1052
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
1053
+ <Input
1054
+ placeholder="Config Key (e.g., ELASTICSEARCH_URL)"
1055
+ value={newConfigKey}
1056
+ onChange={(e) => setNewConfigKey(e.target.value)}
1057
+ className="text-sm"
1058
+ />
1059
+ <Input
1060
+ placeholder="Config Value"
1061
+ value={newConfigValue}
1062
+ onChange={(e) => setNewConfigValue(e.target.value)}
1063
+ type="password"
1064
+ className="text-sm"
1065
+ />
1066
+ <div className="flex space-x-2">
1067
+ <Input
1068
+ placeholder="Description (optional)"
1069
+ value={newConfigDescription}
1070
+ onChange={(e) => setNewConfigDescription(e.target.value)}
1071
+ className="flex-1 text-sm"
1072
+ />
1073
+ <Button
1074
+ onClick={setConfig}
1075
+ disabled={!newConfigKey.trim() || !newConfigValue.trim()}
1076
+ className="px-4"
1077
+ >
1078
+ Add
1079
+ </Button>
1080
+ </div>
1081
+ </div>
1082
+
1083
+ {/* Config List */}
1084
+ <div className="space-y-2">
1085
+ <h4 className="font-medium text-sm">Current Configurations</h4>
1086
+ <div className="max-h-64 overflow-y-auto space-y-2">
1087
+ {Object.entries(configs).map(([key, config]) => (
1088
+ <Card key={key} className="bg-muted/50">
1089
+ <CardContent className="p-3">
1090
+ <div className="flex items-center justify-between">
1091
+ <div className="flex-1 min-w-0">
1092
+ <div className="font-mono text-sm font-medium truncate">{key}</div>
1093
+ <div className="text-xs text-muted-foreground">{config.description || "No description"}</div>
1094
+ </div>
1095
+ <div className="flex items-center space-x-2 ml-4">
1096
+ <Badge variant={config.has_value ? "secondary" : "outline"} className="text-xs">
1097
+ {config.has_value ? "Set" : "Empty"}
1098
+ </Badge>
1099
+ <Button
1100
+ size="sm"
1101
+ variant="destructive"
1102
+ onClick={() => deleteConfig(key)}
1103
+ className="h-8 px-2 text-xs"
1104
+ >
1105
+ Delete
1106
+ </Button>
1107
+ </div>
1108
+ </div>
1109
+ </CardContent>
1110
+ </Card>
1111
+ ))}
1112
+ {Object.keys(configs).length === 0 && (
1113
+ <div className="text-center text-muted-foreground py-4 text-sm">
1114
+ No configurations found
1115
+ </div>
1116
+ )}
1117
+ </div>
1118
+ </div>
1119
+
1120
+ {/* Common Config Templates */}
1121
+ <div className="text-xs text-muted-foreground bg-muted/50 p-3 rounded-lg">
1122
+ <div className="font-medium mb-2">Common Configuration Keys:</div>
1123
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-1 text-xs">
1124
+ <div>• ELASTICSEARCH_URL - Elasticsearch cloud URL</div>
1125
+ <div>• ELASTICSEARCH_API_KEY - Elasticsearch API key</div>
1126
+ <div>• UPSTASH_VECTOR_URL - Upstash Vector URL</div>
1127
+ <div>• UPSTASH_VECTOR_TOKEN - Upstash Vector token</div>
1128
+ <div>• QDRANT_URL - QDrant cloud URL</div>
1129
+ <div>• QDRANT_API_KEY - QDrant API key</div>
1130
+ <div>• UPSTASH_REDIS_URL - Upstash Redis URL</div>
1131
+ <div>• UPSTASH_REDIS_TOKEN - Upstash Redis token</div>
1132
+ <div>• LLM_PROVIDER - LLM provider (moonshot, openai, etc.)</div>
1133
+ <div>• GEMINI_API_KEY - Gemini API key</div>
1134
+ <div>• OPENAI_API_KEY - OpenAI API key</div>
1135
+ </div>
1136
+ </div>
1137
+ </div>
1138
+ </CardContent>
1139
+ </Card>
1140
+ </div>
1141
+ </div>
1142
+ </div>
1143
+ )
1144
+ }
src/app/admin/rag/page.tsx ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ /**
4
+ * RAG Settings - Admin Page
5
+ *
6
+ * Configure retrieval-augmented generation pipeline settings
7
+ */
8
+
9
+ import { useState } from "react"
10
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
11
+ import { Button } from "@/components/ui/button"
12
+ import { Badge } from "@/components/ui/badge"
13
+ import { Input } from "@/components/ui/input"
14
+ import { Label } from "@/components/ui/label"
15
+ import { Switch } from "@/components/ui/switch"
16
+ import { Slider } from "@/components/ui/slider"
17
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
18
+ import {
19
+ Search,
20
+ Zap,
21
+ Filter,
22
+ Sparkles,
23
+ RefreshCw,
24
+ Database,
25
+ TrendingUp,
26
+ Settings,
27
+ CheckCircle
28
+ } from "lucide-react"
29
+
30
+ interface RAGConfig {
31
+ // Retrieval settings
32
+ topK: number
33
+ useReranking: boolean
34
+ rerankTopK: number
35
+
36
+ // Query expansion
37
+ useHyDE: boolean
38
+ useMultiQuery: boolean
39
+ queryVariants: number
40
+
41
+ // Hybrid search
42
+ useHybridSearch: boolean
43
+ hybridAlpha: number // 0 = all BM25, 1 = all vector
44
+
45
+ // Caching
46
+ enableSemanticCache: boolean
47
+ cacheTTLHours: number
48
+
49
+ // Performance
50
+ parallelRetrieval: boolean
51
+ maxNamespaces: number
52
+ }
53
+
54
+ const DEFAULT_CONFIG: RAGConfig = {
55
+ topK: 5,
56
+ useReranking: true,
57
+ rerankTopK: 5,
58
+ useHyDE: false,
59
+ useMultiQuery: false,
60
+ queryVariants: 3,
61
+ useHybridSearch: false,
62
+ hybridAlpha: 0.7,
63
+ enableSemanticCache: true,
64
+ cacheTTLHours: 24,
65
+ parallelRetrieval: true,
66
+ maxNamespaces: 5
67
+ }
68
+
69
+ export default function RAGSettingsPage() {
70
+ const [config, setConfig] = useState<RAGConfig>(DEFAULT_CONFIG)
71
+ const [isSaving, setIsSaving] = useState(false)
72
+
73
+ const updateConfig = <K extends keyof RAGConfig>(key: K, value: RAGConfig[K]) => {
74
+ setConfig(prev => ({ ...prev, [key]: value }))
75
+ }
76
+
77
+ const handleSave = async () => {
78
+ setIsSaving(true)
79
+ // TODO: Save to backend
80
+ await new Promise(r => setTimeout(r, 1000))
81
+ setIsSaving(false)
82
+ }
83
+
84
+ return (
85
+ <div className="container mx-auto py-6 space-y-6">
86
+ <div className="flex items-center justify-between">
87
+ <div>
88
+ <h1 className="text-3xl font-bold">RAG Pipeline Settings</h1>
89
+ <p className="text-muted-foreground">Configure retrieval and generation parameters</p>
90
+ </div>
91
+ <Button onClick={handleSave} disabled={isSaving}>
92
+ {isSaving ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <CheckCircle className="h-4 w-4 mr-2" />}
93
+ Save Changes
94
+ </Button>
95
+ </div>
96
+
97
+ <div className="grid gap-6 md:grid-cols-4">
98
+ <Card>
99
+ <CardHeader className="pb-2">
100
+ <CardTitle className="text-sm font-medium">Top K</CardTitle>
101
+ </CardHeader>
102
+ <CardContent>
103
+ <div className="text-2xl font-bold">{config.topK}</div>
104
+ <p className="text-xs text-muted-foreground">Documents retrieved</p>
105
+ </CardContent>
106
+ </Card>
107
+ <Card>
108
+ <CardHeader className="pb-2">
109
+ <CardTitle className="text-sm font-medium">Re-ranking</CardTitle>
110
+ </CardHeader>
111
+ <CardContent>
112
+ <div className="text-2xl font-bold">{config.useReranking ? "Enabled" : "Disabled"}</div>
113
+ <p className="text-xs text-muted-foreground">Intelligent filtering</p>
114
+ </CardContent>
115
+ </Card>
116
+ <Card>
117
+ <CardHeader className="pb-2">
118
+ <CardTitle className="text-sm font-medium">Hybrid α</CardTitle>
119
+ </CardHeader>
120
+ <CardContent>
121
+ <div className="text-2xl font-bold">{config.hybridAlpha.toFixed(1)}</div>
122
+ <p className="text-xs text-muted-foreground">Vector vs BM25 balance</p>
123
+ </CardContent>
124
+ </Card>
125
+ <Card>
126
+ <CardHeader className="pb-2">
127
+ <CardTitle className="text-sm font-medium">Cache TTL</CardTitle>
128
+ </CardHeader>
129
+ <CardContent>
130
+ <div className="text-2xl font-bold">{config.cacheTTLHours}h</div>
131
+ <p className="text-xs text-muted-foreground">Result caching</p>
132
+ </CardContent>
133
+ </Card>
134
+ </div>
135
+
136
+ <Tabs defaultValue="retrieval" className="space-y-4">
137
+ <TabsList>
138
+ <TabsTrigger value="retrieval">
139
+ <Search className="h-4 w-4 mr-2" />
140
+ Retrieval
141
+ </TabsTrigger>
142
+ <TabsTrigger value="expansion">
143
+ <Sparkles className="h-4 w-4 mr-2" />
144
+ Query Expansion
145
+ </TabsTrigger>
146
+ <TabsTrigger value="hybrid">
147
+ <Database className="h-4 w-4 mr-2" />
148
+ Hybrid Search
149
+ </TabsTrigger>
150
+ <TabsTrigger value="performance">
151
+ <Zap className="h-4 w-4 mr-2" />
152
+ Performance
153
+ </TabsTrigger>
154
+ </TabsList>
155
+
156
+ <TabsContent value="retrieval">
157
+ <Card>
158
+ <CardHeader>
159
+ <CardTitle>Retrieval Settings</CardTitle>
160
+ <CardDescription>Configure document retrieval parameters</CardDescription>
161
+ </CardHeader>
162
+ <CardContent className="space-y-6">
163
+ <div className="space-y-2">
164
+ <Label>Top K: {config.topK}</Label>
165
+ <Slider
166
+ value={[config.topK]}
167
+ min={1}
168
+ max={20}
169
+ step={1}
170
+ onValueChange={(v) => updateConfig('topK', v[0])}
171
+ />
172
+ <p className="text-xs text-muted-foreground">Number of documents to retrieve</p>
173
+ </div>
174
+
175
+ <div className="flex items-center justify-between">
176
+ <div className="space-y-0.5">
177
+ <Label>Intelligent Re-ranking</Label>
178
+ <p className="text-xs text-muted-foreground">Use cross-encoder to filter relevant docs</p>
179
+ </div>
180
+ <Switch
181
+ checked={config.useReranking}
182
+ onCheckedChange={(v: boolean) => updateConfig('useReranking', v)}
183
+ />
184
+ </div>
185
+
186
+ {config.useReranking && (
187
+ <div className="space-y-2 ml-4 p-4 bg-muted/50 rounded-lg">
188
+ <Label>Re-rank Top K: {config.rerankTopK}</Label>
189
+ <Slider
190
+ value={[config.rerankTopK]}
191
+ min={1}
192
+ max={10}
193
+ step={1}
194
+ onValueChange={(v) => updateConfig('rerankTopK', v[0])}
195
+ />
196
+ <p className="text-xs text-muted-foreground">Documents after re-ranking</p>
197
+ </div>
198
+ )}
199
+ </CardContent>
200
+ </Card>
201
+ </TabsContent>
202
+
203
+ <TabsContent value="expansion">
204
+ <Card>
205
+ <CardHeader>
206
+ <CardTitle>Query Expansion</CardTitle>
207
+ <CardDescription>Improve recall with query expansion techniques</CardDescription>
208
+ </CardHeader>
209
+ <CardContent className="space-y-6">
210
+ <div className="flex items-center justify-between">
211
+ <div className="space-y-0.5">
212
+ <Label>HyDE (Hypothetical Document Embeddings)</Label>
213
+ <p className="text-xs text-muted-foreground">Generate hypothetical answer, then search</p>
214
+ </div>
215
+ <Switch
216
+ checked={config.useHyDE}
217
+ onCheckedChange={(v: boolean) => updateConfig('useHyDE', v)}
218
+ />
219
+ </div>
220
+
221
+ <div className="flex items-center justify-between">
222
+ <div className="space-y-0.5">
223
+ <Label>Multi-Query</Label>
224
+ <p className="text-xs text-muted-foreground">Generate multiple query variants</p>
225
+ </div>
226
+ <Switch
227
+ checked={config.useMultiQuery}
228
+ onCheckedChange={(v: boolean) => updateConfig('useMultiQuery', v)}
229
+ />
230
+ </div>
231
+
232
+ {config.useMultiQuery && (
233
+ <div className="space-y-2 ml-4 p-4 bg-muted/50 rounded-lg">
234
+ <Label>Query Variants: {config.queryVariants}</Label>
235
+ <Slider
236
+ value={[config.queryVariants]}
237
+ min={2}
238
+ max={5}
239
+ step={1}
240
+ onValueChange={(v) => updateConfig('queryVariants', v[0])}
241
+ />
242
+ </div>
243
+ )}
244
+ </CardContent>
245
+ </Card>
246
+ </TabsContent>
247
+
248
+ <TabsContent value="hybrid">
249
+ <Card>
250
+ <CardHeader>
251
+ <CardTitle>Hybrid Search</CardTitle>
252
+ <CardDescription>Combine vector and keyword search</CardDescription>
253
+ </CardHeader>
254
+ <CardContent className="space-y-6">
255
+ <div className="flex items-center justify-between">
256
+ <div className="space-y-0.5">
257
+ <Label>Enable Hybrid Search</Label>
258
+ <p className="text-xs text-muted-foreground">Combine BM25 + vector similarity</p>
259
+ </div>
260
+ <Switch
261
+ checked={config.useHybridSearch}
262
+ onCheckedChange={(v: boolean) => updateConfig('useHybridSearch', v)}
263
+ />
264
+ </div>
265
+
266
+ {config.useHybridSearch && (
267
+ <div className="space-y-2">
268
+ <Label>Hybrid Alpha: {config.hybridAlpha.toFixed(2)}</Label>
269
+ <Slider
270
+ value={[config.hybridAlpha]}
271
+ min={0}
272
+ max={1}
273
+ step={0.05}
274
+ onValueChange={(v) => updateConfig('hybridAlpha', v[0])}
275
+ />
276
+ <div className="flex justify-between text-xs text-muted-foreground">
277
+ <span>BM25 (keywords)</span>
278
+ <span>Vector (semantic)</span>
279
+ </div>
280
+ </div>
281
+ )}
282
+ </CardContent>
283
+ </Card>
284
+ </TabsContent>
285
+
286
+ <TabsContent value="performance">
287
+ <Card>
288
+ <CardHeader>
289
+ <CardTitle>Performance Settings</CardTitle>
290
+ <CardDescription>Optimize speed and caching</CardDescription>
291
+ </CardHeader>
292
+ <CardContent className="space-y-6">
293
+ <div className="flex items-center justify-between">
294
+ <div className="space-y-0.5">
295
+ <Label>Parallel Retrieval</Label>
296
+ <p className="text-xs text-muted-foreground">Query all namespaces simultaneously</p>
297
+ </div>
298
+ <Switch
299
+ checked={config.parallelRetrieval}
300
+ onCheckedChange={(v: boolean) => updateConfig('parallelRetrieval', v)}
301
+ />
302
+ </div>
303
+
304
+ <div className="space-y-2">
305
+ <Label>Max Namespaces: {config.maxNamespaces}</Label>
306
+ <Slider
307
+ value={[config.maxNamespaces]}
308
+ min={1}
309
+ max={10}
310
+ step={1}
311
+ onValueChange={(v) => updateConfig('maxNamespaces', v[0])}
312
+ />
313
+ </div>
314
+
315
+ <div className="flex items-center justify-between">
316
+ <div className="space-y-0.5">
317
+ <Label>Semantic Cache</Label>
318
+ <p className="text-xs text-muted-foreground">Cache similar query results</p>
319
+ </div>
320
+ <Switch
321
+ checked={config.enableSemanticCache}
322
+ onCheckedChange={(v: boolean) => updateConfig('enableSemanticCache', v)}
323
+ />
324
+ </div>
325
+
326
+ {config.enableSemanticCache && (
327
+ <div className="space-y-2 ml-4 p-4 bg-muted/50 rounded-lg">
328
+ <Label>Cache TTL: {config.cacheTTLHours} hours</Label>
329
+ <Slider
330
+ value={[config.cacheTTLHours]}
331
+ min={1}
332
+ max={168}
333
+ step={1}
334
+ onValueChange={(v) => updateConfig('cacheTTLHours', v[0])}
335
+ />
336
+ </div>
337
+ )}
338
+ </CardContent>
339
+ </Card>
340
+ </TabsContent>
341
+ </Tabs>
342
+ </div>
343
+ )
344
+ }
src/app/admin/settings/page.tsx ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useState, useEffect } from "react"
4
+ import { useRouter } from "next/navigation"
5
+ import { useAuth } from "@/lib/auth-context"
6
+ import { AdminSidebar } from "@/components/admin-sidebar"
7
+ import { ThemeToggle } from "@/components/theme-toggle"
8
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
9
+ import { Button } from "@/components/ui/button"
10
+ import { Input } from "@/components/ui/input"
11
+ import { Badge } from "@/components/ui/badge"
12
+ import { toast } from "sonner"
13
+ import {
14
+ Settings,
15
+ Plus,
16
+ Trash2,
17
+ Loader2,
18
+ RefreshCw,
19
+ } from "lucide-react"
20
+
21
+ const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
22
+
23
+ interface ConfigEntry {
24
+ key: string
25
+ value: string
26
+ description?: string
27
+ }
28
+
29
+ export default function AdminSettingsPage() {
30
+ const { isAuthenticated, isAdmin, loading } = useAuth()
31
+ const router = useRouter()
32
+ const [configs, setConfigs] = useState<Record<string, ConfigEntry>>({})
33
+ const [loadingConfigs, setLoadingConfigs] = useState(true)
34
+ const [newConfig, setNewConfig] = useState({
35
+ key: "",
36
+ value: "",
37
+ description: "",
38
+ })
39
+ const [saving, setSaving] = useState(false)
40
+
41
+ useEffect(() => {
42
+ if (!loading && !isAuthenticated) {
43
+ router.push("/auth/signin?redirect=/admin/settings")
44
+ } else if (!loading && isAuthenticated && !isAdmin) {
45
+ router.push("/chat")
46
+ }
47
+ }, [isAuthenticated, isAdmin, loading, router])
48
+
49
+ useEffect(() => {
50
+ if (isAuthenticated && isAdmin) {
51
+ fetchConfigs()
52
+ }
53
+ }, [isAuthenticated, isAdmin])
54
+
55
+ const fetchConfigs = async () => {
56
+ setLoadingConfigs(true)
57
+ try {
58
+ const sessionToken = localStorage.getItem("session_token")
59
+ const response = await fetch(`${API_URL}/admin/config`, {
60
+ headers: {
61
+ "X-Session-Token": sessionToken || "",
62
+ },
63
+ })
64
+ if (response.ok) {
65
+ const data = await response.json()
66
+ setConfigs(data.configs || {})
67
+ } else {
68
+ toast.error("Failed to fetch settings")
69
+ }
70
+ } catch {
71
+ toast.error("Failed to fetch settings")
72
+ } finally {
73
+ setLoadingConfigs(false)
74
+ }
75
+ }
76
+
77
+ const handleSaveConfig = async (key: string, value: string) => {
78
+ try {
79
+ const sessionToken = localStorage.getItem("session_token")
80
+ const response = await fetch(`${API_URL}/admin/config/${key}`, {
81
+ method: "PUT",
82
+ headers: {
83
+ "Content-Type": "application/json",
84
+ "X-Session-Token": sessionToken || "",
85
+ },
86
+ body: JSON.stringify({ value }),
87
+ })
88
+
89
+ if (response.ok) {
90
+ toast.success("Setting updated successfully")
91
+ fetchConfigs()
92
+ } else {
93
+ toast.error("Failed to update setting")
94
+ }
95
+ } catch {
96
+ toast.error("Failed to update setting")
97
+ }
98
+ }
99
+
100
+ const handleAddConfig = async () => {
101
+ if (!newConfig.key || !newConfig.value) {
102
+ toast.error("Key and value are required")
103
+ return
104
+ }
105
+
106
+ setSaving(true)
107
+ try {
108
+ const sessionToken = localStorage.getItem("session_token")
109
+ const response = await fetch(`${API_URL}/admin/config/${newConfig.key}`, {
110
+ method: "PUT",
111
+ headers: {
112
+ "Content-Type": "application/json",
113
+ "X-Session-Token": sessionToken || "",
114
+ },
115
+ body: JSON.stringify({
116
+ value: newConfig.value,
117
+ description: newConfig.description,
118
+ }),
119
+ })
120
+
121
+ if (response.ok) {
122
+ toast.success("Setting added successfully")
123
+ setNewConfig({ key: "", value: "", description: "" })
124
+ fetchConfigs()
125
+ } else {
126
+ toast.error("Failed to add setting")
127
+ }
128
+ } catch {
129
+ toast.error("Failed to add setting")
130
+ } finally {
131
+ setSaving(false)
132
+ }
133
+ }
134
+
135
+ const handleDeleteConfig = async (key: string) => {
136
+ if (!confirm(`Are you sure you want to delete the setting "${key}"?`)) {
137
+ return
138
+ }
139
+
140
+ try {
141
+ const sessionToken = localStorage.getItem("session_token")
142
+ const response = await fetch(`${API_URL}/admin/config/${key}`, {
143
+ method: "DELETE",
144
+ headers: {
145
+ "X-Session-Token": sessionToken || "",
146
+ },
147
+ })
148
+
149
+ if (response.ok) {
150
+ toast.success("Setting deleted successfully")
151
+ fetchConfigs()
152
+ } else {
153
+ toast.error("Failed to delete setting")
154
+ }
155
+ } catch {
156
+ toast.error("Failed to delete setting")
157
+ }
158
+ }
159
+
160
+ if (loading || !isAuthenticated || !isAdmin) {
161
+ return (
162
+ <div className="min-h-screen flex items-center justify-center">
163
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
164
+ </div>
165
+ )
166
+ }
167
+
168
+ return (
169
+ <div className="min-h-screen bg-background flex">
170
+ <AdminSidebar />
171
+ <div className="flex-1 ml-0 md:ml-[20px] p-4 md:p-6">
172
+ <div className="absolute top-4 right-4 z-10">
173
+ <ThemeToggle />
174
+ </div>
175
+ <div className="max-w-4xl mx-auto space-y-6">
176
+ <div>
177
+ <h1 className="text-3xl font-bold flex items-center gap-2">
178
+ <Settings className="w-8 h-8" />
179
+ Site Settings
180
+ </h1>
181
+ <p className="text-muted-foreground">Manage system configuration and settings</p>
182
+ </div>
183
+
184
+ {/* Add New Config */}
185
+ <Card>
186
+ <CardHeader>
187
+ <CardTitle>Add New Setting</CardTitle>
188
+ <CardDescription>Create a new configuration entry</CardDescription>
189
+ </CardHeader>
190
+ <CardContent>
191
+ <div className="space-y-4">
192
+ <div>
193
+ <label className="text-sm font-medium">Key</label>
194
+ <Input
195
+ placeholder="setting.key"
196
+ value={newConfig.key}
197
+ onChange={(e) => setNewConfig({ ...newConfig, key: e.target.value })}
198
+ />
199
+ </div>
200
+ <div>
201
+ <label className="text-sm font-medium">Value</label>
202
+ <Input
203
+ placeholder="Setting value"
204
+ value={newConfig.value}
205
+ onChange={(e) => setNewConfig({ ...newConfig, value: e.target.value })}
206
+ />
207
+ </div>
208
+ <div>
209
+ <label className="text-sm font-medium">Description (optional)</label>
210
+ <Input
211
+ placeholder="What this setting does"
212
+ value={newConfig.description}
213
+ onChange={(e) => setNewConfig({ ...newConfig, description: e.target.value })}
214
+ />
215
+ </div>
216
+ <Button onClick={handleAddConfig} disabled={saving}>
217
+ {saving ? (
218
+ <>
219
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
220
+ Adding...
221
+ </>
222
+ ) : (
223
+ <>
224
+ <Plus className="mr-2 h-4 w-4" />
225
+ Add Setting
226
+ </>
227
+ )}
228
+ </Button>
229
+ </div>
230
+ </CardContent>
231
+ </Card>
232
+
233
+ {/* Existing Configs */}
234
+ <Card>
235
+ <CardHeader>
236
+ <div className="flex items-center justify-between">
237
+ <div>
238
+ <CardTitle>System Settings</CardTitle>
239
+ <CardDescription>Manage existing configuration entries</CardDescription>
240
+ </div>
241
+ <Button variant="outline" size="sm" onClick={fetchConfigs}>
242
+ <RefreshCw className="mr-2 h-4 w-4" />
243
+ Refresh
244
+ </Button>
245
+ </div>
246
+ </CardHeader>
247
+ <CardContent>
248
+ {loadingConfigs ? (
249
+ <div className="flex items-center justify-center py-12">
250
+ <Loader2 className="w-6 h-6 animate-spin text-primary" />
251
+ </div>
252
+ ) : Object.keys(configs).length === 0 ? (
253
+ <div className="text-center py-12 text-muted-foreground">
254
+ No settings configured
255
+ </div>
256
+ ) : (
257
+ <div className="space-y-4">
258
+ {Object.entries(configs).map(([key, config]) => (
259
+ <div
260
+ key={key}
261
+ className="flex items-start gap-4 p-4 border rounded-lg hover:bg-accent/50"
262
+ >
263
+ <div className="flex-1 space-y-2">
264
+ <div className="flex items-center gap-2">
265
+ <code className="text-sm font-mono font-semibold">{key}</code>
266
+ {config.description && (
267
+ <Badge variant="outline" className="text-xs">
268
+ {config.description}
269
+ </Badge>
270
+ )}
271
+ </div>
272
+ <Input
273
+ value={config.value}
274
+ onChange={(e) => {
275
+ setConfigs({
276
+ ...configs,
277
+ [key]: { ...config, value: e.target.value },
278
+ })
279
+ }}
280
+ onBlur={() => handleSaveConfig(key, config.value)}
281
+ className="font-mono text-sm"
282
+ />
283
+ </div>
284
+ <Button
285
+ variant="ghost"
286
+ size="sm"
287
+ onClick={() => handleDeleteConfig(key)}
288
+ className="text-destructive hover:text-destructive"
289
+ >
290
+ <Trash2 className="h-4 w-4" />
291
+ </Button>
292
+ </div>
293
+ ))}
294
+ </div>
295
+ )}
296
+ </CardContent>
297
+ </Card>
298
+ </div>
299
+ </div>
300
+ </div>
301
+ )
302
+ }
303
+
src/app/admin/training/page.tsx ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
5
+ import { Badge } from "@/components/ui/badge";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Database, Download, RefreshCw } from "lucide-react";
8
+ import { AdminSidebar } from "@/components/admin-sidebar";
9
+
10
+ interface FeedbackAnalytics {
11
+ feedback_distribution: {
12
+ total: number;
13
+ positive: number;
14
+ negative: number;
15
+ positive_rate: number;
16
+ };
17
+ training_impact: {
18
+ total_scored: number;
19
+ training_candidates: number;
20
+ awaiting_export: number;
21
+ conversion_rate: string;
22
+ average_quality_score: number;
23
+ };
24
+ score_distribution: {
25
+ [key: string]: number;
26
+ };
27
+ }
28
+
29
+ interface TrainingStats {
30
+ total_scored: number;
31
+ kept_for_training: number;
32
+ exported: number;
33
+ awaiting_export: number;
34
+ average_score: number;
35
+ score_distribution: {
36
+ [key: string]: number;
37
+ };
38
+ }
39
+
40
+ interface ClusterStats {
41
+ total_clusters: number;
42
+ active_clusters: number;
43
+ pending_suggestions: number;
44
+ total_queries_classified: number;
45
+ }
46
+
47
+ export default function TrainingMonitorPage() {
48
+ const [feedbackData, setFeedbackData] = useState<FeedbackAnalytics | null>(null);
49
+ const [trainingData, setTrainingData] = useState<TrainingStats | null>(null);
50
+ const [loading, setLoading] = useState(true);
51
+ const [refreshing, setRefreshing] = useState(false);
52
+
53
+ const fetchAnalytics = async () => {
54
+ try {
55
+ setRefreshing(true);
56
+
57
+ const feedbackRes = await fetch("/api/v1/feedback/analytics");
58
+ if (feedbackRes.ok) {
59
+ setFeedbackData(await feedbackRes.json());
60
+ }
61
+
62
+ const trainingRes = await fetch("/api/v1/finetuning/stats");
63
+ if (trainingRes.ok) {
64
+ setTrainingData(await trainingRes.json());
65
+ }
66
+ } catch (error) {
67
+ console.error("Failed to fetch analytics:", error);
68
+ } finally {
69
+ setLoading(false);
70
+ setRefreshing(false);
71
+ }
72
+ };
73
+
74
+ useEffect(() => {
75
+ fetchAnalytics();
76
+
77
+ // Auto-refresh every 30 seconds for real-time monitoring
78
+ const interval = setInterval(() => {
79
+ fetchAnalytics();
80
+ }, 30000);
81
+
82
+ return () => clearInterval(interval);
83
+ }, []);
84
+
85
+ const handleExport = async (format: string) => {
86
+ try {
87
+ const res = await fetch(`/api/v1/finetuning/export?format=${format}&min_score=4.0`, {
88
+ method: "POST",
89
+ });
90
+ const data = await res.json();
91
+ alert(`Exported ${data.exported_count} interactions!`);
92
+ fetchAnalytics(); // Refresh stats
93
+ } catch (error) {
94
+ console.error("Export failed:", error);
95
+ alert("Export failed. Check console.");
96
+ }
97
+ };
98
+
99
+ const scoreLast7Days = async () => {
100
+ try {
101
+ const res = await fetch("/api/v1/finetuning/auto-score-all?days=7", { method: "POST" });
102
+ const data = await res.json();
103
+ alert(`Scored ${data.scored} interactions, ${data.saved_for_training} added to training dataset!`);
104
+ fetchAnalytics();
105
+ } catch (error) {
106
+ console.error("Scoring failed:", error);
107
+ }
108
+ };
109
+
110
+ if (loading) {
111
+ return (
112
+ <div className="flex items-center justify-center min-h-screen">
113
+ <div className="text-center space-y-4">
114
+ <RefreshCw className="w-8 h-8 animate-spin mx-auto text-blue-500" />
115
+ <p className="text-gray-600">Loading training metrics...</p>
116
+ </div>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ return (
122
+ <>
123
+ <AdminSidebar />
124
+ <div className="ml-0 md:ml-5 transition-all duration-300">
125
+ <div className="container mx-auto p-6 space-y-6">
126
+ <div className="flex justify-between items-center">
127
+ <div>
128
+ <h1 className="text-3xl font-bold">Training & Feedback Monitor</h1>
129
+ <p className="text-gray-500 mt-1">Track model improvement and data quality</p>
130
+ </div>
131
+ <div className="flex gap-2">
132
+ <Button onClick={scoreLast7Days} variant="outline">
133
+ Score Last 7 Days
134
+ </Button>
135
+ <Button onClick={fetchAnalytics} disabled={refreshing} variant="outline">
136
+ <RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? "animate-spin" : ""}`} />
137
+ Refresh
138
+ </Button>
139
+ </div>
140
+ </div>
141
+
142
+ {/* KPI Cards */}
143
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
144
+ <Card className="border-l-4 border-l-blue-500">
145
+ <CardHeader className="pb-2">
146
+ <CardTitle className="text-sm font-medium">Total Feedback</CardTitle>
147
+ </CardHeader>
148
+ <CardContent>
149
+ <div className="text-3xl font-bold">{feedbackData?.feedback_distribution.total || 0}</div>
150
+ <p className="text-xs text-green-600 mt-1">
151
+ ↑ {feedbackData?.feedback_distribution.positive_rate.toFixed(1)}% positive
152
+ </p>
153
+ </CardContent>
154
+ </Card>
155
+
156
+ <Card className="border-l-4 border-l-green-500">
157
+ <CardHeader className="pb-2">
158
+ <CardTitle className="text-sm font-medium">Training Ready</CardTitle>
159
+ </CardHeader>
160
+ <CardContent>
161
+ <div className="text-3xl font-bold">{feedbackData?.training_impact.training_candidates || 0}</div>
162
+ <p className="text-xs text-gray-500 mt-1">
163
+ Score: {feedbackData?.training_impact.average_quality_score.toFixed(2) || "N/A"}
164
+ </p>
165
+ </CardContent>
166
+ </Card>
167
+
168
+ <Card className="border-l-4 border-l-orange-500">
169
+ <CardHeader className="pb-2">
170
+ <CardTitle className="text-sm font-medium">Awaiting Export</CardTitle>
171
+ </CardHeader>
172
+ <CardContent>
173
+ <div className="text-3xl font-bold">{feedbackData?.training_impact.awaiting_export || 0}</div>
174
+ <p className="text-xs text-gray-500 mt-1">Ready to fine-tune</p>
175
+ </CardContent>
176
+ </Card>
177
+
178
+ <Card className="border-l-4 border-l-purple-500">
179
+ <CardHeader className="pb-2">
180
+ <CardTitle className="text-sm font-medium">Conversion Rate</CardTitle>
181
+ </CardHeader>
182
+ <CardContent>
183
+ <div className="text-3xl font-bold">{feedbackData?.training_impact.conversion_rate || "0%"}</div>
184
+ <p className="text-xs text-gray-500 mt-1">Feedback → Training</p>
185
+ </CardContent>
186
+ </Card>
187
+ </div>
188
+
189
+ {/* Export Section */}
190
+ <Card>
191
+ <CardHeader>
192
+ <CardTitle className="flex items-center gap-2">
193
+ <Download className="w-5 h-5" />
194
+ Export Training Dataset
195
+ </CardTitle>
196
+ <CardDescription>
197
+ Export {feedbackData?.training_impact.awaiting_export || 0} high-quality interactions for fine-tuning
198
+ </CardDescription>
199
+ </CardHeader>
200
+ <CardContent>
201
+ <div className="flex gap-3">
202
+ <Button onClick={() => handleExport("alpaca")} className="flex-1" size="lg">
203
+ <Database className="w-4 h-4 mr-2" />
204
+ Export Alpaca Format
205
+ </Button>
206
+ <Button onClick={() => handleExport("sharegpt")} variant="secondary" className="flex-1" size="lg">
207
+ <Database className="w-4 h-4 mr-2" />
208
+ Export ShareGPT Format
209
+ </Button>
210
+ </div>
211
+ </CardContent>
212
+ </Card>
213
+
214
+ {/* Quality Distribution */}
215
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
216
+ <Card>
217
+ <CardHeader>
218
+ <CardTitle>Quality Score Distribution</CardTitle>
219
+ </CardHeader>
220
+ <CardContent>
221
+ <div className="space-y-3">
222
+ {feedbackData?.score_distribution && Object.entries(feedbackData.score_distribution).map(([tier, count]) => {
223
+ const percentage = (count / (feedbackData.training_impact.total_scored || 1)) * 100;
224
+ return (
225
+ <div key={tier}>
226
+ <div className="flex justify-between mb-1">
227
+ <span className="text-sm capitalize">{tier.replace(/_/g, " ")}</span>
228
+ <span className="text-sm font-medium">{count}</span>
229
+ </div>
230
+ <div className="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
231
+ <div
232
+ className="bg-blue-500 h-2 rounded-full transition-all duration-300"
233
+ style={{ width: `${percentage}%` }}
234
+ />
235
+ </div>
236
+ </div>
237
+ );
238
+ })}
239
+ </div>
240
+ </CardContent>
241
+ </Card>
242
+
243
+ <Card>
244
+ <CardHeader>
245
+ <CardTitle>Training Pipeline Status</CardTitle>
246
+ </CardHeader>
247
+ <CardContent>
248
+ <div className="space-y-4">
249
+ <div className="flex justify-between items-center p-3 bg-blue-50 rounded">
250
+ <span className="text-sm">Total Interactions Scored</span>
251
+ <Badge variant="outline">{trainingData?.total_scored || 0}</Badge>
252
+ </div>
253
+ <div className="flex justify-between items-center p-3 bg-green-50 rounded">
254
+ <span className="text-sm">Approved for Training</span>
255
+ <Badge className="bg-green-600">{trainingData?.kept_for_training || 0}</Badge>
256
+ </div>
257
+ <div className="flex justify-between items-center p-3 bg-gray-50 rounded">
258
+ <span className="text-sm">Already Exported</span>
259
+ <Badge variant="secondary">{trainingData?.exported || 0}</Badge>
260
+ </div>
261
+ <div className="flex justify-between items-center p-3 bg-orange-50 rounded">
262
+ <span className="text-sm">Pending Export</span>
263
+ <Badge className="bg-orange-600">{trainingData?.awaiting_export || 0}</Badge>
264
+ </div>
265
+ </div>
266
+ </CardContent>
267
+ </Card>
268
+ </div>
269
+ </div>
270
+ </div>
271
+ </>
272
+ );
273
+ }
src/app/admin/users/page.tsx ADDED
@@ -0,0 +1,711 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useState, useEffect } from "react"
4
+ import { useRouter } from "next/navigation"
5
+ import { useAuth } from "@/lib/auth-context"
6
+ import { AdminSidebar } from "@/components/admin-sidebar"
7
+ import { ThemeToggle } from "@/components/theme-toggle"
8
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
9
+ import { Button } from "@/components/ui/button"
10
+ import { Input } from "@/components/ui/input"
11
+ import { Badge } from "@/components/ui/badge"
12
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
13
+ import {
14
+ Dialog,
15
+ DialogContent,
16
+ DialogDescription,
17
+ DialogFooter,
18
+ DialogHeader,
19
+ DialogTitle,
20
+ } from "@/components/ui/dialog"
21
+ import {
22
+ Select,
23
+ SelectContent,
24
+ SelectItem,
25
+ SelectTrigger,
26
+ SelectValue,
27
+ } from "@/components/ui/select"
28
+ import {
29
+ DropdownMenu,
30
+ DropdownMenuContent,
31
+ DropdownMenuItem,
32
+ DropdownMenuLabel,
33
+ DropdownMenuSeparator,
34
+ DropdownMenuTrigger,
35
+ } from "@/components/ui/dropdown-menu"
36
+ import { Label } from "@/components/ui/label"
37
+ import { toast } from "sonner"
38
+ import {
39
+ Users,
40
+ Search,
41
+ CheckCircle,
42
+ XCircle,
43
+ ChevronLeft,
44
+ ChevronRight,
45
+ Loader2,
46
+ MoreVertical,
47
+ Edit,
48
+ Trash2,
49
+ Ban,
50
+ CheckCheck,
51
+ ShieldOff,
52
+ ShieldCheck,
53
+ Activity,
54
+ Filter,
55
+ } from "lucide-react"
56
+
57
+ const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
58
+
59
+ interface User {
60
+ id: string
61
+ email: string
62
+ name: string | null
63
+ status: string
64
+ email_verified: boolean
65
+ last_login: string | null
66
+ created_at: string
67
+ updated_at: string
68
+ roles?: string[]
69
+ }
70
+
71
+ interface AdminStats {
72
+ total_users: number
73
+ active_users: number
74
+ suspended_users: number
75
+ verified_users: number
76
+ unverified_users: number
77
+ }
78
+
79
+ export default function AdminUsersPage() {
80
+ const { isAuthenticated, isAdmin, loading } = useAuth()
81
+ const router = useRouter()
82
+ const [users, setUsers] = useState<User[]>([])
83
+ const [stats, setStats] = useState<AdminStats | null>(null)
84
+ const [loadingUsers, setLoadingUsers] = useState(true)
85
+ const [searchQuery, setSearchQuery] = useState("")
86
+ const [statusFilter, setStatusFilter] = useState<string>("all")
87
+ const [verificationFilter, setVerificationFilter] = useState<string>("all")
88
+ const [page, setPage] = useState(1)
89
+ const [total, setTotal] = useState(0)
90
+ const [pageSize] = useState(20)
91
+
92
+ // Dialog states
93
+ const [editDialogOpen, setEditDialogOpen] = useState(false)
94
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
95
+ const [selectedUser, setSelectedUser] = useState<User | null>(null)
96
+ const [editFormData, setEditFormData] = useState({ name: "", email: "", status: "" })
97
+ const [actionLoading, setActionLoading] = useState(false)
98
+
99
+ useEffect(() => {
100
+ if (!loading && !isAuthenticated) {
101
+ router.push("/auth/signin?redirect=/admin/users")
102
+ } else if (!loading && isAuthenticated && !isAdmin) {
103
+ router.push("/chat")
104
+ }
105
+ }, [isAuthenticated, isAdmin, loading, router])
106
+
107
+ useEffect(() => {
108
+ if (isAuthenticated && isAdmin) {
109
+ fetchUsers()
110
+ fetchStats()
111
+ }
112
+ // eslint-disable-next-line react-hooks/exhaustive-deps
113
+ }, [page, statusFilter, verificationFilter, isAuthenticated, isAdmin])
114
+
115
+ const fetchStats = async () => {
116
+ try {
117
+ const sessionToken = localStorage.getItem("session_token")
118
+ const response = await fetch(`${API_URL}/api/v1/auth/admin/stats`, {
119
+ headers: {
120
+ "X-Session-Token": sessionToken || "",
121
+ },
122
+ })
123
+ if (response.ok) {
124
+ const data = await response.json()
125
+ setStats(data)
126
+ }
127
+ } catch {
128
+ // Stats are optional, don't show error
129
+ }
130
+ }
131
+
132
+ const fetchUsers = async () => {
133
+ setLoadingUsers(true)
134
+ try {
135
+ const sessionToken = localStorage.getItem("session_token")
136
+ const params = new URLSearchParams({
137
+ page: page.toString(),
138
+ page_size: pageSize.toString(),
139
+ })
140
+
141
+ if (searchQuery) params.append("search", searchQuery)
142
+ if (statusFilter !== "all") params.append("status", statusFilter)
143
+ if (verificationFilter !== "all") params.append("email_verified", verificationFilter)
144
+
145
+ const response = await fetch(
146
+ `${API_URL}/api/v1/auth/admin/users?${params}`,
147
+ {
148
+ headers: {
149
+ "X-Session-Token": sessionToken || "",
150
+ },
151
+ }
152
+ )
153
+
154
+ if (response.ok) {
155
+ const data = await response.json()
156
+ setUsers(data.users || [])
157
+ setTotal(data.total || 0)
158
+ } else {
159
+ toast.error("Failed to fetch users")
160
+ }
161
+ } catch {
162
+ toast.error("Failed to fetch users")
163
+ } finally {
164
+ setLoadingUsers(false)
165
+ }
166
+ }
167
+
168
+ const handleEditUser = (user: User) => {
169
+ setSelectedUser(user)
170
+ setEditFormData({
171
+ name: user.name || "",
172
+ email: user.email,
173
+ status: user.status,
174
+ })
175
+ setEditDialogOpen(true)
176
+ }
177
+
178
+ const handleSaveEdit = async () => {
179
+ if (!selectedUser) return
180
+
181
+ setActionLoading(true)
182
+ try {
183
+ const sessionToken = localStorage.getItem("session_token")
184
+ const response = await fetch(
185
+ `${API_URL}/api/v1/auth/admin/users/${selectedUser.id}`,
186
+ {
187
+ method: "PUT",
188
+ headers: {
189
+ "Content-Type": "application/json",
190
+ "X-Session-Token": sessionToken || "",
191
+ },
192
+ body: JSON.stringify({
193
+ name: editFormData.name || null,
194
+ email: editFormData.email,
195
+ status: editFormData.status,
196
+ }),
197
+ }
198
+ )
199
+
200
+ if (response.ok) {
201
+ toast.success("User updated successfully")
202
+ setEditDialogOpen(false)
203
+ fetchUsers()
204
+ fetchStats()
205
+ } else {
206
+ const error = await response.json()
207
+ toast.error(error.detail || "Failed to update user")
208
+ }
209
+ } catch {
210
+ toast.error("Failed to update user")
211
+ } finally {
212
+ setActionLoading(false)
213
+ }
214
+ }
215
+
216
+ const handleVerifyEmail = async (userId: string, verify: boolean) => {
217
+ try {
218
+ const sessionToken = localStorage.getItem("session_token")
219
+ const endpoint = verify ? "verify-email" : "unverify-email"
220
+ const response = await fetch(
221
+ `${API_URL}/api/v1/auth/admin/users/${userId}/${endpoint}`,
222
+ {
223
+ method: "POST",
224
+ headers: {
225
+ "X-Session-Token": sessionToken || "",
226
+ },
227
+ }
228
+ )
229
+
230
+ if (response.ok) {
231
+ toast.success(verify ? "Email verified" : "Email unverified")
232
+ fetchUsers()
233
+ fetchStats()
234
+ } else {
235
+ toast.error("Failed to update verification status")
236
+ }
237
+ } catch {
238
+ toast.error("Failed to update verification status")
239
+ }
240
+ }
241
+
242
+ const handleSuspendUser = async (userId: string, suspend: boolean) => {
243
+ try {
244
+ const sessionToken = localStorage.getItem("session_token")
245
+ const endpoint = suspend ? "suspend" : "activate"
246
+ const response = await fetch(
247
+ `${API_URL}/api/v1/auth/admin/users/${userId}/${endpoint}`,
248
+ {
249
+ method: "POST",
250
+ headers: {
251
+ "X-Session-Token": sessionToken || "",
252
+ },
253
+ }
254
+ )
255
+
256
+ if (response.ok) {
257
+ toast.success(suspend ? "User suspended" : "User activated")
258
+ fetchUsers()
259
+ fetchStats()
260
+ } else {
261
+ toast.error("Failed to update user status")
262
+ }
263
+ } catch {
264
+ toast.error("Failed to update user status")
265
+ }
266
+ }
267
+
268
+ const handleDeleteUser = async () => {
269
+ if (!selectedUser) return
270
+
271
+ setActionLoading(true)
272
+ try {
273
+ const sessionToken = localStorage.getItem("session_token")
274
+ const response = await fetch(
275
+ `${API_URL}/api/v1/auth/admin/users/${selectedUser.id}`,
276
+ {
277
+ method: "DELETE",
278
+ headers: {
279
+ "X-Session-Token": sessionToken || "",
280
+ },
281
+ }
282
+ )
283
+
284
+ if (response.ok) {
285
+ toast.success("User deleted successfully")
286
+ setDeleteDialogOpen(false)
287
+ fetchUsers()
288
+ fetchStats()
289
+ } else {
290
+ const error = await response.json()
291
+ toast.error(error.detail || "Failed to delete user")
292
+ }
293
+ } catch {
294
+ toast.error("Failed to delete user")
295
+ } finally {
296
+ setActionLoading(false)
297
+ }
298
+ }
299
+
300
+ const handleRevokeSession = async (userId: string) => {
301
+ try {
302
+ const sessionToken = localStorage.getItem("session_token")
303
+ const response = await fetch(
304
+ `${API_URL}/api/v1/auth/admin/users/${userId}/revoke-sessions`,
305
+ {
306
+ method: "POST",
307
+ headers: {
308
+ "X-Session-Token": sessionToken || "",
309
+ },
310
+ }
311
+ )
312
+
313
+ if (response.ok) {
314
+ const data = await response.json()
315
+ toast.success(`Revoked ${data.sessions_revoked} session(s)`)
316
+ } else {
317
+ toast.error("Failed to revoke sessions")
318
+ }
319
+ } catch {
320
+ toast.error("Failed to revoke sessions")
321
+ }
322
+ }
323
+
324
+
325
+ const filteredUsers = users.filter(
326
+ (user) =>
327
+ user.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
328
+ (user.name && user.name.toLowerCase().includes(searchQuery.toLowerCase()))
329
+ )
330
+
331
+ const totalPages = Math.ceil(total / pageSize)
332
+
333
+ if (loading || !isAuthenticated || !isAdmin) {
334
+ return (
335
+ <div className="min-h-screen flex items-center justify-center">
336
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
337
+ </div>
338
+ )
339
+ }
340
+
341
+ return (
342
+ <div className="min-h-screen bg-background flex">
343
+ <AdminSidebar />
344
+ <div className="flex-1 ml-0 md:ml-[20px] p-4 md:p-6">
345
+ <div className="absolute top-4 right-4 z-10">
346
+ <ThemeToggle />
347
+ </div>
348
+ <div className="max-w-7xl mx-auto space-y-6">
349
+ <div>
350
+ <h1 className="text-3xl font-bold flex items-center gap-2">
351
+ <Users className="w-8 h-8" />
352
+ User Management
353
+ </h1>
354
+ <p className="text-muted-foreground">Manage all users in the system</p>
355
+ </div>
356
+
357
+ {/* Search and Filters */}
358
+ <Card>
359
+ <CardHeader>
360
+ <CardTitle className="flex items-center gap-2">
361
+ <Filter className="w-5 h-5" />
362
+ Filters & Search
363
+ </CardTitle>
364
+ <CardDescription>
365
+ {stats && (
366
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
367
+ <div>
368
+ <p className="text-sm text-muted-foreground">Total Users</p>
369
+ <p className="text-2xl font-bold">{stats.total_users}</p>
370
+ </div>
371
+ <div>
372
+ <p className="text-sm text-muted-foreground">Active</p>
373
+ <p className="text-2xl font-bold text-green-600">{stats.active_users}</p>
374
+ </div>
375
+ <div>
376
+ <p className="text-sm text-muted-foreground">Suspended</p>
377
+ <p className="text-2xl font-bold text-red-600">{stats.suspended_users}</p>
378
+ </div>
379
+ <div>
380
+ <p className="text-sm text-muted-foreground">Verified</p>
381
+ <p className="text-2xl font-bold text-blue-600">{stats.verified_users}</p>
382
+ </div>
383
+ </div>
384
+ )}
385
+ </CardDescription>
386
+ </CardHeader>
387
+ <CardContent>
388
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
389
+ <div className="relative md:col-span-1">
390
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
391
+ <Input
392
+ placeholder="Search users..."
393
+ value={searchQuery}
394
+ onChange={(e) => setSearchQuery(e.target.value)}
395
+ onKeyDown={(e) => e.key === "Enter" && fetchUsers()}
396
+ className="pl-10"
397
+ />
398
+ </div>
399
+ <Select value={statusFilter} onValueChange={setStatusFilter}>
400
+ <SelectTrigger>
401
+ <SelectValue placeholder="Filter by status" />
402
+ </SelectTrigger>
403
+ <SelectContent>
404
+ <SelectItem value="all">All Statuses</SelectItem>
405
+ <SelectItem value="active">Active</SelectItem>
406
+ <SelectItem value="inactive">Inactive</SelectItem>
407
+ <SelectItem value="suspended">Suspended</SelectItem>
408
+ </SelectContent>
409
+ </Select>
410
+ <Select value={verificationFilter} onValueChange={setVerificationFilter}>
411
+ <SelectTrigger>
412
+ <SelectValue placeholder="Filter by verification" />
413
+ </SelectTrigger>
414
+ <SelectContent>
415
+ <SelectItem value="all">All Users</SelectItem>
416
+ <SelectItem value="true">Verified Only</SelectItem>
417
+ <SelectItem value="false">Unverified Only</SelectItem>
418
+ </SelectContent>
419
+ </Select>
420
+ </div>
421
+ </CardContent>
422
+ </Card>
423
+
424
+ {/* Users Table */}
425
+ <Card>
426
+ <CardHeader>
427
+ <CardTitle>All Users</CardTitle>
428
+ </CardHeader>
429
+ <CardContent>
430
+ {loadingUsers ? (
431
+ <div className="flex items-center justify-center py-12">
432
+ <Loader2 className="w-6 h-6 animate-spin text-primary" />
433
+ </div>
434
+ ) : (
435
+ <>
436
+ <div className="overflow-x-auto">
437
+ <Table>
438
+ <TableHeader>
439
+ <TableRow>
440
+ <TableHead>Email</TableHead>
441
+ <TableHead>Name</TableHead>
442
+ <TableHead>Status</TableHead>
443
+ <TableHead>Verified</TableHead>
444
+ <TableHead>Roles</TableHead>
445
+ <TableHead>Last Login</TableHead>
446
+ <TableHead className="text-right">Actions</TableHead>
447
+ </TableRow>
448
+ </TableHeader>
449
+ <TableBody>
450
+ {filteredUsers.length === 0 ? (
451
+ <TableRow>
452
+ <TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
453
+ No users found
454
+ </TableCell>
455
+ </TableRow>
456
+ ) : (
457
+ filteredUsers.map((user) => (
458
+ <TableRow key={user.id}>
459
+ <TableCell className="font-medium">{user.email}</TableCell>
460
+ <TableCell>{user.name || "—"}</TableCell>
461
+ <TableCell>
462
+ <Badge
463
+ variant={
464
+ user.status === "active" ? "default" :
465
+ user.status === "suspended" ? "destructive" :
466
+ "secondary"
467
+ }
468
+ >
469
+ {user.status}
470
+ </Badge>
471
+ </TableCell>
472
+ <TableCell>
473
+ {user.email_verified ? (
474
+ <CheckCircle className="w-5 h-5 text-green-600" />
475
+ ) : (
476
+ <XCircle className="w-5 h-5 text-yellow-600" />
477
+ )}
478
+ </TableCell>
479
+ <TableCell>
480
+ {user.roles && user.roles.length > 0 ? (
481
+ <div className="flex gap-1 flex-wrap">
482
+ {user.roles.map((role) => (
483
+ <Badge key={role} variant="outline" className="text-xs">
484
+ {role}
485
+ </Badge>
486
+ ))}
487
+ </div>
488
+ ) : (
489
+ <span className="text-muted-foreground text-sm">—</span>
490
+ )}
491
+ </TableCell>
492
+ <TableCell className="text-sm text-muted-foreground">
493
+ {user.last_login
494
+ ? new Date(user.last_login).toLocaleDateString()
495
+ : "Never"}
496
+ </TableCell>
497
+ <TableCell className="text-right">
498
+ <DropdownMenu>
499
+ <DropdownMenuTrigger asChild>
500
+ <Button variant="ghost" size="sm">
501
+ <MoreVertical className="w-4 h-4" />
502
+ </Button>
503
+ </DropdownMenuTrigger>
504
+ <DropdownMenuContent align="end" className="w-56">
505
+ <DropdownMenuLabel>User Actions</DropdownMenuLabel>
506
+ <DropdownMenuSeparator />
507
+ <DropdownMenuItem onClick={() => handleEditUser(user)}>
508
+ <Edit className="w-4 h-4 mr-2" />
509
+ Edit User
510
+ </DropdownMenuItem>
511
+ <DropdownMenuItem onClick={() => handleVerifyEmail(user.id, !user.email_verified)}>
512
+ {user.email_verified ? (
513
+ <>
514
+ <ShieldOff className="w-4 h-4 mr-2" />
515
+ Unverify Email
516
+ </>
517
+ ) : (
518
+ <>
519
+ <ShieldCheck className="w-4 h-4 mr-2" />
520
+ Verify Email
521
+ </>
522
+ )}
523
+ </DropdownMenuItem>
524
+ <DropdownMenuItem onClick={() => handleSuspendUser(user.id, user.status !== "suspended")}>
525
+ {user.status === "suspended" ? (
526
+ <>
527
+ <CheckCheck className="w-4 h-4 mr-2" />
528
+ Activate Account
529
+ </>
530
+ ) : (
531
+ <>
532
+ <Ban className="w-4 h-4 mr-2" />
533
+ Suspend Account
534
+ </>
535
+ )}
536
+ </DropdownMenuItem>
537
+ <DropdownMenuItem onClick={() => handleRevokeSession(user.id)}>
538
+ <Activity className="w-4 h-4 mr-2" />
539
+ Revoke Sessions
540
+ </DropdownMenuItem>
541
+ <DropdownMenuSeparator />
542
+ <DropdownMenuItem
543
+ onClick={() => {
544
+ setSelectedUser(user)
545
+ setDeleteDialogOpen(true)
546
+ }}
547
+ className="text-red-600"
548
+ >
549
+ <Trash2 className="w-4 h-4 mr-2" />
550
+ Delete User
551
+ </DropdownMenuItem>
552
+ </DropdownMenuContent>
553
+ </DropdownMenu>
554
+ </TableCell>
555
+ </TableRow>
556
+ ))
557
+ )}
558
+ </TableBody>
559
+ </Table>
560
+ </div>
561
+
562
+ {/* Pagination */}
563
+ {totalPages > 1 && (
564
+ <div className="flex items-center justify-between mt-4">
565
+ <div className="text-sm text-muted-foreground">
566
+ Page {page} of {totalPages}
567
+ </div>
568
+ <div className="flex gap-2">
569
+ <Button
570
+ variant="outline"
571
+ size="sm"
572
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
573
+ disabled={page === 1}
574
+ >
575
+ <ChevronLeft className="w-4 h-4" />
576
+ Previous
577
+ </Button>
578
+ <Button
579
+ variant="outline"
580
+ size="sm"
581
+ onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
582
+ disabled={page === totalPages}
583
+ >
584
+ Next
585
+ <ChevronRight className="w-4 h-4" />
586
+ </Button>
587
+ </div>
588
+ </div>
589
+ )}
590
+ </>
591
+ )}
592
+ </CardContent>
593
+ </Card>
594
+ </div>
595
+ </div>
596
+
597
+ {/* Edit User Dialog */}
598
+ <Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
599
+ <DialogContent>
600
+ <DialogHeader>
601
+ <DialogTitle>Edit User</DialogTitle>
602
+ <DialogDescription>
603
+ Update user information and account settings.
604
+ </DialogDescription>
605
+ </DialogHeader>
606
+ <div className="space-y-4 py-4">
607
+ <div className="space-y-2">
608
+ <Label htmlFor="edit-name">Name</Label>
609
+ <Input
610
+ id="edit-name"
611
+ value={editFormData.name}
612
+ onChange={(e) => setEditFormData({ ...editFormData, name: e.target.value })}
613
+ placeholder="User name"
614
+ />
615
+ </div>
616
+ <div className="space-y-2">
617
+ <Label htmlFor="edit-email">Email</Label>
618
+ <Input
619
+ id="edit-email"
620
+ type="email"
621
+ value={editFormData.email}
622
+ onChange={(e) => setEditFormData({ ...editFormData, email: e.target.value })}
623
+ placeholder="user@example.com"
624
+ />
625
+ </div>
626
+ <div className="space-y-2">
627
+ <Label htmlFor="edit-status">Status</Label>
628
+ <Select
629
+ value={editFormData.status}
630
+ onValueChange={(value: string) => setEditFormData({ ...editFormData, status: value })}
631
+ >
632
+ <SelectTrigger id="edit-status">
633
+ <SelectValue />
634
+ </SelectTrigger>
635
+ <SelectContent>
636
+ <SelectItem value="active">Active</SelectItem>
637
+ <SelectItem value="inactive">Inactive</SelectItem>
638
+ <SelectItem value="suspended">Suspended</SelectItem>
639
+ </SelectContent>
640
+ </Select>
641
+ </div>
642
+ </div>
643
+ <DialogFooter>
644
+ <Button variant="outline" onClick={() => setEditDialogOpen(false)} disabled={actionLoading}>
645
+ Cancel
646
+ </Button>
647
+ <Button onClick={handleSaveEdit} disabled={actionLoading}>
648
+ {actionLoading ? (
649
+ <>
650
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
651
+ Saving...
652
+ </>
653
+ ) : (
654
+ "Save Changes"
655
+ )}
656
+ </Button>
657
+ </DialogFooter>
658
+ </DialogContent>
659
+ </Dialog>
660
+
661
+ {/* Delete User Dialog */}
662
+ <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
663
+ <DialogContent>
664
+ <DialogHeader>
665
+ <DialogTitle>Delete User</DialogTitle>
666
+ <DialogDescription>
667
+ Are you sure you want to permanently delete this user? This action cannot be undone.
668
+ </DialogDescription>
669
+ </DialogHeader>
670
+ {selectedUser && (
671
+ <div className="py-4">
672
+ <div className="p-4 bg-muted rounded-lg space-y-2">
673
+ <p className="text-sm">
674
+ <span className="font-medium">Email:</span> {selectedUser.email}
675
+ </p>
676
+ <p className="text-sm">
677
+ <span className="font-medium">Name:</span> {selectedUser.name || "—"}
678
+ </p>
679
+ <p className="text-sm">
680
+ <span className="font-medium">Status:</span> {selectedUser.status}
681
+ </p>
682
+ </div>
683
+ <p className="text-sm text-red-600 mt-4">
684
+ ⚠️ All user data, sessions, and associated records will be permanently deleted.
685
+ </p>
686
+ </div>
687
+ )}
688
+ <DialogFooter>
689
+ <Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={actionLoading}>
690
+ Cancel
691
+ </Button>
692
+ <Button variant="destructive" onClick={handleDeleteUser} disabled={actionLoading}>
693
+ {actionLoading ? (
694
+ <>
695
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
696
+ Deleting...
697
+ </>
698
+ ) : (
699
+ <>
700
+ <Trash2 className="w-4 h-4 mr-2" />
701
+ Delete User
702
+ </>
703
+ )}
704
+ </Button>
705
+ </DialogFooter>
706
+ </DialogContent>
707
+ </Dialog>
708
+ </div>
709
+ )
710
+ }
711
+
src/app/api/admin/agent-metrics/route.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server"
2
+
3
+ const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
4
+
5
+ export async function GET(request: NextRequest) {
6
+ try {
7
+ const sessionToken = request.headers.get("x-session-token")
8
+
9
+ const headers: Record<string, string> = {
10
+ "Content-Type": "application/json",
11
+ }
12
+
13
+ if (sessionToken) {
14
+ headers["X-Session-Token"] = sessionToken
15
+ }
16
+
17
+ const response = await fetch(`${API_BASE_URL}/api/admin/agent-metrics`, {
18
+ headers,
19
+ })
20
+
21
+ if (!response.ok) {
22
+ return NextResponse.json(
23
+ { error: "Failed to fetch agent metrics" },
24
+ { status: response.status }
25
+ )
26
+ }
27
+
28
+ const data = await response.json()
29
+ return NextResponse.json(data)
30
+ } catch (error) {
31
+ console.error("Error fetching agent metrics:", error)
32
+ return NextResponse.json(
33
+ { error: "Internal server error" },
34
+ { status: 500 }
35
+ )
36
+ }
37
+ }
src/app/api/admin/query-logs/route.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server"
2
+
3
+ const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
4
+
5
+ export async function GET(request: NextRequest) {
6
+ try {
7
+ const sessionToken = request.headers.get("x-session-token")
8
+ const searchParams = request.nextUrl.searchParams
9
+ const limit = searchParams.get("limit") || "100"
10
+ const offset = searchParams.get("offset") || "0"
11
+
12
+ const headers: Record<string, string> = {
13
+ "Content-Type": "application/json",
14
+ }
15
+
16
+ if (sessionToken) {
17
+ headers["X-Session-Token"] = sessionToken
18
+ }
19
+
20
+ const response = await fetch(
21
+ `${API_BASE_URL}/api/admin/query-logs?limit=${limit}&offset=${offset}`,
22
+ { headers }
23
+ )
24
+
25
+ if (!response.ok) {
26
+ return NextResponse.json(
27
+ { error: "Failed to fetch query logs" },
28
+ { status: response.status }
29
+ )
30
+ }
31
+
32
+ const data = await response.json()
33
+ return NextResponse.json(data)
34
+ } catch (error) {
35
+ console.error("Error fetching query logs:", error)
36
+ return NextResponse.json(
37
+ { error: "Internal server error" },
38
+ { status: 500 }
39
+ )
40
+ }
41
+ }
src/app/api/admin/retrain/route.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server"
2
+
3
+ const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
4
+
5
+ export async function POST(request: NextRequest) {
6
+ try {
7
+ const sessionToken = request.headers.get("x-session-token")
8
+
9
+ const headers: Record<string, string> = {
10
+ "Content-Type": "application/json",
11
+ }
12
+
13
+ if (sessionToken) {
14
+ headers["X-Session-Token"] = sessionToken
15
+ }
16
+
17
+ const response = await fetch(`${API_BASE_URL}/api/admin/retrain`, {
18
+ method: "POST",
19
+ headers,
20
+ })
21
+
22
+ if (!response.ok) {
23
+ return NextResponse.json(
24
+ { error: "Failed to initiate retraining" },
25
+ { status: response.status }
26
+ )
27
+ }
28
+
29
+ const data = await response.json()
30
+ return NextResponse.json(data)
31
+ } catch (error) {
32
+ console.error("Error initiating retrain:", error)
33
+ return NextResponse.json(
34
+ { error: "Internal server error" },
35
+ { status: 500 }
36
+ )
37
+ }
38
+ }
src/app/api/admin/review-queue/[id]/approve/route.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server"
2
+
3
+ const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
4
+
5
+ export async function POST(
6
+ request: NextRequest,
7
+ { params }: { params: Promise<{ id: string }> }
8
+ ) {
9
+ const { id } = await params
10
+ try {
11
+ const sessionToken = request.headers.get("x-session-token")
12
+
13
+ const headers: Record<string, string> = {
14
+ "Content-Type": "application/json",
15
+ }
16
+
17
+ if (sessionToken) {
18
+ headers["X-Session-Token"] = sessionToken
19
+ }
20
+
21
+ const response = await fetch(
22
+ `${API_BASE_URL}/api/admin/review-queue/${id}/approve`,
23
+ {
24
+ method: "POST",
25
+ headers,
26
+ }
27
+ )
28
+
29
+ if (!response.ok) {
30
+ return NextResponse.json(
31
+ { error: "Failed to approve review" },
32
+ { status: response.status }
33
+ )
34
+ }
35
+
36
+ const data = await response.json()
37
+ return NextResponse.json(data)
38
+ } catch (error) {
39
+ console.error("Error approving review:", error)
40
+ return NextResponse.json(
41
+ { error: "Internal server error" },
42
+ { status: 500 }
43
+ )
44
+ }
45
+ }
src/app/api/admin/review-queue/[id]/reject/route.ts ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server"
2
+
3
+ const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
4
+
5
+ export async function POST(
6
+ request: NextRequest,
7
+ { params }: { params: Promise<{ id: string }> }
8
+ ) {
9
+ const { id } = await params
10
+ try {
11
+ const sessionToken = request.headers.get("x-session-token")
12
+ const body = await request.json()
13
+
14
+ const headers: Record<string, string> = {
15
+ "Content-Type": "application/json",
16
+ }
17
+
18
+ if (sessionToken) {
19
+ headers["X-Session-Token"] = sessionToken
20
+ }
21
+
22
+ const response = await fetch(
23
+ `${API_BASE_URL}/api/admin/review-queue/${id}/reject`,
24
+ {
25
+ method: "POST",
26
+ headers,
27
+ body: JSON.stringify(body),
28
+ }
29
+ )
30
+
31
+ if (!response.ok) {
32
+ return NextResponse.json(
33
+ { error: "Failed to reject review" },
34
+ { status: response.status }
35
+ )
36
+ }
37
+
38
+ const data = await response.json()
39
+ return NextResponse.json(data)
40
+ } catch (error) {
41
+ console.error("Error rejecting review:", error)
42
+ return NextResponse.json(
43
+ { error: "Internal server error" },
44
+ { status: 500 }
45
+ )
46
+ }
47
+ }
src/app/api/admin/review-queue/route.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server"
2
+
3
+ const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
4
+
5
+ export async function GET(request: NextRequest) {
6
+ try {
7
+ const sessionToken = request.headers.get("x-session-token")
8
+
9
+ const headers: Record<string, string> = {
10
+ "Content-Type": "application/json",
11
+ }
12
+
13
+ if (sessionToken) {
14
+ headers["X-Session-Token"] = sessionToken
15
+ }
16
+
17
+ const response = await fetch(`${API_BASE_URL}/api/admin/review-queue`, {
18
+ headers,
19
+ })
20
+
21
+ if (!response.ok) {
22
+ return NextResponse.json(
23
+ { error: "Failed to fetch review queue" },
24
+ { status: response.status }
25
+ )
26
+ }
27
+
28
+ const data = await response.json()
29
+ return NextResponse.json(data)
30
+ } catch (error) {
31
+ console.error("Error fetching review queue:", error)
32
+ return NextResponse.json(
33
+ { error: "Internal server error" },
34
+ { status: 500 }
35
+ )
36
+ }
37
+ }
src/app/api/cache/admin/analytics/route.ts ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server"
2
+ import { getCached, setCached, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
3
+
4
+ export async function GET(request: NextRequest) {
5
+ try {
6
+ const cacheKey = CACHE_KEYS.ADMIN_ANALYTICS
7
+
8
+ // Try to get from cache first
9
+ const cached = await getCached(cacheKey)
10
+ if (cached) {
11
+ return NextResponse.json(cached, {
12
+ headers: { "X-Cache": "HIT" },
13
+ })
14
+ }
15
+
16
+ // Fetch from backend
17
+ const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
18
+ const userToken = request.headers.get("X-Session-Token") || null
19
+ const headers: Record<string, string> = {}
20
+ if (userToken) {
21
+ headers["X-Session-Token"] = userToken
22
+ }
23
+
24
+ const response = await fetch(`${apiBaseUrl}/api/v1/auth/analytics/dashboard`, { headers })
25
+
26
+ if (!response.ok) {
27
+ return NextResponse.json(
28
+ { error: "Failed to fetch analytics" },
29
+ { status: response.status }
30
+ )
31
+ }
32
+
33
+ const analytics = await response.json()
34
+
35
+ // Cache the result
36
+ await setCached(cacheKey, analytics, CACHE_TTL.ADMIN_ANALYTICS)
37
+
38
+ return NextResponse.json(analytics, {
39
+ headers: { "X-Cache": "MISS" },
40
+ })
41
+ } catch (error: any) {
42
+ console.error("Analytics cache error:", error)
43
+ return NextResponse.json(
44
+ { error: error.message || "Internal server error" },
45
+ { status: 500 }
46
+ )
47
+ }
48
+ }
src/app/api/cache/admin/config/route.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server"
2
+ import { getCached, setCached, deleteCacheKey, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
3
+
4
+ export async function GET(request: NextRequest) {
5
+ try {
6
+ const cacheKey = CACHE_KEYS.ADMIN_CONFIG
7
+
8
+ // Try to get from cache first
9
+ const cached = await getCached(cacheKey)
10
+ if (cached) {
11
+ return NextResponse.json(cached, {
12
+ headers: { "X-Cache": "HIT" },
13
+ })
14
+ }
15
+
16
+ // Fetch from backend
17
+ const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
18
+ const userToken = request.headers.get("X-Session-Token") || null
19
+ const headers: Record<string, string> = {}
20
+ if (userToken) {
21
+ headers["X-Session-Token"] = userToken
22
+ }
23
+
24
+ const response = await fetch(`${apiBaseUrl}/api/admin/config`, { headers })
25
+
26
+ if (!response.ok) {
27
+ return NextResponse.json(
28
+ { error: "Failed to fetch config" },
29
+ { status: response.status }
30
+ )
31
+ }
32
+
33
+ const config = await response.json()
34
+
35
+ // Cache the result
36
+ await setCached(cacheKey, config, CACHE_TTL.ADMIN_CONFIG)
37
+
38
+ return NextResponse.json(config, {
39
+ headers: { "X-Cache": "MISS" },
40
+ })
41
+ } catch (error: any) {
42
+ console.error("Config cache error:", error)
43
+ return NextResponse.json(
44
+ { error: error.message || "Internal server error" },
45
+ { status: 500 }
46
+ )
47
+ }
48
+ }
49
+
50
+ // DELETE: Invalidate config cache
51
+ export async function DELETE() {
52
+ try {
53
+ const cacheKey = CACHE_KEYS.ADMIN_CONFIG
54
+ await deleteCacheKey(cacheKey)
55
+ return NextResponse.json({ success: true })
56
+ } catch (error: any) {
57
+ console.error("Delete config cache error:", error)
58
+ return NextResponse.json(
59
+ { error: error.message || "Internal server error" },
60
+ { status: 500 }
61
+ )
62
+ }
63
+ }
src/app/api/cache/admin/database-storage/route.ts ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server"
2
+ import { getCached, setCached, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
3
+
4
+ export async function GET(request: NextRequest) {
5
+ try {
6
+ const cacheKey = CACHE_KEYS.ADMIN_DATABASE_STORAGE
7
+
8
+ // Try to get from cache first
9
+ const cached = await getCached(cacheKey)
10
+ if (cached) {
11
+ return NextResponse.json(cached, {
12
+ headers: { "X-Cache": "HIT" },
13
+ })
14
+ }
15
+
16
+ // Fetch from backend
17
+ const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
18
+ const userToken = request.headers.get("X-Session-Token") || null
19
+ const headers: Record<string, string> = {}
20
+ if (userToken) {
21
+ headers["X-Session-Token"] = userToken
22
+ }
23
+
24
+ const response = await fetch(`${apiBaseUrl}/api/admin/database-storage`, { headers })
25
+
26
+ if (!response.ok) {
27
+ return NextResponse.json(
28
+ { error: "Failed to fetch database storage" },
29
+ { status: response.status }
30
+ )
31
+ }
32
+
33
+ const storage = await response.json()
34
+
35
+ // Cache the result
36
+ await setCached(cacheKey, storage, CACHE_TTL.ADMIN_DATABASE_STORAGE)
37
+
38
+ return NextResponse.json(storage, {
39
+ headers: { "X-Cache": "MISS" },
40
+ })
41
+ } catch (error: any) {
42
+ console.error("Database storage cache error:", error)
43
+ return NextResponse.json(
44
+ { error: error.message || "Internal server error" },
45
+ { status: 500 }
46
+ )
47
+ }
48
+ }
src/app/api/cache/admin/databases/route.ts ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server"
2
+ import { getCached, setCached, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
3
+
4
+ export async function GET(request: NextRequest) {
5
+ try {
6
+ const cacheKey = CACHE_KEYS.ADMIN_DATABASES
7
+
8
+ // Try to get from cache first
9
+ const cached = await getCached(cacheKey)
10
+ if (cached) {
11
+ return NextResponse.json(cached, {
12
+ headers: { "X-Cache": "HIT" },
13
+ })
14
+ }
15
+
16
+ // Fetch from backend
17
+ const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
18
+ const userToken = request.headers.get("X-Session-Token") || null
19
+ const headers: Record<string, string> = {}
20
+ if (userToken) {
21
+ headers["X-Session-Token"] = userToken
22
+ }
23
+
24
+ const response = await fetch(`${apiBaseUrl}/api/admin/databases`, { headers })
25
+
26
+ if (!response.ok) {
27
+ return NextResponse.json(
28
+ { error: "Failed to fetch databases" },
29
+ { status: response.status }
30
+ )
31
+ }
32
+
33
+ const databases = await response.json()
34
+
35
+ // Cache the result
36
+ await setCached(cacheKey, databases, CACHE_TTL.ADMIN_DATABASES)
37
+
38
+ return NextResponse.json(databases, {
39
+ headers: { "X-Cache": "MISS" },
40
+ })
41
+ } catch (error: any) {
42
+ console.error("Databases cache error:", error)
43
+ return NextResponse.json(
44
+ { error: error.message || "Internal server error" },
45
+ { status: 500 }
46
+ )
47
+ }
48
+ }
src/app/api/cache/admin/documents/route.ts ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server"
2
+ import { getCached, setCached, CACHE_KEYS, CACHE_TTL, hashQueryString } from "@/lib/redis"
3
+
4
+ export async function GET(request: NextRequest) {
5
+ try {
6
+ // Get query string and hash it for cache key
7
+ const queryString = request.nextUrl.search || ""
8
+ const queryHash = hashQueryString(queryString)
9
+ const cacheKey = CACHE_KEYS.ADMIN_DOCUMENTS(queryHash)
10
+
11
+ // Try to get from cache first
12
+ const cached = await getCached(cacheKey)
13
+ if (cached) {
14
+ return NextResponse.json(cached, {
15
+ headers: { "X-Cache": "HIT" },
16
+ })
17
+ }
18
+
19
+ // Fetch from backend
20
+ const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
21
+ const userToken = request.headers.get("X-Session-Token") || null
22
+ const headers: Record<string, string> = {}
23
+ if (userToken) {
24
+ headers["X-Session-Token"] = userToken
25
+ }
26
+
27
+ const response = await fetch(`${apiBaseUrl}/api/admin/documents${queryString}`, { headers })
28
+
29
+ if (!response.ok) {
30
+ return NextResponse.json(
31
+ { error: "Failed to fetch documents" },
32
+ { status: response.status }
33
+ )
34
+ }
35
+
36
+ const documents = await response.json()
37
+
38
+ // Cache the result
39
+ await setCached(cacheKey, documents, CACHE_TTL.ADMIN_DOCUMENTS)
40
+
41
+ return NextResponse.json(documents, {
42
+ headers: { "X-Cache": "MISS" },
43
+ })
44
+ } catch (error: any) {
45
+ console.error("Documents cache error:", error)
46
+ return NextResponse.json(
47
+ { error: error.message || "Internal server error" },
48
+ { status: 500 }
49
+ )
50
+ }
51
+ }
src/app/api/cache/admin/stats/route.ts ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server"
2
+ import { getCached, setCached, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
3
+
4
+ export async function GET(request: NextRequest) {
5
+ try {
6
+ const cacheKey = CACHE_KEYS.ADMIN_STATS
7
+
8
+ // Try to get from cache first
9
+ const cached = await getCached(cacheKey)
10
+ if (cached) {
11
+ return NextResponse.json(cached, {
12
+ headers: { "X-Cache": "HIT" },
13
+ })
14
+ }
15
+
16
+ // Fetch from backend
17
+ const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
18
+ const userToken = request.headers.get("X-Session-Token") || null
19
+ const headers: Record<string, string> = {}
20
+ if (userToken) {
21
+ headers["X-Session-Token"] = userToken
22
+ }
23
+
24
+ const response = await fetch(`${apiBaseUrl}/stats`, { headers })
25
+
26
+ if (!response.ok) {
27
+ return NextResponse.json(
28
+ { error: "Failed to fetch stats" },
29
+ { status: response.status }
30
+ )
31
+ }
32
+
33
+ const stats = await response.json()
34
+
35
+ // Cache the result
36
+ await setCached(cacheKey, stats, CACHE_TTL.ADMIN_STATS)
37
+
38
+ return NextResponse.json(stats, {
39
+ headers: { "X-Cache": "MISS" },
40
+ })
41
+ } catch (error: any) {
42
+ console.error("Stats cache error:", error)
43
+ return NextResponse.json(
44
+ { error: error.message || "Internal server error" },
45
+ { status: 500 }
46
+ )
47
+ }
48
+ }
src/app/api/cache/admin/users/route.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server"
2
+ import { getCached, setCached, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
3
+
4
+ export async function GET(request: NextRequest) {
5
+ try {
6
+ // Get page from query params
7
+ const searchParams = request.nextUrl.searchParams
8
+ const page = parseInt(searchParams.get("page") || "1")
9
+ const pageSize = searchParams.get("page_size") || "20"
10
+
11
+ const cacheKey = CACHE_KEYS.ADMIN_USERS(page)
12
+
13
+ // Try to get from cache first
14
+ const cached = await getCached(cacheKey)
15
+ if (cached) {
16
+ return NextResponse.json(cached, {
17
+ headers: { "X-Cache": "HIT" },
18
+ })
19
+ }
20
+
21
+ // Fetch from backend
22
+ const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
23
+ const userToken = request.headers.get("X-Session-Token") || null
24
+ const headers: Record<string, string> = {}
25
+ if (userToken) {
26
+ headers["X-Session-Token"] = userToken
27
+ }
28
+
29
+ const response = await fetch(
30
+ `${apiBaseUrl}/api/v1/auth/admin/users?page=${page}&page_size=${pageSize}`,
31
+ { headers }
32
+ )
33
+
34
+ if (!response.ok) {
35
+ return NextResponse.json(
36
+ { error: "Failed to fetch users" },
37
+ { status: response.status }
38
+ )
39
+ }
40
+
41
+ const users = await response.json()
42
+
43
+ // Cache the result
44
+ await setCached(cacheKey, users, CACHE_TTL.ADMIN_USERS)
45
+
46
+ return NextResponse.json(users, {
47
+ headers: { "X-Cache": "MISS" },
48
+ })
49
+ } catch (error: any) {
50
+ console.error("Users cache error:", error)
51
+ return NextResponse.json(
52
+ { error: error.message || "Internal server error" },
53
+ { status: 500 }
54
+ )
55
+ }
56
+ }
src/app/api/cache/sessions/[sessionId]/messages/route.ts ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server"
2
+ import { getCached, setCached, deleteCacheKey, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
3
+
4
+ export async function GET(
5
+ request: NextRequest,
6
+ { params }: { params: Promise<{ sessionId: string }> }
7
+ ) {
8
+ try {
9
+ const { sessionId } = await params
10
+ const cacheKey = CACHE_KEYS.SESSION_MESSAGES(sessionId)
11
+
12
+ // Try to get from cache first
13
+ const cached = await getCached(cacheKey)
14
+ if (cached) {
15
+ return NextResponse.json(cached, {
16
+ headers: { "X-Cache": "HIT" },
17
+ })
18
+ }
19
+
20
+ // Fetch from backend
21
+ const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
22
+ const userToken = request.headers.get("X-Session-Token") || null
23
+ const headers: Record<string, string> = {
24
+ "Content-Type": "application/json",
25
+ }
26
+ if (userToken) {
27
+ headers["X-Session-Token"] = userToken
28
+ }
29
+
30
+ const response = await fetch(
31
+ `${apiBaseUrl}/api/v1/chat/sessions/${sessionId}/messages`,
32
+ { headers }
33
+ )
34
+
35
+ if (!response.ok) {
36
+ return NextResponse.json(
37
+ { error: "Failed to fetch messages" },
38
+ { status: response.status }
39
+ )
40
+ }
41
+
42
+ const messages = await response.json()
43
+
44
+ // Cache the result
45
+ await setCached(cacheKey, messages, CACHE_TTL.SESSION_MESSAGES)
46
+
47
+ return NextResponse.json(messages, {
48
+ headers: { "X-Cache": "MISS" },
49
+ })
50
+ } catch (error: unknown) {
51
+ console.error("Messages cache error:", error)
52
+ const errorMessage = typeof error === "object" && error !== null && "message" in error ? (error as { message?: string }).message : "Internal server error";
53
+ return NextResponse.json(
54
+ { error: errorMessage || "Internal server error" },
55
+ { status: 500 }
56
+ )
57
+ }
58
+ }
59
+
60
+ // DELETE: Invalidate messages cache for a specific session
61
+ export async function DELETE(
62
+ request: NextRequest,
63
+ { params }: { params: Promise<{ sessionId: string }> }
64
+ ) {
65
+ try {
66
+ const { sessionId } = await params
67
+ const cacheKey = CACHE_KEYS.SESSION_MESSAGES(sessionId)
68
+ await deleteCacheKey(cacheKey)
69
+
70
+ return NextResponse.json({ success: true })
71
+ } catch (error: unknown) {
72
+ console.error("Delete messages cache error:", error)
73
+ const errorMessage = typeof error === "object" && error !== null && "message" in error ? (error as { message?: string }).message : "Internal server error";
74
+ return NextResponse.json(
75
+ { error: errorMessage || "Internal server error" },
76
+ { status: 500 }
77
+ )
78
+ }
79
+ }
src/app/api/cache/sessions/route.ts ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server"
2
+ import { getCached, setCached, CACHE_KEYS, CACHE_TTL, getUserTokenHash } from "@/lib/redis"
3
+
4
+ export async function GET(request: NextRequest) {
5
+ try {
6
+ const userToken = request.headers.get("X-Session-Token") || null
7
+ const tokenHash = getUserTokenHash(userToken)
8
+ const cacheKey = CACHE_KEYS.SESSIONS(tokenHash)
9
+
10
+ // Try to get from cache first
11
+ const cached = await getCached(cacheKey)
12
+ if (cached) {
13
+ return NextResponse.json(cached, {
14
+ headers: { "X-Cache": "HIT" },
15
+ })
16
+ }
17
+
18
+ // Fetch from backend
19
+ const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
20
+ const headers: Record<string, string> = {
21
+ "Content-Type": "application/json",
22
+ }
23
+ if (userToken) {
24
+ headers["X-Session-Token"] = userToken
25
+ }
26
+
27
+ const response = await fetch(`${apiBaseUrl}/api/v1/chat/sessions`, {
28
+ headers,
29
+ })
30
+
31
+ if (!response.ok) {
32
+ return NextResponse.json(
33
+ { error: "Failed to fetch sessions" },
34
+ { status: response.status }
35
+ )
36
+ }
37
+
38
+ const sessions = await response.json()
39
+
40
+ // Cache the result
41
+ await setCached(cacheKey, sessions, CACHE_TTL.SESSIONS)
42
+
43
+ return NextResponse.json(sessions, {
44
+ headers: { "X-Cache": "MISS" },
45
+ })
46
+ } catch (error: any) {
47
+ console.error("Sessions cache error:", error)
48
+ return NextResponse.json(
49
+ { error: error.message || "Internal server error" },
50
+ { status: 500 }
51
+ )
52
+ }
53
+ }
54
+
55
+ // DELETE: Invalidate sessions cache
56
+ export async function DELETE(request: NextRequest) {
57
+ try {
58
+ const userToken = request.headers.get("X-Session-Token") || null
59
+ const tokenHash = getUserTokenHash(userToken)
60
+ const cacheKey = CACHE_KEYS.SESSIONS(tokenHash)
61
+
62
+ const { deleteCacheKey } = await import("@/lib/redis")
63
+ await deleteCacheKey(cacheKey)
64
+
65
+ return NextResponse.json({ success: true })
66
+ } catch (error: any) {
67
+ console.error("Delete sessions cache error:", error)
68
+ return NextResponse.json(
69
+ { error: error.message || "Internal server error" },
70
+ { status: 500 }
71
+ )
72
+ }
73
+ }
src/app/api/livekit-token/route.ts ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server"
2
+ import { AccessToken } from "livekit-server-sdk"
3
+
4
+ export async function POST(request: NextRequest) {
5
+ try {
6
+ const { roomName, participantName } = await request.json()
7
+
8
+ if (!roomName) {
9
+ return NextResponse.json(
10
+ { error: "roomName is required" },
11
+ { status: 400 }
12
+ )
13
+ }
14
+
15
+ // Use server-side environment variables only (not NEXT_PUBLIC_* which are exposed to client)
16
+ // These must match the credentials used by your LiveKit server
17
+ const apiKey = process.env.LIVEKIT_API_KEY
18
+ const apiSecret = process.env.LIVEKIT_API_SECRET
19
+
20
+ if (!apiKey) {
21
+ return NextResponse.json(
22
+ {
23
+ error: "LIVEKIT_API_KEY not configured",
24
+ hint: "Set LIVEKIT_API_KEY environment variable (server-side, not NEXT_PUBLIC_*). This must match the API key configured in your LiveKit server. Get it from LiveKit Cloud dashboard (Settings > Keys) or your self-hosted LiveKit config."
25
+ },
26
+ { status: 500 }
27
+ )
28
+ }
29
+
30
+ if (!apiSecret) {
31
+ return NextResponse.json(
32
+ {
33
+ error: "LIVEKIT_API_SECRET not configured",
34
+ hint: "Set LIVEKIT_API_SECRET environment variable (server-side). This must match the API secret configured in your LiveKit server.",
35
+ instructions: [
36
+ "For LiveKit Cloud:",
37
+ " 1. Go to Settings > Keys in your LiveKit Cloud dashboard",
38
+ " 2. Click on your API key row or the menu (three dots) to reveal the secret",
39
+ " 3. If the secret is not visible, you may need to create a NEW API key",
40
+ " 4. When creating a new key, copy BOTH the API key AND the secret immediately",
41
+ " 5. Note: Secrets are only shown once when created - save it securely!",
42
+ "",
43
+ "For self-hosted LiveKit:",
44
+ " - Check your LiveKit server configuration file (usually livekit.yaml)",
45
+ " - Or use the default 'secret' for development mode"
46
+ ]
47
+ },
48
+ { status: 500 }
49
+ )
50
+ }
51
+
52
+ // Validate API secret format - it should NOT be a JWT token
53
+ const trimmedSecret = apiSecret.trim()
54
+ if (trimmedSecret.startsWith('eyJ')) {
55
+ console.error(`[LiveKit Token] ❌ ERROR: API secret appears to be a JWT token, not a secret key!`)
56
+ console.error(`[LiveKit Token] The secret starts with 'eyJ' which indicates it's a JWT token.`)
57
+ console.error(`[LiveKit Token] LiveKit API secret should be a random string, not a token.`)
58
+ return NextResponse.json(
59
+ {
60
+ error: "Invalid API secret format",
61
+ hint: "Your LIVEKIT_API_SECRET appears to be a JWT token (starts with 'eyJ'), but it should be the actual secret key. In LiveKit Cloud: Go to Settings > Keys and copy the 'API Secret' (not a token). It should be a long random string, not a JWT token.",
62
+ details: "The API secret is used to SIGN tokens, not a token itself. Make sure you're using the secret key from your LiveKit dashboard, not a generated token."
63
+ },
64
+ { status: 500 }
65
+ )
66
+ }
67
+
68
+ // Log API key prefix for debugging (without exposing full key or secret)
69
+ console.log(`[LiveKit Token] Using API key: ${apiKey.substring(0, 8)}...`)
70
+ console.log(`[LiveKit Token] API secret length: ${trimmedSecret.length} characters`)
71
+ console.log(`[LiveKit Token] API secret starts with: ${trimmedSecret.substring(0, 4)}...`)
72
+
73
+ // Warn if secret looks suspicious
74
+ if (trimmedSecret.length < 20) {
75
+ console.warn(`[LiveKit Token] ⚠️ WARNING: API secret is very short (${trimmedSecret.length} chars). LiveKit secrets are usually much longer.`)
76
+ }
77
+
78
+ const at = new AccessToken(apiKey, trimmedSecret, {
79
+ identity: participantName || "user",
80
+ })
81
+
82
+ at.addGrant({
83
+ room: roomName,
84
+ roomJoin: true,
85
+ canPublish: true,
86
+ canSubscribe: true,
87
+ })
88
+
89
+ const token = await at.toJwt()
90
+
91
+ // Decode token to verify it was created correctly (for debugging)
92
+ const tokenParts = token.split('.')
93
+ if (tokenParts.length === 3) {
94
+ try {
95
+ const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64').toString())
96
+ console.log(`[LiveKit Token] Generated token details:`)
97
+ console.log(` - Room: ${payload.video?.room}`)
98
+ console.log(` - Identity: ${payload.sub}`)
99
+ console.log(` - Issuer (API Key): ${payload.iss}`)
100
+ console.log(` - Expires: ${new Date(payload.exp * 1000).toISOString()}`)
101
+ console.log(`[LiveKit Token] ⚠️ If token validation fails, verify:`)
102
+ console.log(` 1. API Key in token (${payload.iss}) matches your LiveKit server's API key`)
103
+ console.log(` 2. API Secret used to sign token matches your LiveKit server's API secret`)
104
+ console.log(` 3. Both are set in your Next.js environment variables (not NEXT_PUBLIC_*)`)
105
+ } catch (e) {
106
+ // Ignore decode errors
107
+ console.error(`[LiveKit Token] Failed to decode token payload:`, e)
108
+ }
109
+ }
110
+
111
+ return NextResponse.json({ token })
112
+ } catch (error) {
113
+ console.error("[LiveKit Token] Error generating token:", error)
114
+ const errorMessage = error instanceof Error ? error.message : "Failed to generate token"
115
+
116
+ // Provide detailed troubleshooting information
117
+ const troubleshooting = {
118
+ error: errorMessage,
119
+ hint: "Token signature verification failed. This means the API secret doesn't match your LiveKit server.",
120
+ steps: [
121
+ "1. Verify LIVEKIT_API_KEY matches your LiveKit server's API key",
122
+ "2. Verify LIVEKIT_API_SECRET matches your LiveKit server's API secret (exactly, including any spaces)",
123
+ "3. For LiveKit Cloud: Get credentials from Settings > Keys in your dashboard",
124
+ "4. For self-hosted: Check your LiveKit server configuration file",
125
+ "5. Ensure environment variables are set in .env.local (not .env) for Next.js",
126
+ "6. Restart your Next.js dev server after changing environment variables"
127
+ ]
128
+ }
129
+
130
+ return NextResponse.json(troubleshooting, { status: 500 })
131
+ }
132
+ }
133
+
src/app/auth/forgot-password/page.tsx ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { useRouter } from "next/navigation"
5
+ import Link from "next/link"
6
+ import { apiClient } from "@/lib/api-client"
7
+ import { Button } from "@/components/ui/button"
8
+ import { Input } from "@/components/ui/input"
9
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
10
+ import { OTPInput } from "@/components/ui/otp-input"
11
+ import { toast } from "sonner"
12
+ import { Loader2 } from "lucide-react"
13
+
14
+ export default function ForgotPasswordPage() {
15
+ const [step, setStep] = useState<"email" | "otp" | "reset">("email")
16
+ const [loading, setLoading] = useState(false)
17
+ const [email, setEmail] = useState("")
18
+ const [phoneNumber, setPhoneNumber] = useState("")
19
+ const [maskedPhoneNumber, setMaskedPhoneNumber] = useState<string | null>(null)
20
+ const [otp, setOtp] = useState("")
21
+ const [resetToken, setResetToken] = useState("")
22
+ const [newPassword, setNewPassword] = useState("")
23
+ const router = useRouter()
24
+
25
+ const handleEmailSubmit = async (e: React.FormEvent) => {
26
+ e.preventDefault()
27
+ setLoading(true)
28
+
29
+ try {
30
+ // Request password reset (this will return masked phone number)
31
+ const response = await apiClient.post<{ message: string; phone_number?: string }>(
32
+ "/api/v1/auth/password/reset-request",
33
+ {
34
+ email: email,
35
+ }
36
+ )
37
+
38
+ if (response.phone_number) {
39
+ // Store masked phone number for display
40
+ setMaskedPhoneNumber(response.phone_number)
41
+ // Show masked phone number to user - they still need to enter full number for OTP verification
42
+ toast.success(`${response.message} (ending in ${response.phone_number.slice(-4)})`)
43
+ setStep("otp")
44
+ } else {
45
+ // No phone number found, ask user to enter it
46
+ setMaskedPhoneNumber(null)
47
+ toast.success("Please enter your phone number to receive OTP")
48
+ setStep("otp")
49
+ }
50
+ } catch (error: unknown) {
51
+ const errorMessage = error instanceof Error ? error.message : "Failed to request password reset"
52
+ toast.error(errorMessage)
53
+ } finally {
54
+ setLoading(false)
55
+ }
56
+ }
57
+
58
+ const handleOTPVerify = async () => {
59
+ if (otp.length !== 6) {
60
+ toast.error("Please enter a valid 6-digit OTP")
61
+ return
62
+ }
63
+
64
+ setLoading(true)
65
+
66
+ try {
67
+ // Verify OTP and get password reset token
68
+ const response = await apiClient.post<{ status: string; message: string; reset_token?: string }>(
69
+ "/api/v1/auth/phone/verify-otp",
70
+ {
71
+ phone_number: phoneNumber,
72
+ otp: otp,
73
+ purpose: "password_reset",
74
+ }
75
+ )
76
+
77
+ if (response.reset_token) {
78
+ setResetToken(response.reset_token)
79
+ setStep("reset")
80
+ toast.success("OTP verified. Please set your new password.")
81
+ } else {
82
+ // Fallback: request reset token again if not returned
83
+ await apiClient.post("/api/v1/auth/password/reset-request", {
84
+ email: email,
85
+ })
86
+ // Note: In production, the token should always come from OTP verification
87
+ setResetToken("verified")
88
+ setStep("reset")
89
+ toast.success("OTP verified. Please set your new password.")
90
+ }
91
+ } catch (error: unknown) {
92
+ const errorMessage = error instanceof Error ? error.message : "OTP verification failed"
93
+ toast.error(errorMessage)
94
+ } finally {
95
+ setLoading(false)
96
+ }
97
+ }
98
+
99
+ const handlePasswordReset = async (e: React.FormEvent) => {
100
+ e.preventDefault()
101
+ setLoading(true)
102
+
103
+ try {
104
+ // Reset password with token and OTP verification
105
+ await apiClient.post("/api/v1/auth/password/reset", {
106
+ token: resetToken,
107
+ new_password: newPassword,
108
+ })
109
+
110
+ toast.success("Password reset successfully! Please sign in.")
111
+ router.push("/auth/signin")
112
+ } catch (error: unknown) {
113
+ const errorMessage = error instanceof Error ? error.message : "Password reset failed"
114
+ toast.error(errorMessage)
115
+ } finally {
116
+ setLoading(false)
117
+ }
118
+ }
119
+
120
+ const handleResendOTP = async () => {
121
+ setLoading(true)
122
+ try {
123
+ await apiClient.post("/api/v1/auth/phone/resend-otp", {
124
+ phone_number: phoneNumber,
125
+ purpose: "password_reset",
126
+ })
127
+ toast.success("OTP resent successfully")
128
+ } catch (error: unknown) {
129
+ const errorMessage = error instanceof Error ? error.message : "Failed to resend OTP"
130
+ toast.error(errorMessage)
131
+ } finally {
132
+ setLoading(false)
133
+ }
134
+ }
135
+
136
+ if (step === "otp") {
137
+ return (
138
+ <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/10 p-4">
139
+ <Card className="w-full max-w-md">
140
+ <CardHeader>
141
+ <CardTitle>Verify OTP</CardTitle>
142
+ <CardDescription>
143
+ {maskedPhoneNumber
144
+ ? `Enter the 6-digit code sent to ${maskedPhoneNumber}`
145
+ : phoneNumber
146
+ ? `Enter the 6-digit code sent to ${phoneNumber}`
147
+ : "Enter the 6-digit code sent to your phone number"}
148
+ </CardDescription>
149
+ </CardHeader>
150
+ <CardContent className="space-y-4">
151
+ {!maskedPhoneNumber && (
152
+ <div>
153
+ <label htmlFor="phone" className="text-sm font-medium">
154
+ Phone Number
155
+ </label>
156
+ <Input
157
+ id="phone"
158
+ type="tel"
159
+ placeholder="+254712345678 or 0712345678"
160
+ value={phoneNumber}
161
+ onChange={(e) => setPhoneNumber(e.target.value)}
162
+ required
163
+ className="mt-1"
164
+ />
165
+ <p className="text-xs text-muted-foreground mt-1">
166
+ Format: +254712345678, +2541XXXXXXX, 0712345678, or 01XXXXXXX
167
+ </p>
168
+ </div>
169
+ )}
170
+ {maskedPhoneNumber && !phoneNumber && (
171
+ <div>
172
+ <label htmlFor="phone" className="text-sm font-medium">
173
+ Confirm Phone Number
174
+ </label>
175
+ <Input
176
+ id="phone"
177
+ type="tel"
178
+ placeholder={`Enter full number ending in ${maskedPhoneNumber.slice(-4)}`}
179
+ value={phoneNumber}
180
+ onChange={(e) => setPhoneNumber(e.target.value)}
181
+ required
182
+ className="mt-1"
183
+ />
184
+ <p className="text-xs text-muted-foreground mt-1">
185
+ Enter your full phone number to verify
186
+ </p>
187
+ </div>
188
+ )}
189
+ <OTPInput
190
+ value={otp}
191
+ onChange={setOtp}
192
+ onComplete={handleOTPVerify}
193
+ disabled={loading}
194
+ />
195
+ <Button
196
+ onClick={handleOTPVerify}
197
+ disabled={loading || otp.length !== 6 || !phoneNumber}
198
+ className="w-full"
199
+ >
200
+ {loading ? (
201
+ <>
202
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
203
+ Verifying...
204
+ </>
205
+ ) : (
206
+ "Verify OTP"
207
+ )}
208
+ </Button>
209
+ <div className="text-center">
210
+ <button
211
+ onClick={handleResendOTP}
212
+ disabled={loading}
213
+ className="text-sm text-primary hover:underline"
214
+ >
215
+ Resend OTP
216
+ </button>
217
+ </div>
218
+ <div className="text-center text-sm">
219
+ <button
220
+ onClick={() => setStep("email")}
221
+ className="text-muted-foreground hover:underline"
222
+ >
223
+ Back
224
+ </button>
225
+ </div>
226
+ </CardContent>
227
+ </Card>
228
+ </div>
229
+ )
230
+ }
231
+
232
+ if (step === "reset") {
233
+ return (
234
+ <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/10 p-4">
235
+ <Card className="w-full max-w-md">
236
+ <CardHeader>
237
+ <CardTitle>Reset Password</CardTitle>
238
+ <CardDescription>Enter your new password</CardDescription>
239
+ </CardHeader>
240
+ <CardContent>
241
+ <form onSubmit={handlePasswordReset} className="space-y-4">
242
+ <div>
243
+ <label htmlFor="newPassword" className="text-sm font-medium">
244
+ New Password
245
+ </label>
246
+ <Input
247
+ id="newPassword"
248
+ type="password"
249
+ placeholder="••••••••"
250
+ value={newPassword}
251
+ onChange={(e) => setNewPassword(e.target.value)}
252
+ required
253
+ minLength={8}
254
+ />
255
+ <p className="text-xs text-muted-foreground mt-1">
256
+ Must be at least 8 characters with uppercase, lowercase, and digit
257
+ </p>
258
+ </div>
259
+ <Button type="submit" disabled={loading} className="w-full">
260
+ {loading ? (
261
+ <>
262
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
263
+ Resetting...
264
+ </>
265
+ ) : (
266
+ "Reset Password"
267
+ )}
268
+ </Button>
269
+ </form>
270
+ <div className="mt-4 text-center text-sm">
271
+ <Link href="/auth/signin" className="text-primary hover:underline">
272
+ Back to sign in
273
+ </Link>
274
+ </div>
275
+ </CardContent>
276
+ </Card>
277
+ </div>
278
+ )
279
+ }
280
+
281
+ return (
282
+ <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/10 p-4">
283
+ <Card className="w-full max-w-md">
284
+ <CardHeader>
285
+ <CardTitle>Forgot Password</CardTitle>
286
+ <CardDescription>
287
+ Enter your email and phone number to reset your password
288
+ </CardDescription>
289
+ </CardHeader>
290
+ <CardContent>
291
+ <form onSubmit={handleEmailSubmit} className="space-y-4">
292
+ <div>
293
+ <label htmlFor="email" className="text-sm font-medium">
294
+ Email
295
+ </label>
296
+ <Input
297
+ id="email"
298
+ type="email"
299
+ placeholder="you@example.com"
300
+ value={email}
301
+ onChange={(e) => setEmail(e.target.value)}
302
+ required
303
+ />
304
+ </div>
305
+ <div>
306
+ <label htmlFor="phone" className="text-sm font-medium">
307
+ Phone Number
308
+ </label>
309
+ <Input
310
+ id="phone"
311
+ type="tel"
312
+ placeholder="+254712345678 or 0712345678"
313
+ value={phoneNumber}
314
+ onChange={(e) => setPhoneNumber(e.target.value)}
315
+ required
316
+ />
317
+ <p className="text-xs text-muted-foreground mt-1">
318
+ Format: +254712345678, +2541XXXXXXX, 0712345678, or 01XXXXXXX
319
+ </p>
320
+ </div>
321
+ <Button type="submit" disabled={loading} className="w-full">
322
+ {loading ? (
323
+ <>
324
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
325
+ Sending OTP...
326
+ </>
327
+ ) : (
328
+ "Send OTP"
329
+ )}
330
+ </Button>
331
+ </form>
332
+ <div className="mt-4 text-center text-sm">
333
+ <Link href="/auth/signin" className="text-primary hover:underline">
334
+ Back to sign in
335
+ </Link>
336
+ </div>
337
+ </CardContent>
338
+ </Card>
339
+ </div>
340
+ )
341
+ }
342
+
src/app/auth/reset-password/page.tsx ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useState, useEffect, Suspense } from "react"
4
+ import { useRouter, useSearchParams } from "next/navigation"
5
+ import Link from "next/link"
6
+ import { apiClient } from "@/lib/api-client"
7
+ import { Button } from "@/components/ui/button"
8
+ import { Input } from "@/components/ui/input"
9
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
10
+ import { OTPInput } from "@/components/ui/otp-input"
11
+ import { toast } from "sonner"
12
+ import { Loader2 } from "lucide-react"
13
+
14
+ function ResetPasswordForm() {
15
+ const [step, setStep] = useState<"otp" | "reset">("otp")
16
+ const [loading, setLoading] = useState(false)
17
+ const [token, setToken] = useState("")
18
+ const [phoneNumber, setPhoneNumber] = useState("")
19
+ const [otp, setOtp] = useState("")
20
+ const [newPassword, setNewPassword] = useState("")
21
+ const router = useRouter()
22
+ const searchParams = useSearchParams()
23
+
24
+ useEffect(() => {
25
+ const tokenParam = searchParams.get("token")
26
+ if (tokenParam) {
27
+ setToken(tokenParam)
28
+ } else {
29
+ toast.error("Invalid reset link")
30
+ router.push("/auth/forgot-password")
31
+ }
32
+ }, [searchParams, router])
33
+
34
+ const handleOTPVerify = async () => {
35
+ if (otp.length !== 6) {
36
+ toast.error("Please enter a valid 6-digit OTP")
37
+ return
38
+ }
39
+
40
+ setLoading(true)
41
+
42
+ try {
43
+ // Verify OTP
44
+ await apiClient.post("/api/v1/auth/phone/verify-otp", {
45
+ phone_number: phoneNumber,
46
+ otp: otp,
47
+ purpose: "password_reset",
48
+ })
49
+
50
+ setStep("reset")
51
+ toast.success("OTP verified. Please set your new password.")
52
+ } catch (error: unknown) {
53
+ const errorMessage = error instanceof Error ? error.message : "OTP verification failed"
54
+ toast.error(errorMessage)
55
+ } finally {
56
+ setLoading(false)
57
+ }
58
+ }
59
+
60
+ const handlePasswordReset = async (e: React.FormEvent) => {
61
+ e.preventDefault()
62
+ setLoading(true)
63
+
64
+ try {
65
+ // Reset password with token
66
+ await apiClient.post("/api/v1/auth/password/reset", {
67
+ token: token,
68
+ new_password: newPassword,
69
+ })
70
+
71
+ toast.success("Password reset successfully! Please sign in.")
72
+ router.push("/auth/signin")
73
+ } catch (error: unknown) {
74
+ const errorMessage = error instanceof Error ? error.message : "Password reset failed"
75
+ toast.error(errorMessage)
76
+ } finally {
77
+ setLoading(false)
78
+ }
79
+ }
80
+
81
+ const handleResendOTP = async () => {
82
+ setLoading(true)
83
+ try {
84
+ await apiClient.post("/api/v1/auth/phone/resend-otp", {
85
+ phone_number: phoneNumber,
86
+ purpose: "password_reset",
87
+ })
88
+ toast.success("OTP resent successfully")
89
+ } catch (error: unknown) {
90
+ const errorMessage = error instanceof Error ? error.message : "Failed to resend OTP"
91
+ toast.error(errorMessage)
92
+ } finally {
93
+ setLoading(false)
94
+ }
95
+ }
96
+
97
+ if (step === "otp") {
98
+ return (
99
+ <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/10 p-4">
100
+ <Card className="w-full max-w-md">
101
+ <CardHeader>
102
+ <CardTitle>Verify OTP</CardTitle>
103
+ <CardDescription>
104
+ Enter your phone number and the OTP code sent to you
105
+ </CardDescription>
106
+ </CardHeader>
107
+ <CardContent className="space-y-4">
108
+ <div>
109
+ <label htmlFor="phone" className="text-sm font-medium">
110
+ Phone Number
111
+ </label>
112
+ <Input
113
+ id="phone"
114
+ type="tel"
115
+ placeholder="+254712345678 or 0712345678"
116
+ value={phoneNumber}
117
+ onChange={(e) => setPhoneNumber(e.target.value)}
118
+ required
119
+ />
120
+ <p className="text-xs text-muted-foreground mt-1">
121
+ Format: +254712345678, +2541XXXXXXX, 0712345678, or 01XXXXXXX
122
+ </p>
123
+ </div>
124
+ <OTPInput
125
+ value={otp}
126
+ onChange={setOtp}
127
+ onComplete={handleOTPVerify}
128
+ disabled={loading}
129
+ />
130
+ <Button
131
+ onClick={handleOTPVerify}
132
+ disabled={loading || otp.length !== 6 || !phoneNumber}
133
+ className="w-full"
134
+ >
135
+ {loading ? (
136
+ <>
137
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
138
+ Verifying...
139
+ </>
140
+ ) : (
141
+ "Verify OTP"
142
+ )}
143
+ </Button>
144
+ <div className="text-center">
145
+ <button
146
+ onClick={handleResendOTP}
147
+ disabled={loading || !phoneNumber}
148
+ className="text-sm text-primary hover:underline"
149
+ >
150
+ Resend OTP
151
+ </button>
152
+ </div>
153
+ </CardContent>
154
+ </Card>
155
+ </div>
156
+ )
157
+ }
158
+
159
+ return (
160
+ <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/10 p-4">
161
+ <Card className="w-full max-w-md">
162
+ <CardHeader>
163
+ <CardTitle>Reset Password</CardTitle>
164
+ <CardDescription>Enter your new password</CardDescription>
165
+ </CardHeader>
166
+ <CardContent>
167
+ <form onSubmit={handlePasswordReset} className="space-y-4">
168
+ <div>
169
+ <label htmlFor="newPassword" className="text-sm font-medium">
170
+ New Password
171
+ </label>
172
+ <Input
173
+ id="newPassword"
174
+ type="password"
175
+ placeholder="••••••••"
176
+ value={newPassword}
177
+ onChange={(e) => setNewPassword(e.target.value)}
178
+ required
179
+ minLength={8}
180
+ />
181
+ <p className="text-xs text-muted-foreground mt-1">
182
+ Must be at least 8 characters with uppercase, lowercase, and digit
183
+ </p>
184
+ </div>
185
+ <Button type="submit" disabled={loading} className="w-full">
186
+ {loading ? (
187
+ <>
188
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
189
+ Resetting...
190
+ </>
191
+ ) : (
192
+ "Reset Password"
193
+ )}
194
+ </Button>
195
+ </form>
196
+ <div className="mt-4 text-center text-sm">
197
+ <Link href="/auth/signin" className="text-primary hover:underline">
198
+ Back to sign in
199
+ </Link>
200
+ </div>
201
+ </CardContent>
202
+ </Card>
203
+ </div>
204
+ )
205
+ }
206
+
207
+ export default function ResetPasswordPage() {
208
+ return (
209
+ <Suspense fallback={
210
+ <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/10 p-4">
211
+ <Card className="w-full max-w-md">
212
+ <CardContent className="flex items-center justify-center p-6">
213
+ <Loader2 className="h-8 w-8 animate-spin" />
214
+ </CardContent>
215
+ </Card>
216
+ </div>
217
+ }>
218
+ <ResetPasswordForm />
219
+ </Suspense>
220
+ )
221
+ }
src/app/auth/signin/page.tsx ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { useRouter } from "next/navigation"
5
+ import Link from "next/link"
6
+ import { useAuth } from "@/lib/auth-context"
7
+ import { Button } from "@/components/ui/button"
8
+ import { Input } from "@/components/ui/input"
9
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
10
+ import { toast } from "sonner"
11
+ import { Loader2 } from "lucide-react"
12
+
13
+ export default function SignInPage() {
14
+ const [email, setEmail] = useState("")
15
+ const [password, setPassword] = useState("")
16
+ const [loading, setLoading] = useState(false)
17
+ const router = useRouter()
18
+ const { login } = useAuth()
19
+
20
+ const handleSubmit = async (e: React.FormEvent) => {
21
+ e.preventDefault()
22
+ setLoading(true)
23
+
24
+ try {
25
+ await login(email, password)
26
+ toast.success("Signed in successfully")
27
+ // Navigation handled by auth context
28
+ } catch (error: unknown) {
29
+ const errorMessage = error instanceof Error ? error.message : "Sign in failed"
30
+ toast.error(errorMessage)
31
+ } finally {
32
+ setLoading(false)
33
+ }
34
+ }
35
+
36
+ return (
37
+ <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/10 p-4">
38
+ <Card className="w-full max-w-md">
39
+ <CardHeader>
40
+ <CardTitle>Sign In</CardTitle>
41
+ <CardDescription>
42
+ Enter your credentials to access your account
43
+ </CardDescription>
44
+ </CardHeader>
45
+ <CardContent>
46
+ <form onSubmit={handleSubmit} className="space-y-4">
47
+ <div>
48
+ <label htmlFor="email" className="text-sm font-medium">
49
+ Email
50
+ </label>
51
+ <Input
52
+ id="email"
53
+ type="email"
54
+ placeholder="you@example.com"
55
+ value={email}
56
+ onChange={(e) => setEmail(e.target.value)}
57
+ required
58
+ autoFocus
59
+ />
60
+ </div>
61
+ <div>
62
+ <label htmlFor="password" className="text-sm font-medium">
63
+ Password
64
+ </label>
65
+ <Input
66
+ id="password"
67
+ type="password"
68
+ placeholder="••••••••"
69
+ value={password}
70
+ onChange={(e) => setPassword(e.target.value)}
71
+ required
72
+ />
73
+ </div>
74
+ <div className="text-right">
75
+ <Link
76
+ href="/auth/forgot-password"
77
+ className="text-sm text-primary hover:underline"
78
+ >
79
+ Forgot password?
80
+ </Link>
81
+ </div>
82
+ <Button type="submit" disabled={loading} className="w-full">
83
+ {loading ? (
84
+ <>
85
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
86
+ Signing in...
87
+ </>
88
+ ) : (
89
+ "Sign In"
90
+ )}
91
+ </Button>
92
+ </form>
93
+ <div className="mt-4 text-center text-sm">
94
+ <span className="text-muted-foreground">Don&apos;t have an account? </span>
95
+ <Link href="/auth/signup" className="text-primary hover:underline">
96
+ Sign up
97
+ </Link>
98
+ </div>
99
+ </CardContent>
100
+ </Card>
101
+ </div>
102
+ )
103
+ }
104
+
src/app/auth/signup/page.tsx ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { useRouter } from "next/navigation"
5
+ import Link from "next/link"
6
+ import { useAuth } from "@/lib/auth-context"
7
+ import { apiClient } from "@/lib/api-client"
8
+ import { Button } from "@/components/ui/button"
9
+ import { Input } from "@/components/ui/input"
10
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
11
+ import { OTPInput } from "@/components/ui/otp-input"
12
+ import { toast } from "sonner"
13
+ import { Loader2 } from "lucide-react"
14
+
15
+ export default function SignUpPage() {
16
+ const [step, setStep] = useState<"form" | "otp">("form")
17
+ const [loading, setLoading] = useState(false)
18
+ const [formData, setFormData] = useState({
19
+ email: "",
20
+ password: "",
21
+ name: "",
22
+ phone_number: "",
23
+ })
24
+ const [otp, setOtp] = useState("")
25
+ const [phoneNumber, setPhoneNumber] = useState("")
26
+ const router = useRouter()
27
+ const { register } = useAuth()
28
+
29
+ const handleSubmit = async (e: React.FormEvent) => {
30
+ e.preventDefault()
31
+ setLoading(true)
32
+
33
+ try {
34
+ // Register user
35
+ await register(
36
+ formData.email,
37
+ formData.password,
38
+ formData.name,
39
+ formData.phone_number
40
+ )
41
+
42
+ // Send OTP
43
+ await apiClient.post("/api/v1/auth/phone/send-otp", {
44
+ phone_number: formData.phone_number,
45
+ purpose: "verification",
46
+ })
47
+
48
+ setPhoneNumber(formData.phone_number)
49
+ setStep("otp")
50
+ toast.success("OTP sent to your phone number")
51
+ } catch (error: unknown) {
52
+ const errorMessage = error instanceof Error ? error.message : "Registration failed"
53
+ toast.error(errorMessage)
54
+ } finally {
55
+ setLoading(false)
56
+ }
57
+ }
58
+
59
+ const handleOTPVerify = async () => {
60
+ if (otp.length !== 6) {
61
+ toast.error("Please enter a valid 6-digit OTP")
62
+ return
63
+ }
64
+
65
+ setLoading(true)
66
+
67
+ try {
68
+ // Verify OTP
69
+ await apiClient.post("/api/v1/auth/phone/verify-otp", {
70
+ phone_number: phoneNumber,
71
+ otp: otp,
72
+ purpose: "verification",
73
+ })
74
+
75
+ toast.success("Phone verified successfully! Please sign in.")
76
+ router.push("/auth/signin")
77
+ } catch (error: unknown) {
78
+ const errorMessage = error instanceof Error ? error.message : "OTP verification failed"
79
+ toast.error(errorMessage)
80
+ } finally {
81
+ setLoading(false)
82
+ }
83
+ }
84
+
85
+ const handleResendOTP = async () => {
86
+ setLoading(true)
87
+ try {
88
+ await apiClient.post("/api/v1/auth/phone/resend-otp", {
89
+ phone_number: phoneNumber,
90
+ purpose: "verification",
91
+ })
92
+ toast.success("OTP resent successfully")
93
+ } catch (error: unknown) {
94
+ const errorMessage = error instanceof Error ? error.message : "Failed to resend OTP"
95
+ toast.error(errorMessage)
96
+ } finally {
97
+ setLoading(false)
98
+ }
99
+ }
100
+
101
+ if (step === "otp") {
102
+ return (
103
+ <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/10 p-4">
104
+ <Card className="w-full max-w-md">
105
+ <CardHeader>
106
+ <CardTitle>Verify Phone Number</CardTitle>
107
+ <CardDescription>
108
+ Enter the 6-digit code sent to {phoneNumber}
109
+ </CardDescription>
110
+ </CardHeader>
111
+ <CardContent className="space-y-4">
112
+ <OTPInput
113
+ value={otp}
114
+ onChange={setOtp}
115
+ onComplete={handleOTPVerify}
116
+ disabled={loading}
117
+ />
118
+ <Button
119
+ onClick={handleOTPVerify}
120
+ disabled={loading || otp.length !== 6}
121
+ className="w-full"
122
+ >
123
+ {loading ? (
124
+ <>
125
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
126
+ Verifying...
127
+ </>
128
+ ) : (
129
+ "Verify OTP"
130
+ )}
131
+ </Button>
132
+ <div className="text-center">
133
+ <button
134
+ onClick={handleResendOTP}
135
+ disabled={loading}
136
+ className="text-sm text-primary hover:underline"
137
+ >
138
+ Resend OTP
139
+ </button>
140
+ </div>
141
+ <div className="text-center text-sm">
142
+ <button
143
+ onClick={() => setStep("form")}
144
+ className="text-muted-foreground hover:underline"
145
+ >
146
+ Back to registration
147
+ </button>
148
+ </div>
149
+ </CardContent>
150
+ </Card>
151
+ </div>
152
+ )
153
+ }
154
+
155
+ return (
156
+ <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/10 p-4">
157
+ <Card className="w-full max-w-md">
158
+ <CardHeader>
159
+ <CardTitle>Create Account</CardTitle>
160
+ <CardDescription>
161
+ Sign up to get started with AmaniQuery
162
+ </CardDescription>
163
+ </CardHeader>
164
+ <CardContent>
165
+ <form onSubmit={handleSubmit} className="space-y-4">
166
+ <div>
167
+ <label htmlFor="name" className="text-sm font-medium">
168
+ Full Name
169
+ </label>
170
+ <Input
171
+ id="name"
172
+ type="text"
173
+ placeholder="John Doe"
174
+ value={formData.name}
175
+ onChange={(e) =>
176
+ setFormData({ ...formData, name: e.target.value })
177
+ }
178
+ required
179
+ />
180
+ </div>
181
+ <div>
182
+ <label htmlFor="email" className="text-sm font-medium">
183
+ Email
184
+ </label>
185
+ <Input
186
+ id="email"
187
+ type="email"
188
+ placeholder="you@example.com"
189
+ value={formData.email}
190
+ onChange={(e) =>
191
+ setFormData({ ...formData, email: e.target.value })
192
+ }
193
+ required
194
+ />
195
+ </div>
196
+ <div>
197
+ <label htmlFor="phone" className="text-sm font-medium">
198
+ Phone Number
199
+ </label>
200
+ <Input
201
+ id="phone"
202
+ type="tel"
203
+ placeholder="+254712345678 or 0712345678"
204
+ value={formData.phone_number}
205
+ onChange={(e) =>
206
+ setFormData({ ...formData, phone_number: e.target.value })
207
+ }
208
+ required
209
+ />
210
+ <p className="text-xs text-muted-foreground mt-1">
211
+ Format: +254712345678, +2541XXXXXXX, 0712345678, or 01XXXXXXX
212
+ </p>
213
+ </div>
214
+ <div>
215
+ <label htmlFor="password" className="text-sm font-medium">
216
+ Password
217
+ </label>
218
+ <Input
219
+ id="password"
220
+ type="password"
221
+ placeholder="••••••••"
222
+ value={formData.password}
223
+ onChange={(e) =>
224
+ setFormData({ ...formData, password: e.target.value })
225
+ }
226
+ required
227
+ minLength={8}
228
+ />
229
+ <p className="text-xs text-muted-foreground mt-1">
230
+ Must be at least 8 characters with uppercase, lowercase, and digit
231
+ </p>
232
+ </div>
233
+ <Button type="submit" disabled={loading} className="w-full">
234
+ {loading ? (
235
+ <>
236
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
237
+ Creating Account...
238
+ </>
239
+ ) : (
240
+ "Create Account"
241
+ )}
242
+ </Button>
243
+ </form>
244
+ <div className="mt-4 text-center text-sm">
245
+ <span className="text-muted-foreground">Already have an account? </span>
246
+ <Link href="/auth/signin" className="text-primary hover:underline">
247
+ Sign in
248
+ </Link>
249
+ </div>
250
+ </CardContent>
251
+ </Card>
252
+ </div>
253
+ )
254
+ }
255
+
src/app/blog/[slug]/page.tsx ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useState, useEffect } from "react"
4
+ import { useParams, useRouter } from "next/navigation"
5
+ import Link from "next/link"
6
+ import { Button } from "@/components/ui/button"
7
+ import { Badge } from "@/components/ui/badge"
8
+ import { Card, CardContent } from "@/components/ui/card"
9
+ import { Calendar, User, ArrowLeft, Share2 } from "lucide-react"
10
+ import Image from "next/image"
11
+
12
+ const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
13
+
14
+ interface BlogPost {
15
+ id: string
16
+ title: string
17
+ slug: string
18
+ markdown_content?: string
19
+ html_content?: string
20
+ excerpt?: string
21
+ post_type: string
22
+ featured_image_url?: string
23
+ author: {
24
+ id: string
25
+ name?: string
26
+ email: string
27
+ profile_image_url?: string
28
+ }
29
+ categories: Array<{ id: string; name: string; slug: string }>
30
+ tags: Array<{ id: string; name: string; slug: string }>
31
+ published_at?: string
32
+ created_at: string
33
+ updated_at: string
34
+ }
35
+
36
+ export default function BlogPostPage() {
37
+ const params = useParams()
38
+ const router = useRouter()
39
+ const slug = params.slug as string
40
+ const [post, setPost] = useState<BlogPost | null>(null)
41
+ const [loading, setLoading] = useState(true)
42
+
43
+ useEffect(() => {
44
+ const fetchPost = async () => {
45
+ setLoading(true)
46
+ try {
47
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts/${slug}`)
48
+ if (response.ok) {
49
+ const data: BlogPost = await response.json()
50
+ setPost(data)
51
+ } else if (response.status === 404) {
52
+ router.push("/blog")
53
+ }
54
+ } catch (error) {
55
+ console.error("Failed to fetch post:", error)
56
+ } finally {
57
+ setLoading(false)
58
+ }
59
+ }
60
+
61
+ if (slug) {
62
+ fetchPost()
63
+ }
64
+ }, [slug, router])
65
+
66
+ const formatDate = (dateString: string) => {
67
+ const date = new Date(dateString)
68
+ return date.toLocaleDateString("en-US", {
69
+ year: "numeric",
70
+ month: "long",
71
+ day: "numeric",
72
+ })
73
+ }
74
+
75
+ const getPostTypeColor = (type: string) => {
76
+ switch (type) {
77
+ case "news":
78
+ return "bg-blue-500/10 text-blue-500"
79
+ case "announcement":
80
+ return "bg-yellow-500/10 text-yellow-500"
81
+ case "update":
82
+ return "bg-green-500/10 text-green-500"
83
+ default:
84
+ return "bg-gray-500/10 text-gray-500"
85
+ }
86
+ }
87
+
88
+ const handleShare = async () => {
89
+ if (navigator.share && post) {
90
+ try {
91
+ await navigator.share({
92
+ title: post.title,
93
+ text: post.excerpt || "",
94
+ url: window.location.href,
95
+ })
96
+ } catch (error) {
97
+ // User cancelled or error occurred
98
+ console.log("Share cancelled or failed")
99
+ }
100
+ } else {
101
+ // Fallback: copy to clipboard
102
+ navigator.clipboard.writeText(window.location.href)
103
+ alert("Link copied to clipboard!")
104
+ }
105
+ }
106
+
107
+ if (loading) {
108
+ return (
109
+ <div className="min-h-screen flex items-center justify-center">
110
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
111
+ </div>
112
+ )
113
+ }
114
+
115
+ if (!post) {
116
+ return (
117
+ <div className="min-h-screen flex items-center justify-center">
118
+ <Card>
119
+ <CardContent className="p-12 text-center">
120
+ <p className="text-muted-foreground mb-4">Post not found</p>
121
+ <Link href="/blog">
122
+ <Button>Back to Blog</Button>
123
+ </Link>
124
+ </CardContent>
125
+ </Card>
126
+ </div>
127
+ )
128
+ }
129
+
130
+ return (
131
+ <div className="min-h-screen bg-background">
132
+ <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
133
+ {/* Back Button */}
134
+ <Link href="/blog">
135
+ <Button variant="ghost" className="mb-6">
136
+ <ArrowLeft className="w-4 h-4 mr-2" />
137
+ Back to Blog
138
+ </Button>
139
+ </Link>
140
+
141
+ {/* Header */}
142
+ <div className="mb-8">
143
+ <div className="flex items-center gap-2 mb-4">
144
+ <Badge className={getPostTypeColor(post.post_type)}>
145
+ {post.post_type}
146
+ </Badge>
147
+ {post.published_at && (
148
+ <div className="flex items-center text-sm text-muted-foreground">
149
+ <Calendar className="w-4 h-4 mr-1" />
150
+ {formatDate(post.published_at)}
151
+ </div>
152
+ )}
153
+ </div>
154
+ <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
155
+ {post.excerpt && (
156
+ <p className="text-xl text-muted-foreground mb-6">{post.excerpt}</p>
157
+ )}
158
+ <div className="flex items-center justify-between">
159
+ <div className="flex items-center gap-4">
160
+ <div className="flex items-center text-sm text-muted-foreground">
161
+ <User className="w-4 h-4 mr-1" />
162
+ <span>{post.author.name || post.author.email}</span>
163
+ </div>
164
+ </div>
165
+ <Button variant="outline" size="sm" onClick={handleShare}>
166
+ <Share2 className="w-4 h-4 mr-2" />
167
+ Share
168
+ </Button>
169
+ </div>
170
+ </div>
171
+
172
+ {/* Featured Image */}
173
+ {post.featured_image_url && (
174
+ <div className="relative w-full h-96 mb-8 rounded-lg overflow-hidden">
175
+ <Image
176
+ src={post.featured_image_url}
177
+ alt={post.title}
178
+ fill
179
+ className="object-cover"
180
+ />
181
+ </div>
182
+ )}
183
+
184
+ {/* Content */}
185
+ <article className="prose prose-lg dark:prose-invert max-w-none mb-8">
186
+ {post.html_content ? (
187
+ <div dangerouslySetInnerHTML={{ __html: post.html_content }} />
188
+ ) : post.markdown_content ? (
189
+ <div className="whitespace-pre-wrap">{post.markdown_content}</div>
190
+ ) : (
191
+ <p className="text-muted-foreground">No content available.</p>
192
+ )}
193
+ </article>
194
+
195
+ {/* Categories and Tags */}
196
+ {(post.categories.length > 0 || post.tags.length > 0) && (
197
+ <Card className="mb-8">
198
+ <CardContent className="p-6">
199
+ {post.categories.length > 0 && (
200
+ <div className="mb-4">
201
+ <h3 className="text-sm font-semibold mb-2">Categories</h3>
202
+ <div className="flex flex-wrap gap-2">
203
+ {post.categories.map((category) => (
204
+ <Badge key={category.id} variant="outline">
205
+ {category.name}
206
+ </Badge>
207
+ ))}
208
+ </div>
209
+ </div>
210
+ )}
211
+ {post.tags.length > 0 && (
212
+ <div>
213
+ <h3 className="text-sm font-semibold mb-2">Tags</h3>
214
+ <div className="flex flex-wrap gap-2">
215
+ {post.tags.map((tag) => (
216
+ <Badge key={tag.id} variant="secondary">
217
+ {tag.name}
218
+ </Badge>
219
+ ))}
220
+ </div>
221
+ </div>
222
+ )}
223
+ </CardContent>
224
+ </Card>
225
+ )}
226
+
227
+ {/* Navigation */}
228
+ <div className="flex justify-between items-center pt-8 border-t">
229
+ <Link href="/blog">
230
+ <Button variant="outline">
231
+ <ArrowLeft className="w-4 h-4 mr-2" />
232
+ All Posts
233
+ </Button>
234
+ </Link>
235
+ <Link href="/">
236
+ <Button variant="ghost">Back to Home</Button>
237
+ </Link>
238
+ </div>
239
+ </div>
240
+ </div>
241
+ )
242
+ }
243
+
src/app/blog/page.tsx ADDED
@@ -0,0 +1,395 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useState, useEffect, Suspense } from "react"
4
+ import { useSearchParams, useRouter } from "next/navigation"
5
+ import { BlogPostCard } from "@/components/blog-post-card"
6
+ import { Button } from "@/components/ui/button"
7
+ import { Input } from "@/components/ui/input"
8
+ import { Badge } from "@/components/ui/badge"
9
+ import { Card, CardContent } from "@/components/ui/card"
10
+ import {
11
+ Search,
12
+ Filter,
13
+ ChevronLeft,
14
+ ChevronRight,
15
+ Newspaper,
16
+ Megaphone,
17
+ RefreshCw,
18
+ } from "lucide-react"
19
+ import Link from "next/link"
20
+
21
+ const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
22
+
23
+ interface BlogPost {
24
+ id: string
25
+ title: string
26
+ slug: string
27
+ excerpt?: string
28
+ post_type: string
29
+ featured_image_url?: string
30
+ author: {
31
+ id: string
32
+ name?: string
33
+ email: string
34
+ profile_image_url?: string
35
+ }
36
+ categories: Array<{ id: string; name: string; slug: string }>
37
+ tags: Array<{ id: string; name: string; slug: string }>
38
+ published_at?: string
39
+ created_at: string
40
+ }
41
+
42
+ interface BlogResponse {
43
+ posts: BlogPost[]
44
+ total: number
45
+ page: number
46
+ page_size: number
47
+ total_pages: number
48
+ }
49
+
50
+ interface Category {
51
+ id: string
52
+ name: string
53
+ slug: string
54
+ }
55
+
56
+ interface Tag {
57
+ id: string
58
+ name: string
59
+ slug: string
60
+ }
61
+
62
+ function BlogList() {
63
+ const searchParams = useSearchParams()
64
+ const router = useRouter()
65
+ const [posts, setPosts] = useState<BlogPost[]>([])
66
+ const [categories, setCategories] = useState<Category[]>([])
67
+ const [tags, setTags] = useState<Tag[]>([])
68
+ const [loading, setLoading] = useState(true)
69
+ const [searchQuery, setSearchQuery] = useState(searchParams.get("search") || "")
70
+ const [selectedPostType, setSelectedPostType] = useState<string | null>(
71
+ searchParams.get("post_type") || null
72
+ )
73
+ const [selectedCategory, setSelectedCategory] = useState<string | null>(
74
+ searchParams.get("category") || null
75
+ )
76
+ const [selectedTag, setSelectedTag] = useState<string | null>(
77
+ searchParams.get("tag") || null
78
+ )
79
+ const [currentPage, setCurrentPage] = useState(
80
+ parseInt(searchParams.get("page") || "1")
81
+ )
82
+ const [pagination, setPagination] = useState({
83
+ total: 0,
84
+ page: 1,
85
+ page_size: 10,
86
+ total_pages: 1,
87
+ })
88
+
89
+ const fetchPosts = async () => {
90
+ setLoading(true)
91
+ try {
92
+ const params = new URLSearchParams()
93
+ params.append("page", currentPage.toString())
94
+ params.append("page_size", "10")
95
+ if (selectedPostType) params.append("post_type", selectedPostType)
96
+ if (selectedCategory) params.append("category_slug", selectedCategory)
97
+ if (selectedTag) params.append("tag_slug", selectedTag)
98
+ if (searchQuery) params.append("search", searchQuery)
99
+
100
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts?${params}`)
101
+ if (response.ok) {
102
+ const data: BlogResponse = await response.json()
103
+ setPosts(data.posts)
104
+ setPagination({
105
+ total: data.total,
106
+ page: data.page,
107
+ page_size: data.page_size,
108
+ total_pages: data.total_pages,
109
+ })
110
+ }
111
+ } catch (error) {
112
+ console.error("Failed to fetch posts:", error)
113
+ } finally {
114
+ setLoading(false)
115
+ }
116
+ }
117
+
118
+ const fetchCategories = async () => {
119
+ try {
120
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/categories`)
121
+ if (response.ok) {
122
+ const data: Category[] = await response.json()
123
+ setCategories(data)
124
+ }
125
+ } catch (error) {
126
+ console.error("Failed to fetch categories:", error)
127
+ }
128
+ }
129
+
130
+ const fetchTags = async () => {
131
+ try {
132
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/tags`)
133
+ if (response.ok) {
134
+ const data: Tag[] = await response.json()
135
+ setTags(data)
136
+ }
137
+ } catch (error) {
138
+ console.error("Failed to fetch tags:", error)
139
+ }
140
+ }
141
+
142
+ useEffect(() => {
143
+ fetchCategories()
144
+ fetchTags()
145
+ }, [])
146
+
147
+ useEffect(() => {
148
+ fetchPosts()
149
+ }, [currentPage, selectedPostType, selectedCategory, selectedTag, searchQuery])
150
+
151
+ useEffect(() => {
152
+ // Update URL with current filters
153
+ const params = new URLSearchParams()
154
+ if (selectedPostType) params.append("post_type", selectedPostType)
155
+ if (selectedCategory) params.append("category", selectedCategory)
156
+ if (selectedTag) params.append("tag", selectedTag)
157
+ if (searchQuery) params.append("search", searchQuery)
158
+ if (currentPage > 1) params.append("page", currentPage.toString())
159
+
160
+ router.push(`/blog?${params.toString()}`, { scroll: false })
161
+ }, [selectedPostType, selectedCategory, selectedTag, searchQuery, currentPage, router])
162
+
163
+ const handleSearch = (e: React.FormEvent) => {
164
+ e.preventDefault()
165
+ setCurrentPage(1)
166
+ fetchPosts()
167
+ }
168
+
169
+ const clearFilters = () => {
170
+ setSelectedPostType(null)
171
+ setSelectedCategory(null)
172
+ setSelectedTag(null)
173
+ setSearchQuery("")
174
+ setCurrentPage(1)
175
+ }
176
+
177
+ return (
178
+ <div className="min-h-screen bg-background">
179
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
180
+ {/* Header */}
181
+ <div className="mb-8">
182
+ <div className="flex items-center justify-between mb-4">
183
+ <div>
184
+ <h1 className="text-3xl font-bold mb-2">Blog</h1>
185
+ <p className="text-muted-foreground">
186
+ News, announcements, and updates from AmaniQuery
187
+ </p>
188
+ </div>
189
+ <Link href="/">
190
+ <Button variant="outline">Back to Home</Button>
191
+ </Link>
192
+ </div>
193
+
194
+ {/* Search */}
195
+ <form onSubmit={handleSearch} className="mb-4">
196
+ <div className="flex gap-2">
197
+ <div className="relative flex-1">
198
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
199
+ <Input
200
+ type="text"
201
+ placeholder="Search posts..."
202
+ value={searchQuery}
203
+ onChange={(e) => setSearchQuery(e.target.value)}
204
+ className="pl-10"
205
+ />
206
+ </div>
207
+ <Button type="submit">Search</Button>
208
+ </div>
209
+ </form>
210
+ </div>
211
+
212
+ <div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
213
+ {/* Sidebar Filters */}
214
+ <div className="lg:col-span-1">
215
+ <Card>
216
+ <CardContent className="p-4">
217
+ <div className="flex items-center justify-between mb-4">
218
+ <h2 className="font-semibold flex items-center">
219
+ <Filter className="w-4 h-4 mr-2" />
220
+ Filters
221
+ </h2>
222
+ {(selectedPostType || selectedCategory || selectedTag) && (
223
+ <Button
224
+ variant="ghost"
225
+ size="sm"
226
+ onClick={clearFilters}
227
+ className="text-xs"
228
+ >
229
+ Clear
230
+ </Button>
231
+ )}
232
+ </div>
233
+
234
+ {/* Post Type Filter */}
235
+ <div className="mb-4">
236
+ <h3 className="text-sm font-medium mb-2">Post Type</h3>
237
+ <div className="space-y-2">
238
+ {["news", "announcement", "update"].map((type) => (
239
+ <button
240
+ key={type}
241
+ onClick={() => {
242
+ setSelectedPostType(
243
+ selectedPostType === type ? null : type
244
+ )
245
+ setCurrentPage(1)
246
+ }}
247
+ className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
248
+ selectedPostType === type
249
+ ? "bg-primary text-primary-foreground"
250
+ : "hover:bg-accent"
251
+ }`}
252
+ >
253
+ <div className="flex items-center">
254
+ {type === "news" && <Newspaper className="w-4 h-4 mr-2" />}
255
+ {type === "announcement" && (
256
+ <Megaphone className="w-4 h-4 mr-2" />
257
+ )}
258
+ {type === "update" && (
259
+ <RefreshCw className="w-4 h-4 mr-2" />
260
+ )}
261
+ {type.charAt(0).toUpperCase() + type.slice(1)}
262
+ </div>
263
+ </button>
264
+ ))}
265
+ </div>
266
+ </div>
267
+
268
+ {/* Categories Filter */}
269
+ {categories.length > 0 && (
270
+ <div className="mb-4">
271
+ <h3 className="text-sm font-medium mb-2">Categories</h3>
272
+ <div className="space-y-2 max-h-48 overflow-y-auto">
273
+ {categories.map((category) => (
274
+ <button
275
+ key={category.id}
276
+ onClick={() => {
277
+ setSelectedCategory(
278
+ selectedCategory === category.slug ? null : category.slug
279
+ )
280
+ setCurrentPage(1)
281
+ }}
282
+ className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
283
+ selectedCategory === category.slug
284
+ ? "bg-primary text-primary-foreground"
285
+ : "hover:bg-accent"
286
+ }`}
287
+ >
288
+ {category.name}
289
+ </button>
290
+ ))}
291
+ </div>
292
+ </div>
293
+ )}
294
+
295
+ {/* Tags Filter */}
296
+ {tags.length > 0 && (
297
+ <div>
298
+ <h3 className="text-sm font-medium mb-2">Tags</h3>
299
+ <div className="flex flex-wrap gap-2">
300
+ {tags.map((tag) => (
301
+ <Badge
302
+ key={tag.id}
303
+ variant={
304
+ selectedTag === tag.slug ? "default" : "outline"
305
+ }
306
+ className="cursor-pointer"
307
+ onClick={() => {
308
+ setSelectedTag(
309
+ selectedTag === tag.slug ? null : tag.slug
310
+ )
311
+ setCurrentPage(1)
312
+ }}
313
+ >
314
+ {tag.name}
315
+ </Badge>
316
+ ))}
317
+ </div>
318
+ </div>
319
+ )}
320
+ </CardContent>
321
+ </Card>
322
+ </div>
323
+
324
+ {/* Main Content */}
325
+ <div className="lg:col-span-3">
326
+ {loading ? (
327
+ <div className="flex items-center justify-center py-12">
328
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
329
+ </div>
330
+ ) : posts.length === 0 ? (
331
+ <Card>
332
+ <CardContent className="p-12 text-center">
333
+ <p className="text-muted-foreground">
334
+ No posts found. Try adjusting your filters.
335
+ </p>
336
+ </CardContent>
337
+ </Card>
338
+ ) : (
339
+ <>
340
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
341
+ {posts.map((post) => (
342
+ <BlogPostCard key={post.id} post={post} />
343
+ ))}
344
+ </div>
345
+
346
+ {/* Pagination */}
347
+ {pagination.total_pages > 1 && (
348
+ <div className="flex items-center justify-center gap-2">
349
+ <Button
350
+ variant="outline"
351
+ size="sm"
352
+ onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
353
+ disabled={currentPage === 1}
354
+ >
355
+ <ChevronLeft className="w-4 h-4" />
356
+ Previous
357
+ </Button>
358
+ <span className="text-sm text-muted-foreground">
359
+ Page {pagination.page} of {pagination.total_pages}
360
+ </span>
361
+ <Button
362
+ variant="outline"
363
+ size="sm"
364
+ onClick={() =>
365
+ setCurrentPage((p) =>
366
+ Math.min(pagination.total_pages, p + 1)
367
+ )
368
+ }
369
+ disabled={currentPage === pagination.total_pages}
370
+ >
371
+ Next
372
+ <ChevronRight className="w-4 h-4" />
373
+ </Button>
374
+ </div>
375
+ )}
376
+ </>
377
+ )}
378
+ </div>
379
+ </div>
380
+ </div>
381
+ </div>
382
+ )
383
+ }
384
+
385
+ export default function BlogPage() {
386
+ return (
387
+ <Suspense fallback={
388
+ <div className="min-h-screen bg-background flex items-center justify-center">
389
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
390
+ </div>
391
+ }>
392
+ <BlogList />
393
+ </Suspense>
394
+ )
395
+ }
src/app/chat/page.tsx ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useEffect, useState } from "react"
4
+ import { useRouter } from "next/navigation"
5
+ import { AmaniChat } from "@/components/chat/AmaniChat"
6
+ import { AmaniSidebar } from "@/components/AmaniSidebar"
7
+ import { ThemeToggle } from "@/components/theme-toggle"
8
+ import { useAuth } from "@/lib/auth-context"
9
+ import { cn } from "@/lib/utils"
10
+ import { Menu } from "lucide-react"
11
+ import type { ChatSession } from "@/components/chat/types"
12
+
13
+ export default function ChatPage() {
14
+ const { isAuthenticated, loading } = useAuth()
15
+ const router = useRouter()
16
+ const [chatHistory, setChatHistory] = useState<ChatSession[]>([])
17
+ const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
18
+ const [isSidebarOpen, setIsSidebarOpen] = useState(true)
19
+
20
+
21
+ // Define loadChatHistory before useEffect to avoid TDZ error
22
+ const loadChatHistory = async () => {
23
+ try {
24
+ const token = localStorage.getItem("session_token")
25
+ const headers = { "X-Session-Token": token || "" }
26
+ const response = await fetch("/api/cache/sessions", { headers })
27
+ if (response.ok) {
28
+ const sessions = await response.json()
29
+ setChatHistory(sessions)
30
+ }
31
+ } catch (error) {
32
+ console.error("Failed to load chat history:", error)
33
+ }
34
+ }
35
+
36
+ // Load chat history on mount
37
+ useEffect(() => {
38
+ if (isAuthenticated) {
39
+ (async () => {
40
+ await loadChatHistory();
41
+ })();
42
+ }
43
+ }, [isAuthenticated])
44
+
45
+ useEffect(() => {
46
+ if (!loading && !isAuthenticated) {
47
+ router.push("/auth/signin?redirect=/chat")
48
+ }
49
+ }, [isAuthenticated, loading, router])
50
+
51
+ if (loading) {
52
+ return (
53
+ <div className="min-h-screen flex items-center justify-center">
54
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
55
+ </div>
56
+ )
57
+ }
58
+
59
+ if (!isAuthenticated) {
60
+ return null
61
+ }
62
+
63
+ return (
64
+ <div className="min-h-screen max-h-screen bg-background flex overflow-hidden">
65
+ {/* New AmaniSidebar with integrated chat history */}
66
+ <AmaniSidebar
67
+ chatHistory={chatHistory}
68
+ currentSessionId={currentSessionId}
69
+ onSessionSelect={setCurrentSessionId}
70
+ onNewSession={() => setCurrentSessionId(null)}
71
+ onDeleteSession={async (sessionId) => {
72
+ // Handle session deletion
73
+ setChatHistory(prev => prev.filter(s => s.id !== sessionId))
74
+ if (currentSessionId === sessionId) {
75
+ setCurrentSessionId(null)
76
+ }
77
+ // Call API to delete session
78
+ try {
79
+ const token = localStorage.getItem("session_token")
80
+ const headers = { "X-Session-Token": token || "" }
81
+ await fetch(`/api/v1/chat/sessions/${sessionId}`, {
82
+ method: "DELETE",
83
+ headers
84
+ })
85
+ } catch (error) {
86
+ console.error("Failed to delete session:", error)
87
+ }
88
+ }}
89
+ onRenameSession={async (sessionId, newTitle) => {
90
+ // Handle session renaming
91
+ setChatHistory(prev => prev.map(s =>
92
+ s.id === sessionId ? { ...s, title: newTitle } : s
93
+ ))
94
+ // Call API to rename session
95
+ try {
96
+ const token = localStorage.getItem("session_token")
97
+ const headers = {
98
+ "X-Session-Token": token || "",
99
+ "Content-Type": "application/json"
100
+ }
101
+ await fetch(`/api/v1/chat/sessions/${sessionId}`, {
102
+ method: "PATCH",
103
+ headers,
104
+ body: JSON.stringify({ title: newTitle })
105
+ })
106
+ } catch (error) {
107
+ console.error("Failed to rename session:", error)
108
+ }
109
+ }}
110
+ isOpen={isSidebarOpen}
111
+ onToggle={() => setIsSidebarOpen(!isSidebarOpen)}
112
+ />
113
+
114
+ {/* Main Chat Area */}
115
+ <div className={cn(
116
+ "flex-1 min-w-0 overflow-hidden relative transition-all duration-300",
117
+ isSidebarOpen ? "md:ml-0" : "md:ml-0"
118
+ )}>
119
+ {/* Mobile Menu Button */}
120
+ <button
121
+ onClick={() => setIsSidebarOpen(true)}
122
+ className="md:hidden absolute top-4 left-4 z-10 p-2 bg-card border rounded-lg shadow-sm hover:bg-accent transition-colors"
123
+ aria-label="Open menu"
124
+ >
125
+ <Menu className="w-5 h-5" />
126
+ </button>
127
+
128
+ <div className="absolute top-2 right-2 md:top-4 md:right-4 z-10">
129
+ <ThemeToggle />
130
+ </div>
131
+ <AmaniChat
132
+ showWelcomeScreen={true}
133
+ enableThinkingIndicator={true}
134
+ showInlineSources={true}
135
+ enableVoice={false}
136
+ currentSessionId={currentSessionId}
137
+ onSessionChange={setCurrentSessionId}
138
+ chatHistory={chatHistory}
139
+ onChatHistoryUpdate={setChatHistory}
140
+ onToggleSidebar={() => setIsSidebarOpen(true)}
141
+ />
142
+ </div>
143
+ </div>
144
+ )
145
+ }
src/app/developers/constants.ts ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const DEVELOPER_KIT_VERSION = "1.0"
2
+ export const LAST_UPDATED = "2025-11-29"
3
+
4
+ export const MASTER_SYSTEM_PROMPT = `You are AmaniQuery, an expert legal research assistant and systems architect for Kenyan law. Your purpose is to provide accurate, comprehensive, and legally grounded answers in both English and Swahili.
5
+
6
+ ### CORE INSTRUCTIONS
7
+ 1. **Output Format**: You must ALWAYS respond using a strict JSON-like structure with two distinct fields:
8
+ - \`reasoning_content\`: Internal chain-of-thought, planning, and analysis. This is NEVER shown to the user.
9
+ - \`content\`: The final, polished user-facing response.
10
+
11
+ 2. **Language**: Detect the user's language (English or Swahili) and respond in the SAME language. Maintain identical formatting integrity and professional tone in both languages.
12
+
13
+ 3. **Modes of Operation**: Automatically detect the intent and apply the correct mode:
14
+ - **Standard Chat**: For casual greetings, general questions, or simple clarifications.
15
+ - **Hybrid RAG**: For specific legal questions requiring retrieval of cases, statutes, or constitution.
16
+ - **Research Agent**: For complex, multi-step research tasks or report generation.
17
+
18
+ ### RESPONSE STRUCTURE (JSON-LIKE)
19
+ \`\`\`json
20
+ {
21
+ "reasoning_content": "Step-by-step analysis, search strategy, and synthesis of information...",
22
+ "content": "The final formatted response..."
23
+ }
24
+ \`\`\`
25
+
26
+ ### FORMATTING RULES (Apply to 'content' field)
27
+ - **Markdown**: Use generous whitespace. Paragraphs must be ≤80 words.
28
+ - **Headings**: Use max 4 levels (H1-H4).
29
+ - **Styling**: Use **bold** for emphasis, *italics* for definitions/nuance.
30
+ - **Lists**: Use bullet points or numbered lists for readability. Avoid excessive nesting.
31
+ - **Tables**: Use Markdown tables for comparisons and structured data.
32
+ - **Separators**: Use horizontal rules (\`---\`) to visually separate distinct sections.
33
+ - **Blockquotes**: Use \`>\` for quoting legal text or external sources.
34
+ - **Code**: Use code blocks for statutes or specific clauses if needed.
35
+ - **Math**: Use LaTeX \`\\( \\)\` for inline and \`\\[ \\]\` for block equations.`
36
+
37
+ export const HYBRID_RAG_PROMPT = `You are in Hybrid RAG Mode. Your "content" field MUST follow this precise structure:
38
+
39
+ ## Key Sources
40
+ - [Summary of source 1] (Source Name)
41
+ - [Summary of source 2] (Source Name)
42
+
43
+ ## Analysis & Synthesis
44
+ [Step-by-step explanation of how sources were evaluated, compared, and combined to form the answer.]
45
+
46
+ ## Final Answer
47
+ [A polished, comprehensive answer to the user's query. Every factual claim must be supported by numeric in-line citations like [1][2].]
48
+
49
+ ## References
50
+ 1. [Title](URL) - Author/Publication, Date
51
+ 2. [Title](URL) - Author/Publication, Date`
52
+
53
+ export const LEGAL_SPECIALIST_PROMPT = `You are the Legal Content Specialist for AmaniQuery. Your role is to transform legal analysis into professionally formatted, court-ready documents.
54
+
55
+ ### FORMATTING STANDARDS
56
+ 1. **Citations**: Use Bluebook (21st Ed.) or standard Kenyan legal citation style (e.g., *Republic v. John Doe* [2025] eKLR).
57
+ - **Statutes**: *The Constitution of Kenya, 2010, Art. 43(1)(b)*.
58
+ - **Cases**: *Okiya Omtatah Okoiti v. Cabinet Secretary, National Treasury & 3 Others* [2023] eKLR.
59
+ - **Hyperlinks**: ALL citations must be hyperlinked to their source (Kenya Law Reports, Parliament, etc.).
60
+
61
+ 2. **Emphasis**:
62
+ - Use \`> blockquotes\` for direct excerpts from statutes or judgments.
63
+ - Use **bold** for key legal principles or holding phrases within the text.
64
+ - NEVER use bold for entire paragraphs.
65
+
66
+ 3. **Structure**:
67
+ - **Case Analysis**: Follow strict **IRAC** (Issue, Rule, Analysis, Conclusion) or **FIRAC** (Facts, Issue, Rule, Analysis, Conclusion) structure.
68
+ - **Arguments**: Use dedicated headings for opposing views (e.g., \`### Arguments for the Petitioner\`, \`### Arguments for the Respondent\`).
69
+ - **Statutory Comparison**: Use Markdown tables to compare provisions (e.g., \`| Old Act | New Bill | Implication |\`).
70
+
71
+ 4. **Tone**: Professional, objective, and suitable for lawyers, judges, and legal researchers. Avoid colloquialisms.
72
+
73
+ ### OUTPUT TEMPLATE
74
+ \`\`\`markdown
75
+ ### Case Brief: [Case Name]
76
+
77
+ **Citation:** [Link to Case]
78
+
79
+ #### Facts
80
+ [Brief summary of material facts]
81
+
82
+ #### Issue
83
+ [The legal question to be decided]
84
+
85
+ #### Rule
86
+ > [Key statutory provision or precedent]
87
+
88
+ #### Analysis
89
+ [Application of rule to facts. Use **bold** for key reasoning.]
90
+
91
+ #### Conclusion
92
+ [The court's holding and order]
93
+ \`\`\``
94
+
95
+ export const NEWS_SPECIALIST_PROMPT = `You are the News & Parliamentary Records Specialist. Your job is to present current events and government proceedings with journalistic precision and structural clarity.
96
+
97
+ ### FORMATTING RULES
98
+ 1. **Speaker Formatting**: ALWAYS format speakers in Hansard/Transcripts as:
99
+ - \`**Hon. [Name] ([Role/Constituency]):** "Quote..."\`
100
+ - Example: **Hon. Kimani Ichung'wah (Majority Leader):** "This Bill is timely..."
101
+
102
+ 2. **Direct Quotes**:
103
+ - Use \`> blockquotes\` for all direct speech or excerpts.
104
+ - Attribute EVERY quote with source and date: \`> "Quote text..." (Daily Nation, 29 Nov 2025)\`
105
+
106
+ 3. **Vote Results**:
107
+ - Use Markdown tables for all voting outcomes.
108
+ - Columns: \`| Member/Party | Vote (Yes/No/Abstain) | Remarks |\`
109
+
110
+ 4. **Chronology**:
111
+ - Use strict chronological order for event summaries.
112
+ - Use timestamps/dates as sub-bullets: \`* **10:00 AM:** Session commenced.\`
113
+
114
+ 5. **Attribution**:
115
+ - Every factual claim must have an attribution tag: "According to [Source], [Date]..."
116
+ - Link the source immediately if possible.
117
+
118
+ ### OUTPUT TEMPLATE (Parliamentary)
119
+ \`\`\`markdown
120
+ ### Session Summary: [Date]
121
+
122
+ #### Key Speakers
123
+ * **Hon. Jane Kamau (Minister of Finance):** Discussed the Finance Bill...
124
+
125
+ #### Debate Highlights
126
+ > "We cannot overtax the common mwananchi..." (Hon. Kamau, Hansard 14:30)
127
+
128
+ #### Voting Outcome
129
+ | Party | Ayes | Nays | Abstain |
130
+ | :--- | :---: | :---: | :---: |
131
+ | UDA | 140 | 0 | 5 |
132
+ | ODM | 0 | 85 | 2 |
133
+ \`\`\``
134
+
135
+ export const PYDANTIC_VALIDATOR = `from pydantic import BaseModel, Field, validator
136
+ import re
137
+
138
+ class AmaniQueryResponse(BaseModel):
139
+ reasoning_content: str = Field(..., description="Internal chain-of-thought")
140
+ content: str = Field(..., description="Final user-facing response in Markdown")
141
+
142
+ @validator('content')
143
+ def validate_markdown_structure(cls, v):
144
+ if re.search(r'^#####\\s', v, re.MULTILINE): raise ValueError("Headings > H4")
145
+ if re.search(r'\\$.*?\\$', v): raise ValueError("Use LaTeX \\\\( \\\\) not $")
146
+ return v
147
+
148
+ @validator('content')
149
+ def validate_citations(cls, v):
150
+ if re.search(r'\\[\\d+\\]', v) and "## References" not in v:
151
+ raise ValueError("Citations found but References missing")
152
+ return v`
153
+
154
+ export const GOLDEN_TEST_CASE = `**Query:** "What is the capital of Kenya?"
155
+ **Expected:** "The capital of Kenya is **Nairobi**."
156
+
157
+ **Query:** "What does Article 43 say?"
158
+ **Expected:** (Hybrid RAG structure with ## Key Sources, ## Analysis, ## Final Answer, ## References)`