lengfeng1360 commited on
Commit
927965d
·
verified ·
1 Parent(s): 756887e

Upload 87 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +14 -0
  2. .env.example +196 -0
  3. .gitignore +251 -0
  4. LICENSE +661 -0
  5. README.md +379 -10
  6. api_utils/__init__.py +78 -0
  7. api_utils/app.py +328 -0
  8. api_utils/auth_utils.py +32 -0
  9. api_utils/dependencies.py +57 -0
  10. api_utils/queue_worker.py +351 -0
  11. api_utils/request_processor.py +884 -0
  12. api_utils/request_processor_backup.py +274 -0
  13. api_utils/routes.py +385 -0
  14. api_utils/utils.py +428 -0
  15. auth_profiles/active/.gitkeep +0 -0
  16. auth_profiles/saved/.gitkeep +0 -0
  17. browser_utils/__init__.py +56 -0
  18. browser_utils/initialization.py +669 -0
  19. browser_utils/model_management.py +619 -0
  20. browser_utils/more_modles.js +393 -0
  21. browser_utils/operations.py +783 -0
  22. browser_utils/page_controller.py +914 -0
  23. browser_utils/script_manager.py +183 -0
  24. certs/ca.crt +21 -0
  25. certs/ca.key +28 -0
  26. config/__init__.py +90 -0
  27. config/constants.py +53 -0
  28. config/selectors.py +49 -0
  29. config/settings.py +54 -0
  30. config/timeouts.py +40 -0
  31. deprecated_javascript_version/README.md +233 -0
  32. deprecated_javascript_version/auto_connect_aistudio.cjs +595 -0
  33. deprecated_javascript_version/package.json +8 -0
  34. deprecated_javascript_version/server.cjs +1505 -0
  35. deprecated_javascript_version/test.js +126 -0
  36. docker/.env.docker +150 -0
  37. docker/Dockerfile +116 -0
  38. docker/README-Docker.md +456 -0
  39. docker/README.md +77 -0
  40. docker/SCRIPT_INJECTION_DOCKER.md +209 -0
  41. docker/docker-compose.yml +56 -0
  42. docker/update.sh +30 -0
  43. docs/advanced-configuration.md +356 -0
  44. docs/api-usage.md +415 -0
  45. docs/architecture-guide.md +259 -0
  46. docs/authentication-setup.md +81 -0
  47. docs/daily-usage.md +199 -0
  48. docs/dependency-versions.md +284 -0
  49. docs/development-guide.md +352 -0
  50. docs/environment-configuration.md +363 -0
