release: v6.1.0 - TUI 体验全面升级 + 背景自定义

TUI 界面改进:
- 全面中文化: CommandPalette/HelpPanel/Footer/Chatbox 边框标题等所有用户可见英文汉化
- 辅助中心 (Ctrl+P/F9): 原"设置"重命名, 含 6 项可切换设置
- 独立帮助浮层 (? 键): 重写帮助页面逻辑, 全中文, 带滑入淡入动画
- 背景切换: 辅助中心 [5] 预设背景, [6] 自定义背景; 新增 /bg 命令支持图片提取主色调
- 换行修复: Ctrl+J 作为主推换行键 (Windows 终端不区分 Shift+Enter)
- 引擎切换修复: 解决 call_from_thread 在主线程崩溃的 RuntimeError

技术改进:
- 文本工具调用解析: 检测模型用 <tool_call> 标签返回的工具调用
- 网络工具优化: network_tools.py 和 ai_search.py 改进请求头/重试/编码
- 流式输出修复: append_chunk 的 refresh 改为 layout=True
- 取消响应修复: 引擎层 _cancelled 标志 + TUI 层 worker 身份跟踪

融合 v6.0.0 工作:
- 保留 terminal 模拟器/operit 桥接/深度思考/工作模式/记忆模式等 v6.0.0 全部功能
- 在此基础上叠加本会话的 TUI 改进
This commit is contained in:
shuiyu1123 committed 2026-06-29 13:00:19 +08:00
1 parent b5f638cffc
commit de15b54b19
30 files changed
+3267 -934

No files matched your search

