diff --git a/CHANGELOG.md b/CHANGELOG.md index 594cf03..dd6f8db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,42 @@ --- +## v8.0.4 (2026-08-08) — 稳定性 & 存储升级 + +### 🚀 重大改进 + +- **会话数据改用 SQLite 存储**(替代原先每个会话一个 JSON 文件) + - 新增 `xcli_core/session.py` SQLite 后端:库文件 `chat_history/conversations.db`,启用 WAL 模式、`check_same_thread=False` + - 两张表:`sessions(id,title,created_at,updated_at,engine,model,messages)` 与 `snapshots(name,saved_at,messages)` + - **首次启动自动迁移**:扫描旧的 `chat_history/*.json`,`id`+`messages` 结构写入 `sessions`、纯 `conversation` 结构写入 `snapshots`,迁移后移入 `_migrated_json/` 并写 `.migrated` 标记;迁移包在 try/except 中,绝不阻塞启动 + - `/chat save|list|open` 与 `/resume` 命令改走 SQLite;`memory.py` 关键词搜索同时检索数据库会话,向后兼容未迁移的 JSON + - `SessionManager` 的类名与 `new_session/save/list_sessions/resolve/load` 接口保持不变,旧调用方(`cli_history.py`、`tests/test_session.py`)零改动 + +### 🐛 Bug 修复 / 体验 + +- **去掉「一直打印加载动画」的根源** + - 移除流式显示:`_typeprint` 改为一次整段打印(去掉逐字符 `time.sleep` 打字机循环) + - 移除常驻加载动画:`_generate_response_with_animation` 去掉 `show_animation` 线程、`love_sentences`、`animation_running` 事件与逐帧输出;TTY 仅打印一行静态 `💭 思考中… (按 ESC 取消)`,完成后清除该行 + - 经核查 OpenAI / Ollama 引擎本就用 `stream: False`,「流式」实为打字机 + 逐帧动画,本次一并去除 + +### ✨ 新功能 + +- **展示给用户的消息过长自动截断(仅展示层,存储完整)** + - `cli_display.py` 新增 `_truncate_for_display`:超过 `max_chars=4000` 字符 / `max_lines=160` 行时,保留前若干行与字符,末尾追加 `… (展示已折叠,完整内容共 N 字符 / M 行,已完整保存到会话)` + - 底层会话数据(`messages`)不做任何截断,完整保存 + +### 🛠 工程 + +- **工具调用解析更稳,减少因格式错误浪费 token** + - `_loads_json` 升级为 4 级兜底修复:①标准 → ②未配对反斜杠转义 → ③字符串内未转义换行/制表符(`\n`/`\t` 修正)→ ④多余尾逗号删除(`{...",}`),全局统一复用 + - 新增 `_extract_last_action_json`:扫描所有「成对错括号」对象(字符串感知,追踪 `in_str`/`esc`),返回键集合与 `CONTROL_KEYS={"action","continue","need_continue","think_more","message"}` 相交的最后一个候选;开头即 `[` 纯数组(多次工具调用)直接返回数组;替代原先脆弱的「方法5/6/7」正则抓取 + - `_build_fc_tools` 去除「续轮剥离 Function Calling 工具」逻辑,续轮也始终下发 FC 工具,模型可用结构化调用而非散文 JSON + - 配套独立校验脚本 8 用例全过(含未转义换行、纯数组、尾逗号、正文误触发不应判为工具等边界) + +- 版本号统一更新至 v8.0.4(含 README 中英 badge、`about.txt`、`xcli_core/constants.py` 中央 `VERSION`、TUI banner、LSP/MCP `clientInfo.version`) + +--- + ## v8.0.3 (2026-08-07) — 体验增强 ### ✨ 新功能 diff --git a/README.en.md b/README.en.md index fc1246f..b62f630 100644 --- a/README.en.md +++ b/README.en.md @@ -5,7 +5,7 @@
-
+
diff --git a/README.md b/README.md
index 47dc278..1328bb5 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-
+
diff --git a/about.txt b/about.txt
index ba3c0ca..a6773c3 100644
--- a/about.txt
+++ b/about.txt
@@ -1,7 +1,7 @@
**产品标识**
- **产品名称**:小狸智能体 增强-终端(Xiaoli Pro-CLI)
- **内部版本号**:20260801
-- **发布版本**:发行版8.0.3
+- **发布版本**:发行版8.0.4
**技术规格**
- **主要语言**:简体中文(UTF-8)
diff --git a/lsp_client.py b/lsp_client.py
index 3dc2e17..a696089 100644
--- a/lsp_client.py
+++ b/lsp_client.py
@@ -268,7 +268,7 @@ class LspClient:
"workspace": {"workspaceFolders": True,
"configuration": True},
},
- "clientInfo": {"name": "xiaoli-cli", "version": "8.0.3"},
+ "clientInfo": {"name": "xiaoli-cli", "version": "8.0.4"},
})
if isinstance(result, dict):
self.capabilities = result.get("capabilities", {}) or {}
diff --git a/mcp_client.py b/mcp_client.py
index f367d64..78d7e47 100644
--- a/mcp_client.py
+++ b/mcp_client.py
@@ -153,7 +153,7 @@ class McpClient:
result = self._send("initialize", {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {},
- "clientInfo": {"name": "xiaoli-cli", "version": "8.0.3"},
+ "clientInfo": {"name": "xiaoli-cli", "version": "8.0.4"},
})
# 通知 server 初始化完成(无响应,无需等待)
self._send("notifications/initialized", {}, is_notification=True)
diff --git a/tests/test_session.py b/tests/test_session.py
index f572518..dd9c0c2 100644
--- a/tests/test_session.py
+++ b/tests/test_session.py
@@ -65,15 +65,11 @@ def test_title_generated_from_first_user_msg(tmp_history):
def test_list_sessions_sorted_desc(tmp_history):
mgr = SessionManager(tmp_history)
- # 写入两个会话,并强制 s1 的更新时间更早,制造稳定可排序差
- s1 = mgr.new_session(); mgr.save(s1, [{"role": "user", "content": "旧会话A"}])
- path1 = os.path.join(tmp_history, f"{s1.id}.json")
- with open(path1, "r", encoding="utf-8") as f:
- d = json.load(f)
- d["updated_at"] = "2020-01-01 00:00:00"
- with open(path1, "w", encoding="utf-8") as f:
- json.dump(d, f, ensure_ascii=False)
- s2 = mgr.new_session(); mgr.save(s2, [{"role": "user", "content": "新会话B"}])
+ # 写入两个会话,并用 updated_at 覆盖制造稳定可排序差(SQLite 后端)
+ s1 = mgr.new_session()
+ mgr.save(s1, [{"role": "user", "content": "旧会话A"}], updated_at="2020-01-01 00:00:00")
+ s2 = mgr.new_session()
+ mgr.save(s2, [{"role": "user", "content": "新会话B"}], updated_at="2026-08-08 00:00:00")
sessions = mgr.list_sessions()
assert len(sessions) == 2
# 倒序:更新的在前(updated_at 时间戳更大)
diff --git a/xcli_core/cli_core.py b/xcli_core/cli_core.py
index de61c68..c6beb96 100644
--- a/xcli_core/cli_core.py
+++ b/xcli_core/cli_core.py
@@ -375,16 +375,44 @@ multi 操作支持一次修改多处:
return "\n".join(prompts)
def _loads_json(self, s):
- """json.loads,但容忍未成对出现的反斜杠(Windows 路径 C:\\Users 常见)。
+ """json.loads,带多级容错修复(模型输出的 JSON 常常不规整)。
- 纯 JSON 模式下模型常把路径写成单反斜杠,直接 json.loads 会报非法转义。
- 这里先按标准解析;失败时把「未成对的反斜杠」转义后再试一次。
+ 尝试顺序:
+ 1) 标准解析
+ 2) 未成对的反斜杠转义(Windows 路径 C:\\Users 常见)
+ 3) 字符串内的未转义换行/制表符(多行 args 常见)
+ 4) 去掉对象/数组里多余的尾随逗号(模型高频错误)
"""
try:
return json.loads(s)
except json.JSONDecodeError:
+ pass
+ # 2) 反斜杠修复
+ try:
fixed = re.sub(r'(?= 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)
+ finally:
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
+ except Exception:
+ # 降级:无法检测 ESC,只能等 AI 完成
+ break
# ── AI 响应线程 ──
def generate_in_thread():
@@ -1056,9 +1066,9 @@ multi 操作支持一次修改多处:
finally:
response_ready.set()
- # 启动三个线程
+ # 启动线程(生成 + ESC 检测)
threads = []
- for target in (show_animation, check_for_esc, generate_in_thread):
+ for target in (check_for_esc, generate_in_thread):
t = threading.Thread(target=target, daemon=True)
t.start()
threads.append(t)
@@ -1070,19 +1080,16 @@ multi 操作支持一次修改多处:
# 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请求已取消]"
- # 响应正常返回
+ # 响应正常返回:清除等待行,不做逐字流式打印。
+ # 完整结果由 process_conversation 统一经 _display_response 透出(含展示层截断)。
response = response_result[0]
- if response:
- # 清除动画行后再输出
- if is_tty:
- print("\r" + " " * 80 + "\r", end="", flush=True)
- self._typeprint(response, Fore.CYAN)
+ if is_tty:
+ print("\r" + " " * 80 + "\r", end="", flush=True)
if response and self.shared_conversation_history is not None:
self.shared_conversation_history.append({
@@ -1093,7 +1100,6 @@ multi 操作支持一次修改多处:
return response
finally:
- animation_running.clear()
for t in threads:
t.join(timeout=1)
diff --git a/xcli_core/cli_display.py b/xcli_core/cli_display.py
index 86c729c..7b0b77d 100644
--- a/xcli_core/cli_display.py
+++ b/xcli_core/cli_display.py
@@ -39,9 +39,24 @@ class DisplayMixin:
else:
print(message)
+ def _truncate_for_display(self, content, max_chars=4000, max_lines=160):
+ """仅用于「展示层」的截断:过长时折叠,不影响底层存储的对话数据。"""
+ if not content:
+ return content
+ lines = content.split('\n')
+ if len(lines) > max_lines or len(content) > max_chars:
+ kept = "\n".join(lines[:max_lines])
+ kept = kept[:max_chars]
+ hidden = len(content) - len(kept)
+ note = f"\n\n… (展示已折叠,完整内容共 {len(content)} 字符 / {len(lines)} 行,已完整保存到会话)"
+ return kept + note
+ return content
+
def _display_response(self, content, is_continue=False):
- """统一显示 AI 响应,支持 TUI 和 CLI 模式"""
+ """统一显示 AI 响应,支持 TUI 和 CLI 模式(仅展示层截断,不改存储)"""
user_id_display = f"[用户ID: {self.user_id}]"
+ # 仅展示截断:存储用的 shared_conversation_history 仍是完整内容
+ content = self._truncate_for_display(content)
response_lines = content.split('\n')
if self.tui_output_callback:
for i, line in enumerate(response_lines):
@@ -98,14 +113,14 @@ class DisplayMixin:
self._output(f"{color} {line}{Style.RESET_ALL}")
def _typeprint(self, text, color=None, delay=0.01):
- """逐字输出文本,实现打字机效果"""
- for char in text:
- if color:
- print(f"{color}{char}{Style.RESET_ALL}", end="", flush=True)
- else:
- print(char, end="", flush=True)
- time.sleep(delay)
- print()
+ """输出文本(已取消逐字打字机效果,改为一次性完整输出)。
+
+ 保留方法签名以兼容潜在调用方;不再逐字符 sleep,避免「流式显示」。
+ """
+ if color:
+ print(f"{color}{text}{Style.RESET_ALL}")
+ else:
+ print(text)
# ── 图片显示 ──
diff --git a/xcli_core/cli_history.py b/xcli_core/cli_history.py
index 5a07fe1..dc80249 100644
--- a/xcli_core/cli_history.py
+++ b/xcli_core/cli_history.py
@@ -9,17 +9,17 @@ class HistoryMixin:
"""聊天记录管理相关方法(含 opencode 式会话持久化 /resume)"""
def save_chat_history(self, name):
- """保存当前聊天记录到文件"""
+ """保存当前聊天记录(SQLite 快照)"""
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
+ if not hasattr(self, 'session_manager'):
+ self.init_session_manager()
+ ok = self.session_manager.save_snapshot(
+ name, self.shared_conversation_history.copy())
+ if ok:
+ print(f"{Fore.GREEN}聊天记录已保存为: {name}{Style.RESET_ALL}")
+ return True
+ print(f"{Fore.RED}保存聊天记录失败{Style.RESET_ALL}")
+ return False
except Exception as e:
print(f"{Fore.RED}保存聊天记录失败: {e}{Style.RESET_ALL}")
return False
@@ -27,30 +27,28 @@ class HistoryMixin:
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:
+ if not hasattr(self, 'session_manager'):
+ self.init_session_manager()
+ snaps = self.session_manager.list_snapshots()
+ if not snaps:
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}")
+ for i, (chat_name, saved_at) in enumerate(snaps, 1):
+ print(f"{Fore.GREEN}{i}. {chat_name} (保存时间: {saved_at}){Style.RESET_ALL}")
except Exception as e:
print(f"{Fore.RED}列出聊天记录失败: {e}{Style.RESET_ALL}")
def load_chat_history(self, name):
- """加载指定的聊天记录"""
+ """加载指定的聊天记录(SQLite 快照)"""
try:
- file_path = os.path.join(self.chat_history_dir, f"{name}.json")
- if not os.path.exists(file_path):
+ if not hasattr(self, 'session_manager'):
+ self.init_session_manager()
+ msgs = self.session_manager.load_snapshot(name)
+ if msgs is None:
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.shared_conversation_history = list(msgs)
self._set_shared_conversation_history()
print(f"{Fore.GREEN}已加载聊天记录: {name}{Style.RESET_ALL}")
if self.shared_conversation_history:
diff --git a/xcli_core/constants.py b/xcli_core/constants.py
index aed7bef..1d38302 100644
--- a/xcli_core/constants.py
+++ b/xcli_core/constants.py
@@ -7,7 +7,7 @@ LOVE_FILE_PATH = "love.txt"
DEFAULT_MAX_HISTORY = 999999 # 无限制对话历史
# 版本号(v8.0 基于 v5.4.1 重新立项)
-VERSION = "8.0.3"
+VERSION = "8.0.4"
VERSION_NAME = "小狸 Pro-CLI"
import os
diff --git a/xcli_core/memory.py b/xcli_core/memory.py
index e418d86..1c8eb49 100644
--- a/xcli_core/memory.py
+++ b/xcli_core/memory.py
@@ -248,7 +248,7 @@ class MemoryManager:
md_file, keyword_lower, name, limit - len(results)
))
- # 搜索聊天记录
+ # 搜索聊天记录(遗留 JSON 文件,兼容迁移前的历史)
for json_file in sorted(glob.glob(os.path.join(self.chat_dir, "*.json")), reverse=True):
if len(results) >= limit:
break
@@ -257,6 +257,42 @@ class MemoryManager:
json_file, keyword_lower, name, limit - len(results)
))
+ # 搜索聊天记录(SQLite conversations.db,迁移后的主存储)
+ db_path = os.path.join(self.chat_dir, "conversations.db")
+ if os.path.exists(db_path):
+ try:
+ import sqlite3 as _sqlite3
+ conn = _sqlite3.connect(db_path)
+ conn.row_factory = _sqlite3.Row
+ rows = conn.execute(
+ "SELECT id, messages FROM sessions "
+ "UNION ALL SELECT name AS id, messages FROM snapshots"
+ ).fetchall()
+ conn.close()
+ for row in rows:
+ if len(results) >= limit:
+ break
+ try:
+ msgs = json.loads(row["messages"] or "[]")
+ except (json.JSONDecodeError, ValueError, TypeError):
+ msgs = []
+ text = "\n".join(
+ str(m.get("content", "")) for m in msgs
+ if isinstance(m, dict)
+ )
+ source = f"chat/{row['id']}"
+ for i, line in enumerate(text.split("\n"), 1):
+ if keyword_lower in line.lower():
+ results.append({
+ "source": source,
+ "line": i,
+ "snippet": line.strip()[:120],
+ })
+ if len(results) >= limit:
+ break
+ except Exception:
+ pass
+
return results[:limit]
def _search_file(self, path: str, keyword: str, source: str, limit: int) -> List[Dict]:
diff --git a/xcli_core/rich_tui.py b/xcli_core/rich_tui.py
index 2dc29c5..2372c26 100644
--- a/xcli_core/rich_tui.py
+++ b/xcli_core/rich_tui.py
@@ -28,7 +28,7 @@ class RichTUI:
self.cli = cli
# record=True 是关键:渲染过程被记录,可随时导出文本/SVG/HTML 自验
self.console = Console(record=True, width=100)
- self.version = "8.0.3"
+ self.version = "8.0.4"
self.engine = ""
self.model = ""
self.mode = "普通"
diff --git a/xcli_core/session.py b/xcli_core/session.py
index da0457d..f6f7325 100644
--- a/xcli_core/session.py
+++ b/xcli_core/session.py
@@ -1,30 +1,22 @@
-"""会话(Session)持久化与恢复。
+"""会话(Session)持久化与恢复 —— SQLite 存储后端。
把 openai/ollama/manual 等引擎的对话历史(self.shared_conversation_history)
-按「会话」维度持久化到 chat_history/