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

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

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

View File

@@ -15,5 +15,8 @@ LLM_API_KEY=your_api_key_here
# Intent recognition model (small model recommended for speed) # Intent recognition model (small model recommended for speed)
INTENT_MODEL_NAME=Qwen/Qwen2.5-7B-Instruct 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) # Code generation model (large model recommended for quality)
GENERATION_MODEL_NAME=Qwen/Qwen2.5-72B-Instruct GENERATION_MODEL_NAME=Qwen/Qwen2.5-72B-Instruct

View File

@@ -5,11 +5,16 @@ A Windows-based local AI assistant that can understand natural language commands
## Features ## Features
- **Intent Recognition**: Automatically distinguishes between chat conversations and execution tasks - **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 - **Safety Checks**: Multi-layer security with static analysis and LLM review
- **Sandbox Execution**: Runs generated code in an isolated environment - **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 - **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 ## Project Structure
@@ -32,8 +37,10 @@ LocalAgent/
│ └── manager.py # History manager │ └── manager.py # History manager
├── ui/ # User interface ├── ui/ # User interface
│ ├── chat_view.py # Chat interface │ ├── chat_view.py # Chat interface
│ ├── clarify_view.py # Requirement clarification view
│ ├── task_guide_view.py # Task confirmation 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 ├── tests/ # Unit tests
├── workspace/ # Working directory (auto-created) ├── workspace/ # Working directory (auto-created)
│ ├── input/ # Input files │ ├── input/ # Input files
@@ -85,7 +92,7 @@ LocalAgent/
## Configuration ## Configuration
Edit `.env` file with your settings: Edit `.env` file with your settings (or use the Settings UI in the app):
```env ```env
# SiliconFlow API Configuration # SiliconFlow API Configuration
@@ -93,7 +100,13 @@ LLM_API_URL=https://api.siliconflow.cn/v1/chat/completions
LLM_API_KEY=your_api_key_here LLM_API_KEY=your_api_key_here
# Model Configuration # Model Configuration
# Intent recognition model (small model recommended for speed)
INTENT_MODEL_NAME=Qwen/Qwen2.5-7B-Instruct 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 GENERATION_MODEL_NAME=Qwen/Qwen2.5-72B-Instruct
``` ```
@@ -113,9 +126,26 @@ Describe file processing tasks:
### Workflow ### Workflow
1. Place input files in `workspace/input/` 1. Place input files in `workspace/input/`
2. Describe your task in the chat 2. Describe your task in the chat
3. Review the execution plan and generated code 3. **If the requirement is vague**, the system will ask clarifying questions:
4. Click "Execute" to run - Radio buttons for single-choice options (e.g., watermark type)
5. Find results in `workspace/output/` - 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 ## Security

View File

