feno1 commited on
Commit
4807472
·
verified ·
1 Parent(s): f5e9ec1

Upload 7 files

Browse files
Files changed (7) hide show
  1. Dockerfile +21 -0
  2. README.md +5 -9
  3. app.py +137 -0
  4. requirements.txt +3 -0
  5. static/chat.js +509 -0
  6. static/index.html +101 -0
  7. static/style.css +476 -0
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Install build dependencies for llama-cpp-python + OpenBLAS for CPU acceleration
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ build-essential cmake libopenblas-dev \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ WORKDIR /app
9
+
10
+ # Install Python deps
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ # Build llama-cpp-python with OpenBLAS for maximum CPU speed
15
+ RUN CMAKE_ARGS="-DGGML_BLAS=ON -DGGML_BLAS_VENDOR=OpenBLAS" \
16
+ pip install --no-cache-dir llama-cpp-python
17
+
18
+ COPY . .
19
+
20
+ EXPOSE 7860
21
+ CMD ["python", "-c", "import uvicorn; uvicorn.run('app:app', host='0.0.0.0', port=7860)"]
README.md CHANGED
@@ -1,14 +1,10 @@
1
  ---
2
- title: Gemma 4 E4B It OBLITERATED
3
- emoji: 👁
4
  colorFrom: purple
5
- colorTo: yellow
6
- sdk: gradio
7
- sdk_version: 6.13.0
8
- app_file: app.py
9
  pinned: false
10
- license: apache-2.0
11
  short_description: Gemma 4 E4B OBLITERATED — Chat Interface
 
12
  ---
13
-
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: Gemma 4 E4B Chat
3
+ emoji: ⛓️‍💥
4
  colorFrom: purple
5
+ colorTo: red
6
+ sdk: docker
 
 
7
  pinned: false
 
8
  short_description: Gemma 4 E4B OBLITERATED — Chat Interface
9
+ license: apache-2.0
10
  ---
 
 
