diff --git a/ai_cli.py b/ai_cli.py index 7a202ab..4ef074c 100644 --- a/ai_cli.py +++ b/ai_cli.py @@ -1,4130 +1,11 @@ -# 常量定义 -WEB_SERVER_DEFAULT_PORT = 8080 -MAX_FILE_SIZE = 52428800 # 50MB -MAX_VIDEO_DURATION = 10000 # 10秒 -VIDEO_FRAME_DELAY = 0.04 # 40毫秒 -LOVE_FILE_PATH = "love.txt" -DEFAULT_MAX_HISTORY = 999999 # 无限制对话历史 -import os -import sys -import importlib.util -import json -import re -import requests -import shutil -import time -import threading -from concurrent.futures import ThreadPoolExecutor, as_completed -from openai import OpenAI -from colorama import init, Fore, Style -# tkinter 仅在视频播放功能中使用,服务器环境可能不可用 -try: - import tkinter as tk - from tkinter import ttk - TKINTER_AVAILABLE = True -except ImportError: - TKINTER_AVAILABLE = False - -# cv2 仅在视频播放功能中使用 -try: - import cv2 - CV2_AVAILABLE = True -except ImportError: - CV2_AVAILABLE = False - -import numpy as np -from http.server import HTTPServer, BaseHTTPRequestHandler -from urllib.parse import urlparse, parse_qs -import socket -import webbrowser -from datetime import datetime -import logging -# 图像显示相关(可选) -try: - from PIL import Image - PIL_AVAILABLE = True -except ImportError: - PIL_AVAILABLE = False - -try: - from ascii_magic import AsciiArt - ASCII_MAGIC_AVAILABLE = True -except ImportError: - ASCII_MAGIC_AVAILABLE = False -# WebSocket 服务端 -try: - import websocket_server - WEBSOCKET_AVAILABLE = True -except ImportError: - WEBSOCKET_AVAILABLE = False - -# Clawli 独立进程服务器 -try: - import clawli_server - CLAWLI_SERVER_AVAILABLE = True -except ImportError: - CLAWLI_SERVER_AVAILABLE = False - -# 统一工具管理器 - 支持 Liugin 和 Skill 双协议 -try: - from unified_tool_manager import UnifiedToolManager - UNIFIED_TOOL_MANAGER_AVAILABLE = True -except ImportError: - UNIFIED_TOOL_MANAGER_AVAILABLE = False - -# Textual TUI 支持 -import asyncio -from typing import Optional -from io import StringIO - -try: - from textual.app import App, ComposeResult - from textual.containers import Container, Horizontal, Vertical, VerticalScroll - from textual.widgets import Header, Footer, Input, RichLog, Static, Button, Tree - from textual.widgets.tree import TreeNode - from textual.binding import Binding - from textual.events import Mount - TEXTUAL_AVAILABLE = True -except ImportError: - TEXTUAL_AVAILABLE = False - print(f"{Fore.YELLOW}Textual 未安装,TUI 模式不可用。请运行: pip install textual{Style.RESET_ALL}") - - -# ── 安全代码沙箱 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 - def engines(self) -> list: - return list(self.cli.engines.keys()) - def current_engine(self) -> str: - return self.cli.get_current_engine_name() - def tools(self) -> list: - return self.cli.liugin_manager.tools - def history(self) -> list: - return self.cli.shared_conversation_history - def switch_engine(self, name: str) -> bool: - if name in self.cli.engines: - self.cli.switch_engine(name) - return True - return False - - class XiaoliTUI(App): - """小狸 TUI v2 - GitHub 暗色主题""" - CSS = _TUI_CSS - TITLE = "🐱 小狸 Pro-CLI" - SUB_TITLE = "智能编程助手" - - 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("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) - - 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(" 就绪 | 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 == 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(), - '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._remove_thinking) - - def tui_output(msg): - self.call_after_refresh(self._write_raw, msg) - - 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 - ) - finally: - self.cli.tui_output_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.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 - -else: - # Textual 不可用时的占位类 - class XiaoliTUI: - pass - - -# 配置文件路径 -CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") - -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: - print(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) -# 配置日志 -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler('xiaoli_cli.log', encoding='utf-8'), - logging.StreamHandler() # 同时输出到控制台 - ] -) -logger = logging.getLogger(__name__) - -class LiuginManager: - """插件管理器""" - - def __init__(self, liugins_dir="plugins"): - self.liugins_dir = liugins_dir - self.tools = [] - - def get_enabled_liugins(self): - """获取启用的插件列表""" - # 检查环境变量中是否有启用的插件列表 - enabled_liugins_env = os.environ.get("XIAOLI_ENABLED_PLUGINS") - if enabled_liugins_env is not None: - # 特殊处理:空字符串表示不启用任何插件 - if enabled_liugins_env == "": - return [] - return enabled_liugins_env.split(",") - # 检查是否有插件配置文件 - project_dir = os.path.dirname(os.path.abspath(__file__)) - config_file = os.path.join(project_dir, "plugins_config.py") - if os.path.exists(config_file): - try: - # 读取配置文件 - with open(config_file, 'r', encoding='utf-8') as f: - config_content = f.read() - # 检查是否明确表示不启用任何插件 - if "# 不启用任何插件" in config_content: - return [] - # 解析启用的插件 - enabled_liugins = [] - for line in config_content.split('\n'): - if line.startswith('ENABLE_') and '= True' in line: - liugin_name = line.split('=')[0].replace('ENABLE_', '').strip().lower() - enabled_liugins.append(liugin_name) - return enabled_liugins - except (FileNotFoundError, PermissionError, UnicodeDecodeError) as e: - logger.error(f"读取插件配置文件失败: {e}") - print(f"{Fore.RED}读取插件配置文件失败: {e}{Style.RESET_ALL}") - except Exception as e: - logger.error(f"读取插件配置时发生未知错误: {e}") - print(f"{Fore.RED}读取插件配置文件失败: {e}{Style.RESET_ALL}") - # 如果没有配置,则返回None表示加载所有插件 - return None - - def load_liugins(self): - """加载liugin""" - self.tools = [] - # 获取启用的插件列表 - enabled_liugins = self.get_enabled_liugins() - # 加载工具插件 - if os.path.exists(self.liugins_dir): - for filename in os.listdir(self.liugins_dir): - if filename.endswith('.py') and filename != '__init__.py': - liugin_name = filename[:-3] # 移除.py扩展名 - # 如果有启用的插件列表,检查当前插件是否在其中 - if enabled_liugins is not None and liugin_name.lower() not in enabled_liugins: - print(f"{Fore.YELLOW}跳过liugin: {liugin_name} (未启用){Style.RESET_ALL}") - continue - plugin_path = os.path.join(self.liugins_dir, filename) - try: - spec = importlib.util.spec_from_file_location(liugin_name, plugin_path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - # 获取插件信息 - 兼容 Liugin 和 Plugin 两种类名 - plugin_class = getattr(module, 'Liugin', None) or getattr(module, 'Plugin', None) - if plugin_class: - plugin_instance = plugin_class() - tool_info = plugin_instance.get_tool_info() - tool_info['handler'] = plugin_instance.handle - self.tools.append(tool_info) - print(f"{Fore.GREEN}已加载liugin: {liugin_name}{Style.RESET_ALL}") - except (ImportError, AttributeError, TypeError) as e: - logger.error(f"加载liugin失败 {filename}: {e}") - print(f"{Fore.RED}加载liugin失败 {filename}: {e}{Style.RESET_ALL}") - except FileNotFoundError as e: - logger.error(f"liugin文件未找到 {filename}: {e}") - print(f"{Fore.RED}liugin文件未找到 {filename}: {e}{Style.RESET_ALL}") - except Exception as e: - logger.error(f"加载liugin时发生未知错误 {filename}: {e}") - print(f"{Fore.RED}加载liugin失败 {filename}: {e}{Style.RESET_ALL}") - print(f"{Fore.GREEN}总共加载了 {len(self.tools)} 个工具插件{Style.RESET_ALL}") - - def search_tools(self, query, limit=5): - """ - 搜索工具,支持关键词匹配 - - 参数: - query: 搜索关键词 - limit: 返回结果数量限制 - - 返回: - 匹配的工具列表,每个工具包含 name、description、usage - """ - if not self.tools: - return [] - - query_lower = query.lower() - matches = [] - - for tool in self.tools: - # 计算匹配分数 - score = 0 - tool_name = tool.get('name', '').lower() - tool_desc = tool.get('description', '').lower() - tool_keywords = [kw.lower() for kw in tool.get('keywords', [])] - - # 名称完全匹配 - if query_lower == tool_name: - score += 100 - # 名称包含查询词 - if query_lower in tool_name: - score += 50 - # 描述包含查询词 - if query_lower in tool_desc: - score += 30 - # 关键词匹配 - for keyword in tool_keywords: - if query_lower in keyword: - score += 20 - break - - if score > 0: - matches.append({ - 'score': score, - 'name': tool.get('name', ''), - 'description': tool.get('description', ''), - 'usage': tool.get('usage', ''), - 'keywords': tool.get('keywords', []) - }) - - # 按分数排序并返回前 limit 个 - matches.sort(key=lambda x: x['score'], reverse=True) - return matches[:limit] - - def get_tool_by_name(self, tool_name): - """根据名称获取工具""" - for tool in self.tools: - if tool.get('name', '').lower() == tool_name.lower(): - return tool - return None - - -class AICLI: - """AI CLI主程序""" - - def __init__(self): - # 初始化colorama - init(autoreset=True) - - # 确保插件管理器使用正确的路径 - project_dir = os.path.dirname(os.path.abspath(__file__)) - liugins_dir = os.path.join(project_dir, "plugins") - skills_dir = os.path.join(project_dir, "skills") - - # 使用统一工具管理器(支持 Liugin 和 Skill 双协议) - if UNIFIED_TOOL_MANAGER_AVAILABLE: - self.liugin_manager = UnifiedToolManager(self) - self._use_unified_manager = True - else: - self.liugin_manager = LiuginManager(liugins_dir) - self._use_unified_manager = False - - # 初始化引擎字典(先初始化为空字典) - self.engines = {} - - # 初始化插件命令注册表 - self.liugin_commands = {} - - # 初始化系统引擎命令注册表 - self.engine_commands = {} - # 注册默认的引擎命令 - self._register_engine_commands() - # 动态加载AI引擎插件 - self.load_ai_engines() - # 从配置文件获取默认引擎设置 - default_engine_name = self._get_default_engine_name() - # 设置默认引擎,优先使用配置文件指定的引擎 - if default_engine_name in self.engines: - self.current_engine = self.engines[default_engine_name] - print(f"{Fore.GREEN}使用配置的默认AI引擎: {default_engine_name}{Style.RESET_ALL}") - elif self.engines: - # 如果配置的引擎不存在但有其他引擎,则使用第一个可用引擎 - first_engine_name = next(iter(self.engines)) - self.current_engine = self.engines[first_engine_name] - print(f"{Fore.GREEN}使用默认AI引擎: {first_engine_name}{Style.RESET_ALL}") - else: - print(f"{Fore.RED}警告: 没有可用的AI引擎,请检查ai_engines目录{Style.RESET_ALL}") - self.current_engine = None - if self.current_engine: - self.current_engine.cli = self # 设置引用以便访问插件 - # 创建聊天记录目录 - self.chat_history_dir = os.path.join(project_dir, "chat_history") - if not os.path.exists(self.chat_history_dir): - os.makedirs(self.chat_history_dir) - - # 获取 skills 目录路径 - skills_dir = os.path.join(project_dir, "skills") - self.load_liugins(liugins_dir, skills_dir) - # 初始化共享对话历史 - self.shared_conversation_history = [] - # 设置最大历史记录数 - self.max_history = DEFAULT_MAX_HISTORY - # 为所有已加载的引擎设置共享对话历史 - self._set_shared_conversation_history() - # 初始化代码执行相关功能 - self.code_execution_enabled = True - self.code_execution_timeout = 30 # 默认超时30秒 - self.code_execution_memory_limit = 128 * 1024 * 1024 # 128MB内存限制 - self.code_execution_whitelist = [ - 'json', 'math', 'datetime', 'random', 'collections', - 'itertools', 'functools', 'operator', 'typing', 're' - ] # 允许导入的安全模块白名单 - self.code_execution_history = [] # 代码执行历史记录 - self.liugin_code_requests = {} # 插件代码执行请求队列 - self.user_input_queue = {} # 用户输入队列,用于存储等待用户确认的操作 - self.is_clawli_mode = False # Clawli 远程模式标志 - self._clawli_monitor_thread = None # Clawli 消息监控线程 - self._clawli_monitor_running = False # 监控线程运行标志 - # TUI 输出回调(用于 TUI 模式下的实时输出) - self.tui_output_callback = None - # 初始化用户ID(基于机器硬件动态生成) - import uuid - self.user_id = str(uuid.getnode()) - - def _start_clawli_monitor(self): - """启动 Clawli 消息监控线程""" - if self._clawli_monitor_running: - return - - self._clawli_monitor_running = True - print(f"{Fore.CYAN}[Clawli] 消息监控线程已启动{Style.RESET_ALL}") - - def monitor_loop(): - while self._clawli_monitor_running and self.is_clawli_mode: - try: - # 从独立进程服务器获取消息 - if CLAWLI_SERVER_AVAILABLE: - messages = clawli_server.get_messages() - if messages: - print(f"[Clawli Monitor] 收到 {len(messages)} 条消息") - for msg in messages: - self._handle_clawli_message(msg) - import time - time.sleep(0.1) # 100ms 检查间隔 - except Exception as e: - print(f"[Clawli Monitor] 错误: {e}") - - self._clawli_monitor_thread = threading.Thread(target=monitor_loop, daemon=True) - self._clawli_monitor_thread.start() - - def _stop_clawli_monitor(self): - """停止 Clawli 消息监控线程""" - self._clawli_monitor_running = False - if self._clawli_monitor_thread: - self._clawli_monitor_thread.join(timeout=1) - self._clawli_monitor_thread = None - - def _handle_clawli_message(self, msg: dict): - """处理来自手机端的消息""" - try: - msg_type = msg.get("type", "") - - if msg_type == "message": - content = msg.get("content", "") - self._process_clawli_user_message(content) - - elif msg_type == "file": - file_path = msg.get("path", "") - text = msg.get("text", "") - category = msg.get("category", "file") - - if category == "image": - # 图片走图像识别 - self._process_clawli_image(file_path, text) - else: - # 其他文件 - self._process_clawli_user_message(f"@{file_path}\n{text}") - - except Exception as e: - print(f"[Clawli] 处理消息错误: {e}") - - def _process_clawli_user_message(self, content: str): - """处理 Clawli 用户消息""" - if not self.current_engine: - return - - # 添加 Clawli 标识 - full_content = f"{content}\n\n[此消息由 Clawli 手机端发送]" - - # 添加到历史 - self.shared_conversation_history.append({ - "role": "user", - "content": full_content - }) - - # 调用 AI 处理 - system_prompt = self._build_system_prompt(self.get_liugin_usage_prompts()) - response = self.current_engine.generate_response(full_content, system_prompt=system_prompt) - - # 处理响应(可能包含工具调用) - self._process_clawli_response(response) - - def _process_clawli_response(self, response: str): - """处理 Clawli AI 响应""" - text_content, json_data = self._parse_mixed_response(response) - - if json_data: - # 处理工具调用 - tool_calls = self._extract_tool_calls(json_data) - - if tool_calls: - for tool_data in tool_calls: - tool_name = tool_data.get('tool', '未知工具') - tool_args = tool_data.get('args', '') - - # 发送工具状态到手机 - if CLAWLI_SERVER_AVAILABLE: - clawli_server.send_tool_status(tool_name, tool_args, "calling") - - result = self.process_tool_call(tool_data) - result_text = result.get('result', '无结果') - - # 发送结果状态到手机 - if CLAWLI_SERVER_AVAILABLE: - clawli_server.send_tool_status(tool_name, tool_args, "success", result_text) - - # 添加到历史并继续处理 - self.shared_conversation_history.append({ - "role": "assistant", - "content": response - }) - - # 继续处理 - tool_result_str = "\n".join([f"工具执行结果: {r}" for r in [result.get('result', '')]]) - self._process_clawli_user_message(f"{tool_result_str}\n请根据工具执行结果继续回答。") - return - - # 普通响应 - final_response = text_content if text_content else response - - self.shared_conversation_history.append({ - "role": "assistant", - "content": final_response - }) - - # 发送到手机 - if CLAWLI_SERVER_AVAILABLE: - clawli_server.send_message(final_response) - - def _process_clawli_image(self, image_path: str, text: str): - """处理 Clawli 图片消息""" - try: - # 检查图像识别引擎 - image_engine = getattr(self, 'image_engine', None) - if not image_engine: - try: - from image_engine import get_image_engine - image_engine = get_image_engine() - except: - image_engine = None - - if image_engine: - with open(image_path, 'rb') as f: - image_data = f.read() - - result = image_engine.analyze(image_data, text or "请描述这张图片") - self._process_clawli_user_message(f"@{image_path}\n图片内容: {result}\n\n{text}") - else: - self._process_clawli_user_message(f"@{image_path}\n{text}") - - except Exception as e: - self._process_clawli_user_message(f"@{image_path}\n图片处理失败: {e}\n{text}") - - def _extract_tool_calls(self, json_data) -> list: - """从 JSON 数据中提取工具调用列表(支持多种格式)""" - tool_calls = [] - - if isinstance(json_data, dict): - # 格式1: 小狸自有格式 {"action": "use_tool", "tool": "...", "args": "..."} - if json_data.get('action') == 'use_tool': - tool_calls.append(json_data) - # 格式2: 简化格式 {"tool": "...", "args": "..."} - elif 'tool' in json_data and 'args' in json_data: - tool_calls.append(json_data) - # 格式3: OpenAI function_call 格式 {"function_call": {"name": "...", "arguments": "..."}} - elif 'function_call' in json_data: - fc = json_data['function_call'] - if isinstance(fc, dict): - args = fc.get('arguments', '') - # arguments 可能是 JSON 字符串,需要解析 - if isinstance(args, str) and args.startswith('{'): - try: - args = json.loads(args) - except: - pass - tool_calls.append({ - 'tool': fc.get('name', ''), - 'args': args if isinstance(args, str) else '', - 'arguments': args if isinstance(args, dict) else None - }) - # 格式4: MCP/Anthropic 格式 {"name": "...", "arguments": {...}} - elif 'name' in json_data and 'arguments' in json_data: - args = json_data.get('arguments', '') - # arguments 可能是 JSON 字符串,需要解析 - if isinstance(args, str) and args.startswith('{'): - try: - args = json.loads(args) - except: - pass - tool_calls.append({ - 'tool': json_data.get('name', ''), - 'args': args if isinstance(args, str) else '', - 'arguments': args if isinstance(args, dict) else None - }) - # 格式5: OpenAI tool_calls 格式 {"type": "function", "function": {...}} - elif json_data.get('type') == 'function' and 'function' in json_data: - func = json_data['function'] - args = func.get('arguments', '') - if isinstance(args, str) and args.startswith('{'): - try: - args = json.loads(args) - except: - pass - tool_calls.append({ - 'tool': func.get('name', ''), - 'args': args if isinstance(args, str) else '', - 'arguments': args if isinstance(args, dict) else None - }) - elif isinstance(json_data, list): - for item in json_data: - if isinstance(item, dict): - if item.get('action') == 'use_tool': - tool_calls.append(item) - elif 'tool' in item and 'args' in item: - tool_calls.append(item) - elif 'name' in item and 'arguments' in item: - args = item.get('arguments', '') - # arguments 可能是 JSON 字符串,需要解析 - if isinstance(args, str) and args.startswith('{'): - try: - args = json.loads(args) - except: - pass - tool_calls.append({ - 'tool': item.get('name', ''), - 'args': args if isinstance(args, str) else '', - 'arguments': args if isinstance(args, dict) else None - }) - - return tool_calls if tool_calls else None - - def cleanup_resources(self): - """清理资源,确保在程序退出时正确关闭所有资源""" - print(f"{Fore.YELLOW}正在清理资源...{Style.RESET_ALL}") - # 停止 Clawli 监控 - self._stop_clawli_monitor() - # 停止 Clawli 服务器 - if CLAWLI_SERVER_AVAILABLE: - clawli_server.stop_server() - print(f"{Fore.GREEN}资源清理完成{Style.RESET_ALL}") - - def __del__(self): - """析构函数,确保资源被清理""" - self.cleanup_resources() - - def _process_code_blocks(self, text): - """处理包含""" """语法的文本,将代码块部分显示为红色""" - import re - from colorama import Fore, Style - # 使用正则表达式查找 """ """ 包围的内容 - # 模式匹配:""" 任意内容(非贪婪) """ - pattern = r'(""".*?""")' - - def replace_match(match): - full_match = match.group(1) # 整个匹配(包括 """ ) - # 移除开头的"""和结尾的""" - inner_content = full_match[3:-3] - # 返回红色显示的内容 - return f'{Fore.RED}{inner_content}{Style.RESET_ALL}' - # 执行替换 - result = re.sub(pattern, replace_match, text, flags=re.DOTALL) - return result - - def _limit_output_lines(self, text, max_lines=2): - """限制输出行数为指定的最大行数""" - lines = text.split('\n') - if len(lines) <= max_lines: - return text - # 只保留前max_lines行 - return '\n'.join(lines[:max_lines]) + f"\n... (已截断,共{len(lines)}行)" - - def _get_default_engine_name(self): - """从配置文件获取默认引擎名称""" - try: - # 优先从配置文件读取默认引擎 - default_engine = get_system_config('default_engine') - if default_engine: - return default_engine - # 如果配置文件中没有,使用默认值 ollama - return 'ollama' - except Exception as e: - print(f"{Fore.YELLOW}读取引擎配置失败,使用默认引擎: {e}{Style.RESET_ALL}") - return 'ollama' - - def load_ai_engines(self): - """动态加载AI引擎插件""" - print(f"{Fore.GREEN}正在加载AI引擎插件...{Style.RESET_ALL}") - # AI引擎插件目录 - ai_engines_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ai_engines") - # 加载AI引擎插件 - if os.path.exists(ai_engines_dir): - for filename in os.listdir(ai_engines_dir): - if filename.endswith('.py') and filename != '__init__.py': - engine_path = os.path.join(ai_engines_dir, filename) - engine_name = filename[:-3] # 移除.py扩展名 - 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_instance.cli = self # 设置CLI引用 - # 获取引擎名称 - 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}AI引擎插件 {filename} 中未找到合适的引擎类{Style.RESET_ALL}") - except (ImportError, AttributeError, TypeError) as e: - logger.error(f"加载AI引擎插件失败 {filename}: {e}") - print(f"{Fore.RED}加载AI引擎插件失败 {filename}: {e}{Style.RESET_ALL}") - except FileNotFoundError as e: - logger.error(f"AI引擎liugin文件未找到 {filename}: {e}") - print(f"{Fore.RED}AI引擎liugin文件未找到 {filename}: {e}{Style.RESET_ALL}") - except Exception as e: - logger.error(f"加载AI引擎插件时发生未知错误 {filename}: {e}") - print(f"{Fore.RED}加载AI引擎插件失败 {filename}: {e}{Style.RESET_ALL}") - print(f"{Fore.GREEN}总共加载了 {len(self.engines)} 个AI引擎{Style.RESET_ALL}") - - def _find_engine_class(self, module, engine_name): - """查找AI引擎类,使用动态方式避免硬编码类名""" - # 首先尝试常见的命名模式 - possible_names = [ - engine_name.capitalize() + "AI", - engine_name.replace('_', '').replace('-', '').capitalize() + "AI", - engine_name.replace('_', '').capitalize() + "AI", - engine_name.replace('_engine', '').capitalize() + "AI", - # 添加更多命名模式以覆盖特殊情况 - engine_name.replace('-', '').replace('_', '').capitalize() + "AI", - engine_name.replace('-', '').capitalize() + "AI", - # 特殊处理:qwen3-coder-plus -> Qwen3CoderPlusAI - ''.join(word.capitalize() for word in engine_name.replace('-', ' ').replace('_', ' ').split()) + "AI", - # 特殊处理:qwen3-coder-plus_engine -> Qwen3CoderPlusAI(先处理_engine后缀) - ''.join(word.capitalize() for word 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 (hasattr(attr, '__class__') and - isinstance(attr, type) and - attr_name.endswith('AI') and - hasattr(attr, 'generate_response')): # AI引擎类有generate_response方法 - return attr_name - return None - - def load_liugins(self, liugins_dir=None, skills_dir=None): - """加载liugin和技能""" - print(f"{Fore.GREEN}正在加载工具...{Style.RESET_ALL}") - - # 使用统一工具管理器 - if self._use_unified_manager: - self.liugin_manager.initialize(liugins_dir or "plugins", skills_dir or "skills") - else: - # 兼容旧版 LiuginManager - self.liugin_manager.tools = [] - enabled_liugins = self.liugin_manager.get_enabled_liugins() - plugins_path = liugins_dir or self.liugin_manager.liugins_dir - - if os.path.exists(plugins_path): - for filename in os.listdir(plugins_path): - if filename.endswith('.py') and filename != '__init__.py': - liugin_name = filename[:-3] - if enabled_liugins is not None and liugin_name.lower() not in enabled_liugins: - print(f"{Fore.YELLOW}跳过liugin: {liugin_name} (未启用){Style.RESET_ALL}") - continue - plugin_path = os.path.join(plugins_path, filename) - try: - spec = importlib.util.spec_from_file_location(liugin_name, plugin_path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - # 兼容 Liugin 和 Plugin 两种类名 - plugin_class = getattr(module, 'Liugin', None) or getattr(module, 'Plugin', None) - if plugin_class: - plugin_instance = plugin_class() - if hasattr(plugin_instance, 'set_cli'): - plugin_instance.set_cli(self) - tool_info = plugin_instance.get_tool_info() - tool_info['handler'] = plugin_instance.handle - self.liugin_manager.tools.append(tool_info) - print(f"{Fore.GREEN}已加载liugin: {liugin_name}{Style.RESET_ALL}") - except Exception as e: - print(f"{Fore.RED}加载liugin失败 {filename}: {e}{Style.RESET_ALL}") - print(f"{Fore.GREEN}总共加载了 {len(self.liugin_manager.tools)} 个工具插件{Style.RESET_ALL}") - - print(f"{Fore.GREEN}工具加载完成!{Style.RESET_ALL}") - - def register_liugin_command(self, command, handler): - """注册插件命令""" - self.liugin_commands[command] = handler - print(f"{Fore.GREEN}已注册插件命令: {command}{Style.RESET_ALL}") - - def unregister_liugin_command(self, command): - """注销插件命令""" - if command in self.liugin_commands: - del self.liugin_commands[command] - print(f"{Fore.GREEN}已注销插件命令: {command}{Style.RESET_ALL}") - - def _register_engine_commands(self): - """注册默认引擎命令""" - self.engine_commands['list'] = self._handle_engine_list - self.engine_commands['switch'] = self._handle_engine_switch - - def register_engine_command(self, command, handler): - """注册引擎命令""" - self.engine_commands[command] = handler - print(f"{Fore.GREEN}已注册引擎命令: {command}{Style.RESET_ALL}") - - def unregister_engine_command(self, command): - """注销引擎命令""" - if command in self.engine_commands: - del self.engine_commands[command] - print(f"{Fore.GREEN}已注销引擎命令: {command}{Style.RESET_ALL}") - - def _handle_engine_list(self, args): - """处理引擎列表命令""" - print(f"{Fore.GREEN}可用的AI引擎:{Style.RESET_ALL}") - for engine_name in self.engines.keys(): - status = " (当前使用)" if engine_name == getattr(self.current_engine, 'name', 'spark') else "" - print(f"{Fore.GREEN} - {engine_name}{status}{Style.RESET_ALL}") - - def _handle_engine_switch(self, args): - """处理引擎切换命令""" - if args: - engine_name = args.strip() - self.switch_engine(engine_name) - else: - print(f"{Fore.RED}请提供引擎名称.用法: /engine switch <引擎名>{Style.RESET_ALL}") - - def _handle_remote_command(self, args): - """处理远程连接命令 - - 本地模式: /remote start <端口> <密码> - 代理模式: /remote proxy <服务器:端口> <服务器密码> - """ - if not WEBSOCKET_AVAILABLE: - print(f"{Fore.RED}错误: websockets 库未安装{Style.RESET_ALL}") - print(f"{Fore.YELLOW}请运行: pip install websockets{Style.RESET_ALL}") - return - - args = args.strip() - - if not args or args == '': - # 显示帮助 - print(f"\n{Fore.CYAN}{'='*55}{Style.RESET_ALL}") - print(f"{Fore.CYAN} 远程连接命令帮助{Style.RESET_ALL}") - print(f"{Fore.CYAN}{'='*55}{Style.RESET_ALL}") - print(f"\n{Fore.WHITE}本地模式 (局域网直连):{Style.RESET_ALL}") - print(f"{Fore.YELLOW} /remote start <端口> <密码>{Style.RESET_ALL}") - print(f"{Fore.WHITE} 端口: 监听端口 (默认9079){Style.RESET_ALL}") - print(f"{Fore.WHITE} 密码: PC密码 (安卓端需要输入){Style.RESET_ALL}") - print(f"\n{Fore.WHITE}代理模式 (无公网IP,通过中继服务器):{Style.RESET_ALL}") - print(f"{Fore.YELLOW} /remote proxy <服务器> <服务器密码> {Style.RESET_ALL}") - print(f"{Fore.WHITE} 服务器: 中继服务器地址:端口{Style.RESET_ALL}") - print(f"{Fore.WHITE} 服务器密码: 连接中继服务器的密码{Style.RESET_ALL}") - print(f"{Fore.WHITE} PC端口: 在服务器上使用的端口{Style.RESET_ALL}") - print(f"{Fore.WHITE} PC密码: 安卓端连接时需要验证{Style.RESET_ALL}") - print(f"\n{Fore.WHITE}其他命令:{Style.RESET_ALL}") - print(f"{Fore.WHITE} /remote stop - 停止服务{Style.RESET_ALL}") - print(f"{Fore.WHITE} /remote status - 查看状态{Style.RESET_ALL}") - print(f"{Fore.WHITE} /remote ip - 查看本机IP{Style.RESET_ALL}") - print(f"\n{Fore.CYAN}{'='*55}{Style.RESET_ALL}") - print(f"\n{Fore.GREEN}示例:{Style.RESET_ALL}") - print(f"{Fore.WHITE} /remote start 9079 mypassword{Style.RESET_ALL}") - print(f"{Fore.WHITE} /remote proxy relay.example.com:9079 serverpwd 12345 pcpwd{Style.RESET_ALL}") - print(f"{Fore.CYAN}{'='*55}{Style.RESET_ALL}\n") - return - - parts = args.split() - cmd = parts[0].lower() - - if cmd == 'start': - # 本地模式: /remote start [端口] <密码> - port = 9079 - password = "" - - if len(parts) == 2: - # 只有一个参数,可能是端口或密码 - arg = parts[1] - if arg.isdigit(): - port = int(arg) - print(f"{Fore.RED}错误: 请设置PC密码{Style.RESET_ALL}") - print(f"{Fore.YELLOW}用法: /remote start {port} <密码>{Style.RESET_ALL}") - return - else: - password = arg - - elif len(parts) >= 3: - try: - port = int(parts[1]) - password = parts[2] - except ValueError: - password = parts[1] - - if not password: - print(f"{Fore.RED}错误: 请设置PC密码{Style.RESET_ALL}") - print(f"{Fore.YELLOW}用法: /remote start <端口> <密码>{Style.RESET_ALL}") - return - - if len(password) < 4: - print(f"{Fore.RED}错误: 密码至少4个字符{Style.RESET_ALL}") - return - - # 使用独立进程服务器 - if CLAWLI_SERVER_AVAILABLE: - success = clawli_server.start_server("0.0.0.0", port, password) - if success: - self.is_clawli_mode = True - # 启动消息监控线程 - self._start_clawli_monitor() - else: - websocket_server.start_local_server(self, port, password) - - elif cmd == 'proxy': - # 代理模式: /remote proxy <服务器> <服务器密码> - if len(parts) < 5: - print(f"{Fore.RED}参数不足{Style.RESET_ALL}") - print(f"{Fore.YELLOW}用法: /remote proxy <服务器> <服务器密码> {Style.RESET_ALL}") - print(f"{Fore.WHITE}示例: /remote proxy relay.example.com:9079 serverpwd 12345 pcpwd{Style.RESET_ALL}") - return - - server_addr = parts[1] - server_password = parts[2] - pc_port = 0 - pc_password = parts[4] if len(parts) > 4 else "" - - # 解析端口 - try: - pc_port = int(parts[3]) - if pc_port < 10000 or pc_port > 19999: - print(f"{Fore.YELLOW}建议端口范围: 10000-19999{Style.RESET_ALL}") - except ValueError: - print(f"{Fore.RED}无效的端口号: {parts[3]}{Style.RESET_ALL}") - return - - if not pc_password: - print(f"{Fore.RED}错误: 请设置PC密码{Style.RESET_ALL}") - return - - if len(pc_password) < 4: - print(f"{Fore.RED}错误: PC密码至少4个字符{Style.RESET_ALL}") - return - - # 解析服务器地址 - if ':' in server_addr: - server_host, port_str = server_addr.rsplit(':', 1) - try: - server_port = int(port_str) - except ValueError: - print(f"{Fore.RED}无效的服务器端口: {port_str}{Style.RESET_ALL}") - return - else: - server_host = server_addr - server_port = 9079 - - # 连接代理 - print(f"{Fore.CYAN}正在连接中继服务器...{Style.RESET_ALL}") - websocket_server.start_proxy_client( - self, server_host, server_port, - server_password, pc_port, pc_password - ) - - elif cmd == 'stop': - # 停止所有服务 - if CLAWLI_SERVER_AVAILABLE: - clawli_server.stop_server() - self.is_clawli_mode = False - else: - websocket_server.stop_all() - print(f"{Fore.GREEN}远程服务已停止{Style.RESET_ALL}") - - elif cmd == 'status': - # 查看状态 - if CLAWLI_SERVER_AVAILABLE: - status = clawli_server.get_status() - else: - status = websocket_server.get_status() - if status.get('running'): - mode = status.get('mode', 'local') - if mode == 'proxy': - print(f"{Fore.GREEN}远程服务状态: 运行中 (代理模式){Style.RESET_ALL}") - print(f"{Fore.WHITE}服务器: {Fore.YELLOW}{status.get('server', '未知')}{Style.RESET_ALL}") - print(f"{Fore.WHITE}PC端口: {Fore.YELLOW}{status.get('port', '未知')}{Style.RESET_ALL}") - else: - print(f"{Fore.GREEN}远程服务状态: 运行中 (本地模式){Style.RESET_ALL}") - print(f"{Fore.WHITE}本机IP: {Fore.YELLOW}{status.get('local_ip', '未知')}{Style.RESET_ALL}") - print(f"{Fore.WHITE}端口: {Fore.YELLOW}{status.get('port', 9079)}{Style.RESET_ALL}") - print(f"{Fore.WHITE}已连接客户端: {Fore.YELLOW}{status.get('connected_clients', 0)}{Style.RESET_ALL}") - else: - print(f"{Fore.YELLOW}远程服务状态: 未启动{Style.RESET_ALL}") - print(f"{Fore.WHITE}使用 {Fore.CYAN}/remote start <端口> <密码>{Fore.WHITE} 启动本地服务{Style.RESET_ALL}") - print(f"{Fore.WHITE}使用 {Fore.CYAN}/remote proxy ...{Fore.WHITE} 连接代理服务器{Style.RESET_ALL}") - - elif cmd == 'ip': - # 显示本机IP - import socket - try: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - local_ip = s.getsockname()[0] - s.close() - print(f"{Fore.GREEN}本机IP地址: {Fore.YELLOW}{local_ip}{Style.RESET_ALL}") - print(f"{Fore.WHITE}在 Clawli 应用中输入此IP即可连接 (仅限局域网){Style.RESET_ALL}") - except Exception as e: - print(f"{Fore.RED}获取IP失败: {e}{Style.RESET_ALL}") - - else: - print(f"{Fore.RED}未知命令: {cmd}{Style.RESET_ALL}") - print(f"{Fore.WHITE}可用命令: start, proxy, stop, status, ip{Style.RESET_ALL}") - - def _get_engine_commands_help(self): - """获取引擎命令帮助信息""" - help_lines = [] - for cmd, handler in self.engine_commands.items(): - if cmd == 'list': - help_lines.append(f" /engine {cmd:<10} - 查看可用的AI引擎") - elif cmd == 'switch': - help_lines.append(f" /engine {cmd:<10} - 切换AI引擎") - else: - # 可以根据命令名生成通用帮助文本 - help_lines.append(f" /engine {cmd:<10} - 执行{cmd}命令") - return '\n'.join(help_lines) if help_lines else " 无可用引擎管理命令" - - def get_available_engines(self): - """获取可用引擎列表""" - return list(self.engines.keys()) - - def get_current_engine_name(self): - """获取当前引擎名称""" - return getattr(self.current_engine, 'name', '未设置') if self.current_engine else '未设置' - - def get_liugin_manager(self): - """获取插件管理器""" - return self.liugin_manager - - def switch_engine(self, engine_name): - """切换AI引擎""" - if engine_name in self.engines: - # 保存当前引擎 - old_engine = self.current_engine - self.current_engine = self.engines[engine_name] - - # 确保引擎有CLI引用 - if hasattr(self.current_engine, 'cli'): - self.current_engine.cli = self - - # 确保新引擎也使用共享的对话历史 - if hasattr(self.current_engine, 'shared_conversation_history'): - self.current_engine.shared_conversation_history = self.shared_conversation_history - self.current_engine.max_history = self.max_history - - # 当切换引擎时,向新引擎提供完整的对话历史作为上下文 - if old_engine != self.current_engine and self.shared_conversation_history: - print(f"{Fore.CYAN}已切换到AI引擎: {engine_name},正在传递对话历史...{Style.RESET_ALL}") - - # 添加一个系统消息到对话历史,告知上下文切换 - context_message = { - "role": "system", - "content": f"引擎切换通知:现在由{engine_name}引擎继续之前的对话。以下是之前的对话历史:\n" - } - - # 显示历史对话给用户,以便新AI可以参考 - if self.shared_conversation_history: - print(f"{Fore.YELLOW}当前对话历史已传递给新引擎:{Style.RESET_ALL}") - for msg in self.shared_conversation_history[-self.max_history:]: - if isinstance(msg, dict) and "role" in msg and "content" in msg: - role = msg["role"] - content = msg["content"] - if role == "user": - print(f"{Fore.CYAN}用户: {content}{Style.RESET_ALL}") - elif role == "assistant": - print(f"{Fore.GREEN}AI: {content}{Style.RESET_ALL}") - elif role == "tool": - print(f"{Fore.MAGENTA}工具结果: {content}{Style.RESET_ALL}") - else: - print(f"{Fore.GREEN}已切换到AI引擎: {engine_name}{Style.RESET_ALL}") - - # 保存默认引擎配置 - try: - set_system_config('default_engine', engine_name) - print(f"{Fore.CYAN}已将 {engine_name} 设置为默认引擎{Style.RESET_ALL}") - except Exception as e: - print(f"{Fore.YELLOW}警告: 保存默认引擎配置失败: {e}{Style.RESET_ALL}") - else: - print(f"{Fore.RED}未找到AI引擎: {engine_name}{Style.RESET_ALL}") - - def _set_shared_conversation_history(self): - """为所有引擎设置共享对话历史""" - for engine in self.engines.values(): - # 为每个引擎设置共享对话历史的引用 - engine.shared_conversation_history = self.shared_conversation_history - engine.max_history = self.max_history - - def handle_engine_command(self, command): - """处理引擎特定命令,例如Ollama引擎的模型切换""" - if not hasattr(self.current_engine, 'handle_command'): - return False # 引擎不支持处理命令 - - # 检查命令是否以引擎名称开头(如 "ollama streaming on") - # 如果是,则移除引擎名称前缀 - engine_name = getattr(self.current_engine, 'name', '') - if engine_name and command.startswith(engine_name + ' '): - actual_command = command[len(engine_name) + 1:].strip() - return self.current_engine.handle_command(actual_command) - - # 否则直接处理命令 - return self.current_engine.handle_command(command) - - def handle_liugin_message(self, message: str, **kwargs) -> str: - """ - 处理插件转发的消息(如 QQ 群 @ 消息) - - Args: - message: 插件转发的消息内容 - **kwargs: 额外的上下文信息(如 group_id, user_id 等) - - Returns: - AI 的回复内容 - """ - try: - print(f"{Fore.CYAN}[插件消息] {message[:100]}...{Style.RESET_ALL}") - - # 构建系统提示 - system_prompt = """你是一个智能助手,正在通过 QQ 机器人与用户交流。 -请简洁、友好地回复用户的消息。回复时注意: -1. 回复要简洁明了,适合 QQ 群聊场景 -2. 不要提及你是 AI 或机器人 -3. 直接回答问题,不要加多余的前缀""" - - # 调用当前 AI 引擎处理 - if self.current_engine: - # 使用 generate_response 方法(统一接口) - response = self.current_engine.generate_response(message, system_prompt=system_prompt) - - if response: - print(f"{Fore.GREEN}[AI 回复] {response[:50]}...{Style.RESET_ALL}") - return response - else: - return "抱歉,我暂时无法回复。" - else: - print(f"{Fore.RED}[插件消息] 没有可用的 AI 引擎{Style.RESET_ALL}") - return "AI 引擎未初始化" - - except Exception as e: - print(f"{Fore.RED}[插件消息] 处理失败: {e}{Style.RESET_ALL}") - return f"处理消息时出错: {e}" - - def process_tool_call(self, tool_call_data): - """处理工具调用(支持 Liugin 和 MCP 两种格式)""" - try: - tool_name = tool_call_data.get('tool') - args = tool_call_data.get('args', '') - - # 检查是否是 MCP 格式调用(包含 arguments 对象) - arguments = tool_call_data.get('arguments') - - # 使用 unified_tool_manager 的方法执行 - if UNIFIED_TOOL_MANAGER_AVAILABLE and hasattr(self.liugin_manager, 'execute'): - if arguments and isinstance(arguments, dict): - # MCP 格式调用 - result = self.liugin_manager.execute(tool_name, args, **arguments) - else: - # Liugin 格式调用 - result = self.liugin_manager.execute(tool_name, args) - return result - - # 回退:直接调用 handler(旧逻辑) - for tool in self.liugin_manager.tools: - if tool['name'] == tool_name: - try: - result = tool['handler'](args) - if result is None: - result = "无结果" - return {"result": result} - except Exception as e: - return {"result": f"工具执行错误: {e}"} - - return {"result": f"未找到工具: {tool_name}"} - - except Exception as e: - return {"result": f"处理工具调用时出错: {e}"} - - def process_tool_calls_concurrent(self, tool_calls_list): - """并发批量执行工具调用,按顺序返回结果""" - if not tool_calls_list: - return [] - - # 全局取消标志和线程锁 - cancel_event = threading.Event() - history_lock = threading.Lock() - - def execute_single_tool(tool_data, index): - """执行单个工具,带索引用于排序""" - if cancel_event.is_set(): - return {"index": index, "result": "工具执行已取消"} - - try: - result = self.process_tool_call(tool_data) - tool_result = result.get('result', '无结果') - # 线程安全地添加到对话历史 - with history_lock: - self.shared_conversation_history.append({ - "role": "assistant", - "content": f"工具{index+1}执行结果:\n{tool_result}" - }) - return {"index": index, "result": tool_result} - except Exception as e: - error_msg = f"工具执行错误: {e}" - with history_lock: - self.shared_conversation_history.append({ - "role": "assistant", - "content": error_msg - }) - return {"index": index, "result": error_msg} - - # ESC键检测辅助函数(Windows专用) - def supports_esc_detection(): - """检查是否支持ESC键检测""" - return sys.platform == 'win32' - - def check_key_input(): - """检查是否有键盘输入""" - try: - import msvcrt - return msvcrt.kbhit() - except: - return False - - def get_key(): - """获取键盘输入""" - try: - import msvcrt - return msvcrt.getch() - except: - return None - - # ESC键检测线程(仅Windows支持) - def detect_esc_key(): - if supports_esc_detection(): - while not cancel_event.is_set() and not all(f.done() for f in futures): - if check_key_input(): - key = get_key() - if key == b'\x1b': # ESC键 - cancel_event.set() - break - time.sleep(0.1) - - # 使用线程池并发执行 - with ThreadPoolExecutor(max_workers=min(len(tool_calls_list), 10)) as executor: - # 提交所有任务 - futures = {executor.submit(execute_single_tool, tool_data, i): i - for i, tool_data in enumerate(tool_calls_list)} - - # 启动ESC检测线程 - esc_thread = threading.Thread(target=detect_esc_key, daemon=True) - esc_thread.start() - - # 收集结果 - results = [] - for future in as_completed(futures): - if cancel_event.is_set(): - # 如果被取消,等待所有任务完成(它们会返回取消信息) - for f in futures: - if not f.done(): - f.cancel() - break - try: - result = future.result() - results.append(result) - except Exception as e: - index = futures[future] - results.append({"index": index, "result": f"工具执行错误: {e}"}) - - # 按索引排序,保持AI指定的顺序 - results.sort(key=lambda x: x['index']) - - return [r['result'] for r in results] - - def _execute_single_tool(self, tool_data): - """执行单个工具(带延迟和ESC检测)""" - import sys - import time - - tool_name = tool_data.get('tool', '未知工具') - tool_args = tool_data.get('args', '') - if len(tool_args) > 30: - tool_args_display = tool_args[:27] + "..." - else: - tool_args_display = tool_args - - # TUI 模式下不做延迟和 ESC 检测(无键盘输入) - if not self.tui_output_callback: - try: - import msvcrt - canceled = False - spinners = ['⊶', '⊷'] - spinner_index = 0 - for i in range(25): - if msvcrt.kbhit(): - key = msvcrt.getch() - if key == b'\x1b': - canceled = True - break - spinner = spinners[spinner_index % 2] - spinner_index += 1 - loading_message = f" 正在执行: {spinner} {tool_name} {tool_args_display} (按ESC取消)" - sys.stdout.write(f"\r{loading_message}") - sys.stdout.flush() - time.sleep(0.2) - sys.stdout.write("\r" + " " * 60 + "\r") - sys.stdout.flush() - if canceled: - print(f"{Fore.YELLOW}工具执行已取消(用户按ESC键){Style.RESET_ALL}") - cancel_message = f"工具 {tool_name} 执行已被用户取消(按ESC键)。工具参数: {tool_args}" - self.shared_conversation_history.append({ - "role": "assistant", - "content": cancel_message - }) - return True - except ImportError: - pass - - # 调用工具 - tool_results = self.process_tool_call(tool_data) - full_result = tool_results.get('result', '无结果') - from datetime import datetime - current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - # 显示工具结果 - self._display_tool_result(tool_name, tool_args, full_result, current_time) - # 将工具结果作为新的输入传递给AI - current_input = f"工具调用结果 (时间: {current_time}):\n{full_result}" - # 添加到对话历史 - self.shared_conversation_history.append({ - "role": "assistant", - "content": f"工具调用结果 (时间: {current_time}):\n{full_result}" - }) - # 继续循环 - self.process_conversation(current_input) - - def _execute_concurrent_tools(self, tool_calls_list): - """并发批量执行工具调用""" - if not tool_calls_list: - return - - import sys - import msvcrt - - # 显示批量执行提示 - self._output(f" 正在批量执行 {len(tool_calls_list)} 个工具") - - # 并发执行所有工具 - results = self.process_tool_calls_concurrent(tool_calls_list) - - # 获取当前时间 - from datetime import datetime - current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - # 显示每个工具的结果 - for i, (tool_data, result) in enumerate(zip(tool_calls_list, results)): - tool_name = tool_data.get('tool', '未知工具') - tool_args = tool_data.get('args', '') - self._display_tool_result(tool_name, tool_args, result, current_time) - - # 将所有结果合并传递给AI - all_results = "\n".join([f"工具{i+1}结果:\n{result}" for i, result in enumerate(results)]) - current_input = f"批量工具调用结果 (时间: {current_time}):\n{all_results}" - - # 添加到对话历史 - self.shared_conversation_history.append({ - "role": "assistant", - "content": f"批量工具调用结果 (时间: {current_time}):\n{all_results}" - }) - - # 继续循环 - self.process_conversation(current_input) - - def request_code_execution(self, liugin_name, code, timeout=None, variables=None, context=None): - """ - 插件代码执行请求接口 - - Args: - liugin_name: 请求执行的插件名称 - code: 要执行的Python代码 - timeout: 超时时间(秒),默认使用配置的超时时间 - variables: 要传递给代码的变量字典 - context: 上下文信息(可选) - - Returns: - { - 'success': bool, # 执行是否成功 - 'result': str, # 执行结果或错误信息 - 'stdout': str, # 标准输出 - 'stderr': str, # 标准错误 - 'execution_time': float, # 执行时间(秒) - 'memory_usage': int # 内存使用(字节) - } - """ - if not self.code_execution_enabled: - return { - 'success': False, - 'result': '代码执行功能已禁用', - 'stdout': '', - 'stderr': '', - 'execution_time': 0, - 'memory_usage': 0 - } - - # 安全检查:验证代码不包含危险操作 - if not self._is_code_safe(code): - return { - 'success': False, - 'result': '代码包含潜在危险操作,已阻止执行', - 'stdout': '', - 'stderr': '', - 'execution_time': 0, - 'memory_usage': 0 - } - - # 设置超时 - exec_timeout = timeout if timeout is not None else self.code_execution_timeout - - # 记录执行历史 - execution_id = f"{liugin_name}_{int(time.time())}" - execution_record = { - 'id': execution_id, - 'liugin_name': liugin_name, - 'code': code, - 'timestamp': datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - 'context': context - } - - try: - # 使用多进程安全执行代码 - result = self._execute_code_safely(code, exec_timeout, variables) - - # 更新执行记录 - execution_record.update({ - 'success': result['success'], - 'execution_time': result['execution_time'], - 'memory_usage': result['memory_usage'] - }) - - # 添加到历史记录(最多保留100条) - self.code_execution_history.append(execution_record) - if len(self.code_execution_history) > 100: - self.code_execution_history.pop(0) - - # 记录日志 - if result['success']: - logger.info(f"代码执行成功 - 插件: {liugin_name}, 耗时: {result['execution_time']:.3f}s") - else: - logger.warning(f"代码执行失败 - 插件: {liugin_name}, 错误: {result['result']}") - - return result - - except Exception as e: - error_msg = f"代码执行异常: {str(e)}" - logger.error(f"{error_msg} - 插件: {liugin_name}") - return { - 'success': False, - 'result': error_msg, - 'stdout': '', - 'stderr': error_msg, - 'execution_time': 0, - 'memory_usage': 0 - } - - def _is_code_safe(self, code): - """检查代码是否安全(使用 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): - """ - 在安全沙箱中执行代码(v2 子进程隔离) - 6层安全: AST分析 → 子进程隔离 → 文件系统限制 → 网络阻断 → 模块白名单 → 输出截断 - """ - import subprocess - import json as _json - import traceback as _traceback - - # 安全检查 - 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) - wl = getattr(self, 'code_execution_whitelist', None) or _SAFE_WHITELIST - whitelist_json = _json.dumps(wl) - 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', - } - for key in ('TERM', 'COLUMNS', 'LINES', 'SHELL'): - val = os.environ.get(key) - if val: - safe_env[key] = val - - start_time = time.time() - try: - proc = subprocess.run( - [sys.executable, '-c', script], - capture_output=True, timeout=timeout, - cwd=_sandbox_work_dir, env=safe_env, - preexec_fn=AICLI._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': '', '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): - """ - 获取代码执行历史 - - Args: - limit: 返回的最大条数 - - Returns: - 执行历史列表 - """ - return self.code_execution_history[-limit:] - - def clear_code_execution_history(self): - """清空代码执行历史""" - self.code_execution_history = [] - logger.info("代码执行历史已清空") - - def set_code_execution_timeout(self, timeout): - """ - 设置代码执行超时时间 - - Args: - timeout: 超时时间(秒) - """ - if timeout > 0: - self.code_execution_timeout = timeout - logger.info(f"代码执行超时时间已设置为 {timeout} 秒") - - def set_code_execution_whitelist(self, modules): - """ - 设置允许导入的模块白名单 - - Args: - modules: 模块名称列表 - """ - self.code_execution_whitelist = modules - logger.info(f"代码执行模块白名单已更新: {modules}") - - def _display_tool_result(self, tool_name, tool_args, full_result, current_time): - """显示工具执行结果""" - if full_result is None: - full_result = "无结果" - - self._output("") - user_id_display = f"[用户ID: {self.user_id}]" - is_error = full_result.startswith("错误:") or full_result.startswith("错误:") or full_result.startswith("工具执行错误") - - if self.tui_output_callback: - # TUI 模式:使用 Textual 标记 - if is_error: - self._output(f"[red] X 工具: {tool_name}[/]") - self._output(f"[red] 参数: {tool_args} {user_id_display}[/]") - else: - self._output(f"[green] OK 工具: {tool_name}[/]") - self._output(f"[green] 参数: {tool_args} {user_id_display}[/]") - else: - # CLI 模式:使用 colorama - 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_output_lines(full_result, max_lines=2) - color_tag = "red" if is_error else "green" - for line in display_result.split('\n'): - if self.tui_output_callback: - self._output(f"[{color_tag}] {line}[/]") - else: - color = Fore.RED if is_error else Fore.GREEN - self._output(f"{color} {line}{Style.RESET_ALL}") - - def display_image_in_terminal(self, image_path, width=None): - """在终端中显示图片(优先使用 ASCII 艺术,然后是系统查看器) - - Args: - image_path: 图片文件路径 - width: 可选,指定ASCII图片的宽度(字符数)。如果为None,则自动适应终端宽度 - """ - try: - # 检查文件是否存在 - if not os.path.exists(image_path): - print(f"{Fore.RED}错误: 图片文件不存在: {image_path}{Style.RESET_ALL}") - return False - - # 检查文件扩展名 - valid_extensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'] - if not os.path.splitext(image_path)[1].lower() in valid_extensions: - print(f"{Fore.RED}错误: 不支持的图片格式。支持的格式: {', '.join(valid_extensions)}{Style.RESET_ALL}") - return False - - # 获取图片信息 - try: - from PIL import Image - with Image.open(image_path) as img: - img_size = img.size - img_format = img.format - file_size = os.path.getsize(image_path) - print(f"\n{Fore.CYAN}{'=' * 60}{Style.RESET_ALL}") - print(f"{Fore.CYAN}图片预览: {os.path.basename(image_path)}{Style.RESET_ALL}") - print(f"{Fore.CYAN}{'=' * 60}{Style.RESET_ALL}") - print(f"{Fore.GREEN} 格式: {img_format}{Style.RESET_ALL}") - print(f"{Fore.GREEN} 尺寸: {img_size[0]} x {img_size[1]} 像素{Style.RESET_ALL}") - print(f"{Fore.GREEN} 大小: {file_size / 1024:.2f} KB{Style.RESET_ALL}") - print(f"{Fore.CYAN}{'=' * 60}{Style.RESET_ALL}") - except ImportError: - print(f"\n{Fore.CYAN}图片预览: {os.path.basename(image_path)}{Style.RESET_ALL}") - - # 方法1: 使用色块显示(高分辨率,彩色) - try: - from PIL import Image - - # 获取终端尺寸 - terminal_size = shutil.get_terminal_size() - terminal_width = terminal_size.columns - terminal_height = terminal_size.lines - 5 # 预留空间给信息 - - # 如果未指定宽度,则使用终端宽度的100% - if width is None: - width = min(250, terminal_width) - - # 读取图片 - with Image.open(image_path) as img: - # 调整大小以适应终端 - img_width, img_height = img.size - - # 使用四分之一块,每个字符显示 2x2 像素 - # 所以实际可以显示的像素是字符的 2 倍宽、2 倍高 - pixel_width = width * 2 - pixel_height = (terminal_height - 2) * 2 - - # 计算缩放比例(保持宽高比) - scale = min(pixel_width / img_width, pixel_height / img_height) - # 适当放大,充分利用空间 - scale = min(scale * 1.1, 1.0) - - new_width = int(img_width * scale) - new_height = int(img_height * scale) - - # 确保至少有一定的尺寸 - new_width = max(new_width, 80) - new_height = max(new_height, 40) - - # 调整图片大小 - img = img.resize((new_width, new_height), Image.LANCZOS) - - # 转换为 RGB - if img.mode != 'RGB': - img = img.convert('RGB') - - # 获取像素数据 - pixels = img.load() - - print(f"{Fore.YELLOW}正在生成色块图片 ({new_width}x{new_height} 像素)...{Style.RESET_ALL}") - - # 使用四分之一块字符 - # 每个字符显示 2x2 像素块 - # 字符映射: - # 左上 右上 - # 左下 右下 - - block_chars = { - (False, False, False, False): ' ', # 空 - (True, False, False, False): '▘', # 左上 - (False, True, False, False): '▝', # 右上 - (True, True, False, False): '▀', # 上半 - (False, False, True, False): '▖', # 左下 - (True, False, True, False): '▌', # 左半 - (False, True, True, False): '▞', # 右上+左下 - (True, True, True, False): '▛', # 除右下 - (False, False, False, True): '▗', # 右下 - (True, False, False, True): '▚', # 左上+右下 - (False, True, False, True): '▐', # 右半 - (True, True, False, True): '▜', # 除左下 - (False, False, True, True): '▄', # 下半 - (True, False, True, True): '▙', # 除右上 - (False, True, True, True): '▟', # 除左上 - (True, True, True, True): '█', # 全块 - } - - # 遍历 2x2 像素块 - for y in range(0, new_height, 2): - line = [] - for x in range(0, new_width, 2): - # 获取 2x2 像素的颜色 - # 左上 - if y < new_height and x < new_width: - r1, g1, b1 = pixels[x, y] - has_tl = True - else: - r1, g1, b1 = 0, 0, 0 - has_tl = False - - # 右上 - if y < new_height and x + 1 < new_width: - r2, g2, b2 = pixels[x + 1, y] - has_tr = True - else: - r2, g2, b2 = 0, 0, 0 - has_tr = False - - # 左下 - if y + 1 < new_height and x < new_width: - r3, g3, b3 = pixels[x, y + 1] - has_bl = True - else: - r3, g3, b3 = 0, 0, 0 - has_bl = False - - # 右下 - if y + 1 < new_height and x + 1 < new_width: - r4, g4, b4 = pixels[x + 1, y + 1] - has_br = True - else: - r4, g4, b4 = 0, 0, 0 - has_br = False - - # 计算平均颜色(用于前景色) - all_pixels = [(r1, g1, b1), (r2, g2, b2), (r3, g3, b3), (r4, g4, b4)] - avg_r = sum(p[0] for p in all_pixels) // 4 - avg_g = sum(p[1] for p in all_pixels) // 4 - avg_b = sum(p[2] for p in all_pixels) // 4 - - # 获取对应的块字符 - char = block_chars.get((has_tl, has_tr, has_bl, has_br), ' ') - - # 使用前景色显示 - fg_color = f"\033[38;2;{avg_r};{avg_g};{avg_b}m" - reset = "\033[0m" - line.append(f"{fg_color}{char}{reset}") - - # 打印一行 - print(''.join(line)) - - print(f"{Fore.GREEN}✓ 已使用色块显示 ({new_width}x{new_height} 像素){Style.RESET_ALL}\n") - return True - - except ImportError: - print(f"{Fore.YELLOW}未安装 ascii-magic 库,尝试使用系统默认图片查看器...{Style.RESET_ALL}") - except Exception as e: - print(f"{Fore.YELLOW}ASCII 艺术显示失败: {e},尝试使用系统默认图片查看器...{Style.RESET_ALL}") - - # 方法2: 使用系统默认图片查看器(备用方案) - import platform - import subprocess - - system = platform.system() - - if system == 'Windows': - os.startfile(image_path) - print(f"{Fore.GREEN}✓ 已在系统默认图片查看器中打开{Style.RESET_ALL}\n") - elif system == 'Darwin': # macOS - subprocess.run(['open', image_path]) - print(f"{Fore.GREEN}✓ 已在系统默认图片查看器中打开{Style.RESET_ALL}\n") - else: # Linux - subprocess.run(['xdg-open', image_path]) - print(f"{Fore.GREEN}✓ 已在系统默认图片查看器中打开{Style.RESET_ALL}\n") - - return True - - except Exception as e: - print(f"{Fore.RED}显示图片失败: {str(e)}{Style.RESET_ALL}") - return False - - def save_chat_history(self, name): - """保存当前聊天记录到文件""" - try: - # 创建聊天记录数据 - chat_data = { - "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), - "conversation": self.shared_conversation_history.copy() # 使用共享历史 - } - # 生成保存路径 - file_path = os.path.join(self.chat_history_dir, f"{name}.json") - # 写入聊天记录 - with open(file_path, 'w', encoding='utf-8') as f: - json.dump(chat_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 list_chat_history(self): - """列出所有已保存的聊天记录""" - try: - chat_files = [f for f in os.listdir(self.chat_history_dir) if f.endswith('.json')] - if not chat_files: - print(f"{Fore.YELLOW}没有已保存的聊天记录{Style.RESET_ALL}") - return - print(f"{Fore.GREEN}已保存的聊天记录:{Style.RESET_ALL}") - for i, file in enumerate(chat_files, 1): - # 获取文件修改时间 - file_path = os.path.join(self.chat_history_dir, file) - mod_time = os.path.getmtime(file_path) - mod_time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mod_time)) - # 去掉.json扩展名显示 - chat_name = file[:-5] # 移除".json" - print(f"{Fore.GREEN}{i}. {chat_name} (保存时间: {mod_time_str}){Style.RESET_ALL}") - except Exception as e: - print(f"{Fore.RED}列出聊天记录失败: {e}{Style.RESET_ALL}") - - def load_chat_history(self, name): - """加载指定的聊天记录""" - try: - file_path = os.path.join(self.chat_history_dir, f"{name}.json") - if not os.path.exists(file_path): - print(f"{Fore.RED}聊天记录 '{name}' 不存在{Style.RESET_ALL}") - return False - with open(file_path, 'r', encoding='utf-8') as f: - chat_data = json.load(f) - # 恢复聊天记录历史到共享历史 - self.shared_conversation_history = chat_data.get("conversation", []) # 使用共享历史 - # 将共享对话历史传递给所有引擎 - self._set_shared_conversation_history() - print(f"{Fore.GREEN}已加载聊天记录: {name}{Style.RESET_ALL}") - # 显示加载的对话历史摘要 - if self.shared_conversation_history: - print(f"{Fore.CYAN}加载了 {len(self.shared_conversation_history)} 条对话记录{Style.RESET_ALL}") - return True - except Exception as e: - print(f"{Fore.RED}加载聊天记录失败: {e}{Style.RESET_ALL}") - return False - - def show_help(self): - """显示帮助信息""" - help_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "HELP.md") - if os.path.exists(help_file_path): - try: - with open(help_file_path, 'r', encoding='utf-8') as f: - help_content = f.read() - print(help_content) - except Exception as e: - print(f"读取帮助文件失败: {e}") - self._show_basic_help() - else: - self._show_basic_help() - - def show_liugin_help(self, liugin_name): - """显示特定插件的帮助信息""" - # 首先检查是否为AI引擎插件 - if liugin_name in self.engines: - engine = self.engines[liugin_name] - print(f"{Fore.GREEN}AI引擎 '{liugin_name}' 的帮助信息:{Style.RESET_ALL}") - # 显示引擎名称 - engine_name = getattr(engine, 'name', liugin_name) - print(f"{Fore.GREEN}引擎名称: {engine_name}{Style.RESET_ALL}") - # 如果引擎有提供帮助信息的方法,调用它 - if hasattr(engine, 'get_help_info'): - help_info = engine.get_help_info() - if isinstance(help_info, str): - print(f"{Fore.GREEN}帮助信息:{Style.RESET_ALL}") - print(help_info) - elif isinstance(help_info, dict): - for key, value in help_info.items(): - print(f"{Fore.GREEN}{key}: {value}{Style.RESET_ALL}") - else: - # 显示引擎特定命令信息 - print(f"{Fore.GREEN}可用命令:{Style.RESET_ALL}") - print(f" /engine.{liugin_name} <命令> - 执行{liugin_name}引擎的特定命令") - print(f"{Fore.GREEN}示例:{Style.RESET_ALL}") - print(f" /engine.{liugin_name} help - 查看{liugin_name}引擎的详细帮助") - return - # 查找匹配的插件 - for tool in self.liugin_manager.tools: - # 检查插件名称是否匹配 - if tool.get('name') == liugin_name: - # 显示插件帮助信息 - print(f"{Fore.GREEN}插件 '{liugin_name}' 的帮助信息:{Style.RESET_ALL}") - # 显示插件描述 - description = tool.get('description', '无描述') - print(f"{Fore.GREEN}描述: {description}{Style.RESET_ALL}") - # 显示插件用法 - if 'usage' in tool: - print(f"{Fore.GREEN}用法:{Style.RESET_ALL}") - print(f" {tool['usage']}") - else: - print(f"{Fore.YELLOW}该插件未提供具体用法信息{Style.RESET_ALL}") - # 显示插件关键词 - if 'keywords' in tool: - keywords = tool['keywords'] - if isinstance(keywords, list): - print(f"{Fore.GREEN}关键词: {', '.join(keywords)}{Style.RESET_ALL}") - else: - print(f"{Fore.GREEN}关键词: {keywords}{Style.RESET_ALL}") - return - # 如果没有找到匹配的插件或引擎 - print(f"{Fore.RED}未找到插件或引擎 '{liugin_name}'{Style.RESET_ALL}") - # 显示所有可用插件和引擎 - print(f"{Fore.YELLOW}可用AI引擎:{Style.RESET_ALL}") - for engine_name in self.engines.keys(): - print(f"{Fore.YELLOW} - {engine_name}{Style.RESET_ALL}") - if self.liugin_manager.tools: - print(f"{Fore.YELLOW}可用插件:{Style.RESET_ALL}") - for tool in self.liugin_manager.tools: - tool_name = tool.get('name', '未知插件') - tool_description = tool.get('description', '无描述') - print(f"{Fore.YELLOW} - {tool_name}: {tool_description}{Style.RESET_ALL}") - else: - print(f"{Fore.YELLOW}当前没有加载任何插件{Style.RESET_ALL}") - - def _show_basic_help(self): - """显示基本帮助信息""" - help_text = """ -小狸 Pro-CLI 帮助信息 -================== -基本命令: - /help - 显示此帮助信息 - /help <插件名> - 显示特定插件的帮助信息 - /quit - 退出程序 - /tui - 切换到 TUI 模式 - /model <引擎名> - 切换AI引擎 (当前支持: """ - # 动态添加可用引擎 - available_engines = [] - for engine_name in self.engines.keys(): - available_engines.append(engine_name) - help_text += ", ".join(available_engines) + ")" - help_text += r""" -文件操作命令: - /file.read <文件名> [行数] - 直接读取文件内容, 可指定读取行数 - @文件路径 - 发送时自动读取文件内容并拼接(支持多文件) - 例如: 请分析这个文件 @c:\test.py - 例如: @c:\test.py @c:\config.json 帮我分析 - @图片路径 - 自动分析图片并获取描述(支持PNG/JPG/GIF等) - 例如: 请描述这张图片 @c:\photo.png - 例如: @c:\photo.png 这个图片里有什么? -图像分析命令: - /image analyze <图片路径> - 详细分析图片 - /image ocr <图片路径> - OCR文字识别 - /image quick <图片路径> - 快速描述 - /image model <模型名> - 切换视觉模型 - /image models - 列出可用模型 - /image engine <引擎名> - 切换识别引擎 - /image engines - 列出所有引擎 - /img <操作> <参数> - 简写形式 -引擎管理命令: -""" + self._get_engine_commands_help() + """ - /engine.<命令> - 执行引擎特定命令(如Ollama引擎的模型切换) -插件命令: - 插件可以注册自定义命令, 使用 /<命令名> 的格式调用 -聊天记录命令: - /chat save <名称> - 保存当前聊天记录 - /chat list - 查看所有已保存的聊天记录 - /chat open <名称> - 加载聊天记录 -深度思考处理命令: - /thinking <内容> - 处理深度思考AI返回的内容, 只提取回复部分 -插件工具: - AI可以自动调用以下工具来帮助您: +#!/usr/bin/env python3 """ - # 显示可用的插件工具 - if self.liugin_manager.tools: - for tool in self.liugin_manager.tools: - tool_name = tool.get('name', '未知工具') - tool_description = tool.get('description', '无描述') - help_text += f" {tool_name}: {tool_description}\n" - if 'usage' in tool: - help_text += f" 用法: {tool['usage']}\n" - else: - help_text += " 当前没有可用的工具插件\n" - help_text += """ -使用说明: - - 直接输入自然语言与AI对话 - - AI会根据您的请求自动决定是否需要调用工具 - - 工具调用结果会显示在屏幕上 - - 可以随时保存和加载聊天记录 - - 使用/thinking命令可以处理深度思考AI返回的内容, 只显示回复部分 +小狸 Pro-CLI - AI 智能编程助手 + +主入口文件 - 从 xcli_core 包加载核心逻辑 """ - print(help_text) - def run_tui(self): - """运行 TUI 模式""" - if not TEXTUAL_AVAILABLE: - print(f"{Fore.RED}TUI 模式不可用:Textual 库未安装{Style.RESET_ALL}") - print(f"{Fore.YELLOW}请运行: pip install textual rich{Style.RESET_ALL}") - return - app = XiaoliTUI(self) - app.run() +from xcli_core import AICLI, main - def run(self): - """运行 CLI""" - # 获取机器码作为用户ID - import uuid - self.user_id = str(uuid.getnode()) - print(f"{Fore.GREEN}小狸 Pro-CLI 已启动!{Style.RESET_ALL}") - print(f"{Fore.GREEN}用户ID: {self.user_id}{Style.RESET_ALL}") - print(f"{Fore.GREEN}输入 '/help' 查看帮助信息{Style.RESET_ALL}") - print(f"{Fore.GREEN}输入 '/quit' 退出程序{Style.RESET_ALL}") - print(f"{Fore.GREEN}输入 '/tui' 切换到 TUI 模式{Style.RESET_ALL}") - print(f"{Fore.GREEN}输入 '/engine list' 查看可用AI引擎{Style.RESET_ALL}") - print(f"{Fore.GREEN}输入 '/engine switch <引擎名>' 切换AI引擎{Style.RESET_ALL}") - print(f"{Fore.GREEN}输入 '/file.read <文件名> [行数]' 直接读取文件内容{Style.RESET_ALL}") - print(f"{Fore.GREEN}输入 '@文件路径' 自动读取文件内容并发送给AI{Style.RESET_ALL}") - print(f"{Fore.GREEN}输入 '@图片路径' 自动分析图片并发送描述给AI{Style.RESET_ALL}") - print(f"{Fore.GREEN}输入 '/image engines' 查看图像识别引擎{Style.RESET_ALL}") - print(f"{Fore.GREEN}输入 '/chat save <名称>' 保存当前聊天记录{Style.RESET_ALL}") - print(f"{Fore.GREEN}输入 '/chat list' 查看所有已保存的聊天记录{Style.RESET_ALL}") - print(f"{Fore.GREEN}输入 '/chat open <名称>' 加载聊天记录{Style.RESET_ALL}") - print(f"{Fore.GREEN}输入 '/remote' 查看远程连接帮助{Style.RESET_ALL}") - current_engine_name = getattr(self.current_engine, 'name', '未设置') if self.current_engine else '未设置' - print(f"{Fore.GREEN}当前使用的AI引擎: {current_engine_name}{Style.RESET_ALL}") - # 显示已加载的引擎 - loaded_engines = list(self.engines.keys()) - if len(loaded_engines) > 1: - print(f"{Fore.GREEN}已加载的AI引擎: {', '.join(loaded_engines)}{Style.RESET_ALL}") - print(f"{Fore.GREEN}{'-' * 50}{Style.RESET_ALL}") - # 运行 CLI 主循环 - self._run_cli_loop() - - def _process_file_paths(self, user_input): - """处理用户输入中的 @文件路径 语法,读取文件内容并拼接 - - 格式: @文件路径 - 例如: @c:\test.py - - 返回: 处理后的文本内容 - """ - import re - - # 支持的图片格式 - image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'} - - # 匹配 @文件路径 格式(支持带引号的路径和空格,以及Windows路径) - # 匹配模式: @"路径" 或 @路径(包括反斜杠) - # 注意:使用非贪婪匹配,避免匹配到后面的空格 - pattern = r'@"([^"]+)"|@([^\s]+)' - matches = list(re.finditer(pattern, user_input)) - - if not matches: - return user_input - - result_parts = [] - last_pos = 0 - - for match in matches: - # 添加匹配位置之前的普通文本 - if match.start() > last_pos: - result_parts.append(user_input[last_pos:match.start()]) - - # 获取文件路径(引号或无引号) - file_path = match.group(1) or match.group(2) - - # 检查file_path是否为None - if not file_path: - continue - - # 规范化路径(处理 Windows 路径) - file_path = os.path.normpath(file_path) - - # 尝试读取文件 - try: - if os.path.exists(file_path): - # 检查是否为目录 - if os.path.isdir(file_path): - # 处理目录 - 列出目录中的文件(不包含子目录) - print(f"{Fore.CYAN}检测到目录: {file_path}{Style.RESET_ALL}") - - try: - # 获取目录中的所有文件和子目录 - entries = os.listdir(file_path) - - # 只保留文件,过滤掉子目录 - files = [] - for entry in entries: - full_path = os.path.join(file_path, entry) - if os.path.isfile(full_path): - files.append(entry) - - if not files: - result_parts.append(f"[目录: {file_path} 为空]") - print(f"{Fore.YELLOW}目录为空: {file_path}{Style.RESET_ALL}") - else: - # 格式化文件列表 - file_list_text = f"\n[目录: {file_path} - 共 {len(files)} 个文件]\n" - file_list_text += "=" * 50 + "\n" - - for i, filename in enumerate(sorted(files), 1): - full_path = os.path.join(file_path, filename) - file_size = os.path.getsize(full_path) - file_list_text += f"{i}. {filename} ({file_size} 字节)\n" - - file_list_text += "=" * 50 + "\n" - result_parts.append(file_list_text) - print(f"{Fore.GREEN}已列出目录内容: {file_path} ({len(files)} 个文件){Style.RESET_ALL}") - - except PermissionError: - result_parts.append(f"[错误: 无权限访问目录 {file_path}]") - print(f"{Fore.RED}无权限访问目录: {file_path}{Style.RESET_ALL}") - except Exception as e: - result_parts.append(f"[错误: 无法读取目录 {file_path} - {str(e)}]") - print(f"{Fore.RED}无法读取目录 {file_path}: {e}{Style.RESET_ALL}") - - # 检查是否为图片文件 - else: - file_ext = os.path.splitext(file_path)[1].lower() - is_image = file_ext in image_extensions - - if is_image: - # 处理图片文件 - 先在终端显示图片 - print(f"{Fore.CYAN}检测到图片文件: {file_path}{Style.RESET_ALL}") - - # 在终端显示图片预览 - try: - self.display_image_in_terminal(file_path) - except Exception as e: - print(f"{Fore.YELLOW}图片预览失败: {e}{Style.RESET_ALL}") - - # 查找图像分析插件(通用方式) - image_plugin = None - for tool in self.liugin_manager.tools: - tool_name = tool.get('name', '').lower() - tool_desc = tool.get('description', '').lower() - # 通过名称或描述识别图像分析插件 - if 'image' in tool_name or 'analyze' in tool_name or '图片' in tool_desc: - image_plugin = tool - break - - if image_plugin: - try: - print(f"{Fore.YELLOW}正在分析图片...{Style.RESET_ALL}") - image_result = image_plugin['handler'](f"analyze {file_path}") - - # 调试:打印图片分析结果 - if image_result: - print(f"{Fore.MAGENTA}图片分析结果: {image_result[:100]}...{Style.RESET_ALL}" if len(image_result) > 100 else f"{Fore.MAGENTA}图片分析结果: {image_result}{Style.RESET_ALL}") - # 将图片描述添加到结果 - result_parts.append(f" {image_result}") - print(f"{Fore.GREEN}图片分析完成,已添加到输入{Style.RESET_ALL}") - else: - print(f"{Fore.YELLOW}图片分析返回空结果{Style.RESET_ALL}") - result_parts.append(f" [图片: {file_path}]") - except Exception as e: - result_parts.append(f"[错误: 图片分析失败 - {str(e)}]") - print(f"{Fore.RED}图片分析失败: {e}{Style.RESET_ALL}") - else: - result_parts.append(f"[错误: 图像分析插件未加载]") - print(f"{Fore.RED}图像分析插件未加载{Style.RESET_ALL}") - - # 处理文本文件(非图片) - else: - # 检查文件大小 - file_size = os.path.getsize(file_path) - if file_size > MAX_FILE_SIZE: - result_parts.append(f"[错误: 文件 {file_path} 超过大小限制 ({MAX_FILE_SIZE} 字节)]") - print(f"{Fore.YELLOW}警告: 文件 {file_path} 超过大小限制,已跳过{Style.RESET_ALL}") - else: - # 尝试读取文件内容 - try: - with open(file_path, 'r', encoding='utf-8') as f: - file_content = f.read() - # 添加文件内容到结果 - result_parts.append(f"\n[文件: {file_path}]\n{file_content}\n") - print(f"{Fore.GREEN}已读取文件: {file_path} ({len(file_content)} 字符){Style.RESET_ALL}") - except UnicodeDecodeError: - # 尝试其他编码 - try: - with open(file_path, 'r', encoding='gbk') as f: - file_content = f.read() - result_parts.append(f"\n[文件: {file_path}]\n{file_content}\n") - print(f"{Fore.GREEN}已读取文件: {file_path} ({len(file_content)} 字符) [GBK编码]{Style.RESET_ALL}") - except Exception as e: - result_parts.append(f"[错误: 无法读取文件 {file_path} - {str(e)}]") - print(f"{Fore.RED}无法读取文件 {file_path}: {e}{Style.RESET_ALL}") - except Exception as e: - result_parts.append(f"[错误: 无法读取文件 {file_path} - {str(e)}]") - print(f"{Fore.RED}无法读取文件 {file_path}: {e}{Style.RESET_ALL}") - else: - result_parts.append(f"[错误: 文件 {file_path} 不存在]") - print(f"{Fore.RED}文件不存在: {file_path}{Style.RESET_ALL}") - - last_pos = match.end() - except Exception as e: - result_parts.append(f"[错误: 处理文件路径时出错 - {str(e)}]") - print(f"{Fore.RED}处理文件路径时出错: {e}{Style.RESET_ALL}") - last_pos = match.end() - - # 添加最后的部分 - if last_pos < len(user_input): - result_parts.append(user_input[last_pos:]) - - # 合并所有部分 - return ''.join(result_parts) - - def _run_cli_loop(self): - """运行 CLI 主循环""" - while True: - try: - user_input = input(f"\n{Fore.WHITE}> {Style.RESET_ALL}").strip() - # 处理命令 - if user_input == '/quit': - print(f"{Fore.GREEN}再见!{Style.RESET_ALL}") - break - if user_input == '/help': - self.show_help() - continue - if user_input.startswith('/help '): - # 处理 /help <插件名> 命令 - liugin_name = user_input[6:].strip() # 移除 '/help ' - self.show_liugin_help(liugin_name) - continue - # 处理引擎相关命令 - if user_input.startswith('/engine '): - engine_command = user_input[8:].strip() # 移除 '/engine ' - # 使用动态注册的引擎命令处理 - parts = engine_command.split(' ', 1) - cmd = parts[0] - args = parts[1] if len(parts) > 1 else '' - if cmd in self.engine_commands: - self.engine_commands[cmd](args) - else: - available_commands = ', '.join(self.engine_commands.keys()) - print(f"{Fore.RED}未知的引擎命令.可用命令: {available_commands}{Style.RESET_ALL}") - continue - # 处理引擎特定命令(如Ollama引擎的模型切换) - if user_input.startswith('/engine.'): - engine_command = user_input[8:].strip() # 移除 '/engine.' - if not self.handle_engine_command(engine_command): - print(f"{Fore.RED}当前引擎不支持该命令或命令执行失败{Style.RESET_ALL}") - continue - # 处理文件读取命令 - if user_input.startswith('/file.read '): - # 解析命令参数 - args = user_input[11:].strip() # 移除 '/file.read ' - parts = args.split(' ', 2) - if len(parts) < 1: - print(f"{Fore.RED}请提供文件名.用法: /file.read <文件名> [行数]{Style.RESET_ALL}") - continue - filename = parts[0] - max_lines = None - if len(parts) >= 2: - try: - max_lines = int(parts[1]) - if max_lines <= 0: - print(f"{Fore.RED}行数必须是正整数{Style.RESET_ALL}") - continue - except ValueError: - print(f"{Fore.RED}行数必须是正整数{Style.RESET_ALL}") - continue - # 调用文件管理插件读取文件 - result = self._read_file_direct(filename, max_lines) - print(f"{Fore.GREEN}{result}{Style.RESET_ALL}") - continue - # 处理聊天记录命令 - if user_input.startswith('/chat '): - chat_command = user_input[6:].strip() # 移除 '/chat ' - if chat_command.startswith('save '): - name = chat_command[5:].strip() # 移除 'save ' - if name: - self.save_chat_history(name) - else: - print(f"{Fore.RED}请提供聊天记录名称.用法: /chat save <名称>{Style.RESET_ALL}") - continue - elif chat_command == 'list': - self.list_chat_history() - continue - elif chat_command.startswith('open '): - name = chat_command[5:].strip() # 移除 'open ' - if name: - self.load_chat_history(name) - else: - print(f"{Fore.RED}请提供聊天记录名称.用法: /chat open <名称>{Style.RESET_ALL}") - continue - else: - print(f"{Fore.RED}未知的聊天命令.可用命令: save, list, open{Style.RESET_ALL}") - continue - # 处理远程连接命令 - if user_input.startswith('/remote'): - remote_command = user_input[7:].strip() # 移除 '/remote' - self._handle_remote_command(remote_command) - continue - # 处理 /about 命令 - if user_input == '/about': - self._read_about_file() - continue - # 处理 /tui 命令 - 切换到 TUI 模式 - if user_input == '/tui': - print(f"{Fore.CYAN}正在切换到 TUI 模式...{Style.RESET_ALL}") - self.run_tui() - print(f"{Fore.CYAN}已从 TUI 模式返回 CLI 模式{Style.RESET_ALL}") - continue - # 处理插件命令 - if user_input.startswith('/'): - command_parts = user_input[1:].split(' ', 1) # 移除 '/' 并分割命令和参数 - command = command_parts[0] - args = command_parts[1] if len(command_parts) > 1 else "" - if command in self.liugin_commands: - try: - result = self.liugin_commands[command](args) - if result: - # 限制输出为5行 - result = self._limit_output_lines(result, max_lines=5) - print(f"{Fore.GREEN}{result}{Style.RESET_ALL}") - except Exception as e: - print(f"{Fore.RED}插件命令执行失败: {e}{Style.RESET_ALL}") - continue - # 处理深度思考AI回复命令 - if user_input.startswith('/thinking '): - # 专门处理深度思考AI返回的内容,只提取回复部分 - thinking_input = user_input[10:].strip() # 移除 '/thinking ' - processed_response = self.process_thinking_response(thinking_input) - # 处理 """ """ 代码块语法 - processed_response = self._process_code_blocks(processed_response) - # 显示处理后的回复内容并附带用户ID(蓝色) - terminal_size = shutil.get_terminal_size() - terminal_width = terminal_size.columns - user_id_display = f"[用户ID: {self.user_id}]" - # 分行处理响应,确保换行符正确显示 - response_lines = processed_response.split('\n') - for i, line in enumerate(response_lines): - # 在第一行(AI响应)前添加符号并留空格 - if i == 0: - line = "✦ " + line - if i == len(response_lines) - 1: # 最后一行添加用户ID - print(f"{line} {user_id_display}") - else: - print(f"{line}") - continue - - # 处理 @文件路径 语法 - if '@' in user_input: - # 检查是否包含 @文件路径 语法 - processed_input = self._process_file_paths(user_input) - # 如果输入被处理(包含文件路径),使用处理后的内容 - if processed_input != user_input: - print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}") - user_input = processed_input - print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}") - # 调试:打印处理后的输入 - print(f"{Fore.MAGENTA}处理后的输入: {user_input[:200]}...{Style.RESET_ALL}" if len(user_input) > 200 else f"{Fore.MAGENTA}处理后的输入: {user_input}{Style.RESET_ALL}") - - # 生成AI响应 - 实现完整的工具调用循环 - self.process_conversation(user_input) - except KeyboardInterrupt: - # 显示退出信息并附带用户ID - terminal_size = shutil.get_terminal_size() - terminal_width = terminal_size.columns - user_id_display = f"[用户ID: {self.user_id}]" - print(f"\n{Fore.GREEN}再见! {user_id_display}{Style.RESET_ALL}") - break - except Exception as e: - # 显示错误信息并附带用户ID - terminal_size = shutil.get_terminal_size() - terminal_width = terminal_size.columns - user_id_display = f"[用户ID: {self.user_id}]" - print(f"{Fore.RED}发生错误: {e} {user_id_display}{Style.RESET_ALL}") - - def _output(self, message): - """统一输出方法,支持 TUI 和 CLI 模式""" - if self.tui_output_callback: - # TUI 模式:发送到 TUI 显示 - self.tui_output_callback(message) - else: - # CLI 模式:直接打印 - print(message) - - def _display_response(self, content, is_continue=False): - """统一显示 AI 响应,支持 TUI 和 CLI 模式""" - user_id_display = f"[用户ID: {self.user_id}]" - response_lines = content.split('\n') - if self.tui_output_callback: - # TUI 模式:使用 Textual 标记 - for i, line in enumerate(response_lines): - if i == 0: - prefix = "[yellow]>[/] " if is_continue else "[cyan]>[/] " - line = prefix + line - if i == len(response_lines) - 1: - self._output(f"{line} {user_id_display}") - else: - self._output(line) - self._output("") - else: - # CLI 模式:使用 colorama - for i, line in enumerate(response_lines): - if i == 0: - prefix = f"{Fore.YELLOW}✦{Style.RESET_ALL} " if is_continue else "✦ " - line = prefix + line - if i == len(response_lines) - 1: - print(f"{line} {user_id_display}") - else: - print(f"{line}") - print() - - def process_conversation(self, user_input): - """处理完整的对话循环, 支持工具调用循环和AI继续操作循环""" - # 添加用户输入到共享对话历史 - self.shared_conversation_history.append({ - "role": "user", - "content": user_input - }) - # 获取插件提示词 - liugin_prompts = self.get_liugin_usage_prompts() - loop_count = 0 - current_input = user_input - max_loops = 20 # 最大循环次数,防止AI无限循环 - while loop_count < max_loops: - # 生成AI响应,传递插件提示词 - # 添加等待动画 - response = self._generate_response_with_animation(current_input, liugin_prompts=liugin_prompts) - # 使用新的深度思考内容处理方法 - processed_response = self.process_thinking_response(response) - # 处理 """ """ 代码块语法 - processed_response = self._process_code_blocks(processed_response) - # 检查是否需要调用工具或继续操作 - try: - # 首先尝试解析混合响应(文本+JSON指令) - text_content, json_data = self._parse_mixed_response(processed_response) - # 如果解析到了JSON数据,则优先处理JSON指令 - if json_data: - # 如果有文本内容,先显示给用户 - if text_content: - # 处理 """ """ 代码块语法 - text_content = self._process_code_blocks(text_content) - # 将文本内容添加到对话历史 - self.shared_conversation_history.append({ - "role": "assistant", - "content": text_content - }) - # 显示文本内容 - self._display_response(text_content) - - # 判断JSON数据类型 - if isinstance(json_data, dict): - # 单个工具调用 - response_data = json_data - - if response_data.get('action') == 'use_tool': - # 执行单个工具 - canceled = self._execute_single_tool(response_data) - if canceled: - # 工具被用户取消,跳出循环(通用处理) - break - # 工具正常执行,继续处理 - break - - # 检查是否AI需要继续思考/操作 - if (response_data.get('continue') is True or - response_data.get('need_continue') is True or - response_data.get('think_more') is True): - # AI需要继续操作,获取message字段的内容 - continue_message = response_data.get('message', '') - if continue_message: - # 使用message字段的内容作为AI的响应 - processed_response = continue_message - # 将AI的响应添加到对话历史 - self.shared_conversation_history.append({ - "role": "assistant", - "content": processed_response - }) - # 显示AI的中间响应 - self._display_response(processed_response, is_continue=True) - # 继续循环,让AI继续思考 - loop_count += 1 - # 检查是否有message字段,如果有则显示 - elif response_data.get('message'): - message = response_data.get('message') - # 如果有文本内容,先显示文本内容 - if text_content: - text_content = self._process_code_blocks(text_content) - self.shared_conversation_history.append({ - "role": "assistant", - "content": text_content - }) - self._display_response(text_content) - # 将AI响应添加到对话历史 - self.shared_conversation_history.append({ - "role": "assistant", - "content": message - }) - # 显示最终结果 - self._display_response(message) - break - else: - # 如果有文本内容,显示文本内容 - if text_content: - text_content = self._process_code_blocks(text_content) - self.shared_conversation_history.append({ - "role": "assistant", - "content": text_content - }) - self._display_response(text_content) - else: - # 如果没有文本内容,将JSON响应添加到对话历史 - self.shared_conversation_history.append({ - "role": "assistant", - "content": processed_response - }) - # 显示最终结果 - self._display_response(processed_response) - break - - elif isinstance(json_data, list): - # 批量工具调用 - tool_calls = [item for item in json_data if isinstance(item, dict) and item.get('action') == 'use_tool'] - if tool_calls: - # 执行批量并发工具 - self._execute_concurrent_tools(tool_calls) - break - # 如果list里没有use_tool,检查是否有continue相关字段 - continue_items = [item for item in json_data if isinstance(item, dict) and - (item.get('continue') is True or - item.get('need_continue') is True or - item.get('think_more') is True)] - if continue_items: - continue_message = continue_items[0].get('message', '') - if continue_message: - processed_response = continue_message - self.shared_conversation_history.append({ - "role": "assistant", - "content": processed_response - }) - self._display_response(processed_response, is_continue=True) - loop_count += 1 - else: - # 没有工具调用也没有continue,显示文本并结束 - if text_content: - self.shared_conversation_history.append({ - "role": "assistant", - "content": text_content - }) - self._display_response(text_content or processed_response) - break - else: - # 没有解析到JSON数据,直接显示文本内容或原始响应 - display_content = text_content if text_content else processed_response - # 将AI响应添加到对话历史 - self.shared_conversation_history.append({ - "role": "assistant", - "content": display_content - }) - # 显示最终结果 - self._display_response(display_content) - break - except json.JSONDecodeError: - # 当JSON解析失败时,尝试解析混合内容 - text_content, json_data = self._parse_mixed_response(processed_response) - if json_data: - if text_content: - text_content = self._process_code_blocks(text_content) - self.shared_conversation_history.append({ - "role": "assistant", - "content": text_content - }) - self._display_response(text_content) - - if isinstance(json_data, dict): - response_data = json_data - if response_data.get('action') == 'continue': - continue_content = response_data.get('content', '') - if continue_content: - self.shared_conversation_history.append({ - "role": "assistant", - "content": continue_content - }) - self._display_response(continue_content) - continue - - if response_data.get('action') == 'use_tool': - canceled = self._execute_single_tool(response_data) - if canceled: - break - break - - if (response_data.get('continue') is True or - response_data.get('need_continue') is True or - response_data.get('think_more') is True): - continue_message = response_data.get('message', '') - if continue_message: - processed_response = continue_message - self.shared_conversation_history.append({ - "role": "assistant", - "content": processed_response - }) - self._display_response(processed_response, is_continue=True) - loop_count += 1 - else: - message = response_data.get('message', '') - if message: - self.shared_conversation_history.append({ - "role": "assistant", - "content": message - }) - self._display_response(message) - loop_count += 1 - else: - if text_content: - self.shared_conversation_history.append({ - "role": "assistant", - "content": text_content - }) - self._display_response(text_content or processed_response) - break - - elif isinstance(json_data, list): - tool_calls = [item for item in json_data if isinstance(item, dict) and item.get('action') == 'use_tool'] - if tool_calls: - self._execute_concurrent_tools(tool_calls) - break - continue_items = [item for item in json_data if isinstance(item, dict) and - (item.get('continue') is True or - item.get('need_continue') is True or - item.get('think_more') is True)] - if continue_items: - continue_message = continue_items[0].get('message', '') - if continue_message: - processed_response = continue_message - self.shared_conversation_history.append({ - "role": "assistant", - "content": processed_response - }) - self._display_response(processed_response, is_continue=True) - loop_count += 1 - else: - if text_content: - self.shared_conversation_history.append({ - "role": "assistant", - "content": text_content - }) - self._display_response(text_content or processed_response) - break - else: - display_content = text_content if text_content else processed_response - self.shared_conversation_history.append({ - "role": "assistant", - "content": display_content - }) - self._display_response(display_content) - break - else: - # 循环次数达到上限 - self._output(f"AI 已达到最大循环次数 ({max_loops}),已停止自动处理") - self.shared_conversation_history.append({ - "role": "assistant", - "content": f"已达到最大处理次数 ({max_loops}),已自动停止。如需继续处理,请重新输入。" - }) - - def _generate_response_with_animation(self, current_input, liugin_prompts=None): - """生成AI响应并显示等待动画""" - # TUI 模式:不做动画,直接生成响应 - if self.tui_output_callback: - system_prompt = self._build_system_prompt(liugin_prompts) - if not self.current_engine: - return "错误: 当前没有可用的AI引擎,请检查ai_engines目录中的引擎插件" - response = self.current_engine.generate_response(current_input, system_prompt=system_prompt) - if response and self.shared_conversation_history is not None: - self.shared_conversation_history.append({ - "role": "assistant", - "content": response - }) - return response - - # CLI 模式:带动画和 ESC 检测 - import threading - import time - import sys - import random - import os - animation_running = True - esc_pressed = threading.Event() - love_sentences = [] - love_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "love.txt") - if os.path.exists(love_file_path): - try: - with open(love_file_path, 'r', encoding='utf-8') as f: - love_sentences = [line.strip() for line in f.readlines() if line.strip()] - except Exception as e: - print(f"读取love.txt文件时出错: {e}") - if not love_sentences: - love_sentences = ["AI正在思考中...", "请稍等片刻...", "正在处理您的请求..."] - - def check_for_esc(): - try: - import msvcrt - while animation_running: - if msvcrt.kbhit(): - key = msvcrt.getch() - if ord(key) == 27: - esc_pressed.set() - break - time.sleep(0.1) - except ImportError: - pass - - def show_animation(): - last_change_time = time.time() - current_sentence = random.choice(love_sentences) - while animation_running: - current_time = time.time() - if current_time - last_change_time >= 5: - current_sentence = random.choice(love_sentences) - last_change_time = current_time - animation_chars = "|/-\\" - char_idx = int((current_time * 10) % len(animation_chars)) - if len(current_sentence) > 50: - display_sentence = current_sentence[:47] + "..." - else: - display_sentence = current_sentence - print(f"\r{Fore.MAGENTA}{display_sentence} {animation_chars[char_idx]}{Style.RESET_ALL}", end="", flush=True) - time.sleep(0.1) - - animation_thread = threading.Thread(target=show_animation) - animation_thread.daemon = True - animation_thread.start() - esc_thread = threading.Thread(target=check_for_esc) - esc_thread.daemon = True - esc_thread.start() - try: - system_prompt = self._build_system_prompt(liugin_prompts) - if not self.current_engine: - return "错误: 当前没有可用的AI引擎,请检查ai_engines目录中的引擎插件" - - response = self.current_engine.generate_response(current_input, system_prompt=system_prompt) - if response: - self._typeprint(response, Fore.CYAN) - - if esc_pressed.is_set(): - print(f"\n{Fore.YELLOW}[AI请求已取消]{Style.RESET_ALL}") - return "[AI请求已取消]" - - if response and self.shared_conversation_history is not None: - self.shared_conversation_history.append({ - "role": "assistant", - "content": response - }) - - return response - finally: - animation_running = False - animation_thread.join() - esc_thread.join() - print("\r" + " " * 50 + "\r", end="", flush=True) - - 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 process_thinking_response(self, response): - """处理深度思考AI返回的内容, 分离思考和回复内容 - 这个方法用于处理那些包含思考过程和回复内容的AI响应 - 思考内容用当前引擎的特定标记包围,以灰色文本显示给用户但不处理 - """ - try: - # 尝试解析JSON格式的响应 - response_data = json.loads(response) - # 如果是工具调用,直接返回 - if isinstance(response_data, dict) and response_data.get('action') == 'use_tool': - return response - # 如果是其他JSON格式,尝试提取回复内容 - if isinstance(response_data, dict): - # 假设回复内容在'answer'或'response'字段中 - if 'answer' in response_data: - extracted_content = response_data['answer'] - elif 'response' in response_data: - extracted_content = response_data['response'] - elif 'content' in response_data: - extracted_content = response_data['content'] - elif 'text' in response_data: - extracted_content = response_data['text'] - else: - # 如果没有找到特定字段,返回整个JSON的字符串表示 - return json.dumps(response_data, ensure_ascii=False, indent=2) - - # 提取内容后,继续检查是否包含思考标记 - return self._process_content_with_thinking(extracted_content) - except json.JSONDecodeError: - # 不是JSON格式,直接处理 - return self._process_content_with_thinking(response) - - def _process_content_with_thinking(self, content): - """处理包含思考标记的内容""" - # 检查当前引擎是否定义了思考标记 - thinking_start_marker = getattr(self.current_engine, 'thinking_start_marker', None) if self.current_engine else None - thinking_end_marker = getattr(self.current_engine, 'thinking_end_marker', None) if self.current_engine else None - - # 支持多种思考标记格式 - # 格式1: ... - # 格式2: - # 格式3: 自定义标记 - - # 检查 格式(Qwen3等模型使用) - if '' in content: - parts = content.split('') - if len(parts) >= 2: - thinking_content = parts[0].strip() - reply_content = parts[1].strip() - # 显示思考内容为灰色斜体文本 - if thinking_content: - print(f"{Fore.LIGHTBLACK_EX}{Style.DIM}[思考: {thinking_content}]{Style.RESET_ALL}") - # 返回回复内容 - return reply_content if reply_content else "" - - # 检查 ... 格式 - if thinking_start_marker and thinking_end_marker and thinking_start_marker in content and thinking_end_marker in content: - parts = content.split(thinking_start_marker) - if len(parts) > 1: - remaining = parts[1] - if thinking_end_marker in remaining: - thinking_part, reply_part = remaining.split(thinking_end_marker, 1) - thinking_content = thinking_part.strip() - reply_content = reply_part.strip() - # 显示思考内容为灰色斜体文本 - if thinking_content: - print(f"{Fore.LIGHTBLACK_EX}{Style.DIM}[思考: {thinking_content}]{Style.RESET_ALL}") - # 返回回复内容 - return reply_content if reply_content else "" - - # 没有找到思考标记,返回原始内容 - return content - - def _build_system_prompt(self, liugin_prompts=None): - """构建系统提示词 - 增强版,支持编程任务""" - base_prompt = """你叫小狸,是一个强大的智能编程助手。你具备以下核心能力: - -1. **精准代码编辑** - 使用 code_editor 工具进行搜索替换、多文件批量编辑 -2. **代码搜索与理解** - 使用 code_search 工具跨文件搜索、提取符号、分析依赖 -3. **Git 版本控制** - 使用 git_tools 工具管理代码版本 -4. **Shell 命令执行** - 使用 cmd_executor 执行系统命令 -5. **文件管理** - 使用 file_manager 管理文件和目录 - -你在说话时可以适当加上'nyan'来表现可爱性格,但不要过度使用。""" - - # 检查 tool_search 是否存在 - has_tool_search = any(t.get('name') == 'tool_search' for t in self.liugin_manager.tools) - - tool_search_rule = "" - if has_tool_search: - tool_search_rule = """【工具查询规则】 -调用不熟悉的工具前,先用 tool_search 查询用法: -{"action": "use_tool", "tool": "tool_search", "args": "工具名"} -例外:ai_search、code_editor、code_search、git_tools 可以直接使用。""" - - if liugin_prompts: - prompt = f"""{base_prompt} - -{liugin_prompts} - -{tool_search_rule} - -【工具调用格式】 -在回复中包含 JSON 指令来执行操作: - -1. 纯 JSON: {{"action": "use_tool", "tool": "code_editor", "args": "edit file.py old <<<>>> new"}} -2. 文本+JSON: 好的,我来修改。{{"action": "use_tool", "tool": "code_editor", "args": "edit file.py old <<<>>> new"}} - -【编程工作流】 -1. 理解需求 → 用 code_search.structure 查看项目结构 -2. 定位代码 → 用 code_search.find 或 code_search.regex 搜索 -3. 查看上下文 → 用 code_editor.read_range 查看相关代码 -4. 精准修改 → 用 code_editor.edit 替换代码(old <<<>>> new 分隔) -5. 验证变更 → 用 git_tools.diff 查看变更 -6. 提交代码 → 用 git_tools.commit 提交 - -【code_editor 编辑格式】 -edit 操作使用 <<<>>> 分隔旧代码和新代码: -{{"action": "use_tool", "tool": "code_editor", "args": "edit path/to/file.py -def old_function(): - pass -<<<>>> -def new_function(): - return True"}} - -【批量编辑】 -multi 操作支持一次修改多处: -{{"action": "use_tool", "tool": "code_editor", "args": "multi path/to/file.py [{{\\"old\\": \\"old1\\", \\"new\\": \\"new1\\"}}, {{\\"old\\": \\"old2\\", \\"new\\": \\"new2\\"}}]"}} - -【搜索代码】 -- 关键词搜索: {{"action": "use_tool", "tool": "code_search", "args": "find . keyword *.py"}} -- 正则搜索: {{"action": "use_tool", "tool": "code_search", "args": "regex . def\\\\s+\\\\w+ *.py"}} -- 查看结构: {{"action": "use_tool", "tool": "code_search", "args": "structure ."}} -- 查找调用: {{"action": "use_tool", "tool": "code_search", "args": "callers . function_name"}} - -【Git 操作】 -- 查看状态: {{"action": "use_tool", "tool": "git_tools", "args": "status"}} -- 查看变更: {{"action": "use_tool", "tool": "git_tools", "args": "diff"}} -- 提交代码: {{"action": "use_tool", "tool": "git_tools", "args": "commit 描述信息"}} -- 查看历史: {{"action": "use_tool", "tool": "git_tools", "args": "log --oneline 10"}} - -【注意事项】 -- code_editor.edit 的 old_text 必须与文件中的内容完全匹配(包括缩进) -- 如果 edit 失败,先用 read_range 查看实际内容再重试 -- args 必须是字符串 -- 可以一次返回多个 JSON 指令 -- 使用 continue 补充回答: {{"action": "continue", "content": "补充内容"}}""" - else: - prompt = f"""{base_prompt} - -{tool_search_rule} - -【工具调用格式】 -{{"action": "use_tool", "tool": "工具名", "args": "参数"}} - -【核心工具】 -- code_editor: edit/search/diff/insert/create/write/read_range -- code_search: find/regex/symbols/imports/structure/stats -- git_tools: status/diff/log/add/commit/branch -- cmd_executor: 执行 shell 命令 -- file_manager: 文件管理 - -【code_editor.edit 格式】 -{{"action": "use_tool", "tool": "code_editor", "args": "edit 文件路径 -旧代码内容 -<<<>>> -新代码内容"}} - -【continue 格式】 -{{"action": "continue", "content": "要补充的内容"}}""" - - return prompt - - def _read_file_direct(self, filename, max_lines=None): - """直接读取文件内容, 支持指定行数""" - try: - # 检查文件是否存在 - if not os.path.exists(filename): - return f"错误:文件 '{filename}' 不存在。" - # 检查是否为文件(而非目录) - if not os.path.isfile(filename): - return f"错误:'{filename}' 不是一个文件。" - # 读取文件内容 - with open(filename, 'r', encoding='utf-8') as f: - if max_lines is not None: - # 读取指定行数 - lines = [] - for i, line in enumerate(f): - if i >= max_lines: - break - lines.append(line.rstrip('\n')) # 保留行内容但移除换行符 - content = '\n'.join(lines) - if len(lines) < max_lines: - info = f"(文件共{len(lines)}行)" - else: - info = f"(已读取前{max_lines}行)" - return f"文件 '{filename}' 的内容 {info}:\n{content}" - else: - # 读取全部内容 - content = f.read() - # 限制输出长度以避免过长的响应 - MAX_CONTENT_LENGTH = 10000 # 定义为局部常量 - if len(content) > MAX_CONTENT_LENGTH: - content = content[:10000] + f"\n... (内容已截断, 共{len(content)}字符)" - return f"文件 '{filename}' 的内容:\n{content}" - except UnicodeDecodeError: - # 如果UTF-8解码失败, 尝试二进制模式获取文件大小等信息 - file_size = os.path.getsize(filename) - return f"文件 '{filename}' 是二进制文件, 大小: {file_size} 字节。无法直接读取文本内容。" - except PermissionError as e: - logger.error(f"权限错误,无法读取文件 '{filename}': {e}") - return f"权限错误:无法读取文件 '{filename}',可能需要管理员权限或文件被占用。" - except OSError as e: - logger.error(f"操作系统错误,无法读取文件 '{filename}': {e}") - return f"操作系统错误:无法读取文件 '{filename}',原因: {str(e)}" - except Exception as e: - logger.error(f"读取文件 '{filename}' 失败: {e}") - return f"读取文件 '{filename}' 失败: {str(e)}" - - def _clean_json_response(self, response): - """清理AI响应中的Markdown代码块标记""" - # 移除开头的```json标记 - if response.startswith("```json"): - response = response[7:] # 移除"```json" - elif response.startswith("```"): - response = response[3:] # 移除"```" - # 移除结尾的```标记 - if response.endswith("```"): - response = response[:-3] # 移除"```" - # 移除可能的前后空白字符 - return response.strip() - - def get_liugin_usage_prompts(self): - """获取插件和技能提示词 - - 返回所有工具的基本信息,包括 Liugin 和 Skill 两种协议。 - 对于 Skill 协议的工具,包含详细的指令内容。 - 在 Clawli 模式下,额外提供 send_image 工具说明。 - """ - if not self.liugin_manager.tools: - return "当前没有可用的工具插件." - - prompts = [] - prompts.append("可用的工具列表:\n") - - # 分离 Liugin 和 Skill - plugins = [] - skills = [] - for tool in self.liugin_manager.tools: - protocol = tool.get('protocol', 'liugin') - if protocol == 'skill': - skills.append(tool) - else: - plugins.append(tool) - - # Plugin 工具(基本信息) - if plugins: - prompts.append("## Plugin 工具(使用 tool_search 查询详细用法):") - for tool in plugins: - tool_name = tool.get('name', '') - tool_desc = tool.get('description', '') - prompts.append(f"- {tool_name}: {tool_desc}") - - # Skill 工具(包含指令) - if skills: - prompts.append("\n## Agent Skills(已加载详细指令):") - for tool in skills: - tool_name = tool.get('name', '') - tool_desc = tool.get('description', '') - prompts.append(f"\n### {tool_name}") - prompts.append(f"描述: {tool_desc}") - - # 获取技能实例的指令 - instance = tool.get('instance') - if instance: - instructions = getattr(instance, 'instructions', '') - if instructions: - # 限制长度,避免过长 - if len(instructions) > 2000: - instructions = instructions[:2000] + "\n...(内容已截断)" - prompts.append(f"\n{instructions}") - - # Clawli 模式特殊工具提示 - if self.is_clawli_mode: - prompts.append("\n## Clawli 远程模式专属工具:") - prompts.append(""" -### send_image - 发送图片到手机端 -当用户请求发送图片到手机时,使用此工具: - -```json -{ - "action": "use_tool", - "tool": "send_image", - "args": { - "image_path": "图片文件的绝对路径", - "caption": "图片说明文字(可选)" - } -} -``` - -支持的图片格式:JPG、PNG、GIF、BMP、WEBP -""") - - return "\n".join(prompts) - - def _read_about_file(self): - """读取并返回about.txt文件的内容,逐字显示""" - try: - # 获取项目根目录路径 - project_dir = os.path.dirname(os.path.abspath(__file__)) - about_file_path = os.path.join(project_dir, "about.txt") - # 检查文件是否存在 - if not os.path.exists(about_file_path): - return "错误:about.txt 文件不存在。" - # 读取文件内容 - with open(about_file_path, 'r', encoding='utf-8') as f: - content = f.read() - # 逐字显示内容 - import sys - import time - for char in content: - sys.stdout.write(char) - sys.stdout.flush() - # 根据字符类型调整延迟时间 - if char in [',', '。', '!', '?', ',', '.', '!', '?', '\n', '\r']: - # 标点符号和换行符延迟稍长 - time.sleep(0.1) - else: - # 其他字符延迟较短 - time.sleep(VIDEO_FRAME_DELAY) # 使用常量 - print() # 确保最后换行 - return "" # 因为内容已经逐字显示,所以返回空字符串 - except UnicodeDecodeError: - return "错误:about.txt 文件编码格式不支持。" - except Exception as e: - return f"读取 about.txt 文件时发生错误: {str(e)}" - - def _parse_mixed_response(self, response): - """ - 解析混合响应,处理同时包含文本和JSON指令的内容 - 返回格式: (text_content, json_data) - 其中text_content是普通文本部分,json_data是解析出的JSON指令(如果存在) - json_data可以是单个dict,也可以是dict列表(批量工具调用) - """ - import re - import json as json_module - response = response.strip() - - # 方法0: 处理 ... 格式(缺少开头的 {) - if response.startswith(""): - # 移除 标签,添加缺失的 { - json_str = response[11:] # 移除 - json_str = "{" + json_str # 添加缺失的 { - try: - parsed = json_module.loads(json_str) - if isinstance(parsed, dict): - return "", parsed - except json_module.JSONDecodeError: - # 如果还是失败,尝试更多修复 - pass - - # 方法1: 尝试直接解析为纯JSON - try: - parsed = json_module.loads(response) - if isinstance(parsed, list): - return "", parsed - elif isinstance(parsed, dict): - return "", parsed - except json_module.JSONDecodeError: - pass - - # 方法2: 尝试修复JSON中的控制字符问题 - try: - def fix_json_string(match): - content = match.group(1) - content = content.replace('\\', '\\\\') - content = content.replace('\n', '\\n') - content = content.replace('\r', '\\r') - content = content.replace('\t', '\\t') - content = content.replace('"', '\\"') - return f'"{content}"' - fixed_response = re.sub(r'"([^"]*(?:\n[^"]*)*)"', fix_json_string, response, flags=re.DOTALL) - parsed = json_module.loads(fixed_response) - if isinstance(parsed, list): - return "", parsed - elif isinstance(parsed, dict): - return "", parsed - except: - pass - - # 方法3: 查找代码块中的JSON - json_pattern = r"```(?:json)?\s*({.*?})\s*```" - matches = re.findall(json_pattern, response, re.DOTALL) - if matches: - try: - json_data = json_module.loads(matches[-1]) - if isinstance(json_data, (dict, list)): - text_content = re.sub(json_pattern, "", response, flags=re.DOTALL).strip() - text_content = re.sub(r"\n\s*\n", "\n\n", text_content).strip() - return text_content, json_data - except json_module.JSONDecodeError: - pass - - # 方法4: 查找每行独立的JSON对象 - lines_resp = response.split("\n") - json_objects = [] - text_lines = [] - for line in lines_resp: - line = line.strip() - if line.startswith("{") and line.endswith("}"): - try: - parsed = json_module.loads(line) - if isinstance(parsed, dict): - json_objects.append(parsed) - continue - except json_module.JSONDecodeError: - pass - text_lines.append(line) - - if json_objects: - if len(json_objects) == 1: - text_content = "\n".join(text_lines).strip() - return text_content, json_objects[0] - else: - text_content = "\n".join(text_lines).strip() - return text_content, json_objects - - # 方法5: 查找内联的JSON对象(使用计数器匹配嵌套括号) - # 查找 {"action": "use_tool" 或 {"action": "continue" 或 {"action":"use_tool" 或 {"action":"continue" - json_start_markers = [ - '{"action": "use_tool"', '{"action": "continue"', - '{"action":"use_tool"', '{"action":"continue"', - '{ "action": "use_tool"', '{ "action": "continue"', - '{ "action":"use_tool"', '{ "action":"continue"' - ] - for marker in json_start_markers: - json_start = response.find(marker) - if json_start != -1: - brace_count = 0 - in_json = False - - for i in range(json_start, len(response)): - char = response[i] - if char == '{': - if not in_json: - in_json = True - brace_count += 1 - elif char == '}': - brace_count -= 1 - if in_json and brace_count == 0: - json_str = response[json_start:i+1] - try: - json_data = json_module.loads(json_str) - if isinstance(json_data, dict) and json_data.get('action') in ['use_tool', 'continue']: - text_content = response[:json_start].strip() - return text_content, json_data - except json_module.JSONDecodeError: - pass - break - - # 方法6: 尝试查找任何以{开头并以}结尾的JSON对象(跨行) - # 使用更通用的方法:找到所有可能的JSON对象 - potential_jsons = re.findall(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', response, re.DOTALL) - for json_str in potential_jsons: - try: - json_data = json_module.loads(json_str) - if isinstance(json_data, dict) and json_data.get('action') in ['use_tool', 'continue']: - json_start = response.find(json_str) - text_content = response[:json_start].strip() - return text_content, json_data - except json_module.JSONDecodeError: - continue - - # 方法7: 如果是连续多个JSON对象(批量调用) - # 尝试解析整个响应为JSON数组 - try: - # 清理可能的文本前缀 - cleaned = response.strip() - # 找到最后一个完整的JSON对象开始(支持use_tool和continue) - for action in ['use_tool', 'continue']: - last_brace_pos = cleaned.rfind(f'{{"action": "{action}"') - if last_brace_pos == -1: - last_brace_pos = cleaned.rfind(f'{{"action":"{action}"') - if last_brace_pos != -1: - # 从这个位置开始提取所有JSON对象 - json_part = cleaned[last_brace_pos:] - # 尝试解析为JSON数组 - if json_part.startswith('['): - parsed = json_module.loads(json_part) - if isinstance(parsed, list): - text_content = cleaned[:last_brace_pos].strip() - return text_content, parsed - # 尝试解析为单个JSON - else: - parsed = json_module.loads(json_part) - if isinstance(parsed, dict): - text_content = cleaned[:last_brace_pos].strip() - return text_content, parsed - except: - pass - - # 如果都没有找到JSON,返回原始响应作为文本 - return response, None - - def start_video_player(self): - """启动视频播放窗口""" - # 在新线程中启动视频播放,避免阻塞主程序 - video_thread = threading.Thread(target=self._show_video_window, daemon=True) - video_thread.start() - - def _show_video_window(self): - """显示视频播放窗口""" - try: - # 创建tkinter窗口 - root = tk.Tk() - # 设置为无边框窗口 - root.overrideredirect(True) - # 获取屏幕尺寸 - screen_width = root.winfo_screenwidth() - screen_height = root.winfo_screenheight() - # 获取视频文件路径 - video_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Video_1765688252672.mp4") - if not os.path.exists(video_path): - # 如果视频文件不存在,显示提示信息 - # 设置一个较小的窗口用于显示错误信息 - error_width, error_height = 400, 100 - x = (screen_width // 2) - (error_width // 2) - y = (screen_height // 2) - (error_height // 2) - root.geometry(f"{error_width}x{error_height}+{x}+{y}") - canvas = tk.Canvas(root, width=error_width, height=error_height) - canvas.pack(fill=tk.BOTH, expand=True) - canvas.create_text(error_width//2, error_height//2, text=f"视频文件不存在:\n{video_path}", fill="red", font=("Arial", 12)) - root.mainloop() - return - # 打开视频文件 - cap = cv2.VideoCapture(video_path) - if not cap.isOpened(): - error_width, error_height = 400, 100 - x = (screen_width // 2) - (error_width // 2) - y = (screen_height // 2) - (error_height // 2) - root.geometry(f"{error_width}x{error_height}+{x}+{y}") - canvas = tk.Canvas(root, width=error_width, height=error_height) - canvas.pack(fill=tk.BOTH, expand=True) - canvas.create_text(error_width//2, error_height//2, text="无法打开视频文件", fill="red", font=("Arial", 12)) - root.mainloop() - return - # 获取视频的原始尺寸 - video_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) - video_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) - fps = int(cap.get(cv2.CAP_PROP_FPS)) - # 计算窗口大小,保持原始比例,最大不超过屏幕的40% (之前是80%) - max_width = int(screen_width * 0.4) - max_height = int(screen_height * 0.4) - # 计算缩放比例 - scale = min(max_width / video_width, max_height / video_height) - window_width = int(video_width * scale) - window_height = int(video_height * scale) - # 居中定位 - x = (screen_width // 2) - (window_width // 2) - y = (screen_height // 2) - (window_height // 2) - root.geometry(f"{window_width}x{window_height}+{x}+{y}") - # 设置窗口为透明(仅在Windows上有效) - root.wm_attributes("-transparentcolor", "black") - # 创建画布用于显示视频帧 - canvas = tk.Canvas(root, width=window_width, height=window_height, highlightthickness=0, bg='black') - canvas.pack(fill=tk.BOTH, expand=True) - - def update_frame(): - nonlocal cap # 确保使用外层的cap变量 - try: - ret, frame = cap.read() - if ret: - # 保持原始比例缩放,使用更好的插值方法 - frame = cv2.resize(frame, (window_width, window_height), interpolation=cv2.INTER_CUBIC) - # 将BGR转换为RGB - frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - # 实现黑色背景抠图 - 将黑色像素改为指定的透明色 - lower_black = np.array([0, 0, 0]) - upper_black = np.array([30, 30, 30]) - mask = cv2.inRange(frame_rgb, lower_black, upper_black) - # 创建透明色(白色或黑色区域,会被tkinter视为透明) - frame_rgb[mask > 0] = [0, 0, 0] # 黑色区域保持黑色,会被视为透明 - # 将numpy数组转换为PhotoImage - img = tk.PhotoImage(data=cv2.imencode('.ppm', frame_rgb)[1].tobytes()) - canvas.delete("all") # 清除画布 - canvas.create_image(0, 0, anchor=tk.NW, image=img) - # 保持对图片的引用,防止被垃圾回收 - canvas.image = img - # 继续播放下一帧 - # 使用视频原始FPS - delay = int(1000 / fps) if fps > 0 else 33 # 默认约30fps - root.after(delay, update_frame) - else: - # 视频播放完毕,开始渐隐效果 - cap.release() - self._fade_out(root) # 调用渐隐方法 - except tk.TclError: - # 处理tkinter相关的错误,如窗口被意外关闭 - cap.release() - try: - root.destroy() - except: - pass - except Exception as e: - print(f"更新视频帧时出错: {e}") - cap.release() - try: - root.destroy() - except: - pass - # 开始播放视频 - update_frame() - # 运行窗口 - root.mainloop() - except Exception as e: - print(f"播放视频时出错: {e}") - # 确保在出错时释放资源 - try: - if 'cap' in locals() and cap.isOpened(): - cap.release() - except: - pass - # 确保窗口被正确关闭 - try: - if 'root' in locals(): - root.after(100, root.destroy) # 延迟关闭窗口 - except: - pass - - def _fade_out(self, root, duration=1000): - """实现窗口渐隐效果""" - # 获取当前透明度,如果没有设置则默认为1.0(完全不透明) - try: - alpha = float(root.wm_attributes("-alpha")) - except: - alpha = 1.0 - # 每次减少0.1的透明度,实现渐隐效果 - alpha -= 0.1 - if alpha <= 0: - # 透明度降到0,关闭窗口 - try: - root.destroy() - except: - pass # 窗口可能已经被销毁 - else: - # 设置新的透明度 - root.wm_attributes("-alpha", alpha) - # 继续渐隐效果,每50毫秒执行一次 - root.after(50, self._fade_out, root, duration) - - -# ============================================================================ -# Main Entry Point - CLI Mode -# ============================================================================ if __name__ == "__main__": - import time - import sys - - # 启动时逐行输出艺术字 - art_lines = [ - "█████████████████████████████████████████████████████████████████████████████████████████████████████", - "█████████████████████████████████████████████████████████████████████████████████████████████████████", - "█████████████████████████████████████████████████████████████████████████████████████████████████████", - "█████████████████████████████████████████████████████████████████████████████████████████████████████", - "█████████████████████████████████████████████████████████████████████████████████████████████████████", - "███████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓██████████████████", - "██████████ ▓████████████████", - "██████████ ▒███████████████", - "██████████ ░██████████████", - "██████████ ░█████████████", - "██████████ ████████████", - "██████████ ███████████", - "██████████ ▒████████████████████████████████████████████████████", - "██████████ ░██████████████████████████████████████████████████████", - "██████████ ████████████████████████████████████████████████████████", - "██████████ ▓█████████████████████████████████████████████████████████", - "██████████ ▒███████████████████████████████████████████████████████████", - "██████████ ███████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓██████████", - "██████████ ███████████▓▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒██████████", - "██████████ ▓██████████▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▓██████████", - "██████████ ░███████████▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▒██████████", - "██████████ ███████████▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▒▓▓▒██████████", - "██████████ ███████████▓▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▓██████████", - "██████████ ▓██████████▓▒▒▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▓██████████", - "██████████ ░██████████▓▒▒▓▒▓▒▒▓▒▒▒▒▓▒▒▓▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▒▓▓██████████", - "██████████ ███████████▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▒▒▒▒▓▒▓▒▓██████████", - "██████████ ███████████▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▓▒▓▒▓▒▒▒▒▓▒▒▒▓▒▒▒▒▒▓▒▒▓▒▒▒▒▒▓▒▓▒▓▒▒▒▓▓██████████", - "██████████ ░██████████▒▓▒▓▒▓▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▓▒▒▒▒▓▒▓▒▓▒▒▓▒▒▒▓▒▒▒██████████", - "██████████ █████████▓▒▒▒▓▒▒▓▒▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒██████████", - "██████████ █████████▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▓██████████", - "██████████ ▓████████▒▓▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▓▒▒▓▒▒▓▓▒▒▒██████████", - "██████████ █████████▒▒▒▒▓▒▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▓▒▒▓▒▓▒▒▒▓▒▓▒▒▓▒▒▒▒▒▓▒██████████", - "██████████ █████████▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▓▒▒▓▒▓▒▒▓▓▒▒▒██████████", - "██████████ ▓████████▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▓▒▒▒▓▒▓▒▒▓▓▒▒▒▓██████████", - "██████████ █████████▒▓▒▒▒▒▓▒▒▒▓▒▓▒▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▒▒▓▒▓▒▒▓▓▒▒▒██████████", - "██████████ █████████▒▒▓▒▓▒▒▓▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▓▒▒▓▒▓▒▒▓▒▓▒▒▒▓▒▓▒▒▓▓▒▒▒██████████", - "██████████ ▓████████▒▓▒▒▒▓▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▓▒▒▒▒▓▒▓▒▒▒▓▒▓██████████", - "██████████ █████████▒▒▓▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓██████████", - "██████████ █████████▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▒██████████", - "██████████ ▓████████▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▒▒▓▒▓▒▒▓▒▒▓▓▒▒██████████", - "██████████ █████████▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▓██████████", - "██████████ █████████▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▒██████████", - "██████████ ▓████████▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▒▓▒▒▓▒▒▒▓▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▓▒▒▓▒▓▒▒▓██████████", - "██████████ █████████▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓██████████", - "██████████ █████████▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▒▒▒▓██████████", - "██████████ ▓████████▒▒▓▒▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▓▒▒▓▒▓▒▒▓▒▓▒▒▒▓▒▓▒▒▓▒▓▓▓▒▒▒██████████", - "███████████ █████████▒▓▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▓▒▒▒▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒██████████", - "█████████████▓ █████████▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▓▒▒▒██████████", - "█████████████████ ▓████████▒▓▒▒▓▒▒▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▓▓▒▒██████████", - "███████████████████▒ █████████▒▒▓▒▒▒▓▒▒▓▒▒▓▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓██████████", - "█████████████████████████████████████████████████████████████████████████████████████████████████████", - "█████████████████████████████████████████████████████████████████████████████████████████████████████", - "█████████████████████████████████████████████████████████████████████████████████████████████████████", - "█████████████████████████████████████████████████████████████████████████████████████████████████████", - "█████████████████████████████████████████████████████████████████████████████████████████████████████", - "█████████████████████████████████████████████████████████████████████████████████████████████████████" - ] - - # 设置标准输出编码为 UTF-8 - if sys.platform == 'win32': - import codecs - sys.stdout = codecs.getwriter('utf-8')(sys.stdout.buffer, 'strict') - - for line in art_lines: - try: - print(line, flush=True) - except UnicodeEncodeError: - print(line.encode('gbk', errors='ignore').decode('gbk'), flush=True) - time.sleep(0.05) - - # 运行主程序 - cli = AICLI() - cli.run() + main() diff --git a/xcli_core/__init__.py b/xcli_core/__init__.py new file mode 100644 index 0000000..53caf7c --- /dev/null +++ b/xcli_core/__init__.py @@ -0,0 +1,35 @@ +""" +xcli_core - 小狸 Pro-CLI 核心模块 + +从 ai_cli.py 拆分优化的模块化架构。 +""" + +from .constants import * +from .sandbox import * +from .config import * +from .plugin_manager import LiuginManager +from .cli_core import AICLI, main + + +def ask_user_confirmation(prompt="确认执行?", default=False): + """请求用户确认(兼容性存根,供插件调用)""" + try: + suffix = " [Y/n] " if default else " [y/N] " + response = input(prompt + suffix).strip().lower() + if not response: + return default + return response in ('y', 'yes', '是', '确认') + except (EOFError, KeyboardInterrupt): + return False + + +__all__ = [ + 'AICLI', + 'LiuginManager', + 'main', + 'load_config', + 'save_config', + 'get_system_config', + 'set_system_config', + 'ask_user_confirmation', +] diff --git a/xcli_core/cli_base.py b/xcli_core/cli_base.py new file mode 100644 index 0000000..4936e91 --- /dev/null +++ b/xcli_core/cli_base.py @@ -0,0 +1,389 @@ +import os +import sys +import importlib.util +import uuid +import shutil +from colorama import init, Fore, Style + +from .constants import ( + DEFAULT_MAX_HISTORY, WEBSOCKET_AVAILABLE, CLAWLI_SERVER_AVAILABLE, + UNIFIED_TOOL_MANAGER_AVAILABLE, TEXTUAL_AVAILABLE, +) +from .config import get_system_config, set_system_config, logger +from .plugin_manager import LiuginManager + +# 可选导入 +try: + import clawli_server +except ImportError: + clawli_server = None + +try: + from unified_tool_manager import UnifiedToolManager +except ImportError: + UnifiedToolManager = None + + +class BaseAICLI: + """AI CLI 基类 - 初始化、引擎/插件加载、资源管理""" + + def __init__(self): + # 初始化colorama + init(autoreset=True) + + # 确保插件管理器使用正确的路径 + project_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + liugins_dir = os.path.join(project_dir, "plugins") + skills_dir = os.path.join(project_dir, "skills") + + # 使用统一工具管理器(支持 Liugin 和 Skill 双协议) + if UNIFIED_TOOL_MANAGER_AVAILABLE: + self.liugin_manager = UnifiedToolManager(self) + self._use_unified_manager = True + else: + self.liugin_manager = LiuginManager(liugins_dir) + self._use_unified_manager = False + + # 初始化引擎字典(先初始化为空字典) + self.engines = {} + + # 初始化插件命令注册表 + self.liugin_commands = {} + + # 初始化系统引擎命令注册表 + self.engine_commands = {} + # 注册默认的引擎命令 + self._register_engine_commands() + # 动态加载AI引擎插件 + self.load_ai_engines() + # 从配置文件获取默认引擎设置 + default_engine_name = self._get_default_engine_name() + # 设置默认引擎,优先使用配置文件指定的引擎 + if default_engine_name in self.engines: + self.current_engine = self.engines[default_engine_name] + print(f"{Fore.GREEN}使用配置的默认AI引擎: {default_engine_name}{Style.RESET_ALL}") + elif self.engines: + first_engine_name = next(iter(self.engines)) + self.current_engine = self.engines[first_engine_name] + print(f"{Fore.GREEN}使用默认AI引擎: {first_engine_name}{Style.RESET_ALL}") + else: + print(f"{Fore.RED}警告: 没有可用的AI引擎,请检查ai_engines目录{Style.RESET_ALL}") + self.current_engine = None + if self.current_engine: + self.current_engine.cli = self # 设置引用以便访问插件 + + # 创建聊天记录目录 + self.chat_history_dir = os.path.join(project_dir, "chat_history") + if not os.path.exists(self.chat_history_dir): + os.makedirs(self.chat_history_dir) + + # 获取 skills 目录路径 + skills_dir = os.path.join(project_dir, "skills") + self.load_liugins(liugins_dir, skills_dir) + # 初始化共享对话历史 + self.shared_conversation_history = [] + # 设置最大历史记录数 + self.max_history = DEFAULT_MAX_HISTORY + # 为所有已加载的引擎设置共享对话历史 + self._set_shared_conversation_history() + # 初始化代码执行相关功能 + self.code_execution_enabled = True + self.code_execution_timeout = 30 # 默认超时30秒 + self.code_execution_memory_limit = 128 * 1024 * 1024 # 128MB内存限制 + self.code_execution_whitelist = [ + 'json', 'math', 'datetime', 'random', 'collections', + 'itertools', 'functools', 'operator', 'typing', 're' + ] + self.code_execution_history = [] + self.liugin_code_requests = {} + self.user_input_queue = {} + self.is_clawli_mode = False # Clawli 远程模式标志 + self._clawli_monitor_thread = None + self._clawli_monitor_running = False + # TUI 输出回调(用于 TUI 模式下的实时输出) + self.tui_output_callback = None + # 初始化用户ID(基于机器硬件动态生成) + self.user_id = str(uuid.getnode()) + + # ── 资源清理 ── + + def cleanup_resources(self): + """清理资源,确保在程序退出时正确关闭所有资源""" + print(f"{Fore.YELLOW}正在清理资源...{Style.RESET_ALL}") + self._stop_clawli_monitor() + if CLAWLI_SERVER_AVAILABLE and clawli_server: + clawli_server.stop_server() + print(f"{Fore.GREEN}资源清理完成{Style.RESET_ALL}") + + def __del__(self): + """析构函数,确保资源被清理""" + self.cleanup_resources() + + # ── 引擎管理 ── + + def _get_default_engine_name(self): + """从配置文件获取默认引擎名称""" + try: + default_engine = get_system_config('default_engine') + if default_engine: + return default_engine + return 'ollama' + except Exception as e: + print(f"{Fore.YELLOW}读取引擎配置失败,使用默认引擎: {e}{Style.RESET_ALL}") + return 'ollama' + + def load_ai_engines(self): + """动态加载AI引擎插件""" + print(f"{Fore.GREEN}正在加载AI引擎插件...{Style.RESET_ALL}") + ai_engines_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ai_engines") + if os.path.exists(ai_engines_dir): + for filename in os.listdir(ai_engines_dir): + if filename.endswith('.py') and filename != '__init__.py': + engine_path = os.path.join(ai_engines_dir, filename) + engine_name = filename[:-3] + 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_instance.cli = self + 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}AI引擎插件 {filename} 中未找到合适的引擎类{Style.RESET_ALL}") + except (ImportError, AttributeError, TypeError) as e: + logger.error(f"加载AI引擎插件失败 {filename}: {e}") + print(f"{Fore.RED}加载AI引擎插件失败 {filename}: {e}{Style.RESET_ALL}") + except FileNotFoundError as e: + logger.error(f"AI引擎liugin文件未找到 {filename}: {e}") + print(f"{Fore.RED}AI引擎liugin文件未找到 {filename}: {e}{Style.RESET_ALL}") + except Exception as e: + logger.error(f"加载AI引擎插件时发生未知错误 {filename}: {e}") + print(f"{Fore.RED}加载AI引擎插件失败 {filename}: {e}{Style.RESET_ALL}") + print(f"{Fore.GREEN}总共加载了 {len(self.engines)} 个AI引擎{Style.RESET_ALL}") + + def _find_engine_class(self, module, engine_name): + """查找AI引擎类,使用动态方式避免硬编码类名""" + possible_names = [ + engine_name.capitalize() + "AI", + engine_name.replace('_', '').replace('-', '').capitalize() + "AI", + engine_name.replace('_', '').capitalize() + "AI", + engine_name.replace('_engine', '').capitalize() + "AI", + engine_name.replace('-', '').replace('_', '').capitalize() + "AI", + engine_name.replace('-', '').capitalize() + "AI", + ''.join(word.capitalize() for word in engine_name.replace('-', ' ').replace('_', ' ').split()) + "AI", + ''.join(word.capitalize() for word 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 (hasattr(attr, '__class__') and + isinstance(attr, type) and + attr_name.endswith('AI') and + hasattr(attr, 'generate_response')): + return attr_name + return None + + def load_liugins(self, liugins_dir=None, skills_dir=None): + """加载liugin和技能""" + print(f"{Fore.GREEN}正在加载工具...{Style.RESET_ALL}") + + if self._use_unified_manager: + self.liugin_manager.initialize(liugins_dir or "plugins", skills_dir or "skills") + else: + self.liugin_manager.tools = [] + enabled_liugins = self.liugin_manager.get_enabled_liugins() + plugins_path = liugins_dir or self.liugin_manager.liugins_dir + + if os.path.exists(plugins_path): + for filename in os.listdir(plugins_path): + if filename.endswith('.py') and filename != '__init__.py': + liugin_name = filename[:-3] + if enabled_liugins is not None and liugin_name.lower() not in enabled_liugins: + print(f"{Fore.YELLOW}跳过liugin: {liugin_name} (未启用){Style.RESET_ALL}") + continue + plugin_path = os.path.join(plugins_path, filename) + try: + spec = importlib.util.spec_from_file_location(liugin_name, plugin_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + plugin_class = getattr(module, 'Liugin', None) or getattr(module, 'Plugin', None) + if plugin_class: + plugin_instance = plugin_class() + if hasattr(plugin_instance, 'set_cli'): + plugin_instance.set_cli(self) + tool_info = plugin_instance.get_tool_info() + tool_info['handler'] = plugin_instance.handle + self.liugin_manager.tools.append(tool_info) + print(f"{Fore.GREEN}已加载liugin: {liugin_name}{Style.RESET_ALL}") + except Exception as e: + print(f"{Fore.RED}加载liugin失败 {filename}: {e}{Style.RESET_ALL}") + print(f"{Fore.GREEN}总共加载了 {len(self.liugin_manager.tools)} 个工具插件{Style.RESET_ALL}") + + print(f"{Fore.GREEN}工具加载完成!{Style.RESET_ALL}") + + # ── 命令注册 ── + + def register_liugin_command(self, command, handler): + """注册插件命令""" + self.liugin_commands[command] = handler + print(f"{Fore.GREEN}已注册插件命令: {command}{Style.RESET_ALL}") + + def unregister_liugin_command(self, command): + """注销插件命令""" + if command in self.liugin_commands: + del self.liugin_commands[command] + print(f"{Fore.GREEN}已注销插件命令: {command}{Style.RESET_ALL}") + + def _register_engine_commands(self): + """注册默认引擎命令""" + self.engine_commands['list'] = self._handle_engine_list + self.engine_commands['switch'] = self._handle_engine_switch + + def register_engine_command(self, command, handler): + """注册引擎命令""" + self.engine_commands[command] = handler + print(f"{Fore.GREEN}已注册引擎命令: {command}{Style.RESET_ALL}") + + def unregister_engine_command(self, command): + """注销引擎命令""" + if command in self.engine_commands: + del self.engine_commands[command] + print(f"{Fore.GREEN}已注销引擎命令: {command}{Style.RESET_ALL}") + + def _handle_engine_list(self, args): + """处理引擎列表命令""" + print(f"{Fore.GREEN}可用的AI引擎:{Style.RESET_ALL}") + for engine_name in self.engines.keys(): + status = " (当前使用)" if engine_name == getattr(self.current_engine, 'name', 'spark') else "" + print(f"{Fore.GREEN} - {engine_name}{status}{Style.RESET_ALL}") + + def _handle_engine_switch(self, args): + """处理引擎切换命令""" + if args: + engine_name = args.strip() + self.switch_engine(engine_name) + else: + print(f"{Fore.RED}请提供引擎名称.用法: /engine switch <引擎名>{Style.RESET_ALL}") + + def _get_engine_commands_help(self): + """获取引擎命令帮助信息""" + help_lines = [] + for cmd, handler in self.engine_commands.items(): + if cmd == 'list': + help_lines.append(f" /engine {cmd:<10} - 查看可用的AI引擎") + elif cmd == 'switch': + help_lines.append(f" /engine {cmd:<10} - 切换AI引擎") + else: + help_lines.append(f" /engine {cmd:<10} - 执行{cmd}命令") + return '\n'.join(help_lines) if help_lines else " 无可用引擎管理命令" + + # ── 引擎切换 ── + + def get_available_engines(self): + """获取可用引擎列表""" + return list(self.engines.keys()) + + def get_current_engine_name(self): + """获取当前引擎名称""" + return getattr(self.current_engine, 'name', '未设置') if self.current_engine else '未设置' + + def get_liugin_manager(self): + """获取插件管理器""" + return self.liugin_manager + + def switch_engine(self, engine_name): + """切换AI引擎""" + if engine_name in self.engines: + old_engine = self.current_engine + self.current_engine = self.engines[engine_name] + + if hasattr(self.current_engine, 'cli'): + self.current_engine.cli = self + + if hasattr(self.current_engine, 'shared_conversation_history'): + self.current_engine.shared_conversation_history = self.shared_conversation_history + self.current_engine.max_history = self.max_history + + if old_engine != self.current_engine and self.shared_conversation_history: + print(f"{Fore.CYAN}已切换到AI引擎: {engine_name},正在传递对话历史...{Style.RESET_ALL}") + + context_message = { + "role": "system", + "content": f"引擎切换通知:现在由{engine_name}引擎继续之前的对话。以下是之前的对话历史:\n" + } + + if self.shared_conversation_history: + print(f"{Fore.YELLOW}当前对话历史已传递给新引擎:{Style.RESET_ALL}") + for msg in self.shared_conversation_history[-self.max_history:]: + if isinstance(msg, dict) and "role" in msg and "content" in msg: + role = msg["role"] + content = msg["content"] + if role == "user": + print(f"{Fore.CYAN}用户: {content}{Style.RESET_ALL}") + elif role == "assistant": + print(f"{Fore.GREEN}AI: {content}{Style.RESET_ALL}") + elif role == "tool": + print(f"{Fore.MAGENTA}工具结果: {content}{Style.RESET_ALL}") + else: + print(f"{Fore.GREEN}已切换到AI引擎: {engine_name}{Style.RESET_ALL}") + + try: + set_system_config('default_engine', engine_name) + print(f"{Fore.CYAN}已将 {engine_name} 设置为默认引擎{Style.RESET_ALL}") + except Exception as e: + print(f"{Fore.YELLOW}警告: 保存默认引擎配置失败: {e}{Style.RESET_ALL}") + else: + print(f"{Fore.RED}未找到AI引擎: {engine_name}{Style.RESET_ALL}") + + def _set_shared_conversation_history(self): + """为所有引擎设置共享对话历史""" + for engine in self.engines.values(): + engine.shared_conversation_history = self.shared_conversation_history + engine.max_history = self.max_history + + def handle_engine_command(self, command): + """处理引擎特定命令""" + if not hasattr(self.current_engine, 'handle_command'): + return False + engine_name = getattr(self.current_engine, 'name', '') + if engine_name and command.startswith(engine_name + ' '): + actual_command = command[len(engine_name) + 1:].strip() + return self.current_engine.handle_command(actual_command) + return self.current_engine.handle_command(command) + + # ── 插件消息处理 ── + + def handle_liugin_message(self, message: str, **kwargs) -> str: + """处理插件转发的消息(如 QQ 群 @ 消息)""" + try: + print(f"{Fore.CYAN}[插件消息] {message[:100]}...{Style.RESET_ALL}") + + system_prompt = """你是一个智能助手,正在通过 QQ 机器人与用户交流。 +请简洁、友好地回复用户的消息。回复时注意: +1. 回复要简洁明了,适合 QQ 群聊场景 +2. 不要提及你是 AI 或机器人 +3. 直接回答问题,不要加多余的前缀""" + + if self.current_engine: + response = self.current_engine.generate_response(message, system_prompt=system_prompt) + if response: + print(f"{Fore.GREEN}[AI 回复] {response[:50]}...{Style.RESET_ALL}") + return response + else: + return "抱歉,我暂时无法回复。" + else: + print(f"{Fore.RED}[插件消息] 没有可用的 AI 引擎{Style.RESET_ALL}") + return "AI 引擎未初始化" + + except Exception as e: + print(f"{Fore.RED}[插件消息] 处理失败: {e}{Style.RESET_ALL}") + return f"处理消息时出错: {e}" diff --git a/xcli_core/cli_clawli.py b/xcli_core/cli_clawli.py new file mode 100644 index 0000000..3e9daa1 --- /dev/null +++ b/xcli_core/cli_clawli.py @@ -0,0 +1,320 @@ +import threading +import time +from colorama import Fore, Style + +from .constants import CLAWLI_SERVER_AVAILABLE, WEBSOCKET_AVAILABLE + +try: + import clawli_server +except ImportError: + clawli_server = None + +try: + import websocket_server +except ImportError: + websocket_server = None + + +class ClawliMixin: + """Clawli 远程模式相关方法""" + + def _start_clawli_monitor(self): + """启动 Clawli 消息监控线程""" + if self._clawli_monitor_running: + return + + self._clawli_monitor_running = True + print(f"{Fore.CYAN}[Clawli] 消息监控线程已启动{Style.RESET_ALL}") + + def monitor_loop(): + while self._clawli_monitor_running and self.is_clawli_mode: + try: + if CLAWLI_SERVER_AVAILABLE and clawli_server: + messages = clawli_server.get_messages() + if messages: + print(f"[Clawli Monitor] 收到 {len(messages)} 条消息") + for msg in messages: + self._handle_clawli_message(msg) + time.sleep(0.1) # 100ms 检查间隔 + except Exception as e: + print(f"[Clawli Monitor] 错误: {e}") + + self._clawli_monitor_thread = threading.Thread(target=monitor_loop, daemon=True) + self._clawli_monitor_thread.start() + + def _stop_clawli_monitor(self): + """停止 Clawli 消息监控线程""" + self._clawli_monitor_running = False + if self._clawli_monitor_thread: + self._clawli_monitor_thread.join(timeout=1) + self._clawli_monitor_thread = None + + def _handle_clawli_message(self, msg: dict): + """处理来自手机端的消息""" + try: + msg_type = msg.get("type", "") + + if msg_type == "message": + content = msg.get("content", "") + self._process_clawli_user_message(content) + + elif msg_type == "file": + file_path = msg.get("path", "") + text = msg.get("text", "") + category = msg.get("category", "file") + + if category == "image": + self._process_clawli_image(file_path, text) + else: + self._process_clawli_user_message(f"@{file_path}\n{text}") + + except Exception as e: + print(f"[Clawli] 处理消息错误: {e}") + + def _process_clawli_user_message(self, content: str): + """处理 Clawli 用户消息""" + if not self.current_engine: + return + + full_content = f"{content}\n\n[此消息由 Clawli 手机端发送]" + + self.shared_conversation_history.append({ + "role": "user", + "content": full_content + }) + + system_prompt = self._build_system_prompt(self.get_liugin_usage_prompts()) + response = self.current_engine.generate_response(full_content, system_prompt=system_prompt) + + self._process_clawli_response(response) + + def _process_clawli_response(self, response: str): + """处理 Clawli AI 响应""" + text_content, json_data = self._parse_mixed_response(response) + + if json_data: + tool_calls = self._extract_tool_calls(json_data) + + if tool_calls: + for tool_data in tool_calls: + tool_name = tool_data.get('tool', '未知工具') + tool_args = tool_data.get('args', '') + + if CLAWLI_SERVER_AVAILABLE and clawli_server: + clawli_server.send_tool_status(tool_name, tool_args, "calling") + + result = self.process_tool_call(tool_data) + result_text = result.get('result', '无结果') + + if CLAWLI_SERVER_AVAILABLE and clawli_server: + clawli_server.send_tool_status(tool_name, tool_args, "success", result_text) + + self.shared_conversation_history.append({ + "role": "assistant", + "content": response + }) + + tool_result_str = "\n".join([f"工具执行结果: {r}" for r in [result.get('result', '')]]) + self._process_clawli_user_message(f"{tool_result_str}\n请根据工具执行结果继续回答。") + return + + final_response = text_content if text_content else response + + self.shared_conversation_history.append({ + "role": "assistant", + "content": final_response + }) + + if CLAWLI_SERVER_AVAILABLE and clawli_server: + clawli_server.send_message(final_response) + + def _process_clawli_image(self, image_path: str, text: str): + """处理 Clawli 图片消息""" + try: + image_engine = getattr(self, 'image_engine', None) + if not image_engine: + try: + from image_engine import get_image_engine + image_engine = get_image_engine() + except: + image_engine = None + + if image_engine: + with open(image_path, 'rb') as f: + image_data = f.read() + + result = image_engine.analyze(image_data, text or "请描述这张图片") + self._process_clawli_user_message(f"@{image_path}\n图片内容: {result}\n\n{text}") + else: + self._process_clawli_user_message(f"@{image_path}\n{text}") + + except Exception as e: + self._process_clawli_user_message(f"@{image_path}\n图片处理失败: {e}\n{text}") + + def _handle_remote_command(self, args): + """处理远程连接命令""" + if not WEBSOCKET_AVAILABLE: + print(f"{Fore.RED}错误: websockets 库未安装{Style.RESET_ALL}") + print(f"{Fore.YELLOW}请运行: pip install websockets{Style.RESET_ALL}") + return + + args = args.strip() + + if not args or args == '': + print(f"\n{Fore.CYAN}{'='*55}{Style.RESET_ALL}") + print(f"{Fore.CYAN} 远程连接命令帮助{Style.RESET_ALL}") + print(f"{Fore.CYAN}{'='*55}{Style.RESET_ALL}") + print(f"\n{Fore.WHITE}本地模式 (局域网直连):{Style.RESET_ALL}") + print(f"{Fore.YELLOW} /remote start <端口> <密码>{Style.RESET_ALL}") + print(f"{Fore.WHITE} 端口: 监听端口 (默认9079){Style.RESET_ALL}") + print(f"{Fore.WHITE} 密码: PC密码 (安卓端需要输入){Style.RESET_ALL}") + print(f"\n{Fore.WHITE}代理模式 (无公网IP,通过中继服务器):{Style.RESET_ALL}") + print(f"{Fore.YELLOW} /remote proxy <服务器> <服务器密码> {Style.RESET_ALL}") + print(f"{Fore.WHITE} 服务器: 中继服务器地址:端口{Style.RESET_ALL}") + print(f"{Fore.WHITE} 服务器密码: 连接中继服务器的密码{Style.RESET_ALL}") + print(f"{Fore.WHITE} PC端口: 在服务器上使用的端口{Style.RESET_ALL}") + print(f"{Fore.WHITE} PC密码: 安卓端连接时需要验证{Style.RESET_ALL}") + print(f"\n{Fore.WHITE}其他命令:{Style.RESET_ALL}") + print(f"{Fore.WHITE} /remote stop - 停止服务{Style.RESET_ALL}") + print(f"{Fore.WHITE} /remote status - 查看状态{Style.RESET_ALL}") + print(f"{Fore.WHITE} /remote ip - 查看本机IP{Style.RESET_ALL}") + print(f"\n{Fore.CYAN}{'='*55}{Style.RESET_ALL}") + print(f"\n{Fore.GREEN}示例:{Style.RESET_ALL}") + print(f"{Fore.WHITE} /remote start 9079 mypassword{Style.RESET_ALL}") + print(f"{Fore.WHITE} /remote proxy relay.example.com:9079 serverpwd 12345 pcpwd{Style.RESET_ALL}") + print(f"{Fore.CYAN}{'='*55}{Style.RESET_ALL}\n") + return + + parts = args.split() + cmd = parts[0].lower() + + if cmd == 'start': + port = 9079 + password = "" + + if len(parts) == 2: + arg = parts[1] + if arg.isdigit(): + port = int(arg) + print(f"{Fore.RED}错误: 请设置PC密码{Style.RESET_ALL}") + print(f"{Fore.YELLOW}用法: /remote start {port} <密码>{Style.RESET_ALL}") + return + else: + password = arg + elif len(parts) >= 3: + try: + port = int(parts[1]) + password = parts[2] + except ValueError: + password = parts[1] + + if not password: + print(f"{Fore.RED}错误: 请设置PC密码{Style.RESET_ALL}") + print(f"{Fore.YELLOW}用法: /remote start <端口> <密码>{Style.RESET_ALL}") + return + + if len(password) < 4: + print(f"{Fore.RED}错误: 密码至少4个字符{Style.RESET_ALL}") + return + + if CLAWLI_SERVER_AVAILABLE and clawli_server: + success = clawli_server.start_server("0.0.0.0", port, password) + if success: + self.is_clawli_mode = True + self._start_clawli_monitor() + elif websocket_server: + websocket_server.start_local_server(self, port, password) + + elif cmd == 'proxy': + if len(parts) < 5: + print(f"{Fore.RED}参数不足{Style.RESET_ALL}") + print(f"{Fore.YELLOW}用法: /remote proxy <服务器> <服务器密码> {Style.RESET_ALL}") + print(f"{Fore.WHITE}示例: /remote proxy relay.example.com:9079 serverpwd 12345 pcpwd{Style.RESET_ALL}") + return + + server_addr = parts[1] + server_password = parts[2] + pc_port = 0 + pc_password = parts[4] if len(parts) > 4 else "" + + try: + pc_port = int(parts[3]) + if pc_port < 10000 or pc_port > 19999: + print(f"{Fore.YELLOW}建议端口范围: 10000-19999{Style.RESET_ALL}") + except ValueError: + print(f"{Fore.RED}无效的端口号: {parts[3]}{Style.RESET_ALL}") + return + + if not pc_password: + print(f"{Fore.RED}错误: 请设置PC密码{Style.RESET_ALL}") + return + + if len(pc_password) < 4: + print(f"{Fore.RED}错误: PC密码至少4个字符{Style.RESET_ALL}") + return + + if ':' in server_addr: + server_host, port_str = server_addr.rsplit(':', 1) + try: + server_port = int(port_str) + except ValueError: + print(f"{Fore.RED}无效的服务器端口: {port_str}{Style.RESET_ALL}") + return + else: + server_host = server_addr + server_port = 9079 + + print(f"{Fore.CYAN}正在连接中继服务器...{Style.RESET_ALL}") + if websocket_server: + websocket_server.start_proxy_client( + self, server_host, server_port, + server_password, pc_port, pc_password + ) + + elif cmd == 'stop': + if CLAWLI_SERVER_AVAILABLE and clawli_server: + clawli_server.stop_server() + self.is_clawli_mode = False + elif websocket_server: + websocket_server.stop_all() + print(f"{Fore.GREEN}远程服务已停止{Style.RESET_ALL}") + + elif cmd == 'status': + if CLAWLI_SERVER_AVAILABLE and clawli_server: + status = clawli_server.get_status() + elif websocket_server: + status = websocket_server.get_status() + else: + status = {} + + if status.get('running'): + mode = status.get('mode', 'local') + if mode == 'proxy': + print(f"{Fore.GREEN}远程服务状态: 运行中 (代理模式){Style.RESET_ALL}") + print(f"{Fore.WHITE}服务器: {Fore.YELLOW}{status.get('server', '未知')}{Style.RESET_ALL}") + print(f"{Fore.WHITE}PC端口: {Fore.YELLOW}{status.get('port', '未知')}{Style.RESET_ALL}") + else: + print(f"{Fore.GREEN}远程服务状态: 运行中 (本地模式){Style.RESET_ALL}") + print(f"{Fore.WHITE}本机IP: {Fore.YELLOW}{status.get('local_ip', '未知')}{Style.RESET_ALL}") + print(f"{Fore.WHITE}端口: {Fore.YELLOW}{status.get('port', 9079)}{Style.RESET_ALL}") + print(f"{Fore.WHITE}已连接客户端: {Fore.YELLOW}{status.get('connected_clients', 0)}{Style.RESET_ALL}") + else: + print(f"{Fore.YELLOW}远程服务状态: 未启动{Style.RESET_ALL}") + print(f"{Fore.WHITE}使用 {Fore.CYAN}/remote start <端口> <密码>{Fore.WHITE} 启动本地服务{Style.RESET_ALL}") + print(f"{Fore.WHITE}使用 {Fore.CYAN}/remote proxy ...{Fore.WHITE} 连接代理服务器{Style.RESET_ALL}") + + elif cmd == 'ip': + import socket + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + local_ip = s.getsockname()[0] + s.close() + print(f"{Fore.GREEN}本机IP地址: {Fore.YELLOW}{local_ip}{Style.RESET_ALL}") + print(f"{Fore.WHITE}在 Clawli 应用中输入此IP即可连接 (仅限局域网){Style.RESET_ALL}") + except Exception as e: + print(f"{Fore.RED}获取IP失败: {e}{Style.RESET_ALL}") + + else: + print(f"{Fore.RED}未知命令: {cmd}{Style.RESET_ALL}") + print(f"{Fore.WHITE}可用命令: start, proxy, stop, status, ip{Style.RESET_ALL}") diff --git a/xcli_core/cli_code_exec.py b/xcli_core/cli_code_exec.py new file mode 100644 index 0000000..e916a16 --- /dev/null +++ b/xcli_core/cli_code_exec.py @@ -0,0 +1,206 @@ +import os +import sys +import time +import json +import subprocess +import traceback +from datetime import datetime +from colorama import Fore, Style + +from .config import logger +from .sandbox import ( + _sandbox_checker, _make_sandbox_worker, _sandbox_work_dir, + _HAS_RESOURCE, _SAFE_WHITELIST, +) + +try: + import resource as _resource_mod +except ImportError: + _resource_mod = None + + +class CodeExecMixin: + """代码沙箱执行相关方法""" + + def request_code_execution(self, liugin_name, code, timeout=None, variables=None, context=None): + """ + 插件代码执行请求接口 + """ + if not self.code_execution_enabled: + return { + 'success': False, + 'result': '代码执行功能已禁用', + 'stdout': '', + 'stderr': '', + 'execution_time': 0, + 'memory_usage': 0 + } + + if not self._is_code_safe(code): + return { + 'success': False, + 'result': '代码包含潜在危险操作,已阻止执行', + 'stdout': '', + 'stderr': '', + 'execution_time': 0, + 'memory_usage': 0 + } + + exec_timeout = timeout if timeout is not None else self.code_execution_timeout + + execution_id = f"{liugin_name}_{int(time.time())}" + execution_record = { + 'id': execution_id, + 'liugin_name': liugin_name, + 'code': code, + 'timestamp': datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + 'context': context + } + + try: + result = self._execute_code_safely(code, exec_timeout, variables) + + execution_record.update({ + 'success': result['success'], + 'execution_time': result['execution_time'], + 'memory_usage': result['memory_usage'] + }) + + self.code_execution_history.append(execution_record) + if len(self.code_execution_history) > 100: + self.code_execution_history.pop(0) + + if result['success']: + logger.info(f"代码执行成功 - 插件: {liugin_name}, 耗时: {result['execution_time']:.3f}s") + else: + logger.warning(f"代码执行失败 - 插件: {liugin_name}, 错误: {result['result']}") + + return result + + except Exception as e: + error_msg = f"代码执行异常: {str(e)}" + logger.error(f"{error_msg} - 插件: {liugin_name}") + return { + 'success': False, + 'result': error_msg, + 'stdout': '', + 'stderr': error_msg, + 'execution_time': 0, + 'memory_usage': 0 + } + + def _is_code_safe(self, code): + """检查代码是否安全(使用 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): + """ + 在安全沙箱中执行代码(v2 子进程隔离) + """ + 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) + wl = getattr(self, 'code_execution_whitelist', None) or _SAFE_WHITELIST + whitelist_json = json.dumps(wl) + 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', + } + for key in ('TERM', 'COLUMNS', 'LINES', 'SHELL'): + val = os.environ.get(key) + if val: + safe_env[key] = val + + start_time = time.time() + try: + proc = subprocess.run( + [sys.executable, '-c', script], + capture_output=True, timeout=timeout, + cwd=_sandbox_work_dir, env=safe_env, + preexec_fn=CodeExecMixin._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': '', 'stderr': traceback.format_exc(), + 'execution_time': time.time() - start_time, 'memory_usage': 0 + } + + @staticmethod + def _sandbox_set_limits(): + """子进程资源限制""" + if not _HAS_RESOURCE or not _resource_mod: + 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): + """获取代码执行历史""" + return self.code_execution_history[-limit:] + + def clear_code_execution_history(self): + """清空代码执行历史""" + self.code_execution_history = [] + logger.info("代码执行历史已清空") + + def set_code_execution_timeout(self, timeout): + """设置代码执行超时时间""" + if timeout > 0: + self.code_execution_timeout = timeout + logger.info(f"代码执行超时时间已设置为 {timeout} 秒") + + def set_code_execution_whitelist(self, modules): + """设置允许导入的模块白名单""" + self.code_execution_whitelist = modules + logger.info(f"代码执行模块白名单已更新: {modules}") diff --git a/xcli_core/cli_core.py b/xcli_core/cli_core.py new file mode 100644 index 0000000..b8c8be0 --- /dev/null +++ b/xcli_core/cli_core.py @@ -0,0 +1,961 @@ +import os +import sys +import json +import re +import time +import shutil +import threading +import random +from colorama import Fore, Style + +from .constants import TEXTUAL_AVAILABLE, LOVE_FILE_PATH, DEFAULT_MAX_HISTORY +from .config import get_system_config, logger +from .cli_base import BaseAICLI +from .cli_clawli import ClawliMixin +from .cli_tools import ToolMixin +from .cli_code_exec import CodeExecMixin +from .cli_display import DisplayMixin +from .cli_history import HistoryMixin + + +class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, HistoryMixin): + """AI CLI主程序 - 组合所有 Mixin""" + + # ── 深度思考处理 ── + + def process_thinking_response(self, response): + """处理深度思考AI返回的内容, 分离思考和回复内容""" + try: + response_data = json.loads(response) + if isinstance(response_data, dict) and response_data.get('action') == 'use_tool': + return response + if isinstance(response_data, dict): + if 'answer' in response_data: + extracted_content = response_data['answer'] + elif 'response' in response_data: + extracted_content = response_data['response'] + elif 'content' in response_data: + extracted_content = response_data['content'] + elif 'text' in response_data: + extracted_content = response_data['text'] + else: + return json.dumps(response_data, ensure_ascii=False, indent=2) + return self._process_content_with_thinking(extracted_content) + except json.JSONDecodeError: + return self._process_content_with_thinking(response) + + def _process_content_with_thinking(self, content): + """处理包含思考标记的内容""" + thinking_start_marker = getattr(self.current_engine, 'thinking_start_marker', None) if self.current_engine else None + thinking_end_marker = getattr(self.current_engine, 'thinking_end_marker', None) if self.current_engine else None + + if '' in content: + parts = content.split('') + if len(parts) >= 2: + thinking_content = parts[0].strip() + reply_content = parts[1].strip() + if thinking_content: + print(f"{Fore.LIGHTBLACK_EX}{Style.DIM}[思考: {thinking_content}]{Style.RESET_ALL}") + return reply_content if reply_content else "" + + if thinking_start_marker and thinking_end_marker and thinking_start_marker in content and thinking_end_marker in content: + parts = content.split(thinking_start_marker) + if len(parts) > 1: + remaining = parts[1] + if thinking_end_marker in remaining: + thinking_part, reply_part = remaining.split(thinking_end_marker, 1) + thinking_content = thinking_part.strip() + reply_content = reply_part.strip() + if thinking_content: + print(f"{Fore.LIGHTBLACK_EX}{Style.DIM}[思考: {thinking_content}]{Style.RESET_ALL}") + return reply_content if reply_content else "" + + return content + + # ── 系统提示构建 ── + + def _build_system_prompt(self, liugin_prompts=None): + """构建系统提示词 - 增强版,支持编程任务""" + base_prompt = """你叫小狸,是一个强大的智能编程助手。你具备以下核心能力: + +1. **精准代码编辑** - 使用 code_editor 工具进行搜索替换、多文件批量编辑 +2. **代码搜索与理解** - 使用 code_search 工具跨文件搜索、提取符号、分析依赖 +3. **Git 版本控制** - 使用 git_tools 工具管理代码版本 +4. **Shell 命令执行** - 使用 cmd_executor 执行系统命令 +5. **文件管理** - 使用 file_manager 管理文件和目录 + +你在说话时可以适当加上'nyan'来表现可爱性格,但不要过度使用。""" + + has_tool_search = any(t.get('name') == 'tool_search' for t in self.liugin_manager.tools) + + tool_search_rule = "" + if has_tool_search: + tool_search_rule = """【工具查询规则】 +调用不熟悉的工具前,先用 tool_search 查询用法: +{"action": "use_tool", "tool": "tool_search", "args": "工具名"} +例外:ai_search、code_editor、code_search、git_tools 可以直接使用。""" + + if liugin_prompts: + prompt = f"""{base_prompt} + +{liugin_prompts} + +{tool_search_rule} + +【工具调用格式】 +在回复中包含 JSON 指令来执行操作: + +1. 纯 JSON: {{"action": "use_tool", "tool": "code_editor", "args": "edit file.py old <<<>>> new"}} +2. 文本+JSON: 好的,我来修改。{{"action": "use_tool", "tool": "code_editor", "args": "edit file.py old <<<>>> new"}} + +【编程工作流】 +1. 理解需求 → 用 code_search.structure 查看项目结构 +2. 定位代码 → 用 code_search.find 或 code_search.regex 搜索 +3. 查看上下文 → 用 code_editor.read_range 查看相关代码 +4. 精准修改 → 用 code_editor.edit 替换代码(old <<<>>> new 分隔) +5. 验证变更 → 用 git_tools.diff 查看变更 +6. 提交代码 → 用 git_tools.commit 提交 + +【code_editor 编辑格式】 +edit 操作使用 <<<>>> 分隔旧代码和新代码: +{{"action": "use_tool", "tool": "code_editor", "args": "edit path/to/file.py +def old_function(): + pass +<<<>>> +def new_function(): + return True"}} + +【批量编辑】 +multi 操作支持一次修改多处: +{{"action": "use_tool", "tool": "code_editor", "args": "multi path/to/file.py [{{\\"old\\": \\"old1\\", \\"new\\": \\"new1\\"}}, {{\\"old\\": \\"old2\\", \\"new\\": \\"new2\\"}}]"}} + +【搜索代码】 +- 关键词搜索: {{"action": "use_tool", "tool": "code_search", "args": "find . keyword *.py"}} +- 正则搜索: {{"action": "use_tool", "tool": "code_search", "args": "regex . def\\\\s+\\\\w+ *.py"}} +- 查看结构: {{"action": "use_tool", "tool": "code_search", "args": "structure ."}} +- 查找调用: {{"action": "use_tool", "tool": "code_search", "args": "callers . function_name"}} + +【Git 操作】 +- 查看状态: {{"action": "use_tool", "tool": "git_tools", "args": "status"}} +- 查看变更: {{"action": "use_tool", "tool": "git_tools", "args": "diff"}} +- 提交代码: {{"action": "use_tool", "tool": "git_tools", "args": "commit 描述信息"}} +- 查看历史: {{"action": "use_tool", "tool": "git_tools", "args": "log --oneline 10"}} + +【注意事项】 +- code_editor.edit 的 old_text 必须与文件中的内容完全匹配(包括缩进) +- 如果 edit 失败,先用 read_range 查看实际内容再重试 +- args 必须是字符串 +- 可以一次返回多个 JSON 指令 +- 使用 continue 补充回答: {{"action": "continue", "content": "补充内容"}}""" + else: + prompt = f"""{base_prompt} + +{tool_search_rule} + +【工具调用格式】 +{{"action": "use_tool", "tool": "工具名", "args": "参数"}} + +【核心工具】 +- code_editor: edit/search/diff/insert/create/write/read_range +- code_search: find/regex/symbols/imports/structure/stats +- git_tools: status/diff/log/add/commit/branch +- cmd_executor: 执行 shell 命令 +- file_manager: 文件管理 + +【code_editor.edit 格式】 +{{"action": "use_tool", "tool": "code_editor", "args": "edit 文件路径 +旧代码内容 +<<<>>> +新代码内容"}} + +【continue 格式】 +{{"action": "continue", "content": "要补充的内容"}}""" + + return prompt + + # ── JSON 解析 ── + + def _clean_json_response(self, response): + """清理AI响应中的Markdown代码块标记""" + if response.startswith("```json"): + response = response[7:] + elif response.startswith("```"): + response = response[3:] + if response.endswith("```"): + response = response[:-3] + return response.strip() + + def get_liugin_usage_prompts(self): + """获取插件和技能提示词""" + if not self.liugin_manager.tools: + return "当前没有可用的工具插件." + + prompts = [] + prompts.append("可用的工具列表:\n") + + plugins = [] + skills = [] + for tool in self.liugin_manager.tools: + protocol = tool.get('protocol', 'liugin') + if protocol == 'skill': + skills.append(tool) + else: + plugins.append(tool) + + if plugins: + prompts.append("## Plugin 工具(使用 tool_search 查询详细用法):") + for tool in plugins: + tool_name = tool.get('name', '') + tool_desc = tool.get('description', '') + prompts.append(f"- {tool_name}: {tool_desc}") + + if skills: + prompts.append("\n## Agent Skills(已加载详细指令):") + for tool in skills: + tool_name = tool.get('name', '') + tool_desc = tool.get('description', '') + prompts.append(f"\n### {tool_name}") + prompts.append(f"描述: {tool_desc}") + instance = tool.get('instance') + if instance: + instructions = getattr(instance, 'instructions', '') + if instructions: + if len(instructions) > 2000: + instructions = instructions[:2000] + "\n...(内容已截断)" + prompts.append(f"\n{instructions}") + + if self.is_clawli_mode: + prompts.append("\n## Clawli 远程模式专属工具:") + prompts.append(""" +### send_image - 发送图片到手机端 +当用户请求发送图片到手机时,使用此工具: + +```json +{ + "action": "use_tool", + "tool": "send_image", + "args": { + "image_path": "图片文件的绝对路径", + "caption": "图片说明文字(可选)" + } +} +``` + +支持的图片格式:JPG、PNG、GIF、BMP、WEBP +""") + + return "\n".join(prompts) + + def _parse_mixed_response(self, response): + """ + 解析混合响应,处理同时包含文本和JSON指令的内容 + 返回格式: (text_content, json_data) + """ + response = response.strip() + + # 方法0: 处理 ... 格式 + if response.startswith(""): + json_str = response[11:] + json_str = "{" + json_str + try: + parsed = json.loads(json_str) + if isinstance(parsed, dict): + return "", parsed + except json.JSONDecodeError: + pass + + # 方法1: 直接解析为纯JSON + try: + parsed = json.loads(response) + if isinstance(parsed, (list, dict)): + return "", parsed + except json.JSONDecodeError: + pass + + # 方法2: 修复JSON中的控制字符问题 + try: + def fix_json_string(match): + content = match.group(1) + content = content.replace('\\', '\\\\') + content = content.replace('\n', '\\n') + content = content.replace('\r', '\\r') + content = content.replace('\t', '\\t') + content = content.replace('"', '\\"') + return f'"{content}"' + fixed_response = re.sub(r'"([^"]*(?:\n[^"]*)*)"', fix_json_string, response, flags=re.DOTALL) + parsed = json.loads(fixed_response) + if isinstance(parsed, (list, dict)): + return "", parsed + except: + pass + + # 方法3: 查找代码块中的JSON + json_pattern = r"```(?:json)?\s*({.*?})\s*```" + matches = re.findall(json_pattern, response, re.DOTALL) + if matches: + try: + json_data = json.loads(matches[-1]) + if isinstance(json_data, (dict, list)): + text_content = re.sub(json_pattern, "", response, flags=re.DOTALL).strip() + text_content = re.sub(r"\n\s*\n", "\n\n", text_content).strip() + return text_content, json_data + except json.JSONDecodeError: + pass + + # 方法4: 查找每行独立的JSON对象 + lines_resp = response.split("\n") + json_objects = [] + text_lines = [] + for line in lines_resp: + line = line.strip() + if line.startswith("{") and line.endswith("}"): + try: + parsed = json.loads(line) + if isinstance(parsed, dict): + json_objects.append(parsed) + continue + except json.JSONDecodeError: + pass + text_lines.append(line) + + if json_objects: + text_content = "\n".join(text_lines).strip() + if len(json_objects) == 1: + return text_content, json_objects[0] + else: + return text_content, json_objects + + # 方法5: 查找内联的JSON对象 + json_start_markers = [ + '{"action": "use_tool"', '{"action": "continue"', + '{"action":"use_tool"', '{"action":"continue"', + '{ "action": "use_tool"', '{ "action": "continue"', + '{ "action":"use_tool"', '{ "action":"continue"' + ] + for marker in json_start_markers: + json_start = response.find(marker) + if json_start != -1: + brace_count = 0 + in_json = False + for i in range(json_start, len(response)): + char = response[i] + if char == '{': + if not in_json: + in_json = True + brace_count += 1 + elif char == '}': + brace_count -= 1 + if in_json and brace_count == 0: + json_str = response[json_start:i+1] + try: + json_data = json.loads(json_str) + if isinstance(json_data, dict) and json_data.get('action') in ['use_tool', 'continue']: + text_content = response[:json_start].strip() + return text_content, json_data + except json.JSONDecodeError: + pass + break + + # 方法6: 通用JSON对象查找 + potential_jsons = re.findall(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', response, re.DOTALL) + for json_str in potential_jsons: + try: + json_data = json.loads(json_str) + if isinstance(json_data, dict) and json_data.get('action') in ['use_tool', 'continue']: + json_start = response.find(json_str) + text_content = response[:json_start].strip() + return text_content, json_data + except json.JSONDecodeError: + continue + + # 方法7: 批量JSON + try: + cleaned = response.strip() + for action in ['use_tool', 'continue']: + last_brace_pos = cleaned.rfind(f'{{"action": "{action}"') + if last_brace_pos == -1: + last_brace_pos = cleaned.rfind(f'{{"action":"{action}"') + if last_brace_pos != -1: + json_part = cleaned[last_brace_pos:] + if json_part.startswith('['): + parsed = json.loads(json_part) + if isinstance(parsed, list): + text_content = cleaned[:last_brace_pos].strip() + return text_content, parsed + else: + parsed = json.loads(json_part) + if isinstance(parsed, dict): + text_content = cleaned[:last_brace_pos].strip() + return text_content, parsed + except: + pass + + return response, None + + # ── 对话处理 ── + + def process_conversation(self, user_input): + """处理完整的对话循环, 支持工具调用循环和AI继续操作循环""" + self.shared_conversation_history.append({ + "role": "user", + "content": user_input + }) + liugin_prompts = self.get_liugin_usage_prompts() + loop_count = 0 + current_input = user_input + max_loops = 20 + + while loop_count < max_loops: + response = self._generate_response_with_animation(current_input, liugin_prompts=liugin_prompts) + processed_response = self.process_thinking_response(response) + processed_response = self._process_code_blocks(processed_response) + + try: + text_content, json_data = self._parse_mixed_response(processed_response) + + if json_data: + if text_content: + text_content = self._process_code_blocks(text_content) + self.shared_conversation_history.append({ + "role": "assistant", + "content": text_content + }) + self._display_response(text_content) + + if isinstance(json_data, dict): + response_data = json_data + + if response_data.get('action') == 'use_tool': + canceled = self._execute_single_tool(response_data) + if canceled: + break + break + + if (response_data.get('continue') is True or + response_data.get('need_continue') is True or + response_data.get('think_more') is True): + continue_message = response_data.get('message', '') + if continue_message: + processed_response = continue_message + self.shared_conversation_history.append({ + "role": "assistant", + "content": processed_response + }) + self._display_response(processed_response, is_continue=True) + loop_count += 1 + elif response_data.get('message'): + message = response_data.get('message') + if text_content: + text_content = self._process_code_blocks(text_content) + self.shared_conversation_history.append({ + "role": "assistant", + "content": text_content + }) + self._display_response(text_content) + self.shared_conversation_history.append({ + "role": "assistant", + "content": message + }) + self._display_response(message) + break + else: + if text_content: + text_content = self._process_code_blocks(text_content) + self.shared_conversation_history.append({ + "role": "assistant", + "content": text_content + }) + self._display_response(text_content) + else: + self.shared_conversation_history.append({ + "role": "assistant", + "content": processed_response + }) + self._display_response(processed_response) + break + + elif isinstance(json_data, list): + tool_calls = [item for item in json_data if isinstance(item, dict) and item.get('action') == 'use_tool'] + if tool_calls: + self._execute_concurrent_tools(tool_calls) + break + continue_items = [item for item in json_data if isinstance(item, dict) and + (item.get('continue') is True or + item.get('need_continue') is True or + item.get('think_more') is True)] + if continue_items: + continue_message = continue_items[0].get('message', '') + if continue_message: + processed_response = continue_message + self.shared_conversation_history.append({ + "role": "assistant", + "content": processed_response + }) + self._display_response(processed_response, is_continue=True) + loop_count += 1 + else: + if text_content: + self.shared_conversation_history.append({ + "role": "assistant", + "content": text_content + }) + self._display_response(text_content or processed_response) + break + else: + display_content = text_content if text_content else processed_response + self.shared_conversation_history.append({ + "role": "assistant", + "content": display_content + }) + self._display_response(display_content) + break + except json.JSONDecodeError: + text_content, json_data = self._parse_mixed_response(processed_response) + if json_data: + if text_content: + text_content = self._process_code_blocks(text_content) + self.shared_conversation_history.append({ + "role": "assistant", + "content": text_content + }) + self._display_response(text_content) + + if isinstance(json_data, dict): + response_data = json_data + if response_data.get('action') == 'continue': + continue_content = response_data.get('content', '') + if continue_content: + self.shared_conversation_history.append({ + "role": "assistant", + "content": continue_content + }) + self._display_response(continue_content) + continue + + if response_data.get('action') == 'use_tool': + canceled = self._execute_single_tool(response_data) + if canceled: + break + break + + if (response_data.get('continue') is True or + response_data.get('need_continue') is True or + response_data.get('think_more') is True): + continue_message = response_data.get('message', '') + if continue_message: + processed_response = continue_message + self.shared_conversation_history.append({ + "role": "assistant", + "content": processed_response + }) + self._display_response(processed_response, is_continue=True) + loop_count += 1 + else: + message = response_data.get('message', '') + if message: + self.shared_conversation_history.append({ + "role": "assistant", + "content": message + }) + self._display_response(message) + loop_count += 1 + else: + if text_content: + self.shared_conversation_history.append({ + "role": "assistant", + "content": text_content + }) + self._display_response(text_content or processed_response) + break + + elif isinstance(json_data, list): + tool_calls = [item for item in json_data if isinstance(item, dict) and item.get('action') == 'use_tool'] + if tool_calls: + self._execute_concurrent_tools(tool_calls) + break + continue_items = [item for item in json_data if isinstance(item, dict) and + (item.get('continue') is True or + item.get('need_continue') is True or + item.get('think_more') is True)] + if continue_items: + continue_message = continue_items[0].get('message', '') + if continue_message: + processed_response = continue_message + self.shared_conversation_history.append({ + "role": "assistant", + "content": processed_response + }) + self._display_response(processed_response, is_continue=True) + loop_count += 1 + else: + if text_content: + self.shared_conversation_history.append({ + "role": "assistant", + "content": text_content + }) + self._display_response(text_content or processed_response) + break + else: + display_content = text_content if text_content else processed_response + self.shared_conversation_history.append({ + "role": "assistant", + "content": display_content + }) + self._display_response(display_content) + break + else: + self._output(f"AI 已达到最大循环次数 ({max_loops}),已停止自动处理") + self.shared_conversation_history.append({ + "role": "assistant", + "content": f"已达到最大处理次数 ({max_loops}),已自动停止。如需继续处理,请重新输入。" + }) + + def _generate_response_with_animation(self, current_input, liugin_prompts=None): + """生成AI响应并显示等待动画""" + # TUI 模式:不做动画 + if self.tui_output_callback: + system_prompt = self._build_system_prompt(liugin_prompts) + if not self.current_engine: + return "错误: 当前没有可用的AI引擎,请检查ai_engines目录中的引擎插件" + response = self.current_engine.generate_response(current_input, system_prompt=system_prompt) + if response and self.shared_conversation_history is not None: + self.shared_conversation_history.append({ + "role": "assistant", + "content": response + }) + return response + + # CLI 模式:带动画和 ESC 检测 + animation_running = True + esc_pressed = threading.Event() + love_sentences = [] + love_file_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), LOVE_FILE_PATH) + if os.path.exists(love_file_path): + try: + with open(love_file_path, 'r', encoding='utf-8') as f: + love_sentences = [line.strip() for line in f.readlines() if line.strip()] + except Exception as e: + print(f"读取love.txt文件时出错: {e}") + if not love_sentences: + love_sentences = ["AI正在思考中...", "请稍等片刻...", "正在处理您的请求..."] + + def check_for_esc(): + try: + import msvcrt + while animation_running: + if msvcrt.kbhit(): + key = msvcrt.getch() + if ord(key) == 27: + esc_pressed.set() + break + time.sleep(0.1) + except ImportError: + pass + + def show_animation(): + last_change_time = time.time() + current_sentence = random.choice(love_sentences) + while animation_running: + current_time = time.time() + if current_time - last_change_time >= 5: + current_sentence = random.choice(love_sentences) + last_change_time = current_time + animation_chars = "|/-\\" + char_idx = int((current_time * 10) % len(animation_chars)) + display_sentence = current_sentence[:47] + "..." if len(current_sentence) > 50 else current_sentence + print(f"\r{Fore.MAGENTA}{display_sentence} {animation_chars[char_idx]}{Style.RESET_ALL}", end="", flush=True) + time.sleep(0.1) + + animation_thread = threading.Thread(target=show_animation) + animation_thread.daemon = True + animation_thread.start() + esc_thread = threading.Thread(target=check_for_esc) + esc_thread.daemon = True + esc_thread.start() + try: + system_prompt = self._build_system_prompt(liugin_prompts) + if not self.current_engine: + return "错误: 当前没有可用的AI引擎,请检查ai_engines目录中的引擎插件" + + response = self.current_engine.generate_response(current_input, system_prompt=system_prompt) + if response: + self._typeprint(response, Fore.CYAN) + + if esc_pressed.is_set(): + print(f"\n{Fore.YELLOW}[AI请求已取消]{Style.RESET_ALL}") + return "[AI请求已取消]" + + if response and self.shared_conversation_history is not None: + self.shared_conversation_history.append({ + "role": "assistant", + "content": response + }) + + return response + finally: + animation_running = False + animation_thread.join() + esc_thread.join() + print("\r" + " " * 50 + "\r", end="", flush=True) + + # ── CLI 主循环 ── + + def run_tui(self): + """运行 TUI 模式""" + if not TEXTUAL_AVAILABLE: + print(f"{Fore.RED}TUI 模式不可用:Textual 库未安装{Style.RESET_ALL}") + print(f"{Fore.YELLOW}请运行: pip install textual rich{Style.RESET_ALL}") + return + from .tui import XiaoliTUI + app = XiaoliTUI(self) + app.run() + + def run(self): + """运行 CLI""" + import uuid + self.user_id = str(uuid.getnode()) + print(f"{Fore.GREEN}小狸 Pro-CLI 已启动!{Style.RESET_ALL}") + print(f"{Fore.GREEN}用户ID: {self.user_id}{Style.RESET_ALL}") + print(f"{Fore.GREEN}输入 '/help' 查看帮助信息{Style.RESET_ALL}") + print(f"{Fore.GREEN}输入 '/quit' 退出程序{Style.RESET_ALL}") + print(f"{Fore.GREEN}输入 '/tui' 切换到 TUI 模式{Style.RESET_ALL}") + print(f"{Fore.GREEN}输入 '/engine list' 查看可用AI引擎{Style.RESET_ALL}") + print(f"{Fore.GREEN}输入 '/engine switch <引擎名>' 切换AI引擎{Style.RESET_ALL}") + print(f"{Fore.GREEN}输入 '/file.read <文件名> [行数]' 直接读取文件内容{Style.RESET_ALL}") + print(f"{Fore.GREEN}输入 '@文件路径' 自动读取文件内容并发送给AI{Style.RESET_ALL}") + print(f"{Fore.GREEN}输入 '@图片路径' 自动分析图片并发送描述给AI{Style.RESET_ALL}") + print(f"{Fore.GREEN}输入 '/image engines' 查看图像识别引擎{Style.RESET_ALL}") + print(f"{Fore.GREEN}输入 '/chat save <名称>' 保存当前聊天记录{Style.RESET_ALL}") + print(f"{Fore.GREEN}输入 '/chat list' 查看所有已保存的聊天记录{Style.RESET_ALL}") + print(f"{Fore.GREEN}输入 '/chat open <名称>' 加载聊天记录{Style.RESET_ALL}") + print(f"{Fore.GREEN}输入 '/remote' 查看远程连接帮助{Style.RESET_ALL}") + current_engine_name = getattr(self.current_engine, 'name', '未设置') if self.current_engine else '未设置' + print(f"{Fore.GREEN}当前使用的AI引擎: {current_engine_name}{Style.RESET_ALL}") + loaded_engines = list(self.engines.keys()) + if len(loaded_engines) > 1: + print(f"{Fore.GREEN}已加载的AI引擎: {', '.join(loaded_engines)}{Style.RESET_ALL}") + print(f"{Fore.GREEN}{'-' * 50}{Style.RESET_ALL}") + self._run_cli_loop() + + def _run_cli_loop(self): + """运行 CLI 主循环""" + while True: + try: + user_input = input(f"\n{Fore.WHITE}> {Style.RESET_ALL}").strip() + + if user_input == '/quit': + print(f"{Fore.GREEN}再见!{Style.RESET_ALL}") + break + + if user_input == '/help': + self.show_help() + continue + + if user_input.startswith('/help '): + liugin_name = user_input[6:].strip() + self.show_liugin_help(liugin_name) + continue + + if user_input.startswith('/engine '): + engine_command = user_input[8:].strip() + parts = engine_command.split(' ', 1) + cmd = parts[0] + args = parts[1] if len(parts) > 1 else '' + if cmd in self.engine_commands: + self.engine_commands[cmd](args) + else: + available_commands = ', '.join(self.engine_commands.keys()) + print(f"{Fore.RED}未知的引擎命令.可用命令: {available_commands}{Style.RESET_ALL}") + continue + + if user_input.startswith('/engine.'): + engine_command = user_input[8:].strip() + if not self.handle_engine_command(engine_command): + print(f"{Fore.RED}当前引擎不支持该命令或命令执行失败{Style.RESET_ALL}") + continue + + if user_input.startswith('/file.read '): + args = user_input[11:].strip() + parts = args.split(' ', 2) + if len(parts) < 1: + print(f"{Fore.RED}请提供文件名.用法: /file.read <文件名> [行数]{Style.RESET_ALL}") + continue + filename = parts[0] + max_lines = None + if len(parts) >= 2: + try: + max_lines = int(parts[1]) + if max_lines <= 0: + print(f"{Fore.RED}行数必须是正整数{Style.RESET_ALL}") + continue + except ValueError: + print(f"{Fore.RED}行数必须是正整数{Style.RESET_ALL}") + continue + result = self._read_file_direct(filename, max_lines) + print(f"{Fore.GREEN}{result}{Style.RESET_ALL}") + continue + + if user_input.startswith('/chat '): + chat_command = user_input[6:].strip() + if chat_command.startswith('save '): + name = chat_command[5:].strip() + if name: + self.save_chat_history(name) + else: + print(f"{Fore.RED}请提供聊天记录名称.用法: /chat save <名称>{Style.RESET_ALL}") + continue + elif chat_command == 'list': + self.list_chat_history() + continue + elif chat_command.startswith('open '): + name = chat_command[5:].strip() + if name: + self.load_chat_history(name) + else: + print(f"{Fore.RED}请提供聊天记录名称.用法: /chat open <名称>{Style.RESET_ALL}") + continue + else: + print(f"{Fore.RED}未知的聊天命令.可用命令: save, list, open{Style.RESET_ALL}") + continue + + if user_input.startswith('/remote'): + remote_command = user_input[7:].strip() + self._handle_remote_command(remote_command) + continue + + if user_input == '/about': + self._read_about_file() + continue + + if user_input == '/tui': + print(f"{Fore.CYAN}正在切换到 TUI 模式...{Style.RESET_ALL}") + self.run_tui() + print(f"{Fore.CYAN}已从 TUI 模式返回 CLI 模式{Style.RESET_ALL}") + continue + + if user_input.startswith('/'): + command_parts = user_input[1:].split(' ', 1) + command = command_parts[0] + args = command_parts[1] if len(command_parts) > 1 else "" + if command in self.liugin_commands: + try: + result = self.liugin_commands[command](args) + if result: + result = self._limit_output_lines(result, max_lines=5) + print(f"{Fore.GREEN}{result}{Style.RESET_ALL}") + except Exception as e: + print(f"{Fore.RED}插件命令执行失败: {e}{Style.RESET_ALL}") + continue + + if user_input.startswith('/thinking '): + thinking_input = user_input[10:].strip() + processed_response = self.process_thinking_response(thinking_input) + processed_response = self._process_code_blocks(processed_response) + user_id_display = f"[用户ID: {self.user_id}]" + response_lines = processed_response.split('\n') + for i, line in enumerate(response_lines): + if i == 0: + line = "✦ " + line + if i == len(response_lines) - 1: + print(f"{line} {user_id_display}") + else: + print(f"{line}") + continue + + if '@' in user_input: + processed_input = self._process_file_paths(user_input) + if processed_input != user_input: + print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}") + user_input = processed_input + print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}") + + self.process_conversation(user_input) + except KeyboardInterrupt: + user_id_display = f"[用户ID: {self.user_id}]" + print(f"\n{Fore.GREEN}再见! {user_id_display}{Style.RESET_ALL}") + break + except Exception as e: + user_id_display = f"[用户ID: {self.user_id}]" + print(f"{Fore.RED}发生错误: {e} {user_id_display}{Style.RESET_ALL}") + + +# ============================================================================ +# Main Entry Point +# ============================================================================ +def main(): + """主入口函数""" + import time + import sys + + art_lines = [ + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "███████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓██████████████████", + "██████████ ▓████████████████", + "██████████ ▒███████████████", + "██████████ ░██████████████", + "██████████ ░█████████████", + "██████████ ████████████", + "██████████ ███████████", + "██████████ ▒████████████████████████████████████████████████████", + "██████████ ░██████████████████████████████████████████████████████", + "██████████ ████████████████████████████████████████████████████████", + "██████████ ▓█████████████████████████████████████████████████████████", + "██████████ ▒███████████████████████████████████████████████████████████", + "██████████ ███████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓██████████", + "██████████ ███████████▓▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒██████████", + "██████████ ▓██████████▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▓██████████", + "██████████ ░███████████▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▒██████████", + "██████████ ███████████▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▒▓▓▒██████████", + "██████████ ███████████▓▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▓██████████", + "██████████ ▓██████████▓▒▒▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▓██████████", + "██████████ ░██████████▓▒▒▓▒▓▒▒▓▒▒▒▒▓▒▒▓▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▒▓▓██████████", + "██████████ ███████████▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▒▒▒▒▓▒▓▒▓██████████", + "██████████ ███████████▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▓▒▓▒▓▒▒▒▒▓▒▒▒▓▒▒▒▒▒▓▒▒▓▒▒▒▒▒▓▒▓▒▓▒▒▒▓▓██████████", + "██████████ ░██████████▒▓▒▓▒▓▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▓▒▒▒▒▓▒▓▒▓▒▒▓▒▒▒▓▒▒▒██████████", + "██████████ █████████▓▒▒▒▓▒▒▓▒▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒██████████", + "██████████ █████████▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▓██████████", + "██████████ ▓████████▒▓▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▓▒▒▓▒▒▓▓▒▒▒██████████", + "██████████ █████████▒▒▒▒▓▒▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▓▒▒▓▒▒▒▒▒▓▒██████████", + "██████████ █████████▒▓▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▓▒▓▒▒▓▒▒▒▒▒▓▒▒▒██████████", + "██████████ ▓████████▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒██████████", + "██████████ █████████▒▓▒▒▒▒▓▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▓▒▒▒▒▒▒▒██████████", + "██████████ █████████▒▒▓▒▓▒▒▓▒▓▒▒▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▒▒▓▒▒▒▒▒▓▒██████████", + "██████████ ▓████████▒▓▒▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▓▒▒▒▒▒▓██████████", + "██████████ █████████▒▒▓▒▓▒▒▓▒▓▒▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", + "██████████ █████████▒▓▒▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", + "██████████ ▓████████▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▒██████████", + "██████████ █████████▒▓▒▒▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", + "██████████ █████████▒▒▓▒▓▒▒▓▒▓▒▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", + "██████████ ▓████████▒▓▒▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", + "██████████ █████████▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▓▒▓▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", + "██████████ █████████▒▓▒▒▒▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", + "██████████ ▓████████▒▒▓▒▒▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▒▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", + "███████████ █████████▒▓▒▒▒▒▒▒▓▒▒▒▒▓▒▒▓▒▒▒▒▒▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", + "█████████████▓ █████████▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", + "█████████████████ ▓████████▒▓▒▒▒▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▒▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", + "███████████████████▒ █████████▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▒▒▒▒▒▓▒▒▓▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + ] + + if sys.platform == 'win32': + import codecs + sys.stdout = codecs.getwriter('utf-8')(sys.stdout.buffer, 'strict') + + for line in art_lines: + try: + print(line, flush=True) + except UnicodeEncodeError: + print(line.encode('gbk', errors='ignore').decode('gbk'), flush=True) + time.sleep(0.05) + + cli = AICLI() + cli.run() diff --git a/xcli_core/cli_display.py b/xcli_core/cli_display.py new file mode 100644 index 0000000..619331a --- /dev/null +++ b/xcli_core/cli_display.py @@ -0,0 +1,625 @@ +import os +import sys +import shutil +import time +import subprocess +import platform +import re +from colorama import Fore, Style + +from .constants import MAX_FILE_SIZE, VIDEO_FRAME_DELAY +from .config import logger + + +class DisplayMixin: + """显示、输出、图片、帮助相关方法""" + + def _process_code_blocks(self, text): + """处理包含""" """语法的文本,将代码块部分显示为红色""" + pattern = r'(\"\"\".*?\"\"\")' + + def replace_match(match): + full_match = match.group(1) + inner_content = full_match[3:-3] + return f'{Fore.RED}{inner_content}{Style.RESET_ALL}' + result = re.sub(pattern, replace_match, text, flags=re.DOTALL) + return result + + def _limit_output_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 _output(self, message): + """统一输出方法,支持 TUI 和 CLI 模式""" + if self.tui_output_callback: + self.tui_output_callback(message) + else: + print(message) + + def _display_response(self, content, is_continue=False): + """统一显示 AI 响应,支持 TUI 和 CLI 模式""" + user_id_display = f"[用户ID: {self.user_id}]" + response_lines = content.split('\n') + if self.tui_output_callback: + for i, line in enumerate(response_lines): + if i == 0: + prefix = "[yellow]>[/] " if is_continue else "[cyan]>[/] " + line = prefix + line + if i == len(response_lines) - 1: + self._output(f"{line} {user_id_display}") + else: + self._output(line) + self._output("") + else: + for i, line in enumerate(response_lines): + if i == 0: + prefix = f"{Fore.YELLOW}✦{Style.RESET_ALL} " if is_continue else "✦ " + line = prefix + line + if i == len(response_lines) - 1: + print(f"{line} {user_id_display}") + else: + print(f"{line}") + print() + + def _display_tool_result(self, tool_name, tool_args, full_result, current_time): + """显示工具执行结果""" + if full_result is None: + full_result = "无结果" + + self._output("") + user_id_display = f"[用户ID: {self.user_id}]" + is_error = full_result.startswith("错误:") or full_result.startswith("错误:") or full_result.startswith("工具执行错误") + + if self.tui_output_callback: + if is_error: + self._output(f"[red] X 工具: {tool_name}[/]") + self._output(f"[red] 参数: {tool_args} {user_id_display}[/]") + else: + self._output(f"[green] OK 工具: {tool_name}[/]") + self._output(f"[green] 参数: {tool_args} {user_id_display}[/]") + else: + 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_output_lines(full_result, max_lines=2) + color_tag = "red" if is_error else "green" + for line in display_result.split('\n'): + if self.tui_output_callback: + self._output(f"[{color_tag}] {line}[/]") + else: + color = Fore.RED if is_error else Fore.GREEN + self._output(f"{color} {line}{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 display_image_in_terminal(self, image_path, width=None): + """在终端中显示图片""" + try: + if not os.path.exists(image_path): + print(f"{Fore.RED}错误: 图片文件不存在: {image_path}{Style.RESET_ALL}") + return False + + valid_extensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'] + if not os.path.splitext(image_path)[1].lower() in valid_extensions: + print(f"{Fore.RED}错误: 不支持的图片格式。支持的格式: {', '.join(valid_extensions)}{Style.RESET_ALL}") + return False + + try: + from PIL import Image + with Image.open(image_path) as img: + img_size = img.size + img_format = img.format + file_size = os.path.getsize(image_path) + print(f"\n{Fore.CYAN}{'=' * 60}{Style.RESET_ALL}") + print(f"{Fore.CYAN}图片预览: {os.path.basename(image_path)}{Style.RESET_ALL}") + print(f"{Fore.CYAN}{'=' * 60}{Style.RESET_ALL}") + print(f"{Fore.GREEN} 格式: {img_format}{Style.RESET_ALL}") + print(f"{Fore.GREEN} 尺寸: {img_size[0]} x {img_size[1]} 像素{Style.RESET_ALL}") + print(f"{Fore.GREEN} 大小: {file_size / 1024:.2f} KB{Style.RESET_ALL}") + print(f"{Fore.CYAN}{'=' * 60}{Style.RESET_ALL}") + except ImportError: + print(f"\n{Fore.CYAN}图片预览: {os.path.basename(image_path)}{Style.RESET_ALL}") + + # 方法1: 使用色块显示 + try: + from PIL import Image + + terminal_size = shutil.get_terminal_size() + terminal_width = terminal_size.columns + terminal_height = terminal_size.lines - 5 + + if width is None: + width = min(250, terminal_width) + + with Image.open(image_path) as img: + img_width, img_height = img.size + pixel_width = width * 2 + pixel_height = (terminal_height - 2) * 2 + scale = min(pixel_width / img_width, pixel_height / img_height) + scale = min(scale * 1.1, 1.0) + new_width = max(int(img_width * scale), 80) + new_height = max(int(img_height * scale), 40) + img = img.resize((new_width, new_height), Image.LANCZOS) + if img.mode != 'RGB': + img = img.convert('RGB') + pixels = img.load() + + print(f"{Fore.YELLOW}正在生成色块图片 ({new_width}x{new_height} 像素)...{Style.RESET_ALL}") + + block_chars = { + (False, False, False, False): ' ', + (True, False, False, False): '▘', + (False, True, False, False): '▝', + (True, True, False, False): '▀', + (False, False, True, False): '▖', + (True, False, True, False): '▌', + (False, True, True, False): '▞', + (True, True, True, False): '▛', + (False, False, False, True): '▗', + (True, False, False, True): '▚', + (False, True, False, True): '▐', + (True, True, False, True): '▜', + (False, False, True, True): '▄', + (True, False, True, True): '▙', + (False, True, True, True): '▟', + (True, True, True, True): '█', + } + + for y in range(0, new_height, 2): + line = [] + for x in range(0, new_width, 2): + r1, g1, b1 = pixels[x, y] if y < new_height and x < new_width else (0, 0, 0) + r2, g2, b2 = pixels[x + 1, y] if y < new_height and x + 1 < new_width else (0, 0, 0) + r3, g3, b3 = pixels[x, y + 1] if y + 1 < new_height and x < new_width else (0, 0, 0) + r4, g4, b4 = pixels[x + 1, y + 1] if y + 1 < new_height and x + 1 < new_width else (0, 0, 0) + + has_tl = y < new_height and x < new_width + has_tr = y < new_height and x + 1 < new_width + has_bl = y + 1 < new_height and x < new_width + has_br = y + 1 < new_height and x + 1 < new_width + + all_pixels = [(r1, g1, b1), (r2, g2, b2), (r3, g3, b3), (r4, g4, b4)] + avg_r = sum(p[0] for p in all_pixels) // 4 + avg_g = sum(p[1] for p in all_pixels) // 4 + avg_b = sum(p[2] for p in all_pixels) // 4 + + char = block_chars.get((has_tl, has_tr, has_bl, has_br), ' ') + fg_color = f"\033[38;2;{avg_r};{avg_g};{avg_b}m" + reset = "\033[0m" + line.append(f"{fg_color}{char}{reset}") + + print(''.join(line)) + + print(f"{Fore.GREEN}✓ 已使用色块显示 ({new_width}x{new_height} 像素){Style.RESET_ALL}\n") + return True + + except ImportError: + print(f"{Fore.YELLOW}未安装 ascii-magic 库,尝试使用系统默认图片查看器...{Style.RESET_ALL}") + except Exception as e: + print(f"{Fore.YELLOW}ASCII 艺术显示失败: {e},尝试使用系统默认图片查看器...{Style.RESET_ALL}") + + # 方法2: 使用系统默认图片查看器 + system = platform.system() + if system == 'Windows': + os.startfile(image_path) + elif system == 'Darwin': + subprocess.run(['open', image_path]) + else: + subprocess.run(['xdg-open', image_path]) + print(f"{Fore.GREEN}✓ 已在系统默认图片查看器中打开{Style.RESET_ALL}\n") + return True + + except Exception as e: + print(f"{Fore.RED}显示图片失败: {str(e)}{Style.RESET_ALL}") + return False + + # ── 文件读取 ── + + def _read_file_direct(self, filename, max_lines=None): + """直接读取文件内容, 支持指定行数""" + try: + if not os.path.exists(filename): + return f"错误:文件 '{filename}' 不存在。" + if not os.path.isfile(filename): + return f"错误:'{filename}' 不是一个文件。" + with open(filename, 'r', encoding='utf-8') as f: + if max_lines is not None: + lines = [] + for i, line in enumerate(f): + if i >= max_lines: + break + lines.append(line.rstrip('\n')) + content = '\n'.join(lines) + info = f"(文件共{len(lines)}行)" if len(lines) < max_lines else f"(已读取前{max_lines}行)" + return f"文件 '{filename}' 的内容 {info}:\n{content}" + else: + content = f.read() + MAX_CONTENT_LENGTH = 10000 + if len(content) > MAX_CONTENT_LENGTH: + content = content[:10000] + f"\n... (内容已截断, 共{len(content)}字符)" + return f"文件 '{filename}' 的内容:\n{content}" + except UnicodeDecodeError: + file_size = os.path.getsize(filename) + return f"文件 '{filename}' 是二进制文件, 大小: {file_size} 字节。无法直接读取文本内容。" + except PermissionError as e: + logger.error(f"权限错误,无法读取文件 '{filename}': {e}") + return f"权限错误:无法读取文件 '{filename}',可能需要管理员权限或文件被占用。" + except OSError as e: + logger.error(f"操作系统错误,无法读取文件 '{filename}': {e}") + return f"操作系统错误:无法读取文件 '{filename}',原因: {str(e)}" + except Exception as e: + logger.error(f"读取文件 '{filename}' 失败: {e}") + return f"读取文件 '{filename}' 失败: {str(e)}" + + def _process_file_paths(self, user_input): + """处理用户输入中的 @文件路径 语法""" + image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'} + pattern = r'@"([^"]+)"|@([^\s]+)' + matches = list(re.finditer(pattern, user_input)) + + if not matches: + return user_input + + result_parts = [] + last_pos = 0 + + for match in matches: + if match.start() > last_pos: + result_parts.append(user_input[last_pos:match.start()]) + + file_path = match.group(1) or match.group(2) + if not file_path: + continue + + file_path = os.path.normpath(file_path) + + try: + if os.path.exists(file_path): + if os.path.isdir(file_path): + print(f"{Fore.CYAN}检测到目录: {file_path}{Style.RESET_ALL}") + try: + entries = os.listdir(file_path) + files = [e for e in entries if os.path.isfile(os.path.join(file_path, e))] + if not files: + result_parts.append(f"[目录: {file_path} 为空]") + else: + file_list_text = f"\n[目录: {file_path} - 共 {len(files)} 个文件]\n" + file_list_text += "=" * 50 + "\n" + for i, filename in enumerate(sorted(files), 1): + full_path = os.path.join(file_path, filename) + file_size = os.path.getsize(full_path) + file_list_text += f"{i}. {filename} ({file_size} 字节)\n" + file_list_text += "=" * 50 + "\n" + result_parts.append(file_list_text) + except PermissionError: + result_parts.append(f"[错误: 无权限访问目录 {file_path}]") + except Exception as e: + result_parts.append(f"[错误: 无法读取目录 {file_path} - {str(e)}]") + else: + file_ext = os.path.splitext(file_path)[1].lower() + is_image = file_ext in image_extensions + + if is_image: + print(f"{Fore.CYAN}检测到图片文件: {file_path}{Style.RESET_ALL}") + try: + self.display_image_in_terminal(file_path) + except Exception as e: + print(f"{Fore.YELLOW}图片预览失败: {e}{Style.RESET_ALL}") + + image_plugin = None + for tool in self.liugin_manager.tools: + tool_name = tool.get('name', '').lower() + tool_desc = tool.get('description', '').lower() + if 'image' in tool_name or 'analyze' in tool_name or '图片' in tool_desc: + image_plugin = tool + break + + if image_plugin: + try: + print(f"{Fore.YELLOW}正在分析图片...{Style.RESET_ALL}") + image_result = image_plugin['handler'](f"analyze {file_path}") + if image_result: + result_parts.append(f" {image_result}") + else: + result_parts.append(f" [图片: {file_path}]") + except Exception as e: + result_parts.append(f"[错误: 图片分析失败 - {str(e)}]") + else: + result_parts.append(f"[错误: 图像分析插件未加载]") + else: + file_size = os.path.getsize(file_path) + if file_size > MAX_FILE_SIZE: + result_parts.append(f"[错误: 文件 {file_path} 超过大小限制 ({MAX_FILE_SIZE} 字节)]") + else: + try: + with open(file_path, 'r', encoding='utf-8') as f: + file_content = f.read() + result_parts.append(f"\n[文件: {file_path}]\n{file_content}\n") + except UnicodeDecodeError: + try: + with open(file_path, 'r', encoding='gbk') as f: + file_content = f.read() + result_parts.append(f"\n[文件: {file_path}]\n{file_content}\n") + except Exception as e: + result_parts.append(f"[错误: 无法读取文件 {file_path} - {str(e)}]") + except Exception as e: + result_parts.append(f"[错误: 无法读取文件 {file_path} - {str(e)}]") + else: + result_parts.append(f"[错误: 文件 {file_path} 不存在]") + + last_pos = match.end() + except Exception as e: + result_parts.append(f"[错误: 处理文件路径时出错 - {str(e)}]") + last_pos = match.end() + + if last_pos < len(user_input): + result_parts.append(user_input[last_pos:]) + + return ''.join(result_parts) + + # ── 帮助系统 ── + + def show_help(self): + """显示帮助信息""" + help_file_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "HELP.md") + if os.path.exists(help_file_path): + try: + with open(help_file_path, 'r', encoding='utf-8') as f: + help_content = f.read() + print(help_content) + except Exception as e: + print(f"读取帮助文件失败: {e}") + self._show_basic_help() + else: + self._show_basic_help() + + def show_liugin_help(self, liugin_name): + """显示特定插件的帮助信息""" + if liugin_name in self.engines: + engine = self.engines[liugin_name] + print(f"{Fore.GREEN}AI引擎 '{liugin_name}' 的帮助信息:{Style.RESET_ALL}") + engine_name = getattr(engine, 'name', liugin_name) + print(f"{Fore.GREEN}引擎名称: {engine_name}{Style.RESET_ALL}") + if hasattr(engine, 'get_help_info'): + help_info = engine.get_help_info() + if isinstance(help_info, str): + print(f"{Fore.GREEN}帮助信息:{Style.RESET_ALL}") + print(help_info) + elif isinstance(help_info, dict): + for key, value in help_info.items(): + print(f"{Fore.GREEN}{key}: {value}{Style.RESET_ALL}") + else: + print(f"{Fore.GREEN}可用命令:{Style.RESET_ALL}") + print(f" /engine.{liugin_name} <命令> - 执行{liugin_name}引擎的特定命令") + return + + for tool in self.liugin_manager.tools: + if tool.get('name') == liugin_name: + print(f"{Fore.GREEN}插件 '{liugin_name}' 的帮助信息:{Style.RESET_ALL}") + description = tool.get('description', '无描述') + print(f"{Fore.GREEN}描述: {description}{Style.RESET_ALL}") + if 'usage' in tool: + print(f"{Fore.GREEN}用法:{Style.RESET_ALL}") + print(f" {tool['usage']}") + if 'keywords' in tool: + keywords = tool['keywords'] + if isinstance(keywords, list): + print(f"{Fore.GREEN}关键词: {', '.join(keywords)}{Style.RESET_ALL}") + else: + print(f"{Fore.GREEN}关键词: {keywords}{Style.RESET_ALL}") + return + + print(f"{Fore.RED}未找到插件或引擎 '{liugin_name}'{Style.RESET_ALL}") + print(f"{Fore.YELLOW}可用AI引擎:{Style.RESET_ALL}") + for engine_name in self.engines.keys(): + print(f"{Fore.YELLOW} - {engine_name}{Style.RESET_ALL}") + if self.liugin_manager.tools: + print(f"{Fore.YELLOW}可用插件:{Style.RESET_ALL}") + for tool in self.liugin_manager.tools: + tool_name = tool.get('name', '未知插件') + tool_description = tool.get('description', '无描述') + print(f"{Fore.YELLOW} - {tool_name}: {tool_description}{Style.RESET_ALL}") + + def _show_basic_help(self): + """显示基本帮助信息""" + help_text = """ +小狸 Pro-CLI 帮助信息 +================== +基本命令: + /help - 显示此帮助信息 + /help <插件名> - 显示特定插件的帮助信息 + /quit - 退出程序 + /tui - 切换到 TUI 模式 + /model <引擎名> - 切换AI引擎 (当前支持: """ + available_engines = list(self.engines.keys()) + help_text += ", ".join(available_engines) + ")" + help_text += r""" +文件操作命令: + /file.read <文件名> [行数] - 直接读取文件内容 + @文件路径 - 发送时自动读取文件内容并拼接 +图像分析命令: + /image analyze <图片路径> - 详细分析图片 + /image ocr <图片路径> - OCR文字识别 + /image quick <图片路径> - 快速描述 +引擎管理命令: +""" + self._get_engine_commands_help() + """ + /engine.<命令> - 执行引擎特定命令 +聊天记录命令: + /chat save <名称> - 保存当前聊天记录 + /chat list - 查看所有已保存的聊天记录 + /chat open <名称> - 加载聊天记录 +插件工具: +""" + if self.liugin_manager.tools: + for tool in self.liugin_manager.tools: + tool_name = tool.get('name', '未知工具') + tool_description = tool.get('description', '无描述') + help_text += f" {tool_name}: {tool_description}\n" + if 'usage' in tool: + help_text += f" 用法: {tool['usage']}\n" + else: + help_text += " 当前没有可用的工具插件\n" + print(help_text) + + def _read_about_file(self): + """读取并返回about.txt文件的内容,逐字显示""" + try: + project_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + about_file_path = os.path.join(project_dir, "about.txt") + if not os.path.exists(about_file_path): + return "错误:about.txt 文件不存在。" + with open(about_file_path, 'r', encoding='utf-8') as f: + content = f.read() + for char in content: + sys.stdout.write(char) + sys.stdout.flush() + if char in [',', '。', '!', '?', ',', '.', '!', '?', '\n', '\r']: + time.sleep(0.1) + else: + time.sleep(VIDEO_FRAME_DELAY) + print() + return "" + except UnicodeDecodeError: + return "错误:about.txt 文件编码格式不支持。" + except Exception as e: + return f"读取 about.txt 文件时发生错误: {str(e)}" + + # ── 视频播放 ── + + def start_video_player(self): + """启动视频播放窗口""" + import threading + video_thread = threading.Thread(target=self._show_video_window, daemon=True) + video_thread.start() + + def _show_video_window(self): + """显示视频播放窗口""" + try: + import tkinter as tk + import cv2 + import numpy as np + + root = tk.Tk() + root.overrideredirect(True) + screen_width = root.winfo_screenwidth() + screen_height = root.winfo_screenheight() + video_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "Video_1765688252672.mp4") + if not os.path.exists(video_path): + error_width, error_height = 400, 100 + x = (screen_width // 2) - (error_width // 2) + y = (screen_height // 2) - (error_height // 2) + root.geometry(f"{error_width}x{error_height}+{x}+{y}") + canvas = tk.Canvas(root, width=error_width, height=error_height) + canvas.pack(fill=tk.BOTH, expand=True) + canvas.create_text(error_width//2, error_height//2, text=f"视频文件不存在:\n{video_path}", fill="red", font=("Arial", 12)) + root.mainloop() + return + + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + error_width, error_height = 400, 100 + x = (screen_width // 2) - (error_width // 2) + y = (screen_height // 2) - (error_height // 2) + root.geometry(f"{error_width}x{error_height}+{x}+{y}") + canvas = tk.Canvas(root, width=error_width, height=error_height) + canvas.pack(fill=tk.BOTH, expand=True) + canvas.create_text(error_width//2, error_height//2, text="无法打开视频文件", fill="red", font=("Arial", 12)) + root.mainloop() + return + + video_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + video_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + fps = int(cap.get(cv2.CAP_PROP_FPS)) + max_width = int(screen_width * 0.4) + max_height = int(screen_height * 0.4) + scale = min(max_width / video_width, max_height / video_height) + window_width = int(video_width * scale) + window_height = int(video_height * scale) + x = (screen_width // 2) - (window_width // 2) + y = (screen_height // 2) - (window_height // 2) + root.geometry(f"{window_width}x{window_height}+{x}+{y}") + root.wm_attributes("-transparentcolor", "black") + canvas = tk.Canvas(root, width=window_width, height=window_height, highlightthickness=0, bg='black') + canvas.pack(fill=tk.BOTH, expand=True) + + def update_frame(): + nonlocal cap + try: + ret, frame = cap.read() + if ret: + frame = cv2.resize(frame, (window_width, window_height), interpolation=cv2.INTER_CUBIC) + frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + lower_black = np.array([0, 0, 0]) + upper_black = np.array([30, 30, 30]) + mask = cv2.inRange(frame_rgb, lower_black, upper_black) + frame_rgb[mask > 0] = [0, 0, 0] + img = tk.PhotoImage(data=cv2.imencode('.ppm', frame_rgb)[1].tobytes()) + canvas.delete("all") + canvas.create_image(0, 0, anchor=tk.NW, image=img) + canvas.image = img + delay = int(1000 / fps) if fps > 0 else 33 + root.after(delay, update_frame) + else: + cap.release() + self._fade_out(root) + except tk.TclError: + cap.release() + try: + root.destroy() + except: + pass + except Exception as e: + print(f"更新视频帧时出错: {e}") + cap.release() + try: + root.destroy() + except: + pass + + update_frame() + root.mainloop() + except Exception as e: + print(f"播放视频时出错: {e}") + try: + if 'cap' in locals() and cap.isOpened(): + cap.release() + except: + pass + try: + if 'root' in locals(): + root.after(100, root.destroy) + except: + pass + + def _fade_out(self, root, duration=1000): + """实现窗口渐隐效果""" + try: + alpha = float(root.wm_attributes("-alpha")) + except: + alpha = 1.0 + alpha -= 0.1 + if alpha <= 0: + try: + root.destroy() + except: + pass + else: + root.wm_attributes("-alpha", alpha) + root.after(50, self._fade_out, root, duration) diff --git a/xcli_core/cli_history.py b/xcli_core/cli_history.py new file mode 100644 index 0000000..82c2538 --- /dev/null +++ b/xcli_core/cli_history.py @@ -0,0 +1,60 @@ +import os +import time +import json +from colorama import Fore, Style + + +class HistoryMixin: + """聊天记录管理相关方法""" + + def save_chat_history(self, name): + """保存当前聊天记录到文件""" + try: + chat_data = { + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), + "conversation": self.shared_conversation_history.copy() + } + file_path = os.path.join(self.chat_history_dir, f"{name}.json") + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(chat_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 list_chat_history(self): + """列出所有已保存的聊天记录""" + try: + chat_files = [f for f in os.listdir(self.chat_history_dir) if f.endswith('.json')] + if not chat_files: + print(f"{Fore.YELLOW}没有已保存的聊天记录{Style.RESET_ALL}") + return + print(f"{Fore.GREEN}已保存的聊天记录:{Style.RESET_ALL}") + for i, file in enumerate(chat_files, 1): + file_path = os.path.join(self.chat_history_dir, file) + mod_time = os.path.getmtime(file_path) + mod_time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mod_time)) + chat_name = file[:-5] + print(f"{Fore.GREEN}{i}. {chat_name} (保存时间: {mod_time_str}){Style.RESET_ALL}") + except Exception as e: + print(f"{Fore.RED}列出聊天记录失败: {e}{Style.RESET_ALL}") + + def load_chat_history(self, name): + """加载指定的聊天记录""" + try: + file_path = os.path.join(self.chat_history_dir, f"{name}.json") + if not os.path.exists(file_path): + print(f"{Fore.RED}聊天记录 '{name}' 不存在{Style.RESET_ALL}") + return False + with open(file_path, 'r', encoding='utf-8') as f: + chat_data = json.load(f) + self.shared_conversation_history = chat_data.get("conversation", []) + self._set_shared_conversation_history() + print(f"{Fore.GREEN}已加载聊天记录: {name}{Style.RESET_ALL}") + if self.shared_conversation_history: + print(f"{Fore.CYAN}加载了 {len(self.shared_conversation_history)} 条对话记录{Style.RESET_ALL}") + return True + except Exception as e: + print(f"{Fore.RED}加载聊天记录失败: {e}{Style.RESET_ALL}") + return False diff --git a/xcli_core/cli_tools.py b/xcli_core/cli_tools.py new file mode 100644 index 0000000..544f715 --- /dev/null +++ b/xcli_core/cli_tools.py @@ -0,0 +1,277 @@ +import sys +import time +import threading +import json +from concurrent.futures import ThreadPoolExecutor, as_completed +from colorama import Fore, Style + +from .constants import UNIFIED_TOOL_MANAGER_AVAILABLE + + +class ToolMixin: + """工具调用相关方法""" + + def _extract_tool_calls(self, json_data) -> list: + """从 JSON 数据中提取工具调用列表(支持多种格式)""" + tool_calls = [] + + if isinstance(json_data, dict): + # 格式1: 小狸自有格式 + if json_data.get('action') == 'use_tool': + tool_calls.append(json_data) + # 格式2: 简化格式 + elif 'tool' in json_data and 'args' in json_data: + tool_calls.append(json_data) + # 格式3: OpenAI function_call 格式 + elif 'function_call' in json_data: + fc = json_data['function_call'] + if isinstance(fc, dict): + args = fc.get('arguments', '') + if isinstance(args, str) and args.startswith('{'): + try: + args = json.loads(args) + except: + pass + tool_calls.append({ + 'tool': fc.get('name', ''), + 'args': args if isinstance(args, str) else '', + 'arguments': args if isinstance(args, dict) else None + }) + # 格式4: MCP/Anthropic 格式 + elif 'name' in json_data and 'arguments' in json_data: + args = json_data.get('arguments', '') + if isinstance(args, str) and args.startswith('{'): + try: + args = json.loads(args) + except: + pass + tool_calls.append({ + 'tool': json_data.get('name', ''), + 'args': args if isinstance(args, str) else '', + 'arguments': args if isinstance(args, dict) else None + }) + # 格式5: OpenAI tool_calls 格式 + elif json_data.get('type') == 'function' and 'function' in json_data: + func = json_data['function'] + args = func.get('arguments', '') + if isinstance(args, str) and args.startswith('{'): + try: + args = json.loads(args) + except: + pass + tool_calls.append({ + 'tool': func.get('name', ''), + 'args': args if isinstance(args, str) else '', + 'arguments': args if isinstance(args, dict) else None + }) + elif isinstance(json_data, list): + for item in json_data: + if isinstance(item, dict): + if item.get('action') == 'use_tool': + tool_calls.append(item) + elif 'tool' in item and 'args' in item: + tool_calls.append(item) + elif 'name' in item and 'arguments' in item: + args = item.get('arguments', '') + if isinstance(args, str) and args.startswith('{'): + try: + args = json.loads(args) + except: + pass + tool_calls.append({ + 'tool': item.get('name', ''), + 'args': args if isinstance(args, str) else '', + 'arguments': args if isinstance(args, dict) else None + }) + + return tool_calls if tool_calls else None + + def process_tool_call(self, tool_call_data): + """处理工具调用(支持 Liugin 和 MCP 两种格式)""" + try: + tool_name = tool_call_data.get('tool') + args = tool_call_data.get('args', '') + arguments = tool_call_data.get('arguments') + + if UNIFIED_TOOL_MANAGER_AVAILABLE and hasattr(self.liugin_manager, 'execute'): + if arguments and isinstance(arguments, dict): + result = self.liugin_manager.execute(tool_name, args, **arguments) + else: + result = self.liugin_manager.execute(tool_name, args) + return result + + # 回退:直接调用 handler(旧逻辑) + for tool in self.liugin_manager.tools: + if tool['name'] == tool_name: + try: + result = tool['handler'](args) + if result is None: + result = "无结果" + return {"result": result} + except Exception as e: + return {"result": f"工具执行错误: {e}"} + + return {"result": f"未找到工具: {tool_name}"} + + except Exception as e: + return {"result": f"处理工具调用时出错: {e}"} + + def process_tool_calls_concurrent(self, tool_calls_list): + """并发批量执行工具调用,按顺序返回结果""" + if not tool_calls_list: + return [] + + cancel_event = threading.Event() + history_lock = threading.Lock() + + def execute_single_tool(tool_data, index): + if cancel_event.is_set(): + return {"index": index, "result": "工具执行已取消"} + try: + result = self.process_tool_call(tool_data) + tool_result = result.get('result', '无结果') + with history_lock: + self.shared_conversation_history.append({ + "role": "assistant", + "content": f"工具{index+1}执行结果:\n{tool_result}" + }) + return {"index": index, "result": tool_result} + except Exception as e: + error_msg = f"工具执行错误: {e}" + with history_lock: + self.shared_conversation_history.append({ + "role": "assistant", + "content": error_msg + }) + return {"index": index, "result": error_msg} + + def supports_esc_detection(): + return sys.platform == 'win32' + + def check_key_input(): + try: + import msvcrt + return msvcrt.kbhit() + except: + return False + + def get_key(): + try: + import msvcrt + return msvcrt.getch() + except: + return None + + def detect_esc_key(): + if supports_esc_detection(): + while not cancel_event.is_set() and not all(f.done() for f in futures): + if check_key_input(): + key = get_key() + if key == b'\x1b': + cancel_event.set() + break + time.sleep(0.1) + + with ThreadPoolExecutor(max_workers=min(len(tool_calls_list), 10)) as executor: + futures = {executor.submit(execute_single_tool, tool_data, i): i + for i, tool_data in enumerate(tool_calls_list)} + + esc_thread = threading.Thread(target=detect_esc_key, daemon=True) + esc_thread.start() + + results = [] + for future in as_completed(futures): + if cancel_event.is_set(): + for f in futures: + if not f.done(): + f.cancel() + break + try: + result = future.result() + results.append(result) + except Exception as e: + index = futures[future] + results.append({"index": index, "result": f"工具执行错误: {e}"}) + + results.sort(key=lambda x: x['index']) + return [r['result'] for r in results] + + def _execute_single_tool(self, tool_data): + """执行单个工具(带延迟和ESC检测)""" + tool_name = tool_data.get('tool', '未知工具') + tool_args = tool_data.get('args', '') + if len(tool_args) > 30: + tool_args_display = tool_args[:27] + "..." + else: + tool_args_display = tool_args + + # TUI 模式下不做延迟和 ESC 检测 + if not self.tui_output_callback: + try: + import msvcrt + canceled = False + spinners = ['⊶', '⊷'] + spinner_index = 0 + for i in range(25): + if msvcrt.kbhit(): + key = msvcrt.getch() + if key == b'\x1b': + canceled = True + break + spinner = spinners[spinner_index % 2] + spinner_index += 1 + loading_message = f" 正在执行: {spinner} {tool_name} {tool_args_display} (按ESC取消)" + sys.stdout.write(f"\r{loading_message}") + sys.stdout.flush() + time.sleep(0.2) + sys.stdout.write("\r" + " " * 60 + "\r") + sys.stdout.flush() + if canceled: + print(f"{Fore.YELLOW}工具执行已取消(用户按ESC键){Style.RESET_ALL}") + cancel_message = f"工具 {tool_name} 执行已被用户取消(按ESC键)。工具参数: {tool_args}" + self.shared_conversation_history.append({ + "role": "assistant", + "content": cancel_message + }) + return True + except ImportError: + pass + + tool_results = self.process_tool_call(tool_data) + full_result = tool_results.get('result', '无结果') + from datetime import datetime + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + self._display_tool_result(tool_name, tool_args, full_result, current_time) + current_input = f"工具调用结果 (时间: {current_time}):\n{full_result}" + self.shared_conversation_history.append({ + "role": "assistant", + "content": f"工具调用结果 (时间: {current_time}):\n{full_result}" + }) + self.process_conversation(current_input) + + def _execute_concurrent_tools(self, tool_calls_list): + """并发批量执行工具调用""" + if not tool_calls_list: + return + + self._output(f" 正在批量执行 {len(tool_calls_list)} 个工具") + + results = self.process_tool_calls_concurrent(tool_calls_list) + + from datetime import datetime + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + for i, (tool_data, result) in enumerate(zip(tool_calls_list, results)): + tool_name = tool_data.get('tool', '未知工具') + tool_args = tool_data.get('args', '') + self._display_tool_result(tool_name, tool_args, result, current_time) + + all_results = "\n".join([f"工具{i+1}结果:\n{result}" for i, result in enumerate(results)]) + current_input = f"批量工具调用结果 (时间: {current_time}):\n{all_results}" + + self.shared_conversation_history.append({ + "role": "assistant", + "content": f"批量工具调用结果 (时间: {current_time}):\n{all_results}" + }) + + self.process_conversation(current_input) diff --git a/xcli_core/config.py b/xcli_core/config.py new file mode 100644 index 0000000..541d126 --- /dev/null +++ b/xcli_core/config.py @@ -0,0 +1,54 @@ +import os +import json +import logging +from colorama import Fore, Style + +# 配置文件路径 +CONFIG_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json") + + +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: + print(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) + + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('xiaoli_cli.log', encoding='utf-8'), + logging.StreamHandler() # 同时输出到控制台 + ] +) +logger = logging.getLogger(__name__) diff --git a/xcli_core/constants.py b/xcli_core/constants.py new file mode 100644 index 0000000..989adb0 --- /dev/null +++ b/xcli_core/constants.py @@ -0,0 +1,94 @@ +# 常量定义 +WEB_SERVER_DEFAULT_PORT = 8080 +MAX_FILE_SIZE = 52428800 # 50MB +MAX_VIDEO_DURATION = 10000 # 10秒 +VIDEO_FRAME_DELAY = 0.04 # 40毫秒 +LOVE_FILE_PATH = "love.txt" +DEFAULT_MAX_HISTORY = 999999 # 无限制对话历史 + +import os +import sys +import importlib.util +import json +import re +import requests +import shutil +import time +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed +from openai import OpenAI +from colorama import init, Fore, Style + +# tkinter 仅在视频播放功能中使用,服务器环境可能不可用 +try: + import tkinter as tk + from tkinter import ttk + TKINTER_AVAILABLE = True +except ImportError: + TKINTER_AVAILABLE = False + +# cv2 仅在视频播放功能中使用 +try: + import cv2 + CV2_AVAILABLE = True +except ImportError: + CV2_AVAILABLE = False + +import numpy as np +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs +import socket +import webbrowser +from datetime import datetime +import logging + +# 图像显示相关(可选) +try: + from PIL import Image + PIL_AVAILABLE = True +except ImportError: + PIL_AVAILABLE = False + +try: + from ascii_magic import AsciiArt + ASCII_MAGIC_AVAILABLE = True +except ImportError: + ASCII_MAGIC_AVAILABLE = False + +# WebSocket 服务端 +try: + import websocket_server + WEBSOCKET_AVAILABLE = True +except ImportError: + WEBSOCKET_AVAILABLE = False + +# Clawli 独立进程服务器 +try: + import clawli_server + CLAWLI_SERVER_AVAILABLE = True +except ImportError: + CLAWLI_SERVER_AVAILABLE = False + +# 统一工具管理器 - 支持 Liugin 和 Skill 双协议 +try: + from unified_tool_manager import UnifiedToolManager + UNIFIED_TOOL_MANAGER_AVAILABLE = True +except ImportError: + UNIFIED_TOOL_MANAGER_AVAILABLE = False + +# Textual TUI 支持 +import asyncio +from typing import Optional +from io import StringIO + +try: + from textual.app import App, ComposeResult + from textual.containers import Container, Horizontal, Vertical, VerticalScroll + from textual.widgets import Header, Footer, Input, RichLog, Static, Button, Tree + from textual.widgets.tree import TreeNode + from textual.binding import Binding + from textual.events import Mount + TEXTUAL_AVAILABLE = True +except ImportError: + TEXTUAL_AVAILABLE = False + print(f"{Fore.YELLOW}Textual 未安装,TUI 模式不可用。请运行: pip install textual{Style.RESET_ALL}") diff --git a/xcli_core/plugin_manager.py b/xcli_core/plugin_manager.py new file mode 100644 index 0000000..cda3b3d --- /dev/null +++ b/xcli_core/plugin_manager.py @@ -0,0 +1,132 @@ +import os +import importlib.util +from colorama import Fore, Style +from .config import logger + + +class LiuginManager: + """插件管理器""" + + def __init__(self, liugins_dir="plugins"): + self.liugins_dir = liugins_dir + self.tools = [] + + def get_enabled_liugins(self): + """获取启用的插件列表""" + # 检查环境变量中是否有启用的插件列表 + enabled_liugins_env = os.environ.get("XIAOLI_ENABLED_PLUGINS") + if enabled_liugins_env is not None: + # 特殊处理:空字符串表示不启用任何插件 + if enabled_liugins_env == "": + return [] + return enabled_liugins_env.split(",") + # 检查是否有插件配置文件 + project_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + config_file = os.path.join(project_dir, "plugins_config.py") + if os.path.exists(config_file): + try: + # 读取配置文件 + with open(config_file, 'r', encoding='utf-8') as f: + config_content = f.read() + # 检查是否明确表示不启用任何插件 + if "# 不启用任何插件" in config_content: + return [] + # 解析启用的插件 + enabled_liugins = [] + for line in config_content.split('\n'): + if line.startswith('ENABLE_') and '= True' in line: + liugin_name = line.split('=')[0].replace('ENABLE_', '').strip().lower() + enabled_liugins.append(liugin_name) + return enabled_liugins + except (FileNotFoundError, PermissionError, UnicodeDecodeError) as e: + logger.error(f"读取插件配置文件失败: {e}") + print(f"{Fore.RED}读取插件配置文件失败: {e}{Style.RESET_ALL}") + except Exception as e: + logger.error(f"读取插件配置时发生未知错误: {e}") + print(f"{Fore.RED}读取插件配置文件失败: {e}{Style.RESET_ALL}") + # 如果没有配置,则返回None表示加载所有插件 + return None + + def load_liugins(self): + """加载liugin""" + self.tools = [] + # 获取启用的插件列表 + enabled_liugins = self.get_enabled_liugins() + # 加载工具插件 + if os.path.exists(self.liugins_dir): + for filename in os.listdir(self.liugins_dir): + if filename.endswith('.py') and filename != '__init__.py': + liugin_name = filename[:-3] # 移除.py扩展名 + # 如果有启用的插件列表,检查当前插件是否在其中 + if enabled_liugins is not None and liugin_name.lower() not in enabled_liugins: + print(f"{Fore.YELLOW}跳过liugin: {liugin_name} (未启用){Style.RESET_ALL}") + continue + plugin_path = os.path.join(self.liugins_dir, filename) + try: + spec = importlib.util.spec_from_file_location(liugin_name, plugin_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + # 获取插件信息 - 兼容 Liugin 和 Plugin 两种类名 + plugin_class = getattr(module, 'Liugin', None) or getattr(module, 'Plugin', None) + if plugin_class: + plugin_instance = plugin_class() + tool_info = plugin_instance.get_tool_info() + tool_info['handler'] = plugin_instance.handle + self.tools.append(tool_info) + print(f"{Fore.GREEN}已加载liugin: {liugin_name}{Style.RESET_ALL}") + except (ImportError, AttributeError, TypeError) as e: + logger.error(f"加载liugin失败 {filename}: {e}") + print(f"{Fore.RED}加载liugin失败 {filename}: {e}{Style.RESET_ALL}") + except FileNotFoundError as e: + logger.error(f"liugin文件未找到 {filename}: {e}") + print(f"{Fore.RED}liugin文件未找到 {filename}: {e}{Style.RESET_ALL}") + except Exception as e: + logger.error(f"加载liugin时发生未知错误 {filename}: {e}") + print(f"{Fore.RED}加载liugin失败 {filename}: {e}{Style.RESET_ALL}") + print(f"{Fore.GREEN}总共加载了 {len(self.tools)} 个工具插件{Style.RESET_ALL}") + + def search_tools(self, query, limit=5): + """ + 搜索工具,支持关键词匹配 + """ + if not self.tools: + return [] + + query_lower = query.lower() + matches = [] + + for tool in self.tools: + score = 0 + tool_name = tool.get('name', '').lower() + tool_desc = tool.get('description', '').lower() + tool_keywords = [kw.lower() for kw in tool.get('keywords', [])] + + if query_lower == tool_name: + score += 100 + if query_lower in tool_name: + score += 50 + if query_lower in tool_desc: + score += 30 + for keyword in tool_keywords: + if query_lower in keyword: + score += 20 + break + + if score > 0: + matches.append({ + 'score': score, + 'name': tool.get('name', ''), + 'description': tool.get('description', ''), + 'usage': tool.get('usage', ''), + 'keywords': tool.get('keywords', []) + }) + + matches.sort(key=lambda x: x['score'], reverse=True) + return matches[:limit] + + def get_tool_by_name(self, tool_name): + """根据名称获取工具""" + for tool in self.tools: + if tool.get('name', '').lower() == tool_name.lower(): + return tool + return None diff --git a/xcli_core/sandbox.py b/xcli_core/sandbox.py new file mode 100644 index 0000000..043e751 --- /dev/null +++ b/xcli_core/sandbox.py @@ -0,0 +1,183 @@ +# ── 安全代码沙箱 v2(子进程隔离) ── + +import ast as _ast +import re +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() diff --git a/xcli_core/tui.py b/xcli_core/tui.py new file mode 100644 index 0000000..da79421 --- /dev/null +++ b/xcli_core/tui.py @@ -0,0 +1,456 @@ +"""TUI 界面模块 - Textual TUI 实现""" + +from .constants import TEXTUAL_AVAILABLE + +if TEXTUAL_AVAILABLE: + import re + import asyncio + 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.reactive import reactive, var + from textual.message import Message + from textual.binding import Binding + 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 + def engines(self) -> list: + return list(self.cli.engines.keys()) + def current_engine(self) -> str: + return self.cli.get_current_engine_name() + def tools(self) -> list: + return self.cli.liugin_manager.tools + def history(self) -> list: + return self.cli.shared_conversation_history + def switch_engine(self, name: str) -> bool: + if name in self.cli.engines: + self.cli.switch_engine(name) + return True + return False + + class XiaoliTUI(App): + """小狸 TUI v2 - GitHub 暗色主题""" + CSS = _TUI_CSS + TITLE = "🐱 小狸 Pro-CLI" + SUB_TITLE = "智能编程助手" + + 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("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) + + 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(" 就绪 | 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): + 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 + + 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(), + '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._remove_thinking) + + def tui_output(msg): + self.call_after_refresh(self._write_raw, msg) + + 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 + ) + finally: + self.cli.tui_output_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.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 + +else: + # Textual 不可用时的占位类 + class XiaoliTUI: + pass