v0.1.5 update 增加AI模型集成,增加图片保存,界面优化,异步优化
This commit is contained in:
parent
a57ef160d6
commit
ff616eca13
@ -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: 自动分类的目录
|
||||
---
|
||||
|
||||
正文内容...
|
||||
|
||||
## 相关图片
|
||||
|
||||

|
||||
|
||||

|
||||
```
|
||||
|
||||
### 目录结构
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
221
project/llmclipboard/llmclipboard/ai_processor.py
Normal file
221
project/llmclipboard/llmclipboard/ai_processor.py
Normal 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
|
||||
@ -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: 图片
|
||||
---
|
||||
|
||||

|
||||
"""
|
||||
|
||||
# 保存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"\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()
|
||||
|
||||
251
project/llmclipboard/llmclipboard/category_manager.py
Normal file
251
project/llmclipboard/llmclipboard/category_manager.py
Normal 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()
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
2943
project/llmclipboard/uv.lock
generated
2943
project/llmclipboard/uv.lock
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user