chore: rename project to clawdbot

This commit is contained in:
Peter Steinberger
2026-01-04 14:32:47 +00:00
parent d48dc71fa4
commit 246adaa119
841 changed files with 4590 additions and 4328 deletions

View File

@@ -0,0 +1,43 @@
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "ClawdbotKit",
platforms: [
.iOS(.v17),
.macOS(.v15),
],
products: [
.library(name: "ClawdbotKit", targets: ["ClawdbotKit"]),
.library(name: "ClawdbotChatUI", targets: ["ClawdbotChatUI"]),
],
dependencies: [
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
],
targets: [
.target(
name: "ClawdbotKit",
dependencies: [
.product(name: "ElevenLabsKit", package: "ElevenLabsKit"),
],
resources: [
.process("Resources"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.target(
name: "ClawdbotChatUI",
dependencies: ["ClawdbotKit"],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.testTarget(
name: "ClawdbotKitTests",
dependencies: ["ClawdbotKit", "ClawdbotChatUI"],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
])

View File

@@ -0,0 +1,197 @@
{
"version": 1,
"fallback": {
"emoji": "🧩",
"detailKeys": [
"command",
"path",
"url",
"targetUrl",
"targetId",
"ref",
"element",
"node",
"nodeId",
"jobId",
"requestId",
"to",
"channelId",
"guildId",
"userId",
"name",
"query",
"pattern",
"messageId"
]
},
"tools": {
"bash": {
"emoji": "🛠️",
"title": "Bash",
"detailKeys": ["command"]
},
"process": {
"emoji": "🧰",
"title": "Process",
"detailKeys": ["sessionId"]
},
"read": {
"emoji": "📖",
"title": "Read",
"detailKeys": ["path"]
},
"write": {
"emoji": "✍️",
"title": "Write",
"detailKeys": ["path"]
},
"edit": {
"emoji": "📝",
"title": "Edit",
"detailKeys": ["path"]
},
"attach": {
"emoji": "📎",
"title": "Attach",
"detailKeys": ["path", "url", "fileName"]
},
"browser": {
"emoji": "🌐",
"title": "Browser",
"actions": {
"status": { "label": "status" },
"start": { "label": "start" },
"stop": { "label": "stop" },
"tabs": { "label": "tabs" },
"open": { "label": "open", "detailKeys": ["targetUrl"] },
"focus": { "label": "focus", "detailKeys": ["targetId"] },
"close": { "label": "close", "detailKeys": ["targetId"] },
"snapshot": {
"label": "snapshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element", "format"]
},
"screenshot": {
"label": "screenshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element"]
},
"navigate": {
"label": "navigate",
"detailKeys": ["targetUrl", "targetId"]
},
"console": { "label": "console", "detailKeys": ["level", "targetId"] },
"pdf": { "label": "pdf", "detailKeys": ["targetId"] },
"upload": {
"label": "upload",
"detailKeys": ["paths", "ref", "inputRef", "element", "targetId"]
},
"dialog": {
"label": "dialog",
"detailKeys": ["accept", "promptText", "targetId"]
},
"act": {
"label": "act",
"detailKeys": ["request.kind", "request.ref", "request.selector", "request.text", "request.value"]
}
}
},
"canvas": {
"emoji": "🖼️",
"title": "Canvas",
"actions": {
"present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] },
"hide": { "label": "hide", "detailKeys": ["node", "nodeId"] },
"navigate": { "label": "navigate", "detailKeys": ["url", "node", "nodeId"] },
"eval": { "label": "eval", "detailKeys": ["javaScript", "node", "nodeId"] },
"snapshot": { "label": "snapshot", "detailKeys": ["format", "node", "nodeId"] },
"a2ui_push": { "label": "A2UI push", "detailKeys": ["jsonlPath", "node", "nodeId"] },
"a2ui_reset": { "label": "A2UI reset", "detailKeys": ["node", "nodeId"] }
}
},
"nodes": {
"emoji": "📱",
"title": "Nodes",
"actions": {
"status": { "label": "status" },
"describe": { "label": "describe", "detailKeys": ["node", "nodeId"] },
"pending": { "label": "pending" },
"approve": { "label": "approve", "detailKeys": ["requestId"] },
"reject": { "label": "reject", "detailKeys": ["requestId"] },
"notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] },
"camera_snap": { "label": "camera snap", "detailKeys": ["node", "nodeId", "facing", "deviceId"] },
"camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] },
"camera_clip": { "label": "camera clip", "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] },
"screen_record": {
"label": "screen record",
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
}
}
},
"cron": {
"emoji": "⏰",
"title": "Cron",
"actions": {
"status": { "label": "status" },
"list": { "label": "list" },
"add": {
"label": "add",
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
},
"update": { "label": "update", "detailKeys": ["jobId"] },
"remove": { "label": "remove", "detailKeys": ["jobId"] },
"run": { "label": "run", "detailKeys": ["jobId"] },
"runs": { "label": "runs", "detailKeys": ["jobId"] },
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
}
},
"gateway": {
"emoji": "🔌",
"title": "Gateway",
"actions": {
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
}
},
"whatsapp_login": {
"emoji": "🟢",
"title": "WhatsApp Login",
"actions": {
"start": { "label": "start" },
"wait": { "label": "wait" }
}
},
"discord": {
"emoji": "💬",
"title": "Discord",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
"eventList": { "label": "events", "detailKeys": ["guildId"] },
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
}
}
}
}

View File

@@ -0,0 +1,139 @@
import Foundation
struct AssistantTextSegment: Identifiable {
enum Kind {
case thinking
case response
}
let id = UUID()
let kind: Kind
let text: String
}
enum AssistantTextParser {
static func segments(from raw: String) -> [AssistantTextSegment] {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
guard raw.contains("<") else {
return [AssistantTextSegment(kind: .response, text: trimmed)]
}
var segments: [AssistantTextSegment] = []
var cursor = raw.startIndex
var currentKind: AssistantTextSegment.Kind = .response
var matchedTag = false
while let match = self.nextTag(in: raw, from: cursor) {
matchedTag = true
if match.range.lowerBound > cursor {
self.appendSegment(kind: currentKind, text: raw[cursor..<match.range.lowerBound], to: &segments)
}
guard let tagEnd = raw.range(of: ">", range: match.range.upperBound..<raw.endIndex) else {
cursor = raw.endIndex
break
}
let isSelfClosing = self.isSelfClosingTag(in: raw, tagEnd: tagEnd)
cursor = tagEnd.upperBound
if isSelfClosing { continue }
if match.closing {
currentKind = .response
} else {
currentKind = match.kind == .think ? .thinking : .response
}
}
if cursor < raw.endIndex {
self.appendSegment(kind: currentKind, text: raw[cursor..<raw.endIndex], to: &segments)
}
guard matchedTag else {
return [AssistantTextSegment(kind: .response, text: trimmed)]
}
return segments
}
static func hasVisibleContent(in raw: String) -> Bool {
!self.segments(from: raw).isEmpty
}
private enum TagKind {
case think
case final
}
private struct TagMatch {
let kind: TagKind
let closing: Bool
let range: Range<String.Index>
}
private static func nextTag(in text: String, from start: String.Index) -> TagMatch? {
let candidates: [TagMatch] = [
self.findTagStart(tag: "think", closing: false, in: text, from: start).map {
TagMatch(kind: .think, closing: false, range: $0)
},
self.findTagStart(tag: "think", closing: true, in: text, from: start).map {
TagMatch(kind: .think, closing: true, range: $0)
},
self.findTagStart(tag: "final", closing: false, in: text, from: start).map {
TagMatch(kind: .final, closing: false, range: $0)
},
self.findTagStart(tag: "final", closing: true, in: text, from: start).map {
TagMatch(kind: .final, closing: true, range: $0)
},
].compactMap { $0 }
return candidates.min { $0.range.lowerBound < $1.range.lowerBound }
}
private static func findTagStart(
tag: String,
closing: Bool,
in text: String,
from start: String.Index) -> Range<String.Index>?
{
let token = closing ? "</\(tag)" : "<\(tag)"
var searchRange = start..<text.endIndex
while let range = text.range(
of: token,
options: [.caseInsensitive, .diacriticInsensitive],
range: searchRange)
{
let boundaryIndex = range.upperBound
guard boundaryIndex < text.endIndex else { return range }
let boundary = text[boundaryIndex]
let isBoundary = boundary == ">" || boundary.isWhitespace || (!closing && boundary == "/")
if isBoundary {
return range
}
searchRange = boundaryIndex..<text.endIndex
}
return nil
}
private static func isSelfClosingTag(in text: String, tagEnd: Range<String.Index>) -> Bool {
var cursor = tagEnd.lowerBound
while cursor > text.startIndex {
cursor = text.index(before: cursor)
let char = text[cursor]
if char.isWhitespace { continue }
return char == "/"
}
return false
}
private static func appendSegment(
kind: AssistantTextSegment.Kind,
text: Substring,
to segments: inout [AssistantTextSegment])
{
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
segments.append(AssistantTextSegment(kind: kind, text: trimmed))
}
}

View File

@@ -0,0 +1,489 @@
import Foundation
import Observation
import SwiftUI
#if !os(macOS)
import PhotosUI
import UniformTypeIdentifiers
#endif
@MainActor
struct ClawdbotChatComposer: View {
@Bindable var viewModel: ClawdbotChatViewModel
let style: ClawdbotChatView.Style
let showsSessionSwitcher: Bool
#if !os(macOS)
@State private var pickerItems: [PhotosPickerItem] = []
@FocusState private var isFocused: Bool
#else
@State private var shouldFocusTextView = false
#endif
var body: some View {
VStack(alignment: .leading, spacing: 4) {
if self.showsToolbar {
HStack(spacing: 6) {
if self.showsSessionSwitcher {
self.sessionPicker
}
self.thinkingPicker
Spacer()
self.refreshButton
self.attachmentPicker
}
}
if self.showsAttachments, !self.viewModel.attachments.isEmpty {
self.attachmentsStrip
}
self.editor
}
.padding(self.composerPadding)
.background {
let cornerRadius: CGFloat = 18
#if os(macOS)
if self.style == .standard {
let shape = UnevenRoundedRectangle(
cornerRadii: RectangleCornerRadii(
topLeading: 0,
bottomLeading: cornerRadius,
bottomTrailing: cornerRadius,
topTrailing: 0),
style: .continuous)
shape
.fill(ClawdbotChatTheme.composerBackground)
.overlay(shape.strokeBorder(ClawdbotChatTheme.composerBorder, lineWidth: 1))
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
} else {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
shape
.fill(ClawdbotChatTheme.composerBackground)
.overlay(shape.strokeBorder(ClawdbotChatTheme.composerBorder, lineWidth: 1))
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
}
#else
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
shape
.fill(ClawdbotChatTheme.composerBackground)
.overlay(shape.strokeBorder(ClawdbotChatTheme.composerBorder, lineWidth: 1))
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
#endif
}
#if os(macOS)
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
self.handleDrop(providers)
}
.onAppear {
self.shouldFocusTextView = true
}
#endif
}
private var thinkingPicker: some View {
Picker("Thinking", selection: self.$viewModel.thinkingLevel) {
Text("Off").tag("off")
Text("Low").tag("low")
Text("Medium").tag("medium")
Text("High").tag("high")
}
.labelsHidden()
.pickerStyle(.menu)
.controlSize(.small)
.frame(maxWidth: 140, alignment: .leading)
}
private var sessionPicker: some View {
Picker(
"Session",
selection: Binding(
get: { self.viewModel.sessionKey },
set: { next in self.viewModel.switchSession(to: next) }))
{
ForEach(self.viewModel.sessionChoices, id: \.key) { session in
Text(session.displayName ?? session.key)
.font(.system(.caption, design: .monospaced))
.tag(session.key)
}
}
.labelsHidden()
.pickerStyle(.menu)
.controlSize(.small)
.frame(maxWidth: 160, alignment: .leading)
.help("Session")
}
@ViewBuilder
private var attachmentPicker: some View {
#if os(macOS)
Button {
self.pickFilesMac()
} label: {
Image(systemName: "paperclip")
}
.help("Add Image")
.buttonStyle(.bordered)
.controlSize(.small)
#else
PhotosPicker(selection: self.$pickerItems, maxSelectionCount: 8, matching: .images) {
Image(systemName: "paperclip")
}
.help("Add Image")
.buttonStyle(.bordered)
.controlSize(.small)
.onChange(of: self.pickerItems) { _, newItems in
Task { await self.loadPhotosPickerItems(newItems) }
}
#endif
}
private var attachmentsStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(
self.viewModel.attachments,
id: \ClawdbotPendingAttachment.id)
{ (att: ClawdbotPendingAttachment) in
HStack(spacing: 6) {
if let img = att.preview {
ClawdbotPlatformImageFactory.image(img)
.resizable()
.scaledToFill()
.frame(width: 22, height: 22)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
} else {
Image(systemName: "photo")
}
Text(att.fileName)
.lineLimit(1)
Button {
self.viewModel.removeAttachment(att.id)
} label: {
Image(systemName: "xmark.circle.fill")
}
.buttonStyle(.plain)
}
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(Color.accentColor.opacity(0.08))
.clipShape(Capsule())
}
}
}
}
private var editor: some View {
VStack(alignment: .leading, spacing: 8) {
self.editorOverlay
Rectangle()
.fill(ClawdbotChatTheme.divider)
.frame(height: 1)
.padding(.horizontal, 2)
HStack(alignment: .center, spacing: 8) {
if self.showsConnectionPill {
self.connectionPill
}
Spacer(minLength: 0)
self.sendButton
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(ClawdbotChatTheme.composerField)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(ClawdbotChatTheme.composerBorder)))
.padding(self.editorPadding)
}
private var connectionPill: some View {
HStack(spacing: 6) {
Circle()
.fill(self.viewModel.healthOK ? .green : .orange)
.frame(width: 7, height: 7)
Text(self.activeSessionLabel)
.font(.caption2.weight(.semibold))
Text(self.viewModel.healthOK ? "Connected" : "Connecting…")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(ClawdbotChatTheme.subtleCard)
.clipShape(Capsule())
}
private var activeSessionLabel: String {
let match = self.viewModel.sessions.first { $0.key == self.viewModel.sessionKey }
let trimmed = match?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? self.viewModel.sessionKey : trimmed
}
private var editorOverlay: some View {
ZStack(alignment: .topLeading) {
if self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Text("Message Clawd…")
.foregroundStyle(.tertiary)
.padding(.horizontal, 4)
.padding(.vertical, 4)
}
#if os(macOS)
ChatComposerTextView(text: self.$viewModel.input, shouldFocus: self.$shouldFocusTextView) {
self.viewModel.send()
}
.frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight)
.padding(.horizontal, 4)
.padding(.vertical, 3)
#else
TextEditor(text: self.$viewModel.input)
.font(.system(size: 15))
.scrollContentBackground(.hidden)
.frame(
minHeight: self.textMinHeight,
idealHeight: self.textMinHeight,
maxHeight: self.textMaxHeight)
.padding(.horizontal, 4)
.padding(.vertical, 4)
.focused(self.$isFocused)
#endif
}
}
private var sendButton: some View {
Group {
if self.viewModel.pendingRunCount > 0 {
Button {
self.viewModel.abort()
} label: {
if self.viewModel.isAborting {
ProgressView().controlSize(.mini)
} else {
Image(systemName: "stop.fill")
.font(.system(size: 13, weight: .semibold))
}
}
.buttonStyle(.plain)
.foregroundStyle(.white)
.padding(6)
.background(Circle().fill(Color.red))
.disabled(self.viewModel.isAborting)
} else {
Button {
self.viewModel.send()
} label: {
if self.viewModel.isSending {
ProgressView().controlSize(.mini)
} else {
Image(systemName: "arrow.up")
.font(.system(size: 13, weight: .semibold))
}
}
.buttonStyle(.plain)
.foregroundStyle(.white)
.padding(6)
.background(Circle().fill(Color.accentColor))
.disabled(!self.viewModel.canSend)
}
}
}
private var refreshButton: some View {
Button {
self.viewModel.refresh()
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.bordered)
.controlSize(.small)
.help("Refresh")
}
private var showsToolbar: Bool {
self.style == .standard
}
private var showsAttachments: Bool {
self.style == .standard
}
private var showsConnectionPill: Bool {
self.style == .standard
}
private var composerPadding: CGFloat {
self.style == .onboarding ? 5 : 6
}
private var editorPadding: CGFloat {
self.style == .onboarding ? 5 : 6
}
private var textMinHeight: CGFloat {
self.style == .onboarding ? 24 : 28
}
private var textMaxHeight: CGFloat {
self.style == .onboarding ? 52 : 64
}
#if os(macOS)
private func pickFilesMac() {
let panel = NSOpenPanel()
panel.title = "Select image attachments"
panel.allowsMultipleSelection = true
panel.canChooseDirectories = false
panel.allowedContentTypes = [.image]
panel.begin { resp in
guard resp == .OK else { return }
self.viewModel.addAttachments(urls: panel.urls)
}
}
private func handleDrop(_ providers: [NSItemProvider]) -> Bool {
let fileProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) }
guard !fileProviders.isEmpty else { return false }
for item in fileProviders {
item.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in
guard let data = item as? Data,
let url = URL(dataRepresentation: data, relativeTo: nil)
else { return }
Task { @MainActor in
self.viewModel.addAttachments(urls: [url])
}
}
}
return true
}
#else
private func loadPhotosPickerItems(_ items: [PhotosPickerItem]) async {
for item in items {
do {
guard let data = try await item.loadTransferable(type: Data.self) else { continue }
let type = item.supportedContentTypes.first ?? .image
let ext = type.preferredFilenameExtension ?? "jpg"
let mime = type.preferredMIMEType ?? "image/jpeg"
let name = "photo-\(UUID().uuidString.prefix(8)).\(ext)"
self.viewModel.addImageAttachment(data: data, fileName: name, mimeType: mime)
} catch {
self.viewModel.errorText = error.localizedDescription
}
}
self.pickerItems = []
}
#endif
}
#if os(macOS)
import AppKit
import UniformTypeIdentifiers
private struct ChatComposerTextView: NSViewRepresentable {
@Binding var text: String
@Binding var shouldFocus: Bool
var onSend: () -> Void
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeNSView(context: Context) -> NSScrollView {
let textView = ChatComposerNSTextView()
textView.delegate = context.coordinator
textView.drawsBackground = false
textView.isRichText = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
textView.font = .systemFont(ofSize: 14, weight: .regular)
textView.textContainer?.lineBreakMode = .byWordWrapping
textView.textContainer?.lineFragmentPadding = 0
textView.textContainerInset = NSSize(width: 2, height: 4)
textView.focusRingType = .none
textView.minSize = .zero
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.isHorizontallyResizable = false
textView.isVerticallyResizable = true
textView.autoresizingMask = [.width]
textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
textView.textContainer?.widthTracksTextView = true
textView.string = self.text
textView.onSend = { [weak textView] in
textView?.window?.makeFirstResponder(nil)
self.onSend()
}
let scroll = NSScrollView()
scroll.drawsBackground = false
scroll.borderType = .noBorder
scroll.hasVerticalScroller = true
scroll.autohidesScrollers = true
scroll.scrollerStyle = .overlay
scroll.hasHorizontalScroller = false
scroll.documentView = textView
return scroll
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard let textView = scrollView.documentView as? ChatComposerNSTextView else { return }
if self.shouldFocus, let window = scrollView.window {
window.makeFirstResponder(textView)
self.shouldFocus = false
}
let isEditing = scrollView.window?.firstResponder == textView
// Always allow clearing the text (e.g. after send), even while editing.
// Only skip other updates while editing to avoid cursor jumps.
let shouldClear = self.text.isEmpty && !textView.string.isEmpty
if isEditing, !shouldClear { return }
if textView.string != self.text {
context.coordinator.isProgrammaticUpdate = true
defer { context.coordinator.isProgrammaticUpdate = false }
textView.string = self.text
}
}
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: ChatComposerTextView
var isProgrammaticUpdate = false
init(_ parent: ChatComposerTextView) { self.parent = parent }
func textDidChange(_ notification: Notification) {
guard !self.isProgrammaticUpdate else { return }
guard let view = notification.object as? NSTextView else { return }
guard view.window?.firstResponder === view else { return }
self.parent.text = view.string
}
}
}
private final class ChatComposerNSTextView: NSTextView {
var onSend: (() -> Void)?
override func keyDown(with event: NSEvent) {
let isReturn = event.keyCode == 36
if isReturn {
if event.modifierFlags.contains(.shift) {
super.insertNewline(nil)
return
}
self.onSend?()
return
}
super.keyDown(with: event)
}
}
#endif

View File

