mirror of
https://github.com/clawdbot/clawdbot.git
synced 2026-01-31 19:37:45 +01:00
refactor: rename to openclaw
This commit is contained in:
61
apps/shared/OpenClawKit/Package.swift
Normal file
61
apps/shared/OpenClawKit/Package.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
// swift-tools-version: 6.2
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "OpenClawKit",
|
||||
platforms: [
|
||||
.iOS(.v18),
|
||||
.macOS(.v15),
|
||||
],
|
||||
products: [
|
||||
.library(name: "OpenClawProtocol", targets: ["OpenClawProtocol"]),
|
||||
.library(name: "OpenClawKit", targets: ["OpenClawKit"]),
|
||||
.library(name: "OpenClawChatUI", targets: ["OpenClawChatUI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
|
||||
.package(url: "https://github.com/gonzalezreal/textual", exact: "0.3.1"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "OpenClawProtocol",
|
||||
path: "Sources/OpenClawProtocol",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.target(
|
||||
name: "OpenClawKit",
|
||||
dependencies: [
|
||||
"OpenClawProtocol",
|
||||
.product(name: "ElevenLabsKit", package: "ElevenLabsKit"),
|
||||
],
|
||||
path: "Sources/OpenClawKit",
|
||||
resources: [
|
||||
.process("Resources"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.target(
|
||||
name: "OpenClawChatUI",
|
||||
dependencies: [
|
||||
"OpenClawKit",
|
||||
.product(
|
||||
name: "Textual",
|
||||
package: "textual",
|
||||
condition: .when(platforms: [.macOS, .iOS])),
|
||||
],
|
||||
path: "Sources/OpenClawChatUI",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.testTarget(
|
||||
name: "OpenClawKitTests",
|
||||
dependencies: ["OpenClawKit", "OpenClawChatUI"],
|
||||
path: "Tests/OpenClawKitTests",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
.enableExperimentalFeature("SwiftTesting"),
|
||||
]),
|
||||
])
|
||||
@@ -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(\.self)
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
#if !os(macOS)
|
||||
import PhotosUI
|
||||
import UniformTypeIdentifiers
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
struct OpenClawChatComposer: View {
|
||||
@Bindable var viewModel: OpenClawChatViewModel
|
||||
let style: OpenClawChatView.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(OpenClawChatTheme.composerBackground)
|
||||
.overlay(shape.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
|
||||
} else {
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
shape
|
||||
.fill(OpenClawChatTheme.composerBackground)
|
||||
.overlay(shape.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
|
||||
}
|
||||
#else
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
shape
|
||||
.fill(OpenClawChatTheme.composerBackground)
|
||||
.overlay(shape.strokeBorder(OpenClawChatTheme.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: \OpenClawPendingAttachment.id)
|
||||
{ (att: OpenClawPendingAttachment) in
|
||||
HStack(spacing: 6) {
|
||||
if let img = att.preview {
|
||||
OpenClawPlatformImageFactory.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(OpenClawChatTheme.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(OpenClawChatTheme.composerField)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(OpenClawChatTheme.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(OpenClawChatTheme.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 OpenClaw…")
|
||||
.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
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
enum ChatMarkdownPreprocessor {
|
||||
struct InlineImage: Identifiable {
|
||||
let id = UUID()
|
||||
let label: String
|
||||
let image: OpenClawPlatformImage?
|
||||
}
|
||||
|
||||
struct Result {
|
||||
let cleaned: String
|
||||
let images: [InlineImage]
|
||||
}
|
||||
|
||||
static func preprocess(markdown raw: String) -> Result {
|
||||
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
|
||||
guard let re = try? NSRegularExpression(pattern: pattern) else {
|
||||
return Result(cleaned: raw, images: [])
|
||||
}
|
||||
|
||||
let ns = raw as NSString
|
||||
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
|
||||
if matches.isEmpty { return Result(cleaned: raw, images: []) }
|
||||
|
||||
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: OpenClawPlatformImage? = {
|
||||
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 OpenClawPlatformImage(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 Result(cleaned: normalized, images: images.reversed())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import SwiftUI
|
||||
import Textual
|
||||
|
||||
public enum ChatMarkdownVariant: String, CaseIterable, Sendable {
|
||||
case standard
|
||||
case compact
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ChatMarkdownRenderer: View {
|
||||
enum Context {
|
||||
case user
|
||||
case assistant
|
||||
}
|
||||
|
||||
let text: String
|
||||
let context: Context
|
||||
let variant: ChatMarkdownVariant
|
||||
let font: Font
|
||||
let textColor: Color
|
||||
|
||||
var body: some View {
|
||||
let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
StructuredText(markdown: processed.cleaned)
|
||||
.modifier(ChatMarkdownStyle(
|
||||
variant: self.variant,
|
||||
context: self.context,
|
||||
font: self.font,
|
||||
textColor: self.textColor))
|
||||
|
||||
if !processed.images.isEmpty {
|
||||
InlineImageList(images: processed.images)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatMarkdownStyle: ViewModifier {
|
||||
let variant: ChatMarkdownVariant
|
||||
let context: ChatMarkdownRenderer.Context
|
||||
let font: Font
|
||||
let textColor: Color
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
Group {
|
||||
if self.variant == .compact {
|
||||
content.textual.structuredTextStyle(.default)
|
||||
} else {
|
||||
content.textual.structuredTextStyle(.gitHub)
|
||||
}
|
||||
}
|
||||
.font(self.font)
|
||||
.foregroundStyle(self.textColor)
|
||||
.textual.inlineStyle(self.inlineStyle)
|
||||
.textual.textSelection(.enabled)
|
||||
}
|
||||
|
||||
private var inlineStyle: InlineStyle {
|
||||
let linkColor: Color = self.context == .user ? self.textColor : .accentColor
|
||||
let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9
|
||||
return InlineStyle()
|
||||
.code(.monospaced, .fontScale(codeScale))
|
||||
.link(.foregroundColor(linkColor))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct InlineImageList: View {
|
||||
let images: [ChatMarkdownPreprocessor.InlineImage]
|
||||
|
||||
var body: some View {
|
||||
ForEach(images, id: \.id) { item in
|
||||
if let img = item.image {
|
||||
OpenClawPlatformImageFactory.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
import OpenClawKit
|
||||
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: OpenClawChatMessage
|
||||
let style: OpenClawChatView.Style
|
||||
let markdownVariant: ChatMarkdownVariant
|
||||
let userAccent: Color?
|
||||
|
||||
var body: some View {
|
||||
ChatMessageBody(
|
||||
message: self.message,
|
||||
isUser: self.isUser,
|
||||
style: self.style,
|
||||
markdownVariant: self.markdownVariant,
|
||||
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: OpenClawChatMessage
|
||||
let isUser: Bool
|
||||
let style: OpenClawChatView.Style
|
||||
let markdownVariant: ChatMarkdownVariant
|
||||
let userAccent: Color?
|
||||
|
||||
var body: some View {
|
||||
let text = self.primaryText
|
||||
let textColor = self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.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 {
|
||||
ChatMarkdownRenderer(
|
||||
text: text,
|
||||
context: .user,
|
||||
variant: self.markdownVariant,
|
||||
font: .system(size: 14),
|
||||
textColor: textColor)
|
||||
} else {
|
||||
ChatAssistantTextBody(text: text, markdownVariant: self.markdownVariant)
|
||||
}
|
||||
|
||||
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: [OpenClawChatMessageContent] {
|
||||
self.message.content.filter { content in
|
||||
switch content.type ?? "text" {
|
||||
case "file", "attachment":
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var toolCalls: [OpenClawChatMessageContent] {
|
||||
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: [OpenClawChatMessageContent] {
|
||||
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 ?? OpenClawChatTheme.userBubble
|
||||
}
|
||||
if self.style == .onboarding {
|
||||
return OpenClawChatTheme.onboardingAssistantBubble
|
||||
}
|
||||
return OpenClawChatTheme.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 OpenClawChatTheme.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: OpenClawChatMessageContent
|
||||
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 ? OpenClawChatTheme.userText : OpenClawChatTheme.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: OpenClawChatMessageContent
|
||||
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(OpenClawChatTheme.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 ? OpenClawChatTheme.userText : OpenClawChatTheme.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(OpenClawChatTheme.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: OpenClawChatView.Style
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
TypingDots()
|
||||
if self.style == .standard {
|
||||
Text("OpenClaw 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(OpenClawChatTheme.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
|
||||
let markdownVariant: ChatMarkdownVariant
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ChatAssistantTextBody(text: self.text, markdownVariant: self.markdownVariant)
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(OpenClawChatTheme.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: [OpenClawChatPendingToolCall]
|
||||
|
||||
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(OpenClawChatTheme.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
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatAssistantTextBody: View {
|
||||
let text: String
|
||||
let markdownVariant: ChatMarkdownVariant
|
||||
|
||||
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)
|
||||
ChatMarkdownRenderer(
|
||||
text: segment.text,
|
||||
context: .assistant,
|
||||
variant: self.markdownVariant,
|
||||
font: font,
|
||||
textColor: OpenClawChatTheme.assistantText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
332
apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift
Normal file
332
apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift
Normal file
@@ -0,0 +1,332 @@
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
|
||||
// NOTE: keep this file lightweight; decode must be resilient to varying transcript formats.
|
||||
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
|
||||
public typealias OpenClawPlatformImage = NSImage
|
||||
#elseif canImport(UIKit)
|
||||
import UIKit
|
||||
|
||||
public typealias OpenClawPlatformImage = UIImage
|
||||
#endif
|
||||
|
||||
public struct OpenClawChatUsageCost: 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 OpenClawChatUsage: Codable, Hashable, Sendable {
|
||||
public let input: Int?
|
||||
public let output: Int?
|
||||
public let cacheRead: Int?
|
||||
public let cacheWrite: Int?
|
||||
public let cost: OpenClawChatUsageCost?
|
||||
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(OpenClawChatUsageCost.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 OpenClawChatMessageContent: 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 OpenClawChatMessage: Codable, Identifiable, Sendable {
|
||||
public var id: UUID = .init()
|
||||
public let role: String
|
||||
public let content: [OpenClawChatMessageContent]
|
||||
public let timestamp: Double?
|
||||
public let toolCallId: String?
|
||||
public let toolName: String?
|
||||
public let usage: OpenClawChatUsage?
|
||||
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: [OpenClawChatMessageContent],
|
||||
timestamp: Double?,
|
||||
toolCallId: String? = nil,
|
||||
toolName: String? = nil,
|
||||
usage: OpenClawChatUsage? = 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(OpenClawChatUsage.self, forKey: .usage)
|
||||
self.stopReason = try container.decodeIfPresent(String.self, forKey: .stopReason)
|
||||
|
||||
if let decoded = try? container.decode([OpenClawChatMessageContent].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 = [
|
||||
OpenClawChatMessageContent(
|
||||
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 OpenClawChatHistoryPayload: Codable, Sendable {
|
||||
public let sessionKey: String
|
||||
public let sessionId: String?
|
||||
public let messages: [AnyCodable]?
|
||||
public let thinkingLevel: String?
|
||||
}
|
||||
|
||||
public struct OpenClawSessionPreviewItem: Codable, Hashable, Sendable {
|
||||
public let role: String
|
||||
public let text: String
|
||||
}
|
||||
|
||||
public struct OpenClawSessionPreviewEntry: Codable, Sendable {
|
||||
public let key: String
|
||||
public let status: String
|
||||
public let items: [OpenClawSessionPreviewItem]
|
||||
}
|
||||
|
||||
public struct OpenClawSessionsPreviewPayload: Codable, Sendable {
|
||||
public let ts: Int
|
||||
public let previews: [OpenClawSessionPreviewEntry]
|
||||
|
||||
public init(ts: Int, previews: [OpenClawSessionPreviewEntry]) {
|
||||
self.ts = ts
|
||||
self.previews = previews
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawChatSendResponse: Codable, Sendable {
|
||||
public let runId: String
|
||||
public let status: String
|
||||
}
|
||||
|
||||
public struct OpenClawChatEventPayload: Codable, Sendable {
|
||||
public let runId: String?
|
||||
public let sessionKey: String?
|
||||
public let state: String?
|
||||
public let message: AnyCodable?
|
||||
public let errorMessage: String?
|
||||
}
|
||||
|
||||
public struct OpenClawAgentEventPayload: 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 OpenClawChatPendingToolCall: 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 OpenClawGatewayHealthOK: Codable, Sendable {
|
||||
public let ok: Bool?
|
||||
}
|
||||
|
||||
public struct OpenClawPendingAttachment: 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: OpenClawPlatformImage?
|
||||
|
||||
public init(
|
||||
url: URL?,
|
||||
data: Data,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
type: String = "file",
|
||||
preview: OpenClawPlatformImage?)
|
||||
{
|
||||
self.url = url
|
||||
self.data = data
|
||||
self.fileName = fileName
|
||||
self.mimeType = mimeType
|
||||
self.type = type
|
||||
self.preview = preview
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawChatAttachmentPayload: 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import OpenClawKit
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
public struct OpenClawChatSessionsDefaults: Codable, Sendable {
|
||||
public let model: String?
|
||||
public let contextTokens: Int?
|
||||
}
|
||||
|
||||
public struct OpenClawChatSessionEntry: 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 OpenClawChatSessionsListResponse: Codable, Sendable {
|
||||
public let ts: Double?
|
||||
public let path: String?
|
||||
public let count: Int?
|
||||
public let defaults: OpenClawChatSessionsDefaults?
|
||||
public let sessions: [OpenClawChatSessionEntry]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct ChatSessionsSheet: View {
|
||||
@Bindable var viewModel: OpenClawChatViewModel
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
174
apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTheme.swift
Normal file
174
apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTheme.swift
Normal 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 OpenClawChatTheme {
|
||||
#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("OpenClawChatTheme.assistantBubble"),
|
||||
dynamicProvider: resolvedAssistantBubbleColor(for:))
|
||||
|
||||
static let onboardingAssistantBubbleDynamicNSColor = NSColor(
|
||||
name: NSColor.Name("OpenClawChatTheme.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 OpenClawPlatformImageFactory {
|
||||
static func image(_ image: OpenClawPlatformImage) -> Image {
|
||||
#if os(macOS)
|
||||
Image(nsImage: image)
|
||||
#else
|
||||
Image(uiImage: image)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawChatTransportEvent: Sendable {
|
||||
case health(ok: Bool)
|
||||
case tick
|
||||
case chat(OpenClawChatEventPayload)
|
||||
case agent(OpenClawAgentEventPayload)
|
||||
case seqGap
|
||||
}
|
||||
|
||||
public protocol OpenClawChatTransport: Sendable {
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload
|
||||
func sendMessage(
|
||||
sessionKey: String,
|
||||
message: String,
|
||||
thinking: String,
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
|
||||
func abortRun(sessionKey: String, runId: String) async throws
|
||||
func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse
|
||||
|
||||
func requestHealth(timeoutMs: Int) async throws -> Bool
|
||||
func events() -> AsyncStream<OpenClawChatTransportEvent>
|
||||
|
||||
func setActiveSessionKey(_ sessionKey: String) async throws
|
||||
}
|
||||
|
||||
extension OpenClawChatTransport {
|
||||
public func setActiveSessionKey(_: String) async throws {}
|
||||
|
||||
public func abortRun(sessionKey _: String, runId _: String) async throws {
|
||||
throw NSError(
|
||||
domain: "OpenClawChatTransport",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "chat.abort not supported by this transport"])
|
||||
}
|
||||
|
||||
public func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse {
|
||||
throw NSError(
|
||||
domain: "OpenClawChatTransport",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "sessions.list not supported by this transport"])
|
||||
}
|
||||
}
|
||||
507
apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift
Normal file
507
apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift
Normal file
@@ -0,0 +1,507 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct OpenClawChatView: View {
|
||||
public enum Style {
|
||||
case standard
|
||||
case onboarding
|
||||
}
|
||||
|
||||
@State private var viewModel: OpenClawChatViewModel
|
||||
@State private var scrollerBottomID = UUID()
|
||||
@State private var scrollPosition: UUID?
|
||||
@State private var showSessions = false
|
||||
@State private var hasPerformedInitialScroll = false
|
||||
@State private var isPinnedToBottom = true
|
||||
@State private var lastUserMessageID: UUID?
|
||||
private let showsSessionSwitcher: Bool
|
||||
private let style: Style
|
||||
private let markdownVariant: ChatMarkdownVariant
|
||||
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: OpenClawChatViewModel,
|
||||
showsSessionSwitcher: Bool = false,
|
||||
style: Style = .standard,
|
||||
markdownVariant: ChatMarkdownVariant = .standard,
|
||||
userAccent: Color? = nil)
|
||||
{
|
||||
self._viewModel = State(initialValue: viewModel)
|
||||
self.showsSessionSwitcher = showsSessionSwitcher
|
||||
self.style = style
|
||||
self.markdownVariant = markdownVariant
|
||||
self.userAccent = userAccent
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
if self.style == .standard {
|
||||
OpenClawChatTheme.background
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
VStack(spacing: Layout.stackSpacing) {
|
||||
self.messageList
|
||||
.padding(.horizontal, Layout.outerPaddingHorizontal)
|
||||
OpenClawChatComposer(
|
||||
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 {
|
||||
LazyVStack(spacing: Layout.messageSpacing) {
|
||||
self.messageListRows
|
||||
|
||||
Color.clear
|
||||
#if os(macOS)
|
||||
.frame(height: Layout.messageListPaddingBottom)
|
||||
#else
|
||||
.frame(height: Layout.messageListPaddingBottom + 1)
|
||||
#endif
|
||||
.id(self.scrollerBottomID)
|
||||
}
|
||||
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
|
||||
.scrollTargetLayout()
|
||||
.padding(.top, Layout.messageListPaddingTop)
|
||||
.padding(.horizontal, Layout.messageListPaddingHorizontal)
|
||||
}
|
||||
// Keep the scroll pinned to the bottom for new messages.
|
||||
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
|
||||
.onChange(of: self.scrollPosition) { _, position in
|
||||
guard let position else { return }
|
||||
self.isPinnedToBottom = position == self.scrollerBottomID
|
||||
}
|
||||
|
||||
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
|
||||
self.isPinnedToBottom = true
|
||||
}
|
||||
.onChange(of: self.viewModel.sessionKey) { _, _ in
|
||||
self.hasPerformedInitialScroll = false
|
||||
self.isPinnedToBottom = true
|
||||
}
|
||||
.onChange(of: self.viewModel.isSending) { _, isSending in
|
||||
// Scroll to bottom when user sends a message, even if scrolled up.
|
||||
guard isSending, self.hasPerformedInitialScroll else { return }
|
||||
self.isPinnedToBottom = true
|
||||
withAnimation(.snappy(duration: 0.22)) {
|
||||
self.scrollPosition = self.scrollerBottomID
|
||||
}
|
||||
}
|
||||
.onChange(of: self.viewModel.messages.count) { _, _ in
|
||||
guard self.hasPerformedInitialScroll else { return }
|
||||
if let lastMessage = self.viewModel.messages.last,
|
||||
lastMessage.role.lowercased() == "user",
|
||||
lastMessage.id != self.lastUserMessageID {
|
||||
self.lastUserMessageID = lastMessage.id
|
||||
self.isPinnedToBottom = true
|
||||
withAnimation(.snappy(duration: 0.22)) {
|
||||
self.scrollPosition = self.scrollerBottomID
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard self.isPinnedToBottom else { return }
|
||||
withAnimation(.snappy(duration: 0.22)) {
|
||||
self.scrollPosition = self.scrollerBottomID
|
||||
}
|
||||
}
|
||||
.onChange(of: self.viewModel.pendingRunCount) { _, _ in
|
||||
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
|
||||
withAnimation(.snappy(duration: 0.22)) {
|
||||
self.scrollPosition = self.scrollerBottomID
|
||||
}
|
||||
}
|
||||
.onChange(of: self.viewModel.streamingAssistantText) { _, _ in
|
||||
guard self.hasPerformedInitialScroll, self.isPinnedToBottom 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,
|
||||
markdownVariant: self.markdownVariant,
|
||||
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, markdownVariant: self.markdownVariant)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private var visibleMessages: [OpenClawChatMessage] {
|
||||
let base: [OpenClawChatMessage]
|
||||
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: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
|
||||
var result: [OpenClawChatMessage] = []
|
||||
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(
|
||||
OpenClawChatMessageContent(
|
||||
type: "tool_result",
|
||||
text: toolText,
|
||||
thinking: nil,
|
||||
thinkingSignature: nil,
|
||||
mimeType: nil,
|
||||
fileName: nil,
|
||||
content: nil,
|
||||
id: toolCallId,
|
||||
name: message.toolName,
|
||||
arguments: nil))
|
||||
|
||||
let merged = OpenClawChatMessage(
|
||||
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: OpenClawChatMessage) -> Bool {
|
||||
let role = message.role.lowercased()
|
||||
return role == "toolresult" || role == "tool_result"
|
||||
}
|
||||
|
||||
private func toolCallIds(in message: OpenClawChatMessage) -> 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: OpenClawChatMessage) -> 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(OpenClawChatTheme.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(OpenClawChatTheme.subtleCard)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#elseif canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
private let chatUILogger = Logger(subsystem: "ai.openclaw", category: "OpenClawChatUI")
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class OpenClawChatViewModel {
|
||||
public private(set) var messages: [OpenClawChatMessage] = []
|
||||
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: [OpenClawPendingAttachment] = []
|
||||
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: [OpenClawChatPendingToolCall] = []
|
||||
public private(set) var sessions: [OpenClawChatSessionEntry] = []
|
||||
private let transport: any OpenClawChatTransport
|
||||
|
||||
@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: OpenClawChatPendingToolCall] = [:] {
|
||||
didSet {
|
||||
self.pendingToolCalls = self.pendingToolCallsById.values
|
||||
.sorted { ($0.startedAt ?? 0) < ($1.startedAt ?? 0) }
|
||||
}
|
||||
}
|
||||
|
||||
private var lastHealthPollAt: Date?
|
||||
|
||||
public init(sessionKey: String, transport: any OpenClawChatTransport) {
|
||||
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: [OpenClawChatSessionEntry] {
|
||||
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: [OpenClawChatSessionEntry] = []
|
||||
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)
|
||||
}
|
||||
|
||||
var result: [OpenClawChatSessionEntry] = []
|
||||
var included = Set<String>()
|
||||
for entry in recent where !included.contains(entry.key) {
|
||||
result.append(entry)
|
||||
included.insert(entry.key)
|
||||
}
|
||||
|
||||
if !included.contains(self.sessionKey) {
|
||||
if let current = sorted.first(where: { $0.key == self.sessionKey }) {
|
||||
result.append(current)
|
||||
} else {
|
||||
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: OpenClawPendingAttachment.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]) -> [OpenClawChatMessage] {
|
||||
let decoded = raw.compactMap { item in
|
||||
(try? ChatPayloadDecoding.decode(item, as: OpenClawChatMessage.self))
|
||||
}
|
||||
return Self.dedupeMessages(decoded)
|
||||
}
|
||||
|
||||
private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
|
||||
var result: [OpenClawChatMessage] = []
|
||||
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: OpenClawChatMessage) -> 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: [OpenClawChatMessageContent] = [
|
||||
OpenClawChatMessageContent(
|
||||
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 -> OpenClawChatAttachmentPayload in
|
||||
OpenClawChatAttachmentPayload(
|
||||
type: att.type,
|
||||
mimeType: att.mimeType,
|
||||
fileName: att.fileName,
|
||||
content: att.data.base64EncodedString())
|
||||
}
|
||||
for att in encodedAttachments {
|
||||
userContent.append(
|
||||
OpenClawChatMessageContent(
|
||||
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(
|
||||
OpenClawChatMessage(
|
||||
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) -> OpenClawChatSessionEntry {
|
||||
OpenClawChatSessionEntry(
|
||||
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: OpenClawChatTransportEvent) {
|
||||
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: OpenClawChatEventPayload) {
|
||||
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: OpenClawAgentEventPayload) {
|
||||
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] = OpenClawChatPendingToolCall(
|
||||
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(
|
||||
OpenClawPendingAttachment(
|
||||
url: url,
|
||||
data: data,
|
||||
fileName: fileName,
|
||||
mimeType: mimeType,
|
||||
preview: preview))
|
||||
}
|
||||
|
||||
private static func previewImage(data: Data) -> OpenClawPlatformImage? {
|
||||
#if canImport(AppKit)
|
||||
NSImage(data: data)
|
||||
#elseif canImport(UIKit)
|
||||
UIImage(data: data)
|
||||
#else
|
||||
nil
|
||||
#endif
|
||||
}
|
||||
}
|
||||
93
apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable.swift
Normal file
93
apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
public enum AsyncTimeout {
|
||||
public static func withTimeout<T: Sendable>(
|
||||
seconds: Double,
|
||||
onTimeout: @escaping @Sendable () -> Error,
|
||||
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
let clamped = max(0, seconds)
|
||||
if clamped == 0 {
|
||||
return try await operation()
|
||||
}
|
||||
|
||||
return try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask { try await operation() }
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(clamped * 1_000_000_000))
|
||||
throw onTimeout()
|
||||
}
|
||||
let result = try await group.next()
|
||||
group.cancelAll()
|
||||
if let result { return result }
|
||||
throw onTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
public static func withTimeoutMs<T: Sendable>(
|
||||
timeoutMs: Int,
|
||||
onTimeout: @escaping @Sendable () -> Error,
|
||||
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
let clamped = max(0, timeoutMs)
|
||||
let seconds = Double(clamped) / 1000.0
|
||||
return try await self.withTimeout(seconds: seconds, onTimeout: onTimeout, operation: operation)
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawBonjour {
|
||||
// v0: internal-only, subject to rename.
|
||||
public static let gatewayServiceType = "_openclaw-gw._tcp"
|
||||
public static let gatewayServiceDomain = "local."
|
||||
public static var wideAreaGatewayServiceDomain: String? {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
return resolveWideAreaDomain(env["OPENCLAW_WIDE_AREA_DOMAIN"])
|
||||
}
|
||||
|
||||
public static var gatewayServiceDomains: [String] {
|
||||
var domains = [gatewayServiceDomain]
|
||||
if let wideArea = wideAreaGatewayServiceDomain {
|
||||
domains.append(wideArea)
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
private static func resolveWideAreaDomain(_ raw: String?) -> String? {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
let normalized = normalizeServiceDomain(trimmed)
|
||||
return normalized == gatewayServiceDomain ? nil : normalized
|
||||
}
|
||||
|
||||
public static func normalizeServiceDomain(_ raw: String?) -> String {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
return self.gatewayServiceDomain
|
||||
}
|
||||
|
||||
let lower = trimmed.lowercased()
|
||||
if lower == "local" || lower == "local." {
|
||||
return self.gatewayServiceDomain
|
||||
}
|
||||
|
||||
return lower.hasSuffix(".") ? lower : (lower + ".")
|
||||
}
|
||||
}
|
||||
261
apps/shared/OpenClawKit/Sources/OpenClawKit/BridgeFrames.swift
Normal file
261
apps/shared/OpenClawKit/Sources/OpenClawKit/BridgeFrames.swift
Normal file
@@ -0,0 +1,261 @@
|
||||
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: OpenClawNodeError?
|
||||
|
||||
public init(
|
||||
type: String = "invoke-res",
|
||||
id: String,
|
||||
ok: Bool,
|
||||
payloadJSON: String? = nil,
|
||||
error: OpenClawNodeError? = 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 coreVersion: String?
|
||||
public let uiVersion: 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?,
|
||||
coreVersion: String? = nil,
|
||||
uiVersion: String? = nil,
|
||||
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.coreVersion = coreVersion
|
||||
self.uiVersion = uiVersion
|
||||
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 let mainSessionKey: String?
|
||||
|
||||
public init(
|
||||
type: String = "hello-ok",
|
||||
serverName: String,
|
||||
canvasHostUrl: String? = nil,
|
||||
mainSessionKey: String? = nil)
|
||||
{
|
||||
self.type = type
|
||||
self.serverName = serverName
|
||||
self.canvasHostUrl = canvasHostUrl
|
||||
self.mainSessionKey = mainSessionKey
|
||||
}
|
||||
}
|
||||
|
||||
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 coreVersion: String?
|
||||
public let uiVersion: 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?,
|
||||
coreVersion: String? = nil,
|
||||
uiVersion: String? = nil,
|
||||
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.coreVersion = coreVersion
|
||||
self.uiVersion = uiVersion
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawCameraCommand: String, Codable, Sendable {
|
||||
case list = "camera.list"
|
||||
case snap = "camera.snap"
|
||||
case clip = "camera.clip"
|
||||
}
|
||||
|
||||
public enum OpenClawCameraFacing: String, Codable, Sendable {
|
||||
case back
|
||||
case front
|
||||
}
|
||||
|
||||
public enum OpenClawCameraImageFormat: String, Codable, Sendable {
|
||||
case jpg
|
||||
case jpeg
|
||||
}
|
||||
|
||||
public enum OpenClawCameraVideoFormat: String, Codable, Sendable {
|
||||
case mp4
|
||||
}
|
||||
|
||||
public struct OpenClawCameraSnapParams: Codable, Sendable, Equatable {
|
||||
public var facing: OpenClawCameraFacing?
|
||||
public var maxWidth: Int?
|
||||
public var quality: Double?
|
||||
public var format: OpenClawCameraImageFormat?
|
||||
public var deviceId: String?
|
||||
public var delayMs: Int?
|
||||
|
||||
public init(
|
||||
facing: OpenClawCameraFacing? = nil,
|
||||
maxWidth: Int? = nil,
|
||||
quality: Double? = nil,
|
||||
format: OpenClawCameraImageFormat? = 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 OpenClawCameraClipParams: Codable, Sendable, Equatable {
|
||||
public var facing: OpenClawCameraFacing?
|
||||
public var durationMs: Int?
|
||||
public var includeAudio: Bool?
|
||||
public var format: OpenClawCameraVideoFormat?
|
||||
public var deviceId: String?
|
||||
|
||||
public init(
|
||||
facing: OpenClawCameraFacing? = nil,
|
||||
durationMs: Int? = nil,
|
||||
includeAudio: Bool? = nil,
|
||||
format: OpenClawCameraVideoFormat? = nil,
|
||||
deviceId: String? = nil)
|
||||
{
|
||||
self.facing = facing
|
||||
self.durationMs = durationMs
|
||||
self.includeAudio = includeAudio
|
||||
self.format = format
|
||||
self.deviceId = deviceId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawCanvasA2UIAction: 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 """
|
||||
(() => {
|
||||
const detail = \(json);
|
||||
window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail }));
|
||||
})();
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawCanvasA2UICommand: 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 OpenClawCanvasA2UIPushParams: Codable, Sendable, Equatable {
|
||||
public var messages: [AnyCodable]
|
||||
|
||||
public init(messages: [AnyCodable]) {
|
||||
self.messages = messages
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawCanvasA2UIPushJSONLParams: Codable, Sendable, Equatable {
|
||||
public var jsonl: String
|
||||
|
||||
public init(jsonl: String) {
|
||||
self.jsonl = jsonl
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawCanvasA2UIJSONL: 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
|
||||
public struct OpenClawCanvasNavigateParams: Codable, Sendable, Equatable {
|
||||
public var url: String
|
||||
|
||||
public init(url: String) {
|
||||
self.url = url
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawCanvasPlacement: 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 OpenClawCanvasPresentParams: Codable, Sendable, Equatable {
|
||||
public var url: String?
|
||||
public var placement: OpenClawCanvasPlacement?
|
||||
|
||||
public init(url: String? = nil, placement: OpenClawCanvasPlacement? = nil) {
|
||||
self.url = url
|
||||
self.placement = placement
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawCanvasEvalParams: Codable, Sendable, Equatable {
|
||||
public var javaScript: String
|
||||
|
||||
public init(javaScript: String) {
|
||||
self.javaScript = javaScript
|
||||
}
|
||||
}
|
||||
|
||||
public enum OpenClawCanvasSnapshotFormat: 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 OpenClawCanvasSnapshotParams: Codable, Sendable, Equatable {
|
||||
public var maxWidth: Int?
|
||||
public var quality: Double?
|
||||
public var format: OpenClawCanvasSnapshotFormat?
|
||||
|
||||
public init(maxWidth: Int? = nil, quality: Double? = nil, format: OpenClawCanvasSnapshotFormat? = nil) {
|
||||
self.maxWidth = maxWidth
|
||||
self.quality = quality
|
||||
self.format = format
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawCanvasCommand: String, Codable, Sendable {
|
||||
case present = "canvas.present"
|
||||
case hide = "canvas.hide"
|
||||
case navigate = "canvas.navigate"
|
||||
case evalJS = "canvas.eval"
|
||||
case snapshot = "canvas.snapshot"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawCapability: String, Codable, Sendable {
|
||||
case canvas
|
||||
case camera
|
||||
case screen
|
||||
case voiceWake
|
||||
case location
|
||||
}
|
||||
76
apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift
Normal file
76
apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift
Normal file
@@ -0,0 +1,76 @@
|
||||
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 let scheme = url.scheme?.lowercased(),
|
||||
scheme == "openclaw"
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
|
||||
public struct DeviceAuthEntry: Codable, Sendable {
|
||||
public let token: String
|
||||
public let role: String
|
||||
public let scopes: [String]
|
||||
public let updatedAtMs: Int
|
||||
|
||||
public init(token: String, role: String, scopes: [String], updatedAtMs: Int) {
|
||||
self.token = token
|
||||
self.role = role
|
||||
self.scopes = scopes
|
||||
self.updatedAtMs = updatedAtMs
|
||||
}
|
||||
}
|
||||
|
||||
private struct DeviceAuthStoreFile: Codable {
|
||||
var version: Int
|
||||
var deviceId: String
|
||||
var tokens: [String: DeviceAuthEntry]
|
||||
}
|
||||
|
||||
public enum DeviceAuthStore {
|
||||
private static let fileName = "device-auth.json"
|
||||
|
||||
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
|
||||
guard let store = readStore(), store.deviceId == deviceId else { return nil }
|
||||
let role = normalizeRole(role)
|
||||
return store.tokens[role]
|
||||
}
|
||||
|
||||
public static func storeToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: [String] = []
|
||||
) -> DeviceAuthEntry {
|
||||
let normalizedRole = normalizeRole(role)
|
||||
var next = readStore()
|
||||
if next?.deviceId != deviceId {
|
||||
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
||||
}
|
||||
let entry = DeviceAuthEntry(
|
||||
token: token,
|
||||
role: normalizedRole,
|
||||
scopes: normalizeScopes(scopes),
|
||||
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000)
|
||||
)
|
||||
if next == nil {
|
||||
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
||||
}
|
||||
next?.tokens[normalizedRole] = entry
|
||||
if let store = next {
|
||||
writeStore(store)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
public static func clearToken(deviceId: String, role: String) {
|
||||
guard var store = readStore(), store.deviceId == deviceId else { return }
|
||||
let normalizedRole = normalizeRole(role)
|
||||
guard store.tokens[normalizedRole] != nil else { return }
|
||||
store.tokens.removeValue(forKey: normalizedRole)
|
||||
writeStore(store)
|
||||
}
|
||||
|
||||
private static func normalizeRole(_ role: String) -> String {
|
||||
role.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func normalizeScopes(_ scopes: [String]) -> [String] {
|
||||
let trimmed = scopes
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
return Array(Set(trimmed)).sorted()
|
||||
}
|
||||
|
||||
private static func fileURL() -> URL {
|
||||
DeviceIdentityPaths.stateDirURL()
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent(fileName, isDirectory: false)
|
||||
}
|
||||
|
||||
private static func readStore() -> DeviceAuthStoreFile? {
|
||||
let url = fileURL()
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
|
||||
return nil
|
||||
}
|
||||
guard decoded.version == 1 else { return nil }
|
||||
return decoded
|
||||
}
|
||||
|
||||
private static func writeStore(_ store: DeviceAuthStoreFile) {
|
||||
let url = fileURL()
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder().encode(store)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
} catch {
|
||||
// best-effort only
|
||||
}
|
||||
}
|
||||
}
|
||||
112
apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift
Normal file
112
apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift
Normal file
@@ -0,0 +1,112 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
public struct DeviceIdentity: Codable, Sendable {
|
||||
public var deviceId: String
|
||||
public var publicKey: String
|
||||
public var privateKey: String
|
||||
public var createdAtMs: Int
|
||||
|
||||
public init(deviceId: String, publicKey: String, privateKey: String, createdAtMs: Int) {
|
||||
self.deviceId = deviceId
|
||||
self.publicKey = publicKey
|
||||
self.privateKey = privateKey
|
||||
self.createdAtMs = createdAtMs
|
||||
}
|
||||
}
|
||||
|
||||
enum DeviceIdentityPaths {
|
||||
private static let stateDirEnv = ["OPENCLAW_STATE_DIR"]
|
||||
|
||||
static func stateDirURL() -> URL {
|
||||
for key in self.stateDirEnv {
|
||||
if let raw = getenv(key) {
|
||||
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !value.isEmpty {
|
||||
return URL(fileURLWithPath: value, isDirectory: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
|
||||
return appSupport.appendingPathComponent("OpenClaw", isDirectory: true)
|
||||
}
|
||||
|
||||
return FileManager.default.temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true)
|
||||
}
|
||||
}
|
||||
|
||||
public enum DeviceIdentityStore {
|
||||
private static let fileName = "device.json"
|
||||
|
||||
public static func loadOrCreate() -> DeviceIdentity {
|
||||
let url = self.fileURL()
|
||||
if let data = try? Data(contentsOf: url),
|
||||
let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data),
|
||||
!decoded.deviceId.isEmpty,
|
||||
!decoded.publicKey.isEmpty,
|
||||
!decoded.privateKey.isEmpty {
|
||||
return decoded
|
||||
}
|
||||
let identity = self.generate()
|
||||
self.save(identity)
|
||||
return identity
|
||||
}
|
||||
|
||||
public static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? {
|
||||
guard let privateKeyData = Data(base64Encoded: identity.privateKey) else { return nil }
|
||||
do {
|
||||
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyData)
|
||||
let signature = try privateKey.signature(for: Data(payload.utf8))
|
||||
return self.base64UrlEncode(signature)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func generate() -> DeviceIdentity {
|
||||
let privateKey = Curve25519.Signing.PrivateKey()
|
||||
let publicKey = privateKey.publicKey
|
||||
let publicKeyData = publicKey.rawRepresentation
|
||||
let privateKeyData = privateKey.rawRepresentation
|
||||
let deviceId = SHA256.hash(data: publicKeyData).compactMap { String(format: "%02x", $0) }.joined()
|
||||
return DeviceIdentity(
|
||||
deviceId: deviceId,
|
||||
publicKey: publicKeyData.base64EncodedString(),
|
||||
privateKey: privateKeyData.base64EncodedString(),
|
||||
createdAtMs: Int(Date().timeIntervalSince1970 * 1000))
|
||||
}
|
||||
|
||||
private static func base64UrlEncode(_ data: Data) -> String {
|
||||
let base64 = data.base64EncodedString()
|
||||
return base64
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
public static func publicKeyBase64Url(_ identity: DeviceIdentity) -> String? {
|
||||
guard let data = Data(base64Encoded: identity.publicKey) else { return nil }
|
||||
return self.base64UrlEncode(data)
|
||||
}
|
||||
|
||||
private static func save(_ identity: DeviceIdentity) {
|
||||
let url = self.fileURL()
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder().encode(identity)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
} catch {
|
||||
// best-effort only
|
||||
}
|
||||
}
|
||||
|
||||
private static func fileURL() -> URL {
|
||||
let base = DeviceIdentityPaths.stateDirURL()
|
||||
return base
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent(fileName, isDirectory: false)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
713
apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift
Normal file
713
apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift
Normal file
@@ -0,0 +1,713 @@
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
public protocol WebSocketTasking: AnyObject {
|
||||
var state: URLSessionTask.State { get }
|
||||
func resume()
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
|
||||
func send(_ message: URLSessionWebSocketTask.Message) async throws
|
||||
func receive() async throws -> URLSessionWebSocketTask.Message
|
||||
func receive(completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
|
||||
}
|
||||
|
||||
extension URLSessionWebSocketTask: WebSocketTasking {}
|
||||
|
||||
public struct WebSocketTaskBox: @unchecked Sendable {
|
||||
public let task: any WebSocketTasking
|
||||
public init(task: any WebSocketTasking) {
|
||||
self.task = task
|
||||
}
|
||||
|
||||
public var state: URLSessionTask.State { self.task.state }
|
||||
|
||||
public func resume() { self.task.resume() }
|
||||
|
||||
public func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
self.task.cancel(with: closeCode, reason: reason)
|
||||
}
|
||||
|
||||
public func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
||||
try await self.task.send(message)
|
||||
}
|
||||
|
||||
public func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||
try await self.task.receive()
|
||||
}
|
||||
|
||||
public func receive(
|
||||
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
|
||||
{
|
||||
self.task.receive(completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
public protocol WebSocketSessioning: AnyObject {
|
||||
func makeWebSocketTask(url: URL) -> WebSocketTaskBox
|
||||
}
|
||||
|
||||
extension URLSession: WebSocketSessioning {
|
||||
public func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
let task = self.webSocketTask(with: url)
|
||||
// Avoid "Message too long" receive errors for large snapshots / history payloads.
|
||||
task.maximumMessageSize = 16 * 1024 * 1024 // 16 MB
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
}
|
||||
|
||||
public struct WebSocketSessionBox: @unchecked Sendable {
|
||||
public let session: any WebSocketSessioning
|
||||
|
||||
public init(session: any WebSocketSessioning) {
|
||||
self.session = session
|
||||
}
|
||||
}
|
||||
|
||||
public struct GatewayConnectOptions: Sendable {
|
||||
public var role: String
|
||||
public var scopes: [String]
|
||||
public var caps: [String]
|
||||
public var commands: [String]
|
||||
public var permissions: [String: Bool]
|
||||
public var clientId: String
|
||||
public var clientMode: String
|
||||
public var clientDisplayName: String?
|
||||
|
||||
public init(
|
||||
role: String,
|
||||
scopes: [String],
|
||||
caps: [String],
|
||||
commands: [String],
|
||||
permissions: [String: Bool],
|
||||
clientId: String,
|
||||
clientMode: String,
|
||||
clientDisplayName: String?)
|
||||
{
|
||||
self.role = role
|
||||
self.scopes = scopes
|
||||
self.caps = caps
|
||||
self.commands = commands
|
||||
self.permissions = permissions
|
||||
self.clientId = clientId
|
||||
self.clientMode = clientMode
|
||||
self.clientDisplayName = clientDisplayName
|
||||
}
|
||||
}
|
||||
|
||||
public enum GatewayAuthSource: String, Sendable {
|
||||
case deviceToken = "device-token"
|
||||
case sharedToken = "shared-token"
|
||||
case password = "password"
|
||||
case none = "none"
|
||||
}
|
||||
|
||||
// Avoid ambiguity with the app's own AnyCodable type.
|
||||
private typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable
|
||||
|
||||
private enum ConnectChallengeError: Error {
|
||||
case timeout
|
||||
}
|
||||
|
||||
public actor GatewayChannelActor {
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway")
|
||||
private var task: WebSocketTaskBox?
|
||||
private var pending: [String: CheckedContinuation<GatewayFrame, Error>] = [:]
|
||||
private var connected = false
|
||||
private var isConnecting = false
|
||||
private var connectWaiters: [CheckedContinuation<Void, Error>] = []
|
||||
private var url: URL
|
||||
private var token: String?
|
||||
private var password: String?
|
||||
private let session: WebSocketSessioning
|
||||
private var backoffMs: Double = 500
|
||||
private var shouldReconnect = true
|
||||
private var lastSeq: Int?
|
||||
private var lastTick: Date?
|
||||
private var tickIntervalMs: Double = 30000
|
||||
private var lastAuthSource: GatewayAuthSource = .none
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
private let connectTimeoutSeconds: Double = 6
|
||||
private let connectChallengeTimeoutSeconds: Double = 0.75
|
||||
private var watchdogTask: Task<Void, Never>?
|
||||
private var tickTask: Task<Void, Never>?
|
||||
private let defaultRequestTimeoutMs: Double = 15000
|
||||
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
|
||||
private let connectOptions: GatewayConnectOptions?
|
||||
private let disconnectHandler: (@Sendable (String) async -> Void)?
|
||||
|
||||
public init(
|
||||
url: URL,
|
||||
token: String?,
|
||||
password: String? = nil,
|
||||
session: WebSocketSessionBox? = nil,
|
||||
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil,
|
||||
connectOptions: GatewayConnectOptions? = nil,
|
||||
disconnectHandler: (@Sendable (String) async -> Void)? = nil)
|
||||
{
|
||||
self.url = url
|
||||
self.token = token
|
||||
self.password = password
|
||||
self.session = session?.session ?? URLSession(configuration: .default)
|
||||
self.pushHandler = pushHandler
|
||||
self.connectOptions = connectOptions
|
||||
self.disconnectHandler = disconnectHandler
|
||||
Task { [weak self] in
|
||||
await self?.startWatchdog()
|
||||
}
|
||||
}
|
||||
|
||||
public func authSource() -> GatewayAuthSource { self.lastAuthSource }
|
||||
|
||||
public func shutdown() async {
|
||||
self.shouldReconnect = false
|
||||
self.connected = false
|
||||
|
||||
self.watchdogTask?.cancel()
|
||||
self.watchdogTask = nil
|
||||
|
||||
self.tickTask?.cancel()
|
||||
self.tickTask = nil
|
||||
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
self.task = nil
|
||||
|
||||
await self.failPending(NSError(
|
||||
domain: "Gateway",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
|
||||
|
||||
let waiters = self.connectWaiters
|
||||
self.connectWaiters.removeAll()
|
||||
for waiter in waiters {
|
||||
waiter.resume(throwing: NSError(
|
||||
domain: "Gateway",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
|
||||
}
|
||||
}
|
||||
|
||||
private func startWatchdog() {
|
||||
self.watchdogTask?.cancel()
|
||||
self.watchdogTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.watchdogLoop()
|
||||
}
|
||||
}
|
||||
|
||||
private func watchdogLoop() async {
|
||||
// Keep nudging reconnect in case exponential backoff stalls.
|
||||
while self.shouldReconnect {
|
||||
try? await Task.sleep(nanoseconds: 30 * 1_000_000_000) // 30s cadence
|
||||
guard self.shouldReconnect else { return }
|
||||
if self.connected { continue }
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "gateway watchdog reconnect")
|
||||
self.logger.error("gateway watchdog reconnect failed \(wrapped.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func connect() async throws {
|
||||
if self.connected, self.task?.state == .running { return }
|
||||
if self.isConnecting {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.connectWaiters.append(cont)
|
||||
}
|
||||
return
|
||||
}
|
||||
self.isConnecting = true
|
||||
defer { self.isConnecting = false }
|
||||
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
self.task = self.session.makeWebSocketTask(url: self.url)
|
||||
self.task?.resume()
|
||||
do {
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: self.connectTimeoutSeconds,
|
||||
onTimeout: {
|
||||
NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect timed out"])
|
||||
},
|
||||
operation: { try await self.sendConnect() })
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
|
||||
self.connected = false
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
await self.disconnectHandler?("connect failed: \(wrapped.localizedDescription)")
|
||||
let waiters = self.connectWaiters
|
||||
self.connectWaiters.removeAll()
|
||||
for waiter in waiters {
|
||||
waiter.resume(throwing: wrapped)
|
||||
}
|
||||
self.logger.error("gateway ws connect failed \(wrapped.localizedDescription, privacy: .public)")
|
||||
throw wrapped
|
||||
}
|
||||
self.listen()
|
||||
self.connected = true
|
||||
self.backoffMs = 500
|
||||
self.lastSeq = nil
|
||||
|
||||
let waiters = self.connectWaiters
|
||||
self.connectWaiters.removeAll()
|
||||
for waiter in waiters {
|
||||
waiter.resume(returning: ())
|
||||
}
|
||||
}
|
||||
|
||||
private func sendConnect() async throws {
|
||||
let platform = InstanceIdentity.platformString
|
||||
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
||||
let options = self.connectOptions ?? GatewayConnectOptions(
|
||||
role: "operator",
|
||||
scopes: ["operator.admin", "operator.approvals", "operator.pairing"],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-macos",
|
||||
clientMode: "ui",
|
||||
clientDisplayName: InstanceIdentity.displayName)
|
||||
let clientDisplayName = options.clientDisplayName ?? InstanceIdentity.displayName
|
||||
let clientId = options.clientId
|
||||
let clientMode = options.clientMode
|
||||
let role = options.role
|
||||
let scopes = options.scopes
|
||||
|
||||
let reqId = UUID().uuidString
|
||||
var client: [String: ProtoAnyCodable] = [
|
||||
"id": ProtoAnyCodable(clientId),
|
||||
"displayName": ProtoAnyCodable(clientDisplayName),
|
||||
"version": ProtoAnyCodable(
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
|
||||
"platform": ProtoAnyCodable(platform),
|
||||
"mode": ProtoAnyCodable(clientMode),
|
||||
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
|
||||
]
|
||||
client["deviceFamily"] = ProtoAnyCodable(InstanceIdentity.deviceFamily)
|
||||
if let model = InstanceIdentity.modelIdentifier {
|
||||
client["modelIdentifier"] = ProtoAnyCodable(model)
|
||||
}
|
||||
var params: [String: ProtoAnyCodable] = [
|
||||
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||
"client": ProtoAnyCodable(client),
|
||||
"caps": ProtoAnyCodable(options.caps),
|
||||
"locale": ProtoAnyCodable(primaryLocale),
|
||||
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
||||
"role": ProtoAnyCodable(role),
|
||||
"scopes": ProtoAnyCodable(scopes),
|
||||
]
|
||||
if !options.commands.isEmpty {
|
||||
params["commands"] = ProtoAnyCodable(options.commands)
|
||||
}
|
||||
if !options.permissions.isEmpty {
|
||||
params["permissions"] = ProtoAnyCodable(options.permissions)
|
||||
}
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token
|
||||
let authToken = storedToken ?? self.token
|
||||
let authSource: GatewayAuthSource
|
||||
if storedToken != nil {
|
||||
authSource = .deviceToken
|
||||
} else if authToken != nil {
|
||||
authSource = .sharedToken
|
||||
} else if self.password != nil {
|
||||
authSource = .password
|
||||
} else {
|
||||
authSource = .none
|
||||
}
|
||||
self.lastAuthSource = authSource
|
||||
self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)")
|
||||
let canFallbackToShared = storedToken != nil && self.token != nil
|
||||
if let authToken {
|
||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
|
||||
} else if let password = self.password {
|
||||
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
||||
}
|
||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
let connectNonce = try await self.waitForConnectChallenge()
|
||||
let scopesValue = scopes.joined(separator: ",")
|
||||
var payloadParts = [
|
||||
connectNonce == nil ? "v1" : "v2",
|
||||
identity.deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopesValue,
|
||||
String(signedAtMs),
|
||||
authToken ?? "",
|
||||
]
|
||||
if let connectNonce {
|
||||
payloadParts.append(connectNonce)
|
||||
}
|
||||
let payload = payloadParts.joined(separator: "|")
|
||||
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
|
||||
var device: [String: ProtoAnyCodable] = [
|
||||
"id": ProtoAnyCodable(identity.deviceId),
|
||||
"publicKey": ProtoAnyCodable(publicKey),
|
||||
"signature": ProtoAnyCodable(signature),
|
||||
"signedAt": ProtoAnyCodable(signedAtMs),
|
||||
]
|
||||
if let connectNonce {
|
||||
device["nonce"] = ProtoAnyCodable(connectNonce)
|
||||
}
|
||||
params["device"] = ProtoAnyCodable(device)
|
||||
}
|
||||
|
||||
let frame = RequestFrame(
|
||||
type: "req",
|
||||
id: reqId,
|
||||
method: "connect",
|
||||
params: ProtoAnyCodable(params))
|
||||
let data = try self.encoder.encode(frame)
|
||||
try await self.task?.send(.data(data))
|
||||
do {
|
||||
let response = try await self.waitForConnectResponse(reqId: reqId)
|
||||
try await self.handleConnectResponse(response, identity: identity, role: role)
|
||||
} catch {
|
||||
if canFallbackToShared {
|
||||
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func handleConnectResponse(
|
||||
_ res: ResponseFrame,
|
||||
identity: DeviceIdentity,
|
||||
role: String
|
||||
) async throws {
|
||||
if res.ok == false {
|
||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
||||
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||
}
|
||||
guard let payload = res.payload else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (missing payload)"])
|
||||
}
|
||||
let payloadData = try self.encoder.encode(payload)
|
||||
let ok = try decoder.decode(HelloOk.self, from: payloadData)
|
||||
if let tick = ok.policy["tickIntervalMs"]?.value as? Double {
|
||||
self.tickIntervalMs = tick
|
||||
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
|
||||
self.tickIntervalMs = Double(tick)
|
||||
}
|
||||
if let auth = ok.auth,
|
||||
let deviceToken = auth["deviceToken"]?.value as? String {
|
||||
let authRole = auth["role"]?.value as? String ?? role
|
||||
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
|
||||
.compactMap { $0.value as? String } ?? []
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: authRole,
|
||||
token: deviceToken,
|
||||
scopes: scopes)
|
||||
}
|
||||
self.lastTick = Date()
|
||||
self.tickTask?.cancel()
|
||||
self.tickTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.watchTicks()
|
||||
}
|
||||
await self.pushHandler?(.snapshot(ok))
|
||||
}
|
||||
|
||||
private func listen() {
|
||||
self.task?.receive { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case let .failure(err):
|
||||
Task { await self.handleReceiveFailure(err) }
|
||||
case let .success(msg):
|
||||
Task {
|
||||
await self.handle(msg)
|
||||
await self.listen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleReceiveFailure(_ err: Error) async {
|
||||
let wrapped = self.wrap(err, context: "gateway receive")
|
||||
self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)")
|
||||
self.connected = false
|
||||
await self.disconnectHandler?("receive failed: \(wrapped.localizedDescription)")
|
||||
await self.failPending(wrapped)
|
||||
await self.scheduleReconnect()
|
||||
}
|
||||
|
||||
private func handle(_ msg: URLSessionWebSocketTask.Message) async {
|
||||
let data: Data? = switch msg {
|
||||
case let .data(d): d
|
||||
case let .string(s): s.data(using: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
guard let data else { return }
|
||||
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else {
|
||||
self.logger.error("gateway decode failed")
|
||||
return
|
||||
}
|
||||
switch frame {
|
||||
case let .res(res):
|
||||
let id = res.id
|
||||
if let waiter = pending.removeValue(forKey: id) {
|
||||
waiter.resume(returning: .res(res))
|
||||
}
|
||||
case let .event(evt):
|
||||
if evt.event == "connect.challenge" { return }
|
||||
if let seq = evt.seq {
|
||||
if let last = lastSeq, seq > last + 1 {
|
||||
await self.pushHandler?(.seqGap(expected: last + 1, received: seq))
|
||||
}
|
||||
self.lastSeq = seq
|
||||
}
|
||||
if evt.event == "tick" { self.lastTick = Date() }
|
||||
await self.pushHandler?(.event(evt))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForConnectChallenge() async throws -> String? {
|
||||
guard let task = self.task else { return nil }
|
||||
do {
|
||||
return try await AsyncTimeout.withTimeout(
|
||||
seconds: self.connectChallengeTimeoutSeconds,
|
||||
onTimeout: { ConnectChallengeError.timeout },
|
||||
operation: { [weak self] in
|
||||
guard let self else { return nil }
|
||||
while true {
|
||||
let msg = try await task.receive()
|
||||
guard let data = self.decodeMessageData(msg) else { continue }
|
||||
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue }
|
||||
if case let .event(evt) = frame, evt.event == "connect.challenge" {
|
||||
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
|
||||
let nonce = payload["nonce"]?.value as? String {
|
||||
return nonce
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
if error is ConnectChallengeError { return nil }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForConnectResponse(reqId: String) async throws -> ResponseFrame {
|
||||
guard let task = self.task else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"])
|
||||
}
|
||||
while true {
|
||||
let msg = try await task.receive()
|
||||
guard let data = self.decodeMessageData(msg) else { continue }
|
||||
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (invalid response)"])
|
||||
}
|
||||
if case let .res(res) = frame, res.id == reqId {
|
||||
return res
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? {
|
||||
let data: Data? = switch msg {
|
||||
case let .data(data): data
|
||||
case let .string(text): text.data(using: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private func watchTicks() async {
|
||||
let tolerance = self.tickIntervalMs * 2
|
||||
while self.connected {
|
||||
try? await Task.sleep(nanoseconds: UInt64(tolerance * 1_000_000))
|
||||
guard self.connected else { return }
|
||||
if let last = self.lastTick {
|
||||
let delta = Date().timeIntervalSince(last) * 1000
|
||||
if delta > tolerance {
|
||||
self.logger.error("gateway tick missed; reconnecting")
|
||||
self.connected = false
|
||||
await self.failPending(
|
||||
NSError(
|
||||
domain: "Gateway",
|
||||
code: 4,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway tick missed; reconnecting"]))
|
||||
await self.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleReconnect() async {
|
||||
guard self.shouldReconnect else { return }
|
||||
let delay = self.backoffMs / 1000
|
||||
self.backoffMs = min(self.backoffMs * 2, 30000)
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
guard self.shouldReconnect else { return }
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "gateway reconnect")
|
||||
self.logger.error("gateway reconnect failed \(wrapped.localizedDescription, privacy: .public)")
|
||||
await self.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
public func request(
|
||||
method: String,
|
||||
params: [String: AnyCodable]?,
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
{
|
||||
try await self.connectOrThrow(context: "gateway connect")
|
||||
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
|
||||
let payload = try self.encodeRequest(method: method, params: params, kind: "request")
|
||||
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
|
||||
self.pending[payload.id] = cont
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
|
||||
await self.timeoutRequest(id: payload.id, timeoutMs: effectiveTimeout)
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
try await self.task?.send(.data(payload.data))
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "gateway send \(method)")
|
||||
let waiter = self.pending.removeValue(forKey: payload.id)
|
||||
// Treat send failures as a broken socket: mark disconnected and trigger reconnect.
|
||||
self.connected = false
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.scheduleReconnect()
|
||||
}
|
||||
if let waiter { waiter.resume(throwing: wrapped) }
|
||||
}
|
||||
}
|
||||
}
|
||||
guard case let .res(res) = response else {
|
||||
throw NSError(domain: "Gateway", code: 2, userInfo: [NSLocalizedDescriptionKey: "unexpected frame"])
|
||||
}
|
||||
if res.ok == false {
|
||||
let code = res.error?["code"]?.value as? String
|
||||
let msg = res.error?["message"]?.value as? String
|
||||
let details: [String: AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in
|
||||
acc[pair.key] = AnyCodable(pair.value.value)
|
||||
}
|
||||
throw GatewayResponseError(method: method, code: code, message: msg, details: details)
|
||||
}
|
||||
if let payload = res.payload {
|
||||
// Encode back to JSON with Swift's encoder to preserve types and avoid ObjC bridging exceptions.
|
||||
return try self.encoder.encode(payload)
|
||||
}
|
||||
return Data() // Should not happen, but tolerate empty payloads.
|
||||
}
|
||||
|
||||
public func send(method: String, params: [String: AnyCodable]?) async throws {
|
||||
try await self.connectOrThrow(context: "gateway connect")
|
||||
let payload = try self.encodeRequest(method: method, params: params, kind: "send")
|
||||
guard let task = self.task else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 5,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway socket unavailable"])
|
||||
}
|
||||
do {
|
||||
try await task.send(.data(payload.data))
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "gateway send \(method)")
|
||||
self.connected = false
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.scheduleReconnect()
|
||||
}
|
||||
throw wrapped
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
|
||||
private func wrap(_ error: Error, context: String) -> Error {
|
||||
if let urlError = error as? URLError {
|
||||
let desc = urlError.localizedDescription.isEmpty ? "cancelled" : urlError.localizedDescription
|
||||
return NSError(
|
||||
domain: URLError.errorDomain,
|
||||
code: urlError.errorCode,
|
||||
userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
|
||||
}
|
||||
let ns = error as NSError
|
||||
let desc = ns.localizedDescription.isEmpty ? "unknown" : ns.localizedDescription
|
||||
return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
|
||||
}
|
||||
|
||||
private func connectOrThrow(context: String) async throws {
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
throw self.wrap(error, context: context)
|
||||
}
|
||||
}
|
||||
|
||||
private func encodeRequest(
|
||||
method: String,
|
||||
params: [String: AnyCodable]?,
|
||||
kind: String) throws -> (id: String, data: Data)
|
||||
{
|
||||
let id = UUID().uuidString
|
||||
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
|
||||
let paramsObject: ProtoAnyCodable? = params.map { entries in
|
||||
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
|
||||
dict[entry.key] = ProtoAnyCodable(entry.value.value)
|
||||
}
|
||||
return ProtoAnyCodable(dict)
|
||||
}
|
||||
let frame = RequestFrame(
|
||||
type: "req",
|
||||
id: id,
|
||||
method: method,
|
||||
params: paramsObject)
|
||||
do {
|
||||
let data = try self.encoder.encode(frame)
|
||||
return (id: id, data: data)
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func failPending(_ error: Error) async {
|
||||
let waiters = self.pending
|
||||
self.pending.removeAll()
|
||||
for (_, waiter) in waiters {
|
||||
waiter.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func timeoutRequest(id: String, timeoutMs: Double) async {
|
||||
guard let waiter = self.pending.removeValue(forKey: id) else { return }
|
||||
let err = NSError(
|
||||
domain: "Gateway",
|
||||
code: 5,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway request timed out after \(Int(timeoutMs))ms"])
|
||||
waiter.resume(throwing: err)
|
||||
}
|
||||
}
|
||||
|
||||
// Intentionally no `GatewayChannel` wrapper: the app should use the single shared `GatewayConnection`.
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
public enum GatewayEndpointID {
|
||||
public static func stableID(_ endpoint: NWEndpoint) -> String {
|
||||
switch endpoint {
|
||||
case let .service(name, type, domain, _):
|
||||
// Keep stable across encoded/decoded differences (e.g. \032 for spaces).
|
||||
let normalizedName = Self.normalizeServiceNameForID(name)
|
||||
return "\(type)|\(domain)|\(normalizedName)"
|
||||
default:
|
||||
return String(describing: endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
public static func prettyDescription(_ endpoint: NWEndpoint) -> String {
|
||||
BonjourEscapes.decode(String(describing: endpoint))
|
||||
}
|
||||
|
||||
private static func normalizeServiceNameForID(_ rawName: String) -> String {
|
||||
let decoded = BonjourEscapes.decode(rawName)
|
||||
let normalized = decoded.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
||||
return normalized.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
|
||||
/// Structured error surfaced when the gateway responds with `{ ok: false }`.
|
||||
public struct GatewayResponseError: LocalizedError, @unchecked Sendable {
|
||||
public let method: String
|
||||
public let code: String
|
||||
public let message: String
|
||||
public let details: [String: AnyCodable]
|
||||
|
||||
public init(method: String, code: String?, message: String?, details: [String: AnyCodable]?) {
|
||||
self.method = method
|
||||
self.code = (code?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? code!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: "GATEWAY_ERROR"
|
||||
self.message = (message?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? message!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: "gateway error"
|
||||
self.details = details ?? [:]
|
||||
}
|
||||
|
||||
public var errorDescription: String? {
|
||||
if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" }
|
||||
return "\(self.method): [\(self.code)] \(self.message)"
|
||||
}
|
||||
}
|
||||
|
||||
public struct GatewayDecodingError: LocalizedError, Sendable {
|
||||
public let method: String
|
||||
public let message: String
|
||||
|
||||
public init(method: String, message: String) {
|
||||
self.method = method
|
||||
self.message = message
|
||||
}
|
||||
|
||||
public var errorDescription: String? { "\(self.method): \(self.message)" }
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
private struct NodeInvokeRequestPayload: Codable, Sendable {
|
||||
var id: String
|
||||
var nodeId: String
|
||||
var command: String
|
||||
var paramsJSON: String?
|
||||
var timeoutMs: Int?
|
||||
var idempotencyKey: String?
|
||||
}
|
||||
|
||||
public actor GatewayNodeSession {
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
private var channel: GatewayChannelActor?
|
||||
private var activeURL: URL?
|
||||
private var activeToken: String?
|
||||
private var activePassword: String?
|
||||
private var connectOptions: GatewayConnectOptions?
|
||||
private var onConnected: (@Sendable () async -> Void)?
|
||||
private var onDisconnected: (@Sendable (String) async -> Void)?
|
||||
private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)?
|
||||
|
||||
static func invokeWithTimeout(
|
||||
request: BridgeInvokeRequest,
|
||||
timeoutMs: Int?,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
|
||||
) async -> BridgeInvokeResponse {
|
||||
let timeout = max(0, timeoutMs ?? 0)
|
||||
guard timeout > 0 else {
|
||||
return await onInvoke(request)
|
||||
}
|
||||
|
||||
return await withTaskGroup(of: BridgeInvokeResponse.self) { group in
|
||||
group.addTask { await onInvoke(request) }
|
||||
group.addTask {
|
||||
try? await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000)
|
||||
return BridgeInvokeResponse(
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(
|
||||
code: .unavailable,
|
||||
message: "node invoke timed out")
|
||||
)
|
||||
}
|
||||
|
||||
let first = await group.next()!
|
||||
group.cancelAll()
|
||||
return first
|
||||
}
|
||||
}
|
||||
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
|
||||
private var canvasHostUrl: String?
|
||||
|
||||
public init() {}
|
||||
|
||||
public func connect(
|
||||
url: URL,
|
||||
token: String?,
|
||||
password: String?,
|
||||
connectOptions: GatewayConnectOptions,
|
||||
sessionBox: WebSocketSessionBox?,
|
||||
onConnected: @escaping @Sendable () async -> Void,
|
||||
onDisconnected: @escaping @Sendable (String) async -> Void,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
|
||||
) async throws {
|
||||
let shouldReconnect = self.activeURL != url ||
|
||||
self.activeToken != token ||
|
||||
self.activePassword != password ||
|
||||
self.channel == nil
|
||||
|
||||
self.connectOptions = connectOptions
|
||||
self.onConnected = onConnected
|
||||
self.onDisconnected = onDisconnected
|
||||
self.onInvoke = onInvoke
|
||||
|
||||
if shouldReconnect {
|
||||
if let existing = self.channel {
|
||||
await existing.shutdown()
|
||||
}
|
||||
let channel = GatewayChannelActor(
|
||||
url: url,
|
||||
token: token,
|
||||
password: password,
|
||||
session: sessionBox,
|
||||
pushHandler: { [weak self] push in
|
||||
await self?.handlePush(push)
|
||||
},
|
||||
connectOptions: connectOptions,
|
||||
disconnectHandler: { [weak self] reason in
|
||||
await self?.onDisconnected?(reason)
|
||||
})
|
||||
self.channel = channel
|
||||
self.activeURL = url
|
||||
self.activeToken = token
|
||||
self.activePassword = password
|
||||
}
|
||||
|
||||
guard let channel = self.channel else {
|
||||
throw NSError(domain: "Gateway", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "gateway channel unavailable",
|
||||
])
|
||||
}
|
||||
|
||||
do {
|
||||
try await channel.connect()
|
||||
await onConnected()
|
||||
} catch {
|
||||
await onDisconnected(error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public func disconnect() async {
|
||||
await self.channel?.shutdown()
|
||||
self.channel = nil
|
||||
self.activeURL = nil
|
||||
self.activeToken = nil
|
||||
self.activePassword = nil
|
||||
}
|
||||
|
||||
public func currentCanvasHostUrl() -> String? {
|
||||
self.canvasHostUrl
|
||||
}
|
||||
|
||||
public func currentRemoteAddress() -> String? {
|
||||
guard let url = self.activeURL else { return nil }
|
||||
guard let host = url.host else { return url.absoluteString }
|
||||
let port = url.port ?? (url.scheme == "wss" ? 443 : 80)
|
||||
if host.contains(":") {
|
||||
return "[\(host)]:\(port)"
|
||||
}
|
||||
return "\(host):\(port)"
|
||||
}
|
||||
|
||||
public func sendEvent(event: String, payloadJSON: String?) async {
|
||||
guard let channel = self.channel else { return }
|
||||
let params: [String: AnyCodable] = [
|
||||
"event": AnyCodable(event),
|
||||
"payloadJSON": AnyCodable(payloadJSON ?? NSNull()),
|
||||
]
|
||||
do {
|
||||
try await channel.send(method: "node.event", params: params)
|
||||
} catch {
|
||||
self.logger.error("node event failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
public func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data {
|
||||
guard let channel = self.channel else {
|
||||
throw NSError(domain: "Gateway", code: 11, userInfo: [
|
||||
NSLocalizedDescriptionKey: "not connected",
|
||||
])
|
||||
}
|
||||
|
||||
let params = try self.decodeParamsJSON(paramsJSON)
|
||||
return try await channel.request(
|
||||
method: method,
|
||||
params: params,
|
||||
timeoutMs: Double(timeoutSeconds * 1000))
|
||||
}
|
||||
|
||||
public func subscribeServerEvents(bufferingNewest: Int = 200) -> AsyncStream<EventFrame> {
|
||||
let id = UUID()
|
||||
let session = self
|
||||
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
|
||||
self.serverEventSubscribers[id] = continuation
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
Task { await session.removeServerEventSubscriber(id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePush(_ push: GatewayPush) async {
|
||||
switch push {
|
||||
case let .snapshot(ok):
|
||||
let raw = ok.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.canvasHostUrl = (raw?.isEmpty == false) ? raw : nil
|
||||
await self.onConnected?()
|
||||
case let .event(evt):
|
||||
await self.handleEvent(evt)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEvent(_ evt: EventFrame) async {
|
||||
self.broadcastServerEvent(evt)
|
||||
guard evt.event == "node.invoke.request" else { return }
|
||||
guard let payload = evt.payload else { return }
|
||||
do {
|
||||
let data = try self.encoder.encode(payload)
|
||||
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
|
||||
guard let onInvoke else { return }
|
||||
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
|
||||
let response = await Self.invokeWithTimeout(
|
||||
request: req,
|
||||
timeoutMs: request.timeoutMs,
|
||||
onInvoke: onInvoke
|
||||
)
|
||||
await self.sendInvokeResult(request: request, response: response)
|
||||
} catch {
|
||||
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func sendInvokeResult(request: NodeInvokeRequestPayload, response: BridgeInvokeResponse) async {
|
||||
guard let channel = self.channel else { return }
|
||||
var params: [String: AnyCodable] = [
|
||||
"id": AnyCodable(request.id),
|
||||
"nodeId": AnyCodable(request.nodeId),
|
||||
"ok": AnyCodable(response.ok),
|
||||
]
|
||||
if let payloadJSON = response.payloadJSON {
|
||||
params["payloadJSON"] = AnyCodable(payloadJSON)
|
||||
}
|
||||
if let error = response.error {
|
||||
params["error"] = AnyCodable([
|
||||
"code": error.code.rawValue,
|
||||
"message": error.message,
|
||||
])
|
||||
}
|
||||
do {
|
||||
try await channel.send(method: "node.invoke.result", params: params)
|
||||
} catch {
|
||||
self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeParamsJSON(
|
||||
_ paramsJSON: String?) throws -> [String: AnyCodable]?
|
||||
{
|
||||
guard let paramsJSON, !paramsJSON.isEmpty else { return nil }
|
||||
guard let data = paramsJSON.data(using: .utf8) else {
|
||||
throw NSError(domain: "Gateway", code: 12, userInfo: [
|
||||
NSLocalizedDescriptionKey: "paramsJSON not UTF-8",
|
||||
])
|
||||
}
|
||||
let raw = try JSONSerialization.jsonObject(with: data)
|
||||
guard let dict = raw as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
return dict.reduce(into: [:]) { acc, entry in
|
||||
acc[entry.key] = AnyCodable(entry.value)
|
||||
}
|
||||
}
|
||||
|
||||
private func broadcastServerEvent(_ evt: EventFrame) {
|
||||
for (id, continuation) in self.serverEventSubscribers {
|
||||
if case .terminated = continuation.yield(evt) {
|
||||
self.serverEventSubscribers.removeValue(forKey: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeServerEventSubscriber(_ id: UUID) {
|
||||
self.serverEventSubscribers.removeValue(forKey: id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
|
||||
public enum GatewayPayloadDecoding {
|
||||
public static func decode<T: Decodable>(
|
||||
_ payload: OpenClawProtocol.AnyCodable,
|
||||
as _: T.Type = T.self) throws -> T
|
||||
{
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
|
||||
public 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)
|
||||
}
|
||||
|
||||
public static func decodeIfPresent<T: Decodable>(
|
||||
_ payload: OpenClawProtocol.AnyCodable?,
|
||||
as _: T.Type = T.self) throws -> T?
|
||||
{
|
||||
guard let payload else { return nil }
|
||||
return try self.decode(payload, as: T.self)
|
||||
}
|
||||
|
||||
public static func decodeIfPresent<T: Decodable>(
|
||||
_ payload: AnyCodable?,
|
||||
as _: T.Type = T.self) throws -> T?
|
||||
{
|
||||
guard let payload else { return nil }
|
||||
return try self.decode(payload, as: T.self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import OpenClawProtocol
|
||||
|
||||
/// Server-push messages from the gateway websocket.
|
||||
///
|
||||
/// This is the in-process replacement for the legacy `NotificationCenter` fan-out.
|
||||
public enum GatewayPush: Sendable {
|
||||
/// A full snapshot that arrives on connect (or reconnect).
|
||||
case snapshot(HelloOk)
|
||||
/// A server push event frame.
|
||||
case event(EventFrame)
|
||||
/// A detected sequence gap (`expected...received`) for event frames.
|
||||
case seqGap(expected: Int, received: Int)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
public struct GatewayTLSParams: Sendable {
|
||||
public let required: Bool
|
||||
public let expectedFingerprint: String?
|
||||
public let allowTOFU: Bool
|
||||
public let storeKey: String?
|
||||
|
||||
public init(required: Bool, expectedFingerprint: String?, allowTOFU: Bool, storeKey: String?) {
|
||||
self.required = required
|
||||
self.expectedFingerprint = expectedFingerprint
|
||||
self.allowTOFU = allowTOFU
|
||||
self.storeKey = storeKey
|
||||
}
|
||||
}
|
||||
|
||||
public enum GatewayTLSStore {
|
||||
private static let suiteName = "ai.openclaw.shared"
|
||||
private static let keyPrefix = "gateway.tls."
|
||||
|
||||
private static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: suiteName) ?? .standard
|
||||
}
|
||||
|
||||
public static func loadFingerprint(stableID: String) -> String? {
|
||||
let key = self.keyPrefix + stableID
|
||||
let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if raw?.isEmpty == false { return raw }
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func saveFingerprint(_ value: String, stableID: String) {
|
||||
let key = self.keyPrefix + stableID
|
||||
self.defaults.set(value, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, @unchecked Sendable {
|
||||
private let params: GatewayTLSParams
|
||||
private lazy var session: URLSession = {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.waitsForConnectivity = true
|
||||
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
}()
|
||||
|
||||
public init(params: GatewayTLSParams) {
|
||||
self.params = params
|
||||
super.init()
|
||||
}
|
||||
|
||||
public func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
let task = self.session.webSocketTask(with: url)
|
||||
task.maximumMessageSize = 16 * 1024 * 1024
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
|
||||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let trust = challenge.protectionSpace.serverTrust
|
||||
else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
|
||||
let expected = params.expectedFingerprint.map(normalizeFingerprint)
|
||||
if let fingerprint = certificateFingerprint(trust) {
|
||||
if let expected {
|
||||
if fingerprint == expected {
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
} else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
if params.allowTOFU {
|
||||
if let storeKey = params.storeKey {
|
||||
GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey)
|
||||
}
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let ok = SecTrustEvaluateWithError(trust, nil)
|
||||
if ok || !params.required {
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
} else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func certificateFingerprint(_ trust: SecTrust) -> String? {
|
||||
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
|
||||
let cert = chain.first
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return sha256Hex(SecCertificateCopyData(cert) as Data)
|
||||
}
|
||||
|
||||
private func sha256Hex(_ data: Data) -> String {
|
||||
let digest = SHA256.hash(data: data)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private func normalizeFingerprint(_ raw: String) -> String {
|
||||
let stripped = raw.replacingOccurrences(
|
||||
of: #"(?i)^sha-?256\s*:?\s*"#,
|
||||
with: "",
|
||||
options: .regularExpression)
|
||||
return stripped.lowercased().filter(\.isHexDigit)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
public enum InstanceIdentity {
|
||||
private static let suiteName = "ai.openclaw.shared"
|
||||
private static let instanceIdKey = "instanceId"
|
||||
|
||||
private static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: suiteName) ?? .standard
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
private static func readMainActor<T: Sendable>(_ body: @MainActor () -> T) -> T {
|
||||
if Thread.isMainThread {
|
||||
return MainActor.assumeIsolated { body() }
|
||||
}
|
||||
return DispatchQueue.main.sync {
|
||||
MainActor.assumeIsolated { body() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
public static let instanceId: String = {
|
||||
let defaults = Self.defaults
|
||||
if let existing = defaults.string(forKey: instanceIdKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!existing.isEmpty
|
||||
{
|
||||
return existing
|
||||
}
|
||||
|
||||
let id = UUID().uuidString.lowercased()
|
||||
defaults.set(id, forKey: instanceIdKey)
|
||||
return id
|
||||
}()
|
||||
|
||||
public static let displayName: String = {
|
||||
#if canImport(UIKit)
|
||||
let name = Self.readMainActor {
|
||||
UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return name.isEmpty ? "openclaw" : name
|
||||
#else
|
||||
if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!name.isEmpty
|
||||
{
|
||||
return name
|
||||
}
|
||||
return "openclaw"
|
||||
#endif
|
||||
}()
|
||||
|
||||
public static let modelIdentifier: String? = {
|
||||
#if canImport(UIKit)
|
||||
var systemInfo = utsname()
|
||||
uname(&systemInfo)
|
||||
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
|
||||
String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8)
|
||||
}
|
||||
let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
#else
|
||||
var size = 0
|
||||
guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 1 else { return nil }
|
||||
|
||||
var buffer = [CChar](repeating: 0, count: size)
|
||||
guard sysctlbyname("hw.model", &buffer, &size, nil, 0) == 0 else { return nil }
|
||||
|
||||
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
|
||||
guard let raw = String(bytes: bytes, encoding: .utf8) else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
#endif
|
||||
}()
|
||||
|
||||
public static let deviceFamily: String = {
|
||||
#if canImport(UIKit)
|
||||
return Self.readMainActor {
|
||||
switch UIDevice.current.userInterfaceIdiom {
|
||||
case .pad: return "iPad"
|
||||
case .phone: return "iPhone"
|
||||
default: return "iOS"
|
||||
}
|
||||
}
|
||||
#else
|
||||
return "Mac"
|
||||
#endif
|
||||
}()
|
||||
|
||||
public static let platformString: String = {
|
||||
let v = ProcessInfo.processInfo.operatingSystemVersion
|
||||
#if canImport(UIKit)
|
||||
let name = Self.readMainActor {
|
||||
switch UIDevice.current.userInterfaceIdiom {
|
||||
case .pad: return "iPadOS"
|
||||
case .phone: return "iOS"
|
||||
default: return "iOS"
|
||||
}
|
||||
}
|
||||
return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||
#else
|
||||
return "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||
#endif
|
||||
}()
|
||||
}
|
||||
135
apps/shared/OpenClawKit/Sources/OpenClawKit/JPEGTranscoder.swift
Normal file
135
apps/shared/OpenClawKit/Sources/OpenClawKit/JPEGTranscoder.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawLocationCommand: String, Codable, Sendable {
|
||||
case get = "location.get"
|
||||
}
|
||||
|
||||
public enum OpenClawLocationAccuracy: String, Codable, Sendable {
|
||||
case coarse
|
||||
case balanced
|
||||
case precise
|
||||
}
|
||||
|
||||
public struct OpenClawLocationGetParams: Codable, Sendable, Equatable {
|
||||
public var timeoutMs: Int?
|
||||
public var maxAgeMs: Int?
|
||||
public var desiredAccuracy: OpenClawLocationAccuracy?
|
||||
|
||||
public init(timeoutMs: Int? = nil, maxAgeMs: Int? = nil, desiredAccuracy: OpenClawLocationAccuracy? = nil) {
|
||||
self.timeoutMs = timeoutMs
|
||||
self.maxAgeMs = maxAgeMs
|
||||
self.desiredAccuracy = desiredAccuracy
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawLocationPayload: 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawLocationMode: String, Codable, Sendable, CaseIterable {
|
||||
case off
|
||||
case whileUsing
|
||||
case always
|
||||
}
|
||||
28
apps/shared/OpenClawKit/Sources/OpenClawKit/NodeError.swift
Normal file
28
apps/shared/OpenClawKit/Sources/OpenClawKit/NodeError.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawNodeErrorCode: String, Codable, Sendable {
|
||||
case notPaired = "NOT_PAIRED"
|
||||
case unauthorized = "UNAUTHORIZED"
|
||||
case backgroundUnavailable = "NODE_BACKGROUND_UNAVAILABLE"
|
||||
case invalidRequest = "INVALID_REQUEST"
|
||||
case unavailable = "UNAVAILABLE"
|
||||
}
|
||||
|
||||
public struct OpenClawNodeError: Error, Codable, Sendable, Equatable {
|
||||
public var code: OpenClawNodeErrorCode
|
||||
public var message: String
|
||||
public var retryable: Bool?
|
||||
public var retryAfterMs: Int?
|
||||
|
||||
public init(
|
||||
code: OpenClawNodeErrorCode,
|
||||
message: String,
|
||||
retryable: Bool? = nil,
|
||||
retryAfterMs: Int? = nil)
|
||||
{
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.retryable = retryable
|
||||
self.retryAfterMs = retryAfterMs
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawKitResources {
|
||||
/// Resource bundle for OpenClawKit.
|
||||
///
|
||||
/// Locates the SwiftPM-generated resource bundle, checking multiple locations:
|
||||
/// 1. Inside Bundle.main (packaged apps)
|
||||
/// 2. Bundle.module (SwiftPM development/tests)
|
||||
/// 3. Falls back to Bundle.main if not found (resource lookups will return nil)
|
||||
///
|
||||
/// This avoids a fatal crash when Bundle.module can't locate its resources
|
||||
/// in packaged .app bundles where the resource bundle path differs from
|
||||
/// SwiftPM's expectations.
|
||||
public static let bundle: Bundle = locateBundle()
|
||||
|
||||
private static let bundleName = "OpenClawKit_OpenClawKit"
|
||||
|
||||
private static func locateBundle() -> Bundle {
|
||||
// 1. Check inside Bundle.main (packaged apps copy resources here)
|
||||
if let mainResourceURL = Bundle.main.resourceURL {
|
||||
let bundleURL = mainResourceURL.appendingPathComponent("\(bundleName).bundle")
|
||||
if let bundle = Bundle(url: bundleURL) {
|
||||
return bundle
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check Bundle.main directly for embedded resources
|
||||
if Bundle.main.url(forResource: "tool-display", withExtension: "json") != nil {
|
||||
return Bundle.main
|
||||
}
|
||||
|
||||
// 3. Try Bundle.module (works in SwiftPM development/tests)
|
||||
// Wrap in a function to defer the fatalError until actually called
|
||||
if let moduleBundle = loadModuleBundleSafely() {
|
||||
return moduleBundle
|
||||
}
|
||||
|
||||
// 4. Fallback: return Bundle.main (resource lookups will return nil gracefully)
|
||||
return Bundle.main
|
||||
}
|
||||
|
||||
private static func loadModuleBundleSafely() -> Bundle? {
|
||||
// Bundle.module is generated by SwiftPM and will fatalError if not found.
|
||||
// We check likely locations manually to avoid the crash.
|
||||
let candidates: [URL?] = [
|
||||
Bundle.main.resourceURL,
|
||||
Bundle.main.bundleURL,
|
||||
Bundle(for: BundleLocator.self).resourceURL,
|
||||
Bundle(for: BundleLocator.self).bundleURL,
|
||||
]
|
||||
|
||||
for candidate in candidates {
|
||||
guard let baseURL = candidate else { continue }
|
||||
|
||||
// Direct path
|
||||
let directURL = baseURL.appendingPathComponent("\(bundleName).bundle")
|
||||
if let bundle = Bundle(url: directURL) {
|
||||
return bundle
|
||||
}
|
||||
|
||||
// Inside Resources/
|
||||
let resourcesURL = baseURL
|
||||
.appendingPathComponent("Resources")
|
||||
.appendingPathComponent("\(bundleName).bundle")
|
||||
if let bundle = Bundle(url: resourcesURL) {
|
||||
return bundle
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Helper class for bundle lookup via Bundle(for:)
|
||||
private final class BundleLocator {}
|
||||
@@ -0,0 +1,225 @@
|
||||
<!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: openclaw-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: openclaw-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 openclaw-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 openclaw-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"] #openclaw-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;
|
||||
}
|
||||
#openclaw-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;
|
||||
}
|
||||
#openclaw-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);
|
||||
}
|
||||
#openclaw-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);
|
||||
}
|
||||
#openclaw-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="openclaw-canvas"></canvas>
|
||||
<div id="openclaw-status">
|
||||
<div class="card">
|
||||
<div class="title" id="openclaw-status-title">Ready</div>
|
||||
<div class="subtitle" id="openclaw-status-subtitle">Waiting for agent</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const canvas = document.getElementById('openclaw-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const statusEl = document.getElementById('openclaw-status');
|
||||
const titleEl = document.getElementById('openclaw-status-title');
|
||||
const subtitleEl = document.getElementById('openclaw-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';
|
||||
}
|
||||
|
||||
const api = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.__openclaw = api;
|
||||
})();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"version": 1,
|
||||
"fallback": {
|
||||
"emoji": "🧩",
|
||||
"detailKeys": [
|
||||
"command",
|
||||
"path",
|
||||
"url",
|
||||
"targetUrl",
|
||||
"targetId",
|
||||
"ref",
|
||||
"element",
|
||||
"node",
|
||||
"nodeId",
|
||||
"id",
|
||||
"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": ["id"] },
|
||||
"remove": { "label": "remove", "detailKeys": ["id"] },
|
||||
"run": { "label": "run", "detailKeys": ["id"] },
|
||||
"runs": { "label": "runs", "detailKeys": ["id"] },
|
||||
"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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawScreenCommand: String, Codable, Sendable {
|
||||
case record = "screen.record"
|
||||
}
|
||||
|
||||
public struct OpenClawScreenRecordParams: 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawNodeStorage {
|
||||
public static func appSupportDir() throws -> URL {
|
||||
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
guard let base else {
|
||||
throw NSError(domain: "OpenClawNodeStorage", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Application Support directory unavailable",
|
||||
])
|
||||
}
|
||||
return base.appendingPathComponent("OpenClaw", 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().urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
guard let base else {
|
||||
throw NSError(domain: "OpenClawNodeStorage", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Caches directory unavailable",
|
||||
])
|
||||
}
|
||||
return base.appendingPathComponent("OpenClaw", 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawSystemCommand: String, Codable, Sendable {
|
||||
case run = "system.run"
|
||||
case which = "system.which"
|
||||
case notify = "system.notify"
|
||||
case execApprovalsGet = "system.execApprovals.get"
|
||||
case execApprovalsSet = "system.execApprovals.set"
|
||||
}
|
||||
|
||||
public enum OpenClawNotificationPriority: String, Codable, Sendable {
|
||||
case passive
|
||||
case active
|
||||
case timeSensitive
|
||||
}
|
||||
|
||||
public enum OpenClawNotificationDelivery: String, Codable, Sendable {
|
||||
case system
|
||||
case overlay
|
||||
case auto
|
||||
}
|
||||
|
||||
public struct OpenClawSystemRunParams: Codable, Sendable, Equatable {
|
||||
public var command: [String]
|
||||
public var rawCommand: String?
|
||||
public var cwd: String?
|
||||
public var env: [String: String]?
|
||||
public var timeoutMs: Int?
|
||||
public var needsScreenRecording: Bool?
|
||||
public var agentId: String?
|
||||
public var sessionKey: String?
|
||||
public var approved: Bool?
|
||||
public var approvalDecision: String?
|
||||
|
||||
public init(
|
||||
command: [String],
|
||||
rawCommand: String? = nil,
|
||||
cwd: String? = nil,
|
||||
env: [String: String]? = nil,
|
||||
timeoutMs: Int? = nil,
|
||||
needsScreenRecording: Bool? = nil,
|
||||
agentId: String? = nil,
|
||||
sessionKey: String? = nil,
|
||||
approved: Bool? = nil,
|
||||
approvalDecision: String? = nil)
|
||||
{
|
||||
self.command = command
|
||||
self.rawCommand = rawCommand
|
||||
self.cwd = cwd
|
||||
self.env = env
|
||||
self.timeoutMs = timeoutMs
|
||||
self.needsScreenRecording = needsScreenRecording
|
||||
self.agentId = agentId
|
||||
self.sessionKey = sessionKey
|
||||
self.approved = approved
|
||||
self.approvalDecision = approvalDecision
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawSystemWhichParams: Codable, Sendable, Equatable {
|
||||
public var bins: [String]
|
||||
|
||||
public init(bins: [String]) {
|
||||
self.bins = bins
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawSystemNotifyParams: Codable, Sendable, Equatable {
|
||||
public var title: String
|
||||
public var body: String
|
||||
public var sound: String?
|
||||
public var priority: OpenClawNotificationPriority?
|
||||
public var delivery: OpenClawNotificationDelivery?
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
body: String,
|
||||
sound: String? = nil,
|
||||
priority: OpenClawNotificationPriority? = nil,
|
||||
delivery: OpenClawNotificationDelivery? = nil)
|
||||
{
|
||||
self.title = title
|
||||
self.body = body
|
||||
self.sound = sound
|
||||
self.priority = priority
|
||||
self.delivery = delivery
|
||||
}
|
||||
}
|
||||
201
apps/shared/OpenClawKit/Sources/OpenClawKit/TalkDirective.swift
Normal file
201
apps/shared/OpenClawKit/Sources/OpenClawKit/TalkDirective.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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 }
|
||||
|
||||
override private 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
265
apps/shared/OpenClawKit/Sources/OpenClawKit/ToolDisplay.swift
Normal file
265
apps/shared/OpenClawKit/Sources/OpenClawKit/ToolDisplay.swift
Normal file
@@ -0,0 +1,265 @@
|
||||
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 "\(self.emoji) \(self.label): \(detailLine)"
|
||||
}
|
||||
return "\(self.emoji) \(self.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 = self.config.tools?[key]
|
||||
let fallback = self.config.fallback
|
||||
|
||||
let emoji = spec?.emoji ?? fallback?.emoji ?? "🧩"
|
||||
let title = spec?.title ?? self.titleFromName(trimmedName)
|
||||
let label = spec?.label ?? trimmedName
|
||||
|
||||
let actionRaw = self.valueForKeyPath(args, path: "action") as? String
|
||||
let action = actionRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let actionSpec = action.flatMap { spec?.actions?[$0] }
|
||||
let verb = self.normalizeVerb(actionSpec?.label ?? action)
|
||||
|
||||
var detail: String?
|
||||
if key == "read" {
|
||||
detail = self.readDetail(args)
|
||||
} else if key == "write" || key == "edit" || key == "attach" {
|
||||
detail = self.pathDetail(args)
|
||||
}
|
||||
|
||||
let detailKeys = actionSpec?.detailKeys ?? spec?.detailKeys ?? fallback?.detailKeys ?? []
|
||||
if detail == nil {
|
||||
detail = self.firstValue(args, keys: detailKeys)
|
||||
}
|
||||
|
||||
if detail == nil {
|
||||
detail = meta
|
||||
}
|
||||
|
||||
if let detailValue = detail {
|
||||
detail = self.shortenHomeInString(detailValue)
|
||||
}
|
||||
|
||||
return ToolDisplaySummary(
|
||||
name: trimmedName,
|
||||
emoji: emoji,
|
||||
title: title,
|
||||
label: label,
|
||||
verb: verb,
|
||||
detail: detail)
|
||||
}
|
||||
|
||||
private static func loadConfig() -> ToolDisplayConfig {
|
||||
guard let url = OpenClawKitResources.bundle.url(forResource: "tool-display", withExtension: "json") else {
|
||||
return self.defaultConfig()
|
||||
}
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
return try JSONDecoder().decode(ToolDisplayConfig.self, from: data)
|
||||
} catch {
|
||||
return self.defaultConfig()
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultConfig() -> ToolDisplayConfig {
|
||||
ToolDisplayConfig(
|
||||
version: 1,
|
||||
fallback: ToolDisplaySpec(
|
||||
emoji: "🧩",
|
||||
title: nil,
|
||||
label: nil,
|
||||
detailKeys: [
|
||||
"command",
|
||||
"path",
|
||||
"url",
|
||||
"targetUrl",
|
||||
"targetId",
|
||||
"ref",
|
||||
"element",
|
||||
"node",
|
||||
"nodeId",
|
||||
"id",
|
||||
"requestId",
|
||||
"to",
|
||||
"channelId",
|
||||
"guildId",
|
||||
"userId",
|
||||
"name",
|
||||
"query",
|
||||
"pattern",
|
||||
"messageId",
|
||||
],
|
||||
actions: nil),
|
||||
tools: [
|
||||
"bash": ToolDisplaySpec(
|
||||
emoji: "🛠️",
|
||||
title: "Bash",
|
||||
label: nil,
|
||||
detailKeys: ["command"],
|
||||
actions: nil),
|
||||
"read": ToolDisplaySpec(
|
||||
emoji: "📖",
|
||||
title: "Read",
|
||||
label: nil,
|
||||
detailKeys: ["path"],
|
||||
actions: nil),
|
||||
"write": ToolDisplaySpec(
|
||||
emoji: "✍️",
|
||||
title: "Write",
|
||||
label: nil,
|
||||
detailKeys: ["path"],
|
||||
actions: nil),
|
||||
"edit": ToolDisplaySpec(
|
||||
emoji: "📝",
|
||||
title: "Edit",
|
||||
label: nil,
|
||||
detailKeys: ["path"],
|
||||
actions: nil),
|
||||
"attach": ToolDisplaySpec(
|
||||
emoji: "📎",
|
||||
title: "Attach",
|
||||
label: nil,
|
||||
detailKeys: ["path", "url", "fileName"],
|
||||
actions: nil),
|
||||
"process": ToolDisplaySpec(
|
||||
emoji: "🧰",
|
||||
title: "Process",
|
||||
label: nil,
|
||||
detailKeys: ["sessionId"],
|
||||
actions: 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 offsetAny = self.valueForKeyPath(args, path: "offset")
|
||||
let limitAny = self.valueForKeyPath(args, path: "limit")
|
||||
let offset = (offsetAny as? Double) ?? (offsetAny as? Int).map(Double.init)
|
||||
let limit = (limitAny as? Double) ?? (limitAny as? Int).map(Double.init)
|
||||
if let offset, let limit {
|
||||
let end = offset + limit
|
||||
return "\(path):\(Int(offset))-\(Int(end))"
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
private static func pathDetail(_ args: AnyCodable?) -> String? {
|
||||
self.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 { self.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: "~")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
2454
apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
Normal file
2454
apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
|
||||
public struct WizardOption: Sendable {
|
||||
public let value: AnyCodable?
|
||||
public let label: String
|
||||
public let hint: String?
|
||||
|
||||
public init(value: AnyCodable?, label: String, hint: String?) {
|
||||
self.value = value
|
||||
self.label = label
|
||||
self.hint = hint
|
||||
}
|
||||
}
|
||||
|
||||
public func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? {
|
||||
guard let raw else { return nil }
|
||||
do {
|
||||
let data = try JSONEncoder().encode(raw)
|
||||
return try JSONDecoder().decode(WizardStep.self, from: data)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func parseWizardOptions(_ raw: [[String: AnyCodable]]?) -> [WizardOption] {
|
||||
guard let raw else { return [] }
|
||||
return raw.map { entry in
|
||||
let value = entry["value"]
|
||||
let label = (entry["label"]?.value as? String) ?? ""
|
||||
let hint = entry["hint"]?.value as? String
|
||||
return WizardOption(value: value, label: label, hint: hint)
|
||||
}
|
||||
}
|
||||
|
||||
public func wizardStatusString(_ value: AnyCodable?) -> String? {
|
||||
(value?.value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
|
||||
public func wizardStepType(_ step: WizardStep) -> String {
|
||||
(step.type.value as? String) ?? ""
|
||||
}
|
||||
|
||||
public func anyCodableString(_ value: AnyCodable?) -> String {
|
||||
switch value?.value {
|
||||
case let string as String:
|
||||
string
|
||||
case let int as Int:
|
||||
String(int)
|
||||
case let double as Double:
|
||||
String(double)
|
||||
case let bool as Bool:
|
||||
bool ? "true" : "false"
|
||||
default:
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
public func anyCodableBool(_ value: AnyCodable?) -> Bool {
|
||||
switch value?.value {
|
||||
case let bool as Bool:
|
||||
return bool
|
||||
case let int as Int:
|
||||
return int != 0
|
||||
case let double as Double:
|
||||
return double != 0
|
||||
case let string as String:
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return trimmed == "true" || trimmed == "1" || trimmed == "yes"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public func anyCodableArray(_ value: AnyCodable?) -> [AnyCodable] {
|
||||
switch value?.value {
|
||||
case let arr as [AnyCodable]:
|
||||
return arr
|
||||
case let arr as [Any]:
|
||||
return arr.map { AnyCodable($0) }
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
public func anyCodableEqual(_ lhs: AnyCodable?, _ rhs: AnyCodable?) -> Bool {
|
||||
switch (lhs?.value, rhs?.value) {
|
||||
case let (l as String, r as String):
|
||||
l == r
|
||||
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 Int):
|
||||
l == String(r)
|
||||
case let (l as Int, r as String):
|
||||
String(l) == r
|
||||
case let (l as String, r as Double):
|
||||
l == String(r)
|
||||
case let (l as Double, r as String):
|
||||
String(l) == r
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Testing
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
|
||||
@Suite struct BonjourEscapesTests {
|
||||
@Test func decodePassThrough() {
|
||||
#expect(BonjourEscapes.decode("hello") == "hello")
|
||||
#expect(BonjourEscapes.decode("") == "")
|
||||
}
|
||||
|
||||
@Test func decodeSpaces() {
|
||||
#expect(BonjourEscapes.decode("OpenClaw\\032Gateway") == "OpenClaw 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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite struct CanvasA2UIActionTests {
|
||||
@Test func sanitizeTagValueIsStable() {
|
||||
#expect(OpenClawCanvasA2UIAction.sanitizeTagValue("Hello World!") == "Hello_World_")
|
||||
#expect(OpenClawCanvasA2UIAction.sanitizeTagValue(" ") == "-")
|
||||
#expect(OpenClawCanvasA2UIAction.sanitizeTagValue("macOS 26.2") == "macOS_26.2")
|
||||
}
|
||||
|
||||
@Test func extractActionNameAcceptsNameOrAction() {
|
||||
#expect(OpenClawCanvasA2UIAction.extractActionName(["name": "Hello"]) == "Hello")
|
||||
#expect(OpenClawCanvasA2UIAction.extractActionName(["action": "Wave"]) == "Wave")
|
||||
#expect(OpenClawCanvasA2UIAction.extractActionName(["name": " ", "action": "Fallback"]) == "Fallback")
|
||||
#expect(OpenClawCanvasA2UIAction.extractActionName(["action": " "]) == nil)
|
||||
}
|
||||
|
||||
@Test func formatAgentMessageIsTokenEfficientAndUnambiguous() {
|
||||
let messageContext = OpenClawCanvasA2UIAction.AgentMessageContext(
|
||||
actionName: "Get Weather",
|
||||
session: .init(key: "main", surfaceId: "main"),
|
||||
component: .init(id: "btnWeather", host: "Peter’s iPad", instanceId: "ipad16,6"),
|
||||
contextJSON: "{\"city\":\"Vienna\"}")
|
||||
let msg = OpenClawCanvasA2UIAction.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"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
|
||||
@Suite struct CanvasA2UITests {
|
||||
@Test func commandStringsAreStable() {
|
||||
#expect(OpenClawCanvasA2UICommand.push.rawValue == "canvas.a2ui.push")
|
||||
#expect(OpenClawCanvasA2UICommand.pushJSONL.rawValue == "canvas.a2ui.pushJSONL")
|
||||
#expect(OpenClawCanvasA2UICommand.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 OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl)
|
||||
#expect(messages.count == 4)
|
||||
}
|
||||
|
||||
@Test func jsonlRejectsV0_9CreateSurface() {
|
||||
let jsonl = """
|
||||
{"createSurface":{"surfaceId":"main"}}
|
||||
"""
|
||||
|
||||
#expect(throws: Error.self) {
|
||||
_ = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func jsonlRejectsUnknownShape() {
|
||||
let jsonl = """
|
||||
{"wat":{"nope":1}}
|
||||
"""
|
||||
|
||||
#expect(throws: Error.self) {
|
||||
_ = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite struct CanvasSnapshotFormatTests {
|
||||
@Test func acceptsJpgAlias() throws {
|
||||
struct Wrapper: Codable {
|
||||
var format: OpenClawCanvasSnapshotFormat
|
||||
}
|
||||
|
||||
let data = try #require("{\"format\":\"jpg\"}".data(using: .utf8))
|
||||
let decoded = try JSONDecoder().decode(Wrapper.self, from: data)
|
||||
#expect(decoded.format == .jpeg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Testing
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
@Suite("ChatMarkdownPreprocessor")
|
||||
struct ChatMarkdownPreprocessorTests {
|
||||
@Test func extractsDataURLImages() {
|
||||
let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////GQAJ+wP/2hN8NwAAAABJRU5ErkJggg=="
|
||||
let markdown = """
|
||||
Hello
|
||||
|
||||
)
|
||||
"""
|
||||
|
||||
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
|
||||
|
||||
#expect(result.cleaned == "Hello")
|
||||
#expect(result.images.count == 1)
|
||||
#expect(result.images.first?.image != nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
#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 = OpenClawChatTheme.resolvedAssistantBubbleColor(for: lightAppearance)
|
||||
let darkResolved = OpenClawChatTheme.resolvedAssistantBubbleColor(for: darkAppearance)
|
||||
#expect(try luminance(lightResolved) > luminance(darkResolved))
|
||||
#else
|
||||
#expect(Bool(true))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
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, OpenClawChatTransport {
|
||||
private let state = TestChatTransportState()
|
||||
private let historyResponses: [OpenClawChatHistoryPayload]
|
||||
private let sessionsResponses: [OpenClawChatSessionsListResponse]
|
||||
|
||||
private let stream: AsyncStream<OpenClawChatTransportEvent>
|
||||
private let continuation: AsyncStream<OpenClawChatTransportEvent>.Continuation
|
||||
|
||||
init(
|
||||
historyResponses: [OpenClawChatHistoryPayload],
|
||||
sessionsResponses: [OpenClawChatSessionsListResponse] = [])
|
||||
{
|
||||
self.historyResponses = historyResponses
|
||||
self.sessionsResponses = sessionsResponses
|
||||
var cont: AsyncStream<OpenClawChatTransportEvent>.Continuation!
|
||||
self.stream = AsyncStream { c in
|
||||
cont = c
|
||||
}
|
||||
self.continuation = cont
|
||||
}
|
||||
|
||||
func events() -> AsyncStream<OpenClawChatTransportEvent> {
|
||||
self.stream
|
||||
}
|
||||
|
||||
func setActiveSessionKey(_: String) async throws {}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
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 ?? OpenClawChatHistoryPayload(
|
||||
sessionKey: sessionKey,
|
||||
sessionId: nil,
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
sessionKey _: String,
|
||||
message _: String,
|
||||
thinking _: String,
|
||||
idempotencyKey: String,
|
||||
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
await self.state.sentRunIdsAppend(idempotencyKey)
|
||||
return OpenClawChatSendResponse(runId: idempotencyKey, status: "ok")
|
||||
}
|
||||
|
||||
func abortRun(sessionKey _: String, runId: String) async throws {
|
||||
await self.state.abortedRunIdsAppend(runId)
|
||||
}
|
||||
|
||||
func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse {
|
||||
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 ?? OpenClawChatSessionsListResponse(
|
||||
ts: nil,
|
||||
path: nil,
|
||||
count: 0,
|
||||
defaults: nil,
|
||||
sessions: [])
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs _: Int) async throws -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func emit(_ evt: OpenClawChatTransportEvent) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
extension TestChatTransportState {
|
||||
fileprivate func setHistoryCallCount(_ v: Int) {
|
||||
self.historyCallCount = v
|
||||
}
|
||||
|
||||
fileprivate func setSessionsCallCount(_ v: Int) {
|
||||
self.sessionsCallCount = v
|
||||
}
|
||||
|
||||
fileprivate func sentRunIdsAppend(_ v: String) {
|
||||
self.sentRunIds.append(v)
|
||||
}
|
||||
|
||||
fileprivate func abortedRunIdsAppend(_ v: String) {
|
||||
self.abortedRunIds.append(v)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct ChatViewModelTests {
|
||||
@Test func streamsAssistantAndClearsOnFinal() async throws {
|
||||
let sessionId = "sess-main"
|
||||
let history1 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: sessionId,
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
let history2 = OpenClawChatHistoryPayload(
|
||||
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 { OpenClawChatViewModel(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(
|
||||
OpenClawAgentEventPayload(
|
||||
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(
|
||||
OpenClawAgentEventPayload(
|
||||
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(
|
||||
OpenClawChatEventPayload(
|
||||
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 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: sessionId,
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
let transport = TestChatTransport(historyResponses: [history, history])
|
||||
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
|
||||
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } }
|
||||
|
||||
transport.emit(
|
||||
.agent(
|
||||
OpenClawAgentEventPayload(
|
||||
runId: sessionId,
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Int(Date().timeIntervalSince1970 * 1000),
|
||||
data: ["text": AnyCodable("external stream")])))
|
||||
|
||||
transport.emit(
|
||||
.agent(
|
||||
OpenClawAgentEventPayload(
|
||||
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(
|
||||
OpenClawChatEventPayload(
|
||||
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 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
count: 4,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
OpenClawChatSessionEntry(
|
||||
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),
|
||||
OpenClawChatSessionEntry(
|
||||
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),
|
||||
OpenClawChatSessionEntry(
|
||||
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),
|
||||
OpenClawChatSessionEntry(
|
||||
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 { OpenClawChatViewModel(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 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "custom",
|
||||
sessionId: "sess-custom",
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
OpenClawChatSessionEntry(
|
||||
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 { OpenClawChatViewModel(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 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: sessionId,
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
let transport = TestChatTransport(historyResponses: [history, history])
|
||||
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
|
||||
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } }
|
||||
|
||||
transport.emit(
|
||||
.agent(
|
||||
OpenClawAgentEventPayload(
|
||||
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(
|
||||
OpenClawChatEventPayload(
|
||||
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 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: sessionId,
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
let transport = TestChatTransport(historyResponses: [history, history])
|
||||
let vm = await MainActor.run { OpenClawChatViewModel(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(
|
||||
OpenClawChatEventPayload(
|
||||
runId: runId,
|
||||
sessionKey: "main",
|
||||
state: "aborted",
|
||||
message: nil,
|
||||
errorMessage: nil)))
|
||||
|
||||
try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import XCTest
|
||||
@testable import OpenClawKit
|
||||
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
|
||||
struct GatewayNodeSessionTests {
|
||||
@Test
|
||||
func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async {
|
||||
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
|
||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
||||
request: request,
|
||||
timeoutMs: 50,
|
||||
onInvoke: { req in
|
||||
#expect(req.id == "1")
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{}", error: nil)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(response.ok == true)
|
||||
#expect(response.error == nil)
|
||||
#expect(response.payloadJSON == "{}")
|
||||
}
|
||||
|
||||
@Test
|
||||
func invokeWithTimeoutReturnsTimeoutError() async {
|
||||
let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil)
|
||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
||||
request: request,
|
||||
timeoutMs: 10,
|
||||
onInvoke: { _ in
|
||||
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms
|
||||
return BridgeInvokeResponse(id: "abc", ok: true, payloadJSON: "{}", error: nil)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.code == .unavailable)
|
||||
#expect(response.error?.message.contains("timed out") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func invokeWithTimeoutZeroDisablesTimeout() async {
|
||||
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
|
||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
||||
request: request,
|
||||
timeoutMs: 0,
|
||||
onInvoke: { req in
|
||||
try? await Task.sleep(nanoseconds: 5_000_000)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(response.ok == true)
|
||||
#expect(response.error == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import OpenClawKit
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import XCTest
|
||||
@testable import OpenClawKit
|
||||
|
||||
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"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import XCTest
|
||||
@testable import OpenClawKit
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import XCTest
|
||||
@testable import OpenClawKit
|
||||
|
||||
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."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite struct ToolDisplayRegistryTests {
|
||||
@Test func loadsToolDisplayConfigFromBundle() {
|
||||
let url = OpenClawKitResources.bundle.url(forResource: "tool-display", withExtension: "json")
|
||||
#expect(url != nil)
|
||||
}
|
||||
|
||||
@Test func resolvesKnownToolFromConfig() {
|
||||
let summary = ToolDisplayRegistry.resolve(name: "bash", args: nil)
|
||||
#expect(summary.emoji == "🛠️")
|
||||
#expect(summary.title == "Bash")
|
||||
}
|
||||
}
|
||||
491
apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js
vendored
Normal file
491
apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js
vendored
Normal file
@@ -0,0 +1,491 @@
|
||||
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 "@openclaw/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 openclawTheme = {
|
||||
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 OpenClawA2UIHost 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: openclawTheme,
|
||||
});
|
||||
|
||||
surfaces = [];
|
||||
pendingAction = null;
|
||||
toast = null;
|
||||
#statusListener = null;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding:
|
||||
var(--openclaw-a2ui-inset-top, 0px)
|
||||
var(--openclaw-a2ui-inset-right, 0px)
|
||||
var(--openclaw-a2ui-inset-bottom, 0px)
|
||||
var(--openclaw-a2ui-inset-left, 0px);
|
||||
}
|
||||
|
||||
#surfaces {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding-bottom: var(--openclaw-a2ui-scroll-pad-bottom, 0px);
|
||||
}
|
||||
|
||||
.status {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: var(--openclaw-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(--openclaw-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(--openclaw-a2ui-empty-top, var(--openclaw-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();
|
||||
const api = {
|
||||
applyMessages: (messages) => this.applyMessages(messages),
|
||||
reset: () => this.reset(),
|
||||
getSurfaces: () => Array.from(this.#processor.getSurfaces().keys()),
|
||||
};
|
||||
globalThis.openclawA2UI = api;
|
||||
this.addEventListener("a2uiaction", (evt) => this.#handleA2UIAction(evt));
|
||||
this.#statusListener = (evt) => this.#handleActionStatus(evt);
|
||||
for (const eventName of ["openclaw:a2ui-action-status"]) {
|
||||
globalThis.addEventListener(eventName, this.#statusListener);
|
||||
}
|
||||
this.#syncSurfaces();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.#statusListener) {
|
||||
for (const eventName of ["openclaw:a2ui-action-status"]) {
|
||||
globalThis.removeEventListener(eventName, 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.__openclawLastA2UIAction = userAction;
|
||||
|
||||
const handler =
|
||||
globalThis.webkit?.messageHandlers?.openclawCanvasA2UIAction ??
|
||||
globalThis.openclawCanvasA2UIAction;
|
||||
if (handler?.postMessage) {
|
||||
try {
|
||||
// WebKit message handlers support structured objects; Android's JS interface expects strings.
|
||||
if (handler === globalThis.openclawCanvasA2UIAction) {
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-a2ui-host")) {
|
||||
customElements.define("openclaw-a2ui-host", OpenClawA2UIHost);
|
||||
}
|
||||
45
apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs
Normal file
45
apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs
Normal 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"),
|
||||
"@openclaw/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",
|
||||
codeSplitting: false,
|
||||
sourcemap: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user