Skip to content

Latest commit

 

History

History

README.md

01-Hello (UIKit)

The smallest possible chat in UIKit + Storyboard — initialize the SDK, render messages in a table, send one. The UIKit twin of Examples/SwiftUI/01-Hello/.

  • Interface: Storyboard (Main.storyboard)
  • Lifecycle: AppDelegate (@main) + SceneDelegate (scene-based, generated by Xcode)

Run it

open HelloUIKit.xcodeproj   # from this folder
# Cmd+R on an iPhone simulator

Set your connector token in AppDelegate.swift (currently "YOUR_CONNECTOR_TOKEN").

What this example demonstrates

  • PolyMessaging.initialize(_:) in application(_:didFinishLaunchingWithOptions:)
  • PolyMessaging.chat() for a session, bound to a UITableViewDiffableDataSource via Combine
  • Re-render on every session.$messages emission
  • Auto-scroll as the agent's reply streams in (dataSource.apply completion + scrollToRow)
  • Send with try? await session.send(text)
  • Surface terminal failures (invalid token) via a UIAlertController driven by session.$failureReason

The SDK invariants behind each pattern are in the root README's Integration guide; this example shows them as one concrete UIViewController.

How it works

Each subsection leads with the SDK call (one line — the actual API), then shows how it's wired into a view controller.

Initialize once at app launch — AppDelegate.swift

Configure the SDK once at launch:

PolyMessaging.initialize(.init(
    apiKey: "YOUR_CONNECTOR_TOKEN"  // from Agent Studio → Connector Settings
    // environment defaults to .us — add .uk / .euw / .cluster("dev") / .custom(...) only if needed
))

In AppDelegate:

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        PolyMessaging.initialize(.init(
            apiKey: "YOUR_CONNECTOR_TOKEN"
        ))
        return true
    }
}

After this, PolyMessaging.chat() works from any view controller with no arguments.

Under the hood: initialize just stashes your connector token and environment process-wide — no network happens yet. The work starts when you call chat().

See Quick start.

Get a session and render messages — ChatViewController.swift

Create a session + subscribe to its messages:

let session = PolyMessaging.chat()    // Resume the previous conversation if one exists within the
                                      // session timeout (default 10 min), else start a fresh one.
                                      // — use `start()` instead to always start fresh.

session.$messages                     // Combine publisher of [ChatMessage] — the whole transcript. Cases:
                                      //   .user(UserMessage) / .agent(AgentMessage) / .system(SystemMessage)

session.isReady                       // Bool — false until WebSocket + agent-join complete

In a view controller:

final class ChatViewController: UIViewController {
    // tableView, inputField, sendButton are @IBOutlets wired in Main.storyboard.
    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var inputField: UITextField!
    @IBOutlet weak var sendButton: UIButton!

    private var session: ChatSession!
    private var bag = Set<AnyCancellable>()
    private var dataSource: UITableViewDiffableDataSource<Int, UUID>!

    override func viewDidLoad() {
        super.viewDidLoad()
        session = PolyMessaging.chat()
        configureDataSource()
        bind()
    }

    private func bind() {
        session.$messages
            .receive(on: RunLoop.main)
            .sink { [weak self] messages in self?.render(messages) }
            .store(in: &bag)
    }
}

Streaming is on by defaultConfiguration.streamingEnabled defaults to true, so agent replies grow token-by-token (ChatGPT-style). render(_:) (below) calls reconfigureItems on existing IDs so the cell re-renders as the agent message's text grows. To switch to complete-message bubbles instead, set streamingEnabled: false in AppDelegate.swift. See the root README's Streaming section.

Under the hood: chat() runs the whole REST + WebSocket handshake, agent-join, and resume-or-create for you; isReady flips true once it's connected. messages is the SDK-maintained transcript (.user / .agent / .system) that republishes on every change, so each .sink just hands you the full list to render.

See Integration guide › The core pattern.

Scroll as the agent types — ChatViewController.swift

Signal that triggers an auto-scroll:

session.$messages    // Combine publisher of [ChatMessage] — every emission triggers scroll:
                     //   inserts (new bubble), reconfigureItems (last bubble's text grew during streaming)

In a view controller:

