Upload 6 files
Browse files- .env +12 -0
- Dockerfile +16 -0
- docker-compose.yml +41 -0
- main.py +1226 -0
- requirements.txt +7 -0
- templates/index.html +2440 -0
.env
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
SECURE_C_SES=
|
| 2 |
+
HOST_C_OSES=
|
| 3 |
+
CSESIDX=
|
| 4 |
+
CONFIG_ID=
|
| 5 |
+
PROXY=http://127.0.0.1:10808
|
| 6 |
+
MODEL_NAME=gemini-business
|
| 7 |
+
|
| 8 |
+
SECURE_C_SES=CSE.AdwtfTAueKUjNCa1Cxh_iVlpE3Qvg4OwpHdemB_OY_bz04XrFfufjur2GrP3r6d0jppVoDpMssiZhvRFo1CB1e-lQYFMNw2XbTG4Cp_MQZHlTnF-Hhm9zmqmEJ-X31XBsQTEo4ydS3ccnihSzWRlj88DJ8RLzxY2oKGPB7q3ARX2DBVI0aIwGvBT4OW0OKi2SMRvSGDrRECEshSpimar1KSkINVoh5IlewuxSDw6NtU0xbd_wRmznc2IoledpXsADKh-HCD0xiNtAXAm5uCPPxGqB2nOBG2zscDTlI9jXsAQpTsnTnWWXpIFLNqap22FTgK7532iA47N7SBU47c6OQlzIqZ_XWfcAajegOcON40ASkrJMrjZsWKSbyEKDJu8GAIVL2Gl8tsQ1Gg-3tUZ4KteciMKIveR6GeS4fTPI_GZ-6cG95a_ouVkahTeuqYb-5vfm0i7LIjGvaj5
|
| 9 |
+
CSESIDX=542911192
|
| 10 |
+
CONFIG_ID=f92fd445-439e-481c-9b66-1172bb417558
|
| 11 |
+
HOST_C_OSES=COS.AfQtEyAogaRCQUoa48af3YyE7otiOKuzK-73w6hATwbso1VV_ymbTKCD0oN8IzcC-11i8mFjBWQNYXMHs0RjW5VOchG0_SeHT0gQsvaibZ4cvk1hhAPGeO8uUnPFAfGNrOYZWbB6SODvENkm
|
| 12 |
+
PROXY=
|
Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
COPY requirements.txt .
|
| 5 |
+
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
gcc \
|
| 8 |
+
&& pip install -r requirements.txt \
|
| 9 |
+
&& apt-get purge -y gcc \
|
| 10 |
+
&& apt-get autoremove -y \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
COPY main.py .
|
| 14 |
+
COPY templates/ templates/
|
| 15 |
+
RUN mkdir -p generated_images
|
| 16 |
+
CMD ["python", "-u", "main.py"]
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
geminibusiness:
|
| 3 |
+
build: .
|
| 4 |
+
container_name: geminibusiness
|
| 5 |
+
restart: unless-stopped
|
| 6 |
+
ports:
|
| 7 |
+
- "3003:8000"
|
| 8 |
+
env_file: .env
|
| 9 |
+
environment:
|
| 10 |
+
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/geminibusiness
|
| 11 |
+
- PROXY=http://host.docker.internal:10808
|
| 12 |
+
- HTTP_PROXY=http://host.docker.internal:10808
|
| 13 |
+
- HTTPS_PROXY=http://host.docker.internal:10808
|
| 14 |
+
- NO_PROXY=localhost,127.0.0.1,postgres
|
| 15 |
+
volumes:
|
| 16 |
+
- generated_images:/app/generated_images
|
| 17 |
+
depends_on:
|
| 18 |
+
postgres:
|
| 19 |
+
condition: service_healthy
|
| 20 |
+
|
| 21 |
+
postgres:
|
| 22 |
+
image: docker.1ms.run/postgres:16-alpine
|
| 23 |
+
container_name: geminibusiness-db
|
| 24 |
+
restart: unless-stopped
|
| 25 |
+
environment:
|
| 26 |
+
POSTGRES_USER: postgres
|
| 27 |
+
POSTGRES_PASSWORD: postgres
|
| 28 |
+
POSTGRES_DB: geminibusiness
|
| 29 |
+
volumes:
|
| 30 |
+
- postgres_data:/var/lib/postgresql/data
|
| 31 |
+
ports:
|
| 32 |
+
- "5433:5432"
|
| 33 |
+
healthcheck:
|
| 34 |
+
test: ["CMD-SHELL", "pg_isready -U postgres -d geminibusiness"]
|
| 35 |
+
interval: 5s
|
| 36 |
+
timeout: 5s
|
| 37 |
+
retries: 5
|
| 38 |
+
|
| 39 |
+
volumes:
|
| 40 |
+
postgres_data:
|
| 41 |
+
generated_images:
|
main.py
ADDED
|
@@ -0,0 +1,1226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json, time, hmac, hashlib, base64, os, asyncio, uuid, re, threading
|
| 2 |
+
from dataclasses import dataclass, field
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import List, Optional, Union, Dict, Any, AsyncGenerator, Generator
|
| 5 |
+
from contextlib import asynccontextmanager
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
# import psycopg2 # 不再需要直接导入psycopg2,SQLAlchemy会自动处理
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
import httpx
|
| 11 |
+
from fastapi import FastAPI, HTTPException, Request, Depends
|
| 12 |
+
from fastapi.responses import StreamingResponse, HTMLResponse
|
| 13 |
+
from pydantic import BaseModel
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
# ---------- 数据库相关 ----------
|
| 17 |
+
from sqlalchemy import create_engine, String, Text, DateTime, ForeignKey, select, text
|
| 18 |
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker, Session
|
| 19 |
+
|
| 20 |
+
# ---------- 日志配置 ----------
|
| 21 |
+
logging.basicConfig(
|
| 22 |
+
level=logging.INFO,
|
| 23 |
+
format="%(asctime)s | %(levelname)s | %(message)s",
|
| 24 |
+
datefmt="%H:%M:%S",
|
| 25 |
+
)
|
| 26 |
+
logger = logging.getLogger("gemini")
|
| 27 |
+
|
| 28 |
+
# ---------- Logging helpers ----------
|
| 29 |
+
def log_text(label: str, content: Optional[str]):
|
| 30 |
+
"""Log full text content with length for debugging rendering issues."""
|
| 31 |
+
if content is None:
|
| 32 |
+
logger.info(f"{label}: <none>")
|
| 33 |
+
return
|
| 34 |
+
logger.info(f"{label} (len={len(content)}): {content}")
|
| 35 |
+
|
| 36 |
+
# ---------- 配置 ----------
|
| 37 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 38 |
+
ENV_PATH = BASE_DIR / ".env"
|
| 39 |
+
load_dotenv(ENV_PATH, override=True)
|
| 40 |
+
logger.info(f"加载环境变量: {ENV_PATH}")
|
| 41 |
+
|
| 42 |
+
SECURE_C_SES = os.getenv("SECURE_C_SES")
|
| 43 |
+
HOST_C_OSES = os.getenv("HOST_C_OSES")
|
| 44 |
+
CSESIDX = os.getenv("CSESIDX")
|
| 45 |
+
CONFIG_ID = os.getenv("CONFIG_ID")
|
| 46 |
+
PROXY = os.getenv("PROXY") or None
|
| 47 |
+
JWT_TTL = 270
|
| 48 |
+
|
| 49 |
+
# ---------- 图片生成相关常量 ----------
|
| 50 |
+
IMAGE_SAVE_DIR = BASE_DIR / "generated_images"
|
| 51 |
+
LIST_FILE_METADATA_URL = "https://biz-discoveryengine.googleapis.com/v1alpha/locations/global/widgetListSessionFileMetadata"
|
| 52 |
+
|
| 53 |
+
# ---------- 图片数据类 ----------
|
| 54 |
+
@dataclass
|
| 55 |
+
class ChatImage:
|
| 56 |
+
"""表示生成的图片"""
|
| 57 |
+
file_id: Optional[str] = None
|
| 58 |
+
file_name: Optional[str] = None
|
| 59 |
+
base64_data: Optional[str] = None
|
| 60 |
+
url: Optional[str] = None
|
| 61 |
+
local_path: Optional[str] = None
|
| 62 |
+
mime_type: str = "image/png"
|
| 63 |
+
|
| 64 |
+
def save_to_file(self, directory: Optional[Path] = None) -> str:
|
| 65 |
+
"""保存图片到本地文件,返回文件路径"""
|
| 66 |
+
if self.local_path and os.path.exists(self.local_path):
|
| 67 |
+
return self.local_path
|
| 68 |
+
|
| 69 |
+
save_dir = directory or IMAGE_SAVE_DIR
|
| 70 |
+
os.makedirs(save_dir, exist_ok=True)
|
| 71 |
+
|
| 72 |
+
ext = ".png"
|
| 73 |
+
if self.mime_type:
|
| 74 |
+
ext_map = {
|
| 75 |
+
"image/png": ".png",
|
| 76 |
+
"image/jpeg": ".jpg",
|
| 77 |
+
"image/gif": ".gif",
|
| 78 |
+
"image/webp": ".webp",
|
| 79 |
+
}
|
| 80 |
+
ext = ext_map.get(self.mime_type, ".png")
|
| 81 |
+
|
| 82 |
+
if self.file_name:
|
| 83 |
+
filename = self.file_name
|
| 84 |
+
else:
|
| 85 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 86 |
+
filename = f"gemini_{timestamp}_{uuid.uuid4().hex[:8]}{ext}"
|
| 87 |
+
|
| 88 |
+
filepath = os.path.join(save_dir, filename)
|
| 89 |
+
|
| 90 |
+
if self.base64_data:
|
| 91 |
+
image_data = base64.b64decode(self.base64_data)
|
| 92 |
+
with open(filepath, "wb") as f:
|
| 93 |
+
f.write(image_data)
|
| 94 |
+
self.local_path = filepath
|
| 95 |
+
|
| 96 |
+
return filepath
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@dataclass
|
| 100 |
+
class ChatResponseWithImages:
|
| 101 |
+
"""聊天响应,包含文本和可能的图片"""
|
| 102 |
+
text: str = ""
|
| 103 |
+
images: List[ChatImage] = field(default_factory=list)
|
| 104 |
+
thoughts: List[str] = field(default_factory=list)
|
| 105 |
+
|
| 106 |
+
# PostgreSQL 连接
|
| 107 |
+
|
| 108 |
+
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres.cwlqevigvcjnuksbxqnq:zxc2630388@aws-1-eu-west-2.pooler.supabase.com:5432/postgres")
|
| 109 |
+
# 用于创建数据库的连接(连接到默认的 postgres 库)
|
| 110 |
+
DATABASE_URL_BASE = DATABASE_URL.rsplit("/", 1)[0] + "/postgres" if DATABASE_URL else ""
|
| 111 |
+
DATABASE_NAME = DATABASE_URL.rsplit("/", 1)[-1].split("?")[0] if DATABASE_URL else "geminibusiness"
|
| 112 |
+
|
| 113 |
+
# ---------- 硬编码模型列表 ----------
|
| 114 |
+
MODEL_MAPPING = {
|
| 115 |
+
"gemini-auto": None, # None 表示不指定模型,使用默认
|
| 116 |
+
"gemini-2.5-flash": "gemini-2.5-flash",
|
| 117 |
+
"gemini-2.5-pro": "gemini-2.5-pro",
|
| 118 |
+
"gemini-3-pro": "gemini-3-pro",
|
| 119 |
+
"gemini-3-pro-preview": "gemini-3-pro-preview"
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
# ---------- 数据库模型 ----------
|
| 123 |
+
class Base(DeclarativeBase):
|
| 124 |
+
pass
|
| 125 |
+
|
| 126 |
+
class ChatSession(Base):
|
| 127 |
+
"""聊天会话表"""
|
| 128 |
+
__tablename__ = "chat_sessions"
|
| 129 |
+
|
| 130 |
+
id: Mapped[str] = mapped_column(String(64), primary_key=True) # chatId
|
| 131 |
+
title: Mapped[str] = mapped_column(String(255), default="新对话")
|
| 132 |
+
gemini_session: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) # Google session name
|
| 133 |
+
model: Mapped[str] = mapped_column(String(64), default="gemini-2.5-flash")
|
| 134 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 135 |
+
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 136 |
+
|
| 137 |
+
class ChatMessage(Base):
|
| 138 |
+
"""聊天消息表"""
|
| 139 |
+
__tablename__ = "chat_messages"
|
| 140 |
+
|
| 141 |
+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
| 142 |
+
chat_id: Mapped[str] = mapped_column(String(64), ForeignKey("chat_sessions.id", ondelete="CASCADE"))
|
| 143 |
+
role: Mapped[str] = mapped_column(String(16)) # user / assistant
|
| 144 |
+
content: Mapped[str] = mapped_column(Text)
|
| 145 |
+
thinking: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # 思考过程
|
| 146 |
+
images: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # 生成的图片JSON: [{"file_name": str, "local_path": str, "mime_type": str}]
|
| 147 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 148 |
+
|
| 149 |
+
# ---------- 数据库引擎 ----------
|
| 150 |
+
engine = create_engine(DATABASE_URL, echo=False, pool_size=10, max_overflow=20)
|
| 151 |
+
session_maker = sessionmaker(engine, class_=Session, expire_on_commit=False)
|
| 152 |
+
|
| 153 |
+
def get_db() -> Generator[Session, None, None]:
|
| 154 |
+
with session_maker() as session:
|
| 155 |
+
yield session
|
| 156 |
+
|
| 157 |
+
# ---------- Session 缓存 (chatId -> gemini_session) ----------
|
| 158 |
+
session_cache: Dict[str, str] = {}
|
| 159 |
+
|
| 160 |
+
# ---------- 核心:请求头伪装 ----------
|
| 161 |
+
def get_common_headers(jwt: str) -> dict:
|
| 162 |
+
return {
|
| 163 |
+
"accept": "*/*",
|
| 164 |
+
"accept-encoding": "gzip, deflate, br, zstd",
|
| 165 |
+
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
|
| 166 |
+
"authorization": f"Bearer {jwt}",
|
| 167 |
+
"content-type": "application/json",
|
| 168 |
+
"origin": "https://business.gemini.google",
|
| 169 |
+
"referer": "https://business.gemini.google/",
|
| 170 |
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
| 171 |
+
"x-server-timeout": "1800",
|
| 172 |
+
"sec-ch-ua": '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
|
| 173 |
+
"sec-ch-ua-mobile": "?0",
|
| 174 |
+
"sec-ch-ua-platform": '"Windows"',
|
| 175 |
+
"sec-fetch-dest": "empty",
|
| 176 |
+
"sec-fetch-mode": "cors",
|
| 177 |
+
"sec-fetch-site": "cross-site",
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
# ---------- 加密工具 ----------
|
| 181 |
+
def urlsafe_b64encode(data: bytes) -> str:
|
| 182 |
+
return base64.urlsafe_b64encode(data).decode().rstrip("=")
|
| 183 |
+
|
| 184 |
+
def kq_encode(s: str) -> str:
|
| 185 |
+
b = bytearray()
|
| 186 |
+
for ch in s:
|
| 187 |
+
v = ord(ch)
|
| 188 |
+
if v > 255:
|
| 189 |
+
b.append(v & 255)
|
| 190 |
+
b.append(v >> 8)
|
| 191 |
+
else:
|
| 192 |
+
b.append(v)
|
| 193 |
+
return urlsafe_b64encode(bytes(b))
|
| 194 |
+
|
| 195 |
+
def create_jwt(key_bytes: bytes, key_id: str, csesidx: str) -> str:
|
| 196 |
+
now = int(time.time())
|
| 197 |
+
header = {"alg": "HS256", "typ": "JWT", "kid": key_id}
|
| 198 |
+
payload = {
|
| 199 |
+
"iss": "https://business.gemini.google",
|
| 200 |
+
"aud": "https://biz-discoveryengine.googleapis.com",
|
| 201 |
+
"sub": f"csesidx/{csesidx}",
|
| 202 |
+
"iat": now,
|
| 203 |
+
"exp": now + 300,
|
| 204 |
+
"nbf": now,
|
| 205 |
+
}
|
| 206 |
+
header_b64 = kq_encode(json.dumps(header, separators=(",", ":")))
|
| 207 |
+
payload_b64 = kq_encode(json.dumps(payload, separators=(",", ":")))
|
| 208 |
+
message = f"{header_b64}.{payload_b64}"
|
| 209 |
+
sig = hmac.new(key_bytes, message.encode(), hashlib.sha256).digest()
|
| 210 |
+
return f"{message}.{urlsafe_b64encode(sig)}"
|
| 211 |
+
|
| 212 |
+
# ---------- JWT 管理 ----------
|
| 213 |
+
class JWTManager:
|
| 214 |
+
def __init__(self) -> None:
|
| 215 |
+
self.jwt: str = ""
|
| 216 |
+
self.expires: float = 0
|
| 217 |
+
self._lock = threading.Lock()
|
| 218 |
+
|
| 219 |
+
def get(self) -> str:
|
| 220 |
+
with self._lock:
|
| 221 |
+
if time.time() > self.expires:
|
| 222 |
+
self._refresh()
|
| 223 |
+
return self.jwt
|
| 224 |
+
|
| 225 |
+
def _refresh(self) -> None:
|
| 226 |
+
cookie = f"__Secure-C_SES={SECURE_C_SES}"
|
| 227 |
+
if HOST_C_OSES:
|
| 228 |
+
cookie += f"; __Host-C_OSES={HOST_C_OSES}"
|
| 229 |
+
|
| 230 |
+
logger.debug("正在刷新 JWT...")
|
| 231 |
+
with httpx.Client(proxy=PROXY, verify=False, timeout=30) as cli:
|
| 232 |
+
r = cli.get(
|
| 233 |
+
"https://business.gemini.google/auth/getoxsrf",
|
| 234 |
+
params={"csesidx": CSESIDX},
|
| 235 |
+
headers={
|
| 236 |
+
"cookie": cookie,
|
| 237 |
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
| 238 |
+
"referer": "https://business.gemini.google/"
|
| 239 |
+
},
|
| 240 |
+
)
|
| 241 |
+
if r.status_code != 200:
|
| 242 |
+
logger.error(f"getoxsrf 失败: {r.status_code} {r.text}")
|
| 243 |
+
raise HTTPException(r.status_code, "getoxsrf failed")
|
| 244 |
+
|
| 245 |
+
txt = r.text[4:] if r.text.startswith(")]}'") else r.text
|
| 246 |
+
data = json.loads(txt)
|
| 247 |
+
|
| 248 |
+
key_bytes = base64.urlsafe_b64decode(data["xsrfToken"] + "==")
|
| 249 |
+
self.jwt = create_jwt(key_bytes, data["keyId"], CSESIDX)
|
| 250 |
+
self.expires = time.time() + JWT_TTL
|
| 251 |
+
logger.info("JWT 刷新成功")
|
| 252 |
+
|
| 253 |
+
jwt_mgr = JWTManager()
|
| 254 |
+
|
| 255 |
+
# ---------- Session 管理 ----------
|
| 256 |
+
def create_gemini_session() -> str:
|
| 257 |
+
"""创建新的 Google Gemini Session"""
|
| 258 |
+
jwt = jwt_mgr.get()
|
| 259 |
+
headers = get_common_headers(jwt)
|
| 260 |
+
body = {
|
| 261 |
+
"configId": CONFIG_ID,
|
| 262 |
+
"additionalParams": {"token": "-"},
|
| 263 |
+
"createSessionRequest": {
|
| 264 |
+
"session": {"name": "", "displayName": ""}
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
logger.debug("正在创建 Gemini Session...")
|
| 269 |
+
with httpx.Client(proxy=PROXY, verify=False, timeout=30) as cli:
|
| 270 |
+
r = cli.post(
|
| 271 |
+
"https://biz-discoveryengine.googleapis.com/v1alpha/locations/global/widgetCreateSession",
|
| 272 |
+
headers=headers,
|
| 273 |
+
json=body,
|
| 274 |
+
)
|
| 275 |
+
if r.status_code != 200:
|
| 276 |
+
logger.error(f"createSession 失败: {r.status_code} {r.text}")
|
| 277 |
+
raise HTTPException(r.status_code, "createSession failed")
|
| 278 |
+
sess_name = r.json()["session"]["name"]
|
| 279 |
+
logger.info(f"新建 Gemini Session: ...{sess_name[-15:]}")
|
| 280 |
+
return sess_name
|
| 281 |
+
|
| 282 |
+
async def upload_context_file(session_name: str, mime_type: str, base64_content: str) -> str:
|
| 283 |
+
"""上传文件到指定 Session,返回 fileId"""
|
| 284 |
+
jwt = await jwt_mgr.get()
|
| 285 |
+
headers = get_common_headers(jwt)
|
| 286 |
+
|
| 287 |
+
# 生成随机文件名
|
| 288 |
+
ext = mime_type.split('/')[-1] if '/' in mime_type else "bin"
|
| 289 |
+
file_name = f"upload_{int(time.time())}_{uuid.uuid4().hex[:6]}.{ext}"
|
| 290 |
+
|
| 291 |
+
body = {
|
| 292 |
+
"configId": CONFIG_ID,
|
| 293 |
+
"additionalParams": {"token": "-"},
|
| 294 |
+
"addContextFileRequest": {
|
| 295 |
+
"name": session_name,
|
| 296 |
+
"fileName": file_name,
|
| 297 |
+
"mimeType": mime_type,
|
| 298 |
+
"fileContents": base64_content
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
logger.info(f"📤 上传图片 [{mime_type}] 到 Session...")
|
| 303 |
+
async with httpx.AsyncClient(proxy=PROXY, verify=False, timeout=60) as cli:
|
| 304 |
+
r = await cli.post(
|
| 305 |
+
"https://biz-discoveryengine.googleapis.com/v1alpha/locations/global/widgetAddContextFile",
|
| 306 |
+
headers=headers,
|
| 307 |
+
json=body,
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
if r.status_code != 200:
|
| 311 |
+
logger.error(f"上传文件失败: {r.status_code} {r.text}")
|
| 312 |
+
raise HTTPException(r.status_code, f"Upload failed: {r.text}")
|
| 313 |
+
|
| 314 |
+
data = r.json()
|
| 315 |
+
file_id = data.get("addContextFileResponse", {}).get("fileId")
|
| 316 |
+
logger.info(f"✅ 图片上传成功, ID: {file_id}")
|
| 317 |
+
return file_id
|
| 318 |
+
|
| 319 |
+
def parse_last_message(messages: List['Message']):
|
| 320 |
+
"""解析最后一条消息,分离文本和图片"""
|
| 321 |
+
if not messages:
|
| 322 |
+
return "", []
|
| 323 |
+
|
| 324 |
+
last_msg = messages[-1]
|
| 325 |
+
content = last_msg.content
|
| 326 |
+
|
| 327 |
+
text_content = ""
|
| 328 |
+
images = [] # List of {"mime": str, "data": str_base64}
|
| 329 |
+
|
| 330 |
+
if isinstance(content, str):
|
| 331 |
+
text_content = content
|
| 332 |
+
elif isinstance(content, list):
|
| 333 |
+
for part in content:
|
| 334 |
+
if part.get("type") == "text":
|
| 335 |
+
text_content += part.get("text", "")
|
| 336 |
+
elif part.get("type") == "image_url":
|
| 337 |
+
url = part.get("image_url", {}).get("url", "")
|
| 338 |
+
# 解析 Data URI: data:image/png;base64,xxxxxx
|
| 339 |
+
match = re.match(r"data:(image/[^;]+);base64,(.+)", url)
|
| 340 |
+
if match:
|
| 341 |
+
images.append({"mime": match.group(1), "data": match.group(2)})
|
| 342 |
+
else:
|
| 343 |
+
logger.warning(f"⚠️ 暂不支持非 Base64 图片链接: {url[:30]}...")
|
| 344 |
+
|
| 345 |
+
return text_content, images
|
| 346 |
+
|
| 347 |
+
def get_message_text_content(msg: 'Message') -> str:
|
| 348 |
+
"""从消息中提取文本内容"""
|
| 349 |
+
content = msg.content
|
| 350 |
+
if isinstance(content, str):
|
| 351 |
+
return content
|
| 352 |
+
elif isinstance(content, list):
|
| 353 |
+
text_parts = []
|
| 354 |
+
for part in content:
|
| 355 |
+
if part.get("type") == "text":
|
| 356 |
+
text_parts.append(part.get("text", ""))
|
| 357 |
+
elif part.get("type") == "image_url":
|
| 358 |
+
text_parts.append("[图片]")
|
| 359 |
+
return "".join(text_parts)
|
| 360 |
+
return ""
|
| 361 |
+
|
| 362 |
+
def get_or_create_gemini_session(chat_id: str, is_new_chat: bool) -> tuple[str, bool]:
|
| 363 |
+
"""
|
| 364 |
+
获取或创建 Gemini Session
|
| 365 |
+
返回: (gemini_session, is_new_session)
|
| 366 |
+
"""
|
| 367 |
+
# 如果是新对话,强制创建新 Session
|
| 368 |
+
if is_new_chat:
|
| 369 |
+
logger.info("新对话开始,创建新 Session")
|
| 370 |
+
gemini_session = create_gemini_session()
|
| 371 |
+
session_cache[chat_id] = gemini_session
|
| 372 |
+
return gemini_session, True
|
| 373 |
+
|
| 374 |
+
# 尝试从缓存获取
|
| 375 |
+
if chat_id in session_cache:
|
| 376 |
+
logger.info(f"复用会话: ...{session_cache[chat_id][-15:]}")
|
| 377 |
+
return session_cache[chat_id], False
|
| 378 |
+
|
| 379 |
+
# 从数据库获取
|
| 380 |
+
with session_maker() as db:
|
| 381 |
+
result = db.execute(select(ChatSession).where(ChatSession.id == chat_id))
|
| 382 |
+
chat_session = result.scalar_one_or_none()
|
| 383 |
+
|
| 384 |
+
if chat_session and chat_session.gemini_session:
|
| 385 |
+
session_cache[chat_id] = chat_session.gemini_session
|
| 386 |
+
logger.info(f"从数据库恢复会话: ...{chat_session.gemini_session[-15:]}")
|
| 387 |
+
return chat_session.gemini_session, False
|
| 388 |
+
|
| 389 |
+
# 没有找到,创建新的
|
| 390 |
+
logger.info("未找到缓存会话,新建")
|
| 391 |
+
gemini_session = create_gemini_session()
|
| 392 |
+
session_cache[chat_id] = gemini_session
|
| 393 |
+
return gemini_session, True
|
| 394 |
+
|
| 395 |
+
def update_gemini_session_in_db(chat_id: str, gemini_session: str):
|
| 396 |
+
"""更新数据库中的 Gemini Session"""
|
| 397 |
+
with session_maker() as db:
|
| 398 |
+
result = db.execute(select(ChatSession).where(ChatSession.id == chat_id))
|
| 399 |
+
chat_session = result.scalar_one_or_none()
|
| 400 |
+
if chat_session:
|
| 401 |
+
chat_session.gemini_session = gemini_session
|
| 402 |
+
db.commit()
|
| 403 |
+
|
| 404 |
+
# ---------- 图片生成处理方法 ----------
|
| 405 |
+
async def get_session_file_metadata(session_name: str) -> dict:
|
| 406 |
+
"""获取 session 中的文件元数据,包括下载链接"""
|
| 407 |
+
jwt = await jwt_mgr.get()
|
| 408 |
+
headers = get_common_headers(jwt)
|
| 409 |
+
body = {
|
| 410 |
+
"configId": CONFIG_ID,
|
| 411 |
+
"additionalParams": {"token": "-"},
|
| 412 |
+
"listSessionFileMetadataRequest": {
|
| 413 |
+
"name": session_name,
|
| 414 |
+
"filter": "file_origin_type = AI_GENERATED"
|
| 415 |
+
}
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
async with httpx.AsyncClient(proxy=PROXY, verify=False, timeout=30) as cli:
|
| 419 |
+
resp = await cli.post(LIST_FILE_METADATA_URL, headers=headers, json=body)
|
| 420 |
+
|
| 421 |
+
if resp.status_code == 401:
|
| 422 |
+
# JWT 过期,刷新后重试
|
| 423 |
+
jwt = await jwt_mgr.get()
|
| 424 |
+
headers = get_common_headers(jwt)
|
| 425 |
+
resp = await cli.post(LIST_FILE_METADATA_URL, headers=headers, json=body)
|
| 426 |
+
|
| 427 |
+
if resp.status_code != 200:
|
| 428 |
+
logger.warning(f"获取文件元数据失败: {resp.status_code}")
|
| 429 |
+
return {}
|
| 430 |
+
|
| 431 |
+
data = resp.json()
|
| 432 |
+
result = {}
|
| 433 |
+
file_metadata_list = data.get("listSessionFileMetadataResponse", {}).get("fileMetadata", [])
|
| 434 |
+
for fm in file_metadata_list:
|
| 435 |
+
fid = fm.get("fileId")
|
| 436 |
+
if fid:
|
| 437 |
+
result[fid] = fm
|
| 438 |
+
|
| 439 |
+
return result
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
def build_image_download_url(session_name: str, file_id: str) -> str:
|
| 443 |
+
"""构造正确的图片下载 URL"""
|
| 444 |
+
return f"https://biz-discoveryengine.googleapis.com/v1alpha/{session_name}:downloadFile?fileId={file_id}&alt=media"
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
async def download_image_with_jwt(session_name: str, file_id: str) -> bytes:
|
| 448 |
+
"""使用 JWT 认证下载图片"""
|
| 449 |
+
url = build_image_download_url(session_name, file_id)
|
| 450 |
+
jwt = await jwt_mgr.get()
|
| 451 |
+
headers = get_common_headers(jwt)
|
| 452 |
+
|
| 453 |
+
async with httpx.AsyncClient(proxy=PROXY, verify=False, timeout=120) as cli:
|
| 454 |
+
resp = await cli.get(url, headers=headers, follow_redirects=True)
|
| 455 |
+
|
| 456 |
+
if resp.status_code == 401:
|
| 457 |
+
# JWT 过期,刷新后重试
|
| 458 |
+
jwt = await jwt_mgr.get()
|
| 459 |
+
headers = get_common_headers(jwt)
|
| 460 |
+
resp = await cli.get(url, headers=headers, follow_redirects=True)
|
| 461 |
+
|
| 462 |
+
resp.raise_for_status()
|
| 463 |
+
content = resp.content
|
| 464 |
+
|
| 465 |
+
# 检测是否为 base64 编码的内容
|
| 466 |
+
try:
|
| 467 |
+
text_content = content.decode("utf-8", errors="ignore").strip()
|
| 468 |
+
if text_content.startswith("iVBORw0KGgo") or text_content.startswith("/9j/"):
|
| 469 |
+
# 是 base64 编码,需要解码
|
| 470 |
+
return base64.b64decode(text_content)
|
| 471 |
+
except Exception:
|
| 472 |
+
pass
|
| 473 |
+
|
| 474 |
+
return content
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
async def save_generated_image(session_name: str, file_id: str, file_name: Optional[str], mime_type: str, chat_id: str, image_index: int = 1) -> ChatImage:
|
| 478 |
+
"""下载并保存生成的图片,按 chat_id 命名"""
|
| 479 |
+
img = ChatImage(
|
| 480 |
+
file_id=file_id,
|
| 481 |
+
file_name=file_name,
|
| 482 |
+
mime_type=mime_type,
|
| 483 |
+
)
|
| 484 |
+
|
| 485 |
+
try:
|
| 486 |
+
image_data = await download_image_with_jwt(session_name, file_id)
|
| 487 |
+
os.makedirs(IMAGE_SAVE_DIR, exist_ok=True)
|
| 488 |
+
|
| 489 |
+
ext = ".png"
|
| 490 |
+
ext_map = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp"}
|
| 491 |
+
ext = ext_map.get(mime_type, ".png")
|
| 492 |
+
|
| 493 |
+
# 按 {chat_id}_{序号}.png 命名
|
| 494 |
+
filename = f"{chat_id}_{image_index}{ext}"
|
| 495 |
+
filepath = IMAGE_SAVE_DIR / filename
|
| 496 |
+
|
| 497 |
+
# 如果文件已存在,添加时间戳避免覆盖
|
| 498 |
+
if filepath.exists():
|
| 499 |
+
timestamp = datetime.now().strftime("%H%M%S")
|
| 500 |
+
filename = f"{chat_id}_{image_index}_{timestamp}{ext}"
|
| 501 |
+
filepath = IMAGE_SAVE_DIR / filename
|
| 502 |
+
|
| 503 |
+
with open(filepath, "wb") as f:
|
| 504 |
+
f.write(image_data)
|
| 505 |
+
|
| 506 |
+
img.local_path = str(filepath)
|
| 507 |
+
img.file_name = filename
|
| 508 |
+
img.base64_data = base64.b64encode(image_data).decode("utf-8")
|
| 509 |
+
logger.info(f"图片已保存: {filepath}")
|
| 510 |
+
except Exception as e:
|
| 511 |
+
logger.error(f"下载图片失败: {e}")
|
| 512 |
+
|
| 513 |
+
return img
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
def parse_images_from_response(data_list: list) -> tuple[list, str]:
|
| 517 |
+
"""
|
| 518 |
+
从 API 响应中解析图片文件引用
|
| 519 |
+
返回: (file_ids_list, current_session)
|
| 520 |
+
file_ids_list: [{"fileId": str, "mimeType": str}, ...]
|
| 521 |
+
"""
|
| 522 |
+
file_ids = []
|
| 523 |
+
current_session = None
|
| 524 |
+
|
| 525 |
+
for data in data_list:
|
| 526 |
+
sar = data.get("streamAssistResponse")
|
| 527 |
+
if not sar:
|
| 528 |
+
continue
|
| 529 |
+
|
| 530 |
+
# 获取 session 信息
|
| 531 |
+
session_info = sar.get("sessionInfo", {})
|
| 532 |
+
if session_info.get("session"):
|
| 533 |
+
current_session = session_info["session"]
|
| 534 |
+
|
| 535 |
+
answer = sar.get("answer") or {}
|
| 536 |
+
replies = answer.get("replies") or []
|
| 537 |
+
|
| 538 |
+
for reply in replies:
|
| 539 |
+
gc = reply.get("groundedContent", {})
|
| 540 |
+
content = gc.get("content", {})
|
| 541 |
+
|
| 542 |
+
# 检查 file 字段(图片生成的关键)
|
| 543 |
+
file_info = content.get("file")
|
| 544 |
+
if file_info and file_info.get("fileId"):
|
| 545 |
+
file_ids.append({
|
| 546 |
+
"fileId": file_info["fileId"],
|
| 547 |
+
"mimeType": file_info.get("mimeType", "image/png")
|
| 548 |
+
})
|
| 549 |
+
|
| 550 |
+
return file_ids, current_session
|
| 551 |
+
|
| 552 |
+
|
| 553 |
+
async def get_chat_image_count(chat_id: str) -> int:
|
| 554 |
+
"""获取指定会话中已保存的图片数量"""
|
| 555 |
+
async with async_session_maker() as db:
|
| 556 |
+
result = await db.execute(
|
| 557 |
+
select(ChatMessage).where(
|
| 558 |
+
ChatMessage.chat_id == chat_id,
|
| 559 |
+
ChatMessage.images.isnot(None)
|
| 560 |
+
)
|
| 561 |
+
)
|
| 562 |
+
messages = result.scalars().all()
|
| 563 |
+
count = 0
|
| 564 |
+
for msg in messages:
|
| 565 |
+
if msg.images:
|
| 566 |
+
try:
|
| 567 |
+
images = json.loads(msg.images)
|
| 568 |
+
count += len(images)
|
| 569 |
+
except:
|
| 570 |
+
pass
|
| 571 |
+
return count
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
def delete_chat_images(chat_id: str):
|
| 575 |
+
"""删除与指定 chat_id 相关的所有图片文件"""
|
| 576 |
+
if not IMAGE_SAVE_DIR.exists():
|
| 577 |
+
return
|
| 578 |
+
|
| 579 |
+
deleted_count = 0
|
| 580 |
+
for filepath in IMAGE_SAVE_DIR.glob(f"{chat_id}_*"):
|
| 581 |
+
try:
|
| 582 |
+
filepath.unlink()
|
| 583 |
+
deleted_count += 1
|
| 584 |
+
logger.info(f"已删除图片: {filepath}")
|
| 585 |
+
except Exception as e:
|
| 586 |
+
logger.error(f"删除图片失败 {filepath}: {e}")
|
| 587 |
+
|
| 588 |
+
if deleted_count > 0:
|
| 589 |
+
logger.info(f"共删除 {deleted_count} 张图片 (chat_id: {chat_id})")
|
| 590 |
+
|
| 591 |
+
# ---------- 应用生命周期 ----------
|
| 592 |
+
def ensure_database_exists():
|
| 593 |
+
"""确保数据库存在,不存在则创建"""
|
| 594 |
+
temp_engine = create_engine(DATABASE_URL_BASE, isolation_level="AUTOCOMMIT")
|
| 595 |
+
try:
|
| 596 |
+
with temp_engine.connect() as conn:
|
| 597 |
+
# 检查数据库是否存在
|
| 598 |
+
result = conn.execute(
|
| 599 |
+
text(f"SELECT 1 FROM pg_database WHERE datname = '{DATABASE_NAME}'")
|
| 600 |
+
)
|
| 601 |
+
exists = result.scalar() is not None
|
| 602 |
+
|
| 603 |
+
if not exists:
|
| 604 |
+
logger.info(f"数据库 {DATABASE_NAME} 不存在,正在创建...")
|
| 605 |
+
conn.execute(text(f'CREATE DATABASE "{DATABASE_NAME}"'))
|
| 606 |
+
logger.info(f"数据库 {DATABASE_NAME} 创建成功")
|
| 607 |
+
else:
|
| 608 |
+
logger.info(f"数据库 {DATABASE_NAME} 已存在")
|
| 609 |
+
except Exception as e:
|
| 610 |
+
logger.warning(f"检查/创建数据库时出错: {e}")
|
| 611 |
+
finally:
|
| 612 |
+
temp_engine.dispose()
|
| 613 |
+
|
| 614 |
+
@asynccontextmanager
|
| 615 |
+
async def lifespan(app: FastAPI):
|
| 616 |
+
# 启动时确保数据库存在
|
| 617 |
+
ensure_database_exists()
|
| 618 |
+
# 创建表
|
| 619 |
+
with engine.begin() as conn:
|
| 620 |
+
Base.metadata.create_all(bind=conn)
|
| 621 |
+
logger.info("数据库表已创建")
|
| 622 |
+
yield
|
| 623 |
+
# 关闭时清理
|
| 624 |
+
engine.dispose()
|
| 625 |
+
|
| 626 |
+
# ---------- OpenAI 兼容接口 ----------
|
| 627 |
+
app = FastAPI(title="Gemini-Business OpenAI Gateway", lifespan=lifespan)
|
| 628 |
+
|
| 629 |
+
class Message(BaseModel):
|
| 630 |
+
role: str
|
| 631 |
+
content: Union[str, List[Dict[str, Any]]]
|
| 632 |
+
|
| 633 |
+
class ChatRequest(BaseModel):
|
| 634 |
+
model: str = "gemini-auto"
|
| 635 |
+
messages: List[Message]
|
| 636 |
+
stream: bool = False
|
| 637 |
+
temperature: Optional[float] = 0.7
|
| 638 |
+
top_p: Optional[float] = 1.0
|
| 639 |
+
chat_id: Optional[str] = None # 聊天会话ID,为空则创建新会话
|
| 640 |
+
|
| 641 |
+
def create_chunk(id: str, created: int, model: str, delta: dict, finish_reason: Union[str, None], chat_id: str = None, title: str = None, is_new: bool = False) -> str:
|
| 642 |
+
chunk = {
|
| 643 |
+
"id": id,
|
| 644 |
+
"object": "chat.completion.chunk",
|
| 645 |
+
"created": created,
|
| 646 |
+
"model": model,
|
| 647 |
+
"choices": [{
|
| 648 |
+
"index": 0,
|
| 649 |
+
"delta": delta,
|
| 650 |
+
"finish_reason": finish_reason
|
| 651 |
+
}]
|
| 652 |
+
}
|
| 653 |
+
# 首个chunk附带聊天信息
|
| 654 |
+
if chat_id:
|
| 655 |
+
chunk["chat_id"] = chat_id
|
| 656 |
+
chunk["title"] = title
|
| 657 |
+
chunk["is_new"] = is_new
|
| 658 |
+
return json.dumps(chunk, ensure_ascii=False)
|
| 659 |
+
@app.get("/")
|
| 660 |
+
async def root():
|
| 661 |
+
"""根路径返回详细的 API 信息"""
|
| 662 |
+
html_content = """
|
| 663 |
+
<html>
|
| 664 |
+
<head>
|
| 665 |
+
<title>Gemini Business API</title>
|
| 666 |
+
</head>
|
| 667 |
+
<body>
|
| 668 |
+
<h1>Gemini Business API 运行中</h1>
|
| 669 |
+
<p>可用的 API 端点:</p>
|
| 670 |
+
<ul>
|
| 671 |
+
<li><a href="/health">/health</a> - 健康检查</li>
|
| 672 |
+
<li><a href="/v1/models">/v1/models</a> - 模型列表</li>
|
| 673 |
+
<li><a href="/docs">/docs</a> - API 文档</li>
|
| 674 |
+
</ul>
|
| 675 |
+
<p>聊天接口: POST /v1/chat/completions</p>
|
| 676 |
+
</body>
|
| 677 |
+
</html>
|
| 678 |
+
"""
|
| 679 |
+
return HTMLResponse(content=html_content)
|
| 680 |
+
|
| 681 |
+
@app.get("/v1/models")
|
| 682 |
+
async def list_models():
|
| 683 |
+
data = []
|
| 684 |
+
now = int(time.time())
|
| 685 |
+
for m in MODEL_MAPPING.keys():
|
| 686 |
+
data.append({
|
| 687 |
+
"id": m,
|
| 688 |
+
"object": "model",
|
| 689 |
+
"created": now,
|
| 690 |
+
"owned_by": "google",
|
| 691 |
+
"permission": []
|
| 692 |
+
})
|
| 693 |
+
return {"object": "list", "data": data}
|
| 694 |
+
|
| 695 |
+
@app.get("/health")
|
| 696 |
+
async def health():
|
| 697 |
+
return {"status": "ok", "time": datetime.utcnow().isoformat()}
|
| 698 |
+
|
| 699 |
+
@app.get("/home", response_class=HTMLResponse)
|
| 700 |
+
async def home():
|
| 701 |
+
html_path = BASE_DIR / "templates" / "index.html"
|
| 702 |
+
return HTMLResponse(content=html_path.read_text(encoding="utf-8"))
|
| 703 |
+
|
| 704 |
+
# ---------- 聊天记录 API ----------
|
| 705 |
+
@app.get("/v1/chats")
|
| 706 |
+
async def list_chats(db: Session = Depends(get_db)):
|
| 707 |
+
"""获取所有聊天记录列表"""
|
| 708 |
+
result = db.execute(
|
| 709 |
+
select(ChatSession)
|
| 710 |
+
.order_by(ChatSession.updated_at.desc())
|
| 711 |
+
)
|
| 712 |
+
sessions = result.scalars().all()
|
| 713 |
+
return {
|
| 714 |
+
"chats": [
|
| 715 |
+
{
|
| 716 |
+
"id": s.id,
|
| 717 |
+
"title": s.title,
|
| 718 |
+
"model": s.model,
|
| 719 |
+
"created_at": s.created_at.isoformat(),
|
| 720 |
+
"updated_at": s.updated_at.isoformat()
|
| 721 |
+
}
|
| 722 |
+
for s in sessions
|
| 723 |
+
]
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
@app.get("/v1/chats/{chat_id}")
|
| 727 |
+
async def get_chat(chat_id: str, db: Session = Depends(get_db)):
|
| 728 |
+
"""获取指定聊天的详情和消息历史"""
|
| 729 |
+
result = db.execute(select(ChatSession).where(ChatSession.id == chat_id))
|
| 730 |
+
session = result.scalar_one_or_none()
|
| 731 |
+
if not session:
|
| 732 |
+
raise HTTPException(404, "Chat not found")
|
| 733 |
+
|
| 734 |
+
# 获取消息历史
|
| 735 |
+
msg_result = db.execute(
|
| 736 |
+
select(ChatMessage)
|
| 737 |
+
.where(ChatMessage.chat_id == chat_id)
|
| 738 |
+
.order_by(ChatMessage.created_at)
|
| 739 |
+
)
|
| 740 |
+
messages = msg_result.scalars().all()
|
| 741 |
+
|
| 742 |
+
# 构建消息列表,包含图片信息
|
| 743 |
+
messages_data = []
|
| 744 |
+
for m in messages:
|
| 745 |
+
msg_data = {
|
| 746 |
+
"role": m.role,
|
| 747 |
+
"content": m.content,
|
| 748 |
+
"thinking": m.thinking,
|
| 749 |
+
"created_at": m.created_at.isoformat()
|
| 750 |
+
}
|
| 751 |
+
# 如果有保存的图片,读取文件并转为 base64
|
| 752 |
+
if m.images:
|
| 753 |
+
try:
|
| 754 |
+
images_info = json.loads(m.images)
|
| 755 |
+
images_with_base64 = []
|
| 756 |
+
for img_info in images_info:
|
| 757 |
+
local_path = img_info.get("local_path")
|
| 758 |
+
if local_path and os.path.exists(local_path):
|
| 759 |
+
with open(local_path, "rb") as f:
|
| 760 |
+
img_data = f.read()
|
| 761 |
+
images_with_base64.append({
|
| 762 |
+
"file_name": img_info.get("file_name"),
|
| 763 |
+
"mime_type": img_info.get("mime_type", "image/png"),
|
| 764 |
+
"base64_data": base64.b64encode(img_data).decode("utf-8")
|
| 765 |
+
})
|
| 766 |
+
if images_with_base64:
|
| 767 |
+
msg_data["images"] = images_with_base64
|
| 768 |
+
except Exception as e:
|
| 769 |
+
logger.error(f"加载图片失败: {e}")
|
| 770 |
+
messages_data.append(msg_data)
|
| 771 |
+
|
| 772 |
+
return {
|
| 773 |
+
"id": session.id,
|
| 774 |
+
"title": session.title,
|
| 775 |
+
"model": session.model,
|
| 776 |
+
"created_at": session.created_at.isoformat(),
|
| 777 |
+
"updated_at": session.updated_at.isoformat(),
|
| 778 |
+
"messages": messages_data
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
@app.delete("/v1/chats/{chat_id}")
|
| 782 |
+
async def delete_chat(chat_id: str, db: Session = Depends(get_db)):
|
| 783 |
+
"""删除聊天记录"""
|
| 784 |
+
result = db.execute(select(ChatSession).where(ChatSession.id == chat_id))
|
| 785 |
+
session = result.scalar_one_or_none()
|
| 786 |
+
if not session:
|
| 787 |
+
raise HTTPException(404, "Chat not found")
|
| 788 |
+
|
| 789 |
+
db.delete(session)
|
| 790 |
+
db.commit()
|
| 791 |
+
|
| 792 |
+
# 删除关联的图片文件(按 chat_id 匹配)
|
| 793 |
+
delete_chat_images(chat_id)
|
| 794 |
+
|
| 795 |
+
# 清理缓存
|
| 796 |
+
if chat_id in session_cache:
|
| 797 |
+
del session_cache[chat_id]
|
| 798 |
+
|
| 799 |
+
return {"status": "ok"}
|
| 800 |
+
|
| 801 |
+
@app.patch("/v1/chats/{chat_id}")
|
| 802 |
+
async def update_chat(chat_id: str, title: str, db: Session = Depends(get_db)):
|
| 803 |
+
"""更新聊天标题"""
|
| 804 |
+
result = db.execute(select(ChatSession).where(ChatSession.id == chat_id))
|
| 805 |
+
session = result.scalar_one_or_none()
|
| 806 |
+
if not session:
|
| 807 |
+
raise HTTPException(404, "Chat not found")
|
| 808 |
+
|
| 809 |
+
session.title = title
|
| 810 |
+
db.commit()
|
| 811 |
+
return {"status": "ok", "title": title}
|
| 812 |
+
|
| 813 |
+
# ---------- 聊天完成接口 ----------
|
| 814 |
+
@app.post("/v1/chat/completions")
|
| 815 |
+
async def chat(req: ChatRequest, db: Session = Depends(get_db)):
|
| 816 |
+
# 模型校验
|
| 817 |
+
if req.model not in MODEL_MAPPING:
|
| 818 |
+
raise HTTPException(status_code=404, detail=f"Model '{req.model}' not found.")
|
| 819 |
+
|
| 820 |
+
chat_id = req.chat_id
|
| 821 |
+
is_new = False
|
| 822 |
+
title = "新对话"
|
| 823 |
+
|
| 824 |
+
# 如果没有 chat_id,创建新会话
|
| 825 |
+
if not chat_id:
|
| 826 |
+
chat_id = f"chat-{uuid.uuid4()}"
|
| 827 |
+
is_new = True
|
| 828 |
+
# 使用第一条用户消息作为初始标题(截取前30字符,使用文本内容)
|
| 829 |
+
first_user_msg_content = next(
|
| 830 |
+
(get_message_text_content(m) for m in req.messages if m.role == "user"),
|
| 831 |
+
"新对话"
|
| 832 |
+
)
|
| 833 |
+
title = first_user_msg_content[:30] + ("..." if len(first_user_msg_content) > 30 else "")
|
| 834 |
+
|
| 835 |
+
# 创建数据库记录
|
| 836 |
+
new_session = ChatSession(id=chat_id, title=title, model=req.model)
|
| 837 |
+
db.add(new_session)
|
| 838 |
+
db.commit()
|
| 839 |
+
else:
|
| 840 |
+
# 获取现有会话
|
| 841 |
+
result = db.execute(select(ChatSession).where(ChatSession.id == chat_id))
|
| 842 |
+
session = result.scalar_one_or_none()
|
| 843 |
+
if not session:
|
| 844 |
+
raise HTTPException(404, "Chat not found")
|
| 845 |
+
title = session.title
|
| 846 |
+
|
| 847 |
+
# 获取或创建 Gemini Session
|
| 848 |
+
gemini_session, is_new_session = await get_or_create_gemini_session(chat_id, is_new)
|
| 849 |
+
|
| 850 |
+
# 更新数据库中的 gemini_session
|
| 851 |
+
if is_new_session:
|
| 852 |
+
await update_gemini_session_in_db(chat_id, gemini_session)
|
| 853 |
+
|
| 854 |
+
created_time = int(time.time())
|
| 855 |
+
completion_id = f"chatcmpl-{uuid.uuid4()}"
|
| 856 |
+
|
| 857 |
+
# 解析最后一条消息,分离文本和图片
|
| 858 |
+
last_text_content, current_images = parse_last_message(req.messages)
|
| 859 |
+
|
| 860 |
+
# 保存用户消息到数据库(存储文本内容)
|
| 861 |
+
user_msg = req.messages[-1]
|
| 862 |
+
if user_msg.role == "user":
|
| 863 |
+
db.add(ChatMessage(chat_id=chat_id, role="user", content=get_message_text_content(user_msg)))
|
| 864 |
+
db.commit()
|
| 865 |
+
|
| 866 |
+
# 封装生成器,处理图片上传和重试逻辑
|
| 867 |
+
async def response_wrapper():
|
| 868 |
+
nonlocal gemini_session, is_new_session
|
| 869 |
+
current_file_ids = []
|
| 870 |
+
|
| 871 |
+
try:
|
| 872 |
+
# 如果有图片,先上传到当前 Session
|
| 873 |
+
if current_images:
|
| 874 |
+
for img in current_images:
|
| 875 |
+
fid = await upload_context_file(gemini_session, img["mime"], img["data"])
|
| 876 |
+
current_file_ids.append(fid)
|
| 877 |
+
|
| 878 |
+
async for chunk in stream_chat_generator(
|
| 879 |
+
gemini_session, req, completion_id, created_time,
|
| 880 |
+
chat_id, title, is_new, req.stream,
|
| 881 |
+
text_content=last_text_content,
|
| 882 |
+
file_ids=current_file_ids
|
| 883 |
+
):
|
| 884 |
+
yield chunk
|
| 885 |
+
except HTTPException as e:
|
| 886 |
+
# 如果不是新会话且报错了(可能是 Session 过期),尝试新建会话重试一次
|
| 887 |
+
if not is_new_session and e.status_code in [404, 400, 500]:
|
| 888 |
+
logger.warning(f"会话可能过期 ({e.status_code}),正在重建并重试...")
|
| 889 |
+
new_gemini_session = await create_gemini_session()
|
| 890 |
+
session_cache[chat_id] = new_gemini_session
|
| 891 |
+
await update_gemini_session_in_db(chat_id, new_gemini_session)
|
| 892 |
+
|
| 893 |
+
# 重新上传图片到新 Session
|
| 894 |
+
new_file_ids = []
|
| 895 |
+
if current_images:
|
| 896 |
+
for img in current_images:
|
| 897 |
+
fid = await upload_context_file(new_gemini_session, img["mime"], img["data"])
|
| 898 |
+
new_file_ids.append(fid)
|
| 899 |
+
|
| 900 |
+
# 重试
|
| 901 |
+
async for chunk in stream_chat_generator(
|
| 902 |
+
new_gemini_session, req, completion_id, created_time,
|
| 903 |
+
chat_id, title, is_new, req.stream,
|
| 904 |
+
text_content=last_text_content,
|
| 905 |
+
file_ids=new_file_ids
|
| 906 |
+
):
|
| 907 |
+
yield chunk
|
| 908 |
+
else:
|
| 909 |
+
raise e
|
| 910 |
+
|
| 911 |
+
# 流式处理
|
| 912 |
+
if req.stream:
|
| 913 |
+
return StreamingResponse(response_wrapper(), media_type="text/event-stream")
|
| 914 |
+
|
| 915 |
+
# 非流式处理
|
| 916 |
+
full_content = ""
|
| 917 |
+
extracted_title = None
|
| 918 |
+
collected_images = [] # 收集图片数据
|
| 919 |
+
|
| 920 |
+
async for chunk_str in response_wrapper():
|
| 921 |
+
if chunk_str.startswith("data: [DONE]"):
|
| 922 |
+
break
|
| 923 |
+
if chunk_str.startswith("data: "):
|
| 924 |
+
try:
|
| 925 |
+
data = json.loads(chunk_str[6:])
|
| 926 |
+
delta = data["choices"][0]["delta"]
|
| 927 |
+
if "content" in delta:
|
| 928 |
+
full_content += delta["content"]
|
| 929 |
+
if "images" in delta:
|
| 930 |
+
collected_images.extend(delta["images"])
|
| 931 |
+
if "extracted_title" in data:
|
| 932 |
+
extracted_title = data["extracted_title"]
|
| 933 |
+
except:
|
| 934 |
+
pass
|
| 935 |
+
|
| 936 |
+
log_text("chat.nonstream.content", full_content)
|
| 937 |
+
log_text("chat.nonstream.extracted_title", extracted_title)
|
| 938 |
+
|
| 939 |
+
response_data = {
|
| 940 |
+
"id": completion_id,
|
| 941 |
+
"object": "chat.completion",
|
| 942 |
+
"created": created_time,
|
| 943 |
+
"model": req.model,
|
| 944 |
+
"chat_id": chat_id,
|
| 945 |
+
"title": extracted_title or title,
|
| 946 |
+
"is_new": is_new,
|
| 947 |
+
"choices": [{
|
| 948 |
+
"index": 0,
|
| 949 |
+
"message": {
|
| 950 |
+
"role": "assistant",
|
| 951 |
+
"content": full_content
|
| 952 |
+
},
|
| 953 |
+
"finish_reason": "stop"
|
| 954 |
+
}],
|
| 955 |
+
"usage": {
|
| 956 |
+
"prompt_tokens": 0,
|
| 957 |
+
"completion_tokens": 0,
|
| 958 |
+
"total_tokens": 0
|
| 959 |
+
}
|
| 960 |
+
}
|
| 961 |
+
|
| 962 |
+
# 如果有生成的图片,添加到响应中
|
| 963 |
+
if collected_images:
|
| 964 |
+
response_data["images"] = collected_images
|
| 965 |
+
logger.info(f"非流式响应包含 {len(collected_images)} 张图片")
|
| 966 |
+
|
| 967 |
+
return response_data
|
| 968 |
+
|
| 969 |
+
async def stream_chat_generator(
|
| 970 |
+
gemini_session: str,
|
| 971 |
+
req: ChatRequest,
|
| 972 |
+
completion_id: str,
|
| 973 |
+
created_time: int,
|
| 974 |
+
chat_id: str,
|
| 975 |
+
title: str,
|
| 976 |
+
is_new: bool,
|
| 977 |
+
is_stream: bool = True,
|
| 978 |
+
text_content: str = "",
|
| 979 |
+
file_ids: List[str] = None
|
| 980 |
+
):
|
| 981 |
+
jwt = await jwt_mgr.get()
|
| 982 |
+
headers = get_common_headers(jwt)
|
| 983 |
+
|
| 984 |
+
# 使用传入的文本内容(已通过 parse_last_message 解析)
|
| 985 |
+
if file_ids is None:
|
| 986 |
+
file_ids = []
|
| 987 |
+
|
| 988 |
+
body = {
|
| 989 |
+
"configId": CONFIG_ID,
|
| 990 |
+
"additionalParams": {"token": "-"},
|
| 991 |
+
"streamAssistRequest": {
|
| 992 |
+
"session": gemini_session,
|
| 993 |
+
"query": {"parts": [{"text": text_content}]},
|
| 994 |
+
"filter": "",
|
| 995 |
+
"fileIds": file_ids,
|
| 996 |
+
"answerGenerationMode": "NORMAL",
|
| 997 |
+
"toolsSpec": {
|
| 998 |
+
"webGroundingSpec": {},
|
| 999 |
+
"toolRegistry": "default_tool_registry",
|
| 1000 |
+
"imageGenerationSpec": {},
|
| 1001 |
+
"videoGenerationSpec": {}
|
| 1002 |
+
},
|
| 1003 |
+
"languageCode": "zh-CN",
|
| 1004 |
+
"userMetadata": {"timeZone": "Etc/GMT-8"},
|
| 1005 |
+
"assistSkippingMode": "REQUEST_ASSIST"
|
| 1006 |
+
}
|
| 1007 |
+
}
|
| 1008 |
+
|
| 1009 |
+
# 如果指定了具体模型,添加 assistGenerationConfig
|
| 1010 |
+
target_model_id = MODEL_MAPPING.get(req.model)
|
| 1011 |
+
if target_model_id:
|
| 1012 |
+
body["streamAssistRequest"]["assistGenerationConfig"] = {
|
| 1013 |
+
"modelId": target_model_id
|
| 1014 |
+
}
|
| 1015 |
+
logger.info(f"使用模型: {target_model_id}")
|
| 1016 |
+
else:
|
| 1017 |
+
logger.info(f"使用默认模型 (gemini-auto)")
|
| 1018 |
+
|
| 1019 |
+
# 1. 发送 Role 和聊天信息
|
| 1020 |
+
if is_stream:
|
| 1021 |
+
chunk = create_chunk(completion_id, created_time, req.model, {"role": "assistant"}, None, chat_id, title, is_new)
|
| 1022 |
+
yield f"data: {chunk}\n\n"
|
| 1023 |
+
|
| 1024 |
+
full_content = ""
|
| 1025 |
+
extracted_title = None
|
| 1026 |
+
|
| 1027 |
+
# 2. 发起请求 (等待完整响应后解析,确保稳定性)
|
| 1028 |
+
async with httpx.AsyncClient(proxy=PROXY, verify=False, timeout=120) as cli:
|
| 1029 |
+
r = await cli.post(
|
| 1030 |
+
"https://biz-discoveryengine.googleapis.com/v1alpha/locations/global/widgetStreamAssist",
|
| 1031 |
+
headers=headers,
|
| 1032 |
+
json=body,
|
| 1033 |
+
)
|
| 1034 |
+
|
| 1035 |
+
if r.status_code != 200:
|
| 1036 |
+
# 抛出异常以便外层捕获重试
|
| 1037 |
+
logger.error(f"Google 报错: {r.status_code} {r.text[:200]}")
|
| 1038 |
+
raise HTTPException(status_code=r.status_code, detail=f"Upstream Error {r.text}")
|
| 1039 |
+
|
| 1040 |
+
try:
|
| 1041 |
+
data_list = r.json()
|
| 1042 |
+
except Exception as e:
|
| 1043 |
+
logger.error(f"JSON 解析失败: {e}")
|
| 1044 |
+
if is_stream:
|
| 1045 |
+
yield f"data: {json.dumps({'error': {'message': 'JSON Parse Error'}})}\n\n"
|
| 1046 |
+
return
|
| 1047 |
+
|
| 1048 |
+
# 3. 遍历数组,收集思考过程和正文
|
| 1049 |
+
thinking_parts = [] # 收集所有思考过程
|
| 1050 |
+
raw_content = "" # 先收集原始内容
|
| 1051 |
+
|
| 1052 |
+
for data in data_list:
|
| 1053 |
+
for reply in data.get("streamAssistResponse", {}).get("answer", {}).get("replies", []):
|
| 1054 |
+
# 提取思考过程(thought 字段)- 如果API有这个字段
|
| 1055 |
+
thought = reply.get("thought", "")
|
| 1056 |
+
if thought:
|
| 1057 |
+
thinking_parts.append(thought)
|
| 1058 |
+
if not extracted_title and is_new:
|
| 1059 |
+
extracted_title = thought[:50]
|
| 1060 |
+
|
| 1061 |
+
# 提取正文内容
|
| 1062 |
+
text = reply.get("groundedContent", {}).get("content", {}).get("text", "")
|
| 1063 |
+
if text:
|
| 1064 |
+
if thought:
|
| 1065 |
+
continue
|
| 1066 |
+
else:
|
| 1067 |
+
raw_content += text
|
| 1068 |
+
|
| 1069 |
+
# 4. 从正文中提取 **思考标题** 格式的内容
|
| 1070 |
+
import re
|
| 1071 |
+
# 匹配 **标题** 格式(单独成行或在开头)
|
| 1072 |
+
thinking_pattern = r'\*\*([^*]+)\*\*\s*\n?'
|
| 1073 |
+
|
| 1074 |
+
# 查找所有思考标题
|
| 1075 |
+
matches = re.findall(thinking_pattern, raw_content)
|
| 1076 |
+
|
| 1077 |
+
# 如果找到了思考标题,提取它们
|
| 1078 |
+
if matches:
|
| 1079 |
+
for match in matches:
|
| 1080 |
+
# 过滤掉可能是正文中的加粗文本(通常思考标题是短的英文描述)
|
| 1081 |
+
# 思考标题通常是英文,且不包含中文
|
| 1082 |
+
if re.match(r'^[A-Za-z\s\'\-]+$', match.strip()) and len(match) < 50:
|
| 1083 |
+
thinking_parts.append(match.strip())
|
| 1084 |
+
|
| 1085 |
+
# 只移除位于开头的思考标题,避免正文中的加粗被吞
|
| 1086 |
+
lines = raw_content.splitlines()
|
| 1087 |
+
idx = 0
|
| 1088 |
+
leading_thinking = []
|
| 1089 |
+
while idx < len(lines):
|
| 1090 |
+
m = re.match(r'^\s*\*\*([^*]+)\*\*\s*$', lines[idx])
|
| 1091 |
+
if not m:
|
| 1092 |
+
break
|
| 1093 |
+
leading_thinking.append(m.group(1).strip())
|
| 1094 |
+
idx += 1
|
| 1095 |
+
if leading_thinking:
|
| 1096 |
+
thinking_parts.extend(leading_thinking)
|
| 1097 |
+
full_content = "\n".join(lines[idx:]).strip()
|
| 1098 |
+
else:
|
| 1099 |
+
full_content = raw_content
|
| 1100 |
+
|
| 1101 |
+
# 设置第一个思考为标题
|
| 1102 |
+
if thinking_parts and not extracted_title and is_new:
|
| 1103 |
+
extracted_title = thinking_parts[0][:50]
|
| 1104 |
+
else:
|
| 1105 |
+
full_content = raw_content
|
| 1106 |
+
|
| 1107 |
+
logger.info(f"思考过程数量: {len(thinking_parts)}, 正文长度: {len(full_content)}")
|
| 1108 |
+
if thinking_parts:
|
| 1109 |
+
logger.info(f"思考内容: {thinking_parts}")
|
| 1110 |
+
|
| 1111 |
+
# 合并思考内容用于存储
|
| 1112 |
+
thinking_content = "\n\n".join(thinking_parts) if thinking_parts else None
|
| 1113 |
+
log_text("chat.stream.thinking", thinking_content)
|
| 1114 |
+
|
| 1115 |
+
# 4.5 解析并下载生成的图片
|
| 1116 |
+
generated_images: List[ChatImage] = []
|
| 1117 |
+
image_file_ids, response_session = parse_images_from_response(data_list)
|
| 1118 |
+
|
| 1119 |
+
# 4.6 如果 API 返回了新的 session,更新缓存和数据库(确保后续请求能复用)
|
| 1120 |
+
if response_session and response_session != gemini_session:
|
| 1121 |
+
logger.info(f"检测到新 Session: ...{response_session[-15:]}, 更新缓存")
|
| 1122 |
+
session_cache[chat_id] = response_session
|
| 1123 |
+
await update_gemini_session_in_db(chat_id, response_session)
|
| 1124 |
+
|
| 1125 |
+
if image_file_ids:
|
| 1126 |
+
logger.info(f"检测到 {len(image_file_ids)} 个生成图片")
|
| 1127 |
+
session_for_download = response_session or gemini_session
|
| 1128 |
+
|
| 1129 |
+
try:
|
| 1130 |
+
# 获取文件元数据
|
| 1131 |
+
file_metadata = await get_session_file_metadata(session_for_download)
|
| 1132 |
+
|
| 1133 |
+
# 获取当前会话中已有的图片数量,用于计算新图片序号
|
| 1134 |
+
existing_image_count = await get_chat_image_count(chat_id)
|
| 1135 |
+
|
| 1136 |
+
for idx, finfo in enumerate(image_file_ids):
|
| 1137 |
+
fid = finfo["fileId"]
|
| 1138 |
+
mime = finfo["mimeType"]
|
| 1139 |
+
meta = file_metadata.get(fid, {})
|
| 1140 |
+
file_name = meta.get("name")
|
| 1141 |
+
session_path = meta.get("session") or session_for_download
|
| 1142 |
+
|
| 1143 |
+
# 下载并保存图片,传入 chat_id 和序号
|
| 1144 |
+
image_index = existing_image_count + idx + 1
|
| 1145 |
+
img = await save_generated_image(session_path, fid, file_name, mime, chat_id, image_index)
|
| 1146 |
+
generated_images.append(img)
|
| 1147 |
+
|
| 1148 |
+
except Exception as e:
|
| 1149 |
+
logger.error(f"处理生成图片失败: {e}")
|
| 1150 |
+
|
| 1151 |
+
# 5. 先发送思考过程
|
| 1152 |
+
if thinking_content and is_stream:
|
| 1153 |
+
thinking_chunk = create_chunk(completion_id, created_time, req.model, {"thinking": thinking_content}, None)
|
| 1154 |
+
yield f"data: {thinking_chunk}\n\n"
|
| 1155 |
+
|
| 1156 |
+
# 6. 发送正文内容
|
| 1157 |
+
if full_content:
|
| 1158 |
+
chunk = create_chunk(completion_id, created_time, req.model, {"content": full_content}, None)
|
| 1159 |
+
if is_stream:
|
| 1160 |
+
yield f"data: {chunk}\n\n"
|
| 1161 |
+
log_text("chat.stream.content", full_content)
|
| 1162 |
+
|
| 1163 |
+
# 6.5 发送生成的图片
|
| 1164 |
+
if generated_images and is_stream:
|
| 1165 |
+
images_data = []
|
| 1166 |
+
for img in generated_images:
|
| 1167 |
+
img_info = {
|
| 1168 |
+
"file_id": img.file_id,
|
| 1169 |
+
"file_name": img.file_name,
|
| 1170 |
+
"mime_type": img.mime_type,
|
| 1171 |
+
"local_path": img.local_path,
|
| 1172 |
+
}
|
| 1173 |
+
# 包含 base64 数据供前端显示
|
| 1174 |
+
if img.base64_data:
|
| 1175 |
+
img_info["base64_data"] = img.base64_data
|
| 1176 |
+
images_data.append(img_info)
|
| 1177 |
+
|
| 1178 |
+
image_chunk = create_chunk(completion_id, created_time, req.model, {"images": images_data}, None)
|
| 1179 |
+
yield f"data: {image_chunk}\n\n"
|
| 1180 |
+
logger.info(f"已发送 {len(images_data)} 张图片��客户端")
|
| 1181 |
+
|
| 1182 |
+
# 7. 保存助手消息到数据库(包含思考过程和图片)
|
| 1183 |
+
if full_content or thinking_content or generated_images:
|
| 1184 |
+
# 准备图片信息用于存储(不含 base64,只存文件路径)
|
| 1185 |
+
images_for_db = None
|
| 1186 |
+
if generated_images:
|
| 1187 |
+
images_for_db = json.dumps([{
|
| 1188 |
+
"file_name": img.file_name,
|
| 1189 |
+
"local_path": img.local_path,
|
| 1190 |
+
"mime_type": img.mime_type
|
| 1191 |
+
} for img in generated_images if img.local_path], ensure_ascii=False)
|
| 1192 |
+
|
| 1193 |
+
with session_maker() as save_db:
|
| 1194 |
+
save_db.add(ChatMessage(
|
| 1195 |
+
chat_id=chat_id,
|
| 1196 |
+
role="assistant",
|
| 1197 |
+
content=full_content or "",
|
| 1198 |
+
thinking=thinking_content,
|
| 1199 |
+
images=images_for_db
|
| 1200 |
+
))
|
| 1201 |
+
|
| 1202 |
+
# 如果提取到了标题,更新会话标题
|
| 1203 |
+
if extracted_title and is_new:
|
| 1204 |
+
result = save_db.execute(select(ChatSession).where(ChatSession.id == chat_id))
|
| 1205 |
+
session = result.scalar_one_or_none()
|
| 1206 |
+
if session:
|
| 1207 |
+
session.title = extracted_title
|
| 1208 |
+
|
| 1209 |
+
save_db.commit()
|
| 1210 |
+
|
| 1211 |
+
# 5. 发送 Finish Reason 和标题更新
|
| 1212 |
+
if is_stream:
|
| 1213 |
+
final_data = {}
|
| 1214 |
+
if extracted_title and is_new:
|
| 1215 |
+
final_data["extracted_title"] = extracted_title
|
| 1216 |
+
final_chunk = create_chunk(completion_id, created_time, req.model, final_data, "stop")
|
| 1217 |
+
yield f"data: {final_chunk}\n\n"
|
| 1218 |
+
yield "data: [DONE]\n\n"
|
| 1219 |
+
|
| 1220 |
+
if __name__ == "__main__":
|
| 1221 |
+
if not all([SECURE_C_SES, CSESIDX, CONFIG_ID]):
|
| 1222 |
+
print("Error: Missing required environment variables.")
|
| 1223 |
+
exit(1)
|
| 1224 |
+
import uvicorn
|
| 1225 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
| 1226 |
+
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.110.0
|
| 2 |
+
uvicorn[standard]==0.29.0
|
| 3 |
+
httpx==0.27.0
|
| 4 |
+
pydantic==2.7.0
|
| 5 |
+
python-dotenv==1.0.1
|
| 6 |
+
sqlalchemy==2.0.25
|
| 7 |
+
psycopg2-binary==2.9.11
|
templates/index.html
ADDED
|
@@ -0,0 +1,2440 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8">
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 7 |
+
<title>Gemini Chat</title>
|
| 8 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
|
| 9 |
+
<style>
|
| 10 |
+
* {
|
| 11 |
+
margin: 0;
|
| 12 |
+
padding: 0;
|
| 13 |
+
box-sizing: border-box;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
:root {
|
| 17 |
+
--bg-primary: #ffffff;
|
| 18 |
+
--bg-secondary: #f9f9f9;
|
| 19 |
+
--bg-tertiary: #f0f0f0;
|
| 20 |
+
--text-primary: #000000;
|
| 21 |
+
--text-secondary: #666666;
|
| 22 |
+
--accent: #000000;
|
| 23 |
+
--border: #e5e5e5;
|
| 24 |
+
--shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
| 25 |
+
--sidebar-width: 260px;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
body {
|
| 29 |
+
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
| 30 |
+
background-color: var(--bg-primary);
|
| 31 |
+
color: var(--text-primary);
|
| 32 |
+
height: 100vh;
|
| 33 |
+
display: flex;
|
| 34 |
+
transition: all 0.3s ease;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/* Sidebar */
|
| 38 |
+
.sidebar {
|
| 39 |
+
width: var(--sidebar-width);
|
| 40 |
+
background: var(--bg-secondary);
|
| 41 |
+
border-right: 1px solid var(--border);
|
| 42 |
+
display: flex;
|
| 43 |
+
flex-direction: column;
|
| 44 |
+
height: 100vh;
|
| 45 |
+
flex-shrink: 0;
|
| 46 |
+
transition: transform 0.3s ease;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.sidebar-header {
|
| 50 |
+
padding: 16px;
|
| 51 |
+
border-bottom: 1px solid var(--border);
|
| 52 |
+
display: flex;
|
| 53 |
+
justify-content: space-between;
|
| 54 |
+
align-items: center;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.sidebar-title {
|
| 58 |
+
font-size: 16px;
|
| 59 |
+
font-weight: 600;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.new-chat-btn {
|
| 63 |
+
background: var(--text-primary);
|
| 64 |
+
color: white;
|
| 65 |
+
border: none;
|
| 66 |
+
padding: 8px 16px;
|
| 67 |
+
border-radius: 8px;
|
| 68 |
+
cursor: pointer;
|
| 69 |
+
font-size: 14px;
|
| 70 |
+
display: flex;
|
| 71 |
+
align-items: center;
|
| 72 |
+
gap: 6px;
|
| 73 |
+
transition: opacity 0.2s;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.new-chat-btn:hover {
|
| 77 |
+
opacity: 0.8;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.chat-list {
|
| 81 |
+
flex: 1;
|
| 82 |
+
overflow-y: auto;
|
| 83 |
+
padding: 8px;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.chat-item {
|
| 87 |
+
padding: 12px;
|
| 88 |
+
border-radius: 8px;
|
| 89 |
+
cursor: pointer;
|
| 90 |
+
margin-bottom: 4px;
|
| 91 |
+
transition: background 0.2s;
|
| 92 |
+
display: flex;
|
| 93 |
+
justify-content: space-between;
|
| 94 |
+
align-items: center;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.chat-item:hover {
|
| 98 |
+
background: var(--bg-tertiary);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.chat-item.active {
|
| 102 |
+
background: var(--bg-tertiary);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.chat-item-info {
|
| 106 |
+
flex: 1;
|
| 107 |
+
overflow: hidden;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.chat-item-title {
|
| 111 |
+
font-size: 14px;
|
| 112 |
+
white-space: nowrap;
|
| 113 |
+
overflow: hidden;
|
| 114 |
+
text-overflow: ellipsis;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.chat-item-date {
|
| 118 |
+
font-size: 12px;
|
| 119 |
+
color: var(--text-secondary);
|
| 120 |
+
margin-top: 2px;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.chat-item-actions {
|
| 124 |
+
display: none;
|
| 125 |
+
gap: 4px;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.chat-item:hover .chat-item-actions {
|
| 129 |
+
display: flex;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.chat-item-btn {
|
| 133 |
+
background: none;
|
| 134 |
+
border: none;
|
| 135 |
+
cursor: pointer;
|
| 136 |
+
padding: 4px;
|
| 137 |
+
color: var(--text-secondary);
|
| 138 |
+
border-radius: 4px;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
.chat-item-btn:hover {
|
| 142 |
+
background: var(--bg-primary);
|
| 143 |
+
color: var(--text-primary);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/* Main Area */
|
| 147 |
+
.main-area {
|
| 148 |
+
flex: 1;
|
| 149 |
+
display: flex;
|
| 150 |
+
flex-direction: column;
|
| 151 |
+
height: 100vh;
|
| 152 |
+
overflow: hidden;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
/* Header */
|
| 156 |
+
.header {
|
| 157 |
+
display: flex;
|
| 158 |
+
justify-content: space-between;
|
| 159 |
+
align-items: center;
|
| 160 |
+
padding: 12px 20px;
|
| 161 |
+
background: transparent;
|
| 162 |
+
border-bottom: 1px solid var(--border);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.logo {
|
| 166 |
+
font-size: 18px;
|
| 167 |
+
font-weight: 600;
|
| 168 |
+
display: flex;
|
| 169 |
+
align-items: center;
|
| 170 |
+
gap: 8px;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.model-selector {
|
| 174 |
+
position: relative;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.model-btn {
|
| 178 |
+
background: transparent;
|
| 179 |
+
border: none;
|
| 180 |
+
color: var(--text-secondary);
|
| 181 |
+
padding: 8px 12px;
|
| 182 |
+
border-radius: 8px;
|
| 183 |
+
cursor: pointer;
|
| 184 |
+
display: flex;
|
| 185 |
+
align-items: center;
|
| 186 |
+
gap: 4px;
|
| 187 |
+
font-size: 16px;
|
| 188 |
+
font-weight: 500;
|
| 189 |
+
transition: color 0.2s;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.model-btn:hover {
|
| 193 |
+
color: var(--text-primary);
|
| 194 |
+
background: var(--bg-secondary);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.model-dropdown {
|
| 198 |
+
position: absolute;
|
| 199 |
+
top: 100%;
|
| 200 |
+
right: 0;
|
| 201 |
+
margin-top: 8px;
|
| 202 |
+
background: var(--bg-primary);
|
| 203 |
+
border: 1px solid var(--border);
|
| 204 |
+
border-radius: 12px;
|
| 205 |
+
overflow: hidden;
|
| 206 |
+
display: none;
|
| 207 |
+
min-width: 200px;
|
| 208 |
+
z-index: 100;
|
| 209 |
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.model-dropdown.show {
|
| 213 |
+
display: block;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.model-option {
|
| 217 |
+
padding: 12px 16px;
|
| 218 |
+
cursor: pointer;
|
| 219 |
+
transition: background 0.2s;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.model-option:hover {
|
| 223 |
+
background: var(--bg-secondary);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.model-option.selected {
|
| 227 |
+
background: var(--bg-secondary);
|
| 228 |
+
font-weight: 600;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.model-name {
|
| 232 |
+
font-size: 14px;
|
| 233 |
+
margin-bottom: 2px;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.model-desc {
|
| 237 |
+
font-size: 12px;
|
| 238 |
+
color: var(--text-secondary);
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
/* Main Content Area */
|
| 242 |
+
.main-content {
|
| 243 |
+
flex: 1;
|
| 244 |
+
display: flex;
|
| 245 |
+
flex-direction: column;
|
| 246 |
+
align-items: center;
|
| 247 |
+
justify-content: center;
|
| 248 |
+
width: 100%;
|
| 249 |
+
max-width: 800px;
|
| 250 |
+
margin: 0 auto;
|
| 251 |
+
padding: 20px;
|
| 252 |
+
overflow-y: auto;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
body.has-chat .main-content {
|
| 256 |
+
justify-content: flex-start;
|
| 257 |
+
padding-bottom: 20px;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
/* Welcome / Initial State */
|
| 261 |
+
.welcome-container {
|
| 262 |
+
text-align: center;
|
| 263 |
+
margin-bottom: 40px;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
body.has-chat .welcome-container {
|
| 267 |
+
display: none;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
.welcome-title {
|
| 271 |
+
font-size: 32px;
|
| 272 |
+
font-weight: 500;
|
| 273 |
+
margin-bottom: 30px;
|
| 274 |
+
color: var(--text-primary);
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
/* Chat Messages */
|
| 278 |
+
.chat-messages {
|
| 279 |
+
display: none;
|
| 280 |
+
width: 100%;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
body.has-chat .chat-messages {
|
| 284 |
+
display: flex;
|
| 285 |
+
flex-direction: column;
|
| 286 |
+
gap: 24px;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
.message {
|
| 290 |
+
width: 100%;
|
| 291 |
+
animation: fadeIn 0.3s ease;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
@keyframes fadeIn {
|
| 295 |
+
from {
|
| 296 |
+
opacity: 0;
|
| 297 |
+
transform: translateY(10px);
|
| 298 |
+
}
|
| 299 |
+
to {
|
| 300 |
+
opacity: 1;
|
| 301 |
+
transform: translateY(0);
|
| 302 |
+
}
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.message-user {
|
| 306 |
+
display: flex;
|
| 307 |
+
justify-content: flex-end;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
.message-user .message-content {
|
| 311 |
+
background: var(--bg-secondary);
|
| 312 |
+
padding: 10px 20px;
|
| 313 |
+
border-radius: 20px;
|
| 314 |
+
max-width: 80%;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
.message-assistant {
|
| 318 |
+
display: flex;
|
| 319 |
+
gap: 16px;
|
| 320 |
+
padding: 0 10px;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
.avatar-assistant {
|
| 324 |
+
width: 32px;
|
| 325 |
+
height: 32px;
|
| 326 |
+
border-radius: 50%;
|
| 327 |
+
background: linear-gradient(135deg, #4285f4, #ea4335);
|
| 328 |
+
flex-shrink: 0;
|
| 329 |
+
display: flex;
|
| 330 |
+
align-items: center;
|
| 331 |
+
justify-content: center;
|
| 332 |
+
color: white;
|
| 333 |
+
font-size: 14px;
|
| 334 |
+
font-weight: bold;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
.message-content {
|
| 338 |
+
line-height: 1.6;
|
| 339 |
+
font-size: 16px;
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
/* Input Area */
|
| 343 |
+
.input-container {
|
| 344 |
+
width: 100%;
|
| 345 |
+
max-width: 800px;
|
| 346 |
+
padding: 20px;
|
| 347 |
+
background: var(--bg-primary);
|
| 348 |
+
margin: 0 auto;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
.input-box {
|
| 352 |
+
background: #ffffff;
|
| 353 |
+
border: 1px solid transparent;
|
| 354 |
+
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
|
| 355 |
+
border-radius: 30px;
|
| 356 |
+
padding: 12px 20px;
|
| 357 |
+
display: flex;
|
| 358 |
+
align-items: center;
|
| 359 |
+
gap: 12px;
|
| 360 |
+
transition: all 0.3s;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
.input-box:focus-within {
|
| 364 |
+
box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);
|
| 365 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
.plus-btn {
|
| 369 |
+
width: 32px;
|
| 370 |
+
height: 32px;
|
| 371 |
+
border-radius: 50%;
|
| 372 |
+
border: none;
|
| 373 |
+
background: var(--bg-secondary);
|
| 374 |
+
color: var(--text-primary);
|
| 375 |
+
cursor: pointer;
|
| 376 |
+
display: flex;
|
| 377 |
+
align-items: center;
|
| 378 |
+
justify-content: center;
|
| 379 |
+
transition: background 0.2s;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
.plus-btn:hover {
|
| 383 |
+
background: #e0e0e0;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
#user-input {
|
| 387 |
+
flex: 1;
|
| 388 |
+
border: none;
|
| 389 |
+
background: transparent;
|
| 390 |
+
font-size: 16px;
|
| 391 |
+
padding: 4px 0;
|
| 392 |
+
outline: none;
|
| 393 |
+
resize: none;
|
| 394 |
+
max-height: 150px;
|
| 395 |
+
font-family: inherit;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
#user-input::placeholder {
|
| 399 |
+
color: #999;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
.send-btn {
|
| 403 |
+
background: #e0e0e0;
|
| 404 |
+
border: none;
|
| 405 |
+
width: 32px;
|
| 406 |
+
height: 32px;
|
| 407 |
+
border-radius: 50%;
|
| 408 |
+
display: flex;
|
| 409 |
+
align-items: center;
|
| 410 |
+
justify-content: center;
|
| 411 |
+
cursor: pointer;
|
| 412 |
+
color: var(--text-primary);
|
| 413 |
+
transition: all 0.2s;
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
.send-btn.active {
|
| 417 |
+
background: var(--text-primary);
|
| 418 |
+
color: white;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
/* Markdown Styles */
|
| 422 |
+
|
| 423 |
+
/* 代码块容器 - 浅灰卡片风格 */
|
| 424 |
+
.code-block-wrapper {
|
| 425 |
+
margin: 16px 0;
|
| 426 |
+
border-radius: 8px;
|
| 427 |
+
overflow: hidden;
|
| 428 |
+
background: #f8f8f8;
|
| 429 |
+
border: 1px solid #e0e0e0;
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
/* 代码块头部 */
|
| 433 |
+
.code-block-header {
|
| 434 |
+
display: flex;
|
| 435 |
+
justify-content: space-between;
|
| 436 |
+
align-items: center;
|
| 437 |
+
padding: 8px 12px;
|
| 438 |
+
background: #f0f0f0;
|
| 439 |
+
border-bottom: 1px solid #e0e0e0;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.code-block-lang {
|
| 443 |
+
font-size: 13px;
|
| 444 |
+
color: #666;
|
| 445 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
.code-copy-btn {
|
| 449 |
+
display: flex;
|
| 450 |
+
align-items: center;
|
| 451 |
+
justify-content: center;
|
| 452 |
+
gap: 4px;
|
| 453 |
+
background: transparent;
|
| 454 |
+
border: none;
|
| 455 |
+
color: #666;
|
| 456 |
+
font-size: 13px;
|
| 457 |
+
cursor: pointer;
|
| 458 |
+
padding: 4px 8px;
|
| 459 |
+
border-radius: 4px;
|
| 460 |
+
transition: all 0.2s;
|
| 461 |
+
flex-shrink: 0;
|
| 462 |
+
min-width: 28px;
|
| 463 |
+
min-height: 28px;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
.code-copy-btn:hover {
|
| 467 |
+
background: #e0e0e0;
|
| 468 |
+
color: #333;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
.code-copy-btn.copied {
|
| 472 |
+
color: #22c55e;
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
/* 代码内容区域 */
|
| 476 |
+
.code-block-wrapper pre {
|
| 477 |
+
margin: 0;
|
| 478 |
+
padding: 16px;
|
| 479 |
+
overflow-x: auto;
|
| 480 |
+
background: #f8f8f8;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
.code-block-wrapper pre code {
|
| 484 |
+
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
| 485 |
+
font-size: 14px;
|
| 486 |
+
line-height: 1.6;
|
| 487 |
+
color: #333;
|
| 488 |
+
background: transparent;
|
| 489 |
+
padding: 0;
|
| 490 |
+
display: block;
|
| 491 |
+
white-space: pre;
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
/* 行内代码 */
|
| 495 |
+
.message-content code {
|
| 496 |
+
background: #f6f8fa;
|
| 497 |
+
padding: 2px 6px;
|
| 498 |
+
border-radius: 4px;
|
| 499 |
+
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
| 500 |
+
font-size: 0.9em;
|
| 501 |
+
color: #24292e;
|
| 502 |
+
border: 1px solid #e1e4e8;
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
/* 代码块里的 code 不需要额外背景 */
|
| 506 |
+
.code-block-wrapper pre code {
|
| 507 |
+
background: transparent;
|
| 508 |
+
padding: 0;
|
| 509 |
+
border: none;
|
| 510 |
+
color: #333;
|
| 511 |
+
border-radius: 0;
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
/* 列表样式 */
|
| 515 |
+
.message-content ul,
|
| 516 |
+
.message-content ol {
|
| 517 |
+
margin: 12px 0;
|
| 518 |
+
padding-left: 20px;
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
.message-content ul {
|
| 522 |
+
list-style-type: disc;
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
.message-content ol {
|
| 526 |
+
list-style-type: decimal;
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
.message-content ul li,
|
| 530 |
+
.message-content ol li {
|
| 531 |
+
margin: 6px 0;
|
| 532 |
+
line-height: 1.7;
|
| 533 |
+
color: #333;
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
/* 字段间隔 */
|
| 537 |
+
.message-content > p,
|
| 538 |
+
.message-content > div:not(.code-block-wrapper) {
|
| 539 |
+
margin: 12px 0;
|
| 540 |
+
line-height: 1.7;
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
.message-content > *:first-child {
|
| 544 |
+
margin-top: 0;
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
.message-content > *:last-child {
|
| 548 |
+
margin-bottom: 0;
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
/* 标题样式 */
|
| 552 |
+
.message-content h3 {
|
| 553 |
+
font-size: 16px;
|
| 554 |
+
font-weight: 600;
|
| 555 |
+
margin: 20px 0 12px 0;
|
| 556 |
+
color: #333;
|
| 557 |
+
display: flex;
|
| 558 |
+
align-items: center;
|
| 559 |
+
gap: 8px;
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
.message-content h3:first-child {
|
| 563 |
+
margin-top: 0;
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
/* 表格样式 */
|
| 567 |
+
.message-content table {
|
| 568 |
+
width: 100%;
|
| 569 |
+
border-collapse: collapse;
|
| 570 |
+
margin: 16px 0;
|
| 571 |
+
font-size: 14px;
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
.message-content table th,
|
| 575 |
+
.message-content table td {
|
| 576 |
+
padding: 12px 16px;
|
| 577 |
+
text-align: left;
|
| 578 |
+
border-bottom: 1px solid #e5e5e5;
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
.message-content table th {
|
| 582 |
+
font-weight: 600;
|
| 583 |
+
color: #333;
|
| 584 |
+
background: #f9f9f9;
|
| 585 |
+
border-bottom: 2px solid #e0e0e0;
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
.message-content table td {
|
| 589 |
+
color: #555;
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
.message-content table tr:hover td {
|
| 593 |
+
background: #fafafa;
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
.message-content table td:first-child {
|
| 597 |
+
color: #24292e;
|
| 598 |
+
font-family: 'SFMono-Regular', Consolas, monospace;
|
| 599 |
+
white-space: nowrap;
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
/* Pair rows for two-column tables */
|
| 603 |
+
.code-pairs {
|
| 604 |
+
display: flex;
|
| 605 |
+
flex-direction: column;
|
| 606 |
+
gap: 8px;
|
| 607 |
+
margin: 12px 0;
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
.pair-row {
|
| 611 |
+
display: flex;
|
| 612 |
+
gap: 10px;
|
| 613 |
+
align-items: flex-start;
|
| 614 |
+
padding: 10px 12px;
|
| 615 |
+
background: var(--bg-tertiary);
|
| 616 |
+
border: 1px solid var(--border);
|
| 617 |
+
border-radius: 8px;
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
.pair-code {
|
| 621 |
+
white-space: pre-wrap;
|
| 622 |
+
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
| 623 |
+
color: #24292e;
|
| 624 |
+
background: #f6f8fa;
|
| 625 |
+
padding: 2px 6px;
|
| 626 |
+
border-radius: 4px;
|
| 627 |
+
min-width: 120px;
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
.pair-desc {
|
| 631 |
+
color: var(--text-primary);
|
| 632 |
+
line-height: 1.6;
|
| 633 |
+
flex: 1;
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
/* Scrollbar */
|
| 637 |
+
::-webkit-scrollbar {
|
| 638 |
+
width: 6px;
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
::-webkit-scrollbar-track {
|
| 642 |
+
background: transparent;
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
::-webkit-scrollbar-thumb {
|
| 646 |
+
background: #ddd;
|
| 647 |
+
border-radius: 3px;
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
::-webkit-scrollbar-thumb:hover {
|
| 651 |
+
background: #ccc;
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
/* Loading Animation */
|
| 655 |
+
.loading-dots {
|
| 656 |
+
display: inline-flex;
|
| 657 |
+
gap: 4px;
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
.loading-dots span {
|
| 661 |
+
width: 6px;
|
| 662 |
+
height: 6px;
|
| 663 |
+
background: var(--text-secondary);
|
| 664 |
+
border-radius: 50%;
|
| 665 |
+
animation: bounce 1.4s infinite;
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
.loading-dots span:nth-child(2) {
|
| 669 |
+
animation-delay: 0.2s;
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
.loading-dots span:nth-child(3) {
|
| 673 |
+
animation-delay: 0.4s;
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
@keyframes bounce {
|
| 677 |
+
0%, 80%, 100% {
|
| 678 |
+
transform: translateY(0);
|
| 679 |
+
}
|
| 680 |
+
40% {
|
| 681 |
+
transform: translateY(-6px);
|
| 682 |
+
}
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
/* Empty state */
|
| 686 |
+
.empty-state {
|
| 687 |
+
display: flex;
|
| 688 |
+
flex-direction: column;
|
| 689 |
+
align-items: center;
|
| 690 |
+
justify-content: center;
|
| 691 |
+
height: 200px;
|
| 692 |
+
color: var(--text-secondary);
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
.empty-state svg {
|
| 696 |
+
margin-bottom: 12px;
|
| 697 |
+
opacity: 0.5;
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
/* Thinking Block - 推理过程样式 */
|
| 701 |
+
.thinking-block {
|
| 702 |
+
margin-bottom: 16px;
|
| 703 |
+
font-size: 14px;
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
.thinking-header {
|
| 707 |
+
padding: 4px 0;
|
| 708 |
+
cursor: pointer;
|
| 709 |
+
display: inline-flex;
|
| 710 |
+
align-items: center;
|
| 711 |
+
gap: 6px;
|
| 712 |
+
user-select: none;
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
.thinking-header:hover {
|
| 716 |
+
opacity: 0.8;
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
.thinking-icon {
|
| 720 |
+
transition: transform 0.2s;
|
| 721 |
+
flex-shrink: 0;
|
| 722 |
+
color: #c67b37;
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
.thinking-block:not(.collapsed) .thinking-icon {
|
| 726 |
+
transform: rotate(180deg);
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
.thinking-title {
|
| 730 |
+
font-weight: 400;
|
| 731 |
+
color: #c67b37;
|
| 732 |
+
font-size: 14px;
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
.thinking-content {
|
| 736 |
+
padding: 8px 0 8px 0;
|
| 737 |
+
color: #333;
|
| 738 |
+
line-height: 1.8;
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
.thinking-block.collapsed .thinking-content {
|
| 742 |
+
display: none;
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
.thinking-item {
|
| 746 |
+
font-weight: 400;
|
| 747 |
+
color: #333;
|
| 748 |
+
padding: 1px 0;
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
.thinking-note {
|
| 752 |
+
font-size: 12px;
|
| 753 |
+
color: #999;
|
| 754 |
+
margin-top: 6px;
|
| 755 |
+
font-style: italic;
|
| 756 |
+
}
|
| 757 |
+
|
| 758 |
+
/* Modal 弹窗样式 */
|
| 759 |
+
.modal-overlay {
|
| 760 |
+
display: none;
|
| 761 |
+
position: fixed;
|
| 762 |
+
top: 0;
|
| 763 |
+
left: 0;
|
| 764 |
+
right: 0;
|
| 765 |
+
bottom: 0;
|
| 766 |
+
background: rgba(0, 0, 0, 0.5);
|
| 767 |
+
z-index: 2000;
|
| 768 |
+
align-items: center;
|
| 769 |
+
justify-content: center;
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
.modal-overlay.show {
|
| 773 |
+
display: flex;
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
.modal {
|
| 777 |
+
background: white;
|
| 778 |
+
border-radius: 12px;
|
| 779 |
+
padding: 24px;
|
| 780 |
+
max-width: 400px;
|
| 781 |
+
width: 90%;
|
| 782 |
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
.modal-title {
|
| 786 |
+
font-size: 18px;
|
| 787 |
+
font-weight: 600;
|
| 788 |
+
margin-bottom: 12px;
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
.modal-message {
|
| 792 |
+
color: #666;
|
| 793 |
+
margin-bottom: 20px;
|
| 794 |
+
line-height: 1.5;
|
| 795 |
+
}
|
| 796 |
+
|
| 797 |
+
.modal-buttons {
|
| 798 |
+
display: flex;
|
| 799 |
+
gap: 12px;
|
| 800 |
+
justify-content: flex-end;
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
.modal-btn {
|
| 804 |
+
padding: 10px 20px;
|
| 805 |
+
border-radius: 8px;
|
| 806 |
+
border: none;
|
| 807 |
+
cursor: pointer;
|
| 808 |
+
font-size: 14px;
|
| 809 |
+
font-weight: 500;
|
| 810 |
+
transition: opacity 0.2s;
|
| 811 |
+
}
|
| 812 |
+
|
| 813 |
+
.modal-btn:hover {
|
| 814 |
+
opacity: 0.8;
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
.modal-btn-cancel {
|
| 818 |
+
background: #f0f0f0;
|
| 819 |
+
color: #333;
|
| 820 |
+
}
|
| 821 |
+
|
| 822 |
+
.modal-btn-danger {
|
| 823 |
+
background: #ef4444;
|
| 824 |
+
color: white;
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
/* Mobile responsive */
|
| 828 |
+
@media (max-width: 768px) {
|
| 829 |
+
.sidebar {
|
| 830 |
+
position: fixed;
|
| 831 |
+
left: 0;
|
| 832 |
+
top: 0;
|
| 833 |
+
z-index: 1000;
|
| 834 |
+
transform: translateX(-100%);
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
+
.sidebar.open {
|
| 838 |
+
transform: translateX(0);
|
| 839 |
+
}
|
| 840 |
+
|
| 841 |
+
.sidebar-overlay {
|
| 842 |
+
display: none;
|
| 843 |
+
position: fixed;
|
| 844 |
+
top: 0;
|
| 845 |
+
left: 0;
|
| 846 |
+
right: 0;
|
| 847 |
+
bottom: 0;
|
| 848 |
+
background: rgba(0, 0, 0, 0.5);
|
| 849 |
+
z-index: 999;
|
| 850 |
+
}
|
| 851 |
+
|
| 852 |
+
.sidebar-overlay.show {
|
| 853 |
+
display: block;
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
.menu-toggle {
|
| 857 |
+
display: flex !important;
|
| 858 |
+
}
|
| 859 |
+
}
|
| 860 |
+
|
| 861 |
+
.menu-toggle {
|
| 862 |
+
display: none;
|
| 863 |
+
background: none;
|
| 864 |
+
border: none;
|
| 865 |
+
cursor: pointer;
|
| 866 |
+
padding: 8px;
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
/* 图片预览样式 */
|
| 870 |
+
.image-preview-container {
|
| 871 |
+
display: none;
|
| 872 |
+
padding: 8px 12px;
|
| 873 |
+
background: var(--bg-secondary);
|
| 874 |
+
border-radius: 16px 16px 0 0;
|
| 875 |
+
margin-bottom: -8px;
|
| 876 |
+
}
|
| 877 |
+
|
| 878 |
+
.image-preview-container.has-images {
|
| 879 |
+
display: flex;
|
| 880 |
+
flex-wrap: wrap;
|
| 881 |
+
gap: 8px;
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
.image-preview-item {
|
| 885 |
+
position: relative;
|
| 886 |
+
width: 80px;
|
| 887 |
+
height: 80px;
|
| 888 |
+
border-radius: 8px;
|
| 889 |
+
overflow: hidden;
|
| 890 |
+
border: 1px solid var(--border);
|
| 891 |
+
}
|
| 892 |
+
|
| 893 |
+
.image-preview-item img {
|
| 894 |
+
width: 100%;
|
| 895 |
+
height: 100%;
|
| 896 |
+
object-fit: cover;
|
| 897 |
+
}
|
| 898 |
+
|
| 899 |
+
.image-preview-remove {
|
| 900 |
+
position: absolute;
|
| 901 |
+
top: 4px;
|
| 902 |
+
right: 4px;
|
| 903 |
+
width: 20px;
|
| 904 |
+
height: 20px;
|
| 905 |
+
border-radius: 50%;
|
| 906 |
+
background: rgba(0, 0, 0, 0.6);
|
| 907 |
+
color: white;
|
| 908 |
+
border: none;
|
| 909 |
+
cursor: pointer;
|
| 910 |
+
display: flex;
|
| 911 |
+
align-items: center;
|
| 912 |
+
justify-content: center;
|
| 913 |
+
font-size: 14px;
|
| 914 |
+
line-height: 1;
|
| 915 |
+
transition: background 0.2s;
|
| 916 |
+
}
|
| 917 |
+
|
| 918 |
+
.image-preview-remove:hover {
|
| 919 |
+
background: rgba(0, 0, 0, 0.8);
|
| 920 |
+
}
|
| 921 |
+
|
| 922 |
+
/* 消息中的图片样式 */
|
| 923 |
+
.message-images {
|
| 924 |
+
display: flex;
|
| 925 |
+
flex-wrap: wrap;
|
| 926 |
+
gap: 8px;
|
| 927 |
+
margin-bottom: 8px;
|
| 928 |
+
}
|
| 929 |
+
|
| 930 |
+
.message-image {
|
| 931 |
+
max-width: 200px;
|
| 932 |
+
max-height: 200px;
|
| 933 |
+
border-radius: 8px;
|
| 934 |
+
cursor: pointer;
|
| 935 |
+
transition: transform 0.2s;
|
| 936 |
+
}
|
| 937 |
+
|
| 938 |
+
.message-image:hover {
|
| 939 |
+
transform: scale(1.02);
|
| 940 |
+
}
|
| 941 |
+
|
| 942 |
+
/* 图片放大查看 */
|
| 943 |
+
.image-lightbox {
|
| 944 |
+
display: none;
|
| 945 |
+
position: fixed;
|
| 946 |
+
top: 0;
|
| 947 |
+
left: 0;
|
| 948 |
+
right: 0;
|
| 949 |
+
bottom: 0;
|
| 950 |
+
background: rgba(0, 0, 0, 0.9);
|
| 951 |
+
z-index: 3000;
|
| 952 |
+
align-items: center;
|
| 953 |
+
justify-content: center;
|
| 954 |
+
cursor: zoom-out;
|
| 955 |
+
}
|
| 956 |
+
|
| 957 |
+
.image-lightbox.show {
|
| 958 |
+
display: flex;
|
| 959 |
+
}
|
| 960 |
+
|
| 961 |
+
.image-lightbox img {
|
| 962 |
+
max-width: 90%;
|
| 963 |
+
max-height: 90%;
|
| 964 |
+
object-fit: contain;
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
/* AI 生成的图片样式 */
|
| 968 |
+
.generated-images {
|
| 969 |
+
display: inline-flex;
|
| 970 |
+
flex-wrap: wrap;
|
| 971 |
+
gap: 8px;
|
| 972 |
+
margin-top: 12px;
|
| 973 |
+
padding: 8px;
|
| 974 |
+
background: var(--bg-secondary);
|
| 975 |
+
border-radius: 12px;
|
| 976 |
+
}
|
| 977 |
+
|
| 978 |
+
.generated-image-item {
|
| 979 |
+
position: relative;
|
| 980 |
+
border-radius: 8px;
|
| 981 |
+
overflow: hidden;
|
| 982 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
| 983 |
+
line-height: 0;
|
| 984 |
+
}
|
| 985 |
+
|
| 986 |
+
.generated-image-item img {
|
| 987 |
+
max-width: 400px;
|
| 988 |
+
max-height: 400px;
|
| 989 |
+
display: block;
|
| 990 |
+
cursor: pointer;
|
| 991 |
+
transition: transform 0.2s;
|
| 992 |
+
}
|
| 993 |
+
|
| 994 |
+
.generated-image-item img:hover {
|
| 995 |
+
transform: scale(1.02);
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
+
.generated-image-actions {
|
| 999 |
+
position: absolute;
|
| 1000 |
+
bottom: 8px;
|
| 1001 |
+
right: 8px;
|
| 1002 |
+
display: flex;
|
| 1003 |
+
gap: 6px;
|
| 1004 |
+
}
|
| 1005 |
+
|
| 1006 |
+
.generated-image-btn {
|
| 1007 |
+
width: 32px;
|
| 1008 |
+
height: 32px;
|
| 1009 |
+
border-radius: 50%;
|
| 1010 |
+
background: rgba(0, 0, 0, 0.6);
|
| 1011 |
+
color: white;
|
| 1012 |
+
border: none;
|
| 1013 |
+
cursor: pointer;
|
| 1014 |
+
display: flex;
|
| 1015 |
+
align-items: center;
|
| 1016 |
+
justify-content: center;
|
| 1017 |
+
transition: background 0.2s;
|
| 1018 |
+
}
|
| 1019 |
+
|
| 1020 |
+
.generated-image-btn:hover {
|
| 1021 |
+
background: rgba(0, 0, 0, 0.8);
|
| 1022 |
+
}
|
| 1023 |
+
|
| 1024 |
+
/* +号弹出菜单样式 */
|
| 1025 |
+
.plus-menu-container {
|
| 1026 |
+
position: relative;
|
| 1027 |
+
}
|
| 1028 |
+
|
| 1029 |
+
.plus-menu {
|
| 1030 |
+
position: absolute;
|
| 1031 |
+
bottom: 100%;
|
| 1032 |
+
left: 0;
|
| 1033 |
+
margin-bottom: 8px;
|
| 1034 |
+
background: var(--bg-primary);
|
| 1035 |
+
border: 1px solid var(--border);
|
| 1036 |
+
border-radius: 12px;
|
| 1037 |
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
| 1038 |
+
display: none;
|
| 1039 |
+
min-width: 160px;
|
| 1040 |
+
z-index: 100;
|
| 1041 |
+
overflow: hidden;
|
| 1042 |
+
}
|
| 1043 |
+
|
| 1044 |
+
.plus-menu.show {
|
| 1045 |
+
display: block;
|
| 1046 |
+
}
|
| 1047 |
+
|
| 1048 |
+
.plus-menu-item {
|
| 1049 |
+
display: flex;
|
| 1050 |
+
align-items: center;
|
| 1051 |
+
gap: 10px;
|
| 1052 |
+
padding: 12px 16px;
|
| 1053 |
+
cursor: pointer;
|
| 1054 |
+
transition: background 0.2s;
|
| 1055 |
+
font-size: 14px;
|
| 1056 |
+
color: var(--text-primary);
|
| 1057 |
+
}
|
| 1058 |
+
|
| 1059 |
+
.plus-menu-item:hover {
|
| 1060 |
+
background: var(--bg-secondary);
|
| 1061 |
+
}
|
| 1062 |
+
|
| 1063 |
+
.plus-menu-item svg {
|
| 1064 |
+
flex-shrink: 0;
|
| 1065 |
+
}
|
| 1066 |
+
|
| 1067 |
+
/* 图片生成标签样式 */
|
| 1068 |
+
.image-gen-tag {
|
| 1069 |
+
display: none;
|
| 1070 |
+
align-items: center;
|
| 1071 |
+
gap: 4px;
|
| 1072 |
+
padding: 4px 8px;
|
| 1073 |
+
background: transparent;
|
| 1074 |
+
border-radius: 6px;
|
| 1075 |
+
font-size: 14px;
|
| 1076 |
+
color: #1a73e8;
|
| 1077 |
+
cursor: default;
|
| 1078 |
+
transition: background 0.2s;
|
| 1079 |
+
}
|
| 1080 |
+
|
| 1081 |
+
.image-gen-tag.active {
|
| 1082 |
+
display: flex;
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
+
.image-gen-tag svg {
|
| 1086 |
+
flex-shrink: 0;
|
| 1087 |
+
color: #1a73e8;
|
| 1088 |
+
}
|
| 1089 |
+
|
| 1090 |
+
.image-gen-tag-close {
|
| 1091 |
+
display: none;
|
| 1092 |
+
width: 18px;
|
| 1093 |
+
height: 18px;
|
| 1094 |
+
border-radius: 50%;
|
| 1095 |
+
border: none;
|
| 1096 |
+
background: #e8f0fe;
|
| 1097 |
+
color: #1a73e8;
|
| 1098 |
+
cursor: pointer;
|
| 1099 |
+
align-items: center;
|
| 1100 |
+
justify-content: center;
|
| 1101 |
+
margin-left: 2px;
|
| 1102 |
+
padding: 0;
|
| 1103 |
+
transition: background 0.2s;
|
| 1104 |
+
}
|
| 1105 |
+
|
| 1106 |
+
.image-gen-tag:hover .image-gen-tag-close {
|
| 1107 |
+
display: flex;
|
| 1108 |
+
}
|
| 1109 |
+
|
| 1110 |
+
.image-gen-tag-close:hover {
|
| 1111 |
+
background: #d2e3fc;
|
| 1112 |
+
}
|
| 1113 |
+
</style>
|
| 1114 |
+
</head>
|
| 1115 |
+
|
| 1116 |
+
<body>
|
| 1117 |
+
<!-- Image Lightbox -->
|
| 1118 |
+
<div class="image-lightbox" id="image-lightbox" onclick="closeLightbox()">
|
| 1119 |
+
<img src="" alt="放大图片" id="lightbox-img">
|
| 1120 |
+
</div>
|
| 1121 |
+
|
| 1122 |
+
<!-- Delete Confirm Modal -->
|
| 1123 |
+
<div class="modal-overlay" id="delete-modal">
|
| 1124 |
+
<div class="modal">
|
| 1125 |
+
<div class="modal-title">删除对话</div>
|
| 1126 |
+
<div class="modal-message">确定要删除这个对话吗?此操作无法撤销。</div>
|
| 1127 |
+
<div class="modal-buttons">
|
| 1128 |
+
<button class="modal-btn modal-btn-cancel" onclick="closeDeleteModal()">取消</button>
|
| 1129 |
+
<button class="modal-btn modal-btn-danger" onclick="confirmDelete()">删除</button>
|
| 1130 |
+
</div>
|
| 1131 |
+
</div>
|
| 1132 |
+
</div>
|
| 1133 |
+
|
| 1134 |
+
<!-- Sidebar Overlay for mobile -->
|
| 1135 |
+
<div class="sidebar-overlay" id="sidebar-overlay" onclick="toggleSidebar()"></div>
|
| 1136 |
+
|
| 1137 |
+
<!-- Sidebar -->
|
| 1138 |
+
<div class="sidebar" id="sidebar">
|
| 1139 |
+
<div class="sidebar-header">
|
| 1140 |
+
<span class="sidebar-title">Gemini Chat</span>
|
| 1141 |
+
<button class="new-chat-btn" onclick="newChat()">
|
| 1142 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 1143 |
+
<path d="M12 5v14M5 12h14" />
|
| 1144 |
+
</svg>
|
| 1145 |
+
新对话
|
| 1146 |
+
</button>
|
| 1147 |
+
</div>
|
| 1148 |
+
<div class="chat-list" id="chat-list">
|
| 1149 |
+
<!-- Chat items will be loaded here -->
|
| 1150 |
+
</div>
|
| 1151 |
+
</div>
|
| 1152 |
+
|
| 1153 |
+
<!-- Main Area -->
|
| 1154 |
+
<div class="main-area">
|
| 1155 |
+
<div class="header">
|
| 1156 |
+
<div style="display: flex; align-items: center; gap: 12px;">
|
| 1157 |
+
<button class="menu-toggle" onclick="toggleSidebar()">
|
| 1158 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 1159 |
+
<path d="M3 12h18M3 6h18M3 18h18" />
|
| 1160 |
+
</svg>
|
| 1161 |
+
</button>
|
| 1162 |
+
<div class="logo">
|
| 1163 |
+
<span id="current-chat-title">Gemini Chat</span>
|
| 1164 |
+
</div>
|
| 1165 |
+
</div>
|
| 1166 |
+
<div style="display: flex; gap: 12px; align-items: center;">
|
| 1167 |
+
<div class="model-selector">
|
| 1168 |
+
<button class="model-btn" onclick="toggleDropdown()">
|
| 1169 |
+
<span id="current-model">gemini-3-pro-preview</span>
|
| 1170 |
+
<svg width="10" height="10" viewBox="0 0 12 12" fill="currentColor">
|
| 1171 |
+
<path d="M6 8L2 4h8L6 8z" />
|
| 1172 |
+
</svg>
|
| 1173 |
+
</button>
|
| 1174 |
+
<div class="model-dropdown" id="model-dropdown">
|
| 1175 |
+
<div class="model-option" onclick="selectModel('gemini-auto')">
|
| 1176 |
+
<div class="model-name">gemini-auto</div>
|
| 1177 |
+
<div class="model-desc">自动选择</div>
|
| 1178 |
+
</div>
|
| 1179 |
+
<div class="model-option" onclick="selectModel('gemini-2.5-flash')">
|
| 1180 |
+
<div class="model-name">gemini-2.5-flash</div>
|
| 1181 |
+
<div class="model-desc">快速响应</div>
|
| 1182 |
+
</div>
|
| 1183 |
+
<div class="model-option" onclick="selectModel('gemini-2.5-pro')">
|
| 1184 |
+
<div class="model-name">gemini-2.5-pro</div>
|
| 1185 |
+
<div class="model-desc">平衡推理</div>
|
| 1186 |
+
</div>
|
| 1187 |
+
<div class="model-option" onclick="selectModel('gemini-3-pro')">
|
| 1188 |
+
<div class="model-name">gemini-3-pro</div>
|
| 1189 |
+
<div class="model-desc">旗舰模型</div>
|
| 1190 |
+
</div>
|
| 1191 |
+
<div class="model-option selected" onclick="selectModel('gemini-3-pro-preview')">
|
| 1192 |
+
<div class="model-name">gemini-3-pro-preview</div>
|
| 1193 |
+
<div class="model-desc">预览版旗舰</div>
|
| 1194 |
+
</div>
|
| 1195 |
+
</div>
|
| 1196 |
+
</div>
|
| 1197 |
+
</div>
|
| 1198 |
+
</div>
|
| 1199 |
+
|
| 1200 |
+
<div class="main-content" id="main-content">
|
| 1201 |
+
<div class="welcome-container">
|
| 1202 |
+
<h1 class="welcome-title">你今天在想什么?</h1>
|
| 1203 |
+
</div>
|
| 1204 |
+
|
| 1205 |
+
<div class="chat-messages" id="chat-container">
|
| 1206 |
+
<!-- Messages will appear here -->
|
| 1207 |
+
</div>
|
| 1208 |
+
</div>
|
| 1209 |
+
|
| 1210 |
+
<div class="input-container">
|
| 1211 |
+
<div class="image-preview-container" id="image-preview-container">
|
| 1212 |
+
<!-- 图片预览会动态添加到这里 -->
|
| 1213 |
+
</div>
|
| 1214 |
+
<div class="input-box">
|
| 1215 |
+
<div class="plus-menu-container">
|
| 1216 |
+
<button class="plus-btn" onclick="togglePlusMenu(event)">
|
| 1217 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 1218 |
+
<path d="M12 5v14M5 12h14" />
|
| 1219 |
+
</svg>
|
| 1220 |
+
</button>
|
| 1221 |
+
<div class="plus-menu" id="plus-menu">
|
| 1222 |
+
<div class="plus-menu-item" onclick="toggleImageGenMode()">
|
| 1223 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 1224 |
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
| 1225 |
+
<circle cx="8.5" cy="8.5" r="1.5"/>
|
| 1226 |
+
<polyline points="21 15 16 10 5 21"/>
|
| 1227 |
+
</svg>
|
| 1228 |
+
<span>生成图片</span>
|
| 1229 |
+
</div>
|
| 1230 |
+
</div>
|
| 1231 |
+
</div>
|
| 1232 |
+
<div class="image-gen-tag" id="image-gen-tag">
|
| 1233 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 1234 |
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
| 1235 |
+
<circle cx="8.5" cy="8.5" r="1.5"/>
|
| 1236 |
+
<polyline points="21 15 16 10 5 21"/>
|
| 1237 |
+
</svg>
|
| 1238 |
+
<span>图片</span>
|
| 1239 |
+
<button class="image-gen-tag-close" onclick="toggleImageGenMode()" title="取消生成图片">
|
| 1240 |
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 1241 |
+
<path d="M18 6L6 18M6 6l12 12"/>
|
| 1242 |
+
</svg>
|
| 1243 |
+
</button>
|
| 1244 |
+
</div>
|
| 1245 |
+
<textarea id="user-input" rows="1" placeholder="询问任何问题" onkeydown="handleKeyDown(event)"></textarea>
|
| 1246 |
+
<button class="send-btn" id="send-btn" onclick="sendMessage()">
|
| 1247 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 1248 |
+
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
|
| 1249 |
+
</svg>
|
| 1250 |
+
</button>
|
| 1251 |
+
</div>
|
| 1252 |
+
</div>
|
| 1253 |
+
</div>
|
| 1254 |
+
|
| 1255 |
+
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
|
| 1256 |
+
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"></script>
|
| 1257 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
| 1258 |
+
<script>
|
| 1259 |
+
let currentModel = 'gemini-3-pro-preview';
|
| 1260 |
+
let isGenerating = false;
|
| 1261 |
+
let messages = [];
|
| 1262 |
+
let currentChatId = null;
|
| 1263 |
+
let chatList = [];
|
| 1264 |
+
let pendingImages = []; // 待发送的图片列表 [{dataUrl: string, file: File}]
|
| 1265 |
+
let isImageGenMode = false; // 图片生成模式
|
| 1266 |
+
|
| 1267 |
+
// Initialize
|
| 1268 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 1269 |
+
loadChatList();
|
| 1270 |
+
setupTextarea();
|
| 1271 |
+
setupPasteHandler();
|
| 1272 |
+
restoreLastChat();
|
| 1273 |
+
updateModelUI();
|
| 1274 |
+
});
|
| 1275 |
+
|
| 1276 |
+
// 恢复上次的对话记录
|
| 1277 |
+
function restoreLastChat() {
|
| 1278 |
+
const savedChatId = localStorage.getItem('currentChatId');
|
| 1279 |
+
if (savedChatId) {
|
| 1280 |
+
// 延迟加载,等待 chatList 准备完成
|
| 1281 |
+
setTimeout(() => {
|
| 1282 |
+
if (chatList.some(c => c.id === savedChatId)) {
|
| 1283 |
+
loadChat(savedChatId);
|
| 1284 |
+
} else {
|
| 1285 |
+
localStorage.removeItem('currentChatId');
|
| 1286 |
+
}
|
| 1287 |
+
}, 300);
|
| 1288 |
+
}
|
| 1289 |
+
}
|
| 1290 |
+
|
| 1291 |
+
// 保存当前对话 ID 到 localStorage
|
| 1292 |
+
function saveCurrentChat() {
|
| 1293 |
+
if (currentChatId) {
|
| 1294 |
+
localStorage.setItem('currentChatId', currentChatId);
|
| 1295 |
+
} else {
|
| 1296 |
+
localStorage.removeItem('currentChatId');
|
| 1297 |
+
}
|
| 1298 |
+
}
|
| 1299 |
+
|
| 1300 |
+
// 更新模型选择器 UI
|
| 1301 |
+
function updateModelUI() {
|
| 1302 |
+
document.getElementById('current-model').textContent = currentModel;
|
| 1303 |
+
document.querySelectorAll('.model-option').forEach(opt => {
|
| 1304 |
+
opt.classList.remove('selected');
|
| 1305 |
+
if (opt.querySelector('.model-name').textContent === currentModel) {
|
| 1306 |
+
opt.classList.add('selected');
|
| 1307 |
+
}
|
| 1308 |
+
});
|
| 1309 |
+
}
|
| 1310 |
+
|
| 1311 |
+
function setupTextarea() {
|
| 1312 |
+
const textarea = document.getElementById('user-input');
|
| 1313 |
+
textarea.addEventListener('input', function () {
|
| 1314 |
+
this.style.height = 'auto';
|
| 1315 |
+
this.style.height = Math.min(this.scrollHeight, 150) + 'px';
|
| 1316 |
+
updateSendButton();
|
| 1317 |
+
});
|
| 1318 |
+
}
|
| 1319 |
+
|
| 1320 |
+
// 设置粘贴处理器
|
| 1321 |
+
function setupPasteHandler() {
|
| 1322 |
+
const textarea = document.getElementById('user-input');
|
| 1323 |
+
|
| 1324 |
+
// 监听粘贴事件
|
| 1325 |
+
textarea.addEventListener('paste', async (e) => {
|
| 1326 |
+
const items = e.clipboardData?.items;
|
| 1327 |
+
if (!items) return;
|
| 1328 |
+
|
| 1329 |
+
for (const item of items) {
|
| 1330 |
+
if (item.type.startsWith('image/')) {
|
| 1331 |
+
e.preventDefault();
|
| 1332 |
+
const file = item.getAsFile();
|
| 1333 |
+
if (file) {
|
| 1334 |
+
await addImageFromFile(file);
|
| 1335 |
+
}
|
| 1336 |
+
break;
|
| 1337 |
+
}
|
| 1338 |
+
}
|
| 1339 |
+
});
|
| 1340 |
+
|
| 1341 |
+
// 监听拖放事件
|
| 1342 |
+
const inputContainer = document.querySelector('.input-container');
|
| 1343 |
+
|
| 1344 |
+
inputContainer.addEventListener('dragover', (e) => {
|
| 1345 |
+
e.preventDefault();
|
| 1346 |
+
inputContainer.style.borderColor = 'var(--accent)';
|
| 1347 |
+
});
|
| 1348 |
+
|
| 1349 |
+
inputContainer.addEventListener('dragleave', (e) => {
|
| 1350 |
+
e.preventDefault();
|
| 1351 |
+
inputContainer.style.borderColor = '';
|
| 1352 |
+
});
|
| 1353 |
+
|
| 1354 |
+
inputContainer.addEventListener('drop', async (e) => {
|
| 1355 |
+
e.preventDefault();
|
| 1356 |
+
inputContainer.style.borderColor = '';
|
| 1357 |
+
|
| 1358 |
+
const files = e.dataTransfer?.files;
|
| 1359 |
+
if (files) {
|
| 1360 |
+
for (const file of files) {
|
| 1361 |
+
if (file.type.startsWith('image/')) {
|
| 1362 |
+
await addImageFromFile(file);
|
| 1363 |
+
}
|
| 1364 |
+
}
|
| 1365 |
+
}
|
| 1366 |
+
});
|
| 1367 |
+
}
|
| 1368 |
+
|
| 1369 |
+
// 从文件添加图片
|
| 1370 |
+
async function addImageFromFile(file) {
|
| 1371 |
+
return new Promise((resolve) => {
|
| 1372 |
+
const reader = new FileReader();
|
| 1373 |
+
reader.onload = (e) => {
|
| 1374 |
+
const dataUrl = e.target.result;
|
| 1375 |
+
pendingImages.push({ dataUrl, file });
|
| 1376 |
+
renderImagePreviews();
|
| 1377 |
+
updateSendButton();
|
| 1378 |
+
resolve();
|
| 1379 |
+
};
|
| 1380 |
+
reader.readAsDataURL(file);
|
| 1381 |
+
});
|
| 1382 |
+
}
|
| 1383 |
+
|
| 1384 |
+
// 渲染图片预览
|
| 1385 |
+
function renderImagePreviews() {
|
| 1386 |
+
const container = document.getElementById('image-preview-container');
|
| 1387 |
+
|
| 1388 |
+
if (pendingImages.length === 0) {
|
| 1389 |
+
container.classList.remove('has-images');
|
| 1390 |
+
container.innerHTML = '';
|
| 1391 |
+
return;
|
| 1392 |
+
}
|
| 1393 |
+
|
| 1394 |
+
container.classList.add('has-images');
|
| 1395 |
+
container.innerHTML = pendingImages.map((img, index) => `
|
| 1396 |
+
<div class="image-preview-item">
|
| 1397 |
+
<img src="${img.dataUrl}" alt="预览图片">
|
| 1398 |
+
<button class="image-preview-remove" onclick="removeImage(${index})" title="移除图片">×</button>
|
| 1399 |
+
</div>
|
| 1400 |
+
`).join('');
|
| 1401 |
+
}
|
| 1402 |
+
|
| 1403 |
+
// 移除图片
|
| 1404 |
+
function removeImage(index) {
|
| 1405 |
+
pendingImages.splice(index, 1);
|
| 1406 |
+
renderImagePreviews();
|
| 1407 |
+
updateSendButton();
|
| 1408 |
+
}
|
| 1409 |
+
|
| 1410 |
+
// 清空待发送的图片
|
| 1411 |
+
function clearPendingImages() {
|
| 1412 |
+
pendingImages = [];
|
| 1413 |
+
renderImagePreviews();
|
| 1414 |
+
}
|
| 1415 |
+
|
| 1416 |
+
// 图片放大查看
|
| 1417 |
+
function openLightbox(src) {
|
| 1418 |
+
const lightbox = document.getElementById('image-lightbox');
|
| 1419 |
+
const img = document.getElementById('lightbox-img');
|
| 1420 |
+
img.src = src;
|
| 1421 |
+
lightbox.classList.add('show');
|
| 1422 |
+
}
|
| 1423 |
+
|
| 1424 |
+
function closeLightbox() {
|
| 1425 |
+
document.getElementById('image-lightbox').classList.remove('show');
|
| 1426 |
+
}
|
| 1427 |
+
|
| 1428 |
+
// 下载生成的图片
|
| 1429 |
+
function downloadGeneratedImage(base64Data, fileName, mimeType) {
|
| 1430 |
+
const link = document.createElement('a');
|
| 1431 |
+
link.href = `data:${mimeType};base64,${base64Data}`;
|
| 1432 |
+
link.download = fileName;
|
| 1433 |
+
document.body.appendChild(link);
|
| 1434 |
+
link.click();
|
| 1435 |
+
document.body.removeChild(link);
|
| 1436 |
+
}
|
| 1437 |
+
|
| 1438 |
+
// ESC 关闭 lightbox
|
| 1439 |
+
document.addEventListener('keydown', (e) => {
|
| 1440 |
+
if (e.key === 'Escape') {
|
| 1441 |
+
closeLightbox();
|
| 1442 |
+
closePlusMenu();
|
| 1443 |
+
}
|
| 1444 |
+
});
|
| 1445 |
+
|
| 1446 |
+
// +号菜单功能
|
| 1447 |
+
function togglePlusMenu(event) {
|
| 1448 |
+
event.stopPropagation();
|
| 1449 |
+
const menu = document.getElementById('plus-menu');
|
| 1450 |
+
menu.classList.toggle('show');
|
| 1451 |
+
}
|
| 1452 |
+
|
| 1453 |
+
function closePlusMenu() {
|
| 1454 |
+
const menu = document.getElementById('plus-menu');
|
| 1455 |
+
menu.classList.remove('show');
|
| 1456 |
+
}
|
| 1457 |
+
|
| 1458 |
+
// 点击其他地方关闭菜单
|
| 1459 |
+
document.addEventListener('click', (e) => {
|
| 1460 |
+
if (!e.target.closest('.plus-menu-container')) {
|
| 1461 |
+
closePlusMenu();
|
| 1462 |
+
}
|
| 1463 |
+
});
|
| 1464 |
+
|
| 1465 |
+
// 切换图片生成模式
|
| 1466 |
+
function toggleImageGenMode() {
|
| 1467 |
+
isImageGenMode = !isImageGenMode;
|
| 1468 |
+
const tag = document.getElementById('image-gen-tag');
|
| 1469 |
+
const textarea = document.getElementById('user-input');
|
| 1470 |
+
|
| 1471 |
+
if (isImageGenMode) {
|
| 1472 |
+
tag.classList.add('active');
|
| 1473 |
+
textarea.placeholder = '描述你想生成的图片...';
|
| 1474 |
+
} else {
|
| 1475 |
+
tag.classList.remove('active');
|
| 1476 |
+
textarea.placeholder = '询问任何问题';
|
| 1477 |
+
}
|
| 1478 |
+
|
| 1479 |
+
textarea.focus();
|
| 1480 |
+
closePlusMenu();
|
| 1481 |
+
}
|
| 1482 |
+
|
| 1483 |
+
// 重置图片生成模式
|
| 1484 |
+
function resetImageGenMode() {
|
| 1485 |
+
isImageGenMode = false;
|
| 1486 |
+
const tag = document.getElementById('image-gen-tag');
|
| 1487 |
+
const textarea = document.getElementById('user-input');
|
| 1488 |
+
tag.classList.remove('active');
|
| 1489 |
+
textarea.placeholder = '询问任何问题';
|
| 1490 |
+
}
|
| 1491 |
+
|
| 1492 |
+
function updateSendButton() {
|
| 1493 |
+
const input = document.getElementById('user-input');
|
| 1494 |
+
const btn = document.getElementById('send-btn');
|
| 1495 |
+
// 有文字或有图片时激活发送按钮
|
| 1496 |
+
if (input.value.trim() || pendingImages.length > 0) {
|
| 1497 |
+
btn.classList.add('active');
|
| 1498 |
+
} else {
|
| 1499 |
+
btn.classList.remove('active');
|
| 1500 |
+
}
|
| 1501 |
+
}
|
| 1502 |
+
|
| 1503 |
+
// Sidebar functions
|
| 1504 |
+
function toggleSidebar() {
|
| 1505 |
+
document.getElementById('sidebar').classList.toggle('open');
|
| 1506 |
+
document.getElementById('sidebar-overlay').classList.toggle('show');
|
| 1507 |
+
}
|
| 1508 |
+
|
| 1509 |
+
async function loadChatList() {
|
| 1510 |
+
try {
|
| 1511 |
+
const response = await fetch('/v1/chats');
|
| 1512 |
+
const data = await response.json();
|
| 1513 |
+
chatList = data.chats || [];
|
| 1514 |
+
renderChatList();
|
| 1515 |
+
} catch (error) {
|
| 1516 |
+
console.error('Failed to load chats:', error);
|
| 1517 |
+
}
|
| 1518 |
+
}
|
| 1519 |
+
|
| 1520 |
+
function renderChatList() {
|
| 1521 |
+
const container = document.getElementById('chat-list');
|
| 1522 |
+
if (chatList.length === 0) {
|
| 1523 |
+
container.innerHTML = `
|
| 1524 |
+
<div class="empty-state">
|
| 1525 |
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
| 1526 |
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
| 1527 |
+
</svg>
|
| 1528 |
+
<span>暂无对话记录</span>
|
| 1529 |
+
</div>
|
| 1530 |
+
`;
|
| 1531 |
+
return;
|
| 1532 |
+
}
|
| 1533 |
+
|
| 1534 |
+
container.innerHTML = chatList.map(chat => `
|
| 1535 |
+
<div class="chat-item ${chat.id === currentChatId ? 'active' : ''}"
|
| 1536 |
+
onclick="loadChat('${chat.id}')"
|
| 1537 |
+
data-chat-id="${chat.id}">
|
| 1538 |
+
<div class="chat-item-info">
|
| 1539 |
+
<div class="chat-item-title">${escapeHtml(chat.title)}</div>
|
| 1540 |
+
<div class="chat-item-date">${formatDate(chat.updated_at)}</div>
|
| 1541 |
+
</div>
|
| 1542 |
+
<div class="chat-item-actions">
|
| 1543 |
+
<button class="chat-item-btn" onclick="event.stopPropagation(); deleteChat('${chat.id}')" title="删除">
|
| 1544 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 1545 |
+
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
| 1546 |
+
</svg>
|
| 1547 |
+
</button>
|
| 1548 |
+
</div>
|
| 1549 |
+
</div>
|
| 1550 |
+
`).join('');
|
| 1551 |
+
}
|
| 1552 |
+
|
| 1553 |
+
function formatDate(dateStr) {
|
| 1554 |
+
const date = new Date(dateStr);
|
| 1555 |
+
const now = new Date();
|
| 1556 |
+
const diffMs = now - date;
|
| 1557 |
+
const diffMins = Math.floor(diffMs / 60000);
|
| 1558 |
+
const diffHours = Math.floor(diffMs / 3600000);
|
| 1559 |
+
const diffDays = Math.floor(diffMs / 86400000);
|
| 1560 |
+
|
| 1561 |
+
if (diffMins < 1) return '刚刚';
|
| 1562 |
+
if (diffMins < 60) return `${diffMins} 分钟前`;
|
| 1563 |
+
if (diffHours < 24) return `${diffHours} 小时前`;
|
| 1564 |
+
if (diffDays < 7) return `${diffDays} 天前`;
|
| 1565 |
+
return date.toLocaleDateString('zh-CN');
|
| 1566 |
+
}
|
| 1567 |
+
|
| 1568 |
+
async function loadChat(chatId) {
|
| 1569 |
+
try {
|
| 1570 |
+
const response = await fetch(`/v1/chats/${chatId}`);
|
| 1571 |
+
if (!response.ok) throw new Error('Chat not found');
|
| 1572 |
+
|
| 1573 |
+
const data = await response.json();
|
| 1574 |
+
|
| 1575 |
+
currentChatId = chatId;
|
| 1576 |
+
messages = data.messages.map(m => ({ role: m.role, content: m.content }));
|
| 1577 |
+
|
| 1578 |
+
// Update UI
|
| 1579 |
+
document.body.classList.add('has-chat');
|
| 1580 |
+
document.getElementById('current-chat-title').textContent = data.title;
|
| 1581 |
+
|
| 1582 |
+
// Render messages with thinking and images
|
| 1583 |
+
const container = document.getElementById('chat-container');
|
| 1584 |
+
container.innerHTML = '';
|
| 1585 |
+
data.messages.forEach(msg => {
|
| 1586 |
+
// 传入历史图片(如果有)
|
| 1587 |
+
const historyImages = msg.images || [];
|
| 1588 |
+
const msgDiv = addMessageToDOM(msg.role, msg.content, [], historyImages);
|
| 1589 |
+
|
| 1590 |
+
// 如果是助手消息且有推理过程,渲染推理折叠块
|
| 1591 |
+
if (msg.role === 'assistant' && msg.thinking) {
|
| 1592 |
+
const thinkingContainer = msgDiv.querySelector('.thinking-container');
|
| 1593 |
+
if (thinkingContainer) {
|
| 1594 |
+
// 历史消息默认折叠 (isCollapsed = true)
|
| 1595 |
+
thinkingContainer.innerHTML = createThinkingBlock(msg.thinking, true);
|
| 1596 |
+
}
|
| 1597 |
+
}
|
| 1598 |
+
});
|
| 1599 |
+
|
| 1600 |
+
// Update active state in sidebar
|
| 1601 |
+
document.querySelectorAll('.chat-item').forEach(item => {
|
| 1602 |
+
item.classList.toggle('active', item.dataset.chatId === chatId);
|
| 1603 |
+
});
|
| 1604 |
+
|
| 1605 |
+
// Scroll to bottom
|
| 1606 |
+
const mainContent = document.getElementById('main-content');
|
| 1607 |
+
mainContent.scrollTop = mainContent.scrollHeight;
|
| 1608 |
+
|
| 1609 |
+
// 保存当前对话 ID
|
| 1610 |
+
saveCurrentChat();
|
| 1611 |
+
|
| 1612 |
+
// Close sidebar on mobile
|
| 1613 |
+
if (window.innerWidth <= 768) {
|
| 1614 |
+
toggleSidebar();
|
| 1615 |
+
}
|
| 1616 |
+
} catch (error) {
|
| 1617 |
+
console.error('Failed to load chat:', error);
|
| 1618 |
+
}
|
| 1619 |
+
}
|
| 1620 |
+
|
| 1621 |
+
// 删除对话相关
|
| 1622 |
+
let pendingDeleteChatId = null;
|
| 1623 |
+
|
| 1624 |
+
function deleteChat(chatId) {
|
| 1625 |
+
pendingDeleteChatId = chatId;
|
| 1626 |
+
document.getElementById('delete-modal').classList.add('show');
|
| 1627 |
+
}
|
| 1628 |
+
|
| 1629 |
+
function closeDeleteModal() {
|
| 1630 |
+
document.getElementById('delete-modal').classList.remove('show');
|
| 1631 |
+
pendingDeleteChatId = null;
|
| 1632 |
+
}
|
| 1633 |
+
|
| 1634 |
+
async function confirmDelete() {
|
| 1635 |
+
if (!pendingDeleteChatId) return;
|
| 1636 |
+
|
| 1637 |
+
try {
|
| 1638 |
+
const response = await fetch(`/v1/chats/${pendingDeleteChatId}`, { method: 'DELETE' });
|
| 1639 |
+
if (response.ok) {
|
| 1640 |
+
chatList = chatList.filter(c => c.id !== pendingDeleteChatId);
|
| 1641 |
+
renderChatList();
|
| 1642 |
+
|
| 1643 |
+
if (currentChatId === pendingDeleteChatId) {
|
| 1644 |
+
newChat();
|
| 1645 |
+
}
|
| 1646 |
+
}
|
| 1647 |
+
} catch (error) {
|
| 1648 |
+
console.error('Failed to delete chat:', error);
|
| 1649 |
+
}
|
| 1650 |
+
|
| 1651 |
+
closeDeleteModal();
|
| 1652 |
+
}
|
| 1653 |
+
|
| 1654 |
+
function newChat() {
|
| 1655 |
+
currentChatId = null;
|
| 1656 |
+
messages = [];
|
| 1657 |
+
document.body.classList.remove('has-chat');
|
| 1658 |
+
document.getElementById('chat-container').innerHTML = '';
|
| 1659 |
+
document.getElementById('current-chat-title').textContent = 'Gemini Chat';
|
| 1660 |
+
document.getElementById('user-input').focus();
|
| 1661 |
+
resetImageGenMode(); // 重置图片生成模式
|
| 1662 |
+
|
| 1663 |
+
// 清除已保存的对话 ID
|
| 1664 |
+
saveCurrentChat();
|
| 1665 |
+
|
| 1666 |
+
// Update active state
|
| 1667 |
+
document.querySelectorAll('.chat-item').forEach(item => {
|
| 1668 |
+
item.classList.remove('active');
|
| 1669 |
+
});
|
| 1670 |
+
|
| 1671 |
+
// Close sidebar on mobile
|
| 1672 |
+
if (window.innerWidth <= 768) {
|
| 1673 |
+
toggleSidebar();
|
| 1674 |
+
}
|
| 1675 |
+
}
|
| 1676 |
+
|
| 1677 |
+
// Model selector
|
| 1678 |
+
function toggleDropdown() {
|
| 1679 |
+
document.getElementById('model-dropdown').classList.toggle('show');
|
| 1680 |
+
}
|
| 1681 |
+
|
| 1682 |
+
function selectModel(model) {
|
| 1683 |
+
currentModel = model;
|
| 1684 |
+
document.getElementById('current-model').textContent = model;
|
| 1685 |
+
document.querySelectorAll('.model-option').forEach(opt => {
|
| 1686 |
+
opt.classList.remove('selected');
|
| 1687 |
+
if (opt.querySelector('.model-name').textContent === model) {
|
| 1688 |
+
opt.classList.add('selected');
|
| 1689 |
+
}
|
| 1690 |
+
});
|
| 1691 |
+
toggleDropdown();
|
| 1692 |
+
}
|
| 1693 |
+
|
| 1694 |
+
document.addEventListener('click', function (e) {
|
| 1695 |
+
if (!e.target.closest('.model-selector')) {
|
| 1696 |
+
document.getElementById('model-dropdown').classList.remove('show');
|
| 1697 |
+
}
|
| 1698 |
+
});
|
| 1699 |
+
|
| 1700 |
+
// Input handling
|
| 1701 |
+
function handleKeyDown(e) {
|
| 1702 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 1703 |
+
e.preventDefault();
|
| 1704 |
+
sendMessage();
|
| 1705 |
+
}
|
| 1706 |
+
}
|
| 1707 |
+
|
| 1708 |
+
// 复制代码功能
|
| 1709 |
+
async function copyCode(button, codeId) {
|
| 1710 |
+
const codeElement = document.getElementById(codeId);
|
| 1711 |
+
if (!codeElement) return;
|
| 1712 |
+
|
| 1713 |
+
const code = codeElement.textContent;
|
| 1714 |
+
|
| 1715 |
+
// 复制成功后的 UI 更新
|
| 1716 |
+
function showCopySuccess() {
|
| 1717 |
+
button.classList.add('copied');
|
| 1718 |
+
const svg = button.querySelector('svg');
|
| 1719 |
+
const originalSvg = svg.innerHTML;
|
| 1720 |
+
svg.innerHTML = '<polyline points="20 6 9 17 4 12"></polyline>';
|
| 1721 |
+
setTimeout(() => {
|
| 1722 |
+
button.classList.remove('copied');
|
| 1723 |
+
svg.innerHTML = originalSvg;
|
| 1724 |
+
}, 2000);
|
| 1725 |
+
}
|
| 1726 |
+
|
| 1727 |
+
// Fallback 复制方法
|
| 1728 |
+
function fallbackCopy() {
|
| 1729 |
+
const textArea = document.createElement('textarea');
|
| 1730 |
+
textArea.value = code;
|
| 1731 |
+
textArea.style.position = 'fixed';
|
| 1732 |
+
textArea.style.left = '-9999px';
|
| 1733 |
+
document.body.appendChild(textArea);
|
| 1734 |
+
textArea.select();
|
| 1735 |
+
try {
|
| 1736 |
+
document.execCommand('copy');
|
| 1737 |
+
showCopySuccess();
|
| 1738 |
+
} catch (e) {
|
| 1739 |
+
console.error('复制失败:', e);
|
| 1740 |
+
}
|
| 1741 |
+
document.body.removeChild(textArea);
|
| 1742 |
+
}
|
| 1743 |
+
|
| 1744 |
+
// 尝试使用 Clipboard API
|
| 1745 |
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
| 1746 |
+
try {
|
| 1747 |
+
await navigator.clipboard.writeText(code);
|
| 1748 |
+
showCopySuccess();
|
| 1749 |
+
} catch (err) {
|
| 1750 |
+
console.warn('Clipboard API 失败,使用 fallback:', err);
|
| 1751 |
+
fallbackCopy();
|
| 1752 |
+
}
|
| 1753 |
+
} else {
|
| 1754 |
+
// Clipboard API 不可用,直接使用 fallback
|
| 1755 |
+
fallbackCopy();
|
| 1756 |
+
}
|
| 1757 |
+
}
|
| 1758 |
+
|
| 1759 |
+
document.addEventListener('click', (event) => {
|
| 1760 |
+
const button = event.target.closest('.code-copy-btn');
|
| 1761 |
+
if (!button) return;
|
| 1762 |
+
const codeId = button.dataset.codeId;
|
| 1763 |
+
if (codeId) {
|
| 1764 |
+
copyCode(button, codeId);
|
| 1765 |
+
}
|
| 1766 |
+
});
|
| 1767 |
+
|
| 1768 |
+
function escapeHtml(text) {
|
| 1769 |
+
const div = document.createElement('div');
|
| 1770 |
+
div.textContent = text;
|
| 1771 |
+
return div.innerHTML;
|
| 1772 |
+
}
|
| 1773 |
+
|
| 1774 |
+
function stripTags(html) {
|
| 1775 |
+
return html.replace(/<\/?[^>]+(>|$)/g, '');
|
| 1776 |
+
}
|
| 1777 |
+
|
| 1778 |
+
// Escape literal angle-bracket text (outside code) so DOMPurify/marked don't treat it as HTML and drop content.
|
| 1779 |
+
function escapePseudoTags(markdown) {
|
| 1780 |
+
// Split by code fences / inline code to avoid double-escaping code content
|
| 1781 |
+
return markdown
|
| 1782 |
+
.split(/(```[\s\S]*?```|`[^`]*`)/g)
|
| 1783 |
+
.map(part => {
|
| 1784 |
+
if (part.startsWith('```') || part.startsWith('`')) return part; // keep code as-is
|
| 1785 |
+
return part.replace(/</g, '<').replace(/>/g, '>');
|
| 1786 |
+
})
|
| 1787 |
+
.join('');
|
| 1788 |
+
}
|
| 1789 |
+
|
| 1790 |
+
// Debug logging for render pipeline (toggle as needed)
|
| 1791 |
+
const RENDER_LOG = true;
|
| 1792 |
+
function renderLog(stage, text) {
|
| 1793 |
+
if (!RENDER_LOG) return;
|
| 1794 |
+
const t = typeof text === 'string' ? text : (text === undefined || text === null ? '' : String(text));
|
| 1795 |
+
const preview = t.slice(0, 400);
|
| 1796 |
+
console.debug(`[render-log] ${stage} len=${t.length}`, preview);
|
| 1797 |
+
}
|
| 1798 |
+
|
| 1799 |
+
// Preprocess tables: replace first-column content with placeholders so it won't be broken by Markdown parsing.
|
| 1800 |
+
function preprocessTableCode(markdown) {
|
| 1801 |
+
const lines = markdown.split('\n');
|
| 1802 |
+
const placeholders = {};
|
| 1803 |
+
let tableId = 0;
|
| 1804 |
+
const out = [];
|
| 1805 |
+
|
| 1806 |
+
// 去除 Markdown 格式标记(反引号、加粗)
|
| 1807 |
+
function stripMarkdownFormat(text) {
|
| 1808 |
+
let result = text.trim();
|
| 1809 |
+
// 去除多个反引号包裹: ``code`` -> code
|
| 1810 |
+
while (result.startsWith('``') && result.endsWith('``') && result.length >= 4) {
|
| 1811 |
+
result = result.slice(2, -2);
|
| 1812 |
+
}
|
| 1813 |
+
// 去除单个反引号包裹: `code` -> code
|
| 1814 |
+
if (result.startsWith('`') && result.endsWith('`') && result.length >= 2) {
|
| 1815 |
+
result = result.slice(1, -1);
|
| 1816 |
+
}
|
| 1817 |
+
// 去除加粗包裹: **text** -> text
|
| 1818 |
+
if (result.startsWith('**') && result.endsWith('**') && result.length >= 4) {
|
| 1819 |
+
result = result.slice(2, -2);
|
| 1820 |
+
}
|
| 1821 |
+
// 去除斜体包裹: *text* -> text (单个星号)
|
| 1822 |
+
if (result.startsWith('*') && result.endsWith('*') && !result.startsWith('**') && result.length >= 2) {
|
| 1823 |
+
result = result.slice(1, -1);
|
| 1824 |
+
}
|
| 1825 |
+
return result.trim();
|
| 1826 |
+
}
|
| 1827 |
+
|
| 1828 |
+
for (let i = 0; i < lines.length; i++) {
|
| 1829 |
+
const line = lines[i];
|
| 1830 |
+
const next = lines[i + 1] || '';
|
| 1831 |
+
const isHeader = /\|/.test(line);
|
| 1832 |
+
const isSeparator = /^\s*\|?\s*[:\-]+\s*(\|\s*[:\-]+\s*)+\|?\s*$/.test(next);
|
| 1833 |
+
|
| 1834 |
+
// Detect table start: header line + separator line
|
| 1835 |
+
if (isHeader && isSeparator) {
|
| 1836 |
+
out.push(line);
|
| 1837 |
+
out.push(next);
|
| 1838 |
+
i++; // skip separator, already pushed
|
| 1839 |
+
let rowIndex = 0;
|
| 1840 |
+
|
| 1841 |
+
// Process table body rows
|
| 1842 |
+
while (i + 1 < lines.length) {
|
| 1843 |
+
const row = lines[i + 1];
|
| 1844 |
+
const trimmed = row.trim();
|
| 1845 |
+
if (!trimmed || !/\|/.test(row)) break;
|
| 1846 |
+
|
| 1847 |
+
const hasLeading = row.startsWith('|');
|
| 1848 |
+
const hasTrailing = row.endsWith('|');
|
| 1849 |
+
const body = row.slice(hasLeading ? 1 : 0, row.length - (hasTrailing ? 1 : 0));
|
| 1850 |
+
|
| 1851 |
+
// Split only on the first unescaped pipe to get first/second cell
|
| 1852 |
+
let splitIdx = -1;
|
| 1853 |
+
let escaped = false;
|
| 1854 |
+
for (let k = 0; k < body.length; k++) {
|
| 1855 |
+
const ch = body[k];
|
| 1856 |
+
if (escaped) {
|
| 1857 |
+
escaped = false;
|
| 1858 |
+
continue;
|
| 1859 |
+
}
|
| 1860 |
+
if (ch === '\\') {
|
| 1861 |
+
escaped = true;
|
| 1862 |
+
continue;
|
| 1863 |
+
}
|
| 1864 |
+
if (ch === '|') {
|
| 1865 |
+
splitIdx = k;
|
| 1866 |
+
break;
|
| 1867 |
+
}
|
| 1868 |
+
}
|
| 1869 |
+
|
| 1870 |
+
if (splitIdx === -1) {
|
| 1871 |
+
out.push(row);
|
| 1872 |
+
i++;
|
| 1873 |
+
continue;
|
| 1874 |
+
}
|
| 1875 |
+
|
| 1876 |
+
const firstCell = body.slice(0, splitIdx).trim();
|
| 1877 |
+
const restCells = body.slice(splitIdx + 1);
|
| 1878 |
+
const placeholder = `@@TABLECODE-${tableId}-${rowIndex}@@`;
|
| 1879 |
+
placeholders[placeholder] = escapeHtml(stripMarkdownFormat(firstCell));
|
| 1880 |
+
|
| 1881 |
+
const rebuilt =
|
| 1882 |
+
(hasLeading ? '|' : '') +
|
| 1883 |
+
placeholder +
|
| 1884 |
+
'|' +
|
| 1885 |
+
restCells +
|
| 1886 |
+
(hasTrailing ? '|' : '');
|
| 1887 |
+
out.push(rebuilt);
|
| 1888 |
+
i++;
|
| 1889 |
+
rowIndex++;
|
| 1890 |
+
}
|
| 1891 |
+
tableId++;
|
| 1892 |
+
continue;
|
| 1893 |
+
}
|
| 1894 |
+
|
| 1895 |
+
out.push(line);
|
| 1896 |
+
}
|
| 1897 |
+
return { markdown: out.join('\n'), placeholders };
|
| 1898 |
+
}
|
| 1899 |
+
|
| 1900 |
+
function formatContent(content) {
|
| 1901 |
+
if (!content) return '';
|
| 1902 |
+
|
| 1903 |
+
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
|
| 1904 |
+
console.warn('Markdown libs missing, rendering as plain text');
|
| 1905 |
+
return escapeHtml(content).replace(/\n/g, '<br>');
|
| 1906 |
+
}
|
| 1907 |
+
|
| 1908 |
+
renderLog('input', content);
|
| 1909 |
+
const preEscaped = escapePseudoTags(content);
|
| 1910 |
+
renderLog('preEscaped', preEscaped);
|
| 1911 |
+
const { markdown: safeContent, placeholders } = preprocessTableCode(preEscaped);
|
| 1912 |
+
|
| 1913 |
+
const uniqueBase = 'code-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
|
| 1914 |
+
let codeIndex = 0;
|
| 1915 |
+
|
| 1916 |
+
const renderer = new marked.Renderer();
|
| 1917 |
+
|
| 1918 |
+
renderer.code = (code, infostring) => {
|
| 1919 |
+
const language = (infostring || '').trim().toLowerCase();
|
| 1920 |
+
const displayLang = language ? language.charAt(0).toUpperCase() + language.slice(1) : 'Code';
|
| 1921 |
+
const codeId = `${uniqueBase}-${codeIndex++}`;
|
| 1922 |
+
const codeText = code.replace(/\n$/, '');
|
| 1923 |
+
|
| 1924 |
+
// 语言名称映射(处理常见别名)
|
| 1925 |
+
const langMap = {
|
| 1926 |
+
'js': 'javascript',
|
| 1927 |
+
'ts': 'typescript',
|
| 1928 |
+
'py': 'python',
|
| 1929 |
+
'rb': 'ruby',
|
| 1930 |
+
'sh': 'bash',
|
| 1931 |
+
'shell': 'bash',
|
| 1932 |
+
'yml': 'yaml',
|
| 1933 |
+
'md': 'markdown',
|
| 1934 |
+
'objective-c': 'objectivec',
|
| 1935 |
+
'objc': 'objectivec'
|
| 1936 |
+
};
|
| 1937 |
+
const mappedLang = langMap[language] || language;
|
| 1938 |
+
|
| 1939 |
+
// 使用 highlight.js 进行语法高亮
|
| 1940 |
+
let highlightedCode;
|
| 1941 |
+
try {
|
| 1942 |
+
if (typeof hljs !== 'undefined') {
|
| 1943 |
+
if (mappedLang && hljs.getLanguage(mappedLang)) {
|
| 1944 |
+
// 已知语言,直接高亮
|
| 1945 |
+
highlightedCode = hljs.highlight(codeText, { language: mappedLang, ignoreIllegals: true }).value;
|
| 1946 |
+
} else {
|
| 1947 |
+
// 未知语言或未指定,使用自动检测
|
| 1948 |
+
highlightedCode = hljs.highlightAuto(codeText).value;
|
| 1949 |
+
}
|
| 1950 |
+
} else {
|
| 1951 |
+
highlightedCode = escapeHtml(codeText);
|
| 1952 |
+
}
|
| 1953 |
+
} catch (e) {
|
| 1954 |
+
console.warn('代码高亮失败:', e);
|
| 1955 |
+
highlightedCode = escapeHtml(codeText);
|
| 1956 |
+
}
|
| 1957 |
+
|
| 1958 |
+
return `
|
| 1959 |
+
<div class="code-block-wrapper">
|
| 1960 |
+
<div class="code-block-header">
|
| 1961 |
+
<span class="code-block-lang">${escapeHtml(displayLang)}</span>
|
| 1962 |
+
<button class="code-copy-btn" data-code-id="${codeId}" title="复制代码">
|
| 1963 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 1964 |
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
| 1965 |
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
| 1966 |
+
</svg>
|
| 1967 |
+
</button>
|
| 1968 |
+
</div>
|
| 1969 |
+
<pre><code id="${codeId}" class="hljs language-${mappedLang || 'plaintext'}">${highlightedCode}</code></pre>
|
| 1970 |
+
</div>`;
|
| 1971 |
+
};
|
| 1972 |
+
|
| 1973 |
+
renderer.heading = (text, level) => {
|
| 1974 |
+
if (level === 2 || level === 3) {
|
| 1975 |
+
return `<h3>${text}</h3>`;
|
| 1976 |
+
}
|
| 1977 |
+
return `<h${level}>${text}</h${level}>`;
|
| 1978 |
+
};
|
| 1979 |
+
|
| 1980 |
+
renderer.table = (header, body) => {
|
| 1981 |
+
// Parse table HTML safely using DOM to avoid regex edge cases
|
| 1982 |
+
const temp = document.createElement('div');
|
| 1983 |
+
temp.innerHTML = `<table><thead>${header}</thead><tbody>${body}</tbody></table>`;
|
| 1984 |
+
const rows = Array.from(temp.querySelectorAll('tr'));
|
| 1985 |
+
const isTwoCol = rows.length > 0 && rows.every(r => r.querySelectorAll('td').length === 2);
|
| 1986 |
+
const headerText = (() => {
|
| 1987 |
+
const firstTh = temp.querySelector('th');
|
| 1988 |
+
return firstTh ? stripTags(firstTh.innerHTML).trim() : '';
|
| 1989 |
+
})();
|
| 1990 |
+
|
| 1991 |
+
if (isTwoCol) {
|
| 1992 |
+
const pairRows = rows.map(row => {
|
| 1993 |
+
const cells = row.querySelectorAll('td');
|
| 1994 |
+
const leftRaw = (cells[0]?.innerHTML || '').trim();
|
| 1995 |
+
const rightRaw = (cells[1]?.innerHTML || '').trim();
|
| 1996 |
+
|
| 1997 |
+
// Prefer the original code text stored in placeholders (when we swapped it out before parsing)
|
| 1998 |
+
let leftContent = escapeHtml(stripTags(leftRaw));
|
| 1999 |
+
const placeholderKey = Object.keys(placeholders).find(k => leftRaw.includes(k));
|
| 2000 |
+
if (placeholderKey) {
|
| 2001 |
+
leftContent = placeholders[placeholderKey];
|
| 2002 |
+
} else {
|
| 2003 |
+
// Fallback: match stripped placeholder text like TABLE_CODE_0_0 even if markdown removed underscores
|
| 2004 |
+
const match = leftRaw.match(/TABLE_CODE_(\\d+)_(\\d+)/);
|
| 2005 |
+
if (match) {
|
| 2006 |
+
const fallbackKey = `__TABLE_CODE_${match[1]}_${match[2]}__`;
|
| 2007 |
+
if (placeholders[fallbackKey]) {
|
| 2008 |
+
leftContent = placeholders[fallbackKey];
|
| 2009 |
+
}
|
| 2010 |
+
}
|
| 2011 |
+
}
|
| 2012 |
+
|
| 2013 |
+
return `
|
| 2014 |
+
<div class="pair-row">
|
| 2015 |
+
<code class="pair-code">${leftContent}</code>
|
| 2016 |
+
<span class="pair-desc">${rightRaw}</span>
|
| 2017 |
+
</div>`;
|
| 2018 |
+
}).join('');
|
| 2019 |
+
|
| 2020 |
+
const titleHtml = headerText ? `<div class="pair-title">${escapeHtml(headerText)}</div>` : '';
|
| 2021 |
+
return `<div class="code-pairs">${titleHtml}${pairRows}</div>`;
|
| 2022 |
+
}
|
| 2023 |
+
|
| 2024 |
+
return `<table><thead>${header}</thead><tbody>${body}</tbody></table>`;
|
| 2025 |
+
};
|
| 2026 |
+
|
| 2027 |
+
marked.setOptions({
|
| 2028 |
+
gfm: true,
|
| 2029 |
+
breaks: true,
|
| 2030 |
+
renderer,
|
| 2031 |
+
mangle: false,
|
| 2032 |
+
headerIds: false
|
| 2033 |
+
});
|
| 2034 |
+
|
| 2035 |
+
const rawHtml = marked.parse(safeContent);
|
| 2036 |
+
const withCode = Object.keys(placeholders).reduce((acc, key) => {
|
| 2037 |
+
const html = `<pre><code>${placeholders[key]}</code></pre>`;
|
| 2038 |
+
return acc.replace(new RegExp(key.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'), 'g'), html);
|
| 2039 |
+
}, rawHtml);
|
| 2040 |
+
const cleanHtml = DOMPurify.sanitize(withCode, {
|
| 2041 |
+
ALLOWED_TAGS: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'code', 'pre', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'div', 'span', 'button', 'svg', 'path', 'rect', 'circle', 'line', 'polyline', 'polygon', 'a', 'blockquote'],
|
| 2042 |
+
ALLOWED_ATTR: ['class', 'id', 'width', 'height', 'viewBox', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'fill-rule', 'clip-rule', 'd', 'aria-hidden', 'focusable', 'data-code-id', 'x', 'y', 'rx', 'ry', 'cx', 'cy', 'r', 'x1', 'y1', 'x2', 'y2', 'points', 'title']
|
| 2043 |
+
});
|
| 2044 |
+
|
| 2045 |
+
renderLog('output', cleanHtml);
|
| 2046 |
+
return cleanHtml;
|
| 2047 |
+
}
|
| 2048 |
+
|
| 2049 |
+
// 流式输出打字效果
|
| 2050 |
+
async function typeWriter(element, text, speed = 20) {
|
| 2051 |
+
let index = 0;
|
| 2052 |
+
let displayedText = '';
|
| 2053 |
+
|
| 2054 |
+
return new Promise((resolve) => {
|
| 2055 |
+
function type() {
|
| 2056 |
+
if (index < text.length) {
|
| 2057 |
+
// 每次追加 1-3 个字符,模拟更自然的打字节奏
|
| 2058 |
+
const charsToAdd = Math.min(Math.floor(Math.random() * 3) + 1, text.length - index);
|
| 2059 |
+
displayedText += text.slice(index, index + charsToAdd);
|
| 2060 |
+
index += charsToAdd;
|
| 2061 |
+
element.innerHTML = formatContent(displayedText);
|
| 2062 |
+
|
| 2063 |
+
// 滚动到底部
|
| 2064 |
+
const mainContent = document.getElementById('main-content');
|
| 2065 |
+
mainContent.scrollTop = mainContent.scrollHeight;
|
| 2066 |
+
|
| 2067 |
+
// 随机延��,模拟真实输入
|
| 2068 |
+
const delay = speed + Math.random() * 20;
|
| 2069 |
+
setTimeout(type, delay);
|
| 2070 |
+
} else {
|
| 2071 |
+
resolve();
|
| 2072 |
+
}
|
| 2073 |
+
}
|
| 2074 |
+
type();
|
| 2075 |
+
});
|
| 2076 |
+
}
|
| 2077 |
+
|
| 2078 |
+
// 创建推理过程块
|
| 2079 |
+
function createThinkingBlock(thinkingContent, isCollapsed = false) {
|
| 2080 |
+
const parts = thinkingContent.split('\n\n').filter(p => p.trim());
|
| 2081 |
+
const itemsHtml = parts.map(part => `<div class="thinking-item">${escapeHtml(part.trim())}</div>`).join('');
|
| 2082 |
+
const collapsedClass = isCollapsed ? ' collapsed' : '';
|
| 2083 |
+
const headerText = isCollapsed ? '显示推理过程' : '隐藏推理过程';
|
| 2084 |
+
|
| 2085 |
+
return `
|
| 2086 |
+
<div class="thinking-block${collapsedClass}">
|
| 2087 |
+
<div class="thinking-header" onclick="toggleThinking(this)">
|
| 2088 |
+
<span class="thinking-title">${headerText}</span>
|
| 2089 |
+
<svg class="thinking-icon" width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
| 2090 |
+
<path d="M2 4l4 4 4-4"/>
|
| 2091 |
+
</svg>
|
| 2092 |
+
</div>
|
| 2093 |
+
<div class="thinking-content">
|
| 2094 |
+
${itemsHtml}
|
| 2095 |
+
<div class="thinking-note">推理细节目前仅支持英文</div>
|
| 2096 |
+
</div>
|
| 2097 |
+
</div>
|
| 2098 |
+
`;
|
| 2099 |
+
}
|
| 2100 |
+
|
| 2101 |
+
// 切换推理折叠状态
|
| 2102 |
+
function toggleThinking(header) {
|
| 2103 |
+
const block = header.parentElement;
|
| 2104 |
+
block.classList.toggle('collapsed');
|
| 2105 |
+
// 更新标题文字
|
| 2106 |
+
const titleSpan = header.querySelector('.thinking-title');
|
| 2107 |
+
if (block.classList.contains('collapsed')) {
|
| 2108 |
+
titleSpan.textContent = '显示推理过程';
|
| 2109 |
+
} else {
|
| 2110 |
+
titleSpan.textContent = '隐藏推理过程';
|
| 2111 |
+
}
|
| 2112 |
+
}
|
| 2113 |
+
|
| 2114 |
+
function addMessageToDOM(role, content, images = [], generatedImages = []) {
|
| 2115 |
+
const container = document.getElementById('chat-container');
|
| 2116 |
+
const messageDiv = document.createElement('div');
|
| 2117 |
+
messageDiv.className = `message message-${role}`;
|
| 2118 |
+
|
| 2119 |
+
if (role === 'user') {
|
| 2120 |
+
// 构建图片 HTML
|
| 2121 |
+
const imagesHtml = images.length > 0 ? `
|
| 2122 |
+
<div class="message-images">
|
| 2123 |
+
${images.map(img => `<img src="${img}" class="message-image" onclick="openLightbox('${img}')">`).join('')}
|
| 2124 |
+
</div>
|
| 2125 |
+
` : '';
|
| 2126 |
+
|
| 2127 |
+
messageDiv.innerHTML = `
|
| 2128 |
+
<div class="message-content">
|
| 2129 |
+
${imagesHtml}
|
| 2130 |
+
${escapeHtml(content)}
|
| 2131 |
+
</div>
|
| 2132 |
+
`;
|
| 2133 |
+
} else {
|
| 2134 |
+
// 构建生成图片 HTML
|
| 2135 |
+
const generatedImagesHtml = generatedImages.length > 0 ? `
|
| 2136 |
+
<div class="generated-images">
|
| 2137 |
+
${generatedImages.map((img, idx) => `
|
| 2138 |
+
<div class="generated-image-item">
|
| 2139 |
+
<img src="data:${img.mime_type || 'image/png'};base64,${img.base64_data}"
|
| 2140 |
+
alt="AI生成图片"
|
| 2141 |
+
onclick="openLightbox(this.src)">
|
| 2142 |
+
<div class="generated-image-actions">
|
| 2143 |
+
<button class="generated-image-btn" onclick="downloadGeneratedImage('${img.base64_data}', '${img.file_name || 'generated_' + idx + '.png'}', '${img.mime_type || 'image/png'}')" title="下载">
|
| 2144 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 2145 |
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
| 2146 |
+
<polyline points="7 10 12 15 17 10"/>
|
| 2147 |
+
<line x1="12" y1="15" x2="12" y2="3"/>
|
| 2148 |
+
</svg>
|
| 2149 |
+
</button>
|
| 2150 |
+
</div>
|
| 2151 |
+
</div>
|
| 2152 |
+
`).join('')}
|
| 2153 |
+
</div>
|
| 2154 |
+
` : '';
|
| 2155 |
+
|
| 2156 |
+
messageDiv.innerHTML = `
|
| 2157 |
+
<div class="avatar-assistant">G</div>
|
| 2158 |
+
<div class="assistant-content" style="flex:1; overflow:hidden;">
|
| 2159 |
+
<div class="thinking-container"></div>
|
| 2160 |
+
<div class="message-content">${formatContent(content)}</div>
|
| 2161 |
+
<div class="generated-images-container">${generatedImagesHtml}</div>
|
| 2162 |
+
</div>
|
| 2163 |
+
`;
|
| 2164 |
+
}
|
| 2165 |
+
|
| 2166 |
+
container.appendChild(messageDiv);
|
| 2167 |
+
return messageDiv;
|
| 2168 |
+
}
|
| 2169 |
+
|
| 2170 |
+
async function sendMessage() {
|
| 2171 |
+
const input = document.getElementById('user-input');
|
| 2172 |
+
let content = input.value.trim();
|
| 2173 |
+
|
| 2174 |
+
// 需要有文字或图片才能发送
|
| 2175 |
+
if ((!content && pendingImages.length === 0) || isGenerating) return;
|
| 2176 |
+
|
| 2177 |
+
const startTime = Date.now(); // 记录开始时间
|
| 2178 |
+
|
| 2179 |
+
// 保存当前待发送的图片
|
| 2180 |
+
const imagesToSend = [...pendingImages];
|
| 2181 |
+
const imageDataUrls = imagesToSend.map(img => img.dataUrl);
|
| 2182 |
+
|
| 2183 |
+
// 如果是图片生成模式,添加提示词前缀
|
| 2184 |
+
const wasImageGenMode = isImageGenMode;
|
| 2185 |
+
let displayContent = content; // 用于显示的内容
|
| 2186 |
+
if (isImageGenMode && content) {
|
| 2187 |
+
content = '你生成一张图片给我,下面是关于需要生成图片的描述:\n' + content;
|
| 2188 |
+
}
|
| 2189 |
+
|
| 2190 |
+
document.body.classList.add('has-chat');
|
| 2191 |
+
isGenerating = true;
|
| 2192 |
+
document.getElementById('send-btn').disabled = true;
|
| 2193 |
+
input.value = '';
|
| 2194 |
+
input.style.height = 'auto';
|
| 2195 |
+
clearPendingImages();
|
| 2196 |
+
resetImageGenMode(); // 重置图片生成模式
|
| 2197 |
+
updateSendButton();
|
| 2198 |
+
|
| 2199 |
+
// 构建消息内容(OpenAI 多模态格式)
|
| 2200 |
+
let messageContent;
|
| 2201 |
+
if (imagesToSend.length > 0) {
|
| 2202 |
+
// 多模态消息
|
| 2203 |
+
messageContent = [];
|
| 2204 |
+
// 添加图片
|
| 2205 |
+
for (const img of imagesToSend) {
|
| 2206 |
+
messageContent.push({
|
| 2207 |
+
type: 'image_url',
|
| 2208 |
+
image_url: { url: img.dataUrl }
|
| 2209 |
+
});
|
| 2210 |
+
}
|
| 2211 |
+
// 添加文本(如果有)
|
| 2212 |
+
if (content) {
|
| 2213 |
+
messageContent.push({
|
| 2214 |
+
type: 'text',
|
| 2215 |
+
text: content
|
| 2216 |
+
});
|
| 2217 |
+
}
|
| 2218 |
+
} else {
|
| 2219 |
+
// 纯文本消息
|
| 2220 |
+
messageContent = content;
|
| 2221 |
+
}
|
| 2222 |
+
|
| 2223 |
+
// Add user message to local state (仅保存文本用于显示)
|
| 2224 |
+
messages.push({ role: 'user', content: messageContent });
|
| 2225 |
+
// 显示时使用原始内容(不带前缀),发送时使用带前缀的内容
|
| 2226 |
+
addMessageToDOM('user', wasImageGenMode ? displayContent : content, imageDataUrls);
|
| 2227 |
+
|
| 2228 |
+
// Add assistant placeholder
|
| 2229 |
+
const assistantDiv = addMessageToDOM('assistant', '');
|
| 2230 |
+
const thinkingContainer = assistantDiv.querySelector('.thinking-container');
|
| 2231 |
+
const contentDiv = assistantDiv.querySelector('.message-content');
|
| 2232 |
+
contentDiv.innerHTML = '<div class="loading-dots"><span></span><span></span><span></span></div>';
|
| 2233 |
+
|
| 2234 |
+
// Scroll to bottom
|
| 2235 |
+
const mainContent = document.getElementById('main-content');
|
| 2236 |
+
mainContent.scrollTop = mainContent.scrollHeight;
|
| 2237 |
+
|
| 2238 |
+
try {
|
| 2239 |
+
const response = await fetch('/v1/chat/completions', {
|
| 2240 |
+
method: 'POST',
|
| 2241 |
+
headers: { 'Content-Type': 'application/json' },
|
| 2242 |
+
body: JSON.stringify({
|
| 2243 |
+
model: currentModel,
|
| 2244 |
+
messages: messages,
|
| 2245 |
+
stream: true,
|
| 2246 |
+
chat_id: currentChatId
|
| 2247 |
+
})
|
| 2248 |
+
});
|
| 2249 |
+
|
| 2250 |
+
if (!response.ok) {
|
| 2251 |
+
throw new Error(`HTTP ${response.status}`);
|
| 2252 |
+
}
|
| 2253 |
+
|
| 2254 |
+
const reader = response.body.getReader();
|
| 2255 |
+
const decoder = new TextDecoder();
|
| 2256 |
+
let fullContent = '';
|
| 2257 |
+
let isFirstChunk = true;
|
| 2258 |
+
let collectedContent = ''; // 累积正文内容
|
| 2259 |
+
let collectedThinking = ''; // 累积推理内容
|
| 2260 |
+
let collectedImages = []; // 累积生成的图片
|
| 2261 |
+
let extractedTitle = null;
|
| 2262 |
+
let buffer = ''; // 用于处理跨 chunk 的数据
|
| 2263 |
+
|
| 2264 |
+
contentDiv.innerHTML = '';
|
| 2265 |
+
|
| 2266 |
+
while (true) {
|
| 2267 |
+
const { done, value } = await reader.read();
|
| 2268 |
+
console.log('SSE read:', done ? 'done' : `${value?.length} bytes`);
|
| 2269 |
+
if (done) break;
|
| 2270 |
+
|
| 2271 |
+
buffer += decoder.decode(value, { stream: true });
|
| 2272 |
+
const lines = buffer.split('\n');
|
| 2273 |
+
|
| 2274 |
+
// 保留最后一个可能不完整的行
|
| 2275 |
+
buffer = lines.pop() || '';
|
| 2276 |
+
|
| 2277 |
+
for (const line of lines) {
|
| 2278 |
+
if (line.startsWith('data: ') && line !== 'data: [DONE]') {
|
| 2279 |
+
try {
|
| 2280 |
+
const data = JSON.parse(line.slice(6));
|
| 2281 |
+
console.log('Parsed SSE data keys:', Object.keys(data));
|
| 2282 |
+
|
| 2283 |
+
// Handle first chunk with chat info
|
| 2284 |
+
if (isFirstChunk && data.chat_id) {
|
| 2285 |
+
currentChatId = data.chat_id;
|
| 2286 |
+
|
| 2287 |
+
if (data.is_new) {
|
| 2288 |
+
// Add to chat list
|
| 2289 |
+
chatList.unshift({
|
| 2290 |
+
id: data.chat_id,
|
| 2291 |
+
title: data.title,
|
| 2292 |
+
model: currentModel,
|
| 2293 |
+
updated_at: new Date().toISOString()
|
| 2294 |
+
});
|
| 2295 |
+
renderChatList();
|
| 2296 |
+
}
|
| 2297 |
+
|
| 2298 |
+
// 保存当前对话 ID
|
| 2299 |
+
saveCurrentChat();
|
| 2300 |
+
|
| 2301 |
+
document.getElementById('current-chat-title').textContent = data.title;
|
| 2302 |
+
isFirstChunk = false;
|
| 2303 |
+
}
|
| 2304 |
+
|
| 2305 |
+
// Handle delta
|
| 2306 |
+
const delta = data.choices?.[0]?.delta;
|
| 2307 |
+
if (delta) {
|
| 2308 |
+
console.log('Delta keys:', Object.keys(delta));
|
| 2309 |
+
}
|
| 2310 |
+
|
| 2311 |
+
// 累积推理内容
|
| 2312 |
+
if (delta?.thinking) {
|
| 2313 |
+
collectedThinking = delta.thinking;
|
| 2314 |
+
}
|
| 2315 |
+
|
| 2316 |
+
// 累积正文内容
|
| 2317 |
+
if (delta?.content) {
|
| 2318 |
+
collectedContent += delta.content;
|
| 2319 |
+
}
|
| 2320 |
+
|
| 2321 |
+
// 累积生成的图片
|
| 2322 |
+
if (delta?.images && Array.isArray(delta.images)) {
|
| 2323 |
+
collectedImages.push(...delta.images);
|
| 2324 |
+
console.log('收到图片数据:', delta.images.length, '张, 总计:', collectedImages.length);
|
| 2325 |
+
}
|
| 2326 |
+
|
| 2327 |
+
// Handle title update from extracted_title
|
| 2328 |
+
if (delta?.extracted_title) {
|
| 2329 |
+
extractedTitle = delta.extracted_title;
|
| 2330 |
+
}
|
| 2331 |
+
} catch (e) {
|
| 2332 |
+
// Ignore parse errors
|
| 2333 |
+
console.debug('SSE parse error:', e, line);
|
| 2334 |
+
}
|
| 2335 |
+
}
|
| 2336 |
+
}
|
| 2337 |
+
}
|
| 2338 |
+
|
| 2339 |
+
// 处理 buffer 中可能残留的最后一行数据
|
| 2340 |
+
if (buffer.startsWith('data: ') && buffer !== 'data: [DONE]') {
|
| 2341 |
+
try {
|
| 2342 |
+
const data = JSON.parse(buffer.slice(6));
|
| 2343 |
+
const delta = data.choices?.[0]?.delta;
|
| 2344 |
+
if (delta?.images && Array.isArray(delta.images)) {
|
| 2345 |
+
collectedImages.push(...delta.images);
|
| 2346 |
+
console.log('从 buffer 残留数据中解析到图片:', delta.images.length);
|
| 2347 |
+
}
|
| 2348 |
+
} catch (e) {
|
| 2349 |
+
console.debug('Final buffer parse error:', e);
|
| 2350 |
+
}
|
| 2351 |
+
}
|
| 2352 |
+
|
| 2353 |
+
// 计算推理时长
|
| 2354 |
+
const thinkingTime = Math.round((Date.now() - startTime) / 1000);
|
| 2355 |
+
|
| 2356 |
+
// 显示推理过程(如果有)
|
| 2357 |
+
if (collectedThinking) {
|
| 2358 |
+
// 新消息默认展开 (isCollapsed = false)
|
| 2359 |
+
thinkingContainer.innerHTML = createThinkingBlock(collectedThinking, false);
|
| 2360 |
+
// 滚动到底部
|
| 2361 |
+
mainContent.scrollTop = mainContent.scrollHeight;
|
| 2362 |
+
|
| 2363 |
+
// 短暂延迟后折叠推理
|
| 2364 |
+
setTimeout(() => {
|
| 2365 |
+
const block = thinkingContainer.querySelector('.thinking-block');
|
| 2366 |
+
if (block) {
|
| 2367 |
+
block.classList.add('collapsed');
|
| 2368 |
+
// 更新标题文字
|
| 2369 |
+
const titleSpan = block.querySelector('.thinking-title');
|
| 2370 |
+
if (titleSpan) {
|
| 2371 |
+
titleSpan.textContent = '显示推理过程';
|
| 2372 |
+
}
|
| 2373 |
+
}
|
| 2374 |
+
}, 1500);
|
| 2375 |
+
}
|
| 2376 |
+
|
| 2377 |
+
// 更新标题
|
| 2378 |
+
if (extractedTitle) {
|
| 2379 |
+
document.getElementById('current-chat-title').textContent = extractedTitle;
|
| 2380 |
+
const chatItem = chatList.find(c => c.id === currentChatId);
|
| 2381 |
+
if (chatItem) {
|
| 2382 |
+
chatItem.title = extractedTitle;
|
| 2383 |
+
renderChatList();
|
| 2384 |
+
}
|
| 2385 |
+
}
|
| 2386 |
+
|
| 2387 |
+
// 流式输出收集到的内容
|
| 2388 |
+
if (collectedContent) {
|
| 2389 |
+
await typeWriter(contentDiv, collectedContent, 15);
|
| 2390 |
+
fullContent = collectedContent;
|
| 2391 |
+
}
|
| 2392 |
+
|
| 2393 |
+
// 显示生成的图片
|
| 2394 |
+
console.log('收集到的图片数量:', collectedImages.length);
|
| 2395 |
+
if (collectedImages.length > 0) {
|
| 2396 |
+
console.log('准备显示图片:', collectedImages);
|
| 2397 |
+
const imagesContainer = assistantDiv.querySelector('.generated-images-container');
|
| 2398 |
+
console.log('图片容器:', imagesContainer);
|
| 2399 |
+
if (imagesContainer) {
|
| 2400 |
+
const imagesHtml = `
|
| 2401 |
+
<div class="generated-images">
|
| 2402 |
+
${collectedImages.map((img, idx) => `
|
| 2403 |
+
<div class="generated-image-item">
|
| 2404 |
+
<img src="data:${img.mime_type || 'image/png'};base64,${img.base64_data}"
|
| 2405 |
+
alt="AI生成图片"
|
| 2406 |
+
onclick="openLightbox(this.src)">
|
| 2407 |
+
<div class="generated-image-actions">
|
| 2408 |
+
<button class="generated-image-btn" onclick="downloadGeneratedImage('${img.base64_data}', '${img.file_name || 'generated_' + idx + '.png'}', '${img.mime_type || 'image/png'}')" title="下载">
|
| 2409 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 2410 |
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
| 2411 |
+
<polyline points="7 10 12 15 17 10"/>
|
| 2412 |
+
<line x1="12" y1="15" x2="12" y2="3"/>
|
| 2413 |
+
</svg>
|
| 2414 |
+
</button>
|
| 2415 |
+
</div>
|
| 2416 |
+
</div>
|
| 2417 |
+
`).join('')}
|
| 2418 |
+
</div>
|
| 2419 |
+
`;
|
| 2420 |
+
imagesContainer.innerHTML = imagesHtml;
|
| 2421 |
+
console.log('图片 HTML 已设置');
|
| 2422 |
+
// 滚动到底部显示图片
|
| 2423 |
+
mainContent.scrollTop = mainContent.scrollHeight;
|
| 2424 |
+
}
|
| 2425 |
+
}
|
| 2426 |
+
|
| 2427 |
+
messages.push({ role: 'assistant', content: fullContent });
|
| 2428 |
+
|
| 2429 |
+
} catch (error) {
|
| 2430 |
+
contentDiv.innerHTML = `<span style="color: #ef4444;">Error: ${error.message}</span>`;
|
| 2431 |
+
}
|
| 2432 |
+
|
| 2433 |
+
isGenerating = false;
|
| 2434 |
+
document.getElementById('send-btn').disabled = false;
|
| 2435 |
+
input.focus();
|
| 2436 |
+
}
|
| 2437 |
+
</script>
|
| 2438 |
+
</body>
|
| 2439 |
+
|
| 2440 |
+
</html>
|