From 4d341a7b55d68b66c76ceb1f39b85045ff6b6ec6 Mon Sep 17 00:00:00 2001 From: xiaoli-cli Date: Sat, 25 Apr 2026 21:13:14 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=90=88=E5=B9=B6v2=E5=88=B0v1?= =?UTF-8?q?=EF=BC=8C=E5=88=A0=E9=99=A4xiaoli/core=E5=8C=85=EF=BC=8C?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用v2的GitHub暗色主题TUI替换v1简陋TUI(侧栏、代码高亮、快捷键) - 用v2的子进程隔离沙箱替换v1的线程内exec(6层安全) - 删除xiaoli/包(v2重构版,7个模块+子包) - 删除core/包(从未被引用的死代码) - 保留v1全部功能:Clawli远程、WebSocket、Web服务器等 - 代码减少约3000行,架构更简洁 --- ai_cli.py | 1064 +++++++++++++++++++------------ core/__init__.py | 42 -- core/conversation.py | 315 --------- core/display.py | 131 ---- core/engines.py | 115 ---- core/sandbox.py | 210 ------ xiaoli/__init__.py | 3 - xiaoli/__main__.py | 94 --- xiaoli/app.py | 618 ------------------ xiaoli/config.py | 70 -- xiaoli/conversation/__init__.py | 180 ------ xiaoli/conversation/history.py | 86 --- xiaoli/display/__init__.py | 110 ---- xiaoli/engines.py | 93 --- xiaoli/prompt.py | 69 -- xiaoli/sandbox.py | 540 ---------------- xiaoli/tui.py | 657 ------------------- 17 files changed, 666 insertions(+), 3731 deletions(-) delete mode 100644 core/__init__.py delete mode 100644 core/conversation.py delete mode 100644 core/display.py delete mode 100644 core/engines.py delete mode 100644 core/sandbox.py delete mode 100644 xiaoli/__init__.py delete mode 100644 xiaoli/__main__.py delete mode 100644 xiaoli/app.py delete mode 100644 xiaoli/config.py delete mode 100644 xiaoli/conversation/__init__.py delete mode 100644 xiaoli/conversation/history.py delete mode 100644 xiaoli/display/__init__.py delete mode 100644 xiaoli/engines.py delete mode 100644 xiaoli/prompt.py delete mode 100644 xiaoli/sandbox.py delete mode 100644 xiaoli/tui.py diff --git a/ai_cli.py b/ai_cli.py index 2fd760e..ad59396 100644 --- a/ai_cli.py +++ b/ai_cli.py @@ -90,187 +90,625 @@ except ImportError: print(f"{Fore.YELLOW}Textual 未安装,TUI 模式不可用。请运行: pip install textual{Style.RESET_ALL}") -if TEXTUAL_AVAILABLE: - class TUIBridge: - """桥接类:让 TUI 能够调用同步的 AICLI 方法""" +# ── 安全代码沙箱 v2(子进程隔离) ── +import ast as _ast +import textwrap as _textwrap +import tempfile as _tempfile +import resource as _resource_mod +_HAS_RESOURCE = hasattr(_resource_mod, 'setrlimit') + +_BLOCKED_MODULES = 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 = 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 = [ + 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', +] +_SAFE_WHITELIST = [ + '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', +] + +class _SandboxASTChecker(_ast.NodeVisitor): + """AST 级安全分析""" + def __init__(self): + self.violations = [] + def _check(self, name, node): + if name in _BLOCKED_NAMES: + self.violations.append(f"blocked name: {name} (line {getattr(node,'lineno',0)})") + if name.split('.')[0] in _BLOCKED_MODULES: + self.violations.append(f"blocked module: {name} (line {getattr(node,'lineno',0)})") + def visit_Import(self, node): + for alias in node.names: + self._check(alias.name, node) + self.generic_visit(node) + def visit_ImportFrom(self, node): + if node.module: + self._check(node.module, node) + self.generic_visit(node) + def visit_Call(self, node): + if isinstance(node.func, _ast.Name): + self._check(node.func.id, node) + elif isinstance(node.func, _ast.Attribute): + if isinstance(node.func.value, _ast.Name): + if node.func.value.id in _BLOCKED_NAMES: + self.violations.append(f"blocked call: {node.func.value.id}.{node.func.attr} (line {getattr(node,'lineno',0)})") + self.generic_visit(node) + def visit_Name(self, node): + self._check(node.id, node) + self.generic_visit(node) + def visit_Attribute(self, node): + if node.attr.startswith('__') and node.attr.endswith('__'): + _SAFE_DUNDERS = ('__init__','__str__','__repr__','__len__','__contains__', + '__iter__','__eq__','__ne__','__lt__','__gt__','__le__', + '__ge__','__hash__','__bool__','__add__','__sub__', + '__mul__','__truediv__','__floordiv__','__mod__','__pow__', + '__enter__','__exit__') + if node.attr not in _SAFE_DUNDERS: + self.violations.append(f"blocked dunder: {node.attr} (line {getattr(node,'lineno',0)})") + 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): + self.violations = [] + for p in _BLOCKED_AST_PATTERNS: + if re.search(p, code, re.IGNORECASE): + self.violations.append(f"regex match: {p}") + 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_sandbox_worker(code, variables_json, whitelist_json, work_dir): + """生成子进程隔离脚本""" + return _textwrap.dedent(f'''\ +import sys, os, json, io, traceback +from contextlib import redirect_stdout, redirect_stderr +os.chdir({work_dir!r}) + +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 = _FakeSocketModule() +sys.modules['socket'] = _fake +sys.modules['_socket'] = _fake + +WHITELIST = json.loads({whitelist_json!r}) +safe_modules = {{}} +for m in WHITELIST: + try: safe_modules[m] = __import__(m) + 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)) +''') + +_sandbox_work_dir = _tempfile.mkdtemp(prefix='xiaoli_sandbox_') +_sandbox_checker = _SandboxASTChecker() + + +if TEXTUAL_AVAILABLE: + from textual.containers import Horizontal, Vertical, VerticalScroll, Container + from textual.widgets import ( + Header, Footer, Input, Static, Rule, Label, + DataTable, ProgressBar, TabbedContent, TabPane + ) + 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.box import ROUNDED, HEAVY, DOUBLE + + # ── TUI 主题色 ── + 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" + + _TUI_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 {{ background: {_Theme.BG}; }} + Tab.-active {{ background: {_Theme.BG_LIGHT}; }} + TabbedContent > Tabs {{ background: {_Theme.BG}; }} + """ + + class _TUIBridge: + """桥接 TUI ↔ AICLI""" def __init__(self, cli): self.cli = cli - self._executor = ThreadPoolExecutor(max_workers=4) - - def get_engines(self) -> dict: - return self.cli.engines - - def get_current_engine_name(self) -> str: + def engines(self) -> list: + return list(self.cli.engines.keys()) + def current_engine(self) -> str: return self.cli.get_current_engine_name() - - def get_tools(self) -> list: + def tools(self) -> list: return self.cli.liugin_manager.tools - - def get_conversation_history(self) -> list: + def history(self) -> list: return self.cli.shared_conversation_history - - def switch_engine(self, engine_name: str) -> bool: - if engine_name in self.cli.engines: - self.cli.switch_engine(engine_name) + def switch_engine(self, name: str) -> bool: + if name in self.cli.engines: + self.cli.switch_engine(name) return True return False - - class TUIMainApp(App): - """TUI 主应用""" - - CSS = """ - Screen { background: $surface; } - #chat-log { height: 1fr; } - #user-input { height: 3; } - #right-panel { width: 30; } - #status-bar { height: 1; dock: bottom; } - """ + class XiaoliTUI(App): + """小狸 TUI v2 - GitHub 暗色主题""" + CSS = _TUI_CSS + TITLE = "🐱 小狸 Pro-CLI" + SUB_TITLE = "智能编程助手" BINDINGS = [ - Binding("ctrl+c", "quit", "退出"), - Binding("ctrl+t", "switch_to_cli", "切换CLI"), + 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("escape", "cancel", "取消", show=False), ] + sidebar_visible = var(True) + is_generating = var(False) + def __init__(self, cli): super().__init__() self.cli = cli - self.bridge = TUIBridge(cli) - self.is_generating = False + self.bridge = _TUIBridge(cli) def compose(self) -> ComposeResult: - with Horizontal(): - with Vertical(id="chat-area"): - yield RichLog(id="chat-log", wrap=True, max_lines=10000) - yield Input(placeholder="输入消息后按 Enter 发送...", id="user-input") - with Vertical(id="right-panel"): - yield Static("[b]AI 引擎[/b]") - yield Tree("引擎列表", id="engine-status") - yield Static("[b]可用工具[/b]") - yield RichLog(id="tools-log", wrap=True, max_lines=100) - yield Static("", id="status-bar") + 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(" 就绪 | Ctrl+C 退出", id="status-bar") - def on_mount(self) -> None: - self.title = "小狸 CLI - TUI" - self._update_engine_tree() - self._update_tools_list() - chat_log = self.query_one("#chat-log", RichLog) - chat_log.write(f"[bold cyan]欢迎使用小狸 CLI TUI 模式![/bold cyan]") - chat_log.write(f"[dim]当前引擎: {self.bridge.get_current_engine_name()}[/dim]") - chat_log.write(f"[dim]输入 /cli 切换到 CLI 模式[/dim]") - chat_log.write("") - self.query_one("#user-input", Input).focus() - self.update_status() + def on_mount(self): + self._render_welcome() + self._update_sidebar() + self.query_one("#user-input").focus() - def _update_engine_tree(self) -> None: - tree = self.query_one("#engine-status", Tree) - tree.reset("引擎列表") - engines = self.bridge.get_engines() - current = self.bridge.get_current_engine_name() + 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)) - for name in engines: - node = tree.root.add(f"* {name}") + 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: - node.add("(当前)") - tree.root.expand() - - def _update_tools_list(self) -> None: - tools_log = self.query_one("#tools-log", RichLog) - tools_log.clear() - tools = self.bridge.get_tools() - if tools: - for tool in tools[:15]: - name = tool.get('name', '未知') - desc = tool.get('description', '')[:35] - tools_log.write(f"- {name}: {desc}") - else: - tools_log.write("暂无工具") - - def update_status(self) -> None: - status = self.query_one("#status-bar", Static) - engine = self.bridge.get_current_engine_name() - history_len = len(self.bridge.get_conversation_history()) - status.update(f"引擎: {engine} | {history_len} 条对话 | Ctrl+T: CLI | /cli: 切换模式") - - def on_input_submitted(self, event: Input.Submitted) -> None: - user_input = event.value.strip() - if not user_input: - return - - if user_input == '/cli': - self.action_switch_to_cli() - return - - if user_input.startswith('/engine switch '): - engine_name = user_input[14:].strip() - if self.bridge.switch_engine(engine_name): - chat_log = self.query_one("#chat-log", RichLog) - chat_log.write(f"[green]已切换到引擎: {engine_name}[/]") - self._update_engine_tree() - self.update_status() + engine_container.mount(Static(f" ▸ {name}", classes="engine-item-active")) else: - chat_log = self.query_one("#chat-log", RichLog) - chat_log.write(f"[red]切换失败,引擎 '{engine_name}' 不存在[/]") + 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 == 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 - chat_log = self.query_one("#chat-log", RichLog) - chat_log.write(f"[cyan]你:[/] {user_input}") - chat_log.write("") - event.input.value = "" - chat_log.write("AI 正在思考...") - self.run_worker(self._generate_response_async(user_input)) + self._user_msg(text) + self._thinking() + self.is_generating = True + self._update_status("🔄 思考中...") + self.run_worker(self._generate(text), exclusive=True) - async def _generate_response_async(self, user_input: str) -> None: - """异步生成响应 - 通过回调输出到 TUI""" + 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(), + 'tui': lambda: self._system("已在 TUI 模式中"), + } + + handler = cmds.get(name) + if handler: + handler() + else: + # 尝试插件命令 + if name in self.cli.liugin_commands: + try: + result = self.cli.liugin_commands[name](args) + if result: + self._system(result[:500]) + except Exception as e: + self._error(f"插件命令失败: {e}") + 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.cli.tui_output_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._clear_thinking) + self.call_after_refresh(self._remove_thinking) - def safe_output(message): - self.call_after_refresh(self._write_chat, message) + def tui_output(msg): + self.call_after_refresh(self._write_raw, msg) - original_callback = self.cli.tui_output_callback - self.cli.tui_output_callback = safe_output + original = self.cli.tui_output_callback + self.cli.tui_output_callback = tui_output try: loop = asyncio.get_event_loop() - await loop.run_in_executor(None, self.cli.process_conversation, user_input) + await loop.run_in_executor( + None, self.cli.process_conversation, user_input + ) finally: - self.cli.tui_output_callback = original_callback + self.cli.tui_output_callback = original - self.call_after_refresh(self.update_status) except Exception as e: - import traceback - self.call_after_refresh(self._write_chat, f"[red]错误: {str(e)}[/]") - self.call_after_refresh(self._write_chat, f"[dim]{traceback.format_exc()}[/]") + 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 _write_chat(self, message: str) -> None: - try: - chat_log = self.query_one("#chat-log", RichLog) - chat_log.write(message) - except Exception as e: - print(f"写入聊天失败: {e}") + 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 _clear_thinking(self) -> None: - try: - chat_log = self.query_one("#chat-log", RichLog) - children = list(chat_log.children) - if children and "正在思考" in str(children[-1]): - chat_log.remove_child(children[-1]) - except: - pass + 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.cli.shared_conversation_history.clear() + self.action_clear() + self._system("已开始新对话") + + def action_toggle_sidebar(self): + sidebar = self.query_one("#sidebar") + sidebar.visible = not sidebar.visible + + def action_cancel(self): + if self.is_generating: + self._system("已取消") + self.is_generating = False - def action_switch_to_cli(self) -> None: - import subprocess - import sys - self.exit() - subprocess.Popen([sys.executable, __file__]) - os._exit(0) else: # Textual 不可用时的占位类 - class TUIMainApp: + class XiaoliTUI: pass @@ -1615,274 +2053,104 @@ class AICLI: } def _is_code_safe(self, code): - """ - 检查代码是否安全(使用 AST 语法分析) - - Args: - code: 要检查的Python代码 - - Returns: - bool: 代码是否安全 - """ - import ast - import re - - # 危险模块黑名单 - 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__', - } - - # 危险属性名称模式 - DANGEROUS_ATTRS = { - '__builtins__', '__class__', '__bases__', '__subclasses__', - '__mro__', '__dict__', '__globals__', '__code__', '__closure__', - '__name__', '__file__', '__package__', '__loader__', - } - - # 首先进行快速字符串检查 - dangerous_string_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_string_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 - self.violations = [] - - def check_name(self, name, context=""): - if name in DANGEROUS_FUNCTIONS or name in DANGEROUS_ATTRS: - self.is_safe = False - self.violations.append(f"危险名称: {name} ({context})") - - def visit_Import(self, node): - for alias in node.names: - module_name = alias.name.split('.')[0] - if module_name in DANGEROUS_MODULES: - self.is_safe = False - self.violations.append(f"危险导入: {alias.name}") - self.generic_visit(node) - - def visit_ImportFrom(self, node): - if node.module: - module_name = node.module.split('.')[0] - if module_name in DANGEROUS_MODULES: - self.is_safe = False - self.violations.append(f"危险导入: from {node.module}") - 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) - - if not visitor.is_safe: - logger.warning(f"代码安全检查失败: {visitor.violations}") - - return visitor.is_safe + """检查代码是否安全(使用 v2 AST+正则 沙箱检查器)""" + safe, violations = _sandbox_checker.check(code) + if not safe: + logger.warning(f"代码安全检查失败: {violations}") + return safe def _execute_code_safely(self, code, timeout, variables=None): """ - 在安全沙箱中执行代码 - - Args: - code: 要执行的Python代码 - timeout: 超时时间(秒) - variables: 要传递的变量字典 - - Returns: - 执行结果字典 + 在安全沙箱中执行代码(v2 子进程隔离) + 6层安全: AST分析 → 子进程隔离 → 文件系统限制 → 网络阻断 → 模块白名单 → 输出截断 """ - import io - import traceback - from contextlib import redirect_stdout, redirect_stderr - - # 准备变量 - 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, - 'hasattr': hasattr, + import subprocess + import json as _json + + # 安全检查 + safe, violations = _sandbox_checker.check(code) + if not safe: + return { + 'success': False, + 'result': '代码包含潜在危险操作,已阻止执行', + 'stdout': '', 'stderr': '; '.join(violations[:5]), + 'execution_time': 0, 'memory_usage': 0 + } + + vars_json = _json.dumps(variables or {}, ensure_ascii=False, default=str) + whitelist_json = _json.dumps(self.code_execution_whitelist or _SAFE_WHITELIST) + script = _make_sandbox_worker(code, vars_json, whitelist_json, _sandbox_work_dir) + + safe_env = { + 'PATH': '/usr/bin:/bin', + 'LANG': 'en_US.UTF-8', 'LC_ALL': 'en_US.UTF-8', + 'HOME': _sandbox_work_dir, + 'TMPDIR': _sandbox_work_dir, 'TEMP': _sandbox_work_dir, 'TMP': _sandbox_work_dir, + 'PYTHONNOUSERSITE': '1', 'PYTHONDONTWRITEBYTECODE': '1', } - - # 添加用户输入确认函数(仅在安全沙箱中允许) - def ask_user_confirmation(prompt, operation_id): - """ - 在代码执行中请求用户确认 - - 参数: - prompt: 提示信息 - operation_id: 操作ID - - 返回: - 用户输入的确认信息 - """ - print(f"\n{Fore.YELLOW}{'='*60}{Style.RESET_ALL}") - print(f"{Fore.YELLOW}⚠️ 需要用户确认{Style.RESET_ALL}") - print(f"{Fore.YELLOW}{'='*60}{Style.RESET_ALL}\n") - print(f"{Fore.CYAN}{prompt}{Style.RESET_ALL}\n") - print(f"{Fore.GREEN}操作ID: {operation_id}{Style.RESET_ALL}\n") - print(f"{Fore.YELLOW}请输入您的选择(y/yes 允许,其他拒绝):{Style.RESET_ALL} ", end='', flush=True) - - # 获取用户输入 - user_input = input().strip().lower() - - # 存储用户决定 - self.user_input_queue[operation_id] = user_input - - print(f"\n{Fore.YELLOW}{'='*60}{Style.RESET_ALL}\n") - - return user_input - - safe_builtins['ask_user_confirmation'] = ask_user_confirmation - - # 添加白名单模块 - for module_name in self.code_execution_whitelist: - try: - safe_builtins[module_name] = __import__(module_name) - except ImportError: - pass - - exec_vars.update(safe_builtins) - - # 创建输出捕获器 - stdout_capture = io.StringIO() - stderr_capture = io.StringIO() - - # 执行代码 + for key in ('TERM', 'COLUMNS', 'LINES', 'SHELL'): + val = os.environ.get(key) + if val: + safe_env[key] = val + start_time = time.time() try: - with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): - # 使用超时执行 - result = {'success': True, 'error': None} - - def execute_with_timeout(): - try: - exec(code, exec_vars) - except Exception as e: - result['success'] = False - result['error'] = str(e) - result['traceback'] = traceback.format_exc() - - # 创建超时线程 - import threading - exec_thread = threading.Thread(target=execute_with_timeout) - exec_thread.daemon = True - exec_thread.start() - exec_thread.join(timeout=timeout) - - if exec_thread.is_alive(): - return { - 'success': False, - 'result': f'代码执行超时(超过{timeout}秒)', - 'stdout': stdout_capture.getvalue(), - 'stderr': stderr_capture.getvalue(), - 'execution_time': timeout, - 'memory_usage': 0 - } - - # 获取执行结果 - if result['success']: - # 尝试获取代码中的 'result' 变量 - exec_result = exec_vars.get('result', None) - if exec_result is not None: - result_str = str(exec_result) - else: - result_str = '代码执行成功(无返回值)' - - return { - 'success': True, - 'result': result_str, - 'stdout': stdout_capture.getvalue(), - 'stderr': stderr_capture.getvalue(), - 'execution_time': time.time() - start_time, - 'memory_usage': 0 # 简化版暂不跟踪内存 - } - else: - return { - 'success': False, - 'result': f"代码执行错误: {result['error']}", - 'stdout': stdout_capture.getvalue(), - 'stderr': stderr_capture.getvalue() + '\n' + result['traceback'], - 'execution_time': time.time() - start_time, - 'memory_usage': 0 - } - + proc = subprocess.run( + [sys.executable, '-c', script], + capture_output=True, timeout=timeout, + cwd=_sandbox_work_dir, env=safe_env, + preexec_fn=self._sandbox_set_limits if _HAS_RESOURCE else None, + ) + elapsed = time.time() - start_time + stdout = proc.stdout.decode('utf-8', errors='replace')[:1000000] + stderr = proc.stderr.decode('utf-8', errors='replace')[:1000000] + + try: + data = _json.loads(stdout) + return { + 'success': data.get('success', False), + 'result': data.get('result', ''), + 'stdout': data.get('stdout', ''), + 'stderr': data.get('stderr', ''), + 'execution_time': elapsed, + 'memory_usage': 0 + } + except _json.JSONDecodeError: + return { + 'success': proc.returncode == 0, + 'result': stdout[:2000] if stdout else 'no output', + 'stdout': stdout, 'stderr': stderr, + 'execution_time': elapsed, 'memory_usage': 0 + } + + except subprocess.TimeoutExpired: + return { + 'success': False, + 'result': f'代码执行超时(超过{timeout}秒)', + 'stdout': '', 'stderr': '', + 'execution_time': timeout, 'memory_usage': 0 + } except Exception as e: return { 'success': False, - 'result': f"代码执行异常: {str(e)}", - 'stdout': stdout_capture.getvalue(), - 'stderr': stderr_capture.getvalue() + '\n' + traceback.format_exc(), - 'execution_time': time.time() - start_time, - 'memory_usage': 0 + 'result': f'代码执行异常: {str(e)}', + 'stdout': '', 'stderr': traceback.format_exc(), + 'execution_time': time.time() - start_time, 'memory_usage': 0 } + + @staticmethod + def _sandbox_set_limits(): + """子进程资源限制""" + if not _HAS_RESOURCE: + return + try: + mem = 256 * 1024 * 1024 # 256MB + _resource_mod.setrlimit(_resource_mod.RLIMIT_AS, (mem, mem)) + _resource_mod.setrlimit(_resource_mod.RLIMIT_CPU, (30, 35)) + _resource_mod.setrlimit(_resource_mod.RLIMIT_NPROC, (0, 0)) + _resource_mod.setrlimit(_resource_mod.RLIMIT_FSIZE, (50*1024*1024, 50*1024*1024)) + except (ValueError, OSError): + pass def get_code_execution_history(self, limit=10): """ @@ -2358,9 +2626,9 @@ class AICLI: """运行 TUI 模式""" if not TEXTUAL_AVAILABLE: print(f"{Fore.RED}TUI 模式不可用:Textual 库未安装{Style.RESET_ALL}") - print(f"{Fore.YELLOW}请运行: pip install textual{Style.RESET_ALL}") + print(f"{Fore.YELLOW}请运行: pip install textual rich{Style.RESET_ALL}") return - app = TUIMainApp(self) + app = XiaoliTUI(self) app.run() def run(self): diff --git a/core/__init__.py b/core/__init__.py deleted file mode 100644 index deaea8f..0000000 --- a/core/__init__.py +++ /dev/null @@ -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) diff --git a/core/conversation.py b/core/conversation.py deleted file mode 100644 index b244f1f..0000000 --- a/core/conversation.py +++ /dev/null @@ -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): - """处理深度思考标记""" - # 格式 - if '' in response: - parts = response.split('', 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 "" - - # ... 格式 - 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正在思考中...", "请稍等片刻...", "正在处理您的请求..."] diff --git a/core/display.py b/core/display.py deleted file mode 100644 index 5387b30..0000000 --- a/core/display.py +++ /dev/null @@ -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}") diff --git a/core/engines.py b/core/engines.py deleted file mode 100644 index cd81873..0000000 --- a/core/engines.py +++ /dev/null @@ -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) diff --git a/core/sandbox.py b/core/sandbox.py deleted file mode 100644 index 1cea068..0000000 --- a/core/sandbox.py +++ /dev/null @@ -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 = [] diff --git a/xiaoli/__init__.py b/xiaoli/__init__.py deleted file mode 100644 index 8030826..0000000 --- a/xiaoli/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""小狸 Pro-CLI - 智能编程助手""" -__version__ = "5.0.0" -__author__ = "shuiyu1123" diff --git a/xiaoli/__main__.py b/xiaoli/__main__.py deleted file mode 100644 index bcfcb25..0000000 --- a/xiaoli/__main__.py +++ /dev/null @@ -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() diff --git a/xiaoli/app.py b/xiaoli/app.py deleted file mode 100644 index 24068bc..0000000 --- a/xiaoli/app.py +++ /dev/null @@ -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("资源清理完成") diff --git a/xiaoli/config.py b/xiaoli/config.py deleted file mode 100644 index 55a3c7e..0000000 --- a/xiaoli/config.py +++ /dev/null @@ -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() diff --git a/xiaoli/conversation/__init__.py b/xiaoli/conversation/__init__.py deleted file mode 100644 index 8b55792..0000000 --- a/xiaoli/conversation/__init__.py +++ /dev/null @@ -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: 格式修复 - if response.startswith(""): - 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: - """处理深度思考标记,返回纯回复内容""" - # 格式 - if '' in response: - parts = response.split('', 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 diff --git a/xiaoli/conversation/history.py b/xiaoli/conversation/history.py deleted file mode 100644 index 2568da2..0000000 --- a/xiaoli/conversation/history.py +++ /dev/null @@ -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}") diff --git a/xiaoli/display/__init__.py b/xiaoli/display/__init__.py deleted file mode 100644 index 4f4c277..0000000 --- a/xiaoli/display/__init__.py +++ /dev/null @@ -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)}行)" diff --git a/xiaoli/engines.py b/xiaoli/engines.py deleted file mode 100644 index 9ba61fe..0000000 --- a/xiaoli/engines.py +++ /dev/null @@ -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) diff --git a/xiaoli/prompt.py b/xiaoli/prompt.py deleted file mode 100644 index 34dd810..0000000 --- a/xiaoli/prompt.py +++ /dev/null @@ -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 diff --git a/xiaoli/sandbox.py b/xiaoli/sandbox.py deleted file mode 100644 index 8c75e34..0000000 --- a/xiaoli/sandbox.py +++ /dev/null @@ -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() diff --git a/xiaoli/tui.py b/xiaoli/tui.py deleted file mode 100644 index 06b9516..0000000 --- a/xiaoli/tui.py +++ /dev/null @@ -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()