375 lines
14 KiB
Swift
375 lines
14 KiB
Swift
import AppKit
|
|
import MoltbotChatUI
|
|
import MoltbotKit
|
|
import MoltbotProtocol
|
|
import Foundation
|
|
import OSLog
|
|
import QuartzCore
|
|
import SwiftUI
|
|
|
|
private let webChatSwiftLogger = Logger(subsystem: "com.clawdbot", category: "WebChatSwiftUI")
|
|
|
|
private enum WebChatSwiftUILayout {
|
|
static let windowSize = NSSize(width: 500, height: 840)
|
|
static let panelSize = NSSize(width: 480, height: 640)
|
|
static let windowMinSize = NSSize(width: 480, height: 360)
|
|
static let anchorPadding: CGFloat = 8
|
|
}
|
|
|
|
struct MacGatewayChatTransport: MoltbotChatTransport, Sendable {
|
|
func requestHistory(sessionKey: String) async throws -> MoltbotChatHistoryPayload {
|
|
try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey)
|
|
}
|
|
|
|
func abortRun(sessionKey: String, runId: String) async throws {
|
|
_ = try await GatewayConnection.shared.request(
|
|
method: "chat.abort",
|
|
params: [
|
|
"sessionKey": AnyCodable(sessionKey),
|
|
"runId": AnyCodable(runId),
|
|
],
|
|
timeoutMs: 10000)
|
|
}
|
|
|
|
func listSessions(limit: Int?) async throws -> MoltbotChatSessionsListResponse {
|
|
var params: [String: AnyCodable] = [
|
|
"includeGlobal": AnyCodable(true),
|
|
"includeUnknown": AnyCodable(false),
|
|
]
|
|
if let limit {
|
|
params["limit"] = AnyCodable(limit)
|
|
}
|
|
let data = try await GatewayConnection.shared.request(
|
|
method: "sessions.list",
|
|
params: params,
|
|
timeoutMs: 15000)
|
|
return try JSONDecoder().decode(MoltbotChatSessionsListResponse.self, from: data)
|
|
}
|
|
|
|
func sendMessage(
|
|
sessionKey: String,
|
|
message: String,
|
|
thinking: String,
|
|
idempotencyKey: String,
|
|
attachments: [MoltbotChatAttachmentPayload]) async throws -> MoltbotChatSendResponse
|
|
{
|
|
try await GatewayConnection.shared.chatSend(
|
|
sessionKey: sessionKey,
|
|
message: message,
|
|
thinking: thinking,
|
|
idempotencyKey: idempotencyKey,
|
|
attachments: attachments)
|
|
}
|
|
|
|
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
|
try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
|
|
}
|
|
|
|
func events() -> AsyncStream<MoltbotChatTransportEvent> {
|
|
AsyncStream { continuation in
|
|
let task = Task {
|
|
do {
|
|
try await GatewayConnection.shared.refresh()
|
|
} catch {
|
|
webChatSwiftLogger.error("gateway refresh failed \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
|
|
let stream = await GatewayConnection.shared.subscribe()
|
|
for await push in stream {
|
|
if Task.isCancelled { return }
|
|
if let evt = Self.mapPushToTransportEvent(push) {
|
|
continuation.yield(evt)
|
|
}
|
|
}
|
|
}
|
|
|
|
continuation.onTermination = { @Sendable _ in
|
|
task.cancel()
|
|
}
|
|
}
|
|
}
|
|
|
|
static func mapPushToTransportEvent(_ push: GatewayPush) -> MoltbotChatTransportEvent? {
|
|
switch push {
|
|
case let .snapshot(hello):
|
|
let ok = (try? JSONDecoder().decode(
|
|
MoltbotGatewayHealthOK.self,
|
|
from: JSONEncoder().encode(hello.snapshot.health)))?.ok ?? true
|
|
return .health(ok: ok)
|
|
|
|
case let .event(evt):
|
|
switch evt.event {
|
|
case "health":
|
|
guard let payload = evt.payload else { return nil }
|
|
let ok = (try? JSONDecoder().decode(
|
|
MoltbotGatewayHealthOK.self,
|
|
from: JSONEncoder().encode(payload)))?.ok ?? true
|
|
return .health(ok: ok)
|
|
case "tick":
|
|
return .tick
|
|
case "chat":
|
|
guard let payload = evt.payload else { return nil }
|
|
guard let chat = try? JSONDecoder().decode(
|
|
MoltbotChatEventPayload.self,
|
|
from: JSONEncoder().encode(payload))
|
|
else {
|
|
return nil
|
|
}
|
|
return .chat(chat)
|
|
case "agent":
|
|
guard let payload = evt.payload else { return nil }
|
|
guard let agent = try? JSONDecoder().decode(
|
|
MoltbotAgentEventPayload.self,
|
|
from: JSONEncoder().encode(payload))
|
|
else {
|
|
return nil
|
|
}
|
|
return .agent(agent)
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
case .seqGap:
|
|
return .seqGap
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Window controller
|
|
|
|
@MainActor
|
|
final class WebChatSwiftUIWindowController {
|
|
private let presentation: WebChatPresentation
|
|
private let sessionKey: String
|
|
private let hosting: NSHostingController<MoltbotChatView>
|
|
private let contentController: NSViewController
|
|
private var window: NSWindow?
|
|
private var dismissMonitor: Any?
|
|
var onClosed: (() -> Void)?
|
|
var onVisibilityChanged: ((Bool) -> Void)?
|
|
|
|
convenience init(sessionKey: String, presentation: WebChatPresentation) {
|
|
self.init(sessionKey: sessionKey, presentation: presentation, transport: MacGatewayChatTransport())
|
|
}
|
|
|
|
init(sessionKey: String, presentation: WebChatPresentation, transport: any MoltbotChatTransport) {
|
|
self.sessionKey = sessionKey
|
|
self.presentation = presentation
|
|
let vm = MoltbotChatViewModel(sessionKey: sessionKey, transport: transport)
|
|
let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex)
|
|
self.hosting = NSHostingController(rootView: MoltbotChatView(
|
|
viewModel: vm,
|
|
showsSessionSwitcher: true,
|
|
userAccent: accent))
|
|
self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting)
|
|
self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController)
|
|
}
|
|
|
|
deinit {}
|
|
|
|
var isVisible: Bool {
|
|
self.window?.isVisible ?? false
|
|
}
|
|
|
|
func show() {
|
|
guard let window else { return }
|
|
self.ensureWindowSize()
|
|
window.makeKeyAndOrderFront(nil)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
self.onVisibilityChanged?(true)
|
|
}
|
|
|
|
func presentAnchored(anchorProvider: () -> NSRect?) {
|
|
guard case .panel = self.presentation, let window else { return }
|
|
self.installDismissMonitor()
|
|
let target = self.reposition(using: anchorProvider)
|
|
|
|
if !self.isVisible {
|
|
let start = target.offsetBy(dx: 0, dy: 8)
|
|
window.setFrame(start, display: true)
|
|
window.alphaValue = 0
|
|
window.makeKeyAndOrderFront(nil)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
NSAnimationContext.runAnimationGroup { context in
|
|
context.duration = 0.18
|
|
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
|
window.animator().setFrame(target, display: true)
|
|
window.animator().alphaValue = 1
|
|
}
|
|
} else {
|
|
window.makeKeyAndOrderFront(nil)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
}
|
|
|
|
self.onVisibilityChanged?(true)
|
|
}
|
|
|
|
func close() {
|
|
self.window?.orderOut(nil)
|
|
self.onVisibilityChanged?(false)
|
|
self.onClosed?()
|
|
self.removeDismissMonitor()
|
|
}
|
|
|
|
@discardableResult
|
|
private func reposition(using anchorProvider: () -> NSRect?) -> NSRect {
|
|
guard let window else { return .zero }
|
|
guard let anchor = anchorProvider() else {
|
|
let frame = WindowPlacement.topRightFrame(
|
|
size: WebChatSwiftUILayout.panelSize,
|
|
padding: WebChatSwiftUILayout.anchorPadding)
|
|
window.setFrame(frame, display: false)
|
|
return frame
|
|
}
|
|
let screen = NSScreen.screens.first { screen in
|
|
screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY))
|
|
} ?? NSScreen.main
|
|
let bounds = (screen?.visibleFrame ?? .zero).insetBy(
|
|
dx: WebChatSwiftUILayout.anchorPadding,
|
|
dy: WebChatSwiftUILayout.anchorPadding)
|
|
let frame = WindowPlacement.anchoredBelowFrame(
|
|
size: WebChatSwiftUILayout.panelSize,
|
|
anchor: anchor,
|
|
padding: WebChatSwiftUILayout.anchorPadding,
|
|
in: bounds)
|
|
window.setFrame(frame, display: false)
|
|
return frame
|
|
}
|
|
|
|
private func installDismissMonitor() {
|
|
if ProcessInfo.processInfo.isRunningTests { return }
|
|
guard self.dismissMonitor == nil, self.window != nil else { return }
|
|
self.dismissMonitor = NSEvent.addGlobalMonitorForEvents(
|
|
matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown])
|
|
{ [weak self] _ in
|
|
guard let self, let win = self.window else { return }
|
|
let pt = NSEvent.mouseLocation
|
|
if !win.frame.contains(pt) {
|
|
self.close()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func removeDismissMonitor() {
|
|
if let monitor = self.dismissMonitor {
|
|
NSEvent.removeMonitor(monitor)
|
|
self.dismissMonitor = nil
|
|
}
|
|
}
|
|
|
|
private static func makeWindow(
|
|
for presentation: WebChatPresentation,
|
|
contentViewController: NSViewController) -> NSWindow
|
|
{
|
|
switch presentation {
|
|
case .window:
|
|
let window = NSWindow(
|
|
contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.windowSize),
|
|
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
|
backing: .buffered,
|
|
defer: false)
|
|
window.title = "Moltbot Chat"
|
|
window.contentViewController = contentViewController
|
|
window.isReleasedWhenClosed = false
|
|
window.titleVisibility = .visible
|
|
window.titlebarAppearsTransparent = false
|
|
window.backgroundColor = .clear
|
|
window.isOpaque = false
|
|
window.center()
|
|
WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatSwiftUILayout.windowSize)
|
|
window.minSize = WebChatSwiftUILayout.windowMinSize
|
|
window.contentView?.wantsLayer = true
|
|
window.contentView?.layer?.backgroundColor = NSColor.clear.cgColor
|
|
return window
|
|
case .panel:
|
|
let panel = WebChatPanel(
|
|
contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.panelSize),
|
|
styleMask: [.borderless],
|
|
backing: .buffered,
|
|
defer: false)
|
|
panel.level = .statusBar
|
|
panel.hidesOnDeactivate = true
|
|
panel.hasShadow = true
|
|
panel.isMovable = false
|
|
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
panel.titleVisibility = .hidden
|
|
panel.titlebarAppearsTransparent = true
|
|
panel.backgroundColor = .clear
|
|
panel.isOpaque = false
|
|
panel.contentViewController = contentViewController
|
|
panel.becomesKeyOnlyIfNeeded = true
|
|
panel.contentView?.wantsLayer = true
|
|
panel.contentView?.layer?.backgroundColor = NSColor.clear.cgColor
|
|
panel.setFrame(
|
|
WindowPlacement.topRightFrame(
|
|
size: WebChatSwiftUILayout.panelSize,
|
|
padding: WebChatSwiftUILayout.anchorPadding),
|
|
display: false)
|
|
return panel
|
|
}
|
|
}
|
|
|
|
private static func makeContentController(
|
|
for presentation: WebChatPresentation,
|
|
hosting: NSHostingController<MoltbotChatView>) -> NSViewController
|
|
{
|
|
let controller = NSViewController()
|
|
let effectView = NSVisualEffectView()
|
|
effectView.material = .sidebar
|
|
effectView.blendingMode = .behindWindow
|
|
effectView.state = .active
|
|
effectView.wantsLayer = true
|
|
effectView.layer?.cornerCurve = .continuous
|
|
let cornerRadius: CGFloat = switch presentation {
|
|
case .panel:
|
|
16
|
|
case .window:
|
|
0
|
|
}
|
|
effectView.layer?.cornerRadius = cornerRadius
|
|
effectView.layer?.masksToBounds = true
|
|
|
|
effectView.translatesAutoresizingMaskIntoConstraints = true
|
|
effectView.autoresizingMask = [.width, .height]
|
|
let rootView = effectView
|
|
|
|
hosting.view.translatesAutoresizingMaskIntoConstraints = false
|
|
hosting.view.wantsLayer = true
|
|
hosting.view.layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
|
controller.addChild(hosting)
|
|
effectView.addSubview(hosting.view)
|
|
controller.view = rootView
|
|
|
|
NSLayoutConstraint.activate([
|
|
hosting.view.leadingAnchor.constraint(equalTo: effectView.leadingAnchor),
|
|
hosting.view.trailingAnchor.constraint(equalTo: effectView.trailingAnchor),
|
|
hosting.view.topAnchor.constraint(equalTo: effectView.topAnchor),
|
|
hosting.view.bottomAnchor.constraint(equalTo: effectView.bottomAnchor),
|
|
])
|
|
|
|
return controller
|
|
}
|
|
|
|
private func ensureWindowSize() {
|
|
guard case .window = self.presentation, let window else { return }
|
|
let current = window.frame.size
|
|
let min = WebChatSwiftUILayout.windowMinSize
|
|
if current.width < min.width || current.height < min.height {
|
|
let frame = WindowPlacement.centeredFrame(size: WebChatSwiftUILayout.windowSize)
|
|
window.setFrame(frame, display: false)
|
|
}
|
|
}
|
|
|
|
private static func color(fromHex raw: String?) -> Color? {
|
|
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
|
|
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
|
|
let r = Double((value >> 16) & 0xFF) / 255.0
|
|
let g = Double((value >> 8) & 0xFF) / 255.0
|
|
let b = Double(value & 0xFF) / 255.0
|
|
return Color(red: r, green: g, blue: b)
|
|
}
|
|
}
|