+52 -26
View File
@@ -1,5 +1,6 @@
import subprocess
import json
import time
from urllib.parse import quote
import requests
@@ -7,6 +8,14 @@ import requests
class Liugin:
"""AI搜索插件 - 使用jina.ai进行智能搜索"""
# 统一 User-Agent(模拟浏览器,避免被反爬拦截)
_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
_HEADERS = {
'User-Agent': _UA,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
}
def __init__(self):
self.usage = """AI搜索工具使用方法:/r
ai_search <操作> <参数>/r
@@ -27,6 +36,7 @@ ai_search <操作> <参数>/r
"""
self.cli = None # CLI实例引用
self.timeout = 30 # 请求超时时间(秒)
self.max_retries = 2 # 最大重试次数
def set_cli(self, cli):
"""设置CLI实例引用"""
@@ -162,34 +172,50 @@ JSON格式示例:
return f"网络搜索错误: {str(e)}"
def _execute_curl(self, url):
"""使用 requests 库获取内容(替代 curl 命令)"""
try:
# 使用 requests 库发送请求
response = requests.get(
url,
timeout=self.timeout,
headers={'User-Agent': 'Mozilla/5.0 (compatible; AI-Search-Bot)'}
)
# 检查状态码
if response.status_code != 200:
return f"错误:HTTP请求失败,状态码: {response.status_code}"
"""使用 requests 库获取内容(带重试、UA、编码处理)"""
last_error = None
for attempt in range(self.max_retries + 1):
try:
response = requests.get(
url,
timeout=self.timeout,
headers=self._HEADERS,
allow_redirects=True
)
# 获取内容
content = response.text.strip()
# 检查状态码
if response.status_code != 200:
return f"错误:HTTP请求失败,状态码: {response.status_code}"
if not content:
return "错误:未获取到任何内容"
# 显式编码处理:避免 ISO-8859-1 默认导致中文乱码
if not response.encoding or response.encoding.lower() == 'iso-8859-1':
response.encoding = response.apparent_encoding or 'utf-8'
# 限制输出长度以避免过长输出
if len(content) > 5000:
content = content[:5000] + f"\n\n... (内容已截断,完整内容共{len(content)}字符)"
# 获取内容
content = response.text.strip()
return content
if not content:
return "错误:未获取到任何内容"
except requests.Timeout:
return f"错误:请求超时 (>{self.timeout}秒)"
except requests.ConnectionError:
return "错误:网络连接失败,请检查网络"
except Exception as e:
return f"错误:请求时发生错误: {str(e)}"
# 限制输出长度以避免过长输出
if len(content) > 5000:
content = content[:5000] + f"\n\n... (内容已截断,完整内容共{len(content)}字符)"
return content
except requests.exceptions.Timeout:
last_error = f"请求超时 (>{self.timeout}秒)"
if attempt < self.max_retries:
time.sleep(1)
except requests.exceptions.ConnectionError:
last_error = "网络连接失败,请检查网络"
if attempt < self.max_retries:
time.sleep(1)
except requests.exceptions.RequestException as e:
last_error = str(e)
break
except Exception as e:
last_error = str(e)
break
return f"错误:请求时发生错误: {last_error}"
+10 -2
View File
@@ -368,8 +368,16 @@ class Liugin:
if not test_files and len(py_files) > 5:
suggestions.append(" 添加单元测试")
uses_print = sum(1 for f in py_files if 'print(' in open(f, errors='ignore').read())
uses_log = sum(1 for f in py_files if 'logging' in open(f, errors='ignore').read())
uses_print = 0
uses_log = 0
for f in py_files:
with open(f, errors='ignore') as fh:
content = fh.read()
if 'print(' in content:
uses_print += 1
if 'logging' in content:
uses_log += 1
if uses_print > uses_log and uses_print > 3:
suggestions.append(" 考虑用 logging 替代 print")
+3 -1
View File
@@ -1158,8 +1158,10 @@ else:
def _op_diff(self, path: str, rest: str) -> str:
if not path:
return "错误:请提供第一个文件路径"
if not rest.strip():
return "错误:请提供第二个文件路径"
f1 = self._norm(path)
f2 = self._norm(rest.split()[0] if rest.strip() else "")
f2 = self._norm(rest.split()[0])
if not os.path.exists(f1):
return f"错误:文件不存在: {f1}"
if not os.path.exists(f2):
+568
View File
@@ -0,0 +1,568 @@
"""
编译验证插件 - 批量语法/编译检查
对项目内 Python 文件做 ast.parse + py_compile 双重验证,
支持按目录、单文件或全项目扫描,并发执行加速。
"""
import os
import sys
import ast
import time
import shutil
import py_compile
import concurrent.futures
from pathlib import Path
from typing import List, Tuple, Optional
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from xcli_core.tool_result import ToolResult, ErrorCode
try:
from colorama import Fore, Style, init as _colorama_init
_colorama_init(autoreset=False)
_HAS_COLOR = True
except Exception:
_HAS_COLOR = False
class _Stub:
def __getattr__(self, _): return ""
Fore = Style = _Stub()
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SKIP_DIRS = {
"__pycache__", ".git", ".venv", "venv", "env", ".env",
"node_modules", ".idea", ".vscode", "dist", "build",
".mypy_cache", ".pytest_cache", ".ruff_cache", "site-packages",
}
SKIP_FILES = {
"setup.py",
}
class Liugin:
"""编译验证 - 批量语法检查 / 编译验证 / 清理 __pycache__"""
def __init__(self):
self.usage = """编译验证工具
操作:
all - 验证项目内全部 Python 文件
core - 仅验证 xcli_core/ 目录
engines - 仅验证 ai_engines/ 目录
plugins - 仅验证 plugins/ 目录
dir <目录> - 验证指定目录
check <文件> - 验证单个文件 (可多次指定)
clean [目录] - 清理 __pycache__ (默认项目根)
stats - 统计各目录 Python 文件数量
选项:
-v / --verbose - 显示每个文件的结果
-j / --jobs <N> - 并发线程数 (默认 8)
-q / --quiet - 仅在结束时打印汇总
输出:
✅ 绿色 = 通过
❌ 红色 = 失败
汇总: 总数/通过/失败 + 失败文件详情
示例:
check all -v
check core
check plugins/compile_check.py
check dir ai_engines -j 16
check clean
"""
self.cli = None
def set_cli(self, cli):
self.cli = cli
def get_tool_info(self):
return {
"name": "compile_check",
"description": "编译验证工具 — 批量语法/编译检查(ast.parse + py_compile),支持目录、单文件、全项目扫描,并发加速,清理 __pycache__",
"keywords": ["编译", "验证", "语法", "检查", "compile", "syntax",
"check", "lint", "py_compile", "ast", "批量",
"validate", "verify", "clean", "pycache"],
"usage": self.usage,
}
def get_mcp_definition(self):
return {
"name": "compile_check",
"description": "编译验证工具",
"inputSchema": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["all", "core", "engines", "plugins",
"dir", "check", "clean", "stats"],
"description": "操作类型"
},
"path": {"type": "string",
"description": "目录或文件路径"},
"paths": {"type": "array",
"items": {"type": "string"},
"description": "多文件路径列表"},
"verbose": {"type": "boolean", "default": False},
"jobs": {"type": "integer", "default": 8},
"quiet": {"type": "boolean", "default": False},
},
"required": ["operation"]
}
}
def convert_mcp_args(self, arguments):
op = arguments.get("operation", "")
path = arguments.get("path", "")
return f"{op} {path}".strip()
def handle(self, args: str):
try:
parts = self._parse_args(args)
if not parts:
return self._print_usage()
operation = parts[0].lower()
opts = self._extract_options(parts[1:])
if operation in ("help", "-h", "--help"):
return self._print_usage()
routes = {
"all": self._op_all,
"core": self._op_core,
"engines": self._op_engines,
"plugins": self._op_plugins,
"dir": self._op_dir,
"check": self._op_check,
"clean": self._op_clean,
"stats": self._op_stats,
}
handler = routes.get(operation)
if not handler:
return ToolResult.fail(
f"不支持的操作 '{operation}'",
ErrorCode.UNSUPPORTED_OP,
tool_name="compile_check",
)
return handler(opts)
except Exception as e:
return ToolResult.fail(
f"编译验证错误: {e}",
ErrorCode.EXEC_FAILED,
tool_name="compile_check",
)
def _print_usage(self):
self._emit(self.usage)
return ToolResult.ok("usage_printed", tool_name="compile_check")
def _out(self, msg: str):
"""统一输出,适配 TUI/CLI"""
if self.cli is not None and hasattr(self.cli, "_output"):
self.cli._output(msg)
else:
print(msg)
def _emit(self, msg: str, end: str = "\n"):
"""统一输出,自动适配 TUI/CLI
- TUI 模式: 一行一行输出到 _output,忽略 end(TUI 有自己的状态指示器)
- CLI 模式: 尊重 end,支持 end="" 的进度条刷新
- TUI 模式下 end != "\\n" 的进度条输出直接跳过(避免污染对话区)
"""
if self.cli is not None and hasattr(self.cli, "_output"):
if end != "\n":
return # TUI 模式跳过进度条类输出
self.cli._output(msg)
else:
print(msg, end=end)
def _ok(self, msg: str) -> str:
return f"{Fore.GREEN}{msg}{Style.RESET_ALL}"
def _err(self, msg: str) -> str:
return f"{Fore.RED}{msg}{Style.RESET_ALL}"
def _warn(self, msg: str) -> str:
return f"{Fore.YELLOW}{msg}{Style.RESET_ALL}"
def _dim(self, msg: str) -> str:
return f"{Fore.CYAN}{msg}{Style.RESET_ALL}"
def _banner(self, title: str) -> str:
bar = "═" * max(20, len(title) + 4)
return f"{Fore.CYAN}{bar}\n {title}\n{bar}{Style.RESET_ALL}"
def _parse_args(self, args: str) -> List[str]:
parts, current, in_quotes, qc = [], "", False, None
for ch in args:
if ch in ('"', "'") and not in_quotes:
in_quotes, qc = True, ch
elif ch == qc and in_quotes:
in_quotes, qc = False, None
elif ch == " " and not in_quotes:
if current:
parts.append(current)
current = ""
else:
current += ch
if current:
parts.append(current)
return parts
def _extract_options(self, args: List[str]) -> dict:
opts = {
"positional": [],
"verbose": False,
"quiet": False,
"jobs": 8,
}
i = 0
while i < len(args):
a = args[i]
if a in ("-v", "--verbose"):
opts["verbose"] = True
elif a in ("-q", "--quiet"):
opts["quiet"] = True
elif a in ("-j", "--jobs") and i + 1 < len(args):
try:
opts["jobs"] = max(1, int(args[i + 1]))
except ValueError:
pass
i += 1
elif not a.startswith("-"):
opts["positional"].append(a)
i += 1
return opts
def _collect_py_files(self, root: str) -> List[str]:
root = os.path.abspath(root)
if not os.path.isdir(root):
return []
result: List[str] = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for fn in filenames:
if not fn.endswith(".py"):
continue
if fn in SKIP_FILES:
continue
result.append(os.path.join(dirpath, fn))
result.sort()
return result
def _verify_one(self, filepath: str) -> Tuple[str, bool, str]:
try:
with open(filepath, "r", encoding="utf-8") as f:
src = f.read()
except UnicodeDecodeError:
try:
with open(filepath, "r", encoding="gbk") as f:
src = f.read()
except Exception as e:
return filepath, False, f"读取失败: {e}"
except Exception as e:
return filepath, False, f"读取失败: {e}"
try:
ast.parse(src, filename=filepath)
except SyntaxError as e:
line = getattr(e, "lineno", "?")
col = getattr(e, "offset", "?")
msg = getattr(e, "msg", str(e))
return filepath, False, f"SyntaxError 第{line}行第{col}列: {msg}"
try:
py_compile.compile(filepath, doraise=True, quiet=2)
except py_compile.PyCompileError as e:
return filepath, False, f"PyCompileError: {e.msg if hasattr(e, 'msg') else e}"
except Exception as e:
return filepath, False, f"编译失败: {e}"
return filepath, True, ""
def _run_batch(
self,
files: List[str],
title: str,
verbose: bool = False,
quiet: bool = False,
jobs: int = 8,
) -> Tuple[int, int, List[Tuple[str, str]]]:
total = len(files)
if total == 0:
self._emit(self._warn(f"{title}: 没有可验证的 Python 文件"))
return 0, 0, []
if not quiet:
self._emit(self._banner(f"{title} ({total} 个文件, {jobs} 线程)"))
self._emit("")
passed = 0
failed: List[Tuple[str, str]] = []
start = time.time()
results: List[Tuple[str, bool, str]] = []
if jobs == 1:
for f in files:
results.append(self._verify_one(f))
else:
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as ex:
futures = {ex.submit(self._verify_one, f): f for f in files}
done = 0
for fut in concurrent.futures.as_completed(futures):
results.append(fut.result())
done += 1
if not quiet and not verbose and total > 0:
pct = done * 100 // total
rel = os.path.relpath(futures[fut], PROJECT_ROOT)
self._emit(f"\r 进度: [{done}/{total}] {pct:3d}% {rel[:40]:<40}", end="")
if not quiet and not verbose:
self._emit("")
results.sort(key=lambda x: x[0])
if verbose and not quiet:
self._emit("")
for filepath, ok, err in results:
rel = os.path.relpath(filepath, PROJECT_ROOT)
if ok:
passed += 1
if verbose and not quiet:
self._emit(f" {self._ok('✓')} {rel}")
else:
failed.append((rel, err))
if not quiet:
self._emit(f" {self._err('✗')} {rel}")
self._emit(f" {self._err(err)}")
elapsed = time.time() - start
self._emit("")
self._emit("─" * 60)
if failed:
self._emit(f" {self._err('✗ 失败:')} {len(failed)}/{total}")
self._emit(f" {self._ok('✓ 通过:')} {passed}/{total}")
self._emit(f" {self._dim(f'耗时: {elapsed:.2f}s')}")
self._emit("")
self._emit(self._err("失败列表:"))
for rel, err in failed:
self._emit(f" • {self._err(rel)}")
first_line = err.split("\n")[0][:120]
self._emit(f" {first_line}")
else:
self._emit(f" {self._ok(f'✓ 全部通过:')} {passed}/{total} 个文件")
self._emit(f" {self._dim(f'耗时: {elapsed:.2f}s')}")
self._emit("─" * 60)
return passed, len(failed), failed
def _op_all(self, opts: dict):
files = self._collect_py_files(PROJECT_ROOT)
passed, failed, _ = self._run_batch(
files,
"全项目编译验证",
verbose=opts["verbose"],
quiet=opts["quiet"],
jobs=opts["jobs"],
)
if failed == 0:
return ToolResult.ok(
f"全部通过 ({passed}/{passed})",
tool_name="compile_check",
)
return ToolResult.fail(
f"有 {failed} 个文件编译失败",
ErrorCode.EXEC_FAILED,
tool_name="compile_check",
)
def _op_core(self, opts: dict):
return self._scan_subdir("xcli_core", "xcli_core 编译验证", opts)
def _op_engines(self, opts: dict):
return self._scan_subdir("ai_engines", "ai_engines 编译验证", opts)
def _op_plugins(self, opts: dict):
return self._scan_subdir("plugins", "plugins 编译验证", opts)
def _scan_subdir(self, sub: str, title: str, opts: dict):
target = os.path.join(PROJECT_ROOT, sub)
if not os.path.isdir(target):
return ToolResult.not_found(f"目录: {sub}", tool_name="compile_check")
files = self._collect_py_files(target)
passed, failed, _ = self._run_batch(
files, title,
verbose=opts["verbose"],
quiet=opts["quiet"],
jobs=opts["jobs"],
)
if failed == 0:
return ToolResult.ok(
f"{sub} 全部通过 ({passed})",
tool_name="compile_check",
)
return ToolResult.fail(
f"{sub} 有 {failed} 个文件编译失败",
ErrorCode.EXEC_FAILED,
tool_name="compile_check",
)
def _op_dir(self, opts: dict):
if not opts["positional"]:
return ToolResult.missing("目录路径", tool_name="compile_check")
target = os.path.abspath(opts["positional"][0])
if not os.path.isdir(target):
return ToolResult.not_found(f"目录: {target}", tool_name="compile_check")
files = self._collect_py_files(target)
passed, failed, _ = self._run_batch(
files,
f"目录验证: {os.path.relpath(target, PROJECT_ROOT)}",
verbose=opts["verbose"],
quiet=opts["quiet"],
jobs=opts["jobs"],
)
if failed == 0:
return ToolResult.ok(
f"目录全部通过 ({passed})",
tool_name="compile_check",
)
return ToolResult.fail(
f"目录有 {failed} 个文件编译失败",
ErrorCode.EXEC_FAILED,
tool_name="compile_check",
)
def _op_check(self, opts: dict):
if not opts["positional"]:
return ToolResult.missing("文件路径", tool_name="compile_check")
targets = []
for p in opts["positional"]:
fp = os.path.abspath(p)
if not os.path.isfile(fp):
return ToolResult.not_found(
f"文件: {p}", tool_name="compile_check",
)
targets.append(fp)
if len(targets) == 1:
self._emit(self._banner(
f"单文件验证: {os.path.relpath(targets[0], PROJECT_ROOT)}"
))
filepath, ok, err = self._verify_one(targets[0])
rel = os.path.relpath(filepath, PROJECT_ROOT)
self._emit("")
if ok:
self._emit(f" {self._ok('✓ 通过')} {rel}")
self._emit("─" * 60)
return ToolResult.ok(f"通过: {rel}", tool_name="compile_check")
else:
self._emit(f" {self._err('✗ 失败')} {rel}")
self._emit(f" {self._err(err)}")
self._emit("─" * 60)
return ToolResult.fail(
f"编译失败: {rel}",
ErrorCode.EXEC_FAILED,
tool_name="compile_check",
)
passed, failed, _ = self._run_batch(
targets,
f"批量验证 ({len(targets)} 个文件)",
verbose=opts["verbose"],
quiet=opts["quiet"],
jobs=opts["jobs"],
)
if failed == 0:
return ToolResult.ok(
f"全部通过 ({passed})",
tool_name="compile_check",
)
return ToolResult.fail(
f"有 {failed} 个文件编译失败",
ErrorCode.EXEC_FAILED,
tool_name="compile_check",
)
def _op_clean(self, opts: dict):
target = PROJECT_ROOT
if opts["positional"]:
target = os.path.abspath(opts["positional"][0])
if not os.path.isdir(target):
return ToolResult.not_found(
f"目录: {target}", tool_name="compile_check",
)
self._emit(self._banner(f"清理 __pycache__: {os.path.relpath(target, PROJECT_ROOT)}"))
removed = 0
for dirpath, dirnames, _ in os.walk(target):
for d in list(dirnames):
if d == "__pycache__":
full = os.path.join(dirpath, d)
try:
shutil.rmtree(full)
removed += 1
rel = os.path.relpath(full, PROJECT_ROOT)
if not opts["quiet"]:
self._emit(f" {self._ok('✓ 已删除')} {rel}")
except Exception as e:
rel = os.path.relpath(full, PROJECT_ROOT)
self._emit(f" {self._err('✗ 失败')} {rel}: {e}")
self._emit("")
self._emit("─" * 60)
if removed:
self._emit(f" {self._ok(f'✓ 已清理')} {removed} 个 __pycache__")
else:
self._emit(f" {self._dim('没有可清理的 __pycache__')}")
self._emit("─" * 60)
return ToolResult.ok(
f"清理完成 ({removed} 个目录)",
tool_name="compile_check",
)
def _op_stats(self, opts: dict):
self._emit(self._banner("项目 Python 文件统计"))
subdirs = [
("xcli_core", "xcli_core"),
("ai_engines", "ai_engines"),
("plugins", "plugins"),
]
total = 0
rows: List[Tuple[str, int]] = []
for sub, label in subdirs:
target = os.path.join(PROJECT_ROOT, sub)
if os.path.isdir(target):
count = len(self._collect_py_files(target))
rows.append((label, count))
total += count
other = len(self._collect_py_files(PROJECT_ROOT)) - total
rows.append(("其他(根目录等)", other))
total += other
self._emit("")
self._emit(f" {'目录':<20} {'文件数':>8}")
self._emit(" " + "─" * 30)
for label, count in rows:
bar = "█" * min(count, 40) if count else ""
color = Fore.GREEN if count else Fore.CYAN
self._emit(f" {label:<20} {count:>8} {color}{bar}{Style.RESET_ALL}")
self._emit(" " + "─" * 30)
self._emit(f" {'合计':<20} {total:>8}")
self._emit("")
self._emit("─" * 60)
return ToolResult.ok(
f"统计完成 (共 {total} 个文件)",
tool_name="compile_check",
)
+2 -1
View File
@@ -4,6 +4,7 @@
"""
import os
import re
import subprocess
from colorama import Fore, Style
@@ -317,7 +318,7 @@ JSON格式示例:
"message": "使用了var声明,建议使用let或const"
})
if '==' in content and '!==' not in content:
if re.search(r'(?<![=!])==(?!=)', content):
issues.append({
"level": "warning",
"message": "使用了==比较,建议使用===严格相等"
+1 -1
View File
@@ -354,7 +354,7 @@ class Liugin:
def _auto_commit_message(self, status: str) -> str:
lines = status.strip().split('\n')
added = len([l for l in lines if l.startswith('A ') or l.startswith('A') and l[1] == ' '])
added = len([l for l in lines if l.startswith('A ') or (len(l) > 1 and l.startswith('A') and l[1] == ' ')])
modified = len([l for l in lines if l.startswith('M ') or (len(l) > 1 and l[1] == 'M')])
deleted = len([l for l in lines if l.startswith('D ') or (len(l) > 1 and l[1] == 'D')])
+2 -2
View File
@@ -324,7 +324,7 @@ class Liugin:
lines.append(f"{indent}{prefix}{type_part} {name_part}{id_part}{bounds_part}{state_str}")
children = element.FindAll(
self._uia.CreatePropertyCondition(30000, 0), # TreeScope_Children
2, # TreeScope_Children
self._uia.CreateTrueCondition()
)
if children:
@@ -570,7 +570,7 @@ class Liugin:
try:
children = self._root.FindAll(
self._uia.CreatePropertyCondition(30000, 2),
2, # TreeScope_Children
self._uia.CreateTrueCondition()
)
+1 -1
View File
@@ -355,7 +355,7 @@ class Liugin:
if output:
# 提取纯ID保存到文件
ids = [line.strip().lstrip(' ') for line in result.split('\n')
if line.strip() and not any(c in line for c in [' ', '️', '', '❄️', ''])]
if line.strip() and not any(c in line for c in [' ', '️', '❄️'])]
try:
with open(output, 'w', encoding='utf-8') as f:
f.write('\n'.join(ids))
+113 -95
View File
@@ -8,7 +8,15 @@ from colorama import Fore, Style
class Liugin:
"""网络工具插件"""
# 统一 User-Agent(模拟浏览器,避免被反爬拦截)
_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
_HEADERS = {
'User-Agent': _UA,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
}
def __init__(self):
self.usage = """网络工具使用方法:/r
network_tools <操作> <参数>/r
@@ -35,6 +43,7 @@ network_tools <操作> <参数>/r
"""
self.cli = None # CLI实例引用
self.timeout = 10 # 请求超时时间(秒)
self.max_retries = 2 # 最大重试次数
def set_cli(self, cli):
"""设置CLI实例引用"""
@@ -161,128 +170,137 @@ JSON格式示例:
except Exception as e:
return f"网络工具操作错误: {str(e)}"
def _request(self, url, method='GET', timeout=None):
"""统一请求方法 — 带重试、UA、编码处理"""
timeout = timeout or self.timeout
last_error = None
for attempt in range(self.max_retries + 1):
try:
response = requests.request(
method, url, timeout=timeout, headers=self._HEADERS,
allow_redirects=True
)
# 显式编码处理:优先用响应头声明的编码,兜底 UTF-8
if not response.encoding or response.encoding.lower() == 'iso-8859-1':
response.encoding = response.apparent_encoding or 'utf-8'
return response
except requests.exceptions.Timeout:
last_error = f"请求超时 (>{timeout}秒)"
if attempt < self.max_retries:
time.sleep(1)
except requests.exceptions.ConnectionError:
last_error = "连接失败 (无法连接到服务器)"
if attempt < self.max_retries:
time.sleep(1)
except requests.exceptions.RequestException as e:
last_error = str(e)
break
return last_error
def _ping(self, target):
"""测试网站连通性和延迟"""
"""测试网站连通性和延迟(单次请求,不重试,保证延迟测量准确)"""
try:
# 确保URL格式正确
if not target.startswith(('http://', 'https://')):
test_url = 'http://' + target
else:
test_url = target
# 添加协议如果缺失
if not test_url.startswith(('http://', 'https://')):
test_url = 'http://' + test_url
# 单次请求测量延迟(不使用重试,避免重试干扰延迟数值)
start_time = time.time()
response = requests.get(test_url, timeout=self.timeout)
try:
response = requests.get(
test_url, timeout=self.timeout,
headers=self._HEADERS, allow_redirects=True
)
except requests.exceptions.Timeout:
return f".ping 测试结果:\n目标: {target}\n错误: 请求超时 (>{self.timeout}秒)\n连接状态: 失败"
except requests.exceptions.ConnectionError:
return f".ping 测试结果:\n目标: {target}\n错误: 连接失败 (无法连接到服务器)\n连接状态: 失败"
except requests.exceptions.RequestException as e:
return f".ping 测试结果:\n目标: {target}\n错误: {str(e)}\n连接状态: 失败"
end_time = time.time()
response_time = (end_time - start_time) * 1000 # 转换为毫秒
result = f".ping 测试结果:\n"
result += f"目标: {target}\n"
result += f"HTTP状态码: {response.status_code}\n"
result += f"响应时间: {response_time:.2f} ms\n"
result += f"连接状态: {'成功' if response.status_code < 400 else '失败'}\n"
if response.status_code >= 400:
result += f"错误信息: {response.reason if hasattr(response, 'reason') else '请求失败'}\n"
return result
except requests.exceptions.Timeout:
return f".ping 测试结果:\n目标: {target}\n错误: 请求超时 (>{self.timeout}秒)\n连接状态: 失败"
except requests.exceptions.ConnectionError:
return f".ping 测试结果:\n目标: {target}\n错误: 连接失败 (无法连接到服务器)\n连接状态: 失败"
except requests.exceptions.RequestException as e:
return f".ping 测试结果:\n目标: {target}\n错误: {str(e)}\n连接状态: 失败"
except Exception as e:
return f".ping 测试结果:\n目标: {target}\n错误: {str(e)}\n连接状态: 失败"
def _get_content(self, url):
"""获取网页内容"""
try:
# 确保URL格式正确
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
response = requests.get(url, timeout=self.timeout)
if response.status_code == 200:
content = response.text
# 限制内容长度以避免过长输出
if len(content) > 2000:
content = content[:2000] + f"\n... (内容已截断,完整内容共{len(content)}字符)"
result = f"网页内容 (来自 {url}):\n"
result += content
return result
else:
return f"获取网页内容失败,状态码: {response.status_code}, 原因: {response.reason if hasattr(response, 'reason') else ''}"
except requests.exceptions.Timeout:
return f"获取网页内容超时 (>{self.timeout}秒)"
except requests.exceptions.ConnectionError:
return f"无法连接到 {url},请检查URL是否正确"
except requests.exceptions.RequestException as e:
return f"请求失败: {str(e)}"
except Exception as e:
return f"获取网页内容时发生错误: {str(e)}"
# 确保URL格式正确
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
response = self._request(url)
# _request 返回字符串表示错误
if isinstance(response, str):
return f"获取网页内容失败: {response}"
if response.status_code == 200:
content = response.text
# 限制内容长度以避免过长输出
if len(content) > 2000:
content = content[:2000] + f"\n... (内容已截断,完整内容共{len(content)}字符)"
result = f"网页内容 (来自 {url}):\n"
result += content
return result
else:
return f"获取网页内容失败,状态码: {response.status_code}, 原因: {response.reason if hasattr(response, 'reason') else ''}"
def _check_status(self, url):
"""检查网站状态码"""
try:
# 确保URL格式正确
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
response = requests.get(url, timeout=self.timeout)
result = f"网站状态 (来自 {url}):\n"
result += f"HTTP状态码: {response.status_code}\n"
result += f"状态描述: {response.reason if hasattr(response, 'reason') else 'Unknown'}\n"
result += f"响应时间: {response.elapsed.total_seconds():.2f} 秒\n"
result += f"内容长度: {len(response.content)} 字节\n"
result += f"服务器: {response.headers.get('Server', '未知')}\n"
result += f"内容类型: {response.headers.get('Content-Type', '未知')}\n"
return result
except requests.exceptions.Timeout:
return f"检查网站状态超时 (>{self.timeout}秒)"
except requests.exceptions.ConnectionError:
return f"无法连接到 {url},请检查URL是否正确"
except requests.exceptions.RequestException as e:
return f"请求失败: {str(e)}"
except Exception as e:
return f"检查网站状态时发生错误: {str(e)}"
# 确保URL格式正确
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
response = self._request(url)
if isinstance(response, str):
return f"检查网站状态失败: {response}"
result = f"网站状态 (来自 {url}):\n"
result += f"HTTP状态码: {response.status_code}\n"
result += f"状态描述: {response.reason if hasattr(response, 'reason') else 'Unknown'}\n"
result += f"响应时间: {response.elapsed.total_seconds():.2f} 秒\n"
result += f"内容长度: {len(response.content)} 字节\n"
result += f"服务器: {response.headers.get('Server', '未知')}\n"
result += f"内容类型: {response.headers.get('Content-Type', '未知')}\n"
return result
def _get_headers(self, url):
"""获取网站响应头"""
try:
# 确保URL格式正确
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
response = requests.get(url, timeout=self.timeout)
headers = dict(response.headers)
result = f"响应头 (来自 {url}):\n"
for key, value in headers.items():
result += f"{key}: {value}\n"
return result
except requests.exceptions.Timeout:
return f"获取响应头超时 (>{self.timeout}秒)"
except requests.exceptions.ConnectionError:
return f"无法连接到 {url},请检查URL是否正确"
except requests.exceptions.RequestException as e:
return f"请求失败: {str(e)}"
except Exception as e:
return f"获取响应头时发生错误: {str(e)}"
"""获取网站响应头(优先 HEAD 请求,失败则回退 GET)"""
# 确保URL格式正确
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
# 优先用 HEAD(更高效),失败则回退到 GET
response = self._request(url, method='HEAD')
if isinstance(response, str):
# HEAD 失败(可能服务器不支持),回退到 GET
response = self._request(url, method='GET')
if isinstance(response, str):
return f"获取响应头失败: {response}"
headers = dict(response.headers)
result = f"响应头 (来自 {url}):\n"
for key, value in headers.items():
result += f"{key}: {value}\n"
return result
def _get_ip(self, domain):
"""获取域名对应的IP地址"""
+31 -11
View File
@@ -295,17 +295,37 @@ JSON格式示例:
except Exception:
pass
# 打印醒目提醒
print()
print(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}")
print(f"{Fore.YELLOW} ⏰ 定时任务提醒 #{task_id}{Style.RESET_ALL}")
print(f"{Fore.YELLOW}{'─' * 50}{Style.RESET_ALL}")
print(f" {Fore.CYAN}任务:{Style.RESET_ALL} {message}")
print(f" {Fore.CYAN}时间:{Style.RESET_ALL} {now.strftime('%H:%M:%S')}")
if is_repeat:
next_str = (now + timedelta(seconds=repeat_seconds)).strftime("%Y-%m-%d %H:%M:%S")
print(f" {Fore.CYAN}下次:{Style.RESET_ALL} {next_str}")
print(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}")
# 打印醒目提醒(TUI 兼容)
is_tui = self.cli and getattr(self.cli, 'tui_output_callback', None)
is_generating = self.cli and getattr(self.cli, 'tui_stream_callback', None)
if is_tui and not is_generating:
# TUI 模式且未在生成中:通过回调输出
lines = [
"",
f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}",
f"{Fore.YELLOW} ⏰ 定时任务提醒 #{task_id}{Style.RESET_ALL}",
f"{Fore.YELLOW}{'─' * 50}{Style.RESET_ALL}",
f" {Fore.CYAN}任务:{Style.RESET_ALL} {message}",
f" {Fore.CYAN}时间:{Style.RESET_ALL} {now.strftime('%H:%M:%S')}",
]
if is_repeat:
next_str = (now + timedelta(seconds=repeat_seconds)).strftime("%Y-%m-%d %H:%M:%S")
lines.append(f" {Fore.CYAN}下次:{Style.RESET_ALL} {next_str}")
lines.append(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}")
self.cli.tui_output_callback("\n".join(lines))
elif not is_generating:
# CLI 模式
print()
print(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}")
print(f"{Fore.YELLOW} ⏰ 定时任务提醒 #{task_id}{Style.RESET_ALL}")
print(f"{Fore.YELLOW}{'─' * 50}{Style.RESET_ALL}")
print(f" {Fore.CYAN}任务:{Style.RESET_ALL} {message}")
print(f" {Fore.CYAN}时间:{Style.RESET_ALL} {now.strftime('%H:%M:%S')}")
if is_repeat:
next_str = (now + timedelta(seconds=repeat_seconds)).strftime("%Y-%m-%d %H:%M:%S")
print(f" {Fore.CYAN}下次:{Style.RESET_ALL} {next_str}")
print(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}")
# 自动激活 AI — 将提醒注入对话
self._activate_ai(task_id, message)
+18 -1
View File
@@ -83,7 +83,24 @@ class Liugin:
def convert_mcp_args(self, arguments):
op = arguments.get("operation", "")
name = arguments.get("name", "")
return f"{op} {name}".strip()
parts = [op]
if name:
parts.append(name)
if arguments.get("keyword"):
parts.append(arguments["keyword"])
if arguments.get("file"):
parts.append(arguments["file"])
if arguments.get("count"):
parts.append(str(arguments["count"]))
if arguments.get("language"):
parts.extend(["-l", arguments["language"]])
if arguments.get("code"):
parts.extend(["-c", f'"{arguments["code"]}"'])
if arguments.get("tags"):
parts.extend(["-t", arguments["tags"]])
if arguments.get("description"):
parts.extend(["-d", f'"{arguments["description"]}"'])
return " ".join(parts)
def _load_snippets(self):
try:
+25 -29
View File
@@ -319,18 +319,29 @@ class Liugin:
return None
def _find_agent(self, agent_id: str):
"""线程安全地查找 Agent(精确匹配 + 模糊匹配)"""
with self._lock:
agent = self.agents.get(agent_id)
if not agent:
matches = [a for a in self.agents.values() if agent_id in a.id or agent_id == a.name]
if matches:
agent = matches[0]
return agent
def _list(self, args: str) -> str:
"""列出所有子 Agent"""
with self._lock:
if not self.agents:
return "没有子 Agent"
agents_list = list(self.agents.values())
result = [f"子 Agent 列表 ({len(self.agents)} 个):\n"]
result = [f"子 Agent 列表 ({len(agents_list)} 个):\n"]
# 按状态分组
running = [a for a in self.agents.values() if a.status == "running"]
done = [a for a in self.agents.values() if a.status == "done"]
failed = [a for a in self.agents.values() if a.status == "failed"]
running = [a for a in agents_list if a.status == "running"]
done = [a for a in agents_list if a.status == "done"]
failed = [a for a in agents_list if a.status == "failed"]
if running:
result.append(f" 运行中 ({len(running)}):")
@@ -355,14 +366,9 @@ class Liugin:
if not agent_id:
return "错误:请提供 Agent ID"
agent = self.agents.get(agent_id)
agent = self._find_agent(agent_id)
if not agent:
# 模糊匹配
matches = [a for a in self.agents.values() if agent_id in a.id or agent_id == a.name]
if matches:
agent = matches[0]
else:
return f"错误:未找到 Agent '{agent_id}'"
return f"错误:未找到 Agent '{agent_id}'"
lines = [
f"Agent: {agent.id}",
@@ -386,13 +392,9 @@ class Liugin:
if not agent_id:
return "错误:请提供 Agent ID"
agent = self.agents.get(agent_id)
agent = self._find_agent(agent_id)
if not agent:
matches = [a for a in self.agents.values() if agent_id in a.id or agent_id == a.name]
if matches:
agent = matches[0]
else:
return f"错误:未找到 Agent '{agent_id}'"
return f"错误:未找到 Agent '{agent_id}'"
if agent.status == "running":
return f"Agent {agent.id} 仍在运行中 (步骤 {agent.steps})"
@@ -417,13 +419,9 @@ class Liugin:
agent_id = parts[0]
message = parts[1]
agent = self.agents.get(agent_id)
agent = self._find_agent(agent_id)
if not agent:
matches = [a for a in self.agents.values() if agent_id in a.id or agent_id == a.name]
if matches:
agent = matches[0]
else:
return f"错误:未找到 Agent '{agent_id}'"
return f"错误:未找到 Agent '{agent_id}'"
if agent.status != "running":
return f"Agent {agent.id} 未在运行 (状态: {agent.status})"
@@ -467,17 +465,15 @@ class Liugin:
except Exception as e:
agent.error = str(e)
agent.status = "failed"
agent.finished_at = datetime.now().strftime("%H:%M:%S")
def _kill(self, args: str) -> str:
"""终止子 Agent"""
agent_id = args.strip()
agent = self.agents.get(agent_id)
agent = self._find_agent(agent_id)
if not agent:
matches = [a for a in self.agents.values() if agent_id in a.id or agent_id == a.name]
if matches:
agent = matches[0]
else:
return f"错误:未找到 Agent '{agent_id}'"
return f"错误:未找到 Agent '{agent_id}'"
agent.status = "failed"
agent.error = "被主 Agent 终止"
+26 -19
View File
@@ -189,6 +189,13 @@ class WorkflowEngine:
self.state_dir = os.path.join(self.workflows_dir, ".state")
os.makedirs(self.state_dir, exist_ok=True)
def _out(self, msg):
"""TUI 兼容输出"""
if self.cli and getattr(self.cli, 'tui_output_callback', None):
self.cli.tui_output_callback(msg)
else:
print(msg)
def run(self, path: str, args: dict = None) -> str:
"""执行工作流"""
if not os.path.exists(path):
@@ -226,8 +233,8 @@ class WorkflowEngine:
'started_at': context['workflow']['started_at'],
})
print(f"\n{Fore.CYAN}🔄 工作流 '{wf_name}' 开始 (ID: {wf_id}){Style.RESET_ALL}")
print(f" 共 {len(steps)} 个步骤\n")
self._out(f"\n{Fore.CYAN}🔄 工作流 '{wf_name}' 开始 (ID: {wf_id}){Style.RESET_ALL}")
self._out(f" 共 {len(steps)} 个步骤\n")
failed = False
for i, step in enumerate(steps):
@@ -240,7 +247,7 @@ class WorkflowEngine:
if condition:
resolved_condition = _resolve_vars(condition, context)
if not self._eval_condition(resolved_condition):
print(f" {Fore.YELLOW}⏭ [{step_name}] 条件不满足,跳过{Style.RESET_ALL}")
self._out(f" {Fore.YELLOW}⏭ [{step_name}] 条件不满足,跳过{Style.RESET_ALL}")
context['steps'][step_name] = {
'output': '', 'exit_code': 0, 'skipped': True
}
@@ -249,7 +256,7 @@ class WorkflowEngine:
# 解析步骤参数
resolved_step = _resolve_value(step, context)
print(f" {Fore.CYAN}▶ [{step_name}] {step_type}{Style.RESET_ALL}")
self._out(f" {Fore.CYAN}▶ [{step_name}] {step_type}{Style.RESET_ALL}")
# 更新状态
self._save_state(wf_id, {
@@ -268,29 +275,29 @@ class WorkflowEngine:
if result.get('exit_code', 0) == 0:
output_preview = str(result.get('output', ''))[:100]
print(f" {Fore.GREEN}✓ [{step_name}] 完成{Style.RESET_ALL}"
self._out(f" {Fore.GREEN}✓ [{step_name}] 完成{Style.RESET_ALL}"
f"{' → ' + output_preview if output_preview else ''}")
else:
failed = True
print(f" {Fore.RED}✗ [{step_name}] 失败 (exit={result.get('exit_code')}){Style.RESET_ALL}")
print(f" {result.get('error', result.get('output', ''))[:200]}")
self._out(f" {Fore.RED}✗ [{step_name}] 失败 (exit={result.get('exit_code')}){Style.RESET_ALL}")
self._out(f" {result.get('error', result.get('output', ''))[:200]}")
if not continue_on_error:
# 检查 on_fail 分支
on_fail = resolved_step.get('on_fail')
if on_fail:
print(f" {Fore.YELLOW}→ 执行 on_fail 分支{Style.RESET_ALL}")
self._out(f" {Fore.YELLOW}→ 执行 on_fail 分支{Style.RESET_ALL}")
self._execute_on_fail(on_fail, context)
break
else:
print(f" {Fore.YELLOW}⚠ continue_on_error=true,继续{Style.RESET_ALL}")
self._out(f" {Fore.YELLOW}⚠ continue_on_error=true,继续{Style.RESET_ALL}")
except Exception as e:
failed = True
context['steps'][step_name] = {
'output': '', 'exit_code': 1, 'error': str(e)
}
print(f" {Fore.RED}✗ [{step_name}] 异常: {e}{Style.RESET_ALL}")
self._out(f" {Fore.RED}✗ [{step_name}] 异常: {e}{Style.RESET_ALL}")
if not continue_on_error:
break
@@ -310,7 +317,7 @@ class WorkflowEngine:
})
icon = '✅' if not failed else '❌'
print(f"\n{icon} 工作流 '{wf_name}' {status} (ID: {wf_id})")
self._out(f"\n{icon} 工作流 '{wf_name}' {status} (ID: {wf_id})")
return f"{icon} 工作流 '{wf_name}' {status}"
def resume(self, wf_id: str) -> str:
@@ -335,7 +342,7 @@ class WorkflowEngine:
start_from = state.get('current_step', 0)
context = state.get('context', {})
print(f"\n{Fore.CYAN}🔄 恢复工作流 '{wf.get('name', wf_id)}' 从步骤 {start_from + 1}{Style.RESET_ALL}")
self._out(f"\n{Fore.CYAN}🔄 恢复工作流 '{wf.get('name', wf_id)}' 从步骤 {start_from + 1}{Style.RESET_ALL}")
failed = False
for i in range(start_from, len(steps)):
@@ -345,23 +352,23 @@ class WorkflowEngine:
continue_on_error = step.get('continue_on_error', False)
resolved_step = _resolve_value(step, context)
print(f" {Fore.CYAN}▶ [{step_name}] {step_type}{Style.RESET_ALL}")
self._out(f" {Fore.CYAN}▶ [{step_name}] {step_type}{Style.RESET_ALL}")
try:
result = self._execute_step(resolved_step, context)
context['steps'][step_name] = result
if result.get('exit_code', 0) == 0:
print(f" {Fore.GREEN}✓ [{step_name}] 完成{Style.RESET_ALL}")
self._out(f" {Fore.GREEN}✓ [{step_name}] 完成{Style.RESET_ALL}")
else:
failed = True
print(f" {Fore.RED}✗ [{step_name}] 失败{Style.RESET_ALL}")
self._out(f" {Fore.RED}✗ [{step_name}] 失败{Style.RESET_ALL}")
if not continue_on_error:
break
except Exception as e:
failed = True
context['steps'][step_name] = {'output': '', 'exit_code': 1, 'error': str(e)}
print(f" {Fore.RED}✗ [{step_name}] 异常: {e}{Style.RESET_ALL}")
self._out(f" {Fore.RED}✗ [{step_name}] 异常: {e}{Style.RESET_ALL}")
if not continue_on_error:
break
@@ -553,7 +560,7 @@ class WorkflowEngine:
"""打印步骤"""
message = step.get('message', step.get('text', ''))
resolved = _resolve_vars(str(message), context)
print(f" 💬 {resolved}")
self._out(f" 💬 {resolved}")
return {'output': resolved, 'exit_code': 0}
def _exec_wait_step(self, step: dict, context: dict) -> dict:
@@ -622,7 +629,7 @@ class WorkflowEngine:
def _execute_on_fail(self, on_fail, context: dict):
"""执行 on_fail 处理"""
if isinstance(on_fail, str):
print(f" 💬 {on_fail}")
self._out(f" 💬 {on_fail}")
elif isinstance(on_fail, list):
for step in on_fail:
self._execute_step(step, context)
@@ -636,7 +643,7 @@ class WorkflowEngine:
with open(path, 'w', encoding='utf-8') as f:
json.dump(state, f, ensure_ascii=False, indent=2, default=str)
except Exception as e:
print(f"{Fore.YELLOW}⚠ 保存状态失败: {e}{Style.RESET_ALL}")
self._out(f"{Fore.YELLOW}⚠ 保存状态失败: {e}{Style.RESET_ALL}")
def _load_state(self, wf_id: str) -> dict:
"""加载工作流状态"""