@@ -0,0 +1,114 @@
import Foundation
enum ChatMarkdownSplitter {
struct InlineImage: Identifiable {
let id = UUID()
let label: String
let image: ClawdbotPlatformImage?
}
struct Block: Identifiable {
enum Kind: Equatable {
case text
case code(language: String?)
}
let id = UUID()
let kind: Kind
let text: String
}
struct SplitResult {
let blocks: [Block]
let images: [InlineImage]
}
static func split(markdown raw: String) -> SplitResult {
let extracted = self.extractInlineImages(from: raw)
let blocks = self.splitCodeBlocks(from: extracted.cleaned)
return SplitResult(blocks: blocks, images: extracted.images)
}
private static func splitCodeBlocks(from raw: String) -> [Block] {
var blocks: [Block] = []
var buffer: [String] = []
var inCode = false
var codeLang: String?
var codeLines: [String] = []
for line in raw.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
if line.hasPrefix("```") {
if inCode {
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
codeLines.removeAll(keepingCapacity: true)
inCode = false
codeLang = nil
} else {
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
blocks.append(Block(kind: .text, text: text))
}
buffer.removeAll(keepingCapacity: true)
inCode = true
codeLang = line.dropFirst(3).trimmingCharacters(in: .whitespacesAndNewlines)
if codeLang?.isEmpty == true { codeLang = nil }
}
continue
}
if inCode {
codeLines.append(line)
} else {
buffer.append(line)
}
}
if inCode {
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
} else {
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
blocks.append(Block(kind: .text, text: text))
}
}
return blocks.isEmpty ? [Block(kind: .text, text: raw)] : blocks
}
private static func extractInlineImages(from raw: String) -> (cleaned: String, images: [InlineImage]) {
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
guard let re = try? NSRegularExpression(pattern: pattern) else {
return (raw, [])
}
let ns = raw as NSString
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
if matches.isEmpty { return (raw, []) }
var images: [InlineImage] = []
var cleaned = raw
for match in matches.reversed() {
guard match.numberOfRanges >= 3 else { continue }
let label = ns.substring(with: match.range(at: 1))
let dataURL = ns.substring(with: match.range(at: 2))
let image: ClawdbotPlatformImage? = {
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
let b64 = String(dataURL[dataURL.index(after: comma)...])
guard let data = Data(base64Encoded: b64) else { return nil }
return ClawdbotPlatformImage(data: data)
}()
images.append(InlineImage(label: label, image: image))
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
let end = cleaned.index(start, offsetBy: match.range.length)
cleaned.replaceSubrange(start..<end, with: "")
}
let normalized = cleaned
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return (normalized, images.reversed())
}
}

View File