private func render(_ messages: [ChatMessage]) {
    var snapshot = NSDiffableDataSourceSnapshot<Int, UUID>()
    snapshot.appendSections([0])
    let ids = messages.map(\.id)
    snapshot.appendItems(ids)
    let existing = dataSource.snapshot().itemIdentifiers
    let toReconfigure = ids.filter { existing.contains($0) }
    if !toReconfigure.isEmpty {
        snapshot.reconfigureItems(toReconfigure)
    }
    // Completion fires for every apply — including the reconfigureItems-only
    // case that streaming hits — so the table follows the growing bubble.
    dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in
        self?.scrollToBottom(animated: true)
    }
}

private func scrollToBottom(animated: Bool) {
    // layoutIfNeeded lets the just-reconfigured cell expand to its new
    // height before we ask for the bottom row's position.
    tableView.layoutIfNeeded()
    let count = tableView.numberOfRows(inSection: 0)
    guard count > 0 else { return }
    tableView.scrollToRow(at: IndexPath(row: count - 1, section: 0),
                          at: .bottom, animated: animated)
}

Streaming grows the last agent message's text in place. The diffable snapshot uses the same UUIDs but the new text — so dataSource.apply calls the completion handler, and reconfigureItems re-runs the cell provider with the longer text.

Under the hood: with streamingEnabled: true (the default), ChatSession extends the last .agent message's text on every chunk and re-publishes messages. The Combine sink calls render(_:), which builds the same-IDs snapshot plus a reconfigureItems pass — the apply's completion always fires, so the table tracks the reply as it grows.

See Integration guide › Streaming.

Send a message — ChatViewController.swift

Send a user message (optimistic):

try? await session.send(text)   // throws PolyError; the bubble appears in `messages`
                                // immediately as .pending, then settles into .sent or .failed

In a view controller:

@IBAction func sendTapped(_ sender: Any) {
    guard let text = inputField.text, !text.isEmpty else { return }
    inputField.text = ""
    Task { try? await session.send(text) }
}

sendTapped(_:) is hooked to the send button's Touch Up Inside in Main.storyboard.

Under the hood: send(text) is optimistic — the bubble appears in messages immediately while the SDK manages delivery and the server echo behind the scenes. ChatSession is @MainActor, so call it from the main thread.

See Integration guide › The core pattern.

Catch a bad connector token — ChatViewController.swift

Detect a terminal failure + offer retry:

session.$failureReason  // Combine publisher of PolyError? — non-nil when the chat can't auto-recover:
                        //   invalid apiKey (initial connect 401/403),
                        //   reconnect budget exhausted,
                        //   session expired (idle past sessionTimeoutSeconds, default 10 min)

try await session.client.resume()   // manually re-attempt the connection

In a view controller:

private var isPresentingFailureAlert = false

private func bind() {
    // ...messages sink (above)...

    session.$failureReason
        .receive(on: RunLoop.main)
        .compactMap { $0 }
        .sink { [weak self] reason in self?.presentFailureAlert(reason: reason) }
        .store(in: &bag)
}

private func presentFailureAlert(reason: PolyError) {
    guard !isPresentingFailureAlert, presentedViewController == nil else { return }
    isPresentingFailureAlert = true
    let alert = UIAlertController(
        title: "Couldn't connect",
        message: String(describing: reason),
        preferredStyle: .alert
    )
    alert.addAction(UIAlertAction(title: "Try Again", style: .default) { [weak self] _ in
        self?.isPresentingFailureAlert = false
        Task { try? await self?.session.client.resume() }
    })
    present(alert, animated: true)
}

String(describing:) is intentional — PolyError doesn't conform to LocalizedError, so .localizedDescription is the generic "The operation couldn't be completed". String(describing:) gives the case name (auth(unauthorized)) which is far more useful.

Under the hood: failureReason is fed by both client.connectionStatus.failed (reconnect budget exhausted, session expired) and the initial-connect path that catches an unauthorized REST response and flags sessionState.hasInvalidApiKey. Either way you get a single source of truth for "the chat can't recover from this".

See Integration guide › Terminal errors.

Storyboard note

Main.storyboard hard-codes customModule="HelloUIKit" on the view controller. If you rename the Xcode target, open Main.storyboard in Interface Builder, select the View Controller, and update the Module field in the Identity Inspector to match — or set it to None to let UIKit resolve the class from any module.

What this example skips