feat(macos): add Canvas A2UI renderer
This commit is contained in:
@@ -51,6 +51,7 @@ let package = Package(
|
||||
resources: [
|
||||
.copy("Resources/Clawdis.icns"),
|
||||
.copy("Resources/WebChat"),
|
||||
.copy("Resources/CanvasA2UI"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
|
||||
@@ -28,20 +28,17 @@ final class CanvasManager {
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.nonEmpty
|
||||
|
||||
let isWebTarget = Self.isWebTarget(normalizedTarget)
|
||||
|
||||
if let controller = self.panelController, self.panelSessionKey == session {
|
||||
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
|
||||
controller.applyPreferredPlacement(placement)
|
||||
|
||||
// Existing session: only navigate when an explicit target was provided.
|
||||
if let normalizedTarget {
|
||||
controller.goto(path: normalizedTarget)
|
||||
controller.load(target: normalizedTarget)
|
||||
return self.makeShowResult(
|
||||
directory: controller.directoryPath,
|
||||
target: target,
|
||||
effectiveTarget: normalizedTarget,
|
||||
isWebTarget: isWebTarget)
|
||||
effectiveTarget: normalizedTarget)
|
||||
}
|
||||
|
||||
return CanvasShowResult(
|
||||
@@ -72,8 +69,7 @@ final class CanvasManager {
|
||||
return self.makeShowResult(
|
||||
directory: controller.directoryPath,
|
||||
target: target,
|
||||
effectiveTarget: effectiveTarget,
|
||||
isWebTarget: isWebTarget)
|
||||
effectiveTarget: effectiveTarget)
|
||||
}
|
||||
|
||||
func hide(sessionKey: String) {
|
||||
@@ -111,18 +107,29 @@ final class CanvasManager {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func isWebTarget(_ target: String?) -> Bool {
|
||||
guard let target, let url = URL(string: target), let scheme = url.scheme?.lowercased() else { return false }
|
||||
return scheme == "https" || scheme == "http"
|
||||
private static func directURL(for target: String?) -> URL? {
|
||||
guard let target else { return nil }
|
||||
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
|
||||
if scheme == "https" || scheme == "http" || scheme == "file" { return url }
|
||||
}
|
||||
|
||||
// Convenience: existing absolute paths resolve as local files.
|
||||
if trimmed.hasPrefix("/"), FileManager.default.fileExists(atPath: trimmed) {
|
||||
return URL(fileURLWithPath: trimmed)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func makeShowResult(
|
||||
directory: String,
|
||||
target: String?,
|
||||
effectiveTarget: String,
|
||||
isWebTarget: Bool) -> CanvasShowResult
|
||||
effectiveTarget: String) -> CanvasShowResult
|
||||
{
|
||||
if isWebTarget, let url = URL(string: effectiveTarget) {
|
||||
if let url = Self.directURL(for: effectiveTarget) {
|
||||
return CanvasShowResult(
|
||||
directory: directory,
|
||||
target: target,
|
||||
@@ -151,12 +158,12 @@ final class CanvasManager {
|
||||
if path.hasPrefix("/") { path.removeFirst() }
|
||||
path = path.removingPercentEncoding ?? path
|
||||
|
||||
// Root special-case: welcome page when no index exists.
|
||||
// Root special-case: built-in shell page when no index exists.
|
||||
if path.isEmpty {
|
||||
let a = sessionDir.appendingPathComponent("index.html", isDirectory: false)
|
||||
let b = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
|
||||
if fm.fileExists(atPath: a.path) || fm.fileExists(atPath: b.path) { return .ok }
|
||||
return .welcome
|
||||
return Self.hasBundledA2UIShell() ? .a2uiShell : .welcome
|
||||
}
|
||||
|
||||
// Direct file or directory.
|
||||
@@ -187,6 +194,14 @@ final class CanvasManager {
|
||||
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
|
||||
return fm.fileExists(atPath: b.path)
|
||||
}
|
||||
|
||||
private static func hasBundledA2UIShell() -> Bool {
|
||||
guard let base = Bundle.module.resourceURL?.appendingPathComponent("CanvasA2UI", isDirectory: true) else {
|
||||
return false
|
||||
}
|
||||
let index = base.appendingPathComponent("index.html", isDirectory: false)
|
||||
return FileManager.default.fileExists(atPath: index.path)
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
|
||||
@@ -7,6 +7,8 @@ private let canvasLogger = Logger(subsystem: "com.steipete.clawdis", category: "
|
||||
final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
private let root: URL
|
||||
|
||||
private static let builtinPrefix = "__clawdis__/a2ui"
|
||||
|
||||
init(root: URL) {
|
||||
self.root = root
|
||||
}
|
||||
@@ -64,6 +66,10 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
if path.hasPrefix("/") { path.removeFirst() }
|
||||
path = path.removingPercentEncoding ?? path
|
||||
|
||||
if let builtin = self.builtinResponse(requestPath: path) {
|
||||
return builtin
|
||||
}
|
||||
|
||||
// Special-case: welcome page when root index is missing.
|
||||
if path.isEmpty {
|
||||
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
|
||||
@@ -71,7 +77,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
if !FileManager.default.fileExists(atPath: indexA.path),
|
||||
!FileManager.default.fileExists(atPath: indexB.path)
|
||||
{
|
||||
return self.welcomePage(sessionRoot: sessionRoot)
|
||||
return self.a2uiShellPage(sessionRoot: sessionRoot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +203,54 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
return self.html(body, title: "Canvas")
|
||||
}
|
||||
|
||||
private func a2uiShellPage(sessionRoot: URL) -> CanvasResponse {
|
||||
// Default Canvas UX: when no index exists, show the built-in A2UI shell.
|
||||
if let data = self.loadBundledResourceData(subdirectory: "CanvasA2UI", relativePath: "index.html") {
|
||||
return CanvasResponse(mime: "text/html", data: data)
|
||||
}
|
||||
|
||||
// Fallback for dev misconfiguration: show the classic welcome page.
|
||||
return self.welcomePage(sessionRoot: sessionRoot)
|
||||
}
|
||||
|
||||
private func builtinResponse(requestPath: String) -> CanvasResponse? {
|
||||
let trimmed = requestPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
guard trimmed == Self.builtinPrefix
|
||||
|| trimmed == Self.builtinPrefix + "/"
|
||||
|| trimmed.hasPrefix(Self.builtinPrefix + "/")
|
||||
else { return nil }
|
||||
|
||||
let relative: String
|
||||
if trimmed == Self.builtinPrefix || trimmed == Self.builtinPrefix + "/" {
|
||||
relative = "index.html"
|
||||
} else {
|
||||
relative = String(trimmed.dropFirst((Self.builtinPrefix + "/").count))
|
||||
}
|
||||
|
||||
if relative.isEmpty { return self.html("Not Found", title: "Canvas: 404") }
|
||||
if relative.contains("..") || relative.contains("\\") {
|
||||
return self.html("Forbidden", title: "Canvas: 403")
|
||||
}
|
||||
|
||||
guard let data = self.loadBundledResourceData(subdirectory: "CanvasA2UI", relativePath: relative) else {
|
||||
return self.html("Not Found", title: "Canvas: 404")
|
||||
}
|
||||
|
||||
let ext = (relative as NSString).pathExtension
|
||||
let mime = CanvasScheme.mimeType(forExtension: ext)
|
||||
return CanvasResponse(mime: mime, data: data)
|
||||
}
|
||||
|
||||
private func loadBundledResourceData(subdirectory: String, relativePath: String) -> Data? {
|
||||
guard let base = Bundle.module.resourceURL?.appendingPathComponent(subdirectory, isDirectory: true) else {
|
||||
return nil
|
||||
}
|
||||
let url = base.appendingPathComponent(relativePath, isDirectory: false)
|
||||
return try? Data(contentsOf: url)
|
||||
}
|
||||
|
||||
private func textEncodingName(forMimeType mimeType: String) -> String? {
|
||||
if mimeType.hasPrefix("text/") { return "utf-8" }
|
||||
switch mimeType {
|
||||
|
||||
@@ -100,7 +100,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
if case let .panel(anchorProvider) = self.presentation {
|
||||
self.presentAnchoredPanel(anchorProvider: anchorProvider)
|
||||
if let path {
|
||||
self.goto(path: path)
|
||||
self.load(target: path)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -109,7 +109,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
self.window?.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if let path {
|
||||
self.goto(path: path)
|
||||
self.load(target: path)
|
||||
}
|
||||
self.onVisibilityChanged?(true)
|
||||
}
|
||||
@@ -124,14 +124,27 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
self.onVisibilityChanged?(false)
|
||||
}
|
||||
|
||||
func goto(path: String) {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
func load(target: String) {
|
||||
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased(),
|
||||
scheme == "https" || scheme == "http"
|
||||
{
|
||||
canvasWindowLogger.debug("canvas goto web \(url.absoluteString, privacy: .public)")
|
||||
self.webView.load(URLRequest(url: url))
|
||||
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
|
||||
if scheme == "https" || scheme == "http" {
|
||||
canvasWindowLogger.debug("canvas load url \(url.absoluteString, privacy: .public)")
|
||||
self.webView.load(URLRequest(url: url))
|
||||
return
|
||||
}
|
||||
if scheme == "file" {
|
||||
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
|
||||
self.loadFile(url)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience: absolute paths resolve as local files when they exist.
|
||||
if trimmed.hasPrefix("/"), FileManager.default.fileExists(atPath: trimmed) {
|
||||
let url = URL(fileURLWithPath: trimmed)
|
||||
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
|
||||
self.loadFile(url)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -144,10 +157,16 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
"invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)")
|
||||
return
|
||||
}
|
||||
canvasWindowLogger.debug("canvas goto canvas \(url.absoluteString, privacy: .public)")
|
||||
canvasWindowLogger.debug("canvas load canvas \(url.absoluteString, privacy: .public)")
|
||||
self.webView.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
private func loadFile(_ url: URL) {
|
||||
let fileURL = url.isFileURL ? url : URL(fileURLWithPath: url.path)
|
||||
let accessDir = fileURL.deletingLastPathComponent()
|
||||
self.webView.loadFileURL(fileURL, allowingReadAccessTo: accessDir)
|
||||
}
|
||||
|
||||
func eval(javaScript: String) async -> String {
|
||||
await withCheckedContinuation { cont in
|
||||
self.webView.evaluateJavaScript(javaScript) { result, error in
|
||||
|
||||
@@ -67,6 +67,9 @@ enum ControlRequestHandler {
|
||||
case let .canvasSnapshot(session, outPath):
|
||||
return await self.handleCanvasSnapshot(session: session, outPath: outPath)
|
||||
|
||||
case let .canvasA2UI(session, command, jsonl):
|
||||
return await self.handleCanvasA2UI(session: session, command: command, jsonl: jsonl)
|
||||
|
||||
case .nodeList:
|
||||
return await self.handleNodeList()
|
||||
|
||||
@@ -228,6 +231,86 @@ enum ControlRequestHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleCanvasA2UI(session: String, command: CanvasA2UICommand, jsonl: String?) async -> Response {
|
||||
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
|
||||
do {
|
||||
// Ensure the Canvas is visible and the default page is loaded.
|
||||
_ = try await MainActor.run {
|
||||
try CanvasManager.shared.show(sessionKey: session, path: "/")
|
||||
}
|
||||
|
||||
let ready = await Self.waitForCanvasA2UI(session: session, timeoutMs: 2_000)
|
||||
guard ready else { return Response(ok: false, message: "A2UI not ready") }
|
||||
|
||||
let js: String
|
||||
switch command {
|
||||
case .reset:
|
||||
js = """
|
||||
(() => {
|
||||
if (!globalThis.clawdisA2UI) { return "missing clawdisA2UI"; }
|
||||
globalThis.clawdisA2UI.reset();
|
||||
return "ok";
|
||||
})()
|
||||
"""
|
||||
|
||||
case .pushJSONL:
|
||||
guard let jsonl, !jsonl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return Response(ok: false, message: "missing jsonl")
|
||||
}
|
||||
let messages: [Any]
|
||||
do {
|
||||
messages = try Self.parseJSONL(jsonl)
|
||||
} catch {
|
||||
return Response(ok: false, message: "invalid jsonl: \(error.localizedDescription)")
|
||||
}
|
||||
let data = try JSONSerialization.data(withJSONObject: messages, options: [])
|
||||
let json = String(data: data, encoding: .utf8) ?? "[]"
|
||||
js = """
|
||||
(() => {
|
||||
if (!globalThis.clawdisA2UI) { return "missing clawdisA2UI"; }
|
||||
const messages = \(json);
|
||||
globalThis.clawdisA2UI.applyMessages(messages);
|
||||
return "ok";
|
||||
})()
|
||||
"""
|
||||
}
|
||||
|
||||
let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: js)
|
||||
return Response(ok: true, payload: Data(result.utf8))
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseJSONL(_ text: String) throws -> [Any] {
|
||||
var out: [Any] = []
|
||||
for rawLine in text.split(whereSeparator: \.isNewline) {
|
||||
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if line.isEmpty { continue }
|
||||
let data = Data(line.utf8)
|
||||
let obj = try JSONSerialization.jsonObject(with: data, options: [])
|
||||
out.append(obj)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private static func waitForCanvasA2UI(session: String, timeoutMs: Int) async -> Bool {
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: .milliseconds(timeoutMs))
|
||||
while clock.now < deadline {
|
||||
do {
|
||||
let res = try await CanvasManager.shared.eval(
|
||||
sessionKey: session,
|
||||
javaScript: "(() => globalThis.clawdisA2UI ? 'ready' : '')()")
|
||||
if res == "ready" { return true }
|
||||
} catch {
|
||||
// Ignore; keep waiting.
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 60_000_000)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func handleNodeList() async -> Response {
|
||||
let ids = await BridgeServer.shared.connectedNodeIds()
|
||||
let payload = (try? JSONSerialization.data(
|
||||
|
||||
17798
apps/macos/Sources/Clawdis/Resources/CanvasA2UI/a2ui.bundle.js
Normal file
17798
apps/macos/Sources/Clawdis/Resources/CanvasA2UI/a2ui.bundle.js
Normal file
File diff suppressed because one or more lines are too long
197
apps/macos/Sources/Clawdis/Resources/CanvasA2UI/bootstrap.js
vendored
Normal file
197
apps/macos/Sources/Clawdis/Resources/CanvasA2UI/bootstrap.js
vendored
Normal file
@@ -0,0 +1,197 @@
|
||||
import { html, css, LitElement } from "lit";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { ContextProvider } from "@lit/context";
|
||||
|
||||
import { v0_8 } from "@a2ui/lit";
|
||||
import "@a2ui/lit/ui";
|
||||
import { themeContext } from "@clawdis/a2ui-theme-context";
|
||||
|
||||
const empty = Object.freeze({});
|
||||
const emptyClasses = () => ({});
|
||||
const textHintStyles = () => ({ h1: {}, h2: {}, h3: {}, h4: {}, h5: {}, body: {}, caption: {} });
|
||||
|
||||
const clawdisTheme = {
|
||||
components: {
|
||||
AudioPlayer: emptyClasses(),
|
||||
Button: emptyClasses(),
|
||||
Card: emptyClasses(),
|
||||
Column: emptyClasses(),
|
||||
CheckBox: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
|
||||
DateTimeInput: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
|
||||
Divider: emptyClasses(),
|
||||
Image: {
|
||||
all: emptyClasses(),
|
||||
icon: emptyClasses(),
|
||||
avatar: emptyClasses(),
|
||||
smallFeature: emptyClasses(),
|
||||
mediumFeature: emptyClasses(),
|
||||
largeFeature: emptyClasses(),
|
||||
header: emptyClasses(),
|
||||
},
|
||||
Icon: emptyClasses(),
|
||||
List: emptyClasses(),
|
||||
Modal: { backdrop: emptyClasses(), element: emptyClasses() },
|
||||
MultipleChoice: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
|
||||
Row: emptyClasses(),
|
||||
Slider: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
|
||||
Tabs: { container: emptyClasses(), element: emptyClasses(), controls: { all: emptyClasses(), selected: emptyClasses() } },
|
||||
Text: {
|
||||
all: emptyClasses(),
|
||||
h1: emptyClasses(),
|
||||
h2: emptyClasses(),
|
||||
h3: emptyClasses(),
|
||||
h4: emptyClasses(),
|
||||
h5: emptyClasses(),
|
||||
caption: emptyClasses(),
|
||||
body: emptyClasses(),
|
||||
},
|
||||
TextField: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
|
||||
Video: emptyClasses(),
|
||||
},
|
||||
elements: {
|
||||
a: emptyClasses(),
|
||||
audio: emptyClasses(),
|
||||
body: emptyClasses(),
|
||||
button: emptyClasses(),
|
||||
h1: emptyClasses(),
|
||||
h2: emptyClasses(),
|
||||
h3: emptyClasses(),
|
||||
h4: emptyClasses(),
|
||||
h5: emptyClasses(),
|
||||
iframe: emptyClasses(),
|
||||
input: emptyClasses(),
|
||||
p: emptyClasses(),
|
||||
pre: emptyClasses(),
|
||||
textarea: emptyClasses(),
|
||||
video: emptyClasses(),
|
||||
},
|
||||
markdown: {
|
||||
p: [],
|
||||
h1: [],
|
||||
h2: [],
|
||||
h3: [],
|
||||
h4: [],
|
||||
h5: [],
|
||||
ul: [],
|
||||
ol: [],
|
||||
li: [],
|
||||
a: [],
|
||||
strong: [],
|
||||
em: [],
|
||||
},
|
||||
additionalStyles: {
|
||||
Card: {
|
||||
background: "linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03))",
|
||||
border: "1px solid rgba(255,255,255,.09)",
|
||||
borderRadius: "14px",
|
||||
padding: "14px",
|
||||
boxShadow: "0 10px 30px rgba(0,0,0,.35)",
|
||||
},
|
||||
Column: { gap: "10px" },
|
||||
Row: { gap: "10px", alignItems: "center" },
|
||||
Divider: { opacity: "0.25" },
|
||||
Button: {
|
||||
background: "linear-gradient(135deg, #22c55e 0%, #06b6d4 100%)",
|
||||
border: "0",
|
||||
borderRadius: "12px",
|
||||
padding: "10px 14px",
|
||||
color: "#071016",
|
||||
fontWeight: "650",
|
||||
cursor: "pointer",
|
||||
boxShadow: "0 10px 25px rgba(6, 182, 212, 0.18)",
|
||||
},
|
||||
Text: {
|
||||
...textHintStyles(),
|
||||
h1: { fontSize: "20px", fontWeight: "750", margin: "0 0 6px 0" },
|
||||
h2: { fontSize: "16px", fontWeight: "700", margin: "0 0 6px 0" },
|
||||
body: { fontSize: "13px", lineHeight: "1.4" },
|
||||
caption: { opacity: "0.8" },
|
||||
},
|
||||
TextField: { display: "grid", gap: "6px" },
|
||||
Image: { borderRadius: "12px" },
|
||||
},
|
||||
};
|
||||
|
||||
class ClawdisA2UIHost extends LitElement {
|
||||
static properties = {
|
||||
surfaces: { state: true },
|
||||
};
|
||||
|
||||
#processor = v0_8.Data.createSignalA2uiMessageProcessor();
|
||||
#themeProvider = new ContextProvider(this, {
|
||||
context: themeContext,
|
||||
initialValue: clawdisTheme,
|
||||
});
|
||||
|
||||
surfaces = [];
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
#surfaces {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
globalThis.clawdisA2UI = {
|
||||
applyMessages: (messages) => this.applyMessages(messages),
|
||||
reset: () => this.reset(),
|
||||
getSurfaces: () => Array.from(this.#processor.getSurfaces().keys()),
|
||||
};
|
||||
this.#syncSurfaces();
|
||||
}
|
||||
|
||||
applyMessages(messages) {
|
||||
if (!Array.isArray(messages)) {
|
||||
throw new Error("A2UI: expected messages array");
|
||||
}
|
||||
this.#processor.processMessages(messages);
|
||||
this.#syncSurfaces();
|
||||
return { ok: true, surfaces: this.surfaces.map(([id]) => id) };
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.#processor.clearSurfaces();
|
||||
this.#syncSurfaces();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
#syncSurfaces() {
|
||||
this.surfaces = Array.from(this.#processor.getSurfaces().entries());
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.surfaces.length === 0) {
|
||||
return html`<div style="opacity:.8; padding: 10px;">
|
||||
<div style="font-weight: 700; margin-bottom: 6px;">Canvas (A2UI)</div>
|
||||
<div>Waiting for A2UI messages…</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`<section id="surfaces">
|
||||
${repeat(
|
||||
this.surfaces,
|
||||
([surfaceId]) => surfaceId,
|
||||
([surfaceId, surface]) => html`<a2ui-surface
|
||||
.surfaceId=${surfaceId}
|
||||
.surface=${surface}
|
||||
.processor=${this.#processor}
|
||||
></a2ui-surface>`
|
||||
)}
|
||||
</section>`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("clawdis-a2ui-host", ClawdisA2UIHost);
|
||||
24
apps/macos/Sources/Clawdis/Resources/CanvasA2UI/index.html
Normal file
24
apps/macos/Sources/Clawdis/Resources/CanvasA2UI/index.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!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 type="module" src="/__clawdis__/a2ui/a2ui.bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "rolldown";
|
||||
|
||||
const here = path.dirname(new URL(import.meta.url).pathname);
|
||||
const repoRoot = path.resolve(here, "../../../../../..");
|
||||
const fromHere = (p) => path.resolve(here, p);
|
||||
|
||||
const a2uiLitDist = path.resolve(repoRoot, "vendor/a2ui/renderers/lit/dist/src");
|
||||
const a2uiThemeContext = path.resolve(a2uiLitDist, "0.8/ui/context/theme.js");
|
||||
|
||||
export default defineConfig({
|
||||
input: fromHere("bootstrap.js"),
|
||||
treeshake: false,
|
||||
resolve: {
|
||||
alias: {
|
||||
"@a2ui/lit": path.resolve(a2uiLitDist, "index.js"),
|
||||
"@a2ui/lit/ui": path.resolve(a2uiLitDist, "0.8/ui/ui.js"),
|
||||
"@clawdis/a2ui-theme-context": a2uiThemeContext,
|
||||
"@lit/context": path.resolve(repoRoot, "node_modules/@lit/context/index.js"),
|
||||
"@lit/context/": path.resolve(repoRoot, "node_modules/@lit/context/"),
|
||||
"@lit-labs/signals": path.resolve(repoRoot, "node_modules/@lit-labs/signals/index.js"),
|
||||
"@lit-labs/signals/": path.resolve(repoRoot, "node_modules/@lit-labs/signals/"),
|
||||
lit: path.resolve(repoRoot, "node_modules/lit/index.js"),
|
||||
"lit/": path.resolve(repoRoot, "node_modules/lit/"),
|
||||
},
|
||||
},
|
||||
output: {
|
||||
file: fromHere("a2ui.bundle.js"),
|
||||
format: "esm",
|
||||
inlineDynamicImports: true,
|
||||
sourcemap: false,
|
||||
},
|
||||
});
|
||||
@@ -248,6 +248,8 @@ struct ClawdisCLI {
|
||||
return ParsedCLIRequest(
|
||||
request: .canvasShow(session: session, path: target, placement: placement),
|
||||
kind: .generic)
|
||||
case "a2ui":
|
||||
return try self.parseCanvasA2UI(args: &args)
|
||||
case "hide":
|
||||
var session = "main"
|
||||
while !args.isEmpty {
|
||||
@@ -288,6 +290,44 @@ struct ClawdisCLI {
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseCanvasA2UI(args: inout [String]) throws -> ParsedCLIRequest {
|
||||
guard let sub = args.popFirst() else { throw CLIError.help }
|
||||
switch sub {
|
||||
case "push":
|
||||
var session = "main"
|
||||
var jsonlPath: String?
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--session": session = args.popFirst() ?? session
|
||||
case "--jsonl": jsonlPath = args.popFirst()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
guard let jsonlPath else { throw CLIError.help }
|
||||
let jsonl = try String(contentsOfFile: jsonlPath, encoding: .utf8)
|
||||
return ParsedCLIRequest(
|
||||
request: .canvasA2UI(session: session, command: .pushJSONL, jsonl: jsonl),
|
||||
kind: .generic)
|
||||
|
||||
case "reset":
|
||||
var session = "main"
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--session": session = args.popFirst() ?? session
|
||||
default: break
|
||||
}
|
||||
}
|
||||
return ParsedCLIRequest(
|
||||
request: .canvasA2UI(session: session, command: .reset, jsonl: nil),
|
||||
kind: .generic)
|
||||
|
||||
default:
|
||||
throw CLIError.help
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseCamera(args: inout [String]) throws -> ParsedCLIRequest {
|
||||
guard let sub = args.popFirst() else { throw CLIError.help }
|
||||
switch sub {
|
||||
@@ -473,8 +513,10 @@ struct ClawdisCLI {
|
||||
clawdis-mac node invoke --node <id> --command <name> [--params-json <json>]
|
||||
|
||||
Canvas:
|
||||
clawdis-mac canvas show [--session <key>] [--target </...|https://...>]
|
||||
clawdis-mac canvas show [--session <key>] [--target </...|https://...|file://...>]
|
||||
[--x <screenX> --y <screenY>] [--width <w> --height <h>]
|
||||
clawdis-mac canvas a2ui push --jsonl <path> [--session <key>]
|
||||
clawdis-mac canvas a2ui reset [--session <key>]
|
||||
clawdis-mac canvas hide [--session <key>]
|
||||
clawdis-mac canvas eval --js <code> [--session <key>]
|
||||
clawdis-mac canvas snapshot [--out <path>] [--session <key>]
|
||||
|
||||
@@ -60,13 +60,15 @@ public struct CanvasPlacement: Codable, Sendable {
|
||||
public enum CanvasShowStatus: String, Codable, Sendable {
|
||||
/// Panel was shown, but no navigation occurred (no target passed and session already existed).
|
||||
case shown
|
||||
/// Target was an http(s) URL.
|
||||
/// Target was a direct URL (http(s) or file).
|
||||
case web
|
||||
/// Local canvas target resolved to an existing file.
|
||||
case ok
|
||||
/// Local canvas target did not resolve to a file (404 page).
|
||||
case notFound
|
||||
/// Local canvas root ("/") has no index, so the welcome page is shown.
|
||||
/// Local canvas root ("/") has no index, so the built-in A2UI shell is shown.
|
||||
case a2uiShell
|
||||
/// Legacy fallback when the built-in shell isn't available (dev misconfiguration).
|
||||
case welcome
|
||||
}
|
||||
|
||||
@@ -96,6 +98,13 @@ public struct CanvasShowResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Canvas A2UI
|
||||
|
||||
public enum CanvasA2UICommand: String, Codable, Sendable {
|
||||
case pushJSONL
|
||||
case reset
|
||||
}
|
||||
|
||||
public enum Request: Sendable {
|
||||
case notify(
|
||||
title: String,
|
||||
@@ -117,6 +126,7 @@ public enum Request: Sendable {
|
||||
case canvasHide(session: String)
|
||||
case canvasEval(session: String, javaScript: String)
|
||||
case canvasSnapshot(session: String, outPath: String?)
|
||||
case canvasA2UI(session: String, command: CanvasA2UICommand, jsonl: String?)
|
||||
case nodeList
|
||||
case nodeInvoke(nodeId: String, command: String, paramsJSON: String?)
|
||||
case cameraSnap(facing: CameraFacing?, maxWidth: Int?, quality: Double?, outPath: String?)
|
||||
@@ -151,6 +161,8 @@ extension Request: Codable {
|
||||
case path
|
||||
case javaScript
|
||||
case outPath
|
||||
case canvasA2UICommand
|
||||
case jsonl
|
||||
case facing
|
||||
case maxWidth
|
||||
case quality
|
||||
@@ -173,6 +185,7 @@ extension Request: Codable {
|
||||
case canvasHide
|
||||
case canvasEval
|
||||
case canvasSnapshot
|
||||
case canvasA2UI
|
||||
case nodeList
|
||||
case nodeInvoke
|
||||
case cameraSnap
|
||||
@@ -237,6 +250,12 @@ extension Request: Codable {
|
||||
try container.encode(session, forKey: .session)
|
||||
try container.encodeIfPresent(outPath, forKey: .outPath)
|
||||
|
||||
case let .canvasA2UI(session, command, jsonl):
|
||||
try container.encode(Kind.canvasA2UI, forKey: .type)
|
||||
try container.encode(session, forKey: .session)
|
||||
try container.encode(command, forKey: .canvasA2UICommand)
|
||||
try container.encodeIfPresent(jsonl, forKey: .jsonl)
|
||||
|
||||
case .nodeList:
|
||||
try container.encode(Kind.nodeList, forKey: .type)
|
||||
|
||||
@@ -321,6 +340,12 @@ extension Request: Codable {
|
||||
let outPath = try container.decodeIfPresent(String.self, forKey: .outPath)
|
||||
self = .canvasSnapshot(session: session, outPath: outPath)
|
||||
|
||||
case .canvasA2UI:
|
||||
let session = try container.decode(String.self, forKey: .session)
|
||||
let command = try container.decode(CanvasA2UICommand.self, forKey: .canvasA2UICommand)
|
||||
let jsonl = try container.decodeIfPresent(String.self, forKey: .jsonl)
|
||||
self = .canvasA2UI(session: session, command: command, jsonl: jsonl)
|
||||
|
||||
case .nodeList:
|
||||
self = .nodeList
|
||||
|
||||
|
||||
@@ -161,5 +161,13 @@ struct ControlRequestHandlerTests {
|
||||
}
|
||||
#expect(snap.ok == false)
|
||||
#expect(snap.message == "Canvas disabled by user")
|
||||
|
||||
let a2ui = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) {
|
||||
try await Self.withDefaultOverride(canvasEnabledKey, value: false) {
|
||||
try await ControlRequestHandler.process(request: .canvasA2UI(session: "s", command: .reset, jsonl: nil))
|
||||
}
|
||||
}
|
||||
#expect(a2ui.ok == false)
|
||||
#expect(a2ui.message == "Canvas disabled by user")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user