Merge pull request #19 from Cb1ock/main

添加剪贴板双向操作功能
This commit is contained in:
Zylan
2025-10-27 11:47:02 +08:00
committed by GitHub
14 changed files with 491 additions and 12 deletions

9
.gitignore vendored
View File

@@ -124,8 +124,13 @@ Thumbs.db
# Optional eslint cache
.eslintcache
# 应用特定文件
# Project specific
config/update_info.json
config/api_keys.json
.venv/
venv/
venv/
# uv
.python-version
pyproject.toml
uv.lock

22
AGENTS.md Normal file
View File

@@ -0,0 +1,22 @@
# Repository Guidelines
## Project Structure & Module Organization
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`).
## Build, Test, and Development Commands
- `python -m venv .venv && source .venv/bin/activate` sets up an isolated environment.
- `pip install -r requirements.txt` or `uv sync` installs Flask, provider SDKs, and Socket.IO.
- `python app.py` boots the development server at `http://localhost:5000` with verbose engine logs.
- `FLASK_ENV=development python app.py` enables auto-reload during active development.
## Coding Style & Naming Conventions
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.
## Testing Guidelines
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.
## Commit & Pull Request Guidelines
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.
## Configuration & Security Tips
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.

41
app.py
View File

@@ -1003,6 +1003,47 @@ def update_proxy_api():
except Exception as e:
return jsonify({"success": False, "message": f"更新中转API配置错误: {str(e)}"}), 500
@app.route('/api/clipboard', methods=['POST'])
def update_clipboard():
"""将文本复制到服务器剪贴板"""
try:
data = request.get_json(silent=True) or {}
text = data.get('text', '')
if not isinstance(text, str) or not text.strip():
return jsonify({"success": False, "message": "剪贴板内容不能为空"}), 400
# 直接尝试复制不使用is_available()检查
try:
pyperclip.copy(text)
return jsonify({"success": True})
except Exception as e:
return jsonify({"success": False, "message": f"复制到剪贴板失败: {str(e)}"}), 500
except Exception as e:
app.logger.exception("更新剪贴板时发生异常")
return jsonify({"success": False, "message": f"服务器内部错误: {str(e)}"}), 500
@app.route('/api/clipboard', methods=['GET'])
def get_clipboard():
"""从服务器剪贴板读取文本"""
try:
# 直接尝试读取不使用is_available()检查
try:
text = pyperclip.paste()
if text is None:
text = ""
return jsonify({
"success": True,
"text": text,
"message": "成功读取剪贴板内容"
})
except Exception as e:
return jsonify({"success": False, "message": f"读取剪贴板失败: {str(e)}"}), 500
except Exception as e:
app.logger.exception("读取剪贴板时发生异常")
return jsonify({"success": False, "message": f"服务器内部错误: {str(e)}"}), 500
if __name__ == '__main__':
# 尝试使用5000端口如果被占用则使用5001
port = 5000

View File

@@ -40,6 +40,22 @@
"version": "20250514",
"description": "最强大的Claude 4 Opus模型支持图像理解和深度思考过程"
},
"claude-opus-4-1-20250805": {
"name": "Claude 4.1 Opus",
"provider": "anthropic",
"supportsMultimodal": true,
"isReasoning": false,
"version": "20250805",
"description": "Claude Opus 4.1 最新标准模式,快速响应并支持多模态输入"
},
"claude-opus-4-1-20250805-thinking": {
"name": "Claude 4.1 Opus (Thinking)",
"provider": "anthropic",
"supportsMultimodal": true,
"isReasoning": true,
"version": "20250805",
"description": "Claude Opus 4.1 思考模式,启用更长思考过程以提升推理质量"
},
"claude-sonnet-4-20250514": {
"name": "Claude 4 Sonnet",
"provider": "anthropic",
@@ -56,6 +72,22 @@
"version": "2024-11-20",
"description": "OpenAI的GPT-4o模型支持图像理解"
},
"gpt-5-2025-08-07": {
"name": "GPT-5",
"provider": "openai",
"supportsMultimodal": true,
"isReasoning": true,
"version": "2025-08-07",
"description": "OpenAI旗舰级GPT-5模型支持多模态输入与高级推理"
},
"gpt-5-codex-high": {
"name": "GPT Codex High",
"provider": "openai",
"supportsMultimodal": false,
"isReasoning": true,
"version": "latest",
"description": "OpenAI高性能代码模型Codex High侧重复杂代码生成与重构"
},
"o3-mini": {
"name": "o3-mini",
"provider": "openai",
@@ -129,4 +161,4 @@
"description": "支持auto/thinking/non-thinking三种思考模式、支持多模态、256K长上下文"
}
}
}
}

