|
|
|
|
@ -0,0 +1,381 @@
|
|
|
|
|
# ai_chat_panel.py - AI对话面板组件
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import requests
|
|
|
|
|
import threading
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from PyQt5.QtWidgets import (
|
|
|
|
|
QWidget, QVBoxLayout, QHBoxLayout, QTextEdit, QLineEdit,
|
|
|
|
|
QPushButton, QLabel, QScrollArea, QFrame, QMessageBox
|
|
|
|
|
)
|
|
|
|
|
from PyQt5.QtCore import Qt, pyqtSignal, QThread, QTimer, QSize, pyqtSlot
|
|
|
|
|
from PyQt5.QtGui import QFont, QColor, QTextCursor, QIcon, QPixmap
|
|
|
|
|
from PyQt5.QtGui import QTextDocument, QTextCharFormat
|
|
|
|
|
|
|
|
|
|
class AIChatPanel(QWidget):
|
|
|
|
|
"""AI对话面板"""
|
|
|
|
|
|
|
|
|
|
# 信号定义 - 用于线程安全的UI更新
|
|
|
|
|
update_chat_display = pyqtSignal(str) # 更新聊天显示信号
|
|
|
|
|
|
|
|
|
|
def __init__(self, parent=None):
|
|
|
|
|
super().__init__(parent)
|
|
|
|
|
self.api_key = ""
|
|
|
|
|
self.conversation_history = []
|
|
|
|
|
self.current_streaming_content = ""
|
|
|
|
|
self.is_streaming = False
|
|
|
|
|
self.streaming_thread = None
|
|
|
|
|
self.streaming_timer = None
|
|
|
|
|
|
|
|
|
|
# 加载API密钥
|
|
|
|
|
self.load_api_key()
|
|
|
|
|
|
|
|
|
|
self.init_ui()
|
|
|
|
|
|
|
|
|
|
# 连接信号到槽
|
|
|
|
|
self.update_chat_display.connect(self.on_update_chat_display)
|
|
|
|
|
|
|
|
|
|
def load_api_key(self):
|
|
|
|
|
"""从本地文件加载API密钥"""
|
|
|
|
|
config_file = os.path.join(
|
|
|
|
|
os.path.dirname(__file__),
|
|
|
|
|
"..", "..", "resources", "config", "deepseek_api.json"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
if os.path.exists(config_file):
|
|
|
|
|
with open(config_file, 'r', encoding='utf-8') as f:
|
|
|
|
|
config = json.load(f)
|
|
|
|
|
self.api_key = config.get('api_key', '')
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"加载API密钥失败: {e}")
|
|
|
|
|
|
|
|
|
|
def init_ui(self):
|
|
|
|
|
"""初始化UI"""
|
|
|
|
|
main_layout = QVBoxLayout()
|
|
|
|
|
main_layout.setContentsMargins(8, 8, 8, 8)
|
|
|
|
|
main_layout.setSpacing(8)
|
|
|
|
|
|
|
|
|
|
# 标题栏
|
|
|
|
|
header_layout = QHBoxLayout()
|
|
|
|
|
header_label = QLabel("AI 助手")
|
|
|
|
|
header_font = QFont()
|
|
|
|
|
header_font.setBold(True)
|
|
|
|
|
header_font.setPointSize(11)
|
|
|
|
|
header_label.setFont(header_font)
|
|
|
|
|
header_layout.addWidget(header_label)
|
|
|
|
|
header_layout.addStretch()
|
|
|
|
|
|
|
|
|
|
# 清空历史按钮
|
|
|
|
|
clear_btn = QPushButton("清空")
|
|
|
|
|
clear_btn.setMaximumWidth(50)
|
|
|
|
|
clear_btn.setFont(QFont("微软雅黑", 9))
|
|
|
|
|
clear_btn.clicked.connect(self.clear_history)
|
|
|
|
|
header_layout.addWidget(clear_btn)
|
|
|
|
|
|
|
|
|
|
main_layout.addLayout(header_layout)
|
|
|
|
|
|
|
|
|
|
# 分割线
|
|
|
|
|
line = QFrame()
|
|
|
|
|
line.setFrameShape(QFrame.HLine)
|
|
|
|
|
line.setStyleSheet("color: #d0d0d0;")
|
|
|
|
|
main_layout.addWidget(line)
|
|
|
|
|
|
|
|
|
|
# 对话显示区域
|
|
|
|
|
self.chat_display = QTextEdit()
|
|
|
|
|
self.chat_display.setReadOnly(True)
|
|
|
|
|
self.chat_display.setStyleSheet("""
|
|
|
|
|
QTextEdit {
|
|
|
|
|
background-color: #ffffff;
|
|
|
|
|
border: 1px solid #d0d0d0;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
font-family: 'Segoe UI', '微软雅黑', sans-serif;
|
|
|
|
|
font-size: 10pt;
|
|
|
|
|
padding: 8px;
|
|
|
|
|
color: #333333;
|
|
|
|
|
}
|
|
|
|
|
""")
|
|
|
|
|
self.chat_display.setMinimumHeight(400)
|
|
|
|
|
main_layout.addWidget(self.chat_display)
|
|
|
|
|
|
|
|
|
|
# 输入区域
|
|
|
|
|
input_layout = QVBoxLayout()
|
|
|
|
|
input_layout.setSpacing(4)
|
|
|
|
|
|
|
|
|
|
# 输入框
|
|
|
|
|
self.input_field = QLineEdit()
|
|
|
|
|
self.input_field.setPlaceholderText("输入您的问题或请求...")
|
|
|
|
|
self.input_field.setStyleSheet("""
|
|
|
|
|
QLineEdit {
|
|
|
|
|
background-color: #f9f9f9;
|
|
|
|
|
border: 1px solid #d0d0d0;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
font-family: '微软雅黑', sans-serif;
|
|
|
|
|
font-size: 10pt;
|
|
|
|
|
padding: 6px;
|
|
|
|
|
color: #333333;
|
|
|
|
|
}
|
|
|
|
|
QLineEdit:focus {
|
|
|
|
|
border: 1px solid #0078d4;
|
|
|
|
|
background-color: #ffffff;
|
|
|
|
|
}
|
|
|
|
|
""")
|
|
|
|
|
self.input_field.returnPressed.connect(self.send_user_message)
|
|
|
|
|
input_layout.addWidget(self.input_field)
|
|
|
|
|
|
|
|
|
|
# 按钮区域
|
|
|
|
|
button_layout = QHBoxLayout()
|
|
|
|
|
button_layout.setSpacing(4)
|
|
|
|
|
|
|
|
|
|
# 发送按钮
|
|
|
|
|
self.send_btn = QPushButton("发送")
|
|
|
|
|
self.send_btn.setFont(QFont("微软雅黑", 9))
|
|
|
|
|
self.send_btn.setStyleSheet("""
|
|
|
|
|
QPushButton {
|
|
|
|
|
background-color: #0078d4;
|
|
|
|
|
color: white;
|
|
|
|
|
border: none;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
padding: 6px 16px;
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
}
|
|
|
|
|
QPushButton:hover {
|
|
|
|
|
background-color: #0063b1;
|
|
|
|
|
}
|
|
|
|
|
QPushButton:pressed {
|
|
|
|
|
background-color: #005a9e;
|
|
|
|
|
}
|
|
|
|
|
""")
|
|
|
|
|
self.send_btn.clicked.connect(self.send_user_message)
|
|
|
|
|
button_layout.addStretch()
|
|
|
|
|
button_layout.addWidget(self.send_btn)
|
|
|
|
|
|
|
|
|
|
input_layout.addLayout(button_layout)
|
|
|
|
|
main_layout.addLayout(input_layout)
|
|
|
|
|
|
|
|
|
|
self.setLayout(main_layout)
|
|
|
|
|
|
|
|
|
|
# 设置背景颜色
|
|
|
|
|
self.setStyleSheet("""
|
|
|
|
|
AIChatPanel {
|
|
|
|
|
background-color: #f5f5f5;
|
|
|
|
|
border-left: 1px solid #d0d0d0;
|
|
|
|
|
}
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
def send_user_message(self):
|
|
|
|
|
"""发送用户消息"""
|
|
|
|
|
if not self.api_key:
|
|
|
|
|
QMessageBox.warning(self, "警告", "请先配置DeepSeek API密钥")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
message = self.input_field.text().strip()
|
|
|
|
|
if not message:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# 清空输入框
|
|
|
|
|
self.input_field.clear()
|
|
|
|
|
|
|
|
|
|
# 禁用发送按钮
|
|
|
|
|
self.send_btn.setEnabled(False)
|
|
|
|
|
self.input_field.setEnabled(False)
|
|
|
|
|
|
|
|
|
|
# 显示用户消息
|
|
|
|
|
self.add_message_to_display("用户", message)
|
|
|
|
|
|
|
|
|
|
# 添加用户消息到对话历史
|
|
|
|
|
self.conversation_history.append({"sender": "用户", "message": message})
|
|
|
|
|
|
|
|
|
|
# 显示AI正在思考
|
|
|
|
|
self.add_message_to_display("AI助手", "正在思考...")
|
|
|
|
|
self.conversation_history.append({"sender": "AI助手", "message": ""})
|
|
|
|
|
|
|
|
|
|
# 在新线程中调用API
|
|
|
|
|
self.streaming_thread = threading.Thread(
|
|
|
|
|
target=self.call_deepseek_api_stream,
|
|
|
|
|
args=(message,)
|
|
|
|
|
)
|
|
|
|
|
self.streaming_thread.daemon = True
|
|
|
|
|
self.streaming_thread.start()
|
|
|
|
|
|
|
|
|
|
# 启动定时器更新显示
|
|
|
|
|
self.streaming_timer = QTimer()
|
|
|
|
|
self.streaming_timer.timeout.connect(self.update_streaming_display)
|
|
|
|
|
self.streaming_timer.start(100) # 每100毫秒更新一次显示
|
|
|
|
|
|
|
|
|
|
def add_message_to_display(self, sender, message):
|
|
|
|
|
"""添加消息到显示区域"""
|
|
|
|
|
cursor = self.chat_display.textCursor()
|
|
|
|
|
cursor.movePosition(QTextCursor.End)
|
|
|
|
|
self.chat_display.setTextCursor(cursor)
|
|
|
|
|
|
|
|
|
|
# 设置格式
|
|
|
|
|
char_format = QTextCharFormat()
|
|
|
|
|
char_format.setFont(QFont("微软雅黑", 10))
|
|
|
|
|
|
|
|
|
|
if sender == "用户":
|
|
|
|
|
char_format.setForeground(QColor("#0078d4"))
|
|
|
|
|
char_format.setFontWeight(70)
|
|
|
|
|
prefix = "你: "
|
|
|
|
|
else: # AI助手
|
|
|
|
|
char_format.setForeground(QColor("#333333"))
|
|
|
|
|
prefix = "AI: "
|
|
|
|
|
|
|
|
|
|
# 插入时间戳
|
|
|
|
|
timestamp_format = QTextCharFormat()
|
|
|
|
|
timestamp_format.setForeground(QColor("#999999"))
|
|
|
|
|
timestamp_format.setFont(QFont("微软雅黑", 8))
|
|
|
|
|
cursor.insertText(f"\n[{datetime.now().strftime('%H:%M:%S')}] ", timestamp_format)
|
|
|
|
|
|
|
|
|
|
# 插入前缀
|
|
|
|
|
cursor.insertText(prefix, char_format)
|
|
|
|
|
|
|
|
|
|
# 插入消息
|
|
|
|
|
cursor.insertText(message, char_format)
|
|
|
|
|
|
|
|
|
|
# 自动滚动到底部
|
|
|
|
|
self.chat_display.verticalScrollBar().setValue(
|
|
|
|
|
self.chat_display.verticalScrollBar().maximum()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def update_streaming_display(self):
|
|
|
|
|
"""更新流式显示"""
|
|
|
|
|
if self.is_streaming and self.current_streaming_content:
|
|
|
|
|
# 重新显示所有对话
|
|
|
|
|
self.rebuild_chat_display()
|
|
|
|
|
self.chat_display.verticalScrollBar().setValue(
|
|
|
|
|
self.chat_display.verticalScrollBar().maximum()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def rebuild_chat_display(self):
|
|
|
|
|
"""重新构建聊天显示"""
|
|
|
|
|
self.chat_display.clear()
|
|
|
|
|
cursor = self.chat_display.textCursor()
|
|
|
|
|
|
|
|
|
|
for msg in self.conversation_history:
|
|
|
|
|
sender = msg["sender"]
|
|
|
|
|
message = msg["message"]
|
|
|
|
|
|
|
|
|
|
# 设置格式
|
|
|
|
|
char_format = QTextCharFormat()
|
|
|
|
|
char_format.setFont(QFont("微软雅黑", 10))
|
|
|
|
|
|
|
|
|
|
if sender == "用户":
|
|
|
|
|
char_format.setForeground(QColor("#0078d4"))
|
|
|
|
|
char_format.setFontWeight(70)
|
|
|
|
|
prefix = "你: "
|
|
|
|
|
else:
|
|
|
|
|
char_format.setForeground(QColor("#333333"))
|
|
|
|
|
prefix = "AI: "
|
|
|
|
|
|
|
|
|
|
# 插入分隔符
|
|
|
|
|
if self.chat_display.toPlainText():
|
|
|
|
|
cursor.insertText("\n")
|
|
|
|
|
|
|
|
|
|
# 插入时间戳
|
|
|
|
|
timestamp_format = QTextCharFormat()
|
|
|
|
|
timestamp_format.setForeground(QColor("#999999"))
|
|
|
|
|
timestamp_format.setFont(QFont("微软雅黑", 8))
|
|
|
|
|
cursor.insertText(f"[{datetime.now().strftime('%H:%M:%S')}] ", timestamp_format)
|
|
|
|
|
|
|
|
|
|
# 插入前缀
|
|
|
|
|
cursor.insertText(prefix, char_format)
|
|
|
|
|
|
|
|
|
|
# 插入消息
|
|
|
|
|
cursor.insertText(message, char_format)
|
|
|
|
|
|
|
|
|
|
def call_deepseek_api_stream(self, message):
|
|
|
|
|
"""调用DeepSeek API(流式版本)"""
|
|
|
|
|
url = "https://api.deepseek.com/v1/chat/completions"
|
|
|
|
|
|
|
|
|
|
headers = {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
"Authorization": f"Bearer {self.api_key}"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
messages = [{"role": "user", "content": message}]
|
|
|
|
|
|
|
|
|
|
data = {
|
|
|
|
|
"model": "deepseek-chat",
|
|
|
|
|
"messages": messages,
|
|
|
|
|
"stream": True,
|
|
|
|
|
"temperature": 0.7,
|
|
|
|
|
"max_tokens": 2000
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.is_streaming = True
|
|
|
|
|
self.current_streaming_content = ""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
response = requests.post(url, headers=headers, json=data, stream=True, timeout=30)
|
|
|
|
|
|
|
|
|
|
if response.status_code == 200:
|
|
|
|
|
for line in response.iter_lines():
|
|
|
|
|
if line:
|
|
|
|
|
line = line.decode('utf-8')
|
|
|
|
|
if line.startswith('data: '):
|
|
|
|
|
data_str = line[6:]
|
|
|
|
|
if data_str == '[DONE]':
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
data_obj = json.loads(data_str)
|
|
|
|
|
if 'choices' in data_obj and len(data_obj['choices']) > 0:
|
|
|
|
|
delta = data_obj['choices'][0].get('delta', {})
|
|
|
|
|
if 'content' in delta:
|
|
|
|
|
content = delta['content']
|
|
|
|
|
self.current_streaming_content += content
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
pass
|
|
|
|
|
else:
|
|
|
|
|
error_msg = f"API调用失败: {response.status_code}"
|
|
|
|
|
self.current_streaming_content = error_msg
|
|
|
|
|
|
|
|
|
|
except requests.exceptions.Timeout:
|
|
|
|
|
self.current_streaming_content = "请求超时,请重试"
|
|
|
|
|
except Exception as e:
|
|
|
|
|
self.current_streaming_content = f"错误: {str(e)}"
|
|
|
|
|
|
|
|
|
|
finally:
|
|
|
|
|
self.is_streaming = False
|
|
|
|
|
# 停止定时器
|
|
|
|
|
if self.streaming_timer:
|
|
|
|
|
self.streaming_timer.stop()
|
|
|
|
|
|
|
|
|
|
# 最后更新一次显示,使用信号在主线程中进行
|
|
|
|
|
self.update_chat_display.emit(self.current_streaming_content)
|
|
|
|
|
|
|
|
|
|
@pyqtSlot(str)
|
|
|
|
|
def on_update_chat_display(self, content):
|
|
|
|
|
"""在主线程中更新聊天显示"""
|
|
|
|
|
# 更新最后一条AI消息
|
|
|
|
|
if len(self.conversation_history) > 0:
|
|
|
|
|
self.conversation_history[-1]["message"] = content
|
|
|
|
|
|
|
|
|
|
# 重新构建显示
|
|
|
|
|
self.rebuild_chat_display()
|
|
|
|
|
|
|
|
|
|
# 自动滚动到底部
|
|
|
|
|
self.chat_display.verticalScrollBar().setValue(
|
|
|
|
|
self.chat_display.verticalScrollBar().maximum()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 重新启用输入
|
|
|
|
|
self.send_btn.setEnabled(True)
|
|
|
|
|
self.input_field.setEnabled(True)
|
|
|
|
|
self.input_field.setFocus()
|
|
|
|
|
|
|
|
|
|
def clear_history(self):
|
|
|
|
|
"""清空聊天历史"""
|
|
|
|
|
reply = QMessageBox.question(
|
|
|
|
|
self,
|
|
|
|
|
"确认",
|
|
|
|
|
"确定要清空聊天历史吗?",
|
|
|
|
|
QMessageBox.Yes | QMessageBox.No,
|
|
|
|
|
QMessageBox.No
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if reply == QMessageBox.Yes:
|
|
|
|
|
self.conversation_history = []
|
|
|
|
|
self.chat_display.clear()
|
|
|
|
|
self.input_field.clear()
|