1717import openai
1818import uuid
1919from 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
2025import 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('&', '&').replace('<', '<').replace('>', '>')
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('&', '&').replace('<', '<').replace('>', '>').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('&', '&').replace('<', '<').replace('>', '>')
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('&', '&').replace('<', '<').replace('>', '>')
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