release: v8.0.4 — 会话 SQLite 存储 + 去加载动画 + 展示截断 + 工具调用解析加固

- 会话数据改用 SQLite(chat_history/conversations.db,WAL):sessions / snapshots 两表,
  首次启动自动迁移旧 JSON,/chat 与 /resume 改走 SQLite,memory 搜索兼容数据库
- 去掉流式显示(_typeprint 改整段打印)与常驻加载动画(移除 show_animation 线程及逐帧输出)
- 展示给用户的消息过长自动截断(仅展示层,底层 messages 完整保存)
- 工具调用解析加固:_loads_json 四级兜底修复;新增 _extract_last_action_json 成对错括号扫描;
  续轮也下发 Function Calling 工具,减少长文本格式错误浪费 token
- 版本号统一更新至 8.0.4(README/about.txt/constants/TUI banner/LSP·MCP clientInfo)
- 12 项 session 单测 + 8 项 JSON 提取边界用例全部通过

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
This commit is contained in:
shuiyu67andWorkBuddy committed 2026-08-08 00:54:18 +08:00
1 parent 66de8eea13
commit 749fb3f91c
15 files changed
+510 -249

No files matched your search

+145 -139
View File
@@ -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'(?<!\\)\\(?!\\\\)', r'\\\\', s)
return json.loads(fixed)
except json.JSONDecodeError:
pass
# 3) 字符串内未转义换行/制表符修复
try:
repaired = re.sub(
r'"([^"]*(?:\n[^"]*)*)"',
lambda m: '"' + (m.group(1)
.replace('\\', '\\\\')
.replace('\n', '\\n')
.replace('\r', '\\r')
.replace('\t', '\\t')
.replace('"', '\\"')) + '"',
s, flags=re.DOTALL)
return json.loads(repaired)
except json.JSONDecodeError:
pass
# 4) 尾随逗号修复
try:
trimmed = re.sub(r',\s*([}\]])', r'\1', s)
return json.loads(trimmed)
except json.JSONDecodeError:
raise
def _parse_mixed_response(self, response):
"""
@@ -465,69 +493,87 @@ multi 操作支持一次修改多处:
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
# 方法5: 稳健抽取最后一个「控制指令」JSON(兼容长文本后格式不规整的情况)。
# 结构化 FC 已是主路径;这里是纯 JSON 兜底:不再依赖脆弱的 marker 匹配,
# 而是扫描所有平衡括号的 JSON 对象,取最后一个含控制键的,最大限度避免
# 「长篇文本 + 格式错乱的工具调用」被解析失败、白白浪费 token。
text_before, json_data = self._extract_last_action_json(response)
if json_data is not None:
return text_before, json_data
# 方法6: 通用JSON对象查找
potential_jsons = re.findall(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', response, re.DOTALL)
for json_str in potential_jsons:
return response, None
def _extract_last_action_json(self, response):
"""在可能夹杂长篇正文的响应里,稳健抽取最后一个控制指令 JSON。
控制指令指含以下任一字段的 JSON 对象:
action / continue / need_continue / think_more / message
返回 (text_before, json_data);找不到返回 (response, None)。
"""
# 整段就是 JSON 数组(多个工具调用)时直接返回,保留并发执行能力
stripped = response.strip()
if stripped.startswith('['):
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
arr = self._loads_json(stripped)
if isinstance(arr, list) and arr:
return "", arr
except json.JSONDecodeError:
continue
pass
# 方法7: 批量JSON
CONTROL_KEYS = {"action", "continue", "need_continue", "think_more", "message"}
candidates = [] # (start_index, parsed_dict)
n = len(response)
i = 0
while i < n:
if response[i] == '{':
# 从 i 起做括号/字符串感知的平衡解析,取出一个完整 JSON 对象
depth = 0
in_str = False
esc = False
end = -1
for j in range(i, n):
c = response[j]
if in_str:
if esc:
esc = False
elif c == '\\':
esc = True
elif c == '"':
in_str = False
continue
if c == '"':
in_str = True
elif c == '{':
depth += 1
elif c == '}':
depth -= 1
if depth == 0:
end = j
break
if end != -1:
frag = response[i:end + 1]
try:
obj = self._loads_json(frag)
except json.JSONDecodeError:
obj = None
if isinstance(obj, dict) and (set(obj.keys()) & CONTROL_KEYS):
candidates.append((i, obj))
i = end + 1
continue
i += 1
if candidates:
start, obj = candidates[-1]
return response[:start].strip(), obj
# 退路:整段若为 JSON 数组(如 [{"action":"use_tool"}, ...])
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:
stripped = response.strip()
if stripped.startswith('['):
arr = self._loads_json(stripped)
if isinstance(arr, list) and arr:
return "", arr
except json.JSONDecodeError:
pass
return response, None
@@ -922,10 +968,9 @@ multi 操作支持一次修改多处:
return None
except Exception:
return None
# 工具结果续轮:模型只需决定「继续/收尾」,不需要 FC schema,省下每轮重发的 token。
# 若模型仍想调工具,走纯 JSON action(无需 schema 即可),行为等价、成本更低。
if current_input and str(current_input).lstrip().startswith("工具执行结果"):
return None
# 工具结果续轮也下发 FC 工具:让模型在续轮里继续用结构化 function calling
# 调用工具,避免把工具调用写成散文 JSON(长文本后格式错乱会浪费 token)。
# 仅对带 _fc_supported() 的引擎下发 tools(Manual 等不支持的引擎不传 tools)。
try:
from .fc_tools import mcp_to_openai_tools, prune_tools
all_tools = mcp_to_openai_tools(self.liugin_manager)
@@ -956,90 +1001,55 @@ multi 操作支持一次修改多处:
response = self._call_engine_with_tools(current_input, system_prompt, fc_tools)
return response
# CLI 模式:带动画和跨平台 ESC 取消
animation_running = threading.Event()
animation_running.set()
# CLI 模式:去掉流式显示与逐帧加载动画,改为「一次性等待 + 完整输出」。
# 仍用线程跑生成以支持 ESC 取消,但不再喷任何中间动画/情话帧。
esc_pressed = threading.Event()
response_ready = threading.Event()
response_result = [None] # 用列表存储,方便线程内修改
# 是否真实交互终端:非 TTY(管道 / 日志镜像 / 聊天前端捕获 stdout)时,
# \r 无法原地覆盖,逐帧动画会污染输出流,必须禁用。
# 是否真实交互终端:非 TTY(管道 / 日志镜像 / 聊天前端捕获 stdout)时
# 不打印任何等待提示,避免污染捕获流。
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()
and not line.strip().lower().startswith(('http://', 'https://'))]
except Exception:
pass
if not love_sentences:
love_sentences = ["AI正在思考中...", "请稍等片刻...", "正在处理您的请求..."]
if is_tty:
# 仅一行静态提示,非动画
print(f"{Fore.MAGENTA}💭 思考中… (按 ESC 取消){Style.RESET_ALL}", end="", flush=True)
# ── 跨平台 ESC 检测 ──
def check_for_esc():
"""跨平台 ESC 键检测"""
import platform
if platform.system() == 'Windows':
try:
import msvcrt
while animation_running.is_set():
while not response_ready.is_set() and not esc_pressed.is_set():
if platform.system() == 'Windows':
try:
import msvcrt
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)
except ImportError:
break
else:
# Linux / macOS — 用 select 做非阻塞读取
import select
try:
tty.setcbreak(fd) # cbreak 模式:无需回车即可读取
while animation_running.is_set():
if select.select([sys.stdin], [], [], 0.05)[0]:
ch = sys.stdin.read(1)
import sys as _sys
import tty, termios
fd = _sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setcbreak(fd) # cbreak 模式:无需回车即可读取
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(f"💭 {random.choice(love_sentences)}", 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)
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)