xiaoli-cli 7.0.0: 重构底层架构 + 移除工具截断
- 重构 core/ 底层 (7文件): app/runner/engine/parser/history/safety/display - FC原生调用为主,prompt-JSON保底 - 移除所有工具返回截断 (ai_search/git_tools/cmd_executor/browser_auto/network_tools/sub_agent/tool_search) - 删除无用文件 (旧服务器/WebUI/彩蛋/启动器等21个文件) - 清理 constants.py 死代码 - 版本号更新为 7.0.0
This commit is contained in:
commit
78fea7ccdd
57 files changed
+18358
No files matched your search
@@ -0,0 +1,2 @@
|
||||
"""xiaoli-cli 3.0.0 核心"""
|
||||
from .app import XiaoliCLI
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
"""xiaoli-cli 7.0.0 主类"""
|
||||
import os, sys, json, importlib
|
||||
from datetime import datetime
|
||||
|
||||
from .types import ToolCall, ToolResult
|
||||
from .history import History
|
||||
from .safety import Safety
|
||||
from .display import Display
|
||||
from .engine import EngineAdapter
|
||||
from .runner import Runner
|
||||
|
||||
|
||||
class XiaoliCLI:
|
||||
def __init__(self):
|
||||
self.shared_conversation_history = []
|
||||
self.max_history = 999999
|
||||
self.current_engine = None
|
||||
self.engines = {}
|
||||
self.engine_name = "openai"
|
||||
self.liugin_manager = None
|
||||
self.tools = []
|
||||
self.liugin_commands = {}
|
||||
self.chat_dir = "chat_history"
|
||||
self.memory_manager = None
|
||||
|
||||
self._load_config()
|
||||
self._load_engines()
|
||||
self._load_plugins()
|
||||
self._init_memory()
|
||||
self._init_core()
|
||||
self._register_commands()
|
||||
print("xiaoli-cli 7.0.0 初始化完成")
|
||||
|
||||
def _load_config(self):
|
||||
try:
|
||||
from xcli_core.config import get_system_config
|
||||
self.engine_name = get_system_config("default_engine", "openai")
|
||||
except Exception:
|
||||
self.engine_name = "openai"
|
||||
|
||||
def _load_engines(self):
|
||||
print("正在加载AI引擎...")
|
||||
d = os.path.join(os.path.dirname(os.path.dirname(__file__)), "ai_engines")
|
||||
if not os.path.isdir(d):
|
||||
return
|
||||
for fn in os.listdir(d):
|
||||
if fn.endswith("_engine.py") and not fn.startswith("__"):
|
||||
mn = fn[:-3]
|
||||
try:
|
||||
mod = importlib.import_module(f"ai_engines.{mn}")
|
||||
for an in dir(mod):
|
||||
obj = getattr(mod, an)
|
||||
if isinstance(obj, type) and an.endswith("AI") and hasattr(obj, "generate_response"):
|
||||
eng = obj()
|
||||
eng.cli = self
|
||||
eng.shared_conversation_history = self.shared_conversation_history
|
||||
self.engines[eng.name] = eng
|
||||
print(f" 已加载: {eng.name} ({an})")
|
||||
except Exception as e:
|
||||
print(f" [ERROR] {mn}: {e}")
|
||||
if self.engine_name not in self.engines and self.engines:
|
||||
self.engine_name = list(self.engines.keys())[0]
|
||||
self.current_engine = self.engines.get(self.engine_name)
|
||||
print(f" 共 {len(self.engines)} 个引擎,当前: {self.engine_name}")
|
||||
|
||||
def _load_plugins(self):
|
||||
print("正在加载插件...")
|
||||
try:
|
||||
from unified_tool_manager import UnifiedToolManager
|
||||
self.liugin_manager = UnifiedToolManager(self)
|
||||
pd = os.path.join(os.path.dirname(os.path.dirname(__file__)), "plugins")
|
||||
sd = os.path.join(os.path.dirname(os.path.dirname(__file__)), "skills")
|
||||
self.liugin_manager.initialize(pd, sd)
|
||||
self.tools = self.liugin_manager.tools if hasattr(self.liugin_manager, "tools") else []
|
||||
print(f" 已加载 {len(self.tools)} 个插件")
|
||||
except Exception as e:
|
||||
print(f" [ERROR] 插件加载失败: {e}")
|
||||
import traceback; traceback.print_exc()
|
||||
|
||||
def _init_memory(self):
|
||||
try:
|
||||
from xcli_core.memory import MemoryManager
|
||||
self.memory_manager = MemoryManager(self)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _init_core(self):
|
||||
self.history = History(self.max_history)
|
||||
self.safety = Safety(self, lambda: self.current_engine)
|
||||
self.display = Display()
|
||||
self.engine_adapter = EngineAdapter(self.current_engine)
|
||||
self.runner = Runner(self.engine_adapter, self.liugin_manager,
|
||||
self.history, self.safety, self.display)
|
||||
self.runner.set_prompt_builder(self._build_prompt)
|
||||
|
||||
def _build_prompt(self):
|
||||
p = '你叫小狸,是一个强大的智能编程助手。\n\n'
|
||||
p += '【工具调用】\n当你需要执行操作时,直接调用工具。\n工具执行后,根据结果继续回复或调用下一个工具。\n\n'
|
||||
p += '【补充回复】\n如果你需要补充之前的回复,输出:\n'
|
||||
p += '{"action": "continue", "content": "要补充的内容"}\n\n'
|
||||
p += '【核心工具】\n'
|
||||
p += '1. code_editor - 代码编辑\n2. code_search - 代码搜索\n3. git_tools - Git\n'
|
||||
p += '4. cmd_executor - Shell命令\n5. file_manager - 文件管理\n6. browser_auto - 浏览器\n'
|
||||
p += '7. sub_agent - 子Agent\n8. auto_engineer - 工程化\n9. network_tools - 网络\n'
|
||||
p += '10. ai_search - 网页内容 (url/search/web)\n\n'
|
||||
p += '说话时可适当加nyan表现可爱性格。'
|
||||
return p
|
||||
|
||||
def register_liugin_command(self, name, handler):
|
||||
self.liugin_commands[name] = handler
|
||||
|
||||
def process_conversation(self, user_input):
|
||||
return self.runner.run(user_input)
|
||||
|
||||
def switch_engine(self, name):
|
||||
if name in self.engines:
|
||||
self.engine_name = name
|
||||
self.current_engine = self.engines[name]
|
||||
self.current_engine.shared_conversation_history = self.shared_conversation_history
|
||||
self.engine_adapter = EngineAdapter(self.current_engine)
|
||||
self.runner.set_engine(self.engine_adapter)
|
||||
self.safety._engine = lambda: self.current_engine
|
||||
try:
|
||||
from xcli_core.config import set_system_config
|
||||
set_system_config("default_engine", name)
|
||||
except Exception:
|
||||
pass
|
||||
return f"已切换到: {name}"
|
||||
return f"引擎 {name} 不存在"
|
||||
|
||||
def _register_commands(self):
|
||||
self._commands = {
|
||||
"/help": self._cmd_help,
|
||||
"/engine": self._cmd_engine,
|
||||
"/mode": self._cmd_mode,
|
||||
"/clear": self._cmd_clear,
|
||||
"/history": self._cmd_history,
|
||||
"/tools": self._cmd_tools,
|
||||
"/exit": self._cmd_exit,
|
||||
"/quit": self._cmd_exit,
|
||||
"/save": self._cmd_save,
|
||||
"/config": self._cmd_config,
|
||||
"/new": self._cmd_new,
|
||||
}
|
||||
|
||||
def handle_command(self, user_input):
|
||||
parts = user_input.strip().split(None, 1)
|
||||
cmd = parts[0].lower().lstrip("/")
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
h = self._commands.get("/" + cmd)
|
||||
if h:
|
||||
return h(args)
|
||||
if cmd in self.liugin_commands:
|
||||
try:
|
||||
r = self.liugin_commands[cmd](args)
|
||||
if r: print(r)
|
||||
except Exception as e:
|
||||
print(f"命令失败: {e}")
|
||||
return ""
|
||||
return None
|
||||
|
||||
def _cmd_help(self, a):
|
||||
return "\n".join(["/help - 帮助", "/engine [list|<name>] - 引擎",
|
||||
"/mode [0-3] - 安全模式", "/clear - 清空历史",
|
||||
"/history - 历史", "/tools - 工具", "/save - 保存",
|
||||
"/new - 新对话", "/config - 配置", "/exit - 退出"])
|
||||
|
||||
def _cmd_engine(self, a):
|
||||
if not a or a == "list":
|
||||
lines = [f"当前: {self.engine_name}", "可用:"]
|
||||
for n in self.engines:
|
||||
lines.append(f" {n}{' <==' if n == self.engine_name else ''}")
|
||||
return "\n".join(lines)
|
||||
return self.switch_engine(a)
|
||||
|
||||
def _cmd_mode(self, a):
|
||||
if not a: return f"当前模式: {self.safety.get_mode()}"
|
||||
try:
|
||||
m = int(a)
|
||||
self.safety.set_mode(m)
|
||||
names = {0: "无限制", 1: "普通", 2: "人工", 3: "计划"}
|
||||
return f"已切换: {names.get(m, '未知')}"
|
||||
except ValueError:
|
||||
return "用法: /mode [0-3]"
|
||||
|
||||
def _cmd_clear(self, a):
|
||||
self.history.clear()
|
||||
self.shared_conversation_history.clear()
|
||||
return "已清空"
|
||||
|
||||
def _cmd_history(self, a):
|
||||
msgs = self.history.snapshot()
|
||||
if not msgs: return "无历史"
|
||||
lines = [f"共 {len(msgs)} 条:"]
|
||||
for m in msgs[-10:]:
|
||||
c = m.content[:50] + "..." if len(m.content) > 50 else m.content
|
||||
lines.append(f" [{m.role}] {c}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _cmd_tools(self, a):
|
||||
if not self.tools: return "无工具"
|
||||
lines = [f"共 {len(self.tools)} 个:"]
|
||||
for t in self.tools:
|
||||
lines.append(f" {t.get('name','')}: {t.get('description','')[:60]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _cmd_save(self, a):
|
||||
try:
|
||||
os.makedirs(self.chat_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
fp = os.path.join(self.chat_dir, f"{ts}_chat.json")
|
||||
msgs = self.history.snapshot()
|
||||
data = [{"role": m.role, "content": m.content} for m in msgs]
|
||||
with open(fp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
return f"已保存: {fp}"
|
||||
except Exception as e:
|
||||
return f"失败: {e}"
|
||||
|
||||
def _cmd_new(self, a):
|
||||
self._cmd_save("")
|
||||
self.history.clear()
|
||||
self.shared_conversation_history.clear()
|
||||
return "新对话"
|
||||
|
||||
def _cmd_config(self, a):
|
||||
try:
|
||||
with open("config.json", "r", encoding="utf-8") as f:
|
||||
return json.dumps(json.load(f), ensure_ascii=False, indent=2)[:1000]
|
||||
except Exception as e:
|
||||
return f"失败: {e}"
|
||||
|
||||
def _cmd_exit(self, a):
|
||||
self._cmd_save("")
|
||||
return "__EXIT__"
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from colorama import init, Fore, Style
|
||||
init()
|
||||
except ImportError:
|
||||
class _D:
|
||||
CYAN = GREEN = YELLOW = RESET_ALL = ''
|
||||
Fore = Style = _D()
|
||||
print()
|
||||
print(f" {Fore.CYAN}+========================================+{Style.RESET_ALL}")
|
||||
print(f" {Fore.CYAN}| xiaoli-cli 7.0.0 (重构版) |{Style.RESET_ALL}")
|
||||
print(f" {Fore.CYAN}| 小狸 AI 编程助手 |{Style.RESET_ALL}")
|
||||
print(f" {Fore.CYAN}+========================================+{Style.RESET_ALL}")
|
||||
print()
|
||||
print(f" 引擎: {Fore.GREEN}{self.engine_name}{Style.RESET_ALL if hasattr(Style,'RESET_ALL') else ''}")
|
||||
print(f" 工具: {Fore.GREEN}{len(self.tools)}{Style.RESET_ALL if hasattr(Style,'RESET_ALL') else ''} 个插件")
|
||||
print(f" FC: {'支持' if self.engine_adapter.supports_fc() else '不支持'}")
|
||||
print(f" 输入 /help 查看命令")
|
||||
print()
|
||||
while True:
|
||||
try:
|
||||
ui = input(f"{Fore.CYAN}[用户ID: xiaoli]{Style.RESET_ALL if hasattr(Style,'RESET_ALL') else ''} ").strip()
|
||||
if not ui: continue
|
||||
if ui.startswith("/"):
|
||||
r = self.handle_command(ui)
|
||||
if r == "__EXIT__":
|
||||
print(f" {Fore.YELLOW}再见~{Style.RESET_ALL if hasattr(Style,'RESET_ALL') else ''}")
|
||||
break
|
||||
if r: print(r)
|
||||
continue
|
||||
self.process_conversation(ui)
|
||||
print()
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n {Fore.YELLOW}(Ctrl+C, /exit退出){Style.RESET_ALL if hasattr(Style,'RESET_ALL') else ''}")
|
||||
except EOFError:
|
||||
break
|
||||
except Exception as e:
|
||||
print(f" {Fore.RED}错误: {e}{Style.RESET_ALL if hasattr(Style,'RESET_ALL') else ''}")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""显示输出"""
|
||||
import sys
|
||||
|
||||
try:
|
||||
from colorama import Fore, Style, init
|
||||
init()
|
||||
except ImportError:
|
||||
class _D:
|
||||
CYAN = GREEN = YELLOW = RED = LIGHTBLACK_EX = RESET_ALL = ''
|
||||
Fore = Style = _D()
|
||||
|
||||
|
||||
class Display:
|
||||
def text(self, text, is_continue=False):
|
||||
if text:
|
||||
print(text)
|
||||
|
||||
def thinking(self, text):
|
||||
if text:
|
||||
t = text[:500] + "..." if len(text) > 500 else text
|
||||
print(f"{Style.LIGHTBLACK_EX if hasattr(Style,'LIGHTBLACK_EX') else Fore.LIGHTBLACK_EX}[深度思考: {t}]{Style.RESET_ALL}")
|
||||
|
||||
def tool_start(self, tc):
|
||||
args = tc.args[:80] + "..." if len(tc.args) > 80 else tc.args
|
||||
print(f" {Fore.GREEN}OK 工具:{Fore.RESET_ALL if hasattr(Fore,'RESET_ALL') else Style.RESET_ALL} {tc.name}")
|
||||
print(f" {Fore.CYAN}参数:{Style.RESET_ALL} {args}")
|
||||
|
||||
def tool_result(self, tc, result):
|
||||
text = result.data if result.data is not None else result.error_message
|
||||
if text:
|
||||
s = str(text)
|
||||
if len(s) > 2000:
|
||||
lines = s.split("\n")
|
||||
if len(lines) > 30:
|
||||
s = "\n".join(lines[:30]) + f"\n... (共{len(lines)}行)"
|
||||
print(f" {Fore.CYAN}结果:{Style.RESET_ALL}")
|
||||
for line in s.split("\n"):
|
||||
print(f" {line}")
|
||||
|
||||
def info(self, msg):
|
||||
print(f"{Fore.YELLOW}{msg}{Style.RESET_ALL}")
|
||||
|
||||
def error(self, msg):
|
||||
print(f"{Fore.RED}{msg}{Style.RESET_ALL}")
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
"""引擎适配 — FC为主,prompt-JSON保底"""
|
||||
import json, re, uuid
|
||||
from .types import EngineResponse, ToolCall
|
||||
|
||||
|
||||
class EngineAdapter:
|
||||
def __init__(self, engine):
|
||||
self._engine = engine
|
||||
self._fc = hasattr(engine, 'generate_with_tools')
|
||||
|
||||
def supports_fc(self):
|
||||
return self._fc
|
||||
|
||||
def name(self):
|
||||
return getattr(self._engine, 'name', 'unknown')
|
||||
|
||||
def generate(self, messages, tools=None, system_prompt=None):
|
||||
if self._fc and tools:
|
||||
return self._gen_fc(messages, tools, system_prompt)
|
||||
return self._gen_legacy(messages, system_prompt)
|
||||
|
||||
def _gen_fc(self, messages, tools, system_prompt):
|
||||
try:
|
||||
raw = self._engine.generate_with_tools(messages, tools, system_prompt=system_prompt)
|
||||
text = ""
|
||||
thinking = ""
|
||||
tool_calls = None
|
||||
if isinstance(raw, dict):
|
||||
text = raw.get("content", "") or ""
|
||||
thinking = raw.get("reasoning_content", "") or ""
|
||||
tc_list = raw.get("tool_calls", []) or []
|
||||
if tc_list:
|
||||
tool_calls = []
|
||||
for tc in tc_list:
|
||||
func = tc.get("function", {})
|
||||
cid = tc.get("id", str(uuid.uuid4())[:8])
|
||||
name = func.get("name", "")
|
||||
args_str = func.get("arguments", "{}")
|
||||
arguments = None
|
||||
try:
|
||||
if isinstance(args_str, str) and args_str:
|
||||
arguments = json.loads(args_str)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
args = self._to_legacy(name, arguments) if arguments else (args_str or "")
|
||||
tool_calls.append(ToolCall(id=cid, name=name, args=args,
|
||||
arguments=arguments, raw=tc))
|
||||
return EngineResponse(text=text, thinking=thinking, tool_calls=tool_calls, raw=raw)
|
||||
except Exception as e:
|
||||
return EngineResponse(text=f"引擎调用失败: {e}")
|
||||
|
||||
def _gen_legacy(self, messages, system_prompt):
|
||||
user_input = ""
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
user_input = msg.get("content", "")
|
||||
break
|
||||
try:
|
||||
resp = self._engine.generate_response(user_input, system_prompt=system_prompt)
|
||||
except Exception as e:
|
||||
return EngineResponse(text=f"引擎调用失败: {e}")
|
||||
if not resp:
|
||||
return EngineResponse()
|
||||
text, thinking = self._strip_thinking(resp)
|
||||
return EngineResponse(text=text, thinking=thinking, raw=resp)
|
||||
|
||||
def _strip_thinking(self, text):
|
||||
if not text:
|
||||
return "", ""
|
||||
thinking = ""
|
||||
if "<thinking>" in text and "</thinking>" in text:
|
||||
m = re.search(r'<thinking>(.*?)</thinking>(.*)', text, re.DOTALL)
|
||||
if m:
|
||||
thinking = m.group(1).strip()
|
||||
text = m.group(2).strip()
|
||||
start = getattr(self._engine, 'thinking_start_marker', None)
|
||||
end = getattr(self._engine, 'thinking_end_marker', None)
|
||||
if start and end and start in text and end in text:
|
||||
parts = text.split(start)
|
||||
if len(parts) >= 2:
|
||||
rest = parts[1]
|
||||
if end in rest:
|
||||
tp, rp = rest.split(end, 1)
|
||||
thinking = (thinking + "\n" + tp.strip()).strip() if thinking else tp.strip()
|
||||
text = (parts[0] + rp).strip()
|
||||
return text, thinking
|
||||
|
||||
def _to_legacy(self, tool_name, args):
|
||||
"""FC dict参数 → 旧插件字符串参数"""
|
||||
if not args:
|
||||
return ""
|
||||
if tool_name == "ai_search":
|
||||
url = args.get("url", "")
|
||||
q = args.get("query", args.get("search", ""))
|
||||
if url: return f"url {url}"
|
||||
if q: return f"search {q}"
|
||||
if tool_name == "network_tools":
|
||||
op = args.get("operation", args.get("op", "get"))
|
||||
t = args.get("target", args.get("url", args.get("host", "")))
|
||||
if op and t: return f"{op} {t}"
|
||||
if tool_name == "code_editor":
|
||||
op = args.get("operation", args.get("action", ""))
|
||||
fp = args.get("file", args.get("path", args.get("filename", "")))
|
||||
ct = args.get("content", args.get("text", args.get("code", "")))
|
||||
old = args.get("old", args.get("old_code", args.get("find", "")))
|
||||
new = args.get("new", args.get("new_code", args.get("replace", "")))
|
||||
ln = args.get("line", args.get("line_number", ""))
|
||||
sl = args.get("start", args.get("start_line", ""))
|
||||
el = args.get("end", args.get("end_line", ""))
|
||||
pat = args.get("pattern", args.get("file_pattern", ""))
|
||||
d = args.get("directory", args.get("dir", "."))
|
||||
if op == "create" and fp and ct: return f"create {fp}\n{ct}"
|
||||
if op == "write" and fp and ct: return f"write {fp}\n{ct}"
|
||||
if op == "append" and fp and ct: return f"append {fp}\n{ct}"
|
||||
if op in ("read_range", "read"): return f"read_range {fp} {sl or 1} {el or 50}"
|
||||
if op == "edit": return f"edit {fp}\n{old}\n<<<>>>\n{new}"
|
||||
if op == "insert": return f"insert {fp} {ln}\n{ct}"
|
||||
if op in ("find", "search"): return f"find {pat} {d}"
|
||||
if op == "syntax_check": return f"syntax_check {fp}"
|
||||
return json.dumps(args, ensure_ascii=False)
|
||||
if tool_name == "code_search":
|
||||
op = args.get("operation", "find")
|
||||
pat = args.get("pattern", args.get("query", args.get("search", "")))
|
||||
d = args.get("directory", args.get("dir", "."))
|
||||
return f"{op} {pat} {d}"
|
||||
if tool_name == "git_tools":
|
||||
op = args.get("operation", "status")
|
||||
a = args.get("args", "")
|
||||
return f"{op} {a}" if a else op
|
||||
if tool_name == "cmd_executor":
|
||||
return args.get("command", args.get("cmd", args.get("shell", "")))
|
||||
if tool_name == "file_manager":
|
||||
op = args.get("operation", args.get("action", "list"))
|
||||
p = args.get("path", args.get("file", "."))
|
||||
ct = args.get("content", args.get("text", ""))
|
||||
if op in ("read", "list", "info"): return f"{op} {p}"
|
||||
if op == "write" and ct: return f'write {p} -c "{ct}"'
|
||||
if op == "append" and ct: return f'append {p} -c "{ct}"'
|
||||
if op and p: return f"{op} {p}"
|
||||
if tool_name == "browser_auto":
|
||||
op = args.get("operation", "")
|
||||
url = args.get("url", "")
|
||||
if op == "open" and url: return f"open {url}"
|
||||
if tool_name == "auto_engineer":
|
||||
op = args.get("operation", "")
|
||||
t = args.get("target", args.get("path", ""))
|
||||
if op and t: return f"{op} {t}"
|
||||
return op
|
||||
if tool_name == "sub_agent":
|
||||
op = args.get("operation", "list")
|
||||
if op == "create":
|
||||
task = args.get("task", args.get("prompt", ""))
|
||||
return f"create {task}"
|
||||
return op
|
||||
if tool_name in ("task_manager", "scheduler"):
|
||||
return args.get("operation", "list")
|
||||
# 通用
|
||||
if "operation" in args:
|
||||
op = args["operation"]
|
||||
rest = " ".join(str(v) for k, v in args.items() if k != "operation")
|
||||
return f"{op} {rest}".strip()
|
||||
return " ".join(str(v) for v in args.values())
|
||||
|
||||
def handle_command(self, cmd):
|
||||
if hasattr(self._engine, 'handle_command'):
|
||||
return self._engine.handle_command(cmd)
|
||||
return False
|
||||
|
||||
def get_raw(self):
|
||||
return self._engine
|
||||
@@ -0,0 +1,84 @@
|
||||
"""线程安全对话历史"""
|
||||
import json
|
||||
import threading
|
||||
from .types import Message
|
||||
|
||||
|
||||
class History:
|
||||
def __init__(self, max_size=999999):
|
||||
self._msgs = []
|
||||
self._lock = threading.RLock()
|
||||
self._max = max_size
|
||||
|
||||
def add_user(self, text):
|
||||
with self._lock:
|
||||
self._msgs.append(Message("user", text))
|
||||
self._trim()
|
||||
|
||||
def add_assistant(self, text, tool_calls=None):
|
||||
with self._lock:
|
||||
m = Message("assistant", text)
|
||||
if tool_calls:
|
||||
m.tool_calls = tool_calls
|
||||
self._msgs.append(m)
|
||||
self._trim()
|
||||
|
||||
def add_tool(self, call_id, name, result):
|
||||
with self._lock:
|
||||
self._msgs.append(Message("tool", result, tool_call_id=call_id, name=name))
|
||||
self._trim()
|
||||
|
||||
def add_cancel(self, name, args):
|
||||
with self._lock:
|
||||
self._msgs.append(Message("user", f"工具 {name} 已取消。参数: {args}"))
|
||||
self._trim()
|
||||
|
||||
def _trim(self):
|
||||
if len(self._msgs) > self._max:
|
||||
self._msgs = self._msgs[-self._max:]
|
||||
|
||||
def render(self, system_prompt="", current=None, fc=False):
|
||||
"""渲染为消息列表。fc=True 用 role:tool,否则工具结果用 role:user"""
|
||||
with self._lock:
|
||||
result = []
|
||||
if system_prompt:
|
||||
result.append({"role": "system", "content": system_prompt})
|
||||
for m in self._msgs:
|
||||
if m.role == "tool" and not fc:
|
||||
result.append({"role": "user", "content": m.content})
|
||||
else:
|
||||
entry = {"role": m.role, "content": m.content}
|
||||
if fc and m.role == "tool" and m.tool_call_id:
|
||||
entry["tool_call_id"] = m.tool_call_id
|
||||
if m.name:
|
||||
entry["name"] = m.name
|
||||
if fc and m.role == "assistant" and m.tool_calls:
|
||||
# content 为空时设为 None(OpenAI 规范:有 tool_calls 时 content 可为 null)
|
||||
if not entry.get("content"):
|
||||
entry["content"] = None
|
||||
# arguments 必须是 JSON 字符串(OpenAI 规范)
|
||||
entry["tool_calls"] = [
|
||||
{"id": tc.id, "type": "function",
|
||||
"function": {
|
||||
"name": tc.name,
|
||||
"arguments": json.dumps(tc.arguments, ensure_ascii=False) if tc.arguments
|
||||
else json.dumps({"args": tc.args}, ensure_ascii=False)
|
||||
}}
|
||||
for tc in m.tool_calls
|
||||
]
|
||||
result.append(entry)
|
||||
if current:
|
||||
result.append({"role": "user", "content": current})
|
||||
return result
|
||||
|
||||
def snapshot(self):
|
||||
with self._lock:
|
||||
return list(self._msgs)
|
||||
|
||||
def clear(self):
|
||||
with self._lock:
|
||||
self._msgs.clear()
|
||||
|
||||
def __len__(self):
|
||||
with self._lock:
|
||||
return len(self._msgs)
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
"""JSON解析 — 保底方案"""
|
||||
import json, re, uuid
|
||||
from .types import ToolCall, Plan
|
||||
|
||||
|
||||
def parse_prompt_json(text):
|
||||
"""解析prompt-JSON,返回 (text_content, tool_calls_or_None)"""
|
||||
text = text.strip()
|
||||
json_str = _extract_json(text)
|
||||
if json_str is None:
|
||||
return text, None
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
except json.JSONDecodeError:
|
||||
return text, None
|
||||
idx = text.find(json_str)
|
||||
prefix = text[:idx].strip() if idx > 0 else ""
|
||||
if isinstance(data, list):
|
||||
calls = [c for c in (_make(c) for c in data) if c]
|
||||
return prefix, calls if calls else None
|
||||
if isinstance(data, dict):
|
||||
c = _make(data)
|
||||
if c:
|
||||
return prefix, [c]
|
||||
return prefix, data
|
||||
return text, None
|
||||
|
||||
|
||||
def _extract_json(text):
|
||||
"""平衡括号扫描"""
|
||||
depth = 0
|
||||
start = -1
|
||||
in_str = False
|
||||
esc = False
|
||||
for i, c in enumerate(text):
|
||||
if esc:
|
||||
esc = False
|
||||
continue
|
||||
if c == '\\' and in_str:
|
||||
esc = True
|
||||
continue
|
||||
if c == '"' and not esc:
|
||||
in_str = not in_str
|
||||
continue
|
||||
if in_str:
|
||||
continue
|
||||
if c == '{':
|
||||
if depth == 0:
|
||||
start = i
|
||||
depth += 1
|
||||
elif c == '}':
|
||||
depth -= 1
|
||||
if depth == 0 and start != -1:
|
||||
candidate = text[start:i+1]
|
||||
if '"action"' in candidate or '"tool"' in candidate:
|
||||
return candidate
|
||||
start = -1
|
||||
elif c == '[' and depth == 0:
|
||||
bd = 1
|
||||
j = i + 1
|
||||
in_s = False
|
||||
e2 = False
|
||||
while j < len(text) and bd > 0:
|
||||
cj = text[j]
|
||||
if e2:
|
||||
e2 = False
|
||||
elif cj == '\\' and in_s:
|
||||
e2 = True
|
||||
elif cj == '"' and not e2:
|
||||
in_s = not in_s
|
||||
elif not in_s:
|
||||
if cj == '[': bd += 1
|
||||
elif cj == ']': bd -= 1
|
||||
j += 1
|
||||
if bd == 0:
|
||||
candidate = text[i:j]
|
||||
if '"action"' in candidate or '"tool"' in candidate:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _make(data):
|
||||
"""dict → ToolCall"""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
cid = data.get("id", str(uuid.uuid4())[:8])
|
||||
name = ""
|
||||
args = ""
|
||||
arguments = None
|
||||
if data.get("action") == "use_tool":
|
||||
name = data.get("tool", data.get("name", ""))
|
||||
ra = data.get("args", data.get("arguments", ""))
|
||||
if isinstance(ra, str): args = ra
|
||||
elif isinstance(ra, dict):
|
||||
arguments = ra
|
||||
args = json.dumps(ra, ensure_ascii=False)
|
||||
else: args = str(ra)
|
||||
elif "name" in data and "arguments" in data:
|
||||
name = data["name"]
|
||||
ra = data["arguments"]
|
||||
if isinstance(ra, str):
|
||||
args = ra
|
||||
try: arguments = json.loads(ra)
|
||||
except: pass
|
||||
elif isinstance(ra, dict):
|
||||
arguments = ra
|
||||
args = json.dumps(ra, ensure_ascii=False)
|
||||
elif "tool" in data and "args" in data:
|
||||
name = data["tool"]
|
||||
ra = data["args"]
|
||||
if isinstance(ra, str): args = ra
|
||||
elif isinstance(ra, dict):
|
||||
arguments = ra
|
||||
args = json.dumps(ra, ensure_ascii=False)
|
||||
else:
|
||||
return None
|
||||
if not name:
|
||||
return None
|
||||
return ToolCall(id=cid, name=name, args=args, arguments=arguments, raw=data)
|
||||
|
||||
|
||||
def interpret(resp, fc_mode):
|
||||
"""EngineResponse → Plan"""
|
||||
# FC模式
|
||||
if fc_mode and resp.tool_calls:
|
||||
return Plan(action="use_tool", text=resp.text or "", tool_calls=resp.tool_calls)
|
||||
# prompt-JSON
|
||||
text, parsed = parse_prompt_json(resp.text)
|
||||
if parsed is None:
|
||||
return Plan(action="final", text=resp.text)
|
||||
if isinstance(parsed, list):
|
||||
return Plan(action="use_tool", text=text, tool_calls=parsed)
|
||||
if isinstance(parsed, dict):
|
||||
action = parsed.get("action", "")
|
||||
if action == "use_tool":
|
||||
c = _make(parsed)
|
||||
if c:
|
||||
return Plan(action="use_tool", text=text, tool_calls=[c])
|
||||
return Plan(action="final", text=resp.text)
|
||||
if action == "continue" or parsed.get("continue"):
|
||||
msg = parsed.get("content", parsed.get("message", ""))
|
||||
return Plan(action="continue", text=msg or text)
|
||||
if "message" in parsed:
|
||||
return Plan(action="final", text=parsed["message"])
|
||||
return Plan(action="final", text=text or resp.text)
|
||||
return Plan(action="final", text=resp.text)
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
"""对话循环 — 唯一循环,无重复"""
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from .types import ToolCall, ToolResult
|
||||
from xcli_core.tool_result import ErrorCode
|
||||
from .parser import interpret
|
||||
|
||||
|
||||
class Runner:
|
||||
def __init__(self, engine, manager, history, safety, display):
|
||||
self._engine = engine # EngineAdapter
|
||||
self._mgr = manager # UnifiedToolManager
|
||||
self._history = history # History
|
||||
self._safety = safety # Safety
|
||||
self._display = display # Display
|
||||
self._cancel = threading.Event()
|
||||
self._max_rounds = 50
|
||||
self._prompt_builder = None
|
||||
|
||||
def set_prompt_builder(self, b):
|
||||
self._prompt_builder = b
|
||||
|
||||
def set_engine(self, e):
|
||||
self._engine = e
|
||||
|
||||
def run(self, user_input):
|
||||
self._history.add_user(user_input)
|
||||
self._cancel.clear()
|
||||
return self._loop(user_input)
|
||||
|
||||
def _loop(self, seed):
|
||||
rounds = 0
|
||||
current = seed
|
||||
fc = self._engine.supports_fc()
|
||||
final_text = ""
|
||||
|
||||
while rounds < self._max_rounds:
|
||||
rounds += 1
|
||||
if self._cancel.is_set():
|
||||
self._display.info("(用户取消)")
|
||||
break
|
||||
|
||||
# 系统提示词
|
||||
sp = self._prompt_builder() if self._prompt_builder else ""
|
||||
if not fc:
|
||||
sp = (sp + "\n" + self._mgr.get_tools_prompt()) if sp else self._mgr.get_tools_prompt()
|
||||
|
||||
# 生成
|
||||
if fc:
|
||||
msgs = self._history.render(sp, current, fc=True)
|
||||
tools = self._get_tools()
|
||||
else:
|
||||
msgs = self._history.render(sp, current, fc=False)
|
||||
tools = None
|
||||
|
||||
resp = self._engine.generate(msgs, tools, sp)
|
||||
|
||||
if resp.thinking:
|
||||
self._display.thinking(resp.thinking)
|
||||
|
||||
# 空响应
|
||||
if not resp.text and not resp.tool_calls:
|
||||
break
|
||||
|
||||
# 解释
|
||||
plan = interpret(resp, fc)
|
||||
|
||||
# 显示+历史
|
||||
if plan.text:
|
||||
self._history.add_assistant(plan.text)
|
||||
self._display.text(plan.text)
|
||||
|
||||
if plan.action == "final":
|
||||
final_text = plan.text
|
||||
break
|
||||
if plan.action == "continue":
|
||||
current = None
|
||||
continue
|
||||
if plan.action == "use_tool":
|
||||
results = self._exec_tools(plan.tool_calls, fc)
|
||||
if results is None:
|
||||
break
|
||||
current = None
|
||||
continue
|
||||
else:
|
||||
self._display.info(f"(达到最大轮数{self._max_rounds})")
|
||||
|
||||
return final_text
|
||||
|
||||
def _exec_tools(self, calls, fc):
|
||||
if self._cancel.is_set():
|
||||
for tc in calls:
|
||||
self._history.add_cancel(tc.name, tc.args)
|
||||
return None
|
||||
|
||||
results = []
|
||||
for tc in calls:
|
||||
r = self._exec_one(tc)
|
||||
results.append((tc, r))
|
||||
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if fc:
|
||||
self._history.add_assistant("", tool_calls=calls)
|
||||
for tc, r in results:
|
||||
text = r.data if r.data is not None else (r.error_message or "")
|
||||
self._history.add_tool(tc.id, tc.name, str(text or ""))
|
||||
else:
|
||||
if len(results) == 1:
|
||||
tc, r = results[0]
|
||||
text = r.data if r.data is not None else (r.error_message or "")
|
||||
self._history.add_user(f"工具调用结果 (时间: {now}):\n{text}")
|
||||
else:
|
||||
parts = [f"工具{i+1}结果:\n{r.data if r.data is not None else (r.error_message or '')}"
|
||||
for i, (tc, r) in enumerate(results)]
|
||||
self._history.add_user(f"批量工具结果 (时间: {now}):\n" + "\n".join(parts))
|
||||
return results
|
||||
|
||||
def _exec_one(self, tc):
|
||||
if self._cancel.is_set():
|
||||
return ToolResult.fail("用户取消", code=ErrorCode.NOT_IMPLEMENTED, tool_name=tc.name)
|
||||
allowed, reason = self._safety.check(tc)
|
||||
if not allowed:
|
||||
return ToolResult.fail(f"被拒绝: {reason}", code=ErrorCode.PERMISSION_DENIED, tool_name=tc.name)
|
||||
self._display.tool_start(tc)
|
||||
r = self._mgr.execute(tc.name, tc.args)
|
||||
self._display.tool_result(tc, r)
|
||||
return r
|
||||
|
||||
def _get_tools(self):
|
||||
"""生成FC工具定义"""
|
||||
defs = []
|
||||
for tool in self._mgr.tools:
|
||||
mcp = tool.get("mcp_definition")
|
||||
if mcp:
|
||||
defs.append({"type": "function", "function": {
|
||||
"name": mcp.get("name", tool.get("name", "")),
|
||||
"description": mcp.get("description", tool.get("description", "")),
|
||||
"parameters": mcp.get("inputSchema", {"type": "object", "properties": {}})
|
||||
}})
|
||||
else:
|
||||
defs.append({"type": "function", "function": {
|
||||
"name": tool.get("name", ""),
|
||||
"description": tool.get("description", ""),
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}})
|
||||
return defs
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
"""安全检查"""
|
||||
import json, re, threading
|
||||
from .types import ToolCall
|
||||
|
||||
MODE_UNRESTRICTED = 0
|
||||
MODE_NORMAL = 1
|
||||
MODE_MANUAL = 2
|
||||
MODE_PLAN = 3
|
||||
|
||||
WHITELIST = {
|
||||
"code_editor": ["read_range", "find ", "regex ", "symbols", "imports", "stats",
|
||||
"structure", "context", "todo", "diff "],
|
||||
"git_tools": ["status", "log", "diff", "branch", "remote", "show", "blame"],
|
||||
"file_manager": ["list", "read", "info", "search"],
|
||||
"ai_search": ["url ", "search ", "web "],
|
||||
"network_tools": ["ping", "status", "headers"],
|
||||
"code_search": ["find", "regex", "symbols"],
|
||||
"task_manager": ["list"],
|
||||
"sub_agent": ["list", "status"],
|
||||
"auto_engineer": ["metrics", "security", "complexity", "suggest"],
|
||||
}
|
||||
|
||||
|
||||
class Safety:
|
||||
def __init__(self, cli=None, engine_provider=None):
|
||||
self._cli = cli
|
||||
self._engine = engine_provider or (lambda: None)
|
||||
self._lock = threading.Lock()
|
||||
self._cache = {}
|
||||
try:
|
||||
from xcli_core.config import get_system_config
|
||||
self.mode = get_system_config("safety_mode", MODE_NORMAL)
|
||||
except Exception:
|
||||
self.mode = MODE_NORMAL
|
||||
|
||||
def check(self, tool_call):
|
||||
"""返回 (allowed, reason)"""
|
||||
# 白名单
|
||||
patterns = WHITELIST.get(tool_call.name, [])
|
||||
args_lower = tool_call.args.strip().lower()
|
||||
for p in patterns:
|
||||
if args_lower.startswith(p.lower()):
|
||||
return True, "白名单放行"
|
||||
|
||||
# 无限制/计划模式
|
||||
if self.mode in (MODE_UNRESTRICTED, MODE_PLAN):
|
||||
return True, "模式自动放行"
|
||||
|
||||
# 人工模式
|
||||
if self.mode == MODE_MANUAL:
|
||||
return self._confirm(tool_call)
|
||||
|
||||
# 普通模式:AI分析
|
||||
return self._ai_analyze(tool_call)
|
||||
|
||||
def _ai_analyze(self, tool_call):
|
||||
engine = self._engine()
|
||||
if not engine:
|
||||
return self._confirm(tool_call)
|
||||
|
||||
key = f"{tool_call.name}:{tool_call.args[:100]}"
|
||||
with self._lock:
|
||||
if key in self._cache:
|
||||
cached = self._cache[key]
|
||||
if cached.get("safe"):
|
||||
return True, "AI分析通过(缓存)"
|
||||
return self._confirm(tool_call)
|
||||
|
||||
prompt = f"""分析工具调用的安全性。只返回JSON:
|
||||
{{"safe": true/false, "level": "低/中/高", "reason": "原因"}}
|
||||
|
||||
工具: {tool_call.name}
|
||||
参数: {tool_call.args[:200]}
|
||||
|
||||
safe=true: 读取/查看/无害操作
|
||||
safe=false: 删除/执行命令/修改配置"""
|
||||
|
||||
try:
|
||||
resp = engine.generate_response(prompt, system_prompt="只返回JSON")
|
||||
result = self._parse(resp)
|
||||
if result:
|
||||
with self._lock:
|
||||
self._cache[key] = result
|
||||
if result.get("safe") is True:
|
||||
return True, "AI分析通过"
|
||||
return self._confirm(tool_call)
|
||||
except Exception:
|
||||
pass
|
||||
return self._confirm(tool_call)
|
||||
|
||||
def _parse(self, resp):
|
||||
if not resp:
|
||||
return None
|
||||
resp = resp.strip()
|
||||
# 去thinking
|
||||
if "</thinking>" in resp:
|
||||
resp = resp.split("</thinking>")[-1].strip()
|
||||
# 去markdown
|
||||
if resp.startswith("```"):
|
||||
resp = resp.split("\n", 1)[-1] if "\n" in resp else resp[3:]
|
||||
if resp.endswith("```"):
|
||||
resp = resp[:-3]
|
||||
try:
|
||||
return json.loads(resp.strip())
|
||||
except json.JSONDecodeError:
|
||||
m = re.search(r'\{.*?"safe".*?\}', resp, re.DOTALL)
|
||||
if m:
|
||||
try:
|
||||
return json.loads(m.group())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _confirm(self, tool_call):
|
||||
try:
|
||||
from colorama import Fore, Style
|
||||
except ImportError:
|
||||
Fore = Style = type('D', (), {'RED': '', 'CYAN': '', 'YELLOW': '', 'RESET_ALL': ''})()
|
||||
print(f" {Fore.RED}工具: {tool_call.name}{Style.RESET_ALL}")
|
||||
print(f" {Fore.CYAN}参数: {tool_call.args[:100]}{Style.RESET_ALL}")
|
||||
try:
|
||||
choice = input(f" {Fore.YELLOW}是否执行? (y/n): {Style.RESET_ALL}").strip().lower()
|
||||
allowed = choice in ('y', 'yes', '是')
|
||||
return allowed, "用户确认" if allowed else "用户拒绝"
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return True, "非交互模式自动放行"
|
||||
|
||||
def get_mode(self):
|
||||
return self.mode
|
||||
|
||||
def set_mode(self, mode):
|
||||
self.mode = mode
|
||||
try:
|
||||
from xcli_core.config import set_system_config
|
||||
set_system_config("safety_mode", mode)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,37 @@
|
||||
"""核心数据类型"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
from xcli_core.tool_result import ToolResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCall:
|
||||
id: str
|
||||
name: str
|
||||
args: str
|
||||
arguments: Optional[dict] = None
|
||||
raw: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EngineResponse:
|
||||
text: str = ""
|
||||
thinking: str = ""
|
||||
tool_calls: Optional[list] = None
|
||||
raw: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Plan:
|
||||
action: str = "final" # final | continue | use_tool
|
||||
text: str = ""
|
||||
tool_calls: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
role: str # system | user | assistant | tool
|
||||
content: str
|
||||
tool_call_id: Optional[str] = None
|
||||
tool_calls: Optional[list] = None
|
||||
name: Optional[str] = None
|
||||
Reference in new issue
Block a user