From 79957eba0f4cc79641efb02821443e27211acd22 Mon Sep 17 00:00:00 2001 From: shuiyu67 Date: Sat, 1 Aug 2026 09:28:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(v8.0):=20=E5=9F=BA=E4=BA=8E=20v5.4.1=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E7=AB=8B=E9=A1=B9=20v8.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 版本立项: constants.VERSION=8.0.0,about.txt/README 同步。 - Rich 面板式 TUI (xcli_core/rich_tui.py): 默认 TUI 渲染器,rich 驱动; 核心创新=快照出口(Console(record=True) -> text/svg/html),AI 开发界面 时可把渲染读回自验,补上'看不见渲染'闭环。tools/tui_snapshot.py 无头生成 演示快照。run_tui 优先 RichTUI,textual 作为回退。 - Token 感知压缩: openai_engine 捕获 usage.prompt_tokens -> last_prompt_tokens, 新增 context_window()(模型名查表 MODEL_CONTEXT_WINDOWS + config 覆盖); memory.should_compress 升级为 token 感知(used/context_window >= ratio 触发, 默认 0.7),消息数阈值兜底;cli_core.process_conversation 自动压缩并提示。 - 记忆模式增强: 保留 v5.4.1 的 MEMORY.md/日记/自动保存/AI 摘要压缩,新增 /compress 手动压缩 与 /memory mode 切换。 - requirements 加 rich;.gitignore 忽略 .tui_snapshots/。 --- .gitignore | 3 + README.md | 8 ++- about.txt | 4 +- ai_engines/openai_engine.py | 53 ++++++++++++++ requirements.txt | 2 + tools/tui_snapshot.py | 56 +++++++++++++++ xcli_core/cli_core.py | 93 ++++++++++++++++++++----- xcli_core/constants.py | 4 ++ xcli_core/memory.py | 27 +++++++- xcli_core/rich_tui.py | 134 ++++++++++++++++++++++++++++++++++++ 10 files changed, 359 insertions(+), 25 deletions(-) create mode 100644 tools/tui_snapshot.py create mode 100644 xcli_core/rich_tui.py diff --git a/.gitignore b/.gitignore index 71c9d19..0896722 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ screenshots/gen_*.png # 临时文件 *.tmp *.bak + +# v8.0 TUI 快照产物(运行时生成,供 AI 自验/预览) +.tui_snapshots/ diff --git a/README.md b/README.md index a2ca955..5a5b188 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- version + version python license tests @@ -22,6 +22,12 @@ 小狸 Pro-CLI 是一款基于 Python 的智能编程助手,集成了代码编辑、版本控制、浏览器自动化、图像生成、记忆系统等功能,支持多种 AI 引擎,帮助开发者提升工作效率。 +## v8.0 新特性 + +- **Rich 面板式 TUI(默认)** — `rich` 驱动的可视化对话面板;自带**快照出口**(导出 text/svg/html),AI 开发界面时可把渲染结果读回自验,补上"看不见渲染"的反馈闭环。输入 `/tui` 进入,`/snapshot` 随时导出当前界面。 +- **Token 感知压缩** — 自动读取 OpenAI 格式返回的 `usage.prompt_tokens`,当 `已用 token / 模型上下文窗口 ≥ 0.7`(可在 `config.json` 设 `system.compress_ratio` / `system.context_window` 覆盖)即触发上下文压缩,消息数阈值作为兜底,避免超窗截断。 +- **记忆模式** — 持久化 `MEMORY.md` + 每日日记 + 自动保存聊天记录 + 关键词检索;新增 `/memory mode ` 切换与 `/compress` 手动压缩。 + ## 特性 - **双 AI 引擎** — Ollama 本地模型 + OpenAI 兼容格式(DeepSeek、Grok、硅基流动等) diff --git a/about.txt b/about.txt index e8cd47d..b0259c4 100644 --- a/about.txt +++ b/about.txt @@ -1,7 +1,7 @@ **产品标识** - **产品名称**:小狸智能体 增强-终端(Xiaoli Pro-CLI) -- **内部版本号**:20260428 -- **发布版本**:发行版5.1.3 +- **内部版本号**:20260801 +- **发布版本**:发行版8.0.0 **技术规格** - **主要语言**:简体中文(UTF-8) diff --git a/ai_engines/openai_engine.py b/ai_engines/openai_engine.py index bbc207c..36c72dd 100644 --- a/ai_engines/openai_engine.py +++ b/ai_engines/openai_engine.py @@ -13,6 +13,31 @@ import logging # 设置日志 logger = logging.getLogger(__name__) +try: + from xcli_core.config import get_system_config +except Exception: + def get_system_config(key, default=None): + return default + +# ── 常见模型的上下文窗口(token 数)── +# OpenAI /chat/completions 协议本身不返回模型窗口,需本地维护。 +MODEL_CONTEXT_WINDOWS = { + "gpt-4o": 128000, "gpt-4o-mini": 128000, "gpt-4": 8192, "gpt-4-turbo": 128000, + "gpt-3.5-turbo": 16385, + "deepseek-chat": 64000, "deepseek-reasoner": 64000, + "qwen2.5": 32768, "qwen2": 32768, "qwen-max": 32768, "qwen-plus": 32768, + "llama3": 8192, "llama3.1": 128000, "llama3.2": 128000, "llama2": 4096, + "claude-3": 200000, "claude-3.5": 200000, "claude-3.7": 200000, "claude": 200000, + "grok": 131072, "grok-2": 131072, "grok-3": 131072, + "glm-4": 128000, "glm-4v": 128000, + "mistral": 32768, "mixtral": 32768, "mistral-large": 128000, + "gemini": 1000000, "gemini-1.5": 1000000, "gemini-2.0": 1000000, "gemini-2.5": 1000000, + "yi": 200000, "yi-large": 200000, + "kimi": 200000, "moonshot": 200000, + "abab": 200000, "minimax": 200000, + "baichuan": 192000, "chatglm": 32768, +} + # 添加项目根目录到sys.path current_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.dirname(current_dir) @@ -95,6 +120,9 @@ OpenAI 兼容格式引擎帮助信息 self.conversation_history = [] self.shared_conversation_history = None + # 最近一次请求的 token 用量(供 token 感知压缩使用) + self.last_prompt_tokens = None + # 思考标记(兼容深度思考模型) self.thinking_start_marker = "" self.thinking_end_marker = "" @@ -169,6 +197,13 @@ OpenAI 兼容格式引擎帮助信息 if response.status_code == 200: response_data = response.json() + # ── 记录 token 用量(供 token 感知压缩)── + usage = response_data.get("usage", {}) + if isinstance(usage, dict) and usage.get("prompt_tokens"): + try: + self.last_prompt_tokens = int(usage["prompt_tokens"]) + except (TypeError, ValueError): + self.last_prompt_tokens = None if "choices" in response_data and len(response_data["choices"]) > 0: message = response_data["choices"][0].get("message", {}) @@ -342,6 +377,24 @@ OpenAI 兼容格式引擎帮助信息 self.model = model_name print(f"{Fore.GREEN}已切换模型为: {model_name}{Style.RESET_ALL}") + def context_window(self): + """ + 返回当前模型的上下文窗口(token 数)。 + 优先级: config.system.context_window > 模型名查表 > 默认 128000。 + 注: OpenAI /chat/completions 协议本身不返回模型窗口,需本地维护。 + """ + cfg = get_system_config("context_window", None) + if cfg: + try: + return int(cfg) + except (TypeError, ValueError): + pass + model = (self.model or "").lower() + for key, win in MODEL_CONTEXT_WINDOWS.items(): + if key and key in model: + return win + return 128000 + def list_models(self): """列出可用模型(调用 API 的 /models 端点)""" if not self.base_url: diff --git a/requirements.txt b/requirements.txt index 19a5f2e..1900e47 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,6 +22,8 @@ pygame>=2.0.0 # TUI 模式 textual>=0.40.0 +# v8.0 Rich 面板式 TUI(支持快照自验,默认 TUI 渲染器) +rich>=13.0.0 # WebSocket 远程 websockets>=11.0 diff --git a/tools/tui_snapshot.py b/tools/tui_snapshot.py new file mode 100644 index 0000000..c252097 --- /dev/null +++ b/tools/tui_snapshot.py @@ -0,0 +1,56 @@ +""" +v8.0 TUI 快照生成器(无头) +========================== +直接用 RichTUI.push() 喂一段脚本化对话,导出 .tui_snapshots/demo.{txt,svg,html}。 +AI 可把 .txt 读回核验布局;把 .html/.svg 交给用户预览。 +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from rich.text import Text +from xcli_core.rich_tui import RichTUI, SNAP_DIR + + +def main(): + tui = RichTUI(cli=None) + tui.set_status( + engine="openai", model="deepseek-chat", mode="TUI", + used_tokens=47000, context_window=64000, msg_count=4, + ) + tui.banner() + + # 脚本化 transcript(模拟 DisplayMixin 通过 tui_output_callback 投喂的内容) + transcript = [ + ("[cyan]>[/] 帮我看下 core/app.py 有没有内存泄漏", "user"), + ("[yellow]思考: 我先搜索一下文件结构与资源释放逻辑...[/]", "think"), + ("[green] OK 工具: read_file[/]", "tool_ok"), + ("[green] 文件路径: core/app.py (248 行)[/]", "tool_ok"), + ("[red] X 工具: shell_exec[/]", "tool_err"), + ("[red] 错误: 命令超时 (30s)[/]", "tool_err"), + ("[white]根据分析,core/app.py 在 __del__ 中调用 cleanup_resources," + "但多线程下可能重复释放...[/]", "assistant"), + ("[cyan]>[/] 那顺手修一下吧", "user"), + ("[green] OK 工具: edit_file[/]", "tool_ok"), + ("[green] 已更新 cleanup_resources,加锁防止重复释放[/]", "tool_ok"), + ("[white]已修复:为 cleanup_resources 增加了 _cleaned 标志位,确保只释放一次。[/]", "assistant"), + ] + for msg, _kind in transcript: + tui.push(msg) + + tui.console.print(Text.from_markup(tui._bar())) + + os.makedirs(SNAP_DIR, exist_ok=True) + stem = os.path.join(SNAP_DIR, "demo") + txt, svg, html = tui.save_snapshot(stem) + print("快照已生成:") + print(" 文本:", txt) + print(" SVG :", svg) + print(" HTML:", html) + print("\n===== 文本快照预览 =====\n") + print(tui.snapshot_text()) + + +if __name__ == "__main__": + main() diff --git a/xcli_core/cli_core.py b/xcli_core/cli_core.py index 2ef96e1..22d7f03 100644 --- a/xcli_core/cli_core.py +++ b/xcli_core/cli_core.py @@ -9,7 +9,7 @@ import random from typing import Optional from colorama import Fore, Style -from .constants import TEXTUAL_AVAILABLE, LOVE_FILE_PATH, DEFAULT_MAX_HISTORY +from .constants import TEXTUAL_AVAILABLE, LOVE_FILE_PATH, DEFAULT_MAX_HISTORY, VERSION from .config import get_system_config, logger from .cli_base import BaseAICLI from .safety import get_safety, MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL @@ -714,17 +714,33 @@ multi 操作支持一次修改多处: except Exception: pass - # ── 自动压缩上下文 ── + # ── 自动压缩上下文(token 感知优先,消息数兜底)── try: - if self.memory_manager.should_compress(self.shared_conversation_history): - self.shared_conversation_history = self.memory_manager.compress_context( - self.shared_conversation_history, - engine=self.current_engine - ) - self._set_shared_conversation_history() - print(f"{Fore.DIM}[上下文已压缩,保留最近 10 条]{Style.RESET_ALL}") - except Exception: - pass + 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 + ctx = None + if engine and hasattr(engine, 'context_window'): + try: + ctx = engine.context_window() + 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: """提取任务摘要用于通知显示""" @@ -983,20 +999,35 @@ multi 操作支持一次修改多处: # ── CLI 主循环 ── def run_tui(self): - """运行 TUI 模式""" - if not TEXTUAL_AVAILABLE: - print(f"{Fore.RED}TUI 模式不可用:Textual 库未安装{Style.RESET_ALL}") - print(f"{Fore.YELLOW}请运行: pip install textual rich{Style.RESET_ALL}") + """运行 TUI 模式(v8.0 默认 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 return - from .tui import XiaoliTUI - app = XiaoliTUI(self) - app.run() + except ImportError: + # 回退到旧版 textual TUI + if not TEXTUAL_AVAILABLE: + print(f"{Fore.RED}TUI 不可用: 请安装 rich (pip install rich){Style.RESET_ALL}") + return + from .tui import XiaoliTUI + app = XiaoliTUI(self) + app.run() def run(self): """运行 CLI""" import uuid self.user_id = str(uuid.getnode()) - print(f"{Fore.GREEN}小狸 Pro-CLI 已启动!{Style.RESET_ALL}") + print(f"{Fore.GREEN}小狸 Pro-CLI v{VERSION} 已启动!{Style.RESET_ALL}") print(f"{Fore.GREEN}用户ID: {self.user_id}{Style.RESET_ALL}") print(f"{Fore.GREEN}输入 '/help' 查看帮助信息{Style.RESET_ALL}") print(f"{Fore.GREEN}输入 '/quit' 退出程序{Style.RESET_ALL}") @@ -1215,6 +1246,30 @@ multi 操作支持一次修改多处: 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() diff --git a/xcli_core/constants.py b/xcli_core/constants.py index dd79a68..d2ce9b6 100644 --- a/xcli_core/constants.py +++ b/xcli_core/constants.py @@ -6,6 +6,10 @@ VIDEO_FRAME_DELAY = 0.04 # 40毫秒 LOVE_FILE_PATH = "love.txt" DEFAULT_MAX_HISTORY = 999999 # 无限制对话历史 +# 版本号(v8.0 基于 v5.4.1 重新立项) +VERSION = "8.0.0" +VERSION_NAME = "小狸 Pro-CLI" + import os import sys import importlib.util diff --git a/xcli_core/memory.py b/xcli_core/memory.py index bde3fe0..6f27ced 100644 --- a/xcli_core/memory.py +++ b/xcli_core/memory.py @@ -22,7 +22,8 @@ class MemoryManager: self.memory_dir = os.path.join(project_dir, "memory") self.memory_file = os.path.join(project_dir, "MEMORY.md") self.chat_dir = os.path.join(project_dir, "chat_history") - self.compress_threshold = 50 # 超过 50 条消息触发压缩 + self.compress_threshold = 50 # 超过 50 条消息触发压缩(兜底) + self.compress_ratio = 0.7 # token 感知压缩阈值:used/context_window >= 0.7 触发 self._ensure_dirs() def _ensure_dirs(self): @@ -299,8 +300,28 @@ class MemoryManager: # 上下文压缩 # ══════════════════════════════════════════════ - def should_compress(self, conversation: list) -> bool: - """判断是否需要压缩上下文""" + def set_compress_ratio(self, ratio: float): + """设置 token 感知压缩阈值(0~1)""" + try: + self.compress_ratio = float(ratio) + except (TypeError, ValueError): + pass + + def should_compress(self, conversation: list, prompt_tokens=None, context_window=None, ratio=None) -> bool: + """ + 判断是否需要压缩上下文。 + 优先级: + 1. token 感知: prompt_tokens/context_window >= ratio 触发(OpenAI 格式可拿到真实 token 数) + 2. 兜底: 消息数超过 compress_threshold + """ + if ratio is None: + ratio = self.compress_ratio + if prompt_tokens and context_window: + try: + if int(context_window) > 0 and (int(prompt_tokens) / int(context_window)) >= ratio: + return True + except (TypeError, ValueError): + pass return len(conversation) > self.compress_threshold def compress_context(self, conversation: list, engine=None) -> list: diff --git a/xcli_core/rich_tui.py b/xcli_core/rich_tui.py new file mode 100644 index 0000000..1cecf44 --- /dev/null +++ b/xcli_core/rich_tui.py @@ -0,0 +1,134 @@ +""" +v8.0 Rich 面板式 TUI + 可截取快照出口 +====================================== +- 通过 cli.tui_output_callback = RichTUI.push 接收逐行输出,渲染为 rich 面板 +- Console(record=True) 快照出口: export_text / export_svg / export_html + → AI 开发 TUI 时能把渲染结果读回自验,补上“看不见渲染”的反馈闭环 + +设计取舍(与 7.0 重构前 textual TUI 的区别): +- 不用 textual 的异步全屏 App,避免与同步 input() 主循环冲突; +- 采用 rich 顺序面板流(一问一答往下渲染),天然支持 record=True 快照; +- 这正是“让 AI 能看见自己写的 TUI”的关键能力。 +""" +import os +from rich.console import Console +from rich.panel import Panel +from rich.text import Text +from rich.box import ROUNDED + +SNAP_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".tui_snapshots" +) + + +class RichTUI: + """rich 面板式 TUI:聊天面板 + 状态栏 + 快照出口。""" + + def __init__(self, cli): + self.cli = cli + # record=True 是关键:渲染过程被记录,可随时导出文本/SVG/HTML 自验 + self.console = Console(record=True, width=100) + self.version = "8.0.0" + self.engine = "" + self.model = "" + self.mode = "普通" + self.used_tokens = None + self.context_window = None + self.msg_count = 0 + self._log = [] + + # ── 状态设置(由 cli 在切换引擎/压缩后刷新)── + def set_status(self, engine=None, model=None, mode=None, + used_tokens=None, context_window=None, msg_count=None): + if engine is not None: + self.engine = engine + if model is not None: + self.model = model + if mode is not None: + self.mode = mode + if used_tokens is not None: + self.used_tokens = used_tokens + if context_window is not None: + self.context_window = context_window + if msg_count is not None: + self.msg_count = msg_count + + def banner(self): + self.console.print(Panel( + Text.from_markup(f"[bold cyan]小狸 Pro-CLI[/] [yellow]v{self.version}[/] · Rich TUI"), + subtitle="[dim]自然语言对话 · /exit 返回 CLI · /snapshot 导出快照[/]", + box=ROUNDED, border_style="cyan")) + self.console.print() + + def _bar(self): + if self.used_tokens and self.context_window: + ratio = min(1.0, self.used_tokens / self.context_window) + filled = int(ratio * 10) + bar = "▓" * filled + "░" * (10 - filled) + tok = f"{self.used_tokens}/{self.context_window} {bar}" + elif self.used_tokens: + tok = f"{self.used_tokens} tokens" + else: + tok = "-" + return (f"[dim]引擎 {self.engine or '-'} · 模型 {self.model or '-'} · " + f"模式 {self.mode} · {tok} · 消息 {self.msg_count}[/]") + + # ── 回调入口:DisplayMixin._output 把每行输出投到这里 ── + def push(self, message): + m = (message or "").strip("\n") + if not m: + return + self._log.append(m) + if len(self._log) > 300: + self._log = self._log[-300:] + # 原始 message 已由 DisplayMixin 产出合法 rich markup,直接打印; + # 个别工具结果含裸方括号可能破坏 markup,失败则降级为纯文本。 + try: + self.console.print(m) + except Exception: + self.console.print(m, markup=False) + + # ── 快照出口(AI 自验渲染的关键能力)── + def snapshot_text(self): + return self.console.export_text(clear=False) + + def snapshot_svg(self, title="小狸 Pro-CLI v8.0 Rich TUI"): + return self.console.export_svg(title=title, clear=False) + + def snapshot_html(self, title="小狸 Pro-CLI v8.0 Rich TUI"): + # rich 新版 export_html 不接受 title 参数,标题作为注释写入 + html = self.console.export_html(clear=False) + return f"\n{html}" + + def save_snapshot(self, stem): + os.makedirs(os.path.dirname(stem), exist_ok=True) + with open(stem + ".txt", "w", encoding="utf-8") as f: + f.write(self.snapshot_text()) + with open(stem + ".svg", "w", encoding="utf-8") as f: + f.write(self.snapshot_svg()) + with open(stem + ".html", "w", encoding="utf-8") as f: + f.write(self.snapshot_html()) + return stem + ".txt", stem + ".svg", stem + ".html" + + # ── 主循环 ── + def run(self): + self.banner() + try: + import readline # 可选:输入历史补全 + except Exception: + pass + while True: + try: + ui = input("> ").strip() + except (EOFError, KeyboardInterrupt): + break + if ui in ("/exit", "/quit"): + break + if ui == "/snapshot": + paths = self.save_snapshot(os.path.join(SNAP_DIR, "live")) + print(f"[快照已保存] {paths[2]}") + continue + if ui: + self.cli.process_conversation(ui) + self.msg_count = len(self.cli.shared_conversation_history) + self.console.print(Text.from_markup(self._bar()))