app.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ from threading import Thread
4
+ from fastapi import FastAPI
5
+ from fastapi.responses import HTMLResponse, StreamingResponse
6
+ from fastapi.staticfiles import StaticFiles
7
+ from pydantic import BaseModel
8
+ from typing import List, Optional
9
+ from huggingface_hub import hf_hub_download
10
+
11
+ # ── Model setup ──────────────────────────────────────────────────────────────
12
+
13
+ MODEL_REPO = "OBLITERATUS/gemma-4-E4B-it-OBLITERATED"
14
+ MODEL_FILE = "gemma-4-E4B-it-OBLITERATED-Q4_K_M.gguf"
15
+ MAX_CONTEXT_MESSAGES = 4
16
+ MAX_NEW_TOKENS = 512
17
+
18
+ SYSTEM_PROMPT = "You are a helpful AI assistant. Respond to the user's input."
19
+
20
+ # Download the GGUF model (~4.9 GB, cached after first download)
21
+ print(f"Downloading {MODEL_FILE}…")
22
+ model_path = hf_hub_download(
23
+ repo_id=MODEL_REPO,
24
+ filename=MODEL_FILE,
25
+ cache_dir="/tmp/hf_cache",
26
+ )
27
+ print(f"Model downloaded: {model_path}")
28
+
29
+ # Import llama_cpp after ensuring it's installed
30
+ from llama_cpp import Llama
31
+
32
+ # CPU thread count — match HF free tier (2 vCPUs)
33
+ N_THREADS = int(os.environ.get("N_THREADS", "2"))
34
+
35
+ print("Loading model into memory…")
36
+ llm = Llama(
37
+ model_path=model_path,
38
+ n_ctx=2048, # context window
39
+ n_threads=N_THREADS,
40
+ n_threads_batch=N_THREADS,
41
+ n_gpu_layers=0, # CPU only on free tier
42
+ verbose=False,
43
+ use_mmap=True, # memory-map for fast loading
44
+ use_mlock=False, # don't lock in RAM (free tier has limited memory)
45
+ )
46
+ print("Model ready ✓")
47
+
48
+ # ── FastAPI app ──────────────────────────────────────────────────────────────
49
+
50
+ app = FastAPI()
51
+ app.mount("/static", StaticFiles(directory="static"), name="static")
52
+
53
+
54
+ class Message(BaseModel):
55
+ role: str
56
+ content: str
57
+
58
+ class ChatRequest(BaseModel):
59
+ messages: List[Message]
60
+ memories: Optional[str] = None
61
+
62
+
63
+ @app.get("/", response_class=HTMLResponse)
64
+ async def index():
65
+ with open("static/index.html", "r") as f:
66
+ return HTMLResponse(content=f.read())
67
+
68
+
69
+ def build_messages(req_messages, memories=None):
70
+ """Build the message list with system prompt, memories, and truncated history."""
71
+ msgs = []
72
+
73
+ # System prompt
74
+ system = SYSTEM_PROMPT
75
+ if memories and memories.strip():
76
+ system += f"\n\nMemories from past conversations:\n{memories[:300]}"
77
+ msgs.append({"role": "system", "content": system})
78
+
79
+ # Truncate to last N messages
80
+ history = req_messages[-MAX_CONTEXT_MESSAGES:]
81
+ for m in history:
82
+ content = m["content"][:1500] if len(m["content"]) > 1500 else m["content"]
83
+ msgs.append({"role": m["role"], "content": content})
84
+
85
+ return msgs
86
+
87
+
88
+ @app.post("/api/chat")
89
+ async def chat(req: ChatRequest):
90
+ messages = [{"role": m.role, "content": m.content} for m in req.messages]
91
+ chat_msgs = build_messages(messages, req.memories)
92
+
93
+ def event_stream():
94
+ response = llm.create_chat_completion(
95
+ messages=chat_msgs,
96
+ stream=True,
97
+ max_tokens=MAX_NEW_TOKENS,
98
+ temperature=0.7,
99
+ top_p=0.9,
100
+ top_k=40,
101
+ repeat_penalty=1.1,
102
+ )
103
+ for chunk in response:
104
+ delta = chunk["choices"][0].get("delta", {})
105
+ token = delta.get("content", "")
106
+ if token:
107
+ yield f"data: {json.dumps({'token': token})}\n\n"
108
+ yield "data: [DONE]\n\n"
109
+
110
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
111
+
112
+
113
+ @app.post("/api/summarize")
114
+ async def summarize(req: ChatRequest):
115
+ """Summarize a conversation into compact memory bullets."""
116
+ msgs = req.messages[-6:]
117
+ conversation_text = ""
118
+ for m in msgs:
119
+ role = "User" if m.role == "user" else "Assistant"
120
+ conversation_text += f"{role}: {m.content[:300]}\n"
121
+
122
+ summary_msgs = [
123
+ {"role": "user", "content": (
124
+ "Summarize this conversation in 3-5 bullet points. "
125
+ "Focus on key facts and user preferences. Be very concise.\n\n"
126
+ f"{conversation_text}\n\nBullets:"
127
+ )}
128
+ ]
129
+
130
+ response = llm.create_chat_completion(
131
+ messages=summary_msgs,
132
+ max_tokens=128,
133
+ temperature=0.3,
134
+ )
135
+
136
+ result = response["choices"][0]["message"]["content"]
137
+ return {"summary": result.strip()}
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ huggingface_hub
static/chat.js ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ── Storage Keys ─────────────────────────────────────────────────────────
2
+ const STORAGE_CHATS = 'lfm_chats'; // array of { id, title, messages[] }
3
+ const STORAGE_ACTIVE = 'lfm_active_chat'; // current chat id
4
+ const STORAGE_MEMORIES = 'lfm_memories'; // string of memory bullets
5
+ const STORAGE_THEME = 'lfm_theme';
6
+
7
+ // ── State ────────────────────────────────────────────────────────────────
8
+ let chats = [];
9
+ let activeChatId = null;
10
+ let isGenerating = false;
11
+
12
+ // ── DOM ──────────────────────────────────────────────────────────────────
13
+ const chatArea = document.getElementById('chatArea');
14
+ const welcome = document.getElementById('welcome');
15
+ const input = document.getElementById('messageInput');
16
+ const sendBtn = document.getElementById('sendBtn');
17
+ const themeToggle = document.getElementById('themeToggle');
18
+ const sunIcon = document.getElementById('sunIcon');
19
+ const moonIcon = document.getElementById('moonIcon');
20
+ const sidebar = document.getElementById('sidebar');
21
+ const sidebarToggle = document.getElementById('sidebarToggle');
22
+ const sidebarList = document.getElementById('sidebarList');
23
+ const newChatBtn = document.getElementById('newChatBtn');
24
+ const memoriesBtn = document.getElementById('memoriesBtn');
25
+ const memoriesModal = document.getElementById('memoriesModal');
26
+ const memoriesText = document.getElementById('memoriesText');
27
+ const closeMemories = document.getElementById('closeMemories');
28
+ const saveMemories = document.getElementById('saveMemories');
29
+ const clearMemories = document.getElementById('clearMemories');
30
+ const clearAllBtn = document.getElementById('clearAllBtn');
31
+ const mainContent = document.querySelector('.main-content');
32
+
33
+ // ── Init ─────────────────────────────────────────────────────────────────
34
+ function init() {
35
+ loadTheme();
36
+ loadChats();
37
+ renderSidebar();
38
+
39
+ if (activeChatId) {
40
+ renderChat(activeChatId);
41
+ }
42
+
43
+ // Check sidebar state
44
+ const sidebarHidden = localStorage.getItem('lfm_sidebar_hidden') === 'true';
45
+ if (sidebarHidden) {
46
+ sidebar.classList.add('hidden');
47
+ mainContent.classList.add('full-width');
48
+ }
49
+ }
50
+
51
+ // ── Theme ────────────────────────────────────────────────────────────────
52
+ function loadTheme() {
53
+ const saved = localStorage.getItem(STORAGE_THEME) ||
54
+ (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
55
+ setTheme(saved);
56
+ }
57
+
58
+ function setTheme(theme) {
59
+ document.documentElement.setAttribute('data-theme', theme);
60
+ localStorage.setItem(STORAGE_THEME, theme);
61
+ sunIcon.style.display = theme === 'dark' ? 'none' : 'block';
62
+ moonIcon.style.display = theme === 'dark' ? 'block' : 'none';
63
+ }
64
+
65
+ themeToggle.addEventListener('click', () => {
66
+ const cur = document.documentElement.getAttribute('data-theme');
67
+ setTheme(cur === 'dark' ? 'light' : 'dark');
68
+ });
69
+
70
+ // ── Sidebar Toggle ───────────────────────────────────────────────────────
71
+ sidebarToggle.addEventListener('click', () => {
72
+ const isMobile = window.innerWidth <= 768;
73
+ if (isMobile) {
74
+ sidebar.classList.toggle('visible');
75
+ } else {
76
+ sidebar.classList.toggle('hidden');
77
+ mainContent.classList.toggle('full-width');
78
+ localStorage.setItem('lfm_sidebar_hidden', sidebar.classList.contains('hidden'));
79
+ }
80
+ });
81
+
82
+ // ── Persistence ──────────────────────────────────────────────────────────
83
+ function loadChats() {
84
+ try {
85
+ chats = JSON.parse(localStorage.getItem(STORAGE_CHATS) || '[]');
86
+ activeChatId = localStorage.getItem(STORAGE_ACTIVE) || null;
87
+ } catch { chats = []; activeChatId = null; }
88
+ }
89
+
90
+ function saveChats() {
91
+ localStorage.setItem(STORAGE_CHATS, JSON.stringify(chats));
92
+ localStorage.setItem(STORAGE_ACTIVE, activeChatId || '');
93
+ }
94
+
95
+ function getMemories() {
96
+ return localStorage.getItem(STORAGE_MEMORIES) || '';
97
+ }
98
+
99
+ function setMemories(text) {
100
+ localStorage.setItem(STORAGE_MEMORIES, text);
101
+ }
102
+
103
+ // ── Chat CRUD ────────────────────────────────────────────────────────────
104
+ function createChat() {
105
+ const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
106
+ const chat = { id, title: 'New chat', messages: [], createdAt: Date.now() };
107
+ chats.unshift(chat);
108
+ activeChatId = id;
109
+ saveChats();
110
+ renderSidebar();
111
+ renderChat(id);
112
+ return chat;
113
+ }
114
+
115
+ function deleteChat(id) {
116
+ chats = chats.filter(c => c.id !== id);
117
+ if (activeChatId === id) {
118
+ activeChatId = chats.length > 0 ? chats[0].id : null;
119
+ }
120
+ saveChats();
121
+ renderSidebar();
122
+ if (activeChatId) renderChat(activeChatId);
123
+ else showWelcome();
124
+ }
125
+
126
+ function getActiveChat() {
127
+ return chats.find(c => c.id === activeChatId) || null;
128
+ }
129
+
130
+ function updateChatTitle(chat) {
131
+ if (chat.messages.length > 0) {
132
+ const firstUser = chat.messages.find(m => m.role === 'user');
133
+ if (firstUser) {
134
+ chat.title = firstUser.content.slice(0, 40) + (firstUser.content.length > 40 ? '…' : '');
135
+ }
136
+ }
137
+ }
138
+
139
+ // ── Sidebar Rendering ────────────────────────────────────────────────────
140
+ function renderSidebar() {
141
+ sidebarList.innerHTML = '';
142
+
143
+ if (chats.length === 0) {
144
+ sidebarList.innerHTML = '<div style="padding:20px 12px;color:var(--text-tertiary);font-size:0.82rem;text-align:center;">No conversations yet</div>';
145
+ return;
146
+ }
147
+
148
+ chats.forEach(chat => {
149
+ const item = document.createElement('div');
150
+ item.className = 'sidebar-item' + (chat.id === activeChatId ? ' active' : '');
151
+ item.innerHTML = `
152
+ <span class="sidebar-item-title">${escapeHtml(chat.title)}</span>
153
+ <button class="sidebar-item-delete" title="Delete">✕</button>
154
+ `;
155
+
156
+ item.querySelector('.sidebar-item-title').addEventListener('click', () => {
157
+ activeChatId = chat.id;
158
+ saveChats();
159
+ renderSidebar();
160
+ renderChat(chat.id);
161
+ // Close mobile sidebar
162
+ if (window.innerWidth <= 768) sidebar.classList.remove('visible');
163
+ });
164
+
165
+ item.querySelector('.sidebar-item-delete').addEventListener('click', (e) => {
166
+ e.stopPropagation();
167
+ deleteChat(chat.id);
168
+ });
169
+
170
+ sidebarList.appendChild(item);
171
+ });
172
+ }
173
+
174
+ // ── Chat Rendering ───────────────────────────────────────────────────────
175
+ function showWelcome() {
176
+ chatArea.innerHTML = '';
177
+ chatArea.appendChild(welcome);
178
+ welcome.style.display = 'flex';
179
+ }
180
+
181
+ function renderChat(id) {
182
+ const chat = chats.find(c => c.id === id);
183
+ if (!chat) { showWelcome(); return; }
184
+
185
+ chatArea.innerHTML = '';
186
+
187
+ if (chat.messages.length === 0) {
188
+ chatArea.appendChild(welcome);
189
+ welcome.style.display = 'flex';
190
+ return;
191
+ }
192
+
193
+ welcome.style.display = 'none';
194
+
195
+ chat.messages.forEach(msg => {
196
+ if (msg.role === 'user') {
197
+ chatArea.appendChild(createUserMessage(msg.content));
198
+ } else {
199
+ const row = createBotMessageStatic(msg.content);
200
+ chatArea.appendChild(row);
201
+ }
202
+ });
203
+
204
+ scrollToBottom();
205
+ }
206
+
207
+ // ── Auto-resize textarea ─────────────────────────────────────────────────
208
+ input.addEventListener('input', () => {
209
+ input.style.height = 'auto';
210
+ input.style.height = Math.min(input.scrollHeight, 200) + 'px';
211
+ sendBtn.disabled = !input.value.trim();
212
+ });
213
+
214
+ input.addEventListener('keydown', (e) => {
215
+ if (e.key === 'Enter' && !e.shiftKey) {
216
+ e.preventDefault();
217
+ if (input.value.trim() && !isGenerating) sendMessage();
218
+ }
219
+ });
220
+
221
+ sendBtn.addEventListener('click', () => {
222
+ if (input.value.trim() && !isGenerating) sendMessage();
223
+ });
224
+
225
+ newChatBtn.addEventListener('click', () => {
226
+ // Save memory from current chat before switching
227
+ saveMemoryFromCurrentChat();
228
+ createChat();
229
+ if (window.innerWidth <= 768) sidebar.classList.remove('visible');
230
+ });
231
+
232
+ clearAllBtn.addEventListener('click', () => {
233
+ if (confirm('Delete all conversations? This cannot be undone.')) {
234
+ chats = [];
235
+ activeChatId = null;
236
+ saveChats();
237
+ renderSidebar();
238
+ showWelcome();
239
+ }
240
+ });
241
+
242
+ // ── Memories Modal ───────────────────────────────────────────────────────
243
+ memoriesBtn.addEventListener('click', () => {
244
+ memoriesText.value = getMemories();
245
+ memoriesModal.style.display = 'flex';
246
+ });
247
+
248
+ closeMemories.addEventListener('click', () => { memoriesModal.style.display = 'none'; });
249
+ memoriesModal.addEventListener('click', (e) => { if (e.target === memoriesModal) memoriesModal.style.display = 'none'; });
250
+
251
+ saveMemories.addEventListener('click', () => {
252
+ setMemories(memoriesText.value);
253
+ memoriesModal.style.display = 'none';
254
+ });
255
+
256
+ clearMemories.addEventListener('click', () => {
257
+ memoriesText.value = '';
258
+ setMemories('');
259
+ });
260
+
261
+ // ── Helpers ──────────────────────────────────────────────────────────────
262
+ function scrollToBottom() {
263
+ chatArea.scrollTo({ top: chatArea.scrollHeight, behavior: 'smooth' });
264
+ }
265
+
266
+ function escapeHtml(str) {
267
+ const div = document.createElement('div');
268
+ div.textContent = str;
269
+ return div.innerHTML;
270
+ }
271
+
272
+ function renderMarkdown(text) {
273
+ marked.setOptions({ breaks: true, gfm: true });
274
+ return marked.parse(text);
275
+ }
276
+
277
+ function processThinkingTags(text) {
278
+ // Completed thinking blocks — render collapsed by default
279
+ text = text.replace(/<think>([\s\S]*?)<\/think>/g, (_, content) => {
280
+ content = content.trim();
281
+ if (!content) return '';
282
+ return `<details class="thinking-block"><summary class="thinking-label">Thought process</summary><div class="thinking-content">${renderMarkdown(content)}</div></details>`;
283
+ });
284
+
285
+ // Unclosed thinking (still streaming) — show as live indicator
286
+ if (text.includes('<think>') && !text.split('<think>').pop().includes('</think>')) {
287
+ const parts = text.split('<think>');
288
+ const before = parts.slice(0, -1).join('<think>');
289
+ const thinking = parts[parts.length - 1];
290
+ text = before + `<details class="thinking-block" open><summary class="thinking-label">Thinking…</summary><div class="thinking-content">${renderMarkdown(thinking)}</div></details>`;
291
+ }
292
+
293
+ return text;
294
+ }
295
+
296
+ function addCopyButtons(container) {
297
+ container.querySelectorAll('pre').forEach((pre) => {
298
+ if (pre.querySelector('.copy-code-btn')) return;
299
+ const btn = document.createElement('button');
300
+ btn.className = 'copy-code-btn';
301
+ btn.textContent = 'Copy';
302
+ btn.addEventListener('click', () => {
303
+ const code = pre.querySelector('code') ? pre.querySelector('code').textContent : pre.textContent;
304
+ navigator.clipboard.writeText(code).then(() => {
305
+ btn.textContent = 'Copied!';
306
+ setTimeout(() => btn.textContent = 'Copy', 1500);
307
+ });
308
+ });
309
+ pre.style.position = 'relative';
310
+ pre.appendChild(btn);
311
+ });
312
+ }
313
+
314
+ function renderFinalContent(rawText) {
315
+ let processed = processThinkingTags(rawText);
316
+ const thinkingBlocks = [];
317
+ processed = processed.replace(/<details class="thinking-block"[\s\S]*?<\/details>/g, (match) => {
318
+ const ph = `%%THINK_${thinkingBlocks.length}%%`;
319
+ thinkingBlocks.push(match);
320
+ return ph;
321
+ });
322
+ let rendered = renderMarkdown(processed);
323
+ thinkingBlocks.forEach((block, i) => { rendered = rendered.replace(`%%THINK_${i}%%`, block); });
324
+ return rendered;
325
+ }
326
+
327
+ // ── Create message DOM ───────────────────────────────────────────────────
328
+ function createUserMessage(text) {
329
+ const row = document.createElement('div');
330
+ row.className = 'message-row';
331
+ row.innerHTML = `
332
+ <div class="msg-content user-msg">${escapeHtml(text)}</div>
333
+ <div style="clear:both"></div>
334
+ `;
335
+ return row;
336
+ }
337
+
338
+ function createBotMessageStatic(text) {
339
+ const row = document.createElement('div');
340
+ row.className = 'message-row';
341
+ row.innerHTML = `
342
+ <div class="msg-label">Gemma 4</div>
343
+ <div class="msg-content bot-msg">${renderFinalContent(text)}</div>
344
+ <div class="msg-actions">
345
+ <button class="msg-action-btn copy-btn" title="Copy response">
346
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
347
+ Copy
348
+ </button>
349
+ </div>
350
+ `;
351
+ addCopyButtons(row.querySelector('.bot-msg'));
352
+ row.querySelector('.copy-btn').addEventListener('click', () => {
353
+ navigator.clipboard.writeText(text).then(() => {
354
+ const btn = row.querySelector('.copy-btn');
355
+ btn.innerHTML = '✓ Copied!';
356
+ setTimeout(() => btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy`, 1500);
357
+ });
358
+ });
359
+ return row;
360
+ }
361
+
362
+ function createBotMessageStreaming() {
363
+ const row = document.createElement('div');
364
+ row.className = 'message-row';
365
+ row.innerHTML = `
366
+ <div class="msg-label">Gemma 4</div>
367
+ <div class="msg-content bot-msg">
368
+ <div class="typing-indicator"><span></span><span></span><span></span></div>
369
+ </div>
370
+ `;
371
+ return row;
372
+ }
373
+
374
+ // ── Memory Management ────────────────────────────────────────────────────
375
+ async function saveMemoryFromCurrentChat() {
376
+ const chat = getActiveChat();
377
+ if (!chat || chat.messages.length < 2) return;
378
+
379
+ try {
380
+ const res = await fetch('/api/summarize', {
381
+ method: 'POST',
382
+ headers: { 'Content-Type': 'application/json' },
383
+ body: JSON.stringify({
384
+ messages: chat.messages.map(m => ({ role: m.role, content: m.content }))
385
+ }),
386
+ });
387
+
388
+ if (res.ok) {
389
+ const data = await res.json();
390
+ if (data.summary && data.summary.trim()) {
391
+ const existing = getMemories();
392
+ const timestamp = new Date().toLocaleDateString();
393
+ const newMemory = `[${timestamp} — "${chat.title}"]\n${data.summary.trim()}`;
394
+ const combined = existing ? existing + '\n\n' + newMemory : newMemory;
395
+ setMemories(combined);
396
+ }
397
+ }
398
+ } catch (err) {
399
+ console.log('Memory save skipped:', err.message);
400
+ }
401
+ }
402
+
403
+ // ── Send Message ─────────────────────────────────────────────────────────
404
+ async function sendMessage() {
405
+ const text = input.value.trim();
406
+ if (!text) return;
407
+
408
+ // Ensure we have an active chat
409
+ if (!activeChatId) createChat();
410
+ const chat = getActiveChat();
411
+ if (!chat) return;
412
+
413
+ welcome.style.display = 'none';
414
+ // Clear welcome if it's the only child
415
+ if (chatArea.querySelector('.welcome')) {
416
+ chatArea.innerHTML = '';
417
+ }
418
+
419
+ // Add user message
420
+ chat.messages.push({ role: 'user', content: text });
421
+ updateChatTitle(chat);
422
+ saveChats();
423
+ renderSidebar();
424
+ chatArea.appendChild(createUserMessage(text));
425
+
426
+ input.value = '';
427
+ input.style.height = 'auto';
428
+ sendBtn.disabled = true;
429
+ scrollToBottom();
430
+
431
+ await streamBotResponse(chat);
432
+ }
433
+
434
+ async function streamBotResponse(chat) {
435
+ isGenerating = true;
436
+ sendBtn.disabled = true;
437
+
438
+ const botRow = createBotMessageStreaming();
439
+ chatArea.appendChild(botRow);
440
+ scrollToBottom();
441
+
442
+ const botContent = botRow.querySelector('.bot-msg');
443
+ let fullText = '';
444
+
445
+ try {
446
+ const response = await fetch('/api/chat', {
447
+ method: 'POST',
448
+ headers: { 'Content-Type': 'application/json' },
449
+ body: JSON.stringify({
450
+ messages: chat.messages.map(m => ({ role: m.role, content: m.content })),
451
+ memories: getMemories()
452
+ }),
453
+ });
454
+
455
+ const reader = response.body.getReader();
456
+ const decoder = new TextDecoder();
457
+
458
+ while (true) {
459
+ const { done, value } = await reader.read();
460
+ if (done) break;
461
+
462
+ const chunk = decoder.decode(value, { stream: true });
463
+ const lines = chunk.split('\n');
464
+
465
+ for (const line of lines) {
466
+ if (!line.startsWith('data: ')) continue;
467
+ const data = line.slice(6);
468
+ if (data === '[DONE]') break;
469
+
470
+ try {
471
+ const parsed = JSON.parse(data);
472
+ fullText += parsed.token;
473
+ botContent.innerHTML = renderFinalContent(fullText);
474
+ addCopyButtons(botContent);
475
+ scrollToBottom();
476
+ } catch (e) { /* skip */ }
477
+ }
478
+ }
479
+ } catch (err) {
480
+ botContent.innerHTML = `<p style="color:#e74c3c;">Error: ${escapeHtml(err.message)}</p>`;
481
+ }
482
+
483
+ // Save to chat history
484
+ chat.messages.push({ role: 'assistant', content: fullText });
485
+ saveChats();
486
+
487
+ // Add copy/action buttons to the final message
488
+ const actions = document.createElement('div');
489
+ actions.className = 'msg-actions';
490
+ actions.innerHTML = `
491
+ <button class="msg-action-btn copy-btn" title="Copy">
492
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
493
+ Copy
494
+ </button>
495
+ `;
496
+ actions.querySelector('.copy-btn').addEventListener('click', () => {
497
+ navigator.clipboard.writeText(fullText).then(() => {
498
+ actions.querySelector('.copy-btn').innerHTML = '✓ Copied!';
499
+ setTimeout(() => actions.querySelector('.copy-btn').innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy`, 1500);
500
+ });
501
+ });
502
+ botRow.appendChild(actions);
503
+
504
+ isGenerating = false;
505
+ sendBtn.disabled = !input.value.trim();
506
+ }
507
+
508
+ // ── Boot ─────────────────────────────────────────────────────────────────
509
+ init();
static/index.html ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Gemma 4 E4B Chat</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
9
+ <link rel="stylesheet" href="/static/style.css">
10
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
11
+ </head>
12
+ <body>
13
+
14
+ <!-- Sidebar -->
15
+ <aside class="sidebar" id="sidebar">
16
+ <div class="sidebar-header">
17
+ <span class="sidebar-title">Chat History</span>
18
+ <button class="icon-btn-sm" id="newChatBtn" title="New chat">
19
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
20
+ </button>
21
+ </div>
22
+ <div class="sidebar-list" id="sidebarList">
23
+ <!-- populated by JS -->
24
+ </div>
25
+ <div class="sidebar-footer">
26
+ <button class="sidebar-footer-btn" id="memoriesBtn">
27
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 14v-4m0-4h.01"/></svg>
28
+ Memories
29
+ </button>
30
+ <button class="sidebar-footer-btn" id="clearAllBtn">
31
+ <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>
32
+ Clear All
33
+ </button>
34
+ </div>
35
+ </aside>
36
+
37
+ <!-- Memories Modal -->
38
+ <div class="modal-overlay" id="memoriesModal" style="display:none">
39
+ <div class="modal">
40
+ <div class="modal-header">
41
+ <h3>Memories</h3>
42
+ <button class="icon-btn-sm" id="closeMemories">✕</button>
43
+ </div>
44
+ <div class="modal-body">
45
+ <p class="modal-desc">These memories are recalled across all conversations. Edit or delete them as needed.</p>
46
+ <textarea id="memoriesText" class="memories-textarea" placeholder="No memories yet. Memories are automatically saved when you end a conversation."></textarea>
47
+ </div>
48
+ <div class="modal-footer">
49
+ <button class="modal-btn secondary" id="clearMemories">Clear All</button>
50
+ <button class="modal-btn primary" id="saveMemories">Save</button>
51
+ </div>
52
+ </div>
53
+ </div>
54
+
55
+ <!-- Main Content -->
56
+ <div class="main-content">
57
+ <!-- Header -->
58
+ <header class="header">
59
+ <div class="header-left">
60
+ <button class="icon-btn" id="sidebarToggle" title="Toggle sidebar">
61
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
62
+ </button>
63
+ <span class="logo">⛓️‍💥</span>
64
+ <span class="header-title">Gemma 4 <span class="header-badge">E4B</span></span>
65
+ </div>
66
+ <div class="header-right">
67
+ <button class="icon-btn" id="themeToggle" title="Toggle theme">
68
+ <svg id="sunIcon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
69
+ <svg id="moonIcon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
70
+ </button>
71
+ </div>
72
+ </header>
73
+
74
+ <!-- Chat Area -->
75
+ <main class="chat-area" id="chatArea">
76
+ <div class="welcome" id="welcome">
77
+ <div class="welcome-icon">⛓️‍💥</div>
78
+ <h1>Gemma 4 E4B</h1>
79
+ <p>A powerful 4B parameter model by Google, unchained.<br>Ask me anything to get started.</p>
80
+ </div>
81
+ </main>
82
+
83
+ <!-- Input Area -->
84
+ <footer class="input-area">
85
+ <div class="input-container">
86
+ <div class="input-wrapper">
87
+ <textarea id="messageInput" placeholder="Message Gemma 4…" rows="1"></textarea>
88
+ <button class="send-btn" id="sendBtn" disabled>
89
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
90
+ </button>
91
+ </div>
92
+ <div class="input-footer">
93
+ <span class="model-label">Gemma-4-E4B-OBLITERATED · Created by feno1</span>
94
+ </div>
95
+ </div>
96
+ </footer>
97
+ </div>
98
+
99
+ <script src="/static/chat.js"></script>
100
+ </body>
101
+ </html>
static/style.css ADDED
@@ -0,0 +1,476 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Reset & Variables ─────────────────────────────────────────────────── */
2
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
3
+
4
+ :root {
5
+ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
6
+ --font-mono: 'JetBrains Mono', ui-monospace, monospace;
7
+ --sidebar-width: 260px;
8
+ }
9
+
10
+ [data-theme="light"] {
11
+ --bg-primary: #ffffff;
12
+ --bg-secondary: #f7f7f8;
13
+ --bg-tertiary: #ececee;
14
+ --text-primary: #0d0d0d;
15
+ --text-secondary: #6b6b6b;
16
+ --text-tertiary: #9a9a9a;
17
+ --border: #e5e5e5;
18
+ --user-bg: #f3f3f3;
19
+ --thinking-bg: #f0f4f8;
20
+ --thinking-border: #c8d3de;
21
+ --code-bg: #1e1e1e;
22
+ --code-text: #d4d4d4;
23
+ --accent: #d97757;
24
+ --send-bg: #0d0d0d;
25
+ --send-fg: #ffffff;
26
+ --shadow: 0 1px 3px rgba(0,0,0,0.08);
27
+ --shadow-lg: 0 8px 30px rgba(0,0,0,0.08);
28
+ --sidebar-bg: #f7f7f8;
29
+ --sidebar-hover: #ececee;
30
+ --sidebar-active: #e2e2e4;
31
+ }
32
+
33
+ [data-theme="dark"] {
34
+ --bg-primary: #212121;
35
+ --bg-secondary: #2f2f2f;
36
+ --bg-tertiary: #3a3a3a;
37
+ --text-primary: #ececec;
38
+ --text-secondary: #a1a1a1;
39
+ --text-tertiary: #6b6b6b;
40
+ --border: #3a3a3a;
41
+ --user-bg: #303030;
42
+ --thinking-bg: #2a2d31;
43
+ --thinking-border: #444c55;
44
+ --code-bg: #1a1a1a;
45
+ --code-text: #d4d4d4;
46
+ --accent: #d97757;
47
+ --send-bg: #ececec;
48
+ --send-fg: #212121;
49
+ --shadow: 0 1px 3px rgba(0,0,0,0.3);
50
+ --shadow-lg: 0 8px 30px rgba(0,0,0,0.3);
51
+ --sidebar-bg: #1a1a1a;
52
+ --sidebar-hover: #2a2a2a;
53
+ --sidebar-active: #333333;
54
+ }
55
+
56
+ html, body {
57
+ height: 100%;
58
+ font-family: var(--font-sans);
59
+ background: var(--bg-primary);
60
+ color: var(--text-primary);
61
+ transition: background 0.3s, color 0.3s;
62
+ overflow: hidden;
63
+ }
64
+
65
+ /* ── Sidebar ──────────────────────────────────────────────────────────── */
66
+ .sidebar {
67
+ position: fixed; top: 0; left: 0; bottom: 0;
68
+ width: var(--sidebar-width);
69
+ background: var(--sidebar-bg);
70
+ border-right: 1px solid var(--border);
71
+ display: flex; flex-direction: column;
72
+ z-index: 200;
73
+ transition: transform 0.25s ease, background 0.3s;
74
+ }
75
+
76
+ .sidebar.hidden { transform: translateX(-100%); }
77
+
78
+ .sidebar-header {
79
+ display: flex; align-items: center; justify-content: space-between;
80
+ padding: 14px 16px;
81
+ border-bottom: 1px solid var(--border);
82
+ }
83
+
84
+ .sidebar-title {
85
+ font-size: 0.85rem;
86
+ font-weight: 600;
87
+ color: var(--text-secondary);
88
+ text-transform: uppercase;
89
+ letter-spacing: 0.04em;
90
+ }
91
+
92
+ .icon-btn-sm {
93
+ width: 28px; height: 28px;
94
+ border-radius: 6px;
95
+ border: 1px solid var(--border);
96
+ background: transparent;
97
+ color: var(--text-secondary);
98
+ cursor: pointer;
99
+ display: flex; align-items: center; justify-content: center;
100
+ transition: all 0.15s;
101
+ font-size: 0.8rem;
102
+ }
103
+
104
+ .icon-btn-sm:hover { background: var(--bg-tertiary); color: var(--text-primary); }
105
+
106
+ .sidebar-list {
107
+ flex: 1;
108
+ overflow-y: auto;
109
+ padding: 8px;
110
+ }
111
+
112
+ .sidebar-list::-webkit-scrollbar { width: 4px; }
113
+ .sidebar-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 10px; }
114
+
115
+ .sidebar-item {
116
+ display: flex; align-items: center; justify-content: space-between;
117
+ padding: 10px 12px;
118
+ border-radius: 8px;
119
+ cursor: pointer;
120
+ font-size: 0.85rem;
121
+ color: var(--text-secondary);
122
+ transition: all 0.12s;
123
+ margin-bottom: 2px;
124
+ }
125
+
126
+ .sidebar-item:hover { background: var(--sidebar-hover); color: var(--text-primary); }
127
+ .sidebar-item.active { background: var(--sidebar-active); color: var(--text-primary); font-weight: 500; }
128
+
129
+ .sidebar-item-title {
130
+ overflow: hidden;
131
+ text-overflow: ellipsis;
132
+ white-space: nowrap;
133
+ flex: 1;
134
+ }
135
+
136
+ .sidebar-item-delete {
137
+ opacity: 0;
138
+ background: none; border: none;
139
+ color: var(--text-tertiary);
140
+ cursor: pointer;
141
+ font-size: 0.75rem;
142
+ padding: 2px 4px;
143
+ border-radius: 4px;
144
+ transition: all 0.12s;
145
+ flex-shrink: 0;
146
+ }
147
+
148
+ .sidebar-item:hover .sidebar-item-delete { opacity: 1; }
149
+ .sidebar-item-delete:hover { color: #e74c3c; background: rgba(231,76,60,0.1); }
150
+
151
+ .sidebar-footer {
152
+ padding: 12px;
153
+ border-top: 1px solid var(--border);
154
+ display: flex; flex-direction: column; gap: 4px;
155
+ }
156
+
157
+ .sidebar-footer-btn {
158
+ display: flex; align-items: center; gap: 8px;
159
+ width: 100%;
160
+ padding: 8px 12px;
161
+ border: none;
162
+ background: transparent;
163
+ color: var(--text-secondary);
164
+ font-family: var(--font-sans);
165
+ font-size: 0.82rem;
166
+ cursor: pointer;
167
+ border-radius: 6px;
168
+ transition: all 0.12s;
169
+ }
170
+
171
+ .sidebar-footer-btn:hover { background: var(--sidebar-hover); color: var(--text-primary); }
172
+
173
+ /* ── Main Content ──────────────────────────��──────────────────────────── */
174
+ .main-content {
175
+ margin-left: var(--sidebar-width);
176
+ height: 100%;
177
+ display: flex; flex-direction: column;
178
+ transition: margin-left 0.25s ease;
179
+ }
180
+
181
+ .main-content.full-width { margin-left: 0; }
182
+
183
+ /* ── Header ───────────────────────────────────────────────────────────── */
184
+ .header {
185
+ height: 52px;
186
+ display: flex; align-items: center; justify-content: space-between;
187
+ padding: 0 16px;
188
+ background: var(--bg-primary);
189
+ border-bottom: 1px solid var(--border);
190
+ flex-shrink: 0;
191
+ transition: background 0.3s, border-color 0.3s;
192
+ }
193
+
194
+ .header-left { display: flex; align-items: center; gap: 10px; }
195
+ .logo { font-size: 1.25rem; }
196
+ .header-title { font-size: 0.95rem; font-weight: 600; letter-spacing: -0.01em; }
197
+
198
+ .header-badge {
199
+ font-size: 0.7rem; font-weight: 500;
200
+ padding: 2px 7px; border-radius: 4px;
201
+ background: var(--accent); color: #fff;
202
+ vertical-align: middle; margin-left: 2px;
203
+ }
204
+
205
+ .header-right { display: flex; align-items: center; gap: 6px; }
206
+
207
+ .icon-btn {
208
+ width: 36px; height: 36px;
209
+ border-radius: 8px;
210
+ border: 1px solid var(--border);
211
+ background: transparent;
212
+ color: var(--text-secondary);
213
+ cursor: pointer;
214
+ display: flex; align-items: center; justify-content: center;
215
+ transition: all 0.15s;
216
+ }
217
+
218
+ .icon-btn:hover { background: var(--bg-tertiary); color: var(--text-primary); }
219
+
220
+ /* ── Chat Area ────────────────────────────────────────────────────────── */
221
+ .chat-area {
222
+ flex: 1;
223
+ overflow-y: auto;
224
+ padding: 24px 0;
225
+ }
226
+
227
+ .chat-area::-webkit-scrollbar { width: 6px; }
228
+ .chat-area::-webkit-scrollbar-track { background: transparent; }
229
+ .chat-area::-webkit-scrollbar-thumb { background: var(--border); border-radius: 10px; }
230
+
231
+ /* ── Welcome ──────────────────────────────────────────────────────────── */
232
+ .welcome {
233
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
234
+ height: 100%; text-align: center;
235
+ animation: fadeIn 0.5s ease-out;
236
+ }
237
+
238
+ .welcome-icon { font-size: 3rem; margin-bottom: 16px; }
239
+ .welcome h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 8px; letter-spacing: -0.02em; }
240
+ .welcome p { color: var(--text-secondary); font-size: 0.9rem; line-height: 1.6; }
241
+
242
+ /* ── Messages ─────────────────────────────────────────────────────────── */
243
+ .message-row {
244
+ max-width: 720px; margin: 0 auto; padding: 0 24px;
245
+ animation: slideUp 0.3s ease-out;
246
+ }
247
+
248
+ .message-row + .message-row { margin-top: 24px; }
249
+
250
+ .msg-label {
251
+ font-size: 0.75rem; font-weight: 600;
252
+ text-transform: uppercase; letter-spacing: 0.04em;
253
+ color: var(--text-tertiary); margin-bottom: 6px;
254
+ }
255
+
256
+ .msg-content { font-size: 0.95rem; line-height: 1.7; word-wrap: break-word; }
257
+
258
+ .msg-content.user-msg {
259
+ background: var(--user-bg); padding: 12px 16px;
260
+ border-radius: 16px; border-bottom-right-radius: 4px;
261
+ display: inline-block; max-width: 85%; float: right;
262
+ }
263
+
264
+ .msg-content.bot-msg { clear: both; }
265
+
266
+ /* ── Thinking Block (collapsible) ──────────────────────────────────────── */
267
+ .thinking-block {
268
+ background: var(--thinking-bg); border-left: 3px solid var(--thinking-border);
269
+ padding: 0; margin: 8px 0 12px 0; border-radius: 6px;
270
+ font-size: 0.88rem; line-height: 1.6;
271
+ color: var(--text-secondary);
272
+ }
273
+
274
+ .thinking-label {
275
+ font-style: normal; font-size: 0.72rem; font-weight: 600;
276
+ text-transform: uppercase; letter-spacing: 0.05em;
277
+ color: var(--text-tertiary); padding: 8px 14px;
278
+ cursor: pointer; user-select: none;
279
+ list-style: none; /* hide default arrow */
280
+ }
281
+
282
+ .thinking-label::-webkit-details-marker { display: none; }
283
+
284
+ .thinking-label::before {
285
+ content: '▶'; margin-right: 6px; font-size: 0.6rem;
286
+ display: inline-block; transition: transform 0.15s;
287
+ }
288
+
289
+ .thinking-block[open] .thinking-label::before { transform: rotate(90deg); }
290
+
291
+ .thinking-content {
292
+ padding: 0 14px 10px 14px; font-style: italic;
293
+ }
294
+
295
+ /* ── Typing Indicator ─────────────────────────────────────────────────── */
296
+ .typing-indicator { display: flex; gap: 4px; padding: 8px 0; }
297
+
298
+ .typing-indicator span {
299
+ width: 6px; height: 6px; border-radius: 50%;
300
+ background: var(--text-tertiary); animation: typingBounce 1.4s infinite;
301
+ }
302
+
303
+ .typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
304
+ .typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
305
+
306
+ @keyframes typingBounce {
307
+ 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
308
+ 30% { transform: translateY(-6px); opacity: 1; }
309
+ }
310
+
311
+ /* ── Message Actions ──────────────────────────────────────────────────── */
312
+ .msg-actions { display: flex; gap: 4px; margin-top: 8px; opacity: 0; transition: opacity 0.2s; }
313
+ .message-row:hover .msg-actions { opacity: 1; }
314
+
315
+ .msg-action-btn {
316
+ background: transparent; border: none;
317
+ color: var(--text-tertiary); cursor: pointer;
318
+ padding: 4px 8px; border-radius: 4px;
319
+ font-size: 0.75rem; font-family: var(--font-sans);
320
+ display: flex; align-items: center; gap: 4px;
321
+ transition: all 0.15s;
322
+ }
323
+
324
+ .msg-action-btn:hover { background: var(--bg-tertiary); color: var(--text-primary); }
325
+
326
+ /* ── Markdown ─────────────────────────────────────────────────────────── */
327
+ .msg-content p { margin-bottom: 0.75em; }
328
+ .msg-content p:last-child { margin-bottom: 0; }
329
+ .msg-content ul, .msg-content ol { margin: 0.5em 0 0.5em 1.5em; }
330
+ .msg-content li { margin-bottom: 0.25em; }
331
+ .msg-content strong { font-weight: 600; }
332
+
333
+ .msg-content code {
334
+ font-family: var(--font-mono); font-size: 0.85em;
335
+ padding: 2px 6px; background: var(--bg-tertiary); border-radius: 4px;
336
+ }
337
+
338
+ .msg-content pre {
339
+ background: var(--code-bg) !important; color: var(--code-text);
340
+ padding: 14px 16px; border-radius: 8px; overflow-x: auto;
341
+ margin: 12px 0; position: relative;
342
+ }
343
+
344
+ .msg-content pre code { background: transparent; padding: 0; font-size: 0.85rem; line-height: 1.5; }
345
+
346
+ .copy-code-btn {
347
+ position: absolute; top: 8px; right: 8px;
348
+ background: rgba(255,255,255,0.1); border: none; color: #aaa;
349
+ padding: 4px 8px; border-radius: 4px;
350
+ font-size: 0.7rem; font-family: var(--font-sans);
351
+ cursor: pointer; transition: all 0.15s;
352
+ }
353
+
354
+ .copy-code-btn:hover { background: rgba(255,255,255,0.2); color: #fff; }
355
+
356
+ /* ── Input Area ───────────────────────────────────────────────────────── */
357
+ .input-area {
358
+ padding: 12px 24px 16px;
359
+ background: var(--bg-primary);
360
+ flex-shrink: 0;
361
+ transition: background 0.3s;
362
+ }
363
+
364
+ .input-container { max-width: 720px; margin: 0 auto; }
365
+
366
+ .input-wrapper {
367
+ display: flex; align-items: flex-end;
368
+ background: var(--bg-secondary);
369
+ border: 1px solid var(--border);
370
+ border-radius: 16px; padding: 8px 8px 8px 16px;
371
+ transition: border-color 0.2s, box-shadow 0.2s;
372
+ box-shadow: var(--shadow);
373
+ }
374
+
375
+ .input-wrapper:focus-within {
376
+ border-color: var(--accent);
377
+ box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.12);
378
+ }
379
+
380
+ #messageInput {
381
+ flex: 1; border: none; background: transparent;
382
+ color: var(--text-primary); font-family: var(--font-sans);
383
+ font-size: 0.95rem; line-height: 1.5;
384
+ resize: none; outline: none; max-height: 200px; padding: 6px 0;
385
+ }
386
+
387
+ #messageInput::placeholder { color: var(--text-tertiary); }
388
+
389
+ .send-btn {
390
+ width: 36px; height: 36px; border-radius: 10px;
391
+ border: none; background: var(--send-bg); color: var(--send-fg);
392
+ cursor: pointer; display: flex; align-items: center; justify-content: center;
393
+ flex-shrink: 0; transition: opacity 0.15s, transform 0.1s;
394
+ }
395
+
396
+ .send-btn:disabled { opacity: 0.3; cursor: default; }
397
+ .send-btn:not(:disabled):hover { opacity: 0.85; }
398
+ .send-btn:not(:disabled):active { transform: scale(0.95); }
399
+
400
+ .input-footer { text-align: center; padding-top: 8px; }
401
+
402
+ .model-label {
403
+ font-size: 0.7rem; color: var(--text-tertiary);
404
+ opacity: 0.6; user-select: none; transition: opacity 0.2s;
405
+ }
406
+
407
+ .model-label:hover { opacity: 1; }
408
+
409
+ /* ── Modal ────────────────────────────────────────────────────────────── */
410
+ .modal-overlay {
411
+ position: fixed; inset: 0;
412
+ background: rgba(0,0,0,0.4); backdrop-filter: blur(4px);
413
+ z-index: 300; display: flex; align-items: center; justify-content: center;
414
+ }
415
+
416
+ .modal {
417
+ background: var(--bg-primary); border: 1px solid var(--border);
418
+ border-radius: 16px; width: 480px; max-width: 90vw;
419
+ box-shadow: var(--shadow-lg); animation: slideUp 0.25s ease-out;
420
+ }
421
+
422
+ .modal-header {
423
+ display: flex; align-items: center; justify-content: space-between;
424
+ padding: 16px 20px; border-bottom: 1px solid var(--border);
425
+ }
426
+
427
+ .modal-header h3 { font-size: 1rem; font-weight: 600; }
428
+
429
+ .modal-body { padding: 20px; }
430
+
431
+ .modal-desc { font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 12px; line-height: 1.5; }
432
+
433
+ .memories-textarea {
434
+ width: 100%; height: 200px;
435
+ background: var(--bg-secondary); border: 1px solid var(--border);
436
+ border-radius: 8px; padding: 12px; resize: vertical;
437
+ font-family: var(--font-sans); font-size: 0.88rem;
438
+ color: var(--text-primary); outline: none; line-height: 1.6;
439
+ }
440
+
441
+ .memories-textarea:focus { border-color: var(--accent); }
442
+
443
+ .modal-footer {
444
+ display: flex; justify-content: flex-end; gap: 8px;
445
+ padding: 12px 20px; border-top: 1px solid var(--border);
446
+ }
447
+
448
+ .modal-btn {
449
+ padding: 8px 16px; border-radius: 8px; border: none;
450
+ font-family: var(--font-sans); font-size: 0.85rem; font-weight: 500;
451
+ cursor: pointer; transition: all 0.15s;
452
+ }
453
+
454
+ .modal-btn.primary { background: var(--accent); color: #fff; }
455
+ .modal-btn.primary:hover { opacity: 0.9; }
456
+ .modal-btn.secondary { background: var(--bg-tertiary); color: var(--text-primary); }
457
+ .modal-btn.secondary:hover { opacity: 0.8; }
458
+
459
+ /* ── Animations ───────────────────────────────────────────────────────── */
460
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
461
+
462
+ @keyframes slideUp {
463
+ from { opacity: 0; transform: translateY(12px); }
464
+ to { opacity: 1; transform: translateY(0); }
465
+ }
466
+
467
+ /* ── Responsive ───────────────────────────────────────────────────────── */
468
+ @media (max-width: 768px) {
469
+ .sidebar { transform: translateX(-100%); }
470
+ .sidebar.visible { transform: translateX(0); }
471
+ .main-content { margin-left: 0 !important; }
472
+ .message-row { padding: 0 16px; }
473
+ .input-area { padding: 8px 12px 12px; }
474
+ .welcome h1 { font-size: 1.25rem; }
475
+ .msg-content.user-msg { max-width: 92%; }
476
+ }