Migrate chatbot to Socratic sentiment tutor with Gemini Live Voice Session capability
Browse files- backend/benchmark_b.py +3 -3
- backend/main.py +221 -793
- frontend/src/App.css +225 -16
- frontend/src/App.jsx +82 -127
- frontend/src/components/ChatWindow.jsx +221 -451
- frontend/src/components/EmotionChart.jsx +0 -97
- frontend/src/components/Settings.jsx +8 -34
- frontend/src/components/VoiceSessionModal.jsx +227 -0
backend/benchmark_b.py
CHANGED
|
@@ -74,10 +74,10 @@ for idx, (cat, q) in enumerate(samples, 1):
|
|
| 74 |
results.append({
|
| 75 |
"category": cat,
|
| 76 |
"query": q,
|
| 77 |
-
"latency_b": res_data["
|
| 78 |
-
"tokens_b": res_data["
|
| 79 |
})
|
| 80 |
-
print(f" Done: B ({res_data['
|
| 81 |
# Add a small delay between requests
|
| 82 |
time.sleep(1.5)
|
| 83 |
except Exception as e:
|
|
|
|
| 74 |
results.append({
|
| 75 |
"category": cat,
|
| 76 |
"query": q,
|
| 77 |
+
"latency_b": res_data["latency"],
|
| 78 |
+
"tokens_b": res_data["tokens"]
|
| 79 |
})
|
| 80 |
+
print(f" Done: B ({res_data['latency']}s, {res_data['tokens']}t)")
|
| 81 |
# Add a small delay between requests
|
| 82 |
time.sleep(1.5)
|
| 83 |
except Exception as e:
|
backend/main.py
CHANGED
|
@@ -1,25 +1,23 @@
|
|
| 1 |
-
from fastapi import FastAPI, HTTPException
|
| 2 |
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
from fastapi.staticfiles import StaticFiles
|
| 4 |
import time
|
| 5 |
import os
|
| 6 |
-
import threading
|
| 7 |
-
import csv
|
| 8 |
import re
|
|
|
|
|
|
|
| 9 |
from datetime import datetime
|
| 10 |
-
from typing import List, Optional,
|
| 11 |
-
from pydantic import BaseModel
|
| 12 |
from dotenv import load_dotenv
|
| 13 |
|
| 14 |
-
# LangChain /
|
| 15 |
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 16 |
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
|
| 17 |
-
from langchain_core.prompts import ChatPromptTemplate
|
| 18 |
-
from langgraph.graph import StateGraph, END
|
| 19 |
|
| 20 |
load_dotenv()
|
| 21 |
|
| 22 |
-
app = FastAPI(title="
|
| 23 |
|
| 24 |
# Enable CORS for frontend integration
|
| 25 |
app.add_middleware(
|
|
@@ -30,88 +28,6 @@ app.add_middleware(
|
|
| 30 |
allow_headers=["*"],
|
| 31 |
)
|
| 32 |
|
| 33 |
-
# HuggingFace model state variables (DistilRoBERTa)
|
| 34 |
-
classifier = None
|
| 35 |
-
model_status = "loading"
|
| 36 |
-
model_error = None
|
| 37 |
-
|
| 38 |
-
# HuggingFace NER state variables
|
| 39 |
-
ner_classifier = None
|
| 40 |
-
ner_status = "loading"
|
| 41 |
-
ner_error = None
|
| 42 |
-
|
| 43 |
-
def load_distilroberta():
|
| 44 |
-
global classifier, model_status, model_error
|
| 45 |
-
try:
|
| 46 |
-
print("Loading j-hartmann/emotion-english-distilroberta-base model...")
|
| 47 |
-
# Import transformers inside the loader function to make startup instantaneous
|
| 48 |
-
from transformers import pipeline
|
| 49 |
-
classifier = pipeline(
|
| 50 |
-
"text-classification",
|
| 51 |
-
model="j-hartmann/emotion-english-distilroberta-base",
|
| 52 |
-
top_k=None
|
| 53 |
-
)
|
| 54 |
-
model_status = "ready"
|
| 55 |
-
print("DistilRoBERTa model loaded successfully!")
|
| 56 |
-
except Exception as e:
|
| 57 |
-
model_error = str(e)
|
| 58 |
-
model_status = "failed"
|
| 59 |
-
print(f"Error loading DistilRoBERTa model: {model_error}")
|
| 60 |
-
|
| 61 |
-
def load_ner_model():
|
| 62 |
-
global ner_classifier, ner_status, ner_error
|
| 63 |
-
try:
|
| 64 |
-
print("Loading NER model (dslim/distilbert-NER)...")
|
| 65 |
-
from transformers import pipeline
|
| 66 |
-
ner_classifier = pipeline(
|
| 67 |
-
"ner",
|
| 68 |
-
model="dslim/distilbert-NER",
|
| 69 |
-
aggregation_strategy="simple"
|
| 70 |
-
)
|
| 71 |
-
ner_status = "ready"
|
| 72 |
-
print("NER model loaded successfully!")
|
| 73 |
-
except Exception as e:
|
| 74 |
-
ner_error = str(e)
|
| 75 |
-
ner_status = "failed"
|
| 76 |
-
print(f"Error loading NER model: {ner_error}")
|
| 77 |
-
|
| 78 |
-
def scrub_pii(text: str) -> str:
|
| 79 |
-
if not text:
|
| 80 |
-
return text
|
| 81 |
-
|
| 82 |
-
# 1. Regex PII scrubbing
|
| 83 |
-
# Email addresses
|
| 84 |
-
text = re.sub(r'[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+', '[EMAIL]', text)
|
| 85 |
-
# Phone numbers (safe regex for standard forms like 555-555-5555, +1-555-555-5555, (555) 555-5555)
|
| 86 |
-
text = re.sub(r'\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b', '[PHONE]', text)
|
| 87 |
-
# IP Addresses
|
| 88 |
-
text = re.sub(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', '[IP_ADDRESS]', text)
|
| 89 |
-
# SSNs
|
| 90 |
-
text = re.sub(r'\b\d{3}-\d{2}-\d{4}\b', '[SSN]', text)
|
| 91 |
-
|
| 92 |
-
# 2. NER PII scrubbing (Person, Location, Organization)
|
| 93 |
-
global ner_classifier
|
| 94 |
-
if ner_classifier is not None:
|
| 95 |
-
try:
|
| 96 |
-
entities = ner_classifier(text)
|
| 97 |
-
# Sort from right to left (reverse index order) to avoid shift offset issues
|
| 98 |
-
entities = sorted(entities, key=lambda x: x["start"], reverse=True)
|
| 99 |
-
for ent in entities:
|
| 100 |
-
ent_type = ent["entity_group"]
|
| 101 |
-
if ent_type in ["PER", "LOC", "ORG"]:
|
| 102 |
-
start = ent["start"]
|
| 103 |
-
end = ent["end"]
|
| 104 |
-
text = text[:start] + f"[{ent_type}]" + text[end:]
|
| 105 |
-
except Exception as e:
|
| 106 |
-
print(f"NER PII scrub error: {e}")
|
| 107 |
-
|
| 108 |
-
return text
|
| 109 |
-
|
| 110 |
-
@app.on_event("startup")
|
| 111 |
-
def startup_event():
|
| 112 |
-
threading.Thread(target=load_distilroberta, daemon=True).start()
|
| 113 |
-
threading.Thread(target=load_ner_model, daemon=True).start()
|
| 114 |
-
|
| 115 |
# Pydantic Schemas
|
| 116 |
class ChatMessage(BaseModel):
|
| 117 |
role: str # "user" or "assistant"
|
|
@@ -120,69 +36,15 @@ class ChatMessage(BaseModel):
|
|
| 120 |
class ChatRequest(BaseModel):
|
| 121 |
message: str
|
| 122 |
gemini_api_key: Optional[str] = None
|
| 123 |
-
|
| 124 |
-
history_a: Optional[List[ChatMessage]] = None
|
| 125 |
-
history_b: Optional[List[ChatMessage]] = None
|
| 126 |
-
history_c: Optional[List[ChatMessage]] = None
|
| 127 |
-
history_d: Optional[List[ChatMessage]] = None
|
| 128 |
-
selected_option: Optional[str] = "all" # "all", "A", "B", "C", "D"
|
| 129 |
-
|
| 130 |
-
class EmotionScore(BaseModel):
|
| 131 |
-
label: str
|
| 132 |
-
score: float
|
| 133 |
-
|
| 134 |
-
class SentimentDetailsA(BaseModel):
|
| 135 |
-
detected_sentiment: str
|
| 136 |
-
explanation: str
|
| 137 |
-
|
| 138 |
-
class SentimentDetailsB(BaseModel):
|
| 139 |
-
mapped_sentiment: str
|
| 140 |
-
raw_emotions: List[EmotionScore]
|
| 141 |
|
| 142 |
class ChatResponse(BaseModel):
|
| 143 |
-
sentiment_a: Optional[SentimentDetailsA] = None
|
| 144 |
-
response_a: Optional[str] = None
|
| 145 |
-
latency_a: Optional[float] = None
|
| 146 |
-
prompt_context_a: Optional[str] = None
|
| 147 |
-
|
| 148 |
-
sentiment_b: Optional[SentimentDetailsB] = None
|
| 149 |
-
response_b: Optional[str] = None
|
| 150 |
-
latency_b: Optional[float] = None
|
| 151 |
-
prompt_context_b: Optional[str] = None
|
| 152 |
-
|
| 153 |
-
response_c: Optional[str] = None
|
| 154 |
-
latency_c: Optional[float] = None
|
| 155 |
-
prompt_context_c: Optional[str] = None
|
| 156 |
-
|
| 157 |
-
sentiment_d: Optional[SentimentDetailsB] = None
|
| 158 |
-
response_d: Optional[str] = None
|
| 159 |
-
latency_d: Optional[float] = None
|
| 160 |
-
prompt_context_d: Optional[str] = None
|
| 161 |
-
|
| 162 |
-
tokens_a: Optional[int] = None
|
| 163 |
-
tokens_b: Optional[int] = None
|
| 164 |
-
tokens_c: Optional[int] = None
|
| 165 |
-
tokens_d: Optional[int] = None
|
| 166 |
-
|
| 167 |
-
# State definition for LangGraph
|
| 168 |
-
class AgentState(TypedDict):
|
| 169 |
-
message: str
|
| 170 |
-
system_prompt: str
|
| 171 |
sentiment: str
|
| 172 |
-
explanation: str
|
| 173 |
response: str
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
# Pydantic model for LangChain Structured Output
|
| 179 |
-
class SentimentAnalysis(BaseModel):
|
| 180 |
-
detected_sentiment: str = Field(description="Must be strictly one of: 'confusion', 'frustration', 'boredom', 'confidence', 'sadness', or 'neutral'.")
|
| 181 |
-
explanation: str = Field(description="An extremely concise, single-sentence explanation of why this sentiment was chosen to minimize tokens.")
|
| 182 |
-
|
| 183 |
-
class SentimentAndResponseB(BaseModel):
|
| 184 |
-
detected_sentiment: str = Field(description="Must be strictly one of: 'confusion', 'frustration', 'boredom', 'confidence', 'sadness', or 'neutral'.")
|
| 185 |
-
response: str = Field(description="Your Socratic tutor response. Adjust tone based on the detected sentiment. Keep it under 2 brief paragraphs.")
|
| 186 |
|
| 187 |
# Token estimation helper (using standard ~4 characters per token multiplier for English)
|
| 188 |
def estimate_tokens(text: str) -> int:
|
|
@@ -195,116 +57,7 @@ def calculate_cost(input_tokens: int, output_tokens: int) -> float:
|
|
| 195 |
output_cost = (output_tokens / 1_000_000.0) * 0.30
|
| 196 |
return input_cost + output_cost
|
| 197 |
|
| 198 |
-
#
|
| 199 |
-
MD_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sentiment_log.md")
|
| 200 |
-
MD_FILE_B = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sentiment_log_b.md")
|
| 201 |
-
|
| 202 |
-
def log_to_md(question, sentiment_a, sentiment_b, sentiment_d, latency_a, latency_b, latency_c, latency_d, cost_a, cost_b, cost_c, cost_d, tokens_in_a, tokens_out_a, tokens_in_b, tokens_out_b, tokens_in_c, tokens_out_c, tokens_in_d, tokens_out_d, answer_a, answer_b, answer_c, answer_d, selected_option="all"):
|
| 203 |
-
target_file = MD_FILE_B if selected_option == "b" else MD_FILE
|
| 204 |
-
file_exists = os.path.exists(target_file)
|
| 205 |
-
try:
|
| 206 |
-
with open(target_file, mode="a", encoding="utf-8") as f:
|
| 207 |
-
if not file_exists:
|
| 208 |
-
if selected_option == "b":
|
| 209 |
-
f.write("# Sentiment Analysis Option B Log\n\n")
|
| 210 |
-
f.write("This file tracks Option B (Gemini Single-Pass) user queries, detected sentiments, latencies, estimated costs, and responses.\n\n")
|
| 211 |
-
else:
|
| 212 |
-
f.write("# Sentiment Analysis & Response Comparison Log\n\n")
|
| 213 |
-
f.write("This file tracks and compares user queries, detected sentiments, latencies, estimated costs, and responses across all options.\n\n")
|
| 214 |
-
|
| 215 |
-
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 216 |
-
|
| 217 |
-
f.write(f"## [{timestamp}] Query: \"{question}\"\n\n")
|
| 218 |
-
if selected_option == "b":
|
| 219 |
-
total_tokens_b = tokens_in_b + tokens_out_b
|
| 220 |
-
f.write("<table>\n")
|
| 221 |
-
f.write(" <thead>\n")
|
| 222 |
-
f.write(" <tr>\n")
|
| 223 |
-
f.write(" <th align=\"left\">Metric</th>\n")
|
| 224 |
-
f.write(" <th align=\"left\">Option B (Gemini Single-Pass)</th>\n")
|
| 225 |
-
f.write(" </tr>\n")
|
| 226 |
-
f.write(" </thead>\n")
|
| 227 |
-
f.write(" <tbody>\n")
|
| 228 |
-
f.write(" <tr>\n")
|
| 229 |
-
f.write(f" <td><strong>Detected Sentiment</strong></td>\n")
|
| 230 |
-
f.write(f" <td><code>{sentiment_b}</code></td>\n")
|
| 231 |
-
f.write(" </tr>\n")
|
| 232 |
-
f.write(" <tr>\n")
|
| 233 |
-
f.write(f" <td><strong>Latency</strong></td>\n")
|
| 234 |
-
f.write(f" <td>{round(latency_b, 3)}s</td>\n")
|
| 235 |
-
f.write(" </tr>\n")
|
| 236 |
-
f.write(" <tr>\n")
|
| 237 |
-
f.write(f" <td><strong>Estimated Cost</strong></td>\n")
|
| 238 |
-
f.write(f" <td><code>${cost_b:.7f}</code></td>\n")
|
| 239 |
-
f.write(" </tr>\n")
|
| 240 |
-
f.write(" <tr>\n")
|
| 241 |
-
f.write(f" <td><strong>Tokens Used</strong></td>\n")
|
| 242 |
-
f.write(f" <td>{total_tokens_b} ({tokens_in_b} in / {tokens_out_b} out)</td>\n")
|
| 243 |
-
f.write(" </tr>\n")
|
| 244 |
-
f.write(" </tbody>\n")
|
| 245 |
-
f.write("</table>\n\n")
|
| 246 |
-
f.write("### Option B Response\n")
|
| 247 |
-
f.write(f"{answer_b}\n\n")
|
| 248 |
-
f.write("---\n\n")
|
| 249 |
-
else:
|
| 250 |
-
total_tokens_a = tokens_in_a + tokens_out_a
|
| 251 |
-
total_tokens_b = tokens_in_b + tokens_out_b
|
| 252 |
-
total_tokens_c = tokens_in_c + tokens_out_c
|
| 253 |
-
total_tokens_d = tokens_in_d + tokens_out_d
|
| 254 |
-
f.write("<table>\n")
|
| 255 |
-
f.write(" <thead>\n")
|
| 256 |
-
f.write(" <tr>\n")
|
| 257 |
-
f.write(" <th align=\"left\">Metric</th>\n")
|
| 258 |
-
f.write(" <th align=\"left\">Option A (Gemini 3.1 Flash Lite Double-Pass)</th>\n")
|
| 259 |
-
f.write(" <th align=\"left\">Option B (Gemini Single-Pass)</th>\n")
|
| 260 |
-
f.write(" <th align=\"left\">Option C (DistilRoBERTa Distribution + Gemini)</th>\n")
|
| 261 |
-
f.write(" <th align=\"left\">Option D (DistilRoBERTa Classifier + Gemini)</th>\n")
|
| 262 |
-
f.write(" </tr>\n")
|
| 263 |
-
f.write(" </thead>\n")
|
| 264 |
-
f.write(" <tbody>\n")
|
| 265 |
-
f.write(" <tr>\n")
|
| 266 |
-
f.write(f" <td><strong>Detected Sentiment</strong></td>\n")
|
| 267 |
-
f.write(f" <td><code>{sentiment_a}</code></td>\n")
|
| 268 |
-
f.write(f" <td><code>{sentiment_b}</code></td>\n")
|
| 269 |
-
f.write(f" <td><code>Distribution Context</code></td>\n")
|
| 270 |
-
f.write(f" <td><code>{sentiment_d}</code></td>\n")
|
| 271 |
-
f.write(" </tr>\n")
|
| 272 |
-
f.write(" <tr>\n")
|
| 273 |
-
f.write(f" <td><strong>Latency</strong></td>\n")
|
| 274 |
-
f.write(f" <td>{round(latency_a, 3)}s</td>\n")
|
| 275 |
-
f.write(f" <td>{round(latency_b, 3)}s</td>\n")
|
| 276 |
-
f.write(f" <td>{round(latency_c, 3)}s</td>\n")
|
| 277 |
-
f.write(f" <td>{round(latency_d, 3)}s</td>\n")
|
| 278 |
-
f.write(" </tr>\n")
|
| 279 |
-
f.write(" <tr>\n")
|
| 280 |
-
f.write(f" <td><strong>Estimated Cost</strong></td>\n")
|
| 281 |
-
f.write(f" <td><code>${cost_a:.7f}</code></td>\n")
|
| 282 |
-
f.write(f" <td><code>${cost_b:.7f}</code></td>\n")
|
| 283 |
-
f.write(f" <td><code>${cost_c:.7f}</code></td>\n")
|
| 284 |
-
f.write(f" <td><code>${cost_d:.7f}</code></td>\n")
|
| 285 |
-
f.write(" </tr>\n")
|
| 286 |
-
f.write(" <tr>\n")
|
| 287 |
-
f.write(f" <td><strong>Tokens Used</strong></td>\n")
|
| 288 |
-
f.write(f" <td>{total_tokens_a} ({tokens_in_a} in / {tokens_out_a} out)</td>\n")
|
| 289 |
-
f.write(f" <td>{total_tokens_b} ({tokens_in_b} in / {tokens_out_b} out)</td>\n")
|
| 290 |
-
f.write(f" <td>{total_tokens_c} ({tokens_in_c} in / {tokens_out_c} out)</td>\n")
|
| 291 |
-
f.write(f" <td>{total_tokens_d} ({tokens_in_d} in / {tokens_out_d} out)</td>\n")
|
| 292 |
-
f.write(" </tr>\n")
|
| 293 |
-
f.write(" </tbody>\n")
|
| 294 |
-
f.write("</table>\n\n")
|
| 295 |
-
f.write("### Option A Response\n")
|
| 296 |
-
f.write(f"{answer_a}\n\n")
|
| 297 |
-
f.write("### Option B Response\n")
|
| 298 |
-
f.write(f"{answer_b}\n\n")
|
| 299 |
-
f.write("### Option C Response\n")
|
| 300 |
-
f.write(f"{answer_c}\n\n")
|
| 301 |
-
f.write("### Option D Response\n")
|
| 302 |
-
f.write(f"{answer_d}\n\n")
|
| 303 |
-
f.write("---\n\n")
|
| 304 |
-
except Exception as e:
|
| 305 |
-
print(f"Error writing to MD log: {e}")
|
| 306 |
-
|
| 307 |
-
# Helper to extract text from LangChain message content (which may be a list of dicts for Gemini models)
|
| 308 |
def get_text_content(content: Any) -> str:
|
| 309 |
if isinstance(content, str):
|
| 310 |
return content
|
|
@@ -318,208 +71,111 @@ def get_text_content(content: Any) -> str:
|
|
| 318 |
return "".join(text_parts)
|
| 319 |
return str(content)
|
| 320 |
|
| 321 |
-
#
|
| 322 |
-
def
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
# Confusion: high surprise and fear
|
| 335 |
-
confusion_score = emo_dict.get("surprise", 0.0) * 1.2 + emo_dict.get("fear", 0.0) * 0.8
|
| 336 |
-
|
| 337 |
-
# Frustration: high anger and disgust
|
| 338 |
-
frustration_score = emo_dict.get("anger", 0.0) * 1.2 + emo_dict.get("disgust", 0.0) * 0.8
|
| 339 |
-
|
| 340 |
-
# Boredom: high neutral, and if sadness is minor combined with high neutral
|
| 341 |
-
boredom_score = emo_dict.get("neutral", 0.0) * 1.3 + emo_dict.get("sadness", 0.0) * 0.2
|
| 342 |
-
|
| 343 |
-
# Confidence: driven by joy
|
| 344 |
-
confidence_score = emo_dict.get("joy", 0.0) * 1.2
|
| 345 |
-
|
| 346 |
-
# Sadness: driven by sadness
|
| 347 |
-
sadness_score = emo_dict.get("sadness", 0.0) * 1.2
|
| 348 |
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
"frustration": frustration_score,
|
| 352 |
-
"boredom": boredom_score,
|
| 353 |
-
"confidence": confidence_score,
|
| 354 |
-
"sadness": sadness_score
|
| 355 |
-
}
|
| 356 |
-
|
| 357 |
-
return max(scores, key=scores.get)
|
| 358 |
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
)
|
| 367 |
-
structured_llm = llm.with_structured_output(SentimentAnalysis)
|
| 368 |
-
|
| 369 |
-
def detect_sentiment_node(state: AgentState) -> dict:
|
| 370 |
-
prompt = ChatPromptTemplate.from_messages([
|
| 371 |
-
("system", "Analyze the user's educational query. Determine their emotional state. Classify it strictly as one of: 'confusion', 'frustration', 'boredom', 'confidence', 'sadness', or 'neutral'. Keep the explanation extremely short and concise (under 10 words)."),
|
| 372 |
-
("human", "{message}")
|
| 373 |
-
])
|
| 374 |
-
chain = prompt | structured_llm
|
| 375 |
-
res = chain.invoke({"message": state["message"]})
|
| 376 |
-
|
| 377 |
-
# Estimate input & output tokens
|
| 378 |
-
# Estimate input & output tokens
|
| 379 |
-
input_prompt = f"Analyze the user's educational query. Determine their emotional state. Classify it strictly as one of: 'confusion', 'frustration', 'boredom', 'confidence', 'sadness', or 'neutral'. {state['message']}"
|
| 380 |
-
est_input = estimate_tokens(input_prompt)
|
| 381 |
-
est_output = 40 # Sentiment response is very short
|
| 382 |
-
|
| 383 |
-
return {
|
| 384 |
-
"sentiment": res.detected_sentiment.lower(),
|
| 385 |
-
"explanation": res.explanation,
|
| 386 |
-
"input_tokens": est_input,
|
| 387 |
-
"output_tokens": est_output
|
| 388 |
-
}
|
| 389 |
-
|
| 390 |
-
def generate_response_node(state: AgentState) -> dict:
|
| 391 |
-
custom_system = state.get("system_prompt") or (
|
| 392 |
-
"You are a concise, Socratic educational tutor. Your focus is strictly to teach. "
|
| 393 |
-
"NEVER give the user the direct answer or solution. Instead, guide them, nudge them, and ask leading questions to help them figure it out. "
|
| 394 |
-
"Adjust your behavior and tone based on the user's sentiment. Keep responses brief (max 5 sentences)."
|
| 395 |
-
)
|
| 396 |
-
|
| 397 |
-
sentiment = state["sentiment"]
|
| 398 |
-
tone_instruction = (
|
| 399 |
-
"IMPORTANT: You are a Socratic tutor. NEVER directly state the answer, definition, or solution. "
|
| 400 |
-
"Instead, nudge the user and guide them to find the answer themselves through questions. "
|
| 401 |
-
"Be extremely concise and direct (strictly limit your response to max 5 sentences).\n"
|
| 402 |
-
)
|
| 403 |
-
if sentiment == "confusion":
|
| 404 |
-
tone_instruction += "The user is confused. Give them a stronger, clearer hint to guide them, and ask a direct question to help them take the next step towards the answer without telling it to them."
|
| 405 |
-
elif sentiment == "sadness":
|
| 406 |
-
tone_instruction += "The user is sad. Give them brief, warm, empathetic encouragement and practical tips to overcome it (like taking a micro-break or focusing on progress), and ask a gentle guiding question to continue."
|
| 407 |
-
elif sentiment == "frustration":
|
| 408 |
-
tone_instruction += "The user is frustrated. Empathetically acknowledge their frustration, give them a helpful hint or alternative perspective, and ask a guiding question to help them work through it."
|
| 409 |
-
elif sentiment == "boredom":
|
| 410 |
-
tone_instruction += "The user is bored. Suggest a completely different way to learn this concept (e.g., through a hands-on project, analogy, or challenge) to spark interest, and ask a guiding question to get them started."
|
| 411 |
-
elif sentiment == "confidence":
|
| 412 |
-
tone_instruction += "The user is confident. Celebrate their success briefly, and offer a quick challenge or question to test their understanding."
|
| 413 |
-
else:
|
| 414 |
-
tone_instruction += "Ask a guiding question to nudge them towards the answer."
|
| 415 |
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
est_input = estimate_tokens(custom_system) + estimate_tokens(prompt_context)
|
| 434 |
-
est_output = estimate_tokens(response_text)
|
| 435 |
-
|
| 436 |
-
return {
|
| 437 |
-
"response": response_text,
|
| 438 |
-
"input_tokens": state.get("input_tokens", 0) + est_input,
|
| 439 |
-
"output_tokens": state.get("output_tokens", 0) + est_output
|
| 440 |
-
}
|
| 441 |
-
|
| 442 |
-
builder = StateGraph(AgentState)
|
| 443 |
-
builder.add_node("detect_sentiment", detect_sentiment_node)
|
| 444 |
-
builder.add_node("generate_response", generate_response_node)
|
| 445 |
-
builder.set_entry_point("detect_sentiment")
|
| 446 |
-
builder.add_edge("detect_sentiment", "generate_response")
|
| 447 |
-
builder.add_edge("generate_response", END)
|
| 448 |
-
|
| 449 |
-
graph = builder.compile()
|
| 450 |
-
|
| 451 |
-
initial_state = {
|
| 452 |
-
"message": message,
|
| 453 |
-
"system_prompt": system_prompt or "",
|
| 454 |
-
"sentiment": "",
|
| 455 |
-
"explanation": "",
|
| 456 |
-
"response": "",
|
| 457 |
-
"input_tokens": 0,
|
| 458 |
-
"output_tokens": 0,
|
| 459 |
-
"history": history or []
|
| 460 |
-
}
|
| 461 |
-
|
| 462 |
-
return graph.invoke(initial_state)
|
| 463 |
|
| 464 |
# Option B response helper doing both sentiment detection and response generation in one pass
|
| 465 |
-
def run_flow_b(message: str,
|
| 466 |
import json
|
| 467 |
-
from datetime import datetime
|
| 468 |
|
| 469 |
-
#
|
| 470 |
llm = ChatGoogleGenerativeAI(
|
| 471 |
model="gemini-3.1-flash-lite",
|
| 472 |
google_api_key=api_key,
|
| 473 |
-
temperature=0.
|
| 474 |
max_tokens=350,
|
| 475 |
generation_config={"response_mime_type": "application/json"}
|
| 476 |
)
|
| 477 |
|
| 478 |
-
|
| 479 |
-
|
|
|
|
|
|
|
|
|
|
| 480 |
|
| 481 |
-
# OPTIMIZATION 2: Condensed to minimize prompt tokens while retaining response style constraints.
|
| 482 |
tone_instruction = (
|
| 483 |
-
"JSON: {\"
|
| 484 |
-
"Rules:
|
| 485 |
-
"-
|
| 486 |
-
"-
|
| 487 |
-
"-
|
| 488 |
-
"-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
)
|
| 490 |
|
| 491 |
-
messages = [SystemMessage(content=f"{custom_system}\n{tone_instruction}")]
|
| 492 |
|
|
|
|
| 493 |
if history:
|
| 494 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 495 |
if msg.role == "user":
|
| 496 |
-
messages.append(HumanMessage(content=
|
| 497 |
else:
|
| 498 |
-
messages.append(AIMessage(content=
|
| 499 |
|
| 500 |
messages.append(HumanMessage(content=message))
|
| 501 |
|
| 502 |
res = llm.invoke(messages)
|
| 503 |
raw_response = get_text_content(res.content)
|
| 504 |
-
|
| 505 |
-
# OPTIMIZATION 3: With response_mime_type active, markdown fences (```json) are bypassed entirely.
|
| 506 |
cleaned_json = raw_response.strip()
|
| 507 |
|
| 508 |
try:
|
| 509 |
parsed = json.loads(cleaned_json)
|
| 510 |
-
state_val = parsed.get("
|
| 511 |
-
reply_val = parsed.get("
|
| 512 |
-
|
| 513 |
-
supabase_payload = {
|
| 514 |
-
"state": state_val,
|
| 515 |
-
"reply": reply_val,
|
| 516 |
-
"query": message,
|
| 517 |
-
"timestamp": datetime.now().isoformat()
|
| 518 |
-
}
|
| 519 |
-
print(f"[Supabase Prototype] Directly writing payload to tracking table: {json.dumps(supabase_payload)}")
|
| 520 |
except Exception as e:
|
| 521 |
print(f"Failed to parse LLM JSON response: {e}. Raw response: {raw_response}")
|
| 522 |
-
state_val = "
|
| 523 |
reply_val = "Let's take a look at this concept step by step. What do you think is the first part?"
|
| 524 |
|
| 525 |
prompt_context = f"{custom_system}\n{tone_instruction}\nUser Query: {message}"
|
|
@@ -528,115 +184,11 @@ def run_flow_b(message: str, system_prompt: Optional[str], api_key: str, history
|
|
| 528 |
|
| 529 |
return state_val, reply_val, prompt_context, est_in, est_out
|
| 530 |
|
| 531 |
-
|
| 532 |
-
# Option C response helper using raw DistilRoBERTa emotion scores directly as LLM prompt context
|
| 533 |
-
def run_flow_c(message: str, system_prompt: Optional[str], api_key: str, raw_emotions: List[EmotionScore], history: Optional[List[ChatMessage]] = None):
|
| 534 |
-
llm = ChatGoogleGenerativeAI(
|
| 535 |
-
model="gemini-3.1-flash-lite",
|
| 536 |
-
google_api_key=api_key,
|
| 537 |
-
temperature=0.0,
|
| 538 |
-
max_tokens=300
|
| 539 |
-
)
|
| 540 |
-
custom_system = system_prompt or (
|
| 541 |
-
"You are a concise, Socratic educational tutor. Your focus is strictly to teach. "
|
| 542 |
-
"NEVER give the user the direct answer or solution. Instead, guide them, nudge them, and ask leading questions to help them figure it out. "
|
| 543 |
-
"Adjust your behavior and tone based on the user's emotional state. Keep responses brief (max 5 sentences)."
|
| 544 |
-
)
|
| 545 |
-
|
| 546 |
-
# Format raw emotions nicely for the model's context
|
| 547 |
-
emotion_context_str = ", ".join([f"{item.label}: {item.score:.3f}" for item in raw_emotions])
|
| 548 |
-
|
| 549 |
-
tone_instruction = (
|
| 550 |
-
"IMPORTANT: Socratic tutor. NEVER state answer/definition/solution. Nudge/guide using questions. "
|
| 551 |
-
"Max 5 sentences.\n"
|
| 552 |
-
f"User emotions: {emotion_context_str}.\n"
|
| 553 |
-
"Synthesize: If confusion (surprise/fear), give a stronger hint. If frustration (anger/disgust), be empathetic. "
|
| 554 |
-
"If boredom (neutral), suggest alternative hands-on/analogy path. If sadness, offer quick warm tips. If confidence (joy), challenge them."
|
| 555 |
-
)
|
| 556 |
-
|
| 557 |
-
prompt_context = f"{tone_instruction}\n\nUser Query: {message}"
|
| 558 |
-
|
| 559 |
-
messages = [SystemMessage(content=custom_system)]
|
| 560 |
-
|
| 561 |
-
# Prepend history if exists
|
| 562 |
-
if history:
|
| 563 |
-
for msg in history:
|
| 564 |
-
if msg.role == "user":
|
| 565 |
-
messages.append(HumanMessage(content=msg.content))
|
| 566 |
-
else:
|
| 567 |
-
messages.append(AIMessage(content=msg.content))
|
| 568 |
-
|
| 569 |
-
messages.append(HumanMessage(content=prompt_context))
|
| 570 |
-
|
| 571 |
-
res = llm.invoke(messages)
|
| 572 |
-
response_text = get_text_content(res.content)
|
| 573 |
-
|
| 574 |
-
# Estimate tokens
|
| 575 |
-
est_input = estimate_tokens(custom_system) + estimate_tokens(prompt_context)
|
| 576 |
-
est_output = estimate_tokens(response_text)
|
| 577 |
-
|
| 578 |
-
return response_text, prompt_context, est_input, est_output
|
| 579 |
-
|
| 580 |
-
# Option D response helper using DistilRoBERTa mapped sentiment
|
| 581 |
-
def run_flow_d(message: str, system_prompt: Optional[str], api_key: str, mapped_sentiment: str, history: Optional[List[ChatMessage]] = None):
|
| 582 |
-
llm = ChatGoogleGenerativeAI(
|
| 583 |
-
model="gemini-3.1-flash-lite",
|
| 584 |
-
google_api_key=api_key,
|
| 585 |
-
temperature=0.0,
|
| 586 |
-
max_tokens=300
|
| 587 |
-
)
|
| 588 |
-
custom_system = system_prompt or (
|
| 589 |
-
"You are a concise, Socratic educational tutor. Your focus is strictly to teach. "
|
| 590 |
-
"NEVER give the user the direct answer or solution. Instead, guide them, nudge them, and ask leading questions to help them figure it out. "
|
| 591 |
-
"Adjust your behavior and tone based on the user's sentiment. Keep responses brief (max 5 sentences)."
|
| 592 |
-
)
|
| 593 |
-
|
| 594 |
-
tone_instruction = (
|
| 595 |
-
"IMPORTANT: You are a Socratic tutor. NEVER directly state the answer, definition, or solution. "
|
| 596 |
-
"Instead, nudge the user and guide them to find the answer themselves through questions. "
|
| 597 |
-
"Be extremely concise and direct (strictly limit your response to max 5 sentences).\n"
|
| 598 |
-
)
|
| 599 |
-
if mapped_sentiment == "confusion":
|
| 600 |
-
tone_instruction += "The user is confused. Give them a stronger, clearer hint to guide them, and ask a direct question to help them take the next step towards the answer without telling it to them."
|
| 601 |
-
elif mapped_sentiment == "sadness":
|
| 602 |
-
tone_instruction += "The user is sad. Give them brief, warm, empathetic encouragement and practical tips to overcome it (like taking a micro-break or focusing on progress), and ask a gentle guiding question to continue."
|
| 603 |
-
elif mapped_sentiment == "frustration":
|
| 604 |
-
tone_instruction += "The user is frustrated. Empathetically acknowledge their frustration, give them a helpful hint or alternative perspective, and ask a guiding question to help them work through it."
|
| 605 |
-
elif mapped_sentiment == "boredom":
|
| 606 |
-
tone_instruction += "The user is bored. Suggest a completely different way to learn this concept (e.g., through a hands-on project, analogy, or challenge) to spark interest, and ask a guiding question to get them started."
|
| 607 |
-
elif mapped_sentiment == "confidence":
|
| 608 |
-
tone_instruction += "The user is confident. Celebrate their success briefly, and offer a quick challenge or question to test their understanding."
|
| 609 |
-
else:
|
| 610 |
-
tone_instruction += "Ask a guiding question to nudge them towards the answer."
|
| 611 |
-
|
| 612 |
-
prompt_context = f"{tone_instruction}\n\nUser Query: {message}"
|
| 613 |
-
|
| 614 |
-
messages = [SystemMessage(content=custom_system)]
|
| 615 |
-
if history:
|
| 616 |
-
for msg in history:
|
| 617 |
-
if msg.role == "user":
|
| 618 |
-
messages.append(HumanMessage(content=msg.content))
|
| 619 |
-
else:
|
| 620 |
-
messages.append(AIMessage(content=msg.content))
|
| 621 |
-
messages.append(HumanMessage(content=prompt_context))
|
| 622 |
-
|
| 623 |
-
res = llm.invoke(messages)
|
| 624 |
-
response_text = get_text_content(res.content)
|
| 625 |
-
|
| 626 |
-
est_input = estimate_tokens(custom_system) + estimate_tokens(prompt_context)
|
| 627 |
-
est_output = estimate_tokens(response_text)
|
| 628 |
-
|
| 629 |
-
return response_text, prompt_context, est_input, est_output
|
| 630 |
-
|
| 631 |
-
|
| 632 |
# API Routes
|
| 633 |
@app.get("/api/status")
|
| 634 |
def get_status():
|
| 635 |
return {
|
| 636 |
-
"
|
| 637 |
-
"roberta_error": model_error,
|
| 638 |
-
"ner_status": ner_status,
|
| 639 |
-
"ner_error": ner_error,
|
| 640 |
"gemini_api_key_configured": bool(os.environ.get("GEMINI_API_KEY"))
|
| 641 |
}
|
| 642 |
|
|
@@ -647,273 +199,149 @@ def chat_endpoint(request: ChatRequest):
|
|
| 647 |
if not api_key:
|
| 648 |
raise HTTPException(
|
| 649 |
status_code=400,
|
| 650 |
-
detail="Gemini API Key is missing. Please provide it in the Settings panel."
|
| 651 |
)
|
| 652 |
|
| 653 |
-
|
| 654 |
-
request.message = scrub_pii(request.message)
|
| 655 |
|
| 656 |
-
#
|
| 657 |
-
|
| 658 |
-
response_a = None
|
| 659 |
-
latency_a = None
|
| 660 |
-
prompt_context_a = None
|
| 661 |
-
tokens_a = None
|
| 662 |
-
|
| 663 |
-
sentiment_details_b = None
|
| 664 |
-
response_b = None
|
| 665 |
-
latency_b = None
|
| 666 |
-
prompt_context_b = None
|
| 667 |
-
tokens_b = None
|
| 668 |
-
|
| 669 |
-
response_c = None
|
| 670 |
-
latency_c = None
|
| 671 |
-
prompt_context_c = None
|
| 672 |
-
tokens_c = None
|
| 673 |
-
|
| 674 |
-
sentiment_details_d = None
|
| 675 |
-
response_d = None
|
| 676 |
-
latency_d = None
|
| 677 |
-
prompt_context_d = None
|
| 678 |
-
tokens_d = None
|
| 679 |
-
|
| 680 |
-
# Track metrics for logging
|
| 681 |
-
detected_sentiment_a = "N/A"
|
| 682 |
-
mapped_sentiment_b = "N/A"
|
| 683 |
-
mapped_sentiment_d = "N/A"
|
| 684 |
-
cost_a = 0.0
|
| 685 |
-
cost_b = 0.0
|
| 686 |
-
cost_c = 0.0
|
| 687 |
-
cost_d = 0.0
|
| 688 |
-
est_in_b = 0
|
| 689 |
-
est_out_b = 0
|
| 690 |
-
est_in_c = 0
|
| 691 |
-
est_out_c = 0
|
| 692 |
-
est_in_d = 0
|
| 693 |
-
est_out_d = 0
|
| 694 |
-
|
| 695 |
-
selected = request.selected_option.lower() if request.selected_option else "all"
|
| 696 |
-
run_a = (selected == "all" or selected == "a")
|
| 697 |
-
run_b = (selected == "all" or selected == "b")
|
| 698 |
-
run_c = (selected == "all" or selected == "c")
|
| 699 |
-
run_d = (selected == "all" or selected == "d")
|
| 700 |
-
|
| 701 |
-
# ------------------
|
| 702 |
-
# FLOW A: LangGraph + LangChain Sentiment & Response
|
| 703 |
-
# ------------------
|
| 704 |
-
if run_a:
|
| 705 |
-
start_a = time.time()
|
| 706 |
-
try:
|
| 707 |
-
final_state_a = run_flow_a_langgraph(
|
| 708 |
-
message=request.message,
|
| 709 |
-
system_prompt=request.system_prompt,
|
| 710 |
-
api_key=api_key,
|
| 711 |
-
history=request.history_a
|
| 712 |
-
)
|
| 713 |
-
|
| 714 |
-
detected_sentiment_a = final_state_a["sentiment"]
|
| 715 |
-
explanation_a = final_state_a["explanation"]
|
| 716 |
-
response_a = final_state_a["response"]
|
| 717 |
-
|
| 718 |
-
prompt_context_a = f"Detected Sentiment (LangGraph): {detected_sentiment_a}\nExplanation: {explanation_a}"
|
| 719 |
-
tokens_a = final_state_a.get("input_tokens", 0) + final_state_a.get("output_tokens", 0)
|
| 720 |
-
cost_a = calculate_cost(final_state_a["input_tokens"], final_state_a["output_tokens"])
|
| 721 |
-
|
| 722 |
-
sentiment_details_a = SentimentDetailsA(
|
| 723 |
-
detected_sentiment=detected_sentiment_a,
|
| 724 |
-
explanation=explanation_a
|
| 725 |
-
)
|
| 726 |
-
except Exception as e:
|
| 727 |
-
print(f"Error in Flow A (LangGraph): {e}")
|
| 728 |
-
detected_sentiment_a = "neutral"
|
| 729 |
-
explanation_a = f"Error: {str(e)}"
|
| 730 |
-
response_a = "An error occurred during Flow A generation."
|
| 731 |
-
prompt_context_a = "N/A"
|
| 732 |
-
cost_a = 0.0
|
| 733 |
-
sentiment_details_a = SentimentDetailsA(
|
| 734 |
-
detected_sentiment="neutral",
|
| 735 |
-
explanation=explanation_a
|
| 736 |
-
)
|
| 737 |
-
latency_a = time.time() - start_a
|
| 738 |
-
|
| 739 |
-
# ------------------
|
| 740 |
-
# FLOW B: Gemini Single-Pass (Sentiment & Response in one call)
|
| 741 |
-
# ------------------
|
| 742 |
-
if run_b:
|
| 743 |
-
start_b = time.time()
|
| 744 |
-
try:
|
| 745 |
-
mapped_sentiment_b, response_b, prompt_context_b, est_in_b, est_out_b = run_flow_b(
|
| 746 |
-
message=request.message,
|
| 747 |
-
system_prompt=request.system_prompt,
|
| 748 |
-
api_key=api_key,
|
| 749 |
-
history=request.history_b
|
| 750 |
-
)
|
| 751 |
-
cost_b = calculate_cost(est_in_b, est_out_b)
|
| 752 |
-
tokens_b = est_in_b + est_out_b
|
| 753 |
-
sentiment_details_b = SentimentDetailsB(
|
| 754 |
-
mapped_sentiment=mapped_sentiment_b,
|
| 755 |
-
raw_emotions=[]
|
| 756 |
-
)
|
| 757 |
-
except Exception as e:
|
| 758 |
-
print(f"Flow B single-pass error: {e}")
|
| 759 |
-
mapped_sentiment_b = "neutral"
|
| 760 |
-
response_b = "An error occurred during Flow B generation."
|
| 761 |
-
prompt_context_b = "N/A"
|
| 762 |
-
cost_b = 0.0
|
| 763 |
-
est_in_b = 0
|
| 764 |
-
est_out_b = 0
|
| 765 |
-
sentiment_details_b = SentimentDetailsB(
|
| 766 |
-
mapped_sentiment="neutral",
|
| 767 |
-
raw_emotions=[]
|
| 768 |
-
)
|
| 769 |
-
latency_b = time.time() - start_b
|
| 770 |
-
|
| 771 |
-
# ------------------
|
| 772 |
-
# FLOW C & D: DistilRoBERTa Classifier Setup
|
| 773 |
-
# ------------------
|
| 774 |
-
raw_emotions = []
|
| 775 |
-
classifier_ran = False
|
| 776 |
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
status_code=500,
|
| 787 |
-
detail=f"DistilRoBERTa model is unavailable. Load error: {model_error}"
|
| 788 |
-
)
|
| 789 |
-
|
| 790 |
-
# Run local classifier once
|
| 791 |
-
classifier_results = classifier(request.message)[0]
|
| 792 |
-
raw_emotions = [
|
| 793 |
-
EmotionScore(label=item["label"], score=float(item["score"]))
|
| 794 |
-
for item in classifier_results
|
| 795 |
-
]
|
| 796 |
-
mapped_sentiment_d = map_distilroberta_emotions(classifier_results)
|
| 797 |
-
classifier_ran = True
|
| 798 |
-
except HTTPException as he:
|
| 799 |
-
raise he
|
| 800 |
-
except Exception as e:
|
| 801 |
-
print(f"DistilRoBERTa classification error: {e}")
|
| 802 |
-
|
| 803 |
-
# ------------------
|
| 804 |
-
# FLOW C: Local DistilRoBERTa Raw Scores + Gemini Reply
|
| 805 |
-
# ------------------
|
| 806 |
-
if run_c:
|
| 807 |
-
start_c = time.time()
|
| 808 |
-
try:
|
| 809 |
-
if not classifier_ran:
|
| 810 |
-
raise Exception("Classifier did not run successfully.")
|
| 811 |
-
response_c, prompt_context_c, est_in_c, est_out_c = run_flow_c(
|
| 812 |
-
message=request.message,
|
| 813 |
-
system_prompt=request.system_prompt,
|
| 814 |
-
api_key=api_key,
|
| 815 |
-
raw_emotions=raw_emotions,
|
| 816 |
-
history=request.history_c
|
| 817 |
-
)
|
| 818 |
-
cost_c = calculate_cost(est_in_c, est_out_c)
|
| 819 |
-
tokens_c = est_in_c + est_out_c
|
| 820 |
-
except Exception as e:
|
| 821 |
-
print(f"Flow C error: {e}")
|
| 822 |
-
response_c = "An error occurred during Flow C generation."
|
| 823 |
-
prompt_context_c = "N/A"
|
| 824 |
-
cost_c = 0.0
|
| 825 |
-
est_in_c = 0
|
| 826 |
-
est_out_c = 0
|
| 827 |
-
latency_c = time.time() - start_c
|
| 828 |
-
|
| 829 |
-
# ------------------
|
| 830 |
-
# FLOW D: Local DistilRoBERTa Classifier + Gemini Reply (Old Option B)
|
| 831 |
-
# ------------------
|
| 832 |
-
if run_d:
|
| 833 |
-
start_d = time.time()
|
| 834 |
-
try:
|
| 835 |
-
if not classifier_ran:
|
| 836 |
-
raise Exception("Classifier did not run successfully.")
|
| 837 |
-
response_d, prompt_context_d, est_in_d, est_out_d = run_flow_d(
|
| 838 |
-
message=request.message,
|
| 839 |
-
system_prompt=request.system_prompt,
|
| 840 |
-
api_key=api_key,
|
| 841 |
-
mapped_sentiment=mapped_sentiment_d,
|
| 842 |
-
history=request.history_d
|
| 843 |
-
)
|
| 844 |
-
cost_d = calculate_cost(est_in_d, est_out_d)
|
| 845 |
-
tokens_d = est_in_d + est_out_d
|
| 846 |
-
sentiment_details_d = SentimentDetailsB(
|
| 847 |
-
mapped_sentiment=mapped_sentiment_d,
|
| 848 |
-
raw_emotions=raw_emotions
|
| 849 |
-
)
|
| 850 |
-
except Exception as e:
|
| 851 |
-
print(f"Flow D error: {e}")
|
| 852 |
-
response_d = "An error occurred during Flow D generation."
|
| 853 |
-
prompt_context_d = "N/A"
|
| 854 |
-
cost_d = 0.0
|
| 855 |
-
est_in_d = 0
|
| 856 |
-
est_out_d = 0
|
| 857 |
-
sentiment_details_d = SentimentDetailsB(
|
| 858 |
-
mapped_sentiment="neutral",
|
| 859 |
-
raw_emotions=[]
|
| 860 |
-
)
|
| 861 |
-
latency_d = time.time() - start_d
|
| 862 |
-
|
| 863 |
-
# Log to Markdown file asynchronously or directly (only log values if ran)
|
| 864 |
-
log_to_md(
|
| 865 |
-
question=request.message,
|
| 866 |
-
sentiment_a=detected_sentiment_a,
|
| 867 |
-
sentiment_b=mapped_sentiment_b,
|
| 868 |
-
sentiment_d=mapped_sentiment_d,
|
| 869 |
-
latency_a=latency_a or 0.0,
|
| 870 |
-
latency_b=latency_b or 0.0,
|
| 871 |
-
latency_c=latency_c or 0.0,
|
| 872 |
-
latency_d=latency_d or 0.0,
|
| 873 |
-
cost_a=cost_a,
|
| 874 |
-
cost_b=cost_b,
|
| 875 |
-
cost_c=cost_c,
|
| 876 |
-
cost_d=cost_d,
|
| 877 |
-
tokens_in_a=final_state_a.get("input_tokens", 0) if (run_a and "final_state_a" in locals()) else 0,
|
| 878 |
-
tokens_out_a=final_state_a.get("output_tokens", 0) if (run_a and "final_state_a" in locals()) else 0,
|
| 879 |
-
tokens_in_b=est_in_b,
|
| 880 |
-
tokens_out_b=est_out_b,
|
| 881 |
-
tokens_in_c=est_in_c,
|
| 882 |
-
tokens_out_c=est_out_c,
|
| 883 |
-
tokens_in_d=est_in_d,
|
| 884 |
-
tokens_out_d=est_out_d,
|
| 885 |
-
answer_a=response_a or "Skipped",
|
| 886 |
-
answer_b=response_b or "Skipped",
|
| 887 |
-
answer_c=response_c or "Skipped",
|
| 888 |
-
answer_d=response_d or "Skipped",
|
| 889 |
-
selected_option=selected
|
| 890 |
-
)
|
| 891 |
-
|
| 892 |
-
return ChatResponse(
|
| 893 |
-
sentiment_a=sentiment_details_a,
|
| 894 |
-
response_a=response_a,
|
| 895 |
-
latency_a=round(latency_a, 3) if latency_a is not None else None,
|
| 896 |
-
prompt_context_a=prompt_context_a,
|
| 897 |
-
|
| 898 |
-
sentiment_b=sentiment_details_b,
|
| 899 |
-
response_b=response_b,
|
| 900 |
-
latency_b=round(latency_b, 3) if latency_b is not None else None,
|
| 901 |
-
prompt_context_b=prompt_context_b,
|
| 902 |
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 906 |
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 911 |
|
| 912 |
-
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 916 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 917 |
|
| 918 |
# Mount frontend static files in production if dist folder is built
|
| 919 |
frontend_dist_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend", "dist")
|
|
|
|
| 1 |
+
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
| 2 |
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
from fastapi.staticfiles import StaticFiles
|
| 4 |
import time
|
| 5 |
import os
|
|
|
|
|
|
|
| 6 |
import re
|
| 7 |
+
import asyncio
|
| 8 |
+
import base64
|
| 9 |
from datetime import datetime
|
| 10 |
+
from typing import List, Optional, Any
|
| 11 |
+
from pydantic import BaseModel
|
| 12 |
from dotenv import load_dotenv
|
| 13 |
|
| 14 |
+
# LangChain / Google GenAI imports
|
| 15 |
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 16 |
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
|
|
|
|
|
|
|
| 17 |
|
| 18 |
load_dotenv()
|
| 19 |
|
| 20 |
+
app = FastAPI(title="Socratic Sentiment Chatbot API")
|
| 21 |
|
| 22 |
# Enable CORS for frontend integration
|
| 23 |
app.add_middleware(
|
|
|
|
| 28 |
allow_headers=["*"],
|
| 29 |
)
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
# Pydantic Schemas
|
| 32 |
class ChatMessage(BaseModel):
|
| 33 |
role: str # "user" or "assistant"
|
|
|
|
| 36 |
class ChatRequest(BaseModel):
|
| 37 |
message: str
|
| 38 |
gemini_api_key: Optional[str] = None
|
| 39 |
+
history: Optional[List[ChatMessage]] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
class ChatResponse(BaseModel):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
sentiment: str
|
|
|
|
| 43 |
response: str
|
| 44 |
+
latency: float
|
| 45 |
+
prompt_context: str
|
| 46 |
+
tokens: int
|
| 47 |
+
cost: float
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
# Token estimation helper (using standard ~4 characters per token multiplier for English)
|
| 50 |
def estimate_tokens(text: str) -> int:
|
|
|
|
| 57 |
output_cost = (output_tokens / 1_000_000.0) * 0.30
|
| 58 |
return input_cost + output_cost
|
| 59 |
|
| 60 |
+
# Helper to extract text from LangChain message content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
def get_text_content(content: Any) -> str:
|
| 62 |
if isinstance(content, str):
|
| 63 |
return content
|
|
|
|
| 71 |
return "".join(text_parts)
|
| 72 |
return str(content)
|
| 73 |
|
| 74 |
+
# Regex PII scrubbing helper
|
| 75 |
+
def scrub_pii(text: str) -> str:
|
| 76 |
+
if not text:
|
| 77 |
+
return text
|
| 78 |
+
# Email addresses
|
| 79 |
+
text = re.sub(r'[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+', '[EMAIL]', text)
|
| 80 |
+
# Phone numbers
|
| 81 |
+
text = re.sub(r'\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b', '[PHONE]', text)
|
| 82 |
+
# IP Addresses
|
| 83 |
+
text = re.sub(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', '[IP_ADDRESS]', text)
|
| 84 |
+
# SSNs
|
| 85 |
+
text = re.sub(r'\b\d{3}-\d{2}-\d{4}\b', '[SSN]', text)
|
| 86 |
+
return text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
+
# Markdown Logging helper
|
| 89 |
+
MD_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sentiment_log.md")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
+
def log_to_md(question: str, sentiment: str, latency: float, cost: float, tokens_in: int, tokens_out: int, reply: str):
|
| 92 |
+
file_exists = os.path.exists(MD_FILE)
|
| 93 |
+
try:
|
| 94 |
+
with open(MD_FILE, mode="a", encoding="utf-8") as f:
|
| 95 |
+
if not file_exists:
|
| 96 |
+
f.write("# Socratic Chatbot Sentiment & Response Log\n\n")
|
| 97 |
+
f.write("This file tracks detected user sentiments, response latencies, costs, and Socratic replies.\n\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 100 |
+
f.write(f"## [{timestamp}] Query: \"{question}\"\n\n")
|
| 101 |
+
f.write("<table>\n")
|
| 102 |
+
f.write(" <thead>\n")
|
| 103 |
+
f.write(" <tr><th align=\"left\">Metric</th><th align=\"left\">Value</th></tr>\n")
|
| 104 |
+
f.write(" </thead>\n")
|
| 105 |
+
f.write(" <tbody>\n")
|
| 106 |
+
f.write(f" <tr><td><strong>Detected Sentiment</strong></td><td><code>{sentiment}</code></td></tr>\n")
|
| 107 |
+
f.write(f" <tr><td><strong>Latency</strong></td><td>{round(latency, 3)}s</td></tr>\n")
|
| 108 |
+
f.write(f" <tr><td><strong>Estimated Cost</strong></td><td><code>${cost:.7f}</code></td></tr>\n")
|
| 109 |
+
f.write(f" <tr><td><strong>Tokens</strong></td><td>{tokens_in + tokens_out} ({tokens_in} in / {tokens_out} out)</td></tr>\n")
|
| 110 |
+
f.write(" </tbody>\n")
|
| 111 |
+
f.write("</table>\n\n")
|
| 112 |
+
f.write(f"### Socratic Tutor Reply\n{reply}\n\n")
|
| 113 |
+
f.write("---\n\n")
|
| 114 |
+
except Exception as e:
|
| 115 |
+
print(f"Error writing to MD log: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
# Option B response helper doing both sentiment detection and response generation in one pass
|
| 118 |
+
def run_flow_b(message: str, api_key: str, history: Optional[List[ChatMessage]] = None):
|
| 119 |
import json
|
|
|
|
| 120 |
|
| 121 |
+
# Enforce structural JSON natively.
|
| 122 |
llm = ChatGoogleGenerativeAI(
|
| 123 |
model="gemini-3.1-flash-lite",
|
| 124 |
google_api_key=api_key,
|
| 125 |
+
temperature=0.4,
|
| 126 |
max_tokens=350,
|
| 127 |
generation_config={"response_mime_type": "application/json"}
|
| 128 |
)
|
| 129 |
|
| 130 |
+
custom_system = (
|
| 131 |
+
"Socratic tutor: guide with clear, substantial hints (no tiny nudges) to solve faster. "
|
| 132 |
+
"Confidence is not mastery—continue Socratic hints unless they are close. "
|
| 133 |
+
"Only when close to the solution, give the final answer & ask: 'Do you want to learn something else?'"
|
| 134 |
+
)
|
| 135 |
|
|
|
|
| 136 |
tone_instruction = (
|
| 137 |
+
"JSON format: {\"s\": \"confusion|frustration|confused_but_engaged|confused_and_frustrated|starting_to_get_bored|confident_and_engaged|neutral\", \"r\": \"string\"}\n"
|
| 138 |
+
"Rules:\n"
|
| 139 |
+
"- No robotic templates (e.g. 'I understand', 'it can be frustrating').\n"
|
| 140 |
+
"- NEVER use the phrase 'if you' anywhere in your response (e.g. do not say 'if you think', 'if you were', etc.). Instead, frame instructions or scenarios directly (e.g., say 'think about', 'imagine', 'when looking at', or 'sometimes').\n"
|
| 141 |
+
"- Only ask one question at a time to avoid overwhelming the user.\n"
|
| 142 |
+
"- Close to answer: give final answer directly, ask: 'Do you want to learn something else?'\n"
|
| 143 |
+
"- Not close (including confident_and_engaged): Socratic guidance (substantial hint + question).\n"
|
| 144 |
+
"Replies by state:\n"
|
| 145 |
+
" * confusion: hint + question.\n"
|
| 146 |
+
" * frustration: simplify step + question.\n"
|
| 147 |
+
" * starting_to_get_bored: puzzle/analogy + question.\n"
|
| 148 |
+
" * confident_and_engaged / neutral: guide with hint + question."
|
| 149 |
)
|
| 150 |
|
| 151 |
+
messages = [SystemMessage(content=f"{custom_system}\n\n{tone_instruction}")]
|
| 152 |
|
| 153 |
+
# Minimize tokens: slice history to last 4 messages and truncate to 60 characters
|
| 154 |
if history:
|
| 155 |
+
compact_history = history[-4:]
|
| 156 |
+
for msg in compact_history:
|
| 157 |
+
content = msg.content
|
| 158 |
+
if len(content) > 60:
|
| 159 |
+
content = content[:60] + "..."
|
| 160 |
+
|
| 161 |
if msg.role == "user":
|
| 162 |
+
messages.append(HumanMessage(content=content))
|
| 163 |
else:
|
| 164 |
+
messages.append(AIMessage(content=content))
|
| 165 |
|
| 166 |
messages.append(HumanMessage(content=message))
|
| 167 |
|
| 168 |
res = llm.invoke(messages)
|
| 169 |
raw_response = get_text_content(res.content)
|
|
|
|
|
|
|
| 170 |
cleaned_json = raw_response.strip()
|
| 171 |
|
| 172 |
try:
|
| 173 |
parsed = json.loads(cleaned_json)
|
| 174 |
+
state_val = parsed.get("s", "neutral")
|
| 175 |
+
reply_val = parsed.get("r", "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
except Exception as e:
|
| 177 |
print(f"Failed to parse LLM JSON response: {e}. Raw response: {raw_response}")
|
| 178 |
+
state_val = "neutral"
|
| 179 |
reply_val = "Let's take a look at this concept step by step. What do you think is the first part?"
|
| 180 |
|
| 181 |
prompt_context = f"{custom_system}\n{tone_instruction}\nUser Query: {message}"
|
|
|
|
| 184 |
|
| 185 |
return state_val, reply_val, prompt_context, est_in, est_out
|
| 186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
# API Routes
|
| 188 |
@app.get("/api/status")
|
| 189 |
def get_status():
|
| 190 |
return {
|
| 191 |
+
"status": "ready",
|
|
|
|
|
|
|
|
|
|
| 192 |
"gemini_api_key_configured": bool(os.environ.get("GEMINI_API_KEY"))
|
| 193 |
}
|
| 194 |
|
|
|
|
| 199 |
if not api_key:
|
| 200 |
raise HTTPException(
|
| 201 |
status_code=400,
|
| 202 |
+
detail="Gemini API Key is missing. Please provide it in the Settings panel or environment."
|
| 203 |
)
|
| 204 |
|
| 205 |
+
start_time = time.time()
|
|
|
|
| 206 |
|
| 207 |
+
# Scrub PII
|
| 208 |
+
scrubbed_message = scrub_pii(request.message)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
|
| 210 |
+
try:
|
| 211 |
+
sentiment, reply, prompt_context, est_in, est_out = run_flow_b(
|
| 212 |
+
message=scrubbed_message,
|
| 213 |
+
api_key=api_key,
|
| 214 |
+
history=request.history
|
| 215 |
+
)
|
| 216 |
+
latency = time.time() - start_time
|
| 217 |
+
cost = calculate_cost(est_in, est_out)
|
| 218 |
+
tokens = est_in + est_out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
|
| 220 |
+
# Log to Markdown
|
| 221 |
+
log_to_md(
|
| 222 |
+
question=request.message,
|
| 223 |
+
sentiment=sentiment,
|
| 224 |
+
latency=latency,
|
| 225 |
+
cost=cost,
|
| 226 |
+
tokens_in=est_in,
|
| 227 |
+
tokens_out=est_out,
|
| 228 |
+
reply=reply
|
| 229 |
+
)
|
| 230 |
|
| 231 |
+
return ChatResponse(
|
| 232 |
+
sentiment=sentiment,
|
| 233 |
+
response=reply,
|
| 234 |
+
latency=round(latency, 3),
|
| 235 |
+
prompt_context=prompt_context,
|
| 236 |
+
tokens=tokens,
|
| 237 |
+
cost=cost
|
| 238 |
+
)
|
| 239 |
+
except Exception as e:
|
| 240 |
+
print(f"Chat endpoint error: {e}")
|
| 241 |
+
raise HTTPException(
|
| 242 |
+
status_code=500,
|
| 243 |
+
detail=f"An error occurred: {str(e)}"
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
# WebSocket Endpoint for Socratic voice dialogue via Gemini Multimodal Live API
|
| 247 |
+
@app.websocket("/api/live-ws")
|
| 248 |
+
async def websocket_live_endpoint(websocket: WebSocket):
|
| 249 |
+
await websocket.accept()
|
| 250 |
+
|
| 251 |
+
# Retrieve Gemini API Key from query params or environment
|
| 252 |
+
api_key = websocket.query_params.get("api_key") or os.environ.get("GEMINI_API_KEY")
|
| 253 |
+
if not api_key:
|
| 254 |
+
await websocket.close(code=4000, reason="GEMINI_API_KEY is missing.")
|
| 255 |
+
return
|
| 256 |
|
| 257 |
+
try:
|
| 258 |
+
from google import genai
|
| 259 |
+
from google.genai import types
|
| 260 |
+
except ImportError:
|
| 261 |
+
await websocket.close(code=4001, reason="google-genai SDK not installed.")
|
| 262 |
+
return
|
| 263 |
+
|
| 264 |
+
client = genai.Client(api_key=api_key)
|
| 265 |
+
|
| 266 |
+
# Configure Socratic Tutor instruction for Gemini Live API
|
| 267 |
+
config = types.LiveConnectConfig(
|
| 268 |
+
response_modalities=["AUDIO"], # Audio modality
|
| 269 |
+
system_instruction=types.Content(
|
| 270 |
+
parts=[types.Part.from_text(
|
| 271 |
+
text="Socratic tutor: guide with clear, substantial hints (no tiny nudges) to solve faster. "
|
| 272 |
+
"Confidence is not mastery—continue Socratic hints unless they are close. "
|
| 273 |
+
"Only when close to the solution, give the final answer & ask: 'Do you want to learn something else?' "
|
| 274 |
+
"NEVER use the phrase 'if you' anywhere in your response (e.g. do not say 'if you think', 'if you were', etc.). Instead, frame instructions or scenarios directly (e.g., say 'think about', 'imagine', 'when looking at', or 'sometimes'). "
|
| 275 |
+
"Only ask one question at a time to avoid overwhelming the user. "
|
| 276 |
+
"Keep replies extremely concise (maximum 3 brief sentences) and conversational."
|
| 277 |
+
)]
|
| 278 |
+
)
|
| 279 |
)
|
| 280 |
+
|
| 281 |
+
try:
|
| 282 |
+
# Establish async WebSocket connection to Gemini Live using the Gemini 3.1 Flash Live model
|
| 283 |
+
async with client.aio.live.connect(model="gemini-3.1-flash-live-preview", config=config) as session:
|
| 284 |
+
|
| 285 |
+
async def receive_from_client():
|
| 286 |
+
try:
|
| 287 |
+
while True:
|
| 288 |
+
# Receive JSON from browser client
|
| 289 |
+
message = await websocket.receive_json()
|
| 290 |
+
msg_type = message.get("type")
|
| 291 |
+
|
| 292 |
+
if msg_type == "audio":
|
| 293 |
+
# Decode base64 PCM audio chunk sent from frontend
|
| 294 |
+
audio_bytes = base64.b64decode(message["data"])
|
| 295 |
+
# Stream real-time audio (using 'audio' instead of deprecated 'media')
|
| 296 |
+
await session.send_realtime_input(
|
| 297 |
+
audio=types.Blob(data=audio_bytes, mime_type="audio/pcm;rate=16000")
|
| 298 |
+
)
|
| 299 |
+
elif msg_type == "text":
|
| 300 |
+
# Send real-time text input
|
| 301 |
+
await session.send_realtime_input(text=message["data"])
|
| 302 |
+
except WebSocketDisconnect:
|
| 303 |
+
pass
|
| 304 |
+
except Exception as e:
|
| 305 |
+
print(f"[WebSocket Proxy Client -> Gemini] Error: {e}")
|
| 306 |
+
|
| 307 |
+
async def send_to_client():
|
| 308 |
+
try:
|
| 309 |
+
async for response in session.receive():
|
| 310 |
+
server_content = response.server_content
|
| 311 |
+
if server_content is not None:
|
| 312 |
+
model_turn = server_content.model_turn
|
| 313 |
+
if model_turn is not None:
|
| 314 |
+
for part in model_turn.parts:
|
| 315 |
+
if part.inline_data is not None:
|
| 316 |
+
# Stream PCM audio output back to client as Base64
|
| 317 |
+
audio_b64 = base64.b64encode(part.inline_data.data).decode('utf-8')
|
| 318 |
+
await websocket.send_json({
|
| 319 |
+
"type": "audio",
|
| 320 |
+
"data": audio_b64
|
| 321 |
+
})
|
| 322 |
+
elif part.text is not None:
|
| 323 |
+
# Stream text transcription back to client
|
| 324 |
+
await websocket.send_json({
|
| 325 |
+
"type": "text",
|
| 326 |
+
"data": part.text
|
| 327 |
+
})
|
| 328 |
+
|
| 329 |
+
# Handle turn completion (model finished speaking)
|
| 330 |
+
if server_content.turn_complete:
|
| 331 |
+
await websocket.send_json({"type": "turn_complete"})
|
| 332 |
+
except Exception as e:
|
| 333 |
+
print(f"[WebSocket Proxy Gemini -> Client] Error: {e}")
|
| 334 |
+
|
| 335 |
+
# Run both tasks concurrently
|
| 336 |
+
await asyncio.gather(receive_from_client(), send_to_client())
|
| 337 |
+
|
| 338 |
+
except Exception as e:
|
| 339 |
+
print(f"WebSocket Gemini Live connection failed: {e}")
|
| 340 |
+
finally:
|
| 341 |
+
try:
|
| 342 |
+
await websocket.close()
|
| 343 |
+
except Exception:
|
| 344 |
+
pass
|
| 345 |
|
| 346 |
# Mount frontend static files in production if dist folder is built
|
| 347 |
frontend_dist_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend", "dist")
|
frontend/src/App.css
CHANGED
|
@@ -345,7 +345,7 @@ body {
|
|
| 345 |
border-radius: 16px;
|
| 346 |
display: flex;
|
| 347 |
flex-direction: column;
|
| 348 |
-
min-height:
|
| 349 |
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
| 350 |
overflow: hidden;
|
| 351 |
transition: var(--transition);
|
|
@@ -412,28 +412,34 @@ body {
|
|
| 412 |
letter-spacing: 0.5px;
|
| 413 |
}
|
| 414 |
|
| 415 |
-
.sentiment-badge.
|
| 416 |
-
background-color:
|
| 417 |
-
border: 1px solid
|
| 418 |
-
color:
|
| 419 |
}
|
| 420 |
|
| 421 |
-
.sentiment-badge.
|
| 422 |
-
background-color:
|
| 423 |
-
border: 1px solid
|
| 424 |
-
color:
|
| 425 |
}
|
| 426 |
|
| 427 |
-
.sentiment-badge.
|
| 428 |
-
background-color:
|
| 429 |
-
border: 1px solid
|
| 430 |
-
color:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
}
|
| 432 |
|
| 433 |
.sentiment-badge.neutral {
|
| 434 |
-
background-color:
|
| 435 |
-
border: 1px solid
|
| 436 |
-
color:
|
| 437 |
}
|
| 438 |
|
| 439 |
/* Chat Body Response */
|
|
@@ -678,3 +684,206 @@ body {
|
|
| 678 |
line-height: 1.5;
|
| 679 |
color: var(--text-muted);
|
| 680 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
border-radius: 16px;
|
| 346 |
display: flex;
|
| 347 |
flex-direction: column;
|
| 348 |
+
min-height: auto;
|
| 349 |
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
| 350 |
overflow: hidden;
|
| 351 |
transition: var(--transition);
|
|
|
|
| 412 |
letter-spacing: 0.5px;
|
| 413 |
}
|
| 414 |
|
| 415 |
+
.sentiment-badge.confused_but_engaged, .sentiment-badge.confusion {
|
| 416 |
+
background-color: rgba(245, 158, 11, 0.1);
|
| 417 |
+
border: 1px solid #f59e0b;
|
| 418 |
+
color: #f59e0b;
|
| 419 |
}
|
| 420 |
|
| 421 |
+
.sentiment-badge.confused_and_frustrated, .sentiment-badge.frustration {
|
| 422 |
+
background-color: rgba(244, 63, 94, 0.1);
|
| 423 |
+
border: 1px solid #f43f5e;
|
| 424 |
+
color: #f43f5e;
|
| 425 |
}
|
| 426 |
|
| 427 |
+
.sentiment-badge.starting_to_get_bored {
|
| 428 |
+
background-color: rgba(168, 85, 247, 0.1);
|
| 429 |
+
border: 1px solid #a855f7;
|
| 430 |
+
color: #a855f7;
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
.sentiment-badge.confident_and_engaged {
|
| 434 |
+
background-color: rgba(16, 185, 129, 0.1);
|
| 435 |
+
border: 1px solid #10b981;
|
| 436 |
+
color: #10b981;
|
| 437 |
}
|
| 438 |
|
| 439 |
.sentiment-badge.neutral {
|
| 440 |
+
background-color: rgba(156, 163, 175, 0.1);
|
| 441 |
+
border: 1px solid #9ca3af;
|
| 442 |
+
color: #9ca3af;
|
| 443 |
}
|
| 444 |
|
| 445 |
/* Chat Body Response */
|
|
|
|
| 684 |
line-height: 1.5;
|
| 685 |
color: var(--text-muted);
|
| 686 |
}
|
| 687 |
+
|
| 688 |
+
/* Voice Session Modal */
|
| 689 |
+
.voice-modal-backdrop {
|
| 690 |
+
position: fixed;
|
| 691 |
+
top: 0;
|
| 692 |
+
left: 0;
|
| 693 |
+
width: 100vw;
|
| 694 |
+
height: 100vh;
|
| 695 |
+
background: rgba(11, 12, 16, 0.85);
|
| 696 |
+
backdrop-filter: blur(20px);
|
| 697 |
+
display: flex;
|
| 698 |
+
justify-content: center;
|
| 699 |
+
align-items: center;
|
| 700 |
+
z-index: 2000;
|
| 701 |
+
animation: fadeIn 0.3s ease;
|
| 702 |
+
}
|
| 703 |
+
|
| 704 |
+
.voice-modal-content {
|
| 705 |
+
background: rgba(22, 24, 33, 0.95);
|
| 706 |
+
border: 1px solid rgba(168, 85, 247, 0.25);
|
| 707 |
+
box-shadow: 0 20px 80px rgba(168, 85, 247, 0.15);
|
| 708 |
+
border-radius: 24px;
|
| 709 |
+
width: 90%;
|
| 710 |
+
max-width: 460px;
|
| 711 |
+
padding: 2.5rem;
|
| 712 |
+
display: flex;
|
| 713 |
+
flex-direction: column;
|
| 714 |
+
align-items: center;
|
| 715 |
+
position: relative;
|
| 716 |
+
animation: scaleUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
.voice-modal-close {
|
| 720 |
+
position: absolute;
|
| 721 |
+
top: 1.5rem;
|
| 722 |
+
right: 1.5rem;
|
| 723 |
+
background: none;
|
| 724 |
+
border: none;
|
| 725 |
+
color: var(--text-secondary);
|
| 726 |
+
cursor: pointer;
|
| 727 |
+
padding: 0.5rem;
|
| 728 |
+
border-radius: 50%;
|
| 729 |
+
transition: var(--transition);
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
.voice-modal-close:hover {
|
| 733 |
+
background: rgba(255, 255, 255, 0.05);
|
| 734 |
+
color: var(--text-primary);
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
.voice-modal-header {
|
| 738 |
+
text-align: center;
|
| 739 |
+
margin-bottom: 2rem;
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
.voice-modal-header h2 {
|
| 743 |
+
font-size: 1.6rem;
|
| 744 |
+
font-weight: 700;
|
| 745 |
+
background: linear-gradient(135deg, var(--text-primary), var(--secondary));
|
| 746 |
+
-webkit-background-clip: text;
|
| 747 |
+
-webkit-text-fill-color: transparent;
|
| 748 |
+
margin-bottom: 0.3rem;
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
.voice-modal-header p {
|
| 752 |
+
font-size: 0.85rem;
|
| 753 |
+
color: var(--text-muted);
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
.voice-visualizer-container {
|
| 757 |
+
height: 200px;
|
| 758 |
+
display: flex;
|
| 759 |
+
justify-content: center;
|
| 760 |
+
align-items: center;
|
| 761 |
+
position: relative;
|
| 762 |
+
width: 100%;
|
| 763 |
+
margin-bottom: 1.5rem;
|
| 764 |
+
}
|
| 765 |
+
|
| 766 |
+
.voice-status-indicator {
|
| 767 |
+
display: flex;
|
| 768 |
+
flex-direction: column;
|
| 769 |
+
align-items: center;
|
| 770 |
+
gap: 1.5rem;
|
| 771 |
+
font-size: 0.95rem;
|
| 772 |
+
font-weight: 600;
|
| 773 |
+
color: var(--text-secondary);
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
.pulse-circle {
|
| 777 |
+
width: 80px;
|
| 778 |
+
height: 80px;
|
| 779 |
+
border-radius: 50%;
|
| 780 |
+
background: var(--text-muted);
|
| 781 |
+
transition: var(--transition);
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
.pulse-circle.active {
|
| 785 |
+
background: linear-gradient(135deg, var(--color-happy), #34d399);
|
| 786 |
+
box-shadow: 0 0 30px rgba(16, 185, 129, 0.4);
|
| 787 |
+
animation: float 3s ease-in-out infinite;
|
| 788 |
+
}
|
| 789 |
+
|
| 790 |
+
.pulse-circle.speaking-pulse {
|
| 791 |
+
background: linear-gradient(135deg, var(--secondary), #a855f7);
|
| 792 |
+
box-shadow: 0 0 35px rgba(168, 85, 247, 0.5);
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
.pulse-ring {
|
| 796 |
+
position: absolute;
|
| 797 |
+
width: 80px;
|
| 798 |
+
height: 80px;
|
| 799 |
+
border-radius: 50%;
|
| 800 |
+
border: 2px solid var(--color-happy);
|
| 801 |
+
opacity: 0;
|
| 802 |
+
pointer-events: none;
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
.pulse-ring.speaking-ring {
|
| 806 |
+
border-color: var(--secondary);
|
| 807 |
+
}
|
| 808 |
+
|
| 809 |
+
.pulse-ring.ring-1 {
|
| 810 |
+
animation: ripple 2s cubic-bezier(0.1, 0.8, 0.3, 1) infinite;
|
| 811 |
+
}
|
| 812 |
+
|
| 813 |
+
.pulse-ring.ring-2 {
|
| 814 |
+
animation: ripple 2s cubic-bezier(0.1, 0.8, 0.3, 1) 0.6s infinite;
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
.voice-transcript-box {
|
| 818 |
+
background: rgba(255, 255, 255, 0.02);
|
| 819 |
+
border: 1px solid var(--border-color);
|
| 820 |
+
border-radius: 16px;
|
| 821 |
+
padding: 1rem 1.25rem;
|
| 822 |
+
width: 100%;
|
| 823 |
+
max-height: 100px;
|
| 824 |
+
overflow-y: auto;
|
| 825 |
+
margin-bottom: 2rem;
|
| 826 |
+
text-align: center;
|
| 827 |
+
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2);
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
.voice-transcript-box p {
|
| 831 |
+
margin: 0;
|
| 832 |
+
font-size: 0.9rem;
|
| 833 |
+
line-height: 1.5;
|
| 834 |
+
font-style: italic;
|
| 835 |
+
color: var(--text-primary);
|
| 836 |
+
}
|
| 837 |
+
|
| 838 |
+
.voice-modal-controls {
|
| 839 |
+
display: flex;
|
| 840 |
+
justify-content: center;
|
| 841 |
+
width: 100%;
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
.voice-control-btn {
|
| 845 |
+
background: rgba(255, 255, 255, 0.03);
|
| 846 |
+
border: 1px solid var(--border-color);
|
| 847 |
+
color: var(--text-primary);
|
| 848 |
+
display: flex;
|
| 849 |
+
flex-direction: column;
|
| 850 |
+
align-items: center;
|
| 851 |
+
gap: 0.5rem;
|
| 852 |
+
padding: 0.8rem 1.8rem;
|
| 853 |
+
border-radius: 16px;
|
| 854 |
+
cursor: pointer;
|
| 855 |
+
transition: var(--transition);
|
| 856 |
+
font-size: 0.75rem;
|
| 857 |
+
font-weight: 700;
|
| 858 |
+
text-transform: uppercase;
|
| 859 |
+
}
|
| 860 |
+
|
| 861 |
+
.voice-control-btn:hover {
|
| 862 |
+
background: rgba(255, 255, 255, 0.07);
|
| 863 |
+
border-color: rgba(255, 255, 255, 0.15);
|
| 864 |
+
}
|
| 865 |
+
|
| 866 |
+
.voice-control-btn.muted {
|
| 867 |
+
border-color: rgba(244, 63, 94, 0.3);
|
| 868 |
+
background: rgba(244, 63, 94, 0.08);
|
| 869 |
+
color: var(--color-frustrated);
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
.voice-control-btn.muted:hover {
|
| 873 |
+
background: rgba(244, 63, 94, 0.15);
|
| 874 |
+
}
|
| 875 |
+
|
| 876 |
+
@keyframes ripple {
|
| 877 |
+
0% { transform: scale(1); opacity: 0.8; }
|
| 878 |
+
100% { transform: scale(2.2); opacity: 0; }
|
| 879 |
+
}
|
| 880 |
+
|
| 881 |
+
@keyframes scaleUp {
|
| 882 |
+
from { transform: scale(0.9); opacity: 0; }
|
| 883 |
+
to { transform: scale(1); opacity: 1; }
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
@keyframes float {
|
| 887 |
+
0%, 100% { transform: translateY(0); }
|
| 888 |
+
50% { transform: translateY(-6px); }
|
| 889 |
+
}
|
frontend/src/App.jsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
-
import { Send, Settings as SettingsIcon, GraduationCap,
|
| 3 |
import Settings from './components/Settings';
|
| 4 |
import ChatWindow from './components/ChatWindow';
|
|
|
|
| 5 |
import './App.css';
|
| 6 |
|
| 7 |
const API_BASE = window.location.origin === 'http://localhost:5173' ? 'http://localhost:8000' : '';
|
|
@@ -61,21 +62,15 @@ const samples = {
|
|
| 61 |
|
| 62 |
export default function App() {
|
| 63 |
const [apiKey, setApiKeyState] = useState(() => localStorage.getItem('gemini_api_key') || '');
|
| 64 |
-
const [systemPrompt, setSystemPromptState] = useState(() => localStorage.getItem('system_prompt') || '');
|
| 65 |
const [selectedSampleCategory, setSelectedSampleCategory] = useState('confusion');
|
| 66 |
const [message, setMessage] = useState('');
|
| 67 |
-
const [result, setResult] = useState(null);
|
| 68 |
const [loading, setLoading] = useState(false);
|
| 69 |
const [error, setError] = useState(null);
|
| 70 |
-
const [
|
| 71 |
-
const [historyB, setHistoryB] = useState([]);
|
| 72 |
-
const [historyC, setHistoryC] = useState([]);
|
| 73 |
-
const [historyD, setHistoryD] = useState([]);
|
| 74 |
-
const [selectedOption, setSelectedOption] = useState('all');
|
| 75 |
const [showSettings, setShowSettings] = useState(false);
|
|
|
|
| 76 |
const [backendStatus, setBackendStatus] = useState({
|
| 77 |
-
|
| 78 |
-
roberta_error: null,
|
| 79 |
gemini_api_key_configured: false
|
| 80 |
});
|
| 81 |
|
|
@@ -84,10 +79,6 @@ export default function App() {
|
|
| 84 |
localStorage.setItem('gemini_api_key', val);
|
| 85 |
};
|
| 86 |
|
| 87 |
-
const setSystemPrompt = (val) => {
|
| 88 |
-
setSystemPromptState(val);
|
| 89 |
-
localStorage.setItem('system_prompt', val);
|
| 90 |
-
};
|
| 91 |
|
| 92 |
const checkBackendStatus = async () => {
|
| 93 |
try {
|
|
@@ -95,31 +86,21 @@ export default function App() {
|
|
| 95 |
if (res.ok) {
|
| 96 |
const data = await res.json();
|
| 97 |
setBackendStatus(data);
|
| 98 |
-
return data.
|
| 99 |
}
|
| 100 |
} catch (err) {
|
| 101 |
console.error("Failed to fetch backend status:", err);
|
| 102 |
setBackendStatus({
|
| 103 |
-
|
| 104 |
-
roberta_error: 'Backend is offline or unreachable',
|
| 105 |
gemini_api_key_configured: false
|
| 106 |
});
|
| 107 |
}
|
| 108 |
return 'failed';
|
| 109 |
};
|
| 110 |
|
| 111 |
-
// Poll status on startup
|
| 112 |
useEffect(() => {
|
| 113 |
checkBackendStatus();
|
| 114 |
-
|
| 115 |
-
const interval = setInterval(async () => {
|
| 116 |
-
const status = await checkBackendStatus();
|
| 117 |
-
if (status === 'ready' || status === 'failed') {
|
| 118 |
-
clearInterval(interval);
|
| 119 |
-
}
|
| 120 |
-
}, 4000);
|
| 121 |
-
|
| 122 |
-
return () => clearInterval(interval);
|
| 123 |
}, []);
|
| 124 |
|
| 125 |
const handleSubmit = async (e, followUpText = null) => {
|
|
@@ -131,48 +112,31 @@ export default function App() {
|
|
| 131 |
setLoading(true);
|
| 132 |
setError(null);
|
| 133 |
|
| 134 |
-
//
|
| 135 |
-
let
|
| 136 |
-
let currentHistoryB = historyB;
|
| 137 |
-
let currentHistoryC = historyC;
|
| 138 |
-
let currentHistoryD = historyD;
|
| 139 |
-
|
| 140 |
if (followUpText === null) {
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
currentHistoryB = [];
|
| 144 |
-
currentHistoryC = [];
|
| 145 |
-
currentHistoryD = [];
|
| 146 |
-
setHistoryA([]);
|
| 147 |
-
setHistoryB([]);
|
| 148 |
-
setHistoryC([]);
|
| 149 |
-
setHistoryD([]);
|
| 150 |
}
|
| 151 |
|
| 152 |
-
//
|
| 153 |
-
const
|
| 154 |
-
|
| 155 |
-
const nextHistoryC = [...currentHistoryC, { role: 'user', content: activeMessage }];
|
| 156 |
-
const nextHistoryD = [...currentHistoryD, { role: 'user', content: activeMessage }];
|
| 157 |
-
|
| 158 |
-
setHistoryA(nextHistoryA);
|
| 159 |
-
setHistoryB(nextHistoryB);
|
| 160 |
-
setHistoryC(nextHistoryC);
|
| 161 |
-
setHistoryD(nextHistoryD);
|
| 162 |
|
| 163 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
const res = await fetch(`${API_BASE}/api/chat`, {
|
| 165 |
method: 'POST',
|
| 166 |
headers: { 'Content-Type': 'application/json' },
|
| 167 |
body: JSON.stringify({
|
| 168 |
message: activeMessage,
|
| 169 |
gemini_api_key: apiKey || null,
|
| 170 |
-
|
| 171 |
-
history_a: currentHistoryA,
|
| 172 |
-
history_b: currentHistoryB,
|
| 173 |
-
history_c: currentHistoryC,
|
| 174 |
-
history_d: currentHistoryD,
|
| 175 |
-
selected_option: selectedOption
|
| 176 |
})
|
| 177 |
});
|
| 178 |
|
|
@@ -182,13 +146,20 @@ export default function App() {
|
|
| 182 |
}
|
| 183 |
|
| 184 |
const data = await res.json();
|
| 185 |
-
setResult(data);
|
| 186 |
|
| 187 |
-
// Append
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
if (followUpText === null) {
|
| 194 |
setMessage('');
|
|
@@ -205,7 +176,10 @@ export default function App() {
|
|
| 205 |
setMessage(promptText);
|
| 206 |
};
|
| 207 |
|
| 208 |
-
const
|
|
|
|
|
|
|
|
|
|
| 209 |
|
| 210 |
return (
|
| 211 |
<div className="app-container">
|
|
@@ -214,25 +188,24 @@ export default function App() {
|
|
| 214 |
<div className="header-title-section">
|
| 215 |
<h1 style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
| 216 |
<GraduationCap size={36} color="var(--primary)" />
|
| 217 |
-
Sentiment
|
| 218 |
</h1>
|
| 219 |
-
<p>
|
| 220 |
</div>
|
| 221 |
|
| 222 |
<div className="header-status">
|
| 223 |
-
|
| 224 |
-
<
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
</div>
|
| 229 |
|
| 230 |
<button
|
| 231 |
className="settings-toggle-btn"
|
| 232 |
onClick={() => setShowSettings(!showSettings)}
|
| 233 |
>
|
| 234 |
<SettingsIcon size={18} />
|
| 235 |
-
|
| 236 |
</button>
|
| 237 |
</div>
|
| 238 |
</header>
|
|
@@ -242,50 +215,15 @@ export default function App() {
|
|
| 242 |
<Settings
|
| 243 |
apiKey={apiKey}
|
| 244 |
setApiKey={setApiKey}
|
| 245 |
-
systemPrompt={systemPrompt}
|
| 246 |
-
setSystemPrompt={setSystemPrompt}
|
| 247 |
backendStatus={backendStatus}
|
| 248 |
checkBackendStatus={checkBackendStatus}
|
| 249 |
/>
|
| 250 |
)}
|
| 251 |
|
| 252 |
{/* Main Grid */}
|
| 253 |
-
<main className="main-grid">
|
| 254 |
{/* Chat input box */}
|
| 255 |
<section className="query-card">
|
| 256 |
-
{/* Pipeline Selector */}
|
| 257 |
-
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '0.8rem' }}>
|
| 258 |
-
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase' }}>Active Pipeline:</span>
|
| 259 |
-
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
| 260 |
-
{[
|
| 261 |
-
{ id: 'all', label: 'All (Compare)' },
|
| 262 |
-
{ id: 'A', label: 'Option A (LangGraph 2-Pass)' },
|
| 263 |
-
{ id: 'B', label: 'Option B (Gemini Single-Pass)' },
|
| 264 |
-
{ id: 'C', label: 'Option C (DistilRoBERTa Dist.)' },
|
| 265 |
-
{ id: 'D', label: 'Option D (DistilRoBERTa Class.)' }
|
| 266 |
-
].map((opt) => (
|
| 267 |
-
<button
|
| 268 |
-
key={opt.id}
|
| 269 |
-
type="button"
|
| 270 |
-
onClick={() => setSelectedOption(opt.id)}
|
| 271 |
-
style={{
|
| 272 |
-
background: selectedOption === opt.id ? 'var(--primary)' : 'rgba(255,255,255,0.03)',
|
| 273 |
-
border: '1px solid ' + (selectedOption === opt.id ? 'var(--primary)' : 'var(--border-color)'),
|
| 274 |
-
color: selectedOption === opt.id ? '#fff' : 'var(--text-secondary)',
|
| 275 |
-
padding: '0.4rem 0.8rem',
|
| 276 |
-
borderRadius: '8px',
|
| 277 |
-
fontSize: '0.85rem',
|
| 278 |
-
fontWeight: 600,
|
| 279 |
-
cursor: 'pointer',
|
| 280 |
-
transition: 'var(--transition)'
|
| 281 |
-
}}
|
| 282 |
-
>
|
| 283 |
-
{opt.label}
|
| 284 |
-
</button>
|
| 285 |
-
))}
|
| 286 |
-
</div>
|
| 287 |
-
</div>
|
| 288 |
-
|
| 289 |
<form onSubmit={handleSubmit} className="query-input-wrapper">
|
| 290 |
<textarea
|
| 291 |
value={message}
|
|
@@ -299,20 +237,35 @@ export default function App() {
|
|
| 299 |
}
|
| 300 |
}}
|
| 301 |
/>
|
| 302 |
-
<
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
</form>
|
| 311 |
|
| 312 |
{/* Quick prompts */}
|
| 313 |
<div className="quick-prompts-section" style={{ display: 'flex', flexDirection: 'column', gap: '0.8rem', marginTop: '0.5rem' }}>
|
| 314 |
<div style={{ display: 'flex', alignItems: 'center', gap: '0.8rem', flexWrap: 'wrap', borderBottom: '1px solid var(--border-color)', paddingBottom: '0.6rem' }}>
|
| 315 |
-
<span className="quick-prompt-label" style={{ marginRight: 'auto' }}>
|
| 316 |
<div style={{ display: 'flex', gap: '0.4rem' }}>
|
| 317 |
{Object.keys(samples).map((cat) => (
|
| 318 |
<button
|
|
@@ -353,21 +306,23 @@ export default function App() {
|
|
| 353 |
</div>
|
| 354 |
</section>
|
| 355 |
|
| 356 |
-
{/*
|
| 357 |
-
<section>
|
| 358 |
<ChatWindow
|
| 359 |
-
|
| 360 |
loading={loading}
|
| 361 |
error={error}
|
| 362 |
-
historyA={historyA}
|
| 363 |
-
historyB={historyB}
|
| 364 |
-
historyC={historyC}
|
| 365 |
-
historyD={historyD}
|
| 366 |
-
selectedOption={selectedOption}
|
| 367 |
onSubmitFollowUp={(text) => handleSubmit(null, text)}
|
| 368 |
/>
|
| 369 |
</section>
|
| 370 |
</main>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
</div>
|
| 372 |
);
|
| 373 |
}
|
|
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { Send, Settings as SettingsIcon, GraduationCap, Mic } from 'lucide-react';
|
| 3 |
import Settings from './components/Settings';
|
| 4 |
import ChatWindow from './components/ChatWindow';
|
| 5 |
+
import VoiceSessionModal from './components/VoiceSessionModal';
|
| 6 |
import './App.css';
|
| 7 |
|
| 8 |
const API_BASE = window.location.origin === 'http://localhost:5173' ? 'http://localhost:8000' : '';
|
|
|
|
| 62 |
|
| 63 |
export default function App() {
|
| 64 |
const [apiKey, setApiKeyState] = useState(() => localStorage.getItem('gemini_api_key') || '');
|
|
|
|
| 65 |
const [selectedSampleCategory, setSelectedSampleCategory] = useState('confusion');
|
| 66 |
const [message, setMessage] = useState('');
|
|
|
|
| 67 |
const [loading, setLoading] = useState(false);
|
| 68 |
const [error, setError] = useState(null);
|
| 69 |
+
const [history, setHistory] = useState([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
const [showSettings, setShowSettings] = useState(false);
|
| 71 |
+
const [showVoiceModal, setShowVoiceModal] = useState(false);
|
| 72 |
const [backendStatus, setBackendStatus] = useState({
|
| 73 |
+
status: 'loading',
|
|
|
|
| 74 |
gemini_api_key_configured: false
|
| 75 |
});
|
| 76 |
|
|
|
|
| 79 |
localStorage.setItem('gemini_api_key', val);
|
| 80 |
};
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
const checkBackendStatus = async () => {
|
| 84 |
try {
|
|
|
|
| 86 |
if (res.ok) {
|
| 87 |
const data = await res.json();
|
| 88 |
setBackendStatus(data);
|
| 89 |
+
return data.status;
|
| 90 |
}
|
| 91 |
} catch (err) {
|
| 92 |
console.error("Failed to fetch backend status:", err);
|
| 93 |
setBackendStatus({
|
| 94 |
+
status: 'failed',
|
|
|
|
| 95 |
gemini_api_key_configured: false
|
| 96 |
});
|
| 97 |
}
|
| 98 |
return 'failed';
|
| 99 |
};
|
| 100 |
|
| 101 |
+
// Poll status on startup
|
| 102 |
useEffect(() => {
|
| 103 |
checkBackendStatus();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
}, []);
|
| 105 |
|
| 106 |
const handleSubmit = async (e, followUpText = null) => {
|
|
|
|
| 112 |
setLoading(true);
|
| 113 |
setError(null);
|
| 114 |
|
| 115 |
+
// If starting a brand new topic (not a follow-up), clear history
|
| 116 |
+
let currentHistory = history;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
if (followUpText === null) {
|
| 118 |
+
currentHistory = [];
|
| 119 |
+
setHistory([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
}
|
| 121 |
|
| 122 |
+
// Append optimistic user bubble
|
| 123 |
+
const nextHistory = [...currentHistory, { role: 'user', content: activeMessage }];
|
| 124 |
+
setHistory(nextHistory);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
try {
|
| 127 |
+
// Minimize context token usage: send only role and content to the API
|
| 128 |
+
const optimizedHistoryPayload = currentHistory.map(msg => ({
|
| 129 |
+
role: msg.role,
|
| 130 |
+
content: msg.content
|
| 131 |
+
}));
|
| 132 |
+
|
| 133 |
const res = await fetch(`${API_BASE}/api/chat`, {
|
| 134 |
method: 'POST',
|
| 135 |
headers: { 'Content-Type': 'application/json' },
|
| 136 |
body: JSON.stringify({
|
| 137 |
message: activeMessage,
|
| 138 |
gemini_api_key: apiKey || null,
|
| 139 |
+
history: optimizedHistoryPayload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
})
|
| 141 |
});
|
| 142 |
|
|
|
|
| 146 |
}
|
| 147 |
|
| 148 |
const data = await res.json();
|
|
|
|
| 149 |
|
| 150 |
+
// Append assistant bubble along with sentiment analysis details & metrics
|
| 151 |
+
setHistory([
|
| 152 |
+
...nextHistory,
|
| 153 |
+
{
|
| 154 |
+
role: 'assistant',
|
| 155 |
+
content: data.response,
|
| 156 |
+
sentiment: data.sentiment,
|
| 157 |
+
latency: data.latency,
|
| 158 |
+
tokens: data.tokens,
|
| 159 |
+
cost: data.cost,
|
| 160 |
+
prompt_context: data.prompt_context
|
| 161 |
+
}
|
| 162 |
+
]);
|
| 163 |
|
| 164 |
if (followUpText === null) {
|
| 165 |
setMessage('');
|
|
|
|
| 176 |
setMessage(promptText);
|
| 177 |
};
|
| 178 |
|
| 179 |
+
const clearChat = () => {
|
| 180 |
+
setHistory([]);
|
| 181 |
+
setError(null);
|
| 182 |
+
};
|
| 183 |
|
| 184 |
return (
|
| 185 |
<div className="app-container">
|
|
|
|
| 188 |
<div className="header-title-section">
|
| 189 |
<h1 style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
| 190 |
<GraduationCap size={36} color="var(--primary)" />
|
| 191 |
+
Socratic Sentiment Tutor
|
| 192 |
</h1>
|
| 193 |
+
<p>Context-aware sentiment detection & guidance chatbot (Option B)</p>
|
| 194 |
</div>
|
| 195 |
|
| 196 |
<div className="header-status">
|
| 197 |
+
{history.length > 0 && (
|
| 198 |
+
<button className="settings-toggle-btn" onClick={clearChat} style={{ border: '1px solid rgba(244, 63, 94, 0.2)', color: 'var(--color-frustrated)' }}>
|
| 199 |
+
Clear Conversation
|
| 200 |
+
</button>
|
| 201 |
+
)}
|
|
|
|
| 202 |
|
| 203 |
<button
|
| 204 |
className="settings-toggle-btn"
|
| 205 |
onClick={() => setShowSettings(!showSettings)}
|
| 206 |
>
|
| 207 |
<SettingsIcon size={18} />
|
| 208 |
+
Configure Tutor
|
| 209 |
</button>
|
| 210 |
</div>
|
| 211 |
</header>
|
|
|
|
| 215 |
<Settings
|
| 216 |
apiKey={apiKey}
|
| 217 |
setApiKey={setApiKey}
|
|
|
|
|
|
|
| 218 |
backendStatus={backendStatus}
|
| 219 |
checkBackendStatus={checkBackendStatus}
|
| 220 |
/>
|
| 221 |
)}
|
| 222 |
|
| 223 |
{/* Main Grid */}
|
| 224 |
+
<main className="main-grid" style={{ gridTemplateColumns: '1fr' }}>
|
| 225 |
{/* Chat input box */}
|
| 226 |
<section className="query-card">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
<form onSubmit={handleSubmit} className="query-input-wrapper">
|
| 228 |
<textarea
|
| 229 |
value={message}
|
|
|
|
| 237 |
}
|
| 238 |
}}
|
| 239 |
/>
|
| 240 |
+
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
| 241 |
+
<button
|
| 242 |
+
type="submit"
|
| 243 |
+
className="send-button"
|
| 244 |
+
disabled={loading || !message.trim()}
|
| 245 |
+
>
|
| 246 |
+
<Send size={18} />
|
| 247 |
+
Start Conversation
|
| 248 |
+
</button>
|
| 249 |
+
<button
|
| 250 |
+
type="button"
|
| 251 |
+
className="send-button voice-btn"
|
| 252 |
+
onClick={() => setShowVoiceModal(true)}
|
| 253 |
+
title="Start voice session"
|
| 254 |
+
style={{
|
| 255 |
+
background: 'linear-gradient(135deg, var(--secondary), #7c3aed)',
|
| 256 |
+
padding: '0.6rem 1.1rem'
|
| 257 |
+
}}
|
| 258 |
+
>
|
| 259 |
+
<Mic size={18} />
|
| 260 |
+
Voice Session
|
| 261 |
+
</button>
|
| 262 |
+
</div>
|
| 263 |
</form>
|
| 264 |
|
| 265 |
{/* Quick prompts */}
|
| 266 |
<div className="quick-prompts-section" style={{ display: 'flex', flexDirection: 'column', gap: '0.8rem', marginTop: '0.5rem' }}>
|
| 267 |
<div style={{ display: 'flex', alignItems: 'center', gap: '0.8rem', flexWrap: 'wrap', borderBottom: '1px solid var(--border-color)', paddingBottom: '0.6rem' }}>
|
| 268 |
+
<span className="quick-prompt-label" style={{ marginRight: 'auto' }}>Sample Initial Inquiries:</span>
|
| 269 |
<div style={{ display: 'flex', gap: '0.4rem' }}>
|
| 270 |
{Object.keys(samples).map((cat) => (
|
| 271 |
<button
|
|
|
|
| 306 |
</div>
|
| 307 |
</section>
|
| 308 |
|
| 309 |
+
{/* Conversation Chat Output */}
|
| 310 |
+
<section style={{ width: '100%' }}>
|
| 311 |
<ChatWindow
|
| 312 |
+
history={history}
|
| 313 |
loading={loading}
|
| 314 |
error={error}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
onSubmitFollowUp={(text) => handleSubmit(null, text)}
|
| 316 |
/>
|
| 317 |
</section>
|
| 318 |
</main>
|
| 319 |
+
|
| 320 |
+
{/* Voice Session Overlay Modal */}
|
| 321 |
+
<VoiceSessionModal
|
| 322 |
+
isOpen={showVoiceModal}
|
| 323 |
+
onClose={() => setShowVoiceModal(false)}
|
| 324 |
+
apiKey={apiKey}
|
| 325 |
+
/>
|
| 326 |
</div>
|
| 327 |
);
|
| 328 |
}
|
frontend/src/components/ChatWindow.jsx
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
-
import { Sparkles,
|
| 3 |
-
import EmotionChart from './EmotionChart';
|
| 4 |
|
| 5 |
// Render LaTeX and text mixed together
|
| 6 |
function RenderLatex({ text }) {
|
|
@@ -32,7 +31,6 @@ function RenderLatex({ text }) {
|
|
| 32 |
{inlineParts.map((ip, ipIdx) => {
|
| 33 |
if (ip.startsWith('$') && ip.endsWith('$')) {
|
| 34 |
const formula = ip.slice(1, -1);
|
| 35 |
-
// Make sure it's not a lonely single dollar sign or empty
|
| 36 |
if (formula.trim()) {
|
| 37 |
try {
|
| 38 |
if (window.katex) {
|
|
@@ -46,7 +44,6 @@ function RenderLatex({ text }) {
|
|
| 46 |
return <span key={ipIdx}>{ip}</span>;
|
| 47 |
}
|
| 48 |
|
| 49 |
-
// Non-math text, run standard inline formatting
|
| 50 |
return <span key={ipIdx} dangerouslySetInnerHTML={{ __html: formatInline(ip) }} />;
|
| 51 |
})}
|
| 52 |
</React.Fragment>
|
|
@@ -56,53 +53,17 @@ function RenderLatex({ text }) {
|
|
| 56 |
);
|
| 57 |
}
|
| 58 |
|
| 59 |
-
// Renders the scrollable thread of chat bubbles for a column
|
| 60 |
-
function ChatThread({ history, title }) {
|
| 61 |
-
const assistantMessages = history.filter(msg => msg.role === 'assistant');
|
| 62 |
-
return (
|
| 63 |
-
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.8rem', flex: 1, overflowY: 'auto', maxHeight: '420px', paddingRight: '0.25rem' }}>
|
| 64 |
-
{assistantMessages.map((msg, idx) => (
|
| 65 |
-
<div
|
| 66 |
-
key={idx}
|
| 67 |
-
style={{
|
| 68 |
-
alignSelf: 'flex-start',
|
| 69 |
-
background: 'rgba(255, 255, 255, 0.02)',
|
| 70 |
-
border: '1px solid var(--border-color)',
|
| 71 |
-
borderRadius: '12px',
|
| 72 |
-
padding: '0.75rem 1rem',
|
| 73 |
-
maxWidth: '100%',
|
| 74 |
-
}}
|
| 75 |
-
>
|
| 76 |
-
<span style={{
|
| 77 |
-
fontSize: '0.7rem',
|
| 78 |
-
fontWeight: 700,
|
| 79 |
-
textTransform: 'uppercase',
|
| 80 |
-
color: 'var(--text-secondary)',
|
| 81 |
-
display: 'block',
|
| 82 |
-
marginBottom: '0.3rem'
|
| 83 |
-
}}>
|
| 84 |
-
{title}
|
| 85 |
-
</span>
|
| 86 |
-
<SafeMarkdown content={msg.content} />
|
| 87 |
-
</div>
|
| 88 |
-
))}
|
| 89 |
-
</div>
|
| 90 |
-
);
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
// A simple local Markdown parser that converts basic markdown elements to safe HTML
|
| 94 |
function SafeMarkdown({ content }) {
|
| 95 |
if (!content) return null;
|
| 96 |
|
| 97 |
-
// Split by code blocks first
|
| 98 |
const parts = content.split(/(```[\s\S]*?```)/g);
|
| 99 |
|
| 100 |
return (
|
| 101 |
<div className="chat-response-content">
|
| 102 |
{parts.map((part, index) => {
|
| 103 |
if (part.startsWith('```') && part.endsWith('```')) {
|
| 104 |
-
//
|
| 105 |
-
const code = part.slice(3, -3).replace(/^\w+\n/, ''); // remove language identifier if present
|
| 106 |
return (
|
| 107 |
<pre key={index}>
|
| 108 |
<code>{code}</code>
|
|
@@ -110,7 +71,6 @@ function SafeMarkdown({ content }) {
|
|
| 110 |
);
|
| 111 |
}
|
| 112 |
|
| 113 |
-
// It's normal text, format paragraph breaks, bold, inline code, and lists
|
| 114 |
const formatted = part
|
| 115 |
.split('\n\n')
|
| 116 |
.map((para, paraIdx) => {
|
|
@@ -122,7 +82,6 @@ function SafeMarkdown({ content }) {
|
|
| 122 |
return (
|
| 123 |
<ul key={paraIdx} style={{ marginBottom: '1rem', paddingLeft: '1.5rem' }}>
|
| 124 |
{items.map((item, itemIdx) => {
|
| 125 |
-
// strip initial bullet from first item if split didn't catch it
|
| 126 |
let cleanItem = item;
|
| 127 |
if (itemIdx === 0) {
|
| 128 |
cleanItem = item.replace(/^\s*[-*]\s+/, '');
|
|
@@ -138,7 +97,6 @@ function SafeMarkdown({ content }) {
|
|
| 138 |
);
|
| 139 |
}
|
| 140 |
|
| 141 |
-
// Normal paragraph
|
| 142 |
return (
|
| 143 |
<p key={paraIdx}>
|
| 144 |
<RenderLatex text={para} />
|
|
@@ -155,137 +113,49 @@ function SafeMarkdown({ content }) {
|
|
| 155 |
// Format bold (**), italics (*), and inline code (`)
|
| 156 |
function formatInline(text) {
|
| 157 |
return text
|
| 158 |
-
// Bold
|
| 159 |
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
| 160 |
-
// Italics
|
| 161 |
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
| 162 |
-
// Inline code
|
| 163 |
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
| 164 |
-
// Replace newlines within a paragraph with breaks
|
| 165 |
.replace(/\n/g, '<br />');
|
| 166 |
}
|
| 167 |
|
| 168 |
export default function ChatWindow({
|
| 169 |
-
|
| 170 |
loading,
|
| 171 |
error,
|
| 172 |
-
historyA = [],
|
| 173 |
-
historyB = [],
|
| 174 |
-
historyC = [],
|
| 175 |
-
historyD = [],
|
| 176 |
-
selectedOption = 'all',
|
| 177 |
onSubmitFollowUp
|
| 178 |
}) {
|
| 179 |
-
const [
|
| 180 |
-
const [showPromptB, setShowPromptB] = useState(false);
|
| 181 |
-
const [showPromptC, setShowPromptC] = useState(false);
|
| 182 |
-
const [showPromptD, setShowPromptD] = useState(false);
|
| 183 |
-
const [showDistC, setShowDistC] = useState(false);
|
| 184 |
-
const [showDistD, setShowDistD] = useState(false);
|
| 185 |
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
|
|
|
|
|
|
|
| 193 |
return (
|
| 194 |
-
<div
|
| 195 |
-
className="
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
}}
|
| 201 |
-
>
|
| 202 |
-
{/* Skeleton Column A */}
|
| 203 |
-
{showA && (
|
| 204 |
-
<div className="column-card">
|
| 205 |
-
<div className="column-header">
|
| 206 |
-
<div className="column-title-wrapper">
|
| 207 |
-
<h2><Sparkles size={18} color="var(--primary)" /> Output A</h2>
|
| 208 |
-
<p>Analyzing Sentiment...</p>
|
| 209 |
-
</div>
|
| 210 |
-
<div className="latency-badge">--s</div>
|
| 211 |
-
</div>
|
| 212 |
-
<div className="column-body">
|
| 213 |
-
<div className="skeleton-box" />
|
| 214 |
-
<div className="skeleton-wrapper">
|
| 215 |
-
<div className="skeleton-line long" />
|
| 216 |
-
<div className="skeleton-line medium" />
|
| 217 |
-
<div className="skeleton-line long" />
|
| 218 |
-
<div className="skeleton-line short" />
|
| 219 |
-
</div>
|
| 220 |
-
</div>
|
| 221 |
-
</div>
|
| 222 |
-
)}
|
| 223 |
-
|
| 224 |
-
{/* Skeleton Column B */}
|
| 225 |
-
{showB && (
|
| 226 |
-
<div className="column-card">
|
| 227 |
-
<div className="column-header">
|
| 228 |
-
<div className="column-title-wrapper">
|
| 229 |
-
<h2><Cpu size={18} color="var(--secondary)" /> Output B</h2>
|
| 230 |
-
<p>Analyzing Sentiment...</p>
|
| 231 |
-
</div>
|
| 232 |
-
<div className="latency-badge">--s</div>
|
| 233 |
-
</div>
|
| 234 |
-
<div className="column-body">
|
| 235 |
-
<div className="skeleton-box" />
|
| 236 |
-
<div className="skeleton-wrapper">
|
| 237 |
-
<div className="skeleton-line long" />
|
| 238 |
-
<div className="skeleton-line medium" />
|
| 239 |
-
<div className="skeleton-line long" />
|
| 240 |
-
<div className="skeleton-line short" />
|
| 241 |
-
</div>
|
| 242 |
-
</div>
|
| 243 |
-
</div>
|
| 244 |
-
)}
|
| 245 |
-
|
| 246 |
-
{/* Skeleton Column C */}
|
| 247 |
-
{showC && (
|
| 248 |
-
<div className="column-card">
|
| 249 |
-
<div className="column-header">
|
| 250 |
-
<div className="column-title-wrapper">
|
| 251 |
-
<h2><Brain size={18} color="var(--color-happy)" /> Output C</h2>
|
| 252 |
-
<p>Analyzing Sentiment...</p>
|
| 253 |
-
</div>
|
| 254 |
-
<div className="latency-badge">--s</div>
|
| 255 |
-
</div>
|
| 256 |
-
<div className="column-body">
|
| 257 |
-
<div className="skeleton-box" />
|
| 258 |
-
<div className="skeleton-wrapper">
|
| 259 |
-
<div className="skeleton-line long" />
|
| 260 |
-
<div className="skeleton-line medium" />
|
| 261 |
-
<div className="skeleton-line long" />
|
| 262 |
-
<div className="skeleton-line short" />
|
| 263 |
-
</div>
|
| 264 |
</div>
|
|
|
|
| 265 |
</div>
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
<div className="
|
| 273 |
-
<h2><Brain size={18} color="var(--secondary)" /> Output D</h2>
|
| 274 |
-
<p>Analyzing Sentiment...</p>
|
| 275 |
-
</div>
|
| 276 |
-
<div className="latency-badge">--s</div>
|
| 277 |
-
</div>
|
| 278 |
-
<div className="column-body">
|
| 279 |
-
<div className="skeleton-box" />
|
| 280 |
-
<div className="skeleton-wrapper">
|
| 281 |
-
<div className="skeleton-line long" />
|
| 282 |
-
<div className="skeleton-line medium" />
|
| 283 |
-
<div className="skeleton-line long" />
|
| 284 |
-
<div className="skeleton-line short" />
|
| 285 |
-
</div>
|
| 286 |
</div>
|
| 287 |
</div>
|
| 288 |
-
|
| 289 |
</div>
|
| 290 |
);
|
| 291 |
}
|
|
@@ -301,7 +171,8 @@ export default function ChatWindow({
|
|
| 301 |
display: 'flex',
|
| 302 |
alignItems: 'flex-start',
|
| 303 |
gap: '1rem',
|
| 304 |
-
color: 'var(--color-frustrated)'
|
|
|
|
| 305 |
}}>
|
| 306 |
<AlertCircle size={24} style={{ flexShrink: 0 }} />
|
| 307 |
<div>
|
|
@@ -313,328 +184,227 @@ export default function ChatWindow({
|
|
| 313 |
}
|
| 314 |
|
| 315 |
// Render empty state
|
| 316 |
-
if (
|
| 317 |
return (
|
| 318 |
<div className="empty-state">
|
| 319 |
-
<
|
| 320 |
-
<h3>
|
| 321 |
-
<p>
|
| 322 |
</div>
|
| 323 |
);
|
| 324 |
}
|
| 325 |
|
| 326 |
return (
|
| 327 |
-
<>
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
{result.response_a && (
|
| 363 |
-
<div className="column-card">
|
| 364 |
-
<div className="column-header">
|
| 365 |
-
<div className="column-title-wrapper">
|
| 366 |
-
<h2><Sparkles size={18} color="var(--primary)" /> Output A</h2>
|
| 367 |
-
<p>Gemini 2.5 Flash Lite Sentiment</p>
|
| 368 |
-
</div>
|
| 369 |
-
<div className="column-meta">
|
| 370 |
-
<span className={`sentiment-badge ${result.sentiment_a.detected_sentiment}`}>
|
| 371 |
-
{result.sentiment_a.detected_sentiment}
|
| 372 |
-
</span>
|
| 373 |
-
<div className="latency-badge" title="Response Latency & Tokens">
|
| 374 |
-
<Clock size={12} style={{ display: 'inline', marginRight: '4px', verticalAlign: 'middle' }} />
|
| 375 |
-
{result.sentiment_a.latency_a || result.latency_a}s
|
| 376 |
-
{result.tokens_a !== undefined && result.tokens_a !== null && (
|
| 377 |
-
<>
|
| 378 |
-
<span style={{ margin: '0 6px', opacity: 0.4 }}>|</span>
|
| 379 |
-
<span>{result.tokens_a} tokens</span>
|
| 380 |
-
</>
|
| 381 |
-
)}
|
| 382 |
-
</div>
|
| 383 |
-
</div>
|
| 384 |
-
</div>
|
| 385 |
-
|
| 386 |
-
<div className="column-body">
|
| 387 |
-
<div className="sentiment-analysis-box" style={{ marginBottom: '0.5rem' }}>
|
| 388 |
-
<h3>Gemini Sentiment Analysis</h3>
|
| 389 |
-
<p>{result.sentiment_a.explanation}</p>
|
| 390 |
-
</div>
|
| 391 |
-
|
| 392 |
-
<ChatThread history={historyA} title="Tutor A" />
|
| 393 |
-
</div>
|
| 394 |
-
|
| 395 |
-
{/* Prompt Context A Inspector */}
|
| 396 |
-
<div className="inspector-section">
|
| 397 |
-
<div className="inspector-header" onClick={() => setShowPromptA(!showPromptA)}>
|
| 398 |
-
<span>
|
| 399 |
-
<Terminal size={12} />
|
| 400 |
-
Prompt Context Context (Flow A)
|
| 401 |
-
</span>
|
| 402 |
-
{showPromptA ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
| 403 |
-
</div>
|
| 404 |
-
{showPromptA && (
|
| 405 |
-
<div className="inspector-body">
|
| 406 |
-
{result.prompt_context_a}
|
| 407 |
</div>
|
| 408 |
-
)
|
| 409 |
-
|
| 410 |
-
</div>
|
| 411 |
-
)}
|
| 412 |
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
<>
|
| 430 |
-
<
|
| 431 |
-
|
| 432 |
-
</>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
)}
|
| 434 |
</div>
|
| 435 |
-
</div>
|
| 436 |
-
</div>
|
| 437 |
-
|
| 438 |
-
<div className="column-body">
|
| 439 |
-
<div className="sentiment-analysis-box" style={{ marginBottom: '0.5rem' }}>
|
| 440 |
-
<h3>Gemini Single-Pass Analysis</h3>
|
| 441 |
-
<p>Detected Sentiment and Socratic response generated in one pass: <strong>{result.sentiment_b.mapped_sentiment}</strong></p>
|
| 442 |
-
</div>
|
| 443 |
-
|
| 444 |
-
<ChatThread history={historyB} title="Tutor B" />
|
| 445 |
-
</div>
|
| 446 |
-
|
| 447 |
-
{/* Prompt Context B Inspector */}
|
| 448 |
-
<div className="inspector-section">
|
| 449 |
-
<div className="inspector-header" onClick={() => setShowPromptB(!showPromptB)}>
|
| 450 |
-
<span>
|
| 451 |
-
<Terminal size={12} />
|
| 452 |
-
Prompt Context Context (Flow B)
|
| 453 |
-
</span>
|
| 454 |
-
{showPromptB ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
| 455 |
-
</div>
|
| 456 |
-
{showPromptB && (
|
| 457 |
-
<div className="inspector-body">
|
| 458 |
-
{result.prompt_context_b}
|
| 459 |
-
</div>
|
| 460 |
-
)}
|
| 461 |
-
</div>
|
| 462 |
-
</div>
|
| 463 |
-
)}
|
| 464 |
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
<div className="column-header">
|
| 469 |
-
<div className="column-title-wrapper">
|
| 470 |
-
<h2><Brain size={18} color="var(--color-happy)" /> Output C</h2>
|
| 471 |
-
<p>RoBERTa Raw Scores + Gemini</p>
|
| 472 |
-
</div>
|
| 473 |
-
<div className="column-meta">
|
| 474 |
-
<span className="sentiment-badge happy">
|
| 475 |
-
Distribution
|
| 476 |
-
</span>
|
| 477 |
-
<div className="latency-badge" title="Response Latency & Tokens">
|
| 478 |
-
<Clock size={12} style={{ display: 'inline', marginRight: '4px', verticalAlign: 'middle' }} />
|
| 479 |
-
{result.latency_c}s
|
| 480 |
-
{result.tokens_c !== undefined && result.tokens_c !== null && (
|
| 481 |
-
<>
|
| 482 |
-
<span style={{ margin: '0 6px', opacity: 0.4 }}>|</span>
|
| 483 |
-
<span>{result.tokens_c} tokens</span>
|
| 484 |
-
</>
|
| 485 |
-
)}
|
| 486 |
</div>
|
| 487 |
-
</div>
|
| 488 |
-
</div>
|
| 489 |
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
)}
|
| 505 |
</div>
|
| 506 |
)}
|
| 507 |
</div>
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
<div
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
<
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
{/* Column D: DistilRoBERTa Mapped -> Gemini */}
|
| 531 |
-
{result.response_d && (
|
| 532 |
-
<div className="column-card">
|
| 533 |
-
<div className="column-header">
|
| 534 |
-
<div className="column-title-wrapper">
|
| 535 |
-
<h2><Brain size={18} color="var(--secondary)" /> Output D</h2>
|
| 536 |
-
<p>DistilRoBERTa Classifier + Gemini</p>
|
| 537 |
-
</div>
|
| 538 |
-
<div className="column-meta">
|
| 539 |
-
<span className={`sentiment-badge ${result.sentiment_d.mapped_sentiment}`}>
|
| 540 |
-
{result.sentiment_d.mapped_sentiment}
|
| 541 |
-
</span>
|
| 542 |
-
<div className="latency-badge" title="Response Latency & Tokens">
|
| 543 |
-
<Clock size={12} style={{ display: 'inline', marginRight: '4px', verticalAlign: 'middle' }} />
|
| 544 |
-
{result.sentiment_d.latency_d || result.latency_d}s
|
| 545 |
-
{result.tokens_d !== undefined && result.tokens_d !== null && (
|
| 546 |
-
<>
|
| 547 |
-
<span style={{ margin: '0 6px', opacity: 0.4 }}>|</span>
|
| 548 |
-
<span>{result.tokens_d} tokens</span>
|
| 549 |
-
</>
|
| 550 |
-
)}
|
| 551 |
</div>
|
| 552 |
</div>
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
onClick={() => setShowDistD(!showDistD)}
|
| 559 |
-
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }}
|
| 560 |
-
>
|
| 561 |
-
<h3 style={{ margin: 0 }}>DistilRoBERTa Mapped Category</h3>
|
| 562 |
-
{showDistD ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
| 563 |
</div>
|
| 564 |
-
{showDistD && (
|
| 565 |
-
<div style={{ marginTop: '0.5rem' }}>
|
| 566 |
-
<p>Mapped top-scoring emotions into high-level state: <strong>{result.sentiment_d.mapped_sentiment}</strong></p>
|
| 567 |
-
<EmotionChart rawEmotions={result.sentiment_d.raw_emotions} modelName="DistilRoBERTa" />
|
| 568 |
-
</div>
|
| 569 |
-
)}
|
| 570 |
-
</div>
|
| 571 |
-
|
| 572 |
-
<ChatThread history={historyD} title="Tutor D" />
|
| 573 |
-
</div>
|
| 574 |
-
|
| 575 |
-
{/* Prompt Context D Inspector */}
|
| 576 |
-
<div className="inspector-section">
|
| 577 |
-
<div className="inspector-header" onClick={() => setShowPromptD(!showPromptD)}>
|
| 578 |
-
<span>
|
| 579 |
-
<Terminal size={12} />
|
| 580 |
-
Prompt Context Context (Flow D)
|
| 581 |
-
</span>
|
| 582 |
-
{showPromptD ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
| 583 |
</div>
|
| 584 |
-
{showPromptD && (
|
| 585 |
-
<div className="inspector-body">
|
| 586 |
-
{result.prompt_context_d}
|
| 587 |
-
</div>
|
| 588 |
-
)}
|
| 589 |
</div>
|
| 590 |
-
|
| 591 |
-
|
| 592 |
|
| 593 |
-
{/* Follow-up
|
| 594 |
-
|
| 595 |
-
<div
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
</div>
|
| 604 |
-
<form
|
| 605 |
-
onSubmit={(e) => {
|
| 606 |
-
e.preventDefault();
|
| 607 |
-
const inputEl = e.target.elements.followUpText;
|
| 608 |
-
const val = inputEl.value.trim();
|
| 609 |
-
if (val && !loading) {
|
| 610 |
-
onSubmitFollowUp(val);
|
| 611 |
-
inputEl.value = '';
|
| 612 |
-
}
|
| 613 |
}}
|
| 614 |
-
className="query-input-wrapper"
|
| 615 |
>
|
| 616 |
-
<
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
}
|
| 626 |
}}
|
| 627 |
-
|
| 628 |
-
<button
|
| 629 |
-
type="submit"
|
| 630 |
-
className="send-button"
|
| 631 |
-
disabled={loading}
|
| 632 |
>
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 637 |
</div>
|
| 638 |
-
</>
|
| 639 |
);
|
| 640 |
}
|
|
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
+
import { Sparkles, Clock, Terminal, ChevronDown, ChevronUp, AlertCircle, Brain, Cpu } from 'lucide-react';
|
|
|
|
| 3 |
|
| 4 |
// Render LaTeX and text mixed together
|
| 5 |
function RenderLatex({ text }) {
|
|
|
|
| 31 |
{inlineParts.map((ip, ipIdx) => {
|
| 32 |
if (ip.startsWith('$') && ip.endsWith('$')) {
|
| 33 |
const formula = ip.slice(1, -1);
|
|
|
|
| 34 |
if (formula.trim()) {
|
| 35 |
try {
|
| 36 |
if (window.katex) {
|
|
|
|
| 44 |
return <span key={ipIdx}>{ip}</span>;
|
| 45 |
}
|
| 46 |
|
|
|
|
| 47 |
return <span key={ipIdx} dangerouslySetInnerHTML={{ __html: formatInline(ip) }} />;
|
| 48 |
})}
|
| 49 |
</React.Fragment>
|
|
|
|
| 53 |
);
|
| 54 |
}
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
// A simple local Markdown parser that converts basic markdown elements to safe HTML
|
| 57 |
function SafeMarkdown({ content }) {
|
| 58 |
if (!content) return null;
|
| 59 |
|
|
|
|
| 60 |
const parts = content.split(/(```[\s\S]*?```)/g);
|
| 61 |
|
| 62 |
return (
|
| 63 |
<div className="chat-response-content">
|
| 64 |
{parts.map((part, index) => {
|
| 65 |
if (part.startsWith('```') && part.endsWith('```')) {
|
| 66 |
+
const code = part.slice(3, -3).replace(/^\w+\n/, '');
|
|
|
|
| 67 |
return (
|
| 68 |
<pre key={index}>
|
| 69 |
<code>{code}</code>
|
|
|
|
| 71 |
);
|
| 72 |
}
|
| 73 |
|
|
|
|
| 74 |
const formatted = part
|
| 75 |
.split('\n\n')
|
| 76 |
.map((para, paraIdx) => {
|
|
|
|
| 82 |
return (
|
| 83 |
<ul key={paraIdx} style={{ marginBottom: '1rem', paddingLeft: '1.5rem' }}>
|
| 84 |
{items.map((item, itemIdx) => {
|
|
|
|
| 85 |
let cleanItem = item;
|
| 86 |
if (itemIdx === 0) {
|
| 87 |
cleanItem = item.replace(/^\s*[-*]\s+/, '');
|
|
|
|
| 97 |
);
|
| 98 |
}
|
| 99 |
|
|
|
|
| 100 |
return (
|
| 101 |
<p key={paraIdx}>
|
| 102 |
<RenderLatex text={para} />
|
|
|
|
| 113 |
// Format bold (**), italics (*), and inline code (`)
|
| 114 |
function formatInline(text) {
|
| 115 |
return text
|
|
|
|
| 116 |
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
|
|
| 117 |
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
|
|
|
| 118 |
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
|
|
| 119 |
.replace(/\n/g, '<br />');
|
| 120 |
}
|
| 121 |
|
| 122 |
export default function ChatWindow({
|
| 123 |
+
history = [],
|
| 124 |
loading,
|
| 125 |
error,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
onSubmitFollowUp
|
| 127 |
}) {
|
| 128 |
+
const [openInspectors, setOpenInspectors] = useState({});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
+
const toggleInspector = (idx) => {
|
| 131 |
+
setOpenInspectors(prev => ({
|
| 132 |
+
...prev,
|
| 133 |
+
[idx]: !prev[idx]
|
| 134 |
+
}));
|
| 135 |
+
};
|
| 136 |
|
| 137 |
+
// Render loading skeleton
|
| 138 |
+
if (loading && history.length === 0) {
|
| 139 |
return (
|
| 140 |
+
<div className="single-column-chat">
|
| 141 |
+
<div className="column-card">
|
| 142 |
+
<div className="column-header">
|
| 143 |
+
<div className="column-title-wrapper">
|
| 144 |
+
<h2><Cpu size={18} color="var(--primary)" /> Socratic Tutor</h2>
|
| 145 |
+
<p>Analyzing Sentiment...</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
</div>
|
| 147 |
+
<div className="latency-badge">--s</div>
|
| 148 |
</div>
|
| 149 |
+
<div className="column-body">
|
| 150 |
+
<div className="skeleton-box" />
|
| 151 |
+
<div className="skeleton-wrapper">
|
| 152 |
+
<div className="skeleton-line long" />
|
| 153 |
+
<div className="skeleton-line medium" />
|
| 154 |
+
<div className="skeleton-line long" />
|
| 155 |
+
<div className="skeleton-line short" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
</div>
|
| 157 |
</div>
|
| 158 |
+
</div>
|
| 159 |
</div>
|
| 160 |
);
|
| 161 |
}
|
|
|
|
| 171 |
display: 'flex',
|
| 172 |
alignItems: 'flex-start',
|
| 173 |
gap: '1rem',
|
| 174 |
+
color: 'var(--color-frustrated)',
|
| 175 |
+
marginBottom: '1rem'
|
| 176 |
}}>
|
| 177 |
<AlertCircle size={24} style={{ flexShrink: 0 }} />
|
| 178 |
<div>
|
|
|
|
| 184 |
}
|
| 185 |
|
| 186 |
// Render empty state
|
| 187 |
+
if (history.length === 0) {
|
| 188 |
return (
|
| 189 |
<div className="empty-state">
|
| 190 |
+
<Brain size={48} className="empty-state-icon" style={{ color: 'var(--primary)' }} />
|
| 191 |
+
<h3>Welcome to Socratic Sentiment Tutor</h3>
|
| 192 |
+
<p>Ask any question about math, science, or programming. The Socratic tutor will detect your mood and guide you towards the answers without giving them away directly.</p>
|
| 193 |
</div>
|
| 194 |
);
|
| 195 |
}
|
| 196 |
|
| 197 |
return (
|
| 198 |
+
<div className="single-column-chat" style={{ display: 'flex', flexDirection: 'column', gap: '1rem', width: '100%' }}>
|
| 199 |
+
|
| 200 |
+
{/* Scrollable Conversation Thread */}
|
| 201 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.2rem', width: '100%' }}>
|
| 202 |
+
{history.map((msg, idx) => {
|
| 203 |
+
const isUser = msg.role === 'user';
|
| 204 |
+
|
| 205 |
+
if (isUser) {
|
| 206 |
+
return (
|
| 207 |
+
<div
|
| 208 |
+
key={idx}
|
| 209 |
+
style={{
|
| 210 |
+
alignSelf: 'flex-end',
|
| 211 |
+
background: 'linear-gradient(135deg, var(--primary-dark), var(--primary))',
|
| 212 |
+
border: '1px solid rgba(255, 255, 255, 0.1)',
|
| 213 |
+
borderRadius: '16px 16px 4px 16px',
|
| 214 |
+
padding: '0.9rem 1.25rem',
|
| 215 |
+
maxWidth: '85%',
|
| 216 |
+
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.15)',
|
| 217 |
+
}}
|
| 218 |
+
>
|
| 219 |
+
<span style={{
|
| 220 |
+
fontSize: '0.65rem',
|
| 221 |
+
fontWeight: 800,
|
| 222 |
+
textTransform: 'uppercase',
|
| 223 |
+
color: 'rgba(255, 255, 255, 0.7)',
|
| 224 |
+
display: 'block',
|
| 225 |
+
marginBottom: '0.25rem',
|
| 226 |
+
letterSpacing: '0.5px'
|
| 227 |
+
}}>
|
| 228 |
+
You
|
| 229 |
+
</span>
|
| 230 |
+
<p style={{ margin: 0, fontSize: '0.975rem', lineHeight: 1.5, color: '#fff' }}>
|
| 231 |
+
{msg.content}
|
| 232 |
+
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
</div>
|
| 234 |
+
);
|
| 235 |
+
}
|
|
|
|
|
|
|
| 236 |
|
| 237 |
+
// Assistant / Tutor Bubble
|
| 238 |
+
return (
|
| 239 |
+
<div
|
| 240 |
+
key={idx}
|
| 241 |
+
className="column-card"
|
| 242 |
+
style={{
|
| 243 |
+
alignSelf: 'flex-start',
|
| 244 |
+
width: '100%',
|
| 245 |
+
maxWidth: '90%',
|
| 246 |
+
margin: 0,
|
| 247 |
+
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.12)',
|
| 248 |
+
}}
|
| 249 |
+
>
|
| 250 |
+
{/* Card Header with sentiment state & metrics */}
|
| 251 |
+
<div className="column-header" style={{ padding: '0.8rem 1.25rem', borderBottom: '1px solid var(--border-color)' }}>
|
| 252 |
+
<div className="column-title-wrapper">
|
| 253 |
+
<h3 style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.95rem', fontWeight: 700, margin: 0 }}>
|
| 254 |
+
<Sparkles size={15} color="var(--secondary)" />
|
| 255 |
+
Socratic Tutor
|
| 256 |
+
</h3>
|
| 257 |
+
</div>
|
| 258 |
+
|
| 259 |
+
{msg.sentiment && (
|
| 260 |
+
<div className="column-meta" style={{ gap: '0.6rem' }}>
|
| 261 |
+
<div className="latency-badge" title="Response Latency & Tokens">
|
| 262 |
+
<Clock size={12} style={{ display: 'inline', marginRight: '4px', verticalAlign: 'middle' }} />
|
| 263 |
+
{msg.latency}s
|
| 264 |
+
{msg.tokens !== undefined && (
|
| 265 |
+
<>
|
| 266 |
+
<span style={{ margin: '0 4px', opacity: 0.3 }}>|</span>
|
| 267 |
+
<span>{msg.tokens}t</span>
|
| 268 |
+
</>
|
| 269 |
+
)}
|
| 270 |
+
{msg.cost !== undefined && (
|
| 271 |
+
<>
|
| 272 |
+
<span style={{ margin: '0 4px', opacity: 0.3 }}>|</span>
|
| 273 |
+
<span>${msg.cost.toFixed(5)}</span>
|
| 274 |
+
</>
|
| 275 |
+
)}
|
| 276 |
+
</div>
|
| 277 |
+
</div>
|
| 278 |
)}
|
| 279 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
|
| 281 |
+
{/* Chat Bubble Body */}
|
| 282 |
+
<div className="column-body" style={{ padding: '1.25rem', gap: '1rem' }}>
|
| 283 |
+
<SafeMarkdown content={msg.content} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
</div>
|
|
|
|
|
|
|
| 285 |
|
| 286 |
+
{/* Prompt Context Inspector Toggle */}
|
| 287 |
+
{msg.prompt_context && (
|
| 288 |
+
<div className="inspector-section" style={{ borderTop: '1px solid var(--border-color)', borderRadius: '0 0 12px 12px' }}>
|
| 289 |
+
<div
|
| 290 |
+
className="inspector-header"
|
| 291 |
+
onClick={() => toggleInspector(idx)}
|
| 292 |
+
style={{ padding: '0.6rem 1.25rem', fontSize: '0.8rem', background: 'rgba(255,255,255,0.01)' }}
|
| 293 |
+
>
|
| 294 |
+
<span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
| 295 |
+
<Terminal size={12} />
|
| 296 |
+
View Socratic Context Inspector
|
| 297 |
+
</span>
|
| 298 |
+
{openInspectors[idx] ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
| 299 |
+
</div>
|
| 300 |
+
{openInspectors[idx] && (
|
| 301 |
+
<div className="inspector-body" style={{ fontSize: '0.8rem', whiteSpace: 'pre-wrap', borderTop: '1px solid var(--border-color)', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
| 302 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
| 303 |
+
<strong>Detected Student Sentiment:</strong>
|
| 304 |
+
<span className={`sentiment-badge ${msg.sentiment}`} style={{ fontSize: '0.75rem', padding: '0.2rem 0.5rem', borderRadius: '4px' }}>
|
| 305 |
+
{msg.sentiment.replace(/_/g, ' ')}
|
| 306 |
+
</span>
|
| 307 |
+
</div>
|
| 308 |
+
<div>
|
| 309 |
+
<strong>Prompt Context:</strong>
|
| 310 |
+
<div style={{ marginTop: '0.25rem', opacity: 0.8 }}>
|
| 311 |
+
{msg.prompt_context}
|
| 312 |
+
</div>
|
| 313 |
+
</div>
|
| 314 |
+
</div>
|
| 315 |
)}
|
| 316 |
</div>
|
| 317 |
)}
|
| 318 |
</div>
|
| 319 |
+
);
|
| 320 |
+
})}
|
| 321 |
+
|
| 322 |
+
{/* Loading Bubble when generating */}
|
| 323 |
+
{loading && (
|
| 324 |
+
<div
|
| 325 |
+
className="column-card"
|
| 326 |
+
style={{
|
| 327 |
+
alignSelf: 'flex-start',
|
| 328 |
+
width: '100%',
|
| 329 |
+
maxWidth: '90%',
|
| 330 |
+
margin: 0,
|
| 331 |
+
opacity: 0.7
|
| 332 |
+
}}
|
| 333 |
+
>
|
| 334 |
+
<div className="column-header" style={{ padding: '0.8rem 1.25rem' }}>
|
| 335 |
+
<div className="column-title-wrapper">
|
| 336 |
+
<h3 style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.95rem', fontWeight: 700, margin: 0 }}>
|
| 337 |
+
<Cpu size={15} color="var(--primary)" />
|
| 338 |
+
Socratic Tutor thinking...
|
| 339 |
+
</h3>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
</div>
|
| 341 |
</div>
|
| 342 |
+
<div className="column-body" style={{ padding: '1.25rem' }}>
|
| 343 |
+
<div className="skeleton-wrapper">
|
| 344 |
+
<div className="skeleton-line long" />
|
| 345 |
+
<div className="skeleton-line medium" />
|
| 346 |
+
<div className="skeleton-line short" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
</div>
|
| 350 |
+
)}
|
| 351 |
+
</div>
|
| 352 |
|
| 353 |
+
{/* Follow-up / Reply Area */}
|
| 354 |
+
{history.length > 0 && !loading && (
|
| 355 |
+
<div
|
| 356 |
+
className="query-card"
|
| 357 |
+
style={{
|
| 358 |
+
marginTop: '1.5rem',
|
| 359 |
+
background: 'rgba(99, 102, 241, 0.03)',
|
| 360 |
+
borderColor: 'var(--primary-glow)',
|
| 361 |
+
boxShadow: 'none',
|
| 362 |
+
padding: '1.25rem'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
}}
|
|
|
|
| 364 |
>
|
| 365 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem', marginBottom: '0.8rem' }}>
|
| 366 |
+
<h3 style={{ fontSize: '0.95rem', fontWeight: 800, display: 'flex', alignItems: 'center', gap: '0.4rem', margin: 0 }}>
|
| 367 |
+
<Brain size={16} color="var(--primary)" />
|
| 368 |
+
Socratic Dialogue
|
| 369 |
+
</h3>
|
| 370 |
+
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', margin: 0 }}>
|
| 371 |
+
Reply to the Socratic tutor's guide question to continue exploring the concept.
|
| 372 |
+
</p>
|
| 373 |
+
</div>
|
| 374 |
+
<form
|
| 375 |
+
onSubmit={(e) => {
|
| 376 |
+
e.preventDefault();
|
| 377 |
+
const inputEl = e.target.elements.followUpText;
|
| 378 |
+
const val = inputEl.value.trim();
|
| 379 |
+
if (val) {
|
| 380 |
+
onSubmitFollowUp(val);
|
| 381 |
+
inputEl.value = '';
|
| 382 |
}
|
| 383 |
}}
|
| 384 |
+
className="query-input-wrapper"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
>
|
| 386 |
+
<textarea
|
| 387 |
+
name="followUpText"
|
| 388 |
+
placeholder="Provide your Socratic response..."
|
| 389 |
+
className="query-textarea"
|
| 390 |
+
style={{ minHeight: '60px' }}
|
| 391 |
+
onKeyDown={(e) => {
|
| 392 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 393 |
+
e.preventDefault();
|
| 394 |
+
e.target.form.requestSubmit();
|
| 395 |
+
}
|
| 396 |
+
}}
|
| 397 |
+
/>
|
| 398 |
+
<button
|
| 399 |
+
type="submit"
|
| 400 |
+
className="send-button"
|
| 401 |
+
style={{ padding: '0.6rem 1.25rem' }}
|
| 402 |
+
>
|
| 403 |
+
Reply
|
| 404 |
+
</button>
|
| 405 |
+
</form>
|
| 406 |
+
</div>
|
| 407 |
+
)}
|
| 408 |
</div>
|
|
|
|
| 409 |
);
|
| 410 |
}
|
frontend/src/components/EmotionChart.jsx
DELETED
|
@@ -1,97 +0,0 @@
|
|
| 1 |
-
import React, { useState } from 'react';
|
| 2 |
-
import { ChevronDown, ChevronUp, BarChart2 } from 'lucide-react';
|
| 3 |
-
|
| 4 |
-
export default function EmotionChart({ rawEmotions, modelName = "RoBERTa" }) {
|
| 5 |
-
const [showAll, setShowAll] = useState(false);
|
| 6 |
-
|
| 7 |
-
if (!rawEmotions || rawEmotions.length === 0) {
|
| 8 |
-
return <p style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>No emotional details available.</p>;
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
// Sort by score descending
|
| 12 |
-
const sorted = [...rawEmotions].sort((a, b) => b.score - a.score);
|
| 13 |
-
|
| 14 |
-
// Decide how many to display
|
| 15 |
-
const displayedEmotions = showAll ? sorted : sorted.slice(0, 5);
|
| 16 |
-
|
| 17 |
-
// Group emotions for coloring
|
| 18 |
-
const happySet = new Set(["joy", "amusement", "excitement", "pride", "optimism", "relief", "love", "admiration", "gratitude", "approval"]);
|
| 19 |
-
const sadSet = new Set(["sadness", "grief", "remorse", "disappointment", "embarrassment"]);
|
| 20 |
-
const frustratedSet = new Set(["anger", "annoyance", "disapproval", "disgust", "fear", "nervousness"]);
|
| 21 |
-
|
| 22 |
-
const getBarColor = (label) => {
|
| 23 |
-
if (happySet.has(label)) return 'var(--color-happy)';
|
| 24 |
-
if (sadSet.has(label)) return 'var(--color-sad)';
|
| 25 |
-
if (frustratedSet.has(label)) return 'var(--color-frustrated)';
|
| 26 |
-
return 'var(--color-neutral)';
|
| 27 |
-
};
|
| 28 |
-
|
| 29 |
-
return (
|
| 30 |
-
<div className="emotion-chart-container">
|
| 31 |
-
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
| 32 |
-
<h4 style={{
|
| 33 |
-
fontSize: '0.8rem',
|
| 34 |
-
color: 'var(--text-muted)',
|
| 35 |
-
textTransform: 'uppercase',
|
| 36 |
-
letterSpacing: '0.5px',
|
| 37 |
-
display: 'flex',
|
| 38 |
-
alignItems: 'center',
|
| 39 |
-
gap: '0.3rem'
|
| 40 |
-
}}>
|
| 41 |
-
<BarChart2 size={12} />
|
| 42 |
-
Local {modelName} Model Emotion Breakdown
|
| 43 |
-
</h4>
|
| 44 |
-
<button
|
| 45 |
-
onClick={() => setShowAll(!showAll)}
|
| 46 |
-
style={{
|
| 47 |
-
background: 'none',
|
| 48 |
-
border: 'none',
|
| 49 |
-
color: 'var(--primary)',
|
| 50 |
-
fontSize: '0.75rem',
|
| 51 |
-
cursor: 'pointer',
|
| 52 |
-
display: 'flex',
|
| 53 |
-
alignItems: 'center',
|
| 54 |
-
gap: '0.2rem',
|
| 55 |
-
fontWeight: 600
|
| 56 |
-
}}
|
| 57 |
-
>
|
| 58 |
-
{showAll ? (
|
| 59 |
-
<>
|
| 60 |
-
Show Top 5 <ChevronUp size={12} />
|
| 61 |
-
</>
|
| 62 |
-
) : (
|
| 63 |
-
<>
|
| 64 |
-
Show All 28 <ChevronDown size={12} />
|
| 65 |
-
</>
|
| 66 |
-
)}
|
| 67 |
-
</button>
|
| 68 |
-
</div>
|
| 69 |
-
|
| 70 |
-
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
| 71 |
-
{displayedEmotions.map((item, idx) => {
|
| 72 |
-
const percentage = (item.score * 100).toFixed(1);
|
| 73 |
-
return (
|
| 74 |
-
<div key={idx} className="emotion-bar-row">
|
| 75 |
-
<div className="emotion-bar-label" title={item.label}>
|
| 76 |
-
{item.label}
|
| 77 |
-
</div>
|
| 78 |
-
<div className="emotion-bar-track">
|
| 79 |
-
<div
|
| 80 |
-
className="emotion-bar-fill"
|
| 81 |
-
style={{
|
| 82 |
-
width: `${percentage}%`,
|
| 83 |
-
backgroundColor: getBarColor(item.label),
|
| 84 |
-
boxShadow: `0 0 6px ${getBarColor(item.label)}33`
|
| 85 |
-
}}
|
| 86 |
-
/>
|
| 87 |
-
</div>
|
| 88 |
-
<div className="emotion-bar-value">
|
| 89 |
-
{percentage}%
|
| 90 |
-
</div>
|
| 91 |
-
</div>
|
| 92 |
-
);
|
| 93 |
-
})}
|
| 94 |
-
</div>
|
| 95 |
-
</div>
|
| 96 |
-
);
|
| 97 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/Settings.jsx
CHANGED
|
@@ -1,34 +1,19 @@
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
-
import { Eye, EyeOff, Key,
|
| 3 |
|
| 4 |
export default function Settings({
|
| 5 |
apiKey,
|
| 6 |
setApiKey,
|
| 7 |
-
systemPrompt,
|
| 8 |
-
setSystemPrompt,
|
| 9 |
backendStatus,
|
| 10 |
checkBackendStatus
|
| 11 |
}) {
|
| 12 |
const [showKey, setShowKey] = useState(false);
|
| 13 |
|
| 14 |
-
const getModelStatusText = (status, error) => {
|
| 15 |
-
if (status === 'loading') return 'Downloading/Loading...';
|
| 16 |
-
if (status === 'ready') return 'Ready';
|
| 17 |
-
if (status === 'failed') return `Error: ${error || 'Failed to load model'}`;
|
| 18 |
-
return 'Connecting...';
|
| 19 |
-
};
|
| 20 |
-
|
| 21 |
-
const getModelStatusClass = (status) => {
|
| 22 |
-
if (status === 'ready') return 'ready';
|
| 23 |
-
if (status === 'failed') return 'failed';
|
| 24 |
-
return 'loading';
|
| 25 |
-
};
|
| 26 |
-
|
| 27 |
return (
|
| 28 |
<div className="settings-drawer">
|
| 29 |
<div className="settings-group">
|
| 30 |
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
| 31 |
-
<label style={{ display: 'flex',
|
| 32 |
<Key size={16} color="var(--primary)" />
|
| 33 |
Gemini API Key
|
| 34 |
</label>
|
|
@@ -63,19 +48,6 @@ export default function Settings({
|
|
| 63 |
</div>
|
| 64 |
</div>
|
| 65 |
|
| 66 |
-
<div className="settings-group">
|
| 67 |
-
<label style={{ display: 'flex', alignStatus: 'center', gap: '0.5rem' }}>
|
| 68 |
-
<Sparkles size={16} color="var(--secondary)" />
|
| 69 |
-
Chatbot System Instruction
|
| 70 |
-
</label>
|
| 71 |
-
<textarea
|
| 72 |
-
value={systemPrompt}
|
| 73 |
-
onChange={(e) => setSystemPrompt(e.target.value)}
|
| 74 |
-
placeholder="You are a helpful educational AI assistant..."
|
| 75 |
-
rows={3}
|
| 76 |
-
/>
|
| 77 |
-
</div>
|
| 78 |
-
|
| 79 |
<div style={{
|
| 80 |
display: 'flex',
|
| 81 |
justifyContent: 'space-between',
|
|
@@ -85,17 +57,19 @@ export default function Settings({
|
|
| 85 |
marginTop: '0.5rem'
|
| 86 |
}}>
|
| 87 |
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
| 88 |
-
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
| 89 |
<span className={`status-badge`} style={{ padding: '0.2rem 0.6rem' }}>
|
| 90 |
-
<span className={`status-dot ${
|
| 91 |
-
<span style={{ marginLeft: '4px', fontSize: '0.8rem' }}>
|
|
|
|
|
|
|
| 92 |
</span>
|
| 93 |
</div>
|
| 94 |
<button
|
| 95 |
onClick={checkBackendStatus}
|
| 96 |
className="settings-toggle-btn"
|
| 97 |
style={{ padding: '0.35rem 0.75rem' }}
|
| 98 |
-
title="Refresh
|
| 99 |
>
|
| 100 |
<RefreshCw size={14} />
|
| 101 |
Refresh
|
|
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
+
import { Eye, EyeOff, Key, RefreshCw } from 'lucide-react';
|
| 3 |
|
| 4 |
export default function Settings({
|
| 5 |
apiKey,
|
| 6 |
setApiKey,
|
|
|
|
|
|
|
| 7 |
backendStatus,
|
| 8 |
checkBackendStatus
|
| 9 |
}) {
|
| 10 |
const [showKey, setShowKey] = useState(false);
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
return (
|
| 13 |
<div className="settings-drawer">
|
| 14 |
<div className="settings-group">
|
| 15 |
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
| 16 |
+
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
| 17 |
<Key size={16} color="var(--primary)" />
|
| 18 |
Gemini API Key
|
| 19 |
</label>
|
|
|
|
| 48 |
</div>
|
| 49 |
</div>
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
<div style={{
|
| 52 |
display: 'flex',
|
| 53 |
justifyContent: 'space-between',
|
|
|
|
| 57 |
marginTop: '0.5rem'
|
| 58 |
}}>
|
| 59 |
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
| 60 |
+
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Gemini Connection:</span>
|
| 61 |
<span className={`status-badge`} style={{ padding: '0.2rem 0.6rem' }}>
|
| 62 |
+
<span className={`status-dot ${backendStatus.gemini_api_key_configured || apiKey ? 'ready' : 'loading'}`}></span>
|
| 63 |
+
<span style={{ marginLeft: '4px', fontSize: '0.8rem' }}>
|
| 64 |
+
{backendStatus.gemini_api_key_configured || apiKey ? 'Configured' : 'Missing API Key'}
|
| 65 |
+
</span>
|
| 66 |
</span>
|
| 67 |
</div>
|
| 68 |
<button
|
| 69 |
onClick={checkBackendStatus}
|
| 70 |
className="settings-toggle-btn"
|
| 71 |
style={{ padding: '0.35rem 0.75rem' }}
|
| 72 |
+
title="Refresh connection status"
|
| 73 |
>
|
| 74 |
<RefreshCw size={14} />
|
| 75 |
Refresh
|
frontend/src/components/VoiceSessionModal.jsx
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState, useRef } from 'react';
|
| 2 |
+
import { Mic, X, MicOff, AlertCircle } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
export default function VoiceSessionModal({ isOpen, onClose, apiKey }) {
|
| 5 |
+
const [status, setStatus] = useState('connecting'); // 'connecting', 'listening', 'speaking', 'error'
|
| 6 |
+
const [errorMessage, setErrorMessage] = useState('');
|
| 7 |
+
const [isMuted, setIsMuted] = useState(false);
|
| 8 |
+
|
| 9 |
+
const wsRef = useRef(null);
|
| 10 |
+
const audioContextRef = useRef(null);
|
| 11 |
+
const playbackContextRef = useRef(null);
|
| 12 |
+
const streamRef = useRef(null);
|
| 13 |
+
const processorRef = useRef(null);
|
| 14 |
+
const nextPlayTimeRef = useRef(0);
|
| 15 |
+
const textTranscriptRef = useRef('');
|
| 16 |
+
const [transcript, setTranscript] = useState('');
|
| 17 |
+
|
| 18 |
+
// Helper: Convert ArrayBuffer to Base64
|
| 19 |
+
const base64ArrayBuffer = (arrayBuffer) => {
|
| 20 |
+
let binary = '';
|
| 21 |
+
const bytes = new Uint8Array(arrayBuffer);
|
| 22 |
+
const len = bytes.byteLength;
|
| 23 |
+
for (let i = 0; i < len; i++) {
|
| 24 |
+
binary += String.fromCharCode(bytes[i]);
|
| 25 |
+
}
|
| 26 |
+
return window.btoa(binary);
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
useEffect(() => {
|
| 30 |
+
if (!isOpen) return;
|
| 31 |
+
|
| 32 |
+
setStatus('connecting');
|
| 33 |
+
setTranscript('');
|
| 34 |
+
textTranscriptRef.current = '';
|
| 35 |
+
|
| 36 |
+
// Determine WebSocket URL
|
| 37 |
+
const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
| 38 |
+
const host = window.location.host === 'localhost:5173' ? 'localhost:8000' : window.location.host;
|
| 39 |
+
const wsUrl = `${protocol}${host}/api/live-ws?api_key=${apiKey || ''}`;
|
| 40 |
+
|
| 41 |
+
// Establish WebSocket Connection
|
| 42 |
+
const ws = new WebSocket(wsUrl);
|
| 43 |
+
wsRef.current = ws;
|
| 44 |
+
|
| 45 |
+
ws.onopen = async () => {
|
| 46 |
+
try {
|
| 47 |
+
// Request Microphone access
|
| 48 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 49 |
+
streamRef.current = stream;
|
| 50 |
+
|
| 51 |
+
// Initialize Audio contexts
|
| 52 |
+
audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
| 53 |
+
playbackContextRef.current = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 24000 });
|
| 54 |
+
nextPlayTimeRef.current = playbackContextRef.current.currentTime;
|
| 55 |
+
|
| 56 |
+
// Capture Mic Input
|
| 57 |
+
const source = audioContextRef.current.createMediaStreamSource(stream);
|
| 58 |
+
const processor = audioContextRef.current.createScriptProcessor(2048, 1, 1);
|
| 59 |
+
processorRef.current = processor;
|
| 60 |
+
|
| 61 |
+
processor.onaudioprocess = (e) => {
|
| 62 |
+
if (isMuted) return;
|
| 63 |
+
|
| 64 |
+
const inputData = e.inputBuffer.getChannelData(0);
|
| 65 |
+
// Convert Float32 to Int16 PCM
|
| 66 |
+
const pcmData = new Int16Array(inputData.length);
|
| 67 |
+
for (let i = 0; i < inputData.length; i++) {
|
| 68 |
+
pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 0x7FFF;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// Send chunk to server
|
| 72 |
+
if (ws.readyState === WebSocket.OPEN) {
|
| 73 |
+
const base64Audio = base64ArrayBuffer(pcmData.buffer);
|
| 74 |
+
ws.send(JSON.stringify({ type: 'audio', data: base64Audio }));
|
| 75 |
+
}
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
source.connect(processor);
|
| 79 |
+
processor.connect(audioContextRef.current.destination);
|
| 80 |
+
|
| 81 |
+
setStatus('listening');
|
| 82 |
+
} catch (err) {
|
| 83 |
+
console.error("Microphone access failed:", err);
|
| 84 |
+
setStatus('error');
|
| 85 |
+
setErrorMessage("Microphone access is required for voice session. Please allow mic permissions.");
|
| 86 |
+
}
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
ws.onmessage = async (event) => {
|
| 90 |
+
const message = JSON.parse(event.data);
|
| 91 |
+
|
| 92 |
+
if (message.type === 'audio') {
|
| 93 |
+
setStatus('speaking');
|
| 94 |
+
|
| 95 |
+
// Decode base64 24kHz PCM back to Float32 for Web Audio playback
|
| 96 |
+
const binary = window.atob(message.data);
|
| 97 |
+
const bytes = new Uint8Array(binary.length);
|
| 98 |
+
for (let i = 0; i < binary.length; i++) {
|
| 99 |
+
bytes[i] = binary.charCodeAt(i);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
const int16Data = new Int16Array(bytes.buffer);
|
| 103 |
+
const float32Data = new Float32Array(int16Data.length);
|
| 104 |
+
for (let i = 0; i < int16Data.length; i++) {
|
| 105 |
+
float32Data[i] = int16Data[i] / 0x7FFF;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
const pContext = playbackContextRef.current;
|
| 109 |
+
if (pContext && pContext.state !== 'suspended') {
|
| 110 |
+
const audioBuffer = pContext.createBuffer(1, float32Data.length, 24000);
|
| 111 |
+
audioBuffer.getChannelData(0).set(float32Data);
|
| 112 |
+
|
| 113 |
+
const bufferSource = pContext.createBufferSource();
|
| 114 |
+
bufferSource.buffer = audioBuffer;
|
| 115 |
+
bufferSource.connect(pContext.destination);
|
| 116 |
+
|
| 117 |
+
// Gapless scheduling
|
| 118 |
+
const startTime = Math.max(pContext.currentTime, nextPlayTimeRef.current);
|
| 119 |
+
bufferSource.start(startTime);
|
| 120 |
+
nextPlayTimeRef.current = startTime + audioBuffer.duration;
|
| 121 |
+
}
|
| 122 |
+
} else if (message.type === 'text') {
|
| 123 |
+
// Handle incoming Socratic tutor speech transcription
|
| 124 |
+
textTranscriptRef.current += message.data;
|
| 125 |
+
setTranscript(textTranscriptRef.current);
|
| 126 |
+
} else if (message.type === 'turn_complete') {
|
| 127 |
+
setStatus('listening');
|
| 128 |
+
textTranscriptRef.current = '';
|
| 129 |
+
}
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
ws.onerror = (err) => {
|
| 133 |
+
console.error("WebSocket error:", err);
|
| 134 |
+
setStatus('error');
|
| 135 |
+
setErrorMessage("Lost connection to Gemini Live server.");
|
| 136 |
+
};
|
| 137 |
+
|
| 138 |
+
ws.onclose = () => {
|
| 139 |
+
setStatus('connecting');
|
| 140 |
+
};
|
| 141 |
+
|
| 142 |
+
return () => {
|
| 143 |
+
// Clean up connections and audio context on unmount
|
| 144 |
+
if (wsRef.current) wsRef.current.close();
|
| 145 |
+
if (processorRef.current) processorRef.current.disconnect();
|
| 146 |
+
if (streamRef.current) {
|
| 147 |
+
streamRef.current.getTracks().forEach(track => track.stop());
|
| 148 |
+
}
|
| 149 |
+
if (audioContextRef.current) audioContextRef.current.close();
|
| 150 |
+
if (playbackContextRef.current) playbackContextRef.current.close();
|
| 151 |
+
};
|
| 152 |
+
}, [isOpen, isMuted]);
|
| 153 |
+
|
| 154 |
+
if (!isOpen) return null;
|
| 155 |
+
|
| 156 |
+
return (
|
| 157 |
+
<div className="voice-modal-backdrop">
|
| 158 |
+
<div className="voice-modal-content">
|
| 159 |
+
<button className="voice-modal-close" onClick={onClose}>
|
| 160 |
+
<X size={20} />
|
| 161 |
+
</button>
|
| 162 |
+
|
| 163 |
+
<div className="voice-modal-header">
|
| 164 |
+
<h2>Socratic Voice Space</h2>
|
| 165 |
+
<p>Real-Time Bidirectional Dialogue</p>
|
| 166 |
+
</div>
|
| 167 |
+
|
| 168 |
+
{/* Pulse Animations and Mic States */}
|
| 169 |
+
<div className="voice-visualizer-container">
|
| 170 |
+
{status === 'connecting' && (
|
| 171 |
+
<div className="voice-status-indicator connecting">
|
| 172 |
+
<div className="pulse-circle" />
|
| 173 |
+
<span>Connecting to Gemini Live...</span>
|
| 174 |
+
</div>
|
| 175 |
+
)}
|
| 176 |
+
|
| 177 |
+
{status === 'listening' && (
|
| 178 |
+
<div className="voice-status-indicator listening">
|
| 179 |
+
<div className="pulse-circle active" />
|
| 180 |
+
<div className="pulse-ring ring-1" />
|
| 181 |
+
<div className="pulse-ring ring-2" />
|
| 182 |
+
<span style={{ color: 'var(--color-happy)' }}>Listening to you... Go ahead and speak!</span>
|
| 183 |
+
</div>
|
| 184 |
+
)}
|
| 185 |
+
|
| 186 |
+
{status === 'speaking' && (
|
| 187 |
+
<div className="voice-status-indicator speaking">
|
| 188 |
+
<div className="pulse-circle active speaking-pulse" />
|
| 189 |
+
<div className="pulse-ring ring-1 speaking-ring" />
|
| 190 |
+
<div className="pulse-ring ring-2 speaking-ring" />
|
| 191 |
+
<span style={{ color: 'var(--secondary)' }}>Socratic Tutor is speaking...</span>
|
| 192 |
+
</div>
|
| 193 |
+
)}
|
| 194 |
+
|
| 195 |
+
{status === 'error' && (
|
| 196 |
+
<div className="voice-status-indicator error" style={{ gap: '0.8rem' }}>
|
| 197 |
+
<AlertCircle size={40} color="var(--color-frustrated)" />
|
| 198 |
+
<p style={{ color: 'var(--color-frustrated)', fontSize: '0.9rem', textAlign: 'center', maxWidth: '300px' }}>
|
| 199 |
+
{errorMessage}
|
| 200 |
+
</p>
|
| 201 |
+
</div>
|
| 202 |
+
)}
|
| 203 |
+
</div>
|
| 204 |
+
|
| 205 |
+
{/* Transcription Display */}
|
| 206 |
+
{status === 'speaking' && transcript && (
|
| 207 |
+
<div className="voice-transcript-box">
|
| 208 |
+
<p>"{transcript}"</p>
|
| 209 |
+
</div>
|
| 210 |
+
)}
|
| 211 |
+
|
| 212 |
+
{/* Control Buttons */}
|
| 213 |
+
<div className="voice-modal-controls">
|
| 214 |
+
<button
|
| 215 |
+
className={`voice-control-btn ${isMuted ? 'muted' : ''}`}
|
| 216 |
+
onClick={() => setIsMuted(!isMuted)}
|
| 217 |
+
disabled={status === 'error' || status === 'connecting'}
|
| 218 |
+
title={isMuted ? "Unmute microphone" : "Mute microphone"}
|
| 219 |
+
>
|
| 220 |
+
{isMuted ? <MicOff size={22} /> : <Mic size={22} />}
|
| 221 |
+
<span>{isMuted ? "Muted" : "Active"}</span>
|
| 222 |
+
</button>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
);
|
| 227 |
+
}
|