Initial commit

This commit is contained in:
Mimikko-zeus
2026-01-07 00:17:46 +08:00
commit 4b3286f546
49 changed files with 2492 additions and 0 deletions

4
.env Normal file
View File

@@ -0,0 +1,4 @@
LLM_API_URL=https://api.siliconflow.cn/v1/chat/completions
LLM_API_KEY=sk-fxsxbgatrjjhsnjpkdfgfngukqoqqgitjpxfqfxifcipaqpc
INTENT_MODEL_NAME=Qwen/Qwen2.5-7B-Instruct
GENERATION_MODEL_NAME=Qwen/Qwen2.5-72B-Instruct

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
LLM_API_URL=https://api.siliconflow.cn/v1/chat/completions
LLM_API_KEY=sk-fxsxbgatrjjhsnjpkdfgfngukqoqqgitjpxfqfxifcipaqpc
INTENT_MODEL_NAME=Qwen/Qwen2.5-7B-Instruct
GENERATION_MODEL_NAME=Qwen/Qwen2.5-72B-Instruct

233
PRD.md Normal file
View File

@@ -0,0 +1,233 @@
====================
【产品目标】
====================
- 面向 Windows 小白用户:一句话输入
- 自动判断任务类型:
- chat普通对话如“今天天气怎么样”
- execution本地执行任务文件处理
- chat
- 直接调用 LLM 返回文本
- execution
- 生成执行计划
- 生成 Python 代码
- 安全校验
- 用户确认
- 一次性子进程执行
- 强制工作区副本目录:
workspace/input
workspace/output
workspace/logs
- MVP 明确不做:
- 联网任务(搜索 / 爬取)
- 鼠标 / 键盘自动化
- 后台常驻
- 多任务并行
- 核心安全原则:
- LLM 可以联网“思考”
- Executor执行器禁止联网“动手”
====================
【项目结构(必须严格按此生成)】
====================
LocalAgent/
main.py
requirements.txt
.env.example
ui/
chat_view.py
task_guide_view.py
llm/
client.py
prompts.py
intent/
classifier.py
labels.py
safety/
rule_checker.py
llm_reviewer.py
executor/
sandbox_runner.py
workspace/
input/
output/
logs/
====================
【统一 LLM 调用规则(非常重要)】
====================
- 所有模型(包括 qwen2.5:7b-instruct都通过同一个 API
https://api.siliconflow.cn/v1/chat/completions
- 不区分“本地 / 云端”客户端
- 区分只体现在:
- model name
- prompt
- temperature / max_tokens
.env.example 中必须包含:
LLM_API_URL=https://api.siliconflow.cn/v1/chat/completions
LLM_API_KEY=your_api_key_here
# 用于意图识别的小模型
INTENT_MODEL_NAME=qwen2.5:7b-instruct
# 用于对话 / 计划 / 代码生成的模型(可先用同一个)
GENERATION_MODEL_NAME=Pro/zai-org/GLM-4.7
====================
【llm/client.py 要求】
====================
实现统一的 LLMClient
- 使用 requests.post
- URL / API KEY 从 .env 读取
- 提供方法:
chat(
messages: list[dict],
model: str,
temperature: float,
max_tokens: int
) -> str
- payload 结构参考:
{
"model": model,
"messages": messages,
"stream": false,
"temperature": temperature,
"max_tokens": max_tokens
}
- headers:
Authorization: Bearer <API_KEY>
- 对网络异常 / 非 200 状态码做明确异常抛出
- 不要在 client 中写任何业务逻辑
====================
【意图识别(核心修改点)】
====================
实现 intent/classifier.py
- 使用“小参数 LLM”INTENT_MODEL_NAME例如 qwen2.5:7b-instruct
- 目标:二分类
- chat
- execution
- 要求输出结构化结果:
{
"label": "chat" | "execution",
"confidence": 0.0 ~ 1.0,
"reason": "中文解释,说明为什么这是执行任务/对话任务"
}
- Prompt 必须极短、强约束、可解析
- Prompt 模板放在 llm/prompts.py
- 对 LLM 输出:
- 尝试解析 JSON
- 若解析失败 / 字段缺失 → 走兜底逻辑(判为 chat
intent/labels.py
- 定义常量:
CHAT
EXECUTION
- 定义阈值:
EXECUTION_CONFIDENCE_THRESHOLD = 0.6
- 低于阈值一律判定为 chat宁可少执行不可误执行
====================
【Chat Task 流程】
====================
- 使用 GENERATION_MODEL_NAME
- messages = 用户原始输入
- 返回文本直接展示
- 不触碰本地、不产出文件
====================
【Execution Task 流程】
====================
1) 生成执行计划
- 可用 GENERATION_MODEL_NAME
- 输出中文、可读
- 明确:
- 会做什么
- 不会动原文件
- 输入 / 输出目录
- 可能失败的情况
2) 生成 Python 执行代码
- MVP 先内置“安全示例代码”:
- 遍历 workspace/input
- 复制文件到 workspace/output
- 不依赖第三方库
- 不修改原文件
- 保存为 workspace/task_<id>.py
3) safety/rule_checker.py硬规则
- 静态扫描执行代码:
- 禁止 requests / socket / urllib
- 禁止访问非 workspace 路径
- 禁止危险操作os.remove, shutil.rmtree, subprocess 等)
- 若违反,直接 fail
4) safety/llm_reviewer.py软规则
- 使用 GENERATION_MODEL_NAME
- 输入:用户需求 + 执行计划 + 代码
- 输出pass / fail + 中文原因
5) UI小白引导式方案 C
- 显示:
- 判定原因 reason
- 三步引导:
1) 把文件复制到 input
2) 我来处理
3) 去 output 取
- 执行计划
- 风险提示
- 【开始执行】按钮
6) executor/sandbox_runner.py
- 使用 subprocess 启动一次性 Python 子进程
- 工作目录限定为 workspace
- 捕获 stdout / stderr
- 写入 workspace/logs/task_<id>.log
- 执行完即退出
- 执行器层不允许任何联网能力(由 rule_checker 保证)
====================
【UITkinter最小可跑要求】
====================
- main.py 启动 Tkinter 窗口
- 顶部:输入框 + 发送按钮
- 中部:输出区
- 当识别为 execution
- 切换或弹出 task_guide_view
- 执行完成后展示:
- success / partial / failed
- 成功 / 失败数量
- 日志路径
====================
【requirements.txt最小集
====================
- python-dotenv
- requests
====================
【最小可跑验收标准】
====================
- 未配置 LLM_KEY 时给出明确错误提示
- 输入“今天天气怎么样” → chat
- 输入“把这个文件夹里的图片复制一份” → execution
- execution 能生成 task_<id>.py 并真正执行
- output / logs 中有真实文件
====================
【Plan 模式输出要求】
====================
1) 先输出整体实现计划(步骤、模块职责)
2) 列出所有文件及其责任说明
3) 再按文件路径逐个输出代码内容
4) 确保 main.py 可直接运行
5) main.py 顶部注释说明:
- 如何配置 .env
- 如何运行
- 如何测试(往 input 放文件)

Binary file not shown.

25
debug_env.py Normal file
View File

@@ -0,0 +1,25 @@
"""调试脚本"""
from pathlib import Path
from dotenv import load_dotenv
import os
ENV_PATH = Path(__file__).parent / ".env"
print(f"ENV_PATH: {ENV_PATH}")
print(f"ENV_PATH exists: {ENV_PATH.exists()}")
# 读取文件内容
if ENV_PATH.exists():
print(f"File content:")
print(ENV_PATH.read_text(encoding='utf-8'))
# 加载环境变量
result = load_dotenv(ENV_PATH)
print(f"load_dotenv result: {result}")
# 检查环境变量
print(f"LLM_API_URL: {os.getenv('LLM_API_URL')}")
print(f"LLM_API_KEY: {os.getenv('LLM_API_KEY')}")
print(f"INTENT_MODEL_NAME: {os.getenv('INTENT_MODEL_NAME')}")
print(f"GENERATION_MODEL_NAME: {os.getenv('GENERATION_MODEL_NAME')}")

