{"id":29875734,"url":"https://github.com/peterfriese/conversationkit","last_synced_at":"2026-04-09T15:03:07.737Z","repository":{"id":307374357,"uuid":"966297002","full_name":"peterfriese/ConversationKit","owner":"peterfriese","description":"ConversationKit is a Swift package that provides an elegant and easy-to-use chat interface for iOS applications built with SwiftUI.","archived":false,"fork":false,"pushed_at":"2025-07-30T23:34:31.000Z","size":66,"stargazers_count":5,"open_issues_count":0,"forks_count":3,"subscribers_count":2,"default_branch":"main","last_synced_at":"2025-07-31T00:18:56.700Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Swift","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/peterfriese.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2025-04-14T17:51:41.000Z","updated_at":"2025-07-30T23:34:35.000Z","dependencies_parsed_at":"2025-07-31T00:29:15.167Z","dependency_job_id":null,"html_url":"https://github.com/peterfriese/ConversationKit","commit_stats":null,"previous_names":["peterfriese/conversationkit"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/peterfriese/ConversationKit","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/peterfriese%2FConversationKit","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/peterfriese%2FConversationKit/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/peterfriese%2FConversationKit/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/peterfriese%2FConversationKit/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/peterfriese","download_url":"https://codeload.github.com/peterfriese/ConversationKit/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/peterfriese%2FConversationKit/sbom","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":267978329,"owners_count":24175254,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-07-31T02:00:08.723Z","response_time":66,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2025-07-31T02:42:56.293Z","updated_at":"2026-04-09T15:03:07.729Z","avatar_url":"https://github.com/peterfriese.png","language":"Swift","funding_links":[],"categories":[],"sub_categories":[],"readme":"# ConversationKit\n\nConversationKit 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.\n\n## Features\n\n- 💬 **Ready-to-use chat interface** with built-in message bubbles\n- 👤 **Multi-participant support** (user vs other)\n- 🖼️ **Image message support** with async loading\n- 📝 **Markdown rendering** for rich text messages\n- ⚡️ **Async/await support** for message handling\n- 🎨 **Customizable message rendering** with custom content closures\n- 📱 **Modern iOS design** with glass effects (iOS 17+)\n- 🔄 **Real-time message streaming** support\n- 📎 **Attachment actions** with customizable menu\n- 🎯 **Gemini-style \"Push and Pin\" scrolling** (native SwiftUI clamping logic)\n- ⚙️ **Progressive disclosure APIs** for custom actions and disclaimers\n- 🛑 **Interruptible generation** (built-in stop button support)\n\n## Requirements\n\n- iOS 17.0+\n- Swift 5.10+\n- Xcode 15.0+\n\n## Installation\n\nAdd ConversationKit to your project using Swift Package Manager:\n\n```swift\ndependencies: [\n    .package(url: \"https://github.com/peterfriese/ConversationKit\", from: \"1.0.0\")\n]\n```\n\n## Quick Start\n\n### Basic Usage\n\n```swift\nimport SwiftUI\nimport ConversationKit\n\nstruct ChatView: View {\n    @State private var messages: [DefaultMessage] = []\n    \n    var body: some View {\n        NavigationStack {\n            ConversationView(messages: $messages)\n                .onSendMessage { userMessage in\n                    // Handle the sent message asynchronously\n                    await processMessage(userMessage)\n                }\n                .navigationTitle(\"Chat\")\n                .navigationBarTitleDisplayMode(.inline)\n        }\n    }\n    \n    func processMessage(_ message: any Message) async {\n        // Append the user's message to the messages array\n        if let defaultMessage = message as? DefaultMessage {\n          messages.append(defaultMessage)\n        }\n        // Simulate async response\n        try? await Task.sleep(for: .seconds(1))\n        await MainActor.run {\n            messages.append(DefaultMessage(\n                content: \"You said: \\(message.content ?? \"\")\",\n                participant: .other\n            ))\n        }\n    }\n}\n```\n\n### With Initial Messages\n\n```swift\n@State private var messages: [DefaultMessage] = [\n    .init(content: \"Hello! How can I help you today?\", participant: .other),\n    .init(content: \"I'm doing great, thanks!\", participant: .user),\n    .init(content: \"That's wonderful to hear!\", participant: .other)\n]\n```\n\n## Core Components\n\n### The `Message` protocol\n\nThe 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.\n\n```swift\npublic protocol Message: Identifiable, Hashable {\n  var content: String? { get set }\n  var imageURL: String? { get }\n  var participant: Participant { get }\n  var error: (any Error)? { get }\n\n  init(content: String?, imageURL: String?, participant: Participant)\n}\n\npublic enum Participant {\n    case other\n    case user\n}\n```\n\nConversationKit provides a default implementation of this protocol, `DefaultMessage`.\n\n### ConversationView\n\nThe main chat interface. It can be initialized in a few ways:\n\n1.  **With `DefaultMessage` and attachments**:\n    ```swift\n    ConversationView(\n        messages: $messages,\n        attachments: $attachments,\n        userPrompt: $userPrompt\n    )\n    ```\n2.  **With a custom message type and attachments**:\n    ```swift\n    ConversationView\u003cMyCustomMessage\u003e(\n        messages: $messages,\n        attachments: $attachments,\n        userPrompt: $userPrompt\n    )\n    ```\n3.  **With custom message rendering**:\n    ```swift\n    ConversationView(\n        messages: $messages,\n        attachments: $attachments,\n        userPrompt: $userPrompt\n    ) { message in\n        // Your custom message view\n        CustomMessageView(message: message)\n    }\n    ```\n\n### Interrupting Generation (Stop Button)\n\nWhen 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.\n\nTo 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:\n\n```swift\n.onSendMessage { message in\n    messages.append(message)\n    \n    // Example: A custom async stream\n    let stream = await myAIService.streamResponse(message)\n    \n    for try await chunk in stream {\n        // This line is required for the \"Stop\" button to cancel the stream\n        try Task.checkCancellation() \n        \n        messages.last?.content?.append(chunk)\n    }\n}\n```\n\n## Advanced Usage\n\n### Custom Message Rendering\n\nFor complete control over message appearance:\n\n```swift\nConversationView(messages: $messages) { message in\n    VStack {\n        // Handle images\n        if let imageURL = message.imageURL {\n            AsyncImage(url: URL(string: imageURL)) { phase in\n                if let image = phase.image {\n                    image\n                        .resizable()\n                        .aspectRatio(contentMode: .fill)\n                        .frame(maxWidth: 200, maxHeight: 400)\n                } else if phase.error != nil {\n                    Image(systemName: \"icloud.slash\")\n                } else {\n                    ProgressView()\n                }\n            }\n            .cornerRadius(8.0)\n        }\n        \n        // Handle text content\n        if let content = message.content {\n            HStack {\n                if message.participant == .user {\n                    Spacer()\n                }\n                Markdown(content)\n                    .padding()\n                    .background {\n                        Color(uiColor: message.participant == .other\n                              ? .secondarySystemBackground\n                              : .systemGray4)\n                    }\n                    .roundedCorner(10, corners: .allCorners)\n                if message.participant == .other {\n                    Spacer()\n                }\n            }\n        }\n    }\n}\n```\n\n### Message Streaming \u0026 The \"Push and Pin\" UX\n\nConversationKit 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!\n\nThis 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.\n\n**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.\n\u003e **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.\n\nSupport for real-time streaming responses is fully native:\n\n```swift\nfunc streamResponse() async {\n    let responseText = \"This is a streaming response that appears character by character.\"\n    var message = DefaultMessage(content: \"\", participant: .other)\n    messages.append(message)\n    \n    for character in responseText {\n        message.content?.append(character)\n        messages[messages.count - 1] = message\n        try? await Task.sleep(for: .milliseconds(100))\n    }\n}\n```\n\n### Attachment Actions\n\nAdd custom attachment functionality:\n\n```swift\nConversationView(messages: $messages)\n    .attachmentActions {\n        Button(action: { /* handle photo selection */ }) {\n            Label(\"Photos\", systemImage: \"photo.on.rectangle.angled\")\n        }\n        Button(action: { /* handle camera */ }) {\n            Label(\"Camera\", systemImage: \"camera\")\n        }\n        Button(action: { /* handle documents */ }) {\n            Label(\"Documents\", systemImage: \"doc\")\n        }\n    }\n```\n\n### Disable Attachments\n\nYou can disable the attachments button by applying the `.disableAttachments()` modifier to the `ConversationView`. This will hide the button in the `MessageComposerView`.\n\n```swift\nConversationView(messages: $messages)\n    .disableAttachments()\n```\n\n## AI Integration Examples\n\n### Firebase AI Integration\n\n```swift\nimport ConversationKit\nimport FirebaseAI\n\n@Observable\nclass FirebaseAIChatViewModel {\n    var messages: [DefaultMessage] = []\n    private let model: GenerativeModel\n    private let chat: Chat\n    \n    init() {\n        let firstMessage = DefaultMessage(\n            content: \"Hello! How can I help you today?\",\n            participant: .other\n        )\n        self.messages = [firstMessage]\n        \n        model = FirebaseAI\n            .firebaseAI(backend: .googleAI())\n            .generativeModel(modelName: \"gemini-2.5-flash\")\n        \n        let history = [\n            ModelContent(role: \"model\", parts: firstMessage.content ?? \"\")\n        ]\n        chat = model.startChat(history: history)\n    }\n    \n    func sendMessage(_ message: any Message) async {\n        if let defaultMessage = message as? DefaultMessage {\n          messages.append(defaultMessage)\n        }\n        if let content = message.content {\n            var responseText: String\n            do {\n                let response = try await chat.sendMessage(content)\n                responseText = response.text ?? \"\"\n            } catch {\n                responseText = \"I'm sorry, I don't understand that. Please try again. \\(error.localizedDescription)\"\n            }\n            let response = DefaultMessage(content: responseText, participant: .other)\n            messages.append(response)\n        }\n    }\n}\n\nstruct FirebaseAIChatView: View {\n    @State private var viewModel = FirebaseAIChatViewModel()\n    \n    var body: some View {\n        NavigationStack {\n            ConversationView(messages: $viewModel.messages)\n                .navigationTitle(\"AI Chat\")\n                .navigationBarTitleDisplayMode(.inline)\n                .onSendMessage { message in\n                    await viewModel.sendMessage(message)\n                }\n        }\n    }\n}\n```\n\n### Foundation Models Integration\n\n```swift\nimport ConversationKit\nimport FoundationModels\n\nstruct FoundationModelChatView: View {\n    @State private var messages: [DefaultMessage] = [\n        .init(content: \"Hello! How can I help you today?\", participant: .other)\n    ]\n    let session = LanguageModelSession()\n    \n    var body: some View {\n        NavigationStack {\n            ConversationView(messages: $messages)\n                .navigationTitle(\"AI Chat\")\n                .navigationBarTitleDisplayMode(.inline)\n                .onSendMessage { message in\n                    if let defaultMessage = message as? DefaultMessage {\n                      messages.append(defaultMessage)\n                    }\n                    if let content = message.content {\n                        var responseText: String\n                        do {\n                            let response = try await session.respond(to: content)\n                            responseText = response.content\n                        } catch {\n                            responseText = \"I'm sorry, I don't understand that. Please try again. \\(error.localizedDescription)\"\n                        }\n                        let response = DefaultMessage(content: responseText, participant: .other)\n                        messages.append(response)\n                    }\n                }\n        }\n    }\n}\n```\n\n## Error Handling\n\n`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.\n\n### Attaching Errors to Messages\n\nThe `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.\n\n```swift\n.onSendMessage { userMessage in\n    do {\n        let response = try await chatService.sendMessage(userMessage.content ?? \"\")\n        await MainActor.run {\n            messages.append(DefaultMessage(content: response, participant: .other))\n        }\n    } catch {\n        await MainActor.run {\n            messages.append(DefaultMessage(\n                content: \"Sorry, an error occurred.\",\n                participant: .other,\n                error: error\n            ))\n        }\n    }\n}\n```\n\n### Presenting Errors\n\nTo 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.\n\nFor convenience when using presentation modifiers like `.sheet(item:)`, `ConversationKit` provides an `ErrorWrapper` struct that makes any `Error` identifiable.\n\n```swift\nstruct MyChatView: View {\n    @State private var messages: [DefaultMessage] = []\n    @State private var errorWrapper: ErrorWrapper?\n\n    var body: some View {\n        ConversationView(messages: $messages)\n            .onSendMessage { message in\n                // ... async logic that might throw an error\n            }\n            .onError { error in\n                errorWrapper = ErrorWrapper(error: error)\n            }\n            .sheet(item: $errorWrapper) { wrapper in\n                NavigationStack {\n                    VStack {\n                        Text(\"An Error Occurred\")\n                            .font(.headline)\n                            .padding()\n                        Text(wrapper.error.localizedDescription)\n                        Spacer()\n                    }\n                    .toolbar {\n                        ToolbarItem(placement: .cancellationAction) {\n                            Button(\"Dismiss\") {\n                                errorWrapper = nil\n                            }\n                            .labelStyle(.titleOnly)\n                        }\n                    }\n                }\n            }\n    }\n}\n```\n\n## Message Types\n\n### Text Messages\n\n```swift\nDefaultMessage(content: \"Hello, how are you?\", participant: .user)\n```\n\n### Image Messages\n\n```swift\nDefaultMessage(\n    content: \"Check out this image!\",\n    imageURL: \"https://example.com/image.jpg\",\n    participant: .other\n)\n```\n\n### Image-Only Messages\n\n```swift\nDefaultMessage(\n    imageURL: \"https://example.com/image.jpg\",\n    participant: .user\n)\n```\n\n## Environment Values\n\nConversationKit provides several environment values for customization:\n\n- `onSendMessageAction`: Async closure for handling sent messages\n- `onSubmitAction`: Closure for handling message submission\n- `disableAttachments`: Boolean to disable attachment functionality\n- `attachmentActions`: Custom attachment menu actions\n- `presentErrorAction`: A closure to present an error to the user.\n\n## License\n\nConversationKit is licensed under the Apache License, Version 2.0. See the [LICENSE](LICENSE) file for more details.\n\n## Contributing\n\nContributions are welcome! Please feel free to submit a Pull Request.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpeterfriese%2Fconversationkit","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpeterfriese%2Fconversationkit","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpeterfriese%2Fconversationkit/lists"}