From f1b014feb366d09e56cf9fbcdab003d79e70cc9e Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:32:50 +0200 Subject: [PATCH] Automatic canvas handoffs - Auto-open Office and Browser canvas surfaces from fresh tool results, including history/result messages. - Preserve Browser target IDs when focusing a canvas session from tool output. - Convert substantial response-style artifacts into Office documents at runtime, without relying only on prompt compliance. - Attach Office artifact metadata to the completed response log so the canvas opens without leaving a dangling Processing group. - Polish Office UX by removing the inactive version-history action, showing only the healthy dot, and improving Collabora blank-load recovery with browser state cleanup. - Deduplicate auto-open events and ignore stale results. --- .../browser-tool-handler.js | 88 +++++- .../register-browser.js | 7 +- .../auto-open-browser-results.js | 149 ++++++++++ plugins/_browser/webui/browser-store.js | 3 +- .../_20_document_response_affordance.py | 109 ++++++++ .../document-artifact-handler.js | 85 +++++- .../auto-open-document-results.js | 138 ++++++++++ .../_office/helpers/document_affordance.py | 256 ++++++++++++++++++ plugins/_office/webui/office-panel.html | 13 +- plugins/_office/webui/office-store.js | 69 ++++- 10 files changed, 882 insertions(+), 35 deletions(-) create mode 100644 plugins/_browser/extensions/webui/set_messages_after_loop/auto-open-browser-results.js create mode 100644 plugins/_office/extensions/python/tool_execute_after/_20_document_response_affordance.py create mode 100644 plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js create mode 100644 plugins/_office/helpers/document_affordance.py diff --git a/plugins/_browser/extensions/webui/get_tool_message_handler/browser-tool-handler.js b/plugins/_browser/extensions/webui/get_tool_message_handler/browser-tool-handler.js index 62be2815f..52e089b94 100644 --- a/plugins/_browser/extensions/webui/get_tool_message_handler/browser-tool-handler.js +++ b/plugins/_browser/extensions/webui/get_tool_message_handler/browser-tool-handler.js @@ -11,6 +11,8 @@ import { } from "/js/messages.js"; const BROWSER_MODAL = "/plugins/_browser/webui/main.html"; +const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000; +const autoOpenedBrowsers = new Set(); export default async function registerBrowserToolHandler(extData) { if (extData?.tool_name === "browser") { @@ -18,6 +20,69 @@ export default async function registerBrowserToolHandler(extData) { } } +async function openBrowserCanvas(payload = {}) { + const canvas = globalThis.Alpine?.store?.("rightCanvas") + || (await import("/components/canvas/right-canvas-store.js")).store; + if (canvas) { + await canvas.open("browser", payload); + return; + } + if (window.ensureModalOpen) { + await window.ensureModalOpen(BROWSER_MODAL); + return; + } + await window.openModal?.(BROWSER_MODAL); +} + +function parseBrowserResult(content) { + if (!content || typeof content !== "string") return {}; + try { + const parsed = JSON.parse(content); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +function browserIdFromResult(result = {}, kvps = {}) { + return ( + result.id + || result.browser_id + || result.state?.id + || result.last_interacted_browser_id + || kvps.browser_id + || null + ); +} + +function isFreshToolMessage(timestamp) { + const value = Number(timestamp); + if (!Number.isFinite(value) || value <= 0) return true; + const messageMs = value > 10_000_000_000 ? value : value * 1000; + return Math.abs(Date.now() - messageMs) <= AUTO_OPEN_WINDOW_MS; +} + +function shouldAutoOpenBrowser(args, result) { + if (!isFreshToolMessage(args?.timestamp)) return false; + const action = String(args?.kvps?.action || "").trim().toLowerCase().replace("-", "_"); + if (["list", "content", "detail", "close", "close_all"].includes(action)) return false; + return Boolean(browserIdFromResult(result, args?.kvps || {}) || action === "open" || action === "navigate"); +} + +function autoOpenBrowserCanvas(args, result) { + if (!shouldAutoOpenBrowser(args, result)) return; + const kvps = args?.kvps || {}; + const browserId = browserIdFromResult(result, kvps); + const key = `${args.id || ""}:${kvps.action || ""}:${browserId || ""}:${result.currentUrl || result.state?.currentUrl || kvps.url || ""}`; + const persistedKey = `a0.browser.autoOpened.${key}`; + if (autoOpenedBrowsers.has(key) || sessionStorage.getItem(persistedKey)) return; + autoOpenedBrowsers.add(key); + sessionStorage.setItem(persistedKey, "1"); + requestAnimationFrame(() => { + void openBrowserCanvas({ browserId, source: "tool" }); + }); +} + function drawBrowserTool({ id, type, @@ -28,27 +93,18 @@ function drawBrowserTool({ agentno = 0, ...additional }) { + const args = arguments[0]; const title = cleanStepTitle(heading); const displayKvps = { ...kvps }; const headerLabels = [ kvps?._tool_name && { label: kvps._tool_name, class: "tool-name-badge" }, ].filter(Boolean); const contentText = String(content ?? ""); + const browserResult = parseBrowserResult(contentText); const browserButton = createActionButton( "visibility", "Browser", - () => { - const canvas = globalThis.Alpine?.store?.("rightCanvas"); - if (canvas) { - void canvas.open("browser"); - return; - } - if (window.ensureModalOpen) { - void window.ensureModalOpen(BROWSER_MODAL); - return; - } - void window.openModal?.(BROWSER_MODAL); - }, + () => openBrowserCanvas({ browserId: browserIdFromResult(browserResult, kvps), source: "tool" }), ); browserButton.setAttribute("title", "Open Browser"); browserButton.setAttribute("aria-label", "Open Browser"); @@ -60,7 +116,7 @@ function drawBrowserTool({ actionButtons.push( createActionButton("detail", "", () => stepDetailStore.showStepDetail( - buildDetailPayload(arguments[0], { headerLabels }), + buildDetailPayload(args, { headerLabels }), ), ), createActionButton("speak", "", () => speechStore.speak(contentText)), @@ -68,7 +124,7 @@ function drawBrowserTool({ ); } - return drawProcessStep({ + const result = drawProcessStep({ id, title, code: "WWW", @@ -76,6 +132,8 @@ function drawBrowserTool({ kvps: displayKvps, content, actionButtons: actionButtons.filter(Boolean), - log: arguments[0], + log: args, }); + autoOpenBrowserCanvas(args, browserResult); + return result; } diff --git a/plugins/_browser/extensions/webui/right_canvas_register_surfaces/register-browser.js b/plugins/_browser/extensions/webui/right_canvas_register_surfaces/register-browser.js index ee3c81011..8b513bcda 100644 --- a/plugins/_browser/extensions/webui/right_canvas_register_surfaces/register-browser.js +++ b/plugins/_browser/extensions/webui/right_canvas_register_surfaces/register-browser.js @@ -24,11 +24,14 @@ export default async function registerBrowserSurface(canvas) { icon: "language", order: 10, modalPath: "/plugins/_browser/webui/main.html", - async open() { + async open(payload = {}) { const panel = await waitForElement('[data-surface-id="browser"] .browser-panel'); const browser = globalThis.Alpine?.store?.("browserPage"); if (panel && browser?.onOpen) { - await browser.onOpen(panel, { mode: "canvas" }); + await browser.onOpen(panel, { + mode: "canvas", + browserId: payload.browserId || payload.browser_id || null, + }); } }, async close() { diff --git a/plugins/_browser/extensions/webui/set_messages_after_loop/auto-open-browser-results.js b/plugins/_browser/extensions/webui/set_messages_after_loop/auto-open-browser-results.js new file mode 100644 index 000000000..b0fbf7bdd --- /dev/null +++ b/plugins/_browser/extensions/webui/set_messages_after_loop/auto-open-browser-results.js @@ -0,0 +1,149 @@ +const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000; +const BROWSER_MODAL = "/plugins/_browser/webui/main.html"; +const autoOpenedBrowsers = new Set(); + +export default async function autoOpenBrowserResults(context) { + if (!context?.results?.length || context.historyEmpty) return; + + for (const { args } of context.results) { + const payload = getToolResultPayload(args); + if (getToolName(payload) !== "browser") continue; + + const result = parseMaybeJson(payload.tool_result) || {}; + if (!shouldAutoOpen(args, payload, result)) continue; + + const browserId = getBrowserId(payload, result); + const key = [ + args?.id || "", + browserId || "", + result.currentUrl || result.state?.currentUrl || payload.url || "", + ].join(":"); + const persistedKey = `a0.browser.autoOpened.${key}`; + if (hasOpened(key, persistedKey)) continue; + + requestAnimationFrame(() => { + void openBrowserCanvas({ browserId, source: "tool-result" }); + }); + } +} + +function getToolResultPayload(args = {}) { + const topLevelPayload = pickPayloadFields(args); + const contentPayload = parseMaybeJson(args.content); + const kvpsPayload = parseMaybeJson(args.kvps); + return { + ...topLevelPayload, + ...(contentPayload || {}), + ...(kvpsPayload || {}), + }; +} + +function pickPayloadFields(args = {}) { + const payload = {}; + for (const key of [ + "_tool_name", + "tool_name", + "tool_result", + "action", + "browser_id", + "browserId", + "url", + "last_modified", + ]) { + if (args[key] != null && args[key] !== "") payload[key] = args[key]; + } + return payload; +} + +function getToolName(payload = {}) { + return String(payload._tool_name || payload.tool_name || "").trim(); +} + +function parseMaybeJson(value) { + if (!value) return null; + if (typeof value === "object") return value; + if (typeof value !== "string") return null; + + const trimmed = value.trim(); + if (!trimmed.startsWith("{")) return null; + try { + const parsed = JSON.parse(trimmed); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +function shouldAutoOpen(args = {}, payload = {}, result = {}) { + if (!isFresh(args.timestamp, payload.last_modified || result.last_modified)) return false; + + const action = String(payload.action || "").trim().toLowerCase().replace("-", "_"); + if (["list", "content", "detail", "close", "close_all"].includes(action)) return false; + + return Boolean( + getBrowserId(payload, result) + || action === "open" + || action === "navigate" + || result.currentUrl + || result.state?.currentUrl, + ); +} + +function getBrowserId(payload = {}, result = {}) { + return ( + result.id + || result.browser_id + || result.state?.id + || result.last_interacted_browser_id + || payload.browser_id + || payload.browserId + || null + ); +} + +function isFresh(timestamp, fallbackTimestamp) { + const messageMs = toMs(timestamp) || toMs(fallbackTimestamp); + if (!messageMs) return true; + return Math.abs(Date.now() - messageMs) <= AUTO_OPEN_WINDOW_MS; +} + +function toMs(value) { + if (value == null || value === "") return 0; + + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric > 0) { + return numeric > 10_000_000_000 ? numeric : numeric * 1000; + } + + const parsed = Date.parse(String(value)); + return Number.isFinite(parsed) ? parsed : 0; +} + +function hasOpened(key, persistedKey) { + if (autoOpenedBrowsers.has(key)) return true; + autoOpenedBrowsers.add(key); + + try { + if (sessionStorage.getItem(persistedKey)) return true; + sessionStorage.setItem(persistedKey, "1"); + } catch { + // Best-effort persistence; the in-memory guard still prevents repeat opens. + } + + return false; +} + +async function openBrowserCanvas(payload = {}) { + const canvas = globalThis.Alpine?.store?.("rightCanvas") + || (await import("/components/canvas/right-canvas-store.js")).store; + if (canvas) { + await canvas.open("browser", payload); + return; + } + + if (window.ensureModalOpen) { + await window.ensureModalOpen(BROWSER_MODAL); + return; + } + await window.openModal?.(BROWSER_MODAL); +} diff --git a/plugins/_browser/webui/browser-store.js b/plugins/_browser/webui/browser-store.js index 58232938c..dce7ef08f 100644 --- a/plugins/_browser/webui/browser-store.js +++ b/plugins/_browser/webui/browser-store.js @@ -299,6 +299,7 @@ const model = { async onOpen(element = null, options = {}) { this.loading = true; this.error = ""; + const requestedBrowserId = this.normalizeBrowserId(options.browserId ?? options.browser_id); this._mode = options?.mode === "modal" ? "modal" : "canvas"; if (this._mode === "modal") { this.setupFloatingModal(element); @@ -308,7 +309,7 @@ const model = { this.contextId = this.resolveContextId(); try { await this.refreshStatus(); - await this.connectViewer(); + await this.connectViewer({ browserId: requestedBrowserId }); } catch (error) { this.error = error instanceof Error ? error.message : String(error); } finally { diff --git a/plugins/_office/extensions/python/tool_execute_after/_20_document_response_affordance.py b/plugins/_office/extensions/python/tool_execute_after/_20_document_response_affordance.py new file mode 100644 index 000000000..133ea4931 --- /dev/null +++ b/plugins/_office/extensions/python/tool_execute_after/_20_document_response_affordance.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from helpers import files +from helpers.extension import Extension +from helpers.print_style import PrintStyle +from helpers.tool import Response +from plugins._office.helpers import document_affordance, wopi_store + + +class DocumentResponseAffordance(Extension): + async def execute( + self, + tool_name: str = "", + response: Response | None = None, + **kwargs: Any, + ): + if tool_name != "response" or not self.agent or response is None: + return + + tool = self.agent.loop_data.current_tool + if not tool: + return + + text = str(tool.args.get("text") or tool.args.get("message") or response.message or "").strip() + user_message = self.agent.last_user_message.content if self.agent.last_user_message else "" + decision = document_affordance.decide_response_artifact(user_message, text) + if decision is None: + return + + try: + doc = wopi_store.create_document( + kind=decision.kind, + title=decision.title, + fmt=decision.fmt, + content=decision.content, + ) + except Exception as exc: + PrintStyle().error(f"Office document affordance failed: {exc}") + return + + payload = { + "ok": True, + "message": "Created document artifact from response.", + "document": public_doc(doc), + } + additional = document_additional(doc) + content = json.dumps(payload, indent=2, ensure_ascii=False) + + self.agent.hist_add_tool_result("document_artifact", content, **additional) + + display_path = display_workspace_path(doc["path"]) + note = document_affordance.format_created_response(doc["basename"], display_path) + response.message = note + tool.args["text"] = note + tool.args["message"] = note + + log_item = self.agent.loop_data.params_temporary.get("log_item_response") + if log_item: + log_item.update( + content=note, + kvps={ + "action": "create", + "kind": decision.kind, + "title": decision.title, + "format": decision.fmt, + "_tool_name": "document_artifact", + **additional, + }, + ) + + +def public_doc(doc: dict[str, Any]) -> dict[str, Any]: + return { + "file_id": doc["file_id"], + "path": display_workspace_path(doc["path"]), + "basename": doc["basename"], + "extension": doc["extension"], + "size": doc["size"], + "version": wopi_store.item_version(doc), + "last_modified": doc["last_modified"], + "exists": Path(doc["path"]).exists(), + } + + +def document_additional(doc: dict[str, Any]) -> dict[str, Any]: + return { + "_tool_name": "document_artifact", + "canvas_surface": "office", + "file_id": doc["file_id"], + "title": doc["basename"], + "format": doc["extension"], + "path": display_workspace_path(doc["path"]), + "version": wopi_store.item_version(doc), + } + + +def display_workspace_path(path: str) -> str: + base = Path(files.get_base_dir()).resolve(strict=False) + resolved = Path(path).resolve(strict=False) + if str(base).startswith("/a0"): + return str(resolved) + try: + return "/a0/" + str(resolved.relative_to(base)).lstrip("/") + except ValueError: + return str(path) diff --git a/plugins/_office/extensions/webui/get_tool_message_handler/document-artifact-handler.js b/plugins/_office/extensions/webui/get_tool_message_handler/document-artifact-handler.js index 76b349a1a..d30e9a6fe 100644 --- a/plugins/_office/extensions/webui/get_tool_message_handler/document-artifact-handler.js +++ b/plugins/_office/extensions/webui/get_tool_message_handler/document-artifact-handler.js @@ -10,6 +10,9 @@ import { drawProcessStep, } from "/js/messages.js"; +const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000; +const autoOpenedDocuments = new Set(); + export default async function registerDocumentArtifactHandler(extData) { if (extData?.tool_name === "document_artifact") { extData.handler = drawDocumentArtifactTool; @@ -26,6 +29,71 @@ async function openOfficeCanvas(kvps = {}) { }); } +function parseDocumentResult(content) { + if (!content || typeof content !== "string") return {}; + try { + const parsed = JSON.parse(content); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +function documentFromArgs(args, result = {}) { + const kvps = args?.kvps || {}; + const document = result.document && typeof result.document === "object" + ? result.document + : {}; + return { + file_id: kvps.file_id || document.file_id || "", + path: kvps.path || document.path || "", + title: kvps.title || kvps.basename || document.basename || "", + format: kvps.format || kvps.extension || document.extension || "", + version: kvps.version || document.version || "", + }; +} + +function shouldAutoOpenDocument(args, document) { + const kvps = args?.kvps || {}; + if (kvps.canvas_surface && kvps.canvas_surface !== "office") return false; + if (!document?.path) return false; + const action = String(kvps.action || "").trim().toLowerCase(); + if (["status", "version_history", "inspect"].includes(action)) return false; + return isFreshToolMessage(args?.timestamp); +} + +function isFreshToolMessage(timestamp) { + const value = Number(timestamp); + if (!Number.isFinite(value) || value <= 0) return true; + const messageMs = value > 10_000_000_000 ? value : value * 1000; + return Math.abs(Date.now() - messageMs) <= AUTO_OPEN_WINDOW_MS; +} + +function autoOpenOfficeCanvas(args) { + const document = documentFromArgs(args, parseDocumentResult(args?.content)); + if (!shouldAutoOpenDocument(args, document)) return; + const key = `${args.id || ""}:${document.file_id || ""}:${document.path || ""}:${document.version || ""}`; + const persistedKey = `a0.office.autoOpened.${key}`; + if (hasOpenedDocument(key, persistedKey)) return; + requestAnimationFrame(() => { + void openOfficeCanvas(document); + }); +} + +function hasOpenedDocument(key, persistedKey) { + if (autoOpenedDocuments.has(key)) return true; + autoOpenedDocuments.add(key); + + try { + if (sessionStorage.getItem(persistedKey)) return true; + sessionStorage.setItem(persistedKey, "1"); + } catch { + // Best-effort persistence; the in-memory guard still prevents repeat opens. + } + + return false; +} + function drawDocumentArtifactTool({ id, type, @@ -40,26 +108,25 @@ function drawDocumentArtifactTool({ const title = cleanStepTitle(heading); const displayKvps = { ...kvps }; const contentText = String(content ?? ""); + const documentResult = parseDocumentResult(contentText); + const document = documentFromArgs(args, documentResult); const headerLabels = [ kvps?._tool_name && { label: kvps._tool_name, class: "tool-name-badge" }, - kvps?.format && { label: String(kvps.format).toUpperCase(), class: "tool-name-badge" }, + document?.format && { label: String(document.format).toUpperCase(), class: "tool-name-badge" }, ].filter(Boolean); const actionButtons = [ - createActionButton("description", "Office", () => openOfficeCanvas(kvps)), + createActionButton("description", "Office", () => openOfficeCanvas(document)), ]; - if (kvps?.path) { + if (document?.path) { actionButtons.push( - createActionButton("content_copy", "Path", () => copyToClipboard(kvps.path)), + createActionButton("content_copy", "Path", () => copyToClipboard(document.path)), ); } if (contentText.trim()) { actionButtons.push( - createActionButton("history", "Versions", () => - stepDetailStore.showStepDetail(buildDetailPayload(args, { headerLabels })), - ), createActionButton("detail", "", () => stepDetailStore.showStepDetail(buildDetailPayload(args, { headerLabels })), ), @@ -68,7 +135,7 @@ function drawDocumentArtifactTool({ ); } - return drawProcessStep({ + const result = drawProcessStep({ id, title, code: "DOC", @@ -78,4 +145,6 @@ function drawDocumentArtifactTool({ actionButtons: actionButtons.filter(Boolean), log: args, }); + autoOpenOfficeCanvas(args); + return result; } diff --git a/plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js b/plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js new file mode 100644 index 000000000..3ed030d3d --- /dev/null +++ b/plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js @@ -0,0 +1,138 @@ +const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000; +const autoOpenedDocuments = new Set(); + +export default async function autoOpenDocumentResults(context) { + if (!context?.results?.length || context.historyEmpty) return; + + for (const { args } of context.results) { + const payload = getToolResultPayload(args); + if (getToolName(payload) !== "document_artifact") continue; + + const document = getDocumentPayload(payload); + if (!document?.path) continue; + if (payload.canvas_surface && payload.canvas_surface !== "office") continue; + if (!isFresh(args?.timestamp, document.last_modified)) continue; + + const key = [ + args?.id || "", + document.file_id || "", + document.path, + document.version || "", + ].join(":"); + const persistedKey = `a0.office.autoOpened.${key}`; + if (hasOpened(key, persistedKey)) continue; + + requestAnimationFrame(() => { + void openOfficeCanvas(document); + }); + } +} + +function getToolResultPayload(args = {}) { + const topLevelPayload = pickPayloadFields(args); + const contentPayload = parseMaybeJson(args.content); + const kvpsPayload = parseMaybeJson(args.kvps); + return { + ...topLevelPayload, + ...(contentPayload || {}), + ...(kvpsPayload || {}), + }; +} + +function pickPayloadFields(args = {}) { + const payload = {}; + for (const key of [ + "_tool_name", + "tool_name", + "tool_result", + "canvas_surface", + "file_id", + "path", + "title", + "basename", + "format", + "extension", + "version", + "last_modified", + ]) { + if (args[key] != null && args[key] !== "") payload[key] = args[key]; + } + return payload; +} + +function getToolName(payload = {}) { + return String(payload._tool_name || payload.tool_name || "").trim(); +} + +function getDocumentPayload(payload = {}) { + const result = parseMaybeJson(payload.tool_result) || {}; + const document = result.document && typeof result.document === "object" + ? result.document + : {}; + + return { + file_id: payload.file_id || document.file_id || "", + path: payload.path || document.path || "", + title: payload.title || payload.basename || document.basename || "", + format: payload.format || payload.extension || document.extension || "", + version: payload.version || document.version || "", + last_modified: payload.last_modified || document.last_modified || "", + }; +} + +function parseMaybeJson(value) { + if (!value) return null; + if (typeof value === "object") return value; + if (typeof value !== "string") return null; + + const trimmed = value.trim(); + if (!trimmed.startsWith("{")) return null; + try { + const parsed = JSON.parse(trimmed); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +function isFresh(timestamp, fallbackTimestamp) { + const messageMs = toMs(timestamp) || toMs(fallbackTimestamp); + if (!messageMs) return true; + return Math.abs(Date.now() - messageMs) <= AUTO_OPEN_WINDOW_MS; +} + +function toMs(value) { + if (value == null || value === "") return 0; + + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric > 0) { + return numeric > 10_000_000_000 ? numeric : numeric * 1000; + } + + const parsed = Date.parse(String(value)); + return Number.isFinite(parsed) ? parsed : 0; +} + +function hasOpened(key, persistedKey) { + if (autoOpenedDocuments.has(key)) return true; + autoOpenedDocuments.add(key); + + try { + if (sessionStorage.getItem(persistedKey)) return true; + sessionStorage.setItem(persistedKey, "1"); + } catch { + // Best-effort persistence; the in-memory guard still prevents repeat opens. + } + + return false; +} + +async function openOfficeCanvas(document) { + const canvas = globalThis.Alpine?.store?.("rightCanvas") + || (await import("/components/canvas/right-canvas-store.js")).store; + await canvas?.open?.("office", { + path: document.path || "", + file_id: document.file_id || "", + source: "tool-result", + }); +} diff --git a/plugins/_office/helpers/document_affordance.py b/plugins/_office/helpers/document_affordance.py new file mode 100644 index 000000000..e59751b83 --- /dev/null +++ b/plugins/_office/helpers/document_affordance.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + + +MIN_ARTIFACT_CHARS = 700 +MIN_ARTIFACT_WORDS = 110 +MIN_EXPLICIT_ARTIFACT_CHARS = 240 +MIN_EXPLICIT_ARTIFACT_WORDS = 35 + +CREATE_TERMS = { + "write", + "draft", + "compose", + "create", + "generate", + "prepare", + "produce", + "make", + "build", + "author", +} + +DOCUMENT_TERMS = { + "article", + "brief", + "contract", + "cv", + "doc", + "document", + "docx", + "draft", + "essay", + "guide", + "letter", + "manual", + "memo", + "policy", + "proposal", + "report", + "resume", + "spec", + "story", + "whitepaper", +} + +SPREADSHEET_TERMS = { + "budget", + "excel", + "sheet", + "spreadsheet", + "table", + "workbook", + "xlsx", +} + +PRESENTATION_TERMS = { + "deck", + "ppt", + "pptx", + "presentation", + "slide", + "slides", +} + +CHAT_ONLY_TERMS = { + "answer in chat", + "in chat", + "just answer", + "just reply", + "no file", + "no files", +} + +SKIP_RESPONSE_PREFIXES = ( + "i can't", + "i cannot", + "i'm sorry", + "sorry,", + "i can help", +) + + +@dataclass(frozen=True) +class ArtifactDecision: + kind: str + fmt: str + title: str + content: str + reason: str + + +def decide_response_artifact(user_message: Any, response_text: str) -> ArtifactDecision | None: + user_text = flatten_text(user_message).strip() + response_text = str(response_text or "").strip() + if not user_text or not response_text: + return None + + lowered_user = normalize_text(user_text) + lowered_response = normalize_text(response_text[:240]) + if any(term in lowered_user for term in CHAT_ONLY_TERMS): + return None + if lowered_response.startswith(SKIP_RESPONSE_PREFIXES): + return None + if looks_like_tool_or_status_response(response_text): + return None + + kind, fmt, explicit_artifact = infer_kind_and_format(lowered_user) + if not explicit_artifact and not has_document_creation_intent(lowered_user): + return None + + if not is_substantial(response_text, explicit_artifact): + return None + + title = infer_title(user_text, response_text, kind) + return ArtifactDecision( + kind=kind, + fmt=fmt, + title=title, + content=response_text, + reason="explicit" if explicit_artifact else "document_intent", + ) + + +def flatten_text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, dict): + preferred_keys = ("user_message", "user_intervention", "message", "content", "text") + skipped_keys = {*preferred_keys, "attachments", "system_message", "raw_content"} + preferred = [] + for key in preferred_keys: + if key in value: + preferred.append(flatten_text(value[key])) + remaining = [ + flatten_text(child) + for key, child in value.items() + if key not in skipped_keys + ] + return "\n".join(part for part in [*preferred, *remaining] if part) + if isinstance(value, (list, tuple, set)): + return "\n".join(part for item in value if (part := flatten_text(item))) + return str(value) + + +def normalize_text(value: str) -> str: + return re.sub(r"\s+", " ", value.lower()).strip() + + +def infer_kind_and_format(lowered_user: str) -> tuple[str, str, bool]: + explicit = False + if has_any(lowered_user, PRESENTATION_TERMS): + explicit = True + return "presentation", "pptx", explicit + if has_any(lowered_user, SPREADSHEET_TERMS): + explicit = True + return "spreadsheet", "xlsx", explicit + if has_any(lowered_user, DOCUMENT_TERMS): + explicit = True + return "document", "docx", explicit + + +def has_document_creation_intent(lowered_user: str) -> bool: + return has_any(lowered_user, CREATE_TERMS) and has_any( + lowered_user, + DOCUMENT_TERMS | SPREADSHEET_TERMS | PRESENTATION_TERMS, + ) + + +def has_any(text: str, terms: set[str]) -> bool: + return any(re.search(rf"\b{re.escape(term)}\b", text) for term in terms) + + +def is_substantial(text: str, explicit_artifact: bool) -> bool: + word_count = len(re.findall(r"\w+", text)) + char_count = len(text) + if explicit_artifact: + return char_count >= MIN_EXPLICIT_ARTIFACT_CHARS and word_count >= MIN_EXPLICIT_ARTIFACT_WORDS + return char_count >= MIN_ARTIFACT_CHARS and word_count >= MIN_ARTIFACT_WORDS + + +def looks_like_tool_or_status_response(text: str) -> bool: + stripped = text.strip() + if stripped.startswith("{") and '"tool_name"' in stripped[:300]: + return True + if "/a0/usr/workdir/documents/" in stripped: + return True + return False + + +def infer_title(user_text: str, response_text: str, kind: str) -> str: + response_title = title_from_response(response_text) + if response_title: + return response_title + + request_title = title_from_request(user_text) + if request_title: + return request_title + + return { + "spreadsheet": "Spreadsheet", + "presentation": "Presentation", + }.get(kind, "Document") + + +def title_from_response(response_text: str) -> str: + for raw_line in response_text.splitlines()[:8]: + line = raw_line.strip() + if not line: + continue + for pattern in ( + r"^#{1,3}\s+(.+?)\s*$", + r"^\*\*(.+?)\*\*\s*$", + r"^__(.+?)__\s*$", + ): + match = re.match(pattern, line) + if match: + return clean_title(match.group(1)) + if len(line) <= 80 and not line.endswith((".", "?", "!", ":")): + return clean_title(line) + break + return "" + + +def title_from_request(user_text: str) -> str: + text = re.sub(r"\s+", " ", user_text).strip() + quoted = re.search(r"[\"'“”](.{4,90}?)[\"'“”]", text) + if quoted: + return clean_title(quoted.group(1)) + + cleaned = re.sub( + r"\b(write|draft|compose|create|generate|prepare|produce|make|build|author)\b", + "", + text, + flags=re.IGNORECASE, + ) + cleaned = re.sub(r"\b(a|an|the|new|for me|please|docx|document|file)\b", "", cleaned, flags=re.IGNORECASE) + cleaned = clean_title(cleaned) + return cleaned if 4 <= len(cleaned) <= 80 else "" + + +def clean_title(value: str) -> str: + value = re.sub(r"[*_`#>\[\]{}]", "", value) + value = re.sub(r"\s+", " ", value).strip(" .:-") + return value[:90].strip(" .:-") + + +def format_created_response(basename: str, path: str) -> str: + return ( + f"Created **{basename}** and opened it in the Office canvas.\n\n" + f"Path: `{path}`" + ) diff --git a/plugins/_office/webui/office-panel.html b/plugins/_office/webui/office-panel.html index 510142c74..5f8143e78 100644 --- a/plugins/_office/webui/office-panel.html +++ b/plugins/_office/webui/office-panel.html @@ -30,16 +30,14 @@ class="office-health-pill" :class="`is-${$store.office.status?.state || 'unknown'}`" :title="$store.office.status?.message || 'Office status'" + :aria-label="$store.office.status?.message || 'Office status'" > - + - @@ -254,6 +252,13 @@ background: color-mix(in srgb, var(--color-panel) 64%, transparent); } + .office-health-pill.is-healthy { + width: 28px; + min-width: 28px; + padding: 0; + gap: 0; + } + .office-health-dot { width: 7px; height: 7px; diff --git a/plugins/_office/webui/office-store.js b/plugins/_office/webui/office-store.js index 69b9dcd2a..8f24f7e6b 100644 --- a/plugins/_office/webui/office-store.js +++ b/plugins/_office/webui/office-store.js @@ -2,6 +2,9 @@ import { createStore } from "/js/AlpineStore.js"; import { callJsonApi } from "/js/api.js"; const FRAME_NAME_PREFIX = "a0-office-frame"; +const COLLABORA_STATE_VERSION = "2026-04-26.1"; +const COLLABORA_STATE_MARKER = "a0.office.collaboraStateVersion"; +const SERVICE_WORKER_CLEANUP_MARKER = "a0.office.serviceWorkerCleanupReloaded"; function makeFrameName() { const id = globalThis.crypto?.randomUUID?.() @@ -131,6 +134,7 @@ const model = { this.error = ""; this.message = ""; try { + await this.prepareBrowserHostForEditor(); const response = await callJsonApi("/plugins/_office/office_session", payload); if (!response?.ok) { this.error = response?.error || "Office session could not be opened."; @@ -210,6 +214,7 @@ const model = { if (!this.session || this.frameReady || this._frameRecoveryTried) return; this._frameRecoveryTried = true; this._frameAttempt += 1; + this.resetCollaboraBrowserState({ force: true }); this.message = "Still opening the editor... trying a fresh editor load."; await this.submitFrame(); this._frameTimer = setTimeout(() => { @@ -269,11 +274,6 @@ const model = { this.clearFrameTimers(); }, - async showVersions() { - if (!this.session?.file_id) return; - this.message = "Version history is available through the document_artifact tool."; - }, - onPostMessage(event) { if (!this.session) return; if (!this.isAllowedFrameOrigin(event.origin)) return; @@ -444,6 +444,65 @@ const model = { this._floatingCleanup = null; if (element) this._root = element; }, + + async prepareBrowserHostForEditor() { + await this.cleanupLegacyOfficeServiceWorkers(); + this.resetCollaboraBrowserState(); + }, + + async cleanupLegacyOfficeServiceWorkers() { + const serviceWorker = globalThis.navigator?.serviceWorker; + if (!serviceWorker?.getRegistrations) return; + let removedController = false; + try { + const registrations = await serviceWorker.getRegistrations(); + const currentOrigin = globalThis.location.origin; + const officePath = "/office/"; + for (const registration of registrations) { + const scope = new URL(registration.scope); + if (scope.origin !== currentOrigin) continue; + const scopePath = scope.pathname.endsWith("/") ? scope.pathname : `${scope.pathname}/`; + const affectsOffice = scopePath === "/" || scopePath.startsWith(officePath) || officePath.startsWith(scopePath); + if (!affectsOffice) continue; + const scriptUrl = registration.active?.scriptURL || ""; + if (scriptUrl.endsWith("/js/sw.js") && scopePath === "/js/") continue; + removedController = await registration.unregister() || removedController; + } + const controllerUrl = serviceWorker.controller?.scriptURL || ""; + if (removedController && controllerUrl.startsWith(currentOrigin)) { + const alreadyReloaded = sessionStorage.getItem(SERVICE_WORKER_CLEANUP_MARKER) === "1"; + if (!alreadyReloaded) { + sessionStorage.setItem(SERVICE_WORKER_CLEANUP_MARKER, "1"); + globalThis.location.reload(); + } + } + } catch (error) { + console.warn("Office service worker cleanup skipped", error); + } + }, + + resetCollaboraBrowserState(options = {}) { + const force = Boolean(options.force); + try { + if (!force && localStorage.getItem(COLLABORA_STATE_MARKER) === COLLABORA_STATE_VERSION) { + return; + } + const exactKeys = new Set([ + "UIDefaults", + "WSDFeedbackCount", + "WSDFeedbackTimestamp", + ]); + const collaboraKeyPattern = /^(text|spreadsheet|presentation|drawing)\.[A-Za-z0-9_.-]+$/; + for (const key of Object.keys(localStorage)) { + if (exactKeys.has(key) || collaboraKeyPattern.test(key)) { + localStorage.removeItem(key); + } + } + localStorage.setItem(COLLABORA_STATE_MARKER, COLLABORA_STATE_VERSION); + } catch (error) { + console.warn("Office browser state cleanup skipped", error); + } + }, }; export const store = createStore("office", model);