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):
"""设置输入区域是否可用"""

725
ui/clarify_view.py Normal file
View File

@@ -0,0 +1,725 @@
"""
需求澄清视图组件
用于通过交互式问答澄清用户的模糊需求
"""
import tkinter as tk
from tkinter import ttk
from typing import Callable, Optional, Dict, List, Any
class ClarifyOption:
"""澄清选项数据类"""
def __init__(
self,
id: str,
type: str, # radio, checkbox, input
label: str,
choices: List[str] = None,
default: str = None,
placeholder: str = None
):
self.id = id
self.type = type
self.label = label
self.choices = choices or []
self.default = default
self.placeholder = placeholder or ""
class ClarifyView:
"""
需求澄清视图
支持:
- 单选按钮 (radio)
- 复选框 (checkbox)
- 输入框 (input)
- 多轮对话展示
"""
def __init__(
self,
parent: tk.Widget,
on_submit: Callable[[Dict[str, Any]], None],
on_cancel: Callable[[], None]
):
self.parent = parent
self.on_submit = on_submit
self.on_cancel = on_cancel
# 存储控件变量
self._vars: Dict[str, Any] = {}
self._option_widgets: List[tk.Widget] = []
# 对话历史
self._history: List[Dict[str, Any]] = []
self._create_widgets()
def _create_widgets(self):
"""创建 UI 组件"""
self.frame = tk.Frame(self.parent, bg='#1e1e1e')
# 标题栏
title_frame = tk.Frame(self.frame, bg='#2d2d2d')
title_frame.pack(fill=tk.X)
title_label = tk.Label(
title_frame,
text="💬 需求澄清",
font=('Microsoft YaHei UI', 14, 'bold'),
fg='#4fc3f7',
bg='#2d2d2d',
pady=10
)
title_label.pack(side=tk.LEFT, padx=15)
# 提示信息
tip_label = tk.Label(
title_frame,
text="请回答以下问题,帮助我更好地理解您的需求",
font=('Microsoft YaHei UI', 9),
fg='#888888',
bg='#2d2d2d'
)
tip_label.pack(side=tk.RIGHT, padx=15)
# 主内容区域(可滚动)
content_container = tk.Frame(self.frame, bg='#1e1e1e')
content_container.pack(fill=tk.BOTH, expand=True, padx=15, pady=10)
# 创建 Canvas 和滚动条
self.canvas = tk.Canvas(content_container, bg='#1e1e1e', highlightthickness=0)
scrollbar = ttk.Scrollbar(content_container, orient=tk.VERTICAL, command=self.canvas.yview)
self.content_frame = tk.Frame(self.canvas, bg='#1e1e1e')
self.canvas.configure(yscrollcommand=scrollbar.set)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
self.canvas_window = self.canvas.create_window((0, 0), window=self.content_frame, anchor=tk.NW)
# 绑定事件
self.content_frame.bind("<Configure>", self._on_frame_configure)
self.canvas.bind("<Configure>", self._on_canvas_configure)
# 鼠标滚轮支持
self.canvas.bind_all("<MouseWheel>", self._on_mousewheel)
# 对话历史区域
self.history_frame = tk.Frame(self.content_frame, bg='#1e1e1e')
self.history_frame.pack(fill=tk.X, pady=(0, 10))
# 当前问题区域
self.question_frame = tk.Frame(self.content_frame, bg='#252526', relief=tk.FLAT)
self.question_frame.pack(fill=tk.X, pady=10)
# 问题标签
self.question_label = tk.Label(
self.question_frame,
text="",
font=('Microsoft YaHei UI', 11),
fg='#ffffff',
bg='#252526',
wraplength=600,
justify=tk.LEFT,
padx=15,
pady=10
)
self.question_label.pack(fill=tk.X)
# 选项区域
self.options_frame = tk.Frame(self.question_frame, bg='#252526')
self.options_frame.pack(fill=tk.X, padx=15, pady=(0, 15))
# 底部按钮区域
btn_frame = tk.Frame(self.frame, bg='#1e1e1e')
btn_frame.pack(fill=tk.X, padx=15, pady=15)
# 取消按钮
self.cancel_btn = tk.Button(
btn_frame,
text="取消",
font=('Microsoft YaHei UI', 10),
bg='#424242',
fg='white',
activebackground='#616161',
activeforeground='white',
relief=tk.FLAT,
padx=20,
pady=5,
cursor='hand2',
command=self._on_cancel
)
self.cancel_btn.pack(side=tk.LEFT)
# 已收集信息提示
self.info_label = tk.Label(
btn_frame,
text="",
font=('Microsoft YaHei UI', 9),
fg='#81c784',
bg='#1e1e1e'
)
self.info_label.pack(side=tk.LEFT, padx=20)
# 确定按钮
self.submit_btn = tk.Button(
btn_frame,
text="确定 →",
font=('Microsoft YaHei UI', 10, 'bold'),
bg='#0e639c',
fg='white',
activebackground='#1177bb',
activeforeground='white',
relief=tk.FLAT,
padx=20,
pady=5,
cursor='hand2',
command=self._on_submit
)
self.submit_btn.pack(side=tk.RIGHT)
def _on_frame_configure(self, event):
"""内容框架大小变化"""
self.canvas.configure(scrollregion=self.canvas.bbox("all"))
def _on_canvas_configure(self, event):
"""Canvas 大小变化"""
self.canvas.itemconfig(self.canvas_window, width=event.width)
def _on_mousewheel(self, event):
"""鼠标滚轮"""
self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
def set_question(self, question: str, options: List[Dict[str, Any]]):
"""
设置当前问题和选项
Args:
question: 问题文本
options: 选项列表,每个选项是一个字典
"""
# 更新问题
self.question_label.config(text=f"{question}")
# 清除旧选项
for widget in self._option_widgets:
widget.destroy()
self._option_widgets.clear()
self._vars.clear()
# 创建新选项
for opt_data in options:
opt = ClarifyOption(
id=opt_data.get('id', ''),
type=opt_data.get('type', 'input'),
label=opt_data.get('label', ''),
choices=opt_data.get('choices', []),
default=opt_data.get('default'),
placeholder=opt_data.get('placeholder', '')
)
self._create_option_widget(opt)
def _create_option_widget(self, option: ClarifyOption):
"""创建选项控件"""
# 选项容器
container = tk.Frame(self.options_frame, bg='#252526')
container.pack(fill=tk.X, pady=5)
self._option_widgets.append(container)
# 标签
if option.label:
label = tk.Label(
container,
text=option.label,
font=('Microsoft YaHei UI', 10),
fg='#cccccc',
bg='#252526'
)
label.pack(anchor=tk.W, pady=(0, 5))
if option.type == 'radio':
self._create_radio_option(container, option)
elif option.type == 'checkbox':
self._create_checkbox_option(container, option)
elif option.type == 'input':
self._create_input_option(container, option)
def _create_radio_option(self, parent: tk.Widget, option: ClarifyOption):
"""创建单选按钮"""
var = tk.StringVar(value=option.default or (option.choices[0] if option.choices else ''))
self._vars[option.id] = var
radio_frame = tk.Frame(parent, bg='#252526')
radio_frame.pack(fill=tk.X)
# 检查是否是位置选项(需要预览)
is_position = self._is_position_option(option)
if is_position:
# 使用网格布局显示位置预览
self._create_position_radio_with_preview(radio_frame, option, var)
else:
# 普通单选按钮
for choice in option.choices:
rb = tk.Radiobutton(
radio_frame,
text=choice,
variable=var,
value=choice,
font=('Microsoft YaHei UI', 10),
fg='#e0e0e0',
bg='#252526',
activebackground='#252526',
activeforeground='#ffffff',
selectcolor='#3c3c3c',
cursor='hand2'
)
rb.pack(anchor=tk.W, pady=2)
self._option_widgets.append(rb)
def _is_position_option(self, option: ClarifyOption) -> bool:
"""判断是否是位置选项"""
position_keywords = ['position', 'pos', '位置', '方位']
opt_id_lower = option.id.lower()
label_lower = option.label.lower()
for keyword in position_keywords:
if keyword in opt_id_lower or keyword in label_lower:
return True
# 检查选项是否包含位置相关词汇
position_values = ['左上', '右上', '左下', '右下', '居中', '中心', '顶部', '底部',
'top', 'bottom', 'left', 'right', 'center', 'middle']
for choice in option.choices:
choice_lower = choice.lower()
for pos in position_values:
if pos in choice_lower:
return True
return False
def _create_position_radio_with_preview(self, parent: tk.Widget, option: ClarifyOption, var: tk.StringVar):
"""创建带位置预览的单选按钮"""
container = tk.Frame(parent, bg='#252526')
container.pack(fill=tk.X, pady=5)
# 左侧:单选按钮列表
radio_list = tk.Frame(container, bg='#252526')
radio_list.pack(side=tk.LEFT, fill=tk.Y)
for choice in option.choices:
rb = tk.Radiobutton(
radio_list,
text=choice,
variable=var,
value=choice,
font=('Microsoft YaHei UI', 10),
fg='#e0e0e0',
bg='#252526',
activebackground='#252526',
activeforeground='#ffffff',
selectcolor='#3c3c3c',
cursor='hand2',
command=lambda: self._update_position_preview(var, preview_canvas)
)
rb.pack(anchor=tk.W, pady=2)
self._option_widgets.append(rb)
# 右侧:位置预览
preview_frame = tk.Frame(container, bg='#3c3c3c', relief=tk.SOLID, borderwidth=1)
preview_frame.pack(side=tk.LEFT, padx=(20, 0))
preview_canvas = tk.Canvas(
preview_frame,
width=120,
height=80,
bg='#3c3c3c',
highlightthickness=0
)
preview_canvas.pack(padx=2, pady=2)
self._option_widgets.append(preview_canvas)
# 绘制初始预览
self._update_position_preview(var, preview_canvas)
# 绑定变量变化
var.trace_add('write', lambda *args: self._update_position_preview(var, preview_canvas))
def _update_position_preview(self, var: tk.StringVar, canvas: tk.Canvas):
"""更新位置预览"""
canvas.delete("all")
# 绘制背景矩形(代表图片)
canvas.create_rectangle(5, 5, 115, 75, outline='#666666', width=1)
# 获取当前选择的位置
position = var.get().lower()
# 计算标记位置
positions_map = {
# 中文
'左上': (20, 20),
'右上': (100, 20),
'左下': (20, 60),
'右下': (100, 60),
'居中': (60, 40),
'中心': (60, 40),
'顶部居中': (60, 20),
'底部居中': (60, 60),
'左侧居中': (20, 40),
'右侧居中': (100, 40),
# 英文
'top-left': (20, 20),
'top-right': (100, 20),
'bottom-left': (20, 60),
'bottom-right': (100, 60),
'center': (60, 40),
'top': (60, 20),
'bottom': (60, 60),
'left': (20, 40),
'right': (100, 40),
}
# 查找匹配的位置
marker_pos = None
for key, pos in positions_map.items():
if key in position:
marker_pos = pos
break
if not marker_pos:
# 默认居中
marker_pos = (60, 40)
# 绘制位置标记
x, y = marker_pos
canvas.create_oval(x-8, y-8, x+8, y+8, fill='#4fc3f7', outline='#29b6f6', width=2)
canvas.create_text(x, y, text="W", fill='white', font=('Arial', 8, 'bold'))
def _create_checkbox_option(self, parent: tk.Widget, option: ClarifyOption):
"""创建复选框"""
vars_dict = {}
self._vars[option.id] = vars_dict
checkbox_frame = tk.Frame(parent, bg='#252526')
checkbox_frame.pack(fill=tk.X)
# 解析默认值
default_values = []
if option.default:
if isinstance(option.default, list):
default_values = option.default
elif isinstance(option.default, str):
default_values = [option.default]
for choice in option.choices:
var = tk.BooleanVar(value=choice in default_values)
vars_dict[choice] = var
cb = tk.Checkbutton(
checkbox_frame,
text=choice,
variable=var,
font=('Microsoft YaHei UI', 10),
fg='#e0e0e0',
bg='#252526',
activebackground='#252526',
activeforeground='#ffffff',
selectcolor='#3c3c3c',
cursor='hand2'
)
cb.pack(anchor=tk.W, pady=2)
self._option_widgets.append(cb)
def _create_input_option(self, parent: tk.Widget, option: ClarifyOption):
"""创建输入框"""
var = tk.StringVar(value=option.default or '')
self._vars[option.id] = var
input_container = tk.Frame(parent, bg='#252526')
input_container.pack(fill=tk.X, pady=2)
entry = tk.Entry(
input_container,
textvariable=var,
font=('Microsoft YaHei UI', 10),
bg='#3c3c3c',
fg='#ffffff',
insertbackground='#ffffff',
relief=tk.FLAT,
width=40
)
entry.pack(side=tk.LEFT, ipady=5)
self._option_widgets.append(entry)
# 检查是否是颜色输入(通过 id 或 label 判断)
is_color = self._is_color_option(option)
if is_color:
# 添加颜色预览框
preview_frame = tk.Frame(input_container, bg='#252526')
preview_frame.pack(side=tk.LEFT, padx=(10, 0))
color_preview = tk.Label(
preview_frame,
text=" ",
bg=option.default or '#000000',
width=4,
height=1,
relief=tk.SOLID,
borderwidth=1
)
color_preview.pack(side=tk.LEFT)
self._option_widgets.append(color_preview)
# 添加颜色选择按钮
color_btn = tk.Button(
preview_frame,
text="选择",
font=('Microsoft YaHei UI', 9),
bg='#424242',
fg='white',
activebackground='#616161',
activeforeground='white',
relief=tk.FLAT,
padx=8,
cursor='hand2',
command=lambda v=var, p=color_preview: self._pick_color(v, p)
)
color_btn.pack(side=tk.LEFT, padx=(5, 0))
self._option_widgets.append(color_btn)
# 绑定输入变化事件更新预览
var.trace_add('write', lambda *args, v=var, p=color_preview: self._update_color_preview(v, p))
# 占位符提示
if option.placeholder:
placeholder_label = tk.Label(
parent,
text=f"💡 {option.placeholder}",
font=('Microsoft YaHei UI', 9),
fg='#666666',
bg='#252526'
)
placeholder_label.pack(anchor=tk.W)
self._option_widgets.append(placeholder_label)
def _is_color_option(self, option: ClarifyOption) -> bool:
"""判断是否是颜色选项"""
color_keywords = ['color', 'colour', '颜色', '色彩', 'rgb', 'hex']
# 检查 id
opt_id_lower = option.id.lower()
for keyword in color_keywords:
if keyword in opt_id_lower:
return True
# 检查 label
label_lower = option.label.lower()
for keyword in color_keywords:
if keyword in label_lower:
return True
# 检查默认值是否像颜色值
if option.default:
default = option.default.strip()
if default.startswith('#') and len(default) in [4, 7, 9]:
return True
# 检查 placeholder
if option.placeholder:
placeholder_lower = option.placeholder.lower()
for keyword in color_keywords:
if keyword in placeholder_lower:
return True
# 检查是否包含颜色格式提示
if '#' in option.placeholder and ('rgb' in placeholder_lower or 'rrggbb' in placeholder_lower):
return True
return False
def _update_color_preview(self, var: tk.StringVar, preview: tk.Label):
"""更新颜色预览"""
color = var.get().strip()
# 验证颜色格式
if self._is_valid_color(color):
try:
preview.config(bg=color)
except tk.TclError:
pass # 无效颜色,忽略
def _is_valid_color(self, color: str) -> bool:
"""验证颜色格式是否有效"""
if not color:
return False
# 检查十六进制颜色格式
if color.startswith('#'):
hex_part = color[1:]
if len(hex_part) in [3, 6, 8]:
try:
int(hex_part, 16)
return True
except ValueError:
return False
# 检查常见颜色名称
common_colors = [
'red', 'green', 'blue', 'yellow', 'orange', 'purple', 'pink',
'black', 'white', 'gray', 'grey', 'cyan', 'magenta', 'brown'
]
if color.lower() in common_colors:
return True
return False
def _pick_color(self, var: tk.StringVar, preview: tk.Label):
"""打开颜色选择器"""
from tkinter import colorchooser
# 获取当前颜色作为初始值
current = var.get().strip()
initial_color = current if self._is_valid_color(current) else '#000000'
# 打开颜色选择对话框
color = colorchooser.askcolor(
color=initial_color,
title="选择颜色"
)
if color[1]: # color[1] 是十六进制颜色值
var.set(color[1].upper())
preview.config(bg=color[1])
def add_history_item(self, question: str, answer: str):
"""
添加历史对话项
Args:
question: 问题
answer: 用户的回答
"""
self._history.append({'question': question, 'answer': answer})
# 创建历史项 UI
item_frame = tk.Frame(self.history_frame, bg='#2d2d2d', relief=tk.FLAT)
item_frame.pack(fill=tk.X, pady=3)
# 问题
q_label = tk.Label(
item_frame,
text=f"Q: {question}",
font=('Microsoft YaHei UI', 9),
fg='#888888',
bg='#2d2d2d',
anchor=tk.W,
padx=10,
pady=3
)
q_label.pack(fill=tk.X)
# 回答
a_label = tk.Label(
item_frame,
text=f"A: {answer}",
font=('Microsoft YaHei UI', 9, 'bold'),
fg='#81c784',
bg='#2d2d2d',
anchor=tk.W,
padx=10,
pady=3
)
a_label.pack(fill=tk.X)
def get_current_answers(self) -> Dict[str, Any]:
"""获取当前选项的答案"""
answers = {}
for opt_id, var in self._vars.items():
if isinstance(var, tk.StringVar):
answers[opt_id] = var.get()
elif isinstance(var, dict):
# checkbox 的情况
selected = [k for k, v in var.items() if v.get()]
answers[opt_id] = selected
return answers
def update_info_label(self, collected_count: int, total_count: int):
"""更新已收集信息提示"""
if total_count > 0:
self.info_label.config(text=f"已收集 {collected_count}/{total_count} 项信息")
else:
self.info_label.config(text="")
def set_submit_button_text(self, text: str):
"""设置确定按钮文本"""
self.submit_btn.config(text=text)
def _on_submit(self):
"""确定按钮点击"""
answers = self.get_current_answers()
self.on_submit(answers)
def _on_cancel(self):
"""取消按钮点击"""
self.on_cancel()
def show_loading(self, text: str = "加载中..."):
"""显示加载状态"""
# 禁用按钮
self.submit_btn.config(state=tk.DISABLED)
self.cancel_btn.config(state=tk.DISABLED)
# 更新信息标签显示加载状态
self._original_info_text = self.info_label.cget('text')
self.info_label.config(text=f"{text}", fg='#ffa726')
def hide_loading(self):
"""隐藏加载状态"""
# 恢复按钮
self.submit_btn.config(state=tk.NORMAL)
self.cancel_btn.config(state=tk.NORMAL)
# 恢复信息标签
if hasattr(self, '_original_info_text'):
self.info_label.config(text=self._original_info_text, fg='#81c784')
def show(self):
"""显示视图"""
self.frame.pack(fill=tk.BOTH, expand=True)
def hide(self):
"""隐藏视图"""
self.frame.pack_forget()
def reset(self):
"""重置视图"""
# 清除历史
self._history.clear()
for widget in self.history_frame.winfo_children():
widget.destroy()
# 清除选项
for widget in self._option_widgets:
widget.destroy()
self._option_widgets.clear()
self._vars.clear()
# 重置标签
self.question_label.config(text="")
self.info_label.config(text="")
self.submit_btn.config(text="确定 →")
def get_frame(self) -> tk.Frame:
"""获取主框架"""
return self.frame

