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.
- add_command_dialog.py +712 -0
- command.py +636 -0
- components/__init__.py +15 -0
- components/agent_popup.py +187 -0
- components/chat_history.py +281 -0
- components/command_popup.py +399 -0
- components/feedback_text_edit.py +1125 -0
- components/file_popup.py +417 -0
- components/history_popup.py +582 -0
- components/markdown_display.py +262 -0
- context_formatter.py +301 -0
- debug_logger.py +107 -0
- feedback_config.py +144 -0
- feedback_mcp-1.0.64.dist-info/METADATA +327 -0
- feedback_mcp-1.0.64.dist-info/RECORD +41 -0
- feedback_mcp-1.0.64.dist-info/WHEEL +5 -0
- feedback_mcp-1.0.64.dist-info/entry_points.txt +2 -0
- feedback_mcp-1.0.64.dist-info/top_level.txt +20 -0
- feedback_ui.py +1680 -0
- get_session_id.py +53 -0
- git_operations.py +579 -0
- ide_utils.py +313 -0
- path_config.py +89 -0
- post_task_hook.py +78 -0
- record.py +188 -0
- server.py +746 -0
- session_manager.py +368 -0
- stop_hook.py +87 -0
- tabs/__init__.py +87 -0
- tabs/base_tab.py +34 -0
- tabs/chat_history_style.qss +66 -0
- tabs/chat_history_tab.py +1000 -0
- tabs/chat_tab.py +1931 -0
- tabs/workspace_tab.py +502 -0
- ui/__init__.py +20 -0
- ui/__main__.py +16 -0
- ui/compact_feedback_ui.py +376 -0
- ui/session_list_ui.py +793 -0
- ui/styles/session_list.qss +158 -0
- window_position_manager.py +197 -0
- workspace_manager.py +253 -0
|
@@ -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
|