fix(storage): 兼容消息中的 NUL 字符
This commit is contained in:
parent
afdcf46577
commit
cd6a76cba9
|
|
@ -5,6 +5,10 @@
|
||||||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||||
|
|
||||||
|
## 0.60.17 — 2026-07-31
|
||||||
|
|
||||||
|
- 修复读取论文、PDF 等外部内容时,个别不可见字符可能导致工具结果保存失败并中断对话的问题。
|
||||||
|
|
||||||
## 0.60.16 — 2026-07-31
|
## 0.60.16 — 2026-07-31
|
||||||
|
|
||||||
- 从 platform 打开历史对话时会等待本次登录凭证签发完成后再加载,不再因浏览器缓存的旧登录状态一直停在对话和文件“加载中”。
|
- 从 platform 打开历史对话时会等待本次登录凭证签发完成后再加载,不再因浏览器缓存的旧登录状态一直停在对话和文件“加载中”。
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||||
|
|
||||||
最后更新:2026-07-31(platform 历史 task 稳定进入 + 新建任务名称可选,bump 0.60.16)
|
最后更新:2026-07-31(消息 JSONB NUL 字符兼容,bump 0.60.17)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
### 2026-07-31
|
### 2026-07-31
|
||||||
|
|
||||||
|
- **07-31 / 0.60.17 / 消息 JSONB NUL 字符兼容**:外部工具从论文、PDF 等来源提取的文本可能夹带真实 NUL,Python JSON 会编码为 PostgreSQL `jsonb` 无法转换成 text 的 `\u0000`,导致 tool message INSERT 回滚并中断 run。新增递归清洗并在 `Message.payload` ORM 写入边界统一守门,`Session.append` 同步清洗内存上下文,保留制表符、换行及其余 Unicode;无 schema、migration 或存量数据改动。相关 6 项单元测试、Python 编译及 diff 格式检查通过;未设置 `ZCBOT_TEST_DB_URL`,未连接数据库。
|
||||||
- **07-31 / 0.60.16 / platform 历史 task 稳定进入 + 新建任务名称可选**:普通 platform iframe 不再复用 localStorage 缓存 JWT 抢跑,始终等待父页面本次 `zcbot-token` 后再进入初始 task,消除旧 token 401 后界面永久停在“加载中”的时序;企微/App fragment 登录路径不变。`POST /v1/tasks` 省略/留空 name 且提供 working_dir 时自动以“新对话”占位并启用首条消息标题生成,旧 `auto_title` 语义继续兼容;快速新对话不再发送冗余字段,自定义创建统一为工作目录必填、任务名选填,并换用可搜索/可创建的目录组合框。27 项无 DB 测试、Python 编译、3 个 JavaScript 语法及 diff 格式检查通过;DB 路由用例已补,未配置 `ZCBOT_TEST_DB_URL` 时安全跳过。
|
- **07-31 / 0.60.16 / platform 历史 task 稳定进入 + 新建任务名称可选**:普通 platform iframe 不再复用 localStorage 缓存 JWT 抢跑,始终等待父页面本次 `zcbot-token` 后再进入初始 task,消除旧 token 401 后界面永久停在“加载中”的时序;企微/App fragment 登录路径不变。`POST /v1/tasks` 省略/留空 name 且提供 working_dir 时自动以“新对话”占位并启用首条消息标题生成,旧 `auto_title` 语义继续兼容;快速新对话不再发送冗余字段,自定义创建统一为工作目录必填、任务名选填,并换用可搜索/可创建的目录组合框。27 项无 DB 测试、Python 编译、3 个 JavaScript 语法及 diff 格式检查通过;DB 路由用例已补,未配置 `ZCBOT_TEST_DB_URL` 时安全跳过。
|
||||||
- **07-31 / 0.60.15 / 嵌入初始 task 直接进入工作目录**:platform iframe、企业微信和 App 携带 `task_id` 首次进入时,由 `enterApp` 直接选择目标 task,跳过新对话草稿及 user_root 文件面板初始化,随后沿既有 `selectTask` 链路同步 `working_dir`;401 重签仍不重复定位,普通登录空态不变。JavaScript 语法、7 项静态前端测试及 diff 格式检查通过;无 API、schema、migration 或依赖变化。
|
- **07-31 / 0.60.15 / 嵌入初始 task 直接进入工作目录**:platform iframe、企业微信和 App 携带 `task_id` 首次进入时,由 `enterApp` 直接选择目标 task,跳过新对话草稿及 user_root 文件面板初始化,随后沿既有 `selectTask` 链路同步 `working_dir`;401 重签仍不重复定位,普通登录空态不变。JavaScript 语法、7 项静态前端测试及 diff 格式检查通过;无 API、schema、migration 或依赖变化。
|
||||||
- **07-31 / 0.60.14 / 清空对话后自动标题重生成**:tasks 新增 `title_source`(auto/manual/fixed)与 `auto_title_version`(0024 migration);快速新对话的自动标题在清空后重置为“新对话”,下一条消息重新生成,人工重命名及微信/企微/调度固定标题保持不变。版本闸阻止清空前在途标题跨轮次写回;历史任务无法可靠判定“自动命名后又人工改名”,除当前 pending 外保守回填 manual。相关 18 项无 DB 测试、Python 编译、Alembic 单 head 与 diff 格式检查通过;DB 路由用例已补,未配置 `ZCBOT_TEST_DB_URL` 时按安全门控跳过。
|
- **07-31 / 0.60.14 / 清空对话后自动标题重生成**:tasks 新增 `title_source`(auto/manual/fixed)与 `auto_title_version`(0024 migration);快速新对话的自动标题在清空后重置为“新对话”,下一条消息重新生成,人工重命名及微信/企微/调度固定标题保持不变。版本闸阻止清空前在途标题跨轮次写回;历史任务无法可靠判定“自动命名后又人工改名”,除当前 pending 外保守回填 manual。相关 18 项无 DB 测试、Python 编译、Alembic 单 head 与 diff 格式检查通过;DB 路由用例已补,未配置 `ZCBOT_TEST_DB_URL` 时按安全门控跳过。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.60.16"
|
__version__ = "0.60.17"
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from uuid import UUID
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
|
|
||||||
from .storage import session_scope
|
from .storage import session_scope
|
||||||
from .storage.models import Message, Task
|
from .storage.models import Message, Task, sanitize_jsonb_nul
|
||||||
from .file_store import atomic_write_text
|
from .file_store import atomic_write_text
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -69,7 +69,9 @@ class Session:
|
||||||
返回值:非 system → 新 row 的 message_id(供 loop 给 usage_events 关联用);
|
返回值:非 system → 新 row 的 message_id(供 loop 给 usage_events 关联用);
|
||||||
system 消息不入库,返 None。旧调用方忽略返回值不影响行为。
|
system 消息不入库,返 None。旧调用方忽略返回值不影响行为。
|
||||||
"""
|
"""
|
||||||
msg_dict = _to_dict(msg)
|
# 与 Message.payload 的 ORM 守门保持一致,使当前 run 的内存上下文和落库值
|
||||||
|
# 完全相同;外部工具提取文本偶尔会携带 PostgreSQL JSONB 不支持的 NUL。
|
||||||
|
msg_dict = sanitize_jsonb_nul(_to_dict(msg))
|
||||||
self.messages.append(msg_dict)
|
self.messages.append(msg_dict)
|
||||||
if msg_dict.get("role") == "system":
|
if msg_dict.get("role") == "system":
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,28 @@ from sqlalchemy import (
|
||||||
func,
|
func,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
|
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, validates
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_jsonb_nul(value: Any) -> Any:
|
||||||
|
"""递归移除 PostgreSQL JSONB 无法表示的 NUL 字符。
|
||||||
|
|
||||||
|
Python 的 JSON 编码器会把 ``\x00`` 写成 ``\u0000``,但 PostgreSQL 的
|
||||||
|
``jsonb`` 最终以数据库编码的 text 表示字符串,因而拒绝该转义。消息 payload
|
||||||
|
会包含外部工具提取的文本,所以在统一 ORM 边界做无损于其余 Unicode 的清洗。
|
||||||
|
"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.replace("\x00", "")
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
sanitize_jsonb_nul(key): sanitize_jsonb_nul(item)
|
||||||
|
for key, item in value.items()
|
||||||
|
}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [sanitize_jsonb_nul(item) for item in value]
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return tuple(sanitize_jsonb_nul(item) for item in value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
|
|
@ -162,6 +183,11 @@ class Message(Base):
|
||||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@validates("payload")
|
||||||
|
def _sanitize_payload_nul(self, _key: str, value: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""保证所有 ORM 消息写入都满足 PostgreSQL JSONB 的字符约束。"""
|
||||||
|
return sanitize_jsonb_nul(value)
|
||||||
|
|
||||||
|
|
||||||
class UsageEvent(Base):
|
class UsageEvent(Base):
|
||||||
"""per-event 用量记账(0006 v2 形态)。
|
"""per-event 用量记账(0006 v2 形态)。
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import unittest
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from unittest.mock import patch
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from core.session import Session
|
||||||
|
from core.storage.models import Message, sanitize_jsonb_nul
|
||||||
|
|
||||||
|
|
||||||
|
class MessagePayloadSanitizationTests(unittest.TestCase):
|
||||||
|
def test_recursively_removes_nul_and_preserves_other_text(self) -> None:
|
||||||
|
payload = {
|
||||||
|
"role": "tool",
|
||||||
|
"content": "In 10\tH \x00unnamed\n中文",
|
||||||
|
"nested\x00key": ["a\x00b", {"value": "c\x00d"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned = sanitize_jsonb_nul(payload)
|
||||||
|
|
||||||
|
self.assertEqual(cleaned["content"], "In 10\tH unnamed\n中文")
|
||||||
|
self.assertEqual(cleaned["nestedkey"], ["ab", {"value": "cd"}])
|
||||||
|
self.assertEqual(payload["content"], "In 10\tH \x00unnamed\n中文")
|
||||||
|
|
||||||
|
def test_message_model_sanitizes_payload_on_assignment(self) -> None:
|
||||||
|
message = Message(
|
||||||
|
task_id=uuid4(),
|
||||||
|
idx=0,
|
||||||
|
payload={
|
||||||
|
"role": "tool",
|
||||||
|
"content": "before\x00after",
|
||||||
|
"tool_calls": ({"arguments": "x\x00y"},),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(message.payload["content"], "beforeafter")
|
||||||
|
self.assertEqual(
|
||||||
|
message.payload["tool_calls"],
|
||||||
|
({"arguments": "xy"},),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_session_keeps_sanitized_value_in_memory_and_database_row(self) -> None:
|
||||||
|
class FakeDbSession:
|
||||||
|
row: Message
|
||||||
|
|
||||||
|
def add(self, row: Message) -> None:
|
||||||
|
self.row = row
|
||||||
|
|
||||||
|
def flush(self) -> None:
|
||||||
|
self.row.message_id = uuid4()
|
||||||
|
|
||||||
|
fake_db = FakeDbSession()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def fake_session_scope():
|
||||||
|
yield fake_db
|
||||||
|
|
||||||
|
session = Session(task_id=uuid4())
|
||||||
|
with patch("core.session.session_scope", side_effect=fake_session_scope):
|
||||||
|
session.append({"role": "tool", "content": "before\x00after"})
|
||||||
|
|
||||||
|
expected = {"role": "tool", "content": "beforeafter"}
|
||||||
|
self.assertEqual(session.messages, [expected])
|
||||||
|
self.assertEqual(fake_db.row.payload, expected)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Reference in New Issue