A2UI: share web UI and action bridge
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
public enum ClawdisCanvasA2UIAction: Sendable {
|
||||
public static func sanitizeTagValue(_ value: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let nonEmpty = trimmed.isEmpty ? "-" : trimmed
|
||||
let normalized = nonEmpty.replacingOccurrences(of: " ", with: "_")
|
||||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.:")
|
||||
let scalars = normalized.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
|
||||
return String(scalars)
|
||||
}
|
||||
|
||||
public static func compactJSON(_ obj: Any?) -> String? {
|
||||
guard let obj else { return nil }
|
||||
guard JSONSerialization.isValidJSONObject(obj) else { return nil }
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: obj, options: []),
|
||||
let str = String(data: data, encoding: .utf8)
|
||||
else { return nil }
|
||||
return str
|
||||
}
|
||||
|
||||
public static func formatAgentMessage(
|
||||
actionName: String,
|
||||
sessionKey: String,
|
||||
surfaceId: String,
|
||||
sourceComponentId: String,
|
||||
host: String,
|
||||
instanceId: String,
|
||||
contextJSON: String?)
|
||||
-> String
|
||||
{
|
||||
let ctxSuffix = contextJSON.flatMap { $0.isEmpty ? nil : " ctx=\($0)" } ?? ""
|
||||
return [
|
||||
"CANVAS_A2UI",
|
||||
"action=\(self.sanitizeTagValue(actionName))",
|
||||
"session=\(self.sanitizeTagValue(sessionKey))",
|
||||
"surface=\(self.sanitizeTagValue(surfaceId))",
|
||||
"component=\(self.sanitizeTagValue(sourceComponentId))",
|
||||
"host=\(self.sanitizeTagValue(host))",
|
||||
"instance=\(self.sanitizeTagValue(instanceId))\(ctxSuffix)",
|
||||
"default=update_canvas",
|
||||
].joined(separator: " ")
|
||||
}
|
||||
|
||||
public static func jsDispatchA2UIActionStatus(actionId: String, ok: Bool, error: String?) -> String {
|
||||
let payload: [String: Any] = [
|
||||
"id": actionId,
|
||||
"ok": ok,
|
||||
"error": error ?? "",
|
||||
]
|
||||
let json: String = {
|
||||
if let data = try? JSONSerialization.data(withJSONObject: payload, options: []),
|
||||
let str = String(data: data, encoding: .utf8)
|
||||
{
|
||||
return str
|
||||
}
|
||||
return "{\"id\":\"\(actionId)\",\"ok\":\(ok ? "true" : "false"),\"error\":\"\"}"
|
||||
}()
|
||||
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: \(json) }));"
|
||||
}
|
||||
}
|
||||
@@ -24,4 +24,3 @@ public struct ClawdisCanvasA2UIPushJSONLParams: Codable, Sendable, Equatable {
|
||||
self.jsonl = jsonl
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,4 +73,3 @@ public enum ClawdisCanvasA2UIJSONL: Sendable {
|
||||
return json
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,4 +3,3 @@ import Foundation
|
||||
public enum ClawdisKitResources {
|
||||
public static let bundle: Bundle = .module
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,7 +8,7 @@
|
||||
:root { color-scheme: light dark; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
font: 13px -apple-system, system-ui;
|
||||
font: 14px system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
|
||||
background: #0b1020;
|
||||
color: #e5e7eb;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Canvas</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
body::before, body::after { animation: none !important; }
|
||||
}
|
||||
html,body { height:100%; margin:0; }
|
||||
body {
|
||||
background:
|
||||
radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0,0,0,0) 55%),
|
||||
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.14), rgba(0,0,0,0) 60%),
|
||||
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.10), rgba(0,0,0,0) 60%),
|
||||
#000;
|
||||
overflow: hidden;
|
||||
}
|
||||
body::before {
|
||||
content:"";
|
||||
position: fixed;
|
||||
inset: -20%;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
|
||||
transparent 1px, transparent 48px),
|
||||
repeating-linear-gradient(90deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
|
||||
transparent 1px, transparent 48px);
|
||||
transform: translate3d(0,0,0) rotate(-7deg);
|
||||
will-change: transform, opacity;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
animation: clawdis-grid-drift 140s ease-in-out infinite alternate;
|
||||
}
|
||||
body::after {
|
||||
content:"";
|
||||
position: fixed;
|
||||
inset: -35%;
|
||||
background:
|
||||
radial-gradient(900px 700px at 30% 30%, rgba(42,113,255,0.16), rgba(0,0,0,0) 60%),
|
||||
radial-gradient(800px 650px at 70% 35%, rgba(255,0,138,0.12), rgba(0,0,0,0) 62%),
|
||||
radial-gradient(900px 800px at 55% 75%, rgba(0,209,255,0.10), rgba(0,0,0,0) 62%);
|
||||
filter: blur(28px);
|
||||
opacity: 0.52;
|
||||
will-change: transform, opacity;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
transform: translate3d(0,0,0);
|
||||
pointer-events: none;
|
||||
animation: clawdis-glow-drift 110s ease-in-out infinite alternate;
|
||||
}
|
||||
@supports (mix-blend-mode: screen) {
|
||||
body::after { mix-blend-mode: screen; }
|
||||
}
|
||||
@supports not (mix-blend-mode: screen) {
|
||||
body::after { opacity: 0.70; }
|
||||
}
|
||||
@keyframes clawdis-grid-drift {
|
||||
0% { transform: translate3d(-12px, 8px, 0) rotate(-7deg); opacity: 0.40; }
|
||||
50% { transform: translate3d( 10px,-7px, 0) rotate(-6.6deg); opacity: 0.56; }
|
||||
100% { transform: translate3d(-8px, 6px, 0) rotate(-7.2deg); opacity: 0.42; }
|
||||
}
|
||||
@keyframes clawdis-glow-drift {
|
||||
0% { transform: translate3d(-18px, 12px, 0) scale(1.02); opacity: 0.40; }
|
||||
50% { transform: translate3d( 14px,-10px, 0) scale(1.05); opacity: 0.52; }
|
||||
100% { transform: translate3d(-10px, 8px, 0) scale(1.03); opacity: 0.43; }
|
||||
}
|
||||
canvas {
|
||||
display:block;
|
||||
width:100vw;
|
||||
height:100vh;
|
||||
touch-action: none;
|
||||
}
|
||||
#clawdis-status {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
#clawdis-status .card {
|
||||
text-align: center;
|
||||
padding: 16px 18px;
|
||||
border-radius: 14px;
|
||||
background: rgba(18, 18, 22, 0.42);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
box-shadow: 0 18px 60px rgba(0,0,0,0.55);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
#clawdis-status .title {
|
||||
font: 600 20px -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif;
|
||||
letter-spacing: 0.2px;
|
||||
color: rgba(255,255,255,0.92);
|
||||
text-shadow: 0 0 22px rgba(42, 113, 255, 0.35);
|
||||
}
|
||||
#clawdis-status .subtitle {
|
||||
margin-top: 6px;
|
||||
font: 500 12px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
||||
color: rgba(255,255,255,0.58);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="clawdis-canvas"></canvas>
|
||||
<div id="clawdis-status">
|
||||
<div class="card">
|
||||
<div class="title" id="clawdis-status-title">Ready</div>
|
||||
<div class="subtitle" id="clawdis-status-subtitle">Waiting for agent</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const canvas = document.getElementById('clawdis-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const statusEl = document.getElementById('clawdis-status');
|
||||
const titleEl = document.getElementById('clawdis-status-title');
|
||||
const subtitleEl = document.getElementById('clawdis-status-subtitle');
|
||||
|
||||
function resize() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = Math.max(1, Math.floor(window.innerWidth * dpr));
|
||||
const h = Math.max(1, Math.floor(window.innerHeight * dpr));
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
window.__clawdis = {
|
||||
canvas,
|
||||
ctx,
|
||||
setStatus: (title, subtitle) => {
|
||||
if (!statusEl) return;
|
||||
if (!title && !subtitle) {
|
||||
statusEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
statusEl.style.display = 'grid';
|
||||
if (titleEl && typeof title === 'string') titleEl.textContent = title;
|
||||
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
|
||||
// Auto-hide after 3 seconds.
|
||||
clearTimeout(window.__statusTimeout);
|
||||
window.__statusTimeout = setTimeout(() => {
|
||||
statusEl.style.display = 'none';
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import ClawdisKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite struct CanvasA2UIActionTests {
|
||||
@Test func sanitizeTagValueIsStable() {
|
||||
#expect(ClawdisCanvasA2UIAction.sanitizeTagValue("Hello World!") == "Hello_World_")
|
||||
#expect(ClawdisCanvasA2UIAction.sanitizeTagValue(" ") == "-")
|
||||
#expect(ClawdisCanvasA2UIAction.sanitizeTagValue("macOS 26.2") == "macOS_26.2")
|
||||
}
|
||||
|
||||
@Test func formatAgentMessageIsTokenEfficientAndUnambiguous() {
|
||||
let msg = ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||
actionName: "Get Weather",
|
||||
sessionKey: "main",
|
||||
surfaceId: "main",
|
||||
sourceComponentId: "btnWeather",
|
||||
host: "Peter’s iPad",
|
||||
instanceId: "ipad16,6",
|
||||
contextJSON: "{\"city\":\"Vienna\"}")
|
||||
|
||||
#expect(msg.contains("CANVAS_A2UI "))
|
||||
#expect(msg.contains("action=Get_Weather"))
|
||||
#expect(msg.contains("session=main"))
|
||||
#expect(msg.contains("surface=main"))
|
||||
#expect(msg.contains("component=btnWeather"))
|
||||
#expect(msg.contains("host=Peter_s_iPad"))
|
||||
#expect(msg.contains("instance=ipad16_6 ctx={\"city\":\"Vienna\"}"))
|
||||
#expect(msg.hasSuffix(" default=update_canvas"))
|
||||
}
|
||||
|
||||
@Test func a2uiBundleSupportsAndroidBridgeFallback() throws {
|
||||
guard let url = ClawdisKitResources.bundle.url(forResource: "a2ui.bundle", withExtension: "js")
|
||||
else {
|
||||
throw NSError(domain: "Tests", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Missing resource a2ui.bundle.js",
|
||||
])
|
||||
}
|
||||
let js = try String(contentsOf: url, encoding: .utf8)
|
||||
#expect(js.contains("clawdisCanvasA2UIAction"))
|
||||
#expect(js.contains("globalThis.clawdisCanvasA2UIAction"))
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,12 @@ const empty = Object.freeze({});
|
||||
const emptyClasses = () => ({});
|
||||
const textHintStyles = () => ({ h1: {}, h2: {}, h3: {}, h4: {}, h5: {}, body: {}, caption: {} });
|
||||
|
||||
const isAndroid = /Android/i.test(globalThis.navigator?.userAgent ?? "");
|
||||
const cardShadow = isAndroid ? "0 2px 10px rgba(0,0,0,.18)" : "0 10px 30px rgba(0,0,0,.35)";
|
||||
const buttonShadow = isAndroid ? "0 2px 10px rgba(6, 182, 212, 0.14)" : "0 10px 25px rgba(6, 182, 212, 0.18)";
|
||||
const statusShadow = isAndroid ? "0 2px 10px rgba(0, 0, 0, 0.18)" : "0 10px 24px rgba(0, 0, 0, 0.25)";
|
||||
const statusBlur = isAndroid ? "10px" : "14px";
|
||||
|
||||
const clawdisTheme = {
|
||||
components: {
|
||||
AudioPlayer: emptyClasses(),
|
||||
@@ -85,7 +91,7 @@ const clawdisTheme = {
|
||||
border: "1px solid rgba(255,255,255,.09)",
|
||||
borderRadius: "14px",
|
||||
padding: "14px",
|
||||
boxShadow: "0 10px 30px rgba(0,0,0,.35)",
|
||||
boxShadow: cardShadow,
|
||||
},
|
||||
Column: { gap: "10px" },
|
||||
Row: { gap: "10px", alignItems: "center" },
|
||||
@@ -98,7 +104,7 @@ const clawdisTheme = {
|
||||
color: "#071016",
|
||||
fontWeight: "650",
|
||||
cursor: "pointer",
|
||||
boxShadow: "0 10px 25px rgba(6, 182, 212, 0.18)",
|
||||
boxShadow: buttonShadow,
|
||||
},
|
||||
Text: {
|
||||
...textHintStyles(),
|
||||
@@ -161,11 +167,11 @@ class ClawdisA2UIHost extends LitElement {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font: 13px/1.2 -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(${statusBlur});
|
||||
-webkit-backdrop-filter: blur(${statusBlur});
|
||||
box-shadow: ${statusShadow};
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
@@ -182,11 +188,11 @@ class ClawdisA2UIHost extends LitElement {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font: 13px/1.2 -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(${statusBlur});
|
||||
-webkit-backdrop-filter: blur(${statusBlur});
|
||||
box-shadow: ${statusShadow};
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
@@ -335,10 +341,17 @@ class ClawdisA2UIHost extends LitElement {
|
||||
|
||||
globalThis.__clawdisLastA2UIAction = userAction;
|
||||
|
||||
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction;
|
||||
const handler =
|
||||
globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction ??
|
||||
globalThis.clawdisCanvasA2UIAction;
|
||||
if (handler?.postMessage) {
|
||||
try {
|
||||
handler.postMessage({ userAction });
|
||||
// WebKit message handlers support structured objects; Android's JS interface expects strings.
|
||||
if (handler === globalThis.clawdisCanvasA2UIAction) {
|
||||
handler.postMessage(JSON.stringify({ userAction }));
|
||||
} else {
|
||||
handler.postMessage({ userAction });
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = String(e?.message ?? e);
|
||||
this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: msg };
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "rolldown";
|
||||
|
||||
const here = path.dirname(new URL(import.meta.url).pathname);
|
||||
const repoRoot = path.resolve(here, "../../../../../..");
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(here, "../../../../..");
|
||||
const fromHere = (p) => path.resolve(here, p);
|
||||
const outputFile = path.resolve(here, "../../Sources/ClawdisKit/Resources/CanvasA2UI/a2ui.bundle.js");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user