Обновить 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: Ngo Tan Tai
|
||||||
author_url: https://newnol.io.vn
|
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.
|
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
|
funding_url: https://github.com/newnol
|
||||||
requirements: python-docx, openpyxl, python-pptx, aiohttp, pillow
|
requirements: python-docx, openpyxl, python-pptx, aiohttp, pillow
|
||||||
version: 3.7
|
version: 3.7
|
||||||
@@ -21,6 +21,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -105,7 +106,7 @@ class Tools:
|
|||||||
)
|
)
|
||||||
allow_formulas: bool = Field(
|
allow_formulas: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description="Excel: строки, начинающиеся с '=', считать формулами (иначе — текст, защита от formula injection),
|
description="Excel: строки, начинающиеся с '=', считать формулами (иначе — текст, защита от formula injection)",
|
||||||
)
|
)
|
||||||
convert_numeric_strings: bool = Field(
|
convert_numeric_strings: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
@@ -459,7 +460,7 @@ class Tools:
|
|||||||
r = p.add_run(part[1:-1])
|
r = p.add_run(part[1:-1])
|
||||||
r.font.name = "Consolas"
|
r.font.name = "Consolas"
|
||||||
elif part.startswith("*") and part.endswith("*") and len(part) > 2:
|
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:
|
elif part.startswith("[") and "](" in part:
|
||||||
m = re.match(r"\[(.+?)\]\((.+)\)$", part)
|
m = re.match(r"\[(.+?)\]\((.+)\)$", part)
|
||||||
p.add_run(f"{m.group(1)} ({m.group(2)})" if m else part)
|
p.add_run(f"{m.group(1)} ({m.group(2)})" if m else part)
|
||||||
@@ -750,9 +751,9 @@ class Tools:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _drop_empty_placeholders(slide):
|
def _drop_empty_placeholders(slide):
|
||||||
for ph in slide.placeholders:
|
for sh in list(slide.placeholders):
|
||||||
if ph.has_text_frame and not ph.text_frame.text.strip():
|
if sh.has_text_frame and not sh.text_frame.text.strip():
|
||||||
ph._element.getparent().remove(ph._element)
|
sh._element.getparent().remove(sh._element)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _body_placeholder(slide):
|
def _body_placeholder(slide):
|
||||||
@@ -886,3 +887,277 @@ class Tools:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return base
|
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