Обновить document_generator.py

This commit is contained in:
host committed 2026-09-21 18:09:32 +03:00
1 parent f0caa8431f
commit ee64de8210
1 file changed
+281 -6
+281 -6
View File
@@ -3,7 +3,7 @@ title: Document Generator 3.7
author: Ngo Tan Tai
author_url: https://newnol.io.vn
description: Generates Word, PowerPoint, and Excel files. Finds table data in complex JSON, multi-sheet Excel, Markdown tables in Word, images/notes in PPTX.
required_openwebui_version: 0.4.0
required_open_webui_version: 0.4.0
funding_url: https://github.com/newnol
requirements: python-docx, openpyxl, python-pptx, aiohttp, pillow
version: 3.7
@@ -21,6 +21,7 @@ import asyncio
import logging
import ipaddress
import tempfile
from urllib.parse import urlparse
import aiohttp
from pydantic import BaseModel, Field
@@ -105,7 +106,7 @@ class Tools:
)
allow_formulas: bool = Field(
default=False,
description="Excel: строки, начинающиеся с '=', считать формулами (иначе — текст, защита от formula injection),
description="Excel: строки, начинающиеся с '=', считать формулами (иначе — текст, защита от formula injection)",
)
convert_numeric_strings: bool = Field(
default=False,
@@ -459,7 +460,7 @@ class Tools:
r = p.add_run(part[1:-1])
r.font.name = "Consolas"
elif part.startswith("*") and part.endswith("*") and len(part) > 2:
p.add_run(part[1:-2]).italic = True
p.add_run(part[1:-1]).italic = True
elif part.startswith("[") and "](" in part:
m = re.match(r"\[(.+?)\]\((.+)\)$", part)
p.add_run(f"{m.group(1)} ({m.group(2)})" if m else part)
@@ -750,9 +751,9 @@ class Tools:
@staticmethod
def _drop_empty_placeholders(slide):
for ph in slide.placeholders:
if ph.has_text_frame and not ph.text_frame.text.strip():
ph._element.getparent().remove(ph._element)
for sh in list(slide.placeholders):
if sh.has_text_frame and not sh.text_frame.text.strip():
sh._element.getparent().remove(sh._element)
@staticmethod
def _body_placeholder(slide):
@@ -886,3 +887,277 @@ class Tools:
except Exception:
pass
return base
async def _upload_file(
self, local_path: str, filename: str, api_key: str, mime: str, request=None
) -> str:
url = f"{self.valves.internal_api_url.rstrip('/')}/api/v1/files/"
if not self.valves.process_uploaded_files:
url += "?process=false"
status, text, data = 0, "", {}
try:
with open(local_path, "rb") as f:
file_bytes = f.read()
for attempt in range(3):
try:
form = aiohttp.FormData()
form.add_field(
"file", file_bytes, filename=filename, content_type=mime
)
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=60)
) as session:
async with session.post(
url,
headers={"Authorization": f"Bearer {api_key}"},
data=form,
) as resp:
status, text = resp.status, await resp.text()
if status < 500:
break
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
status, text = 0, str(e)
await asyncio.sleep(1.5 * (attempt + 1))
except Exception as e:
logger.exception("Upload failed for %s", filename)
return f"❌ Системная ошибка: {e}"
finally:
self._safe_unlink(local_path)
try:
data = json.loads(text)
if not isinstance(data, dict):
data = {}
except (json.JSONDecodeError, ValueError):
data = {}
if not 200 <= status < 300:
return f"❌ Ошибка загрузки {status}: {text[:300]}"
file_id = data.get("id") or data.get("uuid") or data.get("file_id")
if not file_id:
return f"❌ Ошибка загрузки: в ответе нет id файла: {text[:300]}"
download_url = f"{self._public_base(request)}/api/v1/files/{file_id}/content"
return f"""
---
### ✅ Документ создан
📁 **Файл:** `{filename}`
🔗 [Скачать]({download_url})
---
"""
def _check_input_size(self, s) -> str:
if len(str(s)) > self.valves.max_content_chars:
return f"❌ Слишком большой ввод (лимит {self.valves.max_content_chars} символов)."
return ""
# ------------------------------------------------------------------
# ПУБЛИЧНЫЕ ФУНКЦИИ
# ------------------------------------------------------------------
async def generate_word_doc(
self,
title: str,
content: str,
__user__: dict = None,
__event_emitter__=None,
__request__=None,
) -> str:
"""
Generate a Word document (.docx).
Args:
title: Document title (also used for the file name)
content: Document body in Markdown: headings (# .. ######), **bold**, *italic*,
`code`, bullet and numbered lists (indent 2 spaces for nesting),
tables (| a | b |), code blocks, <pagebreak> for a page break
"""
api_key = self._get_api_key(__user__)
if not api_key:
return "⚠️ Не указан API Key (UserValves -> openwebui_api_key)."
if not _HAS_DOCX:
return "⚠️ Не установлен пакет `python-docx`."
err = self._check_input_size(content)
if err:
return err
title = str(title).strip() or "Document"
filename = self._safe_filename(title, ".docx")
content = self._ILLEGAL.sub("", str(content))
clean_title = self._ILLEGAL.sub("", title)
fd, local_path = tempfile.mkstemp(suffix=".docx")
os.close(fd)
await self._emit(__event_emitter__, "Генерирую Word-документ…")
try:
await asyncio.to_thread(self._build_docx, clean_title, content, local_path)
except Exception as e:
logger.exception("Word generation failed")
self._safe_unlink(local_path)
return f"❌ Ошибка Word: {e}"
await self._emit(__event_emitter__, "Загружаю файл…")
res = await self._upload_file(
local_path, filename, api_key, MIME_DOCX, __request__
)
await self._emit(__event_emitter__, "Готово", done=True)
return res
async def generate_excel_file(
self,
filename: str,
data_json: str,
__user__: dict = None,
__event_emitter__=None,
__request__=None,
) -> str:
"""
Generate an Excel file (.xlsx). Finds table data automatically in the JSON.
Args:
filename: Name of the Excel file
data_json: JSON with table data. Accepted: [[row], ...]; [{col: value}, ...];
{"Sheet1": [[...]], "Sheet2": [[...]]} or [{"name": "S1", "data": [[...]]}, ...]
for several sheets. CSV or a Markdown table also works.
"""
api_key = self._get_api_key(__user__)
if not api_key:
return "⚠️ Не указан API Key (UserValves -> openwebui_api_key)."
if not _HAS_OPENPYXL:
return "⚠️ Не установлен пакет `openpyxl`."
err = self._check_input_size(data_json)
if err:
return err
filename = self._safe_filename(filename, ".xlsx")
try:
if isinstance(data_json, (list, dict)):
raw = data_json
else:
text = self._strip_fences(data_json)
try:
raw = json.loads(text)
except (json.JSONDecodeError, ValueError):
rows = self._parse_plain_table(text)
raw = rows # CSV/Markdown -> [[..]]
tables = [(n, r) for n, r in self._find_tables(raw) if r]
except Exception as e:
return f"❌ Ошибка Excel: {e}"
if not tables:
return (
"❌ Ошибка Excel: не удалось найти таблицу в данных. "
f"Получено: {str(data_json)[:100]}..."
)
fd, local_path = tempfile.mkstemp(suffix=".xlsx")
os.close(fd)
await self._emit(__event_emitter__, "Генерирую Excel…")
try:
await asyncio.to_thread(self._build_xlsx, tables, local_path)
except Exception as e:
logger.exception("Excel generation failed")
self._safe_unlink(local_path)
return f"❌ Ошибка Excel: {e}"
await self._emit(__event_emitter__, "Загружаю файл…")
res = await self._upload_file(
local_path, filename, api_key, MIME_XLSX, __request__
)
await self._emit(__event_emitter__, "Готово", done=True)
return res
async def generate_pptx_file(
self,
filename: str,
title: str,
slides_content: str,
__user__: dict = None,
__event_emitter__=None,
__request__=None,
) -> str:
"""
Generate a PowerPoint presentation (.pptx).
Args:
filename: Name of the PPTX file
title: Main title of the presentation
slides_content: JSON array [{"title": "...", "content": "text or list of bullets",
"image": "URL / /api/v1/files/<id>/content / data URI (optional)",
"notes": "speaker notes (optional)"}], or {"slides": [...]}, or plain text:
"Slide 1: Title\\n- bullet\\n\\nSlide 2: ...". Indent bullets with 2 spaces for sub-levels.
"""
api_key = self._get_api_key(__user__)
if not api_key:
return "⚠️ Не указан API Key (UserValves -> openwebui_api_key)."
if not _HAS_PPTX:
return "⚠️ Не установлен пакет `python-pptx`."
err = self._check_input_size(slides_content)
if err:
return err
filename = self._safe_filename(filename, ".pptx")
title = self._ILLEGAL.sub("", str(title).strip()) or "Presentation"
# 1. Разбор входа: JSON (в т.ч. в ```-обёртке) -> иначе текст
text = self._strip_fences(slides_content)
try:
parsed = json.loads(text)
except (json.JSONDecodeError, ValueError):
parsed = None
if isinstance(parsed, dict):
if parsed.get("title") and not str(title).strip():
title = str(parsed["title"])
parsed = parsed.get("slides") or parsed.get("data")
if isinstance(parsed, list):
slides = self._normalize_slides(parsed)
else:
slides = self._normalize_slides(
self._parse_slides_from_text(str(slides_content))
)
if not slides:
return (
"❌ Ошибка PPTX: не найдено ни одного слайда. Формат:\n"
'- JSON: [{"title": "...", "content": "..."}]\n'
'- или текст: "Slide 1: Title\\n- content"'
)
slides = self._split_long_slides(slides)
if len(slides) > self.valves.max_slides:
return f"❌ Ошибка PPTX: слишком много слайдов (лимит {self.valves.max_slides})."
# 2. Картинки: загрузка асинхронно, до сборки файла
await self._emit(__event_emitter__, "Готовлю слайды…")
images = {}
for idx, it in enumerate(slides):
if not it["image"]:
continue
try:
raw_img = await self._fetch_image(it["image"], api_key)
images[idx] = await asyncio.to_thread(self._normalize_image, raw_img)
except Exception as e:
logger.warning("Image insert failed for slide %r: %s", it["title"], e)
images[idx] = str(e)[:120]
# 3. Сборка
fd, local_path = tempfile.mkstemp(suffix=".pptx")
os.close(fd)
await self._emit(__event_emitter__, "Генерирую PowerPoint…")
try:
await asyncio.to_thread(self._build_pptx, title, slides, images, local_path)
except Exception as e:
logger.exception("PPTX generation failed")
self._safe_unlink(local_path)
return f"❌ Ошибка PPTX: {e}"
await self._emit(__event_emitter__, "Загружаю файл…")
res = await self._upload_file(
local_path, filename, api_key, MIME_PPTX, __request__
)
await self._emit(__event_emitter__, "Готово", done=True)
return res