Skip to content

peterfriese/ConversationKit

Repository files navigation

ConversationKit

ConversationKit is a SwiftUI library that provides an elegant and easy-to-use chat interface for iOS applications. It offers a complete solution for building conversational UIs with support for text messages, images, markdown rendering, and seamless integration with AI services.

Features

  • 💬 Ready-to-use chat interface with built-in message bubbles
  • 👤 Multi-participant support (user vs other)
  • 🖼️ Image message support with async loading
  • 📝 Markdown rendering for rich text messages
  • ⚡️ Async/await support for message handling
  • 🎨 Customizable message rendering with custom content closures
  • 📱 Modern iOS design with glass effects (iOS 17+)
  • 🔄 Real-time message streaming support
  • 📎 Attachment actions with customizable menu
  • 🎯 Gemini-style "Push and Pin" scrolling (native SwiftUI clamping logic)
  • ⚙️ Progressive disclosure APIs for custom actions and disclaimers
  • 🛑 Interruptible generation (built-in stop button support)

Requirements

  • iOS 17.0+
  • Swift 5.10+
  • Xcode 15.0+

Installation

Add ConversationKit to your project using Swift Package Manager:

dependencies: [
    .package(url: "https://github.com/peterfriese/ConversationKit", from: "1.0.0")
]

Quick Start

Basic Usage

import SwiftUI
import ConversationKit

struct ChatView: View {
    @State private var messages: [DefaultMessage] = []
    
    var body: some View {
        NavigationStack {
            ConversationView(messages: $messages)
                .onSendMessage { userMessage in
                    // Handle the sent message asynchronously
                    await processMessage(userMessage)
                }
                .navigationTitle("Chat")
                .navigationBarTitleDisplayMode(.inline)
        }
    }
    
    func processMessage(_ message: any Message) async {
        // Append the user's message to the messages array
        if let defaultMessage = message as? DefaultMessage {
          messages.append(defaultMessage)
        }
        // Simulate async response
        try? await Task.sleep(for: .seconds(1))
        await MainActor.run {
            messages.append(DefaultMessage(
                content: "You said: \(message.content ?? "")",
                participant: .other
            ))
        }
    }
}

With Initial Messages

@State private var messages: [DefaultMessage] = [
    .init(content: "Hello! How can I help you today?", participant: .other),
    .init(content: "I'm doing great, thanks!", participant: .user),
    .init(content: "That's wonderful to hear!", participant: .other)
]

Core Components

The Message protocol

The basic unit of conversation is the Message protocol. You can use your own types to represent messages, as long as they conform to this protocol.

public protocol Message: Identifiable, Hashable {
  var content: String? { get set }
  var imageURL: String? { get }
  var participant: Participant { get }
  var error: (any Error)? { get }

  init(content: String?, imageURL: String?, participant: Participant)
}

public enum Participant {
    case other
    case user
}

ConversationKit provides a default implementation of this protocol, DefaultMessage.

ConversationView

The main chat interface. It can be initialized in a few ways:

  1. With DefaultMessage and attachments:
    ConversationView(
        messages: $messages,
        attachments: $attachments,
        userPrompt: $userPrompt
    )
  2. With a custom message type and attachments:
    ConversationView<MyCustomMessage>(
        messages: $messages,
        attachments: $attachments,
        userPrompt: $userPrompt
    )
  3. With custom message rendering:
    ConversationView(
        messages: $messages,
        attachments: $attachments,
        userPrompt: $userPrompt
    ) { message in
        // Your custom message view
        CustomMessageView(message: message)
    }

Interrupting Generation (Stop Button)

When the user sends a message, ConversationView automatically tracks the execution of your onSendMessage block. During this time, the "Send" button is replaced by a "Stop" button.

To make the stop button work properly, your async code must be aware of Swift's cooperative cancellation. You can do this by using APIs that automatically throw CancellationError (like URLSession), or by checking manually during streaming operations:

.onSendMessage { message in
    messages.append(message)
    
    // Example: A custom async stream
    let stream = await myAIService.streamResponse(message)
    
    for try await chunk in stream {
        // This line is required for the "Stop" button to cancel the stream
        try Task.checkCancellation() 
        
        messages.last?.content?.append(chunk)
    }
}

Advanced Usage

Custom Message Rendering

