""" 历史记录视图组件 显示任务执行历史,支持 Markdown 渲染、代码复用、失败重试、勾选删除 """ import os import re import tkinter as tk from tkinter import ttk, messagebox from typing import Callable, List, Optional, Set from pathlib import Path from history.manager import TaskRecord, HistoryManager class MarkdownText(tk.Text): """ 支持简单 Markdown 渲染的 Text 组件 """ def __init__(self, parent, **kwargs): super().__init__(parent, **kwargs) self._setup_tags() def _setup_tags(self): """配置 Markdown 样式标签""" # 标题 self.tag_configure('h1', font=('Microsoft YaHei UI', 14, 'bold'), foreground='#ffd54f', spacing3=10) self.tag_configure('h2', font=('Microsoft YaHei UI', 12, 'bold'), foreground='#4fc3f7', spacing3=8) self.tag_configure('h3', font=('Microsoft YaHei UI', 11, 'bold'), foreground='#81c784', spacing3=6) # 普通文本 self.tag_configure('normal', font=('Microsoft YaHei UI', 10), foreground='#d4d4d4') # 代码块 self.tag_configure('code', font=('Consolas', 9), foreground='#ce93d8', background='#1a1a1a') self.tag_configure('code_block', font=('Consolas', 9), foreground='#ce93d8', background='#1a1a1a', lmargin1=20, lmargin2=20, spacing1=5, spacing3=5) # 列表 self.tag_configure('list_item', font=('Microsoft YaHei UI', 10), foreground='#d4d4d4', lmargin1=20, lmargin2=30) # 强调 self.tag_configure('bold', font=('Microsoft YaHei UI', 10, 'bold'), foreground='#ffffff') self.tag_configure('italic', font=('Microsoft YaHei UI', 10, 'italic'), foreground='#b0b0b0') # 状态 self.tag_configure('success', foreground='#81c784') self.tag_configure('error', foreground='#ef5350') self.tag_configure('label', font=('Microsoft YaHei UI', 10, 'bold'), foreground='#4fc3f7') def render_markdown(self, text: str): """渲染 Markdown 文本""" self.config(state=tk.NORMAL) self.delete(1.0, tk.END) lines = text.split('\n') in_code_block = False code_block_content = [] for line in lines: # 代码块处理 if line.strip().startswith('```'): if in_code_block: # 结束代码块 code_text = '\n'.join(code_block_content) self.insert(tk.END, code_text + '\n', 'code_block') code_block_content = [] in_code_block = False else: # 开始代码块 in_code_block = True continue if in_code_block: code_block_content.append(line) continue # 标题 if line.startswith('### '): self.insert(tk.END, line[4:] + '\n', 'h3') elif line.startswith('## '): self.insert(tk.END, line[3:] + '\n', 'h2') elif line.startswith('# '): self.insert(tk.END, line[2:] + '\n', 'h1') # 列表项 elif line.strip().startswith('- ') or line.strip().startswith('* '): content = line.strip()[2:] self.insert(tk.END, ' • ' + content + '\n', 'list_item') elif re.match(r'^\d+\.\s', line.strip()): self.insert(tk.END, ' ' + line.strip() + '\n', 'list_item') # 普通行 else: self._render_inline(line + '\n') # 处理未闭合的代码块 if code_block_content: code_text = '\n'.join(code_block_content) self.insert(tk.END, code_text + '\n', 'code_block') self.config(state=tk.DISABLED) def _render_inline(self, text: str): """渲染行内元素""" # 简单处理:查找 `code` 和 **bold** pattern = r'(`[^`]+`|\*\*[^*]+\*\*)' parts = re.split(pattern, text) for part in parts: if part.startswith('`') and part.endswith('`'): self.insert(tk.END, part[1:-1], 'code') elif part.startswith('**') and part.endswith('**'): self.insert(tk.END, part[2:-2], 'bold') else: self.insert(tk.END, part, 'normal') class CheckboxTreeview(ttk.Treeview): """ 带勾选框的 Treeview """ def __init__(self, parent, **kwargs): super().__init__(parent, **kwargs) # 勾选状态存储 self._checked: Set[str] = set() # 勾选变化回调 self._on_check_changed: Optional[Callable[[Set[str]], None]] = None # 绑定点击事件 self.bind('', self._on_click) def set_on_check_changed(self, callback: Callable[[Set[str]], None]): """设置勾选变化回调""" self._on_check_changed = callback def _on_click(self, event): """处理点击事件""" region = self.identify_region(event.x, event.y) # 点击在第一列(勾选框区域) if region == 'cell': column = self.identify_column(event.x) if column == '#1': # 第一列是勾选框 item = self.identify_row(event.y) if item: self._toggle_check(item) def _toggle_check(self, item: str): """切换勾选状态""" if item in self._checked: self._checked.remove(item) else: self._checked.add(item) # 更新显示 self._update_check_display(item) # 触发回调 if self._on_check_changed: self._on_check_changed(self._checked.copy()) def _update_check_display(self, item: str): """更新勾选框显示""" values = list(self.item(item, 'values')) if values: values[0] = '☑' if item in self._checked else '☐' self.item(item, values=values) def get_checked(self) -> Set[str]: """获取所有勾选的项""" return self._checked.copy() def clear_checked(self): """清除所有勾选""" for item in list(self._checked): self._checked.remove(item) self._update_check_display(item) if self._on_check_changed: self._on_check_changed(set()) def check_all(self): """全选""" for item in self.get_children(): if item not in self._checked: self._checked.add(item) self._update_check_display(item) if self._on_check_changed: self._on_check_changed(self._checked.copy()) def insert_with_checkbox(self, parent, index, iid=None, **kwargs): """插入带勾选框的项""" values = list(kwargs.get('values', [])) # 在最前面插入勾选框 values.insert(0, '☐') kwargs['values'] = values return self.insert(parent, index, iid=iid, **kwargs) class HistoryView: """ 历史记录视图 显示任务执行历史列表,支持: - 查看详情(Markdown 渲染) - 复用成功的代码 - 重试失败的任务 - 勾选删除 """ def __init__( self, parent: tk.Widget, history_manager: HistoryManager, on_back: Callable[[], None], on_reuse_code: Optional[Callable[[TaskRecord], None]] = None, on_retry_task: Optional[Callable[[TaskRecord], None]] = None ): self.parent = parent self.history = history_manager self.on_back = on_back self.on_reuse_code = on_reuse_code self.on_retry_task = on_retry_task self._selected_record: Optional[TaskRecord] = None self._create_widgets() def _create_widgets(self): """创建 UI 组件""" self.frame = tk.Frame(self.parent, bg='#1e1e1e') # 标题栏 title_frame = tk.Frame(self.frame, bg='#1e1e1e') title_frame.pack(fill=tk.X, padx=10, pady=10) # 返回按钮 back_btn = tk.Button( title_frame, text="← 返回", font=('Microsoft YaHei UI', 10), bg='#424242', fg='white', activebackground='#616161', activeforeground='white', relief=tk.FLAT, padx=10, cursor='hand2', command=self.on_back ) back_btn.pack(side=tk.LEFT) # 标题 title_label = tk.Label( title_frame, text="📜 任务历史记录", font=('Microsoft YaHei UI', 14, 'bold'), fg='#ce93d8', bg='#1e1e1e' ) title_label.pack(side=tk.LEFT, padx=20) # 统计信息 stats = self.history.get_stats() stats_text = f"共 {stats['total']} 条 | 成功 {stats['success']} | 失败 {stats['failed']} | 成功率 {stats['success_rate']:.0%}" self.stats_label = tk.Label( title_frame, text=stats_text, font=('Microsoft YaHei UI', 9), fg='#888888', bg='#1e1e1e' ) self.stats_label.pack(side=tk.RIGHT) # 主内容区域(左右分栏) content_frame = tk.Frame(self.frame, bg='#1e1e1e') content_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10)) # 配置列权重,让右侧详情区域更宽 content_frame.columnconfigure(0, weight=2) # 左侧列表 content_frame.columnconfigure(1, weight=3) # 右侧详情 content_frame.rowconfigure(0, weight=1) # 左侧:历史列表 list_frame = tk.LabelFrame( content_frame, text=" 任务列表", font=('Microsoft YaHei UI', 10, 'bold'), fg='#4fc3f7', bg='#1e1e1e', relief=tk.GROOVE ) list_frame.grid(row=0, column=0, sticky='nsew', padx=(0, 5)) # 列表操作栏 list_toolbar = tk.Frame(list_frame, bg='#2d2d2d') list_toolbar.pack(fill=tk.X, padx=3, pady=(3, 0)) # 全选按钮 self.select_all_btn = tk.Button( list_toolbar, text="☑ 全选", font=('Microsoft YaHei UI', 9), bg='#3d3d3d', fg='#aaaaaa', activebackground='#4d4d4d', activeforeground='#ffffff', relief=tk.FLAT, padx=8, cursor='hand2', command=self._select_all ) self.select_all_btn.pack(side=tk.LEFT, padx=(0, 5)) # 取消全选按钮 self.deselect_all_btn = tk.Button( list_toolbar, text="☐ 取消全选", font=('Microsoft YaHei UI', 9), bg='#3d3d3d', fg='#aaaaaa', activebackground='#4d4d4d', activeforeground='#ffffff', relief=tk.FLAT, padx=8, cursor='hand2', command=self._deselect_all ) self.deselect_all_btn.pack(side=tk.LEFT) # 已选数量提示 self.selected_count_label = tk.Label( list_toolbar, text="", font=('Microsoft YaHei UI', 9), fg='#ffd54f', bg='#2d2d2d' ) self.selected_count_label.pack(side=tk.RIGHT, padx=5) # 列表框 list_container = tk.Frame(list_frame, bg='#2d2d2d') list_container.pack(fill=tk.BOTH, expand=True, padx=3, pady=3) # 使用带勾选框的 Treeview 显示列表 columns = ('check', 'time', 'description', 'status', 'duration') self.tree = CheckboxTreeview(list_container, columns=columns, show='headings', height=18) # 配置列 self.tree.heading('check', text='') self.tree.heading('time', text='时间') self.tree.heading('description', text='任务描述') self.tree.heading('status', text='状态') self.tree.heading('duration', text='耗时') self.tree.column('check', width=30, minwidth=30, anchor='center') self.tree.column('time', width=130, minwidth=110) self.tree.column('description', width=180, minwidth=120) self.tree.column('status', width=65, minwidth=55) self.tree.column('duration', width=65, minwidth=50) # 设置勾选变化回调 self.tree.set_on_check_changed(self._on_check_changed) # 滚动条 scrollbar = ttk.Scrollbar(list_container, orient=tk.VERTICAL, command=self.tree.yview) self.tree.configure(yscrollcommand=scrollbar.set) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) # 绑定选择事件 self.tree.bind('<>', self._on_select) # 右侧:详情面板 detail_frame = tk.LabelFrame( content_frame, text=" 任务详情 ", font=('Microsoft YaHei UI', 10, 'bold'), fg='#81c784', bg='#1e1e1e', relief=tk.GROOVE ) detail_frame.grid(row=0, column=1, sticky='nsew', padx=(5, 0)) # 详情文本框(使用 Markdown 渲染) detail_container = tk.Frame(detail_frame, bg='#2d2d2d') detail_container.pack(fill=tk.BOTH, expand=True, padx=3, pady=3) self.detail_text = MarkdownText( detail_container, wrap=tk.WORD, font=('Microsoft YaHei UI', 10), bg='#2d2d2d', fg='#d4d4d4', relief=tk.FLAT, padx=10, pady=10, state=tk.DISABLED ) detail_scrollbar = ttk.Scrollbar(detail_container, orient=tk.VERTICAL, command=self.detail_text.yview) self.detail_text.configure(yscrollcommand=detail_scrollbar.set) detail_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) self.detail_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) # 底部按钮 btn_frame = tk.Frame(self.frame, bg='#1e1e1e') btn_frame.pack(fill=tk.X, padx=10, pady=(0, 10)) # 左侧按钮组 left_btn_frame = tk.Frame(btn_frame, bg='#1e1e1e') left_btn_frame.pack(side=tk.LEFT) # 打开日志按钮 self.open_log_btn = tk.Button( left_btn_frame, text="📄 打开日志", font=('Microsoft YaHei UI', 10), bg='#424242', fg='white', activebackground='#616161', activeforeground='white', relief=tk.FLAT, padx=15, cursor='hand2', state=tk.DISABLED, command=self._open_log ) self.open_log_btn.pack(side=tk.LEFT, padx=(0, 10)) # 复用代码按钮 self.reuse_btn = tk.Button( left_btn_frame, text="🔄 复用此代码", font=('Microsoft YaHei UI', 10), bg='#0e639c', fg='white', activebackground='#1177bb', activeforeground='white', relief=tk.FLAT, padx=15, cursor='hand2', state=tk.DISABLED, command=self._reuse_code ) self.reuse_btn.pack(side=tk.LEFT, padx=(0, 10)) # 重试按钮(仅失败任务可用) self.retry_btn = tk.Button( left_btn_frame, text="🔧 重试(AI修复)", font=('Microsoft YaHei UI', 10), bg='#f57c00', fg='white', activebackground='#ff9800', activeforeground='white', relief=tk.FLAT, padx=15, cursor='hand2', state=tk.DISABLED, command=self._retry_task ) self.retry_btn.pack(side=tk.LEFT) # 右侧按钮组 right_btn_frame = tk.Frame(btn_frame, bg='#1e1e1e') right_btn_frame.pack(side=tk.RIGHT) # 删除选中按钮(默认禁用) self.delete_btn = tk.Button( right_btn_frame, text="🗑️ 删除选中 (0)", font=('Microsoft YaHei UI', 10), bg='#5d5d5d', fg='#888888', activebackground='#5d5d5d', activeforeground='#888888', relief=tk.FLAT, padx=15, cursor='arrow', state=tk.DISABLED, command=self._delete_selected ) self.delete_btn.pack(side=tk.RIGHT) # 加载数据 self._load_data() def _load_data(self): """加载历史数据到列表""" # 清空现有数据 for item in self.tree.get_children(): self.tree.delete(item) # 清空勾选状态 self.tree._checked.clear() # 加载历史记录 records = self.history.get_all() # 用于跟踪已插入的ID,避免重复 inserted_ids = set() for record in records: # 如果task_id已存在,跳过或使用唯一ID if record.task_id in inserted_ids: continue # 使用任务描述(如果有)或截断的用户输入 description = getattr(record, 'task_summary', None) or record.user_input if len(description) > 20: description = description[:20] + "..." status = "✓ 成功" if record.success else "✗ 失败" duration = f"{record.duration_ms}ms" try: self.tree.insert_with_checkbox('', tk.END, iid=record.task_id, values=( record.timestamp, description, status, duration )) inserted_ids.add(record.task_id) except tk.TclError as e: # 如果ID已存在,使用带时间戳的唯一ID if "already exists" in str(e): unique_id = f"{record.task_id}_{len(inserted_ids)}" self.tree.insert_with_checkbox('', tk.END, iid=unique_id, values=( record.timestamp, description, status, duration )) inserted_ids.add(unique_id) else: raise # 更新统计信息 self._update_stats() # 更新删除按钮状态 self._update_delete_button(set()) # 显示空状态提示 if not records: self._show_empty_state() def _update_stats(self): """更新统计信息""" stats = self.history.get_stats() stats_text = f"共 {stats['total']} 条 | 成功 {stats['success']} | 失败 {stats['failed']} | 成功率 {stats['success_rate']:.0%}" self.stats_label.config(text=stats_text) def _on_check_changed(self, checked: Set[str]): """勾选状态变化回调""" self._update_delete_button(checked) # 更新已选数量提示 count = len(checked) if count > 0: self.selected_count_label.config(text=f"已选 {count} 项") else: self.selected_count_label.config(text="") def _update_delete_button(self, checked: Set[str]): """更新删除按钮状态""" count = len(checked) if count > 0: self.delete_btn.config( text=f"🗑️ 删除选中 ({count})", state=tk.NORMAL, bg='#d32f2f', fg='white', activebackground='#f44336', activeforeground='white', cursor='hand2' ) else: self.delete_btn.config( text="🗑️ 删除选中 (0)", state=tk.DISABLED, bg='#5d5d5d', fg='#888888', activebackground='#5d5d5d', activeforeground='#888888', cursor='arrow' ) def _select_all(self): """全选""" self.tree.check_all() def _deselect_all(self): """取消全选""" self.tree.clear_checked() def _delete_selected(self): """删除选中的记录""" checked = self.tree.get_checked() if not checked: return count = len(checked) result = messagebox.askyesno( "确认删除", f"确定要删除选中的 {count} 条记录吗?\n此操作不可恢复。", icon='warning' ) if result: # 删除选中的记录 for task_id in checked: self.history.delete_by_id(task_id) # 重新加载数据 self._load_data() self._show_empty_state() if not self.history.get_all() else None # 重置按钮状态 self.open_log_btn.config(state=tk.DISABLED) self.reuse_btn.config(state=tk.DISABLED) self.retry_btn.config(state=tk.DISABLED) self._selected_record = None messagebox.showinfo("删除成功", f"已删除 {count} 条记录") def _on_select(self, event): """选择记录事件""" selection = self.tree.selection() if not selection: return task_id = selection[0] record = self.history.get_by_id(task_id) if record: self._selected_record = record self._show_record_detail(record) # 更新按钮状态 self.open_log_btn.config(state=tk.NORMAL) self.reuse_btn.config(state=tk.NORMAL if record.success else tk.DISABLED) self.retry_btn.config(state=tk.NORMAL if not record.success else tk.DISABLED) def _show_record_detail(self, record: TaskRecord): """显示记录详情(Markdown 格式)""" # 构建 Markdown 内容 status_text = "✓ 成功" if record.success else "✗ 失败" md_content = f"""## 任务 ID: {record.task_id} **时间:** {record.timestamp} **状态:** {status_text} **耗时:** {record.duration_ms}ms --- ### 用户输入 {record.user_input} --- ### 执行计划 {record.execution_plan} --- ### 生成的代码 ```python {record.code} ``` """ if record.stdout: md_content += f"""--- ### 输出 {record.stdout} """ if record.stderr: md_content += f"""--- ### 错误信息 {record.stderr} """ self.detail_text.render_markdown(md_content) def _show_empty_state(self): """显示空状态""" self.detail_text.config(state=tk.NORMAL) self.detail_text.delete(1.0, tk.END) self.detail_text.insert(tk.END, "暂无历史记录\n\n执行任务后,记录将显示在这里。", 'normal') self.detail_text.config(state=tk.DISABLED) def _open_log(self): """打开日志文件""" if self._selected_record and self._selected_record.log_path: log_path = Path(self._selected_record.log_path) if log_path.exists(): os.startfile(str(log_path)) else: messagebox.showwarning("提示", f"日志文件不存在:\n{log_path}") def _reuse_code(self): """复用代码""" if self._selected_record and self.on_reuse_code: self.on_reuse_code(self._selected_record) def _retry_task(self): """重试失败的任务""" if self._selected_record and self.on_retry_task: self.on_retry_task(self._selected_record) def show(self): """显示视图""" self._load_data() # 刷新数据 self.frame.pack(fill=tk.BOTH, expand=True) def hide(self): """隐藏视图""" self.frame.pack_forget() def get_frame(self) -> tk.Frame: """获取主框架""" return self.frame