A2UI: share web UI and action bridge
This commit is contained in:
@@ -9,6 +9,12 @@ android {
|
|||||||
namespace = "com.steipete.clawdis.node"
|
namespace = "com.steipete.clawdis.node"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
getByName("main") {
|
||||||
|
assets.srcDir(file("../../shared/ClawdisKit/Sources/ClawdisKit/Resources"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.steipete.clawdis.node"
|
applicationId = "com.steipete.clawdis.node"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,23 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Canvas</title>
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: light dark; }
|
|
||||||
html, body { height: 100%; margin: 0; }
|
|
||||||
body {
|
|
||||||
font: 13px -apple-system, system-ui;
|
|
||||||
background: #0b1020;
|
|
||||||
color: #e5e7eb;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
clawdis-a2ui-host { display: block; height: 100%; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<clawdis-a2ui-host></clawdis-a2ui-host>
|
|
||||||
<script src="a2ui.bundle.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -97,6 +97,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
runtime.disconnect()
|
runtime.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||||
|
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
||||||
|
}
|
||||||
|
|
||||||
fun loadChat(sessionKey: String = "main") {
|
fun loadChat(sessionKey: String = "main") {
|
||||||
runtime.loadChat(sessionKey)
|
runtime.loadChat(sessionKey)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import com.steipete.clawdis.node.node.CameraCaptureManager
|
|||||||
import com.steipete.clawdis.node.node.CanvasController
|
import com.steipete.clawdis.node.node.CanvasController
|
||||||
import com.steipete.clawdis.node.protocol.ClawdisCapability
|
import com.steipete.clawdis.node.protocol.ClawdisCapability
|
||||||
import com.steipete.clawdis.node.protocol.ClawdisCameraCommand
|
import com.steipete.clawdis.node.protocol.ClawdisCameraCommand
|
||||||
|
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UIAction
|
||||||
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UICommand
|
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UICommand
|
||||||
import com.steipete.clawdis.node.protocol.ClawdisCanvasCommand
|
import com.steipete.clawdis.node.protocol.ClawdisCanvasCommand
|
||||||
import com.steipete.clawdis.node.voice.VoiceWakeManager
|
import com.steipete.clawdis.node.voice.VoiceWakeManager
|
||||||
@@ -364,6 +365,79 @@ class NodeRuntime(context: Context) {
|
|||||||
session.disconnect()
|
session.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||||
|
scope.launch {
|
||||||
|
val trimmed = payloadJson.trim()
|
||||||
|
if (trimmed.isEmpty()) return@launch
|
||||||
|
|
||||||
|
val root =
|
||||||
|
try {
|
||||||
|
json.parseToJsonElement(trimmed).asObjectOrNull() ?: return@launch
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val userActionObj = (root["userAction"] as? JsonObject) ?: root
|
||||||
|
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
|
||||||
|
java.util.UUID.randomUUID().toString()
|
||||||
|
}
|
||||||
|
val name = (userActionObj["name"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
||||||
|
if (name.isEmpty()) return@launch
|
||||||
|
|
||||||
|
val surfaceId =
|
||||||
|
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
|
||||||
|
val sourceComponentId =
|
||||||
|
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
|
||||||
|
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
|
||||||
|
|
||||||
|
val sessionKey = "main"
|
||||||
|
val message =
|
||||||
|
ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||||
|
actionName = name,
|
||||||
|
sessionKey = sessionKey,
|
||||||
|
surfaceId = surfaceId,
|
||||||
|
sourceComponentId = sourceComponentId,
|
||||||
|
host = displayName.value,
|
||||||
|
instanceId = instanceId.value.lowercase(),
|
||||||
|
contextJson = contextJson,
|
||||||
|
)
|
||||||
|
|
||||||
|
val connected = isConnected.value
|
||||||
|
var error: String? = null
|
||||||
|
if (connected) {
|
||||||
|
try {
|
||||||
|
session.sendEvent(
|
||||||
|
event = "agent.request",
|
||||||
|
payloadJson =
|
||||||
|
buildJsonObject {
|
||||||
|
put("message", JsonPrimitive(message))
|
||||||
|
put("sessionKey", JsonPrimitive(sessionKey))
|
||||||
|
put("thinking", JsonPrimitive("low"))
|
||||||
|
put("deliver", JsonPrimitive(false))
|
||||||
|
put("key", JsonPrimitive(actionId))
|
||||||
|
}.toString(),
|
||||||
|
)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
error = e.message ?: "send failed"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error = "bridge not connected"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
canvas.eval(
|
||||||
|
ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(
|
||||||
|
actionId = actionId,
|
||||||
|
ok = connected && error == null,
|
||||||
|
error = error,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun loadChat(sessionKey: String = "main") {
|
fun loadChat(sessionKey: String = "main") {
|
||||||
chat.load(sessionKey)
|
chat.load(sessionKey)
|
||||||
}
|
}
|
||||||
@@ -581,11 +655,15 @@ class NodeRuntime(context: Context) {
|
|||||||
val raw = paramsJson?.trim().orEmpty()
|
val raw = paramsJson?.trim().orEmpty()
|
||||||
if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required")
|
if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required")
|
||||||
|
|
||||||
if (command == ClawdisCanvasA2UICommand.PushJSONL.rawValue) {
|
val obj =
|
||||||
val obj =
|
json.parseToJsonElement(raw) as? JsonObject
|
||||||
json.parseToJsonElement(raw) as? JsonObject
|
?: throw IllegalArgumentException("INVALID_REQUEST: expected object params")
|
||||||
?: throw IllegalArgumentException("INVALID_REQUEST: expected object params")
|
|
||||||
val jsonl = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
||||||
|
val hasMessagesArray = obj["messages"] is JsonArray
|
||||||
|
|
||||||
|
if (command == ClawdisCanvasA2UICommand.PushJSONL.rawValue || (!hasMessagesArray && jsonlField.isNotBlank())) {
|
||||||
|
val jsonl = jsonlField
|
||||||
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
|
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
|
||||||
val messages =
|
val messages =
|
||||||
jsonl
|
jsonl
|
||||||
@@ -604,9 +682,6 @@ class NodeRuntime(context: Context) {
|
|||||||
return JsonArray(messages).toString()
|
return JsonArray(messages).toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
val obj =
|
|
||||||
json.parseToJsonElement(raw) as? JsonObject
|
|
||||||
?: throw IllegalArgumentException("INVALID_REQUEST: expected object params")
|
|
||||||
val arr = obj["messages"] as? JsonArray ?: throw IllegalArgumentException("INVALID_REQUEST: messages[] required")
|
val arr = obj["messages"] as? JsonArray ?: throw IllegalArgumentException("INVALID_REQUEST: messages[] required")
|
||||||
val out =
|
val out =
|
||||||
arr.mapIndexed { idx, el ->
|
arr.mapIndexed { idx, el ->
|
||||||
@@ -638,7 +713,7 @@ class NodeRuntime(context: Context) {
|
|||||||
|
|
||||||
private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
|
private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
|
||||||
|
|
||||||
private const val a2uiIndexUrl: String = "file:///android_asset/canvas_a2ui/index.html"
|
private const val a2uiIndexUrl: String = "file:///android_asset/CanvasA2UI/index.html"
|
||||||
|
|
||||||
private const val a2uiReadyCheckJS: String =
|
private const val a2uiReadyCheckJS: String =
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class CanvasController {
|
|||||||
@Volatile private var mode: Mode = Mode.CANVAS
|
@Volatile private var mode: Mode = Mode.CANVAS
|
||||||
@Volatile private var url: String = ""
|
@Volatile private var url: String = ""
|
||||||
|
|
||||||
|
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
|
||||||
|
|
||||||
fun attach(webView: WebView) {
|
fun attach(webView: WebView) {
|
||||||
this.webView = webView
|
this.webView = webView
|
||||||
reload()
|
reload()
|
||||||
@@ -59,7 +61,7 @@ class CanvasController {
|
|||||||
if (trimmed.isBlank()) return@withWebViewOnMain
|
if (trimmed.isBlank()) return@withWebViewOnMain
|
||||||
wv.loadUrl(trimmed)
|
wv.loadUrl(trimmed)
|
||||||
}
|
}
|
||||||
Mode.CANVAS -> wv.loadDataWithBaseURL(null, canvasHtml, "text/html", "utf-8", null)
|
Mode.CANVAS -> wv.loadUrl(scaffoldAssetUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,158 +145,3 @@ class CanvasController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val canvasHtml =
|
|
||||||
"""
|
|
||||||
<!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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""".trimIndent()
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.steipete.clawdis.node.protocol
|
||||||
|
|
||||||
|
object ClawdisCanvasA2UIAction {
|
||||||
|
fun sanitizeTagValue(value: String): String {
|
||||||
|
val trimmed = value.trim().ifEmpty { "-" }
|
||||||
|
val normalized = trimmed.replace(" ", "_")
|
||||||
|
val out = StringBuilder(normalized.length)
|
||||||
|
for (c in normalized) {
|
||||||
|
val ok =
|
||||||
|
c.isLetterOrDigit() ||
|
||||||
|
c == '_' ||
|
||||||
|
c == '-' ||
|
||||||
|
c == '.' ||
|
||||||
|
c == ':'
|
||||||
|
out.append(if (ok) c else '_')
|
||||||
|
}
|
||||||
|
return out.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatAgentMessage(
|
||||||
|
actionName: String,
|
||||||
|
sessionKey: String,
|
||||||
|
surfaceId: String,
|
||||||
|
sourceComponentId: String,
|
||||||
|
host: String,
|
||||||
|
instanceId: String,
|
||||||
|
contextJson: String?,
|
||||||
|
): String {
|
||||||
|
val ctxSuffix = contextJson?.takeIf { it.isNotBlank() }?.let { " ctx=$it" }.orEmpty()
|
||||||
|
return listOf(
|
||||||
|
"CANVAS_A2UI",
|
||||||
|
"action=${sanitizeTagValue(actionName)}",
|
||||||
|
"session=${sanitizeTagValue(sessionKey)}",
|
||||||
|
"surface=${sanitizeTagValue(surfaceId)}",
|
||||||
|
"component=${sanitizeTagValue(sourceComponentId)}",
|
||||||
|
"host=${sanitizeTagValue(host)}",
|
||||||
|
"instance=${sanitizeTagValue(instanceId)}$ctxSuffix",
|
||||||
|
"default=update_canvas",
|
||||||
|
).joinToString(separator = " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun jsDispatchA2UIActionStatus(actionId: String, ok: Boolean, error: String?): String {
|
||||||
|
val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"")
|
||||||
|
val okLiteral = if (ok) "true" else "false"
|
||||||
|
val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||||
|
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -6,13 +6,13 @@ import android.content.pm.PackageManager
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebSettings
|
import android.webkit.WebSettings
|
||||||
import android.webkit.WebResourceError
|
import android.webkit.WebResourceError
|
||||||
import android.webkit.WebResourceRequest
|
import android.webkit.WebResourceRequest
|
||||||
import android.webkit.WebResourceResponse
|
import android.webkit.WebResourceResponse
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -24,12 +24,10 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -42,10 +40,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color as ComposeColor
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
@@ -80,7 +75,6 @@ fun RootScreen(viewModel: MainViewModel) {
|
|||||||
PackageManager.PERMISSION_GRANTED
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
CanvasBackdrop(modifier = Modifier.fillMaxSize())
|
|
||||||
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,73 +181,30 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setBackgroundColor(Color.TRANSPARENT)
|
setBackgroundColor(Color.BLACK)
|
||||||
setBackgroundResource(0)
|
setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||||
// WebView transparency + HW acceleration can render as solid black on some Android/WebView builds.
|
|
||||||
// Prefer correct alpha blending since we render the idle backdrop in Compose underneath.
|
addJavascriptInterface(
|
||||||
setLayerType(View.LAYER_TYPE_SOFTWARE, null)
|
CanvasA2UIActionBridge { payload ->
|
||||||
|
viewModel.handleCanvasA2UIActionFromWebView(payload)
|
||||||
|
},
|
||||||
|
CanvasA2UIActionBridge.interfaceName,
|
||||||
|
)
|
||||||
viewModel.canvas.attach(this)
|
viewModel.canvas.attach(this)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
|
||||||
private fun CanvasBackdrop(modifier: Modifier = Modifier) {
|
@JavascriptInterface
|
||||||
val base = MaterialTheme.colorScheme.background
|
fun postMessage(payload: String?) {
|
||||||
|
val msg = payload?.trim().orEmpty()
|
||||||
|
if (msg.isEmpty()) return
|
||||||
|
onMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
Canvas(modifier = modifier.background(base)) {
|
companion object {
|
||||||
// Subtle idle backdrop; also acts as fallback when WebView content is transparent or fails to load.
|
const val interfaceName: String = "clawdisCanvasA2UIAction"
|
||||||
drawRect(
|
|
||||||
brush =
|
|
||||||
Brush.linearGradient(
|
|
||||||
colors =
|
|
||||||
listOf(
|
|
||||||
ComposeColor(0xFF0A2034),
|
|
||||||
ComposeColor(0xFF070A10),
|
|
||||||
ComposeColor(0xFF250726),
|
|
||||||
),
|
|
||||||
start = Offset(0f, 0f),
|
|
||||||
end = Offset(size.width, size.height),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
val step = 48f * density
|
|
||||||
val lineColor = ComposeColor.White.copy(alpha = 0.028f)
|
|
||||||
var x = -step
|
|
||||||
while (x < size.width + step) {
|
|
||||||
drawLine(color = lineColor, start = Offset(x, 0f), end = Offset(x, size.height), strokeWidth = 1f)
|
|
||||||
x += step
|
|
||||||
}
|
|
||||||
var y = -step
|
|
||||||
while (y < size.height + step) {
|
|
||||||
drawLine(color = lineColor, start = Offset(0f, y), end = Offset(size.width, y), strokeWidth = 1f)
|
|
||||||
y += step
|
|
||||||
}
|
|
||||||
|
|
||||||
drawRect(
|
|
||||||
brush =
|
|
||||||
Brush.radialGradient(
|
|
||||||
colors = listOf(ComposeColor(0xFF2A71FF).copy(alpha = 0.22f), ComposeColor.Transparent),
|
|
||||||
center = Offset(size.width * 0.15f, size.height * 0.20f),
|
|
||||||
radius = size.minDimension * 0.9f,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
drawRect(
|
|
||||||
brush =
|
|
||||||
Brush.radialGradient(
|
|
||||||
colors = listOf(ComposeColor(0xFFFF008A).copy(alpha = 0.18f), ComposeColor.Transparent),
|
|
||||||
center = Offset(size.width * 0.85f, size.height * 0.30f),
|
|
||||||
radius = size.minDimension * 0.75f,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
drawRect(
|
|
||||||
brush =
|
|
||||||
Brush.radialGradient(
|
|
||||||
colors = listOf(ComposeColor(0xFF00D1FF).copy(alpha = 0.14f), ComposeColor.Transparent),
|
|
||||||
center = Offset(size.width * 0.60f, size.height * 0.90f),
|
|
||||||
radius = size.minDimension * 0.85f,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.steipete.clawdis.node.protocol
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ClawdisCanvasA2UIActionTest {
|
||||||
|
@Test
|
||||||
|
fun formatAgentMessageMatchesSharedSpec() {
|
||||||
|
val msg =
|
||||||
|
ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||||
|
actionName = "Get Weather",
|
||||||
|
sessionKey = "main",
|
||||||
|
surfaceId = "main",
|
||||||
|
sourceComponentId = "btnWeather",
|
||||||
|
host = "Peter’s iPad",
|
||||||
|
instanceId = "ipad16,6",
|
||||||
|
contextJson = "{\"city\":\"Vienna\"}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"CANVAS_A2UI action=Get_Weather session=main surface=main component=btnWeather host=Peter_s_iPad instance=ipad16_6 ctx={\"city\":\"Vienna\"} default=update_canvas",
|
||||||
|
msg,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun jsDispatchA2uiStatusIsStable() {
|
||||||
|
val js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId = "a1", ok = true, error = null)
|
||||||
|
assertEquals(
|
||||||
|
"window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"a1\", ok: true, error: \"\" } }));",
|
||||||
|
js,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@ import ClawdisKit
|
|||||||
import Network
|
import Network
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
@@ -43,6 +44,90 @@ final class NodeAppModel {
|
|||||||
await self.handleDeepLink(url: url)
|
await self.handleDeepLink(url: url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wire up A2UI action clicks (buttons, etc.)
|
||||||
|
self.screen.onA2UIAction = { [weak self] body in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
await self.handleCanvasA2UIAction(body: body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleCanvasA2UIAction(body: [String: Any]) async {
|
||||||
|
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 actionId: String = {
|
||||||
|
let id = (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return id.isEmpty ? UUID().uuidString : id
|
||||||
|
}()
|
||||||
|
|
||||||
|
let surfaceId: String = {
|
||||||
|
let raw = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return raw.isEmpty ? "main" : raw
|
||||||
|
}()
|
||||||
|
let sourceComponentId: String = {
|
||||||
|
let raw = (userAction[
|
||||||
|
"sourceComponentId",
|
||||||
|
] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return raw.isEmpty ? "-" : raw
|
||||||
|
}()
|
||||||
|
|
||||||
|
let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name
|
||||||
|
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
|
||||||
|
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
|
||||||
|
let sessionKey = "main"
|
||||||
|
|
||||||
|
let message = ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||||
|
actionName: name,
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
surfaceId: surfaceId,
|
||||||
|
sourceComponentId: sourceComponentId,
|
||||||
|
host: host,
|
||||||
|
instanceId: instanceId,
|
||||||
|
contextJSON: contextJSON)
|
||||||
|
|
||||||
|
let ok: Bool
|
||||||
|
var errorText: String? = nil
|
||||||
|
if await !self.isBridgeConnected() {
|
||||||
|
ok = false
|
||||||
|
errorText = "bridge not connected"
|
||||||
|
} else {
|
||||||
|
do {
|
||||||
|
try await self.sendAgentRequest(link: AgentDeepLink(
|
||||||
|
message: message,
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
thinking: "low",
|
||||||
|
deliver: false,
|
||||||
|
to: nil,
|
||||||
|
channel: nil,
|
||||||
|
timeoutSeconds: nil,
|
||||||
|
key: actionId))
|
||||||
|
ok = true
|
||||||
|
} catch {
|
||||||
|
ok = false
|
||||||
|
errorText = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId: actionId, ok: ok, error: errorText)
|
||||||
|
do {
|
||||||
|
_ = try await self.screen.eval(javaScript: js)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setScenePhase(_ phase: ScenePhase) {
|
func setScenePhase(_ phase: ScenePhase) {
|
||||||
@@ -339,8 +424,14 @@ final class NodeAppModel {
|
|||||||
let params = try Self.decodeParams(ClawdisCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
|
let params = try Self.decodeParams(ClawdisCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
|
||||||
messages = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
|
messages = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
|
||||||
} else {
|
} else {
|
||||||
let params = try Self.decodeParams(ClawdisCanvasA2UIPushParams.self, from: req.paramsJSON)
|
do {
|
||||||
messages = params.messages
|
let params = try Self.decodeParams(ClawdisCanvasA2UIPushParams.self, from: req.paramsJSON)
|
||||||
|
messages = params.messages
|
||||||
|
} catch {
|
||||||
|
// Be forgiving: some clients still send JSONL payloads to `canvas.a2ui.push`.
|
||||||
|
let params = try Self.decodeParams(ClawdisCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
|
||||||
|
messages = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try self.screen.showA2UI()
|
try self.screen.showA2UI()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import WebKit
|
|||||||
final class ScreenController {
|
final class ScreenController {
|
||||||
let webView: WKWebView
|
let webView: WKWebView
|
||||||
private let navigationDelegate: ScreenNavigationDelegate
|
private let navigationDelegate: ScreenNavigationDelegate
|
||||||
|
private let a2uiActionHandler: CanvasA2UIActionMessageHandler
|
||||||
|
|
||||||
var mode: ClawdisCanvasMode = .canvas
|
var mode: ClawdisCanvasMode = .canvas
|
||||||
var urlString: String = ""
|
var urlString: String = ""
|
||||||
@@ -16,14 +17,23 @@ final class ScreenController {
|
|||||||
/// Callback invoked when a clawdis:// deep link is tapped in the canvas
|
/// Callback invoked when a clawdis:// deep link is tapped in the canvas
|
||||||
var onDeepLink: ((URL) -> Void)?
|
var onDeepLink: ((URL) -> Void)?
|
||||||
|
|
||||||
|
/// Callback invoked when the user clicks an A2UI action (e.g. button) inside the canvas web UI.
|
||||||
|
var onA2UIAction: (([String: Any]) -> Void)?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let config = WKWebViewConfiguration()
|
let config = WKWebViewConfiguration()
|
||||||
config.websiteDataStore = .nonPersistent()
|
config.websiteDataStore = .nonPersistent()
|
||||||
|
let a2uiActionHandler = CanvasA2UIActionMessageHandler()
|
||||||
|
let userContentController = WKUserContentController()
|
||||||
|
userContentController.add(a2uiActionHandler, name: CanvasA2UIActionMessageHandler.messageName)
|
||||||
|
config.userContentController = userContentController
|
||||||
self.navigationDelegate = ScreenNavigationDelegate()
|
self.navigationDelegate = ScreenNavigationDelegate()
|
||||||
|
self.a2uiActionHandler = a2uiActionHandler
|
||||||
self.webView = WKWebView(frame: .zero, configuration: config)
|
self.webView = WKWebView(frame: .zero, configuration: config)
|
||||||
self.webView.isOpaque = false
|
// Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays.
|
||||||
self.webView.backgroundColor = .clear
|
self.webView.isOpaque = true
|
||||||
self.webView.scrollView.backgroundColor = .clear
|
self.webView.backgroundColor = .black
|
||||||
|
self.webView.scrollView.backgroundColor = .black
|
||||||
self.webView.scrollView.contentInsetAdjustmentBehavior = .never
|
self.webView.scrollView.contentInsetAdjustmentBehavior = .never
|
||||||
self.webView.scrollView.contentInset = .zero
|
self.webView.scrollView.contentInset = .zero
|
||||||
self.webView.scrollView.scrollIndicatorInsets = .zero
|
self.webView.scrollView.scrollIndicatorInsets = .zero
|
||||||
@@ -33,6 +43,7 @@ final class ScreenController {
|
|||||||
self.webView.scrollView.bounces = false
|
self.webView.scrollView.bounces = false
|
||||||
self.webView.navigationDelegate = self.navigationDelegate
|
self.webView.navigationDelegate = self.navigationDelegate
|
||||||
self.navigationDelegate.controller = self
|
self.navigationDelegate.controller = self
|
||||||
|
a2uiActionHandler.controller = self
|
||||||
self.reload()
|
self.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,18 +72,16 @@ final class ScreenController {
|
|||||||
self.webView.load(URLRequest(url: url))
|
self.webView.load(URLRequest(url: url))
|
||||||
}
|
}
|
||||||
case .canvas:
|
case .canvas:
|
||||||
self.webView.loadHTMLString(Self.canvasScaffoldHTML, baseURL: nil)
|
guard let url = Self.canvasScaffoldURL else { return }
|
||||||
|
self.webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func showA2UI() throws {
|
func showA2UI() throws {
|
||||||
guard let url = ClawdisKitResources.bundle.url(
|
guard let url = Self.a2uiIndexURL
|
||||||
forResource: "index",
|
|
||||||
withExtension: "html",
|
|
||||||
subdirectory: "CanvasA2UI")
|
|
||||||
else {
|
else {
|
||||||
throw NSError(domain: "Canvas", code: 10, userInfo: [
|
throw NSError(domain: "Canvas", code: 10, userInfo: [
|
||||||
NSLocalizedDescriptionKey: "A2UI resources missing (CanvasA2UI/index.html)",
|
NSLocalizedDescriptionKey: "A2UI resources missing (index.html)",
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
self.mode = .web
|
self.mode = .web
|
||||||
@@ -145,164 +154,17 @@ final class ScreenController {
|
|||||||
return data.base64EncodedString()
|
return data.base64EncodedString()
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let canvasScaffoldHTML = """
|
// SwiftPM flattens resource directories; ensure resource filenames are unique.
|
||||||
<!doctype html>
|
private static let canvasScaffoldURL: URL? = ClawdisKitResources.bundle.url(
|
||||||
<html>
|
forResource: "scaffold",
|
||||||
<head>
|
withExtension: "html")
|
||||||
<meta charset="utf-8" />
|
private static let a2uiIndexURL: URL? = ClawdisKitResources.bundle.url(forResource: "index", withExtension: "html")
|
||||||
<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() {
|
fileprivate func isBundledA2UIURL(_ url: URL) -> Bool {
|
||||||
const dpr = window.devicePixelRatio || 1;
|
guard url.isFileURL else { return false }
|
||||||
const w = Math.max(1, Math.floor(window.innerWidth * dpr));
|
guard let expected = Self.a2uiIndexURL else { return false }
|
||||||
const h = Math.max(1, Math.floor(window.innerHeight * dpr));
|
return url.standardizedFileURL == expected.standardizedFileURL
|
||||||
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>
|
|
||||||
"""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Navigation Delegate
|
// MARK: - Navigation Delegate
|
||||||
@@ -333,3 +195,31 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
|
|||||||
decisionHandler(.allow)
|
decisionHandler(.allow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||||
|
static let messageName = "clawdisCanvasA2UIAction"
|
||||||
|
|
||||||
|
weak var controller: ScreenController?
|
||||||
|
|
||||||
|
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||||
|
guard message.name == Self.messageName else { return }
|
||||||
|
guard let controller else { return }
|
||||||
|
|
||||||
|
// Only accept actions from local bundled CanvasA2UI content (not arbitrary web pages).
|
||||||
|
guard let url = message.webView?.url, controller.isBundledA2UIURL(url) 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 }
|
||||||
|
|
||||||
|
controller.onA2UIAction?(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import WebKit
|
|||||||
let screen = ScreenController()
|
let screen = ScreenController()
|
||||||
|
|
||||||
#expect(screen.mode == .canvas)
|
#expect(screen.mode == .canvas)
|
||||||
#expect(screen.webView.isOpaque == false)
|
#expect(screen.webView.isOpaque == true)
|
||||||
#expect(screen.webView.backgroundColor == .clear)
|
#expect(screen.webView.backgroundColor == .black)
|
||||||
|
|
||||||
let scrollView = screen.webView.scrollView
|
let scrollView = screen.webView.scrollView
|
||||||
#expect(scrollView.backgroundColor == .clear)
|
#expect(scrollView.backgroundColor == .black)
|
||||||
#expect(scrollView.contentInsetAdjustmentBehavior == .never)
|
#expect(scrollView.contentInsetAdjustmentBehavior == .never)
|
||||||
#expect(scrollView.isScrollEnabled == false)
|
#expect(scrollView.isScrollEnabled == false)
|
||||||
#expect(scrollView.bounces == false)
|
#expect(scrollView.bounces == false)
|
||||||
|
|||||||
@@ -205,8 +205,8 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func a2uiShellPage(sessionRoot: URL) -> CanvasResponse {
|
private func a2uiShellPage(sessionRoot: URL) -> CanvasResponse {
|
||||||
// Default Canvas UX: when no index exists, show the built-in A2UI shell.
|
// Default Canvas UX: when no index exists, show the built-in scaffold page.
|
||||||
if let data = self.loadBundledResourceData(subdirectory: "CanvasA2UI", relativePath: "index.html") {
|
if let data = self.loadBundledResourceData(relativePath: "scaffold.html") {
|
||||||
return CanvasResponse(mime: "text/html", data: data)
|
return CanvasResponse(mime: "text/html", data: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +234,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
|||||||
return self.html("Forbidden", title: "Canvas: 403")
|
return self.html("Forbidden", title: "Canvas: 403")
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let data = self.loadBundledResourceData(subdirectory: "CanvasA2UI", relativePath: relative) else {
|
guard let data = self.loadBundledResourceData(relativePath: relative) else {
|
||||||
return self.html("Not Found", title: "Canvas: 404")
|
return self.html("Not Found", title: "Canvas: 404")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,12 +243,15 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
|||||||
return CanvasResponse(mime: mime, data: data)
|
return CanvasResponse(mime: mime, data: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadBundledResourceData(subdirectory: String, relativePath: String) -> Data? {
|
private func loadBundledResourceData(relativePath: String) -> Data? {
|
||||||
guard let base = ClawdisKitResources.bundle.resourceURL?.appendingPathComponent(subdirectory, isDirectory: true) else {
|
// SwiftPM flattens resource directories; treat bundled canvas resources as uniquely-named files.
|
||||||
return nil
|
if relativePath.contains("/") { return nil }
|
||||||
}
|
let url = URL(fileURLWithPath: relativePath)
|
||||||
let url = base.appendingPathComponent(relativePath, isDirectory: false)
|
let ext = url.pathExtension
|
||||||
return try? Data(contentsOf: url)
|
let name = url.deletingPathExtension().lastPathComponent
|
||||||
|
guard !name.isEmpty, !ext.isEmpty else { return nil }
|
||||||
|
guard let resourceURL = ClawdisKitResources.bundle.url(forResource: name, withExtension: ext) else { return nil }
|
||||||
|
return try? Data(contentsOf: resourceURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func textEncodingName(forMimeType mimeType: String) -> String? {
|
private func textEncodingName(forMimeType mimeType: String) -> String? {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ClawdisIPC
|
import ClawdisIPC
|
||||||
|
import ClawdisKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
@@ -149,7 +150,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
|
|
||||||
canvasWindowLogger.debug("CanvasWindowController init creating WKWebView")
|
canvasWindowLogger.debug("CanvasWindowController init creating WKWebView")
|
||||||
self.webView = WKWebView(frame: .zero, configuration: config)
|
self.webView = WKWebView(frame: .zero, configuration: config)
|
||||||
self.webView.setValue(false, forKey: "drawsBackground")
|
// Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays.
|
||||||
|
self.webView.setValue(true, forKey: "drawsBackground")
|
||||||
|
|
||||||
let sessionDir = self.sessionDir
|
let sessionDir = self.sessionDir
|
||||||
let webView = self.webView
|
let webView = self.webView
|
||||||
@@ -646,23 +648,18 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
|
|||||||
.nonEmpty ?? "main"
|
.nonEmpty ?? "main"
|
||||||
let sourceComponentId = (userAction["sourceComponentId"] as? String)?
|
let sourceComponentId = (userAction["sourceComponentId"] as? String)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
|
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
|
||||||
let host = Self.sanitizeTagValue(InstanceIdentity.displayName)
|
|
||||||
let instanceId = InstanceIdentity.instanceId.lowercased()
|
let instanceId = InstanceIdentity.instanceId.lowercased()
|
||||||
let contextJSON = Self.compactJSON(userAction["context"])
|
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
|
||||||
let contextSuffix = contextJSON.flatMap { $0.isEmpty ? nil : " ctx=\($0)" } ?? ""
|
|
||||||
|
|
||||||
// Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas.
|
// Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas.
|
||||||
let text =
|
let text = ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||||
[
|
actionName: name,
|
||||||
"CANVAS_A2UI",
|
sessionKey: self.sessionKey,
|
||||||
"action=\(Self.sanitizeTagValue(name))",
|
surfaceId: surfaceId,
|
||||||
"session=\(Self.sanitizeTagValue(self.sessionKey))",
|
sourceComponentId: sourceComponentId,
|
||||||
"surface=\(Self.sanitizeTagValue(surfaceId))",
|
host: InstanceIdentity.displayName,
|
||||||
"component=\(Self.sanitizeTagValue(sourceComponentId))",
|
instanceId: instanceId,
|
||||||
"host=\(host)",
|
contextJSON: contextJSON)
|
||||||
"instance=\(instanceId)\(contextSuffix)",
|
|
||||||
"default=update_canvas",
|
|
||||||
].joined(separator: " ")
|
|
||||||
|
|
||||||
Task { [weak webView] in
|
Task { [weak webView] in
|
||||||
if AppStateStore.shared.connectionMode == .local {
|
if AppStateStore.shared.connectionMode == .local {
|
||||||
@@ -680,7 +677,7 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
|
|||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
guard let webView else { return }
|
guard let webView else { return }
|
||||||
let js = Self.jsDispatchA2UIActionStatus(actionId: actionId, ok: result.ok, error: result.error)
|
let js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId: actionId, ok: result.ok, error: result.error)
|
||||||
webView.evaluateJavaScript(js) { _, _ in }
|
webView.evaluateJavaScript(js) { _, _ in }
|
||||||
}
|
}
|
||||||
if !result.ok {
|
if !result.ok {
|
||||||
@@ -690,39 +687,7 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func sanitizeTagValue(_ value: String) -> String {
|
// Formatting helpers live in ClawdisKit (`ClawdisCanvasA2UIAction`).
|
||||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
|
|
||||||
let normalized = trimmed.replacingOccurrences(of: " ", with: "_")
|
|
||||||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.:")
|
|
||||||
let scalars = normalized.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
|
|
||||||
return String(scalars)
|
|
||||||
}
|
|
||||||
|
|
||||||
private 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
|
|
||||||
}
|
|
||||||
|
|
||||||
private 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) }));"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hover chrome container
|
// MARK: - Hover chrome container
|
||||||
|
|||||||
@@ -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
|
self.jsonl = jsonl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,4 +73,3 @@ public enum ClawdisCanvasA2UIJSONL: Sendable {
|
|||||||
return json
|
return json
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,4 +3,3 @@ import Foundation
|
|||||||
public enum ClawdisKitResources {
|
public enum ClawdisKitResources {
|
||||||
public static let bundle: Bundle = .module
|
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; }
|
:root { color-scheme: light dark; }
|
||||||
html, body { height: 100%; margin: 0; }
|
html, body { height: 100%; margin: 0; }
|
||||||
body {
|
body {
|
||||||
font: 13px -apple-system, system-ui;
|
font: 14px system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
|
||||||
background: #0b1020;
|
background: #0b1020;
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
overflow: hidden;
|
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 emptyClasses = () => ({});
|
||||||
const textHintStyles = () => ({ h1: {}, h2: {}, h3: {}, h4: {}, h5: {}, body: {}, caption: {} });
|
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 = {
|
const clawdisTheme = {
|
||||||
components: {
|
components: {
|
||||||
AudioPlayer: emptyClasses(),
|
AudioPlayer: emptyClasses(),
|
||||||
@@ -85,7 +91,7 @@ const clawdisTheme = {
|
|||||||
border: "1px solid rgba(255,255,255,.09)",
|
border: "1px solid rgba(255,255,255,.09)",
|
||||||
borderRadius: "14px",
|
borderRadius: "14px",
|
||||||
padding: "14px",
|
padding: "14px",
|
||||||
boxShadow: "0 10px 30px rgba(0,0,0,.35)",
|
boxShadow: cardShadow,
|
||||||
},
|
},
|
||||||
Column: { gap: "10px" },
|
Column: { gap: "10px" },
|
||||||
Row: { gap: "10px", alignItems: "center" },
|
Row: { gap: "10px", alignItems: "center" },
|
||||||
@@ -98,7 +104,7 @@ const clawdisTheme = {
|
|||||||
color: "#071016",
|
color: "#071016",
|
||||||
fontWeight: "650",
|
fontWeight: "650",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
boxShadow: "0 10px 25px rgba(6, 182, 212, 0.18)",
|
boxShadow: buttonShadow,
|
||||||
},
|
},
|
||||||
Text: {
|
Text: {
|
||||||
...textHintStyles(),
|
...textHintStyles(),
|
||||||
@@ -161,11 +167,11 @@ class ClawdisA2UIHost extends LitElement {
|
|||||||
background: rgba(0, 0, 0, 0.45);
|
background: rgba(0, 0, 0, 0.45);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
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;
|
pointer-events: none;
|
||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(${statusBlur});
|
||||||
-webkit-backdrop-filter: blur(14px);
|
-webkit-backdrop-filter: blur(${statusBlur});
|
||||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
|
box-shadow: ${statusShadow};
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,11 +188,11 @@ class ClawdisA2UIHost extends LitElement {
|
|||||||
background: rgba(0, 0, 0, 0.45);
|
background: rgba(0, 0, 0, 0.45);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
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;
|
pointer-events: none;
|
||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(${statusBlur});
|
||||||
-webkit-backdrop-filter: blur(14px);
|
-webkit-backdrop-filter: blur(${statusBlur});
|
||||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
|
box-shadow: ${statusShadow};
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,10 +341,17 @@ class ClawdisA2UIHost extends LitElement {
|
|||||||
|
|
||||||
globalThis.__clawdisLastA2UIAction = userAction;
|
globalThis.__clawdisLastA2UIAction = userAction;
|
||||||
|
|
||||||
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction;
|
const handler =
|
||||||
|
globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction ??
|
||||||
|
globalThis.clawdisCanvasA2UIAction;
|
||||||
if (handler?.postMessage) {
|
if (handler?.postMessage) {
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
const msg = String(e?.message ?? e);
|
const msg = String(e?.message ?? e);
|
||||||
this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: msg };
|
this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: msg };
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { defineConfig } from "rolldown";
|
import { defineConfig } from "rolldown";
|
||||||
|
|
||||||
const here = path.dirname(new URL(import.meta.url).pathname);
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.resolve(here, "../../../../../..");
|
const repoRoot = path.resolve(here, "../../../../..");
|
||||||
const fromHere = (p) => path.resolve(here, p);
|
const fromHere = (p) => path.resolve(here, p);
|
||||||
const outputFile = path.resolve(here, "../../Sources/ClawdisKit/Resources/CanvasA2UI/a2ui.bundle.js");
|
const outputFile = path.resolve(here, "../../Sources/ClawdisKit/Resources/CanvasA2UI/a2ui.bundle.js");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user