feat(chat): share SwiftUI chat across macOS+iOS
This commit is contained in:
@@ -16,6 +16,8 @@ actor BridgeSession {
|
||||
private var connection: NWConnection?
|
||||
private var queue: DispatchQueue?
|
||||
private var buffer = Data()
|
||||
private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:]
|
||||
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.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<BridgeEventFrame> {
|
||||
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<BridgeRPCResponse, Error>) 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: [
|
||||
|
||||
33
apps/ios/Sources/Chat/ChatSheet.swift
Normal file
33
apps/ios/Sources/Chat/ChatSheet.swift
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
93
apps/ios/Sources/Chat/IOSBridgeChatTransport.swift
Normal file
93
apps/ios/Sources/Chat/IOSBridgeChatTransport.swift
Normal file
@@ -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<ClawdisChatTransportEvent> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ final class NodeAppModel: ObservableObject {
|
||||
private var bridgeTask: Task<Void, Never>?
|
||||
let voiceWake = VoiceWakeManager()
|
||||
|
||||
var bridgeSession: BridgeSession { self.bridge }
|
||||
|
||||
init() {
|
||||
self.voiceWake.configure { [weak self] cmd in
|
||||
guard let self else { return }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ targets:
|
||||
- path: Sources
|
||||
dependencies:
|
||||
- package: ClawdisKit
|
||||
- package: ClawdisKit
|
||||
product: ClawdisChatUI
|
||||
preBuildScripts:
|
||||
- name: SwiftFormat (lint)
|
||||
script: |
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Void, Never>] = [:]
|
||||
private var chatSubscriptions: [String: Set<String>] = [:]
|
||||
private var gatewayPushTask: Task<Void, Never>?
|
||||
|
||||
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<String>()
|
||||
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<String>()
|
||||
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<String> = ["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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"]),
|
||||
|
||||
308
apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift
Normal file
308
apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift
Normal file
@@ -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
|
||||
@@ -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..<end, with: "")
|
||||
}
|
||||
|
||||
let normalized = cleaned
|
||||
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return (normalized, images.reversed())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
private enum ChatUIConstants {
|
||||
static let bubbleMaxWidth: CGFloat = 760
|
||||
static let bubbleCorner: CGFloat = 16
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ChatMessageBubble: View {
|
||||
let message: ClawdisChatMessage
|
||||
|
||||
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: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
|
||||
private var isUser: Bool { self.message.role.lowercased() == "user" }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct ChatMessageBody: View {
|
||||
let message: ClawdisChatMessage
|
||||
let isUser: Bool
|
||||
|
||||
var body: some View {
|
||||
let text = self.primaryText
|
||||
let split = ChatMarkdownSplitter.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,
|
||||
id: \ChatMarkdownSplitter.InlineImage.id)
|
||||
{ (item: ChatMarkdownSplitter.InlineImage) in
|
||||
if let img = item.image {
|
||||
ClawdisPlatformImageFactory.image(img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxHeight: 260)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
||||
} else {
|
||||
Text(item.label.isEmpty ? "Image" : item.label)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !self.inlineAttachments.isEmpty {
|
||||
ForEach(self.inlineAttachments.indices, id: \.self) { idx in
|
||||
AttachmentRow(att: self.inlineAttachments[idx])
|
||||
}
|
||||
}
|
||||
}
|
||||
.textSelection(.enabled)
|
||||
.padding(12)
|
||||
.background(self.bubbleBackground)
|
||||
.overlay(self.bubbleBorder)
|
||||
.clipShape(RoundedRectangle(cornerRadius: ChatUIConstants.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: [ClawdisChatMessageContent] {
|
||||
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(ClawdisChatTheme.subtleCard)
|
||||
}
|
||||
|
||||
private var bubbleBorder: some View {
|
||||
RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous)
|
||||
.strokeBorder(self.isUser ? Color.orange.opacity(0.35) : Color.white.opacity(0.10), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AttachmentRow: View {
|
||||
let att: ClawdisChatMessageContent
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ChatTypingIndicatorBubble: 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(ClawdisChatTheme.subtleCard))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.10), lineWidth: 1))
|
||||
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@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))
|
||||
}
|
||||
}
|
||||
147
apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatModels.swift
Normal file
147
apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatModels.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import ClawdisKit
|
||||
import Foundation
|
||||
|
||||
enum ChatPayloadDecoding {
|
||||
static func decode<T: Decodable>(_ payload: AnyCodable, as _: T.Type = T.self) throws -> T {
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
}
|
||||
47
apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift
Normal file
47
apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<ClawdisChatTransportEvent>
|
||||
|
||||
func setActiveSessionKey(_ sessionKey: String) async throws
|
||||
}
|
||||
|
||||
extension ClawdisChatTransport {
|
||||
public func setActiveSessionKey(_: String) async throws {}
|
||||
}
|
||||
100
apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift
Normal file
100
apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
293
apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift
Normal file
293
apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift
Normal file
@@ -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<Void, Never>?
|
||||
private var pendingRuns = Set<String>() {
|
||||
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
|
||||
}
|
||||
}
|
||||
93
apps/shared/ClawdisKit/Sources/ClawdisKit/AnyCodable.swift
Normal file
93
apps/shared/ClawdisKit/Sources/ClawdisKit/AnyCodable.swift
Normal file
@@ -0,0 +1,93 @@
|
||||
import Foundation
|
||||
|
||||
/// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads.
|
||||
///
|
||||
/// Marked `@unchecked Sendable` because it can hold reference types.
|
||||
public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
|
||||
public let value: Any
|
||||
|
||||
public init(_ value: Any) { self.value = value }
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
|
||||
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
|
||||
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
|
||||
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
|
||||
if container.decodeNil() { self.value = NSNull(); return }
|
||||
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
|
||||
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type")
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self.value {
|
||||
case let intVal as Int: try container.encode(intVal)
|
||||
case let doubleVal as Double: try container.encode(doubleVal)
|
||||
case let boolVal as Bool: try container.encode(boolVal)
|
||||
case let stringVal as String: try container.encode(stringVal)
|
||||
case is NSNull: try container.encodeNil()
|
||||
case let dict as [String: AnyCodable]: try container.encode(dict)
|
||||
case let array as [AnyCodable]: try container.encode(array)
|
||||
case let dict as [String: Any]:
|
||||
try container.encode(dict.mapValues { AnyCodable($0) })
|
||||
case let array as [Any]:
|
||||
try container.encode(array.map { AnyCodable($0) })
|
||||
case let dict as NSDictionary:
|
||||
var converted: [String: AnyCodable] = [:]
|
||||
for (k, v) in dict {
|
||||
guard let key = k as? String else { continue }
|
||||
converted[key] = AnyCodable(v)
|
||||
}
|
||||
try container.encode(converted)
|
||||
case let array as NSArray:
|
||||
try container.encode(array.map { AnyCodable($0) })
|
||||
default:
|
||||
let context = EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Unsupported type")
|
||||
throw EncodingError.invalidValue(self.value, context)
|
||||
}
|
||||
}
|
||||
|
||||
public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool {
|
||||
switch (lhs.value, rhs.value) {
|
||||
case let (l as Int, r as Int): l == r
|
||||
case let (l as Double, r as Double): l == r
|
||||
case let (l as Bool, r as Bool): l == r
|
||||
case let (l as String, r as String): l == r
|
||||
case (_ as NSNull, _ as NSNull): true
|
||||
case let (l as [String: AnyCodable], r as [String: AnyCodable]): l == r
|
||||
case let (l as [AnyCodable], r as [AnyCodable]): l == r
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
switch self.value {
|
||||
case let v as Int:
|
||||
hasher.combine(0); hasher.combine(v)
|
||||
case let v as Double:
|
||||
hasher.combine(1); hasher.combine(v)
|
||||
case let v as Bool:
|
||||
hasher.combine(2); hasher.combine(v)
|
||||
case let v as String:
|
||||
hasher.combine(3); hasher.combine(v)
|
||||
case _ as NSNull:
|
||||
hasher.combine(4)
|
||||
case let v as [String: AnyCodable]:
|
||||
hasher.combine(5)
|
||||
for (k, val) in v.sorted(by: { $0.key < $1.key }) {
|
||||
hasher.combine(k)
|
||||
hasher.combine(val)
|
||||
}
|
||||
case let v as [AnyCodable]:
|
||||
hasher.combine(6)
|
||||
for item in v {
|
||||
hasher.combine(item)
|
||||
}
|
||||
default:
|
||||
hasher.combine(999)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user