@@ -0,0 +1,725 @@
import ClawdbotKit
import Foundation
import SwiftUI
private enum ChatUIConstants {
static let bubbleMaxWidth: CGFloat = 560
static let bubbleCorner: CGFloat = 18
}
private struct ChatBubbleShape: InsettableShape {
enum Tail {
case left
case right
case none
}
let cornerRadius: CGFloat
let tail: Tail
var insetAmount: CGFloat = 0
private let tailWidth: CGFloat = 7
private let tailBaseHeight: CGFloat = 9
func inset(by amount: CGFloat) -> ChatBubbleShape {
var copy = self
copy.insetAmount += amount
return copy
}
func path(in rect: CGRect) -> Path {
let rect = rect.insetBy(dx: self.insetAmount, dy: self.insetAmount)
switch self.tail {
case .left:
return self.leftTailPath(in: rect, radius: self.cornerRadius)
case .right:
return self.rightTailPath(in: rect, radius: self.cornerRadius)
case .none:
return Path(roundedRect: rect, cornerRadius: self.cornerRadius)
}
}
private func rightTailPath(in rect: CGRect, radius r: CGFloat) -> Path {
var path = Path()
let bubbleMinX = rect.minX
let bubbleMaxX = rect.maxX - self.tailWidth
let bubbleMinY = rect.minY
let bubbleMaxY = rect.maxY
let available = max(4, bubbleMaxY - bubbleMinY - 2 * r)
let baseH = min(tailBaseHeight, available)
let baseBottomY = bubbleMaxY - max(r * 0.45, 6)
let baseTopY = baseBottomY - baseH
let midY = (baseTopY + baseBottomY) / 2
let baseTop = CGPoint(x: bubbleMaxX, y: baseTopY)
let baseBottom = CGPoint(x: bubbleMaxX, y: baseBottomY)
let tip = CGPoint(x: bubbleMaxX + self.tailWidth, y: midY)
path.move(to: CGPoint(x: bubbleMinX + r, y: bubbleMinY))
path.addLine(to: CGPoint(x: bubbleMaxX - r, y: bubbleMinY))
path.addQuadCurve(
to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r),
control: CGPoint(x: bubbleMaxX, y: bubbleMinY))
path.addLine(to: baseTop)
path.addCurve(
to: tip,
control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseTopY + baseH * 0.05),
control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY - baseH * 0.15))
path.addCurve(
to: baseBottom,
control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY + baseH * 0.15),
control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05))
path.addQuadCurve(
to: CGPoint(x: bubbleMaxX - r, y: bubbleMaxY),
control: CGPoint(x: bubbleMaxX, y: bubbleMaxY))
path.addLine(to: CGPoint(x: bubbleMinX + r, y: bubbleMaxY))
path.addQuadCurve(
to: CGPoint(x: bubbleMinX, y: bubbleMaxY - r),
control: CGPoint(x: bubbleMinX, y: bubbleMaxY))
path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r))
path.addQuadCurve(
to: CGPoint(x: bubbleMinX + r, y: bubbleMinY),
control: CGPoint(x: bubbleMinX, y: bubbleMinY))
return path
}
private func leftTailPath(in rect: CGRect, radius r: CGFloat) -> Path {
var path = Path()
let bubbleMinX = rect.minX + self.tailWidth
let bubbleMaxX = rect.maxX
let bubbleMinY = rect.minY
let bubbleMaxY = rect.maxY
let available = max(4, bubbleMaxY - bubbleMinY - 2 * r)
let baseH = min(tailBaseHeight, available)
let baseBottomY = bubbleMaxY - max(r * 0.45, 6)
let baseTopY = baseBottomY - baseH
let midY = (baseTopY + baseBottomY) / 2
let baseTop = CGPoint(x: bubbleMinX, y: baseTopY)
let baseBottom = CGPoint(x: bubbleMinX, y: baseBottomY)
let tip = CGPoint(x: bubbleMinX - self.tailWidth, y: midY)
path.move(to: CGPoint(x: bubbleMinX + r, y: bubbleMinY))
path.addLine(to: CGPoint(x: bubbleMaxX - r, y: bubbleMinY))
path.addQuadCurve(
to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r),
control: CGPoint(x: bubbleMaxX, y: bubbleMinY))
path.addLine(to: CGPoint(x: bubbleMaxX, y: bubbleMaxY - r))
path.addQuadCurve(
to: CGPoint(x: bubbleMaxX - r, y: bubbleMaxY),
control: CGPoint(x: bubbleMaxX, y: bubbleMaxY))
path.addLine(to: CGPoint(x: bubbleMinX + r, y: bubbleMaxY))
path.addQuadCurve(
to: CGPoint(x: bubbleMinX, y: bubbleMaxY - r),
control: CGPoint(x: bubbleMinX, y: bubbleMaxY))
path.addLine(to: baseBottom)
path.addCurve(
to: tip,
control1: CGPoint(x: bubbleMinX - self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05),
control2: CGPoint(x: bubbleMinX - self.tailWidth * 0.95, y: midY + baseH * 0.15))
path.addCurve(
to: baseTop,
control1: CGPoint(x: bubbleMinX - self.tailWidth * 0.95, y: midY - baseH * 0.15),
control2: CGPoint(x: bubbleMinX - self.tailWidth * 0.2, y: baseTopY + baseH * 0.05))
path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r))
path.addQuadCurve(
to: CGPoint(x: bubbleMinX + r, y: bubbleMinY),
control: CGPoint(x: bubbleMinX, y: bubbleMinY))
return path
}
}
@MainActor
struct ChatMessageBubble: View {
let message: ClawdbotChatMessage
let style: ClawdbotChatView.Style
let userAccent: Color?
var body: some View {
ChatMessageBody(message: self.message, isUser: self.isUser, style: self.style, userAccent: self.userAccent)
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
.padding(.horizontal, 2)
}
private var isUser: Bool { self.message.role.lowercased() == "user" }
}
@MainActor
private struct ChatMessageBody: View {
let message: ClawdbotChatMessage
let isUser: Bool
let style: ClawdbotChatView.Style
let userAccent: Color?
var body: some View {
let text = self.primaryText
let textColor = self.isUser ? ClawdbotChatTheme.userText : ClawdbotChatTheme.assistantText
VStack(alignment: .leading, spacing: 10) {
if self.isToolResultMessage {
if !text.isEmpty {
ToolResultCard(
title: self.toolResultTitle,
text: text,
isUser: self.isUser)
}
} else if self.isUser {
let split = ChatMarkdownSplitter.split(markdown: text)
ForEach(split.blocks) { block in
switch block.kind {
case .text:
MarkdownTextView(text: block.text, textColor: textColor, font: .system(size: 14))
case let .code(language):
CodeBlockView(code: block.text, language: language, isUser: self.isUser)
}
}
if !split.images.isEmpty {
ForEach(
split.images,
id: \ChatMarkdownSplitter.InlineImage.id)
{ (item: ChatMarkdownSplitter.InlineImage) in
if let img = item.image {
ClawdbotPlatformImageFactory.image(img)
.resizable()
.scaledToFit()
.frame(maxHeight: 260)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
} else {
Text(item.label.isEmpty ? "Image" : item.label)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
} else {
ChatAssistantTextBody(text: text)
}
if !self.inlineAttachments.isEmpty {
ForEach(self.inlineAttachments.indices, id: \.self) { idx in
AttachmentRow(att: self.inlineAttachments[idx], isUser: self.isUser)
}
}
if !self.toolCalls.isEmpty {
ForEach(self.toolCalls.indices, id: \.self) { idx in
ToolCallCard(
content: self.toolCalls[idx],
isUser: self.isUser)
}
}
if !self.inlineToolResults.isEmpty {
ForEach(self.inlineToolResults.indices, id: \.self) { idx in
let toolResult = self.inlineToolResults[idx]
let display = ToolDisplayRegistry.resolve(name: toolResult.name ?? "tool", args: nil)
ToolResultCard(
title: "\(display.emoji) \(display.title)",
text: toolResult.text ?? "",
isUser: self.isUser)
}
}
}
.textSelection(.enabled)
.padding(.vertical, 10)
.padding(.horizontal, 12)
.foregroundStyle(textColor)
.background(self.bubbleBackground)
.clipShape(self.bubbleShape)
.overlay(self.bubbleBorder)
.shadow(color: self.bubbleShadowColor, radius: self.bubbleShadowRadius, y: self.bubbleShadowYOffset)
.padding(.leading, self.tailPaddingLeading)
.padding(.trailing, self.tailPaddingTrailing)
}
private var primaryText: String {
let parts = self.message.content.compactMap { content -> String? in
let kind = (content.type ?? "text").lowercased()
guard kind == "text" || kind.isEmpty else { return nil }
return content.text
}
return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
}
private var inlineAttachments: [ClawdbotChatMessageContent] {
self.message.content.filter { content in
switch content.type ?? "text" {
case "file", "attachment":
true
default:
false
}
}
}
private var toolCalls: [ClawdbotChatMessageContent] {
self.message.content.filter { content in
let kind = (content.type ?? "").lowercased()
if ["toolcall", "tool_call", "tooluse", "tool_use"].contains(kind) {
return true
}
return content.name != nil && content.arguments != nil
}
}
private var inlineToolResults: [ClawdbotChatMessageContent] {
self.message.content.filter { content in
let kind = (content.type ?? "").lowercased()
return kind == "toolresult" || kind == "tool_result"
}
}
private var isToolResultMessage: Bool {
let role = self.message.role.lowercased()
return role == "toolresult" || role == "tool_result"
}
private var toolResultTitle: String {
if let name = self.message.toolName, !name.isEmpty {
let display = ToolDisplayRegistry.resolve(name: name, args: nil)
return "\(display.emoji) \(display.title)"
}
let display = ToolDisplayRegistry.resolve(name: "tool", args: nil)
return "\(display.emoji) \(display.title)"
}
private var bubbleFillColor: Color {
if self.isUser {
return self.userAccent ?? ClawdbotChatTheme.userBubble
}
if self.style == .onboarding {
return ClawdbotChatTheme.onboardingAssistantBubble
}
return ClawdbotChatTheme.assistantBubble
}
private var bubbleBackground: AnyShapeStyle {
AnyShapeStyle(self.bubbleFillColor)
}
private var bubbleBorderColor: Color {
if self.isUser {
return Color.white.opacity(0.12)
}
if self.style == .onboarding {
return ClawdbotChatTheme.onboardingAssistantBorder
}
return Color.white.opacity(0.08)
}
private var bubbleBorderWidth: CGFloat {
if self.isUser { return 0.5 }
if self.style == .onboarding { return 0.8 }
return 1
}
private var bubbleBorder: some View {
self.bubbleShape.strokeBorder(self.bubbleBorderColor, lineWidth: self.bubbleBorderWidth)
}
private var bubbleShape: ChatBubbleShape {
ChatBubbleShape(cornerRadius: ChatUIConstants.bubbleCorner, tail: self.bubbleTail)
}
private var bubbleTail: ChatBubbleShape.Tail {
guard self.style == .onboarding else { return .none }
return self.isUser ? .right : .left
}
private var tailPaddingLeading: CGFloat {
self.style == .onboarding && !self.isUser ? 8 : 0
}
private var tailPaddingTrailing: CGFloat {
self.style == .onboarding && self.isUser ? 8 : 0
}
private var bubbleShadowColor: Color {
self.style == .onboarding && !self.isUser ? Color.black.opacity(0.28) : .clear
}
private var bubbleShadowRadius: CGFloat {
self.style == .onboarding && !self.isUser ? 6 : 0
}
private var bubbleShadowYOffset: CGFloat {
self.style == .onboarding && !self.isUser ? 2 : 0
}
}
private struct AttachmentRow: View {
let att: ClawdbotChatMessageContent
let isUser: Bool
var body: some View {
HStack(spacing: 8) {
Image(systemName: "paperclip")
Text(self.att.fileName ?? "Attachment")
.font(.footnote)
.lineLimit(1)
.foregroundStyle(self.isUser ? ClawdbotChatTheme.userText : ClawdbotChatTheme.assistantText)
Spacer()
}
.padding(10)
.background(self.isUser ? Color.white.opacity(0.2) : Color.black.opacity(0.04))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
private struct ToolCallCard: View {
let content: ClawdbotChatMessageContent
let isUser: Bool
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Text(self.toolName)
.font(.footnote.weight(.semibold))
Spacer(minLength: 0)
}
if let summary = self.summary, !summary.isEmpty {
Text(summary)
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(ClawdbotChatTheme.subtleCard)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1)))
}
private var toolName: String {
"\(self.display.emoji) \(self.display.title)"
}
private var summary: String? {
self.display.detailLine
}
private var display: ToolDisplaySummary {
ToolDisplayRegistry.resolve(name: self.content.name ?? "tool", args: self.content.arguments)
}
}
private struct ToolResultCard: View {
let title: String
let text: String
let isUser: Bool
@State private var expanded = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Text(self.title)
.font(.footnote.weight(.semibold))
Spacer(minLength: 0)
}
Text(self.displayText)
.font(.footnote.monospaced())
.foregroundStyle(self.isUser ? ClawdbotChatTheme.userText : ClawdbotChatTheme.assistantText)
.lineLimit(self.expanded ? nil : Self.previewLineLimit)
if self.shouldShowToggle {
Button(self.expanded ? "Show less" : "Show full output") {
self.expanded.toggle()
}
.buttonStyle(.plain)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(ClawdbotChatTheme.subtleCard)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1)))
}
private static let previewLineLimit = 8
private var lines: [Substring] {
self.text.components(separatedBy: .newlines).map { Substring($0) }
}
private var displayText: String {
guard !self.expanded, self.lines.count > Self.previewLineLimit else { return self.text }
return self.lines.prefix(Self.previewLineLimit).joined(separator: "\n") + "\n"
}
private var shouldShowToggle: Bool {
self.lines.count > Self.previewLineLimit
}
}
@MainActor
struct ChatTypingIndicatorBubble: View {
let style: ClawdbotChatView.Style
var body: some View {
HStack(spacing: 10) {
TypingDots()
if self.style == .standard {
Text("Clawd is thinking…")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
}
}
.padding(.vertical, self.style == .standard ? 12 : 10)
.padding(.horizontal, self.style == .standard ? 12 : 14)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(ClawdbotChatTheme.assistantBubble))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1))
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
.focusable(false)
}
}
extension ChatTypingIndicatorBubble: @MainActor Equatable {
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.style == rhs.style
}
}
@MainActor
struct ChatStreamingAssistantBubble: View {
let text: String
var body: some View {
VStack(alignment: .leading, spacing: 10) {
ChatAssistantTextBody(text: self.text)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(ClawdbotChatTheme.assistantBubble))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1))
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
.focusable(false)
}
}
@MainActor
struct ChatPendingToolsBubble: View {
let toolCalls: [ClawdbotChatPendingToolCall]
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Running tools…", systemImage: "hammer")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(self.toolCalls) { call in
let display = ToolDisplayRegistry.resolve(name: call.name, args: call.args)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("\(display.emoji) \(display.label)")
.font(.footnote.monospaced())
.lineLimit(1)
Spacer(minLength: 0)
ProgressView().controlSize(.mini)
}
if let detail = display.detailLine, !detail.isEmpty {
Text(detail)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.padding(10)
.background(Color.white.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(ClawdbotChatTheme.assistantBubble))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1))
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
.focusable(false)
}
}
extension ChatPendingToolsBubble: @MainActor Equatable {
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.toolCalls == rhs.toolCalls
}
}
@MainActor
private struct TypingDots: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.scenePhase) private var scenePhase
@State private var animate = false
var body: some View {
HStack(spacing: 5) {
ForEach(0..<3, id: \.self) { idx in
Circle()
.fill(Color.secondary.opacity(0.55))
.frame(width: 7, height: 7)
.scaleEffect(self.reduceMotion ? 0.85 : (self.animate ? 1.05 : 0.70))
.opacity(self.reduceMotion ? 0.55 : (self.animate ? 0.95 : 0.30))
.animation(
self.reduceMotion ? nil : .easeInOut(duration: 0.55)
.repeatForever(autoreverses: true)
.delay(Double(idx) * 0.16),
value: self.animate)
}
}
.onAppear { self.updateAnimationState() }
.onDisappear { self.animate = false }
.onChange(of: self.scenePhase) { _, _ in
self.updateAnimationState()
}
.onChange(of: self.reduceMotion) { _, _ in
self.updateAnimationState()
}
}
private func updateAnimationState() {
guard !self.reduceMotion, self.scenePhase == .active else {
self.animate = false
return
}
self.animate = true
}
}
@MainActor
private struct MarkdownTextView: View {
let text: String
let textColor: Color
let font: Font
var body: some View {
let normalized = self.text.replacingOccurrences(
of: "(?<!\\n)\\n(?!\\n)",
with: " ",
options: .regularExpression)
let options = AttributedString.MarkdownParsingOptions(
interpretedSyntax: .inlineOnlyPreservingWhitespace)
if let attributed = try? AttributedString(markdown: normalized, options: options) {
Text(attributed)
.font(self.font)
.foregroundStyle(self.textColor)
} else {
Text(normalized)
.font(self.font)
.foregroundStyle(self.textColor)
}
}
}
@MainActor
private struct ChatAssistantTextBody: View {
let text: String
var body: some View {
let segments = AssistantTextParser.segments(from: self.text)
VStack(alignment: .leading, spacing: 10) {
ForEach(segments) { segment in
let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14)
ChatMarkdownBody(text: segment.text, textColor: ClawdbotChatTheme.assistantText, font: font)
}
}
}
}
@MainActor
private struct ChatMarkdownBody: View {
let text: String
let textColor: Color
let font: Font
var body: some View {
let split = ChatMarkdownSplitter.split(markdown: self.text)
VStack(alignment: .leading, spacing: 10) {
ForEach(split.blocks) { block in
switch block.kind {
case .text:
MarkdownTextView(text: block.text, textColor: self.textColor, font: self.font)
case let .code(language):
CodeBlockView(code: block.text, language: language, isUser: false)
}
}
if !split.images.isEmpty {
ForEach(
split.images,
id: \ChatMarkdownSplitter.InlineImage.id)
{ (item: ChatMarkdownSplitter.InlineImage) in
if let img = item.image {
ClawdbotPlatformImageFactory.image(img)
.resizable()
.scaledToFit()
.frame(maxHeight: 260)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
} else {
Text(item.label.isEmpty ? "Image" : item.label)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
.textSelection(.enabled)
}
}
@MainActor
private struct CodeBlockView: View {
let code: String
let language: String?
let isUser: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let language, !language.isEmpty {
Text(language)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
Text(self.code)
.font(.system(size: 13, weight: .regular, design: .monospaced))
.foregroundStyle(self.isUser ? .white : .primary)
.textSelection(.enabled)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(self.isUser ? Color.white.opacity(0.16) : Color.black.opacity(0.06))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}

View File

@@ -0,0 +1,311 @@
import ClawdbotKit
import Foundation
// NOTE: keep this file lightweight; decode must be resilient to varying transcript formats.
#if canImport(AppKit)
import AppKit
public typealias ClawdbotPlatformImage = NSImage
#elseif canImport(UIKit)
import UIKit
public typealias ClawdbotPlatformImage = UIImage
#endif
public struct ClawdbotChatUsageCost: Codable, Hashable, Sendable {
public let input: Double?
public let output: Double?
public let cacheRead: Double?
public let cacheWrite: Double?
public let total: Double?
}
public struct ClawdbotChatUsage: Codable, Hashable, Sendable {
public let input: Int?
public let output: Int?
public let cacheRead: Int?
public let cacheWrite: Int?
public let cost: ClawdbotChatUsageCost?
public let total: Int?
enum CodingKeys: String, CodingKey {
case input
case output
case cacheRead
case cacheWrite
case cost
case total
case totalTokens
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.input = try container.decodeIfPresent(Int.self, forKey: .input)
self.output = try container.decodeIfPresent(Int.self, forKey: .output)
self.cacheRead = try container.decodeIfPresent(Int.self, forKey: .cacheRead)
self.cacheWrite = try container.decodeIfPresent(Int.self, forKey: .cacheWrite)
self.cost = try container.decodeIfPresent(ClawdbotChatUsageCost.self, forKey: .cost)
self.total =
try container.decodeIfPresent(Int.self, forKey: .total) ??
container.decodeIfPresent(Int.self, forKey: .totalTokens)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(self.input, forKey: .input)
try container.encodeIfPresent(self.output, forKey: .output)
try container.encodeIfPresent(self.cacheRead, forKey: .cacheRead)
try container.encodeIfPresent(self.cacheWrite, forKey: .cacheWrite)
try container.encodeIfPresent(self.cost, forKey: .cost)
try container.encodeIfPresent(self.total, forKey: .total)
}
}
public struct ClawdbotChatMessageContent: Codable, Hashable, Sendable {
public let type: String?
public let text: String?
public let thinking: String?
public let thinkingSignature: String?
public let mimeType: String?
public let fileName: String?
public let content: AnyCodable?
// Tool-call fields (when `type == "toolCall"` or similar)
public let id: String?
public let name: String?
public let arguments: AnyCodable?
public init(
type: String?,
text: String?,
thinking: String? = nil,
thinkingSignature: String? = nil,
mimeType: String?,
fileName: String?,
content: AnyCodable?,
id: String? = nil,
name: String? = nil,
arguments: AnyCodable? = nil)
{
self.type = type
self.text = text
self.thinking = thinking
self.thinkingSignature = thinkingSignature
self.mimeType = mimeType
self.fileName = fileName
self.content = content
self.id = id
self.name = name
self.arguments = arguments
}
enum CodingKeys: String, CodingKey {
case type
case text
case thinking
case thinkingSignature
case mimeType
case fileName
case content
case id
case name
case arguments
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.type = try container.decodeIfPresent(String.self, forKey: .type)
self.text = try container.decodeIfPresent(String.self, forKey: .text)
self.thinking = try container.decodeIfPresent(String.self, forKey: .thinking)
self.thinkingSignature = try container.decodeIfPresent(String.self, forKey: .thinkingSignature)
self.mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType)
self.fileName = try container.decodeIfPresent(String.self, forKey: .fileName)
self.id = try container.decodeIfPresent(String.self, forKey: .id)
self.name = try container.decodeIfPresent(String.self, forKey: .name)
self.arguments = try container.decodeIfPresent(AnyCodable.self, forKey: .arguments)
if let any = try container.decodeIfPresent(AnyCodable.self, forKey: .content) {
self.content = any
} else if let str = try container.decodeIfPresent(String.self, forKey: .content) {
self.content = AnyCodable(str)
} else {
self.content = nil
}
}
}
public struct ClawdbotChatMessage: Codable, Identifiable, Sendable {
public var id: UUID = .init()
public let role: String
public let content: [ClawdbotChatMessageContent]
public let timestamp: Double?
public let toolCallId: String?
public let toolName: String?
public let usage: ClawdbotChatUsage?
public let stopReason: String?
enum CodingKeys: String, CodingKey {
case role
case content
case timestamp
case toolCallId
case tool_call_id
case toolName
case tool_name
case usage
case stopReason
}
public init(
id: UUID = .init(),
role: String,
content: [ClawdbotChatMessageContent],
timestamp: Double?,
toolCallId: String? = nil,
toolName: String? = nil,
usage: ClawdbotChatUsage? = nil,
stopReason: String? = nil)
{
self.id = id
self.role = role
self.content = content
self.timestamp = timestamp
self.toolCallId = toolCallId
self.toolName = toolName
self.usage = usage
self.stopReason = stopReason
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.role = try container.decode(String.self, forKey: .role)
self.timestamp = try container.decodeIfPresent(Double.self, forKey: .timestamp)
self.toolCallId =
try container.decodeIfPresent(String.self, forKey: .toolCallId) ??
container.decodeIfPresent(String.self, forKey: .tool_call_id)
self.toolName =
try container.decodeIfPresent(String.self, forKey: .toolName) ??
container.decodeIfPresent(String.self, forKey: .tool_name)
self.usage = try container.decodeIfPresent(ClawdbotChatUsage.self, forKey: .usage)
self.stopReason = try container.decodeIfPresent(String.self, forKey: .stopReason)
if let decoded = try? container.decode([ClawdbotChatMessageContent].self, forKey: .content) {
self.content = decoded
return
}
// Some session log formats store `content` as a plain string.
if let text = try? container.decode(String.self, forKey: .content) {
self.content = [
ClawdbotChatMessageContent(
type: "text",
text: text,
thinking: nil,
thinkingSignature: nil,
mimeType: nil,
fileName: nil,
content: nil,
id: nil,
name: nil,
arguments: nil),
]
return
}
self.content = []
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.role, forKey: .role)
try container.encodeIfPresent(self.timestamp, forKey: .timestamp)
try container.encodeIfPresent(self.toolCallId, forKey: .toolCallId)
try container.encodeIfPresent(self.toolName, forKey: .toolName)
try container.encodeIfPresent(self.usage, forKey: .usage)
try container.encodeIfPresent(self.stopReason, forKey: .stopReason)
try container.encode(self.content, forKey: .content)
}
}
public struct ClawdbotChatHistoryPayload: Codable, Sendable {
public let sessionKey: String
public let sessionId: String?
public let messages: [AnyCodable]?
public let thinkingLevel: String?
}
public struct ClawdbotChatSendResponse: Codable, Sendable {
public let runId: String
public let status: String
}
public struct ClawdbotChatEventPayload: Codable, Sendable {
public let runId: String?
public let sessionKey: String?
public let state: String?
public let message: AnyCodable?
public let errorMessage: String?
}
public struct ClawdbotAgentEventPayload: Codable, Sendable, Identifiable {
public var id: String { "\(self.runId)-\(self.seq ?? -1)" }
public let runId: String
public let seq: Int?
public let stream: String
public let ts: Int?
public let data: [String: AnyCodable]
}
public struct ClawdbotChatPendingToolCall: Identifiable, Hashable, Sendable {
public var id: String { self.toolCallId }
public let toolCallId: String
public let name: String
public let args: AnyCodable?
public let startedAt: Double?
public let isError: Bool?
}
public struct ClawdbotGatewayHealthOK: Codable, Sendable {
public let ok: Bool?
}
public struct ClawdbotPendingAttachment: Identifiable {
public let id = UUID()
public let url: URL?
public let data: Data
public let fileName: String
public let mimeType: String
public let type: String
public let preview: ClawdbotPlatformImage?
public init(
url: URL?,
data: Data,
fileName: String,
mimeType: String,
type: String = "file",
preview: ClawdbotPlatformImage?)
{
self.url = url
self.data = data
self.fileName = fileName
self.mimeType = mimeType
self.type = type
self.preview = preview
}
}
public struct ClawdbotChatAttachmentPayload: Codable, Sendable, Hashable {
public let type: String
public let mimeType: String
public let fileName: String
public let content: String
public init(type: String, mimeType: String, fileName: String, content: String) {
self.type = type
self.mimeType = mimeType
self.fileName = fileName
self.content = content
}
}

View File

@@ -0,0 +1,9 @@
import ClawdbotKit
import Foundation
enum ChatPayloadDecoding {
static func decode<T: Decodable>(_ payload: AnyCodable, as _: T.Type = T.self) throws -> T {
let data = try JSONEncoder().encode(payload)
return try JSONDecoder().decode(T.self, from: data)
}
}

View File

@@ -0,0 +1,40 @@
import Foundation
public struct ClawdbotChatSessionsDefaults: Codable, Sendable {
public let model: String?
public let contextTokens: Int?
}
public struct ClawdbotChatSessionEntry: Codable, Identifiable, Sendable, Hashable {
public var id: String { self.key }
public let key: String
public let kind: String?
public let displayName: String?
public let surface: String?
public let subject: String?
public let room: String?
public let space: String?
public let updatedAt: Double?
public let sessionId: String?
public let systemSent: Bool?
public let abortedLastRun: Bool?
public let thinkingLevel: String?
public let verboseLevel: String?
public let inputTokens: Int?
public let outputTokens: Int?
public let totalTokens: Int?
public let model: String?
public let contextTokens: Int?
}
public struct ClawdbotChatSessionsListResponse: Codable, Sendable {
public let ts: Double?
public let path: String?
public let count: Int?
public let defaults: ClawdbotChatSessionsDefaults?
public let sessions: [ClawdbotChatSessionEntry]
}

View File

@@ -0,0 +1,69 @@
import Observation
import SwiftUI
@MainActor
struct ChatSessionsSheet: View {
@Bindable var viewModel: ClawdbotChatViewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List(self.viewModel.sessions) { session in
Button {
self.viewModel.switchSession(to: session.key)
self.dismiss()
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(session.displayName ?? session.key)
.font(.system(.body, design: .monospaced))
.lineLimit(1)
if let updatedAt = session.updatedAt, updatedAt > 0 {
Text(Date(timeIntervalSince1970: updatedAt / 1000).formatted(
date: .abbreviated,
time: .shortened))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Sessions")
.toolbar {
#if os(macOS)
ToolbarItem(placement: .automatic) {
Button {
self.viewModel.refreshSessions(limit: 200)
} label: {
Image(systemName: "arrow.clockwise")
}
}
ToolbarItem(placement: .primaryAction) {
Button {
self.dismiss()
} label: {
Image(systemName: "xmark")
}
}
#else
ToolbarItem(placement: .topBarLeading) {
Button {
self.viewModel.refreshSessions(limit: 200)
} label: {
Image(systemName: "arrow.clockwise")
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
self.dismiss()
} label: {
Image(systemName: "xmark")
}
}
#endif
}
.onAppear {
self.viewModel.refreshSessions(limit: 200)
}
}
}
}

View File

@@ -0,0 +1,174 @@
import SwiftUI
#if os(macOS)
import AppKit
#else
import UIKit
#endif
#if os(macOS)
extension NSAppearance {
fileprivate var isDarkAqua: Bool {
self.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
}
}
#endif
enum ClawdbotChatTheme {
#if os(macOS)
static func resolvedAssistantBubbleColor(for appearance: NSAppearance) -> NSColor {
// NSColor semantic colors don't reliably resolve for arbitrary NSAppearance in SwiftPM.
// Use explicit light/dark values so the bubble updates when the system appearance flips.
appearance.isDarkAqua
? NSColor(calibratedWhite: 0.18, alpha: 0.88)
: NSColor(calibratedWhite: 0.94, alpha: 0.92)
}
static func resolvedOnboardingAssistantBubbleColor(for appearance: NSAppearance) -> NSColor {
appearance.isDarkAqua
? NSColor(calibratedWhite: 0.20, alpha: 0.94)
: NSColor(calibratedWhite: 0.97, alpha: 0.98)
}
static let assistantBubbleDynamicNSColor = NSColor(
name: NSColor.Name("ClawdbotChatTheme.assistantBubble"),
dynamicProvider: resolvedAssistantBubbleColor(for:))
static let onboardingAssistantBubbleDynamicNSColor = NSColor(
name: NSColor.Name("ClawdbotChatTheme.onboardingAssistantBubble"),
dynamicProvider: resolvedOnboardingAssistantBubbleColor(for:))
#endif
static var surface: Color {
#if os(macOS)
Color(nsColor: .windowBackgroundColor)
#else
Color(uiColor: .systemBackground)
#endif
}
@ViewBuilder
static var background: some View {
#if os(macOS)
ZStack {
Rectangle()
.fill(.ultraThinMaterial)
LinearGradient(
colors: [
Color.white.opacity(0.12),
Color(nsColor: .windowBackgroundColor).opacity(0.35),
Color.black.opacity(0.35),
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
RadialGradient(
colors: [
Color(nsColor: .systemOrange).opacity(0.14),
.clear,
],
center: .topLeading,
startRadius: 40,
endRadius: 320)
RadialGradient(
colors: [
Color(nsColor: .systemTeal).opacity(0.12),
.clear,
],
center: .topTrailing,
startRadius: 40,
endRadius: 280)
Color.black.opacity(0.08)
}
#else
Color(uiColor: .systemBackground)
#endif
}
static var card: Color {
#if os(macOS)
Color(nsColor: .textBackgroundColor)
#else
Color(uiColor: .secondarySystemBackground)
#endif
}
static var subtleCard: AnyShapeStyle {
#if os(macOS)
AnyShapeStyle(.ultraThinMaterial)
#else
AnyShapeStyle(Color(uiColor: .secondarySystemBackground).opacity(0.9))
#endif
}
static var userBubble: Color {
Color(red: 127 / 255.0, green: 184 / 255.0, blue: 212 / 255.0)
}
static var assistantBubble: Color {
#if os(macOS)
Color(nsColor: self.assistantBubbleDynamicNSColor)
#else
Color(uiColor: .secondarySystemBackground)
#endif
}
static var onboardingAssistantBubble: Color {
#if os(macOS)
Color(nsColor: self.onboardingAssistantBubbleDynamicNSColor)
#else
Color(uiColor: .secondarySystemBackground)
#endif
}
static var onboardingAssistantBorder: Color {
#if os(macOS)
Color.white.opacity(0.12)
#else
Color.white.opacity(0.12)
#endif
}
static var userText: Color { .white }
static var assistantText: Color {
#if os(macOS)
Color(nsColor: .labelColor)
#else
Color(uiColor: .label)
#endif
}
static var composerBackground: AnyShapeStyle {
#if os(macOS)
AnyShapeStyle(.ultraThinMaterial)
#else
AnyShapeStyle(Color(uiColor: .systemBackground))
#endif
}
static var composerField: AnyShapeStyle {
#if os(macOS)
AnyShapeStyle(.thinMaterial)
#else
AnyShapeStyle(Color(uiColor: .secondarySystemBackground))
#endif
}
static var composerBorder: Color {
Color.white.opacity(0.12)
}
static var divider: Color {
Color.secondary.opacity(0.2)
}
}
enum ClawdbotPlatformImageFactory {
static func image(_ image: ClawdbotPlatformImage) -> Image {
#if os(macOS)
Image(nsImage: image)
#else
Image(uiImage: image)
#endif
}
}

View File

@@ -0,0 +1,45 @@
import Foundation
public enum ClawdbotChatTransportEvent: Sendable {
case health(ok: Bool)
case tick
case chat(ClawdbotChatEventPayload)
case agent(ClawdbotAgentEventPayload)
case seqGap
}
public protocol ClawdbotChatTransport: Sendable {
func requestHistory(sessionKey: String) async throws -> ClawdbotChatHistoryPayload
func sendMessage(
sessionKey: String,
message: String,
thinking: String,
idempotencyKey: String,
attachments: [ClawdbotChatAttachmentPayload]) async throws -> ClawdbotChatSendResponse
func abortRun(sessionKey: String, runId: String) async throws
func listSessions(limit: Int?) async throws -> ClawdbotChatSessionsListResponse
func requestHealth(timeoutMs: Int) async throws -> Bool
func events() -> AsyncStream<ClawdbotChatTransportEvent>
func setActiveSessionKey(_ sessionKey: String) async throws
}
extension ClawdbotChatTransport {
public func setActiveSessionKey(_: String) async throws {}
public func abortRun(sessionKey _: String, runId _: String) async throws {
throw NSError(
domain: "ClawdbotChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "chat.abort not supported by this transport"])
}
public func listSessions(limit _: Int?) async throws -> ClawdbotChatSessionsListResponse {
throw NSError(
domain: "ClawdbotChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.list not supported by this transport"])
}
}

View File

@@ -0,0 +1,476 @@
import SwiftUI
@MainActor
public struct ClawdbotChatView: View {
public enum Style {
case standard
case onboarding
}
@State private var viewModel: ClawdbotChatViewModel
@State private var scrollerBottomID = UUID()
@State private var scrollPosition: UUID?
@State private var showSessions = false
@State private var hasPerformedInitialScroll = false
private let showsSessionSwitcher: Bool
private let style: Style
private let userAccent: Color?
private enum Layout {
#if os(macOS)
static let outerPaddingHorizontal: CGFloat = 6
static let outerPaddingVertical: CGFloat = 0
static let composerPaddingHorizontal: CGFloat = 0
static let stackSpacing: CGFloat = 0
static let messageSpacing: CGFloat = 6
static let messageListPaddingTop: CGFloat = 12
static let messageListPaddingBottom: CGFloat = 16
static let messageListPaddingHorizontal: CGFloat = 6
#else
static let outerPaddingHorizontal: CGFloat = 6
static let outerPaddingVertical: CGFloat = 6
static let composerPaddingHorizontal: CGFloat = 6
static let stackSpacing: CGFloat = 6
static let messageSpacing: CGFloat = 12
static let messageListPaddingTop: CGFloat = 10
static let messageListPaddingBottom: CGFloat = 6
static let messageListPaddingHorizontal: CGFloat = 8
#endif
}
public init(
viewModel: ClawdbotChatViewModel,
showsSessionSwitcher: Bool = false,
style: Style = .standard,
userAccent: Color? = nil)
{
self._viewModel = State(initialValue: viewModel)
self.showsSessionSwitcher = showsSessionSwitcher
self.style = style
self.userAccent = userAccent
}
public var body: some View {
ZStack {
ClawdbotChatTheme.background
.ignoresSafeArea()
VStack(spacing: Layout.stackSpacing) {
self.messageList
.padding(.horizontal, Layout.outerPaddingHorizontal)
ClawdbotChatComposer(
viewModel: self.viewModel,
style: self.style,
showsSessionSwitcher: self.showsSessionSwitcher)
.padding(.horizontal, Layout.composerPaddingHorizontal)
}
.padding(.vertical, Layout.outerPaddingVertical)
.frame(maxWidth: .infinity)
.frame(maxHeight: .infinity, alignment: .top)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.onAppear { self.viewModel.load() }
.sheet(isPresented: self.$showSessions) {
if self.showsSessionSwitcher {
ChatSessionsSheet(viewModel: self.viewModel)
} else {
EmptyView()
}
}
}
private var messageList: some View {
ZStack {
ScrollView {
#if os(macOS)
VStack(spacing: 0) {
LazyVStack(spacing: Layout.messageSpacing) {
self.messageListRows
}
Color.clear
.frame(height: Layout.messageListPaddingBottom)
.id(self.scrollerBottomID)
}
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
.scrollTargetLayout()
.padding(.top, Layout.messageListPaddingTop)
.padding(.horizontal, Layout.messageListPaddingHorizontal)
#else
LazyVStack(spacing: Layout.messageSpacing) {
self.messageListRows
Color.clear
.frame(height: Layout.messageListPaddingBottom + 1)
.id(self.scrollerBottomID)
}
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
.scrollTargetLayout()
.padding(.top, Layout.messageListPaddingTop)
.padding(.horizontal, Layout.messageListPaddingHorizontal)
#endif
}
// Keep the scroll pinned to the bottom for new messages.
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
if self.viewModel.isLoading {
ProgressView()
.controlSize(.large)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
self.messageListOverlay
}
// Ensure the message list claims vertical space on the first layout pass.
.frame(maxHeight: .infinity, alignment: .top)
.layoutPriority(1)
.onChange(of: self.viewModel.isLoading) { _, isLoading in
guard !isLoading, !self.hasPerformedInitialScroll else { return }
self.scrollPosition = self.scrollerBottomID
self.hasPerformedInitialScroll = true
}
.onChange(of: self.viewModel.sessionKey) { _, _ in
self.hasPerformedInitialScroll = false
}
.onChange(of: self.viewModel.messages.count) { _, _ in
guard self.hasPerformedInitialScroll else { return }
withAnimation(.snappy(duration: 0.22)) {
self.scrollPosition = self.scrollerBottomID
}
}
.onChange(of: self.viewModel.pendingRunCount) { _, _ in
guard self.hasPerformedInitialScroll else { return }
withAnimation(.snappy(duration: 0.22)) {
self.scrollPosition = self.scrollerBottomID
}
}
}
@ViewBuilder
private var messageListRows: some View {
ForEach(self.visibleMessages) { msg in
ChatMessageBubble(message: msg, style: self.style, userAccent: self.userAccent)
.frame(
maxWidth: .infinity,
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
}
if self.viewModel.pendingRunCount > 0 {
HStack {
ChatTypingIndicatorBubble(style: self.style)
.equatable()
Spacer(minLength: 0)
}
}
if !self.viewModel.pendingToolCalls.isEmpty {
ChatPendingToolsBubble(toolCalls: self.viewModel.pendingToolCalls)
.equatable()
.frame(maxWidth: .infinity, alignment: .leading)
}
if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) {
ChatStreamingAssistantBubble(text: text)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private var visibleMessages: [ClawdbotChatMessage] {
let base: [ClawdbotChatMessage]
if self.style == .onboarding {
guard let first = self.viewModel.messages.first else { return [] }
base = first.role.lowercased() == "user" ? Array(self.viewModel.messages.dropFirst()) : self.viewModel
.messages
} else {
base = self.viewModel.messages
}
return self.mergeToolResults(in: base)
}
@ViewBuilder
private var messageListOverlay: some View {
if self.viewModel.isLoading {
EmptyView()
} else if let error = self.activeErrorText {
let presentation = self.errorPresentation(for: error)
if self.hasVisibleMessageListContent {
VStack(spacing: 0) {
ChatNoticeBanner(
systemImage: presentation.systemImage,
title: presentation.title,
message: error,
tint: presentation.tint,
dismiss: { self.viewModel.errorText = nil },
refresh: { self.viewModel.refresh() })
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.top, 8)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
} else {
ChatNoticeCard(
systemImage: presentation.systemImage,
title: presentation.title,
message: error,
tint: presentation.tint,
actionTitle: "Refresh",
action: { self.viewModel.refresh() })
.padding(.horizontal, 24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
} else if self.showsEmptyState {
ChatNoticeCard(
systemImage: "bubble.left.and.bubble.right.fill",
title: self.emptyStateTitle,
message: self.emptyStateMessage,
tint: .accentColor,
actionTitle: nil,
action: nil)
.padding(.horizontal, 24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private var activeErrorText: String? {
guard let text = self.viewModel.errorText?
.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty
else {
return nil
}
return text
}
private var hasVisibleMessageListContent: Bool {
if !self.visibleMessages.isEmpty {
return true
}
if let text = self.viewModel.streamingAssistantText,
AssistantTextParser.hasVisibleContent(in: text)
{
return true
}
if self.viewModel.pendingRunCount > 0 {
return true
}
if !self.viewModel.pendingToolCalls.isEmpty {
return true
}
return false
}
private var showsEmptyState: Bool {
self.viewModel.messages.isEmpty &&
!(self.viewModel.streamingAssistantText.map { AssistantTextParser.hasVisibleContent(in: $0) } ?? false) &&
self.viewModel.pendingRunCount == 0 &&
self.viewModel.pendingToolCalls.isEmpty
}
private var emptyStateTitle: String {
#if os(macOS)
"Web Chat"
#else
"Chat"
#endif
}
private var emptyStateMessage: String {
#if os(macOS)
"Type a message below to start.\nReturn sends • Shift-Return adds a line break."
#else
"Type a message below to start."
#endif
}
private func errorPresentation(for error: String) -> (title: String, systemImage: String, tint: Color) {
let lower = error.lowercased()
if lower.contains("not connected") || lower.contains("socket") {
return ("Disconnected", "wifi.slash", .orange)
}
if lower.contains("timed out") {
return ("Timed out", "clock.badge.exclamationmark", .orange)
}
return ("Error", "exclamationmark.triangle.fill", .orange)
}
private func mergeToolResults(in messages: [ClawdbotChatMessage]) -> [ClawdbotChatMessage] {
var result: [ClawdbotChatMessage] = []
result.reserveCapacity(messages.count)
for message in messages {
guard self.isToolResultMessage(message) else {
result.append(message)
continue
}
guard let toolCallId = message.toolCallId,
let last = result.last,
self.toolCallIds(in: last).contains(toolCallId)
else {
result.append(message)
continue
}
let toolText = self.toolResultText(from: message)
if toolText.isEmpty {
continue
}
var content = last.content
content.append(
ClawdbotChatMessageContent(
type: "tool_result",
text: toolText,
thinking: nil,
thinkingSignature: nil,
mimeType: nil,
fileName: nil,
content: nil,
id: toolCallId,
name: message.toolName,
arguments: nil))
let merged = ClawdbotChatMessage(
id: last.id,
role: last.role,
content: content,
timestamp: last.timestamp,
toolCallId: last.toolCallId,
toolName: last.toolName,
usage: last.usage,
stopReason: last.stopReason)
result[result.count - 1] = merged
}
return result
}
private func isToolResultMessage(_ message: ClawdbotChatMessage) -> Bool {
let role = message.role.lowercased()
return role == "toolresult" || role == "tool_result"
}
private func toolCallIds(in message: ClawdbotChatMessage) -> Set<String> {
var ids = Set<String>()
for content in message.content {
let kind = (content.type ?? "").lowercased()
let isTool =
["toolcall", "tool_call", "tooluse", "tool_use"].contains(kind) ||
(content.name != nil && content.arguments != nil)
if isTool, let id = content.id {
ids.insert(id)
}
}
if let toolCallId = message.toolCallId {
ids.insert(toolCallId)
}
return ids
}
private func toolResultText(from message: ClawdbotChatMessage) -> String {
let parts = message.content.compactMap { content -> String? in
let kind = (content.type ?? "text").lowercased()
guard kind == "text" || kind.isEmpty else { return nil }
return content.text
}
return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
}
}
private struct ChatNoticeCard: View {
let systemImage: String
let title: String
let message: String
let tint: Color
let actionTitle: String?
let action: (() -> Void)?
var body: some View {
VStack(spacing: 12) {
ZStack {
Circle()
.fill(self.tint.opacity(0.16))
Image(systemName: self.systemImage)
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(self.tint)
}
.frame(width: 52, height: 52)
Text(self.title)
.font(.headline)
Text(self.message)
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineLimit(4)
.frame(maxWidth: 360)
if let actionTitle, let action {
Button(actionTitle, action: action)
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
.padding(18)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(ClawdbotChatTheme.subtleCard)
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)))
.shadow(color: .black.opacity(0.14), radius: 18, y: 8)
}
}
private struct ChatNoticeBanner: View {
let systemImage: String
let title: String
let message: String
let tint: Color
let dismiss: () -> Void
let refresh: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: self.systemImage)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(self.tint)
.padding(.top, 1)
VStack(alignment: .leading, spacing: 3) {
Text(self.title)
.font(.caption.weight(.semibold))
Text(self.message)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 0)
Button(action: self.refresh) {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.bordered)
.controlSize(.small)
.help("Refresh")
Button(action: self.dismiss) {
Image(systemName: "xmark")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.help("Dismiss")
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(ClawdbotChatTheme.subtleCard)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)))
}
}

View File

@@ -0,0 +1,559 @@
import ClawdbotKit
import Foundation
import Observation
import OSLog
import UniformTypeIdentifiers
#if canImport(AppKit)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif
private let chatUILogger = Logger(subsystem: "com.clawdbot", category: "ClawdbotChatUI")
@MainActor
@Observable
public final class ClawdbotChatViewModel {
public private(set) var messages: [ClawdbotChatMessage] = []
public var input: String = ""
public var thinkingLevel: String = "off"
public private(set) var isLoading = false
public private(set) var isSending = false
public private(set) var isAborting = false
public var errorText: String?
public var attachments: [ClawdbotPendingAttachment] = []
public private(set) var healthOK: Bool = false
public private(set) var pendingRunCount: Int = 0
public private(set) var sessionKey: String
public private(set) var sessionId: String?
public private(set) var streamingAssistantText: String?
public private(set) var pendingToolCalls: [ClawdbotChatPendingToolCall] = []
public private(set) var sessions: [ClawdbotChatSessionEntry] = []
private let transport: any ClawdbotChatTransport
@ObservationIgnored
private nonisolated(unsafe) var eventTask: Task<Void, Never>?
private var pendingRuns = Set<String>() {
didSet { self.pendingRunCount = self.pendingRuns.count }
}
@ObservationIgnored
private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task<Void, Never>] = [:]
private let pendingRunTimeoutMs: UInt64 = 120_000
private var pendingToolCallsById: [String: ClawdbotChatPendingToolCall] = [:] {
didSet {
self.pendingToolCalls = self.pendingToolCallsById.values
.sorted { ($0.startedAt ?? 0) < ($1.startedAt ?? 0) }
}
}
private var lastHealthPollAt: Date?
public init(sessionKey: String, transport: any ClawdbotChatTransport) {
self.sessionKey = sessionKey
self.transport = transport
self.eventTask = Task { [weak self] in
guard let self else { return }
let stream = self.transport.events()
for await evt in stream {
if Task.isCancelled { return }
await MainActor.run { [weak self] in
self?.handleTransportEvent(evt)
}
}
}
}
deinit {
self.eventTask?.cancel()
for (_, task) in self.pendingRunTimeoutTasks {
task.cancel()
}
}
public func load() {
Task { await self.bootstrap() }
}
public func refresh() {
Task { await self.bootstrap() }
}
public func send() {
Task { await self.performSend() }
}
public func abort() {
Task { await self.performAbort() }
}
public func refreshSessions(limit: Int? = nil) {
Task { await self.fetchSessions(limit: limit) }
}
public func switchSession(to sessionKey: String) {
Task { await self.performSwitchSession(to: sessionKey) }
}
public var sessionChoices: [ClawdbotChatSessionEntry] {
let now = Date().timeIntervalSince1970 * 1000
let cutoff = now - (24 * 60 * 60 * 1000)
let sorted = self.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
var seen = Set<String>()
var recent: [ClawdbotChatSessionEntry] = []
for entry in sorted {
guard !seen.contains(entry.key) else { continue }
seen.insert(entry.key)
guard (entry.updatedAt ?? 0) >= cutoff else { continue }
recent.append(entry)
}
let mainKey = "main"
var result: [ClawdbotChatSessionEntry] = []
var included = Set<String>()
if let main = sorted.first(where: { $0.key == mainKey }) {
result.append(main)
included.insert(mainKey)
} else if self.sessionKey == mainKey {
result.append(self.placeholderSession(key: mainKey))
included.insert(mainKey)
}
for entry in recent where !included.contains(entry.key) {
result.append(entry)
included.insert(entry.key)
}
if !included.contains(self.sessionKey) {
result.append(self.placeholderSession(key: self.sessionKey))
}
return result
}
public func addAttachments(urls: [URL]) {
Task { await self.loadAttachments(urls: urls) }
}
public func addImageAttachment(data: Data, fileName: String, mimeType: String) {
Task { await self.addImageAttachment(url: nil, data: data, fileName: fileName, mimeType: mimeType) }
}
public func removeAttachment(_ id: ClawdbotPendingAttachment.ID) {
self.attachments.removeAll { $0.id == id }
}
public var canSend: Bool {
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
return !self.isSending && self.pendingRunCount == 0 && (!trimmed.isEmpty || !self.attachments.isEmpty)
}
// MARK: - Internals
private func bootstrap() async {
self.isLoading = true
self.errorText = nil
self.healthOK = false
self.clearPendingRuns(reason: nil)
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
self.sessionId = nil
defer { self.isLoading = false }
do {
do {
try await self.transport.setActiveSessionKey(self.sessionKey)
} catch {
// Best-effort only; history/send/health still work without push events.
}
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
self.messages = Self.decodeMessages(payload.messages ?? [])
self.sessionId = payload.sessionId
if let level = payload.thinkingLevel, !level.isEmpty {
self.thinkingLevel = level
}
await self.pollHealthIfNeeded(force: true)
await self.fetchSessions(limit: 50)
self.errorText = nil
} catch {
self.errorText = error.localizedDescription
chatUILogger.error("bootstrap failed \(error.localizedDescription, privacy: .public)")
}
}
private static func decodeMessages(_ raw: [AnyCodable]) -> [ClawdbotChatMessage] {
let decoded = raw.compactMap { item in
(try? ChatPayloadDecoding.decode(item, as: ClawdbotChatMessage.self))
}
return Self.dedupeMessages(decoded)
}
private static func dedupeMessages(_ messages: [ClawdbotChatMessage]) -> [ClawdbotChatMessage] {
var result: [ClawdbotChatMessage] = []
result.reserveCapacity(messages.count)
var seen = Set<String>()
for message in messages {
guard let key = Self.dedupeKey(for: message) else {
result.append(message)
continue
}
if seen.contains(key) { continue }
seen.insert(key)
result.append(message)
}
return result
}
private static func dedupeKey(for message: ClawdbotChatMessage) -> String? {
guard let timestamp = message.timestamp else { return nil }
let text = message.content.compactMap(\.text).joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
return "\(message.role)|\(timestamp)|\(text)"
}
private func performSend() async {
guard !self.isSending else { return }
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty || !self.attachments.isEmpty else { return }
guard self.healthOK else {
self.errorText = "Gateway health not OK; cannot send"
return
}
self.isSending = true
self.errorText = nil
let runId = UUID().uuidString
let messageText = trimmed.isEmpty && !self.attachments.isEmpty ? "See attached." : trimmed
self.pendingRuns.insert(runId)
self.armPendingRunTimeout(runId: runId)
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
// Optimistically append user message to UI.
var userContent: [ClawdbotChatMessageContent] = [
ClawdbotChatMessageContent(
type: "text",
text: messageText,
thinking: nil,
thinkingSignature: nil,
mimeType: nil,
fileName: nil,
content: nil,
id: nil,
name: nil,
arguments: nil),
]
let encodedAttachments = self.attachments.map { att -> ClawdbotChatAttachmentPayload in
ClawdbotChatAttachmentPayload(
type: att.type,
mimeType: att.mimeType,
fileName: att.fileName,
content: att.data.base64EncodedString())
}
for att in encodedAttachments {
userContent.append(
ClawdbotChatMessageContent(
type: att.type,
text: nil,
thinking: nil,
thinkingSignature: nil,
mimeType: att.mimeType,
fileName: att.fileName,
content: AnyCodable(att.content),
id: nil,
name: nil,
arguments: nil))
}
self.messages.append(
ClawdbotChatMessage(
id: UUID(),
role: "user",
content: userContent,
timestamp: Date().timeIntervalSince1970 * 1000))
// Clear input immediately for responsive UX (before network await)
self.input = ""
self.attachments = []
do {
let response = try await self.transport.sendMessage(
sessionKey: self.sessionKey,
message: messageText,
thinking: self.thinkingLevel,
idempotencyKey: runId,
attachments: encodedAttachments)
if response.runId != runId {
self.clearPendingRun(runId)
self.pendingRuns.insert(response.runId)
self.armPendingRunTimeout(runId: response.runId)
}
} catch {
self.clearPendingRun(runId)
self.errorText = error.localizedDescription
chatUILogger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
}
self.isSending = false
}
private func performAbort() async {
guard !self.pendingRuns.isEmpty else { return }
guard !self.isAborting else { return }
self.isAborting = true
defer { self.isAborting = false }
let runIds = Array(self.pendingRuns)
for runId in runIds {
do {
try await self.transport.abortRun(sessionKey: self.sessionKey, runId: runId)
} catch {
// Best-effort.
}
}
}
private func fetchSessions(limit: Int?) async {
do {
let res = try await self.transport.listSessions(limit: limit)
self.sessions = res.sessions
} catch {
// Best-effort.
}
}
private func performSwitchSession(to sessionKey: String) async {
let next = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !next.isEmpty else { return }
guard next != self.sessionKey else { return }
self.sessionKey = next
await self.bootstrap()
}
private func placeholderSession(key: String) -> ClawdbotChatSessionEntry {
ClawdbotChatSessionEntry(
key: key,
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: nil,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
model: nil,
contextTokens: nil)
}
private func handleTransportEvent(_ evt: ClawdbotChatTransportEvent) {
switch evt {
case let .health(ok):
self.healthOK = ok
case .tick:
Task { await self.pollHealthIfNeeded(force: false) }
case let .chat(chat):
self.handleChatEvent(chat)
case let .agent(agent):
self.handleAgentEvent(agent)
case .seqGap:
self.errorText = "Event stream interrupted; try refreshing."
self.clearPendingRuns(reason: nil)
}
}
private func handleChatEvent(_ chat: ClawdbotChatEventPayload) {
if let sessionKey = chat.sessionKey, sessionKey != self.sessionKey {
return
}
let isOurRun = chat.runId.flatMap { self.pendingRuns.contains($0) } ?? false
if !isOurRun {
// Keep multiple clients in sync: if another client finishes a run for our session, refresh history.
switch chat.state {
case "final", "aborted", "error":
self.streamingAssistantText = nil
self.pendingToolCallsById = [:]
Task { await self.refreshHistoryAfterRun() }
default:
break
}
return
}
switch chat.state {
case "final", "aborted", "error":
if chat.state == "error" {
self.errorText = chat.errorMessage ?? "Chat failed"
}
if let runId = chat.runId {
self.clearPendingRun(runId)
} else if self.pendingRuns.count <= 1 {
self.clearPendingRuns(reason: nil)
}
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
Task { await self.refreshHistoryAfterRun() }
default:
break
}
}
private func handleAgentEvent(_ evt: ClawdbotAgentEventPayload) {
if let sessionId, evt.runId != sessionId {
return
}
switch evt.stream {
case "assistant":
if let text = evt.data["text"]?.value as? String {
self.streamingAssistantText = text
}
case "tool":
guard let phase = evt.data["phase"]?.value as? String else { return }
guard let name = evt.data["name"]?.value as? String else { return }
guard let toolCallId = evt.data["toolCallId"]?.value as? String else { return }
if phase == "start" {
let args = evt.data["args"]
self.pendingToolCallsById[toolCallId] = ClawdbotChatPendingToolCall(
toolCallId: toolCallId,
name: name,
args: args,
startedAt: evt.ts.map(Double.init) ?? Date().timeIntervalSince1970 * 1000,
isError: nil)
} else if phase == "result" {
self.pendingToolCallsById[toolCallId] = nil
}
default:
break
}
}
private func refreshHistoryAfterRun() async {
do {
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
self.messages = Self.decodeMessages(payload.messages ?? [])
self.sessionId = payload.sessionId
if let level = payload.thinkingLevel, !level.isEmpty {
self.thinkingLevel = level
}
} catch {
chatUILogger.error("refresh history failed \(error.localizedDescription, privacy: .public)")
}
}
private func armPendingRunTimeout(runId: String) {
self.pendingRunTimeoutTasks[runId]?.cancel()
self.pendingRunTimeoutTasks[runId] = Task { [weak self] in
let timeoutMs = await MainActor.run { self?.pendingRunTimeoutMs ?? 0 }
try? await Task.sleep(nanoseconds: timeoutMs * 1_000_000)
await MainActor.run { [weak self] in
guard let self else { return }
guard self.pendingRuns.contains(runId) else { return }
self.clearPendingRun(runId)
self.errorText = "Timed out waiting for a reply; try again or refresh."
}
}
}
private func clearPendingRun(_ runId: String) {
self.pendingRuns.remove(runId)
self.pendingRunTimeoutTasks[runId]?.cancel()
self.pendingRunTimeoutTasks[runId] = nil
}
private func clearPendingRuns(reason: String?) {
for runId in self.pendingRuns {
self.pendingRunTimeoutTasks[runId]?.cancel()
}
self.pendingRunTimeoutTasks.removeAll()
self.pendingRuns.removeAll()
if let reason, !reason.isEmpty {
self.errorText = reason
}
}
private func pollHealthIfNeeded(force: Bool) async {
if !force, let last = self.lastHealthPollAt, Date().timeIntervalSince(last) < 10 {
return
}
self.lastHealthPollAt = Date()
do {
let ok = try await self.transport.requestHealth(timeoutMs: 5000)
self.healthOK = ok
} catch {
self.healthOK = false
}
}
private func loadAttachments(urls: [URL]) async {
for url in urls {
do {
let data = try await Task.detached { try Data(contentsOf: url) }.value
await self.addImageAttachment(
url: url,
data: data,
fileName: url.lastPathComponent,
mimeType: Self.mimeType(for: url) ?? "application/octet-stream")
} catch {
await MainActor.run { self.errorText = error.localizedDescription }
}
}
}
private static func mimeType(for url: URL) -> String? {
let ext = url.pathExtension
guard !ext.isEmpty else { return nil }
return (UTType(filenameExtension: ext) ?? .data).preferredMIMEType
}
private func addImageAttachment(url: URL?, data: Data, fileName: String, mimeType: String) async {
if data.count > 5_000_000 {
self.errorText = "Attachment \(fileName) exceeds 5 MB limit"
return
}
let uti: UTType = {
if let url {
return UTType(filenameExtension: url.pathExtension) ?? .data
}
return UTType(mimeType: mimeType) ?? .data
}()
guard uti.conforms(to: .image) else {
self.errorText = "Only image attachments are supported right now"
return
}
let preview = Self.previewImage(data: data)
self.attachments.append(
ClawdbotPendingAttachment(
url: url,
data: data,
fileName: fileName,
mimeType: mimeType,
preview: preview))
}
private static func previewImage(data: Data) -> ClawdbotPlatformImage? {
#if canImport(AppKit)
NSImage(data: data)
#elseif canImport(UIKit)
UIImage(data: data)
#else
nil
#endif
}
}

View File

@@ -0,0 +1,93 @@
import Foundation
/// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads.
///
/// Marked `@unchecked Sendable` because it can hold reference types.
public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
public let value: Any
public init(_ value: Any) { self.value = value }
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
if container.decodeNil() { self.value = NSNull(); return }
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type")
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self.value {
case let intVal as Int: try container.encode(intVal)
case let doubleVal as Double: try container.encode(doubleVal)
case let boolVal as Bool: try container.encode(boolVal)
case let stringVal as String: try container.encode(stringVal)
case is NSNull: try container.encodeNil()
case let dict as [String: AnyCodable]: try container.encode(dict)
case let array as [AnyCodable]: try container.encode(array)
case let dict as [String: Any]:
try container.encode(dict.mapValues { AnyCodable($0) })
case let array as [Any]:
try container.encode(array.map { AnyCodable($0) })
case let dict as NSDictionary:
var converted: [String: AnyCodable] = [:]
for (k, v) in dict {
guard let key = k as? String else { continue }
converted[key] = AnyCodable(v)
}
try container.encode(converted)
case let array as NSArray:
try container.encode(array.map { AnyCodable($0) })
default:
let context = EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Unsupported type")
throw EncodingError.invalidValue(self.value, context)
}
}
public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool {
switch (lhs.value, rhs.value) {
case let (l as Int, r as Int): l == r
case let (l as Double, r as Double): l == r
case let (l as Bool, r as Bool): l == r
case let (l as String, r as String): l == r
case (_ as NSNull, _ as NSNull): true
case let (l as [String: AnyCodable], r as [String: AnyCodable]): l == r
case let (l as [AnyCodable], r as [AnyCodable]): l == r
default:
false
}
}
public func hash(into hasher: inout Hasher) {
switch self.value {
case let v as Int:
hasher.combine(0); hasher.combine(v)
case let v as Double:
hasher.combine(1); hasher.combine(v)
case let v as Bool:
hasher.combine(2); hasher.combine(v)
case let v as String:
hasher.combine(3); hasher.combine(v)
case _ as NSNull:
hasher.combine(4)
case let v as [String: AnyCodable]:
hasher.combine(5)
for (k, val) in v.sorted(by: { $0.key < $1.key }) {
hasher.combine(k)
hasher.combine(val)
}
case let v as [AnyCodable]:
hasher.combine(6)
for item in v {
hasher.combine(item)
}
default:
hasher.combine(999)
}
}
}

View File

@@ -0,0 +1,16 @@
import Foundation
@MainActor
public protocol StreamingAudioPlaying {
func play(stream: AsyncThrowingStream<Data, Error>) async -> StreamingPlaybackResult
func stop() -> Double?
}
@MainActor
public protocol PCMStreamingAudioPlaying {
func play(stream: AsyncThrowingStream<Data, Error>, sampleRate: Double) async -> StreamingPlaybackResult
func stop() -> Double?
}
extension StreamingAudioPlayer: StreamingAudioPlaying {}
extension PCMStreamingAudioPlayer: PCMStreamingAudioPlaying {}

View File

@@ -0,0 +1,33 @@
import Foundation
public enum BonjourEscapes {
/// mDNS / DNS-SD commonly escapes bytes in instance names as `\DDD` (decimal-encoded),
/// e.g. spaces are `\032`.
public static func decode(_ input: String) -> String {
var out = ""
var i = input.startIndex
while i < input.endIndex {
if input[i] == "\\",
let d0 = input.index(i, offsetBy: 1, limitedBy: input.index(before: input.endIndex)),
let d1 = input.index(i, offsetBy: 2, limitedBy: input.index(before: input.endIndex)),
let d2 = input.index(i, offsetBy: 3, limitedBy: input.index(before: input.endIndex)),
input[d0].isNumber,
input[d1].isNumber,
input[d2].isNumber
{
let digits = String(input[d0...d2])
if let value = Int(digits),
let scalar = UnicodeScalar(value)
{
out.append(Character(scalar))
i = input.index(i, offsetBy: 4)
continue
}
}
out.append(input[i])
i = input.index(after: i)
}
return out
}
}

View File

@@ -0,0 +1,27 @@
import Foundation
public enum ClawdbotBonjour {
// v0: internal-only, subject to rename.
public static let bridgeServiceType = "_clawdbot-bridge._tcp"
public static let bridgeServiceDomain = "local."
public static let wideAreaBridgeServiceDomain = "clawdbot.internal."
public static let bridgeServiceDomains = [
bridgeServiceDomain,
wideAreaBridgeServiceDomain,
]
public static func normalizeServiceDomain(_ raw: String?) -> String {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return self.bridgeServiceDomain
}
let lower = trimmed.lowercased()
if lower == "local" || lower == "local." {
return self.bridgeServiceDomain
}
return lower.hasSuffix(".") ? lower : (lower + ".")
}
}

View File

@@ -0,0 +1,242 @@
import Foundation
public struct BridgeBaseFrame: Codable, Sendable {
public let type: String
public init(type: String) {
self.type = type
}
}
public struct BridgeInvokeRequest: Codable, Sendable {
public let type: String
public let id: String
public let command: String
public let paramsJSON: String?
public init(type: String = "invoke", id: String, command: String, paramsJSON: String? = nil) {
self.type = type
self.id = id
self.command = command
self.paramsJSON = paramsJSON
}
}
public struct BridgeInvokeResponse: Codable, Sendable {
public let type: String
public let id: String
public let ok: Bool
public let payloadJSON: String?
public let error: ClawdbotNodeError?
public init(
type: String = "invoke-res",
id: String,
ok: Bool,
payloadJSON: String? = nil,
error: ClawdbotNodeError? = nil)
{
self.type = type
self.id = id
self.ok = ok
self.payloadJSON = payloadJSON
self.error = error
}
}
public struct BridgeEventFrame: Codable, Sendable {
public let type: String
public let event: String
public let payloadJSON: String?
public init(type: String = "event", event: String, payloadJSON: String? = nil) {
self.type = type
self.event = event
self.payloadJSON = payloadJSON
}
}
public struct BridgeHello: Codable, Sendable {
public let type: String
public let nodeId: String
public let displayName: String?
public let token: String?
public let platform: String?
public let version: String?
public let deviceFamily: String?
public let modelIdentifier: String?
public let caps: [String]?
public let commands: [String]?
public let permissions: [String: Bool]?
public init(
type: String = "hello",
nodeId: String,
displayName: String?,
token: String?,
platform: String?,
version: String?,
deviceFamily: String? = nil,
modelIdentifier: String? = nil,
caps: [String]? = nil,
commands: [String]? = nil,
permissions: [String: Bool]? = nil)
{
self.type = type
self.nodeId = nodeId
self.displayName = displayName
self.token = token
self.platform = platform
self.version = version
self.deviceFamily = deviceFamily
self.modelIdentifier = modelIdentifier
self.caps = caps
self.commands = commands
self.permissions = permissions
}
}
public struct BridgeHelloOk: Codable, Sendable {
public let type: String
public let serverName: String
public let canvasHostUrl: String?
public init(type: String = "hello-ok", serverName: String, canvasHostUrl: String? = nil) {
self.type = type
self.serverName = serverName
self.canvasHostUrl = canvasHostUrl
}
}
public struct BridgePairRequest: Codable, Sendable {
public let type: String
public let nodeId: String
public let displayName: String?
public let platform: String?
public let version: String?
public let deviceFamily: String?
public let modelIdentifier: String?
public let caps: [String]?
public let commands: [String]?
public let permissions: [String: Bool]?
public let remoteAddress: String?
public let silent: Bool?
public init(
type: String = "pair-request",
nodeId: String,
displayName: String?,
platform: String?,
version: String?,
deviceFamily: String? = nil,
modelIdentifier: String? = nil,
caps: [String]? = nil,
commands: [String]? = nil,
permissions: [String: Bool]? = nil,
remoteAddress: String? = nil,
silent: Bool? = nil)
{
self.type = type
self.nodeId = nodeId
self.displayName = displayName
self.platform = platform
self.version = version
self.deviceFamily = deviceFamily
self.modelIdentifier = modelIdentifier
self.caps = caps
self.commands = commands
self.permissions = permissions
self.remoteAddress = remoteAddress
self.silent = silent
}
}
public struct BridgePairOk: Codable, Sendable {
public let type: String
public let token: String
public init(type: String = "pair-ok", token: String) {
self.type = type
self.token = token
}
}
public struct BridgePing: Codable, Sendable {
public let type: String
public let id: String
public init(type: String = "ping", id: String) {
self.type = type
self.id = id
}
}
public struct BridgePong: Codable, Sendable {
public let type: String
public let id: String
public init(type: String = "pong", id: String) {
self.type = type
self.id = id
}
}
public struct BridgeErrorFrame: Codable, Sendable {
public let type: String
public let code: String
public let message: String
public init(type: String = "error", code: String, message: String) {
self.type = type
self.code = code
self.message = message
}
}
// MARK: - Optional RPC (node -> bridge)
public struct BridgeRPCRequest: Codable, Sendable {
public let type: String
public let id: String
public let method: String
public let paramsJSON: String?
public init(type: String = "req", id: String, method: String, paramsJSON: String? = nil) {
self.type = type
self.id = id
self.method = method
self.paramsJSON = paramsJSON
}
}
public struct BridgeRPCError: Codable, Sendable, Equatable {
public let code: String
public let message: String
public init(code: String, message: String) {
self.code = code
self.message = message
}
}
public struct BridgeRPCResponse: Codable, Sendable {
public let type: String
public let id: String
public let ok: Bool
public let payloadJSON: String?
public let error: BridgeRPCError?
public init(
type: String = "res",
id: String,
ok: Bool,
payloadJSON: String? = nil,
error: BridgeRPCError? = nil)
{
self.type = type
self.id = id
self.ok = ok
self.payloadJSON = payloadJSON
self.error = error
}
}

View File

@@ -0,0 +1,68 @@
import Foundation
public enum ClawdbotCameraCommand: String, Codable, Sendable {
case list = "camera.list"
case snap = "camera.snap"
case clip = "camera.clip"
}
public enum ClawdbotCameraFacing: String, Codable, Sendable {
case back
case front
}
public enum ClawdbotCameraImageFormat: String, Codable, Sendable {
case jpg
case jpeg
}
public enum ClawdbotCameraVideoFormat: String, Codable, Sendable {
case mp4
}
public struct ClawdbotCameraSnapParams: Codable, Sendable, Equatable {
public var facing: ClawdbotCameraFacing?
public var maxWidth: Int?
public var quality: Double?
public var format: ClawdbotCameraImageFormat?
public var deviceId: String?
public var delayMs: Int?
public init(
facing: ClawdbotCameraFacing? = nil,
maxWidth: Int? = nil,
quality: Double? = nil,
format: ClawdbotCameraImageFormat? = nil,
deviceId: String? = nil,
delayMs: Int? = nil)
{
self.facing = facing
self.maxWidth = maxWidth
self.quality = quality
self.format = format
self.deviceId = deviceId
self.delayMs = delayMs
}
}
public struct ClawdbotCameraClipParams: Codable, Sendable, Equatable {
public var facing: ClawdbotCameraFacing?
public var durationMs: Int?
public var includeAudio: Bool?
public var format: ClawdbotCameraVideoFormat?
public var deviceId: String?
public init(
facing: ClawdbotCameraFacing? = nil,
durationMs: Int? = nil,
includeAudio: Bool? = nil,
format: ClawdbotCameraVideoFormat? = nil,
deviceId: String? = nil)
{
self.facing = facing
self.durationMs = durationMs
self.includeAudio = includeAudio
self.format = format
self.deviceId = deviceId
}
}

View File

@@ -0,0 +1,99 @@
import Foundation
public enum ClawdbotCanvasA2UIAction: Sendable {
public struct AgentMessageContext: Sendable {
public struct Session: Sendable {
public var key: String
public var surfaceId: String
public init(key: String, surfaceId: String) {
self.key = key
self.surfaceId = surfaceId
}
}
public struct Component: Sendable {
public var id: String
public var host: String
public var instanceId: String
public init(id: String, host: String, instanceId: String) {
self.id = id
self.host = host
self.instanceId = instanceId
}
}
public var actionName: String
public var session: Session
public var component: Component
public var contextJSON: String?
public init(actionName: String, session: Session, component: Component, contextJSON: String?) {
self.actionName = actionName
self.session = session
self.component = component
self.contextJSON = contextJSON
}
}
public static func extractActionName(_ userAction: [String: Any]) -> String? {
let keys = ["name", "action"]
for key in keys {
if let raw = userAction[key] as? String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
}
}
return nil
}
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(_ context: AgentMessageContext) -> String {
let ctxSuffix = context.contextJSON.flatMap { $0.isEmpty ? nil : " ctx=\($0)" } ?? ""
return [
"CANVAS_A2UI",
"action=\(self.sanitizeTagValue(context.actionName))",
"session=\(self.sanitizeTagValue(context.session.key))",
"surface=\(self.sanitizeTagValue(context.session.surfaceId))",
"component=\(self.sanitizeTagValue(context.component.id))",
"host=\(self.sanitizeTagValue(context.component.host))",
"instance=\(self.sanitizeTagValue(context.component.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('clawdbot:a2ui-action-status', { detail: \(json) }));"
}
}

View File

@@ -0,0 +1,26 @@
import Foundation
public enum ClawdbotCanvasA2UICommand: String, Codable, Sendable {
/// Render A2UI content on the device canvas.
case push = "canvas.a2ui.push"
/// Legacy alias for `push` when sending JSONL.
case pushJSONL = "canvas.a2ui.pushJSONL"
/// Reset the A2UI renderer state.
case reset = "canvas.a2ui.reset"
}
public struct ClawdbotCanvasA2UIPushParams: Codable, Sendable, Equatable {
public var messages: [AnyCodable]
public init(messages: [AnyCodable]) {
self.messages = messages
}
}
public struct ClawdbotCanvasA2UIPushJSONLParams: Codable, Sendable, Equatable {
public var jsonl: String
public init(jsonl: String) {
self.jsonl = jsonl
}
}

View File

@@ -0,0 +1,81 @@
import Foundation
public enum ClawdbotCanvasA2UIJSONL: Sendable {
public struct ParsedItem: Sendable {
public var lineNumber: Int
public var message: AnyCodable
public init(lineNumber: Int, message: AnyCodable) {
self.lineNumber = lineNumber
self.message = message
}
}
public static func parse(_ text: String) throws -> [ParsedItem] {
var out: [ParsedItem] = []
var lineNumber = 0
for rawLine in text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) {
lineNumber += 1
let line = String(rawLine).trimmingCharacters(in: .whitespacesAndNewlines)
if line.isEmpty { continue }
let data = Data(line.utf8)
let decoded = try JSONDecoder().decode(AnyCodable.self, from: data)
out.append(ParsedItem(lineNumber: lineNumber, message: decoded))
}
return out
}
public static func validateV0_8(_ items: [ParsedItem]) throws {
let allowed = Set([
"beginRendering",
"surfaceUpdate",
"dataModelUpdate",
"deleteSurface",
])
for item in items {
guard let dict = item.message.value as? [String: AnyCodable] else {
throw NSError(domain: "A2UI", code: 1, userInfo: [
NSLocalizedDescriptionKey: "A2UI JSONL line \(item.lineNumber): expected a JSON object",
])
}
if dict.keys.contains("createSurface") {
throw NSError(domain: "A2UI", code: 2, userInfo: [
NSLocalizedDescriptionKey: """
A2UI JSONL line \(item.lineNumber): looks like A2UI v0.9 (`createSurface`).
Canvas currently supports A2UI v0.8 server→client messages
(`beginRendering`, `surfaceUpdate`, `dataModelUpdate`, `deleteSurface`).
""",
])
}
let matched = dict.keys.filter { allowed.contains($0) }
if matched.count != 1 {
let found = dict.keys.sorted().joined(separator: ", ")
throw NSError(domain: "A2UI", code: 3, userInfo: [
NSLocalizedDescriptionKey: """
A2UI JSONL line \(item.lineNumber): expected exactly one of \(allowed.sorted()
.joined(separator: ", ")); found: \(found)
""",
])
}
}
}
public static func decodeMessagesFromJSONL(_ text: String) throws -> [AnyCodable] {
let items = try self.parse(text)
try self.validateV0_8(items)
return items.map(\.message)
}
public static func encodeMessagesJSONArray(_ messages: [AnyCodable]) throws -> String {
let data = try JSONEncoder().encode(messages)
guard let json = String(data: data, encoding: .utf8) else {
throw NSError(domain: "A2UI", code: 10, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode messages payload as UTF-8",
])
}
return json
}
}

View File

@@ -0,0 +1,76 @@
import Foundation
public struct ClawdbotCanvasNavigateParams: Codable, Sendable, Equatable {
public var url: String
public init(url: String) {
self.url = url
}
}
public struct ClawdbotCanvasPlacement: Codable, Sendable, Equatable {
public var x: Double?
public var y: Double?
public var width: Double?
public var height: Double?
public init(x: Double? = nil, y: Double? = nil, width: Double? = nil, height: Double? = nil) {
self.x = x
self.y = y
self.width = width
self.height = height
}
}
public struct ClawdbotCanvasPresentParams: Codable, Sendable, Equatable {
public var url: String?
public var placement: ClawdbotCanvasPlacement?
public init(url: String? = nil, placement: ClawdbotCanvasPlacement? = nil) {
self.url = url
self.placement = placement
}
}
public struct ClawdbotCanvasEvalParams: Codable, Sendable, Equatable {
public var javaScript: String
public init(javaScript: String) {
self.javaScript = javaScript
}
}
public enum ClawdbotCanvasSnapshotFormat: String, Codable, Sendable {
case png
case jpeg
public init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
let raw = try c.decode(String.self).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
switch raw {
case "png":
self = .png
case "jpeg", "jpg":
self = .jpeg
default:
throw DecodingError.dataCorruptedError(in: c, debugDescription: "Invalid snapshot format: \(raw)")
}
}
public func encode(to encoder: Encoder) throws {
var c = encoder.singleValueContainer()
try c.encode(self.rawValue)
}
}
public struct ClawdbotCanvasSnapshotParams: Codable, Sendable, Equatable {
public var maxWidth: Int?
public var quality: Double?
public var format: ClawdbotCanvasSnapshotFormat?
public init(maxWidth: Int? = nil, quality: Double? = nil, format: ClawdbotCanvasSnapshotFormat? = nil) {
self.maxWidth = maxWidth
self.quality = quality
self.format = format
}
}

View File

@@ -0,0 +1,9 @@
import Foundation
public enum ClawdbotCanvasCommand: String, Codable, Sendable {
case present = "canvas.present"
case hide = "canvas.hide"
case navigate = "canvas.navigate"
case evalJS = "canvas.eval"
case snapshot = "canvas.snapshot"
}

View File

@@ -0,0 +1,9 @@
import Foundation
public enum ClawdbotCapability: String, Codable, Sendable {
case canvas
case camera
case screen
case voiceWake
case location
}

View File

@@ -0,0 +1,5 @@
import Foundation
public enum ClawdbotKitResources {
public static let bundle: Bundle = .module
}

View File

@@ -0,0 +1,72 @@
import Foundation
public enum DeepLinkRoute: Sendable, Equatable {
case agent(AgentDeepLink)
}
public struct AgentDeepLink: Codable, Sendable, Equatable {
public let message: String
public let sessionKey: String?
public let thinking: String?
public let deliver: Bool
public let to: String?
public let channel: String?
public let timeoutSeconds: Int?
public let key: String?
public init(
message: String,
sessionKey: String?,
thinking: String?,
deliver: Bool,
to: String?,
channel: String?,
timeoutSeconds: Int?,
key: String?)
{
self.message = message
self.sessionKey = sessionKey
self.thinking = thinking
self.deliver = deliver
self.to = to
self.channel = channel
self.timeoutSeconds = timeoutSeconds
self.key = key
}
}
public enum DeepLinkParser {
public static func parse(_ url: URL) -> DeepLinkRoute? {
guard url.scheme?.lowercased() == "clawdbot" else { return nil }
guard let host = url.host?.lowercased(), !host.isEmpty else { return nil }
guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
let query = (comps.queryItems ?? []).reduce(into: [String: String]()) { dict, item in
guard let value = item.value else { return }
dict[item.name] = value
}
switch host {
case "agent":
guard let message = query["message"],
!message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
return nil
}
let deliver = (query["deliver"] as NSString?)?.boolValue ?? false
let timeoutSeconds = query["timeoutSeconds"].flatMap { Int($0) }.flatMap { $0 >= 0 ? $0 : nil }
return .agent(
.init(
message: message,
sessionKey: query["sessionKey"],
thinking: query["thinking"],
deliver: deliver,
to: query["to"],
channel: query["channel"],
timeoutSeconds: timeoutSeconds,
key: query["key"]))
default:
return nil
}
}
}

View File

@@ -0,0 +1,9 @@
@_exported import ElevenLabsKit
public typealias ElevenLabsVoice = ElevenLabsKit.ElevenLabsVoice
public typealias ElevenLabsTTSRequest = ElevenLabsKit.ElevenLabsTTSRequest
public typealias ElevenLabsTTSClient = ElevenLabsKit.ElevenLabsTTSClient
public typealias TalkTTSValidation = ElevenLabsKit.TalkTTSValidation
public typealias StreamingAudioPlayer = ElevenLabsKit.StreamingAudioPlayer
public typealias PCMStreamingAudioPlayer = ElevenLabsKit.PCMStreamingAudioPlayer
public typealias StreamingPlaybackResult = ElevenLabsKit.StreamingPlaybackResult

View File

@@ -0,0 +1,135 @@
import CoreGraphics
import Foundation
import ImageIO
import UniformTypeIdentifiers
public enum JPEGTranscodeError: LocalizedError, Sendable {
case decodeFailed
case propertiesMissing
case encodeFailed
case sizeLimitExceeded(maxBytes: Int, actualBytes: Int)
public var errorDescription: String? {
switch self {
case .decodeFailed:
"Failed to decode image data"
case .propertiesMissing:
"Failed to read image properties"
case .encodeFailed:
"Failed to encode JPEG"
case let .sizeLimitExceeded(maxBytes, actualBytes):
"JPEG exceeds size limit (\(actualBytes) bytes > \(maxBytes) bytes)"
}
}
}
public struct JPEGTranscoder: Sendable {
public static func clampQuality(_ quality: Double) -> Double {
min(1.0, max(0.05, quality))
}
/// Re-encodes image data to JPEG, optionally downscaling so that the *oriented* pixel width is <= `maxWidthPx`.
///
/// - Important: This normalizes EXIF orientation (the output pixels are rotated if needed; orientation tag is not
/// relied on).
public static func transcodeToJPEG(
imageData: Data,
maxWidthPx: Int?,
quality: Double,
maxBytes: Int? = nil) throws -> (data: Data, widthPx: Int, heightPx: Int)
{
guard let src = CGImageSourceCreateWithData(imageData as CFData, nil) else {
throw JPEGTranscodeError.decodeFailed
}
guard
let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any],
let rawWidth = props[kCGImagePropertyPixelWidth] as? NSNumber,
let rawHeight = props[kCGImagePropertyPixelHeight] as? NSNumber
else {
throw JPEGTranscodeError.propertiesMissing
}
let pixelWidth = rawWidth.intValue
let pixelHeight = rawHeight.intValue
let orientation = (props[kCGImagePropertyOrientation] as? NSNumber)?.intValue ?? 1
guard pixelWidth > 0, pixelHeight > 0 else {
throw JPEGTranscodeError.propertiesMissing
}
let rotates90 = orientation == 5 || orientation == 6 || orientation == 7 || orientation == 8
let orientedWidth = rotates90 ? pixelHeight : pixelWidth
let orientedHeight = rotates90 ? pixelWidth : pixelHeight
let maxDim = max(orientedWidth, orientedHeight)
var targetMaxPixelSize: Int = {
guard let maxWidthPx, maxWidthPx > 0 else { return maxDim }
guard orientedWidth > maxWidthPx else { return maxDim } // never upscale
let scale = Double(maxWidthPx) / Double(orientedWidth)
return max(1, Int((Double(maxDim) * scale).rounded(.toNearestOrAwayFromZero)))
}()
func encode(maxPixelSize: Int, quality: Double) throws -> (data: Data, widthPx: Int, heightPx: Int) {
let thumbOpts: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
kCGImageSourceShouldCacheImmediately: true,
]
guard let img = CGImageSourceCreateThumbnailAtIndex(src, 0, thumbOpts as CFDictionary) else {
throw JPEGTranscodeError.decodeFailed
}
let out = NSMutableData()
guard let dest = CGImageDestinationCreateWithData(out, UTType.jpeg.identifier as CFString, 1, nil) else {
throw JPEGTranscodeError.encodeFailed
}
let q = self.clampQuality(quality)
let encodeProps = [kCGImageDestinationLossyCompressionQuality: q] as CFDictionary
CGImageDestinationAddImage(dest, img, encodeProps)
guard CGImageDestinationFinalize(dest) else {
throw JPEGTranscodeError.encodeFailed
}
return (out as Data, img.width, img.height)
}
guard let maxBytes, maxBytes > 0 else {
return try encode(maxPixelSize: targetMaxPixelSize, quality: quality)
}
let minQuality = max(0.2, self.clampQuality(quality) * 0.35)
let minPixelSize = 256
var best = try encode(maxPixelSize: targetMaxPixelSize, quality: quality)
if best.data.count <= maxBytes {
return best
}
for _ in 0..<6 {
var q = self.clampQuality(quality)
for _ in 0..<6 {
let candidate = try encode(maxPixelSize: targetMaxPixelSize, quality: q)
best = candidate
if candidate.data.count <= maxBytes {
return candidate
}
if q <= minQuality { break }
q = max(minQuality, q * 0.75)
}
let nextPixelSize = max(Int(Double(targetMaxPixelSize) * 0.85), minPixelSize)
if nextPixelSize == targetMaxPixelSize {
break
}
targetMaxPixelSize = nextPixelSize
}
if best.data.count > maxBytes {
throw JPEGTranscodeError.sizeLimitExceeded(maxBytes: maxBytes, actualBytes: best.data.count)
}
return best
}
}

