Vivek0912 commited on
Commit
cce43bc
·
1 Parent(s): 007b730

added new code

Browse files
src/api_client.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API client for communicating with the backend service.
3
+ """
4
+ import requests
5
+ import logging
6
+ from typing import Dict, Any, Tuple, Optional, List
7
+
8
+
9
+ class APIClient:
10
+
11
+ def __init__(self, base_url: str, timeout: int = 60):
12
+ self.base_url = base_url.rstrip('/') # Remove trailing slash if present
13
+ self.timeout = timeout # Used only for health checks, not for query processing
14
+ self.logger = logging.getLogger(__name__)
15
+
16
+ # Define endpoints
17
+ self.endpoints = {
18
+ 'process_text': '/api/process-text',
19
+ 'health': '/api/health',
20
+ 'models': '/api/models',
21
+ 'change_model': '/api/change-model'
22
+ }
23
+
24
+ def send_query(self, question: str, model: str = None, agent: str = None) -> Dict[str, Any]:
25
+ """
26
+ Send a query to the backend with optional model specification
27
+
28
+ Args:
29
+ question: The user's question
30
+ model: Optional AI model name to use for processing
31
+ agent: Agent type (legacy parameter, not used in current backend)
32
+ """
33
+ # Prepare payload with model_name for the new API
34
+ payload = {"question": question}
35
+ if model:
36
+ payload["model_name"] = model
37
+
38
+ self.logger.info(f"Sending API request with: {payload}")
39
+
40
+ # Construct full URL
41
+ full_url = f"{self.base_url}{self.endpoints['process_text']}"
42
+
43
+ try:
44
+ # Remove timeout for query processing - wait for actual result
45
+ # Complex queries may take time and should not be interrupted
46
+ response = requests.post(full_url, json=payload)
47
+ return self._process_response(response)
48
+ except requests.exceptions.ConnectionError as e:
49
+ self.logger.error(f"Connection error: {e}")
50
+ return {
51
+ "message": "❌ Cannot connect to the server. Please check if the backend is running.",
52
+ "rows": [],
53
+ "chart": None,
54
+ "error": True
55
+ }
56
+ except requests.exceptions.RequestException as e:
57
+ self.logger.error(f"Request error: {e}")
58
+ return {
59
+ "message": f"❌ Request failed: {str(e)}",
60
+ "rows": [],
61
+ "chart": None,
62
+ "error": True
63
+ }
64
+ except Exception as e:
65
+ self.logger.error(f"Unexpected error: {e}")
66
+ return {
67
+ "message": f"⚠️ Exception: {str(e)}",
68
+ "rows": [],
69
+ "chart": None,
70
+ "error": True
71
+ }
72
+
73
+ def _process_response(self, response: requests.Response) -> Dict[str, Any]:
74
+ """Process the API response and extract relevant data."""
75
+ try:
76
+ result = response.json()
77
+ self.logger.info(f"API response received: {type(result)} with keys: {list(result.keys()) if isinstance(result, dict) else 'Not a dict'}")
78
+ except ValueError as e:
79
+ self.logger.error(f"Failed to parse JSON response: {e}")
80
+ result = {"detail": response.text}
81
+
82
+ if response.status_code != 200:
83
+ error_msg = result.get('detail') or result.get('message') or response.text
84
+ self.logger.error(f"API error response (status {response.status_code}): {error_msg}")
85
+ return {
86
+ "message": f"❌ Error: {error_msg}",
87
+ "rows": [],
88
+ "chart": None,
89
+ "error": True,
90
+ "model_used": result.get('model_used', 'unknown') if isinstance(result, dict) else 'unknown'
91
+ }
92
+
93
+ # Handle message-only responses (chat responses, errors, etc.)
94
+ if "message" in result and not ("rows" in result or "chart" in result):
95
+ self.logger.info("Processing message-only response")
96
+ return {
97
+ "message": result["message"],
98
+ "rows": [],
99
+ "chart": None,
100
+ "error": False,
101
+ "model_used": result.get("model_used", "unknown"),
102
+ "status": result.get("status", "message")
103
+ }
104
+
105
+ # Handle data responses (successful SQL queries)
106
+ rows = result.get("rows", [])
107
+ chart = result.get("chart", None)
108
+ heading = result.get("summary", "")
109
+ sql = result.get("sql", "")
110
+
111
+ self.logger.info(f"Processing data response - Rows: {len(rows) if isinstance(rows, list) else 'Not a list'}, Heading: {heading}, SQL length: {len(sql) if sql else 0}")
112
+
113
+ # Use heading from backend as-is (backend already parsed JSON and model includes record count)
114
+ if heading and isinstance(heading, str) and heading.strip():
115
+ message = heading.strip()
116
+ else:
117
+ # Fallback message if no heading provided
118
+ if rows and len(rows) > 0:
119
+ message = f"Here are the {len(rows)} results I found:"
120
+ else:
121
+ message = "I could not find matching records for your query."
122
+
123
+ processed_response = {
124
+ "message": message,
125
+ "rows": rows,
126
+ "chart": chart,
127
+ "error": False,
128
+ "model_used": result.get("model_used", "unknown"),
129
+ "status": result.get("status", "success"),
130
+ "sql": sql,
131
+ "heading": heading, # Clean heading from backend
132
+ "summary": result.get("summary", "") # Summary from backend if available
133
+ }
134
+
135
+ self.logger.info(f"Final processed response keys: {list(processed_response.keys())}")
136
+ return processed_response
137
+
138
+ def check_health(self) -> Tuple[str, str, str]:
139
+
140
+ try:
141
+ # Construct health URL
142
+ health_url = f"{self.base_url}{self.endpoints['health']}"
143
+ response = requests.get(health_url)
144
+ try:
145
+ result = response.json()
146
+ status = result.get("status", "")
147
+ except Exception:
148
+ result = {}
149
+ status = ""
150
+
151
+ if status == "healthy":
152
+ return "🟢 Active", "Online", "success"
153
+ elif response.status_code == 503:
154
+ return "🟡 Degraded", "Some Issues", "warning"
155
+ else:
156
+ return "🟡 Limited", f"Status: {response.status_code}", "warning"
157
+ except requests.exceptions.RequestException:
158
+ # Fallback to socket check
159
+ try:
160
+ import socket
161
+ # Extract host and port from base_url
162
+ from urllib.parse import urlparse
163
+ parsed = urlparse(self.base_url)
164
+ host = parsed.hostname or "127.0.0.1"
165
+ port = parsed.port or 8000
166
+ socket.create_connection((host, port), timeout=1).close()
167
+ return "🟡 Reachable", "Port Open", "warning"
168
+ except:
169
+ return "🔴 Offline", "Connection Failed", "error"
170
+ except Exception:
171
+ return "🟡 Unknown", "Check Required", "warning"
172
+
173
+ def get_detailed_health(self) -> Dict[str, Any]:
174
+ """
175
+ Get detailed health information from the API.
176
+
177
+ Returns:
178
+ Dictionary containing detailed health status or error information
179
+ """
180
+ try:
181
+ health_url = f"{self.base_url}{self.endpoints['health']}"
182
+ response = requests.get(health_url, timeout=5)
183
+
184
+ if response.status_code in [200, 503]:
185
+ return response.json()
186
+ else:
187
+ return {
188
+ "status": "error",
189
+ "message": f"Health endpoint returned status {response.status_code}",
190
+ "checks": {}
191
+ }
192
+
193
+ except requests.exceptions.RequestException as e:
194
+ return {
195
+ "status": "error",
196
+ "message": f"Connection failed: {str(e)}",
197
+ "checks": {}
198
+ }
199
+ except Exception as e:
200
+ return {
201
+ "status": "error",
202
+ "message": f"Health check failed: {str(e)}",
203
+ "checks": {}
204
+ }
205
+
206
+ def get(self, endpoint: str, params: Optional[Dict] = None) -> Optional[Dict[str, Any]]:
207
+ """
208
+ Generic GET method for API endpoints
209
+
210
+ Args:
211
+ endpoint: API endpoint path (e.g., "/models")
212
+ params: Optional query parameters
213
+
214
+ Returns:
215
+ API response as dictionary or None if failed
216
+ """
217
+ try:
218
+ url = f"{self.base_url}/api{endpoint}" if not endpoint.startswith('/api') else f"{self.base_url}{endpoint}"
219
+ response = requests.get(url, params=params, timeout=10)
220
+
221
+ if response.status_code == 200:
222
+ return response.json()
223
+ else:
224
+ self.logger.error(f"GET {endpoint} failed with status {response.status_code}")
225
+ return None
226
+
227
+ except Exception as e:
228
+ self.logger.error(f"GET {endpoint} error: {str(e)}")
229
+ return None
230
+
231
+ def post(self, endpoint: str, data: Optional[Dict] = None) -> Optional[Dict[str, Any]]:
232
+ """
233
+ Generic POST method for API endpoints
234
+
235
+ Args:
236
+ endpoint: API endpoint path
237
+ data: Optional POST data
238
+
239
+ Returns:
240
+ API response as dictionary or None if failed
241
+ """
242
+ try:
243
+ url = f"{self.base_url}/api{endpoint}" if not endpoint.startswith('/api') else f"{self.base_url}{endpoint}"
244
+ response = requests.post(url, json=data, timeout=10)
245
+
246
+ if response.status_code in [200, 201]:
247
+ return response.json()
248
+ else:
249
+ self.logger.error(f"POST {endpoint} failed with status {response.status_code}")
250
+ return None
251
+
252
+ except Exception as e:
253
+ self.logger.error(f"POST {endpoint} error: {str(e)}")
254
+ return None
src/chat_components.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chat components for rendering messages and handling user interactions.
3
+ """
4
+ import streamlit as st
5
+ import pandas as pd
6
+ import base64
7
+ from typing import List, Dict, Any
8
+ from config import OUTLINE_INDIGO_USER, DARK_MODE_SLATE_AI, RETRY_BUTTON_TEXT, DOWNLOAD_BUTTON_TEXT
9
+
10
+
11
+ class ChatRenderer:
12
+ """Handles rendering of chat messages and interactions."""
13
+
14
+ def __init__(self):
15
+ self.user_avatar = OUTLINE_INDIGO_USER
16
+ self.ai_avatar = DARK_MODE_SLATE_AI
17
+
18
+ def render_chat_history(self, messages: List[Dict[str, Any]]) -> None:
19
+ """Render the complete chat history."""
20
+ st.markdown("<div class='chat-container'>", unsafe_allow_html=True)
21
+
22
+ for i, msg in enumerate(messages):
23
+ if msg["role"] == "user":
24
+ self._render_user_message(msg, i, messages)
25
+ elif msg["role"] == "assistant":
26
+ # Skip error messages that are already shown after user messages
27
+ if not self._is_error_shown_after_user(i, messages):
28
+ self._render_assistant_message(msg)
29
+
30
+ st.markdown("</div>", unsafe_allow_html=True)
31
+
32
+ def _render_user_message(self, msg: Dict[str, Any], index: int, messages: List[Dict[str, Any]]) -> None:
33
+ """Render a user message with avatar."""
34
+ st.markdown(
35
+ f'''<div style="display: flex; align-items: flex-start; justify-content: flex-end; margin-bottom: 0.5em;">
36
+ <div style="margin-right: 0.5em;">
37
+ <img src="{self.user_avatar}" alt="User" style="width: 2.3rem; height: 2.3rem; border-radius: 50%; border: 2px solid #e3f2fd; background: #fff; object-fit: cover;" />
38
+ </div>
39
+ <div class="user-bubble">{msg["content"]}</div>
40
+ </div>''',
41
+ unsafe_allow_html=True
42
+ )
43
+
44
+ # Check if next message is an error and render retry option
45
+ self._render_error_retry_if_needed(index, messages)
46
+
47
+ def _render_error_retry_if_needed(self, index: int, messages: List[Dict[str, Any]]) -> None:
48
+ """Render error message and retry button if the next message is an error."""
49
+ if (
50
+ index + 1 < len(messages)
51
+ and messages[index + 1]["role"] == "assistant"
52
+ and messages[index + 1].get("is_error")
53
+ ):
54
+ error_msg = messages[index + 1]["content"]
55
+ cols = st.columns([0.3, 0.7])
56
+
57
+ with cols[1]:
58
+ col1, col2 = st.columns([0.8, 0.2])
59
+
60
+ with col1:
61
+ st.markdown(
62
+ f'<div style="background: #ffebee; color: #b71c1c; font-weight: bold; border-radius: 14px; padding: 8px 14px; max-width: 100%; word-wrap: break-word; box-shadow: 0 2px 8px rgba(0,0,0,0.15); text-align: right;">{error_msg}</div>',
63
+ unsafe_allow_html=True
64
+ )
65
+
66
+ with col2:
67
+ if st.button(RETRY_BUTTON_TEXT, key=f"retry_{index}"):
68
+ self._handle_retry(index)
69
+
70
+ def _handle_retry(self, index: int) -> None:
71
+ """Handle retry button click."""
72
+ messages = st.session_state["messages"]
73
+ st.session_state["messages"] = messages[:index + 1]
74
+ st.session_state["messages"].append({
75
+ "role": "assistant",
76
+ "content": "🤔 Thinking...",
77
+ "is_placeholder": True
78
+ })
79
+ st.rerun()
80
+
81
+ def _is_error_shown_after_user(self, index: int, messages: List[Dict[str, Any]]) -> bool:
82
+ """Check if this error message is already shown after the previous user message."""
83
+ if not messages[index].get("is_error"):
84
+ return False
85
+
86
+ # Check if this is an error that follows a user message
87
+ if index > 0 and messages[index - 1]["role"] == "user":
88
+ return True
89
+
90
+ return False
91
+
92
+ def _render_assistant_message(self, msg: Dict[str, Any]) -> None:
93
+ """Render an assistant message with avatar and optional data/charts."""
94
+ bubble_class = "error-bubble" if msg.get("is_error") else "ai-bubble"
95
+
96
+ # Render message bubble
97
+ if msg.get("is_placeholder"):
98
+ self._render_thinking_message(bubble_class)
99
+ else:
100
+ self._render_regular_message(msg["content"], bubble_class)
101
+
102
+ # Render data table if present
103
+ if msg.get("data"):
104
+ self._render_data_table(msg["data"], msg)
105
+
106
+ # Render chart if present
107
+ if msg.get("chart"):
108
+ self._render_chart(msg["chart"])
109
+
110
+ def _render_thinking_message(self, bubble_class: str) -> None:
111
+ """Render a thinking/loading message with spinner."""
112
+ st.markdown(
113
+ f'''<div style="display: flex; align-items: flex-start; margin-bottom: 0.5em;">
114
+ <div style="margin-right: 0.5em;">
115
+ <img src="{self.ai_avatar}" alt="AI" style="width: 2.3rem; height: 2.3rem; border-radius: 50%; border: 2px solid #b2dfdb; background: #fff; object-fit: cover;" />
116
+ </div>
117
+ <div class="{bubble_class}"><span class="spinner"></span>Thinking...</div>
118
+ </div>''',
119
+ unsafe_allow_html=True
120
+ )
121
+
122
+ def _render_regular_message(self, content: str, bubble_class: str) -> None:
123
+ """Render a regular assistant message."""
124
+ st.markdown(
125
+ f'''<div style="display: flex; align-items: flex-start; margin-bottom: 0.5em;">
126
+ <div style="margin-right: 0.5em;">
127
+ <img src="{self.ai_avatar}" alt="AI" style="width: 2.3rem; height: 2.3rem; border-radius: 50%; border: 2px solid #b2dfdb; background: #fff; object-fit: cover;" />
128
+ </div>
129
+ <div class="{bubble_class}">{content}</div>
130
+ </div>''',
131
+ unsafe_allow_html=True
132
+ )
133
+
134
+ def _render_data_table(self, data: List[Dict], msg: Dict[str, Any]) -> None:
135
+ """Render data table with download option."""
136
+ df = pd.DataFrame(data)
137
+ st.dataframe(df, use_container_width=True)
138
+
139
+ csv = df.to_csv(index=False).encode("utf-8")
140
+ st.download_button(
141
+ DOWNLOAD_BUTTON_TEXT,
142
+ csv,
143
+ "results.csv",
144
+ "text/csv",
145
+ key=f"download_csv_{id(msg)}"
146
+ )
147
+
148
+ def _render_chart(self, chart_data: str) -> None:
149
+ """Render chart from base64 data."""
150
+ img_data = base64.b64decode(chart_data)
151
+ st.image(img_data, use_column_width=True)
src/chat_ui.py CHANGED
@@ -1,315 +1,981 @@
1
-
 
 
2
  import streamlit as st
