mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-17 04:01:13 +00:00
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.
This commit is contained in:
parent
370ac9b878
commit
f1b014feb3
10 changed files with 882 additions and 35 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
256
plugins/_office/helpers/document_affordance.py
Normal file
256
plugins/_office/helpers/document_affordance.py
Normal file
|
|
@ -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}`"
|
||||
)
|
||||
|
|
@ -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'"
|
||||
>
|
||||
<span class="office-health-dot"></span>
|
||||
<span x-text="$store.office.status?.state || 'status'"></span>
|
||||
<span x-show="$store.office.status?.state !== 'healthy'" x-text="$store.office.status?.state || 'status'"></span>
|
||||
</span>
|
||||
<button type="button" class="office-icon-button" title="Save" @click="$store.office.save()" :disabled="!$store.office.session">
|
||||
<span class="material-symbols-outlined">save</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button" title="Versions" @click="$store.office.showVersions()" :disabled="!$store.office.session">
|
||||
<span class="material-symbols-outlined">history</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button" title="Refresh status" @click="$store.office.refresh()">
|
||||
<span class="material-symbols-outlined">refresh</span>
|
||||
</button>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue