v0.1.5 update 增加AI模型集成,增加图片保存,界面优化,异步优化

This commit is contained in:
zhukang 2025-03-02 13:58:05 +08:00
parent a57ef160d6
commit ff616eca13
9 changed files with 4429 additions and 59 deletions

View File

@ -1,6 +1,6 @@
# LLMClipboard
智能剪贴板增强工具,专注于提供高质量的文本捕获和处理体验。支持智能标题提取、自动分类、关键词标签等功能,让您的笔记管理更轻松高效。
智能剪贴板增强工具,专注于提供高质量的文本和图片捕获与处理体验。支持智能标题提取、自动分类、关键词标签、大模型集成等功能,让您的笔记管理更轻松高效。
## ✨ 核心特性
@ -9,23 +9,33 @@
- 多格式支持HTML、Markdown、纯文本等
- 保持原文格式,包括段落、换行、列表等
- 智能清理,去除冗余内容和空白行
- **新增**: 大模型集成支持OpenAI和Ollama提供更智能的内容理解
### 🖼️ 图片处理
- **新增**: 支持剪贴板图片保存
- **新增**: 自动提取HTML内容中的图片
- **新增**: 支持网络图片和base64编码图片
- **新增**: 自动关联图片与文本内容
### 🏷️ 自动分类和标签
- 基于内容智能分类,自动归档到合适目录
- 使用 NLP 技术提取关键词作为标签
- 支持自定义分类规则和标签规则
- 使用NLP技术提取关键词作为标签
- **新增**: 可视化分类管理器,轻松编辑分类规则
- **新增**: 大模型辅助分类和标签生成
- YAML front matter 元数据支持
### 🎨 现代化界面
- 简洁优雅的用户界面
- 全新扁平化设计风格
- 支持深色/浅色主题
- 系统托盘常驻
- 快捷键操作支持
- **新增**: 增强的系统托盘功能,状态指示和快捷操作
- **新增**: 最近保存列表,快速访问历史内容
- **新增**: 桌面通知系统,保存反馈更直观
- **新增**: 开机自启动支持
## 🚀 快速开始
### 系统要求
- Python 3.8+
- Python 3.10+
- Windows 10/11
### 安装步骤
@ -43,7 +53,7 @@ python -m venv .venv
3. 安装依赖
```bash
pip install -r requirements.txt
uv sync
```
4. 运行程序
@ -51,6 +61,17 @@ pip install -r requirements.txt
python -m llmclipboard.app
```
### 使用大模型功能(可选)
- 对于OpenAI API:
- 在设置中选择"OpenAI兼容API"
- 输入您的API密钥和模型名称
- 支持自定义API地址兼容各种OpenAI兼容服务
- 对于Ollama:
- 安装Ollama (https://ollama.com/)
- 在设置中选择"Ollama本地模型"
- 选择您已下载的模型如llama2、mistral等
## 📁 项目结构
```
@ -60,6 +81,8 @@ llmclipboard/
│ ├── app.py # 主程序入口
│ ├── document_processor.py # 文档处理核心逻辑
│ ├── gui.py # 图形界面实现
│ ├── ai_processor.py # AI模型集成
│ ├── category_manager.py # 分类管理器
│ ├── categories.json # 分类配置文件
│ └── tests/ # 测试目录
│ ├── __init__.py
@ -71,7 +94,8 @@ llmclipboard/
├── config.ini # 程序配置文件
├── pyproject.toml # 项目依赖配置
├── README.md # 项目说明文档
└── text_capture.log # 日志文件
└── logs/ # 日志目录
└── llmclipboard.log # 日志文件
```
### 核心模块说明
@ -79,36 +103,65 @@ llmclipboard/
- **app.py**: 程序入口,包含主要的应用逻辑和事件循环
- **document_processor.py**: 文档处理核心,实现了标题提取、内容清理、标签生成等功能
- **gui.py**: 图形界面实现,包含主窗口和托盘图标的设计
- **ai_processor.py**: AI模型集成支持OpenAI和Ollama提供智能内容处理
- **category_manager.py**: 分类管理器,提供可视化编辑分类规则的界面
- **categories.json**: 预设的文档分类规则配置
- **tests/**: 单元测试集合,确保核心功能的正确性
## 📝 使用说明
### 基本操作
1. 双击右键:快速保存剪贴板内容
1. 双击右键:快速保存剪贴板内容(文本或图片)
2. 系统托盘图标:
- 左键单击:打开主界面
- 右键单击:显示菜单选项
- 左键双击:打开主界面
- 右键单击:显示菜单选项(启动/停止监听、设置、退出等)
- 图标状态:绿色表示正在监听,灰色表示已停止
### 高级功能
1. 分类管理:
- 点击"分类管理"按钮打开分类管理器
- 添加、删除、编辑分类和关键词
- 实时预览分类效果
2. AI模型设置
- 点击"AI模型设置"按钮配置大模型
- 选择OpenAI或Ollama
- 测试连接确保可用性
3. 最近保存列表:
- 在主界面查看最近保存的文件
- 双击打开对应文件
### 配置说明
配置文件位于 `~/.llmclipboard/config.yaml`
```yaml
save_location: "文档保存位置"
default_category: "默认分类"
auto_save: true # 是否自动保存
dark_mode: auto # 主题模式light/dark/auto
配置文件位于 `config.ini`
```ini
[Settings]
save_location = 文档保存位置
double_click_threshold = 0.3
[AI]
model_type = none/openai/ollama
api_key = 您的API密钥仅OpenAI
base_url = API地址
model = 模型名称
```
### 文档保存格式
```markdown
---
title: 自动提取或手动设置的标题
date: 2025-01-15 21:00:00
date: 2025-03-02 13:45:00
tags: [自动提取的标签]
category: 自动分类的目录
---
正文内容...
## 相关图片
![图片1](./image_20250302_134500_1.png)
![图片2](./image_20250302_134500_2.png)
```
### 目录结构
@ -118,7 +171,9 @@ save_location/
├── 学习/ # 学习笔记
├── 工作/ # 工作文档
├── 想法/ # 想法和灵感
└── 其他/ # 未分类文档
├── 资源/ # 资源文档
├── 图片/ # 图片文件
└── 未分类/ # 未分类文档
```
## 🔧 核心功能说明
@ -128,19 +183,28 @@ save_location/
- 处理多种标题格式Markdown、HTML等
- 标点符号智能处理
- 长度自动优化
- **新增**: 大模型辅助标题提取,更加智能准确
### 文本清理功能
### 文本和图片处理
- 保持原文格式和换行
- 智能去除冗余内容
- 优化空白行和段落间距
- 特殊字符处理
- **新增**: 图片提取和保存
- **新增**: 图文混合内容处理
### 自动分类系统
- 基于内容的智能分类
- 关键词匹配
- 自定义分类规则
- **新增**: 可视化分类规则编辑
- **新增**: 大模型辅助分类
- 默认分类支持
### 性能优化
- **新增**: 多线程处理避免UI卡顿
- **新增**: 异步图片下载和处理
- **新增**: 内存使用优化
## 📦 打包说明
### 生成可执行文件
@ -182,6 +246,8 @@ save_location/
- 文件保存操作记录
- 错误和异常信息
- 调试信息DEBUG 级别,默认不记录)
- **新增**: AI模型调用日志
- **新增**: 图片处理日志
## 🔄 最近更新
@ -200,7 +266,7 @@ save_location/
- 确保 UTF-8 编码支持
- 优化日志格式和可读性
### 2025-01-16(v0.1.4)
### 2025-01-16 (v0.1.4)
- 添加应用程序打包支持
- 使用 PyInstaller 打包为独立可执行文件
- 添加打包配置文件 `llmclipboard.spec`
@ -217,6 +283,31 @@ save_location/
- 补充日志系统使用说明
- 更新项目结构说明
### 2025-03-02 (v0.1.5)
- 🤖 添加AI模型集成
- 支持OpenAI兼容API
- 支持Ollama本地模型
- AI辅助标题提取、分类和标签生成
- 🖼️ 增强图片处理功能
- 支持剪贴板图片保存
- 自动提取HTML内容中的图片
- 支持网络图片和base64编码图片
- 图文混合内容处理
- 🎨 界面改进
- 全新扁平化设计风格
- 增强的系统托盘功能
- 添加最近保存列表
- 桌面通知系统
- 开机自启动支持
- 🔧 功能增强
- 添加可视化分类管理器
- 多线程处理避免UI卡顿
- 异步图片下载和处理
- 内存使用优化
## 🤝 贡献指南
1. Fork 项目
@ -240,4 +331,4 @@ save_location/
如果您遇到问题或有建议:
1. 提交 [Issue](../../issues)
2. 加入讨论组:[Discussions](../../discussions)
3. 发送邮件至:[your-email@example.com]
3. 发送邮件至:[your-email@example.com]

View File

@ -2,3 +2,8 @@
double_click_threshold = 0.3
save_location = F:/BaiduSyncdisk/note/NoteForZhukang/000 inbox
[AI]
model_type = ollama
base_url = http://localhost:11434/api
model = qwen2.5:14b

View File

@ -0,0 +1,221 @@
# ai_processor.py
import logging
import os
import json
import time
from datetime import datetime
class AIProcessor:
"""AI处理器用于集成大模型功能"""
def __init__(self, config=None):
self.logger = logging.getLogger(__name__)
self.config = config or {}
self.model_type = self.config.get('model_type', 'none') # 'openai', 'ollama', 'none'
self.client = None
def initialize(self):
"""初始化AI处理器"""
if self.model_type == 'none':
self.logger.info("AI处理器未启用")
return False
try:
if self.model_type == 'openai':
self.logger.info("初始化OpenAI客户端")
try:
import openai
api_key = self.config.get('api_key', '')
base_url = self.config.get('base_url', 'https://api.openai.com/v1')
if not api_key:
self.logger.error("未配置OpenAI API密钥")
return False
openai.api_key = api_key
if base_url != 'https://api.openai.com/v1':
openai.base_url = base_url
self.client = openai.OpenAI()
self.logger.info("OpenAI客户端初始化成功")
return True
except ImportError:
self.logger.error("未安装openai库请使用pip install openai安装")
return False
except Exception as e:
self.logger.error(f"初始化OpenAI客户端失败: {e}")
return False
elif self.model_type == 'ollama':
self.logger.info("初始化Ollama客户端")
try:
import requests
self.base_url = self.config.get('base_url', 'http://localhost:11434/api')
self.model = self.config.get('model', 'llama2')
# 测试连接
try:
# 修改为直接测试模型可用性
test_prompt = "Hello"
response = requests.post(
f"{self.base_url}/generate",
json={"model": self.model, "prompt": test_prompt, "stream": False}
)
if response.status_code != 200:
self.logger.error(f"连接Ollama服务失败: {response.status_code}")
return False
self.logger.info(f"Ollama客户端初始化成功使用模型: {self.model}")
return True
except Exception as e:
self.logger.error(f"测试Ollama连接失败: {e}")
return False
except ImportError:
self.logger.error("未安装requests库请使用pip install requests安装")
return False
except Exception as e:
self.logger.error(f"初始化Ollama客户端失败: {e}")
return False
else:
self.logger.error(f"不支持的模型类型: {self.model_type}")
return False
except Exception as e:
self.logger.error(f"初始化AI处理器失败: {e}")
return False
def extract_title(self, content):
"""使用AI提取标题"""
if self.model_type == 'none' or not content:
return None
prompt = f"请为以下内容提取一个简洁明了的标题不超过30个字符:\n\n{content[:1000]}"
try:
if self.model_type == 'openai':
response = self.client.chat.completions.create(
model=self.config.get('model', 'gpt-3.5-turbo'),
messages=[{"role": "user", "content": prompt}],
max_tokens=50
)
title = response.choices[0].message.content.strip()
self.logger.info(f"OpenAI提取的标题: {title}")
return title
elif self.model_type == 'ollama':
import requests
self.logger.info(f"使用Ollama提取标题模型: {self.model}")
response = requests.post(
f"{self.base_url}/generate",
json={"model": self.model, "prompt": prompt, "stream": False}
)
if response.status_code != 200:
self.logger.error(f"Ollama API调用失败: {response.status_code}")
self.logger.error(f"错误信息: {response.text}")
return None
result = response.json()
title = result.get('response', '').strip()
self.logger.info(f"Ollama提取的标题: {title}")
return title
except Exception as e:
self.logger.error(f"AI标题提取失败: {e}")
self.logger.error(f"错误详情: {str(e)}")
import traceback
self.logger.error(traceback.format_exc())
return None
def categorize(self, content):
"""使用AI进行内容分类"""
if self.model_type == 'none' or not content:
return None
categories = ["技术", "学习", "工作", "想法", "资源"]
prompt = f"请将以下内容分类到这些类别之一: {', '.join(categories)}。只返回类别名称,不要有其他文字:\n\n{content[:1000]}"
try:
if self.model_type == 'openai':
response = self.client.chat.completions.create(
model=self.config.get('model', 'gpt-3.5-turbo'),
messages=[{"role": "user", "content": prompt}],
max_tokens=10
)
category = response.choices[0].message.content.strip()
# 确保返回的是有效类别
if category in categories:
self.logger.info(f"OpenAI分类结果: {category}")
return category
return None
elif self.model_type == 'ollama':
import requests
self.logger.info(f"使用Ollama进行分类模型: {self.model}")
response = requests.post(
f"{self.base_url}/generate",
json={"model": self.model, "prompt": prompt, "stream": False}
)
if response.status_code != 200:
self.logger.error(f"Ollama API调用失败: {response.status_code}")
return None
category = response.json().get('response', '').strip()
self.logger.info(f"Ollama分类结果: {category}")
# 确保返回的是有效类别
if category in categories:
return category
return None
except Exception as e:
self.logger.error(f"AI分类失败: {e}")
import traceback
self.logger.error(traceback.format_exc())
return None
def generate_tags(self, content):
"""使用AI生成标签"""
if self.model_type == 'none' or not content:
return None
prompt = f"请为以下内容生成3-5个关键词标签每个标签不超过5个字符只返回标签用逗号分隔:\n\n{content[:1000]}"
try:
if self.model_type == 'openai':
response = self.client.chat.completions.create(
model=self.config.get('model', 'gpt-3.5-turbo'),
messages=[{"role": "user", "content": prompt}],
max_tokens=30
)
tags_text = response.choices[0].message.content.strip()
tags = [tag.strip() for tag in tags_text.split(',')]
self.logger.info(f"OpenAI生成的标签: {tags}")
return tags
elif self.model_type == 'ollama':
import requests
self.logger.info(f"使用Ollama生成标签模型: {self.model}")
response = requests.post(
f"{self.base_url}/generate",
json={"model": self.model, "prompt": prompt, "stream": False}
)
if response.status_code != 200:
self.logger.error(f"Ollama API调用失败: {response.status_code}")
return None
tags_text = response.json().get('response', '').strip()
tags = [tag.strip() for tag in tags_text.split(',')]
self.logger.info(f"Ollama生成的标签: {tags}")
return tags
except Exception as e:
self.logger.error(f"AI标签生成失败: {e}")
import traceback
self.logger.error(traceback.format_exc())
return None

View File

@ -13,6 +13,11 @@ from logging.handlers import RotatingFileHandler
from PyQt6.QtWidgets import QApplication
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:
@ -36,7 +41,10 @@ class TextCaptureService:
self.setup_logging()
self._mouse_listener = None
self._keyboard_listener = None
self.doc_processor = DocumentProcessor()
# 加载AI配置
self.ai_config = self.load_ai_config()
self.doc_processor = DocumentProcessor(ai_config=self.ai_config)
def load_config(self):
self.config = configparser.ConfigParser()
@ -46,6 +54,20 @@ class TextCaptureService:
self.double_click_threshold = self.config.getfloat('Settings', 'double_click_threshold', fallback=0.3)
self.save_location = self.config.get('Settings', 'save_location',
fallback=os.path.expanduser('~/Documents'))
def load_ai_config(self):
"""加载AI配置"""
ai_config = {}
if self.config.has_section('AI'):
for key, value in self.config.items('AI'):
ai_config[key] = value
return ai_config
def update_ai_config(self, new_config):
"""更新AI配置"""
self.ai_config = new_config
if hasattr(self, 'doc_processor'):
self.doc_processor.update_ai_config(new_config)
def setup_logging(self):
try:
@ -132,18 +154,109 @@ class TextCaptureService:
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 content
return {"type": "text", "data": content}
except Exception as e:
self.logger.debug(f"获取Unicode文本失败: {e}")
@ -153,15 +266,6 @@ class TextCaptureService:
html_content = win32clipboard.GetClipboardData(CF_HTML)
self.logger.info("获取HTML格式内容")
if html_content:
# 解析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
# 从HTML字符串中提取实际的HTML内容
try:
if isinstance(html_content, bytes):
@ -183,12 +287,49 @@ class TextCaptureService:
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 content
return {"type": "text", "data": content}
except Exception as e:
self.logger.debug(f"获取HTML格式失败: {e}")
@ -201,7 +342,7 @@ class TextCaptureService:
text_content = text_content.decode('gbk', errors='ignore')
content = text_content
if self.is_valid_content(content):
return content
return {"type": "text", "data": content}
except Exception as e:
self.logger.debug(f"获取普通文本失败: {e}")
@ -211,7 +352,7 @@ class TextCaptureService:
self.gui.show_message("剪贴板内容为空或无效", error=True)
return None
return content
return {"type": "text", "data": content}
except Exception as e:
self.logger.error(f"获取剪贴板内容失败: {e}")
@ -224,6 +365,31 @@ class TextCaptureService:
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
@ -239,13 +405,112 @@ class TextCaptureService:
# 获取剪贴板内容
content = self.get_clipboard_content()
if content:
self.logger.info("开始保存内容")
self.save_to_markdown(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)
self.logger.info(f"图片已保存到: {image_path}")
# 创建Markdown内容
md_content = f"""---
title: 图片 {timestamp}
date: {time.strftime("%Y-%m-%d %H:%M:%S")}
tags: 图片
category: 图片
---
![图片](./{image_filename})
"""
# 保存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}")
# 在GUI中显示保存成功的消息
if hasattr(self, 'gui') and self.gui:
self.gui.show_notification(
"图片已保存",
f"保存到: {md_path}",
"info",
["打开文件", "打开文件夹"],
md_path
)
return md_path
except Exception as e:
error_msg = f"保存图片失败: {str(e)}"
self.logger.error(error_msg)
if hasattr(self, 'gui') and self.gui:
self.gui.show_message(error_msg, error=True)
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"
# 保存每张图片并添加引用
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)
self.logger.info(f"附加图片已保存到: {image_path}")
# 添加图片引用
image_content += f"![图片{i+1}](./{image_filename})\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}")
except Exception as e:
self.logger.error(f"添加图片到Markdown文件失败: {e}")
def save_to_markdown(self, content):
"""保存内容到Markdown文件"""
try:
@ -266,15 +531,24 @@ class TextCaptureService:
self.logger.info(f"分类: {result['category']}")
self.logger.info(f"标签: {', '.join(result['tags'])}")
# 在GUI中显示保存成功的消息
# 在GUI中显示保存成功的消息和操作选项
if hasattr(self, 'gui') and self.gui:
self.gui.show_message(f"已保存到: {result['path']}")
self.gui.show_notification(
"内容已保存",
f"标题: {result['title']}\n保存到: {result['path']}",
"info",
["打开文件", "打开文件夹"],
result['path']
)
return result['path']
except Exception as e:
error_msg = f"保存文件失败: {str(e)}"
self.logger.error(error_msg)
if hasattr(self, 'gui') and self.gui:
self.gui.show_message(error_msg, error=True)
return None
def simulate_copy(self):
keyboard.press('ctrl')
@ -308,4 +582,4 @@ def main():
sys.exit(app.exec())
if __name__ == "__main__":
main()
main()

View File

@ -0,0 +1,251 @@
# -*- coding: utf-8 -*-
"""分类管理器模块"""
import os
import json
import logging
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QListWidget, QLineEdit, QMessageBox,
QListWidgetItem, QWidget, QGroupBox, QFormLayout)
from PyQt6.QtCore import Qt, pyqtSignal
logger = logging.getLogger(__name__)
class CategoryEditWidget(QWidget):
"""分类编辑小部件"""
def __init__(self, category_name, keywords, parent=None):
super().__init__(parent)
self.category_name = category_name
self.keywords = keywords.copy()
# 创建布局
layout = QVBoxLayout(self)
# 分类名称
name_layout = QHBoxLayout()
name_label = QLabel("分类名称:")
self.name_edit = QLineEdit(category_name)
name_layout.addWidget(name_label)
name_layout.addWidget(self.name_edit)
layout.addLayout(name_layout)
# 关键词列表
keywords_group = QGroupBox("关键词")
keywords_layout = QVBoxLayout()
# 关键词列表
self.keywords_list = QListWidget()
for keyword in keywords:
self.keywords_list.addItem(keyword)
keywords_layout.addWidget(self.keywords_list)
# 关键词操作按钮
keywords_buttons = QHBoxLayout()
self.add_keyword_edit = QLineEdit()
self.add_keyword_edit.setPlaceholderText("输入新关键词")
self.add_keyword_button = QPushButton("添加")
self.add_keyword_button.clicked.connect(self.add_keyword)
self.remove_keyword_button = QPushButton("删除")
self.remove_keyword_button.clicked.connect(self.remove_keyword)
keywords_buttons.addWidget(self.add_keyword_edit)
keywords_buttons.addWidget(self.add_keyword_button)
keywords_buttons.addWidget(self.remove_keyword_button)
keywords_layout.addLayout(keywords_buttons)
keywords_group.setLayout(keywords_layout)
layout.addWidget(keywords_group)
def add_keyword(self):
"""添加关键词"""
keyword = self.add_keyword_edit.text().strip()
if keyword and keyword not in self.keywords:
self.keywords.append(keyword)
self.keywords_list.addItem(keyword)
self.add_keyword_edit.clear()
elif keyword in self.keywords:
QMessageBox.warning(self, "警告", "该关键词已存在")
def remove_keyword(self):
"""删除关键词"""
selected_items = self.keywords_list.selectedItems()
if not selected_items:
return
for item in selected_items:
keyword = item.text()
self.keywords.remove(keyword)
self.keywords_list.takeItem(self.keywords_list.row(item))
def get_category_data(self):
"""获取分类数据"""
return {
"name": self.name_edit.text().strip(),
"keywords": self.keywords
}
class CategoryManagerDialog(QDialog):
"""分类管理器对话框"""
categories_updated = pyqtSignal(dict)
def __init__(self, categories, parent=None):
super().__init__(parent)
self.setWindowTitle("分类管理器")
self.resize(700, 500)
self.categories = categories.copy()
# 创建布局
layout = QVBoxLayout(self)
# 分类列表和编辑区域
main_layout = QHBoxLayout()
# 左侧分类列表
left_layout = QVBoxLayout()
left_layout.addWidget(QLabel("分类列表:"))
self.categories_list = QListWidget()
self.categories_list.currentRowChanged.connect(self.on_category_selected)
left_layout.addWidget(self.categories_list)
# 分类操作按钮
categories_buttons = QHBoxLayout()
self.add_category_button = QPushButton("添加分类")
self.add_category_button.clicked.connect(self.add_category)
self.remove_category_button = QPushButton("删除分类")
self.remove_category_button.clicked.connect(self.remove_category)
categories_buttons.addWidget(self.add_category_button)
categories_buttons.addWidget(self.remove_category_button)
left_layout.addLayout(categories_buttons)
# 右侧编辑区域
right_layout = QVBoxLayout()
right_layout.addWidget(QLabel("编辑分类:"))
self.edit_container = QWidget()
self.edit_layout = QVBoxLayout(self.edit_container)
right_layout.addWidget(self.edit_container)
# 添加到主布局
main_layout.addLayout(left_layout, 1)
main_layout.addLayout(right_layout, 2)
layout.addLayout(main_layout)
# 底部按钮
buttons_layout = QHBoxLayout()
self.save_button = QPushButton("保存")
self.save_button.clicked.connect(self.save_categories)
self.cancel_button = QPushButton("取消")
self.cancel_button.clicked.connect(self.reject)
buttons_layout.addStretch()
buttons_layout.addWidget(self.save_button)
buttons_layout.addWidget(self.cancel_button)
layout.addLayout(buttons_layout)
# 初始化分类列表
self.init_categories_list()
def init_categories_list(self):
"""初始化分类列表"""
self.categories_list.clear()
for category in self.categories:
self.categories_list.addItem(category)
if self.categories_list.count() > 0:
self.categories_list.setCurrentRow(0)
def on_category_selected(self, row):
"""选择分类时的处理"""
# 清除编辑区域
while self.edit_layout.count():
item = self.edit_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
if row < 0:
return
category = self.categories_list.item(row).text()
keywords = self.categories[category]
# 创建编辑小部件
edit_widget = CategoryEditWidget(category, keywords, self)
self.edit_layout.addWidget(edit_widget)
def add_category(self):
"""添加新分类"""
# 创建默认分类名称
base_name = "新分类"
name = base_name
counter = 1
while name in self.categories:
name = f"{base_name}{counter}"
counter += 1
# 添加到分类字典
self.categories[name] = []
# 添加到列表
self.categories_list.addItem(name)
self.categories_list.setCurrentRow(self.categories_list.count() - 1)
def remove_category(self):
"""删除分类"""
row = self.categories_list.currentRow()
if row < 0:
return
category = self.categories_list.item(row).text()
# 确认删除
reply = QMessageBox.question(
self,
"确认删除",
f"确定要删除分类 '{category}' 吗?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
# 从字典中删除
del self.categories[category]
# 从列表中删除
self.categories_list.takeItem(row)
# 选择新的行
if self.categories_list.count() > 0:
self.categories_list.setCurrentRow(max(0, row - 1))
def save_categories(self):
"""保存分类"""
# 获取当前编辑的分类数据
row = self.categories_list.currentRow()
if row >= 0:
edit_widget = self.edit_layout.itemAt(0).widget()
if edit_widget:
data = edit_widget.get_category_data()
old_name = self.categories_list.item(row).text()
# 如果分类名称已更改
if data["name"] != old_name:
# 检查新名称是否已存在
if data["name"] in self.categories and data["name"] != old_name:
QMessageBox.warning(self, "警告", f"分类名称 '{data['name']}' 已存在")
return
# 更新分类名称
self.categories[data["name"]] = data["keywords"]
del self.categories[old_name]
self.categories_list.item(row).setText(data["name"])
else:
# 仅更新关键词
self.categories[old_name] = data["keywords"]
# 发送更新信号
self.categories_updated.emit(self.categories)
# 接受对话框
self.accept()

View File

@ -10,6 +10,7 @@ from datetime import datetime
from collections import Counter
from pathlib import Path
import logging
from .ai_processor import AIProcessor
# 配置日志记录
logger = logging.getLogger(__name__)
@ -31,10 +32,17 @@ console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
class DocumentProcessor:
def __init__(self, config_path=None):
def __init__(self, config_path=None, ai_config=None):
self.config_path = config_path or os.path.join(os.path.dirname(__file__), 'categories.json')
self.load_categories()
# 初始化AI处理器
self.ai_config = ai_config or {}
self.ai_processor = AIProcessor(self.ai_config)
self.ai_enabled = self.ai_config and self.ai_config.get('model_type', 'none') != 'none'
if self.ai_enabled:
self.ai_processor.initialize()
def load_categories(self):
"""加载预定义的分类规则"""
if os.path.exists(self.config_path):
@ -55,6 +63,12 @@ class DocumentProcessor:
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
with open(self.config_path, 'w', encoding='utf-8') as f:
json.dump(self.categories, f, ensure_ascii=False, indent=2)
def update_categories(self, new_categories):
"""更新分类规则"""
self.categories = new_categories
self._save_categories()
logger.info("分类规则已更新")
def clean_content(self, content):
"""清理内容,移除多余的换行符和空白"""
@ -87,8 +101,27 @@ class DocumentProcessor:
logger.debug("清理后内容的前100个字符: %s", content[:100])
return content
def update_ai_config(self, ai_config):
"""更新AI配置"""
self.ai_config = ai_config or {}
self.ai_processor = AIProcessor(self.ai_config)
self.ai_enabled = self.ai_config and self.ai_config.get('model_type', 'none') != 'none'
if self.ai_enabled:
self.ai_processor.initialize()
def extract_title(self, content):
"""从内容中提取标题"""
# 尝试使用AI提取标题
if self.ai_enabled:
try:
ai_title = self.ai_processor.extract_title(content)
if ai_title:
logger.info(f"AI提取的标题: {ai_title}")
return ai_title
except Exception as e:
logger.error(f"AI提取标题失败: {e}")
# 如果AI提取失败或未启用使用传统方法
if not content or content.isspace():
logger.debug("收到空内容,返回默认标题")
logger.debug("收到空内容或纯空白内容")
@ -175,6 +208,17 @@ class DocumentProcessor:
def extract_tags(self, content):
"""提取内容的标签"""
# 尝试使用AI生成标签
if self.ai_enabled:
try:
ai_tags = self.ai_processor.generate_tags(content)
if ai_tags:
logger.info(f"AI生成的标签: {ai_tags}")
return ai_tags
except Exception as e:
logger.error(f"AI生成标签失败: {e}")
# 如果AI生成失败或未启用使用传统方法
# 使用结巴分词提取关键词作为标签
tags = set(jieba.analyse.extract_tags(content, topK=5))
@ -187,6 +231,17 @@ class DocumentProcessor:
def categorize(self, content, tags):
"""对内容进行分类"""
# 尝试使用AI进行分类
if self.ai_enabled:
try:
ai_category = self.ai_processor.categorize(content)
if ai_category:
logger.info(f"AI分类结果: {ai_category}")
return ai_category
except Exception as e:
logger.error(f"AI分类失败: {e}")
# 如果AI分类失败或未启用使用传统方法
# 基于标签和内容关键词进行分类
category_scores = Counter()

View File

@ -2,18 +2,222 @@ import sys
import os
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QPushButton, QLabel, QFileDialog,
QSystemTrayIcon, QMenu, QSpinBox, QStyle, QLineEdit)
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.4")
self.setMinimumWidth(500)
self.setWindowTitle("LLMClipboard v0.1.5")
self.setMinimumWidth(600)
self.setMinimumHeight(400)
# 初始化服务
from .app import TextCaptureService
@ -27,43 +231,110 @@ class MainWindow(QMainWindow):
# 创建主窗口部件
main_widget = QWidget()
self.setCentralWidget(main_widget)
layout = QVBoxLayout(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)
layout.addLayout(save_path_layout)
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)
layout.addLayout(threshold_layout)
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("状态: 已停止")
layout.addWidget(self.status_label)
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)
layout.addLayout(button_layout)
main_layout.addLayout(button_layout)
# 系统托盘
self.setup_system_tray()
@ -71,6 +342,9 @@ class MainWindow(QMainWindow):
# 设置样式
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)
@ -79,6 +353,16 @@ class MainWindow(QMainWindow):
'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):
@ -87,16 +371,101 @@ class MainWindow(QMainWindow):
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()
@ -104,19 +473,65 @@ class MainWindow(QMainWindow):
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.tray_icon.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon))
# 创建不同状态的图标
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()
show_action = QAction("显示", self)
quit_action = QAction("退出", self)
# 状态显示
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)
quit_action.triggered.connect(self.close)
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)
@ -128,6 +543,63 @@ class MainWindow(QMainWindow):
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):
# 根据系统主题设置深色/浅色模式
@ -155,6 +627,63 @@ class MainWindow(QMainWindow):
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)

View File

@ -1,6 +1,6 @@
[project]
name = "llmclipboard"
version = "0.1.4"
version = "0.1.5"
description = "A cross-platform rich text capture tool with GUI support"
readme = "README.md"
requires-python = ">=3.10"
@ -13,7 +13,10 @@ dependencies = [
"PyQt6",
"darkdetect",
"qt-material",
"jieba"
"jieba",
"Pillow",
"requests",
"openai"
]
[project.scripts]

File diff suppressed because it is too large Load Diff