feat:增强需求澄清与任务管理功能

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

扩展了 HistoryManager,支持任务摘要生成以及记录的批量删除。
优化了 chat_view.py 和 history_view.py 中的 UI 组件,提升用户体验,包括 Markdown 渲染和任务管理选项。
This commit is contained in:
Mimikko-zeus
2026-01-07 12:35:27 +08:00
parent 0a92355bfb
commit 68f4f01cd7
12 changed files with 3158 additions and 160 deletions

View File

@@ -1,11 +1,243 @@
"""
聊天视图组件
处理普通对话的 UI 展示 - 支持流式消息加载动画
处理普通对话的 UI 展示 - 支持流式消息加载动画和 Markdown 渲染
"""
import tkinter as tk
from tkinter import scrolledtext
from typing import Callable, Optional
from typing import Callable, Optional, List, Tuple
import re
import webbrowser
class MarkdownRenderer:
"""Markdown 渲染器 - 将 Markdown 文本渲染到 Text 组件"""
# URL 正则表达式
URL_PATTERN = re.compile(
r'https?://[^\s<>\[\]()\u4e00-\u9fff]+'
)
# Markdown 链接模式 [text](url)
MD_LINK_PATTERN = re.compile(r'\[([^\]]+)\]\(([^)]+)\)')
def __init__(self, text_widget: tk.Text):
self.text_widget = text_widget
self._link_count = 0
self._configure_tags()
def _configure_tags(self):
"""配置 Markdown 样式标签"""
# 标题样式
self.text_widget.tag_configure('md_h1', font=('Microsoft YaHei UI', 16, 'bold'), foreground='#4fc3f7')
self.text_widget.tag_configure('md_h2', font=('Microsoft YaHei UI', 14, 'bold'), foreground='#4fc3f7')
self.text_widget.tag_configure('md_h3', font=('Microsoft YaHei UI', 12, 'bold'), foreground='#4fc3f7')
# 粗体和斜体
self.text_widget.tag_configure('md_bold', font=('Microsoft YaHei UI', 11, 'bold'))
self.text_widget.tag_configure('md_italic', font=('Microsoft YaHei UI', 11, 'italic'))
# 代码样式
self.text_widget.tag_configure('md_code', font=('Consolas', 10), background='#3c3c3c', foreground='#ce9178')
self.text_widget.tag_configure('md_code_block', font=('Consolas', 10), background='#1e1e1e', foreground='#d4d4d4')
# 列表样式
self.text_widget.tag_configure('md_list', foreground='#d4d4d4', lmargin1=20, lmargin2=35)
self.text_widget.tag_configure('md_list_bullet', foreground='#ffd54f')
# 链接样式
self.text_widget.tag_configure('md_link', foreground='#64b5f6', underline=True)
# 引用样式
self.text_widget.tag_configure('md_quote', foreground='#9e9e9e', lmargin1=20, lmargin2=20, font=('Microsoft YaHei UI', 11, 'italic'))
def render(self, text: str, base_tag: str = 'assistant') -> None:
"""
渲染 Markdown 文本
Args:
text: Markdown 文本
base_tag: 基础样式标签
"""
lines = text.split('\n')
in_code_block = False
code_block_content = []
for i, line in enumerate(lines):
# 代码块处理
if line.strip().startswith('```'):
if in_code_block:
# 结束代码块
self._insert_code_block('\n'.join(code_block_content))
code_block_content = []
in_code_block = False
else:
# 开始代码块
in_code_block = True
continue
if in_code_block:
code_block_content.append(line)
continue
# 普通行处理
self._render_line(line, base_tag)
# 添加换行(除了最后一行)
if i < len(lines) - 1:
self.text_widget.insert(tk.END, '\n')
def _render_line(self, line: str, base_tag: str) -> None:
"""渲染单行"""
stripped = line.strip()
# 空行
if not stripped:
return
# 标题
if stripped.startswith('### '):
self.text_widget.insert(tk.END, stripped[4:], 'md_h3')
return
elif stripped.startswith('## '):
self.text_widget.insert(tk.END, stripped[3:], 'md_h2')
return
elif stripped.startswith('# '):
self.text_widget.insert(tk.END, stripped[2:], 'md_h1')
return
# 引用
if stripped.startswith('> '):
self.text_widget.insert(tk.END, stripped[2:], 'md_quote')
return
# 无序列表
if stripped.startswith('- ') or stripped.startswith('* '):
self.text_widget.insert(tk.END, '', 'md_list_bullet')
self._render_inline(stripped[2:], base_tag, 'md_list')
return
# 有序列表
list_match = re.match(r'^(\d+)\.\s+(.+)$', stripped)
if list_match:
num = list_match.group(1)
content = list_match.group(2)
self.text_widget.insert(tk.END, f' {num}. ', 'md_list_bullet')
self._render_inline(content, base_tag, 'md_list')
return
# 普通段落
self._render_inline(line, base_tag)
def _render_inline(self, text: str, base_tag: str, extra_tag: str = None) -> None:
"""渲染行内元素(粗体、斜体、代码、链接)"""
tags = (base_tag, extra_tag) if extra_tag else (base_tag,)
# 先处理 Markdown 链接 [text](url)
last_end = 0
for match in self.MD_LINK_PATTERN.finditer(text):
# 插入链接前的文本
if match.start() > last_end:
self._render_inline_formatting(text[last_end:match.start()], tags)
# 插入链接
link_text = match.group(1)
link_url = match.group(2)
self._insert_link(link_text, link_url)
last_end = match.end()
# 处理剩余文本
if last_end < len(text):
remaining = text[last_end:]
self._render_inline_formatting(remaining, tags)
def _render_inline_formatting(self, text: str, tags: tuple) -> None:
"""处理行内格式粗体、斜体、代码、纯URL"""
# 处理粗体 **text**
parts = re.split(r'(\*\*[^*]+\*\*)', text)
for part in parts:
if part.startswith('**') and part.endswith('**'):
self.text_widget.insert(tk.END, part[2:-2], tags + ('md_bold',))
else:
# 处理斜体 *text*
sub_parts = re.split(r'(\*[^*]+\*)', part)
for sub_part in sub_parts:
if sub_part.startswith('*') and sub_part.endswith('*') and len(sub_part) > 2:
self.text_widget.insert(tk.END, sub_part[1:-1], tags + ('md_italic',))
else:
# 处理行内代码 `code`
code_parts = re.split(r'(`[^`]+`)', sub_part)
for code_part in code_parts:
if code_part.startswith('`') and code_part.endswith('`'):
self.text_widget.insert(tk.END, code_part[1:-1], ('md_code',))
else:
# 处理纯 URL
self._render_urls(code_part, tags)
def _render_urls(self, text: str, tags: tuple) -> None:
"""渲染纯 URL 链接"""
last_end = 0
for match in self.URL_PATTERN.finditer(text):
# 插入 URL 前的文本
if match.start() > last_end:
self.text_widget.insert(tk.END, text[last_end:match.start()], tags)
# 插入 URL 链接
url = match.group(0)
# 清理 URL 末尾的标点
while url and url[-1] in '.,;:!?。,;:!?':
url = url[:-1]
self._insert_link(url, url)
# 如果清理了标点,插入标点
original_url = match.group(0)
if len(original_url) > len(url):
self.text_widget.insert(tk.END, original_url[len(url):], tags)
last_end = match.end()
# 插入剩余文本
if last_end < len(text):
self.text_widget.insert(tk.END, text[last_end:], tags)
def _insert_link(self, text: str, url: str) -> None:
"""插入可点击的链接"""
tag_name = f'link_{self._link_count}'
self._link_count += 1
self.text_widget.tag_configure(tag_name, foreground='#64b5f6', underline=True)
# 绑定点击事件 - 使用 ButtonRelease 而不是 Button-1更可靠
def on_click(event, u=url):
self._open_url(u)
return "break" # 阻止事件继续传播
self.text_widget.tag_bind(tag_name, '<ButtonRelease-1>', on_click)
self.text_widget.tag_bind(tag_name, '<Enter>', lambda e: self._set_cursor('hand2'))
self.text_widget.tag_bind(tag_name, '<Leave>', lambda e: self._set_cursor(''))
self.text_widget.insert(tk.END, text, (tag_name, 'md_link'))
def _set_cursor(self, cursor: str) -> None:
"""设置鼠标光标"""
try:
self.text_widget.config(cursor=cursor)
except:
pass
def _insert_code_block(self, code: str) -> None:
"""插入代码块"""
self.text_widget.insert(tk.END, '\n')
self.text_widget.insert(tk.END, code, 'md_code_block')
self.text_widget.insert(tk.END, '\n')
def _open_url(self, url: str) -> None:
"""打开 URL"""
try:
webbrowser.open(url)
except Exception as e:
print(f"Failed to open URL: {url}, error: {e}")
class LoadingIndicator:
@@ -65,7 +297,7 @@ class ChatView:
聊天视图
包含:
- 消息显示区域
- 消息显示区域(支持 Markdown 渲染)
- 输入框
- 发送按钮
- 流式消息支持
@@ -75,7 +307,8 @@ class ChatView:
self,
parent: tk.Widget,
on_send: Callable[[str], None],
on_show_history: Optional[Callable[[], None]] = None
on_show_history: Optional[Callable[[], None]] = None,
on_show_settings: Optional[Callable[[], None]] = None
):
"""
初始化聊天视图
@@ -84,18 +317,24 @@ class ChatView:
parent: 父容器
on_send: 发送消息回调函数
on_show_history: 显示历史记录回调函数
on_show_settings: 显示设置页面回调函数
"""
self.parent = parent
self.on_send = on_send
self.on_show_history = on_show_history
self.on_show_settings = on_show_settings
# 流式消息状态
self._stream_active = False
self._stream_tag = None
self._stream_buffer = [] # 用于缓存流式内容,最后渲染 Markdown
# 加载指示器
self.loading: Optional[LoadingIndicator] = None
# Markdown 渲染器
self.md_renderer: Optional[MarkdownRenderer] = None
self._create_widgets()
def _create_widgets(self):
@@ -118,10 +357,49 @@ class ChatView:
)
title_label.pack(side=tk.LEFT, expand=True)
# 按钮容器(右侧)
btn_container = tk.Frame(title_frame, bg='#1e1e1e')
btn_container.pack(side=tk.RIGHT)
# 清空对话按钮
self.clear_btn = tk.Button(
btn_container,
text="🗑️ 清空",
font=('Microsoft YaHei UI', 10),
bg='#424242',
fg='#ef9a9a',
activebackground='#616161',
activeforeground='#ef9a9a',
relief=tk.FLAT,
padx=10,
pady=3,
cursor='hand2',
command=self._on_clear_chat
)
self.clear_btn.pack(side=tk.RIGHT, padx=(5, 0))
# 设置按钮
if self.on_show_settings:
self.settings_btn = tk.Button(
btn_container,
text="⚙️ 设置",
font=('Microsoft YaHei UI', 10),
bg='#424242',
fg='#90caf9',
activebackground='#616161',
activeforeground='#90caf9',
relief=tk.FLAT,
padx=10,
pady=3,
cursor='hand2',
command=self.on_show_settings
)
self.settings_btn.pack(side=tk.RIGHT, padx=(5, 0))
# 历史记录按钮
if self.on_show_history:
self.history_btn = tk.Button(
title_frame,
btn_container,
text="📜 历史",
font=('Microsoft YaHei UI', 10),
bg='#424242',
@@ -147,10 +425,14 @@ class ChatView:
relief=tk.FLAT,
padx=10,
pady=10,
state=tk.DISABLED
cursor='arrow'
)
self.message_area.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
# 禁止编辑但允许选择和点击链接
self.message_area.bind('<Key>', lambda e: 'break') # 禁止键盘输入
# 允许鼠标操作(选择文本、点击链接)
# 配置消息标签样式
self.message_area.tag_configure('user', foreground='#4fc3f7', font=('Microsoft YaHei UI', 11, 'bold'))
self.message_area.tag_configure('assistant', foreground='#81c784', font=('Microsoft YaHei UI', 11))
@@ -158,6 +440,9 @@ class ChatView:
self.message_area.tag_configure('error', foreground='#ef5350', font=('Microsoft YaHei UI', 10))
self.message_area.tag_configure('streaming', foreground='#81c784', font=('Microsoft YaHei UI', 11))
# 初始化 Markdown 渲染器
self.md_renderer = MarkdownRenderer(self.message_area)
# 输入区域框架
input_frame = tk.Frame(self.frame, bg='#1e1e1e')
input_frame.pack(fill=tk.X)
@@ -213,28 +498,54 @@ class ChatView:
self.input_entry.delete(0, tk.END)
self.on_send(text)
def add_message(self, message: str, tag: str = 'assistant'):
def _on_clear_chat(self):
"""清空对话"""
from tkinter import messagebox
if messagebox.askyesno("确认", "确定要清空当前对话吗?\n(这将同时清空对话上下文)"):
self.clear_messages()
# 通知 agent 清空上下文(通过回调)
if hasattr(self, 'on_clear_context') and self.on_clear_context:
self.on_clear_context()
# 重新显示欢迎消息
welcome_msg = (
"欢迎使用 LocalAgent!\n"
"- 输入问题进行对话\n"
"- 输入文件处理需求(如\"复制文件\"\"整理图片\")将触发执行模式"
)
self.add_message(welcome_msg, 'system')
def set_clear_context_callback(self, callback: Callable[[], None]):
"""设置清空上下文的回调"""
self.on_clear_context = callback
def add_message(self, message: str, tag: str = 'assistant', use_markdown: bool = True):
"""
添加消息到显示区域
Args:
message: 消息内容
tag: 消息类型 (user/assistant/system/error)
use_markdown: 是否使用 Markdown 渲染assistant 消息默认启用)
"""
self.message_area.config(state=tk.NORMAL)
# 添加前缀
prefix_map = {
'user': '[你] ',
'assistant': '[助手] ',
'system': '[系统] ',
'error': '[错误] '
'user': '\n[你] ',
'assistant': '\n[助手] ',
'system': '\n[系统] ',
'error': '\n[错误] '
}
prefix = prefix_map.get(tag, '')
prefix = prefix_map.get(tag, '\n')
self.message_area.insert(tk.END, "\n" + prefix + message + "\n", tag)
self.message_area.insert(tk.END, prefix, tag)
# 根据消息类型决定是否使用 Markdown 渲染
if use_markdown and tag == 'assistant' and self.md_renderer:
self.md_renderer.render(message, tag)
else:
self.message_area.insert(tk.END, message, tag)
self.message_area.insert(tk.END, '\n')
self.message_area.see(tk.END)
self.message_area.config(state=tk.DISABLED)
def start_stream_message(self, tag: str = 'assistant'):
"""
@@ -245,21 +556,22 @@ class ChatView:
"""
self._stream_active = True
self._stream_tag = tag
self.message_area.config(state=tk.NORMAL)
self._stream_buffer = []
# 添加前缀
prefix_map = {
'user': '[你] ',
'assistant': '[助手] ',
'system': '[系统] ',
'error': '[错误] '
'user': '\n[你] ',
'assistant': '\n[助手] ',
'system': '\n[系统] ',
'error': '\n[错误] '
}
prefix = prefix_map.get(tag, '')
prefix = prefix_map.get(tag, '\n')
self.message_area.insert(tk.END, "\n" + prefix, tag)
self.message_area.insert(tk.END, prefix, tag)
# 使用 mark 来标记内容开始位置,比索引更可靠
self.message_area.mark_set("stream_start", tk.END + "-1c")
self.message_area.mark_gravity("stream_start", tk.LEFT)
self.message_area.see(tk.END)
# 保持 NORMAL 状态以便追加内容
def append_stream_chunk(self, chunk: str):
"""
@@ -271,25 +583,39 @@ class ChatView:
if not self._stream_active:
return
self._stream_buffer.append(chunk)
self.message_area.insert(tk.END, chunk, self._stream_tag)
self.message_area.see(tk.END)
# 强制更新 UI
self.message_area.update_idletasks()
def end_stream_message(self):
"""结束流式消息"""
"""结束流式消息,重新渲染为 Markdown"""
if self._stream_active:
self.message_area.insert(tk.END, "\n")
# 获取完整的流式内容
full_content = ''.join(self._stream_buffer)
# 如果是 assistant 消息且有内容,重新渲染为 Markdown
if self._stream_tag == 'assistant' and self.md_renderer and full_content.strip():
# 删除原来的纯文本内容(从 mark 位置到末尾)
try:
self.message_area.delete("stream_start", tk.END)
except tk.TclError:
pass
# 重新渲染为 Markdown
self.md_renderer.render(full_content, self._stream_tag)
self.message_area.insert(tk.END, '\n')
self.message_area.see(tk.END)
self.message_area.config(state=tk.DISABLED)
# 重置状态
self._stream_active = False
self._stream_tag = None
self._stream_buffer = []
def clear_messages(self):
"""清空消息区域"""
self.message_area.config(state=tk.NORMAL)
self.message_area.delete(1.0, tk.END)
self.message_area.config(state=tk.DISABLED)
def set_input_enabled(self, enabled: bool):
"""设置输入区域是否可用"""