From 929da2b573973cdd2b5442820ff11dd6c0dfa6be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 02:25:40 +0000 Subject: [PATCH] feat: native Visio (.vsd/.vsdx) preview in chat and doc_read Add an in-app Visio viewer so Tevarn can open VSD/VSDX without a separate desktop app. VSDX/VSDM/VDX render to SVG from the XML package; binary .vsd uses LibreOffice or libvisio when present, otherwise extracted text plus an install hint. doc_read understands visio, chat artifacts treat .vsd/.vsdx as previewable, and office-pack routing matches visio/vsd keywords. Co-authored-by: wu1w --- backend/agent/tool_policy.py | 7 +- backend/api/routes/files.py | 51 ++ backend/services/visio_preview.py | 711 +++++++++++++++++++ backend/tests/test_tool_policy.py | 11 + backend/tests/test_visio_preview.py | 147 ++++ backend/tools/builtins/wave_a_tools.py | 21 +- frontend/components/chat/FilePreviewHost.tsx | 82 ++- frontend/lib/artifacts.selftest.ts | 3 + frontend/lib/artifacts.ts | 11 +- frontend/lib/filePreviewLoaders.ts | 26 +- frontend/locales/en.json | 2 + frontend/locales/zh.json | 2 + frontend/scripts/smoke_chat_file_ux_logic.ts | 11 +- 13 files changed, 1076 insertions(+), 9 deletions(-) create mode 100644 backend/services/visio_preview.py create mode 100644 backend/tests/test_visio_preview.py diff --git a/backend/agent/tool_policy.py b/backend/agent/tool_policy.py index 964ab505..27bee19c 100644 --- a/backend/agent/tool_policy.py +++ b/backend/agent/tool_policy.py @@ -276,6 +276,11 @@ "image", "pptx", "大纲", + "vsd", + "vsdx", + "visio", + "Visio", + "流程图", ), "devices": ( "远程", @@ -1132,7 +1137,7 @@ def compact_capability_brief( "manage": ("cron", "config", "ops", "channel", "webhook", "运维", "配置"), "mcp": ("mcp", "MCP", "integrations", "外部工具"), "evolution": ("evolution", "skill", "进化", "curator", "tee"), - "office": ("ppt", "docx", "report", "office", "chart", "tts", "日历", "幻灯"), + "office": ("ppt", "docx", "report", "office", "chart", "tts", "日历", "幻灯", "visio", "vsd"), "devices": ("device", "remote", "ssh", "agent", "设备", "远程"), "github": ("github", "pr", "ci", "gh"), "goal": ("goal", "plan", "autopilot", "目标", "里程碑"), diff --git a/backend/api/routes/files.py b/backend/api/routes/files.py index bc1fe9bf..b790b008 100644 --- a/backend/api/routes/files.py +++ b/backend/api/routes/files.py @@ -405,6 +405,57 @@ async def open_workspace_file( return {"ok": True, "abs_path": abs_path, "path": rel} +@router.get("/visio-preview") +async def visio_preview( + path: str = Query(..., description="Visio file path relative to mode root"), + mode: str = Query("sandbox", description="sandbox | local"), + as_format: str = Query("json", alias="as", description="json | pdf"), + current_user: Annotated[UserRead, Depends(get_current_user)] = None, +): + """Preview Microsoft Visio drawings (.vsd / .vsdx / .vsdm / .vdx). + + VSDX is rendered to SVG in-process. Binary .vsd uses LibreOffice or + libvisio when installed; otherwise returns extracted strings + install hint. + """ + from backend.services.visio_preview import is_visio_path, preview_visio + + rel = (path or "").strip().lstrip("/").replace("\\", "/") + if rel.lower().startswith("workspace/"): + rel = rel[len("workspace/") :] + target, base = _resolve_path(mode, rel) + _check_access(target, base) + if not target.exists() or not target.is_file(): + raise HTTPException(status_code=404, detail="File not found") + if not is_visio_path(target): + raise HTTPException(status_code=400, detail="Not a Visio file (.vsd/.vsdx/.vdx)") + + try: + payload = preview_visio(target) + except Exception as e: + logger.warning("visio preview failed path=%s err=%s", target, e) + raise HTTPException(status_code=500, detail=f"Visio preview failed: {e}") from e + + want_pdf = (as_format or "json").lower() == "pdf" + pdf_path = payload.get("pdf_path") + if want_pdf: + from fastapi.responses import FileResponse + + if not pdf_path or not Path(pdf_path).is_file(): + raise HTTPException( + status_code=422, + detail=payload.get("hint") or "PDF conversion unavailable for this Visio file", + ) + return FileResponse( + path=str(pdf_path), + filename=f"{target.stem}.pdf", + media_type="application/pdf", + ) + + payload["path"] = rel + payload.pop("pdf_path", None) + return payload + + @router.get("/info") async def get_file_info( current_user: Annotated[UserRead, Depends(get_current_user)] = None, diff --git a/backend/services/visio_preview.py b/backend/services/visio_preview.py new file mode 100644 index 00000000..38780401 --- /dev/null +++ b/backend/services/visio_preview.py @@ -0,0 +1,711 @@ +"""Visio (.vsd / .vsdx / .vsdm / .vdx) preview. + +VSDX/VSDM is a ZIP of XML — we render pages to SVG without extra software. +Binary .vsd uses LibreOffice Draw or libvisio ``vsd2svg`` when present. +""" + +from __future__ import annotations + +import hashlib +import io +import logging +import os +import re +import shutil +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET +import zipfile +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +VISIO_EXTENSIONS = frozenset( + {".vsd", ".vsdx", ".vsdm", ".vdx", ".vdw", ".vss", ".vssx", ".vst", ".vstx"} +) +_ZIP_EXTS = frozenset({".vsdx", ".vsdm", ".vssx", ".vstx"}) +_DPI = 96.0 +_MAX_PAGES = 40 +_MAX_SHAPES = 400 + +_SHAPE_FILL = { + "process": "#dbeafe", + "decision": "#fef3c7", + "start": "#dcfce7", + "end": "#fee2e2", + "terminator": "#dcfce7", + "document": "#e0e7ff", + "data": "#f3e8ff", + "subprocess": "#cffafe", + "database": "#e2e8f0", +} + + +def is_visio_path(path: Path | str) -> bool: + return Path(path).suffix.lower() in VISIO_EXTENSIONS + + +def _local(tag: str) -> str: + return tag.rsplit("}", 1)[-1] if "}" in tag else tag + + +def _f(val: str | None, default: float = 0.0) -> float: + if val is None or val == "": + return default + try: + return float(val) + except (TypeError, ValueError): + return default + + +def _esc(text: str) -> str: + return ( + (text or "") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +def find_soffice() -> str | None: + """Locate LibreOffice soffice binary (Windows/macOS/Linux).""" + env = (os.environ.get("LIBREOFFICE_PATH") or os.environ.get("SOFFICE_PATH") or "").strip() + if env and Path(env).is_file(): + return env + names = ["soffice", "soffice.com", "libreoffice"] + for name in names: + found = shutil.which(name) + if found: + return found + candidates: list[Path] = [] + if sys.platform == "win32": + for root in ( + os.environ.get("PROGRAMFILES", r"C:\Program Files"), + os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"), + ): + candidates.append(Path(root) / "LibreOffice" / "program" / "soffice.com") + candidates.append(Path(root) / "LibreOffice" / "program" / "soffice.exe") + elif sys.platform == "darwin": + candidates.append(Path("/Applications/LibreOffice.app/Contents/MacOS/soffice")) + else: + candidates.extend( + [ + Path("/usr/bin/soffice"), + Path("/usr/bin/libreoffice"), + Path("/usr/lib/libreoffice/program/soffice"), + ] + ) + for p in candidates: + if p.is_file(): + return str(p) + return None + + +def find_vsd2svg() -> str | None: + return shutil.which("vsd2svg") or shutil.which("vsd2xhtml") + + +def converter_status() -> dict[str, Any]: + soffice = find_soffice() + vsd2svg = find_vsd2svg() + return { + "soffice": soffice, + "vsd2svg": vsd2svg, + "native_vsdx": True, + "binary_vsd_ready": bool(soffice or vsd2svg), + "install_hint": _install_hint(), + } + + +def _install_hint() -> str: + if sys.platform == "win32": + return ( + "旧版 .vsd 图形预览需要 LibreOffice Draw。" + " PowerShell: winget install --id TheDocumentFoundation.LibreOffice -e" + " --accept-package-agreements --accept-source-agreements" + ) + if sys.platform == "darwin": + return "旧版 .vsd 图形预览需要 LibreOffice:brew install --cask libreoffice" + return "旧版 .vsd 图形预览需要:sudo apt install -y libreoffice-draw libvisio-tools" + + +def preview_visio(path: Path) -> dict[str, Any]: + """Return a JSON-serializable preview payload for a Visio file.""" + path = path.expanduser().resolve() + if not path.is_file(): + raise FileNotFoundError(str(path)) + ext = path.suffix.lower() + if ext not in VISIO_EXTENSIONS: + raise ValueError(f"not a visio file: {path.name}") + + status = converter_status() + if ext in _ZIP_EXTS or _looks_like_zip(path): + pages = extract_zip_visio(path) + return { + "ok": True, + "format": ext.lstrip("."), + "converter": "native", + "name": path.name, + "pages": pages, + "pdf_available": False, + "hint": None, + "converters": status, + } + + if ext == ".vdx" or _looks_like_xml(path): + pages = extract_vdx(path) + return { + "ok": True, + "format": "vdx", + "converter": "native", + "name": path.name, + "pages": pages, + "pdf_available": False, + "hint": None, + "converters": status, + } + + # Binary .vsd / .vss / .vst + converted = convert_binary_visio(path) + if converted: + pages = converted["pages"] + return { + "ok": True, + "format": ext.lstrip("."), + "converter": converted["converter"], + "name": path.name, + "pages": pages, + "pdf_path": converted.get("pdf_path"), + "pdf_available": bool(converted.get("pdf_path")), + "hint": None, + "converters": status, + } + + text = extract_ole_strings(path) + return { + "ok": True, + "format": ext.lstrip("."), + "converter": "strings", + "name": path.name, + "pages": [ + { + "name": path.stem, + "text": text or "(no extractable text)", + "svg": None, + } + ], + "pdf_available": False, + "hint": status["install_hint"], + "converters": status, + } + + +def _looks_like_zip(path: Path) -> bool: + try: + with path.open("rb") as f: + return f.read(4).startswith(b"PK") + except OSError: + return False + + +def _looks_like_xml(path: Path) -> bool: + try: + with path.open("rb") as f: + head = f.read(64).lstrip() + return head.startswith(b" list[dict[str, Any]]: + with zipfile.ZipFile(path) as zf: + names = zf.namelist() + def _page_num(n: str) -> int: + m = re.search(r"page(\d+)", n, re.I) + return int(m.group(1)) if m else 0 + + page_files = sorted( + [n for n in names if re.search(r"visio/pages/page\d+\.xml$", n, re.I)], + key=_page_num, + ) + page_meta = _parse_pages_index(zf) + pages: list[dict[str, Any]] = [] + for i, name in enumerate(page_files[:_MAX_PAGES]): + xml = zf.read(name).decode("utf-8", errors="replace") + meta = page_meta[i] if i < len(page_meta) else {} + pages.append(_render_page_xml(xml, meta.get("name") or f"Page-{i + 1}", meta)) + if not pages: + # stencil / template: fall back to any visio xml text + texts = [] + for n in names: + if n.lower().endswith(".xml") and "visio/" in n.lower().replace("\\", "/"): + raw = zf.read(n).decode("utf-8", errors="replace") + t = _xml_text_dump(raw) + if t: + texts.append(f"[{n}]\n{t}") + pages.append( + { + "name": path.stem, + "text": "\n\n".join(texts)[:20000] or "(empty visio package)", + "svg": None, + } + ) + return pages + + +def _parse_pages_index(zf: zipfile.ZipFile) -> list[dict[str, Any]]: + cand = "visio/pages/pages.xml" + if cand not in zf.namelist(): + return [] + try: + root = ET.fromstring(zf.read(cand)) + except ET.ParseError: + return [] + out: list[dict[str, Any]] = [] + for page in root.iter(): + if _local(page.tag) != "Page": + continue + cells = _cells(page) + for child in page: + if _local(child.tag) == "PageSheet": + cells.update(_cells(child)) + out.append( + { + "name": page.get("Name") or page.get("NameU") or f"Page-{len(out) + 1}", + "width": _f(cells.get("PageWidth"), 11.0), + "height": _f(cells.get("PageHeight"), 8.5), + } + ) + return out + + +def extract_vdx(path: Path) -> list[dict[str, Any]]: + xml = path.read_text(encoding="utf-8", errors="replace") + try: + root = ET.fromstring(xml) + except ET.ParseError: + return [{"name": path.stem, "text": _xml_text_dump(xml), "svg": None}] + pages: list[dict[str, Any]] = [] + for page in root.iter(): + if _local(page.tag) != "Page": + continue + cells = _cells(page) + for child in page: + if _local(child.tag) in {"PageSheet", "PageProps"}: + cells.update(_cells(child)) + name = page.get("Name") or page.get("NameU") or f"Page-{len(pages) + 1}" + meta = { + "name": name, + "width": _f(cells.get("PageWidth"), 11.0), + "height": _f(cells.get("PageHeight"), 8.5), + } + pages.append(_render_page_element(page, name, meta)) + if len(pages) >= _MAX_PAGES: + break + if not pages: + pages.append({"name": path.stem, "text": _xml_text_dump(xml), "svg": None}) + return pages + + +def _cells(el: ET.Element) -> dict[str, str]: + out: dict[str, str] = {} + for child in list(el): + if _local(child.tag) == "Cell": + n = child.get("N") + if n: + out[n] = child.get("V") or "" + return out + + +def _direct_text(el: ET.Element) -> str: + chunks: list[str] = [] + for child in list(el): + if _local(child.tag) == "Text": + t = "".join(child.itertext()).strip() + if t: + chunks.append(re.sub(r"\s+", " ", t)) + return " ".join(chunks) + + +def _collect_shapes(parent: ET.Element, ox: float = 0.0, oy: float = 0.0) -> list[dict[str, Any]]: + found: list[dict[str, Any]] = [] + for el in list(parent): + loc = _local(el.tag) + if loc == "Shapes": + found.extend(_collect_shapes(el, ox, oy)) + continue + if loc != "Shape": + continue + cells = _cells(el) + pin_x = _f(cells.get("PinX")) + ox + pin_y = _f(cells.get("PinY")) + oy + width = _f(cells.get("Width"), 1.0) + height = _f(cells.get("Height"), 0.5) + loc_pin_x = _f(cells.get("LocPinX"), width / 2.0 if width else 0.0) + loc_pin_y = _f(cells.get("LocPinY"), height / 2.0 if height else 0.0) + name_u = (el.get("NameU") or el.get("Name") or "").lower() + text = _direct_text(el) + is_connector = ( + "BeginX" in cells + and "EndX" in cells + and ("connector" in name_u or "dynamic connector" in name_u or width < 0.05 or height < 0.05) + ) or (el.get("Type") or "").lower() == "connector" + if "BeginX" in cells and "EndX" in cells and not text: + is_connector = True + shape = { + "name": el.get("NameU") or el.get("Name") or "", + "text": text, + "pin_x": pin_x, + "pin_y": pin_y, + "width": width, + "height": height, + "loc_pin_x": loc_pin_x, + "loc_pin_y": loc_pin_y, + "begin_x": _f(cells.get("BeginX")) if "BeginX" in cells else None, + "begin_y": _f(cells.get("BeginY")) if "BeginY" in cells else None, + "end_x": _f(cells.get("EndX")) if "EndX" in cells else None, + "end_y": _f(cells.get("EndY")) if "EndY" in cells else None, + "connector": is_connector, + } + found.append(shape) + for sub in list(el): + if _local(sub.tag) == "Shapes": + found.extend(_collect_shapes(sub, pin_x, pin_y)) + if len(found) >= _MAX_SHAPES: + break + return found[:_MAX_SHAPES] + + +def _render_page_xml(xml: str, name: str, meta: dict[str, Any]) -> dict[str, Any]: + try: + root = ET.fromstring(xml) + except ET.ParseError: + return {"name": name, "text": _xml_text_dump(xml), "svg": None} + return _render_page_element(root, name, meta) + + +def _render_page_element(root: ET.Element, name: str, meta: dict[str, Any]) -> dict[str, Any]: + shapes = _collect_shapes(root) + page_w = float(meta.get("width") or 11.0) + page_h = float(meta.get("height") or 8.5) + # infer page size from shapes if missing/tiny + if shapes: + max_x = max((s["pin_x"] + s["width"] / 2 for s in shapes), default=page_w) + max_y = max((s["pin_y"] + s["height"] / 2 for s in shapes), default=page_h) + page_w = max(page_w, max_x + 0.4) + page_h = max(page_h, max_y + 0.4) + texts = [s["text"] for s in shapes if s.get("text")] + svg = _shapes_to_svg(shapes, page_w, page_h, name) + return { + "name": name, + "text": "\n".join(texts) if texts else "(no text on this page)", + "svg": svg, + "shape_count": len(shapes), + "width_in": round(page_w, 3), + "height_in": round(page_h, 3), + } + + +def _fill_for(name_u: str) -> str: + key = (name_u or "").lower() + for k, color in _SHAPE_FILL.items(): + if k in key: + return color + return "#f8fafc" + + +def _shapes_to_svg(shapes: list[dict[str, Any]], page_w: float, page_h: float, title: str) -> str: + w_px = max(120, int(page_w * _DPI)) + h_px = max(80, int(page_h * _DPI)) + parts = [ + f'', + f'', + ] + for s in shapes: + if s.get("connector") and s.get("begin_x") is not None and s.get("end_x") is not None: + x1 = s["begin_x"] * _DPI + y1 = (page_h - s["begin_y"]) * _DPI + x2 = s["end_x"] * _DPI + y2 = (page_h - s["end_y"]) * _DPI + parts.append( + f'' + ) + continue + width = max(s["width"], 0.2) + height = max(s["height"], 0.15) + left = (s["pin_x"] - s["loc_pin_x"]) * _DPI + visio_top = s["pin_y"] - s["loc_pin_y"] + height + top = (page_h - visio_top) * _DPI + rw = width * _DPI + rh = height * _DPI + fill = _fill_for(s.get("name") or "") + rx = 8 if "decision" not in (s.get("name") or "").lower() else 2 + if "decision" in (s.get("name") or "").lower(): + cx = left + rw / 2 + cy = top + rh / 2 + parts.append( + f'' + ) + else: + parts.append( + f'' + ) + label = (s.get("text") or s.get("name") or "").strip() + if label: + fs = max(9, min(14, int(rh * 0.28))) + # wrap roughly + max_chars = max(6, int(rw / (fs * 0.55))) + lines = _wrap(label, max_chars)[:4] + ty = top + rh / 2 - (len(lines) - 1) * (fs + 2) / 2 + for i, line in enumerate(lines): + parts.append( + f'{_esc(line)}' + ) + parts.append("") + return "".join(parts) + + +def _wrap(text: str, width: int) -> list[str]: + text = re.sub(r"\s+", " ", text).strip() + if len(text) <= width: + return [text] + words = text.split(" ") + lines: list[str] = [] + cur = "" + for w in words: + trial = (cur + " " + w).strip() + if len(trial) <= width: + cur = trial + else: + if cur: + lines.append(cur) + cur = w + if cur: + lines.append(cur) + return lines or [text[:width]] + + +def _xml_text_dump(xml: str) -> str: + texts = re.findall(r"]*>([\s\S]*?)", xml, flags=re.I) + cleaned: list[str] = [] + for t in texts: + t = re.sub(r"<[^>]+>", "", t) + t = ( + t.replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .replace(""", '"') + ) + t = re.sub(r"\s+", " ", t).strip() + if t and t not in cleaned: + cleaned.append(t) + return "\n".join(cleaned) + + +def extract_ole_strings(path: Path, limit: int = 8000) -> str: + data = path.read_bytes() + found: list[str] = [] + # UTF-16LE runs + for m in re.finditer(rb"(?:[\x20-\x7e]\x00){4,}", data): + try: + s = m.group().decode("utf-16le").strip() + except UnicodeDecodeError: + continue + if s and s not in found and not s.startswith(("Microsoft", "Visio", "CLSID")): + found.append(s) + # ASCII runs + for m in re.finditer(rb"[\x20-\x7e]{6,}", data): + s = m.group().decode("ascii", errors="ignore").strip() + if s and s not in found and not s.startswith(("Microsoft", "Visio")): + found.append(s) + text = "\n".join(found) + return text[:limit] + + +def convert_binary_visio(path: Path) -> dict[str, Any] | None: + vsd2svg = find_vsd2svg() + if vsd2svg and Path(vsd2svg).name.lower().startswith("vsd2svg"): + svg_pages = _convert_vsd2svg(path, vsd2svg) + if svg_pages: + return {"converter": "libvisio", "pages": svg_pages, "pdf_path": None} + + soffice = find_soffice() + if soffice: + pdf = _convert_soffice(path, soffice, "pdf") + if pdf and pdf.is_file(): + pages = [ + { + "name": path.stem, + "text": f"Converted to PDF via LibreOffice: {pdf.name}", + "svg": None, + } + ] + svg = _convert_soffice(path, soffice, "svg") + if svg and svg.is_file(): + try: + svg_text = svg.read_text(encoding="utf-8", errors="replace") + except OSError: + svg_text = None + if svg_text: + pages = [{"name": path.stem, "text": "(rendered via LibreOffice)", "svg": svg_text}] + return {"converter": "libreoffice", "pages": pages, "pdf_path": str(pdf)} + return None + + +def _cache_dir(src: Path) -> Path: + key = hashlib.sha256(f"{src.resolve()}::{src.stat().st_mtime_ns}::{src.stat().st_size}".encode()).hexdigest()[:16] + try: + from backend.core.config import get_tevarn_home + + root = get_tevarn_home() / "cache" / "visio-preview" / key + except Exception: + root = Path(tempfile.gettempdir()) / "tevarn-visio-preview" / key + root.mkdir(parents=True, exist_ok=True) + return root + + +def _convert_soffice(src: Path, soffice: str, fmt: str) -> Path | None: + outdir = _cache_dir(src) + dest = outdir / f"{src.stem}.{fmt}" + if dest.is_file() and dest.stat().st_size > 0: + return dest + cmd = [ + soffice, + "--headless", + "--norestore", + "--nolockcheck", + "--convert-to", + fmt, + "--outdir", + str(outdir), + str(src), + ] + try: + proc = subprocess.run( + cmd, + capture_output=True, + timeout=90, + check=False, + cwd=str(outdir), + ) + except (OSError, subprocess.TimeoutExpired) as e: + logger.warning("libreoffice convert failed: %s", e) + return None + if dest.is_file() and dest.stat().st_size > 0: + return dest + logger.info( + "libreoffice convert no output fmt=%s rc=%s stderr=%s", + fmt, + proc.returncode, + (proc.stderr or b"")[:400], + ) + return None + + +def _convert_vsd2svg(src: Path, binary: str) -> list[dict[str, Any]] | None: + outdir = _cache_dir(src) + dest = outdir / f"{src.stem}.svg" + if not dest.is_file(): + try: + proc = subprocess.run( + [binary, str(src), str(dest)], + capture_output=True, + timeout=60, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as e: + logger.warning("vsd2svg failed: %s", e) + return None + if not dest.is_file() or dest.stat().st_size <= 0: + logger.info("vsd2svg no output rc=%s", proc.returncode) + return None + try: + svg_text = dest.read_text(encoding="utf-8", errors="replace") + except OSError: + return None + return [{"name": src.stem, "text": "(rendered via libvisio)", "svg": svg_text}] + + +def pages_as_text(preview: dict[str, Any], max_chars: int = 12000) -> str: + """Flatten preview pages for doc_read / agent consumption.""" + parts: list[str] = [] + for i, page in enumerate(preview.get("pages") or [], start=1): + title = page.get("name") or f"Page-{i}" + body = (page.get("text") or "").strip() + parts.append(f"--- {title} ---\n{body}") + hint = preview.get("hint") + if hint: + parts.append(f"[hint] {hint}") + text = "\n\n".join(parts) + return text[:max_chars] + + +def build_minimal_vsdx_bytes( + page_name: str = "Page-1", + shapes: list[dict[str, Any]] | None = None, +) -> bytes: + """Test helper: a tiny VSDX zip the native renderer can open.""" + shapes = shapes or [ + { + "name": "Process", + "text": "HelloVisio", + "pin_x": 2.0, + "pin_y": 6.0, + "width": 1.8, + "height": 0.7, + } + ] + shape_xml = [] + for i, s in enumerate(shapes, start=1): + text = _esc(str(s.get("text") or "")) + shape_xml.append( + f'' + f'' + f'' + f'' + f'' + f"{text}" + f"" + ) + ns = "http://schemas.microsoft.com/office/visio/2012/main" + pages_xml = ( + f'' + f'' + f'' + f"" + f'' + f'' + f"" + ) + page_xml = ( + f'' + f'' + f"{''.join(shape_xml)}" + ) + ctypes = ( + '' + '' + '' + '' + '' + "" + ) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: + zf.writestr("[Content_Types].xml", ctypes) + zf.writestr("visio/pages/pages.xml", pages_xml) + zf.writestr("visio/pages/page1.xml", page_xml) + return buf.getvalue() diff --git a/backend/tests/test_tool_policy.py b/backend/tests/test_tool_policy.py index e3106074..c67b0e66 100644 --- a/backend/tests/test_tool_policy.py +++ b/backend/tests/test_tool_policy.py @@ -209,3 +209,14 @@ def test_profile_assistant_has_session_search(): names, _ = resolve_enabled_tool_names(profile="assistant", user_input="x") assert names is not None assert "session_search" in names + + +def test_visio_keyword_selects_office_pack(): + names, plan = resolve_enabled_tool_names( + mode="default", + profile="dynamic", + user_input="帮我打开这个 vsd 流程图,看看 visio 里画了什么", + ) + assert names is not None + assert "office" in plan.packs + assert "doc_read" in names diff --git a/backend/tests/test_visio_preview.py b/backend/tests/test_visio_preview.py new file mode 100644 index 00000000..3d3fa97c --- /dev/null +++ b/backend/tests/test_visio_preview.py @@ -0,0 +1,147 @@ +"""Native Visio (.vsdx) preview + binary .vsd fallback.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from backend.services.visio_preview import ( + build_minimal_vsdx_bytes, + converter_status, + extract_ole_strings, + is_visio_path, + pages_as_text, + preview_visio, +) + + +def test_is_visio_path(): + assert is_visio_path("flow.vsd") + assert is_visio_path(Path("a/b.VSDX")) + assert is_visio_path("stencil.vsdm") + assert not is_visio_path("flow.pptx") + + +def test_native_vsdx_renders_svg_and_text(tmp_path: Path): + dest = tmp_path / "hello.vsdx" + dest.write_bytes( + build_minimal_vsdx_bytes( + "Flow", + shapes=[ + { + "name": "Process", + "text": "HelloVisio", + "pin_x": 2.0, + "pin_y": 6.0, + "width": 1.8, + "height": 0.7, + }, + { + "name": "Decision", + "text": "OK?", + "pin_x": 5.0, + "pin_y": 6.0, + "width": 1.4, + "height": 1.0, + }, + ], + ) + ) + out = preview_visio(dest) + assert out["ok"] is True + assert out["converter"] == "native" + assert out["pages"] + page = out["pages"][0] + assert page["name"] == "Flow" + assert "HelloVisio" in page["text"] + assert "OK?" in page["text"] + svg = page["svg"] or "" + assert svg.startswith(" + + + + + + + + + + + + + + VDXBox + + + + + + """ + dest = tmp_path / "old.vdx" + dest.write_text(xml, encoding="utf-8") + out = preview_visio(dest) + assert out["converter"] == "native" + assert "VDXBox" in out["pages"][0]["text"] + assert " Any: def has(mod: str) -> bool: return iu.find_spec(mod) is not None + from backend.services.visio_preview import converter_status + + visio = converter_status() checks = { "doc_pdf": has("fitz") or has("pypdf") or has("PyPDF2"), "doc_docx": has("docx"), "doc_xlsx": has("openpyxl"), + "doc_visio_vsdx": True, + "doc_visio_vsd": bool(visio.get("binary_vsd_ready")), "tts_edge": has("edge_tts"), "image_pil": has("PIL"), "playwright": has("playwright"), @@ -151,6 +156,8 @@ def has(mod: str) -> bool: install_hints.append("pip install openpyxl") if not checks["tts_edge"]: install_hints.append("pip install edge-tts") + if not checks["doc_visio_vsd"]: + install_hints.append(str(visio.get("install_hint") or "install LibreOffice for .vsd")) return json.dumps( { @@ -181,8 +188,9 @@ def __init__(self) -> None: super().__init__( name="doc_read", description=( - "读取 PDF/DOCX/XLSX/TXT/MD 文档正文。支持 path 或 url。" + "读取 PDF/DOCX/XLSX/TXT/MD/Visio(VSD/VSDX) 文档正文。支持 path 或 url。" "大文件用 offset/limit 分页(按行或字符块)。" + "Visio 图会按页提取形状文字;旧版 .vsd 无 LibreOffice 时返回可打印字符串。" ), parameters={ "type": "object", @@ -191,7 +199,7 @@ def __init__(self) -> None: "url": {"type": "string", "description": "远程 URL(下载后解析)"}, "format": { "type": "string", - "enum": ["auto", "pdf", "docx", "xlsx", "txt", "md"], + "enum": ["auto", "pdf", "docx", "xlsx", "txt", "md", "visio"], "default": "auto", }, "offset": {"type": "integer", "default": 0, "description": "起始行(0-based)"}, @@ -261,6 +269,10 @@ def _guess(self, p: Path) -> str: ".csv": "txt", ".json": "txt", ".log": "txt", + ".vsd": "visio", + ".vsdx": "visio", + ".vsdm": "visio", + ".vdx": "visio", }.get(ext, "txt") def _download(self, url: str) -> Path | str: @@ -333,6 +345,11 @@ def _extract(self, path: Path, fmt: str) -> str: parts.append("\t".join("" if c is None else str(c) for c in row)) wb.close() return "\n".join(parts) + if fmt == "visio": + from backend.services.visio_preview import pages_as_text, preview_visio + + preview = preview_visio(path) + return pages_as_text(preview) return path.read_text(encoding="utf-8", errors="replace") diff --git a/frontend/components/chat/FilePreviewHost.tsx b/frontend/components/chat/FilePreviewHost.tsx index 3d8fb3f3..b10d2660 100644 --- a/frontend/components/chat/FilePreviewHost.tsx +++ b/frontend/components/chat/FilePreviewHost.tsx @@ -11,6 +11,7 @@ import { loadXlsxTables, parseCsvText, sanitizeHtmlForPreview, + sanitizeSvgForPreview, type SheetTable, } from '@/lib/filePreviewLoaders'; import { useT } from '@/stores/localeStore'; @@ -82,6 +83,25 @@ async function resolveAbsPath(rel: string): Promise<{ abs_path: string; exists: return res.json(); } +type VisioPage = { name: string; svg?: string | null; text: string }; + +async function fetchVisioPreview(path: string): Promise<{ + pages: VisioPage[]; + hint?: string | null; + pdf_available?: boolean; +}> { + const rel = toRelPath(path); + const token = getToken(); + const res = await fetch(`${apiBase()}/files/visio-preview?path=${encodeURIComponent(rel)}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) { + const detail = await res.text().catch(() => ''); + throw new Error(detail || `HTTP ${res.status}`); + } + return res.json(); +} + async function openViaBackend(rel: string): Promise { const token = getToken(); const res = await fetch(`${apiBase()}/files/open?path=${encodeURIComponent(rel)}`, { @@ -100,7 +120,8 @@ type PreviewState = | { type: 'text'; text: string } | { type: 'table'; sheets: SheetTable[]; active: number } | { type: 'pptx'; slides: string[] } - | { type: 'docx'; html: string }; + | { type: 'docx'; html: string } + | { type: 'visio'; pages: VisioPage[]; hint?: string | null }; function resolveKind(artifact: ChatArtifact): NonNullable { if (artifact.kind && artifact.kind !== 'other') return artifact.kind; @@ -131,6 +152,10 @@ function resolveKind(artifact: ChatArtifact): NonNullable doc: 'docx', pptx: 'pptx', ppt: 'pptx', + vsd: 'visio', + vsdx: 'visio', + vsdm: 'visio', + vdx: 'visio', }; return map[ext] || 'other'; } @@ -251,6 +276,36 @@ export function FilePreviewHost({ artifact, onClose }: FilePreviewHostProps) { const slides = await loadPptxSlides(buf); if (cancelled) return; setPreview({ type: 'pptx', slides }); + } else if (k === 'visio') { + const data = await fetchVisioPreview(artifact.path); + if (cancelled) return; + const pages = (data.pages || []).map((p) => ({ + name: p.name || 'Page', + text: p.text || '', + svg: p.svg ? sanitizeSvgForPreview(p.svg) : null, + })); + const hasSvg = pages.some((p) => Boolean(p.svg)); + if (!hasSvg && data.pdf_available) { + const rel = toRelPath(artifact.path); + const token = getToken(); + const pdfRes = await fetch( + `${apiBase()}/files/visio-preview?path=${encodeURIComponent(rel)}&as=pdf`, + { headers: token ? { Authorization: `Bearer ${token}` } : {} }, + ); + if (pdfRes.ok) { + const blob = await pdfRes.blob(); + if (cancelled) return; + const url = URL.createObjectURL(blob); + urls.push(url); + setPreview({ type: 'pdf', url }); + return; + } + } + if (!pages.length) { + setError(t('chat.previewUnsupported')); + return; + } + setPreview({ type: 'visio', pages, hint: data.hint || null }); } else { try { const f = await readFile(rel); @@ -563,6 +618,31 @@ export function FilePreviewHost({ artifact, onClose }: FilePreviewHostProps) {

{t('chat.previewPptxHint')}

)} + + {!loading && !error && preview.type === 'visio' && ( +
+ {preview.pages.map((page, i) => ( +
+
+ {page.name || t('chat.previewVisioPage').replace('{n}', String(i + 1))} +
+ {page.svg ? ( +
+ ) : ( +
+                    {page.text}
+                  
+ )} +
+ ))} +

+ {preview.hint || t('chat.previewVisioHint')} +

+
+ )}
); diff --git a/frontend/lib/artifacts.selftest.ts b/frontend/lib/artifacts.selftest.ts index 24e3bf4b..24ea341b 100644 --- a/frontend/lib/artifacts.selftest.ts +++ b/frontend/lib/artifacts.selftest.ts @@ -27,4 +27,7 @@ const arts = extractArtifacts({ assert(arts.some((a) => a.name.includes('report') || a.path.includes('report')), 'md link'); assert(arts.some((a) => a.path.includes('hello.md')), 'tool path'); assert(arts.some((a) => a.kind === 'table'), 'table kind'); + +const visio = extractArtifacts({ content: '见 docs/网络拓扑.vsd 以及 flow.vsdx' }); +assert(visio.some((a) => a.kind === 'visio'), 'visio kind'); console.log('artifacts.selftest OK', arts.length, arts.map((a) => a.name).join(', ')); diff --git a/frontend/lib/artifacts.ts b/frontend/lib/artifacts.ts index 8358bf37..085b7a41 100644 --- a/frontend/lib/artifacts.ts +++ b/frontend/lib/artifacts.ts @@ -8,7 +8,7 @@ export interface ChatArtifact { name: string; source: 'tool' | 'content' | 'link'; /** 可选 mime 提示 */ - kind?: 'image' | 'table' | 'text' | 'pdf' | 'html' | 'markdown' | 'docx' | 'pptx' | 'other'; + kind?: 'image' | 'table' | 'text' | 'pdf' | 'html' | 'markdown' | 'docx' | 'pptx' | 'visio' | 'other'; } const WRITE_TOOLS = new Set([ @@ -53,6 +53,10 @@ const EXT_KIND: Record = { pptx: 'pptx', ppt: 'pptx', doc: 'docx', + vsd: 'visio', + vsdx: 'visio', + vsdm: 'visio', + vdx: 'visio', }; function basename(p: string): string { @@ -190,7 +194,7 @@ function extractBarePaths(content: string, map: Map) { } // 纯文件名带常见生成扩展 const bare = - /(?:^|[\s"'`])((?:[\w\u4e00-\u9fff.-]+)\.(?:xlsx|xls|csv|pptx|ppt|docx|doc|pdf|png|jpg|jpeg|webp|gif|md|txt|json|html|htm))(?=[\s"'`.,;:!?)]|$)/gi; + /(?:^|[\s"'`])((?:[\w\u4e00-\u9fff.-]+)\.(?:xlsx|xls|csv|pptx|ppt|docx|doc|pdf|png|jpg|jpeg|webp|gif|md|txt|json|html|htm|vsd|vsdx|vsdm|vdx))(?=[\s"'`.,;:!?)]|$)/gi; while ((m = bare.exec(content)) !== null) { const p = normalizeArtifactPath(m[1]); if (p) pushUnique(map, { path: p, name: basename(p), source: 'content', kind: kindOf(p) }); @@ -251,7 +255,8 @@ export function artifactPreviewable(kind: ChatArtifact['kind'] | undefined): boo kind === 'html' || kind === 'markdown' || kind === 'docx' || - kind === 'pptx' + kind === 'pptx' || + kind === 'visio' ); } diff --git a/frontend/lib/filePreviewLoaders.ts b/frontend/lib/filePreviewLoaders.ts index 435ed2a1..2328a6d8 100644 --- a/frontend/lib/filePreviewLoaders.ts +++ b/frontend/lib/filePreviewLoaders.ts @@ -5,6 +5,21 @@ import DOMPurify from 'dompurify'; +type Purify = { sanitize: (dirty: string, cfg?: Record) => string }; + +function getPurify(): Purify { + const raw = DOMPurify as unknown as Purify & { default?: Purify }; + if (typeof raw.sanitize === 'function') return raw; + if (raw.default && typeof raw.default.sanitize === 'function') return raw.default; + // Node smoke / no window: strip obvious XSS; browser uses real DOMPurify. + return { + sanitize: (dirty: string) => + String(dirty || '') + .replace(//gi, '') + .replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, ''), + }; +} + export type SheetTable = { name: string; rows: string[][] }; /** 统一成独立 ArrayBuffer,避免 Uint8Array.buffer 带 byteOffset 时 SheetJS/mammoth 读歪 */ @@ -189,10 +204,19 @@ export async function loadPptxSlides( } export function sanitizeHtmlForPreview(html: string): string { - return DOMPurify.sanitize(html || '', { + return getPurify().sanitize(html || '', { USE_PROFILES: { html: true }, FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button'], FORBID_ATTR: ['style'], ALLOW_DATA_ATTR: false, }); } + +export function sanitizeSvgForPreview(svg: string): string { + return getPurify().sanitize(svg || '', { + USE_PROFILES: { svg: true, svgFilters: true }, + ADD_TAGS: ['marker', 'defs'], + FORBID_TAGS: ['script', 'foreignObject', 'iframe', 'object', 'embed'], + ALLOW_DATA_ATTR: false, + }); +} diff --git a/frontend/locales/en.json b/frontend/locales/en.json index b6965373..9d5fe718 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -374,6 +374,8 @@ "chat.openSystemFail": "Could not open with system app — try download", "chat.previewSlide": "Slide {n}", "chat.previewPptxHint": "PPT preview shows extracted text; download for full layout", + "chat.previewVisioPage": "Page {n}", + "chat.previewVisioHint": "Visio preview is rendered in Tevarn. For legacy .vsd graphics, install LibreOffice Draw.", "chat.sessionFiles": "Session files ({n})", "chat.uploadOkAttached": "Attached {n} file(s)", "common.close": "Close", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 21cfbcf2..952e4418 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -374,6 +374,8 @@ "chat.openSystemFail": "无法用系统应用打开,请尝试下载", "chat.previewSlide": "幻灯片 {n}", "chat.previewPptxHint": "PPT 预览为提取文本;完整版式请下载后打开", + "chat.previewVisioPage": "页 {n}", + "chat.previewVisioHint": "Visio 预览由 Tevarn 内置渲染。旧版 .vsd 若无图形,请安装 LibreOffice Draw。", "chat.sessionFiles": "本会话文件 ({n})", "chat.uploadOkAttached": "已添加 {n} 个附件", "common.close": "关闭", diff --git a/frontend/scripts/smoke_chat_file_ux_logic.ts b/frontend/scripts/smoke_chat_file_ux_logic.ts index 56ad8c92..bbe9e186 100644 --- a/frontend/scripts/smoke_chat_file_ux_logic.ts +++ b/frontend/scripts/smoke_chat_file_ux_logic.ts @@ -46,9 +46,15 @@ async function main() { if (!arts.some((a) => a.kind === 'markdown' || a.name.includes('hello'))) { throw new Error('missing md'); } - if (!artifactPreviewable('docx') || !artifactPreviewable('pptx') || !artifactPreviewable('html')) { + if (!artifactPreviewable('docx') || !artifactPreviewable('pptx') || !artifactPreviewable('html') || !artifactPreviewable('visio')) { throw new Error('previewable'); } + const visioArts = extractArtifacts({ + content: '打开 topology.vsd 和 architecture.vsdx', + }); + if (!visioArts.some((a) => a.kind === 'visio' && a.name.includes('topology'))) { + throw new Error('vsd kind ' + JSON.stringify(visioArts)); + } ok('extractArtifacts kinds'); const sess = collectSessionArtifacts([ { role: 'assistant', content: '见 smoke_preview/doc.pdf', tool_calls: [] }, @@ -65,6 +71,9 @@ async function main() { if (rows[1][1] !== '2,3') throw new Error(JSON.stringify(rows)); const h = sanitizeHtmlForPreview('

x

'); if (h.includes('script') || /onclick/i.test(h)) throw new Error(h); + const { sanitizeSvgForPreview } = await import('../lib/filePreviewLoaders'); + const svg = sanitizeSvgForPreview(''); + if (/script/i.test(svg)) throw new Error(svg); ok('csv+sanitizeHtml'); } catch (e) { ko('csv/html', e);