diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ea76bc..e743c03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.60.23 — 2026-08-03 + +- 修复已有 Word、PowerPoint、Excel 文件转 PDF 时可能找不到文件或错误宣称格式可用的问题;现在会根据服务器实际具备的转换组件提供能力,并在组件缺失时给出明确提示。 + ## 0.60.22 — 2026-08-03 - 生成通用报告 PDF 时会直接使用平台渲染能力,不再临时安装转换组件;已有 Word、PowerPoint、Excel 等文件也可以直接转换为 PDF。 diff --git a/PROGRESS.md b/PROGRESS.md index 73c9729..103ac8c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-08-03(通用 PDF 路由 + host Office 转换,bump 0.60.22) +最后更新:2026-08-03(Office→PDF 组件感知 + 路径兼容,bump 0.60.23) --- @@ -23,6 +23,7 @@ ### 2026-08-03 +- **08-03 / 0.60.23 / Office→PDF 组件感知 + 展示路径兼容**:生产 task `92ac20cf` 暴露两层问题:上传消息给出 user-root 相对的 `测试pdf/x.doc`,host tool 又按 task_dir 拼接导致首次找不到;改用裸文件名后,host 仅安装 `libreoffice-impress` 却因只检测 `soffice` 而错误宣称支持 DOC,最终 Writer 导入返回 `source file could not be loaded`。现 `office_to_pdf` 同时解析 task 相对、user-root 展示路径与 `/workspace` 路径;Debian/Ubuntu 按 Writer/Calc/Impress 实际安装包缓存支持后缀,工具 schema 只声明可用格式,缺组件在启动转换前给出明确管理员提示。RUN bootstrap 与故障表同步要求 host 安装三组件;相关 30 项 unittest、Python 编译及 diff 格式检查通过,本机无 LibreOffice,真实 `.doc/.xlsx/.pptx` 冒烟留部署 host;无 schema、migration、HTTP API 或 Python 依赖变化,无需重建沙箱镜像。 - **08-03 / 0.60.22 / 通用 PDF 路由修复 + host Office 转换**:复盘生产 task 发现通用 system prompt 只展示 `report --format docx`,模型遂误判平台渲染器只能出 Word,进而在 Docker 内探测 LibreOffice、临时安装 WeasyPrint 并手写转换脚本;现补齐 `report --format pdf` 唯一入口,新写 Markdown 报告直接由沙盒 Chromium 出 PDF。新增按 host `soffice` 实际可用性注册的 `office_to_pdf` typed tool,已有 DOCX/PPTX/XLSX/ODF 文件通过 backend host LibreOffice 转换,复用 `/workspace`→user_root 路径翻译并强制用户目录边界,Docker 内不再探测宿主命令。相关 34 项 unittest、Python 编译及 diff 格式检查通过;本机未安装 LibreOffice,真实 soffice 冒烟留部署 host 执行;无 schema、migration、HTTP API 或依赖变化,新增 host 工具无需重建沙箱镜像。 ### 2026-07-31 diff --git a/RUN.md b/RUN.md index 638a33e..ddb40af 100644 --- a/RUN.md +++ b/RUN.md @@ -313,8 +313,8 @@ sudo chmod 600 /opt/zcbot/.env sudo chown zcbot:zcbot /opt/zcbot/.env # PPTX 在线预览(DESIGN §8.3):web 进程(本 host,非 sandbox)调 soffice 把 .pptx 转 PDF。 -# 装 LibreOffice Impress + 中文字体(缺则前端 .pptx 自动回退到"下载查看",不报错)。 -sudo apt-get install -y --no-install-recommends libreoffice-impress fonts-noto-cjk +# 装 Writer/Calc/Impress + 中文字体(PPTX 预览及 office_to_pdf 共用 host soffice)。 +sudo apt-get install -y --no-install-recommends libreoffice-writer libreoffice-calc libreoffice-impress fonts-noto-cjk ``` ### unit 文件 `/etc/systemd/system/zcbot.service` @@ -898,7 +898,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_" /opt | `seedream` tool 没出现在对话里 | `.env` 没设 `ARK_API_KEY`,build_agent 跳过注册。设了重启 web 即可;无需迁移、无需 DB 改动 | | 图像模型选了「GPT 生图」却仍走 seedream / 报错 | `.env` 没设 `UNIFYLLM_API_KEY`(gpt_image tool 未注册,静默 fallback 豆包);或服务器没代理出口(直连 unifyllm.ai TLS 失败)。跑 `scripts/diag_unifyllm.py` 验证连通后重启 web | | `document_*` tool 没出现在对话里 | `.env` 没设 `DOCUMENT_SEARCH_API_KEY`,build_agent 跳过注册。设了重启 web 即可;key 不进入 sandbox。 | -| 文件区点 `.pptx` 弹"服务器未装 LibreOffice"/ 直接回退下载 | web host(非 sandbox)没装 soffice。`sudo apt-get install -y --no-install-recommends libreoffice-impress fonts-noto-cjk` 后**重启 web**。dev(Windows)`winget install TheDocumentFoundation.LibreOffice`。验:`soffice --version` 或 `python -c "from web.pptx_render import find_soffice; print(find_soffice())"` | +| 文件区点 `.pptx` 弹"服务器未装 LibreOffice"/ `office_to_pdf` 提示缺 Writer/Calc/Impress | web host(非 sandbox)缺对应 LibreOffice 组件。`sudo apt-get install -y --no-install-recommends libreoffice-writer libreoffice-calc libreoffice-impress fonts-noto-cjk` 后**重启 web**。dev(Windows)`winget install TheDocumentFoundation.LibreOffice`。验:`soffice --version`,再分别拿一个 `.docx/.xlsx/.pptx` 调 `office_to_pdf` 冒烟。 | | `.pptx` 预览首次慢几秒 | 正常 —— soffice 冷启 + 转换 ~2-4s,转完缓存到源同目录 `.preview/..pdf`,再点即时。源文件一改(mtime/size 变)hash 变、自动重转 | | `mp_*` tool 没出现在对话里 | `.env` 没设 `MP_API_KEY`,build_agent 跳过注册。设了重启 web 即可;Materials Project 联网查询走 host-side tool,离线 pymatgen 不受影响。 | | 豆包调价了 | 改 `config/media/doubao.yaml` 的 `price_cny_per_image` 一行 → 重启 web。**历史 usage_events 不受影响**(units jsonb 里有当时单价 snapshot,聚合查仍按旧价);新写入按新价。涨价瞬间到改 YAML 中间这段记账偏低,开发期接受 | diff --git a/core/__init__.py b/core/__init__.py index 8431600..5542092 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.60.22" +__version__ = "0.60.23" diff --git a/core/agent_builder.py b/core/agent_builder.py index 97cfa89..d47f7d5 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -404,8 +404,8 @@ def _build_system_prompt( if allow_working_dir_rename else "" ) office_pdf_hint = ( - "已有 DOCX/PPTX/XLSX 等 Office 文件需要转 PDF 时,调用 host-side " - "`office_to_pdf`;LibreOffice 在 backend host,不在 Docker shell 内探测 `soffice`。\n" + "已有 Office 文件需要转 PDF 时,仅当 host-side `office_to_pdf` 的工具说明列出该格式" + "才调用;LibreOffice 在 backend host,不在 Docker shell 内探测 `soffice`。\n" if office_to_pdf_available else "" ) prompt += ( diff --git a/tests/test_office_to_pdf.py b/tests/test_office_to_pdf.py index 9d11265..b8b5eef 100644 --- a/tests/test_office_to_pdf.py +++ b/tests/test_office_to_pdf.py @@ -10,6 +10,12 @@ from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from tools.office_to_pdf import OfficeToPdfTool +from tools import office_to_pdf as office_module + + +_ALL_FORMATS = frozenset({ + ".doc", ".docx", ".odt", ".xls", ".xlsx", ".ods", ".ppt", ".pptx", ".odp", +}) class TestOfficeToPdfTool(unittest.TestCase): @@ -28,12 +34,14 @@ class TestOfficeToPdfTool(unittest.TestCase): (outdir / "source.pdf").write_bytes(b"%PDF-1.4 test") return subprocess.CompletedProcess(cmd, 0, b"", b"") - with patch("tools.office_to_pdf.find_soffice", return_value="soffice"), patch( + with patch( + "tools.office_to_pdf.office_supported_suffixes", return_value=_ALL_FORMATS + ), patch("tools.office_to_pdf.find_soffice", return_value="soffice"), patch( "tools.office_to_pdf.subprocess.run", side_effect=fake_run ): result = self._tool(root, task).execute( - source="/workspace/task/source.docx", - output="converted/final.pdf", + source="task/source.docx", + output="task/converted/final.pdf", ) out = task / "converted" / "final.pdf" @@ -57,6 +65,36 @@ class TestOfficeToPdfTool(unittest.TestCase): result = self._tool(root, task).execute(source="notes.md") self.assertIn("[Error] unsupported Office format", result) + def test_reports_missing_writer_component_before_conversion(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + task = root / "task" + task.mkdir() + (task / "legacy.doc").write_bytes(b"doc") + with patch( + "tools.office_to_pdf.office_supported_suffixes", + return_value=frozenset({".ppt", ".pptx", ".odp"}), + ), patch("tools.office_to_pdf.subprocess.run") as run: + tool = self._tool(root, task) + result = tool.execute(source="legacy.doc") + + self.assertIn("lacks the Writer component", result) + self.assertIn("PPT", tool.description) + self.assertNotIn("DOC,", tool.description) + run.assert_not_called() + + def test_debian_component_detection_limits_advertised_formats(self): + office_module.office_supported_suffixes.cache_clear() + try: + with patch("tools.office_to_pdf.find_soffice", return_value="/usr/bin/soffice"), patch( + "tools.office_to_pdf._debian_office_families", + return_value=frozenset({"Impress"}), + ): + supported = office_module.office_supported_suffixes() + self.assertEqual(supported, frozenset({".ppt", ".pptx", ".odp"})) + finally: + office_module.office_supported_suffixes.cache_clear() + if __name__ == "__main__": unittest.main() diff --git a/tools/office_to_pdf.py b/tools/office_to_pdf.py index 330b81a..e5d9297 100644 --- a/tools/office_to_pdf.py +++ b/tools/office_to_pdf.py @@ -6,9 +6,11 @@ DOCX/PPTX/XLSX/ODF 文件交给 host `soffice --headless` 转成 PDF。新写的 """ from __future__ import annotations +import os import shutil import subprocess import tempfile +from functools import lru_cache from pathlib import Path from typing import Optional from uuid import uuid4 @@ -17,35 +19,82 @@ from tools.base import FileOutOfBounds, Tool from web.pptx_render import SofficeNotFoundError, find_soffice -_ALLOWED_SUFFIXES = { - ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".odt", ".odp", ".ods", +_FAMILY_SUFFIXES = { + "Writer": frozenset({".doc", ".docx", ".odt"}), + "Calc": frozenset({".xls", ".xlsx", ".ods"}), + "Impress": frozenset({".ppt", ".pptx", ".odp"}), +} +_KNOWN_SUFFIXES = frozenset().union(*_FAMILY_SUFFIXES.values()) +_DEBIAN_PACKAGES = { + "Writer": ("libreoffice-writer", "libreoffice-writer-nogui"), + "Calc": ("libreoffice-calc", "libreoffice-calc-nogui"), + "Impress": ("libreoffice-impress", "libreoffice-impress-nogui"), } _DEFAULT_TIMEOUT = 120 -def soffice_available() -> bool: - """Host 装有 LibreOffice 时才向 agent 注册工具。""" +def _dpkg_package_installed(package: str) -> bool: try: - find_soffice() - except SofficeNotFoundError: + proc = subprocess.run( + ["dpkg-query", "-W", "-f=${Status}", package], + capture_output=True, + timeout=5, + check=False, + text=True, + ) + except (OSError, subprocess.TimeoutExpired): return False - return True + return proc.returncode == 0 and "install ok installed" in proc.stdout + + +def _debian_office_families(soffice: str) -> Optional[frozenset[str]]: + """标准 Debian/Ubuntu 包安装时返回已安装组件;其他安装形态返回 None。""" + if os.name != "posix" or shutil.which("dpkg-query") is None: + return None + try: + resolved = Path(soffice).resolve() + except OSError: + return None + if not resolved.is_relative_to("/usr"): + return None + return frozenset( + family + for family, packages in _DEBIAN_PACKAGES.items() + if any(_dpkg_package_installed(package) for package in packages) + ) + + +@lru_cache(maxsize=1) +def office_supported_suffixes() -> frozenset[str]: + """返回 host 当前 LibreOffice 组件实际支持的输入后缀。""" + try: + soffice = find_soffice() + except SofficeNotFoundError: + return frozenset() + families = _debian_office_families(soffice) + if families is None: + # Windows/macOS/自带 tar 包通常是完整套件;无法可靠拆包探测时按完整安装处理。 + return _KNOWN_SUFFIXES + return frozenset().union(*(_FAMILY_SUFFIXES[family] for family in families)) + + +def soffice_available() -> bool: + """Host 至少有一个可用 LibreOffice 组件时才向 agent 注册工具。""" + return bool(office_supported_suffixes()) class OfficeToPdfTool(Tool): name = "office_to_pdf" - description = ( - "Convert an existing Office document in the current user's workspace to PDF using " - "LibreOffice on the backend host. Supports DOC/DOCX, PPT/PPTX, XLS/XLSX and ODF. " - "Use this only when an Office file already exists; for a new Markdown report, call " - "the platform rendering/render.py command with --format pdf directly." - ) + description = "Convert an existing Office document to PDF using backend host LibreOffice." parameters = { "type": "object", "properties": { "source": { "type": "string", - "description": "Existing Office file path, relative to task_dir or under /workspace.", + "description": ( + "Existing Office file path: task-relative, user-root-relative as shown in chat, " + "or an absolute path under /workspace." + ), }, "output": { "type": "string", @@ -58,20 +107,67 @@ class OfficeToPdfTool(Tool): "required": ["source"], } + def __init__( + self, + base_dir: Optional[Path] = None, + user_root: Optional[Path] = None, + ) -> None: + super().__init__(base_dir=base_dir, user_root=user_root) + self.supported_suffixes = office_supported_suffixes() + supported = ", ".join(suffix.lstrip(".").upper() for suffix in sorted(self.supported_suffixes)) + self.description = ( + "Convert an existing Office document in the current user's workspace to PDF using " + f"LibreOffice on the backend host. Formats available on this host: {supported or 'none'}. " + "Use this only when an Office file already exists; for a new Markdown report, call " + "the platform rendering/render.py command with --format pdf directly." + ) + + def _resolve_office_path(self, raw: str, *, existing: bool) -> Path: + """兼容 task 相对路径与界面展示的 `/...` 用户根相对路径。""" + p = Path(raw) + if p.is_absolute() or raw == "/workspace" or raw.startswith("/workspace/"): + return self._resolve_user_file(raw) + + base_candidate = self._resolve_user_file(raw) + if existing and base_candidate.exists(): + return base_candidate + if self.user_root is None: + return base_candidate + + root = self.user_root.resolve() + root_candidate = (root / p).resolve() + try: + root_candidate.relative_to(root) + task_rel = self.base_dir.resolve().relative_to(root) + except ValueError: + return base_candidate + + has_task_prefix = p.parts[:len(task_rel.parts)] == task_rel.parts + if (existing and root_candidate.exists()) or has_task_prefix: + return root_candidate + return base_candidate + def execute(self, source: str, output: Optional[str] = None) -> str: try: - src = self._resolve_user_file(source) + src = self._resolve_office_path(source, existing=True) except FileOutOfBounds: return f"[Error] source path out of user workspace: {source}" if not src.is_file(): return f"[Error] source file not found: {source}" - if src.suffix.lower() not in _ALLOWED_SUFFIXES: - allowed = ", ".join(sorted(_ALLOWED_SUFFIXES)) - return f"[Error] unsupported Office format {src.suffix!r}; allowed: {allowed}" + suffix = src.suffix.lower() + if suffix not in _KNOWN_SUFFIXES: + known = ", ".join(sorted(_KNOWN_SUFFIXES)) + return f"[Error] unsupported Office format {src.suffix!r}; known formats: {known}" + if suffix not in self.supported_suffixes: + family = next(name for name, suffixes in _FAMILY_SUFFIXES.items() if suffix in suffixes) + return ( + f"[Error] backend host LibreOffice lacks the {family} component required for " + f"{suffix}; ask the administrator to install that component" + ) raw_out = output.strip() if isinstance(output, str) and output.strip() else "" try: - out = self._resolve_user_file(raw_out) if raw_out else src.with_suffix(".pdf") + out = self._resolve_office_path(raw_out, existing=False) if raw_out else src.with_suffix(".pdf") except FileOutOfBounds: return f"[Error] output path out of user workspace: {output}" if out.suffix.lower() != ".pdf":