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: |
|
||||
|
||||
Reference in New Issue
Block a user