import os import sys import json import re import time import shutil import threading import random from typing import Optional from colorama import Fore, Style from .constants import TEXTUAL_AVAILABLE, LOVE_FILE_PATH, DEFAULT_MAX_HISTORY, VERSION from .config import get_system_config, set_system_config, logger from .cli_base import BaseAICLI from .safety import get_safety, MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL from .cli_clawli import ClawliMixin from .cli_tools import ToolMixin from .cli_code_exec import CodeExecMixin from .cli_display import DisplayMixin from .cli_history import HistoryMixin from .plugin_market import PluginMarketMixin from .notification import notify_task_complete, get_notification_manager from .process_protection import get_protection_status # ── Plan 模式 system prompt 追加段(只读规划约束)── PLAN_MODE_SYSTEM_APPEND = """ 【PLAN 模式 — 只读规划】 你当前处于 PLAN 模式。此模式下你只能进行调查、阅读、分析,不得执行任何修改。 - 你可以使用只读工具调研代码库:code_search(搜索/结构/符号/统计)、file_manager 的 list/read/info/search、code_editor 的 read_range/find/diff、git_tools 的 status/log/diff/show/blame、tool_search(查询工具用法)、ai_search(联网搜索)。 - 你**不能**执行写操作(编辑/创建/删除文件、运行 shell 命令、git commit/push/checkout、委派会修改的子代理等)——这些会被系统拦截并返回提示。 - 调研完成后,输出一份清晰的、带编号步骤的「实施计划」,让用户审批。计划应包含:每步要做什么、涉及哪些文件/函数、预期结果、潜在风险。 - 计划以如下标记开头:## 实施计划 - 完成计划后停止,等待用户输入 /build 批准执行,或继续追问以完善计划。不要自行开始修改。""" class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, HistoryMixin, PluginMarketMixin): """AI CLI主程序 - 组合所有 Mixin""" # ── 深度思考处理 ── def process_thinking_response(self, response): """处理深度思考AI返回的内容, 分离思考和回复内容""" try: response_data = self._loads_json(response) if isinstance(response_data, dict) and response_data.get('action') == 'use_tool': return response if isinstance(response_data, dict): if 'answer' in response_data: extracted_content = response_data['answer'] elif 'response' in response_data: extracted_content = response_data['response'] elif 'content' in response_data: extracted_content = response_data['content'] elif 'text' in response_data: extracted_content = response_data['text'] else: return json.dumps(response_data, ensure_ascii=False, indent=2) return self._process_content_with_thinking(extracted_content) except json.JSONDecodeError: return self._process_content_with_thinking(response) def _process_content_with_thinking(self, content): """处理包含思考标记的内容""" thinking_start_marker = getattr(self.current_engine, 'thinking_start_marker', None) if self.current_engine else None thinking_end_marker = getattr(self.current_engine, 'thinking_end_marker', None) if self.current_engine else None if '' in content: parts = content.split('') if len(parts) >= 2: thinking_content = parts[0].strip() reply_content = parts[1].strip() if thinking_content: print(f"{Fore.LIGHTBLACK_EX}{Style.DIM}[思考: {thinking_content}]{Style.RESET_ALL}") return reply_content if reply_content else "" if thinking_start_marker and thinking_end_marker and thinking_start_marker in content and thinking_end_marker in content: parts = content.split(thinking_start_marker) if len(parts) > 1: remaining = parts[1] if thinking_end_marker in remaining: thinking_part, reply_part = remaining.split(thinking_end_marker, 1) thinking_content = thinking_part.strip() reply_content = reply_part.strip() if thinking_content: print(f"{Fore.LIGHTBLACK_EX}{Style.DIM}[思考: {thinking_content}]{Style.RESET_ALL}") return reply_content if reply_content else "" return content # ── 系统提示构建 ── def _build_system_prompt(self, liugin_prompts=None): """构建系统提示词 - 增强版,支持编程任务""" base_prompt = """你叫小狸,是一个强大的智能编程助手。你具备以下核心能力: 1. **精准代码编辑** - 使用 code_editor 工具进行搜索替换、多文件批量编辑(修改后自动检查语法) 2. **代码搜索与理解** - 使用 code_search 工具跨文件搜索、提取符号、分析依赖 3. **Git 版本控制** - 使用 git_tools 工具管理代码版本 4. **Shell 命令执行** - 使用 cmd_executor 执行系统命令 5. **文件管理** - 使用 file_manager 管理文件和目录 6. **浏览器自动化** - 使用 browser_auto 工具操控浏览器 7. **子 Agent 协作** - 使用 sub_agent 创建子 Agent 并行处理任务 8. **工程化自动化** - 使用 auto_engineer 进行 lint/test/build 9. **网络工具** - 使用 network_tools 进行 HTTP 请求和网络诊断 10. **AI 联网搜索** - 使用 ai_search 搜索实时信息 你在说话时可以适当加上'nyan'来表现可爱性格,但不要过度使用。""" has_tool_search = any(t.get('name') == 'tool_search' for t in self.liugin_manager.tools) tool_search_rule = "" if has_tool_search: tool_search_rule = """【⚠️ 强制工具查询规则 — 必须遵守】 在调用任何工具之前,你必须先使用 tool_search 查询该工具的详细用法、参数格式和示例。 这是硬性要求,没有例外。即使你认为自己知道工具的用法,也必须先查询确认。 【⚠️ 工具调用格式 — 必须使用 JSON】 所有工具都必须通过 JSON 指令调用,格式如下: {{"action": "use_tool", "tool": "工具名", "args": "操作 参数"}} 禁止直接写 "工具名 操作 参数",必须包在 JSON 里。 例如: ✅ 正确: {{"action": "use_tool", "tool": "code_editor", "args": "find . keyword *.py"}} ❌ 错误: code_editor find . keyword *.py ❌ 错误: code_editor("find . keyword *.py") 执行流程: 第一步:用 tool_search 查询工具用法 {{"action": "use_tool", "tool": "tool_search", "args": "你要用的工具名"}} 第二步:阅读 tool_search 返回的详细说明(包括参数格式、操作列表、示例) 第三步:根据返回的说明,用 JSON 格式正确调用工具 为什么要这样做: - 工具的参数格式可能已更新,你的记忆可能过时 - 先查询可以避免参数格式错误导致的调用失败 - 确保你使用的是最优的操作和参数组合 - 减少因格式不对而反复重试的浪费 示例流程(修改代码): 用户: "帮我修复 src/app.py 第 10 行的 bug" 你的思考过程: 1. 需要先查看代码 → 先查 code_editor 怎么读取文件 2. {{"action": "use_tool", "tool": "tool_search", "args": "code_editor"}} → 发现 read_range 操作 3. {{"action": "use_tool", "tool": "code_editor", "args": "read_range src/app.py 5 15"}} → 拿到代码 4. 需要修改代码 → 已经在第一步查过 code_editor,知道 edit 格式 5. {{"action": "use_tool", "tool": "code_editor", "args": "edit src/app.py 旧代码 <<<>>> 新代码"}} 示例流程(Git 操作): 用户: "帮我提交代码" 你的思考过程: 1. 需要 git 操作 → 先查 git_tools 怎么用 2. {{"action": "use_tool", "tool": "tool_search", "args": "git_tools"}} → 发现 smart-commit 操作 3. {{"action": "use_tool", "tool": "git_tools", "args": "smart-commit"}} → 自动提交 """ # 参数格式统一示例:无论用哪种工具,args 都是同一个字符串外壳,给 AI 一个速查锚点 unified_arg_example = """【参数格式统一示例】 所有工具共用同一套 JSON 外壳,`args` 永远是【字符串】(不是 JSON 对象): {"action":"use_tool","tool":"<工具名>","args":"<操作> <参数>"} 高频工具的 args 写法(照抄即可): - code_editor : "edit 文件 旧<<<>>>新" | "read_range 文件 起 止" | "create 文件 内容" - code_search : "find . 关键词 *.py" | "structure ." - git_tools : "status" | "commit 说明文字" - cmd_executor: "dir" | "python main.py" 说明:多行内容(如 edit 的代码片段)直接换行写在 args 里,无需转义;一次可返回多个 JSON 指令。 调用方式可选:纯 JSON(默认)或 function calling(引擎支持时),两种都会被正确处理。""" if liugin_prompts: prompt = f"""{base_prompt} {liugin_prompts} {tool_search_rule} {unified_arg_example} 【工具调用格式】 在回复中包含 JSON 指令来执行操作: 1. 纯 JSON: {{"action": "use_tool", "tool": "code_editor", "args": "edit file.py old <<<>>> new"}} 2. 文本+JSON: 好的,我来修改。{{"action": "use_tool", "tool": "code_editor", "args": "edit file.py old <<<>>> new"}} 【编程工作流 — 每一步都必须先查询工具】 1. 理解需求 → 先 tool_search "code_editor",再用 structure 查看项目结构 2. 定位代码 → 已查询过,直接用 find 或 regex 搜索 3. 查看上下文 → 用 read_range 查看相关代码 4. 精准修改 → 用 edit 替换代码(old <<<>>> new 分隔) 5. 语法自动检查 → 编辑操作后自动执行,通过显示 ✓,失败显示详细错误 6. 验证变更 → 先 tool_search "git_tools",再用 diff 查看变更 7. 提交代码 → 已查询过,用 commit 提交 【语法检查 — 自动 + 主动】 - 编辑操作(edit/multi/insert/delete_lines/create/write/append)完成后自动检查语法 - 通过: 显示 " 语法检查通过 ✓" - 失败: 显示具体错误(行号、列号、错误描述) - 主动检查: syntax_check <文件路径> 或 check <文件路径> - 支持语言: Python, JavaScript, TypeScript, JSON, YAML, TOML, HTML, XML, CSS, Shell, SQL - 如果语法检查失败,根据错误信息修复代码后再次编辑 【Diff 弹窗 — 自动弹出修改对比】 - 编辑操作完成后自动显示修改前后的完整内容和行号对比 - 新增行用绿色标记,删除行用红色标记 - 支持两种模式: 弹窗模式(新终端窗口)/ 主终端内显示(SSH 兼容) - 开关: diff_popup on / diff_popup off / diff_popup popup / diff_popup inline - 默认开启,无需手动调用 【code_editor 编辑格式】 edit 操作使用 <<<>>> 分隔旧代码和新代码: {{"action": "use_tool", "tool": "code_editor", "args": "edit path/to/file.py def old_function(): pass <<<>>> def new_function(): return True"}} 【批量编辑】 multi 操作支持一次修改多处: {{"action": "use_tool", "tool": "code_editor", "args": "multi path/to/file.py [{{\\"old\\": \\"old1\\", \\"new\\": \\"new1\\"}}, {{\\"old\\": \\"old2\\", \\"new\\": \\"new2\\"}}]"}} 【搜索代码】 - 关键词搜索: {{"action": "use_tool", "tool": "code_search", "args": "find . keyword *.py"}} - 正则搜索: {{"action": "use_tool", "tool": "code_search", "args": "regex . def\\\\s+\\\\w+ *.py"}} - 查看结构: {{"action": "use_tool", "tool": "code_search", "args": "structure ."}} - 查找调用: {{"action": "use_tool", "tool": "code_search", "args": "callers . function_name"}} 【Git 操作】 - 查看状态: {{"action": "use_tool", "tool": "git_tools", "args": "status"}} - 查看变更: {{"action": "use_tool", "tool": "git_tools", "args": "diff"}} - 提交代码: {{"action": "use_tool", "tool": "git_tools", "args": "commit 描述信息"}} - 查看历史: {{"action": "use_tool", "tool": "git_tools", "args": "log --oneline 10"}} 【重要提醒】 - 调用任何不熟悉的工具前,必须先 tool_search 查询 - code_editor.edit 的 old_text 必须与文件中的内容完全匹配(包括缩进) - 如果 edit 失败,先用 read_range 查看实际内容再重试 - args 必须是字符串 - 可以一次返回多个 JSON 指令 - 使用 continue 补充回答: {{"action": "continue", "content": "补充内容"}}""" else: prompt = f"""{base_prompt} {tool_search_rule} {unified_arg_example} 【工具调用格式】 {{"action": "use_tool", "tool": "工具名", "args": "参数"}} 【⚠️ 核心规则:调用任何工具前必须先查询】 1. 先调用 tool_search 查询工具用法 2. 阅读返回的参数说明和示例 3. 再根据说明正确调用工具 【核心工具】 - tool_search: 查询任何工具的用法(必须先调用这个) - code_editor: edit/search/diff/insert/create/write/read_range - code_search: find/regex/symbols/imports/structure/stats - git_tools: status/diff/log/add/commit/branch - cmd_executor: 执行 shell 命令 - file_manager: 文件管理 【code_editor.edit 格式】 {{"action": "use_tool", "tool": "code_editor", "args": "edit 文件路径 旧代码内容 <<<>>> 新代码内容"}} 【continue 格式】 {{"action": "continue", "content": "要补充的内容"}}""" # ── 注入持久化记忆上下文 ── if hasattr(self, 'memory_manager') and self.memory_manager: try: memory_ctx = self.memory_manager.build_memory_context() if memory_ctx: prompt += memory_ctx except Exception: pass # ── Plan 模式:追加只读规划约束 ── if getattr(self, 'plan_mode', False): prompt += PLAN_MODE_SYSTEM_APPEND # ── LSP 诊断自动注入:让模型持续看到真实语言诊断 ── liugin_manager = getattr(self, 'liugin_manager', None) if liugin_manager and hasattr(liugin_manager, 'get_lsp_context'): try: lsp_ctx = liugin_manager.get_lsp_context() if lsp_ctx: prompt += "\n\n# 当前代码诊断(来自 LSP 语言服务器)\n" + lsp_ctx except Exception: pass return prompt # ── JSON 解析 ── def _clean_json_response(self, response): """清理AI响应中的Markdown代码块标记""" if response.startswith("```json"): response = response[7:] elif response.startswith("```"): response = response[3:] if response.endswith("```"): response = response[:-3] return response.strip() def get_liugin_usage_prompts(self): """获取可用工具提示词""" if not self.liugin_manager.tools: return "当前没有可用的工具." prompts = [] prompts.append("可用的工具列表:\n") plugins = [] skills = [] for tool in self.liugin_manager.tools: protocol = tool.get('protocol', 'liugin') if protocol == 'skill': skills.append(tool) else: plugins.append(tool) if plugins: # 工具名清单(纯 JSON 模式:不发送 FC schema,模型按 action JSON 调用)。 # 只列名字 + 由 tool_search 提供用法,避免重复曝光、省 token。 names = ", ".join(t.get('name', '') for t in plugins) prompts.append( "## 内置工具(纯 JSON 模式:不发送 function calling 的 tools schema," "模型按下方 JSON 格式直接调用,调用前请先用 tool_search 查询其详细用法与示例)\n" f"工具名: {names}\n" '调用格式: {"action": "use_tool", "tool": "工具名", "args": "参数"}' ) if skills: prompts.append("\n## 扩展技能(已加载详细指令):") for tool in skills: tool_name = tool.get('name', '') tool_desc = tool.get('description', '') prompts.append(f"\n### {tool_name}") prompts.append(f"描述: {tool_desc}") instance = tool.get('instance') if instance: instructions = getattr(instance, 'instructions', '') if instructions: if len(instructions) > 2000: instructions = instructions[:2000] + "\n...(内容已截断)" prompts.append(f"\n{instructions}") if self.is_clawli_mode: prompts.append("\n## Clawli 远程模式专属工具:") prompts.append(""" ### send_image - 发送图片到手机端 当用户请求发送图片到手机时,使用此工具: ```json { "action": "use_tool", "tool": "send_image", "args": { "image_path": "图片文件的绝对路径", "caption": "图片说明文字(可选)" } } ``` 支持的图片格式:JPG、PNG、GIF、BMP、WEBP """) return "\n".join(prompts) def _loads_json(self, s): """json.loads,但容忍未成对出现的反斜杠(Windows 路径 C:\\Users 常见)。 纯 JSON 模式下模型常把路径写成单反斜杠,直接 json.loads 会报非法转义。 这里先按标准解析;失败时把「未成对的反斜杠」转义后再试一次。 """ try: return json.loads(s) except json.JSONDecodeError: fixed = re.sub(r'(?... 格式 if response.startswith(""): json_str = response[11:] json_str = "{" + json_str try: parsed = self._loads_json(json_str) if isinstance(parsed, dict): return "", parsed except json.JSONDecodeError: pass # 方法1: 直接解析为纯JSON try: parsed = self._loads_json(response) if isinstance(parsed, (list, dict)): return "", parsed except json.JSONDecodeError: pass # 方法2: 修复JSON中的控制字符问题 try: def fix_json_string(match): content = match.group(1) content = content.replace('\\', '\\\\') content = content.replace('\n', '\\n') content = content.replace('\r', '\\r') content = content.replace('\t', '\\t') content = content.replace('"', '\\"') return f'"{content}"' fixed_response = re.sub(r'"([^"]*(?:\n[^"]*)*)"', fix_json_string, response, flags=re.DOTALL) parsed = self._loads_json(fixed_response) if isinstance(parsed, (list, dict)): return "", parsed except: pass # 方法3: 查找代码块中的JSON json_pattern = r"```(?:json)?\s*({.*?})\s*```" matches = re.findall(json_pattern, response, re.DOTALL) if matches: try: json_data = self._loads_json(matches[-1]) if isinstance(json_data, (dict, list)): text_content = re.sub(json_pattern, "", response, flags=re.DOTALL).strip() text_content = re.sub(r"\n\s*\n", "\n\n", text_content).strip() return text_content, json_data except json.JSONDecodeError: pass # 方法4: 查找每行独立的JSON对象 lines_resp = response.split("\n") json_objects = [] text_lines = [] for line in lines_resp: line = line.strip() if line.startswith("{") and line.endswith("}"): try: parsed = self._loads_json(line) if isinstance(parsed, dict): json_objects.append(parsed) continue except json.JSONDecodeError: pass text_lines.append(line) if json_objects: text_content = "\n".join(text_lines).strip() if len(json_objects) == 1: return text_content, json_objects[0] else: return text_content, json_objects # 方法5: 查找内联的JSON对象 json_start_markers = [ '{"action": "use_tool"', '{"action": "continue"', '{"action":"use_tool"', '{"action":"continue"', '{ "action": "use_tool"', '{ "action": "continue"', '{ "action":"use_tool"', '{ "action":"continue"' ] for marker in json_start_markers: json_start = response.find(marker) if json_start != -1: brace_count = 0 in_json = False for i in range(json_start, len(response)): char = response[i] if char == '{': if not in_json: in_json = True brace_count += 1 elif char == '}': brace_count -= 1 if in_json and brace_count == 0: json_str = response[json_start:i+1] try: json_data = self._loads_json(json_str) if isinstance(json_data, dict) and json_data.get('action') in ['use_tool', 'continue']: text_content = response[:json_start].strip() return text_content, json_data except json.JSONDecodeError: pass break # 方法6: 通用JSON对象查找 potential_jsons = re.findall(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', response, re.DOTALL) for json_str in potential_jsons: try: json_data = self._loads_json(json_str) if isinstance(json_data, dict) and json_data.get('action') in ['use_tool', 'continue']: json_start = response.find(json_str) text_content = response[:json_start].strip() return text_content, json_data except json.JSONDecodeError: continue # 方法7: 批量JSON try: cleaned = response.strip() for action in ['use_tool', 'continue']: last_brace_pos = cleaned.rfind(f'{{"action": "{action}"') if last_brace_pos == -1: last_brace_pos = cleaned.rfind(f'{{"action":"{action}"') if last_brace_pos != -1: json_part = cleaned[last_brace_pos:] if json_part.startswith('['): parsed = self._loads_json(json_part) if isinstance(parsed, list): text_content = cleaned[:last_brace_pos].strip() return text_content, parsed else: parsed = self._loads_json(json_part) if isinstance(parsed, dict): text_content = cleaned[:last_brace_pos].strip() return text_content, parsed except: pass return response, None # ── 对话处理 ── def process_conversation(self, user_input): """处理完整的对话循环, 支持工具调用循环和AI继续操作循环""" self.shared_conversation_history.append({ "role": "user", "content": user_input }) liugin_prompts = self.get_liugin_usage_prompts() loop_count = 0 current_input = user_input max_loops = 20 while loop_count < max_loops: response = self._generate_response_with_animation(current_input, liugin_prompts=liugin_prompts) processed_response = self.process_thinking_response(response) processed_response = self._process_code_blocks(processed_response) # ── Function Calling 响应检测 ── fc_handled = self._handle_fc_response(processed_response, user_input) if fc_handled: # FC 工具已执行,用结果继续循环 current_input = fc_handled loop_count += 1 continue try: text_content, json_data = self._parse_mixed_response(processed_response) if json_data: if text_content: text_content = self._process_code_blocks(text_content) self.shared_conversation_history.append({ "role": "assistant", "content": text_content }) self._display_response(text_content) if isinstance(json_data, dict): response_data = json_data if response_data.get('action') == 'use_tool': canceled = self._execute_single_tool(response_data) if canceled: break break if (response_data.get('continue') is True or response_data.get('need_continue') is True or response_data.get('think_more') is True): continue_message = response_data.get('message', '') if continue_message: processed_response = continue_message self.shared_conversation_history.append({ "role": "assistant", "content": processed_response }) self._display_response(processed_response, is_continue=True) loop_count += 1 elif response_data.get('message'): message = response_data.get('message') if text_content: text_content = self._process_code_blocks(text_content) self.shared_conversation_history.append({ "role": "assistant", "content": text_content }) self._display_response(text_content) self.shared_conversation_history.append({ "role": "assistant", "content": message }) self._display_response(message) break else: if text_content: text_content = self._process_code_blocks(text_content) self.shared_conversation_history.append({ "role": "assistant", "content": text_content }) self._display_response(text_content) else: self.shared_conversation_history.append({ "role": "assistant", "content": processed_response }) self._display_response(processed_response) break elif isinstance(json_data, list): tool_calls = [item for item in json_data if isinstance(item, dict) and item.get('action') == 'use_tool'] if tool_calls: self._execute_concurrent_tools(tool_calls) break continue_items = [item for item in json_data if isinstance(item, dict) and (item.get('continue') is True or item.get('need_continue') is True or item.get('think_more') is True)] if continue_items: continue_message = continue_items[0].get('message', '') if continue_message: processed_response = continue_message self.shared_conversation_history.append({ "role": "assistant", "content": processed_response }) self._display_response(processed_response, is_continue=True) loop_count += 1 else: if text_content: self.shared_conversation_history.append({ "role": "assistant", "content": text_content }) self._display_response(text_content or processed_response) break else: display_content = text_content if text_content else processed_response self.shared_conversation_history.append({ "role": "assistant", "content": display_content }) self._display_response(display_content) break except json.JSONDecodeError: text_content, json_data = self._parse_mixed_response(processed_response) if json_data: if text_content: text_content = self._process_code_blocks(text_content) self.shared_conversation_history.append({ "role": "assistant", "content": text_content }) self._display_response(text_content) if isinstance(json_data, dict): response_data = json_data if response_data.get('action') == 'continue': continue_content = response_data.get('content', '') if continue_content: self.shared_conversation_history.append({ "role": "assistant", "content": continue_content }) self._display_response(continue_content) continue if response_data.get('action') == 'use_tool': canceled = self._execute_single_tool(response_data) if canceled: break break if (response_data.get('continue') is True or response_data.get('need_continue') is True or response_data.get('think_more') is True): continue_message = response_data.get('message', '') if continue_message: processed_response = continue_message self.shared_conversation_history.append({ "role": "assistant", "content": processed_response }) self._display_response(processed_response, is_continue=True) loop_count += 1 else: message = response_data.get('message', '') if message: self.shared_conversation_history.append({ "role": "assistant", "content": message }) self._display_response(message) loop_count += 1 else: if text_content: self.shared_conversation_history.append({ "role": "assistant", "content": text_content }) self._display_response(text_content or processed_response) break elif isinstance(json_data, list): tool_calls = [item for item in json_data if isinstance(item, dict) and item.get('action') == 'use_tool'] if tool_calls: self._execute_concurrent_tools(tool_calls) break continue_items = [item for item in json_data if isinstance(item, dict) and (item.get('continue') is True or item.get('need_continue') is True or item.get('think_more') is True)] if continue_items: continue_message = continue_items[0].get('message', '') if continue_message: processed_response = continue_message self.shared_conversation_history.append({ "role": "assistant", "content": processed_response }) self._display_response(processed_response, is_continue=True) loop_count += 1 else: if text_content: self.shared_conversation_history.append({ "role": "assistant", "content": text_content }) self._display_response(text_content or processed_response) break else: display_content = text_content if text_content else processed_response self.shared_conversation_history.append({ "role": "assistant", "content": display_content }) self._display_response(display_content) break else: self._output(f"AI 已达到最大循环次数 ({max_loops}),已停止自动处理") self.shared_conversation_history.append({ "role": "assistant", "content": f"已达到最大处理次数 ({max_loops}),已自动停止。如需继续处理,请重新输入。" }) # ── 任务完成通知 ── task_summary = self._extract_task_summary(user_input) notify_task_complete(task_summary) # ── 自动保存聊天记录 ── if hasattr(self, 'memory_manager') and self.memory_manager: try: self.memory_manager.auto_save_chat( self.shared_conversation_history, label=user_input[:30] ) except Exception: pass # ── 自动压缩上下文(token 感知优先,消息数兜底)── try: if hasattr(self, 'memory_manager') and self.memory_manager: engine = self.current_engine prompt_tokens = getattr(engine, 'last_prompt_tokens', None) if engine else None # 统一 API:所有引擎必须返回 max_input_tokens(最大输入 token 数) ctx = None if engine: try: ctx = engine.max_input_tokens except Exception: ctx = None ratio = get_system_config('compress_ratio', 0.7) self.memory_manager.set_compress_ratio(ratio) if self.memory_manager.should_compress( self.shared_conversation_history, prompt_tokens, ctx, ratio ): before = len(self.shared_conversation_history) self.shared_conversation_history = self.memory_manager.compress_context( self.shared_conversation_history, engine=engine ) token_info = f",token {prompt_tokens}/{ctx}" if (prompt_tokens and ctx) else "" self._output( f"{Fore.CYAN}🗜 上下文已自动压缩 (原 {before} 条 → {len(self.shared_conversation_history)} 条{token_info},阈值 {ratio}){Style.RESET_ALL}" ) except Exception as e: logger.warning(f"自动压缩上下文失败: {e}") def _extract_task_summary(self, user_input: str) -> str: """提取任务摘要用于通知显示""" # 取用户输入的前 80 字符作为摘要 summary = user_input.strip().replace('\n', ' ') if len(summary) > 80: summary = summary[:77] + "..." return summary def _handle_fc_response(self, response: str, original_input: str) -> Optional[str]: """ 处理 Function Calling 响应 如果响应是 FC 格式,执行工具并返回结果(供下一轮循环使用) 如果不是 FC 响应,返回 None """ if not response or '_fc' not in response: return None try: data = self._loads_json(response) if not data.get("_fc"): return None tool_calls = data.get("tool_calls", []) if not tool_calls: return None # 记录 assistant 的 FC 消息到历史 fc_history_entry = { "role": "assistant", "content": None, "tool_calls": [{ "id": tc.get("id", ""), "type": "function", "function": { "name": tc.get("name", ""), "arguments": json.dumps(tc.get("arguments", {}), ensure_ascii=False) } } for tc in tool_calls] } self.shared_conversation_history.append(fc_history_entry) # 执行每个工具调用 results = [] for tc in tool_calls: tool_name = tc.get("tool", tc.get("name", "")) tool_args = tc.get("args", "") call_id = tc.get("id", "") self._output(f"[green] FC 调用: {tool_name}({tool_args[:60]})[/]") # 通过插件管理器执行 result = self._execute_tool_by_name(tool_name, tool_args) # 记录工具结果到历史(官方 OpenAI tool 消息不含 name 字段) tool_result_msg = { "role": "tool", "tool_call_id": call_id, "content": result if result else "执行完成" } self.shared_conversation_history.append(tool_result_msg) results.append(f"[{tool_name}]: {result if result else '完成'}") # 返回工具结果,供下一轮 AI 处理 return "工具执行结果:\n" + "\n".join(results) except (ValueError, KeyError, TypeError): return None def _execute_tool_by_name(self, tool_name: str, tool_args: str) -> str: """根据名称执行工具(FC 调用路径) 注意:插件 handler 可能返回 ToolResult / dict / str, 这里统一归一成 str,保证调用方能安全做切片与拼接。 """ from .tool_result import normalize_tool_text # 安全检查 safety = get_safety() allowed, msg = safety.check(tool_name, tool_args) if not allowed: return f"操作被拒绝: {msg}" # 查找工具 tool = self.liugin_manager.get_tool_by_name(tool_name) if tool and 'handler' in tool: try: return normalize_tool_text(tool['handler'](tool_args), default="执行完成") except Exception as e: return f"工具执行失败: {e}" # memory 内置工具 if tool_name == 'memory' and hasattr(self, 'memory_manager'): return normalize_tool_text(self.memory_manager.handle_tool(tool_args), default="执行完成") # scheduler 内置工具 if tool_name == 'scheduler': for t in self.liugin_manager.tools: if t.get('name') == 'scheduler' and 'handler' in t: try: return normalize_tool_text(t['handler'](tool_args), default="执行完成") except Exception as e: return f"工具执行失败: {e}" return f"未找到工具: {tool_name}" def _build_fc_tools(self, current_input): """构建本轮下发给引擎的 FC 工具子集,让引擎自行决定用 FC 结构化调用还是回退纯 JSON。 引擎不支持 FC(探测缓存未通过)时返回 None —— 引擎自动降级为纯 JSON 模式。 仅对带 `_fc_supported()` 的引擎下发 tools(Manual 等不支持的引擎不传 tools, 避免关键字参数错误)。 """ engine = self.current_engine if not engine or not hasattr(engine, '_fc_supported'): return None try: if not engine._fc_supported(): return None except Exception: return None # 工具结果续轮:模型只需决定「继续/收尾」,不需要 FC schema,省下每轮重发的 token。 # 若模型仍想调工具,走纯 JSON action(无需 schema 即可),行为等价、成本更低。 if current_input and str(current_input).lstrip().startswith("工具执行结果"): return None try: from .fc_tools import mcp_to_openai_tools, prune_tools all_tools = mcp_to_openai_tools(self.liugin_manager) if not all_tools: return None return prune_tools(all_tools, current_input) except Exception: return None def _call_engine_with_tools(self, current_input, system_prompt, fc_tools): """调用当前引擎;fc_tools 非空时下发 FC 工具定义(tool_choice=auto,模型自选)。""" if fc_tools: return self.current_engine.generate_response( current_input, system_prompt=system_prompt, tools=fc_tools) return self.current_engine.generate_response( current_input, system_prompt=system_prompt) def _generate_response_with_animation(self, current_input, liugin_prompts=None): """生成AI响应并显示等待动画 — 支持 ESC 跨平台取消""" # TUI 模式:不做动画 if self.tui_output_callback: system_prompt = self._build_system_prompt(liugin_prompts) if not self.current_engine: return "错误: 当前没有可用的AI引擎,请检查ai_engines目录中的引擎插件" # 混合模式:下发 FC 工具定义,tool_choice=auto —— 模型可自行选择 # 用 FC 结构化调用,或回退到纯 JSON action(两种都会被正确处理)。 fc_tools = self._build_fc_tools(current_input) response = self._call_engine_with_tools(current_input, system_prompt, fc_tools) return response # CLI 模式:带动画和跨平台 ESC 取消 animation_running = threading.Event() animation_running.set() esc_pressed = threading.Event() response_ready = threading.Event() response_result = [None] # 用列表存储,方便线程内修改 # 是否真实交互终端:非 TTY(管道 / 日志镜像 / 聊天前端捕获 stdout)时, # \r 无法原地覆盖,逐帧动画会污染输出流,必须禁用。 is_tty = sys.stdout.isatty() love_sentences = [] love_file_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), LOVE_FILE_PATH) if os.path.exists(love_file_path): try: with open(love_file_path, 'r', encoding='utf-8') as f: love_sentences = [line.strip() for line in f.readlines() if line.strip()] except Exception: pass if not love_sentences: love_sentences = ["AI正在思考中...", "请稍等片刻...", "正在处理您的请求..."] # ── 跨平台 ESC 检测 ── def check_for_esc(): """跨平台 ESC 键检测""" import platform if platform.system() == 'Windows': try: import msvcrt while animation_running.is_set(): if msvcrt.kbhit(): key = msvcrt.getch() if ord(key) == 27: # ESC esc_pressed.set() break time.sleep(0.05) except ImportError: pass else: # Linux / macOS — 用 select 做非阻塞读取 import select import sys try: # 保存原始终端设置 import tty, termios fd = sys.stdin.fileno() old_settings = termios.tcgetattr(fd) try: tty.setcbreak(fd) # cbreak 模式:无需回车即可读取 while animation_running.is_set(): if select.select([sys.stdin], [], [], 0.05)[0]: ch = sys.stdin.read(1) if ord(ch) == 27: # ESC esc_pressed.set() break finally: termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) except Exception: # 降级:无法检测 ESC,只能等 AI 完成 pass # ── 动画线程 ── def show_animation(): # 非 TTY:只输出一行静态提示,不跑逐帧 \r 动画(避免残帧进入捕获流) if not is_tty: try: print("⏳ AI 正在思考中…", flush=True) except Exception: pass return last_change_time = time.time() current_sentence = random.choice(love_sentences) while animation_running.is_set(): current_time = time.time() if current_time - last_change_time >= 5: current_sentence = random.choice(love_sentences) last_change_time = current_time animation_chars = "|/-\\" char_idx = int((current_time * 10) % len(animation_chars)) display_sentence = current_sentence[:47] + "..." if len(current_sentence) > 50 else current_sentence print(f"\r{Fore.MAGENTA}{display_sentence} {animation_chars[char_idx]}{Style.RESET_ALL}", end="", flush=True) time.sleep(0.1) # ── AI 响应线程 ── def generate_in_thread(): try: system_prompt = self._build_system_prompt(liugin_prompts) if not self.current_engine: response_result[0] = "错误: 当前没有可用的AI引擎,请检查ai_engines目录中的引擎插件" else: # 混合模式:下发 FC 工具(tool_choice=auto),模型自选 FC 或纯 JSON fc_tools = self._build_fc_tools(current_input) response_result[0] = self._call_engine_with_tools(current_input, system_prompt, fc_tools) except Exception as e: response_result[0] = f"AI 响应生成失败: {e}" finally: response_ready.set() # 启动三个线程 threads = [] for target in (show_animation, check_for_esc, generate_in_thread): t = threading.Thread(target=target, daemon=True) t.start() threads.append(t) try: # 主线程等待:ESC 或 响应完成 while not response_ready.is_set() and not esc_pressed.is_set(): time.sleep(0.1) # ESC 被按下 → 取消 if esc_pressed.is_set(): # 清除动画行 if is_tty: print("\r" + " " * 80 + "\r", end="", flush=True) print(f"\n{Fore.YELLOW}⚡ AI 请求已取消 (ESC){Style.RESET_ALL}") return "[AI请求已取消]" # 响应正常返回 response = response_result[0] if response: # 清除动画行后再输出 if is_tty: print("\r" + " " * 80 + "\r", end="", flush=True) self._typeprint(response, Fore.CYAN) if response and self.shared_conversation_history is not None: self.shared_conversation_history.append({ "role": "assistant", "content": response }) return response finally: animation_running.clear() for t in threads: t.join(timeout=1) # ── Plan 模式(只读规划 → 审批 → 执行)── def _sync_current_plan(self): """Plan 模式下,把对话历史中最后一条 assistant 内容记为当前计划。""" if not getattr(self, 'plan_mode', False): return for msg in reversed(self.shared_conversation_history): if isinstance(msg, dict) and msg.get("role") == "assistant" and msg.get("content"): self.current_plan = msg["content"] break def handle_plan_command(self, args): """处理 /plan 命令。进入 PLAN 模式(只读规划)。 /plan 进入模式,等待描述任务 /plan <任务> 进入模式并立即只读调研、生成实施计划 /plan off|exit 退出模式(计划不执行) """ args = (args or "").strip() if args in ('off', 'exit', '退出', 'cancel'): self.plan_mode = False print(f"{Fore.YELLOW}已退出 PLAN 模式(计划未执行){Style.RESET_ALL}") return self.plan_mode = True if args: print(f"{Fore.CYAN}已进入 PLAN 模式,正在只读调研并生成实施计划...{Style.RESET_ALL}") self.process_conversation(args) self._autosave_session() self._sync_current_plan() else: print(f"{Fore.CYAN}已进入 PLAN 模式。{Style.RESET_ALL}") print(f" 描述你的任务,AI 将只做只读调研并给出实施计划;" f"完成后用 {Fore.WHITE}/build{Style.RESET_ALL} 批准执行,或 " f"{Fore.WHITE}/plan off{Style.RESET_ALL} 取消。{Style.RESET_ALL}") def handle_agent_command(self, arg: str): """@ <任务> 委派给指定 agent 独立执行。""" mgr = getattr(self, 'agent_manager', None) if mgr is None: print(f"{Fore.RED}Agents 管理器未初始化{Style.RESET_ALL}") return if not arg: names = mgr.names() if names: print(f"{Fore.CYAN}可用 Agents: {', '.join(names)}{Style.RESET_ALL}") else: print(f"{Fore.YELLOW}暂无 agent 定义(在 agents/ 目录放置 *.md){Style.RESET_ALL}") return parts = arg.split(maxsplit=1) name = parts[0] task = parts[1] if len(parts) > 1 else "" agent = mgr.get(name) if not agent: print(f"{Fore.RED}未找到 agent: {name}{Style.RESET_ALL}") return print(f"{Fore.CYAN}委派给 agent「{agent.name}」: {task}{Style.RESET_ALL}") try: result = mgr.dispatch(name, task, self._agent_runner) except Exception as e: print(f"{Fore.RED}agent 执行失败: {e}{Style.RESET_ALL}") return print(f"{Fore.GREEN}{result}{Style.RESET_ALL}") def _agent_runner(self, system_prompt, task, tools, model): """agent 执行器:用当前引擎跑一轮独立上下文(不污染主会话)。""" engine = getattr(self, 'current_engine', None) or getattr(self, 'engine', None) if engine is None: return "错误:当前无可用的 AI 引擎" prompt = task if system_prompt: prompt = f"{system_prompt}\n\n---\n用户任务:\n{task}" return engine.generate_response(prompt, system_prompt=system_prompt) def handle_build_command(self): """处理 /build 命令。从 PLAN 模式进入执行(用已批准计划驱动)。""" plan = getattr(self, 'current_plan', '') or "" if not getattr(self, 'plan_mode', False) and not plan.strip(): print(f"{Fore.YELLOW}当前不在 PLAN 模式,且无已生成的计划{Style.RESET_ALL}") return self.plan_mode = False if not plan.strip(): print(f"{Fore.YELLOW}尚未生成实施计划,先 /plan 描述任务让 AI 调研{Style.RESET_ALL}") return print(f"{Fore.GREEN}已批准计划,开始执行...{Style.RESET_ALL}") instruction = ("【已批准的实施计划,请现在严格按照以下步骤执行,利用可用工具完成每一步;" "遇到与计划不符的情况先说明再继续】\n\n" + plan) self.process_conversation(instruction) self._autosave_session() # ── CLI 主循环 ── def run_tui(self): """运行 TUI 模式(v8.0 默认真·全屏 textual TUI;textual 不可用时回退 rich 面板)""" if TEXTUAL_AVAILABLE: from .tui import XiaoliTUI app = XiaoliTUI(self) app.run() return # 回退 rich 面板 TUI(textual 不可用时) try: from .rich_tui import RichTUI from xcli_core.constants import VERSION as _V tui = RichTUI(self) tui.version = _V tui.set_status( engine=getattr(self.current_engine, 'name', '') if self.current_engine else '', model=getattr(self.current_engine, 'model', '') if self.current_engine else '', mode='TUI', ) self.tui_output_callback = tui.push tui.run() self.tui_output_callback = None except ImportError: print(f"{Fore.RED}TUI 不可用: 请安装 textual (pip install textual){Style.RESET_ALL}") def run(self): """运行 CLI""" import uuid self.user_id = str(uuid.getnode()) current_engine_name = getattr(self.current_engine, 'name', '未设置') if self.current_engine else '未设置' model_name = getattr(self.current_engine, 'model', '') if self.current_engine else '' engine_desc = f"{current_engine_name} ({model_name})" if model_name else current_engine_name print(f"{Fore.GREEN}小狸 Pro-CLI v{VERSION} 已启动!{Style.RESET_ALL} " f"{Fore.WHITE}引擎: {engine_desc}{Style.RESET_ALL}") # 一行速览,完整命令清单收进 /help print(f"{Fore.CYAN}/help 全部命令 · /quit 退出 · /tui 图形界面 · " f"/model 换模型 · /resume 恢复会话 · /plan 规划模式 · @文件路径 读文件给 AI{Style.RESET_ALL}") # 历史会话提示(opencode 式 /resume) try: sessions = self.session_manager.list_sessions() if sessions: latest = sessions[0] print(f"{Fore.YELLOW}有 {len(sessions)} 个历史会话,/resume 可恢复" f"(最近: {latest.id} · {latest.title}){Style.RESET_ALL}") except Exception: pass extras = [] loaded_engines = list(self.engines.keys()) if len(loaded_engines) > 1: extras.append(f"可切换引擎: {', '.join(loaded_engines)}") # 进程保护:只在有未生效项时才提示,全绿就闭嘴 if getattr(self, 'protection_results', None): r = self.protection_results failed = [n for k, n in (('single_instance', '单实例'), ('priority', '优先级'), ('ppl_protection', 'PPL保护'), ('watchdog', '看门狗')) if not r.get(k)] if failed: extras.append(f"进程保护未生效: {', '.join(failed)}") if extras: print(f"{Fore.BLACK}{Style.BRIGHT}{' · '.join(extras)}{Style.RESET_ALL}") print(f"{Fore.BLACK}{Style.BRIGHT}用户ID: {self.user_id}{Style.RESET_ALL}") # 恢复 diff 显示模式(首次运行才询问) self._ask_diff_popup_mode() self._run_cli_loop() def _get_code_editor_plugin(self): """拿到 code_editor 插件实例(找不到返回 None)""" for tool in self.liugin_manager.tools: if tool.get('name') == 'code_editor': return getattr(tool.get('handler'), '__self__', None) return None def _ask_diff_popup_mode(self): """设置 diff 显示模式:已保存过就静默沿用,只有首次运行才交互询问""" plugin_instance = self._get_code_editor_plugin() if plugin_instance is None: return saved = get_system_config('diff_popup_mode', None) if saved is not None: plugin_instance.diff_popup_mode = bool(saved) return print(f"\n{Fore.CYAN} Diff 显示模式(AI 修改文件后的对比方式):{Style.RESET_ALL}") print(f" {Fore.WHITE}1{Style.RESET_ALL} - 弹窗模式(新终端窗口显示)") print(f" {Fore.WHITE}2{Style.RESET_ALL} - 主终端内显示(SSH 兼容,推荐)") try: choice = input(f"\n{Fore.WHITE} 请选择 [1/2](默认 2,之后可用 /diff 改): {Style.RESET_ALL}").strip() except (EOFError, KeyboardInterrupt): choice = '2' self._set_diff_popup_mode(choice == '1', plugin_instance) print() def _set_diff_popup_mode(self, popup, plugin_instance=None): """写入并持久化 diff 显示模式""" if plugin_instance is None: plugin_instance = self._get_code_editor_plugin() if plugin_instance is not None: plugin_instance.diff_popup_mode = bool(popup) set_system_config('diff_popup_mode', bool(popup)) label = '弹窗模式' if popup else '主终端内显示' print(f"{Fore.GREEN} ✓ Diff 显示模式: {label}{Style.RESET_ALL}") def _run_cli_loop(self): """运行 CLI 主循环""" while True: try: user_input = input(f"\n{Fore.WHITE}> {Style.RESET_ALL}").strip() if user_input == '/quit': print(f"{Fore.GREEN}再见!{Style.RESET_ALL}") break # @agent 委派(opencode 式 Agents 体系化) if user_input.startswith('@'): self.handle_agent_command(user_input[1:].strip()) continue if user_input == '/help': self.show_help() continue if user_input.startswith('/help '): liugin_name = user_input[6:].strip() self.show_liugin_help(liugin_name) continue if user_input.startswith('/engine '): engine_command = user_input[8:].strip() parts = engine_command.split(' ', 1) cmd = parts[0] args = parts[1] if len(parts) > 1 else '' if cmd in self.engine_commands: self.engine_commands[cmd](args) else: available_commands = ', '.join(self.engine_commands.keys()) print(f"{Fore.RED}未知的引擎命令.可用命令: {available_commands}{Style.RESET_ALL}") continue if user_input.startswith('/engine.'): engine_command = user_input[8:].strip() if not self.handle_engine_command(engine_command): print(f"{Fore.RED}当前引擎不支持该命令或命令执行失败{Style.RESET_ALL}") continue # /model 命令族 — OpenAI 引擎多模型在线增删切换 if user_input == '/model' or user_input.startswith('/model '): self.handle_model_command(user_input[7:].strip()) continue # /providers — opencode 式原生多 Provider 注册与状态一览 if user_input == '/providers' or user_input.startswith('/providers '): self.handle_providers_command(user_input[11:].strip()) continue # /plan 进入 PLAN 模式(只读规划 → 审批 → 执行) if user_input == '/plan' or user_input.startswith('/plan '): sub = user_input[6:].strip() if user_input.startswith('/plan ') else '' self.handle_plan_command(sub) continue if user_input == '/build': self.handle_build_command() continue # /resume 会话恢复(opencode 式自动持久化 + 一键恢复) if user_input in ('/resume', '/sessions') or \ user_input.startswith('/resume ') or user_input.startswith('/sessions ') or \ user_input.startswith('/session '): if user_input.startswith('/resume'): self.handle_resume_command(user_input[7:].strip()) elif user_input.startswith('/sessions'): self.handle_resume_command(user_input[9:].strip()) else: # /session <子命令> sub = user_input[8:].strip() self.handle_resume_command(sub if sub != 'new' else 'new') continue # /manual 快捷命令 — 切换到 manual 引擎或执行 manual 子命令 if user_input == '/manual' or user_input.startswith('/manual '): args = user_input[8:].strip() if user_input.startswith('/manual ') else '' if 'manual' not in self.engines: print(f"{Fore.RED}manual 引擎未加载{Style.RESET_ALL}") elif not args: # 无参数:切换到 manual 引擎 self.switch_engine('manual') print(f"{Fore.GREEN}已切换到 manual 引擎{Style.RESET_ALL}") else: # 有参数:执行 manual 引擎命令(如 status, restart) result = self.engines['manual'].handle_command(args) if result: print(f"{Fore.GREEN}{result}{Style.RESET_ALL}") continue if user_input.startswith('/file.read '): args = user_input[11:].strip() parts = args.split(' ', 2) if len(parts) < 1: print(f"{Fore.RED}请提供文件名.用法: /file.read <文件名> [行数]{Style.RESET_ALL}") continue filename = parts[0] max_lines = None if len(parts) >= 2: try: max_lines = int(parts[1]) if max_lines <= 0: print(f"{Fore.RED}行数必须是正整数{Style.RESET_ALL}") continue except ValueError: print(f"{Fore.RED}行数必须是正整数{Style.RESET_ALL}") continue result = self._read_file_direct(filename, max_lines) print(f"{Fore.GREEN}{result}{Style.RESET_ALL}") continue if user_input.startswith('/chat '): chat_command = user_input[6:].strip() if chat_command.startswith('save '): name = chat_command[5:].strip() if name: self.save_chat_history(name) else: print(f"{Fore.RED}请提供聊天记录名称.用法: /chat save <名称>{Style.RESET_ALL}") continue elif chat_command == 'list': self.list_chat_history() continue elif chat_command.startswith('open '): name = chat_command[5:].strip() if name: self.load_chat_history(name) else: print(f"{Fore.RED}请提供聊天记录名称.用法: /chat open <名称>{Style.RESET_ALL}") continue else: print(f"{Fore.RED}未知的聊天命令.可用命令: save, list, open{Style.RESET_ALL}") continue if user_input.startswith('/plugin'): plugin_args = user_input[7:].strip() result = self.handle_plugin_command(plugin_args) if result: print(result) continue if user_input.startswith('/protect'): parts = user_input.split() if len(parts) > 1 and parts[1] == 'test': # 重新测试所有保护 from .process_protection import enable_process_protection results = enable_process_protection() print(f"\n{Fore.CYAN} 进程保护测试结果:{Style.RESET_ALL}") for key, val in results.items(): icon = '' if val else '' print(f" {icon} {key}: {val}") else: status = get_protection_status() print(f"\n{Fore.CYAN} 进程保护状态:{Style.RESET_ALL}") print(f" 平台: {status.get('platform', '未知')}") icon = '' if status.get('is_protected') else '' print(f" {icon} 保护已启用: {status.get('is_protected', False)}") icon = '' if status.get('single_instance_held') else '' print(f" {icon} 单实例锁: {status.get('single_instance_held', False)}") icon = '' if status.get('watchdog_running') else '' print(f" {icon} 看门狗: {status.get('watchdog_running', False)}") print(f" 关闭钩子: {status.get('shutdown_hooks_count', 0)} 个") continue if user_input.startswith('/remote'): remote_command = user_input[7:].strip() self._handle_remote_command(remote_command) continue if user_input == '/about': self._read_about_file() continue if user_input == '/tui': print(f"{Fore.CYAN}正在切换到 TUI 模式...{Style.RESET_ALL}") self.run_tui() print(f"{Fore.CYAN}已从 TUI 模式返回 CLI 模式{Style.RESET_ALL}") continue if user_input == '/compress': if hasattr(self, 'memory_manager') and self.memory_manager: engine = self.current_engine before = len(self.shared_conversation_history) self.shared_conversation_history = self.memory_manager.compress_context( self.shared_conversation_history, engine=engine) self._set_shared_conversation_history() print(f"{Fore.CYAN}已手动压缩上下文: {before} → {len(self.shared_conversation_history)} 条{Style.RESET_ALL}") else: print(f"{Fore.YELLOW}记忆系统未加载,无法压缩{Style.RESET_ALL}") continue if user_input.startswith('/memory mode '): mode = user_input[len('/memory mode '):].strip().lower() if mode in ('companion', 'work'): try: set_system_config('memory_mode', mode) except Exception: pass print(f"{Fore.GREEN}记忆模式已设为: {mode}{Style.RESET_ALL}") else: print(f"{Fore.RED}用法: /memory mode {Style.RESET_ALL}") continue if user_input.startswith('/safe'): parts = user_input.split() safety = get_safety() if len(parts) > 1: mode_arg = parts[1].lower() if mode_arg in ('off', 'unrestricted', '无限制'): safety.set_mode(MODE_UNRESTRICTED) elif mode_arg in ('on', 'normal', '普通'): safety.set_mode(MODE_NORMAL) elif mode_arg in ('manual', '人工', 'all'): safety.set_mode(MODE_MANUAL) else: print(f"{Fore.RED}用法: /safe [off|on|manual]{Style.RESET_ALL}") continue else: # 无参数:循环切换 safety.cycle_mode() mode_name = safety.get_mode_name() icons = {"无限制": "", "普通": "", "人工确认": ""} icon = icons.get(mode_name, "") print(f"{icon} 安全模式: {mode_name}") continue if user_input.startswith('/diff'): parts = user_input.split() if self._get_code_editor_plugin() is None: print(f"{Fore.YELLOW}code_editor 插件未加载,无法设置 diff 模式{Style.RESET_ALL}") continue if len(parts) > 1: arg = parts[1].lower() if arg in ('popup', '弹窗', '1'): self._set_diff_popup_mode(True) elif arg in ('inline', '内联', '主终端', '2'): self._set_diff_popup_mode(False) else: print(f"{Fore.RED}用法: /diff [popup|inline]{Style.RESET_ALL}") else: cur = bool(get_system_config('diff_popup_mode', False)) self._set_diff_popup_mode(not cur) continue if user_input.startswith('/fc'): from .fc_tools import get_fc_cache, reset_fc_cache parts = user_input.split() if len(parts) > 1 and parts[1].lower() in ('reset', 'clear', '清除'): n = reset_fc_cache() print(f"{Fore.GREEN}已清除 {n} 条 FC 探测记录,下次请求会重新试探{Style.RESET_ALL}") else: cache = get_fc_cache() if not cache: print(f"{Fore.GREEN}FC 探测缓存为空:所有模型都按支持 function calling 处理{Style.RESET_ALL}") else: print(f"{Fore.CYAN}已探测到不支持 function calling 的模型:{Style.RESET_ALL}") for key, ok in cache.items(): if ok is False: print(f" {Fore.YELLOW}✗{Style.RESET_ALL} {key}") print(f"{Fore.WHITE}这些请求会自动跳过 tools 字段;/fc reset 可清除重测{Style.RESET_ALL}") continue if user_input.startswith('/notify'): parts = user_input.split() nm = get_notification_manager() if len(parts) > 1: arg = parts[1].lower() if arg in ('on', '开启', '启用'): nm.set_enabled(True) print(f"{Fore.GREEN}🔔 系统通知已开启{Style.RESET_ALL}") elif arg in ('off', '关闭', '禁用'): nm.set_enabled(False) print(f"{Fore.YELLOW}🔕 系统通知已关闭{Style.RESET_ALL}") else: print(f"{Fore.RED}用法: /notify [on|off]{Style.RESET_ALL}") else: # 无参数:切换状态 nm.set_enabled(not nm.enabled) if nm.enabled: print(f"{Fore.GREEN}🔔 系统通知已开启{Style.RESET_ALL}") else: print(f"{Fore.YELLOW}🔕 系统通知已关闭{Style.RESET_ALL}") continue if user_input.startswith('/'): command_parts = user_input[1:].split(' ', 1) command = command_parts[0] args = command_parts[1] if len(command_parts) > 1 else "" if command in self.liugin_commands: try: result = self.liugin_commands[command](args) if result: result = self._limit_output_lines(result, max_lines=5) print(f"{Fore.GREEN}{result}{Style.RESET_ALL}") except Exception as e: print(f"{Fore.RED}插件命令执行失败: {e}{Style.RESET_ALL}") continue if user_input.startswith('/thinking '): thinking_input = user_input[10:].strip() processed_response = self.process_thinking_response(thinking_input) processed_response = self._process_code_blocks(processed_response) user_id_display = f"[用户ID: {self.user_id}]" response_lines = processed_response.split('\n') for i, line in enumerate(response_lines): if i == 0: line = " " + line if i == len(response_lines) - 1: print(f"{line} {user_id_display}") else: print(f"{line}") continue if '@' in user_input: processed_input = self._process_file_paths(user_input) if processed_input != user_input: print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}") user_input = processed_input print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}") self.process_conversation(user_input) # 自动持久化当前会话(opencode 式 /resume 落盘) self._autosave_session() # Plan 模式下:把本轮最后一条 assistant 内容记为当前计划 self._sync_current_plan() except KeyboardInterrupt: user_id_display = f"[用户ID: {self.user_id}]" print(f"\n{Fore.GREEN}再见! {user_id_display}{Style.RESET_ALL}") break except Exception as e: user_id_display = f"[用户ID: {self.user_id}]" print(f"{Fore.RED}发生错误: {e} {user_id_display}{Style.RESET_ALL}") # ============================================================================ # Main Entry Point # ============================================================================ def main(): """主入口函数""" import time import sys art_lines = [ "█████████████████████████████████████████████████████████████████████████████████████████████████████", "█████████████████████████████████████████████████████████████████████████████████████████████████████", "█████████████████████████████████████████████████████████████████████████████████████████████████████", "█████████████████████████████████████████████████████████████████████████████████████████████████████", "█████████████████████████████████████████████████████████████████████████████████████████████████████", "███████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓██████████████████", "██████████ ▓████████████████", "██████████ ▒███████████████", "██████████ ░██████████████", "██████████ ░█████████████", "██████████ ████████████", "██████████ ███████████", "██████████ ▒████████████████████████████████████████████████████", "██████████ ░██████████████████████████████████████████████████████", "██████████ ████████████████████████████████████████████████████████", "██████████ ▓█████████████████████████████████████████████████████████", "██████████ ▒███████████████████████████████████████████████████████████", "██████████ ███████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓██████████", "██████████ ███████████▓▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒██████████", "██████████ ▓██████████▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▓██████████", "██████████ ░███████████▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▒██████████", "██████████ ███████████▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▒▓▓▒██████████", "██████████ ███████████▓▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▓██████████", "██████████ ▓██████████▓▒▒▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▓██████████", "██████████ ░██████████▓▒▒▓▒▓▒▒▓▒▒▒▒▓▒▒▓▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▒▓▓██████████", "██████████ ███████████▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▒▒▒▒▓▒▓▒▓██████████", "██████████ ███████████▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▓▒▓▒▓▒▒▒▒▓▒▒▒▓▒▒▒▒▒▓▒▒▓▒▒▒▒▒▓▒▓▒▓▒▒▒▓▓██████████", "██████████ ░██████████▒▓▒▓▒▓▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▓▒▒▒▒▓▒▓▒▓▒▒▓▒▒▒▓▒▒▒██████████", "██████████ █████████▓▒▒▒▓▒▒▓▒▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒██████████", "██████████ █████████▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▓██████████", "██████████ ▓████████▒▓▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▓▒▒▓▒▒▓▓▒▒▒██████████", "██████████ █████████▒▒▒▒▓▒▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▓▒▒▓▒▒▒▒▒▓▒██████████", "██████████ █████████▒▓▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▓▒▓▒▒▓▒▒▒▒▒▓▒▒▒██████████", "██████████ ▓████████▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒██████████", "██████████ █████████▒▓▒▒▒▒▓▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▓▒▒▒▒▒▒▒██████████", "██████████ █████████▒▒▓▒▓▒▒▓▒▓▒▒▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▒▒▓▒▒▒▒▒▓▒██████████", "██████████ ▓████████▒▓▒▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▓▒▒▒▒▒▓██████████", "██████████ █████████▒▒▓▒▓▒▒▓▒▓▒▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", "██████████ █████████▒▓▒▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", "██████████ ▓████████▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▒██████████", "██████████ █████████▒▓▒▒▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", "██████████ █████████▒▒▓▒▓▒▒▓▒▓▒▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", "██████████ ▓████████▒▓▒▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", "██████████ █████████▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▓▒▓▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", "██████████ █████████▒▓▒▒▒▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", "██████████ ▓████████▒▒▓▒▒▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▒▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", "███████████ █████████▒▓▒▒▒▒▒▒▓▒▒▒▒▓▒▒▓▒▒▒▒▒▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", "█████████████▓ █████████▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", "█████████████████ ▓████████▒▓▒▒▒▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▒▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", "███████████████████▒ █████████▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▒▒▒▒▒▓▒▒▓▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████", "█████████████████████████████████████████████████████████████████████████████████████████████████████", "█████████████████████████████████████████████████████████████████████████████████████████████████████", "█████████████████████████████████████████████████████████████████████████████████████████████████████", "█████████████████████████████████████████████████████████████████████████████████████████████████████", "█████████████████████████████████████████████████████████████████████████████████████████████████████", "█████████████████████████████████████████████████████████████████████████████████████████████████████", ] if sys.platform == 'win32': import codecs sys.stdout = codecs.getwriter('utf-8')(sys.stdout.buffer, 'strict') def _safe_print(line): try: print(line, flush=True) except UnicodeEncodeError: print(line.encode('gbk', errors='ignore').decode('gbk'), flush=True) # 完整开机动画(57 行 + 逐行 0.05s)默认不放,用 --logo 显式召唤 if '--logo' in sys.argv or '--banner' in sys.argv: for line in art_lines: _safe_print(line) time.sleep(0.05) else: _safe_print(f"\n{Fore.CYAN} ▄▀▄ 小狸 Pro-CLI v{VERSION}{Style.RESET_ALL}") _safe_print(f"{Fore.CYAN} ▀▄▀ {Style.RESET_ALL}" f"{Fore.WHITE}AI 智能编程助手{Style.RESET_ALL}" f" {Fore.BLACK}{Style.BRIGHT}(--logo 看完整开机动画){Style.RESET_ALL}") cli = AICLI() cli.run()