.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
.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
.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
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/>.
README.md CHANGED
@@ -1,10 +1,379 @@
1
- ---
2
- title: AIstudioProxyAPI
3
- emoji: 🐨
4
- colorFrom: indigo
5
- colorTo: red
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AI Studio Proxy API
2
+
3
+ 这是一个基于 Python 的代理服务器,用于将 Google AI Studio 的网页界面转换为 OpenAI 兼容的 API。通过 Camoufox (反指纹检测的 Firefox) 和 Playwright 自动化,提供稳定的 API 访问。
4
+
5
+ [![Star History Chart](https://api.star-history.com/svg?repos=CJackHwang/AIstudioProxyAPI&type=Date)](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
+ ![开发不易,支持作者](./支持作者.jpg)
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
+ ]
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
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
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
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 已停止 ---")
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)
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
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))
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)
auth_profiles/active/.gitkeep ADDED
File without changes
auth_profiles/saved/.gitkeep ADDED
File without changes
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
+ ]
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}")
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)
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
+ })();
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
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
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()
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-----
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-----
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
+ ]
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
+ }
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'
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 已废弃,现在直接从油猴脚本解析模型数据
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'))
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` 中适当增加这个超时时间。
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
+ })();
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
+ }
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'));
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 示例)
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
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"]
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 部署与本地开发的一致性���同时简化了配置和更新流程。
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/` 目录,保持项目根目录整洁
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` 文件了解完整的配置格式和选项。
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
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}"
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)
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)
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 代理服务。
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)
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)
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
+ - 更新所有相关文档以反映新的依赖版本要求
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/)
docs/environment-configuration.md ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 环境变量配置指南
2
+
3
+ 本文档详细介绍如何使用 `.env` 文件来配置 AI Studio Proxy API 项目,实现统一的配置管理。
4
+
5
+ ## 概述
6
+
7
+ 项目采用基于 `.env` 文件的现代化配置管理系统,提供以下优势:
8
+
9
+ ### 主要优势
10
+
11
+ - ✅ **版本更新无忧**: 一个 `git pull` 就完成更新,无需重新配置
12
+ - ✅ **配置集中管理**: 所有配置项统一在 `.env` 文件中,清晰明了
13
+ - ✅ **启动命令简化**: 无需复杂的命令行参数,一键启动
14
+ - ✅ **安全性**: `.env` 文件已被 `.gitignore` 忽略,不会泄露敏感配置
15
+ - ✅ **灵活性**: 支持不同环境的配置管理(开发、测试、生产)
16
+ - ✅ **Docker 兼容**: Docker 和本地环境使用相同的配置方式
17
+ - ✅ **模块化设计**: 配置项按功能分组,便于理解和维护
18
+
19
+ ## 快速开始
20
+
21
+ ### 1. 复制配置模板
22
+
23
+ ```bash
24
+ cp .env.example .env
25
+ ```
26
+
27
+ ### 2. 编辑配置文件
28
+
29
+ 根据您的需要修改 `.env` 文件中的配置项:
30
+
31
+ ```bash
32
+ # 编辑配置文件
33
+ nano .env
34
+ # 或使用其他编辑器
35
+ code .env
36
+ ```
37
+
38
+ ### 3. 启动服务
39
+
40
+ 配置完成后,启动变得非常简单:
41
+
42
+ ```bash
43
+ # 图形界面启动(推荐新手)
44
+ python gui_launcher.py
45
+
46
+ # 命令行启动(推荐日常使用)
47
+ python launch_camoufox.py --headless
48
+
49
+ # 调试模式(首次设置或故障排除)
50
+ python launch_camoufox.py --debug
51
+ ```
52
+
53
+ **就这么简单!** 无需复杂的命令行参数,所有配置都在 `.env` 文件中预设好了。
54
+
55
+ ## 启动命令对比
56
+
57
+ ### 使用 `.env` 配置前(复杂)
58
+
59
+ ```bash
60
+ # 之前需要这样的复杂命令
61
+ python launch_camoufox.py --headless --server-port 2048 --stream-port 3120 --helper '' --internal-camoufox-proxy 'http://127.0.0.1:7890'
62
+ ```
63
+
64
+ ### 使用 `.env` 配置后(简单)
65
+
66
+ ```bash
67
+ # 现在只需要这样
68
+ python launch_camoufox.py --headless
69
+ ```
70
+
71
+ **配置一次,终身受益!** 所有复杂的参数都在 `.env` 文件中预设,启动命令变得极其简洁。
72
+
73
+ ## 主要配置项
74
+
75
+ ### 服务端口配置
76
+
77
+ ```env
78
+ # FastAPI 服务端口
79
+ PORT=8000
80
+ DEFAULT_FASTAPI_PORT=2048
81
+ DEFAULT_CAMOUFOX_PORT=9222
82
+
83
+ # 流式代理服务配置
84
+ STREAM_PORT=3120
85
+ ```
86
+
87
+ ### 代理配置
88
+
89
+ ```env
90
+ # HTTP/HTTPS 代理设置
91
+ HTTP_PROXY=http://127.0.0.1:7890
92
+ HTTPS_PROXY=http://127.0.0.1:7890
93
+
94
+ # 统一代理配置 (优先级更高)
95
+ UNIFIED_PROXY_CONFIG=http://127.0.0.1:7890
96
+
97
+ # 代理绕过列表
98
+ NO_PROXY=localhost;127.0.0.1;*.local
99
+ ```
100
+
101
+ ### 日志配置
102
+
103
+ ```env
104
+ # 服务器日志级别
105
+ SERVER_LOG_LEVEL=INFO
106
+
107
+ # 启用调试日志
108
+ DEBUG_LOGS_ENABLED=false
109
+ TRACE_LOGS_ENABLED=false
110
+
111
+ # 是否重定向 print 输出到日志
112
+ SERVER_REDIRECT_PRINT=false
113
+ ```
114
+
115
+ ### 认证配置
116
+
117
+ ```env
118
+ # 自动保存认证信息
119
+ AUTO_SAVE_AUTH=false
120
+
121
+ # 认证保存超时时间 (秒)
122
+ AUTH_SAVE_TIMEOUT=30
123
+
124
+ # 自动确认登录
125
+ AUTO_CONFIRM_LOGIN=true
126
+ ```
127
+
128
+ ### API 默认参数
129
+
130
+ ```env
131
+ # 默认温度值 (0.0-2.0)
132
+ DEFAULT_TEMPERATURE=1.0
133
+
134
+ # 默认最大输出令牌数
135
+ DEFAULT_MAX_OUTPUT_TOKENS=65536
136
+
137
+ # 默认 Top-P 值 (0.0-1.0)
138
+ DEFAULT_TOP_P=0.95
139
+
140
+ # 默认停止序列 (JSON 数组格式)
141
+ DEFAULT_STOP_SEQUENCES=["用户:"]
142
+
143
+ # 是否在处理请求时自动打开并使用 "URL Context" 功能
144
+ # 参考: https://ai.google.dev/gemini-api/docs/url-context
145
+ ENABLE_URL_CONTEXT=true
146
+
147
+ # 是否默认启用 "指定思考预算" 功能 (true/false),不启用时模型一般将自行决定思考预算
148
+ # 当 API 请求中未提供 reasoning_effort 参数时,将使用此值。
149
+ ENABLE_THINKING_BUDGET=false
150
+
151
+ # "指定思考预算量" 的默认值 (token)
152
+ # 当 API 请求中未提供 reasoning_effort 参数时,将使用此值。
153
+ DEFAULT_THINKING_BUDGET=8192
154
+
155
+ # 是否默认启用 "Google Search" 功能 (true/false)
156
+ # 当 API 请求中未提供 tools 参数时,将使用此设置作为 Google Search 的默认开关状态。
157
+ ENABLE_GOOGLE_SEARCH=false
158
+ ```
159
+
160
+ ### 超时配置
161
+
162
+ ```env
163
+ # 响应完成总超时时间 (毫秒)
164
+ RESPONSE_COMPLETION_TIMEOUT=300000
165
+
166
+ # 轮询间隔 (毫秒)
167
+ POLLING_INTERVAL=300
168
+ POLLING_INTERVAL_STREAM=180
169
+
170
+ # 静默超时 (毫秒)
171
+ SILENCE_TIMEOUT_MS=60000
172
+ ```
173
+
174
+ ### GUI 启动器配置
175
+
176
+ ```env
177
+ # GUI 默认代理地址
178
+ GUI_DEFAULT_PROXY_ADDRESS=http://127.0.0.1:7890
179
+
180
+ # GUI 默认流式代理端口
181
+ GUI_DEFAULT_STREAM_PORT=3120
182
+
183
+ # GUI 默认 Helper 端点
184
+ GUI_DEFAULT_HELPER_ENDPOINT=
185
+ ```
186
+
187
+ ### 脚本注入配置 v3.0 🆕
188
+
189
+ ```env
190
+ # 是否启用油猴脚本注入功能
191
+ ENABLE_SCRIPT_INJECTION=true
192
+
193
+ # 油猴脚本文件路径(相对于项目根目录)
194
+ # 模型数据直接从此脚本文件中解析,无需额外配置文件
195
+ USERSCRIPT_PATH=browser_utils/more_modles.js
196
+ ```
197
+
198
+ **脚本注入功能 v3.0 重大升级**:
199
+ - **🚀 Playwright 原生拦截**: 使用 Playwright 路由拦截,100% 可靠性
200
+ - **🔄 双重保障机制**: 网络拦截 + 脚本注入,确保万无一失
201
+ - **📝 直接脚本解析**: 从油猴脚本中自动解析模型列表,无需配置文件
202
+ - **🔗 前后端同步**: 前端和后端使用相同的模型数据源
203
+ - **⚙️ 零配置维护**: 脚本更新时自动获取新的模型列表
204
+ - **🔄 自动适配**: 油猴脚本更新时无需手动更新配置
205
+
206
+ **与 v1.x 的主要区别**:
207
+ - 移除了 `MODEL_CONFIG_PATH` 配置项(已废弃)
208
+ - 不再需要手动维护模型配置文件
209
+ - 工作机制从"配置文件 + 脚本注入"改为"直接脚本解析 + 网络拦截"
210
+
211
+ 详细使用方法请参见 [脚本注入指南](script_injection_guide.md)。
212
+
213
+ ## 配置优先级
214
+
215
+ 配置项的优先级顺序(从高到低):
216
+
217
+ 1. **命令行参数** - 直接传递给程序的参数
218
+ 2. **环境变量** - 系统环境变量或 `.env` 文件中的变量
219
+ 3. **默认值** - 代码中定义的默认值
220
+
221
+ ## 常见配置场景
222
+
223
+ ### 场景 1:使用代理
224
+
225
+ ```env
226
+ # 启用代理
227
+ HTTP_PROXY=http://127.0.0.1:7890
228
+ HTTPS_PROXY=http://127.0.0.1:7890
229
+
230
+ # GUI 中也使用相同代理
231
+ GUI_DEFAULT_PROXY_ADDRESS=http://127.0.0.1:7890
232
+ ```
233
+
234
+ ### 场景 2:调试模式
235
+
236
+ ```env
237
+ # 启用详细日志
238
+ DEBUG_LOGS_ENABLED=true
239
+ TRACE_LOGS_ENABLED=true
240
+ SERVER_LOG_LEVEL=DEBUG
241
+ SERVER_REDIRECT_PRINT=true
242
+ ```
243
+
244
+ ### 场景 3:生产环境
245
+
246
+ ```env
247
+ # 生产环境配置
248
+ SERVER_LOG_LEVEL=WARNING
249
+ DEBUG_LOGS_ENABLED=false
250
+ TRACE_LOGS_ENABLED=false
251
+
252
+ # 更长的超时时间
253
+ RESPONSE_COMPLETION_TIMEOUT=600000
254
+ SILENCE_TIMEOUT_MS=120000
255
+ ```
256
+
257
+ ### 场景 4:自定义端口
258
+
259
+ ```env
260
+ # 避免端口冲突
261
+ DEFAULT_FASTAPI_PORT=3048
262
+ DEFAULT_CAMOUFOX_PORT=9223
263
+ STREAM_PORT=3121
264
+ ```
265
+
266
+ ### 场景 5:启用脚本注入 v3.0 🆕
267
+
268
+ ```env
269
+ # 启用脚本注入功能 v3.0
270
+ ENABLE_SCRIPT_INJECTION=true
271
+
272
+ # 使用自定义脚本(模型数据直接从脚本解析)
273
+ USERSCRIPT_PATH=browser_utils/my_custom_script.js
274
+
275
+ # 调试模式查看注入效果
276
+ DEBUG_LOGS_ENABLED=true
277
+
278
+ # 流式代理配置(与脚本注入配合使用)
279
+ STREAM_PORT=3120
280
+ ```
281
+
282
+ **v3.0 脚本注入优势**:
283
+ - 使用 Playwright 原生网络拦截,无时序问题
284
+ - 前后端模型数据100%同步
285
+ - 零配置维护,脚本更新自动生效
286
+
287
+ ## 配置优先级
288
+
289
+ 项目采用分层配置系统,按以下优先级顺序确定最终配置:
290
+
291
+ 1. **命令行参数** (最高优先级)
292
+ ```bash
293
+ # 命令行参数会覆盖 .env 文件中的设置
294
+ python launch_camoufox.py --headless --server-port 3048
295
+ ```
296
+
297
+ 2. **`.env` 文件配置** (推荐)
298
+ ```env
299
+ # .env 文件中的配置
300
+ DEFAULT_FASTAPI_PORT=2048
301
+ ```
302
+
303
+ 3. **系统环境变量** (最低优先级)
304
+ ```bash
305
+ # 系统环境变量
306
+ export DEFAULT_FASTAPI_PORT=2048
307
+ ```
308
+
309
+ ### 使用建议
310
+
311
+ - **日常使用**: 在 `.env` 文件中配置所有常用设置
312
+ - **临时调整**: 使用命令行参数进行临时覆盖,无需修改 `.env` 文件
313
+ - **CI/CD 环境**: 可以通过系统环境变量进行配置
314
+
315
+ ## 注意事项
316
+
317
+ ### 1. 文件安全
318
+
319
+ - `.env` 文件已被 `.gitignore` 忽略,不会被提交到版本控制
320
+ - 请勿在 `.env.example` 中包含真实的敏感信息
321
+ - 如需分享配置,请复制并清理敏感信息后再分享
322
+
323
+ ### 2. 格式要求
324
+
325
+ - 环境变量名区分大小写
326
+ - 布尔值使用 `true`/`false`
327
+ - 数组使用 JSON 格式:`["item1", "item2"]`
328
+ - 字符串值如包含特殊字符,请使用引号
329
+
330
+ ### 3. 重启生效
331
+
332
+ 修改 `.env` 文件后需要重启服务才能生效。
333
+
334
+ ### 4. 验证配置
335
+
336
+ 启动服务时,日志会显示加载的配置信息,可以通过日志验证配置是否正确。
337
+
338
+ ## 故障排除
339
+
340
+ ### 配置未生效
341
+
342
+ 1. 检查 `.env` 文件是否在项目根目录
343
+ 2. 检查环境变量名是否正确(区分大小写)
344
+ 3. 检查值的格式是否正确
345
+ 4. 重启服务
346
+
347
+ ### 代理配置问题
348
+
349
+ 1. 确认代理服务器地址和端口正确
350
+ 2. 检查代理服务器是否正常运行
351
+ 3. 验证网络连接
352
+
353
+ ### 端口冲突
354
+
355
+ 1. 检查端口是否被其他程序占用
356
+ 2. 使用 GUI 启动器的端口检查功能
357
+ 3. 修改为其他可用端口
358
+
359
+ ## 更多信息
360
+
361
+ - [安装指南](installation-guide.md)
362
+ - [高级配置](advanced-configuration.md)
363
+ - [故障排除](troubleshooting.md)