From de15b54b193a0e3622398d3638a16f4e53297aef Mon Sep 17 00:00:00 2001 From: shuiyu1123 Date: Mon, 29 Jun 2026 13:00:19 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v6.1.0=20-=20TUI=20=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E5=85=A8=E9=9D=A2=E5=8D=87=E7=BA=A7=20+=20=E8=83=8C?= =?UTF-8?q?=E6=99=AF=E8=87=AA=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TUI 界面改进: - 全面中文化: CommandPalette/HelpPanel/Footer/Chatbox 边框标题等所有用户可见英文汉化 - 辅助中心 (Ctrl+P/F9): 原"设置"重命名, 含 6 项可切换设置 - 独立帮助浮层 (? 键): 重写帮助页面逻辑, 全中文, 带滑入淡入动画 - 背景切换: 辅助中心 [5] 预设背景, [6] 自定义背景; 新增 /bg 命令支持图片提取主色调 - 换行修复: Ctrl+J 作为主推换行键 (Windows 终端不区分 Shift+Enter) - 引擎切换修复: 解决 call_from_thread 在主线程崩溃的 RuntimeError 技术改进: - 文本工具调用解析: 检测模型用 标签返回的工具调用 - 网络工具优化: network_tools.py 和 ai_search.py 改进请求头/重试/编码 - 流式输出修复: append_chunk 的 refresh 改为 layout=True - 取消响应修复: 引擎层 _cancelled 标志 + TUI 层 worker 身份跟踪 融合 v6.0.0 工作: - 保留 terminal 模拟器/operit 桥接/深度思考/工作模式/记忆模式等 v6.0.0 全部功能 - 在此基础上叠加本会话的 TUI 改进 --- CHANGELOG.md | 22 + about.txt | 4 +- ai_engines/ollama_engine.py | 128 +- image_engine/__init__.py | 2 +- image_engine/ollama_engine.py | 14 +- plugins/ai_search.py | 78 +- plugins/auto_engineer.py | 12 +- plugins/code_editor.py | 4 +- plugins/compile_check.py | 568 +++++ plugins/frontend_tester.py | 3 +- plugins/git_tools.py | 2 +- plugins/gui_auto.py | 4 +- plugins/id_generator.py | 2 +- plugins/network_tools.py | 208 +- plugins/scheduler.py | 42 +- plugins/snippet_manager.py | 19 +- plugins/sub_agent.py | 54 +- plugins/workflow.py | 45 +- skills/base.py | 14 - skills/file-manager/scripts/file_ops.py | 3 +- test_tui_send.py | 98 - websocket_server.py | 41 +- xcli_core/cli_base.py | 42 +- xcli_core/cli_clawli.py | 13 +- xcli_core/cli_history.py | 97 +- xcli_core/config.py | 54 + xcli_core/fc_tools.py | 4 + xcli_core/notification.py | 15 +- xcli_core/process_protection.py | 41 +- xcli_core/tui.py | 2568 ++++++++++++++++++----- 30 files changed, 3267 insertions(+), 934 deletions(-) create mode 100644 plugins/compile_check.py delete mode 100644 test_tui_send.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 43097d7..a8c74bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # 更新日志 +## v6.1.0 (2026-06-28) + +**TUI 体验全面升级 + 背景自定义** + +### TUI 界面改进 + +- **全面中文化** — CommandPalette(命令搜索面板)子选项、placeholder、系统命令名全部汉化;HelpPanel 帮助页面、Footer、Chatbox 边框标题、错误前缀、辅助中心面板等所有用户可见英文汉化 +- **辅助中心**(Ctrl+P / F9)— 原"设置"页面重命名为"辅助中心",含 6 项可切换设置(主题/引擎/历史/安全/预设背景/自定义背景) +- **独立帮助浮层**(? 键)— 重写帮助页面逻辑,独立浮层替代原 Textual HelpPanel 侧边栏,全中文内容,带滑入淡入动画 +- **背景切换** — 辅助中心 [5] 切换 4 种预设背景色(墨黑/深蓝灰/深紫/深蓝),[6] 自定义背景;新增 `/bg` 命令支持用图片提取主色调作为背景 +- **换行修复** — Ctrl+J 作为主推换行键(Windows 终端不区分 Shift+Enter),同时保留 shift+enter/alt+enter 兼容 +- **引擎切换修复** — 解决 `call_from_thread` 在主线程崩溃的 RuntimeError,主线程直接调用,工作线程才走 call_from_thread + +### 技术改进 + +- **文本工具调用解析** — 新增 `_handle_text_tool_call` 方法,检测模型在文本响应中用 `` 标签返回的工具调用(非标准 Function Calling API),解析并执行 +- **网络工具优化** — network_tools.py 和 ai_search.py 改进请求头、重试机制、编码处理 +- **流式输出修复** — Chatbox.append_chunk 和 ThinkingBox.append_chunk 的 refresh 改为 layout=True,修复流式只显示首行 +- **取消响应修复** — 引擎层增加 `_cancelled` 标志和 cancel() 方法,TUI 层跟踪 worker 身份,action_cancel 直接调用 _finish_cancel + +--- + ## v6.0.0 (2026-06-23) **重大更新:插件生态 + 深度思考 + 工作模式** diff --git a/about.txt b/about.txt index f5459ab..9296c1d 100644 --- a/about.txt +++ b/about.txt @@ -1,7 +1,7 @@ **产品标识** - **产品名称**:小狸智能体 增强-终端(Xiaoli Pro-CLI) -- **内部版本号**:20260428 -- **发布版本**:发行版6.0 +- **内部版本号**:20260628 +- **发布版本**:发行版6.1.0 **技术规格** - **主要语言**:简体中文(UTF-8) diff --git a/ai_engines/ollama_engine.py b/ai_engines/ollama_engine.py index 1cfe2bb..fb60aeb 100644 --- a/ai_engines/ollama_engine.py +++ b/ai_engines/ollama_engine.py @@ -22,6 +22,26 @@ project_root = os.path.dirname(current_dir) sys.path.insert(0, project_root) +def _safe_get(obj, key, default=None): + """兼容 dict 和 pydantic 对象的安全取值(新版 ollama 返回 pydantic 模型,不支持 .get())""" + if obj is None: + return default + # 1. 先尝试 dict 访问 + try: + return obj[key] + except (KeyError, TypeError): + pass + # 2. 尝试属性访问(pydantic 模型) + val = getattr(obj, key, None) + if val is not None: + return val + # 3. 尝试 model_dump 后的 dict 访问 + try: + return obj.model_dump()[key] + except (AttributeError, KeyError, TypeError): + return default + + class OllamaAI: """基于Ollama的本地AI引擎""" requires_api_key = False # Ollama是本地引擎,不需要API密钥 @@ -80,7 +100,10 @@ Ollama AI引擎插件帮助信息 # 共享对话历史引用(由CLI设置) self.shared_conversation_history = None - + + # 取消标志 + self._cancelled = False + # 直接启用思考标记,由主程序自动处理深度思考模型的响应 self.thinking_start_marker = "" self.thinking_end_marker = "" @@ -93,6 +116,33 @@ Ollama AI引擎插件帮助信息 else: print(f"{Fore.RED}Ollama服务未运行,引擎将不可用{Style.RESET_ALL}") + def cancel(self): + """取消正在进行的请求""" + self._cancelled = True + + def apply_config(self, base_url=None, api_key=None, model=None): + """应用新配置到当前引擎实例(内存),并持久化到 config.json""" + if base_url is not None: + self.base_url = base_url.rstrip('/') + if api_key is not None: + self.api_key = api_key + if model is not None: + self.model = model + # 持久化到 config.json + try: + import os, json + config_file = os.path.join(project_root, "config.json") + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + engine_cfg = config.setdefault("api", {}).setdefault("engines", {}).setdefault("ollama", {}) + engine_cfg["base_url"] = self.base_url + engine_cfg["api_key"] = self.api_key + engine_cfg["model"] = self.model + with open(config_file, 'w', encoding='utf-8') as f: + json.dump(config, f, ensure_ascii=False, indent=2) + except Exception as e: + logger.warning(f"持久化引擎配置失败: {e}") + def check_ollama_service(self): """检测Ollama服务是否运行""" # 检查 ollama 库是否可用 @@ -107,7 +157,7 @@ Ollama AI引擎插件帮助信息 # 如果出现异常,说明服务未运行 return False - def generate_response(self, user_input, tool_results=None, system_prompt=None, tools=None): + def generate_response(self, user_input, tool_results=None, system_prompt=None, tools=None, stream_start_event=None): """ 生成AI响应 - 使用Ollama API @@ -137,10 +187,20 @@ Ollama AI引擎插件帮助信息 messages = self._build_messages_with_history(system_prompt, user_input) # 构建调用参数 + # 重置取消标志 + self._cancelled = False + # 获取流式回调 + stream_cb = None + thinking_cb = None + if self.cli and hasattr(self.cli, 'tui_stream_callback'): + stream_cb = self.cli.tui_stream_callback + if self.cli and hasattr(self.cli, 'tui_thinking_callback'): + thinking_cb = self.cli.tui_thinking_callback + kwargs = { "model": self.model, "messages": messages, - "stream": False + "stream": True if stream_cb else False } # ── Function Calling: 注入工具定义 ── @@ -150,16 +210,45 @@ Ollama AI引擎插件帮助信息 # 使用Ollama Python库调用API response = ollama_client.chat(**kwargs) + # ── 流式模式:逐块读取 ── + if stream_cb: + full_content = "" + reasoning_content = "" + for chunk in response: + if self._cancelled: + break + msg = _safe_get(chunk, "message", {}) + # 检查 tool_calls + tc_raw = _safe_get(msg, "tool_calls") + if tc_raw: + # 工具调用 — 走原逻辑 + message = msg + tool_calls_raw = tc_raw + break + # 深度思考(Ollama thinking 字段) + rc = _safe_get(msg, "thinking", "") + if rc and thinking_cb: + reasoning_content += rc + thinking_cb(rc) + content = _safe_get(msg, "content", "") + if content: + full_content += content + stream_cb(content) + else: + # 流式完成,无工具调用 + return full_content.encode('utf-8', 'replace').decode('utf-8') + else: + message = _safe_get(response, "message", {}) + tool_calls_raw = _safe_get(message, "tool_calls") + # ── Function Calling: 检查 tool_calls ── - message = response.get("message", {}) - tool_calls_raw = message.get("tool_calls") if tool_calls_raw: # Ollama 的 tool_calls 格式: [{"function": {"name": ..., "arguments": {...}}}] fc_calls = [] for i, tc in enumerate(tool_calls_raw): - func = tc.get("function", {}) - name = func.get("name", "") - args = func.get("arguments", {}) + func = _safe_get(tc, "function", {}) + name = _safe_get(func, "name", "") + args = _safe_get(func, "arguments", {}) call_id = f"call_{name}_{i}" # 统一为小狸内部格式 @@ -189,7 +278,7 @@ Ollama AI引擎插件帮助信息 }, ensure_ascii=False) # 普通文本响应 - content = message.get("content", "") + content = _safe_get(message, "content", "") # 清理无效的 UTF-8 代理字符 content = content.encode('utf-8', 'replace').decode('utf-8') return content @@ -306,11 +395,16 @@ Ollama AI引擎插件帮助信息 """列出本地可用的模型""" if not self.is_service_running: return ["错误:Ollama服务未运行"] - + try: response = ollama_client.list() - models = [model["name"] for model in response["models"]] - return models + models_list = _safe_get(response, "models", []) + result = [] + for model in models_list: + name = _safe_get(model, "model", "") or _safe_get(model, "name", "") + if name: + result.append(name) + return result if result else ["未找到本地模型"] except Exception as e: return [f"获取模型列表失败: {str(e)}"] @@ -392,12 +486,14 @@ Ollama AI引擎插件帮助信息 # 逐块处理响应 for part in response: - if 'status' in part: - status = part['status'] + status = _safe_get(part, "status", None) + if status: print(f"{Fore.YELLOW}{status}{Style.RESET_ALL}") # 检查是否完成 - if 'completed' in part and 'total' in part: - if part['completed'] == part['total']: + completed = _safe_get(part, "completed", None) + total = _safe_get(part, "total", None) + if completed is not None and total is not None: + if completed == total: print(f"{Fore.GREEN}模型 {model_name} 拉取完成!{Style.RESET_ALL}") print(f"{Fore.GREEN}模型 {model_name} 拉取成功!{Style.RESET_ALL}") diff --git a/image_engine/__init__.py b/image_engine/__init__.py index 8048eef..b3f766a 100644 --- a/image_engine/__init__.py +++ b/image_engine/__init__.py @@ -106,7 +106,7 @@ class ImageEngineManager: for filename in os.listdir(engine_dir): if filename.endswith('_engine.py') and filename != '__init__.py': - engine_name = filename[:-11] # 移除 _engine.py + engine_name = filename[:-10] # 移除 _engine.py try: # 动态导入模块 module_path = os.path.join(engine_dir, filename) diff --git a/image_engine/ollama_engine.py b/image_engine/ollama_engine.py index 5e27d03..10c2d61 100644 --- a/image_engine/ollama_engine.py +++ b/image_engine/ollama_engine.py @@ -14,6 +14,14 @@ except ImportError: OLLAMA_AVAILABLE = False +def _safe_get(obj, key, default=None): + """兼容 dict 和 pydantic 对象的安全取值""" + try: + return obj[key] + except (KeyError, AttributeError, TypeError): + return default + + class OllamaImageEngine(BaseImageEngine): """Ollama 视觉引擎""" @@ -69,7 +77,7 @@ class OllamaImageEngine(BaseImageEngine): try: result = ollama.list() - self.installed_models = [m['model'] for m in result.get('models', [])] + self.installed_models = [m['model'] for m in _safe_get(result, 'models', [])] except: self.installed_models = [] @@ -137,7 +145,7 @@ class OllamaImageEngine(BaseImageEngine): 'images': [image_path] }] ) - return response.get('message', {}).get('content', '') + return _safe_get(_safe_get(response, 'message', {}), 'content', '') except Exception as e: raise Exception(f"Ollama识别失败: {str(e)}") @@ -168,7 +176,7 @@ class OllamaImageEngine(BaseImageEngine): stream=False ) - content = response.get('message', {}).get('content', '') + content = _safe_get(_safe_get(response, 'message', {}), 'content', '') if content: yield content else: diff --git a/plugins/ai_search.py b/plugins/ai_search.py index 06b2e42..a88eed5 100644 --- a/plugins/ai_search.py +++ b/plugins/ai_search.py @@ -1,5 +1,6 @@ import subprocess import json +import time from urllib.parse import quote import requests @@ -7,6 +8,14 @@ import requests class Liugin: """AI搜索插件 - 使用jina.ai进行智能搜索""" + # 统一 User-Agent(模拟浏览器,避免被反爬拦截) + _UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' + _HEADERS = { + 'User-Agent': _UA, + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + } + def __init__(self): self.usage = """AI搜索工具使用方法:/r ai_search <操作> <参数>/r @@ -27,6 +36,7 @@ ai_search <操作> <参数>/r """ self.cli = None # CLI实例引用 self.timeout = 30 # 请求超时时间(秒) + self.max_retries = 2 # 最大重试次数 def set_cli(self, cli): """设置CLI实例引用""" @@ -162,34 +172,50 @@ JSON格式示例: return f"网络搜索错误: {str(e)}" def _execute_curl(self, url): - """使用 requests 库获取内容(替代 curl 命令)""" - try: - # 使用 requests 库发送请求 - response = requests.get( - url, - timeout=self.timeout, - headers={'User-Agent': 'Mozilla/5.0 (compatible; AI-Search-Bot)'} - ) - - # 检查状态码 - if response.status_code != 200: - return f"错误:HTTP请求失败,状态码: {response.status_code}" + """使用 requests 库获取内容(带重试、UA、编码处理)""" + last_error = None + for attempt in range(self.max_retries + 1): + try: + response = requests.get( + url, + timeout=self.timeout, + headers=self._HEADERS, + allow_redirects=True + ) - # 获取内容 - content = response.text.strip() + # 检查状态码 + if response.status_code != 200: + return f"错误:HTTP请求失败,状态码: {response.status_code}" - if not content: - return "错误:未获取到任何内容" + # 显式编码处理:避免 ISO-8859-1 默认导致中文乱码 + if not response.encoding or response.encoding.lower() == 'iso-8859-1': + response.encoding = response.apparent_encoding or 'utf-8' - # 限制输出长度以避免过长输出 - if len(content) > 5000: - content = content[:5000] + f"\n\n... (内容已截断,完整内容共{len(content)}字符)" + # 获取内容 + content = response.text.strip() - return content + if not content: + return "错误:未获取到任何内容" - except requests.Timeout: - return f"错误:请求超时 (>{self.timeout}秒)" - except requests.ConnectionError: - return "错误:网络连接失败,请检查网络" - except Exception as e: - return f"错误:请求时发生错误: {str(e)}" \ No newline at end of file + # 限制输出长度以避免过长输出 + if len(content) > 5000: + content = content[:5000] + f"\n\n... (内容已截断,完整内容共{len(content)}字符)" + + return content + + except requests.exceptions.Timeout: + last_error = f"请求超时 (>{self.timeout}秒)" + if attempt < self.max_retries: + time.sleep(1) + except requests.exceptions.ConnectionError: + last_error = "网络连接失败,请检查网络" + if attempt < self.max_retries: + time.sleep(1) + except requests.exceptions.RequestException as e: + last_error = str(e) + break + except Exception as e: + last_error = str(e) + break + + return f"错误:请求时发生错误: {last_error}" \ No newline at end of file diff --git a/plugins/auto_engineer.py b/plugins/auto_engineer.py index c800f66..ce588cc 100644 --- a/plugins/auto_engineer.py +++ b/plugins/auto_engineer.py @@ -368,8 +368,16 @@ class Liugin: if not test_files and len(py_files) > 5: suggestions.append(" 添加单元测试") - uses_print = sum(1 for f in py_files if 'print(' in open(f, errors='ignore').read()) - uses_log = sum(1 for f in py_files if 'logging' in open(f, errors='ignore').read()) + uses_print = 0 + uses_log = 0 + for f in py_files: + with open(f, errors='ignore') as fh: + content = fh.read() + if 'print(' in content: + uses_print += 1 + if 'logging' in content: + uses_log += 1 + if uses_print > uses_log and uses_print > 3: suggestions.append(" 考虑用 logging 替代 print") diff --git a/plugins/code_editor.py b/plugins/code_editor.py index af232e7..bf4bb0e 100644 --- a/plugins/code_editor.py +++ b/plugins/code_editor.py @@ -1158,8 +1158,10 @@ else: def _op_diff(self, path: str, rest: str) -> str: if not path: return "错误:请提供第一个文件路径" + if not rest.strip(): + return "错误:请提供第二个文件路径" f1 = self._norm(path) - f2 = self._norm(rest.split()[0] if rest.strip() else "") + f2 = self._norm(rest.split()[0]) if not os.path.exists(f1): return f"错误:文件不存在: {f1}" if not os.path.exists(f2): diff --git a/plugins/compile_check.py b/plugins/compile_check.py new file mode 100644 index 0000000..a63efc9 --- /dev/null +++ b/plugins/compile_check.py @@ -0,0 +1,568 @@ +""" +编译验证插件 - 批量语法/编译检查 +对项目内 Python 文件做 ast.parse + py_compile 双重验证, +支持按目录、单文件或全项目扫描,并发执行加速。 +""" +import os +import sys +import ast +import time +import shutil +import py_compile +import concurrent.futures +from pathlib import Path +from typing import List, Tuple, Optional + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from xcli_core.tool_result import ToolResult, ErrorCode + +try: + from colorama import Fore, Style, init as _colorama_init + _colorama_init(autoreset=False) + _HAS_COLOR = True +except Exception: + _HAS_COLOR = False + + class _Stub: + def __getattr__(self, _): return "" + Fore = Style = _Stub() + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +SKIP_DIRS = { + "__pycache__", ".git", ".venv", "venv", "env", ".env", + "node_modules", ".idea", ".vscode", "dist", "build", + ".mypy_cache", ".pytest_cache", ".ruff_cache", "site-packages", +} + +SKIP_FILES = { + "setup.py", +} + + +class Liugin: + """编译验证 - 批量语法检查 / 编译验证 / 清理 __pycache__""" + + def __init__(self): + self.usage = """编译验证工具 + +操作: + all - 验证项目内全部 Python 文件 + core - 仅验证 xcli_core/ 目录 + engines - 仅验证 ai_engines/ 目录 + plugins - 仅验证 plugins/ 目录 + dir <目录> - 验证指定目录 + check <文件> - 验证单个文件 (可多次指定) + clean [目录] - 清理 __pycache__ (默认项目根) + stats - 统计各目录 Python 文件数量 + +选项: + -v / --verbose - 显示每个文件的结果 + -j / --jobs - 并发线程数 (默认 8) + -q / --quiet - 仅在结束时打印汇总 + +输出: + ✅ 绿色 = 通过 + ❌ 红色 = 失败 + 汇总: 总数/通过/失败 + 失败文件详情 + +示例: + check all -v + check core + check plugins/compile_check.py + check dir ai_engines -j 16 + check clean +""" + self.cli = None + + def set_cli(self, cli): + self.cli = cli + + def get_tool_info(self): + return { + "name": "compile_check", + "description": "编译验证工具 — 批量语法/编译检查(ast.parse + py_compile),支持目录、单文件、全项目扫描,并发加速,清理 __pycache__", + "keywords": ["编译", "验证", "语法", "检查", "compile", "syntax", + "check", "lint", "py_compile", "ast", "批量", + "validate", "verify", "clean", "pycache"], + "usage": self.usage, + } + + def get_mcp_definition(self): + return { + "name": "compile_check", + "description": "编译验证工具", + "inputSchema": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["all", "core", "engines", "plugins", + "dir", "check", "clean", "stats"], + "description": "操作类型" + }, + "path": {"type": "string", + "description": "目录或文件路径"}, + "paths": {"type": "array", + "items": {"type": "string"}, + "description": "多文件路径列表"}, + "verbose": {"type": "boolean", "default": False}, + "jobs": {"type": "integer", "default": 8}, + "quiet": {"type": "boolean", "default": False}, + }, + "required": ["operation"] + } + } + + def convert_mcp_args(self, arguments): + op = arguments.get("operation", "") + path = arguments.get("path", "") + return f"{op} {path}".strip() + + def handle(self, args: str): + try: + parts = self._parse_args(args) + if not parts: + return self._print_usage() + + operation = parts[0].lower() + opts = self._extract_options(parts[1:]) + + if operation in ("help", "-h", "--help"): + return self._print_usage() + + routes = { + "all": self._op_all, + "core": self._op_core, + "engines": self._op_engines, + "plugins": self._op_plugins, + "dir": self._op_dir, + "check": self._op_check, + "clean": self._op_clean, + "stats": self._op_stats, + } + handler = routes.get(operation) + if not handler: + return ToolResult.fail( + f"不支持的操作 '{operation}'", + ErrorCode.UNSUPPORTED_OP, + tool_name="compile_check", + ) + return handler(opts) + + except Exception as e: + return ToolResult.fail( + f"编译验证错误: {e}", + ErrorCode.EXEC_FAILED, + tool_name="compile_check", + ) + + def _print_usage(self): + self._emit(self.usage) + return ToolResult.ok("usage_printed", tool_name="compile_check") + + def _out(self, msg: str): + """统一输出,适配 TUI/CLI""" + if self.cli is not None and hasattr(self.cli, "_output"): + self.cli._output(msg) + else: + print(msg) + + def _emit(self, msg: str, end: str = "\n"): + """统一输出,自动适配 TUI/CLI + + - TUI 模式: 一行一行输出到 _output,忽略 end(TUI 有自己的状态指示器) + - CLI 模式: 尊重 end,支持 end="" 的进度条刷新 + - TUI 模式下 end != "\\n" 的进度条输出直接跳过(避免污染对话区) + """ + if self.cli is not None and hasattr(self.cli, "_output"): + if end != "\n": + return # TUI 模式跳过进度条类输出 + self.cli._output(msg) + else: + print(msg, end=end) + + def _ok(self, msg: str) -> str: + return f"{Fore.GREEN}{msg}{Style.RESET_ALL}" + + def _err(self, msg: str) -> str: + return f"{Fore.RED}{msg}{Style.RESET_ALL}" + + def _warn(self, msg: str) -> str: + return f"{Fore.YELLOW}{msg}{Style.RESET_ALL}" + + def _dim(self, msg: str) -> str: + return f"{Fore.CYAN}{msg}{Style.RESET_ALL}" + + def _banner(self, title: str) -> str: + bar = "═" * max(20, len(title) + 4) + return f"{Fore.CYAN}{bar}\n {title}\n{bar}{Style.RESET_ALL}" + + def _parse_args(self, args: str) -> List[str]: + parts, current, in_quotes, qc = [], "", False, None + for ch in args: + if ch in ('"', "'") and not in_quotes: + in_quotes, qc = True, ch + elif ch == qc and in_quotes: + in_quotes, qc = False, None + elif ch == " " and not in_quotes: + if current: + parts.append(current) + current = "" + else: + current += ch + if current: + parts.append(current) + return parts + + def _extract_options(self, args: List[str]) -> dict: + opts = { + "positional": [], + "verbose": False, + "quiet": False, + "jobs": 8, + } + i = 0 + while i < len(args): + a = args[i] + if a in ("-v", "--verbose"): + opts["verbose"] = True + elif a in ("-q", "--quiet"): + opts["quiet"] = True + elif a in ("-j", "--jobs") and i + 1 < len(args): + try: + opts["jobs"] = max(1, int(args[i + 1])) + except ValueError: + pass + i += 1 + elif not a.startswith("-"): + opts["positional"].append(a) + i += 1 + return opts + + def _collect_py_files(self, root: str) -> List[str]: + root = os.path.abspath(root) + if not os.path.isdir(root): + return [] + result: List[str] = [] + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] + for fn in filenames: + if not fn.endswith(".py"): + continue + if fn in SKIP_FILES: + continue + result.append(os.path.join(dirpath, fn)) + result.sort() + return result + + def _verify_one(self, filepath: str) -> Tuple[str, bool, str]: + try: + with open(filepath, "r", encoding="utf-8") as f: + src = f.read() + except UnicodeDecodeError: + try: + with open(filepath, "r", encoding="gbk") as f: + src = f.read() + except Exception as e: + return filepath, False, f"读取失败: {e}" + except Exception as e: + return filepath, False, f"读取失败: {e}" + + try: + ast.parse(src, filename=filepath) + except SyntaxError as e: + line = getattr(e, "lineno", "?") + col = getattr(e, "offset", "?") + msg = getattr(e, "msg", str(e)) + return filepath, False, f"SyntaxError 第{line}行第{col}列: {msg}" + + try: + py_compile.compile(filepath, doraise=True, quiet=2) + except py_compile.PyCompileError as e: + return filepath, False, f"PyCompileError: {e.msg if hasattr(e, 'msg') else e}" + except Exception as e: + return filepath, False, f"编译失败: {e}" + + return filepath, True, "" + + def _run_batch( + self, + files: List[str], + title: str, + verbose: bool = False, + quiet: bool = False, + jobs: int = 8, + ) -> Tuple[int, int, List[Tuple[str, str]]]: + total = len(files) + if total == 0: + self._emit(self._warn(f"{title}: 没有可验证的 Python 文件")) + return 0, 0, [] + + if not quiet: + self._emit(self._banner(f"{title} ({total} 个文件, {jobs} 线程)")) + self._emit("") + + passed = 0 + failed: List[Tuple[str, str]] = [] + start = time.time() + + results: List[Tuple[str, bool, str]] = [] + if jobs == 1: + for f in files: + results.append(self._verify_one(f)) + else: + with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as ex: + futures = {ex.submit(self._verify_one, f): f for f in files} + done = 0 + for fut in concurrent.futures.as_completed(futures): + results.append(fut.result()) + done += 1 + if not quiet and not verbose and total > 0: + pct = done * 100 // total + rel = os.path.relpath(futures[fut], PROJECT_ROOT) + self._emit(f"\r 进度: [{done}/{total}] {pct:3d}% {rel[:40]:<40}", end="") + if not quiet and not verbose: + self._emit("") + + results.sort(key=lambda x: x[0]) + + if verbose and not quiet: + self._emit("") + + for filepath, ok, err in results: + rel = os.path.relpath(filepath, PROJECT_ROOT) + if ok: + passed += 1 + if verbose and not quiet: + self._emit(f" {self._ok('✓')} {rel}") + else: + failed.append((rel, err)) + if not quiet: + self._emit(f" {self._err('✗')} {rel}") + self._emit(f" {self._err(err)}") + + elapsed = time.time() - start + + self._emit("") + self._emit("─" * 60) + if failed: + self._emit(f" {self._err('✗ 失败:')} {len(failed)}/{total}") + self._emit(f" {self._ok('✓ 通过:')} {passed}/{total}") + self._emit(f" {self._dim(f'耗时: {elapsed:.2f}s')}") + self._emit("") + self._emit(self._err("失败列表:")) + for rel, err in failed: + self._emit(f" • {self._err(rel)}") + first_line = err.split("\n")[0][:120] + self._emit(f" {first_line}") + else: + self._emit(f" {self._ok(f'✓ 全部通过:')} {passed}/{total} 个文件") + self._emit(f" {self._dim(f'耗时: {elapsed:.2f}s')}") + self._emit("─" * 60) + + return passed, len(failed), failed + + def _op_all(self, opts: dict): + files = self._collect_py_files(PROJECT_ROOT) + passed, failed, _ = self._run_batch( + files, + "全项目编译验证", + verbose=opts["verbose"], + quiet=opts["quiet"], + jobs=opts["jobs"], + ) + if failed == 0: + return ToolResult.ok( + f"全部通过 ({passed}/{passed})", + tool_name="compile_check", + ) + return ToolResult.fail( + f"有 {failed} 个文件编译失败", + ErrorCode.EXEC_FAILED, + tool_name="compile_check", + ) + + def _op_core(self, opts: dict): + return self._scan_subdir("xcli_core", "xcli_core 编译验证", opts) + + def _op_engines(self, opts: dict): + return self._scan_subdir("ai_engines", "ai_engines 编译验证", opts) + + def _op_plugins(self, opts: dict): + return self._scan_subdir("plugins", "plugins 编译验证", opts) + + def _scan_subdir(self, sub: str, title: str, opts: dict): + target = os.path.join(PROJECT_ROOT, sub) + if not os.path.isdir(target): + return ToolResult.not_found(f"目录: {sub}", tool_name="compile_check") + files = self._collect_py_files(target) + passed, failed, _ = self._run_batch( + files, title, + verbose=opts["verbose"], + quiet=opts["quiet"], + jobs=opts["jobs"], + ) + if failed == 0: + return ToolResult.ok( + f"{sub} 全部通过 ({passed})", + tool_name="compile_check", + ) + return ToolResult.fail( + f"{sub} 有 {failed} 个文件编译失败", + ErrorCode.EXEC_FAILED, + tool_name="compile_check", + ) + + def _op_dir(self, opts: dict): + if not opts["positional"]: + return ToolResult.missing("目录路径", tool_name="compile_check") + target = os.path.abspath(opts["positional"][0]) + if not os.path.isdir(target): + return ToolResult.not_found(f"目录: {target}", tool_name="compile_check") + files = self._collect_py_files(target) + passed, failed, _ = self._run_batch( + files, + f"目录验证: {os.path.relpath(target, PROJECT_ROOT)}", + verbose=opts["verbose"], + quiet=opts["quiet"], + jobs=opts["jobs"], + ) + if failed == 0: + return ToolResult.ok( + f"目录全部通过 ({passed})", + tool_name="compile_check", + ) + return ToolResult.fail( + f"目录有 {failed} 个文件编译失败", + ErrorCode.EXEC_FAILED, + tool_name="compile_check", + ) + + def _op_check(self, opts: dict): + if not opts["positional"]: + return ToolResult.missing("文件路径", tool_name="compile_check") + + targets = [] + for p in opts["positional"]: + fp = os.path.abspath(p) + if not os.path.isfile(fp): + return ToolResult.not_found( + f"文件: {p}", tool_name="compile_check", + ) + targets.append(fp) + + if len(targets) == 1: + self._emit(self._banner( + f"单文件验证: {os.path.relpath(targets[0], PROJECT_ROOT)}" + )) + filepath, ok, err = self._verify_one(targets[0]) + rel = os.path.relpath(filepath, PROJECT_ROOT) + self._emit("") + if ok: + self._emit(f" {self._ok('✓ 通过')} {rel}") + self._emit("─" * 60) + return ToolResult.ok(f"通过: {rel}", tool_name="compile_check") + else: + self._emit(f" {self._err('✗ 失败')} {rel}") + self._emit(f" {self._err(err)}") + self._emit("─" * 60) + return ToolResult.fail( + f"编译失败: {rel}", + ErrorCode.EXEC_FAILED, + tool_name="compile_check", + ) + + passed, failed, _ = self._run_batch( + targets, + f"批量验证 ({len(targets)} 个文件)", + verbose=opts["verbose"], + quiet=opts["quiet"], + jobs=opts["jobs"], + ) + if failed == 0: + return ToolResult.ok( + f"全部通过 ({passed})", + tool_name="compile_check", + ) + return ToolResult.fail( + f"有 {failed} 个文件编译失败", + ErrorCode.EXEC_FAILED, + tool_name="compile_check", + ) + + def _op_clean(self, opts: dict): + target = PROJECT_ROOT + if opts["positional"]: + target = os.path.abspath(opts["positional"][0]) + if not os.path.isdir(target): + return ToolResult.not_found( + f"目录: {target}", tool_name="compile_check", + ) + + self._emit(self._banner(f"清理 __pycache__: {os.path.relpath(target, PROJECT_ROOT)}")) + removed = 0 + for dirpath, dirnames, _ in os.walk(target): + for d in list(dirnames): + if d == "__pycache__": + full = os.path.join(dirpath, d) + try: + shutil.rmtree(full) + removed += 1 + rel = os.path.relpath(full, PROJECT_ROOT) + if not opts["quiet"]: + self._emit(f" {self._ok('✓ 已删除')} {rel}") + except Exception as e: + rel = os.path.relpath(full, PROJECT_ROOT) + self._emit(f" {self._err('✗ 失败')} {rel}: {e}") + self._emit("") + self._emit("─" * 60) + if removed: + self._emit(f" {self._ok(f'✓ 已清理')} {removed} 个 __pycache__") + else: + self._emit(f" {self._dim('没有可清理的 __pycache__')}") + self._emit("─" * 60) + + return ToolResult.ok( + f"清理完成 ({removed} 个目录)", + tool_name="compile_check", + ) + + def _op_stats(self, opts: dict): + self._emit(self._banner("项目 Python 文件统计")) + + subdirs = [ + ("xcli_core", "xcli_core"), + ("ai_engines", "ai_engines"), + ("plugins", "plugins"), + ] + + total = 0 + rows: List[Tuple[str, int]] = [] + for sub, label in subdirs: + target = os.path.join(PROJECT_ROOT, sub) + if os.path.isdir(target): + count = len(self._collect_py_files(target)) + rows.append((label, count)) + total += count + + other = len(self._collect_py_files(PROJECT_ROOT)) - total + rows.append(("其他(根目录等)", other)) + total += other + + self._emit("") + self._emit(f" {'目录':<20} {'文件数':>8}") + self._emit(" " + "─" * 30) + for label, count in rows: + bar = "█" * min(count, 40) if count else "" + color = Fore.GREEN if count else Fore.CYAN + self._emit(f" {label:<20} {count:>8} {color}{bar}{Style.RESET_ALL}") + self._emit(" " + "─" * 30) + self._emit(f" {'合计':<20} {total:>8}") + self._emit("") + self._emit("─" * 60) + + return ToolResult.ok( + f"统计完成 (共 {total} 个文件)", + tool_name="compile_check", + ) \ No newline at end of file diff --git a/plugins/frontend_tester.py b/plugins/frontend_tester.py index 0ca23a8..ec106fc 100644 --- a/plugins/frontend_tester.py +++ b/plugins/frontend_tester.py @@ -4,6 +4,7 @@ """ import os +import re import subprocess from colorama import Fore, Style @@ -317,7 +318,7 @@ JSON格式示例: "message": "使用了var声明,建议使用let或const" }) - if '==' in content and '!==' not in content: + if re.search(r'(? str: lines = status.strip().split('\n') - added = len([l for l in lines if l.startswith('A ') or l.startswith('A') and l[1] == ' ']) + added = len([l for l in lines if l.startswith('A ') or (len(l) > 1 and l.startswith('A') and l[1] == ' ')]) modified = len([l for l in lines if l.startswith('M ') or (len(l) > 1 and l[1] == 'M')]) deleted = len([l for l in lines if l.startswith('D ') or (len(l) > 1 and l[1] == 'D')]) diff --git a/plugins/gui_auto.py b/plugins/gui_auto.py index cdb78cd..9d53937 100644 --- a/plugins/gui_auto.py +++ b/plugins/gui_auto.py @@ -324,7 +324,7 @@ class Liugin: lines.append(f"{indent}{prefix}{type_part} {name_part}{id_part}{bounds_part}{state_str}") children = element.FindAll( - self._uia.CreatePropertyCondition(30000, 0), # TreeScope_Children + 2, # TreeScope_Children self._uia.CreateTrueCondition() ) if children: @@ -570,7 +570,7 @@ class Liugin: try: children = self._root.FindAll( - self._uia.CreatePropertyCondition(30000, 2), + 2, # TreeScope_Children self._uia.CreateTrueCondition() ) diff --git a/plugins/id_generator.py b/plugins/id_generator.py index 9bd1ace..e44b8fe 100644 --- a/plugins/id_generator.py +++ b/plugins/id_generator.py @@ -355,7 +355,7 @@ class Liugin: if output: # 提取纯ID保存到文件 ids = [line.strip().lstrip(' ') for line in result.split('\n') - if line.strip() and not any(c in line for c in [' ', '️', '', '❄️', ''])] + if line.strip() and not any(c in line for c in [' ', '️', '❄️'])] try: with open(output, 'w', encoding='utf-8') as f: f.write('\n'.join(ids)) diff --git a/plugins/network_tools.py b/plugins/network_tools.py index 7491ef6..58a73ec 100644 --- a/plugins/network_tools.py +++ b/plugins/network_tools.py @@ -8,7 +8,15 @@ from colorama import Fore, Style class Liugin: """网络工具插件""" - + + # 统一 User-Agent(模拟浏览器,避免被反爬拦截) + _UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' + _HEADERS = { + 'User-Agent': _UA, + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + } + def __init__(self): self.usage = """网络工具使用方法:/r network_tools <操作> <参数>/r @@ -35,6 +43,7 @@ network_tools <操作> <参数>/r """ self.cli = None # CLI实例引用 self.timeout = 10 # 请求超时时间(秒) + self.max_retries = 2 # 最大重试次数 def set_cli(self, cli): """设置CLI实例引用""" @@ -161,128 +170,137 @@ JSON格式示例: except Exception as e: return f"网络工具操作错误: {str(e)}" + def _request(self, url, method='GET', timeout=None): + """统一请求方法 — 带重试、UA、编码处理""" + timeout = timeout or self.timeout + last_error = None + for attempt in range(self.max_retries + 1): + try: + response = requests.request( + method, url, timeout=timeout, headers=self._HEADERS, + allow_redirects=True + ) + # 显式编码处理:优先用响应头声明的编码,兜底 UTF-8 + if not response.encoding or response.encoding.lower() == 'iso-8859-1': + response.encoding = response.apparent_encoding or 'utf-8' + return response + except requests.exceptions.Timeout: + last_error = f"请求超时 (>{timeout}秒)" + if attempt < self.max_retries: + time.sleep(1) + except requests.exceptions.ConnectionError: + last_error = "连接失败 (无法连接到服务器)" + if attempt < self.max_retries: + time.sleep(1) + except requests.exceptions.RequestException as e: + last_error = str(e) + break + return last_error + def _ping(self, target): - """测试网站连通性和延迟""" + """测试网站连通性和延迟(单次请求,不重试,保证延迟测量准确)""" try: # 确保URL格式正确 if not target.startswith(('http://', 'https://')): test_url = 'http://' + target else: test_url = target - - # 添加协议如果缺失 - if not test_url.startswith(('http://', 'https://')): - test_url = 'http://' + test_url - + + # 单次请求测量延迟(不使用重试,避免重试干扰延迟数值) start_time = time.time() - response = requests.get(test_url, timeout=self.timeout) + try: + response = requests.get( + test_url, timeout=self.timeout, + headers=self._HEADERS, allow_redirects=True + ) + except requests.exceptions.Timeout: + return f".ping 测试结果:\n目标: {target}\n错误: 请求超时 (>{self.timeout}秒)\n连接状态: 失败" + except requests.exceptions.ConnectionError: + return f".ping 测试结果:\n目标: {target}\n错误: 连接失败 (无法连接到服务器)\n连接状态: 失败" + except requests.exceptions.RequestException as e: + return f".ping 测试结果:\n目标: {target}\n错误: {str(e)}\n连接状态: 失败" end_time = time.time() - + response_time = (end_time - start_time) * 1000 # 转换为毫秒 - + result = f".ping 测试结果:\n" result += f"目标: {target}\n" result += f"HTTP状态码: {response.status_code}\n" result += f"响应时间: {response_time:.2f} ms\n" result += f"连接状态: {'成功' if response.status_code < 400 else '失败'}\n" - + if response.status_code >= 400: result += f"错误信息: {response.reason if hasattr(response, 'reason') else '请求失败'}\n" - + return result - - except requests.exceptions.Timeout: - return f".ping 测试结果:\n目标: {target}\n错误: 请求超时 (>{self.timeout}秒)\n连接状态: 失败" - except requests.exceptions.ConnectionError: - return f".ping 测试结果:\n目标: {target}\n错误: 连接失败 (无法连接到服务器)\n连接状态: 失败" - except requests.exceptions.RequestException as e: - return f".ping 测试结果:\n目标: {target}\n错误: {str(e)}\n连接状态: 失败" + except Exception as e: return f".ping 测试结果:\n目标: {target}\n错误: {str(e)}\n连接状态: 失败" def _get_content(self, url): """获取网页内容""" - try: - # 确保URL格式正确 - if not url.startswith(('http://', 'https://')): - url = 'https://' + url - - response = requests.get(url, timeout=self.timeout) - - if response.status_code == 200: - content = response.text - # 限制内容长度以避免过长输出 - if len(content) > 2000: - content = content[:2000] + f"\n... (内容已截断,完整内容共{len(content)}字符)" - - result = f"网页内容 (来自 {url}):\n" - result += content - return result - else: - return f"获取网页内容失败,状态码: {response.status_code}, 原因: {response.reason if hasattr(response, 'reason') else ''}" - - except requests.exceptions.Timeout: - return f"获取网页内容超时 (>{self.timeout}秒)" - except requests.exceptions.ConnectionError: - return f"无法连接到 {url},请检查URL是否正确" - except requests.exceptions.RequestException as e: - return f"请求失败: {str(e)}" - except Exception as e: - return f"获取网页内容时发生错误: {str(e)}" + # 确保URL格式正确 + if not url.startswith(('http://', 'https://')): + url = 'https://' + url + + response = self._request(url) + # _request 返回字符串表示错误 + if isinstance(response, str): + return f"获取网页内容失败: {response}" + + if response.status_code == 200: + content = response.text + # 限制内容长度以避免过长输出 + if len(content) > 2000: + content = content[:2000] + f"\n... (内容已截断,完整内容共{len(content)}字符)" + + result = f"网页内容 (来自 {url}):\n" + result += content + return result + else: + return f"获取网页内容失败,状态码: {response.status_code}, 原因: {response.reason if hasattr(response, 'reason') else ''}" def _check_status(self, url): """检查网站状态码""" - try: - # 确保URL格式正确 - if not url.startswith(('http://', 'https://')): - url = 'https://' + url - - response = requests.get(url, timeout=self.timeout) - - result = f"网站状态 (来自 {url}):\n" - result += f"HTTP状态码: {response.status_code}\n" - result += f"状态描述: {response.reason if hasattr(response, 'reason') else 'Unknown'}\n" - result += f"响应时间: {response.elapsed.total_seconds():.2f} 秒\n" - result += f"内容长度: {len(response.content)} 字节\n" - result += f"服务器: {response.headers.get('Server', '未知')}\n" - result += f"内容类型: {response.headers.get('Content-Type', '未知')}\n" - - return result - - except requests.exceptions.Timeout: - return f"检查网站状态超时 (>{self.timeout}秒)" - except requests.exceptions.ConnectionError: - return f"无法连接到 {url},请检查URL是否正确" - except requests.exceptions.RequestException as e: - return f"请求失败: {str(e)}" - except Exception as e: - return f"检查网站状态时发生错误: {str(e)}" + # 确保URL格式正确 + if not url.startswith(('http://', 'https://')): + url = 'https://' + url + + response = self._request(url) + if isinstance(response, str): + return f"检查网站状态失败: {response}" + + result = f"网站状态 (来自 {url}):\n" + result += f"HTTP状态码: {response.status_code}\n" + result += f"状态描述: {response.reason if hasattr(response, 'reason') else 'Unknown'}\n" + result += f"响应时间: {response.elapsed.total_seconds():.2f} 秒\n" + result += f"内容长度: {len(response.content)} 字节\n" + result += f"服务器: {response.headers.get('Server', '未知')}\n" + result += f"内容类型: {response.headers.get('Content-Type', '未知')}\n" + + return result def _get_headers(self, url): - """获取网站响应头""" - try: - # 确保URL格式正确 - if not url.startswith(('http://', 'https://')): - url = 'https://' + url - - response = requests.get(url, timeout=self.timeout) - - headers = dict(response.headers) - - result = f"响应头 (来自 {url}):\n" - for key, value in headers.items(): - result += f"{key}: {value}\n" - - return result - - except requests.exceptions.Timeout: - return f"获取响应头超时 (>{self.timeout}秒)" - except requests.exceptions.ConnectionError: - return f"无法连接到 {url},请检查URL是否正确" - except requests.exceptions.RequestException as e: - return f"请求失败: {str(e)}" - except Exception as e: - return f"获取响应头时发生错误: {str(e)}" + """获取网站响应头(优先 HEAD 请求,失败则回退 GET)""" + # 确保URL格式正确 + if not url.startswith(('http://', 'https://')): + url = 'https://' + url + + # 优先用 HEAD(更高效),失败则回退到 GET + response = self._request(url, method='HEAD') + if isinstance(response, str): + # HEAD 失败(可能服务器不支持),回退到 GET + response = self._request(url, method='GET') + if isinstance(response, str): + return f"获取响应头失败: {response}" + + headers = dict(response.headers) + + result = f"响应头 (来自 {url}):\n" + for key, value in headers.items(): + result += f"{key}: {value}\n" + + return result def _get_ip(self, domain): """获取域名对应的IP地址""" diff --git a/plugins/scheduler.py b/plugins/scheduler.py index f6bd96b..be8f295 100644 --- a/plugins/scheduler.py +++ b/plugins/scheduler.py @@ -295,17 +295,37 @@ JSON格式示例: except Exception: pass - # 打印醒目提醒 - print() - print(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}") - print(f"{Fore.YELLOW} ⏰ 定时任务提醒 #{task_id}{Style.RESET_ALL}") - print(f"{Fore.YELLOW}{'─' * 50}{Style.RESET_ALL}") - print(f" {Fore.CYAN}任务:{Style.RESET_ALL} {message}") - print(f" {Fore.CYAN}时间:{Style.RESET_ALL} {now.strftime('%H:%M:%S')}") - if is_repeat: - next_str = (now + timedelta(seconds=repeat_seconds)).strftime("%Y-%m-%d %H:%M:%S") - print(f" {Fore.CYAN}下次:{Style.RESET_ALL} {next_str}") - print(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}") + # 打印醒目提醒(TUI 兼容) + is_tui = self.cli and getattr(self.cli, 'tui_output_callback', None) + is_generating = self.cli and getattr(self.cli, 'tui_stream_callback', None) + + if is_tui and not is_generating: + # TUI 模式且未在生成中:通过回调输出 + lines = [ + "", + f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}", + f"{Fore.YELLOW} ⏰ 定时任务提醒 #{task_id}{Style.RESET_ALL}", + f"{Fore.YELLOW}{'─' * 50}{Style.RESET_ALL}", + f" {Fore.CYAN}任务:{Style.RESET_ALL} {message}", + f" {Fore.CYAN}时间:{Style.RESET_ALL} {now.strftime('%H:%M:%S')}", + ] + if is_repeat: + next_str = (now + timedelta(seconds=repeat_seconds)).strftime("%Y-%m-%d %H:%M:%S") + lines.append(f" {Fore.CYAN}下次:{Style.RESET_ALL} {next_str}") + lines.append(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}") + self.cli.tui_output_callback("\n".join(lines)) + elif not is_generating: + # CLI 模式 + print() + print(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}") + print(f"{Fore.YELLOW} ⏰ 定时任务提醒 #{task_id}{Style.RESET_ALL}") + print(f"{Fore.YELLOW}{'─' * 50}{Style.RESET_ALL}") + print(f" {Fore.CYAN}任务:{Style.RESET_ALL} {message}") + print(f" {Fore.CYAN}时间:{Style.RESET_ALL} {now.strftime('%H:%M:%S')}") + if is_repeat: + next_str = (now + timedelta(seconds=repeat_seconds)).strftime("%Y-%m-%d %H:%M:%S") + print(f" {Fore.CYAN}下次:{Style.RESET_ALL} {next_str}") + print(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}") # 自动激活 AI — 将提醒注入对话 self._activate_ai(task_id, message) diff --git a/plugins/snippet_manager.py b/plugins/snippet_manager.py index 61a2b4f..a8d2f4f 100644 --- a/plugins/snippet_manager.py +++ b/plugins/snippet_manager.py @@ -83,7 +83,24 @@ class Liugin: def convert_mcp_args(self, arguments): op = arguments.get("operation", "") name = arguments.get("name", "") - return f"{op} {name}".strip() + parts = [op] + if name: + parts.append(name) + if arguments.get("keyword"): + parts.append(arguments["keyword"]) + if arguments.get("file"): + parts.append(arguments["file"]) + if arguments.get("count"): + parts.append(str(arguments["count"])) + if arguments.get("language"): + parts.extend(["-l", arguments["language"]]) + if arguments.get("code"): + parts.extend(["-c", f'"{arguments["code"]}"']) + if arguments.get("tags"): + parts.extend(["-t", arguments["tags"]]) + if arguments.get("description"): + parts.extend(["-d", f'"{arguments["description"]}"']) + return " ".join(parts) def _load_snippets(self): try: diff --git a/plugins/sub_agent.py b/plugins/sub_agent.py index 359805c..e9f84bc 100644 --- a/plugins/sub_agent.py +++ b/plugins/sub_agent.py @@ -319,18 +319,29 @@ class Liugin: return None + def _find_agent(self, agent_id: str): + """线程安全地查找 Agent(精确匹配 + 模糊匹配)""" + with self._lock: + agent = self.agents.get(agent_id) + if not agent: + matches = [a for a in self.agents.values() if agent_id in a.id or agent_id == a.name] + if matches: + agent = matches[0] + return agent + def _list(self, args: str) -> str: """列出所有子 Agent""" with self._lock: if not self.agents: return "没有子 Agent" + agents_list = list(self.agents.values()) - result = [f"子 Agent 列表 ({len(self.agents)} 个):\n"] + result = [f"子 Agent 列表 ({len(agents_list)} 个):\n"] # 按状态分组 - running = [a for a in self.agents.values() if a.status == "running"] - done = [a for a in self.agents.values() if a.status == "done"] - failed = [a for a in self.agents.values() if a.status == "failed"] + running = [a for a in agents_list if a.status == "running"] + done = [a for a in agents_list if a.status == "done"] + failed = [a for a in agents_list if a.status == "failed"] if running: result.append(f" 运行中 ({len(running)}):") @@ -355,14 +366,9 @@ class Liugin: if not agent_id: return "错误:请提供 Agent ID" - agent = self.agents.get(agent_id) + agent = self._find_agent(agent_id) if not agent: - # 模糊匹配 - matches = [a for a in self.agents.values() if agent_id in a.id or agent_id == a.name] - if matches: - agent = matches[0] - else: - return f"错误:未找到 Agent '{agent_id}'" + return f"错误:未找到 Agent '{agent_id}'" lines = [ f"Agent: {agent.id}", @@ -386,13 +392,9 @@ class Liugin: if not agent_id: return "错误:请提供 Agent ID" - agent = self.agents.get(agent_id) + agent = self._find_agent(agent_id) if not agent: - matches = [a for a in self.agents.values() if agent_id in a.id or agent_id == a.name] - if matches: - agent = matches[0] - else: - return f"错误:未找到 Agent '{agent_id}'" + return f"错误:未找到 Agent '{agent_id}'" if agent.status == "running": return f"Agent {agent.id} 仍在运行中 (步骤 {agent.steps})" @@ -417,13 +419,9 @@ class Liugin: agent_id = parts[0] message = parts[1] - agent = self.agents.get(agent_id) + agent = self._find_agent(agent_id) if not agent: - matches = [a for a in self.agents.values() if agent_id in a.id or agent_id == a.name] - if matches: - agent = matches[0] - else: - return f"错误:未找到 Agent '{agent_id}'" + return f"错误:未找到 Agent '{agent_id}'" if agent.status != "running": return f"Agent {agent.id} 未在运行 (状态: {agent.status})" @@ -467,17 +465,15 @@ class Liugin: except Exception as e: agent.error = str(e) + agent.status = "failed" + agent.finished_at = datetime.now().strftime("%H:%M:%S") def _kill(self, args: str) -> str: """终止子 Agent""" agent_id = args.strip() - agent = self.agents.get(agent_id) + agent = self._find_agent(agent_id) if not agent: - matches = [a for a in self.agents.values() if agent_id in a.id or agent_id == a.name] - if matches: - agent = matches[0] - else: - return f"错误:未找到 Agent '{agent_id}'" + return f"错误:未找到 Agent '{agent_id}'" agent.status = "failed" agent.error = "被主 Agent 终止" diff --git a/plugins/workflow.py b/plugins/workflow.py index 280ad98..1249b45 100644 --- a/plugins/workflow.py +++ b/plugins/workflow.py @@ -189,6 +189,13 @@ class WorkflowEngine: self.state_dir = os.path.join(self.workflows_dir, ".state") os.makedirs(self.state_dir, exist_ok=True) + def _out(self, msg): + """TUI 兼容输出""" + if self.cli and getattr(self.cli, 'tui_output_callback', None): + self.cli.tui_output_callback(msg) + else: + print(msg) + def run(self, path: str, args: dict = None) -> str: """执行工作流""" if not os.path.exists(path): @@ -226,8 +233,8 @@ class WorkflowEngine: 'started_at': context['workflow']['started_at'], }) - print(f"\n{Fore.CYAN}🔄 工作流 '{wf_name}' 开始 (ID: {wf_id}){Style.RESET_ALL}") - print(f" 共 {len(steps)} 个步骤\n") + self._out(f"\n{Fore.CYAN}🔄 工作流 '{wf_name}' 开始 (ID: {wf_id}){Style.RESET_ALL}") + self._out(f" 共 {len(steps)} 个步骤\n") failed = False for i, step in enumerate(steps): @@ -240,7 +247,7 @@ class WorkflowEngine: if condition: resolved_condition = _resolve_vars(condition, context) if not self._eval_condition(resolved_condition): - print(f" {Fore.YELLOW}⏭ [{step_name}] 条件不满足,跳过{Style.RESET_ALL}") + self._out(f" {Fore.YELLOW}⏭ [{step_name}] 条件不满足,跳过{Style.RESET_ALL}") context['steps'][step_name] = { 'output': '', 'exit_code': 0, 'skipped': True } @@ -249,7 +256,7 @@ class WorkflowEngine: # 解析步骤参数 resolved_step = _resolve_value(step, context) - print(f" {Fore.CYAN}▶ [{step_name}] {step_type}{Style.RESET_ALL}") + self._out(f" {Fore.CYAN}▶ [{step_name}] {step_type}{Style.RESET_ALL}") # 更新状态 self._save_state(wf_id, { @@ -268,29 +275,29 @@ class WorkflowEngine: if result.get('exit_code', 0) == 0: output_preview = str(result.get('output', ''))[:100] - print(f" {Fore.GREEN}✓ [{step_name}] 完成{Style.RESET_ALL}" + self._out(f" {Fore.GREEN}✓ [{step_name}] 完成{Style.RESET_ALL}" f"{' → ' + output_preview if output_preview else ''}") else: failed = True - print(f" {Fore.RED}✗ [{step_name}] 失败 (exit={result.get('exit_code')}){Style.RESET_ALL}") - print(f" {result.get('error', result.get('output', ''))[:200]}") + self._out(f" {Fore.RED}✗ [{step_name}] 失败 (exit={result.get('exit_code')}){Style.RESET_ALL}") + self._out(f" {result.get('error', result.get('output', ''))[:200]}") if not continue_on_error: # 检查 on_fail 分支 on_fail = resolved_step.get('on_fail') if on_fail: - print(f" {Fore.YELLOW}→ 执行 on_fail 分支{Style.RESET_ALL}") + self._out(f" {Fore.YELLOW}→ 执行 on_fail 分支{Style.RESET_ALL}") self._execute_on_fail(on_fail, context) break else: - print(f" {Fore.YELLOW}⚠ continue_on_error=true,继续{Style.RESET_ALL}") + self._out(f" {Fore.YELLOW}⚠ continue_on_error=true,继续{Style.RESET_ALL}") except Exception as e: failed = True context['steps'][step_name] = { 'output': '', 'exit_code': 1, 'error': str(e) } - print(f" {Fore.RED}✗ [{step_name}] 异常: {e}{Style.RESET_ALL}") + self._out(f" {Fore.RED}✗ [{step_name}] 异常: {e}{Style.RESET_ALL}") if not continue_on_error: break @@ -310,7 +317,7 @@ class WorkflowEngine: }) icon = '✅' if not failed else '❌' - print(f"\n{icon} 工作流 '{wf_name}' {status} (ID: {wf_id})") + self._out(f"\n{icon} 工作流 '{wf_name}' {status} (ID: {wf_id})") return f"{icon} 工作流 '{wf_name}' {status}" def resume(self, wf_id: str) -> str: @@ -335,7 +342,7 @@ class WorkflowEngine: start_from = state.get('current_step', 0) context = state.get('context', {}) - print(f"\n{Fore.CYAN}🔄 恢复工作流 '{wf.get('name', wf_id)}' 从步骤 {start_from + 1}{Style.RESET_ALL}") + self._out(f"\n{Fore.CYAN}🔄 恢复工作流 '{wf.get('name', wf_id)}' 从步骤 {start_from + 1}{Style.RESET_ALL}") failed = False for i in range(start_from, len(steps)): @@ -345,23 +352,23 @@ class WorkflowEngine: continue_on_error = step.get('continue_on_error', False) resolved_step = _resolve_value(step, context) - print(f" {Fore.CYAN}▶ [{step_name}] {step_type}{Style.RESET_ALL}") + self._out(f" {Fore.CYAN}▶ [{step_name}] {step_type}{Style.RESET_ALL}") try: result = self._execute_step(resolved_step, context) context['steps'][step_name] = result if result.get('exit_code', 0) == 0: - print(f" {Fore.GREEN}✓ [{step_name}] 完成{Style.RESET_ALL}") + self._out(f" {Fore.GREEN}✓ [{step_name}] 完成{Style.RESET_ALL}") else: failed = True - print(f" {Fore.RED}✗ [{step_name}] 失败{Style.RESET_ALL}") + self._out(f" {Fore.RED}✗ [{step_name}] 失败{Style.RESET_ALL}") if not continue_on_error: break except Exception as e: failed = True context['steps'][step_name] = {'output': '', 'exit_code': 1, 'error': str(e)} - print(f" {Fore.RED}✗ [{step_name}] 异常: {e}{Style.RESET_ALL}") + self._out(f" {Fore.RED}✗ [{step_name}] 异常: {e}{Style.RESET_ALL}") if not continue_on_error: break @@ -553,7 +560,7 @@ class WorkflowEngine: """打印步骤""" message = step.get('message', step.get('text', '')) resolved = _resolve_vars(str(message), context) - print(f" 💬 {resolved}") + self._out(f" 💬 {resolved}") return {'output': resolved, 'exit_code': 0} def _exec_wait_step(self, step: dict, context: dict) -> dict: @@ -622,7 +629,7 @@ class WorkflowEngine: def _execute_on_fail(self, on_fail, context: dict): """执行 on_fail 处理""" if isinstance(on_fail, str): - print(f" 💬 {on_fail}") + self._out(f" 💬 {on_fail}") elif isinstance(on_fail, list): for step in on_fail: self._execute_step(step, context) @@ -636,7 +643,7 @@ class WorkflowEngine: with open(path, 'w', encoding='utf-8') as f: json.dump(state, f, ensure_ascii=False, indent=2, default=str) except Exception as e: - print(f"{Fore.YELLOW}⚠ 保存状态失败: {e}{Style.RESET_ALL}") + self._out(f"{Fore.YELLOW}⚠ 保存状态失败: {e}{Style.RESET_ALL}") def _load_state(self, wf_id: str) -> dict: """加载工作流状态""" diff --git a/skills/base.py b/skills/base.py index 7a6a264..285c0e9 100644 --- a/skills/base.py +++ b/skills/base.py @@ -371,20 +371,6 @@ class SkillLoader: """获取已加载的技能""" return self._skills.get(name) - def get_all_tool_definitions(self, format: str = "openai") -> List[Dict]: - """获取所有技能的工具定义""" - definitions = [] - for skill in self._skills.values(): - if format == "anthropic": - definitions.append(skill.get_anthropic_tool_definition()) - else: - definitions.append(skill.get_tool_definition()) - return definitions - - def get_skill(self, name: str) -> Optional[Union[Skill, MarkdownSkill]]: - """获取已加载的技能""" - return self._skills.get(name) - def get_all_tool_definitions(self, format: str = "openai") -> List[Dict]: """获取所有技能的工具定义""" definitions = [] diff --git a/skills/file-manager/scripts/file_ops.py b/skills/file-manager/scripts/file_ops.py index e2acfa1..8c8c74d 100644 --- a/skills/file-manager/scripts/file_ops.py +++ b/skills/file-manager/scripts/file_ops.py @@ -90,7 +90,8 @@ def read_file(file_path: str, encoding: str = "utf-8", lines: int = None) -> str break content_lines.append(line.rstrip('\n\r')) content = '\n'.join(content_lines) - total_lines = sum(1 for _ in open(file_path, 'r', encoding=encoding)) + with open(file_path, 'r', encoding=encoding) as f2: + total_lines = sum(1 for _ in f2) else: content = f.read() total_lines = content.count('\n') + 1 diff --git a/test_tui_send.py b/test_tui_send.py deleted file mode 100644 index 9ca3429..0000000 --- a/test_tui_send.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env python3 -"""测试 TUI 回车发送消息功能(改进版)""" -import pexpect -import time -import sys - -def test_tui_send(): - child = pexpect.spawn( - 'python3 ai_cli.py', - encoding='utf-8', - timeout=30, - dimensions=(30, 100), - ) - - # 1. 等待 diff 选择提示 - child.expect('请选择') - print('[OK] 出现 diff 选择提示') - child.sendline('2') - - # 2. 等待 CLI 提示符 - child.expect('>', timeout=10) - print('[OK] CLI 就绪') - - # 3. 发送 /tui - child.sendline('/tui') - time.sleep(3) - - # 4. 检查 TUI 启动 - try: - child.expect('回车发送', timeout=5) - print('[OK] TUI 启动成功') - except pexpect.TIMEOUT: - print('[FAIL] TUI 未启动') - child.close() - return False - - # 5. 输入 hello 并回车 - time.sleep(1) - child.send('hello') - time.sleep(0.5) - child.send('\r') # Enter - time.sleep(5) # 等待处理(ollama 可能超时) - - # 6. 读取所有可用输出 - full_output = '' - try: - while True: - chunk = child.read_nonblocking(size=4096, timeout=1) - full_output += chunk - except (pexpect.TIMEOUT, pexpect.EOF): - pass - - # 7. 检测结果 - sent = False - - # 检查是否出现用户消息 "hello" - if 'hello' in full_output: - print('[OK] 检测到 "hello" — 用户消息已显示') - sent = True - - # 检查是否出现错误(ollama 未运行) - if 'Ollama' in full_output or '服务未运行' in full_output: - print('[OK] 收到 Ollama 错误 — 消息已发送并处理') - sent = True - - # 检查是否出现 "思考中" - if '思考中' in full_output: - print('[OK] 看到 "思考中" — AI 正在处理') - sent = True - - # 检查是否出现消息气泡标记 - if ' ' in full_output: # 用户消息前缀 - # 检查是否有新内容出现 - lines = [l for l in full_output.split('\n') if l.strip()] - if len(lines) > 0: - print(f'[INFO] 捕获到 {len(lines)} 行输出') - - if not sent: - # 最后检查:如果 TUI 仍在运行且输入框为空,说明消息被发送了 - # (因为发送后会清空输入框) - print('[INFO] 未检测到明确结果,但 TUI 仍在运行') - # 认为通过:TUI 没有崩溃,说明逻辑正常 - sent = True - - # 退出 - try: - child.send('\x03') # Ctrl+C - time.sleep(0.5) - child.send('\x03') - except Exception: - pass - child.close() - return sent - -if __name__ == '__main__': - result = test_tui_send() - print(f'\n结果: {"✅ 测试通过" if result else "❌ 测试失败"}') - sys.exit(0 if result else 1) diff --git a/websocket_server.py b/websocket_server.py index c20ae7e..8fe0a2b 100644 --- a/websocket_server.py +++ b/websocket_server.py @@ -417,21 +417,17 @@ class LocalServer: image_engine = getattr(self.cli, 'image_engine', None) if not image_engine: try: - from image_engine import get_image_engine - image_engine = get_image_engine() + from image_engine import get_engine_manager + image_engine = get_engine_manager() except: return f"@{file_path}" # 没有图像引擎,直接返回路径 if websocket: await self._send_tool_status(websocket, "图像识别", text or "分析图片", "calling") - # 读取图片 - with open(file_path, 'rb') as f: - image_data = f.read() - # 调用图像识别 try: - result = image_engine.analyze(image_data, text or "请描述这张图片") + result = image_engine.analyze(file_path, text or "请描述这张图片") if websocket: await self._send_tool_status(websocket, "图像识别", text or "分析图片", "success", result[:200] if result else "无结果") @@ -638,9 +634,13 @@ class LocalServer: from colorama import Fore, Style try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - local_ip = s.getsockname()[0] - s.close() + try: + s.connect(("8.8.8.8", 80)) + local_ip = s.getsockname()[0] + except: + local_ip = "未知" + finally: + s.close() except: local_ip = "未知" @@ -657,9 +657,13 @@ class LocalServer: def get_status(self) -> dict: try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - local_ip = s.getsockname()[0] - s.close() + try: + s.connect(("8.8.8.8", 80)) + local_ip = s.getsockname()[0] + except: + local_ip = "未知" + finally: + s.close() except: local_ip = "未知" return { @@ -817,15 +821,12 @@ class ProxyClient: image_engine = getattr(self.cli, 'image_engine', None) if not image_engine: try: - from image_engine import get_image_engine - image_engine = get_image_engine() + from image_engine import get_engine_manager + image_engine = get_engine_manager() except: return f"@{file_path}" - with open(file_path, 'rb') as f: - image_data = f.read() - - result = image_engine.analyze(image_data, text or "请描述这张图片") + result = image_engine.analyze(file_path, text or "请描述这张图片") return await self._process_message(f"@{file_path}", [], f"图片内容: {result}\n\n{text}" if text else f"图片内容: {result}") except Exception as e: @@ -1133,8 +1134,6 @@ def start_proxy_client(cli_instance, server_host: str, server_port: int, websocket_server = None return result - - return _proxy_client.start(server_host, server_port, server_password, pc_port, pc_password) def stop_all(): diff --git a/xcli_core/cli_base.py b/xcli_core/cli_base.py index 6ad3af1..8bb011a 100644 --- a/xcli_core/cli_base.py +++ b/xcli_core/cli_base.py @@ -95,8 +95,8 @@ class BaseAICLI: self.load_liugins(liugins_dir, skills_dir) # 初始化共享对话历史 self.shared_conversation_history = [] - # 设置最大历史记录数 - self.max_history = DEFAULT_MAX_HISTORY + # 设置最大历史记录数 — 从 config 读取,未配置则用默认值 + self.max_history = get_system_config('max_history', DEFAULT_MAX_HISTORY) # 为所有已加载的引擎设置共享对话历史 self._set_shared_conversation_history() # 初始化代码执行相关功能 @@ -115,6 +115,14 @@ class BaseAICLI: self._clawli_monitor_running = False # TUI 输出回调(用于 TUI 模式下的实时输出) self.tui_output_callback = None + # TUI 流式回调(用于 TUI 模式下逐字流式输出) + self.tui_stream_callback = None + # TUI 思考回调(用于 TUI 模式下深度思考内容单独显示) + self.tui_thinking_callback = None + # TUI 钩子:每次调用 AI 前触发(重新显示思考指示器等) + self.tui_before_generate_callback = None + # TUI 确认回调(用于 TUI 模式下工具调用审查,返回 True/False) + self.tui_confirm_callback = None # 初始化用户ID(基于机器硬件动态生成) self.user_id = str(uuid.getnode()) @@ -319,6 +327,13 @@ class BaseAICLI: """获取插件管理器""" return self.liugin_manager + def _output(self, message): + """统一输出方法,支持 TUI 和 CLI 模式""" + if getattr(self, 'tui_output_callback', None): + self.tui_output_callback(message) + else: + print(message) + def switch_engine(self, engine_name): """切换AI引擎""" if engine_name in self.engines: @@ -333,35 +348,24 @@ class BaseAICLI: 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}") + self._output(f"{Fore.CYAN}已切换到AI引擎: {engine_name},正在传递对话历史...{Style.RESET_ALL}") context_message = { "role": "system", "content": f"引擎切换通知:现在由{engine_name}引擎继续之前的对话。以下是之前的对话历史:\n" } + self.shared_conversation_history.append(context_message) - 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}") + self._output(f"{Fore.GREEN}已切换到AI引擎: {engine_name}{Style.RESET_ALL}") else: - print(f"{Fore.GREEN}已切换到AI引擎: {engine_name}{Style.RESET_ALL}") + self._output(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}") + self._output(f"{Fore.YELLOW}警告: 保存默认引擎配置失败: {e}{Style.RESET_ALL}") else: - print(f"{Fore.RED}未找到AI引擎: {engine_name}{Style.RESET_ALL}") + self._output(f"{Fore.RED}未找到AI引擎: {engine_name}{Style.RESET_ALL}") def _set_shared_conversation_history(self): """为所有引擎设置共享对话历史""" diff --git a/xcli_core/cli_clawli.py b/xcli_core/cli_clawli.py index 3e9daa1..339844a 100644 --- a/xcli_core/cli_clawli.py +++ b/xcli_core/cli_clawli.py @@ -96,6 +96,7 @@ class ClawliMixin: tool_calls = self._extract_tool_calls(json_data) if tool_calls: + all_results = [] for tool_data in tool_calls: tool_name = tool_data.get('tool', '未知工具') tool_args = tool_data.get('args', '') @@ -105,6 +106,7 @@ class ClawliMixin: result = self.process_tool_call(tool_data) result_text = result.get('result', '无结果') + all_results.append(result_text) if CLAWLI_SERVER_AVAILABLE and clawli_server: clawli_server.send_tool_status(tool_name, tool_args, "success", result_text) @@ -114,7 +116,7 @@ class ClawliMixin: "content": response }) - tool_result_str = "\n".join([f"工具执行结果: {r}" for r in [result.get('result', '')]]) + tool_result_str = "\n".join(f"工具执行结果: {r}" for r in all_results) self._process_clawli_user_message(f"{tool_result_str}\n请根据工具执行结果继续回答。") return @@ -134,16 +136,13 @@ class ClawliMixin: image_engine = getattr(self, 'image_engine', None) if not image_engine: try: - from image_engine import get_image_engine - image_engine = get_image_engine() + from image_engine import get_engine_manager + image_engine = get_engine_manager() 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 "请描述这张图片") + result = image_engine.analyze(image_path, 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}") diff --git a/xcli_core/cli_history.py b/xcli_core/cli_history.py index 82c2538..e4b2c1d 100644 --- a/xcli_core/cli_history.py +++ b/xcli_core/cli_history.py @@ -1,60 +1,61 @@ -import os -import time -import json from colorama import Fore, Style class HistoryMixin: - """聊天记录管理相关方法""" + """聊天记录管理 — 统一委托给 MemoryManager""" 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}") + """保存当前聊天记录""" + if not hasattr(self, 'memory_manager') or not self.memory_manager: + print(f"{Fore.RED}记忆系统未初始化{Style.RESET_ALL}") return False + self.memory_manager.auto_save_chat( + self.shared_conversation_history, + label=name + ) + print(f"{Fore.GREEN}聊天记录已保存: {name}{Style.RESET_ALL}") + return True 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}") + """列出所有聊天记录""" + if not hasattr(self, 'memory_manager') or not self.memory_manager: + print(f"{Fore.RED}记忆系统未初始化{Style.RESET_ALL}") + return + chats = self.memory_manager.list_chats(limit=30) + if not chats: + print(f"{Fore.YELLOW}没有已保存的聊天记录{Style.RESET_ALL}") + return + print(f"{Fore.GREEN}已保存的聊天记录:{Style.RESET_ALL}") + for i, c in enumerate(chats, 1): + print(f"{Fore.GREEN}{i}. {c['name']} ({c['time']}, {c['size_kb']}KB){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}") + """加载聊天记录(支持序号或文件名)""" + if not hasattr(self, 'memory_manager') or not self.memory_manager: + print(f"{Fore.RED}记忆系统未初始化{Style.RESET_ALL}") return False + + # 支持序号加载 + if name.isdigit(): + chats = self.memory_manager.list_chats(limit=50) + idx = int(name) - 1 + if 0 <= idx < len(chats): + name = chats[idx]['name'].replace('.json', '') + else: + print(f"{Fore.RED}序号 {name} 超出范围{Style.RESET_ALL}") + return False + + conversation = self.memory_manager.load_chat(name) + if conversation is None: + # 尝试加 .json 后缀 + if not name.endswith('.json'): + conversation = self.memory_manager.load_chat(name + '.json') + if conversation is None: + print(f"{Fore.RED}聊天记录 '{name}' 不存在{Style.RESET_ALL}") + return False + + self.shared_conversation_history = conversation + self._set_shared_conversation_history() + print(f"{Fore.GREEN}已加载聊天记录: {name}{Style.RESET_ALL}") + print(f"{Fore.CYAN}加载了 {len(conversation)} 条对话记录{Style.RESET_ALL}") + return True diff --git a/xcli_core/config.py b/xcli_core/config.py index 541d126..4eff300 100644 --- a/xcli_core/config.py +++ b/xcli_core/config.py @@ -42,6 +42,60 @@ def set_system_config(key, value): save_config(config) +# ── 引擎配置档案管理 ── +# 存储结构: config["engine_profiles"][engine_name] = [ {name, base_url, api_key, model}, ... ] + +def get_profiles(engine_name): + """获取指定引擎的所有配置档案""" + config = load_config() + return config.get("engine_profiles", {}).get(engine_name, []) + + +def save_profile(engine_name, profile): + """保存/更新一个配置档案(按 name 去重,已存在则覆盖)""" + config = load_config() + if "engine_profiles" not in config: + config["engine_profiles"] = {} + profiles = config["engine_profiles"].setdefault(engine_name, []) + # 按 name 去重 + for i, p in enumerate(profiles): + if p.get("name") == profile.get("name"): + profiles[i] = profile + break + else: + profiles.append(profile) + config["engine_profiles"][engine_name] = profiles + save_config(config) + + +def delete_profile(engine_name, profile_name): + """删除指定引擎的一个配置档案""" + config = load_config() + profiles = config.get("engine_profiles", {}).get(engine_name, []) + profiles = [p for p in profiles if p.get("name") != profile_name] + if "engine_profiles" not in config: + config["engine_profiles"] = {} + config["engine_profiles"][engine_name] = profiles + save_config(config) + + +def apply_profile(engine_name, profile): + """将档案应用到 config.json 的 api.engines 节点(下次启动或重载时生效)""" + config = load_config() + if "api" not in config: + config["api"] = {} + if "engines" not in config["api"]: + config["api"]["engines"] = {} + engine_cfg = config["api"]["engines"].setdefault(engine_name, {}) + if "base_url" in profile: + engine_cfg["base_url"] = profile["base_url"] + if "api_key" in profile: + engine_cfg["api_key"] = profile["api_key"] + if "model" in profile: + engine_cfg["model"] = profile["model"] + save_config(config) + + # 配置日志 logging.basicConfig( level=logging.INFO, diff --git a/xcli_core/fc_tools.py b/xcli_core/fc_tools.py index bbf5b8e..174e53e 100644 --- a/xcli_core/fc_tools.py +++ b/xcli_core/fc_tools.py @@ -162,6 +162,10 @@ def parse_fc_response(response_data: dict) -> Optional[List[Dict]]: except json.JSONDecodeError: args = {"raw": args_str} + # 确保 args 是 dict(LLM 可能返回非对象 JSON) + if not isinstance(args, dict): + args = {"raw": args} + # 统一为小狸内部格式: {"action": "use_tool", "tool": name, "args": "..." } # 如果 args 里有 args 字段,直接用;否则把整个 args 序列化 if "args" in args and isinstance(args["args"], str): diff --git a/xcli_core/notification.py b/xcli_core/notification.py index 9b0ea96..a521a28 100644 --- a/xcli_core/notification.py +++ b/xcli_core/notification.py @@ -109,17 +109,8 @@ class NotificationManager: pass def _notify_windows(self, title: str, message: str): - """Windows 通知 — 优先使用 win10toast,降级到 PowerShell""" - # 方法1: win10toast (pip install win10toast) - try: - from win10toast import ToastNotifier - toaster = ToastNotifier() - toaster.show_toast(title, message, duration=3, threaded=True) - return - except ImportError: - pass - - # 方法2: PowerShell BurntToast 模块 + """Windows 通知 — 使用 PowerShell(win10toast 在 Python 3.12+ 上不兼容)""" + # 方法1: PowerShell BurntToast 模块 try: ps_script = f''' [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null @@ -138,7 +129,7 @@ class NotificationManager: except Exception: pass - # 方法3: PowerShell 简单弹窗 (最后降级) + # 方法2: PowerShell 简单弹窗 (最后降级) try: escaped_msg = message.replace("'", "''") escaped_title = title.replace("'", "''") diff --git a/xcli_core/process_protection.py b/xcli_core/process_protection.py index fde7b3f..739ea64 100644 --- a/xcli_core/process_protection.py +++ b/xcli_core/process_protection.py @@ -107,24 +107,16 @@ class ProcessProtection: if sys.platform == 'darwin': # macOS: 使用 fcntl 文件锁 import fcntl - self._lock_fd = open(self._lock_file_path, 'w') + self._lock_fd = open(self._lock_file_path, 'a+') + self._lock_fd.seek(0) try: fcntl.flock(self._lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except (IOError, OSError): - # 文件已被锁定 — 已有实例在运行 - # 检查锁文件中的 PID 是否仍在运行 - try: - old_pid = self._lock_fd.read().strip() - if old_pid and self._is_process_running(int(old_pid)): - self._lock_fd.close() - self._lock_fd = None - return False - else: - # 旧进程已死,清理残留锁 - self._lock_fd.seek(0) - self._lock_fd.truncate() - except (ValueError, OSError): - pass + # flock 失败 — 已有存活实例在运行 + # (flock 是进程级锁,进程退出时自动释放) + self._lock_fd.close() + self._lock_fd = None + return False self._lock_fd.seek(0) self._lock_fd.truncate() @@ -135,22 +127,15 @@ class ProcessProtection: else: # Linux: 使用 fcntl 文件锁 import fcntl - self._lock_fd = open(self._lock_file_path, 'w') + self._lock_fd = open(self._lock_file_path, 'a+') + self._lock_fd.seek(0) try: fcntl.flock(self._lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except (IOError, OSError): - # 检查旧进程是否仍在运行 - try: - old_pid = self._lock_fd.read().strip() - if old_pid and self._is_process_running(int(old_pid)): - self._lock_fd.close() - self._lock_fd = None - return False - else: - self._lock_fd.seek(0) - self._lock_fd.truncate() - except (ValueError, OSError): - pass + # flock 失败 — 已有存活实例在运行 + self._lock_fd.close() + self._lock_fd = None + return False self._lock_fd.seek(0) self._lock_fd.truncate() diff --git a/xcli_core/tui.py b/xcli_core/tui.py index 9b34234..c02d93a 100644 --- a/xcli_core/tui.py +++ b/xcli_core/tui.py @@ -1,21 +1,39 @@ -"""TUI 界面模块 - Textual TUI 实现 (动画增强版 + 多行输入)""" +"""TUI 界面模块 — 基于 elia 开源项目架构重写 + +原项目: https://github.com/darrenburns/elia (Apache-2.0) +核心组件: Chatbox(Markdown渲染) + ResponseStatus(LoadingIndicator) + @work(thread=True)流式 +""" from .constants import TEXTUAL_AVAILABLE if TEXTUAL_AVAILABLE: import re - import asyncio - from textual.app import App, ComposeResult + import os + from functools import partial + from dataclasses import dataclass + from textual.app import App, ComposeResult, SystemCommand from textual.containers import Horizontal, Vertical, VerticalScroll, Container - from textual.widgets import Header, Static, Rule, TextArea - from textual.reactive import var + from textual.widgets import Header, Static, Rule, TextArea, Footer, LoadingIndicator, Label + from textual.widgets.option_list import Option + from textual.reactive import var, reactive from textual.binding import Binding - from textual import on + from textual import on, work, events + from textual.message import Message + from textual.widget import Widget + from textual.css.query import NoMatches + from textual.command import CommandPalette, CommandList + from textual.screen import Screen + from rich.console import RenderableType + from rich.markdown import Markdown from rich.syntax import Syntax - from rich.panel import Panel - from rich.box import ROUNDED + from rich.text import Text + from rich.align import Align + from typing import Iterable + + # ═══════════════════════════════════════════════════ + # 主题色 + # ═══════════════════════════════════════════════════ - # ── TUI 主题色 ── class _Theme: BG = "#0d1117" BG_LIGHT = "#161b22" @@ -30,30 +48,25 @@ if TEXTUAL_AVAILABLE: WARNING = "#d29922" ERROR = "#f85149" USER = "#79c0ff" - AI = "#c9d1d9" + AI = "#f0f6fc" TOOL = "#56d364" - TOOL_ERR = "#f85149" CODE_BG = "#161b22" - # 动画额外色 GLOW = "#1f6feb" THINKING = "#8b949e" - WELCOME_ACCENT = "#58a6ff" + + # ═══════════════════════════════════════════════════ + # CSS(融合 elia 样式 + 项目主题色) + # ═══════════════════════════════════════════════════ _TUI_CSS = f""" Screen {{ background: {_Theme.BG}; - /* 全局过渡 */ - transition: background 300ms in_out_cubic; }} #app-container {{ - height: 100%; - width: 100%; - }} - - #main {{ - width: 1fr; height: 1fr; + width: 100%; + layout: vertical; }} #chat-scroll {{ @@ -62,10 +75,9 @@ if TEXTUAL_AVAILABLE: scrollbar-color: {_Theme.BORDER}; scrollbar-color-hover: {_Theme.TEXT_MUTED}; padding: 0 1; - }} - /* ── 输入区域 ── */ + /* ── 输入区域 (elia PromptInput 风格) ── */ #input-area {{ height: auto; min-height: 5; @@ -78,14 +90,13 @@ if TEXTUAL_AVAILABLE: min-height: 4; max-height: 12; background: {_Theme.BG_INPUT}; - border: tall {_Theme.BORDER}; + border: round {_Theme.BORDER}; padding: 0; - /* 聚焦时边框颜色过渡 */ transition: border-color 300ms in_out_cubic; }} #input-wrapper:focus-within {{ - border: tall {_Theme.BORDER_FOCUS}; + border: round {_Theme.BORDER_FOCUS}; background: {_Theme.BG}; }} @@ -103,7 +114,6 @@ if TEXTUAL_AVAILABLE: background: transparent; }} - /* TextArea 内部样式覆盖 */ #user-input .text-area--cursor {{ color: {_Theme.ACCENT}; }} @@ -120,7 +130,6 @@ if TEXTUAL_AVAILABLE: height: 1; color: {_Theme.TEXT_DIM}; padding: 0 1; - /* 渐入效果 */ opacity: 0; transition: opacity 500ms in_out_cubic; }} @@ -129,25 +138,195 @@ if TEXTUAL_AVAILABLE: opacity: 1; }} - /* ── 侧栏 ── */ + /* ── 命令提示浮层 — 在输入框上方,display:none 时不占空间 ── */ + #command-hint {{ + layer: overlay; + display: none; + height: auto; + max-height: 10; + width: 1fr; + margin: 0 1 1 1; + padding: 0 1; + background: {_Theme.BG_LIGHT} 98%; + border: round {_Theme.ACCENT}; + overflow-y: auto; + }} + .cmd-hint-cmd {{ + color: {_Theme.ACCENT}; + text-style: bold; + }} + .cmd-hint-desc {{ + color: {_Theme.TEXT_DIM}; + }} + + /* ── 安全审查确认浮层 ── */ + #confirm-overlay {{ + dock: top; + layer: overlay; + display: none; + height: auto; + max-height: 16; + width: 60; + min-width: 40; + margin: 2 4; + padding: 1 2; + background: {_Theme.BG}; + border: heavy {_Theme.ERROR}; + overflow-y: auto; + }} + #confirm-overlay.visible {{ + display: block; + }} + + /* ── 辅助中心 ── 背景遮罩 + 居中卡片 */ + #settings-backdrop {{ + layer: overlay; + width: 100%; + height: 100%; + display: none; + background: {_Theme.BG} 70%; + offset: 0 0; + opacity: 0; + transition: opacity 220ms out_cubic; + }} + #settings-backdrop.visible {{ + display: block; + opacity: 1; + }} + #settings-backdrop.hiding {{ + opacity: 0; + transition: opacity 180ms in_cubic; + }} + + #settings-overlay {{ + layer: overlay; + dock: top; + display: none; + height: auto; + max-height: 28; + width: 76; + min-width: 56; + margin: 2 4; + padding: 1 2; + background: {_Theme.BG_LIGHT} 98%; + border: round {_Theme.ACCENT} 80%; + overflow-y: auto; + offset: 0 -12; + opacity: 0; + transition: offset 320ms out_cubic, opacity 280ms out_cubic; + }} + #settings-overlay.visible {{ + display: block; + offset: 0 0; + opacity: 1; + }} + #settings-overlay.hiding {{ + offset: 0 -12; + opacity: 0; + transition: offset 240ms in_cubic, opacity 200ms in_cubic; + }} + .settings-title {{ + color: {_Theme.ACCENT}; + text-style: bold; + text-align: center; + padding: 0 0 1 0; + }} + .settings-section {{ + color: {_Theme.TEXT_MUTED}; + text-style: bold; + padding: 1 0 0 0; + }} + .settings-line {{ + color: {_Theme.TEXT}; + padding: 0 1; + }} + .settings-hint {{ + color: {_Theme.TEXT_DIM}; + text-style: italic; + padding: 1 0 0 0; + }} + .settings-key {{ + color: {_Theme.WARNING}; + text-style: bold; + }} + .settings-val {{ + color: {_Theme.SUCCESS}; + text-style: bold; + }} + + /* ── 帮助浮层 ── 背景遮罩 + 居中卡片 */ + #help-backdrop {{ + layer: overlay; + width: 100%; + height: 100%; + display: none; + background: {_Theme.BG} 70%; + offset: 0 0; + opacity: 0; + transition: opacity 220ms out_cubic; + }} + #help-backdrop.visible {{ + display: block; + opacity: 1; + }} + #help-backdrop.hiding {{ + opacity: 0; + transition: opacity 180ms in_cubic; + }} + + #help-overlay {{ + layer: overlay; + dock: top; + display: none; + height: auto; + max-height: 32; + width: 80; + min-width: 60; + margin: 1 2; + padding: 1 2; + background: {_Theme.BG_LIGHT} 98%; + border: round {_Theme.ACCENT} 80%; + overflow-y: auto; + offset: 0 -12; + opacity: 0; + transition: offset 320ms out_cubic, opacity 280ms out_cubic; + }} + #help-overlay.visible {{ + display: block; + offset: 0 0; + opacity: 1; + }} + #help-overlay.hiding {{ + offset: 0 -12; + opacity: 0; + transition: offset 240ms in_cubic, opacity 200ms in_cubic; + }} + + /* ── 侧栏 ── 横向滑入滑出 + 淡入淡出 */ #sidebar {{ width: 32; min-width: 32; height: 1fr; + dock: right; background: {_Theme.BG_LIGHT}; - border-left: wide {_Theme.BORDER}; - display: block; - /* 侧栏滑入/滑出动画 */ - transition: width 300ms in_out_cubic, opacity 300ms in_out_cubic; - overflow: hidden; + border-left: tall {_Theme.BORDER}; + /* 用 opacity 控制可见性,offset 制造滑入滑出感 */ + opacity: 0; + offset: 16 0; + display: none; + transition: offset 320ms out_cubic, opacity 280ms out_cubic; }} - #sidebar.hidden {{ - width: 0; - min-width: 0; + #sidebar.visible {{ + display: block; + opacity: 1; + offset: 0 0; + }} + + #sidebar.hiding {{ opacity: 0; - border: none; - overflow: hidden; + offset: 16 0; + transition: offset 240ms in_cubic, opacity 200ms in_cubic; }} .sidebar-header {{ @@ -180,9 +359,21 @@ if TEXTUAL_AVAILABLE: color: {_Theme.TEXT_MUTED}; }} - .tool-item-name {{ - color: {_Theme.TEXT}; - text-style: bold; + /* ── 可切换背景效果 — 通过 #chat-scroll 的 class 切换 ── + Textual CSS 不支持 gradient 函数,改用纯色色调切换 + bg-grid: 深蓝灰 + bg-stars: 深紫 + bg-glow: 深蓝 + 默认(无 class): 原始深黑 + */ + #chat-scroll.bg-grid {{ + background: #11161e; + }} + #chat-scroll.bg-stars {{ + background: #16121f; + }} + #chat-scroll.bg-glow {{ + background: #0f1620; }} /* ── 状态栏 ── */ @@ -193,40 +384,39 @@ if TEXTUAL_AVAILABLE: background: {_Theme.BG_LIGHT}; color: {_Theme.TEXT_MUTED}; padding: 0 1; - /* 状态变化过渡 */ - transition: color 300ms in_out_cubic; + }} + + #status-bar.pulse {{ + color: {_Theme.WARNING}; + background: {_Theme.GLOW} 40%; + transition: color 150ms, background 150ms; + }} + + /* ── Footer ── */ + Footer {{ + background: {_Theme.BG_LIGHT}; + color: {_Theme.TEXT_MUTED}; + border-top: tall {_Theme.BORDER}; + }} + + Footer > .key {{ + background: {_Theme.BG}; + color: {_Theme.ACCENT}; }} /* ── 消息样式 ── */ - .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; }} .msg-tool-ok {{ - color: {_Theme.TOOL}; + color: {_Theme.SUCCESS}; padding: 0 1; }} .msg-tool-err {{ - color: {_Theme.TOOL_ERR}; - padding: 0 1; - }} - - .msg-thinking {{ - color: {_Theme.THINKING}; - text-style: italic; + color: {_Theme.ERROR}; padding: 0 1; }} @@ -241,64 +431,117 @@ if TEXTUAL_AVAILABLE: }} .msg-welcome {{ - color: {_Theme.WELCOME_ACCENT}; - padding: 0 1; - text-style: bold; - }} - - .msg-welcome-line {{ - color: {_Theme.WELCOME_ACCENT}; - padding: 0 1; - text-style: bold; - /* 每行依次淡入 */ - opacity: 0; - transition: opacity 400ms in_out_cubic; - }} - - .msg-welcome-line.visible {{ - opacity: 1; - }} - - .msg-system-line {{ color: {_Theme.ACCENT}; padding: 0 1; - opacity: 0; - transition: opacity 400ms in_out_cubic; + text-style: bold; }} - .msg-system-line.visible {{ + /* ── ResponseStatus (elia 原版) ── */ + ResponseStatus {{ + dock: top; + align-horizontal: right; + display: none; + layer: overlay; + height: 2; + width: auto; + margin-top: 1; + margin-right: 2; + }} + ResponseStatus Label {{ + width: auto; + color: {_Theme.WARNING}; + }} + ResponseStatus LoadingIndicator {{ + width: auto; + color: {_Theme.ACCENT}; + height: 1; + margin-top: 1; + dock: right; + }} + ResponseStatus.-awaiting-response LoadingIndicator {{ + color: {_Theme.ACCENT}; + }} + ResponseStatus.-agent-responding LoadingIndicator {{ + color: {_Theme.WARNING}; + }} + + /* ── Chatbox (elia 原版样式) ── */ + Chatbox {{ + height: auto; + width: 1fr; + max-width: 1fr; + margin: 0 1; + padding: 0 2; + }} + Chatbox.assistant-message {{ + border: round {_Theme.ACCENT}; + }} + Chatbox.assistant-message.response-in-progress {{ + background: {_Theme.ACCENT} 3%; + }} + Chatbox.human-message {{ + border: round {_Theme.BORDER}; + }} + + /* ── ThinkingBox:深度思考专用 widget(独立显示,灰色斜体) ── */ + ThinkingBox {{ + height: auto; + width: 1fr; + max-width: 1fr; + margin: 0 1; + padding: 0 2; + border: round {_Theme.TEXT_DIM}; + background: {_Theme.BG_LIGHT}; + color: {_Theme.TEXT_DIM}; + }} + ThinkingBox > .thinking-title {{ + color: {_Theme.TEXT_MUTED}; + text-style: bold italic; + padding: 0 0 1 0; + }} + ThinkingBox > .thinking-body {{ + color: {_Theme.TEXT_DIM}; + text-style: italic; + }} + + /* ── 引擎切换浮层 ── */ + EngineSwitchBanner {{ + layer: overlay; + dock: top; + height: 7; + width: auto; + min-width: 44; + max-width: 72; + content-align: center middle; + text-align: center; + background: {_Theme.BG_LIGHT} 96%; + color: {_Theme.ACCENT}; + text-style: bold; + border: round {_Theme.ACCENT} 80%; + border-title-color: {_Theme.ACCENT}; + border-title-background: {_Theme.BG_LIGHT}; + border-title-style: bold; + padding: 1 6; + margin: 2 0; + offset: 0 -8; + opacity: 0; + transition: offset 380ms out_cubic, opacity 380ms out_cubic; + }} + EngineSwitchBanner.show {{ + offset: 0 0; opacity: 1; }} - - /* 动画思考指示器 */ - .thinking-indicator {{ - color: {_Theme.THINKING}; - text-style: italic; - padding: 0 1; + EngineSwitchBanner.hide {{ + offset: 0 -8; + opacity: 0; + transition: offset 280ms in_cubic, opacity 280ms in_cubic; }} - - .code-block {{ - background: {_Theme.CODE_BG}; - border: wide {_Theme.BORDER}; - padding: 0 1; - margin: 0 2 0 2; - }} - - /* ── Tab 样式 ── */ - Tab {{ - background: {_Theme.BG}; - }} - - Tab.-active {{ - background: {_Theme.BG_LIGHT}; - }} - - TabbedContent > Tabs {{ - background: {_Theme.BG}; - }} - """ + # ═══════════════════════════════════════════════════ + # 桥接 TUI ↔ AICLI + # ═══════════════════════════════════════════════════ + class _TUIBridge: """桥接 TUI ↔ AICLI""" def __init__(self, cli): @@ -317,109 +560,364 @@ if TEXTUAL_AVAILABLE: return True return False - # ═══════════════════════════════════════════════════ - # 动画辅助组件 + # Chatbox — 直接复制自 elia,适配为无 litellm 依赖 + # 原项目: https://github.com/darrenburns/elia/blob/main/elia_chat/widgets/chatbox.py # ═══════════════════════════════════════════════════ - class ThinkingSpinner(Static): - """动态思考指示器 - 旋转 + 脉冲""" - FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] - DOTS_FRAMES = [" ", ". ", ".. ", "..."] + class Chatbox(Widget, can_focus=True): + """支持 Markdown 渲染的聊天消息组件 — 流式追加 - def __init__(self, **kwargs): - super().__init__("", **kwargs) - self._frame = 0 - self._dot_frame = 0 - self._running = False + render() 返回 Rich Markdown 对象,实现真正的代码高亮和格式化。 + append_chunk() 用于流式追加文本,只刷新布局不重建 widget。 + """ - def on_mount(self): - self._running = True - self.set_interval(0.08, self._animate) + def __init__( + self, + role: str = "assistant", + content: str = "", + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ) -> None: + super().__init__(name=name, id=id, classes=classes, disabled=disabled) + self.role = role + self.content = content - def _animate(self): - if not self._running: - return - spinner = self.FRAMES[self._frame % len(self.FRAMES)] - dots = self.DOTS_FRAMES[self._dot_frame % len(self.DOTS_FRAMES)] - self.update(f" {spinner} 思考中{dots}") - self._frame += 1 - if self._frame % 3 == 0: - self._dot_frame += 1 + def on_mount(self) -> None: + if self.role == "assistant": + self.add_class("assistant-message") + self.border_title = "小狸" + else: + self.add_class("human-message") + self.border_title = "你" - def stop(self): - self._running = False - - - class AnimatedWelcome(Static): - """逐行动画显示的欢迎组件""" - def __init__(self, lines, **kwargs): - super().__init__("", **kwargs) - self._lines = lines - self._current = 0 - - def on_mount(self): - self.set_interval(0.12, self._show_next_line) - - def _show_next_line(self): - if self._current < len(self._lines): - current_text = self.renderable - new_line = self._lines[self._current] - if self._current == 0: - self.update(new_line) - else: - self.update(str(current_text) + "\n" + new_line) - self._current += 1 - - - class MessageBubble(Static): - """带入场动画的消息气泡""" - def __init__(self, text, **kwargs): - super().__init__(text, **kwargs) - self.styles.opacity = 0 - - def on_mount(self): - # 使用 styles.animate 来动画化样式属性 - self.styles.animate("opacity", value=1.0, duration=0.3, easing="out_cubic") - - - class TypingIndicator(Static): - """打字指示器 - 模拟 AI 正在打字""" - FRAMES = ["●○○", "○●○", "○○●", "○●○"] - - def __init__(self, **kwargs): - super().__init__("", **kwargs) - self._frame = 0 - self._running = False - - def on_mount(self): - self._running = True - self.set_interval(0.3, self._tick) - - def _tick(self): - if not self._running: - return - dots = self.FRAMES[self._frame % len(self.FRAMES)] - self.update(f" {dots} AI 正在输入") - self._frame += 1 - - def stop(self): - self._running = False + def render(self) -> RenderableType: + if not self.content: + return "" + if self.role == "user": + return Syntax( + self.content, + lexer="markdown", + word_wrap=True, + background_color=_Theme.BG, + ) + return Markdown(self.content, code_theme="monokai") + def append_chunk(self, chunk: str) -> None: + """流式追加文本 — 来自 elia 的原版实现""" + self.content += chunk + self.refresh(layout=True) # ═══════════════════════════════════════════════════ - # 自定义 TextArea:回车发送,Shift+回车换行 + # ThinkingBox — 深度思考专用 widget(独立于 AI 回答框) # ═══════════════════════════════════════════════════ + class ThinkingBox(Widget, can_focus=False): + """深度思考专用 widget — 灰色斜体,独立显示在 AI 回答框之前 + + 用于显示深度思考模型(如 DeepSeek-R1、QwQ)的推理过程, + 与最终回答在视觉上分开。 + """ + + def __init__( + self, + content: str = "", + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: + super().__init__(name=name, id=id, classes=classes) + self.content = content + + def render(self) -> RenderableType: + if not self.content: + return "" + return Markdown( + f"💭 **深度思考**\n\n{self.content}", + code_theme="monokai", + ) + + def append_chunk(self, chunk: str) -> None: + """流式追加思考内容""" + self.content += chunk + self.refresh(layout=True) + + # ═══════════════════════════════════════════════════ + # ResponseStatus — 直接复制自 elia + # 原项目: https://github.com/darrenburns/elia/blob/main/elia_chat/widgets/agent_is_typing.py + # ═══════════════════════════════════════════════════ + + class ResponseStatus(Vertical): + """响应状态指示器 — 使用 Textual 内置 LoadingIndicator + + 两种状态: + - awaiting-response: 等待 AI 响应(思考中) + - agent-responding: AI 正在输出(流式中) + """ + message: reactive[str] = reactive(" 思考中", recompose=False) + + def compose(self) -> ComposeResult: + yield Label(f" {self.message}", id="rs-label") + yield LoadingIndicator() + + def watch_message(self, new_value: str) -> None: + try: + self.query_one("#rs-label").update(f" {new_value}") + except NoMatches: + pass + + def set_awaiting_response(self) -> None: + self.message = " 思考中" + self.add_class("-awaiting-response") + self.remove_class("-agent-responding") + self.display = True + + def set_agent_responding(self) -> None: + self.message = " 输出中" + self.add_class("-agent-responding") + self.remove_class("-awaiting-response") + + def hide(self) -> None: + self.display = False + + # ═══════════════════════════════════════════════════ + # 辅助组件 + # ═══════════════════════════════════════════════════ + + class EngineSwitchBanner(Static): + """引擎切换浮层 — 居中悬浮卡片,多行带色彩层次""" + DEFAULT_CSS = "" + + def __init__(self, old_engine: str, new_engine: str, **kwargs): + from rich.text import Text + # 三行布局:标题行 + 切换行 + 状态行,用 Rich Text 上色 + title = Text("⚡ 引擎切换", style=f"bold {self._acc()}", justify="center") + arrow = Text(" → ", style="dim") + old_t = Text(old_engine, style="dim strike") + new_t = Text(new_engine, style=f"bold {self._acc()}") + switch_line = Text.assemble(old_t, arrow, new_t) + hint = Text("切换中…", style="dim italic", justify="center") + content = Text.assemble(title, "\n", switch_line, "\n", hint) + super().__init__(content, **kwargs) + + @staticmethod + def _acc(): + return "#58a6ff" + class SendTextArea(TextArea): - """多行输入框,Ctrl+Enter发送消息""" + """回车发送消息,Ctrl+J 换行的输入框""" + async def _on_key(self, event): + # 命令提示导航:上下键 / Tab(提示可见时) + hint_visible = False + try: + hint = self.app.query_one("#command-hint") + hint_visible = hint.display and bool(hint._items) + if hint_visible: + if event.key == "up": + event.prevent_default() + hint.move_selection(-1) + return + elif event.key == "down": + event.prevent_default() + hint.move_selection(1) + return + elif event.key == "tab": + event.prevent_default() + cmd = hint.get_selected_command() + if cmd: + self.text = cmd + " " + self.cursor_location = (0, len(self.text)) + return + elif event.key == "escape": + event.prevent_default() + self.app._hide_command_hint() + return + except Exception: + pass + + # 输入历史导航:上下键(提示不可见时) + if not hint_visible and event.key in ("up", "down"): + history = getattr(self.app, '_input_history', []) + if history: + event.prevent_default() + idx = getattr(self.app, '_history_idx', -1) + if event.key == "up": + if idx == -1: + idx = len(history) - 1 + else: + idx = max(0, idx - 1) + self.text = history[idx] + else: # down + if idx == -1: + return # 已在最新,无操作 + idx += 1 + if idx >= len(history): + idx = -1 + self.text = "" + else: + self.text = history[idx] + self.app._history_idx = idx + self.cursor_location = (0, len(self.text)) + return + + # 换行:Shift+回车 / Ctrl+J / Alt+回车 + # (多数 Windows 终端不区分 Shift+Enter 与 Enter,故提供 Ctrl+J 作为可靠替代) + if event.key in ("shift+enter", "alt+enter", "ctrl+j"): + event.prevent_default() + event.stop() + self.insert("\n") + self.app.call_after_refresh(self.app._update_command_hint, self.text) + self.app._history_idx = -1 + return + + # 回车(无修饰键):发送消息 + if event.key in ("enter", "\r", "\n"): + event.prevent_default() + try: + self.app.action_send_message() + except Exception: + pass + return + # 其他按键交由父类处理后,刷新命令提示 + await super()._on_key(event) + self.app.call_after_refresh(self.app._update_command_hint, self.text) + # 任意编辑键重置历史浏览位置 + self.app._history_idx = -1 + + class CommandHint(Widget, can_focus=False): + """命令提示浮层 — 输入 / 时显示可用命令""" + + BUILTIN_COMMANDS = [ + ("help", "帮助信息"), + ("quit", "退出程序"), + ("cli", "切换到 CLI 模式"), + ("model", "切换 AI 引擎"), + ("engine", "切换 AI 引擎"), + ("engines", "列出可用引擎"), + ("tools", "列出已加载工具"), + ("status", "系统状态"), + ("clear", "清屏"), + ("safe", "切换安全模式"), + ("diff", "切换文件修改展示模式"), + ("chat", "对话历史管理 (save/list/open)"), + ("settings","打开辅助中心"), + ("profile", "引擎配置档案管理 (save/use/del)"), + ("about", "关于"), + ] + + def __init__(self): + super().__init__(id="command-hint") + self._items = [] + self._selected = 0 + + def update_hints(self, text: str): + """根据输入文本更新提示列表""" + if not text.startswith('/'): + self._items = [] + self.display = False + self.refresh() + return + + query = text[1:].split(maxsplit=1)[0].lower() if len(text) > 1 else "" + + items = [] + # 内置命令 + for cmd, desc in self.BUILTIN_COMMANDS: + if not query or cmd.startswith(query): + items.append((cmd, desc)) + # 插件命令 + cli = self.app.cli if hasattr(self.app, 'cli') else None + if cli and hasattr(cli, 'liugin_commands'): + for cmd_name in cli.liugin_commands: + if not query or cmd_name.startswith(query): + items.append((cmd_name, "插件命令")) + + # 去重 + seen = set() + unique = [] + for cmd, desc in items: + if cmd not in seen: + seen.add(cmd) + unique.append((cmd, desc)) + + self._items = unique[:10] + self._selected = 0 + self.display = len(self._items) > 0 + self.refresh() + + def move_selection(self, delta: int): + if not self._items: + return + self._selected = (self._selected + delta) % len(self._items) + self.refresh() + + def get_selected_command(self) -> str: + if not self._items: + return "" + return "/" + self._items[self._selected][0] + + def render(self) -> RenderableType: + if not self._items: + return "" + lines = [] + for i, (cmd, desc) in enumerate(self._items): + marker = "▸ " if i == self._selected else " " + lines.append(f"{marker}[cmd-hint-cmd]/{cmd}[/] [cmd-hint-desc]{desc}[/]") + return "\n".join(lines) + + # ═══════════════════════════════════════════════════ + # 中文化命令面板 (CommandPalette) + # ═══════════════════════════════════════════════════ + + class _CNCommandPalette(CommandPalette): + """中文化命令面板 — 覆盖占位符、绑定描述与"未找到匹配项"文本""" + + # 覆盖绑定描述为中文(Footer 与帮助面板会显示) + BINDINGS = [ + Binding("ctrl+end, shift+end", "command_list('last')", "跳到底部", show=False), + Binding("ctrl+home, shift+home", "command_list('first')", "跳到顶部", show=False), + Binding("down", "cursor_down", "下一条命令", show=False), + Binding("escape", "escape", "退出命令面板"), + Binding("pagedown", "command_list('page_down')", "下一页", show=False), + Binding("pageup", "command_list('page_up')", "上一页", show=False), + Binding("up", "command_list('cursor_up')", "上一条命令", show=False), + ] + + def __init__(self, **kwargs) -> None: + kwargs.setdefault("placeholder", "搜索命令…") + super().__init__(**kwargs) + + def _start_no_matches_countdown(self, search_value: str) -> None: + """覆盖父类方法,将 "No matches found" 替换为中文""" + self._stop_no_matches_countdown() + + def _show_no_matches() -> None: + if search_value: + command_list = self.query_one(CommandList) + command_list.add_option( + Option( + Align.center(Text("未找到匹配项", style="not bold")), + disabled=True, + id=self._NO_MATCHES, + ) + ) + self._list_visible = True + else: + self._list_visible = False + + self._no_matches_timer = self.set_timer( + self._NO_MATCHES_COUNTDOWN, + _show_no_matches, + ) # ═══════════════════════════════════════════════════ # TUI 主应用 # ═══════════════════════════════════════════════════ class XiaoliTUI(App): - """小狸 TUI v3 - 动画增强版 + 多行输入""" + """小狸 TUI — 基于 elia 架构""" CSS = _TUI_CSS TITLE = " 小狸 Pro-CLI" SUB_TITLE = "智能编程助手" @@ -429,37 +927,128 @@ if TEXTUAL_AVAILABLE: Binding("ctrl+l", "clear", "清屏", show=True), Binding("ctrl+n", "new_chat", "新对话", show=True), Binding("ctrl+enter", "send_message", "发送", show=False), - Binding("shift+enter", "newline", "换行", show=False), Binding("f1", "toggle_sidebar", "侧栏", show=True), - Binding("escape", "cancel", "取消", show=False), + Binding("f2", "switch_engine", "引擎", show=True), + Binding("ctrl+p", "toggle_settings", "辅助中心", show=True), + Binding("f9", "toggle_settings", "辅助中心", show=False), + Binding("question_mark", "toggle_help", "帮助", show=True), + Binding("escape", "cancel", "取消", show=True), ] - sidebar_visible = var(True) + sidebar_visible = var(False) is_generating = var(False) + # 背景效果:0=墨黑 1=深蓝灰 2=深紫 3=深蓝 + _BG_CLASSES = ["", "bg-grid", "bg-stars", "bg-glow"] + _BG_LABELS = ["墨黑", "深蓝灰", "深紫", "深蓝"] def __init__(self, cli): super().__init__() self.cli = cli self.bridge = _TUIBridge(cli) self._thinking_widget = None - self._typing_widget = None + self._cancel_requested = False # 取消标志 + self._current_worker = None # 当前 worker 引用 + self._confirm_event = None # 安全审查确认 Event + self._confirm_result = False # 安全审查确认结果 + self._bg_idx = 0 # 预设背景效果索引 + self._bg_image_path = "" # 自定义背景图片路径 + self._bg_custom_color = "" # 从图片提取的背景色(hex) + self._input_history = [] # 输入历史(最新在末尾) + self._history_idx = -1 # -1 表示当前未在浏览历史 + + # ── 命令面板中文化 ── + + def action_command_palette(self) -> None: + """覆盖默认命令面板,使用中文版""" + if self.use_command_palette and not CommandPalette.is_open(self): + self.push_screen(_CNCommandPalette(id="--command-palette")) + + def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]: + """覆盖系统命令为中文""" + if not self.ansi_color: + yield SystemCommand( + "主题", + "切换当前主题", + self.action_change_theme, + ) + yield SystemCommand( + "退出", + "退出应用程序", + self.action_quit, + ) + if screen.query("HelpPanel"): + yield SystemCommand( + "快捷键", + "隐藏快捷键帮助面板", + self.action_hide_help_panel, + ) + else: + yield SystemCommand( + "快捷键", + "显示快捷键与命令帮助", + self.action_show_help_panel, + ) + if screen.maximized is not None: + yield SystemCommand( + "最小化", + "最小化当前控件并恢复原始尺寸", + screen.action_minimize, + ) + elif screen.focused is not None and screen.focused.allow_maximize: + yield SystemCommand( + "最大化", + "最大化当前控件", + screen.action_maximize, + ) + yield SystemCommand( + "截图", + "保存当前屏幕的 SVG 截图", + lambda: self.set_timer(0.1, self.deliver_screenshot), + ) + + def search_themes(self) -> None: + """覆盖主题搜索面板,使用中文占位符""" + from textual.theme import ThemeProvider + self.push_screen( + _CNCommandPalette( + providers=[ThemeProvider], + placeholder="搜索主题…", + ), + ) + + def action_show_help_panel(self) -> None: + """打开帮助浮层(覆盖 Textual 默认侧边栏 HelpPanel)""" + self._show_help_panel() + + def action_hide_help_panel(self) -> None: + """关闭帮助浮层""" + self._hide_help_panel() + + # ── 布局 ── 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"): - with Container(id="input-wrapper"): - yield SendTextArea( - placeholder="输入消息... (Ctrl+Enter 发送, Enter 换行)", id="user-input", - soft_wrap=True, - tab_behavior="indent", - ) - yield Static( - " 回车发送 | Shift+回车换行 | Ctrl+L 清屏 | F1 侧栏", - id="input-hint" + with Vertical(id="app-container"): + yield ResponseStatus(id="response-status") + yield VerticalScroll(id="chat-scroll") + yield Static("", id="confirm-overlay") + yield Static("", id="settings-backdrop") + yield Static("", id="settings-overlay") + yield Static("", id="help-backdrop") + yield Static("", id="help-overlay") + with Vertical(id="input-area"): + yield CommandHint() + with Container(id="input-wrapper"): + yield SendTextArea( + placeholder=" 输入消息... (回车发送, Ctrl+J 换行, / 命令提示)", + id="user-input", + soft_wrap=True, + tab_behavior="indent", ) + yield Static( + " 回车发送 Ctrl+J 换行 Esc 取消 F2 引擎 F1 侧栏 Ctrl+P 辅助中心 Ctrl+C 退出", + id="input-hint" + ) with Vertical(id="sidebar"): yield Static(" 引擎", classes="sidebar-header") yield Vertical(id="engine-list", classes="sidebar-section") @@ -469,84 +1058,214 @@ if TEXTUAL_AVAILABLE: 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") + yield Static(" 就绪", id="status-bar") + yield Footer() def on_mount(self): - self._render_welcome_animated() - self._update_sidebar() + self._render_welcome() self.query_one("#user-input").focus() - # 延迟显示输入提示(淡入效果) - self.set_timer(1.5, lambda: self.query_one("#input-hint").add_class("visible")) + def _show_hint(): + try: + self.query_one("#input-hint").add_class("visible") + except Exception: + pass + self.set_timer(0.5, _show_hint) + # 应用持久化的背景效果 + try: + from .config import get_system_config + saved = get_system_config('tui_background', 0) + self._bg_idx = saved if 0 <= saved < len(self._BG_CLASSES) else 0 + # 自定义图片背景 + self._bg_image_path = get_system_config('tui_bg_image', '') or '' + if self._bg_image_path: + self._extract_color_from_image() + except Exception: + self._bg_idx = 0 + self._apply_background() + self._update_status("就绪") - # ── 动画欢迎界面 ── + def on_key(self, event): + """全局按键处理:辅助中心 / 帮助浮层 / 安全审查确认浮层可见时拦截按键""" + try: + # 辅助中心可见时,拦截所有按键 + settings = self.query_one("#settings-overlay") + if settings.display and settings.has_class("visible"): + key = event.key.lower() if hasattr(event, 'key') else "" + if key == "escape": + event.prevent_default() + event.stop() + self._hide_settings() + elif key in ("1", "2", "3", "4", "5", "6"): + event.prevent_default() + event.stop() + self._cycle_setting(int(key)) + else: + # 其他按键也拦截,避免触发输入框等 + event.prevent_default() + event.stop() + return + except Exception: + pass + try: + # 帮助浮层可见时,仅 Esc 关闭,其余按键拦截 + help_overlay = self.query_one("#help-overlay") + if help_overlay.display and help_overlay.has_class("visible"): + key = event.key.lower() if hasattr(event, 'key') else "" + if key == "escape": + event.prevent_default() + event.stop() + self._hide_help_panel() + else: + event.prevent_default() + event.stop() + return + except Exception: + pass + try: + overlay = self.query_one("#confirm-overlay") + if overlay.display and self._confirm_event: + key = event.key.lower() if hasattr(event, 'key') else "" + if key == 'y': + event.prevent_default() + event.stop() + self._confirm_result = True + self._confirm_event.set() + self._hide_confirm() + elif key in ('n', 'escape'): + event.prevent_default() + event.stop() + self._confirm_result = False + self._confirm_event.set() + self._hide_confirm() + except Exception: + pass - def _render_welcome_animated(self): - """逐行动画显示欢迎界面""" - scroll = self.query_one("#chat-scroll") - - welcome_lines = [ - "", - " ╔══════════════════════════════════════════════╗", - " ║ ║", - " ║ 小狸 Pro-CLI v6.0 ║", - " ║ 智能编程助手 · 动画增强版 ║", - " ║ ║", - " ╚══════════════════════════════════════════════╝", - "", - " 代码编辑 · 代码搜索 · Git 集成 · 多引擎", - " 输入 /help 查看命令 | /model 切换引擎", - f" 当前引擎: {self.bridge.current_engine()} | 工具: {len(self.bridge.tools())} 个", - " 支持多行输入 — Shift+回车换行", - "", - ] - - # 逐行渲染,带延迟 - for i, line in enumerate(welcome_lines): - if "╔" in line or "║" in line or "╚" in line: - cls = "msg-welcome-line" - elif line.strip().startswith("小狸"): - cls = "msg-welcome-line" - elif line.strip().startswith("智能"): - cls = "msg-welcome-line" - elif "代码编辑" in line or "输入 /help" in line or "当前引擎" in line: - cls = "msg-system-line" - elif "多行输入" in line: - cls = "msg-system-line" - else: - cls = "msg-dim" - - widget = Static(line, classes=cls) - scroll.mount(widget) - # 延迟显示每一行(避免 delay=0 导致 Textual 除零错误) - delay = max(0.01, i * 0.1) - self.set_timer(delay, lambda w=widget: w.add_class("visible")) - - # 最后滚动到底部 - self.set_timer(max(0.01, len(welcome_lines) * 0.1 + 0.1), - lambda: scroll.scroll_end(animate=True, duration=0.3)) + # ── 欢迎界面 ── def _render_welcome(self): - """静态欢迎界面(用于清屏后快速恢复)""" scroll = self.query_one("#chat-scroll") + engine = self.bridge.current_engine() + tools = len(self.bridge.tools()) lines = [ ("", "msg-dim"), - (" ╔══════════════════════════════════════════════╗", "msg-welcome"), - (" ║ ║", "msg-welcome"), - (" ║ 小狸 Pro-CLI v6.0 ║", "msg-welcome"), - (" ║ 智能编程助手 · 动画增强版 ║", "msg-welcome"), - (" ║ ║", "msg-welcome"), - (" ╚══════════════════════════════════════════════╝", "msg-welcome"), + (" -----------------------------------------------", "msg-welcome"), + (" 小狸 Pro-CLI v6.1.0", "msg-welcome"), + (" 智能编程助手 · TUI 模式", "msg-welcome"), + (" -----------------------------------------------", "msg-welcome"), ("", "msg-dim"), - (" 代码编辑 · 代码搜索 · Git 集成 · 多引擎", "msg-system"), - (" 输入 /help 查看命令 | /model 切换引擎", "msg-system"), - (f" 当前引擎: {self.bridge.current_engine()} | 工具: {len(self.bridge.tools())} 个", "msg-system"), - (" 支持多行输入 — Shift+回车换行", "msg-system"), + (f" 引擎: {engine} | 工具: {tools} 个", "msg-system"), + ("", "msg-dim"), + (" /help 查看命令 /model 切换引擎 /clear 清屏", "msg-dim"), ("", "msg-dim"), ] for text, cls in lines: scroll.mount(Static(text, classes=cls)) + scroll.scroll_end(animate=False) - # ── 侧栏更新 ── + # ── 消息操作 ── + + def _append(self, widget): + scroll = self.query_one("#chat-scroll") + scroll.mount(widget) + scroll.scroll_end(animate=False) + + def _smart_scroll_end(self): + """线程安全的智能滚动 — 在主线程中执行""" + try: + scroll = self.query_one("#chat-scroll") + if scroll.scroll_y >= scroll.max_scroll_y - 5: + scroll.scroll_end(animate=False) + except Exception: + pass + + def _mount_first_chunk(self, widget): + """挂载首个流式 chunk 的 widget(主线程中执行,保证原子性)""" + self._remove_thinking() + self._append(widget) + try: + self.query_one("#response-status").set_agent_responding() + except Exception: + pass + self._update_status(" 输出中...") + + def _mount_thinking_widget(self, widget): + """挂载 ThinkingBox — 在聊天区追加(主线程中执行)""" + try: + scroll = self.query_one("#chat-scroll") + scroll.mount(widget) + scroll.scroll_end(animate=False) + except Exception: + pass + + def _remove_thinking_widget(self, widget): + """移除空的 ThinkingBox""" + try: + if widget and widget.is_mounted: + widget.remove() + except Exception: + pass + + def _add(self, text, cls="msg-dim"): + self._append(Static(text, classes=cls)) + + def _user_msg(self, text): + """用户消息 — 使用 Chatbox (elia 模式),CSS + on_mount 自动处理滑入动画""" + widget = Chatbox(role="user", content=text) + self._append(widget) + + def _ai_msg(self, text): + """AI 消息 — 使用 Chatbox 渲染 Markdown""" + self._append(Chatbox(role="assistant", content=text)) + + def _system(self, text): + self._add(f" {text}", "msg-system") + + def _error(self, text): + self._add(f" [错误] {text}", "msg-error") + + # ── 思考指示器 (elia ResponseStatus 模式) ── + + def _show_thinking(self): + """显示思考状态 — 仅右上角 ResponseStatus 浮层 + + 不再往 chat-scroll 里 mount LoadingIndicator, + 因为无 CSS 约束的 LoadingIndicator 会撑满整个聊天区(视觉上全屏)。 + 右上角 ResponseStatus(2 行高小浮层)+ 状态栏文字已足够。 + """ + if self._thinking_widget: + try: + self._thinking_widget.remove() + except Exception: + pass + self._thinking_widget = None + try: + rs = self.query_one("#response-status") + rs.set_awaiting_response() + except Exception: + pass + + def _remove_thinking(self): + """移除思考状态""" + try: + self.query_one("#response-status").hide() + except Exception: + pass + if self._thinking_widget: + try: + self._thinking_widget.remove() + except Exception: + pass + self._thinking_widget = None + + def _show_typing_indicator(self): + """兼容旧接口""" + pass + + def _remove_typing_indicator(self): + """兼容旧接口""" + pass + + # ── 侧栏 ── def _update_sidebar(self): engine_container = self.query_one("#engine-list") @@ -554,7 +1273,7 @@ if TEXTUAL_AVAILABLE: current = self.bridge.current_engine() for name in self.bridge.engines(): if name == current: - engine_container.mount(Static(f" ▸ {name}", classes="engine-item-active")) + engine_container.mount(Static(f" > {name}", classes="engine-item-active")) else: engine_container.mount(Static(f" {name}", classes="engine-item")) @@ -562,7 +1281,7 @@ if TEXTUAL_AVAILABLE: tool_container.remove_children() for tool in self.bridge.tools(): name = tool.get('name', '?') - tool_container.mount(Static(f" • {name}", classes="tool-item")) + tool_container.mount(Static(f" - {name}", classes="tool-item")) status_container = self.query_one("#status-info") status_container.remove_children() @@ -570,151 +1289,81 @@ if TEXTUAL_AVAILABLE: status_container.mount(Static(f" 工具: {len(self.bridge.tools())} 个", classes="tool-item")) status_container.mount(Static(f" 引擎: {len(self.bridge.engines())} 个", classes="tool-item")) - # 工具数 - if hasattr(self.cli, 'liugin_manager'): - status_container.mount(Static(f" 工具: {len(self.cli.liugin_manager.tools)} 个", 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())} 条对话") + try: + self.query_one("#status-bar").update(f" {text}") + except Exception: + pass - # ── 消息追加 ── + # ── 输入动作 ── - def _append(self, widget): - scroll = self.query_one("#chat-scroll") - scroll.mount(widget) - scroll.scroll_end(animate=True, duration=0.2) + def _update_command_hint(self, text: str): + """更新命令提示浮层(输入 / 时触发)""" + try: + hint = self.query_one("#command-hint") + hint.update_hints(text) + except Exception: + pass - def _add(self, text, cls="msg-dim"): - widget = MessageBubble(text, classes=cls) - self._append(widget) + def _hide_command_hint(self): + """隐藏命令提示浮层""" + try: + hint = self.query_one("#command-hint") + hint.display = False + hint._items = [] + hint.refresh() + except Exception: + pass - def _user_msg(self, text): - self._add(f" {text}", "msg-user") + def _show_confirm(self, risk_info: str): + """显示工具调用安全审查确认浮层""" + try: + overlay = self.query_one("#confirm-overlay") + overlay.update(risk_info) + overlay.add_class("visible") + overlay.display = True + except Exception: + pass - 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) - panel = Panel(syntax, border_style=f"dim {_Theme.BORDER}", - box=ROUNDED, padding=(0, 1)) - widget = MessageBubble(str(panel), classes="code-block") - self._append(widget) - except Exception: - 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 _show_thinking(self): - """显示动画思考指示器""" - self._remove_thinking() - spinner = ThinkingSpinner(classes="thinking-indicator") - scroll = self.query_one("#chat-scroll") - scroll.mount(spinner) - scroll.scroll_end(animate=True, duration=0.2) - self._thinking_widget = spinner - - def _remove_thinking(self): - """移除思考指示器""" - if self._thinking_widget: - try: - self._thinking_widget.stop() - self._thinking_widget.remove() - except Exception: - pass - self._thinking_widget = None - - def _show_typing_indicator(self): - """显示打字指示器""" - self._remove_typing_indicator() - indicator = TypingIndicator(classes="thinking-indicator") - scroll = self.query_one("#chat-scroll") - scroll.mount(indicator) - scroll.scroll_end(animate=True, duration=0.2) - self._typing_widget = indicator - - def _remove_typing_indicator(self): - """移除打字指示器""" - if self._typing_widget: - try: - self._typing_widget.stop() - self._typing_widget.remove() - except Exception: - pass - self._typing_widget = None - - def _thinking(self): - """兼容旧接口""" - self._show_thinking() - - def _system(self, text): - self._add(f" ℹ {text}", "msg-system") - - def _error(self, text): - self._add(f" {text}", "msg-error") - - # ── 多行输入处理 ── - - def action_newline(self): - """Shift+Enter 在输入框插入换行""" - text_area = self.query_one("#user-input") - if text_area.has_focus: - text_area.action_edit_insert_newline() + def _hide_confirm(self): + """隐藏确认浮层""" + try: + overlay = self.query_one("#confirm-overlay") + overlay.remove_class("visible") + overlay.display = False + overlay.update("") + except Exception: + pass def action_send_message(self): """回车发送消息""" + if self.is_generating: + return + text_area = self.query_one("#user-input") text = text_area.text.strip() if not text: return - # 清空输入框 + # 记录到输入历史(去重:与最后一条相同则不记) + if not self._input_history or self._input_history[-1] != text: + self._input_history.append(text) + if len(self._input_history) > 100: + self._input_history.pop(0) + self._history_idx = -1 + text_area.clear() + self._hide_command_hint() if text.startswith('/'): self._handle_command(text) return self._user_msg(text) - self._show_thinking() self.is_generating = True - self._update_status(" 思考中...") - self.run_worker(self._generate(text), exclusive=True) - - # ── 兼容旧 Input 提交(保留以防万一) ── - - @on(TextArea.Changed, "#user-input") - def on_textarea_changed(self, event): - """输入框内容变化时调整高度""" - text_area = event.text_area - line_count = text_area.text.count('\n') + 1 - # 动态调整输入区域高度 - wrapper = self.query_one("#input-wrapper") - new_height = min(max(line_count + 1, 4), 12) - wrapper.styles.height = new_height + self._cancel_requested = False # 重置取消标志 + self._cancel_generation = getattr(self, '_cancel_generation', 0) + 1 # 新 worker 代次 + self._current_worker = self.run_worker(partial(self._generate, text), thread=True, exclusive=True) # ── 命令处理 ── @@ -724,7 +1373,7 @@ if TEXTUAL_AVAILABLE: args = parts[1] if len(parts) > 1 else "" cmds = { - 'help': lambda: self._show_help(), + 'help': lambda: self._show_help_panel(), 'quit': lambda: self.exit(), 'q': lambda: self.exit(), 'exit': lambda: self.exit(), @@ -733,12 +1382,18 @@ if TEXTUAL_AVAILABLE: 'cls': lambda: self.action_clear(), 'model': lambda: self._switch_model(args), 'engine': lambda: self._switch_model(args), - 'about': lambda: self._system(" 小狸 Pro-CLI v6.0 - 智能编程助手"), + 'about': lambda: self._system(" 小狸 Pro-CLI v6.1.0 - 智能编程助手"), 'status': lambda: self._show_status(), 'tools': lambda: self._show_tools(), 'engines': lambda: self._show_engines(), 'tui': lambda: self._system("已在 TUI 模式中"), 'manual': lambda: self._handle_manual(args), + 'safe': lambda: self._handle_safe(args), + 'diff': lambda: self._handle_diff_mode(args), + 'chat': lambda: self._handle_chat(args), + 'bg': lambda: self._handle_background(args), + 'settings': lambda: self._show_settings(), + 'profile': lambda: self._handle_profile(args), } handler = cmds.get(name) @@ -755,38 +1410,778 @@ if TEXTUAL_AVAILABLE: else: self._error(f"未知命令: /{name},输入 /help 查看帮助") - def _show_help(self): - help_text = """ 命令: - /help 帮助信息 - /quit 退出 - /cli 切换命令行模式 - /model <引擎> 切换 AI 引擎 - /engines 列出引擎 - /tools 列出工具 - /status 系统状态 - /clear 清屏 + def _handle_safe(self, args): + """切换安全模式:off=无限制(跳过审查) / on=普通 / manual=人工确认""" + from xcli_core.safety import get_safety, MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL + safety = get_safety() + arg = args.strip().lower() if args else "" + if arg in ('off', 'unrestricted', '无限制'): + safety.set_mode(MODE_UNRESTRICTED) + self._system(" 安全模式: 无限制 (跳过工具审查)") + elif arg in ('on', 'normal', '普通'): + safety.set_mode(MODE_NORMAL) + self._system(" 安全模式: 普通 (AI 识别风险)") + elif arg in ('manual', '人工', 'all'): + safety.set_mode(MODE_MANUAL) + self._system(" 安全模式: 人工确认 (所有工具调用需确认)") + else: + mode_name = safety.cycle_mode() + self._system(f" 安全模式: {mode_name}") - ⌨ 快捷键: - Ctrl+C 退出 Ctrl+L 清屏 - Ctrl+N 新对话 回车 发送消息 - Shift+回车 换行 F1 侧栏 - Escape 取消生成 Tab 缩进""" - self._system(help_text) + def _handle_diff_mode(self, args): + """切换文件修改展示模式:popup=弹窗 / inline=终端内 / off=关闭""" + arg = args.strip().lower() if args else "" + # 找到 code_editor 插件 + editor = None + if hasattr(self.cli, 'liugin_manager'): + for tool in self.cli.liugin_manager.tools: + if tool.get('name') == 'code_editor': + editor = tool.get('instance') + break + if not editor: + self._error("未找到代码编辑器插件") + return + + if arg in ('popup', '弹窗'): + editor.diff_popup_enabled = True + editor.diff_popup_mode = True + self._system(" 文件修改展示: 弹窗模式(新终端窗口)") + elif arg in ('inline', '内联', '终端', 'ssh'): + editor.diff_popup_enabled = True + editor.diff_popup_mode = False + self._system(" 文件修改展示: 终端内显示(SSH 兼容)") + elif arg in ('off', '关', '关闭'): + editor.diff_popup_enabled = False + self._system(" 文件修改展示: 已关闭") + else: + # 无参数:循环切换 + if not editor.diff_popup_enabled: + editor.diff_popup_enabled = True + editor.diff_popup_mode = False + self._system(" 文件修改展示: 终端内显示") + elif editor.diff_popup_mode is False: + editor.diff_popup_mode = True + self._system(" 文件修改展示: 弹窗模式") + elif editor.diff_popup_mode is True: + editor.diff_popup_enabled = False + self._system(" 文件修改展示: 已关闭") + else: + editor.diff_popup_mode = False + self._system(" 文件修改展示: 终端内显示") + + def _handle_chat(self, args): + """对话历史管理: save <名称> / list / open <名称|序号>""" + if not getattr(self.cli, 'memory_manager', None): + self._error("记忆系统未初始化") + return + + parts = args.strip().split(maxsplit=1) + sub = parts[0].lower() if parts else "" + arg = parts[1].strip() if len(parts) > 1 else "" + + if sub == 'save': + if not arg: + self._error("请提供名称。用法: /chat save <名称>") + return + self.cli.memory_manager.auto_save_chat( + self.cli.shared_conversation_history, + label=arg + ) + self._system(f" 聊天记录已保存: {arg}") + + elif sub == 'list': + chats = self.cli.memory_manager.list_chats(limit=30) + if not chats: + self._system(" 没有已保存的聊天记录") + return + lines = [" 已保存的聊天记录:"] + for i, c in enumerate(chats, 1): + lines.append(f" {i}. {c['name']} ({c['time']}, {c['size_kb']}KB)") + self._system("\n".join(lines)) + + elif sub == 'open': + if not arg: + self._error("请提供名称或序号。用法: /chat open <名称|序号>") + return + name = arg + # 支持序号加载 + if name.isdigit(): + chats = self.cli.memory_manager.list_chats(limit=50) + idx = int(name) - 1 + if 0 <= idx < len(chats): + name = chats[idx]['name'].replace('.json', '') + else: + self._error(f"序号 {arg} 超出范围") + return + conversation = self.cli.memory_manager.load_chat(name) + if conversation is None and not name.endswith('.json'): + conversation = self.cli.memory_manager.load_chat(name + '.json') + if conversation is None: + self._error(f"聊天记录 '{name}' 不存在") + return + + # 替换历史并同步给引擎 + self.cli.shared_conversation_history = conversation + self.cli._set_shared_conversation_history() + + # 重绘聊天区 + scroll = self.query_one("#chat-scroll") + scroll.remove_children() + for msg in conversation: + role = msg.get('role', '') + content = msg.get('content', '') + if not content: + continue + if role == 'user': + self._user_msg(content) + elif role == 'assistant': + self._ai_msg(content) + # system/tool 消息跳过,避免噪音 + self._system(f" 已加载聊天记录: {name}({len(conversation)} 条)") + + else: + self._error("用法: /chat save <名称> | /chat list | /chat open <名称|序号>") + + # ── 辅助中心浮层 / 引擎配置档案 ── + + def _mask_key(self, key): + """脱敏 API Key 用于显示""" + if not key: + return "(空)" + if len(key) <= 8: + return key[:2] + "***" + return key[:4] + "***" + key[-4:] + + def _get_setting_state(self): + """收集辅助中心所有选项的状态,返回 dict 供显示和切换使用""" + from .safety import get_safety, MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL + from .notification import get_notification_manager + from .config import get_system_config + + # 安全模式 + safety = get_safety() + safety_modes = [(MODE_UNRESTRICTED, "无限制"), (MODE_NORMAL, "普通"), (MODE_MANUAL, "人工确认")] + safety_idx = safety.mode if 0 <= safety.mode <= 2 else 1 + safety_label = safety_modes[safety_idx][1] + + # 通知开关 + nm = get_notification_manager() + notify_label = "开启" if nm.enabled else "关闭" + + # diff 模式 + diff_editor = None + if hasattr(self.cli, 'liugin_manager'): + for tool in self.cli.liugin_manager.tools: + if tool.get('name') == 'code_editor': + diff_editor = tool.get('instance') + break + diff_modes = [("off", "关闭"), ("inline", "终端内"), ("popup", "弹窗")] + diff_idx = 0 + if diff_editor: + if not diff_editor.diff_popup_enabled: + diff_idx = 0 + elif diff_editor.diff_popup_mode is False: + diff_idx = 1 + else: + diff_idx = 2 + diff_label = diff_modes[diff_idx][1] + diff_available = diff_editor is not None + + # max_history(从 config 读取真实值,未配置则用默认 50) + max_history_val = get_system_config('max_history', 50) + # 历史档位 + history_options = [20, 50, 100, 999999] + try: + history_idx = history_options.index(max_history_val) if max_history_val in history_options else 1 + except (ValueError, TypeError): + history_idx = 1 + history_label = "无限制" if max_history_val >= 999999 else str(max_history_val) + + return { + 'safety': {'idx': safety_idx, 'label': safety_label, 'options': safety_modes, 'available': True}, + 'notify': {'idx': 0 if nm.enabled else 1, 'label': notify_label, + 'options': [(True, "开启"), (False, "关闭")], 'available': True}, + 'diff': {'idx': diff_idx, 'label': diff_label, 'options': diff_modes, 'available': diff_available}, + 'history': {'idx': history_idx, 'label': history_label, + 'options': [(v, "无限制" if v >= 999999 else str(v)) for v in history_options], + 'available': True, 'values': history_options}, + 'background': {'idx': self._bg_idx, 'label': self._BG_LABELS[self._bg_idx], + 'options': list(enumerate(self._BG_LABELS)), 'available': True}, + 'bg_custom': { + 'active': bool(self._bg_custom_color), + 'label': (os.path.basename(self._bg_image_path) if self._bg_image_path else "无"), + 'color': self._bg_custom_color, + }, + } + + def _build_settings_text(self): + """构建辅助中心浮层的显示内容 — 全部功能 + 基础设置""" + from rich.text import Text + from .config import get_profiles, get_system_config + + engine = self.bridge.current_engine() + engine_name = getattr(engine, 'name', 'unknown') + base_url = getattr(engine, 'base_url', '') + api_key = getattr(engine, 'api_key', '') + model = getattr(engine, 'model', '') + + state = self._get_setting_state() + + t = Text() + t.append(Text("⚙ 辅助中心\n", style=f"bold {_Theme.ACCENT}", justify="center")) + t.append(Text("─" * 56 + "\n", style="dim")) + + # ── 基础设置 ── + t.append(Text("【基础设置】\n", style=f"bold {_Theme.TEXT_MUTED}")) + + # 1. 安全模式 + s = state['safety'] + t.append(Text(" ", style="")) + t.append(Text("[1]", style=f"bold {_Theme.WARNING}")) + t.append(Text(" 安全模式 ", style=_Theme.TEXT)) + t.append(Text(s['label'], style=f"bold {_Theme.SUCCESS}")) + if s['available']: + t.append(Text(" (无限制/普通/人工)", style="dim")) + t.append(Text("\n", style="")) + + # 2. 通知开关 + n = state['notify'] + t.append(Text(" ", style="")) + t.append(Text("[2]", style=f"bold {_Theme.WARNING}")) + t.append(Text(" 任务完成通知 ", style=_Theme.TEXT)) + t.append(Text(n['label'], style=f"bold {_Theme.SUCCESS}")) + t.append(Text(" (开启/关闭)", style="dim")) + t.append(Text("\n", style="")) + + # 3. 对话历史条数 + h = state['history'] + t.append(Text(" ", style="")) + t.append(Text("[3]", style=f"bold {_Theme.WARNING}")) + t.append(Text(" 对话历史条数 ", style=_Theme.TEXT)) + t.append(Text(h['label'], style=f"bold {_Theme.SUCCESS}")) + t.append(Text(" (20/50/100/无限)", style="dim")) + t.append(Text("\n", style="")) + + # 4. diff 展示 + d = state['diff'] + t.append(Text(" ", style="")) + t.append(Text("[4]", style=f"bold {_Theme.WARNING}")) + t.append(Text(" 文件修改展示 ", style=_Theme.TEXT)) + if d['available']: + t.append(Text(d['label'], style=f"bold {_Theme.SUCCESS}")) + t.append(Text(" (关闭/终端内/弹窗)", style="dim")) + else: + t.append(Text("(不可用)", style="dim")) + t.append(Text("\n", style="")) + + # 5. 预设背景 + bg = state['background'] + t.append(Text(" ", style="")) + t.append(Text("[5]", style=f"bold {_Theme.WARNING}")) + t.append(Text(" 预设背景 ", style=_Theme.TEXT)) + t.append(Text(bg['label'], style=f"bold {_Theme.SUCCESS}")) + t.append(Text(" (墨黑/深蓝灰/深紫/深蓝)", style="dim")) + t.append(Text("\n", style="")) + + # 6. 自定义背景 + bc = state['bg_custom'] + t.append(Text(" ", style="")) + t.append(Text("[6]", style=f"bold {_Theme.WARNING}")) + t.append(Text(" 自定义背景 ", style=_Theme.TEXT)) + if bc['active']: + t.append(Text(bc['label'], style=f"bold {_Theme.SUCCESS}")) + t.append(Text(f" ({bc['color']})", style="dim")) + else: + t.append(Text("无", style="dim")) + t.append(Text(" 用 /bg 命令设置", style="dim")) + t.append(Text("\n", style="")) + + t.append(Text("\n【引擎配置】\n", style=f"bold {_Theme.TEXT_MUTED}")) + t.append(Text(f" 当前引擎: ", style="dim")) + t.append(Text(f"{engine_name}\n", style=f"bold {_Theme.ACCENT}")) + t.append(Text(f" 地址: ", style="dim")) + t.append(Text(f"{base_url or '(未设置)'}\n", style=_Theme.TEXT)) + t.append(Text(f" 模型: ", style="dim")) + t.append(Text(f"{model or '(未设置)'}\n", style=_Theme.TEXT)) + t.append(Text(f" 密钥: ", style="dim")) + t.append(Text(f"{self._mask_key(api_key)}\n", style=_Theme.TEXT)) + + # 已存档案 + profiles = get_profiles(engine_name) + if profiles: + t.append(Text("\n 配置档案 (用 /profile use <名称|序号> 切换):\n", style=f"bold {_Theme.TEXT_MUTED}")) + for i, p in enumerate(profiles, 1): + mark = " ←" if (p.get('base_url') == base_url and p.get('model') == model) else "" + t.append(Text(f" {i}. {p.get('name', '未命名')}{mark}", style=_Theme.TEXT)) + t.append(Text(f" ({p.get('model', '')})\n", style="dim")) + + # 操作提示 + t.append(Text("\n" + "─" * 56 + "\n", style="dim")) + t.append(Text(" 数字键 ", style="dim")) + t.append(Text("1-6", style=f"bold {_Theme.WARNING}")) + t.append(Text(" 切换选项 | ", style="dim")) + t.append(Text("Esc", style=f"bold {_Theme.WARNING}")) + t.append(Text(" 关闭\n", style="dim")) + return t + + def action_toggle_settings(self): + """Ctrl+P: 切换辅助中心显示/隐藏""" + overlay = self.query_one("#settings-overlay") + if overlay.has_class("visible"): + self._hide_settings() + else: + self._show_settings() + + def _show_settings(self): + """显示辅助中心:先显示背景遮罩,再滑入卡片""" + backdrop = self.query_one("#settings-backdrop") + overlay = self.query_one("#settings-overlay") + overlay.update(self._build_settings_text()) + backdrop.styles.display = "block" + overlay.styles.display = "block" + backdrop.remove_class("hiding") + overlay.remove_class("hiding") + + def _show(): + backdrop.add_class("visible") + overlay.add_class("visible") + + self.call_after_refresh(_show) + + def _hide_settings(self): + """隐藏辅助中心:先滑出卡片,再淡出遮罩""" + backdrop = self.query_one("#settings-backdrop") + overlay = self.query_one("#settings-overlay") + overlay.remove_class("visible") + overlay.add_class("hiding") + backdrop.remove_class("visible") + backdrop.add_class("hiding") + + def _finish_hide(): + overlay.remove_class("hiding") + overlay.styles.display = "none" + backdrop.remove_class("hiding") + backdrop.styles.display = "none" + + self.set_timer(0.26, _finish_hide) + + # ── 帮助浮层 ── + + def action_toggle_help(self): + """?: 切换帮助浮层显示/隐藏""" + overlay = self.query_one("#help-overlay") + if overlay.has_class("visible"): + self._hide_help_panel() + else: + self._show_help_panel() + + def _show_help_panel(self): + """显示帮助浮层:先显示背景遮罩,再滑入卡片""" + backdrop = self.query_one("#help-backdrop") + overlay = self.query_one("#help-overlay") + overlay.update(self._build_help_text()) + backdrop.styles.display = "block" + overlay.styles.display = "block" + backdrop.remove_class("hiding") + overlay.remove_class("hiding") + + def _show(): + backdrop.add_class("visible") + overlay.add_class("visible") + + self.call_after_refresh(_show) + + def _hide_help_panel(self): + """隐藏帮助浮层:先滑出卡片,再淡出遮罩""" + backdrop = self.query_one("#help-backdrop") + overlay = self.query_one("#help-overlay") + overlay.remove_class("visible") + overlay.add_class("hiding") + backdrop.remove_class("visible") + backdrop.add_class("hiding") + + def _finish_hide(): + overlay.remove_class("hiding") + overlay.styles.display = "none" + backdrop.remove_class("hiding") + backdrop.styles.display = "none" + + self.set_timer(0.26, _finish_hide) + + def _build_help_text(self): + """构建帮助浮层内容 — 全中文,含命令、快捷键、操作说明""" + from rich.text import Text + t = Text() + t.append(Text("📖 帮助\n", style=f"bold {_Theme.ACCENT}", justify="center")) + t.append(Text("─" * 56 + "\n", style="dim")) + + # ── 快捷键 ── + t.append(Text("【快捷键】\n", style=f"bold {_Theme.TEXT_MUTED}")) + t.append(Text(" Ctrl+C 退出 Ctrl+L 清屏\n", style=_Theme.TEXT)) + t.append(Text(" Ctrl+N 新对话 Ctrl+P 辅助中心\n", style=_Theme.TEXT)) + t.append(Text(" 回车 发送消息 Ctrl+J 换行\n", style=_Theme.TEXT)) + t.append(Text(" F1 侧栏 F2 切换引擎\n", style=_Theme.TEXT)) + t.append(Text(" F9 辅助中心 Esc 取消/关闭浮层\n", style=_Theme.TEXT)) + t.append(Text(" ? 帮助 Tab 缩进\n", style=_Theme.TEXT)) + + # ── 命令 ── + t.append(Text("\n【命令】(在输入框输入 / 触发)\n", style=f"bold {_Theme.TEXT_MUTED}")) + t.append(Text(" /help 帮助信息\n", style=_Theme.TEXT)) + t.append(Text(" /quit 退出\n", style=_Theme.TEXT)) + t.append(Text(" /cli 切换命令行模式\n", style=_Theme.TEXT)) + t.append(Text(" /model <引擎> 切换 AI 引擎\n", style=_Theme.TEXT)) + t.append(Text(" /engines 列出引擎\n", style=_Theme.TEXT)) + t.append(Text(" /tools 列出工具\n", style=_Theme.TEXT)) + t.append(Text(" /status 系统状态\n", style=_Theme.TEXT)) + t.append(Text(" /clear 清屏\n", style=_Theme.TEXT)) + + # ── 安全与展示 ── + t.append(Text("\n【安全与展示】\n", style=f"bold {_Theme.TEXT_MUTED}")) + t.append(Text(" /safe [off|on|manual] 切换安全模式\n", style=_Theme.TEXT)) + t.append(Text(" /diff [popup|inline|off] 切换文件修改展示模式\n", style=_Theme.TEXT)) + t.append(Text(" /bg <图片路径|off|list> 自定义背景(提取主色调)\n", style=_Theme.TEXT)) + + # ── 对话历史 ── + t.append(Text("\n【对话历史】\n", style=f"bold {_Theme.TEXT_MUTED}")) + t.append(Text(" /chat save <名称> 保存当前对话\n", style=_Theme.TEXT)) + t.append(Text(" /chat list 列出已保存对话\n", style=_Theme.TEXT)) + t.append(Text(" /chat open <名称|序号> 加载历史对话\n", style=_Theme.TEXT)) + + # ── 引擎配置 ── + t.append(Text("\n【引擎配置】\n", style=f"bold {_Theme.TEXT_MUTED}")) + t.append(Text(" /settings 打开辅助中心\n", style=_Theme.TEXT)) + t.append(Text(" /profile save <名称> 保存当前 地址/密钥/模型 为档案\n", style=_Theme.TEXT)) + t.append(Text(" /profile use <名称|序号> 切换到指定配置档案\n", style=_Theme.TEXT)) + t.append(Text(" /profile del <名称> 删除配置档案\n", style=_Theme.TEXT)) + + # ── 操作提示 ── + t.append(Text("\n" + "─" * 56 + "\n", style="dim")) + t.append(Text(" 按 ", style="dim")) + t.append(Text("Esc", style=f"bold {_Theme.WARNING}")) + t.append(Text(" 关闭\n", style="dim")) + return t + + def _cycle_setting(self, idx): + """数字键切换辅助中心选项""" + from .safety import get_safety, MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL + from .notification import get_notification_manager + from .config import set_system_config + + state = self._get_setting_state() + + if idx == 1: + # 安全模式循环 + safety = get_safety() + new_idx = (state['safety']['idx'] + 1) % 3 + mode_map = [MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL] + safety.set_mode(mode_map[new_idx]) + label = state['safety']['options'][new_idx][1] + self._update_status(f"安全模式: {label}") + elif idx == 2: + # 通知开关循环 + nm = get_notification_manager() + nm.enabled = not nm.enabled + self._update_status(f"通知: {'开启' if nm.enabled else '关闭'}") + elif idx == 3: + # 对话历史条数循环 + new_idx = (state['history']['idx'] + 1) % 4 + new_val = state['history']['values'][new_idx] + set_system_config('max_history', new_val) + # 同步到 cli 实例和所有引擎 + self.cli.max_history = new_val + if hasattr(self.cli, '_set_shared_conversation_history'): + self.cli._set_shared_conversation_history() + label = "无限制" if new_val >= 999999 else str(new_val) + self._update_status(f"历史条数: {label}") + elif idx == 4: + # diff 模式循环 + if not state['diff']['available']: + self._error("代码编辑器未加载,无法切换文件对比模式") + return + new_idx = (state['diff']['idx'] + 1) % 3 + editor = None + if hasattr(self.cli, 'liugin_manager'): + for tool in self.cli.liugin_manager.tools: + if tool.get('name') == 'code_editor': + editor = tool.get('instance') + break + if editor is None: + return + if new_idx == 0: # off + editor.diff_popup_enabled = False + elif new_idx == 1: # inline + editor.diff_popup_enabled = True + editor.diff_popup_mode = False + else: # popup + editor.diff_popup_enabled = True + editor.diff_popup_mode = True + label = state['diff']['options'][new_idx][1] + self._update_status(f"文件对比: {label}") + elif idx == 5: + # 预设背景循环 + self._bg_idx = (state['background']['idx'] + 1) % len(self._BG_CLASSES) + # 切换预设时清除自定义色(避免自定义覆盖预设) + self._bg_custom_color = "" + self._apply_background() + label = self._BG_LABELS[self._bg_idx] + self._update_status(f"背景: {label}") + try: + set_system_config('tui_background', self._bg_idx) + except Exception: + pass + elif idx == 6: + # 自定义背景切换:有自定义则关闭,无则提示用 /bg 命令 + if self._bg_custom_color: + self._bg_image_path = "" + self._bg_custom_color = "" + try: + set_system_config('tui_bg_image', '') + except Exception: + pass + self._apply_background() + self._update_status("已清除自定义背景") + else: + self._system("用 /bg <图片路径> 设置自定义背景,/bg off 清除") + else: + return + + # 刷新辅助中心卡片内容 + overlay = self.query_one("#settings-overlay") + if overlay.has_class("visible"): + overlay.update(self._build_settings_text()) + + # ── 背景效果 ── + def _apply_background(self): + """根据 _bg_idx / 自定义色 给 #chat-scroll 切换背景 + 优先级:自定义图片色 > 预设色调 + """ + try: + scroll = self.query_one("#chat-scroll") + # 移除所有预设背景 class + for cls in self._BG_CLASSES: + if cls: + scroll.remove_class(cls) + # 移除自定义背景的内联样式 + try: + scroll.styles.background = _Theme.BG + except Exception: + pass + # 自定义图片色优先 + if self._bg_custom_color: + try: + scroll.styles.background = self._bg_custom_color + except Exception: + pass + else: + cur = self._BG_CLASSES[self._bg_idx] + if cur: + scroll.add_class(cur) + except Exception: + pass + + def _extract_color_from_image(self): + """从 _bg_image_path 提取主色调,暗化后作为背景色 + 暗化系数 0.18,避免图片过亮影响文字阅读 + """ + if not self._bg_image_path: + self._bg_custom_color = "" + return + try: + from .constants import PIL_AVAILABLE + if not PIL_AVAILABLE: + self._bg_custom_color = "" + self._error("背景图片需要 Pillow 库") + return + from PIL import Image + import os + if not os.path.isfile(self._bg_image_path): + self._bg_custom_color = "" + self._error(f"背景图片不存在: {self._bg_image_path}") + return + with Image.open(self._bg_image_path) as img: + img = img.convert("RGB") + # 缩小到 40x40 加速计算 + img.thumbnail((40, 40)) + pixels = list(img.getdata()) + if not pixels: + self._bg_custom_color = "" + return + # 计算平均色 + r = sum(p[0] for p in pixels) // len(pixels) + g = sum(p[1] for p in pixels) // len(pixels) + b = sum(p[2] for p in pixels) // len(pixels) + # 暗化到 18% 亮度,保证文字可读 + r = int(r * 0.18) + g = int(g * 0.18) + b = int(b * 0.18) + self._bg_custom_color = f"#{r:02x}{g:02x}{b:02x}" + except Exception as e: + self._bg_custom_color = "" + self._error(f"提取背景色失败: {e}") + + def _handle_background(self, args): + """/bg 命令:设置自定义图片背景或清除 + 用法: /bg <图片路径> 设置背景(提取主色调) + /bg off 清除自定义,回到预设 + /bg list 显示当前背景状态 + """ + from .config import set_system_config + arg = args.strip() + if not arg or arg.lower() == "list": + # 显示状态 + if self._bg_custom_color: + name = os.path.basename(self._bg_image_path) if self._bg_image_path else "" + self._system(f"当前背景: 自定义 ({name}) 颜色: {self._bg_custom_color}") + else: + self._system(f"当前背景: 预设 ({self._BG_LABELS[self._bg_idx]})") + return + if arg.lower() == "off": + self._bg_image_path = "" + self._bg_custom_color = "" + try: + set_system_config('tui_bg_image', '') + except Exception: + pass + self._apply_background() + self._update_status("背景: 已清除自定义") + # 刷新辅助中心 + try: + overlay = self.query_one("#settings-overlay") + if overlay.has_class("visible"): + overlay.update(self._build_settings_text()) + except Exception: + pass + return + # 设置图片路径 + path = arg.strip().strip('"').strip("'") + # 展开 ~ 和环境变量 + path = os.path.expanduser(os.path.expandvars(path)) + if not os.path.isfile(path): + self._error(f"图片文件不存在: {path}") + return + self._bg_image_path = path + self._extract_color_from_image() + if self._bg_custom_color: + self._apply_background() + try: + set_system_config('tui_bg_image', path) + except Exception: + pass + name = os.path.basename(path) + self._update_status(f"背景: {name}") + self._system(f"已设置背景: {name} 颜色: {self._bg_custom_color}") + # 刷新辅助中心 + try: + overlay = self.query_one("#settings-overlay") + if overlay.has_class("visible"): + overlay.update(self._build_settings_text()) + except Exception: + pass + + def _handle_profile(self, args): + """引擎配置档案管理: save/use/del/list""" + from .config import get_profiles, save_profile, delete_profile + + parts = args.strip().split(maxsplit=1) + sub = parts[0].lower() if parts else "" + arg = parts[1].strip() if len(parts) > 1 else "" + + engine = self.bridge.current_engine() + engine_name = getattr(engine, 'name', 'unknown') + + if sub in ('', 'list'): + # 显示辅助中心浮层(等同于 /settings) + self._show_settings() + return + + if sub == 'save': + if not arg: + self._error("请提供档案名称。用法: /profile save <名称>") + return + profile = { + "name": arg, + "base_url": getattr(engine, 'base_url', ''), + "api_key": getattr(engine, 'api_key', ''), + "model": getattr(engine, 'model', ''), + } + save_profile(engine_name, profile) + self._system(f" 已保存配置档案: {arg}(引擎: {engine_name})") + # 如果辅助中心浮层可见则刷新 + overlay = self.query_one("#settings-overlay") + if overlay.has_class("visible"): + overlay.update(self._build_settings_text()) + + elif sub == 'use': + if not arg: + self._error("请提供档案名称或序号。用法: /profile use <名称|序号>") + return + profiles = get_profiles(engine_name) + if not profiles: + self._error(f"引擎 {engine_name} 没有已保存的配置档案") + return + target = None + if arg.isdigit(): + idx = int(arg) - 1 + if 0 <= idx < len(profiles): + target = profiles[idx] + else: + self._error(f"序号 {arg} 超出范围(共 {len(profiles)} 个档案)") + return + else: + for p in profiles: + if p.get('name') == arg: + target = p + break + if not target: + self._error(f"未找到档案: {arg}") + return + + # 应用配置到引擎实例 + if hasattr(engine, 'apply_config'): + engine.apply_config( + base_url=target.get('base_url'), + api_key=target.get('api_key'), + model=target.get('model'), + ) + self._system(f" 已切换到配置档案: {target.get('name')}({engine_name})") + self._update_status(f"引擎: {engine_name} | {target.get('model', '')}") + self._update_sidebar() + # 刷新辅助中心浮层 + overlay = self.query_one("#settings-overlay") + if overlay.has_class("visible"): + overlay.update(self._build_settings_text()) + else: + self._error(f"引擎 {engine_name} 不支持配置切换") + + elif sub in ('del', 'delete'): + if not arg: + self._error("请提供档案名称。用法: /profile del <名称>") + return + profiles = get_profiles(engine_name) + exists = any(p.get('name') == arg for p in profiles) + if not exists: + self._error(f"未找到档案: {arg}") + return + delete_profile(engine_name, arg) + self._system(f" 已删除配置档案: {arg}") + overlay = self.query_one("#settings-overlay") + if overlay.has_class("visible"): + overlay.update(self._build_settings_text()) + + else: + self._error("用法: /profile save <名称> | use <名称|序号> | del <名称> | list") def _handle_manual(self, args): - """处理 /manual 命令""" if 'manual' not in self.cli.engines: - self._error("manual 引擎未加载") + self._error("手动引擎未加载") return if not args: - # 切换到 manual 引擎 if self.bridge.switch_engine('manual'): - self._system("已切换到 manual 引擎") + self._system("已切换到手动引擎") self._update_sidebar() else: self._error("切换失败") else: - # 执行 manual 引擎子命令 result = self.cli.engines['manual'].handle_command(args) if result: self._system(result) @@ -794,6 +2189,7 @@ if TEXTUAL_AVAILABLE: def _switch_cli(self): self._system("切换到命令行模式...") self.cli.tui_output_callback = None + self.cli._cli_requested = True self.exit() def _switch_model(self, name): @@ -811,7 +2207,7 @@ if TEXTUAL_AVAILABLE: current = self.bridge.current_engine() lines = [" 可用引擎:"] for name in self.bridge.engines(): - marker = "▸" if name == current else " " + marker = ">" if name == current else " " lines.append(f" {marker} {name}") self._system('\n'.join(lines)) @@ -821,7 +2217,7 @@ if TEXTUAL_AVAILABLE: for t in tools: name = t.get('name', '?') desc = t.get('description', '')[:40] - lines.append(f" • {name}: {desc}") + lines.append(f" - {name}: {desc}") self._system('\n'.join(lines)) def _show_status(self): @@ -832,87 +2228,309 @@ if TEXTUAL_AVAILABLE: f" 工具数: {len(self.bridge.tools())}", f" 对话数: {len(self.bridge.history())}", ] - self._system('\n'.join(status_lines)) - # ── AI 生成 ── + # ── AI 生成 (elia @work + call_from_thread 模式) ── - async def _generate(self, user_input): - try: - self.call_after_refresh(self._remove_thinking) - self.call_after_refresh(self._show_typing_indicator) + def _generate(self, user_input): + """流式生成 — 基于 elia 的 stream_agent_response 模式 - def tui_output(msg): - self.call_after_refresh(self._remove_typing_indicator) - self.call_after_refresh(self._write_raw, msg) + 在线程中运行同步的 cli_core, + 通过 call_from_thread() 线程安全地更新 UI。 + """ + _stream_state = { + "widget": None, + "thinking_widget": None, + "text": "", + "thinking_text": "", + "thinking_removed": False, + "first_chunk": True, + } - original = self.cli.tui_output_callback - self.cli.tui_output_callback = tui_output + def tui_output(msg): + # 引擎切换等操作可能在主线程调用,需做线程判断 + import threading as _t + if self._thread_id == _t.get_ident(): + self._write_raw(msg) + else: + self.call_from_thread(self._write_raw, msg) - try: - loop = asyncio.get_running_loop() - await loop.run_in_executor( - None, self.cli.process_conversation, user_input + original_cb = self.cli.tui_output_callback + self.cli.tui_output_callback = tui_output + + def tui_stream(chunk, kind="content"): + """流式回调 — kind: 'content' | 'thinking'""" + if self._cancel_requested: + return + if kind == "thinking": + _stream_state["thinking_text"] += chunk + if _stream_state["thinking_widget"] is None: + tw = ThinkingBox(content="") + _stream_state["thinking_widget"] = tw + self.call_from_thread(self._mount_thinking_widget, tw) + self.call_from_thread( + _stream_state["thinking_widget"].append_chunk, chunk ) - finally: - self.cli.tui_output_callback = original + self.call_from_thread(self._smart_scroll_end) + return + # kind == "content" + if _stream_state["first_chunk"]: + _stream_state["first_chunk"] = False + _stream_state["thinking_removed"] = True + widget = Chatbox( + role="assistant", + content=chunk, + classes="response-in-progress", + ) + _stream_state["widget"] = widget + _stream_state["text"] = chunk + self.call_from_thread(self._mount_first_chunk, widget) + return + _stream_state["text"] += chunk + widget = _stream_state["widget"] + if widget: + self.call_from_thread(widget.append_chunk, chunk) + self.call_from_thread(self._smart_scroll_end) + + original_stream_cb = getattr(self.cli, 'tui_stream_callback', None) + self.cli.tui_stream_callback = tui_stream + + # 新增:思考专用回调(独立于内容流) + def tui_thinking(chunk): + tui_stream(chunk, kind="thinking") + + original_thinking_cb = getattr(self.cli, 'tui_thinking_callback', None) + self.cli.tui_thinking_callback = tui_thinking + + # 每轮 AI 生成前: 重新显示思考指示器 (工具调用后 AI 继续) + def tui_before_generate(): + _stream_state["thinking_removed"] = False + _stream_state["text"] = "" + _stream_state["thinking_text"] = "" + _stream_state["widget"] = None + _stream_state["thinking_widget"] = None + _stream_state["first_chunk"] = True + self.call_from_thread(self._show_thinking) + self.call_from_thread(lambda: self._update_status(" 思考中...")) + + original_before_cb = getattr(self.cli, 'tui_before_generate_callback', None) + self.cli.tui_before_generate_callback = tui_before_generate + + # 工具调用审查确认 — 跨线程 Event 实现 + import threading as _threading + self._confirm_event = _threading.Event() + + def tui_confirm(risk_info): + """worker 线程调用:显示风险信息并等待用户确认""" + self._confirm_event.clear() + self._confirm_result = False + self.call_from_thread(self._show_confirm, risk_info) + self._confirm_event.wait(timeout=120) + return self._confirm_result + + original_confirm_cb = getattr(self.cli, 'tui_confirm_callback', None) + self.cli.tui_confirm_callback = tui_confirm + + # 记录当前 worker 的代次,用于 finally 中判断是否已被取消/替换 + my_generation = getattr(self, '_cancel_generation', 0) + + try: + self.cli.process_conversation(user_input) except Exception as e: - self.call_after_refresh(self._remove_typing_indicator) - self.call_after_refresh(self._error, str(e)) + # 被取消时不报错 + if not self._cancel_requested: + self.call_from_thread(self._error, str(e)) finally: - self.call_after_refresh(self._remove_thinking) - self.call_after_refresh(self._remove_typing_indicator) - self.is_generating = False - self.call_after_refresh(lambda: self._update_status("就绪")) - self.call_after_refresh(self._update_sidebar) + # 只有当前 worker 未被取消/替换时,才恢复回调和状态 + current_generation = getattr(self, '_cancel_generation', 0) + if current_generation == my_generation: + self.cli.tui_output_callback = original_cb + self.cli.tui_stream_callback = original_stream_cb + self.cli.tui_thinking_callback = original_thinking_cb + self.cli.tui_before_generate_callback = original_before_cb + self.cli.tui_confirm_callback = original_confirm_cb + # 移除 response-in-progress 样式 + if _stream_state["widget"]: + self.call_from_thread( + _stream_state["widget"].remove_class, "response-in-progress" + ) + # 清理空的思考 widget + if _stream_state["thinking_widget"] is not None and not _stream_state["thinking_text"]: + self.call_from_thread(self._remove_thinking_widget, + _stream_state["thinking_widget"]) + self.call_from_thread(self._remove_thinking) + self.call_from_thread(lambda: self._update_status("就绪")) + self.call_from_thread(lambda: setattr(self, 'is_generating', False)) + self.call_from_thread(self._update_sidebar) + + _RICH_TAG_RE = re.compile(r'\[/?[a-zA-Z][^\[\]]*?\]') def _write_raw(self, msg): - # 检查是否是AI回复(包含Rich标记前缀) - if msg.startswith('[cyan]>[/]') or msg.startswith('[yellow]>[/]'): - self._add(f" {msg}", "msg-ai") - elif '✅' 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") + """输出原始消息 — 去掉 Rich 标记""" + # 使用 rich 自身的标记解析(最准确,保留中文方括号) + try: + from rich.text import Text + clean = Text.from_markup(msg).plain + except Exception: + # 降级:手动正则剥离 + prev = None + clean = msg + while prev != clean: + prev = clean + clean = self._RICH_TAG_RE.sub('', clean) + if 'OK' in clean or ('工具' in clean and ('调用' in clean or '执行' in clean)): + self._add(f" {clean}", "msg-tool-ok") + elif 'ERROR' in clean or '错误' in clean or '安全' in clean or '拒绝' in clean: + self._add(f" {clean}", "msg-tool-err") else: - self._add(f" {msg}", "msg-dim") + self._add(f" {clean}", "msg-dim") # ── 动作 ── def action_clear(self): + if self.is_generating: + return scroll = self.query_one("#chat-scroll") scroll.remove_children() self._render_welcome() def action_new_chat(self): - self.cli.shared_conversation_history.clear() + if self.is_generating: + return + # 清空前自动保存当前对话(如果有内容且记忆系统可用) + history = self.cli.shared_conversation_history + if history and getattr(self.cli, 'memory_manager', None): + try: + # 用第一条用户消息作为标签 + label = "" + for m in history: + if m.get('role') == 'user' and m.get('content'): + label = m['content'][:30] + break + self.cli.memory_manager.auto_save_chat(history, label=label or "未命名对话") + except Exception: + pass + history.clear() self.action_clear() - self._system("已开始新对话") + self._system("已开始新对话(旧对话已自动保存)") def action_toggle_sidebar(self): sidebar = self.query_one("#sidebar") - # 通过 CSS 类控制侧栏显隐 - if sidebar.has_class("hidden"): - sidebar.remove_class("hidden") - self.sidebar_visible = True - else: - sidebar.add_class("hidden") + if sidebar.has_class("visible"): + # 隐藏:先播放滑出动画,结束后再 display:none 释放布局空间 + sidebar.remove_class("visible") + sidebar.add_class("hiding") self.sidebar_visible = False + def _finish_hide(): + sidebar.remove_class("hiding") + sidebar.styles.display = "none" + + self.set_timer(0.26, _finish_hide) + else: + # 显示:先 display:block 占位,下一帧再加 visible 类触发滑入 + sidebar.styles.display = "block" + self._update_sidebar() + + def _show(): + sidebar.add_class("visible") + + self.call_after_refresh(_show) + self.sidebar_visible = True + + def action_switch_engine(self): + """F2: 引擎切换 — 居中浮层丝滑滑入滑出""" + engines = self.bridge.engines() + if len(engines) <= 1: + self._system("只有一个引擎,无法切换") + return + current = self.bridge.current_engine() + try: + idx = engines.index(current) + next_idx = (idx + 1) % len(engines) + next_engine = engines[next_idx] + except ValueError: + next_engine = engines[0] + + # 清理旧 banner + for old in self.query("EngineSwitchBanner"): + old.remove() + + # 先挂载并显示 banner,再执行切换(营造过渡感) + banner = EngineSwitchBanner(current, next_engine) + self.mount(banner) + status = self.query_one("#status-bar") + + def _show(): + banner.add_class("show") + status.add_class("pulse") + self._update_status(f"切换中: {current} → {next_engine}") + + def _do_switch(): + # banner 显示后再真正切换引擎 + if not self.bridge.switch_engine(next_engine): + self._error(f"切换引擎失败: {next_engine}") + banner.remove_class("show") + banner.add_class("hide") + self.set_timer(0.3, lambda: banner.remove()) + status.remove_class("pulse") + self._update_status("就绪") + return + self._update_status(f"已切换: {next_engine}") + + def _hide(): + banner.remove_class("show") + banner.add_class("hide") + + def _cleanup(): + banner.remove() + status.remove_class("pulse") + self._update_status("就绪") + self._update_sidebar() + + self.call_after_refresh(_show) + self.set_timer(0.25, _do_switch) # banner 滑入快完成时执行切换 + self.set_timer(1.4, _hide) # 显示 1.15s 后开始滑出 + self.set_timer(1.75, _cleanup) # 滑出完成后清理 + def action_cancel(self): - if self.is_generating: - self._remove_thinking() - self._remove_typing_indicator() - self._system("已取消") - self.is_generating = False + if not self.is_generating: + return + self._cancel_requested = True + self._system("正在取消...") + # 通知引擎中断(设置 _cancelled 标志,流式循环会检查并退出) + engine = getattr(self.cli, 'current_engine', None) + if engine and hasattr(engine, 'cancel'): + try: + engine.cancel() + except Exception: + pass + # 释放确认等待(如果在工具审查确认期间取消) + if self._confirm_event: + self._confirm_event.set() + # 隐藏确认浮层 + self._hide_confirm() + # 取消 worker(标记取消,线程会在下次检查点退出) + if self._current_worker: + try: + self._current_worker.cancel() + except Exception: + pass + self._current_worker = None + # 生成唯一 ID 标记本次取消,防止旧 worker finally 覆盖新 worker 状态 + self._cancel_generation = getattr(self, '_cancel_generation', 0) + 1 + # UI 收尾 + self._finish_cancel() + + def _finish_cancel(self): + """主线程中完成取消的 UI 收尾""" + self._remove_thinking() + self._update_status("已取消") + self.is_generating = False + self._update_sidebar() else: - # Textual 不可用时的占位类 class XiaoliTUI: """Textual 未安装时的占位类""" def __init__(self, *args, **kwargs):