diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ab392b..909dc99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.60.17 — 2026-07-31 + +- 修复读取论文、PDF 等外部内容时,个别不可见字符可能导致工具结果保存失败并中断对话的问题。 + ## 0.60.16 — 2026-07-31 - 从 platform 打开历史对话时会等待本次登录凭证签发完成后再加载,不再因浏览器缓存的旧登录状态一直停在对话和文件“加载中”。 diff --git a/PROGRESS.md b/PROGRESS.md index 6f852ad..32cb1fd 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `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 +- **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.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` 时按安全门控跳过。 diff --git a/core/__init__.py b/core/__init__.py index 7c6edab..82035d8 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.60.16" +__version__ = "0.60.17" diff --git a/core/session.py b/core/session.py index 8b0265b..8e6270c 100644 --- a/core/session.py +++ b/core/session.py @@ -17,7 +17,7 @@ from uuid import UUID from sqlalchemy import delete, func, select 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 @@ -69,7 +69,9 @@ class Session: 返回值:非 system → 新 row 的 message_id(供 loop 给 usage_events 关联用); 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) if msg_dict.get("role") == "system": return None diff --git a/core/storage/models.py b/core/storage/models.py index 2331269..08e85ce 100644 --- a/core/storage/models.py +++ b/core/storage/models.py @@ -31,7 +31,28 @@ from sqlalchemy import ( func, ) 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): @@ -162,6 +183,11 @@ class Message(Base): 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): """per-event 用量记账(0006 v2 形态)。 diff --git a/tests/test_message_payload.py b/tests/test_message_payload.py new file mode 100644 index 0000000..ce82b91 --- /dev/null +++ b/tests/test_message_payload.py @@ -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()