akhaliq HF Staff commited on
Commit
2866f2d
·
1 Parent(s): ee98620

feat: implement streaming inference with history support and update UI theme

Browse files
Files changed (3) hide show
  1. app.py +103 -109
  2. index.html +387 -146
  3. requirements.txt +1 -0
app.py CHANGED
@@ -2,27 +2,33 @@ import os
2
  import torch
3
  import re
4
  import av
 
 
 
 
5
  from PIL import Image
6
- from transformers import AutoModelForImageTextToText, AutoProcessor
7
  from gradio import Server
8
  from gradio.data_classes import FileData
9
  from fastapi.responses import HTMLResponse
10
  import spaces
11
 
12
- # Load model and processor
13
- model_id = "openbmb/MiniCPM-V-4.6"
14
- print(f"Loading model: {model_id}...")
15
-
16
- processor = AutoProcessor.from_pretrained(model_id, trust_remote_code=True)
17
- model = AutoModelForImageTextToText.from_pretrained(
18
- model_id,
19
- torch_dtype=torch.bfloat16,
 
20
  trust_remote_code=True,
21
- device_map="cuda"
22
- )
 
 
23
 
24
  def load_video(video_path, max_frames=64):
25
- """Utility to load video frames using PyAV."""
26
  try:
27
  container = av.open(video_path)
28
  frames = []
@@ -30,11 +36,9 @@ def load_video(video_path, max_frames=64):
30
  total_frames = stream.frames
31
 
32
  if total_frames <= 0:
33
- print("Frame count unknown, decoding all and sampling...")
34
  temp_frames = []
35
  for frame in container.decode(video=0):
36
  temp_frames.append(frame.to_image())
37
-
38
  if len(temp_frames) > max_frames:
39
  indices = [int(i * len(temp_frames) / max_frames) for i in range(max_frames)]
40
  frames = [temp_frames[i] for i in indices]
@@ -55,116 +59,106 @@ def load_video(video_path, max_frames=64):
55
  print(f"Error loading video: {e}")
56
  return None
57
 
58
- # Utility for response normalization
59
- _PATTERN = re.compile(
60
- r'(```[\s\S]*?```|`[^`]+`|\$\$[\s\S]*?\$\$|\$[^$]+\$|\\\([\s\S]*?\\\)|\\\[[\s\S]*?\\\])'
61
- r'|(?<!\\)(?:\\r\\n|\\[nr])'
62
- )
63
-
64
  def normalize_response_text(text: str) -> str:
65
- if not isinstance(text, str) or "\\" not in text:
66
- return text
67
- return _PATTERN.sub(lambda m: m.group(1) or '\n', text)
 
68
 
69
  app = Server()
70
 
71
  @app.api()
72
  @spaces.GPU(duration=120)
73
- def predict(message: str, file: FileData = None, downsample_mode: str = "16x") -> str:
 
 
 
 
 
 
 
 
 
 
74
  """
75
- General inference endpoint for both image and video.
76
  """
77
- if file is None:
78
- # Text-only inference
79
- messages = [{"role": "user", "content": [{"type": "text", "text": message}]}]
80
- inputs = processor.apply_chat_template(
81
- messages,
82
- tokenize=True,
83
- add_generation_prompt=True,
84
- return_dict=True,
85
- return_tensors="pt"
86
- ).to(model.device)
87
- else:
88
- file_path = file["path"]
89
-
90
- # Robust detection: Try opening with AV first to see if it's a video
91
- is_video = False
92
- try:
93
- container = av.open(file_path)
94
- if len(container.streams.video) > 0:
95
- is_video = True
96
- container.close()
97
- except:
98
  is_video = False
 
 
 
 
 
 
 
99
 
100
- if is_video:
101
- print(f"Processing as video: {file_path}")
102
- frames = load_video(file_path, max_frames=64)
103
- if frames is None or len(frames) == 0:
104
- return "Error: Could not decode video file."
105
-
106
- messages = [
107
- {
108
- "role": "user",
109
- "content": [
110
- {"type": "video", "video": frames},
111
- {"type": "text", "text": message},
112
- ],
113
- }
114
- ]
115
- inputs = processor.apply_chat_template(
116
- messages, tokenize=True, add_generation_prompt=True,
117
- return_dict=True, return_tensors="pt",
118
- processor_kwargs={
119
- "downsample_mode": downsample_mode,
120
- "max_num_frames": 64,
121
- "stack_frames": 1,
122
- "max_slice_nums": 1,
123
- "use_image_id": False,
124
- "do_sample_frames": False, # Fix: Avoid requiring metadata since we already sampled
125
- }
126
- ).to(model.device)
127
- else:
128
- print(f"Processing as image: {file_path}")
129
- messages = [
130
- {
131
- "role": "user",
132
- "content": [
133
- {"type": "image", "url": file_path},
134
- {"type": "text", "text": message},
135
- ],
136
- }
137
- ]
138
- inputs = processor.apply_chat_template(
139
- messages, tokenize=True, add_generation_prompt=True,
140
- return_dict=True, return_tensors="pt",
141
- processor_kwargs={
142
- "downsample_mode": downsample_mode,
143
- "max_slice_nums": 9,
144
- }
145
- ).to(model.device)
146
 
147
- with torch.no_grad():
148
- generate_kwargs = {
149
- **inputs,
150
- "max_new_tokens": 1024,
151
- "do_sample": True,
152
- "temperature": 0.7
 
 
 
153
  }
154
-
155
- if file is not None:
156
- generate_kwargs["downsample_mode"] = downsample_mode
157
-
158
- generated_ids = model.generate(**generate_kwargs)
159
 
160
- generated_ids_trimmed = [
161
- out_ids[len(in_ids):] for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
162
- ]
163
- output_text = processor.batch_decode(
164
- generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
 
 
 
 
165
  )
166
 
167
- return normalize_response_text(output_text[0])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
 
169
  @app.get("/", response_class=HTMLResponse)
170
  async def homepage():
 
2
  import torch
3
  import re
4
  import av
5
+ import uuid
6
+ import copy
7
+ import threading
8
+ import time
9
  from PIL import Image
10
+ from transformers import AutoProcessor, MiniCPMV4_6ForConditionalGeneration, TextIteratorStreamer
11
  from gradio import Server