3
- import requests
4
  import pandas as pd
5
  import base64
6
  import logging
7
- import os
8
-
 
 
 
 
 
 
9
 
10
  # ===============================
11
- # Config: Dynamic API_URL based on environment
12
  # ===============================
 
 
13
 
14
- # Read API base URL and endpoint from environment variables
15
- api_base_url = os.getenv("API_BASE_URL", "http://127.0.0.1:8000")
16
- api_endpoint = os.getenv("API_ENDPOINT", "/api/process-text")
17
- # Ensure no double slashes
18
- API_URL = api_base_url.rstrip("/") + "/" + api_endpoint.lstrip("/")
19
-
20
- st.set_page_config(page_title="AI Database Assistant", layout="wide")
21
-
22
- # Show current API URL in sidebar for debugging
23
- # st.sidebar.info(f"**API URL:** {API_URL}")
24
-
25
- # Setup logger
26
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
27
 
28
- st.title("💬 AI Database Assistant")
29
- st.write("Ask questions in plain English to generate and run SQL queries.")
30
- # st.markdown(
31
- # "<h1 style='text-align: center; color: white; background: rgba(0,0,0,0.6); "
32
- # "padding: 12px; border-radius: 10px;'>🤖 AI Database Assistant</h1>",
33
- # unsafe_allow_html=True,
34
- # )
35
- # st.write("Ask questions in plain English to generate and run SQL queries.")
36
 
37
  # ===============================
38
- # Sidebar Settings
39
  # ===============================
40
- st.sidebar.header("⚙️ Settings")
41
-
42
- model = st.sidebar.selectbox("Select Model", ["gpt-3.5-turbo", "gpt-4", "gpt-4o-mini"])
43
- agent = st.sidebar.selectbox("Select Agent", ["default", "sql-agent", "custom-agent"])
44
- theme = st.sidebar.selectbox("Theme", ["Light", "Dark", "Custom"])
45
 
46
  # ===============================
47
- # Theme CSS
48
  # ===============================
49
- def inject_css(theme_choice: str):
50
- if theme_choice == "Light":
51
- css = """
52
- body, .stApp { background: #f8fafc !important; }
53
- .chat-container { max-width: 900px; margin: auto; }
54
- .user-bubble {
55
- background: linear-gradient(90deg, #e3f2fd, #ffffff);
56
- color: #222;
57
- float: right;
58
- clear: both;
59
- }
60
- .ai-bubble {
61
- background: linear-gradient(90deg, #f3e5f5, #ffffff);
62
- color: #222;
63
- float: left;
64
- clear: both;
65
- }
66
- .error-bubble {
67
- background: #ffebee; color: #b71c1c; font-weight: bold;
68
- float: left; clear: both;
69
- }
70
- section[data-testid="stSidebar"] { background: #e3f2fd !important; }
71
- """
72
- elif theme_choice == "Dark":
73
- css = """
74
- body, .stApp { background: #181825 !important; }
75
- .chat-container { max-width: 900px; margin: auto; }
76
- .user-bubble {
77
- background: linear-gradient(135deg, #3a0ca3, #4361ee);
78
- color: #f8fafc;
79
- float: right;
80
- clear: both;
81
- }
82
- .ai-bubble {
83
- background: linear-gradient(135deg, #14213d, #1d3557);
84
- color: #f8fafc;
85
- float: left;
86
- clear: both;
87
- }
88
- .error-bubble {
89
- background: #ffb4ab; color: #b71c1c; font-weight: bold;
90
- float: left; clear: both;
91
- }
92
- section[data-testid="stSidebar"] { background: #232946 !important; }
93
- """
94
- else: # Custom theme
95
- css = """
96
- body, .stApp { background: #fffbe7 !important; }
97
- .chat-container { max-width: 900px; margin: auto; }
98
- .user-bubble {
99
- background: linear-gradient(90deg, #ffe082, #fffbe7);
100
- color: #6d4c00;
101
- float: right;
102
- clear: both;
103
- }
104
- .ai-bubble {
105
- background: linear-gradient(90deg, #b2dfdb, #fffbe7);
106
- color: #004d40;
107
- float: left;
108
- clear: both;
109
- }
110
- .error-bubble {
111
- background: #ffe0b2; color: #b71c1c; font-weight: bold;
112
- float: left; clear: both;
113
  }
114
- section[data-testid="stSidebar"] { background: #ffe082 !important; }
115
- """
116
 