View File

@@ -1,32 +1,230 @@
"""
历史记录视图组件
显示任务执行历史
显示任务执行历史,支持 Markdown 渲染、代码复用、失败重试、勾选删除
"""
import os
import re
import tkinter as tk
from tkinter import ttk, messagebox
from typing import Callable, List, Optional
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_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()
@@ -68,48 +266,104 @@ class HistoryView:
# 统计信息
stats = self.history.get_stats()
stats_text = f"{stats['total']} 条 | 成功 {stats['success']} | 失败 {stats['failed']} | 成功率 {stats['success_rate']:.0%}"
stats_label = tk.Label(
self.stats_label = tk.Label(
title_frame,
text=stats_text,
font=('Microsoft YaHei UI', 9),
fg='#888888',
bg='#1e1e1e'
)
stats_label.pack(side=tk.RIGHT)
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=" 任务列表 ",
text=" 任务列表",
font=('Microsoft YaHei UI', 10, 'bold'),
fg='#4fc3f7',
bg='#1e1e1e',
relief=tk.GROOVE
)
list_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 5))
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 = ('time', 'input', 'status', 'duration')
self.tree = ttk.Treeview(list_container, columns=columns, show='headings', height=15)
# 使用带勾选框的 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('input', text='任务描述')
self.tree.heading('description', text='任务描述')
self.tree.heading('status', text='状态')
self.tree.heading('duration', text='耗时')
self.tree.column('time', width=120, minwidth=100)
self.tree.column('input', width=250, minwidth=150)
self.tree.column('status', width=60, minwidth=50)
self.tree.column('duration', width=70, minwidth=50)
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)
@@ -130,13 +384,13 @@ class HistoryView:
bg='#1e1e1e',
relief=tk.GROOVE
)
detail_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(5, 0))
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 = tk.Text(
self.detail_text = MarkdownText(
detail_container,
wrap=tk.WORD,
font=('Microsoft YaHei UI', 10),
@@ -154,20 +408,17 @@ class HistoryView:
detail_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.detail_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# 配置详情文本样式
self.detail_text.tag_configure('title', font=('Microsoft YaHei UI', 11, 'bold'), foreground='#ffd54f')
self.detail_text.tag_configure('label', font=('Microsoft YaHei UI', 10, 'bold'), foreground='#4fc3f7')
self.detail_text.tag_configure('success', foreground='#81c784')
self.detail_text.tag_configure('error', foreground='#ef5350')
self.detail_text.tag_configure('code', font=('Consolas', 9), foreground='#ce93d8')
# 底部按钮
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(
btn_frame,
left_btn_frame,
text="📄 打开日志",
font=('Microsoft YaHei UI', 10),
bg='#424242',
@@ -180,23 +431,62 @@ class HistoryView:
state=tk.DISABLED,
command=self._open_log
)
self.open_log_btn.pack(side=tk.LEFT)
self.open_log_btn.pack(side=tk.LEFT, padx=(0, 10))
# 清空历史按钮
clear_btn = tk.Button(
btn_frame,
text="🗑️ 清空历史",
# 复用代码按钮
self.reuse_btn = tk.Button(
left_btn_frame,
text="🔄 复用此代码",
font=('Microsoft YaHei UI', 10),
bg='#d32f2f',
bg='#0e639c',
fg='white',
activebackground='#f44336',
activebackground='#1177bb',
activeforeground='white',
relief=tk.FLAT,
padx=15,
cursor='hand2',
command=self._clear_history
state=tk.DISABLED,
command=self._reuse_code
)
clear_btn.pack(side=tk.RIGHT)
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()
@@ -207,34 +497,116 @@ class HistoryView:
for item in self.tree.get_children():
self.tree.delete(item)
# 清空勾选状态
self.tree._checked.clear()
# 加载历史记录
records = self.history.get_all()
for record in records:
# 截断过长的输入
input_text = record.user_input
if len(input_text) > 30:
input_text = input_text[:30] + "..."
# 使用任务描述(如果有)或截断的用户输入
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"
# 提取时间(只显示时分秒)
time_parts = record.timestamp.split(' ')
time_str = time_parts[1] if len(time_parts) > 1 else record.timestamp
date_str = time_parts[0] if len(time_parts) > 0 else ""
display_time = f"{date_str}\n{time_str}"
self.tree.insert('', tk.END, iid=record.task_id, values=(
self.tree.insert_with_checkbox('', tk.END, iid=record.task_id, values=(
record.timestamp,
input_text,
description,
status,
duration
))
# 更新统计信息
self._update_stats()
# 更新删除按钮状态
self._update_delete_button(set())
# 显示空状态提示
if not records:
self._show_detail("暂无历史记录\n\n执行任务后,记录将显示在这里。")
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):
"""选择记录事件"""
@@ -248,77 +620,84 @@ class HistoryView:
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):
"""显示记录详情"""
self.detail_text.config(state=tk.NORMAL)
self.detail_text.delete(1.0, tk.END)
"""显示记录详情Markdown 格式)"""
# 构建 Markdown 内容
status_text = "✓ 成功" if record.success else "✗ 失败"
# 标题
self.detail_text.insert(tk.END, f"任务 ID: {record.task_id}\n", 'title')
self.detail_text.insert(tk.END, f"时间: {record.timestamp}\n\n")
md_content = f"""## 任务 ID: {record.task_id}
**时间:** {record.timestamp}
**状态:** {status_text}
**耗时:** {record.duration_ms}ms
---
### 用户输入
{record.user_input}
---
### 执行计划
{record.execution_plan}
---
### 生成的代码
```python
{record.code}
```
"""
# 用户输入
self.detail_text.insert(tk.END, "用户输入:\n", 'label')
self.detail_text.insert(tk.END, f"{record.user_input}\n\n")
# 执行状态
self.detail_text.insert(tk.END, "执行状态: ", 'label')
if record.success:
self.detail_text.insert(tk.END, "成功 ✓\n", 'success')
else:
self.detail_text.insert(tk.END, "失败 ✗\n", 'error')
self.detail_text.insert(tk.END, f"耗时: {record.duration_ms}ms\n\n")
# 执行计划
self.detail_text.insert(tk.END, "执行计划:\n", 'label')
plan_preview = record.execution_plan[:500] + "..." if len(record.execution_plan) > 500 else record.execution_plan
self.detail_text.insert(tk.END, f"{plan_preview}\n\n")
# 输出
if record.stdout:
self.detail_text.insert(tk.END, "输出:\n", 'label')
self.detail_text.insert(tk.END, f"{record.stdout}\n\n")
md_content += f"""---
### 输出
{record.stdout}
"""
# 错误
if record.stderr:
self.detail_text.insert(tk.END, "错误:\n", 'label')
self.detail_text.insert(tk.END, f"{record.stderr}\n", 'error')
md_content += f"""---
### 错误信息
{record.stderr}
"""
self.detail_text.config(state=tk.DISABLED)
self.detail_text.render_markdown(md_content)
def _show_detail(self, text: str):
"""显示详情文本"""
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, text)
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:
import os
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 _clear_history(self):
"""清空历史记录"""
result = messagebox.askyesno(
"确认清空",
"确定要清空所有历史记录吗?\n此操作不可恢复。",
icon='warning'
)
if result:
self.history.clear()
self._load_data()
self._show_detail("历史记录已清空")
self.open_log_btn.config(state=tk.DISABLED)
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):
"""显示视图"""
@@ -332,4 +711,3 @@ class HistoryView:
def get_frame(self) -> tk.Frame:
"""获取主框架"""
return self.frame

370
ui/settings_view.py Normal file
View File

@@ -0,0 +1,370 @@
"""
设置视图
用于配置 API 和模型参数
"""
import os
import tkinter as tk
from tkinter import ttk, messagebox
from pathlib import Path
from typing import Callable, Optional, Dict, Any
from dotenv import load_dotenv, set_key
class SettingsView:
"""
设置视图
功能:
- 配置 API URL 和 Key
- 配置各功能使用的模型
- 保存配置到 .env 文件
"""
# 预设模型列表
PRESET_MODELS = [
"Qwen/Qwen2.5-7B-Instruct",
"Qwen/Qwen2.5-14B-Instruct",
"Qwen/Qwen2.5-32B-Instruct",
"Qwen/Qwen2.5-72B-Instruct",
"Qwen/Qwen2.5-Coder-7B-Instruct",
"Qwen/Qwen2.5-Coder-32B-Instruct",
"deepseek-ai/DeepSeek-V3",
"deepseek-ai/DeepSeek-R1",
"Pro/deepseek-ai/DeepSeek-R1",
"THUDM/glm-4-9b-chat",
"01-ai/Yi-1.5-9B-Chat-16K",
]
def __init__(
self,
parent: tk.Widget,
env_path: Path,
on_save: Optional[Callable[[], None]] = None,
on_back: Optional[Callable[[], None]] = None
):
self.parent = parent
self.env_path = env_path
self.on_save = on_save
self.on_back = on_back
# 配置变量
self.vars: Dict[str, tk.StringVar] = {}
# 创建主框架
self.frame = tk.Frame(parent, bg='#1e1e1e')
self._create_ui()
self._load_config()
def _create_ui(self) -> None:
"""创建 UI"""
# 标题栏
header = tk.Frame(self.frame, bg='#2d2d2d')
header.pack(fill=tk.X, pady=(0, 20))
# 返回按钮
back_btn = tk.Button(
header,
text="← 返回",
font=('Microsoft YaHei UI', 10),
bg='#3d3d3d',
fg='#ffffff',
activebackground='#4d4d4d',
activeforeground='#ffffff',
relief=tk.FLAT,
cursor='hand2',
command=self._on_back_click
)
back_btn.pack(side=tk.LEFT, padx=10, pady=10)
# 标题
title = tk.Label(
header,
text="⚙️ 设置",
font=('Microsoft YaHei UI', 16, 'bold'),
bg='#2d2d2d',
fg='#ffffff'
)
title.pack(side=tk.LEFT, padx=20, pady=10)
# 滚动区域
canvas = tk.Canvas(self.frame, bg='#1e1e1e', highlightthickness=0)
scrollbar = ttk.Scrollbar(self.frame, orient=tk.VERTICAL, command=canvas.yview)
self.content_frame = tk.Frame(canvas, bg='#1e1e1e')
canvas.configure(yscrollcommand=scrollbar.set)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=20)
canvas_window = canvas.create_window((0, 0), window=self.content_frame, anchor=tk.NW)
def configure_scroll(event):
canvas.configure(scrollregion=canvas.bbox("all"))
canvas.itemconfig(canvas_window, width=event.width)
self.content_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.bind("<Configure>", configure_scroll)
# 鼠标滚轮支持
def on_mousewheel(event):
canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
canvas.bind_all("<MouseWheel>", on_mousewheel)
# API 配置区
self._create_section("API 配置", [
("LLM_API_URL", "API 地址", "https://api.siliconflow.cn/v1/chat/completions", False),
("LLM_API_KEY", "API Key", "", True),
])
# 模型配置区
self._create_model_section("模型配置", [
("INTENT_MODEL_NAME", "意图识别模型", "用于判断用户输入是对话还是执行任务(推荐小模型)"),
("CHAT_MODEL_NAME", "对话模型", "用于普通对话回复(推荐中等模型)"),
("GENERATION_MODEL_NAME", "代码生成模型", "用于生成执行计划和代码(推荐大模型)"),
])
# 保存按钮
btn_frame = tk.Frame(self.content_frame, bg='#1e1e1e')
btn_frame.pack(fill=tk.X, pady=30)
save_btn = tk.Button(
btn_frame,
text="💾 保存配置",
font=('Microsoft YaHei UI', 12, 'bold'),
bg='#0e639c',
fg='#ffffff',
activebackground='#1177bb',
activeforeground='#ffffff',
relief=tk.FLAT,
cursor='hand2',
padx=30,
pady=10,
command=self._save_config
)
save_btn.pack()
# 提示信息
tip = tk.Label(
self.content_frame,
text="提示:保存后配置立即生效,无需重启应用",
font=('Microsoft YaHei UI', 9),
bg='#1e1e1e',
fg='#808080'
)
tip.pack(pady=(0, 20))
def _create_section(self, title: str, fields: list) -> None:
"""创建配置区域"""
# 区域标题
section_title = tk.Label(
self.content_frame,
text=title,
font=('Microsoft YaHei UI', 12, 'bold'),
bg='#1e1e1e',
fg='#569cd6',
anchor=tk.W
)
section_title.pack(fill=tk.X, pady=(20, 10))
# 分隔线
separator = tk.Frame(self.content_frame, bg='#3d3d3d', height=1)
separator.pack(fill=tk.X, pady=(0, 15))
# 字段
for key, label, default, is_password in fields:
self._create_field(key, label, default, is_password)
def _create_field(self, key: str, label: str, default: str, is_password: bool = False) -> None:
"""创建输入字段"""
field_frame = tk.Frame(self.content_frame, bg='#1e1e1e')
field_frame.pack(fill=tk.X, pady=8)
# 标签
lbl = tk.Label(
field_frame,
text=label,
font=('Microsoft YaHei UI', 10),
bg='#1e1e1e',
fg='#cccccc',
width=12,
anchor=tk.W
)
lbl.pack(side=tk.LEFT)
# 输入框
var = tk.StringVar(value=default)
self.vars[key] = var
entry = tk.Entry(
field_frame,
textvariable=var,
font=('Consolas', 10),
bg='#3c3c3c',
fg='#ffffff',
insertbackground='#ffffff',
relief=tk.FLAT,
show='*' if is_password else ''
)
entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(10, 0), ipady=5)
# 显示/隐藏密码按钮
if is_password:
self._is_password_visible = False
def toggle_password():
self._is_password_visible = not self._is_password_visible
entry.config(show='' if self._is_password_visible else '*')
toggle_btn.config(text='🙈' if self._is_password_visible else '👁')
toggle_btn = tk.Button(
field_frame,
text='👁',
font=('Segoe UI Emoji', 10),
bg='#3c3c3c',
fg='#ffffff',
activebackground='#4c4c4c',
activeforeground='#ffffff',
relief=tk.FLAT,
cursor='hand2',
command=toggle_password
)
toggle_btn.pack(side=tk.LEFT, padx=(5, 0))
def _create_model_section(self, title: str, models: list) -> None:
"""创建模型配置区域"""
# 区域标题
section_title = tk.Label(
self.content_frame,
text=title,
font=('Microsoft YaHei UI', 12, 'bold'),
bg='#1e1e1e',
fg='#569cd6',
anchor=tk.W
)
section_title.pack(fill=tk.X, pady=(30, 10))
# 分隔线
separator = tk.Frame(self.content_frame, bg='#3d3d3d', height=1)
separator.pack(fill=tk.X, pady=(0, 15))
# 模型字段
for key, label, description in models:
self._create_model_field(key, label, description)
def _create_model_field(self, key: str, label: str, description: str) -> None:
"""创建模型选择字段"""
field_frame = tk.Frame(self.content_frame, bg='#1e1e1e')
field_frame.pack(fill=tk.X, pady=10)
# 标签和描述
label_frame = tk.Frame(field_frame, bg='#1e1e1e')
label_frame.pack(fill=tk.X)
lbl = tk.Label(
label_frame,
text=label,
font=('Microsoft YaHei UI', 10, 'bold'),
bg='#1e1e1e',
fg='#cccccc',
anchor=tk.W
)
lbl.pack(side=tk.LEFT)
desc = tk.Label(
label_frame,
text=f" ({description})",
font=('Microsoft YaHei UI', 9),
bg='#1e1e1e',
fg='#808080',
anchor=tk.W
)
desc.pack(side=tk.LEFT)
# 下拉框 + 输入框组合
input_frame = tk.Frame(field_frame, bg='#1e1e1e')
input_frame.pack(fill=tk.X, pady=(5, 0))
var = tk.StringVar()
self.vars[key] = var
# 使用 Combobox 支持下拉选择和自定义输入
combo = ttk.Combobox(
input_frame,
textvariable=var,
values=self.PRESET_MODELS,
font=('Consolas', 10),
state='normal' # 允许自定义输入
)
combo.pack(fill=tk.X, ipady=3)
# 设置样式
style = ttk.Style()
style.configure('TCombobox', fieldbackground='#3c3c3c', background='#3c3c3c')
def _load_config(self) -> None:
"""从 .env 文件加载配置"""
load_dotenv(self.env_path, override=True)
# 加载各配置项
config_keys = [
("LLM_API_URL", "https://api.siliconflow.cn/v1/chat/completions"),
("LLM_API_KEY", ""),
("INTENT_MODEL_NAME", "Qwen/Qwen2.5-7B-Instruct"),
("CHAT_MODEL_NAME", "Qwen/Qwen2.5-32B-Instruct"),
("GENERATION_MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct"),
]
for key, default in config_keys:
value = os.getenv(key, default)
if key in self.vars:
self.vars[key].set(value if value else default)
def _save_config(self) -> None:
"""保存配置到 .env 文件"""
try:
# 验证必填项
api_key = self.vars["LLM_API_KEY"].get().strip()
if not api_key or api_key == "your_api_key_here":
messagebox.showwarning("提示", "请填写有效的 API Key")
return
# 确保 .env 文件存在
if not self.env_path.exists():
self.env_path.touch()
# 保存各配置项
for key, var in self.vars.items():
value = var.get().strip()
set_key(str(self.env_path), key, value)
# 同时更新环境变量
os.environ[key] = value
messagebox.showinfo("成功", "配置已保存!")
if self.on_save:
self.on_save()
except Exception as e:
messagebox.showerror("错误", f"保存配置失败: {str(e)}")
def _on_back_click(self) -> None:
"""返回按钮点击"""
if self.on_back:
self.on_back()
def show(self) -> None:
"""显示视图"""
self._load_config() # 重新加载配置
self.frame.pack(fill=tk.BOTH, expand=True)
def hide(self) -> None:
"""隐藏视图"""
self.frame.pack_forget()
def get_frame(self) -> tk.Frame:
"""获取主框架"""
return self.frame