View File

@@ -0,0 +1,57 @@
import Foundation
public enum ClawdbotLocationCommand: String, Codable, Sendable {
case get = "location.get"
}
public enum ClawdbotLocationAccuracy: String, Codable, Sendable {
case coarse
case balanced
case precise
}
public struct ClawdbotLocationGetParams: Codable, Sendable, Equatable {
public var timeoutMs: Int?
public var maxAgeMs: Int?
public var desiredAccuracy: ClawdbotLocationAccuracy?
public init(timeoutMs: Int? = nil, maxAgeMs: Int? = nil, desiredAccuracy: ClawdbotLocationAccuracy? = nil) {
self.timeoutMs = timeoutMs
self.maxAgeMs = maxAgeMs
self.desiredAccuracy = desiredAccuracy
}
}
public struct ClawdbotLocationPayload: Codable, Sendable, Equatable {
public var lat: Double
public var lon: Double
public var accuracyMeters: Double
public var altitudeMeters: Double?
public var speedMps: Double?
public var headingDeg: Double?
public var timestamp: String
public var isPrecise: Bool
public var source: String?
public init(
lat: Double,
lon: Double,
accuracyMeters: Double,
altitudeMeters: Double? = nil,
speedMps: Double? = nil,
headingDeg: Double? = nil,
timestamp: String,
isPrecise: Bool,
source: String? = nil)
{
self.lat = lat
self.lon = lon
self.accuracyMeters = accuracyMeters
self.altitudeMeters = altitudeMeters
self.speedMps = speedMps
self.headingDeg = headingDeg
self.timestamp = timestamp
self.isPrecise = isPrecise
self.source = source
}
}

