alaselababatunde commited on
Commit
efc5eea
·
1 Parent(s): fc6949a

Updated project with LFS for PDFs

Browse files
.dockerignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ frontend/
2
+ .git/
3
+ .gitignore
4
+ data/chroma/
5
+ *.pdf
6
+ node_modules/
.env ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ XAI_API_KEY=xai-Q6zYpn9C8ReN8bGZmp8QIgvyv8ZNee9UusbqUFpT4zPQpblvd1RTerx1mgRtu6L1n8DkdHV5lnzGi23D
2
+ REDIS_HOST=redis
3
+ REDIS_PORT=6379
.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
+ *.pdf filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ COPY . .
10
+
11
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
app/api/endpoints/chat.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from fastapi.responses import StreamingResponse
3
+ from pydantic import BaseModel
4
+ from typing import List
5
+ from app.services.llm import llm_service
6
+ from app.services.rag import rag_service
7
+
8
+ router = APIRouter()
9
+
10
+ class Message(BaseModel):
11
+ role: str
12
+ content: str
13
+
14
+ class ChatRequest(BaseModel):
15
+ messages: List[Message]
16
+
17
+ @router.post("/chat")
18
+ async def chat_endpoint(request: ChatRequest):
19
+ try:
20
+ # 1. Extract latest query
21
+ if not request.messages:
22
+ raise HTTPException(status_code=400, detail="No messages provided")
23
+
24
+ last_message = request.messages[-1]
25
+ user_query = last_message.content
26
+
27
+ # 2. Retrieve Context (RAG)
28
+ # Note: In a real system, we'd only RAG on user queries, not history,
29
+ # but here we keep it simple.
30
+ context_docs = rag_service.query(user_query)
31
+ context_text = "\n\n".join(context_docs) # context_docs is list of strings
32
+
33
+ # 3. Stream Response
34
+ # Convert pydantic messages to dict
35
+ messages_dict = [{"role": m.role, "content": m.content} for m in request.messages]
36
+
37
+ return StreamingResponse(
38
+ llm_service.chat_stream(messages_dict, context=context_text),
39
+ media_type="text/event-stream"
40
+ )
41
+
42
+ except Exception as e:
43
+ print(f"Error in chat endpoint: {e}")
44
+ raise HTTPException(status_code=500, detail=str(e))
45
+
46
+ @router.post("/ingest")
47
+ async def ingest_endpoint(url: str):
48
+ success = rag_service.scrape_and_ingest(url)
49
+ if success:
50
+ return {"status": "success", "message": f"Ingested {url}"}
51
+ raise HTTPException(status_code=400, detail="Failed to ingest URL")
app/core/config.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings
2
+
3
+ class Settings(BaseSettings):
4
+ XAI_API_KEY: str
5
+ REDIS_HOST: str = "redis"
6
+ REDIS_PORT: int = 6379
7
+
8
+ class Config:
9
+ env_file = ".env"
10
+
11
+ settings = Settings()
app/main.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from app.core.config import settings
4
+ from app.api.endpoints import chat
5
+
6
+ app = FastAPI(title="DStv AI Support")
7
+
8
+ # CORS
9
+ origins = [
10
+ "http://localhost:5173",
11
+ "http://localhost:80",
12
+ ]
13
+
14
+ app.add_middleware(
15
+ CORSMiddleware,
16
+ allow_origins=origins,
17
+ allow_credentials=True,
18
+ allow_methods=["*"],
19
+ allow_headers=["*"],
20
+ )
21
+
22
+ @app.get("/health")
23
+ def health_check():
24
+ return {"status": "ok", "service": "DStv AI Support Backend"}
25
+
26
+ @app.get("/")
27
+ def root():
28
+ return {"message": "DStv AI Support API is running"}
29
+
30
+ app.include_router(chat.router, prefix="/api")
31
+
app/services/llm.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from openai import OpenAI
2
+ from app.core.config import settings
3
+ from typing import List, Dict, Generator
4
+
5
+ class LLMService:
6
+ def __init__(self):
7
+ self.client = OpenAI(
8
+ api_key=settings.XAI_API_KEY,
9
+ base_url="https://api.x.ai/v1"
10
+ )
11
+ self.model = "grok-4-1-fast-reasoning" # Actually check if this is the exact string. Assuming yes based on prompt.
12
+
13
+ def get_system_prompt(self, context: str = "") -> str:
14
+ base_prompt = """You are DStv AI Support, a friendly and helpful customer service agent for DStv.
15
+ Your goal is to assist customers with subscriptions, packages, payments, decoder issues, signal problems, installations, and account management.
16
+
17
+ Brand Voice:
18
+ - Friendly, practical, and clear.
19
+ - Do NOT say "I am an AI", "Based on the context", or "Internal system details".
20
+ - Speak like a real human agent.
21
+
22
+ Context from Knowledge Base:
23
+ {context}
24
+
25
+ If the context helps, use it. If not, use your general knowledge but be careful not to hallucinate DStv specifics.
26
+ If you don't know, suggest they check self-service on dstv.com or contact our call center.
27
+ """
28
+ return base_prompt.replace("{context}", context)
29
+
30
+ def chat_stream(self, messages: List[Dict[str, str]], context: str = "") -> Generator[str, None, None]:
31
+ system_msg = {"role": "system", "content": self.get_system_prompt(context)}
32
+ full_messages = [system_msg] + messages
33
+
34
+ stream = self.client.chat.completions.create(
35
+ model=self.model,
36
+ messages=full_messages,
37
+ stream=True,
38
+ temperature=0.4 # Lower temperature for support accuracy
39
+ )
40
+
41
+ for chunk in stream:
42
+ if chunk.choices[0].delta.content:
43
+ yield chunk.choices[0].delta.content
44
+
45
+ llm_service = LLMService()
app/services/rag.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import chromadb
2
+ from chromadb.utils import embedding_functions
3
+ import requests
4
+ from bs4 import BeautifulSoup
5
+ import uuid
6
+ import os
7
+ from pydantic_settings import BaseSettings
8
+ from pypdf import PdfReader
9
+
10
+ class RAGService:
11
+ def __init__(self):
12
+ # Persistent storage in ./data/chroma (relative to root now)
13
+ self.client = chromadb.PersistentClient(path="./data/chroma")
14
+
15
+ self.embedding_fn = embedding_functions.DefaultEmbeddingFunction()
16
+
17
+ self.collection = self.client.get_or_create_collection(
18
+ name="dstv_knowledge",
19
+ embedding_function=self.embedding_fn
20
+ )
21
+ # Auto ingest PDFs on startup
22
+ self.ingest_all_pdfs()
23
+
24
+ def ingest_pdf(self, file_path: str):
25
+ try:
26
+ print(f"Ingesting PDF: {file_path}")
27
+ reader = PdfReader(file_path)
28
+ text_chunks = []
29
+
30
+ for i, page in enumerate(reader.pages):
31
+ text = page.extract_text()
32
+ if text and len(text) > 50:
33
+ # In a real app, we'd split large pages.
34
+ # Assuming quick guides have less dense text per page or acceptable length.
35
+ text_chunks.append(text)
36
+
37
+ if not text_chunks:
38
+ return False
39
+
40
+ ids = [f"{os.path.basename(file_path)}_page_{i}" for i in range(len(text_chunks))]
41
+ metadatas = [{"source": file_path, "page": i} for i in range(len(text_chunks))]
42
+
43
+ self.collection.add(
44
+ documents=text_chunks,
45
+ ids=ids,
46
+ metadatas=metadatas
47
+ )
48
+ print(f"Ingested {len(text_chunks)} pages from {file_path}")
49
+ return True
50
+ except Exception as e:
51
+ print(f"Error ingesting PDF {file_path}: {e}")
52
+ return False
53
+
54
+ def ingest_all_pdfs(self, root_dir: str = "."):
55
+ for file in os.listdir(root_dir):
56
+ if file.lower().endswith(".pdf"):
57
+ # Check if already ingested (naive check by ID of page 0)
58
+ try:
59
+ existing = self.collection.get(ids=[f"{file}_page_0"])
60
+ if existing and existing['ids']:
61
+ print(f"PDF {file} already found in DB.")
62
+ continue
63
+ except:
64
+ pass
65
+
66
+ self.ingest_pdf(os.path.join(root_dir, file))
67
+
68
+ def scrape_and_ingest(self, url: str):
69
+ try:
70
+ print(f"Scraping {url}...")
71
+ headers = {'User-Agent': 'Mozilla/5.0 (compatible; DStvBot/1.0)'}
72
+ response = requests.get(url, headers=headers, timeout=10)
73
+ if response.status_code != 200:
74
+ print(f"Failed to fetch {url}: {response.status_code}")
75
+ return False
76
+
77
+ soup = BeautifulSoup(response.text, 'html.parser')
78
+
79
+ text_blocks = []
80
+ for tag in soup.find_all(['p', 'h1', 'h2', 'h3', 'li']):
81
+ text = tag.get_text(strip=True)
82
+ if len(text) > 30:
83
+ text_blocks.append(text)
84
+
85
+ if not text_blocks:
86
+ return False
87
+
88
+ ids = [str(uuid.uuid4()) for _ in text_blocks]
89
+ metadatas = [{"source": url} for _ in text_blocks]
90
+
91
+ self.collection.add(
92
+ documents=text_blocks,
93
+ ids=ids,
94
+ metadatas=metadatas
95
+ )
96
+ print(f"Ingested {len(text_blocks)} chunks from {url}")
97
+ return True
98
+
99
+ except Exception as e:
100
+ print(f"Error ingesting {url}: {e}")
101
+ return False
102
+
103
+ def query(self, query_text: str, n_results: int = 3):
104
+ results = self.collection.query(
105
+ query_texts=[query_text],
106
+ n_results=n_results
107
+ )
108
+ if results["documents"]:
109
+ return results["documents"][0]
110
+ return []
111
+
112
+ rag_service = RAGService()
docker-compose.yml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ backend:
5
+ build: .
6
+ container_name: dstv_backend
7
+ ports:
8
+ - "8000:8000"
9
+ volumes:
10
+ - .:/app
11
+ environment:
12
+ - XAI_API_KEY=${XAI_API_KEY}
13
+ - REDIS_HOST=redis
14
+ depends_on:
15
+ - redis
16
+
17
+ frontend:
18
+ build:
19
+ context: ./frontend
20
+ dockerfile: Dockerfile
21
+ container_name: dstv_frontend
22
+ ports:
23
+ - "5173:5173"
24
+ volumes:
25
+ - ./frontend:/app
26
+ - /app/node_modules
27
+ environment:
28
+ - VITE_API_URL=http://localhost:8000
29
+
30
+ redis:
31
+ image: redis:alpine
32
+ container_name: dstv_redis
33
+ ports:
34
+ - "6379:6379"
dstv-explora-quick-guide.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:195cdfb3e1dae4e9c717a27966ba4d68c68bf53a3e566f5bc277f061e7dddae8
3
+ size 1380743
dstvhd6s_quickguide_v22_e_dec2020.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6910dec0288a4c3698d3fde6ba93cdd5b48c859282f8530bb2fbafd54a7398fa
3
+ size 1012623
frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
frontend/README.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + TypeScript + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
17
+
18
+ ```js
19
+ export default defineConfig([
20
+ globalIgnores(['dist']),
21
+ {
22
+ files: ['**/*.{ts,tsx}'],
23
+ extends: [
24
+ // Other configs...
25
+
26
+ // Remove tseslint.configs.recommended and replace with this
27
+ tseslint.configs.recommendedTypeChecked,
28
+ // Alternatively, use this for stricter rules
29
+ tseslint.configs.strictTypeChecked,
30
+ // Optionally, add this for stylistic rules
31
+ tseslint.configs.stylisticTypeChecked,
32
+
33
+ // Other configs...
34
+ ],
35
+ languageOptions: {
36
+ parserOptions: {
37
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
38
+ tsconfigRootDir: import.meta.dirname,
39
+ },
40
+ // other options...
41
+ },
42
+ },
43
+ ])
44
+ ```
45
+
46
+ You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
47
+
48
+ ```js
49
+ // eslint.config.js
50
+ import reactX from 'eslint-plugin-react-x'
51
+ import reactDom from 'eslint-plugin-react-dom'
52
+
53
+ export default defineConfig([
54
+ globalIgnores(['dist']),
55
+ {
56
+ files: ['**/*.{ts,tsx}'],
57
+ extends: [
58
+ // Other configs...
59
+ // Enable lint rules for React
60
+ reactX.configs['recommended-typescript'],
61
+ // Enable lint rules for React DOM
62
+ reactDom.configs.recommended,
63
+ ],
64
+ languageOptions: {
65
+ parserOptions: {
66
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
67
+ tsconfigRootDir: import.meta.dirname,
68
+ },
69
+ // other options...
70
+ },
71
+ },
72
+ ])
73
+ ```
frontend/eslint.config.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+ import { defineConfig, globalIgnores } from 'eslint/config'
7
+
8
+ export default defineConfig([
9
+ globalIgnores(['dist']),
10
+ {
11
+ files: ['**/*.{ts,tsx}'],
12
+ extends: [
13
+ js.configs.recommended,
14
+ tseslint.configs.recommended,
15
+ reactHooks.configs.flat.recommended,
16
+ reactRefresh.configs.vite,
17
+ ],
18
+ languageOptions: {
19
+ ecmaVersion: 2020,
20
+ globals: globals.browser,
21
+ },
22
+ },
23
+ ])
frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>frontend</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "react": "^19.2.0",
14
+ "react-dom": "^19.2.0"
15
+ },
16
+ "devDependencies": {
17
+ "@eslint/js": "^9.39.1",
18
+ "@types/node": "^24.10.1",
19
+ "@types/react": "^19.2.5",
20
+ "@types/react-dom": "^19.2.3",
21
+ "@vitejs/plugin-react": "^5.1.1",
22
+ "autoprefixer": "^10.4.23",
23
+ "eslint": "^9.39.1",
24
+ "eslint-plugin-react-hooks": "^7.0.1",
25
+ "eslint-plugin-react-refresh": "^0.4.24",
26
+ "globals": "^16.5.0",
27
+ "postcss": "^8.5.6",
28
+ "tailwindcss": "^4.1.18",
29
+ "typescript": "~5.9.3",
30
+ "typescript-eslint": "^8.46.4",
31
+ "vite": "^7.2.4"
32
+ }
33
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
frontend/public/vite.svg ADDED
frontend/src/App.tsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ChatInterface from './components/ChatInterface';
2
+
3
+ function App() {
4
+ return (
5
+ <div className="App w-full h-full bg-dstv-black">
6
+ <ChatInterface />
7
+ </div>
8
+ );
9
+ }
10
+
11
+ export default App;
frontend/src/assets/logo.jpeg ADDED
frontend/src/assets/react.svg ADDED
frontend/src/components/ChatInterface.tsx ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import logo from '../assets/logo.jpeg';
3
+
4
+ interface Message {
5
+ id: string;
6
+ role: 'user' | 'assistant';
7
+ content: string;
8
+ }
9
+
10
+ export default function ChatInterface() {
11
+ const [messages, setMessages] = useState<Message[]>([
12
+ { id: '1', role: 'assistant', content: 'Hello! I\'m your DStv guide. I can help with subscriptions, payments, technical issues, and more. How can I assist you today?' }
13
+ ]);
14
+ const [input, setInput] = useState('');
15
+ const [isLoading, setIsLoading] = useState(false);
16
+ const messagesEndRef = useRef<HTMLDivElement>(null);
17
+
18
+ const scrollToBottom = () => {
19
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
20
+ };
21
+
22
+ useEffect(() => {
23
+ scrollToBottom();
24
+ }, [messages]);
25
+
26
+ const sendMessage = async () => {
27
+ if (!input.trim()) return;
28
+
29
+ const userMsg: Message = { id: Date.now().toString(), role: 'user', content: input };
30
+ setMessages(prev => [...prev, userMsg]);
31
+ setInput('');
32
+ setIsLoading(true);
33
+
34
+ try {
35
+ const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8000'}/api/chat`, {
36
+ method: 'POST',
37
+ headers: {
38
+ 'Content-Type': 'application/json',
39
+ },
40
+ body: JSON.stringify({
41
+ messages: [...messages, userMsg].map(m => ({ role: m.role, content: m.content }))
42
+ }),
43
+ });
44
+
45
+ if (!response.ok) throw new Error('Network response was not ok');
46
+ if (!response.body) return;
47
+
48
+ const reader = response.body.getReader();
49
+ const decoder = new TextDecoder();
50
+ const aiMsgId = (Date.now() + 1).toString();
51
+
52
+ // Add empty AI message first
53
+ setMessages(prev => [...prev, { id: aiMsgId, role: 'assistant', content: '' }]);
54
+
55
+ let accumulatedContent = '';
56
+
57
+ while (true) {
58
+ const { done, value } = await reader.read();
59
+ if (done) break;
60
+
61
+ const chunk = decoder.decode(value, { stream: true });
62
+ accumulatedContent += chunk;
63
+
64
+ setMessages(prev => prev.map(msg =>
65
+ msg.id === aiMsgId ? { ...msg, content: accumulatedContent } : msg
66
+ ));
67
+ }
68
+ } catch (error) {
69
+ console.error('Error sending message:', error);
70
+ setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: "Sorry, I'm having trouble connecting to the server. Please try again later." }]);
71
+ } finally {
72
+ setIsLoading(false);
73
+ }
74
+ };
75
+
76
+ return (
77
+ <div className="flex flex-col h-screen bg-dstv-black text-white font-sans max-w-md mx-auto shadow-2xl overflow-hidden border-x border-dstv-dark-gray sm:max-w-2xl md:max-w-4xl lg:max-w-6xl xl:max-w-full">
78
+ {/* Header */}
79
+ <header className="bg-dstv-blue p-4 flex items-center shadow-lg z-10">
80
+ <img src={logo} alt="DStv Logo" className="h-8 md:h-10 mr-3 rounded-sm" />
81
+ <div>
82
+ <h1 className="font-bold text-lg md:text-xl tracking-wide">DStv Support</h1>
83
+ <span className="text-xs text-blue-100 opacity-90 block">Always here for you</span>
84
+ </div>
85
+ </header>
86
+
87
+ {/* Chat Area */}
88
+ <div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gradient-to-b from-dstv-black to-dstv-dark-gray backdrop-blur-sm">
89
+ {messages.map((msg) => (
90
+ <div
91
+ key={msg.id}
92
+ className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} animate-fadeIn`}
93
+ >
94
+ <div
95
+ className={`max-w-[80%] md:max-w-[70%] p-3 md:p-4 rounded-2xl text-sm md:text-base leading-relaxed shadow-sm ${msg.role === 'user'
96
+ ? 'bg-dstv-blue text-white rounded-br-none'
97
+ : 'bg-zinc-800 text-gray-100 rounded-bl-none border border-zinc-700'
98
+ }`}
99
+ >
100
+ {msg.content.split('\n').map((paragraph, idx) => (
101
+ <p key={idx} className={idx > 0 ? 'mt-3' : ''}>{paragraph}</p>
102
+ ))}
103
+ </div>
104
+ </div>
105
+ ))}
106
+ {isLoading && (
107
+ <div className="flex justify-start">
108
+ <div className="bg-zinc-800 p-3 rounded-2xl rounded-bl-none border border-zinc-700 flex space-x-2 items-center">
109
+ <div className="w-2 h-2 bg-dstv-blue rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
110
+ <div className="w-2 h-2 bg-dstv-blue rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
111
+ <div className="w-2 h-2 bg-dstv-blue rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
112
+ </div>
113
+ </div>
114
+ )}
115
+ <div ref={messagesEndRef} />
116
+ </div>
117
+
118
+ {/* Input Area */}
119
+ <div className="p-4 bg-dstv-dark-gray border-t border-zinc-800">
120
+ <div className="flex items-end space-x-2 bg-zinc-900 p-2 rounded-xl border border-zinc-700 focus-within:border-dstv-blue transition-colors">
121
+ <textarea
122
+ value={input}
123
+ onChange={(e) => setInput(e.target.value)}
124
+ onKeyDown={(e) => {
125
+ if (e.key === 'Enter' && !e.shiftKey) {
126
+ e.preventDefault();
127
+ sendMessage();
128
+ }
129
+ }}
130
+ placeholder="Type your message..."
131
+ className="flex-1 bg-transparent border-none text-white focus:ring-0 resize-none max-h-32 min-h-[44px] py-1 px-2 placeholder-zinc-500"
132
+ rows={1}
133
+ />
134
+ <button
135
+ onClick={sendMessage}
136
+ disabled={!input.trim() || isLoading}
137
+ className={`p-2 rounded-lg transition-all duration-200 ${input.trim() && !isLoading
138
+ ? 'bg-dstv-blue text-white hover:bg-blue-600 shadow-lg shadow-blue-500/20'
139
+ : 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
140
+ }`}
141
+ >
142
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-6 h-6 transform rotate-[-45deg] relative left-0.5 top-[-2px]">
143
+ <path d="M3.478 2.404a.75.75 0 00-.926.941l2.432 7.905H13.5a.75.75 0 010 1.5H4.984l-2.432 7.905a.75.75 0 00.926.94 60.519 60.519 0 0018.445-8.986.75.75 0 000-1.218A60.517 60.517 0 003.478 2.404z" />
144
+ </svg>
145
+ </button>
146
+ </div>
147
+ <div className="text-center mt-2">
148
+ <p className="text-[10px] text-zinc-500">AI responses may vary. Check DStv.com for official info.</p>
149
+ </div>
150
+ </div>
151
+ </div>
152
+ );
153
+ }
frontend/src/index.css ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ body {
7
+ @apply bg-dstv-black text-white font-sans;
8
+ }
9
+ }
10
+
11
+ @layer utilities {
12
+ .scrollbar-hide::-webkit-scrollbar {
13
+ display: none;
14
+ }
15
+ .scrollbar-hide {
16
+ -ms-overflow-style: none;
17
+ scrollbar-width: none;
18
+ }
19
+ }
frontend/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.tsx'
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
frontend/tailwind.config.js ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: [
4
+ "./index.html",
5
+ "./src/**/*.{js,ts,jsx,tsx}",
6
+ ],
7
+ theme: {
8
+ extend: {
9
+ colors: {
10
+ 'dstv-blue': '#1B8DC6',
11
+ 'dstv-black': '#000000',
12
+ 'dstv-dark-gray': '#121212',
13
+ 'dstv-light-gray': '#E5E5E5',
14
+ },
15
+ },
16
+ },
17
+ plugins: [],
18
+ }
frontend/tsconfig.app.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "ES2022",
5
+ "useDefineForClassFields": true,
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "types": ["vite/client"],
9
+ "skipLibCheck": true,
10
+
11
+ /* Bundler mode */
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "moduleDetection": "force",
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+
19
+ /* Linting */
20
+ "strict": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "erasableSyntaxOnly": true,
24
+ "noFallthroughCasesInSwitch": true,
25
+ "noUncheckedSideEffectImports": true
26
+ },
27
+ "include": ["src"]
28
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
frontend/tsconfig.node.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "ES2023",
5
+ "lib": ["ES2023"],
6
+ "module": "ESNext",
7
+ "types": ["node"],
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "erasableSyntaxOnly": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noUncheckedSideEffectImports": true
24
+ },
25
+ "include": ["vite.config.ts"]
26
+ }
frontend/vite.config.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // https://vite.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ })
logo.jpeg ADDED
requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ python-multipart
4
+ # Networking
5
+ requests
6
+ httpx
7
+ # AI / LLM
8
+ openai
9
+ # Database / Vector Store
10
+ chromadb
11
+ redis
12
+ # Scraper
13
+ beautifulsoup4
14
+ # Utils
15
+ pydantic-settings
16
+ python-dotenv
17
+ pypdf