heixxin commited on
Commit
793a36a
·
verified ·
1 Parent(s): c24f7b4

Upload 6 files

Browse files
Files changed (6) hide show
  1. .env +12 -0
  2. Dockerfile +16 -0
  3. docker-compose.yml +41 -0
  4. main.py +1226 -0
  5. requirements.txt +7 -0
  6. 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, '&lt;').replace(/>/g, '&gt;');
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>