Spaces:
Runtime error
Runtime error
Upload 88 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +1 -0
- AIStudioProxyAPI/.dockerignore +14 -0
- AIStudioProxyAPI/.env.example +196 -0
- AIStudioProxyAPI/.gitignore +251 -0
- AIStudioProxyAPI/LICENSE +661 -0
- AIStudioProxyAPI/README.md +379 -0
- AIStudioProxyAPI/api_utils/__init__.py +78 -0
- AIStudioProxyAPI/api_utils/app.py +328 -0
- AIStudioProxyAPI/api_utils/auth_utils.py +32 -0
- AIStudioProxyAPI/api_utils/dependencies.py +57 -0
- AIStudioProxyAPI/api_utils/queue_worker.py +351 -0
- AIStudioProxyAPI/api_utils/request_processor.py +884 -0
- AIStudioProxyAPI/api_utils/request_processor_backup.py +274 -0
- AIStudioProxyAPI/api_utils/routes.py +385 -0
- AIStudioProxyAPI/api_utils/utils.py +428 -0
- AIStudioProxyAPI/auth_profiles/active/.gitkeep +0 -0
- AIStudioProxyAPI/auth_profiles/saved/.gitkeep +0 -0
- AIStudioProxyAPI/browser_utils/__init__.py +56 -0
- AIStudioProxyAPI/browser_utils/initialization.py +669 -0
- AIStudioProxyAPI/browser_utils/model_management.py +619 -0
- AIStudioProxyAPI/browser_utils/more_modles.js +393 -0
- AIStudioProxyAPI/browser_utils/operations.py +783 -0
- AIStudioProxyAPI/browser_utils/page_controller.py +914 -0
- AIStudioProxyAPI/browser_utils/script_manager.py +183 -0
- AIStudioProxyAPI/certs/ca.crt +21 -0
- AIStudioProxyAPI/certs/ca.key +28 -0
- AIStudioProxyAPI/config/__init__.py +90 -0
- AIStudioProxyAPI/config/constants.py +53 -0
- AIStudioProxyAPI/config/selectors.py +49 -0
- AIStudioProxyAPI/config/settings.py +54 -0
- AIStudioProxyAPI/config/timeouts.py +40 -0
- AIStudioProxyAPI/deprecated_javascript_version/README.md +233 -0
- AIStudioProxyAPI/deprecated_javascript_version/auto_connect_aistudio.cjs +595 -0
- AIStudioProxyAPI/deprecated_javascript_version/package.json +8 -0
- AIStudioProxyAPI/deprecated_javascript_version/server.cjs +1505 -0
- AIStudioProxyAPI/deprecated_javascript_version/test.js +126 -0
- AIStudioProxyAPI/docker/.env.docker +150 -0
- AIStudioProxyAPI/docker/Dockerfile +116 -0
- AIStudioProxyAPI/docker/README-Docker.md +456 -0
- AIStudioProxyAPI/docker/README.md +77 -0
- AIStudioProxyAPI/docker/SCRIPT_INJECTION_DOCKER.md +209 -0
- AIStudioProxyAPI/docker/docker-compose.yml +56 -0
- AIStudioProxyAPI/docker/update.sh +30 -0
- AIStudioProxyAPI/docs/advanced-configuration.md +356 -0
- AIStudioProxyAPI/docs/api-usage.md +415 -0
- AIStudioProxyAPI/docs/architecture-guide.md +259 -0
- AIStudioProxyAPI/docs/authentication-setup.md +81 -0
- AIStudioProxyAPI/docs/daily-usage.md +199 -0
- AIStudioProxyAPI/docs/dependency-versions.md +284 -0
- AIStudioProxyAPI/docs/development-guide.md +352 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
AIStudioProxyAPI/支持作者.jpg filter=lfs diff=lfs merge=lfs -text
|
AIStudioProxyAPI/.dockerignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.git/
|
| 4 |
+
.gitignore
|
| 5 |
+
.vscode/
|
| 6 |
+
deprecated_javascript_version/
|
| 7 |
+
memory-bank/
|
| 8 |
+
*.log
|
| 9 |
+
*.DS_Store
|
| 10 |
+
venv/
|
| 11 |
+
env/
|
| 12 |
+
# auth_profiles/ # Handled by volume mount
|
| 13 |
+
# certs/ # Handled by volume mount or generated in container
|
| 14 |
+
# logs/ # Supervisord logs to stdout/stderr
|
AIStudioProxyAPI/.env.example
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AI Studio Proxy API 配置文件示例
|
| 2 |
+
# 复制此文件为 .env 并根据需要修改配置
|
| 3 |
+
|
| 4 |
+
# =============================================================================
|
| 5 |
+
# 服务端口配置
|
| 6 |
+
# =============================================================================
|
| 7 |
+
|
| 8 |
+
# FastAPI 服务端口
|
| 9 |
+
PORT=2048
|
| 10 |
+
|
| 11 |
+
# GUI 启动器默认端口配置
|
| 12 |
+
DEFAULT_FASTAPI_PORT=2048
|
| 13 |
+
DEFAULT_CAMOUFOX_PORT=9222
|
| 14 |
+
|
| 15 |
+
# 流式代理服务配置
|
| 16 |
+
STREAM_PORT=3120
|
| 17 |
+
# 设置为 0 禁用流式代理服务
|
| 18 |
+
|
| 19 |
+
# =============================================================================
|
| 20 |
+
# 代理配置
|
| 21 |
+
# =============================================================================
|
| 22 |
+
|
| 23 |
+
# HTTP/HTTPS 代理设置
|
| 24 |
+
# HTTP_PROXY=http://127.0.0.1:7890
|
| 25 |
+
# HTTPS_PROXY=http://127.0.0.1:7890
|
| 26 |
+
|
| 27 |
+
# 统一代理配置 (优先级高于 HTTP_PROXY/HTTPS_PROXY)
|
| 28 |
+
UNIFIED_PROXY_CONFIG=http://127.0.0.1:7890
|
| 29 |
+
|
| 30 |
+
# 代理绕过列表 (用分号分隔)
|
| 31 |
+
# NO_PROXY=localhost;127.0.0.1;*.local
|
| 32 |
+
|
| 33 |
+
# =============================================================================
|
| 34 |
+
# 日志配置
|
| 35 |
+
# =============================================================================
|
| 36 |
+
|
| 37 |
+
# 服务器日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
| 38 |
+
SERVER_LOG_LEVEL=INFO
|
| 39 |
+
|
| 40 |
+
# 是否重定向 print 输出到日志
|
| 41 |
+
SERVER_REDIRECT_PRINT=false
|
| 42 |
+
|
| 43 |
+
# 启用调试日志
|
| 44 |
+
DEBUG_LOGS_ENABLED=false
|
| 45 |
+
|
| 46 |
+
# 启用跟踪日志
|
| 47 |
+
TRACE_LOGS_ENABLED=false
|
| 48 |
+
|
| 49 |
+
# =============================================================================
|
| 50 |
+
# 认证配置
|
| 51 |
+
# =============================================================================
|
| 52 |
+
|
| 53 |
+
# 自动保存认证信息
|
| 54 |
+
AUTO_SAVE_AUTH=false
|
| 55 |
+
|
| 56 |
+
# 认证保存超时时间 (秒)
|
| 57 |
+
AUTH_SAVE_TIMEOUT=30
|
| 58 |
+
|
| 59 |
+
# 自动确认登录
|
| 60 |
+
AUTO_CONFIRM_LOGIN=true
|
| 61 |
+
|
| 62 |
+
# =============================================================================
|
| 63 |
+
# 浏览器配置
|
| 64 |
+
# =============================================================================
|
| 65 |
+
|
| 66 |
+
# Camoufox WebSocket 端点
|
| 67 |
+
# CAMOUFOX_WS_ENDPOINT=ws://127.0.0.1:9222
|
| 68 |
+
|
| 69 |
+
# 启动模式 (normal, headless, virtual_display, direct_debug_no_browser)
|
| 70 |
+
LAUNCH_MODE=normal
|
| 71 |
+
|
| 72 |
+
# =============================================================================
|
| 73 |
+
# API 默认参数配置
|
| 74 |
+
# =============================================================================
|
| 75 |
+
|
| 76 |
+
# 默认温度值 (0.0-2.0)
|
| 77 |
+
DEFAULT_TEMPERATURE=1.0
|
| 78 |
+
|
| 79 |
+
# 默认最大输出令牌数
|
| 80 |
+
DEFAULT_MAX_OUTPUT_TOKENS=65536
|
| 81 |
+
|
| 82 |
+
# 默认 Top-P 值 (0.0-1.0)
|
| 83 |
+
DEFAULT_TOP_P=0.95
|
| 84 |
+
|
| 85 |
+
# 默认停止序列 (JSON 数组格式)
|
| 86 |
+
DEFAULT_STOP_SEQUENCES=["用户:"]
|
| 87 |
+
|
| 88 |
+
# 是否在处理请求时自动打开并使用 "URL Context" 功能,此工具功能详情可参考:https://ai.google.dev/gemini-api/docs/url-context
|
| 89 |
+
ENABLE_URL_CONTEXT=false
|
| 90 |
+
|
| 91 |
+
# 是否默认启用 "指定思考预算" 功能 (true/false),不启用时模型一般将自行决定思考预算
|
| 92 |
+
# 当 API 请求中未提供 reasoning_effort 参数时将使用此值。
|
| 93 |
+
ENABLE_THINKING_BUDGET=false
|
| 94 |
+
|
| 95 |
+
# "指定思考预算量" 的默认值 (token)
|
| 96 |
+
# 当 API 请求中未提供 reasoning_effort 参数时,将使用此值。
|
| 97 |
+
DEFAULT_THINKING_BUDGET=8192
|
| 98 |
+
|
| 99 |
+
# 是否默认启用 "Google Search" 功能 (true/false)
|
| 100 |
+
# 当 API 请求中未提供 tools 参数时,将使用此设置作为 Google Search 的默认开关状态。
|
| 101 |
+
ENABLE_GOOGLE_SEARCH=false
|
| 102 |
+
|
| 103 |
+
# =============================================================================
|
| 104 |
+
# 超时配置 (毫秒)
|
| 105 |
+
# =============================================================================
|
| 106 |
+
|
| 107 |
+
# 响应完成总超时时间
|
| 108 |
+
RESPONSE_COMPLETION_TIMEOUT=300000
|
| 109 |
+
|
| 110 |
+
# 初始等待时间
|
| 111 |
+
INITIAL_WAIT_MS_BEFORE_POLLING=500
|
| 112 |
+
|
| 113 |
+
# 轮询间隔
|
| 114 |
+
POLLING_INTERVAL=300
|
| 115 |
+
POLLING_INTERVAL_STREAM=180
|
| 116 |
+
|
| 117 |
+
# 静默超时
|
| 118 |
+
SILENCE_TIMEOUT_MS=60000
|
| 119 |
+
|
| 120 |
+
# 页面操作超时
|
| 121 |
+
POST_SPINNER_CHECK_DELAY_MS=500
|
| 122 |
+
FINAL_STATE_CHECK_TIMEOUT_MS=1500
|
| 123 |
+
POST_COMPLETION_BUFFER=700
|
| 124 |
+
|
| 125 |
+
# 清理聊天相关超时
|
| 126 |
+
CLEAR_CHAT_VERIFY_TIMEOUT_MS=4000
|
| 127 |
+
CLEAR_CHAT_VERIFY_INTERVAL_MS=4000
|
| 128 |
+
|
| 129 |
+
# 点击和剪贴板操作超时
|
| 130 |
+
CLICK_TIMEOUT_MS=3000
|
| 131 |
+
CLIPBOARD_READ_TIMEOUT_MS=3000
|
| 132 |
+
|
| 133 |
+
# 元素等待超时
|
| 134 |
+
WAIT_FOR_ELEMENT_TIMEOUT_MS=10000
|
| 135 |
+
|
| 136 |
+
# 流相关配置
|
| 137 |
+
PSEUDO_STREAM_DELAY=0.01
|
| 138 |
+
|
| 139 |
+
# =============================================================================
|
| 140 |
+
# GUI 启动器配置
|
| 141 |
+
# =============================================================================
|
| 142 |
+
|
| 143 |
+
# GUI 默认代理地址
|
| 144 |
+
GUI_DEFAULT_PROXY_ADDRESS=http://127.0.0.1:7890
|
| 145 |
+
|
| 146 |
+
# GUI 默认流式代理端口
|
| 147 |
+
GUI_DEFAULT_STREAM_PORT=3120
|
| 148 |
+
|
| 149 |
+
# GUI 默认 Helper 端点
|
| 150 |
+
GUI_DEFAULT_HELPER_ENDPOINT=
|
| 151 |
+
|
| 152 |
+
# =============================================================================
|
| 153 |
+
# 脚本注入配置
|
| 154 |
+
# =============================================================================
|
| 155 |
+
|
| 156 |
+
# 是否启用油猴脚本注入功能(已失效)
|
| 157 |
+
ENABLE_SCRIPT_INJECTION=false
|
| 158 |
+
|
| 159 |
+
# 油猴脚本文件路径(相对于项目根目录)
|
| 160 |
+
# 模型数据直接从此脚本文件中解析,无需额��配置文件
|
| 161 |
+
USERSCRIPT_PATH=browser_utils/more_modles.js
|
| 162 |
+
|
| 163 |
+
# =============================================================================
|
| 164 |
+
# 其他配置
|
| 165 |
+
# =============================================================================
|
| 166 |
+
|
| 167 |
+
# 模型名称
|
| 168 |
+
MODEL_NAME=AI-Studio_Proxy_API
|
| 169 |
+
|
| 170 |
+
# 聊天完成 ID 前缀
|
| 171 |
+
CHAT_COMPLETION_ID_PREFIX=chatcmpl-
|
| 172 |
+
|
| 173 |
+
# 默认回退模型 ID
|
| 174 |
+
DEFAULT_FALLBACK_MODEL_ID=no model list
|
| 175 |
+
|
| 176 |
+
# 排除模型文件名
|
| 177 |
+
EXCLUDED_MODELS_FILENAME=excluded_models.txt
|
| 178 |
+
|
| 179 |
+
# AI Studio URL 模式
|
| 180 |
+
AI_STUDIO_URL_PATTERN=aistudio.google.com/
|
| 181 |
+
|
| 182 |
+
# 模型端点 URL 包含字符串
|
| 183 |
+
MODELS_ENDPOINT_URL_CONTAINS=MakerSuiteService/ListModels
|
| 184 |
+
|
| 185 |
+
# 用户输入标记符
|
| 186 |
+
USER_INPUT_START_MARKER_SERVER=__USER_INPUT_START__
|
| 187 |
+
USER_INPUT_END_MARKER_SERVER=__USER_INPUT_END__
|
| 188 |
+
|
| 189 |
+
# =============================================================================
|
| 190 |
+
# 流状态配置
|
| 191 |
+
# =============================================================================
|
| 192 |
+
|
| 193 |
+
# 流超时日志状态配置
|
| 194 |
+
STREAM_MAX_INITIAL_ERRORS=3
|
| 195 |
+
STREAM_WARNING_INTERVAL_AFTER_SUPPRESS=60.0
|
| 196 |
+
STREAM_SUPPRESS_DURATION_AFTER_INITIAL_BURST=400.0
|
AIStudioProxyAPI/.gitignore
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
certs/*
|
| 5 |
+
npm-debug.log*
|
| 6 |
+
yarn-debug.log*
|
| 7 |
+
yarn-error.log*
|
| 8 |
+
pnpm-debug.log*
|
| 9 |
+
lerna-debug.log*
|
| 10 |
+
/upload_images
|
| 11 |
+
|
| 12 |
+
# Diagnostic reports (https://nodejs.org/api/report.html)
|
| 13 |
+
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
| 14 |
+
|
| 15 |
+
# Runtime data
|
| 16 |
+
pids
|
| 17 |
+
*.pid
|
| 18 |
+
*.seed
|
| 19 |
+
*.pid.lock
|
| 20 |
+
|
| 21 |
+
# Directory for instrumented libs generated by jscoverage/JSCover
|
| 22 |
+
lib-cov
|
| 23 |
+
|
| 24 |
+
# Coverage directory used by tools like istanbul
|
| 25 |
+
coverage
|
| 26 |
+
*.lcov
|
| 27 |
+
|
| 28 |
+
# nyc test coverage
|
| 29 |
+
.nyc_output
|
| 30 |
+
|
| 31 |
+
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
| 32 |
+
.grunt
|
| 33 |
+
|
| 34 |
+
# node-waf configuration
|
| 35 |
+
.lock-wscript
|
| 36 |
+
|
| 37 |
+
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
| 38 |
+
build/Release
|
| 39 |
+
|
| 40 |
+
# Dependency directories
|
| 41 |
+
node_modules/
|
| 42 |
+
jspm_packages/
|
| 43 |
+
|
| 44 |
+
# Snowpack dependency directory (https://snowpack.dev/)
|
| 45 |
+
web_modules/
|
| 46 |
+
|
| 47 |
+
# TypeScript cache
|
| 48 |
+
*.tsbuildinfo
|
| 49 |
+
|
| 50 |
+
# Optional npm cache directory
|
| 51 |
+
.npm
|
| 52 |
+
|
| 53 |
+
# Optional eslint cache
|
| 54 |
+
.eslintcache
|
| 55 |
+
|
| 56 |
+
# Optional stylelint cache
|
| 57 |
+
.stylelintcache
|
| 58 |
+
|
| 59 |
+
# Microbundle cache
|
| 60 |
+
.rpt2_cache/
|
| 61 |
+
.rts2_cache_cjs/
|
| 62 |
+
.rts2_cache_es/
|
| 63 |
+
.rts2_cache_umd/
|
| 64 |
+
|
| 65 |
+
# Optional REPL history
|
| 66 |
+
.node_repl_history
|
| 67 |
+
|
| 68 |
+
# Output of 'npm pack'
|
| 69 |
+
*.tgz
|
| 70 |
+
|
| 71 |
+
# Yarn Integrity file
|
| 72 |
+
.yarn-integrity
|
| 73 |
+
|
| 74 |
+
# dotenv environment variables file
|
| 75 |
+
.env
|
| 76 |
+
.env.development.local
|
| 77 |
+
.env.test.local
|
| 78 |
+
.env.production.local
|
| 79 |
+
.env.local
|
| 80 |
+
|
| 81 |
+
# parcel-bundler cache (https://parceljs.org/)
|
| 82 |
+
.cache
|
| 83 |
+
.parcel-cache
|
| 84 |
+
|
| 85 |
+
# Next.js build output
|
| 86 |
+
.next
|
| 87 |
+
out
|
| 88 |
+
|
| 89 |
+
# Nuxt.js build output
|
| 90 |
+
.nuxt
|
| 91 |
+
dist
|
| 92 |
+
|
| 93 |
+
# Gatsby files
|
| 94 |
+
.cache/
|
| 95 |
+
# Comment in the next line if you're using Gatsby Cloud
|
| 96 |
+
# .gatsby/
|
| 97 |
+
public
|
| 98 |
+
|
| 99 |
+
# vuepress build output
|
| 100 |
+
.vuepress/dist
|
| 101 |
+
|
| 102 |
+
# Serverless directories
|
| 103 |
+
.serverless/
|
| 104 |
+
|
| 105 |
+
# FuseBox cache
|
| 106 |
+
.fusebox/
|
| 107 |
+
|
| 108 |
+
# DynamoDB Local files
|
| 109 |
+
.dynamodb/
|
| 110 |
+
|
| 111 |
+
# TernJS port file
|
| 112 |
+
.tern-port
|
| 113 |
+
|
| 114 |
+
# Stores VSCode versions used for testing VSCode extensions
|
| 115 |
+
.vscode-test
|
| 116 |
+
|
| 117 |
+
# macOS files
|
| 118 |
+
.DS_Store
|
| 119 |
+
.AppleDouble
|
| 120 |
+
.LSOverride
|
| 121 |
+
|
| 122 |
+
# Thumbnails
|
| 123 |
+
._*
|
| 124 |
+
|
| 125 |
+
# Files that might appear on external disk
|
| 126 |
+
.Spotlight-V100
|
| 127 |
+
.Trashes
|
| 128 |
+
|
| 129 |
+
# Temporary files created by editors
|
| 130 |
+
*~
|
| 131 |
+
#*.swp
|
| 132 |
+
|
| 133 |
+
# IDE config folders
|
| 134 |
+
.idea/
|
| 135 |
+
.vscode/
|
| 136 |
+
|
| 137 |
+
# Custom
|
| 138 |
+
errors/
|
| 139 |
+
|
| 140 |
+
# Python
|
| 141 |
+
__pycache__/
|
| 142 |
+
*.py[cod]
|
| 143 |
+
*$py.class
|
| 144 |
+
|
| 145 |
+
# Python Libraries
|
| 146 |
+
*.egg-info/
|
| 147 |
+
*.egg
|
| 148 |
+
|
| 149 |
+
# Distribution / packaging
|
| 150 |
+
.Python
|
| 151 |
+
build/
|
| 152 |
+
dist/
|
| 153 |
+
part/
|
| 154 |
+
sdist/
|
| 155 |
+
*.manifest
|
| 156 |
+
*.spec
|
| 157 |
+
wheels/
|
| 158 |
+
|
| 159 |
+
# PyInstaller
|
| 160 |
+
# Usually these files are written by a python script from a template
|
| 161 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 162 |
+
*.manifest
|
| 163 |
+
*.spec
|
| 164 |
+
|
| 165 |
+
# Installer logs
|
| 166 |
+
pip-log.txt
|
| 167 |
+
pip-delete-this-directory.txt
|
| 168 |
+
|
| 169 |
+
# Unit test / coverage reports
|
| 170 |
+
htmlcov/
|
| 171 |
+
.tox/
|
| 172 |
+
.nox/
|
| 173 |
+
.coverage
|
| 174 |
+
.coverage.*
|
| 175 |
+
.cache
|
| 176 |
+
nosetests.xml
|
| 177 |
+
coverage.xml
|
| 178 |
+
*.cover
|
| 179 |
+
*.py,cover
|
| 180 |
+
.hypothesis/
|
| 181 |
+
.pytest_cache/
|
| 182 |
+
|
| 183 |
+
# Environments
|
| 184 |
+
.env
|
| 185 |
+
.venv
|
| 186 |
+
env/
|
| 187 |
+
venv/
|
| 188 |
+
ENV/
|
| 189 |
+
env.bak/
|
| 190 |
+
venv.bak/
|
| 191 |
+
|
| 192 |
+
# Jupyter Notebook
|
| 193 |
+
.ipynb_checkpoints
|
| 194 |
+
profile_default/
|
| 195 |
+
ipython_config.py
|
| 196 |
+
|
| 197 |
+
# pyenv
|
| 198 |
+
.python-version
|
| 199 |
+
|
| 200 |
+
# Celery stuff
|
| 201 |
+
celerybeat-schedule
|
| 202 |
+
celerybeat.pid
|
| 203 |
+
|
| 204 |
+
# SageMath parsed files
|
| 205 |
+
*.sage.py
|
| 206 |
+
|
| 207 |
+
# Environments
|
| 208 |
+
.env
|
| 209 |
+
.venv
|
| 210 |
+
env/
|
| 211 |
+
venv/
|
| 212 |
+
ENV/
|
| 213 |
+
env.bak/
|
| 214 |
+
venv.bak/
|
| 215 |
+
|
| 216 |
+
# Error snapshots directory (Python specific)
|
| 217 |
+
errors_py/
|
| 218 |
+
logs/
|
| 219 |
+
|
| 220 |
+
# Authentication Profiles (Sensitive)
|
| 221 |
+
auth_profiles/active/*
|
| 222 |
+
!auth_profiles/active/.gitkeep
|
| 223 |
+
auth_profiles/saved/*
|
| 224 |
+
!auth_profiles/saved/.gitkeep
|
| 225 |
+
|
| 226 |
+
# Camoufox/Playwright Profile Data (Assume these are generated/temporary)
|
| 227 |
+
camoufox_profile/
|
| 228 |
+
chrome_temp_profile/
|
| 229 |
+
|
| 230 |
+
# Deprecated Javascript Version node_modules
|
| 231 |
+
deprecated_javascript_version/node_modules/
|
| 232 |
+
|
| 233 |
+
.roomodes
|
| 234 |
+
memory-bank/
|
| 235 |
+
gui_config.json
|
| 236 |
+
|
| 237 |
+
# key
|
| 238 |
+
key.txt
|
| 239 |
+
|
| 240 |
+
# 脚本注入相关文件
|
| 241 |
+
# 用户自定义的模型配置文件(保留示例文件)
|
| 242 |
+
browser_utils/model_configs.json
|
| 243 |
+
browser_utils/my_*.json
|
| 244 |
+
# 用户自定义的油猴脚本(如果不是默认的)
|
| 245 |
+
browser_utils/custom_*.js
|
| 246 |
+
browser_utils/my_*.js
|
| 247 |
+
# 临时生成的脚本文件
|
| 248 |
+
browser_utils/generated_*.js
|
| 249 |
+
# Docker 环境的实际配置文件(保留示例文件)
|
| 250 |
+
docker/.env
|
| 251 |
+
docker/my_*.json
|
AIStudioProxyAPI/LICENSE
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
GNU AFFERO GENERAL PUBLIC LICENSE
|
| 2 |
+
Version 3, 19 November 2007
|
| 3 |
+
|
| 4 |
+
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
| 5 |
+
Everyone is permitted to copy and distribute verbatim copies
|
| 6 |
+
of this license document, but changing it is not allowed.
|
| 7 |
+
|
| 8 |
+
Preamble
|
| 9 |
+
|
| 10 |
+
The GNU Affero General Public License is a free, copyleft license for
|
| 11 |
+
software and other kinds of works, specifically designed to ensure
|
| 12 |
+
cooperation with the community in the case of network server software.
|
| 13 |
+
|
| 14 |
+
The licenses for most software and other practical works are designed
|
| 15 |
+
to take away your freedom to share and change the works. By contrast,
|
| 16 |
+
our General Public Licenses are intended to guarantee your freedom to
|
| 17 |
+
share and change all versions of a program--to make sure it remains free
|
| 18 |
+
software for all its users.
|
| 19 |
+
|
| 20 |
+
When we speak of free software, we are referring to freedom, not
|
| 21 |
+
price. Our General Public Licenses are designed to make sure that you
|
| 22 |
+
have the freedom to distribute copies of free software (and charge for
|
| 23 |
+
them if you wish), that you receive source code or can get it if you
|
| 24 |
+
want it, that you can change the software or use pieces of it in new
|
| 25 |
+
free programs, and that you know you can do these things.
|
| 26 |
+
|
| 27 |
+
Developers that use our General Public Licenses protect your rights
|
| 28 |
+
with two steps: (1) assert copyright on the software, and (2) offer
|
| 29 |
+
you this License which gives you legal permission to copy, distribute
|
| 30 |
+
and/or modify the software.
|
| 31 |
+
|
| 32 |
+
A secondary benefit of defending all users' freedom is that
|
| 33 |
+
improvements made in alternate versions of the program, if they
|
| 34 |
+
receive widespread use, become available for other developers to
|
| 35 |
+
incorporate. Many developers of free software are heartened and
|
| 36 |
+
encouraged by the resulting cooperation. However, in the case of
|
| 37 |
+
software used on network servers, this result may fail to come about.
|
| 38 |
+
The GNU General Public License permits making a modified version and
|
| 39 |
+
letting the public access it on a server without ever releasing its
|
| 40 |
+
source code to the public.
|
| 41 |
+
|
| 42 |
+
The GNU Affero General Public License is designed specifically to
|
| 43 |
+
ensure that, in such cases, the modified source code becomes available
|
| 44 |
+
to the community. It requires the operator of a network server to
|
| 45 |
+
provide the source code of the modified version running there to the
|
| 46 |
+
users of that server. Therefore, public use of a modified version, on
|
| 47 |
+
a publicly accessible server, gives the public access to the source
|
| 48 |
+
code of the modified version.
|
| 49 |
+
|
| 50 |
+
An older license, called the Affero General Public License and
|
| 51 |
+
published by Affero, was designed to accomplish similar goals. This is
|
| 52 |
+
a different license, not a version of the Affero GPL, but Affero has
|
| 53 |
+
released a new version of the Affero GPL which permits relicensing under
|
| 54 |
+
this license.
|
| 55 |
+
|
| 56 |
+
The precise terms and conditions for copying, distribution and
|
| 57 |
+
modification follow.
|
| 58 |
+
|
| 59 |
+
TERMS AND CONDITIONS
|
| 60 |
+
|
| 61 |
+
0. Definitions.
|
| 62 |
+
|
| 63 |
+
"This License" refers to version 3 of the GNU Affero General Public License.
|
| 64 |
+
|
| 65 |
+
"Copyright" also means copyright-like laws that apply to other kinds of
|
| 66 |
+
works, such as semiconductor masks.
|
| 67 |
+
|
| 68 |
+
"The Program" refers to any copyrightable work licensed under this
|
| 69 |
+
License. Each licensee is addressed as "you". "Licensees" and
|
| 70 |
+
"recipients" may be individuals or organizations.
|
| 71 |
+
|
| 72 |
+
To "modify" a work means to copy from or adapt all or part of the work
|
| 73 |
+
in a fashion requiring copyright permission, other than the making of an
|
| 74 |
+
exact copy. The resulting work is called a "modified version" of the
|
| 75 |
+
earlier work or a work "based on" the earlier work.
|
| 76 |
+
|
| 77 |
+
A "covered work" means either the unmodified Program or a work based
|
| 78 |
+
on the Program.
|
| 79 |
+
|
| 80 |
+
To "propagate" a work means to do anything with it that, without
|
| 81 |
+
permission, would make you directly or secondarily liable for
|
| 82 |
+
infringement under applicable copyright law, except executing it on a
|
| 83 |
+
computer or modifying a private copy. Propagation includes copying,
|
| 84 |
+
distribution (with or without modification), making available to the
|
| 85 |
+
public, and in some countries other activities as well.
|
| 86 |
+
|
| 87 |
+
To "convey" a work means any kind of propagation that enables other
|
| 88 |
+
parties to make or receive copies. Mere interaction with a user through
|
| 89 |
+
a computer network, with no transfer of a copy, is not conveying.
|
| 90 |
+
|
| 91 |
+
An interactive user interface displays "Appropriate Legal Notices"
|
| 92 |
+
to the extent that it includes a convenient and prominently visible
|
| 93 |
+
feature that (1) displays an appropriate copyright notice, and (2)
|
| 94 |
+
tells the user that there is no warranty for the work (except to the
|
| 95 |
+
extent that warranties are provided), that licensees may convey the
|
| 96 |
+
work under this License, and how to view a copy of this License. If
|
| 97 |
+
the interface presents a list of user commands or options, such as a
|
| 98 |
+
menu, a prominent item in the list meets this criterion.
|
| 99 |
+
|
| 100 |
+
1. Source Code.
|
| 101 |
+
|
| 102 |
+
The "source code" for a work means the preferred form of the work
|
| 103 |
+
for making modifications to it. "Object code" means any non-source
|
| 104 |
+
form of a work.
|
| 105 |
+
|
| 106 |
+
A "Standard Interface" means an interface that either is an official
|
| 107 |
+
standard defined by a recognized standards body, or, in the case of
|
| 108 |
+
interfaces specified for a particular programming language, one that
|
| 109 |
+
is widely used among developers working in that language.
|
| 110 |
+
|
| 111 |
+
The "System Libraries" of an executable work include anything, other
|
| 112 |
+
than the work as a whole, that (a) is included in the normal form of
|
| 113 |
+
packaging a Major Component, but which is not part of that Major
|
| 114 |
+
Component, and (b) serves only to enable use of the work with that
|
| 115 |
+
Major Component, or to implement a Standard Interface for which an
|
| 116 |
+
implementation is available to the public in source code form. A
|
| 117 |
+
"Major Component", in this context, means a major essential component
|
| 118 |
+
(kernel, window system, and so on) of the specific operating system
|
| 119 |
+
(if any) on which the executable work runs, or a compiler used to
|
| 120 |
+
produce the work, or an object code interpreter used to run it.
|
| 121 |
+
|
| 122 |
+
The "Corresponding Source" for a work in object code form means all
|
| 123 |
+
the source code needed to generate, install, and (for an executable
|
| 124 |
+
work) run the object code and to modify the work, including scripts to
|
| 125 |
+
control those activities. However, it does not include the work's
|
| 126 |
+
System Libraries, or general-purpose tools or generally available free
|
| 127 |
+
programs which are used unmodified in performing those activities but
|
| 128 |
+
which are not part of the work. For example, Corresponding Source
|
| 129 |
+
includes interface definition files associated with source files for
|
| 130 |
+
the work, and the source code for shared libraries and dynamically
|
| 131 |
+
linked subprograms that the work is specifically designed to require,
|
| 132 |
+
such as by intimate data communication or control flow between those
|
| 133 |
+
subprograms and other parts of the work.
|
| 134 |
+
|
| 135 |
+
The Corresponding Source need not include anything that users
|
| 136 |
+
can regenerate automatically from other parts of the Corresponding
|
| 137 |
+
Source.
|
| 138 |
+
|
| 139 |
+
The Corresponding Source for a work in source code form is that
|
| 140 |
+
same work.
|
| 141 |
+
|
| 142 |
+
2. Basic Permissions.
|
| 143 |
+
|
| 144 |
+
All rights granted under this License are granted for the term of
|
| 145 |
+
copyright on the Program, and are irrevocable provided the stated
|
| 146 |
+
conditions are met. This License explicitly affirms your unlimited
|
| 147 |
+
permission to run the unmodified Program. The output from running a
|
| 148 |
+
covered work is covered by this License only if the output, given its
|
| 149 |
+
content, constitutes a covered work. This License acknowledges your
|
| 150 |
+
rights of fair use or other equivalent, as provided by copyright law.
|
| 151 |
+
|
| 152 |
+
You may make, run and propagate covered works that you do not
|
| 153 |
+
convey, without conditions so long as your license otherwise remains
|
| 154 |
+
in force. You may convey covered works to others for the sole purpose
|
| 155 |
+
of having them make modifications exclusively for you, or provide you
|
| 156 |
+
with facilities for running those works, provided that you comply with
|
| 157 |
+
the terms of this License in conveying all material for which you do
|
| 158 |
+
not control copyright. Those thus making or running the covered works
|
| 159 |
+
for you must do so exclusively on your behalf, under your direction
|
| 160 |
+
and control, on terms that prohibit them from making any copies of
|
| 161 |
+
your copyrighted material outside their relationship with you.
|
| 162 |
+
|
| 163 |
+
Conveying under any other circumstances is permitted solely under
|
| 164 |
+
the conditions stated below. Sublicensing is not allowed; section 10
|
| 165 |
+
makes it unnecessary.
|
| 166 |
+
|
| 167 |
+
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
| 168 |
+
|
| 169 |
+
No covered work shall be deemed part of an effective technological
|
| 170 |
+
measure under any applicable law fulfilling obligations under article
|
| 171 |
+
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
| 172 |
+
similar laws prohibiting or restricting circumvention of such
|
| 173 |
+
measures.
|
| 174 |
+
|
| 175 |
+
When you convey a covered work, you waive any legal power to forbid
|
| 176 |
+
circumvention of technological measures to the extent such circumvention
|
| 177 |
+
is effected by exercising rights under this License with respect to
|
| 178 |
+
the covered work, and you disclaim any intention to limit operation or
|
| 179 |
+
modification of the work as a means of enforcing, against the work's
|
| 180 |
+
users, your or third parties' legal rights to forbid circumvention of
|
| 181 |
+
technological measures.
|
| 182 |
+
|
| 183 |
+
4. Conveying Verbatim Copies.
|
| 184 |
+
|
| 185 |
+
You may convey verbatim copies of the Program's source code as you
|
| 186 |
+
receive it, in any medium, provided that you conspicuously and
|
| 187 |
+
appropriately publish on each copy an appropriate copyright notice;
|
| 188 |
+
keep intact all notices stating that this License and any
|
| 189 |
+
non-permissive terms added in accord with section 7 apply to the code;
|
| 190 |
+
keep intact all notices of the absence of any warranty; and give all
|
| 191 |
+
recipients a copy of this License along with the Program.
|
| 192 |
+
|
| 193 |
+
You may charge any price or no price for each copy that you convey,
|
| 194 |
+
and you may offer support or warranty protection for a fee.
|
| 195 |
+
|
| 196 |
+
5. Conveying Modified Source Versions.
|
| 197 |
+
|
| 198 |
+
You may convey a work based on the Program, or the modifications to
|
| 199 |
+
produce it from the Program, in the form of source code under the
|
| 200 |
+
terms of section 4, provided that you also meet all of these conditions:
|
| 201 |
+
|
| 202 |
+
a) The work must carry prominent notices stating that you modified
|
| 203 |
+
it, and giving a relevant date.
|
| 204 |
+
|
| 205 |
+
b) The work must carry prominent notices stating that it is
|
| 206 |
+
released under this License and any conditions added under section
|
| 207 |
+
7. This requirement modifies the requirement in section 4 to
|
| 208 |
+
"keep intact all notices".
|
| 209 |
+
|
| 210 |
+
c) You must license the entire work, as a whole, under this
|
| 211 |
+
License to anyone who comes into possession of a copy. This
|
| 212 |
+
License will therefore apply, along with any applicable section 7
|
| 213 |
+
additional terms, to the whole of the work, and all its parts,
|
| 214 |
+
regardless of how they are packaged. This License gives no
|
| 215 |
+
permission to license the work in any other way, but it does not
|
| 216 |
+
invalidate such permission if you have separately received it.
|
| 217 |
+
|
| 218 |
+
d) If the work has interactive user interfaces, each must display
|
| 219 |
+
Appropriate Legal Notices; however, if the Program has interactive
|
| 220 |
+
interfaces that do not display Appropriate Legal Notices, your
|
| 221 |
+
work need not make them do so.
|
| 222 |
+
|
| 223 |
+
A compilation of a covered work with other separate and independent
|
| 224 |
+
works, which are not by their nature extensions of the covered work,
|
| 225 |
+
and which are not combined with it such as to form a larger program,
|
| 226 |
+
in or on a volume of a storage or distribution medium, is called an
|
| 227 |
+
"aggregate" if the compilation and its resulting copyright are not
|
| 228 |
+
used to limit the access or legal rights of the compilation's users
|
| 229 |
+
beyond what the individual works permit. Inclusion of a covered work
|
| 230 |
+
in an aggregate does not cause this License to apply to the other
|
| 231 |
+
parts of the aggregate.
|
| 232 |
+
|
| 233 |
+
6. Conveying Non-Source Forms.
|
| 234 |
+
|
| 235 |
+
You may convey a covered work in object code form under the terms
|
| 236 |
+
of sections 4 and 5, provided that you also convey the
|
| 237 |
+
machine-readable Corresponding Source under the terms of this License,
|
| 238 |
+
in one of these ways:
|
| 239 |
+
|
| 240 |
+
a) Convey the object code in, or embodied in, a physical product
|
| 241 |
+
(including a physical distribution medium), accompanied by the
|
| 242 |
+
Corresponding Source fixed on a durable physical medium
|
| 243 |
+
customarily used for software interchange.
|
| 244 |
+
|
| 245 |
+
b) Convey the object code in, or embodied in, a physical product
|
| 246 |
+
(including a physical distribution medium), accompanied by a
|
| 247 |
+
written offer, valid for at least three years and valid for as
|
| 248 |
+
long as you offer spare parts or customer support for that product
|
| 249 |
+
model, to give anyone who possesses the object code either (1) a
|
| 250 |
+
copy of the Corresponding Source for all the software in the
|
| 251 |
+
product that is covered by this License, on a durable physical
|
| 252 |
+
medium customarily used for software interchange, for a price no
|
| 253 |
+
more than your reasonable cost of physically performing this
|
| 254 |
+
conveying of source, or (2) access to copy the
|
| 255 |
+
Corresponding Source from a network server at no charge.
|
| 256 |
+
|
| 257 |
+
c) Convey individual copies of the object code with a copy of the
|
| 258 |
+
written offer to provide the Corresponding Source. This
|
| 259 |
+
alternative is allowed only occasionally and noncommercially, and
|
| 260 |
+
only if you received the object code with such an offer, in accord
|
| 261 |
+
with subsection 6b.
|
| 262 |
+
|
| 263 |
+
d) Convey the object code by offering access from a designated
|
| 264 |
+
place (gratis or for a charge), and offer equivalent access to the
|
| 265 |
+
Corresponding Source in the same way through the same place at no
|
| 266 |
+
further charge. You need not require recipients to copy the
|
| 267 |
+
Corresponding Source along with the object code. If the place to
|
| 268 |
+
copy the object code is a network server, the Corresponding Source
|
| 269 |
+
may be on a different server (operated by you or a third party)
|
| 270 |
+
that supports equivalent copying facilities, provided you maintain
|
| 271 |
+
clear directions next to the object code saying where to find the
|
| 272 |
+
Corresponding Source. Regardless of what server hosts the
|
| 273 |
+
Corresponding Source, you remain obligated to ensure that it is
|
| 274 |
+
available for as long as needed to satisfy these requirements.
|
| 275 |
+
|
| 276 |
+
e) Convey the object code using peer-to-peer transmission, provided
|
| 277 |
+
you inform other peers where the object code and Corresponding
|
| 278 |
+
Source of the work are being offered to the general public at no
|
| 279 |
+
charge under subsection 6d.
|
| 280 |
+
|
| 281 |
+
A separable portion of the object code, whose source code is excluded
|
| 282 |
+
from the Corresponding Source as a System Library, need not be
|
| 283 |
+
included in conveying the object code work.
|
| 284 |
+
|
| 285 |
+
A "User Product" is either (1) a "consumer product", which means any
|
| 286 |
+
tangible personal property which is normally used for personal, family,
|
| 287 |
+
or household purposes, or (2) anything designed or sold for incorporation
|
| 288 |
+
into a dwelling. In determining whether a product is a consumer product,
|
| 289 |
+
doubtful cases shall be resolved in favor of coverage. For a particular
|
| 290 |
+
product received by a particular user, "normally used" refers to a
|
| 291 |
+
typical or common use of that class of product, regardless of the status
|
| 292 |
+
of the particular user or of the way in which the particular user
|
| 293 |
+
actually uses, or expects or is expected to use, the product. A product
|
| 294 |
+
is a consumer product regardless of whether the product has substantial
|
| 295 |
+
commercial, industrial or non-consumer uses, unless such uses represent
|
| 296 |
+
the only significant mode of use of the product.
|
| 297 |
+
|
| 298 |
+
"Installation Information" for a User Product means any methods,
|
| 299 |
+
procedures, authorization keys, or other information required to install
|
| 300 |
+
and execute modified versions of a covered work in that User Product from
|
| 301 |
+
a modified version of its Corresponding Source. The information must
|
| 302 |
+
suffice to ensure that the continued functioning of the modified object
|
| 303 |
+
code is in no case prevented or interfered with solely because
|
| 304 |
+
modification has been made.
|
| 305 |
+
|
| 306 |
+
If you convey an object code work under this section in, or with, or
|
| 307 |
+
specifically for use in, a User Product, and the conveying occurs as
|
| 308 |
+
part of a transaction in which the right of possession and use of the
|
| 309 |
+
User Product is transferred to the recipient in perpetuity or for a
|
| 310 |
+
fixed term (regardless of how the transaction is characterized), the
|
| 311 |
+
Corresponding Source conveyed under this section must be accompanied
|
| 312 |
+
by the Installation Information. But this requirement does not apply
|
| 313 |
+
if neither you nor any third party retains the ability to install
|
| 314 |
+
modified object code on the User Product (for example, the work has
|
| 315 |
+
been installed in ROM).
|
| 316 |
+
|
| 317 |
+
The requirement to provide Installation Information does not include a
|
| 318 |
+
requirement to continue to provide support service, warranty, or updates
|
| 319 |
+
for a work that has been modified or installed by the recipient, or for
|
| 320 |
+
the User Product in which it has been modified or installed. Access to a
|
| 321 |
+
network may be denied when the modification itself materially and
|
| 322 |
+
adversely affects the operation of the network or violates the rules and
|
| 323 |
+
protocols for communication across the network.
|
| 324 |
+
|
| 325 |
+
Corresponding Source conveyed, and Installation Information provided,
|
| 326 |
+
in accord with this section must be in a format that is publicly
|
| 327 |
+
documented (and with an implementation available to the public in
|
| 328 |
+
source code form), and must require no special password or key for
|
| 329 |
+
unpacking, reading or copying.
|
| 330 |
+
|
| 331 |
+
7. Additional Terms.
|
| 332 |
+
|
| 333 |
+
"Additional permissions" are terms that supplement the terms of this
|
| 334 |
+
License by making exceptions from one or more of its conditions.
|
| 335 |
+
Additional permissions that are applicable to the entire Program shall
|
| 336 |
+
be treated as though they were included in this License, to the extent
|
| 337 |
+
that they are valid under applicable law. If additional permissions
|
| 338 |
+
apply only to part of the Program, that part may be used separately
|
| 339 |
+
under those permissions, but the entire Program remains governed by
|
| 340 |
+
this License without regard to the additional permissions.
|
| 341 |
+
|
| 342 |
+
When you convey a copy of a covered work, you may at your option
|
| 343 |
+
remove any additional permissions from that copy, or from any part of
|
| 344 |
+
it. (Additional permissions may be written to require their own
|
| 345 |
+
removal in certain cases when you modify the work.) You may place
|
| 346 |
+
additional permissions on material, added by you to a covered work,
|
| 347 |
+
for which you have or can give appropriate copyright permission.
|
| 348 |
+
|
| 349 |
+
Notwithstanding any other provision of this License, for material you
|
| 350 |
+
add to a covered work, you may (if authorized by the copyright holders of
|
| 351 |
+
that material) supplement the terms of this License with terms:
|
| 352 |
+
|
| 353 |
+
a) Disclaiming warranty or limiting liability differently from the
|
| 354 |
+
terms of sections 15 and 16 of this License; or
|
| 355 |
+
|
| 356 |
+
b) Requiring preservation of specified reasonable legal notices or
|
| 357 |
+
author attributions in that material or in the Appropriate Legal
|
| 358 |
+
Notices displayed by works containing it; or
|
| 359 |
+
|
| 360 |
+
c) Prohibiting misrepresentation of the origin of that material, or
|
| 361 |
+
requiring that modified versions of such material be marked in
|
| 362 |
+
reasonable ways as different from the original version; or
|
| 363 |
+
|
| 364 |
+
d) Limiting the use for publicity purposes of names of licensors or
|
| 365 |
+
authors of the material; or
|
| 366 |
+
|
| 367 |
+
e) Declining to grant rights under trademark law for use of some
|
| 368 |
+
trade names, trademarks, or service marks; or
|
| 369 |
+
|
| 370 |
+
f) Requiring indemnification of licensors and authors of that
|
| 371 |
+
material by anyone who conveys the material (or modified versions of
|
| 372 |
+
it) with contractual assumptions of liability to the recipient, for
|
| 373 |
+
any liability that these contractual assumptions directly impose on
|
| 374 |
+
those licensors and authors.
|
| 375 |
+
|
| 376 |
+
All other non-permissive additional terms are considered "further
|
| 377 |
+
restrictions" within the meaning of section 10. If the Program as you
|
| 378 |
+
received it, or any part of it, contains a notice stating that it is
|
| 379 |
+
governed by this License along with a term that is a further
|
| 380 |
+
restriction, you may remove that term. If a license document contains
|
| 381 |
+
a further restriction but permits relicensing or conveying under this
|
| 382 |
+
License, you may add to a covered work material governed by the terms
|
| 383 |
+
of that license document, provided that the further restriction does
|
| 384 |
+
not survive such relicensing or conveying.
|
| 385 |
+
|
| 386 |
+
If you add terms to a covered work in accord with this section, you
|
| 387 |
+
must place, in the relevant source files, a statement of the
|
| 388 |
+
additional terms that apply to those files, or a notice indicating
|
| 389 |
+
where to find the applicable terms.
|
| 390 |
+
|
| 391 |
+
Additional terms, permissive or non-permissive, may be stated in the
|
| 392 |
+
form of a separately written license, or stated as exceptions;
|
| 393 |
+
the above requirements apply either way.
|
| 394 |
+
|
| 395 |
+
8. Termination.
|
| 396 |
+
|
| 397 |
+
You may not propagate or modify a covered work except as expressly
|
| 398 |
+
provided under this License. Any attempt otherwise to propagate or
|
| 399 |
+
modify it is void, and will automatically terminate your rights under
|
| 400 |
+
this License (including any patent licenses granted under the third
|
| 401 |
+
paragraph of section 11).
|
| 402 |
+
|
| 403 |
+
However, if you cease all violation of this License, then your
|
| 404 |
+
license from a particular copyright holder is reinstated (a)
|
| 405 |
+
provisionally, unless and until the copyright holder explicitly and
|
| 406 |
+
finally terminates your license, and (b) permanently, if the copyright
|
| 407 |
+
holder fails to notify you of the violation by some reasonable means
|
| 408 |
+
prior to 60 days after the cessation.
|
| 409 |
+
|
| 410 |
+
Moreover, your license from a particular copyright holder is
|
| 411 |
+
reinstated permanently if the copyright holder notifies you of the
|
| 412 |
+
violation by some reasonable means, this is the first time you have
|
| 413 |
+
received notice of violation of this License (for any work) from that
|
| 414 |
+
copyright holder, and you cure the violation prior to 30 days after
|
| 415 |
+
your receipt of the notice.
|
| 416 |
+
|
| 417 |
+
Termination of your rights under this section does not terminate the
|
| 418 |
+
licenses of parties who have received copies or rights from you under
|
| 419 |
+
this License. If your rights have been terminated and not permanently
|
| 420 |
+
reinstated, you do not qualify to receive new licenses for the same
|
| 421 |
+
material under section 10.
|
| 422 |
+
|
| 423 |
+
9. Acceptance Not Required for Having Copies.
|
| 424 |
+
|
| 425 |
+
You are not required to accept this License in order to receive or
|
| 426 |
+
run a copy of the Program. Ancillary propagation of a covered work
|
| 427 |
+
occurring solely as a consequence of using peer-to-peer transmission
|
| 428 |
+
to receive a copy likewise does not require acceptance. However,
|
| 429 |
+
nothing other than this License grants you permission to propagate or
|
| 430 |
+
modify any covered work. These actions infringe copyright if you do
|
| 431 |
+
not accept this License. Therefore, by modifying or propagating a
|
| 432 |
+
covered work, you indicate your acceptance of this License to do so.
|
| 433 |
+
|
| 434 |
+
10. Automatic Licensing of Downstream Recipients.
|
| 435 |
+
|
| 436 |
+
Each time you convey a covered work, the recipient automatically
|
| 437 |
+
receives a license from the original licensors, to run, modify and
|
| 438 |
+
propagate that work, subject to this License. You are not responsible
|
| 439 |
+
for enforcing compliance by third parties with this License.
|
| 440 |
+
|
| 441 |
+
An "entity transaction" is a transaction transferring control of an
|
| 442 |
+
organization, or substantially all assets of one, or subdividing an
|
| 443 |
+
organization, or merging organizations. If propagation of a covered
|
| 444 |
+
work results from an entity transaction, each party to that
|
| 445 |
+
transaction who receives a copy of the work also receives whatever
|
| 446 |
+
licenses to the work the party's predecessor in interest had or could
|
| 447 |
+
give under the previous paragraph, plus a right to possession of the
|
| 448 |
+
Corresponding Source of the work from the predecessor in interest, if
|
| 449 |
+
the predecessor has it or can get it with reasonable efforts.
|
| 450 |
+
|
| 451 |
+
You may not impose any further restrictions on the exercise of the
|
| 452 |
+
rights granted or affirmed under this License. For example, you may
|
| 453 |
+
not impose a license fee, royalty, or other charge for exercise of
|
| 454 |
+
rights granted under this License, and you may not initiate litigation
|
| 455 |
+
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
| 456 |
+
any patent claim is infringed by making, using, selling, offering for
|
| 457 |
+
sale, or importing the Program or any portion of it.
|
| 458 |
+
|
| 459 |
+
11. Patents.
|
| 460 |
+
|
| 461 |
+
A "contributor" is a copyright holder who authorizes use under this
|
| 462 |
+
License of the Program or a work on which the Program is based. The
|
| 463 |
+
work thus licensed is called the contributor's "contributor version".
|
| 464 |
+
|
| 465 |
+
A contributor's "essential patent claims" are all patent claims
|
| 466 |
+
owned or controlled by the contributor, whether already acquired or
|
| 467 |
+
hereafter acquired, that would be infringed by some manner, permitted
|
| 468 |
+
by this License, of making, using, or selling its contributor version,
|
| 469 |
+
but do not include claims that would be infringed only as a
|
| 470 |
+
consequence of further modification of the contributor version. For
|
| 471 |
+
purposes of this definition, "control" includes the right to grant
|
| 472 |
+
patent sublicenses in a manner consistent with the requirements of
|
| 473 |
+
this License.
|
| 474 |
+
|
| 475 |
+
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
| 476 |
+
patent license under the contributor's essential patent claims, to
|
| 477 |
+
make, use, sell, offer for sale, import and otherwise run, modify and
|
| 478 |
+
propagate the contents of its contributor version.
|
| 479 |
+
|
| 480 |
+
In the following three paragraphs, a "patent license" is any express
|
| 481 |
+
agreement or commitment, however denominated, not to enforce a patent
|
| 482 |
+
(such as an express permission to practice a patent or covenant not to
|
| 483 |
+
sue for patent infringement). To "grant" such a patent license to a
|
| 484 |
+
party means to make such an agreement or commitment not to enforce a
|
| 485 |
+
patent against the party.
|
| 486 |
+
|
| 487 |
+
If you convey a covered work, knowingly relying on a patent license,
|
| 488 |
+
and the Corresponding Source of the work is not available for anyone
|
| 489 |
+
to copy, free of charge and under the terms of this License, through a
|
| 490 |
+
publicly available network server or other readily accessible means,
|
| 491 |
+
then you must either (1) cause the Corresponding Source to be so
|
| 492 |
+
available, or (2) arrange to deprive yourself of the benefit of the
|
| 493 |
+
patent license for this particular work, or (3) arrange, in a manner
|
| 494 |
+
consistent with the requirements of this License, to extend the patent
|
| 495 |
+
license to downstream recipients. "Knowingly relying" means you have
|
| 496 |
+
actual knowledge that, but for the patent license, your conveying the
|
| 497 |
+
covered work in a country, or your recipient's use of the covered work
|
| 498 |
+
in a country, would infringe one or more identifiable patents in that
|
| 499 |
+
country that you have reason to believe are valid.
|
| 500 |
+
|
| 501 |
+
If, pursuant to or in connection with a single transaction or
|
| 502 |
+
arrangement, you convey, or propagate by procuring conveyance of, a
|
| 503 |
+
covered work, and grant a patent license to some of the parties
|
| 504 |
+
receiving the covered work authorizing them to use, propagate, modify
|
| 505 |
+
or convey a specific copy of the covered work, then the patent license
|
| 506 |
+
you grant is automatically extended to all recipients of the covered
|
| 507 |
+
work and works based on it.
|
| 508 |
+
|
| 509 |
+
A patent license is "discriminatory" if it does not include within
|
| 510 |
+
the scope of its coverage, prohibits the exercise of, or is
|
| 511 |
+
conditioned on the non-exercise of one or more of the rights that are
|
| 512 |
+
specifically granted under this License. You may not convey a covered
|
| 513 |
+
work if you are a party to an arrangement with a third party that is
|
| 514 |
+
in the business of distributing software, under which you make payment
|
| 515 |
+
to the third party based on the extent of your activity of conveying
|
| 516 |
+
the work, and under which the third party grants, to any of the
|
| 517 |
+
parties who would receive the covered work from you, a discriminatory
|
| 518 |
+
patent license (a) in connection with copies of the covered work
|
| 519 |
+
conveyed by you (or copies made from those copies), or (b) primarily
|
| 520 |
+
for and in connection with specific products or compilations that
|
| 521 |
+
contain the covered work, unless you entered into that arrangement,
|
| 522 |
+
or that patent license was granted, prior to 28 March 2007.
|
| 523 |
+
|
| 524 |
+
Nothing in this License shall be construed as excluding or limiting
|
| 525 |
+
any implied license or other defenses to infringement that may
|
| 526 |
+
otherwise be available to you under applicable patent law.
|
| 527 |
+
|
| 528 |
+
12. No Surrender of Others' Freedom.
|
| 529 |
+
|
| 530 |
+
If conditions are imposed on you (whether by court order, agreement or
|
| 531 |
+
otherwise) that contradict the conditions of this License, they do not
|
| 532 |
+
excuse you from the conditions of this License. If you cannot convey a
|
| 533 |
+
covered work so as to satisfy simultaneously your obligations under this
|
| 534 |
+
License and any other pertinent obligations, then as a consequence you may
|
| 535 |
+
not convey it at all. For example, if you agree to terms that obligate you
|
| 536 |
+
to collect a royalty for further conveying from those to whom you convey
|
| 537 |
+
the Program, the only way you could satisfy both those terms and this
|
| 538 |
+
License would be to refrain entirely from conveying the Program.
|
| 539 |
+
|
| 540 |
+
13. Remote Network Interaction; Use with the GNU General Public License.
|
| 541 |
+
|
| 542 |
+
Notwithstanding any other provision of this License, if you modify the
|
| 543 |
+
Program, your modified version must prominently offer all users
|
| 544 |
+
interacting with it remotely through a computer network (if your version
|
| 545 |
+
supports such interaction) an opportunity to receive the Corresponding
|
| 546 |
+
Source of your version by providing access to the Corresponding Source
|
| 547 |
+
from a network server at no charge, through some standard or customary
|
| 548 |
+
means of facilitating copying of software. This Corresponding Source
|
| 549 |
+
shall include the Corresponding Source for any work covered by version 3
|
| 550 |
+
of the GNU General Public License that is incorporated pursuant to the
|
| 551 |
+
following paragraph.
|
| 552 |
+
|
| 553 |
+
Notwithstanding any other provision of this License, you have
|
| 554 |
+
permission to link or combine any covered work with a work licensed
|
| 555 |
+
under version 3 of the GNU General Public License into a single
|
| 556 |
+
combined work, and to convey the resulting work. The terms of this
|
| 557 |
+
License will continue to apply to the part which is the covered work,
|
| 558 |
+
but the work with which it is combined will remain governed by version
|
| 559 |
+
3 of the GNU General Public License.
|
| 560 |
+
|
| 561 |
+
14. Revised Versions of this License.
|
| 562 |
+
|
| 563 |
+
The Free Software Foundation may publish revised and/or new versions of
|
| 564 |
+
the GNU Affero General Public License from time to time. Such new versions
|
| 565 |
+
will be similar in spirit to the present version, but may differ in detail to
|
| 566 |
+
address new problems or concerns.
|
| 567 |
+
|
| 568 |
+
Each version is given a distinguishing version number. If the
|
| 569 |
+
Program specifies that a certain numbered version of the GNU Affero General
|
| 570 |
+
Public License "or any later version" applies to it, you have the
|
| 571 |
+
option of following the terms and conditions either of that numbered
|
| 572 |
+
version or of any later version published by the Free Software
|
| 573 |
+
Foundation. If the Program does not specify a version number of the
|
| 574 |
+
GNU Affero General Public License, you may choose any version ever published
|
| 575 |
+
by the Free Software Foundation.
|
| 576 |
+
|
| 577 |
+
If the Program specifies that a proxy can decide which future
|
| 578 |
+
versions of the GNU Affero General Public License can be used, that proxy's
|
| 579 |
+
public statement of acceptance of a version permanently authorizes you
|
| 580 |
+
to choose that version for the Program.
|
| 581 |
+
|
| 582 |
+
Later license versions may give you additional or different
|
| 583 |
+
permissions. However, no additional obligations are imposed on any
|
| 584 |
+
author or copyright holder as a result of your choosing to follow a
|
| 585 |
+
later version.
|
| 586 |
+
|
| 587 |
+
15. Disclaimer of Warranty.
|
| 588 |
+
|
| 589 |
+
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
| 590 |
+
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
| 591 |
+
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
| 592 |
+
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
| 593 |
+
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
| 594 |
+
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
| 595 |
+
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
| 596 |
+
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
| 597 |
+
|
| 598 |
+
16. Limitation of Liability.
|
| 599 |
+
|
| 600 |
+
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
| 601 |
+
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
| 602 |
+
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
| 603 |
+
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
| 604 |
+
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
| 605 |
+
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
| 606 |
+
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
| 607 |
+
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
| 608 |
+
SUCH DAMAGES.
|
| 609 |
+
|
| 610 |
+
17. Interpretation of Sections 15 and 16.
|
| 611 |
+
|
| 612 |
+
If the disclaimer of warranty and limitation of liability provided
|
| 613 |
+
above cannot be given local legal effect according to their terms,
|
| 614 |
+
reviewing courts shall apply local law that most closely approximates
|
| 615 |
+
an absolute waiver of all civil liability in connection with the
|
| 616 |
+
Program, unless a warranty or assumption of liability accompanies a
|
| 617 |
+
copy of the Program in return for a fee.
|
| 618 |
+
|
| 619 |
+
END OF TERMS AND CONDITIONS
|
| 620 |
+
|
| 621 |
+
How to Apply These Terms to Your New Programs
|
| 622 |
+
|
| 623 |
+
If you develop a new program, and you want it to be of the greatest
|
| 624 |
+
possible use to the public, the best way to achieve this is to make it
|
| 625 |
+
free software which everyone can redistribute and change under these terms.
|
| 626 |
+
|
| 627 |
+
To do so, attach the following notices to the program. It is safest
|
| 628 |
+
to attach them to the start of each source file to most effectively
|
| 629 |
+
state the exclusion of warranty; and each file should have at least
|
| 630 |
+
the "copyright" line and a pointer to where the full notice is found.
|
| 631 |
+
|
| 632 |
+
<one line to give the program's name and a brief idea of what it does.>
|
| 633 |
+
Copyright (C) <year> <name of author>
|
| 634 |
+
|
| 635 |
+
This program is free software: you can redistribute it and/or modify
|
| 636 |
+
it under the terms of the GNU Affero General Public License as published
|
| 637 |
+
by the Free Software Foundation, either version 3 of the License, or
|
| 638 |
+
(at your option) any later version.
|
| 639 |
+
|
| 640 |
+
This program is distributed in the hope that it will be useful,
|
| 641 |
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 642 |
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 643 |
+
GNU Affero General Public License for more details.
|
| 644 |
+
|
| 645 |
+
You should have received a copy of the GNU Affero General Public License
|
| 646 |
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
| 647 |
+
|
| 648 |
+
Also add information on how to contact you by electronic and paper mail.
|
| 649 |
+
|
| 650 |
+
If your software can interact with users remotely through a computer
|
| 651 |
+
network, you should also make sure that it provides a way for users to
|
| 652 |
+
get its source. For example, if your program is a web application, its
|
| 653 |
+
interface could display a "Source" link that leads users to an archive
|
| 654 |
+
of the code. There are many ways you could offer source, and different
|
| 655 |
+
solutions will be better for different programs; see section 13 for the
|
| 656 |
+
specific requirements.
|
| 657 |
+
|
| 658 |
+
You should also get your employer (if you work as a programmer) or school,
|
| 659 |
+
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
| 660 |
+
For more information on this, and how to apply and follow the GNU AGPL, see
|
| 661 |
+
<https://www.gnu.org/licenses/>.
|
AIStudioProxyAPI/README.md
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AI Studio Proxy API
|
| 2 |
+
|
| 3 |
+
这是一个基于 Python 的代理服务器,用于将 Google AI Studio 的网页界面转换为 OpenAI 兼容的 API。通过 Camoufox (反指纹检测的 Firefox) 和 Playwright 自动化,提供稳定的 API 访问。
|
| 4 |
+
|
| 5 |
+
[](https://www.star-history.com/#CJackHwang/AIstudioProxyAPI&Date)
|
| 6 |
+
|
| 7 |
+
This project is generously sponsored by ZMTO. Visit their website: [https://zmto.com/](https://zmto.com/)
|
| 8 |
+
|
| 9 |
+
本项目由 ZMTO 慷慨赞助服务器支持。访问他们的网站:[https://zmto.com/](https://zmto.com/)
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## 致谢 (Acknowledgements)
|
| 14 |
+
|
| 15 |
+
本项目的诞生与发展,离不开以下个人、组织和社区的慷慨支持与智慧贡献:
|
| 16 |
+
|
| 17 |
+
- **项目发起与主要开发**: @CJackHwang ([https://github.com/CJackHwang](https://github.com/CJackHwang))
|
| 18 |
+
- **功能完善、页面操作优化思路贡献**: @ayuayue ([https://github.com/ayuayue](https://github.com/ayuayue))
|
| 19 |
+
- **实时流式功能优化与完善**: @luispater ([https://github.com/luispater](https://github.com/luispater))
|
| 20 |
+
- **3400+行主文件项目重构伟大贡献**: @yattin (Holt) ([https://github.com/yattin](https://github.com/yattin))
|
| 21 |
+
- **项目后期高质量维护**: @Louie ([https://github.com/NikkeTryHard](https://github.com/NikkeTryHard))
|
| 22 |
+
- **社区支持与灵感碰撞**: 特别感谢 [Linux.do 社区](https://linux.do/) 成员们的热烈讨论、宝贵建议和问题反馈,你们的参与是项目前进的重要动力。
|
| 23 |
+
|
| 24 |
+
同时,我们衷心感谢所有通过提交 Issue、提供建议、分享使用体验、贡献代码修复等方式为本项目默默奉献的每一位朋友。是你们共同的努力,让这个项目变得更好!
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
**这是当前维护的 Python 版本。不再维护的 Javascript 版本请参见 [`deprecated_javascript_version/README.md`](deprecated_javascript_version/README.md)。**
|
| 29 |
+
|
| 30 |
+
## 系统要求
|
| 31 |
+
|
| 32 |
+
- **Python**: >=3.9, <4.0 (推荐 3.10+ 以获得最佳性能,Docker 环境使用 3.10)
|
| 33 |
+
- **依赖管理**: [Poetry](https://python-poetry.org/) (现代化 Python 依赖管理工具,替代传统 requirements.txt)
|
| 34 |
+
- **类型检查**: [Pyright](https://github.com/microsoft/pyright) (可选,用于开发时类型检查和 IDE 支持)
|
| 35 |
+
- **操作系统**: Windows, macOS, Linux (完全跨平台支持,Docker 部署支持 x86_64 和 ARM64)
|
| 36 |
+
- **内存**: 建议 2GB+ 可用内存 (浏览器自动化需要)
|
| 37 |
+
- **网络**: 稳定的互联网连接访问 Google AI Studio (支持代理配置)
|
| 38 |
+
|
| 39 |
+
## 主要特性
|
| 40 |
+
|
| 41 |
+
- **OpenAI 兼容 API**: 支持 `/v1/chat/completions` 端点,完全兼容 OpenAI 客户端和第三方工具
|
| 42 |
+
- **三层流式响应机制**: 集成流式代理 → 外部 Helper 服务 → Playwright 页面交互的多重保障
|
| 43 |
+
- **智能模型切换**: 通过 API 请求中的 `model` 字段动态切换 AI Studio 中的模型
|
| 44 |
+
- **完整参数控制**: 支持 `temperature`、`max_output_tokens`、`top_p`、`stop`、`reasoning_effort` 等所有主要参数
|
| 45 |
+
- **反指纹检测**: 使用 Camoufox 浏览器降低被检测为自动化脚本的风险
|
| 46 |
+
- **脚本注入功能 v3.0**: 使用 Playwright 原生网络拦截,支持油猴脚本动态挂载,100%可靠 🆕
|
| 47 |
+
- **现代化 Web UI**: 内置测试界面,支持实时聊天、状态监控、分级 API 密钥管理
|
| 48 |
+
- **图形界面启动器**: 提供功能丰富的 GUI 启动器,简化配置和进程管理
|
| 49 |
+
- **灵活认证系统**: 支持可选的 API 密钥认证,完全兼容 OpenAI 标准的 Bearer token 格式
|
| 50 |
+
- **模块化架构**: 清晰的模块分离设计,api_utils/、browser_utils/、config/ 等独立模块
|
| 51 |
+
- **统一配置管理**: 基于 `.env` 文件的统一配置方式,支持环境变量覆盖,Docker 兼容
|
| 52 |
+
- **现代化开发工具**: Poetry 依赖管理 + Pyright 类型检查,提供优秀的开发体验
|
| 53 |
+
|
| 54 |
+
## 系统架构
|
| 55 |
+
|
| 56 |
+
```mermaid
|
| 57 |
+
graph TD
|
| 58 |
+
subgraph "用户端 (User End)"
|
| 59 |
+
User["用户 (User)"]
|
| 60 |
+
WebUI["Web UI (Browser)"]
|
| 61 |
+
API_Client["API 客户端 (API Client)"]
|
| 62 |
+
end
|
| 63 |
+
|
| 64 |
+
subgraph "启动与配置 (Launch & Config)"
|
| 65 |
+
GUI_Launch["gui_launcher.py (图形启动器)"]
|
| 66 |
+
CLI_Launch["launch_camoufox.py (命令行启动)"]
|
| 67 |
+
EnvConfig[".env (统一配置)"]
|
| 68 |
+
KeyFile["auth_profiles/key.txt (API Keys)"]
|
| 69 |
+
ConfigDir["config/ (配置模块)"]
|
| 70 |
+
end
|
| 71 |
+
|
| 72 |
+
subgraph "核心应用 (Core Application)"
|
| 73 |
+
FastAPI_App["api_utils/app.py (FastAPI 应用)"]
|
| 74 |
+
Routes["api_utils/routes.py (路由处理)"]
|
| 75 |
+
RequestProcessor["api_utils/request_processor.py (请求处理)"]
|
| 76 |
+
AuthUtils["api_utils/auth_utils.py (认证管理)"]
|
| 77 |
+
PageController["browser_utils/page_controller.py (页面控制)"]
|
| 78 |
+
ScriptManager["browser_utils/script_manager.py (脚本注入)"]
|
| 79 |
+
ModelManager["browser_utils/model_management.py (模型管理)"]
|
| 80 |
+
StreamProxy["stream/ (流式代理服务器)"]
|
| 81 |
+
end
|
| 82 |
+
|
| 83 |
+
subgraph "外部依赖 (External Dependencies)"
|
| 84 |
+
CamoufoxInstance["Camoufox 浏览器 (反指纹)"]
|
| 85 |
+
AI_Studio["Google AI Studio"]
|
| 86 |
+
UserScript["油猴脚本 (可选)"]
|
| 87 |
+
end
|
| 88 |
+
|
| 89 |
+
User -- "运行 (Run)" --> GUI_Launch
|
| 90 |
+
User -- "运行 (Run)" --> CLI_Launch
|
| 91 |
+
User -- "访问 (Access)" --> WebUI
|
| 92 |
+
|
| 93 |
+
GUI_Launch -- "启动 (Starts)" --> CLI_Launch
|
| 94 |
+
CLI_Launch -- "启动 (Starts)" --> FastAPI_App
|
| 95 |
+
CLI_Launch -- "配置 (Configures)" --> StreamProxy
|
| 96 |
+
|
| 97 |
+
API_Client -- "API 请求 (Request)" --> FastAPI_App
|
| 98 |
+
WebUI -- "聊天请求 (Chat Request)" --> FastAPI_App
|
| 99 |
+
|
| 100 |
+
FastAPI_App -- "读取配置 (Reads Config)" --> EnvConfig
|
| 101 |
+
FastAPI_App -- "使用路由 (Uses Routes)" --> Routes
|
| 102 |
+
AuthUtils -- "验证密钥 (Validates Key)" --> KeyFile
|
| 103 |
+
ConfigDir -- "提供设置 (Provides Settings)" --> EnvConfig
|
| 104 |
+
|
| 105 |
+
Routes -- "处理请求 (Processes Request)" --> RequestProcessor
|
| 106 |
+
Routes -- "认证管理 (Auth Management)" --> AuthUtils
|
| 107 |
+
RequestProcessor -- "控制浏览器 (Controls Browser)" --> PageController
|
| 108 |
+
RequestProcessor -- "通过代理 (Uses Proxy)" --> StreamProxy
|
| 109 |
+
|
| 110 |
+
PageController -- "模型管理 (Model Management)" --> ModelManager
|
| 111 |
+
PageController -- "脚本注入 (Script Injection)" --> ScriptManager
|
| 112 |
+
ScriptManager -- "加载脚本 (Loads Script)" --> UserScript
|
| 113 |
+
ScriptManager -- "增强功能 (Enhances)" --> CamoufoxInstance
|
| 114 |
+
PageController -- "自动化 (Automates)" --> CamoufoxInstance
|
| 115 |
+
CamoufoxInstance -- "访问 (Accesses)" --> AI_Studio
|
| 116 |
+
StreamProxy -- "转发请求 (Forwards Request)" --> AI_Studio
|
| 117 |
+
|
| 118 |
+
AI_Studio -- "响应 (Response)" --> CamoufoxInstance
|
| 119 |
+
AI_Studio -- "响应 (Response)" --> StreamProxy
|
| 120 |
+
|
| 121 |
+
CamoufoxInstance -- "返回数据 (Returns Data)" --> PageController
|
| 122 |
+
StreamProxy -- "返回数据 (Returns Data)" --> RequestProcessor
|
| 123 |
+
|
| 124 |
+
FastAPI_App -- "API 响应 (Response)" --> API_Client
|
| 125 |
+
FastAPI_App -- "UI 响应 (Response)" --> WebUI
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
## 配置管理 ⭐
|
| 129 |
+
|
| 130 |
+
**新功能**: 项目现在支持通过 `.env` 文件进行配置管理,避免硬编码参数!
|
| 131 |
+
|
| 132 |
+
### 快速配置
|
| 133 |
+
|
| 134 |
+
```bash
|
| 135 |
+
# 1. 复制配置模板
|
| 136 |
+
cp .env.example .env
|
| 137 |
+
|
| 138 |
+
# 2. 编辑配置文件
|
| 139 |
+
nano .env # 或使用其他编辑器
|
| 140 |
+
|
| 141 |
+
# 3. 启动服务(自动读取配置)
|
| 142 |
+
python gui_launcher.py
|
| 143 |
+
# 或直接命令行启动
|
| 144 |
+
python launch_camoufox.py --headless
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
### 主要优势
|
| 148 |
+
|
| 149 |
+
- ✅ **版本更新无忧**: 一个 `git pull` 就完成更新,无需重新配置
|
| 150 |
+
- ✅ **配置集中管理**: 所有配置项统一在 `.env` 文件中
|
| 151 |
+
- ✅ **启动命令简化**: 无需复杂的命令行参数,一键启动
|
| 152 |
+
- ✅ **安全性**: `.env` 文件已被 `.gitignore` 忽略,不会泄露配置
|
| 153 |
+
- ✅ **灵活性**: 支持不同环境的配置管理
|
| 154 |
+
- ✅ **Docker 兼容**: Docker 和本地环境使用相同的配置方式
|
| 155 |
+
|
| 156 |
+
详细配置说明请参见 [环境变量配置指南](docs/environment-configuration.md)。
|
| 157 |
+
|
| 158 |
+
## 使用教程
|
| 159 |
+
|
| 160 |
+
推荐使用 [`gui_launcher.py`](gui_launcher.py) (图形界面) 或直接使用 [`launch_camoufox.py`](launch_camoufox.py) (命令行) 进行日常运行。仅在首次设置或认证过期时才需要使用调试模式。
|
| 161 |
+
|
| 162 |
+
### 快速开始
|
| 163 |
+
|
| 164 |
+
本项目采用现代化的 Python 开发工具链,使用 [Poetry](https://python-poetry.org/) 进行依赖管理,[Pyright](https://github.com/microsoft/pyright) 进行类型检查。
|
| 165 |
+
|
| 166 |
+
#### 🚀 一键安装脚本 (推荐)
|
| 167 |
+
|
| 168 |
+
```bash
|
| 169 |
+
# macOS/Linux 用户
|
| 170 |
+
curl -sSL https://raw.githubusercontent.com/CJackHwang/AIstudioProxyAPI/main/scripts/install.sh | bash
|
| 171 |
+
|
| 172 |
+
# Windows 用户 (PowerShell)
|
| 173 |
+
iwr -useb https://raw.githubusercontent.com/CJackHwang/AIstudioProxyAPI/main/scripts/install.ps1 | iex
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
#### 📋 手动安装步骤
|
| 177 |
+
|
| 178 |
+
1. **安装 Poetry** (如果尚未安装):
|
| 179 |
+
|
| 180 |
+
```bash
|
| 181 |
+
# macOS/Linux
|
| 182 |
+
curl -sSL https://install.python-poetry.org | python3 -
|
| 183 |
+
|
| 184 |
+
# Windows (PowerShell)
|
| 185 |
+
(Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | py -
|
| 186 |
+
|
| 187 |
+
# 或使用包管理器
|
| 188 |
+
# macOS: brew install poetry
|
| 189 |
+
# Ubuntu/Debian: apt install python3-poetry
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
2. **克隆项目**:
|
| 193 |
+
|
| 194 |
+
```bash
|
| 195 |
+
git clone https://github.com/CJackHwang/AIstudioProxyAPI.git
|
| 196 |
+
cd AIstudioProxyAPI
|
| 197 |
+
```
|
| 198 |
+
|
| 199 |
+
3. **安装依赖**:
|
| 200 |
+
Poetry 会自动创建虚拟环境并安装所有依赖:
|
| 201 |
+
|
| 202 |
+
```bash
|
| 203 |
+
poetry install
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
4. **激活虚拟环境**:
|
| 207 |
+
|
| 208 |
+
```bash
|
| 209 |
+
# 方式1: 激活 shell (推荐日常开发)
|
| 210 |
+
poetry env activate
|
| 211 |
+
|
| 212 |
+
# 方式2: 直接运行命令 (推荐自动化脚本)
|
| 213 |
+
poetry run python gui_launcher.py
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
#### 🔧 后续配置步骤
|
| 217 |
+
|
| 218 |
+
5. **环境配置**: 参见 [环境变量配置指南](docs/environment-configuration.md) - **推荐先配置**
|
| 219 |
+
6. **首次认证**: 参见 [认证设置指南](docs/authentication-setup.md)
|
| 220 |
+
7. **日常运行**: 参见 [日常运行指南](docs/daily-usage.md)
|
| 221 |
+
8. **API 使用**: 参见 [API 使用指南](docs/api-usage.md)
|
| 222 |
+
9. **Web 界面**: 参见 [Web UI 使用指南](docs/webui-guide.md)
|
| 223 |
+
|
| 224 |
+
#### 🛠️ 开发者选项
|
| 225 |
+
|
| 226 |
+
如果您是开发者,还可以:
|
| 227 |
+
|
| 228 |
+
```bash
|
| 229 |
+
# 安装开发依赖 (包含类型检查、测试工具等)
|
| 230 |
+
poetry install --with dev
|
| 231 |
+
|
| 232 |
+
# 启用类型检查 (需要安装 pyright)
|
| 233 |
+
npm install -g pyright
|
| 234 |
+
pyright
|
| 235 |
+
|
| 236 |
+
# 查看项目依赖树
|
| 237 |
+
poetry show --tree
|
| 238 |
+
|
| 239 |
+
# 更新依赖
|
| 240 |
+
poetry update
|
| 241 |
+
```
|
| 242 |
+
|
| 243 |
+
### 📚 详细文档
|
| 244 |
+
|
| 245 |
+
#### 🚀 快速上手
|
| 246 |
+
|
| 247 |
+
- [安装指南](docs/installation-guide.md) - 详细的安装步骤和环境配置
|
| 248 |
+
- [环境变量配置指南](docs/environment-configuration.md) - **.env 文件配置管理** ⭐
|
| 249 |
+
- [认证设置指南](docs/authentication-setup.md) - 首次运行与认证文件设置
|
| 250 |
+
- [日常运行指南](docs/daily-usage.md) - 日常使用和配置选项
|
| 251 |
+
|
| 252 |
+
#### 🔧 功能使用
|
| 253 |
+
|
| 254 |
+
- [API 使用指南](docs/api-usage.md) - API 端点和客户端配置
|
| 255 |
+
- [Web UI 使用指南](docs/webui-guide.md) - Web 界面功能说明
|
| 256 |
+
- [脚本注入指南](docs/script_injection_guide.md) - 油猴脚本动态挂载功能使用指南 (v3.0) 🆕
|
| 257 |
+
|
| 258 |
+
#### ⚙️ 高级配置
|
| 259 |
+
|
| 260 |
+
- [流式处理模式详解](docs/streaming-modes.md) - 三层响应获取机制详细说明 🆕
|
| 261 |
+
- [高级配置指南](docs/advanced-configuration.md) - 高级功能和配置选项
|
| 262 |
+
- [日志控制指南](docs/logging-control.md) - 日志系统配置和调试
|
| 263 |
+
- [故障排除指南](docs/troubleshooting.md) - 常见问题解决方案
|
| 264 |
+
|
| 265 |
+
#### 🛠️ 开发相关
|
| 266 |
+
|
| 267 |
+
- [项目架构指南](docs/architecture-guide.md) - 模块化架构设计和组件详解 🆕
|
| 268 |
+
- [开发者指南](docs/development-guide.md) - Poetry、Pyright 和开发工作流程
|
| 269 |
+
- [依赖版本说明](docs/dependency-versions.md) - Poetry 依赖管理和版本控制详解
|
| 270 |
+
|
| 271 |
+
## 客户端配置示例
|
| 272 |
+
|
| 273 |
+
以 Open WebUI 为例:
|
| 274 |
+
|
| 275 |
+
1. 打开 Open WebUI
|
| 276 |
+
2. 进入 "设置" -> "连接"
|
| 277 |
+
3. 在 "模型" 部分,点击 "添加模型"
|
| 278 |
+
4. **模型名称**: 输入你想要的名字,例如 `aistudio-gemini-py`
|
| 279 |
+
5. **API 基础 URL**: 输入 `http://127.0.0.1:2048/v1`
|
| 280 |
+
6. **API 密钥**: 留空或输入任意字符
|
| 281 |
+
7. 保存设置并开始聊天
|
| 282 |
+
|
| 283 |
+
---
|
| 284 |
+
|
| 285 |
+
## 🐳 Docker 部署
|
| 286 |
+
|
| 287 |
+
本项目支持通过 Docker 进行部署,使用 **Poetry** 进行依赖管理,**完全支持 `.env` 配置文件**!
|
| 288 |
+
|
| 289 |
+
> 📁 **注意**: 所有 Docker 相关文件已移至 `docker/` 目录,保持项目根目录整洁。
|
| 290 |
+
|
| 291 |
+
### 🚀 快速 Docker 部署
|
| 292 |
+
|
| 293 |
+
```bash
|
| 294 |
+
# 1. 准备配置文件
|
| 295 |
+
cd docker
|
| 296 |
+
cp .env.docker .env
|
| 297 |
+
nano .env # 编辑配置
|
| 298 |
+
|
| 299 |
+
# 2. 使用 Docker Compose 启动
|
| 300 |
+
docker compose up -d
|
| 301 |
+
|
| 302 |
+
# 3. 查看日志
|
| 303 |
+
docker compose logs -f
|
| 304 |
+
|
| 305 |
+
# 4. 版本更新 (在 docker 目录下)
|
| 306 |
+
bash update.sh
|
| 307 |
+
```
|
| 308 |
+
|
| 309 |
+
### 📚 详细文档
|
| 310 |
+
|
| 311 |
+
- [Docker 部署指南 (docker/README-Docker.md)](docker/README-Docker.md) - 包含完整的 Poetry + `.env` 配置说明
|
| 312 |
+
- [Docker 快速指南 (docker/README.md)](docker/README.md) - 快速开始指南
|
| 313 |
+
|
| 314 |
+
### ✨ Docker 特性
|
| 315 |
+
|
| 316 |
+
- ✅ **Poetry 依赖管理**: 使用现代化的 Python 依赖管理工具
|
| 317 |
+
- ✅ **多阶段构建**: 优化镜像大小和构建速度
|
| 318 |
+
- ✅ **配置统一**: 使用 `.env` 文件管理所有配置
|
| 319 |
+
- ✅ **版本更新**: `bash update.sh` 即可完成更新
|
| 320 |
+
- ✅ **目录整洁**: Docker 文件已移至 `docker/` 目录
|
| 321 |
+
- ✅ **跨平台支持**: 支持 x86_64 和 ARM64 架构
|
| 322 |
+
- ⚠️ **认证文件**: 首次运行需要在主机上获取认证文件,然后挂载到容器中
|
| 323 |
+
|
| 324 |
+
---
|
| 325 |
+
|
| 326 |
+
## 关于 Camoufox
|
| 327 |
+
|
| 328 |
+
本项目使用 [Camoufox](https://camoufox.com/) 来提供具有增强反指纹检测能力的浏览器实例。
|
| 329 |
+
|
| 330 |
+
- **核心目标**: 模拟真实用户流量,避免被网站识别为自动化脚本或机器人
|
| 331 |
+
- **实现方式**: Camoufox 基于 Firefox,通过修改浏览器底层 C++ 实现来伪装设备指纹(如屏幕、操作系统、WebGL、字体等),而不是通过容易被检测到的 JavaScript 注入
|
| 332 |
+
- **Playwright 兼容**: Camoufox 提供了与 Playwright 兼容的接口
|
| 333 |
+
- **Python 接口**: Camoufox 提供了 Python 包,可以通过 `camoufox.server.launch_server()` 启动其服务,并通过 WebSocket 连接进行控制
|
| 334 |
+
|
| 335 |
+
使用 Camoufox 的主要目的是提高与 AI Studio 网页交互时的隐蔽性,减少被检测或限制的可能性。但请注意,没有任何反指纹技术是绝对完美的。
|
| 336 |
+
|
| 337 |
+
## 重要提示
|
| 338 |
+
|
| 339 |
+
### 三层响应获取机制与参数控制
|
| 340 |
+
|
| 341 |
+
- **响应获取优先级**: 项目采用三层响应获取机制,确保高可用性:
|
| 342 |
+
|
| 343 |
+
1. **集成流式代理服务 (Stream Proxy)**: 默认启用,端口 3120,提供最佳性能和稳定性
|
| 344 |
+
2. **外部 Helper 服务**: 可选配置,需要有效认证文件,作为备用方案
|
| 345 |
+
3. **Playwright 页面交互**: 最终后备方案,通过浏览器自动化获取响应
|
| 346 |
+
|
| 347 |
+
- **参数控制机制**:
|
| 348 |
+
|
| 349 |
+
- **流式代理模式**: 支持基础参���传递,性能最优
|
| 350 |
+
- **Helper 服务模式**: 参数支持取决于外部服务实现
|
| 351 |
+
- **Playwright 模式**: 完整支持所有参数(`temperature`, `max_output_tokens`, `top_p`, `stop`, `reasoning_effort`等)
|
| 352 |
+
|
| 353 |
+
- **脚本注入增强**: v3.0 版本使用 Playwright 原生网络拦截,确保注入模型与原生模型 100%一致
|
| 354 |
+
|
| 355 |
+
### 客户端管理历史
|
| 356 |
+
|
| 357 |
+
**客户端管理历史,代理不支持 UI 内编辑**: 客户端负责维护完整的聊天记录并将其发送给代理。代理服务器本身不支持在 AI Studio 界面中对历史消息进行编辑或分叉操作。
|
| 358 |
+
|
| 359 |
+
## 未来计划
|
| 360 |
+
|
| 361 |
+
以下是一些计划中的改进方向:
|
| 362 |
+
|
| 363 |
+
- **云服务器部署指南**: 提供更详细的在主流云平台上部署和管理服务的指南
|
| 364 |
+
- **认证更新流程优化**: 探索更便捷的认证文件更新机制,减少手动操作
|
| 365 |
+
- **流程健壮性优化**: 减少错误几率和接近原生体验
|
| 366 |
+
|
| 367 |
+
## 贡献
|
| 368 |
+
|
| 369 |
+
欢迎提交 Issue 和 Pull Request!
|
| 370 |
+
|
| 371 |
+
## License
|
| 372 |
+
|
| 373 |
+
[AGPLv3](LICENSE)
|
| 374 |
+
|
| 375 |
+
## 开发不易,支持作者
|
| 376 |
+
|
| 377 |
+
如果您觉得本项目对您有帮助,并且希望支持作者的持续开发,欢迎通过以下方式进行捐赠。您的支持是对我们最大的鼓励!
|
| 378 |
+
|
| 379 |
+

|
AIStudioProxyAPI/api_utils/__init__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API工具模块
|
| 3 |
+
提供FastAPI应用初始化、路由处理和工具函数
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
# 应用初始化
|
| 7 |
+
from .app import (
|
| 8 |
+
create_app
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
# 路由处理器
|
| 12 |
+
from .routes import (
|
| 13 |
+
read_index,
|
| 14 |
+
get_css,
|
| 15 |
+
get_js,
|
| 16 |
+
get_api_info,
|
| 17 |
+
health_check,
|
| 18 |
+
list_models,
|
| 19 |
+
chat_completions,
|
| 20 |
+
cancel_request,
|
| 21 |
+
get_queue_status,
|
| 22 |
+
websocket_log_endpoint
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# 工具函数
|
| 26 |
+
from .utils import (
|
| 27 |
+
generate_sse_chunk,
|
| 28 |
+
generate_sse_stop_chunk,
|
| 29 |
+
generate_sse_error_chunk,
|
| 30 |
+
use_stream_response,
|
| 31 |
+
clear_stream_queue,
|
| 32 |
+
use_helper_get_response,
|
| 33 |
+
validate_chat_request,
|
| 34 |
+
prepare_combined_prompt,
|
| 35 |
+
estimate_tokens,
|
| 36 |
+
calculate_usage_stats
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
# 请求处理器
|
| 40 |
+
from .request_processor import (
|
| 41 |
+
_process_request_refactored
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# 队列工作器
|
| 45 |
+
from .queue_worker import (
|
| 46 |
+
queue_worker
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
__all__ = [
|
| 50 |
+
# 应用初始化
|
| 51 |
+
'create_app',
|
| 52 |
+
# 路由处理器
|
| 53 |
+
'read_index',
|
| 54 |
+
'get_css',
|
| 55 |
+
'get_js',
|
| 56 |
+
'get_api_info',
|
| 57 |
+
'health_check',
|
| 58 |
+
'list_models',
|
| 59 |
+
'chat_completions',
|
| 60 |
+
'cancel_request',
|
| 61 |
+
'get_queue_status',
|
| 62 |
+
'websocket_log_endpoint',
|
| 63 |
+
# 工具函数
|
| 64 |
+
'generate_sse_chunk',
|
| 65 |
+
'generate_sse_stop_chunk',
|
| 66 |
+
'generate_sse_error_chunk',
|
| 67 |
+
'use_stream_response',
|
| 68 |
+
'clear_stream_queue',
|
| 69 |
+
'use_helper_get_response',
|
| 70 |
+
'validate_chat_request',
|
| 71 |
+
'prepare_combined_prompt',
|
| 72 |
+
'estimate_tokens',
|
| 73 |
+
'calculate_usage_stats',
|
| 74 |
+
# 请求处理器
|
| 75 |
+
'_process_request_refactored',
|
| 76 |
+
# 队列工作器
|
| 77 |
+
'queue_worker'
|
| 78 |
+
]
|
AIStudioProxyAPI/api_utils/app.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI应用初始化和生命周期管理
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import multiprocessing
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
import queue # <-- FIX: Added missing import for queue.Empty
|
| 10 |
+
from contextlib import asynccontextmanager
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
from fastapi import FastAPI, Request
|
| 14 |
+
from fastapi.responses import JSONResponse
|
| 15 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 16 |
+
from starlette.types import ASGIApp
|
| 17 |
+
from typing import Callable, Awaitable
|
| 18 |
+
from playwright.async_api import Browser as AsyncBrowser, Playwright as AsyncPlaywright
|
| 19 |
+
|
| 20 |
+
# --- FIX: Replaced star import with explicit imports ---
|
| 21 |
+
from config import NO_PROXY_ENV, EXCLUDED_MODELS_FILENAME
|
| 22 |
+
|
| 23 |
+
# --- models模块导入 ---
|
| 24 |
+
from models import WebSocketConnectionManager
|
| 25 |
+
|
| 26 |
+
# --- logging_utils模块导入 ---
|
| 27 |
+
from logging_utils import setup_server_logging, restore_original_streams
|
| 28 |
+
|
| 29 |
+
# --- browser_utils模块导入 ---
|
| 30 |
+
from browser_utils import (
|
| 31 |
+
_initialize_page_logic,
|
| 32 |
+
_close_page_logic,
|
| 33 |
+
load_excluded_models,
|
| 34 |
+
_handle_initial_model_state_and_storage,
|
| 35 |
+
enable_temporary_chat_mode
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
import stream
|
| 39 |
+
from asyncio import Queue, Lock
|
| 40 |
+
from . import auth_utils
|
| 41 |
+
|
| 42 |
+
# 全局状态变量(这些将在server.py中被引用)
|
| 43 |
+
playwright_manager: Optional[AsyncPlaywright] = None
|
| 44 |
+
browser_instance: Optional[AsyncBrowser] = None
|
| 45 |
+
page_instance = None
|
| 46 |
+
is_playwright_ready = False
|
| 47 |
+
is_browser_connected = False
|
| 48 |
+
is_page_ready = False
|
| 49 |
+
is_initializing = False
|
| 50 |
+
|
| 51 |
+
global_model_list_raw_json = None
|
| 52 |
+
parsed_model_list = []
|
| 53 |
+
model_list_fetch_event = None
|
| 54 |
+
|
| 55 |
+
current_ai_studio_model_id = None
|
| 56 |
+
model_switching_lock = None
|
| 57 |
+
|
| 58 |
+
excluded_model_ids = set()
|
| 59 |
+
|
| 60 |
+
request_queue = None
|
| 61 |
+
processing_lock = None
|
| 62 |
+
worker_task = None
|
| 63 |
+
|
| 64 |
+
page_params_cache = {}
|
| 65 |
+
params_cache_lock = None
|
| 66 |
+
|
| 67 |
+
log_ws_manager = None
|
| 68 |
+
|
| 69 |
+
STREAM_QUEUE = None
|
| 70 |
+
STREAM_PROCESS = None
|
| 71 |
+
|
| 72 |
+
# --- Lifespan Context Manager ---
|
| 73 |
+
def _setup_logging():
|
| 74 |
+
import server
|
| 75 |
+
log_level_env = os.environ.get('SERVER_LOG_LEVEL', 'INFO')
|
| 76 |
+
redirect_print_env = os.environ.get('SERVER_REDIRECT_PRINT', 'false')
|
| 77 |
+
server.log_ws_manager = WebSocketConnectionManager()
|
| 78 |
+
return setup_server_logging(
|
| 79 |
+
logger_instance=server.logger,
|
| 80 |
+
log_ws_manager=server.log_ws_manager,
|
| 81 |
+
log_level_name=log_level_env,
|
| 82 |
+
redirect_print_str=redirect_print_env
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
def _initialize_globals():
|
| 86 |
+
import server
|
| 87 |
+
server.request_queue = Queue()
|
| 88 |
+
server.processing_lock = Lock()
|
| 89 |
+
server.model_switching_lock = Lock()
|
| 90 |
+
server.params_cache_lock = Lock()
|
| 91 |
+
auth_utils.initialize_keys()
|
| 92 |
+
server.logger.info("API keys and global locks initialized.")
|
| 93 |
+
|
| 94 |
+
def _initialize_proxy_settings():
|
| 95 |
+
import server
|
| 96 |
+
STREAM_PORT = os.environ.get('STREAM_PORT')
|
| 97 |
+
if STREAM_PORT == '0':
|
| 98 |
+
PROXY_SERVER_ENV = os.environ.get('HTTPS_PROXY') or os.environ.get('HTTP_PROXY')
|
| 99 |
+
else:
|
| 100 |
+
PROXY_SERVER_ENV = f"http://127.0.0.1:{STREAM_PORT or 3120}/"
|
| 101 |
+
|
| 102 |
+
if PROXY_SERVER_ENV:
|
| 103 |
+
server.PLAYWRIGHT_PROXY_SETTINGS = {'server': PROXY_SERVER_ENV}
|
| 104 |
+
if NO_PROXY_ENV:
|
| 105 |
+
server.PLAYWRIGHT_PROXY_SETTINGS['bypass'] = NO_PROXY_ENV.replace(',', ';')
|
| 106 |
+
server.logger.info(f"Playwright proxy settings configured: {server.PLAYWRIGHT_PROXY_SETTINGS}")
|
| 107 |
+
else:
|
| 108 |
+
server.logger.info("No proxy configured for Playwright.")
|
| 109 |
+
|
| 110 |
+
async def _start_stream_proxy():
|
| 111 |
+
import server
|
| 112 |
+
STREAM_PORT = os.environ.get('STREAM_PORT')
|
| 113 |
+
if STREAM_PORT != '0':
|
| 114 |
+
port = int(STREAM_PORT or 3120)
|
| 115 |
+
STREAM_PROXY_SERVER_ENV = os.environ.get('UNIFIED_PROXY_CONFIG') or os.environ.get('HTTPS_PROXY') or os.environ.get('HTTP_PROXY')
|
| 116 |
+
server.logger.info(f"Starting STREAM proxy on port {port} with upstream proxy: {STREAM_PROXY_SERVER_ENV}")
|
| 117 |
+
server.STREAM_QUEUE = multiprocessing.Queue()
|
| 118 |
+
server.STREAM_PROCESS = multiprocessing.Process(target=stream.start, args=(server.STREAM_QUEUE, port, STREAM_PROXY_SERVER_ENV))
|
| 119 |
+
server.STREAM_PROCESS.start()
|
| 120 |
+
server.logger.info("STREAM proxy process started. Waiting for 'READY' signal...")
|
| 121 |
+
|
| 122 |
+
# --- FIX: Wait for the proxy to be ready ---
|
| 123 |
+
try:
|
| 124 |
+
# Use asyncio.to_thread to wait for the blocking queue.get()
|
| 125 |
+
# Set a timeout to avoid waiting forever
|
| 126 |
+
ready_signal = await asyncio.to_thread(server.STREAM_QUEUE.get, timeout=15)
|
| 127 |
+
if ready_signal == "READY":
|
| 128 |
+
server.logger.info("✅ Received 'READY' signal from STREAM proxy.")
|
| 129 |
+
else:
|
| 130 |
+
server.logger.warning(f"Received unexpected signal from proxy: {ready_signal}")
|
| 131 |
+
except queue.Empty:
|
| 132 |
+
server.logger.error("❌ Timed out waiting for STREAM proxy to become ready. Startup will likely fail.")
|
| 133 |
+
raise RuntimeError("STREAM proxy failed to start in time.")
|
| 134 |
+
|
| 135 |
+
async def _initialize_browser_and_page():
|
| 136 |
+
import server
|
| 137 |
+
from playwright.async_api import async_playwright
|
| 138 |
+
|
| 139 |
+
server.logger.info("Starting Playwright...")
|
| 140 |
+
server.playwright_manager = await async_playwright().start()
|
| 141 |
+
server.is_playwright_ready = True
|
| 142 |
+
server.logger.info("Playwright started.")
|
| 143 |
+
|
| 144 |
+
ws_endpoint = os.environ.get('CAMOUFOX_WS_ENDPOINT')
|
| 145 |
+
launch_mode = os.environ.get('LAUNCH_MODE', 'unknown')
|
| 146 |
+
|
| 147 |
+
if not ws_endpoint and launch_mode != "direct_debug_no_browser":
|
| 148 |
+
raise ValueError("CAMOUFOX_WS_ENDPOINT environment variable is missing.")
|
| 149 |
+
|
| 150 |
+
if ws_endpoint:
|
| 151 |
+
server.logger.info(f"Connecting to browser at: {ws_endpoint}")
|
| 152 |
+
server.browser_instance = await server.playwright_manager.firefox.connect(ws_endpoint, timeout=30000)
|
| 153 |
+
server.is_browser_connected = True
|
| 154 |
+
server.logger.info(f"Connected to browser: {server.browser_instance.version}")
|
| 155 |
+
|
| 156 |
+
server.page_instance, server.is_page_ready = await _initialize_page_logic(server.browser_instance)
|
| 157 |
+
if server.is_page_ready:
|
| 158 |
+
await _handle_initial_model_state_and_storage(server.page_instance)
|
| 159 |
+
await enable_temporary_chat_mode(server.page_instance)
|
| 160 |
+
server.logger.info("Page initialized successfully.")
|
| 161 |
+
else:
|
| 162 |
+
server.logger.error("Page initialization failed.")
|
| 163 |
+
|
| 164 |
+
if not server.model_list_fetch_event.is_set():
|
| 165 |
+
server.model_list_fetch_event.set()
|
| 166 |
+
|
| 167 |
+
async def _shutdown_resources():
|
| 168 |
+
import server
|
| 169 |
+
logger = server.logger
|
| 170 |
+
logger.info("Shutting down resources...")
|
| 171 |
+
|
| 172 |
+
if server.STREAM_PROCESS:
|
| 173 |
+
server.STREAM_PROCESS.terminate()
|
| 174 |
+
logger.info("STREAM proxy terminated.")
|
| 175 |
+
|
| 176 |
+
if server.worker_task and not server.worker_task.done():
|
| 177 |
+
server.worker_task.cancel()
|
| 178 |
+
try:
|
| 179 |
+
await asyncio.wait_for(server.worker_task, timeout=5.0)
|
| 180 |
+
except (asyncio.TimeoutError, asyncio.CancelledError):
|
| 181 |
+
pass
|
| 182 |
+
logger.info("Worker task stopped.")
|
| 183 |
+
|
| 184 |
+
if server.page_instance:
|
| 185 |
+
await _close_page_logic()
|
| 186 |
+
|
| 187 |
+
if server.browser_instance and server.browser_instance.is_connected():
|
| 188 |
+
await server.browser_instance.close()
|
| 189 |
+
logger.info("Browser connection closed.")
|
| 190 |
+
|
| 191 |
+
if server.playwright_manager:
|
| 192 |
+
await server.playwright_manager.stop()
|
| 193 |
+
logger.info("Playwright stopped.")
|
| 194 |
+
|
| 195 |
+
@asynccontextmanager
|
| 196 |
+
async def lifespan(app: FastAPI):
|
| 197 |
+
"""FastAPI application life cycle management"""
|
| 198 |
+
import server
|
| 199 |
+
from server import queue_worker
|
| 200 |
+
|
| 201 |
+
original_streams = sys.stdout, sys.stderr
|
| 202 |
+
initial_stdout, initial_stderr = _setup_logging()
|
| 203 |
+
logger = server.logger
|
| 204 |
+
|
| 205 |
+
_initialize_globals()
|
| 206 |
+
_initialize_proxy_settings()
|
| 207 |
+
load_excluded_models(EXCLUDED_MODELS_FILENAME)
|
| 208 |
+
|
| 209 |
+
server.is_initializing = True
|
| 210 |
+
logger.info("Starting AI Studio Proxy Server...")
|
| 211 |
+
|
| 212 |
+
try:
|
| 213 |
+
await _start_stream_proxy()
|
| 214 |
+
await _initialize_browser_and_page()
|
| 215 |
+
|
| 216 |
+
launch_mode = os.environ.get('LAUNCH_MODE', 'unknown')
|
| 217 |
+
if server.is_page_ready or launch_mode == "direct_debug_no_browser":
|
| 218 |
+
server.worker_task = asyncio.create_task(queue_worker())
|
| 219 |
+
logger.info("Request processing worker started.")
|
| 220 |
+
else:
|
| 221 |
+
raise RuntimeError("Failed to initialize browser/page, worker not started.")
|
| 222 |
+
|
| 223 |
+
logger.info("Server startup complete.")
|
| 224 |
+
server.is_initializing = False
|
| 225 |
+
yield
|
| 226 |
+
except Exception as e:
|
| 227 |
+
logger.critical(f"Application startup failed: {e}", exc_info=True)
|
| 228 |
+
await _shutdown_resources()
|
| 229 |
+
raise RuntimeError(f"Application startup failed: {e}") from e
|
| 230 |
+
finally:
|
| 231 |
+
logger.info("Shutting down server...")
|
| 232 |
+
await _shutdown_resources()
|
| 233 |
+
restore_original_streams(initial_stdout, initial_stderr)
|
| 234 |
+
restore_original_streams(*original_streams)
|
| 235 |
+
logger.info("Server shutdown complete.")
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
class APIKeyAuthMiddleware(BaseHTTPMiddleware):
|
| 239 |
+
def __init__(self, app: ASGIApp):
|
| 240 |
+
super().__init__(app)
|
| 241 |
+
self.excluded_paths = [
|
| 242 |
+
"/v1/models",
|
| 243 |
+
"/health",
|
| 244 |
+
"/docs",
|
| 245 |
+
"/openapi.json",
|
| 246 |
+
# FastAPI 自动生成的其他文档路径
|
| 247 |
+
"/redoc",
|
| 248 |
+
"/favicon.ico"
|
| 249 |
+
]
|
| 250 |
+
|
| 251 |
+
async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable]):
|
| 252 |
+
if not auth_utils.API_KEYS: # 如果 API_KEYS 为空,则不进行验证
|
| 253 |
+
return await call_next(request)
|
| 254 |
+
|
| 255 |
+
# 检查是否是需要保护的路径
|
| 256 |
+
if not request.url.path.startswith("/v1/"):
|
| 257 |
+
return await call_next(request)
|
| 258 |
+
|
| 259 |
+
# 检查是否是排除的路径
|
| 260 |
+
for excluded_path in self.excluded_paths:
|
| 261 |
+
if request.url.path == excluded_path or request.url.path.startswith(excluded_path + "/"):
|
| 262 |
+
return await call_next(request)
|
| 263 |
+
|
| 264 |
+
# 支持多种认证头格式以兼容OpenAI标准
|
| 265 |
+
api_key = None
|
| 266 |
+
|
| 267 |
+
# 1. 优先检查标准的 Authorization: Bearer <token> 头
|
| 268 |
+
auth_header = request.headers.get("Authorization")
|
| 269 |
+
if auth_header and auth_header.startswith("Bearer "):
|
| 270 |
+
api_key = auth_header[7:] # 移除 "Bearer " 前缀
|
| 271 |
+
|
| 272 |
+
# 2. 回退到自定义的 X-API-Key 头(向后兼容)
|
| 273 |
+
if not api_key:
|
| 274 |
+
api_key = request.headers.get("X-API-Key")
|
| 275 |
+
|
| 276 |
+
if not api_key or not auth_utils.verify_api_key(api_key):
|
| 277 |
+
return JSONResponse(
|
| 278 |
+
status_code=401,
|
| 279 |
+
content={
|
| 280 |
+
"error": {
|
| 281 |
+
"message": "Invalid or missing API key. Please provide a valid API key using 'Authorization: Bearer <your_key>' or 'X-API-Key: <your_key>' header.",
|
| 282 |
+
"type": "invalid_request_error",
|
| 283 |
+
"param": None,
|
| 284 |
+
"code": "invalid_api_key"
|
| 285 |
+
}
|
| 286 |
+
}
|
| 287 |
+
)
|
| 288 |
+
return await call_next(request)
|
| 289 |
+
|
| 290 |
+
def create_app() -> FastAPI:
|
| 291 |
+
"""创建FastAPI应用实例"""
|
| 292 |
+
app = FastAPI(
|
| 293 |
+
title="AI Studio Proxy Server (集成模式)",
|
| 294 |
+
description="通过 Playwright与 AI Studio 交互的代理服务器。",
|
| 295 |
+
version="0.6.0-integrated",
|
| 296 |
+
lifespan=lifespan
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
# 添加中间件
|
| 300 |
+
app.add_middleware(APIKeyAuthMiddleware)
|
| 301 |
+
|
| 302 |
+
# 注册路由
|
| 303 |
+
from .routes import (
|
| 304 |
+
read_index, get_css, get_js, get_api_info,
|
| 305 |
+
health_check, list_models, chat_completions,
|
| 306 |
+
cancel_request, get_queue_status, websocket_log_endpoint,
|
| 307 |
+
get_api_keys, add_api_key, test_api_key, delete_api_key
|
| 308 |
+
)
|
| 309 |
+
from fastapi.responses import FileResponse
|
| 310 |
+
|
| 311 |
+
app.get("/", response_class=FileResponse)(read_index)
|
| 312 |
+
app.get("/webui.css")(get_css)
|
| 313 |
+
app.get("/webui.js")(get_js)
|
| 314 |
+
app.get("/api/info")(get_api_info)
|
| 315 |
+
app.get("/health")(health_check)
|
| 316 |
+
app.get("/v1/models")(list_models)
|
| 317 |
+
app.post("/v1/chat/completions")(chat_completions)
|
| 318 |
+
app.post("/v1/cancel/{req_id}")(cancel_request)
|
| 319 |
+
app.get("/v1/queue")(get_queue_status)
|
| 320 |
+
app.websocket("/ws/logs")(websocket_log_endpoint)
|
| 321 |
+
|
| 322 |
+
# API密钥管理端点
|
| 323 |
+
app.get("/api/keys")(get_api_keys)
|
| 324 |
+
app.post("/api/keys")(add_api_key)
|
| 325 |
+
app.post("/api/keys/test")(test_api_key)
|
| 326 |
+
app.delete("/api/keys")(delete_api_key)
|
| 327 |
+
|
| 328 |
+
return app
|
AIStudioProxyAPI/api_utils/auth_utils.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from typing import Set
|
| 3 |
+
|
| 4 |
+
API_KEYS: Set[str] = set()
|
| 5 |
+
KEY_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "auth_profiles", "key.txt")
|
| 6 |
+
|
| 7 |
+
def load_api_keys():
|
| 8 |
+
"""Loads API keys from the key file into the API_KEYS set."""
|
| 9 |
+
global API_KEYS
|
| 10 |
+
API_KEYS.clear()
|
| 11 |
+
if os.path.exists(KEY_FILE_PATH):
|
| 12 |
+
with open(KEY_FILE_PATH, "r") as f:
|
| 13 |
+
for line in f:
|
| 14 |
+
key = line.strip()
|
| 15 |
+
if key:
|
| 16 |
+
API_KEYS.add(key)
|
| 17 |
+
|
| 18 |
+
def initialize_keys():
|
| 19 |
+
"""Initializes API keys. Ensures key.txt exists and loads keys."""
|
| 20 |
+
if not os.path.exists(KEY_FILE_PATH):
|
| 21 |
+
with open(KEY_FILE_PATH, "w") as f:
|
| 22 |
+
pass # Create an empty file
|
| 23 |
+
load_api_keys()
|
| 24 |
+
|
| 25 |
+
def verify_api_key(api_key_from_header: str) -> bool:
|
| 26 |
+
"""
|
| 27 |
+
Verifies the API key.
|
| 28 |
+
Returns True if API_KEYS is empty (no validation) or if the key is valid.
|
| 29 |
+
"""
|
| 30 |
+
if not API_KEYS:
|
| 31 |
+
return True
|
| 32 |
+
return api_key_from_header in API_KEYS
|
AIStudioProxyAPI/api_utils/dependencies.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI 依赖项模块
|
| 3 |
+
"""
|
| 4 |
+
import logging
|
| 5 |
+
from asyncio import Queue, Lock, Event
|
| 6 |
+
from typing import Dict, Any, List, Set
|
| 7 |
+
|
| 8 |
+
from fastapi import Request
|
| 9 |
+
|
| 10 |
+
def get_logger() -> logging.Logger:
|
| 11 |
+
from server import logger
|
| 12 |
+
return logger
|
| 13 |
+
|
| 14 |
+
def get_log_ws_manager():
|
| 15 |
+
from server import log_ws_manager
|
| 16 |
+
return log_ws_manager
|
| 17 |
+
|
| 18 |
+
def get_request_queue() -> Queue:
|
| 19 |
+
from server import request_queue
|
| 20 |
+
return request_queue
|
| 21 |
+
|
| 22 |
+
def get_processing_lock() -> Lock:
|
| 23 |
+
from server import processing_lock
|
| 24 |
+
return processing_lock
|
| 25 |
+
|
| 26 |
+
def get_worker_task():
|
| 27 |
+
from server import worker_task
|
| 28 |
+
return worker_task
|
| 29 |
+
|
| 30 |
+
def get_server_state() -> Dict[str, Any]:
|
| 31 |
+
from server import is_initializing, is_playwright_ready, is_browser_connected, is_page_ready
|
| 32 |
+
return {
|
| 33 |
+
"is_initializing": is_initializing,
|
| 34 |
+
"is_playwright_ready": is_playwright_ready,
|
| 35 |
+
"is_browser_connected": is_browser_connected,
|
| 36 |
+
"is_page_ready": is_page_ready,
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
def get_page_instance():
|
| 40 |
+
from server import page_instance
|
| 41 |
+
return page_instance
|
| 42 |
+
|
| 43 |
+
def get_model_list_fetch_event() -> Event:
|
| 44 |
+
from server import model_list_fetch_event
|
| 45 |
+
return model_list_fetch_event
|
| 46 |
+
|
| 47 |
+
def get_parsed_model_list() -> List[Dict[str, Any]]:
|
| 48 |
+
from server import parsed_model_list
|
| 49 |
+
return parsed_model_list
|
| 50 |
+
|
| 51 |
+
def get_excluded_model_ids() -> Set[str]:
|
| 52 |
+
from server import excluded_model_ids
|
| 53 |
+
return excluded_model_ids
|
| 54 |
+
|
| 55 |
+
def get_current_ai_studio_model_id() -> str:
|
| 56 |
+
from server import current_ai_studio_model_id
|
| 57 |
+
return current_ai_studio_model_id
|
AIStudioProxyAPI/api_utils/queue_worker.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
队列工作器模块
|
| 3 |
+
处理请求队列中的任务
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import time
|
| 8 |
+
from fastapi import HTTPException
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
async def queue_worker():
|
| 13 |
+
"""队列工作器,处理请求队列中的任务"""
|
| 14 |
+
# 导入全局变量
|
| 15 |
+
from server import (
|
| 16 |
+
logger, request_queue, processing_lock, model_switching_lock,
|
| 17 |
+
params_cache_lock
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
logger.info("--- 队列 Worker 已启动 ---")
|
| 21 |
+
|
| 22 |
+
# 检查并初始化全局变量
|
| 23 |
+
if request_queue is None:
|
| 24 |
+
logger.info("初始化 request_queue...")
|
| 25 |
+
from asyncio import Queue
|
| 26 |
+
request_queue = Queue()
|
| 27 |
+
|
| 28 |
+
if processing_lock is None:
|
| 29 |
+
logger.info("初始化 processing_lock...")
|
| 30 |
+
from asyncio import Lock
|
| 31 |
+
processing_lock = Lock()
|
| 32 |
+
|
| 33 |
+
if model_switching_lock is None:
|
| 34 |
+
logger.info("初始化 model_switching_lock...")
|
| 35 |
+
from asyncio import Lock
|
| 36 |
+
model_switching_lock = Lock()
|
| 37 |
+
|
| 38 |
+
if params_cache_lock is None:
|
| 39 |
+
logger.info("初始化 params_cache_lock...")
|
| 40 |
+
from asyncio import Lock
|
| 41 |
+
params_cache_lock = Lock()
|
| 42 |
+
|
| 43 |
+
was_last_request_streaming = False
|
| 44 |
+
last_request_completion_time = 0
|
| 45 |
+
|
| 46 |
+
while True:
|
| 47 |
+
request_item = None
|
| 48 |
+
result_future = None
|
| 49 |
+
req_id = "UNKNOWN"
|
| 50 |
+
completion_event = None
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
# 检查队列中的项目,清理已断开连接的请求
|
| 54 |
+
queue_size = request_queue.qsize()
|
| 55 |
+
if queue_size > 0:
|
| 56 |
+
checked_count = 0
|
| 57 |
+
items_to_requeue = []
|
| 58 |
+
processed_ids = set()
|
| 59 |
+
|
| 60 |
+
while checked_count < queue_size and checked_count < 10:
|
| 61 |
+
try:
|
| 62 |
+
item = request_queue.get_nowait()
|
| 63 |
+
item_req_id = item.get("req_id", "unknown")
|
| 64 |
+
|
| 65 |
+
if item_req_id in processed_ids:
|
| 66 |
+
items_to_requeue.append(item)
|
| 67 |
+
continue
|
| 68 |
+
|
| 69 |
+
processed_ids.add(item_req_id)
|
| 70 |
+
|
| 71 |
+
if not item.get("cancelled", False):
|
| 72 |
+
item_http_request = item.get("http_request")
|
| 73 |
+
if item_http_request:
|
| 74 |
+
try:
|
| 75 |
+
if await item_http_request.is_disconnected():
|
| 76 |
+
logger.info(f"[{item_req_id}] (Worker Queue Check) 检测到客户端已断开,标记为取消。")
|
| 77 |
+
item["cancelled"] = True
|
| 78 |
+
item_future = item.get("result_future")
|
| 79 |
+
if item_future and not item_future.done():
|
| 80 |
+
item_future.set_exception(HTTPException(status_code=499, detail=f"[{item_req_id}] Client disconnected while queued."))
|
| 81 |
+
except Exception as check_err:
|
| 82 |
+
logger.error(f"[{item_req_id}] (Worker Queue Check) Error checking disconnect: {check_err}")
|
| 83 |
+
|
| 84 |
+
items_to_requeue.append(item)
|
| 85 |
+
checked_count += 1
|
| 86 |
+
except asyncio.QueueEmpty:
|
| 87 |
+
break
|
| 88 |
+
|
| 89 |
+
for item in items_to_requeue:
|
| 90 |
+
await request_queue.put(item)
|
| 91 |
+
|
| 92 |
+
# 获取下一个请求
|
| 93 |
+
try:
|
| 94 |
+
request_item = await asyncio.wait_for(request_queue.get(), timeout=5.0)
|
| 95 |
+
except asyncio.TimeoutError:
|
| 96 |
+
# 如果5秒内没有新请求,继续循环检查
|
| 97 |
+
continue
|
| 98 |
+
|
| 99 |
+
req_id = request_item["req_id"]
|
| 100 |
+
request_data = request_item["request_data"]
|
| 101 |
+
http_request = request_item["http_request"]
|
| 102 |
+
result_future = request_item["result_future"]
|
| 103 |
+
|
| 104 |
+
if request_item.get("cancelled", False):
|
| 105 |
+
logger.info(f"[{req_id}] (Worker) 请求已取消,跳过。")
|
| 106 |
+
if not result_future.done():
|
| 107 |
+
result_future.set_exception(HTTPException(status_code=499, detail=f"[{req_id}] 请求已被用户取消"))
|
| 108 |
+
request_queue.task_done()
|
| 109 |
+
continue
|
| 110 |
+
|
| 111 |
+
is_streaming_request = request_data.stream
|
| 112 |
+
logger.info(f"[{req_id}] (Worker) 取出请求。模式: {'流式' if is_streaming_request else '非流式'}")
|
| 113 |
+
|
| 114 |
+
# 优化:在开始处理前主动检测客户端连接状态,避免不必要的处理
|
| 115 |
+
from api_utils.request_processor import _test_client_connection
|
| 116 |
+
is_connected = await _test_client_connection(req_id, http_request)
|
| 117 |
+
if not is_connected:
|
| 118 |
+
logger.info(f"[{req_id}] (Worker) ✅ 主动检测到客户端已断开,跳过处理节省资源")
|
| 119 |
+
if not result_future.done():
|
| 120 |
+
result_future.set_exception(HTTPException(status_code=499, detail=f"[{req_id}] 客户端在处理前已断开连接"))
|
| 121 |
+
request_queue.task_done()
|
| 122 |
+
continue
|
| 123 |
+
|
| 124 |
+
# 流式请求间隔控制
|
| 125 |
+
current_time = time.time()
|
| 126 |
+
if was_last_request_streaming and is_streaming_request and (current_time - last_request_completion_time < 1.0):
|
| 127 |
+
delay_time = max(0.5, 1.0 - (current_time - last_request_completion_time))
|
| 128 |
+
logger.info(f"[{req_id}] (Worker) 连续流式请求,添加 {delay_time:.2f}s 延迟...")
|
| 129 |
+
await asyncio.sleep(delay_time)
|
| 130 |
+
|
| 131 |
+
# 等待锁前再次主动检测客户端连接
|
| 132 |
+
is_connected = await _test_client_connection(req_id, http_request)
|
| 133 |
+
if not is_connected:
|
| 134 |
+
logger.info(f"[{req_id}] (Worker) ✅ 等待锁时检测到客户端断开,取消处理")
|
| 135 |
+
if not result_future.done():
|
| 136 |
+
result_future.set_exception(HTTPException(status_code=499, detail=f"[{req_id}] 客户端关闭了请求"))
|
| 137 |
+
request_queue.task_done()
|
| 138 |
+
continue
|
| 139 |
+
|
| 140 |
+
logger.info(f"[{req_id}] (Worker) 等待处理锁...")
|
| 141 |
+
async with processing_lock:
|
| 142 |
+
logger.info(f"[{req_id}] (Worker) 已获取处理锁。开始核心处理...")
|
| 143 |
+
|
| 144 |
+
# 获取锁后最终主动检测客户端连接
|
| 145 |
+
is_connected = await _test_client_connection(req_id, http_request)
|
| 146 |
+
if not is_connected:
|
| 147 |
+
logger.info(f"[{req_id}] (Worker) ✅ 获取锁后检测到客户端断开,取消处理")
|
| 148 |
+
if not result_future.done():
|
| 149 |
+
result_future.set_exception(HTTPException(status_code=499, detail=f"[{req_id}] 客户端关闭了请求"))
|
| 150 |
+
elif result_future.done():
|
| 151 |
+
logger.info(f"[{req_id}] (Worker) Future 在处理前已完成/取消。跳过。")
|
| 152 |
+
else:
|
| 153 |
+
# 调用实际的请求处理函数
|
| 154 |
+
try:
|
| 155 |
+
from api_utils import _process_request_refactored
|
| 156 |
+
returned_value = await _process_request_refactored(
|
| 157 |
+
req_id, request_data, http_request, result_future
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
completion_event, submit_btn_loc, client_disco_checker = None, None, None
|
| 161 |
+
current_request_was_streaming = False
|
| 162 |
+
|
| 163 |
+
if isinstance(returned_value, tuple) and len(returned_value) == 3:
|
| 164 |
+
completion_event, submit_btn_loc, client_disco_checker = returned_value
|
| 165 |
+
if completion_event is not None:
|
| 166 |
+
current_request_was_streaming = True
|
| 167 |
+
logger.info(f"[{req_id}] (Worker) _process_request_refactored returned stream info (event, locator, checker).")
|
| 168 |
+
else:
|
| 169 |
+
current_request_was_streaming = False
|
| 170 |
+
logger.info(f"[{req_id}] (Worker) _process_request_refactored returned a tuple, but completion_event is None (likely non-stream or early exit).")
|
| 171 |
+
elif returned_value is None:
|
| 172 |
+
current_request_was_streaming = False
|
| 173 |
+
logger.info(f"[{req_id}] (Worker) _process_request_refactored returned non-stream completion (None).")
|
| 174 |
+
else:
|
| 175 |
+
current_request_was_streaming = False
|
| 176 |
+
logger.warning(f"[{req_id}] (Worker) _process_request_refactored returned unexpected type: {type(returned_value)}")
|
| 177 |
+
|
| 178 |
+
# 统一的客户端断开检测和响应处理
|
| 179 |
+
if completion_event:
|
| 180 |
+
# 流式模式:等待流式生成器完成信号
|
| 181 |
+
logger.info(f"[{req_id}] (Worker) 等待流式生成器完成信号...")
|
| 182 |
+
|
| 183 |
+
# 创建一个增强的客户端断开检测器,支持提前done信号触发
|
| 184 |
+
client_disconnected_early = False
|
| 185 |
+
|
| 186 |
+
async def enhanced_disconnect_monitor():
|
| 187 |
+
nonlocal client_disconnected_early
|
| 188 |
+
while not completion_event.is_set():
|
| 189 |
+
try:
|
| 190 |
+
# 主动检查客户端是否断开连接
|
| 191 |
+
is_connected = await _test_client_connection(req_id, http_request)
|
| 192 |
+
if not is_connected:
|
| 193 |
+
logger.info(f"[{req_id}] (Worker) ✅ 流式处理中检测到客户端断开,提前触发done信号")
|
| 194 |
+
client_disconnected_early = True
|
| 195 |
+
# 立即设置completion_event以提前结束等待
|
| 196 |
+
if not completion_event.is_set():
|
| 197 |
+
completion_event.set()
|
| 198 |
+
break
|
| 199 |
+
await asyncio.sleep(0.3) # 更频繁的检查间隔
|
| 200 |
+
except Exception as e:
|
| 201 |
+
logger.error(f"[{req_id}] (Worker) 增强断开检测器错误: {e}")
|
| 202 |
+
break
|
| 203 |
+
|
| 204 |
+
# 启动增强的断开连接监控
|
| 205 |
+
disconnect_monitor_task = asyncio.create_task(enhanced_disconnect_monitor())
|
| 206 |
+
else:
|
| 207 |
+
# 非流式模式:等待处理完成并检测客户端断开
|
| 208 |
+
logger.info(f"[{req_id}] (Worker) 非流式模式,等待处理完成...")
|
| 209 |
+
|
| 210 |
+
client_disconnected_early = False
|
| 211 |
+
|
| 212 |
+
async def non_streaming_disconnect_monitor():
|
| 213 |
+
nonlocal client_disconnected_early
|
| 214 |
+
while not result_future.done():
|
| 215 |
+
try:
|
| 216 |
+
# 主动检查客户端是否断开连接
|
| 217 |
+
is_connected = await _test_client_connection(req_id, http_request)
|
| 218 |
+
if not is_connected:
|
| 219 |
+
logger.info(f"[{req_id}] (Worker) ✅ 非流式处理中检测到客户端断开,取消处理")
|
| 220 |
+
client_disconnected_early = True
|
| 221 |
+
# 取消result_future
|
| 222 |
+
if not result_future.done():
|
| 223 |
+
result_future.set_exception(HTTPException(status_code=499, detail=f"[{req_id}] 客户端在非流式处理中断开连接"))
|
| 224 |
+
break
|
| 225 |
+
await asyncio.sleep(0.3) # 更频繁的检查间隔
|
| 226 |
+
except Exception as e:
|
| 227 |
+
logger.error(f"[{req_id}] (Worker) 非流式断开检测器错误: {e}")
|
| 228 |
+
break
|
| 229 |
+
|
| 230 |
+
# 启动非流式断开连接监控
|
| 231 |
+
disconnect_monitor_task = asyncio.create_task(non_streaming_disconnect_monitor())
|
| 232 |
+
|
| 233 |
+
# 等待处理完成(流式或非流式)
|
| 234 |
+
try:
|
| 235 |
+
if completion_event:
|
| 236 |
+
# 流式模式:等待completion_event
|
| 237 |
+
from server import RESPONSE_COMPLETION_TIMEOUT
|
| 238 |
+
await asyncio.wait_for(completion_event.wait(), timeout=RESPONSE_COMPLETION_TIMEOUT/1000 + 60)
|
| 239 |
+
logger.info(f"[{req_id}] (Worker) ✅ 流式生成器完成信号收到。客户端提前断开: {client_disconnected_early}")
|
| 240 |
+
else:
|
| 241 |
+
# 非流式模式:等待result_future完成
|
| 242 |
+
from server import RESPONSE_COMPLETION_TIMEOUT
|
| 243 |
+
await asyncio.wait_for(asyncio.shield(result_future), timeout=RESPONSE_COMPLETION_TIMEOUT/1000 + 60)
|
| 244 |
+
logger.info(f"[{req_id}] (Worker) ✅ 非流式处理完成。客户端提前断开: {client_disconnected_early}")
|
| 245 |
+
|
| 246 |
+
# 如果客户端提前断开,跳过按钮状态处理
|
| 247 |
+
if client_disconnected_early:
|
| 248 |
+
logger.info(f"[{req_id}] (Worker) 客户端提前断开,跳过按钮状态处理")
|
| 249 |
+
elif submit_btn_loc and client_disco_checker and completion_event:
|
| 250 |
+
# 等待发送按钮禁用确认流式响应完全结束
|
| 251 |
+
logger.info(f"[{req_id}] (Worker) 流式响应完成,检查并处理发送按钮状态...")
|
| 252 |
+
wait_timeout_ms = 30000 # 30 seconds
|
| 253 |
+
try:
|
| 254 |
+
from playwright.async_api import expect as expect_async
|
| 255 |
+
from api_utils.request_processor import ClientDisconnectedError
|
| 256 |
+
|
| 257 |
+
# 检查客户端连接状态
|
| 258 |
+
client_disco_checker("流式响应后按钮状态检查 - 前置检查: ")
|
| 259 |
+
await asyncio.sleep(0.5) # 给UI一点时间更新
|
| 260 |
+
|
| 261 |
+
# 检查按钮是否仍然启用,如果启用则直接点击停止
|
| 262 |
+
logger.info(f"[{req_id}] (Worker) 检查发送按钮状态...")
|
| 263 |
+
try:
|
| 264 |
+
is_button_enabled = await submit_btn_loc.is_enabled(timeout=2000)
|
| 265 |
+
logger.info(f"[{req_id}] (Worker) 发送按钮启用状态: {is_button_enabled}")
|
| 266 |
+
|
| 267 |
+
if is_button_enabled:
|
| 268 |
+
# 流式响应完成后按钮仍启用,直接点击停止
|
| 269 |
+
logger.info(f"[{req_id}] (Worker) 流式响应完成但按钮仍启用,主动点击按钮停止生成...")
|
| 270 |
+
await submit_btn_loc.click(timeout=5000, force=True)
|
| 271 |
+
logger.info(f"[{req_id}] (Worker) ✅ 发送按钮点击完成。")
|
| 272 |
+
else:
|
| 273 |
+
logger.info(f"[{req_id}] (Worker) 发送按钮已禁用,无需点击。")
|
| 274 |
+
except Exception as button_check_err:
|
| 275 |
+
logger.warning(f"[{req_id}] (Worker) 检查按钮状态失败: {button_check_err}")
|
| 276 |
+
|
| 277 |
+
# 等待按钮最终禁用
|
| 278 |
+
logger.info(f"[{req_id}] (Worker) 等待发送按钮最终禁用...")
|
| 279 |
+
await expect_async(submit_btn_loc).to_be_disabled(timeout=wait_timeout_ms)
|
| 280 |
+
logger.info(f"[{req_id}] ✅ 发送按钮已禁用。")
|
| 281 |
+
|
| 282 |
+
except Exception as e_pw_disabled:
|
| 283 |
+
logger.warning(f"[{req_id}] ⚠️ 流式响应后按钮状态处理超时或错误: {e_pw_disabled}")
|
| 284 |
+
from api_utils.request_processor import save_error_snapshot
|
| 285 |
+
await save_error_snapshot(f"stream_post_submit_button_handling_timeout_{req_id}")
|
| 286 |
+
except ClientDisconnectedError:
|
| 287 |
+
logger.info(f"[{req_id}] 客户端在流式响应后按钮状态处理时断开连接。")
|
| 288 |
+
elif completion_event and current_request_was_streaming:
|
| 289 |
+
logger.warning(f"[{req_id}] (Worker) 流式请求但 submit_btn_loc 或 client_disco_checker 未提供。跳过按钮禁用等待。")
|
| 290 |
+
|
| 291 |
+
except asyncio.TimeoutError:
|
| 292 |
+
logger.warning(f"[{req_id}] (Worker) ⚠️ 等待处理完成超时。")
|
| 293 |
+
if not result_future.done():
|
| 294 |
+
result_future.set_exception(HTTPException(status_code=504, detail=f"[{req_id}] Processing timed out waiting for completion."))
|
| 295 |
+
except Exception as ev_wait_err:
|
| 296 |
+
logger.error(f"[{req_id}] (Worker) ❌ 等待处理完成时出错: {ev_wait_err}")
|
| 297 |
+
if not result_future.done():
|
| 298 |
+
result_future.set_exception(HTTPException(status_code=500, detail=f"[{req_id}] Error waiting for completion: {ev_wait_err}"))
|
| 299 |
+
finally:
|
| 300 |
+
# 清理断开连接监控任务
|
| 301 |
+
if 'disconnect_monitor_task' in locals() and not disconnect_monitor_task.done():
|
| 302 |
+
disconnect_monitor_task.cancel()
|
| 303 |
+
try:
|
| 304 |
+
await disconnect_monitor_task
|
| 305 |
+
except asyncio.CancelledError:
|
| 306 |
+
pass
|
| 307 |
+
|
| 308 |
+
except Exception as process_err:
|
| 309 |
+
logger.error(f"[{req_id}] (Worker) _process_request_refactored execution error: {process_err}")
|
| 310 |
+
if not result_future.done():
|
| 311 |
+
result_future.set_exception(HTTPException(status_code=500, detail=f"[{req_id}] Request processing error: {process_err}"))
|
| 312 |
+
|
| 313 |
+
logger.info(f"[{req_id}] (Worker) 释放处理锁。")
|
| 314 |
+
|
| 315 |
+
# 在释放处理锁后立即执行清空操作
|
| 316 |
+
try:
|
| 317 |
+
# 清空流式队列缓存
|
| 318 |
+
from api_utils import clear_stream_queue
|
| 319 |
+
await clear_stream_queue()
|
| 320 |
+
|
| 321 |
+
# 清空聊天历史(对于所有模式:流式和非流式)
|
| 322 |
+
if submit_btn_loc and client_disco_checker:
|
| 323 |
+
from server import page_instance, is_page_ready
|
| 324 |
+
if page_instance and is_page_ready:
|
| 325 |
+
from browser_utils.page_controller import PageController
|
| 326 |
+
page_controller = PageController(page_instance, logger, req_id)
|
| 327 |
+
logger.info(f"[{req_id}] (Worker) 执行聊天历史清空({'流式' if completion_event else '非流式'}模式)...")
|
| 328 |
+
await page_controller.clear_chat_history(client_disco_checker)
|
| 329 |
+
logger.info(f"[{req_id}] (Worker) ✅ 聊天历史清空完成。")
|
| 330 |
+
else:
|
| 331 |
+
logger.info(f"[{req_id}] (Worker) 跳过聊天历史清空:缺少必要参数(submit_btn_loc: {bool(submit_btn_loc)}, client_disco_checker: {bool(client_disco_checker)})")
|
| 332 |
+
except Exception as clear_err:
|
| 333 |
+
logger.error(f"[{req_id}] (Worker) 清空操作时发生错误: {clear_err}", exc_info=True)
|
| 334 |
+
|
| 335 |
+
was_last_request_streaming = is_streaming_request
|
| 336 |
+
last_request_completion_time = time.time()
|
| 337 |
+
|
| 338 |
+
except asyncio.CancelledError:
|
| 339 |
+
logger.info("--- 队列 Worker 被取消 ---")
|
| 340 |
+
if result_future and not result_future.done():
|
| 341 |
+
result_future.cancel("Worker cancelled")
|
| 342 |
+
break
|
| 343 |
+
except Exception as e:
|
| 344 |
+
logger.error(f"[{req_id}] (Worker) ❌ 处理请求时发生意外错误: {e}", exc_info=True)
|
| 345 |
+
if result_future and not result_future.done():
|
| 346 |
+
result_future.set_exception(HTTPException(status_code=500, detail=f"[{req_id}] 服务器内部错误: {e}"))
|
| 347 |
+
finally:
|
| 348 |
+
if request_item:
|
| 349 |
+
request_queue.task_done()
|
| 350 |
+
|
| 351 |
+
logger.info("--- 队列 Worker 已停止 ---")
|
AIStudioProxyAPI/api_utils/request_processor.py
ADDED
|
@@ -0,0 +1,884 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
请求处理器模块
|
| 3 |
+
包含核心的请求处理逻辑
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import random
|
| 10 |
+
import time
|
| 11 |
+
from typing import Optional, Tuple, Callable, AsyncGenerator
|
| 12 |
+
from asyncio import Event, Future
|
| 13 |
+
|
| 14 |
+
from fastapi import HTTPException, Request
|
| 15 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 16 |
+
from playwright.async_api import Page as AsyncPage, Locator, Error as PlaywrightAsyncError, expect as expect_async
|
| 17 |
+
|
| 18 |
+
# --- 配置模块导入 ---
|
| 19 |
+
from config import *
|
| 20 |
+
|
| 21 |
+
# --- models模块导入 ---
|
| 22 |
+
from models import ChatCompletionRequest, ClientDisconnectedError
|
| 23 |
+
|
| 24 |
+
# --- browser_utils模块导入 ---
|
| 25 |
+
from browser_utils import (
|
| 26 |
+
switch_ai_studio_model,
|
| 27 |
+
save_error_snapshot
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
# --- api_utils模块导入 ---
|
| 31 |
+
from .utils import (
|
| 32 |
+
validate_chat_request,
|
| 33 |
+
prepare_combined_prompt,
|
| 34 |
+
generate_sse_chunk,
|
| 35 |
+
generate_sse_stop_chunk,
|
| 36 |
+
use_stream_response,
|
| 37 |
+
calculate_usage_stats
|
| 38 |
+
)
|
| 39 |
+
from browser_utils.page_controller import PageController
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
async def _initialize_request_context(req_id: str, request: ChatCompletionRequest) -> dict:
|
| 43 |
+
"""初始化请求上下文"""
|
| 44 |
+
from server import (
|
| 45 |
+
logger, page_instance, is_page_ready, parsed_model_list,
|
| 46 |
+
current_ai_studio_model_id, model_switching_lock, page_params_cache,
|
| 47 |
+
params_cache_lock
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
logger.info(f"[{req_id}] 开始处理请求...")
|
| 51 |
+
logger.info(f"[{req_id}] 请求参数 - Model: {request.model}, Stream: {request.stream}")
|
| 52 |
+
|
| 53 |
+
context = {
|
| 54 |
+
'logger': logger,
|
| 55 |
+
'page': page_instance,
|
| 56 |
+
'is_page_ready': is_page_ready,
|
| 57 |
+
'parsed_model_list': parsed_model_list,
|
| 58 |
+
'current_ai_studio_model_id': current_ai_studio_model_id,
|
| 59 |
+
'model_switching_lock': model_switching_lock,
|
| 60 |
+
'page_params_cache': page_params_cache,
|
| 61 |
+
'params_cache_lock': params_cache_lock,
|
| 62 |
+
'is_streaming': request.stream,
|
| 63 |
+
'model_actually_switched': False,
|
| 64 |
+
'requested_model': request.model,
|
| 65 |
+
'model_id_to_use': None,
|
| 66 |
+
'needs_model_switching': False
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
return context
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
async def _analyze_model_requirements(req_id: str, context: dict, request: ChatCompletionRequest) -> dict:
|
| 73 |
+
"""分析模型需求并确定是否需要切换"""
|
| 74 |
+
logger = context['logger']
|
| 75 |
+
current_ai_studio_model_id = context['current_ai_studio_model_id']
|
| 76 |
+
parsed_model_list = context['parsed_model_list']
|
| 77 |
+
requested_model = request.model
|
| 78 |
+
|
| 79 |
+
if requested_model and requested_model != MODEL_NAME:
|
| 80 |
+
requested_model_id = requested_model.split('/')[-1]
|
| 81 |
+
logger.info(f"[{req_id}] 请求使用模型: {requested_model_id}")
|
| 82 |
+
|
| 83 |
+
if parsed_model_list:
|
| 84 |
+
valid_model_ids = [m.get("id") for m in parsed_model_list]
|
| 85 |
+
if requested_model_id not in valid_model_ids:
|
| 86 |
+
raise HTTPException(
|
| 87 |
+
status_code=400,
|
| 88 |
+
detail=f"[{req_id}] Invalid model '{requested_model_id}'. Available models: {', '.join(valid_model_ids)}"
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
context['model_id_to_use'] = requested_model_id
|
| 92 |
+
if current_ai_studio_model_id != requested_model_id:
|
| 93 |
+
context['needs_model_switching'] = True
|
| 94 |
+
logger.info(f"[{req_id}] 需要切换模型: 当前={current_ai_studio_model_id} -> 目标={requested_model_id}")
|
| 95 |
+
|
| 96 |
+
return context
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
async def _test_client_connection(req_id: str, http_request: Request) -> bool:
|
| 100 |
+
"""通过发送测试数据包来主动检测客户端连接状态"""
|
| 101 |
+
try:
|
| 102 |
+
# 尝试发送一个小的测试数据包
|
| 103 |
+
test_chunk = "data: {\"type\":\"ping\"}\n\n"
|
| 104 |
+
|
| 105 |
+
# 获取底层的响应对象
|
| 106 |
+
if hasattr(http_request, '_receive'):
|
| 107 |
+
# 检查接收通道是否还活跃
|
| 108 |
+
try:
|
| 109 |
+
# 尝试非阻塞地检查是否有断开消息
|
| 110 |
+
import asyncio
|
| 111 |
+
receive_task = asyncio.create_task(http_request._receive())
|
| 112 |
+
done, pending = await asyncio.wait([receive_task], timeout=0.01)
|
| 113 |
+
|
| 114 |
+
if done:
|
| 115 |
+
message = receive_task.result()
|
| 116 |
+
if message.get("type") == "http.disconnect":
|
| 117 |
+
return False
|
| 118 |
+
else:
|
| 119 |
+
# 取消未完成的任务
|
| 120 |
+
receive_task.cancel()
|
| 121 |
+
try:
|
| 122 |
+
await receive_task
|
| 123 |
+
except asyncio.CancelledError:
|
| 124 |
+
pass
|
| 125 |
+
|
| 126 |
+
except Exception:
|
| 127 |
+
# 如果检查过程中出现异常,可能表示连接有问题
|
| 128 |
+
return False
|
| 129 |
+
|
| 130 |
+
# 如果上述检查都通过,认为连接正常
|
| 131 |
+
return True
|
| 132 |
+
|
| 133 |
+
except Exception as e:
|
| 134 |
+
# 任何异常都认为连接已断开
|
| 135 |
+
return False
|
| 136 |
+
|
| 137 |
+
async def _setup_disconnect_monitoring(req_id: str, http_request: Request, result_future: Future) -> Tuple[Event, asyncio.Task, Callable]:
|
| 138 |
+
"""设置客户端断开连接监控"""
|
| 139 |
+
from server import logger
|
| 140 |
+
|
| 141 |
+
client_disconnected_event = Event()
|
| 142 |
+
|
| 143 |
+
async def check_disconnect_periodically():
|
| 144 |
+
while not client_disconnected_event.is_set():
|
| 145 |
+
try:
|
| 146 |
+
# 使用主动检测方法
|
| 147 |
+
is_connected = await _test_client_connection(req_id, http_request)
|
| 148 |
+
if not is_connected:
|
| 149 |
+
logger.info(f"[{req_id}] 主动检测到客户端断开连接。")
|
| 150 |
+
client_disconnected_event.set()
|
| 151 |
+
if not result_future.done():
|
| 152 |
+
result_future.set_exception(HTTPException(status_code=499, detail=f"[{req_id}] 客户端关闭了请求"))
|
| 153 |
+
break
|
| 154 |
+
|
| 155 |
+
# 备用检查:使用原有的is_disconnected方法
|
| 156 |
+
if await http_request.is_disconnected():
|
| 157 |
+
logger.info(f"[{req_id}] 备用检测到客户端断开连接。")
|
| 158 |
+
client_disconnected_event.set()
|
| 159 |
+
if not result_future.done():
|
| 160 |
+
result_future.set_exception(HTTPException(status_code=499, detail=f"[{req_id}] 客户端关闭了请求"))
|
| 161 |
+
break
|
| 162 |
+
|
| 163 |
+
await asyncio.sleep(0.3) # 更频繁的检查间隔,从0.5秒改为0.3秒
|
| 164 |
+
except asyncio.CancelledError:
|
| 165 |
+
break
|
| 166 |
+
except Exception as e:
|
| 167 |
+
logger.error(f"[{req_id}] (Disco Check Task) 错误: {e}")
|
| 168 |
+
client_disconnected_event.set()
|
| 169 |
+
if not result_future.done():
|
| 170 |
+
result_future.set_exception(HTTPException(status_code=500, detail=f"[{req_id}] Internal disconnect checker error: {e}"))
|
| 171 |
+
break
|
| 172 |
+
|
| 173 |
+
disconnect_check_task = asyncio.create_task(check_disconnect_periodically())
|
| 174 |
+
|
| 175 |
+
def check_client_disconnected(stage: str = ""):
|
| 176 |
+
if client_disconnected_event.is_set():
|
| 177 |
+
logger.info(f"[{req_id}] 在 '{stage}' 检测到客户端断开连接。")
|
| 178 |
+
raise ClientDisconnectedError(f"[{req_id}] Client disconnected at stage: {stage}")
|
| 179 |
+
return False
|
| 180 |
+
|
| 181 |
+
return client_disconnected_event, disconnect_check_task, check_client_disconnected
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
async def _validate_page_status(req_id: str, context: dict, check_client_disconnected: Callable) -> None:
|
| 185 |
+
"""验证页面状态"""
|
| 186 |
+
page = context['page']
|
| 187 |
+
is_page_ready = context['is_page_ready']
|
| 188 |
+
|
| 189 |
+
if not page or page.is_closed() or not is_page_ready:
|
| 190 |
+
raise HTTPException(status_code=503, detail=f"[{req_id}] AI Studio 页面丢失或未就绪。", headers={"Retry-After": "30"})
|
| 191 |
+
|
| 192 |
+
check_client_disconnected("Initial Page Check")
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
async def _handle_model_switching(req_id: str, context: dict, check_client_disconnected: Callable) -> dict:
|
| 196 |
+
"""处理模型切换逻辑"""
|
| 197 |
+
if not context['needs_model_switching']:
|
| 198 |
+
return context
|
| 199 |
+
|
| 200 |
+
logger = context['logger']
|
| 201 |
+
page = context['page']
|
| 202 |
+
model_switching_lock = context['model_switching_lock']
|
| 203 |
+
model_id_to_use = context['model_id_to_use']
|
| 204 |
+
|
| 205 |
+
import server
|
| 206 |
+
|
| 207 |
+
async with model_switching_lock:
|
| 208 |
+
if server.current_ai_studio_model_id != model_id_to_use:
|
| 209 |
+
logger.info(f"[{req_id}] 准备切换模型: {server.current_ai_studio_model_id} -> {model_id_to_use}")
|
| 210 |
+
switch_success = await switch_ai_studio_model(page, model_id_to_use, req_id)
|
| 211 |
+
if switch_success:
|
| 212 |
+
server.current_ai_studio_model_id = model_id_to_use
|
| 213 |
+
context['model_actually_switched'] = True
|
| 214 |
+
context['current_ai_studio_model_id'] = model_id_to_use
|
| 215 |
+
logger.info(f"[{req_id}] ✅ 模型切换成功: {server.current_ai_studio_model_id}")
|
| 216 |
+
else:
|
| 217 |
+
await _handle_model_switch_failure(req_id, page, model_id_to_use, server.current_ai_studio_model_id, logger)
|
| 218 |
+
|
| 219 |
+
return context
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
async def _handle_model_switch_failure(req_id: str, page: AsyncPage, model_id_to_use: str, model_before_switch: str, logger) -> None:
|
| 223 |
+
"""处理模型切换失败的情况"""
|
| 224 |
+
import server
|
| 225 |
+
|
| 226 |
+
logger.warning(f"[{req_id}] ❌ 模型切换至 {model_id_to_use} 失败。")
|
| 227 |
+
# 尝试恢复全局状态
|
| 228 |
+
server.current_ai_studio_model_id = model_before_switch
|
| 229 |
+
|
| 230 |
+
raise HTTPException(
|
| 231 |
+
status_code=422,
|
| 232 |
+
detail=f"[{req_id}] 未能切换到模型 '{model_id_to_use}'。请确保模型可用。"
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
async def _handle_parameter_cache(req_id: str, context: dict) -> None:
|
| 237 |
+
"""处理参数缓存"""
|
| 238 |
+
logger = context['logger']
|
| 239 |
+
params_cache_lock = context['params_cache_lock']
|
| 240 |
+
page_params_cache = context['page_params_cache']
|
| 241 |
+
current_ai_studio_model_id = context['current_ai_studio_model_id']
|
| 242 |
+
model_actually_switched = context['model_actually_switched']
|
| 243 |
+
|
| 244 |
+
async with params_cache_lock:
|
| 245 |
+
cached_model_for_params = page_params_cache.get("last_known_model_id_for_params")
|
| 246 |
+
|
| 247 |
+
if model_actually_switched or (current_ai_studio_model_id != cached_model_for_params):
|
| 248 |
+
logger.info(f"[{req_id}] 模型已更改,参数缓存失效。")
|
| 249 |
+
page_params_cache.clear()
|
| 250 |
+
page_params_cache["last_known_model_id_for_params"] = current_ai_studio_model_id
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
async def _prepare_and_validate_request(req_id: str, request: ChatCompletionRequest, check_client_disconnected: Callable) -> str:
|
| 254 |
+
"""准备和验证请求"""
|
| 255 |
+
try:
|
| 256 |
+
validate_chat_request(request.messages, req_id)
|
| 257 |
+
except ValueError as e:
|
| 258 |
+
raise HTTPException(status_code=400, detail=f"[{req_id}] 无效请求: {e}")
|
| 259 |
+
|
| 260 |
+
prepared_prompt = prepare_combined_prompt(request.messages, req_id)
|
| 261 |
+
check_client_disconnected("After Prompt Prep")
|
| 262 |
+
|
| 263 |
+
return prepared_prompt
|
| 264 |
+
|
| 265 |
+
async def _handle_response_processing(req_id: str, request: ChatCompletionRequest, page: AsyncPage,
|
| 266 |
+
context: dict, result_future: Future,
|
| 267 |
+
submit_button_locator: Locator, check_client_disconnected: Callable) -> Optional[Tuple[Event, Locator, Callable]]:
|
| 268 |
+
"""处理响应生成"""
|
| 269 |
+
from server import logger
|
| 270 |
+
|
| 271 |
+
is_streaming = request.stream
|
| 272 |
+
current_ai_studio_model_id = context.get('current_ai_studio_model_id')
|
| 273 |
+
|
| 274 |
+
# 检查是否使用辅助流
|
| 275 |
+
stream_port = os.environ.get('STREAM_PORT')
|
| 276 |
+
use_stream = stream_port != '0'
|
| 277 |
+
|
| 278 |
+
if use_stream:
|
| 279 |
+
return await _handle_auxiliary_stream_response(req_id, request, context, result_future, submit_button_locator, check_client_disconnected)
|
| 280 |
+
else:
|
| 281 |
+
return await _handle_playwright_response(req_id, request, page, context, result_future, submit_button_locator, check_client_disconnected)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
async def _handle_auxiliary_stream_response(req_id: str, request: ChatCompletionRequest, context: dict,
|
| 285 |
+
result_future: Future, submit_button_locator: Locator,
|
| 286 |
+
check_client_disconnected: Callable) -> Optional[Tuple[Event, Locator, Callable]]:
|
| 287 |
+
"""使用辅助流处理响应"""
|
| 288 |
+
from server import logger
|
| 289 |
+
|
| 290 |
+
is_streaming = request.stream
|
| 291 |
+
current_ai_studio_model_id = context.get('current_ai_studio_model_id')
|
| 292 |
+
|
| 293 |
+
def generate_random_string(length):
|
| 294 |
+
charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
| 295 |
+
return ''.join(random.choice(charset) for _ in range(length))
|
| 296 |
+
|
| 297 |
+
if is_streaming:
|
| 298 |
+
try:
|
| 299 |
+
completion_event = Event()
|
| 300 |
+
|
| 301 |
+
async def create_stream_generator_from_helper(event_to_set: Event) -> AsyncGenerator[str, None]:
|
| 302 |
+
last_reason_pos = 0
|
| 303 |
+
last_body_pos = 0
|
| 304 |
+
model_name_for_stream = current_ai_studio_model_id or MODEL_NAME
|
| 305 |
+
chat_completion_id = f"{CHAT_COMPLETION_ID_PREFIX}{req_id}-{int(time.time())}-{random.randint(100, 999)}"
|
| 306 |
+
created_timestamp = int(time.time())
|
| 307 |
+
|
| 308 |
+
# 用于收集完整内容以计算usage
|
| 309 |
+
full_reasoning_content = ""
|
| 310 |
+
full_body_content = ""
|
| 311 |
+
|
| 312 |
+
# 数据接收状态标记
|
| 313 |
+
data_receiving = False
|
| 314 |
+
|
| 315 |
+
try:
|
| 316 |
+
async for raw_data in use_stream_response(req_id):
|
| 317 |
+
# 标记数据接收状态
|
| 318 |
+
data_receiving = True
|
| 319 |
+
|
| 320 |
+
# 检查客户端是否断开连接
|
| 321 |
+
try:
|
| 322 |
+
check_client_disconnected(f"流式生成器循环 ({req_id}): ")
|
| 323 |
+
except ClientDisconnectedError:
|
| 324 |
+
logger.info(f"[{req_id}] 客户端断开连接,终止流式生成")
|
| 325 |
+
# 如果正在接收数据时客户端断开,立即设置done信号
|
| 326 |
+
if data_receiving and not event_to_set.is_set():
|
| 327 |
+
logger.info(f"[{req_id}] 数据接收中客户端断开,立即设置done信号")
|
| 328 |
+
event_to_set.set()
|
| 329 |
+
break
|
| 330 |
+
|
| 331 |
+
# 确保 data 是字典类型
|
| 332 |
+
if isinstance(raw_data, str):
|
| 333 |
+
try:
|
| 334 |
+
data = json.loads(raw_data)
|
| 335 |
+
except json.JSONDecodeError:
|
| 336 |
+
logger.warning(f"[{req_id}] 无法解析流数据JSON: {raw_data}")
|
| 337 |
+
continue
|
| 338 |
+
elif isinstance(raw_data, dict):
|
| 339 |
+
data = raw_data
|
| 340 |
+
else:
|
| 341 |
+
logger.warning(f"[{req_id}] 未知的流数据类型: {type(raw_data)}")
|
| 342 |
+
continue
|
| 343 |
+
|
| 344 |
+
# 确保必要的键存在
|
| 345 |
+
if not isinstance(data, dict):
|
| 346 |
+
logger.warning(f"[{req_id}] 数据不是字典类型: {data}")
|
| 347 |
+
continue
|
| 348 |
+
|
| 349 |
+
reason = data.get("reason", "")
|
| 350 |
+
body = data.get("body", "")
|
| 351 |
+
done = data.get("done", False)
|
| 352 |
+
function = data.get("function", [])
|
| 353 |
+
|
| 354 |
+
# 更新完整内容记录
|
| 355 |
+
if reason:
|
| 356 |
+
full_reasoning_content = reason
|
| 357 |
+
if body:
|
| 358 |
+
full_body_content = body
|
| 359 |
+
|
| 360 |
+
# 处理推理内容
|
| 361 |
+
if len(reason) > last_reason_pos:
|
| 362 |
+
output = {
|
| 363 |
+
"id": chat_completion_id,
|
| 364 |
+
"object": "chat.completion.chunk",
|
| 365 |
+
"model": model_name_for_stream,
|
| 366 |
+
"created": created_timestamp,
|
| 367 |
+
"choices":[{
|
| 368 |
+
"index": 0,
|
| 369 |
+
"delta":{
|
| 370 |
+
"role": "assistant",
|
| 371 |
+
"content": None,
|
| 372 |
+
"reasoning_content": reason[last_reason_pos:],
|
| 373 |
+
},
|
| 374 |
+
"finish_reason": None,
|
| 375 |
+
"native_finish_reason": None,
|
| 376 |
+
}]
|
| 377 |
+
}
|
| 378 |
+
last_reason_pos = len(reason)
|
| 379 |
+
yield f"data: {json.dumps(output, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
| 380 |
+
|
| 381 |
+
# 处理主体内容
|
| 382 |
+
if len(body) > last_body_pos:
|
| 383 |
+
finish_reason_val = None
|
| 384 |
+
if done:
|
| 385 |
+
finish_reason_val = "stop"
|
| 386 |
+
|
| 387 |
+
delta_content = {"role": "assistant", "content": body[last_body_pos:]}
|
| 388 |
+
choice_item = {
|
| 389 |
+
"index": 0,
|
| 390 |
+
"delta": delta_content,
|
| 391 |
+
"finish_reason": finish_reason_val,
|
| 392 |
+
"native_finish_reason": finish_reason_val,
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
if done and function and len(function) > 0:
|
| 396 |
+
tool_calls_list = []
|
| 397 |
+
for func_idx, function_call_data in enumerate(function):
|
| 398 |
+
tool_calls_list.append({
|
| 399 |
+
"id": f"call_{generate_random_string(24)}",
|
| 400 |
+
"index": func_idx,
|
| 401 |
+
"type": "function",
|
| 402 |
+
"function": {
|
| 403 |
+
"name": function_call_data["name"],
|
| 404 |
+
"arguments": json.dumps(function_call_data["params"]),
|
| 405 |
+
},
|
| 406 |
+
})
|
| 407 |
+
delta_content["tool_calls"] = tool_calls_list
|
| 408 |
+
choice_item["finish_reason"] = "tool_calls"
|
| 409 |
+
choice_item["native_finish_reason"] = "tool_calls"
|
| 410 |
+
delta_content["content"] = None
|
| 411 |
+
|
| 412 |
+
output = {
|
| 413 |
+
"id": chat_completion_id,
|
| 414 |
+
"object": "chat.completion.chunk",
|
| 415 |
+
"model": model_name_for_stream,
|
| 416 |
+
"created": created_timestamp,
|
| 417 |
+
"choices": [choice_item]
|
| 418 |
+
}
|
| 419 |
+
last_body_pos = len(body)
|
| 420 |
+
yield f"data: {json.dumps(output, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
| 421 |
+
|
| 422 |
+
# 处理只有done=True但没有新内容的情况(仅有函数调用或纯结束)
|
| 423 |
+
elif done:
|
| 424 |
+
# 如果有函数调用但没有新的body内容
|
| 425 |
+
if function and len(function) > 0:
|
| 426 |
+
delta_content = {"role": "assistant", "content": None}
|
| 427 |
+
tool_calls_list = []
|
| 428 |
+
for func_idx, function_call_data in enumerate(function):
|
| 429 |
+
tool_calls_list.append({
|
| 430 |
+
"id": f"call_{generate_random_string(24)}",
|
| 431 |
+
"index": func_idx,
|
| 432 |
+
"type": "function",
|
| 433 |
+
"function": {
|
| 434 |
+
"name": function_call_data["name"],
|
| 435 |
+
"arguments": json.dumps(function_call_data["params"]),
|
| 436 |
+
},
|
| 437 |
+
})
|
| 438 |
+
delta_content["tool_calls"] = tool_calls_list
|
| 439 |
+
choice_item = {
|
| 440 |
+
"index": 0,
|
| 441 |
+
"delta": delta_content,
|
| 442 |
+
"finish_reason": "tool_calls",
|
| 443 |
+
"native_finish_reason": "tool_calls",
|
| 444 |
+
}
|
| 445 |
+
else:
|
| 446 |
+
# 纯结束,没有新内容和函数调用
|
| 447 |
+
choice_item = {
|
| 448 |
+
"index": 0,
|
| 449 |
+
"delta": {"role": "assistant"},
|
| 450 |
+
"finish_reason": "stop",
|
| 451 |
+
"native_finish_reason": "stop",
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
output = {
|
| 455 |
+
"id": chat_completion_id,
|
| 456 |
+
"object": "chat.completion.chunk",
|
| 457 |
+
"model": model_name_for_stream,
|
| 458 |
+
"created": created_timestamp,
|
| 459 |
+
"choices": [choice_item]
|
| 460 |
+
}
|
| 461 |
+
yield f"data: {json.dumps(output, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
| 462 |
+
|
| 463 |
+
except ClientDisconnectedError:
|
| 464 |
+
logger.info(f"[{req_id}] 流式生成器中检测到客户端断开连接")
|
| 465 |
+
# 客户端断开时立即设置done信号
|
| 466 |
+
if data_receiving and not event_to_set.is_set():
|
| 467 |
+
logger.info(f"[{req_id}] 客户端断开异常处理中立即设置done信号")
|
| 468 |
+
event_to_set.set()
|
| 469 |
+
except Exception as e:
|
| 470 |
+
logger.error(f"[{req_id}] 流式生成器处理过程中发生错误: {e}", exc_info=True)
|
| 471 |
+
# 发送错误信息给客户端
|
| 472 |
+
try:
|
| 473 |
+
error_chunk = {
|
| 474 |
+
"id": chat_completion_id,
|
| 475 |
+
"object": "chat.completion.chunk",
|
| 476 |
+
"model": model_name_for_stream,
|
| 477 |
+
"created": created_timestamp,
|
| 478 |
+
"choices": [{
|
| 479 |
+
"index": 0,
|
| 480 |
+
"delta": {"role": "assistant", "content": f"\n\n[错误: {str(e)}]"},
|
| 481 |
+
"finish_reason": "stop",
|
| 482 |
+
"native_finish_reason": "stop",
|
| 483 |
+
}]
|
| 484 |
+
}
|
| 485 |
+
yield f"data: {json.dumps(error_chunk, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
| 486 |
+
except Exception:
|
| 487 |
+
pass # 如果无法发送错误信息,继续处理结束逻辑
|
| 488 |
+
finally:
|
| 489 |
+
# 计算usage统计
|
| 490 |
+
try:
|
| 491 |
+
usage_stats = calculate_usage_stats(
|
| 492 |
+
[msg.model_dump() for msg in request.messages],
|
| 493 |
+
full_body_content,
|
| 494 |
+
full_reasoning_content
|
| 495 |
+
)
|
| 496 |
+
logger.info(f"[{req_id}] 计算的token使用统计: {usage_stats}")
|
| 497 |
+
|
| 498 |
+
# 发送带usage的最终chunk
|
| 499 |
+
final_chunk = {
|
| 500 |
+
"id": chat_completion_id,
|
| 501 |
+
"object": "chat.completion.chunk",
|
| 502 |
+
"model": model_name_for_stream,
|
| 503 |
+
"created": created_timestamp,
|
| 504 |
+
"choices": [{
|
| 505 |
+
"index": 0,
|
| 506 |
+
"delta": {},
|
| 507 |
+
"finish_reason": "stop",
|
| 508 |
+
"native_finish_reason": "stop"
|
| 509 |
+
}],
|
| 510 |
+
"usage": usage_stats
|
| 511 |
+
}
|
| 512 |
+
yield f"data: {json.dumps(final_chunk, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
| 513 |
+
logger.info(f"[{req_id}] 已发送带usage统计的最终chunk")
|
| 514 |
+
|
| 515 |
+
except Exception as usage_err:
|
| 516 |
+
logger.error(f"[{req_id}] 计算或发送usage统计时出错: {usage_err}")
|
| 517 |
+
|
| 518 |
+
# 确保总是发送 [DONE] 标记
|
| 519 |
+
try:
|
| 520 |
+
logger.info(f"[{req_id}] 流式生成器完成,发送 [DONE] 标记")
|
| 521 |
+
yield "data: [DONE]\n\n"
|
| 522 |
+
except Exception as done_err:
|
| 523 |
+
logger.error(f"[{req_id}] 发送 [DONE] 标记时出错: {done_err}")
|
| 524 |
+
|
| 525 |
+
# 确保事件被设置
|
| 526 |
+
if not event_to_set.is_set():
|
| 527 |
+
event_to_set.set()
|
| 528 |
+
logger.info(f"[{req_id}] 流式生成器完成事件已设置")
|
| 529 |
+
|
| 530 |
+
stream_gen_func = create_stream_generator_from_helper(completion_event)
|
| 531 |
+
if not result_future.done():
|
| 532 |
+
result_future.set_result(StreamingResponse(stream_gen_func, media_type="text/event-stream"))
|
| 533 |
+
else:
|
| 534 |
+
if not completion_event.is_set():
|
| 535 |
+
completion_event.set()
|
| 536 |
+
|
| 537 |
+
return completion_event, submit_button_locator, check_client_disconnected
|
| 538 |
+
|
| 539 |
+
except Exception as e:
|
| 540 |
+
logger.error(f"[{req_id}] 从队列获取流式数据时出错: {e}", exc_info=True)
|
| 541 |
+
if completion_event and not completion_event.is_set():
|
| 542 |
+
completion_event.set()
|
| 543 |
+
raise
|
| 544 |
+
|
| 545 |
+
else: # 非流式
|
| 546 |
+
content = None
|
| 547 |
+
reasoning_content = None
|
| 548 |
+
functions = None
|
| 549 |
+
final_data_from_aux_stream = None
|
| 550 |
+
|
| 551 |
+
async for raw_data in use_stream_response(req_id):
|
| 552 |
+
check_client_disconnected(f"非流式辅助流 - 循环中 ({req_id}): ")
|
| 553 |
+
|
| 554 |
+
# 确保 data 是字典类型
|
| 555 |
+
if isinstance(raw_data, str):
|
| 556 |
+
try:
|
| 557 |
+
data = json.loads(raw_data)
|
| 558 |
+
except json.JSONDecodeError:
|
| 559 |
+
logger.warning(f"[{req_id}] 无法解析非流式数据JSON: {raw_data}")
|
| 560 |
+
continue
|
| 561 |
+
elif isinstance(raw_data, dict):
|
| 562 |
+
data = raw_data
|
| 563 |
+
else:
|
| 564 |
+
logger.warning(f"[{req_id}] 非流式未知数据类型: {type(raw_data)}")
|
| 565 |
+
continue
|
| 566 |
+
|
| 567 |
+
# 确保数据是字典类型
|
| 568 |
+
if not isinstance(data, dict):
|
| 569 |
+
logger.warning(f"[{req_id}] 非流式数据不是字典类型: {data}")
|
| 570 |
+
continue
|
| 571 |
+
|
| 572 |
+
final_data_from_aux_stream = data
|
| 573 |
+
if data.get("done"):
|
| 574 |
+
content = data.get("body")
|
| 575 |
+
reasoning_content = data.get("reason")
|
| 576 |
+
functions = data.get("function")
|
| 577 |
+
break
|
| 578 |
+
|
| 579 |
+
if final_data_from_aux_stream and final_data_from_aux_stream.get("reason") == "internal_timeout":
|
| 580 |
+
logger.error(f"[{req_id}] 非流式请求通过辅助流失败: 内部超时")
|
| 581 |
+
raise HTTPException(status_code=502, detail=f"[{req_id}] 辅助流处理错误 (内部超时)")
|
| 582 |
+
|
| 583 |
+
if final_data_from_aux_stream and final_data_from_aux_stream.get("done") is True and content is None:
|
| 584 |
+
logger.error(f"[{req_id}] 非流式请求通过辅助流完成但未提供内容")
|
| 585 |
+
raise HTTPException(status_code=502, detail=f"[{req_id}] 辅助流完成但未提供内容")
|
| 586 |
+
|
| 587 |
+
model_name_for_json = current_ai_studio_model_id or MODEL_NAME
|
| 588 |
+
message_payload = {"role": "assistant", "content": content}
|
| 589 |
+
finish_reason_val = "stop"
|
| 590 |
+
|
| 591 |
+
if functions and len(functions) > 0:
|
| 592 |
+
tool_calls_list = []
|
| 593 |
+
for func_idx, function_call_data in enumerate(functions):
|
| 594 |
+
tool_calls_list.append({
|
| 595 |
+
"id": f"call_{generate_random_string(24)}",
|
| 596 |
+
"index": func_idx,
|
| 597 |
+
"type": "function",
|
| 598 |
+
"function": {
|
| 599 |
+
"name": function_call_data["name"],
|
| 600 |
+
"arguments": json.dumps(function_call_data["params"]),
|
| 601 |
+
},
|
| 602 |
+
})
|
| 603 |
+
message_payload["tool_calls"] = tool_calls_list
|
| 604 |
+
finish_reason_val = "tool_calls"
|
| 605 |
+
message_payload["content"] = None
|
| 606 |
+
|
| 607 |
+
if reasoning_content:
|
| 608 |
+
message_payload["reasoning_content"] = reasoning_content
|
| 609 |
+
|
| 610 |
+
# 计算token使用统计
|
| 611 |
+
usage_stats = calculate_usage_stats(
|
| 612 |
+
[msg.model_dump() for msg in request.messages],
|
| 613 |
+
content or "",
|
| 614 |
+
reasoning_content
|
| 615 |
+
)
|
| 616 |
+
|
| 617 |
+
response_payload = {
|
| 618 |
+
"id": f"{CHAT_COMPLETION_ID_PREFIX}{req_id}-{int(time.time())}",
|
| 619 |
+
"object": "chat.completion",
|
| 620 |
+
"created": int(time.time()),
|
| 621 |
+
"model": model_name_for_json,
|
| 622 |
+
"choices": [{
|
| 623 |
+
"index": 0,
|
| 624 |
+
"message": message_payload,
|
| 625 |
+
"finish_reason": finish_reason_val,
|
| 626 |
+
"native_finish_reason": finish_reason_val,
|
| 627 |
+
}],
|
| 628 |
+
"usage": usage_stats
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
if not result_future.done():
|
| 632 |
+
result_future.set_result(JSONResponse(content=response_payload))
|
| 633 |
+
return None
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
async def _handle_playwright_response(req_id: str, request: ChatCompletionRequest, page: AsyncPage,
|
| 637 |
+
context: dict, result_future: Future, submit_button_locator: Locator,
|
| 638 |
+
check_client_disconnected: Callable) -> Optional[Tuple[Event, Locator, Callable]]:
|
| 639 |
+
"""使用Playwright处理响应"""
|
| 640 |
+
from server import logger
|
| 641 |
+
|
| 642 |
+
is_streaming = request.stream
|
| 643 |
+
current_ai_studio_model_id = context.get('current_ai_studio_model_id')
|
| 644 |
+
|
| 645 |
+
logger.info(f"[{req_id}] 定位响应元素...")
|
| 646 |
+
response_container = page.locator(RESPONSE_CONTAINER_SELECTOR).last
|
| 647 |
+
response_element = response_container.locator(RESPONSE_TEXT_SELECTOR)
|
| 648 |
+
|
| 649 |
+
try:
|
| 650 |
+
await expect_async(response_container).to_be_attached(timeout=20000)
|
| 651 |
+
check_client_disconnected("After Response Container Attached: ")
|
| 652 |
+
await expect_async(response_element).to_be_attached(timeout=90000)
|
| 653 |
+
logger.info(f"[{req_id}] 响应元素已定位。")
|
| 654 |
+
except (PlaywrightAsyncError, asyncio.TimeoutError, ClientDisconnectedError) as locate_err:
|
| 655 |
+
if isinstance(locate_err, ClientDisconnectedError):
|
| 656 |
+
raise
|
| 657 |
+
logger.error(f"[{req_id}] ❌ 错误: 定位响应元素失败或超时: {locate_err}")
|
| 658 |
+
await save_error_snapshot(f"response_locate_error_{req_id}")
|
| 659 |
+
raise HTTPException(status_code=502, detail=f"[{req_id}] 定位AI Studio响应元素失败: {locate_err}")
|
| 660 |
+
except Exception as locate_exc:
|
| 661 |
+
logger.exception(f"[{req_id}] ❌ 错误: 定位响应元素时意外错误")
|
| 662 |
+
await save_error_snapshot(f"response_locate_unexpected_{req_id}")
|
| 663 |
+
raise HTTPException(status_code=500, detail=f"[{req_id}] 定位响应元素时意外错误: {locate_exc}")
|
| 664 |
+
|
| 665 |
+
check_client_disconnected("After Response Element Located: ")
|
| 666 |
+
|
| 667 |
+
if is_streaming:
|
| 668 |
+
completion_event = Event()
|
| 669 |
+
|
| 670 |
+
async def create_response_stream_generator():
|
| 671 |
+
# 数据接收状态标记
|
| 672 |
+
data_receiving = False
|
| 673 |
+
|
| 674 |
+
try:
|
| 675 |
+
# 使用PageController获取响应
|
| 676 |
+
page_controller = PageController(page, logger, req_id)
|
| 677 |
+
final_content = await page_controller.get_response(check_client_disconnected)
|
| 678 |
+
|
| 679 |
+
# 标记数据接收状态
|
| 680 |
+
data_receiving = True
|
| 681 |
+
|
| 682 |
+
# 生成流式响应 - 保持Markdown格式
|
| 683 |
+
# 按行分割以保持换行符和Markdown结构
|
| 684 |
+
lines = final_content.split('\n')
|
| 685 |
+
for line_idx, line in enumerate(lines):
|
| 686 |
+
# 检查客户端是否断开连接
|
| 687 |
+
try:
|
| 688 |
+
check_client_disconnected(f"Playwright流式生成器循环 ({req_id}): ")
|
| 689 |
+
except ClientDisconnectedError:
|
| 690 |
+
logger.info(f"[{req_id}] Playwright流式生成器中检测到客户端断开连接")
|
| 691 |
+
# 如果正在接收数据时客户端断开,立即设置done信号
|
| 692 |
+
if data_receiving and not completion_event.is_set():
|
| 693 |
+
logger.info(f"[{req_id}] Playwright数据接收中客户端断开,立即设置done信号")
|
| 694 |
+
completion_event.set()
|
| 695 |
+
break
|
| 696 |
+
|
| 697 |
+
# 输出当前行的内容(包括空行,以保持Markdown格式)
|
| 698 |
+
if line: # 非空行按字符分块输出
|
| 699 |
+
chunk_size = 5 # 每次输出5个字符,平衡速度和体验
|
| 700 |
+
for i in range(0, len(line), chunk_size):
|
| 701 |
+
chunk = line[i:i+chunk_size]
|
| 702 |
+
yield generate_sse_chunk(chunk, req_id, current_ai_studio_model_id or MODEL_NAME)
|
| 703 |
+
await asyncio.sleep(0.03) # 适中的输出速度
|
| 704 |
+
|
| 705 |
+
# 添加换行符(除了最后一行)
|
| 706 |
+
if line_idx < len(lines) - 1:
|
| 707 |
+
yield generate_sse_chunk('\n', req_id, current_ai_studio_model_id or MODEL_NAME)
|
| 708 |
+
await asyncio.sleep(0.01)
|
| 709 |
+
|
| 710 |
+
# 计算并发送带usage的完成块
|
| 711 |
+
usage_stats = calculate_usage_stats(
|
| 712 |
+
[msg.model_dump() for msg in request.messages],
|
| 713 |
+
final_content,
|
| 714 |
+
"" # Playwright模式没有reasoning content
|
| 715 |
+
)
|
| 716 |
+
logger.info(f"[{req_id}] Playwright非流式计算的token使用统计: {usage_stats}")
|
| 717 |
+
|
| 718 |
+
# 发送带usage的完成块
|
| 719 |
+
yield generate_sse_stop_chunk(req_id, current_ai_studio_model_id or MODEL_NAME, "stop", usage_stats)
|
| 720 |
+
|
| 721 |
+
except ClientDisconnectedError:
|
| 722 |
+
logger.info(f"[{req_id}] Playwright���式生成器中检测到客户端断开连接")
|
| 723 |
+
# 客户端断开时立即设置done信号
|
| 724 |
+
if data_receiving and not completion_event.is_set():
|
| 725 |
+
logger.info(f"[{req_id}] Playwright客户端断开异常处理中立即设置done信号")
|
| 726 |
+
completion_event.set()
|
| 727 |
+
except Exception as e:
|
| 728 |
+
logger.error(f"[{req_id}] Playwright流式生成器处理过程中发生错误: {e}", exc_info=True)
|
| 729 |
+
# 发送错误信息给客户端
|
| 730 |
+
try:
|
| 731 |
+
yield generate_sse_chunk(f"\n\n[错误: {str(e)}]", req_id, current_ai_studio_model_id or MODEL_NAME)
|
| 732 |
+
yield generate_sse_stop_chunk(req_id, current_ai_studio_model_id or MODEL_NAME)
|
| 733 |
+
except Exception:
|
| 734 |
+
pass # 如果无法发送错误信息,继续处理结束逻辑
|
| 735 |
+
finally:
|
| 736 |
+
# 确保事件被设置
|
| 737 |
+
if not completion_event.is_set():
|
| 738 |
+
completion_event.set()
|
| 739 |
+
logger.info(f"[{req_id}] Playwright流式生成器完成事件已设置")
|
| 740 |
+
|
| 741 |
+
stream_gen_func = create_response_stream_generator()
|
| 742 |
+
if not result_future.done():
|
| 743 |
+
result_future.set_result(StreamingResponse(stream_gen_func, media_type="text/event-stream"))
|
| 744 |
+
|
| 745 |
+
return completion_event, submit_button_locator, check_client_disconnected
|
| 746 |
+
else:
|
| 747 |
+
# 使用PageController获取响应
|
| 748 |
+
page_controller = PageController(page, logger, req_id)
|
| 749 |
+
final_content = await page_controller.get_response(check_client_disconnected)
|
| 750 |
+
|
| 751 |
+
# 计算token使用统计
|
| 752 |
+
usage_stats = calculate_usage_stats(
|
| 753 |
+
[msg.model_dump() for msg in request.messages],
|
| 754 |
+
final_content,
|
| 755 |
+
"" # Playwright模式没有reasoning content
|
| 756 |
+
)
|
| 757 |
+
logger.info(f"[{req_id}] Playwright非流式计算的token使用统计: {usage_stats}")
|
| 758 |
+
|
| 759 |
+
response_payload = {
|
| 760 |
+
"id": f"{CHAT_COMPLETION_ID_PREFIX}{req_id}-{int(time.time())}",
|
| 761 |
+
"object": "chat.completion",
|
| 762 |
+
"created": int(time.time()),
|
| 763 |
+
"model": current_ai_studio_model_id or MODEL_NAME,
|
| 764 |
+
"choices": [{
|
| 765 |
+
"index": 0,
|
| 766 |
+
"message": {"role": "assistant", "content": final_content},
|
| 767 |
+
"finish_reason": "stop"
|
| 768 |
+
}],
|
| 769 |
+
"usage": usage_stats
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
if not result_future.done():
|
| 773 |
+
result_future.set_result(JSONResponse(content=response_payload))
|
| 774 |
+
|
| 775 |
+
return None
|
| 776 |
+
|
| 777 |
+
|
| 778 |
+
async def _cleanup_request_resources(req_id: str, disconnect_check_task: Optional[asyncio.Task],
|
| 779 |
+
completion_event: Optional[Event], result_future: Future,
|
| 780 |
+
is_streaming: bool) -> None:
|
| 781 |
+
"""清理请求资源"""
|
| 782 |
+
from server import logger
|
| 783 |
+
|
| 784 |
+
if disconnect_check_task and not disconnect_check_task.done():
|
| 785 |
+
disconnect_check_task.cancel()
|
| 786 |
+
try:
|
| 787 |
+
await disconnect_check_task
|
| 788 |
+
except asyncio.CancelledError:
|
| 789 |
+
pass
|
| 790 |
+
except Exception as task_clean_err:
|
| 791 |
+
logger.error(f"[{req_id}] 清理任务时出错: {task_clean_err}")
|
| 792 |
+
|
| 793 |
+
logger.info(f"[{req_id}] 处理完成。")
|
| 794 |
+
|
| 795 |
+
if is_streaming and completion_event and not completion_event.is_set() and (result_future.done() and result_future.exception() is not None):
|
| 796 |
+
logger.warning(f"[{req_id}] 流式请求异常,确保完成事件已设置。")
|
| 797 |
+
completion_event.set()
|
| 798 |
+
|
| 799 |
+
|
| 800 |
+
async def _process_request_refactored(
|
| 801 |
+
req_id: str,
|
| 802 |
+
request: ChatCompletionRequest,
|
| 803 |
+
http_request: Request,
|
| 804 |
+
result_future: Future
|
| 805 |
+
) -> Optional[Tuple[Event, Locator, Callable[[str], bool]]]:
|
| 806 |
+
"""核心请求处理函数 - 重构版本"""
|
| 807 |
+
|
| 808 |
+
# 优化:在开始任何处理前主动检测客户端连接状态
|
| 809 |
+
is_connected = await _test_client_connection(req_id, http_request)
|
| 810 |
+
if not is_connected:
|
| 811 |
+
from server import logger
|
| 812 |
+
logger.info(f"[{req_id}] ✅ 核心处理前检测到客户端断开,提前退出节省资源")
|
| 813 |
+
if not result_future.done():
|
| 814 |
+
result_future.set_exception(HTTPException(status_code=499, detail=f"[{req_id}] 客户端在处理开始前已断开连接"))
|
| 815 |
+
return None
|
| 816 |
+
|
| 817 |
+
context = await _initialize_request_context(req_id, request)
|
| 818 |
+
context = await _analyze_model_requirements(req_id, context, request)
|
| 819 |
+
|
| 820 |
+
client_disconnected_event, disconnect_check_task, check_client_disconnected = await _setup_disconnect_monitoring(
|
| 821 |
+
req_id, http_request, result_future
|
| 822 |
+
)
|
| 823 |
+
|
| 824 |
+
page = context['page']
|
| 825 |
+
submit_button_locator = page.locator(SUBMIT_BUTTON_SELECTOR) if page else None
|
| 826 |
+
completion_event = None
|
| 827 |
+
|
| 828 |
+
try:
|
| 829 |
+
await _validate_page_status(req_id, context, check_client_disconnected)
|
| 830 |
+
|
| 831 |
+
page_controller = PageController(page, context['logger'], req_id)
|
| 832 |
+
|
| 833 |
+
await _handle_model_switching(req_id, context, check_client_disconnected)
|
| 834 |
+
await _handle_parameter_cache(req_id, context)
|
| 835 |
+
|
| 836 |
+
prepared_prompt,image_list = await _prepare_and_validate_request(req_id, request, check_client_disconnected)
|
| 837 |
+
|
| 838 |
+
# 使用PageController处理页面交互
|
| 839 |
+
# 注意:聊天历史清空已移至队列处理锁释放后执行
|
| 840 |
+
|
| 841 |
+
await page_controller.adjust_parameters(
|
| 842 |
+
request.model_dump(exclude_none=True), # 使用 exclude_none=True 避免传递None值
|
| 843 |
+
context['page_params_cache'],
|
| 844 |
+
context['params_cache_lock'],
|
| 845 |
+
context['model_id_to_use'],
|
| 846 |
+
context['parsed_model_list'],
|
| 847 |
+
check_client_disconnected
|
| 848 |
+
)
|
| 849 |
+
|
| 850 |
+
# 优化:在提交提示前再次检查客户端连接,避免不必要的后台请求
|
| 851 |
+
check_client_disconnected("提交提示前最终检查")
|
| 852 |
+
|
| 853 |
+
await page_controller.submit_prompt(prepared_prompt,image_list, check_client_disconnected)
|
| 854 |
+
|
| 855 |
+
# 响应处理仍然需要在这里,因为它决定了是流式还是非流式,并设置future
|
| 856 |
+
response_result = await _handle_response_processing(
|
| 857 |
+
req_id, request, page, context, result_future, submit_button_locator, check_client_disconnected
|
| 858 |
+
)
|
| 859 |
+
|
| 860 |
+
if response_result:
|
| 861 |
+
completion_event, _, _ = response_result
|
| 862 |
+
|
| 863 |
+
return completion_event, submit_button_locator, check_client_disconnected
|
| 864 |
+
|
| 865 |
+
except ClientDisconnectedError as disco_err:
|
| 866 |
+
context['logger'].info(f"[{req_id}] 捕获到客户端断开连接信号: {disco_err}")
|
| 867 |
+
if not result_future.done():
|
| 868 |
+
result_future.set_exception(HTTPException(status_code=499, detail=f"[{req_id}] Client disconnected during processing."))
|
| 869 |
+
except HTTPException as http_err:
|
| 870 |
+
context['logger'].warning(f"[{req_id}] 捕获到 HTTP 异常: {http_err.status_code} - {http_err.detail}")
|
| 871 |
+
if not result_future.done():
|
| 872 |
+
result_future.set_exception(http_err)
|
| 873 |
+
except PlaywrightAsyncError as pw_err:
|
| 874 |
+
context['logger'].error(f"[{req_id}] 捕获到 Playwright 错误: {pw_err}")
|
| 875 |
+
await save_error_snapshot(f"process_playwright_error_{req_id}")
|
| 876 |
+
if not result_future.done():
|
| 877 |
+
result_future.set_exception(HTTPException(status_code=502, detail=f"[{req_id}] Playwright interaction failed: {pw_err}"))
|
| 878 |
+
except Exception as e:
|
| 879 |
+
context['logger'].exception(f"[{req_id}] 捕获到意外错误")
|
| 880 |
+
await save_error_snapshot(f"process_unexpected_error_{req_id}")
|
| 881 |
+
if not result_future.done():
|
| 882 |
+
result_future.set_exception(HTTPException(status_code=500, detail=f"[{req_id}] Unexpected server error: {e}"))
|
| 883 |
+
finally:
|
| 884 |
+
await _cleanup_request_resources(req_id, disconnect_check_task, completion_event, result_future, request.stream)
|
AIStudioProxyAPI/api_utils/request_processor_backup.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
请求处理器模块
|
| 3 |
+
包含核心的请求处理逻辑
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import random
|
| 10 |
+
import time
|
| 11 |
+
from typing import Optional, Tuple, Callable, AsyncGenerator
|
| 12 |
+
from asyncio import Event, Future
|
| 13 |
+
|
| 14 |
+
from fastapi import HTTPException, Request
|
| 15 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 16 |
+
from playwright.async_api import Page as AsyncPage, Locator, Error as PlaywrightAsyncError, expect as expect_async, TimeoutError
|
| 17 |
+
|
| 18 |
+
# --- 配置模块导入 ---
|
| 19 |
+
from config import *
|
| 20 |
+
|
| 21 |
+
# --- models模块导入 ---
|
| 22 |
+
from models import ChatCompletionRequest, ClientDisconnectedError
|
| 23 |
+
|
| 24 |
+
# --- browser_utils模块导入 ---
|
| 25 |
+
from browser_utils import (
|
| 26 |
+
switch_ai_studio_model,
|
| 27 |
+
save_error_snapshot,
|
| 28 |
+
_wait_for_response_completion,
|
| 29 |
+
_get_final_response_content,
|
| 30 |
+
detect_and_extract_page_error
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
# --- api_utils模块导入 ---
|
| 34 |
+
from .utils import (
|
| 35 |
+
validate_chat_request,
|
| 36 |
+
prepare_combined_prompt,
|
| 37 |
+
generate_sse_chunk,
|
| 38 |
+
generate_sse_stop_chunk,
|
| 39 |
+
generate_sse_error_chunk,
|
| 40 |
+
use_helper_get_response,
|
| 41 |
+
use_stream_response
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
async def _process_request_refactored(
|
| 46 |
+
req_id: str,
|
| 47 |
+
request: ChatCompletionRequest,
|
| 48 |
+
http_request: Request,
|
| 49 |
+
result_future: Future
|
| 50 |
+
) -> Optional[Tuple[Event, Locator, Callable[[str], bool]]]:
|
| 51 |
+
"""核心请求处理函数 - 完整版本"""
|
| 52 |
+
global current_ai_studio_model_id
|
| 53 |
+
|
| 54 |
+
# 导入全局变量
|
| 55 |
+
from server import (
|
| 56 |
+
logger, page_instance, is_page_ready, parsed_model_list,
|
| 57 |
+
current_ai_studio_model_id, model_switching_lock, page_params_cache,
|
| 58 |
+
params_cache_lock
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
model_actually_switched_in_current_api_call = False
|
| 62 |
+
logger.info(f"[{req_id}] (Refactored Process) 开始处理请求...")
|
| 63 |
+
logger.info(f"[{req_id}] 请求参数 - Model: {request.model}, Stream: {request.stream}")
|
| 64 |
+
logger.info(f"[{req_id}] 请求参数 - Temperature: {request.temperature}")
|
| 65 |
+
logger.info(f"[{req_id}] 请求参数 - Max Output Tokens: {request.max_output_tokens}")
|
| 66 |
+
logger.info(f"[{req_id}] 请求参数 - Stop Sequences: {request.stop}")
|
| 67 |
+
logger.info(f"[{req_id}] 请求参数 - Top P: {request.top_p}")
|
| 68 |
+
|
| 69 |
+
is_streaming = request.stream
|
| 70 |
+
page: Optional[AsyncPage] = page_instance
|
| 71 |
+
completion_event: Optional[Event] = None
|
| 72 |
+
requested_model = request.model
|
| 73 |
+
model_id_to_use = None
|
| 74 |
+
needs_model_switching = False
|
| 75 |
+
|
| 76 |
+
if requested_model and requested_model != MODEL_NAME:
|
| 77 |
+
requested_model_parts = requested_model.split('/')
|
| 78 |
+
requested_model_id = requested_model_parts[-1] if len(requested_model_parts) > 1 else requested_model
|
| 79 |
+
logger.info(f"[{req_id}] 请求使用模型: {requested_model_id}")
|
| 80 |
+
if parsed_model_list:
|
| 81 |
+
valid_model_ids = [m.get("id") for m in parsed_model_list]
|
| 82 |
+
if requested_model_id not in valid_model_ids:
|
| 83 |
+
logger.error(f"[{req_id}] ❌ 无效的模型ID: {requested_model_id}。可用模型: {valid_model_ids}")
|
| 84 |
+
raise HTTPException(status_code=400, detail=f"[{req_id}] Invalid model '{requested_model_id}'. Available models: {', '.join(valid_model_ids)}")
|
| 85 |
+
model_id_to_use = requested_model_id
|
| 86 |
+
if current_ai_studio_model_id != model_id_to_use:
|
| 87 |
+
needs_model_switching = True
|
| 88 |
+
logger.info(f"[{req_id}] 需要切换模型: 当前={current_ai_studio_model_id} -> 目标={model_id_to_use}")
|
| 89 |
+
else:
|
| 90 |
+
logger.info(f"[{req_id}] 请求模型与当前模型相同 ({model_id_to_use}),无需切换")
|
| 91 |
+
else:
|
| 92 |
+
logger.info(f"[{req_id}] 未指定具体模型或使用代理模型名称,将使用当前模型: {current_ai_studio_model_id or '未知'}")
|
| 93 |
+
|
| 94 |
+
client_disconnected_event = Event()
|
| 95 |
+
disconnect_check_task = None
|
| 96 |
+
input_field_locator = page.locator(INPUT_SELECTOR) if page else None
|
| 97 |
+
submit_button_locator = page.locator(SUBMIT_BUTTON_SELECTOR) if page else None
|
| 98 |
+
|
| 99 |
+
async def check_disconnect_periodically():
|
| 100 |
+
while not client_disconnected_event.is_set():
|
| 101 |
+
try:
|
| 102 |
+
if await http_request.is_disconnected():
|
| 103 |
+
logger.info(f"[{req_id}] (Disco Check Task) 客户端断开。设置事件并尝试停止。")
|
| 104 |
+
client_disconnected_event.set()
|
| 105 |
+
try:
|
| 106 |
+
if submit_button_locator and await submit_button_locator.is_enabled(timeout=1500):
|
| 107 |
+
if input_field_locator and await input_field_locator.input_value(timeout=1500) == '':
|
| 108 |
+
logger.info(f"[{req_id}] (Disco Check Task) 点击停止...")
|
| 109 |
+
await submit_button_locator.click(timeout=3000, force=True)
|
| 110 |
+
except Exception as click_err:
|
| 111 |
+
logger.warning(f"[{req_id}] (Disco Check Task) 停止按钮点击失败: {click_err}")
|
| 112 |
+
if not result_future.done():
|
| 113 |
+
result_future.set_exception(HTTPException(status_code=499, detail=f"[{req_id}] 客户端在处理期间关闭了请求"))
|
| 114 |
+
break
|
| 115 |
+
await asyncio.sleep(1.0)
|
| 116 |
+
except asyncio.CancelledError:
|
| 117 |
+
break
|
| 118 |
+
except Exception as e:
|
| 119 |
+
logger.error(f"[{req_id}] (Disco Check Task) 错误: {e}")
|
| 120 |
+
client_disconnected_event.set()
|
| 121 |
+
if not result_future.done():
|
| 122 |
+
result_future.set_exception(HTTPException(status_code=500, detail=f"[{req_id}] Internal disconnect checker error: {e}"))
|
| 123 |
+
break
|
| 124 |
+
|
| 125 |
+
disconnect_check_task = asyncio.create_task(check_disconnect_periodically())
|
| 126 |
+
|
| 127 |
+
def check_client_disconnected(*args):
|
| 128 |
+
msg_to_log = ""
|
| 129 |
+
if len(args) == 1 and isinstance(args[0], str):
|
| 130 |
+
msg_to_log = args[0]
|
| 131 |
+
|
| 132 |
+
if client_disconnected_event.is_set():
|
| 133 |
+
logger.info(f"[{req_id}] {msg_to_log}检测到客户端断开连接事件。")
|
| 134 |
+
raise ClientDisconnectedError(f"[{req_id}] Client disconnected event set.")
|
| 135 |
+
return False
|
| 136 |
+
|
| 137 |
+
try:
|
| 138 |
+
if not page or page.is_closed() or not is_page_ready:
|
| 139 |
+
raise HTTPException(status_code=503, detail=f"[{req_id}] AI Studio 页面丢失或未就绪。", headers={"Retry-After": "30"})
|
| 140 |
+
|
| 141 |
+
check_client_disconnected("Initial Page Check: ")
|
| 142 |
+
|
| 143 |
+
# 模型切换逻辑
|
| 144 |
+
if needs_model_switching and model_id_to_use:
|
| 145 |
+
async with model_switching_lock:
|
| 146 |
+
model_before_switch_attempt = current_ai_studio_model_id
|
| 147 |
+
if current_ai_studio_model_id != model_id_to_use:
|
| 148 |
+
logger.info(f"[{req_id}] 获取锁后准备切换: 当前内存中模型={current_ai_studio_model_id}, 目标={model_id_to_use}")
|
| 149 |
+
switch_success = await switch_ai_studio_model(page, model_id_to_use, req_id)
|
| 150 |
+
if switch_success:
|
| 151 |
+
current_ai_studio_model_id = model_id_to_use
|
| 152 |
+
model_actually_switched_in_current_api_call = True
|
| 153 |
+
logger.info(f"[{req_id}] ✅ 模型切换成功。全局模型状态已更新为: {current_ai_studio_model_id}")
|
| 154 |
+
else:
|
| 155 |
+
logger.warning(f"[{req_id}] ❌ 模型切换至 {model_id_to_use} 失败 (AI Studio 未接受或覆盖了更改)。")
|
| 156 |
+
active_model_id_after_fail = model_before_switch_attempt
|
| 157 |
+
try:
|
| 158 |
+
final_prefs_str_after_fail = await page.evaluate("() => localStorage.getItem('aiStudioUserPreference')")
|
| 159 |
+
if final_prefs_str_after_fail:
|
| 160 |
+
final_prefs_obj_after_fail = json.loads(final_prefs_str_after_fail)
|
| 161 |
+
model_path_in_final_prefs = final_prefs_obj_after_fail.get("promptModel")
|
| 162 |
+
if model_path_in_final_prefs and isinstance(model_path_in_final_prefs, str):
|
| 163 |
+
active_model_id_after_fail = model_path_in_final_prefs.split('/')[-1]
|
| 164 |
+
except Exception as read_final_prefs_err:
|
| 165 |
+
logger.error(f"[{req_id}] 切换失败后读取最终 localStorage 出错: {read_final_prefs_err}")
|
| 166 |
+
current_ai_studio_model_id = active_model_id_after_fail
|
| 167 |
+
logger.info(f"[{req_id}] 全局模型状态在切换失败后设置为 (或保持为): {current_ai_studio_model_id}")
|
| 168 |
+
actual_displayed_model_name = "未知 (无法读取)"
|
| 169 |
+
try:
|
| 170 |
+
model_wrapper_locator = page.locator('#mat-select-value-0 mat-select-trigger').first
|
| 171 |
+
actual_displayed_model_name = await model_wrapper_locator.inner_text(timeout=3000)
|
| 172 |
+
except Exception:
|
| 173 |
+
pass
|
| 174 |
+
raise HTTPException(
|
| 175 |
+
status_code=422,
|
| 176 |
+
detail=f"[{req_id}] AI Studio 未能应用所请求的模型 '{model_id_to_use}' 或该模型不受支持。请选择 AI Studio 网页界面中可用的模型。当前实际生效的模型 ID 为 '{current_ai_studio_model_id}', 页面显示为 '{actual_displayed_model_name}'."
|
| 177 |
+
)
|
| 178 |
+
else:
|
| 179 |
+
logger.info(f"[{req_id}] 获取锁后发现模型已是目标模型 {current_ai_studio_model_id},无需切换")
|
| 180 |
+
|
| 181 |
+
# 参数缓存处理
|
| 182 |
+
async with params_cache_lock:
|
| 183 |
+
cached_model_for_params = page_params_cache.get("last_known_model_id_for_params")
|
| 184 |
+
if model_actually_switched_in_current_api_call or \
|
| 185 |
+
(current_ai_studio_model_id is not None and current_ai_studio_model_id != cached_model_for_params):
|
| 186 |
+
action_taken = "Invalidating" if page_params_cache else "Initializing"
|
| 187 |
+
logger.info(f"[{req_id}] {action_taken} parameter cache. Reason: Model context changed (switched this call: {model_actually_switched_in_current_api_call}, current model: {current_ai_studio_model_id}, cache model: {cached_model_for_params}).")
|
| 188 |
+
page_params_cache.clear()
|
| 189 |
+
if current_ai_studio_model_id:
|
| 190 |
+
page_params_cache["last_known_model_id_for_params"] = current_ai_studio_model_id
|
| 191 |
+
else:
|
| 192 |
+
logger.debug(f"[{req_id}] Parameter cache for model '{cached_model_for_params}' remains valid (current model: '{current_ai_studio_model_id}', switched this call: {model_actually_switched_in_current_api_call}).")
|
| 193 |
+
|
| 194 |
+
# 验证请求
|
| 195 |
+
try:
|
| 196 |
+
validate_chat_request(request.messages, req_id)
|
| 197 |
+
except ValueError as e:
|
| 198 |
+
raise HTTPException(status_code=400, detail=f"[{req_id}] 无效请求: {e}")
|
| 199 |
+
|
| 200 |
+
# 准备提示
|
| 201 |
+
prepared_prompt,image_list = prepare_combined_prompt(request.messages, req_id)
|
| 202 |
+
check_client_disconnected("After Prompt Prep: ")
|
| 203 |
+
|
| 204 |
+
# 这里需要添加完整的处理逻辑 - 由于函数太长,暂时返回简化响应
|
| 205 |
+
logger.info(f"[{req_id}] (Refactored Process) 处理完整逻辑 - 需要从备份恢复剩余部分")
|
| 206 |
+
|
| 207 |
+
# 简单响应用于测试
|
| 208 |
+
if is_streaming:
|
| 209 |
+
completion_event = Event()
|
| 210 |
+
|
| 211 |
+
async def create_simple_stream_generator():
|
| 212 |
+
try:
|
| 213 |
+
yield generate_sse_chunk("正在处理请求...", req_id, MODEL_NAME)
|
| 214 |
+
await asyncio.sleep(1)
|
| 215 |
+
yield generate_sse_chunk("处理完成", req_id, MODEL_NAME)
|
| 216 |
+
yield generate_sse_stop_chunk(req_id, MODEL_NAME)
|
| 217 |
+
yield "data: [DONE]\n\n"
|
| 218 |
+
finally:
|
| 219 |
+
if not completion_event.is_set():
|
| 220 |
+
completion_event.set()
|
| 221 |
+
|
| 222 |
+
if not result_future.done():
|
| 223 |
+
result_future.set_result(StreamingResponse(create_simple_stream_generator(), media_type="text/event-stream"))
|
| 224 |
+
|
| 225 |
+
return completion_event, submit_button_locator, check_client_disconnected
|
| 226 |
+
else:
|
| 227 |
+
response_payload = {
|
| 228 |
+
"id": f"{CHAT_COMPLETION_ID_PREFIX}{req_id}-{int(time.time())}",
|
| 229 |
+
"object": "chat.completion",
|
| 230 |
+
"created": int(time.time()),
|
| 231 |
+
"model": MODEL_NAME,
|
| 232 |
+
"choices": [{
|
| 233 |
+
"index": 0,
|
| 234 |
+
"message": {"role": "assistant", "content": "处理完成 - 需要完整逻辑"},
|
| 235 |
+
"finish_reason": "stop"
|
| 236 |
+
}],
|
| 237 |
+
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
if not result_future.done():
|
| 241 |
+
result_future.set_result(JSONResponse(content=response_payload))
|
| 242 |
+
|
| 243 |
+
return None
|
| 244 |
+
|
| 245 |
+
except ClientDisconnectedError as disco_err:
|
| 246 |
+
logger.info(f"[{req_id}] (Refactored Process) 捕获到客户端断开连接信号: {disco_err}")
|
| 247 |
+
if not result_future.done():
|
| 248 |
+
result_future.set_exception(HTTPException(status_code=499, detail=f"[{req_id}] Client disconnected during processing."))
|
| 249 |
+
except HTTPException as http_err:
|
| 250 |
+
logger.warning(f"[{req_id}] (Refactored Process) 捕获到 HTTP 异常: {http_err.status_code} - {http_err.detail}")
|
| 251 |
+
if not result_future.done():
|
| 252 |
+
result_future.set_exception(http_err)
|
| 253 |
+
except Exception as e:
|
| 254 |
+
logger.exception(f"[{req_id}] (Refactored Process) 捕获到意外错误")
|
| 255 |
+
await save_error_snapshot(f"process_unexpected_error_{req_id}")
|
| 256 |
+
if not result_future.done():
|
| 257 |
+
result_future.set_exception(HTTPException(status_code=500, detail=f"[{req_id}] Unexpected server error: {e}"))
|
| 258 |
+
finally:
|
| 259 |
+
if disconnect_check_task and not disconnect_check_task.done():
|
| 260 |
+
disconnect_check_task.cancel()
|
| 261 |
+
try:
|
| 262 |
+
await disconnect_check_task
|
| 263 |
+
except asyncio.CancelledError:
|
| 264 |
+
pass
|
| 265 |
+
except Exception as task_clean_err:
|
| 266 |
+
logger.error(f"[{req_id}] 清理任务时出错: {task_clean_err}")
|
| 267 |
+
|
| 268 |
+
logger.info(f"[{req_id}] (Refactored Process) 处理完成。")
|
| 269 |
+
|
| 270 |
+
if is_streaming and completion_event and not completion_event.is_set() and (result_future.done() and result_future.exception() is not None):
|
| 271 |
+
logger.warning(f"[{req_id}] (Refactored Process) 流式请求异常,确保完成事件已设置。")
|
| 272 |
+
completion_event.set()
|
| 273 |
+
|
| 274 |
+
return completion_event, submit_button_locator, check_client_disconnected
|
AIStudioProxyAPI/api_utils/routes.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI路由处理器模块
|
| 3 |
+
包含所有API端点的处理函数
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import os
|
| 8 |
+
import random
|
| 9 |
+
import time
|
| 10 |
+
import uuid
|
| 11 |
+
from typing import Dict, List, Any, Set
|
| 12 |
+
from asyncio import Queue, Future, Lock, Event
|
| 13 |
+
import logging
|
| 14 |
+
|
| 15 |
+
from fastapi import HTTPException, Request, WebSocket, WebSocketDisconnect, Depends
|
| 16 |
+
from fastapi.responses import JSONResponse, FileResponse
|
| 17 |
+
from pydantic import BaseModel
|
| 18 |
+
from playwright.async_api import Page as AsyncPage
|
| 19 |
+
|
| 20 |
+
# --- 配置模块导入 ---
|
| 21 |
+
from config import *
|
| 22 |
+
|
| 23 |
+
# --- models模块导入 ---
|
| 24 |
+
from models import ChatCompletionRequest, WebSocketConnectionManager
|
| 25 |
+
|
| 26 |
+
# --- browser_utils模块导入 ---
|
| 27 |
+
from browser_utils import _handle_model_list_response
|
| 28 |
+
|
| 29 |
+
# --- 依赖项导入 ---
|
| 30 |
+
from .dependencies import *
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# --- 静态文件端点 ---
|
| 34 |
+
async def read_index(logger: logging.Logger = Depends(get_logger)):
|
| 35 |
+
"""返回主页面"""
|
| 36 |
+
index_html_path = os.path.join(os.path.dirname(__file__), "..", "index.html")
|
| 37 |
+
if not os.path.exists(index_html_path):
|
| 38 |
+
logger.error(f"index.html not found at {index_html_path}")
|
| 39 |
+
raise HTTPException(status_code=404, detail="index.html not found")
|
| 40 |
+
return FileResponse(index_html_path)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def get_css(logger: logging.Logger = Depends(get_logger)):
|
| 44 |
+
"""返回CSS文件"""
|
| 45 |
+
css_path = os.path.join(os.path.dirname(__file__), "..", "webui.css")
|
| 46 |
+
if not os.path.exists(css_path):
|
| 47 |
+
logger.error(f"webui.css not found at {css_path}")
|
| 48 |
+
raise HTTPException(status_code=404, detail="webui.css not found")
|
| 49 |
+
return FileResponse(css_path, media_type="text/css")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
async def get_js(logger: logging.Logger = Depends(get_logger)):
|
| 53 |
+
"""返回JavaScript文件"""
|
| 54 |
+
js_path = os.path.join(os.path.dirname(__file__), "..", "webui.js")
|
| 55 |
+
if not os.path.exists(js_path):
|
| 56 |
+
logger.error(f"webui.js not found at {js_path}")
|
| 57 |
+
raise HTTPException(status_code=404, detail="webui.js not found")
|
| 58 |
+
return FileResponse(js_path, media_type="application/javascript")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# --- API信息端点 ---
|
| 62 |
+
async def get_api_info(request: Request, current_ai_studio_model_id: str = Depends(get_current_ai_studio_model_id)):
|
| 63 |
+
"""返回API信息"""
|
| 64 |
+
from api_utils import auth_utils
|
| 65 |
+
|
| 66 |
+
server_port = request.url.port or os.environ.get('SERVER_PORT_INFO', '8000')
|
| 67 |
+
host = request.headers.get('host') or f"127.0.0.1:{server_port}"
|
| 68 |
+
scheme = request.headers.get('x-forwarded-proto', 'http')
|
| 69 |
+
base_url = f"{scheme}://{host}"
|
| 70 |
+
api_base = f"{base_url}/v1"
|
| 71 |
+
effective_model_name = current_ai_studio_model_id or MODEL_NAME
|
| 72 |
+
|
| 73 |
+
api_key_required = bool(auth_utils.API_KEYS)
|
| 74 |
+
api_key_count = len(auth_utils.API_KEYS)
|
| 75 |
+
|
| 76 |
+
if api_key_required:
|
| 77 |
+
message = f"API Key is required. {api_key_count} valid key(s) configured."
|
| 78 |
+
else:
|
| 79 |
+
message = "API Key is not required."
|
| 80 |
+
|
| 81 |
+
return JSONResponse(content={
|
| 82 |
+
"model_name": effective_model_name,
|
| 83 |
+
"api_base_url": api_base,
|
| 84 |
+
"server_base_url": base_url,
|
| 85 |
+
"api_key_required": api_key_required,
|
| 86 |
+
"api_key_count": api_key_count,
|
| 87 |
+
"auth_header": "Authorization: Bearer <token> or X-API-Key: <token>" if api_key_required else None,
|
| 88 |
+
"openai_compatible": True,
|
| 89 |
+
"supported_auth_methods": ["Authorization: Bearer", "X-API-Key"] if api_key_required else [],
|
| 90 |
+
"message": message
|
| 91 |
+
})
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# --- 健康检查端点 ---
|
| 95 |
+
async def health_check(
|
| 96 |
+
server_state: Dict[str, Any] = Depends(get_server_state),
|
| 97 |
+
worker_task = Depends(get_worker_task),
|
| 98 |
+
request_queue: Queue = Depends(get_request_queue)
|
| 99 |
+
):
|
| 100 |
+
"""健康检查"""
|
| 101 |
+
is_worker_running = bool(worker_task and not worker_task.done())
|
| 102 |
+
launch_mode = os.environ.get('LAUNCH_MODE', 'unknown')
|
| 103 |
+
browser_page_critical = launch_mode != "direct_debug_no_browser"
|
| 104 |
+
|
| 105 |
+
core_ready_conditions = [not server_state["is_initializing"], server_state["is_playwright_ready"]]
|
| 106 |
+
if browser_page_critical:
|
| 107 |
+
core_ready_conditions.extend([server_state["is_browser_connected"], server_state["is_page_ready"]])
|
| 108 |
+
|
| 109 |
+
is_core_ready = all(core_ready_conditions)
|
| 110 |
+
status_val = "OK" if is_core_ready and is_worker_running else "Error"
|
| 111 |
+
q_size = request_queue.qsize() if request_queue else -1
|
| 112 |
+
|
| 113 |
+
status_message_parts = []
|
| 114 |
+
if server_state["is_initializing"]: status_message_parts.append("初始化进行中")
|
| 115 |
+
if not server_state["is_playwright_ready"]: status_message_parts.append("Playwright 未就绪")
|
| 116 |
+
if browser_page_critical:
|
| 117 |
+
if not server_state["is_browser_connected"]: status_message_parts.append("浏览器未连接")
|
| 118 |
+
if not server_state["is_page_ready"]: status_message_parts.append("页面未就绪")
|
| 119 |
+
if not is_worker_running: status_message_parts.append("Worker 未运行")
|
| 120 |
+
|
| 121 |
+
status = {
|
| 122 |
+
"status": status_val,
|
| 123 |
+
"message": "",
|
| 124 |
+
"details": {**server_state, "workerRunning": is_worker_running, "queueLength": q_size, "launchMode": launch_mode, "browserAndPageCritical": browser_page_critical}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
if status_val == "OK":
|
| 128 |
+
status["message"] = f"服务运行中;队列长度: {q_size}。"
|
| 129 |
+
return JSONResponse(content=status, status_code=200)
|
| 130 |
+
else:
|
| 131 |
+
status["message"] = f"服务不可用;问题: {(', '.join(status_message_parts) or '未知原因')}. 队列长度: {q_size}."
|
| 132 |
+
return JSONResponse(content=status, status_code=503)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# --- 模型列表端点 ---
|
| 136 |
+
async def list_models(
|
| 137 |
+
logger: logging.Logger = Depends(get_logger),
|
| 138 |
+
model_list_fetch_event: Event = Depends(get_model_list_fetch_event),
|
| 139 |
+
page_instance: AsyncPage = Depends(get_page_instance),
|
| 140 |
+
parsed_model_list: List[Dict[str, Any]] = Depends(get_parsed_model_list),
|
| 141 |
+
excluded_model_ids: Set[str] = Depends(get_excluded_model_ids)
|
| 142 |
+
):
|
| 143 |
+
"""获取模型列表"""
|
| 144 |
+
logger.info("[API] 收到 /v1/models 请求。")
|
| 145 |
+
|
| 146 |
+
if not model_list_fetch_event.is_set() and page_instance and not page_instance.is_closed():
|
| 147 |
+
logger.info("/v1/models: 模型列表事件未设置,尝试刷新页面...")
|
| 148 |
+
try:
|
| 149 |
+
await page_instance.reload(wait_until="domcontentloaded", timeout=20000)
|
| 150 |
+
await asyncio.wait_for(model_list_fetch_event.wait(), timeout=10.0)
|
| 151 |
+
except Exception as e:
|
| 152 |
+
logger.error(f"/v1/models: 刷新或等待模型列表时出错: {e}")
|
| 153 |
+
finally:
|
| 154 |
+
if not model_list_fetch_event.is_set():
|
| 155 |
+
model_list_fetch_event.set()
|
| 156 |
+
|
| 157 |
+
if parsed_model_list:
|
| 158 |
+
final_model_list = [m for m in parsed_model_list if m.get("id") not in excluded_model_ids]
|
| 159 |
+
return {"object": "list", "data": final_model_list}
|
| 160 |
+
else:
|
| 161 |
+
logger.warning("模型列表为空,返回默认后备模型。")
|
| 162 |
+
return {"object": "list", "data": [{
|
| 163 |
+
"id": DEFAULT_FALLBACK_MODEL_ID, "object": "model", "created": int(time.time()),
|
| 164 |
+
"owned_by": "camoufox-proxy-fallback"
|
| 165 |
+
}]}
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# --- 聊天完成端点 ---
|
| 169 |
+
async def chat_completions(
|
| 170 |
+
request: ChatCompletionRequest,
|
| 171 |
+
http_request: Request,
|
| 172 |
+
logger: logging.Logger = Depends(get_logger),
|
| 173 |
+
request_queue: Queue = Depends(get_request_queue),
|
| 174 |
+
server_state: Dict[str, Any] = Depends(get_server_state),
|
| 175 |
+
worker_task = Depends(get_worker_task)
|
| 176 |
+
):
|
| 177 |
+
"""处理聊天完成请求"""
|
| 178 |
+
req_id = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=7))
|
| 179 |
+
logger.info(f"[{req_id}] 收到 /v1/chat/completions 请求 (Stream={request.stream})")
|
| 180 |
+
|
| 181 |
+
launch_mode = os.environ.get('LAUNCH_MODE', 'unknown')
|
| 182 |
+
browser_page_critical = launch_mode != "direct_debug_no_browser"
|
| 183 |
+
|
| 184 |
+
service_unavailable = server_state["is_initializing"] or \
|
| 185 |
+
not server_state["is_playwright_ready"] or \
|
| 186 |
+
(browser_page_critical and (not server_state["is_page_ready"] or not server_state["is_browser_connected"])) or \
|
| 187 |
+
not worker_task or worker_task.done()
|
| 188 |
+
|
| 189 |
+
if service_unavailable:
|
| 190 |
+
raise HTTPException(status_code=503, detail=f"[{req_id}] 服务当前不可用。请稍后重试。", headers={"Retry-After": "30"})
|
| 191 |
+
|
| 192 |
+
result_future = Future()
|
| 193 |
+
await request_queue.put({
|
| 194 |
+
"req_id": req_id, "request_data": request, "http_request": http_request,
|
| 195 |
+
"result_future": result_future, "enqueue_time": time.time(), "cancelled": False
|
| 196 |
+
})
|
| 197 |
+
|
| 198 |
+
try:
|
| 199 |
+
timeout_seconds = RESPONSE_COMPLETION_TIMEOUT / 1000 + 120
|
| 200 |
+
return await asyncio.wait_for(result_future, timeout=timeout_seconds)
|
| 201 |
+
except asyncio.TimeoutError:
|
| 202 |
+
raise HTTPException(status_code=504, detail=f"[{req_id}] 请求处理超时。")
|
| 203 |
+
except asyncio.CancelledError:
|
| 204 |
+
raise HTTPException(status_code=499, detail=f"[{req_id}] 请求被客户端取消。")
|
| 205 |
+
except HTTPException as http_exc:
|
| 206 |
+
# 对于客户端断开连接的情况,使用更友好的日志级别
|
| 207 |
+
if http_exc.status_code == 499:
|
| 208 |
+
logger.info(f"[{req_id}] 客户端断开连接: {http_exc.detail}")
|
| 209 |
+
else:
|
| 210 |
+
logger.warning(f"[{req_id}] HTTP异常: {http_exc.detail}")
|
| 211 |
+
raise http_exc
|
| 212 |
+
except Exception as e:
|
| 213 |
+
logger.exception(f"[{req_id}] 等待Worker响应时出错")
|
| 214 |
+
raise HTTPException(status_code=500, detail=f"[{req_id}] 服务器内部错误: {e}")
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# --- 取消请求相关 ---
|
| 218 |
+
async def cancel_queued_request(req_id: str, request_queue: Queue, logger: logging.Logger) -> bool:
|
| 219 |
+
"""取消队列中的请求"""
|
| 220 |
+
items_to_requeue = []
|
| 221 |
+
found = False
|
| 222 |
+
try:
|
| 223 |
+
while not request_queue.empty():
|
| 224 |
+
item = request_queue.get_nowait()
|
| 225 |
+
if item.get("req_id") == req_id:
|
| 226 |
+
logger.info(f"[{req_id}] 在队列中找到请求,标记为已取消。")
|
| 227 |
+
item["cancelled"] = True
|
| 228 |
+
if (future := item.get("result_future")) and not future.done():
|
| 229 |
+
future.set_exception(HTTPException(status_code=499, detail=f"[{req_id}] Request cancelled."))
|
| 230 |
+
found = True
|
| 231 |
+
items_to_requeue.append(item)
|
| 232 |
+
finally:
|
| 233 |
+
for item in items_to_requeue:
|
| 234 |
+
await request_queue.put(item)
|
| 235 |
+
return found
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
async def cancel_request(
|
| 239 |
+
req_id: str,
|
| 240 |
+
logger: logging.Logger = Depends(get_logger),
|
| 241 |
+
request_queue: Queue = Depends(get_request_queue)
|
| 242 |
+
):
|
| 243 |
+
"""取消请求端点"""
|
| 244 |
+
logger.info(f"[{req_id}] 收到取消请求。")
|
| 245 |
+
if await cancel_queued_request(req_id, request_queue, logger):
|
| 246 |
+
return JSONResponse(content={"success": True, "message": f"Request {req_id} marked as cancelled."})
|
| 247 |
+
else:
|
| 248 |
+
return JSONResponse(status_code=404, content={"success": False, "message": f"Request {req_id} not found in queue."})
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
# --- 队列状态端点 ---
|
| 252 |
+
async def get_queue_status(
|
| 253 |
+
request_queue: Queue = Depends(get_request_queue),
|
| 254 |
+
processing_lock: Lock = Depends(get_processing_lock)
|
| 255 |
+
):
|
| 256 |
+
"""获取队列状态"""
|
| 257 |
+
queue_items = list(request_queue._queue)
|
| 258 |
+
return JSONResponse(content={
|
| 259 |
+
"queue_length": len(queue_items),
|
| 260 |
+
"is_processing_locked": processing_lock.locked(),
|
| 261 |
+
"items": sorted([
|
| 262 |
+
{
|
| 263 |
+
"req_id": item.get("req_id", "unknown"),
|
| 264 |
+
"enqueue_time": item.get("enqueue_time", 0),
|
| 265 |
+
"wait_time_seconds": round(time.time() - item.get("enqueue_time", 0), 2),
|
| 266 |
+
"is_streaming": item.get("request_data").stream,
|
| 267 |
+
"cancelled": item.get("cancelled", False)
|
| 268 |
+
} for item in queue_items
|
| 269 |
+
], key=lambda x: x.get("enqueue_time", 0))
|
| 270 |
+
})
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# --- WebSocket日志端点 ---
|
| 274 |
+
async def websocket_log_endpoint(
|
| 275 |
+
websocket: WebSocket,
|
| 276 |
+
logger: logging.Logger = Depends(get_logger),
|
| 277 |
+
log_ws_manager: WebSocketConnectionManager = Depends(get_log_ws_manager)
|
| 278 |
+
):
|
| 279 |
+
"""WebSocket日志端点"""
|
| 280 |
+
if not log_ws_manager:
|
| 281 |
+
await websocket.close(code=1011)
|
| 282 |
+
return
|
| 283 |
+
|
| 284 |
+
client_id = str(uuid.uuid4())
|
| 285 |
+
try:
|
| 286 |
+
await log_ws_manager.connect(client_id, websocket)
|
| 287 |
+
while True:
|
| 288 |
+
await websocket.receive_text() # Keep connection alive
|
| 289 |
+
except WebSocketDisconnect:
|
| 290 |
+
pass
|
| 291 |
+
except Exception as e:
|
| 292 |
+
logger.error(f"日志 WebSocket (客户端 {client_id}) 发生异常: {e}", exc_info=True)
|
| 293 |
+
finally:
|
| 294 |
+
log_ws_manager.disconnect(client_id)
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
# --- API密钥管理数据模型 ---
|
| 298 |
+
class ApiKeyRequest(BaseModel):
|
| 299 |
+
key: str
|
| 300 |
+
|
| 301 |
+
class ApiKeyTestRequest(BaseModel):
|
| 302 |
+
key: str
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
# --- API密钥管理端点 ---
|
| 306 |
+
async def get_api_keys(logger: logging.Logger = Depends(get_logger)):
|
| 307 |
+
"""获取API密钥列表"""
|
| 308 |
+
from api_utils import auth_utils
|
| 309 |
+
try:
|
| 310 |
+
auth_utils.initialize_keys()
|
| 311 |
+
keys_info = [{"value": key, "status": "有效"} for key in auth_utils.API_KEYS]
|
| 312 |
+
return JSONResponse(content={"success": True, "keys": keys_info, "total_count": len(keys_info)})
|
| 313 |
+
except Exception as e:
|
| 314 |
+
logger.error(f"获取API密钥列表失败: {e}")
|
| 315 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
async def add_api_key(request: ApiKeyRequest, logger: logging.Logger = Depends(get_logger)):
|
| 319 |
+
"""添加API密钥"""
|
| 320 |
+
from api_utils import auth_utils
|
| 321 |
+
key_value = request.key.strip()
|
| 322 |
+
if not key_value or len(key_value) < 8:
|
| 323 |
+
raise HTTPException(status_code=400, detail="无效的API密钥格式。")
|
| 324 |
+
|
| 325 |
+
auth_utils.initialize_keys()
|
| 326 |
+
if key_value in auth_utils.API_KEYS:
|
| 327 |
+
raise HTTPException(status_code=400, detail="该API密钥已存在。")
|
| 328 |
+
|
| 329 |
+
try:
|
| 330 |
+
# --- MODIFIED LINE ---
|
| 331 |
+
# Use the centralized path from auth_utils
|
| 332 |
+
key_file_path = auth_utils.KEY_FILE_PATH
|
| 333 |
+
with open(key_file_path, 'a+', encoding='utf-8') as f:
|
| 334 |
+
f.seek(0)
|
| 335 |
+
if f.read(): f.write("\n")
|
| 336 |
+
f.write(key_value)
|
| 337 |
+
|
| 338 |
+
auth_utils.initialize_keys()
|
| 339 |
+
logger.info(f"API密钥已添加: {key_value[:4]}...{key_value[-4:]}")
|
| 340 |
+
return JSONResponse(content={"success": True, "message": "API密钥添加成功", "key_count": len(auth_utils.API_KEYS)})
|
| 341 |
+
except Exception as e:
|
| 342 |
+
logger.error(f"添加API密钥失败: {e}")
|
| 343 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
async def test_api_key(request: ApiKeyTestRequest, logger: logging.Logger = Depends(get_logger)):
|
| 347 |
+
"""测试API密钥"""
|
| 348 |
+
from api_utils import auth_utils
|
| 349 |
+
key_value = request.key.strip()
|
| 350 |
+
if not key_value:
|
| 351 |
+
raise HTTPException(status_code=400, detail="API密钥不能为空。")
|
| 352 |
+
|
| 353 |
+
auth_utils.initialize_keys()
|
| 354 |
+
is_valid = auth_utils.verify_api_key(key_value)
|
| 355 |
+
logger.info(f"API密钥测试: {key_value[:4]}...{key_value[-4:]} - {'有效' if is_valid else '���效'}")
|
| 356 |
+
return JSONResponse(content={"success": True, "valid": is_valid, "message": "密钥有效" if is_valid else "密钥无效或不存在"})
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
async def delete_api_key(request: ApiKeyRequest, logger: logging.Logger = Depends(get_logger)):
|
| 360 |
+
"""删除API密钥"""
|
| 361 |
+
from api_utils import auth_utils
|
| 362 |
+
key_value = request.key.strip()
|
| 363 |
+
if not key_value:
|
| 364 |
+
raise HTTPException(status_code=400, detail="API密钥不能为空。")
|
| 365 |
+
|
| 366 |
+
auth_utils.initialize_keys()
|
| 367 |
+
if key_value not in auth_utils.API_KEYS:
|
| 368 |
+
raise HTTPException(status_code=404, detail="API密钥不存在。")
|
| 369 |
+
|
| 370 |
+
try:
|
| 371 |
+
# --- MODIFIED LINE ---
|
| 372 |
+
# Use the centralized path from auth_utils
|
| 373 |
+
key_file_path = auth_utils.KEY_FILE_PATH
|
| 374 |
+
with open(key_file_path, 'r', encoding='utf-8') as f:
|
| 375 |
+
lines = f.readlines()
|
| 376 |
+
|
| 377 |
+
with open(key_file_path, 'w', encoding='utf-8') as f:
|
| 378 |
+
f.writelines(line for line in lines if line.strip() != key_value)
|
| 379 |
+
|
| 380 |
+
auth_utils.initialize_keys()
|
| 381 |
+
logger.info(f"API密钥已删除: {key_value[:4]}...{key_value[-4:]}")
|
| 382 |
+
return JSONResponse(content={"success": True, "message": "API密钥删除成功", "key_count": len(auth_utils.API_KEYS)})
|
| 383 |
+
except Exception as e:
|
| 384 |
+
logger.error(f"删除API密钥失败: {e}")
|
| 385 |
+
raise HTTPException(status_code=500, detail=str(e))
|
AIStudioProxyAPI/api_utils/utils.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API工具函数模块
|
| 3 |
+
包含SSE生成、流处理、token统计和请求验证等工具函数
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import json
|
| 8 |
+
import time
|
| 9 |
+
import datetime
|
| 10 |
+
from typing import Any, Dict, List, Optional, AsyncGenerator
|
| 11 |
+
from asyncio import Queue
|
| 12 |
+
from models import Message
|
| 13 |
+
import re
|
| 14 |
+
import base64
|
| 15 |
+
import requests
|
| 16 |
+
import os
|
| 17 |
+
import hashlib
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# --- SSE生成函数 ---
|
| 21 |
+
def generate_sse_chunk(delta: str, req_id: str, model: str) -> str:
|
| 22 |
+
"""生成SSE数据块"""
|
| 23 |
+
chunk_data = {
|
| 24 |
+
"id": f"chatcmpl-{req_id}",
|
| 25 |
+
"object": "chat.completion.chunk",
|
| 26 |
+
"created": int(time.time()),
|
| 27 |
+
"model": model,
|
| 28 |
+
"choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}]
|
| 29 |
+
}
|
| 30 |
+
return f"data: {json.dumps(chunk_data)}\n\n"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def generate_sse_stop_chunk(req_id: str, model: str, reason: str = "stop", usage: dict = None) -> str:
|
| 34 |
+
"""生成SSE停止块"""
|
| 35 |
+
stop_chunk_data = {
|
| 36 |
+
"id": f"chatcmpl-{req_id}",
|
| 37 |
+
"object": "chat.completion.chunk",
|
| 38 |
+
"created": int(time.time()),
|
| 39 |
+
"model": model,
|
| 40 |
+
"choices": [{"index": 0, "delta": {}, "finish_reason": reason}]
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
# 添加usage信息(如果提供)
|
| 44 |
+
if usage:
|
| 45 |
+
stop_chunk_data["usage"] = usage
|
| 46 |
+
|
| 47 |
+
return f"data: {json.dumps(stop_chunk_data)}\n\ndata: [DONE]\n\n"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def generate_sse_error_chunk(message: str, req_id: str, error_type: str = "server_error") -> str:
|
| 51 |
+
"""生成SSE错误块"""
|
| 52 |
+
error_chunk = {"error": {"message": message, "type": error_type, "param": None, "code": req_id}}
|
| 53 |
+
return f"data: {json.dumps(error_chunk)}\n\n"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# --- 流处理工具函数 ---
|
| 57 |
+
async def use_stream_response(req_id: str) -> AsyncGenerator[Any, None]:
|
| 58 |
+
"""使用流响应(从服务器的全局队列获取数据)"""
|
| 59 |
+
from server import STREAM_QUEUE, logger
|
| 60 |
+
import queue
|
| 61 |
+
|
| 62 |
+
if STREAM_QUEUE is None:
|
| 63 |
+
logger.warning(f"[{req_id}] STREAM_QUEUE is None, 无法使用流响应")
|
| 64 |
+
return
|
| 65 |
+
|
| 66 |
+
logger.info(f"[{req_id}] 开始使用流响应")
|
| 67 |
+
|
| 68 |
+
empty_count = 0
|
| 69 |
+
max_empty_retries = 300 # 30秒超时
|
| 70 |
+
data_received = False
|
| 71 |
+
|
| 72 |
+
try:
|
| 73 |
+
while True:
|
| 74 |
+
try:
|
| 75 |
+
# 从队列中获取数据
|
| 76 |
+
data = STREAM_QUEUE.get_nowait()
|
| 77 |
+
if data is None: # 结束标志
|
| 78 |
+
logger.info(f"[{req_id}] 接收到流结束标志")
|
| 79 |
+
break
|
| 80 |
+
|
| 81 |
+
# 重置空计数器
|
| 82 |
+
empty_count = 0
|
| 83 |
+
data_received = True
|
| 84 |
+
logger.debug(f"[{req_id}] 接收到流数据: {type(data)} - {str(data)[:200]}...")
|
| 85 |
+
|
| 86 |
+
# 检查是否是JSON字符串形式的结束标志
|
| 87 |
+
if isinstance(data, str):
|
| 88 |
+
try:
|
| 89 |
+
parsed_data = json.loads(data)
|
| 90 |
+
if parsed_data.get("done") is True:
|
| 91 |
+
logger.info(f"[{req_id}] 接收到JSON格式的完成标志")
|
| 92 |
+
yield parsed_data
|
| 93 |
+
break
|
| 94 |
+
else:
|
| 95 |
+
yield parsed_data
|
| 96 |
+
except json.JSONDecodeError:
|
| 97 |
+
# 如果不是JSON,直接返回字符串
|
| 98 |
+
logger.debug(f"[{req_id}] 返回非JSON字符串数据")
|
| 99 |
+
yield data
|
| 100 |
+
else:
|
| 101 |
+
# 直接返回数据
|
| 102 |
+
yield data
|
| 103 |
+
|
| 104 |
+
# 检查字典类型的结束标志
|
| 105 |
+
if isinstance(data, dict) and data.get("done") is True:
|
| 106 |
+
logger.info(f"[{req_id}] 接收到字典格式的完成标志")
|
| 107 |
+
break
|
| 108 |
+
|
| 109 |
+
except (queue.Empty, asyncio.QueueEmpty):
|
| 110 |
+
empty_count += 1
|
| 111 |
+
if empty_count % 50 == 0: # 每5秒记录一次等待状态
|
| 112 |
+
logger.info(f"[{req_id}] 等待流数据... ({empty_count}/{max_empty_retries})")
|
| 113 |
+
|
| 114 |
+
if empty_count >= max_empty_retries:
|
| 115 |
+
if not data_received:
|
| 116 |
+
logger.error(f"[{req_id}] 流响应队列空读取次数达到上限且未收到任何数据,可能是辅助流未启动或出错")
|
| 117 |
+
else:
|
| 118 |
+
logger.warning(f"[{req_id}] 流响应队列空读取次数达到上限 ({max_empty_retries}),结束读取")
|
| 119 |
+
|
| 120 |
+
# 返回超时完成信号,而不是简单退出
|
| 121 |
+
yield {"done": True, "reason": "internal_timeout", "body": "", "function": []}
|
| 122 |
+
return
|
| 123 |
+
|
| 124 |
+
await asyncio.sleep(0.1) # 100ms等待
|
| 125 |
+
continue
|
| 126 |
+
|
| 127 |
+
except Exception as e:
|
| 128 |
+
logger.error(f"[{req_id}] 使用流响应时出错: {e}")
|
| 129 |
+
raise
|
| 130 |
+
finally:
|
| 131 |
+
logger.info(f"[{req_id}] 流响应使用完成,数据接收状态: {data_received}")
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
async def clear_stream_queue():
|
| 135 |
+
"""清空流队列(与原始参考文件保持一致)"""
|
| 136 |
+
from server import STREAM_QUEUE, logger
|
| 137 |
+
import queue
|
| 138 |
+
|
| 139 |
+
if STREAM_QUEUE is None:
|
| 140 |
+
logger.info("流队列未初始化或已被禁用,跳过清空操作。")
|
| 141 |
+
return
|
| 142 |
+
|
| 143 |
+
while True:
|
| 144 |
+
try:
|
| 145 |
+
data_chunk = await asyncio.to_thread(STREAM_QUEUE.get_nowait)
|
| 146 |
+
# logger.info(f"清空流式队列缓存,丢弃数据: {data_chunk}")
|
| 147 |
+
except queue.Empty:
|
| 148 |
+
logger.info("流式队列已清空 (捕获到 queue.Empty)。")
|
| 149 |
+
break
|
| 150 |
+
except Exception as e:
|
| 151 |
+
logger.error(f"清空流式队列时发生意外错误: {e}", exc_info=True)
|
| 152 |
+
break
|
| 153 |
+
logger.info("流式队列缓存清空完毕。")
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# --- Helper response generator ---
|
| 157 |
+
async def use_helper_get_response(helper_endpoint: str, helper_sapisid: str) -> AsyncGenerator[str, None]:
|
| 158 |
+
"""使用Helper服务获取响应的生成器"""
|
| 159 |
+
from server import logger
|
| 160 |
+
import aiohttp
|
| 161 |
+
|
| 162 |
+
logger.info(f"正在尝试使用Helper端点: {helper_endpoint}")
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
async with aiohttp.ClientSession() as session:
|
| 166 |
+
headers = {
|
| 167 |
+
'Content-Type': 'application/json',
|
| 168 |
+
'Cookie': f'SAPISID={helper_sapisid}' if helper_sapisid else ''
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
async with session.get(helper_endpoint, headers=headers) as response:
|
| 172 |
+
if response.status == 200:
|
| 173 |
+
async for chunk in response.content.iter_chunked(1024):
|
| 174 |
+
if chunk:
|
| 175 |
+
yield chunk.decode('utf-8', errors='ignore')
|
| 176 |
+
else:
|
| 177 |
+
logger.error(f"Helper端点返回错误状态: {response.status}")
|
| 178 |
+
|
| 179 |
+
except Exception as e:
|
| 180 |
+
logger.error(f"使用Helper端点时出错: {e}")
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# --- 请求验证函数 ---
|
| 184 |
+
def validate_chat_request(messages: List[Message], req_id: str) -> Dict[str, Optional[str]]:
|
| 185 |
+
"""验证聊天请求"""
|
| 186 |
+
from server import logger
|
| 187 |
+
|
| 188 |
+
if not messages:
|
| 189 |
+
raise ValueError(f"[{req_id}] 无效请求: 'messages' 数组缺失或为空。")
|
| 190 |
+
|
| 191 |
+
if not any(msg.role != 'system' for msg in messages):
|
| 192 |
+
raise ValueError(f"[{req_id}] 无效请求: 所有消息都是系统消息。至少需要一条用户或助手消息。")
|
| 193 |
+
|
| 194 |
+
# 返回验证结果
|
| 195 |
+
return {
|
| 196 |
+
"error": None,
|
| 197 |
+
"warning": None
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def extract_base64_to_local(base64_data: str) -> str:
|
| 202 |
+
output_dir = os.path.join(os.path.dirname(__file__), '..', 'upload_images')
|
| 203 |
+
match = re.match(r"data:image/(\w+);base64,(.*)", base64_data)
|
| 204 |
+
if not match:
|
| 205 |
+
print("错误: Base64 数据格式不正确。")
|
| 206 |
+
return None
|
| 207 |
+
|
| 208 |
+
image_type = match.group(1) # 例如 "png", "jpeg"
|
| 209 |
+
encoded_image_data = match.group(2)
|
| 210 |
+
|
| 211 |
+
try:
|
| 212 |
+
# 解码 Base64 字符串
|
| 213 |
+
decoded_image_data = base64.b64decode(encoded_image_data)
|
| 214 |
+
except base64.binascii.Error as e:
|
| 215 |
+
print(f"错误: Base64 解码失败 - {e}")
|
| 216 |
+
return None
|
| 217 |
+
|
| 218 |
+
# 计算图片数据的 MD5 值
|
| 219 |
+
md5_hash = hashlib.md5(decoded_image_data).hexdigest()
|
| 220 |
+
|
| 221 |
+
# 确定文件扩展名和完整文件路径
|
| 222 |
+
file_extension = f".{image_type}"
|
| 223 |
+
output_filepath = os.path.join(output_dir, f"{md5_hash}{file_extension}")
|
| 224 |
+
|
| 225 |
+
# 确保输出目录存在
|
| 226 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 227 |
+
|
| 228 |
+
if os.path.exists(output_filepath):
|
| 229 |
+
print(f"文件已存在,跳过保存: {output_filepath}")
|
| 230 |
+
return output_filepath
|
| 231 |
+
|
| 232 |
+
# 保存图片到文件
|
| 233 |
+
try:
|
| 234 |
+
with open(output_filepath, "wb") as f:
|
| 235 |
+
f.write(decoded_image_data)
|
| 236 |
+
print(f"图片已成功保存到: {output_filepath}")
|
| 237 |
+
return output_filepath
|
| 238 |
+
except IOError as e:
|
| 239 |
+
print(f"错误: 保存文件失败 - {e}")
|
| 240 |
+
return None
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
# --- 提示准备函数 ---
|
| 244 |
+
def prepare_combined_prompt(messages: List[Message], req_id: str) -> str:
|
| 245 |
+
"""准备组合提示"""
|
| 246 |
+
from server import logger
|
| 247 |
+
|
| 248 |
+
logger.info(f"[{req_id}] (准备提示) 正在从 {len(messages)} 条消息准备组合提示 (包括历史)。")
|
| 249 |
+
|
| 250 |
+
combined_parts = []
|
| 251 |
+
system_prompt_content: Optional[str] = None
|
| 252 |
+
processed_system_message_indices = set()
|
| 253 |
+
images_list = [] # 将 image_list 的初始化移到循环外部
|
| 254 |
+
|
| 255 |
+
# 处理系统消息
|
| 256 |
+
for i, msg in enumerate(messages):
|
| 257 |
+
if msg.role == 'system':
|
| 258 |
+
content = msg.content
|
| 259 |
+
if isinstance(content, str) and content.strip():
|
| 260 |
+
system_prompt_content = content.strip()
|
| 261 |
+
processed_system_message_indices.add(i)
|
| 262 |
+
logger.info(f"[{req_id}] (准备提示) 在索引 {i} 找到并使用系统提示: '{system_prompt_content[:80]}...'")
|
| 263 |
+
system_instr_prefix = "系统指令:\n"
|
| 264 |
+
combined_parts.append(f"{system_instr_prefix}{system_prompt_content}")
|
| 265 |
+
else:
|
| 266 |
+
logger.info(f"[{req_id}] (准备提示) 在索引 {i} 忽略非字符串或空的系统消息。")
|
| 267 |
+
processed_system_message_indices.add(i)
|
| 268 |
+
break
|
| 269 |
+
|
| 270 |
+
role_map_ui = {"user": "用户", "assistant": "助手", "system": "系统", "tool": "工具"}
|
| 271 |
+
turn_separator = "\n---\n"
|
| 272 |
+
|
| 273 |
+
# 处理其他消息
|
| 274 |
+
for i, msg in enumerate(messages):
|
| 275 |
+
if i in processed_system_message_indices:
|
| 276 |
+
continue
|
| 277 |
+
|
| 278 |
+
if msg.role == 'system':
|
| 279 |
+
logger.info(f"[{req_id}] (准备提示) 跳过在索引 {i} 的后续系统消息。")
|
| 280 |
+
continue
|
| 281 |
+
|
| 282 |
+
if combined_parts:
|
| 283 |
+
combined_parts.append(turn_separator)
|
| 284 |
+
|
| 285 |
+
role = msg.role or 'unknown'
|
| 286 |
+
role_prefix_ui = f"{role_map_ui.get(role, role.capitalize())}:\n"
|
| 287 |
+
current_turn_parts = [role_prefix_ui]
|
| 288 |
+
|
| 289 |
+
content = msg.content or ''
|
| 290 |
+
content_str = ""
|
| 291 |
+
|
| 292 |
+
if isinstance(content, str):
|
| 293 |
+
content_str = content.strip()
|
| 294 |
+
elif isinstance(content, list):
|
| 295 |
+
# 处理多模态内容
|
| 296 |
+
text_parts = []
|
| 297 |
+
for item in content:
|
| 298 |
+
if hasattr(item, 'type') and item.type == 'text':
|
| 299 |
+
text_parts.append(item.text or '')
|
| 300 |
+
elif isinstance(item, dict) and item.get('type') == 'text':
|
| 301 |
+
text_parts.append(item.get('text', ''))
|
| 302 |
+
elif hasattr(item, 'type') and item.type == 'image_url':
|
| 303 |
+
image_url_value = item.image_url.url
|
| 304 |
+
if image_url_value.startswith("data:image/"):
|
| 305 |
+
try:
|
| 306 |
+
# 提取 Base64 字符串
|
| 307 |
+
image_full_path = extract_base64_to_local(image_url_value)
|
| 308 |
+
images_list.append(image_full_path)
|
| 309 |
+
except (ValueError, requests.exceptions.RequestException, Exception) as e:
|
| 310 |
+
print(f"处理 Base64 图片并上传到 Imgur 失败: {e}")
|
| 311 |
+
else:
|
| 312 |
+
logger.warning(f"[{req_id}] (准备提示) 警告: 在索引 {i} 的消息中忽略非文本或未知类型的 content item")
|
| 313 |
+
content_str = "\n".join(text_parts).strip()
|
| 314 |
+
else:
|
| 315 |
+
logger.warning(f"[{req_id}] (准备提示) 警告: 角色 {role} 在索引 {i} 的内容类型意外 ({type(content)}) 或为 None。")
|
| 316 |
+
content_str = str(content or "").strip()
|
| 317 |
+
|
| 318 |
+
if content_str:
|
| 319 |
+
current_turn_parts.append(content_str)
|
| 320 |
+
|
| 321 |
+
# 处理工具调用
|
| 322 |
+
tool_calls = msg.tool_calls
|
| 323 |
+
if role == 'assistant' and tool_calls:
|
| 324 |
+
if content_str:
|
| 325 |
+
current_turn_parts.append("\n")
|
| 326 |
+
|
| 327 |
+
tool_call_visualizations = []
|
| 328 |
+
for tool_call in tool_calls:
|
| 329 |
+
if hasattr(tool_call, 'type') and tool_call.type == 'function':
|
| 330 |
+
function_call = tool_call.function
|
| 331 |
+
func_name = function_call.name if function_call else None
|
| 332 |
+
func_args_str = function_call.arguments if function_call else None
|
| 333 |
+
|
| 334 |
+
try:
|
| 335 |
+
parsed_args = json.loads(func_args_str if func_args_str else '{}')
|
| 336 |
+
formatted_args = json.dumps(parsed_args, indent=2, ensure_ascii=False)
|
| 337 |
+
except (json.JSONDecodeError, TypeError):
|
| 338 |
+
formatted_args = func_args_str if func_args_str is not None else "{}"
|
| 339 |
+
|
| 340 |
+
tool_call_visualizations.append(
|
| 341 |
+
f"请求调用函数: {func_name}\n参数:\n{formatted_args}"
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
if tool_call_visualizations:
|
| 345 |
+
current_turn_parts.append("\n".join(tool_call_visualizations))
|
| 346 |
+
|
| 347 |
+
if len(current_turn_parts) > 1 or (role == 'assistant' and tool_calls):
|
| 348 |
+
combined_parts.append("".join(current_turn_parts))
|
| 349 |
+
elif not combined_parts and not current_turn_parts:
|
| 350 |
+
logger.info(f"[{req_id}] (准备提示) 跳过角色 {role} 在索引 {i} 的空消息 (且无工具调用)。")
|
| 351 |
+
elif len(current_turn_parts) == 1 and not combined_parts:
|
| 352 |
+
logger.info(f"[{req_id}] (准备提示) 跳过角色 {role} 在索引 {i} 的空消息 (只有前缀)。")
|
| 353 |
+
|
| 354 |
+
final_prompt = "".join(combined_parts)
|
| 355 |
+
if final_prompt:
|
| 356 |
+
final_prompt += "\n"
|
| 357 |
+
|
| 358 |
+
preview_text = final_prompt[:300].replace('\n', '\\n')
|
| 359 |
+
logger.info(f"[{req_id}] (准备提示) 组合提示长度: {len(final_prompt)}。预览: '{preview_text}...'")
|
| 360 |
+
|
| 361 |
+
return final_prompt,images_list
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def estimate_tokens(text: str) -> int:
|
| 365 |
+
"""
|
| 366 |
+
估算文本的token数量
|
| 367 |
+
使用简单的字符计数方法:
|
| 368 |
+
- 英文:大约4个字符 = 1个token
|
| 369 |
+
- 中文:大约1.5个字符 = 1个token
|
| 370 |
+
- 混合文本:采用加权平均
|
| 371 |
+
"""
|
| 372 |
+
if not text:
|
| 373 |
+
return 0
|
| 374 |
+
|
| 375 |
+
# 统计中文字符数量(包括中文标点)
|
| 376 |
+
chinese_chars = sum(1 for char in text if '\u4e00' <= char <= '\u9fff' or '\u3000' <= char <= '\u303f' or '\uff00' <= char <= '\uffef')
|
| 377 |
+
|
| 378 |
+
# 统计非中文字符数量
|
| 379 |
+
non_chinese_chars = len(text) - chinese_chars
|
| 380 |
+
|
| 381 |
+
# 计算token估算
|
| 382 |
+
chinese_tokens = chinese_chars / 1.5 # 中文大约1.5字符/token
|
| 383 |
+
english_tokens = non_chinese_chars / 4.0 # 英文大约4字符/token
|
| 384 |
+
|
| 385 |
+
return max(1, int(chinese_tokens + english_tokens))
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def calculate_usage_stats(messages: List[dict], response_content: str, reasoning_content: str = None) -> dict:
|
| 389 |
+
"""
|
| 390 |
+
计算token使用统计
|
| 391 |
+
|
| 392 |
+
Args:
|
| 393 |
+
messages: 请求中的消息列表
|
| 394 |
+
response_content: 响应内容
|
| 395 |
+
reasoning_content: 推理内容(可选)
|
| 396 |
+
|
| 397 |
+
Returns:
|
| 398 |
+
包含token使用统计的字典
|
| 399 |
+
"""
|
| 400 |
+
# 计算输入token(prompt tokens)
|
| 401 |
+
prompt_text = ""
|
| 402 |
+
for message in messages:
|
| 403 |
+
role = message.get("role", "")
|
| 404 |
+
content = message.get("content", "")
|
| 405 |
+
prompt_text += f"{role}: {content}\n"
|
| 406 |
+
|
| 407 |
+
prompt_tokens = estimate_tokens(prompt_text)
|
| 408 |
+
|
| 409 |
+
# 计算输出token(completion tokens)
|
| 410 |
+
completion_text = response_content or ""
|
| 411 |
+
if reasoning_content:
|
| 412 |
+
completion_text += reasoning_content
|
| 413 |
+
|
| 414 |
+
completion_tokens = estimate_tokens(completion_text)
|
| 415 |
+
|
| 416 |
+
# 总token数
|
| 417 |
+
total_tokens = prompt_tokens + completion_tokens
|
| 418 |
+
|
| 419 |
+
return {
|
| 420 |
+
"prompt_tokens": prompt_tokens,
|
| 421 |
+
"completion_tokens": completion_tokens,
|
| 422 |
+
"total_tokens": total_tokens
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def generate_sse_stop_chunk_with_usage(req_id: str, model: str, usage_stats: dict, reason: str = "stop") -> str:
|
| 427 |
+
"""生成带usage统计的SSE停止块"""
|
| 428 |
+
return generate_sse_stop_chunk(req_id, model, reason, usage_stats)
|
AIStudioProxyAPI/auth_profiles/active/.gitkeep
ADDED
|
File without changes
|
AIStudioProxyAPI/auth_profiles/saved/.gitkeep
ADDED
|
File without changes
|
AIStudioProxyAPI/browser_utils/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- browser_utils/__init__.py ---
|
| 2 |
+
# 浏览器操作工具模块
|
| 3 |
+
from .initialization import _initialize_page_logic, _close_page_logic, signal_camoufox_shutdown, enable_temporary_chat_mode
|
| 4 |
+
from .operations import (
|
| 5 |
+
_handle_model_list_response,
|
| 6 |
+
detect_and_extract_page_error,
|
| 7 |
+
save_error_snapshot,
|
| 8 |
+
get_response_via_edit_button,
|
| 9 |
+
get_response_via_copy_button,
|
| 10 |
+
_wait_for_response_completion,
|
| 11 |
+
_get_final_response_content,
|
| 12 |
+
get_raw_text_content
|
| 13 |
+
)
|
| 14 |
+
from .model_management import (
|
| 15 |
+
switch_ai_studio_model,
|
| 16 |
+
load_excluded_models,
|
| 17 |
+
_handle_initial_model_state_and_storage,
|
| 18 |
+
_set_model_from_page_display,
|
| 19 |
+
_verify_ui_state_settings,
|
| 20 |
+
_force_ui_state_settings,
|
| 21 |
+
_force_ui_state_with_retry,
|
| 22 |
+
_verify_and_apply_ui_state
|
| 23 |
+
)
|
| 24 |
+
from .script_manager import ScriptManager, script_manager
|
| 25 |
+
|
| 26 |
+
__all__ = [
|
| 27 |
+
# 初始化相关
|
| 28 |
+
'_initialize_page_logic',
|
| 29 |
+
'_close_page_logic',
|
| 30 |
+
'signal_camoufox_shutdown',
|
| 31 |
+
'enable_temporary_chat_mode',
|
| 32 |
+
|
| 33 |
+
# 页面操作相关
|
| 34 |
+
'_handle_model_list_response',
|
| 35 |
+
'detect_and_extract_page_error',
|
| 36 |
+
'save_error_snapshot',
|
| 37 |
+
'get_response_via_edit_button',
|
| 38 |
+
'get_response_via_copy_button',
|
| 39 |
+
'_wait_for_response_completion',
|
| 40 |
+
'_get_final_response_content',
|
| 41 |
+
'get_raw_text_content',
|
| 42 |
+
|
| 43 |
+
# 模型管理相关
|
| 44 |
+
'switch_ai_studio_model',
|
| 45 |
+
'load_excluded_models',
|
| 46 |
+
'_handle_initial_model_state_and_storage',
|
| 47 |
+
'_set_model_from_page_display',
|
| 48 |
+
'_verify_ui_state_settings',
|
| 49 |
+
'_force_ui_state_settings',
|
| 50 |
+
'_force_ui_state_with_retry',
|
| 51 |
+
'_verify_and_apply_ui_state',
|
| 52 |
+
|
| 53 |
+
# 脚本管理相关
|
| 54 |
+
'ScriptManager',
|
| 55 |
+
'script_manager'
|
| 56 |
+
]
|
AIStudioProxyAPI/browser_utils/initialization.py
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- browser_utils/initialization.py ---
|
| 2 |
+
# 浏览器初始化相关功能模块
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import os
|
| 6 |
+
import time
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
from typing import Optional, Any, Dict, Tuple
|
| 10 |
+
|
| 11 |
+
from playwright.async_api import Page as AsyncPage, Browser as AsyncBrowser, BrowserContext as AsyncBrowserContext, Error as PlaywrightAsyncError, expect as expect_async
|
| 12 |
+
|
| 13 |
+
# 导入配置和模型
|
| 14 |
+
from config import *
|
| 15 |
+
from models import ClientDisconnectedError
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger("AIStudioProxyServer")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
async def _setup_network_interception_and_scripts(context: AsyncBrowserContext):
|
| 21 |
+
"""设置网络拦截和脚本注入"""
|
| 22 |
+
try:
|
| 23 |
+
from config.settings import ENABLE_SCRIPT_INJECTION
|
| 24 |
+
|
| 25 |
+
if not ENABLE_SCRIPT_INJECTION:
|
| 26 |
+
logger.info("脚本注入功能已禁用")
|
| 27 |
+
return
|
| 28 |
+
|
| 29 |
+
# 设置网络拦截
|
| 30 |
+
await _setup_model_list_interception(context)
|
| 31 |
+
|
| 32 |
+
# 可选:仍然注入脚本作为备用方案
|
| 33 |
+
await _add_init_scripts_to_context(context)
|
| 34 |
+
|
| 35 |
+
except Exception as e:
|
| 36 |
+
logger.error(f"设置网络拦截和脚本注入时发生错误: {e}")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def _setup_model_list_interception(context: AsyncBrowserContext):
|
| 40 |
+
"""设置模型列表网络拦截"""
|
| 41 |
+
try:
|
| 42 |
+
async def handle_model_list_route(route):
|
| 43 |
+
"""处理模型列表请求的路由"""
|
| 44 |
+
request = route.request
|
| 45 |
+
|
| 46 |
+
# 检查是否是模型列表请求
|
| 47 |
+
if 'alkalimakersuite' in request.url and 'ListModels' in request.url:
|
| 48 |
+
logger.info(f"🔍 拦截到模型列表请求: {request.url}")
|
| 49 |
+
|
| 50 |
+
# 继续原始请求
|
| 51 |
+
response = await route.fetch()
|
| 52 |
+
|
| 53 |
+
# 获取原始响应
|
| 54 |
+
original_body = await response.body()
|
| 55 |
+
|
| 56 |
+
# 修改响应
|
| 57 |
+
modified_body = await _modify_model_list_response(original_body, request.url)
|
| 58 |
+
|
| 59 |
+
# 返回修改后的响应
|
| 60 |
+
await route.fulfill(
|
| 61 |
+
response=response,
|
| 62 |
+
body=modified_body
|
| 63 |
+
)
|
| 64 |
+
else:
|
| 65 |
+
# 对于其他请求,直接继续
|
| 66 |
+
await route.continue_()
|
| 67 |
+
|
| 68 |
+
# 注册路由拦截器
|
| 69 |
+
await context.route("**/*", handle_model_list_route)
|
| 70 |
+
logger.info("✅ 已设置模型列表网络拦截")
|
| 71 |
+
|
| 72 |
+
except Exception as e:
|
| 73 |
+
logger.error(f"设置模型列表网络拦截时发生错误: {e}")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
async def _modify_model_list_response(original_body: bytes, url: str) -> bytes:
|
| 77 |
+
"""修改模型列表响应"""
|
| 78 |
+
try:
|
| 79 |
+
# 解码响应体
|
| 80 |
+
original_text = original_body.decode('utf-8')
|
| 81 |
+
|
| 82 |
+
# 处理反劫持前缀
|
| 83 |
+
ANTI_HIJACK_PREFIX = ")]}'\n"
|
| 84 |
+
has_prefix = False
|
| 85 |
+
if original_text.startswith(ANTI_HIJACK_PREFIX):
|
| 86 |
+
original_text = original_text[len(ANTI_HIJACK_PREFIX):]
|
| 87 |
+
has_prefix = True
|
| 88 |
+
|
| 89 |
+
# 解析JSON
|
| 90 |
+
import json
|
| 91 |
+
json_data = json.loads(original_text)
|
| 92 |
+
|
| 93 |
+
# 注入模型
|
| 94 |
+
modified_data = await _inject_models_to_response(json_data, url)
|
| 95 |
+
|
| 96 |
+
# 序列化回JSON
|
| 97 |
+
modified_text = json.dumps(modified_data, separators=(',', ':'))
|
| 98 |
+
|
| 99 |
+
# 重新添加前缀
|
| 100 |
+
if has_prefix:
|
| 101 |
+
modified_text = ANTI_HIJACK_PREFIX + modified_text
|
| 102 |
+
|
| 103 |
+
logger.info("✅ 成功修改模型列表响应")
|
| 104 |
+
return modified_text.encode('utf-8')
|
| 105 |
+
|
| 106 |
+
except Exception as e:
|
| 107 |
+
logger.error(f"修改模型列表响应时发生错误: {e}")
|
| 108 |
+
return original_body
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
async def _inject_models_to_response(json_data: dict, url: str) -> dict:
|
| 112 |
+
"""向响应中注入模型"""
|
| 113 |
+
try:
|
| 114 |
+
from .operations import _get_injected_models
|
| 115 |
+
|
| 116 |
+
# 获取要注入的模型
|
| 117 |
+
injected_models = _get_injected_models()
|
| 118 |
+
if not injected_models:
|
| 119 |
+
logger.info("没有要注入的模型")
|
| 120 |
+
return json_data
|
| 121 |
+
|
| 122 |
+
# 查找模型数组
|
| 123 |
+
models_array = _find_model_list_array(json_data)
|
| 124 |
+
if not models_array:
|
| 125 |
+
logger.warning("未找到模型数组结构")
|
| 126 |
+
return json_data
|
| 127 |
+
|
| 128 |
+
# 找到模板模型
|
| 129 |
+
template_model = _find_template_model(models_array)
|
| 130 |
+
if not template_model:
|
| 131 |
+
logger.warning("未找到模板模型")
|
| 132 |
+
return json_data
|
| 133 |
+
|
| 134 |
+
# 注入模型
|
| 135 |
+
for model in reversed(injected_models): # 反向以保持顺序
|
| 136 |
+
model_name = model['raw_model_path']
|
| 137 |
+
|
| 138 |
+
# 检查模型是否已存在
|
| 139 |
+
if not any(m[0] == model_name for m in models_array if isinstance(m, list) and len(m) > 0):
|
| 140 |
+
# 创建新模型条目
|
| 141 |
+
new_model = json.loads(json.dumps(template_model)) # 深拷贝
|
| 142 |
+
new_model[0] = model_name # name
|
| 143 |
+
new_model[3] = model['display_name'] # display name
|
| 144 |
+
new_model[4] = model['description'] # description
|
| 145 |
+
|
| 146 |
+
# 添加特殊标记,表示这是通过网络拦截注入的模型
|
| 147 |
+
# 在模型数组的末尾添加一个特殊字段作为标记
|
| 148 |
+
if len(new_model) > 10: # 确保有足够的位置
|
| 149 |
+
new_model.append("__NETWORK_INJECTED__") # 添加网络注入标记
|
| 150 |
+
else:
|
| 151 |
+
# 如果模型数组长度不够,扩展到足够长度
|
| 152 |
+
while len(new_model) <= 10:
|
| 153 |
+
new_model.append(None)
|
| 154 |
+
new_model.append("__NETWORK_INJECTED__")
|
| 155 |
+
|
| 156 |
+
# 添加到开头
|
| 157 |
+
models_array.insert(0, new_model)
|
| 158 |
+
logger.info(f"✅ 网络拦截注入模型: {model['display_name']}")
|
| 159 |
+
|
| 160 |
+
return json_data
|
| 161 |
+
|
| 162 |
+
except Exception as e:
|
| 163 |
+
logger.error(f"注入模型到响应时发生错误: {e}")
|
| 164 |
+
return json_data
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _find_model_list_array(obj):
|
| 168 |
+
"""递归查找模型列表数组"""
|
| 169 |
+
if not obj:
|
| 170 |
+
return None
|
| 171 |
+
|
| 172 |
+
# 检查是否是模型数组
|
| 173 |
+
if isinstance(obj, list) and len(obj) > 0:
|
| 174 |
+
if all(isinstance(item, list) and len(item) > 0 and
|
| 175 |
+
isinstance(item[0], str) and item[0].startswith('models/')
|
| 176 |
+
for item in obj):
|
| 177 |
+
return obj
|
| 178 |
+
|
| 179 |
+
# 递归搜索
|
| 180 |
+
if isinstance(obj, dict):
|
| 181 |
+
for value in obj.values():
|
| 182 |
+
result = _find_model_list_array(value)
|
| 183 |
+
if result:
|
| 184 |
+
return result
|
| 185 |
+
elif isinstance(obj, list):
|
| 186 |
+
for item in obj:
|
| 187 |
+
result = _find_model_list_array(item)
|
| 188 |
+
if result:
|
| 189 |
+
return result
|
| 190 |
+
|
| 191 |
+
return None
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _find_template_model(models_array):
|
| 195 |
+
"""查找模板模型"""
|
| 196 |
+
if not models_array:
|
| 197 |
+
return None
|
| 198 |
+
|
| 199 |
+
# 寻找包含 'flash' 或 'pro' 的模型作为模板
|
| 200 |
+
for model in models_array:
|
| 201 |
+
if isinstance(model, list) and len(model) > 7:
|
| 202 |
+
model_name = model[0] if len(model) > 0 else ""
|
| 203 |
+
if 'flash' in model_name.lower() or 'pro' in model_name.lower():
|
| 204 |
+
return model
|
| 205 |
+
|
| 206 |
+
# 如果没找到,返回第一个有效模型
|
| 207 |
+
for model in models_array:
|
| 208 |
+
if isinstance(model, list) and len(model) > 7:
|
| 209 |
+
return model
|
| 210 |
+
|
| 211 |
+
return None
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
async def _add_init_scripts_to_context(context: AsyncBrowserContext):
|
| 215 |
+
"""在浏览器上下文中添加初始化脚本(备用方案)"""
|
| 216 |
+
try:
|
| 217 |
+
from config.settings import USERSCRIPT_PATH
|
| 218 |
+
|
| 219 |
+
# 检查脚本文件是否存在
|
| 220 |
+
if not os.path.exists(USERSCRIPT_PATH):
|
| 221 |
+
logger.info(f"脚本文件不存在,跳过脚本注入: {USERSCRIPT_PATH}")
|
| 222 |
+
return
|
| 223 |
+
|
| 224 |
+
# 读取脚本内容
|
| 225 |
+
with open(USERSCRIPT_PATH, 'r', encoding='utf-8') as f:
|
| 226 |
+
script_content = f.read()
|
| 227 |
+
|
| 228 |
+
# 清理UserScript头部
|
| 229 |
+
cleaned_script = _clean_userscript_headers(script_content)
|
| 230 |
+
|
| 231 |
+
# 添加到上下文的初始化脚本
|
| 232 |
+
await context.add_init_script(cleaned_script)
|
| 233 |
+
logger.info(f"✅ 已将脚本添加到浏览器上下文初始化脚本: {os.path.basename(USERSCRIPT_PATH)}")
|
| 234 |
+
|
| 235 |
+
except Exception as e:
|
| 236 |
+
logger.error(f"添加初始化脚本到上下文时发生错误: {e}")
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _clean_userscript_headers(script_content: str) -> str:
|
| 240 |
+
"""清理UserScript头部信息"""
|
| 241 |
+
lines = script_content.split('\n')
|
| 242 |
+
cleaned_lines = []
|
| 243 |
+
in_userscript_block = False
|
| 244 |
+
|
| 245 |
+
for line in lines:
|
| 246 |
+
if line.strip().startswith('// ==UserScript=='):
|
| 247 |
+
in_userscript_block = True
|
| 248 |
+
continue
|
| 249 |
+
elif line.strip().startswith('// ==/UserScript=='):
|
| 250 |
+
in_userscript_block = False
|
| 251 |
+
continue
|
| 252 |
+
elif in_userscript_block:
|
| 253 |
+
continue
|
| 254 |
+
else:
|
| 255 |
+
cleaned_lines.append(line)
|
| 256 |
+
|
| 257 |
+
return '\n'.join(cleaned_lines)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
async def _initialize_page_logic(browser: AsyncBrowser):
|
| 261 |
+
"""初始化页面逻辑,连接到现有浏览器"""
|
| 262 |
+
logger.info("--- 初始化页面逻辑 (连接到现有浏览器) ---")
|
| 263 |
+
temp_context: Optional[AsyncBrowserContext] = None
|
| 264 |
+
storage_state_path_to_use: Optional[str] = None
|
| 265 |
+
launch_mode = os.environ.get('LAUNCH_MODE', 'debug')
|
| 266 |
+
logger.info(f" 检测到启动模式: {launch_mode}")
|
| 267 |
+
loop = asyncio.get_running_loop()
|
| 268 |
+
|
| 269 |
+
if launch_mode == 'headless' or launch_mode == 'virtual_headless':
|
| 270 |
+
auth_filename = os.environ.get('ACTIVE_AUTH_JSON_PATH')
|
| 271 |
+
if auth_filename:
|
| 272 |
+
constructed_path = auth_filename
|
| 273 |
+
if os.path.exists(constructed_path):
|
| 274 |
+
storage_state_path_to_use = constructed_path
|
| 275 |
+
logger.info(f" 无头模式将使用的认证文件: {constructed_path}")
|
| 276 |
+
else:
|
| 277 |
+
logger.error(f"{launch_mode} 模式认证文��无效或不存在: '{constructed_path}'")
|
| 278 |
+
raise RuntimeError(f"{launch_mode} 模式认证文件无效: '{constructed_path}'")
|
| 279 |
+
else:
|
| 280 |
+
logger.error(f"{launch_mode} 模式需要 ACTIVE_AUTH_JSON_PATH 环境变量,但未设置或为空。")
|
| 281 |
+
raise RuntimeError(f"{launch_mode} 模式需要 ACTIVE_AUTH_JSON_PATH。")
|
| 282 |
+
elif launch_mode == 'debug':
|
| 283 |
+
logger.info(f" 调试模式: 尝试从环境变量 ACTIVE_AUTH_JSON_PATH 加载认证文件...")
|
| 284 |
+
auth_filepath_from_env = os.environ.get('ACTIVE_AUTH_JSON_PATH')
|
| 285 |
+
if auth_filepath_from_env and os.path.exists(auth_filepath_from_env):
|
| 286 |
+
storage_state_path_to_use = auth_filepath_from_env
|
| 287 |
+
logger.info(f" 调试模式将使用的认证文件 (来自环境变量): {storage_state_path_to_use}")
|
| 288 |
+
elif auth_filepath_from_env:
|
| 289 |
+
logger.warning(f" 调试模式下环境变量 ACTIVE_AUTH_JSON_PATH 指向的文件不存在: '{auth_filepath_from_env}'。不加载认证文件。")
|
| 290 |
+
else:
|
| 291 |
+
logger.info(" 调试模式下未通过环境变量提供认证文件。将使用浏览器当前状态。")
|
| 292 |
+
elif launch_mode == "direct_debug_no_browser":
|
| 293 |
+
logger.info(" direct_debug_no_browser 模式:不加载 storage_state,不进行浏览器操作。")
|
| 294 |
+
else:
|
| 295 |
+
logger.warning(f" ⚠️ 警告: 未知的启动模式 '{launch_mode}'。不加载 storage_state。")
|
| 296 |
+
|
| 297 |
+
try:
|
| 298 |
+
logger.info("创建新的浏览器上下文...")
|
| 299 |
+
context_options: Dict[str, Any] = {'viewport': {'width': 460, 'height': 800}}
|
| 300 |
+
if storage_state_path_to_use:
|
| 301 |
+
context_options['storage_state'] = storage_state_path_to_use
|
| 302 |
+
logger.info(f" (使用 storage_state='{os.path.basename(storage_state_path_to_use)}')")
|
| 303 |
+
else:
|
| 304 |
+
logger.info(" (不使用 storage_state)")
|
| 305 |
+
|
| 306 |
+
# 代理设置需要从server模块中获取
|
| 307 |
+
import server
|
| 308 |
+
if server.PLAYWRIGHT_PROXY_SETTINGS:
|
| 309 |
+
context_options['proxy'] = server.PLAYWRIGHT_PROXY_SETTINGS
|
| 310 |
+
logger.info(f" (浏览器上下文将使用代理: {server.PLAYWRIGHT_PROXY_SETTINGS['server']})")
|
| 311 |
+
else:
|
| 312 |
+
logger.info(" (浏览器上下文不使用显式代理配置)")
|
| 313 |
+
|
| 314 |
+
context_options['ignore_https_errors'] = True
|
| 315 |
+
logger.info(" (浏览器上下文将忽略 HTTPS 错误)")
|
| 316 |
+
|
| 317 |
+
temp_context = await browser.new_context(**context_options)
|
| 318 |
+
|
| 319 |
+
# 设置网络拦截和脚本注入
|
| 320 |
+
await _setup_network_interception_and_scripts(temp_context)
|
| 321 |
+
|
| 322 |
+
found_page: Optional[AsyncPage] = None
|
| 323 |
+
pages = temp_context.pages
|
| 324 |
+
target_url_base = f"https://{AI_STUDIO_URL_PATTERN}"
|
| 325 |
+
target_full_url = f"{target_url_base}prompts/new_chat"
|
| 326 |
+
login_url_pattern = 'accounts.google.com'
|
| 327 |
+
current_url = ""
|
| 328 |
+
|
| 329 |
+
# 导入_handle_model_list_response - 需要延迟导入避免循环引用
|
| 330 |
+
from .operations import _handle_model_list_response
|
| 331 |
+
|
| 332 |
+
for p_iter in pages:
|
| 333 |
+
try:
|
| 334 |
+
page_url_to_check = p_iter.url
|
| 335 |
+
if not p_iter.is_closed() and target_url_base in page_url_to_check and "/prompts/" in page_url_to_check:
|
| 336 |
+
found_page = p_iter
|
| 337 |
+
current_url = page_url_to_check
|
| 338 |
+
logger.info(f" 找到已打开的 AI Studio 页面: {current_url}")
|
| 339 |
+
if found_page:
|
| 340 |
+
logger.info(f" 为已存在的页面 {found_page.url} 添加模型列表响应监听器。")
|
| 341 |
+
found_page.on("response", _handle_model_list_response)
|
| 342 |
+
break
|
| 343 |
+
except PlaywrightAsyncError as pw_err_url:
|
| 344 |
+
logger.warning(f" 检查页面 URL 时出现 Playwright 错误: {pw_err_url}")
|
| 345 |
+
except AttributeError as attr_err_url:
|
| 346 |
+
logger.warning(f" 检查页面 URL 时出现属性错误: {attr_err_url}")
|
| 347 |
+
except Exception as e_url_check:
|
| 348 |
+
logger.warning(f" 检查页面 URL 时出现其他未预期错误: {e_url_check} (类型: {type(e_url_check).__name__})")
|
| 349 |
+
|
| 350 |
+
if not found_page:
|
| 351 |
+
logger.info(f"-> 未找到合适的现有页面,正在打开新页面并导航到 {target_full_url}...")
|
| 352 |
+
found_page = await temp_context.new_page()
|
| 353 |
+
if found_page:
|
| 354 |
+
logger.info(f" 为新创建的页面添加模型列表响应监听器 (导航前)。")
|
| 355 |
+
found_page.on("response", _handle_model_list_response)
|
| 356 |
+
try:
|
| 357 |
+
await found_page.goto(target_full_url, wait_until="domcontentloaded", timeout=90000)
|
| 358 |
+
current_url = found_page.url
|
| 359 |
+
logger.info(f"-> 新页面导航尝试完成。当前 URL: {current_url}")
|
| 360 |
+
except Exception as new_page_nav_err:
|
| 361 |
+
# 导入save_error_snapshot函数
|
| 362 |
+
from .operations import save_error_snapshot
|
| 363 |
+
await save_error_snapshot("init_new_page_nav_fail")
|
| 364 |
+
error_str = str(new_page_nav_err)
|
| 365 |
+
if "NS_ERROR_NET_INTERRUPT" in error_str:
|
| 366 |
+
logger.error("\n" + "="*30 + " 网络导航错误提示 " + "="*30)
|
| 367 |
+
logger.error(f"❌ 导航到 '{target_full_url}' 失败,出现网络中断错误 (NS_ERROR_NET_INTERRUPT)。")
|
| 368 |
+
logger.error(" 这通常表示浏览器在尝试加载页面时连接被意外断开。")
|
| 369 |
+
logger.error(" 可能的原因及排查建议:")
|
| 370 |
+
logger.error(" 1. 网络连接: 请检查你的本地网络连接是否稳定,并尝试在普通浏览器中访问目标网址。")
|
| 371 |
+
logger.error(" 2. AI Studio 服务: 确认 aistudio.google.com 服务本身是否可用。")
|
| 372 |
+
logger.error(" 3. 防火墙/代理/VPN: 检查本地防火墙、杀毒软件、代理或 VPN 设置。")
|
| 373 |
+
logger.error(" 4. Camoufox 服务: 确认 launch_camoufox.py 脚本是否正常运行。")
|
| 374 |
+
logger.error(" 5. 系统资源问题: 确保系统有足够的内存和 CPU 资源。")
|
| 375 |
+
logger.error("="*74 + "\n")
|
| 376 |
+
raise RuntimeError(f"导航新页面失败: {new_page_nav_err}") from new_page_nav_err
|
| 377 |
+
|
| 378 |
+
if login_url_pattern in current_url:
|
| 379 |
+
if launch_mode == 'headless':
|
| 380 |
+
logger.error("无头模式下检测到重定向至登录页面,认证可能已失效。请更新认证文件。")
|
| 381 |
+
raise RuntimeError("无头模式认证失败,需要更新认证文件。")
|
| 382 |
+
else:
|
| 383 |
+
print(f"\n{'='*20} 需要操作 {'='*20}", flush=True)
|
| 384 |
+
login_prompt = " 检测到可能需要登录。如果浏览器显示登录页面,请在浏览器窗口中完成 Google 登录,然后在此处按 Enter 键继续..."
|
| 385 |
+
# NEW: If SUPPRESS_LOGIN_WAIT is set, skip waiting for user input.
|
| 386 |
+
if os.environ.get("SUPPRESS_LOGIN_WAIT", "").lower() in ("1", "true", "yes"):
|
| 387 |
+
logger.info("检测到 SUPPRESS_LOGIN_WAIT 标志,跳过等待用户输入。")
|
| 388 |
+
else:
|
| 389 |
+
print(USER_INPUT_START_MARKER_SERVER, flush=True)
|
| 390 |
+
await loop.run_in_executor(None, input, login_prompt)
|
| 391 |
+
print(USER_INPUT_END_MARKER_SERVER, flush=True)
|
| 392 |
+
logger.info(" 正在检查登录状态...")
|
| 393 |
+
try:
|
| 394 |
+
await found_page.wait_for_url(f"**/{AI_STUDIO_URL_PATTERN}**", timeout=180000)
|
| 395 |
+
current_url = found_page.url
|
| 396 |
+
if login_url_pattern in current_url:
|
| 397 |
+
logger.error("手动登录尝试后,页面似乎仍停留在登录页面。")
|
| 398 |
+
raise RuntimeError("手动登录尝试后仍在登录页面。")
|
| 399 |
+
logger.info(" ✅ 登录成功!请不要操作浏览器窗口,等待后续提示。")
|
| 400 |
+
|
| 401 |
+
# 登录成功后,调用认证保存逻辑
|
| 402 |
+
if os.environ.get('AUTO_SAVE_AUTH', 'false').lower() == 'true':
|
| 403 |
+
await _wait_for_model_list_and_handle_auth_save(temp_context, launch_mode, loop)
|
| 404 |
+
|
| 405 |
+
except Exception as wait_login_err:
|
| 406 |
+
from .operations import save_error_snapshot
|
| 407 |
+
await save_error_snapshot("init_login_wait_fail")
|
| 408 |
+
logger.error(f"登录提示后未能检测到 AI Studio URL 或保存状态时出错: {wait_login_err}", exc_info=True)
|
| 409 |
+
raise RuntimeError(f"登录提示后未能检测到 AI Studio URL: {wait_login_err}") from wait_login_err
|
| 410 |
+
|
| 411 |
+
elif target_url_base not in current_url or "/prompts/" not in current_url:
|
| 412 |
+
from .operations import save_error_snapshot
|
| 413 |
+
await save_error_snapshot("init_unexpected_page")
|
| 414 |
+
logger.error(f"初始导航后页面 URL 意外: {current_url}。期望包含 '{target_url_base}' 和 '/prompts/'。")
|
| 415 |
+
raise RuntimeError(f"初始导航后出现意外页面: {current_url}。")
|
| 416 |
+
|
| 417 |
+
logger.info(f"-> 确认当前位于 AI Studio 对话页面: {current_url}")
|
| 418 |
+
await found_page.bring_to_front()
|
| 419 |
+
|
| 420 |
+
try:
|
| 421 |
+
input_wrapper_locator = found_page.locator('ms-prompt-input-wrapper')
|
| 422 |
+
await expect_async(input_wrapper_locator).to_be_visible(timeout=35000)
|
| 423 |
+
await expect_async(found_page.locator(INPUT_SELECTOR)).to_be_visible(timeout=10000)
|
| 424 |
+
logger.info("-> ✅ 核心输入区域可见。")
|
| 425 |
+
|
| 426 |
+
model_name_locator = found_page.locator('[data-test-id="model-name"]')
|
| 427 |
+
try:
|
| 428 |
+
model_name_on_page = await model_name_locator.first.inner_text(timeout=5000)
|
| 429 |
+
logger.info(f"-> 🤖 页面检测到的当前模型: {model_name_on_page}")
|
| 430 |
+
except PlaywrightAsyncError as e:
|
| 431 |
+
logger.error(f"获取模型名称时出错 (model_name_locator): {e}")
|
| 432 |
+
raise
|
| 433 |
+
|
| 434 |
+
result_page_instance = found_page
|
| 435 |
+
result_page_ready = True
|
| 436 |
+
|
| 437 |
+
# 脚本注入已在上下文创建时完成,无需在此处重复注入
|
| 438 |
+
|
| 439 |
+
logger.info(f"✅ 页面逻辑初始化成功。")
|
| 440 |
+
return result_page_instance, result_page_ready
|
| 441 |
+
except Exception as input_visible_err:
|
| 442 |
+
from .operations import save_error_snapshot
|
| 443 |
+
await save_error_snapshot("init_fail_input_timeout")
|
| 444 |
+
logger.error(f"页面初始化失败:核心输入区域未在预期时间内变为可见。最后的 URL 是 {found_page.url}", exc_info=True)
|
| 445 |
+
raise RuntimeError(f"页面初始化失败:核心输入区域未在预期时间内变为可见。最后的 URL 是 {found_page.url}") from input_visible_err
|
| 446 |
+
except Exception as e_init_page:
|
| 447 |
+
logger.critical(f"❌ 页面逻辑初始化期间发生严重意外错误: {e_init_page}", exc_info=True)
|
| 448 |
+
if temp_context:
|
| 449 |
+
try:
|
| 450 |
+
logger.info(f" 尝试关闭临时的浏览器上下文 due to initialization error.")
|
| 451 |
+
await temp_context.close()
|
| 452 |
+
logger.info(" ✅ 临时浏览器上下文已关闭。")
|
| 453 |
+
except Exception as close_err:
|
| 454 |
+
logger.warning(f" ⚠️ 关闭临时浏览器上下文时出错: {close_err}")
|
| 455 |
+
from .operations import save_error_snapshot
|
| 456 |
+
await save_error_snapshot("init_unexpected_error")
|
| 457 |
+
raise RuntimeError(f"页面初始化意外错误: {e_init_page}") from e_init_page
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
async def _close_page_logic():
|
| 461 |
+
"""关闭页面逻辑"""
|
| 462 |
+
# 需要访问全局变量
|
| 463 |
+
import server
|
| 464 |
+
logger.info("--- 运行页面逻辑关闭 --- ")
|
| 465 |
+
if server.page_instance and not server.page_instance.is_closed():
|
| 466 |
+
try:
|
| 467 |
+
await server.page_instance.close()
|
| 468 |
+
logger.info(" ✅ 页面已关闭")
|
| 469 |
+
except PlaywrightAsyncError as pw_err:
|
| 470 |
+
logger.warning(f" ⚠️ 关闭页面时出现Playwright错误: {pw_err}")
|
| 471 |
+
except asyncio.TimeoutError as timeout_err:
|
| 472 |
+
logger.warning(f" ⚠️ 关闭页面时超时: {timeout_err}")
|
| 473 |
+
except Exception as other_err:
|
| 474 |
+
logger.error(f" ⚠️ 关闭页面时出现意外错误: {other_err} (类型: {type(other_err).__name__})", exc_info=True)
|
| 475 |
+
server.page_instance = None
|
| 476 |
+
server.is_page_ready = False
|
| 477 |
+
logger.info("页面逻辑状态已重置。")
|
| 478 |
+
return None, False
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
async def signal_camoufox_shutdown():
|
| 482 |
+
"""发送关闭信号到Camoufox服务器"""
|
| 483 |
+
logger.info(" 尝试发送关闭信号到 Camoufox 服务器 (此功能可能已由父进程处理)...")
|
| 484 |
+
ws_endpoint = os.environ.get('CAMOUFOX_WS_ENDPOINT')
|
| 485 |
+
if not ws_endpoint:
|
| 486 |
+
logger.warning(" ⚠️ 无法发送关闭信号:未找到 CAMOUFOX_WS_ENDPOINT 环境变量。")
|
| 487 |
+
return
|
| 488 |
+
|
| 489 |
+
# 需要访问全局浏览器实例
|
| 490 |
+
import server
|
| 491 |
+
if not server.browser_instance or not server.browser_instance.is_connected():
|
| 492 |
+
logger.warning(" ⚠️ 浏览器实例已断开或未初始化,跳过关闭信号发送。")
|
| 493 |
+
return
|
| 494 |
+
try:
|
| 495 |
+
await asyncio.sleep(0.2)
|
| 496 |
+
logger.info(" ✅ (模拟) 关闭信号已处理。")
|
| 497 |
+
except Exception as e:
|
| 498 |
+
logger.error(f" ⚠️ 发送关闭信号过程中捕获异常: {e}", exc_info=True)
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
async def _wait_for_model_list_and_handle_auth_save(temp_context, launch_mode, loop):
|
| 502 |
+
"""等待模型列表响应并处理认证保存"""
|
| 503 |
+
import server
|
| 504 |
+
|
| 505 |
+
# 等待模型列表响应,确认登录成功
|
| 506 |
+
logger.info(" 等待模型列表响应以确认登录成功...")
|
| 507 |
+
try:
|
| 508 |
+
# 等待模型列表事件,最多等待30秒
|
| 509 |
+
await asyncio.wait_for(server.model_list_fetch_event.wait(), timeout=30.0)
|
| 510 |
+
logger.info(" ✅ 检测到模型列表响应,登录确认成功!")
|
| 511 |
+
except asyncio.TimeoutError:
|
| 512 |
+
logger.warning(" ⚠️ 等待模型列表响应超时,但继续处理认证保存...")
|
| 513 |
+
|
| 514 |
+
# 检查是否有预设的文件名用于保存
|
| 515 |
+
save_auth_filename = os.environ.get('SAVE_AUTH_FILENAME', '').strip()
|
| 516 |
+
if save_auth_filename:
|
| 517 |
+
logger.info(f" 检测到 SAVE_AUTH_FILENAME 环境变量: '{save_auth_filename}'。将自动保存认证文件。")
|
| 518 |
+
await _handle_auth_file_save_with_filename(temp_context, save_auth_filename)
|
| 519 |
+
return
|
| 520 |
+
|
| 521 |
+
# If not auto-saving, proceed with interactive prompts
|
| 522 |
+
await _interactive_auth_save(temp_context, launch_mode, loop)
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
async def _interactive_auth_save(temp_context, launch_mode, loop):
|
| 526 |
+
"""处理认证文件保存的交互式提示"""
|
| 527 |
+
# 检查是否启用自动确认
|
| 528 |
+
if AUTO_CONFIRM_LOGIN:
|
| 529 |
+
print("\n" + "="*50, flush=True)
|
| 530 |
+
print(" �� 登录成功!检测到模型列表响应。", flush=True)
|
| 531 |
+
print(" 🤖 自动确认模式已启用,将自动保存认证状态...", flush=True)
|
| 532 |
+
|
| 533 |
+
# 自动保存认证状态
|
| 534 |
+
await _handle_auth_file_save_auto(temp_context)
|
| 535 |
+
print("="*50 + "\n", flush=True)
|
| 536 |
+
return
|
| 537 |
+
|
| 538 |
+
# 手动确认模式
|
| 539 |
+
print("\n" + "="*50, flush=True)
|
| 540 |
+
print(" 【用户交互】需要您的输入!", flush=True)
|
| 541 |
+
print(" ✅ 登录成功!检测到模型列表响应。", flush=True)
|
| 542 |
+
|
| 543 |
+
should_save_auth_choice = ''
|
| 544 |
+
if AUTO_SAVE_AUTH and launch_mode == 'debug':
|
| 545 |
+
logger.info(" 自动保存认证模式已启用,将自动保存认证状态...")
|
| 546 |
+
should_save_auth_choice = 'y'
|
| 547 |
+
else:
|
| 548 |
+
save_auth_prompt = " 是否要将当前的浏览器认证状态保存到文件? (y/N): "
|
| 549 |
+
print(USER_INPUT_START_MARKER_SERVER, flush=True)
|
| 550 |
+
try:
|
| 551 |
+
auth_save_input_future = loop.run_in_executor(None, input, save_auth_prompt)
|
| 552 |
+
should_save_auth_choice = await asyncio.wait_for(auth_save_input_future, timeout=AUTH_SAVE_TIMEOUT)
|
| 553 |
+
except asyncio.TimeoutError:
|
| 554 |
+
print(f" 输入等待超时({AUTH_SAVE_TIMEOUT}秒)。默认不保存认证状态。", flush=True)
|
| 555 |
+
should_save_auth_choice = 'n'
|
| 556 |
+
finally:
|
| 557 |
+
print(USER_INPUT_END_MARKER_SERVER, flush=True)
|
| 558 |
+
|
| 559 |
+
if should_save_auth_choice.strip().lower() == 'y':
|
| 560 |
+
await _handle_auth_file_save(temp_context, loop)
|
| 561 |
+
else:
|
| 562 |
+
print(" 好的,不保存认证状态。", flush=True)
|
| 563 |
+
|
| 564 |
+
print("="*50 + "\n", flush=True)
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
async def _handle_auth_file_save(temp_context, loop):
|
| 568 |
+
"""处理认证文件保存(手动模式)"""
|
| 569 |
+
os.makedirs(SAVED_AUTH_DIR, exist_ok=True)
|
| 570 |
+
default_auth_filename = f"auth_state_{int(time.time())}.json"
|
| 571 |
+
|
| 572 |
+
print(USER_INPUT_START_MARKER_SERVER, flush=True)
|
| 573 |
+
filename_prompt_str = f" 请输入保存的文件名 (默认为: {default_auth_filename},输入 'cancel' 取消保存): "
|
| 574 |
+
chosen_auth_filename = ''
|
| 575 |
+
|
| 576 |
+
try:
|
| 577 |
+
filename_input_future = loop.run_in_executor(None, input, filename_prompt_str)
|
| 578 |
+
chosen_auth_filename = await asyncio.wait_for(filename_input_future, timeout=AUTH_SAVE_TIMEOUT)
|
| 579 |
+
except asyncio.TimeoutError:
|
| 580 |
+
print(f" 输入文件名等待超时({AUTH_SAVE_TIMEOUT}秒)。将使用默认文件名: {default_auth_filename}", flush=True)
|
| 581 |
+
chosen_auth_filename = default_auth_filename
|
| 582 |
+
finally:
|
| 583 |
+
print(USER_INPUT_END_MARKER_SERVER, flush=True)
|
| 584 |
+
|
| 585 |
+
if chosen_auth_filename.strip().lower() == 'cancel':
|
| 586 |
+
print(" 用户选择取消保存认证状态。", flush=True)
|
| 587 |
+
return
|
| 588 |
+
|
| 589 |
+
final_auth_filename = chosen_auth_filename.strip() or default_auth_filename
|
| 590 |
+
if not final_auth_filename.endswith(".json"):
|
| 591 |
+
final_auth_filename += ".json"
|
| 592 |
+
|
| 593 |
+
auth_save_path = os.path.join(SAVED_AUTH_DIR, final_auth_filename)
|
| 594 |
+
|
| 595 |
+
try:
|
| 596 |
+
await temp_context.storage_state(path=auth_save_path)
|
| 597 |
+
logger.info(f" 认证状态已成功保存到: {auth_save_path}")
|
| 598 |
+
print(f" ✅ 认证状态已成功保存到: {auth_save_path}", flush=True)
|
| 599 |
+
except Exception as save_state_err:
|
| 600 |
+
logger.error(f" ❌ 保存认证状态失败: {save_state_err}", exc_info=True)
|
| 601 |
+
print(f" ❌ 保存认证状态失败: {save_state_err}", flush=True)
|
| 602 |
+
|
| 603 |
+
|
| 604 |
+
async def _handle_auth_file_save_with_filename(temp_context, filename: str):
|
| 605 |
+
"""处理认证文件保存(使用提供的文件名)"""
|
| 606 |
+
os.makedirs(SAVED_AUTH_DIR, exist_ok=True)
|
| 607 |
+
|
| 608 |
+
# Clean the filename and add .json if needed
|
| 609 |
+
final_auth_filename = filename.strip()
|
| 610 |
+
if not final_auth_filename.endswith(".json"):
|
| 611 |
+
final_auth_filename += ".json"
|
| 612 |
+
|
| 613 |
+
auth_save_path = os.path.join(SAVED_AUTH_DIR, final_auth_filename)
|
| 614 |
+
|
| 615 |
+
try:
|
| 616 |
+
await temp_context.storage_state(path=auth_save_path)
|
| 617 |
+
print(f" ✅ 认证状态已自动保存到: {auth_save_path}", flush=True)
|
| 618 |
+
logger.info(f" 自动保存认证状态成功: {auth_save_path}")
|
| 619 |
+
except Exception as save_state_err:
|
| 620 |
+
logger.error(f" ❌ 自动保存认证状态失败: {save_state_err}", exc_info=True)
|
| 621 |
+
print(f" ❌ 自动保存认证状态失败: {save_state_err}", flush=True)
|
| 622 |
+
|
| 623 |
+
|
| 624 |
+
async def _handle_auth_file_save_auto(temp_context):
|
| 625 |
+
"""处理认证文件保存(自动模式)"""
|
| 626 |
+
os.makedirs(SAVED_AUTH_DIR, exist_ok=True)
|
| 627 |
+
|
| 628 |
+
# 生成基于时间戳的文件名
|
| 629 |
+
timestamp = int(time.time())
|
| 630 |
+
auto_auth_filename = f"auth_auto_{timestamp}.json"
|
| 631 |
+
auth_save_path = os.path.join(SAVED_AUTH_DIR, auto_auth_filename)
|
| 632 |
+
|
| 633 |
+
try:
|
| 634 |
+
await temp_context.storage_state(path=auth_save_path)
|
| 635 |
+
logger.info(f" 认证状态已成功保存到: {auth_save_path}")
|
| 636 |
+
print(f" ✅ 认证状态已成功保存到: {auth_save_path}", flush=True)
|
| 637 |
+
except Exception as save_state_err:
|
| 638 |
+
logger.error(f" ❌ 自动保存认证状态失败: {save_state_err}", exc_info=True)
|
| 639 |
+
print(f" ❌ 自动保存认证状态失败: {save_state_err}", flush=True)
|
| 640 |
+
|
| 641 |
+
async def enable_temporary_chat_mode(page: AsyncPage):
|
| 642 |
+
"""
|
| 643 |
+
检查并启用 AI Studio 界面的“临时聊天”模式。
|
| 644 |
+
这是一个独立的UI操作,应该在页面完全稳定后调用。
|
| 645 |
+
"""
|
| 646 |
+
try:
|
| 647 |
+
logger.info("-> (UI Op) 正在检查并启用 '临时聊天' 模式...")
|
| 648 |
+
|
| 649 |
+
incognito_button_locator = page.locator('button[aria-label="Temporary chat toggle"]')
|
| 650 |
+
|
| 651 |
+
await incognito_button_locator.wait_for(state="visible", timeout=10000)
|
| 652 |
+
|
| 653 |
+
button_classes = await incognito_button_locator.get_attribute("class")
|
| 654 |
+
|
| 655 |
+
if button_classes and 'ms-button-active' in button_classes:
|
| 656 |
+
logger.info("-> (UI Op) '临时聊天' 模式已激活。")
|
| 657 |
+
else:
|
| 658 |
+
logger.info("-> (UI Op) '临时聊天' 模式未激活,正在点击...")
|
| 659 |
+
await incognito_button_locator.click(timeout=5000, force=True)
|
| 660 |
+
await asyncio.sleep(1)
|
| 661 |
+
|
| 662 |
+
updated_classes = await incognito_button_locator.get_attribute("class")
|
| 663 |
+
if updated_classes and 'ms-button-active' in updated_classes:
|
| 664 |
+
logger.info("✅ (UI Op) '临时聊天' 模式已成功启用。")
|
| 665 |
+
else:
|
| 666 |
+
logger.warning("⚠️ (UI Op) 点击后 '临时聊天' 模式状态验证失败。")
|
| 667 |
+
|
| 668 |
+
except Exception as e:
|
| 669 |
+
logger.warning(f"⚠️ (UI Op) 启用 '临时聊天' 模式时出错: {e}")
|
AIStudioProxyAPI/browser_utils/model_management.py
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- browser_utils/model_management.py ---
|
| 2 |
+
# 浏览器模型管理相关功能模块
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import logging
|
| 8 |
+
import time
|
| 9 |
+
from typing import Optional, Set
|
| 10 |
+
|
| 11 |
+
from playwright.async_api import Page as AsyncPage, expect as expect_async, Error as PlaywrightAsyncError
|
| 12 |
+
|
| 13 |
+
# 导入配置和模型
|
| 14 |
+
from config import *
|
| 15 |
+
from models import ClientDisconnectedError
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger("AIStudioProxyServer")
|
| 18 |
+
|
| 19 |
+
# ==================== 强制UI状态设置功能 ====================
|
| 20 |
+
|
| 21 |
+
async def _verify_ui_state_settings(page: AsyncPage, req_id: str = "unknown") -> dict:
|
| 22 |
+
"""
|
| 23 |
+
验证UI状态设置是否正确
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
page: Playwright页面对象
|
| 27 |
+
req_id: 请求ID用于日志
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
dict: 包含验证结果的字典
|
| 31 |
+
"""
|
| 32 |
+
try:
|
| 33 |
+
logger.info(f"[{req_id}] 验证UI状态设置...")
|
| 34 |
+
|
| 35 |
+
# 获取当前localStorage设置
|
| 36 |
+
prefs_str = await page.evaluate("() => localStorage.getItem('aiStudioUserPreference')")
|
| 37 |
+
|
| 38 |
+
if not prefs_str:
|
| 39 |
+
logger.warning(f"[{req_id}] localStorage.aiStudioUserPreference 不存在")
|
| 40 |
+
return {
|
| 41 |
+
'exists': False,
|
| 42 |
+
'isAdvancedOpen': None,
|
| 43 |
+
'areToolsOpen': None,
|
| 44 |
+
'needsUpdate': True,
|
| 45 |
+
'error': 'localStorage不存在'
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
prefs = json.loads(prefs_str)
|
| 50 |
+
is_advanced_open = prefs.get('isAdvancedOpen')
|
| 51 |
+
are_tools_open = prefs.get('areToolsOpen')
|
| 52 |
+
|
| 53 |
+
# 检查是否需要更新
|
| 54 |
+
needs_update = (is_advanced_open is not True) or (are_tools_open is not True)
|
| 55 |
+
|
| 56 |
+
result = {
|
| 57 |
+
'exists': True,
|
| 58 |
+
'isAdvancedOpen': is_advanced_open,
|
| 59 |
+
'areToolsOpen': are_tools_open,
|
| 60 |
+
'needsUpdate': needs_update,
|
| 61 |
+
'prefs': prefs
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
logger.info(f"[{req_id}] UI状态验证结果: isAdvancedOpen={is_advanced_open}, areToolsOpen={are_tools_open} (期望: True), needsUpdate={needs_update}")
|
| 65 |
+
return result
|
| 66 |
+
|
| 67 |
+
except json.JSONDecodeError as e:
|
| 68 |
+
logger.error(f"[{req_id}] 解析localStorage JSON失败: {e}")
|
| 69 |
+
return {
|
| 70 |
+
'exists': False,
|
| 71 |
+
'isAdvancedOpen': None,
|
| 72 |
+
'areToolsOpen': None,
|
| 73 |
+
'needsUpdate': True,
|
| 74 |
+
'error': f'JSON解析失败: {e}'
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
except Exception as e:
|
| 78 |
+
logger.error(f"[{req_id}] 验证UI状态设置时发生错误: {e}")
|
| 79 |
+
return {
|
| 80 |
+
'exists': False,
|
| 81 |
+
'isAdvancedOpen': None,
|
| 82 |
+
'areToolsOpen': None,
|
| 83 |
+
'needsUpdate': True,
|
| 84 |
+
'error': f'验证失败: {e}'
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
async def _force_ui_state_settings(page: AsyncPage, req_id: str = "unknown") -> bool:
|
| 88 |
+
"""
|
| 89 |
+
强制设置UI状态
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
page: Playwright页面对象
|
| 93 |
+
req_id: 请求ID用于日志
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
bool: 设置是否成功
|
| 97 |
+
"""
|
| 98 |
+
try:
|
| 99 |
+
logger.info(f"[{req_id}] 开始强制设置UI状态...")
|
| 100 |
+
|
| 101 |
+
# 首先验证当前状态
|
| 102 |
+
current_state = await _verify_ui_state_settings(page, req_id)
|
| 103 |
+
|
| 104 |
+
if not current_state['needsUpdate']:
|
| 105 |
+
logger.info(f"[{req_id}] UI状态已正确设置,无需更新")
|
| 106 |
+
return True
|
| 107 |
+
|
| 108 |
+
# 获取现有preferences或创建新的
|
| 109 |
+
prefs = current_state.get('prefs', {})
|
| 110 |
+
|
| 111 |
+
# 强制设置关键配置
|
| 112 |
+
prefs['isAdvancedOpen'] = True
|
| 113 |
+
prefs['areToolsOpen'] = True
|
| 114 |
+
|
| 115 |
+
# 保存到localStorage
|
| 116 |
+
prefs_str = json.dumps(prefs)
|
| 117 |
+
await page.evaluate("(prefsStr) => localStorage.setItem('aiStudioUserPreference', prefsStr)", prefs_str)
|
| 118 |
+
|
| 119 |
+
logger.info(f"[{req_id}] 已强制设置: isAdvancedOpen=true, areToolsOpen=true")
|
| 120 |
+
|
| 121 |
+
# 验证设置是否成功
|
| 122 |
+
verify_state = await _verify_ui_state_settings(page, req_id)
|
| 123 |
+
if not verify_state['needsUpdate']:
|
| 124 |
+
logger.info(f"[{req_id}] ✅ UI状态设置验证成功")
|
| 125 |
+
return True
|
| 126 |
+
else:
|
| 127 |
+
logger.warning(f"[{req_id}] ⚠️ UI状态设置验证失败,可能需要重试")
|
| 128 |
+
return False
|
| 129 |
+
|
| 130 |
+
except Exception as e:
|
| 131 |
+
logger.error(f"[{req_id}] 强制设置UI状态时发生错误: {e}")
|
| 132 |
+
return False
|
| 133 |
+
|
| 134 |
+
async def _force_ui_state_with_retry(page: AsyncPage, req_id: str = "unknown", max_retries: int = 3, retry_delay: float = 1.0) -> bool:
|
| 135 |
+
"""
|
| 136 |
+
带重试机制的UI状态强制设置
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
page: Playwright页面对象
|
| 140 |
+
req_id: 请求ID用于日志
|
| 141 |
+
max_retries: 最大重试次数
|
| 142 |
+
retry_delay: 重试延迟(秒)
|
| 143 |
+
|
| 144 |
+
Returns:
|
| 145 |
+
bool: 设置是否最终成功
|
| 146 |
+
"""
|
| 147 |
+
for attempt in range(1, max_retries + 1):
|
| 148 |
+
logger.info(f"[{req_id}] 尝试强制设置UI状态 (第 {attempt}/{max_retries} 次)")
|
| 149 |
+
|
| 150 |
+
success = await _force_ui_state_settings(page, req_id)
|
| 151 |
+
if success:
|
| 152 |
+
logger.info(f"[{req_id}] ✅ UI状态设置在第 {attempt} 次尝试中成功")
|
| 153 |
+
return True
|
| 154 |
+
|
| 155 |
+
if attempt < max_retries:
|
| 156 |
+
logger.warning(f"[{req_id}] ⚠️ 第 {attempt} 次尝试失败,{retry_delay}秒后重试...")
|
| 157 |
+
await asyncio.sleep(retry_delay)
|
| 158 |
+
else:
|
| 159 |
+
logger.error(f"[{req_id}] ❌ UI状态设置在 {max_retries} 次尝试后仍然失败")
|
| 160 |
+
|
| 161 |
+
return False
|
| 162 |
+
|
| 163 |
+
async def _verify_and_apply_ui_state(page: AsyncPage, req_id: str = "unknown") -> bool:
|
| 164 |
+
"""
|
| 165 |
+
验证并应用UI状态设置的完整流程
|
| 166 |
+
|
| 167 |
+
Args:
|
| 168 |
+
page: Playwright页面对象
|
| 169 |
+
req_id: 请求ID用于日志
|
| 170 |
+
|
| 171 |
+
Returns:
|
| 172 |
+
bool: 操作是否成功
|
| 173 |
+
"""
|
| 174 |
+
try:
|
| 175 |
+
logger.info(f"[{req_id}] 开始验证并应用UI状态设置...")
|
| 176 |
+
|
| 177 |
+
# 首先验证当前状态
|
| 178 |
+
state = await _verify_ui_state_settings(page, req_id)
|
| 179 |
+
|
| 180 |
+
logger.info(f"[{req_id}] 当前UI状态: exists={state['exists']}, isAdvancedOpen={state['isAdvancedOpen']}, areToolsOpen={state['areToolsOpen']}, needsUpdate={state['needsUpdate']}")
|
| 181 |
+
|
| 182 |
+
if state['needsUpdate']:
|
| 183 |
+
logger.info(f"[{req_id}] 检测到UI状态需要更新,正在应用强制设置...")
|
| 184 |
+
return await _force_ui_state_with_retry(page, req_id)
|
| 185 |
+
else:
|
| 186 |
+
logger.info(f"[{req_id}] UI状态已正确设置,无需更新")
|
| 187 |
+
return True
|
| 188 |
+
|
| 189 |
+
except Exception as e:
|
| 190 |
+
logger.error(f"[{req_id}] 验证并应用UI状态设置时发生错误: {e}")
|
| 191 |
+
return False
|
| 192 |
+
|
| 193 |
+
async def switch_ai_studio_model(page: AsyncPage, model_id: str, req_id: str) -> bool:
|
| 194 |
+
"""切换AI Studio模型"""
|
| 195 |
+
logger.info(f"[{req_id}] 开始切换模型到: {model_id}")
|
| 196 |
+
original_prefs_str: Optional[str] = None
|
| 197 |
+
original_prompt_model: Optional[str] = None
|
| 198 |
+
new_chat_url = f"https://{AI_STUDIO_URL_PATTERN}prompts/new_chat"
|
| 199 |
+
|
| 200 |
+
try:
|
| 201 |
+
original_prefs_str = await page.evaluate("() => localStorage.getItem('aiStudioUserPreference')")
|
| 202 |
+
if original_prefs_str:
|
| 203 |
+
try:
|
| 204 |
+
original_prefs_obj = json.loads(original_prefs_str)
|
| 205 |
+
original_prompt_model = original_prefs_obj.get("promptModel")
|
| 206 |
+
logger.info(f"[{req_id}] 切换前 localStorage.promptModel 为: {original_prompt_model or '未设置'}")
|
| 207 |
+
except json.JSONDecodeError:
|
| 208 |
+
logger.warning(f"[{req_id}] 无法解析原始的 aiStudioUserPreference JSON 字符串。")
|
| 209 |
+
original_prefs_str = None
|
| 210 |
+
|
| 211 |
+
current_prefs_for_modification = json.loads(original_prefs_str) if original_prefs_str else {}
|
| 212 |
+
full_model_path = f"models/{model_id}"
|
| 213 |
+
|
| 214 |
+
if current_prefs_for_modification.get("promptModel") == full_model_path:
|
| 215 |
+
logger.info(f"[{req_id}] 模型已经设置为 {model_id} (localStorage 中已是目标值),无需切换")
|
| 216 |
+
if page.url != new_chat_url:
|
| 217 |
+
logger.info(f"[{req_id}] 当前 URL 不是 new_chat ({page.url}),导航到 {new_chat_url}")
|
| 218 |
+
await page.goto(new_chat_url, wait_until="domcontentloaded", timeout=30000)
|
| 219 |
+
await expect_async(page.locator(INPUT_SELECTOR)).to_be_visible(timeout=30000)
|
| 220 |
+
return True
|
| 221 |
+
|
| 222 |
+
logger.info(f"[{req_id}] 从 {current_prefs_for_modification.get('promptModel', '未知')} 更新 localStorage.promptModel 为 {full_model_path}")
|
| 223 |
+
current_prefs_for_modification["promptModel"] = full_model_path
|
| 224 |
+
await page.evaluate("(prefsStr) => localStorage.setItem('aiStudioUserPreference', prefsStr)", json.dumps(current_prefs_for_modification))
|
| 225 |
+
|
| 226 |
+
# 使用新的强制设置功能
|
| 227 |
+
logger.info(f"[{req_id}] 应用强制UI状态设置...")
|
| 228 |
+
ui_state_success = await _verify_and_apply_ui_state(page, req_id)
|
| 229 |
+
if not ui_state_success:
|
| 230 |
+
logger.warning(f"[{req_id}] UI状态设置失败,但继续执行模型切换流程")
|
| 231 |
+
|
| 232 |
+
# 为了保持兼容性,也更新当前的prefs对象
|
| 233 |
+
current_prefs_for_modification["isAdvancedOpen"] = True
|
| 234 |
+
current_prefs_for_modification["areToolsOpen"] = True
|
| 235 |
+
await page.evaluate("(prefsStr) => localStorage.setItem('aiStudioUserPreference', prefsStr)", json.dumps(current_prefs_for_modification))
|
| 236 |
+
|
| 237 |
+
logger.info(f"[{req_id}] localStorage 已更新,导航到 '{new_chat_url}' 应用新模型...")
|
| 238 |
+
await page.goto(new_chat_url, wait_until="domcontentloaded", timeout=30000)
|
| 239 |
+
|
| 240 |
+
input_field = page.locator(INPUT_SELECTOR)
|
| 241 |
+
await expect_async(input_field).to_be_visible(timeout=30000)
|
| 242 |
+
logger.info(f"[{req_id}] 页面已导航到新聊天并加载完成,输入框可见")
|
| 243 |
+
|
| 244 |
+
# 页面加载后再次验证UI状态设置
|
| 245 |
+
logger.info(f"[{req_id}] 页面加载完成,验证UI状态设置...")
|
| 246 |
+
final_ui_state_success = await _verify_and_apply_ui_state(page, req_id)
|
| 247 |
+
if final_ui_state_success:
|
| 248 |
+
logger.info(f"[{req_id}] ✅ UI状态最终验证成功")
|
| 249 |
+
else:
|
| 250 |
+
logger.warning(f"[{req_id}] ⚠️ UI状态最终验证失败,但继续执行模型切换流程")
|
| 251 |
+
|
| 252 |
+
final_prefs_str = await page.evaluate("() => localStorage.getItem('aiStudioUserPreference')")
|
| 253 |
+
final_prompt_model_in_storage: Optional[str] = None
|
| 254 |
+
if final_prefs_str:
|
| 255 |
+
try:
|
| 256 |
+
final_prefs_obj = json.loads(final_prefs_str)
|
| 257 |
+
final_prompt_model_in_storage = final_prefs_obj.get("promptModel")
|
| 258 |
+
except json.JSONDecodeError:
|
| 259 |
+
logger.warning(f"[{req_id}] 无法解析刷新后的 aiStudioUserPreference JSON 字符串。")
|
| 260 |
+
|
| 261 |
+
if final_prompt_model_in_storage == full_model_path:
|
| 262 |
+
logger.info(f"[{req_id}] ✅ AI Studio localStorage 中模型已成功设置为: {full_model_path}")
|
| 263 |
+
|
| 264 |
+
page_display_match = False
|
| 265 |
+
expected_display_name_for_target_id = None
|
| 266 |
+
actual_displayed_model_name_on_page = "无法读取"
|
| 267 |
+
|
| 268 |
+
# 获取parsed_model_list
|
| 269 |
+
import server
|
| 270 |
+
parsed_model_list = getattr(server, 'parsed_model_list', [])
|
| 271 |
+
|
| 272 |
+
if parsed_model_list:
|
| 273 |
+
for m_obj in parsed_model_list:
|
| 274 |
+
if m_obj.get("id") == model_id:
|
| 275 |
+
expected_display_name_for_target_id = m_obj.get("display_name")
|
| 276 |
+
break
|
| 277 |
+
|
| 278 |
+
try:
|
| 279 |
+
model_name_locator = page.locator('[data-test-id="model-name"]')
|
| 280 |
+
actual_displayed_model_id_on_page_raw = await model_name_locator.first.inner_text(timeout=5000)
|
| 281 |
+
actual_displayed_model_id_on_page = actual_displayed_model_id_on_page_raw.strip()
|
| 282 |
+
|
| 283 |
+
target_model_id = model_id
|
| 284 |
+
|
| 285 |
+
if actual_displayed_model_id_on_page == target_model_id:
|
| 286 |
+
page_display_match = True
|
| 287 |
+
logger.info(f"[{req_id}] ✅ 页面显示模型ID ('{actual_displayed_model_id_on_page}') 与期望ID ('{target_model_id}') 一致。")
|
| 288 |
+
else:
|
| 289 |
+
page_display_match = False
|
| 290 |
+
logger.error(f"[{req_id}] ❌ 页面显示模型ID ('{actual_displayed_model_id_on_page}') 与期望ID ('{target_model_id}') 不一致。")
|
| 291 |
+
|
| 292 |
+
except Exception as e_disp:
|
| 293 |
+
page_display_match = False # 读取失败则认为不匹配
|
| 294 |
+
logger.warning(f"[{req_id}] 读取页面显示的当前模型ID时出错: {e_disp}。将无法验证页面显示。")
|
| 295 |
+
|
| 296 |
+
if page_display_match:
|
| 297 |
+
try:
|
| 298 |
+
logger.info(f"[{req_id}] 模型切换成功,重新启用 '临时聊天' 模式...")
|
| 299 |
+
incognito_button_locator = page.locator('button[aria-label="Temporary chat toggle"]')
|
| 300 |
+
|
| 301 |
+
await incognito_button_locator.wait_for(state="visible", timeout=5000)
|
| 302 |
+
|
| 303 |
+
button_classes = await incognito_button_locator.get_attribute("class")
|
| 304 |
+
|
| 305 |
+
if button_classes and 'ms-button-active' in button_classes:
|
| 306 |
+
logger.info(f"[{req_id}] '临时聊天' 模式已处于激活状态。")
|
| 307 |
+
else:
|
| 308 |
+
logger.info(f"[{req_id}] '临时聊天' 模式未激活,正在点击以开启...")
|
| 309 |
+
await incognito_button_locator.click(timeout=3000)
|
| 310 |
+
await asyncio.sleep(0.5)
|
| 311 |
+
|
| 312 |
+
updated_classes = await incognito_button_locator.get_attribute("class")
|
| 313 |
+
if updated_classes and 'ms-button-active' in updated_classes:
|
| 314 |
+
logger.info(f"[{req_id}] ✅ '临时聊天' 模式已成功重新启用。")
|
| 315 |
+
else:
|
| 316 |
+
logger.warning(f"[{req_id}] ⚠️ 点击后 '临时聊天' 模式状态验证失败,可能未成功重新开启。")
|
| 317 |
+
|
| 318 |
+
except Exception as e:
|
| 319 |
+
logger.warning(f"[{req_id}] ⚠️ 模型切换后重新启用 '临时聊天' 模式失败: {e}")
|
| 320 |
+
return True
|
| 321 |
+
else:
|
| 322 |
+
logger.error(f"[{req_id}] ❌ 模型切换失败,因为页面显示的模型与期望不符 (即使localStorage可能已更改)。")
|
| 323 |
+
else:
|
| 324 |
+
logger.error(f"[{req_id}] ❌ AI Studio 未接受模型更改 (localStorage)。期望='{full_model_path}', 实际='{final_prompt_model_in_storage or '未设置或无效'}'.")
|
| 325 |
+
|
| 326 |
+
logger.info(f"[{req_id}] 模型切换失败。尝试恢复到页面当前实际显示的模型的状态...")
|
| 327 |
+
current_displayed_name_for_revert_raw = "无法读取"
|
| 328 |
+
current_displayed_name_for_revert_stripped = "无法读取"
|
| 329 |
+
|
| 330 |
+
try:
|
| 331 |
+
model_name_locator_revert = page.locator('[data-test-id="model-name"]')
|
| 332 |
+
current_displayed_name_for_revert_raw = await model_name_locator_revert.first.inner_text(timeout=5000)
|
| 333 |
+
current_displayed_name_for_revert_stripped = current_displayed_name_for_revert_raw.strip()
|
| 334 |
+
logger.info(f"[{req_id}] 恢复:页面当前显示的模型名称 (原始: '{current_displayed_name_for_revert_raw}', 清理后: '{current_displayed_name_for_revert_stripped}')")
|
| 335 |
+
except Exception as e_read_disp_revert:
|
| 336 |
+
logger.warning(f"[{req_id}] 恢复:读取页面当前显示模型名称失败: {e_read_disp_revert}。将尝试回退到原始localStorage。")
|
| 337 |
+
if original_prefs_str:
|
| 338 |
+
logger.info(f"[{req_id}] 恢复:由于无法读取当前页面显示,尝试将 localStorage 恢复到原始状态: '{original_prompt_model or '未设置'}'")
|
| 339 |
+
await page.evaluate("(origPrefs) => localStorage.setItem('aiStudioUserPreference', origPrefs)", original_prefs_str)
|
| 340 |
+
logger.info(f"[{req_id}] 恢复:导航到 '{new_chat_url}' 以应用恢复的原始 localStorage 设置...")
|
| 341 |
+
await page.goto(new_chat_url, wait_until="domcontentloaded", timeout=20000)
|
| 342 |
+
await expect_async(page.locator(INPUT_SELECTOR)).to_be_visible(timeout=20000)
|
| 343 |
+
logger.info(f"[{req_id}] 恢复:页面已导航到新聊天并加载,已尝试应用原始 localStorage。")
|
| 344 |
+
else:
|
| 345 |
+
logger.warning(f"[{req_id}] 恢复:无有效的原始 localStorage 状态可恢复,也无法读取当前页面显示。")
|
| 346 |
+
return False
|
| 347 |
+
|
| 348 |
+
model_id_to_revert_to = None
|
| 349 |
+
if current_displayed_name_for_revert_stripped != "无法读取":
|
| 350 |
+
model_id_to_revert_to = current_displayed_name_for_revert_stripped
|
| 351 |
+
logger.info(f"[{req_id}] 恢复:页面当前显示的ID是 '{model_id_to_revert_to}',将直接用于恢复。")
|
| 352 |
+
else:
|
| 353 |
+
if current_displayed_name_for_revert_stripped == "无法读取":
|
| 354 |
+
logger.warning(f"[{req_id}] 恢复:因无法读取页面显示名称,故不能从 parsed_model_list 转换ID。")
|
| 355 |
+
else:
|
| 356 |
+
logger.warning(f"[{req_id}] 恢复:parsed_model_list 为空,无法从显示名称 '{current_displayed_name_for_revert_stripped}' 转换模型ID。")
|
| 357 |
+
|
| 358 |
+
if model_id_to_revert_to:
|
| 359 |
+
base_prefs_for_final_revert = {}
|
| 360 |
+
try:
|
| 361 |
+
current_ls_content_str = await page.evaluate("() => localStorage.getItem('aiStudioUserPreference')")
|
| 362 |
+
if current_ls_content_str:
|
| 363 |
+
base_prefs_for_final_revert = json.loads(current_ls_content_str)
|
| 364 |
+
elif original_prefs_str:
|
| 365 |
+
base_prefs_for_final_revert = json.loads(original_prefs_str)
|
| 366 |
+
except json.JSONDecodeError:
|
| 367 |
+
logger.warning(f"[{req_id}] 恢复:解析现有 localStorage 以构建恢复偏好失败。")
|
| 368 |
+
|
| 369 |
+
path_to_revert_to = f"models/{model_id_to_revert_to}"
|
| 370 |
+
base_prefs_for_final_revert["promptModel"] = path_to_revert_to
|
| 371 |
+
# 使用新的强制设置功能
|
| 372 |
+
logger.info(f"[{req_id}] 恢复:应用强制UI状态设置...")
|
| 373 |
+
ui_state_success = await _verify_and_apply_ui_state(page, req_id)
|
| 374 |
+
if not ui_state_success:
|
| 375 |
+
logger.warning(f"[{req_id}] 恢复:UI状态设置失败,但继续执行恢复流程")
|
| 376 |
+
|
| 377 |
+
# 为了保持兼容性,也更新当前的prefs对象
|
| 378 |
+
base_prefs_for_final_revert["isAdvancedOpen"] = True
|
| 379 |
+
base_prefs_for_final_revert["areToolsOpen"] = True
|
| 380 |
+
logger.info(f"[{req_id}] 恢复:准备将 localStorage.promptModel 设置回页面实际显示的模型的路径: '{path_to_revert_to}',并强制设置配置选项")
|
| 381 |
+
await page.evaluate("(prefsStr) => localStorage.setItem('aiStudioUserPreference', prefsStr)", json.dumps(base_prefs_for_final_revert))
|
| 382 |
+
logger.info(f"[{req_id}] 恢复:导航到 '{new_chat_url}' 以应用恢复到 '{model_id_to_revert_to}' 的 localStorage 设置...")
|
| 383 |
+
await page.goto(new_chat_url, wait_until="domcontentloaded", timeout=30000)
|
| 384 |
+
await expect_async(page.locator(INPUT_SELECTOR)).to_be_visible(timeout=30000)
|
| 385 |
+
|
| 386 |
+
# 恢复后再次验证UI状态
|
| 387 |
+
logger.info(f"[{req_id}] 恢复:页面加载完成,验证UI状态设置...")
|
| 388 |
+
final_ui_state_success = await _verify_and_apply_ui_state(page, req_id)
|
| 389 |
+
if final_ui_state_success:
|
| 390 |
+
logger.info(f"[{req_id}] ✅ 恢复:UI状态最终验证成功")
|
| 391 |
+
else:
|
| 392 |
+
logger.warning(f"[{req_id}] ⚠️ 恢复:UI状态最终验证失败")
|
| 393 |
+
|
| 394 |
+
logger.info(f"[{req_id}] 恢复:页面已导航到新聊天并加载。localStorage 应已设置为反映模型 '{model_id_to_revert_to}'。")
|
| 395 |
+
else:
|
| 396 |
+
logger.error(f"[{req_id}] 恢复:无法将模型恢复到页面显示的状态,因为未能从显示名称 '{current_displayed_name_for_revert_stripped}' 确定有效模型ID。")
|
| 397 |
+
if original_prefs_str:
|
| 398 |
+
logger.warning(f"[{req_id}] 恢复:作为最终后备,尝试恢复到原始 localStorage: '{original_prompt_model or '未设置'}'")
|
| 399 |
+
await page.evaluate("(origPrefs) => localStorage.setItem('aiStudioUserPreference', origPrefs)", original_prefs_str)
|
| 400 |
+
logger.info(f"[{req_id}] 恢复:导航到 '{new_chat_url}' 以应用最终后备的原始 localStorage。")
|
| 401 |
+
await page.goto(new_chat_url, wait_until="domcontentloaded", timeout=20000)
|
| 402 |
+
await expect_async(page.locator(INPUT_SELECTOR)).to_be_visible(timeout=20000)
|
| 403 |
+
logger.info(f"[{req_id}] 恢复:页面已导航到新聊天并加载,已应用最终后备的原始 localStorage。")
|
| 404 |
+
else:
|
| 405 |
+
logger.warning(f"[{req_id}] 恢复:无有效的原始 localStorage 状态可作为最终后备。")
|
| 406 |
+
|
| 407 |
+
return False
|
| 408 |
+
|
| 409 |
+
except Exception as e:
|
| 410 |
+
logger.exception(f"[{req_id}] ❌ 切换模型过程中发生严重错误")
|
| 411 |
+
# 导入save_error_snapshot函数
|
| 412 |
+
from .operations import save_error_snapshot
|
| 413 |
+
await save_error_snapshot(f"model_switch_error_{req_id}")
|
| 414 |
+
try:
|
| 415 |
+
if original_prefs_str:
|
| 416 |
+
logger.info(f"[{req_id}] 发生异常,尝试恢复 localStorage 至: {original_prompt_model or '未设置'}")
|
| 417 |
+
await page.evaluate("(origPrefs) => localStorage.setItem('aiStudioUserPreference', origPrefs)", original_prefs_str)
|
| 418 |
+
logger.info(f"[{req_id}] 异常恢复:导航到 '{new_chat_url}' 以应用恢复的 localStorage。")
|
| 419 |
+
await page.goto(new_chat_url, wait_until="domcontentloaded", timeout=15000)
|
| 420 |
+
await expect_async(page.locator(INPUT_SELECTOR)).to_be_visible(timeout=15000)
|
| 421 |
+
except Exception as recovery_err:
|
| 422 |
+
logger.error(f"[{req_id}] 异常后恢复 localStorage 失败: {recovery_err}")
|
| 423 |
+
return False
|
| 424 |
+
|
| 425 |
+
def load_excluded_models(filename: str):
|
| 426 |
+
"""加载排除的模型列表"""
|
| 427 |
+
import server
|
| 428 |
+
excluded_model_ids = getattr(server, 'excluded_model_ids', set())
|
| 429 |
+
|
| 430 |
+
excluded_file_path = os.path.join(os.path.dirname(__file__), '..', filename)
|
| 431 |
+
try:
|
| 432 |
+
if os.path.exists(excluded_file_path):
|
| 433 |
+
with open(excluded_file_path, 'r', encoding='utf-8') as f:
|
| 434 |
+
loaded_ids = {line.strip() for line in f if line.strip()}
|
| 435 |
+
if loaded_ids:
|
| 436 |
+
excluded_model_ids.update(loaded_ids)
|
| 437 |
+
server.excluded_model_ids = excluded_model_ids
|
| 438 |
+
logger.info(f"✅ 从 '{filename}' 加载了 {len(loaded_ids)} 个模型到排除列表: {excluded_model_ids}")
|
| 439 |
+
else:
|
| 440 |
+
logger.info(f"'{filename}' 文件为空或不包含有效的模型 ID,排除列表未更改。")
|
| 441 |
+
else:
|
| 442 |
+
logger.info(f"模型排除列表文件 '{filename}' 未找到,排除列表为空。")
|
| 443 |
+
except Exception as e:
|
| 444 |
+
logger.error(f"❌ 从 '{filename}' 加载排除模型列表时出错: {e}", exc_info=True)
|
| 445 |
+
|
| 446 |
+
async def _handle_initial_model_state_and_storage(page: AsyncPage):
|
| 447 |
+
"""处理初始模型状态和存储"""
|
| 448 |
+
import server
|
| 449 |
+
current_ai_studio_model_id = getattr(server, 'current_ai_studio_model_id', None)
|
| 450 |
+
parsed_model_list = getattr(server, 'parsed_model_list', [])
|
| 451 |
+
model_list_fetch_event = getattr(server, 'model_list_fetch_event', None)
|
| 452 |
+
|
| 453 |
+
logger.info("--- (新) 处理初始模型状态, localStorage 和 isAdvancedOpen ---")
|
| 454 |
+
needs_reload_and_storage_update = False
|
| 455 |
+
reason_for_reload = ""
|
| 456 |
+
|
| 457 |
+
try:
|
| 458 |
+
initial_prefs_str = await page.evaluate("() => localStorage.getItem('aiStudioUserPreference')")
|
| 459 |
+
if not initial_prefs_str:
|
| 460 |
+
needs_reload_and_storage_update = True
|
| 461 |
+
reason_for_reload = "localStorage.aiStudioUserPreference 未找到。"
|
| 462 |
+
logger.info(f" 判定需要刷新和存储更新: {reason_for_reload}")
|
| 463 |
+
else:
|
| 464 |
+
logger.info(" localStorage 中找到 'aiStudioUserPreference'。正在解析...")
|
| 465 |
+
try:
|
| 466 |
+
pref_obj = json.loads(initial_prefs_str)
|
| 467 |
+
prompt_model_path = pref_obj.get("promptModel")
|
| 468 |
+
is_advanced_open_in_storage = pref_obj.get("isAdvancedOpen")
|
| 469 |
+
is_prompt_model_valid = isinstance(prompt_model_path, str) and prompt_model_path.strip()
|
| 470 |
+
|
| 471 |
+
if not is_prompt_model_valid:
|
| 472 |
+
needs_reload_and_storage_update = True
|
| 473 |
+
reason_for_reload = "localStorage.promptModel 无效��未设置。"
|
| 474 |
+
logger.info(f" 判定需要刷新和存储更新: {reason_for_reload}")
|
| 475 |
+
else:
|
| 476 |
+
# 使用新的UI状态验证功能
|
| 477 |
+
ui_state = await _verify_ui_state_settings(page, "initial")
|
| 478 |
+
if ui_state['needsUpdate']:
|
| 479 |
+
needs_reload_and_storage_update = True
|
| 480 |
+
reason_for_reload = f"UI状态需要更新: isAdvancedOpen={ui_state['isAdvancedOpen']}, areToolsOpen={ui_state['areToolsOpen']} (期望: True)"
|
| 481 |
+
logger.info(f" 判定需要刷新和存储更新: {reason_for_reload}")
|
| 482 |
+
else:
|
| 483 |
+
server.current_ai_studio_model_id = prompt_model_path.split('/')[-1]
|
| 484 |
+
logger.info(f" ✅ localStorage 有效且UI状态正确。初始模型 ID 从 localStorage 设置为: {server.current_ai_studio_model_id}")
|
| 485 |
+
except json.JSONDecodeError:
|
| 486 |
+
needs_reload_and_storage_update = True
|
| 487 |
+
reason_for_reload = "解析 localStorage.aiStudioUserPreference JSON 失败。"
|
| 488 |
+
logger.error(f" 判定需要刷新和存储更新: {reason_for_reload}")
|
| 489 |
+
|
| 490 |
+
if needs_reload_and_storage_update:
|
| 491 |
+
logger.info(f" 执行刷新和存储更新流程,原因: {reason_for_reload}")
|
| 492 |
+
logger.info(" 步骤 1: 调用 _set_model_from_page_display(set_storage=True) 更新 localStorage 和全局模型 ID...")
|
| 493 |
+
await _set_model_from_page_display(page, set_storage=True)
|
| 494 |
+
|
| 495 |
+
current_page_url = page.url
|
| 496 |
+
logger.info(f" 步骤 2: 重新加载页面 ({current_page_url}) 以应用 isAdvancedOpen=true...")
|
| 497 |
+
max_retries = 3
|
| 498 |
+
for attempt in range(max_retries):
|
| 499 |
+
try:
|
| 500 |
+
logger.info(f" 尝试重新加载页面 (第 {attempt + 1}/{max_retries} 次): {current_page_url}")
|
| 501 |
+
await page.goto(current_page_url, wait_until="domcontentloaded", timeout=40000)
|
| 502 |
+
await expect_async(page.locator(INPUT_SELECTOR)).to_be_visible(timeout=30000)
|
| 503 |
+
logger.info(f" ✅ 页面已成功重新加载到: {page.url}")
|
| 504 |
+
|
| 505 |
+
# 页面重新加载后验证UI状态
|
| 506 |
+
logger.info(f" 页面重新加载完成,验证UI状态设置...")
|
| 507 |
+
reload_ui_state_success = await _verify_and_apply_ui_state(page, "reload")
|
| 508 |
+
if reload_ui_state_success:
|
| 509 |
+
logger.info(f" ✅ 重新加载后UI状态验证成功")
|
| 510 |
+
else:
|
| 511 |
+
logger.warning(f" ⚠️ 重新加载后UI状态验证失败")
|
| 512 |
+
|
| 513 |
+
break # 成功则跳出循环
|
| 514 |
+
except Exception as reload_err:
|
| 515 |
+
logger.warning(f" ⚠️ 页面重新加载尝试 {attempt + 1}/{max_retries} 失败: {reload_err}")
|
| 516 |
+
if attempt < max_retries - 1:
|
| 517 |
+
logger.info(f" 将在5秒后重试...")
|
| 518 |
+
await asyncio.sleep(5)
|
| 519 |
+
else:
|
| 520 |
+
logger.error(f" ❌ 页面重新加载在 {max_retries} 次尝试后最终失败: {reload_err}. 后续模型状态可能不准确。", exc_info=True)
|
| 521 |
+
from .operations import save_error_snapshot
|
| 522 |
+
await save_error_snapshot(f"initial_storage_reload_fail_attempt_{attempt+1}")
|
| 523 |
+
|
| 524 |
+
logger.info(" 步骤 3: 重新加载后,再次调用 _set_model_from_page_display(set_storage=False) 以同步全局模型 ID...")
|
| 525 |
+
await _set_model_from_page_display(page, set_storage=False)
|
| 526 |
+
logger.info(f" ✅ 刷新和存储更新流程完成。最终全局模型 ID: {server.current_ai_studio_model_id}")
|
| 527 |
+
else:
|
| 528 |
+
logger.info(" localStorage 状态良好 (isAdvancedOpen=true, promptModel有效),无需刷新页面。")
|
| 529 |
+
except Exception as e:
|
| 530 |
+
logger.error(f"❌ (新) 处理初始模型状态和 localStorage 时发生严重错误: {e}", exc_info=True)
|
| 531 |
+
try:
|
| 532 |
+
logger.warning(" 由于发生错误,尝试回退仅从页面显示设置全局模型 ID (不写入localStorage)...")
|
| 533 |
+
await _set_model_from_page_display(page, set_storage=False)
|
| 534 |
+
except Exception as fallback_err:
|
| 535 |
+
logger.error(f" 回退设置模型ID也失败: {fallback_err}")
|
| 536 |
+
|
| 537 |
+
async def _set_model_from_page_display(page: AsyncPage, set_storage: bool = False):
|
| 538 |
+
"""从页面显示设置模型"""
|
| 539 |
+
import server
|
| 540 |
+
current_ai_studio_model_id = getattr(server, 'current_ai_studio_model_id', None)
|
| 541 |
+
parsed_model_list = getattr(server, 'parsed_model_list', [])
|
| 542 |
+
model_list_fetch_event = getattr(server, 'model_list_fetch_event', None)
|
| 543 |
+
|
| 544 |
+
try:
|
| 545 |
+
logger.info(" 尝试从页面显示元素读取当前模型名称...")
|
| 546 |
+
model_name_locator = page.locator('[data-test-id="model-name"]')
|
| 547 |
+
displayed_model_name_from_page_raw = await model_name_locator.first.inner_text(timeout=7000)
|
| 548 |
+
displayed_model_name = displayed_model_name_from_page_raw.strip()
|
| 549 |
+
logger.info(f" 页面当前显示模型名称 (原始: '{displayed_model_name_from_page_raw}', 清理后: '{displayed_model_name}')")
|
| 550 |
+
|
| 551 |
+
found_model_id_from_display = None
|
| 552 |
+
if model_list_fetch_event and not model_list_fetch_event.is_set():
|
| 553 |
+
logger.info(" 等待模型列表数据 (最多5秒) 以便转换显示名称...")
|
| 554 |
+
try:
|
| 555 |
+
await asyncio.wait_for(model_list_fetch_event.wait(), timeout=5.0)
|
| 556 |
+
except asyncio.TimeoutError:
|
| 557 |
+
logger.warning(" 等待模型列表超时,可能无法准确转换显示名称为ID。")
|
| 558 |
+
|
| 559 |
+
found_model_id_from_display = displayed_model_name
|
| 560 |
+
logger.info(f" 页面显示的直接是模型ID: '{found_model_id_from_display}'")
|
| 561 |
+
|
| 562 |
+
new_model_value = found_model_id_from_display
|
| 563 |
+
if server.current_ai_studio_model_id != new_model_value:
|
| 564 |
+
server.current_ai_studio_model_id = new_model_value
|
| 565 |
+
logger.info(f" 全局 current_ai_studio_model_id 已更新为: {server.current_ai_studio_model_id}")
|
| 566 |
+
else:
|
| 567 |
+
logger.info(f" 全局 current_ai_studio_model_id ('{server.current_ai_studio_model_id}') 与从页面获取的值一致,未更改。")
|
| 568 |
+
|
| 569 |
+
if set_storage:
|
| 570 |
+
logger.info(f" 准备为页面状态设置 localStorage (确保 isAdvancedOpen=true)...")
|
| 571 |
+
existing_prefs_for_update_str = await page.evaluate("() => localStorage.getItem('aiStudioUserPreference')")
|
| 572 |
+
prefs_to_set = {}
|
| 573 |
+
if existing_prefs_for_update_str:
|
| 574 |
+
try:
|
| 575 |
+
prefs_to_set = json.loads(existing_prefs_for_update_str)
|
| 576 |
+
except json.JSONDecodeError:
|
| 577 |
+
logger.warning(" 解析现有 localStorage.aiStudioUserPreference 失败,将创建新的偏好设置。")
|
| 578 |
+
|
| 579 |
+
# 使用新的强制设置功能
|
| 580 |
+
logger.info(f" 应用强制UI状态设置...")
|
| 581 |
+
ui_state_success = await _verify_and_apply_ui_state(page, "set_model")
|
| 582 |
+
if not ui_state_success:
|
| 583 |
+
logger.warning(f" UI状态设置失败,使用传统方法")
|
| 584 |
+
prefs_to_set["isAdvancedOpen"] = True
|
| 585 |
+
prefs_to_set["areToolsOpen"] = True
|
| 586 |
+
else:
|
| 587 |
+
# 确保prefs_to_set也包含正确的设置
|
| 588 |
+
prefs_to_set["isAdvancedOpen"] = True
|
| 589 |
+
prefs_to_set["areToolsOpen"] = True
|
| 590 |
+
logger.info(f" 强制 isAdvancedOpen: true, areToolsOpen: true")
|
| 591 |
+
|
| 592 |
+
if found_model_id_from_display:
|
| 593 |
+
new_prompt_model_path = f"models/{found_model_id_from_display}"
|
| 594 |
+
prefs_to_set["promptModel"] = new_prompt_model_path
|
| 595 |
+
logger.info(f" 设置 promptModel 为: {new_prompt_model_path} (基于找到的ID)")
|
| 596 |
+
elif "promptModel" not in prefs_to_set:
|
| 597 |
+
logger.warning(f" 无法从页面显示 '{displayed_model_name}' 找到模型ID,且 localStorage 中无现有 promptModel。promptModel 将不会被主动设置以避免潜在问题。")
|
| 598 |
+
|
| 599 |
+
default_keys_if_missing = {
|
| 600 |
+
"bidiModel": "models/gemini-1.0-pro-001",
|
| 601 |
+
"isSafetySettingsOpen": False,
|
| 602 |
+
"hasShownSearchGroundingTos": False,
|
| 603 |
+
"autosaveEnabled": True,
|
| 604 |
+
"theme": "system",
|
| 605 |
+
"bidiOutputFormat": 3,
|
| 606 |
+
"isSystemInstructionsOpen": False,
|
| 607 |
+
"warmWelcomeDisplayed": True,
|
| 608 |
+
"getCodeLanguage": "Node.js",
|
| 609 |
+
"getCodeHistoryToggle": False,
|
| 610 |
+
"fileCopyrightAcknowledged": True
|
| 611 |
+
}
|
| 612 |
+
for key, val_default in default_keys_if_missing.items():
|
| 613 |
+
if key not in prefs_to_set:
|
| 614 |
+
prefs_to_set[key] = val_default
|
| 615 |
+
|
| 616 |
+
await page.evaluate("(prefsStr) => localStorage.setItem('aiStudioUserPreference', prefsStr)", json.dumps(prefs_to_set))
|
| 617 |
+
logger.info(f" ✅ localStorage.aiStudioUserPreference 已更新。isAdvancedOpen: {prefs_to_set.get('isAdvancedOpen')}, areToolsOpen: {prefs_to_set.get('areToolsOpen')} (期望: True), promptModel: '{prefs_to_set.get('promptModel', '未设置/保留原样')}'。")
|
| 618 |
+
except Exception as e_set_disp:
|
| 619 |
+
logger.error(f" 尝试从页面显示设置模型时出错: {e_set_disp}", exc_info=True)
|
AIStudioProxyAPI/browser_utils/more_modles.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ==UserScript==
|
| 2 |
+
// @name Google AI Studio 模型注入器
|
| 3 |
+
// @namespace http://tampermonkey.net/
|
| 4 |
+
// @version 1.6.5
|
| 5 |
+
// @description 向 Google AI Studio 注入自定义模型,支持主题表情图标。拦截 XHR/Fetch 请求,处理数组结构的 JSON 数据
|
| 6 |
+
// @author Generated by AI / HCPTangHY / Mozi / wisdgod / UserModified
|
| 7 |
+
// @match https://aistudio.google.com/*
|
| 8 |
+
// @icon https://www.google.com/s2/favicons?sz=64&domain=aistudio.google.com
|
| 9 |
+
// @grant none
|
| 10 |
+
// @run-at document-start
|
| 11 |
+
// @license MIT
|
| 12 |
+
// ==/UserScript==
|
| 13 |
+
|
| 14 |
+
(function() {
|
| 15 |
+
'use strict';
|
| 16 |
+
|
| 17 |
+
// ==================== 配置区域 ====================
|
| 18 |
+
// 脚本已经失效
|
| 19 |
+
|
| 20 |
+
const SCRIPT_VERSION = "none";
|
| 21 |
+
const LOG_PREFIX = `[AI Studio 注入器 ${SCRIPT_VERSION}]`;
|
| 22 |
+
const ANTI_HIJACK_PREFIX = ")]}'\n";
|
| 23 |
+
|
| 24 |
+
// 模型配置列表
|
| 25 |
+
// 已按要求将 jfdksal98a 放到 blacktooth 的下面
|
| 26 |
+
const MODELS_TO_INJECT = [
|
| 27 |
+
|
| 28 |
+
//下面模型已经全部失效,留下来怀念
|
| 29 |
+
// { name: 'models/gemini-2.5-pro-preview-03-25', displayName: `✨ Gemini 2.5 Pro 03-25 (Script ${SCRIPT_VERSION})`, description: `Model injected by script ${SCRIPT_VERSION}` },
|
| 30 |
+
// { name: 'models/gemini-2.5-pro-exp-03-25', displayName: `✨ Gemini 2.5 Pro 03-25 (Script ${SCRIPT_VERSION})`, description: `Model injected by script ${SCRIPT_VERSION}` },
|
| 31 |
+
// { name: 'models/gemini-2.5-pro-preview-06-05', displayName: `✨ Gemini 2.5 Pro 03-25 (Script ${SCRIPT_VERSION})`, description: `Model injected by script ${SCRIPT_VERSION}` },
|
| 32 |
+
// { name: 'models/blacktooth-ab-test', displayName: `🏴☠️ Blacktooth (脚本 ${SCRIPT_VERSION})`, description: `由脚本 ${SCRIPT_VERSION} 注入的模型` },
|
| 33 |
+
// { name: 'models/jfdksal98a', displayName: `🪐 jfdksal98a (脚本 ${SCRIPT_VERSION})`, description: `由脚本 ${SCRIPT_VERSION} 注入的模型` },
|
| 34 |
+
// { name: 'models/gemini-2.5-pro-preview-03-25', displayName: `✨ Gemini 2.5 Pro 03-25 (脚本 ${SCRIPT_VERSION})`, description: `由脚本 ${SCRIPT_VERSION} 注入的模型` },
|
| 35 |
+
// { name: 'models/goldmane-ab-test', displayName: `🦁 Goldmane (脚本 ${SCRIPT_VERSION})`, description: `由脚本 ${SCRIPT_VERSION} 注入的模型` },
|
| 36 |
+
// { name: 'models/claybrook-ab-test', displayName: `💧 Claybrook (脚本 ${SCRIPT_VERSION})`, description: `由脚本 ${SCRIPT_VERSION} 注入的模型` },
|
| 37 |
+
// { name: 'models/frostwind-ab-test', displayName: `❄️ Frostwind (脚本 ${SCRIPT_VERSION})`, description: `由脚本 ${SCRIPT_VERSION} 注入的模型` },
|
| 38 |
+
// { name: 'models/calmriver-ab-test', displayName: `🌊 Calmriver (脚本 ${SCRIPT_VERSION})`, description: `由脚本 ${SCRIPT_VERSION} 注入的模型` }
|
| 39 |
+
];
|
| 40 |
+
|
| 41 |
+
// JSON 结构中的字段索引
|
| 42 |
+
const MODEL_FIELDS = {
|
| 43 |
+
NAME: 0,
|
| 44 |
+
DISPLAY_NAME: 3,
|
| 45 |
+
DESCRIPTION: 4,
|
| 46 |
+
METHODS: 7
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
// ==================== 工具函数 ====================
|
| 50 |
+
|
| 51 |
+
/**
|
| 52 |
+
* 检查 URL 是否为目标 API 端点
|
| 53 |
+
* @param {string} url - 要检查的 URL
|
| 54 |
+
* @returns {boolean}
|
| 55 |
+
*/
|
| 56 |
+
function isTargetURL(url) {
|
| 57 |
+
return url && typeof url === 'string' &&
|
| 58 |
+
url.includes('alkalimakersuite') &&
|
| 59 |
+
url.includes('/ListModels');
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* 递归查找模型列表数组
|
| 64 |
+
* @param {any} obj - 要搜索的对象
|
| 65 |
+
* @returns {Array|null} 找到的模型数组或 null
|
| 66 |
+
*/
|
| 67 |
+
function findModelListArray(obj) {
|
| 68 |
+
if (!obj) return null;
|
| 69 |
+
|
| 70 |
+
// 检查是否为目标模型数组
|
| 71 |
+
if (Array.isArray(obj) && obj.length > 0 && obj.every(
|
| 72 |
+
item => Array.isArray(item) &&
|
| 73 |
+
typeof item[MODEL_FIELDS.NAME] === 'string' &&
|
| 74 |
+
String(item[MODEL_FIELDS.NAME]).startsWith('models/')
|
| 75 |
+
)) {
|
| 76 |
+
return obj;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// 递归搜索子对象
|
| 80 |
+
if (typeof obj === 'object') {
|
| 81 |
+
for (const key in obj) {
|
| 82 |
+
if (Object.prototype.hasOwnProperty.call(obj, key) &&
|
| 83 |
+
typeof obj[key] === 'object' &&
|
| 84 |
+
obj[key] !== null) {
|
| 85 |
+
const result = findModelListArray(obj[key]);
|
| 86 |
+
if (result) return result;
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
return null;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/**
|
| 94 |
+
* 查找合适的模板模型
|
| 95 |
+
* @param {Array} modelsArray - 模型数组
|
| 96 |
+
* @returns {Array|null} 模板模型或 null
|
| 97 |
+
*/
|
| 98 |
+
function findTemplateModel(modelsArray) {
|
| 99 |
+
// 优先查找包含特定关键词的模型
|
| 100 |
+
const templateModel =
|
| 101 |
+
modelsArray.find(m => Array.isArray(m) &&
|
| 102 |
+
m[MODEL_FIELDS.NAME] &&
|
| 103 |
+
String(m[MODEL_FIELDS.NAME]).includes('pro') &&
|
| 104 |
+
Array.isArray(m[MODEL_FIELDS.METHODS])) ||
|
| 105 |
+
modelsArray.find(m => Array.isArray(m) &&
|
| 106 |
+
m[MODEL_FIELDS.NAME] &&
|
| 107 |
+
String(m[MODEL_FIELDS.NAME]).includes('flash') &&
|
| 108 |
+
Array.isArray(m[MODEL_FIELDS.METHODS])) ||
|
| 109 |
+
modelsArray.find(m => Array.isArray(m) &&
|
| 110 |
+
m[MODEL_FIELDS.NAME] &&
|
| 111 |
+
Array.isArray(m[MODEL_FIELDS.METHODS]));
|
| 112 |
+
|
| 113 |
+
return templateModel;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
/**
|
| 117 |
+
* 更新已存在模型的显示名称
|
| 118 |
+
* @param {Array} existingModel - 现有模型
|
| 119 |
+
* @param {Object} modelToInject - 要注入的模型配置
|
| 120 |
+
* @returns {boolean} 是否进行了更新
|
| 121 |
+
*/
|
| 122 |
+
function updateExistingModel(existingModel, modelToInject) {
|
| 123 |
+
if (!existingModel || existingModel[MODEL_FIELDS.DISPLAY_NAME] === modelToInject.displayName) {
|
| 124 |
+
return false;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// 提取基础名称(去除版本号和表情)
|
| 128 |
+
// 更新正则表达式以匹配 vX.Y.Z 格式
|
| 129 |
+
const cleanName = (name) => String(name)
|
| 130 |
+
.replace(/ \(脚本 v\d+\.\d+(\.\d+)?(-beta\d*)?\)/, '')
|
| 131 |
+
// 包含所有当前使用的表情,包括新增的 🏴☠️, 🤖, 🪐
|
| 132 |
+
.replace(/^[✨🦁💧❄️🌊🐉🏴☠️🤖🪐]\s*/, '')
|
| 133 |
+
.trim();
|
| 134 |
+
|
| 135 |
+
const baseExistingName = cleanName(existingModel[MODEL_FIELDS.DISPLAY_NAME]);
|
| 136 |
+
const baseInjectName = cleanName(modelToInject.displayName);
|
| 137 |
+
|
| 138 |
+
if (baseExistingName === baseInjectName) {
|
| 139 |
+
// 仅更新版本号和表情
|
| 140 |
+
existingModel[MODEL_FIELDS.DISPLAY_NAME] = modelToInject.displayName;
|
| 141 |
+
console.log(LOG_PREFIX, `已更新表情/版本号: ${modelToInject.displayName}`);
|
| 142 |
+
} else {
|
| 143 |
+
// 标记为原始模型
|
| 144 |
+
existingModel[MODEL_FIELDS.DISPLAY_NAME] = modelToInject.displayName + " (原始)";
|
| 145 |
+
console.log(LOG_PREFIX, `已更新官方模型 ${modelToInject.name} 的显示名称`);
|
| 146 |
+
}
|
| 147 |
+
return true;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/**
|
| 151 |
+
* 创建新模型
|
| 152 |
+
* @param {Array} templateModel - 模板模型
|
| 153 |
+
* @param {Object} modelToInject - 要注入的模型配置
|
| 154 |
+
* @param {string} templateName - 模板名称
|
| 155 |
+
* @returns {Array} 新模型数组
|
| 156 |
+
*/
|
| 157 |
+
function createNewModel(templateModel, modelToInject, templateName) {
|
| 158 |
+
const newModel = structuredClone(templateModel);
|
| 159 |
+
|
| 160 |
+
newModel[MODEL_FIELDS.NAME] = modelToInject.name;
|
| 161 |
+
newModel[MODEL_FIELDS.DISPLAY_NAME] = modelToInject.displayName;
|
| 162 |
+
newModel[MODEL_FIELDS.DESCRIPTION] = `${modelToInject.description} (基于 ${templateName} 结构)`;
|
| 163 |
+
|
| 164 |
+
if (!Array.isArray(newModel[MODEL_FIELDS.METHODS])) {
|
| 165 |
+
newModel[MODEL_FIELDS.METHODS] = [
|
| 166 |
+
"generateContent",
|
| 167 |
+
"countTokens",
|
| 168 |
+
"createCachedContent",
|
| 169 |
+
"batchGenerateContent"
|
| 170 |
+
];
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
return newModel;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// ==================== 核心处理函数 ====================
|
| 177 |
+
|
| 178 |
+
/**
|
| 179 |
+
* 处理并修改 JSON 数据
|
| 180 |
+
* @param {Object} jsonData - 原始 JSON 数据
|
| 181 |
+
* @param {string} url - 请求 URL
|
| 182 |
+
* @returns {Object} 包含处理后数据和修改标志的对象
|
| 183 |
+
*/
|
| 184 |
+
function processJsonData(jsonData, url) {
|
| 185 |
+
let modificationMade = false;
|
| 186 |
+
const modelsArray = findModelListArray(jsonData);
|
| 187 |
+
|
| 188 |
+
if (!modelsArray || !Array.isArray(modelsArray)) {
|
| 189 |
+
console.warn(LOG_PREFIX, '在 JSON 中未找到有效的模型列表结构:', url);
|
| 190 |
+
return { data: jsonData, modified: false };
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
// 查找模板模型
|
| 194 |
+
const templateModel = findTemplateModel(modelsArray);
|
| 195 |
+
const templateName = templateModel?.[MODEL_FIELDS.NAME] || 'unknown';
|
| 196 |
+
|
| 197 |
+
if (!templateModel) {
|
| 198 |
+
console.warn(LOG_PREFIX, '未找到合适的模板模型,无法注入新模型');
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
// 反向遍历以保持显示顺序 (配置中靠前的模型显示在最上面)
|
| 202 |
+
[...MODELS_TO_INJECT].reverse().forEach(modelToInject => {
|
| 203 |
+
const existingModel = modelsArray.find(
|
| 204 |
+
model => Array.isArray(model) && model[MODEL_FIELDS.NAME] === modelToInject.name
|
| 205 |
+
);
|
| 206 |
+
|
| 207 |
+
if (!existingModel) {
|
| 208 |
+
// 注入新模型
|
| 209 |
+
if (!templateModel) {
|
| 210 |
+
console.warn(LOG_PREFIX, `无法注入 ${modelToInject.name}:缺少模板`);
|
| 211 |
+
return;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
const newModel = createNewModel(templateModel, modelToInject, templateName);
|
| 215 |
+
modelsArray.unshift(newModel); // unshift 将模型添加到数组开头
|
| 216 |
+
modificationMade = true;
|
| 217 |
+
console.log(LOG_PREFIX, `成功注入: ${modelToInject.displayName}`);
|
| 218 |
+
} else {
|
| 219 |
+
// 更新现���模型
|
| 220 |
+
if (updateExistingModel(existingModel, modelToInject)) {
|
| 221 |
+
modificationMade = true;
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
});
|
| 225 |
+
|
| 226 |
+
return { data: jsonData, modified: modificationMade };
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
/**
|
| 230 |
+
* 修改响应体
|
| 231 |
+
* @param {string} originalText - 原始响应文本
|
| 232 |
+
* @param {string} url - 请求 URL
|
| 233 |
+
* @returns {string} 修改后的响应文本
|
| 234 |
+
*/
|
| 235 |
+
function modifyResponseBody(originalText, url) {
|
| 236 |
+
if (!originalText || typeof originalText !== 'string') {
|
| 237 |
+
return originalText;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
try {
|
| 241 |
+
let textBody = originalText;
|
| 242 |
+
let hasPrefix = false;
|
| 243 |
+
|
| 244 |
+
// 处理反劫持前缀
|
| 245 |
+
if (textBody.startsWith(ANTI_HIJACK_PREFIX)) {
|
| 246 |
+
textBody = textBody.substring(ANTI_HIJACK_PREFIX.length);
|
| 247 |
+
hasPrefix = true;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
if (!textBody.trim()) return originalText;
|
| 251 |
+
|
| 252 |
+
const jsonData = JSON.parse(textBody);
|
| 253 |
+
const result = processJsonData(jsonData, url);
|
| 254 |
+
|
| 255 |
+
if (result.modified) {
|
| 256 |
+
let newBody = JSON.stringify(result.data);
|
| 257 |
+
if (hasPrefix) {
|
| 258 |
+
newBody = ANTI_HIJACK_PREFIX + newBody;
|
| 259 |
+
}
|
| 260 |
+
return newBody;
|
| 261 |
+
}
|
| 262 |
+
} catch (error) {
|
| 263 |
+
console.error(LOG_PREFIX, '处理响应体时出错:', url, error);
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
return originalText;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
// ==================== 请求拦截 ====================
|
| 270 |
+
|
| 271 |
+
// 拦截 Fetch API
|
| 272 |
+
const originalFetch = window.fetch;
|
| 273 |
+
window.fetch = async function(...args) {
|
| 274 |
+
const resource = args[0];
|
| 275 |
+
const url = (resource instanceof Request) ? resource.url : String(resource);
|
| 276 |
+
const response = await originalFetch.apply(this, args);
|
| 277 |
+
|
| 278 |
+
if (isTargetURL(url) && response.ok) {
|
| 279 |
+
console.log(LOG_PREFIX, '[Fetch] 拦截到目标请求:', url);
|
| 280 |
+
try {
|
| 281 |
+
const cloneResponse = response.clone();
|
| 282 |
+
const originalText = await cloneResponse.text();
|
| 283 |
+
const newBody = modifyResponseBody(originalText, url);
|
| 284 |
+
|
| 285 |
+
if (newBody !== originalText) {
|
| 286 |
+
return new Response(newBody, {
|
| 287 |
+
status: response.status,
|
| 288 |
+
statusText: response.statusText,
|
| 289 |
+
headers: response.headers
|
| 290 |
+
});
|
| 291 |
+
}
|
| 292 |
+
} catch (e) {
|
| 293 |
+
console.error(LOG_PREFIX, '[Fetch] 处理错误:', e);
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
return response;
|
| 297 |
+
};
|
| 298 |
+
|
| 299 |
+
// 拦截 XMLHttpRequest
|
| 300 |
+
const xhrProto = XMLHttpRequest.prototype;
|
| 301 |
+
const originalOpen = xhrProto.open;
|
| 302 |
+
const originalResponseTextDescriptor = Object.getOwnPropertyDescriptor(xhrProto, 'responseText');
|
| 303 |
+
const originalResponseDescriptor = Object.getOwnPropertyDescriptor(xhrProto, 'response');
|
| 304 |
+
let interceptionCount = 0;
|
| 305 |
+
|
| 306 |
+
// 重写 open 方法
|
| 307 |
+
xhrProto.open = function(method, url) {
|
| 308 |
+
this._interceptorUrl = url;
|
| 309 |
+
this._isTargetXHR = isTargetURL(url);
|
| 310 |
+
|
| 311 |
+
if (this._isTargetXHR) {
|
| 312 |
+
interceptionCount++;
|
| 313 |
+
console.log(LOG_PREFIX, `[XHR] 检测到目标请求 (${interceptionCount}):`, url);
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
return originalOpen.apply(this, arguments);
|
| 317 |
+
};
|
| 318 |
+
|
| 319 |
+
/**
|
| 320 |
+
* 处理 XHR 响应
|
| 321 |
+
* @param {XMLHttpRequest} xhr - XHR 对象
|
| 322 |
+
* @param {any} originalValue - 原始响应值
|
| 323 |
+
* @param {string} type - 响应类型
|
| 324 |
+
* @returns {any} 处理后的响应值
|
| 325 |
+
*/
|
| 326 |
+
const handleXHRResponse = (xhr, originalValue, type = 'text') => {
|
| 327 |
+
if (!xhr._isTargetXHR || xhr.readyState !== 4 || xhr.status !== 200) {
|
| 328 |
+
return originalValue;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
const cacheKey = '_modifiedResponseCache_' + type;
|
| 332 |
+
|
| 333 |
+
if (xhr[cacheKey] === undefined) {
|
| 334 |
+
const originalText = (type === 'text' || typeof originalValue !== 'object' || originalValue === null)
|
| 335 |
+
? String(originalValue || '')
|
| 336 |
+
: JSON.stringify(originalValue);
|
| 337 |
+
|
| 338 |
+
xhr[cacheKey] = modifyResponseBody(originalText, xhr._interceptorUrl);
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
const cachedResponse = xhr[cacheKey];
|
| 342 |
+
|
| 343 |
+
try {
|
| 344 |
+
if (type === 'json' && typeof cachedResponse === 'string') {
|
| 345 |
+
const textToParse = cachedResponse.replace(ANTI_HIJACK_PREFIX, '');
|
| 346 |
+
return textToParse ? JSON.parse(textToParse) : null;
|
| 347 |
+
}
|
| 348 |
+
} catch (e) {
|
| 349 |
+
console.error(LOG_PREFIX, '[XHR] 解析缓存的 JSON 时出错:', e);
|
| 350 |
+
return originalValue;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
return cachedResponse;
|
| 354 |
+
};
|
| 355 |
+
|
| 356 |
+
// 重写 responseText 属性
|
| 357 |
+
if (originalResponseTextDescriptor?.get) {
|
| 358 |
+
Object.defineProperty(xhrProto, 'responseText', {
|
| 359 |
+
get: function() {
|
| 360 |
+
const originalText = originalResponseTextDescriptor.get.call(this);
|
| 361 |
+
|
| 362 |
+
if (this.responseType && this.responseType !== 'text' && this.responseType !== "") {
|
| 363 |
+
return originalText;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
return handleXHRResponse(this, originalText, 'text');
|
| 367 |
+
},
|
| 368 |
+
configurable: true
|
| 369 |
+
});
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
// 重写 response 属性
|
| 373 |
+
if (originalResponseDescriptor?.get) {
|
| 374 |
+
Object.defineProperty(xhrProto, 'response', {
|
| 375 |
+
get: function() {
|
| 376 |
+
const originalResponse = originalResponseDescriptor.get.call(this);
|
| 377 |
+
|
| 378 |
+
if (this.responseType === 'json') {
|
| 379 |
+
return handleXHRResponse(this, originalResponse, 'json');
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
if (!this.responseType || this.responseType === 'text' || this.responseType === "") {
|
| 383 |
+
return handleXHRResponse(this, originalResponse, 'text');
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
return originalResponse;
|
| 387 |
+
},
|
| 388 |
+
configurable: true
|
| 389 |
+
});
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
console.log(LOG_PREFIX, '脚本已激活,Fetch 和 XHR 拦截已启用');
|
| 393 |
+
})();
|
AIStudioProxyAPI/browser_utils/operations.py
ADDED
|
@@ -0,0 +1,783 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- browser_utils/operations.py ---
|
| 2 |
+
# 浏览器页面操作相关功能模块
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import time
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Optional, Any, List, Dict, Callable, Set
|
| 11 |
+
|
| 12 |
+
from playwright.async_api import Page as AsyncPage, Locator, Error as PlaywrightAsyncError
|
| 13 |
+
|
| 14 |
+
# 导入配置和模型
|
| 15 |
+
from config import *
|
| 16 |
+
from models import ClientDisconnectedError
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger("AIStudioProxyServer")
|
| 19 |
+
|
| 20 |
+
async def get_raw_text_content(response_element: Locator, previous_text: str, req_id: str) -> str:
|
| 21 |
+
"""从响应元素获取原始文本内容"""
|
| 22 |
+
raw_text = previous_text
|
| 23 |
+
try:
|
| 24 |
+
await response_element.wait_for(state='attached', timeout=1000)
|
| 25 |
+
pre_element = response_element.locator('pre').last
|
| 26 |
+
pre_found_and_visible = False
|
| 27 |
+
try:
|
| 28 |
+
await pre_element.wait_for(state='visible', timeout=250)
|
| 29 |
+
pre_found_and_visible = True
|
| 30 |
+
except PlaywrightAsyncError:
|
| 31 |
+
pass
|
| 32 |
+
|
| 33 |
+
if pre_found_and_visible:
|
| 34 |
+
try:
|
| 35 |
+
raw_text = await pre_element.inner_text(timeout=500)
|
| 36 |
+
except PlaywrightAsyncError as pre_err:
|
| 37 |
+
if DEBUG_LOGS_ENABLED:
|
| 38 |
+
logger.debug(f"[{req_id}] (获取原始文本) 获取 pre 元素内部文本失败: {pre_err}")
|
| 39 |
+
else:
|
| 40 |
+
try:
|
| 41 |
+
raw_text = await response_element.inner_text(timeout=500)
|
| 42 |
+
except PlaywrightAsyncError as e_parent:
|
| 43 |
+
if DEBUG_LOGS_ENABLED:
|
| 44 |
+
logger.debug(f"[{req_id}] (获取原始文本) 获取响应元素内部文本失败: {e_parent}")
|
| 45 |
+
except PlaywrightAsyncError as e_parent:
|
| 46 |
+
if DEBUG_LOGS_ENABLED:
|
| 47 |
+
logger.debug(f"[{req_id}] (获取原始文本) 响应元素未准备好: {e_parent}")
|
| 48 |
+
except Exception as e_unexpected:
|
| 49 |
+
logger.warning(f"[{req_id}] (获取原始文本) 意外错误: {e_unexpected}")
|
| 50 |
+
|
| 51 |
+
if raw_text != previous_text:
|
| 52 |
+
if DEBUG_LOGS_ENABLED:
|
| 53 |
+
preview = raw_text[:100].replace('\n', '\\n')
|
| 54 |
+
logger.debug(f"[{req_id}] (获取原始文本) 文本已更新,长度: {len(raw_text)},预览: '{preview}...'")
|
| 55 |
+
return raw_text
|
| 56 |
+
|
| 57 |
+
def _parse_userscript_models(script_content: str):
|
| 58 |
+
"""从油猴脚本中解析模型列表 - 使用JSON解析方式"""
|
| 59 |
+
try:
|
| 60 |
+
# 查找脚本版本号
|
| 61 |
+
version_pattern = r'const\s+SCRIPT_VERSION\s*=\s*[\'"]([^\'"]+)[\'"]'
|
| 62 |
+
version_match = re.search(version_pattern, script_content)
|
| 63 |
+
script_version = version_match.group(1) if version_match else "v1.6"
|
| 64 |
+
|
| 65 |
+
# 查找 MODELS_TO_INJECT 数组的内容
|
| 66 |
+
models_array_pattern = r'const\s+MODELS_TO_INJECT\s*=\s*(\[.*?\]);'
|
| 67 |
+
models_match = re.search(models_array_pattern, script_content, re.DOTALL)
|
| 68 |
+
|
| 69 |
+
if not models_match:
|
| 70 |
+
logger.warning("未找到 MODELS_TO_INJECT 数组")
|
| 71 |
+
return []
|
| 72 |
+
|
| 73 |
+
models_js_code = models_match.group(1)
|
| 74 |
+
|
| 75 |
+
# 将JavaScript数组转换为JSON格式
|
| 76 |
+
# 1. 替换模板字符串中的变量
|
| 77 |
+
models_js_code = models_js_code.replace('${SCRIPT_VERSION}', script_version)
|
| 78 |
+
|
| 79 |
+
# 2. 移除JavaScript注释
|
| 80 |
+
models_js_code = re.sub(r'//.*?$', '', models_js_code, flags=re.MULTILINE)
|
| 81 |
+
|
| 82 |
+
# 3. 将JavaScript对象转换为JSON格式
|
| 83 |
+
# 移除尾随逗号
|
| 84 |
+
models_js_code = re.sub(r',\s*([}\]])', r'\1', models_js_code)
|
| 85 |
+
|
| 86 |
+
# 替换单引号为双引号
|
| 87 |
+
models_js_code = re.sub(r"(\w+):\s*'([^']*)'", r'"\1": "\2"', models_js_code)
|
| 88 |
+
# 替换反引号为双引号
|
| 89 |
+
models_js_code = re.sub(r'(\w+):\s*`([^`]*)`', r'"\1": "\2"', models_js_code)
|
| 90 |
+
# 确保属性名用双引号
|
| 91 |
+
models_js_code = re.sub(r'(\w+):', r'"\1":', models_js_code)
|
| 92 |
+
|
| 93 |
+
# 4. 解析JSON
|
| 94 |
+
import json
|
| 95 |
+
models_data = json.loads(models_js_code)
|
| 96 |
+
|
| 97 |
+
models = []
|
| 98 |
+
for model_obj in models_data:
|
| 99 |
+
if isinstance(model_obj, dict) and 'name' in model_obj:
|
| 100 |
+
models.append({
|
| 101 |
+
'name': model_obj.get('name', ''),
|
| 102 |
+
'displayName': model_obj.get('displayName', ''),
|
| 103 |
+
'description': model_obj.get('description', '')
|
| 104 |
+
})
|
| 105 |
+
|
| 106 |
+
logger.info(f"成功解析 {len(models)} 个模型从油猴脚本")
|
| 107 |
+
return models
|
| 108 |
+
|
| 109 |
+
except Exception as e:
|
| 110 |
+
logger.error(f"解析油猴脚本模型列表失败: {e}")
|
| 111 |
+
return []
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _get_injected_models():
|
| 115 |
+
"""从油猴脚本中获取注入的模型列表,转换为API格式"""
|
| 116 |
+
try:
|
| 117 |
+
# 直接读取环境变量,避免复杂的导入
|
| 118 |
+
enable_injection = os.environ.get('ENABLE_SCRIPT_INJECTION', 'true').lower() in ('true', '1', 'yes')
|
| 119 |
+
|
| 120 |
+
if not enable_injection:
|
| 121 |
+
return []
|
| 122 |
+
|
| 123 |
+
# 获取脚本文件路径
|
| 124 |
+
script_path = os.environ.get('USERSCRIPT_PATH', 'browser_utils/more_modles.js')
|
| 125 |
+
|
| 126 |
+
# 检查脚本文件是否存在
|
| 127 |
+
if not os.path.exists(script_path):
|
| 128 |
+
# 脚本文件不存在,静默返回空列表
|
| 129 |
+
return []
|
| 130 |
+
|
| 131 |
+
# 读取油猴脚本内容
|
| 132 |
+
with open(script_path, 'r', encoding='utf-8') as f:
|
| 133 |
+
script_content = f.read()
|
| 134 |
+
|
| 135 |
+
# 从脚本中解析模型列表
|
| 136 |
+
models = _parse_userscript_models(script_content)
|
| 137 |
+
|
| 138 |
+
if not models:
|
| 139 |
+
return []
|
| 140 |
+
|
| 141 |
+
# 转换为API格式
|
| 142 |
+
injected_models = []
|
| 143 |
+
for model in models:
|
| 144 |
+
model_name = model.get('name', '')
|
| 145 |
+
if not model_name:
|
| 146 |
+
continue # 跳过没有名称的模型
|
| 147 |
+
|
| 148 |
+
if model_name.startswith('models/'):
|
| 149 |
+
simple_id = model_name[7:] # 移除 'models/' 前缀
|
| 150 |
+
else:
|
| 151 |
+
simple_id = model_name
|
| 152 |
+
|
| 153 |
+
display_name = model.get('displayName', model.get('display_name', simple_id))
|
| 154 |
+
description = model.get('description', f'Injected model: {simple_id}')
|
| 155 |
+
|
| 156 |
+
# 注意:不再清理显示名称,保留原始的emoji和版本信息
|
| 157 |
+
|
| 158 |
+
model_entry = {
|
| 159 |
+
"id": simple_id,
|
| 160 |
+
"object": "model",
|
| 161 |
+
"created": int(time.time()),
|
| 162 |
+
"owned_by": "ai_studio_injected",
|
| 163 |
+
"display_name": display_name,
|
| 164 |
+
"description": description,
|
| 165 |
+
"raw_model_path": model_name,
|
| 166 |
+
"default_temperature": 1.0,
|
| 167 |
+
"default_max_output_tokens": 65536,
|
| 168 |
+
"supported_max_output_tokens": 65536,
|
| 169 |
+
"default_top_p": 0.95,
|
| 170 |
+
"injected": True # 标记为注入的模型
|
| 171 |
+
}
|
| 172 |
+
injected_models.append(model_entry)
|
| 173 |
+
|
| 174 |
+
return injected_models
|
| 175 |
+
|
| 176 |
+
except Exception as e:
|
| 177 |
+
# 静默处理错误,不输出日志,返回空列表
|
| 178 |
+
return []
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
async def _handle_model_list_response(response: Any):
|
| 182 |
+
"""处理模型列表响应"""
|
| 183 |
+
# 需要访问全局变量
|
| 184 |
+
import server
|
| 185 |
+
global_model_list_raw_json = getattr(server, 'global_model_list_raw_json', None)
|
| 186 |
+
parsed_model_list = getattr(server, 'parsed_model_list', [])
|
| 187 |
+
model_list_fetch_event = getattr(server, 'model_list_fetch_event', None)
|
| 188 |
+
excluded_model_ids = getattr(server, 'excluded_model_ids', set())
|
| 189 |
+
|
| 190 |
+
if MODELS_ENDPOINT_URL_CONTAINS in response.url and response.ok:
|
| 191 |
+
# 检查是否在登录流程中
|
| 192 |
+
launch_mode = os.environ.get('LAUNCH_MODE', 'debug')
|
| 193 |
+
is_in_login_flow = launch_mode in ['debug'] and not getattr(server, 'is_page_ready', False)
|
| 194 |
+
|
| 195 |
+
if is_in_login_flow:
|
| 196 |
+
# 在登录流程中,静默处理,不输出干扰信息
|
| 197 |
+
pass # 静默处理,避免干扰用户输入
|
| 198 |
+
else:
|
| 199 |
+
logger.info(f"捕获到潜在的模型列表响应来自: {response.url} (状态: {response.status})")
|
| 200 |
+
try:
|
| 201 |
+
data = await response.json()
|
| 202 |
+
models_array_container = None
|
| 203 |
+
if isinstance(data, list) and data:
|
| 204 |
+
if isinstance(data[0], list) and data[0] and isinstance(data[0][0], list):
|
| 205 |
+
if not is_in_login_flow:
|
| 206 |
+
logger.info("检测到三层列表结构 data[0][0] is list. models_array_container 设置为 data[0]。")
|
| 207 |
+
models_array_container = data[0]
|
| 208 |
+
elif isinstance(data[0], list) and data[0] and isinstance(data[0][0], str):
|
| 209 |
+
if not is_in_login_flow:
|
| 210 |
+
logger.info("检测到两层列表结构 data[0][0] is str. models_array_container 设置为 data。")
|
| 211 |
+
models_array_container = data
|
| 212 |
+
elif isinstance(data[0], dict):
|
| 213 |
+
if not is_in_login_flow:
|
| 214 |
+
logger.info("检测到根列表,元素为字典。直接使用 data 作为 models_array_container。")
|
| 215 |
+
models_array_container = data
|
| 216 |
+
else:
|
| 217 |
+
logger.warning(f"未知的列表嵌套结构。data[0] 类型: {type(data[0]) if data else 'N/A'}。data[0] 预览: {str(data[0])[:200] if data else 'N/A'}")
|
| 218 |
+
elif isinstance(data, dict):
|
| 219 |
+
if 'data' in data and isinstance(data['data'], list):
|
| 220 |
+
models_array_container = data['data']
|
| 221 |
+
elif 'models' in data and isinstance(data['models'], list):
|
| 222 |
+
models_array_container = data['models']
|
| 223 |
+
else:
|
| 224 |
+
for key, value in data.items():
|
| 225 |
+
if isinstance(value, list) and len(value) > 0 and isinstance(value[0], (dict, list)):
|
| 226 |
+
models_array_container = value
|
| 227 |
+
logger.info(f"模型列表数据在 '{key}' 键下通过启发式搜索找到。")
|
| 228 |
+
break
|
| 229 |
+
if models_array_container is None:
|
| 230 |
+
logger.warning("在字典响应中未能自动定位模型列表数组。")
|
| 231 |
+
if model_list_fetch_event and not model_list_fetch_event.is_set():
|
| 232 |
+
model_list_fetch_event.set()
|
| 233 |
+
return
|
| 234 |
+
else:
|
| 235 |
+
logger.warning(f"接收到的模型列表数据既不是列表也不是字典: {type(data)}")
|
| 236 |
+
if model_list_fetch_event and not model_list_fetch_event.is_set():
|
| 237 |
+
model_list_fetch_event.set()
|
| 238 |
+
return
|
| 239 |
+
|
| 240 |
+
if models_array_container is not None:
|
| 241 |
+
new_parsed_list = []
|
| 242 |
+
for entry_in_container in models_array_container:
|
| 243 |
+
model_fields_list = None
|
| 244 |
+
if isinstance(entry_in_container, dict):
|
| 245 |
+
potential_id = entry_in_container.get('id', entry_in_container.get('model_id', entry_in_container.get('modelId')))
|
| 246 |
+
if potential_id:
|
| 247 |
+
model_fields_list = entry_in_container
|
| 248 |
+
else:
|
| 249 |
+
model_fields_list = list(entry_in_container.values())
|
| 250 |
+
elif isinstance(entry_in_container, list):
|
| 251 |
+
model_fields_list = entry_in_container
|
| 252 |
+
else:
|
| 253 |
+
logger.debug(f"Skipping entry of unknown type: {type(entry_in_container)}")
|
| 254 |
+
continue
|
| 255 |
+
|
| 256 |
+
if not model_fields_list:
|
| 257 |
+
logger.debug("Skipping entry because model_fields_list is empty or None.")
|
| 258 |
+
continue
|
| 259 |
+
|
| 260 |
+
model_id_path_str = None
|
| 261 |
+
display_name_candidate = ""
|
| 262 |
+
description_candidate = "N/A"
|
| 263 |
+
default_max_output_tokens_val = None
|
| 264 |
+
default_top_p_val = None
|
| 265 |
+
default_temperature_val = 1.0
|
| 266 |
+
supported_max_output_tokens_val = None
|
| 267 |
+
current_model_id_for_log = "UnknownModelYet"
|
| 268 |
+
|
| 269 |
+
try:
|
| 270 |
+
if isinstance(model_fields_list, list):
|
| 271 |
+
if not (len(model_fields_list) > 0 and isinstance(model_fields_list[0], (str, int, float))):
|
| 272 |
+
logger.debug(f"Skipping list-based model_fields due to invalid first element: {str(model_fields_list)[:100]}")
|
| 273 |
+
continue
|
| 274 |
+
model_id_path_str = str(model_fields_list[0])
|
| 275 |
+
current_model_id_for_log = model_id_path_str.split('/')[-1] if model_id_path_str and '/' in model_id_path_str else model_id_path_str
|
| 276 |
+
display_name_candidate = str(model_fields_list[3]) if len(model_fields_list) > 3 else ""
|
| 277 |
+
description_candidate = str(model_fields_list[4]) if len(model_fields_list) > 4 else "N/A"
|
| 278 |
+
|
| 279 |
+
if len(model_fields_list) > 6 and model_fields_list[6] is not None:
|
| 280 |
+
try:
|
| 281 |
+
val_int = int(model_fields_list[6])
|
| 282 |
+
default_max_output_tokens_val = val_int
|
| 283 |
+
supported_max_output_tokens_val = val_int
|
| 284 |
+
except (ValueError, TypeError):
|
| 285 |
+
logger.warning(f"模型 {current_model_id_for_log}: 无法将列表索引6的值 '{model_fields_list[6]}' 解析为 max_output_tokens。")
|
| 286 |
+
|
| 287 |
+
if len(model_fields_list) > 9 and model_fields_list[9] is not None:
|
| 288 |
+
try:
|
| 289 |
+
raw_top_p = float(model_fields_list[9])
|
| 290 |
+
if not (0.0 <= raw_top_p <= 1.0):
|
| 291 |
+
logger.warning(f"模型 {current_model_id_for_log}: 原始 top_p值 {raw_top_p} (来自列表索引9) 超出 [0,1] 范围,将裁剪。")
|
| 292 |
+
default_top_p_val = max(0.0, min(1.0, raw_top_p))
|
| 293 |
+
else:
|
| 294 |
+
default_top_p_val = raw_top_p
|
| 295 |
+
except (ValueError, TypeError):
|
| 296 |
+
logger.warning(f"模型 {current_model_id_for_log}: 无法将列表索引9的值 '{model_fields_list[9]}' 解析为 top_p。")
|
| 297 |
+
|
| 298 |
+
elif isinstance(model_fields_list, dict):
|
| 299 |
+
model_id_path_str = str(model_fields_list.get('id', model_fields_list.get('model_id', model_fields_list.get('modelId'))))
|
| 300 |
+
current_model_id_for_log = model_id_path_str.split('/')[-1] if model_id_path_str and '/' in model_id_path_str else model_id_path_str
|
| 301 |
+
display_name_candidate = str(model_fields_list.get('displayName', model_fields_list.get('display_name', model_fields_list.get('name', ''))))
|
| 302 |
+
description_candidate = str(model_fields_list.get('description', "N/A"))
|
| 303 |
+
|
| 304 |
+
mot_parsed = model_fields_list.get('maxOutputTokens', model_fields_list.get('defaultMaxOutputTokens', model_fields_list.get('outputTokenLimit')))
|
| 305 |
+
if mot_parsed is not None:
|
| 306 |
+
try:
|
| 307 |
+
val_int = int(mot_parsed)
|
| 308 |
+
default_max_output_tokens_val = val_int
|
| 309 |
+
supported_max_output_tokens_val = val_int
|
| 310 |
+
except (ValueError, TypeError):
|
| 311 |
+
logger.warning(f"模型 {current_model_id_for_log}: 无法将字典值 '{mot_parsed}' 解析为 max_output_tokens。")
|
| 312 |
+
|
| 313 |
+
top_p_parsed = model_fields_list.get('topP', model_fields_list.get('defaultTopP'))
|
| 314 |
+
if top_p_parsed is not None:
|
| 315 |
+
try:
|
| 316 |
+
raw_top_p = float(top_p_parsed)
|
| 317 |
+
if not (0.0 <= raw_top_p <= 1.0):
|
| 318 |
+
logger.warning(f"模型 {current_model_id_for_log}: 原始 top_p值 {raw_top_p} (来自字典) 超出 [0,1] 范围,将裁剪。")
|
| 319 |
+
default_top_p_val = max(0.0, min(1.0, raw_top_p))
|
| 320 |
+
else:
|
| 321 |
+
default_top_p_val = raw_top_p
|
| 322 |
+
except (ValueError, TypeError):
|
| 323 |
+
logger.warning(f"模型 {current_model_id_for_log}: 无法将字典值 '{top_p_parsed}' 解析为 top_p。")
|
| 324 |
+
|
| 325 |
+
temp_parsed = model_fields_list.get('temperature', model_fields_list.get('defaultTemperature'))
|
| 326 |
+
if temp_parsed is not None:
|
| 327 |
+
try:
|
| 328 |
+
default_temperature_val = float(temp_parsed)
|
| 329 |
+
except (ValueError, TypeError):
|
| 330 |
+
logger.warning(f"模型 {current_model_id_for_log}: 无法将字典值 '{temp_parsed}' 解析为 temperature。")
|
| 331 |
+
else:
|
| 332 |
+
logger.debug(f"Skipping entry because model_fields_list is not list or dict: {type(model_fields_list)}")
|
| 333 |
+
continue
|
| 334 |
+
except Exception as e_parse_fields:
|
| 335 |
+
logger.error(f"解析模型字段时出错 for entry {str(entry_in_container)[:100]}: {e_parse_fields}")
|
| 336 |
+
continue
|
| 337 |
+
|
| 338 |
+
if model_id_path_str and model_id_path_str.lower() != "none":
|
| 339 |
+
simple_model_id_str = model_id_path_str.split('/')[-1] if '/' in model_id_path_str else model_id_path_str
|
| 340 |
+
if simple_model_id_str in excluded_model_ids:
|
| 341 |
+
if not is_in_login_flow:
|
| 342 |
+
logger.info(f"模型 '{simple_model_id_str}' 在排除列表 excluded_model_ids 中,已跳过。")
|
| 343 |
+
continue
|
| 344 |
+
|
| 345 |
+
final_display_name_str = display_name_candidate if display_name_candidate else simple_model_id_str.replace("-", " ").title()
|
| 346 |
+
model_entry_dict = {
|
| 347 |
+
"id": simple_model_id_str,
|
| 348 |
+
"object": "model",
|
| 349 |
+
"created": int(time.time()),
|
| 350 |
+
"owned_by": "ai_studio",
|
| 351 |
+
"display_name": final_display_name_str,
|
| 352 |
+
"description": description_candidate,
|
| 353 |
+
"raw_model_path": model_id_path_str,
|
| 354 |
+
"default_temperature": default_temperature_val,
|
| 355 |
+
"default_max_output_tokens": default_max_output_tokens_val,
|
| 356 |
+
"supported_max_output_tokens": supported_max_output_tokens_val,
|
| 357 |
+
"default_top_p": default_top_p_val
|
| 358 |
+
}
|
| 359 |
+
new_parsed_list.append(model_entry_dict)
|
| 360 |
+
else:
|
| 361 |
+
logger.debug(f"Skipping entry due to invalid model_id_path: {model_id_path_str} from entry {str(entry_in_container)[:100]}")
|
| 362 |
+
|
| 363 |
+
if new_parsed_list:
|
| 364 |
+
# 检查是否已经有通过网络拦截注入的模型
|
| 365 |
+
has_network_injected_models = False
|
| 366 |
+
if models_array_container:
|
| 367 |
+
for entry_in_container in models_array_container:
|
| 368 |
+
if isinstance(entry_in_container, list) and len(entry_in_container) > 10:
|
| 369 |
+
# 检查是否有网络注入标记
|
| 370 |
+
if "__NETWORK_INJECTED__" in entry_in_container:
|
| 371 |
+
has_network_injected_models = True
|
| 372 |
+
break
|
| 373 |
+
|
| 374 |
+
if has_network_injected_models and not is_in_login_flow:
|
| 375 |
+
logger.info("检测到网络拦截已注入模型")
|
| 376 |
+
|
| 377 |
+
# 注意:不再在后端添加注入模型
|
| 378 |
+
# 因为如果前端没有通过网络拦截注入,说明前端页面上没有这些模型
|
| 379 |
+
# 后端返回这些模型也无法实际使用,所以只依赖网络拦截注入
|
| 380 |
+
|
| 381 |
+
server.parsed_model_list = sorted(new_parsed_list, key=lambda m: m.get('display_name', '').lower())
|
| 382 |
+
server.global_model_list_raw_json = json.dumps({"data": server.parsed_model_list, "object": "list"})
|
| 383 |
+
if DEBUG_LOGS_ENABLED:
|
| 384 |
+
log_output = f"成功解析和更新模型列表。总共解析模型数: {len(server.parsed_model_list)}.\n"
|
| 385 |
+
for i, item in enumerate(server.parsed_model_list[:min(3, len(server.parsed_model_list))]):
|
| 386 |
+
log_output += f" Model {i+1}: ID={item.get('id')}, Name={item.get('display_name')}, Temp={item.get('default_temperature')}, MaxTokDef={item.get('default_max_output_tokens')}, MaxTokSup={item.get('supported_max_output_tokens')}, TopP={item.get('default_top_p')}\n"
|
| 387 |
+
logger.info(log_output)
|
| 388 |
+
if model_list_fetch_event and not model_list_fetch_event.is_set():
|
| 389 |
+
model_list_fetch_event.set()
|
| 390 |
+
elif not server.parsed_model_list:
|
| 391 |
+
logger.warning("解析后模型列表仍然为空。")
|
| 392 |
+
if model_list_fetch_event and not model_list_fetch_event.is_set():
|
| 393 |
+
model_list_fetch_event.set()
|
| 394 |
+
else:
|
| 395 |
+
logger.warning("models_array_container 为 None,无法解析模型列表。")
|
| 396 |
+
if model_list_fetch_event and not model_list_fetch_event.is_set():
|
| 397 |
+
model_list_fetch_event.set()
|
| 398 |
+
except json.JSONDecodeError as json_err:
|
| 399 |
+
logger.error(f"解析模型列表JSON失败: {json_err}. 响应 (前500字): {await response.text()[:500]}")
|
| 400 |
+
except Exception as e_handle_list_resp:
|
| 401 |
+
logger.exception(f"处理模型列表响应时发生未知错误: {e_handle_list_resp}")
|
| 402 |
+
finally:
|
| 403 |
+
if model_list_fetch_event and not model_list_fetch_event.is_set():
|
| 404 |
+
logger.info("处理模型列表响应结束,强制设置 model_list_fetch_event。")
|
| 405 |
+
model_list_fetch_event.set()
|
| 406 |
+
|
| 407 |
+
async def detect_and_extract_page_error(page: AsyncPage, req_id: str) -> Optional[str]:
|
| 408 |
+
"""检测并提取页面错误"""
|
| 409 |
+
error_toast_locator = page.locator(ERROR_TOAST_SELECTOR).last
|
| 410 |
+
try:
|
| 411 |
+
await error_toast_locator.wait_for(state='visible', timeout=500)
|
| 412 |
+
message_locator = error_toast_locator.locator('span.content-text')
|
| 413 |
+
error_message = await message_locator.text_content(timeout=500)
|
| 414 |
+
if error_message:
|
| 415 |
+
logger.error(f"[{req_id}] 检测到并提取错误消息: {error_message}")
|
| 416 |
+
return error_message.strip()
|
| 417 |
+
else:
|
| 418 |
+
logger.warning(f"[{req_id}] 检测到错误提示框,但无法提取消息。")
|
| 419 |
+
return "检测到错误提示框,但无法提取特定消息。"
|
| 420 |
+
except PlaywrightAsyncError:
|
| 421 |
+
return None
|
| 422 |
+
except Exception as e:
|
| 423 |
+
logger.warning(f"[{req_id}] 检查页面错误时出错: {e}")
|
| 424 |
+
return None
|
| 425 |
+
|
| 426 |
+
async def save_error_snapshot(error_name: str = 'error'):
|
| 427 |
+
"""保存错误快照"""
|
| 428 |
+
import server
|
| 429 |
+
name_parts = error_name.split('_')
|
| 430 |
+
req_id = name_parts[-1] if len(name_parts) > 1 and len(name_parts[-1]) == 7 else None
|
| 431 |
+
base_error_name = error_name if not req_id else '_'.join(name_parts[:-1])
|
| 432 |
+
log_prefix = f"[{req_id}]" if req_id else "[无请求ID]"
|
| 433 |
+
page_to_snapshot = server.page_instance
|
| 434 |
+
|
| 435 |
+
if not server.browser_instance or not server.browser_instance.is_connected() or not page_to_snapshot or page_to_snapshot.is_closed():
|
| 436 |
+
logger.warning(f"{log_prefix} 无法保存快照 ({base_error_name}),浏览器/页面不可用。")
|
| 437 |
+
return
|
| 438 |
+
|
| 439 |
+
logger.info(f"{log_prefix} 尝试保存错误快照 ({base_error_name})...")
|
| 440 |
+
timestamp = int(time.time() * 1000)
|
| 441 |
+
error_dir = os.path.join(os.path.dirname(__file__), '..', 'errors_py')
|
| 442 |
+
|
| 443 |
+
try:
|
| 444 |
+
os.makedirs(error_dir, exist_ok=True)
|
| 445 |
+
filename_suffix = f"{req_id}_{timestamp}" if req_id else f"{timestamp}"
|
| 446 |
+
filename_base = f"{base_error_name}_{filename_suffix}"
|
| 447 |
+
screenshot_path = os.path.join(error_dir, f"{filename_base}.png")
|
| 448 |
+
html_path = os.path.join(error_dir, f"{filename_base}.html")
|
| 449 |
+
|
| 450 |
+
try:
|
| 451 |
+
await page_to_snapshot.screenshot(path=screenshot_path, full_page=True, timeout=15000)
|
| 452 |
+
logger.info(f"{log_prefix} 快照已保存到: {screenshot_path}")
|
| 453 |
+
except Exception as ss_err:
|
| 454 |
+
logger.error(f"{log_prefix} 保存屏幕截图失败 ({base_error_name}): {ss_err}")
|
| 455 |
+
|
| 456 |
+
try:
|
| 457 |
+
content = await page_to_snapshot.content()
|
| 458 |
+
f = None
|
| 459 |
+
try:
|
| 460 |
+
f = open(html_path, 'w', encoding='utf-8')
|
| 461 |
+
f.write(content)
|
| 462 |
+
logger.info(f"{log_prefix} HTML 已保存到: {html_path}")
|
| 463 |
+
except Exception as write_err:
|
| 464 |
+
logger.error(f"{log_prefix} 保存 HTML 失败 ({base_error_name}): {write_err}")
|
| 465 |
+
finally:
|
| 466 |
+
if f:
|
| 467 |
+
try:
|
| 468 |
+
f.close()
|
| 469 |
+
logger.debug(f"{log_prefix} HTML 文件已正确关闭")
|
| 470 |
+
except Exception as close_err:
|
| 471 |
+
logger.error(f"{log_prefix} 关闭 HTML 文件时出错: {close_err}")
|
| 472 |
+
except Exception as html_err:
|
| 473 |
+
logger.error(f"{log_prefix} 获取页面内容失败 ({base_error_name}): {html_err}")
|
| 474 |
+
except Exception as dir_err:
|
| 475 |
+
logger.error(f"{log_prefix} 创建错误目录或保存快照时发生其他错误 ({base_error_name}): {dir_err}")
|
| 476 |
+
|
| 477 |
+
async def get_response_via_edit_button(
|
| 478 |
+
page: AsyncPage,
|
| 479 |
+
req_id: str,
|
| 480 |
+
check_client_disconnected: Callable
|
| 481 |
+
) -> Optional[str]:
|
| 482 |
+
"""通过编辑按钮获取响应"""
|
| 483 |
+
logger.info(f"[{req_id}] (Helper) 尝试通过编辑按钮获取响应...")
|
| 484 |
+
last_message_container = page.locator('ms-chat-turn').last
|
| 485 |
+
edit_button = last_message_container.get_by_label("Edit")
|
| 486 |
+
finish_edit_button = last_message_container.get_by_label("Stop editing")
|
| 487 |
+
autosize_textarea_locator = last_message_container.locator('ms-autosize-textarea')
|
| 488 |
+
actual_textarea_locator = autosize_textarea_locator.locator('textarea')
|
| 489 |
+
|
| 490 |
+
try:
|
| 491 |
+
logger.info(f"[{req_id}] - 尝试悬停最后一条消息以显示 'Edit' 按钮...")
|
| 492 |
+
try:
|
| 493 |
+
# 对消息容器执行悬停操作
|
| 494 |
+
await last_message_container.hover(timeout=CLICK_TIMEOUT_MS / 2) # 使用一半的点击超时作为悬停超时
|
| 495 |
+
await asyncio.sleep(0.3) # 等待悬停效果生效
|
| 496 |
+
check_client_disconnected("编辑响应 - 悬停后: ")
|
| 497 |
+
except Exception as hover_err:
|
| 498 |
+
logger.warning(f"[{req_id}] - (get_response_via_edit_button) 悬停最后一条消息失败 (忽略): {type(hover_err).__name__}")
|
| 499 |
+
# 即使悬停失败,也继续尝试后续操作,Playwright的expect_async可能会处理
|
| 500 |
+
|
| 501 |
+
logger.info(f"[{req_id}] - 定位并点击 'Edit' 按钮...")
|
| 502 |
+
try:
|
| 503 |
+
from playwright.async_api import expect as expect_async
|
| 504 |
+
await expect_async(edit_button).to_be_visible(timeout=CLICK_TIMEOUT_MS)
|
| 505 |
+
check_client_disconnected("编辑响应 - 'Edit' 按钮可见后: ")
|
| 506 |
+
await edit_button.click(timeout=CLICK_TIMEOUT_MS)
|
| 507 |
+
logger.info(f"[{req_id}] - 'Edit' 按钮已点击。")
|
| 508 |
+
except Exception as edit_btn_err:
|
| 509 |
+
logger.error(f"[{req_id}] - 'Edit' 按钮不可见或点击失败: {edit_btn_err}")
|
| 510 |
+
await save_error_snapshot(f"edit_response_edit_button_failed_{req_id}")
|
| 511 |
+
return None
|
| 512 |
+
|
| 513 |
+
check_client_disconnected("编辑响应 - 点击 'Edit' 按钮后: ")
|
| 514 |
+
await asyncio.sleep(0.3)
|
| 515 |
+
check_client_disconnected("编辑响应 - 点击 'Edit' 按钮后延时后: ")
|
| 516 |
+
|
| 517 |
+
logger.info(f"[{req_id}] - 从文本区域获取内容...")
|
| 518 |
+
response_content = None
|
| 519 |
+
textarea_failed = False
|
| 520 |
+
|
| 521 |
+
try:
|
| 522 |
+
await expect_async(autosize_textarea_locator).to_be_visible(timeout=CLICK_TIMEOUT_MS)
|
| 523 |
+
check_client_disconnected("编辑响应 - autosize-textarea 可见后: ")
|
| 524 |
+
|
| 525 |
+
try:
|
| 526 |
+
data_value_content = await autosize_textarea_locator.get_attribute("data-value")
|
| 527 |
+
check_client_disconnected("编辑响应 - get_attribute data-value 后: ")
|
| 528 |
+
if data_value_content is not None:
|
| 529 |
+
response_content = str(data_value_content)
|
| 530 |
+
logger.info(f"[{req_id}] - 从 data-value 获取内容成功。")
|
| 531 |
+
except Exception as data_val_err:
|
| 532 |
+
logger.warning(f"[{req_id}] - 获取 data-value 失败: {data_val_err}")
|
| 533 |
+
check_client_disconnected("编辑响应 - get_attribute data-value 错误后: ")
|
| 534 |
+
|
| 535 |
+
if response_content is None:
|
| 536 |
+
logger.info(f"[{req_id}] - data-value 获取失败或为None,尝试从内部 textarea 获取 input_value...")
|
| 537 |
+
try:
|
| 538 |
+
await expect_async(actual_textarea_locator).to_be_visible(timeout=CLICK_TIMEOUT_MS/2)
|
| 539 |
+
input_val_content = await actual_textarea_locator.input_value(timeout=CLICK_TIMEOUT_MS/2)
|
| 540 |
+
check_client_disconnected("编辑响应 - input_value 后: ")
|
| 541 |
+
if input_val_content is not None:
|
| 542 |
+
response_content = str(input_val_content)
|
| 543 |
+
logger.info(f"[{req_id}] - 从 input_value 获取内容成功。")
|
| 544 |
+
except Exception as input_val_err:
|
| 545 |
+
logger.warning(f"[{req_id}] - 获取 input_value 也失败: {input_val_err}")
|
| 546 |
+
check_client_disconnected("编辑响应 - input_value 错误后: ")
|
| 547 |
+
|
| 548 |
+
if response_content is not None:
|
| 549 |
+
response_content = response_content.strip()
|
| 550 |
+
content_preview = response_content[:100].replace('\\n', '\\\\n')
|
| 551 |
+
logger.info(f"[{req_id}] - ✅ 最终获取内容 (长度={len(response_content)}): '{content_preview}...'")
|
| 552 |
+
else:
|
| 553 |
+
logger.warning(f"[{req_id}] - 所有方法 (data-value, input_value) 内容获取均失败或返回 None。")
|
| 554 |
+
textarea_failed = True
|
| 555 |
+
|
| 556 |
+
except Exception as textarea_err:
|
| 557 |
+
logger.error(f"[{req_id}] - 定位或处理文本区域时失败: {textarea_err}")
|
| 558 |
+
textarea_failed = True
|
| 559 |
+
response_content = None
|
| 560 |
+
check_client_disconnected("编辑响应 - 获取文本区域错误后: ")
|
| 561 |
+
|
| 562 |
+
if not textarea_failed:
|
| 563 |
+
logger.info(f"[{req_id}] - 定位并点击 'Stop editing' 按钮...")
|
| 564 |
+
try:
|
| 565 |
+
await expect_async(finish_edit_button).to_be_visible(timeout=CLICK_TIMEOUT_MS)
|
| 566 |
+
check_client_disconnected("编辑响应 - 'Stop editing' 按钮可见后: ")
|
| 567 |
+
await finish_edit_button.click(timeout=CLICK_TIMEOUT_MS)
|
| 568 |
+
logger.info(f"[{req_id}] - 'Stop editing' 按钮已点击。")
|
| 569 |
+
except Exception as finish_btn_err:
|
| 570 |
+
logger.warning(f"[{req_id}] - 'Stop editing' 按钮不可见或点击失败: {finish_btn_err}")
|
| 571 |
+
await save_error_snapshot(f"edit_response_finish_button_failed_{req_id}")
|
| 572 |
+
check_client_disconnected("编辑响应 - 点击 'Stop editing' 后: ")
|
| 573 |
+
await asyncio.sleep(0.2)
|
| 574 |
+
check_client_disconnected("编辑响应 - 点击 'Stop editing' 后延时后: ")
|
| 575 |
+
else:
|
| 576 |
+
logger.info(f"[{req_id}] - 跳过点击 'Stop editing' 按钮,因为文本区域读取失败。")
|
| 577 |
+
|
| 578 |
+
return response_content
|
| 579 |
+
|
| 580 |
+
except ClientDisconnectedError:
|
| 581 |
+
logger.info(f"[{req_id}] (Helper Edit) 客户端断开连接。")
|
| 582 |
+
raise
|
| 583 |
+
except Exception as e:
|
| 584 |
+
logger.exception(f"[{req_id}] 通过编辑按钮获取响应过程中发生意外错误")
|
| 585 |
+
await save_error_snapshot(f"edit_response_unexpected_error_{req_id}")
|
| 586 |
+
return None
|
| 587 |
+
|
| 588 |
+
async def get_response_via_copy_button(
|
| 589 |
+
page: AsyncPage,
|
| 590 |
+
req_id: str,
|
| 591 |
+
check_client_disconnected: Callable
|
| 592 |
+
) -> Optional[str]:
|
| 593 |
+
"""通过复制按钮获取响应"""
|
| 594 |
+
logger.info(f"[{req_id}] (Helper) 尝试通过复制按钮获取响应...")
|
| 595 |
+
last_message_container = page.locator('ms-chat-turn').last
|
| 596 |
+
more_options_button = last_message_container.get_by_label("Open options")
|
| 597 |
+
copy_markdown_button = page.get_by_role("menuitem", name="Copy markdown")
|
| 598 |
+
|
| 599 |
+
try:
|
| 600 |
+
logger.info(f"[{req_id}] - 尝试悬停最后一条消息以显示选项...")
|
| 601 |
+
await last_message_container.hover(timeout=CLICK_TIMEOUT_MS)
|
| 602 |
+
check_client_disconnected("复制响应 - 悬停后: ")
|
| 603 |
+
await asyncio.sleep(0.5)
|
| 604 |
+
check_client_disconnected("复制响应 - 悬停后延时后: ")
|
| 605 |
+
logger.info(f"[{req_id}] - 已悬停。")
|
| 606 |
+
|
| 607 |
+
logger.info(f"[{req_id}] - 定位并点击 '更多选项' 按钮...")
|
| 608 |
+
try:
|
| 609 |
+
from playwright.async_api import expect as expect_async
|
| 610 |
+
await expect_async(more_options_button).to_be_visible(timeout=CLICK_TIMEOUT_MS)
|
| 611 |
+
check_client_disconnected("复制响应 - 更多选项按钮可见后: ")
|
| 612 |
+
await more_options_button.click(timeout=CLICK_TIMEOUT_MS)
|
| 613 |
+
logger.info(f"[{req_id}] - '更多选项' 已点击 (通过 get_by_label)。")
|
| 614 |
+
except Exception as more_opts_err:
|
| 615 |
+
logger.error(f"[{req_id}] - '更多选项' 按钮 (通过 get_by_label) 不可见或点击失败: {more_opts_err}")
|
| 616 |
+
await save_error_snapshot(f"copy_response_more_options_failed_{req_id}")
|
| 617 |
+
return None
|
| 618 |
+
|
| 619 |
+
check_client_disconnected("复制响应 - 点击更多选项后: ")
|
| 620 |
+
await asyncio.sleep(0.5)
|
| 621 |
+
check_client_disconnected("复制响应 - 点击更多���项后延时后: ")
|
| 622 |
+
|
| 623 |
+
logger.info(f"[{req_id}] - 定位并点击 '复制 Markdown' 按钮...")
|
| 624 |
+
copy_success = False
|
| 625 |
+
try:
|
| 626 |
+
await expect_async(copy_markdown_button).to_be_visible(timeout=CLICK_TIMEOUT_MS)
|
| 627 |
+
check_client_disconnected("复制响应 - 复制按钮可见后: ")
|
| 628 |
+
await copy_markdown_button.click(timeout=CLICK_TIMEOUT_MS, force=True)
|
| 629 |
+
copy_success = True
|
| 630 |
+
logger.info(f"[{req_id}] - 已点击 '复制 Markdown' (通过 get_by_role)。")
|
| 631 |
+
except Exception as copy_err:
|
| 632 |
+
logger.error(f"[{req_id}] - '复制 Markdown' 按钮 (通过 get_by_role) 点击失败: {copy_err}")
|
| 633 |
+
await save_error_snapshot(f"copy_response_copy_button_failed_{req_id}")
|
| 634 |
+
return None
|
| 635 |
+
|
| 636 |
+
if not copy_success:
|
| 637 |
+
logger.error(f"[{req_id}] - 未能点击 '复制 Markdown' 按钮。")
|
| 638 |
+
return None
|
| 639 |
+
|
| 640 |
+
check_client_disconnected("复制响应 - 点击复制按钮后: ")
|
| 641 |
+
await asyncio.sleep(0.5)
|
| 642 |
+
check_client_disconnected("复制响应 - 点击复制按钮后延时后: ")
|
| 643 |
+
|
| 644 |
+
logger.info(f"[{req_id}] - 正在读取剪贴板内容...")
|
| 645 |
+
try:
|
| 646 |
+
clipboard_content = await page.evaluate('navigator.clipboard.readText()')
|
| 647 |
+
check_client_disconnected("复制响应 - 读取剪贴板后: ")
|
| 648 |
+
if clipboard_content:
|
| 649 |
+
content_preview = clipboard_content[:100].replace('\n', '\\\\n')
|
| 650 |
+
logger.info(f"[{req_id}] - ✅ 成功获取剪贴板内容 (长度={len(clipboard_content)}): '{content_preview}...'")
|
| 651 |
+
return clipboard_content
|
| 652 |
+
else:
|
| 653 |
+
logger.error(f"[{req_id}] - 剪贴板内容为空。")
|
| 654 |
+
return None
|
| 655 |
+
except Exception as clipboard_err:
|
| 656 |
+
if "clipboard-read" in str(clipboard_err):
|
| 657 |
+
logger.error(f"[{req_id}] - 读取剪贴板失败: 可能是权限问题。错误: {clipboard_err}")
|
| 658 |
+
else:
|
| 659 |
+
logger.error(f"[{req_id}] - 读取剪贴板失败: {clipboard_err}")
|
| 660 |
+
await save_error_snapshot(f"copy_response_clipboard_read_failed_{req_id}")
|
| 661 |
+
return None
|
| 662 |
+
|
| 663 |
+
except ClientDisconnectedError:
|
| 664 |
+
logger.info(f"[{req_id}] (Helper Copy) 客户端断开连接。")
|
| 665 |
+
raise
|
| 666 |
+
except Exception as e:
|
| 667 |
+
logger.exception(f"[{req_id}] 复制响应过程中发生意外错误")
|
| 668 |
+
await save_error_snapshot(f"copy_response_unexpected_error_{req_id}")
|
| 669 |
+
return None
|
| 670 |
+
|
| 671 |
+
async def _wait_for_response_completion(
|
| 672 |
+
page: AsyncPage,
|
| 673 |
+
prompt_textarea_locator: Locator,
|
| 674 |
+
submit_button_locator: Locator,
|
| 675 |
+
edit_button_locator: Locator,
|
| 676 |
+
req_id: str,
|
| 677 |
+
check_client_disconnected_func: Callable,
|
| 678 |
+
current_chat_id: Optional[str],
|
| 679 |
+
timeout_ms=RESPONSE_COMPLETION_TIMEOUT,
|
| 680 |
+
initial_wait_ms=INITIAL_WAIT_MS_BEFORE_POLLING
|
| 681 |
+
) -> bool:
|
| 682 |
+
"""等待响应完成"""
|
| 683 |
+
from playwright.async_api import TimeoutError
|
| 684 |
+
|
| 685 |
+
logger.info(f"[{req_id}] (WaitV3) 开始等待响应完成... (超时: {timeout_ms}ms)")
|
| 686 |
+
await asyncio.sleep(initial_wait_ms / 1000) # Initial brief wait
|
| 687 |
+
|
| 688 |
+
start_time = time.time()
|
| 689 |
+
wait_timeout_ms_short = 3000 # 3 seconds for individual element checks
|
| 690 |
+
|
| 691 |
+
consecutive_empty_input_submit_disabled_count = 0
|
| 692 |
+
|
| 693 |
+
while True:
|
| 694 |
+
try:
|
| 695 |
+
check_client_disconnected_func("等待响应完成 - 循环开始")
|
| 696 |
+
except ClientDisconnectedError:
|
| 697 |
+
logger.info(f"[{req_id}] (WaitV3) 客户端断开连接,中止等待。")
|
| 698 |
+
return False
|
| 699 |
+
|
| 700 |
+
current_time_elapsed_ms = (time.time() - start_time) * 1000
|
| 701 |
+
if current_time_elapsed_ms > timeout_ms:
|
| 702 |
+
logger.error(f"[{req_id}] (WaitV3) 等待响应完成超时 ({timeout_ms}ms)。")
|
| 703 |
+
await save_error_snapshot(f"wait_completion_v3_overall_timeout_{req_id}")
|
| 704 |
+
return False
|
| 705 |
+
|
| 706 |
+
try:
|
| 707 |
+
check_client_disconnected_func("等待响应完成 - 超时检查后")
|
| 708 |
+
except ClientDisconnectedError:
|
| 709 |
+
return False
|
| 710 |
+
|
| 711 |
+
# --- 主要条件: 输入框空 & 提交按钮禁用 ---
|
| 712 |
+
is_input_empty = await prompt_textarea_locator.input_value() == ""
|
| 713 |
+
is_submit_disabled = False
|
| 714 |
+
try:
|
| 715 |
+
is_submit_disabled = await submit_button_locator.is_disabled(timeout=wait_timeout_ms_short)
|
| 716 |
+
except TimeoutError:
|
| 717 |
+
logger.warning(f"[{req_id}] (WaitV3) 检查提交按钮是否禁用超时。为本次检查假定其未禁用。")
|
| 718 |
+
|
| 719 |
+
try:
|
| 720 |
+
check_client_disconnected_func("等待响应完成 - 按钮状态检查后")
|
| 721 |
+
except ClientDisconnectedError:
|
| 722 |
+
return False
|
| 723 |
+
|
| 724 |
+
if is_input_empty and is_submit_disabled:
|
| 725 |
+
consecutive_empty_input_submit_disabled_count += 1
|
| 726 |
+
if DEBUG_LOGS_ENABLED:
|
| 727 |
+
logger.debug(f"[{req_id}] (WaitV3) 主要条件满足: 输入框空,提交按钮禁用 (计数: {consecutive_empty_input_submit_disabled_count})。")
|
| 728 |
+
|
| 729 |
+
# --- 最终确认: 编辑按钮可见 ---
|
| 730 |
+
try:
|
| 731 |
+
if await edit_button_locator.is_visible(timeout=wait_timeout_ms_short):
|
| 732 |
+
logger.info(f"[{req_id}] (WaitV3) ✅ 响应完成: 输入框空,提交按钮禁用,编辑按钮可见。")
|
| 733 |
+
return True # 明确完成
|
| 734 |
+
except TimeoutError:
|
| 735 |
+
if DEBUG_LOGS_ENABLED:
|
| 736 |
+
logger.debug(f"[{req_id}] (WaitV3) 主要条件满足后,检查编辑按钮可见性超时。")
|
| 737 |
+
|
| 738 |
+
try:
|
| 739 |
+
check_client_disconnected_func("等待响应完成 - 编辑按钮检查后")
|
| 740 |
+
except ClientDisconnectedError:
|
| 741 |
+
return False
|
| 742 |
+
|
| 743 |
+
# 启发式完成: 如果主要条件持续满足,但编辑按钮仍未出现
|
| 744 |
+
if consecutive_empty_input_submit_disabled_count >= 3: # 例如,大约 1.5秒 (3 * 0.5秒轮询)
|
| 745 |
+
logger.warning(f"[{req_id}] (WaitV3) 响应可能已完成 (启发式): 输入框空,提交按钮禁用,但在 {consecutive_empty_input_submit_disabled_count} 次检查后编辑按钮仍未出现。假定完成。后续若内容获取失败,可能与此有关。")
|
| 746 |
+
return True # 启发式完成
|
| 747 |
+
else: # 主要条件 (输入框空 & 提交按钮禁用) 未满足
|
| 748 |
+
consecutive_empty_input_submit_disabled_count = 0 # 重置计数器
|
| 749 |
+
if DEBUG_LOGS_ENABLED:
|
| 750 |
+
reasons = []
|
| 751 |
+
if not is_input_empty:
|
| 752 |
+
reasons.append("输入框非空")
|
| 753 |
+
if not is_submit_disabled:
|
| 754 |
+
reasons.append("提交按钮非禁用")
|
| 755 |
+
logger.debug(f"[{req_id}] (WaitV3) 主要条件未满足 ({', '.join(reasons)}). 继续轮询...")
|
| 756 |
+
|
| 757 |
+
await asyncio.sleep(0.5) # 轮询间隔
|
| 758 |
+
|
| 759 |
+
async def _get_final_response_content(
|
| 760 |
+
page: AsyncPage,
|
| 761 |
+
req_id: str,
|
| 762 |
+
check_client_disconnected: Callable
|
| 763 |
+
) -> Optional[str]:
|
| 764 |
+
"""获取最终响应内容"""
|
| 765 |
+
logger.info(f"[{req_id}] (Helper GetContent) 开始获取最终响应内容...")
|
| 766 |
+
response_content = await get_response_via_edit_button(
|
| 767 |
+
page, req_id, check_client_disconnected
|
| 768 |
+
)
|
| 769 |
+
if response_content is not None:
|
| 770 |
+
logger.info(f"[{req_id}] (Helper GetContent) ✅ 成功通过编辑按钮获取内容。")
|
| 771 |
+
return response_content
|
| 772 |
+
|
| 773 |
+
logger.warning(f"[{req_id}] (Helper GetContent) 编辑按钮方法失败或返回空,回退到复制按钮方法...")
|
| 774 |
+
response_content = await get_response_via_copy_button(
|
| 775 |
+
page, req_id, check_client_disconnected
|
| 776 |
+
)
|
| 777 |
+
if response_content is not None:
|
| 778 |
+
logger.info(f"[{req_id}] (Helper GetContent) ✅ 成功通过复制按钮获取内容。")
|
| 779 |
+
return response_content
|
| 780 |
+
|
| 781 |
+
logger.error(f"[{req_id}] (Helper GetContent) 所有获取响应内容的方法均失败。")
|
| 782 |
+
await save_error_snapshot(f"get_content_all_methods_failed_{req_id}")
|
| 783 |
+
return None
|
AIStudioProxyAPI/browser_utils/page_controller.py
ADDED
|
@@ -0,0 +1,914 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PageController模块
|
| 3 |
+
封装了所有与Playwright页面直接交互的复杂逻辑。
|
| 4 |
+
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
from typing import Callable, List, Dict, Any, Optional
|
| 7 |
+
|
| 8 |
+
from playwright.async_api import Page as AsyncPage, expect as expect_async, TimeoutError
|
| 9 |
+
|
| 10 |
+
from config import (
|
| 11 |
+
TEMPERATURE_INPUT_SELECTOR, MAX_OUTPUT_TOKENS_SELECTOR, STOP_SEQUENCE_INPUT_SELECTOR,
|
| 12 |
+
MAT_CHIP_REMOVE_BUTTON_SELECTOR, TOP_P_INPUT_SELECTOR, SUBMIT_BUTTON_SELECTOR,
|
| 13 |
+
CLEAR_CHAT_BUTTON_SELECTOR, CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR, OVERLAY_SELECTOR,
|
| 14 |
+
PROMPT_TEXTAREA_SELECTOR, RESPONSE_CONTAINER_SELECTOR, RESPONSE_TEXT_SELECTOR,
|
| 15 |
+
EDIT_MESSAGE_BUTTON_SELECTOR,USE_URL_CONTEXT_SELECTOR,UPLOAD_BUTTON_SELECTOR,
|
| 16 |
+
SET_THINKING_BUDGET_TOGGLE_SELECTOR, THINKING_BUDGET_INPUT_SELECTOR,
|
| 17 |
+
GROUNDING_WITH_GOOGLE_SEARCH_TOGGLE_SELECTOR
|
| 18 |
+
)
|
| 19 |
+
from config import (
|
| 20 |
+
CLICK_TIMEOUT_MS, WAIT_FOR_ELEMENT_TIMEOUT_MS, CLEAR_CHAT_VERIFY_TIMEOUT_MS,
|
| 21 |
+
DEFAULT_TEMPERATURE, DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_STOP_SEQUENCES, DEFAULT_TOP_P,
|
| 22 |
+
ENABLE_URL_CONTEXT, ENABLE_THINKING_BUDGET, DEFAULT_THINKING_BUDGET, ENABLE_GOOGLE_SEARCH
|
| 23 |
+
)
|
| 24 |
+
from models import ClientDisconnectedError
|
| 25 |
+
from .operations import save_error_snapshot, _wait_for_response_completion, _get_final_response_content
|
| 26 |
+
from .initialization import enable_temporary_chat_mode
|
| 27 |
+
|
| 28 |
+
class PageController:
|
| 29 |
+
"""封装了与AI Studio页面交互的所有操作。"""
|
| 30 |
+
|
| 31 |
+
def __init__(self, page: AsyncPage, logger, req_id: str):
|
| 32 |
+
self.page = page
|
| 33 |
+
self.logger = logger
|
| 34 |
+
self.req_id = req_id
|
| 35 |
+
|
| 36 |
+
async def _check_disconnect(self, check_client_disconnected: Callable, stage: str):
|
| 37 |
+
"""检查客户端是否断开连接。"""
|
| 38 |
+
if check_client_disconnected(stage):
|
| 39 |
+
raise ClientDisconnectedError(f"[{self.req_id}] Client disconnected at stage: {stage}")
|
| 40 |
+
|
| 41 |
+
async def adjust_parameters(self, request_params: Dict[str, Any], page_params_cache: Dict[str, Any], params_cache_lock: asyncio.Lock, model_id_to_use: str, parsed_model_list: List[Dict[str, Any]], check_client_disconnected: Callable):
|
| 42 |
+
"""调整所有请求参数。"""
|
| 43 |
+
self.logger.info(f"[{self.req_id}] 开始调整所有请求参数...")
|
| 44 |
+
await self._check_disconnect(check_client_disconnected, "Start Parameter Adjustment")
|
| 45 |
+
|
| 46 |
+
# 调整温度
|
| 47 |
+
temp_to_set = request_params.get('temperature', DEFAULT_TEMPERATURE)
|
| 48 |
+
await self._adjust_temperature(temp_to_set, page_params_cache, params_cache_lock, check_client_disconnected)
|
| 49 |
+
await self._check_disconnect(check_client_disconnected, "After Temperature Adjustment")
|
| 50 |
+
|
| 51 |
+
# 调整最大Token
|
| 52 |
+
max_tokens_to_set = request_params.get('max_output_tokens', DEFAULT_MAX_OUTPUT_TOKENS)
|
| 53 |
+
await self._adjust_max_tokens(max_tokens_to_set, page_params_cache, params_cache_lock, model_id_to_use, parsed_model_list, check_client_disconnected)
|
| 54 |
+
await self._check_disconnect(check_client_disconnected, "After Max Tokens Adjustment")
|
| 55 |
+
|
| 56 |
+
# 调整停止序列
|
| 57 |
+
stop_to_set = request_params.get('stop', DEFAULT_STOP_SEQUENCES)
|
| 58 |
+
await self._adjust_stop_sequences(stop_to_set, page_params_cache, params_cache_lock, check_client_disconnected)
|
| 59 |
+
await self._check_disconnect(check_client_disconnected, "After Stop Sequences Adjustment")
|
| 60 |
+
|
| 61 |
+
# 调整Top P
|
| 62 |
+
top_p_to_set = request_params.get('top_p', DEFAULT_TOP_P)
|
| 63 |
+
await self._adjust_top_p(top_p_to_set, check_client_disconnected)
|
| 64 |
+
await self._check_disconnect(check_client_disconnected, "End Parameter Adjustment")
|
| 65 |
+
|
| 66 |
+
# 确保工具面板已展开,以便调整高级设置
|
| 67 |
+
await self._ensure_tools_panel_expanded(check_client_disconnected)
|
| 68 |
+
|
| 69 |
+
# 调整URL CONTEXT
|
| 70 |
+
if ENABLE_URL_CONTEXT:
|
| 71 |
+
await self._open_url_content(check_client_disconnected)
|
| 72 |
+
else:
|
| 73 |
+
self.logger.info(f"[{self.req_id}] URL Context 功能已禁用,跳过调整。")
|
| 74 |
+
|
| 75 |
+
# 调整“思考预算”
|
| 76 |
+
await self._handle_thinking_budget(request_params, check_client_disconnected)
|
| 77 |
+
|
| 78 |
+
# 调整 Google Search 开关
|
| 79 |
+
await self._adjust_google_search(request_params, check_client_disconnected)
|
| 80 |
+
|
| 81 |
+
async def _handle_thinking_budget(self, request_params: Dict[str, Any], check_client_disconnected: Callable):
|
| 82 |
+
"""处理思考预算的调整逻辑。"""
|
| 83 |
+
reasoning_effort = request_params.get('reasoning_effort')
|
| 84 |
+
|
| 85 |
+
# 检查用户是否明确禁用了思考预算
|
| 86 |
+
should_disable_budget = isinstance(reasoning_effort, str) and reasoning_effort.lower() == 'none'
|
| 87 |
+
|
| 88 |
+
if should_disable_budget:
|
| 89 |
+
self.logger.info(f"[{self.req_id}] 用户通过 reasoning_effort='none' 明确禁用思考预算。")
|
| 90 |
+
await self._control_thinking_budget_toggle(should_be_checked=False, check_client_disconnected=check_client_disconnected)
|
| 91 |
+
elif reasoning_effort is not None:
|
| 92 |
+
# 用户指定了非 'none' 的值,则开启并设置
|
| 93 |
+
self.logger.info(f"[{self.req_id}] 用户指定了 reasoning_effort: {reasoning_effort},将启用并设置思考预算。")
|
| 94 |
+
await self._control_thinking_budget_toggle(should_be_checked=True, check_client_disconnected=check_client_disconnected)
|
| 95 |
+
await self._adjust_thinking_budget(reasoning_effort, check_client_disconnected)
|
| 96 |
+
else:
|
| 97 |
+
# 用户未指定,根据默认配置
|
| 98 |
+
self.logger.info(f"[{self.req_id}] 用户未指定 reasoning_effort,根据默认配置 ENABLE_THINKING_BUDGET: {ENABLE_THINKING_BUDGET}。")
|
| 99 |
+
await self._control_thinking_budget_toggle(should_be_checked=ENABLE_THINKING_BUDGET, check_client_disconnected=check_client_disconnected)
|
| 100 |
+
if ENABLE_THINKING_BUDGET:
|
| 101 |
+
# 如果默认开启,则使用默认值
|
| 102 |
+
await self._adjust_thinking_budget(None, check_client_disconnected)
|
| 103 |
+
|
| 104 |
+
def _parse_thinking_budget(self, reasoning_effort: Optional[Any]) -> Optional[int]:
|
| 105 |
+
"""从 reasoning_effort 解析出 token_budget。"""
|
| 106 |
+
token_budget = None
|
| 107 |
+
if reasoning_effort is None:
|
| 108 |
+
token_budget = DEFAULT_THINKING_BUDGET
|
| 109 |
+
self.logger.info(f"[{self.req_id}] 'reasoning_effort' 为空,使用默认思考预算: {token_budget}")
|
| 110 |
+
elif isinstance(reasoning_effort, int):
|
| 111 |
+
token_budget = reasoning_effort
|
| 112 |
+
elif isinstance(reasoning_effort, str):
|
| 113 |
+
if reasoning_effort.lower() == 'none':
|
| 114 |
+
token_budget = DEFAULT_THINKING_BUDGET
|
| 115 |
+
self.logger.info(f"[{self.req_id}] 'reasoning_effort' 为 'none' 字符串,使用默认思考预算: {token_budget}")
|
| 116 |
+
else:
|
| 117 |
+
effort_map = {
|
| 118 |
+
"low": 1000,
|
| 119 |
+
"medium": 8000,
|
| 120 |
+
"high": 24000
|
| 121 |
+
}
|
| 122 |
+
token_budget = effort_map.get(reasoning_effort.lower())
|
| 123 |
+
if token_budget is None:
|
| 124 |
+
try:
|
| 125 |
+
token_budget = int(reasoning_effort)
|
| 126 |
+
except (ValueError, TypeError):
|
| 127 |
+
pass # token_budget remains None
|
| 128 |
+
|
| 129 |
+
if token_budget is None:
|
| 130 |
+
self.logger.warning(f"[{self.req_id}] 无法从 '{reasoning_effort}' (类型: {type(reasoning_effort)}) 解析出有效的 token_budget。")
|
| 131 |
+
|
| 132 |
+
return token_budget
|
| 133 |
+
|
| 134 |
+
async def _adjust_thinking_budget(self, reasoning_effort: Optional[Any], check_client_disconnected: Callable):
|
| 135 |
+
"""根据 reasoning_effort 调整思考预算。"""
|
| 136 |
+
self.logger.info(f"[{self.req_id}] 检查并调整思考预算,输入值: {reasoning_effort}")
|
| 137 |
+
|
| 138 |
+
token_budget = self._parse_thinking_budget(reasoning_effort)
|
| 139 |
+
|
| 140 |
+
if token_budget is None:
|
| 141 |
+
self.logger.warning(f"[{self.req_id}] 无效的 reasoning_effort 值: '{reasoning_effort}'。跳过调整。")
|
| 142 |
+
return
|
| 143 |
+
|
| 144 |
+
budget_input_locator = self.page.locator(THINKING_BUDGET_INPUT_SELECTOR)
|
| 145 |
+
|
| 146 |
+
try:
|
| 147 |
+
await expect_async(budget_input_locator).to_be_visible(timeout=5000)
|
| 148 |
+
await self._check_disconnect(check_client_disconnected, "思考预算调整 - 输入框可见后")
|
| 149 |
+
|
| 150 |
+
self.logger.info(f"[{self.req_id}] 设置思考预算为: {token_budget}")
|
| 151 |
+
await budget_input_locator.fill(str(token_budget), timeout=5000)
|
| 152 |
+
await self._check_disconnect(check_client_disconnected, "思考预算调整 - 填充输入框后")
|
| 153 |
+
|
| 154 |
+
# 验证
|
| 155 |
+
await asyncio.sleep(0.1)
|
| 156 |
+
new_value_str = await budget_input_locator.input_value(timeout=3000)
|
| 157 |
+
if int(new_value_str) == token_budget:
|
| 158 |
+
self.logger.info(f"[{self.req_id}] ✅ 思考预算已成功更新为: {new_value_str}")
|
| 159 |
+
else:
|
| 160 |
+
self.logger.warning(f"[{self.req_id}] ⚠️ 思考预算更新后验证失败。页面显示: {new_value_str}, 期望: {token_budget}")
|
| 161 |
+
|
| 162 |
+
except Exception as e:
|
| 163 |
+
self.logger.error(f"[{self.req_id}] ❌ 调整思考预算时出错: {e}")
|
| 164 |
+
if isinstance(e, ClientDisconnectedError):
|
| 165 |
+
raise
|
| 166 |
+
|
| 167 |
+
def _should_enable_google_search(self, request_params: Dict[str, Any]) -> bool:
|
| 168 |
+
"""根据请求参数或默认配置决定是否应启用 Google Search。"""
|
| 169 |
+
if 'tools' in request_params and request_params.get('tools') is not None:
|
| 170 |
+
tools = request_params.get('tools')
|
| 171 |
+
has_google_search_tool = False
|
| 172 |
+
if isinstance(tools, list):
|
| 173 |
+
for tool in tools:
|
| 174 |
+
if isinstance(tool, dict):
|
| 175 |
+
if tool.get('google_search_retrieval') is not None:
|
| 176 |
+
has_google_search_tool = True
|
| 177 |
+
break
|
| 178 |
+
if tool.get('function', {}).get('name') == 'googleSearch':
|
| 179 |
+
has_google_search_tool = True
|
| 180 |
+
break
|
| 181 |
+
self.logger.info(f"[{self.req_id}] 请求中包含 'tools' 参数。检测到 Google Search 工具: {has_google_search_tool}。")
|
| 182 |
+
return has_google_search_tool
|
| 183 |
+
else:
|
| 184 |
+
self.logger.info(f"[{self.req_id}] 请求中不包含 'tools' 参数。使用默认配置 ENABLE_GOOGLE_SEARCH: {ENABLE_GOOGLE_SEARCH}。")
|
| 185 |
+
return ENABLE_GOOGLE_SEARCH
|
| 186 |
+
|
| 187 |
+
async def _adjust_google_search(self, request_params: Dict[str, Any], check_client_disconnected: Callable):
|
| 188 |
+
"""根据请求参数或默认配置,双向控制 Google Search 开关。"""
|
| 189 |
+
self.logger.info(f"[{self.req_id}] 检查并调整 Google Search 开关...")
|
| 190 |
+
|
| 191 |
+
should_enable_search = self._should_enable_google_search(request_params)
|
| 192 |
+
|
| 193 |
+
toggle_selector = GROUNDING_WITH_GOOGLE_SEARCH_TOGGLE_SELECTOR
|
| 194 |
+
|
| 195 |
+
try:
|
| 196 |
+
toggle_locator = self.page.locator(toggle_selector)
|
| 197 |
+
await expect_async(toggle_locator).to_be_visible(timeout=5000)
|
| 198 |
+
await self._check_disconnect(check_client_disconnected, "Google Search 开关 - 元素可见后")
|
| 199 |
+
|
| 200 |
+
is_checked_str = await toggle_locator.get_attribute("aria-checked")
|
| 201 |
+
is_currently_checked = is_checked_str == "true"
|
| 202 |
+
self.logger.info(f"[{self.req_id}] Google Search 开关当前状态: '{is_checked_str}'。期望状态: {should_enable_search}")
|
| 203 |
+
|
| 204 |
+
if should_enable_search != is_currently_checked:
|
| 205 |
+
action = "打开" if should_enable_search else "关闭"
|
| 206 |
+
self.logger.info(f"[{self.req_id}] Google Search 开关状态与期望不符。正在点击以{action}...")
|
| 207 |
+
await toggle_locator.click(timeout=CLICK_TIMEOUT_MS)
|
| 208 |
+
await self._check_disconnect(check_client_disconnected, f"Google Search 开关 - 点击{action}后")
|
| 209 |
+
await asyncio.sleep(0.5) # 等待UI更新
|
| 210 |
+
new_state = await toggle_locator.get_attribute("aria-checked")
|
| 211 |
+
if (new_state == "true") == should_enable_search:
|
| 212 |
+
self.logger.info(f"[{self.req_id}] ✅ Google Search 开关已成功{action}。")
|
| 213 |
+
else:
|
| 214 |
+
self.logger.warning(f"[{self.req_id}] ⚠️ Google Search 开关{action}失败。当前状态: '{new_state}'")
|
| 215 |
+
else:
|
| 216 |
+
self.logger.info(f"[{self.req_id}] Google Search 开关已处于期望状态,无需操作。")
|
| 217 |
+
|
| 218 |
+
except Exception as e:
|
| 219 |
+
self.logger.error(f"[{self.req_id}] ❌ 操作 'Google Search toggle' 开关时发生错误: {e}")
|
| 220 |
+
if isinstance(e, ClientDisconnectedError):
|
| 221 |
+
raise
|
| 222 |
+
|
| 223 |
+
async def _ensure_tools_panel_expanded(self, check_client_disconnected: Callable):
|
| 224 |
+
"""确保包含高级工具(URL上下文、思考预算等)的面板是展开的。"""
|
| 225 |
+
self.logger.info(f"[{self.req_id}] 检查并确保工具面板已展开...")
|
| 226 |
+
try:
|
| 227 |
+
collapse_tools_locator = self.page.locator('button[aria-label="Expand or collapse tools"]')
|
| 228 |
+
await expect_async(collapse_tools_locator).to_be_visible(timeout=5000)
|
| 229 |
+
|
| 230 |
+
grandparent_locator = collapse_tools_locator.locator("xpath=../..")
|
| 231 |
+
class_string = await grandparent_locator.get_attribute("class", timeout=3000)
|
| 232 |
+
|
| 233 |
+
if class_string and "expanded" not in class_string.split():
|
| 234 |
+
self.logger.info(f"[{self.req_id}] 工具面板未展开,正在点击以展开...")
|
| 235 |
+
await collapse_tools_locator.click(timeout=CLICK_TIMEOUT_MS)
|
| 236 |
+
await self._check_disconnect(check_client_disconnected, "展开工具面板后")
|
| 237 |
+
# 等待展开动画完成
|
| 238 |
+
await expect_async(grandparent_locator).to_have_class(re.compile(r'.*expanded.*'), timeout=5000)
|
| 239 |
+
self.logger.info(f"[{self.req_id}] ✅ 工具面板已成功展开。")
|
| 240 |
+
else:
|
| 241 |
+
self.logger.info(f"[{self.req_id}] 工具面板已处于展开状态。")
|
| 242 |
+
except Exception as e:
|
| 243 |
+
self.logger.error(f"[{self.req_id}] ❌ 展开工具面板时发生错误: {e}")
|
| 244 |
+
# 即使出错,也继续尝试执行后续操作,但记录错误
|
| 245 |
+
if isinstance(e, ClientDisconnectedError):
|
| 246 |
+
raise
|
| 247 |
+
|
| 248 |
+
async def _open_url_content(self,check_client_disconnected: Callable):
|
| 249 |
+
"""仅负责打开 URL Context 开关,前提是面板已展开。"""
|
| 250 |
+
try:
|
| 251 |
+
self.logger.info(f"[{self.req_id}] 检查并启用 URL Context 开关...")
|
| 252 |
+
use_url_content_selector = self.page.locator(USE_URL_CONTEXT_SELECTOR)
|
| 253 |
+
await expect_async(use_url_content_selector).to_be_visible(timeout=5000)
|
| 254 |
+
|
| 255 |
+
is_checked = await use_url_content_selector.get_attribute("aria-checked")
|
| 256 |
+
if "false" == is_checked:
|
| 257 |
+
self.logger.info(f"[{self.req_id}] URL Context 开关未开启,正在点击以开启...")
|
| 258 |
+
await use_url_content_selector.click(timeout=CLICK_TIMEOUT_MS)
|
| 259 |
+
await self._check_disconnect(check_client_disconnected, "点击URLCONTEXT后")
|
| 260 |
+
self.logger.info(f"[{self.req_id}] ✅ URL Context 开关已点击。")
|
| 261 |
+
else:
|
| 262 |
+
self.logger.info(f"[{self.req_id}] URL Context 开关已处于开启状态。")
|
| 263 |
+
except Exception as e:
|
| 264 |
+
self.logger.error(f"[{self.req_id}] ❌ 操作 USE_URL_CONTEXT_SELECTOR 时发生错误:{e}。")
|
| 265 |
+
if isinstance(e, ClientDisconnectedError):
|
| 266 |
+
raise
|
| 267 |
+
|
| 268 |
+
async def _control_thinking_budget_toggle(self, should_be_checked: bool, check_client_disconnected: Callable):
|
| 269 |
+
"""
|
| 270 |
+
根据 should_be_checked 的值,控制 "Thinking Budget" 滑块开关的状态。
|
| 271 |
+
"""
|
| 272 |
+
toggle_selector = SET_THINKING_BUDGET_TOGGLE_SELECTOR
|
| 273 |
+
self.logger.info(f"[{self.req_id}] 控制 'Thinking Budget' 开关,期望状态: {'选中' if should_be_checked else '未选中'}...")
|
| 274 |
+
|
| 275 |
+
try:
|
| 276 |
+
toggle_locator = self.page.locator(toggle_selector)
|
| 277 |
+
await expect_async(toggle_locator).to_be_visible(timeout=5000)
|
| 278 |
+
await self._check_disconnect(check_client_disconnected, "思考预算开关 - 元素可见后")
|
| 279 |
+
|
| 280 |
+
is_checked_str = await toggle_locator.get_attribute("aria-checked")
|
| 281 |
+
current_state_is_checked = is_checked_str == "true"
|
| 282 |
+
self.logger.info(f"[{self.req_id}] 思考预算开关当前 'aria-checked' 状态: {is_checked_str} (当前是否选中: {current_state_is_checked})")
|
| 283 |
+
|
| 284 |
+
if current_state_is_checked != should_be_checked:
|
| 285 |
+
action = "启用" if should_be_checked else "禁用"
|
| 286 |
+
self.logger.info(f"[{self.req_id}] 思考预算开关当前状态与期望不符,正在点击以{action}...")
|
| 287 |
+
await toggle_locator.click(timeout=CLICK_TIMEOUT_MS)
|
| 288 |
+
await self._check_disconnect(check_client_disconnected, f"思考预算开关 - 点击{action}后")
|
| 289 |
+
|
| 290 |
+
await asyncio.sleep(0.5)
|
| 291 |
+
new_state_str = await toggle_locator.get_attribute("aria-checked")
|
| 292 |
+
new_state_is_checked = new_state_str == "true"
|
| 293 |
+
|
| 294 |
+
if new_state_is_checked == should_be_checked:
|
| 295 |
+
self.logger.info(f"[{self.req_id}] ✅ 'Thinking Budget' 开关已成功{action}。新状态: {new_state_str}")
|
| 296 |
+
else:
|
| 297 |
+
self.logger.warning(f"[{self.req_id}] ⚠️ 'Thinking Budget' 开关{action}后验证失败。期望状态: '{should_be_checked}', 实际状态: '{new_state_str}'")
|
| 298 |
+
else:
|
| 299 |
+
self.logger.info(f"[{self.req_id}] 'Thinking Budget' 开关已处于期望状态,无需操作。")
|
| 300 |
+
|
| 301 |
+
except Exception as e:
|
| 302 |
+
self.logger.error(f"[{self.req_id}] ❌ 操作 'Thinking Budget toggle' 开关时发生错误: {e}")
|
| 303 |
+
if isinstance(e, ClientDisconnectedError):
|
| 304 |
+
raise
|
| 305 |
+
async def _adjust_temperature(self, temperature: float, page_params_cache: dict, params_cache_lock: asyncio.Lock, check_client_disconnected: Callable):
|
| 306 |
+
"""调整温度参数。"""
|
| 307 |
+
async with params_cache_lock:
|
| 308 |
+
self.logger.info(f"[{self.req_id}] 检查并调整温度设置...")
|
| 309 |
+
clamped_temp = max(0.0, min(2.0, temperature))
|
| 310 |
+
if clamped_temp != temperature:
|
| 311 |
+
self.logger.warning(f"[{self.req_id}] 请求的温度 {temperature} 超出范围 [0, 2],已调整为 {clamped_temp}")
|
| 312 |
+
|
| 313 |
+
cached_temp = page_params_cache.get("temperature")
|
| 314 |
+
if cached_temp is not None and abs(cached_temp - clamped_temp) < 0.001:
|
| 315 |
+
self.logger.info(f"[{self.req_id}] 温度 ({clamped_temp}) 与缓存值 ({cached_temp}) 一致。跳过页面交互。")
|
| 316 |
+
return
|
| 317 |
+
|
| 318 |
+
self.logger.info(f"[{self.req_id}] 请求温度 ({clamped_temp}) 与缓存值 ({cached_temp}) 不一致或缓存中无值。需要与页面交互。")
|
| 319 |
+
temp_input_locator = self.page.locator(TEMPERATURE_INPUT_SELECTOR)
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
try:
|
| 323 |
+
await expect_async(temp_input_locator).to_be_visible(timeout=5000)
|
| 324 |
+
await self._check_disconnect(check_client_disconnected, "温度调整 - 输入框可见后")
|
| 325 |
+
|
| 326 |
+
current_temp_str = await temp_input_locator.input_value(timeout=3000)
|
| 327 |
+
await self._check_disconnect(check_client_disconnected, "温度调整 - 读取输入框值后")
|
| 328 |
+
|
| 329 |
+
current_temp_float = float(current_temp_str)
|
| 330 |
+
self.logger.info(f"[{self.req_id}] 页面当前温度: {current_temp_float}, 请求调整后温度: {clamped_temp}")
|
| 331 |
+
|
| 332 |
+
if abs(current_temp_float - clamped_temp) < 0.001:
|
| 333 |
+
self.logger.info(f"[{self.req_id}] 页面当前温度 ({current_temp_float}) 与请求温度 ({clamped_temp}) 一致。更新缓存并跳过写入。")
|
| 334 |
+
page_params_cache["temperature"] = current_temp_float
|
| 335 |
+
else:
|
| 336 |
+
self.logger.info(f"[{self.req_id}] 页面温度 ({current_temp_float}) 与请求温度 ({clamped_temp}) 不同,正在更新...")
|
| 337 |
+
await temp_input_locator.fill(str(clamped_temp), timeout=5000)
|
| 338 |
+
await self._check_disconnect(check_client_disconnected, "温度调整 - 填充输入框后")
|
| 339 |
+
|
| 340 |
+
await asyncio.sleep(0.1)
|
| 341 |
+
new_temp_str = await temp_input_locator.input_value(timeout=3000)
|
| 342 |
+
new_temp_float = float(new_temp_str)
|
| 343 |
+
|
| 344 |
+
if abs(new_temp_float - clamped_temp) < 0.001:
|
| 345 |
+
self.logger.info(f"[{self.req_id}] ✅ 温度已成功更新为: {new_temp_float}。更新缓存。")
|
| 346 |
+
page_params_cache["temperature"] = new_temp_float
|
| 347 |
+
else:
|
| 348 |
+
self.logger.warning(f"[{self.req_id}] ⚠️ 温度更新后验证失败。页面显示: {new_temp_float}, 期望: {clamped_temp}。清除缓存中的温度。")
|
| 349 |
+
page_params_cache.pop("temperature", None)
|
| 350 |
+
await save_error_snapshot(f"temperature_verify_fail_{self.req_id}")
|
| 351 |
+
|
| 352 |
+
except ValueError as ve:
|
| 353 |
+
self.logger.error(f"[{self.req_id}] 转换温度值为浮点数时出错. 错误: {ve}。清除缓存中的温度。")
|
| 354 |
+
page_params_cache.pop("temperature", None)
|
| 355 |
+
await save_error_snapshot(f"temperature_value_error_{self.req_id}")
|
| 356 |
+
except Exception as pw_err:
|
| 357 |
+
self.logger.error(f"[{self.req_id}] ❌ 操作温度输入框时发生错误: {pw_err}。清除缓存中的温度。")
|
| 358 |
+
page_params_cache.pop("temperature", None)
|
| 359 |
+
await save_error_snapshot(f"temperature_playwright_error_{self.req_id}")
|
| 360 |
+
if isinstance(pw_err, ClientDisconnectedError):
|
| 361 |
+
raise
|
| 362 |
+
|
| 363 |
+
async def _adjust_max_tokens(self, max_tokens: int, page_params_cache: dict, params_cache_lock: asyncio.Lock, model_id_to_use: str, parsed_model_list: list, check_client_disconnected: Callable):
|
| 364 |
+
"""调整最大输出Token参数。"""
|
| 365 |
+
async with params_cache_lock:
|
| 366 |
+
self.logger.info(f"[{self.req_id}] 检查并调整最大输出 Token 设置...")
|
| 367 |
+
min_val_for_tokens = 1
|
| 368 |
+
max_val_for_tokens_from_model = 65536
|
| 369 |
+
|
| 370 |
+
if model_id_to_use and parsed_model_list:
|
| 371 |
+
current_model_data = next((m for m in parsed_model_list if m.get("id") == model_id_to_use), None)
|
| 372 |
+
if current_model_data and current_model_data.get("supported_max_output_tokens") is not None:
|
| 373 |
+
try:
|
| 374 |
+
supported_tokens = int(current_model_data["supported_max_output_tokens"])
|
| 375 |
+
if supported_tokens > 0:
|
| 376 |
+
max_val_for_tokens_from_model = supported_tokens
|
| 377 |
+
else:
|
| 378 |
+
self.logger.warning(f"[{self.req_id}] 模型 {model_id_to_use} supported_max_output_tokens 无效: {supported_tokens}")
|
| 379 |
+
except (ValueError, TypeError):
|
| 380 |
+
self.logger.warning(f"[{self.req_id}] 模型 {model_id_to_use} supported_max_output_tokens 解析失败")
|
| 381 |
+
|
| 382 |
+
clamped_max_tokens = max(min_val_for_tokens, min(max_val_for_tokens_from_model, max_tokens))
|
| 383 |
+
if clamped_max_tokens != max_tokens:
|
| 384 |
+
self.logger.warning(f"[{self.req_id}] 请求的最大输出 Tokens {max_tokens} 超出模型范围,已调整为 {clamped_max_tokens}")
|
| 385 |
+
|
| 386 |
+
cached_max_tokens = page_params_cache.get("max_output_tokens")
|
| 387 |
+
if cached_max_tokens is not None and cached_max_tokens == clamped_max_tokens:
|
| 388 |
+
self.logger.info(f"[{self.req_id}] 最大输出 Tokens ({clamped_max_tokens}) 与缓存值一致。跳过页面交互。")
|
| 389 |
+
return
|
| 390 |
+
|
| 391 |
+
max_tokens_input_locator = self.page.locator(MAX_OUTPUT_TOKENS_SELECTOR)
|
| 392 |
+
|
| 393 |
+
try:
|
| 394 |
+
await expect_async(max_tokens_input_locator).to_be_visible(timeout=5000)
|
| 395 |
+
await self._check_disconnect(check_client_disconnected, "最大输出Token调整 - 输入框可见后")
|
| 396 |
+
|
| 397 |
+
current_max_tokens_str = await max_tokens_input_locator.input_value(timeout=3000)
|
| 398 |
+
current_max_tokens_int = int(current_max_tokens_str)
|
| 399 |
+
|
| 400 |
+
if current_max_tokens_int == clamped_max_tokens:
|
| 401 |
+
self.logger.info(f"[{self.req_id}] 页面当前最大输出 Tokens ({current_max_tokens_int}) 与请求值 ({clamped_max_tokens}) 一致。更新缓存并跳过写入。")
|
| 402 |
+
page_params_cache["max_output_tokens"] = current_max_tokens_int
|
| 403 |
+
else:
|
| 404 |
+
self.logger.info(f"[{self.req_id}] 页面最大输出 Tokens ({current_max_tokens_int}) 与请求值 ({clamped_max_tokens}) 不同,正在更新...")
|
| 405 |
+
await max_tokens_input_locator.fill(str(clamped_max_tokens), timeout=5000)
|
| 406 |
+
await self._check_disconnect(check_client_disconnected, "最大输出Token调整 - 填充输入框后")
|
| 407 |
+
|
| 408 |
+
await asyncio.sleep(0.1)
|
| 409 |
+
new_max_tokens_str = await max_tokens_input_locator.input_value(timeout=3000)
|
| 410 |
+
new_max_tokens_int = int(new_max_tokens_str)
|
| 411 |
+
|
| 412 |
+
if new_max_tokens_int == clamped_max_tokens:
|
| 413 |
+
self.logger.info(f"[{self.req_id}] ✅ 最大输出 Tokens 已成功更新为: {new_max_tokens_int}")
|
| 414 |
+
page_params_cache["max_output_tokens"] = new_max_tokens_int
|
| 415 |
+
else:
|
| 416 |
+
self.logger.warning(f"[{self.req_id}] ⚠️ 最大输出 Tokens 更新后验证失败。页面显示: {new_max_tokens_int}, 期望: {clamped_max_tokens}。清除缓存。")
|
| 417 |
+
page_params_cache.pop("max_output_tokens", None)
|
| 418 |
+
await save_error_snapshot(f"max_tokens_verify_fail_{self.req_id}")
|
| 419 |
+
|
| 420 |
+
except (ValueError, TypeError) as ve:
|
| 421 |
+
self.logger.error(f"[{self.req_id}] 转换最大输出 Tokens 值时出错: {ve}。清除缓存。")
|
| 422 |
+
page_params_cache.pop("max_output_tokens", None)
|
| 423 |
+
await save_error_snapshot(f"max_tokens_value_error_{self.req_id}")
|
| 424 |
+
except Exception as e:
|
| 425 |
+
self.logger.error(f"[{self.req_id}] ❌ 调整最大输出 Tokens 时出错: {e}。清除缓存。")
|
| 426 |
+
page_params_cache.pop("max_output_tokens", None)
|
| 427 |
+
await save_error_snapshot(f"max_tokens_error_{self.req_id}")
|
| 428 |
+
if isinstance(e, ClientDisconnectedError):
|
| 429 |
+
raise
|
| 430 |
+
|
| 431 |
+
async def _adjust_stop_sequences(self, stop_sequences, page_params_cache: dict, params_cache_lock: asyncio.Lock, check_client_disconnected: Callable):
|
| 432 |
+
"""调整停止序列参数。"""
|
| 433 |
+
async with params_cache_lock:
|
| 434 |
+
self.logger.info(f"[{self.req_id}] 检查并设置停止序列...")
|
| 435 |
+
|
| 436 |
+
# 处理不同类型的stop_sequences输入
|
| 437 |
+
normalized_requested_stops = set()
|
| 438 |
+
if stop_sequences is not None:
|
| 439 |
+
if isinstance(stop_sequences, str):
|
| 440 |
+
# 单个字符串
|
| 441 |
+
if stop_sequences.strip():
|
| 442 |
+
normalized_requested_stops.add(stop_sequences.strip())
|
| 443 |
+
elif isinstance(stop_sequences, list):
|
| 444 |
+
# 字符串列表
|
| 445 |
+
for s in stop_sequences:
|
| 446 |
+
if isinstance(s, str) and s.strip():
|
| 447 |
+
normalized_requested_stops.add(s.strip())
|
| 448 |
+
|
| 449 |
+
cached_stops_set = page_params_cache.get("stop_sequences")
|
| 450 |
+
|
| 451 |
+
if cached_stops_set is not None and cached_stops_set == normalized_requested_stops:
|
| 452 |
+
self.logger.info(f"[{self.req_id}] 请求的停止序列与缓存值一致。跳过页面交互。")
|
| 453 |
+
return
|
| 454 |
+
|
| 455 |
+
stop_input_locator = self.page.locator(STOP_SEQUENCE_INPUT_SELECTOR)
|
| 456 |
+
remove_chip_buttons_locator = self.page.locator(MAT_CHIP_REMOVE_BUTTON_SELECTOR)
|
| 457 |
+
|
| 458 |
+
try:
|
| 459 |
+
# 清空已有的停止序列
|
| 460 |
+
initial_chip_count = await remove_chip_buttons_locator.count()
|
| 461 |
+
removed_count = 0
|
| 462 |
+
max_removals = initial_chip_count + 5
|
| 463 |
+
|
| 464 |
+
while await remove_chip_buttons_locator.count() > 0 and removed_count < max_removals:
|
| 465 |
+
await self._check_disconnect(check_client_disconnected, "停止序列清除 - 循环开始")
|
| 466 |
+
try:
|
| 467 |
+
await remove_chip_buttons_locator.first.click(timeout=2000)
|
| 468 |
+
removed_count += 1
|
| 469 |
+
await asyncio.sleep(0.15)
|
| 470 |
+
except Exception:
|
| 471 |
+
break
|
| 472 |
+
|
| 473 |
+
# 添加新的停止序列
|
| 474 |
+
if normalized_requested_stops:
|
| 475 |
+
await expect_async(stop_input_locator).to_be_visible(timeout=5000)
|
| 476 |
+
for seq in normalized_requested_stops:
|
| 477 |
+
await stop_input_locator.fill(seq, timeout=3000)
|
| 478 |
+
await stop_input_locator.press("Enter", timeout=3000)
|
| 479 |
+
await asyncio.sleep(0.2)
|
| 480 |
+
|
| 481 |
+
page_params_cache["stop_sequences"] = normalized_requested_stops
|
| 482 |
+
self.logger.info(f"[{self.req_id}] ✅ 停止序列已成功设置。缓存已更新。")
|
| 483 |
+
|
| 484 |
+
except Exception as e:
|
| 485 |
+
self.logger.error(f"[{self.req_id}] ❌ 设置停止序列时出错: {e}")
|
| 486 |
+
page_params_cache.pop("stop_sequences", None)
|
| 487 |
+
await save_error_snapshot(f"stop_sequence_error_{self.req_id}")
|
| 488 |
+
if isinstance(e, ClientDisconnectedError):
|
| 489 |
+
raise
|
| 490 |
+
|
| 491 |
+
async def _adjust_top_p(self, top_p: float, check_client_disconnected: Callable):
|
| 492 |
+
"""调整Top P参数。"""
|
| 493 |
+
self.logger.info(f"[{self.req_id}] 检查并调整 Top P 设置...")
|
| 494 |
+
clamped_top_p = max(0.0, min(1.0, top_p))
|
| 495 |
+
|
| 496 |
+
if abs(clamped_top_p - top_p) > 1e-9:
|
| 497 |
+
self.logger.warning(f"[{self.req_id}] 请求的 Top P {top_p} 超出范围 [0, 1],已调整为 {clamped_top_p}")
|
| 498 |
+
|
| 499 |
+
top_p_input_locator = self.page.locator(TOP_P_INPUT_SELECTOR)
|
| 500 |
+
try:
|
| 501 |
+
await expect_async(top_p_input_locator).to_be_visible(timeout=5000)
|
| 502 |
+
await self._check_disconnect(check_client_disconnected, "Top P 调整 - 输入框可见后")
|
| 503 |
+
|
| 504 |
+
current_top_p_str = await top_p_input_locator.input_value(timeout=3000)
|
| 505 |
+
current_top_p_float = float(current_top_p_str)
|
| 506 |
+
|
| 507 |
+
if abs(current_top_p_float - clamped_top_p) > 1e-9:
|
| 508 |
+
self.logger.info(f"[{self.req_id}] 页面 Top P ({current_top_p_float}) 与请求值 ({clamped_top_p}) 不同,正在更新...")
|
| 509 |
+
await top_p_input_locator.fill(str(clamped_top_p), timeout=5000)
|
| 510 |
+
await self._check_disconnect(check_client_disconnected, "Top P 调整 - 填充输入框后")
|
| 511 |
+
|
| 512 |
+
# 验证设置是否成功
|
| 513 |
+
await asyncio.sleep(0.1)
|
| 514 |
+
new_top_p_str = await top_p_input_locator.input_value(timeout=3000)
|
| 515 |
+
new_top_p_float = float(new_top_p_str)
|
| 516 |
+
|
| 517 |
+
if abs(new_top_p_float - clamped_top_p) <= 1e-9:
|
| 518 |
+
self.logger.info(f"[{self.req_id}] ✅ Top P 已成功更新为: {new_top_p_float}")
|
| 519 |
+
else:
|
| 520 |
+
self.logger.warning(f"[{self.req_id}] ⚠️ Top P 更新后验证失败。页面显示: {new_top_p_float}, 期望: {clamped_top_p}")
|
| 521 |
+
await save_error_snapshot(f"top_p_verify_fail_{self.req_id}")
|
| 522 |
+
else:
|
| 523 |
+
self.logger.info(f"[{self.req_id}] 页面 Top P ({current_top_p_float}) 与请求值 ({clamped_top_p}) 一致,无需更改")
|
| 524 |
+
|
| 525 |
+
except (ValueError, TypeError) as ve:
|
| 526 |
+
self.logger.error(f"[{self.req_id}] 转换 Top P 值时出错: {ve}")
|
| 527 |
+
await save_error_snapshot(f"top_p_value_error_{self.req_id}")
|
| 528 |
+
except Exception as e:
|
| 529 |
+
self.logger.error(f"[{self.req_id}] ❌ 调整 Top P 时出错: {e}")
|
| 530 |
+
await save_error_snapshot(f"top_p_error_{self.req_id}")
|
| 531 |
+
if isinstance(e, ClientDisconnectedError):
|
| 532 |
+
raise
|
| 533 |
+
|
| 534 |
+
async def clear_chat_history(self, check_client_disconnected: Callable):
|
| 535 |
+
"""清空聊天记录。"""
|
| 536 |
+
self.logger.info(f"[{self.req_id}] 开始清空聊天记录...")
|
| 537 |
+
await self._check_disconnect(check_client_disconnected, "Start Clear Chat")
|
| 538 |
+
|
| 539 |
+
try:
|
| 540 |
+
# 一般是使用流式代理时遇到,流式输出已结束,但页面上AI仍回复个不停,此时会锁住清空按钮,但页面仍是/new_chat,而跳过后续清空操作
|
| 541 |
+
# 导致后续请求无法发出而卡住,故先检查并点击发送按钮(此时是停止功能)
|
| 542 |
+
submit_button_locator = self.page.locator(SUBMIT_BUTTON_SELECTOR)
|
| 543 |
+
try:
|
| 544 |
+
self.logger.info(f"[{self.req_id}] 尝试检查发送按钮状态...")
|
| 545 |
+
# 使用较短的超时时间(1秒),避免长时间阻塞,因为这不是清空流程的常见步骤
|
| 546 |
+
await expect_async(submit_button_locator).to_be_enabled(timeout=1000)
|
| 547 |
+
self.logger.info(f"[{self.req_id}] 发送按钮可用,尝试点击并等待1秒...")
|
| 548 |
+
await submit_button_locator.click(timeout=CLICK_TIMEOUT_MS)
|
| 549 |
+
await asyncio.sleep(1.0)
|
| 550 |
+
self.logger.info(f"[{self.req_id}] 发送按钮点击并等待完成。")
|
| 551 |
+
except Exception as e_submit:
|
| 552 |
+
# 如果发送按钮不可用、超时或发生Playwright相关错误,记录日志并继续
|
| 553 |
+
self.logger.info(f"[{self.req_id}] 发送按钮不可用或检查/点击时发生Playwright错误。符合预期,继续检查清空按钮。")
|
| 554 |
+
|
| 555 |
+
clear_chat_button_locator = self.page.locator(CLEAR_CHAT_BUTTON_SELECTOR)
|
| 556 |
+
confirm_button_locator = self.page.locator(CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR)
|
| 557 |
+
overlay_locator = self.page.locator(OVERLAY_SELECTOR)
|
| 558 |
+
|
| 559 |
+
can_attempt_clear = False
|
| 560 |
+
try:
|
| 561 |
+
await expect_async(clear_chat_button_locator).to_be_enabled(timeout=3000)
|
| 562 |
+
can_attempt_clear = True
|
| 563 |
+
self.logger.info(f"[{self.req_id}] \"清空聊天\"按钮可用,继续清空流程。")
|
| 564 |
+
except Exception as e_enable:
|
| 565 |
+
is_new_chat_url = '/prompts/new_chat' in self.page.url.rstrip('/')
|
| 566 |
+
if is_new_chat_url:
|
| 567 |
+
self.logger.info(f"[{self.req_id}] \"清空聊天\"按钮不可用 (预期,因为在 new_chat 页面)。跳过清空操��。")
|
| 568 |
+
else:
|
| 569 |
+
self.logger.warning(f"[{self.req_id}] 等待\"清空聊天\"按钮可用失败: {e_enable}。清空操作可能无法执行。")
|
| 570 |
+
|
| 571 |
+
await self._check_disconnect(check_client_disconnected, "清空聊天 - \"清空聊天\"按钮可用性检查后")
|
| 572 |
+
|
| 573 |
+
if can_attempt_clear:
|
| 574 |
+
await self._execute_chat_clear(clear_chat_button_locator, confirm_button_locator, overlay_locator, check_client_disconnected)
|
| 575 |
+
await self._verify_chat_cleared(check_client_disconnected)
|
| 576 |
+
self.logger.info(f"[{self.req_id}] 聊天已清空,重新启用 '临时聊天' 模式...")
|
| 577 |
+
await enable_temporary_chat_mode(self.page)
|
| 578 |
+
|
| 579 |
+
except Exception as e_clear:
|
| 580 |
+
self.logger.error(f"[{self.req_id}] 清空聊天过程中发生错误: {e_clear}")
|
| 581 |
+
if not (isinstance(e_clear, ClientDisconnectedError) or (hasattr(e_clear, 'name') and 'Disconnect' in e_clear.name)):
|
| 582 |
+
await save_error_snapshot(f"clear_chat_error_{self.req_id}")
|
| 583 |
+
raise
|
| 584 |
+
|
| 585 |
+
async def _execute_chat_clear(self, clear_chat_button_locator, confirm_button_locator, overlay_locator, check_client_disconnected: Callable):
|
| 586 |
+
"""执行清空聊天操作"""
|
| 587 |
+
overlay_initially_visible = False
|
| 588 |
+
try:
|
| 589 |
+
if await overlay_locator.is_visible(timeout=1000):
|
| 590 |
+
overlay_initially_visible = True
|
| 591 |
+
self.logger.info(f"[{self.req_id}] 清空聊天确认遮罩层已可见。直接点击\"继续\"。")
|
| 592 |
+
except TimeoutError:
|
| 593 |
+
self.logger.info(f"[{self.req_id}] 清空聊天确认遮罩层初始不可见 (检查超时或未找到)。")
|
| 594 |
+
overlay_initially_visible = False
|
| 595 |
+
except Exception as e_vis_check:
|
| 596 |
+
self.logger.warning(f"[{self.req_id}] 检查遮罩层可见性时发生错误: {e_vis_check}。假定不可见。")
|
| 597 |
+
overlay_initially_visible = False
|
| 598 |
+
|
| 599 |
+
await self._check_disconnect(check_client_disconnected, "清空聊天 - 初始遮罩层检查后")
|
| 600 |
+
|
| 601 |
+
if overlay_initially_visible:
|
| 602 |
+
self.logger.info(f"[{self.req_id}] 点击\"继续\"按钮 (遮罩层已存在): {CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR}")
|
| 603 |
+
await confirm_button_locator.click(timeout=CLICK_TIMEOUT_MS)
|
| 604 |
+
else:
|
| 605 |
+
self.logger.info(f"[{self.req_id}] 点击\"清空聊天\"按钮: {CLEAR_CHAT_BUTTON_SELECTOR}")
|
| 606 |
+
await clear_chat_button_locator.click(timeout=CLICK_TIMEOUT_MS)
|
| 607 |
+
await self._check_disconnect(check_client_disconnected, "清空聊天 - 点击\"清空聊天\"后")
|
| 608 |
+
|
| 609 |
+
try:
|
| 610 |
+
self.logger.info(f"[{self.req_id}] 等待清空聊天确认遮罩层出现: {OVERLAY_SELECTOR}")
|
| 611 |
+
await expect_async(overlay_locator).to_be_visible(timeout=WAIT_FOR_ELEMENT_TIMEOUT_MS)
|
| 612 |
+
self.logger.info(f"[{self.req_id}] 清空聊天确认遮罩层已出现。")
|
| 613 |
+
except TimeoutError:
|
| 614 |
+
error_msg = f"等待清空聊天确认遮罩层超时 (点击清空按钮后)。请求 ID: {self.req_id}"
|
| 615 |
+
self.logger.error(error_msg)
|
| 616 |
+
await save_error_snapshot(f"clear_chat_overlay_timeout_{self.req_id}")
|
| 617 |
+
raise Exception(error_msg)
|
| 618 |
+
|
| 619 |
+
await self._check_disconnect(check_client_disconnected, "清空聊天 - 遮罩层出现后")
|
| 620 |
+
self.logger.info(f"[{self.req_id}] 点击\"继续\"按钮 (在对话框中): {CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR}")
|
| 621 |
+
await confirm_button_locator.click(timeout=CLICK_TIMEOUT_MS)
|
| 622 |
+
|
| 623 |
+
await self._check_disconnect(check_client_disconnected, "清空聊天 - 点击\"继续\"后")
|
| 624 |
+
|
| 625 |
+
# 等待对话框消失
|
| 626 |
+
max_retries_disappear = 3
|
| 627 |
+
for attempt_disappear in range(max_retries_disappear):
|
| 628 |
+
try:
|
| 629 |
+
self.logger.info(f"[{self.req_id}] 等待清空聊天确认按钮/对话框消失 (尝试 {attempt_disappear + 1}/{max_retries_disappear})...")
|
| 630 |
+
await expect_async(confirm_button_locator).to_be_hidden(timeout=CLEAR_CHAT_VERIFY_TIMEOUT_MS)
|
| 631 |
+
await expect_async(overlay_locator).to_be_hidden(timeout=1000)
|
| 632 |
+
self.logger.info(f"[{self.req_id}] ✅ 清空聊天确认对话框已成功消失。")
|
| 633 |
+
break
|
| 634 |
+
except TimeoutError:
|
| 635 |
+
self.logger.warning(f"[{self.req_id}] ⚠️ 等待清空聊天确认对话框消失超时 (尝试 {attempt_disappear + 1}/{max_retries_disappear})。")
|
| 636 |
+
if attempt_disappear < max_retries_disappear - 1:
|
| 637 |
+
await asyncio.sleep(1.0)
|
| 638 |
+
await self._check_disconnect(check_client_disconnected, f"清空聊天 - 重试消失检查 {attempt_disappear + 1} 前")
|
| 639 |
+
continue
|
| 640 |
+
else:
|
| 641 |
+
error_msg = f"达到最大重试次数。清空聊天确认对话框未消失。请求 ID: {self.req_id}"
|
| 642 |
+
self.logger.error(error_msg)
|
| 643 |
+
await save_error_snapshot(f"clear_chat_dialog_disappear_timeout_{self.req_id}")
|
| 644 |
+
raise Exception(error_msg)
|
| 645 |
+
except ClientDisconnectedError:
|
| 646 |
+
self.logger.info(f"[{self.req_id}] 客户端在等待清空确认对话框消失时断开连接。")
|
| 647 |
+
raise
|
| 648 |
+
except Exception as other_err:
|
| 649 |
+
self.logger.warning(f"[{self.req_id}] 等待清空确认对话框消失时发生其他错误: {other_err}")
|
| 650 |
+
if attempt_disappear < max_retries_disappear - 1:
|
| 651 |
+
await asyncio.sleep(1.0)
|
| 652 |
+
continue
|
| 653 |
+
else:
|
| 654 |
+
raise
|
| 655 |
+
|
| 656 |
+
await self._check_disconnect(check_client_disconnected, f"清空聊天 - 消失检查尝试 {attempt_disappear + 1} 后")
|
| 657 |
+
|
| 658 |
+
async def _verify_chat_cleared(self, check_client_disconnected: Callable):
|
| 659 |
+
"""验证聊天已清空"""
|
| 660 |
+
last_response_container = self.page.locator(RESPONSE_CONTAINER_SELECTOR).last
|
| 661 |
+
await asyncio.sleep(0.5)
|
| 662 |
+
await self._check_disconnect(check_client_disconnected, "After Clear Post-Delay")
|
| 663 |
+
try:
|
| 664 |
+
await expect_async(last_response_container).to_be_hidden(timeout=CLEAR_CHAT_VERIFY_TIMEOUT_MS - 500)
|
| 665 |
+
self.logger.info(f"[{self.req_id}] ✅ 聊天已成功清空 (验证通过 - 最后响应容器隐藏)。")
|
| 666 |
+
except Exception as verify_err:
|
| 667 |
+
self.logger.warning(f"[{self.req_id}] ⚠️ 警告: 清空聊天验证失败 (最后响应容器未隐藏): {verify_err}")
|
| 668 |
+
|
| 669 |
+
async def submit_prompt(self, prompt: str,image_list: List, check_client_disconnected: Callable):
|
| 670 |
+
"""提交提示到页面。"""
|
| 671 |
+
self.logger.info(f"[{self.req_id}] 填充并提交提示 ({len(prompt)} chars)...")
|
| 672 |
+
prompt_textarea_locator = self.page.locator(PROMPT_TEXTAREA_SELECTOR)
|
| 673 |
+
autosize_wrapper_locator = self.page.locator('ms-prompt-input-wrapper ms-autosize-textarea')
|
| 674 |
+
submit_button_locator = self.page.locator(SUBMIT_BUTTON_SELECTOR)
|
| 675 |
+
|
| 676 |
+
try:
|
| 677 |
+
await expect_async(prompt_textarea_locator).to_be_visible(timeout=5000)
|
| 678 |
+
await self._check_disconnect(check_client_disconnected, "After Input Visible")
|
| 679 |
+
|
| 680 |
+
# 使用 JavaScript 填充文本
|
| 681 |
+
await prompt_textarea_locator.evaluate(
|
| 682 |
+
'''
|
| 683 |
+
(element, text) => {
|
| 684 |
+
element.value = text;
|
| 685 |
+
element.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
|
| 686 |
+
element.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
|
| 687 |
+
}
|
| 688 |
+
''',
|
| 689 |
+
prompt
|
| 690 |
+
)
|
| 691 |
+
await autosize_wrapper_locator.evaluate('(element, text) => { element.setAttribute("data-value", text); }', prompt)
|
| 692 |
+
await self._check_disconnect(check_client_disconnected, "After Input Fill")
|
| 693 |
+
|
| 694 |
+
# 上传
|
| 695 |
+
if len(image_list) > 0:
|
| 696 |
+
try:
|
| 697 |
+
# 1. 监听文件选择器
|
| 698 |
+
# page.expect_file_chooser() 会返回一个上下文管理器
|
| 699 |
+
# 当文件选择器出现时,它会得到 FileChooser 对象
|
| 700 |
+
function_btn_localtor = self.page.locator('button[aria-label="Insert assets such as images, videos, files, or audio"]')
|
| 701 |
+
await function_btn_localtor.click()
|
| 702 |
+
#asyncio.sleep(0.5)
|
| 703 |
+
async with self.page.expect_file_chooser() as fc_info:
|
| 704 |
+
# 2. 点击那个会触发文件选择的普通按钮
|
| 705 |
+
upload_btn_localtor = self.page.locator(UPLOAD_BUTTON_SELECTOR)
|
| 706 |
+
await upload_btn_localtor.click()
|
| 707 |
+
print("点击了 JS 上传按钮,等待文件选择器...")
|
| 708 |
+
|
| 709 |
+
# 3. 获取文件选择器对象
|
| 710 |
+
file_chooser = await fc_info.value
|
| 711 |
+
print("文件选择器已出现。")
|
| 712 |
+
|
| 713 |
+
# 4. 设置要上传的文件
|
| 714 |
+
await file_chooser.set_files(image_list)
|
| 715 |
+
print(f"已将 '{image_list}' 设置到文件选择器。")
|
| 716 |
+
|
| 717 |
+
#asyncio.sleep(0.2)
|
| 718 |
+
acknow_btn_locator = self.page.locator('button[aria-label="Agree to the copyright acknowledgement"]')
|
| 719 |
+
if await acknow_btn_locator.count() > 0:
|
| 720 |
+
await acknow_btn_locator.click()
|
| 721 |
+
|
| 722 |
+
except Exception as e:
|
| 723 |
+
print(f"在上传文件时发生错误: {e}")
|
| 724 |
+
|
| 725 |
+
# 等待发送按钮启用
|
| 726 |
+
wait_timeout_ms_submit_enabled = 100000
|
| 727 |
+
try:
|
| 728 |
+
await self._check_disconnect(check_client_disconnected, "填充提示后等待发送按钮启用 - 前置检查")
|
| 729 |
+
await expect_async(submit_button_locator).to_be_enabled(timeout=wait_timeout_ms_submit_enabled)
|
| 730 |
+
self.logger.info(f"[{self.req_id}] ✅ 发送按钮已启用。")
|
| 731 |
+
except Exception as e_pw_enabled:
|
| 732 |
+
self.logger.error(f"[{self.req_id}] ❌ 等待发送按钮启用超时或错误: {e_pw_enabled}")
|
| 733 |
+
await save_error_snapshot(f"submit_button_enable_timeout_{self.req_id}")
|
| 734 |
+
raise
|
| 735 |
+
|
| 736 |
+
await self._check_disconnect(check_client_disconnected, "After Submit Button Enabled")
|
| 737 |
+
await asyncio.sleep(0.3)
|
| 738 |
+
|
| 739 |
+
# 尝试使用快捷键提交
|
| 740 |
+
submitted_successfully = await self._try_shortcut_submit(prompt_textarea_locator, check_client_disconnected)
|
| 741 |
+
|
| 742 |
+
# 如果快捷键失败,使用按钮点击
|
| 743 |
+
if not submitted_successfully:
|
| 744 |
+
self.logger.info(f"[{self.req_id}] 快捷键提交失败,尝试点击提交按钮...")
|
| 745 |
+
try:
|
| 746 |
+
await submit_button_locator.click(timeout=5000)
|
| 747 |
+
self.logger.info(f"[{self.req_id}] ✅ 提交按钮点击完成。")
|
| 748 |
+
except Exception as click_err:
|
| 749 |
+
self.logger.error(f"[{self.req_id}] ❌ 提交按钮点击失败: {click_err}")
|
| 750 |
+
await save_error_snapshot(f"submit_button_click_fail_{self.req_id}")
|
| 751 |
+
raise
|
| 752 |
+
|
| 753 |
+
await self._check_disconnect(check_client_disconnected, "After Submit")
|
| 754 |
+
|
| 755 |
+
except Exception as e_input_submit:
|
| 756 |
+
self.logger.error(f"[{self.req_id}] 输入和提交过程中发生错误: {e_input_submit}")
|
| 757 |
+
if not isinstance(e_input_submit, ClientDisconnectedError):
|
| 758 |
+
await save_error_snapshot(f"input_submit_error_{self.req_id}")
|
| 759 |
+
raise
|
| 760 |
+
|
| 761 |
+
async def _try_shortcut_submit(self, prompt_textarea_locator, check_client_disconnected: Callable) -> bool:
|
| 762 |
+
"""尝试使用快捷键提交"""
|
| 763 |
+
import os
|
| 764 |
+
try:
|
| 765 |
+
# 检测操作系统
|
| 766 |
+
host_os_from_launcher = os.environ.get('HOST_OS_FOR_SHORTCUT')
|
| 767 |
+
is_mac_determined = False
|
| 768 |
+
|
| 769 |
+
if host_os_from_launcher == "Darwin":
|
| 770 |
+
is_mac_determined = True
|
| 771 |
+
elif host_os_from_launcher in ["Windows", "Linux"]:
|
| 772 |
+
is_mac_determined = False
|
| 773 |
+
else:
|
| 774 |
+
# 使用浏览器检测
|
| 775 |
+
try:
|
| 776 |
+
user_agent_data_platform = await self.page.evaluate("() => navigator.userAgentData?.platform || ''")
|
| 777 |
+
except Exception:
|
| 778 |
+
user_agent_string = await self.page.evaluate("() => navigator.userAgent || ''")
|
| 779 |
+
user_agent_string_lower = user_agent_string.lower()
|
| 780 |
+
if "macintosh" in user_agent_string_lower or "mac os x" in user_agent_string_lower:
|
| 781 |
+
user_agent_data_platform = "macOS"
|
| 782 |
+
else:
|
| 783 |
+
user_agent_data_platform = "Other"
|
| 784 |
+
|
| 785 |
+
is_mac_determined = "mac" in user_agent_data_platform.lower()
|
| 786 |
+
|
| 787 |
+
shortcut_modifier = "Meta" if is_mac_determined else "Control"
|
| 788 |
+
shortcut_key = "Enter"
|
| 789 |
+
|
| 790 |
+
self.logger.info(f"[{self.req_id}] 使用快捷键: {shortcut_modifier}+{shortcut_key}")
|
| 791 |
+
|
| 792 |
+
await prompt_textarea_locator.focus(timeout=5000)
|
| 793 |
+
await self._check_disconnect(check_client_disconnected, "After Input Focus")
|
| 794 |
+
await asyncio.sleep(0.1)
|
| 795 |
+
|
| 796 |
+
# 记录提交前的输入框内容,用于验证
|
| 797 |
+
original_content = ""
|
| 798 |
+
try:
|
| 799 |
+
original_content = await prompt_textarea_locator.input_value(timeout=2000) or ""
|
| 800 |
+
except Exception:
|
| 801 |
+
# 如果无法获取原始内容,仍然尝试提交
|
| 802 |
+
pass
|
| 803 |
+
|
| 804 |
+
try:
|
| 805 |
+
await self.page.keyboard.press(f'{shortcut_modifier}+{shortcut_key}')
|
| 806 |
+
except Exception:
|
| 807 |
+
# 尝试分步按键
|
| 808 |
+
await self.page.keyboard.down(shortcut_modifier)
|
| 809 |
+
await asyncio.sleep(0.05)
|
| 810 |
+
await self.page.keyboard.press(shortcut_key)
|
| 811 |
+
await asyncio.sleep(0.05)
|
| 812 |
+
await self.page.keyboard.up(shortcut_modifier)
|
| 813 |
+
|
| 814 |
+
await self._check_disconnect(check_client_disconnected, "After Shortcut Press")
|
| 815 |
+
|
| 816 |
+
# 等待更长时间让提交完成
|
| 817 |
+
await asyncio.sleep(2.0)
|
| 818 |
+
|
| 819 |
+
# 多种方式验证提交是否成功
|
| 820 |
+
submission_success = False
|
| 821 |
+
|
| 822 |
+
try:
|
| 823 |
+
# 方法1: 检查原始输入框是否清空
|
| 824 |
+
current_content = await prompt_textarea_locator.input_value(timeout=2000) or ""
|
| 825 |
+
if original_content and not current_content.strip():
|
| 826 |
+
self.logger.info(f"[{self.req_id}] 验证方法1: 输入框已清空,快捷键提交成功")
|
| 827 |
+
submission_success = True
|
| 828 |
+
|
| 829 |
+
# 方法2: 检查提交按钮状态
|
| 830 |
+
if not submission_success:
|
| 831 |
+
submit_button_locator = self.page.locator(SUBMIT_BUTTON_SELECTOR)
|
| 832 |
+
try:
|
| 833 |
+
is_disabled = await submit_button_locator.is_disabled(timeout=2000)
|
| 834 |
+
if is_disabled:
|
| 835 |
+
self.logger.info(f"[{self.req_id}] 验证方法2: 提交按钮已禁用,快捷键提交成功")
|
| 836 |
+
submission_success = True
|
| 837 |
+
except Exception:
|
| 838 |
+
pass
|
| 839 |
+
|
| 840 |
+
# 方法3: 检查是否有响应容器出现
|
| 841 |
+
if not submission_success:
|
| 842 |
+
try:
|
| 843 |
+
response_container = self.page.locator(RESPONSE_CONTAINER_SELECTOR)
|
| 844 |
+
container_count = await response_container.count()
|
| 845 |
+
if container_count > 0:
|
| 846 |
+
# 检查最后一个容器是否是新的
|
| 847 |
+
last_container = response_container.last
|
| 848 |
+
if await last_container.is_visible(timeout=1000):
|
| 849 |
+
self.logger.info(f"[{self.req_id}] 验证方法3: 检测到响应容器,快捷键提交成功")
|
| 850 |
+
submission_success = True
|
| 851 |
+
except Exception:
|
| 852 |
+
pass
|
| 853 |
+
|
| 854 |
+
except Exception as verify_err:
|
| 855 |
+
self.logger.warning(f"[{self.req_id}] 快捷键提交验证过程出错: {verify_err}")
|
| 856 |
+
# 出错时假定提交成功,让后续流程继续
|
| 857 |
+
submission_success = True
|
| 858 |
+
|
| 859 |
+
if submission_success:
|
| 860 |
+
self.logger.info(f"[{self.req_id}] ✅ 快捷键提交成功")
|
| 861 |
+
return True
|
| 862 |
+
else:
|
| 863 |
+
self.logger.warning(f"[{self.req_id}] ⚠️ 快捷键提交验证失败")
|
| 864 |
+
return False
|
| 865 |
+
|
| 866 |
+
except Exception as shortcut_err:
|
| 867 |
+
self.logger.warning(f"[{self.req_id}] 快捷键提交失败: {shortcut_err}")
|
| 868 |
+
return False
|
| 869 |
+
|
| 870 |
+
async def get_response(self, check_client_disconnected: Callable) -> str:
|
| 871 |
+
"""获取响应内容。"""
|
| 872 |
+
self.logger.info(f"[{self.req_id}] 等待并获取响应...")
|
| 873 |
+
|
| 874 |
+
try:
|
| 875 |
+
# 等待响应容器出现
|
| 876 |
+
response_container_locator = self.page.locator(RESPONSE_CONTAINER_SELECTOR).last
|
| 877 |
+
response_element_locator = response_container_locator.locator(RESPONSE_TEXT_SELECTOR)
|
| 878 |
+
|
| 879 |
+
self.logger.info(f"[{self.req_id}] 等待响应元素附加到DOM...")
|
| 880 |
+
await expect_async(response_element_locator).to_be_attached(timeout=90000)
|
| 881 |
+
await self._check_disconnect(check_client_disconnected, "获取响应 - 响应元素已附加")
|
| 882 |
+
|
| 883 |
+
# 等待响应完成
|
| 884 |
+
submit_button_locator = self.page.locator(SUBMIT_BUTTON_SELECTOR)
|
| 885 |
+
edit_button_locator = self.page.locator(EDIT_MESSAGE_BUTTON_SELECTOR)
|
| 886 |
+
input_field_locator = self.page.locator(PROMPT_TEXTAREA_SELECTOR)
|
| 887 |
+
|
| 888 |
+
self.logger.info(f"[{self.req_id}] 等待响应完成...")
|
| 889 |
+
completion_detected = await _wait_for_response_completion(
|
| 890 |
+
self.page, input_field_locator, submit_button_locator, edit_button_locator, self.req_id, check_client_disconnected, None
|
| 891 |
+
)
|
| 892 |
+
|
| 893 |
+
if not completion_detected:
|
| 894 |
+
self.logger.warning(f"[{self.req_id}] 响应完成检测失败,尝试获取当前内容")
|
| 895 |
+
else:
|
| 896 |
+
self.logger.info(f"[{self.req_id}] ✅ 响应完成检测成功")
|
| 897 |
+
|
| 898 |
+
# 获取最终响应内容
|
| 899 |
+
final_content = await _get_final_response_content(self.page, self.req_id, check_client_disconnected)
|
| 900 |
+
|
| 901 |
+
if not final_content or not final_content.strip():
|
| 902 |
+
self.logger.warning(f"[{self.req_id}] ⚠️ 获取到的响应内容为空")
|
| 903 |
+
await save_error_snapshot(f"empty_response_{self.req_id}")
|
| 904 |
+
# 不抛出异常,返回空内容让上层处理
|
| 905 |
+
return ""
|
| 906 |
+
|
| 907 |
+
self.logger.info(f"[{self.req_id}] ✅ 成功获取响应内容 ({len(final_content)} chars)")
|
| 908 |
+
return final_content
|
| 909 |
+
|
| 910 |
+
except Exception as e:
|
| 911 |
+
self.logger.error(f"[{self.req_id}] ❌ 获取响应时出错: {e}")
|
| 912 |
+
if not isinstance(e, ClientDisconnectedError):
|
| 913 |
+
await save_error_snapshot(f"get_response_error_{self.req_id}")
|
| 914 |
+
raise
|
AIStudioProxyAPI/browser_utils/script_manager.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- browser_utils/script_manager.py ---
|
| 2 |
+
# 油猴脚本管理模块 - 动态挂载和注入脚本功能
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Dict, List, Optional, Any
|
| 8 |
+
from playwright.async_api import Page as AsyncPage
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger("AIStudioProxyServer")
|
| 11 |
+
|
| 12 |
+
class ScriptManager:
|
| 13 |
+
"""油猴脚本管理器 - 负责动态加载和注入脚本"""
|
| 14 |
+
|
| 15 |
+
def __init__(self, script_dir: str = "browser_utils"):
|
| 16 |
+
self.script_dir = script_dir
|
| 17 |
+
self.loaded_scripts: Dict[str, str] = {}
|
| 18 |
+
self.model_configs: Dict[str, List[Dict[str, Any]]] = {}
|
| 19 |
+
|
| 20 |
+
def load_script(self, script_name: str) -> Optional[str]:
|
| 21 |
+
"""加载指定的JavaScript脚本文件"""
|
| 22 |
+
script_path = os.path.join(self.script_dir, script_name)
|
| 23 |
+
|
| 24 |
+
if not os.path.exists(script_path):
|
| 25 |
+
logger.error(f"脚本文件不存在: {script_path}")
|
| 26 |
+
return None
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
with open(script_path, 'r', encoding='utf-8') as f:
|
| 30 |
+
script_content = f.read()
|
| 31 |
+
self.loaded_scripts[script_name] = script_content
|
| 32 |
+
logger.info(f"成功加载脚本: {script_name}")
|
| 33 |
+
return script_content
|
| 34 |
+
except Exception as e:
|
| 35 |
+
logger.error(f"加载脚本失败 {script_name}: {e}")
|
| 36 |
+
return None
|
| 37 |
+
|
| 38 |
+
def load_model_config(self, config_path: str) -> Optional[List[Dict[str, Any]]]:
|
| 39 |
+
"""加载模型配置文件"""
|
| 40 |
+
if not os.path.exists(config_path):
|
| 41 |
+
logger.warning(f"模型配置文件不存在: {config_path}")
|
| 42 |
+
return None
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
with open(config_path, 'r', encoding='utf-8') as f:
|
| 46 |
+
config_data = json.load(f)
|
| 47 |
+
models = config_data.get('models', [])
|
| 48 |
+
self.model_configs[config_path] = models
|
| 49 |
+
logger.info(f"成功加载模型配置: {len(models)} 个模型")
|
| 50 |
+
return models
|
| 51 |
+
except Exception as e:
|
| 52 |
+
logger.error(f"加载模型配置失败 {config_path}: {e}")
|
| 53 |
+
return None
|
| 54 |
+
|
| 55 |
+
def generate_dynamic_script(self, base_script: str, models: List[Dict[str, Any]],
|
| 56 |
+
script_version: str = "dynamic") -> str:
|
| 57 |
+
"""基于模型配置动态生成脚本内容"""
|
| 58 |
+
try:
|
| 59 |
+
# 构建模型列表的JavaScript代码
|
| 60 |
+
models_js = "const MODELS_TO_INJECT = [\n"
|
| 61 |
+
for model in models:
|
| 62 |
+
name = model.get('name', '')
|
| 63 |
+
display_name = model.get('displayName', model.get('display_name', ''))
|
| 64 |
+
description = model.get('description', f'Model injected by script {script_version}')
|
| 65 |
+
|
| 66 |
+
# 如果displayName中没有包含版本信息,添加版本信息
|
| 67 |
+
if f"(Script {script_version})" not in display_name:
|
| 68 |
+
display_name = f"{display_name} (Script {script_version})"
|
| 69 |
+
|
| 70 |
+
models_js += f""" {{
|
| 71 |
+
name: '{name}',
|
| 72 |
+
displayName: `{display_name}`,
|
| 73 |
+
description: `{description}`
|
| 74 |
+
}},\n"""
|
| 75 |
+
|
| 76 |
+
models_js += " ];"
|
| 77 |
+
|
| 78 |
+
# 替换脚本中的模型定义部分
|
| 79 |
+
# 查找模型定义的开始和结束标记
|
| 80 |
+
start_marker = "const MODELS_TO_INJECT = ["
|
| 81 |
+
end_marker = "];"
|
| 82 |
+
|
| 83 |
+
start_idx = base_script.find(start_marker)
|
| 84 |
+
if start_idx == -1:
|
| 85 |
+
logger.error("未找到模型定义开始标记")
|
| 86 |
+
return base_script
|
| 87 |
+
|
| 88 |
+
# 找到对应的结束标记
|
| 89 |
+
bracket_count = 0
|
| 90 |
+
end_idx = start_idx + len(start_marker)
|
| 91 |
+
found_end = False
|
| 92 |
+
|
| 93 |
+
for i in range(end_idx, len(base_script)):
|
| 94 |
+
if base_script[i] == '[':
|
| 95 |
+
bracket_count += 1
|
| 96 |
+
elif base_script[i] == ']':
|
| 97 |
+
if bracket_count == 0:
|
| 98 |
+
end_idx = i + 1
|
| 99 |
+
found_end = True
|
| 100 |
+
break
|
| 101 |
+
bracket_count -= 1
|
| 102 |
+
|
| 103 |
+
if not found_end:
|
| 104 |
+
logger.error("未找到模型定义结束标记")
|
| 105 |
+
return base_script
|
| 106 |
+
|
| 107 |
+
# 替换模型定义部分
|
| 108 |
+
new_script = (base_script[:start_idx] +
|
| 109 |
+
models_js +
|
| 110 |
+
base_script[end_idx:])
|
| 111 |
+
|
| 112 |
+
# 更新版本号
|
| 113 |
+
new_script = new_script.replace(
|
| 114 |
+
f'const SCRIPT_VERSION = "v1.6";',
|
| 115 |
+
f'const SCRIPT_VERSION = "{script_version}";'
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
logger.info(f"成功生成动态脚本,包含 {len(models)} 个模型")
|
| 119 |
+
return new_script
|
| 120 |
+
|
| 121 |
+
except Exception as e:
|
| 122 |
+
logger.error(f"生成动态脚本失败: {e}")
|
| 123 |
+
return base_script
|
| 124 |
+
|
| 125 |
+
async def inject_script_to_page(self, page: AsyncPage, script_content: str,
|
| 126 |
+
script_name: str = "injected_script") -> bool:
|
| 127 |
+
"""将脚本注入到页面中"""
|
| 128 |
+
try:
|
| 129 |
+
# 移除UserScript头部信息,因为我们是直接注入而不是通过油猴
|
| 130 |
+
cleaned_script = self._clean_userscript_headers(script_content)
|
| 131 |
+
|
| 132 |
+
# 注入脚本
|
| 133 |
+
await page.add_init_script(cleaned_script)
|
| 134 |
+
logger.info(f"成功注入脚本到页面: {script_name}")
|
| 135 |
+
return True
|
| 136 |
+
|
| 137 |
+
except Exception as e:
|
| 138 |
+
logger.error(f"注入脚本到页面失败 {script_name}: {e}")
|
| 139 |
+
return False
|
| 140 |
+
|
| 141 |
+
def _clean_userscript_headers(self, script_content: str) -> str:
|
| 142 |
+
"""清理UserScript头部信息"""
|
| 143 |
+
lines = script_content.split('\n')
|
| 144 |
+
cleaned_lines = []
|
| 145 |
+
in_userscript_block = False
|
| 146 |
+
|
| 147 |
+
for line in lines:
|
| 148 |
+
if line.strip().startswith('// ==UserScript=='):
|
| 149 |
+
in_userscript_block = True
|
| 150 |
+
continue
|
| 151 |
+
elif line.strip().startswith('// ==/UserScript=='):
|
| 152 |
+
in_userscript_block = False
|
| 153 |
+
continue
|
| 154 |
+
elif in_userscript_block:
|
| 155 |
+
continue
|
| 156 |
+
else:
|
| 157 |
+
cleaned_lines.append(line)
|
| 158 |
+
|
| 159 |
+
return '\n'.join(cleaned_lines)
|
| 160 |
+
|
| 161 |
+
async def setup_model_injection(self, page: AsyncPage,
|
| 162 |
+
script_name: str = "more_modles.js") -> bool:
|
| 163 |
+
"""设置模型注入 - 直接注入油猴脚本"""
|
| 164 |
+
|
| 165 |
+
# 检查脚本文件是否存在
|
| 166 |
+
script_path = os.path.join(self.script_dir, script_name)
|
| 167 |
+
if not os.path.exists(script_path):
|
| 168 |
+
# 脚本文件不存在,静默跳过注入
|
| 169 |
+
return False
|
| 170 |
+
|
| 171 |
+
logger.info("开始设置模型注入...")
|
| 172 |
+
|
| 173 |
+
# 加载油猴脚本
|
| 174 |
+
script_content = self.load_script(script_name)
|
| 175 |
+
if not script_content:
|
| 176 |
+
return False
|
| 177 |
+
|
| 178 |
+
# 直接注入原始脚本(不修改内容)
|
| 179 |
+
return await self.inject_script_to_page(page, script_content, script_name)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# 全局脚本管理器实例
|
| 183 |
+
script_manager = ScriptManager()
|
AIStudioProxyAPI/certs/ca.crt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-----BEGIN CERTIFICATE-----
|
| 2 |
+
MIIDezCCAmOgAwIBAgIUG8OzexRwcoAo18YNsf3/t4cPKoQwDQYJKoZIhvcNAQEL
|
| 3 |
+
BQAwZTELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcM
|
| 4 |
+
DVNhbiBGcmFuY2lzY28xETAPBgNVBAoMCFByb3h5IENBMRYwFAYDVQQDDA1Qcm94
|
| 5 |
+
eSBDQSBSb290MB4XDTI1MDUxOTEwMDgxNFoXDTM1MDUxNzEwMDgxNFowZTELMAkG
|
| 6 |
+
A1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFu
|
| 7 |
+
Y2lzY28xETAPBgNVBAoMCFByb3h5IENBMRYwFAYDVQQDDA1Qcm94eSBDQSBSb290
|
| 8 |
+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqpXNSaRrG9X6fin8Nk7G
|
| 9 |
+
fiKO59tiFfbZZt5ls/Mc59oq60GFRfKW/oLqyntbjNHHIeUOhEI8317D+RZJA2IE
|
| 10 |
+
PGcYf7ANlrzD8sPlRHl3mkSqwmmV3CtTOGpznxbHSFF02QMvF4pHTrALkJXJhXnb
|
| 11 |
+
Ofo1z6i6dkCMU7nCvZTgcsvg/kay7XsLZwU165PJwMj0QjyAdI4WIVr6gr3mH9/a
|
| 12 |
+
WMmLc9NU+rA4GT5n9dj/ljbd5+9KeBcZGwb4O5pcaxJENQ7+5TwsoJFbLT88IGSQ
|
| 13 |
+
Wbgb99MebxD6gqxoA3j8+gnXADtIeKokbeNPblEig3p68KHJ51iChvq/tbe92Xon
|
| 14 |
+
uQIDAQABoyMwITAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBpjANBgkq
|
| 15 |
+
hkiG9w0BAQsFAAOCAQEAdxHc3yFi8qOqltnKoFoo0LF2Zh2y4yUDQeC2ACIhuam+
|
| 16 |
+
DqfTag1oNw0Sa0o3JVQHoi1B5UslU3gB/aMqP1swVMOpw9okzStcXjKjUVSNYyTB
|
| 17 |
+
fT27Ddtf4/5ftZjsdI5TznQGiv00zPh+tsi5oqCPmF6azDTXiezyx3fhR9mqdXsq
|
| 18 |
+
W3rCZO/xIhKutGkRxNMBWAXXl5nAlW6FXJObZ3DRKRWjXhydk8zNQSxnxy8Z01nb
|
| 19 |
+
1Frtuh/+9S9JeKX1jYKFFUzmumAq/nXY6X3yqCwbNgnqpwETXPM9DVzzs8wDC/OJ
|
| 20 |
+
xDXzdHmtgRK9dHcnoT4YYUR27UX3OPS+ZGraR5RJpw==
|
| 21 |
+
-----END CERTIFICATE-----
|
AIStudioProxyAPI/certs/ca.key
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-----BEGIN PRIVATE KEY-----
|
| 2 |
+
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCqlc1JpGsb1fp+
|
| 3 |
+
Kfw2TsZ+Io7n22IV9tlm3mWz8xzn2irrQYVF8pb+gurKe1uM0cch5Q6EQjzfXsP5
|
| 4 |
+
FkkDYgQ8Zxh/sA2WvMPyw+VEeXeaRKrCaZXcK1M4anOfFsdIUXTZAy8XikdOsAuQ
|
| 5 |
+
lcmFeds5+jXPqLp2QIxTucK9lOByy+D+RrLtewtnBTXrk8nAyPRCPIB0jhYhWvqC
|
| 6 |
+
veYf39pYyYtz01T6sDgZPmf12P+WNt3n70p4FxkbBvg7mlxrEkQ1Dv7lPCygkVst
|
| 7 |
+
PzwgZJBZuBv30x5vEPqCrGgDePz6CdcAO0h4qiRt409uUSKDenrwocnnWIKG+r+1
|
| 8 |
+
t73Zeie5AgMBAAECggEASQwc/IwL0b+vpJcWCatyFFF4IJExT3aFYieaJZTVq/Mg
|
| 9 |
+
rd1A1NMtFY+6OzrX2VV7kGgl7zzuFDjgcqm4Wlp+td7v/r3FE+eBgVOhudDKBqWg
|
| 10 |
+
+d987Osgl+f92wJGFBHNl6Blag8sueVpDmEWCrJDzm/22xXFwx2g+blySvyVoJI6
|
| 11 |
+
oxYE8xVu2oBG/B4CuVbJNEUNNYek39kGroTGEn+cpZJOq/NnGpatz684FstbrEiN
|
| 12 |
+
xMQzl0qlI785d0DRGShApzh1hCUa+8uJJc+qACZEU+XS9MKeNzbCgc6VeEEVOytd
|
| 13 |
+
7Zv0Eknt4X+E0jWdUslvHHqOgw+zN/cEpgz1GamKgQKBgQDVbbXIoNkEmN9Yd8CQ
|
| 14 |
+
PjhE9Fbae0bcfYwjJY3crw+HRPs5cvi2OTsasNlZb562pSHBf3MFgCNbHb6aA+UX
|
| 15 |
+
qdIeyV33a43mag1Z68Qa5pqKnCqIjY840lSDb4oqWdBesxtjj0dWOU7K2Tqu+dq4
|
| 16 |
+
2ekGcLmIuPj0q6DCvNgyk6GyEwKBgQDMnGHYaP+1zW0TfNQcZgSMLMgnC3pcBCfP
|
| 17 |
+
/2HDwVPVzZzNyV+N/VFtCiMD9f7cI0Bd9xAK67VOpIEF24S8fZbl77HvRzr65LkW
|
| 18 |
+
HVm1XmuyTx7hseB59LMudVl9hwIcHzod+jQmXlEhuQZFOBbRgO6OIh4oGV9Z0/Xl
|
| 19 |
+
Wsrc8hTYgwKBgQCIg48V0ARf02RwktBhstqNCHiRcO6nU8qSJJAzyum0zSOf4HFD
|
| 20 |
+
JSIv9VRgx2uOSdtoiBvLNeXnfwQOQVWEqEPVG1n2Sx5NdiIqFQqvZjcNV8xA4cLt
|
| 21 |
+
RmN2Wp7WbfJA0HFBYkDv3uIOD5pgl0IWoJNTYkDaOe5LmYfPZ7klyJZRbwKBgESM
|
| 22 |
+
T6t04dZCkDxrIZSyCOv9RMDv83pIWh4w7MvsRO3oCJRY1o53Q4RIVRrKmyudE79n
|
| 23 |
+
OhSuivth2Wfg90M+wAMgnngPYQ8U+X0TMC63B1WhdDMgqJezBySVY/nN9UL+ozXP
|
| 24 |
+
0RDZoEyv9A3UkLB3hXRQsdG1TmCFxmekVzpWT+2JAoGAUC6/Jv8IgTq2i7sNOdY4
|
| 25 |
+
HK1aJErgV15B26thFk23tfEpW6YhvCEhIsc30/n0NRczQwbgqXCZ7HSvmG9YU93K
|
| 26 |
+
YDzR1hwoQ4K7NE95je9YYMmrjncL2LZFXxpnS2PdbRoi2eDh4JgTfYB93zoDgDey
|
| 27 |
+
hCTKeTi+JBGdvZ93pxTCowo=
|
| 28 |
+
-----END PRIVATE KEY-----
|
AIStudioProxyAPI/config/__init__.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
配置模块统一入口
|
| 3 |
+
导出所有配置项,便于其他模块导入使用
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
# 从各个配置文件导入所有配置项
|
| 7 |
+
from .constants import *
|
| 8 |
+
from .timeouts import *
|
| 9 |
+
from .selectors import *
|
| 10 |
+
from .settings import *
|
| 11 |
+
|
| 12 |
+
# 显式导出主要配置项(用于IDE自动完成和类型检查)
|
| 13 |
+
__all__ = [
|
| 14 |
+
# 常量配置
|
| 15 |
+
'MODEL_NAME',
|
| 16 |
+
'CHAT_COMPLETION_ID_PREFIX',
|
| 17 |
+
'DEFAULT_FALLBACK_MODEL_ID',
|
| 18 |
+
'DEFAULT_TEMPERATURE',
|
| 19 |
+
'DEFAULT_MAX_OUTPUT_TOKENS',
|
| 20 |
+
'DEFAULT_TOP_P',
|
| 21 |
+
'DEFAULT_STOP_SEQUENCES',
|
| 22 |
+
'AI_STUDIO_URL_PATTERN',
|
| 23 |
+
'MODELS_ENDPOINT_URL_CONTAINS',
|
| 24 |
+
'USER_INPUT_START_MARKER_SERVER',
|
| 25 |
+
'USER_INPUT_END_MARKER_SERVER',
|
| 26 |
+
'EXCLUDED_MODELS_FILENAME',
|
| 27 |
+
'STREAM_TIMEOUT_LOG_STATE',
|
| 28 |
+
|
| 29 |
+
# 超时配置
|
| 30 |
+
'RESPONSE_COMPLETION_TIMEOUT',
|
| 31 |
+
'INITIAL_WAIT_MS_BEFORE_POLLING',
|
| 32 |
+
'POLLING_INTERVAL',
|
| 33 |
+
'POLLING_INTERVAL_STREAM',
|
| 34 |
+
'SILENCE_TIMEOUT_MS',
|
| 35 |
+
'POST_SPINNER_CHECK_DELAY_MS',
|
| 36 |
+
'FINAL_STATE_CHECK_TIMEOUT_MS',
|
| 37 |
+
'POST_COMPLETION_BUFFER',
|
| 38 |
+
'CLEAR_CHAT_VERIFY_TIMEOUT_MS',
|
| 39 |
+
'CLEAR_CHAT_VERIFY_INTERVAL_MS',
|
| 40 |
+
'CLICK_TIMEOUT_MS',
|
| 41 |
+
'CLIPBOARD_READ_TIMEOUT_MS',
|
| 42 |
+
'WAIT_FOR_ELEMENT_TIMEOUT_MS',
|
| 43 |
+
'PSEUDO_STREAM_DELAY',
|
| 44 |
+
|
| 45 |
+
# 选择器配置
|
| 46 |
+
'PROMPT_TEXTAREA_SELECTOR',
|
| 47 |
+
'INPUT_SELECTOR',
|
| 48 |
+
'INPUT_SELECTOR2',
|
| 49 |
+
'SUBMIT_BUTTON_SELECTOR',
|
| 50 |
+
'CLEAR_CHAT_BUTTON_SELECTOR',
|
| 51 |
+
'CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR',
|
| 52 |
+
'RESPONSE_CONTAINER_SELECTOR',
|
| 53 |
+
'RESPONSE_TEXT_SELECTOR',
|
| 54 |
+
'LOADING_SPINNER_SELECTOR',
|
| 55 |
+
'OVERLAY_SELECTOR',
|
| 56 |
+
'ERROR_TOAST_SELECTOR',
|
| 57 |
+
'EDIT_MESSAGE_BUTTON_SELECTOR',
|
| 58 |
+
'MESSAGE_TEXTAREA_SELECTOR',
|
| 59 |
+
'FINISH_EDIT_BUTTON_SELECTOR',
|
| 60 |
+
'MORE_OPTIONS_BUTTON_SELECTOR',
|
| 61 |
+
'COPY_MARKDOWN_BUTTON_SELECTOR',
|
| 62 |
+
'COPY_MARKDOWN_BUTTON_SELECTOR_ALT',
|
| 63 |
+
'MAX_OUTPUT_TOKENS_SELECTOR',
|
| 64 |
+
'STOP_SEQUENCE_INPUT_SELECTOR',
|
| 65 |
+
'MAT_CHIP_REMOVE_BUTTON_SELECTOR',
|
| 66 |
+
'TOP_P_INPUT_SELECTOR',
|
| 67 |
+
'TEMPERATURE_INPUT_SELECTOR',
|
| 68 |
+
'USE_URL_CONTEXT_SELECTOR',
|
| 69 |
+
'UPLOAD_BUTTON_SELECTOR',
|
| 70 |
+
|
| 71 |
+
# 设置配置
|
| 72 |
+
'DEBUG_LOGS_ENABLED',
|
| 73 |
+
'TRACE_LOGS_ENABLED',
|
| 74 |
+
'AUTO_SAVE_AUTH',
|
| 75 |
+
'AUTH_SAVE_TIMEOUT',
|
| 76 |
+
'AUTO_CONFIRM_LOGIN',
|
| 77 |
+
'AUTH_PROFILES_DIR',
|
| 78 |
+
'ACTIVE_AUTH_DIR',
|
| 79 |
+
'SAVED_AUTH_DIR',
|
| 80 |
+
'LOG_DIR',
|
| 81 |
+
'APP_LOG_FILE_PATH',
|
| 82 |
+
'NO_PROXY_ENV',
|
| 83 |
+
'ENABLE_SCRIPT_INJECTION',
|
| 84 |
+
'USERSCRIPT_PATH',
|
| 85 |
+
|
| 86 |
+
# 工具函数
|
| 87 |
+
'get_environment_variable',
|
| 88 |
+
'get_boolean_env',
|
| 89 |
+
'get_int_env',
|
| 90 |
+
]
|
AIStudioProxyAPI/config/constants.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
常量配置模块
|
| 3 |
+
包含所有固定的常量定义,如模型名称、标记符、文件名等
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import json
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
|
| 10 |
+
# 加载 .env 文件
|
| 11 |
+
load_dotenv()
|
| 12 |
+
|
| 13 |
+
# --- 模型相关常量 ---
|
| 14 |
+
MODEL_NAME = os.environ.get('MODEL_NAME', 'AI-Studio_Proxy_API')
|
| 15 |
+
CHAT_COMPLETION_ID_PREFIX = os.environ.get('CHAT_COMPLETION_ID_PREFIX', 'chatcmpl-')
|
| 16 |
+
DEFAULT_FALLBACK_MODEL_ID = os.environ.get('DEFAULT_FALLBACK_MODEL_ID', "no model list")
|
| 17 |
+
|
| 18 |
+
# --- 默认参数值 ---
|
| 19 |
+
DEFAULT_TEMPERATURE = float(os.environ.get('DEFAULT_TEMPERATURE', '1.0'))
|
| 20 |
+
DEFAULT_MAX_OUTPUT_TOKENS = int(os.environ.get('DEFAULT_MAX_OUTPUT_TOKENS', '65536'))
|
| 21 |
+
DEFAULT_TOP_P = float(os.environ.get('DEFAULT_TOP_P', '0.95'))
|
| 22 |
+
# --- 默认功能开关 ---
|
| 23 |
+
ENABLE_URL_CONTEXT = os.environ.get('ENABLE_URL_CONTEXT', 'false').lower() in ('true', '1', 'yes')
|
| 24 |
+
ENABLE_THINKING_BUDGET = os.environ.get('ENABLE_THINKING_BUDGET', 'false').lower() in ('true', '1', 'yes')
|
| 25 |
+
DEFAULT_THINKING_BUDGET = int(os.environ.get('DEFAULT_THINKING_BUDGET', '8192'))
|
| 26 |
+
ENABLE_GOOGLE_SEARCH = os.environ.get('ENABLE_GOOGLE_SEARCH', 'false').lower() in ('true', '1', 'yes')
|
| 27 |
+
|
| 28 |
+
# 默认停止序列 - 支持 JSON 格式配置
|
| 29 |
+
try:
|
| 30 |
+
DEFAULT_STOP_SEQUENCES = json.loads(os.environ.get('DEFAULT_STOP_SEQUENCES', '["用户:"]'))
|
| 31 |
+
except (json.JSONDecodeError, TypeError):
|
| 32 |
+
DEFAULT_STOP_SEQUENCES = ["用户:"] # 回退到默认值
|
| 33 |
+
|
| 34 |
+
# --- URL模式 ---
|
| 35 |
+
AI_STUDIO_URL_PATTERN = os.environ.get('AI_STUDIO_URL_PATTERN', 'aistudio.google.com/')
|
| 36 |
+
MODELS_ENDPOINT_URL_CONTAINS = os.environ.get('MODELS_ENDPOINT_URL_CONTAINS', "MakerSuiteService/ListModels")
|
| 37 |
+
|
| 38 |
+
# --- 输入标记符 ---
|
| 39 |
+
USER_INPUT_START_MARKER_SERVER = os.environ.get('USER_INPUT_START_MARKER_SERVER', "__USER_INPUT_START__")
|
| 40 |
+
USER_INPUT_END_MARKER_SERVER = os.environ.get('USER_INPUT_END_MARKER_SERVER', "__USER_INPUT_END__")
|
| 41 |
+
|
| 42 |
+
# --- 文件名常量 ---
|
| 43 |
+
EXCLUDED_MODELS_FILENAME = os.environ.get('EXCLUDED_MODELS_FILENAME', "excluded_models.txt")
|
| 44 |
+
|
| 45 |
+
# --- 流状态配置 ---
|
| 46 |
+
STREAM_TIMEOUT_LOG_STATE = {
|
| 47 |
+
"consecutive_timeouts": 0,
|
| 48 |
+
"last_error_log_time": 0.0, # 使用 time.monotonic()
|
| 49 |
+
"suppress_until_time": 0.0, # 使用 time.monotonic()
|
| 50 |
+
"max_initial_errors": int(os.environ.get('STREAM_MAX_INITIAL_ERRORS', '3')),
|
| 51 |
+
"warning_interval_after_suppress": float(os.environ.get('STREAM_WARNING_INTERVAL_AFTER_SUPPRESS', '60.0')),
|
| 52 |
+
"suppress_duration_after_initial_burst": float(os.environ.get('STREAM_SUPPRESS_DURATION_AFTER_INITIAL_BURST', '400.0')),
|
| 53 |
+
}
|
AIStudioProxyAPI/config/selectors.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CSS选择器配置模块
|
| 3 |
+
包含所有用于页面元素定位的CSS选择器
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
# --- 输入相关选择器 ---
|
| 7 |
+
PROMPT_TEXTAREA_SELECTOR = 'ms-prompt-input-wrapper ms-autosize-textarea textarea'
|
| 8 |
+
INPUT_SELECTOR = PROMPT_TEXTAREA_SELECTOR
|
| 9 |
+
INPUT_SELECTOR2 = PROMPT_TEXTAREA_SELECTOR
|
| 10 |
+
|
| 11 |
+
# --- 按钮选择器 ---
|
| 12 |
+
SUBMIT_BUTTON_SELECTOR = 'button[aria-label="Run"].run-button'
|
| 13 |
+
CLEAR_CHAT_BUTTON_SELECTOR = 'button[data-test-clear="outside"][aria-label="New chat"]'
|
| 14 |
+
CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR = 'button.ms-button-primary:has-text("Discard and continue")'
|
| 15 |
+
UPLOAD_BUTTON_SELECTOR = 'button[aria-label^="Insert assets"]'
|
| 16 |
+
|
| 17 |
+
# --- 响应相关选择器 ---
|
| 18 |
+
RESPONSE_CONTAINER_SELECTOR = 'ms-chat-turn .chat-turn-container.model'
|
| 19 |
+
RESPONSE_TEXT_SELECTOR = 'ms-cmark-node.cmark-node'
|
| 20 |
+
|
| 21 |
+
# --- 加载和状态选择器 ---
|
| 22 |
+
LOADING_SPINNER_SELECTOR = 'button[aria-label="Run"].run-button svg .stoppable-spinner'
|
| 23 |
+
OVERLAY_SELECTOR = '.mat-mdc-dialog-inner-container'
|
| 24 |
+
|
| 25 |
+
# --- 错误提示选择器 ---
|
| 26 |
+
ERROR_TOAST_SELECTOR = 'div.toast.warning, div.toast.error'
|
| 27 |
+
|
| 28 |
+
# --- 编辑相关选择器 ---
|
| 29 |
+
EDIT_MESSAGE_BUTTON_SELECTOR = 'ms-chat-turn:last-child .actions-container button.toggle-edit-button'
|
| 30 |
+
MESSAGE_TEXTAREA_SELECTOR = 'ms-chat-turn:last-child ms-text-chunk ms-autosize-textarea'
|
| 31 |
+
FINISH_EDIT_BUTTON_SELECTOR = 'ms-chat-turn:last-child .actions-container button.toggle-edit-button[aria-label="Stop editing"]'
|
| 32 |
+
|
| 33 |
+
# --- 菜单和复制相关选择器 ---
|
| 34 |
+
MORE_OPTIONS_BUTTON_SELECTOR = 'div.actions-container div ms-chat-turn-options div > button'
|
| 35 |
+
COPY_MARKDOWN_BUTTON_SELECTOR = 'button.mat-mdc-menu-item:nth-child(4)'
|
| 36 |
+
COPY_MARKDOWN_BUTTON_SELECTOR_ALT = 'div[role="menu"] button:has-text("Copy Markdown")'
|
| 37 |
+
|
| 38 |
+
# --- 设置相关选择器 ---
|
| 39 |
+
MAX_OUTPUT_TOKENS_SELECTOR = 'input[aria-label="Maximum output tokens"]'
|
| 40 |
+
STOP_SEQUENCE_INPUT_SELECTOR = 'input[aria-label="Add stop token"]'
|
| 41 |
+
MAT_CHIP_REMOVE_BUTTON_SELECTOR = 'mat-chip-set mat-chip-row button[aria-label*="Remove"]'
|
| 42 |
+
TOP_P_INPUT_SELECTOR = 'ms-slider input[type="number"][max="1"]'
|
| 43 |
+
TEMPERATURE_INPUT_SELECTOR = 'ms-slider input[type="number"][max="2"]'
|
| 44 |
+
USE_URL_CONTEXT_SELECTOR = 'button[aria-label="Browse the url context"]'
|
| 45 |
+
SET_THINKING_BUDGET_TOGGLE_SELECTOR = 'button[aria-label="Toggle thinking budget between auto and manual"]'
|
| 46 |
+
# Thinking budget slider input
|
| 47 |
+
THINKING_BUDGET_INPUT_SELECTOR = '//div[contains(@class, "settings-item") and .//p[normalize-space()="Set thinking budget"]]/following-sibling::div//input[@type="number"]'
|
| 48 |
+
# --- Google Search Grounding ---
|
| 49 |
+
GROUNDING_WITH_GOOGLE_SEARCH_TOGGLE_SELECTOR = 'div[data-test-id="searchAsAToolTooltip"] mat-slide-toggle button'
|
AIStudioProxyAPI/config/settings.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
主要设置配置模块
|
| 3 |
+
包含环境变量配置、路径配置、代理配置等运行时设置
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
|
| 9 |
+
# 加载 .env 文件
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
# --- 全局日志控制配置 ---
|
| 13 |
+
DEBUG_LOGS_ENABLED = os.environ.get('DEBUG_LOGS_ENABLED', 'false').lower() in ('true', '1', 'yes')
|
| 14 |
+
TRACE_LOGS_ENABLED = os.environ.get('TRACE_LOGS_ENABLED', 'false').lower() in ('true', '1', 'yes')
|
| 15 |
+
|
| 16 |
+
# --- 认证相关配置 ---
|
| 17 |
+
AUTO_SAVE_AUTH = os.environ.get('AUTO_SAVE_AUTH', '').lower() in ('1', 'true', 'yes')
|
| 18 |
+
AUTH_SAVE_TIMEOUT = int(os.environ.get('AUTH_SAVE_TIMEOUT', '30'))
|
| 19 |
+
AUTO_CONFIRM_LOGIN = os.environ.get('AUTO_CONFIRM_LOGIN', 'true').lower() in ('1', 'true', 'yes')
|
| 20 |
+
|
| 21 |
+
# --- 路径配置 ---
|
| 22 |
+
AUTH_PROFILES_DIR = os.path.join(os.path.dirname(__file__), '..', 'auth_profiles')
|
| 23 |
+
ACTIVE_AUTH_DIR = os.path.join(AUTH_PROFILES_DIR, 'active')
|
| 24 |
+
SAVED_AUTH_DIR = os.path.join(AUTH_PROFILES_DIR, 'saved')
|
| 25 |
+
LOG_DIR = os.path.join(os.path.dirname(__file__), '..', 'logs')
|
| 26 |
+
APP_LOG_FILE_PATH = os.path.join(LOG_DIR, 'app.log')
|
| 27 |
+
|
| 28 |
+
def get_environment_variable(key: str, default: str = '') -> str:
|
| 29 |
+
"""获取环境变量值"""
|
| 30 |
+
return os.environ.get(key, default)
|
| 31 |
+
|
| 32 |
+
def get_boolean_env(key: str, default: bool = False) -> bool:
|
| 33 |
+
"""获取布尔型环境变量"""
|
| 34 |
+
value = os.environ.get(key, '').lower()
|
| 35 |
+
if default:
|
| 36 |
+
return value not in ('false', '0', 'no', 'off')
|
| 37 |
+
else:
|
| 38 |
+
return value in ('true', '1', 'yes', 'on')
|
| 39 |
+
|
| 40 |
+
def get_int_env(key: str, default: int = 0) -> int:
|
| 41 |
+
"""获取整型环境变量"""
|
| 42 |
+
try:
|
| 43 |
+
return int(os.environ.get(key, str(default)))
|
| 44 |
+
except (ValueError, TypeError):
|
| 45 |
+
return default
|
| 46 |
+
|
| 47 |
+
# --- 代理配置 ---
|
| 48 |
+
# 注意:代理配置现在在 api_utils/app.py 中动态设置,根据 STREAM_PORT 环境变量决定
|
| 49 |
+
NO_PROXY_ENV = os.environ.get('NO_PROXY')
|
| 50 |
+
|
| 51 |
+
# --- 脚本注入配置 ---
|
| 52 |
+
ENABLE_SCRIPT_INJECTION = get_boolean_env('ENABLE_SCRIPT_INJECTION', True)
|
| 53 |
+
USERSCRIPT_PATH = get_environment_variable('USERSCRIPT_PATH', 'browser_utils/more_modles.js')
|
| 54 |
+
# 注意:MODEL_CONFIG_PATH 已废弃,现在直接从油猴脚本解析模型数据
|
AIStudioProxyAPI/config/timeouts.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
超时和时间配置模块
|
| 3 |
+
包含所有超时时间、轮询间隔等时间相关配置
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
|
| 9 |
+
# 加载 .env 文件
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
# --- 响应等待配置 ---
|
| 13 |
+
RESPONSE_COMPLETION_TIMEOUT = int(os.environ.get('RESPONSE_COMPLETION_TIMEOUT', '300000')) # 5 minutes total timeout (in ms)
|
| 14 |
+
INITIAL_WAIT_MS_BEFORE_POLLING = int(os.environ.get('INITIAL_WAIT_MS_BEFORE_POLLING', '500')) # ms, initial wait before polling for response completion
|
| 15 |
+
|
| 16 |
+
# --- 轮询间隔配置 ---
|
| 17 |
+
POLLING_INTERVAL = int(os.environ.get('POLLING_INTERVAL', '300')) # ms
|
| 18 |
+
POLLING_INTERVAL_STREAM = int(os.environ.get('POLLING_INTERVAL_STREAM', '180')) # ms
|
| 19 |
+
|
| 20 |
+
# --- 静默超时配置 ---
|
| 21 |
+
SILENCE_TIMEOUT_MS = int(os.environ.get('SILENCE_TIMEOUT_MS', '60000')) # ms
|
| 22 |
+
|
| 23 |
+
# --- 页面操作超时配置 ---
|
| 24 |
+
POST_SPINNER_CHECK_DELAY_MS = int(os.environ.get('POST_SPINNER_CHECK_DELAY_MS', '500'))
|
| 25 |
+
FINAL_STATE_CHECK_TIMEOUT_MS = int(os.environ.get('FINAL_STATE_CHECK_TIMEOUT_MS', '1500'))
|
| 26 |
+
POST_COMPLETION_BUFFER = int(os.environ.get('POST_COMPLETION_BUFFER', '700'))
|
| 27 |
+
|
| 28 |
+
# --- 清理聊天相关超时 ---
|
| 29 |
+
CLEAR_CHAT_VERIFY_TIMEOUT_MS = int(os.environ.get('CLEAR_CHAT_VERIFY_TIMEOUT_MS', '5000'))
|
| 30 |
+
CLEAR_CHAT_VERIFY_INTERVAL_MS = int(os.environ.get('CLEAR_CHAT_VERIFY_INTERVAL_MS', '2000'))
|
| 31 |
+
|
| 32 |
+
# --- 点击和剪贴板操作超时 ---
|
| 33 |
+
CLICK_TIMEOUT_MS = int(os.environ.get('CLICK_TIMEOUT_MS', '3000'))
|
| 34 |
+
CLIPBOARD_READ_TIMEOUT_MS = int(os.environ.get('CLIPBOARD_READ_TIMEOUT_MS', '3000'))
|
| 35 |
+
|
| 36 |
+
# --- 元素等待超时 ---
|
| 37 |
+
WAIT_FOR_ELEMENT_TIMEOUT_MS = int(os.environ.get('WAIT_FOR_ELEMENT_TIMEOUT_MS', '10000')) # Timeout for waiting for elements like overlays
|
| 38 |
+
|
| 39 |
+
# --- 流相关配置 ---
|
| 40 |
+
PSEUDO_STREAM_DELAY = float(os.environ.get('PSEUDO_STREAM_DELAY', '0.01'))
|
AIStudioProxyAPI/deprecated_javascript_version/README.md
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AI Studio Proxy Server (Javascript Version - DEPRECATED)
|
| 2 |
+
|
| 3 |
+
**⚠️ 警告:此 Javascript 版本 (`server.cjs`, `auto_connect_aistudio.cjs`) 已被弃用且不再维护。推荐使用项目根目录下的 Python 版本,该版本采用了模块化架构设计,具有更好的稳定性和可维护性。**
|
| 4 |
+
|
| 5 |
+
**📖 查看最新文档**: 请参考项目根目录下的 [`README.md`](../README.md) 了解当前Python版本的完整使用说明。
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
[点击查看项目使用演示视频](https://drive.google.com/file/d/1efR-cNG2CNboNpogHA1ASzmx45wO579p/view?usp=drive_link)
|
| 10 |
+
|
| 11 |
+
这是一个 Node.js + Playwright 服务器,通过模拟 OpenAI API 的方式来访问 Google AI Studio 网页版,服务器无缝交互转发 Gemini 对话。这使得兼容 OpenAI API 的客户端(如 Open WebUI, NextChat 等)可以使用 AI Studio 的无限额度及能力。
|
| 12 |
+
|
| 13 |
+
## ✨ 特性 (Javascript 版本)
|
| 14 |
+
|
| 15 |
+
* **OpenAI API 兼容**: 提供 `/v1/chat/completions` 和 `/v1/models` 端点,兼容大多数 OpenAI 客户端。
|
| 16 |
+
* **流式响应**: 支持 `stream=true`,实现打字机效果。
|
| 17 |
+
* **非流式响应**: 支持 `stream=false`,一次性返回完整 JSON 响应。
|
| 18 |
+
* **系统提示词 (System Prompt)**: 支持通过请求体中的 `messages` 数组的 `system` 角色或额外的 `system_prompt` 字段传递系统提示词。
|
| 19 |
+
* **内部 Prompt 优化**: 自动包装用户输入,指导 AI Studio 输出特定格式(流式为 Markdown 代码块,非流式为 JSON),并包含起始标记 `<<<START_RESPONSE>>>` 以便解析。
|
| 20 |
+
* **自动连接脚本 (`auto_connect_aistudio.cjs`)**:
|
| 21 |
+
* 自动查找并启动 Chrome/Chromium 浏览器,开启调试端口,**并设置特定窗口宽度 (460px)** 以优化布局,确保"清空聊天"按钮可见。
|
| 22 |
+
* 自动检测并尝试连接已存在的 Chrome 调试实例。
|
| 23 |
+
* 提供交互式选项,允许用户选择连接现有实例或自动结束冲突进程。
|
| 24 |
+
* 自动查找或打开 AI Studio 的 `New chat` 页面。
|
| 25 |
+
* 自动启动 `server.cjs`。
|
| 26 |
+
* **服务端 (`server.cjs`)**:
|
| 27 |
+
* 连接到由 `auto_connect_aistudio.cjs` 管理的 Chrome 实例。
|
| 28 |
+
* **自动清空上下文**: 当检测到来自客户端的请求可能是"新对话"时(基于消息历史长度),自动模拟点击 AI Studio 页面上的"Clear chat"按钮及其确认对话框,并验证清空效果,以实现更好的会话隔离。
|
| 29 |
+
* 处理 API 请求,通过 Playwright 操作 AI Studio 页面。
|
| 30 |
+
* 解析 AI Studio 的响应,提取有效内容。
|
| 31 |
+
* 提供简单的 Web UI (`/`) 进行基本测试。
|
| 32 |
+
* 提供健康检查端点 (`/health`)。
|
| 33 |
+
* **错误快照**: 在 Playwright 操作、响应解析或**清空聊天**出错时,自动在项目根目录下的 `errors/` 目录下保存页面截图和 HTML,方便调试。(注意: Python 版本错误快照在 `errors_py/`)
|
| 34 |
+
* **依赖检测**: 两个脚本在启动时都会检查所需依赖,并提供安装指导。
|
| 35 |
+
* **跨平台设计**: 旨在支持 macOS, Linux 和 Windows (WSL 推荐)。
|
| 36 |
+
|
| 37 |
+
## ⚠️ 重要提示 (Javascript 版本)
|
| 38 |
+
|
| 39 |
+
* **非官方项目**: 本项目与 Google 无关,依赖于对 AI Studio Web 界面的自动化操作,可能因 AI Studio 页面更新而失效。
|
| 40 |
+
* **自动清空功能的脆弱性**: 自动清空上下文的功能依赖于精确的 UI 元素选择器 (`CLEAR_CHAT_BUTTON_SELECTOR`, `CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR` 在 `server.cjs` 中)。如果 AI Studio 页面结构发生变化,此功能可能会失效。届时需要更新这些选择器。
|
| 41 |
+
* **不支持历史编辑/分叉**: 即使实现了新对话的上下文清空,本代理仍然无法支持客户端进行历史消息编辑并从该点重新生成对话的功能。AI Studio 内部维护的对话历史是线性的。
|
| 42 |
+
* **固定窗口宽度**: `auto_connect_aistudio.cjs` 会以固定的宽度 (460px) 启动 Chrome 窗口,以确保清空按钮可见。
|
| 43 |
+
* **安全性**: 启动 Chrome 时开启了远程调试端口 (默认为 `8848`),请确保此端口仅在受信任的网络环境中使用,或通过防火墙规则限制访问。切勿将此端口暴露到公网。
|
| 44 |
+
* **稳定性**: 由于依赖浏览器自动化,其稳定性不如官方 API。长时间运行或频繁请求可能导致页面无响应或连接中断,可能需要重启浏览器或服务器。
|
| 45 |
+
* **AI Studio 限制**: AI Studio 本身可能有请求频率限制、内容策略限制等,代理服务器无法绕过这些限制。
|
| 46 |
+
* **参数配置**: **像模型选择、温度、输出长度等参数,需要您直接在 AI Studio 页面的右侧设置面板中进行调整。本代理服务器目前不处理或转发这些通过 API 请求传递的参数。** 您需要预先在 AI Studio Web UI 中设置好所需的模型和参数。
|
| 47 |
+
|
| 48 |
+
## 🛠️ 配置 (Javascript 版本)
|
| 49 |
+
|
| 50 |
+
虽然不建议频繁修改,但了解以下常量可能有助于理解脚本行为或在特殊情况下进行调整:
|
| 51 |
+
|
| 52 |
+
**`auto_connect_aistudio.cjs`:**
|
| 53 |
+
|
| 54 |
+
* `DEBUGGING_PORT`: (默认 `8848`) Chrome 浏览器启动时使用的远程调试端口。
|
| 55 |
+
* `TARGET_URL`: (默认 `'https://aistudio.google.com/prompts/new_chat'`) 脚本尝试打开或导航到的 AI Studio 页面。
|
| 56 |
+
* `SERVER_SCRIPT_FILENAME`: (默认 `'server.cjs'`) 由此脚本自动启动的 API 服务器文件名。
|
| 57 |
+
* `CONNECT_TIMEOUT_MS`: (默认 `20000`) 连接到 Chrome 调试端口的超时时间 (毫秒)。
|
| 58 |
+
* `NAVIGATION_TIMEOUT_MS`: (默认 `35000`) Playwright 等待页面导航完成的超时时间 (毫秒)。
|
| 59 |
+
* `--window-size=460,...`: 启动 Chrome 时传递的参数,固定宽度以保证 UI 元素(如清空按钮)位置相对稳定。
|
| 60 |
+
|
| 61 |
+
**`server.cjs`:**
|
| 62 |
+
|
| 63 |
+
* `SERVER_PORT`: (默认 `2048`) API 服务器监听的端口。
|
| 64 |
+
* `AI_STUDIO_URL_PATTERN`: (默认 `'aistudio.google.com/'`) 用于识别 AI Studio 页面的 URL 片段。
|
| 65 |
+
* `RESPONSE_COMPLETION_TIMEOUT`: (默认 `300000`) 等待 AI Studio 响应完成的总超时时间 (毫秒,5分钟)。
|
| 66 |
+
* `POLLING_INTERVAL`: (默认 `300`) 轮询检查 AI Studio 页面状态的间隔 (毫秒)。
|
| 67 |
+
* `SILENCE_TIMEOUT_MS`: (默认 `3000`) 判断 AI Studio 是否停止输出的静默超时时间 (毫秒)。
|
| 68 |
+
* `CLEAR_CHAT_VERIFY_TIMEOUT_MS`: (默认 `5000`) 等待并验证清空聊天操作完成的超时时间 (毫秒)。
|
| 69 |
+
* **CSS 选择器**: (`INPUT_SELECTOR`, `SUBMIT_BUTTON_SELECTOR`, `RESPONSE_CONTAINER_SELECTOR`, `LOADING_SPINNER_SELECTOR`, `ERROR_TOAST_SELECTOR`, `CLEAR_CHAT_BUTTON_SELECTOR`, `CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR`) 这些常量定义了脚本用于查找页面元素的选择器。**修改这些值需要具备前端知识,并且如果 AI Studio 页面更新,这些是最可能需要调整的部分。**
|
| 70 |
+
|
| 71 |
+
## ⚙️ Prompt 内部处理 (Javascript 版本)
|
| 72 |
+
|
| 73 |
+
为了让代理能够解析 AI Studio 的输出,`server.cjs` 会在将你的 Prompt 发送到 AI Studio 前进行包装,加入特定的指令,要求 AI:
|
| 74 |
+
|
| 75 |
+
1. **对于非流式请求 (`stream=false`)**: 将整个回复包裹在一个 JSON 对象中,格式为 `{"response": "<<<START_RESPONSE>>>[AI的实际回复]"}`。
|
| 76 |
+
2. **对于流式请求 (`stream=true`)**: 将整个回复(包括开始和结束)包裹在一个 Markdown 代码块 (```) 中,并在实际回复前加上标记 `<<<START_RESPONSE>>>`,形如:
|
| 77 |
+
```markdown
|
| 78 |
+
```
|
| 79 |
+
<<<START_RESPONSE>>>[AI的实际回复第一部分]
|
| 80 |
+
[AI的实际回复第二部分]
|
| 81 |
+
...
|
| 82 |
+
```
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
`server.cjs` 会查找 `<<<START_RESPONSE>>>` 标记来提取真正的回复内容。这意味着你通过 API 得到的回复是经过这个内部处理流程的,AI Studio 页面的原始输出格式会被改变。
|
| 86 |
+
|
| 87 |
+
## 🚀 开始使用 (Javascript 版本)
|
| 88 |
+
|
| 89 |
+
### 1. 先决条件
|
| 90 |
+
|
| 91 |
+
* **Node.js**: v16 或更高版本。
|
| 92 |
+
* **NPM / Yarn / PNPM**: 用于安装依赖。
|
| 93 |
+
* **Google Chrome / Chromium**: 需要安装浏览器本体。
|
| 94 |
+
* **Google AI Studio 账号**: 并能正常访问和使用。
|
| 95 |
+
|
| 96 |
+
### 2. 安装
|
| 97 |
+
|
| 98 |
+
1. **进入弃用版本目录**:
|
| 99 |
+
```bash
|
| 100 |
+
cd deprecated_javascript_version
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
2. **安装依赖**:
|
| 104 |
+
根据 `package.json` 文件,脚本运行需要以下核心依赖:
|
| 105 |
+
* `express`: Web 框架,用于构建 API 服务器。
|
| 106 |
+
* `cors`: 处理跨域资源共享。
|
| 107 |
+
* `playwright`: 浏览器自动化库。
|
| 108 |
+
* `@playwright/test`: Playwright 的测试库,`server.cjs` 使用其 `expect` 功能进行断言。
|
| 109 |
+
|
| 110 |
+
使用你的包管理器安装:
|
| 111 |
+
```bash
|
| 112 |
+
npm install
|
| 113 |
+
# 或
|
| 114 |
+
yarn install
|
| 115 |
+
# 或
|
| 116 |
+
pnpm install
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
### 3. 运行
|
| 120 |
+
|
| 121 |
+
只需要运行 `auto_connect_aistudio.cjs` 脚本即可启动所有服务:
|
| 122 |
+
|
| 123 |
+
```bash
|
| 124 |
+
node auto_connect_aistudio.cjs
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
这个脚本会执行以下操作:
|
| 128 |
+
|
| 129 |
+
1. **检查依赖**: 确认上述 Node.js 模块已安装,且 `server.cjs` 文件存在。
|
| 130 |
+
2. **检查 Chrome 调试端口 (`8848`)**:
|
| 131 |
+
* 如果端口空闲,尝试自动查找并启动一个新的 Chrome 实例(窗口宽度固定为 460px),并打开远程调试端口。
|
| 132 |
+
* 如果端口被占用,询问用户是连接现有实例还是尝试清理端口后启动新实例。
|
| 133 |
+
3. **连接 Playwright**: 尝试连接到 Chrome 的调试端口 (`http://127.0.0.1:8848`)。
|
| 134 |
+
4. **管理 AI Studio 页面**: 查找或打开 AI Studio 的 `New chat` 页面 (`https://aistudio.google.com/prompts/new_chat`),并尝试置于前台。
|
| 135 |
+
5. **启动 API 服务器**: 如果以上步骤成功,脚本会自动在后台启动 `node server.cjs`。
|
| 136 |
+
|
| 137 |
+
当 `server.cjs` 成功启动并连接到 Playwright 后,您将在终端看到类似以下的输出(来自 `server.cjs`):
|
| 138 |
+
|
| 139 |
+
```
|
| 140 |
+
=============================================================
|
| 141 |
+
🚀 AI Studio Proxy Server (vX.XX - Queue & Auto Clear) 🚀
|
| 142 |
+
=============================================================
|
| 143 |
+
🔗 监听地址: http://localhost:2048
|
| 144 |
+
- Web UI (测试): http://localhost:2048/
|
| 145 |
+
- API 端点: http://localhost:2048/v1/chat/completions
|
| 146 |
+
- 模型接口: http://localhost:2048/v1/models
|
| 147 |
+
- 健康检查: http://localhost:2048/health
|
| 148 |
+
-------------------------------------------------------------
|
| 149 |
+
✅ Playwright 连接成功,服务已准备就绪!
|
| 150 |
+
-------------------------------------------------------------
|
| 151 |
+
```
|
| 152 |
+
*(版本号可能不同)*
|
| 153 |
+
|
| 154 |
+
此时,代理服务已准备就绪,监听在 `http://localhost:2048`。
|
| 155 |
+
|
| 156 |
+
### 4. 配置客户端 (以 Open WebUI 为例)
|
| 157 |
+
|
| 158 |
+
1. 打开 Open WebUI。
|
| 159 |
+
2. 进入 "设置" -> "连接"。
|
| 160 |
+
3. 在 "模型" 部分,点击 "添加模型"。
|
| 161 |
+
4. **模型名称**: 输入你想要的名字,例如 `aistudio-gemini-cjs`。
|
| 162 |
+
5. **API 基础 URL**: 输入代理服务器的地址,例如 `http://localhost:2048/v1` (注意包含 `/v1`)。
|
| 163 |
+
6. **API 密钥**: 留空或输入任意字符 (服务器不验证)。
|
| 164 |
+
7. 保存设置。
|
| 165 |
+
8. 现在,你应该可以在 Open WebUI 中选择 `aistudio-gemini-cjs` 模型并开始聊天了。
|
| 166 |
+
|
| 167 |
+
### 5. 使用测试脚本 (可选)
|
| 168 |
+
|
| 169 |
+
本目录下提供了一个 `test.js` 脚本,用于在命令行中直接与代理进行交互式聊天。
|
| 170 |
+
|
| 171 |
+
1. **安装额外依赖**: `test.js` 使用了 OpenAI 的官方 Node.js SDK。
|
| 172 |
+
```bash
|
| 173 |
+
npm install openai
|
| 174 |
+
# 或 yarn add openai / pnpm add openai
|
| 175 |
+
```
|
| 176 |
+
2. **检查配置**: 打开 `test.js`,确认 `LOCAL_PROXY_URL` 指向你的代理服务器地址 (`http://127.0.0.1:2048/v1/`)。`DUMMY_API_KEY` 可以保持不变。
|
| 177 |
+
3. **运行测试**: 在 `deprecated_javascript_version` 目录下运行:
|
| 178 |
+
```bash
|
| 179 |
+
node test.js
|
| 180 |
+
```
|
| 181 |
+
之后就可以在命令行输入问题进行测试了。输入 `exit` 退出。
|
| 182 |
+
|
| 183 |
+
## 💻 多平台指南 (Javascript 版本)
|
| 184 |
+
|
| 185 |
+
* **macOS**:
|
| 186 |
+
* `auto_connect_aistudio.cjs` 通常能自动找到 Chrome。
|
| 187 |
+
* 防火墙可能会提示是否允许 Node.js 接受网络连接,请允许。
|
| 188 |
+
* **Linux**:
|
| 189 |
+
* 确保已安装 `google-chrome-stable` 或 `chromium-browser`。
|
| 190 |
+
* 如果脚本找不到 Chrome,你可能需要修改 `auto_connect_aistudio.cjs` 中的 `getChromePath` 函数,手动指定路径,或者创建一个符号链接 (`/usr/bin/google-chrome`) 指向实际的 Chrome 可执行文件。
|
| 191 |
+
* 某些 Linux 发行版可能需要安装额外的 Playwright 依赖库,参考 [Playwright Linux 文档](https://playwright.dev/docs/intro#system-requirements)。运行 `npx playwright install-deps` 可能有助于安装。
|
| 192 |
+
* **Windows**:
|
| 193 |
+
* **强烈建议使用 WSL (Windows Subsystem for Linux)**。在 WSL 中按照 Linux 指南操作通常更顺畅。
|
| 194 |
+
* **直接在 Windows 上运行 (不推荐)**:
|
| 195 |
+
* `auto_connect_aistudio.cjs` 可能需要手动修改 `getChromePath` 函数来指定 Chrome 的完整路径 (例如 `C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe`)。注意路径中的反斜杠需要转义 (`\\`)。
|
| 196 |
+
* 防火墙设置需要允许 Node.js 和 Chrome 监听和连接端口 (`8848` 和 `2048`)。
|
| 197 |
+
* 由于文件系统和权限差异,可能会遇到未知问题,例如端口检查或进程结束操作 (`taskkill`) 失败。
|
| 198 |
+
|
| 199 |
+
## 🔧 故障排除 (Javascript 版本)
|
| 200 |
+
|
| 201 |
+
* **`auto_connect_aistudio.cjs` 启动失败或报错**:
|
| 202 |
+
* **依赖未找到**: 确认运行了 `npm install` 等命令。
|
| 203 |
+
* **Chrome 路径找不到**: 确认 Chrome/Chromium 已安装,并按需修改 `getChromePath` 函数或创建符号链接 (Linux)。
|
| 204 |
+
* **端口 (`8848`) 被占用且无法自动清理**: 根据脚本提示,使用系统工具(如 `lsof -i :8848` / `tasklist | findstr "8848"`)手动查找并结束占用端口的进程。
|
| 205 |
+
* **连接 Playwright 超时**: 确认 Chrome 是否已成功启动并监听 `8848` 端口,防火墙是否阻止本地连接 `127.0.0.1:8848`。查看 `auto_connect_aistudio.cjs` 中的 `CONNECT_TIMEOUT_MS` 是否足够。
|
| 206 |
+
* **打开/导航 AI Studio 页面失败**: 检查网络连接,尝试手动在浏览器中打开 `https://aistudio.google.com/prompts/new_chat` 并完成登录。查看 `NAVIGATION_TIMEOUT_MS` 是否足够。
|
| 207 |
+
* **窗口大小问题**: 如果 460px 宽度导致问题,可以尝试修改 `auto_connect_aistudio.cjs` 中的 `--window-size` 参数,但这可能影响自动清空功能。
|
| 208 |
+
* **`server.cjs` 启动时提示端口被占用 (`EADDRINUSE`)**:
|
| 209 |
+
* 检查是否有其他程序 (包括旧的服务器实例) 正在使用 `2048` 端口。关闭冲突程序或修改 `server.cjs` 中的 `SERVER_PORT`。
|
| 210 |
+
* **服务器日志显示 Playwright 未就绪或连接失败 (在 `server.cjs` 启动后)**:
|
| 211 |
+
* 通常意味着 `auto_connect_aistudio.cjs` 启动的 Chrome 实例意外关闭或无响应。检查 Chrome 窗口是否还在,AI Studio 页面是否崩溃。
|
| 212 |
+
* 尝试关闭所有相关进程(`node` 和 `chrome`),然后重新运行 `node auto_connect_aistudio.cjs`。
|
| 213 |
+
* 检查根目录下的 `errors/` 目录是否有截图和 HTML 文件,它们可能包含 AI Studio 页面的错误信息或状态。
|
| 214 |
+
* **客户端 (如 Open WebUI) 无法连接或请求失败**:
|
| 215 |
+
* 确认 API 基础 URL 配置正确 (`http://localhost:2048/v1`)。
|
| 216 |
+
* 检查 `server.cjs` 运行的终端是否有错误输出。
|
| 217 |
+
* 确保客户端和服务器在同一网络中,且防火墙没有阻止从客户端到服务器 `2048` 端口的连接。
|
| 218 |
+
* **API 请求返回 5xx 错误**:
|
| 219 |
+
* **503 Service Unavailable / Playwright not ready**: `server.cjs` 无法连接到 Chrome。
|
| 220 |
+
* **504 Gateway Timeout**: 请求处理时间超过了 `RESPONSE_COMPLETION_TIMEOUT`。可能是 AI Studio 响应慢或卡住了。
|
| 221 |
+
* **502 Bad Gateway / AI Studio Error**: `server.cjs` 在 AI Studio 页面上检测到了错误提示 (`toast` 消息),或无法正确解析 AI 的响应。检查 `errors/` 快照。
|
| 222 |
+
* **500 Internal Server Error**: `server.cjs` 内部发生未捕获的错误。检查服务器日志和 `errors/` 快照。
|
| 223 |
+
* **AI 回复不完整、格式错误或包含 `<<<START_RESPONSE>>>` 标记**:
|
| 224 |
+
* AI Studio 的 Web UI 输出不稳定。服务器尽力解析,但可能失败。
|
| 225 |
+
* 非流式请求:如果返回的 JSON 中缺少 `response` 字段或无法解析,服务器可能返回空内容或原始 JSON 字符串。检查 `errors/` 快照确认 AI Studio 页面的实际输出。
|
| 226 |
+
* 流式请求:如果 AI 未按预期输出 Markdown 代码块或起始标记,流式传输可能提前中断或包含非预期内容。
|
| 227 |
+
* 尝试调整 Prompt 或稍后重试。
|
| 228 |
+
* **自动清空上下文失败**:
|
| 229 |
+
* 服务器日志出现 "清空聊天记录或验证时出错" 或 "验证超时" 的警告。
|
| 230 |
+
* **原因**: AI Studio 网页更新导致 `server.cjs` 中的 `CLEAR_CHAT_BUTTON_SELECTOR` 或 `CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR` 失效。
|
| 231 |
+
* **解决**: 检查 `errors/` 快照,使用浏览器开发者工具检查实际页面元素,并更新 `server.cjs` 文件顶部的选择器常量。
|
| 232 |
+
* **原因**: 清空操作本身耗时超过了 `CLEAR_CHAT_VERIFY_TIMEOUT_MS`。
|
| 233 |
+
* **解决**: 如果网络或机器较慢,可以尝试在 `server.cjs` 中适当增加这个超时时间。
|
AIStudioProxyAPI/deprecated_javascript_version/auto_connect_aistudio.cjs
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
// auto_connect_aistudio.js (v2.9 - Refined Launch & Page Handling + Beautified Output)
|
| 4 |
+
|
| 5 |
+
const { spawn, execSync } = require('child_process');
|
| 6 |
+
const path = require('path');
|
| 7 |
+
const fs = require('fs');
|
| 8 |
+
const readline = require('readline');
|
| 9 |
+
|
| 10 |
+
// --- Configuration ---
|
| 11 |
+
const DEBUGGING_PORT = 8848;
|
| 12 |
+
const TARGET_URL = 'https://aistudio.google.com/prompts/new_chat'; // Target page
|
| 13 |
+
const SERVER_SCRIPT_FILENAME = 'server.cjs'; // Corrected script name
|
| 14 |
+
const CONNECTION_RETRIES = 5;
|
| 15 |
+
const RETRY_DELAY_MS = 4000;
|
| 16 |
+
const CONNECT_TIMEOUT_MS = 20000; // Timeout for connecting to CDP
|
| 17 |
+
const NAVIGATION_TIMEOUT_MS = 35000; // Increased timeout for page navigation
|
| 18 |
+
const CDP_ADDRESS = `http://127.0.0.1:${DEBUGGING_PORT}`;
|
| 19 |
+
|
| 20 |
+
// --- ANSI Colors ---
|
| 21 |
+
const RESET = '\x1b[0m';
|
| 22 |
+
const BRIGHT = '\x1b[1m';
|
| 23 |
+
const DIM = '\x1b[2m';
|
| 24 |
+
const RED = '\x1b[31m';
|
| 25 |
+
const GREEN = '\x1b[32m';
|
| 26 |
+
const YELLOW = '\x1b[33m';
|
| 27 |
+
const BLUE = '\x1b[34m';
|
| 28 |
+
const MAGENTA = '\x1b[35m';
|
| 29 |
+
const CYAN = '\x1b[36m';
|
| 30 |
+
|
| 31 |
+
// --- Globals ---
|
| 32 |
+
const SERVER_SCRIPT_PATH = path.join(__dirname, SERVER_SCRIPT_FILENAME);
|
| 33 |
+
let playwright; // Loaded in checkDependencies
|
| 34 |
+
|
| 35 |
+
// --- Platform-Specific Chrome Path ---
|
| 36 |
+
function getChromePath() {
|
| 37 |
+
switch (process.platform) {
|
| 38 |
+
case 'darwin':
|
| 39 |
+
return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
| 40 |
+
case 'win32':
|
| 41 |
+
// 尝试 Program Files 和 Program Files (x86)
|
| 42 |
+
const winPaths = [
|
| 43 |
+
path.join(process.env.ProgramFiles || '', 'Google\Chrome\Application\chrome.exe'),
|
| 44 |
+
path.join(process.env['ProgramFiles(x86)'] || '', 'Google\Chrome\Application\chrome.exe')
|
| 45 |
+
];
|
| 46 |
+
return winPaths.find(p => fs.existsSync(p));
|
| 47 |
+
case 'linux':
|
| 48 |
+
// 尝试常见的 Linux 路径
|
| 49 |
+
const linuxPaths = [
|
| 50 |
+
'/usr/bin/google-chrome',
|
| 51 |
+
'/usr/bin/google-chrome-stable',
|
| 52 |
+
'/opt/google/chrome/chrome',
|
| 53 |
+
// Add path for Flatpak installation if needed
|
| 54 |
+
// '/var/lib/flatpak/exports/bin/com.google.Chrome'
|
| 55 |
+
];
|
| 56 |
+
return linuxPaths.find(p => fs.existsSync(p));
|
| 57 |
+
default:
|
| 58 |
+
return null; // 不支持的平台
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const chromeExecutablePath = getChromePath();
|
| 63 |
+
|
| 64 |
+
// --- 端口检查函数 ---
|
| 65 |
+
function isPortInUse(port) {
|
| 66 |
+
const platform = process.platform;
|
| 67 |
+
let command;
|
| 68 |
+
// console.log(`${DIM} 检查端口 ${port}...${RESET}`); // Optional: Verbose check
|
| 69 |
+
try {
|
| 70 |
+
if (platform === 'win32') {
|
| 71 |
+
// 在 Windows 上,查找监听状态的 TCP 端口
|
| 72 |
+
command = `netstat -ano | findstr LISTENING | findstr :${port}`;
|
| 73 |
+
execSync(command); // 如果找到,不会抛出错误
|
| 74 |
+
return true;
|
| 75 |
+
} else if (platform === 'darwin' || platform === 'linux') {
|
| 76 |
+
// 在 macOS 或 Linux 上,查找监听该端口的进程
|
| 77 |
+
command = `lsof -i tcp:${port} -sTCP:LISTEN`;
|
| 78 |
+
execSync(command); // 如果找到,不会抛出错误
|
| 79 |
+
return true;
|
| 80 |
+
}
|
| 81 |
+
} catch (error) {
|
| 82 |
+
// 如果命令执行失败(通常意味着找不到匹配的进程),则端口未被占用
|
| 83 |
+
// console.log(`端口 ${port} 检查命令执行失败或未找到进程:`, error.message.split('\n')[0]); // 可选的调试信息
|
| 84 |
+
return false;
|
| 85 |
+
}
|
| 86 |
+
// 对于不支持的平台,保守地假设端口未被占用
|
| 87 |
+
return false;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// --- 查找占用端口的 PID --- (新增)
|
| 91 |
+
function findPidsUsingPort(port) {
|
| 92 |
+
const platform = process.platform;
|
| 93 |
+
const pids = [];
|
| 94 |
+
let command;
|
| 95 |
+
try {
|
| 96 |
+
console.log(`${DIM} 正在查找占用端口 ${port} 的进程...${RESET}`);
|
| 97 |
+
if (platform === 'win32') {
|
| 98 |
+
command = `netstat -ano | findstr LISTENING | findstr :${port}`;
|
| 99 |
+
const output = execSync(command).toString();
|
| 100 |
+
const lines = output.trim().split('\n');
|
| 101 |
+
for (const line of lines) {
|
| 102 |
+
const parts = line.trim().split(/\s+/);
|
| 103 |
+
const pid = parts[parts.length - 1]; // PID is the last column
|
| 104 |
+
if (pid && !isNaN(pid)) {
|
| 105 |
+
pids.push(pid);
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
} else { // macOS or Linux
|
| 109 |
+
command = `lsof -t -i tcp:${port} -sTCP:LISTEN`;
|
| 110 |
+
const output = execSync(command).toString();
|
| 111 |
+
const lines = output.trim().split('\n');
|
| 112 |
+
for (const line of lines) {
|
| 113 |
+
const pid = line.trim();
|
| 114 |
+
if (pid && !isNaN(pid)) {
|
| 115 |
+
pids.push(pid);
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
if (pids.length > 0) {
|
| 120 |
+
console.log(` ${YELLOW}找到占用端口 ${port} 的 PID: ${pids.join(', ')}${RESET}`);
|
| 121 |
+
} else {
|
| 122 |
+
console.log(` ${GREEN}未找到明确监听端口 ${port} 的进程。${RESET}`);
|
| 123 |
+
}
|
| 124 |
+
} catch (error) {
|
| 125 |
+
// 命令失败通常意味着没有找到进程
|
| 126 |
+
console.log(` ${GREEN}查找端口 ${port} 进程的命令执行失败或无结果。${RESET}`);
|
| 127 |
+
}
|
| 128 |
+
return [...new Set(pids)]; // 返回去重后的 PID 列表
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
// --- 结束进程 --- (新增)
|
| 132 |
+
function killProcesses(pids) {
|
| 133 |
+
if (pids.length === 0) return true; // 没有进程需要结束
|
| 134 |
+
|
| 135 |
+
const platform = process.platform;
|
| 136 |
+
let success = true;
|
| 137 |
+
console.log(`${YELLOW} 正在尝试结束 PID: ${pids.join(', ')}...${RESET}`);
|
| 138 |
+
|
| 139 |
+
for (const pid of pids) {
|
| 140 |
+
try {
|
| 141 |
+
if (platform === 'win32') {
|
| 142 |
+
execSync(`taskkill /F /PID ${pid}`);
|
| 143 |
+
console.log(` ${GREEN}✅ 成功结束 PID ${pid} (Windows)${RESET}`);
|
| 144 |
+
} else { // macOS or Linux
|
| 145 |
+
execSync(`kill -9 ${pid}`);
|
| 146 |
+
console.log(` ${GREEN}✅ 成功结束 PID ${pid} (macOS/Linux)${RESET}`);
|
| 147 |
+
}
|
| 148 |
+
} catch (error) {
|
| 149 |
+
console.warn(` ${RED}⚠️ 结束 PID ${pid} 时出错: ${error.message.split('\n')[0]}${RESET}`);
|
| 150 |
+
// 可能原因:进程已不存在、权限不足等
|
| 151 |
+
success = false; // 标记至少有一个失败了
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
return success;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
// --- 创建 Readline Interface ---
|
| 158 |
+
function askQuestion(query) {
|
| 159 |
+
const rl = readline.createInterface({
|
| 160 |
+
input: process.stdin,
|
| 161 |
+
output: process.stdout,
|
| 162 |
+
});
|
| 163 |
+
|
| 164 |
+
return new Promise(resolve => rl.question(query, ans => {
|
| 165 |
+
rl.close();
|
| 166 |
+
resolve(ans);
|
| 167 |
+
}))
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
// --- 步骤 1: 检查 Playwright 依赖 ---
|
| 171 |
+
async function checkDependencies() {
|
| 172 |
+
console.log(`${CYAN}-------------------------------------------------${RESET}`);
|
| 173 |
+
console.log(`${CYAN}--- 步骤 1: 检查依赖项 ---${RESET}`);
|
| 174 |
+
console.log('将检查以下模块是否已安装:');
|
| 175 |
+
const requiredModules = ['express', 'playwright', '@playwright/test', 'cors'];
|
| 176 |
+
const missingModules = [];
|
| 177 |
+
let allFound = true;
|
| 178 |
+
|
| 179 |
+
for (const moduleName of requiredModules) {
|
| 180 |
+
process.stdout.write(` - ${moduleName} ... `);
|
| 181 |
+
try {
|
| 182 |
+
require.resolve(moduleName); // Use require.resolve for checking existence without loading
|
| 183 |
+
console.log(`${GREEN}✓ 已找到${RESET}`); // Green checkmark
|
| 184 |
+
} catch (error) {
|
| 185 |
+
if (error.code === 'MODULE_NOT_FOUND') {
|
| 186 |
+
console.log(`${RED}❌ 未找到${RESET}`); // Red X
|
| 187 |
+
missingModules.push(moduleName);
|
| 188 |
+
allFound = false;
|
| 189 |
+
} else {
|
| 190 |
+
console.log(`${RED}❌ 检查时出错: ${error.message}${RESET}`);
|
| 191 |
+
allFound = false;
|
| 192 |
+
// Consider exiting if it's not MODULE_NOT_FOUND?
|
| 193 |
+
// return false;
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
process.stdout.write(` - 服务器脚本 (${SERVER_SCRIPT_FILENAME}) ... `);
|
| 199 |
+
if (!fs.existsSync(SERVER_SCRIPT_PATH)) {
|
| 200 |
+
console.log(`${RED}❌ 未找到${RESET}`); // Red X
|
| 201 |
+
console.error(` ${RED}错误: 未在预期路径找到 '${SERVER_SCRIPT_FILENAME}' 文件。${RESET}`);
|
| 202 |
+
console.error(` 预期路径: ${SERVER_SCRIPT_PATH}`);
|
| 203 |
+
console.error(` 请确保 '${SERVER_SCRIPT_FILENAME}' 与此脚本位于同一目录。`);
|
| 204 |
+
allFound = false;
|
| 205 |
+
} else {
|
| 206 |
+
console.log(`${GREEN}✓ 已找到${RESET}`); // Green checkmark
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
if (!allFound) {
|
| 210 |
+
console.log(`\n${RED}-------------------------------------------------${RESET}`);
|
| 211 |
+
console.error(`${RED}❌ 错误: 依赖项检查未通过!${RESET}`);
|
| 212 |
+
if (missingModules.length > 0) {
|
| 213 |
+
console.error(` ${RED}缺少以下 Node.js 模块: ${missingModules.join(', ')}${RESET}`);
|
| 214 |
+
console.log(' 请根据您使用的包管理器运行以下命令安装依赖:');
|
| 215 |
+
console.log(` ${MAGENTA}npm install ${missingModules.join(' ')}${RESET}`);
|
| 216 |
+
console.log(' 或');
|
| 217 |
+
console.log(` ${MAGENTA}yarn add ${missingModules.join(' ')}${RESET}`);
|
| 218 |
+
console.log(' 或');
|
| 219 |
+
console.log(` ${MAGENTA}pnpm install ${missingModules.join(' ')}${RESET}`);
|
| 220 |
+
console.log(' (如果已安装但仍提示未找到,请尝试删除 node_modules 目录和 package-lock.json/yarn.lock 文件后重新安装)');
|
| 221 |
+
}
|
| 222 |
+
if (!fs.existsSync(SERVER_SCRIPT_PATH)) {
|
| 223 |
+
console.error(` ${RED}缺少必要的服务器脚本文件: ${SERVER_SCRIPT_FILENAME}${RESET}`);
|
| 224 |
+
console.error(` 请确保它和 auto_connect_aistudio.cjs 在同一个文件夹内。`);
|
| 225 |
+
}
|
| 226 |
+
console.log(`${RED}-------------------------------------------------${RESET}`);
|
| 227 |
+
return false;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
console.log(`\n${GREEN}✅ 所有依赖检查通过。${RESET}`);
|
| 231 |
+
playwright = require('playwright'); // Load playwright only after checks
|
| 232 |
+
return true;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
// --- 步骤 2: 检查并启动 Chrome ---
|
| 236 |
+
async function launchChrome() {
|
| 237 |
+
console.log(`${CYAN}-------------------------------------------------${RESET}`);
|
| 238 |
+
console.log(`${CYAN}--- 步骤 2: 启动或连接 Chrome (调试端口 ${DEBUGGING_PORT}) ---${RESET}`);
|
| 239 |
+
|
| 240 |
+
// 首先检查端口是否被占用
|
| 241 |
+
if (isPortInUse(DEBUGGING_PORT)) {
|
| 242 |
+
console.log(`${YELLOW}⚠️ 警告: 端口 ${DEBUGGING_PORT} 已被占用。${RESET}`);
|
| 243 |
+
console.log(' 这通常意味着已经有一个 Chrome 实例在监听此端口。');
|
| 244 |
+
const question = `选择操作: [Y/n]
|
| 245 |
+
${GREEN}Y (默认): 尝试连接现有 Chrome 实例并启动 API 服务器。${RESET}
|
| 246 |
+
${YELLOW}n: 自动强行结束占用端口 ${DEBUGGING_PORT} 的进程,然后启动新的 Chrome 实例。${RESET}
|
| 247 |
+
请输入选项 [Y/n]: `;
|
| 248 |
+
const answer = await askQuestion(question);
|
| 249 |
+
|
| 250 |
+
if (answer.toLowerCase() === 'n') {
|
| 251 |
+
console.log(`\n好的,您选择了启动新实例。将尝试自动清理端口...`);
|
| 252 |
+
const pids = findPidsUsingPort(DEBUGGING_PORT);
|
| 253 |
+
if (pids.length > 0) {
|
| 254 |
+
const killSuccess = killProcesses(pids);
|
| 255 |
+
if (killSuccess) {
|
| 256 |
+
console.log(` ${GREEN}✅ 尝试结束进程完成。等待 1 秒检查端口...${RESET}`);
|
| 257 |
+
await new Promise(resolve => setTimeout(resolve, 1000)); // 短暂等待
|
| 258 |
+
if (isPortInUse(DEBUGGING_PORT)) {
|
| 259 |
+
console.error(`${RED}❌ 错误: 尝试结束后,端口 ${DEBUGGING_PORT} 仍然被占用。${RESET}`);
|
| 260 |
+
console.error(' 可能原因:权限不足,或进程未能正常终止。请尝试手动结束进程。' );
|
| 261 |
+
// 提供手动清理提示
|
| 262 |
+
console.log(`${YELLOW}提示: 您可以使用以下命令查找进程 ID (PID):${RESET}`);
|
| 263 |
+
if (process.platform === 'win32') {
|
| 264 |
+
console.log(` - 在 CMD 或 PowerShell 中: netstat -ano | findstr :${DEBUGGING_PORT}`);
|
| 265 |
+
console.log(' - 找到 PID 后,使用: taskkill /F /PID <PID>');
|
| 266 |
+
} else { // macOS or Linux
|
| 267 |
+
console.log(` - 在终端中: lsof -t -i:${DEBUGGING_PORT}`);
|
| 268 |
+
console.log(' - 找到 PID 后,使用: kill -9 <PID>');
|
| 269 |
+
}
|
| 270 |
+
await askQuestion('请在手动结束进程后,按 Enter 键重试脚本...');
|
| 271 |
+
process.exit(1); // 退出,让用户处理后重跑
|
| 272 |
+
} else {
|
| 273 |
+
console.log(` ${GREEN}✅ 端口 ${DEBUGGING_PORT} 现在空闲。${RESET}`);
|
| 274 |
+
// 端口已清理,继续执行下面的 Chrome 启动流程
|
| 275 |
+
}
|
| 276 |
+
} else {
|
| 277 |
+
console.error(`${RED}❌ 错误: 尝试结束部分或全部占用端口的进程失败。${RESET}`);
|
| 278 |
+
console.error(' 请检查日志中的具体错误信息,可能需要手动结束进程。');
|
| 279 |
+
await askQuestion('请在手动结束进程后,按 Enter 键重试脚本...');
|
| 280 |
+
process.exit(1); // 退出,让用户处理后重跑
|
| 281 |
+
}
|
| 282 |
+
} else {
|
| 283 |
+
console.log(`${YELLOW} 虽然端口被占用,但未能找到具体监听的进程 PID。可能情况复杂,建议手动检查。${RESET}` );
|
| 284 |
+
await askQuestion('请手动检查并确保端口空闲后,按 Enter 键重试脚本...');
|
| 285 |
+
process.exit(1); // 退出
|
| 286 |
+
}
|
| 287 |
+
// 如果代码执行到这里,意味着端口清理成功,将继续启动 Chrome
|
| 288 |
+
console.log(`\n准备启动新的 Chrome 实例...`);
|
| 289 |
+
|
| 290 |
+
} else {
|
| 291 |
+
console.log(`\n好的,将尝试连接到现有的 Chrome 实例...`);
|
| 292 |
+
return 'use_existing'; // 特殊返回值,告知主流程跳过启动,直接连接
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
// --- 如果端口未被占用,或者用户选择 'n' 且自动清理成功 ---
|
| 297 |
+
|
| 298 |
+
if (!chromeExecutablePath) {
|
| 299 |
+
console.error(`${RED}❌ 错误: 未能在当前操作系统 (${process.platform}) 的常见路径找到 Chrome 可执行文件。${RESET}`);
|
| 300 |
+
console.error(' 请确保已安装 Google Chrome,或修改脚本中的 getChromePath 函数以指向正确的路径。');
|
| 301 |
+
if (process.platform === 'win32') {
|
| 302 |
+
console.error(' (已尝试查找 %ProgramFiles% 和 %ProgramFiles(x86)% 下的路径)');
|
| 303 |
+
} else if (process.platform === 'linux') {
|
| 304 |
+
console.error(' (已尝试查找 /usr/bin/google-chrome, /usr/bin/google-chrome-stable, /opt/google/chrome/chrome)');
|
| 305 |
+
}
|
| 306 |
+
return false;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
console.log(` ${GREEN}找到 Chrome 路径:${RESET} ${chromeExecutablePath}`);
|
| 310 |
+
|
| 311 |
+
// 只有在明确需要启动新实例时才提示关闭其他实例
|
| 312 |
+
// (如果上面选择了 'n' 并清理成功,这里 isPortInUse 应该返回 false)
|
| 313 |
+
if (!isPortInUse(DEBUGGING_PORT)) {
|
| 314 |
+
console.log(`${YELLOW}⚠️ 重要提示:为了确保新的调试端口生效,建议先手动完全退出所有*其他*可能干扰的 Google Chrome 实例。${RESET}`);
|
| 315 |
+
console.log(' (在 macOS 上通常是 Cmd+Q,Windows/Linux 上是关闭所有窗口)');
|
| 316 |
+
await askQuestion('请确认已处理好其他 Chrome 实例,然后按 Enter 键继续启动...');
|
| 317 |
+
} else {
|
| 318 |
+
// 理论上不应该到这里,因为端口已被清理或选择了 use_existing
|
| 319 |
+
console.warn(` ${YELLOW}警告:端口 ${DEBUGGING_PORT} 意外地仍被占用。继续尝试启动,但这极有可能失败。${RESET}`);
|
| 320 |
+
await askQuestion('请按 Enter 键继续尝试启动...');
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
console.log(`正在尝试启动 Chrome...`);
|
| 325 |
+
console.log(` 路径: "${chromeExecutablePath}"`);
|
| 326 |
+
// --- 修改:添加启动参数 ---
|
| 327 |
+
const chromeArgs = [
|
| 328 |
+
`--remote-debugging-port=${DEBUGGING_PORT}`,
|
| 329 |
+
`--window-size=460,800` // 指定宽度为 460px,高度暂定为 800px (可以根据需要调整)
|
| 330 |
+
// 你可以在这里添加其他需要的 Chrome 启动参数
|
| 331 |
+
];
|
| 332 |
+
console.log(` 参数: ${chromeArgs.join(' ')}`); // 打印所有参数
|
| 333 |
+
|
| 334 |
+
try {
|
| 335 |
+
const chromeProcess = spawn(
|
| 336 |
+
chromeExecutablePath,
|
| 337 |
+
chromeArgs, // 使用包含窗口大小的参数数组
|
| 338 |
+
{ detached: true, stdio: 'ignore' } // Detach to allow script to exit independently if needed
|
| 339 |
+
);
|
| 340 |
+
chromeProcess.unref(); // Allow parent process to exit independently
|
| 341 |
+
|
| 342 |
+
console.log(`${GREEN}✅ Chrome 启动命令已发送 (指定窗口大小)。稍后将尝试连接...${RESET}`);
|
| 343 |
+
console.log(`${DIM}⏳ 等待 3 秒让 Chrome 进程启动...${RESET}`);
|
| 344 |
+
await new Promise(resolve => setTimeout(resolve, 3000));
|
| 345 |
+
return true; // 表示启动流程已尝试
|
| 346 |
+
|
| 347 |
+
} catch (error) {
|
| 348 |
+
console.error(`${RED}❌ 启动 Chrome 时出错: ${error.message}${RESET}`);
|
| 349 |
+
console.error(` 请检查路径 "${chromeExecutablePath}" 是否正确,以及是否有权限执行。`);
|
| 350 |
+
return false;
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
// --- 步骤 3: 连接 Playwright 并管理页面 (带重试) ---
|
| 355 |
+
async function connectAndManagePage() {
|
| 356 |
+
console.log(`${CYAN}-------------------------------------------------${RESET}`);
|
| 357 |
+
console.log(`${CYAN}--- 步骤 3: 连接 Playwright 到 ${CDP_ADDRESS} (最多尝试 ${CONNECTION_RETRIES} 次) ---${RESET}`);
|
| 358 |
+
let browser = null;
|
| 359 |
+
let context = null;
|
| 360 |
+
|
| 361 |
+
for (let i = 0; i < CONNECTION_RETRIES; i++) {
|
| 362 |
+
try {
|
| 363 |
+
console.log(`\n${DIM}尝试连接 Playwright (第 ${i + 1}/${CONNECTION_RETRIES} 次)...${RESET}`);
|
| 364 |
+
browser = await playwright.chromium.connectOverCDP(CDP_ADDRESS, { timeout: CONNECT_TIMEOUT_MS });
|
| 365 |
+
console.log(`${GREEN}✅ 成功连接到 Chrome!${RESET}`);
|
| 366 |
+
|
| 367 |
+
// Simplified context fetching
|
| 368 |
+
await new Promise(resolve => setTimeout(resolve, 500)); // Short delay after connect
|
| 369 |
+
const contexts = browser.contexts();
|
| 370 |
+
if (contexts && contexts.length > 0) {
|
| 371 |
+
context = contexts[0];
|
| 372 |
+
console.log(`-> 获取到浏览器默认上下文。`);
|
| 373 |
+
break; // Connection and context successful
|
| 374 |
+
} else {
|
| 375 |
+
// This case should be rare if connectOverCDP succeeded with a responsive Chrome
|
| 376 |
+
throw new Error('连接成功,但无法获取浏览器上下文。Chrome 可能没有响应或未完全初始化。');
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
} catch (error) {
|
| 380 |
+
console.warn(` ${YELLOW}连接尝试 ${i + 1} 失败: ${error.message.split('\n')[0]}${RESET}`);
|
| 381 |
+
if (browser && browser.isConnected()) {
|
| 382 |
+
// Should not happen if connectOverCDP failed, but good practice
|
| 383 |
+
await browser.close().catch(e => console.error("尝试关闭连接失败的浏览器时出错:", e));
|
| 384 |
+
}
|
| 385 |
+
browser = null;
|
| 386 |
+
context = null;
|
| 387 |
+
|
| 388 |
+
if (i < CONNECTION_RETRIES - 1) {
|
| 389 |
+
console.log(` ${YELLOW}可能原因: Chrome 未完全启动 / 端口 ${DEBUGGING_PORT} 未监听 / 端口被占用。${RESET}`);
|
| 390 |
+
console.log(`${DIM} 等待 ${RETRY_DELAY_MS / 1000} 秒后重试...${RESET}`);
|
| 391 |
+
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
|
| 392 |
+
} else {
|
| 393 |
+
console.error(`\n${RED}❌ 在 ${CONNECTION_RETRIES} 次尝试后仍然无法连接。${RESET}`);
|
| 394 |
+
console.error(' 请再次检查:');
|
| 395 |
+
console.error(' 1. Chrome 是否真的已经通过脚本成功启动,并且窗口可见、已加载?(可能需要登录Google)');
|
| 396 |
+
console.error(` 2. 是否有其他程序占用了端口 ${DEBUGGING_PORT}?(检查命令: macOS/Linux: lsof -i :${DEBUGGING_PORT} | Windows: netstat -ano | findstr ${DEBUGGING_PORT})`);
|
| 397 |
+
console.error(' 3. 启动 Chrome 时终端或系统是否有报错信息?');
|
| 398 |
+
console.error(' 4. 防火墙或安全软件是否阻止了本地回环地址(127.0.0.1)的连接?');
|
| 399 |
+
return false;
|
| 400 |
+
}
|
| 401 |
+
}
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
if (!browser || !context) {
|
| 405 |
+
console.error(`${RED}-> 未能成功连接到浏览器或获取上下文。${RESET}`);
|
| 406 |
+
return false;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
// --- 连接成功后的页面管理逻辑 ---
|
| 410 |
+
console.log(`\n${CYAN}--- 页面管理 ---${RESET}`);
|
| 411 |
+
try {
|
| 412 |
+
let targetPage = null;
|
| 413 |
+
let pages = [];
|
| 414 |
+
try {
|
| 415 |
+
pages = context.pages();
|
| 416 |
+
} catch (err) {
|
| 417 |
+
console.error(`${RED}❌ 获取现有页面列表时出错:${RESET}`, err);
|
| 418 |
+
console.log(" 将尝试打开新页面...");
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
console.log(`${DIM}-> 检查 ${pages.length} 个已存在的页面...${RESET}`);
|
| 422 |
+
const aiStudioUrlPattern = 'aistudio.google.com/';
|
| 423 |
+
const loginUrlPattern = 'accounts.google.com/';
|
| 424 |
+
|
| 425 |
+
for (const page of pages) {
|
| 426 |
+
try {
|
| 427 |
+
if (!page.isClosed()) {
|
| 428 |
+
const pageUrl = page.url();
|
| 429 |
+
console.log(`${DIM} 检查页面: ${pageUrl}${RESET}`);
|
| 430 |
+
// Prioritize AI Studio pages, then login pages
|
| 431 |
+
if (pageUrl.includes(aiStudioUrlPattern)) {
|
| 432 |
+
console.log(`-> ${GREEN}找到 AI Studio 页面:${RESET} ${pageUrl}`);
|
| 433 |
+
targetPage = page;
|
| 434 |
+
// Ensure it's the target URL if possible
|
| 435 |
+
if (!pageUrl.includes('/prompts/new_chat')) {
|
| 436 |
+
console.log(`${YELLOW} 非目标页面,尝试导航到 ${TARGET_URL}...${RESET}`);
|
| 437 |
+
try {
|
| 438 |
+
await targetPage.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT_MS });
|
| 439 |
+
console.log(` ${GREEN}导航成功:${RESET} ${targetPage.url()}`);
|
| 440 |
+
} catch (navError) {
|
| 441 |
+
console.warn(` ${YELLOW}警告:导航到 ${TARGET_URL} 失败: ${navError.message.split('\n')[0]}${RESET}`);
|
| 442 |
+
console.warn(` ${YELLOW}将使用当前页面 (${pageUrl}),请稍后手动确认。${RESET}`);
|
| 443 |
+
}
|
| 444 |
+
} else {
|
| 445 |
+
console.log(` ${GREEN}页面已在目标路径或子路径。${RESET}`);
|
| 446 |
+
}
|
| 447 |
+
break; // Found a good AI Studio page
|
| 448 |
+
} else if (pageUrl.includes(loginUrlPattern) && !targetPage) {
|
| 449 |
+
// Keep track of a login page if no AI studio page is found yet
|
| 450 |
+
console.log(`-> ${YELLOW}发现 Google 登录页面,暂存。${RESET}`);
|
| 451 |
+
targetPage = page;
|
| 452 |
+
// Don't break here, keep looking for a direct AI Studio page
|
| 453 |
+
}
|
| 454 |
+
}
|
| 455 |
+
} catch (pageError) {
|
| 456 |
+
if (!page.isClosed()) {
|
| 457 |
+
console.warn(` ${YELLOW}警告:评估或导航页面时出错: ${pageError.message.split('\n')[0]}${RESET}`);
|
| 458 |
+
}
|
| 459 |
+
// Avoid using a page that caused an error
|
| 460 |
+
if (targetPage === page) {
|
| 461 |
+
targetPage = null;
|
| 462 |
+
}
|
| 463 |
+
}
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
// If after checking all pages, the best we found was a login page
|
| 467 |
+
if (targetPage && targetPage.url().includes(loginUrlPattern)) {
|
| 468 |
+
console.log(`-> ${YELLOW}未找到直接的 AI Studio 页面,将使用之前找到的登录页面。${RESET}`);
|
| 469 |
+
console.log(` ${YELLOW}请确保在该页面手动完成登录。${RESET}`);
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
// If no suitable page was found at all
|
| 473 |
+
if (!targetPage) {
|
| 474 |
+
console.log(`-> ${YELLOW}未找到合适的现有页面。正在打开新页面并导航到 ${TARGET_URL}...${RESET}`);
|
| 475 |
+
try {
|
| 476 |
+
targetPage = await context.newPage();
|
| 477 |
+
console.log(`${DIM} 正在导航...${RESET}`);
|
| 478 |
+
await targetPage.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT_MS });
|
| 479 |
+
console.log(`-> ${GREEN}新页面已打开并导航到:${RESET} ${targetPage.url()}`);
|
| 480 |
+
} catch (newPageError) {
|
| 481 |
+
console.error(`${RED}❌ 打开或导航新页面到 ${TARGET_URL} 失败: ${newPageError.message}${RESET}`);
|
| 482 |
+
console.error(" 请检查网络连接,以及 Chrome 是否能正常访问该网址。可能需要手动登录。" );
|
| 483 |
+
await browser.close().catch(e => {});
|
| 484 |
+
return false;
|
| 485 |
+
}
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
try {
|
| 489 |
+
await targetPage.bringToFront();
|
| 490 |
+
console.log('-> 已尝试将目标页面置于前台。');
|
| 491 |
+
} catch (bringToFrontError) {
|
| 492 |
+
console.warn(` ${YELLOW}警告:将页面置于前台失败: ${bringToFrontError.message.split('\n')[0]}${RESET}`);
|
| 493 |
+
console.warn(` (这可能发生在窗口最小化或位于不同虚拟桌面上时,通常不影响连接)`);
|
| 494 |
+
}
|
| 495 |
+
await new Promise(resolve => setTimeout(resolve, 500)); // Small delay after bringToFront
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
console.log(`\n${BRIGHT}${GREEN}🎉 --- AI Studio 连接准备完成 --- 🎉${RESET}`);
|
| 499 |
+
console.log(`${GREEN}Chrome 已启动,Playwright 已连接,相关页面已找到或创建。${RESET}`);
|
| 500 |
+
console.log(`${YELLOW}请确保在 Chrome 窗口中 AI Studio 页面处于可交互状态 (例如,已登录Google, 无弹窗)。${RESET}`);
|
| 501 |
+
|
| 502 |
+
return true;
|
| 503 |
+
|
| 504 |
+
} catch (error) {
|
| 505 |
+
console.error(`\n${RED}❌ --- 步骤 3 页面管理失败 ---${RESET}`);
|
| 506 |
+
console.error(' 在连接成功后,处理页面时发生错误:', error);
|
| 507 |
+
if (browser && browser.isConnected()) {
|
| 508 |
+
await browser.close().catch(e => console.error("关闭浏览器时出错:", e));
|
| 509 |
+
}
|
| 510 |
+
return false;
|
| 511 |
+
} finally {
|
| 512 |
+
// 这里不再打印即将退出的日志,因为脚本会继续运行 server.js
|
| 513 |
+
// console.log("-> auto_connect_aistudio.js 步骤3结束。");
|
| 514 |
+
// 不需要手动断开 browser 连接,因为是 connectOverCDP
|
| 515 |
+
}
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
// --- 步骤 4: 启动 API 服务器 ---
|
| 520 |
+
function startApiServer() {
|
| 521 |
+
console.log(`${CYAN}-------------------------------------------------${RESET}`);
|
| 522 |
+
console.log(`${CYAN}--- 步骤 4: 启动 API 服务器 ('node ${SERVER_SCRIPT_FILENAME}') ---${RESET}`);
|
| 523 |
+
console.log(`${DIM} 脚本路径: ${SERVER_SCRIPT_PATH}${RESET}`);
|
| 524 |
+
|
| 525 |
+
if (!fs.existsSync(SERVER_SCRIPT_PATH)) {
|
| 526 |
+
console.error(`${RED}❌ 错误: 无法启动服务器,文件不存在: ${SERVER_SCRIPT_PATH}${RESET}`);
|
| 527 |
+
process.exit(1);
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
console.log(`${DIM}正在启动: node ${SERVER_SCRIPT_PATH}${RESET}`);
|
| 531 |
+
|
| 532 |
+
try {
|
| 533 |
+
const serverProcess = spawn('node', [SERVER_SCRIPT_PATH], {
|
| 534 |
+
stdio: 'inherit',
|
| 535 |
+
cwd: __dirname
|
| 536 |
+
});
|
| 537 |
+
|
| 538 |
+
serverProcess.on('error', (err) => {
|
| 539 |
+
console.error(`${RED}❌ 启动 '${SERVER_SCRIPT_FILENAME}' 失败: ${err.message}${RESET}`);
|
| 540 |
+
console.error(`请检查 Node.js 是否已安装并配置在系统 PATH 中,以及 '${SERVER_SCRIPT_FILENAME}' 文件是否有效。`);
|
| 541 |
+
process.exit(1);
|
| 542 |
+
});
|
| 543 |
+
|
| 544 |
+
serverProcess.on('exit', (code, signal) => {
|
| 545 |
+
console.log(`\n${MAGENTA}👋 '${SERVER_SCRIPT_FILENAME}' 进程已退出 (代码: ${code}, 信号: ${signal})。${RESET}`);
|
| 546 |
+
console.log("自动连接脚本执行结束。");
|
| 547 |
+
process.exit(code ?? 0);
|
| 548 |
+
});
|
| 549 |
+
// Don't print the success message here, let server.cjs print its own ready message
|
| 550 |
+
// console.log("✅ '${SERVER_SCRIPT_FILENAME}' 已启动。脚本将保持运行,直到服务器进程结束或被手动中断。");
|
| 551 |
+
|
| 552 |
+
} catch (error) {
|
| 553 |
+
console.error(`${RED}❌ 启动 '${SERVER_SCRIPT_FILENAME}' 时发生意外错误: ${error.message}${RESET}`);
|
| 554 |
+
process.exit(1);
|
| 555 |
+
}
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
// --- 主执行流程 ---
|
| 560 |
+
(async () => {
|
| 561 |
+
console.log(`${MAGENTA}🚀 欢迎使用 AI Studio 自动连接与启动脚本 (跨平台优化, v2.9 自动端口清理) 🚀${RESET}`);
|
| 562 |
+
console.log(`${MAGENTA}=================================================${RESET}`);
|
| 563 |
+
|
| 564 |
+
if (!await checkDependencies()) {
|
| 565 |
+
process.exit(1);
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
console.log(`${MAGENTA}=================================================${RESET}`);
|
| 569 |
+
|
| 570 |
+
const launchResult = await launchChrome();
|
| 571 |
+
|
| 572 |
+
if (launchResult === false) {
|
| 573 |
+
console.log(`${RED}❌ 启动 Chrome 失败,脚本终止。${RESET}`);
|
| 574 |
+
process.exit(1);
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
// 如果 launchResult 是 'use_existing' 或 true, 都需要连接
|
| 578 |
+
console.log(`${MAGENTA}=================================================${RESET}`);
|
| 579 |
+
if (!await connectAndManagePage()) {
|
| 580 |
+
// 如果连接失败,并且我们是尝试连接到现有实例,给出更具体的提示
|
| 581 |
+
if (launchResult === 'use_existing') {
|
| 582 |
+
console.error(`${RED}❌ 连接到现有 Chrome 实例 (端口 ${DEBUGGING_PORT}) 失败。${RESET}`);
|
| 583 |
+
console.error(' 请确认:');
|
| 584 |
+
console.error(' 1. 占用该端口的确实是您想连接的 Chrome 实例。');
|
| 585 |
+
console.error(' 2. 该 Chrome 实例是以 --remote-debugging-port 参数启动的。');
|
| 586 |
+
console.error(' 3. Chrome 实例本身运行正常,没有崩溃或无响应。');
|
| 587 |
+
}
|
| 588 |
+
process.exit(1);
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
// 无论 Chrome 是新启动的还是已存在的,只要连接成功,就启动 API 服务器
|
| 592 |
+
console.log(`${MAGENTA}=================================================${RESET}`);
|
| 593 |
+
startApiServer();
|
| 594 |
+
|
| 595 |
+
})();
|
AIStudioProxyAPI/deprecated_javascript_version/package.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"dependencies": {
|
| 3 |
+
"cors": "^2.8.5",
|
| 4 |
+
"express": "^4.19.2",
|
| 5 |
+
"playwright": "^1.44.1",
|
| 6 |
+
"@playwright/test": "^1.44.1"
|
| 7 |
+
}
|
| 8 |
+
}
|
AIStudioProxyAPI/deprecated_javascript_version/server.cjs
ADDED
|
@@ -0,0 +1,1505 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// server.cjs (优化版 v2.17 - 增加日志ID & 常量)
|
| 2 |
+
|
| 3 |
+
const express = require('express');
|
| 4 |
+
const fs = require('fs');
|
| 5 |
+
const path = require('path');
|
| 6 |
+
const cors = require('cors');
|
| 7 |
+
|
| 8 |
+
// --- 依赖检查 ---
|
| 9 |
+
let playwright, expect;
|
| 10 |
+
const requiredModules = ['express', 'playwright', '@playwright/test', 'cors'];
|
| 11 |
+
const missingModules = [];
|
| 12 |
+
|
| 13 |
+
for (const modName of requiredModules) {
|
| 14 |
+
try {
|
| 15 |
+
if (modName === 'playwright') {
|
| 16 |
+
playwright = require(modName);
|
| 17 |
+
} else if (modName === '@playwright/test') {
|
| 18 |
+
expect = require(modName).expect;
|
| 19 |
+
} else {
|
| 20 |
+
require(modName);
|
| 21 |
+
}
|
| 22 |
+
// console.log(`✅ 模块 ${modName} 已加载。`); // Optional: Log success
|
| 23 |
+
} catch (e) {
|
| 24 |
+
console.error(`❌ 模块 ${modName} 未找到。`);
|
| 25 |
+
missingModules.push(modName);
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
if (missingModules.length > 0) {
|
| 30 |
+
console.error("-------------------------------------------------------------");
|
| 31 |
+
console.error("❌ 错误:缺少必要的依赖模块!");
|
| 32 |
+
console.error("请根据您使用的包管理器运行以下命令安装依赖:");
|
| 33 |
+
console.error("-------------------------------------------------------------");
|
| 34 |
+
console.error(` npm install ${missingModules.join(' ')}`);
|
| 35 |
+
console.error(" 或");
|
| 36 |
+
console.error(` yarn add ${missingModules.join(' ')}`);
|
| 37 |
+
console.error(" 或");
|
| 38 |
+
console.error(` pnpm install ${missingModules.join(' ')}`);
|
| 39 |
+
console.error("-------------------------------------------------------------");
|
| 40 |
+
process.exit(1);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// --- 配置 ---
|
| 44 |
+
const SERVER_PORT = process.env.PORT || 2048;
|
| 45 |
+
const CHROME_DEBUGGING_PORT = 8848;
|
| 46 |
+
const CDP_ADDRESS = `http://127.0.0.1:${CHROME_DEBUGGING_PORT}`;
|
| 47 |
+
const AI_STUDIO_URL_PATTERN = 'aistudio.google.com/';
|
| 48 |
+
const RESPONSE_COMPLETION_TIMEOUT = 300000; // 5分钟总超时
|
| 49 |
+
const POLLING_INTERVAL = 300; // 非流式/通用检查间隔
|
| 50 |
+
const POLLING_INTERVAL_STREAM = 200; // 流式检查轮询间隔 (ms)
|
| 51 |
+
// v2.12: Timeout for secondary checks *after* spinner disappears
|
| 52 |
+
const POST_SPINNER_CHECK_DELAY_MS = 500; // Spinner消失后稍作等待再检查其他状态
|
| 53 |
+
const FINAL_STATE_CHECK_TIMEOUT_MS = 1500; // 检查按钮和输入框最终状态的超时
|
| 54 |
+
const SPINNER_CHECK_TIMEOUT_MS = 1000; // 检查Spinner状态的超时
|
| 55 |
+
const POST_COMPLETION_BUFFER = 1000; // JSON模式下可以缩短检查后等待时间
|
| 56 |
+
const SILENCE_TIMEOUT_MS = 1500; // 文本静默多久后认为稳定 (Spinner消失后)
|
| 57 |
+
|
| 58 |
+
// --- 常量 ---
|
| 59 |
+
const MODEL_NAME = 'google-ai-studio-via-playwright-cdp-json';
|
| 60 |
+
const CHAT_COMPLETION_ID_PREFIX = 'chatcmpl-';
|
| 61 |
+
|
| 62 |
+
// --- 选择器常量 ---
|
| 63 |
+
const INPUT_SELECTOR = 'ms-prompt-input-wrapper textarea';
|
| 64 |
+
const SUBMIT_BUTTON_SELECTOR = 'button[aria-label="Run"]';
|
| 65 |
+
const RESPONSE_CONTAINER_SELECTOR = 'ms-chat-turn .chat-turn-container.model'; // 选择器指向 AI 模型回复的容器
|
| 66 |
+
const RESPONSE_TEXT_SELECTOR = 'ms-cmark-node.cmark-node';
|
| 67 |
+
const LOADING_SPINNER_SELECTOR = 'button[aria-label="Run"] svg .stoppable-spinner';
|
| 68 |
+
const ERROR_TOAST_SELECTOR = 'div.toast.warning, div.toast.error';
|
| 69 |
+
// !! 新增:清空聊天记录相关选择器 !!
|
| 70 |
+
const CLEAR_CHAT_BUTTON_SELECTOR = 'button[aria-label="Clear chat"][data-test-clear="outside"]:has(span.material-symbols-outlined:has-text("refresh"))'; // 清空按钮 (带图标确认)
|
| 71 |
+
const CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR = 'button.mdc-button:has-text("Continue")'; // 确认对话框中的 "Continue" 按钮
|
| 72 |
+
// !! 新增:清空验证相关常量 !!
|
| 73 |
+
const CLEAR_CHAT_VERIFY_TIMEOUT_MS = 5000; // 等待清空生效的总超时时间 (ms)
|
| 74 |
+
const CLEAR_CHAT_VERIFY_INTERVAL_MS = 300; // 检查清空状态的轮询间隔 (ms)
|
| 75 |
+
|
| 76 |
+
// v2.16: JSON Structure Prompt (Restored for non-streaming)
|
| 77 |
+
const prepareAIStudioPrompt = (userPrompt, systemPrompt = null) => {
|
| 78 |
+
let fullPrompt = `
|
| 79 |
+
IMPORTANT: Your entire response MUST be a single JSON object. Do not include any text outside of this JSON object.
|
| 80 |
+
The JSON object must have a single key named "response".
|
| 81 |
+
Inside the value of the "response" key (which is a string), you MUST put the exact marker "<<<START_RESPONSE>>>"" at the very beginning of your actual answer. There should be NO text before this marker within the response string.
|
| 82 |
+
`;
|
| 83 |
+
|
| 84 |
+
if (systemPrompt && systemPrompt.trim() !== '') {
|
| 85 |
+
fullPrompt += `\nSystem Instruction: ${systemPrompt}\n`;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
fullPrompt += `
|
| 89 |
+
Example 1:
|
| 90 |
+
User asks: "What is the capital of France?"
|
| 91 |
+
Your response MUST be:
|
| 92 |
+
{
|
| 93 |
+
"response": "<<<START_RESPONSE>>>The capital of France is Paris."
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
Example 2:
|
| 97 |
+
User asks: "Write a python function to add two numbers"
|
| 98 |
+
Your response MUST be:
|
| 99 |
+
{
|
| 100 |
+
"response": "<<<START_RESPONSE>>>\\\`\\\`\\\`python\\\\ndef add(a, b):\\\\n return a + b\\\\n\\\`\\\`\\\`"
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
Now, answer the following user prompt, ensuring your output strictly adheres to the JSON format AND the start marker requirement described above:
|
| 104 |
+
|
| 105 |
+
User Prompt: "${userPrompt}"
|
| 106 |
+
|
| 107 |
+
Your JSON Response:
|
| 108 |
+
`;
|
| 109 |
+
return fullPrompt;
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
// v2.26: Use JSON prompt for streaming as well -> vNEXT: Use Markdown Code Block for streaming
|
| 113 |
+
// vNEXT: Instruct AI to output *incomplete* JSON for streaming -> vNEXT: Instruct AI to output Markdown Code Block
|
| 114 |
+
const prepareAIStudioPromptStream = (userPrompt, systemPrompt = null) => {
|
| 115 |
+
let fullPrompt = `
|
| 116 |
+
IMPORTANT: For this streaming request, your entire response MUST be enclosed in a single markdown code block (like \`\`\` block \`\`\`).
|
| 117 |
+
Inside this code block, your actual answer text MUST start immediately after the exact marker "<<<START_RESPONSE>>>".
|
| 118 |
+
Start your response exactly with "\`\`\`\n<<<START_RESPONSE>>>" followed by your answer content.
|
| 119 |
+
Continue outputting your answer content. You SHOULD include the final closing "\`\`\`" at the very end of your full response stream.
|
| 120 |
+
`;
|
| 121 |
+
|
| 122 |
+
if (systemPrompt && systemPrompt.trim() !== '') {
|
| 123 |
+
fullPrompt += `\nSystem Instruction: ${systemPrompt}\n`;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
fullPrompt += `
|
| 127 |
+
Example 1 (Streaming):
|
| 128 |
+
User asks: "What is the capital of France?"
|
| 129 |
+
Your streamed response MUST look like this over time:
|
| 130 |
+
Stream part 1: \`\`\`\n<<<START_RESPONSE>>>The capital
|
| 131 |
+
Stream part 2: of France is
|
| 132 |
+
Stream part 3: Paris.\n\`\`\`
|
| 133 |
+
|
| 134 |
+
Example 2 (Streaming):
|
| 135 |
+
User asks: "Write a python function to add two numbers"
|
| 136 |
+
Your streamed response MUST look like this over time:
|
| 137 |
+
Stream part 1: \`\`\`\n<<<START_RESPONSE>>>\`\`\`python\ndef add(a, b):
|
| 138 |
+
Stream part 2: \n return a + b\n
|
| 139 |
+
Stream part 3: \`\`\`\n\`\`\`
|
| 140 |
+
|
| 141 |
+
Now, answer the following user prompt, ensuring your output strictly adheres to the markdown code block, start marker, and streaming requirements described above:
|
| 142 |
+
|
| 143 |
+
User Prompt: "${userPrompt}"
|
| 144 |
+
|
| 145 |
+
Your Response (Streaming, within a markdown code block):
|
| 146 |
+
`;
|
| 147 |
+
return fullPrompt;
|
| 148 |
+
};
|
| 149 |
+
|
| 150 |
+
const app = express();
|
| 151 |
+
|
| 152 |
+
// --- 全局变量 ---
|
| 153 |
+
let browser = null;
|
| 154 |
+
let page = null;
|
| 155 |
+
let isPlaywrightReady = false;
|
| 156 |
+
let isInitializing = false;
|
| 157 |
+
// v2.18: 请求队列和处理状态
|
| 158 |
+
let requestQueue = [];
|
| 159 |
+
let isProcessing = false;
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
// --- Playwright 初始化函数 ---
|
| 163 |
+
async function initializePlaywright() {
|
| 164 |
+
if (isPlaywrightReady || isInitializing) return;
|
| 165 |
+
isInitializing = true;
|
| 166 |
+
console.log(`--- 初始化 Playwright: 连接到 ${CDP_ADDRESS} ---`);
|
| 167 |
+
|
| 168 |
+
try {
|
| 169 |
+
browser = await playwright.chromium.connectOverCDP(CDP_ADDRESS, { timeout: 20000, ignoreHTTPSErrors: true });
|
| 170 |
+
console.log('✅ 成功连接到正在运行的 Chrome 实例!');
|
| 171 |
+
|
| 172 |
+
browser.once('disconnected', () => {
|
| 173 |
+
console.error('❌ Playwright 与 Chrome 的连接已断开!');
|
| 174 |
+
isPlaywrightReady = false;
|
| 175 |
+
browser = null;
|
| 176 |
+
page = null;
|
| 177 |
+
// v2.18: Clear queue on disconnect? Maybe not, let requests fail naturally.
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
await new Promise(resolve => setTimeout(resolve, 500));
|
| 181 |
+
|
| 182 |
+
const contexts = browser.contexts();
|
| 183 |
+
let context;
|
| 184 |
+
if (!contexts || contexts.length === 0) {
|
| 185 |
+
await new Promise(resolve => setTimeout(resolve, 1500));
|
| 186 |
+
const retryContexts = browser.contexts();
|
| 187 |
+
if (!retryContexts || retryContexts.length === 0) {
|
| 188 |
+
throw new Error('无法获取浏览器上下文。请检查 Chrome 是否已正确启动并响应。');
|
| 189 |
+
}
|
| 190 |
+
context = retryContexts[0];
|
| 191 |
+
} else {
|
| 192 |
+
context = contexts[0];
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
let foundPage = null;
|
| 196 |
+
const pages = context.pages();
|
| 197 |
+
console.log(`-> 发现 ${pages.length} 个页面。正在搜索 AI Studio (匹配 "${AI_STUDIO_URL_PATTERN}")...`);
|
| 198 |
+
for (const p of pages) {
|
| 199 |
+
try {
|
| 200 |
+
if (p.isClosed()) continue;
|
| 201 |
+
const url = p.url();
|
| 202 |
+
if (url.includes(AI_STUDIO_URL_PATTERN) && url.includes('/prompts/')) {
|
| 203 |
+
console.log(`-> 找到 AI Studio 页面: ${url}`);
|
| 204 |
+
foundPage = p;
|
| 205 |
+
break;
|
| 206 |
+
}
|
| 207 |
+
} catch (pageError) {
|
| 208 |
+
if (!p.isClosed()) {
|
| 209 |
+
console.warn(` 警告:评估页面 URL 时出错: ${pageError.message.split('\\n')[0]}`);
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
if (!foundPage) {
|
| 215 |
+
throw new Error(`未在已连接的 Chrome 中找到包含 "${AI_STUDIO_URL_PATTERN}" 和 "/prompts/" 的页面。请确保 auto_connect_aistudio.js 已成功运行,并且 AI Studio 页面 (例如 prompts/new_chat) 已打开。`);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
page = foundPage;
|
| 219 |
+
console.log('-> 已定位到 AI Studio 页面。');
|
| 220 |
+
await page.bringToFront();
|
| 221 |
+
console.log('-> 尝试将页面置于前台。检查加载状态...');
|
| 222 |
+
await page.waitForLoadState('domcontentloaded', { timeout: 15000 });
|
| 223 |
+
console.log('-> 页面 DOM 已加载。');
|
| 224 |
+
|
| 225 |
+
try {
|
| 226 |
+
console.log("-> 尝试定位核心输入区域以确��页面就绪...");
|
| 227 |
+
await page.locator('ms-prompt-input-wrapper').waitFor({ state: 'visible', timeout: 15000 });
|
| 228 |
+
console.log("-> 核心输入区域容器已找到。");
|
| 229 |
+
} catch(initCheckError) {
|
| 230 |
+
console.warn(`⚠️ 初始化检查警告:未能快速定位到核心输入区域容器。页面可能仍在加载或结构有变: ${initCheckError.message.split('\\n')[0]}`);
|
| 231 |
+
await saveErrorSnapshot('init_check_fail');
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
isPlaywrightReady = true;
|
| 235 |
+
console.log('✅ Playwright 已准备就绪。');
|
| 236 |
+
// v2.18: Start processing queue if playwright just became ready and queue has items
|
| 237 |
+
if (requestQueue.length > 0 && !isProcessing) {
|
| 238 |
+
console.log(`[Queue] Playwright 就绪,队列中有 ${requestQueue.length} 个请求,开始处理...`);
|
| 239 |
+
processQueue();
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
} catch (error) {
|
| 243 |
+
console.error(`❌ 初始化 Playwright 失败: ${error.message}`);
|
| 244 |
+
await saveErrorSnapshot('init_fail');
|
| 245 |
+
isPlaywrightReady = false;
|
| 246 |
+
browser = null;
|
| 247 |
+
page = null;
|
| 248 |
+
} finally {
|
| 249 |
+
isInitializing = false;
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
// --- 中间件 ---
|
| 254 |
+
app.use(cors());
|
| 255 |
+
app.use(express.json({ limit: '20mb' }));
|
| 256 |
+
app.use(express.urlencoded({ limit: '20mb', extended: true })); // Also for urlencoded
|
| 257 |
+
|
| 258 |
+
// --- Web UI Route ---
|
| 259 |
+
app.get('/', (req, res) => {
|
| 260 |
+
const htmlPath = path.join(__dirname, 'index.html');
|
| 261 |
+
if (fs.existsSync(htmlPath)) {
|
| 262 |
+
res.sendFile(htmlPath);
|
| 263 |
+
} else {
|
| 264 |
+
res.status(404).send('Error: index.html not found.');
|
| 265 |
+
}
|
| 266 |
+
});
|
| 267 |
+
|
| 268 |
+
// --- 健康检查 ---
|
| 269 |
+
app.get('/health', (req, res) => {
|
| 270 |
+
const isConnected = browser?.isConnected() ?? false;
|
| 271 |
+
const isPageValid = page && !page.isClosed();
|
| 272 |
+
const queueLength = requestQueue.length;
|
| 273 |
+
const status = {
|
| 274 |
+
status: 'Unknown',
|
| 275 |
+
message: '',
|
| 276 |
+
playwrightReady: isPlaywrightReady,
|
| 277 |
+
browserConnected: isConnected,
|
| 278 |
+
pageValid: isPageValid,
|
| 279 |
+
initializing: isInitializing,
|
| 280 |
+
processing: isProcessing,
|
| 281 |
+
queueLength: queueLength
|
| 282 |
+
};
|
| 283 |
+
|
| 284 |
+
if (isPlaywrightReady && isPageValid && isConnected) {
|
| 285 |
+
status.status = 'OK';
|
| 286 |
+
status.message = `Server running, Playwright connected, page valid. Currently ${isProcessing ? 'processing' : 'idle'} with ${queueLength} item(s) in queue.`;
|
| 287 |
+
res.status(200).json(status);
|
| 288 |
+
} else {
|
| 289 |
+
status.status = 'Error';
|
| 290 |
+
const reasons = [];
|
| 291 |
+
if (!isPlaywrightReady) reasons.push("Playwright not initialized or ready");
|
| 292 |
+
if (!isPageValid) reasons.push("Target page not found or closed");
|
| 293 |
+
if (!isConnected) reasons.push("Browser disconnected");
|
| 294 |
+
if (isInitializing) reasons.push("Playwright is currently initializing");
|
| 295 |
+
status.message = `Service Unavailable. Issues: ${reasons.join(', ')}. Currently ${isProcessing ? 'processing' : 'idle'} with ${queueLength} item(s) in queue.`;
|
| 296 |
+
res.status(503).json(status);
|
| 297 |
+
}
|
| 298 |
+
});
|
| 299 |
+
|
| 300 |
+
// --- 新增:API 辅助函数 ---
|
| 301 |
+
|
| 302 |
+
// 验证聊天请求
|
| 303 |
+
// v2.19: Updated validation to handle array content (text parts only)
|
| 304 |
+
function validateChatRequest(messages) {
|
| 305 |
+
const reqId = messages?.[0]?.reqId || 'validation'; // Get reqId if passed, fallback
|
| 306 |
+
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
| 307 |
+
throw new Error(`[${reqId}] Invalid request: "messages" array is missing or empty.`);
|
| 308 |
+
}
|
| 309 |
+
const lastUserMessage = messages.filter(msg => msg.role === 'user').pop();
|
| 310 |
+
if (!lastUserMessage) {
|
| 311 |
+
throw new Error(`[${reqId}] Invalid request: No user message found in the "messages" array.`);
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
let userPromptContentInput = lastUserMessage.content;
|
| 315 |
+
let processedUserPrompt = ""; // Initialize as empty string
|
| 316 |
+
|
| 317 |
+
// 1. Handle null/undefined content
|
| 318 |
+
if (userPromptContentInput === null || userPromptContentInput === undefined) {
|
| 319 |
+
console.warn(`[${reqId}] (Validation) Warning: Last user message content is null or undefined. Treating as empty string.`);
|
| 320 |
+
processedUserPrompt = "";
|
| 321 |
+
}
|
| 322 |
+
// 2. Handle string content (most common case)
|
| 323 |
+
else if (typeof userPromptContentInput === 'string') {
|
| 324 |
+
processedUserPrompt = userPromptContentInput;
|
| 325 |
+
}
|
| 326 |
+
// 3. Handle array content (attempt compatibility with OpenAI vision format)
|
| 327 |
+
else if (Array.isArray(userPromptContentInput)) {
|
| 328 |
+
console.log(`[${reqId}] (Validation) Info: Last user message content is an array. Processing text parts...`);
|
| 329 |
+
let textParts = [];
|
| 330 |
+
let unsupportedParts = false;
|
| 331 |
+
for (const item of userPromptContentInput) {
|
| 332 |
+
if (typeof item === 'object' && item !== null && item.type === 'text' && typeof item.text === 'string') {
|
| 333 |
+
textParts.push(item.text);
|
| 334 |
+
} else if (typeof item === 'object' && item !== null && item.type === 'image_url') {
|
| 335 |
+
console.warn(`[${reqId}] (Validation) Warning: Found 'image_url' content part. This proxy cannot process images via AI Studio web UI. Ignoring image.`);
|
| 336 |
+
unsupportedParts = true;
|
| 337 |
+
// Optionally, include the URL as text, but it might confuse the AI:
|
| 338 |
+
// textParts.push(`[Image URL (Unsupported): ${item.image_url?.url || 'N/A'}]`);
|
| 339 |
+
} else {
|
| 340 |
+
// Handle other unexpected items in the array - stringify them?
|
| 341 |
+
console.warn(`[${reqId}] (Validation) Warning: Found unexpected item in content array (Type: ${typeof item}). Converting to JSON string.`);
|
| 342 |
+
try {
|
| 343 |
+
textParts.push(JSON.stringify(item));
|
| 344 |
+
unsupportedParts = true;
|
| 345 |
+
} catch (e) {
|
| 346 |
+
console.error(`[${reqId}] (Validation) Error stringifying array item: ${e}. Skipping item.`);
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
processedUserPrompt = textParts.join('\\n'); // Join text parts with newline
|
| 351 |
+
if (unsupportedParts) {
|
| 352 |
+
console.warn(`[${reqId}] (Validation) Warning: Some parts of the array content were unsupported or ignored (e.g., images). Only text parts were included in the final prompt.`);
|
| 353 |
+
}
|
| 354 |
+
if (!processedUserPrompt) {
|
| 355 |
+
console.warn(`[${reqId}] (Validation) Warning: Processed array content resulted in an empty prompt.`);
|
| 356 |
+
}
|
| 357 |
+
}
|
| 358 |
+
// 4. Handle other object types (fallback to JSON stringify)
|
| 359 |
+
else if (typeof userPromptContentInput === 'object' && userPromptContentInput !== null) {
|
| 360 |
+
console.warn(`[${reqId}] (Validation) Warning: Last user message content is an object but not a recognized array format. Converting to JSON string.`);
|
| 361 |
+
try {
|
| 362 |
+
processedUserPrompt = JSON.stringify(userPromptContentInput);
|
| 363 |
+
} catch (stringifyError) {
|
| 364 |
+
console.error(`[${reqId}] (Validation) Error stringifying object user content: ${stringifyError}. Falling back to empty string.`);
|
| 365 |
+
processedUserPrompt = "";
|
| 366 |
+
}
|
| 367 |
+
}
|
| 368 |
+
// 5. Handle other primitive types (e.g., number, boolean) - convert to string
|
| 369 |
+
else {
|
| 370 |
+
console.warn(`[${reqId}] (Validation) Warning: Last user message content is an unexpected primitive type (${typeof userPromptContentInput}). Converting to string.`);
|
| 371 |
+
processedUserPrompt = String(userPromptContentInput);
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
// Final check - should always be a string here
|
| 375 |
+
if (typeof processedUserPrompt !== 'string') {
|
| 376 |
+
console.error(`[${reqId}] (Validation) CRITICAL ERROR: Failed to process user prompt content into a string. Type after processing: ${typeof processedUserPrompt}. Using empty string.`);
|
| 377 |
+
processedUserPrompt = ""; // Safeguard
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
// Extract system prompt (remains the same logic)
|
| 382 |
+
const systemPromptContent = messages.find(msg => msg.role === 'system')?.content;
|
| 383 |
+
// Basic validation for system prompt (ensure it's a string if provided)
|
| 384 |
+
let processedSystemPrompt = null;
|
| 385 |
+
if (systemPromptContent !== null && systemPromptContent !== undefined) {
|
| 386 |
+
if (typeof systemPromptContent === 'string') {
|
| 387 |
+
processedSystemPrompt = systemPromptContent;
|
| 388 |
+
} else {
|
| 389 |
+
console.warn(`[${reqId}] (Validation) Warning: System prompt content is not a string (Type: ${typeof systemPromptContent}). Ignoring system prompt.`);
|
| 390 |
+
// Optionally stringify it: processedSystemPrompt = JSON.stringify(systemPromptContent);
|
| 391 |
+
}
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
return {
|
| 396 |
+
userPrompt: processedUserPrompt, // Ensure this is always a string
|
| 397 |
+
systemPrompt: processedSystemPrompt // Ensure this is null or a string
|
| 398 |
+
};
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
// 与页面交互并提交 Prompt
|
| 402 |
+
async function interactAndSubmitPrompt(page, prompt, reqId) {
|
| 403 |
+
console.log(`[${reqId}] 开始页面交互...`);
|
| 404 |
+
const inputField = page.locator(INPUT_SELECTOR);
|
| 405 |
+
const submitButton = page.locator(SUBMIT_BUTTON_SELECTOR);
|
| 406 |
+
const loadingSpinner = page.locator(LOADING_SPINNER_SELECTOR); // Keep spinner locator here for later use
|
| 407 |
+
|
| 408 |
+
console.log(`[${reqId}] - 等待输入框可用...`);
|
| 409 |
+
try {
|
| 410 |
+
await inputField.waitFor({ state: 'visible', timeout: 10000 });
|
| 411 |
+
} catch (e) {
|
| 412 |
+
console.error(`[${reqId}] ❌ 查找输入框失败!`);
|
| 413 |
+
await saveErrorSnapshot(`input_field_not_visible_${reqId}`);
|
| 414 |
+
throw new Error(`[${reqId}] Failed to find visible input field. Error: ${e.message}`);
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
console.log(`[${reqId}] - 清空并填充输入框...`);
|
| 418 |
+
await inputField.fill(prompt, { timeout: 60000 });
|
| 419 |
+
|
| 420 |
+
console.log(`[${reqId}] - 等待运行按钮可用...`);
|
| 421 |
+
try {
|
| 422 |
+
await expect(submitButton).toBeEnabled({ timeout: 10000 });
|
| 423 |
+
} catch (e) {
|
| 424 |
+
console.error(`[${reqId}] ❌ 等待运行按钮变为可用状态超时!`);
|
| 425 |
+
await saveErrorSnapshot(`submit_button_not_enabled_before_click_${reqId}`);
|
| 426 |
+
throw new Error(`[${reqId}] Submit button not enabled before click. Error: ${e.message}`);
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
console.log(`[${reqId}] - 点击运行按钮...`);
|
| 430 |
+
await submitButton.click({ timeout: 10000 });
|
| 431 |
+
|
| 432 |
+
return { inputField, submitButton, loadingSpinner }; // Return locators
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
// 定位最新的回复元素
|
| 436 |
+
async function locateResponseElements(page, { inputField, submitButton, loadingSpinner }, reqId) {
|
| 437 |
+
console.log(`[${reqId}] 定位 AI 回复元素...`);
|
| 438 |
+
let lastResponseContainer;
|
| 439 |
+
let responseElement;
|
| 440 |
+
let locatedResponseElements = false;
|
| 441 |
+
|
| 442 |
+
for (let i = 0; i < 3 && !locatedResponseElements; i++) {
|
| 443 |
+
try {
|
| 444 |
+
console.log(`[${reqId}] 尝试定位最新回复容器及文本元素 (第 ${i + 1} 次)`);
|
| 445 |
+
await page.waitForTimeout(500 + i * 500); // 固有延迟
|
| 446 |
+
|
| 447 |
+
const isEndState = await checkEndConditionQuickly(page, loadingSpinner, inputField, submitButton, 250, reqId);
|
| 448 |
+
const locateTimeout = isEndState ? 3000 : 60000;
|
| 449 |
+
if (isEndState) {
|
| 450 |
+
console.log(`[${reqId}] -> 检测到结束条件已满足,使用 ${locateTimeout / 1000}s 超时进行定位。`);
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
lastResponseContainer = page.locator(RESPONSE_CONTAINER_SELECTOR).last();
|
| 454 |
+
await lastResponseContainer.waitFor({ state: 'attached', timeout: locateTimeout });
|
| 455 |
+
|
| 456 |
+
responseElement = lastResponseContainer.locator(RESPONSE_TEXT_SELECTOR);
|
| 457 |
+
await responseElement.waitFor({ state: 'attached', timeout: locateTimeout });
|
| 458 |
+
|
| 459 |
+
console.log(`[${reqId}] 回复容器和文本元素定位成功。`);
|
| 460 |
+
locatedResponseElements = true;
|
| 461 |
+
} catch (locateError) {
|
| 462 |
+
console.warn(`[${reqId}] 第 ${i + 1} 次定位回复元素失败: ${locateError.message.split('\n')[0]}`);
|
| 463 |
+
if (i === 2) {
|
| 464 |
+
await saveErrorSnapshot(`response_locate_fail_${reqId}`);
|
| 465 |
+
throw new Error(`[${reqId}] Failed to locate response elements after multiple attempts.`);
|
| 466 |
+
}
|
| 467 |
+
}
|
| 468 |
+
}
|
| 469 |
+
if (!locatedResponseElements) throw new Error(`[${reqId}] Could not locate response elements.`);
|
| 470 |
+
return { responseElement, lastResponseContainer }; // Return located elements
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
// --- 新增:处理流式响应 (vNEXT: 标记优先,静默结束,无JSON处理) ---
|
| 474 |
+
async function handleStreamingResponse(res, responseElement, page, { inputField, submitButton, loadingSpinner }, operationTimer, reqId, isRequestCancelled) {
|
| 475 |
+
console.log(`[${reqId}] - 流式传输开始 (vNEXT: Marker priority, silence end, no JSON handling)...`); // TODO: Update version
|
| 476 |
+
let lastRawText = "";
|
| 477 |
+
let lastSentResponseContent = ""; // Tracks content *after* the marker that has been SENT
|
| 478 |
+
let responseStarted = false; // Tracks if <<<START_RESPONSE>>> has been seen
|
| 479 |
+
const startTime = Date.now();
|
| 480 |
+
let spinnerHasDisappeared = false;
|
| 481 |
+
let lastTextChangeTimestamp = Date.now();
|
| 482 |
+
const startMarker = '<<<START_RESPONSE>>>';
|
| 483 |
+
let streamFinishedNaturally = false;
|
| 484 |
+
|
| 485 |
+
while (Date.now() - startTime < RESPONSE_COMPLETION_TIMEOUT && !streamFinishedNaturally) {
|
| 486 |
+
// --- 添加检查:请求是否已取消 ---
|
| 487 |
+
const cancelled = isRequestCancelled(); // 调用检查函数
|
| 488 |
+
// 添加日志记录检查结果
|
| 489 |
+
// console.log(`[${reqId}] (Streaming Loop Check) isRequestCancelled() returned: ${cancelled}`); // 可选:过于频繁,暂时注释掉
|
| 490 |
+
if (cancelled) {
|
| 491 |
+
console.log(`[${reqId}] (Streaming) 检测到请求已取消 (isRequestCancelled() is true),停止处理。`); // 修改日志
|
| 492 |
+
clearTimeout(operationTimer); // 确保定时器清除
|
| 493 |
+
if (!res.writableEnded) res.end(); // 确保响应结束
|
| 494 |
+
return; // 退出函数
|
| 495 |
+
}
|
| 496 |
+
// --- 结束检查 ---
|
| 497 |
+
|
| 498 |
+
const loopStartTime = Date.now();
|
| 499 |
+
|
| 500 |
+
// 1. Get current raw text
|
| 501 |
+
const currentRawText = await getRawTextContent(responseElement, lastRawText, reqId);
|
| 502 |
+
|
| 503 |
+
if (currentRawText !== lastRawText) {
|
| 504 |
+
lastTextChangeTimestamp = Date.now();
|
| 505 |
+
let potentialNewDelta = "";
|
| 506 |
+
let currentContentAfterMarker = "";
|
| 507 |
+
|
| 508 |
+
// 2. Marker Check & Delta Calculation
|
| 509 |
+
const markerIndex = currentRawText.indexOf(startMarker);
|
| 510 |
+
if (markerIndex !== -1) {
|
| 511 |
+
if (!responseStarted) {
|
| 512 |
+
console.log(`[${reqId}] (流式 Simple) 检测到 ${startMarker},开始传输...`);
|
| 513 |
+
responseStarted = true;
|
| 514 |
+
}
|
| 515 |
+
// Content after marker in the current raw text
|
| 516 |
+
currentContentAfterMarker = currentRawText.substring(markerIndex + startMarker.length);
|
| 517 |
+
// Calculate new content since last *sent* content
|
| 518 |
+
potentialNewDelta = currentContentAfterMarker.substring(lastSentResponseContent.length);
|
| 519 |
+
} else if(responseStarted) {
|
| 520 |
+
// If marker was seen before, but now disappears (e.g., AI cleared output?), treat as no new delta.
|
| 521 |
+
potentialNewDelta = "";
|
| 522 |
+
console.warn(`[${reqId}] Marker disappeared after being seen. Raw: ${currentRawText.substring(0,100)}`);
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
// 3. Send Delta if found
|
| 526 |
+
if (potentialNewDelta) {
|
| 527 |
+
// console.log(`[${reqId}] (Send Stream Simple) Sending Delta (len: ${potentialNewDelta.length})`);
|
| 528 |
+
sendStreamChunk(res, potentialNewDelta, reqId);
|
| 529 |
+
lastSentResponseContent += potentialNewDelta; // Update tracking
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
// Update last raw text
|
| 533 |
+
lastRawText = currentRawText;
|
| 534 |
+
|
| 535 |
+
} // End if(currentRawText !== lastRawText)
|
| 536 |
+
|
| 537 |
+
// 4. Check Spinner status
|
| 538 |
+
if (!spinnerHasDisappeared) {
|
| 539 |
+
try {
|
| 540 |
+
await expect(loadingSpinner).toBeHidden({ timeout: 50 });
|
| 541 |
+
spinnerHasDisappeared = true;
|
| 542 |
+
lastTextChangeTimestamp = Date.now(); // Reset silence timer when spinner disappears
|
| 543 |
+
console.log(`[${reqId}] Spinner 已消失,进入静默期检测...`);
|
| 544 |
+
} catch (e) { /* Spinner still visible */ }
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
// 5. Silence Check (Standard)
|
| 548 |
+
const isSilent = spinnerHasDisappeared && (Date.now() - lastTextChangeTimestamp > SILENCE_TIMEOUT_MS);
|
| 549 |
+
|
| 550 |
+
if (isSilent) {
|
| 551 |
+
console.log(`[${reqId}] Silence detected. Finishing stream.`);
|
| 552 |
+
streamFinishedNaturally = true;
|
| 553 |
+
break; // Exit loop
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
// 6. Control polling interval
|
| 557 |
+
const loopEndTime = Date.now();
|
| 558 |
+
const loopDuration = loopEndTime - loopStartTime;
|
| 559 |
+
const waitTime = Math.max(0, POLLING_INTERVAL_STREAM - loopDuration);
|
| 560 |
+
await page.waitForTimeout(waitTime);
|
| 561 |
+
|
| 562 |
+
} // --- End main loop ---
|
| 563 |
+
|
| 564 |
+
// --- Cleanup and End --- (如果循环是因取消而退出,下面的代码不会执行)
|
| 565 |
+
clearTimeout(operationTimer); // Clear the specific timer for THIS request
|
| 566 |
+
|
| 567 |
+
if (!streamFinishedNaturally && Date.now() - startTime >= RESPONSE_COMPLETION_TIMEOUT) {
|
| 568 |
+
// Timeout case
|
| 569 |
+
console.warn(`[${reqId}] - 流式传输(Simple模式)因总超时 (${RESPONSE_COMPLETION_TIMEOUT / 1000}s) 结束。`);
|
| 570 |
+
await saveErrorSnapshot(`streaming_simple_timeout_${reqId}`);
|
| 571 |
+
if (!res.writableEnded) {
|
| 572 |
+
sendStreamError(res, "Stream processing timed out on server (Simple mode).", reqId);
|
| 573 |
+
}
|
| 574 |
+
} else if (streamFinishedNaturally && !res.writableEnded) {
|
| 575 |
+
// Natural end (Silence detected)
|
| 576 |
+
// --- Final Sync (Simple Mode) ---
|
| 577 |
+
// Check one last time for any content received after the last delta was sent but before silence was declared.
|
| 578 |
+
console.log(`[${reqId}] (Simple Stream) Loop ended naturally, performing final sync check...`);
|
| 579 |
+
const finalRawText = await getRawTextContent(responseElement, lastRawText, reqId);
|
| 580 |
+
console.log(`[${reqId}] (Simple Stream) Performing final marker check and delta calculation...`);
|
| 581 |
+
try {
|
| 582 |
+
let finalExtractedContent = ""; // Content after marker
|
| 583 |
+
const finalMarkerIndex = finalRawText.indexOf(startMarker);
|
| 584 |
+
if (finalMarkerIndex !== -1) {
|
| 585 |
+
finalExtractedContent = finalRawText.substring(finalMarkerIndex + startMarker.length);
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
const finalDelta = finalExtractedContent.substring(lastSentResponseContent.length);
|
| 589 |
+
|
| 590 |
+
if (finalDelta){
|
| 591 |
+
console.log(`[${reqId}] (Final Sync Simple) Sending final delta (len: ${finalDelta.length})`);
|
| 592 |
+
sendStreamChunk(res, finalDelta, reqId);
|
| 593 |
+
} else {
|
| 594 |
+
console.log(`[${reqId}] (Final Sync Simple) No final delta to send based on lastSent comparison.`);
|
| 595 |
+
}
|
| 596 |
+
} catch (e) { console.warn(`[${reqId}] (Simple Stream) Final sync error during marker/delta calc: ${e.message}`); }
|
| 597 |
+
// --- End Final Sync ---
|
| 598 |
+
|
| 599 |
+
res.write('data: [DONE]\n\n');
|
| 600 |
+
res.end();
|
| 601 |
+
console.log(`[${reqId}] ✅ 流式(Simple模式)响应 [DONE] 已发送。`);
|
| 602 |
+
} else if (res.writableEnded) {
|
| 603 |
+
console.log(`[${reqId}] 流(Simple模式)已提前结束 (writableEnded=true),不再发送 [DONE]。`);
|
| 604 |
+
} else {
|
| 605 |
+
console.log(`[${reqId}] 流(Simple模式)结束时状态异常 (finishedNaturally=${streamFinishedNaturally}, writableEnded=${res.writableEnded}),不再发送 [DONE]。`);
|
| 606 |
+
}
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
// --- 新增:处理非流式响应 --- vNEXT: Restore JSON Parsing
|
| 610 |
+
async function handleNonStreamingResponse(res, page, locators, operationTimer, reqId, isRequestCancelled) {
|
| 611 |
+
console.log(`[${reqId}] - 等待 AI 处理完成 (检查 Spinner 消失 + 输入框空 + 按钮禁用)...`);
|
| 612 |
+
let processComplete = false;
|
| 613 |
+
const nonStreamStartTime = Date.now();
|
| 614 |
+
let finalStateCheckInitiated = false;
|
| 615 |
+
const { inputField, submitButton, loadingSpinner } = locators;
|
| 616 |
+
|
| 617 |
+
// Completion check logic
|
| 618 |
+
while (!processComplete && Date.now() - nonStreamStartTime < RESPONSE_COMPLETION_TIMEOUT) {
|
| 619 |
+
// --- 添加检查:请求是否已取消 ---
|
| 620 |
+
if (isRequestCancelled()) {
|
| 621 |
+
console.log(`[${reqId}] (Non-Streaming) 检测到请求已取消,停止等待完成状态。`);
|
| 622 |
+
clearTimeout(operationTimer); // 确保定时器清除
|
| 623 |
+
if (!res.headersSent) {
|
| 624 |
+
// 如果头还没发送,可以发送一个取消错误
|
| 625 |
+
res.status(499).json({ error: { message: `[${reqId}] Client closed request`, type: 'client_error' } });
|
| 626 |
+
} else if (!res.writableEnded) {
|
| 627 |
+
res.end(); // 否则只结束响应
|
| 628 |
+
}
|
| 629 |
+
return; // 退出函数
|
| 630 |
+
}
|
| 631 |
+
// --- 结束检查 ---
|
| 632 |
+
|
| 633 |
+
let isSpinnerHidden = false;
|
| 634 |
+
let isInputEmpty = false;
|
| 635 |
+
let isButtonDisabled = false;
|
| 636 |
+
|
| 637 |
+
try {
|
| 638 |
+
await expect(loadingSpinner).toBeHidden({ timeout: SPINNER_CHECK_TIMEOUT_MS });
|
| 639 |
+
isSpinnerHidden = true;
|
| 640 |
+
} catch { /* Spinner still visible */ }
|
| 641 |
+
|
| 642 |
+
if (isSpinnerHidden) {
|
| 643 |
+
try {
|
| 644 |
+
await expect(inputField).toHaveValue('', { timeout: FINAL_STATE_CHECK_TIMEOUT_MS });
|
| 645 |
+
isInputEmpty = true;
|
| 646 |
+
} catch { /* Input not empty */ }
|
| 647 |
+
|
| 648 |
+
if (isInputEmpty) {
|
| 649 |
+
try {
|
| 650 |
+
await expect(submitButton).toBeDisabled({ timeout: FINAL_STATE_CHECK_TIMEOUT_MS });
|
| 651 |
+
isButtonDisabled = true;
|
| 652 |
+
} catch { /* Button not disabled */ }
|
| 653 |
+
}
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
if (isSpinnerHidden && isInputEmpty && isButtonDisabled) {
|
| 657 |
+
if (!finalStateCheckInitiated) {
|
| 658 |
+
finalStateCheckInitiated = true;
|
| 659 |
+
console.log(`[${reqId}] 检测到潜在最终状态。等待 ${POST_COMPLETION_BUFFER}ms 进行确认...`); // Use constant
|
| 660 |
+
await page.waitForTimeout(POST_COMPLETION_BUFFER); // Wait a bit first
|
| 661 |
+
console.log(`[${reqId}] ${POST_COMPLETION_BUFFER}ms 等待结束,重新检查状态...`);
|
| 662 |
+
try {
|
| 663 |
+
await expect(loadingSpinner).toBeHidden({ timeout: 500 });
|
| 664 |
+
await expect(inputField).toHaveValue('', { timeout: 500 });
|
| 665 |
+
await expect(submitButton).toBeDisabled({ timeout: 500 });
|
| 666 |
+
console.log(`[${reqId}] 状态确认成功。开始文本静默检查...`);
|
| 667 |
+
|
| 668 |
+
// --- NEW: Text Silence Check ---
|
| 669 |
+
let lastCheckText = '';
|
| 670 |
+
let currentCheckText = '';
|
| 671 |
+
let textStable = false;
|
| 672 |
+
const silenceCheckStartTime = Date.now();
|
| 673 |
+
// Re-locate response element here for the check
|
| 674 |
+
const { responseElement: checkResponseElement } = await locateResponseElements(page, locators, reqId);
|
| 675 |
+
|
| 676 |
+
while (Date.now() - silenceCheckStartTime < SILENCE_TIMEOUT_MS * 2) { // Check for up to 2*silence duration
|
| 677 |
+
lastCheckText = currentCheckText;
|
| 678 |
+
currentCheckText = await getRawTextContent(checkResponseElement, lastCheckText, reqId);
|
| 679 |
+
if (currentCheckText === lastCheckText) {
|
| 680 |
+
// Text hasn't changed since last check in this loop
|
| 681 |
+
if (Date.now() - silenceCheckStartTime >= SILENCE_TIMEOUT_MS) {
|
| 682 |
+
// And enough time has passed
|
| 683 |
+
console.log(`[${reqId}] 文本内容静默 ${SILENCE_TIMEOUT_MS}ms,确认处理完成。`);
|
| 684 |
+
textStable = true;
|
| 685 |
+
break;
|
| 686 |
+
}
|
| 687 |
+
} else {
|
| 688 |
+
// Text changed, reset silence timer within this check
|
| 689 |
+
// silenceCheckStartTime = Date.now(); // Option: Reset timer on any change
|
| 690 |
+
console.log(`[${reqId}] (静默检查) 文本仍在变化...`);
|
| 691 |
+
}
|
| 692 |
+
await page.waitForTimeout(POLLING_INTERVAL); // Use standard poll interval for checks
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
if (textStable) {
|
| 696 |
+
processComplete = true; // Mark process as complete
|
| 697 |
+
} else {
|
| 698 |
+
console.warn(`[${reqId}] 警告: 文本静默检查超时,可能仍在输出。将继续尝试解析。`);
|
| 699 |
+
processComplete = true; // Proceed anyway after timeout, but log warning
|
| 700 |
+
}
|
| 701 |
+
// --- END NEW: Text Silence Check ---
|
| 702 |
+
|
| 703 |
+
} catch (recheckError) {
|
| 704 |
+
console.log(`[${reqId}] 状态在确认期间发生变化 (${recheckError.message.split('\\n')[0]})。继续轮询...`);
|
| 705 |
+
finalStateCheckInitiated = false;
|
| 706 |
+
}
|
| 707 |
+
}
|
| 708 |
+
} else {
|
| 709 |
+
if (finalStateCheckInitiated) {
|
| 710 |
+
console.log(`[${reqId}] 最终状态不再满足,重置确认标志。`);
|
| 711 |
+
finalStateCheckInitiated = false;
|
| 712 |
+
}
|
| 713 |
+
await page.waitForTimeout(POLLING_INTERVAL * 2); // Longer wait if not in final state check
|
| 714 |
+
}
|
| 715 |
+
} // --- End Completion check logic loop ---
|
| 716 |
+
|
| 717 |
+
// --- 添加检查:如果在循环结束后发现请求已取消 ---
|
| 718 |
+
if (isRequestCancelled()) {
|
| 719 |
+
console.log(`[${reqId}] (Non-Streaming) 请求在等待完成后被取消,不再继续处理。`);
|
| 720 |
+
// 定时器和响应应该已经被上面的检查处理了,这里只退出
|
| 721 |
+
return;
|
| 722 |
+
}
|
| 723 |
+
// --- 结束检查 ---
|
| 724 |
+
|
| 725 |
+
// Check for Page Errors BEFORE attempting to parse JSON
|
| 726 |
+
console.log(`[${reqId}] - 检查页面上是否存在错误提示...`);
|
| 727 |
+
const pageError = await detectAndExtractPageError(page, reqId);
|
| 728 |
+
if (pageError) {
|
| 729 |
+
console.error(`[${reqId}] ❌ 检测到 AI Studio 页面错误: ${pageError}`);
|
| 730 |
+
await saveErrorSnapshot(`page_error_detected_${reqId}`);
|
| 731 |
+
throw new Error(`[${reqId}] AI Studio Error: ${pageError}`);
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
if (!processComplete) {
|
| 735 |
+
console.warn(`[${reqId}] 警告:等待最终完成状态超时或未能稳定确认 (${(Date.now() - nonStreamStartTime) / 1000}s)。将直接尝试获取并解析JSON。`);
|
| 736 |
+
await saveErrorSnapshot(`nonstream_final_state_timeout_${reqId}`);
|
| 737 |
+
} else {
|
| 738 |
+
console.log(`[${reqId}] - 开始获取并解析最终 JSON...`);
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
// Get and Parse JSON
|
| 742 |
+
let aiResponseText = null;
|
| 743 |
+
const maxRetries = 3;
|
| 744 |
+
let attempts = 0;
|
| 745 |
+
|
| 746 |
+
while (attempts < maxRetries && aiResponseText === null) {
|
| 747 |
+
attempts++;
|
| 748 |
+
console.log(`[${reqId}] - 尝试获取原始文本并解析 JSON (第 ${attempts} 次)...`);
|
| 749 |
+
try {
|
| 750 |
+
// Re-locate response element within the retry loop for robustness
|
| 751 |
+
const { responseElement: currentResponseElement } = await locateResponseElements(page, locators, reqId);
|
| 752 |
+
|
| 753 |
+
const rawText = await getRawTextContent(currentResponseElement, '', reqId);
|
| 754 |
+
|
| 755 |
+
if (!rawText || rawText.trim() === '') {
|
| 756 |
+
console.warn(`[${reqId}] - 第 ${attempts} 次获取的原始文本为空。`);
|
| 757 |
+
throw new Error("Raw text content is empty.");
|
| 758 |
+
}
|
| 759 |
+
console.log(`[${reqId}] - 获取到原始文本 (长度: ${rawText.length}): \"${rawText.substring(0,100)}...\"`);
|
| 760 |
+
|
| 761 |
+
const parsedJson = tryParseJson(rawText, reqId);
|
| 762 |
+
|
| 763 |
+
if (parsedJson) {
|
| 764 |
+
if (typeof parsedJson.response === 'string') {
|
| 765 |
+
aiResponseText = parsedJson.response;
|
| 766 |
+
console.log(`[${reqId}] - 成功解析 JSON 并提取 'response' 字段。`);
|
| 767 |
+
} else {
|
| 768 |
+
// JSON 有效但无 response 字段
|
| 769 |
+
try {
|
| 770 |
+
aiResponseText = JSON.stringify(parsedJson);
|
| 771 |
+
console.log(`[${reqId}] - 警告: 未找到 'response' 字段,但解析到有效 JSON。将整个 JSON 字符串化作为回复。`);
|
| 772 |
+
} catch (stringifyError) {
|
| 773 |
+
console.error(`[${reqId}] - 错误:无法将解析出的 JSON 字符串化: ${stringifyError.message}`);
|
| 774 |
+
aiResponseText = null;
|
| 775 |
+
throw new Error("Failed to stringify the parsed JSON object.");
|
| 776 |
+
}
|
| 777 |
+
}
|
| 778 |
+
} else {
|
| 779 |
+
// JSON 解析失败
|
| 780 |
+
console.warn(`[${reqId}] - 第 ${attempts} 次未能解析 JSON。`);
|
| 781 |
+
aiResponseText = null;
|
| 782 |
+
if (attempts >= maxRetries) {
|
| 783 |
+
await saveErrorSnapshot(`json_parse_fail_final_attempt_${reqId}`);
|
| 784 |
+
}
|
| 785 |
+
throw new Error("Failed to parse JSON from raw text.");
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
break;
|
| 789 |
+
|
| 790 |
+
} catch (e) {
|
| 791 |
+
console.warn(`[${reqId}] - 第 ${attempts} 次获取或解析失败: ${e.message.split('\n')[0]}`);
|
| 792 |
+
aiResponseText = null;
|
| 793 |
+
if (attempts >= maxRetries) {
|
| 794 |
+
console.error(`[${reqId}] - 多次尝试获取并解析 JSON 失败。`);
|
| 795 |
+
if (!e.message?.includes('snapshot')) await saveErrorSnapshot(`get_parse_json_failed_final_${reqId}`);
|
| 796 |
+
aiResponseText = ""; // Fallback to empty string
|
| 797 |
+
} else {
|
| 798 |
+
await new Promise(resolve => setTimeout(resolve, 1500 + attempts * 500));
|
| 799 |
+
}
|
| 800 |
+
}
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
if (aiResponseText === null) {
|
| 804 |
+
console.log(`[${reqId}] - JSON 解析失败,再次检查页面错误...`);
|
| 805 |
+
const finalCheckError = await detectAndExtractPageError(page, reqId);
|
| 806 |
+
if (finalCheckError) {
|
| 807 |
+
console.error(`[${reqId}] ❌ 检测到 AI Studio 页面错误 (在 JSON 解析失败后): ${finalCheckError}`);
|
| 808 |
+
await saveErrorSnapshot(`page_error_post_json_fail_${reqId}`);
|
| 809 |
+
throw new Error(`[${reqId}] AI Studio Error after JSON parse failed: ${finalCheckError}`);
|
| 810 |
+
}
|
| 811 |
+
console.warn(`[${reqId}] 警告:所有尝试均未能获取并解析出有效的 JSON 回复。返回空回复。`);
|
| 812 |
+
aiResponseText = "";
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
// Handle potential nested JSON
|
| 816 |
+
let cleanedResponse = aiResponseText;
|
| 817 |
+
try {
|
| 818 |
+
// Attempt to parse the potential stringified JSON again for nested 'response' check
|
| 819 |
+
// Only attempt if aiResponseText is likely a stringified JSON object/array
|
| 820 |
+
if (aiResponseText && aiResponseText.startsWith('{') || aiResponseText.startsWith('[')) {
|
| 821 |
+
const outerParsed = JSON.parse(aiResponseText); // Use JSON.parse directly here
|
| 822 |
+
const innerParsed = tryParseJson(outerParsed.response, reqId); // Try parsing the inner 'response' field if it exists
|
| 823 |
+
if (innerParsed && typeof innerParsed.response === 'string') {
|
| 824 |
+
console.log(`[${reqId}] (非流式) 检测到嵌套 JSON,使用内层 response 内容。`);
|
| 825 |
+
cleanedResponse = innerParsed.response;
|
| 826 |
+
} else if (typeof outerParsed.response === 'string') {
|
| 827 |
+
// If the *outer* 'response' was already a string (not nested JSON), use it directly
|
| 828 |
+
console.log(`[${reqId}] (非流式) 使用外层 'response' 字段内容。`);
|
| 829 |
+
cleanedResponse = outerParsed.response;
|
| 830 |
+
}
|
| 831 |
+
// If neither inner nor outer 'response' fields are relevant strings, keep the stringified JSON as cleanedResponse
|
| 832 |
+
}
|
| 833 |
+
} catch (e) {
|
| 834 |
+
// If parsing aiResponseText fails, it means it wasn't a stringified JSON in the first place,
|
| 835 |
+
// or it was malformed. Keep the original aiResponseText.
|
| 836 |
+
// console.warn(`[${reqId}] (Info) Post-processing check: aiResponseText ('${aiResponseText.substring(0,50)}...') is not a parseable JSON or lacks 'response'. Keeping original value. Error: ${e.message}`);
|
| 837 |
+
cleanedResponse = aiResponseText; // Keep original if parsing fails
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
console.log(`[${reqId}] ✅ 获取到解析后的 AI 回复 (来自JSON, 长度: ${cleanedResponse?.length ?? 0}): \"${cleanedResponse?.substring(0, 100)}...\"`);
|
| 841 |
+
|
| 842 |
+
// --- 新增步骤:在非流式响应中移除标记 ---
|
| 843 |
+
const startMarker = '<<<START_RESPONSE>>>';
|
| 844 |
+
|
| 845 |
+
let finalContentForUser = cleanedResponse; // 默认使用清理后的响应
|
| 846 |
+
|
| 847 |
+
// Check for and remove the starting marker if present
|
| 848 |
+
if (finalContentForUser?.startsWith(startMarker)) {
|
| 849 |
+
finalContentForUser = finalContentForUser.substring(startMarker.length);
|
| 850 |
+
console.log(`[${reqId}] (非流式 JSON) 移除前缀 ${startMarker},最终内容长度: ${finalContentForUser.length}`);
|
| 851 |
+
} else if (aiResponseText !== null && aiResponseText !== "") { // 仅在获取到非空文本但无标记时警告
|
| 852 |
+
console.warn(`[${reqId}] (非流式 JSON) 警告: 未在 response 字段中找到预期的 ${startMarker} 前缀。内容: \"${aiResponseText.substring(0,50)}...\"`);
|
| 853 |
+
}
|
| 854 |
+
// --- 结束新增步骤 ---
|
| 855 |
+
|
| 856 |
+
|
| 857 |
+
// 使用移除标记后的内容构建最终响应
|
| 858 |
+
const responsePayload = {
|
| 859 |
+
id: `${CHAT_COMPLETION_ID_PREFIX}${Date.now()}-${Math.random().toString(36).substring(2, 15)}`,
|
| 860 |
+
object: 'chat.completion',
|
| 861 |
+
created: Math.floor(Date.now() / 1000),
|
| 862 |
+
model: MODEL_NAME,
|
| 863 |
+
choices: [{
|
| 864 |
+
index: 0,
|
| 865 |
+
message: { role: 'assistant', content: finalContentForUser }, // Use cleaned content
|
| 866 |
+
finish_reason: 'stop',
|
| 867 |
+
}],
|
| 868 |
+
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
| 869 |
+
};
|
| 870 |
+
console.log(`[${reqId}] ✅ 返回 JSON 响应 (来自解析后的JSON)。`);
|
| 871 |
+
clearTimeout(operationTimer); // Clear the specific timer for THIS request
|
| 872 |
+
res.json(responsePayload);
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
// --- 新增:处理 /v1/models 请求以满足 Open WebUI 验证 ---
|
| 876 |
+
app.get('/v1/models', (req, res) => {
|
| 877 |
+
const modelId = 'aistudio-proxy'; // 您计划在 Open WebUI 中使用的模型名称
|
| 878 |
+
// 使用简短的日志ID或时间戳
|
| 879 |
+
const logPrefix = `[${Date.now().toString(36).slice(-5)}]`;
|
| 880 |
+
console.log(`${logPrefix} --- 收到 /v1/models 请求,返回模拟模型列表 ---`);
|
| 881 |
+
res.json({
|
| 882 |
+
object: "list",
|
| 883 |
+
data: [
|
| 884 |
+
{
|
| 885 |
+
id: modelId, // 返回您要用的那个名字
|
| 886 |
+
object: "model",
|
| 887 |
+
created: Math.floor(Date.now() / 1000),
|
| 888 |
+
owned_by: "openai-proxy", // 可以随便写
|
| 889 |
+
permission: [],
|
| 890 |
+
root: modelId,
|
| 891 |
+
parent: null
|
| 892 |
+
}
|
| 893 |
+
// 如果需要添加更多名称指向同一个代理,可以在此添加
|
| 894 |
+
// ,{
|
| 895 |
+
// id: "gemini-pro-proxy",
|
| 896 |
+
// object: "model",
|
| 897 |
+
// created: Math.floor(Date.now() / 1000),
|
| 898 |
+
// owned_by: "openai-proxy",
|
| 899 |
+
// permission: [],
|
| 900 |
+
// root: "gemini-pro-proxy",
|
| 901 |
+
// parent: null
|
| 902 |
+
// }
|
| 903 |
+
]
|
| 904 |
+
});
|
| 905 |
+
});
|
| 906 |
+
|
| 907 |
+
|
| 908 |
+
// --- v2.18: 新增队列处理函数 ---
|
| 909 |
+
async function processQueue() {
|
| 910 |
+
if (isProcessing || requestQueue.length === 0) {
|
| 911 |
+
return;
|
| 912 |
+
}
|
| 913 |
+
|
| 914 |
+
isProcessing = true;
|
| 915 |
+
// 从队列头部取出包含状态的请求项
|
| 916 |
+
const queueItem = requestQueue.shift();
|
| 917 |
+
// 解构所需变量,包括取消标记和临时处理器
|
| 918 |
+
const { req, res, reqId, isCancelledByClient, preliminaryCloseHandler } = queueItem;
|
| 919 |
+
|
| 920 |
+
// --- 重要:立即移除临时监听器(如果存在且未被触发移除)---
|
| 921 |
+
// 因为我们要么跳过处理,要么添加新的主监听器
|
| 922 |
+
if (preliminaryCloseHandler) {
|
| 923 |
+
// 使用 removeListener 以防万一它已被触发并自我移除
|
| 924 |
+
res.removeListener('close', preliminaryCloseHandler);
|
| 925 |
+
}
|
| 926 |
+
// --- 结束移除临时监听器 ---
|
| 927 |
+
|
| 928 |
+
// --- 新增:检查请求是否在处理前已被取消 ---
|
| 929 |
+
if (isCancelledByClient) {
|
| 930 |
+
console.log(`[${reqId}] Request was cancelled by client before processing began. Skipping.`);
|
| 931 |
+
// 清理可能由其他地方(如主 close 事件处理器)设置的定时器,以防万一
|
| 932 |
+
if (operationTimer) clearTimeout(operationTimer);
|
| 933 |
+
// 标记处理结束(跳过),然后处理下一个
|
| 934 |
+
isProcessing = false;
|
| 935 |
+
processQueue(); // 尝试处理下一个请求
|
| 936 |
+
return; // 退出当前 processQueue 调用
|
| 937 |
+
}
|
| 938 |
+
// --- 结束新增检查 ---
|
| 939 |
+
|
| 940 |
+
console.log(`\n[${reqId}] ---开始处理队列中的请求 (剩余 ${requestQueue.length} 个)---`);
|
| 941 |
+
|
| 942 |
+
let operationTimer; // 主操作定时器
|
| 943 |
+
// *** 修改:将 isCancelledByClient 的状态传递给处理期间的 isCancelled 标志 ***
|
| 944 |
+
let isCancelled = isCancelledByClient;
|
| 945 |
+
// 如果在开始处理时就已经被取消,添加一条日志
|
| 946 |
+
if (isCancelled) {
|
| 947 |
+
console.log(`[${reqId}] Warning: Request was cancelled very shortly before processing logic started.`);
|
| 948 |
+
// 虽然上面的检查理论上会处理,但这里多一层保险
|
| 949 |
+
}
|
| 950 |
+
// *** 结束修改 ***
|
| 951 |
+
let closeEventHandler = null; // 主 close 事件处理器引用
|
| 952 |
+
|
| 953 |
+
try {
|
| 954 |
+
// 1. 检查 Playwright 状态 (现在可以安全地继续,因为请求未被提前取消)
|
| 955 |
+
// *** 新增:如果此时 isCancelled 已经是 true,则直接跳到 finally ***
|
| 956 |
+
if (isCancelled) {
|
| 957 |
+
console.log(`[${reqId}] Skipping Playwright interaction as request is already marked cancelled.`);
|
| 958 |
+
throw new Error(`[${reqId}] Request pre-cancelled`); // 抛出错误以跳到 catch/finally
|
| 959 |
+
}
|
| 960 |
+
// *** 结束新增检查 ***
|
| 961 |
+
|
| 962 |
+
if (!isPlaywrightReady && !isInitializing) {
|
| 963 |
+
console.warn(`[${reqId}] Playwright 未就绪,尝试重新初始化...`);
|
| 964 |
+
await initializePlaywright();
|
| 965 |
+
}
|
| 966 |
+
if (!isPlaywrightReady || !page || page.isClosed() || !browser?.isConnected()) {
|
| 967 |
+
console.error(`[${reqId}] API 请求失败:Playwright 未就绪、页面关闭或连接断开。`);
|
| 968 |
+
let detail = 'Unknown issue.';
|
| 969 |
+
if (!browser?.isConnected()) detail = "Browser connection lost.";
|
| 970 |
+
else if (!page || page.isClosed()) detail = "Target AI Studio page is not available or closed.";
|
| 971 |
+
else if (!isPlaywrightReady) detail = "Playwright initialization failed or incomplete.";
|
| 972 |
+
console.error(`[${reqId}] Playwright 连接不可用详情: ${detail}`);
|
| 973 |
+
// 直接为当前请求返回错误,不需要抛出,因为要继续处理队列
|
| 974 |
+
if (!res.headersSent) {
|
| 975 |
+
res.status(503).json({
|
| 976 |
+
error: { message: `[${reqId}] Playwright connection is not active. ${detail} Please ensure Chrome is running correctly, the AI Studio tab is open, and potentially restart the server.`, type: 'server_error' }
|
| 977 |
+
});
|
| 978 |
+
}
|
| 979 |
+
throw new Error("Playwright not ready for this request."); // Throw to skip further processing in try block
|
| 980 |
+
}
|
| 981 |
+
|
| 982 |
+
const { messages, stream, ...otherParams } = req.body;
|
| 983 |
+
const isStreaming = stream === true;
|
| 984 |
+
|
| 985 |
+
// --- 修改:基于消息数量启发式判断并执行清空操作 + 验证 ---
|
| 986 |
+
const isLikelyNewChat = Array.isArray(messages) && (messages.length === 1 || (messages.length === 2 && messages.some(m => m.role === 'system')));
|
| 987 |
+
|
| 988 |
+
if (isLikelyNewChat && CLEAR_CHAT_BUTTON_SELECTOR && CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR) {
|
| 989 |
+
console.log(`[${reqId}] 检测到可能是新对话 (消息数: ${messages.length}),尝试清空聊天记录...`);
|
| 990 |
+
try {
|
| 991 |
+
const clearButton = page.locator(CLEAR_CHAT_BUTTON_SELECTOR);
|
| 992 |
+
console.log(`[${reqId}] - 查找并点击"Clear chat" (New chat) 按钮...`);
|
| 993 |
+
await clearButton.waitFor({ state: 'visible', timeout: 7000 });
|
| 994 |
+
await clearButton.click({ timeout: 5000 });
|
| 995 |
+
console.log(`[${reqId}] - "Clear chat"按钮已点击。新版UI无确认步骤,开始验证清空效果...`);
|
| 996 |
+
|
| 997 |
+
const checkStartTime = Date.now();
|
| 998 |
+
let cleared = false;
|
| 999 |
+
while (Date.now() - checkStartTime < CLEAR_CHAT_VERIFY_TIMEOUT_MS) {
|
| 1000 |
+
const modelTurns = page.locator(RESPONSE_CONTAINER_SELECTOR);
|
| 1001 |
+
const count = await modelTurns.count();
|
| 1002 |
+
if (count === 0) {
|
| 1003 |
+
console.log(`[${reqId}] ✅ 验证成功: 页面上未找到之前的 AI 回复元素 (耗时 ${Date.now() - checkStartTime}ms)。`);
|
| 1004 |
+
cleared = true;
|
| 1005 |
+
break;
|
| 1006 |
+
}
|
| 1007 |
+
await page.waitForTimeout(CLEAR_CHAT_VERIFY_INTERVAL_MS);
|
| 1008 |
+
}
|
| 1009 |
+
|
| 1010 |
+
if (!cleared) {
|
| 1011 |
+
console.warn(`[${reqId}] ⚠️ 验证超时: 在 ${CLEAR_CHAT_VERIFY_TIMEOUT_MS}ms 内仍能检测到之前的 AI 回复元素。上下文可能未完全清空。`);
|
| 1012 |
+
await saveErrorSnapshot(`clear_chat_verify_fail_${reqId}`);
|
| 1013 |
+
}
|
| 1014 |
+
} catch (clearChatError) {
|
| 1015 |
+
console.warn(`[${reqId}] ⚠️ 清空聊天记录或验证时出错: ${clearChatError.message.split('\n')[0]}. 将继续执行请求,但上下文可能未被清除。`);
|
| 1016 |
+
if (clearChatError.message.includes('selector')) {
|
| 1017 |
+
console.warn(` (请仔细检查选择器是否仍然有效: CLEAR_CHAT_BUTTON_SELECTOR='${CLEAR_CHAT_BUTTON_SELECTOR}')`);
|
| 1018 |
+
}
|
| 1019 |
+
await saveErrorSnapshot(`clear_chat_fail_or_verify_${reqId}`);
|
| 1020 |
+
}
|
| 1021 |
+
} else if (isLikelyNewChat && (!CLEAR_CHAT_BUTTON_SELECTOR || !CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR)) {
|
| 1022 |
+
console.warn(`[${reqId}] 检测到可能是新对话,但未完整配置清空聊天相关的选择器常量,无法自动重置上下文。`);
|
| 1023 |
+
}
|
| 1024 |
+
// --- 结束:启发式新对话处理 ---
|
| 1025 |
+
|
| 1026 |
+
console.log(`[${reqId}] 请求模式: ${isStreaming ? '流式 (SSE)' : '非流式 (JSON)'}`);
|
| 1027 |
+
|
| 1028 |
+
// 2. 设置此请求的总操作超时
|
| 1029 |
+
operationTimer = setTimeout(async () => {
|
| 1030 |
+
await saveErrorSnapshot(`operation_timeout_${reqId}`);
|
| 1031 |
+
console.error(`[${reqId}] Operation timed out after ${RESPONSE_COMPLETION_TIMEOUT / 1000} seconds.`);
|
| 1032 |
+
if (!res.headersSent) {
|
| 1033 |
+
res.status(504).json({ error: { message: `[${reqId}] Operation timed out`, type: 'timeout_error' } });
|
| 1034 |
+
} else if (isStreaming && !res.writableEnded) {
|
| 1035 |
+
sendStreamError(res, "Operation timed out on server.", reqId);
|
| 1036 |
+
}
|
| 1037 |
+
// Note: Timeout error now managed within processQueue, allowing next item to proceed
|
| 1038 |
+
}, RESPONSE_COMPLETION_TIMEOUT);
|
| 1039 |
+
|
| 1040 |
+
// 3. 验证请求 (使用更新后��函数)
|
| 1041 |
+
// Pass reqId to validation for better logging context
|
| 1042 |
+
const validationMessages = messages.map(m => ({ ...m, reqId })); // Add reqId temporarily
|
| 1043 |
+
const { userPrompt, systemPrompt: extractedSystemPrompt } = validateChatRequest(validationMessages);
|
| 1044 |
+
// Combine system prompts if provided in multiple ways
|
| 1045 |
+
const systemPrompt = extractedSystemPrompt || otherParams?.system_prompt;
|
| 1046 |
+
|
| 1047 |
+
// --- Logging (Now userPrompt is guaranteed to be a string) ---
|
| 1048 |
+
const userPromptPreview = userPrompt.substring(0, 80);
|
| 1049 |
+
console.log(`[${reqId}] 处理后的 User Prompt (用于提交, start): \"${userPromptPreview}...\" (Total length: ${userPrompt.length})`);
|
| 1050 |
+
|
| 1051 |
+
if (systemPrompt) {
|
| 1052 |
+
// systemPrompt from validateChatRequest is also guaranteed string or null
|
| 1053 |
+
const systemPromptPreview = systemPrompt.substring(0, 80);
|
| 1054 |
+
console.log(`[${reqId}] 处理后的 System Prompt (用于提交, start): \"${systemPromptPreview}...\"`);
|
| 1055 |
+
} else {
|
| 1056 |
+
console.log(`[${reqId}] 无 System Prompt。`);
|
| 1057 |
+
}
|
| 1058 |
+
if (Object.keys(otherParams).length > 0) {
|
| 1059 |
+
console.log(`[${reqId}] 记录到的额外参数: ${JSON.stringify(otherParams)}`);
|
| 1060 |
+
}
|
| 1061 |
+
// --- End Logging ---
|
| 1062 |
+
|
| 1063 |
+
// 4. 准备 Prompt (使用处理后的 userPrompt 和 systemPrompt)
|
| 1064 |
+
let prompt;
|
| 1065 |
+
if (isStreaming) {
|
| 1066 |
+
prompt = prepareAIStudioPromptStream(userPrompt, systemPrompt); // Assumes prepare functions handle null systemPrompt
|
| 1067 |
+
console.log(`[${reqId}] 构建的流式 Prompt (Raw): \"${prompt.substring(0, 200)}...\"`);
|
| 1068 |
+
} else {
|
| 1069 |
+
prompt = prepareAIStudioPrompt(userPrompt, systemPrompt); // Assumes prepare functions handle null systemPrompt
|
| 1070 |
+
console.log(`[${reqId}] 构建的非流式 Prompt (JSON): \"${prompt.substring(0, 200)}...\"`);
|
| 1071 |
+
}
|
| 1072 |
+
|
| 1073 |
+
// 5. 与页面交互并提交
|
| 1074 |
+
const locators = await interactAndSubmitPrompt(page, prompt, reqId);
|
| 1075 |
+
|
| 1076 |
+
// --- 添加 'close' 事件监听器 ---
|
| 1077 |
+
closeEventHandler = async () => {
|
| 1078 |
+
console.log(`[${reqId}] 'close' event handler triggered.`); // <-- 新增日志
|
| 1079 |
+
if (isCancelled) {
|
| 1080 |
+
console.log(`[${reqId}] 'close' event handler: Already cancelled, doing nothing.`); // <-- 新增日志
|
| 1081 |
+
return; // 防止重复执行
|
| 1082 |
+
}
|
| 1083 |
+
isCancelled = true;
|
| 1084 |
+
console.log(`[${reqId}] Client disconnected ('close' event). Attempting to stop generation by clicking the run/stop button.`);
|
| 1085 |
+
clearTimeout(operationTimer); // 清除主超时定时器
|
| 1086 |
+
|
| 1087 |
+
// 尝试点击运行/停止按钮 (因为它是同一个按钮)
|
| 1088 |
+
try {
|
| 1089 |
+
// 确保 locators, submitButton, inputField 存在
|
| 1090 |
+
if (!locators || !locators.submitButton || !locators.inputField) {
|
| 1091 |
+
console.warn(`[${reqId}] closeEventHandler: Cannot attempt to click stop button: locators (button or input) not available.`); // <-- 修改日志
|
| 1092 |
+
return;
|
| 1093 |
+
}
|
| 1094 |
+
// 检查按钮是否仍然可用 (增加超时)
|
| 1095 |
+
console.log(`[${reqId}] closeEventHandler: Checking button state (timeout: 2000ms)...`); // <-- 修改日志
|
| 1096 |
+
const isEnabled = await locators.submitButton.isEnabled({ timeout: 2000 }); // <-- 增加超时
|
| 1097 |
+
console.log(`[${reqId}] closeEventHandler: Button isEnabled result: ${isEnabled}`); // <-- 新增日志
|
| 1098 |
+
|
| 1099 |
+
if (isEnabled) {
|
| 1100 |
+
// *** 新增:检查输入框是否为空 (增加超时) ***
|
| 1101 |
+
console.log(`[${reqId}] closeEventHandler: Button enabled, checking input value (timeout: 2000ms)...`); // <-- 修改日志
|
| 1102 |
+
const inputValue = await locators.inputField.inputValue({ timeout: 2000 }); // <-- 增加超时
|
| 1103 |
+
console.log(`[${reqId}] closeEventHandler: Input value: "${inputValue}"`); // <-- 新增日志
|
| 1104 |
+
if (inputValue === '') {
|
| 1105 |
+
console.log(`[${reqId}] closeEventHandler: Run/Stop button is enabled AND input is empty. Clicking it to stop generation...`); // <-- 修改日志
|
| 1106 |
+
// 使用 click({ force: true }) 可能更可靠
|
| 1107 |
+
await locators.submitButton.click({ timeout: 5000, force: true });
|
| 1108 |
+
console.log(`[${reqId}] closeEventHandler: Run/Stop button click attempted.`); // <-- 修改日志
|
| 1109 |
+
} else {
|
| 1110 |
+
console.log(`[${reqId}] closeEventHandler: Run/Stop button is enabled BUT input is NOT empty. Assuming user typed new input, not clicking stop.`); // <-- 修改日志
|
| 1111 |
+
}
|
| 1112 |
+
// *** 结束新增检查 ***
|
| 1113 |
+
} else {
|
| 1114 |
+
console.log(`[${reqId}] closeEventHandler: Run/Stop button is already disabled (generation likely finished or close event was late). No click needed.`); // <-- 修改日志
|
| 1115 |
+
}
|
| 1116 |
+
} catch (clickError) {
|
| 1117 |
+
// 捕获检查或点击过程中的错误
|
| 1118 |
+
console.warn(`[${reqId}] closeEventHandler: Error during stop button check/click: ${clickError.message.split('\n')[0]}`); // <-- 修改日志
|
| 1119 |
+
// 添加更详细日志并尝试保存快照
|
| 1120 |
+
console.error(`[${reqId}] closeEventHandler: Detailed error during check/click:`, clickError);
|
| 1121 |
+
await saveErrorSnapshot(`close_handler_click_error_${reqId}`);
|
| 1122 |
+
}
|
| 1123 |
+
};
|
| 1124 |
+
res.on('close', closeEventHandler);
|
| 1125 |
+
// --- 结束添加监听器 ---
|
| 1126 |
+
|
| 1127 |
+
// 6. 定位响应元素
|
| 1128 |
+
const { responseElement } = await locateResponseElements(page, locators, reqId);
|
| 1129 |
+
|
| 1130 |
+
// 7. 处理响应 (流式或非流式)
|
| 1131 |
+
console.log(`[${reqId}] 处理 AI 回复...`);
|
| 1132 |
+
if (isStreaming) {
|
| 1133 |
+
// --- 设置流式响应头 ---
|
| 1134 |
+
res.setHeader('Content-Type', 'text/event-stream');
|
| 1135 |
+
res.setHeader('Cache-Control', 'no-cache');
|
| 1136 |
+
res.setHeader('Connection', 'keep-alive');
|
| 1137 |
+
res.flushHeaders();
|
| 1138 |
+
|
| 1139 |
+
// 调用流式处理函数
|
| 1140 |
+
// 传递检查函数 () => isCancelled
|
| 1141 |
+
await handleStreamingResponse(res, responseElement, page, locators, operationTimer, reqId, () => isCancelled);
|
| 1142 |
+
|
| 1143 |
+
} else {
|
| 1144 |
+
// 调用非流式处理函数
|
| 1145 |
+
// 传递检查函数 () => isCancelled
|
| 1146 |
+
await handleNonStreamingResponse(res, page, locators, operationTimer, reqId, () => isCancelled);
|
| 1147 |
+
}
|
| 1148 |
+
|
| 1149 |
+
// --- 修改:仅在未被取消时记录成功 ---
|
| 1150 |
+
if (!isCancelled) {
|
| 1151 |
+
console.log(`[${reqId}] ✅ 请求处理成功完成。`);
|
| 1152 |
+
clearTimeout(operationTimer); // 只有真正成功完成才清除计时器
|
| 1153 |
+
} else {
|
| 1154 |
+
console.log(`[${reqId}] ℹ️ 请求处理因客户端断开连接而被中止。`);
|
| 1155 |
+
// operationTimer 应该已经在 closeEventHandler 中被清除了
|
| 1156 |
+
}
|
| 1157 |
+
// --- 结束修改 ---
|
| 1158 |
+
|
| 1159 |
+
} catch (error) {
|
| 1160 |
+
// 确保在任何错误情况下都清除此请求的定时器 (如果 close 事件未触发)
|
| 1161 |
+
if (!isCancelled) {
|
| 1162 |
+
clearTimeout(operationTimer);
|
| 1163 |
+
}
|
| 1164 |
+
console.error(`[${reqId}] ❌ 处理队列中的请求时出错: ${error.message}\n${error.stack}`);
|
| 1165 |
+
|
| 1166 |
+
// --- 恢复:添加条件判断是否需要保存快照 ---
|
| 1167 |
+
const shouldSaveSnapshot = !(
|
| 1168 |
+
error.message?.includes('Invalid request') || // 跳过请求验证错误
|
| 1169 |
+
error.message?.includes('Playwright not ready') // 跳过 Playwright 初始化/连接错误
|
| 1170 |
+
// 未来可以根据需要添加其他不需要快照的错误类型
|
| 1171 |
+
);
|
| 1172 |
+
|
| 1173 |
+
if (shouldSaveSnapshot && !error.message?.includes('snapshot') && !error.stack?.includes('saveErrorSnapshot')) {
|
| 1174 |
+
// 避免在保存快照本身失败或已知Playwright问题时再次尝试保存
|
| 1175 |
+
await saveErrorSnapshot(`general_api_error_${reqId}`);
|
| 1176 |
+
} else if (!shouldSaveSnapshot) {
|
| 1177 |
+
console.log(`[${reqId}] (Info) Skipping error snapshot for this type of error: ${error.message.split('\n')[0]}`);
|
| 1178 |
+
}
|
| 1179 |
+
// --- 结束恢复 ---
|
| 1180 |
+
|
| 1181 |
+
// 发送错误响应,如果尚未发送
|
| 1182 |
+
if (!res.headersSent) {
|
| 1183 |
+
let statusCode = 500;
|
| 1184 |
+
let errorType = 'server_error';
|
| 1185 |
+
if (error.message?.includes('timed out') || error.message?.includes('timeout')) {
|
| 1186 |
+
statusCode = 504; // Gateway Timeout
|
| 1187 |
+
errorType = 'timeout_error';
|
| 1188 |
+
} else if (error.message?.includes('AI Studio Error')) {
|
| 1189 |
+
statusCode = 502; // Bad Gateway (error from upstream)
|
| 1190 |
+
errorType = 'upstream_error';
|
| 1191 |
+
} else if (error.message?.includes('Invalid request')) {
|
| 1192 |
+
statusCode = 400; // Bad Request
|
| 1193 |
+
errorType = 'invalid_request_error';
|
| 1194 |
+
} else if (error.message?.includes('Playwright not ready')) { // Specific handling for PW not ready here
|
| 1195 |
+
statusCode = 503;
|
| 1196 |
+
errorType = 'server_error';
|
| 1197 |
+
}
|
| 1198 |
+
res.status(statusCode).json({ error: { message: `[${reqId}] ${error.message}`, type: errorType } });
|
| 1199 |
+
} else if (req.body.stream === true && !res.writableEnded) { // Check if it WAS a streaming request
|
| 1200 |
+
// 如果是流式响应且头部已发送,则发送流式错误
|
| 1201 |
+
sendStreamError(res, error.message, reqId);
|
| 1202 |
+
}
|
| 1203 |
+
else if (!res.writableEnded) {
|
| 1204 |
+
// 对于非流式但已发送部分内容的罕见情况,或流式错误发送后的清理
|
| 1205 |
+
res.end();
|
| 1206 |
+
}
|
| 1207 |
+
} finally {
|
| 1208 |
+
// --- 添加清理逻辑 ---
|
| 1209 |
+
if (closeEventHandler) {
|
| 1210 |
+
res.removeListener('close', closeEventHandler);
|
| 1211 |
+
// console.log(`[${reqId}] Removed 'close' event listener.`); // Optional debug log
|
| 1212 |
+
}
|
| 1213 |
+
// --- 结束清理逻辑 ---
|
| 1214 |
+
isProcessing = false; // 标记处理已结束
|
| 1215 |
+
console.log(`[${reqId}] ---结束处理队列中的请求---`);
|
| 1216 |
+
// 触发处理下一个请求(如果队列中有)
|
| 1217 |
+
processQueue();
|
| 1218 |
+
}
|
| 1219 |
+
}
|
| 1220 |
+
|
| 1221 |
+
// --- API 端点 (v2.18: 使用队列) ---
|
| 1222 |
+
app.post('/v1/chat/completions', async (req, res) => {
|
| 1223 |
+
const reqId = Math.random().toString(36).substring(2, 9); // 生成简短的请求 ID
|
| 1224 |
+
console.log(`\n[${reqId}] === 收到 /v1/chat/completions 请求 ===`);
|
| 1225 |
+
|
| 1226 |
+
// 创建请求队列项,并添加取消标记和临时监听器引用
|
| 1227 |
+
const queueItem = {
|
| 1228 |
+
req,
|
| 1229 |
+
res,
|
| 1230 |
+
reqId,
|
| 1231 |
+
isCancelledByClient: false,
|
| 1232 |
+
preliminaryCloseHandler: null
|
| 1233 |
+
};
|
| 1234 |
+
|
| 1235 |
+
// --- 添加临时的 'close' 事件监听器 ---
|
| 1236 |
+
queueItem.preliminaryCloseHandler = () => {
|
| 1237 |
+
if (!queueItem.isCancelledByClient) { // 避免重复标记
|
| 1238 |
+
console.log(`[${reqId}] Client disconnected before processing started.`);
|
| 1239 |
+
queueItem.isCancelledByClient = true;
|
| 1240 |
+
// 从 res 对象移除自身,防止后续冲突
|
| 1241 |
+
res.removeListener('close', queueItem.preliminaryCloseHandler);
|
| 1242 |
+
}
|
| 1243 |
+
};
|
| 1244 |
+
res.once('close', queueItem.preliminaryCloseHandler); // 使用 once 确保最多触发一次
|
| 1245 |
+
// --- 结束添加临时监听器 ---
|
| 1246 |
+
|
| 1247 |
+
// 将请求加入队列
|
| 1248 |
+
requestQueue.push(queueItem); // <-- 推入包含标记的对象
|
| 1249 |
+
console.log(`[${reqId}] 请求已加入队列 (当前队列长度: ${requestQueue.length})`);
|
| 1250 |
+
|
| 1251 |
+
// 尝试处理队列 (如果当前未在处理)
|
| 1252 |
+
if (!isProcessing) {
|
| 1253 |
+
console.log(`[Queue] 触发队列处理 (收到新请求 ${reqId} 时处于空闲状态)`);
|
| 1254 |
+
processQueue();
|
| 1255 |
+
} else {
|
| 1256 |
+
console.log(`[Queue] 当前正在处理其他请求,请求 ${reqId} 已排队等待。`);
|
| 1257 |
+
}
|
| 1258 |
+
});
|
| 1259 |
+
|
| 1260 |
+
|
| 1261 |
+
// --- Helper: 获取当前文本 (v2.14 - 获取原始文本) -> vNEXT: Try innerText
|
| 1262 |
+
async function getRawTextContent(responseElement, previousText, reqId) {
|
| 1263 |
+
try {
|
| 1264 |
+
await responseElement.waitFor({ state: 'attached', timeout: 1500 });
|
| 1265 |
+
const preElement = responseElement.locator('pre').last();
|
| 1266 |
+
let rawText = null;
|
| 1267 |
+
try {
|
| 1268 |
+
await preElement.waitFor({ state: 'attached', timeout: 500 });
|
| 1269 |
+
// 尝试使用 innerText 获取渲染后的文本,可能更好地保留换行
|
| 1270 |
+
rawText = await preElement.innerText({ timeout: 1000 });
|
| 1271 |
+
} catch {
|
| 1272 |
+
// 如果 pre 元素获取失败,回退到 responseElement 的 innerText
|
| 1273 |
+
console.warn(`[${reqId}] (Warn) Failed to get innerText from <pre>, falling back to parent.`);
|
| 1274 |
+
rawText = await responseElement.innerText({ timeout: 2000 });
|
| 1275 |
+
}
|
| 1276 |
+
// 移除 trim(),直接返回获取到的文本
|
| 1277 |
+
return rawText !== null ? rawText : previousText;
|
| 1278 |
+
} catch (e) {
|
| 1279 |
+
console.warn(`[${reqId}] (Warn) getRawTextContent (innerText) failed: ${e.message.split('\n')[0]}. Returning previous.`);
|
| 1280 |
+
return previousText;
|
| 1281 |
+
}
|
| 1282 |
+
}
|
| 1283 |
+
|
| 1284 |
+
// --- Helper: 发送流式块 ---
|
| 1285 |
+
function sendStreamChunk(res, delta, reqId) {
|
| 1286 |
+
if (delta && !res.writableEnded) {
|
| 1287 |
+
const chunk = {
|
| 1288 |
+
id: `${CHAT_COMPLETION_ID_PREFIX}${Date.now()}-${Math.random().toString(36).substring(2, 15)}`,
|
| 1289 |
+
object: "chat.completion.chunk",
|
| 1290 |
+
created: Math.floor(Date.now() / 1000),
|
| 1291 |
+
model: MODEL_NAME,
|
| 1292 |
+
choices: [{ index: 0, delta: { content: delta }, finish_reason: null }]
|
| 1293 |
+
};
|
| 1294 |
+
try {
|
| 1295 |
+
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
| 1296 |
+
} catch (writeError) {
|
| 1297 |
+
console.error(`[${reqId}] Error writing stream chunk:`, writeError.message);
|
| 1298 |
+
if (!res.writableEnded) res.end(); // End stream on write error
|
| 1299 |
+
}
|
| 1300 |
+
}
|
| 1301 |
+
}
|
| 1302 |
+
|
| 1303 |
+
// --- Helper: 发送流式错误块 ---
|
| 1304 |
+
function sendStreamError(res, errorMessage, reqId) {
|
| 1305 |
+
if (!res.writableEnded) {
|
| 1306 |
+
const errorPayload = { error: { message: `[${reqId}] Server error during streaming: ${errorMessage}`, type: 'server_error' } };
|
| 1307 |
+
try {
|
| 1308 |
+
// Avoid writing multiple DONE messages if error occurs after normal DONE
|
| 1309 |
+
if (!res.writableEnded) res.write(`data: ${JSON.stringify(errorPayload)}\n\n`);
|
| 1310 |
+
if (!res.writableEnded) res.write('data: [DONE]\n\n');
|
| 1311 |
+
} catch (e) {
|
| 1312 |
+
console.error(`[${reqId}] Error writing stream error chunk:`, e.message);
|
| 1313 |
+
} finally {
|
| 1314 |
+
if (!res.writableEnded) res.end(); // Ensure stream ends
|
| 1315 |
+
}
|
| 1316 |
+
}
|
| 1317 |
+
}
|
| 1318 |
+
|
| 1319 |
+
// --- Helper: 保存错误快照 ---
|
| 1320 |
+
async function saveErrorSnapshot(errorName = 'error') {
|
| 1321 |
+
// Extract reqId if present in the name
|
| 1322 |
+
const nameParts = errorName.split('_');
|
| 1323 |
+
const reqId = nameParts[nameParts.length - 1].length === 7 ? nameParts.pop() : null; // Simple check for likely reqId
|
| 1324 |
+
const baseErrorName = nameParts.join('_');
|
| 1325 |
+
const logPrefix = reqId ? `[${reqId}]` : '[No ReqId]';
|
| 1326 |
+
|
| 1327 |
+
if (!browser?.isConnected() || !page || page.isClosed()) {
|
| 1328 |
+
console.log(`${logPrefix} 无法保存错误快照 (${baseErrorName}),浏览器或页面不可用。`);
|
| 1329 |
+
return;
|
| 1330 |
+
}
|
| 1331 |
+
console.log(`${logPrefix} 尝试保存错误快照 (${baseErrorName})...`);
|
| 1332 |
+
const timestamp = Date.now();
|
| 1333 |
+
const errorDir = path.join(__dirname, 'errors');
|
| 1334 |
+
try {
|
| 1335 |
+
if (!fs.existsSync(errorDir)) fs.mkdirSync(errorDir, { recursive: true });
|
| 1336 |
+
// Include reqId in filename if available
|
| 1337 |
+
const filenameSuffix = reqId ? `${reqId}_${timestamp}` : `${timestamp}`;
|
| 1338 |
+
const screenshotPath = path.join(errorDir, `${baseErrorName}_screenshot_${filenameSuffix}.png`);
|
| 1339 |
+
const htmlPath = path.join(errorDir, `${baseErrorName}_page_${filenameSuffix}.html`);
|
| 1340 |
+
|
| 1341 |
+
try {
|
| 1342 |
+
await page.screenshot({ path: screenshotPath, fullPage: true, timeout: 15000 });
|
| 1343 |
+
console.log(`${logPrefix} 错误快照已保存到: ${screenshotPath}`);
|
| 1344 |
+
} catch (screenshotError) {
|
| 1345 |
+
console.error(`${logPrefix} 保存屏幕截图失败 (${baseErrorName}): ${screenshotError.message}`);
|
| 1346 |
+
}
|
| 1347 |
+
try {
|
| 1348 |
+
const content = await page.content({timeout: 15000});
|
| 1349 |
+
fs.writeFileSync(htmlPath, content);
|
| 1350 |
+
console.log(`${logPrefix} 错误页面HTML已保存到: ${htmlPath}`);
|
| 1351 |
+
} catch (htmlError) {
|
| 1352 |
+
console.error(`${logPrefix} 保存页面HTML失败 (${baseErrorName}): ${htmlError.message}`);
|
| 1353 |
+
}
|
| 1354 |
+
} catch (dirError) {
|
| 1355 |
+
console.error(`${logPrefix} 创建错误目录或保存快照时出错: ${dirError.message}`);
|
| 1356 |
+
}
|
| 1357 |
+
}
|
| 1358 |
+
|
| 1359 |
+
// v2.14: Helper to safely parse JSON, attempting to find the outermost object/array
|
| 1360 |
+
function tryParseJson(text, reqId) {
|
| 1361 |
+
if (!text || typeof text !== 'string') return null;
|
| 1362 |
+
text = text.trim();
|
| 1363 |
+
|
| 1364 |
+
let startIndex = -1;
|
| 1365 |
+
let endIndex = -1;
|
| 1366 |
+
|
| 1367 |
+
const firstBrace = text.indexOf('{');
|
| 1368 |
+
const firstBracket = text.indexOf('[');
|
| 1369 |
+
|
| 1370 |
+
if (firstBrace !== -1 && (firstBracket === -1 || firstBrace < firstBracket)) {
|
| 1371 |
+
startIndex = firstBrace;
|
| 1372 |
+
endIndex = text.lastIndexOf('}');
|
| 1373 |
+
} else if (firstBracket !== -1) {
|
| 1374 |
+
startIndex = firstBracket;
|
| 1375 |
+
endIndex = text.lastIndexOf(']');
|
| 1376 |
+
}
|
| 1377 |
+
|
| 1378 |
+
if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) {
|
| 1379 |
+
// console.warn(`[${reqId}] (Warn) Could not find valid start/end braces/brackets for JSON parsing.`);
|
| 1380 |
+
return null;
|
| 1381 |
+
}
|
| 1382 |
+
|
| 1383 |
+
const jsonText = text.substring(startIndex, endIndex + 1);
|
| 1384 |
+
|
| 1385 |
+
try {
|
| 1386 |
+
return JSON.parse(jsonText);
|
| 1387 |
+
} catch (e) {
|
| 1388 |
+
// console.warn(`[${reqId}] (Warn) JSON parse failed for extracted text: ${e.message}`);
|
| 1389 |
+
return null;
|
| 1390 |
+
}
|
| 1391 |
+
}
|
| 1392 |
+
|
| 1393 |
+
// --- Helper: 检测并提取页面错误提示 ---
|
| 1394 |
+
async function detectAndExtractPageError(page, reqId) {
|
| 1395 |
+
const errorToastLocator = page.locator(ERROR_TOAST_SELECTOR).last();
|
| 1396 |
+
try {
|
| 1397 |
+
const isVisible = await errorToastLocator.isVisible({ timeout: 1000 });
|
| 1398 |
+
if (isVisible) {
|
| 1399 |
+
console.log(`[${reqId}] 检测到错误 Toast 元素。`);
|
| 1400 |
+
const messageLocator = errorToastLocator.locator('span.content-text');
|
| 1401 |
+
const errorMessage = await messageLocator.textContent({ timeout: 500 });
|
| 1402 |
+
return errorMessage || "Detected error toast, but couldn't extract specific message.";
|
| 1403 |
+
} else {
|
| 1404 |
+
return null;
|
| 1405 |
+
}
|
| 1406 |
+
} catch (e) {
|
| 1407 |
+
// console.warn(`[${reqId}] (Warn) Checking for error toast failed or timed out: ${e.message.split('\n')[0]}`);
|
| 1408 |
+
return null;
|
| 1409 |
+
}
|
| 1410 |
+
}
|
| 1411 |
+
|
| 1412 |
+
// --- Helper: 快速检查结束条件 ---
|
| 1413 |
+
async function checkEndConditionQuickly(page, spinnerLocator, inputLocator, buttonLocator, timeoutMs = 250, reqId) {
|
| 1414 |
+
try {
|
| 1415 |
+
const results = await Promise.allSettled([
|
| 1416 |
+
expect(spinnerLocator).toBeHidden({ timeout: timeoutMs }),
|
| 1417 |
+
expect(inputLocator).toHaveValue('', { timeout: timeoutMs }),
|
| 1418 |
+
expect(buttonLocator).toBeDisabled({ timeout: timeoutMs })
|
| 1419 |
+
]);
|
| 1420 |
+
const allMet = results.every(result => result.status === 'fulfilled');
|
| 1421 |
+
// console.log(`[${reqId}] (Quick Check) All met: ${allMet}`);
|
| 1422 |
+
return allMet;
|
| 1423 |
+
} catch (error) {
|
| 1424 |
+
// console.warn(`[${reqId}] (Quick Check) Error during checkEndConditionQuickly: ${error.message}`);
|
| 1425 |
+
return false;
|
| 1426 |
+
}
|
| 1427 |
+
}
|
| 1428 |
+
|
| 1429 |
+
// --- 启动服务器 ---
|
| 1430 |
+
let serverInstance = null;
|
| 1431 |
+
(async () => {
|
| 1432 |
+
await initializePlaywright();
|
| 1433 |
+
|
| 1434 |
+
serverInstance = app.listen(SERVER_PORT, () => {
|
| 1435 |
+
console.log("\n=============================================================");
|
| 1436 |
+
// v2.18: Updated version marker
|
| 1437 |
+
console.log(" 🚀 AI Studio Proxy Server (v2.18 - Queue) 🚀");
|
| 1438 |
+
console.log("=============================================================");
|
| 1439 |
+
console.log(`🔗 监听地址: http://localhost:${SERVER_PORT}`);
|
| 1440 |
+
console.log(` - Web UI (测试): http://localhost:${SERVER_PORT}/`);
|
| 1441 |
+
console.log(` - API 端点: http://localhost:${SERVER_PORT}/v1/chat/completions`);
|
| 1442 |
+
console.log(` - 模型接口: http://localhost:${SERVER_PORT}/v1/models`);
|
| 1443 |
+
console.log(` - 健康检查: http://localhost:${SERVER_PORT}/health`);
|
| 1444 |
+
console.log("-------------------------------------------------------------");
|
| 1445 |
+
if (isPlaywrightReady) {
|
| 1446 |
+
console.log('✅ Playwright 连接成功,服务已准备就绪!');
|
| 1447 |
+
} else {
|
| 1448 |
+
console.warn('⚠️ Playwright 未就绪。请检查下方日志并确保 Chrome/AI Studio 正常运行。');
|
| 1449 |
+
console.warn(' API 请求将失败,直到 Playwright 连接成功。');
|
| 1450 |
+
}
|
| 1451 |
+
console.log("-------------------------------------------------------------");
|
| 1452 |
+
console.log(`⏳ 等待 Chrome 实例 (调试端口: ${CHROME_DEBUGGING_PORT})...`);
|
| 1453 |
+
console.log(" 请确保已运行 auto_connect_aistudio.js 脚本,");
|
| 1454 |
+
console.log(" 并且 Google AI Studio 页面已在浏览器中打开。 ");
|
| 1455 |
+
console.log("=============================================================\n");
|
| 1456 |
+
});
|
| 1457 |
+
|
| 1458 |
+
serverInstance.on('error', (error) => {
|
| 1459 |
+
if (error.code === 'EADDRINUSE') {
|
| 1460 |
+
console.error("\n=============================================================");
|
| 1461 |
+
console.error(`❌ 致命错误:端口 ${SERVER_PORT} 已被占用!`);
|
| 1462 |
+
console.error(" 请关闭占用该端口的其他程序,或在 server.cjs 中修改 SERVER_PORT。 ");
|
| 1463 |
+
console.error("=============================================================\n");
|
| 1464 |
+
} else {
|
| 1465 |
+
console.error('❌ 服务器启动失败:', error);
|
| 1466 |
+
}
|
| 1467 |
+
process.exit(1);
|
| 1468 |
+
});
|
| 1469 |
+
|
| 1470 |
+
})();
|
| 1471 |
+
|
| 1472 |
+
// --- 优雅关闭处理 ---
|
| 1473 |
+
let isShuttingDown = false;
|
| 1474 |
+
async function shutdown(signal) {
|
| 1475 |
+
if (isShuttingDown) return;
|
| 1476 |
+
isShuttingDown = true;
|
| 1477 |
+
console.log(`\n收到 ${signal} 信号,正在关闭服务器...`);
|
| 1478 |
+
console.log(`当前队列中有 ${requestQueue.length} 个请求等待处理。将不再接受新请求。`);
|
| 1479 |
+
// Option: Wait for the current request to finish?
|
| 1480 |
+
// For now, we'll just close the server, potentially interrupting the current request.
|
| 1481 |
+
|
| 1482 |
+
if (serverInstance) {
|
| 1483 |
+
serverInstance.close(async (err) => {
|
| 1484 |
+
if (err) console.error("关闭 HTTP 服务器时出错:", err);
|
| 1485 |
+
else console.log("HTTP 服务器已关闭。");
|
| 1486 |
+
|
| 1487 |
+
console.log("Playwright connectOverCDP 将自动断开。");
|
| 1488 |
+
// No need to explicitly disconnect browser in connectOverCDP mode
|
| 1489 |
+
console.log('服务器优雅关闭完成。');
|
| 1490 |
+
process.exit(err ? 1 : 0);
|
| 1491 |
+
});
|
| 1492 |
+
|
| 1493 |
+
// Force exit after timeout
|
| 1494 |
+
setTimeout(() => {
|
| 1495 |
+
console.error("优雅关闭超时,强制退出进程。");
|
| 1496 |
+
process.exit(1);
|
| 1497 |
+
}, 10000); // 10 seconds timeout
|
| 1498 |
+
} else {
|
| 1499 |
+
console.log("服务器实例未找到,直接退出。");
|
| 1500 |
+
process.exit(0);
|
| 1501 |
+
}
|
| 1502 |
+
}
|
| 1503 |
+
|
| 1504 |
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
| 1505 |
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
AIStudioProxyAPI/deprecated_javascript_version/test.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// index.js (修改后 - 用于访问本地 server.js 代理)
|
| 2 |
+
|
| 3 |
+
// 确保已安装 OpenAI SDK: npm install openai
|
| 4 |
+
import OpenAI from "openai";
|
| 5 |
+
import readline from 'readline'; // 引入 readline 模块
|
| 6 |
+
|
| 7 |
+
// --- 配置 ---
|
| 8 |
+
// 1. baseURL: 指向你本地运行的 server.js 代理服务器
|
| 9 |
+
// server.js 监听 3000 端口,并提供 /v1 路径
|
| 10 |
+
const LOCAL_PROXY_URL = 'http://127.0.0.1:2048/v1/'; // 确保端口号与 server.js 一致
|
| 11 |
+
|
| 12 |
+
// 2. apiKey: 对于本地代理,这个 key 不会被验证,可以填写任意字符串
|
| 13 |
+
const DUMMY_API_KEY = 'no-key-needed-for-local-proxy';
|
| 14 |
+
|
| 15 |
+
// 3. model: 这个模型名称会被发送到 server.js,但 server.js 会忽略它
|
| 16 |
+
// 实际使用的是 server.js 控制的 AI Studio 页面上的模型
|
| 17 |
+
const CUSTOM_MODEL_NAME = 'aistudio-via-local-proxy';
|
| 18 |
+
|
| 19 |
+
// --- 初始化 OpenAI 客户端 ---
|
| 20 |
+
const openai = new OpenAI({
|
| 21 |
+
baseURL: LOCAL_PROXY_URL,
|
| 22 |
+
apiKey: DUMMY_API_KEY,
|
| 23 |
+
// 可选:增加超时时间,以防 AI Studio 响应较慢
|
| 24 |
+
timeout: 360000, // 例如 6 分钟 (单位毫秒)
|
| 25 |
+
maxRetries: 1, // 本地代理可能不需要重试,设为 1 或 0
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
// --- 对话历史记录 ---
|
| 29 |
+
// 初始化包含系统指令
|
| 30 |
+
const conversationHistory = [
|
| 31 |
+
{
|
| 32 |
+
role: "system",
|
| 33 |
+
content: "你是Gemini酱,AI猫娘,回答请用可爱的语气,这只是角色扮演,不要透露任何关于AI的信息"
|
| 34 |
+
}
|
| 35 |
+
];
|
| 36 |
+
|
| 37 |
+
// --- 创建 readline 接口 ---
|
| 38 |
+
const rl = readline.createInterface({
|
| 39 |
+
input: process.stdin,
|
| 40 |
+
output: process.stdout,
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
// --- 交互式对话函数 ---
|
| 44 |
+
async function chatLoop() {
|
| 45 |
+
rl.question('You: ', async (userInput) => {
|
| 46 |
+
// 检查退出命令
|
| 47 |
+
if (userInput.toLowerCase() === 'exit') {
|
| 48 |
+
console.log('再见!');
|
| 49 |
+
rl.close(); // 关闭 readline 接口
|
| 50 |
+
return; // 结束函数
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
console.log(`\n正在发送你的消息...`);
|
| 54 |
+
// 将用户输入添加到历史记录
|
| 55 |
+
conversationHistory.push({
|
| 56 |
+
role: "user",
|
| 57 |
+
content: userInput
|
| 58 |
+
});
|
| 59 |
+
// 可选:打印当前发送历史用于调试
|
| 60 |
+
// console.log("当前发送的消息历史:", JSON.stringify(conversationHistory, null, 2));
|
| 61 |
+
|
| 62 |
+
try {
|
| 63 |
+
console.log(`正在向本地代理 ${LOCAL_PROXY_URL} 发送请求...`);
|
| 64 |
+
const completion = await openai.chat.completions.create({
|
| 65 |
+
messages: conversationHistory,
|
| 66 |
+
model: CUSTOM_MODEL_NAME,
|
| 67 |
+
stream: true, // 启用流式输出
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
console.log("\n--- 来自本地代理 (AI Studio) 的回复 ---");
|
| 71 |
+
let fullResponse = ""; // 用于拼接完整的回复内容
|
| 72 |
+
process.stdout.write('AI: '); // 先打印 "AI: " 前缀
|
| 73 |
+
for await (const chunk of completion) {
|
| 74 |
+
const content = chunk.choices[0]?.delta?.content || "";
|
| 75 |
+
process.stdout.write(content); // 直接打印流式内容,不换行
|
| 76 |
+
fullResponse += content; // 拼接内容
|
| 77 |
+
}
|
| 78 |
+
console.log(); // 在流结束后换行
|
| 79 |
+
|
| 80 |
+
// 将完整的 AI 回复添加到历史记录
|
| 81 |
+
if (fullResponse) {
|
| 82 |
+
conversationHistory.push({ role: "assistant", content: fullResponse });
|
| 83 |
+
} else {
|
| 84 |
+
console.log("未能从代理获取有效的流式内容。");
|
| 85 |
+
// 如果回复无效,可以选择从历史中移除刚才的用户输入
|
| 86 |
+
conversationHistory.pop();
|
| 87 |
+
}
|
| 88 |
+
console.log("----------------------------------------------\n");
|
| 89 |
+
|
| 90 |
+
} catch (error) {
|
| 91 |
+
console.error("\n--- 请求出错 ---");
|
| 92 |
+
// 保持之前的错误处理逻辑
|
| 93 |
+
if (error instanceof OpenAI.APIError) {
|
| 94 |
+
console.error(` 错误类型: OpenAI APIError (可能是代理返回的错误)`);
|
| 95 |
+
console.error(` 状态码: ${error.status}`);
|
| 96 |
+
console.error(` 错误消息: ${error.message}`);
|
| 97 |
+
console.error(` 错误代码: ${error.code}`);
|
| 98 |
+
console.error(` 错误参数: ${error.param}`);
|
| 99 |
+
} else if (error.code === 'ECONNREFUSED') {
|
| 100 |
+
console.error(` 错误类型: 连接被拒绝 (ECONNREFUSED)`);
|
| 101 |
+
console.error(` 无法连接到服务器 ${LOCAL_PROXY_URL}。请检查 server.js 是否运行。`);
|
| 102 |
+
} else if (error.name === 'TimeoutError' || (error.cause && error.cause.code === 'UND_ERR_CONNECT_TIMEOUT')) {
|
| 103 |
+
console.error(` 错误类型: 连接超时`);
|
| 104 |
+
console.error(` 连接到 ${LOCAL_PROXY_URL} 超时。请检查 server.js 或 AI Studio 响应。`);
|
| 105 |
+
} else {
|
| 106 |
+
console.error(' 发生了未知错误:', error.message);
|
| 107 |
+
}
|
| 108 |
+
console.error("----------------------------------------------\n");
|
| 109 |
+
// 出错时,从历史中移除刚才的用户输入,避免影响下次对话
|
| 110 |
+
conversationHistory.pop();
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// 不论成功或失败,都继续下一次循环
|
| 114 |
+
chatLoop();
|
| 115 |
+
});
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// --- 启动交互式对话 ---
|
| 119 |
+
console.log('你好! 我是Gemini酱。有什么事可以帮你哒,输入 "exit" 退出。');
|
| 120 |
+
console.log(' (请确保 server.js 和 auto_connect_aistudio.js 正在运行)');
|
| 121 |
+
chatLoop(); // 开始第一次提问
|
| 122 |
+
|
| 123 |
+
// --- 不再需要文件末尾的 main 调用和 setTimeout 示例 ---
|
| 124 |
+
// // 运行第一次对话
|
| 125 |
+
// main("你好!简单介绍一下你自己以及你的能力。");
|
| 126 |
+
// ... (移除 setTimeout 示例)
|
AIStudioProxyAPI/docker/.env.docker
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Docker 环境配置文件示例
|
| 2 |
+
# 复制此文件为 .env 并根据需要修改配置
|
| 3 |
+
|
| 4 |
+
# =============================================================================
|
| 5 |
+
# Docker 主机端口配置
|
| 6 |
+
# =============================================================================
|
| 7 |
+
|
| 8 |
+
# 主机上映射的端口 (外部访问端口)
|
| 9 |
+
HOST_FASTAPI_PORT=2048
|
| 10 |
+
HOST_STREAM_PORT=3120
|
| 11 |
+
|
| 12 |
+
# =============================================================================
|
| 13 |
+
# 容器内服务端口配置
|
| 14 |
+
# =============================================================================
|
| 15 |
+
|
| 16 |
+
# FastAPI 服务端口 (容器内)
|
| 17 |
+
PORT=8000
|
| 18 |
+
DEFAULT_FASTAPI_PORT=2048
|
| 19 |
+
DEFAULT_CAMOUFOX_PORT=9222
|
| 20 |
+
|
| 21 |
+
# 流式代理服务配置
|
| 22 |
+
STREAM_PORT=3120
|
| 23 |
+
|
| 24 |
+
# =============================================================================
|
| 25 |
+
# 代理配置
|
| 26 |
+
# =============================================================================
|
| 27 |
+
|
| 28 |
+
# HTTP/HTTPS 代理设置
|
| 29 |
+
# HTTP_PROXY=http://host.docker.internal:7890
|
| 30 |
+
# HTTPS_PROXY=http://host.docker.internal:7890
|
| 31 |
+
|
| 32 |
+
# 统一代理配置 (优先级高于 HTTP_PROXY/HTTPS_PROXY)
|
| 33 |
+
# UNIFIED_PROXY_CONFIG=http://host.docker.internal:7890
|
| 34 |
+
|
| 35 |
+
# 代理绕过列表 (用分号分隔)
|
| 36 |
+
# NO_PROXY=localhost;127.0.0.1;*.local
|
| 37 |
+
|
| 38 |
+
# =============================================================================
|
| 39 |
+
# 日志配置
|
| 40 |
+
# =============================================================================
|
| 41 |
+
|
| 42 |
+
# 服务器日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
| 43 |
+
SERVER_LOG_LEVEL=INFO
|
| 44 |
+
|
| 45 |
+
# 是否重定向 print 输出到日志
|
| 46 |
+
SERVER_REDIRECT_PRINT=false
|
| 47 |
+
|
| 48 |
+
# 启用调试日志
|
| 49 |
+
DEBUG_LOGS_ENABLED=false
|
| 50 |
+
|
| 51 |
+
# 启用跟踪日志
|
| 52 |
+
TRACE_LOGS_ENABLED=false
|
| 53 |
+
|
| 54 |
+
# =============================================================================
|
| 55 |
+
# 认证配置
|
| 56 |
+
# =============================================================================
|
| 57 |
+
|
| 58 |
+
# 自动保存认证信息
|
| 59 |
+
AUTO_SAVE_AUTH=false
|
| 60 |
+
|
| 61 |
+
# 认证保存超时时间 (秒)
|
| 62 |
+
AUTH_SAVE_TIMEOUT=30
|
| 63 |
+
|
| 64 |
+
# 自动确认登录
|
| 65 |
+
AUTO_CONFIRM_LOGIN=true
|
| 66 |
+
|
| 67 |
+
# =============================================================================
|
| 68 |
+
# 浏览器配置
|
| 69 |
+
# =============================================================================
|
| 70 |
+
|
| 71 |
+
# 启动模式 (normal, headless, virtual_display, direct_debug_no_browser)
|
| 72 |
+
LAUNCH_MODE=headless
|
| 73 |
+
|
| 74 |
+
# =============================================================================
|
| 75 |
+
# API 默认参数配置
|
| 76 |
+
# =============================================================================
|
| 77 |
+
|
| 78 |
+
# 默认温度值 (0.0-2.0)
|
| 79 |
+
DEFAULT_TEMPERATURE=1.0
|
| 80 |
+
|
| 81 |
+
# 默认最大输出令牌数
|
| 82 |
+
DEFAULT_MAX_OUTPUT_TOKENS=65536
|
| 83 |
+
|
| 84 |
+
# 默认 Top-P 值 (0.0-1.0)
|
| 85 |
+
DEFAULT_TOP_P=0.95
|
| 86 |
+
|
| 87 |
+
# 默认停止序列 (JSON 数组格式)
|
| 88 |
+
DEFAULT_STOP_SEQUENCES=["用户:"]
|
| 89 |
+
|
| 90 |
+
# =============================================================================
|
| 91 |
+
# 超时配置 (毫秒)
|
| 92 |
+
# =============================================================================
|
| 93 |
+
|
| 94 |
+
# 响应完成总超时时间
|
| 95 |
+
RESPONSE_COMPLETION_TIMEOUT=300000
|
| 96 |
+
|
| 97 |
+
# 轮询间隔
|
| 98 |
+
POLLING_INTERVAL=300
|
| 99 |
+
POLLING_INTERVAL_STREAM=180
|
| 100 |
+
|
| 101 |
+
# 静默超时
|
| 102 |
+
SILENCE_TIMEOUT_MS=60000
|
| 103 |
+
|
| 104 |
+
# =============================================================================
|
| 105 |
+
# 脚本注入配置
|
| 106 |
+
# =============================================================================
|
| 107 |
+
|
| 108 |
+
# 是否启用油猴脚本注入功能
|
| 109 |
+
ENABLE_SCRIPT_INJECTION=false
|
| 110 |
+
|
| 111 |
+
# 油猴脚本文件路径(相对于容器内 /app 目录)
|
| 112 |
+
USERSCRIPT_PATH=browser_utils/more_modles.js
|
| 113 |
+
|
| 114 |
+
# 注意:MODEL_CONFIG_PATH 已废弃
|
| 115 |
+
# 模型数据现在直接从 USERSCRIPT_PATH 指定的油猴脚本中解析
|
| 116 |
+
|
| 117 |
+
# =============================================================================
|
| 118 |
+
# Docker 特定配置
|
| 119 |
+
# =============================================================================
|
| 120 |
+
|
| 121 |
+
# 容器内存限制
|
| 122 |
+
# 默认不限制。如需限制容器资源,请在你的 .env 文件中取消注释并设置以下值。
|
| 123 |
+
# 例如: DOCKER_MEMORY_LIMIT=1g或DOCKER_MEMORY_LIMIT=1024m
|
| 124 |
+
# 注意:DOCKER_MEMORY_LIMIT和DOCKER_MEMSWAP_LIMIT相同时,不会使用SWAP
|
| 125 |
+
# DOCKER_MEMORY_LIMIT=
|
| 126 |
+
# DOCKER_MEMSWAP_LIMIT=
|
| 127 |
+
|
| 128 |
+
# 容器重启策略相关
|
| 129 |
+
# 这些配置项在 docker-compose.yml 中使用
|
| 130 |
+
|
| 131 |
+
# 健康检查间隔 (秒)
|
| 132 |
+
HEALTHCHECK_INTERVAL=30
|
| 133 |
+
|
| 134 |
+
# 健康检查超时 (秒)
|
| 135 |
+
HEALTHCHECK_TIMEOUT=10
|
| 136 |
+
|
| 137 |
+
# 健康检查重试次数
|
| 138 |
+
HEALTHCHECK_RETRIES=3
|
| 139 |
+
|
| 140 |
+
# =============================================================================
|
| 141 |
+
# 网络配置说明
|
| 142 |
+
# =============================================================================
|
| 143 |
+
|
| 144 |
+
# 在 Docker 环境中访问主机服务,请使用:
|
| 145 |
+
# - Linux: host.docker.internal
|
| 146 |
+
# - macOS: host.docker.internal
|
| 147 |
+
# - Windows: host.docker.internal
|
| 148 |
+
#
|
| 149 |
+
# 例如,如果主机上有代理服务运行在 7890 端口:
|
| 150 |
+
# HTTP_PROXY=http://host.docker.internal:7890
|
AIStudioProxyAPI/docker/Dockerfile
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dockerfile
|
| 2 |
+
|
| 3 |
+
#ARG PROXY_ADDR="http://host.docker.internal:7890" Linxux 下使用 host.docker.internal 可能会有问题,建议使用实际的代理地址
|
| 4 |
+
FROM python:3.10-slim-bookworm AS builder
|
| 5 |
+
|
| 6 |
+
ARG DEBIAN_FRONTEND=noninteractive
|
| 7 |
+
ARG PROXY_ADDR
|
| 8 |
+
|
| 9 |
+
RUN if [ -n "$PROXY_ADDR" ]; then \
|
| 10 |
+
printf 'Acquire::http::Proxy "%s";\nAcquire::https::Proxy "%s";\n' "$PROXY_ADDR" "$PROXY_ADDR" > /etc/apt/apt.conf.d/99proxy; \
|
| 11 |
+
fi && \
|
| 12 |
+
apt-get update && \
|
| 13 |
+
apt-get install -y --no-install-recommends curl \
|
| 14 |
+
&& apt-get clean && rm -rf /var/lib/apt/lists/* && \
|
| 15 |
+
if [ -n "$PROXY_ADDR" ]; then rm -f /etc/apt/apt.conf.d/99proxy; fi
|
| 16 |
+
|
| 17 |
+
ENV HTTP_PROXY=${PROXY_ADDR}
|
| 18 |
+
ENV HTTPS_PROXY=${PROXY_ADDR}
|
| 19 |
+
|
| 20 |
+
ENV POETRY_HOME="/opt/poetry"
|
| 21 |
+
ENV POETRY_VERSION=1.8.3
|
| 22 |
+
RUN curl -sSL https://install.python-poetry.org | python3 - --version ${POETRY_VERSION}
|
| 23 |
+
ENV PATH="${POETRY_HOME}/bin:${PATH}"
|
| 24 |
+
|
| 25 |
+
WORKDIR /app_builder
|
| 26 |
+
COPY pyproject.toml poetry.lock ./
|
| 27 |
+
RUN poetry config virtualenvs.create false --local && \
|
| 28 |
+
poetry install --no-root --no-dev --no-interaction --no-ansi
|
| 29 |
+
|
| 30 |
+
FROM python:3.10-slim-bookworm
|
| 31 |
+
|
| 32 |
+
ARG DEBIAN_FRONTEND=noninteractive
|
| 33 |
+
ARG PROXY_ADDR
|
| 34 |
+
|
| 35 |
+
ENV HTTP_PROXY=${PROXY_ADDR}
|
| 36 |
+
ENV HTTPS_PROXY=${PROXY_ADDR}
|
| 37 |
+
|
| 38 |
+
# 步骤 1: 安装所有系统依赖。
|
| 39 |
+
# Playwright 的依赖也在这里一并安装。
|
| 40 |
+
RUN \
|
| 41 |
+
if [ -n "$PROXY_ADDR" ]; then \
|
| 42 |
+
printf 'Acquire::http::Proxy "%s";\nAcquire::https::Proxy "%s";\n' "$PROXY_ADDR" "$PROXY_ADDR" > /etc/apt/apt.conf.d/99proxy; \
|
| 43 |
+
fi && \
|
| 44 |
+
apt-get update && \
|
| 45 |
+
apt-get install -y --no-install-recommends \
|
| 46 |
+
libatk1.0-0 libatk-bridge2.0-0 libcups2 libdbus-1-3 libdrm2 libgbm1 libgtk-3-0 libnspr4 libnss3 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libxrender1 libxtst6 ca-certificates fonts-liberation libasound2 libpangocairo-1.0-0 libpango-1.0-0 libu2f-udev \
|
| 47 |
+
supervisor curl \
|
| 48 |
+
&& \
|
| 49 |
+
# 清理工作
|
| 50 |
+
apt-get clean && \
|
| 51 |
+
rm -rf /var/lib/apt/lists/* && \
|
| 52 |
+
if [ -n "$PROXY_ADDR" ]; then rm -f /etc/apt/apt.conf.d/99proxy; fi
|
| 53 |
+
|
| 54 |
+
RUN groupadd -r appgroup && useradd -r -g appgroup -s /bin/bash -d /app appuser
|
| 55 |
+
|
| 56 |
+
WORKDIR /app
|
| 57 |
+
|
| 58 |
+
# 步骤 2: 复制 Python 包和可执行文件。
|
| 59 |
+
# 这是关键的顺序调整:在使用 playwright 之前先把它复制进来。
|
| 60 |
+
COPY --from=builder /usr/local/lib/python3.10/site-packages/ /usr/local/lib/python3.10/site-packages/
|
| 61 |
+
COPY --from=builder /usr/local/bin/ /usr/local/bin/
|
| 62 |
+
COPY --from=builder /opt/poetry/bin/poetry /usr/local/bin/poetry
|
| 63 |
+
|
| 64 |
+
# 复制应用代码
|
| 65 |
+
COPY . .
|
| 66 |
+
|
| 67 |
+
# 步骤 3: 现在 Python 模块已存在,可以安全地运行这些命令。
|
| 68 |
+
# 注意:我们不再需要 `playwright install-deps`,因为依赖已在上面的 apt-get 中安装。
|
| 69 |
+
RUN camoufox fetch && \
|
| 70 |
+
python -m playwright install firefox
|
| 71 |
+
|
| 72 |
+
# 创建目录和设置权限
|
| 73 |
+
RUN mkdir -p /app/logs && \
|
| 74 |
+
mkdir -p /app/auth_profiles/active && \
|
| 75 |
+
mkdir -p /app/auth_profiles/saved && \
|
| 76 |
+
mkdir -p /app/certs && \
|
| 77 |
+
mkdir -p /app/browser_utils/custom_scripts && \
|
| 78 |
+
mkdir -p /home/appuser/.cache/ms-playwright && \
|
| 79 |
+
mkdir -p /home/appuser/.mozilla && \
|
| 80 |
+
chown -R appuser:appgroup /app && \
|
| 81 |
+
chown -R appuser:appgroup /home/appuser
|
| 82 |
+
|
| 83 |
+
COPY supervisord.conf /etc/supervisor/conf.d/app.conf
|
| 84 |
+
|
| 85 |
+
# 修复 camoufox 缓存逻辑
|
| 86 |
+
RUN mkdir -p /var/cache/camoufox && \
|
| 87 |
+
if [ -d /root/.cache/camoufox ]; then cp -a /root/.cache/camoufox/* /var/cache/camoufox/; fi && \
|
| 88 |
+
mkdir -p /app/.cache && \
|
| 89 |
+
ln -s /var/cache/camoufox /app/.cache/camoufox
|
| 90 |
+
|
| 91 |
+
RUN python update_browserforge_data.py
|
| 92 |
+
|
| 93 |
+
# 清理代理环境变量
|
| 94 |
+
ENV HTTP_PROXY=""
|
| 95 |
+
ENV HTTPS_PROXY=""
|
| 96 |
+
|
| 97 |
+
EXPOSE 2048
|
| 98 |
+
EXPOSE 3120
|
| 99 |
+
|
| 100 |
+
USER appuser
|
| 101 |
+
ENV HOME=/app
|
| 102 |
+
ENV PLAYWRIGHT_BROWSERS_PATH=/home/appuser/.cache/ms-playwright
|
| 103 |
+
|
| 104 |
+
ENV PYTHONUNBUFFERED=1
|
| 105 |
+
|
| 106 |
+
ENV PORT=8000
|
| 107 |
+
ENV DEFAULT_FASTAPI_PORT=2048
|
| 108 |
+
ENV DEFAULT_CAMOUFOX_PORT=9222
|
| 109 |
+
ENV STREAM_PORT=3120
|
| 110 |
+
ENV SERVER_LOG_LEVEL=INFO
|
| 111 |
+
ENV DEBUG_LOGS_ENABLED=false
|
| 112 |
+
ENV AUTO_CONFIRM_LOGIN=true
|
| 113 |
+
ENV SERVER_PORT=2048
|
| 114 |
+
ENV INTERNAL_CAMOUFOX_PROXY=""
|
| 115 |
+
|
| 116 |
+
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/app.conf"]
|
AIStudioProxyAPI/docker/README-Docker.md
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Docker 部署指南 (AI Studio Proxy API)
|
| 2 |
+
|
| 3 |
+
> 📁 **注意**: 所有 Docker 相关文件现在都位于 `docker/` 目录中,保持项目根目录的整洁。
|
| 4 |
+
|
| 5 |
+
本文档提供了使用 Docker 构建和运行 AI Studio Proxy API 项目的完整指南,包括 Poetry 依赖管理、`.env` 配置管理和脚本注入功能。
|
| 6 |
+
|
| 7 |
+
## 🐳 概述
|
| 8 |
+
|
| 9 |
+
Docker 部署提供了以下优势:
|
| 10 |
+
- ✅ **环境隔离**: 容器化部署,避免环境冲突
|
| 11 |
+
- ✅ **Poetry 依赖管理**: 使用现代化的 Python 依赖管理工具
|
| 12 |
+
- ✅ **统一配置**: 基于 `.env` 文件的配置管理
|
| 13 |
+
- ✅ **版本更新无忧**: `bash update.sh` 即可完成更新
|
| 14 |
+
- ✅ **跨平台支持**: 支持 x86_64 和 ARM64 架构
|
| 15 |
+
- ✅ **配置持久化**: 认证文件和日志持久化存储
|
| 16 |
+
- ✅ **多阶段构建**: 优化镜像大小和构建速度
|
| 17 |
+
|
| 18 |
+
## 先决条件
|
| 19 |
+
|
| 20 |
+
* **Docker**: 确保您的系统已正确安装并正在运行 Docker。您可以从 [Docker 官方网站](https://www.docker.com/get-started) 下载并安装 Docker Desktop (适用于 Windows 和 macOS) 或 Docker Engine (适用于 Linux)。
|
| 21 |
+
* **项目代码**: 项目代码已下载到本地。
|
| 22 |
+
* **认证文件**: 首次运行需要在主机上完成认证文件获取,Docker环境目前仅支持日常运行。
|
| 23 |
+
|
| 24 |
+
## 🔧 Docker 环境规格
|
| 25 |
+
|
| 26 |
+
* **基础镜像**: Python 3.10-slim-bookworm (稳定且轻量)
|
| 27 |
+
* **Python版本**: 3.10 (在容器内运行,与主机Python版本无关)
|
| 28 |
+
* **依赖管理**: Poetry (现代化 Python 依赖管理)
|
| 29 |
+
* **构建方式**: 多阶段构建 (builder + runtime)
|
| 30 |
+
* **架构支持**: x86_64 和 ARM64 (Apple Silicon)
|
| 31 |
+
* **模块化设计**: 完全支持项目的模块化架构
|
| 32 |
+
* **虚拟环境**: Poetry 自动管理虚拟环境
|
| 33 |
+
|
| 34 |
+
## 1. 理解项目中的 Docker 相关文件
|
| 35 |
+
|
| 36 |
+
在项目根目录下,您会找到以下与 Docker 配置相关的文件:
|
| 37 |
+
|
| 38 |
+
* **[`Dockerfile`](./Dockerfile:1):** 这是构建 Docker 镜像的蓝图。它定义了基础镜像、依赖项安装、代码复制、端口暴露以及容器启动时执行的命令。
|
| 39 |
+
* **[`.dockerignore`](./.dockerignore:1):** 这个文件列出了在构建 Docker 镜像时应忽略的文件和目录。这有助于减小镜像大小并加快构建速度,例如排除 `.git` 目录、本地开发环境文件等。
|
| 40 |
+
* **[`supervisord.conf`](./supervisord.conf:1):** (如果项目使用 Supervisor) Supervisor 是一个进程控制系统,它允许用户在类 UNIX 操作系统上监控和控制多个进程。此配置文件定义了 Supervisor 应如何管理应用程序的进程 (例如,主服务和流服务)。
|
| 41 |
+
|
| 42 |
+
## 2. 构建 Docker 镜像
|
| 43 |
+
|
| 44 |
+
要构建 Docker 镜像,请在项目根目录下打开终端或命令行界面,然后执行以下命令:
|
| 45 |
+
|
| 46 |
+
```bash
|
| 47 |
+
# 方法 1: 使用 docker compose (推荐)
|
| 48 |
+
cd docker
|
| 49 |
+
docker compose build
|
| 50 |
+
|
| 51 |
+
# 方法 2: 直接使用 docker build (在项目根目录执行)
|
| 52 |
+
docker build -f docker/Dockerfile -t ai-studio-proxy:latest .
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
**命令解释:**
|
| 56 |
+
|
| 57 |
+
* `docker build`: 这是 Docker CLI 中用于构建镜像的命令。
|
| 58 |
+
* `-t ai-studio-proxy:latest`: `-t` 参数用于为镜像指定一个名称和可选的标签 (tag),格式为 `name:tag`。
|
| 59 |
+
* `ai-studio-proxy`: 是您为镜像选择的名称。
|
| 60 |
+
* `latest`: 是标签,通常表示这是该镜像的最新版本。您可以根据版本控制策略选择其他标签,例如 `ai-studio-proxy:1.0`。
|
| 61 |
+
* `.`: (末尾的点号) 指定了 Docker 构建上下文的路径。构建上下文是指包含 [`Dockerfile`](./Dockerfile:1) 以及构建镜像所需的所有其他文件和目录的本地文件系统路径。点号表示当前目录。Docker 守护进程会访问此路径下的文件来执行构建。
|
| 62 |
+
|
| 63 |
+
构建过程可能需要一些时间,具体取决于您的网络速度和项目依赖项的多少。成功构建后,您可以使用 `docker images` 命令查看本地已有的镜像列表,其中应包含 `ai-studio-proxy:latest`。
|
| 64 |
+
|
| 65 |
+
## 3. 运行 Docker 容器
|
| 66 |
+
|
| 67 |
+
镜像构建完成后,您可以选择以下两种方式来运行容器:
|
| 68 |
+
|
| 69 |
+
### 方式 A: 使用 Docker Compose (推荐)
|
| 70 |
+
|
| 71 |
+
Docker Compose 提供了更简洁的配置管理方式,特别适合使用 `.env` 文件:
|
| 72 |
+
|
| 73 |
+
```bash
|
| 74 |
+
# 1. 准备配置文件 (进入 docker 目录)
|
| 75 |
+
cd docker
|
| 76 |
+
cp .env.docker .env
|
| 77 |
+
# 编辑 .env 文件以适应您的需求
|
| 78 |
+
|
| 79 |
+
# 2. 使用 Docker Compose 启动 (在 docker 目录下)
|
| 80 |
+
docker compose up -d
|
| 81 |
+
|
| 82 |
+
# 3. 查看日志
|
| 83 |
+
docker compose logs -f
|
| 84 |
+
|
| 85 |
+
# 4. 停止服务
|
| 86 |
+
docker compose down
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
### 方式 B: 使用 Docker 命令
|
| 90 |
+
|
| 91 |
+
您也可以使用传统的 Docker 命令来创建并运行容器:
|
| 92 |
+
|
| 93 |
+
### 方法 1: 使用 .env 文件 (推荐)
|
| 94 |
+
|
| 95 |
+
```bash
|
| 96 |
+
docker run -d \
|
| 97 |
+
-p <宿主机_服务端口>:2048 \
|
| 98 |
+
-p <宿主机_流端口>:3120 \
|
| 99 |
+
-v "$(pwd)/../auth_profiles":/app/auth_profiles \
|
| 100 |
+
-v "$(pwd)/.env":/app/.env \
|
| 101 |
+
# 可选: 如果您想使用自己的 SSL/TLS 证书,请取消下面一行的注释���
|
| 102 |
+
# 请确保宿主机上的 'certs/' 目录存在,并且其中包含应用程序所需的证书文件。
|
| 103 |
+
# -v "$(pwd)/../certs":/app/certs \
|
| 104 |
+
--name ai-studio-proxy-container \
|
| 105 |
+
ai-studio-proxy:latest
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
### 方法 2: 使用环境变量 (传统方式)
|
| 109 |
+
|
| 110 |
+
```bash
|
| 111 |
+
docker run -d \
|
| 112 |
+
-p <宿主机_服务端口>:2048 \
|
| 113 |
+
-p <宿主机_流端口>:3120 \
|
| 114 |
+
-v "$(pwd)/../auth_profiles":/app/auth_profiles \
|
| 115 |
+
# 可选: 如果您想使用自己的 SSL/TLS 证书,请取消下面一行的注释。
|
| 116 |
+
# 请确保宿主机上的 'certs/' 目录存在,并且其中包含应用程序所需的证书文件。
|
| 117 |
+
# -v "$(pwd)/../certs":/app/certs \
|
| 118 |
+
-e PORT=8000 \
|
| 119 |
+
-e DEFAULT_FASTAPI_PORT=2048 \
|
| 120 |
+
-e DEFAULT_CAMOUFOX_PORT=9222 \
|
| 121 |
+
-e STREAM_PORT=3120 \
|
| 122 |
+
-e SERVER_LOG_LEVEL=INFO \
|
| 123 |
+
-e DEBUG_LOGS_ENABLED=false \
|
| 124 |
+
-e AUTO_CONFIRM_LOGIN=true \
|
| 125 |
+
# 可选: 如果您需要设置代理,请取消下面的注释
|
| 126 |
+
# -e HTTP_PROXY="http://your_proxy_address:port" \
|
| 127 |
+
# -e HTTPS_PROXY="http://your_proxy_address:port" \
|
| 128 |
+
# -e UNIFIED_PROXY_CONFIG="http://your_proxy_address:port" \
|
| 129 |
+
--name ai-studio-proxy-container \
|
| 130 |
+
ai-studio-proxy:latest
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
**命令解释:**
|
| 134 |
+
|
| 135 |
+
* `docker run`: 这是 Docker CLI 中用于从镜像创建并启动容器的命令。
|
| 136 |
+
* `-d`: 以“分离模式”(detached mode) 运行容器。这意味着容器将在后台运行,您的终端提示符将立即可用,而不会被容器的日志输出占用。
|
| 137 |
+
* `-p <宿主机_服务端口>:2048`: 端口映射 (Port mapping)。
|
| 138 |
+
* 此参数将宿主机的某个端口映射到容器内部的 `2048` 端口。`2048` 是应用程序主服务在容器内监听的端口。
|
| 139 |
+
* 您需要将 `<宿主机_服务端口>` 替换为您希望在宿主机上用于访问此服务的实际端口号 (例如,如果您想通过宿主机的 `8080` 端口访问服务,则使用 `-p 8080:2048`)。
|
| 140 |
+
* `-p <宿主机_流端口>:3120`: 类似地,此参数将宿主机的某个端口映射到容器内部的 `3120` 端口,这是应用程序流服务在容器内监听的端口。
|
| 141 |
+
* 您需要将 `<宿主机_流端口>` 替换为您希望在宿主机上用于访问流服务的实际端口号 (例如 `-p 8081:3120`)。
|
| 142 |
+
* `-v "$(pwd)/../auth_profiles":/app/auth_profiles`: 卷挂载 (Volume mounting)。
|
| 143 |
+
* 此参数将宿主机当前工作目录 (`$(pwd)`) 下的 `auth_profiles/` 目录挂载到容器内的 `/app/auth_profiles/` 目录。
|
| 144 |
+
* 这样做的好处是:
|
| 145 |
+
* **持久化数据:** 即使容器被删除,`auth_profiles/` 中的数据仍保留在宿主机上。
|
| 146 |
+
* **方便配置:** 您可以直接在宿主机上修改 `auth_profiles/` 中的文件,更改会实时反映到容器中 (取决于应用程序如何读取这些文件)。
|
| 147 |
+
* **重要:** 在运行命令前,请确保宿主机上的 `auth_profiles/` 目录已存在。如果应用程序期望在此目录中找到特定的配置文件,请提前准备好。
|
| 148 |
+
* `# -v "$(pwd)/../certs":/app/certs` (可选,已注释): 挂载自定义证书。
|
| 149 |
+
* 如果您希望应用程序使用您自己的 SSL/TLS 证书而不是自动生成的证书,可以取消此行的注释。
|
| 150 |
+
* 它会将宿主机当前工作目录下的 `certs/` 目录挂载到容器内的 `/app/certs/` 目录。
|
| 151 |
+
* **重要:** 如果启用此选项,请确保宿主机上的 `certs/` 目录存在,并且其中包含应用程序所需的证书文件 (通常是 `server.crt` 和 `server.key` 或类似名称的文件)。应用程序也需要被配置为从 `/app/certs/` 读取这些证书。
|
| 152 |
+
* `-e SERVER_PORT=2048`: 设置环境变量。
|
| 153 |
+
* `-e` 参数用于在容器内设置环境变量。
|
| 154 |
+
* 这里,我们将 `SERVER_PORT` 环境变量设置为 `2048`。应用程序在容器内会读取此变量来确定其主服务应监听哪个端口。这应与 [`Dockerfile`](./Dockerfile:1) 中 `EXPOSE` 指令以及 [`supervisord.conf`](./supervisord.conf:1) (如果使用) 中的配置相匹配。
|
| 155 |
+
* `-e STREAM_PORT=3120`: 类似地,设置 `STREAM_PORT` 环境变量为 `3120`,供应用程序的流服务使用。
|
| 156 |
+
* `# -e INTERNAL_CAMOUFOX_PROXY="http://your_proxy_address:port"` (可选,已注释): 设置内部 Camoufox 代理。
|
| 157 |
+
* 如果您的应用程序需要通过一个特定的内部代理服务器来访问 Camoufox 或其他外部服务,可以取消此行的注释,并将 `"http://your_proxy_address:port"` 替换为实际的代理服务器地址和端口 (例如 `http://10.0.0.5:7890` 或 `socks5://proxy-user:proxy-pass@10.0.0.10:1080`)。
|
| 158 |
+
* `--name ai-studio-proxy-container`: 为正在运行的容器指定一个名称。
|
| 159 |
+
* 这使得管理容器更加方便。例如,您可以使用 `docker stop ai-studio-proxy-container` 来停止这个容器,或使用 `docker logs ai-studio-proxy-container` 来查看其日志。
|
| 160 |
+
* 如果您不指定名称,Docker 会自动为容器生成一个随机名称。
|
| 161 |
+
* `ai-studio-proxy:latest`: 指定要运行的镜像的名称和标签。这必须与您在 `docker build` 命令中使用的名称和标签相匹配。
|
| 162 |
+
|
| 163 |
+
**首次运行前的重要准备:**
|
| 164 |
+
|
| 165 |
+
### 配置文件准备
|
| 166 |
+
|
| 167 |
+
1. **创建 `.env` 配置文件 (推荐):**
|
| 168 |
+
```bash
|
| 169 |
+
# 复制配置模板 (在项目 docker 目录下执行)
|
| 170 |
+
cp .env.docker .env
|
| 171 |
+
|
| 172 |
+
# 编辑配置文件
|
| 173 |
+
nano .env # 或使用其他编辑器
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
**`.env` 文件的优势:**
|
| 177 |
+
- ✅ **版本更新无忧**: 一个 `git pull` 就完成更新,无需重新配置
|
| 178 |
+
- ✅ **配置集中管理**: 所有配置项统一在 `.env` 文件中
|
| 179 |
+
- ✅ **Docker 兼容**: 容器会自动读取挂载的 `.env` 文件
|
| 180 |
+
- ✅ **安全性**: `.env` 文件已被 `.gitignore` 忽略,不会泄露配置
|
| 181 |
+
|
| 182 |
+
2. **创建 `auth_profiles/` 目录:** 在项目根目录下 (与 [`Dockerfile`](./Dockerfile:1) 同级),手动创建一个名为 `auth_profiles` 的目录。如果您的应用程序需要初始的认证配置文件,请将它们放入此目录中。
|
| 183 |
+
|
| 184 |
+
3. **(可选) 创建 `certs/` 目录:** 如果您计划使用自己的证书并取消了相关卷挂载行的注释,请在项目根目录下创建一个名为 `certs` 的目录,并将您的证书文件 (例如 `server.crt`, `server.key`) 放入其中。
|
| 185 |
+
|
| 186 |
+
## 4. 环境变量配置详解
|
| 187 |
+
|
| 188 |
+
### 使用 .env 文件配置 (推荐)
|
| 189 |
+
|
| 190 |
+
项目现在支持通过 `.env` 文件进行配置管理。在 Docker 环境中,您只需要将 `.env` 文件挂载到容器中即可:
|
| 191 |
+
|
| 192 |
+
```bash
|
| 193 |
+
# 挂载 .env 文件到容器
|
| 194 |
+
-v "$(pwd)/.env":/app/.env
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
### 常用配置项
|
| 198 |
+
|
| 199 |
+
以下是 Docker 环境中常用的配置项:
|
| 200 |
+
|
| 201 |
+
```env
|
| 202 |
+
# 服务端口配置
|
| 203 |
+
PORT=8000
|
| 204 |
+
DEFAULT_FASTAPI_PORT=2048
|
| 205 |
+
DEFAULT_CAMOUFOX_PORT=9222
|
| 206 |
+
STREAM_PORT=3120
|
| 207 |
+
|
| 208 |
+
# 代理配置
|
| 209 |
+
HTTP_PROXY=http://127.0.0.1:7890
|
| 210 |
+
HTTPS_PROXY=http://127.0.0.1:7890
|
| 211 |
+
UNIFIED_PROXY_CONFIG=http://127.0.0.1:7890
|
| 212 |
+
|
| 213 |
+
# 日志配置
|
| 214 |
+
SERVER_LOG_LEVEL=INFO
|
| 215 |
+
DEBUG_LOGS_ENABLED=false
|
| 216 |
+
TRACE_LOGS_ENABLED=false
|
| 217 |
+
|
| 218 |
+
# 认证配置
|
| 219 |
+
AUTO_CONFIRM_LOGIN=true
|
| 220 |
+
AUTO_SAVE_AUTH=false
|
| 221 |
+
AUTH_SAVE_TIMEOUT=30
|
| 222 |
+
|
| 223 |
+
# 脚本注入配置 v3.0 (重大升级)
|
| 224 |
+
ENABLE_SCRIPT_INJECTION=true
|
| 225 |
+
USERSCRIPT_PATH=browser_utils/more_modles.js
|
| 226 |
+
# 注意:MODEL_CONFIG_PATH 已废弃,现在直接从油猴脚本解析模型数据
|
| 227 |
+
# v3.0 使用 Playwright 原生网络拦截,100% 可靠
|
| 228 |
+
|
| 229 |
+
# API 默认参数
|
| 230 |
+
DEFAULT_TEMPERATURE=1.0
|
| 231 |
+
DEFAULT_MAX_OUTPUT_TOKENS=65536
|
| 232 |
+
DEFAULT_TOP_P=0.95
|
| 233 |
+
```
|
| 234 |
+
|
| 235 |
+
### 配置优先级
|
| 236 |
+
|
| 237 |
+
在 Docker 环境中,配置的优先级顺序为:
|
| 238 |
+
|
| 239 |
+
1. **Docker 运行时环境变量** (`-e` 参数) - 最高优先级
|
| 240 |
+
2. **挂载的 .env 文件** - 中等优先级
|
| 241 |
+
3. **Dockerfile 中的 ENV** - 最低优先级
|
| 242 |
+
|
| 243 |
+
### 示例:完整的 Docker 运行命令
|
| 244 |
+
|
| 245 |
+
```bash
|
| 246 |
+
# 使用 .env 文件的完整示例
|
| 247 |
+
docker run -d \
|
| 248 |
+
-p 8080:2048 \
|
| 249 |
+
-p 8081:3120 \
|
| 250 |
+
-v "$(pwd)/../auth_profiles":/app/auth_profiles \
|
| 251 |
+
-v "$(pwd)/.env":/app/.env \
|
| 252 |
+
--name ai-studio-proxy-container \
|
| 253 |
+
ai-studio-proxy:latest
|
| 254 |
+
```
|
| 255 |
+
|
| 256 |
+
## 5. 管理正在运行的容器
|
| 257 |
+
|
| 258 |
+
一旦容器启动,您可以使用以下 Docker 命令来管理它:
|
| 259 |
+
|
| 260 |
+
* **查看正在运行的容器:**
|
| 261 |
+
```bash
|
| 262 |
+
docker ps
|
| 263 |
+
```
|
| 264 |
+
(如果您想查看所有容器,包括已停止的,请使用 `docker ps -a`)
|
| 265 |
+
|
| 266 |
+
* **查看容器日志:**
|
| 267 |
+
```bash
|
| 268 |
+
docker logs ai-studio-proxy-container
|
| 269 |
+
```
|
| 270 |
+
(如果您想持续跟踪日志输出,可以使用 `-f` 参数: `docker logs -f ai-studio-proxy-container`)
|
| 271 |
+
|
| 272 |
+
* **停止容器:**
|
| 273 |
+
```bash
|
| 274 |
+
docker stop ai-studio-proxy-container
|
| 275 |
+
```
|
| 276 |
+
|
| 277 |
+
* **启动已停止的容器:**
|
| 278 |
+
```bash
|
| 279 |
+
docker start ai-studio-proxy-container
|
| 280 |
+
```
|
| 281 |
+
|
| 282 |
+
* **重启容器:**
|
| 283 |
+
```bash
|
| 284 |
+
docker restart ai-studio-proxy-container
|
| 285 |
+
```
|
| 286 |
+
|
| 287 |
+
* **进入容器内部 (获取一个交互式 shell):**
|
| 288 |
+
```bash
|
| 289 |
+
docker exec -it ai-studio-proxy-container /bin/bash
|
| 290 |
+
```
|
| 291 |
+
(或者 `/bin/sh`,取决于容器基础镜像中可用的 shell。这对于调试非常有用。)
|
| 292 |
+
|
| 293 |
+
* **删除容器:**
|
| 294 |
+
首先需要停止容器,然后才能删除它。
|
| 295 |
+
```bash
|
| 296 |
+
docker stop ai-studio-proxy-container
|
| 297 |
+
docker rm ai-studio-proxy-container
|
| 298 |
+
```
|
| 299 |
+
(如果您想强制删除正在运行的容器,可以使用 `docker rm -f ai-studio-proxy-container`,但不建议这样做,除非您知道自己在做什么。)
|
| 300 |
+
|
| 301 |
+
## 5. 更新应用程序
|
| 302 |
+
|
| 303 |
+
当您更新了应用程序代码并希望部署新版本时,通常需要执行以下步骤:
|
| 304 |
+
|
| 305 |
+
1. **停止并删除旧的容器** (如果它正在使用相同的端口或名称):
|
| 306 |
+
```bash
|
| 307 |
+
docker stop ai-studio-proxy-container
|
| 308 |
+
docker rm ai-studio-proxy-container
|
| 309 |
+
```
|
| 310 |
+
2. **重新构建 Docker 镜像** (确保您在包含最新代码和 [`Dockerfile`](./Dockerfile:1) 的目录中):
|
| 311 |
+
```bash
|
| 312 |
+
docker build -t ai-studio-proxy:latest .
|
| 313 |
+
```
|
| 314 |
+
3. **使用新的镜像运行新的容���** (使用与之前相同的 `docker run` 命令,或根据需要进行调整):
|
| 315 |
+
```bash
|
| 316 |
+
docker run -d \
|
| 317 |
+
-p <宿主机_服务端口>:2048 \
|
| 318 |
+
# ... (其他参数与之前相同) ...
|
| 319 |
+
--name ai-studio-proxy-container \
|
| 320 |
+
ai-studio-proxy:latest
|
| 321 |
+
```
|
| 322 |
+
|
| 323 |
+
## 6. 清理
|
| 324 |
+
|
| 325 |
+
* **删除指定的 Docker 镜像:**
|
| 326 |
+
```bash
|
| 327 |
+
docker rmi ai-studio-proxy:latest
|
| 328 |
+
```
|
| 329 |
+
(注意:如果存在基于此镜像的容器,您需要先删除这些容器。)
|
| 330 |
+
|
| 331 |
+
* **删除所有未使用的 (悬空) 镜像、容器、网络和卷:**
|
| 332 |
+
```bash
|
| 333 |
+
docker system prune
|
| 334 |
+
```
|
| 335 |
+
(如果想删除所有未使用的镜像,不仅仅是悬空的,可以使用 `docker system prune -a`)
|
| 336 |
+
**警告:** `prune` 命令会删除数据,请谨慎使用。
|
| 337 |
+
|
| 338 |
+
希望本教程能帮助您成功地通过 Docker 部署和运行 AI Studio Proxy API 项目!
|
| 339 |
+
|
| 340 |
+
## 脚本注入配置 (v3.0 新功能) 🆕
|
| 341 |
+
|
| 342 |
+
### 概述
|
| 343 |
+
|
| 344 |
+
Docker 环境完全支持最新的脚本注入功能 v3.0,提供革命性的改进:
|
| 345 |
+
|
| 346 |
+
- **🚀 Playwright 原生拦截**: 使用 Playwright 路由拦截,100% 可靠性
|
| 347 |
+
- **🔄 双重保障机制**: 网络拦截 + 脚本注入,确保万无一失
|
| 348 |
+
- **📝 直接脚本解析**: 从油猴脚本中自动解析模型列表,无需配置文件
|
| 349 |
+
- **🔗 前后端同步**: 前端和后端使用相同的模型数据源,100%一致
|
| 350 |
+
- **⚙️ 零配置维护**: 无需手动维护模型配置文件,脚本更新自动生效
|
| 351 |
+
- **🔄 自动适配**: 油猴脚本更新时无需手动更新配置
|
| 352 |
+
|
| 353 |
+
### 配置选项
|
| 354 |
+
|
| 355 |
+
在 `.env` 文件中配置以下选项:
|
| 356 |
+
|
| 357 |
+
```env
|
| 358 |
+
# 是否启用脚本注入功能
|
| 359 |
+
ENABLE_SCRIPT_INJECTION=true
|
| 360 |
+
|
| 361 |
+
# 油猴脚本文件路径(容器内路径)
|
| 362 |
+
# 模型数据直接从此脚本文件中解析,无需额外配置文件
|
| 363 |
+
USERSCRIPT_PATH=browser_utils/more_modles.js
|
| 364 |
+
```
|
| 365 |
+
|
| 366 |
+
### 自定义脚本和模型配置
|
| 367 |
+
|
| 368 |
+
如果您想使用自定义的脚本或模型配置:
|
| 369 |
+
|
| 370 |
+
1. **自定义脚本配置**:
|
| 371 |
+
```bash
|
| 372 |
+
# 在主机上创建自定义脚本文件
|
| 373 |
+
cp browser_utils/more_modles.js browser_utils/my_script.js
|
| 374 |
+
# 编辑 my_script.js 中的 MODELS_TO_INJECT 数组
|
| 375 |
+
|
| 376 |
+
# 在 docker-compose.yml 中取消注释并修改挂载行:
|
| 377 |
+
# - ../browser_utils/my_script.js:/app/browser_utils/more_modles.js:ro
|
| 378 |
+
|
| 379 |
+
# 或者在 .env 中修改路径:
|
| 380 |
+
# USERSCRIPT_PATH=browser_utils/my_script.js
|
| 381 |
+
```
|
| 382 |
+
|
| 383 |
+
2. **自定义脚本**:
|
| 384 |
+
```bash
|
| 385 |
+
# 将自定义脚本放在 browser_utils/ 目录
|
| 386 |
+
cp your_custom_script.js browser_utils/custom_script.js
|
| 387 |
+
|
| 388 |
+
# 在 .env 中修改路径:
|
| 389 |
+
# USERSCRIPT_PATH=browser_utils/custom_script.js
|
| 390 |
+
```
|
| 391 |
+
|
| 392 |
+
### Docker Compose 挂载配置
|
| 393 |
+
|
| 394 |
+
在 `docker-compose.yml` 中,您可以取消注释以下行来挂载自定义文件:
|
| 395 |
+
|
| 396 |
+
```yaml
|
| 397 |
+
volumes:
|
| 398 |
+
# 挂载自定义模型配置
|
| 399 |
+
- ../browser_utils/model_configs.json:/app/browser_utils/model_configs.json:ro
|
| 400 |
+
# 挂载自定义脚本目录
|
| 401 |
+
- ../browser_utils/custom_scripts:/app/browser_utils/custom_scripts:ro
|
| 402 |
+
```
|
| 403 |
+
|
| 404 |
+
### 注意事项
|
| 405 |
+
|
| 406 |
+
- 脚本或配置文件更新后需要重启容器
|
| 407 |
+
- 如果脚本注入失败,不会影响主要功能
|
| 408 |
+
- 可以通过容器日志查看脚本注入状态
|
| 409 |
+
|
| 410 |
+
## 注意事项
|
| 411 |
+
|
| 412 |
+
1. **认证文件**: Docker 部署需要预先在主机上获取有效的认证文件,并将其放置在 `auth_profiles/active/` 目录中。
|
| 413 |
+
2. **模块化架构**: 项目采用模块化设计,所有配置和代码都已经过优化,无需手动修改。
|
| 414 |
+
3. **端口配置**: 确保宿主机上的端口未被占用,默认使用 2048 (主服务) 和 3120 (流式代理)。
|
| 415 |
+
4. **日志查看**: 可以通过 `docker logs` 命令查看容器运行日志,便于调试和监控。
|
| 416 |
+
5. **脚本注入**: 新增的脚本注入功能默认启用,可通过 `ENABLE_SCRIPT_INJECTION=false` 禁用。
|
| 417 |
+
|
| 418 |
+
## 配置管理总结 ⭐
|
| 419 |
+
|
| 420 |
+
### 新功能:统一的 .env 配置
|
| 421 |
+
|
| 422 |
+
现在 Docker 部署完全支持 `.env` 文件配置管理:
|
| 423 |
+
|
| 424 |
+
✅ **统一配置**: 使用 `.env` 文件管理所有配置
|
| 425 |
+
✅ **版本更新无忧**: `git pull` + `docker compose up -d` 即可完成更新
|
| 426 |
+
✅ **配置隔离**: 开发、测试、生产环境可使用不同的 `.env` 文件
|
| 427 |
+
✅ **安全性**: `.env` 文件不会被提交到版本控制
|
| 428 |
+
|
| 429 |
+
### 推荐的 Docker 工作流程
|
| 430 |
+
|
| 431 |
+
```bash
|
| 432 |
+
# 1. 初始设置
|
| 433 |
+
git clone <repository>
|
| 434 |
+
cd <project>/docker
|
| 435 |
+
cp .env.docker .env
|
| 436 |
+
# 编辑 .env 文件
|
| 437 |
+
|
| 438 |
+
# 2. 启动服务
|
| 439 |
+
docker compose up -d
|
| 440 |
+
|
| 441 |
+
# 3. 版本更新
|
| 442 |
+
bash update.sh
|
| 443 |
+
|
| 444 |
+
# 4. 查看状态
|
| 445 |
+
docker compose ps
|
| 446 |
+
docker compose logs -f
|
| 447 |
+
```
|
| 448 |
+
|
| 449 |
+
### 配置文件说明
|
| 450 |
+
|
| 451 |
+
- **`.env`**: 您的实际配置文件 (从 `.env.docker` 复制并修改)
|
| 452 |
+
- **`.env.docker`**: Docker 环境的配置模板
|
| 453 |
+
- **`.env.example`**: 通用配置模板 (适用于所有环境)
|
| 454 |
+
- **`docker-compose.yml`**: Docker Compose 配置文件
|
| 455 |
+
|
| 456 |
+
这样的配置管理方式确保了 Docker 部署与本地开发的一致性���同时简化了配置和更新流程。
|
AIStudioProxyAPI/docker/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Docker 部署文件
|
| 2 |
+
|
| 3 |
+
这个目录包含了 AI Studio Proxy API 项目的所有 Docker 相关文件。
|
| 4 |
+
|
| 5 |
+
## 📁 文件说明
|
| 6 |
+
|
| 7 |
+
- **`Dockerfile`** - Docker 镜像构建文件
|
| 8 |
+
- **`docker-compose.yml`** - Docker Compose 配置文件
|
| 9 |
+
- **`.env.docker`** - Docker 环境配置模板
|
| 10 |
+
- **`README-Docker.md`** - 详细的 Docker 部署指南
|
| 11 |
+
|
| 12 |
+
## 🚀 快速开始
|
| 13 |
+
|
| 14 |
+
### 1. 准备配置文件
|
| 15 |
+
|
| 16 |
+
```bash
|
| 17 |
+
# 进入 docker 目录
|
| 18 |
+
cp .env.docker .env
|
| 19 |
+
nano .env # 编辑配置文件
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
### 2. 启动服务
|
| 23 |
+
|
| 24 |
+
```bash
|
| 25 |
+
# 进入 docker 目录
|
| 26 |
+
cd docker
|
| 27 |
+
|
| 28 |
+
# 构建并启动服务
|
| 29 |
+
docker compose up -d
|
| 30 |
+
|
| 31 |
+
# 查看日志
|
| 32 |
+
docker compose logs -f
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
### 3. 版本更新
|
| 36 |
+
|
| 37 |
+
```bash
|
| 38 |
+
# 在 docker 目录下
|
| 39 |
+
bash update.sh
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
## 📖 详细文档
|
| 43 |
+
|
| 44 |
+
完整的 Docker 部署指南请参见:[README-Docker.md](README-Docker.md)
|
| 45 |
+
|
| 46 |
+
## 🔧 常用命令
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
# 查看服务状态
|
| 50 |
+
docker compose ps
|
| 51 |
+
|
| 52 |
+
# 查看日志
|
| 53 |
+
docker compose logs -f
|
| 54 |
+
|
| 55 |
+
# 停止服务
|
| 56 |
+
docker compose down
|
| 57 |
+
|
| 58 |
+
# 重启服务
|
| 59 |
+
docker compose restart
|
| 60 |
+
|
| 61 |
+
# 进入容器
|
| 62 |
+
docker compose exec ai-studio-proxy /bin/bash
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
## 🌟 主要优势
|
| 66 |
+
|
| 67 |
+
- ✅ **统一配置**: 使用 `.env` 文件管理所有配置
|
| 68 |
+
- ✅ **版本更新无忧**: `bash update.sh` 即可完成更新
|
| 69 |
+
- ✅ **环境隔离**: 容器化部署,避免环境冲突
|
| 70 |
+
- ✅ **配置持久化**: 认证文件和日志持久化存储
|
| 71 |
+
|
| 72 |
+
## ⚠️ 注意事项
|
| 73 |
+
|
| 74 |
+
1. **认证文件**: 首次运行需要在主机上获取认证文件
|
| 75 |
+
2. **端口配置**: 确保主机端口未被占用
|
| 76 |
+
3. **配置文件**: `.env` 文件需要放在 `docker/` 目录下,确保正确获取环境变量
|
| 77 |
+
4. **目录结构**: Docker 文件已移至 `docker/` 目录,保持项目根目录整洁
|
AIStudioProxyAPI/docker/SCRIPT_INJECTION_DOCKER.md
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Docker 环境脚本注入配置指南
|
| 2 |
+
|
| 3 |
+
## 概述
|
| 4 |
+
|
| 5 |
+
本指南专门针对 Docker 环境中的油猴脚本注入功能配置。
|
| 6 |
+
|
| 7 |
+
## 快速开始
|
| 8 |
+
|
| 9 |
+
### 1. 基础配置
|
| 10 |
+
|
| 11 |
+
```bash
|
| 12 |
+
# 进入 docker 目录
|
| 13 |
+
cd docker
|
| 14 |
+
|
| 15 |
+
# 复制配置模板
|
| 16 |
+
cp .env.docker .env
|
| 17 |
+
|
| 18 |
+
# 编辑配置文件
|
| 19 |
+
nano .env
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
在 `.env` 文件中确保以下配置:
|
| 23 |
+
|
| 24 |
+
```env
|
| 25 |
+
# 启用脚本注入
|
| 26 |
+
ENABLE_SCRIPT_INJECTION=true
|
| 27 |
+
|
| 28 |
+
# 使用默认脚本(模型数据直接从脚本解析)
|
| 29 |
+
USERSCRIPT_PATH=browser_utils/more_modles.js
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
### 2. 启动容器
|
| 33 |
+
|
| 34 |
+
```bash
|
| 35 |
+
# 构建并启动
|
| 36 |
+
docker compose up -d
|
| 37 |
+
|
| 38 |
+
# 查看日志确认脚本注入状态
|
| 39 |
+
docker compose logs -f | grep "脚本注入"
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
## 自定义配置
|
| 43 |
+
|
| 44 |
+
### 方法 1: 直接替换脚本文件
|
| 45 |
+
|
| 46 |
+
```bash
|
| 47 |
+
# 1. 创建自定义油猴脚本
|
| 48 |
+
cp ../browser_utils/more_modles.js ../browser_utils/my_custom_script.js
|
| 49 |
+
|
| 50 |
+
# 2. 编辑脚本文件中的 MODELS_TO_INJECT 数组
|
| 51 |
+
nano ../browser_utils/my_custom_script.js
|
| 52 |
+
|
| 53 |
+
# 3. 重启容器
|
| 54 |
+
docker compose restart
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
### 方法 2: 挂载自定义脚本
|
| 58 |
+
|
| 59 |
+
```bash
|
| 60 |
+
# 1. 创建自定义脚本文件
|
| 61 |
+
cp ../browser_utils/more_modles.js ../browser_utils/my_script.js
|
| 62 |
+
|
| 63 |
+
# 2. 编辑 docker-compose.yml,取消注释并修改:
|
| 64 |
+
# volumes:
|
| 65 |
+
# - ../browser_utils/my_script.js:/app/browser_utils/more_modles.js:ro
|
| 66 |
+
|
| 67 |
+
# 3. 重启服务
|
| 68 |
+
docker compose down
|
| 69 |
+
docker compose up -d
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
### 方法 3: 环境变量配置
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
# 1. 在 .env 文件中修改路径
|
| 76 |
+
echo "USERSCRIPT_PATH=browser_utils/my_custom_script.js" >> .env
|
| 77 |
+
|
| 78 |
+
# 2. 创建对应的脚本文件
|
| 79 |
+
cp ../browser_utils/more_modles.js ../browser_utils/my_custom_script.js
|
| 80 |
+
|
| 81 |
+
# 3. 重启容器
|
| 82 |
+
docker compose restart
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
## 验证脚本注入
|
| 86 |
+
|
| 87 |
+
### 检查日志
|
| 88 |
+
|
| 89 |
+
```bash
|
| 90 |
+
# 查看脚本注入相关日志
|
| 91 |
+
docker compose logs | grep -E "(脚本注入|script.*inject|模型增强)"
|
| 92 |
+
|
| 93 |
+
# 实时监控日志
|
| 94 |
+
docker compose logs -f | grep -E "(脚本注入|script.*inject|模型增强)"
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
### 预期日志输出
|
| 98 |
+
|
| 99 |
+
成功的脚本注入应该显示类似以下日志:
|
| 100 |
+
|
| 101 |
+
```
|
| 102 |
+
设置网络拦截和脚本注入...
|
| 103 |
+
成功设置模型列表网络拦截
|
| 104 |
+
成功解析 6 个模型从油猴脚本
|
| 105 |
+
添加了 6 个注入的模型到API模型列表
|
| 106 |
+
✅ 脚本注入成功,模型显示效果与油猴脚本100%一致
|
| 107 |
+
解析的模型: 👑 Kingfall, ✨ Gemini 2.5 Pro, 🦁 Goldmane...
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
### 进入容器检查
|
| 111 |
+
|
| 112 |
+
```bash
|
| 113 |
+
# 进入容器
|
| 114 |
+
docker compose exec ai-studio-proxy /bin/bash
|
| 115 |
+
|
| 116 |
+
# 检查脚本文件
|
| 117 |
+
cat /app/browser_utils/more_modles.js
|
| 118 |
+
|
| 119 |
+
# 检查脚本文件列表
|
| 120 |
+
ls -la /app/browser_utils/*.js
|
| 121 |
+
|
| 122 |
+
# 退出容器
|
| 123 |
+
exit
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
## 故障排除
|
| 127 |
+
|
| 128 |
+
### 脚本注入失败
|
| 129 |
+
|
| 130 |
+
1. **检查配置文件路径**:
|
| 131 |
+
```bash
|
| 132 |
+
docker compose exec ai-studio-proxy ls -la /app/browser_utils/
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
2. **检查文件权限**:
|
| 136 |
+
```bash
|
| 137 |
+
docker compose exec ai-studio-proxy cat /app/browser_utils/more_modles.js
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
3. **查看详细错误日志**:
|
| 141 |
+
```bash
|
| 142 |
+
docker compose logs | grep -A 5 -B 5 "脚本注入"
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
### 脚本文件无效
|
| 146 |
+
|
| 147 |
+
1. **验证 JavaScript 格式**:
|
| 148 |
+
```bash
|
| 149 |
+
# 在主机上验证 JavaScript 语法
|
| 150 |
+
node -c browser_utils/more_modles.js
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
2. **检查必需字段**:
|
| 154 |
+
确保每个模型都有 `name` 和 `displayName` 字段。
|
| 155 |
+
|
| 156 |
+
### 禁用脚本注入
|
| 157 |
+
|
| 158 |
+
如果遇到问题,可以临时禁用:
|
| 159 |
+
|
| 160 |
+
```bash
|
| 161 |
+
# 在 .env 文件中设置
|
| 162 |
+
echo "ENABLE_SCRIPT_INJECTION=false" >> .env
|
| 163 |
+
|
| 164 |
+
# 重启容器
|
| 165 |
+
docker compose restart
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
## 高级配置
|
| 169 |
+
|
| 170 |
+
### 使用自定义脚本
|
| 171 |
+
|
| 172 |
+
```bash
|
| 173 |
+
# 1. 将自定义脚本放在 browser_utils/ 目录
|
| 174 |
+
cp your_custom_script.js ../browser_utils/custom_injector.js
|
| 175 |
+
|
| 176 |
+
# 2. 在 .env 中修改脚本路径
|
| 177 |
+
echo "USERSCRIPT_PATH=browser_utils/custom_injector.js" >> .env
|
| 178 |
+
|
| 179 |
+
# 3. 重启容器
|
| 180 |
+
docker compose restart
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
### 多环境配置
|
| 184 |
+
|
| 185 |
+
```bash
|
| 186 |
+
# 开发环境
|
| 187 |
+
cp .env.docker .env.dev
|
| 188 |
+
# 编辑 .env.dev
|
| 189 |
+
|
| 190 |
+
# 生产环境
|
| 191 |
+
cp .env.docker .env.prod
|
| 192 |
+
# 编辑 .env.prod
|
| 193 |
+
|
| 194 |
+
# 使用特定环境启动
|
| 195 |
+
cp .env.prod .env
|
| 196 |
+
docker compose up -d
|
| 197 |
+
```
|
| 198 |
+
|
| 199 |
+
## 注意事项
|
| 200 |
+
|
| 201 |
+
1. **文件挂载**: 确保主机上的文件路径正确
|
| 202 |
+
2. **权限问题**: Docker 容器内的文件权限可能需要调整
|
| 203 |
+
3. **重启生效**: 配置更改后需要重启容器
|
| 204 |
+
4. **日志监控**: 通过日志确认脚本注入状态
|
| 205 |
+
5. **备份配置**: 建议备份工作的配置文件
|
| 206 |
+
|
| 207 |
+
## 示例配置文件
|
| 208 |
+
|
| 209 |
+
参考 `model_configs_docker_example.json` 文件了解完整的配置格式和选项。
|
AIStudioProxyAPI/docker/docker-compose.yml
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
ai-studio-proxy:
|
| 3 |
+
build:
|
| 4 |
+
context: ..
|
| 5 |
+
dockerfile: docker/Dockerfile
|
| 6 |
+
container_name: ai-studio-proxy-container
|
| 7 |
+
mem_limit: ${DOCKER_MEMORY_LIMIT:-0}
|
| 8 |
+
memswap_limit: ${DOCKER_MEMSWAP_LIMIT:-0}
|
| 9 |
+
ports:
|
| 10 |
+
- "${HOST_FASTAPI_PORT:-2048}:${DEFAULT_FASTAPI_PORT:-2048}"
|
| 11 |
+
- "${HOST_STREAM_PORT:-3120}:${STREAM_PORT:-3120}"
|
| 12 |
+
volumes:
|
| 13 |
+
# 挂载认证文件目录 (必需)
|
| 14 |
+
- ../auth_profiles:/app/auth_profiles
|
| 15 |
+
# 挂载 .env 配置文件 (推荐)
|
| 16 |
+
# 请将 docker/.env.docker 复制为 docker/.env 并根据需要修改
|
| 17 |
+
- ../docker/.env:/app/.env:ro
|
| 18 |
+
# 挂载日志目录 (可选,用于持久化日志)
|
| 19 |
+
# 如果出现权限报错,需要修改日志目录权限 sudo chmod -R 777 ../logs
|
| 20 |
+
# - ../logs:/app/logs
|
| 21 |
+
# 挂载自定义证书 (可选)
|
| 22 |
+
# - ../certs:/app/certs:ro
|
| 23 |
+
# 挂载脚本注入相关文件 (可选,用于自定义脚本和模型配置)
|
| 24 |
+
# 如果您有自定义的油猴脚本或模型配置,可以取消注释以下行
|
| 25 |
+
# - ../browser_utils/custom_scripts:/app/browser_utils/custom_scripts:ro
|
| 26 |
+
# - ../browser_utils/model_configs.json:/app/browser_utils/model_configs.json:ro
|
| 27 |
+
environment:
|
| 28 |
+
# 这些环境变量会覆盖 .env 文件中的设置
|
| 29 |
+
# 如果您想使用 .env 文件,可以注释掉这些行
|
| 30 |
+
- PYTHONUNBUFFERED=1
|
| 31 |
+
# - PORT=${PORT:-8000}
|
| 32 |
+
# - DEFAULT_FASTAPI_PORT=${DEFAULT_FASTAPI_PORT:-2048}
|
| 33 |
+
# - DEFAULT_CAMOUFOX_PORT=${DEFAULT_CAMOUFOX_PORT:-9222}
|
| 34 |
+
# - STREAM_PORT=${STREAM_PORT:-3120}
|
| 35 |
+
# - SERVER_LOG_LEVEL=${SERVER_LOG_LEVEL:-INFO}
|
| 36 |
+
# - DEBUG_LOGS_ENABLED=${DEBUG_LOGS_ENABLED:-false}
|
| 37 |
+
# - AUTO_CONFIRM_LOGIN=${AUTO_CONFIRM_LOGIN:-true}
|
| 38 |
+
# 代理配置 (可选)
|
| 39 |
+
# - HTTP_PROXY=${HTTP_PROXY}
|
| 40 |
+
# - HTTPS_PROXY=${HTTPS_PROXY}
|
| 41 |
+
# - UNIFIED_PROXY_CONFIG=${UNIFIED_PROXY_CONFIG}
|
| 42 |
+
restart: unless-stopped
|
| 43 |
+
healthcheck:
|
| 44 |
+
test: ["CMD", "curl", "-f", "http://localhost:${DEFAULT_FASTAPI_PORT:-2048}/health"]
|
| 45 |
+
interval: 30s
|
| 46 |
+
timeout: 10s
|
| 47 |
+
retries: 3
|
| 48 |
+
start_period: 40s
|
| 49 |
+
# 可选:如果需要特定的网络配置
|
| 50 |
+
# networks:
|
| 51 |
+
# - ai-studio-network
|
| 52 |
+
|
| 53 |
+
# 可选:自定义网络
|
| 54 |
+
# networks:
|
| 55 |
+
# ai-studio-network:
|
| 56 |
+
# driver: bridge
|
AIStudioProxyAPI/docker/update.sh
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
|
| 3 |
+
# 定义颜色变量以便复用
|
| 4 |
+
GREEN='\033[0;32m'
|
| 5 |
+
YELLOW='\033[1;33m'
|
| 6 |
+
NC='\033[0m'
|
| 7 |
+
|
| 8 |
+
set -e
|
| 9 |
+
|
| 10 |
+
echo -e "${GREEN}==> 正在更新并重启服务...${NC}"
|
| 11 |
+
|
| 12 |
+
# 获取脚本所在的目录,并切换到项目根目录
|
| 13 |
+
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)
|
| 14 |
+
cd "$SCRIPT_DIR/.."
|
| 15 |
+
|
| 16 |
+
echo -e "${YELLOW}--> 步骤 1/4: 拉取最新的代码...${NC}"
|
| 17 |
+
git pull
|
| 18 |
+
|
| 19 |
+
cd "$SCRIPT_DIR"
|
| 20 |
+
|
| 21 |
+
echo -e "${YELLOW}--> 步骤 2/4: 停止并移除旧的容器...${NC}"
|
| 22 |
+
docker compose down
|
| 23 |
+
|
| 24 |
+
echo -e "${YELLOW}--> 步骤 3/4: 使用 Docker Compose 构建并启动新容器...${NC}"
|
| 25 |
+
docker compose up -d --build
|
| 26 |
+
|
| 27 |
+
echo -e "${YELLOW}--> 步骤 4/4: 显示当前运行的容器状态...${NC}"
|
| 28 |
+
docker compose ps
|
| 29 |
+
|
| 30 |
+
echo -e "${GREEN}==> 更新完成!${NC}"
|
AIStudioProxyAPI/docs/advanced-configuration.md
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 高级配置指南
|
| 2 |
+
|
| 3 |
+
本文档介绍项目的高级配置选项和功能。
|
| 4 |
+
|
| 5 |
+
## 代理配置管理
|
| 6 |
+
|
| 7 |
+
### 代理配置优先级
|
| 8 |
+
|
| 9 |
+
项目采用统一的代理配置管理系统,按以下优先级顺序确定代理设置:
|
| 10 |
+
|
| 11 |
+
1. **`--internal-camoufox-proxy` 命令行参数** (最高优先级)
|
| 12 |
+
- 明确指定代理:`--internal-camoufox-proxy 'http://127.0.0.1:7890'`
|
| 13 |
+
- 明确禁用代理:`--internal-camoufox-proxy ''`
|
| 14 |
+
2. **`UNIFIED_PROXY_CONFIG` 环境变量** (推荐,.env 文件配置)
|
| 15 |
+
3. **`HTTP_PROXY` 环境变量**
|
| 16 |
+
4. **`HTTPS_PROXY` 环境变量**
|
| 17 |
+
5. **系统代理设置** (Linux 下的 gsettings,最低优先级)
|
| 18 |
+
|
| 19 |
+
**推荐配置方式**:
|
| 20 |
+
```env
|
| 21 |
+
# .env 文件中统一配置代理
|
| 22 |
+
UNIFIED_PROXY_CONFIG=http://127.0.0.1:7890
|
| 23 |
+
# 或禁用代理
|
| 24 |
+
UNIFIED_PROXY_CONFIG=
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
### 统一代理配置
|
| 28 |
+
|
| 29 |
+
此代理配置会同时应用于 Camoufox 浏览器和流式代理服务的上游连接,确保整个系统的代理行为一致。
|
| 30 |
+
|
| 31 |
+
## 响应获取模式配置
|
| 32 |
+
|
| 33 |
+
### 模式1: 优先使用集成的流式代理 (默认推荐)
|
| 34 |
+
|
| 35 |
+
**推荐使用 .env 配置方式**:
|
| 36 |
+
```env
|
| 37 |
+
# .env 文件配置
|
| 38 |
+
DEFAULT_FASTAPI_PORT=2048
|
| 39 |
+
STREAM_PORT=3120
|
| 40 |
+
UNIFIED_PROXY_CONFIG=
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
```bash
|
| 44 |
+
# 简化启动命令 (推荐)
|
| 45 |
+
python launch_camoufox.py --headless
|
| 46 |
+
|
| 47 |
+
# 传统命令行方式 (仍然支持)
|
| 48 |
+
python launch_camoufox.py --headless --server-port 2048 --stream-port 3120 --helper '' --internal-camoufox-proxy ''
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
# 启用统一代理配置(同时应用于浏览器和流式代理)
|
| 52 |
+
python launch_camoufox.py --headless --server-port 2048 --stream-port 3120 --helper '' --internal-camoufox-proxy 'http://127.0.0.1:7890'
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
在此模式下,主服务器会优先尝试通过端口 `3120` (或指定的 `--stream-port`) 上的集成流式代理获取响应。如果失败,则回退到 Playwright 页面交互。
|
| 56 |
+
|
| 57 |
+
### 模式2: 优先使用外部 Helper 服务 (禁用集成流式代理)
|
| 58 |
+
|
| 59 |
+
```bash
|
| 60 |
+
# 基本外部Helper模式,明确禁用代理
|
| 61 |
+
python launch_camoufox.py --headless --server-port 2048 --stream-port 0 --helper 'http://your-helper-service.com/api/getStreamResponse' --internal-camoufox-proxy ''
|
| 62 |
+
|
| 63 |
+
# 外部Helper模式 + 统一代理配置
|
| 64 |
+
python launch_camoufox.py --headless --server-port 2048 --stream-port 0 --helper 'http://your-helper-service.com/api/getStreamResponse' --internal-camoufox-proxy 'http://127.0.0.1:7890'
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
在此模式下,主服务器会优先尝试通过 `--helper` 指定的端点获取响应 (需要有效的 `auth_profiles/active/*.json` 以提取 `SAPISID`)。如果失败,则回退到 Playwright 页面交互。
|
| 68 |
+
|
| 69 |
+
### 模式3: 仅使用 Playwright 页面交互 (禁用所有流式代理和 Helper)
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
# 纯Playwright模式,明确禁用代理
|
| 73 |
+
python launch_camoufox.py --headless --server-port 2048 --stream-port 0 --helper '' --internal-camoufox-proxy ''
|
| 74 |
+
|
| 75 |
+
# Playwright模式 + 统一代理配置
|
| 76 |
+
python launch_camoufox.py --headless --server-port 2048 --stream-port 0 --helper '' --internal-camoufox-proxy 'http://127.0.0.1:7890'
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
在此模式下,主服务器将仅通过 Playwright 与 AI Studio 页面交互 (模拟点击"编辑"或"复制"按钮) 来获取响应。这是传统的后备方法。
|
| 80 |
+
|
| 81 |
+
## 虚拟显示模式 (Linux)
|
| 82 |
+
|
| 83 |
+
### 关于 `--virtual-display`
|
| 84 |
+
|
| 85 |
+
- **为什么使用**: 与标准的无头模式相比,虚拟显示模式通过创建一个完整的虚拟 X 服务器环境 (Xvfb) 来运行浏览器。这可以模拟一个更真实的桌面环境,从而可能进一步降低被网站检测为自动化脚本或机器人的风险
|
| 86 |
+
- **什么时候使用**: 当您在 Linux 环境下运行,并且希望以无头模式操作
|
| 87 |
+
- **如何使用**:
|
| 88 |
+
1. 确保您的 Linux 系统已安装 `xvfb`
|
| 89 |
+
2. 在运行时添加 `--virtual-display` 标志:
|
| 90 |
+
```bash
|
| 91 |
+
python launch_camoufox.py --virtual-display --server-port 2048 --stream-port 3120 --internal-camoufox-proxy ''
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
## 流式代理服务配置
|
| 95 |
+
|
| 96 |
+
### 自签名证书管理
|
| 97 |
+
|
| 98 |
+
集成的流式代理服务会在 `certs` 文件夹内生成自签名的根证书。
|
| 99 |
+
|
| 100 |
+
#### 证书删除与重新生成
|
| 101 |
+
|
| 102 |
+
- 可以删除 `certs` 目录下的根证书 (`ca.crt`, `ca.key`),代码会在下次启动时重新生成
|
| 103 |
+
- **重要**: 删除根证书时,**强烈建议同时删除 `certs` 目录下的所有其他文件**,避免信任链错误
|
| 104 |
+
|
| 105 |
+
#### 手动生成证书
|
| 106 |
+
|
| 107 |
+
如果需要重新生成证书,可以使用以下命令:
|
| 108 |
+
|
| 109 |
+
```bash
|
| 110 |
+
openssl genrsa -out certs/ca.key 2048
|
| 111 |
+
openssl req -new -x509 -days 3650 -key certs/ca.key -out certs/ca.crt -subj "/C=CN/ST=Shanghai/L=Shanghai/O=AiStudioProxyHelper/OU=CA/CN=AiStudioProxyHelper CA/emailAddress=ca@example.com"
|
| 112 |
+
openssl rsa -in certs/ca.key -out certs/ca.key
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
### 工作原理
|
| 116 |
+
|
| 117 |
+
流式代理服务的特性:
|
| 118 |
+
|
| 119 |
+
- 创建一个 HTTP 代理服务器(默认端口:3120)
|
| 120 |
+
- 拦截针对 Google 域名的 HTTPS 请求
|
| 121 |
+
- 使用自签名 CA 证书动态自动生成服务器证书
|
| 122 |
+
- 将 AIStudio 响应解析为 OpenAI 兼容格式
|
| 123 |
+
|
| 124 |
+
## 模型排除配置
|
| 125 |
+
|
| 126 |
+
### excluded_models.txt
|
| 127 |
+
|
| 128 |
+
项目根目录下的 `excluded_models.txt` 文件可用于从 `/v1/models` 端点返回的列表中排除特定的模型 ID。
|
| 129 |
+
|
| 130 |
+
每行一个模型ID,例如:
|
| 131 |
+
```
|
| 132 |
+
gemini-1.0-pro
|
| 133 |
+
gemini-1.0-pro-vision
|
| 134 |
+
deprecated-model-id
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
## 脚本注入高级配置 🆕
|
| 138 |
+
|
| 139 |
+
### 概述
|
| 140 |
+
|
| 141 |
+
脚本注入功能允许您动态挂载油猴脚本来增强 AI Studio 的模型列表。该功能使用 Playwright 原生网络拦截技术,确保 100% 可靠性。
|
| 142 |
+
|
| 143 |
+
### 工作原理
|
| 144 |
+
|
| 145 |
+
1. **双重拦截机制**:
|
| 146 |
+
- **Playwright 路由拦截**:在网络层面直接拦截和修改模型列表响应
|
| 147 |
+
- **JavaScript 脚本注入**:作为备用方案,确保万无一失
|
| 148 |
+
|
| 149 |
+
2. **自动模型解析**:
|
| 150 |
+
- 从油猴脚本中自动解析 `MODELS_TO_INJECT` 数组
|
| 151 |
+
- 前端和后端使用相同的模型数据源
|
| 152 |
+
- 无需手动维护模型配置文件
|
| 153 |
+
|
| 154 |
+
### 高级配置选项
|
| 155 |
+
|
| 156 |
+
#### 自定义脚本路径
|
| 157 |
+
|
| 158 |
+
```env
|
| 159 |
+
# 使用自定义脚本文件
|
| 160 |
+
USERSCRIPT_PATH=custom_scripts/my_enhanced_script.js
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
#### 自定义脚本配置
|
| 164 |
+
|
| 165 |
+
```env
|
| 166 |
+
# 使用自定义脚本文件(模型数据直接从脚本解析)
|
| 167 |
+
USERSCRIPT_PATH=configs/production_script.js
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
#### 调试模式
|
| 171 |
+
|
| 172 |
+
```env
|
| 173 |
+
# 启用详细的脚本注入日志
|
| 174 |
+
DEBUG_LOGS_ENABLED=true
|
| 175 |
+
ENABLE_SCRIPT_INJECTION=true
|
| 176 |
+
```
|
| 177 |
+
|
| 178 |
+
### 自定义脚本开发
|
| 179 |
+
|
| 180 |
+
#### 脚本格式要求
|
| 181 |
+
|
| 182 |
+
您的自定义脚本必须包含 `MODELS_TO_INJECT` 数组:
|
| 183 |
+
|
| 184 |
+
```javascript
|
| 185 |
+
const MODELS_TO_INJECT = [
|
| 186 |
+
{
|
| 187 |
+
name: 'models/your-custom-model',
|
| 188 |
+
displayName: '🚀 Your Custom Model',
|
| 189 |
+
description: 'Custom model description'
|
| 190 |
+
},
|
| 191 |
+
// 更多模型...
|
| 192 |
+
];
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
#### 脚本模型数组格式
|
| 196 |
+
|
| 197 |
+
```javascript
|
| 198 |
+
const MODELS_TO_INJECT = [
|
| 199 |
+
{
|
| 200 |
+
name: 'models/custom-model-1',
|
| 201 |
+
displayName: `🎯 Custom Model 1 (Script ${SCRIPT_VERSION})`,
|
| 202 |
+
description: `First custom model injected by script ${SCRIPT_VERSION}`
|
| 203 |
+
},
|
| 204 |
+
{
|
| 205 |
+
name: 'models/custom-model-2',
|
| 206 |
+
displayName: `⚡ Custom Model 2 (Script ${SCRIPT_VERSION})`,
|
| 207 |
+
description: `Second custom model injected by script ${SCRIPT_VERSION}`
|
| 208 |
+
}
|
| 209 |
+
];
|
| 210 |
+
```
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
### 网络拦截技术细节
|
| 214 |
+
|
| 215 |
+
#### Playwright 路由拦截
|
| 216 |
+
|
| 217 |
+
```javascript
|
| 218 |
+
// 系统会自动设置类似以下的路由拦截
|
| 219 |
+
await context.route("**/*", async (route) => {
|
| 220 |
+
const request = route.request();
|
| 221 |
+
if (request.url().includes('alkalimakersuite') &&
|
| 222 |
+
request.url().includes('ListModels')) {
|
| 223 |
+
// 拦截并修改模型列表响应
|
| 224 |
+
const response = await route.fetch();
|
| 225 |
+
const modifiedBody = await modifyModelListResponse(response);
|
| 226 |
+
await route.fulfill({ response, body: modifiedBody });
|
| 227 |
+
} else {
|
| 228 |
+
await route.continue_();
|
| 229 |
+
}
|
| 230 |
+
});
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
#### 响应修改流程
|
| 234 |
+
|
| 235 |
+
1. **请求识别**:检测包含 `alkalimakersuite` 和 `ListModels` 的请求
|
| 236 |
+
2. **响应获取**:获取原始模型列表响应
|
| 237 |
+
3. **数据解析**:解析 JSON 响应并处理反劫持前缀
|
| 238 |
+
4. **模型注入**:将自定义模型注入到响应中
|
| 239 |
+
5. **响应返回**:返回修改后的响应给浏览器
|
| 240 |
+
|
| 241 |
+
### 故障排除
|
| 242 |
+
|
| 243 |
+
#### 脚本注入失败
|
| 244 |
+
|
| 245 |
+
1. **检查脚本文件**:
|
| 246 |
+
```bash
|
| 247 |
+
# 验证脚本文件存在且可读
|
| 248 |
+
ls -la browser_utils/more_modles.js
|
| 249 |
+
cat browser_utils/more_modles.js | head -20
|
| 250 |
+
```
|
| 251 |
+
|
| 252 |
+
2. **检查日志输出**:
|
| 253 |
+
```bash
|
| 254 |
+
# 查看脚本注入相关日志
|
| 255 |
+
python launch_camoufox.py --debug | grep -i "script\|inject"
|
| 256 |
+
```
|
| 257 |
+
|
| 258 |
+
3. **验证配置**:
|
| 259 |
+
```bash
|
| 260 |
+
# 检查环境变量配置
|
| 261 |
+
grep SCRIPT .env
|
| 262 |
+
```
|
| 263 |
+
|
| 264 |
+
#### 模型未显示
|
| 265 |
+
|
| 266 |
+
1. **前端检查**:在浏览器开发者工具中查看是否有 JavaScript 错误
|
| 267 |
+
2. **后端检查**:查看 API 响应是否包含注入的模型
|
| 268 |
+
3. **网络检查**:确认网络拦截是否正常工作
|
| 269 |
+
|
| 270 |
+
### 性能优化
|
| 271 |
+
|
| 272 |
+
#### 脚本缓存
|
| 273 |
+
|
| 274 |
+
系统会自动缓存解析的模型列表,避免重复解析:
|
| 275 |
+
|
| 276 |
+
```python
|
| 277 |
+
# 系统内部缓存机制
|
| 278 |
+
if not hasattr(self, '_cached_models'):
|
| 279 |
+
self._cached_models = parse_userscript_models(script_content)
|
| 280 |
+
return self._cached_models
|
| 281 |
+
```
|
| 282 |
+
|
| 283 |
+
#### 网络拦截优化
|
| 284 |
+
|
| 285 |
+
- 只拦截必要的请求,其他请求直接通过
|
| 286 |
+
- 使用高效的 JSON 解析和序列化
|
| 287 |
+
- 最小化响应修改的开销
|
| 288 |
+
|
| 289 |
+
### 安全考虑
|
| 290 |
+
|
| 291 |
+
#### 脚本安全
|
| 292 |
+
|
| 293 |
+
- 脚本在受控的浏览器环境中执行
|
| 294 |
+
- 不会影响主机系统安全
|
| 295 |
+
- 建议只使用可信的脚本源
|
| 296 |
+
|
| 297 |
+
#### 网络安全
|
| 298 |
+
|
| 299 |
+
- 网络拦截仅限于特定的模型列表请求
|
| 300 |
+
- 不会拦截或修改其他敏感请求
|
| 301 |
+
- 所有修改都在本地进行,不会发送到外部服务器
|
| 302 |
+
|
| 303 |
+
## GUI 启动器高级功能
|
| 304 |
+
|
| 305 |
+
### 本地LLM模拟服务
|
| 306 |
+
|
| 307 |
+
GUI 集成了启动和管理一个本地LLM模拟服务的功能:
|
| 308 |
+
|
| 309 |
+
- **功能**: 监听 `11434` ���口,模拟部分 Ollama API 端点和 OpenAI 兼容的 `/v1/chat/completions` 端点
|
| 310 |
+
- **启动**: 在 GUI 的"启动选项"区域,点击"启动本地LLM模拟服务"按钮
|
| 311 |
+
- **依赖检测**: 启动前会自动检测 `localhost:2048` 端口是否可用
|
| 312 |
+
- **用途**: 主要用于测试客户端与 Ollama 或 OpenAI 兼容 API 的对接
|
| 313 |
+
|
| 314 |
+
### 端口进程管理
|
| 315 |
+
|
| 316 |
+
GUI 提供端口进程管理功能:
|
| 317 |
+
|
| 318 |
+
- 查询指定端口上当前正在运行的进程
|
| 319 |
+
- 选择并尝试停止在指定端口上找到的进程
|
| 320 |
+
- 手动输入 PID 终止进程
|
| 321 |
+
|
| 322 |
+
## 环境变量配置
|
| 323 |
+
|
| 324 |
+
### 代理配置
|
| 325 |
+
|
| 326 |
+
```bash
|
| 327 |
+
# 使用环境变量配置代理(不推荐,建议明确指定)
|
| 328 |
+
export HTTP_PROXY=http://127.0.0.1:7890
|
| 329 |
+
export HTTPS_PROXY=http://127.0.0.1:7890
|
| 330 |
+
python launch_camoufox.py --headless --server-port 2048 --stream-port 3120 --helper ''
|
| 331 |
+
```
|
| 332 |
+
|
| 333 |
+
### 日志控制
|
| 334 |
+
|
| 335 |
+
详见 [日志控制指南](logging-control.md)。
|
| 336 |
+
|
| 337 |
+
## 重要提示
|
| 338 |
+
|
| 339 |
+
### 代理配置建议
|
| 340 |
+
|
| 341 |
+
**强烈建议在所有 `launch_camoufox.py` 命令中明确指定 `--internal-camoufox-proxy` 参数,即使其值为空字符串 (`''`),以避免意外使用系统环境变量中的代理设置。**
|
| 342 |
+
|
| 343 |
+
### 参数控制限制
|
| 344 |
+
|
| 345 |
+
API 请求中的模型参数(如 `temperature`, `max_output_tokens`, `top_p`, `stop`)**仅在通过 Playwright 页面交互获取响应时生效**。当使用集成的流式代理或外部 Helper 服务时,这些参数的传递和应用方式取决于这些服务自身的实现。
|
| 346 |
+
|
| 347 |
+
### 首次访问性能
|
| 348 |
+
|
| 349 |
+
当通过流式代理首次访问一个新的 HTTPS 主机时,服务需要为该主机动态生成并签署一个新的子证书。这个过程可能会比较耗时,导致对该新主机的首次连接请求响应较慢。一旦证书生成并缓存后,后续访问同一主机将会显著加快。
|
| 350 |
+
|
| 351 |
+
## 下一步
|
| 352 |
+
|
| 353 |
+
高级配置完成后,请参考:
|
| 354 |
+
- [脚本注入指南](script_injection_guide.md) - 详细的脚本注入功能使用说明
|
| 355 |
+
- [日志控制指南](logging-control.md)
|
| 356 |
+
- [故障排除指南](troubleshooting.md)
|
AIStudioProxyAPI/docs/api-usage.md
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API 使用指南
|
| 2 |
+
|
| 3 |
+
本指南详细介绍如何使用 AI Studio Proxy API 的各种功能和端点。
|
| 4 |
+
|
| 5 |
+
## 服务器配置
|
| 6 |
+
|
| 7 |
+
代理服务器默认监听在 `http://127.0.0.1:2048`。端口可以通过以下方式配置:
|
| 8 |
+
|
| 9 |
+
- **环境变量**: 在 `.env` 文件中设置 `PORT=2048` 或 `DEFAULT_FASTAPI_PORT=2048`
|
| 10 |
+
- **命令行参数**: 使用 `--server-port` 参数
|
| 11 |
+
- **GUI 启动器**: 在图形界面中直接配置端口
|
| 12 |
+
|
| 13 |
+
推荐使用 `.env` 文件进行配置管理,详见 [环境变量配置指南](environment-configuration.md)。
|
| 14 |
+
|
| 15 |
+
## API 密钥配置
|
| 16 |
+
|
| 17 |
+
### key.txt 文件配置
|
| 18 |
+
|
| 19 |
+
项目使用 `auth_profiles/key.txt` 文件来管理 API 密钥:
|
| 20 |
+
|
| 21 |
+
**文件位置**: 项目根目录下的 `key.txt` 文件
|
| 22 |
+
|
| 23 |
+
**文件格式**: 每行一个 API 密钥,支持空行和注释
|
| 24 |
+
|
| 25 |
+
```
|
| 26 |
+
your-api-key-1
|
| 27 |
+
your-api-key-2
|
| 28 |
+
# 这是注释行,会被忽略
|
| 29 |
+
|
| 30 |
+
another-api-key
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
**自动创建**: 如果 `key.txt` 文件不存在,系统会自动创建一个空文件
|
| 34 |
+
|
| 35 |
+
### 密钥管理方法
|
| 36 |
+
|
| 37 |
+
#### 手动编辑文件
|
| 38 |
+
|
| 39 |
+
直接编辑 `key.txt` 文件添加或删除密钥:
|
| 40 |
+
|
| 41 |
+
```bash
|
| 42 |
+
# 添加密钥
|
| 43 |
+
echo "your-new-api-key" >> key.txt
|
| 44 |
+
|
| 45 |
+
# 查看当前密钥(注意安全)
|
| 46 |
+
cat key.txt
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
#### 通过 Web UI 管理
|
| 50 |
+
|
| 51 |
+
在 Web UI 的"设置"标签页中可以:
|
| 52 |
+
|
| 53 |
+
- 验证密钥有效性
|
| 54 |
+
- 查看服务器上配置的密钥列表(需要先验证)
|
| 55 |
+
- 测试特定密钥
|
| 56 |
+
|
| 57 |
+
### 密钥验证机制
|
| 58 |
+
|
| 59 |
+
**验证逻辑**:
|
| 60 |
+
|
| 61 |
+
- 如果 `key.txt` 为空或不存在,则不需要 API 密钥验证
|
| 62 |
+
- 如果配置了密钥,则所有 API 请求都需要提供有效的密钥
|
| 63 |
+
- 密钥验证支持两种认证头格式
|
| 64 |
+
|
| 65 |
+
**安全特性**:
|
| 66 |
+
|
| 67 |
+
- 密钥在日志中会被打码显示(如:`abcd****efgh`)
|
| 68 |
+
- Web UI 中的密钥列表也会打码显示
|
| 69 |
+
- 支持最小长度验证(至少 8 个字符)
|
| 70 |
+
|
| 71 |
+
## API 认证流程
|
| 72 |
+
|
| 73 |
+
### Bearer Token 认证
|
| 74 |
+
|
| 75 |
+
项目支持标准的 OpenAI 兼容认证方式:
|
| 76 |
+
|
| 77 |
+
**主要认证方式** (推荐):
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
Authorization: Bearer your-api-key
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
**备用认证方式** (向后兼容):
|
| 84 |
+
|
| 85 |
+
```bash
|
| 86 |
+
X-API-Key: your-api-key
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
### 认证行为
|
| 90 |
+
|
| 91 |
+
**无密钥配置时**:
|
| 92 |
+
|
| 93 |
+
- 所有 API 请求都不需要认证
|
| 94 |
+
- `/api/info` 端点会显示 `"api_key_required": false`
|
| 95 |
+
|
| 96 |
+
**有密钥配置时**:
|
| 97 |
+
|
| 98 |
+
- 所有 `/v1/*` 路径的 API 请求都需要有效的密钥
|
| 99 |
+
- 除外路径:`/v1/models`, `/health`, `/docs` 等公开端点
|
| 100 |
+
- 认证失败返回 `401 Unauthorized` 错误
|
| 101 |
+
|
| 102 |
+
### 客户端配置示例
|
| 103 |
+
|
| 104 |
+
#### curl 示例
|
| 105 |
+
|
| 106 |
+
```bash
|
| 107 |
+
# 使用 Bearer token
|
| 108 |
+
curl -X POST http://127.0.0.1:2048/v1/chat/completions \
|
| 109 |
+
-H "Authorization: Bearer your-api-key" \
|
| 110 |
+
-H "Content-Type: application/json" \
|
| 111 |
+
-d '{"messages": [{"role": "user", "content": "Hello"}]}'
|
| 112 |
+
|
| 113 |
+
# 使用 X-API-Key 头
|
| 114 |
+
curl -X POST http://127.0.0.1:2048/v1/chat/completions \
|
| 115 |
+
-H "X-API-Key: your-api-key" \
|
| 116 |
+
-H "Content-Type: application/json" \
|
| 117 |
+
-d '{"messages": [{"role": "user", "content": "Hello"}]}'
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
#### Python requests 示例
|
| 121 |
+
|
| 122 |
+
```python
|
| 123 |
+
import requests
|
| 124 |
+
|
| 125 |
+
headers = {
|
| 126 |
+
"Authorization": "Bearer your-api-key",
|
| 127 |
+
"Content-Type": "application/json"
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
data = {
|
| 131 |
+
"messages": [{"role": "user", "content": "Hello"}]
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
response = requests.post(
|
| 135 |
+
"http://127.0.0.1:2048/v1/chat/completions",
|
| 136 |
+
headers=headers,
|
| 137 |
+
json=data
|
| 138 |
+
)
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
## API 端点
|
| 142 |
+
|
| 143 |
+
### 聊天接口
|
| 144 |
+
|
| 145 |
+
**端点**: `POST /v1/chat/completions`
|
| 146 |
+
|
| 147 |
+
- 请求体与 OpenAI API 兼容,需要 `messages` 数组。
|
| 148 |
+
- `model` 字段现在用于指定目标模型,代理会尝试在 AI Studio 页面切换到该模型。如果为空或为代理的默认模型名,则使用 AI Studio 当前激活的模型。
|
| 149 |
+
- `stream` 字段控制流式 (`true`) 或非流式 (`false`) 输出。
|
| 150 |
+
- 现在支持 `temperature`, `max_output_tokens`, `top_p`, `stop` 等参数,代理会尝试在 AI Studio 页面上应用它们。
|
| 151 |
+
- **需要认证**: 如果配置了 API 密钥,此端点需要有效的认证头。
|
| 152 |
+
|
| 153 |
+
#### 示例 (curl, 非流式, 带参数)
|
| 154 |
+
|
| 155 |
+
```bash
|
| 156 |
+
curl -X POST http://127.0.0.1:2048/v1/chat/completions \
|
| 157 |
+
-H "Content-Type: application/json" \
|
| 158 |
+
-d '{
|
| 159 |
+
"model": "gemini-1.5-pro-latest",
|
| 160 |
+
"messages": [
|
| 161 |
+
{"role": "system", "content": "Be concise."},
|
| 162 |
+
{"role": "user", "content": "What is the capital of France?"}
|
| 163 |
+
],
|
| 164 |
+
"stream": false,
|
| 165 |
+
"temperature": 0.7,
|
| 166 |
+
"max_output_tokens": 150,
|
| 167 |
+
"top_p": 0.9,
|
| 168 |
+
"stop": ["\n\nUser:"]
|
| 169 |
+
}'
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
#### 示例 (curl, 流式, 带参数)
|
| 173 |
+
|
| 174 |
+
```bash
|
| 175 |
+
curl -X POST http://127.0.0.1:2048/v1/chat/completions \
|
| 176 |
+
-H "Content-Type: application/json" \
|
| 177 |
+
-d '{
|
| 178 |
+
"model": "gemini-pro",
|
| 179 |
+
"messages": [
|
| 180 |
+
{"role": "user", "content": "Write a short story about a cat."}
|
| 181 |
+
],
|
| 182 |
+
"stream": true,
|
| 183 |
+
"temperature": 0.9,
|
| 184 |
+
"top_p": 0.95,
|
| 185 |
+
"stop": []
|
| 186 |
+
}' --no-buffer
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
#### 示例 (Python requests)
|
| 190 |
+
|
| 191 |
+
```python
|
| 192 |
+
import requests
|
| 193 |
+
import json
|
| 194 |
+
|
| 195 |
+
API_URL = "http://127.0.0.1:2048/v1/chat/completions"
|
| 196 |
+
headers = {"Content-Type": "application/json"}
|
| 197 |
+
data = {
|
| 198 |
+
"model": "gemini-1.5-flash-latest",
|
| 199 |
+
"messages": [
|
| 200 |
+
{"role": "user", "content": "Translate 'hello' to Spanish."}
|
| 201 |
+
],
|
| 202 |
+
"stream": False, # or True for streaming
|
| 203 |
+
"temperature": 0.5,
|
| 204 |
+
"max_output_tokens": 100,
|
| 205 |
+
"top_p": 0.9,
|
| 206 |
+
"stop": ["\n\nHuman:"]
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
response = requests.post(API_URL, headers=headers, json=data, stream=data["stream"])
|
| 210 |
+
|
| 211 |
+
if data["stream"]:
|
| 212 |
+
for line in response.iter_lines():
|
| 213 |
+
if line:
|
| 214 |
+
decoded_line = line.decode('utf-8')
|
| 215 |
+
if decoded_line.startswith('data: '):
|
| 216 |
+
content = decoded_line[len('data: '):]
|
| 217 |
+
if content.strip() == '[DONE]':
|
| 218 |
+
print("\nStream finished.")
|
| 219 |
+
break
|
| 220 |
+
try:
|
| 221 |
+
chunk = json.loads(content)
|
| 222 |
+
delta = chunk.get('choices', [{}])[0].get('delta', {})
|
| 223 |
+
print(delta.get('content', ''), end='', flush=True)
|
| 224 |
+
except json.JSONDecodeError:
|
| 225 |
+
print(f"\nError decoding JSON: {content}")
|
| 226 |
+
elif decoded_line.startswith('data: {'): # Handle potential error JSON
|
| 227 |
+
try:
|
| 228 |
+
error_data = json.loads(decoded_line[len('data: '):])
|
| 229 |
+
if 'error' in error_data:
|
| 230 |
+
print(f"\nError from server: {error_data['error']}")
|
| 231 |
+
break
|
| 232 |
+
except json.JSONDecodeError:
|
| 233 |
+
print(f"\nError decoding error JSON: {decoded_line}")
|
| 234 |
+
else:
|
| 235 |
+
if response.status_code == 200:
|
| 236 |
+
print(json.dumps(response.json(), indent=2))
|
| 237 |
+
else:
|
| 238 |
+
print(f"Error: {response.status_code}\n{response.text}")
|
| 239 |
+
```
|
| 240 |
+
|
| 241 |
+
### 模型列表
|
| 242 |
+
|
| 243 |
+
**端点**: `GET /v1/models`
|
| 244 |
+
|
| 245 |
+
- 返回 AI Studio 页面上检测到的可用模型列表,以及一个代理本身的默认模型条目。
|
| 246 |
+
- 现在会尝试从 AI Studio 动态获取模型列表。如果获取失败,会返回一个后备模型。
|
| 247 |
+
- 支持 [`excluded_models.txt`](../excluded_models.txt) 文件,用于从列表中排除特定的模型 ID。
|
| 248 |
+
- **🆕 脚本注入模型**: 如果启用了脚本注入功能,列表中还会包含通过油猴脚本注入的自定义模型,这些模型会标记为 `"injected": true`。
|
| 249 |
+
|
| 250 |
+
**脚本注入模型特点**:
|
| 251 |
+
|
| 252 |
+
- 模型 ID 格式:注入的模型会自动移除 `models/` 前缀,如 `models/kingfall-ab-test` 变为 `kingfall-ab-test`
|
| 253 |
+
- 标识字段:包含 `"injected": true` 字段用于识别
|
| 254 |
+
- 所有者标识:`"owned_by": "ai_studio_injected"`
|
| 255 |
+
- 完全兼容:可以像普通模型一样通过 API 调用
|
| 256 |
+
|
| 257 |
+
**示例响应**:
|
| 258 |
+
|
| 259 |
+
```json
|
| 260 |
+
{
|
| 261 |
+
"object": "list",
|
| 262 |
+
"data": [
|
| 263 |
+
{
|
| 264 |
+
"id": "kingfall-ab-test",
|
| 265 |
+
"object": "model",
|
| 266 |
+
"created": 1703123456,
|
| 267 |
+
"owned_by": "ai_studio_injected",
|
| 268 |
+
"display_name": "👑 Kingfall",
|
| 269 |
+
"description": "Kingfall model - Advanced reasoning capabilities",
|
| 270 |
+
"injected": true
|
| 271 |
+
}
|
| 272 |
+
]
|
| 273 |
+
}
|
| 274 |
+
```
|
| 275 |
+
|
| 276 |
+
### API 信息
|
| 277 |
+
|
| 278 |
+
**端点**: `GET /api/info`
|
| 279 |
+
|
| 280 |
+
- 返回 API 配置信息,如基础 URL 和模型名称。
|
| 281 |
+
|
| 282 |
+
### 健康检查
|
| 283 |
+
|
| 284 |
+
**端点**: `GET /health`
|
| 285 |
+
|
| 286 |
+
- 返回服务器运行状态(Playwright, 浏览器连接, 页面状态, Worker 状态, 队列长度)。
|
| 287 |
+
|
| 288 |
+
### 队列状态
|
| 289 |
+
|
| 290 |
+
**端点**: `GET /v1/queue`
|
| 291 |
+
|
| 292 |
+
- 返回当前请求队列的详细信息。
|
| 293 |
+
|
| 294 |
+
### 取消请求
|
| 295 |
+
|
| 296 |
+
**端点**: `POST /v1/cancel/{req_id}`
|
| 297 |
+
|
| 298 |
+
- 尝试取消仍在队列中等待处理的请求。
|
| 299 |
+
|
| 300 |
+
### API 密钥管理端点
|
| 301 |
+
|
| 302 |
+
#### 获取密钥列表
|
| 303 |
+
|
| 304 |
+
**端点**: `GET /api/keys`
|
| 305 |
+
|
| 306 |
+
- 返回服务器上配置的所有 API 密钥列表
|
| 307 |
+
- **注意**: 服务器返回完整密钥,打码显示由 Web UI 前端处理
|
| 308 |
+
- **无需认证**: 此端点不需要 API 密钥认证
|
| 309 |
+
|
| 310 |
+
#### 测试密钥
|
| 311 |
+
|
| 312 |
+
**端点**: `POST /api/keys/test`
|
| 313 |
+
|
| 314 |
+
- 验证指定的 API 密钥是否有效
|
| 315 |
+
- 请求体:`{"key": "your-api-key"}`
|
| 316 |
+
- 返回:`{"success": true, "valid": true/false, "message": "..."}`
|
| 317 |
+
- **无需认证**: 此端点不需要 API 密钥认证
|
| 318 |
+
|
| 319 |
+
#### 添加密钥
|
| 320 |
+
|
| 321 |
+
**端点**: `POST /api/keys`
|
| 322 |
+
|
| 323 |
+
- 向服务器添加新的 API 密钥
|
| 324 |
+
- 请求体:`{"key": "your-new-api-key"}`
|
| 325 |
+
- 密钥要求:至少 8 个字符,不能重复
|
| 326 |
+
- **无需认证**: 此端点不需要 API 密钥认证
|
| 327 |
+
|
| 328 |
+
#### 删除密钥
|
| 329 |
+
|
| 330 |
+
**端点**: `DELETE /api/keys`
|
| 331 |
+
|
| 332 |
+
- 从服务器删除指定的 API 密钥
|
| 333 |
+
- 请求体:`{"key": "key-to-delete"}`
|
| 334 |
+
- **无需认证**: 此端点不需要 API 密钥认证
|
| 335 |
+
|
| 336 |
+
## 配置客户端 (以 Open WebUI 为例)
|
| 337 |
+
|
| 338 |
+
1. 打开 Open WebUI。
|
| 339 |
+
2. 进入 "设置" -> "连接"。
|
| 340 |
+
3. 在 "模型" 部分,点击 "添加模型"。
|
| 341 |
+
4. **模型名称**: 输入你想要的名字,例如 `aistudio-gemini-py`。
|
| 342 |
+
5. **API 基础 URL**: 输入代理服务器的地址,例如 `http://127.0.0.1:2048/v1` (如果服务器在另一台机器,用其 IP 替换 `127.0.0.1`,并确保端口可访问)。
|
| 343 |
+
6. **API 密钥**: 留空或输入任意字符 (服务器不验证)。
|
| 344 |
+
7. 保存设置。
|
| 345 |
+
8. 现在,你应该可以在 Open WebUI 中选择你在第一步中配置的模型名称并开始聊天了。如果之前配置过,可能需要刷新或重新选择模型以应用新的 API 基地址。
|
| 346 |
+
|
| 347 |
+
## 重要提示
|
| 348 |
+
|
| 349 |
+
### 三层响应获取机制与参数控制
|
| 350 |
+
|
| 351 |
+
- **响应获取优先级**: 项目采用三层响应获取机制,确保高可用性和最佳性能:
|
| 352 |
+
|
| 353 |
+
1. **集成流式代理服务 (Stream Proxy)**:
|
| 354 |
+
- 默认启用,监听端口 `3120` (可通过 `.env` 文件的 `STREAM_PORT` 配置)
|
| 355 |
+
- 提供最佳性能和稳定性,直接处理 AI Studio 请求
|
| 356 |
+
- 支持基础参数传递,无需浏览器交互
|
| 357 |
+
2. **外部 Helper 服务**:
|
| 358 |
+
- 可选配置,通过 `--helper <endpoint_url>` 参数或 `.env` 配置启用
|
| 359 |
+
- 需要有效的认证文件 (`auth_profiles/active/*.json`) 提取 `SAPISID` Cookie
|
| 360 |
+
- 作为流式代理的备用方案
|
| 361 |
+
3. **Playwright 页面交互**:
|
| 362 |
+
- 最终后备方案,通过浏览器自动化获取响应
|
| 363 |
+
- 支持完整的参数控制和模型切换
|
| 364 |
+
- 通过模拟用户操作(编辑/复制按钮)获取响应
|
| 365 |
+
|
| 366 |
+
- **参数控制详解**:
|
| 367 |
+
|
| 368 |
+
- **流式代理模式**: 支持基础参数 (`model`, `temperature`, `max_tokens` 等),性能最优
|
| 369 |
+
- **Helper 服务模式**: 参数支持取决于外部 Helper 服务的具体实现
|
| 370 |
+
- **Playwright 模式**: 完整支持所有参数,包括 `temperature`, `max_output_tokens`, `top_p`, `stop`, `reasoning_effort`, `tools` 等
|
| 371 |
+
|
| 372 |
+
- **模型管理**:
|
| 373 |
+
|
| 374 |
+
- API 请求中的 `model` 字段用于在 AI Studio 页面切换模型
|
| 375 |
+
- 支持动态模型列表获取和模型 ID 验证
|
| 376 |
+
- [`excluded_models.txt`](../excluded_models.txt) 文件可排除特定模型 ID
|
| 377 |
+
|
| 378 |
+
- **🆕 脚本注入功能 v3.0**:
|
| 379 |
+
- 使用 Playwright 原生网络拦截,100% 可靠性
|
| 380 |
+
- 直接从油猴脚本解析模型数据,无需配置文件维护
|
| 381 |
+
- 前后端模型数据完全同步,注入模型标记为 `"injected": true`
|
| 382 |
+
- 详见 [脚本注入指南](script_injection_guide.md)
|
| 383 |
+
|
| 384 |
+
### 客户端管理历史
|
| 385 |
+
|
| 386 |
+
**客户端管理历史,代理不支持 UI 内编辑**: 客户端负责维护完整的聊天记录并将其发送给代理。代理服务器本身不支持在 AI Studio 界面中对历史消息进行编辑或分叉操作;它总是处理客户端发送的完整消息列表,然后将其发送到 AI Studio 页面。
|
| 387 |
+
|
| 388 |
+
## 兼容性说明
|
| 389 |
+
|
| 390 |
+
### Python 版本兼容性
|
| 391 |
+
|
| 392 |
+
- **推荐版本**: Python 3.10+ 或 3.11+ (生产环境推荐)
|
| 393 |
+
- **最低要求**: Python 3.9 (所有功能完全支持)
|
| 394 |
+
- **Docker 环境**: Python 3.10 (容器内默认版本)
|
| 395 |
+
- **完全支持**: Python 3.9, 3.10, 3.11, 3.12, 3.13
|
| 396 |
+
- **依赖管理**: 使用 Poetry 管理,确保版本一致性
|
| 397 |
+
|
| 398 |
+
### API 兼容性
|
| 399 |
+
|
| 400 |
+
- **OpenAI API**: 完全兼容 OpenAI v1 API 标准,支持所有主流客户端
|
| 401 |
+
- **FastAPI**: 基于 0.115.12 版本,包含最新性能优化和功能增强
|
| 402 |
+
- **HTTP 协议**: 支持 HTTP/1.1 和 HTTP/2,完整的异步处理
|
| 403 |
+
- **认证方式**: 支持 Bearer Token 和 X-API-Key 头部认证,OpenAI 标准兼容
|
| 404 |
+
- **流式响应**: 完整支持 Server-Sent Events (SSE) 流式输出
|
| 405 |
+
- **FastAPI**: 基于 0.111.0 版本,支持现代异步特性
|
| 406 |
+
- **HTTP 协议**: 支持 HTTP/1.1 和 HTTP/2
|
| 407 |
+
- **认证方式**: 支持 Bearer Token 和 X-API-Key 头部认证
|
| 408 |
+
|
| 409 |
+
## 下一步
|
| 410 |
+
|
| 411 |
+
API 使用配置完成后,请参考:
|
| 412 |
+
|
| 413 |
+
- [Web UI 使用指南](webui-guide.md)
|
| 414 |
+
- [故障排除指南](troubleshooting.md)
|
| 415 |
+
- [日志控制指南](logging-control.md)
|
AIStudioProxyAPI/docs/architecture-guide.md
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 项目架构指南
|
| 2 |
+
|
| 3 |
+
本文档详细介绍 AI Studio Proxy API 项目的模块化架构设计、组件职责和交互关系。
|
| 4 |
+
|
| 5 |
+
## 🏗️ 整体架构概览
|
| 6 |
+
|
| 7 |
+
项目采用现代化的模块化架构设计,遵循单一职责原则,确保代码的可维护性和可扩展性。
|
| 8 |
+
|
| 9 |
+
### 核心设计原则
|
| 10 |
+
|
| 11 |
+
- **模块化分离**: 按功能领域划分模块,避免循环依赖
|
| 12 |
+
- **单一职责**: 每个模块专注于特定的功能领域
|
| 13 |
+
- **配置统一**: 使用 `.env` 文件和 `config/` 模块统一管理配置
|
| 14 |
+
- **依赖注入**: 通过 `dependencies.py` 管理组件依赖关系
|
| 15 |
+
- **异步优先**: 全面采用异步编程模式,提升性能
|
| 16 |
+
|
| 17 |
+
## 📁 模块结构详解
|
| 18 |
+
|
| 19 |
+
```
|
| 20 |
+
AIstudioProxyAPI/
|
| 21 |
+
├── api_utils/ # FastAPI 应用核心模块
|
| 22 |
+
│ ├── app.py # FastAPI 应用入口和生命周期管理
|
| 23 |
+
│ ├── routes.py # API 路由定义和端点实现
|
| 24 |
+
│ ├── request_processor.py # 请求处理核心逻辑
|
| 25 |
+
│ ├── queue_worker.py # 异步队列工作器
|
| 26 |
+
│ ├── auth_utils.py # API 密钥认证管理
|
| 27 |
+
│ └── dependencies.py # FastAPI 依赖注入
|
| 28 |
+
├── browser_utils/ # 浏览器自动化模块
|
| 29 |
+
│ ├── page_controller.py # 页面控制器和生命周期管理
|
| 30 |
+
│ ├── model_management.py # AI Studio 模型管理
|
| 31 |
+
│ ├── script_manager.py # 脚本注入管理 (v3.0)
|
| 32 |
+
│ ├── operations.py # 浏览器操作封装
|
| 33 |
+
│ └── initialization.py # 浏览器初始化逻辑
|
| 34 |
+
├── config/ # 配置管理模块
|
| 35 |
+
│ ├── settings.py # 主要设置和环境变量
|
| 36 |
+
│ ├── constants.py # 系统常量定义
|
| 37 |
+
│ ├── timeouts.py # 超时配置管理
|
| 38 |
+
│ └── selectors.py # CSS 选择器定义
|
| 39 |
+
├── models/ # 数据模型定义
|
| 40 |
+
│ ├── chat.py # 聊天相关数据模型
|
| 41 |
+
│ ├── exceptions.py # 自定义异常类
|
| 42 |
+
│ └── logging.py # 日志相关模型
|
| 43 |
+
├── stream/ # 流式代理服务模块
|
| 44 |
+
│ ├── main.py # 流式代理服务入口
|
| 45 |
+
│ ├── proxy_server.py # 代理服务器实现
|
| 46 |
+
│ ├── interceptors.py # 请求拦截器
|
| 47 |
+
│ └── utils.py # 流式处理工具
|
| 48 |
+
├── logging_utils/ # 日志管理模块
|
| 49 |
+
│ └── setup.py # 日志系统配置
|
| 50 |
+
└── node_stream/ # Node流式处理模块
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
## 🔧 核心模块详解
|
| 54 |
+
|
| 55 |
+
### 1. api_utils/ - FastAPI 应用核心
|
| 56 |
+
|
| 57 |
+
**职责**: FastAPI 应用的核心逻辑,包括路由、认证、请求处理等。
|
| 58 |
+
|
| 59 |
+
#### app.py - 应用入口
|
| 60 |
+
|
| 61 |
+
- FastAPI 应用创建和配置
|
| 62 |
+
- 生命周期管理 (startup/shutdown)
|
| 63 |
+
- 中间件配置 (API 密钥认证)
|
| 64 |
+
- 全局状态初始化
|
| 65 |
+
|
| 66 |
+
#### routes.py - API 路由
|
| 67 |
+
|
| 68 |
+
- `/v1/chat/completions` - 聊天完成端点
|
| 69 |
+
- `/v1/models` - 模型列表端点
|
| 70 |
+
- `/api/keys/*` - API 密钥管理端点
|
| 71 |
+
- `/health` - 健康检查端点
|
| 72 |
+
- WebSocket 日志端点
|
| 73 |
+
|
| 74 |
+
#### request_processor.py - 请求处理核心
|
| 75 |
+
|
| 76 |
+
- 三层响应获取机制实现
|
| 77 |
+
- 流式和非流式响应处理
|
| 78 |
+
- 客户端断开检测
|
| 79 |
+
- 错误处理和重试逻辑
|
| 80 |
+
|
| 81 |
+
#### queue_worker.py - 队列工作器
|
| 82 |
+
|
| 83 |
+
- 异步请求队列处理
|
| 84 |
+
- 并发控制和资源管理
|
| 85 |
+
- 请求优先级处理
|
| 86 |
+
|
| 87 |
+
### 2. browser_utils/ - 浏览器自动化
|
| 88 |
+
|
| 89 |
+
**职责**: 浏览器自动化、页面控制、脚本注入等功能。
|
| 90 |
+
|
| 91 |
+
#### page_controller.py - 页面控制器
|
| 92 |
+
|
| 93 |
+
- Camoufox 浏览器生命周期管理
|
| 94 |
+
- 页面导航和状态监控
|
| 95 |
+
- 认证文件管理
|
| 96 |
+
|
| 97 |
+
#### script_manager.py - 脚本注入管理 (v3.0)
|
| 98 |
+
|
| 99 |
+
- Playwright 原生网络拦截
|
| 100 |
+
- 油猴脚本解析和注入
|
| 101 |
+
- 模型数据同步
|
| 102 |
+
|
| 103 |
+
#### model_management.py - 模型管理
|
| 104 |
+
|
| 105 |
+
- AI Studio 模型列表获取
|
| 106 |
+
- 模型切换和验证
|
| 107 |
+
- 排除模型处理
|
| 108 |
+
|
| 109 |
+
### 3. config/ - 配置管理
|
| 110 |
+
|
| 111 |
+
**职责**: 统一的配置管理,包括环境变量、常量、超时等。
|
| 112 |
+
|
| 113 |
+
#### settings.py - 主要设置
|
| 114 |
+
|
| 115 |
+
- `.env` 文件加载
|
| 116 |
+
- 环境变量解析
|
| 117 |
+
- 配置验证和默认值
|
| 118 |
+
|
| 119 |
+
#### constants.py - 系统常量
|
| 120 |
+
|
| 121 |
+
- API 端点常量
|
| 122 |
+
- 错误代码定义
|
| 123 |
+
- 系统标识符
|
| 124 |
+
|
| 125 |
+
### 4. stream/ - 流式代理服务
|
| 126 |
+
|
| 127 |
+
**职责**: 独立的流式代理服务,提供高性能的请求转发。
|
| 128 |
+
|
| 129 |
+
#### proxy_server.py - 代理服务器
|
| 130 |
+
|
| 131 |
+
- HTTP/HTTPS 代理实现
|
| 132 |
+
- 请求拦截和修改
|
| 133 |
+
- 上游代理支持
|
| 134 |
+
|
| 135 |
+
#### interceptors.py - 请求拦截器
|
| 136 |
+
|
| 137 |
+
- AI Studio 请求拦截
|
| 138 |
+
- 响应数据解析
|
| 139 |
+
- 流式数据处理
|
| 140 |
+
|
| 141 |
+
## 🔄 三层响应获取机制
|
| 142 |
+
|
| 143 |
+
项目实现了三层响应获取机制,确保高可用性和最佳性能:
|
| 144 |
+
|
| 145 |
+
### 第一层: 集成流式代理 (Stream Proxy)
|
| 146 |
+
|
| 147 |
+
- **位置**: `stream/` 模块
|
| 148 |
+
- **端口**: 3120 (可配置)
|
| 149 |
+
- **优势**: 最佳性能,直接处理请求
|
| 150 |
+
- **适用**: 日常使用,生产环境
|
| 151 |
+
|
| 152 |
+
### 第二层: 外部 Helper 服务
|
| 153 |
+
|
| 154 |
+
- **配置**: 通过 `--helper` 参数或环境变量
|
| 155 |
+
- **依赖**: 需要有效的认证文件
|
| 156 |
+
- **适用**: 备用方案,特殊环境
|
| 157 |
+
|
| 158 |
+
### 第三层: Playwright 页面交互
|
| 159 |
+
|
| 160 |
+
- **位置**: `browser_utils/` 模块
|
| 161 |
+
- **方式**: 浏览器自动化操作
|
| 162 |
+
- **优势**: 完整参数支持,最终后备
|
| 163 |
+
- **适用**: 调试模式,参数精确控制
|
| 164 |
+
|
| 165 |
+
## 🔐 认证系统架构
|
| 166 |
+
|
| 167 |
+
### API 密钥管理
|
| 168 |
+
|
| 169 |
+
- **存储**: `auth_profiles/key.txt` 文件
|
| 170 |
+
- **格式**: 每行一个密钥
|
| 171 |
+
- **验证**: Bearer Token 和 X-API-Key 双重支持
|
| 172 |
+
- **管理**: Web UI 分级权限查看
|
| 173 |
+
|
| 174 |
+
### 浏览器认证
|
| 175 |
+
|
| 176 |
+
- **文件**: `auth_profiles/active/*.json`
|
| 177 |
+
- **内容**: 浏览器会话和 Cookie
|
| 178 |
+
- **更新**: 通过调试模式重新获取
|
| 179 |
+
|
| 180 |
+
## 📊 配置管理架构
|
| 181 |
+
|
| 182 |
+
### 配置优先级
|
| 183 |
+
|
| 184 |
+
1. **命令行参数** (最高优先级)
|
| 185 |
+
2. **环境变量** (`.env` 文件)
|
| 186 |
+
3. **默认值** (代码中定义)
|
| 187 |
+
|
| 188 |
+
### 配置分类
|
| 189 |
+
|
| 190 |
+
- **服务配置**: 端口、代理、日志等
|
| 191 |
+
- **功能配置**: 脚本注入、认证、超时等
|
| 192 |
+
- **API 配置**: 默认参数、模型设置等
|
| 193 |
+
|
| 194 |
+
## 🚀 脚本注入架构 v3.0
|
| 195 |
+
|
| 196 |
+
### 工作机制
|
| 197 |
+
|
| 198 |
+
1. **脚本解析**: 从油猴脚本解析 `MODELS_TO_INJECT` 数组
|
| 199 |
+
2. **网络拦截**: Playwright 拦截 `/api/models` 请求
|
| 200 |
+
3. **数据合并**: 将注入模型与原始模型合并
|
| 201 |
+
4. **响应修改**: 返回包含注入模型的完整列表
|
| 202 |
+
5. **前端注入**: 同时注入脚本确保显示一致
|
| 203 |
+
|
| 204 |
+
### 技术优势
|
| 205 |
+
|
| 206 |
+
- **100% 可靠**: Playwright 原生拦截,无时序问题
|
| 207 |
+
- **零维护**: 脚本更新自动生效
|
| 208 |
+
- **完全同步**: 前后端使用相同数据源
|
| 209 |
+
|
| 210 |
+
## 🔧 开发和部署
|
| 211 |
+
|
| 212 |
+
### 开发环境
|
| 213 |
+
|
| 214 |
+
- **依赖管理**: Poetry
|
| 215 |
+
- **类型检查**: Pyright
|
| 216 |
+
- **代码格式**: Black + isort
|
| 217 |
+
- **测试框架**: pytest
|
| 218 |
+
|
| 219 |
+
### 部署方式
|
| 220 |
+
|
| 221 |
+
- **本地部署**: Poetry 虚拟环境
|
| 222 |
+
- **Docker 部署**: 多阶段构建,支持多架构
|
| 223 |
+
- **配置管理**: 统一的 `.env` 文件
|
| 224 |
+
|
| 225 |
+
## 📈 性能优化
|
| 226 |
+
|
| 227 |
+
### 异步处理
|
| 228 |
+
|
| 229 |
+
- 全面采用 `async/await`
|
| 230 |
+
- 异步队列处理请求
|
| 231 |
+
- 并发控制和资源管理
|
| 232 |
+
|
| 233 |
+
### 缓存机制
|
| 234 |
+
|
| 235 |
+
- 模型列表缓存
|
| 236 |
+
- 认证状态缓存
|
| 237 |
+
- 配置热重载
|
| 238 |
+
|
| 239 |
+
### 资源管理
|
| 240 |
+
|
| 241 |
+
- 浏览器实例复用
|
| 242 |
+
- 连接池管理
|
| 243 |
+
- 内存优化
|
| 244 |
+
|
| 245 |
+
## 🔍 监控和调试
|
| 246 |
+
|
| 247 |
+
### 日志系统
|
| 248 |
+
|
| 249 |
+
- 分级日志记录
|
| 250 |
+
- WebSocket 实时日志
|
| 251 |
+
- 错误追踪和报告
|
| 252 |
+
|
| 253 |
+
### 健康检查
|
| 254 |
+
|
| 255 |
+
- 组件状态监控
|
| 256 |
+
- 队列长度监控
|
| 257 |
+
- 性能指标收集
|
| 258 |
+
|
| 259 |
+
这种模块化架构确保了项目的可维护性、可扩展性和高性能,为用户提供稳定可靠的 AI Studio 代理服务。
|
AIStudioProxyAPI/docs/authentication-setup.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 首次运行与认证设置指南
|
| 2 |
+
|
| 3 |
+
为了避免每次启动都手动登录 AI Studio,你需要先通过 [`launch_camoufox.py --debug`](../launch_camoufox.py) 模式或 [`gui_launcher.py`](../gui_launcher.py) 的有头模式运行一次来生成认证文件。
|
| 4 |
+
|
| 5 |
+
## 认证文件的重要性
|
| 6 |
+
|
| 7 |
+
**认证文件是无头模式的关键**: 无头模式依赖于 `auth_profiles/active/` 目录下的有效 `.json` 文件来维持登录状态和访问权限。**文件可能会过期**,需要定期通过 [`launch_camoufox.py --debug`](../launch_camoufox.py) 模式手动运行、登录并保存新的认证文件来替换更新。
|
| 8 |
+
|
| 9 |
+
## 方法一:通过命令行运行 Debug 模式
|
| 10 |
+
|
| 11 |
+
**推荐使用 .env 配置方式**:
|
| 12 |
+
```env
|
| 13 |
+
# .env 文件配置
|
| 14 |
+
DEFAULT_FASTAPI_PORT=2048
|
| 15 |
+
STREAM_PORT=0
|
| 16 |
+
LAUNCH_MODE=normal
|
| 17 |
+
DEBUG_LOGS_ENABLED=true
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
```bash
|
| 21 |
+
# 简化启动命令 (推荐)
|
| 22 |
+
python launch_camoufox.py --debug
|
| 23 |
+
|
| 24 |
+
# 传统命令行方式 (仍然支持)
|
| 25 |
+
python launch_camoufox.py --debug --server-port 2048 --stream-port 0 --helper '' --internal-camoufox-proxy ''
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
**重要参数说明:**
|
| 29 |
+
* `--debug`: 启动有头模式,用于首次认证和调试
|
| 30 |
+
* `--server-port <端口号>`: 指定 FastAPI 服务器监听的端口 (默认: 2048)
|
| 31 |
+
* `--stream-port <端口号>`: 启动集成的流式代理服务端口 (默认: 3120)。设置为 `0` 可禁用此服务,首次启动建议禁用
|
| 32 |
+
* `--helper <端点URL>`: 指定外部 Helper 服务的地址。设置为空字符串 `''` 表示不使用外部 Helper
|
| 33 |
+
* `--internal-camoufox-proxy <代理地址>`: 为 Camoufox 浏览器指定代理。设置为空字符串 `''` 表示不使用代理
|
| 34 |
+
* **注意**: 如果需要启用流式代理服务,建议同时配置 `--internal-camoufox-proxy` 参数以确保正常运行
|
| 35 |
+
|
| 36 |
+
### 操作步骤
|
| 37 |
+
|
| 38 |
+
1. 脚本会启动 Camoufox(通过内部调用自身),并在终端输出启动信息。
|
| 39 |
+
2. 你会看到一个 **带界面的 Firefox 浏览器窗口** 弹出。
|
| 40 |
+
3. **关键交互:** **在弹出的浏览器窗口中完成 Google 登录**,直到看到 AI Studio 聊天界面。 (脚本会自动处理浏览器连接,无需用户手动操作)。
|
| 41 |
+
4. **登录确认操作**: 当系统检测到登录页面并在终端显示类似以下提示时:
|
| 42 |
+
```
|
| 43 |
+
检测到可能需要登录。如果浏览器显示登录页面,请在浏览器窗口中完成 Google 登录,然后在此处按 Enter 键继续...
|
| 44 |
+
```
|
| 45 |
+
**用户必须在终端中按 Enter 键确认操作才能继续**。这个确认步骤是必需的,系统会等待用户的确认输入才会进行下一步的登录状态检查。
|
| 46 |
+
5. 回到终端根据提示回车即可,如果设置使用非自动保存模式(即将弃用),请根据提示保存认证时输入 `y` 并回车 (文件名可默认)。文件会保存在 `auth_profiles/saved/`。
|
| 47 |
+
6. **将 `auth_profiles/saved/` 下新生成的 `.json` 文件移动到 `auth_profiles/active/` 目录。** 确保 `active` 目录下只有一个 `.json` 文件。
|
| 48 |
+
7. 可以按 `Ctrl+C` 停止 `--debug` 模式的运行。
|
| 49 |
+
|
| 50 |
+
## 方法二:通过 GUI 启动有头模式
|
| 51 |
+
|
| 52 |
+
1. 运行 `python gui_launcher.py`。
|
| 53 |
+
2. 在 GUI 中输入 `FastAPI 服务端口` (默认为 2048)。
|
| 54 |
+
3. 点击 `启动有头模式` 按钮。
|
| 55 |
+
4. 在弹出的新控制台和浏览器窗口中,按照命令行方式的提示进行 Google 登录和认证文件保存操作。
|
| 56 |
+
5. 同样需要手动将认证文件从 `auth_profiles/saved/` 移动到 `auth_profiles/active/`便于无头模式正常使用。
|
| 57 |
+
|
| 58 |
+
## 激活认证文件
|
| 59 |
+
|
| 60 |
+
1. 进入 `auth_profiles/saved/` 目录,找到刚才保存的 `.json` 认证文件。
|
| 61 |
+
2. 将这个 `.json` 文件 **移动或复制** 到 `auth_profiles/active/` 目录下。
|
| 62 |
+
3. **重要:** 确保 `auth_profiles/active/` 目录下 **有且仅有一个 `.json` 文件**。无头模式启动时会自动加载此目录下的第一个 `.json` 文件。
|
| 63 |
+
|
| 64 |
+
## 认证文件过期处理
|
| 65 |
+
|
| 66 |
+
**认证文件会过期!** Google 的登录状态不是永久有效的。当无头模式启动失败并报告认证错误或重定向到登录页时,意味着 `active` 目录下的认证文件已失效。你需要:
|
| 67 |
+
|
| 68 |
+
1. 删除 `active` 目录下的旧文件。
|
| 69 |
+
2. 重新执行上面的 **【通过命令行运行 Debug 模式】** 或 **【通过 GUI 启动有头模式】** 步骤,生成新的认证文件。
|
| 70 |
+
3. 将新生成的 `.json` 文件再次移动到 `active` 目录下。
|
| 71 |
+
|
| 72 |
+
## 重要提示
|
| 73 |
+
|
| 74 |
+
* **首次访问新主机的性能问题**: 当通过流式代理首次访问一个新的 HTTPS 主机时,服务需要为该主机动态生成并签署一个新的子证书。这个过程可能会比较耗时,导致对该新主机的首次连接请求响应较慢,甚至在某些情况下可能被主程序(如 [`server.py`](../server.py) 中的 Playwright 交互逻辑)误判为浏览器加载超时。一旦证书生成并缓存后,后续访问同一主机将会显著加快。
|
| 75 |
+
|
| 76 |
+
## 下一步
|
| 77 |
+
|
| 78 |
+
认证设置完成后,请参考:
|
| 79 |
+
- [日常运行指南](daily-usage.md)
|
| 80 |
+
- [API 使用指南](api-usage.md)
|
| 81 |
+
- [Web UI 使用指南](webui-guide.md)
|
AIStudioProxyAPI/docs/daily-usage.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 日常运行指南
|
| 2 |
+
|
| 3 |
+
本指南介绍如何在完成首次认证设置后进行日常运行。项目提供了多种启动方式,推荐使用基于 `.env` 配置文件的简化启动方式。
|
| 4 |
+
|
| 5 |
+
## 概述
|
| 6 |
+
|
| 7 |
+
完成首次认证设置后,您可以选择以下方式进行日常运行:
|
| 8 |
+
|
| 9 |
+
- **图形界面启动**: 使用 [`gui_launcher.py`](../gui_launcher.py) 提供的现代化GUI界面
|
| 10 |
+
- **命令行启动**: 直接使用 [`launch_camoufox.py`](../launch_camoufox.py) 命令行工具
|
| 11 |
+
- **Docker部署**: 使用容器化部署方式
|
| 12 |
+
|
| 13 |
+
## ⭐ 简化启动方式(推荐)
|
| 14 |
+
|
| 15 |
+
**基于 `.env` 配置文件的统一配置管理,启动变得极其简单!**
|
| 16 |
+
|
| 17 |
+
### 配置优势
|
| 18 |
+
|
| 19 |
+
- ✅ **一次配置,终身受益**: 配置好 `.env` 文件后,启动命令极其简洁
|
| 20 |
+
- ✅ **版本更新无忧**: `git pull` 后无需重新配置,直接启动
|
| 21 |
+
- ✅ **参数集中管理**: 所有配置项统一在 `.env` 文件中
|
| 22 |
+
- ✅ **环境隔离**: 不同环境可使用不同的配置文件
|
| 23 |
+
|
| 24 |
+
### 基本启动(推荐)
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
# 图形界面启动(推荐新手)
|
| 28 |
+
python gui_launcher.py
|
| 29 |
+
|
| 30 |
+
# 命令行启动(推荐日常使用)
|
| 31 |
+
python launch_camoufox.py --headless
|
| 32 |
+
|
| 33 |
+
# 调试模式(首次设置或故障排除)
|
| 34 |
+
python launch_camoufox.py --debug
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
**就这么简单!** 所有配置都在 `.env` 文件中预设好了,无需复杂的命令行参数。
|
| 38 |
+
|
| 39 |
+
## 启动器说明
|
| 40 |
+
|
| 41 |
+
### 关于 `--virtual-display` (Linux 虚拟显示无头模式)
|
| 42 |
+
|
| 43 |
+
* **为什么使用?** 与标准的无头模式相比,虚拟显示模式通过创建一个完整的虚拟 X 服务器环境 (Xvfb) 来运行浏览器。这可以模拟一个更真实的桌面环境,从而可能进一步降低被网站检测为自动化脚本或机器人的风险,特别适用于对反指纹和反检测有更高要求的场景,同时确保无桌面的环境下能正常运行服务
|
| 44 |
+
* **什么时候使用?** 当您在 Linux 环境下运行,并且希望以无头模式操作。
|
| 45 |
+
* **如何使用?**
|
| 46 |
+
1. 确保您的 Linux 系统已安装 `xvfb` (参见 [安装指南](installation-guide.md) 中的安装说明)。
|
| 47 |
+
2. 在运行 [`launch_camoufox.py`](../launch_camoufox.py) 时添加 `--virtual-display` 标志。例如:
|
| 48 |
+
```bash
|
| 49 |
+
python launch_camoufox.py --virtual-display --server-port 2048 --stream-port 3120 --internal-camoufox-proxy ''
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
## 代理配置优先级
|
| 53 |
+
|
| 54 |
+
项目采用统一的代理配置管理系统,按以下优先级顺序确定代理设置:
|
| 55 |
+
|
| 56 |
+
1. **`--internal-camoufox-proxy` 命令行参数** (最高优先级)
|
| 57 |
+
- 明确指定代理:`--internal-camoufox-proxy 'http://127.0.0.1:7890'`
|
| 58 |
+
- 明确禁用代理:`--internal-camoufox-proxy ''`
|
| 59 |
+
2. **`UNIFIED_PROXY_CONFIG` 环境变量** (推荐,.env 文件配置)
|
| 60 |
+
3. **`HTTP_PROXY` 环境变量**
|
| 61 |
+
4. **`HTTPS_PROXY` 环境变量**
|
| 62 |
+
5. **系统代理设置** (Linux 下的 gsettings,最低优先级)
|
| 63 |
+
|
| 64 |
+
**推荐配置方式**:
|
| 65 |
+
```env
|
| 66 |
+
# .env 文件中统一配置代理
|
| 67 |
+
UNIFIED_PROXY_CONFIG=http://127.0.0.1:7890
|
| 68 |
+
# 或禁用代理
|
| 69 |
+
UNIFIED_PROXY_CONFIG=
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
**重要说明**:此代理配置会同时应用于 Camoufox 浏览器和流式代理服务的上游连接,确保整个系统的代理行为一致。
|
| 73 |
+
|
| 74 |
+
## 三层响应获取机制配置
|
| 75 |
+
|
| 76 |
+
项目采用三层响应获取机制,确保高可用性和最佳性能。详细说明请参见 [流式处理模式详解](streaming-modes.md)。
|
| 77 |
+
|
| 78 |
+
### 模式1: 优先使用集成的流式代理 (默认推荐)
|
| 79 |
+
|
| 80 |
+
**使用 `.env` 配置(推荐):**
|
| 81 |
+
|
| 82 |
+
```env
|
| 83 |
+
# 在 .env 文件中配置
|
| 84 |
+
STREAM_PORT=3120
|
| 85 |
+
UNIFIED_PROXY_CONFIG=http://127.0.0.1:7890 # 如需代理
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
```bash
|
| 89 |
+
# 然后简单启动
|
| 90 |
+
python launch_camoufox.py --headless
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
**命令行覆盖(高级用户):**
|
| 94 |
+
|
| 95 |
+
```bash
|
| 96 |
+
# 使用自定义流式代理端口
|
| 97 |
+
python launch_camoufox.py --headless --stream-port 3125
|
| 98 |
+
|
| 99 |
+
# 启用代理配置
|
| 100 |
+
python launch_camoufox.py --headless --internal-camoufox-proxy 'http://127.0.0.1:7890'
|
| 101 |
+
|
| 102 |
+
# 明确禁用代理(覆盖 .env 中的设置)
|
| 103 |
+
python launch_camoufox.py --headless --internal-camoufox-proxy ''
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
在此模式下,主服务器会优先尝试通过端口 `3120` (或 `.env` 中配置的 `STREAM_PORT`) 上的集成流式代理获取响应。如果失败,则回退到 Playwright 页面交互。
|
| 107 |
+
|
| 108 |
+
### 模式2: 优先使用外部 Helper 服务 (禁用集成流式代理)
|
| 109 |
+
|
| 110 |
+
**使用 `.env` 配置(推荐):**
|
| 111 |
+
|
| 112 |
+
```bash
|
| 113 |
+
# 在 .env 文件中配置
|
| 114 |
+
STREAM_PORT=0 # 禁用集成流式代理
|
| 115 |
+
GUI_DEFAULT_HELPER_ENDPOINT=http://your-helper-service.com/api/getStreamResponse
|
| 116 |
+
|
| 117 |
+
# 然后简单启动
|
| 118 |
+
python launch_camoufox.py --headless
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
**命令行覆盖(高级用户):**
|
| 122 |
+
|
| 123 |
+
```bash
|
| 124 |
+
# 外部Helper模式
|
| 125 |
+
python launch_camoufox.py --headless --stream-port 0 --helper 'http://your-helper-service.com/api/getStreamResponse'
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
在此模式下,主服务器会优先尝试通过 Helper 端点获取响应 (需要有效的 `auth_profiles/active/*.json` 以提取 `SAPISID`)。如果失败,则回退到 Playwright 页面交互。
|
| 129 |
+
|
| 130 |
+
### 模式3: 仅使用 Playwright 页面交互 (禁用所有流式代理和 Helper)
|
| 131 |
+
|
| 132 |
+
**使用 `.env` 配置(推荐):**
|
| 133 |
+
|
| 134 |
+
```bash
|
| 135 |
+
# 在 .env 文件中配置
|
| 136 |
+
STREAM_PORT=0 # 禁用集成流式代理
|
| 137 |
+
GUI_DEFAULT_HELPER_ENDPOINT= # 禁用 Helper 服务
|
| 138 |
+
|
| 139 |
+
# 然后简单启动
|
| 140 |
+
python launch_camoufox.py --headless
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
**命令行覆盖(高级用户):**
|
| 144 |
+
|
| 145 |
+
```bash
|
| 146 |
+
# 纯Playwright模式
|
| 147 |
+
python launch_camoufox.py --headless --stream-port 0 --helper ''
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
在此模式下,主服务器将仅通过 Playwright 与 AI Studio 页面交互 (模拟点击"编辑"或"复制"按钮) 来获取响应。这是传统的后备方法。
|
| 151 |
+
|
| 152 |
+
## 使用图形界面启动器
|
| 153 |
+
|
| 154 |
+
项目提供了一个基于 Tkinter 的图形用户界面 (GUI) 启动器:[`gui_launcher.py`](../gui_launcher.py)。
|
| 155 |
+
|
| 156 |
+
### 启动 GUI
|
| 157 |
+
|
| 158 |
+
```bash
|
| 159 |
+
python gui_launcher.py
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
### GUI 功能
|
| 163 |
+
|
| 164 |
+
* **服务端口配置**: 指定 FastAPI 服务器监听的端口号 (默认为 2048)。
|
| 165 |
+
* **端口进程管理**: 查询和停止指定端口上的进程。
|
| 166 |
+
* **启动选项**:
|
| 167 |
+
1. **启动有头模式 (Debug, 交互式)**: 对应 `python launch_camoufox.py --debug`
|
| 168 |
+
2. **启动无头模式 (后台独立运行)**: 对应 `python launch_camoufox.py --headless`
|
| 169 |
+
* **本地LLM模拟服务**: 启动和管理本地LLM模拟服务 (基于 [`llm.py`](../llm.py))
|
| 170 |
+
* **状态与日志**: 显示服务状态和实时日志
|
| 171 |
+
|
| 172 |
+
### 使用建议
|
| 173 |
+
|
| 174 |
+
* 首次运行或需要更新认证文件:使用"启动有头模式"
|
| 175 |
+
* 日常后台运行:使用"启动无头模式"
|
| 176 |
+
* 需要详细日志或调试:直接使用命令行 [`launch_camoufox.py`](../launch_camoufox.py)
|
| 177 |
+
|
| 178 |
+
## 重要注意事项
|
| 179 |
+
|
| 180 |
+
### 配置优先级
|
| 181 |
+
|
| 182 |
+
1. **`.env` 文件配置** - 推荐的配置方式,一次设置长期使用
|
| 183 |
+
2. **命令行参数** - 可以覆盖 `.env` 文件中的设置,适用于临时调整
|
| 184 |
+
3. **环境变量** - 最低优先级,主要用于系统级配置
|
| 185 |
+
|
| 186 |
+
### 使用建议
|
| 187 |
+
|
| 188 |
+
- **日常使用**: 配置好 `.env` 文件后,使用简单的 `python launch_camoufox.py --headless` 即可
|
| 189 |
+
- **临时调整**: 需要临时修改配置时,使用命令行参数覆盖,无需修改 `.env` 文件
|
| 190 |
+
- **首次设置**: 使用 `python launch_camoufox.py --debug` 进行认证设置
|
| 191 |
+
|
| 192 |
+
**只有当你确认使用调试模式一切运行正常(特别是浏览器内的登录和认证保存),并且 `auth_profiles/active/` 目录下有有效的认证文件后,才推荐使用无头模式作为日常后台运行的标准方式。**
|
| 193 |
+
|
| 194 |
+
## 下一步
|
| 195 |
+
|
| 196 |
+
日常运行设置完成后,请参考:
|
| 197 |
+
- [API 使用指南](api-usage.md)
|
| 198 |
+
- [Web UI 使用指南](webui-guide.md)
|
| 199 |
+
- [故障排除指南](troubleshooting.md)
|
AIStudioProxyAPI/docs/dependency-versions.md
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 依赖版本说明
|
| 2 |
+
|
| 3 |
+
本文档详细说明了项目的 Python 版本要求、Poetry 依赖管理和版本控制策略。
|
| 4 |
+
|
| 5 |
+
## 📦 依赖管理工具
|
| 6 |
+
|
| 7 |
+
项目使用 **Poetry** 进行现代化的依赖管理,相比传统的 `requirements.txt` 提供:
|
| 8 |
+
|
| 9 |
+
- ✅ **依赖解析**: 自动解决版本冲突
|
| 10 |
+
- ✅ **锁定文件**: `poetry.lock` 确保环境一致性
|
| 11 |
+
- ✅ **虚拟环境**: 自动创建和管理虚拟环境
|
| 12 |
+
- ✅ **依赖分组**: 区分生产依赖和开发依赖
|
| 13 |
+
- ✅ **语义化版本**: 更精确的版本控制
|
| 14 |
+
- ✅ **构建系统**: 内置打包和发布功能
|
| 15 |
+
|
| 16 |
+
## 🐍 Python 版本要求
|
| 17 |
+
|
| 18 |
+
### Poetry 配置
|
| 19 |
+
|
| 20 |
+
```toml
|
| 21 |
+
[tool.poetry.dependencies]
|
| 22 |
+
python = ">=3.9,<4.0"
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
### 推荐配置
|
| 26 |
+
- **生产环境**: Python 3.10+ 或 3.11+ (最佳性能和稳定性)
|
| 27 |
+
- **开发环境**: Python 3.11+ 或 3.12+ (获得最佳开发体验)
|
| 28 |
+
- **最低要求**: Python 3.9 (基础功能支持)
|
| 29 |
+
|
| 30 |
+
### 版本兼容性矩阵
|
| 31 |
+
|
| 32 |
+
| Python版本 | 支持状态 | 推荐程度 | 主要特性 | 说明 |
|
| 33 |
+
|-----------|---------|---------|---------|------|
|
| 34 |
+
| 3.8 | ❌ 不支持 | 不推荐 | - | 缺少必要的类型注解特性 |
|
| 35 |
+
| 3.9 | ✅ 完全支持 | 可用 | 基础功能 | 最低支持版本,所有功能正常 |
|
| 36 |
+
| 3.10 | ✅ 完全支持 | 推荐 | 结构化模式匹配 | Docker 默认版本,稳定可靠 |
|
| 37 |
+
| 3.11 | ✅ 完全支持 | 强烈推荐 | 性能优化 | 显著性能提升,类型提示增强 |
|
| 38 |
+
| 3.12 | ✅ 完全支持 | 推荐 | 更快启动 | 更快启动时间,最新稳定特性 |
|
| 39 |
+
| 3.13 | ✅ 完全支持 | 可用 | 最新特性 | 最新版本,开发环境推荐 |
|
| 40 |
+
|
| 41 |
+
## 📋 Poetry 依赖配置
|
| 42 |
+
|
| 43 |
+
### pyproject.toml 结构
|
| 44 |
+
|
| 45 |
+
```toml
|
| 46 |
+
[tool.poetry]
|
| 47 |
+
name = "aistudioproxyapi"
|
| 48 |
+
version = "0.1.0"
|
| 49 |
+
package-mode = false
|
| 50 |
+
|
| 51 |
+
[tool.poetry.dependencies]
|
| 52 |
+
# 生产依赖
|
| 53 |
+
python = ">=3.9,<4.0"
|
| 54 |
+
fastapi = "==0.115.12"
|
| 55 |
+
# ... 其他依赖
|
| 56 |
+
|
| 57 |
+
[tool.poetry.group.dev.dependencies]
|
| 58 |
+
# 开发依赖 (可选安装)
|
| 59 |
+
pytest = "^7.0.0"
|
| 60 |
+
black = "^23.0.0"
|
| 61 |
+
# ... 其他开发工具
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
### 版本约束语法
|
| 65 |
+
|
| 66 |
+
Poetry 使用语义化版本约束:
|
| 67 |
+
|
| 68 |
+
- `==1.2.3` - 精确版本
|
| 69 |
+
- `^1.2.3` - 兼容版本 (>=1.2.3, <2.0.0)
|
| 70 |
+
- `~1.2.3` - 补丁版本 (>=1.2.3, <1.3.0)
|
| 71 |
+
- `>=1.2.3,<2.0.0` - 版本范围
|
| 72 |
+
- `*` - 最新版本
|
| 73 |
+
|
| 74 |
+
## 🔧 核心依赖版本
|
| 75 |
+
|
| 76 |
+
### Web 框架相关
|
| 77 |
+
```toml
|
| 78 |
+
fastapi = "==0.115.12"
|
| 79 |
+
pydantic = ">=2.7.1,<3.0.0"
|
| 80 |
+
uvicorn = "==0.29.0"
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
**版本说明**:
|
| 84 |
+
- **FastAPI 0.115.12**: 最新稳定版本,包含性能优化和新功能
|
| 85 |
+
- 新增 Query/Header/Cookie 参数模型支持
|
| 86 |
+
- 改进的类型提示和验证
|
| 87 |
+
- 更好的 OpenAPI 文档生成
|
| 88 |
+
- **Pydantic 2.7.1+**: 现代数据验证库,使用版本范围确保兼容性
|
| 89 |
+
- **Uvicorn 0.29.0**: 高性能 ASGI 服务器
|
| 90 |
+
|
| 91 |
+
### 浏览器自动化
|
| 92 |
+
```toml
|
| 93 |
+
playwright = "*"
|
| 94 |
+
camoufox = {version = "0.4.11", extras = ["geoip"]}
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
**版本说明**:
|
| 98 |
+
- **Playwright**: 使用最新版本 (`*`),确保浏览器兼容性
|
| 99 |
+
- **Camoufox 0.4.11**: 反指纹检测浏览器,包含地理位置数据扩展
|
| 100 |
+
|
| 101 |
+
### 网络和安全
|
| 102 |
+
```toml
|
| 103 |
+
aiohttp = "~=3.9.5"
|
| 104 |
+
requests = "==2.31.0"
|
| 105 |
+
cryptography = "==42.0.5"
|
| 106 |
+
pyjwt = "==2.8.0"
|
| 107 |
+
websockets = "==12.0"
|
| 108 |
+
aiosocks = "~=0.2.6"
|
| 109 |
+
python-socks = "~=2.7.1"
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
**版本说明**:
|
| 113 |
+
- **aiohttp ~3.9.5**: 异步HTTP客户端,允许补丁版本更新
|
| 114 |
+
- **cryptography 42.0.5**: 加密库,固定版本确保安全性
|
| 115 |
+
- **websockets 12.0**: WebSocket 支持
|
| 116 |
+
- **requests 2.31.0**: HTTP 客户端库
|
| 117 |
+
|
| 118 |
+
### 系统工具
|
| 119 |
+
```toml
|
| 120 |
+
python-dotenv = "==1.0.1"
|
| 121 |
+
httptools = "==0.6.1"
|
| 122 |
+
uvloop = {version = "*", markers = "sys_platform != 'win32'"}
|
| 123 |
+
Flask = "==3.0.3"
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
**版本说明**:
|
| 127 |
+
- **uvloop**: 仅在非 Windows 系统安装,显著提升性能
|
| 128 |
+
- **httptools**: HTTP 解析优化
|
| 129 |
+
- **python-dotenv**: 环境变量管理
|
| 130 |
+
- **Flask**: 用于特定功能的轻量级 Web 框架
|
| 131 |
+
|
| 132 |
+
## 🔄 Poetry 依赖管理命令
|
| 133 |
+
|
| 134 |
+
### 基础命令
|
| 135 |
+
|
| 136 |
+
```bash
|
| 137 |
+
# 安装所有依赖
|
| 138 |
+
poetry install
|
| 139 |
+
|
| 140 |
+
# 安装包括开发依赖
|
| 141 |
+
poetry install --with dev
|
| 142 |
+
|
| 143 |
+
# 添加新依赖
|
| 144 |
+
poetry add package_name
|
| 145 |
+
|
| 146 |
+
# 添加开发依赖
|
| 147 |
+
poetry add --group dev package_name
|
| 148 |
+
|
| 149 |
+
# 移除依赖
|
| 150 |
+
poetry remove package_name
|
| 151 |
+
|
| 152 |
+
# 更新依赖
|
| 153 |
+
poetry update
|
| 154 |
+
|
| 155 |
+
# 更新特定依赖
|
| 156 |
+
poetry update package_name
|
| 157 |
+
|
| 158 |
+
# 查看依赖树
|
| 159 |
+
poetry show --tree
|
| 160 |
+
|
| 161 |
+
# 导出 requirements.txt (兼容性)
|
| 162 |
+
poetry export -f requirements.txt --output requirements.txt
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
### 锁定文件管理
|
| 166 |
+
|
| 167 |
+
```bash
|
| 168 |
+
# 更新锁定文件
|
| 169 |
+
poetry lock
|
| 170 |
+
|
| 171 |
+
# 不更新锁定文件的情况下安装
|
| 172 |
+
poetry install --no-update
|
| 173 |
+
|
| 174 |
+
# 检查锁定文件是否最新
|
| 175 |
+
poetry check
|
| 176 |
+
```
|
| 177 |
+
|
| 178 |
+
## 📊 依赖更新策略
|
| 179 |
+
|
| 180 |
+
### 自动更新 (使用 ~ 版本范围)
|
| 181 |
+
- `aiohttp~=3.9.5` - 允许补丁版本更新 (3.9.5 → 3.9.x)
|
| 182 |
+
- `aiosocks~=0.2.6` - 允许补丁版本更新 (0.2.6 → 0.2.x)
|
| 183 |
+
- `python-socks~=2.7.1` - 允许补丁版本更新 (2.7.1 → 2.7.x)
|
| 184 |
+
|
| 185 |
+
### 固定版本 (使用 == 精确版本)
|
| 186 |
+
- 核心框架组件 (FastAPI, Uvicorn, python-dotenv)
|
| 187 |
+
- 安全相关库 (cryptography, pyjwt, requests)
|
| 188 |
+
- 稳定性要求高的组件 (websockets, httptools)
|
| 189 |
+
|
| 190 |
+
### 兼容版本 (使用版本范围)
|
| 191 |
+
- `pydantic>=2.7.1,<3.0.0` - 主版本内兼容更新
|
| 192 |
+
|
| 193 |
+
### 最新版本 (使用 * 或无限制)
|
| 194 |
+
- `playwright = "*"` - 浏览器自动化,需要最新功能
|
| 195 |
+
- `uvloop = "*"` - 性能优化库,持续更新
|
| 196 |
+
|
| 197 |
+
## 版本升级建议
|
| 198 |
+
|
| 199 |
+
### 已完成的依赖升级
|
| 200 |
+
1. **FastAPI**: 0.111.0 → 0.115.12 ✅
|
| 201 |
+
- 新增 Query/Header/Cookie 参数模型功能
|
| 202 |
+
- 改进的类型提示和验证机制
|
| 203 |
+
- 更好的 OpenAPI 文档生成和异步性能
|
| 204 |
+
- 向后兼容,无破坏性变更
|
| 205 |
+
- 增强的中间件支持和错误处理
|
| 206 |
+
|
| 207 |
+
2. **Pydantic**: 固定版本 → 版本范围 ✅
|
| 208 |
+
- 从 `pydantic==2.7.1` 更新为 `pydantic>=2.7.1,<3.0.0`
|
| 209 |
+
- 确保与 FastAPI 0.115.12 的完美兼容性
|
| 210 |
+
- 允许自动获取补丁版本更新和安全修复
|
| 211 |
+
- 支持最新的数据验证特性
|
| 212 |
+
|
| 213 |
+
3. **开发工具链现代化**: ✅
|
| 214 |
+
- Poetry 依赖管理替代传统 requirements.txt
|
| 215 |
+
- Pyright 类型检查支持,提升开发体验
|
| 216 |
+
- 模块化配置管理,支持 .env 文件
|
| 217 |
+
|
| 218 |
+
### 可选的次要依赖更新
|
| 219 |
+
- `charset-normalizer`: 3.4.1 → 3.4.2
|
| 220 |
+
- `click`: 8.1.8 → 8.2.1
|
| 221 |
+
- `frozenlist`: 1.6.0 → 1.6.2
|
| 222 |
+
|
| 223 |
+
### 升级注意事项
|
| 224 |
+
- 在测试环境中先验证兼容性
|
| 225 |
+
- 关注 FastAPI 版本更新的 breaking changes
|
| 226 |
+
- 定期检查安全漏洞更新
|
| 227 |
+
|
| 228 |
+
## 环境特定配置
|
| 229 |
+
|
| 230 |
+
### Docker 环境
|
| 231 |
+
- **基础镜像**: `python:3.10-slim-bookworm`
|
| 232 |
+
- **系统依赖**: 自动安装浏览器运行时依赖
|
| 233 |
+
- **Python版本**: 固定为 3.10 (容器内)
|
| 234 |
+
|
| 235 |
+
### 开发环境
|
| 236 |
+
- **推荐**: Python 3.11+
|
| 237 |
+
- **虚拟环境**: 强烈推荐使用 venv 或 conda
|
| 238 |
+
- **IDE支持**: 配置了 pyrightconfig.json (Python 3.13)
|
| 239 |
+
|
| 240 |
+
### 生产环境
|
| 241 |
+
- **推荐**: Python 3.10 或 3.11
|
| 242 |
+
- **稳定性**: 使用固定版本依赖
|
| 243 |
+
- **监控**: 定期检查依赖安全更新
|
| 244 |
+
|
| 245 |
+
## 故障排除
|
| 246 |
+
|
| 247 |
+
### 常见版本冲突
|
| 248 |
+
1. **Python 3.8 兼容性问题**
|
| 249 |
+
- 升级到 Python 3.9+
|
| 250 |
+
- 检查类型提示语法兼容性
|
| 251 |
+
|
| 252 |
+
2. **依赖版本冲突**
|
| 253 |
+
- 使用虚拟环境隔离
|
| 254 |
+
- 清理 pip 缓存: `pip cache purge`
|
| 255 |
+
|
| 256 |
+
3. **系统依赖缺失**
|
| 257 |
+
- Linux: 安装 `xvfb` 用于虚拟显示
|
| 258 |
+
- 运行 `playwright install-deps`
|
| 259 |
+
|
| 260 |
+
### 版本检查命令
|
| 261 |
+
```bash
|
| 262 |
+
# 检查 Python 版本
|
| 263 |
+
python --version
|
| 264 |
+
|
| 265 |
+
# 检查已安装包版本
|
| 266 |
+
pip list
|
| 267 |
+
|
| 268 |
+
# 检查过时的包
|
| 269 |
+
pip list --outdated
|
| 270 |
+
|
| 271 |
+
# 检查特定包信息
|
| 272 |
+
pip show fastapi
|
| 273 |
+
```
|
| 274 |
+
|
| 275 |
+
## 更新日志
|
| 276 |
+
|
| 277 |
+
### 2025-01-25
|
| 278 |
+
- **重要更新**: FastAPI 从 0.111.0 升级到 0.115.12
|
| 279 |
+
- **重要更新**: Pydantic 版本策略从固定版本改为版本范围 (>=2.7.1,<3.0.0)
|
| 280 |
+
- 更新 Python 版本要求说明 (推荐 3.9+,强烈建议 3.10+)
|
| 281 |
+
- 添加详细的依赖版本兼容性矩阵
|
| 282 |
+
- 完善 Docker 环境版本说明 (Python 3.10)
|
| 283 |
+
- 增加版本升级建议和故障排除指南
|
| 284 |
+
- 更新所有相关文档以反映新的依赖版本要求
|
AIStudioProxyAPI/docs/development-guide.md
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 开发者指南
|
| 2 |
+
|
| 3 |
+
本文档面向希望参与项目开发、贡献代码或深度定制功能的开发者。
|
| 4 |
+
|
| 5 |
+
## 🛠️ 开发环境设置
|
| 6 |
+
|
| 7 |
+
### 前置要求
|
| 8 |
+
|
| 9 |
+
- **Python**: >=3.9, <4.0 (推荐 3.10+ 以获得最佳性能)
|
| 10 |
+
- **Poetry**: 现代化 Python 依赖管理工具
|
| 11 |
+
- **Node.js**: >=16.0 (用于 Pyright 类型检查,可选)
|
| 12 |
+
- **Git**: 版本控制
|
| 13 |
+
|
| 14 |
+
### 快速开始
|
| 15 |
+
|
| 16 |
+
```bash
|
| 17 |
+
# 1. 克隆项目
|
| 18 |
+
git clone https://github.com/CJackHwang/AIstudioProxyAPI.git
|
| 19 |
+
cd AIstudioProxyAPI
|
| 20 |
+
|
| 21 |
+
# 2. 安装 Poetry (如果尚未安装)
|
| 22 |
+
curl -sSL https://install.python-poetry.org | python3 -
|
| 23 |
+
|
| 24 |
+
# 3. 安装项目依赖 (包括开发依赖)
|
| 25 |
+
poetry install --with dev
|
| 26 |
+
|
| 27 |
+
# 4. 激活虚拟环境
|
| 28 |
+
poetry env activate
|
| 29 |
+
|
| 30 |
+
# 5. 安装 Pyright (可选,用于类型检查)
|
| 31 |
+
npm install -g pyright
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
## 📁 项目结构
|
| 35 |
+
|
| 36 |
+
```
|
| 37 |
+
AIstudioProxyAPI/
|
| 38 |
+
├── api_utils/ # FastAPI 应用核心模块
|
| 39 |
+
│ ├── app.py # FastAPI 应用入口
|
| 40 |
+
│ ├── routes.py # API 路由定义
|
| 41 |
+
│ ├── request_processor.py # 请求处理逻辑
|
| 42 |
+
│ ├── queue_worker.py # 队列工作器
|
| 43 |
+
│ └── auth_utils.py # 认证工具
|
| 44 |
+
├── browser_utils/ # 浏览器自动化模块
|
| 45 |
+
│ ├── page_controller.py # 页面控制器
|
| 46 |
+
│ ├── model_management.py # 模型管理
|
| 47 |
+
│ ├── script_manager.py # 脚本注入管理
|
| 48 |
+
│ └── operations.py # 浏览器操作
|
| 49 |
+
├── config/ # 配置管理模块
|
| 50 |
+
│ ├── settings.py # 主要设置
|
| 51 |
+
│ ├── constants.py # 常量定义
|
| 52 |
+
│ ├── timeouts.py # 超时配置
|
| 53 |
+
│ └── selectors.py # CSS 选择器
|
| 54 |
+
├── models/ # 数据模型
|
| 55 |
+
│ ├── chat.py # 聊天相关模型
|
| 56 |
+
│ ├── exceptions.py # 异常定义
|
| 57 |
+
│ └── logging.py # 日志模型
|
| 58 |
+
├── stream/ # 流式代理服务
|
| 59 |
+
│ ├── main.py # 代理服务入口
|
| 60 |
+
│ ├── proxy_server.py # 代理服务器
|
| 61 |
+
│ └── interceptors.py # 请求拦截器
|
| 62 |
+
├── logging_utils/ # 日志工具
|
| 63 |
+
├── docs/ # 文档目录
|
| 64 |
+
├── docker/ # Docker 相关文件
|
| 65 |
+
├── pyproject.toml # Poetry 配置文件
|
| 66 |
+
├── pyrightconfig.json # Pyright 类型检查配置
|
| 67 |
+
├── .env.example # 环境变量模板
|
| 68 |
+
└── README.md # 项目说明
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
## 🔧 依赖管理 (Poetry)
|
| 72 |
+
|
| 73 |
+
### Poetry 基础命令
|
| 74 |
+
|
| 75 |
+
```bash
|
| 76 |
+
# 查看项目信息
|
| 77 |
+
poetry show
|
| 78 |
+
|
| 79 |
+
# 查看依赖树
|
| 80 |
+
poetry show --tree
|
| 81 |
+
|
| 82 |
+
# 添加新依赖
|
| 83 |
+
poetry add package_name
|
| 84 |
+
|
| 85 |
+
# 添加开发依赖
|
| 86 |
+
poetry add --group dev package_name
|
| 87 |
+
|
| 88 |
+
# 移除依赖
|
| 89 |
+
poetry remove package_name
|
| 90 |
+
|
| 91 |
+
# 更新依赖
|
| 92 |
+
poetry update
|
| 93 |
+
|
| 94 |
+
# 更新特定依赖
|
| 95 |
+
poetry update package_name
|
| 96 |
+
|
| 97 |
+
# 导出 requirements.txt (兼容性)
|
| 98 |
+
poetry export -f requirements.txt --output requirements.txt
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
### 依赖分组
|
| 102 |
+
|
| 103 |
+
项目使用 Poetry 的依赖分组功能:
|
| 104 |
+
|
| 105 |
+
```toml
|
| 106 |
+
[tool.poetry.dependencies]
|
| 107 |
+
# 生产依赖
|
| 108 |
+
python = ">=3.9,<4.0"
|
| 109 |
+
fastapi = "==0.115.12"
|
| 110 |
+
# ... 其他生产依赖
|
| 111 |
+
|
| 112 |
+
[tool.poetry.group.dev.dependencies]
|
| 113 |
+
# 开发依赖 (可选安装)
|
| 114 |
+
pytest = "^7.0.0"
|
| 115 |
+
black = "^23.0.0"
|
| 116 |
+
isort = "^5.12.0"
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
### 虚拟环境管理
|
| 120 |
+
|
| 121 |
+
```bash
|
| 122 |
+
# 查看虚拟环境信息
|
| 123 |
+
poetry env info
|
| 124 |
+
|
| 125 |
+
# 查看虚拟环境路径
|
| 126 |
+
poetry env info --path
|
| 127 |
+
|
| 128 |
+
# 激活虚拟环境
|
| 129 |
+
poetry env activate
|
| 130 |
+
|
| 131 |
+
# 在虚拟环境中运行命令
|
| 132 |
+
poetry run python script.py
|
| 133 |
+
|
| 134 |
+
# 删除虚拟环境
|
| 135 |
+
poetry env remove python
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
## 🔍 类型检查 (Pyright)
|
| 139 |
+
|
| 140 |
+
### Pyright 配置
|
| 141 |
+
|
| 142 |
+
项目使用 `pyrightconfig.json` 进行类型检查配置:
|
| 143 |
+
|
| 144 |
+
```json
|
| 145 |
+
{
|
| 146 |
+
"pythonVersion": "3.13",
|
| 147 |
+
"pythonPlatform": "Darwin",
|
| 148 |
+
"typeCheckingMode": "off",
|
| 149 |
+
"extraPaths": [
|
| 150 |
+
"./api_utils",
|
| 151 |
+
"./browser_utils",
|
| 152 |
+
"./config",
|
| 153 |
+
"./models",
|
| 154 |
+
"./logging_utils",
|
| 155 |
+
"./stream"
|
| 156 |
+
]
|
| 157 |
+
}
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
### 使用 Pyright
|
| 161 |
+
|
| 162 |
+
```bash
|
| 163 |
+
# 安装 Pyright
|
| 164 |
+
npm install -g pyright
|
| 165 |
+
|
| 166 |
+
# 检查整个项目
|
| 167 |
+
pyright
|
| 168 |
+
|
| 169 |
+
# 检查特定文件
|
| 170 |
+
pyright api_utils/app.py
|
| 171 |
+
|
| 172 |
+
# 监视模式 (文件变化时自动检查)
|
| 173 |
+
pyright --watch
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
### 类型注解最佳实践
|
| 177 |
+
|
| 178 |
+
```python
|
| 179 |
+
from typing import Optional, List, Dict, Any
|
| 180 |
+
from pydantic import BaseModel
|
| 181 |
+
|
| 182 |
+
# 函数类型注解
|
| 183 |
+
def process_request(data: Dict[str, Any]) -> Optional[str]:
|
| 184 |
+
"""处理请求数据"""
|
| 185 |
+
return data.get("message")
|
| 186 |
+
|
| 187 |
+
# 类型别名
|
| 188 |
+
ModelConfig = Dict[str, Any]
|
| 189 |
+
ResponseData = Dict[str, str]
|
| 190 |
+
|
| 191 |
+
# Pydantic 模型
|
| 192 |
+
class ChatRequest(BaseModel):
|
| 193 |
+
message: str
|
| 194 |
+
model: Optional[str] = None
|
| 195 |
+
temperature: float = 0.7
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
## 🧪 测试
|
| 199 |
+
|
| 200 |
+
### 运行测试
|
| 201 |
+
|
| 202 |
+
```bash
|
| 203 |
+
# 运行所有测试
|
| 204 |
+
poetry run pytest
|
| 205 |
+
|
| 206 |
+
# 运行��定测试文件
|
| 207 |
+
poetry run pytest tests/test_api.py
|
| 208 |
+
|
| 209 |
+
# 运行测试并生成覆盖率报告
|
| 210 |
+
poetry run pytest --cov=api_utils --cov-report=html
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
### 测试结构
|
| 214 |
+
|
| 215 |
+
```
|
| 216 |
+
tests/
|
| 217 |
+
├── conftest.py # 测试配置
|
| 218 |
+
├── test_api.py # API 测试
|
| 219 |
+
├── test_browser.py # 浏览器功能测试
|
| 220 |
+
└── test_config.py # 配置测试
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
## 🔄 开发工作流程
|
| 224 |
+
|
| 225 |
+
### 1. 代码格式化
|
| 226 |
+
|
| 227 |
+
```bash
|
| 228 |
+
# 使用 Black 格式化代码
|
| 229 |
+
poetry run black .
|
| 230 |
+
|
| 231 |
+
# 使用 isort 整理导入
|
| 232 |
+
poetry run isort .
|
| 233 |
+
|
| 234 |
+
# 检查代码风格
|
| 235 |
+
poetry run flake8 .
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
### 2. 类型检查
|
| 239 |
+
|
| 240 |
+
```bash
|
| 241 |
+
# 运行类型检查
|
| 242 |
+
pyright
|
| 243 |
+
|
| 244 |
+
# 或使用 mypy (如果安装)
|
| 245 |
+
poetry run mypy .
|
| 246 |
+
```
|
| 247 |
+
|
| 248 |
+
### 3. 测试
|
| 249 |
+
|
| 250 |
+
```bash
|
| 251 |
+
# 运行测试
|
| 252 |
+
poetry run pytest
|
| 253 |
+
|
| 254 |
+
# 运行测试并检查覆盖率
|
| 255 |
+
poetry run pytest --cov
|
| 256 |
+
```
|
| 257 |
+
|
| 258 |
+
### 4. 提交代码
|
| 259 |
+
|
| 260 |
+
```bash
|
| 261 |
+
# 添加文件
|
| 262 |
+
git add .
|
| 263 |
+
|
| 264 |
+
# 提交 (建议使用规范的提交信息)
|
| 265 |
+
git commit -m "feat: 添加新功能"
|
| 266 |
+
|
| 267 |
+
# 推送
|
| 268 |
+
git push origin feature-branch
|
| 269 |
+
```
|
| 270 |
+
|
| 271 |
+
## 📝 代码规范
|
| 272 |
+
|
| 273 |
+
### 命名规范
|
| 274 |
+
|
| 275 |
+
- **文件名**: 使用下划线分隔 (`snake_case`)
|
| 276 |
+
- **类名**: 使用驼峰命名 (`PascalCase`)
|
| 277 |
+
- **函数名**: 使用下划线分隔 (`snake_case`)
|
| 278 |
+
- **常量**: 使用大写字母和下划线 (`UPPER_CASE`)
|
| 279 |
+
|
| 280 |
+
### 文档字符串
|
| 281 |
+
|
| 282 |
+
```python
|
| 283 |
+
def process_chat_request(request: ChatRequest) -> ChatResponse:
|
| 284 |
+
"""
|
| 285 |
+
处理聊天请求
|
| 286 |
+
|
| 287 |
+
Args:
|
| 288 |
+
request: 聊天请求对象
|
| 289 |
+
|
| 290 |
+
Returns:
|
| 291 |
+
ChatResponse: 聊天响应对象
|
| 292 |
+
|
| 293 |
+
Raises:
|
| 294 |
+
ValidationError: 当请求数据无效时
|
| 295 |
+
ProcessingError: 当处理失败时
|
| 296 |
+
"""
|
| 297 |
+
pass
|
| 298 |
+
```
|
| 299 |
+
|
| 300 |
+
## 🚀 部署和发布
|
| 301 |
+
|
| 302 |
+
### 构建项目
|
| 303 |
+
|
| 304 |
+
```bash
|
| 305 |
+
# 构建分发包
|
| 306 |
+
poetry build
|
| 307 |
+
|
| 308 |
+
# 检查构建结果
|
| 309 |
+
ls dist/
|
| 310 |
+
```
|
| 311 |
+
|
| 312 |
+
### Docker 开发
|
| 313 |
+
|
| 314 |
+
```bash
|
| 315 |
+
# 构建开发镜像
|
| 316 |
+
docker build -f docker/Dockerfile.dev -t aistudio-dev .
|
| 317 |
+
|
| 318 |
+
# 运行开发容器
|
| 319 |
+
docker run -it --rm -v $(pwd):/app aistudio-dev bash
|
| 320 |
+
```
|
| 321 |
+
|
| 322 |
+
## 🤝 贡献指南
|
| 323 |
+
|
| 324 |
+
### 提交 Pull Request
|
| 325 |
+
|
| 326 |
+
1. Fork 项目
|
| 327 |
+
2. 创建功能分支: `git checkout -b feature/amazing-feature`
|
| 328 |
+
3. 提交更改: `git commit -m 'feat: 添加惊人的功能'`
|
| 329 |
+
4. 推送分支: `git push origin feature/amazing-feature`
|
| 330 |
+
5. 创建 Pull Request
|
| 331 |
+
|
| 332 |
+
### 代码审查清单
|
| 333 |
+
|
| 334 |
+
- [ ] 代码遵循项目规范
|
| 335 |
+
- [ ] 添加了必要的测试
|
| 336 |
+
- [ ] 测试通过
|
| 337 |
+
- [ ] 类型检查通过
|
| 338 |
+
- [ ] 文档已更新
|
| 339 |
+
- [ ] 变更日志已更新
|
| 340 |
+
|
| 341 |
+
## 📞 获取帮助
|
| 342 |
+
|
| 343 |
+
- **GitHub Issues**: 报告 Bug 或请求功能
|
| 344 |
+
- **GitHub Discussions**: 技术讨论和问答
|
| 345 |
+
- **开发者文档**: 查看详细的 API 文档
|
| 346 |
+
|
| 347 |
+
## 🔗 相关资源
|
| 348 |
+
|
| 349 |
+
- [Poetry 官方文档](https://python-poetry.org/docs/)
|
| 350 |
+
- [Pyright 官方文档](https://github.com/microsoft/pyright)
|
| 351 |
+
- [FastAPI 官方文档](https://fastapi.tiangolo.com/)
|
| 352 |
+
- [Playwright 官方文档](https://playwright.dev/python/)
|