View File

@@ -0,0 +1,7 @@
import Foundation
public enum ClawdbotLocationMode: String, Codable, Sendable, CaseIterable {
case off
case whileUsing
case always
}

View File

@@ -0,0 +1,28 @@
import Foundation
public enum ClawdbotNodeErrorCode: String, Codable, Sendable {
case notPaired = "NOT_PAIRED"
case unauthorized = "UNAUTHORIZED"
case backgroundUnavailable = "NODE_BACKGROUND_UNAVAILABLE"
case invalidRequest = "INVALID_REQUEST"
case unavailable = "UNAVAILABLE"
}
public struct ClawdbotNodeError: Error, Codable, Sendable, Equatable {
public var code: ClawdbotNodeErrorCode
public var message: String
public var retryable: Bool?
public var retryAfterMs: Int?
public init(
code: ClawdbotNodeErrorCode,
message: String,
retryable: Bool? = nil,
retryAfterMs: Int? = nil)
{
self.code = code
self.message = message
self.retryable = retryable
self.retryAfterMs = retryAfterMs
}
}

View File

@@ -0,0 +1,224 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Canvas</title>
<script>
(() => {
try {
const params = new URLSearchParams(window.location.search);
const platform = (params.get('platform') || '').trim().toLowerCase();
if (platform) {
document.documentElement.dataset.platform = platform;
return;
}
if (/android/i.test(navigator.userAgent || '')) {
document.documentElement.dataset.platform = 'android';
}
} catch (_) {}
})();
</script>
<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;
}
:root[data-platform="android"] body {
background:
radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.62), rgba(0,0,0,0) 55%),
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.52), rgba(0,0,0,0) 60%),
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.48), rgba(0,0,0,0) 60%),
#0b1328;
}
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: clawdbot-grid-drift 140s ease-in-out infinite alternate;
}
:root[data-platform="android"] body::before { opacity: 0.80; }
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: clawdbot-glow-drift 110s ease-in-out infinite alternate;
}
:root[data-platform="android"] body::after { opacity: 0.85; }
@supports (mix-blend-mode: screen) {
body::after { mix-blend-mode: screen; }
}
@supports not (mix-blend-mode: screen) {
body::after { opacity: 0.70; }
}
@keyframes clawdbot-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 clawdbot-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 {
position: fixed;
inset: 0;
display:block;
width:100vw;
height:100vh;
touch-action: none;
z-index: 1;
}
:root[data-platform="android"] #clawdbot-canvas {
background:
radial-gradient(1100px 800px at 20% 15%, rgba(42, 113, 255, 0.78), rgba(0,0,0,0) 58%),
radial-gradient(900px 650px at 82% 28%, rgba(255, 0, 138, 0.66), rgba(0,0,0,0) 62%),
radial-gradient(1000px 900px at 60% 88%, rgba(0, 209, 255, 0.58), rgba(0,0,0,0) 62%),
#141c33;
}
#clawdbot-status {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: flex-start;
flex-direction: column;
padding-top: calc(20px + env(safe-area-inset-top, 0px));
pointer-events: none;
z-index: 3;
}
#clawdbot-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);
}
#clawdbot-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);
}
#clawdbot-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="clawdbot-canvas"></canvas>
<div id="clawdbot-status">
<div class="card">
<div class="title" id="clawdbot-status-title">Ready</div>
<div class="subtitle" id="clawdbot-status-subtitle">Waiting for agent</div>
</div>
</div>
<script>
(() => {
const canvas = document.getElementById('clawdbot-canvas');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('clawdbot-status');
const titleEl = document.getElementById('clawdbot-status-title');
const subtitleEl = document.getElementById('clawdbot-status-subtitle');
const debugStatusEnabledByQuery = (() => {
try {
const params = new URLSearchParams(window.location.search);
const raw = params.get('debugStatus') ?? params.get('debug');
if (!raw) return false;
const normalized = String(raw).trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes';
} catch (_) {
return false;
}
})();
let debugStatusEnabled = debugStatusEnabledByQuery;
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();
const setDebugStatusEnabled = (enabled) => {
debugStatusEnabled = !!enabled;
if (!statusEl) return;
if (!debugStatusEnabled) {
statusEl.style.display = 'none';
}
};
if (statusEl && !debugStatusEnabled) {
statusEl.style.display = 'none';
}
window.__clawdbot = {
canvas,
ctx,
setDebugStatusEnabled,
setStatus: (title, subtitle) => {
if (!statusEl || !debugStatusEnabled) return;
if (!title && !subtitle) {
statusEl.style.display = 'none';
return;
}
statusEl.style.display = 'flex';
if (titleEl && typeof title === 'string') titleEl.textContent = title;
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
if (!debugStatusEnabled) {
clearTimeout(window.__statusTimeout);
window.__statusTimeout = setTimeout(() => {
statusEl.style.display = 'none';
}, 3000);
} else {
clearTimeout(window.__statusTimeout);
}
}
};
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,27 @@
import Foundation
public enum ClawdbotScreenCommand: String, Codable, Sendable {
case record = "screen.record"
}
public struct ClawdbotScreenRecordParams: Codable, Sendable, Equatable {
public var screenIndex: Int?
public var durationMs: Int?
public var fps: Double?
public var format: String?
public var includeAudio: Bool?
public init(
screenIndex: Int? = nil,
durationMs: Int? = nil,
fps: Double? = nil,
format: String? = nil,
includeAudio: Bool? = nil)
{
self.screenIndex = screenIndex
self.durationMs = durationMs
self.fps = fps
self.format = format
self.includeAudio = includeAudio
}
}

View File

@@ -0,0 +1,37 @@
import Foundation
public enum ClawdbotNodeStorage {
public static func appSupportDir() throws -> URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
guard let base else {
throw NSError(domain: "ClawdbotNodeStorage", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Application Support directory unavailable",
])
}
return base.appendingPathComponent("Clawdbot", isDirectory: true)
}
public static func canvasRoot(sessionKey: String) throws -> URL {
let root = try appSupportDir().appendingPathComponent("canvas", isDirectory: true)
let safe = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let session = safe.isEmpty ? "main" : safe
return root.appendingPathComponent(session, isDirectory: true)
}
public static func cachesDir() throws -> URL {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
guard let base else {
throw NSError(domain: "ClawdbotNodeStorage", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Caches directory unavailable",
])
}
return base.appendingPathComponent("Clawdbot", isDirectory: true)
}
public static func canvasSnapshotsRoot(sessionKey: String) throws -> URL {
let root = try cachesDir().appendingPathComponent("canvas-snapshots", isDirectory: true)
let safe = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let session = safe.isEmpty ? "main" : safe
return root.appendingPathComponent(session, isDirectory: true)
}
}