For complete control over message appearance:

ConversationView(messages: $messages) { message in
    VStack {
        // Handle images
        if let imageURL = message.imageURL {
            AsyncImage(url: URL(string: imageURL)) { phase in
                if let image = phase.image {
                    image
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(maxWidth: 200, maxHeight: 400)
                } else if phase.error != nil {
                    Image(systemName: "icloud.slash")
                } else {
                    ProgressView()
                }
            }
            .cornerRadius(8.0)
        }
        
        // Handle text content
        if let content = message.content {
            HStack {
                if message.participant == .user {
                    Spacer()
                }
                Markdown(content)
                    .padding()
                    .background {
                        Color(uiColor: message.participant == .other
                              ? .secondarySystemBackground
                              : .systemGray4)
                    }
                    .roundedCorner(10, corners: .allCorners)
                if message.participant == .other {
                    Spacer()
                }
            }
        }
    }
}

Message Streaming & The "Push and Pin" UX

ConversationKit features a custom, physics-driven scrolling paradigm designed specifically for AI chatbots. When a user sends a short message, it rests naturally at the bottom above the composer. As the AI begins generating its response directly below, the expanding text smoothly pushes the user's message upward natively. The exact moment the user's message touches the top navigation bar, the ScrollView natively pins it in place, allowing the rest of the massive generated response to flow organically downwards out of view!

This relies entirely on SwiftUI's brilliant internal layout clamping, removing the need for fragile GeometryReader clutches, and ensures the user is never violently auto-scrolled away from the text they are reading.

Important Note: To deliver this butter-smooth scroll physics, ConversationKit utilizes an Optimistic UI state. It instantly adds the user's message to the layout internally the exact millisecond the Send button is tapped to perfectly sync with the keyboard dismissal animation. You still must append the user's message to your own messages array inside your onSendMessage closure (as documented below). The SDK automatically deduplicates your actual message against its optimistic placeholder.

Critical UUID Constraint: When mapping the user's message into your array, you must preserve the exact message.id provided to you in the .onSendMessage closure. If you create a new Message with a brand new UUID instead, the deduplication engine will fail and the message will momentarily jump or appear twice.

Support for real-time streaming responses is fully native:

func streamResponse() async {
    let responseText = "This is a streaming response that appears character by character."
    var message = DefaultMessage(content: "", participant: .other)
    messages.append(message)
    
    for character in responseText {
        message.content?.append(character)
        messages[messages.count - 1] = message
        try? await Task.sleep(for: .milliseconds(100))
    }
}

Attachment Actions

Add custom attachment functionality:

ConversationView(messages: $messages)
    .attachmentActions {
        Button(action: { /* handle photo selection */ }) {
            Label("Photos", systemImage: "photo.on.rectangle.angled")
        }
        Button(action: { /* handle camera */ }) {
            Label("Camera", systemImage: "camera")
        }
        Button(action: { /* handle documents */ }) {
            Label("Documents", systemImage: "doc")
        }
    }

Disable Attachments

You can disable the attachments button by applying the .disableAttachments() modifier to the ConversationView. This will hide the button in the MessageComposerView.

ConversationView(messages: $messages)
    .disableAttachments()

AI Integration Examples

Firebase AI Integration

import ConversationKit
import FirebaseAI

@Observable
class FirebaseAIChatViewModel {
    var messages: [DefaultMessage] = []
    private let model: GenerativeModel
    private let chat: Chat
    
    init() {
        let firstMessage = DefaultMessage(
            content: "Hello! How can I help you today?",
            participant: .other
        )
        self.messages = [firstMessage]
        
        model = FirebaseAI
            .firebaseAI(backend: .googleAI())
            .generativeModel(modelName: "gemini-2.5-flash")
        
        let history = [
            ModelContent(role: "model", parts: firstMessage.content ?? "")
        ]
        chat = model.startChat(history: history)
    }
    
    func sendMessage(_ message: any Message) async {
        if let defaultMessage = message as? DefaultMessage {
          messages.append(defaultMessage)
        }
        if let content = message.content {
            var responseText: String
            do {
                let response = try await chat.sendMessage(content)
                responseText = response.text ?? ""
            } catch {
                responseText = "I'm sorry, I don't understand that. Please try again. \(error.localizedDescription)"
            }
            let response = DefaultMessage(content: responseText, participant: .other)
            messages.append(response)
        }
    }
}