117
- # Common bubble styling
118
- css += """
119
- .user-bubble, .ai-bubble, .error-bubble {
120
- border-radius: 14px;
121
- padding: 10px 14px;
122
- margin: 6px 0;
123
- display: inline-block;
124
- max-width: 75%;
125
- word-wrap: break-word;
126
- box-shadow: 0 2px 8px rgba(0,0,0,0.25);
127
- }
128
- """
129
- st.markdown(f"<style>{css}</style>", unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
- inject_css(theme)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
 
133
  # ===============================
134
- # Avatar Setup
135
  # ===============================
136
- # Default base64 encoded avatars
137
- DEFAULT_USER_AVATAR = """
138
- data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6bTAgM2MyLjY3IDAgOC0xLjMzIDgtM3Y0LjE1YzAgMS42My0zLjMzIDIuODUtOCAyLjg1cy04LTEuMjItOC0yLjg1VjJjMCAxLjY3IDUuMzMgMyA4IDN6bTAgMTFjLTIuNjcgMC04LTEuMzMtOC0zdjQuMTVjMCAxLjYzIDMuMzMgMi44NSA4IDIuODVzOC0xLjIyIDgtMi44NVYxM2MwIDEuNjctNS4zMyAzLTggM3oiIGZpbGw9IiM0Q0FGNTAiLz48L3N2Zz4=
139
- """
140
 
141
- DEFAULT_AI_AVATAR = """
142
- data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6bS0xIDE2LjkydjEuMDhjMCAuNTUuNDUgMSAxIDFoMmMuNTUgMCAxLS40NSAxLTF2LTEuMDhjLS42My4xMy0xLjI5LjItMS45Ny4yLS43IDAtMS4zNC0uMDctMS45Ny0uMnptNS43OC0yLjc0QzE1LjI0IDE3LjM0IDEzLjY3IDE4IDEyIDE4cy0zLjI0LS42Ni00Ljc4LTEuODJDNC40NCAxNC4zNCAzIDEyLjA0IDMgOS41IDMgNS45MSA1LjkxIDMgOS41IDNoNUM2LjY1IDMgNCA0LjY1IDQgNi43MWMwIDIuMDYgMS42NyAzLjc0IDMuNzMgMy43NC4zNiAwIC43MS0uMDUgMS4wNS0uMTUgMS4xIDEuMTEgMi41NyAxLjc5IDQuMjIgMS43OSAxLjY0IDAgMy4xMi0uNjkgNC4yMi0xLjc5LjMzLjEuNjkuMTUgMS4wNS4xNSAyLjA2IDAgMy43My0xLjY4IDMuNzMtMy43NCAwLTIuMDYtMS42Ny0zLjcxLTMuNzMtMy43MWgtNUM3LjkxIDMgNSA1LjkxIDUgOS41YzAgMi41NCAxLjQ0IDQuODQgMy43OCA2LjY4eiIgZmlsbD0iIzI5NzlGRiIvPjwvc3ZnPg==
143
- """
 
 
 
 
 
 
 
144
 
145
  # ===============================
146
- # Session State
147
  # ===============================
148
- if "messages" not in st.session_state:
149
- st.session_state["messages"] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
 
151
  # ===============================
152
- # Helper: Render Chat History
153
  # ===============================
154
  def render_chat():
 
155
  st.markdown("<div class='chat-container'>", unsafe_allow_html=True)
156
- for msg in st.session_state["messages"]:
 
 
157
  if msg["role"] == "user":
158
- st.markdown(f'<div class="user-bubble">{msg["content"]}</div>', unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
159
  elif msg["role"] == "assistant":
 
160
  bubble_class = "error-bubble" if msg.get("is_error") else "ai-bubble"
161
- st.markdown(f'<div class="{bubble_class}">{msg["content"]}</div>', unsafe_allow_html=True)
162
-
163
- if msg.get("data"):
164
- df = pd.DataFrame(msg["data"])
165
- st.dataframe(df, use_container_width=True)
166
-
167
- # Add download button for results
168
- csv = df.to_csv(index=False).encode("utf-8")
169
- st.download_button("📥 Download CSV", csv, "results.csv", "text/csv", key=f"download_csv_{id(msg)}")
170
-
171
- if msg.get("chart"):
172
- img_data = base64.b64decode(msg["chart"])
173
- st.image(img_data, use_column_width=True)
174
- st.markdown("</div>", unsafe_allow_html=True)
175
-
176
-
177
- def render_chat():
178
- # Spinner CSS (only inject once)
179
- st.markdown('''
180
- <style>
181
- .spinner {
182
- display: inline-block;
183
- width: 1.3em;
184
- height: 1.3em;
185
- border: 3px solid #e0e0e0;
186
- border-top: 3px solid #2193b0;
187
- border-radius: 50%;
188
- animation: spin 0.8s linear infinite;
189
- margin-right: 0.7em;
190
- }
191
- @keyframes spin {
192
- 0% { transform: rotate(0deg); }
193
- 100% { transform: rotate(360deg); }
194
- }
195
- </style>
196
- ''', unsafe_allow_html=True)
197
- st.markdown("<div class='chat-container'>", unsafe_allow_html=True)
198
- user_avatar = DEFAULT_USER_AVATAR
199
- ai_avatar = DEFAULT_AI_AVATAR
200
- for msg in st.session_state["messages"]:
201
- if msg["role"] == "user":
202
- st.markdown(
203
- f'''<div style="display: flex; align-items: flex-start; justify-content: flex-end; margin-bottom: 0.5em;">
204
- <div style="margin-right: 0.5em;">
205
- <img src="{user_avatar}" alt="User" style="width: 2.3rem; height: 2.3rem; border-radius: 50%; border: 2px solid #e3f2fd; background: #fff; object-fit: cover;" />
206
- </div>
207
- <div class="user-bubble">{msg["content"]}</div>
208
- </div>''',
209
- unsafe_allow_html=True)
210
- elif msg["role"] == "assistant":
211
- bubble_class = "error-bubble" if msg.get("is_error") else "ai-bubble"
212
- # Show spinner if this is a placeholder 'Thinking...' message
213
- if msg.get("is_placeholder"):
214
  st.markdown(
215
  f'''<div style="display: flex; align-items: flex-start; margin-bottom: 0.5em;">
216
  <div style="margin-right: 0.5em;">
217
- <img src="{ai_avatar_url}" alt="AI" style="width: 2.3rem; height: 2.3rem; border-radius: 50%; border: 2px solid #b2dfdb; background: #fff; object-fit: cover;" />
218
  </div>
219
- <div class="{bubble_class}"><span class="spinner"></span>Thinking...</div>
220
  </div>''',
221
  unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  else:
 
223
  st.markdown(
224
  f'''<div style="display: flex; align-items: flex-start; margin-bottom: 0.5em;">
225
  <div style="margin-right: 0.5em;">
226
- <img src="{ai_avatar_url}" alt="AI" style="width: 2.3rem; height: 2.3rem; border-radius: 50%; border: 2px solid #b2dfdb; background: #fff; object-fit: cover;" />
227
  </div>
228
  <div class="{bubble_class}">{msg["content"]}</div>
229
  </div>''',
230
  unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
 
232
- if msg.get("data"):
233
- df = pd.DataFrame(msg["data"])
234
- st.dataframe(df, use_container_width=True)
235
-
236
- # Add download button for results
237
- csv = df.to_csv(index=False).encode("utf-8")
238
- st.download_button("\U0001F4C5 Download CSV", csv, "results.csv", "text/csv", key=f"download_csv_{id(msg)}")
239
-
240
- if msg.get("chart"):
241
- img_data = base64.b64decode(msg["chart"])
242
- st.image(img_data, use_column_width=True)
243
- st.markdown("</div>", unsafe_allow_html=True)
244
-
245
  render_chat()
246
 
247
  # ===============================
248
- # User Input
249
  # ===============================
250
-
251
- # Disable chat input while AI is working
252
  pending = False
253
- if st.session_state["messages"]: # Check if there are any messages
254
  if st.session_state["messages"][-1]["role"] == "assistant":
255
  pending = st.session_state["messages"][-1].get("is_placeholder", False)
256
 
257
- user_query = st.chat_input(
258
- "Ask me something about your database...",
259
- disabled=pending or False # Ensure disabled is always boolean
260
- )
261
  if user_query and not pending:
262
  st.session_state["messages"].append({"role": "user", "content": user_query})
263
- st.session_state["messages"].append({"role": "assistant", "content": "🤔 Thinking...", "is_placeholder": True})
 
 
 
 
 
264
  st.rerun()
265
 
266
  # ===============================
267
- # Handle Pending Assistant Response
268
  # ===============================
269
- if (
 
 
270
  st.session_state["messages"]
271
  and st.session_state["messages"][-1].get("is_placeholder")
272
  and len(st.session_state["messages"]) >= 2
273
  and st.session_state["messages"][-2]["role"] == "user"
274
- ):
275
- user_query = st.session_state["messages"][-2]["content"]
276
-
277
- try:
278
- with st.spinner("Working..."):
279
- payload = {"question": user_query, "model": model, "agent": agent}
280
- logging.info(f"Sending API request with: {payload}")
281
- response = requests.post(API_URL, json=payload, timeout=60)
282
 
283
- try:
284
- result = response.json()
285
- except ValueError:
286
- result = {"detail": response.text}
 
 
287
 
288
- if response.status_code != 200:
289
- answer_text = f" Error: {result.get('detail') or result.get('message') or response.text}"
290
- rows, chart, is_error = [], None, True
291
- elif "message" in result and not ("rows" in result or "chart" in result):
292
- answer_text, rows, chart, is_error = result["message"], [], None, False
293
- else:
294
- rows = result.get("rows", [])
295
- chart = result.get("chart", None)
296
- if rows:
297
- answer_text = f"Here are the top {len(rows)} results I found:"
298
- else:
299
- answer_text = "I could not find matching records for your query."
300
- is_error = False
301
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  except Exception as e:
303
  logging.error(f"Exception: {e}")
304
  answer_text, rows, chart, is_error = f"⚠️ Exception: {str(e)}", [], None, True
305
-
306
- # Replace placeholder with final assistant message
 
 
 
 
 
 
 
 
 
 
 
307
  st.session_state["messages"][-1] = {
308
  "role": "assistant",
309
  "content": answer_text,
310
  "data": rows,
311
  "chart": chart,
312
  "is_error": is_error,
 
 
 
 
313
  }
314
-
315
  st.rerun()
 
1
+ """
2
+ AI Database Assistant - Streamlit Chat Interface
3
+ """
4
  import streamlit as st
 
5
  import pandas as pd
6
  import base64
7
  import logging
8
+ from typing import List, Dict, Any
9
+ from api_client import APIClient
10
+ from themes import ThemeManager
11
+ from config import (
12
+ BASE_URL, PAGE_TITLE, PAGE_LAYOUT, AVAILABLE_MODELS, AVAILABLE_AGENTS,
13
+ OUTLINE_INDIGO_USER, DARK_MODE_SLATE_AI, AVAILABLE_THEMES,
14
+ CHAT_INPUT_PLACEHOLDER, THINKING_MESSAGE, WORKING_MESSAGE, RETRY_BUTTON_TEXT, DOWNLOAD_BUTTON_TEXT
15
+ )
16
 
17
  # ===============================
18
+ # Configuration
19
  # ===============================
20
+ # Configuration is now imported from config.py
21
+ # To change environments, only modify BASE_URL in config.py
22
 
23
+ # Setup
24
+ st.set_page_config(page_title=PAGE_TITLE, layout=PAGE_LAYOUT)
 
 
 
 
 
 
 
 
 
 
25
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
26
 
27
+ # Initialize API Client with base URL from config
28
+ api_client = APIClient(BASE_URL)
 
 
 
 
 
 
29
 
30
  # ===============================
31
+ # Theme Management
32
  # ===============================
33
+ # Initialize theme manager
34
+ theme_manager = ThemeManager()
 
 
 
35
 
36
  # ===============================
37
+ # Database Status Functions
38
  # ===============================
39
+ @st.cache_data(ttl=30) # Reduced cache time for more responsive health checks
40
+ def check_api_status():
41
+ """Check if the API is reachable and responsive with a lightweight health check."""
42
+ try:
43
+ return api_client.check_health()
44
+ except Exception as e:
45
+ logging.error(f"Health check failed: {e}")
46
+ return "🔴 Error", f"Failed: {str(e)}", "error"
47
+
48
+ @st.cache_data(ttl=30) # Reduced cache time for detailed health
49
+ def get_detailed_health_status():
50
+ """Get detailed health status from the API."""
51
+ try:
52
+ return api_client.get_detailed_health()
53
+ except Exception as e:
54
+ logging.error(f"Detailed health check failed: {e}")
55
+ return {
56
+ "status": "error",
57
+ "message": f"Health check failed: {str(e)}",
58
+ "checks": {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  }
 
 
60
 
61
+ def should_skip_api_call(force_refresh=False):
62
+ """Enhanced validation to determine if API calls should be skipped - FOR HEALTH CHECKS ONLY."""
63
+ try:
64
+ # Always allow API calls when force refresh is requested
65
+ if force_refresh:
66
+ logging.info("API call allowed: Force refresh requested")
67
+ return False
68
+
69
+ # Check for immediate health check flag (when System Status is just enabled)
70
+ if st.session_state.get("force_immediate_health_check", False):
71
+ logging.info("API call allowed: System Status just enabled")
72
+ # Clear the flag after use
73
+ st.session_state["force_immediate_health_check"] = False
74
+ return False
75
+
76
+ # Skip if currently processing a query (to prevent concurrent calls)
77
+ if st.session_state.get("processing_query", False):
78
+ logging.info("API call skipped: Query processing in progress")
79
+ return True
80
+
81
+ # Check if System Status section is enabled - only call API if it's visible
82
+ if not st.session_state.get("sidebar_settings", {}).get("show_system_status", False):
83
+ logging.info("API call skipped: System Status section is disabled")
84
+ return True
85
+
86
+ # Block API calls if ANY UI interaction happened in last 5 seconds (FOR HEALTH CHECKS)
87
+ if "recent_ui_action" in st.session_state:
88
+ current_time = pd.Timestamp.now().timestamp()
89
+ time_since_action = current_time - st.session_state.get("recent_ui_action", 0)
90
+ if time_since_action < 5: # 5 seconds protection
91
+ logging.info(f"API call blocked: UI interaction {time_since_action:.1f}s ago")
92
+ return True
93
+
94
+ # Check rate limiting for regular health checks
95
+ current_time = pd.Timestamp.now().timestamp()
96
+ last_update = st.session_state.get("last_status_update", 0)
97
+
98
+ # Allow call if we don't have cached status
99
+ if "last_api_status" not in st.session_state:
100
+ logging.info("API call allowed: No cached status available")
101
+ return False
102
+
103
+ # Otherwise, respect the rate limit
104
+ time_since_update = current_time - last_update
105
+ if time_since_update < 30: # 30 seconds rate limit
106
+ logging.info(f"API call skipped: Rate limit (updated {time_since_update:.1f}s ago)")
107
+ return True
108
+
109
+ logging.info("API call allowed: Rate limit passed")
110
+ return False
111
+ except Exception as e:
112
+ logging.error(f"Error in should_skip_api_call: {e}")
113
+ return False # Allow API call on error (fail safe)
114
 
115
+ def should_skip_query_processing():
116
+ """Determine if query processing should be skipped - FOR QUERY PROCESSING ONLY."""
117
+ try:
118
+ # Never skip if we have a legitimate query
119
+ if "legitimate_query_time" in st.session_state:
120
+ current_time = pd.Timestamp.now().timestamp()
121
+ time_since_query = current_time - st.session_state.get("legitimate_query_time", 0)
122
+ if time_since_query < 10: # Allow legitimate queries within 10 seconds
123
+ logging.info(f"Query processing allowed: Legitimate query {time_since_query:.1f}s ago")
124
+ return False
125
+
126
+ # Skip if already processing a query (to prevent concurrent calls)
127
+ if st.session_state.get("processing_query", False):
128
+ logging.info("Query processing skipped: Already processing a query")
129
+ return True
130
+
131
+ # Block if recent UI action but no legitimate query flag
132
+ if "recent_ui_action" in st.session_state and "legitimate_query_time" not in st.session_state:
133
+ current_time = pd.Timestamp.now().timestamp()
134
+ time_since_action = current_time - st.session_state.get("recent_ui_action", 0)
135
+ if time_since_action < 5: # 5 seconds protection
136
+ logging.info(f"Query processing blocked: UI interaction {time_since_action:.1f}s ago without legitimate query")
137
+ return True
138
+
139
+ logging.info("Query processing allowed: No blocking conditions")
140
+ return False
141
+ except Exception as e:
142
+ logging.error(f"Error in should_skip_query_processing: {e}")
143
+ return False # Allow processing on error (fail safe)
144
+
145
+ def silent_status_update():
146
+ """Update status silently without UI disruption."""
147
+ try:
148
+ # Use the validation function
149
+ if should_skip_api_call():
150
+ return
151
+
152
+ # Clear cache and update timestamp
153
+ st.cache_data.clear()
154
+ st.session_state["last_status_update"] = pd.Timestamp.now().timestamp()
155
+ except:
156
+ pass # Silent failure
157
+
158
+ def get_query_stats():
159
+ """Get query statistics from session state."""
160
+ if "query_stats" not in st.session_state:
161
+ st.session_state["query_stats"] = {
162
+ "total_queries": 0,
163
+ "successful_queries": 0,
164
+ "failed_queries": 0,
165
+ "session_start": pd.Timestamp.now()
166
+ }
167
+
168
+ stats = st.session_state["query_stats"]
169
+ success_rate = 0
170
+ if stats["total_queries"] > 0:
171
+ success_rate = round((stats["successful_queries"] / stats["total_queries"]) * 100, 1)
172
+
173
+ return stats["total_queries"], success_rate, stats["successful_queries"], stats["failed_queries"]
174
+
175
+ def update_query_stats(success=True):
176
+ """Update query statistics."""
177
+ if "query_stats" not in st.session_state:
178
+ st.session_state["query_stats"] = {
179
+ "total_queries": 0,
180
+ "successful_queries": 0,
181
+ "failed_queries": 0,
182
+ "session_start": pd.Timestamp.now()
183
+ }
184
+
185
+ st.session_state["query_stats"]["total_queries"] += 1
186
+ if success:
187
+ st.session_state["query_stats"]["successful_queries"] += 1
188
+ else:
189
+ st.session_state["query_stats"]["failed_queries"] += 1
190
 
191
  # ===============================
192
+ # Session State Management
193
  # ===============================
194
+ if "messages" not in st.session_state:
195
+ st.session_state["messages"] = []
 
 
196
 
197
+ if "query_stats" not in st.session_state:
198
+ st.session_state["query_stats"] = {
199
+ "total_queries": 0,
200
+ "successful_queries": 0,
201
+ "failed_queries": 0,
202
+ "session_start": pd.Timestamp.now()
203
+ }
204
+
205
+ if "processing_query" not in st.session_state:
206
+ st.session_state["processing_query"] = False
207
 
208
  # ===============================
209
+ # Main App Setup
210
  # ===============================
211
+ # Professional header positioned at top-left of chat area
212
+ st.markdown(f"""
213
+ <div style=" display: flex; align-items: center; padding-left: 0.5rem;">
214
+ <h2 style="margin: 0; font-size: 1.5rem; font-weight: 600; color: inherit;">
215
+ 💬 {PAGE_TITLE}
216
+ </h2>
217
+ </div>
218
+ """, unsafe_allow_html=True)
219
+
220
+ st.markdown("""
221
+ <div style=" padding-left: 0.5rem;">
222
+ <p style="margin: 0; font-size: 0.9rem; opacity: 0.7; color: inherit;">
223
+ Ask questions in plain English to generate and run SQL queries.
224
+ </p>
225
+ </div>
226
+ """, unsafe_allow_html=True)
227
+
228
+
229
+ # Sidebar
230
+ with st.sidebar:
231
+ # Quick status indicator at the top
232
+ if "sidebar_settings" in st.session_state:
233
+ visible_count = sum([
234
+ st.session_state["sidebar_settings"]["show_model_selection"],
235
+ st.session_state["sidebar_settings"]["show_agent_selection"],
236
+ st.session_state["sidebar_settings"]["show_theme_selection"],
237
+ st.session_state["sidebar_settings"]["show_system_status"],
238
+ st.session_state["sidebar_settings"]["show_tips"]
239
+ ])
240
+
241
+ if visible_count == 5:
242
+ st.caption("🟢 All sections visible")
243
+ elif visible_count > 0:
244
+ st.caption(f"🟡 {visible_count}/5 sections visible")
245
+ else:
246
+ st.caption("🔴 No sections visible")
247
+
248
+ # Sidebar Display Settings - User Configurable
249
+ # Initialize settings state tracking
250
+ if "settings_interaction_count" not in st.session_state:
251
+ st.session_state["settings_interaction_count"] = 0
252
+
253
+ # Track if user has recently interacted with settings
254
+ keep_expanded = st.session_state.get("settings_interaction_count", 0) > 0
255
+
256
+ # Create expander that stays open for a few interactions
257
+ settings_expander = st.expander(
258
+ "⚙️ Sidebar Settings",
259
+ expanded=keep_expanded
260
+ )
261
+
262
+ with settings_expander:
263
+ st.markdown("**Choose what to display:**")
264
+
265
+ # Initialize sidebar visibility settings in session state with new defaults
266
+ if "sidebar_settings" not in st.session_state:
267
+ st.session_state["sidebar_settings"] = {
268
+ "show_model_selection": True, # Default unchecked
269
+ "show_agent_selection": False, # Default unchecked
270
+ "show_theme_selection": True,
271
+ "show_system_status": False, # Default unchecked - only load health checks when enabled
272
+ "show_tips": True
273
+ }
274
+
275
+ # Store previous values to detect changes
276
+ prev_settings = st.session_state["sidebar_settings"].copy()
277
+
278
+ # Configurable checkboxes
279
+ col1, col2 = st.columns(2)
280
+ with col1:
281
+ show_model = st.checkbox(
282
+ "🤖 AI Model",
283
+ value=st.session_state["sidebar_settings"]["show_model_selection"],
284
+ help="Show/hide AI model selection",
285
+ key="settings_model"
286
+ )
287
+ show_agent = st.checkbox(
288
+ "🎯 Agent Type",
289
+ value=st.session_state["sidebar_settings"]["show_agent_selection"],
290
+ help="Show/hide agent selection",
291
+ key="settings_agent"
292
+ )
293
+ show_theme = st.checkbox(
294
+ "🎨 Theme",
295
+ value=st.session_state["sidebar_settings"]["show_theme_selection"],
296
+ help="Show/hide theme selection",
297
+ key="settings_theme"
298
+ )
299
+
300
+ with col2:
301
+ show_status = st.checkbox(
302
+ "📊 System Status",
303
+ value=st.session_state["sidebar_settings"]["show_system_status"],
304
+ help="Show/hide system status",
305
+ key="settings_status"
306
+ )
307
+ show_tips = st.checkbox(
308
+ "💡 Tips & Help",
309
+ value=st.session_state["sidebar_settings"]["show_tips"],
310
+ help="Show/hide tips section",
311
+ key="settings_tips"
312
+ )
313
+
314
+ # Detect if any setting changed
315
+ new_settings = {
316
+ "show_model_selection": show_model,
317
+ "show_agent_selection": show_agent,
318
+ "show_theme_selection": show_theme,
319
+ "show_system_status": show_status,
320
+ "show_tips": show_tips
321
+ }
322
+
323
+ # Check if settings changed
324
+ settings_changed = any(
325
+ prev_settings.get(key) != new_settings[key]
326
+ for key in new_settings.keys()
327
+ )
328
+
329
+ # Special handling for System Status being enabled
330
+ status_just_enabled = (
331
+ not prev_settings.get("show_system_status", False) and
332
+ new_settings["show_system_status"]
333
+ )
334
+
335
+ # Update session state
336
+ st.session_state["sidebar_settings"].update(new_settings)
337
+
338
+ # Increment interaction count when settings change
339
+ if settings_changed:
340
+ st.session_state["settings_interaction_count"] += 1
341
+ # Mark UI action to prevent unnecessary API calls for most settings
342
+ if not status_just_enabled: # Don't block API calls when System Status is enabled
343
+ st.session_state["recent_ui_action"] = pd.Timestamp.now().timestamp()
344
+ # Reset counter after 10 interactions to prevent it growing indefinitely
345
+ if st.session_state["settings_interaction_count"] > 10:
346
+ st.session_state["settings_interaction_count"] = 5
347
+
348
+ # If System Status was just enabled, clear cache and allow immediate health check
349
+ if status_just_enabled:
350
+ st.cache_data.clear()
351
+ # Clear any cached status to force fresh call
352
+ for key in ["last_api_status", "last_api_delta", "last_api_type", "last_detailed_health"]:
353
+ if key in st.session_state:
354
+ del st.session_state[key]
355
+ st.session_state["last_status_update"] = 0
356
+ # Set flag to trigger immediate health check
357
+ st.session_state["force_immediate_health_check"] = True
358
+ # Remove any recent UI action timestamp to allow API call
359
+ if "recent_ui_action" in st.session_state:
360
+ del st.session_state["recent_ui_action"]
361
+
362
+ # Show current settings status
363
+ visible_count = sum([show_model, show_agent, show_theme, show_status, show_tips])
364
+
365
+ if visible_count == 5:
366
+ st.success(f"✅ All {visible_count} sections visible")
367
+ elif visible_count > 0:
368
+ st.info(f"ℹ️ {visible_count}/5 sections visible")
369
+ else:
370
+ st.warning("⚠️ No sections visible")
371
+
372
+ # Use the current session state values for conditional rendering
373
+ show_model = st.session_state["sidebar_settings"]["show_model_selection"]
374
+ show_agent = st.session_state["sidebar_settings"]["show_agent_selection"]
375
+ show_theme = st.session_state["sidebar_settings"]["show_theme_selection"]
376
+ show_status = st.session_state["sidebar_settings"]["show_system_status"]
377
+ show_tips = st.session_state["sidebar_settings"]["show_tips"]
378
+
379
+ st.markdown("---")
380
+
381
+ # Model Selection (conditional display)
382
+ if show_model:
383
+ st.markdown("### 🤖 AI Model")
384
+
385
+ # Static list of available models - no API calls needed
386
+ available_models = [
387
+ "gpt-4o-mini",
388
+ "gpt-3.5-turbo",
389
+ "gemini-1.5-pro",
390
+ "gemini-1.5-flash",
391
+ "claude-3-haiku",
392
+ "claude-3-sonnet",
393
+ "mistral-small",
394
+ "mistral-medium",
395
+ "mistral-large",
396
+ ]
397
+
398
+ model = st.selectbox(
399
+ "Choose your AI model:",
400
+ available_models,
401
+ index=0, # Default to first model
402
+ help="Select the AI model for processing your queries",
403
+ key="model_selector"
404
+ )
405
+
406
+ # Show model descriptions - static information
407
+ model_descriptions = {
408
+ "gpt-4o-mini": "⚡ Fast & cost-effective OpenAI model",
409
+ "gpt-3.5-turbo": "🔥 Reliable & quick OpenAI model",
410
+ "gemini-pro": "💎 Google's powerful Gemini model",
411
+ "gemini-1.5-pro": "🔬 Google's latest Gemini model",
412
+ "gemini-1.5-flash": "⚡ Google's fast Gemini model",
413
+ "claude-3-haiku": "🌸 Anthropic's efficient Claude model",
414
+ "claude-3-sonnet": "🎵 Anthropic's balanced Claude model",
415
+ "mistral-small": "🎯 Mistral's efficient model",
416
+ "mistral-medium": "⚖️ Mistral's balanced model",
417
+ "mistral-large": "🦾 Mistral's most capable model",
418
+
419
+ }
420
+
421
+ description = model_descriptions.get(model, "🤖 Advanced AI model")
422
+ st.info(description)
423
+
424
+ # Show provider information - static
425
+ provider_info = {
426
+ "gpt-4o-mini": "🏢 OpenAI",
427
+ "gpt-3.5-turbo": "🏢 OpenAI",
428
+ "gemini-pro": "🔍 Google",
429
+ "gemini-1.5-pro": "🔍 Google",
430
+ "gemini-1.5-flash": "🔍 Google",
431
+ "claude-3-haiku": "🤖 Anthropic",
432
+ "claude-3-sonnet": "🤖 Anthropic",
433
+ "mistral-small": "⚡ Mistral AI",
434
+ "mistral-medium": "⚡ Mistral AI",
435
+ "mistral-large": "⚡ Mistral AI",
436
+ }
437
+
438
+ provider = provider_info.get(model, "🤖 AI Provider")
439
+ st.caption(f"Provider: {provider}")
440
+
441
+ # Show setup hints for different providers
442
+ if model.startswith("gemini"):
443
+ st.caption("💡 Requires GOOGLE_API_KEY in .env")
444
+ elif model.startswith("claude"):
445
+ st.caption("💡 Requires ANTHROPIC_API_KEY in .env")
446
+ elif model.startswith("mistral"):
447
+ st.caption("💡 Requires MISTRAL_API_KEY in .env")
448
+ elif model in ["llama3.2", "llama3.1", "codellama", "phi3"]:
449
+ st.caption("💡 Requires Ollama installed locally")
450
+ else:
451
+ st.caption("💡 Requires OPENAI_API_KEY in .env")
452
+
453
+ # Mark UI action to prevent unnecessary API calls for other interactions
454
+ if model:
455
+ st.session_state["recent_ui_action"] = pd.Timestamp.now().timestamp()
456
+
457
+ st.markdown("---")
458
+ else:
459
+ # Use default model when hidden
460
+ model = AVAILABLE_MODELS[0]
461
+
462
+ # Agent Selection (conditional display)
463
+ if show_agent:
464
+ st.markdown("### 🎯 Agent Type")
465
+ agent = st.selectbox(
466
+ "Choose your agent:",
467
+ AVAILABLE_AGENTS,
468
+ help="Select the specialized agent for your database tasks",
469
+ key="agent_selector"
470
+ )
471
+
472
+ # Mark UI action to prevent unnecessary API calls
473
+ if agent:
474
+ st.session_state["recent_ui_action"] = pd.Timestamp.now().timestamp()
475
+
476
+ # Show agent info
477
+ agent_info = {
478
+ "default": "🔧 General-purpose database assistant",
479
+ "sql-agent": "💾 Specialized in SQL optimization",
480
+ "custom-agent": "🎨 Customized for specific workflows"
481
+ }
482
+ st.info(agent_info.get(agent, "Specialized database agent"))
483
+ st.markdown("---")
484
+ else:
485
+ # Use default agent when hidden
486
+ agent = AVAILABLE_AGENTS[0]
487
+
488
+ # Theme Selection (conditional display)
489
+ if show_theme:
490
+ st.markdown("### 🎨 Appearance")
491
+ theme = st.selectbox(
492
+ "Choose your theme:",
493
+ AVAILABLE_THEMES,
494
+ help="Customize the visual appearance",
495
+ key="theme_selector"
496
+ )
497
+
498
+ # Mark UI action to prevent unnecessary API calls
499
+ if theme:
500
+ st.session_state["recent_ui_action"] = pd.Timestamp.now().timestamp()
501
+
502
+ st.markdown("---")
503
+ else:
504
+ # Use default theme when hidden
505
+ theme = AVAILABLE_THEMES[0]
506
+
507
+ # System Status (conditional display)
508
+ if show_status:
509
+ st.markdown("### 📊 System Status")
510
+
511
+ # Skip status check if currently processing a query to avoid slowdown
512
+ if st.session_state.get("processing_query", False):
513
+ st.info("⏳ Status check paused during query processing")
514
+ # Show cached stats only
515
+ total_queries, success_rate, successful, failed = get_query_stats()
516
+ col1, col2 = st.columns(2)
517
+ with col1:
518
+ st.metric("API Status", "⏳ Processing", delta="Query in progress")
519
+ with col2:
520
+ st.metric("Total Queries", total_queries, delta=f"+{total_queries}")
521
+ else:
522
+ # Check if this is a force refresh scenario
523
+ is_force_refresh = st.session_state.get("force_refresh_requested", False)
524
+
525
+ # Use centralized validation to avoid unnecessary API calls
526
+ if not should_skip_api_call(force_refresh=is_force_refresh):
527
+ logging.info("=== HEALTH CHECK API CALL STARTING ===")
528
+ try:
529
+ # Get real-time API status only when validation passes
530
+ status_text, status_delta, status_type = check_api_status()
531
+
532
+ # Get detailed health information
533
+ detailed_health = get_detailed_health_status()
534
+
535
+ # Store the status for future use
536
+ st.session_state["last_api_status"] = status_text
537
+ st.session_state["last_api_delta"] = status_delta
538
+ st.session_state["last_api_type"] = status_type
539
+ st.session_state["last_detailed_health"] = detailed_health
540
+ st.session_state["last_status_update"] = pd.Timestamp.now().timestamp()
541
+
542
+ logging.info("=== HEALTH CHECK API CALL COMPLETED ===")
543
+
544
+ # Clear force refresh flag after successful update
545
+ if is_force_refresh:
546
+ st.session_state["force_refresh_requested"] = False
547
+
548
+ except Exception as e:
549
+ logging.error(f"Status check failed: {e}")
550
+ # Use error values if API fails
551
+ status_text = "🔴 Failed"
552
+ status_delta = f"Error: {str(e)[:50]}..."
553
+ status_type = "error"
554
+ detailed_health = {
555
+ "status": "error",
556
+ "message": f"Status check failed: {str(e)}",
557
+ "checks": {}
558
+ }
559
+
560
+ # Clear force refresh flag even on error
561
+ if is_force_refresh:
562
+ st.session_state["force_refresh_requested"] = False
563
+ else:
564
+ # Use cached values to avoid API calls, with better defaults
565
+ status_text = st.session_state.get("last_api_status", "🟡 Loading...")
566
+ status_delta = st.session_state.get("last_api_delta", "Initializing...")
567
+ status_type = st.session_state.get("last_api_type", "normal")
568
+ detailed_health = st.session_state.get("last_detailed_health", {
569
+ "status": "unknown",
570
+ "message": "Loading system status...",
571
+ "checks": {}
572
+ })
573
+
574
+ # Get query statistics
575
+ total_queries, success_rate, successful, failed = get_query_stats()
576
+
577
+ # Display main metrics
578
+ col1, col2 = st.columns(2)
579
+ with col1:
580
+ st.metric("API Status", status_text, delta=status_delta)
581
+ with col2:
582
+ st.metric("Total Queries", total_queries, delta=f"+{total_queries}")
583
+
584
+ # Status indicator with more details
585
+ if status_type == "success":
586
+ st.success("✅ All systems operational")
587
+ elif status_type == "warning":
588
+ st.warning("⚠️ Limited functionality - some features may be slow")
589
+ else:
590
+ st.error("❌ API connection failed - please check your backend service")
591
+
592
+ # Show detailed health checks in an expander
593
+ with st.expander("🔍 Detailed System Health", expanded=False):
594
+ if detailed_health.get("status") in ["healthy", "unhealthy"]:
595
+ # Display timestamp
596
+ if "timestamp" in detailed_health:
597
+ st.caption(f"Last checked: {detailed_health['timestamp']}")
598
+
599
+ # Display individual checks
600
+ checks = detailed_health.get("checks", {})
601
+
602
+ if "database" in checks:
603
+ db_check = checks["database"]
604
+ db_status = db_check.get("status", "unknown")
605
+ db_message = db_check.get("message", "No information")
606
+
607
+ if db_status == "healthy":
608
+ st.success(f"🗃️ Database: {db_message}")
609
+ elif db_status == "unhealthy":
610
+ st.error(f"🗃️ Database: {db_message}")
611
+ else:
612
+ st.warning(f"🗃️ Database: {db_message}")
613
+
614
+ if "openai_api" in checks:
615
+ api_check = checks["openai_api"]
616
+ api_status = api_check.get("status", "unknown")
617
+ api_message = api_check.get("message", "No information")
618
+
619
+ if api_status == "configured":
620
+ st.success(f"🤖 OpenAI API: {api_message}")
621
+ elif api_status == "error":
622
+ st.error(f"🤖 OpenAI API: {api_message}")
623
+ else:
624
+ st.warning(f"🤖 OpenAI API: {api_message}")
625
+
626
+ # Display version if available
627
+ if "version" in detailed_health:
628
+ st.info(f"📦 Version: {detailed_health['version']}")
629
+
630
+ else:
631
+ # Show error information
632
+ st.error(f"❌ Health check failed: {detailed_health.get('message', 'Unknown error')}")
633
+ st.caption("Unable to retrieve detailed system status")
634
+
635
+ # Additional detailed stats (always show regardless of processing state)
636
+ if total_queries > 0:
637
+ col3, col4 = st.columns(2)
638
+ with col3:
639
+ st.metric("Success Rate", f"{success_rate}%", delta=f"{successful} successful")
640
+ with col4:
641
+ session_duration = pd.Timestamp.now() - st.session_state["query_stats"]["session_start"]
642
+ hours = int(session_duration.total_seconds() // 3600)
643
+ minutes = int((session_duration.total_seconds() % 3600) // 60)
644
+ st.metric("Session Time", f"{hours}h {minutes}m", delta="Active")
645
+
646
+ # Show last update time and refresh button
647
+ col_refresh1, col_refresh2 = st.columns([2, 1])
648
+ with col_refresh1:
649
+ st.caption(f"🔄 Last updated: {pd.Timestamp.now().strftime('%H:%M:%S')}")
650
+ with col_refresh2:
651
+ if st.button("🔄", help="Force Refresh Status", key="refresh_status"):
652
+ # Set force refresh flag to bypass validation
653
+ st.session_state["force_refresh_requested"] = True
654
+ # Clear all cached data and force fresh API calls
655
+ st.cache_data.clear()
656
+ # Reset validation timestamps to allow immediate API calls
657
+ st.session_state["last_status_update"] = 0
658
+ if "recent_ui_action" in st.session_state:
659
+ del st.session_state["recent_ui_action"]
660
+ # Clear cached status values
661
+ for key in ["last_api_status", "last_api_delta", "last_api_type", "last_detailed_health"]:
662
+ if key in st.session_state:
663
+ del st.session_state[key]
664
+ st.rerun()
665
+
666
+ st.markdown("---")
667
+
668
+ # Tips and Help (conditional display)
669
+ if show_tips:
670
+ st.markdown("### 💡 Quick Tips")
671
+ with st.expander("📝 How to ask questions"):
672
+ st.markdown("""
673
+ - **"Show me all customers from Chicago"**
674
+ - **"What are the top 5 branches by transactions?"**
675
+ - **"Calculate total transactions by month"**
676
+ - **"Find customers who haven't done any transactions recently"**
677
+ """)
678
+
679
+ with st.expander("⚡ Pro Tips"):
680
+ st.markdown("""
681
+ - Be specific about what data you want
682
+ - Mention date ranges when relevant
683
+ - Ask for summaries or aggregations
684
+ - Use natural language - no SQL needed!
685
+ """)
686
+
687
+ with st.expander("🔧 Troubleshooting"):
688
+ st.markdown("""
689
+ - **No results?** Try rephrasing your question
690
+ - **Error message?** Use the retry button
691
+ - **Slow response?** Check your connection
692
+ - **Wrong data?** Be more specific in your query
693
+ """)
694
+
695
+ st.markdown("---")
696
+
697
+ # Quick actions
698
+ st.markdown("### 🚀 Quick Actions")
699
+
700
+ # Check if query is currently being processed
701
+ is_processing = st.session_state.get("processing_query", False)
702
+
703
+ # Show processing indicator if query is running
704
+ if is_processing:
705
+ st.info("⚡ Query in progress... All action buttons are temporarily disabled.")
706
+
707
+ col_action1, col_action2 = st.columns(2)
708
+ with col_action1:
709
+ # Disable Clear Chat button when query is processing
710
+ clear_chat_disabled = is_processing
711
+ clear_chat_help = "Cannot clear chat while query is processing" if is_processing else "Clear all chat messages"
712
+
713
+ if st.button("🗑️ Clear Chat",
714
+ use_container_width=True,
715
+ disabled=clear_chat_disabled,
716
+ help=clear_chat_help):
717
+ # Simple UI action - block API calls for 5 seconds
718
+ st.session_state["recent_ui_action"] = pd.Timestamp.now().timestamp()
719
+ st.session_state["messages"] = []
720
+ logging.info("Clear Chat button clicked - blocking API calls for 5 seconds")
721
+ st.rerun()
722
+
723
+ with col_action2:
724
+ # Disable Reset Stats button when query is processing to prevent any API calls
725
+ reset_stats_disabled = is_processing
726
+ reset_stats_help = "Cannot reset stats while query is processing" if is_processing else "Reset query statistics"
727
+
728
+ if st.button("📊 Reset Stats",
729
+ use_container_width=True,
730
+ disabled=reset_stats_disabled,
731
+ help=reset_stats_help):
732
+ # Simple UI action - block API calls for 5 seconds
733
+ st.session_state["recent_ui_action"] = pd.Timestamp.now().timestamp()
734
+ st.session_state["query_stats"] = {
735
+ "total_queries": 0,
736
+ "successful_queries": 0,
737
+ "failed_queries": 0,
738
+ "session_start": pd.Timestamp.now()
739
+ }
740
+ logging.info("Reset Stats button clicked - blocking API calls for 5 seconds")
741
+ st.rerun()
742
+
743
+ # Sample Queries button - also disabled during processing to avoid confusion
744
+ sample_disabled = is_processing
745
+ sample_help = "Cannot show samples while query is processing" if is_processing else "Show sample queries you can copy"
746
+
747
+ if st.button("📋 Sample Queries",
748
+ use_container_width=True,
749
+ disabled=sample_disabled,
750
+ help=sample_help):
751
+ # Simple UI action - block API calls for 5 seconds
752
+ st.session_state["recent_ui_action"] = pd.Timestamp.now().timestamp()
753
+ logging.info("Sample Queries button clicked - blocking API calls for 5 seconds")
754
+ sample_queries = [
755
+ "Show me the top 10 customers by transactions",
756
+ "Which branches collected more transactions last month?",
757
+ "Calculate average transactions value",
758
+ "List all active customers"
759
+ ]
760
+ # Display sample queries in the sidebar instead of adding to chat
761
+ st.markdown("**Sample queries you can copy and paste:**")
762
+ for query in sample_queries:
763
+ st.code(query)
764
+
765
+ # Footer
766
+ st.markdown("### ℹ️ About")
767
+ st.markdown("""
768
+ **AI Database Assistant** v2.0
769
+ 🚀 Powered by advanced AI
770
+ 💬 Natural language to SQL
771
+ 📈 Real-time analytics
772
+ """)
773
+
774
+
775
+ # Apply theme
776
+ theme_manager.inject_theme(theme)
777
 
778
  # ===============================
779
+ # Chat Rendering Function
780
  # ===============================
781
  def render_chat():
782
+ """Render chat history with proper error handling."""
783
  st.markdown("<div class='chat-container'>", unsafe_allow_html=True)
784
+ messages = st.session_state["messages"]
785
+
786
+ for i, msg in enumerate(messages):
787
  if msg["role"] == "user":
788
+ # User message with avatar
789
+ st.markdown(
790
+ f'''<div style="display: flex; align-items: flex-start; justify-content: flex-end; margin-bottom: 0.5em;">
791
+ <div style="margin-right: 0.5em;">
792
+ <img src="{OUTLINE_INDIGO_USER}" alt="User" style="width: 2.3rem; height: 2.3rem; border-radius: 50%; border: 2px solid #e3f2fd; background: #fff; object-fit: cover;" />
793
+ </div>
794
+ <div class="user-bubble">{msg["content"]}</div>
795
+ </div>''',
796
+ unsafe_allow_html=True)
797
+
798
  elif msg["role"] == "assistant":
799
+ # Show all assistant messages normally (including errors)
800
  bubble_class = "error-bubble" if msg.get("is_error") else "ai-bubble"
801
+
802
+ # Skip rendering placeholder messages (we use Streamlit spinner instead)
803
+ if msg.get("is_placeholder"):
804
+ continue
805
+ else:
806
+ # For error messages, show retry button below the message
807
+ if msg.get("is_error"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
808
  st.markdown(
809
  f'''<div style="display: flex; align-items: flex-start; margin-bottom: 0.5em;">
810
  <div style="margin-right: 0.5em;">
811
+ <img src="{DARK_MODE_SLATE_AI}" alt="AI" style="width: 2.3rem; height: 2.3rem; border-radius: 50%; border: 2px solid #b2dfdb; background: #fff; object-fit: cover;" />
812
  </div>
813
+ <div class="{bubble_class}">{msg["content"]}</div>
814
  </div>''',
815
  unsafe_allow_html=True)
816
+
817
+ # Show retry button below the error message, aligned with the error message
818
+ # Use same layout structure as the error message for alignment
819
+ cols = st.columns([0.03, 0.85])
820
+ with cols[0]:
821
+ st.empty() # Empty space where avatar would be
822
+ with cols[1]:
823
+ if st.button(RETRY_BUTTON_TEXT, key=f"retry_error_{i}"):
824
+ # Use the stored user query and index for reliable retry
825
+ stored_user_query = msg.get("user_query")
826
+ stored_user_index = msg.get("user_query_index")
827
+
828
+ if stored_user_query and stored_user_index is not None:
829
+ # Remove all messages after the user query and retry
830
+ st.session_state["messages"] = st.session_state["messages"][:stored_user_index+1]
831
+ st.session_state["messages"].append({"role": "assistant", "content": "� Processing your query...", "is_placeholder": True})
832
+ # Mark this as a legitimate retry, not a UI interaction
833
+ st.session_state["legitimate_query_time"] = pd.Timestamp.now().timestamp()
834
+ # Remove any recent UI action flag to allow this query to process
835
+ if "recent_ui_action" in st.session_state:
836
+ del st.session_state["recent_ui_action"]
837
+ st.rerun()
838
  else:
839
+ # Regular AI message
840
  st.markdown(
841
  f'''<div style="display: flex; align-items: flex-start; margin-bottom: 0.5em;">
842
  <div style="margin-right: 0.5em;">
843
+ <img src="{DARK_MODE_SLATE_AI}" alt="AI" style="width: 2.3rem; height: 2.3rem; border-radius: 50%; border: 2px solid #b2dfdb; background: #fff; object-fit: cover;" />
844
  </div>
845
  <div class="{bubble_class}">{msg["content"]}</div>
846
  </div>''',
847
  unsafe_allow_html=True)
848
+
849
+ # Show data table and download if present
850
+ if msg.get("data"):
851
+ df = pd.DataFrame(msg["data"])
852
+ st.dataframe(df, use_container_width=True)
853
+ csv = df.to_csv(index=False).encode("utf-8")
854
+ st.download_button(DOWNLOAD_BUTTON_TEXT, csv, "results.csv", "text/csv", key=f"download_csv_{id(msg)}")
855
+
856
+ # Show chart if present
857
+ if msg.get("chart"):
858
+ img_data = base64.b64decode(msg["chart"])
859
+ st.image(img_data, use_column_width=True)
860
+
861
+ st.markdown("</div>", unsafe_allow_html=True)
862
 
863
+ # Render chat
 
 
 
 
 
 
 
 
 
 
 
 
864
  render_chat()
865
 
866
  # ===============================
867
+ # User Input Handling
868
  # ===============================
869
+ # Check if AI is thinking
 
870
  pending = False
871
+ if st.session_state["messages"]:
872
  if st.session_state["messages"][-1]["role"] == "assistant":
873
  pending = st.session_state["messages"][-1].get("is_placeholder", False)
874
 
875
+ # Get user input
876
+ user_query = st.chat_input(CHAT_INPUT_PLACEHOLDER, disabled=pending)
877
+
 
878
  if user_query and not pending:
879
  st.session_state["messages"].append({"role": "user", "content": user_query})
880
+ st.session_state["messages"].append({"role": "assistant", "content": " Processing ...", "is_placeholder": True})
881
+ # Mark this as a legitimate user query, not a UI interaction
882
+ st.session_state["legitimate_query_time"] = pd.Timestamp.now().timestamp()
883
+ # Remove any recent UI action flag to allow this query to process
884
+ if "recent_ui_action" in st.session_state:
885
+ del st.session_state["recent_ui_action"]
886
  st.rerun()
887
 
888
  # ===============================
889
+ # API Response Handling
890
  # ===============================
891
+ # CRITICAL: Only process API calls for legitimate user queries, not UI interactions
892
+ # Check if we have a placeholder message from a user query
893
+ has_placeholder = (
894
  st.session_state["messages"]
895
  and st.session_state["messages"][-1].get("is_placeholder")
896
  and len(st.session_state["messages"]) >= 2
897
  and st.session_state["messages"][-2]["role"] == "user"
898
+ )
 
 
 
 
 
 
 
899
 
900
+ # Check if this is a legitimate query that should be processed
901
+ # Block if recent UI action (sidebar interactions) triggered this rerun
902
+ should_process_query = (
903
+ has_placeholder
904
+ and not should_skip_query_processing() # Use query-specific validation
905
+ )
906
 
907
+ if has_placeholder:
908
+ logging.info(f"=== QUERY PROCESSING CHECK ===")
909
+ logging.info(f"Has placeholder: {has_placeholder}")
910
+ logging.info(f"Should process query: {should_process_query}")
911
+ if not should_process_query:
912
+ logging.info("BLOCKED: Query processing blocked by should_skip_api_call")
913
+ else:
914
+ logging.info("ALLOWED: Query processing allowed")
 
 
 
 
 
915
 
916
+ if should_process_query:
917
+ user_query = st.session_state["messages"][-2]["content"]
918
+ user_query_index = len(st.session_state["messages"]) - 2 # Store the user query index
919
+
920
+ # Set processing flag to pause status checks
921
+ st.session_state["processing_query"] = True
922
+
923
+ try:
924
+ with st.spinner(THINKING_MESSAGE):
925
+ logging.info(f"Sending query to API: {user_query}")
926
+
927
+ try:
928
+ # Use the API client instead of direct requests
929
+ result = api_client.send_query(user_query, model, agent)
930
+
931
+ if result:
932
+ answer_text = result.get("message", "No response received")
933
+ rows = result.get("rows", [])
934
+ chart = result.get("chart", None)
935
+ is_error = result.get("error", False)
936
+ model_used = result.get("model_used", "unknown")
937
+ status = result.get("status", "unknown")
938
+
939
+ # Add model information to the response for successful queries
940
+ if not is_error and model_used != "unknown":
941
+ answer_text += f"\n\n*Powered by: {model_used}*"
942
+ else:
943
+ answer_text = "❌ Error: Unable to process your request. Please try again."
944
+ rows, chart, is_error = [], None, True
945
+ model_used, status = "unknown", "error"
946
+
947
+ except Exception as e:
948
+ logging.error(f"API connectivity error: {e}")
949
+ answer_text, rows, chart, is_error = (
950
+ "❌ API is not available. Please check your connection or try again later.", [], None, True
951
+ )
952
+ model_used, status = "unknown", "error"
953
+
954
  except Exception as e:
955
  logging.error(f"Exception: {e}")
956
  answer_text, rows, chart, is_error = f"⚠️ Exception: {str(e)}", [], None, True
957
+ model_used, status = "unknown", "error"
958
+
959
+ # Update query statistics
960
+ update_query_stats(success=not is_error)
961
+
962
+ # Clear processing flag
963
+ st.session_state["processing_query"] = False
964
+
965
+ # Clear legitimate query flag after processing
966
+ if "legitimate_query_time" in st.session_state:
967
+ del st.session_state["legitimate_query_time"]
968
+
969
+ # Replace placeholder with final response
970
  st.session_state["messages"][-1] = {
971
  "role": "assistant",
972
  "content": answer_text,
973
  "data": rows,
974
  "chart": chart,
975
  "is_error": is_error,
976
+ "model_used": model_used,
977
+ "status": status,
978
+ "user_query": user_query if is_error else None, # Store user query for retry
979
+ "user_query_index": user_query_index if is_error else None, # Store user query index for retry
980
  }
 
981
  st.rerun()
src/config.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration settings for the AI Database Assistant.
3
+ """
4
+ import logging
5
+ from typing import List
6
+ import os
7
+
8
+ # API Configuration
9
+ # Change only the BASE_URL for different deployment environments
10
+ BASE_URL = os.environ.get("BASE_URL", "http://127.0.0.1:8000") # Development Environment
11
+
12
+ # UI Configuration
13
+ PAGE_TITLE = "AI Assistant"
14
+ PAGE_LAYOUT = "wide"
15
+
16
+ # Model Options - Simplified for better UX
17
+ AVAILABLE_MODELS: List[str] = [
18
+ "gpt-4o-mini", # Fast & cost-effective
19
+ "gpt-3.5-turbo", # Reliable & quick
20
+ "gpt-4" # Most capable
21
+ ]
22
+ AVAILABLE_AGENTS: List[str] = [
23
+ "default", # General database assistant
24
+ "sql-agent" # SQL optimization expert
25
+ ]
26
+ AVAILABLE_THEMES: List[str] = [
27
+ "Light", # Clean & bright
28
+ "Dark" # Easy on eyes
29
+ ]
30
+
31
+ # Logging Configuration
32
+ LOG_LEVEL = logging.INFO
33
+ LOG_FORMAT = "%(asctime)s - %(levelname)s - %(message)s"
34
+
35
+ # UI Messages
36
+ CHAT_INPUT_PLACEHOLDER = "Ask me something about your database..."
37
+ THINKING_MESSAGE = "🧠 Processing..."
38
+ WORKING_MESSAGE = "Working..."
39
+ RETRY_BUTTON_TEXT = "🔄 Try Again"
40
+ DOWNLOAD_BUTTON_TEXT = "📥 Download CSV"
41
+
42
+ # Default Avatars (Base64 encoded SVGs)
43
+ DEFAULT_USER_AVATAR = """
44
+ data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+CiAgPCEtLSBkYXRhYmFzZSAtLT4KICA8ZWxsaXBzZSBjeD0iMTciIGN5PSI2IiByeD0iNS41IiByeT0iMi41IiBmaWxsPSIjV0ZGM0UwIiBzdHJva2U9IiNFRjZD MDAiIHN0cm9rZS13aWR0aD0iMSIvPgogIDxwYXRoIGQ9Ik0xMS41IDZ2NWMwIDEuMyAyLjQ2IDIuNCA1LjUgMi40czUuNS0xLjEgNS41LTIuNFY2IiBmaWxsPSIjV0ZGM0UwIiBzdHJva2U9IiNFRjZD MDAiIHN0cm9rZS13aWR0aD0iMSIvPgogIDxwYXRoIGQ9Ik0xMS41IDguNWMwIDEuMyAyLjQ2IDIuNCA1LjUgMi40czUuNS0xLjEgNS41LTIuNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjRUY2QzAwIiBzdHJva2Utd2lkdGg9IjEiLz4KICA8IS0tIHVzZXIgYnVzdCAtLT4KICA8Y2lyY2xlIGN4PSI3IiBjeT0iOSIgcj0iMyIgZmlsbD0iI0ZGQTcyNiIvPgogIDxwYXRoIGQ9Ik0yLjUgMTdjMC0yLjUgMi42LTQuNSA0LjUtNC41UzExLjUgMTQuNSAxMS41IDE3djEuNUgyLjVWMTd6IiBmaWxsPSIjRkZDQzgwIi8+Cjwvc3ZnPg==
45
+ """
46
+ OUTLINE_INDIGO_USER ="""
47
+ data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+CiAgPGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjM2NkYxIiBzdHJva2Utd2lkdGg9IjEuMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj4KICAgIDxlbGxpcHNlIGN4PSIxMiIgY3k9IjUuNSIgcng9IjciIHJ5PSIyLjgiLz4KICAgIDxwYXRoIGQ9Ik01IDUuNXY3YzAgMS43IDMuMSAzIDcgM3M3LTEuMyA3LTMgMHYtNyIvPgogICAgPHBhdGggZD0iTSA1IDguOGMwIDEuNyAzLjEgMyA3IDNzNy0xLjMgNy0zIi8+CiAgICA8Y2lyY2xlIGN4PSI4LjIiIGN5PSIxMy44IiByPSIyLjIiLz4KICAgIDxwYXRoIGQ9Ik0zLjUgMTkuNWMuNy0yLjQgMy4xLTQgNC43LTRzNCAxLjYgNC43IDQiLz4KICA8L2c+Cjwvc3ZnPg==
48
+ """
49
+ DARK_MODE_SLATE_AI="""
50
+ data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+CiAgPHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiByeD0iNiIgZmlsbD0iIzBGMTE3QSIvPgogIDwhLS0gc3R5bGl6ZWQgY3lsaW5kZXIgLS0+CiAgPGVsbGlwc2UgY3g9IjEyIiBjeT0iNyIgcng9IjciIHJ5PSIzIiBmaWxsPSIjMUYyOTM3IiBzdHJva2U9IiM5NEEzQjgiIHN0cm9rZS13aWR0aD0iMSIvPgogIDxwYXRoIGQ9Ik01IDd2N2MwIDEuNyAzLjEgMyA3IDNzNy0xLjMgNy0zVjciIGZpbGw9IiMxMTE4MjciIHN0cm9rZT0iIzk0QTNCOCIgc3Ryb2tlLXdpZHRoPSIxIi8+CiAgPHBhdGggZD0iTSA1IDEwYzAgMS43IDMuMSAzIDcgM3M3LTEuMyA3LTMiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzZCNzI4MCIgc3Ryb2tlLXdpZHRoPSIxIi8+CiAgPCEtLSBib3QgZmFjZSAtLT4KICA8Y2lyY2xlIGN4PSIxMiIgY3k9IjE1LjUiIHI9IjMiIGZpbGw9IiMxMTE4MjciIHN0cm9rZT0iIzk0QTNCOCIvPgogIDxjaXJjbGUgY3g9IjExIiBjeT0iMTUuNSIgcj0iMC43IiBmaWxsPSIjOTRBM0I4Ii8+CiAgPGNpcmNsZSBjeD0iMTMiIGN5PSIxNS41IiByPSIwLjciIGZpbGw9IiM5NEEzQjgiLz4KPC9zdmc+
51
+ """
52
+ DEFAULT_AI_AVATAR = """
53
+ data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+CiAgPGRlZnM+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9ImciIHgxPSIwIiB5MT0iMCIgeDI9IjI0IiB5Mj0iMjQiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZSI+CiAgICAgIDxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzRDQUY1MCIvPgogICAgICA8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMyRTdEMzIiLz4KICAgIDwvbGluZWFyR3JhZGllbnQ+CiAgPC9kZWZzPgogIDwhLS0gZGF0YWJhc2UgLS0+CiAgPGVsbGlwc2UgY3g9IjEyIiBjeT0iNiIgcng9IjciIHJ5PSIzIiBmaWxsPSIjRThGNUU5IiBzdHJva2U9IiMyRTdEMzIiIHN0cm9rZS13aWR0aD0iMSIvPgogIDxwYXRoIGQ9Ik01IDZ2NmMwIDEuNjYgMy4xMyAzIDcgM3M3LTEuMzQgNy0zVjYiIGZpbGw9IiNFOEY1RTkiIHN0cm9rZT0iIzJFN0QzMiIgc3Ryb2tlLXdpZHRoPSIxIi8+CiAgPHBhdGggZD0iTSA1IDljMCAxLjY2IDMuMTMgMyA3IDNzNy0xLjM0IDctMyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMkU3RDMyIiBzdHJva2Utd2lkdGg9IjEiLz4KICA8IS0tIGJvdCBoZWFkIC0tPgogIDxjaXJjbGUgY3g9IjEyIiBjeT0iMTUuNSIgcj0iMyIgZmlsbD0idXJsKCNnKSIgLz4KICA8cmVjdCB4PSI5IiB5PSIxNC4yIiB3aWR0aD0iNiIgaGVpZ2h0PSIyLjYiIHJ4PSIxLjMiIGZpbGw9IiNGRkZGRkYiIG9wYWNpdHk9IjAuOSIvPgogIDxjaXJjbGUgY3g9IjEwLjciIGN5PSIxNS41IiByPSIwLjciIGZpbGw9IiMyRTdEMzIiLz4KICA8Y2lyY2xlIGN4PSIxMy4zIiBjeT0iMTUuNSIgcj0iMC43IiBmaWxsPSIjMkU3RDMyIi8+CiAgPCEtLSBhbnRlbm5hIC0tPgogIDxsaW5lIHgxPSIxMiIgeTE9IjEyLjMiIHgyPSIxMiIgeTI9IjEwLjMiIHN0cm9rZT0iIzJFN0QzMiIgc3Ryb2tlLXdpZHRoPSIxIi8+CiAgPGNpcmNsZSBjeD0iMTIiIGN5PSI5LjciIHI9IjAuNiIgZmlsbD0iIzJFN0QzMiIvPgo8L3N2Zz4=
54
+ """
src/run_api_client_tests.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test runner for API Client unit tests
3
+ """
4
+ import sys
5
+ import os
6
+
7
+ # Add the current directory to path so we can import api_client
8
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
9
+
10
+ import pytest
11
+
12
+ if __name__ == "__main__":
13
+ # Run the tests with verbose output and coverage
14
+ exit_code = pytest.main([
15
+ "test_api_client.py",
16
+ "-v", # Verbose output
17
+ "--tb=short", # Short traceback format
18
+ "--durations=10", # Show 10 slowest tests
19
+ "-x", # Stop on first failure
20
+ ])
21
+
22
+ print(f"\nTest execution completed with exit code: {exit_code}")
23
+ sys.exit(exit_code)
src/session_manager.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Session management for the AI Database Assistant.
3
+ """
4
+ import streamlit as st
5
+ from typing import List, Dict, Any
6
+
7
+
8
+ class SessionManager:
9
+ """Manages session state and message handling."""
10
+
11
+ @staticmethod
12
+ def initialize_session() -> None:
13
+ """Initialize session state variables."""
14
+ if "messages" not in st.session_state:
15
+ st.session_state["messages"] = []
16
+
17
+ @staticmethod
18
+ def get_messages() -> List[Dict[str, Any]]:
19
+ """Get all messages from session state."""
20
+ return st.session_state.get("messages", [])
21
+
22
+ @staticmethod
23
+ def add_user_message(content: str) -> None:
24
+ """Add a user message to the session."""
25
+ st.session_state["messages"].append({
26
+ "role": "user",
27
+ "content": content
28
+ })
29
+
30
+ @staticmethod
31
+ def add_thinking_message() -> None:
32
+ """Add a thinking placeholder message."""
33
+ st.session_state["messages"].append({
34
+ "role": "assistant",
35
+ "content": "🤔 Thinking...",
36
+ "is_placeholder": True
37
+ })
38
+
39
+ @staticmethod
40
+ def replace_last_message(content: str, data: List[Dict] = None,
41
+ chart: str = None, is_error: bool = False) -> None:
42
+ """Replace the last message with final response."""
43
+ st.session_state["messages"][-1] = {
44
+ "role": "assistant",
45
+ "content": content,
46
+ "data": data or [],
47
+ "chart": chart,
48
+ "is_error": is_error,
49
+ }
50
+
51
+ @staticmethod
52
+ def is_ai_thinking() -> bool:
53
+ """Check if AI is currently thinking (has placeholder message)."""
54
+ messages = SessionManager.get_messages()
55
+ if not messages:
56
+ return False
57
+
58
+ last_message = messages[-1]
59
+ return (
60
+ last_message["role"] == "assistant" and
61
+ last_message.get("is_placeholder", False)
62
+ )
63
+
64
+ @staticmethod
65
+ def get_last_user_message() -> str:
66
+ """Get the content of the last user message."""
67
+ messages = SessionManager.get_messages()
68
+ if len(messages) >= 2:
69
+ return messages[-2]["content"]
70
+ return ""
71
+
72
+ @staticmethod
73
+ def has_pending_response() -> bool:
74
+ """Check if there's a pending response to process."""
75
+ messages = SessionManager.get_messages()
76
+ return (
77
+ messages and
78
+ messages[-1].get("is_placeholder") and
79
+ len(messages) >= 2 and
80
+ messages[-2]["role"] == "user"
81
+ )
src/streamlit_app.py DELETED
@@ -1,40 +0,0 @@
1
- import altair as alt
2
- import numpy as np
3
- import pandas as pd
4
- import streamlit as st
5
-
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/test_api_client.py ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for APIClient class
3
+ """
4
+ import pytest
5
+ import requests
6
+ import json
7
+ from unittest.mock import Mock, patch, MagicMock
8
+ from api_client import APIClient
9
+
10
+
11
+ class TestAPIClient:
12
+ """Test cases for APIClient class"""
13
+
14
+ def setup_method(self):
15
+ """Set up test fixtures before each test method."""
16
+ self.base_url = "http://localhost:8000"
17
+ self.client = APIClient(self.base_url, timeout=30)
18
+
19
+ def test_init(self):
20
+ """Test APIClient initialization"""
21
+ client = APIClient("http://localhost:8000/", timeout=45)
22
+ assert client.base_url == "http://localhost:8000" # Trailing slash removed
23
+ assert client.timeout == 45
24
+ assert client.endpoints['process_text'] == '/api/process-text'
25
+ assert client.endpoints['health'] == '/api/health'
26
+
27
+ @patch('requests.post')
28
+ def test_send_query_success_with_data(self, mock_post):
29
+ """Test successful query with data response"""
30
+ # Mock successful response
31
+ mock_response = Mock()
32
+ mock_response.status_code = 200
33
+ mock_response.json.return_value = {
34
+ "sql": "SELECT * FROM employees",
35
+ "rows": [{"id": 1, "name": "John Doe"}],
36
+ "heading": "Employee Details",
37
+ "summary": "List of employees",
38
+ "chart": None
39
+ }
40
+ mock_post.return_value = mock_response
41
+
42
+ result = self.client.send_query("Show me all employees")
43
+
44
+ assert result["error"] == False
45
+ assert result["message"] == "Employee Details"
46
+ assert len(result["rows"]) == 1
47
+ assert result["rows"][0]["name"] == "John Doe"
48
+ assert result["sql"] == "SELECT * FROM employees"
49
+
50
+ @patch('requests.post')
51
+ def test_send_query_success_with_message_only(self, mock_post):
52
+ """Test successful query with message-only response"""
53
+ mock_response = Mock()
54
+ mock_response.status_code = 200
55
+ mock_response.json.return_value = {
56
+ "message": "I understand your question about banking services.",
57
+ "model_used": "gpt-4"
58
+ }
59
+ mock_post.return_value = mock_response
60
+
61
+ result = self.client.send_query("What services do you offer?")
62
+
63
+ assert result["error"] == False
64
+ assert result["message"] == "I understand your question about banking services."
65
+ assert result["rows"] == []
66
+ assert result["model_used"] == "gpt-4"
67
+ assert result["status"] == "message"
68
+
69
+ @patch('requests.post')
70
+ def test_send_query_with_model_parameter(self, mock_post):
71
+ """Test query with specific model parameter"""
72
+ mock_response = Mock()
73
+ mock_response.status_code = 200
74
+ mock_response.json.return_value = {"message": "Success"}
75
+ mock_post.return_value = mock_response
76
+
77
+ self.client.send_query("Test question", model="gpt-4-turbo")
78
+
79
+ # Verify the payload includes model_name
80
+ args, kwargs = mock_post.call_args
81
+ payload = kwargs['json']
82
+ assert payload["question"] == "Test question"
83
+ assert payload["model_name"] == "gpt-4-turbo"
84
+
85
+ @patch('requests.post')
86
+ def test_send_query_connection_error(self, mock_post):
87
+ """Test handling of connection errors"""
88
+ mock_post.side_effect = requests.exceptions.ConnectionError("Connection failed")
89
+
90
+ result = self.client.send_query("Test question")
91
+
92
+ assert result["error"] == True
93
+ assert "Cannot connect to the server" in result["message"]
94
+ assert result["rows"] == []
95
+
96
+ @patch('requests.post')
97
+ def test_send_query_request_exception(self, mock_post):
98
+ """Test handling of general request exceptions"""
99
+ mock_post.side_effect = requests.exceptions.RequestException("Request failed")
100
+
101
+ result = self.client.send_query("Test question")
102
+
103
+ assert result["error"] == True
104
+ assert "Request failed" in result["message"]
105
+ assert result["rows"] == []
106
+
107
+ @patch('requests.post')
108
+ def test_send_query_unexpected_exception(self, mock_post):
109
+ """Test handling of unexpected exceptions"""
110
+ mock_post.side_effect = ValueError("Unexpected error")
111
+
112
+ result = self.client.send_query("Test question")
113
+
114
+ assert result["error"] == True
115
+ assert "Exception" in result["message"]
116
+ assert "Unexpected error" in result["message"]
117
+
118
+ def test_process_response_success_with_data(self):
119
+ """Test _process_response with successful data response"""
120
+ mock_response = Mock()
121
+ mock_response.status_code = 200
122
+ mock_response.json.return_value = {
123
+ "sql": "SELECT * FROM branches",
124
+ "rows": [{"id": 1, "name": "Main Branch"}, {"id": 2, "name": "Downtown"}],
125
+ "heading": "Branch Information",
126
+ "summary": "List of all branches"
127
+ }
128
+
129
+ result = self.client._process_response(mock_response)
130
+
131
+ assert result["error"] == False
132
+ assert result["message"] == "Branch Information"
133
+ assert len(result["rows"]) == 2
134
+ assert result["heading"] == "Branch Information"
135
+
136
+ def test_process_response_with_empty_heading(self):
137
+ """Test _process_response with empty heading"""
138
+ mock_response = Mock()
139
+ mock_response.status_code = 200
140
+ mock_response.json.return_value = {
141
+ "rows": [{"id": 1, "name": "Test"}],
142
+ "heading": "",
143
+ "summary": ""
144
+ }
145
+
146
+ result = self.client._process_response(mock_response)
147
+
148
+ assert result["message"] == "Here are the 1 results I found:"
149
+
150
+ def test_process_response_no_data_no_heading(self):
151
+ """Test _process_response with no data and no heading"""
152
+ mock_response = Mock()
153
+ mock_response.status_code = 200
154
+ mock_response.json.return_value = {
155
+ "rows": [],
156
+ "heading": "",
157
+ "summary": ""
158
+ }
159
+
160
+ result = self.client._process_response(mock_response)
161
+
162
+ assert result["message"] == "I could not find matching records for your query."
163
+
164
+ def test_process_response_error_status(self):
165
+ """Test _process_response with error status code"""
166
+ mock_response = Mock()
167
+ mock_response.status_code = 400
168
+ mock_response.json.return_value = {
169
+ "detail": "Bad request error"
170
+ }
171
+
172
+ result = self.client._process_response(mock_response)
173
+
174
+ assert result["error"] == True
175
+ assert "Error: Bad request error" in result["message"]
176
+
177
+ def test_process_response_invalid_json(self):
178
+ """Test _process_response with invalid JSON"""
179
+ mock_response = Mock()
180
+ mock_response.status_code = 200
181
+ mock_response.json.side_effect = ValueError("Invalid JSON")
182
+ mock_response.text = "Invalid response text"
183
+
184
+ result = self.client._process_response(mock_response)
185
+
186
+ # Should handle the JSON parsing error gracefully
187
+ assert "detail" in result or "message" in result
188
+
189
+ @patch('requests.get')
190
+ def test_check_health_healthy(self, mock_get):
191
+ """Test health check with healthy status"""
192
+ mock_response = Mock()
193
+ mock_response.json.return_value = {"status": "healthy"}
194
+ mock_get.return_value = mock_response
195
+
196
+ status, message, level = self.client.check_health()
197
+
198
+ assert status == "🟢 Active"
199
+ assert message == "Online"
200
+ assert level == "success"
201
+
202
+ @patch('requests.get')
203
+ def test_check_health_degraded(self, mock_get):
204
+ """Test health check with degraded status"""
205
+ mock_response = Mock()
206
+ mock_response.status_code = 503
207
+ mock_response.json.return_value = {"status": "degraded"}
208
+ mock_get.return_value = mock_response
209
+
210
+ status, message, level = self.client.check_health()
211
+
212
+ assert status == "🟡 Degraded"
213
+ assert message == "Some Issues"
214
+ assert level == "warning"
215
+
216
+ @patch('requests.get')
217
+ @patch('socket.create_connection')
218
+ def test_check_health_connection_error_with_socket_fallback(self, mock_socket, mock_get):
219
+ """Test health check with connection error but socket reachable"""
220
+ mock_get.side_effect = requests.exceptions.RequestException("Connection failed")
221
+ mock_socket.return_value.close.return_value = None
222
+
223
+ status, message, level = self.client.check_health()
224
+
225
+ assert status == "🟡 Reachable"
226
+ assert message == "Port Open"
227
+ assert level == "warning"
228
+
229
+ @patch('requests.get')
230
+ @patch('socket.create_connection')
231
+ def test_check_health_completely_offline(self, mock_socket, mock_get):
232
+ """Test health check when completely offline"""
233
+ mock_get.side_effect = requests.exceptions.RequestException("Connection failed")
234
+ mock_socket.side_effect = Exception("Socket connection failed")
235
+
236
+ status, message, level = self.client.check_health()
237
+
238
+ assert status == "🔴 Offline"
239
+ assert message == "Connection Failed"
240
+ assert level == "error"
241
+
242
+ @patch('requests.get')
243
+ def test_get_detailed_health_success(self, mock_get):
244
+ """Test get_detailed_health with successful response"""
245
+ expected_health = {
246
+ "status": "healthy",
247
+ "checks": {"database": "ok", "api": "ok"}
248
+ }
249
+ mock_response = Mock()
250
+ mock_response.status_code = 200
251
+ mock_response.json.return_value = expected_health
252
+ mock_get.return_value = mock_response
253
+
254
+ result = self.client.get_detailed_health()
255
+
256
+ assert result == expected_health
257
+
258
+ @patch('requests.get')
259
+ def test_get_detailed_health_error(self, mock_get):
260
+ """Test get_detailed_health with error response"""
261
+ mock_response = Mock()
262
+ mock_response.status_code = 500
263
+ mock_get.return_value = mock_response
264
+
265
+ result = self.client.get_detailed_health()
266
+
267
+ assert result["status"] == "error"
268
+ assert "500" in result["message"]
269
+
270
+ @patch('requests.get')
271
+ def test_get_method_success(self, mock_get):
272
+ """Test generic GET method with success"""
273
+ expected_data = {"models": ["gpt-4", "gpt-3.5"]}
274
+ mock_response = Mock()
275
+ mock_response.status_code = 200
276
+ mock_response.json.return_value = expected_data
277
+ mock_get.return_value = mock_response
278
+
279
+ result = self.client.get("/models")
280
+
281
+ assert result == expected_data
282
+
283
+ @patch('requests.get')
284
+ def test_get_method_error(self, mock_get):
285
+ """Test generic GET method with error"""
286
+ mock_response = Mock()
287
+ mock_response.status_code = 404
288
+ mock_get.return_value = mock_response
289
+
290
+ result = self.client.get("/models")
291
+
292
+ assert result is None
293
+
294
+ @patch('requests.post')
295
+ def test_post_method_success(self, mock_post):
296
+ """Test generic POST method with success"""
297
+ expected_data = {"result": "success"}
298
+ mock_response = Mock()
299
+ mock_response.status_code = 200
300
+ mock_response.json.return_value = expected_data
301
+ mock_post.return_value = mock_response
302
+
303
+ result = self.client.post("/change-model", {"model": "gpt-4"})
304
+
305
+ assert result == expected_data
306
+
307
+ @patch('requests.post')
308
+ def test_post_method_error(self, mock_post):
309
+ """Test generic POST method with error"""
310
+ mock_response = Mock()
311
+ mock_response.status_code = 400
312
+ mock_post.return_value = mock_response
313
+
314
+ result = self.client.post("/change-model", {"model": "invalid"})
315
+
316
+ assert result is None
317
+
318
+ def test_url_construction(self):
319
+ """Test URL construction for different endpoints"""
320
+ client = APIClient("http://localhost:8000/")
321
+
322
+ # Test that trailing slash is removed
323
+ assert client.base_url == "http://localhost:8000"
324
+
325
+ # Test endpoint URLs
326
+ process_url = f"{client.base_url}{client.endpoints['process_text']}"
327
+ assert process_url == "http://localhost:8000/api/process-text"
328
+
329
+ @patch('requests.post')
330
+ def test_send_query_with_legacy_agent_parameter(self, mock_post):
331
+ """Test that legacy agent parameter is handled correctly"""
332
+ mock_response = Mock()
333
+ mock_response.status_code = 200
334
+ mock_response.json.return_value = {"message": "Success"}
335
+ mock_post.return_value = mock_response
336
+
337
+ # Agent parameter should be ignored in payload
338
+ self.client.send_query("Test", model="gpt-4", agent="legacy_agent")
339
+
340
+ args, kwargs = mock_post.call_args
341
+ payload = kwargs['json']
342
+ assert "agent" not in payload # Agent should not be in payload
343
+ assert payload["model_name"] == "gpt-4"
344
+
345
+
346
+ if __name__ == "__main__":
347
+ pytest.main([__file__, "-v"])
src/test_heading_parsing.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Quick test to check heading parsing in API client
3
+ """
4
+ import logging
5
+ from api_client import APIClient
6
+
7
+ # Set up logging to see debug info
8
+ logging.basicConfig(level=logging.INFO)
9
+
10
+ # Create API client
11
+ client = APIClient("http://localhost:8000")
12
+
13
+ # Test with a simple query
14
+ print("Testing heading parsing...")
15
+ response = client.send_query("Show me all employees")
16
+
17
+ print(f"\nResponse keys: {list(response.keys())}")
18
+ print(f"Message: {response.get('message')}")
19
+ print(f"Heading: {response.get('heading')}")
20
+ print(f"Summary: {response.get('summary')}")
21
+ print(f"Original heading: {response.get('original_heading')}")
22
+ print(f"Rows count: {len(response.get('rows', []))}")
src/themes.py ADDED
@@ -0,0 +1,740 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Theme management for the AI Database Assistant.
3
+ Handles CSS injection and theme-specific styling.
4
+ """
5
+ import streamlit as st
6
+ from typing import Dict
7
+
8
+
9
+ class ThemeManager:
10
+ """Manages themes and CSS styling for the application."""
11
+
12
+ def __init__(self):
13
+ self.themes = {
14
+ "Light": self._get_light_theme(),
15
+ "Dark": self._get_dark_theme(),
16
+ "Custom": self._get_custom_theme()
17
+ }
18
+
19
+ def _get_light_theme(self) -> str:
20
+ """Returns CSS for light theme."""
21
+ return """
22
+ /* Light Theme - Clean and Professional */
23
+ body, .stApp {
24
+ background: #f8fafc !important;
25
+ color: #1f2937 !important;
26
+ }
27
+
28
+ /* Ensure all text is dark in light mode */
29
+ div, p, span, label, h1, h2, h3, h4, h5, h6 {
30
+ color: #1f2937 !important;
31
+ }
32
+
33
+ /* Professional header colors for light theme */
34
+ .main-header h1 {
35
+ color: #1f2937 !important;
36
+ }
37
+
38
+ .main-header p {
39
+ color: #6b7280 !important;
40
+ }
41
+
42
+ .chat-container { max-width: 900px; margin: auto; }
43
+
44
+ .user-bubble {
45
+ background: linear-gradient(90deg, #e3f2fd, #ffffff);
46
+ color: #222 !important; float: right; clear: both;
47
+ }
48
+ .ai-bubble {
49
+ background: linear-gradient(90deg, #f3e5f5, #ffffff);
50
+ color: #222 !important; float: left; clear: both;
51
+ }
52
+ .error-bubble {
53
+ background: #ffebee; color: #b71c1c !important; font-weight: bold;
54
+ float: left; clear: both;
55
+ }
56
+
57
+ /* Sidebar styling */
58
+ section[data-testid="stSidebar"] {
59
+ background: #e3f2fd !important;
60
+ border-right: 2px solid #1976d2;
61
+ }
62
+
63
+ /* Sidebar text should be dark */
64
+ section[data-testid="stSidebar"] * {
65
+ color: #1f2937 !important;
66
+ }
67
+
68
+ .sidebar-content { padding: 1rem; }
69
+
70
+ /* Input fields in light mode */
71
+ .stTextInput > div > div > input {
72
+ background-color: #ffffff !important;
73
+ color: #1f2937 !important;
74
+ border: 1px solid #d1d5db !important;
75
+ }
76
+
77
+ /* Select boxes in light mode */
78
+ .stSelectbox > div > div > select {
79
+ background-color: #ffffff !important;
80
+ color: #1f2937 !important;
81
+ border: 1px solid #d1d5db !important;
82
+ }
83
+
84
+ /* Dropdown options in light mode */
85
+ .stSelectbox > div > div > select > option {
86
+ background-color: #ffffff !important;
87
+ color: #1f2937 !important;
88
+ }
89
+
90
+ /* Buttons in light mode - Strong selectors for all buttons */
91
+ .stButton > button {
92
+ background: linear-gradient(45deg, #667eea, #764ba2) !important;
93
+ color: #ffffff !important;
94
+ border: none !important;
95
+ font-weight: 600 !important;
96
+ border-radius: 8px !important;
97
+ padding: 0.6rem 1.2rem !important;
98
+ min-height: 2.8rem !important;
99
+ font-size: 14px !important;
100
+ transition: all 0.2s ease !important;
101
+ }
102
+
103
+ .stButton > button:hover {
104
+ background: linear-gradient(45deg, #5a67d8, #6b46c1) !important;
105
+ color: #ffffff !important;
106
+ transform: translateY(-1px) !important;
107
+ }
108
+
109
+ /* Ensure button text stays white - stronger selectors */
110
+ .stButton > button span,
111
+ .stButton > button div,
112
+ .stButton > button *,
113
+ .stButton > button p {
114
+ color: #ffffff !important;
115
+ font-weight: 600 !important;
116
+ }
117
+
118
+ /* Specific targeting for all button states and text elements */
119
+ button[kind="primary"],
120
+ button[kind="secondary"],
121
+ div[data-testid="stButton"] > button,
122
+ .stButton button {
123
+ background: linear-gradient(45deg, #667eea, #764ba2) !important;
124
+ color: #ffffff !important;
125
+ border: none !important;
126
+ font-weight: 600 !important;
127
+ }
128
+
129
+ /* All text inside buttons must be white */
130
+ button[kind="primary"] *,
131
+ button[kind="primary"] span,
132
+ button[kind="primary"] div,
133
+ button[kind="primary"] p,
134
+ button[kind="secondary"] *,
135
+ button[kind="secondary"] span,
136
+ button[kind="secondary"] div,
137
+ button[kind="secondary"] p,
138
+ div[data-testid="stButton"] > button *,
139
+ div[data-testid="stButton"] > button span,
140
+ div[data-testid="stButton"] > button div,
141
+ div[data-testid="stButton"] > button p,
142
+ .stButton button *,
143
+ .stButton button span,
144
+ .stButton button div,
145
+ .stButton button p {
146
+ color: #ffffff !important;
147
+ font-weight: 600 !important;
148
+ }
149
+
150
+ button[kind="primary"]:hover,
151
+ button[kind="secondary"]:hover,
152
+ div[data-testid="stButton"] > button:hover,
153
+ .stButton button:hover {
154
+ background: linear-gradient(45deg, #5a67d8, #6b46c1) !important;
155
+ color: #ffffff !important;
156
+ }
157
+
158
+ /* Hover state text colors */
159
+ button[kind="primary"]:hover *,
160
+ button[kind="secondary"]:hover *,
161
+ div[data-testid="stButton"] > button:hover *,
162
+ .stButton button:hover * {
163
+ color: #ffffff !important;
164
+ }
165
+
166
+ /* Force all button text elements to be white */
167
+ .stButton > button * {
168
+ color: #ffffff !important;
169
+ }
170
+
171
+ /* Disabled button styling */
172
+ .stButton > button:disabled {
173
+ background: #9ca3af !important;
174
+ color: #ffffff !important;
175
+ opacity: 0.6 !important;
176
+ }
177
+
178
+ .stButton > button:disabled * {
179
+ color: #ffffff !important;
180
+ }
181
+
182
+ /* Copy button styling in light mode */
183
+ .copy-button {
184
+ background: #6b7280 !important;
185
+ color: #ffffff !important;
186
+ border: none !important;
187
+ }
188
+
189
+ .copy-button:hover {
190
+ background: #4b5563 !important;
191
+ color: #ffffff !important;
192
+ }
193
+
194
+ /* Labels and form elements */
195
+ .stTextInput label, .stSelectbox label, .stRadio label, .stCheckbox label {
196
+ color: #1f2937 !important;
197
+ font-weight: 500 !important;
198
+ }
199
+
200
+ /* Metrics in light mode */
201
+ [data-testid="metric-container"] {
202
+ background: #ffffff !important;
203
+ border: 1px solid #e5e7eb !important;
204
+ color: #1f2937 !important;
205
+ }
206
+
207
+ [data-testid="metric-container"] * {
208
+ color: #1f2937 !important;
209
+ }
210
+
211
+ /* Additional button overrides for light theme */
212
+ div[data-testid="column"] .stButton > button {
213
+ background: linear-gradient(45deg, #667eea, #764ba2) !important;
214
+ color: #ffffff !important;
215
+ }
216
+
217
+ /* Force override of any inherited text colors */
218
+ .stButton > button p, .stButton > button div, .stButton > button span {
219
+ color: #ffffff !important;
220
+ }
221
+
222
+ /* Hide Streamlit toolbar and menu */
223
+ .stToolbar {
224
+ display: none !important;
225
+ }
226
+
227
+ /* Hide Streamlit header menu */
228
+ header[data-testid="stHeader"] {
229
+ display: none !important;
230
+ }
231
+
232
+ /* Hide the running/rerun indicator */
233
+ .stAppView > .main .block-container {
234
+ padding-top: 1rem !important;
235
+ }
236
+ """
237
+
238
+ def _get_dark_theme(self) -> str:
239
+ """Returns CSS for dark theme."""
240
+ return """
241
+ /* Modern Dark Theme - High Contrast & Clean */
242
+ body, .stApp {
243
+ background: #1a1a1a !important;
244
+ color: #ffffff !important;
245
+ }
246
+
247
+ /* Override all text colors for visibility */
248
+ div, p, span, label, h1, h2, h3, h4, h5, h6 {
249
+ color: #ffffff !important;
250
+ }
251
+
252
+ /* Professional header colors for dark theme */
253
+ .main-header h1 {
254
+ color: #ffffff !important;
255
+ }
256
+
257
+ .main-header p {
258
+ color: #9ca3af !important;
259
+ }
260
+
261
+ /* Main container */
262
+ .chat-container {
263
+ max-width: 900px;
264
+ margin: auto;
265
+ }
266
+
267
+ /* User message bubble - Clean Blue */
268
+ .user-bubble {
269
+ background: #2563eb !important;
270
+ color: #ffffff !important;
271
+ float: right;
272
+ clear: both;
273
+ border: none !important;
274
+ box-shadow: 0 2px 10px rgba(37, 99, 235, 0.4) !important;
275
+ }
276
+
277
+ /* AI message bubble - Clean Dark Gray */
278
+ .ai-bubble {
279
+ background: #374151 !important;
280
+ color: #ffffff !important;
281
+ float: left;
282
+ clear: both;
283
+ border: 1px solid #4b5563 !important;
284
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3) !important;
285
+ }
286
+
287
+ /* Error bubble - Clean Red */
288
+ .error-bubble {
289
+ background: #dc2626 !important;
290
+ color: #ffffff !important;
291
+ font-weight: bold;
292
+ float: left;
293
+ clear: both;
294
+ border: none !important;
295
+ box-shadow: 0 2px 10px rgba(220, 38, 38, 0.4) !important;
296
+ }
297
+
298
+ /* Sidebar - Clean Dark */
299
+ section[data-testid="stSidebar"] {
300
+ background: #2d2d2d !important;
301
+ border-right: 2px solid #4b5563 !important;
302
+ }
303
+
304
+ /* Sidebar content visibility */
305
+ section[data-testid="stSidebar"] * {
306
+ color: #ffffff !important;
307
+ }
308
+
309
+ /* Input fields - High contrast */
310
+ .stTextInput > div > div > input {
311
+ background-color: #374151 !important;
312
+ color: #ffffff !important;
313
+ border: 2px solid #6b7280 !important;
314
+ }
315
+
316
+ /* Select boxes - High contrast */
317
+ .stSelectbox > div > div > select {
318
+ background-color: #374151 !important;
319
+ color: #ffffff !important;
320
+ border: 2px solid #6b7280 !important;
321
+ }
322
+
323
+ /* Dropdown options styling */
324
+ .stSelectbox > div > div > select > option {
325
+ background-color: #374151 !important;
326
+ color: #ffffff !important;
327
+ }
328
+
329
+ /* Alternative dropdown styling for better browser support */
330
+ div[data-baseweb="select"] {
331
+ background-color: #374151 !important;
332
+ }
333
+
334
+ div[data-baseweb="select"] > div {
335
+ background-color: #374151 !important;
336
+ color: #ffffff !important;
337
+ border: 2px solid #6b7280 !important;
338
+ }
339
+
340
+ /* Dropdown menu styling */
341
+ ul[role="listbox"] {
342
+ background-color: #374151 !important;
343
+ border: 1px solid #6b7280 !important;
344
+ }
345
+
346
+ li[role="option"] {
347
+ background-color: #374151 !important;
348
+ color: #ffffff !important;
349
+ }
350
+
351
+ li[role="option"]:hover {
352
+ background-color: #4b5563 !important;
353
+ color: #ffffff !important;
354
+ }
355
+
356
+ /* Buttons - Clean and visible - Strong selectors for all buttons */
357
+ .stButton > button {
358
+ background: #2563eb !important;
359
+ color: #ffffff !important;
360
+ border: none !important;
361
+ font-weight: 600 !important;
362
+ border-radius: 8px !important;
363
+ padding: 0.6rem 1.2rem !important;
364
+ min-height: 2.8rem !important;
365
+ font-size: 14px !important;
366
+ transition: all 0.2s ease !important;
367
+ }
368
+
369
+ .stButton > button:hover {
370
+ background: #1d4ed8 !important;
371
+ color: #ffffff !important;
372
+ transform: translateY(-1px) !important;
373
+ }
374
+
375
+ /* Ensure button text stays white - stronger selectors */
376
+ .stButton > button span,
377
+ .stButton > button div,
378
+ .stButton > button *,
379
+ .stButton > button p {
380
+ color: #ffffff !important;
381
+ font-weight: 600 !important;
382
+ }
383
+
384
+ /* Specific targeting for all button states and text elements */
385
+ button[kind="primary"],
386
+ button[kind="secondary"],
387
+ div[data-testid="stButton"] > button,
388
+ .stButton button {
389
+ background: #2563eb !important;
390
+ color: #ffffff !important;
391
+ border: none !important;
392
+ font-weight: 600 !important;
393
+ }
394
+
395
+ /* All text inside buttons must be white */
396
+ button[kind="primary"] *,
397
+ button[kind="primary"] span,
398
+ button[kind="primary"] div,
399
+ button[kind="primary"] p,
400
+ button[kind="secondary"] *,
401
+ button[kind="secondary"] span,
402
+ button[kind="secondary"] div,
403
+ button[kind="secondary"] p,
404
+ div[data-testid="stButton"] > button *,
405
+ div[data-testid="stButton"] > button span,
406
+ div[data-testid="stButton"] > button div,
407
+ div[data-testid="stButton"] > button p,
408
+ .stButton button *,
409
+ .stButton button span,
410
+ .stButton button div,
411
+ .stButton button p {
412
+ color: #ffffff !important;
413
+ font-weight: 600 !important;
414
+ }
415
+
416
+ button[kind="primary"]:hover,
417
+ button[kind="secondary"]:hover,
418
+ div[data-testid="stButton"] > button:hover,
419
+ .stButton button:hover {
420
+ background: #1d4ed8 !important;
421
+ color: #ffffff !important;
422
+ }
423
+
424
+ /* Hover state text colors */
425
+ button[kind="primary"]:hover *,
426
+ button[kind="secondary"]:hover *,
427
+ div[data-testid="stButton"] > button:hover *,
428
+ .stButton button:hover * {
429
+ color: #ffffff !important;
430
+ }
431
+
432
+ /* Expander - Clean styling */
433
+ .streamlit-expanderHeader {
434
+ background-color: #374151 !important;
435
+ color: #ffffff !important;
436
+ border: 1px solid #6b7280 !important;
437
+ }
438
+
439
+ /* Metrics - High visibility */
440
+ [data-testid="metric-container"] {
441
+ background: #374151 !important;
442
+ border: 1px solid #6b7280 !important;
443
+ color: #ffffff !important;
444
+ }
445
+
446
+ [data-testid="metric-container"] * {
447
+ color: #ffffff !important;
448
+ }
449
+
450
+ /* Code blocks - GitHub dark style */
451
+ pre, code {
452
+ background-color: #0d1117 !important;
453
+ color: #f0f6fc !important;
454
+ border: 1px solid #30363d !important;
455
+ }
456
+
457
+ /* Tables - Clean dark */
458
+ .dataframe, .dataframe * {
459
+ background-color: #374151 !important;
460
+ color: #ffffff !important;
461
+ }
462
+
463
+ /* Table headers */
464
+ .dataframe th {
465
+ background-color: #2563eb !important;
466
+ color: #ffffff !important;
467
+ }
468
+
469
+ /* Streamlit widgets override */
470
+ .stRadio > div, .stCheckbox > div {
471
+ color: #ffffff !important;
472
+ }
473
+
474
+ .stRadio > div > label, .stCheckbox > div > label {
475
+ color: #ffffff !important;
476
+ }
477
+
478
+ /* Spinner for dark theme */
479
+ .stSpinner > div {
480
+ border-top-color: #2563eb !important;
481
+ }
482
+
483
+ /* Success/Info messages */
484
+ .stSuccess, .stInfo {
485
+ background-color: #374151 !important;
486
+ color: #ffffff !important;
487
+ border: 1px solid #6b7280 !important;
488
+ }
489
+
490
+ /* Warning messages */
491
+ .stWarning {
492
+ background-color: #f59e0b !important;
493
+ color: #000000 !important;
494
+ }
495
+
496
+ /* Error messages */
497
+ .stError {
498
+ background-color: #dc2626 !important;
499
+ color: #ffffff !important;
500
+ }
501
+
502
+ /* Additional dropdown fixes */
503
+ .stSelectbox [data-testid="stMarkdownContainer"] {
504
+ color: #ffffff !important;
505
+ }
506
+
507
+ /* Streamlit's custom selectbox */
508
+ div[data-testid="stSelectbox"] div[data-testid="stMarkdownContainer"] p {
509
+ color: #ffffff !important;
510
+ }
511
+
512
+ /* Multi-select components */
513
+ .stMultiSelect > div > div > div {
514
+ background-color: #374151 !important;
515
+ color: #ffffff !important;
516
+ border: 2px solid #6b7280 !important;
517
+ }
518
+
519
+ /* Radio button labels */
520
+ .stRadio div[role="radiogroup"] label {
521
+ color: #ffffff !important;
522
+ }
523
+
524
+ /* Hide Streamlit toolbar and menu */
525
+ .stToolbar {
526
+ display: none !important;
527
+ }
528
+
529
+ /* Hide Streamlit header menu */
530
+ header[data-testid="stHeader"] {
531
+ display: none !important;
532
+ }
533
+
534
+ /* Hide the running/rerun indicator */
535
+ .stAppView > .main .block-container {
536
+ padding-top: 1rem !important;
537
+ }
538
+ """
539
+
540
+ def _get_custom_theme(self) -> str:
541
+ """Returns CSS for custom theme."""
542
+ return """
543
+ body, .stApp { background: #fffbe7 !important; }
544
+ .chat-container { max-width: 900px; margin: auto; }
545
+ .user-bubble {
546
+ background: linear-gradient(90deg, #ffe082, #fffbe7);
547
+ color: #6d4c00;
548
+ float: right;
549
+ clear: both;
550
+ }
551
+ .ai-bubble {
552
+ background: linear-gradient(90deg, #b2dfdb, #fffbe7);
553
+ color: #004d40;
554
+ float: left;
555
+ clear: both;
556
+ }
557
+ .error-bubble {
558
+ background: #ffe0b2; color: #b71c1c; font-weight: bold;
559
+ float: left; clear: both;
560
+ }
561
+ section[data-testid="stSidebar"] { background: #ffe082 !important; }
562
+
563
+ /* Hide Streamlit toolbar and menu */
564
+ .stToolbar {
565
+ display: none !important;
566
+ }
567
+
568
+ /* Hide Streamlit header menu */
569
+ header[data-testid="stHeader"] {
570
+ display: none !important;
571
+ }
572
+
573
+ /* Hide the running/rerun indicator */
574
+ .stAppView > .main .block-container {
575
+ padding-top: 1rem !important;
576
+ }
577
+ """
578
+
579
+ def _get_common_styles(self) -> str:
580
+ """Returns common CSS styles for all themes."""
581
+ return """
582
+ /* Professional header styling */
583
+ .main-header h1 {
584
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif !important;
585
+ font-weight: 600 !important;
586
+ line-height: 1.2 !important;
587
+ margin: 0 !important;
588
+ }
589
+
590
+ .main-header p {
591
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif !important;
592
+ font-weight: 400 !important;
593
+ line-height: 1.4 !important;
594
+ margin: 0 !important;
595
+ }
596
+
597
+ .user-bubble, .ai-bubble, .error-bubble {
598
+ border-radius: 14px;
599
+ padding: 10px 14px;
600
+ margin: 6px 0;
601
+ display: inline-block;
602
+ max-width: 75%;
603
+ word-wrap: break-word;
604
+ box-shadow: 0 2px 8px rgba(0,0,0,0.25);
605
+ }
606
+ .spinner {
607
+ display: inline-block;
608
+ width: 1.3em;
609
+ height: 1.3em;
610
+ border: 3px solid #e0e0e0;
611
+ border-top: 3px solid #2193b0;
612
+ border-radius: 50%;
613
+ animation: spin 0.8s linear infinite;
614
+ margin-right: 0.7em;
615
+ }
616
+ @keyframes spin {
617
+ 0% { transform: rotate(0deg); }
618
+ 100% { transform: rotate(360deg); }
619
+ }
620
+
621
+ /* Enhanced button styling for all themes */
622
+ .stButton > button {
623
+ border-radius: 8px !important;
624
+ font-weight: 600 !important;
625
+ transition: all 0.2s ease !important;
626
+ padding: 0.6rem 1.2rem !important;
627
+ min-height: 2.8rem !important;
628
+ font-size: 14px !important;
629
+ border: none !important;
630
+ }
631
+
632
+ .stButton > button:hover {
633
+ transform: translateY(-1px) !important;
634
+ }
635
+
636
+ /* Force all button text elements to maintain proper colors - Super strong selectors */
637
+ .stButton > button *,
638
+ .stButton > button p,
639
+ .stButton > button div,
640
+ .stButton > button span,
641
+ .stButton > button strong,
642
+ .stButton > button em,
643
+ .stButton > button i,
644
+ .stButton > button b,
645
+ div[data-testid="stButton"] > button *,
646
+ div[data-testid="stButton"] > button p,
647
+ div[data-testid="stButton"] > button div,
648
+ div[data-testid="stButton"] > button span,
649
+ div[data-testid="stButton"] > button strong,
650
+ div[data-testid="stButton"] > button em,
651
+ div[data-testid="stButton"] > button i,
652
+ div[data-testid="stButton"] > button b,
653
+ button[kind="primary"] *,
654
+ button[kind="primary"] p,
655
+ button[kind="primary"] div,
656
+ button[kind="primary"] span,
657
+ button[kind="primary"] strong,
658
+ button[kind="primary"] em,
659
+ button[kind="primary"] i,
660
+ button[kind="primary"] b,
661
+ button[kind="secondary"] *,
662
+ button[kind="secondary"] p,
663
+ button[kind="secondary"] div,
664
+ button[kind="secondary"] span,
665
+ button[kind="secondary"] strong,
666
+ button[kind="secondary"] em,
667
+ button[kind="secondary"] i,
668
+ button[kind="secondary"] b,
669
+ .stButton button *,
670
+ .stButton button p,
671
+ .stButton button div,
672
+ .stButton button span,
673
+ .stButton button strong,
674
+ .stButton button em,
675
+ .stButton button i,
676
+ .stButton button b {
677
+ font-weight: 600 !important;
678
+ /* Note: color is set by individual themes */
679
+ }
680
+
681
+ /* Additional strong selectors for buttons */
682
+ div[data-testid="stButton"] button,
683
+ .stButton button,
684
+ button[data-testid],
685
+ [data-testid="stButton"] button {
686
+ border-radius: 8px !important;
687
+ font-weight: 600 !important;
688
+ transition: all 0.2s ease !important;
689
+ padding: 0.6rem 1.2rem !important;
690
+ min-height: 2.8rem !important;
691
+ font-size: 14px !important;
692
+ border: none !important;
693
+ }
694
+
695
+ /* Ensure ALL button content inherits button color */
696
+ .stButton > button,
697
+ div[data-testid="stButton"] > button,
698
+ button[kind="primary"],
699
+ button[kind="secondary"],
700
+ .stButton button {
701
+ /* Background and text colors are set by individual themes */
702
+ }
703
+
704
+ /* Critical: Force text color inheritance from button */
705
+ .stButton > button *,
706
+ div[data-testid="stButton"] > button *,
707
+ button[kind="primary"] *,
708
+ button[kind="secondary"] *,
709
+ .stButton button * {
710
+ color: inherit !important;
711
+ }
712
+
713
+ /* Scrollbar styling */
714
+ ::-webkit-scrollbar {
715
+ width: 8px;
716
+ height: 8px;
717
+ }
718
+ ::-webkit-scrollbar-track {
719
+ background: rgba(0,0,0,0.1);
720
+ border-radius: 4px;
721
+ }
722
+ ::-webkit-scrollbar-thumb {
723
+ background: rgba(128,128,128,0.4);
724
+ border-radius: 4px;
725
+ }
726
+ ::-webkit-scrollbar-thumb:hover {
727
+ background: rgba(128,128,128,0.6);
728
+ }
729
+ """
730
+
731
+ def inject_theme(self, theme_name: str) -> None:
732
+ """Inject the selected theme CSS into the Streamlit app."""
733
+ if theme_name not in self.themes:
734
+ theme_name = "Light" # Default fallback
735
+
736
+ theme_css = self.themes[theme_name]
737
+ common_css = self._get_common_styles()
738
+ combined_css = theme_css + common_css
739
+
740
+ st.markdown(f"<style>{combined_css}</style>", unsafe_allow_html=True)