Spaces:
Sleeping
Sleeping
chenzerong
commited on
Commit
·
8ff3f24
1
Parent(s):
9c4ecbb
add flask
Browse files- .dockerignore +51 -0
- .gitignore +47 -0
- Dockerfile +59 -0
- README.md +92 -4
- app.py +294 -44
- flask_app.py +134 -0
- requirements.txt +5 -1
- service.py +149 -0
- static/uploads/.gitkeep +0 -0
- templates/index.html +429 -0
.dockerignore
ADDED
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Git
|
2 |
+
.git
|
3 |
+
.gitignore
|
4 |
+
.gitattributes
|
5 |
+
|
6 |
+
# Python
|
7 |
+
__pycache__/
|
8 |
+
*.py[cod]
|
9 |
+
*$py.class
|
10 |
+
*.so
|
11 |
+
.Python
|
12 |
+
env/
|
13 |
+
build/
|
14 |
+
develop-eggs/
|
15 |
+
dist/
|
16 |
+
downloads/
|
17 |
+
eggs/
|
18 |
+
.eggs/
|
19 |
+
lib/
|
20 |
+
lib64/
|
21 |
+
parts/
|
22 |
+
sdist/
|
23 |
+
var/
|
24 |
+
*.egg-info/
|
25 |
+
.installed.cfg
|
26 |
+
*.egg
|
27 |
+
|
28 |
+
# Environments
|
29 |
+
# 不排除.env文件,Docker构建时需要
|
30 |
+
# .env
|
31 |
+
.venv
|
32 |
+
env/
|
33 |
+
venv/
|
34 |
+
ENV/
|
35 |
+
env.bak/
|
36 |
+
venv.bak/
|
37 |
+
|
38 |
+
# IDE
|
39 |
+
.idea/
|
40 |
+
.vscode/
|
41 |
+
*.swp
|
42 |
+
*.swo
|
43 |
+
|
44 |
+
# 应用特定
|
45 |
+
*.log
|
46 |
+
uploads/*
|
47 |
+
!uploads/.gitkeep
|
48 |
+
|
49 |
+
# Docker
|
50 |
+
Dockerfile
|
51 |
+
.dockerignore
|
.gitignore
ADDED
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# 环境变量
|
2 |
+
.env
|
3 |
+
|
4 |
+
# 上传的文件
|
5 |
+
static/uploads/
|
6 |
+
!static/uploads/.gitkeep
|
7 |
+
|
8 |
+
# Python 缓存文件
|
9 |
+
__pycache__/
|
10 |
+
*.py[cod]
|
11 |
+
*$py.class
|
12 |
+
*.so
|
13 |
+
.Python
|
14 |
+
build/
|
15 |
+
develop-eggs/
|
16 |
+
dist/
|
17 |
+
downloads/
|
18 |
+
eggs/
|
19 |
+
.eggs/
|
20 |
+
lib/
|
21 |
+
lib64/
|
22 |
+
parts/
|
23 |
+
sdist/
|
24 |
+
var/
|
25 |
+
wheels/
|
26 |
+
*.egg-info/
|
27 |
+
.installed.cfg
|
28 |
+
*.egg
|
29 |
+
|
30 |
+
# 虚拟环境
|
31 |
+
.venv/
|
32 |
+
venv/
|
33 |
+
ENV/
|
34 |
+
env/
|
35 |
+
|
36 |
+
# IDE 文件
|
37 |
+
.idea/
|
38 |
+
.vscode/
|
39 |
+
*.swp
|
40 |
+
*.swo
|
41 |
+
|
42 |
+
# Docker 相关
|
43 |
+
.dockerignore
|
44 |
+
|
45 |
+
# 其他
|
46 |
+
.DS_Store
|
47 |
+
Thumbs.db
|
Dockerfile
ADDED
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FROM python:3.9-slim
|
2 |
+
|
3 |
+
WORKDIR /code
|
4 |
+
|
5 |
+
# 安装系统依赖
|
6 |
+
RUN apt-get update && \
|
7 |
+
apt-get install -y --no-install-recommends curl && \
|
8 |
+
apt-get clean && \
|
9 |
+
rm -rf /var/lib/apt/lists/*
|
10 |
+
|
11 |
+
# 复制必要的文件
|
12 |
+
COPY ./requirements.txt /code/requirements.txt
|
13 |
+
|
14 |
+
# 安装依赖
|
15 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
16 |
+
pip install --no-cache-dir --upgrade -r /code/requirements.txt && \
|
17 |
+
pip install gunicorn
|
18 |
+
|
19 |
+
# 复制应用文件
|
20 |
+
COPY ./flask_app.py /code/flask_app.py
|
21 |
+
COPY ./service.py /code/service.py
|
22 |
+
COPY ./templates /code/templates
|
23 |
+
COPY ./static /code/static
|
24 |
+
COPY ./.env /code/.env
|
25 |
+
|
26 |
+
# 创建必要的目录
|
27 |
+
RUN mkdir -p /code/static/uploads
|
28 |
+
|
29 |
+
# 创建启动脚本
|
30 |
+
RUN echo '#!/bin/bash\n\
|
31 |
+
# 优先使用Docker Secret\n\
|
32 |
+
if [ -f /run/secrets/MISTRAL_API_KEY ]; then\n\
|
33 |
+
export MISTRAL_API_KEY=$(cat /run/secrets/MISTRAL_API_KEY)\n\
|
34 |
+
# 其次使用Hugging Face Repository Secret\n\
|
35 |
+
elif [ -n "$HF_SECRET_MISTRAL_API_KEY" ]; then\n\
|
36 |
+
export MISTRAL_API_KEY=$HF_SECRET_MISTRAL_API_KEY\n\
|
37 |
+
# 最后尝试读取.env文件\n\
|
38 |
+
elif [ -f /code/.env ]; then\n\
|
39 |
+
export $(grep -v "^#" /code/.env | xargs)\n\
|
40 |
+
fi\n\
|
41 |
+
# 启动应用\n\
|
42 |
+
exec "$@"' > /code/entrypoint.sh && \
|
43 |
+
chmod +x /code/entrypoint.sh
|
44 |
+
|
45 |
+
# 设置端口
|
46 |
+
ENV PORT=7860
|
47 |
+
|
48 |
+
# 设置健康检查
|
49 |
+
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
50 |
+
CMD curl -f http://localhost:${PORT}/ || exit 1
|
51 |
+
|
52 |
+
# 暴露端口
|
53 |
+
EXPOSE ${PORT}
|
54 |
+
|
55 |
+
# 使用启动脚本
|
56 |
+
ENTRYPOINT ["/code/entrypoint.sh"]
|
57 |
+
|
58 |
+
# 启动应用
|
59 |
+
CMD ["gunicorn", "--workers=2", "--bind=0.0.0.0:7860", "flask_app:app"]
|
README.md
CHANGED
@@ -3,11 +3,99 @@ title: MistralApp
|
|
3 |
emoji: 💬
|
4 |
colorFrom: yellow
|
5 |
colorTo: purple
|
6 |
-
sdk:
|
7 |
-
|
8 |
-
app_file: app.py
|
9 |
pinned: false
|
10 |
license: apache-2.0
|
11 |
---
|
12 |
|
13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
3 |
emoji: 💬
|
4 |
colorFrom: yellow
|
5 |
colorTo: purple
|
6 |
+
sdk: docker
|
7 |
+
app_port: 7860
|
|
|
8 |
pinned: false
|
9 |
license: apache-2.0
|
10 |
---
|
11 |
|
12 |
+
# Mistral AI 多模态聊天助手
|
13 |
+
|
14 |
+
一个基于[Flask](https://flask.palletsprojects.com/)和[Mistral AI API](https://docs.mistral.ai/api/)的多模态聊天应用,支持文本和图像分析。
|
15 |
+
|
16 |
+
## 特性
|
17 |
+
|
18 |
+
- **多模态对话**: 支持文本和图像的混合输入
|
19 |
+
- **直接粘贴图片**: 可以使用`Ctrl+V`直接从剪贴板粘贴图片 ✨
|
20 |
+
- **现代化UI**: 友好的聊天界面,类似于现代消息应用
|
21 |
+
- **自定义系统提示**: 可以根据需要自定义AI助手的行为
|
22 |
+
- **响应式设计**: 适配不同的屏幕尺寸
|
23 |
+
|
24 |
+
## 使用方法
|
25 |
+
|
26 |
+
### 本地运行
|
27 |
+
|
28 |
+
1. 设置环境并安装依赖:
|
29 |
+
```bash
|
30 |
+
pip install -r requirements.txt
|
31 |
+
```
|
32 |
+
|
33 |
+
2. 设置Mistral API密钥:
|
34 |
+
```bash
|
35 |
+
export MISTRAL_API_KEY=your_api_key_here
|
36 |
+
```
|
37 |
+
|
38 |
+
3. 运行应用:
|
39 |
+
```bash
|
40 |
+
python flask_app.py
|
41 |
+
```
|
42 |
+
|
43 |
+
4. 在浏览器访问:
|
44 |
+
```
|
45 |
+
http://localhost:5000
|
46 |
+
```
|
47 |
+
|
48 |
+
### Docker部署
|
49 |
+
|
50 |
+
#### 本地构建和运行
|
51 |
+
|
52 |
+
1. 创建包含API密钥的.env文件:
|
53 |
+
```bash
|
54 |
+
echo "MISTRAL_API_KEY=your_mistral_api_key" > .env
|
55 |
+
```
|
56 |
+
|
57 |
+
2. 构建Docker镜像:
|
58 |
+
```bash
|
59 |
+
docker build -t mistralapp .
|
60 |
+
```
|
61 |
+
|
62 |
+
3. 运行Docker容器:
|
63 |
+
```bash
|
64 |
+
docker run -p 7860:7860 mistralapp
|
65 |
+
```
|
66 |
+
|
67 |
+
或者直接通过环境变量提供API密钥:
|
68 |
+
```bash
|
69 |
+
docker run -p 7860:7860 -e MISTRAL_API_KEY=your_api_key_here mistralapp
|
70 |
+
```
|
71 |
+
|
72 |
+
4. 在浏览器访问:
|
73 |
+
```
|
74 |
+
http://localhost:7860
|
75 |
+
```
|
76 |
+
|
77 |
+
### Hugging Face Spaces部署
|
78 |
+
|
79 |
+
此应用已配置为可以直接在Hugging Face Spaces上部署:
|
80 |
+
|
81 |
+
1. 在Hugging Face Spaces创建一个新的Space
|
82 |
+
2. 选择Docker作为SDK并设置app_port为7860
|
83 |
+
3. 在Space设置中添加Repository Secret:
|
84 |
+
- 名称:`MISTRAL_API_KEY`
|
85 |
+
- 值:您的Mistral API密钥
|
86 |
+
4. 将代码推送到该Space的仓库
|
87 |
+
5. Hugging Face将自动构建Docker镜像并启动应用
|
88 |
+
|
89 |
+
## 技术栈
|
90 |
+
|
91 |
+
- **后端**: Flask, Python, Mistral AI API
|
92 |
+
- **前端**: HTML, CSS, JavaScript
|
93 |
+
- **图像处理**: Pillow
|
94 |
+
- **部署**: Docker, Gunicorn
|
95 |
+
|
96 |
+
## 版本说明
|
97 |
+
|
98 |
+
项目提供了多个版本:
|
99 |
+
|
100 |
+
- **Flask版本** (`flask_app.py`): 支持直接粘贴图片,提供更现代的UI
|
101 |
+
- **Docker部署版本**: 使用Dockerfile配置,适合在Hugging Face Spaces上运行
|
app.py
CHANGED
@@ -1,64 +1,314 @@
|
|
1 |
-
import
|
2 |
-
|
|
|
|
|
|
|
|
|
|
|
3 |
|
4 |
"""
|
5 |
-
|
6 |
"""
|
7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
9 |
|
10 |
def respond(
|
11 |
message,
|
12 |
-
history
|
13 |
system_message,
|
14 |
max_tokens,
|
15 |
temperature,
|
16 |
top_p,
|
|
|
17 |
):
|
18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
19 |
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
|
|
25 |
|
26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
27 |
|
28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
29 |
|
30 |
-
|
31 |
-
|
32 |
-
max_tokens=max_tokens,
|
33 |
-
stream=True,
|
34 |
-
temperature=temperature,
|
35 |
-
top_p=top_p,
|
36 |
-
):
|
37 |
-
token = message.choices[0].delta.content
|
38 |
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
""
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
62 |
|
63 |
if __name__ == "__main__":
|
64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import base64
|
3 |
+
import io
|
4 |
+
import time
|
5 |
+
import streamlit as st
|
6 |
+
from PIL import Image
|
7 |
+
from service import Service
|
8 |
|
9 |
"""
|
10 |
+
使用 mistralai 官方库的 Service 类处理 API 请求
|
11 |
"""
|
12 |
+
# 设置页面配置 - 必须是第一个Streamlit命令
|
13 |
+
st.set_page_config(
|
14 |
+
page_title="Mistral 聊天助手",
|
15 |
+
page_icon="🤖",
|
16 |
+
layout="wide",
|
17 |
+
initial_sidebar_state="collapsed"
|
18 |
+
)
|
19 |
+
|
20 |
+
# 初始化API服务
|
21 |
+
service = Service()
|
22 |
+
|
23 |
+
# 初始化会话状态
|
24 |
+
if "messages" not in st.session_state:
|
25 |
+
st.session_state.messages = []
|
26 |
+
|
27 |
+
if "image_data" not in st.session_state:
|
28 |
+
st.session_state.image_data = None
|
29 |
+
|
30 |
+
def encode_image_to_base64(image):
|
31 |
+
"""将图像转换为 base64 字符串"""
|
32 |
+
if image is None:
|
33 |
+
return None
|
34 |
+
|
35 |
+
try:
|
36 |
+
# 如果是PIL图像
|
37 |
+
if isinstance(image, Image.Image):
|
38 |
+
buffered = io.BytesIO()
|
39 |
+
image.save(buffered, format="PNG")
|
40 |
+
img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
|
41 |
+
return f"data:image/png;base64,{img_str}"
|
42 |
+
# 如果是字节流或文件上传对象
|
43 |
+
elif hasattr(image, 'read') or isinstance(image, bytes):
|
44 |
+
if hasattr(image, 'read'):
|
45 |
+
image_bytes = image.read()
|
46 |
+
else:
|
47 |
+
image_bytes = image
|
48 |
+
img_str = base64.b64encode(image_bytes).decode("utf-8")
|
49 |
+
return f"data:image/png;base64,{img_str}"
|
50 |
+
# 如果是文件路径
|
51 |
+
elif isinstance(image, str) and os.path.isfile(image):
|
52 |
+
with open(image, "rb") as img_file:
|
53 |
+
img_str = base64.b64encode(img_file.read()).decode("utf-8")
|
54 |
+
return f"data:image/png;base64,{img_str}"
|
55 |
+
else:
|
56 |
+
st.error(f"不支持的图像类型: {type(image)}")
|
57 |
+
return None
|
58 |
+
except Exception as e:
|
59 |
+
st.error(f"编码图像时出错: {str(e)}")
|
60 |
+
return None
|
61 |
|
62 |
+
def read_file_content(file_path):
|
63 |
+
"""提取文件内容"""
|
64 |
+
if file_path is None:
|
65 |
+
return None
|
66 |
+
|
67 |
+
try:
|
68 |
+
print(f"尝试读取文件内容: {file_path}")
|
69 |
+
file_ext = os.path.splitext(file_path)[1].lower()
|
70 |
+
|
71 |
+
# 文本文件扩展名列表
|
72 |
+
text_exts = ['.txt', '.md', '.py', '.js', '.html', '.css', '.json', '.csv', '.xml', '.yaml', '.yml', '.ini', '.conf']
|
73 |
+
|
74 |
+
if file_ext in text_exts:
|
75 |
+
try:
|
76 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
77 |
+
content = f.read()
|
78 |
+
print(f"成功读取文件内容,长度: {len(content)}")
|
79 |
+
return content
|
80 |
+
except UnicodeDecodeError:
|
81 |
+
# 尝试使用其他编码
|
82 |
+
try:
|
83 |
+
with open(file_path, 'r', encoding='gbk') as f:
|
84 |
+
content = f.read()
|
85 |
+
print(f"使用GBK编码成功读取文件内容,长度: {len(content)}")
|
86 |
+
return content
|
87 |
+
except:
|
88 |
+
print(f"无法解码文件内容,可能是二进制文件")
|
89 |
+
return f"无法读取文件内容,文件可能是二进制格式或使用了不支持的编码。"
|
90 |
+
else:
|
91 |
+
return f"文件类型 {file_ext} 暂不支持直接读取内容,但我可以尝试分析文件名称。"
|
92 |
+
except Exception as e:
|
93 |
+
print(f"读取文件时出错: {str(e)}")
|
94 |
+
return f"读取文件时出错: {str(e)}"
|
95 |
|
96 |
def respond(
|
97 |
message,
|
98 |
+
history,
|
99 |
system_message,
|
100 |
max_tokens,
|
101 |
temperature,
|
102 |
top_p,
|
103 |
+
image=None
|
104 |
):
|
105 |
+
try:
|
106 |
+
print(f"响应函数收到:message={message[:50]}...(已截断), 图片={image is not None}")
|
107 |
+
|
108 |
+
# 准备完整的消息历史
|
109 |
+
messages = [{"role": "system", "content": system_message}]
|
110 |
+
|
111 |
+
# 添加历史消息
|
112 |
+
for msg in history:
|
113 |
+
if msg["role"] == "user":
|
114 |
+
messages.append({"role": "user", "content": msg["content"]})
|
115 |
+
elif msg["role"] == "assistant":
|
116 |
+
messages.append({"role": "assistant", "content": msg["content"]})
|
117 |
+
|
118 |
+
# 设置模型和参数
|
119 |
+
service.model = "mistral-small-latest" # 可以根据需要修改为其他模型
|
120 |
+
|
121 |
+
# 处理带图像的请求
|
122 |
+
if image is not None:
|
123 |
+
print("处理带图像的请求...")
|
124 |
+
# 使用 chat_with_image 方法处理多模态请求
|
125 |
+
response = service.chat_with_image(
|
126 |
+
text_prompt=message if message else "请分析这张图片",
|
127 |
+
image_base64=image,
|
128 |
+
history=messages
|
129 |
+
)
|
130 |
+
print("图像请求已发送到API")
|
131 |
+
else:
|
132 |
+
print("处理纯文本请求...")
|
133 |
+
# 纯文本请求,添加用户消息并获取响应
|
134 |
+
messages.append({"role": "user", "content": message})
|
135 |
+
response = service.get_response(messages)
|
136 |
+
|
137 |
+
# 返回响应结果
|
138 |
+
print(f"API返回响应: {response[:50]}...(已截断)")
|
139 |
+
return response
|
140 |
+
|
141 |
+
except Exception as e:
|
142 |
+
print(f"API 请求错误: {str(e)}")
|
143 |
+
return f"处理请求时出错: {str(e)}"
|
144 |
|
145 |
+
# 加载系统提示
|
146 |
+
def load_system_prompt():
|
147 |
+
return """你是一个有帮助的AI助手,可以回答用户的问题,也可以分析用户上传的图片。
|
148 |
+
如果用户上传了图片,请详细描述图片内容,并回答用户关于图片的问题。
|
149 |
+
如果用户没有上传图片,请正常回答用户的文本问题。
|
150 |
+
"""
|
151 |
|
152 |
+
# 获取API响应
|
153 |
+
def get_api_response(prompt, image_data=None):
|
154 |
+
try:
|
155 |
+
# 准备消息历史(不包括最新的用户消息)
|
156 |
+
messages = []
|
157 |
+
# 添加系统消息
|
158 |
+
messages.append({"role": "system", "content": load_system_prompt()})
|
159 |
+
|
160 |
+
# 添加历史消息
|
161 |
+
for msg in st.session_state.messages:
|
162 |
+
if msg["role"] != "system": # 跳过系统消息,因为我们已经添加了
|
163 |
+
messages.append({"role": msg["role"], "content": msg["content"]})
|
164 |
+
|
165 |
+
# 处理带图像的请求
|
166 |
+
if image_data:
|
167 |
+
st.info("正在处理图像...")
|
168 |
+
# 使用 chat_with_image 方法处理多模态请求
|
169 |
+
return service.chat_with_image(
|
170 |
+
text_prompt=prompt if prompt else "请分析这张图片",
|
171 |
+
image_base64=image_data,
|
172 |
+
history=messages
|
173 |
+
)
|
174 |
+
else:
|
175 |
+
# 添加最新的用户消息
|
176 |
+
messages.append({"role": "user", "content": prompt})
|
177 |
+
# 纯文本请求
|
178 |
+
return service.get_response(messages)
|
179 |
+
except Exception as e:
|
180 |
+
st.error(f"API 请求错误: {str(e)}")
|
181 |
+
return f"处理请求时出错: {str(e)}"
|
182 |
|
183 |
+
# 显示标题和说明
|
184 |
+
st.title("🤖 Mistral 多模态聊天助手")
|
185 |
+
st.markdown("""
|
186 |
+
### 使用说明
|
187 |
+
- 输入文字问题并按回车发送
|
188 |
+
- 点击"📋 粘贴图片"按钮,然后粘贴剪贴板中的图片
|
189 |
+
- 也可以使用"📎 上传图片"上传本地图片文件
|
190 |
+
- 图片和文字可以一起发送,或单独发送
|
191 |
+
""")
|
192 |
|
193 |
+
# 创建两列布局
|
194 |
+
col1, col2 = st.columns([3, 1])
|
|
|
|
|
|
|
|
|
|
|
|
|
195 |
|
196 |
+
with col2:
|
197 |
+
st.subheader("选项")
|
198 |
+
# 添加图片上传按钮
|
199 |
+
uploaded_file = st.file_uploader("📎 上传图片", type=["jpg", "jpeg", "png"], key="file_uploader")
|
200 |
+
|
201 |
+
# 粘贴图片按钮
|
202 |
+
if st.button("📋 粘贴图片"):
|
203 |
+
st.session_state.paste_mode = True
|
204 |
+
|
205 |
+
# 粘贴模式激活时显示粘贴区域
|
206 |
+
if "paste_mode" in st.session_state and st.session_state.paste_mode:
|
207 |
+
st.markdown("### 粘贴图片区域")
|
208 |
+
st.markdown("按 Ctrl+V 粘贴图片")
|
209 |
+
# 使用实验性功能接收粘贴的图片
|
210 |
+
pasted_image = st.camera_input("粘贴的图片会显示在这里", key="camera")
|
211 |
+
|
212 |
+
if pasted_image:
|
213 |
+
st.session_state.image_data = encode_image_to_base64(pasted_image)
|
214 |
+
st.session_state.paste_mode = False
|
215 |
+
st.experimental_rerun()
|
216 |
+
|
217 |
+
# 如果通过文件上传器上传了图片
|
218 |
+
if uploaded_file:
|
219 |
+
st.session_state.image_data = encode_image_to_base64(uploaded_file)
|
220 |
+
st.image(uploaded_file, caption="已上传的图片", use_column_width=True)
|
221 |
+
|
222 |
+
# 清除图片按钮
|
223 |
+
if st.session_state.image_data and st.button("🗑️ 清除图片"):
|
224 |
+
st.session_state.image_data = None
|
225 |
+
st.experimental_rerun()
|
226 |
+
|
227 |
+
# 清除对话按钮
|
228 |
+
if st.button("🧹 清除对话"):
|
229 |
+
st.session_state.messages = []
|
230 |
+
st.session_state.image_data = None
|
231 |
+
st.experimental_rerun()
|
232 |
|
233 |
+
with col1:
|
234 |
+
# 显示聊天历史
|
235 |
+
for message in st.session_state.messages:
|
236 |
+
with st.chat_message(message["role"]):
|
237 |
+
# 显示消息内容
|
238 |
+
st.markdown(message["content"])
|
239 |
+
# 如果消息包含图片
|
240 |
+
if "image" in message and message["image"]:
|
241 |
+
st.image(message["image"], use_column_width=True)
|
242 |
+
|
243 |
+
# 显示当前上传的图片预览
|
244 |
+
if st.session_state.image_data:
|
245 |
+
with st.expander("📷 当前图片预览", expanded=True):
|
246 |
+
# 从base64解码图片以显示预览
|
247 |
+
if "base64" in st.session_state.image_data:
|
248 |
+
image_b64 = st.session_state.image_data.split(",")[1]
|
249 |
+
image_bytes = base64.b64decode(image_b64)
|
250 |
+
st.image(image_bytes, caption="即将发送的图片", use_column_width=True)
|
251 |
+
|
252 |
+
# 用户输入
|
253 |
+
prompt = st.chat_input("输入您的问题...", key="user_input")
|
254 |
+
|
255 |
+
# 处理用户输入
|
256 |
+
if prompt:
|
257 |
+
# 添加用户消息到历史
|
258 |
+
user_message = {"role": "user", "content": prompt}
|
259 |
+
if st.session_state.image_data:
|
260 |
+
user_message["image"] = st.session_state.image_data
|
261 |
+
|
262 |
+
st.session_state.messages.append(user_message)
|
263 |
+
|
264 |
+
# 显示用户消息
|
265 |
+
with st.chat_message("user"):
|
266 |
+
st.markdown(prompt)
|
267 |
+
if st.session_state.image_data:
|
268 |
+
# 从base64解码图片以显示预览
|
269 |
+
if "base64" in st.session_state.image_data:
|
270 |
+
image_b64 = st.session_state.image_data.split(",")[1]
|
271 |
+
image_bytes = base64.b64decode(image_b64)
|
272 |
+
st.image(image_bytes, use_column_width=True)
|
273 |
+
|
274 |
+
# 显示助手思考中的状态
|
275 |
+
with st.chat_message("assistant"):
|
276 |
+
with st.spinner("思考中..."):
|
277 |
+
# 获取API响应
|
278 |
+
response = get_api_response(prompt, st.session_state.image_data)
|
279 |
+
|
280 |
+
# 显示响应
|
281 |
+
message_placeholder = st.empty()
|
282 |
+
full_response = ""
|
283 |
+
|
284 |
+
# 模拟流式响应
|
285 |
+
for chunk in response.split():
|
286 |
+
full_response += chunk + " "
|
287 |
+
message_placeholder.markdown(full_response + "▌")
|
288 |
+
time.sleep(0.01)
|
289 |
+
|
290 |
+
message_placeholder.markdown(full_response)
|
291 |
+
|
292 |
+
# 添加助手响应到历史
|
293 |
+
st.session_state.messages.append({"role": "assistant", "content": full_response})
|
294 |
+
|
295 |
+
# 清除当前图片数据,防止重复使用
|
296 |
+
st.session_state.image_data = None
|
297 |
+
|
298 |
+
# 重新运行页面以更新UI
|
299 |
+
st.experimental_rerun()
|
300 |
|
301 |
if __name__ == "__main__":
|
302 |
+
# 从环境变量获取 API 密钥,或者提示用户设置
|
303 |
+
api_key = os.environ.get("MISTRAL_API_KEY", "")
|
304 |
+
|
305 |
+
if not api_key:
|
306 |
+
st.sidebar.warning("未设置 MISTRAL_API_KEY 环境变量。请设置环境变量或在代码中直接设置密钥。")
|
307 |
+
api_key = st.sidebar.text_input("输入您的 Mistral API 密钥:", type="password")
|
308 |
+
|
309 |
+
# 设置 API 密钥
|
310 |
+
if api_key:
|
311 |
+
service.headers = {"Authorization": f"Bearer {api_key}"}
|
312 |
+
st.sidebar.success("API密钥已配置")
|
313 |
+
else:
|
314 |
+
st.sidebar.error("请设置 Mistral API 密钥以继续使用")
|
flask_app.py
ADDED
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from flask import Flask, render_template, request, jsonify, session
|
2 |
+
import base64
|
3 |
+
import io
|
4 |
+
import os
|
5 |
+
import json
|
6 |
+
from PIL import Image
|
7 |
+
from service import Service
|
8 |
+
|
9 |
+
app = Flask(__name__)
|
10 |
+
app.secret_key = os.urandom(24) # 用于session加密
|
11 |
+
|
12 |
+
# 初始化服务
|
13 |
+
service = Service()
|
14 |
+
|
15 |
+
# 确保目录存在
|
16 |
+
if not os.path.exists('static'):
|
17 |
+
os.makedirs('static')
|
18 |
+
if not os.path.exists('static/uploads'):
|
19 |
+
os.makedirs('static/uploads')
|
20 |
+
|
21 |
+
@app.route('/')
|
22 |
+
def index():
|
23 |
+
"""渲染主页"""
|
24 |
+
return render_template('index.html')
|
25 |
+
|
26 |
+
@app.route('/api/chat', methods=['POST'])
|
27 |
+
def chat():
|
28 |
+
"""处理聊天请求"""
|
29 |
+
data = request.json
|
30 |
+
prompt = data.get('message', '')
|
31 |
+
image_data = data.get('image', None)
|
32 |
+
history = data.get('history', [])
|
33 |
+
|
34 |
+
# 确保历史记录包含系统提示
|
35 |
+
has_system_prompt = False
|
36 |
+
for msg in history:
|
37 |
+
if msg.get('role') == 'system':
|
38 |
+
has_system_prompt = True
|
39 |
+
break
|
40 |
+
|
41 |
+
if not has_system_prompt:
|
42 |
+
# 添加默认的系统提示
|
43 |
+
history.insert(0, {
|
44 |
+
"role": "system",
|
45 |
+
"content": "你是一个AI度量专家助手。你可以分析文本和图像的内容。"
|
46 |
+
})
|
47 |
+
|
48 |
+
try:
|
49 |
+
if image_data:
|
50 |
+
# 使用图像调用API
|
51 |
+
response = service.chat_with_image(
|
52 |
+
text_prompt=prompt,
|
53 |
+
image_base64=image_data,
|
54 |
+
history=history
|
55 |
+
)
|
56 |
+
else:
|
57 |
+
# 添加当前用户消息
|
58 |
+
current_history = history.copy()
|
59 |
+
current_history.append({"role": "user", "content": prompt})
|
60 |
+
# 纯文本请求
|
61 |
+
response = service.get_response(current_history)
|
62 |
+
|
63 |
+
return jsonify({
|
64 |
+
'status': 'success',
|
65 |
+
'response': response
|
66 |
+
})
|
67 |
+
except Exception as e:
|
68 |
+
return jsonify({
|
69 |
+
'status': 'error',
|
70 |
+
'message': str(e)
|
71 |
+
}), 500
|
72 |
+
|
73 |
+
@app.route('/api/upload_image', methods=['POST'])
|
74 |
+
def upload_image():
|
75 |
+
"""处理图片上传"""
|
76 |
+
if 'file' not in request.files:
|
77 |
+
return jsonify({'status': 'error', 'message': '没有文件'})
|
78 |
+
|
79 |
+
file = request.files['file']
|
80 |
+
if file.filename == '':
|
81 |
+
return jsonify({'status': 'error', 'message': '没有选择文件'})
|
82 |
+
|
83 |
+
if file:
|
84 |
+
filename = f"upload_{os.urandom(8).hex()}.png"
|
85 |
+
filepath = os.path.join('static/uploads', filename)
|
86 |
+
|
87 |
+
# 保存文件
|
88 |
+
file.save(filepath)
|
89 |
+
|
90 |
+
# 读取文件并转为base64
|
91 |
+
with open(filepath, "rb") as img_file:
|
92 |
+
img_str = base64.b64encode(img_file.read()).decode('utf-8')
|
93 |
+
|
94 |
+
image_data = f"data:image/png;base64,{img_str}"
|
95 |
+
|
96 |
+
return jsonify({
|
97 |
+
'status': 'success',
|
98 |
+
'image_data': image_data,
|
99 |
+
'image_url': f"/static/uploads/{filename}"
|
100 |
+
})
|
101 |
+
|
102 |
+
@app.route('/api/paste_image', methods=['POST'])
|
103 |
+
def paste_image():
|
104 |
+
"""处理粘贴的图片"""
|
105 |
+
data = request.json
|
106 |
+
image_data = data.get('image_data')
|
107 |
+
|
108 |
+
if not image_data or not image_data.startswith('data:image'):
|
109 |
+
return jsonify({'status': 'error', 'message': '无效的图片数据'})
|
110 |
+
|
111 |
+
try:
|
112 |
+
# 从base64解码
|
113 |
+
image_data_parts = image_data.split(',')
|
114 |
+
if len(image_data_parts) != 2:
|
115 |
+
return jsonify({'status': 'error', 'message': '图片格式错误'})
|
116 |
+
|
117 |
+
# 保存图片到文件
|
118 |
+
filename = f"paste_{os.urandom(8).hex()}.png"
|
119 |
+
filepath = os.path.join('static/uploads', filename)
|
120 |
+
|
121 |
+
image_bytes = base64.b64decode(image_data_parts[1])
|
122 |
+
with open(filepath, "wb") as f:
|
123 |
+
f.write(image_bytes)
|
124 |
+
|
125 |
+
return jsonify({
|
126 |
+
'status': 'success',
|
127 |
+
'image_data': image_data,
|
128 |
+
'image_url': f"/static/uploads/{filename}"
|
129 |
+
})
|
130 |
+
except Exception as e:
|
131 |
+
return jsonify({'status': 'error', 'message': str(e)})
|
132 |
+
|
133 |
+
if __name__ == '__main__':
|
134 |
+
app.run(debug=True, port=5000)
|
requirements.txt
CHANGED
@@ -1 +1,5 @@
|
|
1 |
-
huggingface_hub==0.
|
|
|
|
|
|
|
|
|
|
1 |
+
huggingface_hub==0.28.0
|
2 |
+
mistralai
|
3 |
+
flask
|
4 |
+
pillow
|
5 |
+
requests
|
service.py
ADDED
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
使用 mistralai 官方 Python 库的服务类
|
3 |
+
"""
|
4 |
+
import os
|
5 |
+
from mistralai import Mistral
|
6 |
+
from mistralai.models import SystemMessage, UserMessage, AssistantMessage
|
7 |
+
import base64
|
8 |
+
from typing import List, Dict, Any, Union, Optional
|
9 |
+
|
10 |
+
class Service:
|
11 |
+
"""
|
12 |
+
使用 mistralai 官方库的服务类,支持多模态内容
|
13 |
+
"""
|
14 |
+
def __init__(self):
|
15 |
+
"""
|
16 |
+
初始化服务类
|
17 |
+
"""
|
18 |
+
self.model = "mistral-small-latest" # 默认模型
|
19 |
+
# 尝试从不同来源获取 API 密钥
|
20 |
+
self.api_key = None
|
21 |
+
|
22 |
+
# 1. 从环境变量获取
|
23 |
+
self.api_key = os.environ.get("MISTRAL_API_KEY")
|
24 |
+
|
25 |
+
# 2. 如果环境变量中没有,尝试从 Hugging Face hub secrets 获取
|
26 |
+
if not self.api_key:
|
27 |
+
try:
|
28 |
+
from huggingface_hub import get_secret
|
29 |
+
self.api_key = get_secret("MISTRAL_API_KEY")
|
30 |
+
except Exception:
|
31 |
+
pass
|
32 |
+
self.headers = {
|
33 |
+
"Authorization": "Bearer YOUR_API_KEY_HERE" # 这将在使用前被替换
|
34 |
+
}
|
35 |
+
api_key = os.environ.get("MISTRAL_API_KEY", "")
|
36 |
+
if not api_key:
|
37 |
+
try:
|
38 |
+
from huggingface_hub import get_secret
|
39 |
+
api_key = get_secret("MISTRAL_API_KEY")
|
40 |
+
except Exception:
|
41 |
+
pass
|
42 |
+
if not api_key:
|
43 |
+
raise ValueError("API 密钥未设置。请设置 service.headers['Authorization'] = 'Bearer YOUR_API_KEY'")
|
44 |
+
|
45 |
+
# 初始化客户端
|
46 |
+
self.client = Mistral(api_key=api_key)
|
47 |
+
|
48 |
+
def load_system_prompt(self, prompt_file: str) -> str:
|
49 |
+
"""
|
50 |
+
加载系统提示文件
|
51 |
+
|
52 |
+
Args:
|
53 |
+
prompt_file: 系统提示文件路径
|
54 |
+
|
55 |
+
Returns:
|
56 |
+
文件内容
|
57 |
+
"""
|
58 |
+
try:
|
59 |
+
with open(prompt_file, 'r', encoding='utf-8') as f:
|
60 |
+
return f.read()
|
61 |
+
except Exception as e:
|
62 |
+
print(f"加载系统提示文件失败: {e}")
|
63 |
+
return ""
|
64 |
+
|
65 |
+
def get_response(self, messages: List[Dict[str, Any]]) -> str:
|
66 |
+
"""
|
67 |
+
从 Mistral API 获取响应
|
68 |
+
|
69 |
+
Args:
|
70 |
+
messages: 消息列表,包含角色和内容
|
71 |
+
|
72 |
+
Returns:
|
73 |
+
API 响应内容
|
74 |
+
"""
|
75 |
+
try:
|
76 |
+
# 发送请求
|
77 |
+
response = self.client.chat.complete(
|
78 |
+
model=self.model,
|
79 |
+
messages=messages, # 直接使用字典形式的消息
|
80 |
+
stream=False # 不使用流式响应
|
81 |
+
)
|
82 |
+
|
83 |
+
# 提取响应内容
|
84 |
+
return response.choices[0].message.content
|
85 |
+
|
86 |
+
except Exception as e:
|
87 |
+
error_msg = f"API 请求错误: {str(e)}"
|
88 |
+
print(error_msg)
|
89 |
+
return error_msg
|
90 |
+
|
91 |
+
def chat_with_image(self,
|
92 |
+
text_prompt: str,
|
93 |
+
image_base64: Optional[str] = None,
|
94 |
+
history: Optional[List[Dict[str, Any]]] = None) -> str:
|
95 |
+
"""
|
96 |
+
结合文本和图像(如果有)进行聊天
|
97 |
+
|
98 |
+
Args:
|
99 |
+
text_prompt: 文本提示
|
100 |
+
image_base64: 图像的base64编码字符串(可选)
|
101 |
+
history: 聊天历史记录(可选)
|
102 |
+
|
103 |
+
Returns:
|
104 |
+
模型响应
|
105 |
+
"""
|
106 |
+
# 初始化消息列表
|
107 |
+
if not history:
|
108 |
+
history = []
|
109 |
+
|
110 |
+
messages = list(history) # 复制历史记录
|
111 |
+
|
112 |
+
# 为当前用户请求创建消息内容
|
113 |
+
user_content: Union[str, List[Dict[str, Any]]]
|
114 |
+
|
115 |
+
if image_base64:
|
116 |
+
# 如果有图像,创建多模态内容
|
117 |
+
user_content = [
|
118 |
+
{"type": "text", "text": text_prompt if text_prompt else "请分析这张图片"},
|
119 |
+
{"type": "image_url", "image_url": {"url": image_base64}}
|
120 |
+
]
|
121 |
+
else:
|
122 |
+
# 纯文本内容
|
123 |
+
user_content = text_prompt
|
124 |
+
|
125 |
+
# 添加用户消息
|
126 |
+
messages.append({"role": "user", "content": user_content})
|
127 |
+
|
128 |
+
# 获取响应
|
129 |
+
return self.get_response(messages)
|
130 |
+
|
131 |
+
|
132 |
+
# 示例用法
|
133 |
+
if __name__ == "__main__":
|
134 |
+
# 创建服务实例
|
135 |
+
service = Service()
|
136 |
+
service.headers["Authorization"] = "Bearer YOUR_API_KEY" # 替换为实际 API 密钥
|
137 |
+
|
138 |
+
# 加载系统提示
|
139 |
+
system_prompt = "你是一个有用的AI助手,可以回答问题和分析图像。"
|
140 |
+
|
141 |
+
# 准备消息
|
142 |
+
messages = [
|
143 |
+
{"role": "system", "content": system_prompt},
|
144 |
+
{"role": "user", "content": "你好,请介绍一下自己"}
|
145 |
+
]
|
146 |
+
|
147 |
+
# 获取响应
|
148 |
+
response = service.get_response(messages)
|
149 |
+
print(response)
|
static/uploads/.gitkeep
ADDED
File without changes
|
templates/index.html
ADDED
@@ -0,0 +1,429 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="zh">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8">
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<title>Mistral AI</title>
|
7 |
+
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
8 |
+
<style>
|
9 |
+
body {
|
10 |
+
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
11 |
+
margin: 0;
|
12 |
+
padding: 0;
|
13 |
+
background-color: #f5f8fa;
|
14 |
+
color: #333;
|
15 |
+
}
|
16 |
+
.container {
|
17 |
+
max-width: 1200px;
|
18 |
+
margin: 0 auto;
|
19 |
+
padding: 20px;
|
20 |
+
}
|
21 |
+
.sidebar {
|
22 |
+
background-color: #fff;
|
23 |
+
border-radius: 8px;
|
24 |
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
25 |
+
padding: 20px;
|
26 |
+
margin-bottom: 20px;
|
27 |
+
}
|
28 |
+
.chat-container {
|
29 |
+
display: flex;
|
30 |
+
height: calc(100vh - 200px);
|
31 |
+
min-height: 500px;
|
32 |
+
gap: 20px;
|
33 |
+
}
|
34 |
+
.chat-box {
|
35 |
+
flex: 1;
|
36 |
+
background-color: #fff;
|
37 |
+
border-radius: 8px;
|
38 |
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
39 |
+
display: flex;
|
40 |
+
flex-direction: column;
|
41 |
+
overflow: hidden;
|
42 |
+
}
|
43 |
+
.chat-messages {
|
44 |
+
flex: 1;
|
45 |
+
overflow-y: auto;
|
46 |
+
padding: 20px;
|
47 |
+
}
|
48 |
+
.message {
|
49 |
+
margin-bottom: 16px;
|
50 |
+
display: flex;
|
51 |
+
align-items: flex-start;
|
52 |
+
}
|
53 |
+
.user-message {
|
54 |
+
justify-content: flex-end;
|
55 |
+
}
|
56 |
+
.assistant-message {
|
57 |
+
justify-content: flex-start;
|
58 |
+
}
|
59 |
+
.message-content {
|
60 |
+
max-width: 80%;
|
61 |
+
padding: 12px 16px;
|
62 |
+
border-radius: 18px;
|
63 |
+
overflow-wrap: break-word;
|
64 |
+
}
|
65 |
+
.user-message .message-content {
|
66 |
+
background-color: #0084ff;
|
67 |
+
color: white;
|
68 |
+
border-bottom-right-radius: 4px;
|
69 |
+
}
|
70 |
+
.assistant-message .message-content {
|
71 |
+
background-color: #f1f0f0;
|
72 |
+
color: #333;
|
73 |
+
border-bottom-left-radius: 4px;
|
74 |
+
}
|
75 |
+
.message-image {
|
76 |
+
max-width: 100%;
|
77 |
+
max-height: 300px;
|
78 |
+
border-radius: 8px;
|
79 |
+
margin-bottom: 8px;
|
80 |
+
}
|
81 |
+
.input-area {
|
82 |
+
background-color: #fff;
|
83 |
+
border-top: 1px solid #e6ecf0;
|
84 |
+
padding: 15px;
|
85 |
+
display: flex;
|
86 |
+
align-items: center;
|
87 |
+
gap: 10px;
|
88 |
+
}
|
89 |
+
.image-preview {
|
90 |
+
display: flex;
|
91 |
+
gap: 10px;
|
92 |
+
margin-bottom: 10px;
|
93 |
+
flex-wrap: wrap;
|
94 |
+
}
|
95 |
+
.image-preview img {
|
96 |
+
max-width: 100px;
|
97 |
+
max-height: 100px;
|
98 |
+
border-radius: 4px;
|
99 |
+
object-fit: cover;
|
100 |
+
}
|
101 |
+
.preview-container {
|
102 |
+
position: relative;
|
103 |
+
display: inline-block;
|
104 |
+
}
|
105 |
+
.remove-image {
|
106 |
+
position: absolute;
|
107 |
+
top: -5px;
|
108 |
+
right: -5px;
|
109 |
+
background-color: rgba(255, 255, 255, 0.8);
|
110 |
+
border-radius: 50%;
|
111 |
+
width: 20px;
|
112 |
+
height: 20px;
|
113 |
+
display: flex;
|
114 |
+
align-items: center;
|
115 |
+
justify-content: center;
|
116 |
+
cursor: pointer;
|
117 |
+
font-size: 12px;
|
118 |
+
border: 1px solid #ddd;
|
119 |
+
}
|
120 |
+
textarea {
|
121 |
+
flex: 1;
|
122 |
+
border: 1px solid #e6ecf0;
|
123 |
+
border-radius: 20px;
|
124 |
+
padding: 10px 15px;
|
125 |
+
resize: none;
|
126 |
+
height: 48px;
|
127 |
+
font-size: 16px;
|
128 |
+
line-height: 1.5;
|
129 |
+
outline: none;
|
130 |
+
}
|
131 |
+
.send-button {
|
132 |
+
border: none;
|
133 |
+
background-color: #0084ff;
|
134 |
+
color: white;
|
135 |
+
border-radius: 50%;
|
136 |
+
width: 40px;
|
137 |
+
height: 40px;
|
138 |
+
display: flex;
|
139 |
+
align-items: center;
|
140 |
+
justify-content: center;
|
141 |
+
cursor: pointer;
|
142 |
+
}
|
143 |
+
.send-button:disabled {
|
144 |
+
background-color: #cccccc;
|
145 |
+
cursor: not-allowed;
|
146 |
+
}
|
147 |
+
.clear-button {
|
148 |
+
background-color: #f7f7f7;
|
149 |
+
border: 1px solid #ddd;
|
150 |
+
color: #666;
|
151 |
+
padding: 8px 16px;
|
152 |
+
border-radius: 4px;
|
153 |
+
cursor: pointer;
|
154 |
+
transition: all 0.2s;
|
155 |
+
margin-bottom: 15px;
|
156 |
+
}
|
157 |
+
.clear-button:hover {
|
158 |
+
background-color: #ebebeb;
|
159 |
+
}
|
160 |
+
.system-prompt {
|
161 |
+
width: 100%;
|
162 |
+
padding: 10px;
|
163 |
+
border: 1px solid #e6ecf0;
|
164 |
+
border-radius: 4px;
|
165 |
+
margin-bottom: 15px;
|
166 |
+
min-height: 100px;
|
167 |
+
resize: vertical;
|
168 |
+
}
|
169 |
+
.thinking {
|
170 |
+
font-style: italic;
|
171 |
+
color: #666;
|
172 |
+
margin-bottom: 15px;
|
173 |
+
padding: 10px;
|
174 |
+
border-radius: 8px;
|
175 |
+
background-color: #f9f9f9;
|
176 |
+
display: inline-block;
|
177 |
+
}
|
178 |
+
</style>
|
179 |
+
</head>
|
180 |
+
<body>
|
181 |
+
<div class="container">
|
182 |
+
<h1 class="mb-4">Mistral AI 助手</h1>
|
183 |
+
|
184 |
+
<div class="chat-container">
|
185 |
+
<div class="chat-box">
|
186 |
+
<div class="chat-messages" id="chat-messages">
|
187 |
+
<!-- 聊天消息将在这里动态添加 -->
|
188 |
+
</div>
|
189 |
+
|
190 |
+
<div class="input-area-container">
|
191 |
+
<div class="image-preview" id="image-preview"></div>
|
192 |
+
<div class="input-area">
|
193 |
+
<textarea id="message-input" placeholder="输入消息或按Ctrl+V粘贴图片..." onkeydown="handleKeyDown(event)"></textarea>
|
194 |
+
<button class="send-button" id="send-button" onclick="sendMessage()" disabled>
|
195 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
196 |
+
<path d="M22 2L11 13" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
197 |
+
<path d="M22 2L15 22L11 13L2 9L22 2Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
198 |
+
</svg>
|
199 |
+
</button>
|
200 |
+
</div>
|
201 |
+
</div>
|
202 |
+
</div>
|
203 |
+
|
204 |
+
<div class="sidebar" style="width: 300px;">
|
205 |
+
<h5>设置</h5>
|
206 |
+
<label for="system-prompt">系统提示</label>
|
207 |
+
<textarea id="system-prompt" class="system-prompt">你是一个AI度量专家助手。你可以分析文本和图像的内容。</textarea>
|
208 |
+
|
209 |
+
<button class="clear-button" onclick="clearChat()">清除聊天记录</button>
|
210 |
+
|
211 |
+
<div class="mt-4">
|
212 |
+
<p><strong>使用说明</strong></p>
|
213 |
+
<ul>
|
214 |
+
<li>输入文字直接提问</li>
|
215 |
+
<li>使用Ctrl+V粘贴图片</li>
|
216 |
+
<li>图片和文字可以一起发送</li>
|
217 |
+
</ul>
|
218 |
+
</div>
|
219 |
+
</div>
|
220 |
+
</div>
|
221 |
+
</div>
|
222 |
+
|
223 |
+
<script>
|
224 |
+
// 全局变量
|
225 |
+
let chatHistory = [];
|
226 |
+
let currentImageData = null;
|
227 |
+
|
228 |
+
// 页面加载时初始化
|
229 |
+
document.addEventListener('DOMContentLoaded', function() {
|
230 |
+
// 监听粘贴事件
|
231 |
+
document.addEventListener('paste', handlePaste);
|
232 |
+
|
233 |
+
// 监听输入框变化
|
234 |
+
const messageInput = document.getElementById('message-input');
|
235 |
+
messageInput.addEventListener('input', function() {
|
236 |
+
document.getElementById('send-button').disabled = !messageInput.value.trim() && !currentImageData;
|
237 |
+
});
|
238 |
+
});
|
239 |
+
|
240 |
+
// 处理粘贴事件
|
241 |
+
function handlePaste(event) {
|
242 |
+
const items = (event.clipboardData || event.originalEvent.clipboardData).items;
|
243 |
+
|
244 |
+
for (let i = 0; i < items.length; i++) {
|
245 |
+
if (items[i].type.indexOf('image') !== -1) {
|
246 |
+
const blob = items[i].getAsFile();
|
247 |
+
const reader = new FileReader();
|
248 |
+
|
249 |
+
reader.onload = function(e) {
|
250 |
+
// 设置当前图片数据
|
251 |
+
currentImageData = e.target.result;
|
252 |
+
|
253 |
+
// 显示图片预览
|
254 |
+
const imagePreview = document.getElementById('image-preview');
|
255 |
+
imagePreview.innerHTML = `
|
256 |
+
<div class="preview-container">
|
257 |
+
<img src="${e.target.result}" alt="粘贴的图片">
|
258 |
+
<div class="remove-image" onclick="removeImage()">×</div>
|
259 |
+
</div>
|
260 |
+
`;
|
261 |
+
|
262 |
+
// 启用发送按钮
|
263 |
+
document.getElementById('send-button').disabled = false;
|
264 |
+
|
265 |
+
// 发送图片到服务器保存
|
266 |
+
saveImageToServer(currentImageData);
|
267 |
+
};
|
268 |
+
|
269 |
+
reader.readAsDataURL(blob);
|
270 |
+
}
|
271 |
+
}
|
272 |
+
}
|
273 |
+
|
274 |
+
// 移除图片
|
275 |
+
function removeImage() {
|
276 |
+
currentImageData = null;
|
277 |
+
document.getElementById('image-preview').innerHTML = '';
|
278 |
+
|
279 |
+
// 如果消息输入框也是空的,禁用发送按钮
|
280 |
+
const messageInput = document.getElementById('message-input');
|
281 |
+
document.getElementById('send-button').disabled = !messageInput.value.trim();
|
282 |
+
}
|
283 |
+
|
284 |
+
// 保存图片到服务器
|
285 |
+
function saveImageToServer(imageData) {
|
286 |
+
fetch('/api/paste_image', {
|
287 |
+
method: 'POST',
|
288 |
+
headers: {
|
289 |
+
'Content-Type': 'application/json',
|
290 |
+
},
|
291 |
+
body: JSON.stringify({ image_data: imageData })
|
292 |
+
})
|
293 |
+
.then(response => response.json())
|
294 |
+
.then(data => {
|
295 |
+
if (data.status === 'success') {
|
296 |
+
// 成功保存,可以在这里做一些处理,比如更新图片预览的src为服务器返回的URL
|
297 |
+
console.log('图片已保存到服务器:', data.image_url);
|
298 |
+
} else {
|
299 |
+
console.error('保存图片错误:', data.message);
|
300 |
+
}
|
301 |
+
})
|
302 |
+
.catch(error => {
|
303 |
+
console.error('保存图片请求错误:', error);
|
304 |
+
});
|
305 |
+
}
|
306 |
+
|
307 |
+
// 发送消息的键盘处理
|
308 |
+
function handleKeyDown(event) {
|
309 |
+
if (event.key === 'Enter' && !event.shiftKey) {
|
310 |
+
event.preventDefault();
|
311 |
+
sendMessage();
|
312 |
+
}
|
313 |
+
}
|
314 |
+
|
315 |
+
// 发送消息
|
316 |
+
function sendMessage() {
|
317 |
+
const messageInput = document.getElementById('message-input');
|
318 |
+
const message = messageInput.value.trim();
|
319 |
+
|
320 |
+
// 如果没有消息文本也没有图片,不发送
|
321 |
+
if (!message && !currentImageData) {
|
322 |
+
return;
|
323 |
+
}
|
324 |
+
|
325 |
+
// 添加用户消息到聊天窗口
|
326 |
+
addMessageToChat('user', message, currentImageData);
|
327 |
+
|
328 |
+
// 准备请求数据
|
329 |
+
const systemPrompt = document.getElementById('system-prompt').value.trim();
|
330 |
+
let history = [...chatHistory];
|
331 |
+
|
332 |
+
// 确保历史记录中有系统提示
|
333 |
+
const hasSystemPrompt = history.some(msg => msg.role === 'system');
|
334 |
+
if (!hasSystemPrompt && systemPrompt) {
|
335 |
+
history.unshift({
|
336 |
+
role: 'system',
|
337 |
+
content: systemPrompt
|
338 |
+
});
|
339 |
+
}
|
340 |
+
|
341 |
+
// 显示思考中状态
|
342 |
+
const thinkingEl = document.createElement('div');
|
343 |
+
thinkingEl.className = 'message assistant-message';
|
344 |
+
thinkingEl.innerHTML = '<div class="thinking">思考中...</div>';
|
345 |
+
document.getElementById('chat-messages').appendChild(thinkingEl);
|
346 |
+
|
347 |
+
// 发送请求到后端
|
348 |
+
fetch('/api/chat', {
|
349 |
+
method: 'POST',
|
350 |
+
headers: {
|
351 |
+
'Content-Type': 'application/json',
|
352 |
+
},
|
353 |
+
body: JSON.stringify({
|
354 |
+
message: message,
|
355 |
+
image: currentImageData,
|
356 |
+
history: history
|
357 |
+
})
|
358 |
+
})
|
359 |
+
.then(response => response.json())
|
360 |
+
.then(data => {
|
361 |
+
// 移除思考中状态
|
362 |
+
document.getElementById('chat-messages').removeChild(thinkingEl);
|
363 |
+
|
364 |
+
if (data.status === 'success') {
|
365 |
+
// 添加助手响应到聊天窗口
|
366 |
+
addMessageToChat('assistant', data.response);
|
367 |
+
} else {
|
368 |
+
// 显示错误消息
|
369 |
+
addMessageToChat('assistant', `错误: ${data.message}`);
|
370 |
+
}
|
371 |
+
})
|
372 |
+
.catch(error => {
|
373 |
+
// 移除思考中状态
|
374 |
+
document.getElementById('chat-messages').removeChild(thinkingEl);
|
375 |
+
|
376 |
+
// 显示错误消息
|
377 |
+
addMessageToChat('assistant', `请求错误: ${error}`);
|
378 |
+
});
|
379 |
+
|
380 |
+
// 清空输入
|
381 |
+
messageInput.value = '';
|
382 |
+
removeImage();
|
383 |
+
|
384 |
+
// 禁用发送按钮
|
385 |
+
document.getElementById('send-button').disabled = true;
|
386 |
+
}
|
387 |
+
|
388 |
+
// 添加消息到聊天窗口和历史记录
|
389 |
+
function addMessageToChat(role, content, image = null) {
|
390 |
+
const chatMessages = document.getElementById('chat-messages');
|
391 |
+
|
392 |
+
// 创建消息元素
|
393 |
+
const messageEl = document.createElement('div');
|
394 |
+
messageEl.className = `message ${role}-message`;
|
395 |
+
|
396 |
+
let messageContent = '';
|
397 |
+
|
398 |
+
// 如果有图片,添加图片
|
399 |
+
if (image) {
|
400 |
+
messageContent += `<img src="${image}" alt="用户上传的图片" class="message-image"><br>`;
|
401 |
+
}
|
402 |
+
|
403 |
+
// 添加文本内容
|
404 |
+
if (content) {
|
405 |
+
messageContent += content.replace(/\n/g, '<br>');
|
406 |
+
}
|
407 |
+
|
408 |
+
messageEl.innerHTML = `<div class="message-content">${messageContent}</div>`;
|
409 |
+
chatMessages.appendChild(messageEl);
|
410 |
+
|
411 |
+
// 滚动到底部
|
412 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
413 |
+
|
414 |
+
// 添加到历史记录
|
415 |
+
chatHistory.push({
|
416 |
+
role: role,
|
417 |
+
content: content
|
418 |
+
});
|
419 |
+
}
|
420 |
+
|
421 |
+
// 清除聊天记录
|
422 |
+
function clearChat() {
|
423 |
+
chatHistory = [];
|
424 |
+
document.getElementById('chat-messages').innerHTML = '';
|
425 |
+
removeImage();
|
426 |
+
}
|
427 |
+
</script>
|
428 |
+
</body>
|
429 |
+
</html>
|