refactor: 合并v2到v1,删除xiaoli/core包,架构统一
- 用v2的GitHub暗色主题TUI替换v1简陋TUI(侧栏、代码高亮、快捷键) - 用v2的子进程隔离沙箱替换v1的线程内exec(6层安全) - 删除xiaoli/包(v2重构版,7个模块+子包) - 删除core/包(从未被引用的死代码) - 保留v1全部功能:Clawli远程、WebSocket、Web服务器等 - 代码减少约3000行,架构更简洁
This commit is contained in:
1 parent
ab0287d186
commit
4d341a7b55
17 files changed
+666
-3731
No files matched your search
@@ -1,42 +0,0 @@
|
||||
"""核心配置管理模块"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 常量
|
||||
CONFIG_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json")
|
||||
MAX_FILE_SIZE = 52428800 # 50MB
|
||||
DEFAULT_MAX_HISTORY = 999999
|
||||
|
||||
def load_config():
|
||||
"""加载配置文件"""
|
||||
try:
|
||||
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
return {"api": {"engines": {}}, "system": {}, "plugins": {}}
|
||||
except json.JSONDecodeError:
|
||||
return {"api": {"engines": {}}, "system": {}, "plugins": {}}
|
||||
|
||||
def save_config(config):
|
||||
"""保存配置文件"""
|
||||
try:
|
||||
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"保存配置失败: {e}")
|
||||
|
||||
def get_system_config(key, default=None):
|
||||
"""获取系统配置"""
|
||||
config = load_config()
|
||||
return config.get("system", {}).get(key, default)
|
||||
|
||||
def set_system_config(key, value):
|
||||
"""设置系统配置"""
|
||||
config = load_config()
|
||||
if "system" not in config:
|
||||
config["system"] = {}
|
||||
config["system"][key] = value
|
||||
save_config(config)
|
||||
@@ -1,315 +0,0 @@
|
||||
"""
|
||||
对话处理模块 - 核心对话循环和工具调用
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from colorama import Fore, Style
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConversationManager:
|
||||
"""对话管理器 - 处理用户输入、AI 响应、工具调用循环"""
|
||||
|
||||
def __init__(self, cli):
|
||||
self.cli = cli
|
||||
self.history = []
|
||||
self.max_history = 999999
|
||||
|
||||
def process(self, user_input: str):
|
||||
"""处理完整的对话循环"""
|
||||
self.history.append({"role": "user", "content": user_input})
|
||||
|
||||
tool_prompts = self._get_tool_prompts()
|
||||
loop_count = 0
|
||||
current_input = user_input
|
||||
max_loops = 20
|
||||
|
||||
while loop_count < max_loops:
|
||||
response = self._generate_with_animation(current_input, tool_prompts)
|
||||
processed = self._process_thinking(response)
|
||||
processed = self._process_code_blocks(processed)
|
||||
|
||||
try:
|
||||
text_content, json_data = self._parse_response(processed)
|
||||
|
||||
if json_data:
|
||||
if text_content:
|
||||
self._add_and_display(text_content)
|
||||
|
||||
if isinstance(json_data, dict):
|
||||
result = self._handle_dict(json_data, text_content, processed)
|
||||
if result == "break":
|
||||
break
|
||||
elif result == "continue":
|
||||
loop_count += 1
|
||||
continue
|
||||
elif isinstance(json_data, list):
|
||||
result = self._handle_list(json_data, text_content, processed)
|
||||
if result == "break":
|
||||
break
|
||||
elif result == "continue":
|
||||
loop_count += 1
|
||||
continue
|
||||
else:
|
||||
display = text_content if text_content else processed
|
||||
self._add_and_display(display)
|
||||
break
|
||||
|
||||
except json.JSONDecodeError:
|
||||
text_content, json_data = self._parse_response(processed)
|
||||
if json_data:
|
||||
# Re-process with parsed data
|
||||
pass
|
||||
else:
|
||||
self._add_and_display(processed)
|
||||
break
|
||||
|
||||
if loop_count >= max_loops:
|
||||
self.cli.display.output(f"AI 已达到最大循环次数 ({max_loops}),已停止")
|
||||
|
||||
def _handle_dict(self, data, text_content, processed):
|
||||
"""处理 dict 类型的 JSON 数据"""
|
||||
if data.get('action') == 'use_tool':
|
||||
self.cli._execute_single_tool(data)
|
||||
return "break"
|
||||
|
||||
if data.get('action') == 'continue':
|
||||
content = data.get('content', '')
|
||||
if content:
|
||||
self._add_and_display(content)
|
||||
return "continue"
|
||||
|
||||
if data.get('continue') or data.get('need_continue') or data.get('think_more'):
|
||||
msg = data.get('message', '')
|
||||
if msg:
|
||||
self.history.append({"role": "assistant", "content": msg})
|
||||
self.cli.display.response(msg, is_continue=True)
|
||||
return "continue"
|
||||
|
||||
msg = data.get('message', '')
|
||||
if msg:
|
||||
self._add_and_display(msg)
|
||||
return "break"
|
||||
|
||||
if text_content:
|
||||
self._add_and_display(text_content)
|
||||
else:
|
||||
self._add_and_display(processed)
|
||||
return "break"
|
||||
|
||||
def _handle_list(self, data_list, text_content, processed):
|
||||
"""处理 list 类型的 JSON 数据"""
|
||||
tool_calls = [item for item in data_list
|
||||
if isinstance(item, dict) and item.get('action') == 'use_tool']
|
||||
|
||||
if tool_calls:
|
||||
self.cli._execute_concurrent_tools(tool_calls)
|
||||
return "break"
|
||||
|
||||
continue_items = [item for item in data_list
|
||||
if isinstance(item, dict) and
|
||||
(item.get('continue') or item.get('need_continue') or item.get('think_more'))]
|
||||
|
||||
if continue_items:
|
||||
msg = continue_items[0].get('message', '')
|
||||
if msg:
|
||||
self.history.append({"role": "assistant", "content": msg})
|
||||
self.cli.display.response(msg, is_continue=True)
|
||||
return "continue"
|
||||
|
||||
if text_content:
|
||||
self._add_and_display(text_content)
|
||||
else:
|
||||
self._add_and_display(processed)
|
||||
return "break"
|
||||
|
||||
def _add_and_display(self, content):
|
||||
"""添加到历史并显示"""
|
||||
self.history.append({"role": "assistant", "content": content})
|
||||
self.cli.display.response(content)
|
||||
|
||||
def _generate_with_animation(self, user_input, tool_prompts=None):
|
||||
"""生成 AI 响应(带动画)"""
|
||||
system_prompt = self._build_system_prompt(tool_prompts)
|
||||
|
||||
if not self.cli.engine_manager.current_engine:
|
||||
return "错误: 当前没有可用的AI引擎"
|
||||
|
||||
# TUI 模式:直接生成
|
||||
if self.cli.display.tui_callback:
|
||||
response = self.cli.engine_manager.generate_response(user_input, system_prompt)
|
||||
if response:
|
||||
self.history.append({"role": "assistant", "content": response})
|
||||
return response
|
||||
|
||||
# CLI 模式:带动画
|
||||
stop_event = threading.Event()
|
||||
esc_event = threading.Event()
|
||||
|
||||
# 加载等待句子
|
||||
sentences = self._load_love_sentences()
|
||||
|
||||
anim_thread = threading.Thread(
|
||||
target=self.cli.display.thinking_animation,
|
||||
args=(sentences, stop_event, esc_event), daemon=True)
|
||||
anim_thread.start()
|
||||
|
||||
try:
|
||||
response = self.cli.engine_manager.generate_response(user_input, system_prompt)
|
||||
if response:
|
||||
self.cli.display.typeprint(response, Fore.CYAN)
|
||||
self.history.append({"role": "assistant", "content": response})
|
||||
return response
|
||||
finally:
|
||||
stop_event.set()
|
||||
anim_thread.join()
|
||||
|
||||
def _build_system_prompt(self, tool_prompts=None):
|
||||
"""构建系统提示词"""
|
||||
base = ("你叫小狸,是一个强大的智能编程助手。你具备精准的代码编辑能力、"
|
||||
"完整的 Git 工作流支持、以及跨文件代码搜索和理解能力。"
|
||||
"你可以帮助用户编写、修改、调试代码,管理版本控制,理解复杂代码库。")
|
||||
|
||||
tool_section = ""
|
||||
if tool_prompts:
|
||||
tool_section = f"\n\n{tool_prompts}"
|
||||
|
||||
call_format = """
|
||||
【工具调用格式】
|
||||
当需要执行操作时,在回复中包含 JSON 指令:
|
||||
|
||||
1. 纯 JSON: {"action": "use_tool", "tool": "工具名", "args": "参数"}
|
||||
2. 文本+JSON: 好的,我来修改这个文件。{"action": "use_tool", "tool": "code_editor", "args": "edit file.py old_code <<<>>> new_code"}
|
||||
|
||||
【核心工具】
|
||||
- code_editor: 精准代码编辑(edit/search/diff/insert/create/write)
|
||||
- code_search: 代码搜索与理解(find/regex/symbols/imports/structure)
|
||||
- git_tools: Git 操作(status/diff/log/add/commit/branch)
|
||||
- cmd_executor: 执行 shell 命令
|
||||
- file_manager: 文件管理
|
||||
|
||||
【编码工作流】
|
||||
1. 先用 code_search 了解代码结构
|
||||
2. 用 code_editor.read_range 查看相关代码
|
||||
3. 用 code_editor.edit 精准修改
|
||||
4. 用 git_tools 提交变更
|
||||
|
||||
【注意事项】
|
||||
- code_editor.edit 的格式: old_text <<<>>> new_text(用 <<<>>> 分隔)
|
||||
- args 必须是字符串
|
||||
- 可以一次返回多个 JSON 指令顺序执行
|
||||
- 使用 continue 补充回答: {"action": "continue", "content": "补充内容"}"""
|
||||
|
||||
return base + tool_section + call_format
|
||||
|
||||
def _process_thinking(self, response):
|
||||
"""处理深度思考标记"""
|
||||
# <think/> 格式
|
||||
if '<think/>' in response:
|
||||
parts = response.split('<think/>', 1)
|
||||
if len(parts) >= 2:
|
||||
thinking = parts[0].strip()
|
||||
reply = parts[1].strip()
|
||||
if thinking:
|
||||
print(f"{Fore.LIGHTBLACK_EX}[思考: {thinking[:200]}...]{Style.RESET_ALL}" if len(thinking) > 200 else f"{Fore.LIGHTBLACK_EX}[思考: {thinking}]{Style.RESET_ALL}")
|
||||
return reply if reply else ""
|
||||
|
||||
# <thinking>...</thinking> 格式
|
||||
engine = self.cli.engine_manager.current_engine
|
||||
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():
|
||||
print(f"{Fore.LIGHTBLACK_EX}[思考: {thinking.strip()[:200]}]{Style.RESET_ALL}")
|
||||
return reply.strip()
|
||||
|
||||
return response
|
||||
|
||||
def _process_code_blocks(self, text):
|
||||
"""处理代码块标记"""
|
||||
pattern = r'(""".*?""")'
|
||||
def replace(m):
|
||||
inner = m.group(1)[3:-3]
|
||||
return f'{Fore.RED}{inner}{Style.RESET_ALL}'
|
||||
return re.sub(pattern, replace, text, flags=re.DOTALL)
|
||||
|
||||
def _parse_response(self, response):
|
||||
"""解析混合响应(文本+JSON)"""
|
||||
response = response.strip()
|
||||
|
||||
# 直接 JSON
|
||||
try:
|
||||
parsed = json.loads(response)
|
||||
if isinstance(parsed, (dict, list)):
|
||||
return "", parsed
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 代码块中的 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 text, data
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 内联 JSON
|
||||
markers = ['{"action": "use_tool"', '{"action": "continue"',
|
||||
'{"action":"use_tool"', '{"action":"continue"']
|
||||
for marker in markers:
|
||||
idx = response.find(marker)
|
||||
if idx != -1:
|
||||
brace_count = 0
|
||||
for i in range(idx, len(response)):
|
||||
if response[i] == '{':
|
||||
brace_count += 1
|
||||
elif response[i] == '}':
|
||||
brace_count -= 1
|
||||
if brace_count == 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 _get_tool_prompts(self):
|
||||
"""获取工具提示词"""
|
||||
if not self.cli.tool_manager or not self.cli.tool_manager.tools:
|
||||
return "当前没有可用的工具"
|
||||
|
||||
lines = ["可用工具列表:\n"]
|
||||
for tool in self.cli.tool_manager.tools:
|
||||
name = tool.get('name', '')
|
||||
desc = tool.get('description', '')
|
||||
lines.append(f"- {name}: {desc}")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _load_love_sentences(self):
|
||||
"""加载等待动画句子"""
|
||||
import os
|
||||
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "love.txt")
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return [l.strip() for l in f.readlines() if l.strip()]
|
||||
except:
|
||||
pass
|
||||
return ["AI正在思考中...", "请稍等片刻...", "正在处理您的请求..."]
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
"""
|
||||
显示与输出模块 - 统一 CLI/TUI 输出
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import random
|
||||
import shutil
|
||||
import threading
|
||||
from colorama import Fore, Style
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Display:
|
||||
"""统一显示管理器"""
|
||||
|
||||
def __init__(self, user_id="", tui_callback=None):
|
||||
self.user_id = user_id
|
||||
self.tui_callback = tui_callback
|
||||
|
||||
def output(self, message):
|
||||
"""统一输出方法"""
|
||||
if self.tui_callback:
|
||||
self.tui_callback(message)
|
||||
else:
|
||||
print(message)
|
||||
|
||||
def response(self, content, is_continue=False):
|
||||
"""显示 AI 响应"""
|
||||
user_id_display = f"[用户ID: {self.user_id}]"
|
||||
lines = content.split('\n')
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if i == 0:
|
||||
prefix = f"{Fore.YELLOW}✦{Style.RESET_ALL} " if is_continue else "✦ "
|
||||
line = prefix + line
|
||||
if i == len(lines) - 1:
|
||||
print(f"{line} {user_id_display}")
|
||||
else:
|
||||
print(line)
|
||||
print()
|
||||
|
||||
def tool_result(self, tool_name, tool_args, full_result, is_error=False):
|
||||
"""显示工具执行结果"""
|
||||
if full_result is None:
|
||||
full_result = "无结果"
|
||||
|
||||
self.output("")
|
||||
user_id_display = f"[用户ID: {self.user_id}]"
|
||||
|
||||
if is_error:
|
||||
self.output(f"{Fore.RED} X 工具: {tool_name}{Style.RESET_ALL}")
|
||||
self.output(f"{Fore.RED} 参数: {tool_args} {user_id_display}{Style.RESET_ALL}")
|
||||
else:
|
||||
self.output(f"{Fore.GREEN} OK 工具: {tool_name}{Style.RESET_ALL}")
|
||||
self.output(f"{Fore.GREEN} 参数: {tool_args} {user_id_display}{Style.RESET_ALL}")
|
||||
|
||||
display_result = self._limit_lines(full_result, max_lines=2)
|
||||
color = Fore.RED if is_error else Fore.GREEN
|
||||
for line in display_result.split('\n'):
|
||||
self.output(f"{color} {line}{Style.RESET_ALL}")
|
||||
|
||||
def error(self, message):
|
||||
"""显示错误"""
|
||||
self.output(f"{Fore.RED}{message}{Style.RESET_ALL}")
|
||||
|
||||
def success(self, message):
|
||||
"""显示成功"""
|
||||
self.output(f"{Fore.GREEN}{message}{Style.RESET_ALL}")
|
||||
|
||||
def info(self, message):
|
||||
"""显示信息"""
|
||||
self.output(f"{Fore.CYAN}{message}{Style.RESET_ALL}")
|
||||
|
||||
def warning(self, message):
|
||||
"""显示警告"""
|
||||
self.output(f"{Fore.YELLOW}{message}{Style.RESET_ALL}")
|
||||
|
||||
def typeprint(self, text, color=None, delay=0.01):
|
||||
"""打字机效果输出"""
|
||||
for char in text:
|
||||
if color:
|
||||
print(f"{color}{char}{Style.RESET_ALL}", end="", flush=True)
|
||||
else:
|
||||
print(char, end="", flush=True)
|
||||
time.sleep(delay)
|
||||
print()
|
||||
|
||||
def thinking_animation(self, sentences, stop_event, esc_event):
|
||||
"""等待动画"""
|
||||
if not sentences:
|
||||
sentences = ["AI正在思考中...", "请稍等片刻...", "正在处理您的请求..."]
|
||||
|
||||
last_change = time.time()
|
||||
current_sentence = random.choice(sentences)
|
||||
chars = "|/-\\"
|
||||
idx = 0
|
||||
|
||||
while not stop_event.is_set():
|
||||
now = time.time()
|
||||
if now - last_change >= 5:
|
||||
current_sentence = random.choice(sentences)
|
||||
last_change = now
|
||||
|
||||
char = chars[idx % len(chars)]
|
||||
display = current_sentence[:47] + "..." if len(current_sentence) > 50 else current_sentence
|
||||
print(f"\r{Fore.MAGENTA}{display} {char}{Style.RESET_ALL}", end="", flush=True)
|
||||
idx += 1
|
||||
time.sleep(0.1)
|
||||
|
||||
print("\r" + " " * 60 + "\r", end="", flush=True)
|
||||
|
||||
def _limit_lines(self, text, max_lines=2):
|
||||
"""限制输出行数"""
|
||||
lines = text.split('\n')
|
||||
if len(lines) <= max_lines:
|
||||
return text
|
||||
return '\n'.join(lines[:max_lines]) + f"\n... (已截断,共{len(lines)}行)"
|
||||
|
||||
def show_banner(self, engine_name, engines, user_id):
|
||||
"""显示启动横幅"""
|
||||
self.success(f"小狸 Pro-CLI 已启动!")
|
||||
self.success(f"用户ID: {user_id}")
|
||||
print(f"{Fore.GREEN}输入 '/help' 查看帮助信息{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '/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}")
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
"""
|
||||
AI 引擎管理模块 - 动态加载和切换 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_engine = None
|
||||
|
||||
def load_engines(self, engines_dir: str) -> dict:
|
||||
"""动态加载所有 AI 引擎"""
|
||||
print(f"{Fore.GREEN}正在加载AI引擎插件...{Style.RESET_ALL}")
|
||||
|
||||
if not os.path.exists(engines_dir):
|
||||
print(f"{Fore.YELLOW}引擎目录不存在: {engines_dir}{Style.RESET_ALL}")
|
||||
return self.engines
|
||||
|
||||
for filename in os.listdir(engines_dir):
|
||||
if not filename.endswith('.py') or filename == '__init__.py':
|
||||
continue
|
||||
|
||||
engine_name = filename[:-3]
|
||||
engine_path = os.path.join(engines_dir, filename)
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(engine_name, engine_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
class_name = self._find_engine_class(module, engine_name)
|
||||
if class_name and hasattr(module, class_name):
|
||||
engine_instance = getattr(module, class_name)()
|
||||
engine_key = getattr(engine_instance, 'name', engine_name)
|
||||
|
||||
if engine_key not in self.engines:
|
||||
self.engines[engine_key] = engine_instance
|
||||
print(f"{Fore.GREEN}已加载AI引擎: {engine_key} ({class_name}){Style.RESET_ALL}")
|
||||
else:
|
||||
print(f"{Fore.YELLOW}AI引擎 {engine_key} 已存在,跳过{Style.RESET_ALL}")
|
||||
else:
|
||||
print(f"{Fore.YELLOW}引擎插件 {filename} 中未找到合适的引擎类{Style.RESET_ALL}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"加载引擎失败 {filename}: {e}")
|
||||
print(f"{Fore.RED}加载引擎失败 {filename}: {e}{Style.RESET_ALL}")
|
||||
|
||||
print(f"{Fore.GREEN}总共加载了 {len(self.engines)} 个AI引擎{Style.RESET_ALL}")
|
||||
return self.engines
|
||||
|
||||
def _find_engine_class(self, module, engine_name):
|
||||
"""查找引擎类"""
|
||||
possible_names = [
|
||||
engine_name.capitalize() + "AI",
|
||||
engine_name.replace('_', '').replace('-', '').capitalize() + "AI",
|
||||
''.join(w.capitalize() for w in engine_name.replace('-', ' ').replace('_', ' ').split()) + "AI",
|
||||
''.join(w.capitalize() for w in engine_name.replace('_engine', '').replace('-', ' ').replace('_', ' ').split()) + "AI",
|
||||
]
|
||||
|
||||
for name in possible_names:
|
||||
if hasattr(module, name):
|
||||
return name
|
||||
|
||||
# 通用查找
|
||||
for attr_name in dir(module):
|
||||
attr = getattr(module, attr_name)
|
||||
if (isinstance(attr, type) and
|
||||
attr_name.endswith('AI') and
|
||||
hasattr(attr, 'generate_response')):
|
||||
return attr_name
|
||||
return None
|
||||
|
||||
def set_default(self, name: str) -> bool:
|
||||
"""设置默认引擎"""
|
||||
if name in self.engines:
|
||||
self.current_engine = self.engines[name]
|
||||
return True
|
||||
return False
|
||||
|
||||
def switch(self, name: str) -> bool:
|
||||
"""切换引擎"""
|
||||
if name not in self.engines:
|
||||
return False
|
||||
|
||||
old_engine = self.current_engine
|
||||
self.current_engine = self.engines[name]
|
||||
|
||||
if hasattr(self.current_engine, 'cli'):
|
||||
self.current_engine.cli = getattr(old_engine, 'cli', None)
|
||||
|
||||
return True
|
||||
|
||||
def get_current_name(self) -> str:
|
||||
"""获取当前引擎名称"""
|
||||
if self.current_engine:
|
||||
return getattr(self.current_engine, 'name', '未设置')
|
||||
return '未设置'
|
||||
|
||||
def list_engines(self) -> list:
|
||||
"""列出所有引擎"""
|
||||
return list(self.engines.keys())
|
||||
|
||||
def generate_response(self, user_input: str, system_prompt: str = None) -> str:
|
||||
"""使用当前引擎生成响应"""
|
||||
if not self.current_engine:
|
||||
return "错误: 没有可用的AI引擎"
|
||||
return self.current_engine.generate_response(user_input, system_prompt=system_prompt)
|
||||
-210
@@ -1,210 +0,0 @@
|
||||
"""
|
||||
安全代码沙箱 - 在隔离环境中执行用户/插件代码
|
||||
"""
|
||||
import io
|
||||
import ast
|
||||
import time
|
||||
import re
|
||||
import threading
|
||||
import traceback
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
from datetime import datetime
|
||||
from colorama import Fore, Style
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CodeSandbox:
|
||||
"""安全代码执行沙箱"""
|
||||
|
||||
# 危险模块黑名单
|
||||
DANGEROUS_MODULES = {
|
||||
'os', 'sys', 'subprocess', 'shutil', 'socket', 'pickle',
|
||||
'marshal', 'ctypes', 'multiprocessing', 'threading',
|
||||
'signal', 'resource', 'posix', 'nt', 'builtins',
|
||||
'importlib', 'code', 'codeop', 'commands', 'popen2',
|
||||
}
|
||||
|
||||
# 危险函数/属性黑名单
|
||||
DANGEROUS_FUNCTIONS = {
|
||||
'exec', 'eval', 'compile', 'execfile', 'open',
|
||||
'input', 'raw_input', 'globals', 'locals', 'vars',
|
||||
'dir', 'getattr', 'setattr', 'delattr', 'hasattr',
|
||||
'__import__', '__builtins__', '__class__', '__bases__',
|
||||
'__subclasses__', '__mro__', '__dict__', '__getattribute__',
|
||||
}
|
||||
|
||||
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 = []
|
||||
self.max_history = 100
|
||||
|
||||
def is_safe(self, code: str) -> bool:
|
||||
"""检查代码是否安全(AST 分析 + 字符串快速检查)"""
|
||||
# 快速字符串检查
|
||||
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',
|
||||
]
|
||||
for pattern in dangerous_patterns:
|
||||
if re.search(pattern, code, re.IGNORECASE):
|
||||
return False
|
||||
|
||||
# AST 分析
|
||||
try:
|
||||
tree = ast.parse(code)
|
||||
except SyntaxError:
|
||||
return False
|
||||
|
||||
class SafetyVisitor(ast.NodeVisitor):
|
||||
def __init__(self):
|
||||
self.is_safe = True
|
||||
|
||||
def _check_name(self, name):
|
||||
if name in CodeSandbox.DANGEROUS_FUNCTIONS:
|
||||
self.is_safe = False
|
||||
|
||||
def visit_Import(self, node):
|
||||
for alias in node.names:
|
||||
if alias.name.split('.')[0] in CodeSandbox.DANGEROUS_MODULES:
|
||||
self.is_safe = False
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ImportFrom(self, node):
|
||||
if node.module:
|
||||
if node.module.split('.')[0] in CodeSandbox.DANGEROUS_MODULES:
|
||||
self.is_safe = False
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Call(self, node):
|
||||
if isinstance(node.func, ast.Name):
|
||||
self._check_name(node.func.id)
|
||||
elif isinstance(node.func, ast.Attribute):
|
||||
self._check_name(node.func.attr)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Attribute(self, node):
|
||||
self._check_name(node.attr)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Name(self, node):
|
||||
self._check_name(node.id)
|
||||
self.generic_visit(node)
|
||||
|
||||
visitor = SafetyVisitor()
|
||||
visitor.visit(tree)
|
||||
return visitor.is_safe
|
||||
|
||||
def execute(self, code: str, variables: dict = None) -> dict:
|
||||
"""
|
||||
在安全沙箱中执行代码
|
||||
|
||||
Returns:
|
||||
{
|
||||
'success': bool,
|
||||
'result': str,
|
||||
'stdout': str,
|
||||
'stderr': str,
|
||||
'execution_time': float,
|
||||
}
|
||||
"""
|
||||
if not self.enabled:
|
||||
return {'success': False, 'result': '代码执行功能已禁用',
|
||||
'stdout': '', 'stderr': '', 'execution_time': 0}
|
||||
|
||||
if not self.is_safe(code):
|
||||
return {'success': False, 'result': '代码包含潜在危险操作,已阻止执行',
|
||||
'stdout': '', 'stderr': '', 'execution_time': 0}
|
||||
|
||||
# 准备变量
|
||||
exec_vars = {}
|
||||
if variables:
|
||||
exec_vars.update(variables)
|
||||
|
||||
# 添加安全的内置函数
|
||||
safe_builtins = {
|
||||
'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, 'bool': bool,
|
||||
'type': type, 'isinstance': isinstance,
|
||||
}
|
||||
exec_vars.update(safe_builtins)
|
||||
|
||||
# 添加白名单模块
|
||||
for module_name in self.whitelist:
|
||||
try:
|
||||
exec_vars[module_name] = __import__(module_name)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 捕获输出
|
||||
stdout_capture = io.StringIO()
|
||||
stderr_capture = io.StringIO()
|
||||
|
||||
start_time = time.time()
|
||||
result = {'success': True, 'error': None, 'traceback': None}
|
||||
|
||||
def _exec():
|
||||
try:
|
||||
exec(code, exec_vars)
|
||||
except Exception as e:
|
||||
result['success'] = False
|
||||
result['error'] = str(e)
|
||||
result['traceback'] = traceback.format_exc()
|
||||
|
||||
exec_thread = threading.Thread(target=_exec, daemon=True)
|
||||
exec_thread.start()
|
||||
exec_thread.join(timeout=self.timeout)
|
||||
|
||||
exec_time = time.time() - start_time
|
||||
|
||||
if exec_thread.is_alive():
|
||||
return {
|
||||
'success': False,
|
||||
'result': f'代码执行超时(超过 {self.timeout} 秒)',
|
||||
'stdout': stdout_capture.getvalue(),
|
||||
'stderr': stderr_capture.getvalue(),
|
||||
'execution_time': self.timeout,
|
||||
}
|
||||
|
||||
if result['success']:
|
||||
exec_result = exec_vars.get('result', None)
|
||||
result_str = str(exec_result) if exec_result is not None else '代码执行成功(无返回值)'
|
||||
return {
|
||||
'success': True,
|
||||
'result': result_str,
|
||||
'stdout': stdout_capture.getvalue(),
|
||||
'stderr': stderr_capture.getvalue(),
|
||||
'execution_time': exec_time,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'success': False,
|
||||
'result': f"代码执行错误: {result['error']}",
|
||||
'stdout': stdout_capture.getvalue(),
|
||||
'stderr': stderr_capture.getvalue() + '\n' + (result['traceback'] or ''),
|
||||
'execution_time': exec_time,
|
||||
}
|
||||
|
||||
def add_to_history(self, record: dict):
|
||||
"""添加执行记录"""
|
||||
self.history.append(record)
|
||||
if len(self.history) > self.max_history:
|
||||
self.history.pop(0)
|
||||
|
||||
def get_history(self, limit=10):
|
||||
"""获取执行历史"""
|
||||
return self.history[-limit:]
|
||||
|
||||
def clear_history(self):
|
||||
"""清空执行历史"""
|
||||
self.history = []
|
||||
@@ -1,3 +0,0 @@
|
||||
"""小狸 Pro-CLI - 智能编程助手"""
|
||||
__version__ = "5.0.0"
|
||||
__author__ = "shuiyu1123"
|
||||
@@ -1,94 +0,0 @@
|
||||
"""入口点 - 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('--cli', action='store_true', help='使用传统 CLI 模式(默认 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()
|
||||
|
||||
# 检查 Textual 可用性
|
||||
if not args.cli:
|
||||
try:
|
||||
from xiaoli.tui import TEXTUAL_AVAILABLE
|
||||
if not TEXTUAL_AVAILABLE:
|
||||
raise ImportError
|
||||
except ImportError:
|
||||
print("⚠️ Textual 未安装,自动切换到命令行模式")
|
||||
print(" 安装 TUI: pip install textual rich")
|
||||
args.cli = True
|
||||
|
||||
from xiaoli.app import App
|
||||
app = App()
|
||||
|
||||
if args.engine:
|
||||
app._switch_model(args.engine)
|
||||
|
||||
if args.cli:
|
||||
app.run()
|
||||
else:
|
||||
app._run_tui()
|
||||
|
||||
|
||||
def _show_art():
|
||||
"""启动艺术字"""
|
||||
art = [
|
||||
"█████████████████████████████████████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ▒████████████████████████████████████████████████████",
|
||||
"██████████ ░██████████████████████████████████████████████████████",
|
||||
"██████████ ████████████████████████████████████████████████████████",
|
||||
"██████████ ▓█████████████████████████████████████████████████████████",
|
||||
"██████████ ▒███████████████████████████████████████████████████████████",
|
||||
"██████████ █████████████████████████████████████████████████████████████",
|
||||
"██████████ ███████████████████████████████████████████████████████████████",
|
||||
"██████████ ▓███████████████████████████████████████████████████████████████",
|
||||
"██████████ ░████████████████████████████████████████████████████████████████",
|
||||
"██████████ ██████████████████████████████████████████████████████████████████",
|
||||
"██████████ ████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ▓████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ░██████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ████████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ██████████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ░██████████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ████████████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ████████████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ▓██████████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ████████████████████████████████████████████████████████████████████████████",
|
||||
"██████████ ████████████████████████████████████████████████████████████████████████████",
|
||||
"███████████ ████████████████████████████████████████████████████████████████████████████",
|
||||
"█████████████▓ ████████████████████████████████████████████████████████████████████████████",
|
||||
"█████████████████ ▓██████████████████████████████████████████████████████████████████████████",
|
||||
"███████████████████▒ ████████████████████████████████████████████████████████████████████████████",
|
||||
"█████████████████████████████████████████████████████████████████████████████████████████████████████",
|
||||
"█████████████████████████████████████████████████████████████████████████████████████████████████████",
|
||||
]
|
||||
|
||||
for line in art:
|
||||
try:
|
||||
print(line, flush=True)
|
||||
except UnicodeEncodeError:
|
||||
pass
|
||||
time.sleep(0.03)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
-618
@@ -1,618 +0,0 @@
|
||||
"""主应用 - 薄编排层,协调各模块"""
|
||||
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(),
|
||||
'cli': lambda a: self.run(),
|
||||
'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 退出
|
||||
/cli 切换到命令行模式
|
||||
/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):
|
||||
"""启动 TUI 模式,不可用时降级到 CLI"""
|
||||
try:
|
||||
from xiaoli.tui import run_tui, TEXTUAL_AVAILABLE
|
||||
if not TEXTUAL_AVAILABLE:
|
||||
raise ImportError("Textual 未安装")
|
||||
self.display.info("正在启动 TUI 模式...")
|
||||
run_tui(self)
|
||||
except ImportError:
|
||||
self.display.warn("Textual 未安装,TUI 不可用")
|
||||
self.display.info("安装命令: pip install textual rich")
|
||||
self.display.info("已切换到命令行模式")
|
||||
self.run()
|
||||
except Exception as e:
|
||||
self.err(f"TUI 启动失败: {e}")
|
||||
self.display.info("已切换到命令行模式")
|
||||
self.run()
|
||||
|
||||
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("资源清理完成")
|
||||
@@ -1,70 +0,0 @@
|
||||
"""配置管理 - 加载/保存/默认值"""
|
||||
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()
|
||||
@@ -1,180 +0,0 @@
|
||||
"""响应解析器 - 从 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
|
||||
@@ -1,86 +0,0 @@
|
||||
"""对话历史管理"""
|
||||
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}")
|
||||
@@ -1,110 +0,0 @@
|
||||
"""统一显示输出 - 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 v5.0 已启动!")
|
||||
self.ok(f"用户ID: {user_id}")
|
||||
print(f"{Fore.GREEN}TUI 模式 | '/help' 帮助 | '/cli' 命令行 | '/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)}行)"
|
||||
@@ -1,93 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,69 +0,0 @@
|
||||
"""系统提示词构建"""
|
||||
|
||||
|
||||
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
|
||||
@@ -1,540 +0,0 @@
|
||||
"""
|
||||
安全代码沙箱 v2 - 多层隔离执行环境
|
||||
|
||||
安全层级:
|
||||
1. AST 静态分析 - 拒绝危险语法
|
||||
2. 子进程隔离 - 独立进程执行,资源限制
|
||||
3. 文件系统限制 - 仅允许临时目录读写
|
||||
4. 网络阻断 - 禁止 socket 连接
|
||||
5. 模块白名单 - 仅允许安全模块导入
|
||||
6. 输出截断 - 防止内存耗尽
|
||||
"""
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import time
|
||||
import traceback
|
||||
import threading
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import resource
|
||||
HAS_RESOURCE = True
|
||||
except ImportError:
|
||||
HAS_RESOURCE = False
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 安全策略配置
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
BLOCKED_MODULES: Set[str] = frozenset({
|
||||
'os', 'sys', 'subprocess', 'shutil', 'socket', 'pickle',
|
||||
'marshal', 'ctypes', 'multiprocessing', 'threading',
|
||||
'signal', 'resource', 'posix', 'nt', 'builtins',
|
||||
'importlib', 'code', 'codeop', 'bdb', 'pdb',
|
||||
'compileall', 'py_compile', 'zipimport', 'pkgutil',
|
||||
'http', 'urllib', 'ftplib', 'smtplib', 'xmlrpc',
|
||||
'asyncio', 'concurrent', '_thread',
|
||||
})
|
||||
|
||||
BLOCKED_NAMES: Set[str] = frozenset({
|
||||
'exec', 'eval', 'compile', 'execfile', 'open',
|
||||
'globals', 'locals', 'vars', 'getattr', 'setattr',
|
||||
'delattr', '__import__', '__builtins__', '__subclasses__',
|
||||
'__loader__', '__spec__', '__file__', '__name__',
|
||||
'breakpoint', 'exit', 'quit', 'help',
|
||||
})
|
||||
|
||||
BLOCKED_AST_PATTERNS: List[str] = [
|
||||
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', r'\bbreakpoint\b',
|
||||
]
|
||||
|
||||
DEFAULT_WHITELIST: List[str] = [
|
||||
'json', 'math', 'datetime', 'random', 'collections',
|
||||
'itertools', 'functools', 'operator', 'typing', 're',
|
||||
'string', 'textwrap', 'copy', 'decimal', 'fractions',
|
||||
'statistics', 'bisect', 'heapq', 'array', 'struct',
|
||||
'hashlib', 'hmac', 'secrets', 'base64', 'binascii',
|
||||
'uuid', 'pathlib', 'enum', 'dataclasses', 'contextlib',
|
||||
]
|
||||
|
||||
DEFAULT_MEMORY_LIMIT_MB = 256
|
||||
DEFAULT_CPU_TIME_LIMIT = 30
|
||||
DEFAULT_OUTPUT_LIMIT = 1_000_000
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 结果数据类
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class SandboxResult:
|
||||
"""沙箱执行结果"""
|
||||
success: bool
|
||||
result: str = ""
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
time: float = 0.0
|
||||
exit_code: int = 0
|
||||
killed: bool = False
|
||||
security_violation: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'success': self.success,
|
||||
'result': self.result,
|
||||
'stdout': self.stdout,
|
||||
'stderr': self.stderr,
|
||||
'time': round(self.time, 4),
|
||||
'exit_code': self.exit_code,
|
||||
'killed': self.killed,
|
||||
'security_violation': self.security_violation,
|
||||
}
|
||||
|
||||
def __str__(self) -> str:
|
||||
status = "OK" if self.success else "FAIL"
|
||||
parts = [f"{status}: {self.result}"]
|
||||
if self.stdout:
|
||||
parts.append(f"stdout={self.stdout[:200]}")
|
||||
if self.stderr:
|
||||
parts.append(f"stderr={self.stderr[:200]}")
|
||||
if self.security_violation:
|
||||
parts.append(f"security={self.security_violation}")
|
||||
parts.append(f"{self.time:.3f}s")
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# AST 安全检查器
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class ASTSecurityChecker(ast.NodeVisitor):
|
||||
"""AST 级静态安全分析"""
|
||||
|
||||
def __init__(self, blocked_modules: Set[str] = None,
|
||||
blocked_names: Set[str] = None):
|
||||
self.blocked = blocked_modules or BLOCKED_MODULES
|
||||
self.blocked_names = blocked_names or BLOCKED_NAMES
|
||||
self.violations: List[str] = []
|
||||
|
||||
def _check_name(self, name: str, node: ast.AST):
|
||||
lineno = getattr(node, 'lineno', 0)
|
||||
if name in self.blocked_names:
|
||||
self.violations.append(f"blocked name: {name} (line {lineno})")
|
||||
if name.split('.')[0] in self.blocked:
|
||||
self.violations.append(f"blocked module: {name} (line {lineno})")
|
||||
|
||||
def visit_Import(self, node):
|
||||
for alias in node.names:
|
||||
self._check_name(alias.name, node)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ImportFrom(self, node):
|
||||
if node.module:
|
||||
self._check_name(node.module, node)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Call(self, node):
|
||||
if isinstance(node.func, ast.Name):
|
||||
self._check_name(node.func.id, node)
|
||||
elif isinstance(node.func, ast.Attribute):
|
||||
if isinstance(node.func.value, ast.Name):
|
||||
name = node.func.value.id
|
||||
if name in self.blocked_names:
|
||||
lineno = getattr(node, 'lineno', 0)
|
||||
self.violations.append(
|
||||
f"blocked call: {name}.{node.func.attr} (line {lineno})"
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Name(self, node):
|
||||
self._check_name(node.id, node)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Attribute(self, node):
|
||||
if node.attr.startswith('__') and node.attr.endswith('__'):
|
||||
if node.attr not in ('__init__', '__str__', '__repr__',
|
||||
'__len__', '__contains__', '__iter__',
|
||||
'__eq__', '__ne__', '__lt__', '__gt__',
|
||||
'__le__', '__ge__', '__hash__', '__bool__',
|
||||
'__add__', '__sub__', '__mul__', '__truediv__',
|
||||
'__floordiv__', '__mod__', '__pow__',
|
||||
'__enter__', '__exit__'):
|
||||
lineno = getattr(node, 'lineno', 0)
|
||||
self.violations.append(
|
||||
f"blocked dunder: {node.attr} (line {lineno})"
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Delete(self, node):
|
||||
self.violations.append(f"blocked del (line {getattr(node, 'lineno', 0)})")
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Global(self, node):
|
||||
self.violations.append(f"blocked global (line {getattr(node, 'lineno', 0)})")
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Nonlocal(self, node):
|
||||
self.violations.append(f"blocked nonlocal (line {getattr(node, 'lineno', 0)})")
|
||||
self.generic_visit(node)
|
||||
|
||||
def check(self, code: str) -> Tuple[bool, List[str]]:
|
||||
"""检查代码安全性,返回 (safe, violations)"""
|
||||
self.violations = []
|
||||
|
||||
for pattern in BLOCKED_AST_PATTERNS:
|
||||
if re.search(pattern, code, re.IGNORECASE):
|
||||
self.violations.append(f"regex match: {pattern}")
|
||||
|
||||
try:
|
||||
tree = ast.parse(code)
|
||||
except SyntaxError as e:
|
||||
return False, [f"syntax error: {e}"]
|
||||
|
||||
self.visit(tree)
|
||||
return len(self.violations) == 0, self.violations
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 子进程包装脚本
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _make_worker_script(code: str, variables_json: str,
|
||||
whitelist_json: str, work_dir: str) -> str:
|
||||
return textwrap.dedent(f'''\
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import io
|
||||
import traceback
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
|
||||
os.chdir({work_dir!r})
|
||||
|
||||
# Block networking
|
||||
class _FakeSocket:
|
||||
def __getattr__(self, name):
|
||||
raise OSError("network access blocked by sandbox")
|
||||
class _FakeSocketModule:
|
||||
AF_INET = 0
|
||||
SOCK_STREAM = 0
|
||||
def __getattr__(self, name):
|
||||
raise OSError("network access blocked by sandbox")
|
||||
def socket(self, *a, **kw):
|
||||
raise OSError("network access blocked by sandbox")
|
||||
|
||||
import types
|
||||
_fake_mod = _FakeSocketModule()
|
||||
sys.modules['socket'] = _fake_mod
|
||||
sys.modules['_socket'] = _fake_mod
|
||||
|
||||
WHITELIST = json.loads({whitelist_json!r})
|
||||
safe_modules = {{}}
|
||||
for mod_name in WHITELIST:
|
||||
try:
|
||||
safe_modules[mod_name] = __import__(mod_name)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
safe_builtins = {{
|
||||
'print': print, 'len': len, 'str': str, 'int': int,
|
||||
'float': float, 'list': list, 'dict': dict, 'tuple': tuple,
|
||||
'set': set, 'frozenset': frozenset,
|
||||
'range': range, 'enumerate': enumerate, 'zip': zip,
|
||||
'sorted': sorted, 'reversed': reversed,
|
||||
'sum': sum, 'max': max, 'min': min, 'abs': abs,
|
||||
'round': round, 'pow': pow, 'divmod': divmod,
|
||||
'type': type, 'isinstance': isinstance, 'issubclass': issubclass,
|
||||
'hasattr': hasattr, 'callable': callable, 'id': id,
|
||||
'chr': chr, 'ord': ord, 'hex': hex, 'oct': oct, 'bin': bin,
|
||||
'bool': bool, 'bytes': bytes, 'bytearray': bytearray,
|
||||
'map': map, 'filter': filter,
|
||||
'any': any, 'all': all,
|
||||
'True': True, 'False': False, 'None': None,
|
||||
}}
|
||||
|
||||
user_vars = json.loads({variables_json!r})
|
||||
|
||||
exec_vars = {{}}
|
||||
exec_vars.update(safe_builtins)
|
||||
exec_vars.update(safe_modules)
|
||||
exec_vars.update(user_vars)
|
||||
|
||||
stdout_buf = io.StringIO()
|
||||
stderr_buf = io.StringIO()
|
||||
result_data = {{"success": True, "result": "", "stdout": "", "stderr": "", "error": ""}}
|
||||
|
||||
try:
|
||||
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
|
||||
exec({code!r}, exec_vars)
|
||||
val = exec_vars.get('result', None)
|
||||
result_data["result"] = str(val) if val is not None else "executed ok"
|
||||
except Exception as e:
|
||||
result_data["success"] = False
|
||||
result_data["error"] = str(e)
|
||||
result_data["stderr"] = traceback.format_exc()
|
||||
|
||||
result_data["stdout"] = stdout_buf.getvalue()
|
||||
result_data["stderr"] = stderr_buf.getvalue() or result_data.get("stderr", "")
|
||||
|
||||
max_out = 500000
|
||||
if len(result_data["stdout"]) > max_out:
|
||||
result_data["stdout"] = result_data["stdout"][:max_out] + "\\n... (truncated)"
|
||||
if len(result_data["stderr"]) > max_out:
|
||||
result_data["stderr"] = result_data["stderr"][:max_out] + "\\n... (truncated)"
|
||||
|
||||
sys.stdout.write(json.dumps(result_data, ensure_ascii=False))
|
||||
''')
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 主沙箱类
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class Sandbox:
|
||||
"""安全代码执行沙箱 v2
|
||||
|
||||
mode:
|
||||
- 'subprocess': 子进程隔离(默认,最安全)
|
||||
- 'threaded': 线程内 exec(兼容模式)
|
||||
"""
|
||||
|
||||
def __init__(self, timeout: int = 30,
|
||||
memory_limit_mb: int = DEFAULT_MEMORY_LIMIT_MB,
|
||||
whitelist: List[str] = None,
|
||||
mode: str = 'subprocess',
|
||||
work_dir: str = None):
|
||||
self.enabled = True
|
||||
self.timeout = timeout
|
||||
self.memory_limit_mb = memory_limit_mb
|
||||
self.whitelist = whitelist or DEFAULT_WHITELIST
|
||||
self.mode = mode
|
||||
self.work_dir = work_dir or tempfile.mkdtemp(prefix='xiaoli_sandbox_')
|
||||
self.history: List[Dict] = []
|
||||
self._checker = ASTSecurityChecker()
|
||||
os.makedirs(self.work_dir, exist_ok=True)
|
||||
|
||||
def is_safe(self, code: str) -> Tuple[bool, List[str]]:
|
||||
"""静态安全检查(AST + 正则)"""
|
||||
return self._checker.check(code)
|
||||
|
||||
def run(self, code: str, variables: dict = None) -> dict:
|
||||
"""执行代码,返回结果字典(兼容旧接口)"""
|
||||
result = self.execute(code, variables)
|
||||
return result.to_dict()
|
||||
|
||||
def execute(self, code: str, variables: dict = None) -> SandboxResult:
|
||||
"""执行代码,返回 SandboxResult"""
|
||||
if not self.enabled:
|
||||
return SandboxResult(
|
||||
success=False, result="sandbox disabled",
|
||||
security_violation="sandbox is off"
|
||||
)
|
||||
|
||||
safe, violations = self.is_safe(code)
|
||||
if not safe:
|
||||
return SandboxResult(
|
||||
success=False,
|
||||
result="code failed security check",
|
||||
security_violation="; ".join(violations[:5])
|
||||
)
|
||||
|
||||
if self.mode == 'subprocess':
|
||||
result = self._run_subprocess(code, variables)
|
||||
else:
|
||||
result = self._run_threaded(code, variables)
|
||||
|
||||
self.history.append({
|
||||
'code': code[:500],
|
||||
'success': result.success,
|
||||
'time': result.time,
|
||||
'timestamp': time.time(),
|
||||
})
|
||||
return result
|
||||
|
||||
def cleanup(self):
|
||||
"""清理临时目录"""
|
||||
try:
|
||||
if os.path.exists(self.work_dir) and 'xiaoli_sandbox_' in self.work_dir:
|
||||
shutil.rmtree(self.work_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── subprocess ──
|
||||
|
||||
def _run_subprocess(self, code: str, variables: dict = None) -> SandboxResult:
|
||||
vars_json = json.dumps(variables or {}, ensure_ascii=False, default=str)
|
||||
whitelist_json = json.dumps(self.whitelist)
|
||||
script = _make_worker_script(code, vars_json, whitelist_json, self.work_dir)
|
||||
start = time.time()
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, '-c', script],
|
||||
capture_output=True,
|
||||
timeout=self.timeout,
|
||||
cwd=self.work_dir,
|
||||
env=self._safe_env(),
|
||||
preexec_fn=self._set_limits if HAS_RESOURCE else None,
|
||||
)
|
||||
elapsed = time.time() - start
|
||||
|
||||
stdout = proc.stdout.decode('utf-8', errors='replace')[:DEFAULT_OUTPUT_LIMIT]
|
||||
stderr = proc.stderr.decode('utf-8', errors='replace')[:DEFAULT_OUTPUT_LIMIT]
|
||||
|
||||
try:
|
||||
data = json.loads(stdout)
|
||||
return SandboxResult(
|
||||
success=data.get('success', False),
|
||||
result=data.get('result', ''),
|
||||
stdout=data.get('stdout', ''),
|
||||
stderr=data.get('stderr', ''),
|
||||
time=elapsed,
|
||||
exit_code=proc.returncode,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return SandboxResult(
|
||||
success=proc.returncode == 0,
|
||||
result=stdout[:2000] if stdout else "no output",
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
time=elapsed,
|
||||
exit_code=proc.returncode,
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return SandboxResult(
|
||||
success=False,
|
||||
result=f"timeout ({self.timeout}s)",
|
||||
time=self.timeout,
|
||||
killed=True,
|
||||
)
|
||||
except Exception as e:
|
||||
return SandboxResult(
|
||||
success=False,
|
||||
result=f"subprocess error: {e}",
|
||||
time=time.time() - start,
|
||||
)
|
||||
|
||||
def _safe_env(self) -> dict:
|
||||
env = {
|
||||
'PATH': '/usr/bin:/bin',
|
||||
'LANG': 'en_US.UTF-8',
|
||||
'LC_ALL': 'en_US.UTF-8',
|
||||
'HOME': self.work_dir,
|
||||
'TMPDIR': self.work_dir,
|
||||
'TEMP': self.work_dir,
|
||||
'TMP': self.work_dir,
|
||||
'PYTHONNOUSERSITE': '1',
|
||||
'PYTHONDONTWRITEBYTECODE': '1',
|
||||
}
|
||||
for key in ('TERM', 'COLUMNS', 'LINES', 'SHELL'):
|
||||
val = os.environ.get(key)
|
||||
if val:
|
||||
env[key] = val
|
||||
return env
|
||||
|
||||
@staticmethod
|
||||
def _set_limits():
|
||||
if not HAS_RESOURCE:
|
||||
return
|
||||
try:
|
||||
mem_bytes = DEFAULT_MEMORY_LIMIT_MB * 1024 * 1024
|
||||
resource.setrlimit(resource.RLIMIT_AS, (mem_bytes, mem_bytes))
|
||||
resource.setrlimit(resource.RLIMIT_CPU, (
|
||||
DEFAULT_CPU_TIME_LIMIT, DEFAULT_CPU_TIME_LIMIT + 5
|
||||
))
|
||||
resource.setrlimit(resource.RLIMIT_NPROC, (0, 0))
|
||||
resource.setrlimit(resource.RLIMIT_FSIZE, (50 * 1024 * 1024, 50 * 1024 * 1024))
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
# ── threaded (compat) ──
|
||||
|
||||
def _run_threaded(self, code: str, variables: dict = None) -> SandboxResult:
|
||||
exec_vars = {}
|
||||
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_name in self.whitelist:
|
||||
try:
|
||||
exec_vars[mod_name] = __import__(mod_name)
|
||||
except ImportError:
|
||||
pass
|
||||
if variables:
|
||||
exec_vars.update(variables)
|
||||
|
||||
stdout_buf = io.StringIO()
|
||||
stderr_buf = io.StringIO()
|
||||
result = {'success': True, 'error': None, 'tb': None}
|
||||
|
||||
def _exec():
|
||||
try:
|
||||
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
|
||||
exec(code, exec_vars)
|
||||
except Exception as e:
|
||||
result['success'] = False
|
||||
result['error'] = str(e)
|
||||
result['tb'] = traceback.format_exc()
|
||||
|
||||
start = time.time()
|
||||
thread = threading.Thread(target=_exec, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout=self.timeout)
|
||||
elapsed = time.time() - start
|
||||
|
||||
if thread.is_alive():
|
||||
return SandboxResult(
|
||||
success=False,
|
||||
result=f"timeout ({self.timeout}s)",
|
||||
stdout=stdout_buf.getvalue(),
|
||||
stderr=stderr_buf.getvalue(),
|
||||
time=self.timeout,
|
||||
killed=True,
|
||||
)
|
||||
|
||||
stdout = stdout_buf.getvalue()
|
||||
stderr = stderr_buf.getvalue()
|
||||
|
||||
if result['success']:
|
||||
val = exec_vars.get('result', None)
|
||||
return SandboxResult(
|
||||
success=True,
|
||||
result=str(val) if val is not None else "executed ok",
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
time=elapsed,
|
||||
)
|
||||
else:
|
||||
return SandboxResult(
|
||||
success=False,
|
||||
result=f"error: {result['error']}",
|
||||
stdout=stdout,
|
||||
stderr=stderr + ('\n' + (result['tb'] or '')),
|
||||
time=elapsed,
|
||||
)
|
||||
|
||||
def __del__(self):
|
||||
self.cleanup()
|
||||
-657
@@ -1,657 +0,0 @@
|
||||
"""
|
||||
小狸 Pro-CLI TUI v2 - 对标 Claude Code / OpenCode
|
||||
现代化终端界面,支持代码高亮、工具时间线、文件浏览
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
try:
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll, Container
|
||||
from textual.widgets import (
|
||||
Header, Footer, Input, Static, Rule, Label,
|
||||
DataTable, ProgressBar, TabbedContent, TabPane
|
||||
)
|
||||
from textual.binding import Binding
|
||||
from textual.reactive import reactive, var
|
||||
from textual.message import Message
|
||||
from textual import work, on
|
||||
from rich.text import Text
|
||||
from rich.markdown import Markdown as RichMarkdown
|
||||
from rich.syntax import Syntax
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.tree import Tree as RichTree
|
||||
from rich.columns import Columns
|
||||
from rich.align import Align
|
||||
from rich.box import ROUNDED, HEAVY, DOUBLE
|
||||
TEXTUAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
TEXTUAL_AVAILABLE = False
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 主题色
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class Theme:
|
||||
BG = "#0d1117"
|
||||
BG_LIGHT = "#161b22"
|
||||
BG_INPUT = "#0d1117"
|
||||
BORDER = "#30363d"
|
||||
BORDER_FOCUS = "#58a6ff"
|
||||
TEXT = "#c9d1d9"
|
||||
TEXT_DIM = "#484f58"
|
||||
TEXT_MUTED = "#8b949e"
|
||||
ACCENT = "#58a6ff"
|
||||
SUCCESS = "#3fb950"
|
||||
WARNING = "#d29922"
|
||||
ERROR = "#f85149"
|
||||
USER = "#79c0ff"
|
||||
AI = "#c9d1d9"
|
||||
TOOL = "#56d364"
|
||||
TOOL_ERR = "#f85149"
|
||||
CODE_BG = "#161b22"
|
||||
LINK = "#58a6ff"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# CSS
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
CSS = f"""
|
||||
Screen {{
|
||||
background: {Theme.BG};
|
||||
}}
|
||||
|
||||
#app-container {{
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}}
|
||||
|
||||
/* ── 主聊天区 ── */
|
||||
#main {{
|
||||
width: 1fr;
|
||||
height: 1fr;
|
||||
}}
|
||||
|
||||
#chat-scroll {{
|
||||
height: 1fr;
|
||||
background: {Theme.BG};
|
||||
scrollbar-color: {Theme.BORDER};
|
||||
scrollbar-color-hover: {Theme.TEXT_MUTED};
|
||||
padding: 0 1;
|
||||
}}
|
||||
|
||||
#input-area {{
|
||||
height: auto;
|
||||
min-height: 4;
|
||||
max-height: 10;
|
||||
padding: 1 1 0 1;
|
||||
}}
|
||||
|
||||
#user-input {{
|
||||
height: auto;
|
||||
min-height: 3;
|
||||
background: {Theme.BG_INPUT};
|
||||
border: tall {Theme.BORDER};
|
||||
color: {Theme.TEXT};
|
||||
padding: 0 1;
|
||||
}}
|
||||
|
||||
#user-input:focus {{
|
||||
border: tall {Theme.BORDER_FOCUS};
|
||||
}}
|
||||
|
||||
#input-hint {{
|
||||
height: 1;
|
||||
color: {Theme.TEXT_DIM};
|
||||
padding: 0 1;
|
||||
text-size: 80%;
|
||||
}}
|
||||
|
||||
/* ── 侧边栏 ── */
|
||||
#sidebar {{
|
||||
width: 32;
|
||||
min-width: 32;
|
||||
height: 1fr;
|
||||
background: {Theme.BG_LIGHT};
|
||||
border-left: wide {Theme.BORDER};
|
||||
display: block;
|
||||
}}
|
||||
|
||||
#sidebar.hidden {{
|
||||
display: none;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
}}
|
||||
|
||||
.sidebar-header {{
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: {Theme.ACCENT};
|
||||
text-style: bold;
|
||||
padding: 1 0 0 0;
|
||||
text-size: 90%;
|
||||
}}
|
||||
|
||||
.sidebar-section {{
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
margin: 0 0 1 0;
|
||||
}}
|
||||
|
||||
.engine-item {{
|
||||
padding: 0 1;
|
||||
color: {Theme.TEXT_MUTED};
|
||||
text-size: 85%;
|
||||
}}
|
||||
|
||||
.engine-item-active {{
|
||||
padding: 0 1;
|
||||
color: {Theme.SUCCESS};
|
||||
text-style: bold;
|
||||
text-size: 85%;
|
||||
}}
|
||||
|
||||
.tool-item {{
|
||||
padding: 0 0 0 1;
|
||||
color: {Theme.TEXT_MUTED};
|
||||
text-size: 80%;
|
||||
}}
|
||||
|
||||
.tool-item-name {{
|
||||
color: {Theme.TEXT};
|
||||
text-style: bold;
|
||||
}}
|
||||
|
||||
/* ── 状态栏 ── */
|
||||
#status-bar {{
|
||||
height: 1;
|
||||
width: 100%;
|
||||
dock: bottom;
|
||||
background: {Theme.BG_LIGHT};
|
||||
color: {Theme.TEXT_MUTED};
|
||||
padding: 0 1;
|
||||
text-size: 80%;
|
||||
}}
|
||||
|
||||
/* ── 消息样式 ── */
|
||||
.msg-user {{
|
||||
color: {Theme.USER};
|
||||
padding: 1 0 0 1;
|
||||
text-style: bold;
|
||||
}}
|
||||
|
||||
.msg-ai {{
|
||||
color: {Theme.AI};
|
||||
padding: 0 1;
|
||||
}}
|
||||
|
||||
.msg-system {{
|
||||
color: {Theme.ACCENT};
|
||||
padding: 0 1;
|
||||
text-size: 85%;
|
||||
}}
|
||||
|
||||
.msg-tool-ok {{
|
||||
color: {Theme.TOOL};
|
||||
padding: 0 1;
|
||||
text-size: 85%;
|
||||
}}
|
||||
|
||||
.msg-tool-err {{
|
||||
color: {Theme.TOOL_ERR};
|
||||
padding: 0 1;
|
||||
text-size: 85%;
|
||||
}}
|
||||
|
||||
.msg-thinking {{
|
||||
color: {Theme.TEXT_DIM};
|
||||
text-style: italic;
|
||||
padding: 0 1;
|
||||
}}
|
||||
|
||||
.msg-error {{
|
||||
color: {Theme.ERROR};
|
||||
padding: 0 1;
|
||||
}}
|
||||
|
||||
.msg-dim {{
|
||||
color: {Theme.TEXT_DIM};
|
||||
padding: 0 1;
|
||||
text-size: 85%;
|
||||
}}
|
||||
|
||||
.msg-welcome {{
|
||||
color: {Theme.ACCENT};
|
||||
padding: 0 1;
|
||||
text-style: bold;
|
||||
}}
|
||||
|
||||
/* ── 代码块 ── */
|
||||
.code-block {{
|
||||
background: {Theme.CODE_BG};
|
||||
border: wide {Theme.BORDER};
|
||||
padding: 0 1;
|
||||
margin: 0 2 0 2;
|
||||
text-size: 85%;
|
||||
}}
|
||||
|
||||
/* ── Tab ── */
|
||||
Tab {{
|
||||
background: {Theme.BG};
|
||||
}}
|
||||
|
||||
Tab.-active {{
|
||||
background: {Theme.BG_LIGHT};
|
||||
}}
|
||||
|
||||
TabbedContent > Tabs {{
|
||||
background: {Theme.BG};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 消息组件
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class ChatMessage(Static):
|
||||
"""单条聊天消息"""
|
||||
def __init__(self, content, msg_type="ai", **kwargs):
|
||||
self.msg_type = msg_type
|
||||
super().__init__(content, **kwargs)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 桥接
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class TUIBridge:
|
||||
def __init__(self, app_instance):
|
||||
self.app = app_instance
|
||||
|
||||
def engines(self) -> list:
|
||||
return self.app.engine_mgr.names()
|
||||
|
||||
def current_engine(self) -> str:
|
||||
return self.app.engine_mgr.current_name()
|
||||
|
||||
def tools(self) -> list:
|
||||
if self.app.tool_mgr and hasattr(self.app.tool_mgr, '_tools'):
|
||||
return list(self.app.tool_mgr._tools.values())
|
||||
return []
|
||||
|
||||
def history(self) -> list:
|
||||
return self.app.history.get()
|
||||
|
||||
def switch_engine(self, name: str) -> bool:
|
||||
return self.app._switch_model(name)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 主应用
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class XiaoliTUI(App):
|
||||
"""小狸 TUI v2"""
|
||||
|
||||
CSS = CSS
|
||||
TITLE = "🐱 小狸 Pro-CLI"
|
||||
SUB_TITLE = "智能编程助手 v5.0"
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+c", "quit", "退出", show=True),
|
||||
Binding("ctrl+l", "clear", "清屏", show=True),
|
||||
Binding("ctrl+n", "new_chat", "新对话", show=True),
|
||||
Binding("f1", "toggle_sidebar", "侧栏", show=True),
|
||||
Binding("f2", "toggle_tools", "工具", show=True),
|
||||
Binding("escape", "cancel", "取消", show=False),
|
||||
]
|
||||
|
||||
sidebar_visible = var(True)
|
||||
is_generating = var(False)
|
||||
|
||||
def __init__(self, app_instance):
|
||||
super().__init__()
|
||||
self.app_inst = app_instance
|
||||
self.bridge = TUIBridge(app_instance)
|
||||
self._tool_calls = [] # 工具调用历史
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(show_clock=True)
|
||||
with Horizontal(id="app-container"):
|
||||
# 主聊天区
|
||||
with Vertical(id="main"):
|
||||
yield VerticalScroll(id="chat-scroll")
|
||||
with Vertical(id="input-area"):
|
||||
yield Input(
|
||||
placeholder=" 输入消息... (Enter 发送, /help 帮助)",
|
||||
id="user-input"
|
||||
)
|
||||
yield Static(
|
||||
" Tab 补全 | ↑↓ 历史 | Ctrl+L 清屏 | F1 侧栏",
|
||||
id="input-hint"
|
||||
)
|
||||
# 侧边栏
|
||||
with Vertical(id="sidebar"):
|
||||
yield Static("⚙️ 引擎", classes="sidebar-header")
|
||||
yield Vertical(id="engine-list", classes="sidebar-section")
|
||||
yield Rule(line_style="heavy")
|
||||
yield Static("🔧 工具", classes="sidebar-header")
|
||||
yield Vertical(id="tool-list", classes="sidebar-section")
|
||||
yield Rule(line_style="heavy")
|
||||
yield Static("📊 状态", classes="sidebar-header")
|
||||
yield Vertical(id="status-info", classes="sidebar-section")
|
||||
yield Static(" 就绪 | ollama | Ctrl+C 退出", id="status-bar")
|
||||
|
||||
def on_mount(self):
|
||||
self._render_welcome()
|
||||
self._update_sidebar()
|
||||
self.query_one("#user-input").focus()
|
||||
|
||||
# ── 渲染 ──
|
||||
|
||||
def _render_welcome(self):
|
||||
scroll = self.query_one("#chat-scroll")
|
||||
lines = [
|
||||
("", "msg-dim"),
|
||||
(" ╔══════════════════════════════════════════════╗", "msg-welcome"),
|
||||
(" ║ ║", "msg-welcome"),
|
||||
(" ║ 🐱 小狸 Pro-CLI v5.0 ║", "msg-welcome"),
|
||||
(" ║ 智能编程助手 · 对标 Claude Code ║", "msg-welcome"),
|
||||
(" ║ ║", "msg-welcome"),
|
||||
(" ╚══════════════════════════════════════════════╝", "msg-welcome"),
|
||||
("", "msg-dim"),
|
||||
(" 💡 代码编辑 · 代码搜索 · Git 集成 · 多引擎", "msg-system"),
|
||||
(" 📝 输入 /help 查看命令 | /model 切换引擎", "msg-system"),
|
||||
(f" 🔧 当前引擎: {self.bridge.current_engine()} | 工具: {len(self.bridge.tools())} 个", "msg-system"),
|
||||
("", "msg-dim"),
|
||||
]
|
||||
for text, cls in lines:
|
||||
scroll.mount(Static(text, classes=cls))
|
||||
|
||||
def _update_sidebar(self):
|
||||
# 引擎列表
|
||||
engine_container = self.query_one("#engine-list")
|
||||
engine_container.remove_children()
|
||||
current = self.bridge.current_engine()
|
||||
for name in self.bridge.engines():
|
||||
if name == current:
|
||||
engine_container.mount(Static(f" ▸ {name}", classes="engine-item-active"))
|
||||
else:
|
||||
engine_container.mount(Static(f" {name}", classes="engine-item"))
|
||||
|
||||
# 工具列表
|
||||
tool_container = self.query_one("#tool-list")
|
||||
tool_container.remove_children()
|
||||
for tool in self.bridge.tools():
|
||||
name = tool.get('name', '?')
|
||||
tool_container.mount(Static(f" • {name}", classes="tool-item"))
|
||||
|
||||
# 状态信息
|
||||
status_container = self.query_one("#status-info")
|
||||
status_container.remove_children()
|
||||
status_container.mount(Static(f" 对话: {len(self.bridge.history())} 条", classes="tool-item"))
|
||||
status_container.mount(Static(f" 工具: {len(self.bridge.tools())} 个", classes="tool-item"))
|
||||
status_container.mount(Static(f" 引擎: {len(self.bridge.engines())} 个", classes="tool-item"))
|
||||
|
||||
def _update_status(self, text: str):
|
||||
bar = self.query_one("#status-bar")
|
||||
engine = self.bridge.current_engine()
|
||||
bar.update(f" {text} | {engine} | {len(self.bridge.history())} 条对话")
|
||||
|
||||
def _append(self, widget):
|
||||
scroll = self.query_one("#chat-scroll")
|
||||
scroll.mount(widget)
|
||||
scroll.scroll_end(animate=False)
|
||||
|
||||
def _add(self, text, cls="msg-dim"):
|
||||
self._append(Static(text, classes=cls))
|
||||
|
||||
# ── 消息类型 ──
|
||||
|
||||
def _user_msg(self, text):
|
||||
self._add(f" 👤 {text}", "msg-user")
|
||||
|
||||
def _ai_msg(self, text):
|
||||
# 检测代码块并高亮
|
||||
if "```" in text:
|
||||
self._render_with_code(text)
|
||||
else:
|
||||
self._add(f" ✦ {text}", "msg-ai")
|
||||
|
||||
def _render_with_code(self, text):
|
||||
"""渲染包含代码块的消息"""
|
||||
import re
|
||||
parts = re.split(r'```(\w*)\n(.*?)```', text, flags=re.DOTALL)
|
||||
i = 0
|
||||
while i < len(parts):
|
||||
if i % 3 == 0:
|
||||
# 普通文本
|
||||
if parts[i].strip():
|
||||
for line in parts[i].strip().split('\n'):
|
||||
self._add(f" ✦ {line}", "msg-ai")
|
||||
elif i % 3 == 1:
|
||||
# 语言标识
|
||||
pass
|
||||
elif i % 3 == 2:
|
||||
# 代码块
|
||||
code = parts[i]
|
||||
lang = parts[i-1] if i > 1 else ""
|
||||
try:
|
||||
syntax = Syntax(code, lang or "python", theme="monokai",
|
||||
line_numbers=True, word_wrap=True)
|
||||
self._append(Panel(syntax, border_style=f"dim {Theme.BORDER}",
|
||||
box=ROUNDED, padding=(0, 1)))
|
||||
except:
|
||||
for line in code.split('\n'):
|
||||
self._add(f" {line}", "msg-dim")
|
||||
i += 1
|
||||
|
||||
def _tool_ok(self, name, args):
|
||||
self._add(f" ✅ {name}: {args[:60]}", "msg-tool-ok")
|
||||
|
||||
def _tool_err(self, name, args):
|
||||
self._add(f" ❌ {name}: {args[:60]}", "msg-tool-err")
|
||||
|
||||
def _thinking(self):
|
||||
self._add(" 💭 思考中...", "msg-thinking")
|
||||
|
||||
def _system(self, text):
|
||||
self._add(f" ℹ️ {text}", "msg-system")
|
||||
|
||||
def _error(self, text):
|
||||
self._add(f" ⚠️ {text}", "msg-error")
|
||||
|
||||
# ── 命令处理 ──
|
||||
|
||||
@on(Input.Submitted, "#user-input")
|
||||
def on_input(self, event):
|
||||
text = event.value.strip()
|
||||
if not text:
|
||||
return
|
||||
event.input.value = ""
|
||||
|
||||
if text.startswith('/'):
|
||||
self._handle_command(text)
|
||||
return
|
||||
|
||||
self._user_msg(text)
|
||||
self._thinking()
|
||||
self.is_generating = True
|
||||
self._update_status("🔄 思考中...")
|
||||
self.run_worker(self._generate(text), exclusive=True)
|
||||
|
||||
def _handle_command(self, cmd):
|
||||
parts = cmd[1:].split(maxsplit=1)
|
||||
name = parts[0].lower()
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
cmds = {
|
||||
'help': lambda: self._show_help(),
|
||||
'quit': lambda: self.exit(),
|
||||
'q': lambda: self.exit(),
|
||||
'exit': lambda: self.exit(),
|
||||
'cli': lambda: self._switch_cli(),
|
||||
'clear': lambda: self.action_clear(),
|
||||
'cls': lambda: self.action_clear(),
|
||||
'model': lambda: self._switch_model(args),
|
||||
'engine': lambda: self._switch_model(args),
|
||||
'about': lambda: self._system("🐱 小狸 Pro-CLI v5.0 - 智能编程助手"),
|
||||
'status': lambda: self._show_status(),
|
||||
'tools': lambda: self._show_tools(),
|
||||
'engines': lambda: self._show_engines(),
|
||||
}
|
||||
|
||||
handler = cmds.get(name)
|
||||
if handler:
|
||||
handler()
|
||||
else:
|
||||
self._error(f"未知命令: /{name},输入 /help 查看帮助")
|
||||
|
||||
def _show_help(self):
|
||||
help_text = """ 📖 命令:
|
||||
/help 帮助信息
|
||||
/quit 退出
|
||||
/cli 切换命令行模式
|
||||
/model <引擎> 切换 AI 引擎
|
||||
/engines 列出引擎
|
||||
/tools 列出工具
|
||||
/status 系统状态
|
||||
/clear 清屏
|
||||
|
||||
⌨️ 快捷键:
|
||||
Ctrl+C 退出 Ctrl+L 清屏
|
||||
Ctrl+N 新对话 F1 侧栏
|
||||
Escape 取消生成"""
|
||||
self._system(help_text)
|
||||
|
||||
def _switch_cli(self):
|
||||
self._system("切换到命令行模式...")
|
||||
self.app_inst.tui_callback = None
|
||||
self.exit()
|
||||
|
||||
def _switch_model(self, name):
|
||||
name = name.strip()
|
||||
if not name:
|
||||
self._show_engines()
|
||||
return
|
||||
if self.bridge.switch_engine(name):
|
||||
self._system(f"已切换到: {name}")
|
||||
self._update_sidebar()
|
||||
else:
|
||||
self._error(f"未找到引擎: {name}")
|
||||
|
||||
def _show_engines(self):
|
||||
current = self.bridge.current_engine()
|
||||
lines = [" 可用引擎:"]
|
||||
for name in self.bridge.engines():
|
||||
marker = "▸" if name == current else " "
|
||||
lines.append(f" {marker} {name}")
|
||||
self._system('\n'.join(lines))
|
||||
|
||||
def _show_tools(self):
|
||||
tools = self.bridge.tools()
|
||||
lines = [f" 可用工具 ({len(tools)} 个):"]
|
||||
for t in tools:
|
||||
name = t.get('name', '?')
|
||||
desc = t.get('description', '')[:40]
|
||||
lines.append(f" • {name}: {desc}")
|
||||
self._system('\n'.join(lines))
|
||||
|
||||
def _show_status(self):
|
||||
self._system(f""" 系统状态:
|
||||
引擎: {self.bridge.current_engine()}
|
||||
引擎数: {len(self.bridge.engines())}
|
||||
工具数: {len(self.bridge.tools())}
|
||||
对话数: {len(self.bridge.history())}""")
|
||||
|
||||
# ── 生成 ──
|
||||
|
||||
async def _generate(self, user_input):
|
||||
try:
|
||||
self.call_after_refresh(self._remove_thinking)
|
||||
|
||||
def tui_output(msg):
|
||||
self.call_after_refresh(self._write_raw, msg)
|
||||
|
||||
original = self.app_inst.tui_callback
|
||||
self.app_inst.tui_callback = tui_output
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
None, self.app_inst.process_conversation, user_input
|
||||
)
|
||||
finally:
|
||||
self.app_inst.tui_callback = original
|
||||
|
||||
except Exception as e:
|
||||
self.call_after_refresh(self._error, str(e))
|
||||
finally:
|
||||
self.is_generating = False
|
||||
self.call_after_refresh(lambda: self._update_status("就绪"))
|
||||
self.call_after_refresh(self._update_sidebar)
|
||||
|
||||
def _remove_thinking(self):
|
||||
scroll = self.query_one("#chat-scroll")
|
||||
for child in reversed(list(scroll.children)):
|
||||
if '思考中' in str(getattr(child, 'renderable', '')):
|
||||
child.remove()
|
||||
break
|
||||
|
||||
def _write_raw(self, msg):
|
||||
if '✅' in msg or 'OK 工具' in msg:
|
||||
self._add(f" {msg}", "msg-tool-ok")
|
||||
elif '❌' in msg or 'X 工具' in msg or '错误' in msg:
|
||||
self._add(f" {msg}", "msg-tool-err")
|
||||
elif '✦' in msg:
|
||||
self._add(f" {msg}", "msg-ai")
|
||||
elif '工具' in msg and ('调用' in msg or '执行' in msg):
|
||||
self._add(f" {msg}", "msg-tool-ok")
|
||||
else:
|
||||
self._add(f" {msg}", "msg-dim")
|
||||
|
||||
# ── 动作 ──
|
||||
|
||||
def action_clear(self):
|
||||
scroll = self.query_one("#chat-scroll")
|
||||
scroll.remove_children()
|
||||
self._render_welcome()
|
||||
|
||||
def action_new_chat(self):
|
||||
self.app_inst.history.clear()
|
||||
self.action_clear()
|
||||
self._system("已开始新对话")
|
||||
|
||||
def action_toggle_sidebar(self):
|
||||
sidebar = self.query_one("#sidebar")
|
||||
sidebar.visible = not sidebar.visible
|
||||
|
||||
def action_toggle_tools(self):
|
||||
self.action_toggle_sidebar()
|
||||
|
||||
def action_cancel(self):
|
||||
if self.is_generating:
|
||||
self._system("已取消")
|
||||
self.is_generating = False
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 启动
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def run_tui(app_instance):
|
||||
if not TEXTUAL_AVAILABLE:
|
||||
print("⚠️ Textual 未安装")
|
||||
print(" pip install textual rich")
|
||||
return
|
||||
XiaoliTUI(app_instance).run()
|
||||
Reference in new issue
Block a user