feedback-mcp 1.0.64__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of feedback-mcp might be problematic. Click here for more details.

@@ -0,0 +1,1125 @@
1
+ """
2
+ 反馈文本编辑组件
3
+
4
+ 支持图片粘贴和快捷键提交的文本编辑框。
5
+ """
6
+
7
+ import base64
8
+ import sys
9
+ import uuid
10
+ from typing import List, Dict, Any, Optional
11
+ from io import BytesIO
12
+
13
+ from PySide6.QtWidgets import QTextEdit, QApplication, QLabel, QDialog, QVBoxLayout, QScrollArea
14
+ from PySide6.QtCore import Qt, QIODevice, QBuffer, QRect, QPoint, QTimer
15
+ from PySide6.QtGui import QKeyEvent, QPixmap, QTextImageFormat, QTextDocument, QTextCursor
16
+
17
+ # 尝试导入PIL库用于图片压缩
18
+ try:
19
+ from PIL import Image
20
+ PIL_AVAILABLE = True
21
+ except ImportError:
22
+ PIL_AVAILABLE = False
23
+ # 不显示警告,使用Qt内置功能作为替代
24
+
25
+ # 导入指令弹窗组件
26
+ try:
27
+ from .command_popup import CommandPopup
28
+ COMMAND_POPUP_AVAILABLE = True
29
+ except ImportError:
30
+ try:
31
+ from command_popup import CommandPopup
32
+ COMMAND_POPUP_AVAILABLE = True
33
+ except ImportError:
34
+ COMMAND_POPUP_AVAILABLE = False
35
+ print("Warning: CommandPopup component not available")
36
+
37
+ # 导入文件弹窗组件
38
+ try:
39
+ from .file_popup import FilePopup
40
+ FILE_POPUP_AVAILABLE = True
41
+ except ImportError:
42
+ try:
43
+ from file_popup import FilePopup
44
+ FILE_POPUP_AVAILABLE = True
45
+ except ImportError:
46
+ FILE_POPUP_AVAILABLE = False
47
+ print("Warning: FilePopup component not available")
48
+
49
+ # 导入指令管理器
50
+ try:
51
+ from ..command import CommandManager
52
+ except ImportError:
53
+ try:
54
+ from command import CommandManager
55
+ except ImportError:
56
+ CommandManager = None
57
+ print("Warning: CommandManager not available")
58
+
59
+
60
+ class ImageViewerDialog(QDialog):
61
+ """图片查看器对话框"""
62
+
63
+ def __init__(self, pixmap: QPixmap, parent=None):
64
+ super().__init__(parent)
65
+ self.setWindowTitle("图片查看器")
66
+ self.setWindowFlags(Qt.Dialog | Qt.WindowCloseButtonHint)
67
+
68
+ # 设置对话框大小
69
+ self.resize(800, 600)
70
+
71
+ # 创建布局
72
+ layout = QVBoxLayout(self)
73
+
74
+ # 创建滚动区域
75
+ scroll_area = QScrollArea()
76
+ scroll_area.setWidgetResizable(True)
77
+ scroll_area.setAlignment(Qt.AlignCenter)
78
+
79
+ # 创建标签显示图片
80
+ image_label = QLabel()
81
+ image_label.setPixmap(pixmap)
82
+ image_label.setAlignment(Qt.AlignCenter)
83
+
84
+ scroll_area.setWidget(image_label)
85
+ layout.addWidget(scroll_area)
86
+
87
+ def keyPressEvent(self, event):
88
+ """处理键盘事件,ESC键关闭对话框"""
89
+ if event.key() == Qt.Key_Escape:
90
+ self.close()
91
+ else:
92
+ super().keyPressEvent(event)
93
+
94
+
95
+ class FeedbackTextEdit(QTextEdit):
96
+ """反馈文本编辑框
97
+
98
+ 支持以下功能:
99
+ 1. Ctrl+Enter / Cmd+Enter 快捷键提交
100
+ 2. 图片粘贴支持,自动转换为base64格式,支持压缩
101
+ 3. 拖拽文件支持
102
+ 4. 图片点击放大查看
103
+ 5. 输入"/"时弹出指令列表
104
+ """
105
+
106
+ # 大文本阈值常量
107
+ LARGE_TEXT_THRESHOLD = 2000 # 2k字符开始缩略
108
+ HUGE_TEXT_THRESHOLD = 10000 # 10k字符保存为文件
109
+ PREVIEW_LENGTH = 10 # 预览长度(只显示前10个字)
110
+
111
+ def __init__(self, parent=None):
112
+ super().__init__(parent)
113
+ self.pasted_images = {} # {image_id: base64_data}
114
+ self.original_images = {} # {image_id: QPixmap}
115
+ self.large_texts = {} # {placeholder_id: original_text} 1k~10k文本
116
+ self.text_files = {} # {placeholder_id: file_path} >10k文本
117
+ self.setAcceptDrops(True)
118
+
119
+ # 指令弹窗相关属性
120
+ self.command_popup = None # 指令弹窗实例
121
+ self.project_path = None # 项目路径
122
+ self.command_manager = None # 指令管理器
123
+ self.slash_position = -1 # "/"字符的位置
124
+
125
+ # 文件弹窗相关属性
126
+ self.file_popup = None # 文件弹窗实例
127
+ self.at_position = -1 # "@"字符的位置
128
+
129
+ # 智能延迟机制相关属性
130
+ self.slash_check_timer = QTimer()
131
+ self.slash_check_timer.setSingleShot(True)
132
+ self.slash_check_timer.timeout.connect(self._delayed_check_slash)
133
+ self.last_checked_line = "" # 记录上次检查的行内容,避免重复检查
134
+
135
+ # 智能//检测相关属性
136
+ self.slash_timer = QTimer()
137
+ self.slash_timer.setSingleShot(True)
138
+ self.slash_timer.timeout.connect(self._handle_slash_timeout)
139
+ self.pending_slash_position = -1 # 等待中的/位置
140
+ self.current_slash_count = 0 # 当前斜杠数量
141
+ self.waiting_for_more_slashes = False # 是否正在等待更多斜杠
142
+
143
+ # 可配置的指令选择处理器
144
+ self.custom_command_handler = None
145
+
146
+ # 连接文本变化信号,确保中文输入法输入也能触发检查
147
+ self.textChanged.connect(self._on_text_changed)
148
+
149
+ def set_project_path(self, project_path: str):
150
+ """设置项目路径"""
151
+ self.project_path = project_path
152
+ if CommandManager:
153
+ self.command_manager = CommandManager(project_path)
154
+ # 启用缓存并预加载所有命令
155
+ self.command_manager.enable_cache()
156
+ self._preload_commands()
157
+
158
+ def set_command_handler(self, handler):
159
+ """设置自定义指令选择处理器"""
160
+ self.custom_command_handler = handler
161
+
162
+ def _preload_commands(self):
163
+ """预加载所有命令到缓存"""
164
+ if not self.command_manager:
165
+ return
166
+ try:
167
+ # 预加载所有类型的命令
168
+ self.command_manager.load_project_commands()
169
+ self.command_manager.load_personal_commands()
170
+ self.command_manager.load_plugin_commands()
171
+ except Exception as e:
172
+ print(f"预加载命令失败: {e}")
173
+
174
+ def _on_text_changed(self):
175
+ """文本变化时触发,确保中文输入法输入也能被检测
176
+
177
+ 使用防抖(Debounce)模式:300ms内无新输入才触发检查
178
+ """
179
+ # 启动延迟检查,确保所有文本变化(包括中文输入法)都能触发斜杠检测
180
+ self.slash_check_timer.stop()
181
+ self.slash_check_timer.start(300) # 防抖延迟300ms
182
+
183
+ def _compress_image_qt(self, pixmap: QPixmap, max_size_mb: int = 2) -> bytes:
184
+ """使用Qt内置功能压缩图片"""
185
+ # 目标大小(字节)
186
+ max_size_bytes = max_size_mb * 1024 * 1024
187
+
188
+ # 首先尝试PNG格式
189
+ buffer = QBuffer()
190
+ buffer.open(QIODevice.WriteOnly)
191
+
192
+ # 尝试不同的图片格式和质量
193
+ formats_and_quality = [
194
+ ("PNG", 100), # PNG无损压缩
195
+ ("JPEG", 90), # JPEG高质量
196
+ ("JPEG", 80), # JPEG中等质量
197
+ ("JPEG", 60), # JPEG较低质量
198
+ ("JPEG", 40), # JPEG低质量
199
+ ]
200
+
201
+ original_pixmap = pixmap
202
+
203
+ for format_name, quality in formats_and_quality:
204
+ # 重置buffer
205
+ buffer.close()
206
+ buffer = QBuffer()
207
+ buffer.open(QIODevice.WriteOnly)
208
+
209
+ # 保存图片
210
+ if format_name == "PNG":
211
+ success = original_pixmap.save(buffer, format_name)
212
+ else:
213
+ success = original_pixmap.save(buffer, format_name, quality)
214
+
215
+ if success:
216
+ data = buffer.data().data()
217
+ if len(data) <= max_size_bytes:
218
+ buffer.close()
219
+ return data
220
+
221
+ # 如果仍然过大,尝试缩小图片尺寸
222
+ scale_factors = [0.8, 0.6, 0.4, 0.3]
223
+
224
+ for scale_factor in scale_factors:
225
+ scaled_pixmap = original_pixmap.scaled(
226
+ int(original_pixmap.width() * scale_factor),
227
+ int(original_pixmap.height() * scale_factor),
228
+ Qt.KeepAspectRatio,
229
+ Qt.SmoothTransformation
230
+ )
231
+
232
+ buffer.close()
233
+ buffer = QBuffer()
234
+ buffer.open(QIODevice.WriteOnly)
235
+
236
+ if scaled_pixmap.save(buffer, "JPEG", 60):
237
+ data = buffer.data().data()
238
+ if len(data) <= max_size_bytes:
239
+ buffer.close()
240
+ return data
241
+
242
+ # 最后的尝试:最低质量
243
+ buffer.close()
244
+ buffer = QBuffer()
245
+ buffer.open(QIODevice.WriteOnly)
246
+ original_pixmap.save(buffer, "JPEG", 30)
247
+ data = buffer.data().data()
248
+ buffer.close()
249
+ return data
250
+
251
+ def _compress_image(self, image_data: bytes, max_size_mb: int = 2) -> bytes:
252
+ """压缩图片到指定大小以内
253
+
254
+ Args:
255
+ image_data: 原始图片字节数据
256
+ max_size_mb: 最大文件大小(MB)
257
+
258
+ Returns:
259
+ bytes: 压缩后的图片数据
260
+ """
261
+ # 检查原始大小
262
+ original_size = len(image_data)
263
+ max_size_bytes = max_size_mb * 1024 * 1024
264
+
265
+ if original_size <= max_size_bytes:
266
+ return image_data
267
+
268
+ # 如果有PIL库,优先使用PIL
269
+ if PIL_AVAILABLE:
270
+ try:
271
+ # 打开图片
272
+ image = Image.open(BytesIO(image_data))
273
+
274
+ # 转换为RGB模式(如果需要)
275
+ if image.mode in ('RGBA', 'LA', 'P'):
276
+ # 创建白色背景
277
+ background = Image.new('RGB', image.size, (255, 255, 255))
278
+ if image.mode == 'P':
279
+ image = image.convert('RGBA')
280
+ background.paste(image, mask=image.split()[-1] if image.mode in ('RGBA', 'LA') else None)
281
+ image = background
282
+ elif image.mode != 'RGB':
283
+ image = image.convert('RGB')
284
+
285
+ # 计算压缩质量
286
+ quality = 90
287
+ while quality > 20:
288
+ output = BytesIO()
289
+ image.save(output, format='JPEG', quality=quality, optimize=True)
290
+ compressed_data = output.getvalue()
291
+
292
+ if len(compressed_data) <= max_size_bytes:
293
+ return compressed_data
294
+
295
+ quality -= 10
296
+
297
+ # 如果质量压缩还不够,尝试缩小图片尺寸
298
+ width, height = image.size
299
+ scale_factor = 0.8
300
+
301
+ while scale_factor > 0.3:
302
+ new_width = int(width * scale_factor)
303
+ new_height = int(height * scale_factor)
304
+ resized_image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
305
+
306
+ output = BytesIO()
307
+ resized_image.save(output, format='JPEG', quality=70, optimize=True)
308
+ compressed_data = output.getvalue()
309
+
310
+ if len(compressed_data) <= max_size_bytes:
311
+ return compressed_data
312
+
313
+ scale_factor -= 0.1
314
+
315
+ # 最后尝试
316
+ output = BytesIO()
317
+ image.save(output, format='JPEG', quality=30, optimize=True)
318
+ return output.getvalue()
319
+
320
+ except Exception as e:
321
+ print(f"PIL图片压缩失败,使用Qt备用方案: {e}")
322
+
323
+ # 使用Qt内置功能作为备用方案
324
+ try:
325
+ # 从字节数据创建QPixmap
326
+ pixmap = QPixmap()
327
+ pixmap.loadFromData(image_data)
328
+
329
+ if not pixmap.isNull():
330
+ return self._compress_image_qt(pixmap, max_size_mb)
331
+ else:
332
+ print("无法从数据创建QPixmap")
333
+ return image_data
334
+
335
+ except Exception as e:
336
+ print(f"Qt图片压缩失败: {e}")
337
+ return image_data
338
+
339
+ def _add_image_to_editor(self, pixmap: QPixmap, image_data: bytes = None):
340
+ """添加图片到编辑器,支持点击放大"""
341
+ try:
342
+ # 压缩图片数据
343
+ if image_data is None:
344
+ # 从pixmap获取数据
345
+ buffer = QBuffer()
346
+ buffer.open(QIODevice.WriteOnly)
347
+ pixmap.save(buffer, "PNG")
348
+ image_data = buffer.data().data()
349
+ buffer.close()
350
+
351
+ # 压缩图片
352
+ compressed_data = self._compress_image(image_data)
353
+ base64_image = base64.b64encode(compressed_data).decode('utf-8')
354
+
355
+ # 生成UUID作为图片ID
356
+ image_id = str(uuid.uuid4())
357
+
358
+ # 存储原始图片用于放大查看
359
+ self.original_images[image_id] = pixmap
360
+ self.pasted_images[image_id] = base64_image
361
+
362
+ # 创建显示用的缩放图片
363
+ display_pixmap = pixmap.copy()
364
+
365
+ # 设置显示尺寸:最大宽度50%(相对于编辑器宽度),最大高度300px
366
+ editor_width = self.viewport().width() - 20 # 留一些边距
367
+ max_width = min(int(editor_width * 0.5), pixmap.width()) # 50%宽度
368
+ max_height = 300
369
+
370
+ if pixmap.width() > max_width or pixmap.height() > max_height:
371
+ display_pixmap = pixmap.scaled(
372
+ max_width, max_height,
373
+ Qt.KeepAspectRatio,
374
+ Qt.SmoothTransformation
375
+ )
376
+
377
+ # 插入图片到文档
378
+ cursor = self.textCursor()
379
+ cursor.insertText("\n") # 添加换行
380
+
381
+ # 创建图片格式
382
+ image_format = QTextImageFormat()
383
+ image_format.setWidth(display_pixmap.width())
384
+ image_format.setHeight(display_pixmap.height())
385
+ image_format.setName(image_id)
386
+
387
+ # 将图片添加到文档资源
388
+ self.document().addResource(
389
+ QTextDocument.ImageResource,
390
+ image_format.name(),
391
+ display_pixmap
392
+ )
393
+
394
+ # 插入图片
395
+ cursor.insertImage(image_format)
396
+ cursor.insertText("\n") # 添加换行
397
+
398
+ print(f"图片已添加,原始大小: {len(image_data)/1024:.1f}KB, 压缩后: {len(compressed_data)/1024:.1f}KB")
399
+
400
+ except Exception as e:
401
+ print(f"添加图片时出错: {e}")
402
+
403
+ def mousePressEvent(self, event):
404
+ """处理鼠标单击事件,执行正常的文本编辑行为"""
405
+ # 关闭指令弹窗
406
+ self._close_command_popup()
407
+ # 关闭文件弹窗
408
+ self._close_file_popup()
409
+ # 执行默认行为(正常的光标定位和文本编辑)
410
+ super().mousePressEvent(event)
411
+
412
+ def mouseDoubleClickEvent(self, event):
413
+ """处理鼠标双击事件,支持图片放大查看"""
414
+ if event.button() == Qt.LeftButton:
415
+ # 获取点击位置的光标
416
+ click_pos = event.pos()
417
+ cursor = self.cursorForPosition(click_pos)
418
+
419
+ # 检查点击位置是否有图片
420
+ char_format = cursor.charFormat()
421
+ if char_format.isImageFormat():
422
+ # 获取图片格式信息
423
+ image_format = char_format.toImageFormat()
424
+
425
+ # 获取光标位置的矩形
426
+ cursor_rect = self.cursorRect(cursor)
427
+
428
+ # 创建图片的实际显示区域
429
+ image_rect = QRect(
430
+ cursor_rect.x(),
431
+ cursor_rect.y(),
432
+ image_format.width(),
433
+ image_format.height()
434
+ )
435
+
436
+ # 只有双击在图片区域内才显示预览
437
+ if image_rect.contains(click_pos):
438
+ try:
439
+ # 从图片名称获取image_id
440
+ image_id = image_format.name()
441
+ pixmap = self.original_images.get(image_id)
442
+ if pixmap:
443
+ # 显示放大的图片
444
+ dialog = ImageViewerDialog(pixmap, self)
445
+ dialog.exec()
446
+ # 直接返回,不执行默认双击行为
447
+ event.accept()
448
+ return
449
+ except Exception as e:
450
+ print(f"显示图片时出错: {e}")
451
+
452
+ # 如果双击在图片上但不在有效区域内,仍然阻止默认行为
453
+ event.accept()
454
+ return
455
+
456
+ # 如果不是图片双击,执行默认行为
457
+ super().mouseDoubleClickEvent(event)
458
+
459
+ def add_image_file(self, file_path: str):
460
+ """从文件路径添加图片"""
461
+ try:
462
+ pixmap = QPixmap(file_path)
463
+ if not pixmap.isNull():
464
+ # 读取文件数据
465
+ with open(file_path, 'rb') as f:
466
+ image_data = f.read()
467
+ self._add_image_to_editor(pixmap, image_data)
468
+ else:
469
+ print(f"无法加载图片文件: {file_path}")
470
+ except Exception as e:
471
+ print(f"添加图片文件时出错: {e}")
472
+
473
+ def _check_slash_input(self):
474
+ """智能检查斜杠输入以触发指令弹窗"""
475
+ if not COMMAND_POPUP_AVAILABLE or not self.command_manager:
476
+ return False
477
+
478
+ cursor = self.textCursor()
479
+
480
+ # 检查当前行的文本
481
+ cursor.select(QTextCursor.LineUnderCursor)
482
+ line_text = cursor.selectedText()
483
+
484
+ # 如果与上次检查的行内容相同,不重复处理
485
+ if line_text == self.last_checked_line:
486
+ return False
487
+
488
+ # 检查是否以 / 开头
489
+ if line_text.startswith('/'):
490
+ # 立即显示合并的指令列表
491
+ self._cancel_slash_wait()
492
+ cursor.movePosition(QTextCursor.StartOfLine)
493
+ self.slash_position = cursor.position()
494
+
495
+ # 获取 / 后面的内容作为过滤文本
496
+ filter_text = line_text[1:].strip() # 去掉开头的 /
497
+
498
+ # 显示所有指令(合并显示)
499
+ self._show_command_popup(filter_text, "all")
500
+ return True
501
+ else:
502
+ # 不是以 / 开头,关闭弹窗
503
+ self._cancel_slash_wait()
504
+ self._close_command_popup()
505
+ return False
506
+
507
+ def _handle_slash_timeout(self):
508
+ """处理斜杠输入超时 - 新版本不再需要等待"""
509
+ # 重置状态
510
+ self.waiting_for_more_slashes = False
511
+ self.current_slash_count = 0
512
+ self.pending_slash_position = -1
513
+
514
+ def _cancel_slash_wait(self):
515
+ """取消斜杠等待状态"""
516
+ self.slash_timer.stop()
517
+ self.waiting_for_more_slashes = False
518
+ self.current_slash_count = 0
519
+ self.pending_slash_position = -1
520
+
521
+ def _show_command_popup(self, filter_text: str = "", command_type: str = ""):
522
+ """显示指令弹窗"""
523
+ if not COMMAND_POPUP_AVAILABLE or not self.command_manager:
524
+ print("Warning: CommandPopup or CommandManager not available")
525
+ return
526
+
527
+ # 如果弹窗已存在且可见,更新其内容而不是直接返回
528
+ if self.command_popup:
529
+ try:
530
+ if self.command_popup.isVisible():
531
+ # 更新弹窗内容
532
+ commands = self._load_commands_by_type(command_type)
533
+ self.command_popup.set_commands(commands)
534
+ if filter_text:
535
+ self.command_popup.set_filter(filter_text)
536
+ return
537
+ except Exception as e:
538
+ # 如果检查可见性失败,说明对象可能已经无效,清理它
539
+ print(f"Popup check failed: {e}")
540
+ self.command_popup = None
541
+
542
+ # 关闭之前的弹窗
543
+ self._close_command_popup()
544
+
545
+ # 直接创建新弹窗
546
+ self._create_new_popup(filter_text, command_type)
547
+
548
+ def _create_new_popup(self, filter_text: str = "", command_type: str = ""):
549
+ """创建新的指令弹窗"""
550
+ try:
551
+ # 创建新的弹窗
552
+ self.command_popup = CommandPopup(self)
553
+
554
+ # 设置项目路径和指令类型
555
+ if hasattr(self, 'project_path') and self.project_path:
556
+ self.command_popup.set_project_path(self.project_path)
557
+ self.command_popup.set_command_type(command_type)
558
+
559
+ # 连接信号
560
+ self.command_popup.command_selected.connect(self._on_command_selected)
561
+ self.command_popup.popup_closed.connect(self._on_popup_closed)
562
+ # add_command_requested 信号连接已移除
563
+
564
+ # 根据类型加载指令
565
+ commands = self._load_commands_by_type(command_type)
566
+ self.command_popup.set_commands(commands)
567
+
568
+ # 更新弹窗标题
569
+ type_names = {
570
+ 'all': '📋 所有指令',
571
+ 'project': '📝 项目指令',
572
+ 'personal': '👤 个人指令'
573
+ }
574
+ title = type_names.get(command_type, '📝 选择指令')
575
+
576
+ # 新版不需要触发字符提示
577
+ self.command_popup.title_label.setText(title)
578
+
579
+ # 设置过滤
580
+ if filter_text:
581
+ self.command_popup.set_filter(filter_text)
582
+
583
+ # 计算弹窗位置(在光标下方)
584
+ cursor_rect = self.cursorRect(self.textCursor())
585
+ popup_position = self.mapToGlobal(QPoint(cursor_rect.x(), cursor_rect.bottom() + 5))
586
+
587
+ # 显示弹窗
588
+ self.command_popup.show_at_position(popup_position)
589
+
590
+ except Exception as e:
591
+ print(f"创建指令弹窗失败: {e}")
592
+ self.command_popup = None
593
+
594
+ def _load_commands_by_type(self, command_type: str) -> List[Dict[str, Any]]:
595
+ """根据类型加载指令"""
596
+ commands = []
597
+
598
+ if not self.command_manager:
599
+ return commands
600
+
601
+ try:
602
+ if command_type == "all":
603
+ # 加载所有类型的指令(合并显示)
604
+ # 1. 项目指令
605
+ project_commands = self.command_manager.load_project_commands()
606
+ for cmd in project_commands:
607
+ cmd['type'] = 'project'
608
+ cmd['category'] = '📝 项目指令'
609
+ commands.append(cmd)
610
+
611
+ # 2. 个人指令
612
+ personal_commands = self.command_manager.load_personal_commands()
613
+ for cmd in personal_commands:
614
+ cmd['type'] = 'personal'
615
+ cmd['category'] = '👤 个人指令'
616
+ commands.append(cmd)
617
+
618
+ # 3. 插件指令(新增)
619
+ plugin_commands = self.command_manager.load_plugin_commands()
620
+ for cmd in plugin_commands:
621
+ cmd['type'] = 'plugin'
622
+ # 不设置 category,因为指令标题已通过 @插件名 标注
623
+ commands.append(cmd)
624
+
625
+ elif command_type == "project":
626
+ # 加载项目指令
627
+ project_commands = self.command_manager.load_project_commands()
628
+ for cmd in project_commands:
629
+ cmd['type'] = 'project'
630
+ cmd['category'] = '📝 项目指令'
631
+ commands.append(cmd)
632
+
633
+ elif command_type == "personal":
634
+ # 加载个人指令
635
+ personal_commands = self.command_manager.load_personal_commands()
636
+ for cmd in personal_commands:
637
+ cmd['type'] = 'personal'
638
+ cmd['category'] = '👤 个人指令'
639
+ commands.append(cmd)
640
+
641
+ except Exception as e:
642
+ print(f"加载{command_type}指令时出错: {e}")
643
+
644
+ return commands
645
+
646
+ def _close_command_popup(self):
647
+ """关闭指令弹窗"""
648
+ if self.command_popup:
649
+ try:
650
+ # 断开信号连接,避免删除后的回调
651
+ self.command_popup.command_selected.disconnect()
652
+ self.command_popup.popup_closed.disconnect()
653
+ except:
654
+ pass # 忽略断开连接的错误
655
+
656
+ try:
657
+ # 先解除父子关系,避免 Qt 对象销毁顺序问题
658
+ self.command_popup.setParent(None)
659
+ self.command_popup.close()
660
+ self.command_popup.hide()
661
+ except:
662
+ pass # 忽略关闭错误
663
+
664
+ # 延迟删除,确保不会立即删除
665
+ try:
666
+ self.command_popup.deleteLater()
667
+ except:
668
+ pass
669
+
670
+ self.command_popup = None
671
+ self.slash_position = -1
672
+ self.last_checked_line = "" # 重置检查状态
673
+
674
+ # 取消所有等待状态
675
+ self._cancel_slash_wait()
676
+
677
+ def _on_command_selected(self, command_content: str, command_data: dict = None):
678
+ """处理指令选择"""
679
+ # 先清空 /xxx 内容
680
+ cursor = self.textCursor()
681
+ cursor.select(QTextCursor.LineUnderCursor)
682
+ line_text = cursor.selectedText()
683
+
684
+ # 如果当前行以 / 开头,清空整行
685
+ if line_text.startswith('/'):
686
+ cursor.movePosition(QTextCursor.StartOfLine)
687
+ cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.KeepAnchor)
688
+ cursor.removeSelectedText()
689
+
690
+ # 如果有自定义处理器,使用自定义处理器
691
+ if self.custom_command_handler:
692
+ # 传递完整的指令数据给自定义处理器
693
+ if command_data:
694
+ self.custom_command_handler(command_content, command_data)
695
+ else:
696
+ self.custom_command_handler(command_content)
697
+ else:
698
+ # 默认行为:插入指令内容
699
+ cursor = self.textCursor()
700
+ cursor.insertText(command_content)
701
+
702
+ # 关闭弹窗
703
+ self._close_command_popup()
704
+
705
+ def _smart_remove_trigger_slashes(self):
706
+ """智能删除触发弹窗的触发字符(/或、)"""
707
+ cursor = self.textCursor()
708
+
709
+ # 获取当前行文本
710
+ cursor.select(QTextCursor.LineUnderCursor)
711
+ line_text = cursor.selectedText().strip()
712
+
713
+ # 检查是否是纯触发字符(只支持纯/或纯、,不支持混合)
714
+ trigger_patterns = [
715
+ "/", "//", "///", # 英文斜杠
716
+ "、", "、、", "、、、", # 中文顿号
717
+ ]
718
+
719
+ if line_text in trigger_patterns:
720
+ # 选中整行并删除
721
+ cursor.movePosition(QTextCursor.StartOfLine)
722
+ cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.KeepAnchor)
723
+ cursor.removeSelectedText()
724
+ print(f"智能删除触发字符: '{line_text}'")
725
+
726
+ def _on_popup_closed(self):
727
+ """处理弹窗关闭"""
728
+ # 简单地清空引用,让Qt自己管理对象删除
729
+ self.command_popup = None
730
+ self.slash_position = -1
731
+
732
+ # 添加指令请求处理方法已移除
733
+ # 用户需要直接编辑 .md 文件来管理指令
734
+
735
+ def keyPressEvent(self, event: QKeyEvent):
736
+ # 如果指令弹窗打开,让弹窗处理某些按键
737
+ if self.command_popup and self.command_popup.isVisible():
738
+ if event.key() in (Qt.Key_Up, Qt.Key_Down, Qt.Key_Return, Qt.Key_Enter):
739
+ # 方向键和回车键传递给弹窗处理
740
+ self.command_popup.keyPressEvent(event)
741
+ return
742
+ elif event.key() == Qt.Key_Escape:
743
+ # ESC键关闭弹窗
744
+ self._close_command_popup()
745
+ return
746
+ elif event.text().isdigit():
747
+ # 数字键快速选择
748
+ self.command_popup.keyPressEvent(event)
749
+ return
750
+ # 其他按键(如字母)继续在输入框中输入,用于过滤
751
+
752
+ # 如果文件弹窗打开,让弹窗处理某些按键
753
+ if self.file_popup and self.file_popup.isVisible():
754
+ if event.key() in (Qt.Key_Up, Qt.Key_Down, Qt.Key_Return, Qt.Key_Enter):
755
+ self.file_popup.keyPressEvent(event)
756
+ return
757
+ elif event.key() == Qt.Key_Escape:
758
+ self._close_file_popup()
759
+ return
760
+ elif event.text().isdigit():
761
+ self.file_popup.keyPressEvent(event)
762
+ return
763
+
764
+ # ESC键处理:清空已选择的指令(当输入框有焦点时)
765
+ if event.key() == Qt.Key_Escape:
766
+ # 寻找有_clear_selected_command方法的父组件(通常是ChatTab)
767
+ parent = self.parent()
768
+ has_command = False
769
+ while parent:
770
+ if hasattr(parent, '_clear_selected_command'):
771
+ parent._clear_selected_command()
772
+ has_command = True
773
+ break
774
+ parent = parent.parent()
775
+
776
+ # 无论是否找到清空方法,都要继续传播事件以支持双击ESC
777
+ # 让事件继续向上传播到主窗口
778
+ event.ignore() # 忽略事件,让它继续传播
779
+ super().keyPressEvent(event)
780
+ return
781
+
782
+ if (event.key() == Qt.Key_Return and
783
+ (event.modifiers() == Qt.ControlModifier or event.modifiers() == Qt.MetaModifier)):
784
+ # 寻找有_submit_feedback方法的父组件(通常是ChatTab)
785
+ parent = self.parent()
786
+ while parent:
787
+ if hasattr(parent, '_submit_feedback'):
788
+ parent._submit_feedback()
789
+ return
790
+ parent = parent.parent()
791
+ return
792
+ elif (event.key() == Qt.Key_Backspace and
793
+ (event.modifiers() & Qt.ControlModifier)):
794
+ # Cmd+Backspace (macOS) / Ctrl+Backspace: 删除光标到行首
795
+ self._delete_to_line_start()
796
+ return
797
+ elif (event.key() == Qt.Key_V and
798
+ (event.modifiers() == Qt.ControlModifier or event.modifiers() == Qt.MetaModifier)):
799
+ # Handle paste operation
800
+ self._handle_paste()
801
+ else:
802
+ # 先执行默认键盘处理
803
+ super().keyPressEvent(event)
804
+
805
+ if event.key() not in (Qt.Key_Control, Qt.Key_Meta, Qt.Key_Shift, Qt.Key_Alt):
806
+ # 停止之前的计时器
807
+ self.slash_check_timer.stop()
808
+ # 启动新的延迟检查,使用防抖模式
809
+ self.slash_check_timer.start(300) # 防抖延迟300ms
810
+
811
+ def _handle_paste(self):
812
+ """处理粘贴操作,支持文本和图片,大文本自动转换为占位符"""
813
+ import os
814
+ from datetime import datetime
815
+
816
+ clipboard = QApplication.clipboard()
817
+ mime_data = clipboard.mimeData()
818
+
819
+ if mime_data.hasImage():
820
+ # Handle image paste
821
+ image = clipboard.image()
822
+ if not image.isNull():
823
+ pixmap = QPixmap.fromImage(image)
824
+ self._add_image_to_editor(pixmap)
825
+ return
826
+
827
+ if mime_data.hasText():
828
+ text = mime_data.text()
829
+ text_len = len(text)
830
+
831
+ # 小文本直接插入
832
+ if text_len <= self.LARGE_TEXT_THRESHOLD:
833
+ cursor = self.textCursor()
834
+ cursor.insertText(text)
835
+ return
836
+
837
+ # 大文本处理
838
+ placeholder_id = str(uuid.uuid4())[:8]
839
+ # 预览文本:取前100字符,替换换行为空格,确保单行显示
840
+ preview = text[:self.PREVIEW_LENGTH].replace('\n', ' ').replace('\r', '')
841
+
842
+ if text_len > self.HUGE_TEXT_THRESHOLD:
843
+ # >10k: 保存为文件
844
+ try:
845
+ # 获取存储目录
846
+ tmp_dir = self._get_tmp_dir()
847
+ if tmp_dir:
848
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
849
+ file_name = f"{timestamp}_{placeholder_id}.txt"
850
+ file_path = os.path.join(tmp_dir, file_name)
851
+
852
+ with open(file_path, 'w', encoding='utf-8') as f:
853
+ f.write(text)
854
+
855
+ self.text_files[placeholder_id] = file_path
856
+ placeholder = f"[粘贴文本转为文件({placeholder_id}) {preview}... {file_name}]"
857
+ print(f"大文本已保存到文件: {file_path}, 长度: {text_len}")
858
+ else:
859
+ # 无法获取目录,降级为大文本处理
860
+ self.large_texts[placeholder_id] = text
861
+ placeholder = f"[粘贴文本({placeholder_id}) {preview}... {text_len}字]"
862
+ except Exception as e:
863
+ print(f"保存大文本文件失败: {e}")
864
+ self.large_texts[placeholder_id] = text
865
+ placeholder = f"[粘贴文本({placeholder_id}) {preview}... {text_len}字]"
866
+ else:
867
+ # 2k~10k: 存储在内存
868
+ self.large_texts[placeholder_id] = text
869
+ placeholder = f"[粘贴文本({placeholder_id}) {preview}... {text_len}字]"
870
+ print(f"大文本已存储,ID: {placeholder_id}, 长度: {text_len}")
871
+
872
+ cursor = self.textCursor()
873
+ cursor.insertText(placeholder)
874
+ return
875
+
876
+ # Fallback to default paste behavior
877
+ super().paste()
878
+
879
+ def _get_tmp_dir(self) -> str:
880
+ """获取临时文件存储目录"""
881
+ import os
882
+ if self.project_path:
883
+ tmp_dir = os.path.join(self.project_path, '.workspace', 'chat_history', 'tmp')
884
+ os.makedirs(tmp_dir, exist_ok=True)
885
+ return tmp_dir
886
+ return None
887
+
888
+ def get_resolved_text(self) -> str:
889
+ """获取解析后的文本,将占位符替换为原始内容或标记删除"""
890
+ text = self.toPlainText()
891
+ return self.resolve_large_text_placeholders(text)
892
+
893
+ def resolve_large_text_placeholders(self, text: str) -> str:
894
+ """解析大文本占位符,将占位符替换为原始内容或标记删除
895
+
896
+ Args:
897
+ text: 输入文本
898
+
899
+ Returns:
900
+ str: 解析后的文本
901
+ """
902
+ import re
903
+
904
+ # 处理大文本占位符 [粘贴文本(id) preview... xxx字]
905
+ lt_pattern = r'\[粘贴文本\(([a-f0-9]{8})\) .+?\.\.\. \d+字\]'
906
+ for match in re.finditer(lt_pattern, text):
907
+ placeholder_id = match.group(1)
908
+ if placeholder_id in self.large_texts:
909
+ # 格式完整,替换为原文
910
+ text = text.replace(match.group(0), self.large_texts[placeholder_id])
911
+ else:
912
+ # 格式被破坏或ID不存在,删除
913
+ text = text.replace(match.group(0), '')
914
+
915
+ # 处理文件占位符 [粘贴文本转为文件(id) preview... filename.txt]
916
+ tf_pattern = r'\[粘贴文本转为文件\(([a-f0-9]{8})\) .+?\.\.\. [^\]]+\.txt\]'
917
+ for match in re.finditer(tf_pattern, text):
918
+ placeholder_id = match.group(1)
919
+ if placeholder_id in self.text_files:
920
+ # 格式完整,替换为文件路径标记
921
+ file_path = self.text_files[placeholder_id]
922
+ text = text.replace(match.group(0), f"[大文本已保存到文件: {file_path}]")
923
+ else:
924
+ # 格式被破坏或ID不存在,删除
925
+ text = text.replace(match.group(0), '')
926
+
927
+ return text.strip()
928
+
929
+ def clear_large_texts(self):
930
+ """清空大文本存储"""
931
+ self.large_texts.clear()
932
+ self.text_files.clear()
933
+
934
+ def _delete_to_line_start(self):
935
+ """删除光标到行首的内容"""
936
+ cursor = self.textCursor()
937
+ cursor.movePosition(QTextCursor.StartOfLine, QTextCursor.KeepAnchor)
938
+ cursor.removeSelectedText()
939
+
940
+ def _get_existing_image_ids(self) -> List[str]:
941
+ """获取文档中实际存在的图片ID列表"""
942
+ existing_ids = []
943
+ cursor = QTextCursor(self.document())
944
+ cursor.movePosition(QTextCursor.Start)
945
+
946
+ while not cursor.atEnd():
947
+ cursor.movePosition(QTextCursor.NextCharacter, QTextCursor.KeepAnchor)
948
+ char_format = cursor.charFormat()
949
+ if char_format.isImageFormat():
950
+ image_format = char_format.toImageFormat()
951
+ image_id = image_format.name()
952
+ if image_id and image_id not in existing_ids:
953
+ existing_ids.append(image_id)
954
+ cursor.clearSelection()
955
+ cursor.movePosition(QTextCursor.NextCharacter)
956
+
957
+ return existing_ids
958
+
959
+ def get_pasted_images(self) -> List[str]:
960
+ """获取粘贴的图片列表"""
961
+ existing_ids = self._get_existing_image_ids()
962
+ return [self.pasted_images[img_id] for img_id in existing_ids if img_id in self.pasted_images]
963
+
964
+ def clear_images(self):
965
+ """清空图片和大文本列表"""
966
+ self.pasted_images.clear()
967
+ self.original_images.clear()
968
+ self.large_texts.clear()
969
+ self.text_files.clear()
970
+
971
+ def _delayed_check_slash(self):
972
+ """延迟检查触发字符输入"""
973
+ cursor = self.textCursor()
974
+ cursor.select(QTextCursor.LineUnderCursor)
975
+ current_line = cursor.selectedText()
976
+
977
+ # 如果弹窗已经显示,更新过滤
978
+ if self.command_popup and hasattr(self.command_popup, 'isVisible') and self.command_popup.isVisible():
979
+ if current_line.startswith('/'):
980
+ filter_text = current_line[1:].strip()
981
+ self.command_popup.set_filter(filter_text)
982
+ else:
983
+ # 不是以 / 开头,关闭弹窗
984
+ self._close_command_popup()
985
+ elif self.file_popup and hasattr(self.file_popup, 'isVisible') and self.file_popup.isVisible():
986
+ # 如果文件弹窗已显示,更新过滤
987
+ if current_line.startswith('@'):
988
+ raw_filter_text = current_line[1:] # 保留原始文本(包括空格)
989
+ filter_text = raw_filter_text.strip()
990
+ # 如果原始文本包含空格(包括末尾空格),关闭弹窗
991
+ if ' ' in raw_filter_text:
992
+ self._close_file_popup()
993
+ else:
994
+ self.file_popup.set_filter(filter_text)
995
+ else:
996
+ # 不是以 @ 开头,关闭弹窗
997
+ self._close_file_popup()
998
+ else:
999
+ # 弹窗未显示,检查是否需要显示
1000
+ if self._check_slash_input():
1001
+ self.last_checked_line = current_line
1002
+ elif self._check_at_input():
1003
+ self.last_checked_line = current_line
1004
+ else:
1005
+ self.last_checked_line = current_line
1006
+
1007
+ def _check_at_input(self):
1008
+ """检查 @ 输入以触发文件弹窗(支持任意位置)"""
1009
+ if not FILE_POPUP_AVAILABLE or not self.project_path:
1010
+ return False
1011
+
1012
+ cursor = self.textCursor()
1013
+ pos = cursor.position()
1014
+
1015
+ # 获取光标前的文本
1016
+ cursor.movePosition(QTextCursor.StartOfLine)
1017
+ start_pos = cursor.position()
1018
+ cursor.setPosition(pos)
1019
+ cursor.setPosition(start_pos, QTextCursor.KeepAnchor)
1020
+ text_before = cursor.selectedText()[::-1] # 反转以便从光标位置向前查找
1021
+
1022
+ # 查找最近的 @ 符号
1023
+ at_idx = text_before.find('@')
1024
+ if at_idx == -1:
1025
+ self._close_file_popup()
1026
+ return False
1027
+
1028
+ # 计算 @ 的实际位置和过滤文本
1029
+ self.at_position = pos - at_idx - 1
1030
+ raw_filter_text = text_before[:at_idx][::-1] # 反转回来,保留原始文本
1031
+ filter_text = raw_filter_text.strip()
1032
+
1033
+ # 如果原始文本包含空格(包括末尾空格),关闭弹窗
1034
+ if ' ' in raw_filter_text:
1035
+ self._close_file_popup()
1036
+ return False
1037
+
1038
+ self._show_file_popup(filter_text)
1039
+ return True
1040
+
1041
+ def _show_file_popup(self, filter_text: str = ""):
1042
+ """显示文件弹窗"""
1043
+ if not FILE_POPUP_AVAILABLE or not self.project_path:
1044
+ return
1045
+
1046
+ if self.file_popup and hasattr(self.file_popup, 'isVisible') and self.file_popup.isVisible():
1047
+ if filter_text:
1048
+ self.file_popup.set_filter(filter_text)
1049
+ return
1050
+
1051
+ self._close_file_popup()
1052
+
1053
+ try:
1054
+ self.file_popup = FilePopup(self)
1055
+ self.file_popup.set_project_dir(self.project_path)
1056
+ self.file_popup.file_selected.connect(self._on_file_selected)
1057
+ self.file_popup.popup_closed.connect(self._on_file_popup_closed)
1058
+
1059
+ if filter_text:
1060
+ self.file_popup.set_filter(filter_text)
1061
+
1062
+ cursor_rect = self.cursorRect(self.textCursor())
1063
+ popup_position = self.mapToGlobal(QPoint(cursor_rect.x(), cursor_rect.bottom() + 5))
1064
+ self.file_popup.show_at_position(popup_position)
1065
+ except Exception as e:
1066
+ print(f"创建文件弹窗失败: {e}")
1067
+ self.file_popup = None
1068
+
1069
+ def _on_file_selected(self, file_path: str):
1070
+ """处理文件选择"""
1071
+ import os
1072
+ if self.at_position < 0:
1073
+ self._close_file_popup()
1074
+ return
1075
+
1076
+ cursor = self.textCursor()
1077
+ current_pos = cursor.position()
1078
+
1079
+ # 选中从 @ 到当前光标位置的文本并删除
1080
+ cursor.setPosition(self.at_position)
1081
+ cursor.setPosition(current_pos, QTextCursor.KeepAnchor)
1082
+ cursor.removeSelectedText()
1083
+
1084
+ # 生成相对路径,文件夹末尾加 /
1085
+ rel_path = os.path.relpath(file_path, self.project_path)
1086
+ if os.path.isdir(file_path):
1087
+ rel_path += '/'
1088
+
1089
+ cursor.insertText(rel_path)
1090
+ self._close_file_popup()
1091
+
1092
+ def _close_file_popup(self):
1093
+ """关闭文件弹窗"""
1094
+ if self.file_popup:
1095
+ try:
1096
+ self.file_popup.file_selected.disconnect()
1097
+ self.file_popup.popup_closed.disconnect()
1098
+ except:
1099
+ pass
1100
+
1101
+ try:
1102
+ # 先解除父子关系,避免 Qt 对象销毁顺序问题
1103
+ self.file_popup.setParent(None)
1104
+ self.file_popup.close()
1105
+ self.file_popup.hide()
1106
+ except:
1107
+ pass
1108
+
1109
+ try:
1110
+ self.file_popup.deleteLater()
1111
+ except:
1112
+ pass
1113
+
1114
+ self.file_popup = None
1115
+ self.at_position = -1
1116
+
1117
+ def _on_file_popup_closed(self):
1118
+ """处理文件弹窗关闭"""
1119
+ self.file_popup = None
1120
+ self.at_position = -1
1121
+
1122
+ def _submit_feedback(self):
1123
+ """提交反馈"""
1124
+ # 实现提交反馈的逻辑
1125
+ pass