12
  from gradio.data_classes import FileData
13
  from fastapi.responses import HTMLResponse
14
  import spaces
15
 
16
+ # ---------- Globals & Model Loading ----------
17
+ MODEL_ID = "openbmb/MiniCPM-V-4.6"
18
+ print(f"Loading processor: {MODEL_ID}")
19
+ processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True)
20
+ print(f"Loading model: {MODEL_ID}")
21
+ model = MiniCPMV4_6ForConditionalGeneration.from_pretrained(
22
+ MODEL_ID,
23
+ torch_dtype=torch.bfloat16,
24
+ attn_implementation="sdpa",
25
  trust_remote_code=True,
26
+ device_map="cuda"
27
+ ).eval()
28
+
29
+ # ---------- Helper Functions ----------
30
 
31
  def load_video(video_path, max_frames=64):
 
32
  try:
33
  container = av.open(video_path)
34
  frames = []
 
36
  total_frames = stream.frames
37
 
38
  if total_frames <= 0:
 
39
  temp_frames = []
40
  for frame in container.decode(video=0):
41
  temp_frames.append(frame.to_image())
 
42
  if len(temp_frames) > max_frames:
43
  indices = [int(i * len(temp_frames) / max_frames) for i in range(max_frames)]
44
  frames = [temp_frames[i] for i in indices]
 
59
  print(f"Error loading video: {e}")
60
  return None
61
 
 
 
 
 
 
 
62
  def normalize_response_text(text: str) -> str:
63
+ # Basic normalization, could be expanded
64
+ return text.replace("\\n", "\n")
65
+
66
+ # ---------- Inference Endpoint ----------
67
 
68
  app = Server()
69
 
70
  @app.api()
71
  @spaces.GPU(duration=120)
72
+ def predict(
73
+ message: str,
74
+ history: list[list[str, str]] = None,
75
+ files: list[FileData] = None,
76
+ thinking_mode: bool = True,
77
+ max_new_tokens: int = 1024,
78
+ temperature: float = 0.7,
79
+ top_p: float = 0.8,
80
+ top_k: int = 100,
81
+ max_frames: int = 64
82
+ ):
83
  """
84
+ Streaming inference endpoint with history support.
85
  """
86
+ messages = []
87
+
88
+ # Process history
89
+ if history:
90
+ for turn in history:
91
+ if turn[0]:
92
+ messages.append({"role": "user", "content": [{"type": "text", "text": turn[0]}]})
93
+ if turn[1]:
94
+ messages.append({"role": "assistant", "content": [{"type": "text", "text": turn[1]}]})
95
+
96
+ content = []
97
+
98
+ if files:
99
+ for f in files:
100
+ file_path = f["path"]
101
+ # Detect if video or image
 
 
 
 
 
102
  is_video = False
103
+ try:
104
+ container = av.open(file_path)
105
+ if len(container.streams.video) > 0:
106
+ is_video = True
107
+ container.close()
108
+ except:
109
+ is_video = False
110
 
111
+ if is_video:
112
+ frames = load_video(file_path, max_frames=max_frames)
113
+ if frames:
114
+ content.append({"type": "video", "video": frames})
115
+ else:
116
+ content.append({"type": "image", "image": Image.open(file_path).convert("RGB")})
117
+
118
+ content.append({"type": "text", "text": message})
119
+ messages = [{"role": "user", "content": content}]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
 
121
+ inputs = processor.apply_chat_template(
122
+ messages,
123
+ add_generation_prompt=True,
124
+ tokenize=True,
125
+ return_dict=True,
126
+ return_tensors="pt",
127
+ enable_thinking=thinking_mode,
128
+ processor_kwargs={
129
+ "max_num_frames": max_frames,
130
  }
131
+ ).to(model.device)
 
 
 
 
132
 
133
+ # Cast float tensors to bfloat16
134
+ for k, v in inputs.items():
135
+ if isinstance(v, torch.Tensor) and torch.is_floating_point(v):
136
+ inputs[k] = v.to(dtype=torch.bfloat16)
137
+
138
+ streamer = TextIteratorStreamer(
139
+ processor.tokenizer,
140
+ skip_prompt=True,
141
+ skip_special_tokens=True,
142
  )
143
 
144
+ generate_kwargs = {
145
+ **inputs,
146
+ "max_new_tokens": max_new_tokens,
147
+ "do_sample": temperature > 0,
148
+ "temperature": temperature,
149
+ "top_p": top_p,
150
+ "top_k": top_k,
151
+ "streamer": streamer,
152
+ }
153
+
154
+ # Start generation in a separate thread
155
+ thread = threading.Thread(target=model.generate, kwargs=generate_kwargs)
156
+ thread.start()
157
+
158
+ full_text = ""
159
+ for new_text in streamer:
160
+ full_text += new_text
161
+ yield normalize_response_text(full_text)
162
 
163
  @app.get("/", response_class=HTMLResponse)
164
  async def homepage():
index.html CHANGED
@@ -3,19 +3,20 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
- <title>MiniCPM-V | OpenBMB</title>
7
  <script src="https://cdn.tailwindcss.com"></script>
8
  <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
9
  <script src="https://unpkg.com/lucide@latest"></script>
10
  <style>
11
  :root {
12
- --bg: #0A0C10;
13
  --blue: #3B5BFF;
14
  --cyan: #27D4EA;
15
  --text: #FFFFFF;
16
- --text-muted: #6E7681;
17
  --glass: rgba(255, 255, 255, 0.03);
18
- --glass-border: rgba(255, 255, 255, 0.1);
 
19
  }
20
 
21
  body {
@@ -26,7 +27,7 @@
26
  margin: 0;
27
  display: flex;
28
  flex-direction: column;
29
- overflow: hidden; /* Prevent body scroll */
30
  }
31
 
32
  h1, h2, h3 { font-family: 'Outfit', sans-serif; }
@@ -34,46 +35,53 @@
34
  .chat-scroll-area {
35
  flex: 1;
36
  overflow-y: auto;
37
- padding-bottom: 120px; /* Space for floating input */
38
  -webkit-overflow-scrolling: touch;
 
39
  }
40
 
41
- /* Modern Scrollbar */
42
- .chat-scroll-area::-webkit-scrollbar {
43
- width: 5px;
44
- }
45
- .chat-scroll-area::-webkit-scrollbar-track {
46
- background: transparent;
47
- }
48
- .chat-scroll-area::-webkit-scrollbar-thumb {
49
- background: rgba(255, 255, 255, 0.1);
50
- border-radius: 10px;
51
- }
52
 
53
  .message-bubble {
54
  max-width: 85%;
55
  animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
 
56
  }
57
 
58
  @keyframes fadeIn {
59
- from { opacity: 0; transform: translateY(10px); }
60
  to { opacity: 1; transform: translateY(0); }
61
  }
62
 
63
  .user-message {
64
  background: linear-gradient(135deg, var(--blue), var(--cyan));
65
  color: #FFFFFF;
66
- box-shadow: 0 10px 30px rgba(59, 91, 255, 0.2);
 
67
  }
68
 
69
  .bot-message {
70
- background: rgba(255, 255, 255, 0.04);
71
  border: 1px solid var(--glass-border);
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  }
73
 
74
  .typing-dot {
75
- width: 4px;
76
- height: 4px;
77
  background: var(--cyan);
78
  border-radius: 50%;
79
  animation: bounce 1.4s infinite ease-in-out;
@@ -87,94 +95,205 @@
87
  }
88
 
89
  .input-pill {
90
- background: rgba(255, 255, 255, 0.05);
91
- backdrop-filter: blur(20px);
92
- -webkit-backdrop-filter: blur(20px);
93
  border: 1px solid var(--glass-border);
94
- transition: all 0.3s ease;
95
  }
96
 
97
  .input-pill:focus-within {
98
- border-color: var(--blue);
99
- box-shadow: 0 0 30px rgba(59, 91, 255, 0.1);
 
100
  }
101
 
102
  .logo-glow {
103
- filter: drop-shadow(0 0 10px rgba(39, 212, 234, 0.3));
104
  }
105
 
106
  .send-btn {
107
  background: linear-gradient(135deg, var(--blue), var(--cyan));
108
- transition: transform 0.2s ease, opacity 0.2s ease;
109
  }
110
- .send-btn:active { transform: scale(0.95); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
- #user-input::placeholder { color: #555; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  </style>
114
  </head>
115
  <body>
116
 
117
- <!-- Minimalist Header -->
118
- <header class="h-20 flex items-center justify-between px-6 md:px-12 shrink-0 z-50">
119
  <div class="flex items-center gap-4">
120
- <img src="https://cdn-avatars.huggingface.co/v1/production/uploads/1670387859384-633fe7784b362488336bbfad.png"
121
- alt="OpenBMB" class="w-10 h-10 logo-glow">
 
 
 
122
  <div>
123
- <h1 class="text-xl font-bold tracking-tight">MiniCPM-V</h1>
124
- <p class="text-[10px] text-muted uppercase tracking-[0.2em] font-medium">By OpenBMB</p>
125
  </div>
126
  </div>
127
- <div class="hidden md:flex items-center gap-2 text-[10px] font-bold text-muted uppercase tracking-widest">
128
- <span class="w-1.5 h-1.5 rounded-full bg-[#27D4EA] animate-pulse"></span>
129
- Vision System Online
 
 
 
 
 
130
  </div>
131
  </header>
132
 
133
- <!-- Chat Messages Scroll Area -->
134
- <main id="chat-messages" class="chat-scroll-area px-4 md:px-0">
135
- <div class="max-w-3xl mx-auto space-y-8 pt-4">
136
- <!-- Bot Greeting -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  <div class="flex gap-4 items-start">
138
- <div class="bot-message p-6 rounded-3xl rounded-tl-none message-bubble shadow-2xl">
139
  <p class="text-white/90 leading-relaxed text-[15px]">
140
- Welcome to <span class="font-bold text-[#27D4EA]">MiniCPM-V 4.6</span>.
141
- I can analyze images and videos with high efficiency.
142
  <br><br>
143
- Drop a file below to begin.
144
  </p>
145
  </div>
146
  </div>
147
  </div>
148
  </main>
149
 
150
- <!-- Floating Input Bar -->
151
  <div class="fixed bottom-0 left-0 right-0 p-6 md:p-10 pointer-events-none">
152
  <div class="max-w-3xl mx-auto pointer-events-auto">
153
- <!-- Media Preview -->
154
- <div id="preview-container" class="hidden mb-6 animate-in">
155
- <div class="relative inline-block group">
156
- <img id="image-preview" src="" class="h-36 w-auto rounded-3xl border border-white/20 shadow-2xl hidden object-cover" />
157
- <video id="video-preview" class="h-36 w-auto rounded-3xl border border-white/20 shadow-2xl hidden object-cover" muted loop></video>
158
- <button id="cancel-file" class="absolute -top-3 -right-3 bg-white text-black rounded-full p-2 shadow-xl hover:bg-neutral-200 transition-all">
159
- <i data-lucide="x" class="w-4 h-4"></i>
160
- </button>
161
- </div>
162
  </div>
163
 
164
- <!-- Pill Input -->
165
- <div class="input-pill rounded-[2.5rem] p-2 flex items-end gap-2 pr-3 shadow-2xl">
166
  <div class="flex items-center">
167
- <input type="file" id="file-input" class="hidden" accept="image/*,video/*">
168
- <button id="upload-trigger" class="p-4 text-white/30 hover:text-[#27D4EA] transition-colors">
169
- <i data-lucide="paperclip" class="w-6 h-6"></i>
 
170
  </button>
171
  </div>
172
 
173
- <textarea id="user-input" rows="1" placeholder="Type your message..."
174
- class="flex-1 bg-transparent border-none focus:ring-0 text-white py-4 px-1 resize-none max-h-40 scrollbar-none text-[16px] leading-relaxed"></textarea>
175
 
176
- <button id="send-btn" class="send-btn w-12 h-12 text-white rounded-full flex items-center justify-center disabled:opacity-20 disabled:grayscale group shrink-0 mb-1">
177
- <i data-lucide="arrow-up" class="w-5 h-5 group-hover:scale-110 transition-transform" id="send-icon"></i>
178
  <i data-lucide="loader-2" class="w-5 h-5 animate-spin hidden" id="loading-icon"></i>
179
  </button>
180
  </div>
@@ -186,20 +305,33 @@
186
 
187
  lucide.createIcons();
188
 
189
- const chatMessages = document.getElementById('chat-messages');
 
 
190
  const userInput = document.getElementById('user-input');
191
  const sendBtn = document.getElementById('send-btn');
192
  const fileInput = document.getElementById('file-input');
193
  const uploadTrigger = document.getElementById('upload-trigger');
194
  const previewContainer = document.getElementById('preview-container');
195
- const imagePreview = document.getElementById('image-preview');
196
- const videoPreview = document.getElementById('video-preview');
197
- const cancelFile = document.getElementById('cancel-file');
198
  const sendIcon = document.getElementById('send-icon');
199
  const loadingIcon = document.getElementById('loading-icon');
200
-
201
- let selectedFile = null;
 
 
 
 
 
 
 
 
 
 
202
  let client = null;
 
 
 
203
 
204
  async function init() {
205
  try {
@@ -208,119 +340,222 @@
208
  }
209
  init();
210
 
211
- // UI Interactions
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  uploadTrigger.onclick = () => fileInput.click();
213
  fileInput.onchange = (e) => {
214
- const file = e.target.files[0];
215
- if (file) {
216
- selectedFile = file;
217
- previewContainer.classList.remove('hidden');
218
- const url = URL.createObjectURL(file);
219
- if (file.type.startsWith('image/')) {
220
- imagePreview.src = url;
221
- imagePreview.classList.remove('hidden');
222
- videoPreview.classList.add('hidden');
223
- } else {
224
- videoPreview.src = url;
225
- videoPreview.classList.remove('hidden');
226
- imagePreview.classList.add('hidden');
227
- videoPreview.play();
228
- }
229
- }
230
  };
231
 
232
- cancelFile.onclick = () => {
233
- selectedFile = null;
234
- fileInput.value = '';
235
- previewContainer.classList.add('hidden');
236
- imagePreview.src = '';
237
- videoPreview.src = '';
238
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
 
240
- function appendMessage(role, text, mediaUrl = null, mediaType = null) {
 
241
  const div = document.createElement('div');
242
  div.className = `flex gap-4 items-start ${role === 'user' ? 'flex-row-reverse' : ''}`;
243
 
244
  let mediaHtml = '';
245
- if (mediaUrl) {
246
- if (mediaType.startsWith('image')) {
247
- mediaHtml = `<img src="${mediaUrl}" class="max-w-xs md:max-w-md rounded-3xl mb-4 border border-white/10" />`;
248
- } else {
249
- mediaHtml = `<video src="${mediaUrl}" controls class="max-w-xs md:max-w-md rounded-3xl mb-4 border border-white/10"></video>`;
250
- }
 
 
 
 
 
 
 
251
  }
252
 
253
  const bubbleClass = role === 'user' ? 'user-message' : 'bot-message';
254
-
255
  div.innerHTML = `
256
- <div class="${bubbleClass} p-6 rounded-[2rem] ${role === 'user' ? 'rounded-tr-none' : 'rounded-tl-none'} message-bubble shadow-xl">
257
  ${mediaHtml}
258
- <p class="leading-relaxed text-[15px] whitespace-pre-wrap font-medium">${text}</p>
 
259
  </div>
260
  `;
261
 
262
- // Get the inner container
263
- const container = chatMessages.querySelector('.max-w-3xl');
264
- container.appendChild(div);
 
 
 
 
 
265
 
266
- // Smooth scroll to bottom
267
- chatMessages.scrollTo({ top: chatMessages.scrollHeight, behavior: 'smooth' });
 
 
 
 
 
 
 
 
 
 
 
 
268
  }
269
 
270
  async function sendMessage() {
271
  const text = userInput.value.trim();
272
- if (!text && !selectedFile) return;
273
 
 
274
  const content = text;
275
- const file = selectedFile;
276
 
277
  userInput.value = '';
278
  userInput.style.height = 'auto';
279
- const fileUrl = file ? URL.createObjectURL(file) : null;
280
- const fileType = file ? file.type : null;
281
 
282
- appendMessage('user', content, fileUrl, fileType);
283
- cancelFile.click();
 
284
 
285
  sendIcon.classList.add('hidden');
286
  loadingIcon.classList.remove('hidden');
287
- sendBtn.disabled = true;
288
-
289
- const thinkingId = 'think-' + Date.now();
290
- const thinkingDiv = document.createElement('div');
291
- thinkingDiv.id = thinkingId;
292
- thinkingDiv.className = 'flex gap-4 items-start';
293
- thinkingDiv.innerHTML = `
294
- <div class="bot-message p-6 rounded-[2rem] rounded-tl-none message-bubble flex items-center gap-4">
295
- <div class="flex gap-1.5">
296
- <div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div>
297
- </div>
298
- </div>
299
- `;
300
- const container = chatMessages.querySelector('.max-w-3xl');
301
- container.appendChild(thinkingDiv);
302
- chatMessages.scrollTo({ top: chatMessages.scrollHeight, behavior: 'smooth' });
 
 
303
 
304
  try {
305
- let fileData = file ? handle_file(file) : null;
306
- const result = await client.predict("/predict", {
 
307
  message: content,
308
- file: fileData,
309
- downsample_mode: "16x"
 
 
 
 
 
 
310
  });
 
 
 
 
 
 
 
 
 
311
 
312
- document.getElementById(thinkingId).remove();
313
- appendMessage('bot', result.data);
 
314
  } catch (err) {
315
- document.getElementById(thinkingId).remove();
316
- appendMessage('bot', "The system encountered an error. Please check your file format and try again.");
 
 
317
  } finally {
318
- sendIcon.classList.remove('hidden');
319
- loadingIcon.classList.add('hidden');
320
- sendBtn.disabled = false;
321
  }
322
  }
323
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  sendBtn.onclick = sendMessage;
325
  userInput.onkeydown = (e) => {
326
  if (e.key === 'Enter' && !e.shiftKey) {
@@ -328,6 +563,12 @@
328
  sendMessage();
329
  }
330
  };
 
 
 
 
 
 
331
  </script>
332
  </body>
333
  </html>
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
+ <title>MiniCPM-V | OpenBMB Premium</title>
7
  <script src="https://cdn.tailwindcss.com"></script>
8
  <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
9
  <script src="https://unpkg.com/lucide@latest"></script>
10
  <style>
11
  :root {
12
+ --bg: #05070A;
13
  --blue: #3B5BFF;
14
  --cyan: #27D4EA;
15
  --text: #FFFFFF;
16
+ --text-muted: #8B949E;
17
  --glass: rgba(255, 255, 255, 0.03);
18
+ --glass-border: rgba(255, 255, 255, 0.08);
19
+ --accent: #3B5BFF;
20
  }
21
 
22
  body {
 
27
  margin: 0;
28
  display: flex;
29
  flex-direction: column;
30
+ overflow: hidden;
31
  }
32
 
33
  h1, h2, h3 { font-family: 'Outfit', sans-serif; }
 
35
  .chat-scroll-area {
36
  flex: 1;
37
  overflow-y: auto;
38
+ padding-bottom: 140px;
39
  -webkit-overflow-scrolling: touch;
40
+ scroll-behavior: smooth;
41
  }
42
 
43
+ .chat-scroll-area::-webkit-scrollbar { width: 4px; }
44
+ .chat-scroll-area::-webkit-scrollbar-track { background: transparent; }
45
+ .chat-scroll-area::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 10px; }
 
 
 
 
 
 
 
 
46
 
47
  .message-bubble {
48
  max-width: 85%;
49
  animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
50
+ position: relative;
51
  }
52
 
53
  @keyframes fadeIn {
54
+ from { opacity: 0; transform: translateY(15px); }
55
  to { opacity: 1; transform: translateY(0); }
56
  }
57
 
58
  .user-message {
59
  background: linear-gradient(135deg, var(--blue), var(--cyan));
60
  color: #FFFFFF;
61
+ box-shadow: 0 8px 25px rgba(59, 91, 255, 0.15);
62
+ border-radius: 24px 24px 4px 24px;
63
  }
64
 
65
  .bot-message {
66
+ background: rgba(255, 255, 255, 0.03);
67
  border: 1px solid var(--glass-border);
68
+ border-radius: 24px 24px 24px 4px;
69
+ backdrop-filter: blur(10px);
70
+ }
71
+
72
+ .thinking-block {
73
+ background: rgba(59, 91, 255, 0.05);
74
+ border-left: 3px solid var(--blue);
75
+ padding: 12px 16px;
76
+ margin-bottom: 12px;
77
+ border-radius: 4px 12px 12px 4px;
78
+ font-size: 14px;
79
+ color: var(--text-muted);
80
+ font-style: italic;
81
  }
82
 
83
  .typing-dot {
84
+ width: 4px; height: 4px;
 
85
  background: var(--cyan);
86
  border-radius: 50%;
87
  animation: bounce 1.4s infinite ease-in-out;
 
95
  }
96
 
97
  .input-pill {
98
+ background: rgba(255, 255, 255, 0.04);
99
+ backdrop-filter: blur(25px);
100
+ -webkit-backdrop-filter: blur(25px);
101
  border: 1px solid var(--glass-border);
102
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
103
  }
104
 
105
  .input-pill:focus-within {
106
+ border-color: rgba(59, 91, 255, 0.4);
107
+ background: rgba(255, 255, 255, 0.06);
108
+ box-shadow: 0 0 40px rgba(59, 91, 255, 0.08);
109
  }
110
 
111
  .logo-glow {
112
+ filter: drop-shadow(0 0 15px rgba(39, 212, 234, 0.4));
113
  }
114
 
115
  .send-btn {
116
  background: linear-gradient(135deg, var(--blue), var(--cyan));
117
+ transition: all 0.3s ease;
118
  }
119
+ .send-btn:hover:not(:disabled) { transform: scale(1.05); filter: brightness(1.1); }
120
+ .send-btn:active:not(:disabled) { transform: scale(0.95); }
121
+
122
+ .settings-panel {
123
+ background: rgba(10, 12, 16, 0.95);
124
+ backdrop-filter: blur(30px);
125
+ border-left: 1px solid var(--glass-border);
126
+ transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
127
+ }
128
+
129
+ .control-slider {
130
+ -webkit-appearance: none;
131
+ width: 100%;
132
+ height: 4px;
133
+ background: rgba(255, 255, 255, 0.1);
134
+ border-radius: 2px;
135
+ outline: none;
136
+ }
137
+ .control-slider::-webkit-slider-thumb {
138
+ -webkit-appearance: none;
139
+ width: 12px; height: 12px;
140
+ background: var(--blue);
141
+ border-radius: 50%;
142
+ cursor: pointer;
143
+ transition: scale 0.2s;
144
+ }
145
+ .control-slider::-webkit-slider-thumb:hover { scale: 1.2; }
146
 
147
+ .toggle-switch {
148
+ width: 36px; height: 20px;
149
+ background: rgba(255, 255, 255, 0.1);
150
+ border-radius: 10px;
151
+ position: relative;
152
+ cursor: pointer;
153
+ transition: background 0.3s;
154
+ }
155
+ .toggle-switch.active { background: var(--blue); }
156
+ .toggle-switch::after {
157
+ content: '';
158
+ position: absolute;
159
+ top: 2px; left: 2px;
160
+ width: 16px; height: 16px;
161
+ background: white;
162
+ border-radius: 50%;
163
+ transition: transform 0.3s;
164
+ }
165
+ .toggle-switch.active::after { transform: translateX(16px); }
166
+
167
+ .media-preview-item {
168
+ position: relative;
169
+ animation: scaleIn 0.3s ease-out;
170
+ }
171
+ @keyframes scaleIn { from { scale: 0.8; opacity: 0; } to { scale: 1; opacity: 1; } }
172
+
173
+ .shimmer {
174
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.05), transparent);
175
+ background-size: 200% 100%;
176
+ animation: shimmer 2s infinite;
177
+ }
178
+ @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
179
  </style>
180
  </head>
181
  <body>
182
 
183
+ <!-- Header -->
184
+ <header class="h-20 flex items-center justify-between px-6 md:px-12 shrink-0 z-50 border-b border-white/5">
185
  <div class="flex items-center gap-4">
186
+ <div class="relative">
187
+ <img src="https://cdn-avatars.huggingface.co/v1/production/uploads/1670387859384-633fe7784b362488336bbfad.png"
188
+ alt="OpenBMB" class="w-10 h-10 logo-glow">
189
+ <div class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-[var(--bg)]"></div>
190
+ </div>
191
  <div>
192
+ <h1 class="text-xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-white/60">MiniCPM-V</h1>
193
+ <p class="text-[10px] text-muted uppercase tracking-[0.2em] font-bold opacity-50">By OpenBMB</p>
194
  </div>
195
  </div>
196
+ <div class="flex items-center gap-6">
197
+ <div class="hidden md:flex items-center gap-2 text-[10px] font-bold text-muted uppercase tracking-widest bg-white/5 px-3 py-1.5 rounded-full border border-white/5">
198
+ <span class="w-1.5 h-1.5 rounded-full bg-[#27D4EA] animate-pulse"></span>
199
+ v4.6 Intelligence Engine
200
+ </div>
201
+ <button id="toggle-settings" class="p-2 text-white/40 hover:text-white transition-colors relative">
202
+ <i data-lucide="sliders-horizontal" class="w-5 h-5"></i>
203
+ </button>
204
  </div>
205
  </header>
206
 
207
+ <!-- Settings Panel (Side) -->
208
+ <div id="settings-panel" class="fixed top-0 right-0 h-full w-80 z-[100] translate-x-full settings-panel p-8 flex flex-col gap-8 shadow-[-20px_0_50px_rgba(0,0,0,0.5)]">
209
+ <div class="flex items-center justify-between">
210
+ <h2 class="text-lg font-bold">Engine Settings</h2>
211
+ <button id="close-settings" class="text-white/40 hover:text-white"><i data-lucide="x" class="w-5 h-5"></i></button>
212
+ </div>
213
+
214
+ <div class="space-y-6">
215
+ <div class="flex items-center justify-between">
216
+ <span class="text-sm font-medium text-white/70">Thinking Mode</span>
217
+ <div id="thinking-toggle" class="toggle-switch active"></div>
218
+ </div>
219
+ <div class="flex items-center justify-between">
220
+ <span class="text-sm font-medium text-white/70">Streaming</span>
221
+ <div id="streaming-toggle" class="toggle-switch active"></div>
222
+ </div>
223
+ <div class="space-y-3">
224
+ <div class="flex justify-between text-xs font-bold text-white/40 uppercase tracking-widest">
225
+ <span>Temperature</span>
226
+ <span id="temp-val">0.7</span>
227
+ </div>
228
+ <input type="range" id="temp-slider" min="0" max="2" step="0.01" value="0.7" class="control-slider">
229
+ </div>
230
+ <div class="space-y-3">
231
+ <div class="flex justify-between text-xs font-bold text-white/40 uppercase tracking-widest">
232
+ <span>Max Tokens</span>
233
+ <span id="tokens-val">1024</span>
234
+ </div>
235
+ <input type="range" id="tokens-slider" min="64" max="4096" step="64" value="1024" class="control-slider">
236
+ </div>
237
+ <div class="space-y-3">
238
+ <div class="flex justify-between text-xs font-bold text-white/40 uppercase tracking-widest">
239
+ <span>Top-P</span>
240
+ <span id="p-val">0.8</span>
241
+ </div>
242
+ <input type="range" id="p-slider" min="0" max="1" step="0.05" value="0.8" class="control-slider">
243
+ </div>
244
+
245
+ <button onclick="clearHistory()" class="w-full py-4 rounded-2xl bg-red-500/10 border border-red-500/20 text-red-500 text-sm font-bold hover:bg-red-500/20 transition-all flex items-center justify-center gap-2">
246
+ <i data-lucide="trash-2" class="w-4 h-4"></i>
247
+ Clear Conversation
248
+ </button>
249
+ </div>
250
+
251
+ <div class="mt-auto p-4 bg-white/5 rounded-2xl border border-white/5 text-[10px] text-white/30 leading-relaxed">
252
+ MiniCPM-V 4.6 is a multimodal large language model with strong OCR and reasoning capabilities.
253
+ </div>
254
+ </div>
255
+
256
+ <!-- Chat Area -->
257
+ <main id="chat-messages" class="chat-scroll-area px-4">
258
+ <div class="max-w-3xl mx-auto space-y-8 pt-8" id="chat-container">
259
+ <!-- Greeting -->
260
  <div class="flex gap-4 items-start">
261
+ <div class="bot-message p-6 message-bubble shadow-2xl">
262
  <p class="text-white/90 leading-relaxed text-[15px]">
263
+ Welcome to <span class="font-bold text-[#27D4EA]">MiniCPM-V 4.6 Premium</span>.
264
+ I've been upgraded with <span class="text-white font-bold">Thinking Mode</span> and <span class="text-white font-bold">Multimodal Streaming</span>.
265
  <br><br>
266
+ Upload multiple images or a video to see me in action.
267
  </p>
268
  </div>
269
  </div>
270
  </div>
271
  </main>
272
 
273
+ <!-- Input Area -->
274
  <div class="fixed bottom-0 left-0 right-0 p-6 md:p-10 pointer-events-none">
275
  <div class="max-w-3xl mx-auto pointer-events-auto">
276
+
277
+ <!-- Multi-file Preview -->
278
+ <div id="preview-container" class="hidden mb-6 flex flex-wrap gap-3 max-h-40 overflow-y-auto p-2 scrollbar-none">
279
+ <!-- Preview items will be injected here -->
 
 
 
 
 
280
  </div>
281
 
282
+ <!-- Input Bar -->
283
+ <div class="input-pill rounded-[2rem] p-2 flex items-end gap-2 pr-3 shadow-2xl overflow-hidden">
284
  <div class="flex items-center">
285
+ <input type="file" id="file-input" class="hidden" accept="image/*,video/*" multiple>
286
+ <button id="upload-trigger" class="p-4 text-white/30 hover:text-[#27D4EA] transition-colors relative group">
287
+ <i data-lucide="plus" class="w-6 h-6"></i>
288
+ <span id="file-count-badge" class="hidden absolute top-3 right-3 w-4 h-4 bg-blue-600 text-[9px] font-bold rounded-full flex items-center justify-center">0</span>
289
  </button>
290
  </div>
291
 
292
+ <textarea id="user-input" rows="1" placeholder="Describe what you see..."
293
+ class="flex-1 bg-transparent border-none focus:ring-0 text-white py-4 px-1 resize-none max-h-48 scrollbar-none text-[16px] leading-relaxed"></textarea>
294
 
