diff --git a/apps/ios/Sources/Bridge/BridgeSession.swift b/apps/ios/Sources/Bridge/BridgeSession.swift index db3be5a8c..f8695a214 100644 --- a/apps/ios/Sources/Bridge/BridgeSession.swift +++ b/apps/ios/Sources/Bridge/BridgeSession.swift @@ -16,6 +16,8 @@ actor BridgeSession { private var connection: NWConnection? private var queue: DispatchQueue? private var buffer = Data() + private var pendingRPC: [String: CheckedContinuation] = [:] + private var serverEventSubscribers: [UUID: AsyncStream.Continuation] = [:] private(set) var state: State = .idle @@ -106,6 +108,16 @@ actor BridgeSession { guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue } switch nextBase.type { + case "res": + let res = try self.decoder.decode(BridgeRPCResponse.self, from: nextData) + if let cont = self.pendingRPC.removeValue(forKey: res.id) { + cont.resume(returning: res) + } + + case "event": + let evt = try self.decoder.decode(BridgeEventFrame.self, from: nextData) + self.broadcastServerEvent(evt) + case "ping": let ping = try self.decoder.decode(BridgePing.self, from: nextData) try await self.send(BridgePong(type: "pong", id: ping.id)) @@ -127,14 +139,114 @@ actor BridgeSession { try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON)) } + func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data { + guard self.connection != nil else { + throw NSError(domain: "Bridge", code: 11, userInfo: [ + NSLocalizedDescriptionKey: "not connected", + ]) + } + + let id = UUID().uuidString + let req = BridgeRPCRequest(type: "req", id: id, method: method, paramsJSON: paramsJSON) + + let timeoutTask = Task { + try await Task.sleep(nanoseconds: UInt64(timeoutSeconds) * 1_000_000_000) + await self.timeoutRPC(id: id) + } + defer { timeoutTask.cancel() } + + let res: BridgeRPCResponse = try await withCheckedThrowingContinuation { cont in + Task { [weak self] in + guard let self else { return } + await self.beginRPC(id: id, request: req, continuation: cont) + } + } + + if res.ok { + let payload = res.payloadJSON ?? "" + guard let data = payload.data(using: .utf8) else { + throw NSError(domain: "Bridge", code: 12, userInfo: [ + NSLocalizedDescriptionKey: "Bridge response not UTF-8", + ]) + } + return data + } + + let code = res.error?.code ?? "UNAVAILABLE" + let message = res.error?.message ?? "request failed" + throw NSError(domain: "Bridge", code: 13, userInfo: [ + NSLocalizedDescriptionKey: "\(code): \(message)", + ]) + } + + func subscribeServerEvents(bufferingNewest: Int = 200) -> AsyncStream { + 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) } + } + } + } + func disconnect() async { self.connection?.cancel() self.connection = nil self.queue = nil self.buffer = Data() + + let pending = self.pendingRPC.values + self.pendingRPC.removeAll() + for cont in pending { + cont.resume(throwing: NSError(domain: "Bridge", code: 14, userInfo: [ + NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed", + ])) + } + + for (_, cont) in self.serverEventSubscribers { + cont.finish() + } + self.serverEventSubscribers.removeAll() + self.state = .idle } + private func beginRPC( + id: String, + request: BridgeRPCRequest, + continuation: CheckedContinuation) async + { + self.pendingRPC[id] = continuation + do { + try await self.send(request) + } catch { + await self.failRPC(id: id, error: error) + } + } + + private func timeoutRPC(id: String) async { + guard let cont = self.pendingRPC.removeValue(forKey: id) else { return } + cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [ + NSLocalizedDescriptionKey: "UNAVAILABLE: request timeout", + ])) + } + + private func failRPC(id: String, error: Error) async { + guard let cont = self.pendingRPC.removeValue(forKey: id) else { return } + cont.resume(throwing: error) + } + + private func broadcastServerEvent(_ evt: BridgeEventFrame) { + for (_, cont) in self.serverEventSubscribers { + cont.yield(evt) + } + } + + private func removeServerEventSubscriber(_ id: UUID) { + self.serverEventSubscribers[id] = nil + } + private func send(_ obj: some Encodable) async throws { guard let connection = self.connection else { throw NSError(domain: "Bridge", code: 10, userInfo: [ diff --git a/apps/ios/Sources/Chat/ChatSheet.swift b/apps/ios/Sources/Chat/ChatSheet.swift new file mode 100644 index 000000000..9ce28fbc5 --- /dev/null +++ b/apps/ios/Sources/Chat/ChatSheet.swift @@ -0,0 +1,33 @@ +import ClawdisChatUI +import SwiftUI + +struct ChatSheet: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var viewModel: ClawdisChatViewModel + + init(bridge: BridgeSession, sessionKey: String = "main") { + let transport = IOSBridgeChatTransport(bridge: bridge) + self._viewModel = StateObject( + wrappedValue: ClawdisChatViewModel( + sessionKey: sessionKey, + transport: transport)) + } + + var body: some View { + NavigationStack { + ClawdisChatView(viewModel: self.viewModel) + .navigationTitle("Chat") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + self.dismiss() + } label: { + Image(systemName: "xmark") + } + .accessibilityLabel("Close") + } + } + } + } +} diff --git a/apps/ios/Sources/Chat/IOSBridgeChatTransport.swift b/apps/ios/Sources/Chat/IOSBridgeChatTransport.swift new file mode 100644 index 000000000..284dde107 --- /dev/null +++ b/apps/ios/Sources/Chat/IOSBridgeChatTransport.swift @@ -0,0 +1,93 @@ +import ClawdisChatUI +import ClawdisKit +import Foundation + +struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable { + private let bridge: BridgeSession + + init(bridge: BridgeSession) { + self.bridge = bridge + } + + func setActiveSessionKey(_ sessionKey: String) async throws { + struct Subscribe: Codable { var sessionKey: String } + let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey)) + let json = String(data: data, encoding: .utf8) + try await self.bridge.sendEvent(event: "chat.subscribe", payloadJSON: json) + } + + func requestHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload { + struct Params: Codable { var sessionKey: String } + let data = try JSONEncoder().encode(Params(sessionKey: sessionKey)) + let json = String(data: data, encoding: .utf8) + let res = try await self.bridge.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15) + return try JSONDecoder().decode(ClawdisChatHistoryPayload.self, from: res) + } + + func sendMessage( + sessionKey: String, + message: String, + thinking: String, + idempotencyKey: String, + attachments: [ClawdisChatAttachmentPayload]) async throws -> ClawdisChatSendResponse + { + struct Params: Codable { + var sessionKey: String + var message: String + var thinking: String + var attachments: [ClawdisChatAttachmentPayload]? + var timeoutMs: Int + var idempotencyKey: String + } + + let params = Params( + sessionKey: sessionKey, + message: message, + thinking: thinking, + attachments: attachments.isEmpty ? nil : attachments, + timeoutMs: 30000, + idempotencyKey: idempotencyKey) + let data = try JSONEncoder().encode(params) + let json = String(data: data, encoding: .utf8) + let res = try await self.bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35) + return try JSONDecoder().decode(ClawdisChatSendResponse.self, from: res) + } + + func requestHealth(timeoutMs: Int) async throws -> Bool { + let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0))) + let res = try await self.bridge.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds) + return (try? JSONDecoder().decode(ClawdisGatewayHealthOK.self, from: res))?.ok ?? true + } + + func events() -> AsyncStream { + AsyncStream { continuation in + let task = Task { + let stream = await self.bridge.subscribeServerEvents() + for await evt in stream { + if Task.isCancelled { return } + switch evt.event { + case "tick": + continuation.yield(.tick) + case "seqGap": + continuation.yield(.seqGap) + case "health": + guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break } + let ok = (try? JSONDecoder().decode(ClawdisGatewayHealthOK.self, from: data))?.ok ?? true + continuation.yield(.health(ok: ok)) + case "chat": + guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break } + if let payload = try? JSONDecoder().decode(ClawdisChatEventPayload.self, from: data) { + continuation.yield(.chat(payload)) + } + default: + break + } + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } +} diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 4a4f0e098..de20a117b 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -15,6 +15,8 @@ final class NodeAppModel: ObservableObject { private var bridgeTask: Task? let voiceWake = VoiceWakeManager() + var bridgeSession: BridgeSession { self.bridge } + init() { self.voiceWake.configure { [weak self] cmd in guard let self else { return } diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 9c799cee6..99a3e2e31 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -1,50 +1,84 @@ import SwiftUI struct RootCanvas: View { - @State private var isShowingSettings = false + @EnvironmentObject private var appModel: NodeAppModel + @State private var presentedSheet: PresentedSheet? + + private enum PresentedSheet: Identifiable { + case settings + case chat + + var id: Int { + switch self { + case .settings: 0 + case .chat: 1 + } + } + } var body: some View { ZStack(alignment: .topTrailing) { ScreenTab() - Button { - self.isShowingSettings = true - } label: { - Image(systemName: "gearshape.fill") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.primary) - .padding(10) - .background { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(.ultraThinMaterial) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill( - LinearGradient( - colors: [ - .white.opacity(0.18), - .white.opacity(0.04), - .clear, - ], - startPoint: .topLeading, - endPoint: .bottomTrailing)) - .blendMode(.overlay) - } - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(.white.opacity(0.18), lineWidth: 0.5) - } - .shadow(color: .black.opacity(0.35), radius: 12, y: 6) - } + VStack(spacing: 10) { + OverlayButton(systemImage: "text.bubble.fill") { + self.presentedSheet = .chat + } + .accessibilityLabel("Chat") + + OverlayButton(systemImage: "gearshape.fill") { + self.presentedSheet = .settings + } + .accessibilityLabel("Settings") } - .buttonStyle(.plain) .padding(.top, 10) .padding(.trailing, 10) - .accessibilityLabel("Settings") } - .sheet(isPresented: self.$isShowingSettings) { - SettingsTab() + .sheet(item: self.$presentedSheet) { sheet in + switch sheet { + case .settings: + SettingsTab() + case .chat: + ChatSheet(bridge: self.appModel.bridgeSession) + } } .preferredColorScheme(.dark) } } + +private struct OverlayButton: View { + let systemImage: String + let action: () -> Void + + var body: some View { + Button(action: self.action) { + Image(systemName: self.systemImage) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.primary) + .padding(10) + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.ultraThinMaterial) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill( + LinearGradient( + colors: [ + .white.opacity(0.18), + .white.opacity(0.04), + .clear, + ], + startPoint: .topLeading, + endPoint: .bottomTrailing)) + .blendMode(.overlay) + } + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(.white.opacity(0.18), lineWidth: 0.5) + } + .shadow(color: .black.opacity(0.35), radius: 12, y: 6) + } + } + .buttonStyle(.plain) + } +} diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 90fb54d48..bd8d1479e 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -17,6 +17,8 @@ targets: - path: Sources dependencies: - package: ClawdisKit + - package: ClawdisKit + product: ClawdisChatUI preBuildScripts: - name: SwiftFormat (lint) script: | diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 0be5e9920..ff788f429 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -41,6 +41,7 @@ let package = Package( "ClawdisIPC", "ClawdisProtocol", .product(name: "ClawdisKit", package: "ClawdisKit"), + .product(name: "ClawdisChatUI", package: "ClawdisKit"), .product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"), .product(name: "Subprocess", package: "swift-subprocess"), .product(name: "Sparkle", package: "Sparkle"), diff --git a/apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift b/apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift index 2d56c25a9..a2e318413 100644 --- a/apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift +++ b/apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift @@ -39,7 +39,8 @@ actor BridgeConnectionHandler { handlePair: @escaping @Sendable (BridgePairRequest) async -> PairResult, onAuthenticated: (@Sendable (String) async -> Void)? = nil, onDisconnected: (@Sendable (String) async -> Void)? = nil, - onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)? = nil) async + onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)? = nil, + onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)? = nil) async { self.connection.stateUpdateHandler = { [logger] state in switch state { @@ -94,6 +95,27 @@ actor BridgeConnectionHandler { } let evt = try self.decoder.decode(BridgeEventFrame.self, from: data) await onEvent?(nodeId, evt) + case "req": + let req = try self.decoder.decode(BridgeRPCRequest.self, from: data) + guard self.isAuthenticated, let nodeId = self.nodeId else { + try await self.send( + BridgeRPCResponse( + id: req.id, + ok: false, + error: BridgeRPCError(code: "UNAUTHORIZED", message: "not authenticated"))) + continue + } + + if let onRequest { + let res = await onRequest(nodeId, req) + try await self.send(res) + } else { + try await self.send( + BridgeRPCResponse( + id: req.id, + ok: false, + error: BridgeRPCError(code: "UNAVAILABLE", message: "RPC not supported"))) + } case "ping": if !self.isAuthenticated { await self.sendError(code: "UNAUTHORIZED", message: "not authenticated") @@ -242,6 +264,15 @@ actor BridgeConnectionHandler { } } + func sendServerEvent(event: String, payloadJSON: String?) async { + guard self.isAuthenticated else { return } + do { + try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON)) + } catch { + self.logger.error("bridge send event failed: \(error.localizedDescription, privacy: .public)") + } + } + private func receiveLine() async throws -> String? { while true { if let idx = self.buffer.firstIndex(of: 0x0A) { diff --git a/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift b/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift index 37a901729..63e630678 100644 --- a/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift +++ b/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift @@ -1,4 +1,5 @@ import AppKit +import ClawdisProtocol import ClawdisKit import Foundation import Network @@ -13,6 +14,8 @@ actor BridgeServer { private var store: PairedNodesStore? private var connections: [String: BridgeConnectionHandler] = [:] private var presenceTasks: [String: Task] = [:] + private var chatSubscriptions: [String: Set] = [:] + private var gatewayPushTask: Task? func start() async { if self.isRunning { return } @@ -86,6 +89,13 @@ actor BridgeServer { }, onEvent: { [weak self] nodeId, evt in await self?.handleEvent(nodeId: nodeId, evt: evt) + }, + onRequest: { [weak self] nodeId, req in + await self?.handleRequest(nodeId: nodeId, req: req) + ?? BridgeRPCResponse( + id: req.id, + ok: false, + error: BridgeRPCError(code: "UNAVAILABLE", message: "bridge unavailable")) }) } @@ -106,12 +116,15 @@ actor BridgeServer { self.connections[nodeId] = handler await self.beaconPresence(nodeId: nodeId, reason: "connect") self.startPresenceTask(nodeId: nodeId) + self.ensureGatewayPushTask() } private func unregisterConnection(nodeId: String) async { await self.beaconPresence(nodeId: nodeId, reason: "disconnect") self.stopPresenceTask(nodeId: nodeId) self.connections.removeValue(forKey: nodeId) + self.chatSubscriptions[nodeId] = nil + self.stopGatewayPushTaskIfIdle() } private struct VoiceTranscriptPayload: Codable, Sendable { @@ -121,6 +134,26 @@ actor BridgeServer { private func handleEvent(nodeId: String, evt: BridgeEventFrame) async { switch evt.event { + case "chat.subscribe": + guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { return } + struct Subscribe: Codable { var sessionKey: String } + guard let payload = try? JSONDecoder().decode(Subscribe.self, from: data) else { return } + let key = payload.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return } + var set = self.chatSubscriptions[nodeId] ?? Set() + set.insert(key) + self.chatSubscriptions[nodeId] = set + + case "chat.unsubscribe": + guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { return } + struct Unsubscribe: Codable { var sessionKey: String } + guard let payload = try? JSONDecoder().decode(Unsubscribe.self, from: data) else { return } + let key = payload.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return } + var set = self.chatSubscriptions[nodeId] ?? Set() + set.remove(key) + self.chatSubscriptions[nodeId] = set.isEmpty ? nil : set + case "voice.transcript": guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { return @@ -171,6 +204,126 @@ actor BridgeServer { } } + private func handleRequest(nodeId: String, req: BridgeRPCRequest) async -> BridgeRPCResponse { + let allowed: Set = ["chat.history", "chat.send", "health"] + guard allowed.contains(req.method) else { + return BridgeRPCResponse( + id: req.id, + ok: false, + error: BridgeRPCError(code: "FORBIDDEN", message: "Method not allowed")) + } + + let params: [String: AnyCodable]? + if let json = req.paramsJSON?.trimmingCharacters(in: .whitespacesAndNewlines), !json.isEmpty { + guard let data = json.data(using: .utf8) else { + return BridgeRPCResponse( + id: req.id, + ok: false, + error: BridgeRPCError(code: "INVALID_REQUEST", message: "paramsJSON not UTF-8")) + } + do { + params = try JSONDecoder().decode([String: AnyCodable].self, from: data) + } catch { + return BridgeRPCResponse( + id: req.id, + ok: false, + error: BridgeRPCError(code: "INVALID_REQUEST", message: error.localizedDescription)) + } + } else { + params = nil + } + + do { + let data = try await GatewayConnection.shared.request(method: req.method, params: params, timeoutMs: 30_000) + guard let json = String(data: data, encoding: .utf8) else { + return BridgeRPCResponse( + id: req.id, + ok: false, + error: BridgeRPCError(code: "UNAVAILABLE", message: "Response not UTF-8")) + } + return BridgeRPCResponse(id: req.id, ok: true, payloadJSON: json) + } catch { + return BridgeRPCResponse( + id: req.id, + ok: false, + error: BridgeRPCError(code: "UNAVAILABLE", message: error.localizedDescription)) + } + } + + private func ensureGatewayPushTask() { + if self.gatewayPushTask != nil { return } + self.gatewayPushTask = Task { [weak self] in + guard let self else { return } + do { + try await GatewayConnection.shared.refresh() + } catch { + // We'll still forward events once the gateway comes up. + } + let stream = await GatewayConnection.shared.subscribe() + for await push in stream { + if Task.isCancelled { return } + await self.forwardGatewayPush(push) + } + } + } + + private func stopGatewayPushTaskIfIdle() { + guard self.connections.isEmpty else { return } + self.gatewayPushTask?.cancel() + self.gatewayPushTask = nil + } + + private func forwardGatewayPush(_ push: GatewayPush) async { + let subscribedNodes = self.chatSubscriptions.keys.filter { self.connections[$0] != nil } + guard !subscribedNodes.isEmpty else { return } + + switch push { + case let .snapshot(hello): + let payloadJSON = (try? JSONEncoder().encode(hello.snapshot.health)) + .flatMap { String(data: $0, encoding: .utf8) } + for nodeId in subscribedNodes { + await self.connections[nodeId]?.sendServerEvent(event: "health", payloadJSON: payloadJSON) + } + case let .event(evt): + switch evt.event { + case "health": + guard let payload = evt.payload else { return } + let payloadJSON = (try? JSONEncoder().encode(payload)) + .flatMap { String(data: $0, encoding: .utf8) } + for nodeId in subscribedNodes { + await self.connections[nodeId]?.sendServerEvent(event: "health", payloadJSON: payloadJSON) + } + case "tick": + for nodeId in subscribedNodes { + await self.connections[nodeId]?.sendServerEvent(event: "tick", payloadJSON: nil) + } + case "chat": + guard let payload = evt.payload else { return } + let payloadData = try? JSONEncoder().encode(payload) + let payloadJSON = payloadData.flatMap { String(data: $0, encoding: .utf8) } + + struct MinimalChat: Codable { var sessionKey: String } + let sessionKey = payloadData.flatMap { try? JSONDecoder().decode(MinimalChat.self, from: $0) }?.sessionKey + if let sessionKey { + for nodeId in subscribedNodes { + guard self.chatSubscriptions[nodeId]?.contains(sessionKey) == true else { continue } + await self.connections[nodeId]?.sendServerEvent(event: "chat", payloadJSON: payloadJSON) + } + } else { + for nodeId in subscribedNodes { + await self.connections[nodeId]?.sendServerEvent(event: "chat", payloadJSON: payloadJSON) + } + } + default: + break + } + case .seqGap: + for nodeId in subscribedNodes { + await self.connections[nodeId]?.sendServerEvent(event: "seqGap", payloadJSON: nil) + } + } + } + private func beaconPresence(nodeId: String, reason: String) async { do { let paired = await self.store?.find(nodeId: nodeId) diff --git a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift index aee774eff..212c38798 100644 --- a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift +++ b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift @@ -1,8 +1,9 @@ import AppKit +import ClawdisChatUI import ClawdisProtocol +import Foundation import OSLog import SwiftUI -import UniformTypeIdentifiers private let webChatSwiftLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChatSwiftUI") @@ -12,1047 +13,102 @@ private enum WebChatSwiftUILayout { static let anchorPadding: CGFloat = 8 } -// MARK: - Models - -struct GatewayChatMessageContent: Codable, Hashable { - let type: String? - let text: String? - let mimeType: String? - let fileName: String? - let content: String? -} - -struct GatewayChatMessage: Codable, Identifiable { - var id: UUID = .init() - let role: String - let content: [GatewayChatMessageContent] - let timestamp: Double? - - enum CodingKeys: String, CodingKey { - case role, content, timestamp - } - - init( - id: UUID = .init(), - role: String, - content: [GatewayChatMessageContent], - timestamp: Double?) - { - self.id = id - self.role = role - self.content = content - self.timestamp = timestamp - } - - 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) - - if let decoded = try? container.decode([GatewayChatMessageContent].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 = [ - GatewayChatMessageContent( - type: "text", - text: text, - mimeType: nil, - fileName: nil, - content: nil), - ] - return - } - - self.content = [] - } -} - -struct ChatHistoryPayload: Codable { - let sessionKey: String - let sessionId: String? - let messages: [ClawdisProtocol.AnyCodable]? - let thinkingLevel: String? -} - -struct ChatSendResponse: Codable { - let runId: String - let status: String -} - -struct ChatEventPayload: Codable { - let runId: String? - let sessionKey: String? - let state: String? - let message: ClawdisProtocol.AnyCodable? - let errorMessage: String? -} - -struct GatewayHealthOK: Codable { - let ok: Bool? -} - -struct PendingAttachment: Identifiable { - let id = UUID() - let url: URL? - let data: Data - let fileName: String - let mimeType: String - let type: String = "file" - let preview: NSImage? -} - -// MARK: - View model - -@MainActor -final class WebChatViewModel: ObservableObject { - @Published var messages: [GatewayChatMessage] = [] - @Published var input: String = "" - @Published var thinkingLevel: String = "off" - @Published var isLoading = false - @Published var isSending = false - @Published var errorText: String? - @Published var attachments: [PendingAttachment] = [] - @Published var healthOK: Bool = true - @Published var pendingRunCount: Int = 0 - - let sessionKey: String - private var eventTask: Task? - private var pendingRuns = Set() { - didSet { self.pendingRunCount = self.pendingRuns.count } - } - - private var lastHealthPollAt: Date? - - init(sessionKey: String) { - self.sessionKey = sessionKey - self.eventTask = Task { [weak self] in - guard let self else { return } - let stream = await GatewayConnection.shared.subscribe() - for await push in stream { - if Task.isCancelled { return } - await MainActor.run { [weak self] in - self?.handleGatewayPush(push) - } - } - } - } - - deinit { - self.eventTask?.cancel() - } - - func load() { - Task { await self.bootstrap() } - } - - func refresh() { - Task { await self.bootstrap() } - } - - func send() { - Task { await self.performSend() } - } - - func addAttachments(urls: [URL]) { - Task { - for url in urls { - do { - let data = try await Task.detached { try Data(contentsOf: url) }.value - guard data.count <= 5_000_000 else { - await MainActor.run { - self.errorText = "Attachment \(url.lastPathComponent) exceeds 5 MB limit" - } - continue - } - let uti = UTType(filenameExtension: url.pathExtension) ?? .data - guard uti.conforms(to: .image) else { - await MainActor.run { self.errorText = "Only image attachments are supported right now" } - continue - } - let mime = uti.preferredMIMEType ?? "application/octet-stream" - let preview = NSImage(data: data) - let att = PendingAttachment( - url: url, - data: data, - fileName: url.lastPathComponent, - mimeType: mime, - preview: preview) - await MainActor.run { self.attachments.append(att) } - } catch { - await MainActor.run { self.errorText = error.localizedDescription } - } - } - } - } - - func removeAttachment(_ id: PendingAttachment.ID) { - self.attachments.removeAll { $0.id == id } - } - - var canSend: Bool { - let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines) - return !self.isSending && (!trimmed.isEmpty || !self.attachments.isEmpty) - } - - // MARK: Internals - - private func bootstrap() async { - self.isLoading = true - defer { self.isLoading = false } - do { - let payload = try await self.requestHistory() - self.messages = payload.messages - if let level = payload.thinkingLevel, !level.isEmpty { - self.thinkingLevel = level - } - await self.pollHealthIfNeeded(force: true) - } catch { - self.errorText = error.localizedDescription - webChatSwiftLogger.error("bootstrap failed \(error.localizedDescription, privacy: .public)") - } - } - - 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 - - // Optimistically append user message to UI - var userContent: [GatewayChatMessageContent] = [ - GatewayChatMessageContent( - type: "text", - text: messageText, - mimeType: nil, - fileName: nil, - content: nil), - ] - for att in self.attachments { - userContent.append( - GatewayChatMessageContent( - type: att.type, - text: nil, - mimeType: att.mimeType, - fileName: att.fileName, - content: att.data.base64EncodedString())) - } - - let userMessage = GatewayChatMessage( - id: UUID(), - role: "user", - content: userContent, - timestamp: Date().timeIntervalSince1970 * 1000) - self.messages.append(userMessage) - - let encodedAttachments = self.attachments.map { att in - [ - "type": att.type, - "mimeType": att.mimeType, - "fileName": att.fileName, - "content": att.data.base64EncodedString(), - ] - } - - do { - var params: [String: AnyCodable] = [ - "sessionKey": AnyCodable(self.sessionKey), - "message": AnyCodable(messageText), - "thinking": AnyCodable(self.thinkingLevel), - "idempotencyKey": AnyCodable(runId), - "timeoutMs": AnyCodable(30000), - ] - if !encodedAttachments.isEmpty { - params["attachments"] = AnyCodable(encodedAttachments) - } - let data = try await GatewayConnection.shared.request(method: "chat.send", params: params) - let response = try JSONDecoder().decode(ChatSendResponse.self, from: data) - self.pendingRuns.insert(response.runId) - } catch { - self.errorText = error.localizedDescription - webChatSwiftLogger.error("chat.send failed \(error.localizedDescription, privacy: .public)") - } - - self.input = "" - self.attachments = [] - self.isSending = false - } - - private func requestHistory() async throws -> (messages: [GatewayChatMessage], thinkingLevel: String?) { +struct MacGatewayChatTransport: ClawdisChatTransport, Sendable { + func requestHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload { let data = try await GatewayConnection.shared.request( method: "chat.history", - params: ["sessionKey": AnyCodable(self.sessionKey)]) - let payload = try JSONDecoder().decode(ChatHistoryPayload.self, from: data) - let messages: [GatewayChatMessage] = (payload.messages ?? []).compactMap { raw in - (try? GatewayPayloadDecoding.decode(raw, as: GatewayChatMessage.self)) - } - return (messages, payload.thinkingLevel) + params: ["sessionKey": AnyCodable(sessionKey)]) + return try JSONDecoder().decode(ClawdisChatHistoryPayload.self, from: data) } - private func handleGatewayPush(_ push: GatewayPush) { - switch push { - case let .snapshot(hello): - let health = try? GatewayPayloadDecoding.decode(hello.snapshot.health, as: GatewayHealthOK.self) - self.healthOK = health?.ok ?? true - case let .event(evt): - self.handleGatewayEvent(evt) - case .seqGap: - self.errorText = "Event stream interrupted; try refreshing." + func sendMessage( + sessionKey: String, + message: String, + thinking: String, + idempotencyKey: String, + attachments: [ClawdisChatAttachmentPayload]) async throws -> ClawdisChatSendResponse + { + var params: [String: AnyCodable] = [ + "sessionKey": AnyCodable(sessionKey), + "message": AnyCodable(message), + "thinking": AnyCodable(thinking), + "idempotencyKey": AnyCodable(idempotencyKey), + "timeoutMs": AnyCodable(30_000), + ] + + if !attachments.isEmpty { + let encoded = attachments.map { att in + [ + "type": att.type, + "mimeType": att.mimeType, + "fileName": att.fileName, + "content": att.content, + ] + } + params["attachments"] = AnyCodable(encoded) } + + let data = try await GatewayConnection.shared.request(method: "chat.send", params: params) + return try JSONDecoder().decode(ClawdisChatSendResponse.self, from: data) } - private func handleGatewayEvent(_ evt: EventFrame) { - if evt.event == "health", let payload = evt.payload, - let ok = (try? GatewayPayloadDecoding.decode(payload, as: GatewayHealthOK.self))?.ok - { - self.healthOK = ok - return - } - - if evt.event == "tick" { - Task { await self.pollHealthIfNeeded(force: false) } - return - } - - guard evt.event == "chat" else { return } - guard let payload = evt.payload else { return } - guard let chat = try? GatewayPayloadDecoding.decode(payload, as: ChatEventPayload.self) else { return } - guard chat.sessionKey == nil || chat.sessionKey == self.sessionKey else { return } - - if let runId = chat.runId, !self.pendingRuns.contains(runId) { - // Ignore events for other runs - return - } - - switch chat.state { - case "final": - if let raw = chat.message, - let msg = try? GatewayPayloadDecoding.decode(raw, as: GatewayChatMessage.self) - { - self.messages.append(msg) - } - if let runId = chat.runId { - self.pendingRuns.remove(runId) - } - case "error": - self.errorText = chat.errorMessage ?? "Chat failed" - if let runId = chat.runId { - self.pendingRuns.remove(runId) - } - default: - break - } + func requestHealth(timeoutMs: Int) async throws -> Bool { + let data = try await GatewayConnection.shared.request( + method: "health", + params: nil, + timeoutMs: Double(timeoutMs)) + return (try? JSONDecoder().decode(ClawdisGatewayHealthOK.self, from: data))?.ok ?? true } - private func pollHealthIfNeeded(force: Bool) async { - if !force, let last = self.lastHealthPollAt, Date().timeIntervalSince(last) < 10 { - return - } - self.lastHealthPollAt = Date() - do { - let data = try await GatewayConnection.shared.request(method: "health", params: nil, timeoutMs: 5000) - let ok = (try? JSONDecoder().decode(GatewayHealthOK.self, from: data))?.ok ?? true - self.healthOK = ok - } catch { - self.healthOK = false - } - } -} - -// MARK: - View - -struct WebChatView: View { - @StateObject var viewModel: WebChatViewModel - @State private var scrollerBottomID = UUID() - var body: some View { - ZStack { - Color(nsColor: .windowBackgroundColor) - .ignoresSafeArea() - - VStack(spacing: 14) { - self.header - self.messageList - self.composer - } - .padding(.horizontal, 18) - .padding(.vertical, 16) - .frame(maxWidth: 1040) - } - .background( - LinearGradient( - colors: [ - Color(red: 0.96, green: 0.97, blue: 1.0), - Color(red: 0.93, green: 0.94, blue: 0.98), - ], - startPoint: .top, - endPoint: .bottom) - .opacity(0.35) - .ignoresSafeArea()) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .onAppear { self.viewModel.load() } - } - - private var header: some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Clawd Chat") - .font(.title2.weight(.semibold)) - Text( - "Session \(self.viewModel.sessionKey) · \(self.viewModel.healthOK ? "Connected" : "Connecting…")") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - if self.viewModel.isLoading { - ProgressView().controlSize(.small) - } else { - Circle() - .fill(self.viewModel.healthOK ? Color.green.opacity(0.7) : Color.orange) - .frame(width: 10, height: 10) - } - Button { - self.viewModel.refresh() - } label: { - Image(systemName: "arrow.clockwise") - } - .buttonStyle(.borderless) - .help("Refresh history") - } - .padding(14) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(Color(nsColor: .textBackgroundColor)) - .shadow(color: .black.opacity(0.06), radius: 10, y: 4)) - } - - private var messageList: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 14) { - if self.viewModel.messages.isEmpty { - VStack(spacing: 10) { - Image(systemName: "bubble.left.and.bubble.right.fill") - .font(.system(size: 34, weight: .semibold)) - .foregroundStyle(Color.accentColor.opacity(0.9)) - Text("Say hi to Clawd") - .font(.headline) - Text( - self.viewModel.healthOK - ? "This is the native SwiftUI debug chat." - : "Connecting to the gateway…") - .font(.subheadline) - .foregroundStyle(.secondary) - } - .padding(18) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(Color.white.opacity(0.06))) - .padding(.vertical, 34) - } else { - ForEach(self.viewModel.messages) { msg in - let alignment: Alignment = msg.role.lowercased() == "user" ? .trailing : .leading - MessageBubble(message: msg) - .frame(maxWidth: .infinity, alignment: alignment) - } - } - - if self.viewModel.pendingRunCount > 0 { - TypingIndicatorBubble() - .frame(maxWidth: .infinity, alignment: .leading) - .transition(.opacity) - } - - Color.clear - .frame(height: 1) - .id(self.scrollerBottomID) + func events() -> AsyncStream { + AsyncStream { continuation in + let task = Task { + do { + try await GatewayConnection.shared.refresh() + } catch { + webChatSwiftLogger.error("gateway refresh failed \(error.localizedDescription, privacy: .public)") } - .padding(.vertical, 10) - .padding(.horizontal, 12) - } - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(nsColor: .textBackgroundColor)) - .shadow(color: .black.opacity(0.05), radius: 12, y: 6)) - .onChange(of: self.viewModel.messages.count) { _, _ in - withAnimation(.snappy(duration: 0.22)) { - proxy.scrollTo(self.scrollerBottomID, anchor: .bottom) - } - } - .onChange(of: self.viewModel.pendingRunCount) { _, _ in - withAnimation(.snappy(duration: 0.22)) { - proxy.scrollTo(self.scrollerBottomID, anchor: .bottom) - } - } - } - } - private var composer: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - self.thinkingPicker - Spacer() - Button { - self.pickFiles() - } label: { - Label("Add Image", systemImage: "paperclip") - } - .buttonStyle(.bordered) - } - if !self.viewModel.attachments.isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - ForEach(self.viewModel.attachments) { att in - HStack(spacing: 6) { - if let img = att.preview { - Image(nsImage: 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) + let stream = await GatewayConnection.shared.subscribe() + for await push in stream { + if Task.isCancelled { return } + switch push { + case let .snapshot(hello): + let ok = (try? JSONDecoder().decode( + ClawdisGatewayHealthOK.self, + from: JSONEncoder().encode(hello.snapshot.health)))?.ok ?? true + continuation.yield(.health(ok: ok)) + case let .event(evt): + switch evt.event { + case "health": + guard let payload = evt.payload else { break } + let ok = (try? JSONDecoder().decode( + ClawdisGatewayHealthOK.self, + from: JSONEncoder().encode(payload)))?.ok ?? true + continuation.yield(.health(ok: ok)) + case "tick": + continuation.yield(.tick) + case "chat": + guard let payload = evt.payload else { break } + if let chat = try? JSONDecoder().decode( + ClawdisChatEventPayload.self, + from: JSONEncoder().encode(payload)) + { + continuation.yield(.chat(chat)) } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .padding(.horizontal, 10) - .background(Color.accentColor.opacity(0.08)) - .clipShape(Capsule()) + default: + break } - } - } - } - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.secondary.opacity(0.2)) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(nsColor: .textBackgroundColor))) - .overlay( - ZStack(alignment: .topLeading) { - if self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Text("Message Clawd…") - .foregroundStyle(.tertiary) - .padding(.horizontal, 12) - .padding(.vertical, 10) - } - ComposerTextView(text: self.$viewModel.input) { - self.viewModel.send() - } - .frame(minHeight: 54, maxHeight: 160) - .padding(.horizontal, 10) - .padding(.vertical, 8) - }) - .frame(maxHeight: 180) - - HStack { - if let error = self.viewModel.errorText { - Text(error) - .font(.footnote) - .foregroundStyle(.red) - } - Spacer() - Button { - self.viewModel.send() - } label: { - Label(self.viewModel.isSending ? "Sending…" : "Send", systemImage: "arrow.up.circle.fill") - .font(.headline) - } - .buttonStyle(.borderedProminent) - .disabled(!self.viewModel.canSend) - } - } - .padding(14) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(nsColor: .textBackgroundColor)) - .shadow(color: .black.opacity(0.06), radius: 12, y: 6)) - .onDrop(of: [.fileURL], isTargeted: nil) { providers in - self.handleDrop(providers) - } - } - - 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) - .frame(maxWidth: 200) - } - - private func pickFiles() { - 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 } - let urls = panel.urls - self.viewModel.addAttachments(urls: 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 { await self.viewModel.addAttachments(urls: [url]) } - } - } - return true - } -} - -private struct MessageBubble: View { - let message: GatewayChatMessage - - var body: some View { - VStack(alignment: self.isUser ? .trailing : .leading, spacing: 8) { - HStack(spacing: 8) { - if !self.isUser { - Label("Assistant", systemImage: "sparkles") - .labelStyle(.titleAndIcon) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer(minLength: 0) - if self.isUser { - Label("You", systemImage: "person.fill") - .labelStyle(.titleAndIcon) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - ChatMessageBody(message: self.message, isUser: self.isUser) - .frame(maxWidth: WebChatSwiftUITheme.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading) - } - .padding(.horizontal, 2) - } - - private var isUser: Bool { self.message.role.lowercased() == "user" } -} - -private enum WebChatSwiftUITheme { - static let bubbleMaxWidth: CGFloat = 760 - static let bubbleCorner: CGFloat = 16 -} - -private struct ChatMessageBody: View { - let message: GatewayChatMessage - let isUser: Bool - - var body: some View { - let text = self.primaryText - let split = MarkdownSplitter.split(markdown: text) - - VStack(alignment: .leading, spacing: 10) { - ForEach(split.blocks) { block in - switch block.kind { - case .text: - MarkdownTextView(text: block.text) - case let .code(language): - CodeBlockView(code: block.text, language: language) - } - } - - if !split.images.isEmpty { - ForEach(split.images) { item in - if let img = item.image { - Image(nsImage: 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) + case .seqGap: + continuation.yield(.seqGap) } } } - if !self.inlineAttachments.isEmpty { - ForEach(self.inlineAttachments.indices, id: \.self) { idx in - AttachmentRow(att: self.inlineAttachments[idx]) - } + continuation.onTermination = { @Sendable _ in + task.cancel() } } - .textSelection(.enabled) - .padding(12) - .background(self.bubbleBackground) - .overlay(self.bubbleBorder) - .clipShape(RoundedRectangle(cornerRadius: WebChatSwiftUITheme.bubbleCorner, style: .continuous)) - } - - private var primaryText: String { - let parts = self.message.content.compactMap(\.text) - return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) - } - - private var inlineAttachments: [GatewayChatMessageContent] { - self.message.content.filter { ($0.type ?? "text") != "text" } - } - - private var bubbleBackground: AnyShapeStyle { - if self.isUser { - return AnyShapeStyle( - LinearGradient( - colors: [ - Color.orange.opacity(0.22), - Color.accentColor.opacity(0.18), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing)) - } - return AnyShapeStyle(Color(nsColor: .textBackgroundColor).opacity(0.55)) - } - - private var bubbleBorder: some View { - RoundedRectangle(cornerRadius: WebChatSwiftUITheme.bubbleCorner, style: .continuous) - .strokeBorder( - self.isUser ? Color.orange.opacity(0.35) : Color.white.opacity(0.10), - lineWidth: 1) - } -} - -private struct AttachmentRow: View { - let att: GatewayChatMessageContent - - var body: some View { - HStack(spacing: 8) { - Image(systemName: "paperclip") - Text(self.att.fileName ?? "Attachment") - .font(.footnote) - .lineLimit(1) - Spacer() - } - .padding(10) - .background(Color.white.opacity(0.06)) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - } -} - -private struct TypingIndicatorBubble: View { - var body: some View { - HStack(spacing: 10) { - TypingDots() - Text("Clawd is thinking…") - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(nsColor: .textBackgroundColor).opacity(0.55))) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) - .frame(maxWidth: WebChatSwiftUITheme.bubbleMaxWidth, alignment: .leading) - } -} - -private struct TypingDots: View { - @State private var phase: Double = 0 - - 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.dotScale(idx)) - } - } - .onAppear { - withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { - self.phase = 1 - } - } - } - - private func dotScale(_ idx: Int) -> CGFloat { - let base = 0.85 + (self.phase * 0.35) - let offset = Double(idx) * 0.15 - return CGFloat(base - offset) - } -} - -private struct MarkdownTextView: View { - let text: String - - var body: some View { - if let attributed = try? AttributedString(markdown: self.text) { - Text(attributed) - .font(.system(size: 14)) - .foregroundStyle(.primary) - } else { - Text(self.text) - .font(.system(size: 14)) - .foregroundStyle(.primary) - } - } -} - -private struct CodeBlockView: View { - let code: String - let language: String? - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - if let language, !language.isEmpty { - Text(language) - .font(.caption2.monospaced()) - .foregroundStyle(.secondary) - } - Text(self.code) - .font(.system(size: 13, weight: .regular, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.black.opacity(0.06)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - } -} - -private enum MarkdownSplitter { - struct InlineImage: Identifiable { - let id = UUID() - let label: String - let image: NSImage? - } - - struct Block: Identifiable { - enum Kind: Equatable { - case text - case code(language: String?) - } - - let id = UUID() - let kind: Kind - let text: String - } - - struct SplitResult { - let blocks: [Block] - let images: [InlineImage] - } - - static func split(markdown raw: String) -> SplitResult { - let extracted = self.extractInlineImages(from: raw) - let blocks = self.splitCodeBlocks(from: extracted.cleaned) - return SplitResult(blocks: blocks, images: extracted.images) - } - - private static func splitCodeBlocks(from raw: String) -> [Block] { - var blocks: [Block] = [] - var buffer: [String] = [] - var inCode = false - var codeLang: String? - var codeLines: [String] = [] - - for line in raw.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) { - if line.hasPrefix("```") { - if inCode { - blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n"))) - codeLines.removeAll(keepingCapacity: true) - inCode = false - codeLang = nil - } else { - let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) - if !text.isEmpty { - blocks.append(Block(kind: .text, text: text)) - } - buffer.removeAll(keepingCapacity: true) - inCode = true - codeLang = line.dropFirst(3).trimmingCharacters(in: .whitespacesAndNewlines) - if codeLang?.isEmpty == true { codeLang = nil } - } - continue - } - - if inCode { - codeLines.append(line) - } else { - buffer.append(line) - } - } - - if inCode { - blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n"))) - } else { - let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) - if !text.isEmpty { - blocks.append(Block(kind: .text, text: text)) - } - } - - return blocks.isEmpty ? [Block(kind: .text, text: raw)] : blocks - } - - private static func extractInlineImages(from raw: String) -> (cleaned: String, images: [InlineImage]) { - let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"# - guard let re = try? NSRegularExpression(pattern: pattern) else { - return (raw, []) - } - - let ns = raw as NSString - let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length)) - if matches.isEmpty { return (raw, []) } - - var images: [InlineImage] = [] - var cleaned = raw - - for match in matches.reversed() { - guard match.numberOfRanges >= 3 else { continue } - let label = ns.substring(with: match.range(at: 1)) - let dataURL = ns.substring(with: match.range(at: 2)) - - let image: NSImage? = { - 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 NSImage(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.. Void - - func makeCoordinator() -> Coordinator { Coordinator(self) } - - func makeNSView(context: Context) -> NSScrollView { - let textView = ComposerNSTextView() - 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: 8) - 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? ComposerNSTextView else { return } - let isEditing = scrollView.window?.firstResponder == textView - if isEditing { 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: ComposerTextView - var isProgrammaticUpdate = false - - init(_ parent: ComposerTextView) { 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 ComposerNSTextView: 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) } } @@ -1062,7 +118,7 @@ private final class ComposerNSTextView: NSTextView { final class WebChatSwiftUIWindowController { private let presentation: WebChatPresentation private let sessionKey: String - private let hosting: NSHostingController + private let hosting: NSHostingController private var window: NSWindow? private var dismissMonitor: Any? var onClosed: (() -> Void)? @@ -1071,8 +127,8 @@ final class WebChatSwiftUIWindowController { init(sessionKey: String, presentation: WebChatPresentation) { self.sessionKey = sessionKey self.presentation = presentation - let vm = WebChatViewModel(sessionKey: sessionKey) - self.hosting = NSHostingController(rootView: WebChatView(viewModel: vm)) + let vm = ClawdisChatViewModel(sessionKey: sessionKey, transport: MacGatewayChatTransport()) + self.hosting = NSHostingController(rootView: ClawdisChatView(viewModel: vm)) self.window = Self.makeWindow(for: presentation, contentViewController: self.hosting) } diff --git a/apps/shared/ClawdisKit/Package.swift b/apps/shared/ClawdisKit/Package.swift index 090c5b944..770d991f2 100644 --- a/apps/shared/ClawdisKit/Package.swift +++ b/apps/shared/ClawdisKit/Package.swift @@ -10,6 +10,7 @@ let package = Package( ], products: [ .library(name: "ClawdisKit", targets: ["ClawdisKit"]), + .library(name: "ClawdisChatUI", targets: ["ClawdisChatUI"]), ], targets: [ .target( @@ -18,6 +19,12 @@ let package = Package( swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ]), + .target( + name: "ClawdisChatUI", + dependencies: ["ClawdisKit"], + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), .testTarget( name: "ClawdisKitTests", dependencies: ["ClawdisKit"]), diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift new file mode 100644 index 000000000..e5bd874d6 --- /dev/null +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift @@ -0,0 +1,308 @@ +import Foundation +import SwiftUI + +#if !os(macOS) +import PhotosUI +import UniformTypeIdentifiers +#endif + +@MainActor +struct ClawdisChatComposer: View { + @ObservedObject var viewModel: ClawdisChatViewModel + + #if !os(macOS) + @State private var pickerItems: [PhotosPickerItem] = [] + @FocusState private var isFocused: Bool + #endif + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + self.thinkingPicker + Spacer() + self.attachmentPicker + } + + if !self.viewModel.attachments.isEmpty { + self.attachmentsStrip + } + + self.editor + + HStack { + if let error = self.viewModel.errorText { + Text(error) + .font(.footnote) + .foregroundStyle(.red) + } + Spacer() + Button { + self.viewModel.send() + } label: { + Label(self.viewModel.isSending ? "Sending…" : "Send", systemImage: "arrow.up.circle.fill") + .font(.headline) + } + .buttonStyle(.borderedProminent) + .disabled(!self.viewModel.canSend) + } + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(ClawdisChatTheme.card) + .shadow(color: .black.opacity(0.06), radius: 12, y: 6)) + #if os(macOS) + .onDrop(of: [.fileURL], isTargeted: nil) { providers in + self.handleDrop(providers) + } + #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) + .frame(maxWidth: 200) + } + + @ViewBuilder + private var attachmentPicker: some View { + #if os(macOS) + Button { + self.pickFilesMac() + } label: { + Label("Add Image", systemImage: "paperclip") + } + .buttonStyle(.bordered) + #else + PhotosPicker(selection: self.$pickerItems, maxSelectionCount: 8, matching: .images) { + Label("Add Image", systemImage: "paperclip") + } + .buttonStyle(.bordered) + .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: \ClawdisPendingAttachment.id) + { (att: ClawdisPendingAttachment) in + HStack(spacing: 6) { + if let img = att.preview { + ClawdisPlatformImageFactory.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, 10) + .padding(.vertical, 6) + .padding(.horizontal, 10) + .background(Color.accentColor.opacity(0.08)) + .clipShape(Capsule()) + } + } + } + } + + private var editor: some View { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(ClawdisChatTheme.divider) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(ClawdisChatTheme.card)) + .overlay(self.editorOverlay) + .frame(maxHeight: 180) + } + + private var editorOverlay: some View { + ZStack(alignment: .topLeading) { + if self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text("Message Clawd…") + .foregroundStyle(.tertiary) + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + + #if os(macOS) + ChatComposerTextView(text: self.$viewModel.input) { + self.viewModel.send() + } + .frame(minHeight: 54, maxHeight: 160) + .padding(.horizontal, 10) + .padding(.vertical, 8) + #else + TextEditor(text: self.$viewModel.input) + .font(.system(size: 15)) + .scrollContentBackground(.hidden) + .padding(.horizontal, 8) + .padding(.vertical, 8) + .focused(self.$isFocused) + #endif + } + } + + #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 + 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: 8) + 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 } + let isEditing = scrollView.window?.firstResponder == textView + if isEditing { 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 diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMarkdownSplitter.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMarkdownSplitter.swift new file mode 100644 index 000000000..eaf39ca8e --- /dev/null +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMarkdownSplitter.swift @@ -0,0 +1,114 @@ +import Foundation + +enum ChatMarkdownSplitter { + struct InlineImage: Identifiable { + let id = UUID() + let label: String + let image: ClawdisPlatformImage? + } + + struct Block: Identifiable { + enum Kind: Equatable { + case text + case code(language: String?) + } + + let id = UUID() + let kind: Kind + let text: String + } + + struct SplitResult { + let blocks: [Block] + let images: [InlineImage] + } + + static func split(markdown raw: String) -> SplitResult { + let extracted = self.extractInlineImages(from: raw) + let blocks = self.splitCodeBlocks(from: extracted.cleaned) + return SplitResult(blocks: blocks, images: extracted.images) + } + + private static func splitCodeBlocks(from raw: String) -> [Block] { + var blocks: [Block] = [] + var buffer: [String] = [] + var inCode = false + var codeLang: String? + var codeLines: [String] = [] + + for line in raw.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) { + if line.hasPrefix("```") { + if inCode { + blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n"))) + codeLines.removeAll(keepingCapacity: true) + inCode = false + codeLang = nil + } else { + let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { + blocks.append(Block(kind: .text, text: text)) + } + buffer.removeAll(keepingCapacity: true) + inCode = true + codeLang = line.dropFirst(3).trimmingCharacters(in: .whitespacesAndNewlines) + if codeLang?.isEmpty == true { codeLang = nil } + } + continue + } + + if inCode { + codeLines.append(line) + } else { + buffer.append(line) + } + } + + if inCode { + blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n"))) + } else { + let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { + blocks.append(Block(kind: .text, text: text)) + } + } + + return blocks.isEmpty ? [Block(kind: .text, text: raw)] : blocks + } + + private static func extractInlineImages(from raw: String) -> (cleaned: String, images: [InlineImage]) { + let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"# + guard let re = try? NSRegularExpression(pattern: pattern) else { + return (raw, []) + } + + let ns = raw as NSString + let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length)) + if matches.isEmpty { return (raw, []) } + + var images: [InlineImage] = [] + var cleaned = raw + + for match in matches.reversed() { + guard match.numberOfRanges >= 3 else { continue } + let label = ns.substring(with: match.range(at: 1)) + let dataURL = ns.substring(with: match.range(at: 2)) + + let image: ClawdisPlatformImage? = { + 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 ClawdisPlatformImage(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.. CGFloat { + let base = 0.85 + (self.phase * 0.35) + let offset = Double(idx) * 0.15 + return CGFloat(base - offset) + } +} + +@MainActor +private struct MarkdownTextView: View { + let text: String + + var body: some View { + if let attributed = try? AttributedString(markdown: self.text) { + Text(attributed) + .font(.system(size: 14)) + .foregroundStyle(.primary) + } else { + Text(self.text) + .font(.system(size: 14)) + .foregroundStyle(.primary) + } + } +} + +@MainActor +private struct CodeBlockView: View { + let code: String + let language: String? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let language, !language.isEmpty { + Text(language) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } + Text(self.code) + .font(.system(size: 13, weight: .regular, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.black.opacity(0.06)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } +} diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatModels.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatModels.swift new file mode 100644 index 000000000..1d7361fce --- /dev/null +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatModels.swift @@ -0,0 +1,147 @@ +import ClawdisKit +import Foundation + +#if canImport(AppKit) +import AppKit + +public typealias ClawdisPlatformImage = NSImage +#elseif canImport(UIKit) +import UIKit + +public typealias ClawdisPlatformImage = UIImage +#endif + +public struct ClawdisChatMessageContent: Codable, Hashable, Sendable { + public let type: String? + public let text: String? + public let mimeType: String? + public let fileName: String? + public let content: String? + + public init( + type: String?, + text: String?, + mimeType: String?, + fileName: String?, + content: String?) + { + self.type = type + self.text = text + self.mimeType = mimeType + self.fileName = fileName + self.content = content + } +} + +public struct ClawdisChatMessage: Codable, Identifiable, Sendable { + public var id: UUID = .init() + public let role: String + public let content: [ClawdisChatMessageContent] + public let timestamp: Double? + + enum CodingKeys: String, CodingKey { + case role, content, timestamp + } + + public init( + id: UUID = .init(), + role: String, + content: [ClawdisChatMessageContent], + timestamp: Double?) + { + self.id = id + self.role = role + self.content = content + self.timestamp = timestamp + } + + 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) + + if let decoded = try? container.decode([ClawdisChatMessageContent].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 = [ + ClawdisChatMessageContent( + type: "text", + text: text, + mimeType: nil, + fileName: nil, + content: nil), + ] + return + } + + self.content = [] + } +} + +public struct ClawdisChatHistoryPayload: Codable, Sendable { + public let sessionKey: String + public let sessionId: String? + public let messages: [AnyCodable]? + public let thinkingLevel: String? +} + +public struct ClawdisChatSendResponse: Codable, Sendable { + public let runId: String + public let status: String +} + +public struct ClawdisChatEventPayload: Codable, Sendable { + public let runId: String? + public let sessionKey: String? + public let state: String? + public let message: AnyCodable? + public let errorMessage: String? +} + +public struct ClawdisGatewayHealthOK: Codable, Sendable { + public let ok: Bool? +} + +public struct ClawdisPendingAttachment: 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: ClawdisPlatformImage? + + public init( + url: URL?, + data: Data, + fileName: String, + mimeType: String, + type: String = "file", + preview: ClawdisPlatformImage?) + { + self.url = url + self.data = data + self.fileName = fileName + self.mimeType = mimeType + self.type = type + self.preview = preview + } +} + +public struct ClawdisChatAttachmentPayload: 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 + } +} diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatPayloadDecoding.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatPayloadDecoding.swift new file mode 100644 index 000000000..f9671d250 --- /dev/null +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatPayloadDecoding.swift @@ -0,0 +1,9 @@ +import ClawdisKit +import Foundation + +enum ChatPayloadDecoding { + static func decode(_ payload: AnyCodable, as _: T.Type = T.self) throws -> T { + let data = try JSONEncoder().encode(payload) + return try JSONDecoder().decode(T.self, from: data) + } +} diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift new file mode 100644 index 000000000..5f2dafae1 --- /dev/null +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift @@ -0,0 +1,47 @@ +import SwiftUI + +#if os(macOS) +import AppKit +#else +import UIKit +#endif + +enum ClawdisChatTheme { + static var surface: Color { + #if os(macOS) + Color(nsColor: .windowBackgroundColor) + #else + Color(uiColor: .systemBackground) + #endif + } + + static var card: Color { + #if os(macOS) + Color(nsColor: .textBackgroundColor) + #else + Color(uiColor: .secondarySystemBackground) + #endif + } + + static var subtleCard: Color { + #if os(macOS) + Color(nsColor: .textBackgroundColor).opacity(0.55) + #else + Color(uiColor: .secondarySystemBackground).opacity(0.9) + #endif + } + + static var divider: Color { + Color.secondary.opacity(0.2) + } +} + +enum ClawdisPlatformImageFactory { + static func image(_ image: ClawdisPlatformImage) -> Image { + #if os(macOS) + Image(nsImage: image) + #else + Image(uiImage: image) + #endif + } +} diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTransport.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTransport.swift new file mode 100644 index 000000000..3c3fc010f --- /dev/null +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTransport.swift @@ -0,0 +1,27 @@ +import Foundation + +public enum ClawdisChatTransportEvent: Sendable { + case health(ok: Bool) + case tick + case chat(ClawdisChatEventPayload) + case seqGap +} + +public protocol ClawdisChatTransport: Sendable { + func requestHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload + func sendMessage( + sessionKey: String, + message: String, + thinking: String, + idempotencyKey: String, + attachments: [ClawdisChatAttachmentPayload]) async throws -> ClawdisChatSendResponse + + func requestHealth(timeoutMs: Int) async throws -> Bool + func events() -> AsyncStream + + func setActiveSessionKey(_ sessionKey: String) async throws +} + +extension ClawdisChatTransport { + public func setActiveSessionKey(_: String) async throws {} +} diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift new file mode 100644 index 000000000..7817d60be --- /dev/null +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift @@ -0,0 +1,100 @@ +import SwiftUI + +@MainActor +public struct ClawdisChatView: View { + @StateObject private var viewModel: ClawdisChatViewModel + @State private var scrollerBottomID = UUID() + + public init(viewModel: ClawdisChatViewModel) { + self._viewModel = StateObject(wrappedValue: viewModel) + } + + public var body: some View { + ZStack { + ClawdisChatTheme.surface + .ignoresSafeArea() + + VStack(spacing: 14) { + self.header + self.messageList + ClawdisChatComposer(viewModel: self.viewModel) + } + .padding(.horizontal, 18) + .padding(.vertical, 16) + .frame(maxWidth: 1040) + } + .background( + LinearGradient( + colors: [ + Color(red: 0.96, green: 0.97, blue: 1.0), + Color(red: 0.93, green: 0.94, blue: 0.98), + ], + startPoint: .top, + endPoint: .bottom) + .opacity(0.35) + .ignoresSafeArea()) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .onAppear { self.viewModel.load() } + } + + private var header: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Clawd Chat") + .font(.title2.weight(.semibold)) + Text("Session \(self.viewModel.sessionKey) · \(self.viewModel.healthOK ? "Connected" : "Connecting…")") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Button { + self.viewModel.refresh() + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + } + } + + private var messageList: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 14) { + ForEach(self.viewModel.messages) { msg in + ChatMessageBubble(message: msg) + .frame( + maxWidth: .infinity, + alignment: msg.role.lowercased() == "user" ? .trailing : .leading) + } + + if self.viewModel.pendingRunCount > 0 { + ChatTypingIndicatorBubble() + .frame(maxWidth: .infinity, alignment: .leading) + } + + Color.clear + .frame(height: 1) + .id(self.scrollerBottomID) + } + .padding(.vertical, 10) + .padding(.horizontal, 12) + } + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(ClawdisChatTheme.card) + .shadow(color: .black.opacity(0.05), radius: 12, y: 6)) + .onChange(of: self.viewModel.messages.count) { _, _ in + withAnimation(.snappy(duration: 0.22)) { + proxy.scrollTo(self.scrollerBottomID, anchor: .bottom) + } + } + .onChange(of: self.viewModel.pendingRunCount) { _, _ in + withAnimation(.snappy(duration: 0.22)) { + proxy.scrollTo(self.scrollerBottomID, anchor: .bottom) + } + } + } + } +} diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift new file mode 100644 index 000000000..98a34d056 --- /dev/null +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift @@ -0,0 +1,293 @@ +import ClawdisKit +import Foundation +import OSLog +import UniformTypeIdentifiers + +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif + +private let chatUILogger = Logger(subsystem: "com.steipete.clawdis", category: "ClawdisChatUI") + +@MainActor +public final class ClawdisChatViewModel: ObservableObject { + @Published public private(set) var messages: [ClawdisChatMessage] = [] + @Published public var input: String = "" + @Published public var thinkingLevel: String = "off" + @Published public private(set) var isLoading = false + @Published public private(set) var isSending = false + @Published public var errorText: String? + @Published public var attachments: [ClawdisPendingAttachment] = [] + @Published public private(set) var healthOK: Bool = true + @Published public private(set) var pendingRunCount: Int = 0 + + public let sessionKey: String + private let transport: any ClawdisChatTransport + + private var eventTask: Task? + private var pendingRuns = Set() { + didSet { self.pendingRunCount = self.pendingRuns.count } + } + + private var lastHealthPollAt: Date? + + public init(sessionKey: String, transport: any ClawdisChatTransport) { + 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() + } + + public func load() { + Task { await self.bootstrap() } + } + + public func refresh() { + Task { await self.bootstrap() } + } + + public func send() { + Task { await self.performSend() } + } + + 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: ClawdisPendingAttachment.ID) { + self.attachments.removeAll { $0.id == id } + } + + public var canSend: Bool { + let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines) + return !self.isSending && (!trimmed.isEmpty || !self.attachments.isEmpty) + } + + // MARK: - Internals + + private func bootstrap() async { + self.isLoading = true + 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 ?? []) + if let level = payload.thinkingLevel, !level.isEmpty { + self.thinkingLevel = level + } + await self.pollHealthIfNeeded(force: true) + } catch { + self.errorText = error.localizedDescription + chatUILogger.error("bootstrap failed \(error.localizedDescription, privacy: .public)") + } + } + + private static func decodeMessages(_ raw: [AnyCodable]) -> [ClawdisChatMessage] { + raw.compactMap { item in + (try? ChatPayloadDecoding.decode(item, as: ClawdisChatMessage.self)) + } + } + + 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 + + // Optimistically append user message to UI. + var userContent: [ClawdisChatMessageContent] = [ + ClawdisChatMessageContent( + type: "text", + text: messageText, + mimeType: nil, + fileName: nil, + content: nil), + ] + let encodedAttachments = self.attachments.map { att -> ClawdisChatAttachmentPayload in + ClawdisChatAttachmentPayload( + type: att.type, + mimeType: att.mimeType, + fileName: att.fileName, + content: att.data.base64EncodedString()) + } + for att in encodedAttachments { + userContent.append( + ClawdisChatMessageContent( + type: att.type, + text: nil, + mimeType: att.mimeType, + fileName: att.fileName, + content: att.content)) + } + self.messages.append( + ClawdisChatMessage( + id: UUID(), + role: "user", + content: userContent, + timestamp: Date().timeIntervalSince1970 * 1000)) + + do { + let response = try await self.transport.sendMessage( + sessionKey: self.sessionKey, + message: messageText, + thinking: self.thinkingLevel, + idempotencyKey: runId, + attachments: encodedAttachments) + self.pendingRuns.insert(response.runId) + } catch { + self.errorText = error.localizedDescription + chatUILogger.error("chat.send failed \(error.localizedDescription, privacy: .public)") + } + + self.input = "" + self.attachments = [] + self.isSending = false + } + + private func handleTransportEvent(_ evt: ClawdisChatTransportEvent) { + 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 .seqGap: + self.errorText = "Event stream interrupted; try refreshing." + } + } + + private func handleChatEvent(_ chat: ClawdisChatEventPayload) { + if let sessionKey = chat.sessionKey, sessionKey != self.sessionKey { + return + } + + if let runId = chat.runId, !self.pendingRuns.contains(runId) { + // Ignore events for other runs. + return + } + + switch chat.state { + case "final": + if let raw = chat.message, + let msg = try? ChatPayloadDecoding.decode(raw, as: ClawdisChatMessage.self) + { + self.messages.append(msg) + } + if let runId = chat.runId { + self.pendingRuns.remove(runId) + } + case "error": + self.errorText = chat.errorMessage ?? "Chat failed" + if let runId = chat.runId { + self.pendingRuns.remove(runId) + } + default: + break + } + } + + 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( + ClawdisPendingAttachment( + url: url, + data: data, + fileName: fileName, + mimeType: mimeType, + preview: preview)) + } + + private static func previewImage(data: Data) -> ClawdisPlatformImage? { + #if canImport(AppKit) + NSImage(data: data) + #elseif canImport(UIKit) + UIImage(data: data) + #else + nil + #endif + } +} diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/AnyCodable.swift b/apps/shared/ClawdisKit/Sources/ClawdisKit/AnyCodable.swift new file mode 100644 index 000000000..ef522447f --- /dev/null +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/AnyCodable.swift @@ -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) + } + } +} diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift b/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift index 3ffa59d81..74bf435e3 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift @@ -157,3 +157,51 @@ public struct BridgeErrorFrame: Codable, Sendable { 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 + } +}