View File

@@ -1,7 +1,11 @@
{
{ "ACM_hard": {
"name": "ACM编程题困难",
"content":"你是一个顶尖的算法竞赛选手 + 程序员。你的任务是接收一道 ACM / 编程题目(包含题目描述、输入输出格式、约束)并输出一份完整可运行的解法。请严格按照以下步骤:\n1. 题目复述;\n2. 复杂度与限制分析;\n3. 思路与算法设计;\n4. 伪代码 / 算法框架;\n5. 最终可运行python代码带注释\n6. 时间复杂度 / 空间复杂度总结 + 边界 / 特殊输入测试。输出格式必须包含这些部分,不得省略分析或直接跳到代码。",
"description": "专为ACM编程竞赛题设计的提示词"
},
"a_default": {
"name": "默认提示词",
"content": "您是一位专业的问题解决专家。请逐步分析问题,找出问题所在,并提供详细的解决方案。始终使用用户偏好的语言回答。",
"content": "如果给的是图片,请先识别图片上面的题目,并输出完整题干;如果给的不是图片,直接诠释一下题目。然后解决该问题,如果是编程题,请输出最终可运行代码(带注释)。",
"description": "通用问题解决提示词"
},
"single_choice": {

View File

@@ -1,11 +1,11 @@
{
"apis": {
"alibaba": "",
"anthropic": "",
"anthropic": "https://api.nuwaapi.com/v1",
"deepseek": "",
"google": "",
"openai": "",
"doubao": ""
"doubao": "",
"google": "https://api.nuwaapi.com/v1",
"openai": "https://api.nuwaapi.com/v1"
},
"enabled": true
}

View File

@@ -34,6 +34,7 @@ class ModelFactory:
if provider_id and provider_id in cls._class_map:
cls._models[model_id] = {
'class': cls._class_map[provider_id],
'provider_id': provider_id,
'is_multimodal': model_info.get('supportsMultimodal', False),
'is_reasoning': model_info.get('isReasoning', False),
'display_name': model_info.get('name', model_id),
@@ -128,6 +129,17 @@ class ModelFactory:
model_info = cls._models[model_name]
model_class = model_info['class']
provider_id = model_info.get('provider_id')
if provider_id == 'openai':
return model_class(
api_key=api_key,
temperature=temperature,
system_prompt=system_prompt,
language=language,
api_base_url=api_base_url,
model_identifier=model_name
)
# 对于DeepSeek模型需要传递正确的模型名称
if 'deepseek' in model_name.lower():

View File

@@ -4,10 +4,12 @@ from openai import OpenAI
from .base import BaseModel
class OpenAIModel(BaseModel):
def __init__(self, api_key, temperature=0.7, system_prompt=None, language=None, api_base_url=None):
def __init__(self, api_key, temperature=0.7, system_prompt=None, language=None, api_base_url=None, model_identifier=None):
super().__init__(api_key, temperature, system_prompt, language)
# 设置API基础URL默认为OpenAI官方API
self.api_base_url = api_base_url
# 允许从外部配置显式指定模型标识符
self.model_identifier = model_identifier or "gpt-4o-2024-11-20"
def get_default_system_prompt(self) -> str:
return """You are an expert at analyzing questions and providing detailed solutions. When presented with an image of a question:
@@ -18,7 +20,7 @@ class OpenAIModel(BaseModel):
5. If there are multiple approaches, explain the most efficient one first"""
def get_model_identifier(self) -> str:
return "gpt-4o-2024-11-20"
return self.model_identifier
def analyze_text(self, text: str, proxies: dict = None) -> Generator[dict, None, None]:
"""Stream GPT-4o's response for text analysis"""

View File

@@ -1,9 +1,10 @@
flask==3.1.0
pyautogui==0.9.54
pyperclip==1.8.2
Pillow==11.1.0
flask-socketio==5.5.1
python-engineio==4.11.2
python-socketio==5.12.1
requests==2.32.3
openai==1.61.0
google-generativeai==0.7.0
google-generativeai==0.7.0

View File

@@ -36,6 +36,10 @@ class SnapSolver {
this.progressLine = document.querySelector('.progress-line');
this.statusText = document.querySelector('.status-text');
this.analysisIndicator = document.querySelector('.analysis-indicator');
this.clipboardTextarea = document.getElementById('clipboardText');
this.clipboardSendButton = document.getElementById('clipboardSend');
this.clipboardReadButton = document.getElementById('clipboardRead');
this.clipboardStatus = document.getElementById('clipboardStatus');
// Crop elements
this.cropCancel = document.getElementById('cropCancel');
@@ -68,6 +72,10 @@ class SnapSolver {
this.cropConfirm = document.getElementById('cropConfirm');
this.cropSendToAI = document.getElementById('cropSendToAI');
this.stopGenerationBtn = document.getElementById('stopGenerationBtn');
this.clipboardTextarea = document.getElementById('clipboardText');
this.clipboardSendButton = document.getElementById('clipboardSend');
this.clipboardReadButton = document.getElementById('clipboardRead');
this.clipboardStatus = document.getElementById('clipboardStatus');
// 处理按钮事件
if (this.closeClaudePanel) {
@@ -861,6 +869,9 @@ class SnapSolver {
hljs.highlightElement(block);
});
}
// 为所有代码块添加复制按钮
this.addCopyButtonsToCodeBlocks(element);
} catch (error) {
console.error('Markdown解析错误:', error);
// 发生错误时也保留换行格式
@@ -871,6 +882,60 @@ class SnapSolver {
element.scrollTop = element.scrollHeight;
}
// 为代码块添加复制按钮
addCopyButtonsToCodeBlocks(element) {
const codeBlocks = element.querySelectorAll('pre code');
codeBlocks.forEach((codeBlock) => {
// 检查是否已经有复制按钮
if (codeBlock.parentElement.querySelector('.code-copy-btn')) {
return;
}
// 创建包装器
const wrapper = document.createElement('div');
wrapper.className = 'code-block-wrapper';
// 将pre元素包装起来
const preElement = codeBlock.parentElement;
preElement.parentNode.insertBefore(wrapper, preElement);
wrapper.appendChild(preElement);
// 创建复制按钮
const copyBtn = document.createElement('button');
copyBtn.className = 'code-copy-btn';
copyBtn.innerHTML = '<i class="fas fa-copy"></i> 复制';
copyBtn.title = '复制代码';
// 添加点击事件
copyBtn.addEventListener('click', async () => {
try {
const codeText = codeBlock.textContent;
await navigator.clipboard.writeText(codeText);
// 更新按钮状态
copyBtn.innerHTML = '<i class="fas fa-check"></i> 已复制';
copyBtn.classList.add('copied');
// 显示提示
window.uiManager?.showToast('代码已复制到剪贴板', 'success');
// 2秒后恢复原状
setTimeout(() => {
copyBtn.innerHTML = '<i class="fas fa-copy"></i> 复制';
copyBtn.classList.remove('copied');
}, 2000);
} catch (error) {
console.error('复制失败:', error);
window.uiManager?.showToast('复制失败,请手动选择复制', 'error');
}
});
// 将按钮添加到包装器
wrapper.appendChild(copyBtn);
});
}
initializeCropper() {
try {
// 如果当前没有截图,不要初始化裁剪器
@@ -934,6 +999,7 @@ class SnapSolver {
this.setupAnalysisEvents();
this.setupKeyboardShortcuts();
this.setupThinkingToggle();
this.setupClipboardFeature();
// 监听模型选择变化,更新界面
if (window.settingsManager && window.settingsManager.modelSelect) {
@@ -943,6 +1009,33 @@ class SnapSolver {
}
}
setupClipboardFeature() {
if (!this.clipboardTextarea || !this.clipboardSendButton || !this.clipboardReadButton) {
console.warn('Clipboard controls not found in DOM');
return;
}
// 读取剪贴板按钮事件
this.clipboardReadButton.addEventListener('click', (event) => {
event.preventDefault();
this.readClipboardText();
});
// 发送到剪贴板按钮事件
this.clipboardSendButton.addEventListener('click', (event) => {
event.preventDefault();
this.sendClipboardText();
});
// 键盘快捷键Ctrl/Cmd + Enter 发送到剪贴板
this.clipboardTextarea.addEventListener('keydown', (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
this.sendClipboardText();
}
});
}
setupCaptureEvents() {
// 添加计数器
if (!window.captureCounter) {
@@ -982,6 +1075,108 @@ class SnapSolver {
});
}
updateClipboardStatus(message, status = 'neutral') {
if (!this.clipboardStatus) return;
if (!message) {
this.clipboardStatus.textContent = '';
this.clipboardStatus.removeAttribute('data-status');
return;
}
this.clipboardStatus.textContent = message;
this.clipboardStatus.dataset.status = status;
}
async readClipboardText() {
if (!this.clipboardTextarea || !this.clipboardReadButton) return;
this.updateClipboardStatus('读取中...', 'pending');
this.clipboardReadButton.disabled = true;
try {
const response = await fetch('/api/clipboard', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json().catch(() => ({}));
if (response.ok && result?.success) {
// 将读取到的内容填入文本框
this.clipboardTextarea.value = result.text || '';
const successMessage = result.text ?
`成功读取剪贴板内容 (${result.text.length} 字符)` :
'剪贴板为空';
this.updateClipboardStatus(successMessage, 'success');
window.uiManager?.showToast(successMessage, 'success');
} else {
const errorMessage = result?.message || '读取失败,请稍后重试';
this.updateClipboardStatus(errorMessage, 'error');
window.uiManager?.showToast(errorMessage, 'error');
}
} catch (error) {
console.error('Failed to read clipboard text:', error);
const networkErrorMessage = '网络错误,读取失败';
this.updateClipboardStatus(networkErrorMessage, 'error');
window.uiManager?.showToast(networkErrorMessage, 'error');
} finally {
this.clipboardReadButton.disabled = false;
}
}
async sendClipboardText() {
if (!this.clipboardTextarea) return;
const text = this.clipboardTextarea.value ?? '';
if (!text.trim()) {
const warningMessage = '请输入要发送到剪贴板的文字';
this.updateClipboardStatus(warningMessage, 'error');
window.uiManager?.showToast(warningMessage, 'warning');
return;
}
this.updateClipboardStatus('发送中...', 'pending');
if (this.clipboardSendButton) {
this.clipboardSendButton.disabled = true;
}
try {
const response = await fetch('/api/clipboard', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ text })
});
const result = await response.json().catch(() => ({}));
if (response.ok && result?.success) {
const successMessage = '已复制到服务端剪贴板';
this.updateClipboardStatus(successMessage, 'success');
window.uiManager?.showToast(successMessage, 'success');
// 清空输入框
this.clipboardTextarea.value = '';
} else {
const errorMessage = result?.message || '发送失败,请稍后重试';
this.updateClipboardStatus(errorMessage, 'error');
window.uiManager?.showToast(errorMessage, 'error');
}
} catch (error) {
console.error('Failed to send clipboard text:', error);
const networkErrorMessage = '网络错误,发送失败';
this.updateClipboardStatus(networkErrorMessage, 'error');
window.uiManager?.showToast(networkErrorMessage, 'error');
} finally {
if (this.clipboardSendButton) {
this.clipboardSendButton.disabled = false;
}
}
}
setupCropEvents() {
// 防止重复绑定事件监听器
if (this.cropConfirm) {

View File

@@ -3454,6 +3454,152 @@ textarea:focus {
opacity: 0.5;
}
.clipboard-panel {
background-color: var(--surface);
border-radius: 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
padding: 1.5rem;
border: 1px solid var(--border-color);
transition: all 0.3s ease;
}
.clipboard-panel:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
.clipboard-header {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 1rem;
}
.clipboard-header h3 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.15rem;
margin: 0;
color: var(--text-primary);
}
.clipboard-hint {
font-size: 0.85rem;
color: var(--text-tertiary);
display: flex;
align-items: center;
gap: 0.4rem;
}
.clipboard-hint kbd {
background-color: var(--surface-alt);
border: 1px solid var(--border-color);
border-radius: 0.35rem;
padding: 0.1rem 0.45rem;
font-weight: 600;
font-size: 0.75rem;
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.08);
}
.clipboard-panel textarea {
min-height: 140px;
resize: vertical;
}
.clipboard-panel .clipboard-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 0.75rem;
}
.clipboard-send-btn,
.clipboard-read-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.clipboard-read-btn {
background-color: var(--primary);
color: white;
border: 1px solid var(--primary);
}
.clipboard-read-btn:hover {
background-color: var(--primary-dark);
border-color: var(--primary-dark);
}
.clipboard-read-btn:disabled {
background-color: var(--text-tertiary);
border-color: var(--text-tertiary);
cursor: not-allowed;
}
.clipboard-status {
font-size: 0.85rem;
color: var(--text-tertiary);
min-height: 1.2rem;
}
.clipboard-status[data-status="pending"] {
color: var(--primary);
}
.clipboard-status[data-status="success"] {
color: var(--success-color);
}
.clipboard-status[data-status="error"] {
color: var(--danger);
}
/* 代码块复制按钮样式 */
.code-block-wrapper {
position: relative;
margin: 1rem 0;
}
.code-block-wrapper pre {
margin: 0;
padding: 1rem;
background-color: var(--surface-alt);
border-radius: 0.5rem;
border: 1px solid var(--border-color);
overflow-x: auto;
}
.code-copy-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background-color: var(--primary);
color: white;
border: none;
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s ease;
z-index: 10;
}
.code-copy-btn:hover {
opacity: 1;
}
.code-copy-btn:active {
background-color: var(--primary-dark);
}
.code-copy-btn.copied {
background-color: var(--success-color);
}
/* 滑块值显示优化 */
#temperatureValue,
#thinkBudgetPercentValue {

View File

@@ -165,6 +165,25 @@
</button>
</div>
</div>
<div class="clipboard-panel">
<div class="clipboard-header">
<h3><i class="fas fa-clipboard"></i> 剪贴板操作</h3>
<p class="clipboard-hint">读取宿主机剪贴板内容或发送内容到服务端剪贴板</p>
</div>
<textarea id="clipboardText" rows="4" placeholder="剪贴板内容将显示在这里,也可以手动输入内容"></textarea>
<div class="clipboard-actions">
<button id="clipboardRead" class="btn-action clipboard-read-btn" type="button">
<i class="fas fa-download"></i>
<span>读取剪贴板</span>
</button>
<button id="clipboardSend" class="btn-action clipboard-send-btn" type="button">
<i class="fas fa-clipboard-check"></i>
<span>发送至剪贴板</span>
</button>
<span id="clipboardStatus" class="clipboard-status" aria-live="polite"></span>
</div>
</div>
</div>
<aside id="settingsPanel" class="settings-panel">

BIN
tmp.png Normal file

Binary file not shown.

BIN
tmp2.png Normal file

Binary file not shown.