fix(rendering): detect LibreOffice components

This commit is contained in:
caoqianming 2026-08-03 09:27:30 +08:00
parent 21a90cf201
commit 8999fb1b77
7 changed files with 168 additions and 29 deletions

View File

@ -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。

View File

@ -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

6
RUN.md
View File

@ -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_<user_uuid>" /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/<stem>.<hash>.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 中间这段记账偏低,开发期接受 |

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.60.22"
__version__ = "0.60.23"

View File

@ -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 += (

View File

@ -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()

View File

@ -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 相对路径与界面展示的 `<working_dir>/...` 用户根相对路径。"""
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":