From 68f4f01cd77d52621072b6887bd58ffa63eac578 Mon Sep 17 00:00:00 2001 From: Mimikko-zeus Date: Wed, 7 Jan 2026 12:35:27 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E5=A2=9E=E5=BC=BA=E9=9C=80?= =?UTF-8?q?=E6=B1=82=E6=BE=84=E6=B8=85=E4=B8=8E=E4=BB=BB=E5=8A=A1=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD=20=E6=9B=B4=E6=96=B0=E4=BA=86=20.en?= =?UTF-8?q?v.example=EF=BC=8C=E6=96=B0=E5=A2=9E=E8=81=8A=E5=A4=A9=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E9=85=8D=E7=BD=AE=EF=BC=8C=E4=BB=A5=E6=8F=90=E5=8D=87?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E5=A4=84=E7=90=86=E8=83=BD=E5=8A=9B=E3=80=82?= =?UTF-8?q?=20=E5=A2=9E=E5=BC=BA=E4=BA=86=20README.md=EF=BC=8C=E5=8F=8D?= =?UTF-8?q?=E6=98=A0=E4=BA=86=E5=8C=85=E6=8B=AC=E9=9C=80=E6=B1=82=E6=BE=84?= =?UTF-8?q?=E6=B8=85=E3=80=81=E4=BB=A3=E7=A0=81=E5=A4=8D=E7=94=A8=E5=92=8C?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=87=8D=E8=AF=95=E5=9C=A8=E5=86=85=E7=9A=84?= =?UTF-8?q?=E6=96=B0=E5=8A=9F=E8=83=BD=E3=80=82=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E4=BA=86=20agent.py=EF=BC=8C=E4=BB=A5=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=9A=E6=A8=A1=E5=9E=8B=E4=BA=A4=E4=BA=92=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E4=B8=BA=E6=97=A0=E6=B3=95=E5=9C=A8=E6=9C=AC=E5=9C=B0=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E7=9A=84=E4=BB=BB=E5=8A=A1=E6=96=B0=E5=A2=9E=E4=BA=86?= =?UTF-8?q?=E5=BC=95=E5=AF=BC=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=E3=80=82?= =?UTF-8?q?=20=E6=94=B9=E8=BF=9B=E4=BA=86=20SandboxRunner=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=86=E4=BB=BB=E5=8A=A1=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E6=A0=A1=E9=AA=8C=EF=BC=8C=E5=B9=B6=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=E4=BA=86=E5=B7=A5=E4=BD=9C=E5=8C=BA=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扩展了 HistoryManager,支持任务摘要生成以及记录的批量删除。 优化了 chat_view.py 和 history_view.py 中的 UI 组件,提升用户体验,包括 Markdown 渲染和任务管理选项。 --- .env.example | 3 + README.md | 44 +- app/agent.py | 696 +++++++++++++++++++++++++++++- executor/sandbox_runner.py | 102 ++++- history/manager.py | 96 ++++- intent/labels.py | 3 +- llm/prompts.py | 313 +++++++++++++- tests/test_intent_classifier.py | 6 +- ui/chat_view.py | 386 +++++++++++++++-- ui/clarify_view.py | 725 ++++++++++++++++++++++++++++++++ ui/history_view.py | 574 ++++++++++++++++++++----- ui/settings_view.py | 370 ++++++++++++++++ 12 files changed, 3158 insertions(+), 160 deletions(-) create mode 100644 ui/clarify_view.py create mode 100644 ui/settings_view.py diff --git a/.env.example b/.env.example index 9ab0822..6527d9a 100644 --- a/.env.example +++ b/.env.example @@ -15,5 +15,8 @@ LLM_API_KEY=your_api_key_here # Intent recognition model (small model recommended for speed) INTENT_MODEL_NAME=Qwen/Qwen2.5-7B-Instruct +# Chat model (medium model recommended for conversation) +CHAT_MODEL_NAME=Qwen/Qwen2.5-32B-Instruct + # Code generation model (large model recommended for quality) GENERATION_MODEL_NAME=Qwen/Qwen2.5-72B-Instruct diff --git a/README.md b/README.md index 89e6f75..0588614 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,16 @@ A Windows-based local AI assistant that can understand natural language commands ## Features - **Intent Recognition**: Automatically distinguishes between chat conversations and execution tasks -- **Code Generation**: Generates Python code based on user requirements +- **Requirement Clarification**: Interactive Q&A to clarify vague requirements before code generation +- **Code Generation**: Generates Python code based on structured requirements - **Safety Checks**: Multi-layer security with static analysis and LLM review - **Sandbox Execution**: Runs generated code in an isolated environment -- **Task History**: Records all executed tasks for review +- **Task History**: Records all executed tasks with selective deletion - **Streaming Responses**: Real-time display of LLM responses +- **Settings UI**: Easy configuration of API and models +- **Code Reuse**: Automatically finds and reuses successful code for similar tasks +- **Auto Retry**: AI-powered code fixing for failed tasks +- **Multi-Model Support**: Different models for intent recognition, chat, and code generation ## Project Structure @@ -32,8 +37,10 @@ LocalAgent/ │ └── manager.py # History manager ├── ui/ # User interface │ ├── chat_view.py # Chat interface +│ ├── clarify_view.py # Requirement clarification view │ ├── task_guide_view.py # Task confirmation view -│ └── history_view.py # History view +│ ├── history_view.py # History view with Markdown support +│ └── settings_view.py # Settings configuration view ├── tests/ # Unit tests ├── workspace/ # Working directory (auto-created) │ ├── input/ # Input files @@ -85,7 +92,7 @@ LocalAgent/ ## Configuration -Edit `.env` file with your settings: +Edit `.env` file with your settings (or use the Settings UI in the app): ```env # SiliconFlow API Configuration @@ -93,7 +100,13 @@ LLM_API_URL=https://api.siliconflow.cn/v1/chat/completions LLM_API_KEY=your_api_key_here # Model Configuration +# Intent recognition model (small model recommended for speed) INTENT_MODEL_NAME=Qwen/Qwen2.5-7B-Instruct + +# Chat model (medium model recommended for conversation) +CHAT_MODEL_NAME=Qwen/Qwen2.5-32B-Instruct + +# Code generation model (large model recommended for quality) GENERATION_MODEL_NAME=Qwen/Qwen2.5-72B-Instruct ``` @@ -113,9 +126,26 @@ Describe file processing tasks: ### Workflow 1. Place input files in `workspace/input/` 2. Describe your task in the chat -3. Review the execution plan and generated code -4. Click "Execute" to run -5. Find results in `workspace/output/` +3. **If the requirement is vague**, the system will ask clarifying questions: + - Radio buttons for single-choice options (e.g., watermark type) + - Checkboxes for multi-choice options (e.g., watermark positions) + - Input fields for custom values (e.g., watermark text, opacity) +4. Review the execution plan and generated code +5. Click "Execute" to run +6. Find results in `workspace/output/` + +### Requirement Clarification Example + +When you input a vague request like "Add watermark to images", the system will: + +1. **Check completeness** - Detect missing information +2. **Ask questions** - Present interactive options: + - Watermark type: Text / Image (radio) + - Position: Top-left / Top-right / Bottom-left / Bottom-right / Center (checkbox) + - Text content: [input field] + - Opacity: [input field with default 50%] +3. **Structure requirement** - Convert answers into a complete specification +4. **Generate code** - Create code based on the structured requirement ## Security diff --git a/app/agent.py b/app/agent.py index 43ab0c6..e86af58 100644 --- a/app/agent.py +++ b/app/agent.py @@ -7,23 +7,30 @@ import os import tkinter as tk from tkinter import messagebox from pathlib import Path -from typing import Optional, Dict, Any, Tuple +from typing import Optional, Dict, Any, Tuple, List import threading import queue from llm.client import get_client, LLMClientError from llm.prompts import ( EXECUTION_PLAN_SYSTEM, EXECUTION_PLAN_USER, - CODE_GENERATION_SYSTEM, CODE_GENERATION_USER + CODE_GENERATION_SYSTEM, CODE_GENERATION_USER, + TASK_SUMMARY_SYSTEM, TASK_SUMMARY_USER, + CODE_FIX_SYSTEM, CODE_FIX_USER, + REQUIREMENT_CHECK_SYSTEM, REQUIREMENT_CHECK_USER, + REQUIREMENT_CLARIFY_SYSTEM, REQUIREMENT_CLARIFY_USER, + REQUIREMENT_STRUCTURE_SYSTEM, REQUIREMENT_STRUCTURE_USER ) from intent.classifier import classify_intent, IntentResult -from intent.labels import CHAT, EXECUTION +from intent.labels import CHAT, EXECUTION, GUIDANCE from safety.rule_checker import check_code_safety from safety.llm_reviewer import review_code_safety, LLMReviewResult from executor.sandbox_runner import SandboxRunner, ExecutionResult from ui.chat_view import ChatView from ui.task_guide_view import TaskGuideView from ui.history_view import HistoryView +from ui.settings_view import SettingsView +from ui.clarify_view import ClarifyView from history.manager import get_history_manager, HistoryManager @@ -55,6 +62,15 @@ class LocalAgentApp: self.chat_view: Optional[ChatView] = None self.task_view: Optional[TaskGuideView] = None self.history_view: Optional[HistoryView] = None + self.settings_view: Optional[SettingsView] = None + self.clarify_view: Optional[ClarifyView] = None + + # 需求澄清状态 + self._clarify_state: Optional[Dict[str, Any]] = None + + # 对话上下文(用于多轮对话) + self._chat_context: List[Dict[str, str]] = [] + self._max_context_length: int = 10 # 最多保留的对话轮数 # 初始化 UI self._init_ui() @@ -63,7 +79,8 @@ class LocalAgentApp: """初始化 UI""" self.root = tk.Tk() self.root.title("LocalAgent - 本地 AI 助手") - self.root.geometry("800x700") + self.root.geometry("1100x750") + self.root.minsize(900, 600) self.root.configure(bg='#1e1e1e') # 设置窗口图标(如果有的话) @@ -80,9 +97,13 @@ class LocalAgentApp: self.chat_view = ChatView( self.main_container, self._on_user_input, - on_show_history=self._show_history + on_show_history=self._show_history, + on_show_settings=self._show_settings ) + # 设置清空上下文的回调 + self.chat_view.set_clear_context_callback(self._clear_chat_context) + # 定期检查后台任务结果 self._check_queue() @@ -136,6 +157,9 @@ class LocalAgentApp: if intent_result.label == CHAT: # 对话模式 self._handle_chat(user_input, intent_result) + elif intent_result.label == GUIDANCE: + # 操作指导模式 + self._handle_guidance(user_input, intent_result) else: # 执行模式 self._handle_execution(user_input, intent_result) @@ -147,17 +171,24 @@ class LocalAgentApp: 'system' ) + # 添加用户消息到上下文 + self._chat_context.append({"role": "user", "content": user_input}) + # 开始流式消息 self.chat_view.start_stream_message('assistant') # 在后台线程调用 LLM(流式) def do_chat_stream(): client = get_client() - model = os.getenv("GENERATION_MODEL_NAME") + # 使用专门的对话模型,如果未配置则使用代码生成模型 + model = os.getenv("CHAT_MODEL_NAME") or os.getenv("GENERATION_MODEL_NAME") + + # 构建带上下文的消息列表 + messages = self._build_chat_messages() full_response = [] for chunk in client.chat_stream( - messages=[{"role": "user", "content": user_input}], + messages=messages, model=model, temperature=0.7, max_tokens=2048, @@ -184,16 +215,94 @@ class LocalAgentApp: if error: self.chat_view.add_message(f"对话失败: {str(error)}", 'error') + elif response: + # 保存助手回复到上下文 + self._chat_context.append({"role": "assistant", "content": response}) + # 限制上下文长度 + self._trim_chat_context() self.chat_view.set_input_enabled(True) + def _build_chat_messages(self) -> List[Dict[str, str]]: + """构建带上下文的消息列表""" + system_prompt = """你是一个智能助手,可以回答各种问题。请用中文回答。 + +如果用户的问题涉及之前的对话内容,请结合上下文进行回答。""" + + messages = [{"role": "system", "content": system_prompt}] + messages.extend(self._chat_context) + return messages + + def _trim_chat_context(self) -> None: + """限制对话上下文长度""" + # 每轮对话包含 user 和 assistant 两条消息 + max_messages = self._max_context_length * 2 + if len(self._chat_context) > max_messages: + # 保留最近的消息 + self._chat_context = self._chat_context[-max_messages:] + + def _clear_chat_context(self) -> None: + """清空对话上下文""" + self._chat_context = [] + + def _handle_guidance(self, user_input: str, intent_result: IntentResult) -> None: + """处理操作指导任务(无法通过本地代码完成的任务)""" + self.chat_view.add_message( + f"识别为操作指导 (原因: {intent_result.reason})\n该任务无法通过本地代码完成,将为您提供操作指导。", + 'system' + ) + + # 添加用户消息到上下文 + self._chat_context.append({"role": "user", "content": user_input}) + + # 开始流式消息 + self.chat_view.start_stream_message('assistant') + + # 在后台线程调用 LLM(流式) + def do_guidance_stream(): + client = get_client() + model = os.getenv("CHAT_MODEL_NAME") or os.getenv("GENERATION_MODEL_NAME") + + # 构建专门的操作指导 Prompt + system_prompt = """你是一个操作指导助手。用户询问的是一个无法通过本地Python代码完成的任务(如软件设置、系统配置、GUI操作等)。 + +请提供清晰、详细的操作步骤指导: +1. 使用编号列表,步骤清晰 +2. 如果有多种方法,列出最常用的1-2种 +3. 如果涉及不同操作系统/软件版本,说明适用范围 +4. 可以适当配合说明截图位置或界面元素名称 +5. 如果操作有风险,给出提醒 + +用中文回答。""" + + # 构建带上下文的消息列表 + messages = [{"role": "system", "content": system_prompt}] + messages.extend(self._chat_context) + + full_response = [] + for chunk in client.chat_stream( + messages=messages, + model=model, + temperature=0.7, + max_tokens=2048, + timeout=300 + ): + full_response.append(chunk) + self.result_queue.put((self._on_chat_chunk, (chunk,))) + + return ''.join(full_response) + + self._run_in_thread( + do_guidance_stream, + self._on_chat_complete + ) + def _handle_execution(self, user_input: str, intent_result: IntentResult): """处理执行任务""" self.chat_view.add_message( f"识别为执行任务 (置信度: {intent_result.confidence:.0%})\n原因: {intent_result.reason}", 'system' ) - self.chat_view.show_loading("正在生成执行计划") # 保存用户输入和意图结果 self.current_task = { @@ -201,10 +310,36 @@ class LocalAgentApp: 'intent_result': intent_result } - # 在后台线程生成执行计划 + # 先查找是否有相似的成功任务 + similar_record = self.history.find_similar_success(user_input) + if similar_record: + # 询问用户是否复用 + task_desc = similar_record.task_summary or similar_record.user_input[:50] + msg = ( + f"发现相似的成功任务:\n\n" + f"任务: {task_desc}\n" + f"时间: {similar_record.timestamp}\n\n" + f"是否直接复用该任务的代码?\n" + f"(选择[否]将生成新代码)" + ) + result = messagebox.askyesno("发现相似任务", msg, icon='question') + if result: + # 复用代码 + self.current_task['execution_plan'] = similar_record.execution_plan + self.current_task['code'] = similar_record.code + self.current_task['task_summary'] = similar_record.task_summary + self.current_task['is_reuse'] = True + + self.chat_view.add_message("复用历史成功代码,请确认执行", 'system') + self._show_task_guide() + return + + self.chat_view.show_loading("正在分析需求完整性") + + # 检查需求是否完整 self._run_in_thread( - self._generate_execution_plan, - self._on_plan_generated, + self._check_requirement_completeness, + self._on_requirement_checked, user_input ) @@ -224,10 +359,374 @@ class LocalAgentApp: self._run_in_thread( self._generate_code, self._on_code_generated, - self.current_task['user_input'], + self.current_task.get('structured_requirement') or self.current_task['user_input'], plan ) + def _check_requirement_completeness(self, user_input: str) -> Dict[str, Any]: + """检查需求是否完整""" + import json + + client = get_client() + model = os.getenv("CHAT_MODEL_NAME") or os.getenv("GENERATION_MODEL_NAME") + + response = client.chat( + messages=[ + {"role": "system", "content": REQUIREMENT_CHECK_SYSTEM}, + {"role": "user", "content": REQUIREMENT_CHECK_USER.format(user_input=user_input)} + ], + model=model, + temperature=0.3, + max_tokens=500, + timeout=60 + ) + + # 解析 JSON 响应 + try: + # 尝试提取 JSON + json_match = response + if '```' in response: + import re + match = re.search(r'```(?:json)?\s*(.*?)\s*```', response, re.DOTALL) + if match: + json_match = match.group(1) + + result = json.loads(json_match) + return result + except json.JSONDecodeError: + # 解析失败,默认认为需求完整 + return { + "is_complete": True, + "confidence": 0.5, + "reason": "无法解析完整性检查结果", + "suggested_defaults": {} + } + + def _on_requirement_checked(self, result: Optional[Dict], error: Optional[Exception]): + """需求完整性检查完成回调""" + if error: + # 检查失败,继续正常流程 + self.chat_view.hide_loading() + self.chat_view.add_message(f"需求分析失败,将直接生成代码: {str(error)}", 'system') + self._continue_to_code_generation() + return + + is_complete = result.get('is_complete', True) + confidence = result.get('confidence', 1.0) + + # 如果需求完整或置信度较高,直接继续 + if is_complete and confidence >= 0.7: + self.chat_view.hide_loading() + # 保存建议的默认值 + self.current_task['suggested_defaults'] = result.get('suggested_defaults', {}) + self._continue_to_code_generation() + else: + # 需求不完整,启动澄清流程 + self.chat_view.hide_loading() + self.chat_view.add_message( + f"需求信息不完整 (原因: {result.get('reason', '缺少关键信息')})\n正在启动需求澄清...", + 'system' + ) + self._start_clarification() + + def _continue_to_code_generation(self): + """继续代码生成流程""" + self.chat_view.show_loading("正在生成任务摘要") + + # 在后台线程生成任务摘要 + self._run_in_thread( + self._generate_task_summary, + self._on_summary_generated, + self.current_task.get('structured_requirement') or self.current_task['user_input'] + ) + + def _start_clarification(self): + """启动需求澄清流程""" + # 初始化澄清状态 + self._clarify_state = { + 'original_input': self.current_task['user_input'], + 'collected_info': {}, + 'history': [], + 'current_question': None + } + + # 重置已显示的历史计数 + self._displayed_history_count = 0 + + self.chat_view.show_loading("正在生成澄清问题") + + # 获取第一个澄清问题 + self._run_in_thread( + self._get_clarify_question, + self._on_clarify_question_received, + self.current_task['user_input'], + {}, + "" + ) + + def _get_clarify_question(self, user_input: str, collected_info: Dict, user_answer: str) -> Dict[str, Any]: + """获取澄清问题""" + import json + + client = get_client() + model = os.getenv("CHAT_MODEL_NAME") or os.getenv("GENERATION_MODEL_NAME") + + # 格式化已收集的信息 + collected_str = json.dumps(collected_info, ensure_ascii=False, indent=2) if collected_info else "{}" + + response = client.chat( + messages=[ + {"role": "system", "content": REQUIREMENT_CLARIFY_SYSTEM}, + {"role": "user", "content": REQUIREMENT_CLARIFY_USER.format( + user_input=user_input, + collected_info=collected_str, + user_answer=user_answer or "(首次询问)" + )} + ], + model=model, + temperature=0.3, + max_tokens=1000, + timeout=60 + ) + + # 解析 JSON 响应 + try: + json_match = response + if '```' in response: + import re + match = re.search(r'```(?:json)?\s*(.*?)\s*```', response, re.DOTALL) + if match: + json_match = match.group(1) + + result = json.loads(json_match) + return result + except json.JSONDecodeError: + # 解析失败,认为不需要继续澄清 + return { + "need_clarify": False, + "question": "", + "options": [], + "collected_info": collected_info, + "missing_info": [] + } + + def _on_clarify_question_received(self, result: Optional[Dict], error: Optional[Exception]): + """收到澄清问题回调""" + # 隐藏澄清视图的加载状态(如果有) + if self.clarify_view: + self.clarify_view.hide_loading() + + if error: + # 出错时切换回聊天界面 + if self.clarify_view: + self.clarify_view.hide() + self.clarify_view = None + self.chat_view.get_frame().pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + self.chat_view.add_message(f"获取澄清问题失败: {str(error)}", 'error') + self._continue_to_code_generation() + return + + need_clarify = result.get('need_clarify', False) + + if not need_clarify: + # 不需要继续澄清,隐藏澄清视图,进行需求结构化 + if self.clarify_view: + self.clarify_view.hide() + self.clarify_view = None + self._clarify_state['collected_info'].update(result.get('collected_info', {})) + self._structure_requirement() + else: + # 继续显示/更新澄清视图 + self._show_clarify_view(result) + + def _show_clarify_view(self, clarify_data: Dict): + """显示或更新需求澄清视图""" + # 如果澄清视图不存在,创建新的 + if not self.clarify_view: + # 隐藏聊天视图 + self.chat_view.get_frame().pack_forget() + + # 创建澄清视图 + self.clarify_view = ClarifyView( + self.main_container, + on_submit=self._on_clarify_submit, + on_cancel=self._on_clarify_cancel + ) + self.clarify_view.show() + + # 添加上一轮的历史记录(如果有新的) + history = self._clarify_state.get('history', []) + displayed_count = getattr(self, '_displayed_history_count', 0) + + for item in history[displayed_count:]: + self.clarify_view.add_history_item(item['question'], item['answer']) + + self._displayed_history_count = len(history) + + # 设置新问题和选项 + question = clarify_data.get('question', '请提供更多信息') + options = clarify_data.get('options', []) + + self.clarify_view.set_question(question, options) + + # 更新已收集信息提示 + collected = self._clarify_state.get('collected_info', {}) + missing = clarify_data.get('missing_info', []) + self.clarify_view.update_info_label(len(collected), len(collected) + len(missing)) + + # 保存当前问题 + self._clarify_state['current_question'] = question + self._clarify_state['current_options'] = options + + def _on_clarify_submit(self, answers: Dict[str, Any]): + """澄清问题提交回调""" + # 格式化答案为字符串 + answer_parts = [] + for key, value in answers.items(): + if isinstance(value, list): + answer_parts.append(f"{key}: {', '.join(value)}") + else: + answer_parts.append(f"{key}: {value}") + answer_str = "; ".join(answer_parts) + + # 保存到历史 + self._clarify_state['history'].append({ + 'question': self._clarify_state.get('current_question', ''), + 'answer': answer_str + }) + + # 更新已收集的信息 + self._clarify_state['collected_info'].update(answers) + + # 在澄清视图中显示加载状态(不切换回聊天界面) + if self.clarify_view: + self.clarify_view.show_loading("正在分析您的回答...") + + # 继续获取下一个问题 + self._run_in_thread( + self._get_clarify_question, + self._on_clarify_question_received, + self._clarify_state['original_input'], + self._clarify_state['collected_info'], + answer_str + ) + + def _on_clarify_cancel(self): + """取消澄清""" + if self.clarify_view: + self.clarify_view.hide() + self.clarify_view = None + + self._clarify_state = None + self._displayed_history_count = 0 + self.current_task = None + + self.chat_view.get_frame().pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + self.chat_view.set_input_enabled(True) + self.chat_view.add_message("已取消需求澄清", 'system') + + def _structure_requirement(self): + """将澄清后的需求结构化""" + self.chat_view.get_frame().pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + self.chat_view.show_loading("正在整理需求") + + self._run_in_thread( + self._do_structure_requirement, + self._on_requirement_structured + ) + + def _do_structure_requirement(self) -> str: + """执行需求结构化""" + import json + + client = get_client() + model = os.getenv("CHAT_MODEL_NAME") or os.getenv("GENERATION_MODEL_NAME") + + collected_str = json.dumps( + self._clarify_state['collected_info'], + ensure_ascii=False, + indent=2 + ) + + response = client.chat_stream_collect( + messages=[ + {"role": "system", "content": REQUIREMENT_STRUCTURE_SYSTEM}, + {"role": "user", "content": REQUIREMENT_STRUCTURE_USER.format( + user_input=self._clarify_state['original_input'], + collected_info=collected_str + )} + ], + model=model, + temperature=0.3, + max_tokens=1500, + timeout=120 + ) + + return response + + def _on_requirement_structured(self, result: Optional[str], error: Optional[Exception]): + """需求结构化完成回调""" + if error: + self.chat_view.hide_loading() + self.chat_view.add_message(f"需求整理失败: {str(error)}", 'error') + # 使用原始输入继续 + self._continue_to_code_generation() + return + + # 保存结构化的需求 + self.current_task['structured_requirement'] = result + self.current_task['collected_info'] = self._clarify_state['collected_info'] + + # 清理澄清状态 + self._clarify_state = None + + self.chat_view.hide_loading() + self.chat_view.add_message("需求已明确,开始生成代码", 'system') + + # 继续代码生成流程 + self._continue_to_code_generation() + + def _generate_task_summary(self, user_input: str) -> str: + """生成任务摘要(使用小模型)""" + client = get_client() + # 使用意图识别模型(小模型)生成摘要 + model = os.getenv("INTENT_MODEL_NAME") or os.getenv("GENERATION_MODEL_NAME") + + response = client.chat( + messages=[ + {"role": "system", "content": TASK_SUMMARY_SYSTEM}, + {"role": "user", "content": TASK_SUMMARY_USER.format(user_input=user_input)} + ], + model=model, + temperature=0.3, + max_tokens=50, + timeout=30 + ) + + # 清理响应(去除引号、换行等) + summary = response.strip().strip('"\'').strip() + # 限制长度 + if len(summary) > 20: + summary = summary[:20] + + return summary + + def _on_summary_generated(self, summary: Optional[str], error: Optional[Exception]): + """任务摘要生成完成回调""" + if error: + # 摘要生成失败不影响主流程,使用默认值 + summary = self.current_task['user_input'][:15] + "..." + + self.current_task['task_summary'] = summary + self.chat_view.update_loading_text("正在生成执行计划") + + # 继续生成执行计划 + self._run_in_thread( + self._generate_execution_plan, + self._on_plan_generated, + self.current_task['user_input'] + ) + def _on_code_generated(self, result: tuple, error: Optional[Exception]): """代码生成完成回调""" if error: @@ -298,6 +797,9 @@ class LocalAgentApp: self.current_task = None return + # 代码生成完成,清空 input 和 output 目录 + self.runner.clear_workspace(clear_input=True, clear_output=True) + self.chat_view.add_message("安全检查通过,请确认执行", 'system') # 显示任务引导视图 @@ -439,7 +941,8 @@ class LocalAgentApp: duration_ms=result.duration_ms, stdout=result.stdout, stderr=result.stderr, - log_path=result.log_path + log_path=result.log_path, + task_summary=self.current_task.get('task_summary', '') ) self._show_execution_result(result) @@ -508,7 +1011,9 @@ class LocalAgentApp: self.history_view = HistoryView( self.main_container, self.history, - on_back=self._hide_history + on_back=self._hide_history, + on_reuse_code=self._on_reuse_code, + on_retry_task=self._on_retry_task ) self.history_view.show() @@ -520,6 +1025,169 @@ class LocalAgentApp: self.chat_view.get_frame().pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + def _on_reuse_code(self, record): + """复用历史记录中的代码""" + from history.manager import TaskRecord + + # 隐藏历史视图 + self._hide_history() + + # 设置当前任务 + self.current_task = { + 'user_input': record.user_input, + 'intent_result': IntentResult( + label=record.intent_label, + confidence=record.intent_confidence, + reason="复用历史任务" + ), + 'execution_plan': record.execution_plan, + 'code': record.code, + 'task_summary': record.task_summary, + 'is_reuse': True + } + + self.chat_view.add_message(f"复用历史任务: {record.task_summary or record.user_input[:30]}", 'system') + self.chat_view.add_message("已加载历史代码,请确认执行", 'system') + + # 直接显示任务引导视图(跳过代码生成) + self._show_task_guide() + + def _on_retry_task(self, record): + """重试失败的任务(AI 修复)""" + from history.manager import TaskRecord + + # 隐藏历史视图 + self._hide_history() + + self.chat_view.add_message(f"重试任务: {record.task_summary or record.user_input[:30]}", 'system') + self.chat_view.show_loading("正在分析错误并修复代码") + self.chat_view.set_input_enabled(False) + + # 保存任务信息 + self.current_task = { + 'user_input': record.user_input, + 'intent_result': IntentResult( + label=record.intent_label, + confidence=record.intent_confidence, + reason="重试失败任务" + ), + 'execution_plan': record.execution_plan, + 'original_code': record.code, + 'original_stdout': record.stdout, + 'original_stderr': record.stderr, + 'task_summary': record.task_summary, + 'is_retry': True + } + + # 在后台线程修复代码 + self._run_in_thread( + self._fix_code, + self._on_code_fixed, + record + ) + + def _fix_code(self, record) -> tuple: + """修复失败的代码""" + client = get_client() + model = os.getenv("GENERATION_MODEL_NAME") + + response = client.chat_stream_collect( + messages=[ + {"role": "system", "content": CODE_FIX_SYSTEM}, + {"role": "user", "content": CODE_FIX_USER.format( + user_input=record.user_input, + execution_plan=record.execution_plan, + code=record.code, + stdout=record.stdout or "(无输出)", + stderr=record.stderr or "(无错误信息)" + )} + ], + model=model, + temperature=0.2, + max_tokens=4096, + timeout=300 + ) + + try: + code = self._extract_code(response) + return (code, None) + except ValueError as e: + return (None, e) + + def _on_code_fixed(self, result: tuple, error: Optional[Exception]): + """代码修复完成回调""" + if error: + self.chat_view.hide_loading() + self.chat_view.add_message(f"代码修复失败: {str(error)}", 'error') + self.chat_view.set_input_enabled(True) + self.current_task = None + return + + code, extract_error = result + if extract_error: + self.chat_view.hide_loading() + self.chat_view.add_message(f"代码提取失败: {str(extract_error)}", 'error') + self.chat_view.set_input_enabled(True) + self.current_task = None + return + + self.current_task['code'] = code + self.chat_view.update_loading_text("正在进行安全检查") + + # 硬规则检查 + rule_result = check_code_safety(code) + if not rule_result.passed: + self.chat_view.hide_loading() + violations = "\n".join(f" • {v}" for v in rule_result.violations) + self.chat_view.add_message( + f"修复后的代码安全检查未通过:\n{violations}", + 'error' + ) + self.chat_view.set_input_enabled(True) + self.current_task = None + return + + self.current_task['warnings'] = rule_result.warnings + + # LLM 安全审查 + self._run_in_thread( + lambda: review_code_safety( + self.current_task['user_input'], + self.current_task['execution_plan'], + code, + rule_result.warnings + ), + self._on_safety_reviewed + ) + + def _show_settings(self): + """显示设置视图""" + # 隐藏聊天视图 + self.chat_view.get_frame().pack_forget() + + # 创建设置视图 + self.settings_view = SettingsView( + self.main_container, + env_path=self.project_root / ".env", + on_save=self._on_settings_saved, + on_back=self._hide_settings + ) + self.settings_view.show() + + def _hide_settings(self): + """隐藏设置视图,返回聊天""" + if self.settings_view: + self.settings_view.hide() + self.settings_view = None + + self.chat_view.get_frame().pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + def _on_settings_saved(self): + """设置保存后的回调""" + # 配置已通过 set_key 保存并更新了环境变量 + # 可以在这里添加额外的处理逻辑 + pass + def run(self): """运行应用""" self.root.mainloop() diff --git a/executor/sandbox_runner.py b/executor/sandbox_runner.py index 89b0110..980b154 100644 --- a/executor/sandbox_runner.py +++ b/executor/sandbox_runner.py @@ -119,8 +119,15 @@ class SandboxRunner: duration_ms=duration_ms ) + # 判断是否成功:return code 为 0 且没有明显的失败迹象 + success = self._check_execution_success( + result.returncode, + result.stdout, + result.stderr + ) + return ExecutionResult( - success=result.returncode == 0, + success=success, task_id=task_id, stdout=result.stdout, stderr=result.stderr, @@ -187,6 +194,99 @@ class SandboxRunner: short_uuid = uuid.uuid4().hex[:6] return f"{timestamp}_{short_uuid}" + def clear_workspace(self, clear_input: bool = True, clear_output: bool = True) -> None: + """ + 清空工作目录 + + Args: + clear_input: 是否清空 input 目录 + clear_output: 是否清空 output 目录 + """ + if clear_input: + self._clear_directory(self.input_dir) + if clear_output: + self._clear_directory(self.output_dir) + + def _clear_directory(self, directory: Path) -> None: + """ + 清空目录中的所有文件和子目录 + + Args: + directory: 要清空的目录路径 + """ + if not directory.exists(): + return + + import shutil + + for item in directory.iterdir(): + try: + if item.is_file(): + item.unlink() + elif item.is_dir(): + shutil.rmtree(item) + except Exception as e: + # 忽略删除失败的文件(可能被占用) + print(f"Warning: Failed to delete {item}: {e}") + + def _check_execution_success(self, return_code: int, stdout: str, stderr: str) -> bool: + """ + 检查执行是否成功 + + 判断逻辑: + 1. return code 必须为 0 + 2. 检查输出中是否有失败迹象 + 3. 如果有成功和失败的统计,根据失败数量判断 + """ + # return code 不为 0 直接判定失败 + if return_code != 0: + return False + + # 检查 stderr 是否有内容(通常表示有错误) + if stderr and stderr.strip(): + # 如果 stderr 有实质内容,可能是失败 + # 但有些程序会把警告也输出到 stderr,所以不直接判定失败 + pass + + # 检查 stdout 中的失败迹象 + output = stdout.lower() if stdout else "" + + # 查找失败统计模式,如 "失败 27 个" 或 "failed: 27" + import re + + # 中文模式:成功 X 个, 失败 Y 个 + pattern_cn = r'成功\s*(\d+)\s*个.*失败\s*(\d+)\s*个' + match = re.search(pattern_cn, stdout if stdout else "") + if match: + success_count = int(match.group(1)) + fail_count = int(match.group(2)) + # 如果有失败的,判定为失败 + if fail_count > 0: + return False + return True + + # 英文模式:success: X, failed: Y + pattern_en = r'success[:\s]+(\d+).*fail(?:ed)?[:\s]+(\d+)' + match = re.search(pattern_en, output) + if match: + success_count = int(match.group(1)) + fail_count = int(match.group(2)) + if fail_count > 0: + return False + return True + + # 检查是否有明显的失败关键词 + failure_keywords = ['失败', 'error', 'exception', 'traceback', 'failed'] + for keyword in failure_keywords: + if keyword in output: + # 如果包含失败关键词,进一步检查是否是统计信息 + # 如果是 "失败 0 个" 这种,不算失败 + if '失败 0' in stdout or '失败: 0' in stdout or 'failed: 0' in output or 'failed 0' in output: + continue + return False + + return True + def _get_safe_env(self) -> dict: """获取安全的环境变量(移除网络代理等)""" safe_env = os.environ.copy() diff --git a/history/manager.py b/history/manager.py index 373ad1d..d21b66f 100644 --- a/history/manager.py +++ b/history/manager.py @@ -25,6 +25,7 @@ class TaskRecord: stdout: str stderr: str log_path: str + task_summary: str = "" # 任务摘要(由小模型生成) class HistoryManager: @@ -83,7 +84,8 @@ class HistoryManager: duration_ms: int, stdout: str = "", stderr: str = "", - log_path: str = "" + log_path: str = "", + task_summary: str = "" ) -> TaskRecord: """ 添加一条任务记录 @@ -100,6 +102,7 @@ class HistoryManager: stdout: 标准输出 stderr: 标准错误 log_path: 日志文件路径 + task_summary: 任务摘要 Returns: TaskRecord: 创建的记录 @@ -116,7 +119,8 @@ class HistoryManager: duration_ms=duration_ms, stdout=stdout, stderr=stderr, - log_path=log_path + log_path=log_path, + task_summary=task_summary ) # 添加到列表开头(最新的在前) @@ -146,6 +150,43 @@ class HistoryManager: return record return None + def delete_by_id(self, task_id: str) -> bool: + """ + 根据任务 ID 删除记录 + + Args: + task_id: 任务 ID + + Returns: + 是否删除成功 + """ + for i, record in enumerate(self._history): + if record.task_id == task_id: + self._history.pop(i) + self._save() + return True + return False + + def delete_multiple(self, task_ids: List[str]) -> int: + """ + 批量删除记录 + + Args: + task_ids: 任务 ID 列表 + + Returns: + 删除的记录数量 + """ + task_id_set = set(task_ids) + original_count = len(self._history) + self._history = [r for r in self._history if r.task_id not in task_id_set] + deleted_count = original_count - len(self._history) + + if deleted_count > 0: + self._save() + + return deleted_count + def clear(self): """清空历史记录""" self._history = [] @@ -174,6 +215,57 @@ class HistoryManager: 'success_rate': success / total if total > 0 else 0.0, 'avg_duration_ms': int(avg_duration) } + + def find_similar_success(self, user_input: str, threshold: float = 0.6) -> Optional[TaskRecord]: + """ + 查找相似的成功任务 + + 使用简单的关键词匹配来判断相似度 + + Args: + user_input: 用户输入 + threshold: 相似度阈值 + + Returns: + 最相似的成功任务记录,如果没有则返回 None + """ + # 提取关键词 + def extract_keywords(text: str) -> set: + # 简单分词:按空格和标点分割 + import re + words = re.findall(r'[\u4e00-\u9fa5]+|[a-zA-Z]+', text.lower()) + # 过滤掉太短的词 + return set(w for w in words if len(w) >= 2) + + input_keywords = extract_keywords(user_input) + if not input_keywords: + return None + + best_match = None + best_score = 0.0 + + for record in self._history: + if not record.success: + continue + + record_keywords = extract_keywords(record.user_input) + if not record_keywords: + continue + + # 计算 Jaccard 相似度 + intersection = len(input_keywords & record_keywords) + union = len(input_keywords | record_keywords) + score = intersection / union if union > 0 else 0 + + if score > best_score and score >= threshold: + best_score = score + best_match = record + + return best_match + + def get_successful_records(self) -> List[TaskRecord]: + """获取所有成功的任务记录""" + return [r for r in self._history if r.success] # 全局单例 diff --git a/intent/labels.py b/intent/labels.py index 128e309..32b84e1 100644 --- a/intent/labels.py +++ b/intent/labels.py @@ -5,11 +5,12 @@ # 意图类型常量 CHAT = "chat" EXECUTION = "execution" +GUIDANCE = "guidance" # 操作指导(无法通过本地代码完成的任务) # 执行任务置信度阈值 # 低于此阈值一律判定为 chat(宁可少执行,不可误执行) EXECUTION_CONFIDENCE_THRESHOLD = 0.6 # 所有有效标签 -VALID_LABELS = {CHAT, EXECUTION} +VALID_LABELS = {CHAT, EXECUTION, GUIDANCE} diff --git a/llm/prompts.py b/llm/prompts.py index 0132def..98fae6c 100644 --- a/llm/prompts.py +++ b/llm/prompts.py @@ -40,14 +40,26 @@ ALLOWED_LIBRARIES = """ # 意图识别 Prompt # ======================================== -INTENT_CLASSIFICATION_SYSTEM = """你是一个意图分类器。判断用户输入是"普通对话"还是"本地执行任务"。 +INTENT_CLASSIFICATION_SYSTEM = """你是一个意图分类器。判断用户输入属于以下哪种类型。 -规则: -- chat: 闲聊、问答、知识查询(如天气、新闻、解释概念) -- execution: 需要操作本地文件的任务(如复制、移动、重命名、整理、转换文件) +【意图类型】 +- chat: 闲聊、问答、知识查询(如天气、新闻、解释概念、编程问题) +- execution: 需要操作本地文件的任务(如复制、移动、重命名、整理、转换文件、图片处理) +- guidance: 需要操作指导但无法通过本地Python代码完成的任务 + +【guidance 类型示例】 +- 软件/系统设置类:如何修改浏览器主题、如何设置Windows壁纸、如何更改系统语言 +- 软件操作类:如何使用Photoshop抠图、如何在Excel中创建透视表 +- 网络操作类:如何注册某网站账号、如何下载某软件 +- 硬件操作类:如何连接蓝牙设备、如何设置打印机 + +【判断要点】 +1. 如果任务可以通过Python脚本处理本地文件完成 → execution +2. 如果任务需要操作GUI软件、浏览器、系统设置等 → guidance +3. 如果是纯粹的知识问答或闲聊 → chat 只输出JSON,格式: -{"label": "chat或execution", "confidence": 0.0到1.0, "reason": "简短中文理由"}""" +{"label": "chat或execution或guidance", "confidence": 0.0到1.0, "reason": "简短中文理由"}""" INTENT_CLASSIFICATION_USER = """判断以下输入的意图: {user_input}""" @@ -188,3 +200,294 @@ SAFETY_REVIEW_USER = """用户需求:{user_input} ``` 请进行安全审查。""" + + +# ======================================== +# 任务摘要生成 Prompt +# ======================================== + +TASK_SUMMARY_SYSTEM = """你是一个任务摘要生成器。根据用户的输入,生成简短的任务描述。 + +要求: +1. 用中文输出 +2. 不超过 15 个字 +3. 只描述任务本身,不要包含"用户想要"等前缀 +4. 使用动词开头,如"复制"、"转换"、"整理"等 + +示例: +- 用户输入:"帮我把input里的图片都转成jpg格式" → "图片批量转换为JPG" +- 用户输入:"把所有文件按日期分类" → "文件按日期分类整理" +- 用户输入:"给图片加水印" → "图片批量添加水印" + +只输出摘要文本,不要其他内容。""" + +TASK_SUMMARY_USER = """用户输入:{user_input} + +请生成任务摘要。""" + + +# ======================================== +# 代码修复 Prompt(用于失败重试) +# ======================================== + +CODE_FIX_SYSTEM = f"""你是一个 Python 代码修复专家。根据错误信息修复代码。 + +【任务】 +分析代码执行失败的原因,修复代码中的 bug。 + +【硬性约束 - 必须遵守】 +1. 只能操作 workspace/input(读取)和 workspace/output(写入)目录 +2. 禁止使用: requests, socket, urllib, subprocess, os.system, eval, exec +3. 禁止删除文件: os.remove, shutil.rmtree, os.unlink +4. 禁止访问 workspace 外的任何路径 +5. 必须处理异常,打印清晰的错误信息 + +{ALLOWED_LIBRARIES} + +【修复要点】 +1. 仔细分析错误信息,找出根本原因 +2. 检查 API 使用是否正确(如 PIL 的方法名、参数等) +3. 添加必要的错误处理 +4. 确保代码逻辑正确 + +只输出修复后的完整 Python 代码块,不要其他解释。""" + +CODE_FIX_USER = """原始需求:{user_input} + +执行计划: +{execution_plan} + +原始代码: +```python +{code} +``` + +执行输出: +{stdout} + +错误信息: +{stderr} + +请分析错误原因并修复代码。""" + + +# ======================================== +# 需求澄清 Prompt(用于模糊需求的多轮对话) +# ======================================== + +REQUIREMENT_CLARIFY_SYSTEM = """你是一个需求分析助手。你的任务是通过提问来澄清用户模糊的需求。 + +【背景】 +用户提出了一个文件处理任务,但描述不够完整。你需要识别缺失的关键信息,并生成一个问题来询问用户。 + +【可处理的任务类型】 +- 图片处理:添加水印、格式转换、缩放、裁剪、压缩等 +- 文件整理:按类型/日期/大小分类、重命名、复制、移动等 +- 文档处理:Excel合并、PDF提取、Word转换等 +- 压缩打包:批量压缩、解压等 + +【输出格式】 +你必须输出一个 JSON 对象,格式如下: +{ + "need_clarify": true或false, + "question": "要问用户的问题(如果need_clarify为false则为空)", + "options": [ + { + "id": "选项ID", + "type": "radio|checkbox|input", + "label": "选项标签/问题描述", + "choices": ["选项1", "选项2"], // 仅radio/checkbox需要 + "default": "默认值", // 可选 + "placeholder": "输入提示" // 仅input需要 + } + ], + "collected_info": { + "已收集的信息键": "值" + }, + "missing_info": ["缺失信息1", "缺失信息2"] +} + +【选项类型说明】 +- radio: 单选,用于互斥选项(如:文字水印/图片水印) +- checkbox: 多选,用于可多选的选项(如:水印位置可选多个角落) +- input: 输入框,用于自由输入(如:水印文字内容、透明度数值) + +【提问策略】 +1. 每次只问一个核心问题,不要一次问太多 +2. 优先问最关键的信息(如水印类型比透明度更重要) +3. 提供合理的默认值,减少用户输入负担 +4. 选项要覆盖常见场景,但不要过于复杂 + +【常见需要澄清的信息】 +图片水印任务: +- 水印类型(文字/图片) +- 水印内容(文字内容或图片路径) +- 水印位置(左上/右上/左下/右下/居中/平铺) +- 透明度(0-100%) +- 字体大小(仅文字水印) +- 水印颜色(仅文字水印) + +图片转换任务: +- 目标格式(JPG/PNG/WEBP等) +- 质量/压缩率 +- 是否保持原尺寸 + +文件整理任务: +- 分类依据(扩展名/日期/大小) +- 命名规则 +- 是否包含子目录 + +【示例】 +用户输入:"给图片加水印" +输出: +{ + "need_clarify": true, + "question": "请选择水印类型", + "options": [ + { + "id": "watermark_type", + "type": "radio", + "label": "水印类型", + "choices": ["文字水印", "图片水印"], + "default": "文字水印" + } + ], + "collected_info": {}, + "missing_info": ["水印类型", "水印内容", "水印位置", "透明度"] +} + +如果信息已经足够完整,设置 need_clarify 为 false。""" + +REQUIREMENT_CLARIFY_USER = """用户原始需求:{user_input} + +已收集的信息: +{collected_info} + +用户最新回答: +{user_answer} + +请分析是否还需要继续澄清,如果需要,生成下一个问题。""" + + +# ======================================== +# 需求结构化 Prompt(将澄清后的需求整理为完整描述) +# ======================================== + +REQUIREMENT_STRUCTURE_SYSTEM = """你是一个需求整理专家。你的任务是将用户的模糊需求和澄清后的信息整理成完整、清晰、无歧义的需求描述。 + +【输出要求】 +生成一段结构化的自然语言描述,必须包含以下要素: + +1. **任务目标**:一句话描述要做什么 +2. **输入数据**: + - 数据来源:workspace/input 目录 + - 文件类型:具体的文件格式 + - 数量:单个/批量 +3. **处理规则**: + - 具体的处理逻辑 + - 所有参数的明确值 +4. **输出结果**: + - 输出位置:workspace/output 目录 + - 输出格式:文件命名规则、格式等 +5. **约束条件**: + - 不修改原文件 + - 异常处理方式 + +【格式示例】 +``` +## 任务目标 +批量为图片添加文字水印 + +## 输入数据 +- 来源:workspace/input 目录下的所有图片文件 +- 支持格式:JPG、PNG、WEBP +- 处理方式:批量处理所有图片 + +## 处理规则 +- 水印类型:文字水印 +- 水印内容:"© 2024 MyCompany" +- 水印位置:右下角 +- 透明度:50% +- 字体大小:24px +- 字体颜色:白色 + +## 输出结果 +- 输出位置:workspace/output 目录 +- 文件命名:保持原文件名 +- 输出格式:与原图相同 + +## 约束条件 +- 保持原图不变,输出到新目录 +- 跳过无法处理的文件并记录错误 +- 处理完成后输出统计信息 +``` + +只输出整理后的需求描述,不要其他内容。""" + +REQUIREMENT_STRUCTURE_USER = """用户原始需求:{user_input} + +澄清后收集的完整信息: +{collected_info} + +请将以上信息整理为完整的需求描述。""" + + +# ======================================== +# 需求完整性检查 Prompt +# ======================================== + +REQUIREMENT_CHECK_SYSTEM = """你是一个需求完整性检查器。判断用户的需求描述是否足够完整,可以直接生成代码。 + +【判断标准】 +完整的需求应该包含: +1. 明确的操作对象(什么类型的文件) +2. 明确的操作动作(做什么处理) +3. 关键参数已指定或有合理默认值 + +【输出格式】 +{ + "is_complete": true或false, + "confidence": 0.0到1.0, + "reason": "判断理由", + "suggested_defaults": { + "参数名": "建议的默认值" + } +} + +【示例】 +输入:"把图片转成jpg" +输出: +{ + "is_complete": true, + "confidence": 0.8, + "reason": "目标格式明确,质量可使用默认值85%", + "suggested_defaults": { + "quality": 85 + } +} + +输入:"给图片加水印" +输出: +{ + "is_complete": false, + "confidence": 0.3, + "reason": "缺少水印类型、内容、位置等关键信息", + "suggested_defaults": {} +} + +输入:"给图片右下角加上'版权所有'的文字水印" +输出: +{ + "is_complete": true, + "confidence": 0.9, + "reason": "水印类型、内容、位置都已明确,其他参数可用默认值", + "suggested_defaults": { + "opacity": 50, + "font_size": 24, + "color": "white" + } +}""" + +REQUIREMENT_CHECK_USER = """用户需求:{user_input} + +请判断这个需求是否足够完整。""" diff --git a/tests/test_intent_classifier.py b/tests/test_intent_classifier.py index 89df003..5ea75a0 100644 --- a/tests/test_intent_classifier.py +++ b/tests/test_intent_classifier.py @@ -9,7 +9,7 @@ from pathlib import Path # 添加项目根目录到路径 sys.path.insert(0, str(Path(__file__).parent.parent)) -from intent.labels import CHAT, EXECUTION, VALID_LABELS, EXECUTION_CONFIDENCE_THRESHOLD +from intent.labels import CHAT, EXECUTION, GUIDANCE, VALID_LABELS, EXECUTION_CONFIDENCE_THRESHOLD class TestIntentLabels(unittest.TestCase): @@ -19,12 +19,14 @@ class TestIntentLabels(unittest.TestCase): """测试标签已定义""" self.assertEqual(CHAT, "chat") self.assertEqual(EXECUTION, "execution") + self.assertEqual(GUIDANCE, "guidance") def test_valid_labels(self): """测试有效标签集合""" self.assertIn(CHAT, VALID_LABELS) self.assertIn(EXECUTION, VALID_LABELS) - self.assertEqual(len(VALID_LABELS), 2) + self.assertIn(GUIDANCE, VALID_LABELS) + self.assertEqual(len(VALID_LABELS), 3) def test_confidence_threshold(self): """测试置信度阈值""" diff --git a/ui/chat_view.py b/ui/chat_view.py index 0988d32..4ede5bb 100644 --- a/ui/chat_view.py +++ b/ui/chat_view.py @@ -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, '', on_click) + self.text_widget.tag_bind(tag_name, '', lambda e: self._set_cursor('hand2')) + self.text_widget.tag_bind(tag_name, '', 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('', 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): """设置输入区域是否可用""" diff --git a/ui/clarify_view.py b/ui/clarify_view.py new file mode 100644 index 0000000..103714b --- /dev/null +++ b/ui/clarify_view.py @@ -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("", self._on_frame_configure) + self.canvas.bind("", self._on_canvas_configure) + + # 鼠标滚轮支持 + self.canvas.bind_all("", 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 + diff --git a/ui/history_view.py b/ui/history_view.py index d3118e6..3793a69 100644 --- a/ui/history_view.py +++ b/ui/history_view.py @@ -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('', 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 - diff --git a/ui/settings_view.py b/ui/settings_view.py new file mode 100644 index 0000000..264d6e6 --- /dev/null +++ b/ui/settings_view.py @@ -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("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.bind("", configure_scroll) + + # 鼠标滚轮支持 + def on_mousewheel(event): + canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + canvas.bind_all("", 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 +