feat: v3.6 重构 - 拆分 ai_cli.py 为模块化架构
本次更新由 mimo 自动化执行,与开发者无关。 新增 xiaoli/ 包: - __main__.py: 入口点 (python -m xiaoli) - app.py: 主应用薄编排层(~400行 vs 原3860行) - config.py: 配置管理 - engines.py: AI 引擎管理 - sandbox.py: 代码安全沙箱 - prompt.py: 系统提示词构建 - conversation/: 对话解析器 + 历史管理 - display/: CLI/TUI 双模式输出 改进: - 支持 argparse (--tui, --engine, --version) - 插件命令注册表 - 更新 README - 更新 .gitignore 原 ai_cli.py 保留向后兼容
This commit is contained in:
1 parent
95a0e4bc5c
commit
e1eee6a0c2
12 files changed
+1592
-65
No files matched your search
@@ -0,0 +1,180 @@
|
||||
"""响应解析器 - 从 AI 响应中提取文本和工具调用"""
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
from colorama import Fore, Style
|
||||
from typing import Tuple, Optional, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_response(response: str) -> Tuple[str, Optional[Union[dict, list]]]:
|
||||
"""
|
||||
解析混合响应,分离文本和 JSON 工具调用
|
||||
|
||||
Returns:
|
||||
(text_content, json_data)
|
||||
json_data 可以是 dict(单个调用)或 list(批量调用),或 None
|
||||
"""
|
||||
response = response.strip()
|
||||
if not response:
|
||||
return "", None
|
||||
|
||||
# 方法1: <tool_call> 格式修复
|
||||
if response.startswith("<tool_call>"):
|
||||
try:
|
||||
parsed = json.loads("{" + response[11:])
|
||||
if isinstance(parsed, dict):
|
||||
return "", parsed
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 方法2: 纯 JSON
|
||||
try:
|
||||
parsed = json.loads(response)
|
||||
if isinstance(parsed, (dict, list)):
|
||||
return "", parsed
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 方法3: 代码块中的 JSON
|
||||
pattern = r"```(?:json)?\s*({.*?})\s*```"
|
||||
matches = re.findall(pattern, response, re.DOTALL)
|
||||
if matches:
|
||||
try:
|
||||
data = json.loads(matches[-1])
|
||||
if isinstance(data, (dict, list)):
|
||||
text = re.sub(pattern, "", response, flags=re.DOTALL).strip()
|
||||
return re.sub(r"\n\s*\n", "\n\n", text).strip(), data
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 方法4: 每行独立 JSON
|
||||
lines = response.split("\n")
|
||||
json_objs = []
|
||||
text_lines = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("{") and stripped.endswith("}"):
|
||||
try:
|
||||
obj = json.loads(stripped)
|
||||
if isinstance(obj, dict):
|
||||
json_objs.append(obj)
|
||||
continue
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
text_lines.append(line)
|
||||
|
||||
if json_objs:
|
||||
text = "\n".join(text_lines).strip()
|
||||
return (text, json_objs[0]) if len(json_objs) == 1 else (text, json_objs)
|
||||
|
||||
# 方法5: 内联 JSON(use_tool / continue)
|
||||
for marker in ['{"action": "use_tool"', '{"action": "continue"',
|
||||
'{"action":"use_tool"', '{"action":"continue"']:
|
||||
idx = response.find(marker)
|
||||
if idx != -1:
|
||||
brace = 0
|
||||
for i in range(idx, len(response)):
|
||||
if response[i] == '{':
|
||||
brace += 1
|
||||
elif response[i] == '}':
|
||||
brace -= 1
|
||||
if brace == 0:
|
||||
try:
|
||||
data = json.loads(response[idx:i + 1])
|
||||
if isinstance(data, dict) and data.get('action') in ['use_tool', 'continue']:
|
||||
return response[:idx].strip(), data
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
break
|
||||
|
||||
return response, None
|
||||
|
||||
|
||||
def process_thinking(response: str, engine=None) -> str:
|
||||
"""处理深度思考标记,返回纯回复内容"""
|
||||
# <think/> 格式
|
||||
if '<think/>' in response:
|
||||
parts = response.split('<think/>', 1)
|
||||
if len(parts) >= 2:
|
||||
thinking = parts[0].strip()
|
||||
reply = parts[1].strip()
|
||||
if thinking:
|
||||
short = thinking[:200] + "..." if len(thinking) > 200 else thinking
|
||||
print(f"{Fore.LIGHTBLACK_EX}[思考: {short}]{Style.RESET_ALL}")
|
||||
return reply if reply else ""
|
||||
|
||||
# 自定义标记
|
||||
if engine:
|
||||
start = getattr(engine, 'thinking_start_marker', None)
|
||||
end = getattr(engine, 'thinking_end_marker', None)
|
||||
if start and end and start in response and end in response:
|
||||
parts = response.split(start, 1)
|
||||
if len(parts) > 1 and end in parts[1]:
|
||||
thinking, reply = parts[1].split(end, 1)
|
||||
if thinking.strip():
|
||||
short = thinking.strip()[:200]
|
||||
print(f"{Fore.LIGHTBLACK_EX}[思考: {short}]{Style.RESET_ALL}")
|
||||
return reply.strip()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def process_code_blocks(text: str) -> str:
|
||||
"""处理三引号代码块,红色显示"""
|
||||
def replace(m):
|
||||
inner = m.group(1)[3:-3]
|
||||
return f'{Fore.RED}{inner}{Style.RESET_ALL}'
|
||||
return re.sub(r'(""".*?""")', replace, text, flags=re.DOTALL)
|
||||
|
||||
|
||||
def extract_tool_calls(json_data) -> list:
|
||||
"""从 JSON 数据中提取工具调用列表(支持多种格式)"""
|
||||
calls = []
|
||||
|
||||
if isinstance(json_data, dict):
|
||||
json_data = [json_data]
|
||||
|
||||
if not isinstance(json_data, list):
|
||||
return calls
|
||||
|
||||
for item in json_data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
# 小狸格式
|
||||
if item.get('action') == 'use_tool':
|
||||
calls.append(item)
|
||||
# 简化格式
|
||||
elif 'tool' in item and 'args' in item:
|
||||
calls.append(item)
|
||||
# OpenAI function_call
|
||||
elif 'function_call' in item:
|
||||
fc = item['function_call']
|
||||
args = fc.get('arguments', '')
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except:
|
||||
pass
|
||||
calls.append({
|
||||
'tool': fc.get('name', ''),
|
||||
'args': args if isinstance(args, str) else '',
|
||||
'arguments': args if isinstance(args, dict) else None
|
||||
})
|
||||
# MCP 格式
|
||||
elif 'name' in item and 'arguments' in item:
|
||||
args = item.get('arguments', '')
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except:
|
||||
pass
|
||||
calls.append({
|
||||
'tool': item.get('name', ''),
|
||||
'args': args if isinstance(args, str) else '',
|
||||
'arguments': args if isinstance(args, dict) else None
|
||||
})
|
||||
|
||||
return calls
|
||||
@@ -0,0 +1,86 @@
|
||||
"""对话历史管理"""
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional
|
||||
from colorama import Fore, Style
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class History:
|
||||
"""对话历史管理器"""
|
||||
|
||||
def __init__(self, history_dir: str, max_items: int = 999999):
|
||||
self.dir = history_dir
|
||||
self.max = max_items
|
||||
self.messages = []
|
||||
|
||||
os.makedirs(self.dir, exist_ok=True)
|
||||
|
||||
def add(self, role: str, content: str):
|
||||
"""添加消息"""
|
||||
self.messages.append({"role": role, "content": content})
|
||||
if len(self.messages) > self.max:
|
||||
self.messages = self.messages[-self.max:]
|
||||
|
||||
def get(self) -> list:
|
||||
"""获取全部历史"""
|
||||
return self.messages
|
||||
|
||||
def clear(self):
|
||||
"""清空历史"""
|
||||
self.messages = []
|
||||
|
||||
def set_all(self, messages: list):
|
||||
"""设置全部历史"""
|
||||
self.messages = messages
|
||||
|
||||
def save(self, name: str) -> bool:
|
||||
"""保存到文件"""
|
||||
try:
|
||||
data = {
|
||||
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"conversation": self.messages.copy()
|
||||
}
|
||||
path = os.path.join(self.dir, f"{name}.json")
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2, default=str)
|
||||
print(f"{Fore.GREEN}聊天记录已保存: {name}{Style.RESET_ALL}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"{Fore.RED}保存失败: {e}{Style.RESET_ALL}")
|
||||
return False
|
||||
|
||||
def load(self, name: str) -> bool:
|
||||
"""从文件加载"""
|
||||
try:
|
||||
path = os.path.join(self.dir, f"{name}.json")
|
||||
if not os.path.exists(path):
|
||||
print(f"{Fore.RED}记录 '{name}' 不存在{Style.RESET_ALL}")
|
||||
return False
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
self.messages = data.get("conversation", [])
|
||||
print(f"{Fore.GREEN}已加载: {name} ({len(self.messages)} 条){Style.RESET_ALL}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"{Fore.RED}加载失败: {e}{Style.RESET_ALL}")
|
||||
return False
|
||||
|
||||
def list_saved(self):
|
||||
"""列出所有已保存的记录"""
|
||||
try:
|
||||
files = [f for f in os.listdir(self.dir) if f.endswith('.json')]
|
||||
if not files:
|
||||
print(f"{Fore.YELLOW}没有已保存的记录{Style.RESET_ALL}")
|
||||
return
|
||||
print(f"{Fore.GREEN}已保存的记录:{Style.RESET_ALL}")
|
||||
for i, f in enumerate(files, 1):
|
||||
path = os.path.join(self.dir, f)
|
||||
mtime = time.strftime("%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(os.path.getmtime(path)))
|
||||
print(f"{Fore.GREEN}{i}. {f[:-5]} ({mtime}){Style.RESET_ALL}")
|
||||
except Exception as e:
|
||||
print(f"{Fore.RED}列出记录失败: {e}{Style.RESET_ALL}")
|
||||
Reference in new issue
Block a user