Subh775 commited on
Commit
a67e64a
·
verified ·
1 Parent(s): 109a944

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +12 -0
  2. backend.py +101 -0
  3. requirements.txt +6 -0
  4. stem.html +775 -0
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ EXPOSE 7860
11
+
12
+ CMD ["uvicorn", "backend:app", "--host", "0.0.0.0", "--port", "7860"]
backend.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ from fastapi import FastAPI
4
+ from fastapi.responses import FileResponse, StreamingResponse
5
+ from pydantic import BaseModel
6
+ from langgraph.graph import StateGraph, START, END
7
+ from langgraph.checkpoint.memory import InMemorySaver
8
+ from langgraph.graph.message import add_messages
9
+ from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
10
+ from langchain_mistralai import ChatMistralAI
11
+ from typing import TypedDict, Annotated
12
+
13
+
14
+ # LangGraph Setup
15
+ llm = ChatMistralAI()
16
+
17
+
18
+ class ChatState(TypedDict):
19
+ messages: Annotated[list[BaseMessage], add_messages]
20
+
21
+
22
+ def chat_node(state: ChatState):
23
+ response = llm.invoke(state["messages"])
24
+ return {"messages": [response]}
25
+
26
+
27
+ checkpointer = InMemorySaver()
28
+
29
+ graph = StateGraph(ChatState)
30
+ graph.add_node("chat_node", chat_node)
31
+ graph.add_edge(START, "chat_node")
32
+ graph.add_edge("chat_node", END)
33
+
34
+ chatbot = graph.compile(checkpointer=checkpointer)
35
+
36
+ # Track known thread IDs
37
+ known_threads = []
38
+
39
+
40
+ # FastAPI Setup
41
+ app = FastAPI()
42
+
43
+ STATIC_DIR = os.path.dirname(os.path.abspath(__file__))
44
+
45
+
46
+ class ChatRequest(BaseModel):
47
+ message: str
48
+ thread_id: str
49
+
50
+
51
+ @app.get("/")
52
+ def serve_index():
53
+ return FileResponse(os.path.join(STATIC_DIR, "stem.html"))
54
+
55
+
56
+ @app.get("/threads")
57
+ def get_threads():
58
+ return {"threads": known_threads}
59
+
60
+
61
+ @app.get("/history/{thread_id}")
62
+ def get_history(thread_id: str):
63
+ config = {"configurable": {"thread_id": thread_id}}
64
+ state = chatbot.get_state(config)
65
+ messages = state.values.get("messages", [])
66
+
67
+ result = []
68
+ for msg in messages:
69
+ role = "user" if isinstance(msg, HumanMessage) else "assistant"
70
+ result.append({"role": role, "content": msg.content})
71
+
72
+ return {"messages": result}
73
+
74
+
75
+ @app.post("/chat")
76
+ def chat(request: ChatRequest):
77
+ if request.thread_id not in known_threads:
78
+ known_threads.append(request.thread_id)
79
+
80
+ config = {"configurable": {"thread_id": request.thread_id}}
81
+
82
+ def stream_tokens():
83
+ for chunk, metadata in chatbot.stream(
84
+ {"messages": [HumanMessage(content=request.message)]},
85
+ config=config,
86
+ stream_mode="messages",
87
+ ):
88
+ if isinstance(chunk, AIMessage) and chunk.content:
89
+ yield f"data: {json.dumps({'token': chunk.content})}\n\n"
90
+ yield "data: [DONE]\n\n"
91
+
92
+ return StreamingResponse(stream_tokens(), media_type="text/event-stream")
93
+
94
+
95
+ # Static file serving for assets (logos, etc.)
96
+ @app.get("/{filepath:path}")
97
+ def serve_static(filepath: str):
98
+ full_path = os.path.join(STATIC_DIR, filepath)
99
+ if os.path.isfile(full_path):
100
+ return FileResponse(full_path)
101
+ return {"error": "not found"}
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ langgraph
2
+ langchain
3
+ langchain-core
4
+ langchain-mistralai
5
+ fastapi
6
+ uvicorn
stem.html ADDED
@@ -0,0 +1,775 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ /* Animations */
404
+ @keyframes fadeIn {
405
+ from { opacity: 0; transform: translateY(10px); }
406
+ to { opacity: 1; transform: translateY(0); }
407
+ }
408
+
409
+ /* Scrollbar Styling */
410
+ ::-webkit-scrollbar { width: 6px; }
411
+ ::-webkit-scrollbar-track { background: transparent; }
412
+ ::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; }
413
+ ::-webkit-scrollbar-thumb:hover { background: #555; }
414
+
415
+ .cursor::after {
416
+ content: '▋';
417
+ animation: blink 1s step-start infinite;
418
+ color: var(--text-secondary);
419
+ margin-left: 2px;
420
+ }
421
+ @keyframes blink { 50% { opacity: 0; } }
422
+ </style>
423
+ </head>
424
+ <body>
425
+
426
+ <aside class="sidebar" id="sidebar">
427
+ <div class="sidebar-header">
428
+ <img src="assets/stembotix.png" alt="STEMbotix Logo" class="brand-logo">
429
+ </div>
430
+
431
+ <button class="new-chat-btn">
432
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
433
+ <path d="M12 5v14M5 12h14"/>
434
+ </svg>
435
+ New Chat
436
+ </button>
437
+
438
+ <div class="chat-history" id="chatHistoryList">
439
+ </div>
440
+
441
+ <div class="sidebar-footer">
442
+ <div class="settings-btn">
443
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
444
+ <circle cx="12" cy="12" r="3"></circle>
445
+ <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>
446
+ </svg>
447
+ Settings
448
+ </div>
449
+ </div>
450
+ </aside>
451
+
452
+ <main class="main-chat">
453
+
454
+ <header class="main-header">
455
+ <button class="toggle-sidebar-btn" id="toggleSidebarBtn" title="Toggle Sidebar">
456
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
457
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
458
+ <line x1="9" y1="3" x2="9" y2="21"></line>
459
+ </svg>
460
+ </button>
461
+ </header>
462
+
463
+ <div class="chat-container" id="chatContainer">
464
+ <div class="message-row ai">
465
+ <div class="ai-avatar"></div>
466
+ <div class="message-content">
467
+ Lights out mode activated. How can I assist you with your project today?
468
+ </div>
469
+ </div>
470
+ </div>
471
+
472
+ <div class="input-container">
473
+ <div class="input-box">
474
+ <textarea id="userInput" placeholder="Ask StemGraph AI..." rows="1"></textarea>
475
+ <button class="send-btn" id="sendBtn">
476
+ <svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"></path></svg>
477
+ </button>
478
+ </div>
479
+ <div class="disclaimer-text">
480
+ StemGraph is an agent and can make mistakes.
481
+ </div>
482
+ </div>
483
+ </main>
484
+
485
+ <script>
486
+ /* =========================================
487
+ State
488
+ ========================================= */
489
+ let currentThreadId = crypto.randomUUID();
490
+ const threads = []; // {id, title}
491
+ let isSending = false;
492
+
493
+ /* =========================================
494
+ Sidebar Toggle Logic
495
+ ========================================= */
496
+ const sidebar = document.getElementById('sidebar');
497
+ const toggleSidebarBtn = document.getElementById('toggleSidebarBtn');
498
+
499
+ toggleSidebarBtn.addEventListener('click', () => {
500
+ sidebar.classList.toggle('collapsed');
501
+ });
502
+
503
+ /* =========================================
504
+ Chat History Sidebar
505
+ ========================================= */
506
+ const chatHistoryList = document.getElementById('chatHistoryList');
507
+
508
+ function addThreadToSidebar(id, title) {
509
+ threads.unshift({id: id, title: title});
510
+ renderHistory();
511
+ }
512
+
513
+ function renderHistory() {
514
+ chatHistoryList.innerHTML = '';
515
+ threads.forEach((thread) => {
516
+ const item = document.createElement('div');
517
+ item.className = `history-item ${thread.id === currentThreadId ? 'active' : ''}`;
518
+ item.setAttribute('data-thread-id', thread.id);
519
+ item.innerHTML = `
520
+ <span class="chat-title">${thread.title}</span>
521
+ <button class="options-btn" onclick="toggleMenu(event, this)">&#8942;</button>
522
+ <div class="options-menu">
523
+ <div class="option-item" onclick="renameChat(event, this)">
524
+ <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>
525
+ Rename
526
+ </div>
527
+ <div class="option-item delete" onclick="deleteChat(event, this)">
528
+ <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>
529
+ Delete
530
+ </div>
531
+ </div>
532
+ `;
533
+ item.addEventListener('click', () => loadThread(thread.id));
534
+ chatHistoryList.appendChild(item);
535
+ });
536
+ }
537
+
538
+ function loadThread(threadId) {
539
+ currentThreadId = threadId;
540
+ renderHistory();
541
+ chatContainer.innerHTML = '';
542
+
543
+ fetch('/history/' + threadId)
544
+ .then(res => res.json())
545
+ .then(data => {
546
+ data.messages.forEach(msg => {
547
+ const sender = msg.role === 'user' ? 'user' : 'ai';
548
+ const el = appendMessage(sender, msg.content);
549
+ if (sender === 'ai') renderMath(el);
550
+ });
551
+ });
552
+ }
553
+
554
+ /* =========================================
555
+ New Chat
556
+ ========================================= */
557
+ document.querySelector('.new-chat-btn').addEventListener('click', () => {
558
+ currentThreadId = crypto.randomUUID();
559
+ chatContainer.innerHTML = '';
560
+ appendMessage('ai', 'Lights out mode activated. How can I assist you with your project today?');
561
+ renderHistory();
562
+ });
563
+
564
+ /* =========================================
565
+ Options Menu (Rename / Delete)
566
+ ========================================= */
567
+ function toggleMenu(e, btn) {
568
+ e.stopPropagation();
569
+ closeAllMenus();
570
+ const menu = btn.nextElementSibling;
571
+ menu.classList.add('show');
572
+ btn.classList.add('menu-open');
573
+ }
574
+
575
+ document.addEventListener('click', closeAllMenus);
576
+
577
+ function closeAllMenus() {
578
+ document.querySelectorAll('.options-menu.show').forEach(menu => {
579
+ menu.classList.remove('show');
580
+ });
581
+ document.querySelectorAll('.options-btn.menu-open').forEach(btn => {
582
+ btn.classList.remove('menu-open');
583
+ });
584
+ }
585
+
586
+ function deleteChat(e, optionEl) {
587
+ e.stopPropagation();
588
+ const item = optionEl.closest('.history-item');
589
+ const threadId = item.getAttribute('data-thread-id');
590
+ const idx = threads.findIndex(t => t.id === threadId);
591
+ if (idx !== -1) threads.splice(idx, 1);
592
+ item.style.opacity = '0';
593
+ setTimeout(() => { item.remove(); }, 200);
594
+ }
595
+
596
+ function renameChat(e, optionEl) {
597
+ e.stopPropagation();
598
+ const item = optionEl.closest('.history-item');
599
+ const titleSpan = item.querySelector('.chat-title');
600
+ const threadId = item.getAttribute('data-thread-id');
601
+ closeAllMenus();
602
+
603
+ const currentTitle = titleSpan.innerText;
604
+ const input = document.createElement('input');
605
+ input.type = 'text';
606
+ input.value = currentTitle;
607
+ input.className = 'rename-input';
608
+
609
+ titleSpan.replaceWith(input);
610
+ input.focus();
611
+ input.selectionStart = input.selectionEnd = input.value.length;
612
+
613
+ function saveRename() {
614
+ const newTitle = input.value.trim() || 'Untitled Chat';
615
+ titleSpan.innerText = newTitle;
616
+ input.replaceWith(titleSpan);
617
+ const thread = threads.find(t => t.id === threadId);
618
+ if (thread) thread.title = newTitle;
619
+ }
620
+
621
+ input.addEventListener('blur', saveRename);
622
+ input.addEventListener('keydown', (evt) => {
623
+ if (evt.key === 'Enter') saveRename();
624
+ if (evt.key === 'Escape') {
625
+ titleSpan.innerText = currentTitle;
626
+ input.replaceWith(titleSpan);
627
+ }
628
+ });
629
+ input.addEventListener('click', (evt) => evt.stopPropagation());
630
+ }
631
+
632
+ /* =========================================
633
+ Chat Messaging Logic
634
+ ========================================= */
635
+ const chatContainer = document.getElementById('chatContainer');
636
+ const userInput = document.getElementById('userInput');
637
+ const sendBtn = document.getElementById('sendBtn');
638
+
639
+ userInput.addEventListener('input', function() {
640
+ this.style.height = '56px';
641
+ this.style.height = (this.scrollHeight) + 'px';
642
+
643
+ if (this.value.trim().length > 0) {
644
+ sendBtn.style.color = 'var(--text-primary)';
645
+ } else {
646
+ sendBtn.style.color = 'var(--text-secondary)';
647
+ }
648
+ });
649
+
650
+ userInput.addEventListener('keydown', function(e) {
651
+ if (e.key === 'Enter' && !e.shiftKey) {
652
+ e.preventDefault();
653
+ sendMessage();
654
+ }
655
+ });
656
+
657
+ sendBtn.addEventListener('click', sendMessage);
658
+
659
+ function sendMessage() {
660
+ const text = userInput.value.trim();
661
+ if (!text || isSending) return;
662
+
663
+ isSending = true;
664
+ appendMessage('user', text);
665
+ userInput.value = '';
666
+ userInput.style.height = '56px';
667
+ sendBtn.style.color = 'var(--text-secondary)';
668
+
669
+ // Add thread to sidebar on first message
670
+ const exists = threads.find(t => t.id === currentThreadId);
671
+ if (!exists) {
672
+ const title = text.length > 30 ? text.substring(0, 30) + '...' : text;
673
+ addThreadToSidebar(currentThreadId, title);
674
+ }
675
+
676
+ streamResponse(text);
677
+ }
678
+
679
+ function streamResponse(text) {
680
+ const contentElement = appendMessage('ai', '');
681
+ contentElement.classList.add('cursor');
682
+
683
+ fetch('/chat', {
684
+ method: 'POST',
685
+ headers: {'Content-Type': 'application/json'},
686
+ body: JSON.stringify({message: text, thread_id: currentThreadId})
687
+ }).then(response => {
688
+ const reader = response.body.getReader();
689
+ const decoder = new TextDecoder();
690
+ let buffer = '';
691
+
692
+ function read() {
693
+ reader.read().then(({done, value}) => {
694
+ if (done) {
695
+ contentElement.classList.remove('cursor');
696
+ renderMath(contentElement);
697
+ isSending = false;
698
+ return;
699
+ }
700
+
701
+ buffer += decoder.decode(value, {stream: true});
702
+ const lines = buffer.split('\n');
703
+ buffer = lines.pop();
704
+
705
+ lines.forEach(line => {
706
+ if (!line.startsWith('data: ')) return;
707
+ const payload = line.substring(6);
708
+ if (payload === '[DONE]') {
709
+ contentElement.classList.remove('cursor');
710
+ renderMath(contentElement);
711
+ isSending = false;
712
+ return;
713
+ }
714
+ const data = JSON.parse(payload);
715
+ contentElement.textContent += data.token;
716
+ scrollToBottom();
717
+ });
718
+
719
+ read();
720
+ });
721
+ }
722
+
723
+ read();
724
+ });
725
+ }
726
+
727
+ function appendMessage(sender, text) {
728
+ const rowDiv = document.createElement('div');
729
+ rowDiv.classList.add('message-row', sender);
730
+
731
+ let contentHTML = '';
732
+ if (sender === 'ai') contentHTML += `<div class="ai-avatar"></div>`;
733
+ contentHTML += `<div class="message-content">${text}</div>`;
734
+
735
+ rowDiv.innerHTML = contentHTML;
736
+ chatContainer.appendChild(rowDiv);
737
+ scrollToBottom();
738
+
739
+ return rowDiv.querySelector('.message-content');
740
+ }
741
+
742
+ function scrollToBottom() {
743
+ chatContainer.scrollTop = chatContainer.scrollHeight;
744
+ }
745
+
746
+ /* =========================================
747
+ Math Rendering (KaTeX)
748
+ ========================================= */
749
+ function renderMath(element) {
750
+ if (typeof renderMathInElement === 'undefined') return;
751
+ renderMathInElement(element, {
752
+ delimiters: [
753
+ {left: '$$', right: '$$', display: true},
754
+ {left: '$', right: '$', display: false},
755
+ {left: '\\(', right: '\\)', display: false},
756
+ {left: '\\[', right: '\\]', display: true}
757
+ ],
758
+ throwOnError: false
759
+ });
760
+ }
761
+
762
+ /* =========================================
763
+ Init: Load existing threads
764
+ ========================================= */
765
+ fetch('/threads')
766
+ .then(res => res.json())
767
+ .then(data => {
768
+ data.threads.forEach(id => {
769
+ threads.push({id: id, title: id.substring(0, 8) + '...'});
770
+ });
771
+ renderHistory();
772
+ });
773
+ </script>
774
+ </body>
775
+ </html>