Gaston895 commited on
Commit
b46239f
·
0 Parent(s):

Initial commit: LFM2-700M FastAPI inference app

Browse files
Files changed (7) hide show
  1. Dockerfile +50 -0
  2. README.md +71 -0
  3. app.py +202 -0
  4. hf_space_clone +1 -0
  5. requirements.txt +7 -0
  6. start.sh +9 -0
  7. static/index.html +228 -0
Dockerfile ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # HuggingFace Spaces runs as a non-root user
4
+ RUN useradd -m -u 1000 user
5
+ WORKDIR /app
6
+
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ git \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Python deps
12
+ COPY --chown=user requirements.txt ./
13
+ RUN pip install --no-cache-dir -r requirements.txt
14
+
15
+ # App source
16
+ COPY --chown=user . .
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Pre-download model into the image at build time so startup is instant.
20
+ # Pass --build-arg HF_TOKEN=<your_token> to authenticate during build.
21
+ # On HuggingFace Spaces, set HF_TOKEN as a Space secret.
22
+ # ---------------------------------------------------------------------------
23
+ ARG HF_TOKEN=""
24
+ ENV MODEL_ID=gsstec/LFM2-700M
25
+ ENV HF_HOME=/app/model_cache
26
+ ENV TRANSFORMERS_CACHE=/app/model_cache
27
+ ENV HF_HUB_DISABLE_PROGRESS_BARS=1
28
+
29
+ RUN HF_TOKEN="${HF_TOKEN}" \
30
+ HUGGING_FACE_HUB_TOKEN="${HF_TOKEN}" \
31
+ python - <<'EOF'
32
+ from transformers import AutoModelForCausalLM, AutoTokenizer
33
+ import os
34
+ model_id = os.environ["MODEL_ID"]
35
+ token = os.environ.get("HF_TOKEN") or None
36
+ print(f"Pre-downloading {model_id} (authenticated={bool(token)}) ...")
37
+ AutoTokenizer.from_pretrained(model_id, token=token, clean_up_tokenization_spaces=False)
38
+ AutoModelForCausalLM.from_pretrained(model_id, token=token, device_map="cpu")
39
+ print("Done.")
40
+ EOF
41
+
42
+ RUN chown -R user:user /app/model_cache
43
+
44
+ USER user
45
+
46
+ EXPOSE 7860
47
+
48
+ # python app.py — model loads at module level, then uvicorn starts in the same process
49
+ # No forking, no workers — model is guaranteed loaded before first request
50
+ CMD ["python", "app.py"]
README.md ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: LFM2-700M
3
+ emoji: 🤖
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 7860
9
+ ---
10
+
11
+ # LFM2-700M — FastAPI Inference Space
12
+
13
+ Runs [gsstec/LFM2-700M](https://huggingface.co/gsstec/LFM2-700M) behind a lightweight **FastAPI** server.
14
+
15
+ ## Endpoints
16
+
17
+ | Method | Path | Description |
18
+ |--------|------|-------------|
19
+ | `GET` | `/` | Interactive HTML docs page |
20
+ | `GET` | `/health` | Liveness check |
21
+ | `POST` | `/chat` | Generate a response |
22
+
23
+ ## POST /chat — request body
24
+
25
+ ```json
26
+ {
27
+ "messages": [
28
+ {"role": "user", "content": "What is C. elegans?"}
29
+ ],
30
+ "max_new_tokens": 512,
31
+ "temperature": 0.3,
32
+ "min_p": 0.15,
33
+ "repetition_penalty": 1.05,
34
+ "stream": false
35
+ }
36
+ ```
37
+
38
+ Set `"stream": true` to receive a streaming `text/plain` response via chunked transfer.
39
+
40
+ ## POST /chat — response body (non-streaming)
41
+
42
+ ```json
43
+ {
44
+ "response": "C. elegans, also known as Caenorhabditis elegans, is a small ..."
45
+ }
46
+ ```
47
+
48
+ ## Local development
49
+
50
+ ```bash
51
+ pip install -r requirements.txt
52
+ uvicorn app:app --reload --port 7860
53
+ ```
54
+
55
+ ## Docker
56
+
57
+ ```bash
58
+ docker build -t lfm2-700m .
59
+ docker run -p 7860:7860 lfm2-700m
60
+ ```
61
+
62
+ ## Deploy to HuggingFace Spaces
63
+
64
+ 1. Create a new Space at https://huggingface.co/new-space → choose **Docker** SDK
65
+ 2. Push all files to the Space repo:
66
+ ```bash
67
+ git remote add space https://huggingface.co/spaces/<your-org>/<space-name>
68
+ git push space main
69
+ ```
70
+
71
+ > **GPU tip:** Upgrade the Space hardware to a GPU tier and uncomment `attn_implementation="flash_attention_2"` in `app.py` for faster inference.
app.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import torch
4
+ import logging
5
+
6
+ # ---------------------------------------------------------------------------
7
+ # Environment must be configured BEFORE importing transformers/huggingface_hub
8
+ # ---------------------------------------------------------------------------
9
+ os.environ.setdefault("HF_HOME", "/app/model_cache")
10
+ os.environ.setdefault("TRANSFORMERS_CACHE", "/app/model_cache")
11
+
12
+ # Propagate HF_TOKEN so authenticated requests are made automatically
13
+ _hf_token = os.getenv("HF_TOKEN")
14
+ if _hf_token:
15
+ os.environ["HUGGING_FACE_HUB_TOKEN"] = _hf_token
16
+ os.environ["HF_TOKEN"] = _hf_token
17
+
18
+ from fastapi import FastAPI, HTTPException, Request
19
+ from fastapi.exceptions import RequestValidationError
20
+ from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
21
+ from pydantic import BaseModel
22
+ from typing import List
23
+ from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
24
+ from threading import Thread
25
+
26
+ logging.basicConfig(level=logging.WARNING)
27
+ logger = logging.getLogger(__name__)
28
+ logging.getLogger("uvicorn.access").setLevel(logging.INFO)
29
+
30
+ MODEL_ID = os.getenv("MODEL_ID", "gsstec/LFM2-700M")
31
+
32
+ # Max out CPU threads for faster matrix ops
33
+ _num_threads = int(os.getenv("NUM_THREADS", os.cpu_count() or 4))
34
+ torch.set_num_threads(_num_threads)
35
+ torch.set_num_interop_threads(max(1, _num_threads // 2))
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Model loading — runs at import time, before uvicorn accepts any requests
39
+ # ---------------------------------------------------------------------------
40
+ _token_kwarg = {"token": _hf_token} if _hf_token else {}
41
+ print(f"Loading model: {MODEL_ID} (CPU threads: {_num_threads}, authenticated={bool(_hf_token)})")
42
+
43
+ tokenizer = AutoTokenizer.from_pretrained(
44
+ MODEL_ID,
45
+ clean_up_tokenization_spaces=False,
46
+ **_token_kwarg,
47
+ )
48
+ tokenizer.padding_side = "left"
49
+ if tokenizer.pad_token is None:
50
+ tokenizer.pad_token = tokenizer.eos_token
51
+
52
+ model = AutoModelForCausalLM.from_pretrained(
53
+ MODEL_ID,
54
+ device_map="auto",
55
+ torch_dtype=torch.bfloat16,
56
+ # attn_implementation="flash_attention_2", # uncomment on compatible GPU
57
+ **_token_kwarg,
58
+ )
59
+ model.eval()
60
+
61
+ print("Model ready.")
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # App
65
+ # ---------------------------------------------------------------------------
66
+ app = FastAPI(title="LFM2-700M Inference API")
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Validation error handler
71
+ # ---------------------------------------------------------------------------
72
+ @app.exception_handler(RequestValidationError)
73
+ async def validation_exception_handler(request: Request, exc: RequestValidationError):
74
+ body = await request.body()
75
+ safe_errors = [
76
+ {k: (v.decode(errors="replace") if isinstance(v, bytes) else v)
77
+ for k, v in err.items()}
78
+ for err in exc.errors()
79
+ ]
80
+ logger.error(f"422 {request.method} {request.url} | {safe_errors}")
81
+ return JSONResponse(
82
+ status_code=422,
83
+ content={"detail": safe_errors, "body_received": body.decode(errors="replace")},
84
+ )
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Schemas
89
+ # ---------------------------------------------------------------------------
90
+ class Message(BaseModel):
91
+ role: str # "user" | "assistant" | "system"
92
+ content: str
93
+
94
+
95
+ class ChatRequest(BaseModel):
96
+ messages: List[Message]
97
+ max_new_tokens: int = 512
98
+ temperature: float = 0.3
99
+ min_p: float = 0.15
100
+ repetition_penalty: float = 1.05
101
+ stream: bool = False
102
+
103
+
104
+ class ChatResponse(BaseModel):
105
+ response: str
106
+
107
+
108
+ class AskRequest(BaseModel):
109
+ question: str
110
+ max_new_tokens: int = 512
111
+ temperature: float = 0.3
112
+ min_p: float = 0.15
113
+ repetition_penalty: float = 1.05
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Shared helpers
118
+ # ---------------------------------------------------------------------------
119
+ def build_input_ids(messages: List[Message]) -> torch.Tensor:
120
+ chat = [{"role": m.role, "content": m.content} for m in messages]
121
+ encoding = tokenizer.apply_chat_template(
122
+ chat,
123
+ add_generation_prompt=True,
124
+ return_tensors="pt",
125
+ tokenize=True,
126
+ return_dict=True,
127
+ )
128
+ return encoding["input_ids"].to(model.device)
129
+
130
+
131
+ def _generate(input_ids: torch.Tensor, req, streamer=None) -> torch.Tensor:
132
+ kwargs = dict(
133
+ input_ids=input_ids,
134
+ do_sample=True,
135
+ temperature=req.temperature,
136
+ min_p=req.min_p,
137
+ repetition_penalty=req.repetition_penalty,
138
+ max_new_tokens=req.max_new_tokens,
139
+ use_cache=True,
140
+ )
141
+ if streamer:
142
+ kwargs["streamer"] = streamer
143
+ with torch.inference_mode():
144
+ return model.generate(**kwargs)
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Routes
149
+ # ---------------------------------------------------------------------------
150
+ _UI_HTML = open(os.path.join(os.path.dirname(__file__), "static", "index.html"), encoding="utf-8").read()
151
+
152
+
153
+ @app.get("/", response_class=HTMLResponse)
154
+ @app.get("/ui", response_class=HTMLResponse)
155
+ def ui():
156
+ """Chat UI — served at both / and /ui"""
157
+ return _UI_HTML
158
+
159
+
160
+ @app.get("/health")
161
+ def health():
162
+ return {"status": "ok", "model": MODEL_ID, "ready": True,
163
+ "cpu_threads": _num_threads}
164
+
165
+
166
+ @app.post("/chat")
167
+ async def chat(request: Request):
168
+ try:
169
+ body = await request.body()
170
+ req = ChatRequest(**json.loads(body))
171
+ except Exception as e:
172
+ raise HTTPException(status_code=422, detail=f"Invalid request body: {e}")
173
+
174
+ input_ids = build_input_ids(req.messages)
175
+
176
+ if req.stream:
177
+ streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
178
+ Thread(target=_generate, args=(input_ids, req, streamer), daemon=True).start()
179
+ return StreamingResponse((tok for tok in streamer), media_type="text/plain")
180
+
181
+ output = _generate(input_ids, req)
182
+ new_tokens = output[0][input_ids.shape[-1]:]
183
+ return ChatResponse(response=tokenizer.decode(new_tokens, skip_special_tokens=True))
184
+
185
+
186
+ @app.post("/ask")
187
+ async def ask(request: Request):
188
+ try:
189
+ body = await request.body()
190
+ req = AskRequest(**json.loads(body))
191
+ except Exception as e:
192
+ raise HTTPException(status_code=422, detail=f"Invalid request body: {e}")
193
+
194
+ input_ids = build_input_ids([Message(role="user", content=req.question)])
195
+ output = _generate(input_ids, req)
196
+ new_tokens = output[0][input_ids.shape[-1]:]
197
+ return ChatResponse(response=tokenizer.decode(new_tokens, skip_special_tokens=True))
198
+
199
+
200
+ if __name__ == "__main__":
201
+ import uvicorn
202
+ uvicorn.run(app, host="0.0.0.0", port=7860, log_level="warning", access_log=True)
hf_space_clone ADDED
@@ -0,0 +1 @@
 
 
1
+ Subproject commit 1ca5003f8c3bf11b0547164f7e7fc9cf2c2d3a20
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ fastapi>=0.111.0
2
+ uvicorn[standard]>=0.29.0
3
+ transformers>=4.51.0
4
+ torch>=2.3.0
5
+ accelerate>=0.30.0
6
+ pydantic>=2.0.0
7
+ sentencepiece
start.sh ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # start.sh — pre-warm model then hand off to uvicorn
3
+ # Python loads app.py (model loading) then exec's uvicorn so it inherits the process
4
+
5
+ exec python -c "
6
+ import app # runs all module-level code: model loads here
7
+ import uvicorn
8
+ uvicorn.run(app.app, host='0.0.0.0', port=7860, log_level='warning', access_log=True)
9
+ "
static/index.html ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>LFM2-700M Chat</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ body {
11
+ font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
12
+ background: #f7f8fa;
13
+ color: #1f2328;
14
+ height: 100vh;
15
+ display: flex;
16
+ flex-direction: column;
17
+ align-items: center;
18
+ padding: 24px 16px;
19
+ }
20
+
21
+ h1 {
22
+ font-size: 1.2rem;
23
+ font-weight: 600;
24
+ margin-bottom: 16px;
25
+ color: #1f2328;
26
+ }
27
+ h1 span { color: #3b82d4; }
28
+
29
+ #chat-box {
30
+ width: 100%;
31
+ max-width: 720px;
32
+ flex: 1;
33
+ overflow-y: auto;
34
+ background: #fff;
35
+ border: 1px solid #e5e7eb;
36
+ border-radius: 10px;
37
+ padding: 16px;
38
+ display: flex;
39
+ flex-direction: column;
40
+ gap: 12px;
41
+ margin-bottom: 16px;
42
+ }
43
+
44
+ .msg {
45
+ max-width: 85%;
46
+ padding: 10px 14px;
47
+ border-radius: 10px;
48
+ line-height: 1.6;
49
+ font-size: 0.93rem;
50
+ white-space: pre-wrap;
51
+ word-break: break-word;
52
+ }
53
+ .msg.user {
54
+ align-self: flex-end;
55
+ background: #3b82d4;
56
+ color: #fff;
57
+ border-bottom-right-radius: 2px;
58
+ }
59
+ .msg.assistant {
60
+ align-self: flex-start;
61
+ background: #f0f2f5;
62
+ color: #1f2328;
63
+ border-bottom-left-radius: 2px;
64
+ }
65
+ .msg.thinking {
66
+ align-self: flex-start;
67
+ background: #f0f2f5;
68
+ color: #57606a;
69
+ font-style: italic;
70
+ animation: pulse 1.2s ease-in-out infinite;
71
+ }
72
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
73
+
74
+ #input-row {
75
+ display: flex;
76
+ width: 100%;
77
+ max-width: 720px;
78
+ gap: 8px;
79
+ }
80
+
81
+ #question {
82
+ flex: 1;
83
+ padding: 10px 14px;
84
+ font-size: 0.95rem;
85
+ border: 1px solid #e5e7eb;
86
+ border-radius: 8px;
87
+ outline: none;
88
+ background: #fff;
89
+ resize: none;
90
+ height: 44px;
91
+ line-height: 1.4;
92
+ font-family: inherit;
93
+ }
94
+ #question:focus { border-color: #3b82d4; }
95
+
96
+ #send-btn {
97
+ padding: 0 20px;
98
+ height: 44px;
99
+ background: #3b82d4;
100
+ color: #fff;
101
+ border: none;
102
+ border-radius: 8px;
103
+ font-size: 0.95rem;
104
+ font-weight: 500;
105
+ cursor: pointer;
106
+ white-space: nowrap;
107
+ }
108
+ #send-btn:disabled { background: #a5c4ef; cursor: not-allowed; }
109
+ #send-btn:hover:not(:disabled) { background: #2b6cbf; }
110
+
111
+ #settings {
112
+ display: flex;
113
+ gap: 12px;
114
+ width: 100%;
115
+ max-width: 720px;
116
+ margin-bottom: 10px;
117
+ flex-wrap: wrap;
118
+ }
119
+ .setting {
120
+ display: flex;
121
+ align-items: center;
122
+ gap: 6px;
123
+ font-size: 0.82rem;
124
+ color: #57606a;
125
+ }
126
+ .setting input[type=range] { width: 80px; }
127
+ .setting span { min-width: 28px; text-align: right; font-weight: 500; color: #1f2328; }
128
+ </style>
129
+ </head>
130
+ <body>
131
+
132
+ <h1>🤖 <span>LFM2-700M</span> Chat</h1>
133
+
134
+ <div id="settings">
135
+ <div class="setting">
136
+ Max tokens
137
+ <input type="range" id="max_tokens" min="64" max="1024" step="64" value="512"
138
+ oninput="document.getElementById('mt_val').textContent=this.value">
139
+ <span id="mt_val">512</span>
140
+ </div>
141
+ <div class="setting">
142
+ Temperature
143
+ <input type="range" id="temperature" min="0.01" max="1.5" step="0.01" value="0.3"
144
+ oninput="document.getElementById('temp_val').textContent=parseFloat(this.value).toFixed(2)">
145
+ <span id="temp_val">0.30</span>
146
+ </div>
147
+ <div class="setting">
148
+ Min-P
149
+ <input type="range" id="min_p" min="0" max="1" step="0.01" value="0.15"
150
+ oninput="document.getElementById('mp_val').textContent=parseFloat(this.value).toFixed(2)">
151
+ <span id="mp_val">0.15</span>
152
+ </div>
153
+ </div>
154
+
155
+ <div id="chat-box"></div>
156
+
157
+ <div id="input-row">
158
+ <textarea id="question" placeholder="Ask anything…" rows="1"></textarea>
159
+ <button id="send-btn" onclick="sendQuestion()">Send</button>
160
+ </div>
161
+
162
+ <script>
163
+ const API = ''; // same origin — served by FastAPI
164
+
165
+ const chatBox = document.getElementById('chat-box');
166
+ const input = document.getElementById('question');
167
+ const sendBtn = document.getElementById('send-btn');
168
+
169
+ // Send on Enter (Shift+Enter = newline)
170
+ input.addEventListener('keydown', e => {
171
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendQuestion(); }
172
+ });
173
+
174
+ function appendMsg(role, text) {
175
+ const div = document.createElement('div');
176
+ div.className = `msg ${role}`;
177
+ div.textContent = text;
178
+ chatBox.appendChild(div);
179
+ chatBox.scrollTop = chatBox.scrollHeight;
180
+ return div;
181
+ }
182
+
183
+ async function sendQuestion() {
184
+ const q = input.value.trim();
185
+ if (!q) return;
186
+
187
+ input.value = '';
188
+ sendBtn.disabled = true;
189
+ appendMsg('user', q);
190
+ const thinking = appendMsg('thinking', 'Thinking…');
191
+
192
+ const body = {
193
+ question: q,
194
+ max_new_tokens: parseInt(document.getElementById('max_tokens').value),
195
+ temperature: parseFloat(document.getElementById('temperature').value),
196
+ min_p: parseFloat(document.getElementById('min_p').value),
197
+ repetition_penalty: 1.05
198
+ };
199
+
200
+ try {
201
+ const res = await fetch(`${API}/ask`, {
202
+ method: 'POST',
203
+ headers: { 'Content-Type': 'application/json' },
204
+ body: JSON.stringify(body)
205
+ });
206
+
207
+ if (!res.ok) {
208
+ const err = await res.text();
209
+ thinking.className = 'msg assistant';
210
+ thinking.textContent = `Error ${res.status}: ${err}`;
211
+ } else {
212
+ const data = await res.json();
213
+ thinking.className = 'msg assistant';
214
+ thinking.textContent = data.response;
215
+ }
216
+ } catch (err) {
217
+ thinking.className = 'msg assistant';
218
+ thinking.textContent = `Network error: ${err.message}`;
219
+ }
220
+
221
+ sendBtn.disabled = false;
222
+ input.focus();
223
+ chatBox.scrollTop = chatBox.scrollHeight;
224
+ }
225
+ </script>
226
+
227
+ </body>
228
+ </html>