View File

@@ -0,0 +1,62 @@
import Foundation
public enum ClawdbotSystemCommand: String, Codable, Sendable {
case run = "system.run"
case notify = "system.notify"
}
public enum ClawdbotNotificationPriority: String, Codable, Sendable {
case passive
case active
case timeSensitive
}
public enum ClawdbotNotificationDelivery: String, Codable, Sendable {
case system
case overlay
case auto
}
public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
public var command: [String]
public var cwd: String?
public var env: [String: String]?
public var timeoutMs: Int?
public var needsScreenRecording: Bool?
public init(
command: [String],
cwd: String? = nil,
env: [String: String]? = nil,
timeoutMs: Int? = nil,
needsScreenRecording: Bool? = nil)
{
self.command = command
self.cwd = cwd
self.env = env
self.timeoutMs = timeoutMs
self.needsScreenRecording = needsScreenRecording
}
}
public struct ClawdbotSystemNotifyParams: Codable, Sendable, Equatable {
public var title: String
public var body: String
public var sound: String?
public var priority: ClawdbotNotificationPriority?
public var delivery: ClawdbotNotificationDelivery?
public init(
title: String,
body: String,
sound: String? = nil,
priority: ClawdbotNotificationPriority? = nil,
delivery: ClawdbotNotificationDelivery? = nil)
{
self.title = title
self.body = body
self.sound = sound
self.priority = priority
self.delivery = delivery
}
}

View File

@@ -0,0 +1,201 @@
import Foundation
public struct TalkDirective: Equatable, Sendable {
public var voiceId: String?
public var modelId: String?
public var speed: Double?
public var rateWPM: Int?
public var stability: Double?
public var similarity: Double?
public var style: Double?
public var speakerBoost: Bool?
public var seed: Int?
public var normalize: String?
public var language: String?
public var outputFormat: String?
public var latencyTier: Int?
public var once: Bool?
public init(
voiceId: String? = nil,
modelId: String? = nil,
speed: Double? = nil,
rateWPM: Int? = nil,
stability: Double? = nil,
similarity: Double? = nil,
style: Double? = nil,
speakerBoost: Bool? = nil,
seed: Int? = nil,
normalize: String? = nil,
language: String? = nil,
outputFormat: String? = nil,
latencyTier: Int? = nil,
once: Bool? = nil)
{
self.voiceId = voiceId
self.modelId = modelId
self.speed = speed
self.rateWPM = rateWPM
self.stability = stability
self.similarity = similarity
self.style = style
self.speakerBoost = speakerBoost
self.seed = seed
self.normalize = normalize
self.language = language
self.outputFormat = outputFormat
self.latencyTier = latencyTier
self.once = once
}
}
public struct TalkDirectiveParseResult: Equatable, Sendable {
public let directive: TalkDirective?
public let stripped: String
public let unknownKeys: [String]
public init(directive: TalkDirective?, stripped: String, unknownKeys: [String]) {
self.directive = directive
self.stripped = stripped
self.unknownKeys = unknownKeys
}
}
public enum TalkDirectiveParser {
public static func parse(_ text: String) -> TalkDirectiveParseResult {
let normalized = text.replacingOccurrences(of: "\r\n", with: "\n")
var lines = normalized.split(separator: "\n", omittingEmptySubsequences: false)
guard !lines.isEmpty else { return TalkDirectiveParseResult(directive: nil, stripped: text, unknownKeys: []) }
guard let firstNonEmptyIndex =
lines.firstIndex(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
else {
return TalkDirectiveParseResult(directive: nil, stripped: text, unknownKeys: [])
}
var firstNonEmpty = firstNonEmptyIndex
if firstNonEmpty > 0 {
lines.removeSubrange(0..<firstNonEmpty)
firstNonEmpty = 0
}
let head = lines[firstNonEmpty].trimmingCharacters(in: .whitespacesAndNewlines)
guard head.hasPrefix("{"), head.hasSuffix("}") else {
return TalkDirectiveParseResult(directive: nil, stripped: text, unknownKeys: [])
}
guard let data = head.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else {
return TalkDirectiveParseResult(directive: nil, stripped: text, unknownKeys: [])
}
let speakerBoost = self.boolValue(json, keys: ["speaker_boost", "speakerBoost"])
?? self.boolValue(json, keys: ["no_speaker_boost", "noSpeakerBoost"]).map { !$0 }
let directive = TalkDirective(
voiceId: stringValue(json, keys: ["voice", "voice_id", "voiceId"]),
modelId: stringValue(json, keys: ["model", "model_id", "modelId"]),
speed: doubleValue(json, keys: ["speed"]),
rateWPM: intValue(json, keys: ["rate", "wpm"]),
stability: doubleValue(json, keys: ["stability"]),
similarity: doubleValue(json, keys: ["similarity", "similarity_boost", "similarityBoost"]),
style: doubleValue(json, keys: ["style"]),
speakerBoost: speakerBoost,
seed: intValue(json, keys: ["seed"]),
normalize: stringValue(json, keys: ["normalize", "apply_text_normalization"]),
language: stringValue(json, keys: ["lang", "language_code", "language"]),
outputFormat: stringValue(json, keys: ["output_format", "format"]),
latencyTier: intValue(json, keys: ["latency", "latency_tier", "latencyTier"]),
once: boolValue(json, keys: ["once"]))
let hasDirective = [
directive.voiceId,
directive.modelId,
directive.speed.map { "\($0)" },
directive.rateWPM.map { "\($0)" },
directive.stability.map { "\($0)" },
directive.similarity.map { "\($0)" },
directive.style.map { "\($0)" },
directive.speakerBoost.map { "\($0)" },
directive.seed.map { "\($0)" },
directive.normalize,
directive.language,
directive.outputFormat,
directive.latencyTier.map { "\($0)" },
directive.once.map { "\($0)" },
].contains { $0 != nil }
guard hasDirective else {
return TalkDirectiveParseResult(directive: nil, stripped: text, unknownKeys: [])
}
let knownKeys = Set([
"voice", "voice_id", "voiceid",
"model", "model_id", "modelid",
"speed", "rate", "wpm",
"stability", "similarity", "similarity_boost", "similarityboost",
"style",
"speaker_boost", "speakerboost",
"no_speaker_boost", "nospeakerboost",
"seed",
"normalize", "apply_text_normalization",
"lang", "language_code", "language",
"output_format", "format",
"latency", "latency_tier", "latencytier",
"once",
])
let unknownKeys = json.keys.filter { !knownKeys.contains($0.lowercased()) }.sorted()
lines.remove(at: firstNonEmpty)
if firstNonEmpty < lines.count {
let next = lines[firstNonEmpty].trimmingCharacters(in: .whitespacesAndNewlines)
if next.isEmpty {
lines.remove(at: firstNonEmpty)
}
}
let stripped = lines.joined(separator: "\n")
return TalkDirectiveParseResult(directive: directive, stripped: stripped, unknownKeys: unknownKeys)
}
private static func stringValue(_ dict: [String: Any], keys: [String]) -> String? {
for key in keys {
if let value = dict[key] as? String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
}
}
return nil
}
private static func doubleValue(_ dict: [String: Any], keys: [String]) -> Double? {
for key in keys {
if let value = dict[key] as? Double { return value }
if let value = dict[key] as? Int { return Double(value) }
if let value = dict[key] as? String, let parsed = Double(value) { return parsed }
}
return nil
}
private static func intValue(_ dict: [String: Any], keys: [String]) -> Int? {
for key in keys {
if let value = dict[key] as? Int { return value }
if let value = dict[key] as? Double { return Int(value) }
if let value = dict[key] as? String, let parsed = Int(value) { return parsed }
}
return nil
}
private static func boolValue(_ dict: [String: Any], keys: [String]) -> Bool? {
for key in keys {
if let value = dict[key] as? Bool { return value }
if let value = dict[key] as? String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if ["true", "yes", "1"].contains(trimmed) { return true }
if ["false", "no", "0"].contains(trimmed) { return false }
}
}
return nil
}
}

View File

@@ -0,0 +1,13 @@
public enum TalkHistoryTimestamp: Sendable {
/// Gateway history timestamps have historically been emitted as either seconds (Double, epoch seconds)
/// or milliseconds (Double, epoch ms). This helper accepts either.
public static func isAfter(_ timestamp: Double, sinceSeconds: Double) -> Bool {
let sinceMs = sinceSeconds * 1000
// ~2286-11-20 in epoch seconds. Anything bigger is almost certainly epoch milliseconds.
if timestamp > 10_000_000_000 {
return timestamp >= sinceMs - 500
}
return timestamp >= sinceSeconds - 0.5
}
}

View File

@@ -0,0 +1,17 @@
public enum TalkPromptBuilder: Sendable {
public static func build(transcript: String, interruptedAtSeconds: Double?) -> String {
var lines: [String] = [
"Talk Mode active. Reply in a concise, spoken tone.",
"You may optionally prefix the response with JSON (first line) to set ElevenLabs voice (id or alias), e.g. {\"voice\":\"<id>\",\"once\":true}.",
]
if let interruptedAtSeconds {
let formatted = String(format: "%.1f", interruptedAtSeconds)
lines.append("Assistant speech interrupted at \(formatted)s.")
}
lines.append("")
lines.append(transcript)
return lines.joined(separator: "\n")
}
}

View File

@@ -0,0 +1,110 @@
import AVFoundation
import Foundation
@MainActor
public final class TalkSystemSpeechSynthesizer: NSObject {
public enum SpeakError: Error {
case canceled
}
public static let shared = TalkSystemSpeechSynthesizer()
private let synth = AVSpeechSynthesizer()
private var speakContinuation: CheckedContinuation<Void, Error>?
private var currentUtterance: AVSpeechUtterance?
private var currentToken = UUID()
private var watchdog: Task<Void, Never>?
public var isSpeaking: Bool { self.synth.isSpeaking }
private override init() {
super.init()
self.synth.delegate = self
}
public func stop() {
self.currentToken = UUID()
self.watchdog?.cancel()
self.watchdog = nil
self.synth.stopSpeaking(at: .immediate)
self.finishCurrent(with: SpeakError.canceled)
}
public func speak(text: String, language: String? = nil) async throws {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.stop()
let token = UUID()
self.currentToken = token
let utterance = AVSpeechUtterance(string: trimmed)
if let language, let voice = AVSpeechSynthesisVoice(language: language) {
utterance.voice = voice
}
self.currentUtterance = utterance
let estimatedSeconds = max(3.0, min(180.0, Double(trimmed.count) * 0.08))
self.watchdog?.cancel()
self.watchdog = Task { @MainActor [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(estimatedSeconds * 1_000_000_000))
if Task.isCancelled { return }
guard self.currentToken == token else { return }
if self.synth.isSpeaking {
self.synth.stopSpeaking(at: .immediate)
}
self.finishCurrent(
with: NSError(domain: "TalkSystemSpeechSynthesizer", code: 408, userInfo: [
NSLocalizedDescriptionKey: "system TTS timed out after \(estimatedSeconds)s",
]))
}
try await withTaskCancellationHandler(operation: {
try await withCheckedThrowingContinuation { cont in
self.speakContinuation = cont
self.synth.speak(utterance)
}
}, onCancel: {
Task { @MainActor in
self.stop()
}
})
if self.currentToken != token {
throw SpeakError.canceled
}
}
private func handleFinish(error: Error?) {
guard self.currentUtterance != nil else { return }
self.watchdog?.cancel()
self.watchdog = nil
self.finishCurrent(with: error)
}
private func finishCurrent(with error: Error?) {
self.currentUtterance = nil
let cont = self.speakContinuation
self.speakContinuation = nil
if let error {
cont?.resume(throwing: error)
} else {
cont?.resume(returning: ())
}
}
}
extension TalkSystemSpeechSynthesizer: AVSpeechSynthesizerDelegate {
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
Task { @MainActor in
self.handleFinish(error: nil)
}
}
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
Task { @MainActor in
self.handleFinish(error: SpeakError.canceled)
}
}
}

