Canvas: forward A2UI actions
This commit is contained in:
@@ -37,6 +37,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
private let sessionDir: URL
|
private let sessionDir: URL
|
||||||
private let schemeHandler: CanvasSchemeHandler
|
private let schemeHandler: CanvasSchemeHandler
|
||||||
private let webView: WKWebView
|
private let webView: WKWebView
|
||||||
|
private var a2uiActionMessageHandler: CanvasA2UIActionMessageHandler?
|
||||||
private let watcher: CanvasFileWatcher
|
private let watcher: CanvasFileWatcher
|
||||||
private let container: HoverChromeContainerView
|
private let container: HoverChromeContainerView
|
||||||
let presentation: CanvasPresentation
|
let presentation: CanvasPresentation
|
||||||
@@ -94,6 +95,10 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
let window = Self.makeWindow(for: presentation, contentView: self.container)
|
let window = Self.makeWindow(for: presentation, contentView: self.container)
|
||||||
super.init(window: window)
|
super.init(window: window)
|
||||||
|
|
||||||
|
let handler = CanvasA2UIActionMessageHandler(sessionKey: sessionKey)
|
||||||
|
self.a2uiActionMessageHandler = handler
|
||||||
|
self.webView.configuration.userContentController.add(handler, name: CanvasA2UIActionMessageHandler.messageName)
|
||||||
|
|
||||||
self.webView.navigationDelegate = self
|
self.webView.navigationDelegate = self
|
||||||
self.window?.delegate = self
|
self.window?.delegate = self
|
||||||
self.container.onClose = { [weak self] in
|
self.container.onClose = { [weak self] in
|
||||||
@@ -107,6 +112,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
@MainActor deinit {
|
@MainActor deinit {
|
||||||
|
self.webView.configuration.userContentController.removeScriptMessageHandler(forName: CanvasA2UIActionMessageHandler.messageName)
|
||||||
self.watcher.stop()
|
self.watcher.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,6 +486,85 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||||
|
static let messageName = "clawdisCanvasA2UIAction"
|
||||||
|
|
||||||
|
private let sessionKey: String
|
||||||
|
|
||||||
|
init(sessionKey: String) {
|
||||||
|
self.sessionKey = sessionKey
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||||
|
guard message.name == Self.messageName else { return }
|
||||||
|
|
||||||
|
// Only accept actions from local Canvas content (not arbitrary web pages).
|
||||||
|
guard let webView = message.webView,
|
||||||
|
webView.url?.scheme == CanvasScheme.scheme
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let path = webView.url?.path ?? ""
|
||||||
|
guard path == "/" || path.isEmpty || path.hasPrefix("/__clawdis__/a2ui") else { return }
|
||||||
|
|
||||||
|
let body: [String: Any] = {
|
||||||
|
if let dict = message.body as? [String: Any] { return dict }
|
||||||
|
if let dict = message.body as? [AnyHashable: Any] {
|
||||||
|
return dict.reduce(into: [String: Any]()) { acc, pair in
|
||||||
|
guard let key = pair.key as? String else { return }
|
||||||
|
acc[key] = pair.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [:]
|
||||||
|
}()
|
||||||
|
guard !body.isEmpty else { return }
|
||||||
|
|
||||||
|
let userActionAny = body["userAction"] ?? body
|
||||||
|
let userAction: [String: Any] = {
|
||||||
|
if let dict = userActionAny as? [String: Any] { return dict }
|
||||||
|
if let dict = userActionAny as? [AnyHashable: Any] {
|
||||||
|
return dict.reduce(into: [String: Any]()) { acc, pair in
|
||||||
|
guard let key = pair.key as? String else { return }
|
||||||
|
acc[key] = pair.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [:]
|
||||||
|
}()
|
||||||
|
guard !userAction.isEmpty else { return }
|
||||||
|
|
||||||
|
guard let name = userAction["name"] as? String, !name.isEmpty else { return }
|
||||||
|
|
||||||
|
let json: String = {
|
||||||
|
if let data = try? JSONSerialization.data(withJSONObject: userAction, options: [.prettyPrinted, .sortedKeys]),
|
||||||
|
let str = String(data: data, encoding: .utf8)
|
||||||
|
{
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}()
|
||||||
|
|
||||||
|
canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)")
|
||||||
|
|
||||||
|
let text = json.isEmpty
|
||||||
|
? "A2UI action: \(name)"
|
||||||
|
: "A2UI action: \(name)\n\n```json\n\(json)\n```"
|
||||||
|
|
||||||
|
Task {
|
||||||
|
let result = await AgentRPC.shared.send(
|
||||||
|
text: text,
|
||||||
|
thinking: nil,
|
||||||
|
sessionKey: self.sessionKey,
|
||||||
|
deliver: false,
|
||||||
|
to: nil,
|
||||||
|
channel: "webchat")
|
||||||
|
if !result.ok {
|
||||||
|
canvasWindowLogger.error(
|
||||||
|
"A2UI action send failed name=\(name, privacy: .public) error=\(result.error ?? "unknown", privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Hover chrome container
|
// MARK: - Hover chrome container
|
||||||
|
|
||||||
private final class HoverChromeContainerView: NSView {
|
private final class HoverChromeContainerView: NSView {
|
||||||
@@ -591,16 +676,24 @@ private final class HoverChromeContainerView: NSView {
|
|||||||
v.material = .hudWindow
|
v.material = .hudWindow
|
||||||
v.blendingMode = .withinWindow
|
v.blendingMode = .withinWindow
|
||||||
v.state = .active
|
v.state = .active
|
||||||
|
v.appearance = NSAppearance(named: .vibrantDark)
|
||||||
v.wantsLayer = true
|
v.wantsLayer = true
|
||||||
v.layer?.cornerRadius = 11
|
v.layer?.cornerRadius = 10
|
||||||
v.layer?.masksToBounds = true
|
v.layer?.masksToBounds = true
|
||||||
v.layer?.borderWidth = 1
|
v.layer?.borderWidth = 1
|
||||||
v.layer?.borderColor = NSColor.white.withAlphaComponent(0.12).cgColor
|
v.layer?.borderColor = NSColor.white.withAlphaComponent(0.18).cgColor
|
||||||
|
v.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.22).cgColor
|
||||||
|
v.layer?.shadowColor = NSColor.black.withAlphaComponent(0.35).cgColor
|
||||||
|
v.layer?.shadowOpacity = 0.35
|
||||||
|
v.layer?.shadowRadius = 8
|
||||||
|
v.layer?.shadowOffset = .zero
|
||||||
return v
|
return v
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private let closeButton: NSButton = {
|
private let closeButton: NSButton = {
|
||||||
let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")
|
let cfg = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold)
|
||||||
|
let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")?
|
||||||
|
.withSymbolConfiguration(cfg)
|
||||||
?? NSImage(size: NSSize(width: 18, height: 18))
|
?? NSImage(size: NSSize(width: 18, height: 18))
|
||||||
let btn = NSButton(image: img, target: nil, action: nil)
|
let btn = NSButton(image: img, target: nil, action: nil)
|
||||||
btn.isBordered = false
|
btn.isBordered = false
|
||||||
@@ -647,13 +740,13 @@ private final class HoverChromeContainerView: NSView {
|
|||||||
|
|
||||||
self.closeBackground.centerXAnchor.constraint(equalTo: self.closeButton.centerXAnchor),
|
self.closeBackground.centerXAnchor.constraint(equalTo: self.closeButton.centerXAnchor),
|
||||||
self.closeBackground.centerYAnchor.constraint(equalTo: self.closeButton.centerYAnchor),
|
self.closeBackground.centerYAnchor.constraint(equalTo: self.closeButton.centerYAnchor),
|
||||||
self.closeBackground.widthAnchor.constraint(equalToConstant: 22),
|
self.closeBackground.widthAnchor.constraint(equalToConstant: 20),
|
||||||
self.closeBackground.heightAnchor.constraint(equalToConstant: 22),
|
self.closeBackground.heightAnchor.constraint(equalToConstant: 20),
|
||||||
|
|
||||||
self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
|
self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
|
||||||
self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
|
self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
|
||||||
self.closeButton.widthAnchor.constraint(equalToConstant: 16),
|
self.closeButton.widthAnchor.constraint(equalToConstant: 20),
|
||||||
self.closeButton.heightAnchor.constraint(equalToConstant: 16),
|
self.closeButton.heightAnchor.constraint(equalToConstant: 20),
|
||||||
|
|
||||||
self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||||
self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||||
|
|||||||
@@ -17757,8 +17757,68 @@ var ClawdisA2UIHost = class extends i$1 {
|
|||||||
reset: () => this.reset(),
|
reset: () => this.reset(),
|
||||||
getSurfaces: () => Array.from(this.#processor.getSurfaces().keys())
|
getSurfaces: () => Array.from(this.#processor.getSurfaces().keys())
|
||||||
};
|
};
|
||||||
|
this.addEventListener("a2uiaction", (evt) => this.#handleA2UIAction(evt));
|
||||||
this.#syncSurfaces();
|
this.#syncSurfaces();
|
||||||
}
|
}
|
||||||
|
#handleA2UIAction(evt) {
|
||||||
|
const payload = evt?.detail ?? evt?.payload ?? null;
|
||||||
|
if (!payload || payload.eventType !== "a2ui.action") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const action = payload.action;
|
||||||
|
const name = action?.name;
|
||||||
|
if (!name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sourceComponentId = payload.sourceComponentId ?? "";
|
||||||
|
const surfaces = this.#processor.getSurfaces();
|
||||||
|
let surfaceId = null;
|
||||||
|
let sourceNode = null;
|
||||||
|
for (const [sid, surface] of surfaces.entries()) {
|
||||||
|
const node = surface?.components?.get?.(sourceComponentId) ?? null;
|
||||||
|
if (node) {
|
||||||
|
surfaceId = sid;
|
||||||
|
sourceNode = node;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const context = {};
|
||||||
|
const ctxItems = Array.isArray(action?.context) ? action.context : [];
|
||||||
|
for (const item of ctxItems) {
|
||||||
|
const key = item?.key;
|
||||||
|
const value = item?.value ?? null;
|
||||||
|
if (!key || !value) continue;
|
||||||
|
if (typeof value.path === "string") {
|
||||||
|
const resolved = sourceNode ? this.#processor.getData(sourceNode, value.path, surfaceId ?? undefined) : null;
|
||||||
|
context[key] = resolved;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(value, "literalString")) {
|
||||||
|
context[key] = value.literalString ?? "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(value, "literalNumber")) {
|
||||||
|
context[key] = value.literalNumber ?? 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(value, "literalBoolean")) {
|
||||||
|
context[key] = value.literalBoolean ?? false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const userAction = {
|
||||||
|
name,
|
||||||
|
surfaceId: surfaceId ?? "main",
|
||||||
|
sourceComponentId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
...Object.keys(context).length ? { context } : {}
|
||||||
|
};
|
||||||
|
globalThis.__clawdisLastA2UIAction = userAction;
|
||||||
|
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction;
|
||||||
|
if (handler?.postMessage) {
|
||||||
|
handler.postMessage({ userAction });
|
||||||
|
}
|
||||||
|
}
|
||||||
applyMessages(messages) {
|
applyMessages(messages) {
|
||||||
if (!Array.isArray(messages)) {
|
if (!Array.isArray(messages)) {
|
||||||
throw new Error("A2UI: expected messages array");
|
throw new Error("A2UI: expected messages array");
|
||||||
|
|||||||
@@ -150,9 +150,80 @@ class ClawdisA2UIHost extends LitElement {
|
|||||||
reset: () => this.reset(),
|
reset: () => this.reset(),
|
||||||
getSurfaces: () => Array.from(this.#processor.getSurfaces().keys()),
|
getSurfaces: () => Array.from(this.#processor.getSurfaces().keys()),
|
||||||
};
|
};
|
||||||
|
this.addEventListener("a2uiaction", (evt) => this.#handleA2UIAction(evt));
|
||||||
this.#syncSurfaces();
|
this.#syncSurfaces();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#handleA2UIAction(evt) {
|
||||||
|
const payload = evt?.detail ?? evt?.payload ?? null;
|
||||||
|
if (!payload || payload.eventType !== "a2ui.action") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = payload.action;
|
||||||
|
const name = action?.name;
|
||||||
|
if (!name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceComponentId = payload.sourceComponentId ?? "";
|
||||||
|
const surfaces = this.#processor.getSurfaces();
|
||||||
|
|
||||||
|
let surfaceId = null;
|
||||||
|
let sourceNode = null;
|
||||||
|
for (const [sid, surface] of surfaces.entries()) {
|
||||||
|
const node = surface?.components?.get?.(sourceComponentId) ?? null;
|
||||||
|
if (node) {
|
||||||
|
surfaceId = sid;
|
||||||
|
sourceNode = node;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = {};
|
||||||
|
const ctxItems = Array.isArray(action?.context) ? action.context : [];
|
||||||
|
for (const item of ctxItems) {
|
||||||
|
const key = item?.key;
|
||||||
|
const value = item?.value ?? null;
|
||||||
|
if (!key || !value) continue;
|
||||||
|
|
||||||
|
if (typeof value.path === "string") {
|
||||||
|
const resolved = sourceNode
|
||||||
|
? this.#processor.getData(sourceNode, value.path, surfaceId ?? undefined)
|
||||||
|
: null;
|
||||||
|
context[key] = resolved;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(value, "literalString")) {
|
||||||
|
context[key] = value.literalString ?? "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(value, "literalNumber")) {
|
||||||
|
context[key] = value.literalNumber ?? 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(value, "literalBoolean")) {
|
||||||
|
context[key] = value.literalBoolean ?? false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAction = {
|
||||||
|
name,
|
||||||
|
surfaceId: surfaceId ?? "main",
|
||||||
|
sourceComponentId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
...(Object.keys(context).length ? { context } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.__clawdisLastA2UIAction = userAction;
|
||||||
|
|
||||||
|
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction;
|
||||||
|
if (handler?.postMessage) {
|
||||||
|
handler.postMessage({ userAction });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
applyMessages(messages) {
|
applyMessages(messages) {
|
||||||
if (!Array.isArray(messages)) {
|
if (!Array.isArray(messages)) {
|
||||||
throw new Error("A2UI: expected messages array");
|
throw new Error("A2UI: expected messages array");
|
||||||
|
|||||||
Reference in New Issue
Block a user