struct FirebaseAIChatView: View {
    @State private var viewModel = FirebaseAIChatViewModel()
    
    var body: some View {
        NavigationStack {
            ConversationView(messages: $viewModel.messages)
                .navigationTitle("AI Chat")
                .navigationBarTitleDisplayMode(.inline)
                .onSendMessage { message in
                    await viewModel.sendMessage(message)
                }
        }
    }
}

Foundation Models Integration

import ConversationKit
import FoundationModels

struct FoundationModelChatView: View {
    @State private var messages: [DefaultMessage] = [
        .init(content: "Hello! How can I help you today?", participant: .other)
    ]
    let session = LanguageModelSession()
    
    var body: some View {
        NavigationStack {
            ConversationView(messages: $messages)
                .navigationTitle("AI Chat")
                .navigationBarTitleDisplayMode(.inline)
                .onSendMessage { message in
                    if let defaultMessage = message as? DefaultMessage {
                      messages.append(defaultMessage)
                    }
                    if let content = message.content {
                        var responseText: String
                        do {
                            let response = try await session.respond(to: content)
                            responseText = response.content
                        } catch {
                            responseText = "I'm sorry, I don't understand that. Please try again. \(error.localizedDescription)"
                        }
                        let response = DefaultMessage(content: responseText, participant: .other)
                        messages.append(response)
                    }
                }
        }
    }
}

Error Handling

ConversationKit provides a robust mechanism for handling and displaying errors that may occur during asynchronous operations, such as fetching a response from an AI service.

Attaching Errors to Messages

The Message protocol includes an optional error property. You can create a message with an associated error and display it in the conversation history. MessageView will automatically render a default error UI if a message contains an error.

.onSendMessage { userMessage in
    do {
        let response = try await chatService.sendMessage(userMessage.content ?? "")
        await MainActor.run {
            messages.append(DefaultMessage(content: response, participant: .other))
        }
    } catch {
        await MainActor.run {
            messages.append(DefaultMessage(
                content: "Sorry, an error occurred.",
                participant: .other,
                error: error
            ))
        }
    }
}

Presenting Errors

To handle errors presented by ConversationKit views (for example, when a user taps the info button on a message with an error), use the .onError(perform:) view modifier. This modifier allows you to catch the error and present it using any standard SwiftUI presentation mechanism.

For convenience when using presentation modifiers like .sheet(item:), ConversationKit provides an ErrorWrapper struct that makes any Error identifiable.

struct MyChatView: View {
    @State private var messages: [DefaultMessage] = []
    @State private var errorWrapper: ErrorWrapper?

    var body: some View {
        ConversationView(messages: $messages)
            .onSendMessage { message in
                // ... async logic that might throw an error
            }
            .onError { error in
                errorWrapper = ErrorWrapper(error: error)
            }
            .sheet(item: $errorWrapper) { wrapper in
                NavigationStack {
                    VStack {
                        Text("An Error Occurred")
                            .font(.headline)
                            .padding()
                        Text(wrapper.error.localizedDescription)
                        Spacer()
                    }
                    .toolbar {
                        ToolbarItem(placement: .cancellationAction) {
                            Button("Dismiss") {
                                errorWrapper = nil
                            }
                            .labelStyle(.titleOnly)
                        }
                    }
                }
            }
    }
}

Message Types

Text Messages

DefaultMessage(content: "Hello, how are you?", participant: .user)

Image Messages

DefaultMessage(
    content: "Check out this image!",
    imageURL: "https://example.com/image.jpg",
    participant: .other
)

Image-Only Messages

DefaultMessage(
    imageURL: "https://example.com/image.jpg",
    participant: .user
)

Environment Values

ConversationKit provides several environment values for customization:

  • onSendMessageAction: Async closure for handling sent messages
  • onSubmitAction: Closure for handling message submission
  • disableAttachments: Boolean to disable attachment functionality
  • attachmentActions: Custom attachment menu actions
  • presentErrorAction: A closure to present an error to the user.

License

ConversationKit is licensed under the Apache License, Version 2.0. See the LICENSE file for more details.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

About

ConversationKit is a Swift package that provides an elegant and easy-to-use chat interface for iOS applications built with SwiftUI.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages