chenge commited on
Commit
4a5aabd
·
1 Parent(s): 508c748
Files changed (4) hide show
  1. README.md +5 -4
  2. app.py +1630 -0
  3. rednote_hilab.png +0 -0
  4. requirements.txt +5 -0
README.md CHANGED
@@ -1,12 +1,13 @@
1
  ---
2
- title: Dots Vlm1 Demo
3
- emoji: 👀
4
  colorFrom: gray
5
- colorTo: purple
6
  sdk: gradio
7
- sdk_version: 5.40.0
8
  app_file: app.py
9
  pinned: false
 
10
  ---
11
 
12
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: Dots Demo
3
+ emoji: 💻
4
  colorFrom: gray
5
+ colorTo: pink
6
  sdk: gradio
7
+ sdk_version: 5.32.1
8
  app_file: app.py
9
  pinned: false
10
+ license: apache-2.0
11
  ---
12
 
13
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py ADDED
@@ -0,0 +1,1630 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ import json
4
+ import base64
5
+ import io
6
+ import gradio as gr
7
+ import modelscope_studio.components.antd as antd
8
+ import modelscope_studio.components.antdx as antdx
9
+ import modelscope_studio.components.base as ms
10
+ from openai import OpenAI
11
+ import requests
12
+ from typing import Generator, Dict, Any, List, Union
13
+ import logging
14
+ import time
15
+ from PIL import Image
16
+ import datetime
17
+
18
+ # =========== Configuration
19
+ # MODEL NAME
20
+ model = os.getenv("MODEL_NAME")
21
+ # 代理服务器配置
22
+ PROXY_BASE_URL = os.getenv("PROXY_API_BASE", "http://localhost:8000")
23
+ PROXY_TIMEOUT = int(os.getenv("PROXY_TIMEOUT", 30))
24
+ MAX_RETRIES = int(os.getenv("MAX_RETRIES", 5))
25
+ # 保存历史
26
+ save_history = True
27
+
28
+ # =========== Configuration
29
+
30
+ # 配置日志
31
+ logging.basicConfig(level=logging.INFO)
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # =========== 对话日志功能
35
+ # 创建对话日志文件夹
36
+ CONVERSATION_LOG_DIR = "conversation_logs"
37
+ os.makedirs(CONVERSATION_LOG_DIR, exist_ok=True)
38
+
39
+ def save_conversation_log(history_messages, assistant_content, metadata=None):
40
+ """保存对话日志到JSON文件"""
41
+ try:
42
+ timestamp = datetime.datetime.now()
43
+ filename = f"gradio_app_{timestamp.strftime('%Y%m%d_%H%M%S_%f')}.json"
44
+ filepath = os.path.join(CONVERSATION_LOG_DIR, filename)
45
+
46
+ log_data = {
47
+ "timestamp": timestamp.isoformat(),
48
+ "history_messages": history_messages, # 原封不动保存发送给模型的消息
49
+ "assistant_content": assistant_content,
50
+ "metadata": metadata or {}
51
+ }
52
+
53
+ with open(filepath, 'w', encoding='utf-8') as f:
54
+ json.dump(log_data, f, ensure_ascii=False, indent=2)
55
+
56
+ logger.info(f"对话日志已保存: {filepath}")
57
+
58
+ except Exception as e:
59
+ logger.error(f"保存对话日志失败: {str(e)}")
60
+
61
+ # =========== 图像处理工具函数
62
+ def encode_image_to_base64(image_path_or_pil: Union[str, Image.Image]) -> str:
63
+ """将图像文件或PIL图像对象转换为base64编码字符串"""
64
+ try:
65
+ if isinstance(image_path_or_pil, str):
66
+ # 如果是文件路径
67
+ with open(image_path_or_pil, "rb") as image_file:
68
+ return base64.b64encode(image_file.read()).decode('utf-8')
69
+ else:
70
+ # 如果是PIL图像对象
71
+ buffer = io.BytesIO()
72
+ # 保存为JPEG格式
73
+ if image_path_or_pil.mode == 'RGBA':
74
+ # 如果是RGBA模式,转换为RGB
75
+ rgb_image = Image.new('RGB', image_path_or_pil.size, (255, 255, 255))
76
+ rgb_image.paste(image_path_or_pil, mask=image_path_or_pil.split()[-1])
77
+ rgb_image.save(buffer, format="JPEG", quality=85)
78
+ else:
79
+ image_path_or_pil.save(buffer, format="JPEG", quality=85)
80
+ image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
81
+ return image_base64
82
+ except Exception as e:
83
+ logger.error(f"Error encoding image to base64: {str(e)}")
84
+ raise
85
+
86
+ def create_multimodal_content(text: str, images: List[Union[str, Image.Image]] = None) -> List[Dict]:
87
+ """创建多模态内容格式,兼容OpenAI API"""
88
+ content = []
89
+
90
+ # 添加文本内容
91
+ if text and text.strip():
92
+ content.append({
93
+ "type": "text",
94
+ "text": text
95
+ })
96
+
97
+ # 添加图像内容
98
+ if images:
99
+ for i, image in enumerate(images):
100
+ try:
101
+ image_base64 = encode_image_to_base64(image)
102
+ content.append({
103
+ "type": "image_url",
104
+ "image_url": {
105
+ "url": f"data:image/jpeg;base64,{image_base64}"
106
+ }
107
+ })
108
+ logger.info(f"Added image {i+1}/{len(images)} to multimodal content")
109
+ except Exception as e:
110
+ logger.error(f"Failed to process image {i+1}: {str(e)}")
111
+ continue
112
+
113
+ return content if content else [{"type": "text", "text": text or ""}]
114
+
115
+ def convert_images_to_base64_list(images: List[Union[str, Image.Image]]) -> List[str]:
116
+ """将图片列表转换为base64字符串列表,用于持久化存储"""
117
+ base64_images = []
118
+ for i, image in enumerate(images):
119
+ try:
120
+ base64_str = encode_image_to_base64(image)
121
+ base64_images.append(base64_str)
122
+ logger.info(f"Converted image {i+1}/{len(images)} to base64 for storage")
123
+ except Exception as e:
124
+ logger.error(f"Failed to convert image {i+1} to base64: {str(e)}")
125
+ continue
126
+ return base64_images
127
+
128
+ def restore_images_from_base64_list(base64_images: List[str]) -> List[Image.Image]:
129
+ """从base64字符串列表恢复图片对象"""
130
+ images = []
131
+ for i, base64_str in enumerate(base64_images):
132
+ try:
133
+ image_data = base64.b64decode(base64_str)
134
+ image = Image.open(io.BytesIO(image_data))
135
+ images.append(image)
136
+ logger.info(f"Restored image {i+1}/{len(base64_images)} from base64")
137
+ except Exception as e:
138
+ logger.error(f"Failed to restore image {i+1} from base64: {str(e)}")
139
+ continue
140
+ return images
141
+
142
+ class DeltaObject:
143
+ """模拟OpenAI Delta对象"""
144
+ def __init__(self, data: dict):
145
+ self.content = data.get('content')
146
+ self.role = data.get('role')
147
+
148
+ class ChoiceObject:
149
+ """模拟OpenAI Choice对象"""
150
+ def __init__(self, choice_data: dict):
151
+ delta_data = choice_data.get('delta', {})
152
+ self.delta = DeltaObject(delta_data)
153
+ self.finish_reason = choice_data.get('finish_reason')
154
+ self.index = choice_data.get('index', 0)
155
+
156
+ class ChunkObject:
157
+ """模拟OpenAI Chunk对象"""
158
+ def __init__(self, chunk_data: dict):
159
+ choices_data = chunk_data.get('choices', [])
160
+ self.choices = [ChoiceObject(choice) for choice in choices_data]
161
+ self.id = chunk_data.get('id', '')
162
+ self.object = chunk_data.get('object', 'chat.completion.chunk')
163
+ self.created = chunk_data.get('created', 0)
164
+ self.model = chunk_data.get('model', '')
165
+
166
+ class ProxyClient:
167
+ """代理客户端,用于与中间服务通信"""
168
+
169
+ def __init__(self, base_url: str, timeout: int = 30):
170
+ self.base_url = base_url.rstrip('/')
171
+ self.timeout = timeout
172
+ self.session = requests.Session()
173
+
174
+ def chat_completions_create(self, model: str, messages: list, stream: bool = True, **kwargs):
175
+ """创建聊天完成请求"""
176
+ if self.base_url.endswith('/v1'):
177
+ url = f"{self.base_url}/chat/completions"
178
+ else:
179
+ url = f"{self.base_url}/v1/chat/completions"
180
+
181
+ payload = {
182
+ "model": model,
183
+ "messages": messages,
184
+ "stream": stream,
185
+ **kwargs
186
+ }
187
+
188
+ try:
189
+ response = self.session.post(
190
+ url,
191
+ json=payload,
192
+ stream=stream,
193
+ timeout=self.timeout,
194
+ headers={"Content-Type": "application/json"}
195
+ )
196
+ response.raise_for_status()
197
+
198
+ if stream:
199
+ return self._parse_stream_response(response)
200
+ else:
201
+ return response.json()
202
+
203
+ except requests.exceptions.RequestException as e:
204
+ logger.error(f"Request failed: {str(e)}")
205
+ raise Exception(f"Failed to connect to proxy server: {str(e)}")
206
+
207
+ def _parse_stream_response(self, response) -> Generator[ChunkObject, None, None]:
208
+ """解析流式响应"""
209
+ try:
210
+ # 确保响应编码正确
211
+ response.encoding = 'utf-8'
212
+
213
+ for line in response.iter_lines(decode_unicode=True):
214
+ if not line:
215
+ continue
216
+
217
+ line = line.strip()
218
+ if line.startswith('data: '):
219
+ data = line[6:] # 移除 'data: ' 前缀
220
+
221
+ if data == '[DONE]':
222
+ break
223
+
224
+ try:
225
+ chunk_data = json.loads(data)
226
+
227
+ # 检查是否是错误响应
228
+ if 'error' in chunk_data:
229
+ raise Exception(f"Stream error: {chunk_data.get('detail', chunk_data['error'])}")
230
+
231
+ # 创建与OpenAI客户端兼容的响应对象
232
+ yield ChunkObject(chunk_data)
233
+
234
+ except json.JSONDecodeError as e:
235
+ logger.warning(f"Failed to parse JSON: {data}, error: {str(e)}")
236
+ continue
237
+
238
+ except Exception as e:
239
+ logger.error(f"Error parsing stream response: {str(e)}")
240
+ raise
241
+
242
+ def health_check(self) -> dict:
243
+ """健康检查"""
244
+ try:
245
+ url = f"{self.base_url}/health"
246
+ response = self.session.get(url, timeout=self.timeout)
247
+ response.raise_for_status()
248
+ # 处理空响应体的情况
249
+ if response.text.strip():
250
+ return response.json()
251
+ else:
252
+ # 如果响应体为空但状态码是200,认为服务健康
253
+ logger.info("Health check returned empty response with 200 status, assuming healthy")
254
+ return {"status": "healthy"}
255
+ except Exception as e:
256
+ logger.error(f"Health check failed: {str(e)}")
257
+ return {"status": "unhealthy", "error": str(e)}
258
+
259
+ # 初始化代理客户端
260
+ client = ProxyClient(PROXY_BASE_URL, PROXY_TIMEOUT)
261
+
262
+ def chat_with_retry(history_messages, max_retries=MAX_RETRIES):
263
+ """带重试机制的聊天函数"""
264
+ last_exception = None
265
+
266
+ for attempt in range(max_retries):
267
+ try:
268
+ logger.info(f"Chat attempt {attempt + 1}/{max_retries}")
269
+
270
+ # 检查代理服务健康状态
271
+ health = client.health_check()
272
+ if health.get("status") != "healthy":
273
+ raise Exception(f"Proxy service unhealthy: {health}")
274
+
275
+ response = client.chat_completions_create(
276
+ model=model,
277
+ messages=history_messages,
278
+ stream=True,
279
+ temperature=0.1,
280
+ top_p=0.9,
281
+ max_tokens=30000
282
+ )
283
+
284
+ return response
285
+
286
+ except Exception as e:
287
+ last_exception = e
288
+ logger.warning(f"Attempt {attempt + 1} failed: {str(e)}")
289
+
290
+ if attempt < max_retries - 1:
291
+ # 指数退避
292
+ wait_time = min(2 ** attempt, 4)
293
+ logger.info(f"Retrying in {wait_time} seconds...")
294
+ time.sleep(wait_time)
295
+ else:
296
+ logger.error(f"All {max_retries} attempts failed")
297
+
298
+ raise last_exception
299
+
300
+
301
+ is_modelscope_studio = os.getenv('MODELSCOPE_ENVIRONMENT') == 'studio'
302
+ def get_text(text: str, cn_text: str):
303
+ if is_modelscope_studio:
304
+ return cn_text
305
+ return text
306
+
307
+ logo_img = os.path.join(os.path.dirname(__file__), "rednote_hilab.png")
308
+
309
+
310
+
311
+ DEFAULT_CONVERSATIONS_HISTORY = [{"role": "placeholder"}]
312
+
313
+ DEFAULT_LOCALE = 'zh_CN' if is_modelscope_studio else 'en_US'
314
+
315
+ DEFAULT_THEME = {
316
+ "token": {
317
+ "colorPrimary": "#6A57FF",
318
+ }
319
+ }
320
+
321
+
322
+ def format_history(history):
323
+ messages = [{
324
+ "role": "system",
325
+ "content": "",
326
+ }]
327
+ for item in history:
328
+ if item["role"] == "user":
329
+ # 支持多模态内容格式
330
+ content = item["content"]
331
+ if isinstance(content, dict):
332
+ if "multimodal" in content:
333
+ # 如果是保存的多模态内容,直接使用
334
+ messages.append({
335
+ "role": "user",
336
+ "content": content["multimodal"]
337
+ })
338
+ logger.info(f"Added multimodal message with {content.get('images_count', 0)} images to context")
339
+ elif "images_base64" in content:
340
+ # 如果有base64图片数据,重新构建多模态内容
341
+ text = content.get("text", "")
342
+ images_base64 = content.get("images_base64", [])
343
+
344
+ if images_base64:
345
+ # 从base64恢复图片并创建多模态内容
346
+ restored_images = restore_images_from_base64_list(images_base64)
347
+ multimodal_content = create_multimodal_content(text, restored_images)
348
+ messages.append({
349
+ "role": "user",
350
+ "content": multimodal_content
351
+ })
352
+ logger.info(f"Restored and added multimodal message with {len(restored_images)} images to context")
353
+ else:
354
+ # 没有图片,只有文本
355
+ messages.append({"role": "user", "content": text})
356
+ else:
357
+ # 如果content是复杂对象,提取text字段
358
+ text_content = content.get("text", str(content))
359
+ messages.append({"role": "user", "content": text_content})
360
+ else:
361
+ # 传统文本内容
362
+ messages.append({"role": "user", "content": content})
363
+ elif item["role"] == "assistant":
364
+ # 助手消息:合并thinking内容和content,保持原始格式
365
+ assistant_content = item["content"] or ""
366
+
367
+ # 检查是否有thinking内容需要合并
368
+ thinking_content = item.get("meta", {}).get("thinking_content", "")
369
+ if thinking_content:
370
+ # 重建完整的原始输出,不添加额外换行符
371
+ # thinking_content 和 assistant_content 都已包含原始的换行符
372
+ full_content = f"<think>{thinking_content}</think>{assistant_content}"
373
+ else:
374
+ full_content = assistant_content
375
+
376
+ messages.append({"role": "assistant", "content": full_content})
377
+ return messages
378
+
379
+
380
+ class Gradio_Events:
381
+
382
+ @staticmethod
383
+ def _submit(state_value):
384
+ history = state_value["conversations_history"][
385
+ state_value["conversation_id"]]
386
+ # submit
387
+ history_messages = format_history(history)
388
+
389
+ history.append({
390
+ "role": "assistant",
391
+ "content": "",
392
+ "key": str(uuid.uuid4()),
393
+ "meta": {
394
+ "reason_content": "",
395
+ "thinking_content": "", # 添加thinking内容存储
396
+ "is_thinking": False, # 添加thinking状���
397
+ "thinking_done": False # 添加thinking完成状态
398
+ },
399
+ "loading": True,
400
+ })
401
+
402
+ yield {
403
+ chatbot: gr.update(items=history),
404
+ state: gr.update(value=state_value),
405
+ }
406
+ try:
407
+ response = chat_with_retry(history_messages)
408
+
409
+ thought_done = False
410
+ in_thinking = False
411
+ accumulated_content = ""
412
+
413
+ for chunk in response:
414
+ # 安全地访问chunk属性
415
+ if chunk.choices and len(chunk.choices) > 0:
416
+ content = chunk.choices[0].delta.content
417
+ else:
418
+ content = None
419
+ raise ValueError('Content is None')
420
+
421
+ history[-1]["loading"] = False
422
+ print(content, end='')
423
+ if content:
424
+ accumulated_content += content
425
+
426
+ # 检查是否进入thinking模式
427
+ if "<think>" in accumulated_content and not in_thinking:
428
+ in_thinking = True
429
+ history[-1]["meta"]["is_thinking"] = True
430
+ # 提取thinking标签之前的内容并保存
431
+ before_think = accumulated_content.split("<think>")[0]
432
+ if before_think.strip():
433
+ # 保存thinking之前的内容
434
+ history[-1]["content"] = before_think
435
+ # 重置accumulated_content为thinking标签后的内容
436
+ think_parts = accumulated_content.split("<think>", 1)
437
+ if len(think_parts) > 1:
438
+ accumulated_content = think_parts[1]
439
+ else:
440
+ accumulated_content = ""
441
+ continue
442
+
443
+ # 检查是否退出thinking模式
444
+ if "</think>" in accumulated_content and in_thinking:
445
+ in_thinking = False
446
+ history[-1]["meta"]["is_thinking"] = False
447
+ history[-1]["meta"]["thinking_done"] = True
448
+
449
+ # 分离thinking内容和后续内容
450
+ think_parts = accumulated_content.split("</think>", 1)
451
+ thinking_content = think_parts[0]
452
+ history[-1]["meta"]["thinking_content"] = thinking_content
453
+
454
+ # 处理thinking后的内容 - 追加而不是覆盖
455
+ if len(think_parts) > 1:
456
+ after_think_content = think_parts[1]
457
+ if after_think_content.strip():
458
+ # 如果之前已有内容,则追加;否则直接设置
459
+ current_content = history[-1]["content"] or ""
460
+ history[-1]["content"] = current_content + after_think_content
461
+
462
+ accumulated_content = "" # 重置累积内容
463
+ yield {
464
+ chatbot: gr.update(items=history),
465
+ state: gr.update(value=state_value)
466
+ }
467
+ continue
468
+
469
+ # 如果在thinking模式中,只更新thinking内容,不修改content
470
+ if in_thinking:
471
+ # 检查是否包含完整的thinking结束标签
472
+ if "</think>" not in accumulated_content:
473
+ history[-1]["meta"]["thinking_content"] = accumulated_content
474
+ else:
475
+ # 如果不在thinking模式中,正常添加内容到content
476
+ if not thought_done:
477
+ thought_done = True
478
+ if not history[-1]["content"]: # 如果content为空才初始化
479
+ history[-1]["content"] = ""
480
+ history[-1]["content"] += content
481
+
482
+ yield {
483
+ chatbot: gr.update(items=history),
484
+ state: gr.update(value=state_value)
485
+ }
486
+
487
+ history[-1]["meta"]["end"] = True
488
+ print("Answer: ", history[-1]["content"])
489
+
490
+ # 保存对话日志
491
+ # 获取用户消息(倒数第二个消息)
492
+ user_message = None
493
+ for i in range(len(history) - 2, -1, -1):
494
+ if history[i]["role"] == "user":
495
+ user_message = history[i]
496
+ break
497
+
498
+ if user_message:
499
+ save_conversation_log(
500
+ history_messages=history_messages, # 这是发送给模型的原始数据
501
+ assistant_content=history[-1]["content"],
502
+ metadata={
503
+ "model": model,
504
+ "proxy_base_url": PROXY_BASE_URL,
505
+ "conversation_id": state_value["conversation_id"],
506
+ "thinking_content": history[-1]["meta"].get("thinking_content", ""),
507
+ "has_thinking": bool(history[-1]["meta"].get("thinking_content"))
508
+ }
509
+ )
510
+
511
+ except Exception as e:
512
+ history[-1]["loading"] = False
513
+ history[-1]["meta"]["end"] = True
514
+ history[-1]["meta"]["error"] = True
515
+ history[-1]["content"] = "Failed to respond, please try again."
516
+ yield {
517
+ chatbot: gr.update(items=history),
518
+ state: gr.update(value=state_value)
519
+ }
520
+ print('Error: ',e)
521
+ raise e
522
+
523
+
524
+ @staticmethod
525
+ def submit(sender_value, state_value):
526
+ if not state_value["conversation_id"]:
527
+ random_id = str(uuid.uuid4())
528
+ history = []
529
+ state_value["conversation_id"] = random_id
530
+ state_value["conversations_history"][random_id] = history
531
+ # 使用文本内容作为对话标签
532
+ label = sender_value if isinstance(sender_value, str) else "New Chat"
533
+ state_value["conversations"].append({
534
+ "label": label,
535
+ "key": random_id
536
+ })
537
+
538
+ history = state_value["conversations_history"][
539
+ state_value["conversation_id"]]
540
+
541
+ # 处理多模态内容
542
+ uploaded_images = state_value.get("uploaded_images", [])
543
+
544
+ if uploaded_images:
545
+ # 创建多模态内容
546
+ multimodal_content = create_multimodal_content(sender_value, uploaded_images)
547
+ # 转换图片为base64用于持久化存储
548
+ images_base64 = convert_images_to_base64_list(uploaded_images)
549
+
550
+ message_content = {
551
+ "text": sender_value,
552
+ "images_count": len(uploaded_images), # 保存图片数量
553
+ "images_base64": images_base64, # 保存base64图片数据
554
+ "multimodal": multimodal_content # 用于API调用的多模态内容
555
+ }
556
+
557
+ logger.info(f"Saving message with {len(uploaded_images)} images to history")
558
+ # 清空已上传的图片
559
+ state_value["uploaded_images"] = []
560
+ state_value["image_file_paths"] = []
561
+ else:
562
+ # 纯文本内容
563
+ message_content = sender_value
564
+
565
+ history.append({
566
+ "role": "user",
567
+ "meta": {},
568
+ "key": str(uuid.uuid4()),
569
+ "content": message_content
570
+ })
571
+
572
+ # preprocess submit
573
+ yield Gradio_Events.preprocess_submit()(state_value)
574
+ try:
575
+ for chunk in Gradio_Events._submit(state_value):
576
+ yield chunk
577
+ except Exception as e:
578
+ raise e
579
+ finally:
580
+ # postprocess submit - 包括清空图片上传组件
581
+ yield Gradio_Events.postprocess_submit(state_value)
582
+
583
+ @staticmethod
584
+ def regenerate_message(state_value, e: gr.EventData):
585
+ conversation_key = e._data["component"]["conversationKey"]
586
+ history = state_value["conversations_history"][
587
+ state_value["conversation_id"]]
588
+ index = -1
589
+ for i, conversation in enumerate(history):
590
+ if conversation["key"] == conversation_key:
591
+ index = i
592
+ break
593
+ if index == -1:
594
+ yield gr.skip()
595
+ history = history[:index]
596
+ state_value["conversations_history"][
597
+ state_value["conversation_id"]] = history
598
+
599
+ yield {
600
+ chatbot:gr.update(items=history),
601
+ state: gr.update(value=state_value)
602
+ }
603
+
604
+ # preprocess submit
605
+ yield Gradio_Events.preprocess_submit(clear_input=False)(state_value)
606
+ try:
607
+ for chunk in Gradio_Events._submit(state_value):
608
+ yield chunk
609
+ except Exception as e:
610
+ raise e
611
+ finally:
612
+ # postprocess submit
613
+ yield Gradio_Events.postprocess_submit(state_value)
614
+
615
+
616
+ @staticmethod
617
+ def preprocess_submit(clear_input=True):
618
+
619
+ def preprocess_submit_handler(state_value):
620
+ history = state_value["conversations_history"][
621
+ state_value["conversation_id"]]
622
+ for conversation in history:
623
+ if "meta" in conversation:
624
+ conversation["meta"]["disabled"] = True
625
+ return {
626
+ sender: gr.update(value=None, loading=True) if clear_input else gr.update(loading=True),
627
+ conversations:
628
+ gr.update(active_key=state_value["conversation_id"],
629
+ items=list(
630
+ map(
631
+ lambda item: {
632
+ **item,
633
+ "disabled":
634
+ True if item["key"] != state_value[
635
+ "conversation_id"] else False,
636
+ }, state_value["conversations"]))),
637
+ add_conversation_btn:
638
+ gr.update(disabled=True),
639
+ clear_btn:
640
+ gr.update(disabled=True),
641
+ conversation_delete_menu_item:
642
+ gr.update(disabled=True),
643
+ chatbot:
644
+ gr.update(items=history),
645
+ state:
646
+ gr.update(value=state_value),
647
+ image_upload: gr.update(value=None), # 发送消息时立即清空图片上传组件
648
+ green_image_indicator: gr.update(count=0, elem_style=dict(display="block")), # 左侧绿色指示器显示0
649
+ trash_button: gr.update(elem_style=dict(display="none")), # 隐藏垃圾桶按钮
650
+ stop_btn: gr.update(visible=True) # 显示停止按钮
651
+ }
652
+
653
+ return preprocess_submit_handler
654
+
655
+ @staticmethod
656
+ def postprocess_submit(state_value):
657
+ history = state_value["conversations_history"][
658
+ state_value["conversation_id"]]
659
+ for conversation in history:
660
+ if "meta" in conversation:
661
+ conversation["meta"]["disabled"] = False
662
+ return {
663
+ sender: gr.update(loading=False),
664
+ conversation_delete_menu_item: gr.update(disabled=False),
665
+ clear_btn: gr.update(disabled=False),
666
+ conversations: gr.update(items=state_value["conversations"]),
667
+ add_conversation_btn: gr.update(disabled=False),
668
+ chatbot: gr.update(items=history),
669
+ state: gr.update(value=state_value),
670
+ stop_btn: gr.update(visible=False) # 隐藏停止按钮
671
+ }
672
+
673
+ @staticmethod
674
+ def cancel(state_value):
675
+ history = state_value["conversations_history"][
676
+ state_value["conversation_id"]]
677
+ history[-1]["loading"] = False
678
+ history[-1]["meta"]["end"] = True
679
+ history[-1]["meta"]["canceled"] = True
680
+ return Gradio_Events.postprocess_submit(state_value)
681
+
682
+ @staticmethod
683
+ def delete_message(state_value, e: gr.EventData):
684
+ conversation_key = e._data["component"]["conversationKey"]
685
+ history = state_value["conversations_history"][
686
+ state_value["conversation_id"]]
687
+ history = [item for item in history if item["key"] != conversation_key]
688
+ state_value["conversations_history"][
689
+ state_value["conversation_id"]] = history
690
+
691
+ return gr.update(items=history if len(history) >
692
+ 0 else DEFAULT_CONVERSATIONS_HISTORY), gr.update(
693
+ value=state_value)
694
+
695
+
696
+
697
+ @staticmethod
698
+ def edit_message(state_value, e: gr.EventData):
699
+ conversation_key = e._data["component"]["conversationKey"]
700
+ history = state_value["conversations_history"][
701
+ state_value["conversation_id"]]
702
+ index = -1
703
+ for i, conversation in enumerate(history):
704
+ if conversation["key"] == conversation_key:
705
+ index = i
706
+ break
707
+ if index == -1:
708
+ return gr.skip()
709
+ state_value["editing_message_index"] = index
710
+ text = ''
711
+ if isinstance(history[index]["content"], str):
712
+ text = history[index]["content"]
713
+ else:
714
+ text = history[index]["content"]["text"]
715
+ return gr.update(value=text), gr.update(value=state_value)
716
+
717
+ @staticmethod
718
+ def confirm_edit_message(edit_textarea_value, state_value):
719
+ history = state_value["conversations_history"][
720
+ state_value["conversation_id"]]
721
+ message = history[state_value["editing_message_index"]]
722
+ if isinstance(message["content"], str):
723
+ message["content"] = edit_textarea_value
724
+ else:
725
+ message["content"]["text"] = edit_textarea_value
726
+ return gr.update(items=history), gr.update(value=state_value)
727
+
728
+ @staticmethod
729
+ def select_suggestion(sender_value, e: gr.EventData):
730
+ return gr.update(value=sender_value[:-1] + e._data["payload"][0])
731
+
732
+ @staticmethod
733
+ def new_chat(state_value):
734
+ if not state_value["conversation_id"]:
735
+ return gr.skip()
736
+ state_value["conversation_id"] = ""
737
+ # 清空上传的图片(修复新对话图片泄露bug)
738
+ state_value["uploaded_images"] = []
739
+ state_value["image_file_paths"] = []
740
+ return gr.update(active_key=state_value["conversation_id"]), gr.update(
741
+ items=DEFAULT_CONVERSATIONS_HISTORY), gr.update(value=state_value)
742
+
743
+ @staticmethod
744
+ def select_conversation(state_value, e: gr.EventData):
745
+ active_key = e._data["payload"][0]
746
+ if state_value["conversation_id"] == active_key or (
747
+ active_key not in state_value["conversations_history"]):
748
+ return gr.skip()
749
+ state_value["conversation_id"] = active_key
750
+ # 切换对话时清空上传的图片(避免图片泄露到其他对话)
751
+ state_value["uploaded_images"] = []
752
+ state_value["image_file_paths"] = []
753
+ return gr.update(active_key=active_key), gr.update(
754
+ items=state_value["conversations_history"][active_key]), gr.update(
755
+ value=state_value)
756
+
757
+ @staticmethod
758
+ def click_conversation_menu(state_value, e: gr.EventData):
759
+ conversation_id = e._data["payload"][0]["key"]
760
+ operation = e._data["payload"][1]["key"]
761
+ if operation == "delete":
762
+ del state_value["conversations_history"][conversation_id]
763
+
764
+ state_value["conversations"] = [
765
+ item for item in state_value["conversations"]
766
+ if item["key"] != conversation_id
767
+ ]
768
+
769
+ if state_value["conversation_id"] == conversation_id:
770
+ state_value["conversation_id"] = ""
771
+ # 删除当前对话时清空上传的图片
772
+ state_value["uploaded_images"] = []
773
+ state_value["image_file_paths"] = []
774
+ return gr.update(
775
+ items=state_value["conversations"],
776
+ active_key=state_value["conversation_id"]), gr.update(
777
+ items=DEFAULT_CONVERSATIONS_HISTORY), gr.update(
778
+ value=state_value)
779
+ else:
780
+ return gr.update(
781
+ items=state_value["conversations"]), gr.skip(), gr.update(
782
+ value=state_value)
783
+ return gr.skip()
784
+
785
+ @staticmethod
786
+ def clear_conversation_history(state_value):
787
+ if not state_value["conversation_id"]:
788
+ return gr.skip()
789
+ state_value["conversations_history"][
790
+ state_value["conversation_id"]] = []
791
+ # 清空对话历史时也清空上传的图片
792
+ state_value["uploaded_images"] = []
793
+ state_value["image_file_paths"] = []
794
+ return gr.update(items=DEFAULT_CONVERSATIONS_HISTORY), gr.update(
795
+ value=state_value)
796
+
797
+ @staticmethod
798
+ def close_modal():
799
+ return gr.update(open=False)
800
+
801
+ @staticmethod
802
+ def open_modal():
803
+ return gr.update(open=True)
804
+
805
+ @staticmethod
806
+ def update_browser_state(state_value):
807
+
808
+ return gr.update(value=dict(
809
+ conversations=state_value["conversations"],
810
+ conversations_history=state_value["conversations_history"]))
811
+
812
+ @staticmethod
813
+ def apply_browser_state(browser_state_value, state_value):
814
+ state_value["conversations"] = browser_state_value["conversations"]
815
+ state_value["conversations_history"] = browser_state_value[
816
+ "conversations_history"]
817
+ return gr.update(
818
+ items=browser_state_value["conversations"]), gr.update(
819
+ value=state_value)
820
+
821
+ @staticmethod
822
+ def handle_image_upload(files, state_value):
823
+ """处理图片上传"""
824
+ logger.info(f"handle_image_upload called with files: {files}")
825
+
826
+ if not files:
827
+ # 没有文件时重置为默认状态
828
+ logger.info("No files provided, resetting to default state")
829
+ return (
830
+ gr.update(value=state_value),
831
+ gr.update(count=0, elem_style=dict(display="block")), # 左侧绿色指示器显示0
832
+ gr.update(elem_style=dict(display="none")), # 隐藏垃圾桶按钮
833
+ )
834
+
835
+ # 显示上传中状态
836
+ logger.info("Upload in progress...")
837
+ # 这里可以添加一个临时的loading状态,但由于返回限制,我们直接进行处理
838
+
839
+ try:
840
+ # 处理上传的文件
841
+ uploaded_images = []
842
+ image_file_paths = []
843
+
844
+ for i, file_info in enumerate(files):
845
+ logger.info(f"Processing file {i}: {file_info}, type: {type(file_info)}")
846
+
847
+ if isinstance(file_info, dict):
848
+ # 如果是文件信息字典
849
+ file_path = file_info.get('name') or file_info.get('path')
850
+ logger.info(f"Extracted path from dict: {file_path}")
851
+ else:
852
+ # 如果直接是文件路径
853
+ file_path = file_info
854
+ logger.info(f"Direct file path: {file_path}")
855
+
856
+ if file_path:
857
+ # 保存文件路径
858
+ image_file_paths.append(file_path)
859
+ logger.info(f"Added to image_file_paths: {file_path}")
860
+
861
+ # 使用PIL加载图片
862
+ image = Image.open(file_path)
863
+ logger.info(f"Loaded image with size: {image.size}")
864
+
865
+ # 可选:调整图片大小以节省带宽
866
+ if max(image.size) > 1024:
867
+ ratio = 1024 / max(image.size)
868
+ new_size = tuple(int(dim * ratio) for dim in image.size)
869
+ image = image.resize(new_size, Image.Resampling.LANCZOS)
870
+ logger.info(f"Resized image to: {new_size}")
871
+
872
+ uploaded_images.append(image)
873
+
874
+ # 替换而不是追加图片(修复累积bug)
875
+ state_value["uploaded_images"] = uploaded_images
876
+ state_value["image_file_paths"] = image_file_paths
877
+
878
+ logger.info(f"Successfully uploaded {len(uploaded_images)} images")
879
+
880
+ # 显示状态指示器,显示图片数量
881
+ return (
882
+ gr.update(value=state_value),
883
+ gr.update(count=len(uploaded_images), elem_style=dict(display="block")), # 左侧绿色指示器
884
+ gr.update(elem_style=dict(display="block")), # 显示垃圾桶按钮
885
+ )
886
+
887
+ except Exception as e:
888
+ logger.error(f"Error handling image upload: {str(e)}")
889
+ import traceback
890
+ logger.error(f"Full traceback: {traceback.format_exc()}")
891
+ return (
892
+ gr.update(value=state_value),
893
+ gr.update(count=0, elem_style=dict(display="block")), # 左侧绿色指示器显示0
894
+ gr.update(elem_style=dict(display="none")), # 隐藏垃圾桶按钮
895
+ )
896
+
897
+
898
+
899
+ @staticmethod
900
+ def clear_images(state_value):
901
+ """清空上传的图片"""
902
+ state_value["uploaded_images"] = []
903
+ state_value["image_file_paths"] = []
904
+ logger.info("Cleared all uploaded images")
905
+ return (
906
+ gr.update(value=state_value),
907
+ gr.update(count=0, elem_style=dict(display="block")), # 左侧绿色指示器显示0
908
+ gr.update(elem_style=dict(display="none")), # 隐藏垃圾桶按钮
909
+ )
910
+
911
+
912
+ css = """
913
+ .gradio-container {
914
+ padding: 0 !important;
915
+ }
916
+ .gradio-container > main.fillable {
917
+ padding: 0 !important;
918
+ }
919
+ #chatbot {
920
+ height: calc(100vh - 21px - 16px);
921
+ }
922
+ #chatbot .chatbot-conversations {
923
+ height: 100%;
924
+ background-color: var(--ms-gr-ant-color-bg-layout);
925
+ }
926
+ #chatbot .chatbot-conversations .chatbot-conversations-list {
927
+ padding-left: 0;
928
+ padding-right: 0;
929
+ }
930
+ #chatbot .chatbot-chat {
931
+ padding: 32px;
932
+ height: 100%;
933
+ }
934
+ @media (max-width: 768px) {
935
+ #chatbot .chatbot-chat {
936
+ padding: 0;
937
+ }
938
+ }
939
+ #chatbot .chatbot-chat .chatbot-chat-messages {
940
+ flex: 1;
941
+ }
942
+ #chatbot .chatbot-chat .chatbot-chat-messages .chatbot-chat-message .chatbot-chat-message-footer {
943
+ visibility: hidden;
944
+ opacity: 0;
945
+ transition: opacity 0.2s;
946
+ }
947
+ #chatbot .chatbot-chat .chatbot-chat-messages .chatbot-chat-message:last-child .chatbot-chat-message-footer {
948
+ visibility: visible;
949
+ opacity: 1;
950
+ }
951
+ #chatbot .chatbot-chat .chatbot-chat-messages .chatbot-chat-message:hover .chatbot-chat-message-footer {
952
+ visibility: visible;
953
+ opacity: 1;
954
+ }
955
+ /* Thinking区域样式 */
956
+ .thinking-content .ant-collapse {
957
+ background: linear-gradient(135deg, #f6f9fc 0%, #f0f4f8 100%);
958
+ border: 1px solid #e1e8ed;
959
+ border-radius: 8px;
960
+ margin-bottom: 12px;
961
+ }
962
+ .thinking-content .ant-collapse > .ant-collapse-item > .ant-collapse-header {
963
+ padding: 8px 12px;
964
+ font-size: 13px;
965
+ color: #5a6c7d;
966
+ font-weight: 500;
967
+ }
968
+ .thinking-content .ant-collapse-content > .ant-collapse-content-box {
969
+ padding: 12px;
970
+ background: #fff;
971
+ border-radius: 0 0 6px 6px;
972
+ font-size: 13px;
973
+ color: #667788;
974
+ line-height: 1.5;
975
+ white-space: pre-wrap;
976
+ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
977
+ }
978
+
979
+ /* 图片预览和展示样式 */
980
+ .image-preview-container {
981
+ background: #fafafa;
982
+ border: 1px solid #d9d9d9;
983
+ border-radius: 8px;
984
+ padding: 12px;
985
+ margin-bottom: 12px;
986
+ }
987
+
988
+ .image-gallery img {
989
+ transition: all 0.2s ease;
990
+ border-radius: 4px;
991
+ }
992
+
993
+ .image-gallery img:hover {
994
+ transform: scale(1.05);
995
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
996
+ z-index: 10;
997
+ position: relative;
998
+ }
999
+
1000
+ .image-thumbnail {
1001
+ position: relative;
1002
+ display: inline-block;
1003
+ margin: 4px;
1004
+ border-radius: 6px;
1005
+ overflow: hidden;
1006
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
1007
+ transition: all 0.2s ease;
1008
+ }
1009
+
1010
+ .image-thumbnail:hover {
1011
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
1012
+ transform: translateY(-2px);
1013
+ }
1014
+
1015
+ .image-upload-preview {
1016
+ background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
1017
+ border: 2px dashed #d9d9d9;
1018
+ border-radius: 8px;
1019
+ padding: 16px;
1020
+ margin-bottom: 16px;
1021
+ text-align: center;
1022
+ transition: all 0.3s ease;
1023
+ }
1024
+
1025
+ .image-upload-preview.has-images {
1026
+ border-style: solid;
1027
+ border-color: #6A57FF;
1028
+ background: linear-gradient(135deg, #f6f9fc 0%, #f0f4f8 100%);
1029
+ }
1030
+
1031
+ /* 响应式图片展示 */
1032
+ @media (max-width: 768px) {
1033
+ .image-gallery img {
1034
+ width: 80px !important;
1035
+ height: 60px !important;
1036
+ }
1037
+
1038
+ .image-thumbnail {
1039
+ width: 80px;
1040
+ height: 60px;
1041
+ }
1042
+ }
1043
+
1044
+ /* 图片加载动画 */
1045
+ @keyframes imageLoad {
1046
+ from { opacity: 0; transform: scale(0.8); }
1047
+ to { opacity: 1; transform: scale(1); }
1048
+ }
1049
+
1050
+ .image-gallery img {
1051
+ animation: imageLoad 0.3s ease;
1052
+ }
1053
+ """
1054
+
1055
+
1056
+ def logo():
1057
+ with antd.Typography.Title(level=1,
1058
+ elem_style=dict(fontSize=24,
1059
+ padding=8,
1060
+ margin=0)):
1061
+ with antd.Flex(align="center", gap="small", justify="center"):
1062
+ antd.Image(logo_img,
1063
+ preview=False,
1064
+ alt="logo",
1065
+ width=24,
1066
+ height=24)
1067
+ ms.Span("dots.vlm1.inst")
1068
+
1069
+
1070
+ with gr.Blocks(css=css, fill_width=True) as demo:
1071
+ state = gr.State({
1072
+ "conversations_history": {},
1073
+ "conversations": [],
1074
+ "conversation_id": "",
1075
+ "editing_message_index": -1,
1076
+ "uploaded_images": [], # 存储当前上传的图片
1077
+ "image_file_paths": [], # 存储图片文件路径用于预览
1078
+ })
1079
+
1080
+ with ms.Application(), antdx.XProvider(
1081
+ theme=DEFAULT_THEME, locale=DEFAULT_LOCALE), ms.AutoLoading():
1082
+ with antd.Row(gutter=[20, 20], wrap=False, elem_id="chatbot"):
1083
+ # Left Column
1084
+ with antd.Col(md=dict(flex="0 0 260px", span=24, order=0),
1085
+ span=0,
1086
+ order=1,
1087
+ elem_classes="chatbot-conversations",
1088
+ elem_style=dict(
1089
+ maxWidth="260px",
1090
+ minWidth="260px",
1091
+ overflow="hidden")):
1092
+ with antd.Flex(vertical=True,
1093
+ gap="small",
1094
+ elem_style=dict(height="100%", width="100%", minWidth="0")):
1095
+ # Logo
1096
+ logo()
1097
+
1098
+ # New Conversation Button
1099
+ with antd.Button(value=None,
1100
+ color="primary",
1101
+ variant="filled",
1102
+ block=True, elem_style=dict(maxWidth="100%")) as add_conversation_btn:
1103
+ ms.Text(get_text("New Conversation", "新建对话"))
1104
+ with ms.Slot("icon"):
1105
+ antd.Icon("PlusOutlined")
1106
+
1107
+ # Conversations List
1108
+ with antdx.Conversations(
1109
+ elem_classes="chatbot-conversations-list",
1110
+ elem_style=dict(
1111
+ width="100%",
1112
+ minWidth="0",
1113
+ overflow="hidden",
1114
+ flex="1"
1115
+ )
1116
+ ) as conversations:
1117
+ with ms.Slot('menu.items'):
1118
+ with antd.Menu.Item(
1119
+ label="Delete", key="delete", danger=True
1120
+ ) as conversation_delete_menu_item:
1121
+ with ms.Slot("icon"):
1122
+ antd.Icon("DeleteOutlined")
1123
+ # Right Column
1124
+ with antd.Col(flex=1, elem_style=dict(height="100%")):
1125
+ with antd.Flex(vertical=True,
1126
+ gap="middle",
1127
+ elem_classes="chatbot-chat"):
1128
+ # Chatbot
1129
+ with antdx.Bubble.List(
1130
+ items=DEFAULT_CONVERSATIONS_HISTORY,
1131
+ elem_classes="chatbot-chat-messages") as chatbot:
1132
+ # Define Chatbot Roles
1133
+ with ms.Slot("roles"):
1134
+ # Placeholder Role
1135
+ with antdx.Bubble.List.Role(
1136
+ role="placeholder",
1137
+ styles=dict(content=dict(width="100%")),
1138
+ variant="borderless"):
1139
+ with ms.Slot("messageRender"):
1140
+ with antd.Space(
1141
+ direction="vertical",
1142
+ size=16,
1143
+ elem_style=dict(width="100%")):
1144
+ with antdx.Welcome(
1145
+ styles=dict(icon=dict(
1146
+ flexShrink=0)),
1147
+ variant="borderless",
1148
+ title=get_text(
1149
+ "Hello, I'm dots.",
1150
+ "你好,我是 dots."),
1151
+ description=get_text(
1152
+ "",
1153
+ ""),
1154
+ ):
1155
+ with ms.Slot("icon"):
1156
+ antd.Image(logo_img,
1157
+ preview=False)
1158
+
1159
+
1160
+ # User Role
1161
+ with antdx.Bubble.List.Role(
1162
+ role="user",
1163
+ placement="end",
1164
+ elem_classes="chatbot-chat-message",
1165
+ class_names=dict(
1166
+ footer="chatbot-chat-message-footer"),
1167
+ styles=dict(content=dict(
1168
+ maxWidth="100%",
1169
+ overflow='auto',
1170
+ ))):
1171
+ with ms.Slot(
1172
+ "messageRender",
1173
+ params_mapping="""(content) => {
1174
+ // 检查多种图片存储格式
1175
+ let imageCount = 0;
1176
+ let textContent = '';
1177
+ let imagesBase64 = [];
1178
+
1179
+ if (typeof content === 'object') {
1180
+ // 新格式:检查 images_count
1181
+ if (content.images_count && content.images_count > 0) {
1182
+ imageCount = content.images_count;
1183
+ textContent = content.text || '';
1184
+ imagesBase64 = content.images_base64 || [];
1185
+ }
1186
+ // 旧格式:检查 images 数组
1187
+ else if (content.images && content.images.length > 0) {
1188
+ imageCount = content.images.length;
1189
+ textContent = content.text || '';
1190
+ imagesBase64 = content.images || [];
1191
+ }
1192
+ // 纯文本格式
1193
+ else {
1194
+ textContent = content.text || content;
1195
+ }
1196
+ } else {
1197
+ // 字符串格式
1198
+ textContent = content;
1199
+ }
1200
+
1201
+ if (imageCount > 0 && imagesBase64.length > 0) {
1202
+ const imageHtml = imagesBase64.map((base64, index) =>
1203
+ `<img src="data:image/jpeg;base64,${base64}"
1204
+ style="width: 120px; height: 90px; object-fit: cover; border-radius: 6px; margin: 4px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); cursor: pointer;"
1205
+ alt="Image ${index + 1}" />`
1206
+ ).join('');
1207
+
1208
+ return {
1209
+ image_info: {
1210
+ style: { marginBottom: '8px', fontSize: '13px', color: '#666' },
1211
+ value: `📷 包含 ${imageCount} 张图片`
1212
+ },
1213
+ text_content: {
1214
+ value: textContent
1215
+ },
1216
+ image_gallery: {
1217
+ value: `<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px;">${imageHtml}</div>`
1218
+ }
1219
+ };
1220
+ }
1221
+
1222
+ return {
1223
+ text_content: {
1224
+ value: textContent
1225
+ },
1226
+ image_info: { style: { display: 'none' } },
1227
+ image_gallery: { value: '' }
1228
+ };
1229
+ }"""):
1230
+ # 图片信息提示
1231
+ antd.Typography.Text(as_item="image_info", type="secondary")
1232
+
1233
+ # 图片展示区域 - 使用Markdown组件显示HTML
1234
+ ms.Markdown(as_item="image_gallery")
1235
+
1236
+ # 文本内容
1237
+ ms.Markdown(as_item="text_content")
1238
+ with ms.Slot("footer",
1239
+ params_mapping="""(bubble) => {
1240
+ return {
1241
+ copy_btn: {
1242
+ copyable: { text: typeof bubble.content === 'string' ? bubble.content : bubble.content?.text, tooltips: false },
1243
+ },
1244
+ edit_btn: { conversationKey: bubble.key, disabled: bubble.meta.disabled },
1245
+ delete_btn: { conversationKey: bubble.key, disabled: bubble.meta.disabled },
1246
+ };
1247
+ }"""):
1248
+ with antd.Typography.Text(
1249
+ copyable=dict(tooltips=False),
1250
+ as_item="copy_btn"):
1251
+ with ms.Slot("copyable.icon"):
1252
+ with antd.Button(value=None,
1253
+ size="small",
1254
+ color="default",
1255
+ variant="text"):
1256
+ with ms.Slot("icon"):
1257
+ antd.Icon("CopyOutlined")
1258
+ with antd.Button(value=None,
1259
+ size="small",
1260
+ color="default",
1261
+ variant="text"):
1262
+ with ms.Slot("icon"):
1263
+ antd.Icon("CheckOutlined")
1264
+ with antd.Button(value=None,
1265
+ size="small",
1266
+ color="default",
1267
+ variant="text",
1268
+ as_item="edit_btn"
1269
+ ) as user_edit_btn:
1270
+ with ms.Slot("icon"):
1271
+ antd.Icon("EditOutlined")
1272
+ with antd.Popconfirm(
1273
+ title="Delete the message",
1274
+ description=
1275
+ "Are you sure to delete this message?",
1276
+ ok_button_props=dict(danger=True),
1277
+ as_item="delete_btn"
1278
+ ) as user_delete_popconfirm:
1279
+ with antd.Button(value=None,
1280
+ size="small",
1281
+ color="default",
1282
+ variant="text",
1283
+ as_item="delete_btn"):
1284
+ with ms.Slot("icon"):
1285
+ antd.Icon("DeleteOutlined")
1286
+
1287
+ # Chatbot Role
1288
+ with antdx.Bubble.List.Role(
1289
+ role="assistant",
1290
+ placement="start",
1291
+ elem_classes="chatbot-chat-message",
1292
+ class_names=dict(
1293
+ footer="chatbot-chat-message-footer"),
1294
+ styles=dict(content=dict(
1295
+ maxWidth="100%", overflow='auto'))):
1296
+ with ms.Slot("avatar"):
1297
+ antd.Avatar(
1298
+ os.path.join(os.path.dirname(__file__),
1299
+ "rednote_hilab.png"))
1300
+ with ms.Slot(
1301
+ "messageRender",
1302
+ params_mapping="""(content, bubble) => {
1303
+ const has_error = bubble?.meta?.error
1304
+ const thinking_content = bubble?.meta?.thinking_content || ""
1305
+ const is_thinking = bubble?.meta?.is_thinking || false
1306
+ const thinking_done = bubble?.meta?.thinking_done || false
1307
+
1308
+ return {
1309
+ thinking_collapse: {
1310
+ items: thinking_content || is_thinking ? [{
1311
+ key: '1',
1312
+ label: is_thinking ? '🤔 正在思考...' : '🤔 思考过程',
1313
+ children: thinking_content || '思考中...'
1314
+ }] : [],
1315
+ style: {
1316
+ display: (thinking_content || is_thinking) ? 'block' : 'none',
1317
+ marginBottom: thinking_content || is_thinking ? '12px' : '0'
1318
+ },
1319
+ size: 'small',
1320
+ ghost: true
1321
+ },
1322
+ answer: {
1323
+ value: content
1324
+ },
1325
+ canceled: bubble.meta?.canceled ? undefined : { style: { display: 'none' } }
1326
+ }
1327
+ }"""):
1328
+ # Thinking区域 - 可折叠显示
1329
+ antd.Collapse(
1330
+ as_item="thinking_collapse",
1331
+ elem_classes="thinking-content"
1332
+ )
1333
+
1334
+ # 回答内容
1335
+ ms.Markdown(
1336
+ as_item="answer",
1337
+ elem_classes="answer-content")
1338
+
1339
+ antd.Divider(as_item="canceled")
1340
+ antd.Typography.Text(get_text(
1341
+ "Chat completion paused.", "聊天已暂停。"),
1342
+ as_item="canceled",
1343
+ type="warning")
1344
+
1345
+ with ms.Slot("footer",
1346
+ params_mapping="""(bubble) => {
1347
+ if (bubble?.meta?.end) {
1348
+ return {
1349
+ copy_btn: {
1350
+ copyable: { text: bubble.content, tooltips: false },
1351
+ },
1352
+ regenerate_btn: { conversationKey: bubble.key, disabled: bubble.meta.disabled },
1353
+ delete_btn: { conversationKey: bubble.key, disabled: bubble.meta.disabled },
1354
+ edit_btn: { conversationKey: bubble.key, disabled: bubble.meta.disabled },
1355
+ };
1356
+ }
1357
+ return { actions_container: { style: { display: 'none' } } };
1358
+ }"""):
1359
+ with ms.Div(as_item="actions_container"):
1360
+ with antd.Typography.Text(
1361
+ copyable=dict(tooltips=False),
1362
+ as_item="copy_btn"):
1363
+ with ms.Slot("copyable.icon"):
1364
+ with antd.Button(
1365
+ value=None,
1366
+ size="small",
1367
+ color="default",
1368
+ variant="text"):
1369
+ with ms.Slot("icon"):
1370
+ antd.Icon(
1371
+ "CopyOutlined")
1372
+ with antd.Button(
1373
+ value=None,
1374
+ size="small",
1375
+ color="default",
1376
+ variant="text"):
1377
+ with ms.Slot("icon"):
1378
+ antd.Icon(
1379
+ "CheckOutlined")
1380
+
1381
+ with antd.Popconfirm(
1382
+ title=get_text(
1383
+ "Regenerate the message",
1384
+ "重新生成消息"),
1385
+ description=get_text(
1386
+ "Regenerate the message will also delete all subsequent messages.",
1387
+ "重新生成消息将会删除所有的后续消息。"),
1388
+ ok_button_props=dict(
1389
+ danger=True),
1390
+ as_item="regenerate_btn"
1391
+ ) as chatbot_regenerate_popconfirm:
1392
+ with antd.Button(
1393
+ value=None,
1394
+ size="small",
1395
+ color="default",
1396
+ variant="text",
1397
+ as_item="regenerate_btn",
1398
+ ):
1399
+ with ms.Slot("icon"):
1400
+ antd.Icon("SyncOutlined")
1401
+ with antd.Button(value=None,
1402
+ size="small",
1403
+ color="default",
1404
+ variant="text",
1405
+ as_item="edit_btn"
1406
+ ) as chatbot_edit_btn:
1407
+ with ms.Slot("icon"):
1408
+ antd.Icon("EditOutlined")
1409
+ with antd.Popconfirm(
1410
+ title=get_text("Delete the message", "删除消息"),
1411
+ description=get_text(
1412
+ "Are you sure to delete this message?",
1413
+ "确定要删除这条消息吗?"),
1414
+ ok_button_props=dict(
1415
+ danger=True),
1416
+ as_item="delete_btn"
1417
+ ) as chatbot_delete_popconfirm:
1418
+ with antd.Button(
1419
+ value=None,
1420
+ size="small",
1421
+ color="default",
1422
+ variant="text",
1423
+ as_item="delete_btn"):
1424
+ with ms.Slot("icon"):
1425
+ antd.Icon("DeleteOutlined")
1426
+
1427
+
1428
+
1429
+
1430
+ # Sender
1431
+ with antdx.Suggestion(
1432
+ # onKeyDown Handler in Javascript
1433
+ should_trigger="""(e, { onTrigger, onKeyDown }) => {
1434
+ switch(e.key) {
1435
+ case '/':
1436
+ onTrigger()
1437
+ break
1438
+ case 'ArrowRight':
1439
+ case 'ArrowLeft':
1440
+ case 'ArrowUp':
1441
+ case 'ArrowDown':
1442
+ break;
1443
+ default:
1444
+ onTrigger(false)
1445
+ }
1446
+ onKeyDown(e)
1447
+ }""") as suggestion:
1448
+ with ms.Slot("children"):
1449
+ with antdx.Sender(placeholder=get_text(
1450
+ "Enter Prompt",
1451
+ "输入"), ) as sender:
1452
+ with ms.Slot("actions"):
1453
+ # 停止生成按钮
1454
+ with antd.Button(
1455
+ type="text",
1456
+ size="large",
1457
+ visible=False, # 初始隐藏
1458
+ elem_style=dict(
1459
+ color="#ff4d4f", # 红色
1460
+ border="none",
1461
+ background="transparent"
1462
+ )
1463
+ ) as stop_btn:
1464
+ with ms.Slot("icon"):
1465
+ antd.Icon("StopOutlined")
1466
+ with ms.Slot("prefix"):
1467
+ # Image Upload Button with Counter - 图片上传按钮
1468
+ with antd.Space(size="small"):
1469
+ with antd.Tooltip(title="点击上传图片", color="green"):
1470
+ with antd.Upload(
1471
+ accept="image/*",
1472
+ multiple=True,
1473
+ show_upload_list=False,
1474
+ elem_style=dict(display="inline-block")
1475
+ ) as image_upload:
1476
+ with antd.Badge(
1477
+ count=0, # 默认显示0
1478
+ size="small",
1479
+ color="#52c41a", # 绿色
1480
+ elem_style=dict(display="block") # 默认显示
1481
+ ) as green_image_indicator:
1482
+ with antd.Button(
1483
+ type="text",
1484
+ size="large",
1485
+ elem_style=dict(
1486
+ color="#52c41a", # 绿色图标
1487
+ border="none",
1488
+ background="transparent"
1489
+ )
1490
+ ):
1491
+ with ms.Slot("icon"):
1492
+ antd.Icon("PictureOutlined")
1493
+
1494
+ # Trash Button - 垃圾桶清理按钮
1495
+ with antd.Tooltip(title="清除已上传的图片", color="red"):
1496
+ with antd.Button(
1497
+ type="text",
1498
+ size="large",
1499
+ elem_style=dict(
1500
+ color="#ff4d4f", # 红色图标
1501
+ border="none",
1502
+ background="transparent",
1503
+ display="none" # 默认隐藏,有图片时显示
1504
+ )
1505
+ ) as trash_button:
1506
+ with ms.Slot("icon"):
1507
+ antd.Icon("DeleteOutlined")
1508
+
1509
+ # Clear Button - 清空对话历史按钮
1510
+ with antd.Tooltip(title=get_text(
1511
+ "Clear Conversation History",
1512
+ "清空对话历史"), ):
1513
+ with antd.Button(
1514
+ value=None,
1515
+ type="text") as clear_btn:
1516
+ with ms.Slot("icon"):
1517
+ antd.Icon("ClearOutlined")
1518
+
1519
+ # Modals
1520
+ with antd.Modal(title=get_text("Edit Message", "编辑消息"),
1521
+ open=False,
1522
+ centered=True,
1523
+ width="60%") as edit_modal:
1524
+ edit_textarea = antd.Input.Textarea(auto_size=dict(minRows=2,
1525
+ maxRows=6),
1526
+ elem_style=dict(width="100%"))
1527
+ # Events Handler
1528
+ if save_history:
1529
+ browser_state = gr.BrowserState(
1530
+ {
1531
+ "conversations_history": {},
1532
+ "conversations": [],
1533
+ },
1534
+ storage_key="dots_chatbot_storage")
1535
+ state.change(fn=Gradio_Events.update_browser_state,
1536
+ inputs=[state],
1537
+ outputs=[browser_state])
1538
+
1539
+ demo.load(fn=Gradio_Events.apply_browser_state,
1540
+ inputs=[browser_state, state],
1541
+ outputs=[conversations, state])
1542
+
1543
+ add_conversation_btn.click(fn=Gradio_Events.new_chat,
1544
+ inputs=[state],
1545
+ outputs=[conversations, chatbot, state])
1546
+ conversations.active_change(fn=Gradio_Events.select_conversation,
1547
+ inputs=[state],
1548
+ outputs=[conversations, chatbot, state])
1549
+ conversations.menu_click(fn=Gradio_Events.click_conversation_menu,
1550
+ inputs=[state],
1551
+ outputs=[conversations, chatbot, state])
1552
+
1553
+ clear_btn.click(fn=Gradio_Events.clear_conversation_history,
1554
+ inputs=[state],
1555
+ outputs=[chatbot, state])
1556
+
1557
+ suggestion.select(fn=Gradio_Events.select_suggestion,
1558
+ inputs=[sender],
1559
+ outputs=[sender])
1560
+
1561
+ gr.on(triggers=[user_edit_btn.click, chatbot_edit_btn.click],
1562
+ fn=Gradio_Events.edit_message,
1563
+ inputs=[state],
1564
+ outputs=[edit_textarea, state]).then(fn=Gradio_Events.open_modal,
1565
+ outputs=[edit_modal])
1566
+ edit_modal.ok(fn=Gradio_Events.confirm_edit_message,
1567
+ inputs=[edit_textarea, state],
1568
+ outputs=[chatbot, state]).then(fn=Gradio_Events.close_modal,
1569
+ outputs=[edit_modal])
1570
+ edit_modal.cancel(fn=Gradio_Events.close_modal, outputs=[edit_modal])
1571
+ gr.on(triggers=[
1572
+ chatbot_delete_popconfirm.confirm, user_delete_popconfirm.confirm
1573
+ ],
1574
+ fn=Gradio_Events.delete_message,
1575
+ inputs=[state],
1576
+ outputs=[chatbot, state])
1577
+
1578
+ regenerating_event = chatbot_regenerate_popconfirm.confirm(
1579
+ fn=Gradio_Events.regenerate_message,
1580
+ inputs=[state],
1581
+ outputs=[sender, clear_btn, conversation_delete_menu_item, add_conversation_btn, conversations, chatbot, state,
1582
+ image_upload, green_image_indicator, trash_button, stop_btn])
1583
+
1584
+ # 图片上传事件
1585
+ image_upload.change(fn=Gradio_Events.handle_image_upload,
1586
+ inputs=[image_upload, state],
1587
+ outputs=[state, green_image_indicator, trash_button])
1588
+
1589
+ # 清空图片事件 - 垃圾桶按钮
1590
+ trash_button.click(fn=Gradio_Events.clear_images,
1591
+ inputs=[state],
1592
+ outputs=[state, green_image_indicator, trash_button])
1593
+
1594
+ submit_event = sender.submit(fn=Gradio_Events.submit,
1595
+ inputs=[sender, state],
1596
+ outputs=[sender, clear_btn, conversation_delete_menu_item,
1597
+ add_conversation_btn, conversations, chatbot, state,
1598
+ image_upload, green_image_indicator, trash_button, stop_btn])
1599
+ # 停止按钮点击事件
1600
+ stop_btn.click(fn=None, cancels=[submit_event, regenerating_event])
1601
+ stop_btn.click(fn=Gradio_Events.cancel,
1602
+ inputs=[state],
1603
+ outputs=[
1604
+ sender, conversation_delete_menu_item, clear_btn,
1605
+ conversations, add_conversation_btn, chatbot, state, stop_btn
1606
+ ])
1607
+
1608
+ sender.cancel(fn=None, cancels=[submit_event, regenerating_event])
1609
+ sender.cancel(fn=Gradio_Events.cancel,
1610
+ inputs=[state],
1611
+ outputs=[
1612
+ sender, conversation_delete_menu_item, clear_btn,
1613
+ conversations, add_conversation_btn, chatbot, state, stop_btn
1614
+ ])
1615
+
1616
+ if __name__ == "__main__":
1617
+ import sys
1618
+ import argparse
1619
+
1620
+ parser = argparse.ArgumentParser(description="启动 Gradio Demo")
1621
+ parser.add_argument("--port", type=int, default=7960, help="指定服务端口,默认为7960")
1622
+ parser.add_argument("--host", type=str, default="0.0.0.0", help="指定服务地址,默认为0.0.0.0(允许外部访问),使用127.0.0.1仅允许本地访问")
1623
+ args = parser.parse_args()
1624
+
1625
+ demo.queue(default_concurrency_limit=200).launch(
1626
+ ssr_mode=False,
1627
+ max_threads=200,
1628
+ server_port=args.port,
1629
+ server_name=args.host # 使用命令行参数控制访问地址
1630
+ )
rednote_hilab.png ADDED
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio
2
+ modelscope_studio
3
+ openai
4
+ langfuse
5
+ pillow