2
executor/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# 执行器模块

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

240
executor/sandbox_runner.py Normal file
View File

@@ -0,0 +1,240 @@
"""
沙箱执行器
在受限环境中执行生成的 Python 代码
"""
import os
import sys
import subprocess
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from dataclasses import dataclass
@dataclass
class ExecutionResult:
"""执行结果"""
success: bool
task_id: str
stdout: str
stderr: str
return_code: int
log_path: str
duration_ms: int
class SandboxRunner:
"""
沙箱执行器
特性:
1. 使用 subprocess 启动独立 Python 进程
2. 工作目录限定为 workspace
3. 捕获所有输出
4. 写入日志文件
"""
def __init__(self, workspace_path: Optional[str] = None):
if workspace_path:
self.workspace = Path(workspace_path)
else:
# 默认使用项目根目录下的 workspace
self.workspace = Path(__file__).parent.parent / "workspace"
self.input_dir = self.workspace / "input"
self.output_dir = self.workspace / "output"
self.logs_dir = self.workspace / "logs"
# 确保目录存在
self.input_dir.mkdir(parents=True, exist_ok=True)
self.output_dir.mkdir(parents=True, exist_ok=True)
self.logs_dir.mkdir(parents=True, exist_ok=True)
def save_task_code(self, code: str, task_id: Optional[str] = None) -> tuple[str, Path]:
"""
保存任务代码到文件
Args:
code: Python 代码
task_id: 任务 ID可选自动生成
Returns:
(task_id, code_path)
"""
if not task_id:
task_id = self._generate_task_id()
code_path = self.workspace / f"task_{task_id}.py"
code_path.write_text(code, encoding='utf-8')
return task_id, code_path
def execute(self, code: str, task_id: Optional[str] = None, timeout: int = 60) -> ExecutionResult:
"""
执行代码
Args:
code: Python 代码
task_id: 任务 ID
timeout: 超时时间(秒)
Returns:
ExecutionResult: 执行结果
"""
# 保存代码
task_id, code_path = self.save_task_code(code, task_id)
# 准备日志
log_path = self.logs_dir / f"task_{task_id}.log"
start_time = datetime.now()
try:
# 使用 subprocess 执行
result = subprocess.run(
[sys.executable, str(code_path)],
cwd=str(self.workspace),
capture_output=True,
text=True,
timeout=timeout,
# 不继承父进程的环境变量中的网络代理等
env=self._get_safe_env()
)
end_time = datetime.now()
duration_ms = int((end_time - start_time).total_seconds() * 1000)
# 写入日志
self._write_log(
log_path=log_path,
task_id=task_id,
code_path=code_path,
stdout=result.stdout,
stderr=result.stderr,
return_code=result.returncode,
duration_ms=duration_ms
)
return ExecutionResult(
success=result.returncode == 0,
task_id=task_id,
stdout=result.stdout,
stderr=result.stderr,
return_code=result.returncode,
log_path=str(log_path),
duration_ms=duration_ms
)
except subprocess.TimeoutExpired:
end_time = datetime.now()
duration_ms = int((end_time - start_time).total_seconds() * 1000)
error_msg = f"执行超时(超过 {timeout} 秒)"
self._write_log(
log_path=log_path,
task_id=task_id,
code_path=code_path,
stdout="",
stderr=error_msg,
return_code=-1,
duration_ms=duration_ms
)
return ExecutionResult(
success=False,
task_id=task_id,
stdout="",
stderr=error_msg,
return_code=-1,
log_path=str(log_path),
duration_ms=duration_ms
)
except Exception as e:
end_time = datetime.now()
duration_ms = int((end_time - start_time).total_seconds() * 1000)
error_msg = f"执行异常: {str(e)}"
self._write_log(
log_path=log_path,
task_id=task_id,
code_path=code_path,
stdout="",
stderr=error_msg,
return_code=-1,
duration_ms=duration_ms
)
return ExecutionResult(
success=False,
task_id=task_id,
stdout="",
stderr=error_msg,
return_code=-1,
log_path=str(log_path),
duration_ms=duration_ms
)
def _generate_task_id(self) -> str:
"""生成任务 ID"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
short_uuid = uuid.uuid4().hex[:6]
return f"{timestamp}_{short_uuid}"
def _get_safe_env(self) -> dict:
"""获取安全的环境变量(移除网络代理等)"""
safe_env = os.environ.copy()
# 移除可能的网络代理设置
proxy_vars = [
'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy',
'ALL_PROXY', 'all_proxy', 'NO_PROXY', 'no_proxy'
]
for var in proxy_vars:
safe_env.pop(var, None)
return safe_env
def _write_log(
self,
log_path: Path,
task_id: str,
code_path: Path,
stdout: str,
stderr: str,
return_code: int,
duration_ms: int
):
"""写入执行日志"""
log_content = f"""========================================
任务执行日志
========================================
任务 ID: {task_id}
代码文件: {code_path}
执行时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
耗时: {duration_ms} ms
返回码: {return_code}
状态: {"成功" if return_code == 0 else "失败"}
========================================
标准输出 (stdout)
========================================
{stdout if stdout else "(无输出)"}
========================================
标准错误 (stderr)
========================================
{stderr if stderr else "(无错误)"}
"""
log_path.write_text(log_content, encoding='utf-8')
def run_task(code: str, task_id: Optional[str] = None) -> ExecutionResult:
"""便捷函数:执行任务"""
runner = SandboxRunner()
return runner.execute(code, task_id)

2
intent/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# 意图识别模块

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

152
intent/classifier.py Normal file
View File

@@ -0,0 +1,152 @@
"""
意图识别器
使用小参数 LLM 进行意图二分类
"""
import os
import json
from pathlib import Path
from typing import Optional
from dataclasses import dataclass
from dotenv import load_dotenv
from llm.client import get_client, LLMClientError, ENV_PATH
from llm.prompts import INTENT_CLASSIFICATION_SYSTEM, INTENT_CLASSIFICATION_USER
from intent.labels import CHAT, EXECUTION, EXECUTION_CONFIDENCE_THRESHOLD, VALID_LABELS
@dataclass
class IntentResult:
"""意图识别结果"""
label: str # chat 或 execution
confidence: float # 0.0 ~ 1.0
reason: str # 中文解释
raw_response: Optional[str] = None # 原始 LLM 响应(调试用)
class IntentClassifier:
"""
意图分类器
使用小参数 LLM如 qwen2.5:7b-instruct进行快速意图识别
"""
def __init__(self):
load_dotenv(ENV_PATH)
self.model_name = os.getenv("INTENT_MODEL_NAME")
def classify(self, user_input: str) -> IntentResult:
"""
对用户输入进行意图分类
Args:
user_input: 用户输入的文本
Returns:
IntentResult: 包含 label, confidence, reason 的结果
"""
try:
client = get_client()
messages = [
{"role": "system", "content": INTENT_CLASSIFICATION_SYSTEM},
{"role": "user", "content": INTENT_CLASSIFICATION_USER.format(user_input=user_input)}
]
response = client.chat(
messages=messages,
model=self.model_name,
temperature=0.1, # 低温度,更确定性的输出
max_tokens=256
)
return self._parse_response(response)
except LLMClientError as e:
# LLM 调用失败,走兜底逻辑
return IntentResult(
label=CHAT,
confidence=0.0,
reason=f"意图识别失败({str(e)}),默认为对话模式"
)
except Exception as e:
# 其他异常,走兜底逻辑
return IntentResult(
label=CHAT,
confidence=0.0,
reason=f"意图识别异常({str(e)}),默认为对话模式"
)
def _parse_response(self, response: str) -> IntentResult:
"""
解析 LLM 响应
尝试解析 JSON若失败则走兜底逻辑
"""
try:
# 尝试提取 JSONLLM 可能会在 JSON 前后加一些文字)
json_str = self._extract_json(response)
data = json.loads(json_str)
# 验证必要字段
label = data.get("label", "").lower()
confidence = float(data.get("confidence", 0.0))
reason = data.get("reason", "")
# 验证 label 有效性
if label not in VALID_LABELS:
return IntentResult(
label=CHAT,
confidence=0.0,
reason=f"无效的意图标签 '{label}',默认为对话模式",
raw_response=response
)
# 应用置信度阈值
if label == EXECUTION and confidence < EXECUTION_CONFIDENCE_THRESHOLD:
return IntentResult(
label=CHAT,
confidence=confidence,
reason=f"执行任务置信度不足({confidence:.2f} < {EXECUTION_CONFIDENCE_THRESHOLD}),降级为对话模式。原因: {reason}",
raw_response=response
)
return IntentResult(
label=label,
confidence=confidence,
reason=reason,
raw_response=response
)
except (json.JSONDecodeError, ValueError, TypeError) as e:
# JSON 解析失败,走兜底逻辑
return IntentResult(
label=CHAT,
confidence=0.0,
reason=f"响应解析失败,默认为对话模式",
raw_response=response
)
def _extract_json(self, text: str) -> str:
"""
从文本中提取 JSON 字符串
LLM 可能会在 JSON 前后添加解释文字,需要提取纯 JSON 部分
"""
# 尝试找到 JSON 对象的起止位置
start = text.find('{')
end = text.rfind('}')
if start != -1 and end != -1 and end > start:
return text[start:end + 1]
# 如果找不到,返回原文本让 json.loads 报错
return text
# 便捷函数
def classify_intent(user_input: str) -> IntentResult:
"""快速进行意图分类"""
classifier = IntentClassifier()
return classifier.classify(user_input)

15
intent/labels.py Normal file
View File

@@ -0,0 +1,15 @@
"""
意图标签定义
"""
# 意图类型常量
CHAT = "chat"
EXECUTION = "execution"
# 执行任务置信度阈值
# 低于此阈值一律判定为 chat宁可少执行不可误执行
EXECUTION_CONFIDENCE_THRESHOLD = 0.6
# 所有有效标签
VALID_LABELS = {CHAT, EXECUTION}

2
llm/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# LLM 模块

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

124
llm/client.py Normal file
View File

@@ -0,0 +1,124 @@
"""
LLM 统一调用客户端
所有模型通过 SiliconFlow API 调用
"""
import os
import requests
from pathlib import Path
from typing import Optional
from dotenv import load_dotenv
# 获取项目根目录
PROJECT_ROOT = Path(__file__).parent.parent
ENV_PATH = PROJECT_ROOT / ".env"
class LLMClientError(Exception):
"""LLM 客户端异常"""
pass
class LLMClient:
"""
统一的 LLM 调用客户端
使用方式:
client = LLMClient()
response = client.chat(
messages=[{"role": "user", "content": "你好"}],
model="Qwen/Qwen2.5-7B-Instruct",
temperature=0.7,
max_tokens=1024
)
"""
def __init__(self):
load_dotenv(ENV_PATH)
self.api_url = os.getenv("LLM_API_URL")
self.api_key = os.getenv("LLM_API_KEY")
if not self.api_url:
raise LLMClientError("未配置 LLM_API_URL请检查 .env 文件")
if not self.api_key or self.api_key == "your_api_key_here":
raise LLMClientError("未配置有效的 LLM_API_KEY请检查 .env 文件")
def chat(
self,
messages: list[dict],
model: str,
temperature: float = 0.7,
max_tokens: int = 1024
) -> str:
"""
调用 LLM 进行对话
Args:
messages: 消息列表,格式为 [{"role": "user/assistant/system", "content": "..."}]
model: 模型名称
temperature: 温度参数,控制随机性
max_tokens: 最大生成 token 数
Returns:
LLM 生成的文本内容
Raises:
LLMClientError: 网络异常或 API 返回错误
"""
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
payload = {
"model": model,
"messages": messages,
"stream": False,
"temperature": temperature,
"max_tokens": max_tokens
}
try:
response = requests.post(
self.api_url,
headers=headers,
json=payload,
timeout=60
)
except requests.exceptions.Timeout:
raise LLMClientError("请求超时,请检查网络连接")
except requests.exceptions.ConnectionError:
raise LLMClientError("网络连接失败,请检查网络设置")
except requests.exceptions.RequestException as e:
raise LLMClientError(f"网络请求异常: {str(e)}")
if response.status_code != 200:
error_msg = f"API 返回错误 (状态码: {response.status_code})"
try:
error_detail = response.json()
if "error" in error_detail:
error_msg += f": {error_detail['error']}"
except:
error_msg += f": {response.text[:200]}"
raise LLMClientError(error_msg)
try:
result = response.json()
content = result["choices"][0]["message"]["content"]
return content
except (KeyError, IndexError, TypeError) as e:
raise LLMClientError(f"解析 API 响应失败: {str(e)}")
# 全局单例(延迟初始化)
_client: Optional[LLMClient] = None
def get_client() -> LLMClient:
"""获取 LLM 客户端单例"""
global _client
if _client is None:
_client = LLMClient()
return _client

130
llm/prompts.py Normal file
View File

@@ -0,0 +1,130 @@
"""
Prompt 模板集合
所有与 LLM 交互的 Prompt 统一在此管理
"""
# ========================================
# 意图识别 Prompt
# ========================================
INTENT_CLASSIFICATION_SYSTEM = """你是一个意图分类器。判断用户输入是"普通对话"还是"本地执行任务"
规则:
- chat: 闲聊、问答、知识查询(如天气、新闻、解释概念)
- execution: 需要操作本地文件的任务(如复制、移动、重命名、整理文件)
只输出JSON格式
{"label": "chat或execution", "confidence": 0.0到1.0, "reason": "简短中文理由"}"""
INTENT_CLASSIFICATION_USER = """判断以下输入的意图:
{user_input}"""
# ========================================
# 执行计划生成 Prompt
# ========================================
EXECUTION_PLAN_SYSTEM = """你是一个任务规划助手。根据用户需求,生成清晰的执行计划。
约束:
1. 所有操作只在 workspace 目录内进行
2. 输入文件来自 workspace/input
3. 输出文件保存到 workspace/output
4. 绝不修改或删除原始文件
5. 不进行任何网络操作
输出格式(中文):
## 任务理解
[简述用户想做什么]
## 执行步骤
1. [步骤1]
2. [步骤2]
...
## 输入输出
- 输入目录: workspace/input
- 输出目录: workspace/output
## 风险提示
[可能失败的情况]"""
EXECUTION_PLAN_USER = """用户需求:{user_input}
请生成执行计划。"""
# ========================================
# 代码生成 Prompt
# ========================================
CODE_GENERATION_SYSTEM = """你是一个 Python 代码生成器。根据执行计划生成安全的文件处理代码。
硬性约束:
1. 只能操作 workspace/input 和 workspace/output 目录
2. 禁止使用: requests, socket, urllib, subprocess, os.system
3. 禁止删除文件: os.remove, shutil.rmtree, os.unlink
4. 禁止访问 workspace 外的任何路径
5. 只使用标准库: os, shutil, pathlib, json, csv 等
代码模板:
```python
import os
import shutil
from pathlib import Path
# 工作目录
WORKSPACE = Path(__file__).parent
INPUT_DIR = WORKSPACE / "input"
OUTPUT_DIR = WORKSPACE / "output"
def main():
# 确保输出目录存在
OUTPUT_DIR.mkdir(exist_ok=True)
# TODO: 实现具体逻辑
print("任务完成")
if __name__ == "__main__":
main()
```
只输出 Python 代码,不要其他解释。"""
CODE_GENERATION_USER = """执行计划:
{execution_plan}
用户原始需求:{user_input}
请生成 Python 代码。"""
# ========================================
# 安全审查 Prompt
# ========================================
SAFETY_REVIEW_SYSTEM = """你是一个代码安全审查员。检查代码是否符合安全规范。
检查项:
1. 是否只操作 workspace 目录
2. 是否有网络请求代码
3. 是否有危险的文件删除操作
4. 是否有执行外部命令的代码
5. 代码逻辑是否与用户需求一致
输出JSON格式
{"pass": true或false, "reason": "中文审查结论"}"""
SAFETY_REVIEW_USER = """用户需求:{user_input}
执行计划:
{execution_plan}
待审查代码:
```python
{code}
```
请进行安全审查。"""

518
main.py Normal file
View File

@@ -0,0 +1,518 @@
"""
LocalAgent - Windows 本地 AI 执行助手 (MVP)
========================================
配置说明
========================================
1. 复制 .env.example 为 .env
2. 在 .env 中填入你的 SiliconFlow API Key:
LLM_API_KEY=sk-xxxxx
========================================
运行方式
========================================
方式一:使用 Anaconda
conda create -n localagent python=3.10
conda activate localagent
pip install -r requirements.txt
python main.py
方式二:直接运行(需已安装依赖)
python main.py
========================================
测试方法
========================================
1. 对话测试:输入 "今天天气怎么样" → 应识别为 chat
2. 执行测试:
- 将测试文件放入 workspace/input 目录
- 输入 "把这些文件复制一份" → 应识别为 execution
- 确认执行后,检查 workspace/output 目录
========================================
"""
import os
import sys
import tkinter as tk
from tkinter import messagebox
from pathlib import Path
from typing import Optional
from dotenv import load_dotenv
import threading
import queue
# 确保项目根目录在 Python 路径中
PROJECT_ROOT = Path(__file__).parent
ENV_PATH = PROJECT_ROOT / ".env"
sys.path.insert(0, str(PROJECT_ROOT))
# 在导入其他模块之前先加载环境变量
load_dotenv(ENV_PATH)
from llm.client import get_client, LLMClientError
from llm.prompts import (
EXECUTION_PLAN_SYSTEM, EXECUTION_PLAN_USER,
CODE_GENERATION_SYSTEM, CODE_GENERATION_USER
)
from intent.classifier import classify_intent, IntentResult
from intent.labels import CHAT, EXECUTION
from safety.rule_checker import check_code_safety
from safety.llm_reviewer import review_code_safety
from executor.sandbox_runner import SandboxRunner, ExecutionResult
from ui.chat_view import ChatView
from ui.task_guide_view import TaskGuideView
class LocalAgentApp:
"""
LocalAgent 主应用
职责:
1. 管理 UI 状态切换
2. 协调各模块工作流程
3. 处理用户交互
"""
def __init__(self):
self.workspace = PROJECT_ROOT / "workspace"
self.runner = SandboxRunner(str(self.workspace))
# 当前任务状态
self.current_task: Optional[dict] = None
# 线程通信队列
self.result_queue = queue.Queue()
# 初始化 UI
self._init_ui()
def _init_ui(self):
"""初始化 UI"""
self.root = tk.Tk()
self.root.title("LocalAgent - 本地 AI 助手")
self.root.geometry("800x700")
self.root.configure(bg='#1e1e1e')
# 设置窗口图标(如果有的话)
try:
self.root.iconbitmap(PROJECT_ROOT / "icon.ico")
except:
pass
# 主容器
self.main_container = tk.Frame(self.root, bg='#1e1e1e')
self.main_container.pack(fill=tk.BOTH, expand=True)
# 聊天视图
self.chat_view = ChatView(self.main_container, self._on_user_input)
# 任务引导视图(初始隐藏)
self.task_view: Optional[TaskGuideView] = None
# 定期检查后台任务结果
self._check_queue()
def _check_queue(self):
"""检查后台任务队列"""
try:
while True:
callback, args = self.result_queue.get_nowait()
callback(*args)
except queue.Empty:
pass
# 每 100ms 检查一次
self.root.after(100, self._check_queue)
def _run_in_thread(self, func, callback, *args):
"""在后台线程运行函数,完成后回调"""
def wrapper():
try:
result = func(*args)
self.result_queue.put((callback, (result, None)))
except Exception as e:
self.result_queue.put((callback, (None, e)))
thread = threading.Thread(target=wrapper, daemon=True)
thread.start()
def _on_user_input(self, user_input: str):
"""处理用户输入"""
# 显示用户消息
self.chat_view.add_message(user_input, 'user')
self.chat_view.set_input_enabled(False)
self.chat_view.add_message("正在分析您的需求...", 'system')
# 在后台线程进行意图识别
self._run_in_thread(
classify_intent,
lambda result, error: self._on_intent_result(user_input, result, error),
user_input
)
def _on_intent_result(self, user_input: str, intent_result: Optional[IntentResult], error: Optional[Exception]):
"""意图识别完成回调"""
if error:
self.chat_view.add_message(f"意图识别失败: {str(error)}", 'error')
self.chat_view.set_input_enabled(True)
return
if intent_result.label == CHAT:
# 对话模式
self._handle_chat(user_input, intent_result)
else:
# 执行模式
self._handle_execution(user_input, intent_result)
def _handle_chat(self, user_input: str, intent_result: IntentResult):
"""处理对话任务"""
self.chat_view.add_message(
f"识别为对话模式 (原因: {intent_result.reason})",
'system'
)
self.chat_view.add_message("正在生成回复...", 'system')
# 在后台线程调用 LLM
def do_chat():
client = get_client()
model = os.getenv("GENERATION_MODEL_NAME")
return client.chat(
messages=[{"role": "user", "content": user_input}],
model=model,
temperature=0.7,
max_tokens=2048
)
self._run_in_thread(
do_chat,
self._on_chat_result
)
def _on_chat_result(self, response: Optional[str], error: Optional[Exception]):
"""对话完成回调"""
if error:
self.chat_view.add_message(f"对话失败: {str(error)}", 'error')
else:
self.chat_view.add_message(response, 'assistant')
self.chat_view.set_input_enabled(True)
def _handle_execution(self, user_input: str, intent_result: IntentResult):
"""处理执行任务"""
self.chat_view.add_message(
f"识别为执行任务 (置信度: {intent_result.confidence:.0%})\n原因: {intent_result.reason}",
'system'
)
self.chat_view.add_message("正在生成执行计划...", 'system')
# 保存用户输入和意图结果
self.current_task = {
'user_input': user_input,
'intent_result': intent_result
}
# 在后台线程生成执行计划
self._run_in_thread(
self._generate_execution_plan,
self._on_plan_generated,
user_input
)
def _on_plan_generated(self, plan: Optional[str], error: Optional[Exception]):
"""执行计划生成完成回调"""
if error:
self.chat_view.add_message(f"生成执行计划失败: {str(error)}", 'error')
self.chat_view.set_input_enabled(True)
self.current_task = None
return
self.current_task['execution_plan'] = plan
self.chat_view.add_message("正在生成执行代码...", 'system')
# 在后台线程生成代码
self._run_in_thread(
self._generate_code,
self._on_code_generated,
self.current_task['user_input'],
plan
)
def _on_code_generated(self, code: Optional[str], error: Optional[Exception]):
"""代码生成完成回调"""
if error:
self.chat_view.add_message(f"生成代码失败: {str(error)}", 'error')
self.chat_view.set_input_enabled(True)
self.current_task = None
return
self.current_task['code'] = code
self.chat_view.add_message("正在进行安全检查...", 'system')
# 硬规则检查(同步,很快)
rule_result = check_code_safety(code)
if not rule_result.passed:
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
# 在后台线程进行 LLM 安全审查
self._run_in_thread(
review_code_safety,
self._on_safety_reviewed,
self.current_task['user_input'],
self.current_task['execution_plan'],
code
)
def _on_safety_reviewed(self, review_result, error: Optional[Exception]):
"""安全审查完成回调"""
if error:
self.chat_view.add_message(f"安全审查失败: {str(error)}", 'error')
self.chat_view.set_input_enabled(True)
self.current_task = None
return
if not review_result.passed:
self.chat_view.add_message(
f"安全审查未通过: {review_result.reason}",
'error'
)
self.chat_view.set_input_enabled(True)
self.current_task = None
return
self.chat_view.add_message("安全检查通过,请确认执行", 'system')
# 显示任务引导视图
self._show_task_guide()
def _generate_execution_plan(self, user_input: str) -> str:
"""生成执行计划"""
client = get_client()
model = os.getenv("GENERATION_MODEL_NAME")
response = client.chat(
messages=[
{"role": "system", "content": EXECUTION_PLAN_SYSTEM},
{"role": "user", "content": EXECUTION_PLAN_USER.format(user_input=user_input)}
],
model=model,
temperature=0.3,
max_tokens=1024
)
return response
def _generate_code(self, user_input: str, execution_plan: str) -> str:
"""生成执行代码"""
client = get_client()
model = os.getenv("GENERATION_MODEL_NAME")
response = client.chat(
messages=[
{"role": "system", "content": CODE_GENERATION_SYSTEM},
{"role": "user", "content": CODE_GENERATION_USER.format(
user_input=user_input,
execution_plan=execution_plan
)}
],
model=model,
temperature=0.2,
max_tokens=2048
)
# 提取代码块
code = self._extract_code(response)
return code
def _extract_code(self, response: str) -> str:
"""从 LLM 响应中提取代码"""
import re
# 尝试提取 ```python ... ``` 代码块
pattern = r'```python\s*(.*?)\s*```'
matches = re.findall(pattern, response, re.DOTALL)
if matches:
return matches[0]
# 尝试提取 ``` ... ``` 代码块
pattern = r'```\s*(.*?)\s*```'
matches = re.findall(pattern, response, re.DOTALL)
if matches:
return matches[0]
# 如果没有代码块,返回原始响应
return response
def _show_task_guide(self):
"""显示任务引导视图"""
if not self.current_task:
return
# 隐藏聊天视图
self.chat_view.get_frame().pack_forget()
# 创建任务引导视图
self.task_view = TaskGuideView(
self.main_container,
on_execute=self._on_execute_task,
on_cancel=self._on_cancel_task,
workspace_path=self.workspace
)
# 设置内容
self.task_view.set_intent_result(
self.current_task['intent_result'].reason,
self.current_task['intent_result'].confidence
)
self.task_view.set_execution_plan(self.current_task['execution_plan'])
# 显示
self.task_view.show()
def _on_execute_task(self):
"""执行任务"""
if not self.current_task:
return
self.task_view.set_buttons_enabled(False)
# 在后台线程执行
def do_execute():
return self.runner.execute(self.current_task['code'])
self._run_in_thread(
do_execute,
self._on_execution_complete
)
def _on_execution_complete(self, result: Optional[ExecutionResult], error: Optional[Exception]):
"""执行完成回调"""
if error:
messagebox.showerror("执行错误", f"执行失败: {str(error)}")
else:
self._show_execution_result(result)
# 刷新输出文件列表
if self.task_view:
self.task_view.refresh_output()
self._back_to_chat()
def _show_execution_result(self, result: ExecutionResult):
"""显示执行结果"""
if result.success:
status = "执行成功"
else:
status = "执行失败"
message = f"""{status}
任务 ID: {result.task_id}
耗时: {result.duration_ms} ms
日志文件: {result.log_path}
输出:
{result.stdout if result.stdout else '(无输出)'}
{f'错误信息: {result.stderr}' if result.stderr else ''}
"""
if result.success:
messagebox.showinfo("执行结果", message)
# 打开 output 目录
os.startfile(str(self.workspace / "output"))
else:
messagebox.showerror("执行结果", message)
def _on_cancel_task(self):
"""取消任务"""
self.current_task = None
self._back_to_chat()
def _back_to_chat(self):
"""返回聊天视图"""
if self.task_view:
self.task_view.hide()
self.task_view = None
self.chat_view.get_frame().pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
self.chat_view.set_input_enabled(True)
self.current_task = None
def run(self):
"""运行应用"""
self.root.mainloop()
def check_environment():
"""检查运行环境"""
load_dotenv(ENV_PATH)
api_key = os.getenv("LLM_API_KEY")
if not api_key or api_key == "your_api_key_here":
print("=" * 50)
print("错误: 未配置 LLM API Key")
print("=" * 50)
print()
print("请按以下步骤配置:")
print("1. 复制 .env.example 为 .env")
print("2. 在 .env 中设置 LLM_API_KEY=你的API密钥")
print()
print("获取 API Key: https://siliconflow.cn")
print("=" * 50)
# 显示 GUI 错误提示
root = tk.Tk()
root.withdraw()
messagebox.showerror(
"配置错误",
"未配置 LLM API Key\n\n"
"请按以下步骤配置:\n"
"1. 复制 .env.example 为 .env\n"
"2. 在 .env 中设置 LLM_API_KEY=你的API密钥\n\n"
"获取 API Key: https://siliconflow.cn"
)
root.destroy()
return False
return True
def main():
"""主入口"""
print("=" * 50)
print("LocalAgent - Windows 本地 AI 执行助手")
print("=" * 50)
# 检查环境
if not check_environment():
sys.exit(1)
# 创建工作目录
workspace = PROJECT_ROOT / "workspace"
(workspace / "input").mkdir(parents=True, exist_ok=True)
(workspace / "output").mkdir(parents=True, exist_ok=True)
(workspace / "logs").mkdir(parents=True, exist_ok=True)
print(f"工作目录: {workspace}")
print(f"输入目录: {workspace / 'input'}")
print(f"输出目录: {workspace / 'output'}")
print(f"日志目录: {workspace / 'logs'}")
print("=" * 50)
# 启动应用
app = LocalAgentApp()
app.run()
if __name__ == "__main__":
main()

9
requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
# LocalAgent MVP 依赖
# 使用 Anaconda 创建虚拟环境后安装:
# conda create -n localagent python=3.10
# conda activate localagent
# pip install -r requirements.txt
python-dotenv>=1.0.0
requests>=2.31.0

2
safety/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# 安全检查模块

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

132
safety/llm_reviewer.py Normal file
View File

@@ -0,0 +1,132 @@
"""
LLM 软规则审查器
使用 LLM 进行代码安全审查
"""
import os
import json
from typing import Optional
from dataclasses import dataclass
from dotenv import load_dotenv
from llm.client import get_client, LLMClientError, ENV_PATH
from llm.prompts import SAFETY_REVIEW_SYSTEM, SAFETY_REVIEW_USER
@dataclass
class LLMReviewResult:
"""LLM 审查结果"""
passed: bool
reason: str
raw_response: Optional[str] = None
class LLMReviewer:
"""
LLM 安全审查器
使用大模型对代码进行语义级别的安全审查
"""
def __init__(self):
load_dotenv(ENV_PATH)
self.model_name = os.getenv("GENERATION_MODEL_NAME")
def review(
self,
user_input: str,
execution_plan: str,
code: str
) -> LLMReviewResult:
"""
审查代码安全性
Args:
user_input: 用户原始需求
execution_plan: 执行计划
code: 待审查的代码
Returns:
LLMReviewResult: 审查结果
"""
try:
client = get_client()
messages = [
{"role": "system", "content": SAFETY_REVIEW_SYSTEM},
{"role": "user", "content": SAFETY_REVIEW_USER.format(
user_input=user_input,
execution_plan=execution_plan,
code=code
)}
]
response = client.chat(
messages=messages,
model=self.model_name,
temperature=0.1,
max_tokens=512
)
return self._parse_response(response)
except LLMClientError as e:
# LLM 调用失败,保守起见判定为不通过
return LLMReviewResult(
passed=False,
reason=f"安全审查失败({str(e)}),出于安全考虑拒绝执行"
)
except Exception as e:
return LLMReviewResult(
passed=False,
reason=f"安全审查异常({str(e)}),出于安全考虑拒绝执行"
)
def _parse_response(self, response: str) -> LLMReviewResult:
"""解析 LLM 响应"""
try:
# 提取 JSON
json_str = self._extract_json(response)
data = json.loads(json_str)
passed = data.get("pass", False)
reason = data.get("reason", "未提供原因")
# 确保 passed 是布尔值
if isinstance(passed, str):
passed = passed.lower() in ('true', 'yes', '1', 'pass')
return LLMReviewResult(
passed=bool(passed),
reason=reason,
raw_response=response
)
except (json.JSONDecodeError, ValueError, TypeError):
# 解析失败,保守判定
return LLMReviewResult(
passed=False,
reason=f"审查结果解析失败,出于安全考虑拒绝执行",
raw_response=response
)
def _extract_json(self, text: str) -> str:
"""从文本中提取 JSON"""
start = text.find('{')
end = text.rfind('}')
if start != -1 and end != -1 and end > start:
return text[start:end + 1]
return text
def review_code_safety(
user_input: str,
execution_plan: str,
code: str
) -> LLMReviewResult:
"""便捷函数:审查代码安全性"""
reviewer = LLMReviewer()
return reviewer.review(user_input, execution_plan, code)

208
safety/rule_checker.py Normal file
View File

@@ -0,0 +1,208 @@
"""
硬规则安全检查器
静态扫描执行代码,检测危险操作
"""
import re
import ast
from typing import List, Tuple
from dataclasses import dataclass
@dataclass
class RuleCheckResult:
"""规则检查结果"""
passed: bool
violations: List[str] # 违规项列表
class RuleChecker:
"""
硬规则检查器
静态扫描代码,检测以下危险操作:
1. 网络请求: requests, socket, urllib, http.client
2. 危险文件操作: os.remove, shutil.rmtree, os.unlink
3. 执行外部命令: subprocess, os.system, os.popen
4. 访问非 workspace 路径
"""
# 禁止导入的模块
FORBIDDEN_IMPORTS = {
'requests',
'socket',
'urllib',
'urllib.request',
'urllib.parse',
'urllib.error',
'http.client',
'httplib',
'ftplib',
'smtplib',
'telnetlib',
'subprocess',
}
# 禁止调用的函数(模块.函数 或 单独函数名)
FORBIDDEN_CALLS = {
'os.remove',
'os.unlink',
'os.rmdir',
'os.removedirs',
'os.system',
'os.popen',
'os.spawn',
'os.spawnl',
'os.spawnle',
'os.spawnlp',
'os.spawnlpe',
'os.spawnv',
'os.spawnve',
'os.spawnvp',
'os.spawnvpe',
'os.exec',
'os.execl',
'os.execle',
'os.execlp',
'os.execlpe',
'os.execv',
'os.execve',
'os.execvp',
'os.execvpe',
'shutil.rmtree',
'shutil.move', # move 可能导致原文件丢失
'eval',
'exec',
'compile',
'__import__',
}
# 危险路径模式(正则)
DANGEROUS_PATH_PATTERNS = [
r'[A-Za-z]:\\', # Windows 绝对路径
r'\\\\', # UNC 路径
r'/etc/',
r'/usr/',
r'/bin/',
r'/home/',
r'/root/',
r'\.\./', # 父目录遍历
r'\.\.', # 父目录
]
def check(self, code: str) -> RuleCheckResult:
"""
检查代码是否符合安全规则
Args:
code: Python 代码字符串
Returns:
RuleCheckResult: 检查结果
"""
violations = []
# 1. 检查禁止的导入
import_violations = self._check_imports(code)
violations.extend(import_violations)
# 2. 检查禁止的函数调用
call_violations = self._check_calls(code)
violations.extend(call_violations)
# 3. 检查危险路径
path_violations = self._check_paths(code)
violations.extend(path_violations)
return RuleCheckResult(
passed=len(violations) == 0,
violations=violations
)
def _check_imports(self, code: str) -> List[str]:
"""检查禁止的导入"""
violations = []
try:
tree = ast.parse(code)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
module_name = alias.name.split('.')[0]
if alias.name in self.FORBIDDEN_IMPORTS or module_name in self.FORBIDDEN_IMPORTS:
violations.append(f"禁止导入模块: {alias.name}")
elif isinstance(node, ast.ImportFrom):
if node.module:
module_name = node.module.split('.')[0]
if node.module in self.FORBIDDEN_IMPORTS or module_name in self.FORBIDDEN_IMPORTS:
violations.append(f"禁止导入模块: {node.module}")
except SyntaxError:
# 如果代码有语法错误,使用正则匹配
for module in self.FORBIDDEN_IMPORTS:
pattern = rf'\bimport\s+{re.escape(module)}\b|\bfrom\s+{re.escape(module)}\b'
if re.search(pattern, code):
violations.append(f"禁止导入模块: {module}")
return violations
def _check_calls(self, code: str) -> List[str]:
"""检查禁止的函数调用"""
violations = []
try:
tree = ast.parse(code)
for node in ast.walk(tree):
if isinstance(node, ast.Call):
call_name = self._get_call_name(node)
if call_name in self.FORBIDDEN_CALLS:
violations.append(f"禁止调用函数: {call_name}")
except SyntaxError:
# 如果代码有语法错误,使用正则匹配
for func in self.FORBIDDEN_CALLS:
pattern = rf'\b{re.escape(func)}\s*\('
if re.search(pattern, code):
violations.append(f"禁止调用函数: {func}")
return violations
def _get_call_name(self, node: ast.Call) -> str:
"""获取函数调用的完整名称"""
if isinstance(node.func, ast.Name):
return node.func.id
elif isinstance(node.func, ast.Attribute):
parts = []
current = node.func
while isinstance(current, ast.Attribute):
parts.append(current.attr)
current = current.value
if isinstance(current, ast.Name):
parts.append(current.id)
return '.'.join(reversed(parts))
return ''
def _check_paths(self, code: str) -> List[str]:
"""检查危险路径访问"""
violations = []
for pattern in self.DANGEROUS_PATH_PATTERNS:
matches = re.findall(pattern, code, re.IGNORECASE)
if matches:
# 排除 workspace 相关的合法路径
for match in matches:
if 'workspace' not in code[max(0, code.find(match)-50):code.find(match)+50].lower():
violations.append(f"检测到可疑路径模式: {match}")
break
return violations
def check_code_safety(code: str) -> RuleCheckResult:
"""便捷函数:检查代码安全性"""
checker = RuleChecker()
return checker.check(code)

2
ui/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# UI 模块

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

164
ui/chat_view.py Normal file
View File

@@ -0,0 +1,164 @@
"""
聊天视图组件
处理普通对话的 UI 展示
"""
import tkinter as tk
from tkinter import scrolledtext
from typing import Callable, Optional
class ChatView:
"""
聊天视图
包含:
- 消息显示区域
- 输入框
- 发送按钮
"""
def __init__(
self,
parent: tk.Widget,
on_send: Callable[[str], None]
):
"""
初始化聊天视图
Args:
parent: 父容器
on_send: 发送消息回调函数
"""
self.parent = parent
self.on_send = on_send
self._create_widgets()
def _create_widgets(self):
"""创建 UI 组件"""
# 主框架
self.frame = tk.Frame(self.parent, bg='#1e1e1e')
self.frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# 标题
title_label = tk.Label(
self.frame,
text="LocalAgent - 本地 AI 助手",
font=('Microsoft YaHei UI', 16, 'bold'),
fg='#61dafb',
bg='#1e1e1e'
)
title_label.pack(pady=(0, 10))
# 消息显示区域
self.message_area = scrolledtext.ScrolledText(
self.frame,
wrap=tk.WORD,
font=('Microsoft YaHei UI', 11),
bg='#2d2d2d',
fg='#d4d4d4',
insertbackground='white',
relief=tk.FLAT,
padx=10,
pady=10,
state=tk.DISABLED
)
self.message_area.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
# 配置消息标签样式
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('system', foreground='#ffb74d', font=('Microsoft YaHei UI', 10, 'italic'))
self.message_area.tag_configure('error', foreground='#ef5350', font=('Microsoft YaHei UI', 10))
# 输入区域框架
input_frame = tk.Frame(self.frame, bg='#1e1e1e')
input_frame.pack(fill=tk.X)
# 输入框
self.input_entry = tk.Entry(
input_frame,
font=('Microsoft YaHei UI', 12),
bg='#3c3c3c',
fg='#ffffff',
insertbackground='white',
relief=tk.FLAT
)
self.input_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, ipady=8, padx=(0, 10))
self.input_entry.bind('<Return>', self._on_enter_pressed)
# 发送按钮
self.send_button = tk.Button(
input_frame,
text="发送",
font=('Microsoft YaHei UI', 11, 'bold'),
bg='#0078d4',
fg='white',
activebackground='#106ebe',
activeforeground='white',
relief=tk.FLAT,
padx=20,
pady=5,
cursor='hand2',
command=self._on_send_clicked
)
self.send_button.pack(side=tk.RIGHT)
# 显示欢迎消息
welcome_msg = (
"欢迎使用 LocalAgent!\n"
"- 输入问题进行对话\n"
"- 输入文件处理需求(如\"复制文件\"\"整理图片\")将触发执行模式"
)
self.add_message(welcome_msg, 'system')
def _on_enter_pressed(self, event):
"""回车键处理"""
self._on_send_clicked()
def _on_send_clicked(self):
"""发送按钮点击处理"""
text = self.input_entry.get().strip()
if text:
self.input_entry.delete(0, tk.END)
self.on_send(text)
def add_message(self, message: str, tag: str = 'assistant'):
"""
添加消息到显示区域
Args:
message: 消息内容
tag: 消息类型 (user/assistant/system/error)
"""
self.message_area.config(state=tk.NORMAL)
# 添加前缀
prefix_map = {
'user': '[你] ',
'assistant': '[助手] ',
'system': '[系统] ',
'error': '[错误] '
}
prefix = prefix_map.get(tag, '')
self.message_area.insert(tk.END, "\n" + prefix + message + "\n", tag)
self.message_area.see(tk.END)
self.message_area.config(state=tk.DISABLED)
def clear_messages(self):
"""清空消息区域"""
self.message_area.config(state=tk.NORMAL)
self.message_area.delete(1.0, tk.END)
self.message_area.config(state=tk.DISABLED)
def set_input_enabled(self, enabled: bool):
"""设置输入区域是否可用"""
state = tk.NORMAL if enabled else tk.DISABLED
self.input_entry.config(state=state)
self.send_button.config(state=state)
def get_frame(self) -> tk.Frame:
"""获取主框架"""
return self.frame

524
ui/task_guide_view.py Normal file
View File

@@ -0,0 +1,524 @@
"""
任务引导视图组件
执行任务的引导式 UI - 支持文件拖拽和 Markdown 渲染
"""
import tkinter as tk
from tkinter import scrolledtext, messagebox
from tkinter import ttk
from typing import Callable, Optional, List
from pathlib import Path
import shutil
import re
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', spacing1=10, spacing3=5)
self.tag_configure('h2', font=('Microsoft YaHei UI', 12, 'bold'), foreground='#81c784', spacing1=8, spacing3=4)
self.tag_configure('h3', font=('Microsoft YaHei UI', 11, 'bold'), foreground='#4fc3f7', spacing1=6, spacing3=3)
# 列表样式
self.tag_configure('bullet', foreground='#ce93d8', lmargin1=20, lmargin2=35)
self.tag_configure('numbered', foreground='#ce93d8', lmargin1=20, lmargin2=35)
# 代码样式
self.tag_configure('code', font=('Consolas', 10), background='#3c3c3c', foreground='#f8f8f2')
# 粗体和斜体
self.tag_configure('bold', font=('Microsoft YaHei UI', 10, 'bold'))
self.tag_configure('italic', font=('Microsoft YaHei UI', 10, 'italic'))
# 普通文本
self.tag_configure('normal', font=('Microsoft YaHei UI', 10), foreground='#d4d4d4')
def set_markdown(self, text: str):
"""设置 Markdown 内容并渲染"""
self.config(state=tk.NORMAL)
self.delete(1.0, tk.END)
lines = text.split('\n')
for line in lines:
self._render_line(line)
self.config(state=tk.DISABLED)
def _render_line(self, line: str):
"""渲染单行 Markdown"""
stripped = line.strip()
# 标题
if stripped.startswith('### '):
self.insert(tk.END, stripped[4:] + '\n', 'h3')
elif stripped.startswith('## '):
self.insert(tk.END, stripped[3:] + '\n', 'h2')
elif stripped.startswith('# '):
self.insert(tk.END, stripped[2:] + '\n', 'h1')
# 无序列表
elif stripped.startswith('- ') or stripped.startswith('* '):
self.insert(tk.END, '' + stripped[2:] + '\n', 'bullet')
# 有序列表
elif re.match(r'^\d+\.\s', stripped):
match = re.match(r'^(\d+\.)\s(.*)$', stripped)
if match:
self.insert(tk.END, ' ' + match.group(1) + ' ' + match.group(2) + '\n', 'numbered')
# 普通文本
else:
# 处理行内格式
self._render_inline(line + '\n')
def _render_inline(self, text: str):
"""渲染行内 Markdown粗体、斜体、代码"""
# 简化处理:直接插入普通文本
# 完整实现需要更复杂的解析
self.insert(tk.END, text, 'normal')
class DropZone(tk.Frame):
"""文件拖拽区域"""
def __init__(
self,
parent,
title: str,
target_dir: Path,
is_input: bool = True,
**kwargs
):
super().__init__(parent, **kwargs)
self.target_dir = target_dir
self.is_input = is_input
self.configure(bg='#2d2d2d', relief=tk.GROOVE, bd=2)
# 确保目录存在
self.target_dir.mkdir(parents=True, exist_ok=True)
self._create_widgets(title)
self._setup_drag_drop()
self._refresh_file_list()
def _create_widgets(self, title: str):
"""创建组件"""
# 标题
title_frame = tk.Frame(self, bg='#2d2d2d')
title_frame.pack(fill=tk.X, padx=5, pady=5)
tk.Label(
title_frame,
text=title,
font=('Microsoft YaHei UI', 11, 'bold'),
fg='#4fc3f7' if self.is_input else '#81c784',
bg='#2d2d2d'
).pack(side=tk.LEFT)
# 打开文件夹按钮
open_btn = tk.Button(
title_frame,
text="📂",
font=('Microsoft YaHei UI', 10),
bg='#424242',
fg='white',
relief=tk.FLAT,
cursor='hand2',
command=self._open_folder
)
open_btn.pack(side=tk.RIGHT)
# 刷新按钮
refresh_btn = tk.Button(
title_frame,
text="🔄",
font=('Microsoft YaHei UI', 10),
bg='#424242',
fg='white',
relief=tk.FLAT,
cursor='hand2',
command=self._refresh_file_list
)
refresh_btn.pack(side=tk.RIGHT, padx=(0, 5))
# 拖拽提示区域
self.drop_label = tk.Label(
self,
text="将文件拖拽到此处\n或点击 📂 打开文件夹",
font=('Microsoft YaHei UI', 10),
fg='#888888',
bg='#3c3c3c',
relief=tk.SUNKEN,
padx=20,
pady=15
)
self.drop_label.pack(fill=tk.X, padx=5, pady=5)
# 文件列表
self.file_listbox = tk.Listbox(
self,
font=('Microsoft YaHei UI', 9),
bg='#2d2d2d',
fg='#d4d4d4',
selectbackground='#0078d4',
relief=tk.FLAT,
height=4
)
self.file_listbox.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
# 文件计数
self.count_label = tk.Label(
self,
text="0 个文件",
font=('Microsoft YaHei UI', 9),
fg='#888888',
bg='#2d2d2d'
)
self.count_label.pack(pady=(0, 5))
def _setup_drag_drop(self):
"""设置拖拽功能Windows 需要 windnd 库,这里用简化方案)"""
# 由于 Tkinter 原生不支持文件拖拽,使用点击打开文件夹的方式
self.drop_label.bind('<Button-1>', lambda e: self._open_folder())
def _open_folder(self):
"""打开目标文件夹"""
import os
os.startfile(str(self.target_dir))
def _refresh_file_list(self):
"""刷新文件列表"""
self.file_listbox.delete(0, tk.END)
files = list(self.target_dir.glob('*'))
files = [f for f in files if f.is_file()]
for f in files:
self.file_listbox.insert(tk.END, f.name)
self.count_label.config(text=f"{len(files)} 个文件")
def get_files(self) -> List[Path]:
"""获取目录中的文件列表"""
files = list(self.target_dir.glob('*'))
return [f for f in files if f.is_file()]
def clear_files(self):
"""清空目录中的文件"""
for f in self.target_dir.glob('*'):
if f.is_file():
f.unlink()
self._refresh_file_list()
class TaskGuideView:
"""
任务引导视图
小白引导式界面,包含:
- 意图识别结果
- 文件拖拽区域(输入/输出)
- 执行计划展示Markdown 渲染)
- 风险提示
- 执行按钮
"""
def __init__(
self,
parent: tk.Widget,
on_execute: Callable[[], None],
on_cancel: Callable[[], None],
workspace_path: Optional[Path] = None
):
self.parent = parent
self.on_execute = on_execute
self.on_cancel = on_cancel
if workspace_path:
self.workspace = workspace_path
else:
self.workspace = Path(__file__).parent.parent / "workspace"
self.input_dir = self.workspace / "input"
self.output_dir = self.workspace / "output"
self._create_widgets()
def _create_widgets(self):
"""创建 UI 组件"""
# 主框架
self.frame = tk.Frame(self.parent, bg='#1e1e1e')
# 标题
title_label = tk.Label(
self.frame,
text="执行任务确认",
font=('Microsoft YaHei UI', 16, 'bold'),
fg='#ffd54f',
bg='#1e1e1e'
)
title_label.pack(pady=(10, 15))
# 上半部分:文件区域
file_section = tk.Frame(self.frame, bg='#1e1e1e')
file_section.pack(fill=tk.X, padx=10, pady=5)
# 输入文件区域
input_frame = tk.LabelFrame(
file_section,
text=" 📥 输入文件 ",
font=('Microsoft YaHei UI', 11, 'bold'),
fg='#4fc3f7',
bg='#1e1e1e',
relief=tk.GROOVE
)
input_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 5))
self.input_zone = DropZone(
input_frame,
title="待处理文件",
target_dir=self.input_dir,
is_input=True,
bg='#2d2d2d'
)
self.input_zone.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
# 箭头
arrow_frame = tk.Frame(file_section, bg='#1e1e1e')
arrow_frame.pack(side=tk.LEFT, padx=10)
tk.Label(
arrow_frame,
text="➡️",
font=('Microsoft YaHei UI', 20),
fg='#ffd54f',
bg='#1e1e1e'
).pack(pady=30)
# 输出文件区域
output_frame = tk.LabelFrame(
file_section,
text=" 📤 输出文件 ",
font=('Microsoft YaHei UI', 11, 'bold'),
fg='#81c784',
bg='#1e1e1e',
relief=tk.GROOVE
)
output_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(5, 0))
self.output_zone = DropZone(
output_frame,
title="处理结果",
target_dir=self.output_dir,
is_input=False,
bg='#2d2d2d'
)
self.output_zone.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
# 意图识别结果区域
self._create_intent_section()
# 执行计划区域Markdown
self._create_plan_section()
# 风险提示区域
self._create_risk_section()
# 按钮区域
self._create_button_section()
def _create_intent_section(self):
"""创建意图识别结果区域"""
section = tk.LabelFrame(
self.frame,
text=" 🎯 意图识别 ",
font=('Microsoft YaHei UI', 11, 'bold'),
fg='#81c784',
bg='#1e1e1e',
relief=tk.GROOVE
)
section.pack(fill=tk.X, padx=10, pady=5)
self.intent_label = tk.Label(
section,
text="",
font=('Microsoft YaHei UI', 10),
fg='#d4d4d4',
bg='#1e1e1e',
wraplength=650,
justify=tk.LEFT
)
self.intent_label.pack(padx=10, pady=8, anchor=tk.W)
def _create_plan_section(self):
"""创建执行计划区域(支持 Markdown"""
section = tk.LabelFrame(
self.frame,
text=" 📄 执行计划 ",
font=('Microsoft YaHei UI', 11, 'bold'),
fg='#ce93d8',
bg='#1e1e1e',
relief=tk.GROOVE
)
section.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
# 使用 Markdown 渲染的 Text
self.plan_text = MarkdownText(
section,
wrap=tk.WORD,
bg='#2d2d2d',
fg='#d4d4d4',
relief=tk.FLAT,
height=8,
padx=10,
pady=10
)
# 添加滚动条
scrollbar = ttk.Scrollbar(section, orient=tk.VERTICAL, command=self.plan_text.yview)
self.plan_text.configure(yscrollcommand=scrollbar.set)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.plan_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
def _create_risk_section(self):
"""创建风险提示区域"""
section = tk.LabelFrame(
self.frame,
text=" ⚠️ 安全提示 ",
font=('Microsoft YaHei UI', 11, 'bold'),
fg='#ffb74d',
bg='#1e1e1e',
relief=tk.GROOVE
)
section.pack(fill=tk.X, padx=10, pady=5)
self.risk_label = tk.Label(
section,
text="• 所有操作仅在 workspace 目录内进行\n• 原始文件不会被修改或删除\n• 执行代码已通过安全检查",
font=('Microsoft YaHei UI', 10),
fg='#d4d4d4',
bg='#1e1e1e',
justify=tk.LEFT
)
self.risk_label.pack(padx=10, pady=8, anchor=tk.W)
def _create_button_section(self):
"""创建按钮区域"""
button_frame = tk.Frame(self.frame, bg='#1e1e1e')
button_frame.pack(fill=tk.X, padx=10, pady=15)
# 刷新文件列表按钮
self.refresh_btn = tk.Button(
button_frame,
text="🔄 刷新文件",
font=('Microsoft YaHei UI', 10),
bg='#424242',
fg='white',
activebackground='#616161',
activeforeground='white',
relief=tk.FLAT,
padx=15,
pady=5,
cursor='hand2',
command=self._refresh_all
)
self.refresh_btn.pack(side=tk.LEFT, padx=(0, 10))
# 取消按钮
self.cancel_btn = tk.Button(
button_frame,
text="取消",
font=('Microsoft YaHei UI', 11),
bg='#616161',
fg='white',
activebackground='#757575',
activeforeground='white',
relief=tk.FLAT,
padx=20,
pady=5,
cursor='hand2',
command=self.on_cancel
)
self.cancel_btn.pack(side=tk.RIGHT, padx=(10, 0))
# 执行按钮
self.execute_btn = tk.Button(
button_frame,
text="🚀 开始执行",
font=('Microsoft YaHei UI', 12, 'bold'),
bg='#4caf50',
fg='white',
activebackground='#66bb6a',
activeforeground='white',
relief=tk.FLAT,
padx=30,
pady=8,
cursor='hand2',
command=self._on_execute_clicked
)
self.execute_btn.pack(side=tk.RIGHT)
def _refresh_all(self):
"""刷新所有文件列表"""
self.input_zone._refresh_file_list()
self.output_zone._refresh_file_list()
def _on_execute_clicked(self):
"""执行按钮点击"""
# 刷新文件列表
self.input_zone._refresh_file_list()
# 检查 input 目录是否有文件
files = self.input_zone.get_files()
if not files:
result = messagebox.askyesno(
"确认执行",
"输入文件夹为空,确定要继续执行吗?",
icon='warning'
)
if not result:
return
self.on_execute()
def set_intent_result(self, reason: str, confidence: float):
"""设置意图识别结果"""
self.intent_label.config(
text=f"识别结果: 执行任务 (置信度: {confidence:.0%})\n原因: {reason}"
)
def set_execution_plan(self, plan: str):
"""设置执行计划Markdown 格式)"""
self.plan_text.set_markdown(plan)
def set_risk_info(self, info: str):
"""设置风险提示"""
self.risk_label.config(text=info)
def set_buttons_enabled(self, enabled: bool):
"""设置按钮是否可用"""
state = tk.NORMAL if enabled else tk.DISABLED
self.execute_btn.config(state=state)
self.cancel_btn.config(state=state)
def refresh_output(self):
"""刷新输出文件列表"""
self.output_zone._refresh_file_list()
def show(self):
"""显示视图"""
self.frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
self._refresh_all()
def hide(self):
"""隐藏视图"""
self.frame.pack_forget()
def get_frame(self) -> tk.Frame:
"""获取主框架"""
return self.frame