968 lines
41 KiB
Python
968 lines
41 KiB
Python
import os
|
||
import time
|
||
import configparser
|
||
from pynput import mouse
|
||
import win32clipboard
|
||
import win32con
|
||
from html2text import HTML2Text
|
||
import keyboard
|
||
import threading
|
||
import sys
|
||
import logging
|
||
from logging.handlers import RotatingFileHandler
|
||
from PyQt6.QtWidgets import QApplication, QWidget
|
||
from llmclipboard.gui import MainWindow
|
||
from llmclipboard.document_processor import DocumentProcessor
|
||
import io
|
||
from PIL import Image
|
||
import uuid
|
||
import re
|
||
import requests
|
||
|
||
# 重定向标准输出和标准错误到日志
|
||
class StreamToLogger:
|
||
def __init__(self, logger, level):
|
||
self.logger = logger
|
||
self.level = level
|
||
self.linebuf = ''
|
||
|
||
def write(self, buf):
|
||
for line in buf.rstrip().splitlines():
|
||
self.logger.log(self.level, line.rstrip())
|
||
|
||
def flush(self):
|
||
pass
|
||
|
||
class TextCaptureService:
|
||
def __init__(self):
|
||
# 设置日志记录
|
||
self.setup_logging()
|
||
|
||
# 加载配置
|
||
self.load_config()
|
||
self.running = True
|
||
self.last_right_click_time = 0
|
||
self._mouse_listener = None
|
||
self._keyboard_listener = None
|
||
|
||
# 加载AI配置
|
||
self.ai_config = self.load_ai_config()
|
||
|
||
# 确定配置文件路径
|
||
if getattr(sys, 'frozen', False):
|
||
# 打包环境 - 使用可执行文件所在目录
|
||
config_dir = os.path.dirname(sys.executable)
|
||
else:
|
||
# 开发环境 - 使用项目根目录
|
||
config_dir = os.path.dirname(os.path.dirname(__file__))
|
||
|
||
# 初始化文档处理器,传入配置路径
|
||
self.doc_processor = DocumentProcessor(
|
||
ai_config=self.ai_config,
|
||
config_path=os.path.join(config_dir, 'categories.json')
|
||
)
|
||
self.logger.info(f"文档处理器已初始化,分类配置路径: {os.path.join(config_dir, 'categories.json')}")
|
||
|
||
def load_config(self):
|
||
try:
|
||
# 使用 ConfigParser 的 BasicInterpolation 而不是 ExtendedInterpolation
|
||
# 这样可以避免 % 符号的问题
|
||
self.config = configparser.ConfigParser(interpolation=configparser.BasicInterpolation())
|
||
|
||
# 确定配置文件路径
|
||
if getattr(sys, 'frozen', False):
|
||
# 打包环境 - 使用可执行文件所在目录
|
||
config_dir = os.path.dirname(sys.executable)
|
||
else:
|
||
# 开发环境 - 使用项目根目录
|
||
config_dir = os.path.dirname(os.path.dirname(__file__))
|
||
|
||
self.config_file = os.path.join(config_dir, 'config.ini')
|
||
self.logger.info(f"配置文件路径: {self.config_file}")
|
||
|
||
if os.path.exists(self.config_file):
|
||
self.config.read(self.config_file, encoding='utf-8')
|
||
self.logger.info(f"成功读取配置文件")
|
||
else:
|
||
self.logger.warning(f"配置文件不存在,将使用默认配置")
|
||
# 使用默认配置
|
||
if not self.config.has_section('Settings'):
|
||
self.config.add_section('Settings')
|
||
self.config.set('Settings', 'double_click_threshold', '0.3')
|
||
self.config.set('Settings', 'save_location', os.path.expanduser('~/Documents'))
|
||
|
||
# 保存默认配置
|
||
try:
|
||
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||
self.config.write(f)
|
||
self.logger.info(f"已创建默认配置文件: {self.config_file}")
|
||
except Exception as save_error:
|
||
self.logger.error(f"创建默认配置文件失败: {save_error}")
|
||
|
||
# 获取设置值,处理可能的插值语法错误
|
||
try:
|
||
self.double_click_threshold = self.config.getfloat('Settings', 'double_click_threshold', fallback=0.3)
|
||
except (configparser.InterpolationError, ValueError) as e:
|
||
self.logger.error(f"读取double_click_threshold设置时出错: {e}")
|
||
self.double_click_threshold = 0.3
|
||
|
||
try:
|
||
save_location = self.config.get('Settings', 'save_location', fallback=os.path.expanduser('~/Documents'))
|
||
# 检查路径是否存在,如果不存在则使用默认路径
|
||
if not os.path.exists(save_location):
|
||
self.logger.warning(f"保存路径不存在: {save_location},将使用默认路径")
|
||
save_location = os.path.expanduser('~/Documents')
|
||
self.save_location = save_location
|
||
except (configparser.InterpolationError, ValueError) as e:
|
||
self.logger.error(f"读取save_location设置时出错: {e}")
|
||
self.save_location = os.path.expanduser('~/Documents')
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"加载配置时出错: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
# 使用默认值
|
||
self.double_click_threshold = 0.3
|
||
self.save_location = os.path.expanduser('~/Documents')
|
||
|
||
def load_ai_config(self):
|
||
try:
|
||
if not self.config.has_section('AI'):
|
||
self.config.add_section('AI')
|
||
self.config.set('AI', 'model_type', 'none')
|
||
self.config.set('AI', 'api_key', '')
|
||
self.config.set('AI', 'base_url', 'https://api.openai.com/v1')
|
||
self.config.set('AI', 'model', 'gpt-3.5-turbo')
|
||
|
||
# 保存默认AI配置
|
||
try:
|
||
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||
self.config.write(f)
|
||
except Exception as e:
|
||
self.logger.error(f"保存默认AI配置失败: {e}")
|
||
|
||
# 读取AI配置,处理可能的插值语法错误
|
||
try:
|
||
self.ai_model_type = self.config.get('AI', 'model_type', fallback='none')
|
||
except configparser.InterpolationError as e:
|
||
self.logger.error(f"读取model_type设置时出错: {e}")
|
||
self.ai_model_type = 'none'
|
||
|
||
try:
|
||
self.ai_api_key = self.config.get('AI', 'api_key', fallback='')
|
||
except configparser.InterpolationError as e:
|
||
self.logger.error(f"读取api_key设置时出错: {e}")
|
||
self.ai_api_key = ''
|
||
|
||
try:
|
||
self.ai_base_url = self.config.get('AI', 'base_url', fallback='https://api.openai.com/v1')
|
||
except configparser.InterpolationError as e:
|
||
self.logger.error(f"读取base_url设置时出错: {e}")
|
||
self.ai_base_url = 'https://api.openai.com/v1'
|
||
|
||
try:
|
||
self.ai_model = self.config.get('AI', 'model', fallback='gpt-3.5-turbo')
|
||
except configparser.InterpolationError as e:
|
||
self.logger.error(f"读取model设置时出错: {e}")
|
||
self.ai_model = 'gpt-3.5-turbo'
|
||
|
||
self.logger.info(f"AI配置已加载: 模型类型={self.ai_model_type}, 模型={self.ai_model}")
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"加载AI配置时出错: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
# 使用默认值
|
||
self.ai_model_type = 'none'
|
||
self.ai_api_key = ''
|
||
self.ai_base_url = 'https://api.openai.com/v1'
|
||
self.ai_model = 'gpt-3.5-turbo'
|
||
|
||
ai_config = {
|
||
'model_type': self.ai_model_type,
|
||
'api_key': self.ai_api_key,
|
||
'base_url': self.ai_base_url,
|
||
'model': self.ai_model
|
||
}
|
||
return ai_config
|
||
|
||
def update_ai_config(self, new_config):
|
||
"""更新AI配置"""
|
||
try:
|
||
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))
|
||
|
||
# 保存到文件
|
||
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||
self.config.write(f)
|
||
|
||
# 更新实例变量
|
||
self.ai_model_type = new_config.get('model_type', 'none')
|
||
self.ai_api_key = new_config.get('api_key', '')
|
||
self.ai_base_url = new_config.get('base_url', 'https://api.openai.com/v1')
|
||
self.ai_model = new_config.get('model', 'gpt-3.5-turbo')
|
||
|
||
self.logger.info(f"AI配置已更新: 模型类型={self.ai_model_type}, 模型={self.ai_model}")
|
||
return True
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"更新AI配置时出错: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return False
|
||
|
||
def setup_logging(self):
|
||
try:
|
||
# 获取应用程序根目录
|
||
if getattr(sys, 'frozen', False):
|
||
# 如果是打包后的环境
|
||
app_root = os.path.dirname(sys.executable)
|
||
else:
|
||
# 如果是开发环境
|
||
app_root = os.path.dirname(os.path.dirname(__file__))
|
||
|
||
# 设置日志目录
|
||
log_dir = os.path.join(app_root, 'logs')
|
||
if not os.path.exists(log_dir):
|
||
os.makedirs(log_dir)
|
||
|
||
# 设置日志文件路径
|
||
log_file = os.path.join(log_dir, 'llmclipboard.log')
|
||
|
||
# 创建 RotatingFileHandler
|
||
file_handler = RotatingFileHandler(
|
||
log_file,
|
||
maxBytes=5*1024*1024, # 5MB
|
||
backupCount=3,
|
||
encoding='utf-8'
|
||
)
|
||
|
||
# 设置日志格式
|
||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||
file_handler.setFormatter(formatter)
|
||
|
||
# 配置根日志记录器
|
||
root_logger = logging.getLogger()
|
||
root_logger.setLevel(logging.INFO)
|
||
|
||
# 移除所有现有的处理器
|
||
for handler in root_logger.handlers[:]:
|
||
root_logger.removeHandler(handler)
|
||
|
||
# 添加新的处理器
|
||
root_logger.addHandler(file_handler)
|
||
|
||
# 创建应用程序日志记录器
|
||
self.logger = logging.getLogger(__name__)
|
||
|
||
# 重定向标准输出和标准错误到日志
|
||
sys.stdout = StreamToLogger(self.logger, logging.INFO)
|
||
sys.stderr = StreamToLogger(self.logger, logging.ERROR)
|
||
|
||
self.logger.info('日志系统初始化完成')
|
||
self.logger.info(f'日志文件路径: {log_file}')
|
||
except Exception as e:
|
||
# 如果日志设置失败,尝试写入到临时目录
|
||
import tempfile
|
||
temp_dir = tempfile.gettempdir()
|
||
fallback_log = os.path.join(temp_dir, 'llmclipboard_error.log')
|
||
with open(fallback_log, 'a', encoding='utf-8') as f:
|
||
f.write(f'{time.strftime("%Y-%m-%d %H:%M:%S")} - 日志初始化失败: {str(e)}\n')
|
||
|
||
def is_valid_content(self, content):
|
||
"""验证内容是否有效"""
|
||
if not content:
|
||
return False
|
||
|
||
# 检查是否是空的JSON对象
|
||
if content.strip().startswith('{') and content.strip().endswith('}'):
|
||
try:
|
||
import json
|
||
data = json.loads(content)
|
||
# 检查是否是空的excalidraw内容
|
||
if data.get('type') == 'excalidraw/clipboard' and not data.get('elements'):
|
||
self.logger.info("跳过空的excalidraw内容")
|
||
return False
|
||
except:
|
||
pass
|
||
|
||
# 检查内容是否全是空白字符
|
||
if not content.strip():
|
||
return False
|
||
|
||
# 检查内容是否太短
|
||
if len(content.strip()) < 5:
|
||
return False
|
||
|
||
return True
|
||
|
||
def extract_images_from_html(self, html_content):
|
||
"""从HTML内容中提取图片"""
|
||
import re
|
||
import requests
|
||
from io import BytesIO
|
||
from PIL import Image
|
||
|
||
# 查找所有图片标签
|
||
img_tags = re.findall(r'<img[^>]+src="([^"]+)"[^>]*>', html_content)
|
||
images = []
|
||
|
||
for img_url in img_tags:
|
||
try:
|
||
# 处理相对路径和绝对路径
|
||
if img_url.startswith('data:image'):
|
||
# 处理base64编码的图片
|
||
try:
|
||
import base64
|
||
# 提取MIME类型和base64数据
|
||
mime_type, base64_data = img_url.split(',', 1)
|
||
mime_type = mime_type.split(':')[1].split(';')[0]
|
||
|
||
# 解码base64数据
|
||
binary_data = base64.b64decode(base64_data)
|
||
|
||
# 创建PIL图像
|
||
img = Image.open(BytesIO(binary_data))
|
||
images.append(img)
|
||
self.logger.info(f"成功提取base64图片,大小: {img.size}")
|
||
except Exception as e:
|
||
self.logger.error(f"处理base64图片失败: {e}")
|
||
elif img_url.startswith(('http://', 'https://')):
|
||
# 下载网络图片
|
||
response = requests.get(img_url, stream=True, timeout=5)
|
||
if response.status_code == 200:
|
||
img = Image.open(BytesIO(response.content))
|
||
images.append(img)
|
||
self.logger.info(f"成功下载网络图片: {img_url}")
|
||
else:
|
||
self.logger.warning(f"下载图片失败: {img_url}, 状态码: {response.status_code}")
|
||
else:
|
||
self.logger.warning(f"不支持的图片URL格式: {img_url}")
|
||
except Exception as e:
|
||
self.logger.error(f"处理图片URL失败: {img_url}, 错误: {e}")
|
||
|
||
return images
|
||
|
||
def get_clipboard_content(self):
|
||
"""获取剪贴板内容"""
|
||
try:
|
||
win32clipboard.OpenClipboard()
|
||
content = None
|
||
|
||
# 检查是否有图片格式
|
||
try:
|
||
if win32clipboard.IsClipboardFormatAvailable(win32con.CF_DIB):
|
||
self.logger.info("检测到图片格式内容")
|
||
|
||
# 获取图片数据
|
||
image_data = win32clipboard.GetClipboardData(win32con.CF_DIB)
|
||
|
||
# 转换为PIL图像
|
||
try:
|
||
from io import BytesIO
|
||
|
||
# 创建BMP文件头
|
||
offset = 14 # BMP文件头大小
|
||
header_size = 40 # BITMAPINFOHEADER大小
|
||
|
||
# 从DIB数据中提取宽度和高度
|
||
width = int.from_bytes(image_data[4:8], byteorder='little')
|
||
height = int.from_bytes(image_data[8:12], byteorder='little')
|
||
|
||
# 计算文件大小
|
||
file_size = offset + len(image_data)
|
||
|
||
# 创建BMP文件头
|
||
bmp_header = b'BM' + \
|
||
file_size.to_bytes(4, byteorder='little') + \
|
||
b'\x00\x00\x00\x00' + \
|
||
offset.to_bytes(4, byteorder='little')
|
||
|
||
# 组合BMP文件
|
||
bmp_data = bmp_header + image_data
|
||
|
||
# 转换为PIL图像
|
||
image = Image.open(BytesIO(bmp_data))
|
||
|
||
# 返回图片内容
|
||
return {"type": "image", "data": image}
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"处理图片失败: {e}")
|
||
# 如果图片处理失败,继续尝试其他格式
|
||
except Exception as e:
|
||
self.logger.debug(f"获取图片失败: {e}")
|
||
|
||
# 首先尝试获取Unicode文本
|
||
try:
|
||
content = win32clipboard.GetClipboardData(win32con.CF_UNICODETEXT)
|
||
self.logger.info("获取Unicode文本格式内容")
|
||
if content and self.is_valid_content(content):
|
||
return {"type": "text", "data": content}
|
||
except Exception as e:
|
||
self.logger.debug(f"获取Unicode文本失败: {e}")
|
||
|
||
# 尝试获取HTML格式
|
||
try:
|
||
CF_HTML = win32clipboard.RegisterClipboardFormat("HTML Format")
|
||
html_content = win32clipboard.GetClipboardData(CF_HTML)
|
||
self.logger.info("获取HTML格式内容")
|
||
if html_content:
|
||
# 从HTML字符串中提取实际的HTML内容
|
||
try:
|
||
if isinstance(html_content, bytes):
|
||
html_content = html_content.decode('utf-8')
|
||
|
||
# 查找HTML内容的开始和结束
|
||
start = html_content.find('<html>')
|
||
if start == -1:
|
||
start = html_content.find('<!--StartFragment-->')
|
||
if start != -1:
|
||
start = html_content.find('<', start + 20)
|
||
|
||
end = html_content.find('</html>')
|
||
if end == -1:
|
||
end = html_content.find('<!--EndFragment-->')
|
||
|
||
if start != -1:
|
||
if end != -1:
|
||
html_content = html_content[start:end]
|
||
else:
|
||
html_content = html_content[start:]
|
||
|
||
# 提取HTML中的图片
|
||
images = self.extract_images_from_html(html_content)
|
||
if images:
|
||
self.logger.info(f"从HTML中提取到 {len(images)} 张图片")
|
||
# 如果只有一张图片且没有其他内容,直接返回图片
|
||
if len(images) == 1:
|
||
return {"type": "image", "data": images[0]}
|
||
# 否则返回混合内容
|
||
else:
|
||
# 解析HTML格式的内容为文本
|
||
h = HTML2Text()
|
||
h.body_width = 0 # 禁用自动换行
|
||
h.single_line_break = True # 使用单行换行
|
||
h.ignore_emphasis = False
|
||
h.ignore_images = False
|
||
h.ignore_links = False
|
||
h.ignore_tables = False
|
||
text_content = h.handle(html_content).strip()
|
||
|
||
if text_content and self.is_valid_content(text_content):
|
||
return {
|
||
"type": "mixed",
|
||
"data": text_content,
|
||
"images": images
|
||
}
|
||
else:
|
||
# 如果文本内容无效,但有多张图片,返回第一张图片
|
||
return {"type": "image", "data": images[0]}
|
||
except Exception as e:
|
||
self.logger.debug(f"HTML内容提取失败: {e}")
|
||
|
||
# 如果没有提取到图片或提取失败,使用传统方法处理HTML
|
||
h = HTML2Text()
|
||
h.body_width = 0 # 禁用自动换行
|
||
h.single_line_break = True # 使用单行换行
|
||
h.ignore_emphasis = False
|
||
h.ignore_images = False
|
||
h.ignore_links = False
|
||
h.ignore_tables = False
|
||
content = h.handle(html_content).strip()
|
||
if content and self.is_valid_content(content):
|
||
return {"type": "text", "data": content}
|
||
except Exception as e:
|
||
self.logger.debug(f"获取HTML格式失败: {e}")
|
||
|
||
# 如果HTML获取失败,尝试获取普通文本
|
||
try:
|
||
text_content = win32clipboard.GetClipboardData(win32con.CF_TEXT)
|
||
self.logger.info("获取普通文本格式内容")
|
||
if text_content:
|
||
if isinstance(text_content, bytes):
|
||
text_content = text_content.decode('gbk', errors='ignore')
|
||
content = text_content
|
||
if self.is_valid_content(content):
|
||
return {"type": "text", "data": content}
|
||
except Exception as e:
|
||
self.logger.debug(f"获取普通文本失败: {e}")
|
||
|
||
if not content or not self.is_valid_content(content):
|
||
self.logger.warning("剪贴板内容为空或无效")
|
||
if hasattr(self, 'gui') and self.gui:
|
||
self.gui.show_message("剪贴板内容为空或无效", error=True)
|
||
return None
|
||
|
||
return {"type": "text", "data": content}
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"获取剪贴板内容失败: {e}")
|
||
if hasattr(self, 'gui') and self.gui:
|
||
self.gui.show_message(f"获取剪贴板内容失败: {str(e)}", error=True)
|
||
return None
|
||
finally:
|
||
try:
|
||
win32clipboard.CloseClipboard()
|
||
except:
|
||
pass
|
||
|
||
def process_content_in_thread(self, content):
|
||
"""在线程中处理内容,避免阻塞UI"""
|
||
def worker():
|
||
try:
|
||
if content.get('type') == 'image':
|
||
self.save_image(content['data'])
|
||
elif content.get('type') == 'mixed':
|
||
# 保存文本内容
|
||
md_path = self.save_to_markdown(content['data'])
|
||
|
||
# 保存图片并添加到Markdown文件
|
||
if md_path and content.get('images'):
|
||
self.append_images_to_markdown(md_path, content['images'])
|
||
else:
|
||
self.save_to_markdown(content['data'])
|
||
except Exception as e:
|
||
self.logger.error(f"处理内容失败: {e}")
|
||
if hasattr(self, 'gui') and self.gui:
|
||
self.gui.show_message(f"处理内容失败: {str(e)}", error=True)
|
||
|
||
# 创建并启动线程
|
||
thread = threading.Thread(target=worker)
|
||
thread.daemon = True
|
||
thread.start()
|
||
|
||
def on_click(self, x, y, button, pressed):
|
||
if not pressed or not self.running:
|
||
return True
|
||
|
||
if button == mouse.Button.right:
|
||
current_time = time.time()
|
||
if (current_time - self.last_right_click_time) < self.double_click_threshold:
|
||
self.logger.info("检测到双击右键")
|
||
# 先执行复制操作
|
||
self.simulate_copy()
|
||
time.sleep(0.1) # 等待复制完成
|
||
|
||
# 获取剪贴板内容
|
||
content = self.get_clipboard_content()
|
||
if content:
|
||
self.logger.info(f"开始保存内容,类型: {content.get('type', 'unknown')}")
|
||
# 在线程中处理内容,避免阻塞UI
|
||
self.process_content_in_thread(content)
|
||
else:
|
||
self.logger.warning("没有可保存的内容")
|
||
self.last_right_click_time = current_time
|
||
return True
|
||
|
||
def save_image(self, image):
|
||
"""保存图片并创建Markdown引用"""
|
||
try:
|
||
# 确保保存目录存在
|
||
image_dir = os.path.join(self.save_location, "图片")
|
||
if not os.path.exists(image_dir):
|
||
os.makedirs(image_dir)
|
||
self.logger.info(f"创建图片保存目录: {image_dir}")
|
||
|
||
# 生成文件名
|
||
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
||
image_filename = f"image_{timestamp}.png"
|
||
image_path = os.path.join(image_dir, image_filename)
|
||
|
||
# 保存图片
|
||
image.save(image_path)
|
||
|
||
# 获取图片文件大小和尺寸信息
|
||
file_size = os.path.getsize(image_path)
|
||
file_size_str = self.format_file_size(file_size)
|
||
img_width, img_height = image.size
|
||
|
||
self.logger.info(f"图片已保存到: {image_path}")
|
||
self.logger.info(f"图片大小: {file_size_str}")
|
||
self.logger.info(f"图片尺寸: {img_width}x{img_height}")
|
||
|
||
# 创建Markdown内容
|
||
md_content = f"""---
|
||
title: 图片 {timestamp}
|
||
date: {time.strftime("%Y-%m-%d %H:%M:%S")}
|
||
tags: 图片
|
||
category: 图片
|
||
size: {file_size_str}
|
||
dimensions: {img_width}x{img_height}
|
||
---
|
||
|
||

|
||
"""
|
||
|
||
# 保存Markdown文件
|
||
md_path = os.path.join(image_dir, f"image_{timestamp}.md")
|
||
with open(md_path, 'w', encoding='utf-8') as f:
|
||
f.write(md_content)
|
||
|
||
self.logger.info(f"Markdown文件已保存到: {md_path}")
|
||
|
||
# 构建详细的通知消息
|
||
notification_message = f"图片尺寸: {img_width}x{img_height}\n"
|
||
notification_message += f"文件大小: {file_size_str}\n"
|
||
notification_message += f"保存到: {md_path}"
|
||
|
||
# 在GUI中显示保存成功的消息
|
||
if hasattr(self, 'gui') and self.gui:
|
||
try:
|
||
self.gui.show_notification(
|
||
"图片已保存",
|
||
notification_message,
|
||
"info",
|
||
["打开文件", "打开文件夹"],
|
||
md_path
|
||
)
|
||
except Exception as e:
|
||
# 如果通知显示失败,记录错误并尝试使用基本消息
|
||
self.logger.error(f"显示图片保存通知失败: {str(e)}")
|
||
try:
|
||
self.gui.show_message(f"图片已保存到: {md_path}")
|
||
except Exception as e2:
|
||
self.logger.error(f"显示基本消息也失败: {str(e2)}")
|
||
|
||
return md_path
|
||
|
||
except Exception as e:
|
||
error_msg = f"保存图片失败: {str(e)}"
|
||
self.logger.error(error_msg)
|
||
if hasattr(self, 'gui') and self.gui:
|
||
try:
|
||
self.gui.show_message(error_msg, error=True)
|
||
except Exception as e2:
|
||
self.logger.error(f"显示错误消息失败: {str(e2)}")
|
||
return None
|
||
|
||
def append_images_to_markdown(self, md_path, images):
|
||
"""将图片添加到已有的Markdown文件中"""
|
||
try:
|
||
if not os.path.exists(md_path):
|
||
self.logger.error(f"Markdown文件不存在: {md_path}")
|
||
return
|
||
|
||
# 获取Markdown文件所在目录
|
||
md_dir = os.path.dirname(md_path)
|
||
|
||
# 读取原始内容
|
||
with open(md_path, 'r', encoding='utf-8') as f:
|
||
original_content = f.read()
|
||
|
||
# 准备添加的图片内容
|
||
image_content = "\n\n## 相关图片\n\n"
|
||
|
||
# 图片信息汇总
|
||
total_size = 0
|
||
image_info_list = []
|
||
|
||
# 保存每张图片并添加引用
|
||
for i, image in enumerate(images):
|
||
try:
|
||
# 生成图片文件名
|
||
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
||
image_filename = f"image_{timestamp}_{i+1}.png"
|
||
image_path = os.path.join(md_dir, image_filename)
|
||
|
||
# 保存图片
|
||
image.save(image_path)
|
||
|
||
# 获取图片信息
|
||
file_size = os.path.getsize(image_path)
|
||
total_size += file_size
|
||
img_width, img_height = image.size
|
||
|
||
# 记录图片信息
|
||
image_info = {
|
||
'path': image_path,
|
||
'size': file_size,
|
||
'size_str': self.format_file_size(file_size),
|
||
'dimensions': f"{img_width}x{img_height}"
|
||
}
|
||
image_info_list.append(image_info)
|
||
|
||
self.logger.info(f"附加图片已保存到: {image_path}")
|
||
self.logger.info(f"图片大小: {image_info['size_str']}")
|
||
self.logger.info(f"图片尺寸: {image_info['dimensions']}")
|
||
|
||
# 添加图片引用
|
||
image_content += f" *{image_info['dimensions']} - {image_info['size_str']}*\n\n"
|
||
except Exception as e:
|
||
self.logger.error(f"保存附加图片失败: {e}")
|
||
|
||
# 更新Markdown文件
|
||
with open(md_path, 'w', encoding='utf-8') as f:
|
||
f.write(original_content + image_content)
|
||
|
||
self.logger.info(f"已将 {len(images)} 张图片添加到Markdown文件: {md_path}")
|
||
self.logger.info(f"总图片大小: {self.format_file_size(total_size)}")
|
||
|
||
# 构建通知消息
|
||
if image_info_list:
|
||
notification_message = f"已添加 {len(images)} 张图片到文档\n"
|
||
notification_message += f"总大小: {self.format_file_size(total_size)}\n"
|
||
for i, info in enumerate(image_info_list[:3]): # 只显示前3张图片的信息
|
||
notification_message += f"图片{i+1}: {info['dimensions']} - {info['size_str']}\n"
|
||
if len(image_info_list) > 3:
|
||
notification_message += f"... 以及 {len(image_info_list) - 3} 张其他图片\n"
|
||
notification_message += f"文件: {md_path}"
|
||
|
||
# 显示通知
|
||
if hasattr(self, 'gui') and self.gui:
|
||
try:
|
||
self.gui.show_notification(
|
||
"图片已添加到文档",
|
||
notification_message,
|
||
"info",
|
||
["打开文件", "打开文件夹"],
|
||
md_path
|
||
)
|
||
except Exception as e:
|
||
self.logger.error(f"显示图片添加通知失败: {str(e)}")
|
||
try:
|
||
self.gui.show_message(f"已添加 {len(images)} 张图片到: {md_path}")
|
||
except Exception as e2:
|
||
self.logger.error(f"显示基本消息也失败: {str(e2)}")
|
||
|
||
except Exception as e:
|
||
error_msg = f"添加图片到Markdown文件失败: {e}"
|
||
self.logger.error(error_msg)
|
||
if hasattr(self, 'gui') and self.gui:
|
||
try:
|
||
self.gui.show_message(error_msg, error=True)
|
||
except Exception as e2:
|
||
self.logger.error(f"显示错误消息失败: {str(e2)}")
|
||
|
||
def save_to_markdown(self, content):
|
||
"""保存内容到Markdown文件"""
|
||
try:
|
||
# 确保保存目录存在
|
||
if not os.path.exists(self.save_location):
|
||
os.makedirs(self.save_location)
|
||
self.logger.info(f"创建保存目录: {self.save_location}")
|
||
|
||
# 使用文档处理器处理内容
|
||
result = self.doc_processor.process_document(content, self.save_location)
|
||
|
||
# 保存处理后的内容
|
||
with open(result['path'], 'w', encoding='utf-8') as f:
|
||
f.write(result['content'])
|
||
|
||
# 获取文件大小
|
||
file_size = os.path.getsize(result['path'])
|
||
file_size_str = self.format_file_size(file_size)
|
||
|
||
self.logger.info(f"文件保存成功: {result['path']}")
|
||
self.logger.info(f"文件大小: {file_size_str}")
|
||
self.logger.info(f"标题: {result['title']}")
|
||
self.logger.info(f"分类: {result['category']}")
|
||
self.logger.info(f"标签: {', '.join(result['tags'])}")
|
||
|
||
# 构建详细的通知消息
|
||
notification_message = f"标题: {result['title']}\n"
|
||
notification_message += f"分类: {result['category']}\n"
|
||
notification_message += f"大小: {file_size_str}\n"
|
||
notification_message += f"标签: {', '.join(result['tags'])}\n"
|
||
notification_message += f"保存到: {result['path']}"
|
||
|
||
# 在GUI中显示保存成功的消息和操作选项
|
||
if hasattr(self, 'gui') and self.gui:
|
||
try:
|
||
self.gui.show_notification(
|
||
"内容已保存",
|
||
notification_message,
|
||
"info",
|
||
["打开文件", "打开文件夹"],
|
||
result['path']
|
||
)
|
||
except Exception as e:
|
||
# 如果通知显示失败,记录错误并尝试使用基本消息
|
||
self.logger.error(f"显示通知失败: {str(e)}")
|
||
try:
|
||
self.gui.show_message(f"内容已保存到: {result['path']}")
|
||
except Exception as e2:
|
||
self.logger.error(f"显示基本消息也失败: {str(e2)}")
|
||
|
||
return result['path']
|
||
|
||
except Exception as e:
|
||
error_msg = f"保存文件失败: {str(e)}"
|
||
self.logger.error(error_msg)
|
||
if hasattr(self, 'gui') and self.gui:
|
||
try:
|
||
self.gui.show_message(error_msg, error=True)
|
||
except Exception as e2:
|
||
self.logger.error(f"显示错误消息失败: {str(e2)}")
|
||
return None
|
||
|
||
def format_file_size(self, size_bytes):
|
||
"""格式化文件大小显示"""
|
||
if size_bytes < 1024:
|
||
return f"{size_bytes} B"
|
||
elif size_bytes < 1024 * 1024:
|
||
return f"{size_bytes/1024:.1f} KB"
|
||
elif size_bytes < 1024 * 1024 * 1024:
|
||
return f"{size_bytes/(1024*1024):.1f} MB"
|
||
else:
|
||
return f"{size_bytes/(1024*1024*1024):.1f} GB"
|
||
|
||
def simulate_copy(self):
|
||
keyboard.press('ctrl')
|
||
keyboard.press('c')
|
||
keyboard.release('c')
|
||
keyboard.release('ctrl')
|
||
time.sleep(0.1) # 等待复制操作完成
|
||
|
||
def start(self):
|
||
"""启动文本捕获服务"""
|
||
try:
|
||
if self.running and self._mouse_listener is not None:
|
||
self.logger.warning("服务已经在运行中,忽略重复启动请求")
|
||
return
|
||
|
||
self.logger.info("正在启动文本捕获服务...")
|
||
self.running = True
|
||
|
||
# 创建并启动鼠标监听器
|
||
try:
|
||
self._mouse_listener = mouse.Listener(on_click=self.on_click)
|
||
self._mouse_listener.start()
|
||
self.logger.info("鼠标监听器启动成功")
|
||
except Exception as e:
|
||
self.logger.error(f"启动鼠标监听器失败: {e}")
|
||
self.running = False
|
||
raise Exception(f"启动鼠标监听器失败: {e}")
|
||
|
||
self.logger.info("文本捕获服务启动成功")
|
||
except Exception as e:
|
||
self.logger.error(f"启动文本捕获服务失败: {e}")
|
||
self.running = False
|
||
# 确保清理任何可能已创建的资源
|
||
if self._mouse_listener:
|
||
try:
|
||
self._mouse_listener.stop()
|
||
except:
|
||
pass
|
||
self._mouse_listener = None
|
||
raise Exception(f"启动文本捕获服务失败: {e}")
|
||
|
||
def stop(self):
|
||
"""停止文本捕获服务"""
|
||
try:
|
||
if not self.running:
|
||
self.logger.warning("服务已经停止,忽略重复停止请求")
|
||
return
|
||
|
||
self.logger.info("正在停止文本捕获服务...")
|
||
self.running = False
|
||
|
||
# 停止鼠标监听器
|
||
if self._mouse_listener:
|
||
try:
|
||
self._mouse_listener.stop()
|
||
self.logger.info("鼠标监听器停止成功")
|
||
except Exception as e:
|
||
self.logger.error(f"停止鼠标监听器失败: {e}")
|
||
finally:
|
||
self._mouse_listener = None
|
||
|
||
# 停止键盘监听器(如果存在)
|
||
if self._keyboard_listener:
|
||
try:
|
||
self._keyboard_listener.stop()
|
||
self.logger.info("键盘监听器停止成功")
|
||
except Exception as e:
|
||
self.logger.error(f"停止键盘监听器失败: {e}")
|
||
finally:
|
||
self._keyboard_listener = None
|
||
|
||
self.logger.info("文本捕获服务停止成功")
|
||
except Exception as e:
|
||
self.logger.error(f"停止文本捕获服务失败: {e}")
|
||
# 确保设置状态为已停止,即使发生异常
|
||
self.running = False
|
||
self._mouse_listener = None
|
||
self._keyboard_listener = None
|
||
|
||
# 全局变量
|
||
app = None
|
||
window = None
|
||
service = None
|
||
tray_icon = None # 添加全局变量保存托盘图标引用
|
||
|
||
def main():
|
||
global app, window, service, tray_icon
|
||
|
||
try:
|
||
# 创建QApplication实例
|
||
app = QApplication(sys.argv)
|
||
|
||
# 设置应用程序属性,确保所有窗口关闭后应用程序不会退出
|
||
app.setQuitOnLastWindowClosed(False)
|
||
|
||
# 创建主窗口
|
||
from llmclipboard.gui import MainWindow
|
||
window = MainWindow()
|
||
|
||
# 保存服务实例的引用
|
||
service = window.service
|
||
|
||
# 保存系统托盘图标的引用
|
||
tray_icon = window.tray_icon
|
||
|
||
# 确保系统托盘图标可见
|
||
if tray_icon is not None and not tray_icon.isVisible():
|
||
print("确保系统托盘图标可见")
|
||
tray_icon.show()
|
||
|
||
# 确保tray_icon不会被垃圾回收,在应用程序退出前调用cleanup
|
||
app.aboutToQuit.connect(cleanup)
|
||
|
||
# 显示主窗口
|
||
window.show()
|
||
|
||
# 运行应用程序
|
||
return app.exec()
|
||
except Exception as e:
|
||
print(f"Application error: {str(e)}")
|
||
logging.error(f"Application error: {str(e)}", exc_info=True)
|
||
# 在打包环境中,确保错误信息被记录
|
||
if getattr(sys, 'frozen', False):
|
||
import traceback
|
||
error_log_path = os.path.join(os.path.dirname(sys.executable), 'error_log.txt')
|
||
with open(error_log_path, 'a', encoding='utf-8') as f:
|
||
f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - Error: {str(e)}\n")
|
||
traceback.print_exc(file=f)
|
||
return 1
|
||
|
||
def cleanup():
|
||
"""清理资源,确保应用程序正确退出"""
|
||
global tray_icon, service
|
||
|
||
print("Cleaning up resources...")
|
||
|
||
# 停止服务
|
||
if service is not None and hasattr(service, 'running') and service.running:
|
||
print("Stopping service...")
|
||
service.stop()
|
||
|
||
# 隐藏并销毁系统托盘图标
|
||
if tray_icon is not None:
|
||
print("Hiding tray icon...")
|
||
tray_icon.hide()
|
||
tray_icon.setVisible(False)
|
||
tray_icon = None
|
||
|
||
if __name__ == "__main__":
|
||
# 使用try-except块捕获可能的异常
|
||
try:
|
||
sys.exit(main())
|
||
except Exception as e:
|
||
print(f"Application error: {e}")
|
||
sys.exit(1)
|