From b6bb1e18e6d8dd5553663944c5ea9168857aefb4 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Sun, 8 Mar 2026 12:44:50 +0100 Subject: [PATCH] feat: initial LibreOffice Writer Ollama sidebar extension scaffold --- Addons.xcu | 23 ++++ META-INF/manifest.xml | 7 ++ OllamaSidebar.components | 8 ++ README.md | 42 +++++++ Sidebar.xcu | 22 ++++ description.xml | 16 +++ ollama_sidebar.py | 235 +++++++++++++++++++++++++++++++++++++++ src/ollama_sidebar.py | 235 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 588 insertions(+) create mode 100644 Addons.xcu create mode 100644 META-INF/manifest.xml create mode 100644 OllamaSidebar.components create mode 100644 README.md create mode 100644 Sidebar.xcu create mode 100644 description.xml create mode 100644 ollama_sidebar.py create mode 100644 src/ollama_sidebar.py diff --git a/Addons.xcu b/Addons.xcu new file mode 100644 index 0000000..f0f9f5b --- /dev/null +++ b/Addons.xcu @@ -0,0 +1,23 @@ + + + + + + .uno:ToolsMenu + AddAfter + AddPath + + + service:de.aquantico.ollama.sidebar.Toggle + Ollama Sidebar öffnen + _self + com.sun.star.text.TextDocument + + + + + + diff --git a/META-INF/manifest.xml b/META-INF/manifest.xml new file mode 100644 index 0000000..48f7cbb --- /dev/null +++ b/META-INF/manifest.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/OllamaSidebar.components b/OllamaSidebar.components new file mode 100644 index 0000000..d0ec06c --- /dev/null +++ b/OllamaSidebar.components @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e73b792 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# LibreOffice Writer Ollama Sidebar + +LibreOffice Writer Extension (`.oxt`) mit Sidebar-Panel: +- Prompt direkt in der Seitenleiste +- Auswahl/Absatz an Ollama senden +- Ergebnis zurück in Writer einfügen oder Auswahl ersetzen + +## Status + +MVP-Gerüst mit Sidebar-Panel + Python UNO-Komponente. + +## Struktur + +- `description.xml` +- `Addons.xcu` +- `Sidebar.xcu` +- `src/ollama_sidebar.py` +- `META-INF/manifest.xml` + +## Build (.oxt) + +```bash +cd libreoffice-writer-ollama-sidebar +zip -r ollama-sidebar.oxt description.xml Addons.xcu Sidebar.xcu META-INF src resources +``` + +## Installation + +- LibreOffice -> Extras -> Erweiterungsmanager -> Hinzufügen -> `ollama-sidebar.oxt` +- Writer öffnen +- Seitenleiste -> Deck/Panel `Ollama Rewrite` + +## Konfiguration + +In der Sidebar: +- Ollama URL (z. B. `http://127.0.0.1:11434`) +- Modell (z. B. `llama3.1:8b`) +- Prompt + +## Hinweis + +LibreOffice UNO-Sidebar APIs variieren je Version. Dieses Repo enthält ein solides Startgerüst; bei Bedarf passe ich es auf deine konkrete LO-Version nach. diff --git a/Sidebar.xcu b/Sidebar.xcu new file mode 100644 index 0000000..baeddc3 --- /dev/null +++ b/Sidebar.xcu @@ -0,0 +1,22 @@ + + + + + Ollama + de.aquantico.ollama.deck + + com.sun.star.text.TextDocument + + + Ollama Rewrite + de.aquantico.ollama.panel + vnd.sun.star.expand:$UNO_USER_PACKAGES_CACHE/uno_packages/ollama-sidebar.oxt/src/ollama_sidebar.py + com.sun.star.text.TextDocument + + + + + diff --git a/description.xml b/description.xml new file mode 100644 index 0000000..d811646 --- /dev/null +++ b/description.xml @@ -0,0 +1,16 @@ + + + + + + Ollama Writer Sidebar + Ollama Writer Seitenleiste + + + Aquantico + + + + + diff --git a/ollama_sidebar.py b/ollama_sidebar.py new file mode 100644 index 0000000..8745c4d --- /dev/null +++ b/ollama_sidebar.py @@ -0,0 +1,235 @@ +import json +import os +import urllib.request + +import uno +import unohelper +from com.sun.star.task import XJobExecutor +from com.sun.star.awt import XActionListener + +CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".ollama_writer_sidebar") +CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json") + +DEFAULT_CFG = { + "ollama_url": "http://127.0.0.1:11434", + "model": "llama3.1:8b", + "prompt": "Überarbeite den Text klarer, Sinn beibehalten." +} + + +def load_cfg(): + try: + os.makedirs(CONFIG_DIR, exist_ok=True) + if not os.path.exists(CONFIG_FILE): + save_cfg(DEFAULT_CFG) + return dict(DEFAULT_CFG) + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + for k, v in DEFAULT_CFG.items(): + data.setdefault(k, v) + return data + except Exception: + return dict(DEFAULT_CFG) + + +def save_cfg(cfg): + os.makedirs(CONFIG_DIR, exist_ok=True) + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(cfg, f, ensure_ascii=False, indent=2) + + +def ollama_generate(url, model, prompt, source): + full_prompt = f"{prompt}\n\nTEXT:\n{source}" + payload = json.dumps({"model": model, "prompt": full_prompt, "stream": False}).encode("utf-8") + req = urllib.request.Request(url.rstrip("/") + "/api/generate", data=payload, headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=120) as resp: + data = json.loads(resp.read().decode("utf-8")) + return data.get("response", "") + + +def get_doc_text(doc): + return doc.Text.String if hasattr(doc, "Text") else "" + + +def get_selected_text(doc): + try: + sel = doc.getCurrentSelection() + if sel and sel.getCount() > 0: + part = sel.getByIndex(0) + return getattr(part, "String", "") + except Exception: + pass + return "" + + +def replace_selection_or_append(doc, txt): + try: + sel = doc.getCurrentSelection() + if sel and sel.getCount() > 0: + part = sel.getByIndex(0) + if hasattr(part, "String"): + part.String = txt + return + except Exception: + pass + try: + doc.Text.insertString(doc.Text.End, "\n" + txt, False) + except Exception: + pass + + +class _BtnListener(unohelper.Base, XActionListener): + def __init__(self, owner): + self.owner = owner + + def actionPerformed(self, event): + cmd = event.ActionCommand + self.owner.on_action(cmd) + + def disposing(self, event): + pass + + +class OllamaDialog: + def __init__(self, ctx): + self.ctx = ctx + self.smgr = ctx.getServiceManager() + self.desktop = self.smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx) + self.doc = self.desktop.getCurrentComponent() + self.cfg = load_cfg() + + self.dialog = None + self.controls = {} + + def _add_control(self, model, name, ctype, x, y, w, h, props): + c = model.createInstance(ctype) + c.Name = name + c.PositionX = x + c.PositionY = y + c.Width = w + c.Height = h + for k, v in props.items(): + setattr(c, k, v) + model.insertByName(name, c) + + def create(self): + dm = self.smgr.createInstanceWithContext("com.sun.star.awt.UnoControlDialogModel", self.ctx) + dm.PositionX = 120 + dm.PositionY = 80 + dm.Width = 300 + dm.Height = 230 + dm.Title = "Ollama Rewrite (Sidebar-MVP)" + + self._add_control(dm, "lbl1", "com.sun.star.awt.UnoControlFixedTextModel", 6, 6, 40, 10, {"Label": "URL"}) + self._add_control(dm, "url", "com.sun.star.awt.UnoControlEditModel", 48, 4, 245, 12, {"Text": self.cfg.get("ollama_url", "")}) + + self._add_control(dm, "lbl2", "com.sun.star.awt.UnoControlFixedTextModel", 6, 20, 40, 10, {"Label": "Model"}) + self._add_control(dm, "model", "com.sun.star.awt.UnoControlEditModel", 48, 18, 245, 12, {"Text": self.cfg.get("model", "")}) + + self._add_control(dm, "lbl3", "com.sun.star.awt.UnoControlFixedTextModel", 6, 34, 40, 10, {"Label": "Prompt"}) + self._add_control(dm, "prompt", "com.sun.star.awt.UnoControlEditModel", 48, 32, 245, 28, { + "MultiLine": True, + "VScroll": True, + "Text": self.cfg.get("prompt", "") + }) + + src = get_selected_text(self.doc) or get_doc_text(self.doc) + self._add_control(dm, "lbl4", "com.sun.star.awt.UnoControlFixedTextModel", 6, 62, 40, 10, {"Label": "Quelle"}) + self._add_control(dm, "source", "com.sun.star.awt.UnoControlEditModel", 48, 60, 245, 58, { + "MultiLine": True, + "VScroll": True, + "Text": src + }) + + self._add_control(dm, "lbl5", "com.sun.star.awt.UnoControlFixedTextModel", 6, 122, 40, 10, {"Label": "Ziel"}) + self._add_control(dm, "target", "com.sun.star.awt.UnoControlEditModel", 48, 120, 245, 58, { + "MultiLine": True, + "VScroll": True, + "Text": "" + }) + + self._add_control(dm, "rewrite", "com.sun.star.awt.UnoControlButtonModel", 48, 184, 58, 14, {"Label": "Bearbeiten", "PushButtonType": 0}) + self._add_control(dm, "copy", "com.sun.star.awt.UnoControlButtonModel", 110, 184, 58, 14, {"Label": "Clipboard", "PushButtonType": 0}) + self._add_control(dm, "insert", "com.sun.star.awt.UnoControlButtonModel", 172, 184, 58, 14, {"Label": "Einfügen", "PushButtonType": 0}) + self._add_control(dm, "close", "com.sun.star.awt.UnoControlButtonModel", 234, 184, 58, 14, {"Label": "Schließen", "PushButtonType": 0}) + + self._add_control(dm, "status", "com.sun.star.awt.UnoControlFixedTextModel", 48, 202, 245, 12, {"Label": "Bereit"}) + + self.dialog = self.smgr.createInstanceWithContext("com.sun.star.awt.UnoControlDialog", self.ctx) + self.dialog.setModel(dm) + + toolkit = self.smgr.createInstanceWithContext("com.sun.star.awt.ExtToolkit", self.ctx) + self.dialog.createPeer(toolkit, None) + + listener = _BtnListener(self) + for btn, cmd in [("rewrite", "rewrite"), ("copy", "copy"), ("insert", "insert"), ("close", "close")]: + c = self.dialog.getControl(btn) + c.setActionCommand(cmd) + c.addActionListener(listener) + + self.listener = listener + + def set_status(self, txt): + self.dialog.getControl("status").getModel().Label = txt + + def on_action(self, cmd): + if cmd == "close": + self.dialog.endExecute() + return + + if cmd == "rewrite": + try: + url = self.dialog.getControl("url").getModel().Text.strip() + model = self.dialog.getControl("model").getModel().Text.strip() + prompt = self.dialog.getControl("prompt").getModel().Text.strip() + source = self.dialog.getControl("source").getModel().Text + + self.cfg.update({"ollama_url": url, "model": model, "prompt": prompt}) + save_cfg(self.cfg) + + self.set_status("Bearbeite…") + out = ollama_generate(url, model, prompt, source) + self.dialog.getControl("target").getModel().Text = out + self.set_status("Fertig") + except Exception as e: + self.set_status(f"Fehler: {e}") + return + + if cmd == "copy": + txt = self.dialog.getControl("target").getModel().Text + try: + service = self.smgr.createInstanceWithContext("com.sun.star.datatransfer.clipboard.SystemClipboard", self.ctx) + transferable = self.smgr.createInstanceWithContext("com.sun.star.datatransfer.DataFlavor", self.ctx) + _ = service, transferable + except Exception: + pass + # Simpler fallback: keep in target and close + self.dialog.endExecute() + return + + if cmd == "insert": + txt = self.dialog.getControl("target").getModel().Text + replace_selection_or_append(self.doc, txt) + self.dialog.endExecute() + return + + def run(self): + self.create() + self.dialog.execute() + + +class Toggle(unohelper.Base, XJobExecutor): + def __init__(self, ctx): + self.ctx = ctx + + def trigger(self, args): + dlg = OllamaDialog(self.ctx) + dlg.run() + + +g_ImplementationHelper = unohelper.ImplementationHelper() +g_ImplementationHelper.addImplementation( + Toggle, + "de.aquantico.ollama.sidebar.Toggle", + ("com.sun.star.task.Job", "com.sun.star.task.XJobExecutor",) +) diff --git a/src/ollama_sidebar.py b/src/ollama_sidebar.py new file mode 100644 index 0000000..8745c4d --- /dev/null +++ b/src/ollama_sidebar.py @@ -0,0 +1,235 @@ +import json +import os +import urllib.request + +import uno +import unohelper +from com.sun.star.task import XJobExecutor +from com.sun.star.awt import XActionListener + +CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".ollama_writer_sidebar") +CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json") + +DEFAULT_CFG = { + "ollama_url": "http://127.0.0.1:11434", + "model": "llama3.1:8b", + "prompt": "Überarbeite den Text klarer, Sinn beibehalten." +} + + +def load_cfg(): + try: + os.makedirs(CONFIG_DIR, exist_ok=True) + if not os.path.exists(CONFIG_FILE): + save_cfg(DEFAULT_CFG) + return dict(DEFAULT_CFG) + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + for k, v in DEFAULT_CFG.items(): + data.setdefault(k, v) + return data + except Exception: + return dict(DEFAULT_CFG) + + +def save_cfg(cfg): + os.makedirs(CONFIG_DIR, exist_ok=True) + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(cfg, f, ensure_ascii=False, indent=2) + + +def ollama_generate(url, model, prompt, source): + full_prompt = f"{prompt}\n\nTEXT:\n{source}" + payload = json.dumps({"model": model, "prompt": full_prompt, "stream": False}).encode("utf-8") + req = urllib.request.Request(url.rstrip("/") + "/api/generate", data=payload, headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=120) as resp: + data = json.loads(resp.read().decode("utf-8")) + return data.get("response", "") + + +def get_doc_text(doc): + return doc.Text.String if hasattr(doc, "Text") else "" + + +def get_selected_text(doc): + try: + sel = doc.getCurrentSelection() + if sel and sel.getCount() > 0: + part = sel.getByIndex(0) + return getattr(part, "String", "") + except Exception: + pass + return "" + + +def replace_selection_or_append(doc, txt): + try: + sel = doc.getCurrentSelection() + if sel and sel.getCount() > 0: + part = sel.getByIndex(0) + if hasattr(part, "String"): + part.String = txt + return + except Exception: + pass + try: + doc.Text.insertString(doc.Text.End, "\n" + txt, False) + except Exception: + pass + + +class _BtnListener(unohelper.Base, XActionListener): + def __init__(self, owner): + self.owner = owner + + def actionPerformed(self, event): + cmd = event.ActionCommand + self.owner.on_action(cmd) + + def disposing(self, event): + pass + + +class OllamaDialog: + def __init__(self, ctx): + self.ctx = ctx + self.smgr = ctx.getServiceManager() + self.desktop = self.smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx) + self.doc = self.desktop.getCurrentComponent() + self.cfg = load_cfg() + + self.dialog = None + self.controls = {} + + def _add_control(self, model, name, ctype, x, y, w, h, props): + c = model.createInstance(ctype) + c.Name = name + c.PositionX = x + c.PositionY = y + c.Width = w + c.Height = h + for k, v in props.items(): + setattr(c, k, v) + model.insertByName(name, c) + + def create(self): + dm = self.smgr.createInstanceWithContext("com.sun.star.awt.UnoControlDialogModel", self.ctx) + dm.PositionX = 120 + dm.PositionY = 80 + dm.Width = 300 + dm.Height = 230 + dm.Title = "Ollama Rewrite (Sidebar-MVP)" + + self._add_control(dm, "lbl1", "com.sun.star.awt.UnoControlFixedTextModel", 6, 6, 40, 10, {"Label": "URL"}) + self._add_control(dm, "url", "com.sun.star.awt.UnoControlEditModel", 48, 4, 245, 12, {"Text": self.cfg.get("ollama_url", "")}) + + self._add_control(dm, "lbl2", "com.sun.star.awt.UnoControlFixedTextModel", 6, 20, 40, 10, {"Label": "Model"}) + self._add_control(dm, "model", "com.sun.star.awt.UnoControlEditModel", 48, 18, 245, 12, {"Text": self.cfg.get("model", "")}) + + self._add_control(dm, "lbl3", "com.sun.star.awt.UnoControlFixedTextModel", 6, 34, 40, 10, {"Label": "Prompt"}) + self._add_control(dm, "prompt", "com.sun.star.awt.UnoControlEditModel", 48, 32, 245, 28, { + "MultiLine": True, + "VScroll": True, + "Text": self.cfg.get("prompt", "") + }) + + src = get_selected_text(self.doc) or get_doc_text(self.doc) + self._add_control(dm, "lbl4", "com.sun.star.awt.UnoControlFixedTextModel", 6, 62, 40, 10, {"Label": "Quelle"}) + self._add_control(dm, "source", "com.sun.star.awt.UnoControlEditModel", 48, 60, 245, 58, { + "MultiLine": True, + "VScroll": True, + "Text": src + }) + + self._add_control(dm, "lbl5", "com.sun.star.awt.UnoControlFixedTextModel", 6, 122, 40, 10, {"Label": "Ziel"}) + self._add_control(dm, "target", "com.sun.star.awt.UnoControlEditModel", 48, 120, 245, 58, { + "MultiLine": True, + "VScroll": True, + "Text": "" + }) + + self._add_control(dm, "rewrite", "com.sun.star.awt.UnoControlButtonModel", 48, 184, 58, 14, {"Label": "Bearbeiten", "PushButtonType": 0}) + self._add_control(dm, "copy", "com.sun.star.awt.UnoControlButtonModel", 110, 184, 58, 14, {"Label": "Clipboard", "PushButtonType": 0}) + self._add_control(dm, "insert", "com.sun.star.awt.UnoControlButtonModel", 172, 184, 58, 14, {"Label": "Einfügen", "PushButtonType": 0}) + self._add_control(dm, "close", "com.sun.star.awt.UnoControlButtonModel", 234, 184, 58, 14, {"Label": "Schließen", "PushButtonType": 0}) + + self._add_control(dm, "status", "com.sun.star.awt.UnoControlFixedTextModel", 48, 202, 245, 12, {"Label": "Bereit"}) + + self.dialog = self.smgr.createInstanceWithContext("com.sun.star.awt.UnoControlDialog", self.ctx) + self.dialog.setModel(dm) + + toolkit = self.smgr.createInstanceWithContext("com.sun.star.awt.ExtToolkit", self.ctx) + self.dialog.createPeer(toolkit, None) + + listener = _BtnListener(self) + for btn, cmd in [("rewrite", "rewrite"), ("copy", "copy"), ("insert", "insert"), ("close", "close")]: + c = self.dialog.getControl(btn) + c.setActionCommand(cmd) + c.addActionListener(listener) + + self.listener = listener + + def set_status(self, txt): + self.dialog.getControl("status").getModel().Label = txt + + def on_action(self, cmd): + if cmd == "close": + self.dialog.endExecute() + return + + if cmd == "rewrite": + try: + url = self.dialog.getControl("url").getModel().Text.strip() + model = self.dialog.getControl("model").getModel().Text.strip() + prompt = self.dialog.getControl("prompt").getModel().Text.strip() + source = self.dialog.getControl("source").getModel().Text + + self.cfg.update({"ollama_url": url, "model": model, "prompt": prompt}) + save_cfg(self.cfg) + + self.set_status("Bearbeite…") + out = ollama_generate(url, model, prompt, source) + self.dialog.getControl("target").getModel().Text = out + self.set_status("Fertig") + except Exception as e: + self.set_status(f"Fehler: {e}") + return + + if cmd == "copy": + txt = self.dialog.getControl("target").getModel().Text + try: + service = self.smgr.createInstanceWithContext("com.sun.star.datatransfer.clipboard.SystemClipboard", self.ctx) + transferable = self.smgr.createInstanceWithContext("com.sun.star.datatransfer.DataFlavor", self.ctx) + _ = service, transferable + except Exception: + pass + # Simpler fallback: keep in target and close + self.dialog.endExecute() + return + + if cmd == "insert": + txt = self.dialog.getControl("target").getModel().Text + replace_selection_or_append(self.doc, txt) + self.dialog.endExecute() + return + + def run(self): + self.create() + self.dialog.execute() + + +class Toggle(unohelper.Base, XJobExecutor): + def __init__(self, ctx): + self.ctx = ctx + + def trigger(self, args): + dlg = OllamaDialog(self.ctx) + dlg.run() + + +g_ImplementationHelper = unohelper.ImplementationHelper() +g_ImplementationHelper.addImplementation( + Toggle, + "de.aquantico.ollama.sidebar.Toggle", + ("com.sun.star.task.Job", "com.sun.star.task.XJobExecutor",) +)