Files
LocalAgent/ui/history_view.py
Mimikko-zeus 68f4f01cd7 feat:增强需求澄清与任务管理功能
更新了 .env.example,新增聊天模型配置,以提升对话处理能力。
增强了 README.md,反映了包括需求澄清、代码复用和自动重试在内的新功能。
重构了 agent.py,以支持多模型交互,并为无法在本地执行的任务新增了引导处理逻辑。
改进了 SandboxRunner,增加了任务执行成功校验,并加入了工作区清理功能。

扩展了 HistoryManager,支持任务摘要生成以及记录的批量删除。
优化了 chat_view.py 和 history_view.py 中的 UI 组件,提升用户体验,包括 Markdown 渲染和任务管理选项。
2026-01-07 12:35:27 +08:00

714 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
历史记录视图组件
显示任务执行历史,支持 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('<Button-1>', 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('<<TreeviewSelect>>', 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()
for record in records:
# 使用任务描述(如果有)或截断的用户输入
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"
self.tree.insert_with_checkbox('', tk.END, iid=record.task_id, values=(
record.timestamp,
description,
status,
duration
))
# 更新统计信息
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