poc/project/llmclipboard/llmclipboard/gui.py

690 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import sys
import os
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QPushButton, QLabel, QFileDialog,
QSystemTrayIcon, QMenu, QSpinBox, QStyle, QLineEdit,
QDialog, QTabWidget, QGroupBox, QComboBox, QCheckBox,
QDialogButtonBox, QMessageBox, QListWidget)
from PyQt6.QtCore import Qt, QSettings, QTimer
from PyQt6.QtGui import QIcon, QAction
from qt_material import apply_stylesheet
import darkdetect
from configparser import ConfigParser
from .category_manager import CategoryManagerDialog
class AISettingsDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("AI模型设置")
self.resize(500, 300)
layout = QVBoxLayout()
# 模型类型选择
model_type_layout = QHBoxLayout()
model_type_label = QLabel("模型类型:")
self.model_type_combo = QComboBox()
self.model_type_combo.addItems(["不使用AI", "OpenAI兼容API", "Ollama本地模型"])
self.model_type_combo.currentIndexChanged.connect(self.on_model_type_changed)
model_type_layout.addWidget(model_type_label)
model_type_layout.addWidget(self.model_type_combo)
layout.addLayout(model_type_layout)
# OpenAI设置
self.openai_group = QGroupBox("OpenAI设置")
openai_layout = QVBoxLayout()
api_key_layout = QHBoxLayout()
api_key_label = QLabel("API密钥:")
self.api_key_edit = QLineEdit()
self.api_key_edit.setEchoMode(QLineEdit.EchoMode.Password)
api_key_layout.addWidget(api_key_label)
api_key_layout.addWidget(self.api_key_edit)
openai_layout.addLayout(api_key_layout)
base_url_layout = QHBoxLayout()
base_url_label = QLabel("API地址:")
self.base_url_edit = QLineEdit("https://api.openai.com/v1")
base_url_layout.addWidget(base_url_label)
base_url_layout.addWidget(self.base_url_edit)
openai_layout.addLayout(base_url_layout)
model_layout = QHBoxLayout()
model_label = QLabel("模型:")
self.model_edit = QLineEdit("gpt-3.5-turbo")
model_layout.addWidget(model_label)
model_layout.addWidget(self.model_edit)
openai_layout.addLayout(model_layout)
self.openai_group.setLayout(openai_layout)
layout.addWidget(self.openai_group)
# Ollama设置
self.ollama_group = QGroupBox("Ollama设置")
ollama_layout = QVBoxLayout()
ollama_url_layout = QHBoxLayout()
ollama_url_label = QLabel("服务地址:")
self.ollama_url_edit = QLineEdit("http://localhost:11434/api")
ollama_url_layout.addWidget(ollama_url_label)
ollama_url_layout.addWidget(self.ollama_url_edit)
ollama_layout.addLayout(ollama_url_layout)
ollama_model_layout = QHBoxLayout()
ollama_model_label = QLabel("模型:")
self.ollama_model_edit = QLineEdit("llama2")
ollama_model_layout.addWidget(ollama_model_label)
ollama_model_layout.addWidget(self.ollama_model_edit)
ollama_layout.addLayout(ollama_model_layout)
self.ollama_group.setLayout(ollama_layout)
layout.addWidget(self.ollama_group)
# 测试连接按钮
self.test_button = QPushButton("测试连接")
self.test_button.clicked.connect(self.test_connection)
layout.addWidget(self.test_button)
# 按钮
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
self.setLayout(layout)
# 初始化界面状态
self.on_model_type_changed(0)
def on_model_type_changed(self, index):
"""模型类型变更处理"""
if index == 0: # 不使用AI
self.openai_group.setVisible(False)
self.ollama_group.setVisible(False)
self.test_button.setEnabled(False)
elif index == 1: # OpenAI
self.openai_group.setVisible(True)
self.ollama_group.setVisible(False)
self.test_button.setEnabled(True)
elif index == 2: # Ollama
self.openai_group.setVisible(False)
self.ollama_group.setVisible(True)
self.test_button.setEnabled(True)
def test_connection(self):
"""测试AI连接"""
model_type_index = self.model_type_combo.currentIndex()
if model_type_index == 0: # 不使用AI
QMessageBox.information(self, "测试结果", "未启用AI功能")
return
if model_type_index == 1: # OpenAI
api_key = self.api_key_edit.text()
base_url = self.base_url_edit.text()
model = self.model_edit.text()
if not api_key:
QMessageBox.warning(self, "测试失败", "请输入API密钥")
return
try:
import openai
openai.api_key = api_key
if base_url != "https://api.openai.com/v1":
openai.base_url = base_url
client = openai.OpenAI()
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": "Hello"}],
max_tokens=5
)
QMessageBox.information(self, "测试结果", "连接成功!")
except ImportError:
QMessageBox.warning(self, "测试失败", "未安装openai库请使用pip install openai安装")
except Exception as e:
QMessageBox.warning(self, "测试失败", f"连接失败: {str(e)}")
elif model_type_index == 2: # Ollama
base_url = self.ollama_url_edit.text()
model = self.ollama_model_edit.text()
try:
import requests
response = requests.get(f"{base_url}/tags")
if response.status_code != 200:
QMessageBox.warning(self, "测试失败", f"连接Ollama服务失败: {response.status_code}")
return
# 检查模型是否存在
models = response.json().get('models', [])
model_exists = any(m['name'] == model for m in models)
if model_exists:
QMessageBox.information(self, "测试结果", f"连接成功!模型 {model} 可用")
else:
QMessageBox.warning(self, "测试结果", f"连接成功,但模型 {model} 不存在")
except ImportError:
QMessageBox.warning(self, "测试失败", "未安装requests库请使用pip install requests安装")
except Exception as e:
QMessageBox.warning(self, "测试失败", f"连接失败: {str(e)}")
def get_config(self):
"""获取AI配置"""
model_type_index = self.model_type_combo.currentIndex()
if model_type_index == 0: # 不使用AI
return {"model_type": "none"}
if model_type_index == 1: # OpenAI
return {
"model_type": "openai",
"api_key": self.api_key_edit.text(),
"base_url": self.base_url_edit.text(),
"model": self.model_edit.text()
}
elif model_type_index == 2: # Ollama
return {
"model_type": "ollama",
"base_url": self.ollama_url_edit.text(),
"model": self.ollama_model_edit.text()
}
return {"model_type": "none"}
def set_config(self, config):
"""设置AI配置"""
model_type = config.get("model_type", "none")
if model_type == "none":
self.model_type_combo.setCurrentIndex(0)
elif model_type == "openai":
self.model_type_combo.setCurrentIndex(1)
self.api_key_edit.setText(config.get("api_key", ""))
self.base_url_edit.setText(config.get("base_url", "https://api.openai.com/v1"))
self.model_edit.setText(config.get("model", "gpt-3.5-turbo"))
elif model_type == "ollama":
self.model_type_combo.setCurrentIndex(2)
self.ollama_url_edit.setText(config.get("base_url", "http://localhost:11434/api"))
self.ollama_model_edit.setText(config.get("model", "llama2"))
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("LLMClipboard v0.1.5")
self.setMinimumWidth(600)
self.setMinimumHeight(400)
# 初始化服务
from .app import TextCaptureService
self.service = TextCaptureService()
# 加载配置
self.config = ConfigParser()
self.config_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.ini')
self.load_config()
# 创建主窗口部件
main_widget = QWidget()
self.setCentralWidget(main_widget)
main_layout = QVBoxLayout(main_widget)
main_layout.setContentsMargins(20, 20, 20, 20)
main_layout.setSpacing(15)
# 创建标题标签
title_label = QLabel("LLM智能剪贴板")
title_label.setStyleSheet("font-size: 24px; font-weight: bold; margin-bottom: 15px;")
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
main_layout.addWidget(title_label)
# 创建设置组
settings_group = QGroupBox("基本设置")
settings_layout = QVBoxLayout()
settings_layout.setSpacing(10)
# 保存路径设置
save_path_layout = QHBoxLayout()
save_path_label = QLabel("保存路径:")
save_path_label.setMinimumWidth(100)
self.save_path_edit = QLineEdit(self.config.get('Settings', 'save_location'))
browse_button = QPushButton("浏览...")
browse_button.setFixedWidth(80)
browse_button.clicked.connect(self.browse_save_path)
save_path_layout.addWidget(save_path_label)
save_path_layout.addWidget(self.save_path_edit)
save_path_layout.addWidget(browse_button)
settings_layout.addLayout(save_path_layout)
# 双击阈值设置
threshold_layout = QHBoxLayout()
threshold_label = QLabel("双击阈值 (秒):")
threshold_label.setMinimumWidth(100)
self.threshold_spin = QSpinBox()
self.threshold_spin.setRange(1, 1000)
self.threshold_spin.setValue(int(float(self.config.get('Settings', 'double_click_threshold')) * 1000))
self.threshold_spin.setSingleStep(100)
threshold_layout.addWidget(threshold_label)
threshold_layout.addWidget(self.threshold_spin)
threshold_layout.addStretch()
settings_layout.addLayout(threshold_layout)
settings_group.setLayout(settings_layout)
main_layout.addWidget(settings_group)
# 创建高级设置组
advanced_group = QGroupBox("高级设置")
advanced_layout = QVBoxLayout()
# 高级设置按钮
advanced_buttons_layout = QHBoxLayout()
# AI设置按钮
self.ai_button = QPushButton("AI模型设置")
self.ai_button.setMinimumHeight(30)
self.ai_button.clicked.connect(self.show_ai_settings)
advanced_buttons_layout.addWidget(self.ai_button)
# 分类管理按钮
self.category_button = QPushButton("分类管理")
self.category_button.setMinimumHeight(30)
self.category_button.clicked.connect(self.show_category_manager)
advanced_buttons_layout.addWidget(self.category_button)
advanced_layout.addLayout(advanced_buttons_layout)
# 最近保存列表
recent_group = QGroupBox("最近保存")
recent_layout = QVBoxLayout()
self.recent_list = QListWidget()
self.recent_list.itemDoubleClicked.connect(self.open_recent_file)
recent_layout.addWidget(self.recent_list)
recent_group.setLayout(recent_layout)
advanced_layout.addWidget(recent_group)
advanced_group.setLayout(advanced_layout)
main_layout.addWidget(advanced_group)
# 状态显示
status_group = QGroupBox("状态")
status_layout = QVBoxLayout()
self.status_label = QLabel("状态: 已停止")
self.status_label.setStyleSheet("font-weight: bold;")
status_layout.addWidget(self.status_label)
status_group.setLayout(status_layout)
main_layout.addWidget(status_group)
# 控制按钮
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
self.start_button = QPushButton("启动监听")
self.start_button.setMinimumHeight(40)
self.start_button.setStyleSheet("font-weight: bold;")
self.start_button.clicked.connect(self.toggle_monitoring)
self.save_button = QPushButton("保存设置")
self.save_button.setMinimumHeight(40)
self.save_button.clicked.connect(self.save_config)
button_layout.addWidget(self.start_button)
button_layout.addWidget(self.save_button)
main_layout.addLayout(button_layout)
# 系统托盘
self.setup_system_tray()
# 设置样式
self.setup_style()
# 保存的文件路径
self._last_saved_file = None
def load_config(self):
if os.path.exists(self.config_file):
self.config.read(self.config_file)
else:
self.config['Settings'] = {
'save_location': os.path.expanduser('~/Documents'),
'double_click_threshold': '0.3'
}
# 添加AI设置
if not self.config.has_section('AI'):
self.config['AI'] = {
'model_type': 'none',
'api_key': '',
'base_url': 'https://api.openai.com/v1',
'model': 'gpt-3.5-turbo'
}
self.save_config()
def save_config(self):
self.config['Settings']['save_location'] = self.save_path_edit.text()
self.config['Settings']['double_click_threshold'] = str(self.threshold_spin.value() / 1000)
with open(self.config_file, 'w') as f:
self.config.write(f)
# 重新加载服务配置
if hasattr(self.service, 'load_config'):
self.service.load_config()
self.show_message("设置已保存")
def browse_save_path(self):
folder = QFileDialog.getExistingDirectory(self, "选择保存路径", self.save_path_edit.text())
if folder:
self.save_path_edit.setText(folder)
def show_ai_settings(self):
"""显示AI设置对话框"""
dialog = AISettingsDialog(self)
# 设置当前配置
ai_config = {}
if self.config.has_section('AI'):
for key, value in self.config.items('AI'):
ai_config[key] = value
dialog.set_config(ai_config)
if dialog.exec():
# 获取新配置
new_config = dialog.get_config()
# 保存到配置文件
if not self.config.has_section('AI'):
self.config.add_section('AI')
for key, value in new_config.items():
self.config.set('AI', key, str(value))
self.save_config()
# 更新服务的AI配置
if hasattr(self.service, 'update_ai_config'):
self.service.update_ai_config(new_config)
def show_category_manager(self):
"""显示分类管理器对话框"""
# 获取当前分类
categories = {}
if hasattr(self.service, 'doc_processor'):
categories = self.service.doc_processor.categories
# 创建分类管理器对话框
dialog = CategoryManagerDialog(categories, self)
# 连接更新信号
dialog.categories_updated.connect(self.update_categories)
# 显示对话框
dialog.exec()
def update_categories(self, new_categories):
"""更新分类"""
if hasattr(self.service, 'doc_processor'):
self.service.doc_processor.update_categories(new_categories)
self.show_message("分类已更新")
def add_recent_file(self, file_path):
"""添加最近保存的文件到列表"""
# 检查是否已存在
for i in range(self.recent_list.count()):
if self.recent_list.item(i).data(Qt.ItemDataRole.UserRole) == file_path:
# 已存在,移到顶部
item = self.recent_list.takeItem(i)
self.recent_list.insertItem(0, item)
return
# 不存在,添加到顶部
item = QListWidgetItem(os.path.basename(file_path))
item.setData(Qt.ItemDataRole.UserRole, file_path)
self.recent_list.insertItem(0, item)
# 限制最大数量
while self.recent_list.count() > 10:
self.recent_list.takeItem(self.recent_list.count() - 1)
def open_recent_file(self, item):
"""打开最近保存的文件"""
file_path = item.data(Qt.ItemDataRole.UserRole)
if file_path and os.path.exists(file_path):
self.open_saved_file(file_path)
def toggle_monitoring(self):
if self.start_button.text() == "启动监听":
self.start_button.setText("停止监听")
self.status_label.setText("状态: 正在运行")
self.toggle_action.setText("停止监听")
self.status_action.setText("状态: 正在运行")
self.tray_icon.setIcon(self.active_icon)
self.save_config()
# 重新加载配置
self.service.load_config()
self.service.start()
else:
self.start_button.setText("启动监听")
self.status_label.setText("状态: 已停止")
self.toggle_action.setText("启动监听")
self.status_action.setText("状态: 已停止")
self.tray_icon.setIcon(self.inactive_icon)
self.service.stop()
def setup_system_tray(self):
self.tray_icon = QSystemTrayIcon(self)
# 创建不同状态的图标
self.active_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton)
self.inactive_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon)
# 设置初始图标
self.tray_icon.setIcon(self.inactive_icon)
# 创建托盘菜单
tray_menu = QMenu()
# 状态显示
self.status_action = QAction("状态: 已停止", self)
self.status_action.setEnabled(False)
tray_menu.addAction(self.status_action)
tray_menu.addSeparator()
# 主要操作
self.toggle_action = QAction("启动监听", self)
self.toggle_action.triggered.connect(self.toggle_monitoring)
tray_menu.addAction(self.toggle_action)
show_action = QAction("显示主窗口", self)
show_action.triggered.connect(self.show)
tray_menu.addAction(show_action)
# 设置子菜单
settings_menu = QMenu("设置", self)
# 保存路径设置
change_path_action = QAction("更改保存路径", self)
change_path_action.triggered.connect(self.browse_save_path)
settings_menu.addAction(change_path_action)
# AI设置
ai_settings_action = QAction("AI模型设置", self)
ai_settings_action.triggered.connect(self.show_ai_settings)
settings_menu.addAction(ai_settings_action)
# 自启动设置
self.autostart_action = QAction("开机自启动", self)
self.autostart_action.setCheckable(True)
self.autostart_action.setChecked(self.is_autostart_enabled())
self.autostart_action.triggered.connect(self.toggle_autostart)
settings_menu.addAction(self.autostart_action)
tray_menu.addMenu(settings_menu)
tray_menu.addSeparator()
# 退出操作
quit_action = QAction("退出", self)
quit_action.triggered.connect(self.quit_application)
tray_menu.addAction(quit_action)
self.tray_icon.setContextMenu(tray_menu)
self.tray_icon.show()
# 双击托盘图标显示主窗口
self.tray_icon.activated.connect(self.tray_icon_activated)
def tray_icon_activated(self, reason):
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
self.show()
def is_autostart_enabled(self):
"""检查是否启用了自启动"""
import winreg
try:
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run",
0, winreg.KEY_READ
)
try:
value, _ = winreg.QueryValueEx(key, "LLMClipboard")
return True
except:
return False
finally:
winreg.CloseKey(key)
except:
return False
def toggle_autostart(self):
"""切换自启动状态"""
import winreg
try:
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run",
0, winreg.KEY_WRITE
)
if self.autostart_action.isChecked():
# 获取应用程序路径
if getattr(sys, 'frozen', False):
app_path = sys.executable
else:
app_path = sys.argv[0]
winreg.SetValueEx(
key, "LLMClipboard", 0, winreg.REG_SZ, f'"{app_path}"'
)
self.show_message("已设置开机自启动")
else:
try:
winreg.DeleteValue(key, "LLMClipboard")
self.show_message("已取消开机自启动")
except:
pass
winreg.CloseKey(key)
except Exception as e:
self.show_message(f"设置自启动失败: {str(e)}", error=True)
def quit_application(self):
"""完全退出应用程序"""
if self.service.running:
self.service.stop()
QApplication.quit()
def setup_style(self):
# 根据系统主题设置深色/浅色模式
if darkdetect.isDark():
apply_stylesheet(self, theme='dark_blue.xml')
else:
apply_stylesheet(self, theme='light_blue.xml')
def closeEvent(self, event):
if self.start_button.text() == "停止监听":
self.service.stop()
event.ignore()
self.hide()
self.tray_icon.showMessage(
"LLMClipboard",
"应用程序已最小化到系统托盘",
QSystemTrayIcon.Icon.Information,
2000
)
def show_message(self, message, error=False):
"""显示消息通知"""
if error:
self.status_label.setStyleSheet("color: red;")
else:
self.status_label.setStyleSheet("color: green;")
self.status_label.setText(message)
def closeEvent(self, event):
if self.start_button.text() == "停止监听":
self.service.stop()
event.ignore()
self.hide()
self.tray_icon.showMessage(
"LLMClipboard",
"应用程序已最小化到系统托盘",
QSystemTrayIcon.Icon.Information,
2000
)
def show_message(self, message, error=False):
"""显示消息通知"""
if error:
self.status_label.setStyleSheet("color: red; font-weight: bold;")
else:
self.status_label.setStyleSheet("color: green; font-weight: bold;")
self.status_label.setText(message)
# 5秒后清除消息
QTimer.singleShot(5000, lambda: self.status_label.setText("就绪"))
def show_notification(self, title, message, icon_type="info", actions=None, file_path=None):
"""显示带操作按钮的通知"""
icon = QSystemTrayIcon.Icon.Information
if icon_type == "error":
icon = QSystemTrayIcon.Icon.Critical
elif icon_type == "warning":
icon = QSystemTrayIcon.Icon.Warning
# 保存文件路径用于后续操作
self._last_saved_file = file_path
# 显示通知
self.tray_icon.showMessage(
title,
message,
icon,
5000 # 显示5秒
)
# 如果有文件路径,添加通知点击操作
if file_path:
self.tray_icon.messageClicked.connect(lambda: self.open_saved_file(file_path))
# 更新状态标签
self.show_message(message)
# 添加到最近文件列表
if file_path and os.path.exists(file_path):
self.add_recent_file(file_path)
def open_saved_file(self, file_path):
"""打开保存的文件"""
try:
os.startfile(file_path)
except Exception as e:
self.show_message(f"打开文件失败: {str(e)}", error=True)