@@ -7,23 +7,30 @@ import os
import tkinter as tk import tkinter as tk
from tkinter import messagebox from tkinter import messagebox
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any, Tuple from typing import Optional, Dict, Any, Tuple, List
import threading import threading
import queue import queue
from llm.client import get_client, LLMClientError from llm.client import get_client, LLMClientError
from llm.prompts import ( from llm.prompts import (
EXECUTION_PLAN_SYSTEM, EXECUTION_PLAN_USER, 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.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.rule_checker import check_code_safety
from safety.llm_reviewer import review_code_safety, LLMReviewResult from safety.llm_reviewer import review_code_safety, LLMReviewResult
from executor.sandbox_runner import SandboxRunner, ExecutionResult from executor.sandbox_runner import SandboxRunner, ExecutionResult
from ui.chat_view import ChatView from ui.chat_view import ChatView
from ui.task_guide_view import TaskGuideView from ui.task_guide_view import TaskGuideView
from ui.history_view import HistoryView 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 from history.manager import get_history_manager, HistoryManager
@@ -55,6 +62,15 @@ class LocalAgentApp:
self.chat_view: Optional[ChatView] = None self.chat_view: Optional[ChatView] = None
self.task_view: Optional[TaskGuideView] = None self.task_view: Optional[TaskGuideView] = None
self.history_view: Optional[HistoryView] = 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 # 初始化 UI
self._init_ui() self._init_ui()
@@ -63,7 +79,8 @@ class LocalAgentApp:
"""初始化 UI""" """初始化 UI"""
self.root = tk.Tk() self.root = tk.Tk()
self.root.title("LocalAgent - 本地 AI 助手") self.root.title("LocalAgent - 本地 AI 助手")
self.root.geometry("800x700") self.root.geometry("1100x750")
self.root.minsize(900, 600)
self.root.configure(bg='#1e1e1e') self.root.configure(bg='#1e1e1e')
# 设置窗口图标(如果有的话) # 设置窗口图标(如果有的话)
@@ -80,9 +97,13 @@ class LocalAgentApp:
self.chat_view = ChatView( self.chat_view = ChatView(
self.main_container, self.main_container,
self._on_user_input, 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() self._check_queue()
@@ -136,6 +157,9 @@ class LocalAgentApp:
if intent_result.label == CHAT: if intent_result.label == CHAT:
# 对话模式 # 对话模式
self._handle_chat(user_input, intent_result) self._handle_chat(user_input, intent_result)
elif intent_result.label == GUIDANCE:
# 操作指导模式
self._handle_guidance(user_input, intent_result)
else: else:
# 执行模式 # 执行模式
self._handle_execution(user_input, intent_result) self._handle_execution(user_input, intent_result)
@@ -147,17 +171,24 @@ class LocalAgentApp:
'system' 'system'
) )
# 添加用户消息到上下文
self._chat_context.append({"role": "user", "content": user_input})
# 开始流式消息 # 开始流式消息
self.chat_view.start_stream_message('assistant') self.chat_view.start_stream_message('assistant')
# 在后台线程调用 LLM流式 # 在后台线程调用 LLM流式
def do_chat_stream(): def do_chat_stream():
client = get_client() 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 = [] full_response = []
for chunk in client.chat_stream( for chunk in client.chat_stream(
messages=[{"role": "user", "content": user_input}], messages=messages,
model=model, model=model,
temperature=0.7, temperature=0.7,
max_tokens=2048, max_tokens=2048,
@@ -184,16 +215,94 @@ class LocalAgentApp:
if error: if error:
self.chat_view.add_message(f"对话失败: {str(error)}", '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) 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): def _handle_execution(self, user_input: str, intent_result: IntentResult):
"""处理执行任务""" """处理执行任务"""
self.chat_view.add_message( self.chat_view.add_message(
f"识别为执行任务 (置信度: {intent_result.confidence:.0%})\n原因: {intent_result.reason}", f"识别为执行任务 (置信度: {intent_result.confidence:.0%})\n原因: {intent_result.reason}",
'system' 'system'
) )
self.chat_view.show_loading("正在生成执行计划")
# 保存用户输入和意图结果 # 保存用户输入和意图结果
self.current_task = { self.current_task = {
@@ -201,10 +310,36 @@ class LocalAgentApp:
'intent_result': intent_result '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._run_in_thread(
self._generate_execution_plan, self._check_requirement_completeness,
self._on_plan_generated, self._on_requirement_checked,
user_input user_input
) )
@@ -224,10 +359,374 @@ class LocalAgentApp:
self._run_in_thread( self._run_in_thread(
self._generate_code, self._generate_code,
self._on_code_generated, self._on_code_generated,
self.current_task['user_input'], self.current_task.get('structured_requirement') or self.current_task['user_input'],
plan 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]): def _on_code_generated(self, result: tuple, error: Optional[Exception]):
"""代码生成完成回调""" """代码生成完成回调"""
if error: if error:
@@ -298,6 +797,9 @@ class LocalAgentApp:
self.current_task = None self.current_task = None
return return
# 代码生成完成,清空 input 和 output 目录
self.runner.clear_workspace(clear_input=True, clear_output=True)
self.chat_view.add_message("安全检查通过,请确认执行", 'system') self.chat_view.add_message("安全检查通过,请确认执行", 'system')
# 显示任务引导视图 # 显示任务引导视图
@@ -439,7 +941,8 @@ class LocalAgentApp:
duration_ms=result.duration_ms, duration_ms=result.duration_ms,
stdout=result.stdout, stdout=result.stdout,
stderr=result.stderr, 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) self._show_execution_result(result)
@@ -508,7 +1011,9 @@ class LocalAgentApp:
self.history_view = HistoryView( self.history_view = HistoryView(
self.main_container, self.main_container,
self.history, 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() 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) 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): def run(self):
"""运行应用""" """运行应用"""
self.root.mainloop() self.root.mainloop()

View File

@@ -119,8 +119,15 @@ class SandboxRunner:
duration_ms=duration_ms duration_ms=duration_ms
) )
# 判断是否成功return code 为 0 且没有明显的失败迹象
success = self._check_execution_success(
result.returncode,
result.stdout,
result.stderr
)
return ExecutionResult( return ExecutionResult(
success=result.returncode == 0, success=success,
task_id=task_id, task_id=task_id,
stdout=result.stdout, stdout=result.stdout,
stderr=result.stderr, stderr=result.stderr,
@@ -187,6 +194,99 @@ class SandboxRunner:
short_uuid = uuid.uuid4().hex[:6] short_uuid = uuid.uuid4().hex[:6]
return f"{timestamp}_{short_uuid}" 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: def _get_safe_env(self) -> dict:
"""获取安全的环境变量(移除网络代理等)""" """获取安全的环境变量(移除网络代理等)"""
safe_env = os.environ.copy() safe_env = os.environ.copy()

View File

@@ -25,6 +25,7 @@ class TaskRecord:
stdout: str stdout: str
stderr: str stderr: str
log_path: str log_path: str
task_summary: str = "" # 任务摘要(由小模型生成)
class HistoryManager: class HistoryManager:
@@ -83,7 +84,8 @@ class HistoryManager:
duration_ms: int, duration_ms: int,
stdout: str = "", stdout: str = "",
stderr: str = "", stderr: str = "",
log_path: str = "" log_path: str = "",
task_summary: str = ""
) -> TaskRecord: ) -> TaskRecord:
""" """
添加一条任务记录 添加一条任务记录
@@ -100,6 +102,7 @@ class HistoryManager:
stdout: 标准输出 stdout: 标准输出
stderr: 标准错误 stderr: 标准错误
log_path: 日志文件路径 log_path: 日志文件路径
task_summary: 任务摘要
Returns: Returns:
TaskRecord: 创建的记录 TaskRecord: 创建的记录
@@ -116,7 +119,8 @@ class HistoryManager:
duration_ms=duration_ms, duration_ms=duration_ms,
stdout=stdout, stdout=stdout,
stderr=stderr, stderr=stderr,
log_path=log_path log_path=log_path,
task_summary=task_summary
) )
# 添加到列表开头(最新的在前) # 添加到列表开头(最新的在前)
@@ -146,6 +150,43 @@ class HistoryManager:
return record return record
return None 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): def clear(self):
"""清空历史记录""" """清空历史记录"""
self._history = [] self._history = []
@@ -175,6 +216,57 @@ class HistoryManager:
'avg_duration_ms': int(avg_duration) '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]
# 全局单例 # 全局单例
_manager: Optional[HistoryManager] = None _manager: Optional[HistoryManager] = None

