Subh775 commited on
Commit
26078c9
·
1 Parent(s): 9f68572

memory, context from langchain; languges; personalization; features; improvement

Browse files
Files changed (14) hide show
  1. Dockerfile +1 -1
  2. app.py +201 -0
  3. backend.py +0 -160
  4. config.py +22 -0
  5. db.py +109 -0
  6. graph.py +58 -0
  7. guard.py +22 -0
  8. prompts.py +95 -0
  9. requirements.txt +6 -4
  10. retriever.py +59 -0
  11. static/app.js +427 -0
  12. static/index.html +111 -0
  13. static/style.css +554 -0
  14. stem.html +0 -796
Dockerfile CHANGED
@@ -9,4 +9,4 @@ COPY . .
9
 
10
  EXPOSE 7860
11
 
12
- CMD ["uvicorn", "backend:app", "--host", "0.0.0.0", "--port", "7860"]
 
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-mistralai
5
  langgraph-checkpoint-sqlite
6
- fastapi
7
- uvicorn
 
 
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)">&#8942;</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)">&#8942;</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>