mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-19 07:59:34 +00:00
Make Office artifacts ODF-first
Promote LibreOffice-native ODT, ODS, and ODP as first-class defaults for Writer, Spreadsheet, and Presentation while keeping OOXML as explicit compatibility formats. Add ODF package generation, validation, read/edit support, and focused tests for Markdown, ODT, ODS, ODP, DOCX, XLSX, and PPTX artifact behavior. Reduce automatic document response triggering so meta-discussions about generated files do not create artifacts, while explicit file and canvas requests still work through the intended Markdown editor or Desktop affordance. Preserve the native A0 browser launcher, sync the live container, and validate the flow with real chats and Playwright.
This commit is contained in:
parent
d326513983
commit
2398bd1601
21 changed files with 1036 additions and 118 deletions
|
|
@ -33,6 +33,10 @@ class OfficeSession(ApiHandler):
|
|||
)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
if doc["extension"] in {"odt", "ods", "odp"}:
|
||||
validation = libreoffice.validate_odf(doc["path"])
|
||||
if not validation.get("ok"):
|
||||
return {"ok": False, "error": validation.get("error") or "ODF validation failed."}
|
||||
if doc["extension"] == "docx":
|
||||
validation = libreoffice.validate_docx(doc["path"])
|
||||
if not validation.get("ok"):
|
||||
|
|
|
|||
|
|
@ -20,12 +20,24 @@ R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
|||
CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
|
||||
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||||
XML_NS = "http://www.w3.org/XML/1998/namespace"
|
||||
ODF_OFFICE_NS = "urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
||||
ODF_TEXT_NS = "urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
||||
ODF_TABLE_NS = "urn:oasis:names:tc:opendocument:xmlns:table:1.0"
|
||||
ODF_DRAW_NS = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
|
||||
ODF_PRESENTATION_NS = "urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"
|
||||
ODS_DIRECT_EDIT_ROW_LIMIT = 10000
|
||||
ODS_DIRECT_EDIT_COLUMN_LIMIT = 1024
|
||||
|
||||
for prefix, namespace in {
|
||||
"w": W_NS,
|
||||
"a": A_NS,
|
||||
"p": P_NS,
|
||||
"r": R_NS,
|
||||
"office": ODF_OFFICE_NS,
|
||||
"text": ODF_TEXT_NS,
|
||||
"table": ODF_TABLE_NS,
|
||||
"draw": ODF_DRAW_NS,
|
||||
"presentation": ODF_PRESENTATION_NS,
|
||||
}.items():
|
||||
ET.register_namespace(prefix, namespace)
|
||||
|
||||
|
|
@ -40,6 +52,12 @@ def read_artifact(doc: dict[str, Any], max_chars: int = 12000) -> dict[str, Any]
|
|||
ext = str(doc["extension"]).lower()
|
||||
if ext == "md":
|
||||
content = _read_markdown(path)
|
||||
elif ext == "odt":
|
||||
content = _read_odt(path)
|
||||
elif ext == "ods":
|
||||
content = _read_ods(path)
|
||||
elif ext == "odp":
|
||||
content = _read_odp(path)
|
||||
elif ext == "docx":
|
||||
content = _read_docx(path)
|
||||
elif ext == "xlsx":
|
||||
|
|
@ -74,6 +92,12 @@ def edit_artifact(
|
|||
invalidate_sessions = bool(kwargs.pop("invalidate_sessions", False))
|
||||
if ext == "md":
|
||||
updated, details = _edit_markdown(before, op, content=content, find=find, replace=replace, **kwargs)
|
||||
elif ext == "odt":
|
||||
updated, details = _edit_odt(before, op, content=content, find=find, replace=replace, **kwargs)
|
||||
elif ext == "ods":
|
||||
updated, details = _edit_ods(before, op, content=content, find=find, replace=replace, sheet=sheet, cells=cells, rows=rows, **kwargs)
|
||||
elif ext == "odp":
|
||||
updated, details = _edit_odp(before, op, content=content, find=find, replace=replace, slides=slides, **kwargs)
|
||||
elif ext == "docx":
|
||||
updated, details = _edit_docx(before, op, content=content, find=find, replace=replace, **kwargs)
|
||||
elif ext == "xlsx":
|
||||
|
|
@ -179,6 +203,65 @@ def _read_markdown(path: Path) -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
def _read_odt(path: Path) -> dict[str, Any]:
|
||||
root = _odf_content_root(path)
|
||||
paragraphs = _odf_text_lines(root)
|
||||
headings = [
|
||||
"".join(node.itertext()).strip()
|
||||
for node in root.iter(qn(ODF_TEXT_NS, "h"))
|
||||
if "".join(node.itertext()).strip()
|
||||
]
|
||||
return {
|
||||
"kind": "document",
|
||||
"format": "odt",
|
||||
"paragraph_count": len(paragraphs),
|
||||
"headings": headings[:40],
|
||||
"text": "\n".join(paragraphs),
|
||||
"paragraphs": paragraphs[:80],
|
||||
}
|
||||
|
||||
|
||||
def _read_ods(path: Path) -> dict[str, Any]:
|
||||
sheets = _ods_sheets_from_bytes(
|
||||
path.read_bytes(),
|
||||
max_rows=ODS_DIRECT_EDIT_ROW_LIMIT,
|
||||
max_cols=ODS_DIRECT_EDIT_COLUMN_LIMIT,
|
||||
)
|
||||
return {
|
||||
"kind": "spreadsheet",
|
||||
"format": "ods",
|
||||
"sheet_count": len(sheets),
|
||||
"sheets": [
|
||||
{
|
||||
"name": sheet["name"],
|
||||
"max_row": len(sheet["rows"]),
|
||||
"max_column": max((len(row) for row in sheet["rows"]), default=0),
|
||||
"chart_count": 0,
|
||||
"charts": [],
|
||||
"preview_rows": sheet["rows"][:80],
|
||||
}
|
||||
for sheet in sheets[:8]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _read_odp(path: Path) -> dict[str, Any]:
|
||||
slides = _odp_text_slides(path.read_bytes())
|
||||
return {
|
||||
"kind": "presentation",
|
||||
"format": "odp",
|
||||
"slide_count": len(slides),
|
||||
"slides": [
|
||||
{
|
||||
"index": index + 1,
|
||||
"title": slide.get("title", ""),
|
||||
"lines": [slide.get("title", ""), *slide.get("bullets", [])],
|
||||
}
|
||||
for index, slide in enumerate(slides[:40])
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _read_docx(path: Path) -> dict[str, Any]:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
xml = archive.read("word/document.xml")
|
||||
|
|
@ -272,6 +355,30 @@ def _edit_markdown(before: bytes, op: str, *, content: str = "", find: str = "",
|
|||
return updated.encode("utf-8"), details
|
||||
|
||||
|
||||
def _edit_odt(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
|
||||
if op not in {"set_text", "append_text", "prepend_text", "replace_text", "delete_text"}:
|
||||
raise ValueError(f"Unsupported ODT operation: {op}")
|
||||
|
||||
paragraphs = _odf_text_lines(ET.fromstring(_zip_member(before, "content.xml")))
|
||||
if op == "set_text":
|
||||
lines = _text_lines(content)
|
||||
return document_store.odt_bytes_from_paragraphs(lines), {"paragraphs_written": len(lines)}
|
||||
if op == "append_text":
|
||||
lines = [*paragraphs, *_text_lines(content)]
|
||||
return document_store.odt_bytes_from_paragraphs(lines), {"paragraphs_written": len(lines)}
|
||||
if op == "prepend_text":
|
||||
lines = [*_text_lines(content), *paragraphs]
|
||||
return document_store.odt_bytes_from_paragraphs(lines), {"paragraphs_written": len(lines)}
|
||||
|
||||
if not find:
|
||||
raise ValueError("find is required for replace_text")
|
||||
replacement = "" if op == "delete_text" else replace
|
||||
joined, count = _replace_limited("\n".join(paragraphs), find, replacement, _int_or_none(kwargs.get("count")))
|
||||
if count == 0:
|
||||
return before, {"replacements": count}
|
||||
return document_store.odt_bytes_from_paragraphs(joined.splitlines()), {"replacements": count}
|
||||
|
||||
|
||||
def _edit_docx(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
|
||||
if op not in {"set_text", "append_text", "prepend_text", "replace_text", "delete_text"}:
|
||||
raise ValueError(f"Unsupported DOCX operation: {op}")
|
||||
|
|
@ -327,6 +434,75 @@ def _edit_docx(before: bytes, op: str, *, content: str = "", find: str = "", rep
|
|||
return _zip_from_existing(files), details
|
||||
|
||||
|
||||
def _edit_ods(
|
||||
before: bytes,
|
||||
op: str,
|
||||
*,
|
||||
content: str = "",
|
||||
find: str = "",
|
||||
replace: str = "",
|
||||
sheet: str = "",
|
||||
cells: Any = None,
|
||||
rows: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[bytes, dict[str, Any]]:
|
||||
if op not in {"set_text", "set_rows", "append_text", "append_rows", "set_cells", "replace_text", "delete_text"}:
|
||||
raise ValueError(f"Unsupported ODS operation: {op}")
|
||||
|
||||
sheets = _ods_sheets_from_bytes(
|
||||
before,
|
||||
max_rows=ODS_DIRECT_EDIT_ROW_LIMIT,
|
||||
max_cols=ODS_DIRECT_EDIT_COLUMN_LIMIT,
|
||||
strict_limits=True,
|
||||
)
|
||||
if not sheets:
|
||||
sheets = [{"name": "Sheet1", "rows": []}]
|
||||
worksheet = _ods_sheet(sheets, sheet)
|
||||
details: dict[str, Any] = {"sheet": worksheet["name"]}
|
||||
|
||||
if op in {"set_text", "set_rows"}:
|
||||
parsed_rows = _normalize_rows(rows if rows is not None else content)
|
||||
worksheet["rows"] = parsed_rows
|
||||
details["rows_written"] = len(parsed_rows)
|
||||
elif op in {"append_text", "append_rows"}:
|
||||
parsed_rows = _normalize_rows(rows if rows is not None else content)
|
||||
worksheet["rows"].extend(parsed_rows)
|
||||
details["rows_appended"] = len(parsed_rows)
|
||||
details["start_row"] = max(len(worksheet["rows"]) - len(parsed_rows) + 1, 1)
|
||||
elif op == "set_cells":
|
||||
assignments = _normalize_cells(cells, default_sheet=worksheet["name"])
|
||||
for sheet_name, cell, value in assignments:
|
||||
target = _ods_sheet(sheets, sheet_name)
|
||||
row_idx, col_idx = _cell_indices(cell)
|
||||
_set_matrix_value(target["rows"], row_idx, col_idx, value)
|
||||
details["cells_written"] = len(assignments)
|
||||
else:
|
||||
if not find:
|
||||
raise ValueError("find is required for replace_text")
|
||||
replacement = "" if op == "delete_text" else replace
|
||||
count = 0
|
||||
limit = _int_or_none(kwargs.get("count"))
|
||||
for item in sheets:
|
||||
for row_idx, row in enumerate(item["rows"]):
|
||||
for col_idx, value in enumerate(row):
|
||||
if not isinstance(value, str) or find not in value:
|
||||
continue
|
||||
remaining = None if limit is None else max(limit - count, 0)
|
||||
if remaining == 0:
|
||||
break
|
||||
row[col_idx], replaced = _replace_limited(value, find, replacement, remaining)
|
||||
count += replaced
|
||||
if limit is not None and count >= limit:
|
||||
break
|
||||
if limit is not None and count >= limit:
|
||||
break
|
||||
details["replacements"] = count
|
||||
if count == 0:
|
||||
return before, details
|
||||
|
||||
return document_store.ods_bytes_from_sheets(sheets), details
|
||||
|
||||
|
||||
def _edit_xlsx(
|
||||
path: Path,
|
||||
op: str,
|
||||
|
|
@ -956,6 +1132,57 @@ def _edit_pptx(before: bytes, op: str, *, content: str = "", find: str = "", rep
|
|||
return _zip_from_existing(files), {"replacements": count}
|
||||
|
||||
|
||||
def _edit_odp(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", slides: Any = None, **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
|
||||
if op not in {"set_text", "set_slides", "append_text", "append_slide", "replace_text", "delete_text"}:
|
||||
raise ValueError(f"Unsupported ODP operation: {op}")
|
||||
|
||||
if op in {"set_text", "set_slides"}:
|
||||
parsed_slides = _normalize_slides(slides if slides is not None else content)
|
||||
return document_store.odp_bytes_from_slides(parsed_slides), {"slides_written": len(parsed_slides)}
|
||||
|
||||
existing = _odp_text_slides(before)
|
||||
if op in {"append_text", "append_slide"}:
|
||||
existing.extend(_normalize_slides(slides if slides is not None else content))
|
||||
return document_store.odp_bytes_from_slides(existing), {"slides_written": len(existing)}
|
||||
|
||||
if not find:
|
||||
raise ValueError("find is required for replace_text")
|
||||
replacement = "" if op == "delete_text" else replace
|
||||
count = 0
|
||||
limit = _int_or_none(kwargs.get("count"))
|
||||
for slide in existing:
|
||||
title, title_count = _replace_limited(
|
||||
str(slide.get("title") or ""),
|
||||
find,
|
||||
replacement,
|
||||
None if limit is None else max(limit - count, 0),
|
||||
)
|
||||
if title_count:
|
||||
slide["title"] = title
|
||||
count += title_count
|
||||
if limit is not None and count >= limit:
|
||||
break
|
||||
bullets = []
|
||||
for bullet in slide.get("bullets") or []:
|
||||
updated, replaced = _replace_limited(
|
||||
str(bullet),
|
||||
find,
|
||||
replacement,
|
||||
None if limit is None else max(limit - count, 0),
|
||||
)
|
||||
bullets.append(updated)
|
||||
count += replaced
|
||||
if limit is not None and count >= limit:
|
||||
bullets.extend(slide.get("bullets", [])[len(bullets):])
|
||||
break
|
||||
slide["bullets"] = bullets
|
||||
if limit is not None and count >= limit:
|
||||
break
|
||||
if count == 0:
|
||||
return before, {"replacements": count}
|
||||
return document_store.odp_bytes_from_slides(existing), {"replacements": count}
|
||||
|
||||
|
||||
def _replace_text_in_paragraphs(
|
||||
root: ET.Element,
|
||||
*,
|
||||
|
|
@ -1140,6 +1367,188 @@ def _markdown_table_rows(lines: list[str]) -> list[list[str]]:
|
|||
return rows
|
||||
|
||||
|
||||
def _zip_member(data: bytes, name: str) -> bytes:
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
return archive.read(name)
|
||||
|
||||
|
||||
def _odf_content_root(path: Path) -> ET.Element:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
return ET.fromstring(archive.read("content.xml"))
|
||||
|
||||
|
||||
def _odf_text_lines(root: ET.Element) -> list[str]:
|
||||
lines = []
|
||||
for node in root.iter():
|
||||
if node.tag not in {qn(ODF_TEXT_NS, "h"), qn(ODF_TEXT_NS, "p")}:
|
||||
continue
|
||||
text = "".join(node.itertext()).strip()
|
||||
if text:
|
||||
lines.append(text)
|
||||
return lines
|
||||
|
||||
|
||||
def _ods_sheets_from_bytes(
|
||||
data: bytes,
|
||||
*,
|
||||
max_rows: int | None = None,
|
||||
max_cols: int | None = None,
|
||||
strict_limits: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
root = ET.fromstring(_zip_member(data, "content.xml"))
|
||||
sheets = []
|
||||
for index, table in enumerate(root.iter(qn(ODF_TABLE_NS, "table")), start=1):
|
||||
name = table.get(qn(ODF_TABLE_NS, "name")) or table.get("name") or f"Sheet{index}"
|
||||
rows = []
|
||||
for row in table:
|
||||
if row.tag != qn(ODF_TABLE_NS, "table-row"):
|
||||
continue
|
||||
values = _ods_row_values(row, max_cols=max_cols, strict_limits=strict_limits)
|
||||
repeat_rows = _repeat_count(row.get(qn(ODF_TABLE_NS, "number-rows-repeated")))
|
||||
append_count = repeat_rows
|
||||
if max_rows is not None:
|
||||
remaining = max(max_rows - len(rows), 0)
|
||||
append_count = min(repeat_rows, remaining)
|
||||
if strict_limits and repeat_rows > append_count and _row_has_content(values):
|
||||
raise ValueError(f"ODS direct editing is limited to {max_rows} populated rows per sheet.")
|
||||
if remaining == 0:
|
||||
if not strict_limits:
|
||||
break
|
||||
continue
|
||||
for _ in range(append_count):
|
||||
rows.append(values.copy())
|
||||
if max_rows is not None and len(rows) >= max_rows and not strict_limits:
|
||||
break
|
||||
sheets.append({"name": name, "rows": _trim_blank_edges(rows)})
|
||||
return sheets
|
||||
|
||||
|
||||
def _ods_row_values(
|
||||
row: ET.Element,
|
||||
*,
|
||||
max_cols: int | None = None,
|
||||
strict_limits: bool = False,
|
||||
) -> list[Any]:
|
||||
values = []
|
||||
for cell in row:
|
||||
if cell.tag not in {qn(ODF_TABLE_NS, "table-cell"), qn(ODF_TABLE_NS, "covered-table-cell")}:
|
||||
continue
|
||||
value = _ods_cell_value(cell)
|
||||
repeat = _repeat_count(cell.get(qn(ODF_TABLE_NS, "number-columns-repeated")))
|
||||
append_count = repeat
|
||||
if max_cols is not None:
|
||||
remaining = max(max_cols - len(values), 0)
|
||||
append_count = min(repeat, remaining)
|
||||
if strict_limits and repeat > append_count and _cell_has_content(value):
|
||||
raise ValueError(f"ODS direct editing is limited to {max_cols} populated columns per sheet.")
|
||||
if remaining == 0:
|
||||
if not strict_limits:
|
||||
break
|
||||
continue
|
||||
for _ in range(append_count):
|
||||
values.append(value)
|
||||
if max_cols is not None and len(values) >= max_cols and not strict_limits:
|
||||
break
|
||||
return values
|
||||
|
||||
|
||||
def _ods_cell_value(cell: ET.Element) -> Any:
|
||||
value_type = str(cell.get(qn(ODF_OFFICE_NS, "value-type")) or "").lower()
|
||||
if value_type in {"float", "currency", "percentage"}:
|
||||
raw = cell.get(qn(ODF_OFFICE_NS, "value"))
|
||||
if raw not in (None, ""):
|
||||
try:
|
||||
number = float(raw)
|
||||
return int(number) if number.is_integer() else number
|
||||
except ValueError:
|
||||
pass
|
||||
if value_type == "boolean":
|
||||
raw = str(cell.get(qn(ODF_OFFICE_NS, "boolean-value")) or "").lower()
|
||||
if raw in {"true", "false"}:
|
||||
return raw == "true"
|
||||
text = "\n".join("".join(node.itertext()).strip() for node in cell.iter(qn(ODF_TEXT_NS, "p")))
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _repeat_count(value: Any) -> int:
|
||||
try:
|
||||
count = int(value or 1)
|
||||
except (TypeError, ValueError):
|
||||
count = 1
|
||||
return max(1, count)
|
||||
|
||||
|
||||
def _trim_blank_edges(rows: list[list[Any]]) -> list[list[Any]]:
|
||||
trimmed = []
|
||||
for row in rows:
|
||||
next_row = list(row)
|
||||
while next_row and not _cell_has_content(next_row[-1]):
|
||||
next_row.pop()
|
||||
trimmed.append(next_row)
|
||||
while trimmed and not _row_has_content(trimmed[-1]):
|
||||
trimmed.pop()
|
||||
return trimmed
|
||||
|
||||
|
||||
def _cell_has_content(value: Any) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, str):
|
||||
return bool(value.strip())
|
||||
return True
|
||||
|
||||
|
||||
def _row_has_content(row: list[Any]) -> bool:
|
||||
return any(_cell_has_content(value) for value in row)
|
||||
|
||||
|
||||
def _ods_sheet(sheets: list[dict[str, Any]], name: str = "") -> dict[str, Any]:
|
||||
normalized = str(name or "").strip()
|
||||
if normalized:
|
||||
for sheet in sheets:
|
||||
if str(sheet["name"]).casefold() == normalized.casefold():
|
||||
return sheet
|
||||
sheet = {"name": normalized, "rows": []}
|
||||
sheets.append(sheet)
|
||||
return sheet
|
||||
return sheets[0]
|
||||
|
||||
|
||||
def _cell_indices(cell: str) -> tuple[int, int]:
|
||||
match = re.fullmatch(r"\$?([A-Za-z]{1,4})\$?([1-9][0-9]*)", str(cell or "").strip())
|
||||
if not match:
|
||||
raise ValueError(f"Invalid cell reference: {cell}")
|
||||
col = 0
|
||||
for char in match.group(1).upper():
|
||||
col = col * 26 + (ord(char) - 64)
|
||||
return int(match.group(2)), col
|
||||
|
||||
|
||||
def _set_matrix_value(rows: list[list[Any]], row_idx: int, col_idx: int, value: Any) -> None:
|
||||
while len(rows) < row_idx:
|
||||
rows.append([])
|
||||
row = rows[row_idx - 1]
|
||||
while len(row) < col_idx:
|
||||
row.append("")
|
||||
row[col_idx - 1] = value
|
||||
|
||||
|
||||
def _odp_text_slides(data: bytes) -> list[dict[str, Any]]:
|
||||
root = ET.fromstring(_zip_member(data, "content.xml"))
|
||||
slides = []
|
||||
for page in root.iter(qn(ODF_DRAW_NS, "page")):
|
||||
lines = []
|
||||
for node in page.iter():
|
||||
if node.tag not in {qn(ODF_TEXT_NS, "h"), qn(ODF_TEXT_NS, "p")}:
|
||||
continue
|
||||
text = "".join(node.itertext()).strip()
|
||||
if text:
|
||||
lines.append(text)
|
||||
if lines:
|
||||
slides.append({"title": lines[0], "bullets": lines[1:]})
|
||||
return slides
|
||||
|
||||
|
||||
def _pptx_text_slides(data: bytes) -> list[dict[str, Any]]:
|
||||
slides = []
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ DOCUMENT_TERMS = {
|
|||
"manual",
|
||||
"markdown",
|
||||
"memo",
|
||||
"odt",
|
||||
"open document",
|
||||
"opendocument",
|
||||
"policy",
|
||||
"proposal",
|
||||
"report",
|
||||
|
|
@ -49,11 +52,14 @@ DOCUMENT_TERMS = {
|
|||
"spec",
|
||||
"story",
|
||||
"whitepaper",
|
||||
"writer",
|
||||
}
|
||||
|
||||
SPREADSHEET_TERMS = {
|
||||
"budget",
|
||||
"calc",
|
||||
"excel",
|
||||
"ods",
|
||||
"sheet",
|
||||
"spreadsheet",
|
||||
"table",
|
||||
|
|
@ -63,6 +69,8 @@ SPREADSHEET_TERMS = {
|
|||
|
||||
PRESENTATION_TERMS = {
|
||||
"deck",
|
||||
"impress",
|
||||
"odp",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"presentation",
|
||||
|
|
@ -89,6 +97,9 @@ EXPLICIT_FORMAT_TERMS = {
|
|||
"docx",
|
||||
"md",
|
||||
"markdown",
|
||||
"odp",
|
||||
"ods",
|
||||
"odt",
|
||||
"pptx",
|
||||
"xlsx",
|
||||
}
|
||||
|
|
@ -98,8 +109,10 @@ HANDOFF_TERMS = {
|
|||
"artifacts",
|
||||
"canvas",
|
||||
"document canvas",
|
||||
"download",
|
||||
"downloadable",
|
||||
"editable",
|
||||
"export",
|
||||
"open it",
|
||||
"save it",
|
||||
"save this",
|
||||
|
|
@ -119,6 +132,36 @@ CHAT_ONLY_TERMS = {
|
|||
"no files",
|
||||
}
|
||||
|
||||
META_DISCUSSION_TERMS = {
|
||||
"affordance",
|
||||
"automatically",
|
||||
"auto",
|
||||
"disable",
|
||||
"issue",
|
||||
"less triggered",
|
||||
"problem",
|
||||
"speedbump",
|
||||
"stop",
|
||||
"trigger",
|
||||
"triggered",
|
||||
"why",
|
||||
}
|
||||
|
||||
OOXML_COMPAT_TERMS = {
|
||||
"docx",
|
||||
"excel",
|
||||
"microsoft word",
|
||||
"powerpoint",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"word",
|
||||
"xlsx",
|
||||
}
|
||||
|
||||
ODF_DOCUMENT_TERMS = {"odt", "open document text", "opendocument text", "writer"}
|
||||
ODF_SPREADSHEET_TERMS = {"calc", "ods", "open document spreadsheet", "opendocument spreadsheet"}
|
||||
ODF_PRESENTATION_TERMS = {"impress", "odp", "open document presentation", "opendocument presentation"}
|
||||
|
||||
SKIP_RESPONSE_PREFIXES = (
|
||||
"i can't",
|
||||
"i cannot",
|
||||
|
|
@ -200,11 +243,17 @@ def normalize_text(value: str) -> str:
|
|||
|
||||
def infer_kind_and_format(lowered_user: str) -> tuple[str, str]:
|
||||
if has_any(lowered_user, PRESENTATION_TERMS):
|
||||
return "presentation", "pptx"
|
||||
if has_any(lowered_user, {"powerpoint", "ppt", "pptx"}):
|
||||
return "presentation", "pptx"
|
||||
return "presentation", "odp"
|
||||
if has_any(lowered_user, SPREADSHEET_TERMS):
|
||||
return "spreadsheet", "xlsx"
|
||||
if has_any(lowered_user, {"docx"}):
|
||||
if has_any(lowered_user, {"excel", "xlsx"}):
|
||||
return "spreadsheet", "xlsx"
|
||||
return "spreadsheet", "ods"
|
||||
if has_any(lowered_user, {"docx", "microsoft word", "word"}):
|
||||
return "document", "docx"
|
||||
if has_any(lowered_user, ODF_DOCUMENT_TERMS):
|
||||
return "document", "odt"
|
||||
return "document", "md"
|
||||
|
||||
|
||||
|
|
@ -213,8 +262,6 @@ def artifact_intent(lowered_user: str, response_text: str) -> str | None:
|
|||
return None
|
||||
if has_explicit_handoff_signal(lowered_user):
|
||||
return "explicit_handoff"
|
||||
if has_any(lowered_user, DELIVERABLE_TERMS) and looks_like_standalone_artifact(response_text):
|
||||
return "document_intent"
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -226,25 +273,42 @@ def has_document_creation_intent(lowered_user: str) -> bool:
|
|||
|
||||
|
||||
def has_explicit_handoff_signal(lowered_user: str) -> bool:
|
||||
if has_any(lowered_user, EXPLICIT_FORMAT_TERMS | HANDOFF_TERMS):
|
||||
return True
|
||||
if has_any(lowered_user, FILE_HANDOFF_TERMS) and has_any(
|
||||
lowered_user,
|
||||
DOCUMENT_TERMS | SPREADSHEET_TERMS | PRESENTATION_TERMS,
|
||||
):
|
||||
if looks_like_affordance_meta_discussion(lowered_user):
|
||||
return False
|
||||
|
||||
creation = r"(?:write|draft|compose|create|generate|prepare|produce|make|build|author|format|convert|turn|save|export)"
|
||||
handoff = r"(?:file|files|artifact|artifacts|canvas|download|downloadable|editable file|open in canvas)"
|
||||
format_name = (
|
||||
r"(?:md|markdown|odt|ods|odp|docx|xlsx|pptx|writer|calc|impress|word|excel|"
|
||||
r"powerpoint|document|spreadsheet|workbook|presentation|deck|slides)"
|
||||
)
|
||||
|
||||
if re.search(rf"\b{creation}\b(?:\W+\w+){{0,10}}\W+\b{handoff}\b", lowered_user):
|
||||
return True
|
||||
if re.search(
|
||||
r"\b(?:convert|format|save|turn)\b(?:\W+\w+){0,8}?\W+(?:as|to|into)\s+"
|
||||
r"(?:a|an|the)?\s*(?:doc|document|markdown|spreadsheet|workbook|presentation|deck|slides|md|docx|xlsx|pptx)\b",
|
||||
rf"\b{creation}\b(?:\W+\w+){{0,10}}\W+(?:as|to|into)\s+"
|
||||
rf"(?:a|an|the)?\s*{format_name}\b",
|
||||
lowered_user,
|
||||
):
|
||||
return True
|
||||
return bool(re.search(
|
||||
r"\b(?:write|draft|compose|create|generate|prepare|produce|make|build|author|format)\b"
|
||||
r"(?:\s+(?:me|us|a|an|the|new|blank|editable|office|word|excel|powerpoint))*"
|
||||
r"\s+(?:doc|document|markdown|spreadsheet|workbook|presentation|deck|slides)\b",
|
||||
if re.search(rf"\b{creation}\b(?:\W+\w+){{0,10}}\W+\b(?:md|markdown|odt|ods|odp|docx|xlsx|pptx|writer|calc|impress)\b", lowered_user):
|
||||
return True
|
||||
return bool(
|
||||
has_any(lowered_user, FILE_HANDOFF_TERMS | HANDOFF_TERMS)
|
||||
and has_any(
|
||||
lowered_user,
|
||||
EXPLICIT_FORMAT_TERMS | ODF_DOCUMENT_TERMS | ODF_SPREADSHEET_TERMS | ODF_PRESENTATION_TERMS | OOXML_COMPAT_TERMS,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def looks_like_affordance_meta_discussion(lowered_user: str) -> bool:
|
||||
if not has_any(lowered_user, META_DISCUSSION_TERMS):
|
||||
return False
|
||||
return has_any(
|
||||
lowered_user,
|
||||
))
|
||||
DOCUMENT_TERMS | SPREADSHEET_TERMS | PRESENTATION_TERMS | EXPLICIT_FORMAT_TERMS | FILE_HANDOFF_TERMS,
|
||||
)
|
||||
|
||||
|
||||
def has_any(text: str, terms: set[str]) -> bool:
|
||||
|
|
|
|||
|
|
@ -20,9 +20,25 @@ from plugins._office.helpers import pptx_writer
|
|||
|
||||
|
||||
PLUGIN_NAME = "_office"
|
||||
SUPPORTED_EXTENSIONS = {"md", "docx", "xlsx", "pptx"}
|
||||
OPEN_DOCUMENT_EXTENSIONS = {"odt", "ods", "odp"}
|
||||
OOXML_EXTENSIONS = {"docx", "xlsx", "pptx"}
|
||||
SUPPORTED_EXTENSIONS = {"md", *OPEN_DOCUMENT_EXTENSIONS, *OOXML_EXTENSIONS}
|
||||
DEFAULT_TTL_SECONDS = 8 * 60 * 60
|
||||
MAX_SAVE_BYTES = 512 * 1024 * 1024
|
||||
ODF_OFFICE_NS = "urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
||||
ODF_TEXT_NS = "urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
||||
ODF_TABLE_NS = "urn:oasis:names:tc:opendocument:xmlns:table:1.0"
|
||||
ODF_DRAW_NS = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
|
||||
ODF_PRESENTATION_NS = "urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"
|
||||
ODF_STYLE_NS = "urn:oasis:names:tc:opendocument:xmlns:style:1.0"
|
||||
ODF_FO_NS = "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
|
||||
ODF_MANIFEST_NS = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"
|
||||
ODF_VERSION = "1.2"
|
||||
ODF_MIMETYPES = {
|
||||
"odt": "application/vnd.oasis.opendocument.text",
|
||||
"ods": "application/vnd.oasis.opendocument.spreadsheet",
|
||||
"odp": "application/vnd.oasis.opendocument.presentation",
|
||||
}
|
||||
|
||||
STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "documents"))
|
||||
DB_PATH = STATE_DIR / "documents.sqlite3"
|
||||
|
|
@ -58,8 +74,6 @@ def normalize_extension(value: str) -> str:
|
|||
if not ext:
|
||||
ext = "md"
|
||||
if ext not in SUPPORTED_EXTENSIONS:
|
||||
if ext == "odt":
|
||||
raise ValueError("ODT editing is not supported in this migration. Use Markdown or DOCX.")
|
||||
raise ValueError(f"Unsupported document format: {ext}")
|
||||
return ext
|
||||
|
||||
|
|
@ -599,7 +613,7 @@ def create_document(
|
|||
|
||||
|
||||
def _unique_document_path(title: str, ext: str, context_id: str = "") -> Path:
|
||||
base = safe_title(title, "Document")
|
||||
base = safe_document_stem(title, ext, "Document")
|
||||
root = document_home(context_id) if ext == "md" else document_binary_home(context_id)
|
||||
candidate = root / f"{base}.{ext}"
|
||||
index = 2
|
||||
|
|
@ -609,10 +623,24 @@ def _unique_document_path(title: str, ext: str, context_id: str = "") -> Path:
|
|||
return candidate.resolve(strict=False)
|
||||
|
||||
|
||||
def safe_document_stem(title: str, ext: str, fallback: str = "Document") -> str:
|
||||
base = safe_title(title, fallback)
|
||||
suffix = f".{normalize_extension(ext)}"
|
||||
if base.casefold().endswith(suffix.casefold()):
|
||||
base = base[: -len(suffix)].rstrip(" ._") or fallback
|
||||
return base
|
||||
|
||||
|
||||
def template_bytes(kind: str, ext: str, title: str, content: str) -> bytes:
|
||||
ext = normalize_extension(ext or "md")
|
||||
if ext == "md":
|
||||
return _markdown(title, content).encode("utf-8")
|
||||
if ext == "odt":
|
||||
return odt_bytes(title, content)
|
||||
if ext == "ods":
|
||||
return ods_bytes(title, content)
|
||||
if ext == "odp":
|
||||
return odp_bytes(title, content)
|
||||
if ext == "docx":
|
||||
return _docx(title, content)
|
||||
if ext == "xlsx":
|
||||
|
|
@ -638,6 +666,219 @@ def _zip_bytes(files_map: dict[str, str | bytes]) -> bytes:
|
|||
return buffer.getvalue()
|
||||
|
||||
|
||||
def odf_zip_bytes(ext: str, files_map: dict[str, str | bytes]) -> bytes:
|
||||
ext = normalize_extension(ext)
|
||||
if ext not in ODF_MIMETYPES:
|
||||
raise ValueError(f"Unsupported ODF format: {ext}")
|
||||
media_type = ODF_MIMETYPES[ext]
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as archive:
|
||||
archive.writestr("mimetype", media_type, compress_type=zipfile.ZIP_STORED)
|
||||
for name, value in files_map.items():
|
||||
if name == "mimetype":
|
||||
continue
|
||||
data = value.encode("utf-8") if isinstance(value, str) else value
|
||||
archive.writestr(name, data, compress_type=zipfile.ZIP_DEFLATED)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def odt_bytes(title: str, content: str) -> bytes:
|
||||
return odt_bytes_from_paragraphs(_document_lines(title, content))
|
||||
|
||||
|
||||
def odt_bytes_from_paragraphs(paragraphs: list[str]) -> bytes:
|
||||
lines = [str(line) for line in paragraphs] or [""]
|
||||
body = "\n".join(_odt_paragraph(line, index == 0) for index, line in enumerate(lines))
|
||||
return _odf_package(
|
||||
"odt",
|
||||
f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-content {_odf_content_namespaces()} office:version="{ODF_VERSION}">
|
||||
<office:body>
|
||||
<office:text>
|
||||
{body}
|
||||
</office:text>
|
||||
</office:body>
|
||||
</office:document-content>
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def ods_bytes(title: str, content: str) -> bytes:
|
||||
return ods_bytes_from_sheets([{"name": "Sheet1", "rows": _xlsx_rows(title, content)}])
|
||||
|
||||
|
||||
def ods_bytes_from_sheets(sheets: list[dict[str, Any]]) -> bytes:
|
||||
normalized = []
|
||||
for index, sheet in enumerate(sheets or []):
|
||||
name = safe_title(str(sheet.get("name") or f"Sheet{index + 1}"), f"Sheet{index + 1}")[:31] or f"Sheet{index + 1}"
|
||||
rows = sheet.get("rows") or []
|
||||
normalized.append({"name": name, "rows": rows})
|
||||
if not normalized:
|
||||
normalized = [{"name": "Sheet1", "rows": [["Spreadsheet"]]}]
|
||||
|
||||
tables = "\n".join(
|
||||
f"""<table:table table:name="{escape(sheet['name'])}">
|
||||
{''.join(_ods_row(row) for row in sheet['rows'])}
|
||||
</table:table>"""
|
||||
for sheet in normalized
|
||||
)
|
||||
return _odf_package(
|
||||
"ods",
|
||||
f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-content {_odf_content_namespaces()} office:version="{ODF_VERSION}">
|
||||
<office:body>
|
||||
<office:spreadsheet>
|
||||
{tables}
|
||||
</office:spreadsheet>
|
||||
</office:body>
|
||||
</office:document-content>
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def odp_bytes(title: str, content: str) -> bytes:
|
||||
return odp_bytes_from_slides(pptx_writer.slides_from_text(title, content))
|
||||
|
||||
|
||||
def odp_bytes_from_slides(slides: list[dict[str, Any]]) -> bytes:
|
||||
normalized = pptx_writer.normalize_slides(slides)
|
||||
if not normalized:
|
||||
normalized = [{"title": "Presentation", "bullets": []}]
|
||||
pages = "\n".join(_odp_page(slide, index) for index, slide in enumerate(normalized, start=1))
|
||||
return _odf_package(
|
||||
"odp",
|
||||
f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-content {_odf_content_namespaces()} office:version="{ODF_VERSION}">
|
||||
<office:body>
|
||||
<office:presentation>
|
||||
{pages}
|
||||
</office:presentation>
|
||||
</office:body>
|
||||
</office:document-content>
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def _document_lines(title: str, content: str) -> list[str]:
|
||||
lines = [str(title or "Document").strip() or "Document"]
|
||||
lines.extend(line.rstrip() for line in str(content or "").splitlines() if line.strip())
|
||||
if len(lines) == 1:
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
|
||||
def _odf_package(ext: str, content_xml: str) -> bytes:
|
||||
return odf_zip_bytes(
|
||||
ext,
|
||||
{
|
||||
"content.xml": content_xml,
|
||||
"styles.xml": _odf_styles_xml(),
|
||||
"meta.xml": _odf_meta_xml(),
|
||||
"settings.xml": _odf_settings_xml(),
|
||||
"META-INF/manifest.xml": _odf_manifest_xml(ODF_MIMETYPES[ext]),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _odf_content_namespaces() -> str:
|
||||
return (
|
||||
f'xmlns:office="{ODF_OFFICE_NS}" '
|
||||
f'xmlns:text="{ODF_TEXT_NS}" '
|
||||
f'xmlns:table="{ODF_TABLE_NS}" '
|
||||
f'xmlns:draw="{ODF_DRAW_NS}" '
|
||||
f'xmlns:presentation="{ODF_PRESENTATION_NS}" '
|
||||
f'xmlns:style="{ODF_STYLE_NS}" '
|
||||
f'xmlns:fo="{ODF_FO_NS}"'
|
||||
)
|
||||
|
||||
|
||||
def _odf_styles_xml() -> str:
|
||||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-styles {_odf_content_namespaces()} office:version="{ODF_VERSION}">
|
||||
<office:styles>
|
||||
<style:style style:name="Standard" style:family="paragraph"/>
|
||||
<style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph">
|
||||
<style:text-properties fo:font-weight="bold" fo:font-size="18pt"/>
|
||||
</style:style>
|
||||
</office:styles>
|
||||
</office:document-styles>
|
||||
"""
|
||||
|
||||
|
||||
def _odf_meta_xml() -> str:
|
||||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-meta xmlns:office="{ODF_OFFICE_NS}" office:version="{ODF_VERSION}">
|
||||
<office:meta/>
|
||||
</office:document-meta>
|
||||
"""
|
||||
|
||||
|
||||
def _odf_settings_xml() -> str:
|
||||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-settings xmlns:office="{ODF_OFFICE_NS}" office:version="{ODF_VERSION}">
|
||||
<office:settings/>
|
||||
</office:document-settings>
|
||||
"""
|
||||
|
||||
|
||||
def _odf_manifest_xml(media_type: str) -> str:
|
||||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<manifest:manifest xmlns:manifest="{ODF_MANIFEST_NS}" manifest:version="{ODF_VERSION}">
|
||||
<manifest:file-entry manifest:full-path="/" manifest:media-type="{media_type}"/>
|
||||
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
|
||||
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
|
||||
<manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
|
||||
<manifest:file-entry manifest:full-path="settings.xml" manifest:media-type="text/xml"/>
|
||||
</manifest:manifest>
|
||||
"""
|
||||
|
||||
|
||||
def _odt_paragraph(line: str, heading: bool = False) -> str:
|
||||
text = escape(str(line))
|
||||
if heading:
|
||||
return f'<text:h text:outline-level="1">{text}</text:h>'
|
||||
return f"<text:p>{text}</text:p>"
|
||||
|
||||
|
||||
def _ods_row(row: list[Any]) -> str:
|
||||
cells = "".join(_ods_cell(value) for value in row)
|
||||
return f"<table:table-row>{cells}</table:table-row>"
|
||||
|
||||
|
||||
def _ods_cell(value: Any) -> str:
|
||||
value = _xlsx_value(value)
|
||||
if value in (None, ""):
|
||||
return "<table:table-cell/>"
|
||||
if isinstance(value, bool):
|
||||
text = "TRUE" if value else "FALSE"
|
||||
return (
|
||||
f'<table:table-cell office:value-type="boolean" office:boolean-value="{str(value).lower()}">'
|
||||
f"<text:p>{text}</text:p></table:table-cell>"
|
||||
)
|
||||
if isinstance(value, (int, float)):
|
||||
return (
|
||||
f'<table:table-cell office:value-type="float" office:value="{value}">'
|
||||
f"<text:p>{value}</text:p></table:table-cell>"
|
||||
)
|
||||
text = escape(str(value))
|
||||
return f'<table:table-cell office:value-type="string"><text:p>{text}</text:p></table:table-cell>'
|
||||
|
||||
|
||||
def _odp_page(slide: dict[str, Any], index: int) -> str:
|
||||
title = escape(str(slide.get("title") or f"Slide {index}"))
|
||||
bullets = [escape(str(item)) for item in slide.get("bullets") or []]
|
||||
bullet_items = "".join(f"<text:list-item><text:p>{bullet}</text:p></text:list-item>" for bullet in bullets)
|
||||
body = f"<text:list>{bullet_items}</text:list>" if bullet_items else "<text:p/>"
|
||||
return f"""<draw:page draw:name="Slide {index}" draw:master-page-name="Default">
|
||||
<draw:frame presentation:class="title" draw:name="Title {index}" svg:width="24cm" svg:height="2cm" svg:x="1.5cm" svg:y="1cm" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0">
|
||||
<draw:text-box><text:p>{title}</text:p></draw:text-box>
|
||||
</draw:frame>
|
||||
<draw:frame presentation:class="outline" draw:name="Content {index}" svg:width="24cm" svg:height="12cm" svg:x="1.5cm" svg:y="3.5cm" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0">
|
||||
<draw:text-box>{body}</draw:text-box>
|
||||
</draw:frame>
|
||||
</draw:page>"""
|
||||
|
||||
|
||||
def _docx(title: str, content: str) -> bytes:
|
||||
lines = [title] + [line for line in content.splitlines() if line.strip()]
|
||||
if len(lines) == 1:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ from typing import Any
|
|||
|
||||
SOFFICE_BINARIES = ("soffice", "libreoffice")
|
||||
CONVERT_TIMEOUT_SECONDS = 45
|
||||
ODF_MIMETYPES = {
|
||||
"odt": "application/vnd.oasis.opendocument.text",
|
||||
"ods": "application/vnd.oasis.opendocument.spreadsheet",
|
||||
"odp": "application/vnd.oasis.opendocument.presentation",
|
||||
}
|
||||
|
||||
|
||||
def find_soffice() -> str:
|
||||
|
|
@ -73,6 +78,29 @@ def validate_docx(path: str | Path) -> dict[str, Any]:
|
|||
return {"ok": True}
|
||||
|
||||
|
||||
def validate_odf(path: str | Path) -> dict[str, Any]:
|
||||
source = Path(path)
|
||||
if not source.exists():
|
||||
return {"ok": False, "error": f"File not found: {source}"}
|
||||
ext = source.suffix.lower().lstrip(".")
|
||||
expected_mimetype = ODF_MIMETYPES.get(ext)
|
||||
if not expected_mimetype:
|
||||
return {"ok": False, "error": f"Unsupported ODF extension: {ext}"}
|
||||
try:
|
||||
with zipfile.ZipFile(source) as archive:
|
||||
first = archive.infolist()[0]
|
||||
mimetype = archive.read("mimetype").decode("utf-8")
|
||||
archive.getinfo("content.xml")
|
||||
archive.getinfo("META-INF/manifest.xml")
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"ODF package validation failed: {exc}"}
|
||||
if first.filename != "mimetype" or first.compress_type != zipfile.ZIP_STORED:
|
||||
return {"ok": False, "error": "ODF mimetype must be the first uncompressed package entry."}
|
||||
if mimetype != expected_mimetype:
|
||||
return {"ok": False, "error": f"ODF mimetype mismatch: expected {expected_mimetype}, got {mimetype}"}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def convert_document(path: str | Path, target_format: str, output_dir: str | Path | None = None) -> dict[str, Any]:
|
||||
source = Path(path)
|
||||
if not source.exists():
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from helpers import files, virtual_desktop
|
|||
from plugins._office.helpers import document_store, libreoffice
|
||||
|
||||
|
||||
OFFICIAL_EXTENSIONS = {"docx", "xlsx", "pptx"}
|
||||
OFFICIAL_EXTENSIONS = {"odt", "ods", "odp", "docx", "xlsx", "pptx"}
|
||||
SYSTEM_SESSION_ID = "agent-zero-desktop"
|
||||
SYSTEM_FILE_ID = "system-desktop"
|
||||
SYSTEM_TITLE = "Desktop"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
name: _office
|
||||
title: LibreOffice
|
||||
description: Markdown-first writing and LibreOffice-backed document artifacts in the right canvas.
|
||||
description: Markdown writing and ODF-first LibreOffice document artifacts in the right canvas.
|
||||
version: "0.1"
|
||||
settings_sections:
|
||||
- developer
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
### document_artifact
|
||||
create/open/read/edit reusable document artifacts in the Agent Zero canvas
|
||||
formats: md docx xlsx pptx
|
||||
formats: md odt ods odp docx xlsx pptx
|
||||
default format: md
|
||||
methods: create open read edit inspect export version_history restore_version status
|
||||
common args: method action kind title format content path file_id
|
||||
`method` is accepted as an alias for action when the tool_name has no suffix
|
||||
tool results save or update artifacts only; they do not open the canvas automatically
|
||||
created/updated artifacts are shown with explicit Download and Open in canvas message actions
|
||||
XLSX charts: use edit operation `create_chart` with `chart` object instead of code execution for embedded spreadsheet charts
|
||||
ODF is first-class for LibreOffice: use ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress unless the user explicitly requests Microsoft compatibility
|
||||
DOCX/XLSX/PPTX are compatibility formats, not defaults
|
||||
XLSX charts: use edit operation `create_chart` with `chart` object instead of code execution for embedded spreadsheet charts when an embedded chart is required
|
||||
chart types: line bar column pie area scatter stock ohlc candlestick
|
||||
XLSX create/edit tabular content: CSV, TSV, Markdown tables, or rows arrays become real spreadsheet cells
|
||||
ODT/ODS/ODP editing is intentionally unsupported in this migration
|
||||
for nontrivial document artifact work, load skill `office-artifacts` or the specific Markdown/Word/Excel/Presentation skill first
|
||||
ODS/XLSX create/edit tabular content: CSV, TSV, Markdown tables, or rows arrays become real spreadsheet cells
|
||||
for nontrivial document artifact work, load skill `office-artifacts` or the specific Markdown/Writer/Calc/Impress skill first
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
---
|
||||
name: excel-workbooks
|
||||
description: Use when creating, opening, or editing Excel-compatible XLSX spreadsheets, workbooks, tables, budgets, formulas, sheets, or charts.
|
||||
version: "1.0.0"
|
||||
description: Use when creating, opening, or editing LibreOffice Calc ODS spreadsheets, or XLSX workbooks only when Excel compatibility is explicitly required.
|
||||
version: "1.1.0"
|
||||
author: "Agent Zero Core Team"
|
||||
tags: ["excel", "xlsx", "spreadsheet", "workbook", "calc", "tables", "charts", "budget"]
|
||||
tags: ["calc", "ods", "opendocument", "excel", "xlsx", "spreadsheet", "workbook", "tables", "charts", "budget"]
|
||||
triggers:
|
||||
- "Calc"
|
||||
- "ODS"
|
||||
- "ods"
|
||||
- "OpenDocument Spreadsheet"
|
||||
- "Excel"
|
||||
- "XLSX"
|
||||
- "xlsx"
|
||||
|
|
@ -17,11 +21,11 @@ allowed_tools:
|
|||
- document_artifact
|
||||
---
|
||||
|
||||
# Excel Workbooks
|
||||
# Calc Spreadsheets
|
||||
|
||||
Use XLSX when the user asks for Excel, a spreadsheet, a workbook, tables that should remain editable as cells, formulas, or embedded spreadsheet charts.
|
||||
Use ODS when the user asks for a spreadsheet, workbook, editable table, budget, formulas, or Calc file. Use XLSX only when the user asks for Excel/XLSX compatibility, provides an existing `.xlsx`, or needs embedded spreadsheet charts supported by the tool.
|
||||
|
||||
The canvas is user-owned UI. Creating or editing an XLSX must save the workbook and return action buttons, but must not open the canvas automatically. Use Desktop/Calc only for explicit GUI requests, visual chart/layout polish, or final visual confirmation.
|
||||
The canvas is user-owned UI. Creating or editing an ODS or XLSX must save the workbook and return action buttons, but must not open the canvas automatically. Use Desktop/Calc only for explicit GUI requests, visual chart/layout polish, or final visual confirmation.
|
||||
|
||||
## Workflow
|
||||
|
||||
|
|
@ -33,13 +37,13 @@ Create a workbook:
|
|||
"tool_args": {
|
||||
"kind": "spreadsheet",
|
||||
"title": "Budget",
|
||||
"format": "xlsx",
|
||||
"format": "ods",
|
||||
"content": "Item,Amount\nPlatform,1000"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For a blank workbook request, create a simple workbook with the requested title and `format: "xlsx"`; do not call `status` first unless the user asked for availability.
|
||||
For a blank workbook request, create a simple workbook with the requested title and `format: "ods"`; do not call `status` first unless the user asked for availability.
|
||||
|
||||
Edit cells:
|
||||
|
||||
|
|
@ -61,5 +65,5 @@ Practical rules:
|
|||
|
||||
- `content` may be CSV, TSV, or a Markdown table; the tool writes real spreadsheet cells.
|
||||
- Use `rows` for whole-table replacement, `append_rows` for adding records, and `set_cells` for precise edits.
|
||||
- Use `create_chart` with a chart object for embedded charts before reaching for code execution.
|
||||
- Use `create_chart` with a chart object for embedded charts when working in XLSX compatibility format; otherwise use Calc/Desktop or code execution for chart workflows that ODS direct editing does not yet cover.
|
||||
- Do not open Calc/canvas automatically. The user can choose Open in canvas when they want the visible spreadsheet.
|
||||
|
|
|
|||
|
|
@ -23,13 +23,14 @@ Use the Desktop as a full Linux GUI when the user explicitly needs a visual work
|
|||
|
||||
## Operating Model
|
||||
|
||||
1. Prefer `document_artifact` for creating, reading, and editing Markdown, DOCX, XLSX, and PPTX files.
|
||||
1. Prefer `document_artifact` for creating, reading, and editing Markdown, ODT, ODS, ODP, DOCX, XLSX, and PPTX files.
|
||||
2. Treat Markdown as first-class. For writing, notes, reports, and drafts with no explicit binary Office requirement, create Markdown and use the custom Markdown editor when the user opens the canvas.
|
||||
3. Use the Desktop only when the user asks for the Desktop, a GUI app, binary Office visual work, or visual confirmation.
|
||||
4. Never open the Desktop/canvas automatically from a tool result if the user has not opened it. Offer the explicit Open in canvas action instead.
|
||||
5. Launch common apps from the Desktop icons, the header buttons, or `scripts/desktopctl.sh`.
|
||||
6. Use the external Agent Zero Browser for web browsing. Do not launch an operating-system browser in this version.
|
||||
7. Verify GUI work by observing the desktop state, checking window titles, and saving the file before reporting success.
|
||||
3. Treat ODF as first-class for LibreOffice office work: ODT in Writer, ODS in Calc, ODP in Impress. Use DOCX/XLSX/PPTX only for explicit Microsoft compatibility.
|
||||
4. Use the Desktop only when the user asks for the Desktop, a GUI app, binary Office visual work, or visual confirmation.
|
||||
5. Never open the Desktop/canvas automatically from a tool result if the user has not opened it. Offer the explicit Open in canvas action instead.
|
||||
6. Launch common apps from the Desktop icons, the header buttons, or `scripts/desktopctl.sh`.
|
||||
7. Use the external Agent Zero Browser for web browsing. Do not launch an operating-system browser in this version.
|
||||
8. Verify GUI work by observing the desktop state, checking window titles, and saving the file before reporting success.
|
||||
|
||||
## Control Flow
|
||||
|
||||
|
|
@ -95,9 +96,9 @@ Use these folders when the user asks to inspect or manipulate project files, ski
|
|||
|
||||
## App Map
|
||||
|
||||
- `LibreOffice Writer`: word processing and DOCX layout.
|
||||
- `LibreOffice Calc`: spreadsheets, formulas, tables, charts.
|
||||
- `LibreOffice Impress`: presentations and slide polish.
|
||||
- `LibreOffice Writer`: ODT word processing and DOCX compatibility layout.
|
||||
- `LibreOffice Calc`: ODS spreadsheets, formulas, tables, charts, and XLSX compatibility.
|
||||
- `LibreOffice Impress`: ODP presentations, slide polish, and PPTX compatibility.
|
||||
- `Workdir`: graphical file management with Thunar at the configured Agent Zero workdir (default `/a0/usr/workdir`).
|
||||
- `Terminal`: shell work inside the Agent Zero runtime.
|
||||
- `Settings`: XFCE system settings.
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ allowed_tools:
|
|||
|
||||
# Markdown Documents
|
||||
|
||||
Markdown is the default document format for normal writing, notes, reports, briefs, drafts, and collaborative text work unless the user explicitly asks for DOCX, XLSX, PPTX, or another binary Office-compatible format.
|
||||
Markdown is the default document format for normal writing, notes, reports, briefs, drafts, and collaborative text work unless the user explicitly asks for a binary office file. When they do ask for a LibreOffice office file, prefer ODF: ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress. Use DOCX, XLSX, or PPTX only for explicit Microsoft compatibility.
|
||||
|
||||
The canvas is user-owned UI. Create or update the saved Markdown artifact, but never open the canvas automatically. The document message will provide explicit Download and Open in canvas actions.
|
||||
|
||||
|
|
@ -45,7 +45,7 @@ Minimal create:
|
|||
|
||||
Practical rules:
|
||||
|
||||
- Prefer Markdown over DOCX for writing unless Word compatibility is explicitly needed.
|
||||
- Prefer Markdown over ODT/DOCX for writing unless a binary Writer/Word file is explicitly needed.
|
||||
- Keep agent-only cleanup simple: if the user asks to fix a typo, update the file and finish; do not force a canvas workflow.
|
||||
- Use clear headings and Markdown tables when they improve editability.
|
||||
- The custom Markdown editor is available when the user chooses Open in canvas.
|
||||
|
|
|
|||
|
|
@ -1,17 +1,21 @@
|
|||
---
|
||||
name: office-artifacts
|
||||
description: Use when creating, opening, reading, or editing editable document canvas artifacts such as Markdown documents, DOCX documents, XLSX spreadsheets, and PPTX presentations with the document_artifact tool.
|
||||
version: "1.3.0"
|
||||
description: Use when creating, opening, reading, or editing editable document canvas artifacts such as Markdown documents, LibreOffice-native ODT/ODS/ODP files, and compatibility DOCX/XLSX/PPTX files with the document_artifact tool.
|
||||
version: "1.4.0"
|
||||
author: "Agent Zero Core Team"
|
||||
tags: ["documents", "markdown", "md", "docx", "xlsx", "pptx", "canvas", "spreadsheets", "presentations"]
|
||||
tags: ["documents", "markdown", "md", "odt", "ods", "odp", "docx", "xlsx", "pptx", "canvas", "spreadsheets", "presentations", "libreoffice", "opendocument"]
|
||||
triggers:
|
||||
- "document canvas"
|
||||
- "markdown document"
|
||||
- "editable document"
|
||||
- "md"
|
||||
- "odt"
|
||||
- "ods"
|
||||
- "odp"
|
||||
- "docx"
|
||||
- "xlsx"
|
||||
- "pptx"
|
||||
- "writer"
|
||||
- "spreadsheet"
|
||||
- "presentation"
|
||||
allowed_tools:
|
||||
|
|
@ -20,16 +24,16 @@ allowed_tools:
|
|||
|
||||
# Document Artifacts
|
||||
|
||||
Use `document_artifact` for substantial deliverables that should remain editable in the custom document canvas. Markdown is the first-class document format and the default for writing, notes, reports, briefs, and drafts. Use DOCX, XLSX, or PPTX only when the user explicitly asks for that binary format, provides an existing file in that format, or needs a Word/Excel/PowerPoint-compatible artifact.
|
||||
Use `document_artifact` for substantial deliverables that should remain editable in the custom document canvas or LibreOffice Desktop. Markdown remains the default for ordinary writing, notes, reports, briefs, and drafts when no binary office file is needed. For LibreOffice office files, ODF is first-class: use ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress. Use DOCX, XLSX, or PPTX only when the user explicitly asks for Microsoft compatibility, provides an existing file in that format, or needs that compatibility surface.
|
||||
|
||||
The canvas is user-owned UI. Creating, reading, or editing an artifact must save the file and update its state, but it must not open the canvas automatically if the user has not opened it. Tool results provide explicit Download and Open in canvas actions for the user.
|
||||
|
||||
For format-specific work, prefer the matching skill when available:
|
||||
|
||||
- `markdown-documents` for Markdown-first editable writing.
|
||||
- `word-documents` for DOCX/Word-compatible files.
|
||||
- `excel-workbooks` for XLSX/Excel-compatible spreadsheets.
|
||||
- `presentation-decks` for PPTX/PowerPoint-compatible decks.
|
||||
- `word-documents` for Writer/ODT files and DOCX compatibility files.
|
||||
- `excel-workbooks` for Calc/ODS spreadsheets and XLSX compatibility workbooks.
|
||||
- `presentation-decks` for Impress/ODP decks and PPTX compatibility decks.
|
||||
|
||||
## Workflow
|
||||
|
||||
|
|
@ -67,7 +71,7 @@ Read:
|
|||
}
|
||||
```
|
||||
|
||||
Edit text in a Markdown, DOCX, or PPTX file:
|
||||
Edit text in a Markdown, ODT, DOCX, ODP, or PPTX file:
|
||||
```json
|
||||
{
|
||||
"tool_name": "document_artifact:edit",
|
||||
|
|
@ -85,7 +89,7 @@ Set spreadsheet cells:
|
|||
{
|
||||
"tool_name": "document_artifact:edit",
|
||||
"tool_args": {
|
||||
"path": "/a0/usr/workdir/documents/Budget.xlsx",
|
||||
"path": "/a0/usr/workdir/documents/Budget.ods",
|
||||
"operation": "set_cells",
|
||||
"cells": {
|
||||
"Sheet1!B2": 12500,
|
||||
|
|
@ -118,16 +122,17 @@ Create an embedded spreadsheet chart:
|
|||
|
||||
## Edit Operations
|
||||
|
||||
- MD and DOCX: `set_text`, `append_text`, `prepend_text`, `replace_text`, `delete_text`.
|
||||
- XLSX: `set_cells`, `append_rows`, `set_rows`, `create_chart`, `replace_text`, `delete_text`.
|
||||
- PPTX: `set_slides`, `append_slide`, `replace_text`, `delete_text`.
|
||||
- MD, ODT, and DOCX: `set_text`, `append_text`, `prepend_text`, `replace_text`, `delete_text`.
|
||||
- ODS and XLSX: `set_cells`, `append_rows`, `set_rows`, `replace_text`, `delete_text`.
|
||||
- XLSX only: `create_chart` for embedded spreadsheet charts.
|
||||
- ODP and PPTX: `set_slides`, `append_slide`, `replace_text`, `delete_text`.
|
||||
|
||||
Arguments:
|
||||
|
||||
- `replace_text` and `delete_text` require `find`; `replace_text` uses `replace`.
|
||||
- `set_cells` accepts `{ "A1": "value", "Sheet2!B3": 42 }` or `[{"sheet":"Sheet1","cell":"A1","value":"value"}]`.
|
||||
- `rows` accepts an array of rows. `content` can also be CSV, TSV, or a Markdown table.
|
||||
- `create_chart` accepts `chart` as an object or JSON string. Supported XLSX chart types: `line`, `bar`, `column`, `pie`, `area`, `scatter`, `stock`, `ohlc`, `candlestick`. Use `data_range`, `categories`/`labels`, `position`, `title`, `width`, and `height`. For stock-style charts only, provide Open/High/Low/Close columns in that order, or rely on a sheet whose headers are `Date, Open, High, Low, Close`.
|
||||
- `create_chart` accepts `chart` as an object or JSON string for XLSX compatibility workbooks. Supported XLSX chart types: `line`, `bar`, `column`, `pie`, `area`, `scatter`, `stock`, `ohlc`, `candlestick`. Use `data_range`, `categories`/`labels`, `position`, `title`, `width`, and `height`. For stock-style charts only, provide Open/High/Low/Close columns in that order, or rely on a sheet whose headers are `Date, Open, High, Low, Close`.
|
||||
- `slides` accepts `[{"title":"Slide title","bullets":["point"]}]`. Text slides can be separated with a line containing `---`.
|
||||
- `count` limits text replacements.
|
||||
|
||||
|
|
@ -136,10 +141,10 @@ Arguments:
|
|||
- Prefer `file_id` from canvas context or prior tool output; use `path` when that is all you have.
|
||||
- Use `read` before editing unless the current saved content is already known.
|
||||
- Do not create an artifact for tiny one-shot edits or answers the agent can finish cleanly in chat or by directly editing the file.
|
||||
- For document-style requests with no requested binary format, create Markdown and let the custom Markdown editor be the primary interactive surface.
|
||||
- For document-style writing requests with no requested binary format, create Markdown and let the custom Markdown editor be the primary interactive surface.
|
||||
- For spreadsheet or presentation file requests with no Microsoft compatibility requirement, create ODS or ODP.
|
||||
- The Desktop runtime may be warmed during Agent Zero startup, but visible Desktop/canvas use remains opt-in. Treat LibreOffice GUI work as appropriate for explicit GUI requests, binary Office visual polish, or final layout inspection.
|
||||
- Never open the canvas automatically from a tool result. If the user has not opened the canvas, leave the saved artifact available through the normal UI affordance.
|
||||
- Do not create ODT, ODS, or ODP in this pass; return a clear unsupported response if asked.
|
||||
- Use native `create_chart` for embedded spreadsheet charts. Reach for Python/code execution only when the requested chart behavior is not supported by the tool.
|
||||
- Use `edit` for precise saved changes; use the visual document canvas for human/manual layout polish.
|
||||
- Direct edits update version history and refresh the canvas on edit/open results.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
---
|
||||
name: presentation-decks
|
||||
description: Use when creating, opening, or editing PowerPoint-compatible PPTX presentations, slide decks, talks, briefing decks, or LibreOffice Impress files.
|
||||
version: "1.0.0"
|
||||
description: Use when creating, opening, or editing LibreOffice Impress ODP presentations, or PPTX decks only when PowerPoint compatibility is explicitly required.
|
||||
version: "1.1.0"
|
||||
author: "Agent Zero Core Team"
|
||||
tags: ["presentation", "pptx", "powerpoint", "slides", "deck", "impress"]
|
||||
tags: ["presentation", "odp", "opendocument", "pptx", "powerpoint", "slides", "deck", "impress"]
|
||||
triggers:
|
||||
- "Impress"
|
||||
- "ODP"
|
||||
- "odp"
|
||||
- "OpenDocument Presentation"
|
||||
- "PowerPoint"
|
||||
- "PPTX"
|
||||
- "pptx"
|
||||
|
|
@ -17,11 +21,11 @@ allowed_tools:
|
|||
- document_artifact
|
||||
---
|
||||
|
||||
# Presentation Decks
|
||||
# Impress Presentations
|
||||
|
||||
Use PPTX when the user asks for PowerPoint, a presentation, slides, a deck, or an Impress-compatible artifact.
|
||||
Use ODP when the user asks for a presentation, slides, a deck, or an Impress artifact. Use PPTX only when the user asks for PowerPoint/PPTX compatibility or provides an existing `.pptx`.
|
||||
|
||||
The canvas is user-owned UI. Creating or editing a PPTX must save the deck and return action buttons, but must not open the canvas automatically. Use Desktop/Impress only for explicit GUI requests, visual layout polish, or final visual confirmation.
|
||||
The canvas is user-owned UI. Creating or editing an ODP or PPTX must save the deck and return action buttons, but must not open the canvas automatically. Use Desktop/Impress only for explicit GUI requests, visual layout polish, or final visual confirmation.
|
||||
|
||||
## Workflow
|
||||
|
||||
|
|
@ -33,7 +37,7 @@ Create:
|
|||
"tool_args": {
|
||||
"kind": "presentation",
|
||||
"title": "Roadmap",
|
||||
"format": "pptx",
|
||||
"format": "odp",
|
||||
"content": "Title Slide\n\n---\n\nNext Steps"
|
||||
}
|
||||
}
|
||||
|
|
@ -59,5 +63,5 @@ Practical rules:
|
|||
|
||||
- Use `slides` arrays for structured decks and `---` separators for simple text-to-slide creation.
|
||||
- Keep slide text concise and scannable.
|
||||
- Do not create ODP in this workflow.
|
||||
- Treat PPTX as a compatibility export/request, not the default presentation format.
|
||||
- Do not open Impress/canvas automatically. The user can choose Open in canvas when they want to inspect or polish the deck visually.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
---
|
||||
name: word-documents
|
||||
description: Use when creating, opening, or editing Word-compatible DOCX documents, including requests for Word files, DOCX reports, memos, contracts, resumes, or documents that must work in Microsoft Word or LibreOffice Writer.
|
||||
version: "1.0.0"
|
||||
description: Use when creating, opening, or editing LibreOffice Writer ODT documents, or DOCX documents only when Microsoft Word compatibility is explicitly required.
|
||||
version: "1.1.0"
|
||||
author: "Agent Zero Core Team"
|
||||
tags: ["word", "docx", "writer", "documents", "reports", "memos", "contracts"]
|
||||
tags: ["writer", "odt", "opendocument", "word", "docx", "documents", "reports", "memos", "contracts"]
|
||||
triggers:
|
||||
- "Writer"
|
||||
- "ODT"
|
||||
- "odt"
|
||||
- "OpenDocument Text"
|
||||
- "Word"
|
||||
- "DOCX"
|
||||
- "docx"
|
||||
|
|
@ -15,11 +19,11 @@ allowed_tools:
|
|||
- document_artifact
|
||||
---
|
||||
|
||||
# Word Documents
|
||||
# Writer Documents
|
||||
|
||||
Use DOCX only when the user explicitly asks for Word/DOCX compatibility, provides an existing `.docx`, or needs a binary Office file. For ordinary writing with no binary requirement, use Markdown instead.
|
||||
Use ODT for LibreOffice Writer documents. Use DOCX only when the user explicitly asks for Word/DOCX/Microsoft compatibility, provides an existing `.docx`, or needs that compatibility format. For ordinary writing with no binary requirement, use Markdown instead.
|
||||
|
||||
The canvas is user-owned UI. Creating or editing a DOCX must save the file and return action buttons, but must not open the canvas automatically. Use Desktop/Writer only for explicit GUI requests, visual layout polish, or final visual confirmation.
|
||||
The canvas is user-owned UI. Creating or editing an ODT or DOCX must save the file and return action buttons, but must not open the canvas automatically. Use Desktop/Writer only for explicit GUI requests, visual layout polish, or final visual confirmation.
|
||||
|
||||
## Workflow
|
||||
|
||||
|
|
@ -31,7 +35,7 @@ Create:
|
|||
"tool_args": {
|
||||
"kind": "document",
|
||||
"title": "Board Memo",
|
||||
"format": "docx",
|
||||
"format": "odt",
|
||||
"content": "Memo body text."
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +49,6 @@ Edit:
|
|||
|
||||
Practical rules:
|
||||
|
||||
- Keep DOCX content clean and structured. Use headings and paragraphs; avoid over-formatting unless requested.
|
||||
- Do not create ODT in this workflow.
|
||||
- Keep Writer content clean and structured. Use headings and paragraphs; avoid over-formatting unless requested.
|
||||
- Treat DOCX as a compatibility export/request, not the default Writer format.
|
||||
- Do not say the document is open. Say it was created or updated, and rely on the Open in canvas action for user-controlled viewing.
|
||||
|
|
|
|||
|
|
@ -42,6 +42,13 @@ class DocumentArtifact(Tool):
|
|||
path=path,
|
||||
context_id=self._context_id(),
|
||||
)
|
||||
if doc["extension"] in {"odt", "ods", "odp"}:
|
||||
validation = libreoffice.validate_odf(doc["path"])
|
||||
if not validation.get("ok"):
|
||||
return Response(
|
||||
message=f"document_artifact create failed: {validation.get('error')}",
|
||||
break_loop=False,
|
||||
)
|
||||
if doc["extension"] == "docx":
|
||||
validation = libreoffice.validate_docx(doc["path"])
|
||||
if not validation.get("ok"):
|
||||
|
|
|
|||
|
|
@ -21,15 +21,15 @@
|
|||
<span class="material-symbols-outlined">article</span>
|
||||
<span class="office-button-label">Markdown</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button office-command-button" aria-label="New DOCX" @click="$store.office.create('document', 'docx')">
|
||||
<button type="button" class="office-icon-button office-command-button" aria-label="New Writer document" @click="$store.office.create('document', 'odt')">
|
||||
<span class="material-symbols-outlined">description</span>
|
||||
<span class="office-button-label">DOCX</span>
|
||||
<span class="office-button-label">Writer</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button office-command-button" aria-label="New spreadsheet" @click="$store.office.create('spreadsheet', 'xlsx')">
|
||||
<button type="button" class="office-icon-button office-command-button" aria-label="New spreadsheet" @click="$store.office.create('spreadsheet', 'ods')">
|
||||
<span class="material-symbols-outlined">table_chart</span>
|
||||
<span class="office-button-label">Spreadsheet</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button office-command-button" aria-label="New presentation" @click="$store.office.create('presentation', 'pptx')">
|
||||
<button type="button" class="office-icon-button office-command-button" aria-label="New presentation" @click="$store.office.create('presentation', 'odp')">
|
||||
<span class="material-symbols-outlined">co_present</span>
|
||||
<span class="office-button-label">Presentation</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -360,7 +360,7 @@ const model = {
|
|||
},
|
||||
|
||||
async create(kind = "document", format = "") {
|
||||
const fmt = String(format || (kind === "spreadsheet" ? "xlsx" : kind === "presentation" ? "pptx" : "md")).toLowerCase();
|
||||
const fmt = String(format || (kind === "spreadsheet" ? "ods" : kind === "presentation" ? "odp" : "md")).toLowerCase();
|
||||
const title = this.defaultTitle(kind, fmt);
|
||||
await this.openSession({
|
||||
action: "create",
|
||||
|
|
@ -831,7 +831,7 @@ const model = {
|
|||
|
||||
isBinaryOffice(tab = this.session) {
|
||||
const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
|
||||
return ext === "docx" || ext === "xlsx" || ext === "pptx";
|
||||
return ["odt", "ods", "odp", "docx", "xlsx", "pptx"].includes(ext);
|
||||
},
|
||||
|
||||
hasOfficialOffice(tab = this.session) {
|
||||
|
|
@ -1872,6 +1872,7 @@ const model = {
|
|||
defaultTitle(kind, fmt) {
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
if (fmt === "md") return `Document ${date}`;
|
||||
if (fmt === "odt") return `Writer ${date}`;
|
||||
if (fmt === "docx") return `DOCX ${date}`;
|
||||
if (kind === "spreadsheet") return `Spreadsheet ${date}`;
|
||||
if (kind === "presentation") return `Presentation ${date}`;
|
||||
|
|
@ -1891,9 +1892,9 @@ const model = {
|
|||
const ext = String(tab.extension || tab.document?.extension || "").toLowerCase();
|
||||
if (this.isDesktopSession(tab)) return "desktop_windows";
|
||||
if (ext === "md") return "article";
|
||||
if (ext === "docx") return "description";
|
||||
if (ext === "xlsx") return "table_chart";
|
||||
if (ext === "pptx") return "co_present";
|
||||
if (ext === "odt" || ext === "docx") return "description";
|
||||
if (ext === "ods" || ext === "xlsx") return "table_chart";
|
||||
if (ext === "odp" || ext === "pptx") return "co_present";
|
||||
return "draft";
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue