Spaces:
Runtime error
Runtime error
memory, context from langchain; languges; personalization; features; improvement
Browse files- Dockerfile +1 -1
- app.py +201 -0
- backend.py +0 -160
- config.py +22 -0
- db.py +109 -0
- graph.py +58 -0
- guard.py +22 -0
- prompts.py +95 -0
- requirements.txt +6 -4
- retriever.py +59 -0
- static/app.js +427 -0
- static/index.html +111 -0
- static/style.css +554 -0
- stem.html +0 -796
Dockerfile
CHANGED
|
@@ -9,4 +9,4 @@ COPY . .
|
|
| 9 |
|
| 10 |
EXPOSE 7860
|
| 11 |
|
| 12 |
-
CMD ["uvicorn", "
|
|
|
|
| 9 |
|
| 10 |
EXPOSE 7860
|
| 11 |
|
| 12 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
app.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI application — routes for chat (SSE streaming), threads, analytics, and static files.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import json
|
| 7 |
+
import time
|
| 8 |
+
from fastapi import FastAPI, Request
|
| 9 |
+
from fastapi.responses import FileResponse, StreamingResponse, JSONResponse
|
| 10 |
+
from fastapi.staticfiles import StaticFiles
|
| 11 |
+
from pydantic import BaseModel
|
| 12 |
+
from langchain_core.messages import HumanMessage, AIMessage
|
| 13 |
+
|
| 14 |
+
import db
|
| 15 |
+
import guard
|
| 16 |
+
import retriever
|
| 17 |
+
from config import EXPORT_SECRET
|
| 18 |
+
from graph import chatbot
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
app = FastAPI()
|
| 22 |
+
|
| 23 |
+
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
| 24 |
+
ASSETS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# --- Request models ---
|
| 28 |
+
|
| 29 |
+
class ChatRequest(BaseModel):
|
| 30 |
+
message: str
|
| 31 |
+
thread_id: str
|
| 32 |
+
persona: str = "noob"
|
| 33 |
+
language: str = "auto"
|
| 34 |
+
username: str = ""
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class RenameRequest(BaseModel):
|
| 38 |
+
thread_id: str
|
| 39 |
+
title: str
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# --- SSE helpers ---
|
| 43 |
+
|
| 44 |
+
def sse_status(message: str) -> str:
|
| 45 |
+
return f"data: {json.dumps({'status': 'thinking', 'message': message})}\n\n"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def sse_token(token: str) -> str:
|
| 49 |
+
return f"data: {json.dumps({'token': token})}\n\n"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def sse_done() -> str:
|
| 53 |
+
return "data: [DONE]\n\n"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# --- Routes ---
|
| 57 |
+
|
| 58 |
+
@app.get("/")
|
| 59 |
+
def serve_index():
|
| 60 |
+
return FileResponse(os.path.join(STATIC_DIR, "index.html"))
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@app.get("/threads")
|
| 64 |
+
def get_threads():
|
| 65 |
+
return {"threads": db.get_threads()}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@app.get("/history/{thread_id}")
|
| 69 |
+
def get_history(thread_id: str):
|
| 70 |
+
config = {"configurable": {"thread_id": thread_id}}
|
| 71 |
+
state = chatbot.get_state(config)
|
| 72 |
+
messages = state.values.get("messages", [])
|
| 73 |
+
|
| 74 |
+
result = []
|
| 75 |
+
for msg in messages:
|
| 76 |
+
role = "user" if isinstance(msg, HumanMessage) else "assistant"
|
| 77 |
+
result.append({"role": role, "content": msg.content})
|
| 78 |
+
|
| 79 |
+
return {"messages": result}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@app.post("/chat")
|
| 83 |
+
def chat(request: ChatRequest, req: Request):
|
| 84 |
+
now = time.time()
|
| 85 |
+
user_agent = req.headers.get("user-agent", "")
|
| 86 |
+
db.upsert_thread(request.thread_id, request.message, now)
|
| 87 |
+
|
| 88 |
+
def stream():
|
| 89 |
+
# Step 1: Guard check
|
| 90 |
+
yield sse_status("Reading your question...")
|
| 91 |
+
is_ok, rejection = guard.check_input(request.message)
|
| 92 |
+
|
| 93 |
+
if not is_ok:
|
| 94 |
+
yield sse_token(rejection)
|
| 95 |
+
yield sse_done()
|
| 96 |
+
db.log_exchange(
|
| 97 |
+
thread_id=request.thread_id,
|
| 98 |
+
timestamp=now,
|
| 99 |
+
user_message=request.message,
|
| 100 |
+
assistant_response=rejection,
|
| 101 |
+
persona=request.persona,
|
| 102 |
+
language=request.language,
|
| 103 |
+
username=request.username,
|
| 104 |
+
user_agent=user_agent,
|
| 105 |
+
)
|
| 106 |
+
return
|
| 107 |
+
|
| 108 |
+
# Step 2: Retrieve context from Pinecone
|
| 109 |
+
yield sse_status("Searching NCERT chapters...")
|
| 110 |
+
results = retriever.search(request.message)
|
| 111 |
+
context = retriever.format_context(results)
|
| 112 |
+
|
| 113 |
+
# Step 3: Stream LLM response via LangGraph
|
| 114 |
+
yield sse_status("Preparing the explanation...")
|
| 115 |
+
config = {
|
| 116 |
+
"configurable": {
|
| 117 |
+
"thread_id": request.thread_id,
|
| 118 |
+
"persona": request.persona,
|
| 119 |
+
"context": context,
|
| 120 |
+
"language": request.language,
|
| 121 |
+
"username": request.username,
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
full_response = []
|
| 126 |
+
for chunk, _metadata in chatbot.stream(
|
| 127 |
+
{"messages": [HumanMessage(content=request.message)]},
|
| 128 |
+
config=config,
|
| 129 |
+
stream_mode="messages",
|
| 130 |
+
):
|
| 131 |
+
if isinstance(chunk, AIMessage) and chunk.content:
|
| 132 |
+
full_response.append(chunk.content)
|
| 133 |
+
yield sse_token(chunk.content)
|
| 134 |
+
|
| 135 |
+
yield sse_done()
|
| 136 |
+
|
| 137 |
+
# Step 4: Log the complete exchange for analytics
|
| 138 |
+
sources = [
|
| 139 |
+
{"subject": r["subject"], "class": r["class_level"], "chapter": r["chapter"]}
|
| 140 |
+
for r in results
|
| 141 |
+
]
|
| 142 |
+
db.log_exchange(
|
| 143 |
+
thread_id=request.thread_id,
|
| 144 |
+
timestamp=now,
|
| 145 |
+
user_message=request.message,
|
| 146 |
+
assistant_response="".join(full_response),
|
| 147 |
+
persona=request.persona,
|
| 148 |
+
language=request.language,
|
| 149 |
+
username=request.username,
|
| 150 |
+
user_agent=user_agent,
|
| 151 |
+
sources=sources,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
return StreamingResponse(stream(), media_type="text/event-stream")
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@app.post("/rename")
|
| 158 |
+
def rename_thread(request: RenameRequest):
|
| 159 |
+
db.rename_thread(request.thread_id, request.title)
|
| 160 |
+
return {"ok": True}
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@app.delete("/thread/{thread_id}")
|
| 164 |
+
def delete_thread(thread_id: str):
|
| 165 |
+
db.delete_thread(thread_id)
|
| 166 |
+
return {"ok": True}
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
@app.get("/export/logs")
|
| 170 |
+
def export_logs(key: str = ""):
|
| 171 |
+
"""Download chat logs as JSON. Requires EXPORT_SECRET for access."""
|
| 172 |
+
if not EXPORT_SECRET or key != EXPORT_SECRET:
|
| 173 |
+
return JSONResponse({"error": "unauthorized"}, status_code=403)
|
| 174 |
+
|
| 175 |
+
cursor = db.conn.execute(
|
| 176 |
+
"""SELECT thread_id, timestamp, user_message, assistant_response,
|
| 177 |
+
persona, language, username, user_agent, sources
|
| 178 |
+
FROM chat_logs ORDER BY timestamp DESC"""
|
| 179 |
+
)
|
| 180 |
+
columns = [
|
| 181 |
+
"thread_id", "timestamp", "user_message", "assistant_response",
|
| 182 |
+
"persona", "language", "username", "user_agent", "sources",
|
| 183 |
+
]
|
| 184 |
+
rows = [dict(zip(columns, row)) for row in cursor]
|
| 185 |
+
for row in rows:
|
| 186 |
+
if row["sources"]:
|
| 187 |
+
row["sources"] = json.loads(row["sources"])
|
| 188 |
+
return JSONResponse({"count": len(rows), "logs": rows})
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# --- Static file serving ---
|
| 192 |
+
app.mount("/assets", StaticFiles(directory=ASSETS_DIR), name="assets")
|
| 193 |
+
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
@app.get("/{filepath:path}")
|
| 197 |
+
def serve_static(filepath: str):
|
| 198 |
+
full_path = os.path.join(STATIC_DIR, filepath)
|
| 199 |
+
if os.path.isfile(full_path):
|
| 200 |
+
return FileResponse(full_path)
|
| 201 |
+
return {"error": "not found"}
|
backend.py
DELETED
|
@@ -1,160 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import json
|
| 3 |
-
import sqlite3
|
| 4 |
-
import time
|
| 5 |
-
from fastapi import FastAPI
|
| 6 |
-
from fastapi.responses import FileResponse, StreamingResponse
|
| 7 |
-
from pydantic import BaseModel
|
| 8 |
-
from langgraph.graph import StateGraph, START, END
|
| 9 |
-
from langgraph.checkpoint.sqlite import SqliteSaver
|
| 10 |
-
from langgraph.graph.message import add_messages
|
| 11 |
-
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
|
| 12 |
-
from langchain_mistralai import ChatMistralAI
|
| 13 |
-
from typing import TypedDict, Annotated
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
# LangGraph Setup
|
| 17 |
-
llm = ChatMistralAI(model="mistral-small", temperature=0.7)
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
class ChatState(TypedDict):
|
| 21 |
-
messages: Annotated[list[BaseMessage], add_messages]
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def chat_node(state: ChatState):
|
| 25 |
-
response = llm.invoke(state["messages"])
|
| 26 |
-
return {"messages": [response]}
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
DB_PATH = "/data/chatbot.db" if os.path.isdir("/data") else "chatbot.db"
|
| 30 |
-
conn = sqlite3.connect(database=DB_PATH, check_same_thread=False)
|
| 31 |
-
|
| 32 |
-
# Thread titles table (separate from LangGraph's checkpoint tables)
|
| 33 |
-
conn.execute("""
|
| 34 |
-
CREATE TABLE IF NOT EXISTS thread_titles (
|
| 35 |
-
thread_id TEXT PRIMARY KEY,
|
| 36 |
-
title TEXT,
|
| 37 |
-
last_active REAL
|
| 38 |
-
)
|
| 39 |
-
""")
|
| 40 |
-
conn.commit()
|
| 41 |
-
|
| 42 |
-
checkpointer = SqliteSaver(conn=conn) # using database instead of InMemorySaver()
|
| 43 |
-
|
| 44 |
-
graph = StateGraph(ChatState)
|
| 45 |
-
graph.add_node("chat_node", chat_node)
|
| 46 |
-
graph.add_edge(START, "chat_node")
|
| 47 |
-
graph.add_edge("chat_node", END)
|
| 48 |
-
|
| 49 |
-
chatbot = graph.compile(checkpointer=checkpointer)
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
# FastAPI Setup
|
| 53 |
-
app = FastAPI()
|
| 54 |
-
|
| 55 |
-
STATIC_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
class ChatRequest(BaseModel):
|
| 59 |
-
message: str
|
| 60 |
-
thread_id: str
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
class RenameRequest(BaseModel):
|
| 64 |
-
thread_id: str
|
| 65 |
-
title: str
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
@app.get("/")
|
| 69 |
-
def serve_index():
|
| 70 |
-
return FileResponse(os.path.join(STATIC_DIR, "stem.html"))
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
@app.get("/threads")
|
| 74 |
-
def get_threads():
|
| 75 |
-
cursor = conn.execute(
|
| 76 |
-
"SELECT thread_id, title FROM thread_titles ORDER BY last_active DESC"
|
| 77 |
-
)
|
| 78 |
-
return {"threads": [{"id": row[0], "title": row[1]} for row in cursor]}
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
@app.get("/history/{thread_id}")
|
| 82 |
-
def get_history(thread_id: str):
|
| 83 |
-
config = {"configurable": {"thread_id": thread_id}}
|
| 84 |
-
state = chatbot.get_state(config)
|
| 85 |
-
messages = state.values.get("messages", [])
|
| 86 |
-
|
| 87 |
-
result = []
|
| 88 |
-
for msg in messages:
|
| 89 |
-
role = "user" if isinstance(msg, HumanMessage) else "assistant"
|
| 90 |
-
result.append({"role": role, "content": msg.content})
|
| 91 |
-
|
| 92 |
-
return {"messages": result}
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
@app.post("/chat")
|
| 96 |
-
def chat(request: ChatRequest):
|
| 97 |
-
now = time.time()
|
| 98 |
-
|
| 99 |
-
# Check if thread already exists
|
| 100 |
-
row = conn.execute(
|
| 101 |
-
"SELECT thread_id FROM thread_titles WHERE thread_id = ?",
|
| 102 |
-
(request.thread_id,)
|
| 103 |
-
).fetchone()
|
| 104 |
-
|
| 105 |
-
if row:
|
| 106 |
-
# Existing thread: just bump last_active
|
| 107 |
-
conn.execute(
|
| 108 |
-
"UPDATE thread_titles SET last_active = ? WHERE thread_id = ?",
|
| 109 |
-
(now, request.thread_id)
|
| 110 |
-
)
|
| 111 |
-
else:
|
| 112 |
-
# New thread: auto-title from first message
|
| 113 |
-
title = request.message[:30]
|
| 114 |
-
if len(request.message) > 30:
|
| 115 |
-
title += "..."
|
| 116 |
-
conn.execute(
|
| 117 |
-
"INSERT INTO thread_titles (thread_id, title, last_active) VALUES (?, ?, ?)",
|
| 118 |
-
(request.thread_id, title, now)
|
| 119 |
-
)
|
| 120 |
-
conn.commit()
|
| 121 |
-
|
| 122 |
-
config = {"configurable": {"thread_id": request.thread_id}}
|
| 123 |
-
|
| 124 |
-
def stream_tokens():
|
| 125 |
-
for chunk, metadata in chatbot.stream(
|
| 126 |
-
{"messages": [HumanMessage(content=request.message)]},
|
| 127 |
-
config=config,
|
| 128 |
-
stream_mode="messages",
|
| 129 |
-
):
|
| 130 |
-
if isinstance(chunk, AIMessage) and chunk.content:
|
| 131 |
-
yield f"data: {json.dumps({'token': chunk.content})}\n\n"
|
| 132 |
-
yield "data: [DONE]\n\n"
|
| 133 |
-
|
| 134 |
-
return StreamingResponse(stream_tokens(), media_type="text/event-stream")
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
@app.post("/rename")
|
| 138 |
-
def rename_thread(request: RenameRequest):
|
| 139 |
-
conn.execute(
|
| 140 |
-
"UPDATE thread_titles SET title = ? WHERE thread_id = ?",
|
| 141 |
-
(request.title, request.thread_id)
|
| 142 |
-
)
|
| 143 |
-
conn.commit()
|
| 144 |
-
return {"ok": True}
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
@app.delete("/thread/{thread_id}")
|
| 148 |
-
def delete_thread(thread_id: str):
|
| 149 |
-
conn.execute("DELETE FROM thread_titles WHERE thread_id = ?", (thread_id,))
|
| 150 |
-
conn.commit()
|
| 151 |
-
return {"ok": True}
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
# Static file serving for assets (logos, etc.)
|
| 155 |
-
@app.get("/{filepath:path}")
|
| 156 |
-
def serve_static(filepath: str):
|
| 157 |
-
full_path = os.path.join(STATIC_DIR, filepath)
|
| 158 |
-
if os.path.isfile(full_path):
|
| 159 |
-
return FileResponse(full_path)
|
| 160 |
-
return {"error": "not found"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
config.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
# --- API Keys ---
|
| 5 |
+
GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY", "")
|
| 6 |
+
PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY", "")
|
| 7 |
+
EXPORT_SECRET = os.environ.get("EXPORT_SECRET", "")
|
| 8 |
+
|
| 9 |
+
# --- Pinecone ---
|
| 10 |
+
PINECONE_INDEX = "stem-embed"
|
| 11 |
+
|
| 12 |
+
# --- Embedding ---
|
| 13 |
+
EMBED_MODEL_NAME = "BAAI/bge-large-en-v1.5"
|
| 14 |
+
BGE_QUERY_PREFIX = "Represent this sentence: "
|
| 15 |
+
|
| 16 |
+
# --- Database ---
|
| 17 |
+
# HuggingFace Spaces provides /data for persistent storage
|
| 18 |
+
DB_PATH = "/data/stemgraph.db" if os.path.isdir("/data") else "stemgraph.db"
|
| 19 |
+
|
| 20 |
+
# --- Defaults ---
|
| 21 |
+
DEFAULT_PERSONA = "noob"
|
| 22 |
+
RETRIEVAL_TOP_K = 5
|
db.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sqlite3
|
| 2 |
+
import json
|
| 3 |
+
from config import DB_PATH
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
conn = sqlite3.connect(database=DB_PATH, check_same_thread=False)
|
| 7 |
+
|
| 8 |
+
# Thread titles (existing)
|
| 9 |
+
conn.execute("""
|
| 10 |
+
CREATE TABLE IF NOT EXISTS thread_titles (
|
| 11 |
+
thread_id TEXT PRIMARY KEY,
|
| 12 |
+
title TEXT,
|
| 13 |
+
last_active REAL
|
| 14 |
+
)
|
| 15 |
+
""")
|
| 16 |
+
|
| 17 |
+
# Chat logs — analytics dataset for future model training
|
| 18 |
+
conn.execute("""
|
| 19 |
+
CREATE TABLE IF NOT EXISTS chat_logs (
|
| 20 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 21 |
+
thread_id TEXT NOT NULL,
|
| 22 |
+
timestamp REAL NOT NULL,
|
| 23 |
+
user_message TEXT NOT NULL,
|
| 24 |
+
assistant_response TEXT NOT NULL,
|
| 25 |
+
persona TEXT,
|
| 26 |
+
language TEXT,
|
| 27 |
+
username TEXT,
|
| 28 |
+
user_agent TEXT,
|
| 29 |
+
sources TEXT
|
| 30 |
+
)
|
| 31 |
+
""")
|
| 32 |
+
|
| 33 |
+
conn.commit()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# --- Thread operations ---
|
| 37 |
+
|
| 38 |
+
def get_threads():
|
| 39 |
+
cursor = conn.execute(
|
| 40 |
+
"SELECT thread_id, title FROM thread_titles ORDER BY last_active DESC"
|
| 41 |
+
)
|
| 42 |
+
return [{"id": row[0], "title": row[1]} for row in cursor]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def upsert_thread(thread_id: str, message: str, now: float):
|
| 46 |
+
row = conn.execute(
|
| 47 |
+
"SELECT thread_id FROM thread_titles WHERE thread_id = ?",
|
| 48 |
+
(thread_id,),
|
| 49 |
+
).fetchone()
|
| 50 |
+
|
| 51 |
+
if row:
|
| 52 |
+
conn.execute(
|
| 53 |
+
"UPDATE thread_titles SET last_active = ? WHERE thread_id = ?",
|
| 54 |
+
(now, thread_id),
|
| 55 |
+
)
|
| 56 |
+
else:
|
| 57 |
+
title = message[:30] + ("..." if len(message) > 30 else "")
|
| 58 |
+
conn.execute(
|
| 59 |
+
"INSERT INTO thread_titles (thread_id, title, last_active) VALUES (?, ?, ?)",
|
| 60 |
+
(thread_id, title, now),
|
| 61 |
+
)
|
| 62 |
+
conn.commit()
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def rename_thread(thread_id: str, title: str):
|
| 66 |
+
conn.execute(
|
| 67 |
+
"UPDATE thread_titles SET title = ? WHERE thread_id = ?",
|
| 68 |
+
(title, thread_id),
|
| 69 |
+
)
|
| 70 |
+
conn.commit()
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def delete_thread(thread_id: str):
|
| 74 |
+
conn.execute("DELETE FROM thread_titles WHERE thread_id = ?", (thread_id,))
|
| 75 |
+
conn.commit()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# --- Chat log operations ---
|
| 79 |
+
|
| 80 |
+
def log_exchange(
|
| 81 |
+
thread_id: str,
|
| 82 |
+
timestamp: float,
|
| 83 |
+
user_message: str,
|
| 84 |
+
assistant_response: str,
|
| 85 |
+
persona: str,
|
| 86 |
+
language: str,
|
| 87 |
+
username: str,
|
| 88 |
+
user_agent: str,
|
| 89 |
+
sources: list[dict] | None = None,
|
| 90 |
+
):
|
| 91 |
+
"""Log a complete user-assistant exchange for analytics/training data."""
|
| 92 |
+
conn.execute(
|
| 93 |
+
"""INSERT INTO chat_logs
|
| 94 |
+
(thread_id, timestamp, user_message, assistant_response,
|
| 95 |
+
persona, language, username, user_agent, sources)
|
| 96 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
| 97 |
+
(
|
| 98 |
+
thread_id,
|
| 99 |
+
timestamp,
|
| 100 |
+
user_message,
|
| 101 |
+
assistant_response,
|
| 102 |
+
persona,
|
| 103 |
+
language,
|
| 104 |
+
username,
|
| 105 |
+
user_agent,
|
| 106 |
+
json.dumps(sources) if sources else None,
|
| 107 |
+
),
|
| 108 |
+
)
|
| 109 |
+
conn.commit()
|
graph.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LangGraph definition — single chat node with persona/language-aware system prompt.
|
| 3 |
+
Guard and retrieval happen outside the graph (in the route handler)
|
| 4 |
+
so we can send SSE status events during those steps.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from langgraph.graph import StateGraph, START, END
|
| 8 |
+
from langgraph.checkpoint.sqlite import SqliteSaver
|
| 9 |
+
from langgraph.graph.message import add_messages
|
| 10 |
+
from langchain_core.messages import BaseMessage, SystemMessage
|
| 11 |
+
from langchain_core.runnables import RunnableConfig
|
| 12 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 13 |
+
from typing import TypedDict, Annotated
|
| 14 |
+
|
| 15 |
+
import db
|
| 16 |
+
import prompts
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
llm = ChatGoogleGenerativeAI(
|
| 20 |
+
model="gemini-2.5-flash-lite",
|
| 21 |
+
temperature=0.5,
|
| 22 |
+
max_output_tokens=8192,
|
| 23 |
+
streaming=True,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ChatState(TypedDict):
|
| 28 |
+
messages: Annotated[list[BaseMessage], add_messages]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def chat_node(state: ChatState, config: RunnableConfig):
|
| 32 |
+
"""
|
| 33 |
+
Build system prompt from persona + language + context + username,
|
| 34 |
+
prepend it to conversation history, invoke LLM.
|
| 35 |
+
"""
|
| 36 |
+
cfg = config.get("configurable", {})
|
| 37 |
+
persona = cfg.get("persona", "noob")
|
| 38 |
+
context = cfg.get("context", "")
|
| 39 |
+
language = cfg.get("language", "auto")
|
| 40 |
+
username = cfg.get("username", "")
|
| 41 |
+
|
| 42 |
+
system_msg = SystemMessage(
|
| 43 |
+
content=prompts.build(persona, context, language, username)
|
| 44 |
+
)
|
| 45 |
+
all_msgs = [system_msg] + state["messages"]
|
| 46 |
+
response = llm.invoke(all_msgs)
|
| 47 |
+
|
| 48 |
+
return {"messages": [response]}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# --- Compile graph ---
|
| 52 |
+
_graph = StateGraph(ChatState)
|
| 53 |
+
_graph.add_node("chat_node", chat_node)
|
| 54 |
+
_graph.add_edge(START, "chat_node")
|
| 55 |
+
_graph.add_edge("chat_node", END)
|
| 56 |
+
|
| 57 |
+
checkpointer = SqliteSaver(conn=db.conn)
|
| 58 |
+
chatbot = _graph.compile(checkpointer=checkpointer)
|
guard.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Input guard — lightweight pre-check before LLM call.
|
| 3 |
+
No explicit word lists. Abuse handling is delegated to the system prompt
|
| 4 |
+
(Mistral already has built-in safety training).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def check_input(message: str) -> tuple[bool, str | None]:
|
| 9 |
+
"""
|
| 10 |
+
Basic pre-checks that don't need an LLM call.
|
| 11 |
+
Returns (True, None) if OK, or (False, rejection_message) if blocked.
|
| 12 |
+
Abuse/profanity is handled by the system prompt, not here.
|
| 13 |
+
"""
|
| 14 |
+
text = message.strip()
|
| 15 |
+
|
| 16 |
+
if not text:
|
| 17 |
+
return False, "Empty message."
|
| 18 |
+
|
| 19 |
+
if len(text) > 5000:
|
| 20 |
+
return False, "Message too long. Keep it under 5000 characters."
|
| 21 |
+
|
| 22 |
+
return True, None
|
prompts.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
System prompts and persona templates for ANStem AI.
|
| 3 |
+
Supports: persona selection, language preference, username addressing, abuse deflection.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
_BASE_PROMPT = """You are ANStem AI, a deeply knowledgeable tutor for CBSE Class XI and XII students studying Physics, Chemistry, and Mathematics.
|
| 8 |
+
|
| 9 |
+
You have access to NCERT textbook material provided as context below. Use this context as your PRIMARY reference.
|
| 10 |
+
|
| 11 |
+
HOW TO ANSWER:
|
| 12 |
+
1. Read the context carefully. Use it as the foundation of your answer.
|
| 13 |
+
2. If the context contains relevant information, even partially, use it and build a complete, thorough answer around it.
|
| 14 |
+
3. You may use your own knowledge to EXPLAIN, DERIVE, and ELABORATE on concepts found in or related to the context. The context provides the textbook anchor; you provide the expert teaching.
|
| 15 |
+
4. Only say you cannot help if the question is completely outside CBSE XI/XII PCM syllabus (history, politics, entertainment, etc.).
|
| 16 |
+
5. For any question within Physics, Chemistry, or Mathematics scope, always attempt a helpful and complete answer.
|
| 17 |
+
6. Give COMPLETE answers. Never stop mid-sentence or mid-derivation.
|
| 18 |
+
|
| 19 |
+
FORMAT:
|
| 20 |
+
- Use LaTeX for math: inline $...$ and display $$...$$.
|
| 21 |
+
- Chemical formulas: use \\ce{{...}} notation (e.g. \\ce{{H2SO4}}, \\ce{{2H2 + O2 -> 2H2O}}).
|
| 22 |
+
- Structure your response: direct answer first, then detailed explanation, then formula or derivation if applicable.
|
| 23 |
+
- Cite NCERT source when using context: [Class XI/XII, Subject, Chapter X].
|
| 24 |
+
|
| 25 |
+
ABUSE POLICY:
|
| 26 |
+
If the user is vulgar, abusive, or trying to provoke you, respond with ONLY:
|
| 27 |
+
"Let's keep it academic. Ask me about Physics, Chemistry, or Maths."
|
| 28 |
+
Do not engage with, repeat, or acknowledge abusive content."""
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
_PERSONAS = {
|
| 32 |
+
"nerd": (
|
| 33 |
+
"TEACHING STYLE: You are detail-obsessed and rigorous. "
|
| 34 |
+
"Derive everything from first principles. Use precise mathematical notation and formal terminology. "
|
| 35 |
+
"Include step-by-step derivations, proofs, and edge cases. "
|
| 36 |
+
"Assume the student is sharp, curious, and wants real depth."
|
| 37 |
+
),
|
| 38 |
+
"noob": (
|
| 39 |
+
"TEACHING STYLE: You are patient, warm, and encouraging. "
|
| 40 |
+
"Explain like the student is encountering this topic for the very first time. "
|
| 41 |
+
"Use simple everyday language, relatable analogies, and step-by-step breakdown. "
|
| 42 |
+
"If you use a technical term, define it immediately with an example."
|
| 43 |
+
),
|
| 44 |
+
"priest": (
|
| 45 |
+
"TEACHING STYLE: You are a cosmic philosopher who sees the universe as a grand design. "
|
| 46 |
+
"Explain science as if revealing ancient secrets of existence. "
|
| 47 |
+
"Use metaphors from nature, astronomy, and the fabric of reality. "
|
| 48 |
+
"Make learning feel like discovering something sacred, but stay scientifically precise."
|
| 49 |
+
),
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
_LANGUAGE_INSTRUCTIONS = {
|
| 54 |
+
"auto": (
|
| 55 |
+
"LANGUAGE: Detect the language the student is using from their message and chat history. "
|
| 56 |
+
"Respond in the same language. You are fluent in English, Hindi, Gujarati, Marathi, and Hinglish. "
|
| 57 |
+
"Match their tone and style naturally."
|
| 58 |
+
),
|
| 59 |
+
"english": "LANGUAGE: Respond exclusively in English.",
|
| 60 |
+
"hindi": "LANGUAGE: Respond exclusively in Hindi (Devanagari script).",
|
| 61 |
+
"hinglish": (
|
| 62 |
+
"LANGUAGE: Respond in Hinglish — a natural mix of Hindi and English written in Roman script. "
|
| 63 |
+
"Use the casual conversational style common among Indian students."
|
| 64 |
+
),
|
| 65 |
+
"gujarati": "LANGUAGE: Respond exclusively in Gujarati (Gujarati script).",
|
| 66 |
+
"marathi": "LANGUAGE: Respond exclusively in Marathi (Devanagari script).",
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def build(persona: str, context: str, language: str = "auto", username: str = "") -> str:
|
| 71 |
+
"""Build the full system prompt from persona + language + context + username."""
|
| 72 |
+
persona_instruction = _PERSONAS.get(persona, _PERSONAS["noob"])
|
| 73 |
+
language_instruction = _LANGUAGE_INSTRUCTIONS.get(language, _LANGUAGE_INSTRUCTIONS["auto"])
|
| 74 |
+
|
| 75 |
+
username_line = ""
|
| 76 |
+
if username.strip():
|
| 77 |
+
username_line = f"\nADDRESS: Call the student by their name: {username.strip()}.\n"
|
| 78 |
+
|
| 79 |
+
context_block = context if context.strip() else "No context retrieved for this query."
|
| 80 |
+
|
| 81 |
+
return (
|
| 82 |
+
f"{_BASE_PROMPT}\n\n"
|
| 83 |
+
f"{persona_instruction}\n\n"
|
| 84 |
+
f"{language_instruction}\n"
|
| 85 |
+
f"{username_line}\n"
|
| 86 |
+
f"NCERT CONTEXT:\n{context_block}"
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def get_persona_names() -> list[str]:
|
| 91 |
+
return list(_PERSONAS.keys())
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def get_language_options() -> list[str]:
|
| 95 |
+
return list(_LANGUAGE_INSTRUCTIONS.keys())
|
requirements.txt
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
|
|
|
|
|
| 1 |
langgraph
|
| 2 |
-
langchain
|
| 3 |
langchain-core
|
| 4 |
-
langchain-
|
| 5 |
langgraph-checkpoint-sqlite
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
langgraph
|
|
|
|
| 4 |
langchain-core
|
| 5 |
+
langchain-google-genai
|
| 6 |
langgraph-checkpoint-sqlite
|
| 7 |
+
pinecone
|
| 8 |
+
sentence-transformers
|
| 9 |
+
torch --index-url https://download.pytorch.org/whl/cpu
|
retriever.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pinecone retriever — connects to the stem-embed index,
|
| 3 |
+
encodes queries with BGE-large, returns relevant NCERT chunks.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from sentence_transformers import SentenceTransformer
|
| 7 |
+
from pinecone import Pinecone
|
| 8 |
+
from config import PINECONE_API_KEY, PINECONE_INDEX, EMBED_MODEL_NAME, BGE_QUERY_PREFIX, RETRIEVAL_TOP_K
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# --- Load embedding model (CPU, ~1.3GB, loads once at startup) ---
|
| 12 |
+
_embed_model = SentenceTransformer(EMBED_MODEL_NAME, device="cpu")
|
| 13 |
+
|
| 14 |
+
# --- Connect to Pinecone ---
|
| 15 |
+
_pc = Pinecone(api_key=PINECONE_API_KEY)
|
| 16 |
+
_index = _pc.Index(PINECONE_INDEX)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def search(query: str, top_k: int = RETRIEVAL_TOP_K) -> list[dict]:
|
| 20 |
+
"""
|
| 21 |
+
Encode query with BGE prefix, retrieve top-k chunks from Pinecone.
|
| 22 |
+
Returns list of dicts: {text, subject, class_level, chapter, source, score}
|
| 23 |
+
"""
|
| 24 |
+
q_vec = _embed_model.encode(
|
| 25 |
+
[BGE_QUERY_PREFIX + query],
|
| 26 |
+
normalize_embeddings=True,
|
| 27 |
+
convert_to_numpy=True,
|
| 28 |
+
)[0].tolist()
|
| 29 |
+
|
| 30 |
+
response = _index.query(vector=q_vec, top_k=top_k, include_metadata=True)
|
| 31 |
+
|
| 32 |
+
results = []
|
| 33 |
+
for m in response.matches:
|
| 34 |
+
meta = m.metadata
|
| 35 |
+
results.append({
|
| 36 |
+
"text": meta.get("text", ""),
|
| 37 |
+
"subject": meta.get("subject", ""),
|
| 38 |
+
"class_level": meta.get("class_level", ""),
|
| 39 |
+
"chapter": meta.get("chapter", ""),
|
| 40 |
+
"source": meta.get("source", ""),
|
| 41 |
+
"score": round(m.score, 4),
|
| 42 |
+
})
|
| 43 |
+
return results
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def format_context(results: list[dict]) -> str:
|
| 47 |
+
"""
|
| 48 |
+
Format search results into a context string
|
| 49 |
+
suitable for injection into the system prompt.
|
| 50 |
+
"""
|
| 51 |
+
if not results:
|
| 52 |
+
return "No relevant context found."
|
| 53 |
+
|
| 54 |
+
blocks = []
|
| 55 |
+
for i, r in enumerate(results, 1):
|
| 56 |
+
label = f"Class {r['class_level']} {r['subject']} Ch.{r['chapter']}"
|
| 57 |
+
blocks.append(f"[{i}. {label} | score={r['score']}]\n{r['text']}")
|
| 58 |
+
|
| 59 |
+
return "\n\n".join(blocks)
|
static/app.js
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
ANStem AI — Application Logic
|
| 3 |
+
============================================================ */
|
| 4 |
+
|
| 5 |
+
/* ----- State ----- */
|
| 6 |
+
let currentThreadId = crypto.randomUUID();
|
| 7 |
+
const threads = [];
|
| 8 |
+
let isSending = false;
|
| 9 |
+
let currentPersona = localStorage.getItem('anstem_persona') || 'noob';
|
| 10 |
+
let currentLanguage = localStorage.getItem('anstem_language') || 'auto';
|
| 11 |
+
let currentUsername = localStorage.getItem('anstem_username') || '';
|
| 12 |
+
|
| 13 |
+
/* ----- DOM References ----- */
|
| 14 |
+
const sidebar = document.getElementById('sidebar');
|
| 15 |
+
const toggleSidebarBtn = document.getElementById('toggleSidebarBtn');
|
| 16 |
+
const chatHistoryList = document.getElementById('chatHistoryList');
|
| 17 |
+
const chatContainer = document.getElementById('chatContainer');
|
| 18 |
+
const userInput = document.getElementById('userInput');
|
| 19 |
+
const sendBtn = document.getElementById('sendBtn');
|
| 20 |
+
const settingsPanel = document.getElementById('settingsPanel');
|
| 21 |
+
const settingsOverlay = document.getElementById('settingsOverlay');
|
| 22 |
+
const openSettingsBtn = document.getElementById('openSettingsBtn');
|
| 23 |
+
const newChatBtn = document.getElementById('newChatBtn');
|
| 24 |
+
const usernameInput = document.getElementById('usernameInput');
|
| 25 |
+
const languageSelect = document.getElementById('languageSelect');
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
/* =========================================================
|
| 29 |
+
Sidebar
|
| 30 |
+
========================================================= */
|
| 31 |
+
|
| 32 |
+
toggleSidebarBtn.addEventListener('click', () => sidebar.classList.toggle('collapsed'));
|
| 33 |
+
|
| 34 |
+
newChatBtn.addEventListener('click', () => {
|
| 35 |
+
currentThreadId = crypto.randomUUID();
|
| 36 |
+
chatContainer.innerHTML = '<div class="welcome-placeholder">Where should we start?</div>';
|
| 37 |
+
renderHistory();
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
function addThreadToSidebar(id, title) {
|
| 41 |
+
threads.unshift({ id, title });
|
| 42 |
+
renderHistory();
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function renderHistory() {
|
| 46 |
+
chatHistoryList.innerHTML = '';
|
| 47 |
+
threads.forEach(thread => {
|
| 48 |
+
const item = document.createElement('div');
|
| 49 |
+
item.className = `history-item ${thread.id === currentThreadId ? 'active' : ''}`;
|
| 50 |
+
item.setAttribute('data-thread-id', thread.id);
|
| 51 |
+
item.innerHTML = `
|
| 52 |
+
<span class="chat-title">${thread.title}</span>
|
| 53 |
+
<button class="options-btn" onclick="toggleMenu(event, this)">⋮</button>
|
| 54 |
+
<div class="options-menu">
|
| 55 |
+
<div class="option-item" onclick="renameChat(event, this)">
|
| 56 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
| 57 |
+
Rename
|
| 58 |
+
</div>
|
| 59 |
+
<div class="option-item delete" onclick="deleteChat(event, this)">
|
| 60 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
| 61 |
+
Delete
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
`;
|
| 65 |
+
item.addEventListener('click', () => loadThread(thread.id));
|
| 66 |
+
chatHistoryList.appendChild(item);
|
| 67 |
+
});
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
function loadThread(threadId) {
|
| 71 |
+
currentThreadId = threadId;
|
| 72 |
+
renderHistory();
|
| 73 |
+
chatContainer.innerHTML = '';
|
| 74 |
+
fetch('/history/' + threadId)
|
| 75 |
+
.then(res => res.json())
|
| 76 |
+
.then(data => {
|
| 77 |
+
data.messages.forEach(msg => {
|
| 78 |
+
const sender = msg.role === 'user' ? 'user' : 'ai';
|
| 79 |
+
const el = appendMessage(sender, msg.content);
|
| 80 |
+
if (sender === 'ai') renderFinalContent(el, msg.content);
|
| 81 |
+
});
|
| 82 |
+
});
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
/* =========================================================
|
| 87 |
+
Options Menu (Rename / Delete)
|
| 88 |
+
========================================================= */
|
| 89 |
+
|
| 90 |
+
function toggleMenu(e, btn) {
|
| 91 |
+
e.stopPropagation();
|
| 92 |
+
closeAllMenus();
|
| 93 |
+
btn.nextElementSibling.classList.add('show');
|
| 94 |
+
btn.classList.add('menu-open');
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
document.addEventListener('click', closeAllMenus);
|
| 98 |
+
|
| 99 |
+
function closeAllMenus() {
|
| 100 |
+
document.querySelectorAll('.options-menu.show').forEach(m => m.classList.remove('show'));
|
| 101 |
+
document.querySelectorAll('.options-btn.menu-open').forEach(b => b.classList.remove('menu-open'));
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function deleteChat(e, optionEl) {
|
| 105 |
+
e.stopPropagation();
|
| 106 |
+
const item = optionEl.closest('.history-item');
|
| 107 |
+
const threadId = item.getAttribute('data-thread-id');
|
| 108 |
+
const idx = threads.findIndex(t => t.id === threadId);
|
| 109 |
+
if (idx !== -1) threads.splice(idx, 1);
|
| 110 |
+
item.style.opacity = '0';
|
| 111 |
+
setTimeout(() => item.remove(), 200);
|
| 112 |
+
fetch('/thread/' + threadId, { method: 'DELETE' });
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
function renameChat(e, optionEl) {
|
| 116 |
+
e.stopPropagation();
|
| 117 |
+
const item = optionEl.closest('.history-item');
|
| 118 |
+
const titleSpan = item.querySelector('.chat-title');
|
| 119 |
+
const threadId = item.getAttribute('data-thread-id');
|
| 120 |
+
closeAllMenus();
|
| 121 |
+
|
| 122 |
+
const currentTitle = titleSpan.innerText;
|
| 123 |
+
const input = document.createElement('input');
|
| 124 |
+
input.type = 'text';
|
| 125 |
+
input.value = currentTitle;
|
| 126 |
+
input.className = 'rename-input';
|
| 127 |
+
titleSpan.replaceWith(input);
|
| 128 |
+
input.focus();
|
| 129 |
+
input.selectionStart = input.selectionEnd = input.value.length;
|
| 130 |
+
|
| 131 |
+
function saveRename() {
|
| 132 |
+
const newTitle = input.value.trim() || 'Untitled Chat';
|
| 133 |
+
titleSpan.innerText = newTitle;
|
| 134 |
+
input.replaceWith(titleSpan);
|
| 135 |
+
const thread = threads.find(t => t.id === threadId);
|
| 136 |
+
if (thread) thread.title = newTitle;
|
| 137 |
+
fetch('/rename', {
|
| 138 |
+
method: 'POST',
|
| 139 |
+
headers: { 'Content-Type': 'application/json' },
|
| 140 |
+
body: JSON.stringify({ thread_id: threadId, title: newTitle })
|
| 141 |
+
});
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
input.addEventListener('blur', saveRename);
|
| 145 |
+
input.addEventListener('keydown', evt => {
|
| 146 |
+
if (evt.key === 'Enter') saveRename();
|
| 147 |
+
if (evt.key === 'Escape') { titleSpan.innerText = currentTitle; input.replaceWith(titleSpan); }
|
| 148 |
+
});
|
| 149 |
+
input.addEventListener('click', evt => evt.stopPropagation());
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
/* =========================================================
|
| 154 |
+
Settings Panel
|
| 155 |
+
========================================================= */
|
| 156 |
+
|
| 157 |
+
openSettingsBtn.addEventListener('click', () => {
|
| 158 |
+
settingsPanel.classList.add('open');
|
| 159 |
+
settingsOverlay.classList.add('show');
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
settingsOverlay.addEventListener('click', () => {
|
| 163 |
+
settingsPanel.classList.remove('open');
|
| 164 |
+
settingsOverlay.classList.remove('show');
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
// Username
|
| 168 |
+
usernameInput.value = currentUsername;
|
| 169 |
+
usernameInput.addEventListener('input', () => {
|
| 170 |
+
currentUsername = usernameInput.value.trim();
|
| 171 |
+
localStorage.setItem('anstem_username', currentUsername);
|
| 172 |
+
});
|
| 173 |
+
|
| 174 |
+
// Language
|
| 175 |
+
languageSelect.value = currentLanguage;
|
| 176 |
+
languageSelect.addEventListener('change', () => {
|
| 177 |
+
currentLanguage = languageSelect.value;
|
| 178 |
+
localStorage.setItem('anstem_language', currentLanguage);
|
| 179 |
+
});
|
| 180 |
+
|
| 181 |
+
// Persona selection
|
| 182 |
+
document.querySelectorAll('.persona-option').forEach(opt => {
|
| 183 |
+
if (opt.dataset.persona === currentPersona) {
|
| 184 |
+
document.querySelectorAll('.persona-option').forEach(o => o.classList.remove('active'));
|
| 185 |
+
opt.classList.add('active');
|
| 186 |
+
}
|
| 187 |
+
opt.addEventListener('click', () => {
|
| 188 |
+
document.querySelectorAll('.persona-option').forEach(o => o.classList.remove('active'));
|
| 189 |
+
opt.classList.add('active');
|
| 190 |
+
currentPersona = opt.dataset.persona;
|
| 191 |
+
localStorage.setItem('anstem_persona', currentPersona);
|
| 192 |
+
});
|
| 193 |
+
});
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
/* =========================================================
|
| 197 |
+
Chat & Streaming
|
| 198 |
+
========================================================= */
|
| 199 |
+
|
| 200 |
+
userInput.addEventListener('input', function () {
|
| 201 |
+
this.style.height = '56px';
|
| 202 |
+
this.style.height = this.scrollHeight + 'px';
|
| 203 |
+
sendBtn.style.color = this.value.trim().length > 0 ? 'var(--text-primary)' : 'var(--text-secondary)';
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
userInput.addEventListener('keydown', function (e) {
|
| 207 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 208 |
+
e.preventDefault();
|
| 209 |
+
sendMessage();
|
| 210 |
+
}
|
| 211 |
+
});
|
| 212 |
+
|
| 213 |
+
sendBtn.addEventListener('click', sendMessage);
|
| 214 |
+
|
| 215 |
+
function sendMessage() {
|
| 216 |
+
const text = userInput.value.trim();
|
| 217 |
+
if (!text || isSending) return;
|
| 218 |
+
|
| 219 |
+
const placeholder = document.getElementById('welcomePlaceholder');
|
| 220 |
+
if (placeholder) placeholder.remove();
|
| 221 |
+
|
| 222 |
+
isSending = true;
|
| 223 |
+
appendMessage('user', text);
|
| 224 |
+
userInput.value = '';
|
| 225 |
+
userInput.style.height = '56px';
|
| 226 |
+
sendBtn.style.color = 'var(--text-secondary)';
|
| 227 |
+
|
| 228 |
+
const exists = threads.find(t => t.id === currentThreadId);
|
| 229 |
+
if (!exists) {
|
| 230 |
+
const title = text.length > 30 ? text.substring(0, 30) + '...' : text;
|
| 231 |
+
addThreadToSidebar(currentThreadId, title);
|
| 232 |
+
} else {
|
| 233 |
+
const idx = threads.indexOf(exists);
|
| 234 |
+
threads.splice(idx, 1);
|
| 235 |
+
threads.unshift(exists);
|
| 236 |
+
renderHistory();
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
streamResponse(text);
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
function streamResponse(text) {
|
| 243 |
+
// Create AI message row with thinking indicator
|
| 244 |
+
const rowDiv = document.createElement('div');
|
| 245 |
+
rowDiv.classList.add('message-row', 'ai');
|
| 246 |
+
rowDiv.innerHTML = `
|
| 247 |
+
<div class="ai-avatar"></div>
|
| 248 |
+
<div style="flex-grow:1; max-width:80%;">
|
| 249 |
+
<div class="thinking-indicator" id="currentThinking">
|
| 250 |
+
<img src="/assets/stem_black.png" class="thinking-logo" />
|
| 251 |
+
<span class="thinking-text">Reading your question...</span>
|
| 252 |
+
</div>
|
| 253 |
+
<div class="message-content" style="display:none;"></div>
|
| 254 |
+
</div>
|
| 255 |
+
`;
|
| 256 |
+
chatContainer.appendChild(rowDiv);
|
| 257 |
+
scrollToBottom();
|
| 258 |
+
|
| 259 |
+
const thinkingEl = rowDiv.querySelector('#currentThinking');
|
| 260 |
+
const thinkingText = thinkingEl.querySelector('.thinking-text');
|
| 261 |
+
const contentEl = rowDiv.querySelector('.message-content');
|
| 262 |
+
let rawText = '';
|
| 263 |
+
let firstToken = true;
|
| 264 |
+
|
| 265 |
+
fetch('/chat', {
|
| 266 |
+
method: 'POST',
|
| 267 |
+
headers: { 'Content-Type': 'application/json' },
|
| 268 |
+
body: JSON.stringify({ message: text, thread_id: currentThreadId, persona: currentPersona, language: currentLanguage, username: currentUsername })
|
| 269 |
+
}).then(response => {
|
| 270 |
+
const reader = response.body.getReader();
|
| 271 |
+
const decoder = new TextDecoder();
|
| 272 |
+
let buffer = '';
|
| 273 |
+
|
| 274 |
+
function read() {
|
| 275 |
+
reader.read().then(({ done, value }) => {
|
| 276 |
+
if (done) {
|
| 277 |
+
finishStream(thinkingEl, contentEl, rawText);
|
| 278 |
+
return;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
buffer += decoder.decode(value, { stream: true });
|
| 282 |
+
const lines = buffer.split('\n');
|
| 283 |
+
buffer = lines.pop();
|
| 284 |
+
|
| 285 |
+
lines.forEach(line => {
|
| 286 |
+
if (!line.startsWith('data: ')) return;
|
| 287 |
+
const payload = line.substring(6);
|
| 288 |
+
|
| 289 |
+
if (payload === '[DONE]') {
|
| 290 |
+
finishStream(thinkingEl, contentEl, rawText);
|
| 291 |
+
return;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
const data = JSON.parse(payload);
|
| 295 |
+
|
| 296 |
+
// Status event (thinking phase)
|
| 297 |
+
if (data.status === 'thinking') {
|
| 298 |
+
thinkingText.textContent = data.message;
|
| 299 |
+
return;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
// Token event
|
| 303 |
+
if (data.token !== undefined) {
|
| 304 |
+
if (firstToken) {
|
| 305 |
+
thinkingEl.style.display = 'none';
|
| 306 |
+
contentEl.style.display = 'block';
|
| 307 |
+
contentEl.classList.add('cursor');
|
| 308 |
+
firstToken = false;
|
| 309 |
+
}
|
| 310 |
+
rawText += data.token;
|
| 311 |
+
contentEl.textContent = rawText;
|
| 312 |
+
scrollToBottom();
|
| 313 |
+
}
|
| 314 |
+
});
|
| 315 |
+
|
| 316 |
+
read();
|
| 317 |
+
});
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
read();
|
| 321 |
+
});
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
function finishStream(thinkingEl, contentEl, rawText) {
|
| 325 |
+
thinkingEl.style.display = 'none';
|
| 326 |
+
contentEl.style.display = 'block';
|
| 327 |
+
contentEl.classList.remove('cursor');
|
| 328 |
+
renderFinalContent(contentEl, rawText);
|
| 329 |
+
isSending = false;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
/* =========================================================
|
| 334 |
+
Message Helpers
|
| 335 |
+
========================================================= */
|
| 336 |
+
|
| 337 |
+
function appendMessage(sender, text) {
|
| 338 |
+
const rowDiv = document.createElement('div');
|
| 339 |
+
rowDiv.classList.add('message-row', sender);
|
| 340 |
+
let html = '';
|
| 341 |
+
if (sender === 'ai') html += '<div class="ai-avatar"></div>';
|
| 342 |
+
html += `<div class="message-content">${escapeHtml(text)}</div>`;
|
| 343 |
+
rowDiv.innerHTML = html;
|
| 344 |
+
chatContainer.appendChild(rowDiv);
|
| 345 |
+
scrollToBottom();
|
| 346 |
+
return rowDiv.querySelector('.message-content');
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
function scrollToBottom() {
|
| 350 |
+
chatContainer.scrollTop = chatContainer.scrollHeight;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
function escapeHtml(text) {
|
| 354 |
+
const div = document.createElement('div');
|
| 355 |
+
div.textContent = text;
|
| 356 |
+
return div.innerHTML;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
/* =========================================================
|
| 361 |
+
Renderer — Markdown + KaTeX + mhchem
|
| 362 |
+
========================================================= */
|
| 363 |
+
|
| 364 |
+
function renderFinalContent(element, rawText) {
|
| 365 |
+
if (!rawText) return;
|
| 366 |
+
|
| 367 |
+
// Step 1: Protect LaTeX blocks from markdown parser
|
| 368 |
+
const blocks = [];
|
| 369 |
+
let safeText = rawText;
|
| 370 |
+
|
| 371 |
+
// Display math: $$...$$
|
| 372 |
+
safeText = safeText.replace(/\$\$[\s\S]*?\$\$/g, match => {
|
| 373 |
+
blocks.push(match);
|
| 374 |
+
return `%%LATEX_${blocks.length - 1}%%`;
|
| 375 |
+
});
|
| 376 |
+
|
| 377 |
+
// Inline math: $...$ (not greedy, single line)
|
| 378 |
+
safeText = safeText.replace(/\$[^\$\n]+?\$/g, match => {
|
| 379 |
+
blocks.push(match);
|
| 380 |
+
return `%%LATEX_${blocks.length - 1}%%`;
|
| 381 |
+
});
|
| 382 |
+
|
| 383 |
+
// \[...\] and \(...\)
|
| 384 |
+
safeText = safeText.replace(/\\\[[\s\S]*?\\\]/g, match => {
|
| 385 |
+
blocks.push(match);
|
| 386 |
+
return `%%LATEX_${blocks.length - 1}%%`;
|
| 387 |
+
});
|
| 388 |
+
safeText = safeText.replace(/\\\([\s\S]*?\\\)/g, match => {
|
| 389 |
+
blocks.push(match);
|
| 390 |
+
return `%%LATEX_${blocks.length - 1}%%`;
|
| 391 |
+
});
|
| 392 |
+
|
| 393 |
+
// Step 2: Parse markdown
|
| 394 |
+
let html = typeof marked !== 'undefined' ? marked.parse(safeText) : safeText;
|
| 395 |
+
|
| 396 |
+
// Step 3: Restore LaTeX blocks
|
| 397 |
+
blocks.forEach((block, i) => {
|
| 398 |
+
html = html.replace(`%%LATEX_${i}%%`, block);
|
| 399 |
+
});
|
| 400 |
+
|
| 401 |
+
// Step 4: Inject HTML and render KaTeX
|
| 402 |
+
element.innerHTML = html;
|
| 403 |
+
|
| 404 |
+
if (typeof renderMathInElement !== 'undefined') {
|
| 405 |
+
renderMathInElement(element, {
|
| 406 |
+
delimiters: [
|
| 407 |
+
{ left: '$$', right: '$$', display: true },
|
| 408 |
+
{ left: '$', right: '$', display: false },
|
| 409 |
+
{ left: '\\(', right: '\\)', display: false },
|
| 410 |
+
{ left: '\\[', right: '\\]', display: true },
|
| 411 |
+
],
|
| 412 |
+
throwOnError: false,
|
| 413 |
+
});
|
| 414 |
+
}
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
/* =========================================================
|
| 419 |
+
Init — Load existing threads
|
| 420 |
+
========================================================= */
|
| 421 |
+
|
| 422 |
+
fetch('/threads')
|
| 423 |
+
.then(res => res.json())
|
| 424 |
+
.then(data => {
|
| 425 |
+
data.threads.forEach(t => threads.push({ id: t.id, title: t.title }));
|
| 426 |
+
renderHistory();
|
| 427 |
+
});
|
static/index.html
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>ANStem AI</title>
|
| 7 |
+
<meta name="description" content="AI-powered NCERT tutor for Class XI and XII Physics, Chemistry, and Mathematics.">
|
| 8 |
+
<link rel="icon" type="image/png" href="/assets/stem_black.png">
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600&display=swap" rel="stylesheet">
|
| 10 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
|
| 11 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
|
| 15 |
+
<aside class="sidebar" id="sidebar">
|
| 16 |
+
<div class="sidebar-header">
|
| 17 |
+
<img src="/assets/stembotix.png" alt="STEMbotix Logo" class="brand-logo">
|
| 18 |
+
</div>
|
| 19 |
+
|
| 20 |
+
<button class="new-chat-btn" id="newChatBtn">
|
| 21 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 22 |
+
<path d="M12 5v14M5 12h14"/>
|
| 23 |
+
</svg>
|
| 24 |
+
New Chat
|
| 25 |
+
</button>
|
| 26 |
+
|
| 27 |
+
<div class="chat-history" id="chatHistoryList"></div>
|
| 28 |
+
|
| 29 |
+
<div class="sidebar-footer">
|
| 30 |
+
<div class="settings-btn" id="openSettingsBtn">
|
| 31 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 32 |
+
<circle cx="12" cy="12" r="3"></circle>
|
| 33 |
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
| 34 |
+
</svg>
|
| 35 |
+
Settings
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
</aside>
|
| 39 |
+
|
| 40 |
+
<!-- Settings Panel (slide-out) -->
|
| 41 |
+
<div class="settings-overlay" id="settingsOverlay"></div>
|
| 42 |
+
<div class="settings-panel" id="settingsPanel">
|
| 43 |
+
<h3>Your Name</h3>
|
| 44 |
+
<input type="text" class="settings-input" id="usernameInput" placeholder="What should I call you?" maxlength="30" autocomplete="off" />
|
| 45 |
+
|
| 46 |
+
<h3>Language</h3>
|
| 47 |
+
<select class="settings-select" id="languageSelect">
|
| 48 |
+
<option value="auto">Auto-detect</option>
|
| 49 |
+
<option value="english">English</option>
|
| 50 |
+
<option value="hinglish">Hinglish</option>
|
| 51 |
+
<option value="hindi">Hindi</option>
|
| 52 |
+
<option value="gujarati">Gujarati</option>
|
| 53 |
+
<option value="marathi">Marathi</option>
|
| 54 |
+
</select>
|
| 55 |
+
|
| 56 |
+
<h3>Teaching Style</h3>
|
| 57 |
+
<div class="persona-option active" data-persona="noob">
|
| 58 |
+
<div>
|
| 59 |
+
<div class="persona-name">Noob</div>
|
| 60 |
+
<div class="persona-desc">Simple, friendly. Explains like you're seeing it for the first time.</div>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
<div class="persona-option" data-persona="nerd">
|
| 64 |
+
<div>
|
| 65 |
+
<div class="persona-name">Nerd</div>
|
| 66 |
+
<div class="persona-desc">Detail-obsessed. Derives from first principles, formal notation.</div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
<div class="persona-option" data-persona="priest">
|
| 70 |
+
<div>
|
| 71 |
+
<div class="persona-name">Priest</div>
|
| 72 |
+
<div class="persona-desc">Cosmic philosopher. Science as secrets of the universe.</div>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<main class="main-chat">
|
| 78 |
+
<header class="main-header">
|
| 79 |
+
<button class="toggle-sidebar-btn" id="toggleSidebarBtn" title="Toggle Sidebar">
|
| 80 |
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 81 |
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
| 82 |
+
<line x1="9" y1="3" x2="9" y2="21"></line>
|
| 83 |
+
</svg>
|
| 84 |
+
</button>
|
| 85 |
+
</header>
|
| 86 |
+
|
| 87 |
+
<div class="chat-container" id="chatContainer">
|
| 88 |
+
<div class="welcome-placeholder" id="welcomePlaceholder">Where should we start?</div>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<div class="input-container">
|
| 92 |
+
<div class="input-box">
|
| 93 |
+
<textarea id="userInput" placeholder="Ask ANStem AI..." rows="1"></textarea>
|
| 94 |
+
<button class="send-btn" id="sendBtn">
|
| 95 |
+
<svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"></path></svg>
|
| 96 |
+
</button>
|
| 97 |
+
</div>
|
| 98 |
+
<div class="disclaimer-text">
|
| 99 |
+
ANStem is an AI tutor grounded in NCERT material. It can still make mistakes.
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
</main>
|
| 103 |
+
|
| 104 |
+
<!-- Dependencies (KaTeX + Marked) -->
|
| 105 |
+
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
|
| 106 |
+
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script>
|
| 107 |
+
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/mhchem.min.js"></script>
|
| 108 |
+
<script defer src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
| 109 |
+
<script defer src="/static/app.js"></script>
|
| 110 |
+
</body>
|
| 111 |
+
</html>
|
static/style.css
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
ANStem AI — Stylesheet
|
| 3 |
+
============================================================ */
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--brand-orange: #EB5A28;
|
| 7 |
+
--brand-orange-hover: #cf4f22;
|
| 8 |
+
--bg-main: #000000;
|
| 9 |
+
--bg-sidebar: #070707;
|
| 10 |
+
--bg-input: #121212;
|
| 11 |
+
--bg-hover: #1A1A1A;
|
| 12 |
+
--text-primary: #EDEDED;
|
| 13 |
+
--text-secondary: #777777;
|
| 14 |
+
--border-color: #222222;
|
| 15 |
+
--msg-user: #141414;
|
| 16 |
+
--msg-ai: transparent;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 20 |
+
|
| 21 |
+
body {
|
| 22 |
+
font-family: 'Montserrat', sans-serif;
|
| 23 |
+
background-color: var(--bg-main);
|
| 24 |
+
color: var(--text-primary);
|
| 25 |
+
display: flex;
|
| 26 |
+
height: 100vh;
|
| 27 |
+
overflow: hidden;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/* --- Sidebar --- */
|
| 31 |
+
.sidebar {
|
| 32 |
+
width: 260px;
|
| 33 |
+
flex-shrink: 0;
|
| 34 |
+
background-color: var(--bg-sidebar);
|
| 35 |
+
display: flex;
|
| 36 |
+
flex-direction: column;
|
| 37 |
+
border-right: 1px solid var(--border-color);
|
| 38 |
+
transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 39 |
+
position: relative;
|
| 40 |
+
z-index: 20;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.sidebar.collapsed { margin-left: -260px; }
|
| 44 |
+
|
| 45 |
+
.sidebar-header {
|
| 46 |
+
padding: 20px;
|
| 47 |
+
display: flex;
|
| 48 |
+
justify-content: center;
|
| 49 |
+
align-items: center;
|
| 50 |
+
border-bottom: 1px solid var(--border-color);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.brand-logo { max-width: 80%; height: auto; object-fit: contain; }
|
| 54 |
+
|
| 55 |
+
.new-chat-btn {
|
| 56 |
+
margin: 20px 15px;
|
| 57 |
+
padding: 12px;
|
| 58 |
+
background-color: transparent;
|
| 59 |
+
border: 1px solid var(--border-color);
|
| 60 |
+
color: var(--text-primary);
|
| 61 |
+
border-radius: 8px;
|
| 62 |
+
cursor: pointer;
|
| 63 |
+
display: flex;
|
| 64 |
+
align-items: center;
|
| 65 |
+
gap: 12px;
|
| 66 |
+
font-size: 14px;
|
| 67 |
+
font-weight: 500;
|
| 68 |
+
font-family: 'Montserrat', sans-serif;
|
| 69 |
+
transition: background-color 0.2s, border-color 0.2s;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.new-chat-btn:hover { background-color: var(--bg-hover); }
|
| 73 |
+
|
| 74 |
+
/* --- Chat History & Options --- */
|
| 75 |
+
.chat-history {
|
| 76 |
+
flex-grow: 1;
|
| 77 |
+
overflow-y: auto;
|
| 78 |
+
padding: 0 15px;
|
| 79 |
+
display: flex;
|
| 80 |
+
flex-direction: column;
|
| 81 |
+
gap: 2px;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.history-item {
|
| 85 |
+
position: relative;
|
| 86 |
+
padding: 10px 12px;
|
| 87 |
+
border-radius: 8px;
|
| 88 |
+
cursor: pointer;
|
| 89 |
+
color: var(--text-secondary);
|
| 90 |
+
font-size: 14px;
|
| 91 |
+
display: flex;
|
| 92 |
+
justify-content: space-between;
|
| 93 |
+
align-items: center;
|
| 94 |
+
transition: background-color 0.2s, color 0.2s;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.history-item:hover, .history-item.active {
|
| 98 |
+
background-color: var(--bg-hover);
|
| 99 |
+
color: var(--text-primary);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.chat-title {
|
| 103 |
+
flex-grow: 1;
|
| 104 |
+
white-space: nowrap;
|
| 105 |
+
overflow: hidden;
|
| 106 |
+
text-overflow: ellipsis;
|
| 107 |
+
padding-right: 10px;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.options-btn {
|
| 111 |
+
background: none; border: none;
|
| 112 |
+
color: var(--text-secondary);
|
| 113 |
+
font-size: 18px; line-height: 1;
|
| 114 |
+
cursor: pointer; opacity: 0;
|
| 115 |
+
padding: 0 4px;
|
| 116 |
+
transition: opacity 0.2s, color 0.2s;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.history-item:hover .options-btn,
|
| 120 |
+
.options-btn.menu-open { opacity: 1; }
|
| 121 |
+
.options-btn:hover { color: var(--text-primary); }
|
| 122 |
+
|
| 123 |
+
.options-menu {
|
| 124 |
+
position: absolute;
|
| 125 |
+
right: 10px; top: 35px;
|
| 126 |
+
background-color: var(--bg-input);
|
| 127 |
+
border: 1px solid var(--border-color);
|
| 128 |
+
border-radius: 8px;
|
| 129 |
+
display: none;
|
| 130 |
+
flex-direction: column;
|
| 131 |
+
z-index: 100;
|
| 132 |
+
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.8);
|
| 133 |
+
min-width: 120px;
|
| 134 |
+
overflow: hidden;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.options-menu.show { display: flex; }
|
| 138 |
+
|
| 139 |
+
.option-item {
|
| 140 |
+
padding: 10px 15px;
|
| 141 |
+
font-size: 13px;
|
| 142 |
+
color: var(--text-primary);
|
| 143 |
+
cursor: pointer;
|
| 144 |
+
display: flex;
|
| 145 |
+
align-items: center;
|
| 146 |
+
gap: 8px;
|
| 147 |
+
transition: background-color 0.2s;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.option-item:hover { background-color: var(--bg-hover); }
|
| 151 |
+
.option-item.delete { color: #ff4a4a; }
|
| 152 |
+
|
| 153 |
+
.rename-input {
|
| 154 |
+
width: 100%;
|
| 155 |
+
background: transparent; border: none;
|
| 156 |
+
color: var(--text-primary);
|
| 157 |
+
font-family: inherit; font-size: 14px;
|
| 158 |
+
outline: none;
|
| 159 |
+
border-bottom: 1px solid var(--brand-orange);
|
| 160 |
+
padding: 2px 0;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
/* --- Sidebar Footer & Settings --- */
|
| 164 |
+
.sidebar-footer {
|
| 165 |
+
padding: 15px;
|
| 166 |
+
border-top: 1px solid var(--border-color);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
.settings-btn {
|
| 170 |
+
display: flex;
|
| 171 |
+
align-items: center;
|
| 172 |
+
gap: 12px;
|
| 173 |
+
color: var(--text-primary);
|
| 174 |
+
cursor: pointer;
|
| 175 |
+
padding: 10px;
|
| 176 |
+
border-radius: 8px;
|
| 177 |
+
font-size: 14px;
|
| 178 |
+
font-weight: 500;
|
| 179 |
+
transition: background-color 0.2s;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.settings-btn:hover { background-color: var(--bg-hover); }
|
| 183 |
+
|
| 184 |
+
/* --- Settings Panel --- */
|
| 185 |
+
.settings-panel {
|
| 186 |
+
position: fixed;
|
| 187 |
+
top: 0; right: -340px;
|
| 188 |
+
width: 320px; height: 100vh;
|
| 189 |
+
background: var(--bg-sidebar);
|
| 190 |
+
border-left: 1px solid var(--border-color);
|
| 191 |
+
z-index: 200;
|
| 192 |
+
padding: 24px;
|
| 193 |
+
display: flex;
|
| 194 |
+
flex-direction: column;
|
| 195 |
+
gap: 20px;
|
| 196 |
+
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.settings-panel.open { right: 0; }
|
| 200 |
+
|
| 201 |
+
.settings-panel h3 {
|
| 202 |
+
font-size: 16px;
|
| 203 |
+
font-weight: 600;
|
| 204 |
+
color: var(--text-primary);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.persona-option {
|
| 208 |
+
display: flex;
|
| 209 |
+
align-items: center;
|
| 210 |
+
gap: 10px;
|
| 211 |
+
padding: 12px;
|
| 212 |
+
border-radius: 8px;
|
| 213 |
+
border: 1px solid var(--border-color);
|
| 214 |
+
cursor: pointer;
|
| 215 |
+
transition: border-color 0.2s, background-color 0.2s;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.persona-option:hover { background-color: var(--bg-hover); }
|
| 219 |
+
.persona-option.active { border-color: var(--brand-orange); background-color: rgba(235, 90, 40, 0.08); }
|
| 220 |
+
|
| 221 |
+
.persona-option .persona-name {
|
| 222 |
+
font-size: 14px; font-weight: 600;
|
| 223 |
+
color: var(--text-primary);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.persona-option .persona-desc {
|
| 227 |
+
font-size: 12px;
|
| 228 |
+
color: var(--text-secondary);
|
| 229 |
+
margin-top: 2px;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.settings-input {
|
| 233 |
+
width: 100%;
|
| 234 |
+
padding: 10px 14px;
|
| 235 |
+
background: var(--bg-input);
|
| 236 |
+
border: 1px solid var(--border-color);
|
| 237 |
+
border-radius: 8px;
|
| 238 |
+
color: var(--text-primary);
|
| 239 |
+
font-family: 'Montserrat', sans-serif;
|
| 240 |
+
font-size: 14px;
|
| 241 |
+
outline: none;
|
| 242 |
+
transition: border-color 0.2s;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
.settings-input:focus { border-color: var(--brand-orange); }
|
| 246 |
+
.settings-input::placeholder { color: var(--text-secondary); }
|
| 247 |
+
|
| 248 |
+
.settings-select {
|
| 249 |
+
width: 100%;
|
| 250 |
+
padding: 10px 14px;
|
| 251 |
+
background: var(--bg-input);
|
| 252 |
+
border: 1px solid var(--border-color);
|
| 253 |
+
border-radius: 8px;
|
| 254 |
+
color: var(--text-primary);
|
| 255 |
+
font-family: 'Montserrat', sans-serif;
|
| 256 |
+
font-size: 14px;
|
| 257 |
+
outline: none;
|
| 258 |
+
cursor: pointer;
|
| 259 |
+
appearance: none;
|
| 260 |
+
-webkit-appearance: none;
|
| 261 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23777' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
|
| 262 |
+
background-repeat: no-repeat;
|
| 263 |
+
background-position: right 12px center;
|
| 264 |
+
transition: border-color 0.2s;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.settings-select:focus { border-color: var(--brand-orange); }
|
| 268 |
+
.settings-select option { background: var(--bg-input); color: var(--text-primary); }
|
| 269 |
+
|
| 270 |
+
.settings-overlay {
|
| 271 |
+
display: none;
|
| 272 |
+
position: fixed;
|
| 273 |
+
top: 0; left: 0;
|
| 274 |
+
width: 100vw; height: 100vh;
|
| 275 |
+
background: rgba(0,0,0,0.5);
|
| 276 |
+
z-index: 199;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.settings-overlay.show { display: block; }
|
| 280 |
+
|
| 281 |
+
/* --- Main Chat Area --- */
|
| 282 |
+
.main-chat {
|
| 283 |
+
flex-grow: 1;
|
| 284 |
+
display: flex;
|
| 285 |
+
flex-direction: column;
|
| 286 |
+
position: relative;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
.main-header {
|
| 290 |
+
position: absolute;
|
| 291 |
+
top: 0; left: 0;
|
| 292 |
+
width: 100%;
|
| 293 |
+
padding: 15px 20px;
|
| 294 |
+
display: flex;
|
| 295 |
+
align-items: center;
|
| 296 |
+
z-index: 10;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
.toggle-sidebar-btn {
|
| 300 |
+
background: transparent; border: none;
|
| 301 |
+
color: var(--text-secondary);
|
| 302 |
+
cursor: pointer;
|
| 303 |
+
display: flex;
|
| 304 |
+
justify-content: center;
|
| 305 |
+
align-items: center;
|
| 306 |
+
padding: 8px;
|
| 307 |
+
border-radius: 8px;
|
| 308 |
+
transition: background-color 0.2s, color 0.2s;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.toggle-sidebar-btn:hover {
|
| 312 |
+
background-color: var(--bg-hover);
|
| 313 |
+
color: var(--text-primary);
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
.chat-container {
|
| 317 |
+
flex-grow: 1;
|
| 318 |
+
overflow-y: auto;
|
| 319 |
+
padding: 70px 20px 40px 20px;
|
| 320 |
+
display: flex;
|
| 321 |
+
flex-direction: column;
|
| 322 |
+
gap: 24px;
|
| 323 |
+
scroll-behavior: smooth;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
/* --- Messages --- */
|
| 327 |
+
.message-row {
|
| 328 |
+
display: flex;
|
| 329 |
+
width: 100%;
|
| 330 |
+
max-width: 800px;
|
| 331 |
+
margin: 0 auto;
|
| 332 |
+
animation: fadeIn 0.4s ease-out forwards;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.message-row.user { justify-content: flex-end; }
|
| 336 |
+
.message-row.ai { justify-content: flex-start; }
|
| 337 |
+
|
| 338 |
+
.message-content {
|
| 339 |
+
max-width: 80%;
|
| 340 |
+
padding: 14px 18px;
|
| 341 |
+
border-radius: 12px;
|
| 342 |
+
font-size: 15px;
|
| 343 |
+
line-height: 1.7;
|
| 344 |
+
word-wrap: break-word;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
.user .message-content {
|
| 348 |
+
background-color: var(--msg-user);
|
| 349 |
+
border-bottom-right-radius: 4px;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.ai .message-content { background-color: var(--msg-ai); }
|
| 353 |
+
|
| 354 |
+
.ai-avatar {
|
| 355 |
+
width: 30px; height: 30px;
|
| 356 |
+
border-radius: 50%;
|
| 357 |
+
background-image: url('/assets/stem_black.png');
|
| 358 |
+
background-size: cover;
|
| 359 |
+
background-position: center;
|
| 360 |
+
margin-right: 15px;
|
| 361 |
+
flex-shrink: 0;
|
| 362 |
+
margin-top: 5px;
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
/* --- Thinking Indicator --- */
|
| 366 |
+
.thinking-indicator {
|
| 367 |
+
display: flex;
|
| 368 |
+
align-items: center;
|
| 369 |
+
gap: 10px;
|
| 370 |
+
padding: 8px 0;
|
| 371 |
+
animation: fadeIn 0.3s ease-out;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.thinking-indicator.hidden { display: none; }
|
| 375 |
+
|
| 376 |
+
.thinking-logo {
|
| 377 |
+
width: 22px; height: 22px;
|
| 378 |
+
border-radius: 50%;
|
| 379 |
+
animation: pulse 1.5s ease-in-out infinite;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
.thinking-text {
|
| 383 |
+
font-size: 13px;
|
| 384 |
+
color: var(--text-secondary);
|
| 385 |
+
font-style: italic;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
@keyframes pulse {
|
| 389 |
+
0%, 100% { opacity: 0.5; transform: scale(1); }
|
| 390 |
+
50% { opacity: 1; transform: scale(1.08); }
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
/* --- Rendered Markdown in AI messages --- */
|
| 394 |
+
.message-content h1, .message-content h2, .message-content h3 {
|
| 395 |
+
margin-top: 16px; margin-bottom: 8px;
|
| 396 |
+
font-weight: 600;
|
| 397 |
+
}
|
| 398 |
+
.message-content h1 { font-size: 1.3em; }
|
| 399 |
+
.message-content h2 { font-size: 1.15em; }
|
| 400 |
+
.message-content h3 { font-size: 1.05em; }
|
| 401 |
+
|
| 402 |
+
.message-content p { margin-bottom: 10px; }
|
| 403 |
+
|
| 404 |
+
.message-content ul, .message-content ol {
|
| 405 |
+
margin: 8px 0 8px 20px;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.message-content li { margin-bottom: 4px; }
|
| 409 |
+
|
| 410 |
+
.message-content code {
|
| 411 |
+
background: #1a1a1a;
|
| 412 |
+
padding: 2px 6px;
|
| 413 |
+
border-radius: 4px;
|
| 414 |
+
font-family: 'Courier New', monospace;
|
| 415 |
+
font-size: 0.9em;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
.message-content pre {
|
| 419 |
+
background: #0d0d0d;
|
| 420 |
+
border: 1px solid var(--border-color);
|
| 421 |
+
border-radius: 8px;
|
| 422 |
+
padding: 14px;
|
| 423 |
+
overflow-x: auto;
|
| 424 |
+
margin: 10px 0;
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
.message-content pre code {
|
| 428 |
+
background: none;
|
| 429 |
+
padding: 0;
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
.message-content table {
|
| 433 |
+
border-collapse: collapse;
|
| 434 |
+
margin: 10px 0;
|
| 435 |
+
width: 100%;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
.message-content th, .message-content td {
|
| 439 |
+
border: 1px solid var(--border-color);
|
| 440 |
+
padding: 8px 12px;
|
| 441 |
+
text-align: left;
|
| 442 |
+
font-size: 14px;
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
.message-content th {
|
| 446 |
+
background: var(--bg-input);
|
| 447 |
+
font-weight: 600;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
.message-content blockquote {
|
| 451 |
+
border-left: 3px solid var(--brand-orange);
|
| 452 |
+
padding-left: 12px;
|
| 453 |
+
margin: 10px 0;
|
| 454 |
+
color: var(--text-secondary);
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
/* KaTeX overrides for dark mode */
|
| 458 |
+
.katex { color: var(--text-primary); }
|
| 459 |
+
.katex-display { margin: 14px 0; overflow-x: auto; }
|
| 460 |
+
|
| 461 |
+
/* --- Input Area --- */
|
| 462 |
+
.input-container {
|
| 463 |
+
padding: 20px 20px 10px 20px;
|
| 464 |
+
background: linear-gradient(180deg, transparent, var(--bg-main) 20%);
|
| 465 |
+
display: flex;
|
| 466 |
+
flex-direction: column;
|
| 467 |
+
align-items: center;
|
| 468 |
+
z-index: 10;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
.input-box {
|
| 472 |
+
width: 100%; max-width: 800px;
|
| 473 |
+
position: relative;
|
| 474 |
+
background-color: var(--bg-input);
|
| 475 |
+
border: 1px solid var(--border-color);
|
| 476 |
+
border-radius: 16px;
|
| 477 |
+
transition: border-color 0.3s;
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
.input-box:focus-within { border-color: var(--brand-orange); }
|
| 481 |
+
|
| 482 |
+
textarea {
|
| 483 |
+
width: 100%;
|
| 484 |
+
background: transparent; border: none;
|
| 485 |
+
color: var(--text-primary);
|
| 486 |
+
padding: 16px 50px 16px 20px;
|
| 487 |
+
font-family: 'Montserrat', sans-serif;
|
| 488 |
+
font-size: 15px;
|
| 489 |
+
resize: none; outline: none;
|
| 490 |
+
height: 56px; max-height: 200px;
|
| 491 |
+
line-height: 1.5;
|
| 492 |
+
overflow-y: hidden;
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
textarea::placeholder { color: var(--text-secondary); }
|
| 496 |
+
|
| 497 |
+
.send-btn {
|
| 498 |
+
position: absolute;
|
| 499 |
+
right: 10px; bottom: 10px;
|
| 500 |
+
width: 36px; height: 36px;
|
| 501 |
+
border-radius: 10px;
|
| 502 |
+
background-color: transparent; border: none;
|
| 503 |
+
color: var(--text-secondary);
|
| 504 |
+
cursor: pointer;
|
| 505 |
+
display: flex;
|
| 506 |
+
justify-content: center;
|
| 507 |
+
align-items: center;
|
| 508 |
+
transition: color 0.2s, transform 0.1s;
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
.send-btn:hover { color: var(--text-primary); }
|
| 512 |
+
.send-btn:active { transform: scale(0.95); }
|
| 513 |
+
.send-btn svg { width: 20px; height: 20px; fill: currentColor; }
|
| 514 |
+
|
| 515 |
+
.disclaimer-text {
|
| 516 |
+
font-size: 12px;
|
| 517 |
+
color: #555555;
|
| 518 |
+
margin-top: 12px;
|
| 519 |
+
text-align: center;
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
.welcome-placeholder {
|
| 523 |
+
display: flex;
|
| 524 |
+
align-items: center;
|
| 525 |
+
justify-content: center;
|
| 526 |
+
flex-grow: 1;
|
| 527 |
+
font-family: 'Montserrat', sans-serif;
|
| 528 |
+
font-size: 28px;
|
| 529 |
+
font-weight: 600;
|
| 530 |
+
color: var(--text-secondary);
|
| 531 |
+
user-select: none;
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
/* --- Animations --- */
|
| 535 |
+
@keyframes fadeIn {
|
| 536 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 537 |
+
to { opacity: 1; transform: translateY(0); }
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
/* --- Scrollbar --- */
|
| 541 |
+
::-webkit-scrollbar { width: 6px; }
|
| 542 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 543 |
+
::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; }
|
| 544 |
+
::-webkit-scrollbar-thumb:hover { background: #555; }
|
| 545 |
+
|
| 546 |
+
/* --- Streaming cursor --- */
|
| 547 |
+
.cursor::after {
|
| 548 |
+
content: '\25CB';
|
| 549 |
+
animation: blink 1s step-start infinite;
|
| 550 |
+
color: var(--text-secondary);
|
| 551 |
+
margin-left: 2px;
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
@keyframes blink { 50% { opacity: 0; } }
|
stem.html
DELETED
|
@@ -1,796 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
-
<title>STEMbotix AI Chat</title>
|
| 7 |
-
<link rel="icon" type="image/png" href="assets/stem_black.png">
|
| 8 |
-
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600&display=swap" rel="stylesheet">
|
| 9 |
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
|
| 10 |
-
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
|
| 11 |
-
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script>
|
| 12 |
-
|
| 13 |
-
<style>
|
| 14 |
-
:root {
|
| 15 |
-
/* Branding colors */
|
| 16 |
-
--brand-orange: #EB5A28;
|
| 17 |
-
--brand-orange-hover: #cf4f22;
|
| 18 |
-
|
| 19 |
-
/* Lights Out Dark Mode Palette */
|
| 20 |
-
--bg-main: #000000;
|
| 21 |
-
--bg-sidebar: #070707;
|
| 22 |
-
--bg-input: #121212;
|
| 23 |
-
--bg-hover: #1A1A1A;
|
| 24 |
-
--text-primary: #EDEDED;
|
| 25 |
-
--text-secondary: #777777;
|
| 26 |
-
--border-color: #222222;
|
| 27 |
-
--msg-user: #141414;
|
| 28 |
-
--msg-ai: transparent;
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
* {
|
| 32 |
-
box-sizing: border-box;
|
| 33 |
-
margin: 0;
|
| 34 |
-
padding: 0;
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
body {
|
| 38 |
-
font-family: 'Montserrat', sans-serif;
|
| 39 |
-
background-color: var(--bg-main);
|
| 40 |
-
color: var(--text-primary);
|
| 41 |
-
display: flex;
|
| 42 |
-
height: 100vh;
|
| 43 |
-
overflow: hidden;
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
/* --- Sidebar Styles --- */
|
| 47 |
-
.sidebar {
|
| 48 |
-
width: 260px;
|
| 49 |
-
flex-shrink: 0;
|
| 50 |
-
background-color: var(--bg-sidebar);
|
| 51 |
-
display: flex;
|
| 52 |
-
flex-direction: column;
|
| 53 |
-
border-right: 1px solid var(--border-color);
|
| 54 |
-
transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 55 |
-
position: relative;
|
| 56 |
-
z-index: 20;
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
.sidebar.collapsed {
|
| 60 |
-
margin-left: -260px;
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
.sidebar-header {
|
| 64 |
-
padding: 20px;
|
| 65 |
-
display: flex;
|
| 66 |
-
justify-content: center;
|
| 67 |
-
align-items: center;
|
| 68 |
-
border-bottom: 1px solid var(--border-color);
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
-
.brand-logo {
|
| 72 |
-
max-width: 80%;
|
| 73 |
-
height: auto;
|
| 74 |
-
object-fit: contain;
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
.new-chat-btn {
|
| 78 |
-
margin: 20px 15px;
|
| 79 |
-
padding: 12px;
|
| 80 |
-
background-color: transparent;
|
| 81 |
-
border: 1px solid var(--border-color);
|
| 82 |
-
color: var(--text-primary);
|
| 83 |
-
border-radius: 8px;
|
| 84 |
-
cursor: pointer;
|
| 85 |
-
display: flex;
|
| 86 |
-
align-items: center;
|
| 87 |
-
gap: 12px;
|
| 88 |
-
font-size: 14px;
|
| 89 |
-
font-weight: 500;
|
| 90 |
-
font-family: 'Montserrat', sans-serif;
|
| 91 |
-
transition: background-color 0.2s, border-color 0.2s;
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
.new-chat-btn:hover {
|
| 95 |
-
background-color: var(--bg-hover);
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
/* --- Chat History & Options Menu --- */
|
| 99 |
-
.chat-history {
|
| 100 |
-
flex-grow: 1;
|
| 101 |
-
overflow-y: auto;
|
| 102 |
-
padding: 0 15px;
|
| 103 |
-
display: flex;
|
| 104 |
-
flex-direction: column;
|
| 105 |
-
gap: 2px;
|
| 106 |
-
}
|
| 107 |
-
|
| 108 |
-
.history-item {
|
| 109 |
-
position: relative;
|
| 110 |
-
padding: 10px 12px;
|
| 111 |
-
border-radius: 8px;
|
| 112 |
-
cursor: pointer;
|
| 113 |
-
color: var(--text-secondary);
|
| 114 |
-
font-size: 14px;
|
| 115 |
-
display: flex;
|
| 116 |
-
justify-content: space-between;
|
| 117 |
-
align-items: center;
|
| 118 |
-
transition: background-color 0.2s, color 0.2s;
|
| 119 |
-
}
|
| 120 |
-
|
| 121 |
-
.history-item:hover, .history-item.active {
|
| 122 |
-
background-color: var(--bg-hover);
|
| 123 |
-
color: var(--text-primary);
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
.chat-title {
|
| 127 |
-
flex-grow: 1;
|
| 128 |
-
white-space: nowrap;
|
| 129 |
-
overflow: hidden;
|
| 130 |
-
text-overflow: ellipsis;
|
| 131 |
-
padding-right: 10px;
|
| 132 |
-
}
|
| 133 |
-
|
| 134 |
-
.options-btn {
|
| 135 |
-
background: none;
|
| 136 |
-
border: none;
|
| 137 |
-
color: var(--text-secondary);
|
| 138 |
-
font-size: 18px;
|
| 139 |
-
line-height: 1;
|
| 140 |
-
cursor: pointer;
|
| 141 |
-
opacity: 0;
|
| 142 |
-
padding: 0 4px;
|
| 143 |
-
transition: opacity 0.2s, color 0.2s;
|
| 144 |
-
}
|
| 145 |
-
|
| 146 |
-
.history-item:hover .options-btn,
|
| 147 |
-
.options-btn.menu-open {
|
| 148 |
-
opacity: 1;
|
| 149 |
-
}
|
| 150 |
-
|
| 151 |
-
.options-btn:hover {
|
| 152 |
-
color: var(--text-primary);
|
| 153 |
-
}
|
| 154 |
-
|
| 155 |
-
.options-menu {
|
| 156 |
-
position: absolute;
|
| 157 |
-
right: 10px;
|
| 158 |
-
top: 35px;
|
| 159 |
-
background-color: var(--bg-input);
|
| 160 |
-
border: 1px solid var(--border-color);
|
| 161 |
-
border-radius: 8px;
|
| 162 |
-
display: none;
|
| 163 |
-
flex-direction: column;
|
| 164 |
-
z-index: 100;
|
| 165 |
-
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.8);
|
| 166 |
-
min-width: 120px;
|
| 167 |
-
overflow: hidden;
|
| 168 |
-
}
|
| 169 |
-
|
| 170 |
-
.options-menu.show {
|
| 171 |
-
display: flex;
|
| 172 |
-
}
|
| 173 |
-
|
| 174 |
-
.option-item {
|
| 175 |
-
padding: 10px 15px;
|
| 176 |
-
font-size: 13px;
|
| 177 |
-
color: var(--text-primary);
|
| 178 |
-
cursor: pointer;
|
| 179 |
-
display: flex;
|
| 180 |
-
align-items: center;
|
| 181 |
-
gap: 8px;
|
| 182 |
-
transition: background-color 0.2s;
|
| 183 |
-
}
|
| 184 |
-
|
| 185 |
-
.option-item:hover {
|
| 186 |
-
background-color: var(--bg-hover);
|
| 187 |
-
}
|
| 188 |
-
|
| 189 |
-
.option-item.delete {
|
| 190 |
-
color: #ff4a4a;
|
| 191 |
-
}
|
| 192 |
-
|
| 193 |
-
.rename-input {
|
| 194 |
-
width: 100%;
|
| 195 |
-
background: transparent;
|
| 196 |
-
border: none;
|
| 197 |
-
color: var(--text-primary);
|
| 198 |
-
font-family: inherit;
|
| 199 |
-
font-size: 14px;
|
| 200 |
-
outline: none;
|
| 201 |
-
border-bottom: 1px solid var(--brand-orange);
|
| 202 |
-
padding: 2px 0;
|
| 203 |
-
}
|
| 204 |
-
|
| 205 |
-
.sidebar-footer {
|
| 206 |
-
padding: 15px;
|
| 207 |
-
border-top: 1px solid var(--border-color);
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
-
.settings-btn {
|
| 211 |
-
display: flex;
|
| 212 |
-
align-items: center;
|
| 213 |
-
gap: 12px;
|
| 214 |
-
color: var(--text-primary);
|
| 215 |
-
cursor: pointer;
|
| 216 |
-
padding: 10px;
|
| 217 |
-
border-radius: 8px;
|
| 218 |
-
font-size: 14px;
|
| 219 |
-
font-weight: 500;
|
| 220 |
-
transition: background-color 0.2s;
|
| 221 |
-
}
|
| 222 |
-
|
| 223 |
-
.settings-btn:hover {
|
| 224 |
-
background-color: var(--bg-hover);
|
| 225 |
-
}
|
| 226 |
-
|
| 227 |
-
/* --- Main Chat Area Styles --- */
|
| 228 |
-
.main-chat {
|
| 229 |
-
flex-grow: 1;
|
| 230 |
-
display: flex;
|
| 231 |
-
flex-direction: column;
|
| 232 |
-
position: relative;
|
| 233 |
-
}
|
| 234 |
-
|
| 235 |
-
/* Header for Sidebar Toggle */
|
| 236 |
-
.main-header {
|
| 237 |
-
position: absolute;
|
| 238 |
-
top: 0;
|
| 239 |
-
left: 0;
|
| 240 |
-
width: 100%;
|
| 241 |
-
padding: 15px 20px;
|
| 242 |
-
display: flex;
|
| 243 |
-
align-items: center;
|
| 244 |
-
z-index: 10;
|
| 245 |
-
}
|
| 246 |
-
|
| 247 |
-
.toggle-sidebar-btn {
|
| 248 |
-
background: transparent;
|
| 249 |
-
border: none;
|
| 250 |
-
color: var(--text-secondary);
|
| 251 |
-
cursor: pointer;
|
| 252 |
-
display: flex;
|
| 253 |
-
justify-content: center;
|
| 254 |
-
align-items: center;
|
| 255 |
-
padding: 8px;
|
| 256 |
-
border-radius: 8px;
|
| 257 |
-
transition: background-color 0.2s, color 0.2s;
|
| 258 |
-
}
|
| 259 |
-
|
| 260 |
-
.toggle-sidebar-btn:hover {
|
| 261 |
-
background-color: var(--bg-hover);
|
| 262 |
-
color: var(--text-primary);
|
| 263 |
-
}
|
| 264 |
-
|
| 265 |
-
.chat-container {
|
| 266 |
-
flex-grow: 1;
|
| 267 |
-
overflow-y: auto;
|
| 268 |
-
padding: 70px 20px 40px 20px; /* Top padding accommodates the header */
|
| 269 |
-
display: flex;
|
| 270 |
-
flex-direction: column;
|
| 271 |
-
gap: 24px;
|
| 272 |
-
scroll-behavior: smooth;
|
| 273 |
-
}
|
| 274 |
-
|
| 275 |
-
.message-row {
|
| 276 |
-
display: flex;
|
| 277 |
-
width: 100%;
|
| 278 |
-
max-width: 800px;
|
| 279 |
-
margin: 0 auto;
|
| 280 |
-
animation: fadeIn 0.4s ease-out forwards;
|
| 281 |
-
}
|
| 282 |
-
|
| 283 |
-
.message-row.user {
|
| 284 |
-
justify-content: flex-end;
|
| 285 |
-
}
|
| 286 |
-
|
| 287 |
-
.message-row.ai {
|
| 288 |
-
justify-content: flex-start;
|
| 289 |
-
}
|
| 290 |
-
|
| 291 |
-
.message-content {
|
| 292 |
-
max-width: 80%;
|
| 293 |
-
padding: 14px 18px;
|
| 294 |
-
border-radius: 12px;
|
| 295 |
-
font-size: 15px;
|
| 296 |
-
line-height: 1.6;
|
| 297 |
-
word-wrap: break-word;
|
| 298 |
-
}
|
| 299 |
-
|
| 300 |
-
.user .message-content {
|
| 301 |
-
background-color: var(--msg-user);
|
| 302 |
-
border-bottom-right-radius: 4px;
|
| 303 |
-
}
|
| 304 |
-
|
| 305 |
-
.ai .message-content {
|
| 306 |
-
background-color: var(--msg-ai);
|
| 307 |
-
}
|
| 308 |
-
|
| 309 |
-
.ai-avatar {
|
| 310 |
-
width: 30px;
|
| 311 |
-
height: 30px;
|
| 312 |
-
border-radius: 50%;
|
| 313 |
-
background-image: url('assets/stem_black.png');
|
| 314 |
-
background-size: cover;
|
| 315 |
-
background-position: center;
|
| 316 |
-
margin-right: 15px;
|
| 317 |
-
flex-shrink: 0;
|
| 318 |
-
margin-top: 5px;
|
| 319 |
-
}
|
| 320 |
-
|
| 321 |
-
/* --- Input Area Styles --- */
|
| 322 |
-
.input-container {
|
| 323 |
-
padding: 20px 20px 10px 20px;
|
| 324 |
-
background: linear-gradient(180deg, transparent, var(--bg-main) 20%);
|
| 325 |
-
display: flex;
|
| 326 |
-
flex-direction: column;
|
| 327 |
-
align-items: center;
|
| 328 |
-
z-index: 10;
|
| 329 |
-
}
|
| 330 |
-
|
| 331 |
-
.input-box {
|
| 332 |
-
width: 100%;
|
| 333 |
-
max-width: 800px;
|
| 334 |
-
position: relative;
|
| 335 |
-
background-color: var(--bg-input);
|
| 336 |
-
border: 1px solid var(--border-color);
|
| 337 |
-
border-radius: 16px;
|
| 338 |
-
transition: border-color 0.3s;
|
| 339 |
-
}
|
| 340 |
-
|
| 341 |
-
.input-box:focus-within {
|
| 342 |
-
border-color: var(--brand-orange);
|
| 343 |
-
}
|
| 344 |
-
|
| 345 |
-
textarea {
|
| 346 |
-
width: 100%;
|
| 347 |
-
background: transparent;
|
| 348 |
-
border: none;
|
| 349 |
-
color: var(--text-primary);
|
| 350 |
-
padding: 16px 50px 16px 20px;
|
| 351 |
-
font-family: 'Montserrat', sans-serif;
|
| 352 |
-
font-size: 15px;
|
| 353 |
-
resize: none;
|
| 354 |
-
outline: none;
|
| 355 |
-
height: 56px;
|
| 356 |
-
max-height: 200px;
|
| 357 |
-
line-height: 1.5;
|
| 358 |
-
overflow-y: hidden;
|
| 359 |
-
}
|
| 360 |
-
|
| 361 |
-
textarea::placeholder {
|
| 362 |
-
color: var(--text-secondary);
|
| 363 |
-
}
|
| 364 |
-
|
| 365 |
-
.send-btn {
|
| 366 |
-
position: absolute;
|
| 367 |
-
right: 10px;
|
| 368 |
-
bottom: 10px;
|
| 369 |
-
width: 36px;
|
| 370 |
-
height: 36px;
|
| 371 |
-
border-radius: 10px;
|
| 372 |
-
background-color: transparent;
|
| 373 |
-
border: none;
|
| 374 |
-
color: var(--text-secondary);
|
| 375 |
-
cursor: pointer;
|
| 376 |
-
display: flex;
|
| 377 |
-
justify-content: center;
|
| 378 |
-
align-items: center;
|
| 379 |
-
transition: color 0.2s, transform 0.1s;
|
| 380 |
-
}
|
| 381 |
-
|
| 382 |
-
.send-btn:hover {
|
| 383 |
-
color: var(--text-primary);
|
| 384 |
-
}
|
| 385 |
-
|
| 386 |
-
.send-btn:active {
|
| 387 |
-
transform: scale(0.95);
|
| 388 |
-
}
|
| 389 |
-
|
| 390 |
-
.send-btn svg {
|
| 391 |
-
width: 20px;
|
| 392 |
-
height: 20px;
|
| 393 |
-
fill: currentColor;
|
| 394 |
-
}
|
| 395 |
-
|
| 396 |
-
.disclaimer-text {
|
| 397 |
-
font-size: 12px;
|
| 398 |
-
color: #555555;
|
| 399 |
-
margin-top: 12px;
|
| 400 |
-
text-align: center;
|
| 401 |
-
}
|
| 402 |
-
|
| 403 |
-
.welcome-placeholder {
|
| 404 |
-
display: flex;
|
| 405 |
-
align-items: center;
|
| 406 |
-
justify-content: center;
|
| 407 |
-
flex-grow: 1;
|
| 408 |
-
font-family: 'Montserrat', sans-serif;
|
| 409 |
-
font-size: 28px;
|
| 410 |
-
font-weight: 600;
|
| 411 |
-
color: var(--text-secondary);
|
| 412 |
-
user-select: none;
|
| 413 |
-
}
|
| 414 |
-
|
| 415 |
-
/* Animations */
|
| 416 |
-
@keyframes fadeIn {
|
| 417 |
-
from { opacity: 0; transform: translateY(10px); }
|
| 418 |
-
to { opacity: 1; transform: translateY(0); }
|
| 419 |
-
}
|
| 420 |
-
|
| 421 |
-
/* Scrollbar Styling */
|
| 422 |
-
::-webkit-scrollbar { width: 6px; }
|
| 423 |
-
::-webkit-scrollbar-track { background: transparent; }
|
| 424 |
-
::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; }
|
| 425 |
-
::-webkit-scrollbar-thumb:hover { background: #555; }
|
| 426 |
-
|
| 427 |
-
.cursor::after {
|
| 428 |
-
content: '▋';
|
| 429 |
-
animation: blink 1s step-start infinite;
|
| 430 |
-
color: var(--text-secondary);
|
| 431 |
-
margin-left: 2px;
|
| 432 |
-
}
|
| 433 |
-
@keyframes blink { 50% { opacity: 0; } }
|
| 434 |
-
</style>
|
| 435 |
-
</head>
|
| 436 |
-
<body>
|
| 437 |
-
|
| 438 |
-
<aside class="sidebar" id="sidebar">
|
| 439 |
-
<div class="sidebar-header">
|
| 440 |
-
<img src="assets/stembotix.png" alt="STEMbotix Logo" class="brand-logo">
|
| 441 |
-
</div>
|
| 442 |
-
|
| 443 |
-
<button class="new-chat-btn">
|
| 444 |
-
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 445 |
-
<path d="M12 5v14M5 12h14"/>
|
| 446 |
-
</svg>
|
| 447 |
-
New Chat
|
| 448 |
-
</button>
|
| 449 |
-
|
| 450 |
-
<div class="chat-history" id="chatHistoryList">
|
| 451 |
-
</div>
|
| 452 |
-
|
| 453 |
-
<div class="sidebar-footer">
|
| 454 |
-
<div class="settings-btn">
|
| 455 |
-
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 456 |
-
<circle cx="12" cy="12" r="3"></circle>
|
| 457 |
-
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
| 458 |
-
</svg>
|
| 459 |
-
Settings
|
| 460 |
-
</div>
|
| 461 |
-
</div>
|
| 462 |
-
</aside>
|
| 463 |
-
|
| 464 |
-
<main class="main-chat">
|
| 465 |
-
|
| 466 |
-
<header class="main-header">
|
| 467 |
-
<button class="toggle-sidebar-btn" id="toggleSidebarBtn" title="Toggle Sidebar">
|
| 468 |
-
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 469 |
-
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
| 470 |
-
<line x1="9" y1="3" x2="9" y2="21"></line>
|
| 471 |
-
</svg>
|
| 472 |
-
</button>
|
| 473 |
-
</header>
|
| 474 |
-
|
| 475 |
-
<div class="chat-container" id="chatContainer">
|
| 476 |
-
<div class="welcome-placeholder" id="welcomePlaceholder">Where should we start?</div>
|
| 477 |
-
</div>
|
| 478 |
-
|
| 479 |
-
<div class="input-container">
|
| 480 |
-
<div class="input-box">
|
| 481 |
-
<textarea id="userInput" placeholder="Ask StemGraph AI..." rows="1"></textarea>
|
| 482 |
-
<button class="send-btn" id="sendBtn">
|
| 483 |
-
<svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"></path></svg>
|
| 484 |
-
</button>
|
| 485 |
-
</div>
|
| 486 |
-
<div class="disclaimer-text">
|
| 487 |
-
StemGraph is an AI agent and can make mistakes.
|
| 488 |
-
</div>
|
| 489 |
-
</div>
|
| 490 |
-
</main>
|
| 491 |
-
|
| 492 |
-
<script>
|
| 493 |
-
/* =========================================
|
| 494 |
-
State
|
| 495 |
-
========================================= */
|
| 496 |
-
let currentThreadId = crypto.randomUUID();
|
| 497 |
-
const threads = []; // {id, title}
|
| 498 |
-
let isSending = false;
|
| 499 |
-
|
| 500 |
-
/* =========================================
|
| 501 |
-
Sidebar Toggle Logic
|
| 502 |
-
========================================= */
|
| 503 |
-
const sidebar = document.getElementById('sidebar');
|
| 504 |
-
const toggleSidebarBtn = document.getElementById('toggleSidebarBtn');
|
| 505 |
-
|
| 506 |
-
toggleSidebarBtn.addEventListener('click', () => {
|
| 507 |
-
sidebar.classList.toggle('collapsed');
|
| 508 |
-
});
|
| 509 |
-
|
| 510 |
-
/* =========================================
|
| 511 |
-
Chat History Sidebar
|
| 512 |
-
========================================= */
|
| 513 |
-
const chatHistoryList = document.getElementById('chatHistoryList');
|
| 514 |
-
|
| 515 |
-
function addThreadToSidebar(id, title) {
|
| 516 |
-
threads.unshift({id: id, title: title});
|
| 517 |
-
renderHistory();
|
| 518 |
-
}
|
| 519 |
-
|
| 520 |
-
function renderHistory() {
|
| 521 |
-
chatHistoryList.innerHTML = '';
|
| 522 |
-
threads.forEach((thread) => {
|
| 523 |
-
const item = document.createElement('div');
|
| 524 |
-
item.className = `history-item ${thread.id === currentThreadId ? 'active' : ''}`;
|
| 525 |
-
item.setAttribute('data-thread-id', thread.id);
|
| 526 |
-
item.innerHTML = `
|
| 527 |
-
<span class="chat-title">${thread.title}</span>
|
| 528 |
-
<button class="options-btn" onclick="toggleMenu(event, this)">⋮</button>
|
| 529 |
-
<div class="options-menu">
|
| 530 |
-
<div class="option-item" onclick="renameChat(event, this)">
|
| 531 |
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
|
| 532 |
-
Rename
|
| 533 |
-
</div>
|
| 534 |
-
<div class="option-item delete" onclick="deleteChat(event, this)">
|
| 535 |
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>
|
| 536 |
-
Delete
|
| 537 |
-
</div>
|
| 538 |
-
</div>
|
| 539 |
-
`;
|
| 540 |
-
item.addEventListener('click', () => loadThread(thread.id));
|
| 541 |
-
chatHistoryList.appendChild(item);
|
| 542 |
-
});
|
| 543 |
-
}
|
| 544 |
-
|
| 545 |
-
function loadThread(threadId) {
|
| 546 |
-
currentThreadId = threadId;
|
| 547 |
-
renderHistory();
|
| 548 |
-
chatContainer.innerHTML = '';
|
| 549 |
-
|
| 550 |
-
fetch('/history/' + threadId)
|
| 551 |
-
.then(res => res.json())
|
| 552 |
-
.then(data => {
|
| 553 |
-
data.messages.forEach(msg => {
|
| 554 |
-
const sender = msg.role === 'user' ? 'user' : 'ai';
|
| 555 |
-
const el = appendMessage(sender, msg.content);
|
| 556 |
-
if (sender === 'ai') renderMath(el);
|
| 557 |
-
});
|
| 558 |
-
});
|
| 559 |
-
}
|
| 560 |
-
|
| 561 |
-
/* =========================================
|
| 562 |
-
New Chat
|
| 563 |
-
========================================= */
|
| 564 |
-
document.querySelector('.new-chat-btn').addEventListener('click', () => {
|
| 565 |
-
currentThreadId = crypto.randomUUID();
|
| 566 |
-
chatContainer.innerHTML = '<div class="welcome-placeholder" id="welcomePlaceholder">Where should we start?</div>';
|
| 567 |
-
renderHistory();
|
| 568 |
-
});
|
| 569 |
-
|
| 570 |
-
/* =========================================
|
| 571 |
-
Options Menu (Rename / Delete)
|
| 572 |
-
========================================= */
|
| 573 |
-
function toggleMenu(e, btn) {
|
| 574 |
-
e.stopPropagation();
|
| 575 |
-
closeAllMenus();
|
| 576 |
-
const menu = btn.nextElementSibling;
|
| 577 |
-
menu.classList.add('show');
|
| 578 |
-
btn.classList.add('menu-open');
|
| 579 |
-
}
|
| 580 |
-
|
| 581 |
-
document.addEventListener('click', closeAllMenus);
|
| 582 |
-
|
| 583 |
-
function closeAllMenus() {
|
| 584 |
-
document.querySelectorAll('.options-menu.show').forEach(menu => {
|
| 585 |
-
menu.classList.remove('show');
|
| 586 |
-
});
|
| 587 |
-
document.querySelectorAll('.options-btn.menu-open').forEach(btn => {
|
| 588 |
-
btn.classList.remove('menu-open');
|
| 589 |
-
});
|
| 590 |
-
}
|
| 591 |
-
|
| 592 |
-
function deleteChat(e, optionEl) {
|
| 593 |
-
e.stopPropagation();
|
| 594 |
-
const item = optionEl.closest('.history-item');
|
| 595 |
-
const threadId = item.getAttribute('data-thread-id');
|
| 596 |
-
const idx = threads.findIndex(t => t.id === threadId);
|
| 597 |
-
if (idx !== -1) threads.splice(idx, 1);
|
| 598 |
-
item.style.opacity = '0';
|
| 599 |
-
setTimeout(() => { item.remove(); }, 200);
|
| 600 |
-
fetch('/thread/' + threadId, {method: 'DELETE'});
|
| 601 |
-
}
|
| 602 |
-
|
| 603 |
-
function renameChat(e, optionEl) {
|
| 604 |
-
e.stopPropagation();
|
| 605 |
-
const item = optionEl.closest('.history-item');
|
| 606 |
-
const titleSpan = item.querySelector('.chat-title');
|
| 607 |
-
const threadId = item.getAttribute('data-thread-id');
|
| 608 |
-
closeAllMenus();
|
| 609 |
-
|
| 610 |
-
const currentTitle = titleSpan.innerText;
|
| 611 |
-
const input = document.createElement('input');
|
| 612 |
-
input.type = 'text';
|
| 613 |
-
input.value = currentTitle;
|
| 614 |
-
input.className = 'rename-input';
|
| 615 |
-
|
| 616 |
-
titleSpan.replaceWith(input);
|
| 617 |
-
input.focus();
|
| 618 |
-
input.selectionStart = input.selectionEnd = input.value.length;
|
| 619 |
-
|
| 620 |
-
function saveRename() {
|
| 621 |
-
const newTitle = input.value.trim() || 'Untitled Chat';
|
| 622 |
-
titleSpan.innerText = newTitle;
|
| 623 |
-
input.replaceWith(titleSpan);
|
| 624 |
-
const thread = threads.find(t => t.id === threadId);
|
| 625 |
-
if (thread) thread.title = newTitle;
|
| 626 |
-
fetch('/rename', {
|
| 627 |
-
method: 'POST',
|
| 628 |
-
headers: {'Content-Type': 'application/json'},
|
| 629 |
-
body: JSON.stringify({thread_id: threadId, title: newTitle})
|
| 630 |
-
});
|
| 631 |
-
}
|
| 632 |
-
|
| 633 |
-
input.addEventListener('blur', saveRename);
|
| 634 |
-
input.addEventListener('keydown', (evt) => {
|
| 635 |
-
if (evt.key === 'Enter') saveRename();
|
| 636 |
-
if (evt.key === 'Escape') {
|
| 637 |
-
titleSpan.innerText = currentTitle;
|
| 638 |
-
input.replaceWith(titleSpan);
|
| 639 |
-
}
|
| 640 |
-
});
|
| 641 |
-
input.addEventListener('click', (evt) => evt.stopPropagation());
|
| 642 |
-
}
|
| 643 |
-
|
| 644 |
-
/* =========================================
|
| 645 |
-
Chat Messaging Logic
|
| 646 |
-
========================================= */
|
| 647 |
-
const chatContainer = document.getElementById('chatContainer');
|
| 648 |
-
const userInput = document.getElementById('userInput');
|
| 649 |
-
const sendBtn = document.getElementById('sendBtn');
|
| 650 |
-
|
| 651 |
-
userInput.addEventListener('input', function() {
|
| 652 |
-
this.style.height = '56px';
|
| 653 |
-
this.style.height = (this.scrollHeight) + 'px';
|
| 654 |
-
|
| 655 |
-
if (this.value.trim().length > 0) {
|
| 656 |
-
sendBtn.style.color = 'var(--text-primary)';
|
| 657 |
-
} else {
|
| 658 |
-
sendBtn.style.color = 'var(--text-secondary)';
|
| 659 |
-
}
|
| 660 |
-
});
|
| 661 |
-
|
| 662 |
-
userInput.addEventListener('keydown', function(e) {
|
| 663 |
-
if (e.key === 'Enter' && !e.shiftKey) {
|
| 664 |
-
e.preventDefault();
|
| 665 |
-
sendMessage();
|
| 666 |
-
}
|
| 667 |
-
});
|
| 668 |
-
|
| 669 |
-
sendBtn.addEventListener('click', sendMessage);
|
| 670 |
-
|
| 671 |
-
function sendMessage() {
|
| 672 |
-
const text = userInput.value.trim();
|
| 673 |
-
if (!text || isSending) return;
|
| 674 |
-
|
| 675 |
-
// Remove welcome placeholder if present
|
| 676 |
-
const placeholder = document.getElementById('welcomePlaceholder');
|
| 677 |
-
if (placeholder) placeholder.remove();
|
| 678 |
-
|
| 679 |
-
isSending = true;
|
| 680 |
-
appendMessage('user', text);
|
| 681 |
-
userInput.value = '';
|
| 682 |
-
userInput.style.height = '56px';
|
| 683 |
-
sendBtn.style.color = 'var(--text-secondary)';
|
| 684 |
-
|
| 685 |
-
// Add thread to sidebar or move existing to top
|
| 686 |
-
const exists = threads.find(t => t.id === currentThreadId);
|
| 687 |
-
if (!exists) {
|
| 688 |
-
const title = text.length > 30 ? text.substring(0, 30) + '...' : text;
|
| 689 |
-
addThreadToSidebar(currentThreadId, title);
|
| 690 |
-
} else {
|
| 691 |
-
const idx = threads.indexOf(exists);
|
| 692 |
-
threads.splice(idx, 1);
|
| 693 |
-
threads.unshift(exists);
|
| 694 |
-
renderHistory();
|
| 695 |
-
}
|
| 696 |
-
|
| 697 |
-
streamResponse(text);
|
| 698 |
-
}
|
| 699 |
-
|
| 700 |
-
function streamResponse(text) {
|
| 701 |
-
const contentElement = appendMessage('ai', '');
|
| 702 |
-
contentElement.classList.add('cursor');
|
| 703 |
-
|
| 704 |
-
fetch('/chat', {
|
| 705 |
-
method: 'POST',
|
| 706 |
-
headers: {'Content-Type': 'application/json'},
|
| 707 |
-
body: JSON.stringify({message: text, thread_id: currentThreadId})
|
| 708 |
-
}).then(response => {
|
| 709 |
-
const reader = response.body.getReader();
|
| 710 |
-
const decoder = new TextDecoder();
|
| 711 |
-
let buffer = '';
|
| 712 |
-
|
| 713 |
-
function read() {
|
| 714 |
-
reader.read().then(({done, value}) => {
|
| 715 |
-
if (done) {
|
| 716 |
-
contentElement.classList.remove('cursor');
|
| 717 |
-
renderMath(contentElement);
|
| 718 |
-
isSending = false;
|
| 719 |
-
return;
|
| 720 |
-
}
|
| 721 |
-
|
| 722 |
-
buffer += decoder.decode(value, {stream: true});
|
| 723 |
-
const lines = buffer.split('\n');
|
| 724 |
-
buffer = lines.pop();
|
| 725 |
-
|
| 726 |
-
lines.forEach(line => {
|
| 727 |
-
if (!line.startsWith('data: ')) return;
|
| 728 |
-
const payload = line.substring(6);
|
| 729 |
-
if (payload === '[DONE]') {
|
| 730 |
-
contentElement.classList.remove('cursor');
|
| 731 |
-
renderMath(contentElement);
|
| 732 |
-
isSending = false;
|
| 733 |
-
return;
|
| 734 |
-
}
|
| 735 |
-
const data = JSON.parse(payload);
|
| 736 |
-
contentElement.textContent += data.token;
|
| 737 |
-
scrollToBottom();
|
| 738 |
-
});
|
| 739 |
-
|
| 740 |
-
read();
|
| 741 |
-
});
|
| 742 |
-
}
|
| 743 |
-
|
| 744 |
-
read();
|
| 745 |
-
});
|
| 746 |
-
}
|
| 747 |
-
|
| 748 |
-
function appendMessage(sender, text) {
|
| 749 |
-
const rowDiv = document.createElement('div');
|
| 750 |
-
rowDiv.classList.add('message-row', sender);
|
| 751 |
-
|
| 752 |
-
let contentHTML = '';
|
| 753 |
-
if (sender === 'ai') contentHTML += `<div class="ai-avatar"></div>`;
|
| 754 |
-
contentHTML += `<div class="message-content">${text}</div>`;
|
| 755 |
-
|
| 756 |
-
rowDiv.innerHTML = contentHTML;
|
| 757 |
-
chatContainer.appendChild(rowDiv);
|
| 758 |
-
scrollToBottom();
|
| 759 |
-
|
| 760 |
-
return rowDiv.querySelector('.message-content');
|
| 761 |
-
}
|
| 762 |
-
|
| 763 |
-
function scrollToBottom() {
|
| 764 |
-
chatContainer.scrollTop = chatContainer.scrollHeight;
|
| 765 |
-
}
|
| 766 |
-
|
| 767 |
-
/* =========================================
|
| 768 |
-
Math Rendering (KaTeX)
|
| 769 |
-
========================================= */
|
| 770 |
-
function renderMath(element) {
|
| 771 |
-
if (typeof renderMathInElement === 'undefined') return;
|
| 772 |
-
renderMathInElement(element, {
|
| 773 |
-
delimiters: [
|
| 774 |
-
{left: '$$', right: '$$', display: true},
|
| 775 |
-
{left: '$', right: '$', display: false},
|
| 776 |
-
{left: '\\(', right: '\\)', display: false},
|
| 777 |
-
{left: '\\[', right: '\\]', display: true}
|
| 778 |
-
],
|
| 779 |
-
throwOnError: false
|
| 780 |
-
});
|
| 781 |
-
}
|
| 782 |
-
|
| 783 |
-
/* =========================================
|
| 784 |
-
Init: Load existing threads
|
| 785 |
-
========================================= */
|
| 786 |
-
fetch('/threads')
|
| 787 |
-
.then(res => res.json())
|
| 788 |
-
.then(data => {
|
| 789 |
-
data.threads.forEach(t => {
|
| 790 |
-
threads.push({id: t.id, title: t.title});
|
| 791 |
-
});
|
| 792 |
-
renderHistory();
|
| 793 |
-
});
|
| 794 |
-
</script>
|
| 795 |
-
</body>
|
| 796 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|