Skip to content

Commit 21d0287

Browse files
author
TerryTian-tech
committed
update 0.4.2
1 parent 9d6a758 commit 21d0287

File tree

1 file changed

+134
-104
lines changed

1 file changed

+134
-104
lines changed

AIChat.py

Lines changed: 134 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -17,30 +17,24 @@
1717
import openai
1818
import uuid
1919
from datetime import datetime
20+
import mistune
21+
from pygments import highlight
22+
from pygments.lexers import get_lexer_by_name, guess_lexer, TextLexer
23+
from pygments.formatters import HtmlFormatter
24+
from pygments.util import ClassNotFound
2025
import os
2126

2227
# ==================== Markdown 解析器(使用 mistune + Pygments)====================
23-
# 尝试导入 mistune 和 pygments,如果不可用则使用正则表达式降级处理
24-
try:
25-
import mistune
26-
from pygments import highlight
27-
from pygments.lexers import get_lexer_by_name, guess_lexer, TextLexer
28-
from pygments.formatters import HtmlFormatter
29-
from pygments.util import ClassNotFound
30-
MARKDOWN_LIBS_AVAILABLE = True
31-
except ImportError:
32-
MARKDOWN_LIBS_AVAILABLE = False
33-
print("警告: mistune 或 pygments 未安装,将使用正则表达式解析。建议运行: pip install mistune pygments")
34-
35-
36-
class PygmentsRenderer(mistune.HTMLRenderer if MARKDOWN_LIBS_AVAILABLE else object):
28+
29+
30+
31+
class PygmentsRenderer(mistune.HTMLRenderer):
3732
"""
3833
使用 Pygments 进行代码高亮的 mistune 渲染器
3934
"""
4035

4136
def __init__(self, style='monokai', css_class='code-highlight', *args, **kwargs):
42-
if MARKDOWN_LIBS_AVAILABLE:
43-
super().__init__(*args, **kwargs)
37+
super().__init__(*args, **kwargs)
4438
self.style = style
4539
self.css_class = css_class
4640

@@ -49,9 +43,6 @@ def block_code(self, code, info=None):
4943
if not code or not code.strip():
5044
return ''
5145

