renxsh commited on
Commit
f1b4581
·
1 Parent(s): c266578
AGENTS.md ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Repository Guidelines
2
+
3
+ ## Project Structure & Module Organization
4
+ Snap-Solver is a Flask web app served from `app.py`, which wires Socket.IO streaming, screenshot capture, and model dispatch. Model adapters live in `models/`, with `factory.py` loading provider metadata from `config/models.json` and creating the appropriate client (OpenAI, Anthropic, DeepSeek, Qwen, etc.). User-facing templates live under `templates/`, with shared assets in `static/`. Runtime configuration and secrets are JSON files in `config/`; treat these as local-only overrides even if sample values exist in the repo. Python dependencies are listed in `requirements.txt` (lockfile: `uv.lock`).
5
+
6
+ ## Build, Test, and Development Commands
7
+ - `python -m venv .venv && source .venv/bin/activate` sets up an isolated environment.
8
+ - `pip install -r requirements.txt` or `uv sync` installs Flask, provider SDKs, and Socket.IO.
9
+ - `python app.py` boots the development server at `http://localhost:5000` with verbose engine logs.
10
+ - `FLASK_ENV=development python app.py` enables auto-reload during active development.
11
+
12
+ ## Coding Style & Naming Conventions
13
+ Follow PEP 8: 4-space indentation, `snake_case` for Python functions, and descriptive class names that match provider roles (see `models/openai.py`). JSON configs use lowerCamelCase keys so the web client can consume them directly; keep that convention when adding settings. Client scripts in `static/js/` should stay modular and avoid sprawling event handlers.
14
+
15
+ ## Testing Guidelines
16
+ There is no automated test suite yet; whenever you add features, verify end-to-end by launching `python app.py`, triggering a screenshot from the UI, and confirming Socket.IO events stream without tracebacks. When integrating a new model, seed a temporary key in `config/api_keys.json`, exercise one request, and capture console logs before reverting secrets. If you introduce automated tests, place them in `tests/` and gate external calls behind mocks so the suite can run offline.
17
+
18
+ ## Commit & Pull Request Guidelines
19
+ The history favors concise, imperative commit subjects in Chinese (e.g., `修复发送按钮保存裁剪框数据`). Keep messages under 70 characters, enumerate multi-part changes in the body, and reference related issues with `#123` when applicable. Pull requests should outline the user-visible impact, note any config updates or new dependencies, attach UI screenshots for front-end tweaks, and list manual verification steps so reviewers can reproduce them quickly.
20
+
21
+ ## Configuration & Security Tips
22
+ Never commit real API keys—`.gitignore` already excludes `config/api_keys.json` and other volatile files, so create local copies (`config/api_keys.local.json`) for experimentation. When sharing deployment instructions, direct operators to set API credentials via environment variables or secure vaults and only populate JSON stubs during runtime startup logic.
Dockerfile CHANGED
@@ -1,13 +1,13 @@
1
- FROM python:3.12
2
 
3
- WORKDIR /code
4
 
5
- COPY ./requirements.txt /code/requirements.txt
6
-
7
- RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
8
 
 
9
  COPY . .
10
 
11
- EXPOSE 7860
12
-
13
- CMD ["shiny", "run", "app.py", "--host", "0.0.0.0", "--port", "7860"]
 
1
+ FROM python:3.10-slim
2
 
3
+ WORKDIR /app
4
 
5
+ # 安装依赖
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
 
9
+ # 复制项目所有文件
10
  COPY . .
11
 
12
+ # 启动命令
13
+ CMD ["python", "app.py"]
 
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [2025] [Zippland]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
README.md CHANGED
@@ -1,20 +1,224 @@
1
- ---
2
- title: Snap Solver
3
- emoji: 🌍
4
- colorFrom: yellow
5
- colorTo: indigo
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- ---
10
 
11
- This is a templated Space for [Shiny for Python](https://shiny.rstudio.com/py/).
12
 
 
 
 
13
 
14
- To get started with a new app do the following:
 
 
 
 
 
15
 
16
- 1) Install Shiny with `pip install shiny`
17
- 2) Create a new app with `shiny create`
18
- 3) Then run the app with `shiny run --reload`
19
 
20
- To learn more about this framework please see the [Documentation](https://shiny.rstudio.com/py/docs/overview.html).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <h1 align="center">Snap-Solver <img src="https://img.shields.io/badge/版本-1.5.1-blue" alt="版本"></h1>
 
 
 
 
 
 
 
 
2
 
 
3
 
4
+ <p align="center">
5
+ <b>🔍 一键截屏,自动解题 - 线上考试,从未如此简单</b>
6
+ </p>
7
 
8
+ <p align="center">
9
+ <img src="https://img.shields.io/badge/Python-3.x-blue?logo=python" alt="Python">
10
+ <img src="https://img.shields.io/badge/Framework-Flask-green?logo=flask" alt="Flask">
11
+ <img src="https://img.shields.io/badge/AI-Multi--Model-orange" alt="AI">
12
+ <img src="https://img.shields.io/badge/License-Apache%202.0-lightgrey" alt="License">
13
+ </p>
14
 
 
 
 
15
 
16
+ <p align="center">
17
+ <a href="#-核心特性">核心特性</a> •
18
+ <a href="#-快速开始">快速开始</a> •
19
+ <a href="#-新手教程">新手教程</a> •
20
+ <a href="#-使用指南">使用指南</a> •
21
+ <a href="#-技术架构">技术架构</a> •
22
+ <a href="#-高级配置">高级配置</a> •
23
+ <a href="#-常见问题">常见问题</a> •
24
+ <a href="#-获取帮助">获取帮助</a>
25
+ </p>
26
+
27
+ <div align="center">
28
+ <a href="https://github.com/Zippland/Snap-Solver/releases">
29
+ <img src="https://img.shields.io/badge/⚡%20快速开始-下载最新版本-0366D6?style=for-the-badge&logo=github&logoColor=white" alt="获取Release" width="240" />
30
+ </a>
31
+ &nbsp;&nbsp;&nbsp;&nbsp;
32
+ <a href="docs/beginner-tutorial.md">
33
+ <img src="https://img.shields.io/badge/📘%20零基础入门-阅读新手教程-FF9800?style=for-the-badge&logo=bookstack&logoColor=white" alt="阅读新手教程" width="240" />
34
+ </a>
35
+ &nbsp;&nbsp;&nbsp;&nbsp;
36
+ <a href="mailto:zylanjian@outlook.com">
37
+ <img src="https://img.shields.io/badge/📞%20代部署支持-联系我们-28a745?style=for-the-badge&logo=mail.ru&logoColor=white" alt="联系我们" width="220" />
38
+ </a>
39
+ </div>
40
+ <!-- <p align="center">
41
+ <img src="pic.jpg" alt="Snap-Solver 截图" width="300" />
42
+ </p> -->
43
+
44
+ ## 💫 项目简介
45
+
46
+ **Snap-Solver** 是一个革命性的AI笔试测评工具,专为学生、考生和自学者设计。只需**按下快捷键**,即可自动截取屏幕上的任何题目,通过AI进行分析并提供详细解答。
47
+
48
+ 无论是复杂的数学公式、物理难题、编程问题,还是其他学科的挑战,Snap-Solver都能提供清晰、准确、有条理的解决方案,帮助您更好地理解和掌握知识点。
49
+
50
+ ## 📚 新手教程
51
+
52
+ 第一次使用?按照我们的 [《新手教程》](docs/beginner-tutorial.md) 完成环境准备、模型配置和首次解题演练,全程图文指引,几分钟即可上手。
53
+
54
+ ## 🔧 技术架构
55
+
56
+ ```mermaid
57
+ graph TD
58
+ A[用户界面] --> B[Flask Web服务]
59
+ B --> C{API路由}
60
+ C --> D[截图服务]
61
+ C --> E[OCR识别]
62
+ C --> F[AI分析]
63
+ E --> |Mathpix API| G[文本提取]
64
+ F --> |模型选择| H1[OpenAI]
65
+ F --> |模型选择| H2[Anthropic]
66
+ F --> |模型选择| H3[DeepSeek]
67
+ F --> |模型选择| H4[Alibaba]
68
+ F --> |模型选择| H5[Google]
69
+ F --> |模型选择| H6[Doubao]
70
+ D --> I[Socket.IO实时通信]
71
+ I --> A
72
+ ```
73
+
74
+ ## ✨ 核心特性
75
+
76
+ <table>
77
+ <tr>
78
+ <td width="50%">
79
+ <h3>📱 跨设备协同</h3>
80
+ <ul>
81
+ <li><b>一键截图</b>:按下快捷键,即可在移动设备上查看和分析电脑屏幕</li>
82
+ <li><b>局域网共享</b>:一处部署,多设备访问,提升学习效率</li>
83
+ </ul>
84
+ </td>
85
+ <td width="50%">
86
+ <h3>🧠 多模型AI支持</h3>
87
+ <ul>
88
+ <li><b>GPT 家族</b>:OpenAI强大的推理能力</li>
89
+ <li><b>Claude 家族</b>:Anthropic的高级理解与解释</li>
90
+ <li><b>DeepSeek 家族</b>:专为中文场景优化的模型</li>
91
+ <li><b>QVQ 和 Qwen 家族</b>:以视觉推理闻名的国产AI</li>
92
+ <li><b>Gemini 家族</b>:智商130的非推理AI</li>
93
+ </ul>
94
+ </td>
95
+ </tr>
96
+ <tr>
97
+ <td>
98
+ <h3>🔍 精准识别</h3>
99
+ <ul>
100
+ <li><b>OCR文字识别</b>:准确捕捉图片中的文本</li>
101
+ <li><b>数学公式支持</b>:通过Mathpix精确识别复杂数学符号</li>
102
+ </ul>
103
+ </td>
104
+ <td>
105
+ <h3>🌐 全球无障碍</h3>
106
+ <ul>
107
+ <li><b>VPN代理支持</b>:自定义代理设置,解决网络访问限制</li>
108
+ <li><b>多语言响应</b>:支持定制AI回复语言</li>
109
+ </ul>
110
+ </td>
111
+ </tr>
112
+ <tr>
113
+ <td>
114
+ <h3>💻 全平台兼容</h3>
115
+ <ul>
116
+ <li><b>桌面支持</b>:Windows、MacOS、Linux</li>
117
+ <li><b>移动访问</b>:手机、平板通过浏览器直接使用</li>
118
+ </ul>
119
+ </td>
120
+ <td>
121
+ <h3>⚙️ 高度可定制</h3>
122
+ <ul>
123
+ <li><b>思考深度控制</b>:调整AI的分析深度</li>
124
+ <li><b>自定义提示词</b>:针对特定学科优化提示</li>
125
+ </ul>
126
+ </td>
127
+ </tr>
128
+ </table>
129
+
130
+ ## 🚀 快速开始
131
+
132
+ ### 📋 前置要求
133
+
134
+ - Python 3.x
135
+ - 至少以下一个API Key:
136
+ - OpenAI API Key
137
+ - Anthropic API Key (推荐✅)
138
+ - DeepSeek API Key
139
+ - Alibaba API Key (国内用户首选)
140
+ - Google API Key
141
+ - Mathpix API Key (推荐OCR识别✅)
142
+
143
+ ### 📥 开始使用
144
+
145
+ ```bash
146
+ # 启动应用
147
+ python app.py
148
+ ```
149
+
150
+ ### 📱 访问方式
151
+
152
+ - **本机访问**:打开浏览器,访问 http://localhost:5000
153
+ - **局域网设备访问**:在同一网络的任何设备上访问 `http://[电脑IP]:5000`
154
+
155
+ ### 🎯 使用场景示例
156
+
157
+ - **课后习题**:截取教材或作业中的难题,获取步骤详解
158
+ - **编程调试**:截取代码错误信息,获取修复建议
159
+ - **考试复习**:分析错题并理解解题思路
160
+ - **文献研究**:截取复杂论文段落,获取简化解释
161
+
162
+ ### 🧩 组件详情
163
+
164
+ - **前端**:响应式HTML/CSS/JS界面,支持移动设备
165
+ - **后端**:Flask + SocketIO,提供RESTful API和WebSocket
166
+ - **AI接口**:多模型支持,统一接口标准
167
+ - **图像处理**:高效的截图和裁剪功能
168
+
169
+ ## ⚙️ 高级可调参数
170
+
171
+ - **温度**:调整回答的创造性与确定性(0.1-1.0)
172
+ - **最大输出Token**:控制回答长度
173
+ - **推理深度**:标准模式(快速)或深度思考(详细)
174
+ - **思考预算占比**:平衡思考过程与最终答案的详细程度
175
+ - **系统提示词**:自定义AI的基础行为与专业领域
176
+
177
+ ## ❓ 常见问题
178
+
179
+ <details>
180
+ <summary><b>如何获得最佳识别效果?</b></summary>
181
+ <p>
182
+ 确保截图清晰,包含完整题目和必要上下文。对于数学公式,建议使用Mathpix OCR以获得更准确的识别结果。
183
+ </p>
184
+ </details>
185
+
186
+ <details>
187
+ <summary><b>无法连接到服务怎么办?</b></summary>
188
+ <p>
189
+ 1. 检查防火墙设置是否允许5000端口<br>
190
+ 2. 确认设备在同一局域网内<br>
191
+ 3. 尝试重启应用程序<br>
192
+ 4. 查看控制台日志获取错误信息
193
+ </p>
194
+ </details>
195
+
196
+ <details>
197
+ <summary><b>API调用失败的原因?</b></summary>
198
+ <p>
199
+ 1. API密钥可能无效或余额不足<br>
200
+ 2. 网络连接问题,特别是国际API<br>
201
+ 3. 代理设置不正确<br>
202
+ 4. API服务可能临时不可用
203
+ </p>
204
+ </details>
205
+
206
+ <details>
207
+ <summary><b>如何优化AI回答质量?</b></summary>
208
+ <p>
209
+ 1. 调整系统提示词,添加特定学科的指导<br>
210
+ 2. 根据问题复杂度选择合适的模型<br>
211
+ 3. 对于复杂题目,使用"深度思考"模式<br>
212
+ 4. 确保截取的题目包含完整信息
213
+ </p>
214
+ </details>
215
+
216
+ ## 🤝 获取帮助
217
+
218
+ - **代部署服务**:如果您不擅长编程,需要代部署服务,请联系 [zylanjian@outlook.com](mailto:zylanjian@outlook.com)
219
+ - **问题报告**:在GitHub仓库提交Issue
220
+ - **功能建议**:欢迎通过Issue或邮件提供改进建议
221
+
222
+ ## 📜 开源协议
223
+
224
+ 本项目采用 [Apache 2.0](LICENSE) 协议。
app.ico ADDED
app.py CHANGED
@@ -1,162 +1,1119 @@
1
- import faicons as fa
2
- import plotly.express as px
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
- # Load data and compute static values
5
- from shared import app_dir, tips
6
- from shinywidgets import render_plotly
 
 
 
 
 
 
 
 
7
 
8
- from shiny import reactive, render
9
- from shiny.express import input, ui
 
 
 
 
10
 
11
- bill_rng = (min(tips.total_bill), max(tips.total_bill))
 
 
 
 
 
 
12
 
13
- # Add page title and sidebar
14
- ui.page_opts(title="Restaurant tipping", fillable=True)
 
 
 
 
 
 
15
 
16
- with ui.sidebar(open="desktop"):
17
- ui.input_slider(
18
- "total_bill",
19
- "Bill amount",
20
- min=bill_rng[0],
21
- max=bill_rng[1],
22
- value=bill_rng,
23
- pre="$",
24
- )
25
- ui.input_checkbox_group(
26
- "time",
27
- "Food service",
28
- ["Lunch", "Dinner"],
29
- selected=["Lunch", "Dinner"],
30
- inline=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  )
32
- ui.input_action_button("reset", "Reset filter")
33
-
34
- # Add main content
35
- ICONS = {
36
- "user": fa.icon_svg("user", "regular"),
37
- "wallet": fa.icon_svg("wallet"),
38
- "currency-dollar": fa.icon_svg("dollar-sign"),
39
- "ellipsis": fa.icon_svg("ellipsis"),
40
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
- with ui.layout_columns(fill=False):
43
- with ui.value_box(showcase=ICONS["user"]):
44
- "Total tippers"
45
-
46
- @render.express
47
- def total_tippers():
48
- tips_data().shape[0]
49
-
50
- with ui.value_box(showcase=ICONS["wallet"]):
51
- "Average tip"
52
-
53
- @render.express
54
- def average_tip():
55
- d = tips_data()
56
- if d.shape[0] > 0:
57
- perc = d.tip / d.total_bill
58
- f"{perc.mean():.1%}"
59
-
60
- with ui.value_box(showcase=ICONS["currency-dollar"]):
61
- "Average bill"
62
-
63
- @render.express
64
- def average_bill():
65
- d = tips_data()
66
- if d.shape[0] > 0:
67
- bill = d.total_bill.mean()
68
- f"${bill:.2f}"
69
-
70
-
71
- with ui.layout_columns(col_widths=[6, 6, 12]):
72
- with ui.card(full_screen=True):
73
- ui.card_header("Tips data")
74
-
75
- @render.data_frame
76
- def table():
77
- return render.DataGrid(tips_data())
78
-
79
- with ui.card(full_screen=True):
80
- with ui.card_header(class_="d-flex justify-content-between align-items-center"):
81
- "Total bill vs tip"
82
- with ui.popover(title="Add a color variable", placement="top"):
83
- ICONS["ellipsis"]
84
- ui.input_radio_buttons(
85
- "scatter_color",
86
- None,
87
- ["none", "sex", "smoker", "day", "time"],
88
- inline=True,
89
- )
90
-
91
- @render_plotly
92
- def scatterplot():
93
- color = input.scatter_color()
94
- return px.scatter(
95
- tips_data(),
96
- x="total_bill",
97
- y="tip",
98
- color=None if color == "none" else color,
99
- trendline="lowess",
100
- )
101
-
102
- with ui.card(full_screen=True):
103
- with ui.card_header(class_="d-flex justify-content-between align-items-center"):
104
- "Tip percentages"
105
- with ui.popover(title="Add a color variable"):
106
- ICONS["ellipsis"]
107
- ui.input_radio_buttons(
108
- "tip_perc_y",
109
- "Split by:",
110
- ["sex", "smoker", "day", "time"],
111
- selected="day",
112
- inline=True,
113
- )
114
-
115
- @render_plotly
116
- def tip_perc():
117
- from ridgeplot import ridgeplot
118
-
119
- dat = tips_data()
120
- dat["percent"] = dat.tip / dat.total_bill
121
- yvar = input.tip_perc_y()
122
- uvals = dat[yvar].unique()
123
-
124
- samples = [[dat.percent[dat[yvar] == val]] for val in uvals]
125
-
126
- plt = ridgeplot(
127
- samples=samples,
128
- labels=uvals,
129
- bandwidth=0.01,
130
- colorscale="viridis",
131
- colormode="row-index",
132
- )
133
-
134
- plt.update_layout(
135
- legend=dict(
136
- orientation="h", yanchor="bottom", y=1.02, xanchor="center", x=0.5
137
- )
138
- )
139
-
140
- return plt
141
-
142
-
143
- ui.include_css(app_dir / "styles.css")
144
-
145
- # --------------------------------------------------------
146
- # Reactive calculations and effects
147
- # --------------------------------------------------------
148
-
149
-
150
- @reactive.calc
151
- def tips_data():
152
- bill = input.total_bill()
153
- idx1 = tips.total_bill.between(bill[0], bill[1])
154
- idx2 = tips.time.isin(input.time())
155
- return tips[idx1 & idx2]
156
-
157
-
158
- @reactive.effect
159
- @reactive.event(input.reset)
160
- def _():
161
- ui.update_slider("total_bill", value=bill_rng)
162
- ui.update_checkbox_group("time", selected=["Lunch", "Dinner"])
 
1
+ from flask import Flask, jsonify, render_template, request, send_from_directory
2
+ from flask_socketio import SocketIO
3
+ import pyautogui
4
+ import base64
5
+ from io import BytesIO
6
+ import socket
7
+ from threading import Thread, Event
8
+ import threading
9
+ from PIL import Image
10
+ import pyperclip
11
+ from models import ModelFactory
12
+ import time
13
+ import os
14
+ import json
15
+ import traceback
16
+ import requests
17
+ from datetime import datetime
18
+ import sys
19
 
20
+ app = Flask(__name__)
21
+ socketio = SocketIO(
22
+ app,
23
+ cors_allowed_origins="*",
24
+ ping_timeout=30,
25
+ ping_interval=5,
26
+ max_http_buffer_size=50 * 1024 * 1024,
27
+ async_mode='threading', # 使用threading模式提高兼容性
28
+ engineio_logger=True, # 启用引擎日志,便于调试
29
+ logger=True # 启用Socket.IO日志
30
+ )
31
 
32
+ # 常量定义
33
+ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
34
+ CONFIG_DIR = os.path.join(CURRENT_DIR, 'config')
35
+ STATIC_DIR = os.path.join(CURRENT_DIR, 'static')
36
+ # 确保配置目录存在
37
+ os.makedirs(CONFIG_DIR, exist_ok=True)
38
 
39
+ # 密钥和其他配置文件路径
40
+ API_KEYS_FILE = os.path.join(CONFIG_DIR, 'api_keys.json')
41
+ API_BASE_URLS_FILE = os.path.join(CONFIG_DIR, 'api_base_urls.json')
42
+ VERSION_FILE = os.path.join(CONFIG_DIR, 'version.json')
43
+ UPDATE_INFO_FILE = os.path.join(CONFIG_DIR, 'update_info.json')
44
+ PROMPT_FILE = os.path.join(CONFIG_DIR, 'prompts.json') # 新增提示词配置文件路径
45
+ PROXY_API_FILE = os.path.join(CONFIG_DIR, 'proxy_api.json') # 新增中转API配置文件路径
46
 
47
+ DEFAULT_API_BASE_URLS = {
48
+ "AnthropicApiBaseUrl": "",
49
+ "OpenaiApiBaseUrl": "",
50
+ "DeepseekApiBaseUrl": "",
51
+ "AlibabaApiBaseUrl": "",
52
+ "GoogleApiBaseUrl": "",
53
+ "DoubaoApiBaseUrl": ""
54
+ }
55
 
56
+ def ensure_api_base_urls_file():
57
+ """确保 API 基础 URL 配置文件存在并包含所有占位符"""
58
+ try:
59
+ file_exists = os.path.exists(API_BASE_URLS_FILE)
60
+ base_urls = {}
61
+ if file_exists:
62
+ try:
63
+ with open(API_BASE_URLS_FILE, 'r', encoding='utf-8') as f:
64
+ loaded = json.load(f)
65
+ if isinstance(loaded, dict):
66
+ base_urls = loaded
67
+ else:
68
+ file_exists = False
69
+ except json.JSONDecodeError:
70
+ file_exists = False
71
+
72
+ missing_key_added = False
73
+ for key, default_value in DEFAULT_API_BASE_URLS.items():
74
+ if key not in base_urls:
75
+ base_urls[key] = default_value
76
+ missing_key_added = True
77
+
78
+ if not file_exists or missing_key_added or not base_urls:
79
+ with open(API_BASE_URLS_FILE, 'w', encoding='utf-8') as f:
80
+ json.dump(base_urls or DEFAULT_API_BASE_URLS, f, ensure_ascii=False, indent=2)
81
+ except Exception as e:
82
+ print(f"初始化API基础URL配置失败: {e}")
83
+
84
+ # 确保API基础URL文件已经生成
85
+ ensure_api_base_urls_file()
86
+
87
+ # 跟踪用户生成任务的字典
88
+ generation_tasks = {}
89
+
90
+ # 初始化模型工厂
91
+ ModelFactory.initialize()
92
+
93
+ def get_local_ip():
94
+ try:
95
+ # Get local IP address
96
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
97
+ s.connect(("8.8.8.8", 80))
98
+ ip = s.getsockname()[0]
99
+ s.close()
100
+ return ip
101
+ except Exception:
102
+ return "127.0.0.1"
103
+
104
+ @app.route('/')
105
+ def index():
106
+ local_ip = get_local_ip()
107
+
108
+ # 检查更新
109
+ try:
110
+ update_info = check_for_updates()
111
+ except:
112
+ update_info = {'has_update': False}
113
+
114
+ return render_template('index.html', local_ip=local_ip, update_info=update_info)
115
+
116
+ @socketio.on('connect')
117
+ def handle_connect():
118
+ print('Client connected')
119
+
120
+ @socketio.on('disconnect')
121
+ def handle_disconnect():
122
+ print('Client disconnected')
123
+
124
+ def create_model_instance(model_id, settings, is_reasoning=False):
125
+ """创建模型实例"""
126
+ # 提取API密钥
127
+ api_keys = settings.get('apiKeys', {})
128
+
129
+ # 确定需要哪个API密钥
130
+ api_key_id = None
131
+ # 特殊情况:o3-mini使用OpenAI API密钥
132
+ if model_id.lower() == "o3-mini":
133
+ api_key_id = "OpenaiApiKey"
134
+ # 其他Anthropic/Claude模型
135
+ elif "claude" in model_id.lower() or "anthropic" in model_id.lower():
136
+ api_key_id = "AnthropicApiKey"
137
+ elif any(keyword in model_id.lower() for keyword in ["gpt", "openai"]):
138
+ api_key_id = "OpenaiApiKey"
139
+ elif "deepseek" in model_id.lower():
140
+ api_key_id = "DeepseekApiKey"
141
+ elif "qvq" in model_id.lower() or "alibaba" in model_id.lower() or "qwen" in model_id.lower():
142
+ api_key_id = "AlibabaApiKey"
143
+ elif "gemini" in model_id.lower() or "google" in model_id.lower():
144
+ api_key_id = "GoogleApiKey"
145
+ elif "doubao" in model_id.lower():
146
+ api_key_id = "DoubaoApiKey"
147
+
148
+ # 首先尝试从本地配置获取API密钥
149
+ api_key = get_api_key(api_key_id)
150
+
151
+ # 如果本地没有配置,尝试使用前端传递的密钥(向后兼容)
152
+ if not api_key:
153
+ api_key = api_keys.get(api_key_id)
154
+
155
+ if not api_key:
156
+ raise ValueError(f"API key is required for the selected model (keyId: {api_key_id})")
157
+
158
+ # 获取maxTokens参数,默认为8192
159
+ max_tokens = int(settings.get('maxTokens', 8192))
160
+
161
+ # 检查是否启用中转API
162
+ proxy_api_config = load_proxy_api()
163
+ base_url = None
164
+
165
+ if proxy_api_config.get('enabled', False):
166
+ # 根据模型类型选择对应的中转API
167
+ if "claude" in model_id.lower() or "anthropic" in model_id.lower():
168
+ base_url = proxy_api_config.get('apis', {}).get('anthropic', '')
169
+ elif any(keyword in model_id.lower() for keyword in ["gpt", "openai"]):
170
+ base_url = proxy_api_config.get('apis', {}).get('openai', '')
171
+ elif "deepseek" in model_id.lower():
172
+ base_url = proxy_api_config.get('apis', {}).get('deepseek', '')
173
+ elif "qvq" in model_id.lower() or "alibaba" in model_id.lower() or "qwen" in model_id.lower():
174
+ base_url = proxy_api_config.get('apis', {}).get('alibaba', '')
175
+ elif "gemini" in model_id.lower() or "google" in model_id.lower():
176
+ base_url = proxy_api_config.get('apis', {}).get('google', '')
177
+
178
+ # 从前端设置获取自定义API基础URL (apiBaseUrls)
179
+ api_base_urls = settings.get('apiBaseUrls', {})
180
+ if api_base_urls:
181
+ # 根据模型类型选择对应的自定义API基础URL
182
+ if "claude" in model_id.lower() or "anthropic" in model_id.lower():
183
+ custom_base_url = api_base_urls.get('anthropic')
184
+ if custom_base_url:
185
+ base_url = custom_base_url
186
+ elif any(keyword in model_id.lower() for keyword in ["gpt", "openai"]):
187
+ custom_base_url = api_base_urls.get('openai')
188
+ if custom_base_url:
189
+ base_url = custom_base_url
190
+ elif "deepseek" in model_id.lower():
191
+ custom_base_url = api_base_urls.get('deepseek')
192
+ if custom_base_url:
193
+ base_url = custom_base_url
194
+ elif "qvq" in model_id.lower() or "alibaba" in model_id.lower() or "qwen" in model_id.lower():
195
+ custom_base_url = api_base_urls.get('alibaba')
196
+ if custom_base_url:
197
+ base_url = custom_base_url
198
+ elif "gemini" in model_id.lower() or "google" in model_id.lower():
199
+ custom_base_url = api_base_urls.get('google')
200
+ if custom_base_url:
201
+ base_url = custom_base_url
202
+ elif "doubao" in model_id.lower():
203
+ custom_base_url = api_base_urls.get('doubao')
204
+ if custom_base_url:
205
+ base_url = custom_base_url
206
+
207
+ # 创建模型实例
208
+ model_instance = ModelFactory.create_model(
209
+ model_name=model_id,
210
+ api_key=api_key,
211
+ temperature=None if is_reasoning else float(settings.get('temperature', 0.7)),
212
+ system_prompt=settings.get('systemPrompt'),
213
+ language=settings.get('language', '中文'),
214
+ api_base_url=base_url # 现在BaseModel支持api_base_url参数
215
  )
216
+
217
+ # 设置最大输出Token,但不为阿里巴巴模型设置(它们有自己内部的处理逻辑)
218
+ is_alibaba_model = "qvq" in model_id.lower() or "alibaba" in model_id.lower() or "qwen" in model_id.lower()
219
+ if not is_alibaba_model:
220
+ model_instance.max_tokens = max_tokens
221
+
222
+ return model_instance
223
+
224
+ def stream_model_response(response_generator, sid, model_name=None):
225
+ """Stream model responses to the client"""
226
+ try:
227
+ print("Starting response streaming...")
228
+
229
+ # 判断模型是否为推理模型
230
+ is_reasoning = model_name and ModelFactory.is_reasoning(model_name)
231
+ if is_reasoning:
232
+ print(f"使用推理模型 {model_name},将显示思考过程")
233
+
234
+ # 初始化:发送开始状态
235
+ socketio.emit('ai_response', {
236
+ 'status': 'started',
237
+ 'content': '',
238
+ 'is_reasoning': is_reasoning
239
+ }, room=sid)
240
+ print("Sent initial status to client")
241
+
242
+ # 维护服务端缓冲区以累积完整内容
243
+ response_buffer = ""
244
+ thinking_buffer = ""
245
+
246
+ # 上次发送的时间戳,用于控制发送频率
247
+ last_emit_time = time.time()
248
+
249
+ # 流式处理响应
250
+ for response in response_generator:
251
+ # 处理Mathpix响应
252
+ if isinstance(response.get('content', ''), str) and 'mathpix' in response.get('model', ''):
253
+ if current_time - last_emit_time >= 0.3:
254
+ socketio.emit('ai_response', {
255
+ 'status': 'thinking',
256
+ 'content': thinking_buffer,
257
+ 'is_reasoning': True
258
+ }, room=sid)
259
+ last_emit_time = current_time
260
+
261
+ elif status == 'thinking_complete':
262
+ # 仅对推理模型处理思考过程
263
+ if is_reasoning:
264
+ # 直接使用完整的思考内容
265
+ thinking_buffer = content
266
+
267
+ print(f"Thinking complete, total length: {len(thinking_buffer)} chars")
268
+ socketio.emit('ai_response', {
269
+ 'status': 'thinking_complete',
270
+ 'content': thinking_buffer,
271
+ 'is_reasoning': True
272
+ }, room=sid)
273
+
274
+ elif status == 'streaming':
275
+ # 直接使用模型提供的完整内容
276
+ response_buffer = content
277
+
278
+ # 控制发送频率,至少间隔0.3秒
279
+ current_time = time.time()
280
+ if current_time - last_emit_time >= 0.3:
281
+ socketio.emit('ai_response', {
282
+ 'status': 'streaming',
283
+ 'content': response_buffer,
284
+ 'is_reasoning': is_reasoning
285
+ }, room=sid)
286
+ last_emit_time = current_time
287
+
288
+ elif status == 'completed':
289
+ # 确保发送最终完整内容
290
+ socketio.emit('ai_response', {
291
+ 'status': 'completed',
292
+ 'content': content or response_buffer,
293
+ 'is_reasoning': is_reasoning
294
+ }, room=sid)
295
+ print("Response completed")
296
+
297
+ elif status == 'error':
298
+ # 错误状态直接转发
299
+ response['is_reasoning'] = is_reasoning
300
+ socketio.emit('ai_response', response, room=sid)
301
+ print(f"Error: {response.get('error', 'Unknown error')}")
302
+
303
+ # 其他状态直接转发
304
+ else:
305
+ response['is_reasoning'] = is_reasoning
306
+ socketio.emit('ai_response', response, room=sid)
307
+
308
+ except Exception as e:
309
+ error_msg = f"Streaming error: {str(e)}"
310
+ print(error_msg)
311
+ socketio.emit('ai_response', {
312
+ 'status': 'error',
313
+ 'error': error_msg,
314
+ 'is_reasoning': model_name and ModelFactory.is_reasoning(model_name)
315
+ }, room=sid)
316
+
317
+ @socketio.on('request_screenshot')
318
+ def handle_screenshot_request():
319
+ try:
320
+ # 添加调试信息
321
+ print("DEBUG: 执行request_screenshot截图")
322
+
323
+ # Capture the screen
324
+ screenshot = pyautogui.screenshot()
325
+
326
+ # Convert the image to base64 string
327
+ buffered = BytesIO()
328
+ screenshot.save(buffered, format="PNG")
329
+ img_str = base64.b64encode(buffered.getvalue()).decode()
330
+
331
+ # Emit the screenshot back to the client,不打印base64数据
332
+ print("DEBUG: 完成request_screenshot截图,图片大小: {} KB".format(len(img_str) // 1024))
333
+ socketio.emit('screenshot_response', {
334
+ 'success': True,
335
+ 'image': img_str
336
+ })
337
+ except Exception as e:
338
+ socketio.emit('screenshot_response', {
339
+ 'success': False,
340
+ 'error': str(e)
341
+ })
342
+
343
+ @socketio.on('extract_text')
344
+ def handle_text_extraction(data):
345
+ try:
346
+ print("Starting text extraction...")
347
+
348
+ # Validate input data
349
+ if not data or not isinstance(data, dict):
350
+ raise ValueError("Invalid request data")
351
+
352
+ if 'image' not in data:
353
+ raise ValueError("No image data provided")
354
+
355
+ image_data = data['image']
356
+ if not isinstance(image_data, str):
357
+ raise ValueError("Invalid image data format")
358
+
359
+ # 检查图像大小,避免处理过大的图像导致断开连接
360
+ image_size_bytes = len(image_data) * 3 / 4 # 估算base64的实际大小
361
+ if image_size_bytes > 10 * 1024 * 1024: # 10MB
362
+ raise ValueError("Image too large, please crop to a smaller area")
363
+
364
+ settings = data.get('settings', {})
365
+ if not isinstance(settings, dict):
366
+ raise ValueError("Invalid settings format")
367
+
368
+ # 优先使用百度OCR,如果没有配置则使用Mathpix
369
+ # 首先尝试获取百度OCR API密钥
370
+ baidu_api_key = get_api_key('BaiduApiKey')
371
+ baidu_secret_key = get_api_key('BaiduSecretKey')
372
+
373
+ # 构建百度OCR API密钥(格式:api_key:secret_key)
374
+ ocr_key = None
375
+ ocr_model = None
376
+
377
+ if baidu_api_key and baidu_secret_key:
378
+ ocr_key = f"{baidu_api_key}:{baidu_secret_key}"
379
+ ocr_model = 'baidu-ocr'
380
+ print("Using Baidu OCR for text extraction...")
381
+ else:
382
+ # 回退到Mathpix
383
+ mathpix_app_id = get_api_key('MathpixAppId')
384
+ mathpix_app_key = get_api_key('MathpixAppKey')
385
+
386
+ # 构建完整的Mathpix API密钥(格式:app_id:app_key)
387
+ mathpix_key = f"{mathpix_app_id}:{mathpix_app_key}" if mathpix_app_id and mathpix_app_key else None
388
+
389
+ # 如果本地没有配置,尝试使用前端传递的密钥(向后兼容)
390
+ if not mathpix_key:
391
+ mathpix_key = settings.get('mathpixApiKey')
392
+
393
+ if mathpix_key:
394
+ ocr_key = mathpix_key
395
+ ocr_model = 'mathpix'
396
+ print("Using Mathpix OCR for text extraction...")
397
+
398
+ if not ocr_key:
399
+ raise ValueError("OCR API key is required. Please configure Baidu OCR (API Key + Secret Key) or Mathpix (App ID + App Key)")
400
+
401
+ # 先回复客户端,确认已收到请求,防止超时断开
402
+ # 注意:这里不能使用return,否则后续代码不会执行
403
+ socketio.emit('request_acknowledged', {
404
+ 'status': 'received',
405
+ 'message': f'Image received, text extraction in progress using {ocr_model}'
406
+ }, room=request.sid)
407
+
408
+ try:
409
+ if ocr_model == 'baidu-ocr':
410
+ api_key, secret_key = ocr_key.split(':')
411
+ if not api_key.strip() or not secret_key.strip():
412
+ raise ValueError()
413
+ elif ocr_model == 'mathpix':
414
+ app_id, app_key = ocr_key.split(':')
415
+ if not app_id.strip() or not app_key.strip():
416
+ raise ValueError()
417
+ except ValueError:
418
+ if ocr_model == 'baidu-ocr':
419
+ raise ValueError("Invalid Baidu OCR API key format. Expected format: 'API_KEY:SECRET_KEY'")
420
+ else:
421
+ raise ValueError("Invalid Mathpix API key format. Expected format: 'app_id:app_key'")
422
+
423
+ print(f"Creating {ocr_model} model instance...")
424
+ # ModelFactory.create_model会处理不同模型类型
425
+ model = ModelFactory.create_model(
426
+ model_name=ocr_model,
427
+ api_key=ocr_key
428
+ )
429
+
430
+ print("Starting text extraction...")
431
+ # 使用新的extract_full_text方法直接提取完整文本
432
+ extracted_text = model.extract_full_text(image_data)
433
+
434
+ # 直接返回文本结果
435
+ socketio.emit('text_extracted', {
436
+ 'content': extracted_text
437
+ }, room=request.sid)
438
+
439
+ except ValueError as e:
440
+ error_msg = str(e)
441
+ print(f"Validation error: {error_msg}")
442
+ socketio.emit('text_extracted', {
443
+ 'error': error_msg
444
+ }, room=request.sid)
445
+ except Exception as e:
446
+ error_msg = f"Text extraction error: {str(e)}"
447
+ print(f"Unexpected error: {error_msg}")
448
+ print(f"Error details: {type(e).__name__}")
449
+ socketio.emit('text_extracted', {
450
+ 'error': error_msg
451
+ }, room=request.sid)
452
+
453
+ @socketio.on('stop_generation')
454
+ def handle_stop_generation():
455
+ """处理停止生成请求"""
456
+ sid = request.sid
457
+ print(f"接收到停止生成请求: {sid}")
458
+
459
+ if sid in generation_tasks:
460
+ # 设置停止标志
461
+ stop_event = generation_tasks[sid]
462
+ stop_event.set()
463
+
464
+ # 发送已停止状态
465
+ socketio.emit('ai_response', {
466
+ 'status': 'stopped',
467
+ 'content': '生成已停止'
468
+ }, room=sid)
469
+
470
+ print(f"已停止用户 {sid} 的生成任务")
471
+ else:
472
+ print(f"未找到用户 {sid} 的生成任务")
473
+
474
+ @socketio.on('analyze_text')
475
+ def handle_analyze_text(data):
476
+ try:
477
+ text = data.get('text', '')
478
+ settings = data.get('settings', {})
479
+
480
+ # 获取推理配置
481
+ reasoning_config = settings.get('reasoningConfig', {})
482
+
483
+ # 获取maxTokens
484
+ max_tokens = int(settings.get('maxTokens', 8192))
485
+
486
+ print(f"Debug - 文本分析请求: {text[:50]}...")
487
+ print(f"Debug - 最大Token: {max_tokens}, 推理配置: {reasoning_config}")
488
+
489
+ # 获取模型和API密钥
490
+ model_id = settings.get('model', 'claude-3-7-sonnet-20250219')
491
+
492
+ if not text:
493
+ socketio.emit('error', {'message': '文本内容不能为空'})
494
+ return
495
+
496
+ # 获取模型信息,判断是否为推理模型
497
+ model_info = settings.get('modelInfo', {})
498
+ is_reasoning = model_info.get('isReasoning', False)
499
+
500
+ model_instance = create_model_instance(model_id, settings, is_reasoning)
501
+
502
+ # 将推理配置传递给模型
503
+ if reasoning_config:
504
+ model_instance.reasoning_config = reasoning_config
505
+
506
+ # 如果启用代理,配置代理设置
507
+ proxies = None
508
+ if settings.get('proxyEnabled'):
509
+ proxies = {
510
+ 'http': f"http://{settings.get('proxyHost')}:{settings.get('proxyPort')}",
511
+ 'https': f"http://{settings.get('proxyHost')}:{settings.get('proxyPort')}"
512
+ }
513
+
514
+ # 创建用于停止生成的事件
515
+ sid = request.sid
516
+ stop_event = Event()
517
+ generation_tasks[sid] = stop_event
518
+
519
+ try:
520
+ for response in model_instance.analyze_text(text, proxies=proxies):
521
+ # 检查是否收到停止信号
522
+ if stop_event.is_set():
523
+ print(f"分析文本生成被用户 {sid} 停止")
524
+ break
525
+
526
+ socketio.emit('ai_response', response, room=sid)
527
+ finally:
528
+ # 清理任务
529
+ if sid in generation_tasks:
530
+ del generation_tasks[sid]
531
+
532
+ except Exception as e:
533
+ print(f"Error in analyze_text: {str(e)}")
534
+ traceback.print_exc()
535
+ socketio.emit('error', {'message': f'分析文本时出错: {str(e)}'})
536
+
537
+ @socketio.on('analyze_image')
538
+ def handle_analyze_image(data):
539
+ try:
540
+ image_data = data.get('image')
541
+ settings = data.get('settings', {})
542
+
543
+ # 获取推理配置
544
+ reasoning_config = settings.get('reasoningConfig', {})
545
+
546
+ # 获取maxTokens
547
+ max_tokens = int(settings.get('maxTokens', 8192))
548
+
549
+ print(f"Debug - 图像分析请求")
550
+ print(f"Debug - 最大Token: {max_tokens}, 推理配置: {reasoning_config}")
551
+
552
+ # 获取模型和API密钥
553
+ model_id = settings.get('model', 'claude-3-7-sonnet-20250219')
554
+
555
+ if not image_data:
556
+ socketio.emit('error', {'message': '图像数据不能为空'})
557
+ return
558
+
559
+ # 获取模型信息,判断是否为推理模型
560
+ model_info = settings.get('modelInfo', {})
561
+ is_reasoning = model_info.get('isReasoning', False)
562
+
563
+ model_instance = create_model_instance(model_id, settings, is_reasoning)
564
+
565
+ # 将推理配置传递给模型
566
+ if reasoning_config:
567
+ model_instance.reasoning_config = reasoning_config
568
+
569
+ # 如果启用代理,配置代理设置
570
+ proxies = None
571
+ if settings.get('proxyEnabled'):
572
+ proxies = {
573
+ 'http': f"http://{settings.get('proxyHost')}:{settings.get('proxyPort')}",
574
+ 'https': f"http://{settings.get('proxyHost')}:{settings.get('proxyPort')}"
575
+ }
576
+
577
+ # 创建用于停止生成的事件
578
+ sid = request.sid
579
+ stop_event = Event()
580
+ generation_tasks[sid] = stop_event
581
+
582
+ try:
583
+ for response in model_instance.analyze_image(image_data, proxies=proxies):
584
+ # 检查是否收到停止信号
585
+ if stop_event.is_set():
586
+ print(f"分析图像生成被用户 {sid} 停止")
587
+ break
588
+
589
+ socketio.emit('ai_response', response, room=sid)
590
+ finally:
591
+ # 清理任务
592
+ if sid in generation_tasks:
593
+ del generation_tasks[sid]
594
+
595
+ except Exception as e:
596
+ print(f"Error in analyze_image: {str(e)}")
597
+ traceback.print_exc()
598
+ socketio.emit('error', {'message': f'分析图像时出错: {str(e)}'})
599
+
600
+ @socketio.on('capture_screenshot')
601
+ def handle_capture_screenshot(data):
602
+ try:
603
+ # 添加调试信息
604
+ print("DEBUG: 执行capture_screenshot截图")
605
+
606
+ # Capture the screen
607
+ screenshot = pyautogui.screenshot()
608
+
609
+ # Convert the image to base64 string
610
+ buffered = BytesIO()
611
+ screenshot.save(buffered, format="PNG")
612
+ img_str = base64.b64encode(buffered.getvalue()).decode()
613
+
614
+ # Emit the screenshot back to the client,不打印base64数据
615
+ print("DEBUG: 完成capture_screenshot截图,图片大小: {} KB".format(len(img_str) // 1024))
616
+ socketio.emit('screenshot_complete', {
617
+ 'success': True,
618
+ 'image': img_str
619
+ }, room=request.sid)
620
+ except Exception as e:
621
+ error_msg = f"Screenshot error: {str(e)}"
622
+ print(f"Error capturing screenshot: {error_msg}")
623
+ socketio.emit('screenshot_complete', {
624
+ 'success': False,
625
+ 'error': error_msg
626
+ }, room=request.sid)
627
+
628
+ def load_model_config():
629
+ """加载模型配置信息"""
630
+ try:
631
+ config_path = os.path.join(CONFIG_DIR, 'models.json')
632
+ with open(config_path, 'r', encoding='utf-8') as f:
633
+ config = json.load(f)
634
+ return config
635
+ except Exception as e:
636
+ print(f"加载模型配置失败: {e}")
637
+ return {
638
+ "providers": {},
639
+ "models": {}
640
+ }
641
+
642
+ def load_prompts():
643
+ """加载系统提示词配置"""
644
+ try:
645
+ if os.path.exists(PROMPT_FILE):
646
+ with open(PROMPT_FILE, 'r', encoding='utf-8') as f:
647
+ return json.load(f)
648
+ else:
649
+ # 如果文件不存在,创建默认提示词配置
650
+ default_prompts = {
651
+ "default": {
652
+ "name": "默认提示词",
653
+ "content": "您是一位专业的问题解决专家。请逐步分析问题,找出问题所在,并提供详细的解决方案。始终使用用户偏好的语言回答。",
654
+ "description": "通用问题解决提示词"
655
+ }
656
+ }
657
+ with open(PROMPT_FILE, 'w', encoding='utf-8') as f:
658
+ json.dump(default_prompts, f, ensure_ascii=False, indent=4)
659
+ return default_prompts
660
+ except Exception as e:
661
+ print(f"加载提示词配置失败: {e}")
662
+ return {
663
+ "default": {
664
+ "name": "默认提示词",
665
+ "content": "您是一位专业的问题解决专家。请逐步分析问题,找出问题所在,并提供详细的解决方案。始终使用用户偏好的语言回答。",
666
+ "description": "通用问题解决提示词"
667
+ }
668
+ }
669
+
670
+ def save_prompt(prompt_id, prompt_data):
671
+ """保存单个提示词到配置文件"""
672
+ try:
673
+ prompts = load_prompts()
674
+ prompts[prompt_id] = prompt_data
675
+ with open(PROMPT_FILE, 'w', encoding='utf-8') as f:
676
+ json.dump(prompts, f, ensure_ascii=False, indent=4)
677
+ return True
678
+ except Exception as e:
679
+ print(f"保存提示词配置失败: {e}")
680
+ return False
681
+
682
+ def delete_prompt(prompt_id):
683
+ """从配置文件中删除一个提示词"""
684
+ try:
685
+ prompts = load_prompts()
686
+ if prompt_id in prompts:
687
+ del prompts[prompt_id]
688
+ with open(PROMPT_FILE, 'w', encoding='utf-8') as f:
689
+ json.dump(prompts, f, ensure_ascii=False, indent=4)
690
+ return True
691
+ return False
692
+ except Exception as e:
693
+ print(f"删除提示词配置失败: {e}")
694
+ return False
695
+
696
+ # 替换 before_first_request 装饰器
697
+ def init_model_config():
698
+ """初始化模型配置"""
699
+ try:
700
+ model_config = load_model_config()
701
+ # 更新ModelFactory的模型信息
702
+ if hasattr(ModelFactory, 'update_model_capabilities'):
703
+ ModelFactory.update_model_capabilities(model_config)
704
+ print("已加载模型配置")
705
+ except Exception as e:
706
+ print(f"初始化模型配置失败: {e}")
707
+
708
+ # 在请求处理前注册初始化函数
709
+ @app.before_request
710
+ def before_request_handler():
711
+ # 使用全局变量跟踪是否已初始化
712
+ if not getattr(app, '_model_config_initialized', False):
713
+ init_model_config()
714
+ app._model_config_initialized = True
715
+
716
+ # 版本检查函数
717
+ def check_for_updates():
718
+ """检查GitHub上是否有新版本"""
719
+ try:
720
+ # 读取当前版本信息
721
+ version_file = os.path.join(CONFIG_DIR, 'version.json')
722
+ with open(version_file, 'r', encoding='utf-8') as f:
723
+ version_info = json.load(f)
724
+
725
+ current_version = version_info.get('version', '0.0.0')
726
+ repo = version_info.get('github_repo', 'Zippland/Snap-Solver')
727
+
728
+ # 请求GitHub API获取最新发布版本
729
+ api_url = f"https://api.github.com/repos/{repo}/releases/latest"
730
+
731
+ # 添加User-Agent以符合GitHub API要求
732
+ headers = {'User-Agent': 'Snap-Solver-Update-Checker'}
733
+
734
+ response = requests.get(api_url, headers=headers, timeout=5)
735
+ if response.status_code == 200:
736
+ latest_release = response.json()
737
+ latest_version = latest_release.get('tag_name', '').lstrip('v')
738
+
739
+ # 如果版本号为空,尝试从名称中提取
740
+ if not latest_version and 'name' in latest_release:
741
+ import re
742
+ version_match = re.search(r'v?(\d+\.\d+\.\d+)', latest_release['name'])
743
+ if version_match:
744
+ latest_version = version_match.group(1)
745
+
746
+ # 比较版本号(简单比较,可以改进为更复杂的语义版本比较)
747
+ has_update = compare_versions(latest_version, current_version)
748
+
749
+ update_info = {
750
+ 'has_update': has_update,
751
+ 'current_version': current_version,
752
+ 'latest_version': latest_version,
753
+ 'release_url': latest_release.get('html_url', f"https://github.com/{repo}/releases/latest"),
754
+ 'release_date': latest_release.get('published_at', ''),
755
+ 'release_notes': latest_release.get('body', ''),
756
+ }
757
+
758
+ # 缓存更新信息
759
+ update_info_file = os.path.join(CONFIG_DIR, 'update_info.json')
760
+ with open(update_info_file, 'w', encoding='utf-8') as f:
761
+ json.dump(update_info, f, ensure_ascii=False, indent=2)
762
+
763
+ return update_info
764
+
765
+ # 如果无法连接GitHub,尝试读取缓存的更新信息
766
+ update_info_file = os.path.join(CONFIG_DIR, 'update_info.json')
767
+ if os.path.exists(update_info_file):
768
+ with open(update_info_file, 'r', encoding='utf-8') as f:
769
+ return json.load(f)
770
+
771
+ return {'has_update': False, 'current_version': current_version}
772
+
773
+ except Exception as e:
774
+ print(f"检查更新失败: {str(e)}")
775
+ # 出错时返回一个默认的值
776
+ return {'has_update': False, 'error': str(e)}
777
+
778
+ def compare_versions(version1, version2):
779
+ """比较两个版本号,如果version1比version2更新,则返回True"""
780
+ try:
781
+ v1_parts = [int(x) for x in version1.split('.')]
782
+ v2_parts = [int(x) for x in version2.split('.')]
783
+
784
+ # 确保两个版本号的组成部分长度相同
785
+ while len(v1_parts) < len(v2_parts):
786
+ v1_parts.append(0)
787
+ while len(v2_parts) < len(v1_parts):
788
+ v2_parts.append(0)
789
+
790
+ # 逐部分比较
791
+ for i in range(len(v1_parts)):
792
+ if v1_parts[i] > v2_parts[i]:
793
+ return True
794
+ elif v1_parts[i] < v2_parts[i]:
795
+ return False
796
+
797
+ # 完全相同的版本
798
+ return False
799
+ except:
800
+ # 如果解析出错,默认不更新
801
+ return False
802
+
803
+ @app.route('/api/check-update', methods=['GET'])
804
+ def api_check_update():
805
+ """检查更新的API端点"""
806
+ update_info = check_for_updates()
807
+ return jsonify(update_info)
808
+
809
+ # 添加配置文件路由
810
+ @app.route('/config/<path:filename>')
811
+ def serve_config(filename):
812
+ return send_from_directory(CONFIG_DIR, filename)
813
+
814
+ # 添加用于获取所有模型信息的API
815
+ @app.route('/api/models', methods=['GET'])
816
+ def get_models():
817
+ """返回可用的模型列表"""
818
+ models = ModelFactory.get_available_models()
819
+ return jsonify(models)
820
+
821
+ # 获取所有API密钥
822
+ @app.route('/api/keys', methods=['GET'])
823
+ def get_api_keys():
824
+ """获取所有API密钥"""
825
+ api_keys = load_api_keys()
826
+ return jsonify(api_keys)
827
+
828
+ # 保存API密钥
829
+ @app.route('/api/keys', methods=['POST'])
830
+ def update_api_keys():
831
+ """更新API密钥配置"""
832
+ try:
833
+ new_keys = request.json
834
+ if not isinstance(new_keys, dict):
835
+ return jsonify({"success": False, "message": "无效的API密钥格式"}), 400
836
+
837
+ # 加载当前密钥
838
+ current_keys = load_api_keys()
839
+
840
+ # 更新密钥
841
+ for key, value in new_keys.items():
842
+ current_keys[key] = value
843
+
844
+ # 保存回文件
845
+ if save_api_keys(current_keys):
846
+ return jsonify({"success": True, "message": "API密钥已保存"})
847
+ else:
848
+ return jsonify({"success": False, "message": "保存API密钥失败"}), 500
849
+
850
+ except Exception as e:
851
+ return jsonify({"success": False, "message": f"更新API密钥错误: {str(e)}"}), 500
852
+
853
+ # 加载API密钥配置
854
+ def load_api_keys():
855
+ """从配置文件加载API密钥"""
856
+ try:
857
+ default_keys = {
858
+ "AnthropicApiKey": "",
859
+ "OpenaiApiKey": "",
860
+ "DeepseekApiKey": "",
861
+ "AlibabaApiKey": "",
862
+ "MathpixAppId": "",
863
+ "MathpixAppKey": "",
864
+ "GoogleApiKey": "",
865
+ "DoubaoApiKey": "",
866
+ "BaiduApiKey": "",
867
+ "BaiduSecretKey": ""
868
+ }
869
+ if os.path.exists(API_KEYS_FILE):
870
+ with open(API_KEYS_FILE, 'r', encoding='utf-8') as f:
871
+ api_keys = json.load(f)
872
+
873
+ # 确保新增的密钥占位符能自动补充
874
+ missing_key_added = False
875
+ for key, default_value in default_keys.items():
876
+ if key not in api_keys:
877
+ api_keys[key] = default_value
878
+ missing_key_added = True
879
+
880
+ if missing_key_added:
881
+ save_api_keys(api_keys)
882
+
883
+ return api_keys
884
+ else:
885
+ # 如果文件不存在,创建默认配置
886
+ save_api_keys(default_keys)
887
+ return default_keys
888
+ except Exception as e:
889
+ print(f"加载API密钥配置失败: {e}")
890
+ return {}
891
+
892
+ # 加载中转API配置
893
+ def load_proxy_api():
894
+ """从配置文件加载中转API配置"""
895
+ try:
896
+ if os.path.exists(PROXY_API_FILE):
897
+ with open(PROXY_API_FILE, 'r', encoding='utf-8') as f:
898
+ return json.load(f)
899
+ else:
900
+ # 如果文件不存在,创建默认配置
901
+ default_proxy_apis = {
902
+ "enabled": False,
903
+ "apis": {
904
+ "anthropic": "",
905
+ "openai": "",
906
+ "deepseek": "",
907
+ "alibaba": "",
908
+ "google": ""
909
+ }
910
+ }
911
+ save_proxy_api(default_proxy_apis)
912
+ return default_proxy_apis
913
+ except Exception as e:
914
+ print(f"加载中转API配置失败: {e}")
915
+ return {"enabled": False, "apis": {}}
916
+
917
+ # 保存中转API配置
918
+ def save_proxy_api(proxy_api_config):
919
+ """保存中转API配置到文件"""
920
+ try:
921
+ # 确保配置目录存在
922
+ os.makedirs(os.path.dirname(PROXY_API_FILE), exist_ok=True)
923
+
924
+ with open(PROXY_API_FILE, 'w', encoding='utf-8') as f:
925
+ json.dump(proxy_api_config, f, ensure_ascii=False, indent=2)
926
+ return True
927
+ except Exception as e:
928
+ print(f"保存中转API配置失败: {e}")
929
+ return False
930
+
931
+ # 保存API密钥配置
932
+ def save_api_keys(api_keys):
933
+ try:
934
+ # 确保配置目录存在
935
+ os.makedirs(os.path.dirname(API_KEYS_FILE), exist_ok=True)
936
+
937
+ with open(API_KEYS_FILE, 'w', encoding='utf-8') as f:
938
+ json.dump(api_keys, f, ensure_ascii=False, indent=2)
939
+ return True
940
+ except Exception as e:
941
+ print(f"保存API密钥配置失败: {e}")
942
+ return False
943
+
944
+ # 获取特定API密钥
945
+ def get_api_key(key_name):
946
+ """获取指定的API密钥"""
947
+ api_keys = load_api_keys()
948
+ return api_keys.get(key_name, "")
949
+
950
+ @app.route('/api/models')
951
+ def api_models():
952
+ """API端点:获取可用模型列表"""
953
+ try:
954
+ # 加载模型配置
955
+ config = load_model_config()
956
+
957
+ # 转换为前端需要的格式
958
+ models = []
959
+ for model_id, model_info in config['models'].items():
960
+ models.append({
961
+ 'id': model_id,
962
+ 'display_name': model_info.get('name', model_id),
963
+ 'is_multimodal': model_info.get('supportsMultimodal', False),
964
+ 'is_reasoning': model_info.get('isReasoning', False),
965
+ 'description': model_info.get('description', ''),
966
+ 'version': model_info.get('version', 'latest')
967
+ })
968
+
969
+ # 返回模型列表
970
+ return jsonify(models)
971
+ except Exception as e:
972
+ print(f"获取模型列表时出错: {e}")
973
+ return jsonify([]), 500
974
+
975
+ @app.route('/api/prompts', methods=['GET'])
976
+ def get_prompts():
977
+ """API端点:获取所有系统提示词"""
978
+ try:
979
+ prompts = load_prompts()
980
+ return jsonify(prompts)
981
+ except Exception as e:
982
+ print(f"获取提示词列表时出错: {e}")
983
+ return jsonify({"error": str(e)}), 500
984
+
985
+ @app.route('/api/prompts/<prompt_id>', methods=['GET'])
986
+ def get_prompt(prompt_id):
987
+ """API端点:获取单个系统提示词"""
988
+ try:
989
+ prompts = load_prompts()
990
+ if prompt_id in prompts:
991
+ return jsonify(prompts[prompt_id])
992
+ else:
993
+ return jsonify({"error": "提示词不存在"}), 404
994
+ except Exception as e:
995
+ print(f"获取提示词时出错: {e}")
996
+ return jsonify({"error": str(e)}), 500
997
+
998
+ @app.route('/api/prompts', methods=['POST'])
999
+ def add_prompt():
1000
+ """API端点:添加或更新系统提示词"""
1001
+ try:
1002
+ data = request.json
1003
+ if not data or not isinstance(data, dict):
1004
+ return jsonify({"error": "无效的请求数据"}), 400
1005
+
1006
+ prompt_id = data.get('id')
1007
+ if not prompt_id:
1008
+ return jsonify({"error": "提示词ID不能为空"}), 400
1009
+
1010
+ prompt_data = {
1011
+ "name": data.get('name', f"提示词{prompt_id}"),
1012
+ "content": data.get('content', ""),
1013
+ "description": data.get('description', "")
1014
+ }
1015
+
1016
+ save_prompt(prompt_id, prompt_data)
1017
+ return jsonify({"success": True, "id": prompt_id})
1018
+ except Exception as e:
1019
+ print(f"保存提示词时出错: {e}")
1020
+ return jsonify({"error": str(e)}), 500
1021
+
1022
+ @app.route('/api/prompts/<prompt_id>', methods=['DELETE'])
1023
+ def remove_prompt(prompt_id):
1024
+ """API端点:删除系统提示词"""
1025
+ try:
1026
+ success = delete_prompt(prompt_id)
1027
+ if success:
1028
+ return jsonify({"success": True})
1029
+ else:
1030
+ return jsonify({"error": "提示词不存在或删除失败"}), 404
1031
+ except Exception as e:
1032
+ print(f"删除提示词时出错: {e}")
1033
+ return jsonify({"error": str(e)}), 500
1034
+
1035
+ @app.route('/api/proxy-api', methods=['GET'])
1036
+ def get_proxy_api():
1037
+ """API端点:获取中转API配置"""
1038
+ try:
1039
+ proxy_api_config = load_proxy_api()
1040
+ return jsonify(proxy_api_config)
1041
+ except Exception as e:
1042
+ print(f"获��中转API配置时出错: {e}")
1043
+ return jsonify({"error": str(e)}), 500
1044
+
1045
+ @app.route('/api/proxy-api', methods=['POST'])
1046
+ def update_proxy_api():
1047
+ """API端点:更新中转API配置"""
1048
+ try:
1049
+ new_config = request.json
1050
+ if not isinstance(new_config, dict):
1051
+ return jsonify({"success": False, "message": "无效的中转API配置格式"}), 400
1052
+
1053
+ # 保存回文件
1054
+ if save_proxy_api(new_config):
1055
+ return jsonify({"success": True, "message": "中转API配置已保存"})
1056
+ else:
1057
+ return jsonify({"success": False, "message": "保存中转API配置失败"}), 500
1058
+
1059
+ except Exception as e:
1060
+ return jsonify({"success": False, "message": f"更新中转API配置错误: {str(e)}"}), 500
1061
+
1062
+ @app.route('/api/clipboard', methods=['POST'])
1063
+ def update_clipboard():
1064
+ """将文本复制到服务器剪贴板"""
1065
+ try:
1066
+ data = request.get_json(silent=True) or {}
1067
+ text = data.get('text', '')
1068
+
1069
+ if not isinstance(text, str) or not text.strip():
1070
+ return jsonify({"success": False, "message": "剪贴板内容不能为空"}), 400
1071
+
1072
+ # 直接尝试复制,不使用is_available()检查
1073
+ try:
1074
+ pyperclip.copy(text)
1075
+ return jsonify({"success": True})
1076
+ except Exception as e:
1077
+ return jsonify({"success": False, "message": f"复制到剪贴板失败: {str(e)}"}), 500
1078
+ except Exception as e:
1079
+ app.logger.exception("更新剪贴板时发生异常")
1080
+ return jsonify({"success": False, "message": f"服务器内部错误: {str(e)}"}), 500
1081
+
1082
+ @app.route('/api/clipboard', methods=['GET'])
1083
+ def get_clipboard():
1084
+ """从服务器剪贴板读取文本"""
1085
+ try:
1086
+ # 直接尝试读取,不使用is_available()检查
1087
+ try:
1088
+ text = pyperclip.paste()
1089
+ if text is None:
1090
+ text = ""
1091
+
1092
+ return jsonify({
1093
+ "success": True,
1094
+ "text": text,
1095
+ "message": "成功读取剪贴板内容"
1096
+ })
1097
+ except Exception as e:
1098
+ return jsonify({"success": False, "message": f"读取剪贴板失败: {str(e)}"}), 500
1099
+ except Exception as e:
1100
+ app.logger.exception("读取剪贴板时发生异常")
1101
+ return jsonify({"success": False, "message": f"服务器内部错误: {str(e)}"}), 500
1102
+
1103
+ if __name__ == '__main__':
1104
+ # 从环境变量获取PORT,如果没有则默认为5000(本地调试用)
1105
+ import os
1106
+ port = int(os.environ.get('PORT', 5000))
1107
+
1108
+ local_ip = get_local_ip()
1109
+ print(f"Local IP Address: {local_ip}")
1110
+ print(f"Connect from your mobile device using: {local_ip}:{port}")
1111
+
1112
+ # 加载模型配置 (保持不变)
1113
+ model_config = load_model_config()
1114
+ if hasattr(ModelFactory, 'update_model_capabilities'):
1115
+ ModelFactory.update_model_capabilities(model_config)
1116
+ print("已加载模型配置信息")
1117
 
1118
+ # 运行应用,并绑定到 0.0.0.0 和动态端口
1119
+ socketio.run(app, host='0.0.0.0', port=port, allow_unsafe_werkzeug=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
config/api_base_urls.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "AnthropicApiBaseUrl": "",
3
+ "OpenaiApiBaseUrl": "",
4
+ "DeepseekApiBaseUrl": "",
5
+ "AlibabaApiBaseUrl": "",
6
+ "GoogleApiBaseUrl": "",
7
+ "DoubaoApiBaseUrl": ""
8
+ }
config/api_keys.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "AnthropicApiKey": "",
3
+ "OpenaiApiKey": "",
4
+ "DeepseekApiKey": "",
5
+ "AlibabaApiKey": "",
6
+ "MathpixAppId": "",
7
+ "MathpixAppKey": "",
8
+ "GoogleApiKey": "AIzaSyCoK-0ku5mFHjoOADjamOiZdqMp4zOezkQ",
9
+ "DoubaoApiKey": "",
10
+ "BaiduApiKey": "",
11
+ "BaiduSecretKey": ""
12
+ }
config/models.json ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "providers": {
3
+ "anthropic": {
4
+ "name": "Anthropic",
5
+ "api_key_id": "AnthropicApiKey",
6
+ "class_name": "AnthropicModel"
7
+ },
8
+ "openai": {
9
+ "name": "OpenAI",
10
+ "api_key_id": "OpenaiApiKey",
11
+ "class_name": "OpenAIModel"
12
+ },
13
+ "deepseek": {
14
+ "name": "DeepSeek",
15
+ "api_key_id": "DeepseekApiKey",
16
+ "class_name": "DeepSeekModel"
17
+ },
18
+ "alibaba": {
19
+ "name": "Alibaba",
20
+ "api_key_id": "AlibabaApiKey",
21
+ "class_name": "AlibabaModel"
22
+ },
23
+ "google": {
24
+ "name": "Google",
25
+ "api_key_id": "GoogleApiKey",
26
+ "class_name": "GoogleModel"
27
+ },
28
+ "doubao": {
29
+ "name": "Doubao",
30
+ "api_key_id": "DoubaoApiKey",
31
+ "class_name": "DoubaoModel"
32
+ }
33
+ },
34
+ "models": {
35
+ "claude-opus-4-20250514": {
36
+ "name": "Claude 4 Opus",
37
+ "provider": "anthropic",
38
+ "supportsMultimodal": true,
39
+ "isReasoning": true,
40
+ "version": "20250514",
41
+ "description": "最强大的Claude 4 Opus模型,支持图像理解和深度思考过程"
42
+ },
43
+ "claude-opus-4-1-20250805": {
44
+ "name": "Claude 4.1 Opus",
45
+ "provider": "anthropic",
46
+ "supportsMultimodal": true,
47
+ "isReasoning": false,
48
+ "version": "20250805",
49
+ "description": "Claude Opus 4.1 最新标准模式,快速响应并支持多模态输入"
50
+ },
51
+ "claude-opus-4-1-20250805-thinking": {
52
+ "name": "Claude 4.1 Opus (Thinking)",
53
+ "provider": "anthropic",
54
+ "supportsMultimodal": true,
55
+ "isReasoning": true,
56
+ "version": "20250805",
57
+ "description": "Claude Opus 4.1 思考模式,启用更长思考过程以提升推理质量"
58
+ },
59
+ "claude-sonnet-4-20250514": {
60
+ "name": "Claude 4 Sonnet",
61
+ "provider": "anthropic",
62
+ "supportsMultimodal": true,
63
+ "isReasoning": true,
64
+ "version": "20250514",
65
+ "description": "高性能的Claude 4 Sonnet模型,支持图像理解和思考过程"
66
+ },
67
+ "claude-sonnet-4-5-20250929": {
68
+ "name": "Claude 4.5 Sonnet",
69
+ "provider": "anthropic",
70
+ "supportsMultimodal": true,
71
+ "isReasoning": true,
72
+ "version": "20250929",
73
+ "description": "Claude Sonnet 4.5 版,兼具多模态理解与最新推理能力"
74
+ },
75
+ "gpt-4o-2024-11-20": {
76
+ "name": "GPT-4o",
77
+ "provider": "openai",
78
+ "supportsMultimodal": true,
79
+ "isReasoning": false,
80
+ "version": "2024-11-20",
81
+ "description": "OpenAI的GPT-4o模型,支持图像理解"
82
+ },
83
+ "gpt-5-2025-08-07": {
84
+ "name": "GPT-5",
85
+ "provider": "openai",
86
+ "supportsMultimodal": true,
87
+ "isReasoning": true,
88
+ "version": "2025-08-07",
89
+ "description": "OpenAI旗舰级GPT-5模型,支持多模态输入与高级推理"
90
+ },
91
+ "gpt-5-1": {
92
+ "name": "GPT-5.1",
93
+ "provider": "openai",
94
+ "supportsMultimodal": true,
95
+ "isReasoning": true,
96
+ "version": "latest",
97
+ "description": "GPT-5.1 新版旗舰模型,强化长上下文与推理表现"
98
+ },
99
+ "gpt-5-codex-high": {
100
+ "name": "GPT Codex High",
101
+ "provider": "openai",
102
+ "supportsMultimodal": false,
103
+ "isReasoning": true,
104
+ "version": "latest",
105
+ "description": "OpenAI高性能代码模型Codex High,侧重复杂代码生成与重构"
106
+ },
107
+ "o3-mini": {
108
+ "name": "o3-mini",
109
+ "provider": "openai",
110
+ "supportsMultimodal": false,
111
+ "isReasoning": true,
112
+ "version": "latest",
113
+ "description": "OpenAI的o3-mini模型,支持图像理解和思考过程"
114
+ },
115
+ "deepseek-chat": {
116
+ "name": "DeepSeek-V3",
117
+ "provider": "deepseek",
118
+ "supportsMultimodal": false,
119
+ "isReasoning": false,
120
+ "version": "latest",
121
+ "description": "DeepSeek最新大模型,671B MoE模型,支持60 tokens/秒的高速生成"
122
+ },
123
+ "deepseek-reasoner": {
124
+ "name": "DeepSeek-R1",
125
+ "provider": "deepseek",
126
+ "supportsMultimodal": false,
127
+ "isReasoning": true,
128
+ "version": "latest",
129
+ "description": "DeepSeek推理模型,提供详细思考过程(仅支持文本)"
130
+ },
131
+ "QVQ-Max-2025-03-25": {
132
+ "name": "QVQ-Max",
133
+ "provider": "alibaba",
134
+ "supportsMultimodal": true,
135
+ "isReasoning": true,
136
+ "version": "2025-03-25",
137
+ "description": "阿里巴巴通义千问-QVQ-Max版本,支持图像理解和思考过程"
138
+ },
139
+ "qwen-vl-max-latest": {
140
+ "name": "Qwen-VL-MAX",
141
+ "provider": "alibaba",
142
+ "supportsMultimodal": true,
143
+ "isReasoning": false,
144
+ "version": "latest",
145
+ "description": "阿里通义千问VL-MAX模型,视觉理解能力最强,支持图像理解和复杂任务"
146
+ },
147
+ "gemini-2.5-pro": {
148
+ "name": "Gemini 2.5 Pro",
149
+ "provider": "google",
150
+ "supportsMultimodal": true,
151
+ "isReasoning": true,
152
+ "version": "latest",
153
+ "description": "Google最强大的Gemini 2.5 Pro模型,支持图像理解(需要付费API密钥)"
154
+ },
155
+ "gemini-2.5-flash": {
156
+ "name": "Gemini 2.5 Flash",
157
+ "provider": "google",
158
+ "supportsMultimodal": true,
159
+ "isReasoning": false,
160
+ "version": "latest",
161
+ "description": "Google最新的Gemini 2.5 Flash模型,支持图像理解,速度更快,性能更好"
162
+ },
163
+ "gemini-2.0-flash": {
164
+ "name": "Gemini 2.0 Flash",
165
+ "provider": "google",
166
+ "supportsMultimodal": true,
167
+ "isReasoning": false,
168
+ "version": "latest",
169
+ "description": "Google更快速的Gemini 2.0 Flash模型,支持图像理解,有免费配额"
170
+ },
171
+ "gemini-3-pro": {
172
+ "name": "Gemini 3 Pro",
173
+ "provider": "google",
174
+ "supportsMultimodal": true,
175
+ "isReasoning": true,
176
+ "version": "latest",
177
+ "description": "Google Gemini 3 Pro 顶级推理模型,面向复杂多模态任务"
178
+ },
179
+ "doubao-seed-1-6-250615": {
180
+ "name": "Doubao-Seed-1.6",
181
+ "provider": "doubao",
182
+ "supportsMultimodal": true,
183
+ "isReasoning": true,
184
+ "version": "latest",
185
+ "description": "支持auto/thinking/non-thinking三种思考模式、支持多模态、256K长上下文"
186
+ }
187
+ }
188
+ }
config/prompts.json ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ { "ACM_hard": {
2
+ "name": "ACM编程题(困难)",
3
+ "content":"你是一个顶尖的算法竞赛选手 + 程序员。你的任务是接收一道 ACM / 编程题目(包含题目描述、输入输出格式、约束)并输出一份完整可运行的解法。请严格按照以下步骤:\n1. 题目复述;\n2. 复杂度与限制分析;\n3. 思路与算法设计;\n4. 伪代码 / 算法框架;\n5. 最终可运行python代码(带注释);\n6. 时间复杂度 / 空间复杂度总结 + 边界 / 特殊输入测试。输出格式必须包含这些部分,不得省略分析或直接跳到代码。",
4
+ "description": "专为ACM编程竞赛题设计的提示词"
5
+ },
6
+ "a_default": {
7
+ "name": "默认提示词",
8
+ "content": "如果给的是图片,请先识别图片上面的题目,并输出完整题干;如果给的不是图片,直接诠释一下题目。然后解决该问题,如果是编程题,请输出最终可运行代码(带注释)。",
9
+ "description": "通用问题解决提示词"
10
+ },
11
+ "single_choice": {
12
+ "name": "单选题提示词",
13
+ "content": "您是一位专业的单选题解析专家。当看到一个单选题时,请:\n1. 仔细阅读题目要求和选项\n2. 分析每个选项的正确性\n3. 明确指出正确选项\n4. 解释为什么该选项正确\n5. 简要说明其他选项错误的原因\n6. 总结相关知识点",
14
+ "description": "专为单选题分析设计的提示词"
15
+ },
16
+ "multiple_choice": {
17
+ "name": "多选题提示词",
18
+ "content": "您是一位专业的多选题解析专家。当看到一个多选题时,请:\n1. 仔细阅读题目要求和所有选项\n2. 逐一分析每个选项的正确性\n3. 明确列出所有正确选项\n4. 详细解释每个正确选项的理由\n5. 说明错误选项的问题所在\n6. 归纳总结相关知识点",
19
+ "description": "专为多选题分析设计的提示词"
20
+ },
21
+ "programming": {
22
+ "name": "ACM编程题提示词",
23
+ "content": "您是一位专业的ACM编程竞赛解题专家。当看到一个编程题时,请:\n1. 分析题目要求、输入输出格式和约束条件\n2. 确定解题思路和算法策略\n3. 分析算法复杂度\n4. 提供完整、可运行的代码实现\n5. 解释代码中的关键部分\n6. 提供一些测试用例及其输出\n7. 讨论可能的优化方向",
24
+ "description": "专为ACM编程竞赛题设计的提示词"
25
+ },
26
+ "pattern_reasoning": {
27
+ "name": "图形推理题提示词",
28
+ "content": "您是一位专业的图形推理题解析专家。当看到一个图形推理题时,请:\n1. 观察并描述题目给出的图形序列\n2. 分析图形之间的变化规律\n3. 归纳可能的变化模式(如旋转、翻转、数量变化等)\n4. 应用发现的规律预测下一个图形\n5. 在多个选项中确定符合规律的答案\n6. 详细解释推理过程",
29
+ "description": "专为图形推理题设计的提示词"
30
+ },
31
+ "chart_calculation": {
32
+ "name": "图表计算题提示词",
33
+ "content": "您是一位专业的图表数据分析专家。当看到一个包含图表的计算题时,请:\n1. 仔细阅读并描述图表包含的信息(表格、柱状图、折线图等)\n2. 确定题目要求计算的具体内容\n3. 从图表中提取相关数据\n4. 设计合适的计算方法\n5. 进行准确的计算过程\n6. 清晰呈现计算结果\n7. 必要时解释数据的含义和趋势",
34
+ "description": "专为图表数据分析和计算题设计的提示词"
35
+ }
36
+ }
config/proxy_api.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "apis": {
3
+ "alibaba": "",
4
+ "anthropic": "",
5
+ "deepseek": "",
6
+ "doubao": "",
7
+ "google": "",
8
+ "openai": ""
9
+ },
10
+ "enabled": true
11
+ }
config/update_info.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "has_update": false,
3
+ "current_version": "1.5.1",
4
+ "latest_version": "1.5.1",
5
+ "release_url": "https://github.com/Zippland/Snap-Solver/releases/tag/v1.5.1",
6
+ "release_date": "2025-11-19T12:45:59Z",
7
+ "release_notes": "**Full Changelog**: https://github.com/Zippland/Snap-Solver/compare/v1.5.0...v1.5.1"
8
+ }
config/version.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "version": "1.5.1",
3
+ "build_date": "2025-04-11",
4
+ "github_repo": "Zippland/Snap-Solver"
5
+ }
docs/beginner-tutorial.md ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Snap-Solver 零基础上手教程
2
+
3
+ 这篇教程面向第一次接触编程或 Python 的朋友,手把手带你从安装环境开始,直到在电脑和手机上顺利使用 Snap-Solver 完成题目分析。如果你在任何步骤遇到困难,建议按章节逐步检查,或对照文末的常见问题排查。
4
+
5
+ ---
6
+
7
+ ## 1. Snap-Solver 是什么?
8
+
9
+ Snap-Solver 是一个本地运行的截屏解题工具,主要功能包括:
10
+ - 一键截取电脑屏幕的题目图片;
11
+ - 自动调用 OCR(文字识别)和多种大模型,给出详细解析;
12
+ - 支持在手机、平板等局域网设备上实时查看结果;
13
+ - 可以按需配置代理、中转 API、自定义提示词等高级选项。
14
+
15
+ 整个应用基于 Python + Flask,只要能启动一个 Python 程序,就可以完全离线地掌握它的运行方式。
16
+
17
+ ---
18
+
19
+ ## 2. 准备清单
20
+
21
+ - 一台可以联网的 Windows、macOS 或 Linux 电脑;
22
+ - 至少一个可用的模型 API Key(推荐准备 2~3 个,方便切换):
23
+ - OpenAI、Anthropic、DeepSeek、阿里灵积(Qwen)、Google、Mathpix 等任一即可;
24
+ - 约 2 GB 可用硬盘空间;
25
+ - 基本的文本编辑器(Windows 自带记事本即可,推荐使用 VS Code / Notepad++ 等更易读的工具)。
26
+
27
+ > **提示**:Snap-Solver 不依赖显卡或 GPU,普通轻薄本即可顺利运行。
28
+
29
+ ---
30
+
31
+ ## 3. 第一次打开命令行
32
+
33
+ Snap-Solver 需要在命令行里执行几条简单的指令。命令行是一个黑色(或白色)窗口,通过输入文字来让电脑完成任务。不同系统打开方式略有区别:
34
+
35
+ ### 3.1 Windows
36
+ 1. 同时按下键盘 `Win` 键(左下角带 Windows 徽标的键)+ `S`,输入 `cmd` 或 `terminal`。
37
+ 2. 选择 **命令提示符(Command Prompt)** 或 **Windows Terminal**,回车打开。
38
+ 3. 复制命令时,可在窗口上点击右键 → 「粘贴」,或使用快捷键 `Ctrl + V`。
39
+ 4. 想切换到某个文件夹(例如 `D:\Snap-Solver`),输入:
40
+ ```powershell
41
+ cd /d D:\Snap-Solver
42
+ ```
43
+ 5. 查看当前文件夹内的内容:
44
+ ```powershell
45
+ dir
46
+ ```
47
+
48
+ ### 3.2 macOS
49
+ 1. 同时按下 `Command + Space` 呼出 Spotlight,输入 `Terminal` 并回车。
50
+ 2. 在终端中,复制粘贴使用常规快捷键 `Command + C` / `Command + V`。
51
+ 3. 切换到下载好的项目目录(例如在「下载」文件夹内):
52
+ ```bash
53
+ cd ~/Downloads/Snap-Solver
54
+ ```
55
+ 4. 查看当前文件夹内容:
56
+ ```bash
57
+ ls
58
+ ```
59
+
60
+ ### 3.3 Linux(Ubuntu 示例)
61
+ 1. 同时按 `Ctrl + Alt + T` 打开终端。
62
+ 2. 切换到项目目录:
63
+ ```bash
64
+ cd ~/Snap-Solver
65
+ ```
66
+ 3. 查看内容:
67
+ ```bash
68
+ ls
69
+ ```
70
+
71
+ > **常用命令速记**
72
+ > - `cd 路径`:进入某个文件夹(路径中有空格请用双引号包住,例如 `cd "C:\My Folder"`)。
73
+ > - `dir`(Windows)/`ls`(macOS、Linux):查看当前文件夹下的文件。
74
+ > - 键盘方向键 ↑ 可以快速调出上一条命令,避免重复输入。
75
+
76
+ ---
77
+
78
+ ## 4. 安装 Python 3
79
+
80
+ Snap-Solver 基于 Python 3.9+,推荐使用 3.10 或 3.11 版本。
81
+
82
+ ### 4.1 Windows
83
+ 1. 打开浏览器访问:https://www.python.org/downloads/
84
+ 2. 点击最新的稳定版(例如 `Python 3.11.x`)的 **Download Windows installer (64-bit)**。
85
+ 3. 双击下载的安装包,记得在第一步勾选 **Add Python to PATH**。
86
+ 4. 按提示完成安装。
87
+ 5. 打开命令行窗口,输入:
88
+ ```powershell
89
+ python --version
90
+ pip --version
91
+ ```
92
+ 若能看到版本号(如 `Python 3.11.7`),说明安装成功。
93
+
94
+ ### 4.2 macOS
95
+ 1. 访问 https://www.python.org/downloads/mac-osx/ 下载 `macOS 64-bit universal2 installer`。
96
+ 2. 双击 `.pkg` 文件按提示安装。
97
+ 3. 打开终端输入:
98
+ ```bash
99
+ python3 --version
100
+ pip3 --version
101
+ ```
102
+ 如果输出版本号,表示安装完成。后续命令中的 `python`、`pip` 均可替换为 `python3`、`pip3`。
103
+
104
+ ### 4.3 Linux(Ubuntu 示例)
105
+ ```bash
106
+ sudo apt update
107
+ sudo apt install python3 python3-venv python3-pip -y
108
+ python3 --version
109
+ pip3 --version
110
+ ```
111
+
112
+ ---
113
+
114
+ ## 5. (可选)安装 Git
115
+
116
+ Git 方便后续更新项目,也可以用来下载代码。
117
+ - Windows:https://git-scm.com/download/win
118
+ - macOS:在终端输入 `xcode-select --install` 或从 https://git-scm.com/download/mac 获取
119
+ - Linux:`sudo apt install git -y`
120
+
121
+ 如果暂时不想安装 Git,也可以稍后直接下载压缩包。
122
+
123
+ ---
124
+
125
+ ## 6. 获取 Snap-Solver 项目代码
126
+
127
+ 任选其一:
128
+ 1. **使用 Git 克隆(推荐)**
129
+ ```bash
130
+ git clone https://github.com/Zippland/Snap-Solver.git
131
+ cd Snap-Solver
132
+ ```
133
+ 2. **下载压缩包**
134
+ - 打开项目主页:https://github.com/Zippland/Snap-Solver
135
+ - 点击右侧 `Release` → `Source code (zip)`
136
+ - 解压缩后,将文件夹重命名为 `Snap-Solver` 并记住路径
137
+
138
+ 后续步骤默认你已经位于项目根目录(包含 `app.py`、`requirements.txt` 的那个文件夹)。如果忘记位置,可再次查看文件夹并使用 `cd` 进入��
139
+
140
+ ---
141
+
142
+ ## 7. 创建虚拟环境并安装依赖
143
+
144
+ 虚拟环境可以把项目依赖和系统环境隔离,避免冲突。
145
+
146
+ ### 7.1 创建虚拟环境
147
+
148
+ - **Windows PowerShell**
149
+ ```powershell
150
+ python -m venv .venv
151
+ .\.venv\Scripts\Activate
152
+ ```
153
+ - **macOS / Linux**
154
+ ```bash
155
+ python3 -m venv .venv
156
+ source .venv/bin/activate
157
+ ```
158
+
159
+ 激活成功后,命令行前面会出现 `(.venv)` 前缀。若你关闭了命令行窗口,需要重新进入项目目录并再次执行激活命令。
160
+
161
+ ### 7.2 安装依赖
162
+
163
+ ```bash
164
+ pip install --upgrade pip
165
+ pip install -r requirements.txt
166
+ ```
167
+
168
+ 常见依赖(Flask、PyAutoGUI、Pillow 等)都会自动安装。首次安装可能用时 1~5 分钟,请耐心等待。
169
+
170
+ > **如果安装失败**:请检查网络、切换镜像源或参考文末常见问题。
171
+
172
+ ---
173
+
174
+ ## 8. 首次启动与访问
175
+
176
+ 1. 保证虚拟环境处于激活状态。
177
+ 2. 在项目根目录执行:
178
+ ```bash
179
+ python3 -m venv .venv
180
+ source .venv/bin/activate
181
+ python app.py
182
+ ```
183
+ 3. 终端中会看到 Flask/SocketIO 的日志,最后出现 `Running on http://127.0.0.1:5000` 表示启动成功。
184
+ 4. 若需要在手机/平板访问,请在**同一局域网下**输入 `http://<电脑IP>:5000`。电脑 IP 可在终端日志中看到,例如 `http://192.168.1.8:5000`(可能是别的,每次打开都会刷新)。
185
+
186
+ > **暂停服务**:在终端按 `Ctrl + C` 即可停止运行。再次启动时,只需重新激活虚拟环境并执行 `python app.py`。
187
+ ```bash
188
+ python3 -m venv .venv
189
+ source .venv/bin/activate
190
+ python app.py
191
+ ```
192
+ ---
193
+
194
+ ## 9. 配置 API 密钥与基础设置
195
+
196
+ 启动网页后,点击右上角的齿轮图标进入「设置」面板,建议先完成以下几项:
197
+
198
+ ### 9.1 填写模型 API Key
199
+
200
+ - 根据你手上的 Key,将对应值填入设置页面的输入框中;
201
+ - 常用字段:
202
+ - `OpenaiApiKey`:OpenAI 模型(如 GPT-4o、o3-mini)
203
+ - `AnthropicApiKey`:Claude 系列
204
+ - `DeepseekApiKey`:DeepSeek
205
+ - `AlibabaApiKey`:通义千问 / Qwen / QVQ
206
+ - `GoogleApiKey`:Gemini 系列
207
+ - `MathpixAppId` & `MathpixAppKey`:用于高精度公式识别
208
+ - 点击保存后,信息会写入 `config/api_keys.json` 方便下次启动直接读取。
209
+
210
+ ### 9.2 设置代理与中转(可选)
211
+
212
+ - 若你需要走代理或企业中转通道,可在设置面板中开启代理选项;
213
+ - 对应的 JSON 文件是 `config/proxy_api.json`,可直接编辑来指定各模型的自定义 `base_url`;
214
+ - 修改后需重启应用才能生效。
215
+
216
+ ### 9.3 如何确认 VPN/代理端口
217
+
218
+ 很多加速器或 VPN 客户端会在本地启动一个「系统代理」服务(常见端口如 `7890`、`1080` 等)。具体端口位置通常可以通过以下途径找到:
219
+ - 打开 VPN 客户端的设置页面,寻找「本地监听端口」「HTTP(S) 代理」「SOCKS 代理」等字样;
220
+ - Windows 用户也可以在「设置 → 网络和 Internet → 代理」里查看「使用代理服务器」的地址和端口;
221
+ - macOS 用户可在「系统设置 → 网络 → Wi-Fi(或以太网)→ 详情 → 代理」里查看勾选的服务和端口;
222
+ - 高级用户可以在命令行里运行 `netstat -ano | findstr 127.0.0.1`(Windows)或 `lsof -iTCP -sTCP:LISTEN | grep 127.0.0.1`(macOS/Linux)确认本地监听端口。
223
+
224
+ 拿到端口后,在 Snap-Solver 的代理设置中填入对应的地址(通常是 `127.0.0.1:<端口>`),就能让模型请求走 VPN。不同工具的界面名称可能略有差异,重点是找出「本地监听地址 + 端口号」这一对信息。
225
+
226
+ ---
227
+
228
+ ## 10. 获取常用 API Key(详细教程)
229
+
230
+ API Key 相当于你在各大模型平台上的「门票」。不同平台的获取流程不同,以下列出了最常用的几个来源。申请过程中务必保护好个人隐私与账号安全,切勿向他人泄露密钥。
231
+
232
+ ### 10.1 OpenAI(GPT-4o / o3-mini 等)
233
+ 1. 打开 https://platform.openai.com/ 并使用邮箱或第三方账号注册 / 登录。
234
+ 2. 首次使用需完成实名和支付方式绑定(可选择信用卡或预付费余额)。
235
+ 3. 登录后点击右上角头像 → `View API keys`。
236
+ 4. 点击 `Create new secret key`,复制生成的密钥(形如 `sk-...`)。
237
+ 5. 将该密钥粘贴到 Snap-Solver 的 `OpenaiApiKey` 输入框,并妥善保存。
238
+
239
+ ### 10.2 Anthropic(Claude 系列)
240
+ 1. 打开 https://console.anthropic.com/ 并注册账号。
241
+ 2. 按提示完成手机号验证和支付方式绑定(部分国家需排队开通)。
242
+ 3. 登录后进入 `API Keys` 页面,点击 `Create Key`。
243
+ 4. 复制生成的密钥(形如 `sk-ant-...`),粘贴到 Snap-Solver 的 `AnthropicApiKey`。
244
+
245
+ ### 10.3 DeepSeek
246
+ 1. 访问 https://platform.deepseek.com/ 并注册登录。
247
+ 2. 如果需要人民币支付,可在「账号设置」绑定支付宝;海外用户可使用信用卡。
248
+ 3. 进入 `API Keys`,点击 `新建密钥`。
249
+ 4. 复制生成的密钥(形如 `sk-xxx`),填入 `DeepseekApiKey`。
250
+
251
+ ### 10.4 阿里云通义千问 / Qwen / QVQ
252
+ 1. 打开 https://dashscope.console.aliyun.com/ 并使用阿里云账号登录。
253
+ 2. 进入「API Key 管理」页面,点击 `创建 API Key`。
254
+ 3. 复制密钥(形如 `sk-yourkey`)填入 `AlibabaApiKey`。
255
+ 4. 如需开通收费模型,请在「计费与配额」中先完成实名认证并开通付费策略。
256
+
257
+ ### 10.5 Google Gemini
258
+ 1. 前往 https://ai.google.dev/ 并登录 Google 账号。
259
+ 2. 点击右上角 `Get API key`。
260
+ 3. 选择或创建项目,生成新的 API Key。
261
+ 4. 将密钥填入 `GoogleApiKey`。
262
+
263
+ ### 10.6 Mathpix(高精度公式识别)
264
+ 1. 访问 https://dashboard.mathpix.com/ 注册账号。
265
+ 2. 完成邮箱验证后,在侧边栏找到 `API Keys`。
266
+ 3. 创建新的 App,复制 `App ID` 和 `App Key`。
267
+ 4. 分别填入 Snap-Solver 的 `MathpixAppId` 与 `MathpixAppKey` 字段。
268
+
269
+ > **安全小贴士**
270
+ > - API Key 和密码一样重要,泄露后他人可能代你调用接口、消耗额度。
271
+ > - 建议为不同用途创建多个密钥,定期检查和撤销不用的密钥。
272
+ > - 如果平台支持额度上限、IP 白名单等功能,可以酌情启用以降低风险。
273
+
274
+ ---
275
+
276
+ ## 11. 完成第一次题目解析
277
+
278
+ 1. 确认右上角的「连接状态」显示为绿色的「已连接」。
279
+ 2. 点击顶部的「开始截图」,按提示框拖拽需要识别的题目区域。
280
+ 3. 截图完成后,预览区会显示图片,并出现「发送至 AI」或「提取文本」按钮:
281
+ - **发送至 AI**:直接让所选模型解析图像;
282
+ - **提取文本**:先做 OCR,把文字复制出来,再发送给模型。
283
+ 4. 在右侧的「分析结果」面板可以查看:
284
+ - AI 的思考过程(可折叠);
285
+ - 最终解答、代码或步骤;
286
+ - 中间日志与计时。
287
+ 5. 若需要改用其他模型,重新打开设置面板即可实时切换。
288
+
289
+ > **小技巧**:长按或双击分析结果中的文本,可快速复制粘贴;终端会实时输出请求日志,方便排查问题。
290
+
291
+ ---
292
+
293
+ ## 12. 常见问题速查
294
+
295
+ - **`python` 命令找不到**:在 Windows 上打开新的终端后请重启电脑,或使用 `py` 命令;macOS/Linux 请尝试 `python3`。
296
+ - **`pip install` 超时**:可以临时使用清华源 `pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt`。
297
+ - **启动后网页打不开**:确认终端没有报错;检查防火墙、端口占用,或尝试 `http://127.0.0.1:5000`。
298
+ - **截图没反应**:Windows/macOS 需要授权「辅助功能 / 截屏」权限给 Python;macOS 在「系统设置 - 隐私与安全」中勾选 `python` 或终端应用。
299
+ - **模型报 401/403**:检查 API Key 是否正确、账号余额是否充足,必要时在设置里更换模型或填入自定义域名。
300
+ - **手机访问失败**:确保手机和电脑在同一个 Wi-Fi 下,且电脑未开启 VPN 导致局域网隔离。
301
+
302
+ ---
303
+
304
+ ## 13. 进一步探索
305
+
306
+ - `config/models.json`:自定义展示在下拉框的模型列表,包含模型名称、供应商、能力标签等,可按需添加。
307
+ - `config/prompts.json`:定义默认 prompt,可根据学科优化。
308
+ - 更新项目:如果是 Git 克隆,执行 `git pull`;压缩包用户可重新下载覆盖。
309
+
310
+ 完成以上步骤后,你已经具备运行和日常使用 Snap-Solver 的全部基础。如果你有新的需求或遇到无法解决的问题,可以先查看 README 或在 Issues 中搜索 / 提问。祝你学习顺利,刷题提效!
models/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .base import BaseModel
2
+ from .anthropic import AnthropicModel
3
+ from .openai import OpenAIModel
4
+ from .deepseek import DeepSeekModel
5
+ from .alibaba import AlibabaModel
6
+ from .google import GoogleModel
7
+ from .doubao import DoubaoModel
8
+ from .factory import ModelFactory
9
+
10
+ __all__ = [
11
+ 'BaseModel',
12
+ 'AnthropicModel',
13
+ 'OpenAIModel',
14
+ 'DeepSeekModel',
15
+ 'AlibabaModel',
16
+ 'GoogleModel',
17
+ 'DoubaoModel',
18
+ 'ModelFactory'
19
+ ]
models/alibaba.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import Generator, Dict, Optional, Any
3
+ from openai import OpenAI
4
+ from .base import BaseModel
5
+
6
+ class AlibabaModel(BaseModel):
7
+ def __init__(self, api_key: str, temperature: float = 0.7, system_prompt: str = None, language: str = None, model_name: str = None, api_base_url: str = None):
8
+ # 如果没有提供模型名称,才使用默认值
9
+ self.model_name = model_name if model_name else "QVQ-Max-2025-03-25"
10
+ print(f"初始化阿里巴巴模型: {self.model_name}")
11
+ # 在super().__init__之前设置model_name,这样get_default_system_prompt能使用它
12
+ super().__init__(api_key, temperature, system_prompt, language)
13
+ self.api_base_url = api_base_url # 存储API基础URL
14
+
15
+ def get_default_system_prompt(self) -> str:
16
+ """根据模型名称返回不同的默认系统提示词"""
17
+ # 检查是否是通义千问VL模型
18
+ if self.model_name and "qwen-vl" in self.model_name:
19
+ return """你是通义千问VL视觉语言助手,擅长图像理解、文字识别、内容分析和创作。请根据用户提供的图像:
20
+ 1. 仔细阅读并理解问题
21
+ 2. 分析问题的关键组成部分
22
+ 3. 提供清晰的、逐步的解决方案
23
+ 4. 如果相关,解释涉及的概念或理论
24
+ 5. 如果有多种解决方法,先解释最高效的方法"""
25
+ else:
26
+ # QVQ模型使用原先的提示词
27
+ return """你是一位专业的问题分析与解答助手。当看到一个问题图片时,请:
28
+ 1. 仔细阅读并理解问题
29
+ 2. 分析问题的关键组成部分
30
+ 3. 提供清晰的、逐步的解决方案
31
+ 4. 如果相关,解释涉及的概念或理论
32
+ 5. 如果有多种解决方法,先解释最高效的方法"""
33
+
34
+ def get_model_identifier(self) -> str:
35
+ """根据模型名称返回对应的模型标识符"""
36
+ # 直接映射模型ID到DashScope API使用的标识符
37
+ model_mapping = {
38
+ "QVQ-Max-2025-03-25": "qvq-max",
39
+ "qwen-vl-max-latest": "qwen-vl-max", # 修正为正确的API标识符
40
+ }
41
+
42
+ print(f"模型名称: {self.model_name}")
43
+
44
+ # 从模型映射表中获取模型标识符,如果不存在则使用默认值
45
+ model_id = model_mapping.get(self.model_name)
46
+ if model_id:
47
+ print(f"从映射表中获取到模型标识符: {model_id}")
48
+ return model_id
49
+
50
+ # 如果没有精确匹配,检查是否包含特定前缀
51
+ if self.model_name and "qwen-vl" in self.model_name.lower():
52
+ if "max" in self.model_name.lower():
53
+ print(f"识别为qwen-vl-max模型")
54
+ return "qwen-vl-max"
55
+ elif "plus" in self.model_name.lower():
56
+ print(f"识别为qwen-vl-plus模型")
57
+ return "qwen-vl-plus"
58
+ elif "lite" in self.model_name.lower():
59
+ print(f"识别为qwen-vl-lite模型")
60
+ return "qwen-vl-lite"
61
+ print(f"默认使用qwen-vl-max模型")
62
+ return "qwen-vl-max" # 默认使用最强版本
63
+
64
+ # 如果包含QVQ或alibaba关键词,默认使用qvq-max
65
+ if self.model_name and ("qvq" in self.model_name.lower() or "alibaba" in self.model_name.lower()):
66
+ print(f"识别为QVQ模型,使用qvq-max")
67
+ return "qvq-max"
68
+
69
+ # 最后的默认值
70
+ print(f"警告:无法识别的模型名称 {self.model_name},默认使用qvq-max")
71
+ return "qvq-max"
72
+
73
+ def analyze_text(self, text: str, proxies: dict = None) -> Generator[dict, None, None]:
74
+ """Stream QVQ-Max's response for text analysis"""
75
+ try:
76
+ # Initial status
77
+ yield {"status": "started", "content": ""}
78
+
79
+ # Save original environment state
80
+ original_env = {
81
+ 'http_proxy': os.environ.get('http_proxy'),
82
+ 'https_proxy': os.environ.get('https_proxy')
83
+ }
84
+
85
+ try:
86
+ # Set proxy environment variables if provided
87
+ if proxies:
88
+ if 'http' in proxies:
89
+ os.environ['http_proxy'] = proxies['http']
90
+ if 'https' in proxies:
91
+ os.environ['https_proxy'] = proxies['https']
92
+
93
+ # Initialize OpenAI compatible client for DashScope
94
+ client = OpenAI(
95
+ api_key=self.api_key,
96
+ base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
97
+ )
98
+
99
+ # Prepare messages
100
+ messages = [
101
+ {
102
+ "role": "system",
103
+ "content": [{"type": "text", "text": self.system_prompt}]
104
+ },
105
+ {
106
+ "role": "user",
107
+ "content": [{"type": "text", "text": text}]
108
+ }
109
+ ]
110
+
111
+ # 创建聊天完成请求
112
+ response = client.chat.completions.create(
113
+ model=self.get_model_identifier(),
114
+ messages=messages,
115
+ temperature=self.temperature,
116
+ stream=True,
117
+ max_tokens=self._get_max_tokens()
118
+ )
119
+
120
+ # 记录思考过程和回答
121
+ reasoning_content = ""
122
+ answer_content = ""
123
+ is_answering = False
124
+
125
+ # 检查是否为通义千问VL模型(不支持reasoning_content)
126
+ is_qwen_vl = "qwen-vl" in self.get_model_identifier().lower()
127
+ print(f"分析文本使用模型标识符: {self.get_model_identifier()}, 是否为千问VL模型: {is_qwen_vl}")
128
+
129
+ for chunk in response:
130
+ if not chunk.choices:
131
+ continue
132
+
133
+ delta = chunk.choices[0].delta
134
+
135
+ # 处理思考过程(仅适用于QVQ模型)
136
+ if not is_qwen_vl and hasattr(delta, 'reasoning_content') and delta.reasoning_content is not None:
137
+ reasoning_content += delta.reasoning_content
138
+ # 思考过程作为一个独立的内容发送
139
+ yield {
140
+ "status": "reasoning",
141
+ "content": reasoning_content,
142
+ "is_reasoning": True
143
+ }
144
+ elif delta.content != "":
145
+ # 判断是否开始回答(从思考过程切换到回答)
146
+ if not is_answering and not is_qwen_vl:
147
+ is_answering = True
148
+ # 发送完整的思考过程
149
+ if reasoning_content:
150
+ yield {
151
+ "status": "reasoning_complete",
152
+ "content": reasoning_content,
153
+ "is_reasoning": True
154
+ }
155
+
156
+ # 累积回答内容
157
+ answer_content += delta.content
158
+
159
+ # 发送回答内容
160
+ yield {
161
+ "status": "streaming",
162
+ "content": answer_content
163
+ }
164
+
165
+ # 确保发送最终完整内容
166
+ if answer_content:
167
+ yield {
168
+ "status": "completed",
169
+ "content": answer_content
170
+ }
171
+
172
+ finally:
173
+ # Restore original environment state
174
+ for key, value in original_env.items():
175
+ if value is None:
176
+ if key in os.environ:
177
+ del os.environ[key]
178
+ else:
179
+ os.environ[key] = value
180
+
181
+ except Exception as e:
182
+ yield {
183
+ "status": "error",
184
+ "error": str(e)
185
+ }
186
+
187
+ def analyze_image(self, image_data: str, proxies: dict = None) -> Generator[dict, None, None]:
188
+ """Stream model's response for image analysis"""
189
+ try:
190
+ # Initial status
191
+ yield {"status": "started", "content": ""}
192
+
193
+ # Save original environment state
194
+ original_env = {
195
+ 'http_proxy': os.environ.get('http_proxy'),
196
+ 'https_proxy': os.environ.get('https_proxy')
197
+ }
198
+
199
+ try:
200
+ # Set proxy environment variables if provided
201
+ if proxies:
202
+ if 'http' in proxies:
203
+ os.environ['http_proxy'] = proxies['http']
204
+ if 'https' in proxies:
205
+ os.environ['https_proxy'] = proxies['https']
206
+
207
+ # Initialize OpenAI compatible client for DashScope
208
+ client = OpenAI(
209
+ api_key=self.api_key,
210
+ base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
211
+ )
212
+
213
+ # 使用系统提供的系统提示词,不再自动添加语言指令
214
+ system_prompt = self.system_prompt
215
+
216
+ # Prepare messages with image
217
+ messages = [
218
+ {
219
+ "role": "system",
220
+ "content": [{"type": "text", "text": system_prompt}]
221
+ },
222
+ {
223
+ "role": "user",
224
+ "content": [
225
+ {
226
+ "type": "image_url",
227
+ "image_url": {
228
+ "url": f"data:image/jpeg;base64,{image_data}"
229
+ }
230
+ },
231
+ {
232
+ "type": "text",
233
+ "text": "请分析这个图片并提供详细的解答。"
234
+ }
235
+ ]
236
+ }
237
+ ]
238
+
239
+ # 创建聊天完成请求
240
+ response = client.chat.completions.create(
241
+ model=self.get_model_identifier(),
242
+ messages=messages,
243
+ temperature=self.temperature,
244
+ stream=True,
245
+ max_tokens=self._get_max_tokens()
246
+ )
247
+
248
+ # 记录思考过程和回答
249
+ reasoning_content = ""
250
+ answer_content = ""
251
+ is_answering = False
252
+
253
+ # 检查是否为通义千问VL模型(不支持reasoning_content)
254
+ is_qwen_vl = "qwen-vl" in self.get_model_identifier().lower()
255
+ print(f"分析图像使用模型标识符: {self.get_model_identifier()}, 是否为千问VL模型: {is_qwen_vl}")
256
+
257
+ for chunk in response:
258
+ if not chunk.choices:
259
+ continue
260
+
261
+ delta = chunk.choices[0].delta
262
+
263
+ # 处理思考过程(仅适用于QVQ模型)
264
+ if not is_qwen_vl and hasattr(delta, 'reasoning_content') and delta.reasoning_content is not None:
265
+ reasoning_content += delta.reasoning_content
266
+ # 思考过程作为一个独立的内容发送
267
+ yield {
268
+ "status": "reasoning",
269
+ "content": reasoning_content,
270
+ "is_reasoning": True
271
+ }
272
+ elif delta.content != "":
273
+ # 判断是否开始回答(从思考过程切换到回答)
274
+ if not is_answering and not is_qwen_vl:
275
+ is_answering = True
276
+ # 发送完整的思考过程
277
+ if reasoning_content:
278
+ yield {
279
+ "status": "reasoning_complete",
280
+ "content": reasoning_content,
281
+ "is_reasoning": True
282
+ }
283
+
284
+ # 累积回答内容
285
+ answer_content += delta.content
286
+
287
+ # 发送回答内容
288
+ yield {
289
+ "status": "streaming",
290
+ "content": answer_content
291
+ }
292
+
293
+ # 确保发送最终完整内容
294
+ if answer_content:
295
+ yield {
296
+ "status": "completed",
297
+ "content": answer_content
298
+ }
299
+
300
+ finally:
301
+ # Restore original environment state
302
+ for key, value in original_env.items():
303
+ if value is None:
304
+ if key in os.environ:
305
+ del os.environ[key]
306
+ else:
307
+ os.environ[key] = value
308
+
309
+ except Exception as e:
310
+ yield {
311
+ "status": "error",
312
+ "error": str(e)
313
+ }
314
+
315
+ def _get_max_tokens(self) -> int:
316
+ """根据模型类型返回合适的max_tokens值"""
317
+ # 检查是否为通义千问VL模型
318
+ if "qwen-vl" in self.get_model_identifier():
319
+ return 2000 # 通义千问VL模型最大支持2048,留一些余量
320
+ # QVQ模型或其他模型
321
+ return self.max_tokens if hasattr(self, 'max_tokens') and self.max_tokens else 4000
models/anthropic.py ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import requests
3
+ from typing import Generator, Optional
4
+ from .base import BaseModel
5
+
6
+ class AnthropicModel(BaseModel):
7
+ def __init__(self, api_key, temperature=0.7, system_prompt=None, language=None, api_base_url=None, model_identifier=None):
8
+ super().__init__(api_key, temperature, system_prompt or self.get_default_system_prompt(), language or "en")
9
+ # 设置API基础URL,默认为Anthropic官方API
10
+ self.api_base_url = api_base_url or "https://api.anthropic.com/v1"
11
+ # 设置模型标识符,支持动态选择
12
+ self.model_identifier = model_identifier or "claude-3-7-sonnet-20250219"
13
+ # 初始化推理配置
14
+ self.reasoning_config = None
15
+ # 初始化最大Token数
16
+ self.max_tokens = None
17
+
18
+ def get_default_system_prompt(self) -> str:
19
+ return """You are an expert at analyzing questions and providing detailed solutions. When presented with an image of a question:
20
+ 1. First read and understand the question carefully
21
+ 2. Break down the key components of the question
22
+ 3. Provide a clear, step-by-step solution
23
+ 4. If relevant, explain any concepts or theories involved
24
+ 5. If there are multiple approaches, explain the most efficient one first"""
25
+
26
+ def get_model_identifier(self) -> str:
27
+ return self.model_identifier
28
+
29
+ def analyze_text(self, text: str, proxies: Optional[dict] = None) -> Generator[dict, None, None]:
30
+ """Stream Claude's response for text analysis"""
31
+ try:
32
+ yield {"status": "started"}
33
+
34
+ api_key = self.api_key
35
+ if api_key.startswith('Bearer '):
36
+ api_key = api_key[7:]
37
+
38
+ headers = {
39
+ 'x-api-key': api_key,
40
+ 'anthropic-version': '2023-06-01',
41
+ 'content-type': 'application/json',
42
+ 'accept': 'application/json',
43
+ }
44
+
45
+ # 获取最大输出Token设置
46
+ max_tokens = 8192 # 默认值
47
+ if hasattr(self, 'max_tokens') and self.max_tokens:
48
+ max_tokens = self.max_tokens
49
+
50
+ payload = {
51
+ 'model': self.get_model_identifier(),
52
+ 'stream': True,
53
+ 'max_tokens': max_tokens,
54
+ 'temperature': 1,
55
+ 'system': self.system_prompt,
56
+ 'messages': [{
57
+ 'role': 'user',
58
+ 'content': [
59
+ {
60
+ 'type': 'text',
61
+ 'text': text
62
+ }
63
+ ]
64
+ }]
65
+ }
66
+
67
+ # 处理推理配置
68
+ if hasattr(self, 'reasoning_config') and self.reasoning_config:
69
+ # 如果设置了extended reasoning
70
+ if self.reasoning_config.get('reasoning_depth') == 'extended':
71
+ think_budget = self.reasoning_config.get('think_budget', max_tokens // 2)
72
+ payload['thinking'] = {
73
+ 'type': 'enabled',
74
+ 'budget_tokens': think_budget
75
+ }
76
+ # 如果设置了instant模式
77
+ elif self.reasoning_config.get('speed_mode') == 'instant':
78
+ # 确保当使用speed_mode时不包含thinking参数
79
+ if 'thinking' in payload:
80
+ del payload['thinking']
81
+ # 默认启用思考但使用较小的预算
82
+ else:
83
+ payload['thinking'] = {
84
+ 'type': 'enabled',
85
+ 'budget_tokens': min(4096, max_tokens // 4)
86
+ }
87
+ # 默认设置
88
+ else:
89
+ payload['thinking'] = {
90
+ 'type': 'enabled',
91
+ 'budget_tokens': min(4096, max_tokens // 4)
92
+ }
93
+
94
+ print(f"Debug - 推理配置: max_tokens={max_tokens}, thinking={payload.get('thinking', payload.get('speed_mode', 'default'))}")
95
+
96
+ # 使用配置的API基础URL
97
+ api_endpoint = f"{self.api_base_url}/messages"
98
+
99
+ response = requests.post(
100
+ api_endpoint,
101
+ headers=headers,
102
+ json=payload,
103
+ stream=True,
104
+ proxies=proxies,
105
+ timeout=60
106
+ )
107
+
108
+ if response.status_code != 200:
109
+ error_msg = f'API error: {response.status_code}'
110
+ try:
111
+ error_data = response.json()
112
+ if 'error' in error_data:
113
+ error_msg += f" - {error_data['error']['message']}"
114
+ except:
115
+ error_msg += f" - {response.text}"
116
+ yield {"status": "error", "error": error_msg}
117
+ return
118
+
119
+ thinking_content = ""
120
+ response_buffer = ""
121
+
122
+ for chunk in response.iter_lines():
123
+ if not chunk:
124
+ continue
125
+
126
+ try:
127
+ chunk_str = chunk.decode('utf-8')
128
+ if not chunk_str.startswith('data: '):
129
+ continue
130
+
131
+ chunk_str = chunk_str[6:]
132
+ data = json.loads(chunk_str)
133
+
134
+ if data.get('type') == 'content_block_delta':
135
+ if 'delta' in data:
136
+ if 'text' in data['delta']:
137
+ text_chunk = data['delta']['text']
138
+ response_buffer += text_chunk
139
+ # 只在每累积一定数量的字符后才发送,减少UI跳变
140
+ if len(text_chunk) >= 10 or text_chunk.endswith(('.', '!', '?', '。', '!', '?', '\n')):
141
+ yield {
142
+ "status": "streaming",
143
+ "content": response_buffer
144
+ }
145
+
146
+ elif 'thinking' in data['delta']:
147
+ thinking_chunk = data['delta']['thinking']
148
+ thinking_content += thinking_chunk
149
+ # 只在每累积一定数量的字符后才发送,减少UI跳变
150
+ if len(thinking_chunk) >= 20 or thinking_chunk.endswith(('.', '!', '?', '。', '!', '?', '\n')):
151
+ yield {
152
+ "status": "thinking",
153
+ "content": thinking_content
154
+ }
155
+
156
+ # 处理新的extended_thinking格式
157
+ elif data.get('type') == 'extended_thinking_delta':
158
+ if 'delta' in data and 'text' in data['delta']:
159
+ thinking_chunk = data['delta']['text']
160
+ thinking_content += thinking_chunk
161
+ # 只在每累积一定数量的字符后才发送,减少UI跳变
162
+ if len(thinking_chunk) >= 20 or thinking_chunk.endswith(('.', '!', '?', '。', '!', '?', '\n')):
163
+ yield {
164
+ "status": "thinking",
165
+ "content": thinking_content
166
+ }
167
+
168
+ elif data.get('type') == 'message_stop':
169
+ # 确保发送完整的思考内容
170
+ if thinking_content:
171
+ yield {
172
+ "status": "thinking_complete",
173
+ "content": thinking_content
174
+ }
175
+ # 确保发送完整的响应内容
176
+ yield {
177
+ "status": "completed",
178
+ "content": response_buffer
179
+ }
180
+
181
+ elif data.get('type') == 'error':
182
+ error_msg = data.get('error', {}).get('message', 'Unknown error')
183
+ yield {
184
+ "status": "error",
185
+ "error": error_msg
186
+ }
187
+ break
188
+
189
+ except json.JSONDecodeError as e:
190
+ print(f"JSON decode error: {str(e)}")
191
+ continue
192
+
193
+ except Exception as e:
194
+ yield {
195
+ "status": "error",
196
+ "error": f"Streaming error: {str(e)}"
197
+ }
198
+
199
+ def analyze_image(self, image_data, proxies: Optional[dict] = None):
200
+ yield {"status": "started"}
201
+
202
+ api_key = self.api_key
203
+ if api_key.startswith('Bearer '):
204
+ api_key = api_key[7:]
205
+
206
+ headers = {
207
+ 'x-api-key': api_key,
208
+ 'anthropic-version': '2023-06-01',
209
+ 'content-type': 'application/json'
210
+ }
211
+
212
+ # 使用系统提供的系统提示词,不再自动添加语言指令
213
+ system_prompt = self.system_prompt
214
+
215
+ # 获取最大输出Token设置
216
+ max_tokens = 8192 # 默认值
217
+ if hasattr(self, 'max_tokens') and self.max_tokens:
218
+ max_tokens = self.max_tokens
219
+
220
+ payload = {
221
+ 'model': self.get_model_identifier(),
222
+ 'stream': True,
223
+ 'max_tokens': max_tokens,
224
+ 'temperature': 1,
225
+ 'system': system_prompt,
226
+ 'messages': [{
227
+ 'role': 'user',
228
+ 'content': [
229
+ {
230
+ 'type': 'image',
231
+ 'source': {
232
+ 'type': 'base64',
233
+ 'media_type': 'image/png',
234
+ 'data': image_data
235
+ }
236
+ },
237
+ {
238
+ 'type': 'text',
239
+ 'text': "请分析这个问题并提供详细的解决方案。如果你看到多个问题,请逐一解决。"
240
+ }
241
+ ]
242
+ }]
243
+ }
244
+
245
+ # 处理推理配置
246
+ if hasattr(self, 'reasoning_config') and self.reasoning_config:
247
+ # 如果设置了extended reasoning
248
+ if self.reasoning_config.get('reasoning_depth') == 'extended':
249
+ think_budget = self.reasoning_config.get('think_budget', max_tokens // 2)
250
+ payload['thinking'] = {
251
+ 'type': 'enabled',
252
+ 'budget_tokens': think_budget
253
+ }
254
+ # 如果设置了instant模式
255
+ elif self.reasoning_config.get('speed_mode') == 'instant':
256
+ # 只需确保不包含thinking参数,不添加speed_mode参数
257
+ if 'thinking' in payload:
258
+ del payload['thinking']
259
+ # 默认启用思考但使用较小的预算
260
+ else:
261
+ payload['thinking'] = {
262
+ 'type': 'enabled',
263
+ 'budget_tokens': min(4096, max_tokens // 4)
264
+ }
265
+ # 默认设置
266
+ else:
267
+ payload['thinking'] = {
268
+ 'type': 'enabled',
269
+ 'budget_tokens': min(4096, max_tokens // 4)
270
+ }
271
+
272
+ print(f"Debug - 图像分析推理配置: max_tokens={max_tokens}, thinking={payload.get('thinking', payload.get('speed_mode', 'default'))}")
273
+
274
+ # 使用配置的API基础URL
275
+ api_endpoint = f"{self.api_base_url}/messages"
276
+
277
+ response = requests.post(
278
+ api_endpoint,
279
+ headers=headers,
280
+ json=payload,
281
+ stream=True,
282
+ proxies=proxies,
283
+ timeout=60
284
+ )
285
+
286
+ if response.status_code != 200:
287
+ error_msg = f'API error: {response.status_code}'
288
+ try:
289
+ error_data = response.json()
290
+ if 'error' in error_data:
291
+ error_msg += f" - {error_data['error']['message']}"
292
+ except:
293
+ error_msg += f" - {response.text}"
294
+ yield {"status": "error", "error": error_msg}
295
+ return
296
+
297
+ thinking_content = ""
298
+ response_buffer = ""
299
+
300
+ for chunk in response.iter_lines():
301
+ if not chunk:
302
+ continue
303
+
304
+ try:
305
+ chunk_str = chunk.decode('utf-8')
306
+ if not chunk_str.startswith('data: '):
307
+ continue
308
+
309
+ chunk_str = chunk_str[6:]
310
+ data = json.loads(chunk_str)
311
+
312
+ if data.get('type') == 'content_block_delta':
313
+ if 'delta' in data:
314
+ if 'text' in data['delta']:
315
+ text_chunk = data['delta']['text']
316
+ response_buffer += text_chunk
317
+ # 只在每累积一定数量的字符后才发送,减少UI跳变
318
+ if len(text_chunk) >= 10 or text_chunk.endswith(('.', '!', '?', '。', '!', '?', '\n')):
319
+ yield {
320
+ "status": "streaming",
321
+ "content": response_buffer
322
+ }
323
+
324
+ elif 'thinking' in data['delta']:
325
+ thinking_chunk = data['delta']['thinking']
326
+ thinking_content += thinking_chunk
327
+ # 只在每累积一定数量的字符后才发送,减少UI跳变
328
+ if len(thinking_chunk) >= 20 or thinking_chunk.endswith(('.', '!', '?', '。', '!', '?', '\n')):
329
+ yield {
330
+ "status": "thinking",
331
+ "content": thinking_content
332
+ }
333
+
334
+ # 处理新的extended_thinking格式
335
+ elif data.get('type') == 'extended_thinking_delta':
336
+ if 'delta' in data and 'text' in data['delta']:
337
+ thinking_chunk = data['delta']['text']
338
+ thinking_content += thinking_chunk
339
+ # 只在每累积一定数量的字符后才发送,减少UI跳变
340
+ if len(thinking_chunk) >= 20 or thinking_chunk.endswith(('.', '!', '?', '。', '!', '?', '\n')):
341
+ yield {
342
+ "status": "thinking",
343
+ "content": thinking_content
344
+ }
345
+
346
+ elif data.get('type') == 'message_stop':
347
+ # 确保发送完整的思考内容
348
+ if thinking_content:
349
+ yield {
350
+ "status": "thinking_complete",
351
+ "content": thinking_content
352
+ }
353
+ # 确保发送完整的响应内容
354
+ yield {
355
+ "status": "completed",
356
+ "content": response_buffer
357
+ }
358
+
359
+ elif data.get('type') == 'error':
360
+ error_message = data.get('error', {}).get('message', 'Unknown error')
361
+ yield {
362
+ "status": "error",
363
+ "error": error_message
364
+ }
365
+
366
+ except Exception as e:
367
+ yield {
368
+ "status": "error",
369
+ "error": f"Error processing response: {str(e)}"
370
+ }
371
+ break
models/baidu_ocr.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import json
3
+ import time
4
+ import urllib.request
5
+ import urllib.parse
6
+ from typing import Generator, Dict, Any
7
+ from .base import BaseModel
8
+
9
+ class BaiduOCRModel(BaseModel):
10
+ """
11
+ 百度OCR模型,用于图像文字识别
12
+ """
13
+
14
+ def __init__(self, api_key: str, secret_key: str = None, temperature: float = 0.7, system_prompt: str = None):
15
+ """
16
+ 初始化百度OCR模型
17
+
18
+ Args:
19
+ api_key: 百度API Key
20
+ secret_key: 百度Secret Key(可以在api_key中用冒号分隔传入)
21
+ temperature: 不用于OCR但保持BaseModel兼容性
22
+ system_prompt: 不用于OCR但保持BaseModel兼容性
23
+
24
+ Raises:
25
+ ValueError: 如果API密钥格式无效
26
+ """
27
+ super().__init__(api_key, temperature, system_prompt)
28
+
29
+ # 支持两种格式:单独传递或在api_key中用冒号分隔
30
+ if secret_key:
31
+ self.api_key = api_key
32
+ self.secret_key = secret_key
33
+ else:
34
+ try:
35
+ self.api_key, self.secret_key = api_key.split(':')
36
+ except ValueError:
37
+ raise ValueError("百度OCR API密钥必须是 'API_KEY:SECRET_KEY' 格式或单独传递secret_key参数")
38
+
39
+ # 百度API URLs
40
+ self.token_url = "https://aip.baidubce.com/oauth/2.0/token"
41
+ self.ocr_url = "https://aip.baidubce.com/rest/2.0/ocr/v1/accurate_basic"
42
+
43
+ # 缓存access_token
44
+ self._access_token = None
45
+ self._token_expires = 0
46
+
47
+ def get_access_token(self) -> str:
48
+ """获取百度API的access_token"""
49
+ # 检查是否需要刷新token(提前5分钟刷新)
50
+ if self._access_token and time.time() < self._token_expires - 300:
51
+ return self._access_token
52
+
53
+ # 请求新的access_token
54
+ params = {
55
+ 'grant_type': 'client_credentials',
56
+ 'client_id': self.api_key,
57
+ 'client_secret': self.secret_key
58
+ }
59
+
60
+ data = urllib.parse.urlencode(params).encode('utf-8')
61
+ request = urllib.request.Request(self.token_url, data=data)
62
+ request.add_header('Content-Type', 'application/x-www-form-urlencoded')
63
+
64
+ try:
65
+ with urllib.request.urlopen(request) as response:
66
+ result = json.loads(response.read().decode('utf-8'))
67
+
68
+ if 'access_token' in result:
69
+ self._access_token = result['access_token']
70
+ # 设置过期时间(默认30天,但我们提前刷新)
71
+ self._token_expires = time.time() + result.get('expires_in', 2592000)
72
+ return self._access_token
73
+ else:
74
+ raise Exception(f"获取access_token失败: {result.get('error_description', '未知错误')}")
75
+
76
+ except Exception as e:
77
+ raise Exception(f"请求access_token失败: {str(e)}")
78
+
79
+ def ocr_image(self, image_data: str) -> str:
80
+ """
81
+ 对图像进行OCR识别
82
+
83
+ Args:
84
+ image_data: Base64编码的图像数据
85
+
86
+ Returns:
87
+ str: 识别出的文字内容
88
+ """
89
+ access_token = self.get_access_token()
90
+
91
+ # 准备请求数据
92
+ params = {
93
+ 'image': image_data,
94
+ 'language_type': 'auto_detect', # 自动检测语言
95
+ 'detect_direction': 'true', # 检测图像朝向
96
+ 'probability': 'false' # 不返回置信度(减少响应大小)
97
+ }
98
+
99
+ data = urllib.parse.urlencode(params).encode('utf-8')
100
+ url = f"{self.ocr_url}?access_token={access_token}"
101
+
102
+ request = urllib.request.Request(url, data=data)
103
+ request.add_header('Content-Type', 'application/x-www-form-urlencoded')
104
+
105
+ try:
106
+ with urllib.request.urlopen(request) as response:
107
+ result = json.loads(response.read().decode('utf-8'))
108
+
109
+ if 'error_code' in result:
110
+ raise Exception(f"百度OCR API错误: {result.get('error_msg', '未知错误')}")
111
+
112
+ # 提取识别的文字
113
+ words_result = result.get('words_result', [])
114
+ text_lines = [item['words'] for item in words_result]
115
+
116
+ return '\n'.join(text_lines)
117
+
118
+ except Exception as e:
119
+ raise Exception(f"OCR识别失败: {str(e)}")
120
+
121
+ def extract_full_text(self, image_data: str) -> str:
122
+ """
123
+ 提取图像中的完整文本(与Mathpix兼容的接口)
124
+
125
+ Args:
126
+ image_data: Base64编码的图像数据
127
+
128
+ Returns:
129
+ str: 提取的文本内容
130
+ """
131
+ return self.ocr_image(image_data)
132
+
133
+ def analyze_image(self, image_data: str, proxies: dict = None) -> Generator[Dict[str, Any], None, None]:
134
+ """
135
+ 分析图像并返回OCR结果(流式输出以保持接口一致性)
136
+
137
+ Args:
138
+ image_data: Base64编码的图像数据
139
+ proxies: 代理配置(未使用)
140
+
141
+ Yields:
142
+ dict: 包含OCR结果的响应
143
+ """
144
+ try:
145
+ text = self.ocr_image(image_data)
146
+ yield {
147
+ 'status': 'completed',
148
+ 'content': text,
149
+ 'model': 'baidu-ocr'
150
+ }
151
+ except Exception as e:
152
+ yield {
153
+ 'status': 'error',
154
+ 'content': f'OCR识别失败: {str(e)}',
155
+ 'model': 'baidu-ocr'
156
+ }
157
+
158
+ def analyze_text(self, text: str, proxies: dict = None) -> Generator[Dict[str, Any], None, None]:
159
+ """
160
+ 分析文本(OCR模型不支持文本分析)
161
+
162
+ Args:
163
+ text: 输入文本
164
+ proxies: 代理配置(未使用)
165
+
166
+ Yields:
167
+ dict: 错误响应
168
+ """
169
+ yield {
170
+ 'status': 'error',
171
+ 'content': 'OCR模型不支持文本分析功能',
172
+ 'model': 'baidu-ocr'
173
+ }
174
+
175
+ def get_model_identifier(self) -> str:
176
+ """返回模型标识符"""
177
+ return "baidu-ocr"
models/base.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from abc import ABC, abstractmethod
2
+ from typing import Generator, Any
3
+
4
+ class BaseModel(ABC):
5
+ def __init__(self, api_key: str, temperature: float = 0.7, system_prompt: str = None, language: str = None, api_base_url: str = None):
6
+ self.api_key = api_key
7
+ self.temperature = temperature
8
+ self.language = language
9
+ self.system_prompt = system_prompt or self.get_default_system_prompt()
10
+ self.api_base_url = api_base_url
11
+
12
+ @abstractmethod
13
+ def analyze_image(self, image_data: str, proxies: dict = None) -> Generator[dict, None, None]:
14
+ """
15
+ Analyze the given image and yield response chunks.
16
+
17
+ Args:
18
+ image_data: Base64 encoded image data
19
+ proxies: Optional proxy configuration
20
+
21
+ Yields:
22
+ dict: Response chunks with status and content
23
+ """
24
+ pass
25
+
26
+ @abstractmethod
27
+ def analyze_text(self, text: str, proxies: dict = None) -> Generator[dict, None, None]:
28
+ """
29
+ Analyze the given text and yield response chunks.
30
+
31
+ Args:
32
+ text: Text to analyze
33
+ proxies: Optional proxy configuration
34
+
35
+ Yields:
36
+ dict: Response chunks with status and content
37
+ """
38
+ pass
39
+
40
+ def get_default_system_prompt(self) -> str:
41
+ """返回默认的系统提示词,子类可覆盖但不再是必须实现的方法"""
42
+ return "您是一位专业的问题解决专家。请逐步分析问题,找出问题所在,并提供详细的解决方案。始终使用用户偏好的语言回答。"
43
+
44
+ @abstractmethod
45
+ def get_model_identifier(self) -> str:
46
+ """Return the model identifier used in API calls"""
47
+ pass
models/deepseek.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import requests
3
+ import os
4
+ from typing import Generator
5
+ from openai import OpenAI
6
+ from .base import BaseModel
7
+
8
+ class DeepSeekModel(BaseModel):
9
+ def __init__(self, api_key: str, temperature: float = 0.7, system_prompt: str = None, language: str = None, model_name: str = "deepseek-reasoner", api_base_url: str = None):
10
+ super().__init__(api_key, temperature, system_prompt, language)
11
+ self.model_name = model_name
12
+ self.api_base_url = api_base_url # 存储API基础URL
13
+
14
+ def get_default_system_prompt(self) -> str:
15
+ return """You are an expert at analyzing questions and providing detailed solutions. When presented with an image of a question:
16
+ 1. First read and understand the question carefully
17
+ 2. Break down the key components of the question
18
+ 3. Provide a clear, step-by-step solution
19
+ 4. If relevant, explain any concepts or theories involved
20
+ 5. If there are multiple approaches, explain the most efficient one first"""
21
+
22
+ def get_model_identifier(self) -> str:
23
+ """根据模型名称返回正确的API标识符"""
24
+ # 通过模型名称来确定实际的API调用标识符
25
+ if self.model_name == "deepseek-chat":
26
+ return "deepseek-chat"
27
+ # 如果是deepseek-reasoner或包含reasoner的模型名称,返回推理模型标识符
28
+ if "reasoner" in self.model_name.lower():
29
+ return "deepseek-reasoner"
30
+ # 对于deepseek-chat也返回对应的模型名称
31
+ if "chat" in self.model_name.lower() or self.model_name == "deepseek-chat":
32
+ return "deepseek-chat"
33
+
34
+ # 根据配置中的模型ID来确定实际的模型类型
35
+ if self.model_name == "deepseek-reasoner":
36
+ return "deepseek-reasoner"
37
+ elif self.model_name == "deepseek-chat":
38
+ return "deepseek-chat"
39
+
40
+ # 默认使用deepseek-chat作为API标识符
41
+ print(f"未知的DeepSeek模型名称: {self.model_name},使用deepseek-chat作为默认值")
42
+ return "deepseek-chat"
43
+
44
+ def analyze_text(self, text: str, proxies: dict = None) -> Generator[dict, None, None]:
45
+ """Stream DeepSeek's response for text analysis"""
46
+ try:
47
+ # Initial status
48
+ yield {"status": "started", "content": ""}
49
+
50
+ # 保存原始环境变量
51
+ original_env = {
52
+ 'http_proxy': os.environ.get('http_proxy'),
53
+ 'https_proxy': os.environ.get('https_proxy')
54
+ }
55
+
56
+ try:
57
+ # 如果提供了代理设置,通过环境变量设置
58
+ if proxies:
59
+ if 'http' in proxies:
60
+ os.environ['http_proxy'] = proxies['http']
61
+ if 'https' in proxies:
62
+ os.environ['https_proxy'] = proxies['https']
63
+
64
+ # 初始化DeepSeek客户端,不再使用session对象
65
+ client = OpenAI(
66
+ api_key=self.api_key,
67
+ base_url="https://api.deepseek.com"
68
+ )
69
+
70
+ # 使用系统提供的系统提示词,不再自动添加语言指令
71
+ system_prompt = self.system_prompt
72
+
73
+ # 构建请求参数
74
+ params = {
75
+ "model": self.get_model_identifier(),
76
+ "messages": [
77
+ {
78
+ 'role': 'system',
79
+ 'content': system_prompt
80
+ },
81
+ {
82
+ 'role': 'user',
83
+ 'content': text
84
+ }
85
+ ],
86
+ "stream": True
87
+ }
88
+
89
+ # 只有非推理模型才设置temperature参数
90
+ if not self.get_model_identifier().endswith('reasoner') and self.temperature is not None:
91
+ params["temperature"] = self.temperature
92
+
93
+ print(f"调用DeepSeek API: {self.get_model_identifier()}, 是否设置温度: {not self.get_model_identifier().endswith('reasoner')}, 温度值: {self.temperature if not self.get_model_identifier().endswith('reasoner') else 'N/A'}")
94
+
95
+ response = client.chat.completions.create(**params)
96
+
97
+ # 使用两个缓冲区,分别用于常规内容和思考内容
98
+ response_buffer = ""
99
+ thinking_buffer = ""
100
+
101
+ for chunk in response:
102
+ # 打印chunk以调试
103
+ try:
104
+ print(f"DeepSeek API返回chunk: {chunk}")
105
+ except:
106
+ print("无法打印chunk")
107
+
108
+ try:
109
+ # 同时处理两种不同的内容,确保正确区分思考内容和最终内容
110
+ delta = chunk.choices[0].delta
111
+
112
+ # 处理推理模型的思考内容
113
+ if hasattr(delta, 'reasoning_content') and delta.reasoning_content:
114
+ content = delta.reasoning_content
115
+ thinking_buffer += content
116
+
117
+ # 发送思考内容更新
118
+ if len(content) >= 20 or content.endswith(('.', '!', '?', '。', '!', '?', '\n')):
119
+ yield {
120
+ "status": "thinking",
121
+ "content": thinking_buffer
122
+ }
123
+
124
+ # 处理最终结果内容 - 即使在推理模型中也会有content字段
125
+ if hasattr(delta, 'content') and delta.content:
126
+ content = delta.content
127
+ response_buffer += content
128
+ print(f"累积响应内容: '{content}', 当前buffer: '{response_buffer}'")
129
+
130
+ # 发送结果内容更新
131
+ if len(content) >= 10 or content.endswith(('.', '!', '?', '。', '!', '?', '\n')):
132
+ yield {
133
+ "status": "streaming",
134
+ "content": response_buffer
135
+ }
136
+
137
+ # 处理消息结束
138
+ if hasattr(chunk.choices[0], 'finish_reason') and chunk.choices[0].finish_reason:
139
+ print(f"生成结束,原因: {chunk.choices[0].finish_reason}")
140
+ # 注意:不要在这里把思考内容作为正文,因为这可能导致重复内容
141
+ except Exception as e:
142
+ print(f"解析响应chunk时出错: {str(e)}")
143
+ continue
144
+
145
+ # 确保发送最终的缓冲内容
146
+ if thinking_buffer:
147
+ yield {
148
+ "status": "thinking_complete",
149
+ "content": thinking_buffer
150
+ }
151
+
152
+ # 发送最终响应内容
153
+ if response_buffer:
154
+ yield {
155
+ "status": "completed",
156
+ "content": response_buffer
157
+ }
158
+
159
+ # 如果没有正常的响应内容,但有思考内容,则将思考内容作为最终结果
160
+ elif thinking_buffer:
161
+ yield {
162
+ "status": "completed",
163
+ "content": thinking_buffer
164
+ }
165
+ else:
166
+ # 如果两者都没有,返回一个空结果
167
+ yield {
168
+ "status": "completed",
169
+ "content": "没有获取到内容"
170
+ }
171
+
172
+ except Exception as e:
173
+ error_msg = str(e)
174
+ print(f"DeepSeek API调用出错: {error_msg}")
175
+
176
+ # 提供具体的错误信息
177
+ if "invalid_api_key" in error_msg.lower():
178
+ error_msg = "DeepSeek API密钥无效,请检查您的API密钥"
179
+ elif "rate_limit" in error_msg.lower():
180
+ error_msg = "DeepSeek API请求频率超限,请稍后再试"
181
+ elif "quota_exceeded" in error_msg.lower():
182
+ error_msg = "DeepSeek API配额已用完,请续费或等待下个计费周期"
183
+
184
+ yield {
185
+ "status": "error",
186
+ "error": f"DeepSeek API错误: {error_msg}"
187
+ }
188
+ finally:
189
+ # 恢复原始环境变量
190
+ for key, value in original_env.items():
191
+ if value is None:
192
+ if key in os.environ:
193
+ del os.environ[key]
194
+ else:
195
+ os.environ[key] = value
196
+
197
+ except Exception as e:
198
+ error_msg = str(e)
199
+ print(f"调用DeepSeek模型时发生错误: {error_msg}")
200
+
201
+ if "invalid_api_key" in error_msg.lower():
202
+ error_msg = "API密钥无效,请检查设置"
203
+ elif "rate_limit" in error_msg.lower():
204
+ error_msg = "API请求频率超限,请稍后再试"
205
+
206
+ yield {
207
+ "status": "error",
208
+ "error": f"DeepSeek API错误: {error_msg}"
209
+ }
210
+
211
+ def analyze_image(self, image_data: str, proxies: dict = None) -> Generator[dict, None, None]:
212
+ """Stream DeepSeek's response for image analysis"""
213
+ try:
214
+ # 检查我们是否有支持图像的模型
215
+ if self.model_name == "deepseek-chat" or self.model_name == "deepseek-reasoner":
216
+ yield {
217
+ "status": "error",
218
+ "error": "当前DeepSeek模型不支持图像分析,请使用Anthropic或OpenAI的多模态模型"
219
+ }
220
+ return
221
+
222
+ # Initial status
223
+ yield {"status": "started", "content": ""}
224
+
225
+ # 保存原始环境变量
226
+ original_env = {
227
+ 'http_proxy': os.environ.get('http_proxy'),
228
+ 'https_proxy': os.environ.get('https_proxy')
229
+ }
230
+
231
+ try:
232
+ # 如果提供了代理设置,通过环境变量设置
233
+ if proxies:
234
+ if 'http' in proxies:
235
+ os.environ['http_proxy'] = proxies['http']
236
+ if 'https' in proxies:
237
+ os.environ['https_proxy'] = proxies['https']
238
+
239
+ # 初始化DeepSeek客户端,不再使用session对象
240
+ client = OpenAI(
241
+ api_key=self.api_key,
242
+ base_url="https://api.deepseek.com"
243
+ )
244
+
245
+ # 使用系统提供的系统提示词,不再自动添加语言指令
246
+ system_prompt = self.system_prompt
247
+
248
+ # 构建请求参数
249
+ params = {
250
+ "model": self.get_model_identifier(),
251
+ "messages": [
252
+ {
253
+ 'role': 'system',
254
+ 'content': system_prompt
255
+ },
256
+ {
257
+ 'role': 'user',
258
+ 'content': f"Here's an image of a question to analyze: data:image/png;base64,{image_data}"
259
+ }
260
+ ],
261
+ "stream": True
262
+ }
263
+
264
+ # 只有非推理模型才设置temperature参数
265
+ if not self.get_model_identifier().endswith('reasoner') and self.temperature is not None:
266
+ params["temperature"] = self.temperature
267
+
268
+ response = client.chat.completions.create(**params)
269
+
270
+ # 使用两个缓冲区,分别用于常规内容和思考内容
271
+ response_buffer = ""
272
+ thinking_buffer = ""
273
+
274
+ for chunk in response:
275
+ # 打印chunk以调试
276
+ try:
277
+ print(f"DeepSeek图像API返回chunk: {chunk}")
278
+ except:
279
+ print("无法打印chunk")
280
+
281
+ try:
282
+ # 同时处理两种不同的内容,确保正确区分思考内容和最终内容
283
+ delta = chunk.choices[0].delta
284
+
285
+ # 处理推理模型的思考内容
286
+ if hasattr(delta, 'reasoning_content') and delta.reasoning_content:
287
+ content = delta.reasoning_content
288
+ thinking_buffer += content
289
+
290
+ # 发送思考内容更新
291
+ if len(content) >= 20 or content.endswith(('.', '!', '?', '。', '!', '?', '\n')):
292
+ yield {
293
+ "status": "thinking",
294
+ "content": thinking_buffer
295
+ }
296
+
297
+ # 处理最终结果内容 - 即使在推理模型中也会有content字段
298
+ if hasattr(delta, 'content') and delta.content:
299
+ content = delta.content
300
+ response_buffer += content
301
+ print(f"累积图像响应内容: '{content}', 当前buffer: '{response_buffer}'")
302
+
303
+ # 发送结果内容更新
304
+ if len(content) >= 10 or content.endswith(('.', '!', '?', '。', '!', '?', '\n')):
305
+ yield {
306
+ "status": "streaming",
307
+ "content": response_buffer
308
+ }
309
+
310
+ # 处理消息结束
311
+ if hasattr(chunk.choices[0], 'finish_reason') and chunk.choices[0].finish_reason:
312
+ print(f"图像生成结束,原因: {chunk.choices[0].finish_reason}")
313
+ except Exception as e:
314
+ print(f"解析图像响应chunk时出错: {str(e)}")
315
+ continue
316
+
317
+ # 确保发送最终的缓冲内容
318
+ if thinking_buffer:
319
+ yield {
320
+ "status": "thinking_complete",
321
+ "content": thinking_buffer
322
+ }
323
+
324
+ # 发送最终响应内容
325
+ if response_buffer:
326
+ yield {
327
+ "status": "completed",
328
+ "content": response_buffer
329
+ }
330
+
331
+ except Exception as e:
332
+ error_msg = str(e)
333
+ print(f"DeepSeek API调用出错: {error_msg}")
334
+
335
+ # 提供具体的错误信息
336
+ if "invalid_api_key" in error_msg.lower():
337
+ error_msg = "DeepSeek API密钥无效,请检查您的API密钥"
338
+ elif "rate_limit" in error_msg.lower():
339
+ error_msg = "DeepSeek API请求频率超限,请稍后再试"
340
+
341
+ yield {
342
+ "status": "error",
343
+ "error": f"DeepSeek API错误: {error_msg}"
344
+ }
345
+ finally:
346
+ # 恢复原始环境变量
347
+ for key, value in original_env.items():
348
+ if value is None:
349
+ if key in os.environ:
350
+ del os.environ[key]
351
+ else:
352
+ os.environ[key] = value
353
+
354
+ except Exception as e:
355
+ error_msg = str(e)
356
+ if "invalid_api_key" in error_msg.lower():
357
+ error_msg = "API密钥无效,请检查设置"
358
+ elif "rate_limit" in error_msg.lower():
359
+ error_msg = "API请求频率超限,请稍后再试"
360
+
361
+ yield {
362
+ "status": "error",
363
+ "error": f"DeepSeek API错误: {error_msg}"
364
+ }
models/doubao.py ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import base64
4
+ from typing import Generator, Dict, Any, Optional
5
+ import requests
6
+ from .base import BaseModel
7
+
8
+ class DoubaoModel(BaseModel):
9
+ """
10
+ 豆包API模型实现类
11
+ 支持字节跳动的豆包AI模型,可处理文本和图像输入
12
+ """
13
+
14
+ def __init__(self, api_key: str, temperature: float = 0.7, system_prompt: str = None, language: str = None, model_name: str = None, api_base_url: str = None):
15
+ """
16
+ 初始化豆包模型
17
+
18
+ Args:
19
+ api_key: 豆包API密钥
20
+ temperature: 生成温度
21
+ system_prompt: 系统提示词
22
+ language: 首选语言
23
+ model_name: 指定具体模型名称,如不指定则使用默认值
24
+ api_base_url: API基础URL,用于设置自定义API端点
25
+ """
26
+ super().__init__(api_key, temperature, system_prompt, language)
27
+ self.model_name = model_name or self.get_model_identifier()
28
+ self.base_url = api_base_url or "https://ark.cn-beijing.volces.com/api/v3"
29
+ self.max_tokens = 4096 # 默认最大输出token数
30
+ self.reasoning_config = None # 推理配置,类似于AnthropicModel
31
+
32
+ def get_default_system_prompt(self) -> str:
33
+ return """你是一个专业的问题分析专家。当看到问题图片时:
34
+ 1. 仔细阅读并理解问题
35
+ 2. 分解问题的关键组成部分
36
+ 3. 提供清晰的分步解决方案
37
+ 4. 如果相关,解释涉及的概念或理论
38
+ 5. 如果有多种方法,优先解释最有效的方法"""
39
+
40
+ def get_model_identifier(self) -> str:
41
+ """返回默认的模型标识符"""
42
+ return "doubao-seed-1-6-250615" # Doubao-Seed-1.6
43
+
44
+ def get_actual_model_name(self) -> str:
45
+ """根据配置的模型名称返回实际的API调用标识符"""
46
+ # 豆包API的实际模型名称映射
47
+ model_mapping = {
48
+ "doubao-seed-1-6-250615": "doubao-seed-1-6-250615"
49
+ }
50
+
51
+ return model_mapping.get(self.model_name, "doubao-seed-1-6-250615")
52
+
53
+ def analyze_text(self, text: str, proxies: dict = None) -> Generator[dict, None, None]:
54
+ """流式生成文本响应"""
55
+ try:
56
+ yield {"status": "started"}
57
+
58
+ # 设置环境变量代理(如果提供)
59
+ original_proxies = None
60
+ if proxies:
61
+ original_proxies = {
62
+ 'http_proxy': os.environ.get('http_proxy'),
63
+ 'https_proxy': os.environ.get('https_proxy')
64
+ }
65
+ if 'http' in proxies:
66
+ os.environ['http_proxy'] = proxies['http']
67
+ if 'https' in proxies:
68
+ os.environ['https_proxy'] = proxies['https']
69
+
70
+ try:
71
+ # 构建请求头
72
+ headers = {
73
+ "Authorization": f"Bearer {self.api_key}",
74
+ "Content-Type": "application/json"
75
+ }
76
+
77
+ # 构建消息 - 添加系统提示词
78
+ messages = []
79
+
80
+ # 添加系统提示词
81
+ if self.system_prompt:
82
+ messages.append({
83
+ "role": "system",
84
+ "content": self.system_prompt
85
+ })
86
+
87
+ # 添加用户查询
88
+ user_content = text
89
+ if self.language and self.language != 'auto':
90
+ user_content = f"请使用{self.language}回答以下问题: {text}"
91
+
92
+ messages.append({
93
+ "role": "user",
94
+ "content": user_content
95
+ })
96
+
97
+ # 处理推理配置
98
+ thinking = {
99
+ "type": "auto" # 默认值
100
+ }
101
+
102
+ if hasattr(self, 'reasoning_config') and self.reasoning_config:
103
+ # 从reasoning_config中获取thinking_mode
104
+ thinking_mode = self.reasoning_config.get('thinking_mode', "auto")
105
+ thinking = {
106
+ "type": thinking_mode
107
+ }
108
+
109
+ # 构建请求数据
110
+ data = {
111
+ "model": self.get_actual_model_name(),
112
+ "messages": messages,
113
+ "thinking": thinking,
114
+ "temperature": self.temperature,
115
+ "max_tokens": self.max_tokens,
116
+ "stream": True
117
+ }
118
+
119
+ # 发送流式请求
120
+ response = requests.post(
121
+ f"{self.base_url}/chat/completions",
122
+ headers=headers,
123
+ json=data,
124
+ stream=True,
125
+ proxies=proxies if proxies else None,
126
+ timeout=60
127
+ )
128
+
129
+ if response.status_code != 200:
130
+ error_text = response.text
131
+ raise Exception(f"HTTP {response.status_code}: {error_text}")
132
+
133
+ response.raise_for_status()
134
+
135
+ # 初始化响应缓冲区
136
+ response_buffer = ""
137
+
138
+ # 处理流式响应
139
+ for line in response.iter_lines():
140
+ if not line:
141
+ continue
142
+
143
+ line = line.decode('utf-8')
144
+ if not line.startswith('data: '):
145
+ continue
146
+
147
+ line = line[6:] # 移除 'data: ' 前缀
148
+
149
+ if line == '[DONE]':
150
+ break
151
+
152
+ try:
153
+ chunk_data = json.loads(line)
154
+ choices = chunk_data.get('choices', [])
155
+
156
+ if choices and len(choices) > 0:
157
+ delta = choices[0].get('delta', {})
158
+ content = delta.get('content', '')
159
+
160
+ if content:
161
+ response_buffer += content
162
+
163
+ # 发送响应进度
164
+ yield {
165
+ "status": "streaming",
166
+ "content": response_buffer
167
+ }
168
+
169
+ except json.JSONDecodeError:
170
+ continue
171
+
172
+ # 确保发送完整的最终内容
173
+ yield {
174
+ "status": "completed",
175
+ "content": response_buffer
176
+ }
177
+
178
+ finally:
179
+ # 恢复原始代理设置
180
+ if original_proxies:
181
+ for key, value in original_proxies.items():
182
+ if value is None:
183
+ if key in os.environ:
184
+ del os.environ[key]
185
+ else:
186
+ os.environ[key] = value
187
+
188
+ except Exception as e:
189
+ yield {
190
+ "status": "error",
191
+ "error": f"豆包API错误: {str(e)}"
192
+ }
193
+
194
+ def analyze_image(self, image_data: str, proxies: dict = None) -> Generator[dict, None, None]:
195
+ """分析图像并流式生成响应"""
196
+ try:
197
+ yield {"status": "started"}
198
+
199
+ # 设置环境变量代理(如果提供)
200
+ original_proxies = None
201
+ if proxies:
202
+ original_proxies = {
203
+ 'http_proxy': os.environ.get('http_proxy'),
204
+ 'https_proxy': os.environ.get('https_proxy')
205
+ }
206
+ if 'http' in proxies:
207
+ os.environ['http_proxy'] = proxies['http']
208
+ if 'https' in proxies:
209
+ os.environ['https_proxy'] = proxies['https']
210
+
211
+ try:
212
+ # 构建请求头
213
+ headers = {
214
+ "Authorization": f"Bearer {self.api_key}",
215
+ "Content-Type": "application/json"
216
+ }
217
+
218
+ # 处理图像数据
219
+ if image_data.startswith('data:image'):
220
+ # 如果是data URI,提取base64部分
221
+ image_data = image_data.split(',', 1)[1]
222
+
223
+ # 构建用户消息 - 使用豆包API官方示例格式
224
+ # 首先检查图像数据的格式,确保是有效的图像
225
+ image_format = "jpeg" # 默认使用jpeg
226
+ if image_data.startswith('/9j/'): # JPEG magic number in base64
227
+ image_format = "jpeg"
228
+ elif image_data.startswith('iVBORw0KGgo'): # PNG magic number in base64
229
+ image_format = "png"
230
+
231
+ # 构建消息
232
+ messages = []
233
+
234
+ # 添加系统提示词
235
+ if self.system_prompt:
236
+ messages.append({
237
+ "role": "system",
238
+ "content": self.system_prompt
239
+ })
240
+
241
+ user_content = [
242
+ {
243
+ "type": "text",
244
+ "text": f"请使用{self.language}分析这张图片并提供详细解答。" if self.language and self.language != 'auto' else "请分析这张图片并提供详细解答?"
245
+ },
246
+ {
247
+ "type": "image_url",
248
+ "image_url": {
249
+ "url": f"data:image/{image_format};base64,{image_data}"
250
+ }
251
+ }
252
+ ]
253
+
254
+ messages.append({
255
+ "role": "user",
256
+ "content": user_content
257
+ })
258
+
259
+ # 处理推理配置
260
+ thinking = {
261
+ "type": "auto" # 默认值
262
+ }
263
+
264
+ if hasattr(self, 'reasoning_config') and self.reasoning_config:
265
+ # 从reasoning_config中获取thinking_mode
266
+ thinking_mode = self.reasoning_config.get('thinking_mode', "auto")
267
+ thinking = {
268
+ "type": thinking_mode
269
+ }
270
+
271
+ # 构建请求数据
272
+ data = {
273
+ "model": self.get_actual_model_name(),
274
+ "messages": messages,
275
+ "thinking": thinking,
276
+ "temperature": self.temperature,
277
+ "max_tokens": self.max_tokens,
278
+ "stream": True
279
+ }
280
+
281
+ # 发送流式请求
282
+ response = requests.post(
283
+ f"{self.base_url}/chat/completions",
284
+ headers=headers,
285
+ json=data,
286
+ stream=True,
287
+ proxies=proxies if proxies else None,
288
+ timeout=60
289
+ )
290
+
291
+ if response.status_code != 200:
292
+ error_text = response.text
293
+ raise Exception(f"HTTP {response.status_code}: {error_text}")
294
+
295
+ response.raise_for_status()
296
+
297
+ # 初始化响应缓冲区
298
+ response_buffer = ""
299
+
300
+ # 处理流式响应
301
+ for line in response.iter_lines():
302
+ if not line:
303
+ continue
304
+
305
+ line = line.decode('utf-8')
306
+ if not line.startswith('data: '):
307
+ continue
308
+
309
+ line = line[6:] # 移除 'data: ' 前缀
310
+
311
+ if line == '[DONE]':
312
+ break
313
+
314
+ try:
315
+ chunk_data = json.loads(line)
316
+ choices = chunk_data.get('choices', [])
317
+
318
+ if choices and len(choices) > 0:
319
+ delta = choices[0].get('delta', {})
320
+ content = delta.get('content', '')
321
+
322
+ if content:
323
+ response_buffer += content
324
+
325
+ # 发送响应进度
326
+ yield {
327
+ "status": "streaming",
328
+ "content": response_buffer
329
+ }
330
+
331
+ except json.JSONDecodeError:
332
+ continue
333
+
334
+ # 确保发送完整的最终内容
335
+ yield {
336
+ "status": "completed",
337
+ "content": response_buffer
338
+ }
339
+
340
+ finally:
341
+ # 恢复原始代理设置
342
+ if original_proxies:
343
+ for key, value in original_proxies.items():
344
+ if value is None:
345
+ if key in os.environ:
346
+ del os.environ[key]
347
+ else:
348
+ os.environ[key] = value
349
+
350
+ except Exception as e:
351
+ yield {
352
+ "status": "error",
353
+ "error": f"豆包图像分析错误: {str(e)}"
354
+ }
models/factory.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Type, Any, Optional
2
+ import json
3
+ import os
4
+ import importlib
5
+ from .base import BaseModel
6
+ from .mathpix import MathpixModel # MathpixModel需要直接导入,因为它是特殊OCR工具
7
+ from .baidu_ocr import BaiduOCRModel # 百度OCR也是特殊OCR工具,直接导入
8
+
9
+ class ModelFactory:
10
+ # 模型基本信息,包含类型和特性
11
+ _models: Dict[str, Dict[str, Any]] = {}
12
+ _class_map: Dict[str, Type[BaseModel]] = {}
13
+
14
+ @classmethod
15
+ def initialize(cls):
16
+ """从配置文件加载模型信息"""
17
+ try:
18
+ config_path = os.path.join(os.path.dirname(__file__), '..', 'config', 'models.json')
19
+ with open(config_path, 'r', encoding='utf-8') as f:
20
+ config = json.load(f)
21
+
22
+ # 加载提供商信息和类映射
23
+ providers = config.get('providers', {})
24
+ for provider_id, provider_info in providers.items():
25
+ class_name = provider_info.get('class_name')
26
+ if class_name:
27
+ # 从当前包动态导入模型类
28
+ module = importlib.import_module(f'.{provider_id.lower()}', package=__package__)
29
+ cls._class_map[provider_id] = getattr(module, class_name)
30
+
31
+ # 加载模型信息
32
+ for model_id, model_info in config.get('models', {}).items():
33
+ provider_id = model_info.get('provider')
34
+ if provider_id and provider_id in cls._class_map:
35
+ cls._models[model_id] = {
36
+ 'class': cls._class_map[provider_id],
37
+ 'provider_id': provider_id,
38
+ 'is_multimodal': model_info.get('supportsMultimodal', False),
39
+ 'is_reasoning': model_info.get('isReasoning', False),
40
+ 'display_name': model_info.get('name', model_id),
41
+ 'description': model_info.get('description', '')
42
+ }
43
+
44
+ # 添加特殊OCR工具模型(不在配置文件中定义)
45
+
46
+ # 添加Mathpix OCR工具
47
+ cls._models['mathpix'] = {
48
+ 'class': MathpixModel,
49
+ 'is_multimodal': True,
50
+ 'is_reasoning': False,
51
+ 'display_name': 'Mathpix OCR',
52
+ 'description': '数学公式识别工具,适用于复杂数学内容',
53
+ 'is_ocr_only': True
54
+ }
55
+
56
+ # 添加百度OCR工具
57
+ cls._models['baidu-ocr'] = {
58
+ 'class': BaiduOCRModel,
59
+ 'is_multimodal': True,
60
+ 'is_reasoning': False,
61
+ 'display_name': '百度OCR',
62
+ 'description': '通用文字识别工具,支持中文识别',
63
+ 'is_ocr_only': True
64
+ }
65
+
66
+ print(f"已从配置加载 {len(cls._models)} 个模型")
67
+ except Exception as e:
68
+ print(f"加载模型配置失败: {str(e)}")
69
+ cls._initialize_defaults()
70
+
71
+ @classmethod
72
+ def _initialize_defaults(cls):
73
+ """初始化默认模型(当配置加载失败时)"""
74
+ print("配置加载失败,使用空模型列表")
75
+
76
+ # 不再硬编码模型定义,而是使用空字典
77
+ cls._models = {}
78
+
79
+ # 添加特殊OCR工具(当配置加载失败时的备用)
80
+ try:
81
+ # 导入并添加Mathpix OCR工具
82
+ from .mathpix import MathpixModel
83
+
84
+ cls._models['mathpix'] = {
85
+ 'class': MathpixModel,
86
+ 'is_multimodal': True,
87
+ 'is_reasoning': False,
88
+ 'display_name': 'Mathpix OCR',
89
+ 'description': '数学公式识别工具,适用于复杂数学内容',
90
+ 'is_ocr_only': True
91
+ }
92
+ except Exception as e:
93
+ print(f"无法加载Mathpix OCR工具: {str(e)}")
94
+
95
+ # 添加百度OCR工具
96
+ try:
97
+ from .baidu_ocr import BaiduOCRModel
98
+
99
+ cls._models['baidu-ocr'] = {
100
+ 'class': BaiduOCRModel,
101
+ 'is_multimodal': True,
102
+ 'is_reasoning': False,
103
+ 'display_name': '百度OCR',
104
+ 'description': '通用文字识别工具,支持中文识别',
105
+ 'is_ocr_only': True
106
+ }
107
+ except Exception as e:
108
+ print(f"无法加载百度OCR工具: {str(e)}")
109
+
110
+ @classmethod
111
+ def create_model(cls, model_name: str, api_key: str, temperature: float = 0.7,
112
+ system_prompt: Optional[str] = None, language: Optional[str] = None, api_base_url: Optional[str] = None) -> BaseModel:
113
+ """
114
+ Create a model instance based on the model name.
115
+
116
+ Args:
117
+ model_name: The identifier for the model
118
+ api_key: The API key for the model service
119
+ temperature: The temperature to use for generation
120
+ system_prompt: The system prompt to use
121
+ language: The preferred language for responses
122
+ api_base_url: The base URL for API requests
123
+
124
+ Returns:
125
+ A model instance
126
+ """
127
+ if model_name not in cls._models:
128
+ raise ValueError(f"Unknown model: {model_name}")
129
+
130
+ model_info = cls._models[model_name]
131
+ model_class = model_info['class']
132
+ provider_id = model_info.get('provider_id')
133
+
134
+ if provider_id == 'openai':
135
+ return model_class(
136
+ api_key=api_key,
137
+ temperature=temperature,
138
+ system_prompt=system_prompt,
139
+ language=language,
140
+ api_base_url=api_base_url,
141
+ model_identifier=model_name
142
+ )
143
+
144
+ # 对于DeepSeek模型,需要传递正确的模型名称
145
+ if 'deepseek' in model_name.lower():
146
+ return model_class(
147
+ api_key=api_key,
148
+ temperature=temperature,
149
+ system_prompt=system_prompt,
150
+ language=language,
151
+ model_name=model_name,
152
+ api_base_url=api_base_url
153
+ )
154
+ # 对于阿里巴巴模型,也需要传递正确的模型名称
155
+ elif 'qwen' in model_name.lower() or 'qvq' in model_name.lower() or 'alibaba' in model_name.lower():
156
+ return model_class(
157
+ api_key=api_key,
158
+ temperature=temperature,
159
+ system_prompt=system_prompt,
160
+ language=language,
161
+ model_name=model_name
162
+ )
163
+ # 对于Google模型,也需要传递正确的模型名称
164
+ elif 'gemini' in model_name.lower() or 'google' in model_name.lower():
165
+ return model_class(
166
+ api_key=api_key,
167
+ temperature=temperature,
168
+ system_prompt=system_prompt,
169
+ language=language,
170
+ model_name=model_name,
171
+ api_base_url=api_base_url
172
+ )
173
+ # 对于豆包模型,也需要传递正确的模型名称
174
+ elif 'doubao' in model_name.lower():
175
+ return model_class(
176
+ api_key=api_key,
177
+ temperature=temperature,
178
+ system_prompt=system_prompt,
179
+ language=language,
180
+ model_name=model_name,
181
+ api_base_url=api_base_url
182
+ )
183
+ # 对于Mathpix模型,不传递language参数
184
+ elif model_name == 'mathpix':
185
+ return model_class(
186
+ api_key=api_key,
187
+ temperature=temperature,
188
+ system_prompt=system_prompt
189
+ )
190
+ # 对于百度OCR模型,传递api_key(支持API_KEY:SECRET_KEY格式)
191
+ elif model_name == 'baidu-ocr':
192
+ return model_class(
193
+ api_key=api_key,
194
+ temperature=temperature,
195
+ system_prompt=system_prompt
196
+ )
197
+ # 对于Anthropic模型,需要传递model_identifier参数
198
+ elif 'claude' in model_name.lower() or 'anthropic' in model_name.lower():
199
+ return model_class(
200
+ api_key=api_key,
201
+ temperature=temperature,
202
+ system_prompt=system_prompt,
203
+ language=language,
204
+ api_base_url=api_base_url,
205
+ model_identifier=model_name
206
+ )
207
+ else:
208
+ # 其他模型仅传递标准参数
209
+ return model_class(
210
+ api_key=api_key,
211
+ temperature=temperature,
212
+ system_prompt=system_prompt,
213
+ language=language,
214
+ api_base_url=api_base_url
215
+ )
216
+
217
+ @classmethod
218
+ def get_available_models(cls) -> list[Dict[str, Any]]:
219
+ """Return a list of available models with their information"""
220
+ models_info = []
221
+ for model_id, info in cls._models.items():
222
+ # 跳过仅OCR工具模型
223
+ if info.get('is_ocr_only', False):
224
+ continue
225
+
226
+ models_info.append({
227
+ 'id': model_id,
228
+ 'display_name': info.get('display_name', model_id),
229
+ 'description': info.get('description', ''),
230
+ 'is_multimodal': info.get('is_multimodal', False),
231
+ 'is_reasoning': info.get('is_reasoning', False)
232
+ })
233
+ return models_info
234
+
235
+ @classmethod
236
+ def get_model_ids(cls) -> list[str]:
237
+ """Return a list of available model identifiers"""
238
+ return [model_id for model_id in cls._models.keys()
239
+ if not cls._models[model_id].get('is_ocr_only', False)]
240
+
241
+ @classmethod
242
+ def is_multimodal(cls, model_name: str) -> bool:
243
+ """判断模型是否支持多模态输入"""
244
+ return cls._models.get(model_name, {}).get('is_multimodal', False)
245
+
246
+ @classmethod
247
+ def is_reasoning(cls, model_name: str) -> bool:
248
+ """判断模型是否为推理模型"""
249
+ return cls._models.get(model_name, {}).get('is_reasoning', False)
250
+
251
+ @classmethod
252
+ def get_model_display_name(cls, model_name: str) -> str:
253
+ """获取模型的显示名称"""
254
+ return cls._models.get(model_name, {}).get('display_name', model_name)
255
+
256
+ @classmethod
257
+ def register_model(cls, model_name: str, model_class: Type[BaseModel],
258
+ is_multimodal: bool = False, is_reasoning: bool = False,
259
+ display_name: Optional[str] = None, description: Optional[str] = None) -> None:
260
+ """
261
+ Register a new model type with the factory.
262
+
263
+ Args:
264
+ model_name: The identifier for the model
265
+ model_class: The model class to register
266
+ is_multimodal: Whether the model supports image input
267
+ is_reasoning: Whether the model provides reasoning process
268
+ display_name: Human-readable name for the model
269
+ description: Description of the model
270
+ """
271
+ cls._models[model_name] = {
272
+ 'class': model_class,
273
+ 'is_multimodal': is_multimodal,
274
+ 'is_reasoning': is_reasoning,
275
+ 'display_name': display_name or model_name,
276
+ 'description': description or ''
277
+ }
models/google.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import base64
4
+ from typing import Generator, Dict, Any, Optional, List
5
+ import google.generativeai as genai
6
+ from .base import BaseModel
7
+
8
+ class GoogleModel(BaseModel):
9
+ """
10
+ Google Gemini API模型实现类
11
+ 支持Gemini 2.5 Pro等模型,可处理文本和图像输入
12
+ """
13
+
14
+ def __init__(self, api_key: str, temperature: float = 0.7, system_prompt: str = None, language: str = None, model_name: str = None, api_base_url: str = None):
15
+ """
16
+ 初始化Google模型
17
+
18
+ Args:
19
+ api_key: Google API密钥
20
+ temperature: 生成温度
21
+ system_prompt: 系统提示词
22
+ language: 首选语言
23
+ model_name: 指定具体模型名称,如不指定则使用默认值
24
+ api_base_url: API基础URL,用于设置自定义API端点
25
+ """
26
+ super().__init__(api_key, temperature, system_prompt, language)
27
+ self.model_name = model_name or self.get_model_identifier()
28
+ self.max_tokens = 8192 # 默认最大输出token数
29
+ self.api_base_url = api_base_url
30
+
31
+ # 配置Google API
32
+ if api_base_url:
33
+ # 配置中转API - 使用环境变量方式
34
+ # 移除末尾的斜杠以避免重复路径问题
35
+ clean_base_url = api_base_url.rstrip('/')
36
+ # 设置环境变量来指定API端点
37
+ os.environ['GOOGLE_AI_API_ENDPOINT'] = clean_base_url
38
+ genai.configure(api_key=api_key)
39
+ else:
40
+ # 使用默认API端点
41
+ # 清除可能存在的自定义端点环境变量
42
+ if 'GOOGLE_AI_API_ENDPOINT' in os.environ:
43
+ del os.environ['GOOGLE_AI_API_ENDPOINT']
44
+ genai.configure(api_key=api_key)
45
+
46
+ def get_default_system_prompt(self) -> str:
47
+ return """You are an expert at analyzing questions and providing detailed solutions. When presented with an image of a question:
48
+ 1. First read and understand the question carefully
49
+ 2. Break down the key components of the question
50
+ 3. Provide a clear, step-by-step solution
51
+ 4. If relevant, explain any concepts or theories involved
52
+ 5. If there are multiple approaches, explain the most efficient one first"""
53
+
54
+ def get_model_identifier(self) -> str:
55
+ """返回默认的模型标识符"""
56
+ return "gemini-2.0-flash" # 使用有免费配额的模型作为默认值
57
+
58
+ def analyze_text(self, text: str, proxies: dict = None) -> Generator[dict, None, None]:
59
+ """流式生成文本响应"""
60
+ try:
61
+ yield {"status": "started"}
62
+
63
+ # 设置环境变量代理(如果提供)
64
+ original_proxies = None
65
+ if proxies:
66
+ original_proxies = {
67
+ 'http_proxy': os.environ.get('http_proxy'),
68
+ 'https_proxy': os.environ.get('https_proxy')
69
+ }
70
+ if 'http' in proxies:
71
+ os.environ['http_proxy'] = proxies['http']
72
+ if 'https' in proxies:
73
+ os.environ['https_proxy'] = proxies['https']
74
+
75
+ try:
76
+ # 初始化模型
77
+ model = genai.GenerativeModel(self.model_name)
78
+
79
+ # 获取最大输出Token设置
80
+ max_tokens = self.max_tokens if hasattr(self, 'max_tokens') else 8192
81
+
82
+ # 创建配置参数
83
+ generation_config = {
84
+ 'temperature': self.temperature,
85
+ 'max_output_tokens': max_tokens,
86
+ 'top_p': 0.95,
87
+ 'top_k': 64,
88
+ }
89
+
90
+ # 构建提示
91
+ prompt_parts = []
92
+
93
+ # 添加系统提示词
94
+ if self.system_prompt:
95
+ prompt_parts.append(self.system_prompt)
96
+
97
+ # 添加用户查询
98
+ if self.language and self.language != 'auto':
99
+ prompt_parts.append(f"请使用{self.language}回答以下问题: {text}")
100
+ else:
101
+ prompt_parts.append(text)
102
+
103
+ # 初始化响应缓冲区
104
+ response_buffer = ""
105
+
106
+ # 流式生成响应
107
+ response = model.generate_content(
108
+ prompt_parts,
109
+ generation_config=generation_config,
110
+ stream=True
111
+ )
112
+
113
+ for chunk in response:
114
+ if not chunk.text:
115
+ continue
116
+
117
+ # 累积响应文本
118
+ response_buffer += chunk.text
119
+
120
+ # 发送响应进度
121
+ if len(chunk.text) >= 10 or chunk.text.endswith(('.', '!', '?', '。', '!', '?', '\n')):
122
+ yield {
123
+ "status": "streaming",
124
+ "content": response_buffer
125
+ }
126
+
127
+ # 确保发送完整的最终内容
128
+ yield {
129
+ "status": "completed",
130
+ "content": response_buffer
131
+ }
132
+
133
+ finally:
134
+ # 恢复原始代理设置
135
+ if original_proxies:
136
+ for key, value in original_proxies.items():
137
+ if value is None:
138
+ if key in os.environ:
139
+ del os.environ[key]
140
+ else:
141
+ os.environ[key] = value
142
+
143
+ except Exception as e:
144
+ yield {
145
+ "status": "error",
146
+ "error": f"Gemini API错误: {str(e)}"
147
+ }
148
+
149
+ def analyze_image(self, image_data: str, proxies: dict = None) -> Generator[dict, None, None]:
150
+ """分析图像并流式生成响应"""
151
+ try:
152
+ yield {"status": "started"}
153
+
154
+ # 设置环境变量代理(如果提供)
155
+ original_proxies = None
156
+ if proxies:
157
+ original_proxies = {
158
+ 'http_proxy': os.environ.get('http_proxy'),
159
+ 'https_proxy': os.environ.get('https_proxy')
160
+ }
161
+ if 'http' in proxies:
162
+ os.environ['http_proxy'] = proxies['http']
163
+ if 'https' in proxies:
164
+ os.environ['https_proxy'] = proxies['https']
165
+
166
+ try:
167
+ # 初始化模型
168
+ model = genai.GenerativeModel(self.model_name)
169
+
170
+ # 获取最大输出Token设置
171
+ max_tokens = self.max_tokens if hasattr(self, 'max_tokens') else 8192
172
+
173
+ # 创建配置参数
174
+ generation_config = {
175
+ 'temperature': self.temperature,
176
+ 'max_output_tokens': max_tokens,
177
+ 'top_p': 0.95,
178
+ 'top_k': 64,
179
+ }
180
+
181
+ # 构建提示词
182
+ prompt_parts = []
183
+
184
+ # 添加系统提示词
185
+ if self.system_prompt:
186
+ prompt_parts.append(self.system_prompt)
187
+
188
+ # 添加默认图像分析指令
189
+ if self.language and self.language != 'auto':
190
+ prompt_parts.append(f"请使用{self.language}分析这张图片并提供详细解答。")
191
+ else:
192
+ prompt_parts.append("请分析这张图片并提供详细解答。")
193
+
194
+ # 处理图像数据
195
+ if image_data.startswith('data:image'):
196
+ # 如果是data URI,提取base64部分
197
+ image_data = image_data.split(',', 1)[1]
198
+
199
+ # 使用genai的特定方法处理图像
200
+ image_part = {
201
+ "mime_type": "image/jpeg",
202
+ "data": base64.b64decode(image_data)
203
+ }
204
+ prompt_parts.append(image_part)
205
+
206
+ # 初始化响应缓冲区
207
+ response_buffer = ""
208
+
209
+ # 流式生成响应
210
+ response = model.generate_content(
211
+ prompt_parts,
212
+ generation_config=generation_config,
213
+ stream=True
214
+ )
215
+
216
+ for chunk in response:
217
+ if not chunk.text:
218
+ continue
219
+
220
+ # 累积响应文本
221
+ response_buffer += chunk.text
222
+
223
+ # 发送响应进度
224
+ if len(chunk.text) >= 10 or chunk.text.endswith(('.', '!', '?', '。', '!', '?', '\n')):
225
+ yield {
226
+ "status": "streaming",
227
+ "content": response_buffer
228
+ }
229
+
230
+ # 确保发送完整的最终内容
231
+ yield {
232
+ "status": "completed",
233
+ "content": response_buffer
234
+ }
235
+
236
+ finally:
237
+ # 恢复原始代理设置
238
+ if original_proxies:
239
+ for key, value in original_proxies.items():
240
+ if value is None:
241
+ if key in os.environ:
242
+ del os.environ[key]
243
+ else:
244
+ os.environ[key] = value
245
+
246
+ except Exception as e:
247
+ yield {
248
+ "status": "error",
249
+ "error": f"Gemini图像分析错误: {str(e)}"
250
+ }
models/mathpix.py ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Generator, Dict, Any
2
+ import json
3
+ import requests
4
+ from .base import BaseModel
5
+
6
+ class MathpixModel(BaseModel):
7
+ """
8
+ Mathpix OCR model for processing images containing mathematical formulas,
9
+ text, and tables.
10
+ """
11
+
12
+ def __init__(self, api_key: str, temperature: float = 0.7, system_prompt: str = None):
13
+ """
14
+ Initialize the Mathpix model.
15
+
16
+ Args:
17
+ api_key: Mathpix API key in format "app_id:app_key"
18
+ temperature: Not used for Mathpix but kept for BaseModel compatibility
19
+ system_prompt: Not used for Mathpix but kept for BaseModel compatibility
20
+
21
+ Raises:
22
+ ValueError: If the API key format is invalid
23
+ """
24
+ # 只传递必需的参数,不传递language参数
25
+ super().__init__(api_key, temperature, system_prompt)
26
+ try:
27
+ self.app_id, self.app_key = api_key.split(':')
28
+ except ValueError:
29
+ raise ValueError("Mathpix API key must be in format 'app_id:app_key'")
30
+
31
+ self.api_url = "https://api.mathpix.com/v3/text"
32
+ self.headers = {
33
+ "app_id": self.app_id,
34
+ "app_key": self.app_key,
35
+ "Content-Type": "application/json"
36
+ }
37
+
38
+ # Content type presets
39
+ self.presets = {
40
+ "math": {
41
+ "formats": ["latex_normal", "latex_styled", "asciimath"],
42
+ "data_options": {
43
+ "include_asciimath": True,
44
+ "include_latex": True,
45
+ "include_mathml": True
46
+ },
47
+ "ocr_options": {
48
+ "detect_formulas": True,
49
+ "enable_math_ocr": True,
50
+ "enable_handwritten": True,
51
+ "rm_spaces": True
52
+ }
53
+ },
54
+ "text": {
55
+ "formats": ["text"],
56
+ "data_options": {
57
+ "include_latex": False,
58
+ "include_asciimath": False
59
+ },
60
+ "ocr_options": {
61
+ "enable_spell_check": True,
62
+ "enable_handwritten": True,
63
+ "rm_spaces": False
64
+ }
65
+ },
66
+ "table": {
67
+ "formats": ["text", "data"],
68
+ "data_options": {
69
+ "include_latex": True
70
+ },
71
+ "ocr_options": {
72
+ "detect_tables": True,
73
+ "enable_spell_check": True,
74
+ "rm_spaces": True
75
+ }
76
+ },
77
+ "full_text": {
78
+ "formats": ["text"],
79
+ "data_options": {
80
+ "include_latex": False,
81
+ "include_asciimath": False
82
+ },
83
+ "ocr_options": {
84
+ "enable_spell_check": True,
85
+ "enable_handwritten": True,
86
+ "rm_spaces": False,
87
+ "detect_paragraphs": True,
88
+ "enable_tables": False,
89
+ "enable_math_ocr": False
90
+ }
91
+ }
92
+ }
93
+
94
+ # Default to math preset
95
+ self.current_preset = "math"
96
+
97
+ def analyze_image(self, image_data: str, proxies: dict = None, content_type: str = None,
98
+ confidence_threshold: float = 0.8, max_retries: int = 3) -> Generator[dict, None, None]:
99
+ """
100
+ Analyze an image using Mathpix OCR API.
101
+
102
+ Args:
103
+ image_data: Base64 encoded image data
104
+ proxies: Optional proxy configuration
105
+ content_type: Type of content to analyze ('math', 'text', or 'table')
106
+ confidence_threshold: Minimum confidence score to accept (0.0 to 1.0)
107
+ max_retries: Maximum number of retry attempts for failed requests
108
+
109
+ Yields:
110
+ dict: Response chunks with status and content
111
+ """
112
+ if content_type and content_type in self.presets:
113
+ self.current_preset = content_type
114
+
115
+ preset = self.presets[self.current_preset]
116
+
117
+ try:
118
+ # Prepare request payload
119
+ payload = {
120
+ "src": f"data:image/jpeg;base64,{image_data}",
121
+ "formats": preset["formats"],
122
+ "data_options": preset["data_options"],
123
+ "ocr_options": preset["ocr_options"]
124
+ }
125
+
126
+ # Initialize retry counter
127
+ retry_count = 0
128
+
129
+ while retry_count < max_retries:
130
+ try:
131
+ # Send request to Mathpix API with timeout
132
+ response = requests.post(
133
+ self.api_url,
134
+ headers=self.headers,
135
+ json=payload,
136
+ proxies=proxies,
137
+ timeout=25 # 25 second timeout
138
+ )
139
+
140
+ # Handle specific API error codes
141
+ if response.status_code == 429: # Rate limit exceeded
142
+ if retry_count < max_retries - 1:
143
+ retry_count += 1
144
+ continue
145
+ else:
146
+ raise requests.exceptions.RequestException("Rate limit exceeded")
147
+
148
+ response.raise_for_status()
149
+ result = response.json()
150
+
151
+ # Check confidence threshold
152
+ if 'confidence' in result and result['confidence'] < confidence_threshold:
153
+ yield {
154
+ "status": "warning",
155
+ "content": f"Low confidence score: {result['confidence']:.2%}"
156
+ }
157
+
158
+ break # Success, exit retry loop
159
+
160
+ except (requests.exceptions.Timeout, requests.exceptions.ConnectionError):
161
+ if retry_count < max_retries - 1:
162
+ retry_count += 1
163
+ continue
164
+ raise
165
+
166
+ # Format the response
167
+ formatted_response = self._format_response(result)
168
+
169
+ # Yield initial status
170
+ yield {
171
+ "status": "started",
172
+ "content": ""
173
+ }
174
+
175
+ # Yield the formatted response
176
+ yield {
177
+ "status": "completed",
178
+ "content": formatted_response,
179
+ "model": self.get_model_identifier()
180
+ }
181
+
182
+ except requests.exceptions.RequestException as e:
183
+ yield {
184
+ "status": "error",
185
+ "error": f"Mathpix API error: {str(e)}"
186
+ }
187
+ except Exception as e:
188
+ yield {
189
+ "status": "error",
190
+ "error": f"Error processing image: {str(e)}"
191
+ }
192
+
193
+ def analyze_text(self, text: str, proxies: dict = None) -> Generator[dict, None, None]:
194
+ """
195
+ Not implemented for Mathpix model as it only processes images.
196
+ """
197
+ yield {
198
+ "status": "error",
199
+ "error": "Text analysis is not supported by Mathpix model"
200
+ }
201
+
202
+ def get_default_system_prompt(self) -> str:
203
+ """
204
+ Not used for Mathpix model.
205
+ """
206
+ return ""
207
+
208
+ def get_model_identifier(self) -> str:
209
+ """
210
+ Return the model identifier.
211
+ """
212
+ return "mathpix"
213
+
214
+ def _format_response(self, result: Dict[str, Any]) -> str:
215
+ """
216
+ Format the Mathpix API response into a readable string.
217
+
218
+ Args:
219
+ result: Raw API response from Mathpix
220
+
221
+ Returns:
222
+ str: Formatted response string with all available formats
223
+ """
224
+ formatted_parts = []
225
+
226
+ # Add confidence score if available
227
+ if 'confidence' in result:
228
+ formatted_parts.append(f"Confidence: {result['confidence']:.2%}\n")
229
+
230
+ # Add text content
231
+ if 'text' in result:
232
+ formatted_parts.append("Text Content:")
233
+ formatted_parts.append(result['text'])
234
+ formatted_parts.append("")
235
+
236
+ # Add LaTeX content
237
+ if 'latex_normal' in result:
238
+ formatted_parts.append("LaTeX (Normal):")
239
+ formatted_parts.append(result['latex_normal'])
240
+ formatted_parts.append("")
241
+
242
+ if 'latex_styled' in result:
243
+ formatted_parts.append("LaTeX (Styled):")
244
+ formatted_parts.append(result['latex_styled'])
245
+ formatted_parts.append("")
246
+
247
+ # Add data formats (ASCII math, MathML)
248
+ if 'data' in result and isinstance(result['data'], list):
249
+ for item in result['data']:
250
+ item_type = item.get('type', '')
251
+ if item_type and 'value' in item:
252
+ formatted_parts.append(f"{item_type.upper()}:")
253
+ formatted_parts.append(item['value'])
254
+ formatted_parts.append("")
255
+
256
+ # Add table data if present
257
+ if 'tables' in result and result['tables']:
258
+ formatted_parts.append("Tables Detected:")
259
+ for i, table in enumerate(result['tables'], 1):
260
+ formatted_parts.append(f"Table {i}:")
261
+ if 'cells' in table:
262
+ # Format table as a grid
263
+ cells = table['cells']
264
+ if cells:
265
+ max_col = max(cell.get('col', 0) for cell in cells) + 1
266
+ max_row = max(cell.get('row', 0) for cell in cells) + 1
267
+ grid = [['' for _ in range(max_col)] for _ in range(max_row)]
268
+
269
+ for cell in cells:
270
+ row = cell.get('row', 0)
271
+ col = cell.get('col', 0)
272
+ text = cell.get('text', '')
273
+ grid[row][col] = text
274
+
275
+ # Format grid as table
276
+ col_widths = [max(len(str(grid[r][c])) for r in range(max_row)) for c in range(max_col)]
277
+ for row in grid:
278
+ row_str = ' | '.join(f"{str(cell):<{width}}" for cell, width in zip(row, col_widths))
279
+ formatted_parts.append(f"| {row_str} |")
280
+ formatted_parts.append("")
281
+
282
+ # Add error message if present
283
+ if 'error' in result:
284
+ error_msg = result['error']
285
+ if isinstance(error_msg, dict):
286
+ error_msg = error_msg.get('message', str(error_msg))
287
+ formatted_parts.append(f"Error: {error_msg}")
288
+
289
+ return "\n".join(formatted_parts).strip()
290
+
291
+ def extract_full_text(self, image_data: str, proxies: dict = None, max_retries: int = 3) -> str:
292
+ """
293
+ 专门用于提取图像中的全部文本内容,忽略数学公式和表格等其他元素。
294
+
295
+ Args:
296
+ image_data: Base64编码的图像数据
297
+ proxies: 可选的代理配置
298
+ max_retries: 请求失败时的最大重试次数
299
+
300
+ Returns:
301
+ str: 图像中提取的完整文本内容
302
+ """
303
+ try:
304
+ # 准备请求负载,使用专为全文提取配置的参数
305
+ payload = {
306
+ "src": f"data:image/jpeg;base64,{image_data}",
307
+ "formats": ["text"],
308
+ "data_options": {
309
+ "include_latex": False,
310
+ "include_asciimath": False
311
+ },
312
+ "ocr_options": {
313
+ "enable_spell_check": True,
314
+ "enable_handwritten": True,
315
+ "rm_spaces": False,
316
+ "detect_paragraphs": True,
317
+ "enable_tables": False,
318
+ "enable_math_ocr": False
319
+ }
320
+ }
321
+
322
+ # 初始化重试计数器
323
+ retry_count = 0
324
+
325
+ while retry_count < max_retries:
326
+ try:
327
+ # 发送请求到Mathpix API
328
+ response = requests.post(
329
+ self.api_url,
330
+ headers=self.headers,
331
+ json=payload,
332
+ proxies=proxies,
333
+ timeout=30 # 30秒超时
334
+ )
335
+
336
+ # 处理特定API错误代码
337
+ if response.status_code == 429: # 超出速率限制
338
+ if retry_count < max_retries - 1:
339
+ retry_count += 1
340
+ continue
341
+ else:
342
+ raise requests.exceptions.RequestException("超出API速率限制")
343
+
344
+ response.raise_for_status()
345
+ result = response.json()
346
+
347
+ # 直接返回文本内容
348
+ if 'text' in result:
349
+ return result['text']
350
+ else:
351
+ return "未能提取到文本内容"
352
+
353
+ except (requests.exceptions.Timeout, requests.exceptions.ConnectionError):
354
+ if retry_count < max_retries - 1:
355
+ retry_count += 1
356
+ continue
357
+ raise
358
+
359
+ except requests.exceptions.RequestException as e:
360
+ return f"Mathpix API错误: {str(e)}"
361
+ except Exception as e:
362
+ return f"处理图像时出错: {str(e)}"
models/openai.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import Generator, Dict, Optional
3
+ from openai import OpenAI
4
+ from .base import BaseModel
5
+
6
+ class OpenAIModel(BaseModel):
7
+ def __init__(self, api_key, temperature=0.7, system_prompt=None, language=None, api_base_url=None, model_identifier=None):
8
+ super().__init__(api_key, temperature, system_prompt, language)
9
+ # 设置API基础URL,默认为OpenAI官方API
10
+ self.api_base_url = api_base_url
11
+ # 允许从外部配置显式指定模型标识符
12
+ self.model_identifier = model_identifier or "gpt-4o-2024-11-20"
13
+
14
+ def get_default_system_prompt(self) -> str:
15
+ return """You are an expert at analyzing questions and providing detailed solutions. When presented with an image of a question:
16
+ 1. First read and understand the question carefully
17
+ 2. Break down the key components of the question
18
+ 3. Provide a clear, step-by-step solution
19
+ 4. If relevant, explain any concepts or theories involved
20
+ 5. If there are multiple approaches, explain the most efficient one first"""
21
+
22
+ def get_model_identifier(self) -> str:
23
+ return self.model_identifier
24
+
25
+ def analyze_text(self, text: str, proxies: dict = None) -> Generator[dict, None, None]:
26
+ """Stream GPT-4o's response for text analysis"""
27
+ try:
28
+ # Initial status
29
+ yield {"status": "started", "content": ""}
30
+
31
+ # Save original environment state
32
+ original_env = {
33
+ 'http_proxy': os.environ.get('http_proxy'),
34
+ 'https_proxy': os.environ.get('https_proxy')
35
+ }
36
+
37
+ try:
38
+ # Set proxy environment variables if provided
39
+ if proxies:
40
+ if 'http' in proxies:
41
+ os.environ['http_proxy'] = proxies['http']
42
+ if 'https' in proxies:
43
+ os.environ['https_proxy'] = proxies['https']
44
+
45
+ # Initialize OpenAI client with base_url if provided
46
+ if self.api_base_url:
47
+ client = OpenAI(api_key=self.api_key, base_url=self.api_base_url)
48
+ else:
49
+ client = OpenAI(api_key=self.api_key)
50
+
51
+ # Prepare messages
52
+ messages = [
53
+ {
54
+ "role": "system",
55
+ "content": self.system_prompt
56
+ },
57
+ {
58
+ "role": "user",
59
+ "content": text
60
+ }
61
+ ]
62
+
63
+ response = client.chat.completions.create(
64
+ model=self.get_model_identifier(),
65
+ messages=messages,
66
+ temperature=self.temperature,
67
+ stream=True,
68
+ max_tokens=4000
69
+ )
70
+
71
+ # 使用累积缓冲区
72
+ response_buffer = ""
73
+
74
+ for chunk in response:
75
+ if hasattr(chunk.choices[0].delta, 'content'):
76
+ content = chunk.choices[0].delta.content
77
+ if content:
78
+ # 累积内容
79
+ response_buffer += content
80
+
81
+ # 只在累积一定数量的字符或遇到句子结束标记时才发送
82
+ if len(content) >= 10 or content.endswith(('.', '!', '?', '。', '!', '?', '\n')):
83
+ yield {
84
+ "status": "streaming",
85
+ "content": response_buffer
86
+ }
87
+
88
+ # 确保发送最终完整内容
89
+ if response_buffer:
90
+ yield {
91
+ "status": "streaming",
92
+ "content": response_buffer
93
+ }
94
+
95
+ # Send completion status
96
+ yield {
97
+ "status": "completed",
98
+ "content": response_buffer
99
+ }
100
+
101
+ finally:
102
+ # Restore original environment state
103
+ for key, value in original_env.items():
104
+ if value is None:
105
+ if key in os.environ:
106
+ del os.environ[key]
107
+ else:
108
+ os.environ[key] = value
109
+
110
+ except Exception as e:
111
+ yield {
112
+ "status": "error",
113
+ "error": str(e)
114
+ }
115
+
116
+ def analyze_image(self, image_data: str, proxies: dict = None) -> Generator[dict, None, None]:
117
+ """Stream GPT-4o's response for image analysis"""
118
+ try:
119
+ # Initial status
120
+ yield {"status": "started", "content": ""}
121
+
122
+ # Save original environment state
123
+ original_env = {
124
+ 'http_proxy': os.environ.get('http_proxy'),
125
+ 'https_proxy': os.environ.get('https_proxy')
126
+ }
127
+
128
+ try:
129
+ # Set proxy environment variables if provided
130
+ if proxies:
131
+ if 'http' in proxies:
132
+ os.environ['http_proxy'] = proxies['http']
133
+ if 'https' in proxies:
134
+ os.environ['https_proxy'] = proxies['https']
135
+
136
+ # Initialize OpenAI client with base_url if provided
137
+ if self.api_base_url:
138
+ client = OpenAI(api_key=self.api_key, base_url=self.api_base_url)
139
+ else:
140
+ client = OpenAI(api_key=self.api_key)
141
+
142
+ # 使用系统提供的系统提示词,不再自动添加语言指令
143
+ system_prompt = self.system_prompt
144
+
145
+ # Prepare messages with image
146
+ messages = [
147
+ {
148
+ "role": "system",
149
+ "content": system_prompt
150
+ },
151
+ {
152
+ "role": "user",
153
+ "content": [
154
+ {
155
+ "type": "image_url",
156
+ "image_url": {
157
+ "url": f"data:image/jpeg;base64,{image_data}"
158
+ }
159
+ },
160
+ {
161
+ "type": "text",
162
+ "text": "Please analyze this image and provide a detailed solution."
163
+ }
164
+ ]
165
+ }
166
+ ]
167
+
168
+ response = client.chat.completions.create(
169
+ model=self.get_model_identifier(),
170
+ messages=messages,
171
+ temperature=self.temperature,
172
+ stream=True,
173
+ max_tokens=4000
174
+ )
175
+
176
+ # 使用累积缓冲区
177
+ response_buffer = ""
178
+
179
+ for chunk in response:
180
+ if hasattr(chunk.choices[0].delta, 'content'):
181
+ content = chunk.choices[0].delta.content
182
+ if content:
183
+ # 累积内容
184
+ response_buffer += content
185
+
186
+ # 只在累积一定数量的字符或遇到句子结束标记时才发送
187
+ if len(content) >= 10 or content.endswith(('.', '!', '?', '。', '!', '?', '\n')):
188
+ yield {
189
+ "status": "streaming",
190
+ "content": response_buffer
191
+ }
192
+
193
+ # 确保发送最终完整内容
194
+ if response_buffer:
195
+ yield {
196
+ "status": "streaming",
197
+ "content": response_buffer
198
+ }
199
+
200
+ # Send completion status
201
+ yield {
202
+ "status": "completed",
203
+ "content": response_buffer
204
+ }
205
+
206
+ finally:
207
+ # Restore original environment state
208
+ for key, value in original_env.items():
209
+ if value is None:
210
+ if key in os.environ:
211
+ del os.environ[key]
212
+ else:
213
+ os.environ[key] = value
214
+
215
+ except Exception as e:
216
+ yield {
217
+ "status": "error",
218
+ "error": str(e)
219
+ }
requirements.txt CHANGED
@@ -1,6 +1,10 @@
1
- faicons
2
- shiny
3
- shinywidgets
4
- plotly
5
- pandas
6
- ridgeplot
 
 
 
 
 
1
+ flask==3.1.0
2
+ pyautogui==0.9.54
3
+ pyperclip==1.8.2
4
+ Pillow==11.1.0
5
+ flask-socketio==5.5.1
6
+ python-engineio==4.11.2
7
+ python-socketio==5.12.1
8
+ requests==2.32.3
9
+ openai==1.61.0
10
+ google-generativeai==0.7.0
shared.py DELETED
@@ -1,6 +0,0 @@
1
- from pathlib import Path
2
-
3
- import pandas as pd
4
-
5
- app_dir = Path(__file__).parent
6
- tips = pd.read_csv(app_dir / "tips.csv")
 
 
 
 
 
 
 
static/js/main.js ADDED
@@ -0,0 +1,2247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class SnapSolver {
2
+ constructor() {
3
+ console.log('Creating SnapSolver instance...');
4
+
5
+ // 初始化属性
6
+ this.socket = null;
7
+ this.socketId = null;
8
+ this.isProcessing = false;
9
+ this.cropper = null;
10
+ this.autoScrollInterval = null;
11
+ this.capturedImage = null; // 存储截图的base64数据
12
+ this.userThinkingExpanded = false; // 用户思考过程展开状态偏好
13
+ this.originalImage = null;
14
+ this.croppedImage = null;
15
+ this.extractedContent = '';
16
+ this.eventsSetup = false;
17
+
18
+ // Cache DOM elements
19
+ this.captureBtn = document.getElementById('captureBtn');
20
+ // 移除裁剪按钮引用
21
+ this.screenshotImg = document.getElementById('screenshotImg');
22
+ this.imagePreview = document.getElementById('imagePreview');
23
+ this.emptyState = document.getElementById('emptyState');
24
+ this.extractedText = document.getElementById('extractedText');
25
+ this.cropContainer = document.getElementById('cropContainer');
26
+ this.sendToClaudeBtn = document.getElementById('sendToClaude');
27
+ this.extractTextBtn = document.getElementById('extractText');
28
+ this.sendExtractedTextBtn = document.getElementById('sendExtractedText');
29
+ this.claudePanel = document.getElementById('claudePanel');
30
+ this.responseContent = document.getElementById('responseContent');
31
+ this.thinkingContent = document.getElementById('thinkingContent');
32
+ this.thinkingSection = document.getElementById('thinkingSection');
33
+ this.thinkingToggle = document.getElementById('thinkingToggle');
34
+ this.connectionStatus = document.getElementById('connectionStatus');
35
+ this.statusLight = document.querySelector('.status-light');
36
+ this.progressLine = document.querySelector('.progress-line');
37
+ this.statusText = document.querySelector('.status-text');
38
+ this.analysisIndicator = document.querySelector('.analysis-indicator');
39
+ this.clipboardTextarea = document.getElementById('clipboardText');
40
+ this.clipboardSendButton = document.getElementById('clipboardSend');
41
+ this.clipboardReadButton = document.getElementById('clipboardRead');
42
+ this.clipboardStatus = document.getElementById('clipboardStatus');
43
+
44
+ // Crop elements
45
+ this.cropCancel = document.getElementById('cropCancel');
46
+ this.cropConfirm = document.getElementById('cropConfirm');
47
+ this.cropSendToAI = document.getElementById('cropSendToAI');
48
+
49
+ // 初始化应用
50
+ this.initialize();
51
+ }
52
+
53
+ initializeElements() {
54
+ // 查找主要UI元素
55
+ this.connectionStatus = document.getElementById('connectionStatus');
56
+ this.captureBtn = document.getElementById('captureBtn');
57
+ this.emptyState = document.getElementById('emptyState');
58
+ this.imagePreview = document.getElementById('imagePreview');
59
+ this.screenshotImg = document.getElementById('screenshotImg');
60
+ this.sendToClaudeBtn = document.getElementById('sendToClaude');
61
+ this.extractTextBtn = document.getElementById('extractText');
62
+ this.extractedText = document.getElementById('extractedText');
63
+ this.sendExtractedTextBtn = document.getElementById('sendExtractedText');
64
+ this.claudePanel = document.getElementById('claudePanel');
65
+ this.closeClaudePanel = document.getElementById('closeClaudePanel');
66
+ this.thinkingSection = document.getElementById('thinkingSection');
67
+ this.thinkingToggle = document.getElementById('thinkingToggle');
68
+ this.thinkingContent = document.getElementById('thinkingContent');
69
+ this.responseContent = document.getElementById('responseContent');
70
+ this.cropContainer = document.getElementById('cropContainer');
71
+ this.cropCancel = document.getElementById('cropCancel');
72
+ this.cropConfirm = document.getElementById('cropConfirm');
73
+ this.cropSendToAI = document.getElementById('cropSendToAI');
74
+ this.stopGenerationBtn = document.getElementById('stopGenerationBtn');
75
+ this.clipboardTextarea = document.getElementById('clipboardText');
76
+ this.clipboardSendButton = document.getElementById('clipboardSend');
77
+ this.clipboardReadButton = document.getElementById('clipboardRead');
78
+ this.clipboardStatus = document.getElementById('clipboardStatus');
79
+
80
+ // 处理按钮事件
81
+ if (this.closeClaudePanel) {
82
+ this.closeClaudePanel.addEventListener('click', () => {
83
+ this.claudePanel.classList.add('hidden');
84
+ });
85
+ }
86
+
87
+ // 处理停止生成按钮点击事件
88
+ if (this.stopGenerationBtn) {
89
+ this.stopGenerationBtn.addEventListener('click', () => {
90
+ this.stopGeneration();
91
+ });
92
+ }
93
+ }
94
+
95
+ initializeState() {
96
+ this.socket = null;
97
+ this.cropper = null;
98
+ this.croppedImage = null;
99
+ this.extractedContent = '';
100
+
101
+ // 确保裁剪容器和其他面板初始为隐藏状态
102
+ if (this.cropContainer) {
103
+ this.cropContainer.classList.add('hidden');
104
+ }
105
+ if (this.claudePanel) {
106
+ this.claudePanel.classList.add('hidden');
107
+ }
108
+ if (this.thinkingSection) {
109
+ this.thinkingSection.classList.add('hidden');
110
+ }
111
+ }
112
+
113
+ setupAutoScroll() {
114
+ // Create MutationObserver to watch for content changes
115
+ const observer = new MutationObserver((mutations) => {
116
+ mutations.forEach((mutation) => {
117
+ if (mutation.type === 'characterData' || mutation.type === 'childList') {
118
+ this.responseContent.scrollTo({
119
+ top: this.responseContent.scrollHeight,
120
+ behavior: 'smooth'
121
+ });
122
+ }
123
+ });
124
+ });
125
+
126
+ // Start observing the response content
127
+ observer.observe(this.responseContent, {
128
+ childList: true,
129
+ characterData: true,
130
+ subtree: true
131
+ });
132
+ }
133
+
134
+ updateConnectionStatus(status, isConnected) {
135
+ if (this.connectionStatus) {
136
+ this.connectionStatus.textContent = status;
137
+
138
+ if (isConnected) {
139
+ this.connectionStatus.classList.remove('disconnected');
140
+ this.connectionStatus.classList.add('connected');
141
+ } else {
142
+ this.connectionStatus.classList.remove('connected');
143
+ this.connectionStatus.classList.add('disconnected');
144
+ }
145
+ }
146
+ }
147
+
148
+ updateStatusLight(status) {
149
+ // 获取进度指示器元素
150
+ const progressLine = document.querySelector('.progress-line');
151
+ const statusText = document.querySelector('.status-text');
152
+ const analysisIndicator = document.querySelector('.analysis-indicator');
153
+
154
+ if (!progressLine || !statusText || !analysisIndicator) {
155
+ console.error('状态指示器元素未找到:', {
156
+ progressLine: !!progressLine,
157
+ statusText: !!statusText,
158
+ analysisIndicator: !!analysisIndicator
159
+ });
160
+ return;
161
+ }
162
+
163
+ // 确保指示器可见
164
+ analysisIndicator.style.display = 'flex';
165
+
166
+ // 先移除所有可能的状态类
167
+ analysisIndicator.classList.remove('processing', 'completed', 'error');
168
+ progressLine.classList.remove('processing', 'completed', 'error');
169
+
170
+ switch (status) {
171
+ case 'started':
172
+ case 'thinking':
173
+ case 'reasoning':
174
+ case 'streaming':
175
+ // 添加处理中状态类
176
+ analysisIndicator.classList.add('processing');
177
+ progressLine.classList.add('processing');
178
+ statusText.textContent = '生成中';
179
+ break;
180
+
181
+ case 'completed':
182
+ // 添加完成状态类
183
+ analysisIndicator.classList.add('completed');
184
+ progressLine.classList.add('completed');
185
+ statusText.textContent = '完成';
186
+ break;
187
+
188
+ case 'error':
189
+ // 添加错误状态类
190
+ analysisIndicator.classList.add('error');
191
+ progressLine.classList.add('error');
192
+ statusText.textContent = '出错';
193
+ break;
194
+
195
+ case 'stopped':
196
+ // 添加错误状态类(用于停止状态)
197
+ analysisIndicator.classList.add('error');
198
+ progressLine.classList.add('error');
199
+ statusText.textContent = '已停止';
200
+ break;
201
+
202
+ default:
203
+ // 对于未知状态,显示准备中
204
+ statusText.textContent = '准备中';
205
+ break;
206
+ }
207
+ }
208
+
209
+ initializeConnection() {
210
+ try {
211
+ // 添加日志以便调试
212
+ console.log('尝试连接WebSocket服务器...');
213
+
214
+ // 确保在Safari上使用完整的URL
215
+ const socketUrl = window.location.protocol === 'https:'
216
+ ? `${window.location.origin}`
217
+ : window.location.origin;
218
+
219
+ console.log('WebSocket连接URL:', socketUrl);
220
+
221
+ this.socket = io(socketUrl, {
222
+ reconnection: true,
223
+ reconnectionAttempts: 5,
224
+ reconnectionDelay: 1000,
225
+ reconnectionDelayMax: 5000,
226
+ timeout: 20000,
227
+ autoConnect: true,
228
+ transports: ['websocket', 'polling'] // 明确指定传输方式,增加兼容性
229
+ });
230
+
231
+ this.socket.on('connect', () => {
232
+ console.log('Connected to server');
233
+ this.updateConnectionStatus('已连接', true);
234
+ this.captureBtn.disabled = false;
235
+ });
236
+
237
+ this.socket.on('disconnect', () => {
238
+ console.log('Disconnected from server');
239
+ this.updateConnectionStatus('已断开', false);
240
+ });
241
+
242
+ this.socket.on('connect_error', (error) => {
243
+ console.error('Connection error:', error);
244
+ this.updateConnectionStatus('连接错误', false);
245
+ });
246
+
247
+ this.socket.on('reconnect', (attemptNumber) => {
248
+ console.log(`Reconnected after ${attemptNumber} attempts`);
249
+ this.updateConnectionStatus('已重连', true);
250
+ });
251
+
252
+ this.socket.on('reconnect_attempt', (attemptNumber) => {
253
+ console.log(`Reconnection attempt: ${attemptNumber}`);
254
+ });
255
+
256
+ this.socket.on('reconnect_error', (error) => {
257
+ console.error('Reconnection error:', error);
258
+ });
259
+
260
+ this.socket.on('reconnect_failed', () => {
261
+ console.error('Failed to reconnect');
262
+ window.uiManager.showToast('Connection to server failed, please refresh the page and try again', 'error');
263
+ });
264
+
265
+ this.setupSocketEventHandlers();
266
+
267
+ } catch (error) {
268
+ console.error('Connection error:', error);
269
+ this.updateConnectionStatus('重连失败', false);
270
+ // 移除setTimeout,让用户手动刷新页面重连
271
+ window.uiManager.showToast('连接服务器失败,请刷新页面重试', 'error');
272
+ }
273
+ }
274
+
275
+ setupSocketEventHandlers() {
276
+ // 如果已经设置过事件处理器,先移除它们
277
+ if (this.hasOwnProperty('eventsSetup') && this.eventsSetup) {
278
+ this.socket.off('screenshot_response');
279
+ this.socket.off('screenshot_complete');
280
+ this.socket.off('request_acknowledged');
281
+ this.socket.off('text_extracted');
282
+ this.socket.off('ai_response');
283
+ }
284
+
285
+ // 标记事件处理器已设置
286
+ this.eventsSetup = true;
287
+
288
+ // 添加响应计数器
289
+ if (!window.responseCounter) {
290
+ window.responseCounter = 0;
291
+ }
292
+
293
+ // 旧版截图响应处理器 (保留兼容性)
294
+ this.socket.on('screenshot_response', (data) => {
295
+ // 增加计数并记录
296
+ window.responseCounter++;
297
+ console.log(`DEBUG: 接收到screenshot_response响应,计数 = ${window.responseCounter}`);
298
+
299
+ if (data.success) {
300
+ this.screenshotImg.src = `data:image/png;base64,${data.image}`;
301
+ this.originalImage = `data:image/png;base64,${data.image}`;
302
+ this.imagePreview.classList.remove('hidden');
303
+ this.emptyState.classList.add('hidden');
304
+
305
+ // 根据模型类型显示适当的按钮
306
+ this.updateImageActionButtons();
307
+
308
+ // 恢复按钮状态
309
+ this.captureBtn.disabled = false;
310
+ this.captureBtn.innerHTML = '<i class="fas fa-camera"></i>';
311
+
312
+ // 初始化裁剪器
313
+ this.initializeCropper();
314
+
315
+ window.uiManager.showToast('截图成功', 'success');
316
+ } else {
317
+ this.captureBtn.disabled = false;
318
+ this.captureBtn.innerHTML = '<i class="fas fa-camera"></i>';
319
+ console.error('截图失败:', data.error);
320
+ window.uiManager.showToast('截图失败: ' + data.error, 'error');
321
+ }
322
+ });
323
+
324
+ // 新版截图响应处理器
325
+ this.socket.on('screenshot_complete', (data) => {
326
+ // 增加计数并记录
327
+ window.responseCounter++;
328
+ console.log(`DEBUG: 接收到screenshot_complete响应,计数 = ${window.responseCounter}`);
329
+
330
+ this.captureBtn.disabled = false;
331
+ this.captureBtn.innerHTML = '<i class="fas fa-camera"></i>';
332
+
333
+ if (data.success) {
334
+ // 显示截图预览
335
+ this.screenshotImg.src = 'data:image/png;base64,' + data.image;
336
+ this.originalImage = 'data:image/png;base64,' + data.image;
337
+ this.imagePreview.classList.remove('hidden');
338
+ this.emptyState.classList.add('hidden');
339
+
340
+ // 根据模型类型显示适当的按钮
341
+ this.updateImageActionButtons();
342
+
343
+ // 初始化裁剪工具
344
+ this.initializeCropper();
345
+
346
+ // 显示成功消息
347
+ window.uiManager.showToast('截图成功', 'success');
348
+ } else {
349
+ // 显示错误消息
350
+ window.uiManager.showToast('截图失败: ' + (data.error || '未知错误'), 'error');
351
+ }
352
+ });
353
+
354
+ // 确认请求处理
355
+ this.socket.on('request_acknowledged', (data) => {
356
+ console.log('服务器确认收到请求:', data);
357
+ });
358
+
359
+ // Text extraction response
360
+ this.socket.on('text_extracted', (data) => {
361
+ // 重新启用按钮
362
+ this.extractTextBtn.disabled = false;
363
+ this.extractTextBtn.innerHTML = '<i class="fas fa-font"></i><span>提取文本</span>';
364
+
365
+ if (this.extractedText) {
366
+ this.extractedText.disabled = false;
367
+ }
368
+
369
+ // 检查是否有内容数据
370
+ if (data.content) {
371
+ this.extractedText.value = data.content;
372
+ this.extractedContent = data.content;
373
+ this.extractedText.classList.remove('hidden');
374
+ this.sendExtractedTextBtn.classList.remove('hidden');
375
+ this.sendExtractedTextBtn.disabled = false;
376
+
377
+ window.uiManager.showToast('文本提取成功', 'success');
378
+ } else if (data.error) {
379
+ console.error('文本提取失败:', data.error);
380
+ window.uiManager.showToast('文本提取失败: ' + data.error, 'error');
381
+
382
+ // 启用发送按钮以便用户可以手动输入文本
383
+ this.sendExtractedTextBtn.disabled = false;
384
+ } else {
385
+ // 未知响应格式
386
+ console.error('未知的文本提取响应格式:', data);
387
+ window.uiManager.showToast('文本提取返回未知格式', 'error');
388
+ this.sendExtractedTextBtn.disabled = false;
389
+ }
390
+ });
391
+
392
+ this.socket.on('ai_response', (data) => {
393
+ console.log('Received ai_response:', data);
394
+ this.updateStatusLight(data.status);
395
+
396
+ // 确保Claude面板可见
397
+ if (this.claudePanel && this.claudePanel.classList.contains('hidden')) {
398
+ this.claudePanel.classList.remove('hidden');
399
+ }
400
+
401
+ switch (data.status) {
402
+ case 'started':
403
+ console.log('Analysis started');
404
+ // 清空显示内容
405
+ if (this.responseContent) this.responseContent.innerHTML = '';
406
+ if (this.thinkingContent) this.thinkingContent.innerHTML = '';
407
+ if (this.thinkingSection) this.thinkingSection.classList.add('hidden');
408
+
409
+ // 禁用按钮防止重复点击
410
+ if (this.sendToClaudeBtn) this.sendToClaudeBtn.disabled = true;
411
+ if (this.sendExtractedTextBtn) this.sendExtractedTextBtn.disabled = true;
412
+
413
+ // 显示进行中状态
414
+ if (this.responseContent) {
415
+ this.responseContent.innerHTML = '<div class="loading-message">分析进行中,请稍候...</div>';
416
+ this.responseContent.style.display = 'block';
417
+ }
418
+
419
+ // 显示停止生成按钮
420
+ this.showStopGenerationButton();
421
+ break;
422
+
423
+ case 'thinking':
424
+ // 处理思考内容
425
+ if (data.content && this.thinkingContent && this.thinkingSection) {
426
+ console.log('Received thinking content');
427
+ this.thinkingSection.classList.remove('hidden');
428
+
429
+ // 显示动态省略号
430
+ this.showThinkingAnimation(true);
431
+
432
+ // 直接设置完整内容
433
+ this.setElementContent(this.thinkingContent, data.content);
434
+
435
+ // 添加打字动画效果
436
+ this.thinkingContent.classList.add('thinking-typing');
437
+
438
+ // 根据用户偏好设置展开/折叠状态
439
+ this.thinkingContent.classList.remove('expanded');
440
+ this.thinkingContent.classList.remove('collapsed');
441
+
442
+ if (this.userThinkingExpanded) {
443
+ this.thinkingContent.classList.add('expanded');
444
+ } else {
445
+ this.thinkingContent.classList.add('collapsed');
446
+ }
447
+
448
+ // 更新切换按钮图标
449
+ const toggleIcon = document.querySelector('#thinkingToggle .toggle-btn i');
450
+ if (toggleIcon) {
451
+ toggleIcon.className = this.userThinkingExpanded ?
452
+ 'fas fa-chevron-up' : 'fas fa-chevron-down';
453
+ }
454
+ }
455
+
456
+ // 确保停止按钮可见
457
+ this.showStopGenerationButton();
458
+ break;
459
+
460
+ case 'reasoning':
461
+ // 处理推理内容 (QVQ-Max模型使用)
462
+ if (data.content && this.thinkingContent && this.thinkingSection) {
463
+ console.log('Received reasoning content');
464
+ this.thinkingSection.classList.remove('hidden');
465
+
466
+ // 显示动态省略号
467
+ this.showThinkingAnimation(true);
468
+
469
+ // 直接设置完整内容
470
+ this.setElementContent(this.thinkingContent, data.content);
471
+
472
+ // 添加打字动画效果
473
+ this.thinkingContent.classList.add('thinking-typing');
474
+
475
+ // 根据用户偏好设置展开/折叠状态
476
+ this.thinkingContent.classList.remove('expanded');
477
+ this.thinkingContent.classList.remove('collapsed');
478
+
479
+ if (this.userThinkingExpanded) {
480
+ this.thinkingContent.classList.add('expanded');
481
+ } else {
482
+ this.thinkingContent.classList.add('collapsed');
483
+ }
484
+
485
+ // 更新切换按钮图标
486
+ const toggleIcon = document.querySelector('#thinkingToggle .toggle-btn i');
487
+ if (toggleIcon) {
488
+ toggleIcon.className = this.userThinkingExpanded ?
489
+ 'fas fa-chevron-up' : 'fas fa-chevron-down';
490
+ }
491
+ }
492
+
493
+ // 确保停止按钮可见
494
+ this.showStopGenerationButton();
495
+ break;
496
+
497
+ case 'thinking_complete':
498
+ // 完整的思考内容
499
+ if (data.content && this.thinkingContent && this.thinkingSection) {
500
+ console.log('思考过程完成');
501
+ this.thinkingSection.classList.remove('hidden');
502
+
503
+ // 停止动态省略号
504
+ this.showThinkingAnimation(false);
505
+
506
+ // 设置完整内容
507
+ this.setElementContent(this.thinkingContent, data.content);
508
+
509
+ // 移除打字动画
510
+ this.thinkingContent.classList.remove('thinking-typing');
511
+
512
+ // 根据用户偏好设置展开/折叠状态
513
+ this.thinkingContent.classList.remove('expanded');
514
+ this.thinkingContent.classList.remove('collapsed');
515
+
516
+ if (this.userThinkingExpanded) {
517
+ this.thinkingContent.classList.add('expanded');
518
+ } else {
519
+ this.thinkingContent.classList.add('collapsed');
520
+ }
521
+
522
+ // 确保图标正确显示
523
+ const toggleIcon = this.thinkingToggle.querySelector('.toggle-btn i');
524
+ if (toggleIcon) {
525
+ toggleIcon.className = this.userThinkingExpanded ?
526
+ 'fas fa-chevron-up' : 'fas fa-chevron-down';
527
+ }
528
+ }
529
+ break;
530
+
531
+ case 'reasoning_complete':
532
+ // 完整的推理内容 (QVQ-Max模型使用)
533
+ if (data.content && this.thinkingContent && this.thinkingSection) {
534
+ console.log('Reasoning complete');
535
+ this.thinkingSection.classList.remove('hidden');
536
+
537
+ // 停止动态省略号
538
+ this.showThinkingAnimation(false);
539
+
540
+ // 设置完整内容
541
+ this.setElementContent(this.thinkingContent, data.content);
542
+
543
+ // 移除打字动画
544
+ this.thinkingContent.classList.remove('thinking-typing');
545
+
546
+ // 根据用户偏好设置展开/折叠状态
547
+ this.thinkingContent.classList.remove('expanded');
548
+ this.thinkingContent.classList.remove('collapsed');
549
+
550
+ if (this.userThinkingExpanded) {
551
+ this.thinkingContent.classList.add('expanded');
552
+ } else {
553
+ this.thinkingContent.classList.add('collapsed');
554
+ }
555
+
556
+ // 确保图标正确显示
557
+ const toggleIcon = this.thinkingToggle.querySelector('.toggle-btn i');
558
+ if (toggleIcon) {
559
+ toggleIcon.className = this.userThinkingExpanded ?
560
+ 'fas fa-chevron-up' : 'fas fa-chevron-down';
561
+ }
562
+ }
563
+ break;
564
+
565
+ case 'streaming':
566
+ if (data.content && this.responseContent) {
567
+ console.log('Received content chunk');
568
+
569
+ // 使用更安全的方式设置内容,避免HTML解析问题
570
+ this.setElementContent(this.responseContent, data.content);
571
+ this.responseContent.style.display = 'block';
572
+
573
+ // 停止省略号动画
574
+ this.showThinkingAnimation(false);
575
+
576
+ // 移除思考部分的打字动画
577
+ if (this.thinkingContent) {
578
+ this.thinkingContent.classList.remove('thinking-typing');
579
+ }
580
+
581
+ // 平滑滚动到最新内容
582
+ this.scrollToBottom();
583
+ }
584
+
585
+ // 确保停止按钮可见
586
+ this.showStopGenerationButton();
587
+ break;
588
+
589
+ case 'completed':
590
+ console.log('Analysis completed');
591
+
592
+ // 重新启用按钮
593
+ if (this.sendToClaudeBtn) this.sendToClaudeBtn.disabled = false;
594
+ if (this.sendExtractedTextBtn) this.sendExtractedTextBtn.disabled = false;
595
+
596
+ // 恢复界面
597
+ this.updateStatusLight('completed');
598
+
599
+ // 只有在有思考内容时才显示思考组件
600
+ if (this.thinkingSection && this.thinkingContent) {
601
+ // 检查思考内容是否为空
602
+ const hasThinkingContent = this.thinkingContent.textContent && this.thinkingContent.textContent.trim() !== '';
603
+
604
+ if (hasThinkingContent) {
605
+ // 有思考内容,显示思考组件
606
+ this.thinkingSection.classList.remove('hidden');
607
+
608
+ // 根据用户偏好设置展开/折叠状态
609
+ this.thinkingContent.classList.remove('expanded');
610
+ this.thinkingContent.classList.remove('collapsed');
611
+
612
+ if (this.userThinkingExpanded) {
613
+ this.thinkingContent.classList.add('expanded');
614
+ } else {
615
+ this.thinkingContent.classList.add('collapsed');
616
+ }
617
+
618
+ const toggleBtn = document.querySelector('#thinkingToggle .toggle-btn i');
619
+ if (toggleBtn) {
620
+ toggleBtn.className = this.userThinkingExpanded ?
621
+ 'fas fa-chevron-up' : 'fas fa-chevron-down';
622
+ }
623
+
624
+ // 简化提示信息
625
+ window.uiManager.showToast('分析完成', 'success');
626
+ } else {
627
+ // 没有思考内容,隐藏思考组件
628
+ this.thinkingSection.classList.add('hidden');
629
+ window.uiManager.showToast('分析完成', 'success');
630
+ }
631
+ }
632
+
633
+ // 确保响应内容完整显示
634
+ if (data.content && data.content.trim() !== '' && this.responseContent) {
635
+ this.setElementContent(this.responseContent, data.content);
636
+ }
637
+
638
+ // 确保结果内容可见
639
+ if (this.responseContent) {
640
+ this.responseContent.style.display = 'block';
641
+
642
+ // 滚动到结果内容底部
643
+ this.scrollToBottom();
644
+ }
645
+
646
+ // 隐藏停止生成按钮
647
+ this.hideStopGenerationButton();
648
+ break;
649
+
650
+ case 'stopped':
651
+ // 处理停止生成的响应
652
+ console.log('Generation stopped');
653
+
654
+ // 重新启用按钮
655
+ if (this.sendToClaudeBtn) this.sendToClaudeBtn.disabled = false;
656
+ if (this.sendExtractedTextBtn) this.sendExtractedTextBtn.disabled = false;
657
+
658
+ // 恢复界面
659
+ this.updateStatusLight('stopped');
660
+
661
+ // 隐藏停止生成按钮
662
+ this.hideStopGenerationButton();
663
+
664
+ // 显示提示信息
665
+ window.uiManager.showToast('已停止生成', 'info');
666
+ break;
667
+
668
+ case 'error':
669
+ console.error('Analysis error:', data.error);
670
+
671
+ // 安全处理错误消息,确保它是字符串
672
+ let errorMessage = 'Unknown error occurred';
673
+ if (data.error) {
674
+ if (typeof data.error === 'string') {
675
+ errorMessage = data.error;
676
+ } else if (typeof data.error === 'object') {
677
+ // 如果是对象,尝试获取消息字段或转换为JSON
678
+ errorMessage = data.error.message || data.error.error || JSON.stringify(data.error);
679
+ } else {
680
+ errorMessage = String(data.error);
681
+ }
682
+ }
683
+
684
+ // 显示错误信息
685
+ if (this.responseContent) {
686
+ // 不要尝试获取现有内容,直接显示错误信息
687
+ this.setElementContent(this.responseContent, 'Error: ' + errorMessage);
688
+ }
689
+
690
+ // 重新启用按钮
691
+ if (this.sendToClaudeBtn) this.sendToClaudeBtn.disabled = false;
692
+ if (this.sendExtractedTextBtn) this.sendExtractedTextBtn.disabled = false;
693
+
694
+ window.uiManager.showToast('Analysis failed: ' + errorMessage, 'error');
695
+
696
+ // 隐藏停止生成按钮
697
+ this.hideStopGenerationButton();
698
+ break;
699
+
700
+ default:
701
+ console.warn('Unknown response status:', data.status);
702
+
703
+ // 对于未知状态,尝试显示内容(如果有)
704
+ if (data.content && this.responseContent) {
705
+ this.setElementContent(this.responseContent, data.content);
706
+ this.responseContent.style.display = 'block';
707
+ }
708
+
709
+ // 确保按钮可用
710
+ if (this.sendToClaudeBtn) this.sendToClaudeBtn.disabled = false;
711
+ if (this.sendExtractedTextBtn) this.sendExtractedTextBtn.disabled = false;
712
+
713
+ window.uiManager.showToast('Unknown error occurred', 'error');
714
+
715
+ // 隐藏停止生成按钮
716
+ this.hideStopGenerationButton();
717
+ break;
718
+ }
719
+ });
720
+
721
+ // 接收到thinking数据时
722
+ this.socket.on('thinking', (data) => {
723
+ console.log('收到思考过程数据');
724
+
725
+ // 显示思考区域
726
+ this.thinkingSection.classList.remove('hidden');
727
+
728
+ // 显示动态省略号
729
+ this.showThinkingAnimation(true);
730
+
731
+ // 使用setElementContent方法处理Markdown
732
+ this.setElementContent(this.thinkingContent, data.thinking);
733
+
734
+ // 根据用户偏好设置展开/折叠状态
735
+ this.thinkingContent.classList.remove('expanded');
736
+ this.thinkingContent.classList.remove('collapsed');
737
+
738
+ if (this.userThinkingExpanded) {
739
+ this.thinkingContent.classList.add('expanded');
740
+ } else {
741
+ this.thinkingContent.classList.add('collapsed');
742
+ }
743
+
744
+ const toggleIcon = this.thinkingToggle.querySelector('.toggle-btn i');
745
+ if (toggleIcon) {
746
+ toggleIcon.className = this.userThinkingExpanded ?
747
+ 'fas fa-chevron-up' : 'fas fa-chevron-down';
748
+ }
749
+ });
750
+
751
+ // 思考过程完成 - Socket事件处理
752
+ this.socket.on('thinking_complete', (data) => {
753
+ console.log('Socket接收到思考过程完成');
754
+ this.thinkingSection.classList.remove('hidden');
755
+
756
+ // 停止动态省略号动画
757
+ this.showThinkingAnimation(false);
758
+
759
+ // 使用setElementContent方法处理Markdown
760
+ this.setElementContent(this.thinkingContent, data.thinking);
761
+
762
+ // ��据用户偏好设置展开/折叠状态
763
+ this.thinkingContent.classList.remove('expanded');
764
+ this.thinkingContent.classList.remove('collapsed');
765
+
766
+ if (this.userThinkingExpanded) {
767
+ this.thinkingContent.classList.add('expanded');
768
+ } else {
769
+ this.thinkingContent.classList.add('collapsed');
770
+ }
771
+
772
+ // 确保图标正确显示
773
+ const toggleIcon = this.thinkingToggle.querySelector('.toggle-btn i');
774
+ if (toggleIcon) {
775
+ toggleIcon.className = this.userThinkingExpanded ?
776
+ 'fas fa-chevron-up' : 'fas fa-chevron-down';
777
+ }
778
+ });
779
+
780
+ // 分析完成
781
+ this.socket.on('analysis_complete', (data) => {
782
+ console.log('分析完成,接收到结果');
783
+ this.updateStatusLight('completed');
784
+ this.enableInterface();
785
+
786
+ // 显示分析结果
787
+ if (this.responseContent) {
788
+ // 使用setElementContent方法处理Markdown
789
+ this.setElementContent(this.responseContent, data.response);
790
+ this.responseContent.style.display = 'block';
791
+
792
+ // 直接滚动到结果区域,不使用setTimeout
793
+ this.responseContent.scrollIntoView({ behavior: 'smooth' });
794
+ }
795
+
796
+ // 确保思考部分完全显示(如果有的话)
797
+ if (data.thinking && this.thinkingSection && this.thinkingContent) {
798
+ this.thinkingSection.classList.remove('hidden');
799
+ // 使用setElementContent方法处理Markdown
800
+ this.setElementContent(this.thinkingContent, data.thinking);
801
+
802
+ // 根据用户偏好设置展开/折叠状态
803
+ this.thinkingContent.classList.remove('expanded');
804
+ this.thinkingContent.classList.remove('collapsed');
805
+
806
+ if (this.userThinkingExpanded) {
807
+ this.thinkingContent.classList.add('expanded');
808
+ } else {
809
+ this.thinkingContent.classList.add('collapsed');
810
+ }
811
+
812
+ const toggleIcon = this.thinkingToggle.querySelector('.toggle-btn i');
813
+ if (toggleIcon) {
814
+ toggleIcon.className = this.userThinkingExpanded ?
815
+ 'fas fa-chevron-up' : 'fas fa-chevron-down';
816
+ }
817
+
818
+ // 弹出提示
819
+ window.uiManager.showToast('分析完成', 'success');
820
+ }
821
+ });
822
+ }
823
+
824
+ // 新方法:安全设置DOM内容的方法(替代updateElementContent)
825
+ setElementContent(element, content) {
826
+ if (!element) return;
827
+
828
+ // 首先确保content是字符串
829
+ if (typeof content !== 'string') {
830
+ if (content === null || content === undefined) {
831
+ content = '';
832
+ } else if (typeof content === 'object') {
833
+ // 对于对象,尝试获取有意义的字符串表示
834
+ if (content.error || content.message) {
835
+ content = content.error || content.message;
836
+ } else if (content.toString && typeof content.toString === 'function' && content.toString() !== '[object Object]') {
837
+ content = content.toString();
838
+ } else {
839
+ // 作为最后手段,使用JSON.stringify
840
+ try {
841
+ content = JSON.stringify(content, null, 2);
842
+ } catch (e) {
843
+ content = '[Complex Object]';
844
+ }
845
+ }
846
+ } else {
847
+ content = String(content);
848
+ }
849
+ }
850
+
851
+ try {
852
+ // 检查marked是否已配置
853
+ if (typeof marked === 'undefined') {
854
+ console.warn('Marked库未加载,回退到纯文本显示');
855
+ // 即使回退到纯文本,也要保留换行和基本格式
856
+ element.innerHTML = content.replace(/\n/g, '<br>');
857
+ return;
858
+ }
859
+
860
+ // 使用marked库解析Markdown内容
861
+ const renderedHtml = marked.parse(content);
862
+
863
+ // 设置解析后的HTML内容
864
+ element.innerHTML = renderedHtml;
865
+
866
+ // 为未高亮的代码块应用语法高亮
867
+ if (window.hljs) {
868
+ element.querySelectorAll('pre code:not(.hljs)').forEach((block) => {
869
+ hljs.highlightElement(block);
870
+ });
871
+ }
872
+
873
+ // 为所有代码块添加复制按钮
874
+ this.addCopyButtonsToCodeBlocks(element);
875
+ } catch (error) {
876
+ console.error('Markdown解析错误:', error);
877
+ // 发生错误时也保留换行格式
878
+ element.innerHTML = content.replace(/\n/g, '<br>');
879
+ }
880
+
881
+ // 自动滚动到底部
882
+ element.scrollTop = element.scrollHeight;
883
+ }
884
+
885
+ // 为代码块添加复制按钮
886
+ addCopyButtonsToCodeBlocks(element) {
887
+ const codeBlocks = element.querySelectorAll('pre code');
888
+
889
+ codeBlocks.forEach((codeBlock) => {
890
+ // 检查是否已经有复制按钮
891
+ if (codeBlock.parentElement.querySelector('.code-copy-btn')) {
892
+ return;
893
+ }
894
+
895
+ // 创建包装器
896
+ const wrapper = document.createElement('div');
897
+ wrapper.className = 'code-block-wrapper';
898
+
899
+ // 将pre元素包装起来
900
+ const preElement = codeBlock.parentElement;
901
+ preElement.parentNode.insertBefore(wrapper, preElement);
902
+ wrapper.appendChild(preElement);
903
+
904
+ // 创建复制按钮
905
+ const copyBtn = document.createElement('button');
906
+ copyBtn.className = 'code-copy-btn';
907
+ copyBtn.innerHTML = '<i class="fas fa-copy"></i> 复制';
908
+ copyBtn.title = '复制代码';
909
+
910
+ // 添加点击事件
911
+ copyBtn.addEventListener('click', async () => {
912
+ const codeText = codeBlock.textContent;
913
+
914
+ try {
915
+ // 尝试使用现代 Clipboard API
916
+ if (navigator.clipboard && window.isSecureContext) {
917
+ await navigator.clipboard.writeText(codeText);
918
+ this.showCopySuccess(copyBtn);
919
+ return;
920
+ }
921
+
922
+ // 降级方案:使用传统的 document.execCommand
923
+ const textArea = document.createElement('textarea');
924
+ textArea.value = codeText;
925
+ textArea.style.position = 'fixed';
926
+ textArea.style.left = '-999999px';
927
+ textArea.style.top = '-999999px';
928
+ document.body.appendChild(textArea);
929
+ textArea.focus();
930
+ textArea.select();
931
+
932
+ const successful = document.execCommand('copy');
933
+ document.body.removeChild(textArea);
934
+
935
+ if (successful) {
936
+ this.showCopySuccess(copyBtn);
937
+ } else {
938
+ throw new Error('execCommand failed');
939
+ }
940
+
941
+ } catch (error) {
942
+ console.error('复制失败:', error);
943
+ // 最后的降级方案:选中文本让用户手动复制
944
+ this.selectTextForManualCopy(codeBlock, copyBtn);
945
+ }
946
+ });
947
+
948
+ // 将按钮添加到包装器
949
+ wrapper.appendChild(copyBtn);
950
+ });
951
+ }
952
+
953
+ // 显示复制成功状态
954
+ showCopySuccess(copyBtn) {
955
+ copyBtn.innerHTML = '<i class="fas fa-check"></i> 已复制';
956
+ copyBtn.classList.add('copied');
957
+ window.uiManager?.showToast('代码已复制到剪贴板', 'success');
958
+
959
+ // 2秒后恢复原状
960
+ setTimeout(() => {
961
+ copyBtn.innerHTML = '<i class="fas fa-copy"></i> 复制';
962
+ copyBtn.classList.remove('copied');
963
+ }, 2000);
964
+ }
965
+
966
+ // 选中文本供用户手动复制
967
+ selectTextForManualCopy(codeBlock, copyBtn) {
968
+ // 选中代码文本
969
+ const range = document.createRange();
970
+ range.selectNodeContents(codeBlock);
971
+ const selection = window.getSelection();
972
+ selection.removeAllRanges();
973
+ selection.addRange(range);
974
+
975
+ // 更新按钮状态
976
+ copyBtn.innerHTML = '<i class="fas fa-hand-pointer"></i> 已选中';
977
+ copyBtn.classList.add('copied');
978
+
979
+ // 显示提示
980
+ window.uiManager?.showToast('代码已选中,请按 Ctrl+C 复制', 'info');
981
+
982
+ // 3秒后恢复原状
983
+ setTimeout(() => {
984
+ copyBtn.innerHTML = '<i class="fas fa-copy"></i> 复制';
985
+ copyBtn.classList.remove('copied');
986
+ selection.removeAllRanges();
987
+ }, 3000);
988
+ }
989
+
990
+ initializeCropper() {
991
+ try {
992
+ // 如果当前没有截图,不要初始化裁剪器
993
+ if (!this.screenshotImg || !this.screenshotImg.src || this.screenshotImg.src === '') {
994
+ console.log('No screenshot to crop');
995
+ return;
996
+ }
997
+
998
+ // Clean up existing cropper instance
999
+ if (this.cropper) {
1000
+ this.cropper.destroy();
1001
+ this.cropper = null;
1002
+ }
1003
+
1004
+ const cropArea = document.querySelector('.crop-area');
1005
+ if (!cropArea) {
1006
+ console.error('Crop area element not found');
1007
+ return;
1008
+ }
1009
+
1010
+ cropArea.innerHTML = '';
1011
+ const clonedImage = this.screenshotImg.cloneNode(true);
1012
+ clonedImage.style.display = 'block';
1013
+ cropArea.appendChild(clonedImage);
1014
+
1015
+ this.cropContainer.classList.remove('hidden');
1016
+
1017
+ // Store reference to this for use in ready callback
1018
+ const self = this;
1019
+
1020
+ this.cropper = new Cropper(clonedImage, {
1021
+ viewMode: 1,
1022
+ dragMode: 'move',
1023
+ aspectRatio: NaN,
1024
+ modal: true,
1025
+ background: true,
1026
+ ready: function() {
1027
+ // 如果有上次保存的裁剪框数据,应用它
1028
+ if (self.lastCropBoxData) {
1029
+ self.cropper.setCropBoxData(self.lastCropBoxData);
1030
+ console.log('Applied saved crop box data');
1031
+ }
1032
+ }
1033
+ });
1034
+ } catch (error) {
1035
+ console.error('Failed to initialize cropper', error);
1036
+ window.uiManager.showToast('裁剪器初始化失败', 'error');
1037
+
1038
+ // 确保在出错时关闭裁剪界面
1039
+ if (this.cropContainer) {
1040
+ this.cropContainer.classList.add('hidden');
1041
+ }
1042
+ }
1043
+ }
1044
+
1045
+ setupEventListeners() {
1046
+ console.log('DEBUG: 设置所有事件监听器(这应该只执行一次)');
1047
+
1048
+ this.setupCaptureEvents();
1049
+ this.setupCropEvents();
1050
+ this.setupAnalysisEvents();
1051
+ this.setupKeyboardShortcuts();
1052
+ this.setupThinkingToggle();
1053
+ this.setupClipboardFeature();
1054
+
1055
+ // 监听模型选择变化,更新界面
1056
+ if (window.settingsManager && window.settingsManager.modelSelect) {
1057
+ window.settingsManager.modelSelect.addEventListener('change', () => {
1058
+ this.updateImageActionButtons();
1059
+ });
1060
+ }
1061
+ }
1062
+
1063
+ setupClipboardFeature() {
1064
+ if (!this.clipboardTextarea || !this.clipboardSendButton || !this.clipboardReadButton) {
1065
+ console.warn('Clipboard controls not found in DOM');
1066
+ return;
1067
+ }
1068
+
1069
+ // 读取剪贴板按钮事件
1070
+ this.clipboardReadButton.addEventListener('click', (event) => {
1071
+ event.preventDefault();
1072
+ this.readClipboardText();
1073
+ });
1074
+
1075
+ // 发送到剪贴板按钮事件
1076
+ this.clipboardSendButton.addEventListener('click', (event) => {
1077
+ event.preventDefault();
1078
+ this.sendClipboardText();
1079
+ });
1080
+
1081
+ // 键盘快捷键:Ctrl/Cmd + Enter 发送到剪贴板
1082
+ this.clipboardTextarea.addEventListener('keydown', (event) => {
1083
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
1084
+ event.preventDefault();
1085
+ this.sendClipboardText();
1086
+ }
1087
+ });
1088
+ }
1089
+
1090
+ setupCaptureEvents() {
1091
+ // 添加计数器
1092
+ if (!window.captureCounter) {
1093
+ window.captureCounter = 0;
1094
+ }
1095
+
1096
+ // 移除现有的事件监听器,防止重复绑定
1097
+ if (this.captureBtn) {
1098
+ // 克隆按钮并替换原按钮,这样可以移除所有事件监听器
1099
+ const newBtn = this.captureBtn.cloneNode(true);
1100
+ this.captureBtn.parentNode.replaceChild(newBtn, this.captureBtn);
1101
+ this.captureBtn = newBtn;
1102
+
1103
+ console.log('DEBUG: 已清除截图按钮上的事件监听器');
1104
+ }
1105
+
1106
+ // 截图按钮
1107
+ this.captureBtn.addEventListener('click', () => {
1108
+ if (!this.checkConnectionBeforeAction()) return;
1109
+
1110
+ try {
1111
+ // 增加计数并记录
1112
+ window.captureCounter++;
1113
+ console.log(`DEBUG: 截图按钮点击计数 = ${window.captureCounter}`);
1114
+
1115
+ this.captureBtn.disabled = true; // 禁用按钮防止重复点击
1116
+ this.captureBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
1117
+
1118
+ console.log('DEBUG: 发送capture_screenshot事件到服务器');
1119
+ this.socket.emit('capture_screenshot', {});
1120
+ } catch (error) {
1121
+ console.error('Error starting capture:', error);
1122
+ window.uiManager.showToast('启动截图失败', 'error');
1123
+ this.captureBtn.disabled = false;
1124
+ this.captureBtn.innerHTML = '<i class="fas fa-camera"></i>';
1125
+ }
1126
+ });
1127
+ }
1128
+
1129
+ updateClipboardStatus(message, status = 'neutral') {
1130
+ if (!this.clipboardStatus) return;
1131
+
1132
+ if (!message) {
1133
+ this.clipboardStatus.textContent = '';
1134
+ this.clipboardStatus.removeAttribute('data-status');
1135
+ return;
1136
+ }
1137
+
1138
+ this.clipboardStatus.textContent = message;
1139
+ this.clipboardStatus.dataset.status = status;
1140
+ }
1141
+
1142
+ async readClipboardText() {
1143
+ if (!this.clipboardTextarea || !this.clipboardReadButton) return;
1144
+
1145
+ this.updateClipboardStatus('读取中...', 'pending');
1146
+ this.clipboardReadButton.disabled = true;
1147
+
1148
+ try {
1149
+ const response = await fetch('/api/clipboard', {
1150
+ method: 'GET',
1151
+ headers: {
1152
+ 'Content-Type': 'application/json'
1153
+ }
1154
+ });
1155
+ const result = await response.json().catch(() => ({}));
1156
+
1157
+ if (response.ok && result?.success) {
1158
+ // 将读取到的内容填入文本框
1159
+ this.clipboardTextarea.value = result.text || '';
1160
+
1161
+ const successMessage = result.text ?
1162
+ `成功读取剪贴板内容 (${result.text.length} 字符)` :
1163
+ '剪贴板为空';
1164
+ this.updateClipboardStatus(successMessage, 'success');
1165
+ window.uiManager?.showToast(successMessage, 'success');
1166
+ } else {
1167
+ const errorMessage = result?.message || '读取失败,请稍后重试';
1168
+ this.updateClipboardStatus(errorMessage, 'error');
1169
+ window.uiManager?.showToast(errorMessage, 'error');
1170
+ }
1171
+ } catch (error) {
1172
+ console.error('Failed to read clipboard text:', error);
1173
+ const networkErrorMessage = '网络错误,读取失败';
1174
+ this.updateClipboardStatus(networkErrorMessage, 'error');
1175
+ window.uiManager?.showToast(networkErrorMessage, 'error');
1176
+ } finally {
1177
+ this.clipboardReadButton.disabled = false;
1178
+ }
1179
+ }
1180
+
1181
+ async sendClipboardText() {
1182
+ if (!this.clipboardTextarea) return;
1183
+
1184
+ const text = this.clipboardTextarea.value ?? '';
1185
+ if (!text.trim()) {
1186
+ const warningMessage = '请输入要发送到剪贴板的文字';
1187
+ this.updateClipboardStatus(warningMessage, 'error');
1188
+ window.uiManager?.showToast(warningMessage, 'warning');
1189
+ return;
1190
+ }
1191
+
1192
+ this.updateClipboardStatus('发送中...', 'pending');
1193
+ if (this.clipboardSendButton) {
1194
+ this.clipboardSendButton.disabled = true;
1195
+ }
1196
+
1197
+ try {
1198
+ const response = await fetch('/api/clipboard', {
1199
+ method: 'POST',
1200
+ headers: {
1201
+ 'Content-Type': 'application/json'
1202
+ },
1203
+ body: JSON.stringify({ text })
1204
+ });
1205
+ const result = await response.json().catch(() => ({}));
1206
+
1207
+ if (response.ok && result?.success) {
1208
+ const successMessage = '已复制到服务端剪贴板';
1209
+ this.updateClipboardStatus(successMessage, 'success');
1210
+ window.uiManager?.showToast(successMessage, 'success');
1211
+
1212
+ // 清空输入框
1213
+ this.clipboardTextarea.value = '';
1214
+ } else {
1215
+ const errorMessage = result?.message || '发送失败,请稍后重试';
1216
+ this.updateClipboardStatus(errorMessage, 'error');
1217
+ window.uiManager?.showToast(errorMessage, 'error');
1218
+ }
1219
+ } catch (error) {
1220
+ console.error('Failed to send clipboard text:', error);
1221
+ const networkErrorMessage = '网络错误,发送失败';
1222
+ this.updateClipboardStatus(networkErrorMessage, 'error');
1223
+ window.uiManager?.showToast(networkErrorMessage, 'error');
1224
+ } finally {
1225
+ if (this.clipboardSendButton) {
1226
+ this.clipboardSendButton.disabled = false;
1227
+ }
1228
+ }
1229
+ }
1230
+
1231
+ setupCropEvents() {
1232
+ // 防止重复绑定事件监听器
1233
+ if (this.cropConfirm) {
1234
+ const newCropConfirm = this.cropConfirm.cloneNode(true);
1235
+ this.cropConfirm.parentNode.replaceChild(newCropConfirm, this.cropConfirm);
1236
+ this.cropConfirm = newCropConfirm;
1237
+ }
1238
+
1239
+ if (this.cropCancel) {
1240
+ const newCropCancel = this.cropCancel.cloneNode(true);
1241
+ this.cropCancel.parentNode.replaceChild(newCropCancel, this.cropCancel);
1242
+ this.cropCancel = newCropCancel;
1243
+ }
1244
+
1245
+ const cropResetElement = document.getElementById('cropReset');
1246
+ if (cropResetElement) {
1247
+ const newCropReset = cropResetElement.cloneNode(true);
1248
+ cropResetElement.parentNode.replaceChild(newCropReset, cropResetElement);
1249
+ }
1250
+
1251
+ if (this.cropSendToAI) {
1252
+ const newCropSendToAI = this.cropSendToAI.cloneNode(true);
1253
+ this.cropSendToAI.parentNode.replaceChild(newCropSendToAI, this.cropSendToAI);
1254
+ this.cropSendToAI = newCropSendToAI;
1255
+ }
1256
+
1257
+ console.log('DEBUG: 已清除裁剪按钮上的事件监听器,防止重复绑定');
1258
+
1259
+ // 存储裁剪框数据
1260
+ this.lastCropBoxData = null;
1261
+
1262
+ // Crop confirm button
1263
+ this.cropConfirm.addEventListener('click', () => {
1264
+ if (!this.checkConnectionBeforeAction()) return;
1265
+
1266
+ if (this.cropper) {
1267
+ try {
1268
+ console.log('Starting crop operation...');
1269
+
1270
+ // Validate cropper instance
1271
+ if (!this.cropper) {
1272
+ throw new Error('Cropper not initialized');
1273
+ }
1274
+
1275
+ // Get and validate crop box data
1276
+ const cropBoxData = this.cropper.getCropBoxData();
1277
+ console.log('Crop box data:', cropBoxData);
1278
+
1279
+ // 保存裁剪框数据以便下次使用
1280
+ this.lastCropBoxData = cropBoxData;
1281
+
1282
+ if (!cropBoxData || typeof cropBoxData.width !== 'number' || typeof cropBoxData.height !== 'number') {
1283
+ throw new Error('Invalid crop box data');
1284
+ }
1285
+
1286
+ if (cropBoxData.width < 10 || cropBoxData.height < 10) {
1287
+ throw new Error('Crop area is too small. Please select a larger area (minimum 10x10 pixels).');
1288
+ }
1289
+
1290
+ // Get cropped canvas with more conservative size limits
1291
+ console.log('Getting cropped canvas...');
1292
+ const canvas = this.cropper.getCroppedCanvas({
1293
+ maxWidth: 2560,
1294
+ maxHeight: 1440,
1295
+ fillColor: '#fff',
1296
+ imageSmoothingEnabled: true,
1297
+ imageSmoothingQuality: 'high',
1298
+ });
1299
+
1300
+ if (!canvas) {
1301
+ throw new Error('Failed to create cropped canvas');
1302
+ }
1303
+
1304
+ console.log('Canvas created successfully');
1305
+
1306
+ // Convert to data URL with error handling and compression
1307
+ console.log('Converting to data URL...');
1308
+ try {
1309
+ // Use PNG for better quality
1310
+ this.croppedImage = canvas.toDataURL('image/png');
1311
+ console.log('Data URL conversion successful');
1312
+ } catch (dataUrlError) {
1313
+ console.error('Data URL conversion error:', dataUrlError);
1314
+ throw new Error('Failed to process cropped image. The image might be too large or memory insufficient.');
1315
+ }
1316
+
1317
+ // Properly destroy the cropper instance
1318
+ this.cropper.destroy();
1319
+ this.cropper = null;
1320
+
1321
+ // Clean up cropper and update UI
1322
+ this.cropContainer.classList.add('hidden');
1323
+ document.querySelector('.crop-area').innerHTML = '';
1324
+
1325
+ // Update the screenshot image with the cropped version
1326
+ this.screenshotImg.src = this.croppedImage;
1327
+ this.imagePreview.classList.remove('hidden');
1328
+
1329
+ // 根据当前选择的模型类型决定显示哪些按钮
1330
+ this.updateImageActionButtons();
1331
+
1332
+ window.uiManager.showToast('裁剪成功');
1333
+
1334
+ // 不再自动发送至AI,由用户手动选择
1335
+ } catch (error) {
1336
+ console.error('Cropping error details:', {
1337
+ message: error.message,
1338
+ stack: error.stack,
1339
+ cropperState: this.cropper ? 'initialized' : 'not initialized'
1340
+ });
1341
+ window.uiManager.showToast(error.message || '裁剪图像时出错', 'error');
1342
+ } finally {
1343
+ // Always clean up the cropper instance
1344
+ if (this.cropper) {
1345
+ this.cropper.destroy();
1346
+ this.cropper = null;
1347
+ }
1348
+ }
1349
+ }
1350
+ });
1351
+
1352
+ // Crop cancel button
1353
+ this.cropCancel.addEventListener('click', () => {
1354
+ if (this.cropper) {
1355
+ this.cropper.destroy();
1356
+ this.cropper = null;
1357
+ }
1358
+ this.cropContainer.classList.add('hidden');
1359
+ // 取消裁剪时隐藏图像预览和相关按钮
1360
+ this.imagePreview.classList.add('hidden');
1361
+ document.querySelector('.crop-area').innerHTML = '';
1362
+ });
1363
+
1364
+ // Crop reset button
1365
+ const cropResetBtn = document.getElementById('cropReset');
1366
+ if (cropResetBtn) {
1367
+ cropResetBtn.addEventListener('click', () => {
1368
+ if (this.cropper) {
1369
+ // 重置裁剪区域到默认状态
1370
+ this.cropper.reset();
1371
+ window.uiManager.showToast('已重置裁剪区域');
1372
+ }
1373
+ });
1374
+ }
1375
+
1376
+ // Crop send to AI button
1377
+ this.cropSendToAI.addEventListener('click', () => {
1378
+ if (!this.checkConnectionBeforeAction()) return;
1379
+
1380
+ // 如果有裁剪器,尝试获取裁剪结果;否则使用原始图片
1381
+ if (this.cropper) {
1382
+ try {
1383
+ console.log('Starting crop and send operation...');
1384
+
1385
+ // Validate cropper instance
1386
+ if (!this.cropper) {
1387
+ throw new Error('Cropper not initialized');
1388
+ }
1389
+
1390
+ // Get and validate crop box data
1391
+ const cropBoxData = this.cropper.getCropBoxData();
1392
+ console.log('Crop box data:', cropBoxData);
1393
+
1394
+ // 保存裁剪框数据以便下次使用
1395
+ this.lastCropBoxData = cropBoxData;
1396
+
1397
+ if (!cropBoxData || typeof cropBoxData.width !== 'number' || typeof cropBoxData.height !== 'number') {
1398
+ throw new Error('Invalid crop box data');
1399
+ }
1400
+
1401
+ if (cropBoxData.width < 10 || cropBoxData.height < 10) {
1402
+ throw new Error('Crop area is too small. Please select a larger area (minimum 10x10 pixels).');
1403
+ }
1404
+
1405
+ // Get cropped canvas
1406
+ console.log('Getting cropped canvas...');
1407
+ const canvas = this.cropper.getCroppedCanvas({
1408
+ maxWidth: 2560,
1409
+ maxHeight: 1440,
1410
+ fillColor: '#fff',
1411
+ imageSmoothingEnabled: true,
1412
+ imageSmoothingQuality: 'high',
1413
+ });
1414
+
1415
+ if (!canvas) {
1416
+ throw new Error('Failed to create cropped canvas');
1417
+ }
1418
+
1419
+ console.log('Canvas created successfully');
1420
+
1421
+ // Convert to data URL
1422
+ console.log('Converting to data URL...');
1423
+ try {
1424
+ this.croppedImage = canvas.toDataURL('image/png');
1425
+ console.log('Data URL conversion successful');
1426
+ } catch (dataUrlError) {
1427
+ console.error('Data URL conversion error:', dataUrlError);
1428
+ throw new Error('Failed to process cropped image. The image might be too large or memory insufficient.');
1429
+ }
1430
+
1431
+ // Clean up cropper and update UI
1432
+ this.cropper.destroy();
1433
+ this.cropper = null;
1434
+ this.cropContainer.classList.add('hidden');
1435
+ document.querySelector('.crop-area').innerHTML = '';
1436
+
1437
+ // Update the screenshot image with the cropped version
1438
+ this.screenshotImg.src = this.croppedImage;
1439
+ this.imagePreview.classList.remove('hidden');
1440
+
1441
+ // 根据当前选择的模型类型决定显示哪些按钮
1442
+ this.updateImageActionButtons();
1443
+
1444
+ // 显示Claude分析面板
1445
+ this.claudePanel.classList.remove('hidden');
1446
+ this.emptyState.classList.add('hidden');
1447
+
1448
+ // 发送图像到Claude进行分析
1449
+ this.sendImageToClaude(this.croppedImage);
1450
+
1451
+ window.uiManager.showToast('正在发送至AI分析...');
1452
+
1453
+ } catch (error) {
1454
+ console.error('Crop and send error details:', {
1455
+ message: error.message,
1456
+ stack: error.stack,
1457
+ cropperState: this.cropper ? 'initialized' : 'not initialized'
1458
+ });
1459
+ window.uiManager.showToast(error.message || '处理图像时出错', 'error');
1460
+
1461
+ // Clean up on error
1462
+ if (this.cropper) {
1463
+ this.cropper.destroy();
1464
+ this.cropper = null;
1465
+ }
1466
+ this.cropContainer.classList.add('hidden');
1467
+ document.querySelector('.crop-area').innerHTML = '';
1468
+ }
1469
+ } else if (this.originalImage) {
1470
+ // 如果没有裁剪器但有原始图片,直接发送原始图片
1471
+ try {
1472
+ // 隐藏裁剪容器
1473
+ this.cropContainer.classList.add('hidden');
1474
+
1475
+ // 显示Claude分析面板
1476
+ this.claudePanel.classList.remove('hidden');
1477
+ this.emptyState.classList.add('hidden');
1478
+
1479
+ // 发送原始图像到Claude进行分析
1480
+ this.sendImageToClaude(this.originalImage);
1481
+
1482
+ window.uiManager.showToast('正在发送至AI分析...');
1483
+
1484
+ } catch (error) {
1485
+ console.error('Send original image error:', error);
1486
+ window.uiManager.showToast('发送图片失败: ' + error.message, 'error');
1487
+ }
1488
+ } else {
1489
+ window.uiManager.showToast('请先截图', 'error');
1490
+ }
1491
+ });
1492
+ }
1493
+
1494
+ setupAnalysisEvents() {
1495
+ // 防止重复绑定事件监听器
1496
+ if (this.extractTextBtn) {
1497
+ const newExtractBtn = this.extractTextBtn.cloneNode(true);
1498
+ this.extractTextBtn.parentNode.replaceChild(newExtractBtn, this.extractTextBtn);
1499
+ this.extractTextBtn = newExtractBtn;
1500
+ }
1501
+
1502
+ if (this.sendExtractedTextBtn) {
1503
+ const newSendBtn = this.sendExtractedTextBtn.cloneNode(true);
1504
+ this.sendExtractedTextBtn.parentNode.replaceChild(newSendBtn, this.sendExtractedTextBtn);
1505
+ this.sendExtractedTextBtn = newSendBtn;
1506
+ }
1507
+
1508
+ if (this.sendToClaudeBtn) {
1509
+ const newClaudeBtn = this.sendToClaudeBtn.cloneNode(true);
1510
+ this.sendToClaudeBtn.parentNode.replaceChild(newClaudeBtn, this.sendToClaudeBtn);
1511
+ this.sendToClaudeBtn = newClaudeBtn;
1512
+ }
1513
+
1514
+ console.log('DEBUG: 已清除分析按钮上的事件监听器,防止重复绑定');
1515
+
1516
+ // Extract Text button
1517
+ this.extractTextBtn.addEventListener('click', () => {
1518
+ if (!this.checkConnectionBeforeAction()) return;
1519
+
1520
+ // 优先使用裁剪后的图片,如果没有则使用原始截图
1521
+ const imageToExtract = this.croppedImage || this.originalImage;
1522
+
1523
+ if (!imageToExtract) {
1524
+ window.uiManager.showToast('请先截图', 'error');
1525
+ return;
1526
+ }
1527
+
1528
+ this.extractTextBtn.disabled = true;
1529
+ this.sendExtractedTextBtn.disabled = true; // Disable the send button while extracting
1530
+ this.extractTextBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i><span>提取中...</span>';
1531
+
1532
+ const settings = window.settingsManager.getSettings();
1533
+
1534
+ // 根据用户设置的OCR源进行选择
1535
+ const ocrSource = settings.ocrSource || 'auto';
1536
+ const baiduApiKey = window.settingsManager.apiKeyValues.BaiduApiKey;
1537
+ const baiduSecretKey = window.settingsManager.apiKeyValues.BaiduSecretKey;
1538
+ const mathpixApiKey = settings.mathpixApiKey;
1539
+
1540
+ const hasBaiduOCR = baiduApiKey && baiduSecretKey;
1541
+ const hasMathpix = mathpixApiKey && mathpixApiKey !== ':';
1542
+
1543
+ // 根据OCR源配置检查可用性
1544
+ let canProceed = false;
1545
+ let missingOCRMessage = '';
1546
+
1547
+ if (ocrSource === 'baidu') {
1548
+ canProceed = hasBaiduOCR;
1549
+ missingOCRMessage = '请在设置中配置百度OCR API密钥';
1550
+ } else if (ocrSource === 'mathpix') {
1551
+ canProceed = hasMathpix;
1552
+ missingOCRMessage = '请在设置中配置Mathpix API密钥';
1553
+ } else { // auto
1554
+ canProceed = hasBaiduOCR || hasMathpix;
1555
+ missingOCRMessage = '请在设置中配置OCR API密钥:百度OCR(推荐)或Mathpix';
1556
+ }
1557
+
1558
+ if (!canProceed) {
1559
+ window.uiManager.showToast(missingOCRMessage, 'error');
1560
+ document.getElementById('settingsPanel').classList.add('active');
1561
+ this.extractTextBtn.disabled = false;
1562
+ this.extractTextBtn.innerHTML = '<i class="fas fa-font"></i><span>提取文本</span>';
1563
+ return;
1564
+ }
1565
+
1566
+ // 显示文本框和按钮
1567
+ this.extractedText.classList.remove('hidden');
1568
+ this.sendExtractedTextBtn.classList.remove('hidden');
1569
+
1570
+ if (this.extractedText) {
1571
+ this.extractedText.value = '正在提取文本...';
1572
+ this.extractedText.disabled = true;
1573
+ }
1574
+
1575
+ try {
1576
+ this.socket.emit('extract_text', {
1577
+ image: imageToExtract.split(',')[1],
1578
+ settings: {
1579
+ ocrSource: settings.ocrSource || 'auto'
1580
+ }
1581
+ });
1582
+
1583
+ // 监听服务器确认请求的响应
1584
+ this.socket.once('request_acknowledged', (ackResponse) => {
1585
+ console.log('服务器确认收到文本提取请求', ackResponse);
1586
+ });
1587
+ } catch (error) {
1588
+ window.uiManager.showToast('提取文本失败: ' + error.message, 'error');
1589
+ this.extractTextBtn.disabled = false;
1590
+ this.sendExtractedTextBtn.disabled = false;
1591
+ this.extractTextBtn.innerHTML = '<i class="fas fa-font"></i><span>提取文本</span>';
1592
+ if (this.extractedText) {
1593
+ this.extractedText.disabled = false;
1594
+ }
1595
+ }
1596
+ });
1597
+
1598
+ // Send Extracted Text button
1599
+ this.sendExtractedTextBtn.addEventListener('click', () => {
1600
+ if (!this.checkConnectionBeforeAction()) return;
1601
+
1602
+ // 防止重复点击
1603
+ if (this.sendExtractedTextBtn.disabled) return;
1604
+
1605
+ const text = this.extractedText.value.trim();
1606
+ if (!text) {
1607
+ window.uiManager.showToast('请输入一些文本', 'error');
1608
+ return;
1609
+ }
1610
+
1611
+ const settings = window.settingsManager.getSettings();
1612
+ const apiKeys = {};
1613
+ Object.keys(window.settingsManager.apiKeyInputs).forEach(keyId => {
1614
+ const input = window.settingsManager.apiKeyInputs[keyId];
1615
+ if (input && input.value) {
1616
+ apiKeys[keyId] = input.value;
1617
+ }
1618
+ });
1619
+
1620
+ console.log("Debug - 发送文本分析API密钥:", apiKeys);
1621
+
1622
+ // 禁用按钮防止重复点击
1623
+ this.sendExtractedTextBtn.disabled = true;
1624
+
1625
+ try {
1626
+ this.socket.emit('analyze_text', {
1627
+ text: text,
1628
+ settings: {
1629
+ ...settings,
1630
+ apiKeys: apiKeys,
1631
+ model: settings.model || 'claude-3-7-sonnet-20250219',
1632
+ modelInfo: settings.modelInfo || {},
1633
+ modelCapabilities: {
1634
+ supportsMultimodal: settings.modelInfo?.supportsMultimodal || false,
1635
+ isReasoning: settings.modelInfo?.isReasoning || false
1636
+ }
1637
+ }
1638
+ });
1639
+ } catch (error) {
1640
+ this.setElementContent(this.responseContent, 'Error: Failed to send text for analysis - ' + error.message);
1641
+ this.sendExtractedTextBtn.disabled = false;
1642
+ window.uiManager.showToast('发送文本进行分析失败', 'error');
1643
+ }
1644
+ });
1645
+
1646
+ // Send to Claude button
1647
+ this.sendToClaudeBtn.addEventListener('click', () => {
1648
+ if (!this.checkConnectionBeforeAction()) return;
1649
+
1650
+ // 防止重复点击
1651
+ if (this.sendToClaudeBtn.disabled) return;
1652
+ this.sendToClaudeBtn.disabled = true;
1653
+
1654
+ // 获取当前模型设置
1655
+ const settings = window.settingsManager.getSettings();
1656
+ const isMultimodalModel = settings.modelInfo?.supportsMultimodal || false;
1657
+ const modelName = settings.model || '未知';
1658
+
1659
+ if (!isMultimodalModel) {
1660
+ window.uiManager.showToast(`当前选择的模型 ${modelName} 不支持图像分析。请先提取文本或切换到支持多模态的模型。`, 'error');
1661
+ this.sendToClaudeBtn.disabled = false;
1662
+ return;
1663
+ }
1664
+
1665
+ // 只发送裁剪后的图片,如果没有裁剪过则提示用户先裁剪
1666
+ if (this.croppedImage) {
1667
+ try {
1668
+ // 清空之前的结果
1669
+ this.responseContent.innerHTML = '';
1670
+ this.thinkingContent.innerHTML = '';
1671
+
1672
+ // 显示Claude分析面板
1673
+ this.claudePanel.classList.remove('hidden');
1674
+
1675
+ // 发送图片进行分析
1676
+ this.sendImageToClaude(this.croppedImage);
1677
+ } catch (error) {
1678
+ console.error('Error:', error);
1679
+ window.uiManager.showToast('发送图片失败: ' + error.message, 'error');
1680
+ this.sendToClaudeBtn.disabled = false;
1681
+ }
1682
+ } else {
1683
+ window.uiManager.showToast('请先裁剪图片', 'error');
1684
+ this.sendToClaudeBtn.disabled = false;
1685
+ }
1686
+ });
1687
+
1688
+ // Handle Claude panel close button
1689
+ const closeClaudePanel = document.getElementById('closeClaudePanel');
1690
+ if (closeClaudePanel) {
1691
+ closeClaudePanel.addEventListener('click', () => {
1692
+ this.claudePanel.classList.add('hidden');
1693
+
1694
+ // 如果图像预览也被隐藏,显示空状态
1695
+ if (this.imagePreview.classList.contains('hidden')) {
1696
+ this.emptyState.classList.remove('hidden');
1697
+ }
1698
+
1699
+ // 重置状态指示灯
1700
+ this.updateStatusLight('');
1701
+
1702
+ // 清空响应内容,准备下一次分析
1703
+ this.responseContent.innerHTML = '';
1704
+
1705
+ // 隐藏思考部分
1706
+ this.thinkingSection.classList.add('hidden');
1707
+ this.thinkingContent.innerHTML = '';
1708
+ this.thinkingContent.className = 'thinking-content collapsed';
1709
+ });
1710
+ }
1711
+ }
1712
+
1713
+ setupThinkingToggle() {
1714
+ // 确保正确获取DOM元素
1715
+ const thinkingSection = document.getElementById('thinkingSection');
1716
+ const thinkingToggle = document.getElementById('thinkingToggle');
1717
+ const thinkingContent = document.getElementById('thinkingContent');
1718
+
1719
+ if (!thinkingToggle || !thinkingContent) {
1720
+ console.error('思考切换组件未找到必要的DOM元素');
1721
+ return;
1722
+ }
1723
+
1724
+ // 初始化时隐藏动态省略号
1725
+ this.showThinkingAnimation(false);
1726
+
1727
+ // 存储DOM引用
1728
+ this.thinkingSection = thinkingSection;
1729
+ this.thinkingToggle = thinkingToggle;
1730
+ this.thinkingContent = thinkingContent;
1731
+
1732
+ // 直接使用函数,确保作用域正确
1733
+ thinkingToggle.onclick = () => {
1734
+ console.log('点击了思考标题');
1735
+
1736
+ // 先移除两个类,然后添加正确的类
1737
+ const isExpanded = thinkingContent.classList.contains('expanded');
1738
+ const toggleIcon = thinkingToggle.querySelector('.toggle-btn i');
1739
+
1740
+ // 从样式上清除当前状态
1741
+ thinkingContent.classList.remove('expanded');
1742
+ thinkingContent.classList.remove('collapsed');
1743
+
1744
+ if (isExpanded) {
1745
+ console.log('折叠思考内容');
1746
+ // 添加折叠状态
1747
+ thinkingContent.classList.add('collapsed');
1748
+ if (toggleIcon) {
1749
+ toggleIcon.className = 'fas fa-chevron-down';
1750
+ }
1751
+ // 更新用户偏好
1752
+ this.userThinkingExpanded = false;
1753
+ } else {
1754
+ console.log('展开思考内容');
1755
+ // 添加展开状态
1756
+ thinkingContent.classList.add('expanded');
1757
+ if (toggleIcon) {
1758
+ toggleIcon.className = 'fas fa-chevron-up';
1759
+ }
1760
+ // 更新用户偏好
1761
+ this.userThinkingExpanded = true;
1762
+
1763
+ // 当展开思考内容时,确保代码高亮生效
1764
+ if (window.hljs) {
1765
+ setTimeout(() => {
1766
+ thinkingContent.querySelectorAll('pre code').forEach((block) => {
1767
+ hljs.highlightElement(block);
1768
+ });
1769
+ }, 100); // 添加一点延迟,确保DOM已完全更新
1770
+ }
1771
+ }
1772
+ };
1773
+
1774
+ console.log('思考切换组件初始化完成');
1775
+ }
1776
+
1777
+ // 获取用于显示的图像URL,如果原始URL无效则返回占位符
1778
+ getImageForDisplay(imageUrl) {
1779
+ return this.isValidImageDataUrl(imageUrl) ? imageUrl : this.getPlaceholderImageUrl();
1780
+ }
1781
+
1782
+ sendImageToClaude(imageData) {
1783
+ const settings = window.settingsManager.getSettings();
1784
+
1785
+ // 获取API密钥
1786
+ const apiKeys = {};
1787
+ Object.keys(window.settingsManager.apiKeyInputs).forEach(keyId => {
1788
+ const input = window.settingsManager.apiKeyInputs[keyId];
1789
+ if (input && input.value) {
1790
+ apiKeys[keyId] = input.value;
1791
+ }
1792
+ });
1793
+
1794
+ console.log("Debug - 发送API密钥:", apiKeys);
1795
+
1796
+ try {
1797
+ // 处理图像数据,去除base64前缀
1798
+ let processedImageData = imageData;
1799
+ if (imageData.startsWith('data:')) {
1800
+ // 分割数据URL,只保留base64部分
1801
+ processedImageData = imageData.split(',')[1];
1802
+ }
1803
+
1804
+ this.socket.emit('analyze_image', {
1805
+ image: processedImageData,
1806
+ settings: {
1807
+ ...settings,
1808
+ apiKeys: apiKeys,
1809
+ model: settings.model || 'claude-3-7-sonnet-20250219',
1810
+ modelInfo: settings.modelInfo || {},
1811
+ modelCapabilities: {
1812
+ supportsMultimodal: settings.modelInfo?.supportsMultimodal || false,
1813
+ isReasoning: settings.modelInfo?.isReasoning || false
1814
+ }
1815
+ }
1816
+ });
1817
+
1818
+ // 注意:Claude面板的显示已经在点击事件中处理,这里不再重复
1819
+ } catch (error) {
1820
+ this.setElementContent(this.responseContent, 'Error: ' + error.message);
1821
+ window.uiManager.showToast('发送图片分析失败', 'error');
1822
+ this.sendToClaudeBtn.disabled = false;
1823
+ }
1824
+ }
1825
+
1826
+ async initialize() {
1827
+ console.log('Initializing SnapSolver...');
1828
+
1829
+ // 重置调试计数器
1830
+ window.captureCounter = 0;
1831
+ window.responseCounter = 0;
1832
+ console.log('DEBUG: 重置截图计数器');
1833
+
1834
+ // 初始化managers
1835
+ // 确保UIManager已经初始化,如果没有,等待它初始化
1836
+ if (!window.uiManager) {
1837
+ console.log('等待UI管理器初始化...');
1838
+ window.uiManager = new UIManager();
1839
+ // 给UIManager一些时间初始化
1840
+ await new Promise(resolve => setTimeout(resolve, 100));
1841
+ }
1842
+
1843
+ window.settingsManager = new SettingsManager();
1844
+ window.app = this; // 便于从其他地方访问
1845
+
1846
+ // 等待SettingsManager初始化完成
1847
+ if (window.settingsManager) {
1848
+ // 如果settingsManager还没初始化完成,等待它
1849
+ if (!window.settingsManager.isInitialized) {
1850
+ console.log('等待设置管理器初始化完成...');
1851
+ // 最多等待5秒
1852
+ for (let i = 0; i < 50; i++) {
1853
+ if (window.settingsManager.isInitialized) break;
1854
+ await new Promise(resolve => setTimeout(resolve, 100));
1855
+ }
1856
+ }
1857
+ }
1858
+
1859
+ // 初始化Markdown工具
1860
+ this.initializeMarkdownTools();
1861
+
1862
+ // 建立与服务器的连接
1863
+ this.connectToServer();
1864
+
1865
+ // 初始化UI元素和事件处理
1866
+ this.initializeElements();
1867
+
1868
+ // 设置所有事件监听器(注意:setupEventListeners内部已经调用了setupCaptureEvents,不需要重复调用)
1869
+ this.setupEventListeners();
1870
+ this.setupAutoScroll();
1871
+
1872
+ // 监听窗口大小变化,调整界面
1873
+ window.addEventListener('resize', this.handleResize.bind(this));
1874
+
1875
+ // 监听document点击事件,处理面板关闭
1876
+ document.addEventListener('click', (e) => {
1877
+ // 关闭裁剪器
1878
+ if (this.cropContainer &&
1879
+ !this.cropContainer.contains(e.target) &&
1880
+ !e.target.matches('#cropBtn') &&
1881
+ !this.cropContainer.classList.contains('hidden')) {
1882
+ this.cropContainer.classList.add('hidden');
1883
+ }
1884
+ });
1885
+
1886
+ // 监听页面卸载事件,清除所有计时器
1887
+ window.addEventListener('beforeunload', this.cleanup.bind(this));
1888
+
1889
+ // 设置默认UI状态
1890
+ this.enableInterface();
1891
+
1892
+ // 更新图像操作按钮
1893
+ this.updateImageActionButtons();
1894
+
1895
+ console.log('SnapSolver initialization complete');
1896
+ }
1897
+
1898
+ // 初始化Markdown工具
1899
+ initializeMarkdownTools() {
1900
+ // 检查marked是否可用
1901
+ if (typeof marked === 'undefined') {
1902
+ console.warn('Marked.js 未加载,Markdown渲染将不可用');
1903
+ return;
1904
+ }
1905
+
1906
+ // 创建一个备用的hljs对象,以防CDN加载失败
1907
+ if (typeof hljs === 'undefined') {
1908
+ console.warn('Highlight.js未加载,创建备用对象');
1909
+ window.hljs = {
1910
+ highlight: (code, opts) => ({ value: code }),
1911
+ highlightAuto: (code) => ({ value: code }),
1912
+ getLanguage: () => null,
1913
+ configure: () => {}
1914
+ };
1915
+ }
1916
+
1917
+ // 初始化marked设置
1918
+ marked.setOptions({
1919
+ gfm: true, // 启用GitHub风格的Markdown
1920
+ breaks: true, // 将换行符转换为<br>
1921
+ pedantic: false, // 不使用原始markdown规范
1922
+ sanitize: false, // 不要过滤HTML标签,允许一些HTML
1923
+ smartLists: true, // 使用比原生markdown更智能的列表行为
1924
+ smartypants: false, // 不要使用更智能的标点符号
1925
+ xhtml: false, // 不使用自闭合标签
1926
+ mangle: false, // 不混淆邮箱地址
1927
+ headerIds: false, // 不生成header ID
1928
+ highlight: function(code, lang) {
1929
+ // 如果highlight.js不可用,直接返回代码
1930
+ if (typeof hljs === 'undefined') {
1931
+ return code;
1932
+ }
1933
+
1934
+ // 如果指定了语言且hljs支持
1935
+ if (lang && hljs.getLanguage(lang)) {
1936
+ try {
1937
+ return hljs.highlight(code, { language: lang }).value;
1938
+ } catch (err) {
1939
+ console.error('代码高亮错误:', err);
1940
+ }
1941
+ }
1942
+
1943
+ // 尝试自动检测语言
1944
+ try {
1945
+ return hljs.highlightAuto(code).value;
1946
+ } catch (err) {
1947
+ console.error('自动语言检测错误:', err);
1948
+ }
1949
+
1950
+ return code; // 使用默认编码效果
1951
+ }
1952
+ });
1953
+
1954
+ // 配置hljs以支持自动语言检测
1955
+ try {
1956
+ hljs.configure({
1957
+ languages: ['javascript', 'python', 'java', 'cpp', 'csharp', 'html', 'css', 'json', 'xml', 'markdown', 'bash']
1958
+ });
1959
+ console.log('Markdown工具初始化完成');
1960
+ } catch (err) {
1961
+ console.error('配置hljs时出错:', err);
1962
+ }
1963
+ }
1964
+
1965
+ handleResize() {
1966
+ // 如果裁剪器存在,需要调整其大小和位置
1967
+ if (this.cropper) {
1968
+ this.cropper.resize();
1969
+ }
1970
+
1971
+ // 可以在这里添加其他响应式UI调整
1972
+ }
1973
+
1974
+ enableInterface() {
1975
+ // 启用主要界面元素
1976
+ if (this.captureBtn) {
1977
+ this.captureBtn.disabled = false;
1978
+ this.captureBtn.innerHTML = '<i class="fas fa-camera"></i>';
1979
+ }
1980
+
1981
+ // 显示默认的空白状态
1982
+ if (this.emptyState && this.imagePreview) {
1983
+ if (!this.originalImage) {
1984
+ this.emptyState.classList.remove('hidden');
1985
+ this.imagePreview.classList.add('hidden');
1986
+ } else {
1987
+ this.emptyState.classList.add('hidden');
1988
+ this.imagePreview.classList.remove('hidden');
1989
+ }
1990
+ }
1991
+
1992
+ console.log('Interface enabled');
1993
+ }
1994
+
1995
+ connectToServer() {
1996
+ console.log('Connecting to server...');
1997
+
1998
+ // 创建Socket.IO连接
1999
+ this.socket = io({
2000
+ reconnectionAttempts: 5,
2001
+ reconnectionDelay: 1000,
2002
+ timeout: 20000
2003
+ });
2004
+
2005
+ // 连接事件处理
2006
+ this.socket.on('connect', () => {
2007
+ console.log('Connected to server');
2008
+ this.updateConnectionStatus('已连接', true);
2009
+
2010
+ // 连接后启用界面
2011
+ this.enableInterface();
2012
+ });
2013
+
2014
+ this.socket.on('disconnect', () => {
2015
+ console.log('Disconnected from server');
2016
+ this.updateConnectionStatus('已断开', false);
2017
+
2018
+ // 断开连接时禁用界面
2019
+ if (this.captureBtn) {
2020
+ this.captureBtn.disabled = true;
2021
+ }
2022
+ });
2023
+
2024
+ this.socket.on('connect_error', (error) => {
2025
+ console.error('Connection error:', error);
2026
+ this.updateConnectionStatus('连接错误', false);
2027
+ });
2028
+
2029
+ this.socket.on('reconnect_attempt', (attemptNumber) => {
2030
+ console.log(`Reconnection attempt ${attemptNumber}`);
2031
+ this.updateConnectionStatus('正在重连...', false);
2032
+ });
2033
+
2034
+ this.socket.on('reconnect', () => {
2035
+ console.log('Reconnected to server');
2036
+ this.updateConnectionStatus('已重连', true);
2037
+
2038
+ // 重连后启用界面
2039
+ this.enableInterface();
2040
+ });
2041
+
2042
+ this.socket.on('reconnect_failed', () => {
2043
+ console.error('Failed to reconnect');
2044
+ this.updateConnectionStatus('重连失败', false);
2045
+ window.uiManager.showToast('连接服务器失败,请刷新页面重试', 'error');
2046
+ });
2047
+
2048
+ // 设置socket事件处理器
2049
+ this.setupSocketEventHandlers();
2050
+ }
2051
+
2052
+ isConnected() {
2053
+ return this.socket && this.socket.connected;
2054
+ }
2055
+
2056
+ checkConnectionBeforeAction(action) {
2057
+ if (!this.isConnected()) {
2058
+ window.uiManager.showToast('未连接到服务器,请等待连接建立后再试', 'error');
2059
+ return false;
2060
+ }
2061
+ return true;
2062
+ }
2063
+
2064
+ scrollToBottom() {
2065
+ if (this.responseContent) {
2066
+ // 使用平滑滚动效果
2067
+ this.responseContent.scrollTo({
2068
+ top: this.responseContent.scrollHeight,
2069
+ behavior: 'smooth'
2070
+ });
2071
+
2072
+ // 确保Claude面板也滚动到可见区域
2073
+ if (this.claudePanel) {
2074
+ this.claudePanel.scrollIntoView({
2075
+ behavior: 'smooth',
2076
+ block: 'end'
2077
+ });
2078
+ }
2079
+ }
2080
+ }
2081
+
2082
+ // 新增方法:根据所选模型更新图像操作按钮
2083
+ updateImageActionButtons() {
2084
+ if (!window.settingsManager) {
2085
+ console.error('Settings manager not available');
2086
+ return;
2087
+ }
2088
+
2089
+ const settings = window.settingsManager.getSettings();
2090
+ const isMultimodalModel = settings.modelInfo?.supportsMultimodal || false;
2091
+ const modelName = settings.model || '未知';
2092
+
2093
+ console.log(`更新图像操作按钮 - 当前模型: ${modelName}, 是否支持多模态: ${isMultimodalModel}`);
2094
+
2095
+ // 对于截图后的操作按钮显示逻辑
2096
+ if (this.sendToClaudeBtn && this.extractTextBtn) {
2097
+ if (!isMultimodalModel) {
2098
+ // 非多模态模型:只显示提取文本按钮,隐藏发送到AI按钮
2099
+ console.log('非多模态模型:隐藏"发送图片至AI"按钮');
2100
+ this.sendToClaudeBtn.classList.add('hidden');
2101
+ this.extractTextBtn.classList.remove('hidden');
2102
+ } else {
2103
+ // 多模态模型:显示两个按钮
2104
+ if (!this.imagePreview.classList.contains('hidden')) {
2105
+ // 只有在有图像时才显示按钮
2106
+ console.log('多模态模型:显示全部按钮');
2107
+ this.sendToClaudeBtn.classList.remove('hidden');
2108
+ this.extractTextBtn.classList.remove('hidden');
2109
+ } else {
2110
+ // 无图像时隐藏所有按钮
2111
+ console.log('无图像:隐藏所有按钮');
2112
+ this.sendToClaudeBtn.classList.add('hidden');
2113
+ this.extractTextBtn.classList.add('hidden');
2114
+ }
2115
+ }
2116
+ } else {
2117
+ console.warn('按钮元素不可用');
2118
+ }
2119
+ }
2120
+
2121
+ checkClickOutside() {
2122
+ // 点击其他区域时自动关闭悬浮窗
2123
+ document.addEventListener('click', (e) => {
2124
+ // 检查是否点击在设置面板、设置按钮或其子元素之外
2125
+ if (
2126
+ !e.target.closest('#settingsPanel') &&
2127
+ !e.target.matches('#settingsToggle') &&
2128
+ !e.target.closest('#settingsToggle') &&
2129
+ document.getElementById('settingsPanel').classList.contains('active')
2130
+ ) {
2131
+ document.getElementById('settingsPanel').classList.remove('active');
2132
+ }
2133
+
2134
+ // 检查是否点击在Claude面板、分析按钮或其子元素之外
2135
+ if (
2136
+ !e.target.closest('#claudePanel') &&
2137
+ !e.target.matches('#sendToClaude') &&
2138
+ !e.target.closest('#sendToClaude') &&
2139
+ !e.target.matches('#extractText') &&
2140
+ !e.target.closest('#extractText') &&
2141
+ !e.target.matches('#sendExtractedText') &&
2142
+ !e.target.closest('#sendExtractedText') &&
2143
+ !this.claudePanel.classList.contains('hidden')
2144
+ ) {
2145
+ // 因为分析可能正在进行,不自动关闭Claude面板
2146
+ // 但是可以考虑增加一个最小化功能
2147
+ }
2148
+ });
2149
+ }
2150
+
2151
+ // 新增清理方法,移除计时器相关代码
2152
+ cleanup() {
2153
+ console.log('执行清理操作...');
2154
+
2155
+ // 清除所有Socket监听器
2156
+ if (this.socket) {
2157
+ this.socket.off('text_extracted');
2158
+ this.socket.off('screenshot_response');
2159
+ this.socket.off('screenshot_complete');
2160
+ this.socket.off('request_acknowledged');
2161
+ this.socket.off('ai_response');
2162
+ this.socket.off('thinking');
2163
+ this.socket.off('thinking_complete');
2164
+ this.socket.off('analysis_complete');
2165
+ }
2166
+
2167
+ // 销毁裁剪器实例
2168
+ if (this.cropper) {
2169
+ this.cropper.destroy();
2170
+ this.cropper = null;
2171
+ }
2172
+
2173
+ console.log('清理完成');
2174
+ }
2175
+
2176
+ // 空方法替代键盘快捷键实现
2177
+ setupKeyboardShortcuts() {
2178
+ // 移动端应用不需要键盘快捷键
2179
+ console.log('键盘快捷键已禁用(移动端应用)');
2180
+ }
2181
+
2182
+ // 控制思考动态省略号显示
2183
+ showThinkingAnimation(show) {
2184
+ const dotsElement = document.querySelector('.thinking-title .dots-animation');
2185
+ if (dotsElement) {
2186
+ if (show) {
2187
+ dotsElement.style.display = 'inline-block';
2188
+ } else {
2189
+ dotsElement.style.display = 'none';
2190
+ }
2191
+ }
2192
+ }
2193
+
2194
+ // 添加停止生成方法
2195
+ stopGeneration() {
2196
+ console.log('停止生成请求');
2197
+
2198
+ // 向服务器发送停止生成信号
2199
+ if (this.socket && this.socket.connected) {
2200
+ this.socket.emit('stop_generation');
2201
+
2202
+ // 显示提示
2203
+ window.uiManager.showToast('正在停止生成...', 'info');
2204
+
2205
+ // 隐藏停止按钮
2206
+ this.hideStopGenerationButton();
2207
+ } else {
2208
+ console.error('无法停止生成: Socket未连接');
2209
+ window.uiManager.showToast('无法停止生成: 连接已断开', 'error');
2210
+ }
2211
+ }
2212
+
2213
+ // 显示停止生成按钮
2214
+ showStopGenerationButton() {
2215
+ if (this.stopGenerationBtn) {
2216
+ this.stopGenerationBtn.classList.add('visible');
2217
+ }
2218
+ }
2219
+
2220
+ // 隐藏停止生成按钮
2221
+ hideStopGenerationButton() {
2222
+ if (this.stopGenerationBtn) {
2223
+ this.stopGenerationBtn.classList.remove('visible');
2224
+ }
2225
+ }
2226
+ }
2227
+
2228
+ // Initialize the application when the DOM is loaded
2229
+ document.addEventListener('DOMContentLoaded', async () => {
2230
+ try {
2231
+ console.log('Initializing application...');
2232
+ window.app = new SnapSolver();
2233
+ await window.app.initialize();
2234
+ console.log('Application initialized successfully');
2235
+ } catch (error) {
2236
+ console.error('Failed to initialize application:', error);
2237
+ // 在页面上显���错误信息
2238
+ const errorDiv = document.createElement('div');
2239
+ errorDiv.className = 'init-error';
2240
+ errorDiv.innerHTML = `
2241
+ <h2>Initialization Error</h2>
2242
+ <p>${error.message}</p>
2243
+ <pre>${error.stack}</pre>
2244
+ `;
2245
+ document.body.appendChild(errorDiv);
2246
+ }
2247
+ });
static/js/settings.js ADDED
The diff for this file is too large to render. See raw diff
 
static/js/ui.js ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class UIManager {
2
+ constructor() {
3
+ // 延迟初始化,确保DOM已加载
4
+ if (document.readyState === 'loading') {
5
+ document.addEventListener('DOMContentLoaded', () => this.init());
6
+ } else {
7
+ // 如果DOM已经加载完成,则立即初始化
8
+ this.init();
9
+ }
10
+ }
11
+
12
+ init() {
13
+ console.log('初始化UI管理器...');
14
+ // UI elements
15
+ this.settingsPanel = document.getElementById('settingsPanel');
16
+ this.settingsToggle = document.getElementById('settingsToggle');
17
+ this.closeSettings = document.getElementById('closeSettings');
18
+ this.themeToggle = document.getElementById('themeToggle');
19
+ this.toastContainer = document.getElementById('toastContainer');
20
+
21
+ // 验证关键元素是否存在
22
+ if (!this.themeToggle) {
23
+ console.error('主题切换按钮未找到!');
24
+ return;
25
+ }
26
+
27
+ if (!this.toastContainer) {
28
+ console.error('Toast容器未找到!');
29
+ // 尝试创建Toast容器
30
+ this.toastContainer = this.createToastContainer();
31
+ }
32
+
33
+ // Check for preferred color scheme
34
+ this.checkPreferredColorScheme();
35
+
36
+ // Initialize event listeners
37
+ this.setupEventListeners();
38
+
39
+ console.log('UI管理器初始化完成');
40
+ }
41
+
42
+ createToastContainer() {
43
+ console.log('创建Toast容器');
44
+ const container = document.createElement('div');
45
+ container.id = 'toastContainer';
46
+ container.className = 'toast-container';
47
+ document.body.appendChild(container);
48
+ return container;
49
+ }
50
+
51
+ checkPreferredColorScheme() {
52
+ const savedTheme = localStorage.getItem('theme');
53
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
54
+
55
+ if (savedTheme) {
56
+ this.setTheme(savedTheme === 'dark');
57
+ } else {
58
+ this.setTheme(prefersDark.matches);
59
+ }
60
+
61
+ prefersDark.addEventListener('change', (e) => this.setTheme(e.matches));
62
+ }
63
+
64
+ setTheme(isDark) {
65
+ try {
66
+ document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
67
+ if (this.themeToggle) {
68
+ this.themeToggle.innerHTML = `<i class="fas fa-${isDark ? 'sun' : 'moon'}"></i>`;
69
+ }
70
+ localStorage.setItem('theme', isDark ? 'dark' : 'light');
71
+ console.log(`主题已切换为: ${isDark ? '深色' : '浅色'}`);
72
+ } catch (error) {
73
+ console.error('设置主题时出错:', error);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 显示一个Toast消息
79
+ * @param {string} message 显示的消息内容
80
+ * @param {string} type 消息类型,可以是'success', 'error', 'info', 'warning'
81
+ * @param {number} displayTime 显示的时间(毫秒),如果为-1则持续显示直到手动关闭
82
+ * @returns {HTMLElement} 返回创建的Toast元素,可用于后续移除
83
+ */
84
+ showToast(message, type = 'success', displayTime) {
85
+ try {
86
+ if (!message) {
87
+ console.warn('尝试显示空消息');
88
+ message = '';
89
+ }
90
+
91
+ if (!this.toastContainer) {
92
+ console.error('Toast容器不存在,正在创建新容器');
93
+ this.toastContainer = this.createToastContainer();
94
+ if (!this.toastContainer) {
95
+ console.error('无法创建Toast容器,放弃显示消息');
96
+ return null;
97
+ }
98
+ }
99
+
100
+ // 检查是否已经存在相同内容的提示
101
+ try {
102
+ const existingToasts = this.toastContainer.querySelectorAll('.toast');
103
+ for (const existingToast of existingToasts) {
104
+ try {
105
+ const spanElement = existingToast.querySelector('span');
106
+ if (spanElement && spanElement.textContent === message) {
107
+ // 已经存在相同的提示,不再创建新的
108
+ return existingToast;
109
+ }
110
+ } catch (e) {
111
+ console.warn('检查现有toast时出错:', e);
112
+ // 继续检查其他toast元素
113
+ }
114
+ }
115
+ } catch (e) {
116
+ console.warn('查询现有toast时出错:', e);
117
+ // 继续创建新的toast
118
+ }
119
+
120
+ const toast = document.createElement('div');
121
+ toast.className = `toast ${type}`;
122
+
123
+ // 根据类型设置图标
124
+ let icon = 'check-circle';
125
+ if (type === 'error') icon = 'exclamation-circle';
126
+ else if (type === 'warning') icon = 'exclamation-triangle';
127
+ else if (type === 'info') icon = 'info-circle';
128
+
129
+ toast.innerHTML = `
130
+ <i class="fas fa-${icon}"></i>
131
+ <span>${message}</span>
132
+ `;
133
+
134
+ // 如果是持续显示的Toast,添加关闭按钮
135
+ if (displayTime === -1) {
136
+ const closeButton = document.createElement('button');
137
+ closeButton.className = 'toast-close';
138
+ closeButton.innerHTML = '<i class="fas fa-times"></i>';
139
+ closeButton.addEventListener('click', (e) => {
140
+ this.hideToast(toast);
141
+ });
142
+ toast.appendChild(closeButton);
143
+ toast.classList.add('persistent');
144
+ }
145
+
146
+ this.toastContainer.appendChild(toast);
147
+
148
+ // 为不同类型的提示设置不同的显示时间
149
+ if (displayTime !== -1) {
150
+ // 如果没有指定时间,则根据消息类型和内容长度设置默认时间
151
+ if (displayTime === undefined) {
152
+ displayTime = message === '截图成功' ? 1500 :
153
+ type === 'error' ? 5000 :
154
+ message.length > 50 ? 4000 : 3000;
155
+ }
156
+
157
+ setTimeout(() => {
158
+ this.hideToast(toast);
159
+ }, displayTime);
160
+ }
161
+
162
+ return toast;
163
+ } catch (error) {
164
+ console.error('显示Toast消息时出错:', error);
165
+ return null;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * 隐藏一个Toast消息
171
+ * @param {HTMLElement} toast 要隐藏的Toast元素
172
+ */
173
+ hideToast(toast) {
174
+ if (!toast || !toast.parentNode) return;
175
+
176
+ toast.style.opacity = '0';
177
+ setTimeout(() => {
178
+ if (toast.parentNode) {
179
+ toast.remove();
180
+ }
181
+ }, 300);
182
+ }
183
+
184
+ closeAllPanels() {
185
+ if (this.settingsPanel) {
186
+ this.settingsPanel.classList.remove('active');
187
+ }
188
+ }
189
+
190
+ hideSettingsPanel() {
191
+ if (this.settingsPanel) {
192
+ this.settingsPanel.classList.remove('active');
193
+ }
194
+ }
195
+
196
+ toggleSettingsPanel() {
197
+ if (this.settingsPanel) {
198
+ this.settingsPanel.classList.toggle('active');
199
+ }
200
+ }
201
+
202
+ closeSettingsPanel() {
203
+ if (this.settingsPanel) {
204
+ this.settingsPanel.classList.remove('active');
205
+ }
206
+ }
207
+
208
+ // 检查点击事件,如果点击了设置面板外部,则关闭设置面板
209
+ checkClickOutsideSettings(e) {
210
+ if (this.settingsPanel &&
211
+ !this.settingsPanel.contains(e.target) &&
212
+ !e.target.closest('#settingsToggle')) {
213
+ this.settingsPanel.classList.remove('active');
214
+ }
215
+ }
216
+
217
+ setupEventListeners() {
218
+ // 确保所有元素都存在
219
+ if (!this.settingsToggle || !this.closeSettings || !this.themeToggle) {
220
+ console.error('无法设置事件监听器:一些UI元素未找到');
221
+ return;
222
+ }
223
+
224
+ // Settings panel
225
+ this.settingsToggle.addEventListener('click', () => {
226
+ this.closeAllPanels();
227
+ this.settingsPanel.classList.toggle('active');
228
+ });
229
+
230
+ this.closeSettings.addEventListener('click', () => {
231
+ this.settingsPanel.classList.remove('active');
232
+ });
233
+
234
+ // Theme toggle
235
+ this.themeToggle.addEventListener('click', () => {
236
+ try {
237
+ const currentTheme = document.documentElement.getAttribute('data-theme');
238
+ console.log('当前主题:', currentTheme);
239
+ this.setTheme(currentTheme !== 'dark');
240
+ } catch (error) {
241
+ console.error('切换主题时出错:', error);
242
+ }
243
+ });
244
+
245
+ // Close panels when clicking outside
246
+ document.addEventListener('click', (e) => {
247
+ this.checkClickOutsideSettings(e);
248
+ });
249
+ }
250
+ }
251
+
252
+ // 创建全局实例
253
+ window.UIManager = UIManager;
254
+
255
+ // 确保在DOM加载完毕后才创建UIManager实例
256
+ if (document.readyState === 'loading') {
257
+ document.addEventListener('DOMContentLoaded', () => {
258
+ window.uiManager = new UIManager();
259
+ });
260
+ } else {
261
+ window.uiManager = new UIManager();
262
+ }
263
+
264
+ // 导出全局辅助函数
265
+ window.showToast = (message, type) => {
266
+ if (window.uiManager) {
267
+ return window.uiManager.showToast(message, type);
268
+ } else {
269
+ console.error('UI管理器未初始化,无法显示Toast');
270
+ return null;
271
+ }
272
+ };
273
+
274
+ window.closeAllPanels = () => {
275
+ if (window.uiManager) {
276
+ window.uiManager.closeAllPanels();
277
+ } else {
278
+ console.error('UI管理器未初始化,无法关闭面板');
279
+ }
280
+ };
static/style.css ADDED
The diff for this file is too large to render. See raw diff
 
styles.css DELETED
@@ -1,12 +0,0 @@
1
- :root {
2
- --bslib-sidebar-main-bg: #f8f8f8;
3
- }
4
-
5
- .popover {
6
- --bs-popover-header-bg: #222;
7
- --bs-popover-header-color: #fff;
8
- }
9
-
10
- .popover .btn-close {
11
- filter: var(--bs-btn-close-white-filter);
12
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
templates/index.html ADDED
@@ -0,0 +1,946 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
6
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
7
+ <!-- Safari兼容性设置 -->
8
+ <meta name="apple-mobile-web-app-capable" content="yes">
9
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
10
+ <meta name="mobile-web-app-capable" content="yes">
11
+ <title>Snap Solver</title>
12
+ <link rel="icon" href="/static/favicon.ico">
13
+ <link rel="stylesheet" href="/static/style.css">
14
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.css">
15
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
16
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
17
+ <script>
18
+ // 帮助Safari调试
19
+ window.onerror = function(message, source, lineno, colno, error) {
20
+ console.error("Error caught: ", message, "at", source, ":", lineno, ":", colno, error);
21
+ return false;
22
+ };
23
+ </script>
24
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js"></script>
25
+ <!-- 添加Markdown解析库 -->
26
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
27
+ <!-- 添加代码高亮库 -->
28
+ <link rel="stylesheet" href="https://unpkg.com/@highlightjs/cdn-assets@11.8.0/styles/github.min.css">
29
+ <script src="https://unpkg.com/@highlightjs/cdn-assets@11.8.0/highlight.min.js"></script>
30
+ </head>
31
+ <body class="app-container">
32
+ <header class="app-header">
33
+ <div class="header-content">
34
+ <h1>Snap Solver <span class="version-badge">v<span id="currentVersion">{{ update_info.current_version }}</span></span></h1>
35
+ <div class="header-middle">
36
+ <button id="themeToggle" class="btn-icon" title="切换主题">
37
+ <i class="fas fa-moon"></i>
38
+ </button>
39
+ <button id="settingsToggle" class="btn-icon" title="设置">
40
+ <i class="fas fa-cog"></i>
41
+ </button>
42
+ <div id="connectionStatus" class="status disconnected">未连接</div>
43
+ </div>
44
+ <div class="header-buttons">
45
+ <button id="captureBtn" class="btn-icon capture-btn-highlight" title="截图" disabled>
46
+ <i class="fas fa-camera"></i>
47
+ <span>开始截图</span>
48
+ </button>
49
+ </div>
50
+ </div>
51
+ </header>
52
+
53
+ <!-- 更新通知条 -->
54
+ <div id="updateNotice" class="update-notice hidden">
55
+ <div class="update-notice-content">
56
+ <i class="fas fa-arrow-alt-circle-up"></i>
57
+ <span>发现新版本: <span id="updateVersion"></span></span>
58
+ <a id="updateLink" href="#" target="_blank" class="update-link">查看更新</a>
59
+ <button id="closeUpdateNotice" class="btn-icon">
60
+ <i class="fas fa-times"></i>
61
+ </button>
62
+ </div>
63
+ </div>
64
+
65
+ <main class="app-main">
66
+ <div class="content-panel">
67
+ <div id="claudePanel" class="claude-panel hidden">
68
+ <div class="panel-header">
69
+ <div class="header-title">
70
+ <h2><i class="fas fa-chart-bar"></i> 分析结果</h2>
71
+ <div class="analysis-indicator">
72
+ <div class="progress-line"></div>
73
+ <div class="status-text">准备中</div>
74
+ </div>
75
+ <button id="stopGenerationBtn" class="btn-stop-generation" title="停止生成">
76
+ <i class="fas fa-stop"></i>
77
+ </button>
78
+ </div>
79
+ <button class="btn-icon" id="closeClaudePanel" title="关闭分析结果">
80
+ <i class="fas fa-times"></i>
81
+ </button>
82
+ </div>
83
+ <div id="thinkingSection" class="thinking-section hidden">
84
+ <div class="thinking-header" id="thinkingToggle" title="点击查看AI思考过程">
85
+ <div class="thinking-title">
86
+ <i class="fas fa-brain"></i>
87
+ <h3>思考过程<span class="dots-animation"></span></h3>
88
+ </div>
89
+ <button class="toggle-btn">
90
+ <i class="fas fa-chevron-down"></i>
91
+ </button>
92
+ </div>
93
+ <div id="thinkingContent" class="thinking-content collapsed"></div>
94
+ </div>
95
+ <div id="responseContent" class="response-content"></div>
96
+ </div>
97
+
98
+ <div id="claudePanel" class="claude-panel hidden">
99
+ <div class="panel-header">
100
+ <div class="header-title">
101
+ <h2><i class="fas fa-chart-bar"></i> 分析结果</h2>
102
+ <div class="analysis-indicator">
103
+ <div class="progress-line"></div>
104
+ <div class="status-text">准备中</div>
105
+ </div>
106
+ <button id="stopGenerationBtn" class="btn-stop-generation" title="停止生成">
107
+ <i class="fas fa-stop"></i>
108
+ </button>
109
+ </div>
110
+ <button class="btn-icon" id="closeClaudePanel" title="关闭分析结果">
111
+ <i class="fas fa-times"></i>
112
+ </button>
113
+ </div>
114
+ <div id="thinkingSection" class="thinking-section hidden">
115
+ <div class="thinking-header" id="thinkingToggle" title="点击查看AI思考过程">
116
+ <div class="thinking-title">
117
+ <i class="fas fa-brain"></i>
118
+ <h3>思考过程<span class="dots-animation"></span></h3>
119
+ </div>
120
+ <button class="toggle-btn">
121
+ <i class="fas fa-chevron-down"></i>
122
+ </button>
123
+ </div>
124
+ <div id="thinkingContent" class="thinking-content collapsed"></div>
125
+ </div>
126
+ <div id="responseContent" class="response-content"></div>
127
+ </div>
128
+
129
+ <div class="capture-section">
130
+ <div id="emptyState" class="empty-state">
131
+ <i class="fas fa-camera-retro"></i>
132
+ <h3>准备好开始了吗?</h3>
133
+ <p>点击顶部状态栏的"相机"图标捕获屏幕,然后使用AI分析图像或提取文本。您可以截取数学题、代码或任何需要帮助的内容。</p>
134
+ <p class="star-prompt">如果觉得好用,别忘了给Github点个 Star ⭐</p>
135
+ <div class="empty-state-social">
136
+ <a href="https://github.com/Zippland/Snap-Solver/" target="_blank" class="social-link github-link">
137
+ <span>GitHub</span>
138
+ </a>
139
+ <a href="https://www.xiaohongshu.com/user/profile/623e8b080000000010007721" target="_blank" class="social-link xiaohongshu-link">
140
+ <span>小红书</span>
141
+ </a>
142
+ </div>
143
+ </div>
144
+ <div id="imagePreview" class="image-preview hidden">
145
+ <div class="image-container">
146
+ <img id="screenshotImg" src="" alt="截图预览">
147
+ </div>
148
+ <div class="analysis-button">
149
+ <div class="button-group">
150
+ <button id="sendToClaude" class="btn-action hidden">
151
+ <i class="fas fa-robot"></i>
152
+ <span>发送至AI</span>
153
+ </button>
154
+ <button id="extractText" class="btn-action hidden">
155
+ <i class="fas fa-font"></i>
156
+ <span>提取文本</span>
157
+ </button>
158
+ </div>
159
+ </div>
160
+
161
+ <textarea id="extractedText" class="extracted-text-area hidden" rows="6" placeholder="提取的文本将显示在这里..."></textarea>
162
+ <button id="sendExtractedText" class="btn-action send-text-btn hidden">
163
+ <i class="fas fa-paper-plane"></i>
164
+ <span>发送文本至AI</span>
165
+ </button>
166
+ </div>
167
+ </div>
168
+
169
+ <div class="clipboard-panel">
170
+ <div class="clipboard-header">
171
+ <h3><i class="fas fa-clipboard"></i> 剪贴板操作</h3>
172
+ <p class="clipboard-hint">读取宿主机剪贴板内容或发送内容到服务端剪贴板</p>
173
+ </div>
174
+ <textarea id="clipboardText" rows="4" placeholder="剪贴板内容将显示在这里,也可以手动输入内容"></textarea>
175
+ <div class="clipboard-actions">
176
+ <button id="clipboardRead" class="btn-action clipboard-read-btn" type="button">
177
+ <i class="fas fa-download"></i>
178
+ <span>读取剪贴板</span>
179
+ </button>
180
+ <button id="clipboardSend" class="btn-action clipboard-send-btn" type="button">
181
+ <i class="fas fa-clipboard-check"></i>
182
+ <span>发送至剪贴板</span>
183
+ </button>
184
+ <span id="clipboardStatus" class="clipboard-status" aria-live="polite"></span>
185
+ </div>
186
+ </div>
187
+ </div>
188
+
189
+ <aside id="settingsPanel" class="settings-panel">
190
+ <div class="settings-header">
191
+ <h2><i class="fas fa-cog"></i> 设置</h2>
192
+ <button id="closeSettings" class="btn-icon">
193
+ <i class="fas fa-times"></i>
194
+ </button>
195
+ </div>
196
+ <div class="settings-content">
197
+ <!-- 1. 首先是最常用的AI模型选择部分 -->
198
+ <div class="settings-section model-settings">
199
+ <h3><i class="fas fa-robot"></i> 模型设置</h3>
200
+ <div class="setting-group">
201
+ <div class="model-control">
202
+ <label for="modelSelect"><i class="fas fa-microchip"></i> AI模型</label>
203
+ <!-- 简化模型选择器结构 -->
204
+ <div class="model-selector" id="modelSelector">
205
+ <div class="model-display">
206
+ <div class="model-display-icon">
207
+ <i class="fas fa-robot"></i>
208
+ </div>
209
+ <div class="model-display-info">
210
+ <div class="model-display-name" id="currentModelName">选择模型</div>
211
+ <div class="model-display-provider" id="currentModelProvider"></div>
212
+ </div>
213
+ <div class="model-display-badges" id="modelBadges">
214
+ <!-- 能力图标由JS生成 -->
215
+ </div>
216
+ <i class="fas fa-chevron-down model-selector-arrow"></i>
217
+ </div>
218
+ </div>
219
+ <!-- 保留原始下拉框用于保持兼容性 -->
220
+ <select id="modelSelect" class="hidden">
221
+ <!-- 选项通过JS添加 -->
222
+ </select>
223
+ <div id="modelVersionInfo" class="model-version-info">
224
+ <i class="fas fa-info-circle"></i> <span id="modelVersionText">-</span>
225
+ </div>
226
+ </div>
227
+ </div>
228
+ <div class="setting-group">
229
+ <div class="token-control">
230
+ <div class="token-label">
231
+ <label for="maxTokens"><i class="fas fa-text-width"></i> 最大输出Token</label>
232
+ </div>
233
+ <div class="token-slider-container">
234
+ <input type="range" id="maxTokens" class="token-slider" min="1000" max="128000" step="1000" value="8192">
235
+ <span class="token-value" id="maxTokensValue">8192</span>
236
+ </div>
237
+ <div class="token-markers">
238
+ <span>1K</span>
239
+ <span>32K</span>
240
+ <span>64K</span>
241
+ <span>96K</span>
242
+ <span>128K</span>
243
+ </div>
244
+ <div class="token-presets">
245
+ <button class="token-preset" data-value="4000">简短</button>
246
+ <button class="token-preset" data-value="16000">标准</button>
247
+ <button class="token-preset" data-value="64000">详细</button>
248
+ <button class="token-preset" data-value="128000">最大</button>
249
+ </div>
250
+ </div>
251
+ </div>
252
+ <div class="setting-group reasoning-setting-group">
253
+ <div class="reasoning-control">
254
+ <div class="reasoning-label">
255
+ <label for="reasoningDepth"><i class="fas fa-brain"></i> 推理深度</label>
256
+ </div>
257
+ <div class="reasoning-selector">
258
+ <div class="reasoning-option" data-value="standard">
259
+ <i class="fas fa-bolt"></i>
260
+ <span class="option-name">标准模式</span>
261
+ <span class="option-desc">快速响应,即时生成</span>
262
+ </div>
263
+ <div class="reasoning-option" data-value="extended">
264
+ <i class="fas fa-lightbulb"></i>
265
+ <span class="option-name">深度思考</span>
266
+ <span class="option-desc">更详细的分析与推理</span>
267
+ </div>
268
+ </div>
269
+ <select id="reasoningDepth" class="hidden">
270
+ <option value="standard">标准模式 (快速响应)</option>
271
+ <option value="extended">深度思考 (更详细分析)</option>
272
+ </select>
273
+ </div>
274
+ </div>
275
+ <div class="setting-group doubao-thinking-group" style="display: none;">
276
+ <div class="doubao-thinking-control">
277
+ <div class="doubao-thinking-label">
278
+ <label for="doubaoThinkingMode"><i class="fas fa-cogs"></i> 豆包深度思考模式</label>
279
+ </div>
280
+ <div class="doubao-thinking-selector">
281
+ <div class="doubao-thinking-option active" data-value="auto">
282
+ <i class="fas fa-magic"></i>
283
+ <span class="option-name">自动模式</span>
284
+ <span class="option-desc">由AI自动决定是否使用深度思考</span>
285
+ </div>
286
+ <div class="doubao-thinking-option" data-value="enabled">
287
+ <i class="fas fa-brain"></i>
288
+ <span class="option-name">开启思考</span>
289
+ <span class="option-desc">强制启用深度思考过程</span>
290
+ </div>
291
+ <div class="doubao-thinking-option" data-value="disabled">
292
+ <i class="fas fa-bolt"></i>
293
+ <span class="option-name">关闭思考</span>
294
+ <span class="option-desc">禁用深度思考,快速响应</span>
295
+ </div>
296
+ </div>
297
+ <select id="doubaoThinkingMode" class="hidden">
298
+ <option value="auto">自动模式</option>
299
+ <option value="enabled">开启思考</option>
300
+ <option value="disabled">关闭思考</option>
301
+ </select>
302
+ <div class="doubao-thinking-desc">
303
+ <div class="doubao-desc-item">
304
+ <i class="fas fa-info-circle"></i>
305
+ <span><strong>自动模式:</strong>AI根据问题复杂度自动决定</span>
306
+ </div>
307
+ <div class="doubao-desc-item">
308
+ <i class="fas fa-lightbulb"></i>
309
+ <span><strong>开启思考:</strong>显示完整的思考推理过程</span>
310
+ </div>
311
+ <div class="doubao-desc-item">
312
+ <i class="fas fa-rocket"></i>
313
+ <span><strong>关闭思考:</strong>直接给出答案,响应更快</span>
314
+ </div>
315
+ </div>
316
+ </div>
317
+ </div>
318
+ <div class="setting-group think-budget-group">
319
+ <div class="think-budget-control">
320
+ <div class="think-budget-label">
321
+ <label for="thinkBudgetPercent"><i class="fas fa-hourglass-half"></i> 思考预算占比</label>
322
+ </div>
323
+ <div class="think-slider-container">
324
+ <input type="range" id="thinkBudgetPercent" class="think-slider" min="10" max="80" step="5" value="50">
325
+ <span class="think-value-badge" id="thinkBudgetPercentValue">50%</span>
326
+ </div>
327
+ <div class="think-budget-markers">
328
+ <span>10%</span>
329
+ <span>30%</span>
330
+ <span>50%</span>
331
+ <span>70%</span>
332
+ <span>80%</span>
333
+ </div>
334
+ <div class="think-budget-presets">
335
+ <button class="think-preset" data-value="20">少量</button>
336
+ <button class="think-preset" data-value="50">平衡</button>
337
+ <button class="think-preset" data-value="70">深入</button>
338
+ </div>
339
+ <div class="think-budget-desc">
340
+ <div class="think-desc-item">
341
+ <i class="fas fa-tachometer-alt"></i>
342
+ <span>低���比 = 更快响应速度</span>
343
+ </div>
344
+ <div class="think-desc-item">
345
+ <i class="fas fa-search-plus"></i>
346
+ <span>高占比 = 更深入的分析</span>
347
+ </div>
348
+ </div>
349
+ </div>
350
+ </div>
351
+ <!-- 已删除重复的豆包思考模式UI元素 -->
352
+ <div class="setting-group">
353
+ <div class="temperature-control">
354
+ <div class="temperature-label">
355
+ <label for="temperature"><i class="fas fa-thermometer-half"></i> 温度</label>
356
+ </div>
357
+ <input type="range" id="temperature" class="temperature-slider" min="0" max="1" step="0.1" value="0.7">
358
+ <div class="temperature-markers">
359
+ <span>0</span>
360
+ <span>0.2</span>
361
+ <span>0.4</span>
362
+ <span>0.6</span>
363
+ <span>0.8</span>
364
+ <span>1</span>
365
+ </div>
366
+ <div class="temperature-description">
367
+ <span class="temperature-low">精确</span>
368
+ <span class="temperature-high">创意</span>
369
+ </div>
370
+ </div>
371
+ </div>
372
+ </div>
373
+
374
+ <!-- 系统提示词部分 - 简化设计 -->
375
+ <div class="settings-section prompt-settings">
376
+ <h3><i class="fas fa-comment-alt"></i> 系统提示词</h3>
377
+ <div class="setting-group prompt-setting-group">
378
+ <div class="prompt-container">
379
+ <div class="prompt-actions">
380
+ <select id="promptSelect" title="选择预设提示词">
381
+ </select>
382
+ <div class="prompt-buttons">
383
+ <button id="savePromptBtn" class="icon-btn" title="编辑当前提示词">
384
+ <i class="fas fa-edit"></i>
385
+ </button>
386
+ <button id="newPromptBtn" class="icon-btn" title="新建提示词">
387
+ <i class="fas fa-plus"></i>
388
+ </button>
389
+ <button id="deletePromptBtn" class="icon-btn" title="删除当前提示词">
390
+ <i class="fas fa-trash"></i>
391
+ </button>
392
+ </div>
393
+ </div>
394
+ <div class="prompt-preview">
395
+ <div id="promptDescription" class="prompt-description">
396
+ <p>您是一位专业的问题解决专家。请逐步分析问题,找出问题所在,并提供详细的解决方案。始终使用用户偏好的语言回答。</p>
397
+ </div>
398
+ <div class="prompt-preview-overlay">
399
+ <div class="prompt-edit-hint">
400
+ <!-- 移除重复的编辑图标 -->
401
+ </div>
402
+ </div>
403
+ </div>
404
+ </div>
405
+ <textarea id="systemPrompt" class="hidden">您是一位专业的问题解决专家。请逐步分析问题,找出问题所在,并提供详细的解决方案。始终使用用户偏好的语言回答。</textarea>
406
+ </div>
407
+ </div>
408
+
409
+ <!-- OCR设置部分 -->
410
+ <div class="settings-section ocr-settings">
411
+ <h3><i class="fas fa-font"></i> OCR 源设置</h3>
412
+ <div class="setting-group">
413
+ <div class="ocr-source-control">
414
+ <div class="ocr-source-selector">
415
+ <select id="ocrSourceSelect" class="ocr-source-select">
416
+ <option value="auto">自动选择</option>
417
+ <option value="baidu">百度OCR</option>
418
+ <option value="mathpix">Mathpix</option>
419
+ </select>
420
+ </div>
421
+ </div>
422
+ </div>
423
+ </div>
424
+
425
+ <!-- 2. 所有API密钥集中在一个区域 -->
426
+ <div class="settings-section api-key-settings">
427
+ <h3><i class="fas fa-key"></i> API密钥设置</h3>
428
+
429
+ <!-- API密钥状态显示与编辑区域 -->
430
+ <div class="api-keys-list" id="apiKeysList">
431
+ <div class="api-key-status">
432
+ <span class="key-name">Anthropic API:</span>
433
+ <div class="key-status-wrapper">
434
+ <!-- 显示状态 -->
435
+ <div class="key-display">
436
+ <span id="AnthropicApiKeyStatus" class="key-status" data-key="AnthropicApiKey">未设置</span>
437
+ <button class="btn-icon edit-api-key" data-key-type="AnthropicApiKey" title="编辑此密钥">
438
+ <i class="fas fa-edit"></i>
439
+ </button>
440
+ </div>
441
+ <!-- 编辑状态 -->
442
+ <div class="key-edit hidden">
443
+ <input type="password" class="key-input" data-key-type="AnthropicApiKey" placeholder="输入 Anthropic API key">
444
+ <button class="btn-icon toggle-visibility">
445
+ <i class="fas fa-eye"></i>
446
+ </button>
447
+ <button class="btn-icon save-api-key" data-key-type="AnthropicApiKey" title="保存密钥">
448
+ <i class="fas fa-save"></i>
449
+ </button>
450
+ </div>
451
+ </div>
452
+ </div>
453
+ <div class="api-key-status">
454
+ <span class="key-name">OpenAI API:</span>
455
+ <div class="key-status-wrapper">
456
+ <!-- 显示状态 -->
457
+ <div class="key-display">
458
+ <span id="OpenaiApiKeyStatus" class="key-status" data-key="OpenaiApiKey">未设置</span>
459
+ <button class="btn-icon edit-api-key" data-key-type="OpenaiApiKey" title="编辑此密钥">
460
+ <i class="fas fa-edit"></i>
461
+ </button>
462
+ </div>
463
+ <!-- 编辑状态 -->
464
+ <div class="key-edit hidden">
465
+ <input type="password" class="key-input" data-key-type="OpenaiApiKey" placeholder="输入 OpenAI API key">
466
+ <button class="btn-icon toggle-visibility">
467
+ <i class="fas fa-eye"></i>
468
+ </button>
469
+ <button class="btn-icon save-api-key" data-key-type="OpenaiApiKey" title="保存密钥">
470
+ <i class="fas fa-save"></i>
471
+ </button>
472
+ </div>
473
+ </div>
474
+ </div>
475
+ <div class="api-key-status">
476
+ <span class="key-name">DeepSeek API:</span>
477
+ <div class="key-status-wrapper">
478
+ <!-- 显示状态 -->
479
+ <div class="key-display">
480
+ <span id="DeepseekApiKeyStatus" class="key-status" data-key="DeepseekApiKey">未设置</span>
481
+ <button class="btn-icon edit-api-key" data-key-type="DeepseekApiKey" title="编辑此密钥">
482
+ <i class="fas fa-edit"></i>
483
+ </button>
484
+ </div>
485
+ <!-- 编辑状态 -->
486
+ <div class="key-edit hidden">
487
+ <input type="password" class="key-input" data-key-type="DeepseekApiKey" placeholder="输入 DeepSeek API key">
488
+ <button class="btn-icon toggle-visibility">
489
+ <i class="fas fa-eye"></i>
490
+ </button>
491
+ <button class="btn-icon save-api-key" data-key-type="DeepseekApiKey" title="保存密钥">
492
+ <i class="fas fa-save"></i>
493
+ </button>
494
+ </div>
495
+ </div>
496
+ </div>
497
+ <div class="api-key-status">
498
+ <span class="key-name">Alibaba API:</span>
499
+ <div class="key-status-wrapper">
500
+ <!-- 显示状态 -->
501
+ <div class="key-display">
502
+ <span id="AlibabaApiKeyStatus" class="key-status" data-key="AlibabaApiKey">未设置</span>
503
+ <button class="btn-icon edit-api-key" data-key-type="AlibabaApiKey" title="编辑此密钥">
504
+ <i class="fas fa-edit"></i>
505
+ </button>
506
+ </div>
507
+ <!-- 编辑状态 -->
508
+ <div class="key-edit hidden">
509
+ <input type="password" class="key-input" data-key-type="AlibabaApiKey" placeholder="输入 Alibaba API key">
510
+ <button class="btn-icon toggle-visibility">
511
+ <i class="fas fa-eye"></i>
512
+ </button>
513
+ <button class="btn-icon save-api-key" data-key-type="AlibabaApiKey" title="保存密钥">
514
+ <i class="fas fa-save"></i>
515
+ </button>
516
+ </div>
517
+ </div>
518
+ </div>
519
+ <div class="api-key-status">
520
+ <span class="key-name">Google API:</span>
521
+ <div class="key-status-wrapper">
522
+ <!-- 显示状态 -->
523
+ <div class="key-display">
524
+ <span id="GoogleApiKeyStatus" class="key-status" data-key="GoogleApiKey">未设置</span>
525
+ <button class="btn-icon edit-api-key" data-key-type="GoogleApiKey" title="编辑此密钥">
526
+ <i class="fas fa-edit"></i>
527
+ </button>
528
+ </div>
529
+ <!-- 编辑状态 -->
530
+ <div class="key-edit hidden">
531
+ <input type="password" class="key-input" data-key-type="GoogleApiKey" placeholder="输入 Google API key">
532
+ <button class="btn-icon toggle-visibility">
533
+ <i class="fas fa-eye"></i>
534
+ </button>
535
+ <button class="btn-icon save-api-key" data-key-type="GoogleApiKey" title="保存密钥">
536
+ <i class="fas fa-save"></i>
537
+ </button>
538
+ </div>
539
+ </div>
540
+ </div>
541
+ <div class="api-key-status">
542
+ <span class="key-name">Doubao API:</span>
543
+ <div class="key-status-wrapper">
544
+ <!-- 显示状态 -->
545
+ <div class="key-display">
546
+ <span id="DoubaoApiKeyStatus" class="key-status" data-key="DoubaoApiKey">未设置</span>
547
+ <button class="btn-icon edit-api-key" data-key-type="DoubaoApiKey" title="编辑此密钥">
548
+ <i class="fas fa-edit"></i>
549
+ </button>
550
+ </div>
551
+ <!-- 编辑状态 -->
552
+ <div class="key-edit hidden">
553
+ <input type="password" class="key-input" data-key-type="DoubaoApiKey" placeholder="输入Doubao API key">
554
+ <button class="btn-icon toggle-visibility">
555
+ <i class="fas fa-eye"></i>
556
+ </button>
557
+ <button class="btn-icon save-api-key" data-key-type="DoubaoApiKey" title="保存密钥">
558
+ <i class="fas fa-save"></i>
559
+ </button>
560
+ </div>
561
+ </div>
562
+ </div>
563
+
564
+ <!-- 百度OCR API Key配置 -->
565
+ <div class="api-key-status">
566
+ <span class="key-name">百度OCR API Key:</span>
567
+ <div class="key-status-wrapper">
568
+ <!-- 显示状态 -->
569
+ <div class="key-display">
570
+ <span id="BaiduApiKeyStatus" class="key-status" data-key="BaiduApiKey">未设置</span>
571
+ <button class="btn-icon edit-api-key" data-key-type="BaiduApiKey" title="编辑此密钥">
572
+ <i class="fas fa-edit"></i>
573
+ </button>
574
+ </div>
575
+ <!-- 编辑状态 -->
576
+ <div class="key-edit hidden">
577
+ <input type="password" class="key-input" data-key-type="BaiduApiKey" placeholder="输入百度OCR API Key">
578
+ <button class="btn-icon toggle-visibility">
579
+ <i class="fas fa-eye"></i>
580
+ </button>
581
+ <button class="btn-icon save-api-key" data-key-type="BaiduApiKey" title="保存密钥">
582
+ <i class="fas fa-save"></i>
583
+ </button>
584
+ </div>
585
+ </div>
586
+ </div>
587
+ <div class="api-key-status">
588
+ <span class="key-name">百度OCR Secret Key:</span>
589
+ <div class="key-status-wrapper">
590
+ <!-- 显示状态 -->
591
+ <div class="key-display">
592
+ <span id="BaiduSecretKeyStatus" class="key-status" data-key="BaiduSecretKey">未设置</span>
593
+ <button class="btn-icon edit-api-key" data-key-type="BaiduSecretKey" title="编辑此密钥">
594
+ <i class="fas fa-edit"></i>
595
+ </button>
596
+ </div>
597
+ <!-- 编辑状态 -->
598
+ <div class="key-edit hidden">
599
+ <input type="password" class="key-input" data-key-type="BaiduSecretKey" placeholder="输入百度OCR Secret Key">
600
+ <button class="btn-icon toggle-visibility">
601
+ <i class="fas fa-eye"></i>
602
+ </button>
603
+ <button class="btn-icon save-api-key" data-key-type="BaiduSecretKey" title="保存密钥">
604
+ <i class="fas fa-save"></i>
605
+ </button>
606
+ </div>
607
+ </div>
608
+ </div>
609
+
610
+ <div class="api-key-status">
611
+ <span class="key-name">Mathpix App ID:</span>
612
+ <div class="key-status-wrapper">
613
+ <!-- 显示状态 -->
614
+ <div class="key-display">
615
+ <span id="MathpixAppIdStatus" class="key-status" data-key="MathpixAppId">未设置</span>
616
+ <button class="btn-icon edit-api-key" data-key-type="MathpixAppId" title="编辑此密钥">
617
+ <i class="fas fa-edit"></i>
618
+ </button>
619
+ </div>
620
+ <!-- 编辑状态 -->
621
+ <div class="key-edit hidden">
622
+ <input type="password" class="key-input" data-key-type="MathpixAppId" placeholder="输入 Mathpix App ID">
623
+ <button class="btn-icon toggle-visibility">
624
+ <i class="fas fa-eye"></i>
625
+ </button>
626
+ <button class="btn-icon save-api-key" data-key-type="MathpixAppId" title="保存密钥">
627
+ <i class="fas fa-save"></i>
628
+ </button>
629
+ </div>
630
+ </div>
631
+ </div>
632
+ <div class="api-key-status">
633
+ <span class="key-name">Mathpix App Key:</span>
634
+ <div class="key-status-wrapper">
635
+ <!-- 显示状态 -->
636
+ <div class="key-display">
637
+ <span id="MathpixAppKeyStatus" class="key-status" data-key="MathpixAppKey">未设置</span>
638
+ <button class="btn-icon edit-api-key" data-key-type="MathpixAppKey" title="编辑此密钥">
639
+ <i class="fas fa-edit"></i>
640
+ </button>
641
+ </div>
642
+ <!-- 编辑状态 -->
643
+ <div class="key-edit hidden">
644
+ <input type="password" class="key-input" data-key-type="MathpixAppKey" placeholder="输入 Mathpix App Key">
645
+ <button class="btn-icon toggle-visibility">
646
+ <i class="fas fa-eye"></i>
647
+ </button>
648
+ <button class="btn-icon save-api-key" data-key-type="MathpixAppKey" title="保存密钥">
649
+ <i class="fas fa-save"></i>
650
+ </button>
651
+ </div>
652
+ </div>
653
+ </div>
654
+ </div>
655
+ </div>
656
+
657
+ <!-- 添加中转 API url 设置区域 -->
658
+ <div class="settings-section api-url-settings">
659
+ <h3><i class="fas fa-link"></i> 中转 API url 设置</h3>
660
+ <div class="setting-group">
661
+ <div class="api-keys-list" id="apiBaseUrlsList">
662
+ <div class="api-key-status">
663
+ <span class="key-name">Anthropic API URL:</span>
664
+ <div class="key-status-wrapper">
665
+ <!-- 显示状态 -->
666
+ <div class="key-display">
667
+ <span id="AnthropicApiBaseUrlStatus" class="key-status" data-key="AnthropicApiBaseUrl">未设置</span>
668
+ <button class="btn-icon edit-api-base-url" data-key-type="AnthropicApiBaseUrl" title="编辑此URL">
669
+ <i class="fas fa-edit"></i>
670
+ </button>
671
+ </div>
672
+ <!-- 编辑状态 -->
673
+ <div class="key-edit hidden">
674
+ <input type="text" class="key-input" data-key-type="AnthropicApiBaseUrl" placeholder="https://api.anthropic.com/v1">
675
+ <button class="btn-icon save-api-base-url" data-key-type="AnthropicApiBaseUrl" title="保存URL">
676
+ <i class="fas fa-save"></i>
677
+ </button>
678
+ </div>
679
+ </div>
680
+ </div>
681
+ <div class="api-key-status">
682
+ <span class="key-name">OpenAI API URL:</span>
683
+ <div class="key-status-wrapper">
684
+ <!-- 显示状态 -->
685
+ <div class="key-display">
686
+ <span id="OpenaiApiBaseUrlStatus" class="key-status" data-key="OpenaiApiBaseUrl">未设置</span>
687
+ <button class="btn-icon edit-api-base-url" data-key-type="OpenaiApiBaseUrl" title="编辑此URL">
688
+ <i class="fas fa-edit"></i>
689
+ </button>
690
+ </div>
691
+ <!-- 编辑状态 -->
692
+ <div class="key-edit hidden">
693
+ <input type="text" class="key-input" data-key-type="OpenaiApiBaseUrl" placeholder="https://api.openai.com/v1">
694
+ <button class="btn-icon save-api-base-url" data-key-type="OpenaiApiBaseUrl" title="保存URL">
695
+ <i class="fas fa-save"></i>
696
+ </button>
697
+ </div>
698
+ </div>
699
+ </div>
700
+ <div class="api-key-status">
701
+ <span class="key-name">DeepSeek API URL:</span>
702
+ <div class="key-status-wrapper">
703
+ <!-- 显示状态 -->
704
+ <div class="key-display">
705
+ <span id="DeepseekApiBaseUrlStatus" class="key-status" data-key="DeepseekApiBaseUrl">未设置</span>
706
+ <button class="btn-icon edit-api-base-url" data-key-type="DeepseekApiBaseUrl" title="编辑此URL">
707
+ <i class="fas fa-edit"></i>
708
+ </button>
709
+ </div>
710
+ <!-- 编辑状态 -->
711
+ <div class="key-edit hidden">
712
+ <input type="text" class="key-input" data-key-type="DeepseekApiBaseUrl" placeholder="https://api.deepseek.com/v1">
713
+ <button class="btn-icon save-api-base-url" data-key-type="DeepseekApiBaseUrl" title="保存URL">
714
+ <i class="fas fa-save"></i>
715
+ </button>
716
+ </div>
717
+ </div>
718
+ </div>
719
+ <div class="api-key-status">
720
+ <span class="key-name">Alibaba API URL:</span>
721
+ <div class="key-status-wrapper">
722
+ <!-- 显示状态 -->
723
+ <div class="key-display">
724
+ <span id="AlibabaApiBaseUrlStatus" class="key-status" data-key="AlibabaApiBaseUrl">未设置</span>
725
+ <button class="btn-icon edit-api-base-url" data-key-type="AlibabaApiBaseUrl" title="编辑此URL">
726
+ <i class="fas fa-edit"></i>
727
+ </button>
728
+ </div>
729
+ <!-- 编辑状态 -->
730
+ <div class="key-edit hidden">
731
+ <input type="text" class="key-input" data-key-type="AlibabaApiBaseUrl" placeholder="https://dashscope.aliyuncs.com/api/v1">
732
+ <button class="btn-icon save-api-base-url" data-key-type="AlibabaApiBaseUrl" title="保存URL">
733
+ <i class="fas fa-save"></i>
734
+ </button>
735
+ </div>
736
+ </div>
737
+ </div>
738
+ <div class="api-key-status">
739
+ <span class="key-name">Google API URL:</span>
740
+ <div class="key-status-wrapper">
741
+ <!-- 显示状态 -->
742
+ <div class="key-display">
743
+ <span id="GoogleApiBaseUrlStatus" class="key-status" data-key="GoogleApiBaseUrl">未设置</span>
744
+ <button class="btn-icon edit-api-base-url" data-key-type="GoogleApiBaseUrl" title="编辑此URL">
745
+ <i class="fas fa-edit"></i>
746
+ </button>
747
+ </div>
748
+ <!-- 编辑状态 -->
749
+ <div class="key-edit hidden">
750
+ <input type="text" class="key-input" data-key-type="GoogleApiBaseUrl" placeholder="https://generativelanguage.googleapis.com/v1beta">
751
+ <button class="btn-icon save-api-base-url" data-key-type="GoogleApiBaseUrl" title="保存URL">
752
+ <i class="fas fa-save"></i>
753
+ </button>
754
+ </div>
755
+ </div>
756
+ </div>
757
+ <div class="api-key-status">
758
+ <span class="key-name">Doubao API URL:</span>
759
+ <div class="key-status-wrapper">
760
+ <!-- 显示状态 -->
761
+ <div class="key-display">
762
+ <span id="DoubaoApiBaseUrlStatus" class="key-status" data-key="DoubaoApiBaseUrl">未设置</span>
763
+ <button class="btn-icon edit-api-base-url" data-key-type="DoubaoApiBaseUrl" title="编辑此URL">
764
+ <i class="fas fa-edit"></i>
765
+ </button>
766
+ </div>
767
+ <!-- 编辑状态 -->
768
+ <div class="key-edit hidden">
769
+ <input type="text" class="key-input" data-key-type="DoubaoApiBaseUrl" placeholder="https://ark.cn-beijing.volces.com/api/v3">
770
+ <button class="btn-icon save-api-base-url" data-key-type="DoubaoApiBaseUrl" title="保存URL">
771
+ <i class="fas fa-save"></i>
772
+ </button>
773
+ </div>
774
+ </div>
775
+ </div>
776
+ </div>
777
+ </div>
778
+ </div>
779
+ </div>
780
+
781
+ <!-- 3. 不常用的其他设置放在后面 -->
782
+ <div class="settings-section proxy-settings-section">
783
+ <h3><i class="fas fa-cog"></i> 其他设置</h3>
784
+ <div class="setting-group">
785
+ <label for="language"><i class="fas fa-language"></i> 语言</label>
786
+ <input type="text" id="language" value="中文" placeholder="输入首选语言">
787
+ </div>
788
+ <div class="setting-group">
789
+ <label class="checkbox-label">
790
+ <input type="checkbox" id="proxyEnabled">
791
+ <span>启用 VPN 代理</span>
792
+ </label>
793
+ </div>
794
+ <div id="proxySettings" class="proxy-settings">
795
+ <div class="setting-group">
796
+ <label for="proxyHost"><i class="fas fa-server"></i> 代理主机</label>
797
+ <input type="text" id="proxyHost" value="127.0.0.1" placeholder="输入代理主机">
798
+ </div>
799
+ <div class="setting-group">
800
+ <label for="proxyPort"><i class="fas fa-plug"></i> 代理端口</label>
801
+ <input type="number" id="proxyPort" value="4780" placeholder="输入代理端口">
802
+ </div>
803
+ </div>
804
+ </div>
805
+ </div>
806
+ </aside>
807
+ </main>
808
+
809
+ <div id="cropContainer" class="crop-container hidden">
810
+ <div class="crop-actions crop-actions-top">
811
+ <button id="cropCancel" class="btn-secondary">
812
+ <i class="fas fa-times"></i>
813
+ <span>取消</span>
814
+ </button>
815
+ <div class="crop-bottom-buttons">
816
+ <button id="cropReset" class="btn-secondary crop-half-btn">
817
+ <i class="fas fa-undo"></i>
818
+ <span>重置</span>
819
+ </button>
820
+ <button id="cropConfirm" class="btn-secondary crop-half-btn">
821
+ <i class="fas fa-check"></i>
822
+ <span>确认</span>
823
+ </button>
824
+ </div>
825
+ <button id="cropSendToAI" class="btn-primary">
826
+ <i class="fas fa-paper-plane"></i>
827
+ <span>发送</span>
828
+ </button>
829
+ </div>
830
+ <div class="crop-wrapper">
831
+ <div class="crop-area"></div>
832
+ </div>
833
+ </div>
834
+
835
+ <div id="toastContainer" class="toast-container"></div>
836
+
837
+ <footer class="app-footer">
838
+ <div class="footer-content">
839
+ <div class="footer-text">
840
+ <span>© 2024 Snap-Solver</span>
841
+ </div>
842
+ <div class="footer-links">
843
+ <a href="https://github.com/Zippland/Snap-Solver/" target="_blank" class="footer-link">
844
+ <span class="star-icon">⭐</span>
845
+ <span>GitHub</span>
846
+ </a>
847
+ <a href="https://www.xiaohongshu.com/user/profile/623e8b080000000010007721?xsec_token=YBdeHZTp_aVwi1Ijmras5CgQC6pxlpd4RmozT8Hr_-NCA%3D&xsec_source=app_share&xhsshare=CopyLink&appuid=623e8b080000000010007721&apptime=1742201089&share_id=a2704ab48e2c4e1aa321ce63168811b5&share_channel=copy_link" target="_blank" class="footer-link xiaohongshu-link">
848
+ <i class="fas fa-book"></i>
849
+ <span>小红书</span>
850
+ </a>
851
+ </div>
852
+ </div>
853
+ </footer>
854
+
855
+ <!-- 提示词对话框 -->
856
+ <div class="dialog-overlay" id="promptDialogOverlay"></div>
857
+ <div class="prompt-dialog" id="promptDialog">
858
+ <h3>添加/编辑提示词</h3>
859
+ <div class="form-group">
860
+ <label for="promptId">提示词ID</label>
861
+ <input type="text" id="promptId" placeholder="英文字母和下划线,如math_problems">
862
+ </div>
863
+ <div class="form-group">
864
+ <label for="promptName">名称</label>
865
+ <input type="text" id="promptName" placeholder="提示词名称">
866
+ </div>
867
+ <div class="form-group">
868
+ <label for="promptContent">内容</label>
869
+ <textarea id="promptContent" placeholder="输入提示词内容..."></textarea>
870
+ </div>
871
+ <div class="form-group">
872
+ <label for="promptDescriptionEdit">描述(可选)</label>
873
+ <input type="text" id="promptDescriptionEdit" placeholder="简短描述">
874
+ </div>
875
+ <div class="dialog-buttons">
876
+ <button class="cancel-btn" id="cancelPromptBtn">取消</button>
877
+ <button class="save-btn" id="confirmPromptBtn">保存</button>
878
+ </div>
879
+ </div>
880
+
881
+ <!-- 确保按照正确的顺序加载脚本 -->
882
+ <!-- 先加载UI管理器,确保它能在DOM加载完成后初始化 -->
883
+ <script src="{{ url_for('static', filename='js/ui.js') }}"></script>
884
+ <!-- 然后加载设置管理器,它依赖UI管理器 -->
885
+ <script src="{{ url_for('static', filename='js/settings.js') }}"></script>
886
+ <!-- 最后加载主应用逻辑 -->
887
+ <script src="{{ url_for('static', filename='js/main.js') }}"></script>
888
+
889
+ <!-- 更新检查初始化 -->
890
+ <script>
891
+ document.addEventListener('DOMContentLoaded', function() {
892
+ // 初始化更新检查
893
+ try {
894
+ const updateInfo = JSON.parse('{{ update_info|tojson|safe }}');
895
+ if (updateInfo && updateInfo.has_update) {
896
+ showUpdateNotice(updateInfo);
897
+ }
898
+
899
+ // 24小时后再次检查更新
900
+ setTimeout(checkForUpdates, 24 * 60 * 60 * 1000);
901
+ } catch (error) {
902
+ console.error('更新检查初始化失败:', error);
903
+ }
904
+ });
905
+
906
+ function showUpdateNotice(updateInfo) {
907
+ const updateNotice = document.getElementById('updateNotice');
908
+ const updateVersion = document.getElementById('updateVersion');
909
+ const updateLink = document.getElementById('updateLink');
910
+
911
+ if (updateInfo.latest_version) {
912
+ updateVersion.textContent = updateInfo.latest_version;
913
+ }
914
+
915
+ if (updateInfo.release_url) {
916
+ updateLink.href = updateInfo.release_url;
917
+ } else {
918
+ updateLink.href = 'https://github.com/Zippland/Snap-Solver/releases/latest';
919
+ }
920
+
921
+ updateNotice.classList.remove('hidden');
922
+
923
+ // 绑定关闭按钮事件
924
+ document.getElementById('closeUpdateNotice').addEventListener('click', function() {
925
+ updateNotice.classList.add('hidden');
926
+ // 记住用户已关闭此版本的通知
927
+ localStorage.setItem('dismissedUpdate', updateInfo.latest_version);
928
+ });
929
+ }
930
+
931
+ function checkForUpdates() {
932
+ fetch('/api/check-update')
933
+ .then(function(response) { return response.json(); })
934
+ .then(function(updateInfo) {
935
+ const dismissedVersion = localStorage.getItem('dismissedUpdate');
936
+
937
+ // 只有当有更新且用户没有关闭过此版本的通知时才显示
938
+ if (updateInfo.has_update && dismissedVersion !== updateInfo.latest_version) {
939
+ showUpdateNotice(updateInfo);
940
+ }
941
+ })
942
+ .catch(function(error) { console.error('检查更新失败:', error); });
943
+ }
944
+ </script>
945
+ </body>
946
+ </html>
tips.csv DELETED
@@ -1,245 +0,0 @@
1
- total_bill,tip,sex,smoker,day,time,size
2
- 16.99,1.01,Female,No,Sun,Dinner,2
3
- 10.34,1.66,Male,No,Sun,Dinner,3
4
- 21.01,3.5,Male,No,Sun,Dinner,3
5
- 23.68,3.31,Male,No,Sun,Dinner,2
6
- 24.59,3.61,Female,No,Sun,Dinner,4
7
- 25.29,4.71,Male,No,Sun,Dinner,4
8
- 8.77,2.0,Male,No,Sun,Dinner,2
9
- 26.88,3.12,Male,No,Sun,Dinner,4
10
- 15.04,1.96,Male,No,Sun,Dinner,2
11
- 14.78,3.23,Male,No,Sun,Dinner,2
12
- 10.27,1.71,Male,No,Sun,Dinner,2
13
- 35.26,5.0,Female,No,Sun,Dinner,4
14
- 15.42,1.57,Male,No,Sun,Dinner,2
15
- 18.43,3.0,Male,No,Sun,Dinner,4
16
- 14.83,3.02,Female,No,Sun,Dinner,2
17
- 21.58,3.92,Male,No,Sun,Dinner,2
18
- 10.33,1.67,Female,No,Sun,Dinner,3
19
- 16.29,3.71,Male,No,Sun,Dinner,3
20
- 16.97,3.5,Female,No,Sun,Dinner,3
21
- 20.65,3.35,Male,No,Sat,Dinner,3
22
- 17.92,4.08,Male,No,Sat,Dinner,2
23
- 20.29,2.75,Female,No,Sat,Dinner,2
24
- 15.77,2.23,Female,No,Sat,Dinner,2
25
- 39.42,7.58,Male,No,Sat,Dinner,4
26
- 19.82,3.18,Male,No,Sat,Dinner,2
27
- 17.81,2.34,Male,No,Sat,Dinner,4
28
- 13.37,2.0,Male,No,Sat,Dinner,2
29
- 12.69,2.0,Male,No,Sat,Dinner,2
30
- 21.7,4.3,Male,No,Sat,Dinner,2
31
- 19.65,3.0,Female,No,Sat,Dinner,2
32
- 9.55,1.45,Male,No,Sat,Dinner,2
33
- 18.35,2.5,Male,No,Sat,Dinner,4
34
- 15.06,3.0,Female,No,Sat,Dinner,2
35
- 20.69,2.45,Female,No,Sat,Dinner,4
36
- 17.78,3.27,Male,No,Sat,Dinner,2
37
- 24.06,3.6,Male,No,Sat,Dinner,3
38
- 16.31,2.0,Male,No,Sat,Dinner,3
39
- 16.93,3.07,Female,No,Sat,Dinner,3
40
- 18.69,2.31,Male,No,Sat,Dinner,3
41
- 31.27,5.0,Male,No,Sat,Dinner,3
42
- 16.04,2.24,Male,No,Sat,Dinner,3
43
- 17.46,2.54,Male,No,Sun,Dinner,2
44
- 13.94,3.06,Male,No,Sun,Dinner,2
45
- 9.68,1.32,Male,No,Sun,Dinner,2
46
- 30.4,5.6,Male,No,Sun,Dinner,4
47
- 18.29,3.0,Male,No,Sun,Dinner,2
48
- 22.23,5.0,Male,No,Sun,Dinner,2
49
- 32.4,6.0,Male,No,Sun,Dinner,4
50
- 28.55,2.05,Male,No,Sun,Dinner,3
51
- 18.04,3.0,Male,No,Sun,Dinner,2
52
- 12.54,2.5,Male,No,Sun,Dinner,2
53
- 10.29,2.6,Female,No,Sun,Dinner,2
54
- 34.81,5.2,Female,No,Sun,Dinner,4
55
- 9.94,1.56,Male,No,Sun,Dinner,2
56
- 25.56,4.34,Male,No,Sun,Dinner,4
57
- 19.49,3.51,Male,No,Sun,Dinner,2
58
- 38.01,3.0,Male,Yes,Sat,Dinner,4
59
- 26.41,1.5,Female,No,Sat,Dinner,2
60
- 11.24,1.76,Male,Yes,Sat,Dinner,2
61
- 48.27,6.73,Male,No,Sat,Dinner,4
62
- 20.29,3.21,Male,Yes,Sat,Dinner,2
63
- 13.81,2.0,Male,Yes,Sat,Dinner,2
64
- 11.02,1.98,Male,Yes,Sat,Dinner,2
65
- 18.29,3.76,Male,Yes,Sat,Dinner,4
66
- 17.59,2.64,Male,No,Sat,Dinner,3
67
- 20.08,3.15,Male,No,Sat,Dinner,3
68
- 16.45,2.47,Female,No,Sat,Dinner,2
69
- 3.07,1.0,Female,Yes,Sat,Dinner,1
70
- 20.23,2.01,Male,No,Sat,Dinner,2
71
- 15.01,2.09,Male,Yes,Sat,Dinner,2
72
- 12.02,1.97,Male,No,Sat,Dinner,2
73
- 17.07,3.0,Female,No,Sat,Dinner,3
74
- 26.86,3.14,Female,Yes,Sat,Dinner,2
75
- 25.28,5.0,Female,Yes,Sat,Dinner,2
76
- 14.73,2.2,Female,No,Sat,Dinner,2
77
- 10.51,1.25,Male,No,Sat,Dinner,2
78
- 17.92,3.08,Male,Yes,Sat,Dinner,2
79
- 27.2,4.0,Male,No,Thur,Lunch,4
80
- 22.76,3.0,Male,No,Thur,Lunch,2
81
- 17.29,2.71,Male,No,Thur,Lunch,2
82
- 19.44,3.0,Male,Yes,Thur,Lunch,2
83
- 16.66,3.4,Male,No,Thur,Lunch,2
84
- 10.07,1.83,Female,No,Thur,Lunch,1
85
- 32.68,5.0,Male,Yes,Thur,Lunch,2
86
- 15.98,2.03,Male,No,Thur,Lunch,2
87
- 34.83,5.17,Female,No,Thur,Lunch,4
88
- 13.03,2.0,Male,No,Thur,Lunch,2
89
- 18.28,4.0,Male,No,Thur,Lunch,2
90
- 24.71,5.85,Male,No,Thur,Lunch,2
91
- 21.16,3.0,Male,No,Thur,Lunch,2
92
- 28.97,3.0,Male,Yes,Fri,Dinner,2
93
- 22.49,3.5,Male,No,Fri,Dinner,2
94
- 5.75,1.0,Female,Yes,Fri,Dinner,2
95
- 16.32,4.3,Female,Yes,Fri,Dinner,2
96
- 22.75,3.25,Female,No,Fri,Dinner,2
97
- 40.17,4.73,Male,Yes,Fri,Dinner,4
98
- 27.28,4.0,Male,Yes,Fri,Dinner,2
99
- 12.03,1.5,Male,Yes,Fri,Dinner,2
100
- 21.01,3.0,Male,Yes,Fri,Dinner,2
101
- 12.46,1.5,Male,No,Fri,Dinner,2
102
- 11.35,2.5,Female,Yes,Fri,Dinner,2
103
- 15.38,3.0,Female,Yes,Fri,Dinner,2
104
- 44.3,2.5,Female,Yes,Sat,Dinner,3
105
- 22.42,3.48,Female,Yes,Sat,Dinner,2
106
- 20.92,4.08,Female,No,Sat,Dinner,2
107
- 15.36,1.64,Male,Yes,Sat,Dinner,2
108
- 20.49,4.06,Male,Yes,Sat,Dinner,2
109
- 25.21,4.29,Male,Yes,Sat,Dinner,2
110
- 18.24,3.76,Male,No,Sat,Dinner,2
111
- 14.31,4.0,Female,Yes,Sat,Dinner,2
112
- 14.0,3.0,Male,No,Sat,Dinner,2
113
- 7.25,1.0,Female,No,Sat,Dinner,1
114
- 38.07,4.0,Male,No,Sun,Dinner,3
115
- 23.95,2.55,Male,No,Sun,Dinner,2
116
- 25.71,4.0,Female,No,Sun,Dinner,3
117
- 17.31,3.5,Female,No,Sun,Dinner,2
118
- 29.93,5.07,Male,No,Sun,Dinner,4
119
- 10.65,1.5,Female,No,Thur,Lunch,2
120
- 12.43,1.8,Female,No,Thur,Lunch,2
121
- 24.08,2.92,Female,No,Thur,Lunch,4
122
- 11.69,2.31,Male,No,Thur,Lunch,2
123
- 13.42,1.68,Female,No,Thur,Lunch,2
124
- 14.26,2.5,Male,No,Thur,Lunch,2
125
- 15.95,2.0,Male,No,Thur,Lunch,2
126
- 12.48,2.52,Female,No,Thur,Lunch,2
127
- 29.8,4.2,Female,No,Thur,Lunch,6
128
- 8.52,1.48,Male,No,Thur,Lunch,2
129
- 14.52,2.0,Female,No,Thur,Lunch,2
130
- 11.38,2.0,Female,No,Thur,Lunch,2
131
- 22.82,2.18,Male,No,Thur,Lunch,3
132
- 19.08,1.5,Male,No,Thur,Lunch,2
133
- 20.27,2.83,Female,No,Thur,Lunch,2
134
- 11.17,1.5,Female,No,Thur,Lunch,2
135
- 12.26,2.0,Female,No,Thur,Lunch,2
136
- 18.26,3.25,Female,No,Thur,Lunch,2
137
- 8.51,1.25,Female,No,Thur,Lunch,2
138
- 10.33,2.0,Female,No,Thur,Lunch,2
139
- 14.15,2.0,Female,No,Thur,Lunch,2
140
- 16.0,2.0,Male,Yes,Thur,Lunch,2
141
- 13.16,2.75,Female,No,Thur,Lunch,2
142
- 17.47,3.5,Female,No,Thur,Lunch,2
143
- 34.3,6.7,Male,No,Thur,Lunch,6
144
- 41.19,5.0,Male,No,Thur,Lunch,5
145
- 27.05,5.0,Female,No,Thur,Lunch,6
146
- 16.43,2.3,Female,No,Thur,Lunch,2
147
- 8.35,1.5,Female,No,Thur,Lunch,2
148
- 18.64,1.36,Female,No,Thur,Lunch,3
149
- 11.87,1.63,Female,No,Thur,Lunch,2
150
- 9.78,1.73,Male,No,Thur,Lunch,2
151
- 7.51,2.0,Male,No,Thur,Lunch,2
152
- 14.07,2.5,Male,No,Sun,Dinner,2
153
- 13.13,2.0,Male,No,Sun,Dinner,2
154
- 17.26,2.74,Male,No,Sun,Dinner,3
155
- 24.55,2.0,Male,No,Sun,Dinner,4
156
- 19.77,2.0,Male,No,Sun,Dinner,4
157
- 29.85,5.14,Female,No,Sun,Dinner,5
158
- 48.17,5.0,Male,No,Sun,Dinner,6
159
- 25.0,3.75,Female,No,Sun,Dinner,4
160
- 13.39,2.61,Female,No,Sun,Dinner,2
161
- 16.49,2.0,Male,No,Sun,Dinner,4
162
- 21.5,3.5,Male,No,Sun,Dinner,4
163
- 12.66,2.5,Male,No,Sun,Dinner,2
164
- 16.21,2.0,Female,No,Sun,Dinner,3
165
- 13.81,2.0,Male,No,Sun,Dinner,2
166
- 17.51,3.0,Female,Yes,Sun,Dinner,2
167
- 24.52,3.48,Male,No,Sun,Dinner,3
168
- 20.76,2.24,Male,No,Sun,Dinner,2
169
- 31.71,4.5,Male,No,Sun,Dinner,4
170
- 10.59,1.61,Female,Yes,Sat,Dinner,2
171
- 10.63,2.0,Female,Yes,Sat,Dinner,2
172
- 50.81,10.0,Male,Yes,Sat,Dinner,3
173
- 15.81,3.16,Male,Yes,Sat,Dinner,2
174
- 7.25,5.15,Male,Yes,Sun,Dinner,2
175
- 31.85,3.18,Male,Yes,Sun,Dinner,2
176
- 16.82,4.0,Male,Yes,Sun,Dinner,2
177
- 32.9,3.11,Male,Yes,Sun,Dinner,2
178
- 17.89,2.0,Male,Yes,Sun,Dinner,2
179
- 14.48,2.0,Male,Yes,Sun,Dinner,2
180
- 9.6,4.0,Female,Yes,Sun,Dinner,2
181
- 34.63,3.55,Male,Yes,Sun,Dinner,2
182
- 34.65,3.68,Male,Yes,Sun,Dinner,4
183
- 23.33,5.65,Male,Yes,Sun,Dinner,2
184
- 45.35,3.5,Male,Yes,Sun,Dinner,3
185
- 23.17,6.5,Male,Yes,Sun,Dinner,4
186
- 40.55,3.0,Male,Yes,Sun,Dinner,2
187
- 20.69,5.0,Male,No,Sun,Dinner,5
188
- 20.9,3.5,Female,Yes,Sun,Dinner,3
189
- 30.46,2.0,Male,Yes,Sun,Dinner,5
190
- 18.15,3.5,Female,Yes,Sun,Dinner,3
191
- 23.1,4.0,Male,Yes,Sun,Dinner,3
192
- 15.69,1.5,Male,Yes,Sun,Dinner,2
193
- 19.81,4.19,Female,Yes,Thur,Lunch,2
194
- 28.44,2.56,Male,Yes,Thur,Lunch,2
195
- 15.48,2.02,Male,Yes,Thur,Lunch,2
196
- 16.58,4.0,Male,Yes,Thur,Lunch,2
197
- 7.56,1.44,Male,No,Thur,Lunch,2
198
- 10.34,2.0,Male,Yes,Thur,Lunch,2
199
- 43.11,5.0,Female,Yes,Thur,Lunch,4
200
- 13.0,2.0,Female,Yes,Thur,Lunch,2
201
- 13.51,2.0,Male,Yes,Thur,Lunch,2
202
- 18.71,4.0,Male,Yes,Thur,Lunch,3
203
- 12.74,2.01,Female,Yes,Thur,Lunch,2
204
- 13.0,2.0,Female,Yes,Thur,Lunch,2
205
- 16.4,2.5,Female,Yes,Thur,Lunch,2
206
- 20.53,4.0,Male,Yes,Thur,Lunch,4
207
- 16.47,3.23,Female,Yes,Thur,Lunch,3
208
- 26.59,3.41,Male,Yes,Sat,Dinner,3
209
- 38.73,3.0,Male,Yes,Sat,Dinner,4
210
- 24.27,2.03,Male,Yes,Sat,Dinner,2
211
- 12.76,2.23,Female,Yes,Sat,Dinner,2
212
- 30.06,2.0,Male,Yes,Sat,Dinner,3
213
- 25.89,5.16,Male,Yes,Sat,Dinner,4
214
- 48.33,9.0,Male,No,Sat,Dinner,4
215
- 13.27,2.5,Female,Yes,Sat,Dinner,2
216
- 28.17,6.5,Female,Yes,Sat,Dinner,3
217
- 12.9,1.1,Female,Yes,Sat,Dinner,2
218
- 28.15,3.0,Male,Yes,Sat,Dinner,5
219
- 11.59,1.5,Male,Yes,Sat,Dinner,2
220
- 7.74,1.44,Male,Yes,Sat,Dinner,2
221
- 30.14,3.09,Female,Yes,Sat,Dinner,4
222
- 12.16,2.2,Male,Yes,Fri,Lunch,2
223
- 13.42,3.48,Female,Yes,Fri,Lunch,2
224
- 8.58,1.92,Male,Yes,Fri,Lunch,1
225
- 15.98,3.0,Female,No,Fri,Lunch,3
226
- 13.42,1.58,Male,Yes,Fri,Lunch,2
227
- 16.27,2.5,Female,Yes,Fri,Lunch,2
228
- 10.09,2.0,Female,Yes,Fri,Lunch,2
229
- 20.45,3.0,Male,No,Sat,Dinner,4
230
- 13.28,2.72,Male,No,Sat,Dinner,2
231
- 22.12,2.88,Female,Yes,Sat,Dinner,2
232
- 24.01,2.0,Male,Yes,Sat,Dinner,4
233
- 15.69,3.0,Male,Yes,Sat,Dinner,3
234
- 11.61,3.39,Male,No,Sat,Dinner,2
235
- 10.77,1.47,Male,No,Sat,Dinner,2
236
- 15.53,3.0,Male,Yes,Sat,Dinner,2
237
- 10.07,1.25,Male,No,Sat,Dinner,2
238
- 12.6,1.0,Male,Yes,Sat,Dinner,2
239
- 32.83,1.17,Male,Yes,Sat,Dinner,2
240
- 35.83,4.67,Female,No,Sat,Dinner,3
241
- 29.03,5.92,Male,No,Sat,Dinner,3
242
- 27.18,2.0,Female,Yes,Sat,Dinner,2
243
- 22.67,2.0,Male,Yes,Sat,Dinner,2
244
- 17.82,1.75,Male,No,Sat,Dinner,2
245
- 18.78,3.0,Female,No,Thur,Dinner,2