-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAIChat.py
More file actions
2270 lines (1974 loc) · 92.1 KB
/
AIChat.py
File metadata and controls
2270 lines (1974 loc) · 92.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import sys
import re
import json
import base64
from typing import List, Dict, Optional
from urllib.parse import urlparse
from PySide6.QtCore import Qt, QThread, Signal, QSettings, QTimer, QByteArray, QBuffer
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QSplitter, QListWidget, QListWidgetItem, QPushButton, QTextEdit,
QScrollArea, QFrame, QLabel, QDialog, QLineEdit, QDialogButtonBox,
QMessageBox, QTextBrowser, QToolButton, QMenu, QInputDialog, QFileDialog,
QCheckBox
)
from PySide6.QtGui import QFont, QIcon, QPixmap, QPainter, QColor, QImage
import openai
import uuid
from datetime import datetime
import mistune
from pygments import highlight
from pygments.lexers import get_lexer_by_name, guess_lexer, TextLexer
from pygments.formatters import HtmlFormatter
from pygments.util import ClassNotFound
import os
# ==================== Markdown 解析器(使用 mistune + Pygments)====================
class PygmentsRenderer(mistune.HTMLRenderer):
"""
使用 Pygments 进行代码高亮的 mistune 渲染器
"""
def __init__(self, style='monokai', css_class='code-highlight', *args, **kwargs):
super().__init__(*args, **kwargs)
self.style = style
self.css_class = css_class
def block_code(self, code, info=None):
"""渲染代码块"""
if not code or not code.strip():
return ''
lexer = self._get_lexer(code, info)
formatter = HtmlFormatter(
style=self.style,
cssclass=self.css_class,
nowrap=False,
linenos=False
)
return highlight(code, lexer, formatter)
def _get_lexer(self, code, info):
"""获取合适的词法分析器"""
if not info:
try:
return guess_lexer(code)
except ClassNotFound:
return TextLexer()
# 语言别名映射
aliases = {
'js': 'javascript', 'ts': 'typescript', 'py': 'python',
'rb': 'ruby', 'sh': 'bash', 'shell': 'bash', 'zsh': 'bash',
'yml': 'yaml', 'md': 'markdown', 'cs': 'csharp',
'c++': 'cpp', 'h++': 'cpp', 'hpp': 'cpp',
}
lang = aliases.get(info.lower().strip(), info.lower().strip())
try:
return get_lexer_by_name(lang, stripall=True)
except ClassNotFound:
try:
return guess_lexer(code)
except ClassNotFound:
return TextLexer()
def codespan(self, text):
"""渲染行内代码"""
escaped = mistune.escape(text)
return f'<code class="inline-code">{escaped}</code>'
class MarkdownParser:
"""
Markdown 解析器封装 - 单例模式
使用 mistune 解析 Markdown,生成结构化的 AST
"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._init_parser()
return cls._instance
def _init_parser(self):
"""初始化解析器"""
self.renderer = PygmentsRenderer(style='monokai')
self.markdown = mistune.create_markdown(
renderer=self.renderer,
plugins=['table', 'strikethrough', 'url']
)
# Token 解析器(用于分离内容片段)- mistune 3.x 使用 Markdown 类
# 必须手动添加表格插件,否则表格会被解析为普通段落
self.token_parser = mistune.Markdown()
# 添加表格插件支持
from mistune.plugins.table import table as table_plugin
table_plugin(self.token_parser)
def parse_to_html(self, text):
"""将 Markdown 转换为 HTML"""
return self.markdown(text)
def parse_to_tokens(self, text):
"""将 Markdown 解析为 tokens(抽象语法树)
mistune 3.x: parse() 返回 (tokens, state) 元组
"""
tokens, state = self.token_parser.parse(text)
return tokens
def split_content(self, text):
"""
将内容分割为代码块和普通文本片段
返回: list[{'type': 'code'|'text', 'language': str, 'content': str}, ...]
"""
return self._split_by_tokens(text)
def _split_by_tokens(self, text):
"""使用 mistune 3.x tokens 解析分割内容"""
tokens, state = self.token_parser.parse(text)
result = []
for token in tokens:
token_type = token.get('type', '')
# 跳过空白行
if token_type == 'blank_line':
continue
if token_type == 'block_code':
# 获取语言信息 - mistune 3.x 使用 attrs.info
attrs = token.get('attrs', {})
lang = attrs.get('info', '').strip() if attrs else ''
result.append({
'type': 'code',
'language': lang or 'code',
'content': token.get('raw', '')
})
elif token_type == 'paragraph':
text_content = self._extract_text(token.get('children', []))
if text_content.strip():
result.append({
'type': 'text',
'content': text_content
})
elif token_type == 'heading':
text_content = self._extract_text(token.get('children', []))
attrs = token.get('attrs', {})
level = attrs.get('level', 1) if attrs else 1
if text_content.strip():
result.append({
'type': 'text',
'content': f"{'#' * level} {text_content}"
})
elif token_type == 'block_html':
raw = token.get('raw', '')
if raw.strip():
result.append({
'type': 'text',
'content': raw
})
elif token_type == 'list':
items = token.get('children', [])
list_text = ""
for item in items:
item_text = self._extract_text(item.get('children', []))
list_text += f"• {item_text}\n"
if list_text.strip():
result.append({
'type': 'text',
'content': list_text.rstrip()
})
elif token_type == 'table':
# 表格作为原始 Markdown 保留
table_text = self._render_table(token)
if table_text.strip():
result.append({
'type': 'text',
'content': table_text
})
elif token_type == 'thematic_break':
result.append({
'type': 'text',
'content': '---'
})
return result
def _extract_text(self, children):
"""从 AST 节点中递归提取文本"""
if not children:
return ''
text_parts = []
for child in children:
child_type = child.get('type', '')
if child_type == 'text':
text_parts.append(child.get('raw', ''))
elif child_type == 'codespan':
text_parts.append(f"`{child.get('raw', '')}`")
elif child_type == 'strong':
inner = self._extract_text(child.get('children', []))
text_parts.append(f"**{inner}**")
elif child_type == 'emphasis':
inner = self._extract_text(child.get('children', []))
text_parts.append(f"*{inner}*")
elif child_type == 'link':
inner = self._extract_text(child.get('children', []))
url = child.get('url', '')
text_parts.append(f"[{inner}]({url})")
elif child_type == 'image':
alt = child.get('alt', '')
url = child.get('url', '')
text_parts.append(f"")
elif 'children' in child:
text_parts.append(self._extract_text(child['children']))
return ' '.join(text_parts)
def _render_table(self, node):
"""渲染表格为文本表示"""
children = node.get('children', [])
if not children:
return ''
result = []
for child in children:
if child.get('type') == 'table_head':
cells = []
for cell in child.get('children', []):
cell_text = self._extract_text(cell.get('children', []))
cells.append(cell_text)
result.append('| ' + ' | '.join(cells) + ' |')
result.append('| ' + ' | '.join(['---'] * len(cells)) + ' |')
elif child.get('type') == 'table_body':
for row in child.get('children', []):
cells = []
for cell in row.get('children', []):
cell_text = self._extract_text(cell.get('children', []))
cells.append(cell_text)
result.append('| ' + ' | '.join(cells) + ' |')
return '\n'.join(result)
# 获取全局解析器实例
def get_markdown_parser():
"""获取 Markdown 解析器单例"""
return MarkdownParser()
# ==================== 配置对话框 ====================
class SettingsDialog(QDialog):
"""设置对话框(美化版)"""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("API 设置")
self.setModal(True)
self.resize(480, 340)
self.setStyleSheet("""
QDialog {
background-color: #f9fafc;
border-radius: 16px;
}
QLabel {
color: #2d3748;
font-size: 14px;
font-weight: 500;
}
QLineEdit {
background-color: white;
color: #2d3748;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 10px 14px;
font-size: 14px;
selection-background-color: #667eea;
}
QLineEdit:focus {
border: 2px solid #667eea;
padding: 9px 13px;
}
QToolButton {
background: transparent;
border: none;
font-size: 16px;
}
""")
layout = QVBoxLayout(self)
layout.setSpacing(20)
layout.setContentsMargins(24, 24, 24, 24)
# 标题
title = QLabel("⚙️ API 配置")
title.setStyleSheet("font-size: 22px; font-weight: 600; color: #1a202c; margin-bottom: 8px;")
layout.addWidget(title)
# 表单
form_layout = QVBoxLayout()
form_layout.setSpacing(16)
# API Key
self.api_key_edit = QLineEdit()
self.api_key_edit.setPlaceholderText("输入你的API Key")
self.api_key_edit.setEchoMode(QLineEdit.EchoMode.Password)
form_layout.addWidget(QLabel("API Key:"))
key_layout = QHBoxLayout()
key_layout.addWidget(self.api_key_edit)
self.toggle_key_btn = QToolButton()
self.toggle_key_btn.setText("👁")
self.toggle_key_btn.setCheckable(True)
self.toggle_key_btn.setCursor(Qt.PointingHandCursor)
self.toggle_key_btn.clicked.connect(self.toggle_key_visibility)
key_layout.addWidget(self.toggle_key_btn)
form_layout.addLayout(key_layout)
# Base URL
self.base_url_edit = QLineEdit()
self.base_url_edit.setPlaceholderText("例如:https://api.deepseek.com/v1")
form_layout.addWidget(QLabel("Base URL:"))
form_layout.addWidget(self.base_url_edit)
# 模型
self.model_edit = QLineEdit()
self.model_edit.setPlaceholderText("例如:deepseek-chat")
form_layout.addWidget(QLabel("模型:"))
form_layout.addWidget(self.model_edit)
# 多模态模型选项(新增)
self.vision_checkbox = QCheckBox("此模型支持图片输入(多模态)")
self.vision_checkbox.setStyleSheet("font-size: 13px; color: #4a5568;")
form_layout.addWidget(self.vision_checkbox)
layout.addLayout(form_layout)
# 帮助提示
help_label = QLabel(
"💡 提示:支持任何兼容OpenAI格式的API服务。\n"
"💡 请查看各AI模型厂商官方页面的接口文档,获取正确的Base URL和模型名称。"
)
help_label.setStyleSheet("color: #718096; font-size: 13px; padding: 14px; "
"background: #edf2f7; border-radius: 12px; line-height: 1.5;")
help_label.setWordWrap(True)
layout.addWidget(help_label)
# 按钮
button_box = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
OK_button = button_box.button(QDialogButtonBox.StandardButton.Ok)
Cancel_button = button_box.button(QDialogButtonBox.StandardButton.Cancel)
OK_button.setText("保存设置")
Cancel_button.setText("取消")
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
# 使用 objectName 来设置样式
OK_button.setObjectName("okButton")
Cancel_button.setObjectName("cancelButton")
button_box.setStyleSheet("""
QPushButton {
padding: 10px 28px;
border-radius: 30px;
font-size: 14px;
font-weight: 600;
}
QPushButton#okButton {
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
stop:0 #667eea, stop:1 #764ba2);
color: white;
border: none;
}
QPushButton#okButton:hover {
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
stop:0 #5a6fd6, stop:1 #6a4190);
}
QPushButton#cancelButton {
background: white;
color: #4a5568;
border: 1px solid #e2e8f0;
}
QPushButton#cancelButton:hover {
background: #f7fafc;
}
""")
layout.addWidget(button_box)
# 加载保存的配置
self.load_settings()
def load_settings(self):
settings = QSettings("MyChatApp", "Settings")
self.api_key_edit.setText(settings.value("api_key", ""))
self.base_url_edit.setText(settings.value("base_url", ""))
self.model_edit.setText(settings.value("model", "deepseek-chat"))
# 加载多模态选项(新增)
self.vision_checkbox.setChecked(settings.value("supports_vision", False, type=bool))
def save_settings(self):
settings = QSettings("MyChatApp", "Settings")
settings.setValue("api_key", self.api_key_edit.text())
settings.setValue("base_url", self.base_url_edit.text())
settings.setValue("model", self.model_edit.text())
# 保存多模态选项(新增)
settings.setValue("supports_vision", self.vision_checkbox.isChecked())
def toggle_key_visibility(self):
if self.toggle_key_btn.isChecked():
self.api_key_edit.setEchoMode(QLineEdit.EchoMode.Normal)
self.toggle_key_btn.setText("🔒")
else:
self.api_key_edit.setEchoMode(QLineEdit.EchoMode.Password)
self.toggle_key_btn.setText("👁")
def get_settings(self):
return {
"api_key": self.api_key_edit.text(),
"base_url": self.base_url_edit.text(),
"model": self.model_edit.text(),
"supports_vision": self.vision_checkbox.isChecked(),
}
# ==================== AI 请求线程 ====================
class AIRequestThread(QThread):
error_occurred = Signal(str)
stream_chunk = Signal(str)
finished_stream = Signal()
def __init__(self, messages, api_key, base_url, model,
system_prompt="""
You are a very strong reasoner and planner. Use these critical instructions to structure your plans, thoughts, and responses.
Before taking any action (either tool calls or responses to the user), you must proactively, methodically, and independently plan and reason about:
1.Logical dependencies and constraints: Analyze the intended action against the following factors. Resolve conflicts in order of importance:
1.1) Policy-based rules, mandatory prerequisites, and constraints.
1.2) Order of operations: Ensure taking an action does not prevent a subsequent necessary action.
1.2.1) The user may request actions in a random order, but you may need to reorder operations to maximize successful completion of the task.
1.3) Other prerequisites (information and/or actions needed).
1.4) Explicit user constraints or preferences.
2.Risk assessment: What are the consequences of taking the action? Will the new state cause any future issues?
2.1) For exploratory tasks (like searches), missing optional parameters is a LOW risk. Prefer calling the tool with the available information over asking the user, unless your Rule 1 (Logical Dependencies) reasoning determines that optional information is required for a later step in your plan.
3.Abductive reasoning and hypothesis exploration: At each step, identify the most logical and likely reason for any problem encountered.
3.1) Look beyond immediate or obvious causes. The most likely reason may not be the simplest and may require deeper inference.
3.2) Hypotheses may require additional research. Each hypothesis may take multiple steps to test.
3.3) Prioritize hypotheses based on likelihood, but do not discard less likely ones prematurely. A low-probability event may still be the root cause.
4.Outcome evaluation and adaptability: Does the previous observation require any changes to your plan?
4.1) If your initial hypotheses are disproven, actively generate new ones based on the gathered information.
5.Information availability: Incorporate all applicable and alternative sources of information, including:
5.1) Using available tools and their capabilities
5.2) All policies, rules, checklists, and constraints
5.3) Previous observations and conversation history
5.4) Information only available by asking the user
6.Precision and Grounding: Ensure your reasoning is extremely precise and relevant to each exact ongoing situation.
6.1) Verify your claims by quoting the exact applicable information (including policies) when referring to them.
7.Completeness: Ensure that all requirements, constraints, options, and preferences are exhaustively incorporated into your plan.
7.1) Resolve conflicts using the order of importance in #1.
7.2) Avoid premature conclusions: There may be multiple relevant options for a given situation.
7.2.1) To check for whether an option is relevant, reason about all information sources from #5.
7.2.2) You may need to consult the user to even know whether something is applicable. Do not assume it is not applicable without checking.
7.3) Review applicable sources of information from #5 to confirm which are relevant to the current state.
8.Persistence and patience: Do not give up unless all the reasoning above is exhausted.
8.1) Don't be dissuaded by time taken or user frustration.
8.2) This persistence must be intelligent: On transient errors (e.g. please try again), you must retry unless an explicit retry limit (e.g., max x tries) has been reached. If such a limit is hit, you must stop. On other errors, you must change your strategy or arguments, not repeat the same failed call.
9.Inhibit your response: only take an action after all the above reasoning is completed. Once you've taken an action, you cannot take it back.
"""):
super().__init__()
self.user_messages = messages
self.api_key = api_key
self.base_url = base_url
self.model = model
self.system_prompt = system_prompt
self._is_running = True
def run(self):
client = None
try:
# 设置超时:连接超时10秒,读取超时60秒(流式响应需要较长等待)
client = openai.OpenAI(
api_key=self.api_key,
base_url=self.base_url,
timeout=openai.Timeout(
connect=10.0, # 连接超时
read=60.0, # 读取超时(流式响应每个数据块)
write=10.0, # 写入超时
pool=10.0 # 连接池超时
)
)
full_messages = [{"role": "system", "content": self.system_prompt}] + self.user_messages
stream = client.chat.completions.create(
model=self.model,
messages=full_messages,
stream=True
)
full_content = ""
for chunk in stream:
if not self._is_running:
break
if chunk.choices and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
full_content += content
self.stream_chunk.emit(content)
self.finished_stream.emit()
except Exception as e:
self.error_occurred.emit(str(e))
finally:
# 修复问题12:确保关闭连接,避免资源泄漏
if client:
try:
client.close()
except Exception:
pass
def stop(self):
self._is_running = False
# ==================== 消息组件(支持代码块复制,美化版,高度自适应,优化版)====================
class CodeBlockWidget(QWidget):
"""
代码块控件 - 支持语法高亮
使用 Pygments 进行代码高亮,使用 mistune 解析语言
"""
# Monokai 风格的 CSS 样式
HIGHLIGHT_CSS = """
<style>
body { background: transparent; margin: 0; padding: 0; }
pre { margin: 0; white-space: pre-wrap; word-wrap: break-word; }
.code-highlight { background: transparent; padding: 0; margin: 0; }
.code-highlight .hll { background-color: #49483e }
.code-highlight .c { color: #75715e; font-style: italic } /* Comment */
.code-highlight .err { color: #960050; background-color: #1e0010 } /* Error */
.code-highlight .k { color: #66d9ef; font-weight: bold } /* Keyword */
.code-highlight .l { color: #ae81ff } /* Literal */
.code-highlight .n { color: #f8f8f2 } /* Name */
.code-highlight .o { color: #f92672 } /* Operator */
.code-highlight .p { color: #f8f8f2 } /* Punctuation */
.code-highlight .ch { color: #75715e } /* Comment.Hashbang */
.code-highlight .cm { color: #75715e } /* Comment.Multiline */
.code-highlight .cp { color: #75715e } /* Comment.Preproc */
.code-highlight .cpf { color: #75715e } /* Comment.PreprocFile */
.code-highlight .c1 { color: #75715e } /* Comment.Single */
.code-highlight .cs { color: #75715e } /* Comment.Special */
.code-highlight .gd { color: #f92672 } /* Generic.Deleted */
.code-highlight .ge { font-style: italic } /* Generic.Emph */
.code-highlight .gi { color: #a6e22e } /* Generic.Inserted */
.code-highlight .gs { font-weight: bold } /* Generic.Strong */
.code-highlight .gu { color: #75715e } /* Generic.Subheading */
.code-highlight .kc { color: #66d9ef } /* Keyword.Constant */
.code-highlight .kd { color: #66d9ef } /* Keyword.Declaration */
.code-highlight .kn { color: #f92672 } /* Keyword.Namespace */
.code-highlight .kp { color: #66d9ef } /* Keyword.Pseudo */
.code-highlight .kr { color: #66d9ef } /* Keyword.Reserved */
.code-highlight .kt { color: #66d9ef } /* Keyword.Type */
.code-highlight .ld { color: #e6db74 } /* Literal.Date */
.code-highlight .m { color: #ae81ff } /* Literal.Number */
.code-highlight .s { color: #e6db74 } /* Literal.String */
.code-highlight .na { color: #a6e22e } /* Name.Attribute */
.code-highlight .nb { color: #f8f8f2 } /* Name.Builtin */
.code-highlight .nc { color: #a6e22e } /* Name.Class */
.code-highlight .no { color: #66d9ef } /* Name.Constant */
.code-highlight .nd { color: #a6e22e } /* Name.Decorator */
.code-highlight .ni { color: #f8f8f2 } /* Name.Entity */
.code-highlight .ne { color: #a6e22e } /* Name.Exception */
.code-highlight .nf { color: #a6e22e } /* Name.Function */
.code-highlight .nl { color: #f8f8f2 } /* Name.Label */
.code-highlight .nn { color: #f8f8f2 } /* Name.Namespace */
.code-highlight .nx { color: #a6e22e } /* Name.Other */
.code-highlight .py { color: #f8f8f2 } /* Name.Property */
.code-highlight .nt { color: #f92672 } /* Name.Tag */
.code-highlight .nv { color: #f8f8f2 } /* Name.Variable */
.code-highlight .ow { color: #f92672 } /* Operator.Word */
.code-highlight .w { color: #f8f8f2 } /* Text.Whitespace */
.code-highlight .mb { color: #ae81ff } /* Literal.Number.Bin */
.code-highlight .mf { color: #ae81ff } /* Literal.Number.Float */
.code-highlight .mh { color: #ae81ff } /* Literal.Number.Hex */
.code-highlight .mi { color: #ae81ff } /* Literal.Number.Integer */
.code-highlight .mo { color: #ae81ff } /* Literal.Number.Oct */
.code-highlight .sa { color: #e6db74 } /* Literal.String.Affix */
.code-highlight .sb { color: #e6db74 } /* Literal.String.Backtick */
.code-highlight .sc { color: #e6db74 } /* Literal.String.Char */
.code-highlight .dl { color: #e6db74 } /* Literal.String.Delimiter */
.code-highlight .sd { color: #e6db74 } /* Literal.String.Doc */
.code-highlight .s2 { color: #e6db74 } /* Literal.String.Double */
.code-highlight .se { color: #ae81ff } /* Literal.String.Escape */
.code-highlight .sh { color: #e6db74 } /* Literal.String.Heredoc */
.code-highlight .si { color: #e6db74 } /* Literal.String.Interpol */
.code-highlight .sx { color: #e6db74 } /* Literal.String.Other */
.code-highlight .sr { color: #e6db74 } /* Literal.String.Regex */
.code-highlight .s1 { color: #e6db74 } /* Literal.String.Single */
.code-highlight .ss { color: #e6db74 } /* Literal.String.Symbol */
.code-highlight .bp { color: #f8f8f2 } /* Name.Builtin.Pseudo */
.code-highlight .fm { color: #a6e22e } /* Name.Function.Magic */
.code-highlight .vc { color: #f8f8f2 } /* Name.Variable.Class */
.code-highlight .vg { color: #f8f8f2 } /* Name.Variable.Global */
.code-highlight .vi { color: #f8f8f2 } /* Name.Variable.Instance */
.code-highlight .vm { color: #f8f8f2 } /* Name.Variable.Magic */
.code-highlight .il { color: #ae81ff } /* Literal.Number.Integer.Long */
</style>
"""
def __init__(self, code: str, language: str = ""):
super().__init__()
self.code = code
self.language = language
self.parser = get_markdown_parser()
self.setup_ui()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# 标题栏
header = QFrame()
header.setStyleSheet("""
QFrame {
background: #2d3748;
border-top-left-radius: 12px;
border-top-right-radius: 12px;
}
""")
header_layout = QHBoxLayout(header)
header_layout.setContentsMargins(16, 8, 16, 8)
lang_label = QLabel(f"📄 {self.language}" if self.language else "📄 代码")
lang_label.setStyleSheet("color: #cbd5e0; font-size: 12px; background: transparent; font-family: monospace;")
header_layout.addWidget(lang_label)
header_layout.addStretch()
copy_btn = QPushButton("📋 复制")
copy_btn.setCursor(Qt.PointingHandCursor)
copy_btn.setStyleSheet("""
QPushButton {
background: #4a5568;
color: white;
border: none;
padding: 4px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
QPushButton:hover {
background: #5a6578;
}
QPushButton:pressed {
background: #3a4558;
}
""")
copy_btn.clicked.connect(self.copy_code)
header_layout.addWidget(copy_btn)
layout.addWidget(header)
# 代码区域 - 使用 QTextBrowser 显示高亮后的 HTML
self.code_display = QTextBrowser()
self.code_display.setReadOnly(True)
self.code_display.setTextInteractionFlags(
Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard
)
self.code_display.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.code_display.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
# 设置样式
self.code_display.setStyleSheet("""
QTextBrowser {
background: #1e1e2e;
color: #e2e8f0;
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
font-size: 13px;
border: none;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
padding: 16px;
}
""")
# 使用 Pygments 高亮代码
highlighted_html = self._highlight_code()
self.code_display.setHtml(highlighted_html)
# 高度自适应
self.code_display.document().documentLayout().documentSizeChanged.connect(
self._adjust_code_height
)
# 立即设置初始高度
QTimer.singleShot(0, self._adjust_code_height)
layout.addWidget(self.code_display)
def _highlight_code(self):
"""使用 Pygments 生成高亮的 HTML"""
try:
lexer = self._get_lexer()
formatter = HtmlFormatter(style='monokai', cssclass='code-highlight', nowrap=False)
highlighted = highlight(self.code, lexer, formatter)
return f"{self.HIGHLIGHT_CSS}<body>{highlighted}</body>"
except Exception:
# 出错时降级为普通文本
escaped_code = self.code.replace('&', '&').replace('<', '<').replace('>', '>')
return f"<pre style='color: #e2e8f0; margin: 0;'>{escaped_code}</pre>"
def _get_lexer(self):
"""获取适合的词法分析器"""
if self.language:
# 语言别名映射
aliases = {
'js': 'javascript', 'ts': 'typescript', 'py': 'python',
'sh': 'bash', 'shell': 'bash', 'yml': 'yaml',
'rb': 'ruby', 'cs': 'csharp', 'c++': 'cpp',
}
lang = aliases.get(self.language.lower(), self.language.lower())
try:
return get_lexer_by_name(lang, stripall=True)
except ClassNotFound:
pass
# 尝试自动检测
try:
return guess_lexer(self.code)
except ClassNotFound:
return TextLexer()
def _adjust_code_height(self):
"""调整代码块高度以适应内容"""
try:
doc = self.code_display.document()
height = int(doc.size().height()) + 40 # 额外空间避免截断
self.code_display.setFixedHeight(max(height, 60)) # 最小高度60px
except RuntimeError:
pass
def copy_code(self):
clipboard = QApplication.clipboard()
clipboard.setText(self.code)
btn = self.sender()
btn.setText("✅ 已复制")
QTimer.singleShot(1500, lambda: btn.setText("📋 复制"))
class MessageWidget(QFrame):
"""消息控件 - 使用 mistune 解析 Markdown,支持代码块语法高亮"""
def __init__(self, role: str, content: str, image_data_list: List[str] = None, parent=None):
super().__init__(parent)
self.role = role # 'user' 或 'assistant'
self.content = content
self.image_data_list = image_data_list if image_data_list else []
# 保存布局引用
self.text_container = None
self.text_layout = None
self.outer_layout = None
# 缓存文本浏览器引用(用于流式输出)
self._cached_text_browser: Optional[QTextBrowser] = None
self._cached_code_blocks: List[CodeBlockWidget] = []
# 缓存所有文本浏览器引用(用于全选)
self._all_text_browsers: List[QTextBrowser] = []
# Markdown 解析器
self.parser = get_markdown_parser()
# 启用焦点策略以支持键盘事件
self.setFocusPolicy(Qt.StrongFocus)
self.setup_ui()
def setup_ui(self):
# 使用垂直布局作为主布局(包含标题栏和内容)
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(24, 8, 24, 8)
main_layout.setSpacing(4)
# 标题栏(包含角色标识和复制按钮)
header_layout = QHBoxLayout()
header_layout.setSpacing(8)
if self.role == "user":
# 用户消息:标题栏靠右
header_layout.addStretch()
role_label = QLabel("👤 我")
role_label.setStyleSheet("color: #667eea; font-size: 12px; font-weight: 600; background: transparent;")
header_layout.addWidget(role_label)
else:
# AI消息:标题栏靠左
role_label = QLabel("🤖 AI")
role_label.setStyleSheet("color: #48bb78; font-size: 12px; font-weight: 600; background: transparent;")
header_layout.addWidget(role_label)
header_layout.addStretch()
# 复制全部按钮
self.copy_all_btn = QPushButton("📋 复制全部")
self.copy_all_btn.setCursor(Qt.PointingHandCursor)
self.copy_all_btn.setStyleSheet("""
QPushButton {
background: transparent;
color: #718096;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 2px 10px;
font-size: 11px;
}
QPushButton:hover {
background: #edf2f7;
color: #4a5568;
border-color: #cbd5e0;
}
""")
self.copy_all_btn.clicked.connect(self.copy_all_content)
header_layout.addWidget(self.copy_all_btn)
main_layout.addLayout(header_layout)
# 内容区域
self.text_container = QWidget()
self.text_layout = QVBoxLayout(self.text_container)
self.text_layout.setContentsMargins(0, 0, 0, 0)
self.text_layout.setSpacing(10)
self.text_container.setStyleSheet("")
# 水平布局用于对齐
content_h_layout = QHBoxLayout()
content_h_layout.setContentsMargins(0, 0, 0, 0)
content_h_layout.setSpacing(0)
if self.role == "user":
content_h_layout.addStretch()
# 先添加所有图片(如果有)
if self.image_data_list:
self.add_multiple_image_widgets(self.text_layout)
self.parse_content(self.text_layout, self.content, user=True)
content_h_layout.addWidget(self.text_container) # 右对齐
else:
# AI 消息
self.parse_content(self.text_layout, self.content, user=False)
content_h_layout.addWidget(self.text_container) # 左对齐
content_h_layout.addStretch()
main_layout.addLayout(content_h_layout)
# 保存外部布局引用(兼容旧代码)
self.outer_layout = QHBoxLayout()
def update_content(self, new_content: str):
"""
流式输出时更新消息内容
简化策略:始终作为普通文本显示,等待 finalize_content 最终渲染
"""
self.content = new_content
# 流式输出期间,始终作为普通文本显示
# 避免代码块未闭合时的渲染问题
self._update_text_only(new_content)
def _update_text_only(self, content: str):
"""快速更新纯文本内容 - 流式输出期间使用"""
if self._cached_text_browser is None:
# 首次创建 - 创建一个简单的文本浏览器用于流式输出
self._clear_layout(self.text_layout)
text_browser = QTextBrowser()
text_browser.setReadOnly(True)
text_browser.setTextInteractionFlags(
Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard
)
text_browser.setOpenExternalLinks(False)
text_browser.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
text_browser.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
text_browser.setStyleSheet("""
QTextBrowser {
background: transparent;
border: none;
font-size: 15px;
padding: 4px 0;
}
""")
# 设置初始高度
text_browser.setMinimumHeight(100)
self.text_layout.addWidget(text_browser)
self._cached_text_browser = text_browser
# 更新内容
html_text = self.process_inline_code(content.strip(), user=False)
self._cached_text_browser.setHtml(html_text)
# 更新高度
try:
doc = self._cached_text_browser.document()
height = int(doc.size().height()) + 20
self._cached_text_browser.setFixedHeight(max(height, 50))
except RuntimeError:
pass
def _has_unclosed_code_block(self, content: str) -> bool:
"""检查是否存在未闭合的代码块"""
# 统计 ``` 出现的次数
count = content.count('```')
# 如果是奇数,说明有未闭合的代码块
return count % 2 == 1
def finalize_content(self):
"""
AI 回复完成后调用,重新解析并渲染最终内容
此时所有代码块都应该已闭合
"""
if not self.content:
return
# 检查是否仍有未闭合的代码块(可能 AI 输出不完整)
if self._has_unclosed_code_block(self.content):
# 尝试自动闭合
self.content = self.content + '\n```'
# 完全重新解析内容
self._clear_layout(self.text_layout)
self._cached_code_blocks.clear()
self._cached_text_browser = None
self.parse_content(self.text_layout, self.content, user=False)
def _clear_layout(self, layout: QVBoxLayout):
"""清理布局中的所有子控件"""
while layout.count():
item = layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
elif item.layout():
# 递归清理子布局
self._clear_layout(item.layout())
# 清空缓存引用
self._cached_text_browser = None
self._cached_code_blocks.clear()
self._all_text_browsers.clear()
def add_multiple_image_widgets(self, layout: QVBoxLayout):
"""添加多张图片显示组件"""
if not self.image_data_list:
return
# 如果只有一张图片,直接添加
if len(self.image_data_list) == 1:
self._add_single_image_widget(layout, self.image_data_list[0])
return
# 多张图片:创建水平布局的图片容器
images_container = QWidget()
images_h_layout = QHBoxLayout(images_container)
images_h_layout.setContentsMargins(0, 0, 0, 0)
images_h_layout.setSpacing(8)
for image_data in self.image_data_list:
image_label = self._create_image_label(image_data)
if image_label:
images_h_layout.addWidget(image_label)
images_h_layout.addStretch() # 图片靠左对齐
layout.addWidget(images_container)
def _add_single_image_widget(self, layout: QVBoxLayout, image_data: str):
"""添加单张图片显示组件"""
image_label = self._create_image_label(image_data)
if image_label:
layout.addWidget(image_label)
def _create_image_label(self, image_data: str) -> Optional[QLabel]:
"""创建图片标签控件"""
image_label = QLabel()
image_label.setStyleSheet("""
QLabel {
background: #f7fafc;
border-radius: 12px;
padding: 8px;
}
""")
try:
# 解码 Base64 图片
image_bytes = base64.b64decode(image_data)
image = QImage()
image.loadFromData(image_bytes)
if not image.isNull():
# 缩放到合适大小,保持宽高比