52-
if not MARKDOWN_LIBS_AVAILABLE:
53-
return f'<pre><code>{code}</code></pre>'
54-
5546
lexer = self._get_lexer(code, info)
5647
formatter = HtmlFormatter(
5748
style=self.style,
@@ -64,9 +55,6 @@ def block_code(self, code, info=None):
6455

6556
def _get_lexer(self, code, info):
6657
"""获取合适的词法分析器"""
67-
if not MARKDOWN_LIBS_AVAILABLE:
68-
return None
69-
7058
if not info:
7159
try:
7260
return guess_lexer(code)
@@ -93,10 +81,7 @@ def _get_lexer(self, code, info):
9381

9482
def codespan(self, text):
9583
"""渲染行内代码"""
96-
if MARKDOWN_LIBS_AVAILABLE:
97-
escaped = mistune.escape(text)
98-
else:
99-
escaped = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
84+
escaped = mistune.escape(text)
10085
return f'<code class="inline-code">{escaped}</code>'
10186

10287

@@ -116,12 +101,6 @@ def __new__(cls):
116101

117102
def _init_parser(self):
118103
"""初始化解析器"""
119-
if not MARKDOWN_LIBS_AVAILABLE:
120-
self.renderer = None
121-
self.markdown = None
122-
self.token_parser = None
123-
return
124-
125104
self.renderer = PygmentsRenderer(style='monokai')
126105
self.markdown = mistune.create_markdown(
127106
renderer=self.renderer,
@@ -132,31 +111,21 @@ def _init_parser(self):
132111

133112
def parse_to_html(self, text):
134113
"""将 Markdown 转换为 HTML"""
135-
if MARKDOWN_LIBS_AVAILABLE and self.markdown:
136-
return self.markdown(text)
137-
else:
138-
# 降级处理:简单的 HTML 转义
139-
return text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('\n', '<br>')
114+
return self.markdown(text)
140115

141116
def parse_to_tokens(self, text):
142117
"""将 Markdown 解析为 tokens(抽象语法树)
143118
mistune 3.x: parse() 返回 (tokens, state) 元组
144119
"""
145-
if MARKDOWN_LIBS_AVAILABLE and self.token_parser:
146-
tokens, state = self.token_parser.parse(text)
147-
return tokens
148-
return []
120+
tokens, state = self.token_parser.parse(text)
121+
return tokens
149122

150123
def split_content(self, text):
151124
"""
152125
将内容分割为代码块和普通文本片段
153126
返回: list[{'type': 'code'|'text', 'language': str, 'content': str}, ...]
154127
"""
155-
if MARKDOWN_LIBS_AVAILABLE and self.token_parser:
156-
return self._split_by_tokens(text)
157-
else:
158-
# 降级处理:使用正则表达式
159-
return self._split_by_regex(text)
128+
return self._split_by_tokens(text)
160129

161130
def _split_by_tokens(self, text):
162131
"""使用 mistune 3.x tokens 解析分割内容"""
@@ -285,46 +254,6 @@ def _render_table(self, node):
285254
result.append('| ' + ' | '.join(cells) + ' |')
286255

287256
return '\n'.join(result)
288-
289-
def _split_by_regex(self, text):
290-
"""使用正则表达式分割内容(降级方案)"""
291-
result = []
292-
# 匹配代码块
293-
code_pattern = r'```([^\n]*)\n([\s\S]*?)```'
294-
last_end = 0
295-
296-
for match in re.finditer(code_pattern, text):
297-
# 添加代码块之前的文本
298-
if match.start() > last_end:
299-
plain_text = text[last_end:match.start()].strip()
300-
if plain_text:
301-
result.append({
302-
'type': 'text',
303-
'content': plain_text
304-
})
305-
306-
# 添加代码块
307-
lang = match.group(1).strip() or 'code'
308-
code = match.group(2)
309-
if code.strip():
310-
result.append({
311-
'type': 'code',
312-
'language': lang,
313-
'content': code
314-
})
315-
316-
last_end = match.end()
317-
318-
# 添加最后的文本
319-
if last_end < len(text):
320-
remaining = text[last_end:].strip()
321-
if remaining:
322-
result.append({
323-
'type': 'text',
324-
'content': remaining
325-
})
326-
327-
return result if result else [{'type': 'text', 'content': text}]
328257

329258

330259
# 获取全局解析器实例
@@ -776,26 +705,18 @@ def setup_ui(self):
776705

777706
def _highlight_code(self):
778707
"""使用 Pygments 生成高亮的 HTML"""
779-
if not MARKDOWN_LIBS_AVAILABLE:
780-
# 降级处理:纯文本显示
781-
escaped_code = self.code.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
782-
return f"<pre style='color: #e2e8f0; margin: 0;'>{escaped_code}</pre>"
783-
784708
try:
785709
lexer = self._get_lexer()
786710
formatter = HtmlFormatter(style='monokai', cssclass='code-highlight', nowrap=False)
787711
highlighted = highlight(self.code, lexer, formatter)
788712
return f"{self.HIGHLIGHT_CSS}<body>{highlighted}</body>"
789-
except Exception as e:
713+
except Exception:
790714
# 出错时降级为普通文本
791715
escaped_code = self.code.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
792716
return f"<pre style='color: #e2e8f0; margin: 0;'>{escaped_code}</pre>"
793717

794718
def _get_lexer(self):
795719
"""获取适合的词法分析器"""
796-
if not MARKDOWN_LIBS_AVAILABLE:
797-
return None
798-
799720
if self.language:
800721
# 语言别名映射
801722
aliases = {
@@ -849,35 +770,92 @@ def __init__(self, role: str, content: str, image_data_list: List[str] = None, p
849770
# 缓存文本浏览器引用(用于流式输出)
850771
self._cached_text_browser: Optional[QTextBrowser] = None
851772
self._cached_code_blocks: List[CodeBlockWidget] = []
773+
# 缓存所有文本浏览器引用(用于全选)
774+
self._all_text_browsers: List[QTextBrowser] = []
852775

853776
# Markdown 解析器
854777
self.parser = get_markdown_parser()
855778

779+
# 启用焦点策略以支持键盘事件
780+
self.setFocusPolicy(Qt.StrongFocus)
781+
856782
self.setup_ui()
857783

858784
def setup_ui(self):
859-
self.outer_layout = QHBoxLayout(self)
860-
self.outer_layout.setContentsMargins(24, 8, 24, 8)
861-
self.outer_layout.setSpacing(0)
862-
785+
# 使用垂直布局作为主布局(包含标题栏和内容)
786+
main_layout = QVBoxLayout(self)
787+
main_layout.setContentsMargins(24, 8, 24, 8)
788+
main_layout.setSpacing(4)
789+
790+
# 标题栏(包含角色标识和复制按钮)
791+
header_layout = QHBoxLayout()
792+
header_layout.setSpacing(8)
793+
794+
if self.role == "user":
795+
# 用户消息:标题栏靠右
796+
header_layout.addStretch()
797+
role_label = QLabel("👤 我")
798+
role_label.setStyleSheet("color: #667eea; font-size: 12px; font-weight: 600; background: transparent;")
799+
header_layout.addWidget(role_label)
800+
else:
801+
# AI消息:标题栏靠左
802+
role_label = QLabel("🤖 AI")
803+
role_label.setStyleSheet("color: #48bb78; font-size: 12px; font-weight: 600; background: transparent;")
804+
header_layout.addWidget(role_label)
805+
header_layout.addStretch()
806+
807+
# 复制全部按钮
808+
self.copy_all_btn = QPushButton("📋 复制全部")
809+
self.copy_all_btn.setCursor(Qt.PointingHandCursor)
810+
self.copy_all_btn.setStyleSheet("""
811+
QPushButton {
812+
background: transparent;
813+
color: #718096;
814+
border: 1px solid #e2e8f0;
815+
border-radius: 12px;
816+
padding: 2px 10px;
817+
font-size: 11px;
818+
}
819+
QPushButton:hover {
820+
background: #edf2f7;
821+
color: #4a5568;
822+
border-color: #cbd5e0;
823+
}
824+
""")
825+
self.copy_all_btn.clicked.connect(self.copy_all_content)
826+
header_layout.addWidget(self.copy_all_btn)
827+
828+
main_layout.addLayout(header_layout)
829+
830+
# 内容区域
863831
self.text_container = QWidget()
864832
self.text_layout = QVBoxLayout(self.text_container)
865833
self.text_layout.setContentsMargins(0, 0, 0, 0)
866834
self.text_layout.setSpacing(10)
867835
self.text_container.setStyleSheet("")
868836

837+
# 水平布局用于对齐
838+
content_h_layout = QHBoxLayout()
839+
content_h_layout.setContentsMargins(0, 0, 0, 0)
840+
content_h_layout.setSpacing(0)
841+
869842
if self.role == "user":
870-
self.outer_layout.addStretch()
843+
content_h_layout.addStretch()
871844
# 先添加所有图片(如果有)
872845
if self.image_data_list:
873846
self.add_multiple_image_widgets(self.text_layout)
874847
self.parse_content(self.text_layout, self.content, user=True)
875-
self.outer_layout.addWidget(self.text_container) # 右对齐
848+
content_h_layout.addWidget(self.text_container) # 右对齐
876849
else:
877850
# AI 消息
878851
self.parse_content(self.text_layout, self.content, user=False)
879-
self.outer_layout.addWidget(self.text_container) # 左对齐
880-
self.outer_layout.addStretch()
852+
content_h_layout.addWidget(self.text_container) # 左对齐
853+
content_h_layout.addStretch()
854+
855+
main_layout.addLayout(content_h_layout)
856+
857+
# 保存外部布局引用(兼容旧代码)
858+
self.outer_layout = QHBoxLayout()
881859

882860
def update_content(self, new_content: str):
883861
"""
@@ -969,6 +947,7 @@ def _clear_layout(self, layout: QVBoxLayout):
969947
# 清空缓存引用
970948
self._cached_text_browser = None
971949
self._cached_code_blocks.clear()
950+
self._all_text_browsers.clear()
972951

973952
def add_multiple_image_widgets(self, layout: QVBoxLayout):
974953
"""添加多张图片显示组件"""
@@ -1089,6 +1068,9 @@ def _add_text_segment(self, layout: QVBoxLayout, text: str, user: bool):
10891068
# 添加到布局
10901069
layout.addWidget(text_browser)
10911070

1071+
# 缓存文本浏览器引用(用于全选)
1072+
self._all_text_browsers.append(text_browser)
1073+
10921074
# 高度自适应 - 使用信号连接确保文档渲染完成后更新高度
10931075
def update_height(tb):
10941076
try:
@@ -1125,6 +1107,54 @@ def process_inline_code(self, text: str, user: bool) -> str:
11251107
text = text.replace('\n', '<br>')
11261108
color = "#1e40af" if user else "#1a202c"
11271109
return f'<div style="line-height: 1.7; color: {color};">{text}</div>'
1110+
1111+
def get_all_text(self) -> str:
1112+
"""获取消息的所有文本内容(纯文本格式)"""
1113+
# 直接返回原始内容,因为 self.content 保存了原始 markdown 文本
1114+
return self.content
1115+
1116+
def copy_all_content(self):
1117+
"""复制消息的全部内容到剪贴板"""
1118+
clipboard = QApplication.clipboard()
1119+
all_text = self.get_all_text()
1120+
clipboard.setText(all_text)
1121+
1122+
# 更新按钮状态显示
1123+
self.copy_all_btn.setText("✅ 已复制")
1124+
QTimer.singleShot(1500, lambda: self.copy_all_btn.setText("📋 复制全部"))
1125+
1126+
def select_all_text(self):
1127+
"""选中消息内的所有文本(用于 CTRL+A)"""
1128+
# 选中所有文本浏览器中的内容
1129+
for text_browser in self._all_text_browsers:
1130+
try:
1131+
text_browser.selectAll()
1132+
except RuntimeError:
1133+
pass
1134+
1135+
# 选中所有代码块中的内容
1136+
for code_block in self._cached_code_blocks:
1137+
try:
1138+
code_browser = code_block.code_display
1139+
code_browser.selectAll()
1140+
except (RuntimeError, AttributeError):
1141+
pass
1142+
1143+
# 如果有缓存的文本浏览器(流式输出时),也选中
1144+
if self._cached_text_browser:
1145+
try:
1146+
self._cached_text_browser.selectAll()
1147+
except RuntimeError:
1148+
pass
1149+
1150+
def keyPressEvent(self, event):
1151+
"""处理键盘事件,实现 CTRL+A 全选"""
1152+
if event.key() == Qt.Key_A and event.modifiers() == Qt.ControlModifier:
1153+
# CTRL+A: 全选当前消息的所有内容
1154+
self.select_all_text()
1155+
event.accept()
1156+
else:
1157+
super().keyPressEvent(event)
11281158

11291159

11301160
# ==================== 主窗口 ====================
@@ -1135,7 +1165,7 @@ class ChatWindow(QMainWindow):
11351165

11361166
def __init__(self):
11371167
super().__init__()
1138-
self.setWindowTitle("AI 聊天机器人 V0.4.1")
1168+
self.setWindowTitle("AI 聊天机器人 V0.4.2")
11391169
self.resize(1200, 800)
11401170
self.setMinimumSize(1000, 600)
11411171

0 commit comments

Comments
 (0)