feat: v3.6 重构 - 拆分 ai_cli.py 为模块化架构
本次更新由 mimo 自动化执行,与开发者无关。 新增 xiaoli/ 包: - __main__.py: 入口点 (python -m xiaoli) - app.py: 主应用薄编排层(~400行 vs 原3860行) - config.py: 配置管理 - engines.py: AI 引擎管理 - sandbox.py: 代码安全沙箱 - prompt.py: 系统提示词构建 - conversation/: 对话解析器 + 历史管理 - display/: CLI/TUI 双模式输出 改进: - 支持 argparse (--tui, --engine, --version) - 插件命令注册表 - 更新 README - 更新 .gitignore 原 ai_cli.py 保留向后兼容
This commit is contained in:
1 parent
95a0e4bc5c
commit
e1eee6a0c2
12 files changed
+1592
-65
No files matched your search
@@ -0,0 +1,3 @@
|
||||
"""小狸 Pro-CLI - 智能编程助手"""
|
||||
__version__ = "3.6.0"
|
||||
__author__ = "shuiyu1123"
|
||||
@@ -0,0 +1,83 @@
|
||||
"""入口点 - python -m xiaoli"""
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
import time
|
||||
from colorama import init
|
||||
|
||||
# 确保项目根目录在 sys.path 中
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="小狸 Pro-CLI - 智能编程助手")
|
||||
parser.add_argument('--tui', action='store_true', help='启动 TUI 模式')
|
||||
parser.add_argument('--engine', type=str, help='指定 AI 引擎')
|
||||
parser.add_argument('--version', action='store_true', help='显示版本')
|
||||
args = parser.parse_args()
|
||||
|
||||
init(autoreset=True)
|
||||
|
||||
if args.version:
|
||||
from xiaoli import __version__
|
||||
print(f"小狸 Pro-CLI v{__version__}")
|
||||
return
|
||||
|
||||
# 启动动画
|
||||
_show_art()
|
||||
|
||||
from xiaoli.app import App
|
||||
app = App()
|
||||
|
||||
if args.engine:
|
||||
app._switch_model(args.engine)
|
||||
|
||||
if args.tui:
|
||||
app._run_tui()
|
||||
else:
|
||||
app.run()
|
||||
|
||||
|
||||
def _show_art():
|
||||
"""启动艺术字"""
|
||||
art = [
|
||||
"█████████████████████████████████████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ▒████████████████████████████████████████████████████",
|
||||
"██████████ ░██████████████████████████████████████████████████████",
|
||||
"██████████ ████████████████████████████████████████████████████████",
|
||||
"██████████ ▓█████████████████████████████████████████████████████████",
|
||||
"██████████ ▒███████████████████████████████████████████████████████████",
|
||||
"██████████ █████████████████████████████████████████████████████████████",
|
||||
"██████████ ███████████████████████████████████████████████████████████████",
|
||||
"██████████ ▓███████████████████████████████████████████████████████████████",
|
||||
"██████████ ░████████████████████████████████████████████████████████████████",
|
||||
"██████████ ██████████████████████████████████████████████████████████████████",
|
||||
"██████████ ████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ▓████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ░██████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ████████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ██████████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ░██████████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ████████████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ████████████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ▓██████████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ████████████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ████████████████████████████████████████████████████████████████████████████",
|
||||
"███████████ ████████████████████████████████████████████████████████████████████████████",
|
||||
"█████████████▓ ████████████████████████████████████████████████████████████████████████████",
|
||||
"█████████████████ ▓██████████████████████████████████████████████████████████████████████████",
|
||||
"███████████████████▒ ████████████████████████████████████████████████████████████████████████████",
|
||||
"█████████████████████████████████████████████████████████████████████████████████████████████████████",
|
||||
"█████████████████████████████████████████████████████████████████████████████████████████████████████",
|
||||
]
|
||||
|
||||
for line in art:
|
||||
try:
|
||||
print(line, flush=True)
|
||||
except UnicodeEncodeError:
|
||||
pass
|
||||
time.sleep(0.03)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+607
@@ -0,0 +1,607 @@
|
||||
"""主应用 - 薄编排层,协调各模块"""
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
import threading
|
||||
import logging
|
||||
import shutil
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from colorama import Fore, Style, init
|
||||
|
||||
from . import __version__
|
||||
from .config import get as cfg_get, set as cfg_set
|
||||
from .display import Display
|
||||
from .engines import EngineManager
|
||||
from .conversation import parse_response, process_thinking, process_code_blocks, extract_tool_calls
|
||||
from .conversation.history import History
|
||||
from .sandbox import Sandbox
|
||||
from . import prompt as prompt_builder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
MAX_FILE_SIZE = 52428800
|
||||
|
||||
|
||||
class App:
|
||||
"""小狸 Pro-CLI 主应用"""
|
||||
|
||||
def __init__(self):
|
||||
init(autoreset=True)
|
||||
self.user_id = str(uuid.getnode())
|
||||
self.dir = PROJECT_DIR
|
||||
|
||||
# 插件命令注册表(必须在 _init_tools 之前)
|
||||
self.liugin_commands = {}
|
||||
self.engine_commands = {}
|
||||
self.current_dir = os.getcwd()
|
||||
self.user_input_queue = {}
|
||||
self.chat_history_dir = os.path.join(self.dir, "chat_history")
|
||||
self.is_clawli = False
|
||||
self.tui_callback = None
|
||||
|
||||
# 子系统
|
||||
self.engine_mgr = EngineManager()
|
||||
self.display = Display(self.user_id)
|
||||
self.sandbox = Sandbox(timeout=cfg_get('code_execution_timeout', 30))
|
||||
self.history = History(os.path.join(self.dir, "chat_history"))
|
||||
|
||||
# 工具
|
||||
self.tool_mgr = None
|
||||
self.liugin_manager = None
|
||||
self._init_tools()
|
||||
if self.tool_mgr:
|
||||
self.liugin_manager = self.tool_mgr
|
||||
|
||||
# 引擎
|
||||
self._init_engines()
|
||||
|
||||
def register_liugin_command(self, command, handler):
|
||||
"""注册插件命令"""
|
||||
self.liugin_commands[command] = handler
|
||||
|
||||
def unregister_liugin_command(self, command):
|
||||
"""注销插件命令"""
|
||||
self.liugin_commands.pop(command, None)
|
||||
|
||||
def register_engine_command(self, command, handler):
|
||||
"""注册引擎命令"""
|
||||
self.engine_commands[command] = handler
|
||||
|
||||
def handle_plugin_message(self, message, **kwargs):
|
||||
"""处理插件消息"""
|
||||
return self.handle_liugin_message(message, **kwargs)
|
||||
|
||||
def _init_engines(self):
|
||||
"""初始化引擎"""
|
||||
engines_dir = os.path.join(self.dir, "ai_engines")
|
||||
self.engine_mgr.load_all(engines_dir)
|
||||
|
||||
default = cfg_get('default_engine', 'ollama')
|
||||
if not self.engine_mgr.set_default(default) and self.engine_mgr.engines:
|
||||
first = next(iter(self.engine_mgr.engines))
|
||||
self.engine_mgr.set_default(first)
|
||||
|
||||
if self.engine_mgr.current:
|
||||
self.engine_mgr.current.cli = self
|
||||
|
||||
# 共享历史
|
||||
for eng in self.engine_mgr.engines.values():
|
||||
eng.shared_conversation_history = self.history.messages
|
||||
eng.max_history = self.history.max
|
||||
|
||||
def _init_tools(self):
|
||||
"""初始化工具"""
|
||||
try:
|
||||
from unified_tool_manager import UnifiedToolManager
|
||||
self.tool_mgr = UnifiedToolManager(self)
|
||||
plugins_dir = os.path.join(self.dir, "plugins")
|
||||
skills_dir = os.path.join(self.dir, "skills")
|
||||
self.tool_mgr.initialize(plugins_dir, skills_dir)
|
||||
except ImportError:
|
||||
logger.warning("UnifiedToolManager 不可用")
|
||||
|
||||
def run(self):
|
||||
"""启动 CLI 主循环"""
|
||||
self.display.banner(
|
||||
self.engine_mgr.current_name(),
|
||||
self.engine_mgr.names(),
|
||||
self.user_id
|
||||
)
|
||||
self._loop()
|
||||
|
||||
def _loop(self):
|
||||
"""CLI 主循环"""
|
||||
while True:
|
||||
try:
|
||||
user_input = input(f"\n{Fore.WHITE}> {Style.RESET_ALL}").strip()
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
# 命令处理
|
||||
if user_input.startswith('/'):
|
||||
if self._handle_command(user_input):
|
||||
continue
|
||||
|
||||
# @文件路径
|
||||
if '@' in user_input:
|
||||
processed = self._process_file_refs(user_input)
|
||||
if processed != user_input:
|
||||
user_input = processed
|
||||
|
||||
# 对话
|
||||
self.process_conversation(user_input)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{Fore.GREEN}再见!{Style.RESET_ALL}")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"{Fore.RED}错误: {e}{Style.RESET_ALL}")
|
||||
|
||||
def _handle_command(self, cmd: str) -> bool:
|
||||
"""处理命令,返回 True 表示已处理"""
|
||||
parts = cmd[1:].split(maxsplit=1)
|
||||
name = parts[0]
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
commands = {
|
||||
'quit': lambda a: sys.exit(0),
|
||||
'help': lambda a: self._show_help(a),
|
||||
'about': lambda a: self._show_about(),
|
||||
'tui': lambda a: self._run_tui(),
|
||||
'model': lambda a: self._switch_model(a),
|
||||
'engine': lambda a: self._engine_cmd(a),
|
||||
'chat': lambda a: self._chat_cmd(a),
|
||||
'remote': lambda a: self._remote_cmd(a),
|
||||
'file.read': lambda a: self._file_read(a),
|
||||
'thinking': lambda a: self._handle_thinking(a),
|
||||
}
|
||||
|
||||
handler = commands.get(name)
|
||||
if handler:
|
||||
handler(args)
|
||||
return True
|
||||
|
||||
# 插件命令
|
||||
if name in self.liugin_commands:
|
||||
try:
|
||||
result = self.liugin_commands[name](args)
|
||||
if result:
|
||||
self.display.out(Display._trunc(result, 5))
|
||||
except Exception as e:
|
||||
self.err(f"插件命令失败: {e}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _switch_model(self, name: str):
|
||||
name = name.strip()
|
||||
if self.engine_mgr.switch(name):
|
||||
self.engine_mgr.current.cli = self
|
||||
self.engine_mgr.current.shared_conversation_history = self.history.messages
|
||||
cfg_set('default_engine', name)
|
||||
self.ok(f"已切换到: {name}")
|
||||
else:
|
||||
self.err(f"未找到引擎: {name}")
|
||||
|
||||
def _engine_cmd(self, args: str):
|
||||
parts = args.split(maxsplit=1)
|
||||
cmd = parts[0] if parts else ""
|
||||
rest = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
if cmd == 'list':
|
||||
for name in self.engine_mgr.names():
|
||||
cur = " (当前)" if name == self.engine_mgr.current_name() else ""
|
||||
print(f" {name}{cur}")
|
||||
elif cmd == 'switch' and rest:
|
||||
self._switch_model(rest)
|
||||
else:
|
||||
print("用法: /engine list | /engine switch <名称>")
|
||||
|
||||
def _chat_cmd(self, args: str):
|
||||
parts = args.split(maxsplit=1)
|
||||
cmd = parts[0] if parts else ""
|
||||
rest = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
if cmd == 'save' and rest:
|
||||
self.history.save(rest)
|
||||
elif cmd == 'list':
|
||||
self.history.list_saved()
|
||||
elif cmd == 'open' and rest:
|
||||
self.history.load(rest)
|
||||
for eng in self.engine_mgr.engines.values():
|
||||
eng.shared_conversation_history = self.history.messages
|
||||
else:
|
||||
print("用法: /chat save/list/open <名称>")
|
||||
|
||||
def _file_read(self, args: str):
|
||||
parts = args.split(maxsplit=1)
|
||||
if not parts:
|
||||
print("用法: /file.read <路径> [行数]")
|
||||
return
|
||||
|
||||
path = parts[0]
|
||||
max_lines = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
||||
|
||||
if not os.path.exists(path):
|
||||
print(f"{Fore.RED}文件不存在: {path}{Style.RESET_ALL}")
|
||||
return
|
||||
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
if max_lines:
|
||||
lines = [f.readline() for _ in range(max_lines)]
|
||||
content = ''.join(lines)
|
||||
print(f"📄 {path} (前 {max_lines} 行):\n{content}")
|
||||
else:
|
||||
content = f.read()
|
||||
if len(content) > 10000:
|
||||
content = content[:10000] + "\n... (截断)"
|
||||
print(f"📄 {path}:\n{content}")
|
||||
|
||||
def _handle_thinking(self, args: str):
|
||||
processed = process_thinking(args, self.engine_mgr.current)
|
||||
processed = process_code_blocks(processed)
|
||||
uid = f"[用户ID: {self.user_id}]"
|
||||
for line in processed.split('\n'):
|
||||
print(f"✦ {line}")
|
||||
print(uid)
|
||||
|
||||
def _show_help(self, args: str = ""):
|
||||
if args:
|
||||
self._show_tool_help(args)
|
||||
return
|
||||
|
||||
tools = []
|
||||
if self.tool_mgr and hasattr(self.tool_mgr, '_tools'):
|
||||
tools = list(self.tool_mgr._tools.keys())
|
||||
|
||||
print(f"""
|
||||
{Fore.CYAN}小狸 Pro-CLI v{__version__} 帮助{Style.RESET_ALL}
|
||||
|
||||
{Fore.WHITE}基本命令:{Style.RESET_ALL}
|
||||
/help 帮助信息
|
||||
/quit 退出
|
||||
/model <引擎> 切换引擎
|
||||
/engine list 列出引擎
|
||||
/chat save/list/open 聊天记录
|
||||
/file.read <路径> 读取文件
|
||||
/about 关于
|
||||
|
||||
{Fore.WHITE}编码工具:{Style.RESET_ALL}
|
||||
code_editor 精准代码编辑
|
||||
code_search 代码搜索理解
|
||||
git_tools Git 版本控制
|
||||
cmd_executor Shell 命令
|
||||
file_manager 文件管理
|
||||
|
||||
{Fore.WHITE}已加载工具 ({len(tools)}):{Style.RESET_ALL}""")
|
||||
|
||||
for t in tools:
|
||||
print(f" • {t}")
|
||||
|
||||
def _show_tool_help(self, name: str):
|
||||
if self.tool_mgr and hasattr(self.tool_mgr, '_tools'):
|
||||
tool = self.tool_mgr._tools.get(name)
|
||||
if tool:
|
||||
print(f"\n{Fore.GREEN}{name}{Style.RESET_ALL}: {tool.get('description', '')}")
|
||||
if 'usage' in tool:
|
||||
print(tool['usage'])
|
||||
return
|
||||
print(f"{Fore.RED}未找到: {name}{Style.RESET_ALL}")
|
||||
|
||||
def _show_about(self):
|
||||
path = os.path.join(self.dir, "about.txt")
|
||||
if os.path.exists(path):
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
for ch in f.read():
|
||||
sys.stdout.write(ch)
|
||||
sys.stdout.flush()
|
||||
time.sleep(0.04 if ch in ',。!?\n' else 0.02)
|
||||
print()
|
||||
|
||||
def _run_tui(self):
|
||||
try:
|
||||
from ai_cli import TUIMainApp
|
||||
app = TUIMainApp(self)
|
||||
app.run()
|
||||
except Exception as e:
|
||||
self.err(f"TUI 不可用: {e}")
|
||||
|
||||
def _remote_cmd(self, args: str):
|
||||
try:
|
||||
from ai_cli import AICLI
|
||||
# 复用原有的远程命令处理
|
||||
temp = AICLI.__new__(AICLI)
|
||||
temp.is_clawli_mode = self.is_clawli
|
||||
temp._handle_remote_command(args)
|
||||
except Exception as e:
|
||||
self.err(f"远程命令失败: {e}")
|
||||
|
||||
def process_conversation(self, user_input: str):
|
||||
"""核心对话循环"""
|
||||
self.history.add("user", user_input)
|
||||
|
||||
tool_prompts = self._get_tool_prompts()
|
||||
has_search = any(t.get('name') == 'tool_search'
|
||||
for t in (self.tool_mgr._tools.values() if self.tool_mgr and hasattr(self.tool_mgr, '_tools') else []))
|
||||
|
||||
sys_prompt = prompt_builder.build(
|
||||
tool_prompts=tool_prompts,
|
||||
has_tool_search=has_search,
|
||||
is_clawli=self.is_clawli
|
||||
)
|
||||
|
||||
loop_count = 0
|
||||
current = user_input
|
||||
|
||||
while loop_count < 20:
|
||||
response = self._generate(current, sys_prompt)
|
||||
processed = process_thinking(response, self.engine_mgr.current)
|
||||
processed = process_code_blocks(processed)
|
||||
|
||||
text, json_data = parse_response(processed)
|
||||
|
||||
if json_data:
|
||||
if text:
|
||||
self.history.add("assistant", text)
|
||||
self.display.ai(text)
|
||||
|
||||
if isinstance(json_data, dict):
|
||||
action = json_data.get('action')
|
||||
if action == 'use_tool':
|
||||
self._run_tool(json_data)
|
||||
break
|
||||
if action == 'continue':
|
||||
content = json_data.get('content', '')
|
||||
if content:
|
||||
self.history.add("assistant", content)
|
||||
self.display.ai(content, is_continue=True)
|
||||
loop_count += 1
|
||||
continue
|
||||
if json_data.get('continue') or json_data.get('need_continue'):
|
||||
msg = json_data.get('message', '')
|
||||
if msg:
|
||||
self.history.add("assistant", msg)
|
||||
self.display.ai(msg, is_continue=True)
|
||||
loop_count += 1
|
||||
continue
|
||||
# 有 message 字段
|
||||
msg = json_data.get('message', '')
|
||||
if msg:
|
||||
self.history.add("assistant", msg)
|
||||
self.display.ai(msg)
|
||||
break
|
||||
|
||||
elif isinstance(json_data, list):
|
||||
tool_calls = [i for i in json_data
|
||||
if isinstance(i, dict) and i.get('action') == 'use_tool']
|
||||
if tool_calls:
|
||||
self._run_tools_batch(tool_calls)
|
||||
break
|
||||
cont = [i for i in json_data
|
||||
if isinstance(i, dict) and (i.get('continue') or i.get('need_continue'))]
|
||||
if cont:
|
||||
msg = cont[0].get('message', '')
|
||||
if msg:
|
||||
self.history.add("assistant", msg)
|
||||
self.display.ai(msg, is_continue=True)
|
||||
loop_count += 1
|
||||
continue
|
||||
|
||||
self.display.ai(text or processed)
|
||||
break
|
||||
else:
|
||||
display = text or processed
|
||||
self.history.add("assistant", display)
|
||||
self.display.ai(display)
|
||||
break
|
||||
|
||||
def _generate(self, user_input: str, sys_prompt: str) -> str:
|
||||
"""生成 AI 响应(带动画)"""
|
||||
if self.tui_callback:
|
||||
resp = self.engine_mgr.generate(user_input, sys_prompt)
|
||||
if resp:
|
||||
self.history.add("assistant", resp)
|
||||
return resp
|
||||
|
||||
stop = threading.Event()
|
||||
sentences = self._load_sentences()
|
||||
anim = threading.Thread(target=self.display.thinking, args=(sentences, stop), daemon=True)
|
||||
anim.start()
|
||||
|
||||
try:
|
||||
resp = self.engine_mgr.generate(user_input, sys_prompt)
|
||||
if resp:
|
||||
self.display.typeprint(resp, Fore.CYAN)
|
||||
self.history.add("assistant", resp)
|
||||
return resp
|
||||
finally:
|
||||
stop.set()
|
||||
anim.join()
|
||||
|
||||
def _run_tool(self, data: dict):
|
||||
"""执行单个工具"""
|
||||
name = data.get('tool', '未知')
|
||||
args = data.get('args', '')
|
||||
uid = self.user_id
|
||||
|
||||
# ESC 取消(仅 Windows)
|
||||
try:
|
||||
import msvcrt
|
||||
canceled = False
|
||||
for i in range(25):
|
||||
if msvcrt.kbhit() and msvcrt.getch() == b'\x1b':
|
||||
canceled = True
|
||||
break
|
||||
spin = '⊶' if i % 2 == 0 else '⊷'
|
||||
disp_args = args[:27] + "..." if len(args) > 30 else args
|
||||
sys.stdout.write(f"\r 正在执行: {spin} {name} {disp_args} (ESC取消)")
|
||||
sys.stdout.flush()
|
||||
time.sleep(0.2)
|
||||
sys.stdout.write("\r" + " " * 60 + "\r")
|
||||
if canceled:
|
||||
print(f"{Fore.YELLOW}已取消{Style.RESET_ALL}")
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
result = self._execute_tool(data)
|
||||
is_err = isinstance(result, str) and (result.startswith("错误") or result.startswith("工具执行错误"))
|
||||
if is_err:
|
||||
self.display.tool_err(name, args, result)
|
||||
else:
|
||||
self.display.tool_ok(name, args, result)
|
||||
|
||||
ts = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.history.add("assistant", f"工具结果 ({ts}):\n{result}")
|
||||
self.process_conversation(f"工具执行结果 ({ts}):\n{result}")
|
||||
|
||||
def _run_tools_batch(self, calls: list):
|
||||
"""批量并发执行工具"""
|
||||
self.display.info(f"批量执行 {len(calls)} 个工具")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(len(calls), 10)) as pool:
|
||||
futures = {pool.submit(self._execute_tool, c): i for i, c in enumerate(calls)}
|
||||
results = []
|
||||
for future in as_completed(futures):
|
||||
idx = futures[future]
|
||||
try:
|
||||
results.append((idx, future.result()))
|
||||
except Exception as e:
|
||||
results.append((idx, f"错误: {e}"))
|
||||
|
||||
results.sort(key=lambda x: x[0])
|
||||
ts = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
for i, (idx, result) in enumerate(results):
|
||||
name = calls[idx].get('tool', '未知')
|
||||
args = calls[idx].get('args', '')
|
||||
is_err = isinstance(result, str) and result.startswith("错误")
|
||||
if is_err:
|
||||
self.display.tool_err(name, args, result)
|
||||
else:
|
||||
self.display.tool_ok(name, args, result)
|
||||
|
||||
all_results = "\n".join([f"工具{i + 1}: {r}" for i, (_, r) in enumerate(results)])
|
||||
self.history.add("assistant", f"批量结果 ({ts}):\n{all_results}")
|
||||
self.process_conversation(f"批量工具结果 ({ts}):\n{all_results}")
|
||||
|
||||
def _execute_tool(self, data: dict) -> str:
|
||||
"""调用统一工具管理器执行"""
|
||||
name = data.get('tool', '')
|
||||
args = data.get('args', '')
|
||||
arguments = data.get('arguments')
|
||||
|
||||
try:
|
||||
if self.tool_mgr and hasattr(self.tool_mgr, 'execute'):
|
||||
if arguments and isinstance(arguments, dict):
|
||||
result = self.tool_mgr.execute(name, args, **arguments)
|
||||
else:
|
||||
result = self.tool_mgr.execute(name, args)
|
||||
if isinstance(result, dict):
|
||||
return result.get('result', str(result))
|
||||
return str(result) if result else "无结果"
|
||||
except Exception as e:
|
||||
return f"工具执行错误: {e}"
|
||||
|
||||
return f"未找到工具: {name}"
|
||||
|
||||
def _get_tool_prompts(self) -> str:
|
||||
"""获取工具提示词"""
|
||||
if not self.tool_mgr or not hasattr(self.tool_mgr, '_tools'):
|
||||
return ""
|
||||
|
||||
lines = []
|
||||
for name, tool in self.tool_mgr._tools.items():
|
||||
desc = tool.get('description', '')
|
||||
lines.append(f"- {name}: {desc}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _load_sentences(self) -> list:
|
||||
path = os.path.join(self.dir, "love.txt")
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return [l.strip() for l in f if l.strip()]
|
||||
except:
|
||||
pass
|
||||
return ["AI正在思考中...", "请稍等片刻..."]
|
||||
|
||||
def _process_file_refs(self, text: str) -> str:
|
||||
"""处理 @文件路径 语法"""
|
||||
pattern = r'@"([^"]+)"|@([^\s]+)'
|
||||
matches = list(re.finditer(pattern, text))
|
||||
if not matches:
|
||||
return text
|
||||
|
||||
image_exts = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'}
|
||||
parts = []
|
||||
last = 0
|
||||
|
||||
for m in matches:
|
||||
if m.start() > last:
|
||||
parts.append(text[last:m.start()])
|
||||
|
||||
path = (m.group(1) or m.group(2) or '').strip()
|
||||
if not path:
|
||||
continue
|
||||
path = os.path.normpath(os.path.expanduser(path))
|
||||
|
||||
if os.path.isdir(path):
|
||||
try:
|
||||
files = [f for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))]
|
||||
parts.append(f"\n[目录: {path} - {len(files)} 个文件]\n" + '\n'.join(files[:20]))
|
||||
except Exception as e:
|
||||
parts.append(f"[错误: {e}]")
|
||||
|
||||
elif os.path.isfile(path):
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext in image_exts:
|
||||
parts.append(f"[图片: {path}]")
|
||||
else:
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if len(content) > MAX_FILE_SIZE:
|
||||
parts.append(f"[错误: 文件过大]")
|
||||
else:
|
||||
parts.append(f"\n[文件: {path}]\n{content}\n")
|
||||
self.display.ok(f"已读取: {path}")
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
with open(path, 'r', encoding='gbk') as f:
|
||||
parts.append(f"\n[文件: {path}]\n{f.read()}\n")
|
||||
except:
|
||||
parts.append(f"[错误: 无法读取 {path}]")
|
||||
except Exception as e:
|
||||
parts.append(f"[错误: {e}]")
|
||||
else:
|
||||
parts.append(f"[错误: 不存在 {path}]")
|
||||
|
||||
last = m.end()
|
||||
|
||||
if last < len(text):
|
||||
parts.append(text[last:])
|
||||
|
||||
return ''.join(parts)
|
||||
|
||||
def handle_liugin_message(self, message: str, **kwargs) -> str:
|
||||
"""处理插件转发的消息(如 QQ)"""
|
||||
sys_prompt = "你是一个智能助手,简洁友好地回复。不要提及你是AI。"
|
||||
if self.engine_mgr.current:
|
||||
resp = self.engine_mgr.generate(message, sys_prompt)
|
||||
return resp or "抱歉,暂时无法回复。"
|
||||
return "AI 引擎未初始化"
|
||||
|
||||
def ok(self, msg):
|
||||
self.display.ok(msg)
|
||||
|
||||
def err(self, msg):
|
||||
self.display.err(msg)
|
||||
|
||||
def cleanup(self):
|
||||
"""清理资源"""
|
||||
self.display.warn("正在清理资源...")
|
||||
self.display.ok("资源清理完成")
|
||||
@@ -0,0 +1,70 @@
|
||||
"""配置管理 - 加载/保存/默认值"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CONFIG_FILE = os.path.join(PROJECT_DIR, "config.json")
|
||||
|
||||
# 默认配置
|
||||
DEFAULTS = {
|
||||
"system": {
|
||||
"default_engine": "ollama",
|
||||
"max_history": 999999,
|
||||
"code_execution_timeout": 30,
|
||||
"max_file_size": 52428800,
|
||||
},
|
||||
"api": {"engines": {}},
|
||||
"plugins": {},
|
||||
}
|
||||
|
||||
_cache = None
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
"""加载配置(带缓存)"""
|
||||
global _cache
|
||||
if _cache is not None:
|
||||
return _cache
|
||||
try:
|
||||
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
|
||||
_cache = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
_cache = DEFAULTS.copy()
|
||||
return _cache
|
||||
|
||||
|
||||
def save(config: dict = None):
|
||||
"""保存配置"""
|
||||
global _cache
|
||||
cfg = config or _cache or DEFAULTS
|
||||
try:
|
||||
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||
_cache = cfg
|
||||
except Exception as e:
|
||||
logger.error(f"保存配置失败: {e}")
|
||||
|
||||
|
||||
def get(key: str, default=None):
|
||||
"""获取系统配置项"""
|
||||
cfg = load()
|
||||
return cfg.get("system", {}).get(key, default)
|
||||
|
||||
|
||||
def set(key: str, value):
|
||||
"""设置系统配置项"""
|
||||
cfg = load()
|
||||
if "system" not in cfg:
|
||||
cfg["system"] = {}
|
||||
cfg["system"][key] = value
|
||||
save(cfg)
|
||||
|
||||
|
||||
def reload():
|
||||
"""强制重新加载"""
|
||||
global _cache
|
||||
_cache = None
|
||||
return load()
|
||||
@@ -0,0 +1,180 @@
|
||||
"""响应解析器 - 从 AI 响应中提取文本和工具调用"""
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
from colorama import Fore, Style
|
||||
from typing import Tuple, Optional, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_response(response: str) -> Tuple[str, Optional[Union[dict, list]]]:
|
||||
"""
|
||||
解析混合响应,分离文本和 JSON 工具调用
|
||||
|
||||
Returns:
|
||||
(text_content, json_data)
|
||||
json_data 可以是 dict(单个调用)或 list(批量调用),或 None
|
||||
"""
|
||||
response = response.strip()
|
||||
if not response:
|
||||
return "", None
|
||||
|
||||
# 方法1: <tool_call> 格式修复
|
||||
if response.startswith("<tool_call>"):
|
||||
try:
|
||||
parsed = json.loads("{" + response[11:])
|
||||
if isinstance(parsed, dict):
|
||||
return "", parsed
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 方法2: 纯 JSON
|
||||
try:
|
||||
parsed = json.loads(response)
|
||||
if isinstance(parsed, (dict, list)):
|
||||
return "", parsed
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 方法3: 代码块中的 JSON
|
||||
pattern = r"```(?:json)?\s*({.*?})\s*```"
|
||||
matches = re.findall(pattern, response, re.DOTALL)
|
||||
if matches:
|
||||
try:
|
||||
data = json.loads(matches[-1])
|
||||
if isinstance(data, (dict, list)):
|
||||
text = re.sub(pattern, "", response, flags=re.DOTALL).strip()
|
||||
return re.sub(r"\n\s*\n", "\n\n", text).strip(), data
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 方法4: 每行独立 JSON
|
||||
lines = response.split("\n")
|
||||
json_objs = []
|
||||
text_lines = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("{") and stripped.endswith("}"):
|
||||
try:
|
||||
obj = json.loads(stripped)
|
||||
if isinstance(obj, dict):
|
||||
json_objs.append(obj)
|
||||
continue
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
text_lines.append(line)
|
||||
|
||||
if json_objs:
|
||||
text = "\n".join(text_lines).strip()
|
||||
return (text, json_objs[0]) if len(json_objs) == 1 else (text, json_objs)
|
||||
|
||||
# 方法5: 内联 JSON(use_tool / continue)
|
||||
for marker in ['{"action": "use_tool"', '{"action": "continue"',
|
||||
'{"action":"use_tool"', '{"action":"continue"']:
|
||||
idx = response.find(marker)
|
||||
if idx != -1:
|
||||
brace = 0
|
||||
for i in range(idx, len(response)):
|
||||
if response[i] == '{':
|
||||
brace += 1
|
||||
elif response[i] == '}':
|
||||
brace -= 1
|
||||
if brace == 0:
|
||||
try:
|
||||
data = json.loads(response[idx:i + 1])
|
||||
if isinstance(data, dict) and data.get('action') in ['use_tool', 'continue']:
|
||||
return response[:idx].strip(), data
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
break
|
||||
|
||||
return response, None
|
||||
|
||||
|
||||
def process_thinking(response: str, engine=None) -> str:
|
||||
"""处理深度思考标记,返回纯回复内容"""
|
||||
# <think/> 格式
|
||||
if '<think/>' in response:
|
||||
parts = response.split('<think/>', 1)
|
||||
if len(parts) >= 2:
|
||||
thinking = parts[0].strip()
|
||||
reply = parts[1].strip()
|
||||
if thinking:
|
||||
short = thinking[:200] + "..." if len(thinking) > 200 else thinking
|
||||
print(f"{Fore.LIGHTBLACK_EX}[思考: {short}]{Style.RESET_ALL}")
|
||||
return reply if reply else ""
|
||||
|
||||
# 自定义标记
|
||||
if engine:
|
||||
start = getattr(engine, 'thinking_start_marker', None)
|
||||
end = getattr(engine, 'thinking_end_marker', None)
|
||||
if start and end and start in response and end in response:
|
||||
parts = response.split(start, 1)
|
||||
if len(parts) > 1 and end in parts[1]:
|
||||
thinking, reply = parts[1].split(end, 1)
|
||||
if thinking.strip():
|
||||
short = thinking.strip()[:200]
|
||||
print(f"{Fore.LIGHTBLACK_EX}[思考: {short}]{Style.RESET_ALL}")
|
||||
return reply.strip()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def process_code_blocks(text: str) -> str:
|
||||
"""处理三引号代码块,红色显示"""
|
||||
def replace(m):
|
||||
inner = m.group(1)[3:-3]
|
||||
return f'{Fore.RED}{inner}{Style.RESET_ALL}'
|
||||
return re.sub(r'(""".*?""")', replace, text, flags=re.DOTALL)
|
||||
|
||||
|
||||
def extract_tool_calls(json_data) -> list:
|
||||
"""从 JSON 数据中提取工具调用列表(支持多种格式)"""
|
||||
calls = []
|
||||
|
||||
if isinstance(json_data, dict):
|
||||
json_data = [json_data]
|
||||
|
||||
if not isinstance(json_data, list):
|
||||
return calls
|
||||
|
||||
for item in json_data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
# 小狸格式
|
||||
if item.get('action') == 'use_tool':
|
||||
calls.append(item)
|
||||
# 简化格式
|
||||
elif 'tool' in item and 'args' in item:
|
||||
calls.append(item)
|
||||
# OpenAI function_call
|
||||
elif 'function_call' in item:
|
||||
fc = item['function_call']
|
||||
args = fc.get('arguments', '')
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except:
|
||||
pass
|
||||
calls.append({
|
||||
'tool': fc.get('name', ''),
|
||||
'args': args if isinstance(args, str) else '',
|
||||
'arguments': args if isinstance(args, dict) else None
|
||||
})
|
||||
# MCP 格式
|
||||
elif 'name' in item and 'arguments' in item:
|
||||
args = item.get('arguments', '')
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except:
|
||||
pass
|
||||
calls.append({
|
||||
'tool': item.get('name', ''),
|
||||
'args': args if isinstance(args, str) else '',
|
||||
'arguments': args if isinstance(args, dict) else None
|
||||
})
|
||||
|
||||
return calls
|
||||
@@ -0,0 +1,86 @@
|
||||
"""对话历史管理"""
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional
|
||||
from colorama import Fore, Style
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class History:
|
||||
"""对话历史管理器"""
|
||||
|
||||
def __init__(self, history_dir: str, max_items: int = 999999):
|
||||
self.dir = history_dir
|
||||
self.max = max_items
|
||||
self.messages = []
|
||||
|
||||
os.makedirs(self.dir, exist_ok=True)
|
||||
|
||||
def add(self, role: str, content: str):
|
||||
"""添加消息"""
|
||||
self.messages.append({"role": role, "content": content})
|
||||
if len(self.messages) > self.max:
|
||||
self.messages = self.messages[-self.max:]
|
||||
|
||||
def get(self) -> list:
|
||||
"""获取全部历史"""
|
||||
return self.messages
|
||||
|
||||
def clear(self):
|
||||
"""清空历史"""
|
||||
self.messages = []
|
||||
|
||||
def set_all(self, messages: list):
|
||||
"""设置全部历史"""
|
||||
self.messages = messages
|
||||
|
||||
def save(self, name: str) -> bool:
|
||||
"""保存到文件"""
|
||||
try:
|
||||
data = {
|
||||
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"conversation": self.messages.copy()
|
||||
}
|
||||
path = os.path.join(self.dir, f"{name}.json")
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2, default=str)
|
||||
print(f"{Fore.GREEN}聊天记录已保存: {name}{Style.RESET_ALL}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"{Fore.RED}保存失败: {e}{Style.RESET_ALL}")
|
||||
return False
|
||||
|
||||
def load(self, name: str) -> bool:
|
||||
"""从文件加载"""
|
||||
try:
|
||||
path = os.path.join(self.dir, f"{name}.json")
|
||||
if not os.path.exists(path):
|
||||
print(f"{Fore.RED}记录 '{name}' 不存在{Style.RESET_ALL}")
|
||||
return False
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
self.messages = data.get("conversation", [])
|
||||
print(f"{Fore.GREEN}已加载: {name} ({len(self.messages)} 条){Style.RESET_ALL}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"{Fore.RED}加载失败: {e}{Style.RESET_ALL}")
|
||||
return False
|
||||
|
||||
def list_saved(self):
|
||||
"""列出所有已保存的记录"""
|
||||
try:
|
||||
files = [f for f in os.listdir(self.dir) if f.endswith('.json')]
|
||||
if not files:
|
||||
print(f"{Fore.YELLOW}没有已保存的记录{Style.RESET_ALL}")
|
||||
return
|
||||
print(f"{Fore.GREEN}已保存的记录:{Style.RESET_ALL}")
|
||||
for i, f in enumerate(files, 1):
|
||||
path = os.path.join(self.dir, f)
|
||||
mtime = time.strftime("%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(os.path.getmtime(path)))
|
||||
print(f"{Fore.GREEN}{i}. {f[:-5]} ({mtime}){Style.RESET_ALL}")
|
||||
except Exception as e:
|
||||
print(f"{Fore.RED}列出记录失败: {e}{Style.RESET_ALL}")
|
||||
@@ -0,0 +1,110 @@
|
||||
"""统一显示输出 - CLI/TUI 双模式"""
|
||||
import sys
|
||||
import time
|
||||
import random
|
||||
import threading
|
||||
from colorama import Fore, Style
|
||||
|
||||
|
||||
class Display:
|
||||
"""统一输出管理器"""
|
||||
|
||||
def __init__(self, user_id="", tui_callback=None):
|
||||
self.user_id = user_id
|
||||
self.tui = tui_callback
|
||||
|
||||
def out(self, msg):
|
||||
"""输出"""
|
||||
if self.tui:
|
||||
self.tui(msg)
|
||||
else:
|
||||
print(msg)
|
||||
|
||||
def ai(self, content, is_continue=False):
|
||||
"""显示 AI 响应"""
|
||||
uid = f"[用户ID: {self.user_id}]"
|
||||
lines = content.split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
prefix = f"{Fore.YELLOW}✦{Style.RESET_ALL} " if is_continue else "✦ "
|
||||
if i == 0:
|
||||
line = prefix + line
|
||||
if i == len(lines) - 1:
|
||||
print(f"{line} {uid}")
|
||||
else:
|
||||
print(line)
|
||||
print()
|
||||
|
||||
def tool_ok(self, name, args, result):
|
||||
"""工具执行成功"""
|
||||
self.out("")
|
||||
uid = f"[用户ID: {self.user_id}]"
|
||||
self.out(f"{Fore.GREEN} OK 工具: {name}{Style.RESET_ALL}")
|
||||
self.out(f"{Fore.GREEN} 参数: {args} {uid}{Style.RESET_ALL}")
|
||||
for line in self._trunc(result).split('\n'):
|
||||
self.out(f"{Fore.GREEN} {line}{Style.RESET_ALL}")
|
||||
|
||||
def tool_err(self, name, args, result):
|
||||
"""工具执行失败"""
|
||||
self.out("")
|
||||
uid = f"[用户ID: {self.user_id}]"
|
||||
self.out(f"{Fore.RED} X 工具: {name}{Style.RESET_ALL}")
|
||||
self.out(f"{Fore.RED} 参数: {args} {uid}{Style.RESET_ALL}")
|
||||
for line in self._trunc(result).split('\n'):
|
||||
self.out(f"{Fore.RED} {line}{Style.RESET_ALL}")
|
||||
|
||||
def err(self, msg):
|
||||
self.out(f"{Fore.RED}{msg}{Style.RESET_ALL}")
|
||||
|
||||
def ok(self, msg):
|
||||
self.out(f"{Fore.GREEN}{msg}{Style.RESET_ALL}")
|
||||
|
||||
def info(self, msg):
|
||||
self.out(f"{Fore.CYAN}{msg}{Style.RESET_ALL}")
|
||||
|
||||
def warn(self, msg):
|
||||
self.out(f"{Fore.YELLOW}{msg}{Style.RESET_ALL}")
|
||||
|
||||
def typeprint(self, text, color=None, delay=0.01):
|
||||
"""打字机效果"""
|
||||
for ch in text:
|
||||
print(f"{color}{ch}{Style.RESET_ALL}" if color else ch, end="", flush=True)
|
||||
time.sleep(delay)
|
||||
print()
|
||||
|
||||
def thinking(self, sentences, stop_event):
|
||||
"""等待动画"""
|
||||
if not sentences:
|
||||
sentences = ["AI正在思考中...", "请稍等片刻..."]
|
||||
last_change = time.time()
|
||||
cur = random.choice(sentences)
|
||||
chars = "|/-\\"
|
||||
idx = 0
|
||||
while not stop_event.is_set():
|
||||
now = time.time()
|
||||
if now - last_change >= 5:
|
||||
cur = random.choice(sentences)
|
||||
last_change = now
|
||||
disp = cur[:47] + "..." if len(cur) > 50 else cur
|
||||
print(f"\r{Fore.MAGENTA}{disp} {chars[idx % 4]}{Style.RESET_ALL}", end="", flush=True)
|
||||
idx += 1
|
||||
time.sleep(0.1)
|
||||
print("\r" + " " * 60 + "\r", end="", flush=True)
|
||||
|
||||
def banner(self, engine_name, engines, user_id):
|
||||
"""启动横幅"""
|
||||
self.ok(f"小狸 Pro-CLI v3.6 已启动!")
|
||||
self.ok(f"用户ID: {user_id}")
|
||||
print(f"{Fore.GREEN}输入 '/help' 查看帮助 | '/quit' 退出{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}当前引擎: {engine_name}{Style.RESET_ALL}")
|
||||
if len(engines) > 1:
|
||||
print(f"{Fore.GREEN}已加载: {', '.join(engines)}{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}{'-' * 50}{Style.RESET_ALL}")
|
||||
|
||||
@staticmethod
|
||||
def _trunc(text, max_lines=2):
|
||||
if not text:
|
||||
return "无结果"
|
||||
lines = text.split('\n')
|
||||
if len(lines) <= max_lines:
|
||||
return text
|
||||
return '\n'.join(lines[:max_lines]) + f"\n... (已截断,共{len(lines)}行)"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""AI 引擎管理 - 动态加载和切换"""
|
||||
import os
|
||||
import importlib.util
|
||||
import logging
|
||||
from colorama import Fore, Style
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EngineManager:
|
||||
"""AI 引擎管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.engines = {}
|
||||
self.current = None
|
||||
|
||||
def load_all(self, engines_dir: str) -> dict:
|
||||
"""加载目录中所有引擎"""
|
||||
print(f"{Fore.GREEN}正在加载AI引擎...{Style.RESET_ALL}")
|
||||
|
||||
if not os.path.exists(engines_dir):
|
||||
print(f"{Fore.YELLOW}引擎目录不存在{Style.RESET_ALL}")
|
||||
return self.engines
|
||||
|
||||
for fn in sorted(os.listdir(engines_dir)):
|
||||
if not fn.endswith('.py') or fn == '__init__.py':
|
||||
continue
|
||||
|
||||
name = fn[:-3]
|
||||
path = os.path.join(engines_dir, fn)
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
cls = self._find_class(module, name)
|
||||
if not cls:
|
||||
continue
|
||||
|
||||
instance = getattr(module, cls)()
|
||||
key = getattr(instance, 'name', name)
|
||||
|
||||
if key not in self.engines:
|
||||
self.engines[key] = instance
|
||||
print(f"{Fore.GREEN} ✓ {key} ({cls}){Style.RESET_ALL}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"加载引擎失败 {fn}: {e}")
|
||||
print(f"{Fore.RED} ✗ {fn}: {e}{Style.RESET_ALL}")
|
||||
|
||||
print(f"{Fore.GREEN}共 {len(self.engines)} 个引擎{Style.RESET_ALL}")
|
||||
return self.engines
|
||||
|
||||
def _find_class(self, module, engine_name):
|
||||
"""查找引擎类"""
|
||||
patterns = [
|
||||
engine_name.capitalize() + "AI",
|
||||
engine_name.replace('_', '').replace('-', '').capitalize() + "AI",
|
||||
''.join(w.capitalize() for w in engine_name.replace('-', ' ').replace('_', ' ').split()) + "AI",
|
||||
]
|
||||
for name in patterns:
|
||||
if hasattr(module, name):
|
||||
return name
|
||||
for attr in dir(module):
|
||||
obj = getattr(module, attr)
|
||||
if (isinstance(obj, type) and attr.endswith('AI') and
|
||||
hasattr(obj, 'generate_response')):
|
||||
return attr
|
||||
return None
|
||||
|
||||
def set_default(self, name: str) -> bool:
|
||||
if name in self.engines:
|
||||
self.current = self.engines[name]
|
||||
return True
|
||||
return False
|
||||
|
||||
def switch(self, name: str) -> bool:
|
||||
if name not in self.engines:
|
||||
return False
|
||||
self.current = self.engines[name]
|
||||
return True
|
||||
|
||||
def current_name(self) -> str:
|
||||
return getattr(self.current, 'name', '未设置') if self.current else '未设置'
|
||||
|
||||
def names(self) -> list:
|
||||
return list(self.engines.keys())
|
||||
|
||||
def generate(self, user_input: str, system_prompt: str = None) -> str:
|
||||
if not self.current:
|
||||
return "错误: 没有可用的AI引擎"
|
||||
return self.current.generate_response(user_input, system_prompt=system_prompt)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""系统提示词构建"""
|
||||
|
||||
|
||||
def build(tool_prompts: str = None, has_tool_search: bool = False,
|
||||
is_clawli: bool = False) -> str:
|
||||
"""构建系统提示词"""
|
||||
|
||||
base = """你叫小狸,是一个强大的智能编程助手。
|
||||
|
||||
## 核心能力
|
||||
1. **精准代码编辑** - 用 code_editor 搜索替换、批量编辑、diff 对比
|
||||
2. **代码搜索理解** - 用 code_search 跨文件搜索、符号提取、依赖分析
|
||||
3. **Git 版本控制** - 用 git_tools 查看状态、提交、分支管理
|
||||
4. **Shell 命令执行** - 用 cmd_executor 执行系统命令
|
||||
5. **文件管理** - 用 file_manager 管理文件目录"""
|
||||
|
||||
tool_section = f"\n\n## 可用工具\n{tool_prompts}" if tool_prompts else ""
|
||||
|
||||
search_rule = ""
|
||||
if has_tool_search:
|
||||
search_rule = """
|
||||
|
||||
## 工具查询
|
||||
不熟悉的工具先查用法: {"action":"use_tool","tool":"tool_search","args":"工具名"}
|
||||
例外: code_editor, code_search, git_tools, ai_search 可直接使用。"""
|
||||
|
||||
workflow = """
|
||||
|
||||
## 工具调用格式
|
||||
在回复中包含 JSON 指令执行操作:
|
||||
|
||||
纯 JSON: {"action":"use_tool","tool":"code_editor","args":"edit file.py old <<<>>> new"}
|
||||
文本+JSON: 好的,我来修改。{"action":"use_tool","tool":"code_editor","args":"edit file.py old <<<>>> new"}
|
||||
|
||||
## 编程工作流
|
||||
1. 理解代码 → code_search structure/find/regex
|
||||
2. 查看上下文 → code_editor read_range
|
||||
3. 精准修改 → code_editor edit (old <<<>>> new)
|
||||
4. 验证变更 → git_tools diff
|
||||
5. 提交代码 → git_tools commit
|
||||
|
||||
## code_editor.edit 格式
|
||||
{"action":"use_tool","tool":"code_editor","args":"edit 路径
|
||||
旧代码(必须与文件完全匹配,含缩进)
|
||||
<<<>>>
|
||||
新代码"}
|
||||
|
||||
## Git 操作
|
||||
- 状态: {"action":"use_tool","tool":"git_tools","args":"status"}
|
||||
- 提交: {"action":"use_tool","tool":"git_tools","args":"commit 说明"}
|
||||
- 日志: {"action":"use_tool","tool":"git_tools","args":"log --oneline 10"}
|
||||
|
||||
## 补充回答
|
||||
{"action":"continue","content":"补充内容"}
|
||||
|
||||
## 注意事项
|
||||
- edit 的 old_text 必须与文件内容完全匹配(含缩进和空行)
|
||||
- edit 失败时先用 read_range 查看实际内容
|
||||
- args 必须是字符串
|
||||
- 可返回多个 JSON 指令顺序执行"""
|
||||
|
||||
clawli_extra = ""
|
||||
if is_clawli:
|
||||
clawli_extra = """
|
||||
|
||||
## Clawli 远程模式
|
||||
当用户请求发图到手机: {"action":"use_tool","tool":"send_image","args":"{\"image_path\":\"路径\",\"caption\":\"说明\"}"}"""
|
||||
|
||||
return base + tool_section + search_rule + workflow + clawli_extra
|
||||
@@ -0,0 +1,145 @@
|
||||
"""安全代码沙箱"""
|
||||
import io
|
||||
import ast
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
import traceback
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
from colorama import Fore, Style
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DANGEROUS_MODULES = frozenset({
|
||||
'os', 'sys', 'subprocess', 'shutil', 'socket', 'pickle',
|
||||
'marshal', 'ctypes', 'multiprocessing', 'threading',
|
||||
'signal', 'resource', 'posix', 'nt', 'builtins',
|
||||
'importlib', 'code', 'codeop',
|
||||
})
|
||||
|
||||
DANGEROUS_NAMES = frozenset({
|
||||
'exec', 'eval', 'compile', 'execfile', 'open',
|
||||
'globals', 'locals', 'vars', 'getattr', 'setattr',
|
||||
'delattr', '__import__', '__builtins__', '__subclasses__',
|
||||
})
|
||||
|
||||
DANGEROUS_PATTERNS = [
|
||||
r'__', r'\beval\b', r'\bexec\b', r'\bcompile\b',
|
||||
r'\bglobals\b', r'\blocals\b', r'\bgetattr\b',
|
||||
r'\bsetattr\b', r'\b__import__\b',
|
||||
]
|
||||
|
||||
|
||||
class Sandbox:
|
||||
"""安全代码执行沙箱"""
|
||||
|
||||
def __init__(self, timeout=30, whitelist=None):
|
||||
self.enabled = True
|
||||
self.timeout = timeout
|
||||
self.whitelist = whitelist or [
|
||||
'json', 'math', 'datetime', 'random', 'collections',
|
||||
'itertools', 'functools', 'operator', 'typing', 're'
|
||||
]
|
||||
self.history = []
|
||||
|
||||
def is_safe(self, code: str) -> bool:
|
||||
"""快速安全检查"""
|
||||
for pattern in DANGEROUS_PATTERNS:
|
||||
if re.search(pattern, code, re.IGNORECASE):
|
||||
return False
|
||||
|
||||
try:
|
||||
tree = ast.parse(code)
|
||||
except SyntaxError:
|
||||
return False
|
||||
|
||||
class Checker(ast.NodeVisitor):
|
||||
def __init__(self):
|
||||
self.ok = True
|
||||
|
||||
def _check(self, name):
|
||||
if name in DANGEROUS_NAMES or name in DANGEROUS_MODULES:
|
||||
self.ok = False
|
||||
|
||||
def visit_Import(self, node):
|
||||
for alias in node.names:
|
||||
if alias.name.split('.')[0] in DANGEROUS_MODULES:
|
||||
self.ok = False
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ImportFrom(self, node):
|
||||
if node.module and node.module.split('.')[0] in DANGEROUS_MODULES:
|
||||
self.ok = False
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Call(self, node):
|
||||
if isinstance(node.func, ast.Name):
|
||||
self._check(node.func.id)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Name(self, node):
|
||||
self._check(node.id)
|
||||
self.generic_visit(node)
|
||||
|
||||
checker = Checker()
|
||||
checker.visit(tree)
|
||||
return checker.ok
|
||||
|
||||
def run(self, code: str, variables: dict = None) -> dict:
|
||||
"""执行代码"""
|
||||
if not self.enabled:
|
||||
return {'success': False, 'result': '沙箱已禁用', 'stdout': '', 'stderr': '', 'time': 0}
|
||||
|
||||
if not self.is_safe(code):
|
||||
return {'success': False, 'result': '代码包含危险操作', 'stdout': '', 'stderr': '', 'time': 0}
|
||||
|
||||
exec_vars = dict(variables or {})
|
||||
exec_vars.update({
|
||||
'print': print, 'len': len, 'str': str, 'int': int,
|
||||
'float': float, 'list': list, 'dict': dict, 'tuple': tuple,
|
||||
'set': set, 'range': range, 'enumerate': enumerate,
|
||||
'zip': zip, 'sorted': sorted, 'sum': sum, 'max': max,
|
||||
'min': min, 'abs': abs, 'round': round, 'type': type,
|
||||
'isinstance': isinstance,
|
||||
})
|
||||
|
||||
for mod in self.whitelist:
|
||||
try:
|
||||
exec_vars[mod] = __import__(mod)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
start = time.time()
|
||||
result = {'success': True, 'error': None, 'tb': None}
|
||||
|
||||
def _exec():
|
||||
try:
|
||||
exec(code, exec_vars)
|
||||
except Exception as e:
|
||||
result['success'] = False
|
||||
result['error'] = str(e)
|
||||
result['tb'] = traceback.format_exc()
|
||||
|
||||
thread = threading.Thread(target=_exec, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout=self.timeout)
|
||||
elapsed = time.time() - start
|
||||
|
||||
if thread.is_alive():
|
||||
return {'success': False, 'result': f'超时 ({self.timeout}s)',
|
||||
'stdout': stdout.getvalue(), 'stderr': stderr.getvalue(), 'time': self.timeout}
|
||||
|
||||
if result['success']:
|
||||
val = exec_vars.get('result', None)
|
||||
return {'success': True,
|
||||
'result': str(val) if val is not None else '执行成功(无返回值)',
|
||||
'stdout': stdout.getvalue(), 'stderr': stderr.getvalue(), 'time': elapsed}
|
||||
else:
|
||||
return {'success': False,
|
||||
'result': f"错误: {result['error']}",
|
||||
'stdout': stdout.getvalue(),
|
||||
'stderr': stderr.getvalue() + '\n' + (result['tb'] or ''),
|
||||
'time': elapsed}
|
||||
Reference in new issue
Block a user