feat(v8.0): 基于 v5.4.1 重构立项 v8.0.0

- 版本立项: 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 <companion|work> 切换。
- requirements 加 rich;.gitignore 忽略 .tui_snapshots/。
This commit is contained in:
shuiyu67 committed 2026-08-01 09:28:39 +08:00
1 parent b8faaae0c6
commit 79957eba0f
10 files changed
+359 -25

No files matched your search

+74 -19
View File
@@ -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 <companion|work>{Style.RESET_ALL}")
continue
if user_input.startswith('/safe'):
parts = user_input.split()
safety = get_safety()
+4
View File
@@ -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
+24 -3
View File
@@ -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:
+134
View File
@@ -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"<!-- {title} -->\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()))