View File

@@ -0,0 +1,194 @@
import Foundation
public struct ToolDisplaySummary: Sendable, Equatable {
public let name: String
public let emoji: String
public let title: String
public let label: String
public let verb: String?
public let detail: String?
public var detailLine: String? {
var parts: [String] = []
if let verb, !verb.isEmpty { parts.append(verb) }
if let detail, !detail.isEmpty { parts.append(detail) }
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
public var summaryLine: String {
if let detailLine {
return "\(emoji) \(label): \(detailLine)"
}
return "\(emoji) \(label)"
}
}
public enum ToolDisplayRegistry {
private struct ToolDisplayActionSpec: Decodable {
let label: String?
let detailKeys: [String]?
}
private struct ToolDisplaySpec: Decodable {
let emoji: String?
let title: String?
let label: String?
let detailKeys: [String]?
let actions: [String: ToolDisplayActionSpec]?
}
private struct ToolDisplayConfig: Decodable {
let version: Int?
let fallback: ToolDisplaySpec?
let tools: [String: ToolDisplaySpec]?
}
private static let config: ToolDisplayConfig = loadConfig()
public static func resolve(name: String?, args: AnyCodable?, meta: String? = nil) -> ToolDisplaySummary {
let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "tool"
let key = trimmedName.lowercased()
let spec = config.tools?[key]
let fallback = config.fallback
let emoji = spec?.emoji ?? fallback?.emoji ?? "🧩"
let title = spec?.title ?? titleFromName(trimmedName)
let label = spec?.label ?? trimmedName
let actionRaw = valueForKeyPath(args, path: "action") as? String
let action = actionRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
let actionSpec = action.flatMap { spec?.actions?[$0] }
let verb = normalizeVerb(actionSpec?.label ?? action)
var detail: String?
if key == "read" {
detail = readDetail(args)
} else if key == "write" || key == "edit" || key == "attach" {
detail = pathDetail(args)
}
let detailKeys = actionSpec?.detailKeys ?? spec?.detailKeys ?? fallback?.detailKeys ?? []
if detail == nil {
detail = firstValue(args, keys: detailKeys)
}
if detail == nil {
detail = meta
}
if let detailValue = detail {
detail = shortenHomeInString(detailValue)
}
return ToolDisplaySummary(
name: trimmedName,
emoji: emoji,
title: title,
label: label,
verb: verb,
detail: detail)
}
private static func loadConfig() -> ToolDisplayConfig {
guard let url = ClawdbotKitResources.bundle.url(forResource: "tool-display", withExtension: "json") else {
return ToolDisplayConfig(version: nil, fallback: nil, tools: nil)
}
do {
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(ToolDisplayConfig.self, from: data)
} catch {
return ToolDisplayConfig(version: nil, fallback: nil, tools: nil)
}
}
private static func titleFromName(_ name: String) -> String {
let cleaned = name.replacingOccurrences(of: "_", with: " ").trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return "Tool" }
return cleaned
.split(separator: " ")
.map { part in
let upper = part.uppercased()
if part.count <= 2 && part == upper { return String(part) }
return String(upper.prefix(1)) + String(part.lowercased().dropFirst())
}
.joined(separator: " ")
}
private static func normalizeVerb(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else { return nil }
return trimmed.replacingOccurrences(of: "_", with: " ")
}
private static func readDetail(_ args: AnyCodable?) -> String? {
guard let path = valueForKeyPath(args, path: "path") as? String else { return nil }
let offset = valueForKeyPath(args, path: "offset") as? Double
let limit = valueForKeyPath(args, path: "limit") as? Double
if let offset, let limit {
let end = offset + limit
return "\(path):\(Int(offset))-\(Int(end))"
}
return path
}
private static func pathDetail(_ args: AnyCodable?) -> String? {
return valueForKeyPath(args, path: "path") as? String
}
private static func firstValue(_ args: AnyCodable?, keys: [String]) -> String? {
for key in keys {
if let value = valueForKeyPath(args, path: key),
let rendered = renderValue(value)
{
return rendered
}
}
return nil
}
private static func renderValue(_ value: Any) -> String? {
if let str = value as? String {
let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let first = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed
if first.count > 160 { return String(first.prefix(157)) + "" }
return first
}
if let num = value as? Int { return String(num) }
if let num = value as? Double { return String(num) }
if let bool = value as? Bool { return bool ? "true" : "false" }
if let array = value as? [Any] {
let items = array.compactMap { renderValue($0) }
guard !items.isEmpty else { return nil }
let preview = items.prefix(3).joined(separator: ", ")
return items.count > 3 ? "\(preview)" : preview
}
if let dict = value as? [String: Any] {
if let label = dict["name"].flatMap({ renderValue($0) }) { return label }
if let label = dict["id"].flatMap({ renderValue($0) }) { return label }
}
return nil
}
private static func valueForKeyPath(_ args: AnyCodable?, path: String) -> Any? {
guard let args else { return nil }
let parts = path.split(separator: ".").map(String.init)
var current: Any? = args.value
for part in parts {
if let dict = current as? [String: AnyCodable] {
current = dict[part]?.value
} else if let dict = current as? [String: Any] {
current = dict[part]
} else {
return nil
}
}
return current
}
private static func shortenHomeInString(_ value: String) -> String {
let home = NSHomeDirectory()
guard !home.isEmpty else { return value }
return value.replacingOccurrences(of: home, with: "~")
}
}

View File

@@ -0,0 +1,37 @@
@testable import ClawdbotChatUI
import Testing
@Suite struct AssistantTextParserTests {
@Test func splitsThinkAndFinalSegments() {
let segments = AssistantTextParser.segments(
from: "<think>internal</think>\n\n<final>Hello there</final>")
#expect(segments.count == 2)
#expect(segments[0].kind == .thinking)
#expect(segments[0].text == "internal")
#expect(segments[1].kind == .response)
#expect(segments[1].text == "Hello there")
}
@Test func keepsTextWithoutTags() {
let segments = AssistantTextParser.segments(from: "Just text.")
#expect(segments.count == 1)
#expect(segments[0].kind == .response)
#expect(segments[0].text == "Just text.")
}
@Test func ignoresThinkingLikeTags() {
let raw = "<thinking>example</thinking>\nKeep this."
let segments = AssistantTextParser.segments(from: raw)
#expect(segments.count == 1)
#expect(segments[0].kind == .response)
#expect(segments[0].text == raw.trimmingCharacters(in: .whitespacesAndNewlines))
}
@Test func dropsEmptyTaggedContent() {
let segments = AssistantTextParser.segments(from: "<think></think>")
#expect(segments.isEmpty)
}
}

View File

@@ -0,0 +1,27 @@
import ClawdbotKit
import Testing
@Suite struct BonjourEscapesTests {
@Test func decodePassThrough() {
#expect(BonjourEscapes.decode("hello") == "hello")
#expect(BonjourEscapes.decode("") == "")
}
@Test func decodeSpaces() {
#expect(BonjourEscapes.decode("Clawdbot\\032Gateway") == "Clawdbot Gateway")
}
@Test func decodeMultipleEscapes() {
#expect(BonjourEscapes.decode("A\\038B\\047C\\032D") == "A&B/C D")
}
@Test func decodeIgnoresInvalidEscapeSequences() {
#expect(BonjourEscapes.decode("Hello\\03World") == "Hello\\03World")
#expect(BonjourEscapes.decode("Hello\\XYZWorld") == "Hello\\XYZWorld")
}
@Test func decodeUsesDecimalUnicodeScalarValue() {
#expect(BonjourEscapes.decode("Hello\\065World") == "HelloAWorld")
}
}

View File

@@ -0,0 +1,36 @@
import ClawdbotKit
import Foundation
import Testing
@Suite struct CanvasA2UIActionTests {
@Test func sanitizeTagValueIsStable() {
#expect(ClawdbotCanvasA2UIAction.sanitizeTagValue("Hello World!") == "Hello_World_")
#expect(ClawdbotCanvasA2UIAction.sanitizeTagValue(" ") == "-")
#expect(ClawdbotCanvasA2UIAction.sanitizeTagValue("macOS 26.2") == "macOS_26.2")
}
@Test func extractActionNameAcceptsNameOrAction() {
#expect(ClawdbotCanvasA2UIAction.extractActionName(["name": "Hello"]) == "Hello")
#expect(ClawdbotCanvasA2UIAction.extractActionName(["action": "Wave"]) == "Wave")
#expect(ClawdbotCanvasA2UIAction.extractActionName(["name": " ", "action": "Fallback"]) == "Fallback")
#expect(ClawdbotCanvasA2UIAction.extractActionName(["action": " "]) == nil)
}
@Test func formatAgentMessageIsTokenEfficientAndUnambiguous() {
let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext(
actionName: "Get Weather",
session: .init(key: "main", surfaceId: "main"),
component: .init(id: "btnWeather", host: "Peters iPad", instanceId: "ipad16,6"),
contextJSON: "{\"city\":\"Vienna\"}")
let msg = ClawdbotCanvasA2UIAction.formatAgentMessage(messageContext)
#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"))
}
}

View File

@@ -0,0 +1,43 @@
import ClawdbotKit
import Testing
@Suite struct CanvasA2UITests {
@Test func commandStringsAreStable() {
#expect(ClawdbotCanvasA2UICommand.push.rawValue == "canvas.a2ui.push")
#expect(ClawdbotCanvasA2UICommand.pushJSONL.rawValue == "canvas.a2ui.pushJSONL")
#expect(ClawdbotCanvasA2UICommand.reset.rawValue == "canvas.a2ui.reset")
}
@Test func jsonlDecodesAndValidatesV0_8() throws {
let jsonl = """
{"beginRendering":{"surfaceId":"main","timestamp":1}}
{"surfaceUpdate":{"surfaceId":"main","ops":[]}}
{"dataModelUpdate":{"dataModel":{"title":"Hello"}}}
{"deleteSurface":{"surfaceId":"main"}}
"""
let messages = try ClawdbotCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl)
#expect(messages.count == 4)
}
@Test func jsonlRejectsV0_9CreateSurface() {
let jsonl = """
{"createSurface":{"surfaceId":"main"}}
"""
#expect(throws: Error.self) {
_ = try ClawdbotCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl)
}
}
@Test func jsonlRejectsUnknownShape() {
let jsonl = """
{"wat":{"nope":1}}
"""
#expect(throws: Error.self) {
_ = try ClawdbotCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl)
}
}
}

View File

@@ -0,0 +1,16 @@
import ClawdbotKit
import Foundation
import Testing
@Suite struct CanvasSnapshotFormatTests {
@Test func acceptsJpgAlias() throws {
struct Wrapper: Codable {
var format: ClawdbotCanvasSnapshotFormat
}
let data = try #require("{\"format\":\"jpg\"}".data(using: .utf8))
let decoded = try JSONDecoder().decode(Wrapper.self, from: data)
#expect(decoded.format == .jpeg)
}
}

View File

@@ -0,0 +1,29 @@
@testable import ClawdbotChatUI
import Foundation
import Testing
#if os(macOS)
import AppKit
#endif
#if os(macOS)
private func luminance(_ color: NSColor) throws -> CGFloat {
let rgb = try #require(color.usingColorSpace(.deviceRGB))
return 0.2126 * rgb.redComponent + 0.7152 * rgb.greenComponent + 0.0722 * rgb.blueComponent
}
#endif
@Suite struct ChatThemeTests {
@Test func assistantBubbleResolvesForLightAndDark() throws {
#if os(macOS)
let lightAppearance = try #require(NSAppearance(named: .aqua))
let darkAppearance = try #require(NSAppearance(named: .darkAqua))
let lightResolved = ClawdbotChatTheme.resolvedAssistantBubbleColor(for: lightAppearance)
let darkResolved = ClawdbotChatTheme.resolvedAssistantBubbleColor(for: darkAppearance)
#expect(try luminance(lightResolved) > luminance(darkResolved))
#else
#expect(Bool(true))
#endif
}
}

View File

@@ -0,0 +1,494 @@
@testable import ClawdbotChatUI
import ClawdbotKit
import Foundation
import Testing
private struct TimeoutError: Error, CustomStringConvertible {
let label: String
var description: String { "Timeout waiting for: \(self.label)" }
}
private func waitUntil(
_ label: String,
timeoutSeconds: Double = 2.0,
pollMs: UInt64 = 10,
_ condition: @escaping @Sendable () async -> Bool) async throws
{
let deadline = Date().addingTimeInterval(timeoutSeconds)
while Date() < deadline {
if await condition() {
return
}
try await Task.sleep(nanoseconds: pollMs * 1_000_000)
}
throw TimeoutError(label: label)
}
private actor TestChatTransportState {
var historyCallCount: Int = 0
var sessionsCallCount: Int = 0
var sentRunIds: [String] = []
var abortedRunIds: [String] = []
}
private final class TestChatTransport: @unchecked Sendable, ClawdbotChatTransport {
private let state = TestChatTransportState()
private let historyResponses: [ClawdbotChatHistoryPayload]
private let sessionsResponses: [ClawdbotChatSessionsListResponse]
private let stream: AsyncStream<ClawdbotChatTransportEvent>
private let continuation: AsyncStream<ClawdbotChatTransportEvent>.Continuation
init(
historyResponses: [ClawdbotChatHistoryPayload],
sessionsResponses: [ClawdbotChatSessionsListResponse] = [])
{
self.historyResponses = historyResponses
self.sessionsResponses = sessionsResponses
var cont: AsyncStream<ClawdbotChatTransportEvent>.Continuation!
self.stream = AsyncStream { c in
cont = c
}
self.continuation = cont
}
func events() -> AsyncStream<ClawdbotChatTransportEvent> {
self.stream
}
func setActiveSessionKey(_: String) async throws {}
func requestHistory(sessionKey: String) async throws -> ClawdbotChatHistoryPayload {
let idx = await self.state.historyCallCount
await self.state.setHistoryCallCount(idx + 1)
if idx < self.historyResponses.count {
return self.historyResponses[idx]
}
return self.historyResponses.last ?? ClawdbotChatHistoryPayload(
sessionKey: sessionKey,
sessionId: nil,
messages: [],
thinkingLevel: "off")
}
func sendMessage(
sessionKey _: String,
message _: String,
thinking _: String,
idempotencyKey: String,
attachments _: [ClawdbotChatAttachmentPayload]) async throws -> ClawdbotChatSendResponse
{
await self.state.sentRunIdsAppend(idempotencyKey)
return ClawdbotChatSendResponse(runId: idempotencyKey, status: "ok")
}
func abortRun(sessionKey _: String, runId: String) async throws {
await self.state.abortedRunIdsAppend(runId)
}
func listSessions(limit _: Int?) async throws -> ClawdbotChatSessionsListResponse {
let idx = await self.state.sessionsCallCount
await self.state.setSessionsCallCount(idx + 1)
if idx < self.sessionsResponses.count {
return self.sessionsResponses[idx]
}
return self.sessionsResponses.last ?? ClawdbotChatSessionsListResponse(
ts: nil,
path: nil,
count: 0,
defaults: nil,
sessions: [])
}
func requestHealth(timeoutMs _: Int) async throws -> Bool {
true
}
func emit(_ evt: ClawdbotChatTransportEvent) {
self.continuation.yield(evt)
}
func lastSentRunId() async -> String? {
let ids = await self.state.sentRunIds
return ids.last
}
func abortedRunIds() async -> [String] {
await self.state.abortedRunIds
}
}
private extension TestChatTransportState {
func setHistoryCallCount(_ v: Int) {
self.historyCallCount = v
}
func setSessionsCallCount(_ v: Int) {
self.sessionsCallCount = v
}
func sentRunIdsAppend(_ v: String) {
self.sentRunIds.append(v)
}
func abortedRunIdsAppend(_ v: String) {
self.abortedRunIds.append(v)
}
}
@Suite struct ChatViewModelTests {
@Test func streamsAssistantAndClearsOnFinal() async throws {
let sessionId = "sess-main"
let history1 = ClawdbotChatHistoryPayload(
sessionKey: "main",
sessionId: sessionId,
messages: [],
thinkingLevel: "off")
let history2 = ClawdbotChatHistoryPayload(
sessionKey: "main",
sessionId: sessionId,
messages: [
AnyCodable([
"role": "assistant",
"content": [["type": "text", "text": "final answer"]],
"timestamp": Date().timeIntervalSince1970 * 1000,
]),
],
thinkingLevel: "off")
let transport = TestChatTransport(historyResponses: [history1, history2])
let vm = await MainActor.run { ClawdbotChatViewModel(sessionKey: "main", transport: transport) }
await MainActor.run { vm.load() }
try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } }
await MainActor.run {
vm.input = "hi"
vm.send()
}
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
transport.emit(
.agent(
ClawdbotAgentEventPayload(
runId: sessionId,
seq: 1,
stream: "assistant",
ts: Int(Date().timeIntervalSince1970 * 1000),
data: ["text": AnyCodable("streaming…")])))
try await waitUntil("assistant stream visible") { await MainActor.run { vm.streamingAssistantText == "streaming…" } }
transport.emit(
.agent(
ClawdbotAgentEventPayload(
runId: sessionId,
seq: 2,
stream: "tool",
ts: Int(Date().timeIntervalSince1970 * 1000),
data: [
"phase": AnyCodable("start"),
"name": AnyCodable("demo"),
"toolCallId": AnyCodable("t1"),
"args": AnyCodable(["x": 1]),
])))
try await waitUntil("tool call pending") { await MainActor.run { vm.pendingToolCalls.count == 1 } }
let runId = try #require(await transport.lastSentRunId())
transport.emit(
.chat(
ClawdbotChatEventPayload(
runId: runId,
sessionKey: "main",
state: "final",
message: nil,
errorMessage: nil)))
try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } }
try await waitUntil("history refresh") { await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) } }
#expect(await MainActor.run { vm.streamingAssistantText } == nil)
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
}
@Test func clearsStreamingOnExternalFinalEvent() async throws {
let sessionId = "sess-main"
let history = ClawdbotChatHistoryPayload(
sessionKey: "main",
sessionId: sessionId,
messages: [],
thinkingLevel: "off")
let transport = TestChatTransport(historyResponses: [history, history])
let vm = await MainActor.run { ClawdbotChatViewModel(sessionKey: "main", transport: transport) }
await MainActor.run { vm.load() }
try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } }
transport.emit(
.agent(
ClawdbotAgentEventPayload(
runId: sessionId,
seq: 1,
stream: "assistant",
ts: Int(Date().timeIntervalSince1970 * 1000),
data: ["text": AnyCodable("external stream")])))
transport.emit(
.agent(
ClawdbotAgentEventPayload(
runId: sessionId,
seq: 2,
stream: "tool",
ts: Int(Date().timeIntervalSince1970 * 1000),
data: [
"phase": AnyCodable("start"),
"name": AnyCodable("demo"),
"toolCallId": AnyCodable("t1"),
"args": AnyCodable(["x": 1]),
])))
try await waitUntil("streaming active") { await MainActor.run { vm.streamingAssistantText == "external stream" } }
try await waitUntil("tool call pending") { await MainActor.run { vm.pendingToolCalls.count == 1 } }
transport.emit(
.chat(
ClawdbotChatEventPayload(
runId: "other-run",
sessionKey: "main",
state: "final",
message: nil,
errorMessage: nil)))
try await waitUntil("streaming cleared") { await MainActor.run { vm.streamingAssistantText == nil } }
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
}
@Test func sessionChoicesPreferMainAndRecent() async throws {
let now = Date().timeIntervalSince1970 * 1000
let recent = now - (2 * 60 * 60 * 1000)
let recentOlder = now - (5 * 60 * 60 * 1000)
let stale = now - (26 * 60 * 60 * 1000)
let history = ClawdbotChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "off")
let sessions = ClawdbotChatSessionsListResponse(
ts: now,
path: nil,
count: 4,
defaults: nil,
sessions: [
ClawdbotChatSessionEntry(
key: "recent-1",
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: recent,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
model: nil,
contextTokens: nil),
ClawdbotChatSessionEntry(
key: "main",
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: stale,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
model: nil,
contextTokens: nil),
ClawdbotChatSessionEntry(
key: "recent-2",
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: recentOlder,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
model: nil,
contextTokens: nil),
ClawdbotChatSessionEntry(
key: "old-1",
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: stale,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
model: nil,
contextTokens: nil),
])
let transport = TestChatTransport(
historyResponses: [history],
sessionsResponses: [sessions])
let vm = await MainActor.run { ClawdbotChatViewModel(sessionKey: "main", transport: transport) }
await MainActor.run { vm.load() }
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
#expect(keys == ["main", "recent-1", "recent-2"])
}
@Test func sessionChoicesIncludeCurrentWhenMissing() async throws {
let now = Date().timeIntervalSince1970 * 1000
let recent = now - (30 * 60 * 1000)
let history = ClawdbotChatHistoryPayload(
sessionKey: "custom",
sessionId: "sess-custom",
messages: [],
thinkingLevel: "off")
let sessions = ClawdbotChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
ClawdbotChatSessionEntry(
key: "main",
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: recent,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
model: nil,
contextTokens: nil),
])
let transport = TestChatTransport(
historyResponses: [history],
sessionsResponses: [sessions])
let vm = await MainActor.run { ClawdbotChatViewModel(sessionKey: "custom", transport: transport) }
await MainActor.run { vm.load() }
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
#expect(keys == ["main", "custom"])
}
@Test func clearsStreamingOnExternalErrorEvent() async throws {
let sessionId = "sess-main"
let history = ClawdbotChatHistoryPayload(
sessionKey: "main",
sessionId: sessionId,
messages: [],
thinkingLevel: "off")
let transport = TestChatTransport(historyResponses: [history, history])
let vm = await MainActor.run { ClawdbotChatViewModel(sessionKey: "main", transport: transport) }
await MainActor.run { vm.load() }
try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } }
transport.emit(
.agent(
ClawdbotAgentEventPayload(
runId: sessionId,
seq: 1,
stream: "assistant",
ts: Int(Date().timeIntervalSince1970 * 1000),
data: ["text": AnyCodable("external stream")])))
try await waitUntil("streaming active") { await MainActor.run { vm.streamingAssistantText == "external stream" } }
transport.emit(
.chat(
ClawdbotChatEventPayload(
runId: "other-run",
sessionKey: "main",
state: "error",
message: nil,
errorMessage: "boom")))
try await waitUntil("streaming cleared") { await MainActor.run { vm.streamingAssistantText == nil } }
}
@Test func abortRequestsDoNotClearPendingUntilAbortedEvent() async throws {
let sessionId = "sess-main"
let history = ClawdbotChatHistoryPayload(
sessionKey: "main",
sessionId: sessionId,
messages: [],
thinkingLevel: "off")
let transport = TestChatTransport(historyResponses: [history, history])
let vm = await MainActor.run { ClawdbotChatViewModel(sessionKey: "main", transport: transport) }
await MainActor.run { vm.load() }
try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } }
await MainActor.run {
vm.input = "hi"
vm.send()
}
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
let runId = try #require(await transport.lastSentRunId())
await MainActor.run { vm.abort() }
try await waitUntil("abortRun called") {
let ids = await transport.abortedRunIds()
return ids == [runId]
}
// Pending remains until the gateway broadcasts an aborted/final chat event.
#expect(await MainActor.run { vm.pendingRunCount } == 1)
transport.emit(
.chat(
ClawdbotChatEventPayload(
runId: runId,
sessionKey: "main",
state: "aborted",
message: nil,
errorMessage: nil)))
try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } }
}
}