View File

@@ -5,11 +5,12 @@
# 意图类型常量 # 意图类型常量
CHAT = "chat" CHAT = "chat"
EXECUTION = "execution" EXECUTION = "execution"
GUIDANCE = "guidance" # 操作指导(无法通过本地代码完成的任务)
# 执行任务置信度阈值 # 执行任务置信度阈值
# 低于此阈值一律判定为 chat宁可少执行不可误执行 # 低于此阈值一律判定为 chat宁可少执行不可误执行
EXECUTION_CONFIDENCE_THRESHOLD = 0.6 EXECUTION_CONFIDENCE_THRESHOLD = 0.6
# 所有有效标签 # 所有有效标签
VALID_LABELS = {CHAT, EXECUTION} VALID_LABELS = {CHAT, EXECUTION, GUIDANCE}

View File

@@ -40,14 +40,26 @@ ALLOWED_LIBRARIES = """
# 意图识别 Prompt # 意图识别 Prompt
# ======================================== # ========================================
INTENT_CLASSIFICATION_SYSTEM = """你是一个意图分类器。判断用户输入"普通对话"还是"本地执行任务" INTENT_CLASSIFICATION_SYSTEM = """你是一个意图分类器。判断用户输入属于以下哪种类型
规则: 【意图类型】
- chat: 闲聊、问答、知识查询(如天气、新闻、解释概念) - chat: 闲聊、问答、知识查询(如天气、新闻、解释概念、编程问题
- execution: 需要操作本地文件的任务(如复制、移动、重命名、整理、转换文件) - execution: 需要操作本地文件的任务(如复制、移动、重命名、整理、转换文件、图片处理
- guidance: 需要操作指导但无法通过本地Python代码完成的任务
【guidance 类型示例】
- 软件/系统设置类如何修改浏览器主题、如何设置Windows壁纸、如何更改系统语言
- 软件操作类如何使用Photoshop抠图、如何在Excel中创建透视表
- 网络操作类:如何注册某网站账号、如何下载某软件
- 硬件操作类:如何连接蓝牙设备、如何设置打印机
【判断要点】
1. 如果任务可以通过Python脚本处理本地文件完成 → execution
2. 如果任务需要操作GUI软件、浏览器、系统设置等 → guidance
3. 如果是纯粹的知识问答或闲聊 → chat
只输出JSON格式 只输出JSON格式
{"label": "chat或execution", "confidence": 0.0到1.0, "reason": "简短中文理由"}""" {"label": "chat或execution或guidance", "confidence": 0.0到1.0, "reason": "简短中文理由"}"""
INTENT_CLASSIFICATION_USER = """判断以下输入的意图: INTENT_CLASSIFICATION_USER = """判断以下输入的意图:
{user_input}""" {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}
请判断这个需求是否足够完整。"""

View File

@@ -9,7 +9,7 @@ from pathlib import Path
# 添加项目根目录到路径 # 添加项目根目录到路径
sys.path.insert(0, str(Path(__file__).parent.parent)) 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): class TestIntentLabels(unittest.TestCase):
@@ -19,12 +19,14 @@ class TestIntentLabels(unittest.TestCase):
"""测试标签已定义""" """测试标签已定义"""
self.assertEqual(CHAT, "chat") self.assertEqual(CHAT, "chat")
self.assertEqual(EXECUTION, "execution") self.assertEqual(EXECUTION, "execution")
self.assertEqual(GUIDANCE, "guidance")
def test_valid_labels(self): def test_valid_labels(self):
"""测试有效标签集合""" """测试有效标签集合"""
self.assertIn(CHAT, VALID_LABELS) self.assertIn(CHAT, VALID_LABELS)
self.assertIn(EXECUTION, 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): def test_confidence_threshold(self):
"""测试置信度阈值""" """测试置信度阈值"""

View File

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

725
ui/clarify_view.py Normal file
View File

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

View File

@@ -1,32 +1,230 @@
""" """
历史记录视图组件 历史记录视图组件
显示任务执行历史 显示任务执行历史,支持 Markdown 渲染、代码复用、失败重试、勾选删除
""" """
import os
import re
import tkinter as tk import tkinter as tk
from tkinter import ttk, messagebox from tkinter import ttk, messagebox
from typing import Callable, List, Optional from typing import Callable, List, Optional, Set
from pathlib import Path from pathlib import Path
from history.manager import TaskRecord, HistoryManager from history.manager import TaskRecord, HistoryManager
class MarkdownText(tk.Text):
"""
支持简单 Markdown 渲染的 Text 组件
"""
def __init__(self, parent, **kwargs):
super().__init__(parent, **kwargs)
self._setup_tags()
def _setup_tags(self):
"""配置 Markdown 样式标签"""
# 标题
self.tag_configure('h1', font=('Microsoft YaHei UI', 14, 'bold'), foreground='#ffd54f', spacing3=10)
self.tag_configure('h2', font=('Microsoft YaHei UI', 12, 'bold'), foreground='#4fc3f7', spacing3=8)
self.tag_configure('h3', font=('Microsoft YaHei UI', 11, 'bold'), foreground='#81c784', spacing3=6)
# 普通文本
self.tag_configure('normal', font=('Microsoft YaHei UI', 10), foreground='#d4d4d4')
# 代码块
self.tag_configure('code', font=('Consolas', 9), foreground='#ce93d8', background='#1a1a1a')
self.tag_configure('code_block', font=('Consolas', 9), foreground='#ce93d8', background='#1a1a1a',
lmargin1=20, lmargin2=20, spacing1=5, spacing3=5)
# 列表
self.tag_configure('list_item', font=('Microsoft YaHei UI', 10), foreground='#d4d4d4', lmargin1=20, lmargin2=30)
# 强调
self.tag_configure('bold', font=('Microsoft YaHei UI', 10, 'bold'), foreground='#ffffff')
self.tag_configure('italic', font=('Microsoft YaHei UI', 10, 'italic'), foreground='#b0b0b0')
# 状态
self.tag_configure('success', foreground='#81c784')
self.tag_configure('error', foreground='#ef5350')
self.tag_configure('label', font=('Microsoft YaHei UI', 10, 'bold'), foreground='#4fc3f7')
def render_markdown(self, text: str):
"""渲染 Markdown 文本"""
self.config(state=tk.NORMAL)
self.delete(1.0, tk.END)
lines = text.split('\n')
in_code_block = False
code_block_content = []
for line in lines:
# 代码块处理
if line.strip().startswith('```'):
if in_code_block:
# 结束代码块
code_text = '\n'.join(code_block_content)
self.insert(tk.END, code_text + '\n', 'code_block')
code_block_content = []
in_code_block = False
else:
# 开始代码块
in_code_block = True
continue
if in_code_block:
code_block_content.append(line)
continue
# 标题
if line.startswith('### '):
self.insert(tk.END, line[4:] + '\n', 'h3')
elif line.startswith('## '):
self.insert(tk.END, line[3:] + '\n', 'h2')
elif line.startswith('# '):
self.insert(tk.END, line[2:] + '\n', 'h1')
# 列表项
elif line.strip().startswith('- ') or line.strip().startswith('* '):
content = line.strip()[2:]
self.insert(tk.END, '' + content + '\n', 'list_item')
elif re.match(r'^\d+\.\s', line.strip()):
self.insert(tk.END, ' ' + line.strip() + '\n', 'list_item')
# 普通行
else:
self._render_inline(line + '\n')
# 处理未闭合的代码块
if code_block_content:
code_text = '\n'.join(code_block_content)
self.insert(tk.END, code_text + '\n', 'code_block')
self.config(state=tk.DISABLED)
def _render_inline(self, text: str):
"""渲染行内元素"""
# 简单处理:查找 `code` 和 **bold**
pattern = r'(`[^`]+`|\*\*[^*]+\*\*)'
parts = re.split(pattern, text)
for part in parts:
if part.startswith('`') and part.endswith('`'):
self.insert(tk.END, part[1:-1], 'code')
elif part.startswith('**') and part.endswith('**'):
self.insert(tk.END, part[2:-2], 'bold')
else:
self.insert(tk.END, part, 'normal')
class CheckboxTreeview(ttk.Treeview):
"""
带勾选框的 Treeview
"""
def __init__(self, parent, **kwargs):
super().__init__(parent, **kwargs)
# 勾选状态存储
self._checked: Set[str] = set()
# 勾选变化回调
self._on_check_changed: Optional[Callable[[Set[str]], None]] = None
# 绑定点击事件
self.bind('<Button-1>', self._on_click)
def set_on_check_changed(self, callback: Callable[[Set[str]], None]):
"""设置勾选变化回调"""
self._on_check_changed = callback
def _on_click(self, event):
"""处理点击事件"""
region = self.identify_region(event.x, event.y)
# 点击在第一列(勾选框区域)
if region == 'cell':
column = self.identify_column(event.x)
if column == '#1': # 第一列是勾选框
item = self.identify_row(event.y)
if item:
self._toggle_check(item)
def _toggle_check(self, item: str):
"""切换勾选状态"""
if item in self._checked:
self._checked.remove(item)
else:
self._checked.add(item)
# 更新显示
self._update_check_display(item)
# 触发回调
if self._on_check_changed:
self._on_check_changed(self._checked.copy())
def _update_check_display(self, item: str):
"""更新勾选框显示"""
values = list(self.item(item, 'values'))
if values:
values[0] = '' if item in self._checked else ''
self.item(item, values=values)
def get_checked(self) -> Set[str]:
"""获取所有勾选的项"""
return self._checked.copy()
def clear_checked(self):
"""清除所有勾选"""
for item in list(self._checked):
self._checked.remove(item)
self._update_check_display(item)
if self._on_check_changed:
self._on_check_changed(set())
def check_all(self):
"""全选"""
for item in self.get_children():
if item not in self._checked:
self._checked.add(item)
self._update_check_display(item)
if self._on_check_changed:
self._on_check_changed(self._checked.copy())
def insert_with_checkbox(self, parent, index, iid=None, **kwargs):
"""插入带勾选框的项"""
values = list(kwargs.get('values', []))
# 在最前面插入勾选框
values.insert(0, '')
kwargs['values'] = values
return self.insert(parent, index, iid=iid, **kwargs)
class HistoryView: class HistoryView:
""" """
历史记录视图 历史记录视图
显示任务执行历史列表,支持查看详情 显示任务执行历史列表,支持
- 查看详情Markdown 渲染)
- 复用成功的代码
- 重试失败的任务
- 勾选删除
""" """
def __init__( def __init__(
self, self,
parent: tk.Widget, parent: tk.Widget,
history_manager: HistoryManager, 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.parent = parent
self.history = history_manager self.history = history_manager
self.on_back = on_back 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._selected_record: Optional[TaskRecord] = None
self._create_widgets() self._create_widgets()
@@ -68,19 +266,24 @@ class HistoryView:
# 统计信息 # 统计信息
stats = self.history.get_stats() stats = self.history.get_stats()
stats_text = f"{stats['total']} 条 | 成功 {stats['success']} | 失败 {stats['failed']} | 成功率 {stats['success_rate']:.0%}" stats_text = f"{stats['total']} 条 | 成功 {stats['success']} | 失败 {stats['failed']} | 成功率 {stats['success_rate']:.0%}"
stats_label = tk.Label( self.stats_label = tk.Label(
title_frame, title_frame,
text=stats_text, text=stats_text,
font=('Microsoft YaHei UI', 9), font=('Microsoft YaHei UI', 9),
fg='#888888', fg='#888888',
bg='#1e1e1e' 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 = tk.Frame(self.frame, bg='#1e1e1e')
content_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10)) 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( list_frame = tk.LabelFrame(
content_frame, content_frame,
@@ -90,26 +293,77 @@ class HistoryView:
bg='#1e1e1e', bg='#1e1e1e',
relief=tk.GROOVE 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 = tk.Frame(list_frame, bg='#2d2d2d')
list_container.pack(fill=tk.BOTH, expand=True, padx=3, pady=3) list_container.pack(fill=tk.BOTH, expand=True, padx=3, pady=3)
# 使用 Treeview 显示列表 # 使用带勾选框的 Treeview 显示列表
columns = ('time', 'input', 'status', 'duration') columns = ('check', 'time', 'description', 'status', 'duration')
self.tree = ttk.Treeview(list_container, columns=columns, show='headings', height=15) self.tree = CheckboxTreeview(list_container, columns=columns, show='headings', height=18)
# 配置列 # 配置列
self.tree.heading('check', text='')
self.tree.heading('time', text='时间') self.tree.heading('time', text='时间')
self.tree.heading('input', text='任务描述') self.tree.heading('description', text='任务描述')
self.tree.heading('status', text='状态') self.tree.heading('status', text='状态')
self.tree.heading('duration', text='耗时') self.tree.heading('duration', text='耗时')
self.tree.column('time', width=120, minwidth=100) self.tree.column('check', width=30, minwidth=30, anchor='center')
self.tree.column('input', width=250, minwidth=150) self.tree.column('time', width=130, minwidth=110)
self.tree.column('status', width=60, minwidth=50) self.tree.column('description', width=180, minwidth=120)
self.tree.column('duration', width=70, minwidth=50) 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) scrollbar = ttk.Scrollbar(list_container, orient=tk.VERTICAL, command=self.tree.yview)
@@ -130,13 +384,13 @@ class HistoryView:
bg='#1e1e1e', bg='#1e1e1e',
relief=tk.GROOVE 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 = tk.Frame(detail_frame, bg='#2d2d2d')
detail_container.pack(fill=tk.BOTH, expand=True, padx=3, pady=3) detail_container.pack(fill=tk.BOTH, expand=True, padx=3, pady=3)
self.detail_text = tk.Text( self.detail_text = MarkdownText(
detail_container, detail_container,
wrap=tk.WORD, wrap=tk.WORD,
font=('Microsoft YaHei UI', 10), font=('Microsoft YaHei UI', 10),
@@ -154,20 +408,17 @@ class HistoryView:
detail_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) detail_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.detail_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) 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 = tk.Frame(self.frame, bg='#1e1e1e')
btn_frame.pack(fill=tk.X, padx=10, pady=(0, 10)) 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( self.open_log_btn = tk.Button(
btn_frame, left_btn_frame,
text="📄 打开日志", text="📄 打开日志",
font=('Microsoft YaHei UI', 10), font=('Microsoft YaHei UI', 10),
bg='#424242', bg='#424242',
@@ -180,23 +431,62 @@ class HistoryView:
state=tk.DISABLED, state=tk.DISABLED,
command=self._open_log 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( self.reuse_btn = tk.Button(
btn_frame, left_btn_frame,
text="🗑️ 清空历史", text="🔄 复用此代码",
font=('Microsoft YaHei UI', 10), font=('Microsoft YaHei UI', 10),
bg='#d32f2f', bg='#0e639c',
fg='white', fg='white',
activebackground='#f44336', activebackground='#1177bb',
activeforeground='white', activeforeground='white',
relief=tk.FLAT, relief=tk.FLAT,
padx=15, padx=15,
cursor='hand2', 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() self._load_data()
@@ -207,34 +497,116 @@ class HistoryView:
for item in self.tree.get_children(): for item in self.tree.get_children():
self.tree.delete(item) self.tree.delete(item)
# 清空勾选状态
self.tree._checked.clear()
# 加载历史记录 # 加载历史记录
records = self.history.get_all() records = self.history.get_all()
for record in records: for record in records:
# 截断过长的输入 # 使用任务描述(如果有)或截断的用户输入
input_text = record.user_input description = getattr(record, 'task_summary', None) or record.user_input
if len(input_text) > 30: if len(description) > 20:
input_text = input_text[:30] + "..." description = description[:20] + "..."
status = "✓ 成功" if record.success else "✗ 失败" status = "✓ 成功" if record.success else "✗ 失败"
duration = f"{record.duration_ms}ms" duration = f"{record.duration_ms}ms"
# 提取时间(只显示时分秒) self.tree.insert_with_checkbox('', tk.END, iid=record.task_id, values=(
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=(
record.timestamp, record.timestamp,
input_text, description,
status, status,
duration duration
)) ))
# 更新统计信息
self._update_stats()
# 更新删除按钮状态
self._update_delete_button(set())
# 显示空状态提示 # 显示空状态提示
if not records: 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): def _on_select(self, event):
"""选择记录事件""" """选择记录事件"""
@@ -248,77 +620,84 @@ class HistoryView:
if record: if record:
self._selected_record = record self._selected_record = record
self._show_record_detail(record) self._show_record_detail(record)
# 更新按钮状态
self.open_log_btn.config(state=tk.NORMAL) 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): def _show_record_detail(self, record: TaskRecord):
"""显示记录详情""" """显示记录详情Markdown 格式)"""
self.detail_text.config(state=tk.NORMAL) # 构建 Markdown 内容
self.detail_text.delete(1.0, tk.END) status_text = "✓ 成功" if record.success else "✗ 失败"
# 标题 md_content = f"""## 任务 ID: {record.task_id}
self.detail_text.insert(tk.END, f"任务 ID: {record.task_id}\n", 'title')
self.detail_text.insert(tk.END, f"时间: {record.timestamp}\n\n")
# 用户输入 **时间:** {record.timestamp}
self.detail_text.insert(tk.END, "用户输入:\n", 'label') **状态:** {status_text}
self.detail_text.insert(tk.END, f"{record.user_input}\n\n") **耗时:** {record.duration_ms}ms
# 执行状态 ---
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") ### 用户输入
{record.user_input}
# 执行计划 ---
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") {record.execution_plan}
---
### 生成的代码
```python
{record.code}
```
"""
# 输出
if record.stdout: if record.stdout:
self.detail_text.insert(tk.END, "输出:\n", 'label') md_content += f"""---
self.detail_text.insert(tk.END, f"{record.stdout}\n\n")
### 输出
{record.stdout}
"""
# 错误
if record.stderr: if record.stderr:
self.detail_text.insert(tk.END, "错误:\n", 'label') md_content += f"""---
self.detail_text.insert(tk.END, f"{record.stderr}\n", 'error')
self.detail_text.config(state=tk.DISABLED) ### 错误信息
{record.stderr}
"""
def _show_detail(self, text: str): self.detail_text.render_markdown(md_content)
"""显示详情文本"""
def _show_empty_state(self):
"""显示空状态"""
self.detail_text.config(state=tk.NORMAL) self.detail_text.config(state=tk.NORMAL)
self.detail_text.delete(1.0, tk.END) 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) self.detail_text.config(state=tk.DISABLED)
def _open_log(self): def _open_log(self):
"""打开日志文件""" """打开日志文件"""
if self._selected_record and self._selected_record.log_path: if self._selected_record and self._selected_record.log_path:
import os
log_path = Path(self._selected_record.log_path) log_path = Path(self._selected_record.log_path)
if log_path.exists(): if log_path.exists():
os.startfile(str(log_path)) os.startfile(str(log_path))
else: else:
messagebox.showwarning("提示", f"日志文件不存在:\n{log_path}") messagebox.showwarning("提示", f"日志文件不存在:\n{log_path}")
def _clear_history(self): def _reuse_code(self):
"""清空历史记录""" """复用代码"""
result = messagebox.askyesno( if self._selected_record and self.on_reuse_code:
"确认清空", self.on_reuse_code(self._selected_record)
"确定要清空所有历史记录吗?\n此操作不可恢复。",
icon='warning'
)
if result: def _retry_task(self):
self.history.clear() """重试失败的任务"""
self._load_data() if self._selected_record and self.on_retry_task:
self._show_detail("历史记录已清空") self.on_retry_task(self._selected_record)
self.open_log_btn.config(state=tk.DISABLED)
def show(self): def show(self):
"""显示视图""" """显示视图"""
@@ -332,4 +711,3 @@ class HistoryView:
def get_frame(self) -> tk.Frame: def get_frame(self) -> tk.Frame:
"""获取主框架""" """获取主框架"""
return self.frame return self.frame

370
ui/settings_view.py Normal file
View File

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