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,1000 @@
1
+ """
2
+ 对话记录标签页 - 展示所有对话内容
3
+ """
4
+ import sys
5
+ import os
6
+ import json
7
+ import weakref
8
+ from typing import List, Dict, Optional
9
+ from PySide6.QtWidgets import (
10
+ QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QFrame, QLabel, QSizePolicy, QPushButton
11
+ )
12
+ from PySide6.QtCore import Qt, QFile, QTextStream, QTimer
13
+ import pyperclip
14
+
15
+ try:
16
+ from .base_tab import BaseTab
17
+ except ImportError:
18
+ from base_tab import BaseTab
19
+
20
+ try:
21
+ from ..components.markdown_display import MarkdownDisplayWidget
22
+ except ImportError:
23
+ try:
24
+ from components.markdown_display import MarkdownDisplayWidget
25
+ except ImportError:
26
+ from PySide6.QtWidgets import QTextEdit
27
+ MarkdownDisplayWidget = QTextEdit
28
+
29
+
30
+ class ChatHistoryTab(BaseTab):
31
+ """对话记录标签页 - 展示所有对话内容"""
32
+
33
+ def __init__(self, project_path: Optional[str] = None, session_id: Optional[str] = None, workspace_id: Optional[str] = None, parent=None):
34
+ super().__init__(parent)
35
+ self.project_path = project_path
36
+ self.session_id = session_id
37
+ self.workspace_id = workspace_id
38
+
39
+ # 计算 workspace_path
40
+ if workspace_id and project_path:
41
+ self.workspace_path = os.path.join(project_path, '.workspace', workspace_id)
42
+ else:
43
+ self.workspace_path = None
44
+
45
+ # UI组件
46
+ self.scroll_area = None
47
+ self.messages_container = None
48
+ self.messages_layout = None
49
+ self.load_more_button = None
50
+
51
+ # 历史记录管理
52
+ self.all_history = []
53
+ self.displayed_count = 10
54
+ self.current_start_idx = -1 # 当前显示的起始索引,用于增量加载
55
+ self._loaded = False # 延迟加载标志
56
+
57
+ self.create_ui()
58
+
59
+ def create_ui(self):
60
+ """创建对话记录Tab的UI"""
61
+ layout = QVBoxLayout(self)
62
+ layout.setContentsMargins(0, 0, 0, 0)
63
+ layout.setSpacing(0)
64
+
65
+ # 创建滚动区域
66
+ self.scroll_area = QScrollArea()
67
+ self.scroll_area.setWidgetResizable(True)
68
+ self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
69
+ self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
70
+
71
+ # 容器
72
+ self.messages_container = QWidget()
73
+ self.messages_container.setObjectName("messagesContainer")
74
+ self.messages_layout = QVBoxLayout(self.messages_container)
75
+ self.messages_layout.setContentsMargins(15, 15, 15, 15)
76
+ self.messages_layout.setSpacing(5)
77
+ self.messages_layout.setAlignment(Qt.AlignTop)
78
+
79
+ self.scroll_area.setWidget(self.messages_container)
80
+ layout.addWidget(self.scroll_area)
81
+
82
+ # 加载样式表
83
+ self._load_stylesheet()
84
+
85
+ def _load_stylesheet(self):
86
+ """加载QSS样式表"""
87
+ try:
88
+ current_dir = os.path.dirname(os.path.abspath(__file__))
89
+ qss_path = os.path.join(current_dir, "chat_history_style.qss")
90
+ qss_file = QFile(qss_path)
91
+ if qss_file.open(QFile.ReadOnly | QFile.Text):
92
+ stream = QTextStream(qss_file)
93
+ self.setStyleSheet(stream.readAll())
94
+ qss_file.close()
95
+ else:
96
+ print(f"无法加载样式表: {qss_path}", file=sys.stderr)
97
+ except Exception as e:
98
+ print(f"加载样式表出错: {e}", file=sys.stderr)
99
+
100
+ def load_history(self):
101
+ """加载并显示对话历史记录"""
102
+ # 清空现有消息
103
+ self._clear_messages()
104
+
105
+ # 读取历史记录
106
+ self.all_history = self._load_history_from_file()
107
+
108
+ if not self.all_history:
109
+ # 如果没有历史记录,显示提示
110
+ self._show_empty_message()
111
+ return
112
+
113
+ # 显示最后5条记录
114
+ self._display_records()
115
+
116
+ def _display_records(self):
117
+ """显示记录(从最新的开始显示指定数量)"""
118
+ # 清空所有现有消息
119
+ self._clear_messages()
120
+ self.load_more_button = None
121
+
122
+ total = len(self.all_history)
123
+ # 计算要显示的记录范围
124
+ start_idx = max(0, total - self.displayed_count)
125
+ self.current_start_idx = start_idx
126
+ records_to_show = self.all_history[start_idx:]
127
+
128
+ # 如果还有更多记录,显示"加载更多"按钮
129
+ if start_idx > 0:
130
+ self._add_load_more_button()
131
+
132
+ # 显示记录
133
+ for record in records_to_show:
134
+ self._render_record(record)
135
+
136
+ def _render_record(self, record):
137
+ """渲染单条记录"""
138
+ role = record.get('role')
139
+ if role == 'user':
140
+ self._add_user_message(record.get('content', ''))
141
+ elif role == 'assistant':
142
+ self._add_assistant_message(record.get('content', ''))
143
+ elif role == 'tool':
144
+ name = record.get('name', '')
145
+ # feedback 工具拆分为两条消息
146
+ if 'feedback' in name.lower():
147
+ self._add_feedback_messages(record)
148
+ else:
149
+ self._add_tool_message(
150
+ name,
151
+ record.get('input', {}),
152
+ record.get('output', ''),
153
+ record.get('timestamp', '')
154
+ )
155
+
156
+ def _add_load_more_button(self):
157
+ """添加加载更多按钮"""
158
+ self.load_more_button = QPushButton("点击查看更多")
159
+ self.load_more_button.setObjectName("loadMoreButton")
160
+ self.load_more_button.clicked.connect(self._load_more)
161
+ self.load_more_button.setStyleSheet("""
162
+ QPushButton {
163
+ background-color: #3a3a3a;
164
+ color: #e0e0e0;
165
+ border: 1px solid #555;
166
+ border-radius: 4px;
167
+ padding: 8px 16px;
168
+ font-size: 13px;
169
+ }
170
+ QPushButton:hover {
171
+ background-color: #4a4a4a;
172
+ }
173
+ """)
174
+ self.messages_layout.insertWidget(0, self.load_more_button)
175
+
176
+ def _load_more(self):
177
+ """增量加载更多记录,保持滚动位置"""
178
+ if self.current_start_idx <= 0:
179
+ return
180
+
181
+ # 记录当前滚动位置
182
+ scrollbar = self.scroll_area.verticalScrollBar()
183
+ old_scroll_value = scrollbar.value()
184
+ old_max = scrollbar.maximum()
185
+
186
+ # 计算新的起始索引
187
+ new_start_idx = max(0, self.current_start_idx - 10)
188
+ new_records = self.all_history[new_start_idx:self.current_start_idx]
189
+ self.current_start_idx = new_start_idx
190
+ self.displayed_count += len(new_records)
191
+
192
+ # 移除旧的"加载更多"按钮
193
+ if self.load_more_button:
194
+ self.messages_layout.removeWidget(self.load_more_button)
195
+ self.load_more_button.deleteLater()
196
+ self.load_more_button = None
197
+
198
+ # 如果还有更多记录,添加新的"加载更多"按钮
199
+ if new_start_idx > 0:
200
+ self._add_load_more_button()
201
+
202
+ # 记录插入位置(在"加载更多"按钮之后)
203
+ insert_pos = 1 if self.load_more_button else 0
204
+
205
+ # 在顶部插入新记录
206
+ for record in new_records:
207
+ count_before = self.messages_layout.count()
208
+ self._render_record_at_position(record, insert_pos)
209
+ widgets_added = self.messages_layout.count() - count_before
210
+ insert_pos += widgets_added
211
+
212
+ # 恢复滚动位置(使用两次延迟确保布局完全更新)
213
+ def restore_scroll():
214
+ # 强制处理布局更新
215
+ self.messages_container.updateGeometry()
216
+ # 再次延迟以确保滚动条范围已更新
217
+ def do_restore():
218
+ new_max = scrollbar.maximum()
219
+ height_diff = new_max - old_max
220
+ scrollbar.setValue(old_scroll_value + height_diff)
221
+ QTimer.singleShot(50, do_restore)
222
+
223
+ QTimer.singleShot(0, restore_scroll)
224
+
225
+ def _render_record_at_position(self, record, position):
226
+ """在指定位置渲染记录(用于增量加载)
227
+
228
+ 复用现有的 _render_record 方法,然后将新添加的 widget 移动到指定位置
229
+ """
230
+ # 记录当前 widget 数量
231
+ count_before = self.messages_layout.count()
232
+
233
+ # 使用现有方法添加记录(会添加到末尾)
234
+ self._render_record(record)
235
+
236
+ # 计算新添加的 widget 数量
237
+ count_after = self.messages_layout.count()
238
+ widgets_added = count_after - count_before
239
+
240
+ # 从末尾取出新添加的 widgets,插入到指定位置
241
+ for i in range(widgets_added):
242
+ item = self.messages_layout.takeAt(count_before)
243
+ if item and item.widget():
244
+ self.messages_layout.insertWidget(position + i, item.widget())
245
+
246
+ def _clear_messages(self):
247
+ """清空所有消息"""
248
+ while self.messages_layout.count():
249
+ child = self.messages_layout.takeAt(0)
250
+ if child.widget():
251
+ child.widget().deleteLater()
252
+
253
+ def _show_empty_message(self):
254
+ """显示无历史记录提示"""
255
+ empty_label = QLabel("暂无对话记录")
256
+ empty_label.setObjectName("emptyStateLabel")
257
+ empty_label.setAlignment(Qt.AlignCenter)
258
+ self.messages_layout.addWidget(empty_label)
259
+
260
+ def _setup_content_display(self, content: str) -> MarkdownDisplayWidget:
261
+ """创建并配置内容显示组件(使用MarkdownDisplayWidget)"""
262
+ content_display = MarkdownDisplayWidget()
263
+ content_display.setMarkdownText(content)
264
+ content_display.setStyleSheet('''
265
+ QTextEdit {
266
+ background-color: transparent;
267
+ border: none;
268
+ padding: 0px;
269
+ color: #e0e0e0;
270
+ }
271
+ ''')
272
+ content_display.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
273
+ content_display.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
274
+ content_display.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
275
+
276
+ # 根据内容自适应高度
277
+ doc = content_display.document()
278
+ doc.setTextWidth(content_display.viewport().width() if content_display.viewport().width() > 0 else 400)
279
+ height = int(doc.size().height()) + 10
280
+ content_display.setFixedHeight(height)
281
+
282
+ return content_display
283
+
284
+ def _create_avatar(self, text: str) -> QLabel:
285
+ """创建头像标签"""
286
+ label = QLabel(text)
287
+ label.setObjectName("avatarLabel")
288
+ label.setFixedSize(32, 32)
289
+ label.setAlignment(Qt.AlignCenter)
290
+ return label
291
+
292
+ def _safe_set_text_later(self, widget, text: str, delay: int = 1000):
293
+ """安全地延迟设置文本,使用弱引用避免访问已销毁对象"""
294
+ weak_widget = weakref.ref(widget)
295
+
296
+ def restore():
297
+ w = weak_widget()
298
+ if w is not None:
299
+ try:
300
+ w.setText(text)
301
+ except RuntimeError:
302
+ pass # 对象已销毁
303
+
304
+ QTimer.singleShot(delay, restore)
305
+
306
+ def _copy_content(self, content: str, button: QPushButton):
307
+ """复制内容到剪贴板"""
308
+ try:
309
+ pyperclip.copy(content)
310
+ button.setText("✓")
311
+ self._safe_set_text_later(button, "📋")
312
+ except Exception as e:
313
+ print(f"复制失败: {e}", file=sys.stderr)
314
+
315
+ def _quote_content(self, msg_type: str, content: str, button: QPushButton):
316
+ """生成引用格式并复制到剪贴板"""
317
+ truncated = content[:100] + "..." if len(content) > 100 else content
318
+ truncated = truncated.replace('\n', '\n> ')
319
+ quote = f"----请回忆如下引用的历史对话内容----\n```quote\n[{msg_type}]\n{truncated}\n```\n---------"
320
+ pyperclip.copy(quote)
321
+ button.setText("✓")
322
+ self._safe_set_text_later(button, "📎")
323
+
324
+ def _save_content(self, content: str):
325
+ """弹窗输入标题,保存为.md文件"""
326
+ from PySide6.QtWidgets import QInputDialog
327
+ title, ok = QInputDialog.getText(self, "保存文档", "请输入文档标题:")
328
+ if ok and title:
329
+ safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).strip()
330
+ if not safe_title:
331
+ safe_title = "untitled"
332
+
333
+ # 优先使用 workspace_path,否则回退到 project_path/.workspace/
334
+ if self.workspace_path and os.path.exists(self.workspace_path):
335
+ save_dir = self.workspace_path
336
+ else:
337
+ save_dir = os.path.join(self.project_path, '.workspace') if self.project_path else '.'
338
+
339
+ os.makedirs(save_dir, exist_ok=True)
340
+ file_path = os.path.join(save_dir, f"{safe_title}.md")
341
+
342
+ with open(file_path, 'w', encoding='utf-8') as f:
343
+ f.write(content)
344
+
345
+
346
+
347
+ def _add_user_message(self, content: str):
348
+ """添加用户消息(居左展示,与AI消息样式相同)"""
349
+ row_widget = QWidget()
350
+ row_layout = QHBoxLayout(row_widget)
351
+ row_layout.setContentsMargins(0, 5, 0, 5)
352
+ row_layout.setSpacing(10)
353
+
354
+ # 1. 头像
355
+ avatar = self._create_avatar("👤")
356
+ row_layout.addWidget(avatar, alignment=Qt.AlignTop)
357
+
358
+ # 2. 消息气泡容器
359
+ bubble_container = QWidget()
360
+ bubble_layout = QVBoxLayout(bubble_container)
361
+ bubble_layout.setContentsMargins(0, 0, 0, 0)
362
+ bubble_layout.setSpacing(2)
363
+
364
+ # 角色标签和按钮
365
+ header_widget = QWidget()
366
+ header_layout = QHBoxLayout(header_widget)
367
+ header_layout.setContentsMargins(0, 0, 0, 0)
368
+ role_label = QLabel("User")
369
+ role_label.setObjectName("roleLabel")
370
+ header_layout.addWidget(role_label)
371
+
372
+ copy_btn = QPushButton("📋")
373
+ copy_btn.setFixedSize(24, 24)
374
+ copy_btn.setStyleSheet("""
375
+ QPushButton {
376
+ background-color: transparent;
377
+ border: none;
378
+ font-size: 14px;
379
+ }
380
+ QPushButton:hover {
381
+ background-color: rgba(255, 255, 255, 0.1);
382
+ border-radius: 4px;
383
+ }
384
+ """)
385
+ copy_btn.setCursor(Qt.PointingHandCursor)
386
+ copy_btn.clicked.connect(lambda: self._copy_content(content, copy_btn))
387
+ header_layout.addWidget(copy_btn)
388
+
389
+ quote_btn = QPushButton("📎")
390
+ quote_btn.setFixedSize(24, 24)
391
+ quote_btn.setStyleSheet("""
392
+ QPushButton {
393
+ background-color: transparent;
394
+ border: none;
395
+ font-size: 14px;
396
+ }
397
+ QPushButton:hover {
398
+ background-color: rgba(255, 255, 255, 0.1);
399
+ border-radius: 4px;
400
+ }
401
+ """)
402
+ quote_btn.setCursor(Qt.PointingHandCursor)
403
+ quote_btn.clicked.connect(lambda: self._quote_content("用户消息", content, quote_btn))
404
+ header_layout.addWidget(quote_btn)
405
+ header_layout.addStretch()
406
+
407
+ bubble_layout.addWidget(header_widget)
408
+
409
+ # 气泡
410
+ bubble = QFrame()
411
+ bubble.setObjectName("aiBubble") # 使用与AI相同的样式
412
+ bubble_content_layout = QVBoxLayout(bubble)
413
+ bubble_content_layout.setContentsMargins(12, 8, 12, 8)
414
+
415
+ if content:
416
+ content_display = self._setup_content_display(content)
417
+ bubble_content_layout.addWidget(content_display)
418
+
419
+ bubble_layout.addWidget(bubble)
420
+
421
+ row_layout.addWidget(bubble_container, stretch=1)
422
+
423
+ # 3. 右侧占位
424
+ row_layout.addStretch(0)
425
+
426
+ self.messages_layout.addWidget(row_widget)
427
+
428
+ def _add_assistant_message(self, content: str):
429
+ """添加AI消息"""
430
+ row_widget = QWidget()
431
+ row_layout = QHBoxLayout(row_widget)
432
+ row_layout.setContentsMargins(0, 5, 0, 5)
433
+ row_layout.setSpacing(10)
434
+
435
+ # 1. 头像
436
+ avatar = self._create_avatar("🤖")
437
+ row_layout.addWidget(avatar, alignment=Qt.AlignTop)
438
+
439
+ # 2. 消息气泡容器
440
+ bubble_container = QWidget()
441
+ bubble_layout = QVBoxLayout(bubble_container)
442
+ bubble_layout.setContentsMargins(0, 0, 0, 0)
443
+ bubble_layout.setSpacing(2)
444
+
445
+ # 角色标签和复制按钮
446
+ header_widget = QWidget()
447
+ header_layout = QHBoxLayout(header_widget)
448
+ header_layout.setContentsMargins(0, 0, 0, 0)
449
+ role_label = QLabel("AI Assistant")
450
+ role_label.setObjectName("roleLabel")
451
+ header_layout.addWidget(role_label)
452
+
453
+ copy_btn = QPushButton("📋")
454
+ copy_btn.setFixedSize(24, 24)
455
+ copy_btn.setStyleSheet("""
456
+ QPushButton {
457
+ background-color: transparent;
458
+ border: none;
459
+ font-size: 14px;
460
+ }
461
+ QPushButton:hover {
462
+ background-color: rgba(255, 255, 255, 0.1);
463
+ border-radius: 4px;
464
+ }
465
+ """)
466
+ copy_btn.setCursor(Qt.PointingHandCursor)
467
+ copy_btn.clicked.connect(lambda: self._copy_content(content, copy_btn))
468
+ header_layout.addWidget(copy_btn)
469
+
470
+ quote_btn = QPushButton("📎")
471
+ quote_btn.setFixedSize(24, 24)
472
+ quote_btn.setStyleSheet("""
473
+ QPushButton {
474
+ background-color: transparent;
475
+ border: none;
476
+ font-size: 14px;
477
+ }
478
+ QPushButton:hover {
479
+ background-color: rgba(255, 255, 255, 0.1);
480
+ border-radius: 4px;
481
+ }
482
+ """)
483
+ quote_btn.setCursor(Qt.PointingHandCursor)
484
+ quote_btn.clicked.connect(lambda: self._quote_content("AI回复", content, quote_btn))
485
+ header_layout.addWidget(quote_btn)
486
+
487
+ save_btn = QPushButton("💾")
488
+ save_btn.setFixedSize(24, 24)
489
+ save_btn.setStyleSheet("""
490
+ QPushButton {
491
+ background-color: transparent;
492
+ border: none;
493
+ font-size: 14px;
494
+ }
495
+ QPushButton:hover {
496
+ background-color: rgba(255, 255, 255, 0.1);
497
+ border-radius: 4px;
498
+ }
499
+ """)
500
+ save_btn.setCursor(Qt.PointingHandCursor)
501
+ save_btn.clicked.connect(lambda: self._save_content(content))
502
+ header_layout.addWidget(save_btn)
503
+ header_layout.addStretch()
504
+
505
+ bubble_layout.addWidget(header_widget)
506
+
507
+ # 气泡
508
+ bubble = QFrame()
509
+ bubble.setObjectName("aiBubble")
510
+ bubble_content_layout = QVBoxLayout(bubble)
511
+ bubble_content_layout.setContentsMargins(12, 8, 12, 8)
512
+
513
+ if content:
514
+ content_display = self._setup_content_display(content)
515
+ bubble_content_layout.addWidget(content_display)
516
+
517
+ bubble_layout.addWidget(bubble)
518
+
519
+ row_layout.addWidget(bubble_container, stretch=1)
520
+
521
+ # 3. 右侧占位
522
+ row_layout.addStretch(0)
523
+
524
+ self.messages_layout.addWidget(row_widget)
525
+
526
+ def _add_feedback_messages(self, record: Dict):
527
+ """将 feedback 工具拆分为两条消息:AI反馈 + 用户回复"""
528
+ input_data = record.get('input', {})
529
+ output = record.get('output', '')
530
+
531
+ # 消息1: AI 反馈 (使用 assistant 样式)
532
+ work_title = input_data.get('work_title', '')
533
+ message = input_data.get('message', '')
534
+ options = input_data.get('predefined_options', [])
535
+ files = input_data.get('files', [])
536
+
537
+ parts = []
538
+ if work_title:
539
+ parts.append(f"📢 **{work_title}**")
540
+ if message:
541
+ parts.append(message)
542
+ if options:
543
+ parts.append(f"**选项**: {' | '.join(options)}")
544
+ if files:
545
+ file_list = ', '.join([f"`{f}`" for f in files])
546
+ parts.append(f"**相关文件**: {file_list}")
547
+
548
+ ai_content = '\n\n'.join(parts) if parts else ''
549
+ if ai_content:
550
+ self._add_assistant_message(ai_content)
551
+
552
+ # 消息2: 用户回复 (使用 user 样式)
553
+ user_content = self._extract_user_feedback(output)
554
+ if user_content:
555
+ self._add_user_message(user_content)
556
+
557
+ def _extract_user_feedback(self, output: str) -> str:
558
+ """从 feedback output 中提取用户输入"""
559
+ if not output:
560
+ return ''
561
+ # 提取 💬 用户输入 或 🔘 用户选择 后面的内容
562
+ for marker in ['💬 用户输入:\n', '🔘 用户选择的选项:\n']:
563
+ if marker in output:
564
+ idx = output.find(marker)
565
+ content = output[idx + len(marker):]
566
+ # 截断到 💡 请注意 之前
567
+ if '💡 请注意' in content:
568
+ end_idx = content.find('💡 请注意')
569
+ content = content[:end_idx].strip()
570
+ return content
571
+ return ''
572
+
573
+ def _format_tool_input(self, name: str, input_data: Dict) -> str:
574
+ """格式化工具输入为 markdown"""
575
+ if name == 'Task':
576
+ desc = input_data.get('description', '')
577
+ prompt = input_data.get('prompt', '')
578
+ agent_type = input_data.get('subagent_type', '')
579
+ parts = []
580
+ # 格式: Agent(agent名称):描述
581
+ if agent_type and desc:
582
+ parts.append(f"**Agent**({agent_type}):{desc}")
583
+ elif agent_type:
584
+ parts.append(f"**Agent**({agent_type})")
585
+ elif desc:
586
+ parts.append(f"**描述**: {desc}")
587
+ if prompt:
588
+ parts.append(f"**Prompt**:\n{prompt}")
589
+ return '\n\n'.join(parts) if parts else str(input_data)
590
+ elif name in ('Read', 'Glob', 'Grep'):
591
+ file_path = input_data.get('file_path', input_data.get('path', ''))
592
+ pattern = input_data.get('pattern', '')
593
+ parts = []
594
+ if file_path:
595
+ parts.append(f"**路径**: `{file_path}`")
596
+ if pattern:
597
+ parts.append(f"**模式**: `{pattern}`")
598
+ return '\n'.join(parts) if parts else str(input_data)
599
+ elif name in ('Edit', 'Write'):
600
+ file_path = input_data.get('file_path', '')
601
+ return f"**文件**: `{file_path}`" if file_path else str(input_data)
602
+ elif name == 'Hook':
603
+ cmd = input_data.get('command', '')
604
+ return f"**命令**: `{cmd}`" if cmd else str(input_data)
605
+ elif 'feedback' in name.lower():
606
+ # feedback 工具特殊处理
607
+ work_title = input_data.get('work_title', '')
608
+ message = input_data.get('message', '')
609
+ options = input_data.get('predefined_options', [])
610
+ files = input_data.get('files', [])
611
+ parts = []
612
+ if work_title:
613
+ parts.append(f"📢 **{work_title}**")
614
+ if message:
615
+ parts.append(message)
616
+ if options:
617
+ parts.append(f"**选项**: {' | '.join(options)}")
618
+ if files:
619
+ parts.append(f"**相关文件**: {', '.join(files)}")
620
+ return '\n\n'.join(parts) if parts else str(input_data)
621
+ else:
622
+ # 其他工具显示简化的 JSON
623
+ input_str = json.dumps(input_data, ensure_ascii=False, indent=2)
624
+ if len(input_str) > 300:
625
+ input_str = input_str[:300] + "..."
626
+ return f"```json\n{input_str}\n```"
627
+
628
+ def _format_feedback_output(self, output: str) -> str:
629
+ """格式化 feedback 工具的输出,提取用户输入"""
630
+ if not output:
631
+ return ''
632
+ # 提取 💬 用户输入 或 🔘 用户选择 后面的内容
633
+ for marker in ['💬 用户输入:\n', '🔘 用户选择的选项:\n']:
634
+ if marker in output:
635
+ idx = output.find(marker)
636
+ content = output[idx + len(marker):]
637
+ # 截断到 💡 请注意 之前
638
+ if '💡 请注意' in content:
639
+ end_idx = content.find('💡 请注意')
640
+ content = content[:end_idx].strip()
641
+ return f"**用户反馈**: {content}" if content else ''
642
+ return ''
643
+
644
+ def _add_tool_message(self, name: str, input_data: Dict, output: str, timestamp: str):
645
+ """添加工具调用消息(默认折叠,feedback 默认展开)"""
646
+ is_feedback = 'feedback' in name.lower()
647
+
648
+ row_widget = QWidget()
649
+ row_layout = QHBoxLayout(row_widget)
650
+ row_layout.setContentsMargins(0, 2, 0, 2)
651
+ row_layout.setSpacing(10)
652
+
653
+ # 1. 头像
654
+ avatar = self._create_avatar("💬" if is_feedback else "⚙️")
655
+ row_layout.addWidget(avatar, alignment=Qt.AlignTop)
656
+
657
+ # 2. 消息气泡容器
658
+ bubble_container = QWidget()
659
+ bubble_layout = QVBoxLayout(bubble_container)
660
+ bubble_layout.setContentsMargins(0, 0, 0, 0)
661
+ bubble_layout.setSpacing(2)
662
+
663
+ # 生成标题文本
664
+ # Task 工具特殊处理:显示 Agent(agent_type): description
665
+ if name == 'Task':
666
+ agent_type = input_data.get('subagent_type', '')
667
+ desc = input_data.get('description', '')
668
+ if agent_type and desc:
669
+ header_title = f"Agent({agent_type}): {desc}"
670
+ elif agent_type:
671
+ header_title = f"Agent({agent_type})"
672
+ else:
673
+ header_title = f"Tool: {name}"
674
+ else:
675
+ header_title = f"Tool: {name}"
676
+
677
+ # 可点击的标题(用于展开/折叠)+ 复制按钮
678
+ # feedback 默认展开,其他默认折叠
679
+ initial_expanded = is_feedback
680
+
681
+ # Header 容器(包含折叠按钮和复制按钮)
682
+ header_widget = QWidget()
683
+ header_layout = QHBoxLayout(header_widget)
684
+ header_layout.setContentsMargins(0, 0, 0, 0)
685
+ header_layout.setSpacing(4)
686
+
687
+ header_btn = QPushButton(f"{'▼' if initial_expanded else '▶'} {header_title}")
688
+ header_btn.setObjectName("toolHeaderButton")
689
+ # Task/Agent 使用绿色,其他工具使用灰色
690
+ header_color = "#4CAF50" if name == 'Task' else "#888"
691
+ header_hover_color = "#66BB6A" if name == 'Task' else "#aaa"
692
+ header_btn.setStyleSheet(f"""
693
+ QPushButton {{
694
+ background-color: transparent;
695
+ color: {header_color};
696
+ border: none;
697
+ text-align: left;
698
+ padding: 2px 0;
699
+ font-size: 12px;
700
+ }}
701
+ QPushButton:hover {{
702
+ color: {header_hover_color};
703
+ cursor: pointer;
704
+ }}
705
+ """)
706
+ header_btn.setCursor(Qt.PointingHandCursor)
707
+ header_layout.addWidget(header_btn)
708
+
709
+ # 复制按钮
710
+ copy_btn = QPushButton("📋")
711
+ copy_btn.setFixedSize(24, 24)
712
+ copy_btn.setStyleSheet("""
713
+ QPushButton {
714
+ background-color: transparent;
715
+ border: none;
716
+ font-size: 14px;
717
+ }
718
+ QPushButton:hover {
719
+ background-color: rgba(255, 255, 255, 0.1);
720
+ border-radius: 4px;
721
+ }
722
+ """)
723
+ copy_btn.setCursor(Qt.PointingHandCursor)
724
+ header_layout.addWidget(copy_btn)
725
+
726
+ # 引用按钮
727
+ quote_btn = QPushButton("📎")
728
+ quote_btn.setFixedSize(24, 24)
729
+ quote_btn.setStyleSheet("""
730
+ QPushButton {
731
+ background-color: transparent;
732
+ border: none;
733
+ font-size: 14px;
734
+ }
735
+ QPushButton:hover {
736
+ background-color: rgba(255, 255, 255, 0.1);
737
+ border-radius: 4px;
738
+ }
739
+ """)
740
+ quote_btn.setCursor(Qt.PointingHandCursor)
741
+ header_layout.addWidget(quote_btn)
742
+
743
+ # 保存按钮
744
+ save_btn = QPushButton("💾")
745
+ save_btn.setFixedSize(24, 24)
746
+ save_btn.setStyleSheet("""
747
+ QPushButton {
748
+ background-color: transparent;
749
+ border: none;
750
+ font-size: 14px;
751
+ }
752
+ QPushButton:hover {
753
+ background-color: rgba(255, 255, 255, 0.1);
754
+ border-radius: 4px;
755
+ }
756
+ """)
757
+ save_btn.setCursor(Qt.PointingHandCursor)
758
+ header_layout.addWidget(save_btn)
759
+ header_layout.addStretch()
760
+
761
+ bubble_layout.addWidget(header_widget)
762
+
763
+ # 气泡(feedback 默认显示,其他默认隐藏)
764
+ bubble = QFrame()
765
+ bubble.setObjectName("aiBubble")
766
+ bubble.setVisible(initial_expanded)
767
+ bubble_content_layout = QVBoxLayout(bubble)
768
+ bubble_content_layout.setContentsMargins(12, 8, 12, 8)
769
+
770
+ # 格式化输入
771
+ input_str = self._format_tool_input(name, input_data)
772
+
773
+ # 输出内容
774
+ output_str = str(output) if output else ''
775
+
776
+ # 过滤掉 agentId 行(Agent 调用结果中的元信息)
777
+ if output_str:
778
+ lines = output_str.split('\n')
779
+ filtered_lines = [line for line in lines if not line.strip().startswith('agentId:')]
780
+ output_str = '\n'.join(filtered_lines).strip()
781
+
782
+ # 处理 base64 图片 (只有真正的 base64 数据才替换)
783
+ # 真正的 base64 图片数据通常包含 data:image 或者是纯 base64 编码的长字符串
784
+ if output_str and len(output_str) > 500:
785
+ # 检测是否为 base64 图片数据
786
+ is_base64_image = (
787
+ 'data:image' in output_str.lower() or
788
+ (output_str.startswith('/9j/') or output_str.startswith('iVBOR')) # JPEG/PNG base64 头
789
+ )
790
+ if is_base64_image:
791
+ output_str = "[图片]"
792
+
793
+ # feedback 工具特殊处理输出
794
+ if is_feedback:
795
+ output_str = self._format_feedback_output(output_str)
796
+
797
+ # 构建内容(不再截断,展示完整内容)
798
+ content_parts = [f"**Input:**\n{input_str}"]
799
+ if output_str:
800
+ content_parts.append(f"**Output:**\n{output_str}")
801
+ else:
802
+ content_parts.append("**Output:** (无输出)")
803
+ content = '\n\n'.join(content_parts)
804
+ content_display = self._setup_content_display(content)
805
+ bubble_content_layout.addWidget(content_display)
806
+
807
+ bubble_layout.addWidget(bubble)
808
+
809
+ # 复制按钮点击事件(复制完整内容:Input + Output)
810
+ copy_btn.clicked.connect(lambda: self._copy_content(content, copy_btn))
811
+
812
+ # 引用按钮点击事件
813
+ quote_btn.clicked.connect(lambda: self._quote_content(f"工具调用: {name}", content, quote_btn))
814
+
815
+ # 保存按钮点击事件
816
+ save_btn.clicked.connect(lambda: self._save_content(content))
817
+
818
+ # 点击展开/折叠
819
+ def toggle_content():
820
+ is_visible = bubble.isVisible()
821
+ bubble.setVisible(not is_visible)
822
+ header_btn.setText(f"{'▼' if not is_visible else '▶'} {header_title}")
823
+
824
+ header_btn.clicked.connect(toggle_content)
825
+
826
+ row_layout.addWidget(bubble_container, stretch=1)
827
+
828
+ # 3. 右侧占位
829
+ row_layout.addStretch(0)
830
+
831
+ self.messages_layout.addWidget(row_widget)
832
+
833
+ def _load_history_from_file(self) -> List[Dict]:
834
+ """从Claude Code的session .jsonl文件加载历史记录"""
835
+ try:
836
+ if not self.session_id or not self.project_path:
837
+ return []
838
+
839
+ # 编码项目路径 (Claude Code 将 / 和 _ 都替换为 -)
840
+ encoded_path = self.project_path.replace('/', '-').replace('_', '-')
841
+
842
+ # 构建 .jsonl 文件路径
843
+ home_dir = os.path.expanduser('~')
844
+ jsonl_file = os.path.join(home_dir, '.claude', 'projects', encoded_path, f'{self.session_id}.jsonl')
845
+
846
+ if not os.path.exists(jsonl_file):
847
+ return []
848
+
849
+ # 读取所有行
850
+ lines = []
851
+ with open(jsonl_file, 'r', encoding='utf-8') as f:
852
+ lines = f.readlines()
853
+
854
+ # 第一遍:收集所有 tool_results
855
+ tool_results = {}
856
+ for line in lines:
857
+ line = line.strip()
858
+ if not line:
859
+ continue
860
+ try:
861
+ entry = json.loads(line)
862
+ message = entry.get('message', {})
863
+ if message.get('role') != 'user':
864
+ continue
865
+ content = message.get('content', [])
866
+ if isinstance(content, list):
867
+ for item in content:
868
+ if isinstance(item, dict) and item.get('type') == 'tool_result':
869
+ tool_use_id = item.get('tool_use_id')
870
+ tool_content = item.get('content', '')
871
+ # 处理 content 为数组的情况
872
+ if isinstance(tool_content, list):
873
+ texts = []
874
+ for c in tool_content:
875
+ if isinstance(c, dict) and c.get('type') == 'text':
876
+ texts.append(c.get('text', ''))
877
+ tool_content = '\n'.join(texts)
878
+ if tool_use_id:
879
+ tool_results[tool_use_id] = tool_content
880
+ except json.JSONDecodeError:
881
+ continue
882
+
883
+ # 第二遍:构建消息列表
884
+ messages = []
885
+ for line in lines:
886
+ line = line.strip()
887
+ if not line:
888
+ continue
889
+
890
+ try:
891
+ entry = json.loads(line)
892
+ message = entry.get('message', {})
893
+ role = message.get('role')
894
+
895
+ # 处理 system 消息 (hook)
896
+ entry_type = entry.get('type')
897
+ if entry_type == 'system':
898
+ subtype = entry.get('subtype', '')
899
+ if subtype == 'stop_hook_summary':
900
+ hook_infos = entry.get('hookInfos', [])
901
+ hook_errors = entry.get('hookErrors', [])
902
+ hook_cmd = hook_infos[0].get('command', '') if hook_infos else ''
903
+ # hookErrors 实际上是 hook 的输出内容
904
+ hook_output = '\n'.join(hook_errors) if hook_errors else '执行完成'
905
+ messages.append({
906
+ 'role': 'tool',
907
+ 'name': 'Hook',
908
+ 'input': {'command': hook_cmd},
909
+ 'output': hook_output,
910
+ 'timestamp': entry.get('timestamp', '')
911
+ })
912
+ continue
913
+
914
+ if role not in ['user', 'assistant']:
915
+ continue
916
+
917
+ timestamp = entry.get('timestamp', '')
918
+ content = message.get('content', [])
919
+
920
+ # 处理 user 消息
921
+ if role == 'user':
922
+ if isinstance(content, str):
923
+ # 过滤 hook 注入的内容(在 "Stop hook feedback:" 或 "hook feedback:" 后面)
924
+ user_content = content
925
+ for marker in ['Stop hook feedback:\n', 'hook feedback:\n']:
926
+ if marker in user_content:
927
+ # 只保留 marker 之前的内容 + marker 本身
928
+ idx = user_content.find(marker)
929
+ user_content = user_content[:idx + len(marker)].rstrip()
930
+ break
931
+ if user_content:
932
+ messages.append({'role': 'user', 'content': user_content, 'timestamp': timestamp})
933
+ # tool_result 不作为独立消息显示
934
+
935
+ # 处理 assistant 消息
936
+ elif role == 'assistant':
937
+ if isinstance(content, list):
938
+ for item in content:
939
+ if not isinstance(item, dict):
940
+ continue
941
+
942
+ item_type = item.get('type')
943
+
944
+ # 文本消息
945
+ if item_type == 'text':
946
+ text = item.get('text', '')
947
+ if text:
948
+ messages.append({'role': 'assistant', 'content': text, 'timestamp': timestamp})
949
+
950
+ # 工具调用
951
+ elif item_type == 'tool_use':
952
+ tool_id = item.get('id')
953
+ tool_name = item.get('name', '')
954
+ tool_input = item.get('input', {})
955
+ tool_output = tool_results.get(tool_id, '')
956
+
957
+ messages.append({
958
+ 'role': 'tool',
959
+ 'name': tool_name,
960
+ 'input': tool_input,
961
+ 'output': tool_output,
962
+ 'timestamp': timestamp
963
+ })
964
+
965
+ except json.JSONDecodeError:
966
+ continue
967
+
968
+ return messages
969
+
970
+ except Exception as e:
971
+ print(f"加载历史记录失败: {e}", file=sys.stderr)
972
+ return []
973
+
974
+
975
+ def refresh_history(self):
976
+ """刷新历史记录"""
977
+ self.load_history()
978
+ self._scroll_to_bottom()
979
+
980
+ def _scroll_to_bottom(self):
981
+ """滚动到底部"""
982
+ weak_scroll = weakref.ref(self.scroll_area)
983
+
984
+ def do_scroll():
985
+ scroll = weak_scroll()
986
+ if scroll is not None:
987
+ try:
988
+ scroll.verticalScrollBar().setValue(scroll.verticalScrollBar().maximum())
989
+ except RuntimeError:
990
+ pass # 对象已销毁
991
+
992
+ QTimer.singleShot(100, do_scroll)
993
+
994
+ def showEvent(self, event):
995
+ """Tab显示时加载历史记录并滚动到底部"""
996
+ super().showEvent(event)
997
+ if not self._loaded:
998
+ self._loaded = True
999
+ self.load_history()
1000
+ self._scroll_to_bottom()