295
+ <button id="send-btn" class="send-btn w-12 h-12 text-white rounded-full flex items-center justify-center disabled:opacity-20 disabled:grayscale shrink-0 mb-1">
296
+ <i data-lucide="arrow-up" class="w-5 h-5" id="send-icon"></i>
297
  <i data-lucide="loader-2" class="w-5 h-5 animate-spin hidden" id="loading-icon"></i>
298
  </button>
299
  </div>
 
305
 
306
  lucide.createIcons();
307
 
308
+ // DOM Elements
309
+ const chatContainer = document.getElementById('chat-container');
310
+ const chatScrollArea = document.getElementById('chat-messages');
311
  const userInput = document.getElementById('user-input');
312
  const sendBtn = document.getElementById('send-btn');
313
  const fileInput = document.getElementById('file-input');
314
  const uploadTrigger = document.getElementById('upload-trigger');
315
  const previewContainer = document.getElementById('preview-container');
316
+ const fileCountBadge = document.getElementById('file-count-badge');
 
 
317
  const sendIcon = document.getElementById('send-icon');
318
  const loadingIcon = document.getElementById('loading-icon');
319
+
320
+ const settingsPanel = document.getElementById('settings-panel');
321
+ const toggleSettings = document.getElementById('toggle-settings');
322
+ const closeSettings = document.getElementById('close-settings');
323
+
324
+ const thinkingToggle = document.getElementById('thinking-toggle');
325
+ const streamingToggle = document.getElementById('streaming-toggle');
326
+ const tempSlider = document.getElementById('temp-slider');
327
+ const tokensSlider = document.getElementById('tokens-slider');
328
+ const pSlider = document.getElementById('p-slider');
329
+
330
+ let selectedFiles = [];
331
  let client = null;
332
+ let isSettingsOpen = false;
333
+ let chatHistory = [];
334
+ let currentJob = null;
335
 