View File

@@ -0,0 +1,19 @@
import XCTest
@testable import ClawdbotKit
final class ElevenLabsTTSValidationTests: XCTestCase {
func testValidatedOutputFormatAllowsOnlyMp3Presets() {
XCTAssertEqual(ElevenLabsTTSClient.validatedOutputFormat("mp3_44100_128"), "mp3_44100_128")
XCTAssertEqual(ElevenLabsTTSClient.validatedOutputFormat("pcm_16000"), "pcm_16000")
}
func testValidatedLanguageAcceptsTwoLetterCodes() {
XCTAssertEqual(ElevenLabsTTSClient.validatedLanguage("EN"), "en")
XCTAssertNil(ElevenLabsTTSClient.validatedLanguage("eng"))
}
func testValidatedNormalizeAcceptsKnownValues() {
XCTAssertEqual(ElevenLabsTTSClient.validatedNormalize("AUTO"), "auto")
XCTAssertNil(ElevenLabsTTSClient.validatedNormalize("maybe"))
}
}

View File

@@ -0,0 +1,128 @@
import ClawdbotKit
import CoreGraphics
import ImageIO
import Testing
import UniformTypeIdentifiers
@Suite struct JPEGTranscoderTests {
private func makeSolidJPEG(width: Int, height: Int, orientation: Int? = nil) throws -> Data {
let cs = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
guard
let ctx = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: cs,
bitmapInfo: bitmapInfo)
else {
throw NSError(domain: "JPEGTranscoderTests", code: 1)
}
ctx.setFillColor(red: 1, green: 0, blue: 0, alpha: 1)
ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
guard let img = ctx.makeImage() else {
throw NSError(domain: "JPEGTranscoderTests", code: 5)
}
let out = NSMutableData()
guard let dest = CGImageDestinationCreateWithData(out, UTType.jpeg.identifier as CFString, 1, nil) else {
throw NSError(domain: "JPEGTranscoderTests", code: 2)
}
var props: [CFString: Any] = [
kCGImageDestinationLossyCompressionQuality: 1.0,
]
if let orientation {
props[kCGImagePropertyOrientation] = orientation
}
CGImageDestinationAddImage(dest, img, props as CFDictionary)
guard CGImageDestinationFinalize(dest) else {
throw NSError(domain: "JPEGTranscoderTests", code: 3)
}
return out as Data
}
private func makeNoiseJPEG(width: Int, height: Int) throws -> Data {
let bytesPerPixel = 4
let byteCount = width * height * bytesPerPixel
var data = Data(count: byteCount)
let cs = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
let out = try data.withUnsafeMutableBytes { rawBuffer -> Data in
guard let base = rawBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
throw NSError(domain: "JPEGTranscoderTests", code: 6)
}
for idx in 0..<byteCount {
base[idx] = UInt8.random(in: 0...255)
}
guard
let ctx = CGContext(
data: base,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * bytesPerPixel,
space: cs,
bitmapInfo: bitmapInfo)
else {
throw NSError(domain: "JPEGTranscoderTests", code: 7)
}
guard let img = ctx.makeImage() else {
throw NSError(domain: "JPEGTranscoderTests", code: 8)
}
let encoded = NSMutableData()
guard let dest = CGImageDestinationCreateWithData(encoded, UTType.jpeg.identifier as CFString, 1, nil) else {
throw NSError(domain: "JPEGTranscoderTests", code: 9)
}
CGImageDestinationAddImage(dest, img, nil)
guard CGImageDestinationFinalize(dest) else {
throw NSError(domain: "JPEGTranscoderTests", code: 10)
}
return encoded as Data
}
return out
}
@Test func downscalesToMaxWidthPx() throws {
let input = try makeSolidJPEG(width: 2000, height: 1000)
let out = try JPEGTranscoder.transcodeToJPEG(imageData: input, maxWidthPx: 1600, quality: 0.9)
#expect(out.widthPx == 1600)
#expect(abs(out.heightPx - 800) <= 1)
#expect(out.data.count > 0)
}
@Test func doesNotUpscaleWhenSmallerThanMaxWidthPx() throws {
let input = try makeSolidJPEG(width: 800, height: 600)
let out = try JPEGTranscoder.transcodeToJPEG(imageData: input, maxWidthPx: 1600, quality: 0.9)
#expect(out.widthPx == 800)
#expect(out.heightPx == 600)
}
@Test func normalizesOrientationAndUsesOrientedWidthForMaxWidthPx() throws {
// Encode a landscape image but mark it rotated 90° (orientation 6). Oriented width becomes 1000.
let input = try makeSolidJPEG(width: 2000, height: 1000, orientation: 6)
let out = try JPEGTranscoder.transcodeToJPEG(imageData: input, maxWidthPx: 1600, quality: 0.9)
#expect(out.widthPx == 1000)
#expect(out.heightPx == 2000)
}
@Test func respectsMaxBytes() throws {
let input = try makeNoiseJPEG(width: 1600, height: 1200)
let out = try JPEGTranscoder.transcodeToJPEG(
imageData: input,
maxWidthPx: 1600,
quality: 0.95,
maxBytes: 180_000)
#expect(out.data.count <= 180_000)
}
}

View File

@@ -0,0 +1,74 @@
import XCTest
@testable import ClawdbotKit
final class TalkDirectiveTests: XCTestCase {
func testParsesDirectiveAndStripsLine() {
let text = """
{"voice":"abc123","once":true}
Hello there.
"""
let result = TalkDirectiveParser.parse(text)
XCTAssertEqual(result.directive?.voiceId, "abc123")
XCTAssertEqual(result.directive?.once, true)
XCTAssertEqual(result.stripped, "Hello there.")
}
func testIgnoresNonDirective() {
let text = "Hello world."
let result = TalkDirectiveParser.parse(text)
XCTAssertNil(result.directive)
XCTAssertEqual(result.stripped, text)
}
func testKeepsDirectiveLineIfNoRecognizedFields() {
let text = """
{"unknown":"value"}
Hello.
"""
let result = TalkDirectiveParser.parse(text)
XCTAssertNil(result.directive)
XCTAssertEqual(result.stripped, text)
}
func testParsesExtendedOptions() {
let text = """
{"voice_id":"v1","model_id":"m1","rate":200,"stability":0.5,"similarity":0.8,"style":0.2,"speaker_boost":true,"seed":1234,"normalize":"auto","lang":"en","output_format":"mp3_44100_128"}
Hello.
"""
let result = TalkDirectiveParser.parse(text)
XCTAssertEqual(result.directive?.voiceId, "v1")
XCTAssertEqual(result.directive?.modelId, "m1")
XCTAssertEqual(result.directive?.rateWPM, 200)
XCTAssertEqual(result.directive?.stability, 0.5)
XCTAssertEqual(result.directive?.similarity, 0.8)
XCTAssertEqual(result.directive?.style, 0.2)
XCTAssertEqual(result.directive?.speakerBoost, true)
XCTAssertEqual(result.directive?.seed, 1234)
XCTAssertEqual(result.directive?.normalize, "auto")
XCTAssertEqual(result.directive?.language, "en")
XCTAssertEqual(result.directive?.outputFormat, "mp3_44100_128")
XCTAssertEqual(result.stripped, "Hello.")
}
func testSkipsLeadingEmptyLinesWhenParsingDirective() {
let text = """
{"voice":"abc123"}
Hello there.
"""
let result = TalkDirectiveParser.parse(text)
XCTAssertEqual(result.directive?.voiceId, "abc123")
XCTAssertEqual(result.stripped, "Hello there.")
}
func testTracksUnknownKeys() {
let text = """
{"voice":"abc","mystery":"value","extra":1}
Hi.
"""
let result = TalkDirectiveParser.parse(text)
XCTAssertEqual(result.directive?.voiceId, "abc")
XCTAssertEqual(result.unknownKeys, ["extra", "mystery"])
}
}

View File

@@ -0,0 +1,16 @@
import XCTest
@testable import ClawdbotKit
final class TalkHistoryTimestampTests: XCTestCase {
func testSecondsTimestampsAreAcceptedWithSmallTolerance() {
XCTAssertTrue(TalkHistoryTimestamp.isAfter(999.6, sinceSeconds: 1000))
XCTAssertFalse(TalkHistoryTimestamp.isAfter(999.4, sinceSeconds: 1000))
}
func testMillisecondsTimestampsAreAcceptedWithSmallTolerance() {
let sinceSeconds = 1_700_000_000.0
let sinceMs = sinceSeconds * 1000
XCTAssertTrue(TalkHistoryTimestamp.isAfter(sinceMs - 500, sinceSeconds: sinceSeconds))
XCTAssertFalse(TalkHistoryTimestamp.isAfter(sinceMs - 501, sinceSeconds: sinceSeconds))
}
}

View File

@@ -0,0 +1,16 @@
import XCTest
@testable import ClawdbotKit
final class TalkPromptBuilderTests: XCTestCase {
func testBuildIncludesTranscript() {
let prompt = TalkPromptBuilder.build(transcript: "Hello", interruptedAtSeconds: nil)
XCTAssertTrue(prompt.contains("Talk Mode active."))
XCTAssertTrue(prompt.hasSuffix("\n\nHello"))
}
func testBuildIncludesInterruptionLineWhenProvided() {
let prompt = TalkPromptBuilder.build(transcript: "Hi", interruptedAtSeconds: 1.234)
XCTAssertTrue(prompt.contains("Assistant speech interrupted at 1.2s."))
}
}

View File

@@ -0,0 +1,484 @@
import { html, css, LitElement, unsafeCSS } 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 "@clawdbot/a2ui-theme-context";
const modalStyles = css`
dialog {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 24px;
border: none;
background: rgba(5, 8, 16, 0.65);
backdrop-filter: blur(6px);
display: grid;
place-items: center;
}
dialog::backdrop {
background: rgba(5, 8, 16, 0.65);
backdrop-filter: blur(6px);
}
`;
const modalElement = customElements.get("a2ui-modal");
if (modalElement && Array.isArray(modalElement.styles)) {
modalElement.styles = [...modalElement.styles, modalStyles];
}
const empty = Object.freeze({});
const emptyClasses = () => ({});
const textHintStyles = () => ({ h1: {}, h2: {}, h3: {}, h4: {}, h5: {}, body: {}, caption: {} });
const isAndroid = /Android/i.test(globalThis.navigator?.userAgent ?? "");
const cardShadow = isAndroid ? "0 2px 10px rgba(0,0,0,.18)" : "0 10px 30px rgba(0,0,0,.35)";
const buttonShadow = isAndroid ? "0 2px 10px rgba(6, 182, 212, 0.14)" : "0 10px 25px rgba(6, 182, 212, 0.18)";
const statusShadow = isAndroid ? "0 2px 10px rgba(0, 0, 0, 0.18)" : "0 10px 24px rgba(0, 0, 0, 0.25)";
const statusBlur = isAndroid ? "10px" : "14px";
const clawdbotTheme = {
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: cardShadow,
},
Modal: {
background: "rgba(12, 16, 24, 0.92)",
border: "1px solid rgba(255,255,255,.12)",
borderRadius: "16px",
padding: "16px",
boxShadow: "0 30px 80px rgba(0,0,0,.6)",
width: "min(520px, calc(100vw - 48px))",
},
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: buttonShadow,
},
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 ClawdbotA2UIHost extends LitElement {
static properties = {
surfaces: { state: true },
pendingAction: { state: true },
toast: { state: true },
};
#processor = v0_8.Data.createSignalA2uiMessageProcessor();
#themeProvider = new ContextProvider(this, {
context: themeContext,
initialValue: clawdbotTheme,
});
surfaces = [];
pendingAction = null;
toast = null;
#statusListener = null;
static styles = css`
:host {
display: block;
height: 100%;
position: relative;
box-sizing: border-box;
padding:
var(--clawdbot-a2ui-inset-top, 0px)
var(--clawdbot-a2ui-inset-right, 0px)
var(--clawdbot-a2ui-inset-bottom, 0px)
var(--clawdbot-a2ui-inset-left, 0px);
}
#surfaces {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
height: 100%;
overflow: auto;
padding-bottom: var(--clawdbot-a2ui-scroll-pad-bottom, 0px);
}
.status {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: var(--clawdbot-a2ui-status-top, 12px);
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.92);
font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
pointer-events: none;
backdrop-filter: blur(${unsafeCSS(statusBlur)});
-webkit-backdrop-filter: blur(${unsafeCSS(statusBlur)});
box-shadow: ${unsafeCSS(statusShadow)};
z-index: 5;
}
.toast {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: var(--clawdbot-a2ui-toast-bottom, 12px);
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.92);
font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
pointer-events: none;
backdrop-filter: blur(${unsafeCSS(statusBlur)});
-webkit-backdrop-filter: blur(${unsafeCSS(statusBlur)});
box-shadow: ${unsafeCSS(statusShadow)};
z-index: 5;
}
.toast.error {
border-color: rgba(255, 109, 109, 0.35);
color: rgba(255, 223, 223, 0.98);
}
.empty {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: var(--clawdbot-a2ui-empty-top, var(--clawdbot-a2ui-status-top, 12px));
text-align: center;
opacity: 0.8;
padding: 10px 12px;
pointer-events: none;
}
.empty-title {
font-weight: 700;
margin-bottom: 6px;
}
.spinner {
width: 12px;
height: 12px;
border-radius: 999px;
border: 2px solid rgba(255, 255, 255, 0.25);
border-top-color: rgba(255, 255, 255, 0.92);
animation: spin 0.75s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
`;
connectedCallback() {
super.connectedCallback();
globalThis.clawdbotA2UI = {
applyMessages: (messages) => this.applyMessages(messages),
reset: () => this.reset(),
getSurfaces: () => Array.from(this.#processor.getSurfaces().keys()),
};
this.addEventListener("a2uiaction", (evt) => this.#handleA2UIAction(evt));
this.#statusListener = (evt) => this.#handleActionStatus(evt);
globalThis.addEventListener("clawdbot:a2ui-action-status", this.#statusListener);
this.#syncSurfaces();
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.#statusListener) {
globalThis.removeEventListener("clawdbot:a2ui-action-status", this.#statusListener);
this.#statusListener = null;
}
}
#makeActionId() {
return globalThis.crypto?.randomUUID?.() ?? `a2ui_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
#setToast(text, kind = "ok", timeoutMs = 1400) {
const toast = { text, kind, expiresAt: Date.now() + timeoutMs };
this.toast = toast;
this.requestUpdate();
setTimeout(() => {
if (this.toast === toast) {
this.toast = null;
this.requestUpdate();
}
}, timeoutMs + 30);
}
#handleActionStatus(evt) {
const detail = evt?.detail ?? null;
if (!detail || typeof detail.id !== "string") return;
if (!this.pendingAction || this.pendingAction.id !== detail.id) return;
if (detail.ok) {
this.pendingAction = { ...this.pendingAction, phase: "sent", sentAt: Date.now() };
} else {
const msg = typeof detail.error === "string" && detail.error ? detail.error : "send failed";
this.pendingAction = { ...this.pendingAction, phase: "error", error: msg };
this.#setToast(`Failed: ${msg}`, "error", 4500);
}
this.requestUpdate();
}
#handleA2UIAction(evt) {
const payload = evt?.detail ?? evt?.payload ?? null;
if (!payload || payload.eventType !== "a2ui.action") {
return;
}
const action = payload.action;
const name = action?.name;
if (!name) {
return;
}
const sourceComponentId = payload.sourceComponentId ?? "";
const surfaces = this.#processor.getSurfaces();
let surfaceId = null;
let sourceNode = null;
for (const [sid, surface] of surfaces.entries()) {
const node = surface?.components?.get?.(sourceComponentId) ?? null;
if (node) {
surfaceId = sid;
sourceNode = node;
break;
}
}
const context = {};
const ctxItems = Array.isArray(action?.context) ? action.context : [];
for (const item of ctxItems) {
const key = item?.key;
const value = item?.value ?? null;
if (!key || !value) continue;
if (typeof value.path === "string") {
const resolved = sourceNode
? this.#processor.getData(sourceNode, value.path, surfaceId ?? undefined)
: null;
context[key] = resolved;
continue;
}
if (Object.prototype.hasOwnProperty.call(value, "literalString")) {
context[key] = value.literalString ?? "";
continue;
}
if (Object.prototype.hasOwnProperty.call(value, "literalNumber")) {
context[key] = value.literalNumber ?? 0;
continue;
}
if (Object.prototype.hasOwnProperty.call(value, "literalBoolean")) {
context[key] = value.literalBoolean ?? false;
continue;
}
}
const actionId = this.#makeActionId();
this.pendingAction = { id: actionId, name, phase: "sending", startedAt: Date.now() };
this.requestUpdate();
const userAction = {
id: actionId,
name,
surfaceId: surfaceId ?? "main",
sourceComponentId,
timestamp: new Date().toISOString(),
...(Object.keys(context).length ? { context } : {}),
};
globalThis.__clawdbotLastA2UIAction = userAction;
const handler =
globalThis.webkit?.messageHandlers?.clawdbotCanvasA2UIAction ??
globalThis.clawdbotCanvasA2UIAction;
if (handler?.postMessage) {
try {
// WebKit message handlers support structured objects; Android's JS interface expects strings.
if (handler === globalThis.clawdbotCanvasA2UIAction) {
handler.postMessage(JSON.stringify({ userAction }));
} else {
handler.postMessage({ userAction });
}
} catch (e) {
const msg = String(e?.message ?? e);
this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: msg };
this.#setToast(`Failed: ${msg}`, "error", 4500);
}
} else {
this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: "missing native bridge" };
this.#setToast("Failed: missing native bridge", "error", 4500);
}
}
applyMessages(messages) {
if (!Array.isArray(messages)) {
throw new Error("A2UI: expected messages array");
}
this.#processor.processMessages(messages);
this.#syncSurfaces();
if (this.pendingAction?.phase === "sent") {
this.#setToast(`Updated: ${this.pendingAction.name}`, "ok", 1100);
this.pendingAction = null;
}
this.requestUpdate();
return { ok: true, surfaces: this.surfaces.map(([id]) => id) };
}
reset() {
this.#processor.clearSurfaces();
this.#syncSurfaces();
this.pendingAction = null;
this.requestUpdate();
return { ok: true };
}
#syncSurfaces() {
this.surfaces = Array.from(this.#processor.getSurfaces().entries());
}
render() {
if (this.surfaces.length === 0) {
return html`<div class="empty">
<div class="empty-title">Canvas (A2UI)</div>
<div>Waiting for A2UI messages…</div>
</div>`;
}
const statusText =
this.pendingAction?.phase === "sent"
? `Working: ${this.pendingAction.name}`
: this.pendingAction?.phase === "sending"
? `Sending: ${this.pendingAction.name}`
: this.pendingAction?.phase === "error"
? `Failed: ${this.pendingAction.name}`
: "";
return html`
${this.pendingAction && this.pendingAction.phase !== "error"
? html`<div class="status"><div class="spinner"></div><div>${statusText}</div></div>`
: ""}
${this.toast
? html`<div class="toast ${this.toast.kind === "error" ? "error" : ""}">${this.toast.text}</div>`
: ""}
<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("clawdbot-a2ui-host", ClawdbotA2UIHost);

View File

@@ -0,0 +1,45 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "rolldown";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "../../../../..");
const fromHere = (p) => path.resolve(here, p);
const outputFile = path.resolve(
here,
"../../../../..",
"src",
"canvas-host",
"a2ui",
"a2ui.bundle.js",
);
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"),
experimental: {
attachDebugInfo: "none",
},
treeshake: false,
resolve: {
alias: {
"@a2ui/lit": path.resolve(a2uiLitDist, "index.js"),
"@a2ui/lit/ui": path.resolve(a2uiLitDist, "0.8/ui/ui.js"),
"@clawdbot/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: outputFile,
format: "esm",
inlineDynamicImports: true,
sourcemap: false,
},
});