Обновить document_generator.py
This commit is contained in:
1 parent
f0caa8431f
commit
ee64de8210
1 file changed
+281
-6
+281
-6
@@ -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
|
||||
Reference in new issue
Block a user