336
  async function init() {
337
  try {
 
340
  }
341
  init();
342
 
343
+ // Settings Logic
344
+ toggleSettings.onclick = () => {
345
+ isSettingsOpen = true;
346
+ settingsPanel.classList.remove('translate-x-full');
347
+ };
348
+ closeSettings.onclick = () => {
349
+ isSettingsOpen = false;
350
+ settingsPanel.classList.add('translate-x-full');
351
+ };
352
+
353
+ const setupToggle = (el) => {
354
+ el.onclick = () => el.classList.toggle('active');
355
+ };
356
+ setupToggle(thinkingToggle);
357
+ setupToggle(streamingToggle);
358
+
359
+ const setupSlider = (slider, valEl) => {
360
+ slider.oninput = () => valEl.textContent = slider.value;
361
+ };
362
+ setupSlider(tempSlider, document.getElementById('temp-val'));
363
+ setupSlider(tokensSlider, document.getElementById('tokens-val'));
364
+ setupSlider(pSlider, document.getElementById('p-val'));
365
+
366
+ // File Handling
367
  uploadTrigger.onclick = () => fileInput.click();
368
  fileInput.onchange = (e) => {
369
+ const files = Array.from(e.target.files);
370
+ selectedFiles = [...selectedFiles, ...files];
371
+ renderPreviews();
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  };
373
 
374
+ function renderPreviews() {
375
+ previewContainer.innerHTML = '';
376
+ if (selectedFiles.length > 0) {
377
+ previewContainer.classList.remove('hidden');
378
+ fileCountBadge.classList.remove('hidden');
379
+ fileCountBadge.textContent = selectedFiles.length;
380
+
381
+ selectedFiles.forEach((file, index) => {
382
+ const url = URL.createObjectURL(file);
383
+ const item = document.createElement('div');
384
+ item.className = 'media-preview-item h-24 w-24 rounded-2xl overflow-hidden border border-white/20 shadow-lg';
385
+
386
+ if (file.type.startsWith('image/')) {
387
+ item.innerHTML = `<img src="${url}" class="w-full h-full object-cover">`;
388
+ } else {
389
+ item.innerHTML = `<video src="${url}" class="w-full h-full object-cover" muted></video><div class="absolute inset-0 flex items-center justify-center bg-black/20"><i data-lucide="play" class="w-6 h-6 text-white"></i></div>`;
390
+ }
391
+
392
+ const removeBtn = document.createElement('button');
393
+ removeBtn.className = 'absolute -top-1 -right-1 bg-red-500 text-white rounded-full p-1 shadow-lg scale-75';
394
+ removeBtn.innerHTML = '<i data-lucide="x" class="w-3 h-3"></i>';
395
+ removeBtn.onclick = (e) => {
396
+ e.stopPropagation();
397
+ selectedFiles.splice(index, 1);
398
+ renderPreviews();
399
+ };
400
+ item.appendChild(removeBtn);
401
+ previewContainer.appendChild(item);
402
+ });
403
+ lucide.createIcons();
404
+ } else {
405
+ previewContainer.classList.add('hidden');
406
+ fileCountBadge.classList.add('hidden');
407
+ }
408
+ }
409
 
410
+ // Message Handling
411
+ function appendMessage(role, text = '', files = []) {
412
  const div = document.createElement('div');
413
  div.className = `flex gap-4 items-start ${role === 'user' ? 'flex-row-reverse' : ''}`;
414
 
415
  let mediaHtml = '';
416
+ if (files.length > 0) {
417
+ mediaHtml = '<div class="flex flex-wrap gap-2 mb-4">';
418
+ files.forEach(file => {
419
+ const url = typeof file === 'string' ? file : URL.createObjectURL(file);
420
+ const type = typeof file === 'string' ? (file.match(/\.(mp4|webm|mkv)/i) ? 'video' : 'image') : (file.type.startsWith('video') ? 'video' : 'image');
421
+
422
+ if (type === 'image') {
423
+ mediaHtml += `<img src="${url}" class="h-48 rounded-2xl border border-white/10 shadow-lg object-contain bg-black/20" />`;
424
+ } else {
425
+ mediaHtml += `<video src="${url}" controls class="h-48 rounded-2xl border border-white/10 shadow-lg" />`;
426
+ }
427
+ });
428
+ mediaHtml += '</div>';
429
  }
430
 
431
  const bubbleClass = role === 'user' ? 'user-message' : 'bot-message';
 
432
  div.innerHTML = `
433
+ <div class="${bubbleClass} p-6 message-bubble shadow-xl">
434
  ${mediaHtml}
435
+ <div class="thinking-container hidden"></div>
436
+ <div class="content-container leading-relaxed text-[15px] whitespace-pre-wrap font-medium">${text}</div>
437
  </div>
438
  `;
439
 
440
+ chatContainer.appendChild(div);
441
+ chatScrollArea.scrollTo({ top: chatScrollArea.scrollHeight, behavior: 'smooth' });
442
+ return div;
443
+ }
444
+
445
+ function updateBotMessage(div, fullText) {
446
+ const thinkingContainer = div.querySelector('.thinking-container');
447
+ const contentContainer = div.querySelector('.content-container');
448
 
449
+ const thinkMatch = fullText.match(/<think>([\s\S]*?)<\/think>/);
450
+ const thinkingText = thinkMatch ? thinkMatch[1].trim() : (fullText.includes('<think>') && !fullText.includes('</think>') ? fullText.split('<think>')[1].trim() : '');
451
+ const actualText = fullText.replace(/<think>[\s\S]*?<\/think>/, '').trim();
452
+
453
+ if (thinkingText) {
454
+ thinkingContainer.classList.remove('hidden');
455
+ thinkingContainer.innerHTML = `<div class="thinking-block">${thinkingText}</div>`;
456
+ } else {
457
+ thinkingContainer.classList.add('hidden');
458
+ }
459
+
460
+ contentContainer.textContent = actualText;
461
+ chatScrollArea.scrollTo({ top: chatScrollArea.scrollHeight, behavior: 'smooth' });
462
+ return actualText;
463
  }
464
 
465
  async function sendMessage() {
466
  const text = userInput.value.trim();
467
+ if (!text && selectedFiles.length === 0) return;
468
 
469
+ const filesToUpload = [...selectedFiles];
470
  const content = text;
 
471
 
472
  userInput.value = '';
473
  userInput.style.height = 'auto';
 
 
474
 
475
+ appendMessage('user', content, filesToUpload);
476
+ selectedFiles = [];
477
+ renderPreviews();
478
 
479
  sendIcon.classList.add('hidden');
480
  loadingIcon.classList.remove('hidden');
481
+ sendBtn.innerHTML = '<i data-lucide="square" class="w-5 h-5 fill-white"></i>';
482
+ sendBtn.classList.remove('send-btn');
483
+ sendBtn.classList.add('bg-red-500/20', 'hover:bg-red-500/40', 'border', 'border-red-500/50');
484
+ lucide.createIcons();
485
+
486
+ let isStopped = false;
487
+ sendBtn.onclick = () => {
488
+ if (currentJob) {
489
+ currentJob.cancel();
490
+ isStopped = true;
491
+ resetSendBtn();
492
+ }
493
+ };
494
+
495
+ // Bot response placeholder
496
+ const botDiv = appendMessage('bot', '');
497
+ const contentContainer = botDiv.querySelector('.content-container');
498
+ contentContainer.innerHTML = '<div class="flex gap-1.5 py-2"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div>';
499
 
500
  try {
501
+ const gradioFiles = filesToUpload.length > 0 ? filesToUpload.map(f => handle_file(f)) : null;
502
+
503
+ currentJob = client.submit("/predict", {
504
  message: content,
505
+ history: chatHistory,
506
+ files: gradioFiles,
507
+ thinking_mode: thinkingToggle.classList.contains('active'),
508
+ max_new_tokens: parseInt(tokensSlider.value),
509
+ temperature: parseFloat(tempSlider.value),
510
+ top_p: parseFloat(pSlider.value),
511
+ top_k: 100,
512
+ max_frames: 64
513
  });
514
+
515
+ let finalAnswer = "";
516
+ for await (const msg of currentJob) {
517
+ if (isStopped) break;
518
+ if (msg.type === "data" && msg.data) {
519
+ const chunk = msg.data[0];
520
+ finalAnswer = updateBotMessage(botDiv, chunk);
521
+ }
522
+ }
523
 
524
+ if (!isStopped) {
525
+ chatHistory.push([content, finalAnswer]);
526
+ }
527
  } catch (err) {
528
+ console.error(err);
529
+ if (!isStopped) {
530
+ contentContainer.textContent = "I encountered an error while processing your request. Please try again.";
531
+ }
532
  } finally {
533
+ resetSendBtn();
534
+ currentJob = null;
 
535
  }
536
  }
537
 
538
+ function resetSendBtn() {
539
+ sendBtn.innerHTML = '<i data-lucide="arrow-up" class="w-5 h-5" id="send-icon"></i>';
540
+ sendBtn.className = 'send-btn w-12 h-12 text-white rounded-full flex items-center justify-center disabled:opacity-20 disabled:grayscale shrink-0 mb-1';
541
+ sendBtn.onclick = sendMessage;
542
+ lucide.createIcons();
543
+ }
544
+
545
+ window.clearHistory = function() {
546
+ chatHistory = [];
547
+ chatContainer.innerHTML = `
548
+ <div class="flex gap-4 items-start">
549
+ <div class="bot-message p-6 message-bubble shadow-2xl">
550
+ <p class="text-white/90 leading-relaxed text-[15px]">
551
+ History cleared. How can I help you today?
552
+ </p>
553
+ </div>
554
+ </div>
555
+ `;
556
+ closeSettings.click();
557
+ }
558
+
559
  sendBtn.onclick = sendMessage;
560
  userInput.onkeydown = (e) => {
561
  if (e.key === 'Enter' && !e.shiftKey) {
 
563
  sendMessage();
564
  }
565
  };
566
+
567
+ // Auto-resize textarea
568
+ userInput.oninput = () => {
569
+ userInput.style.height = 'auto';
570
+ userInput.style.height = userInput.scrollHeight + 'px';
571
+ };
572
  </script>
573
  </body>
574
  </html>
requirements.txt CHANGED
@@ -8,3 +8,4 @@ pillow
8
  av
9
  accelerate
10
  sentencepiece
 
 
8
  av
9
  accelerate
10
  sentencepiece
11
+ modelscope-studio