fix(rendering): detect LibreOffice components
This commit is contained in:
parent
21a90cf201
commit
8999fb1b77
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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
6
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_<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 中间这段记账偏低,开发期接受 |
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.60.22"
|
||||
__version__ = "0.60.23"
|
||||
|
|
|
|||
|
|
@ -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 += (
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
Loading…
Reference in New Issue