Skip to content

Response API stream support #148

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jun 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
7B029E3E2C69BEA70025681A /* ChatStructureOutputToolDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B029E3D2C69BEA70025681A /* ChatStructureOutputToolDemoView.swift */; };
7B1268052B08246400400694 /* AssistantConfigurationDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1268042B08246400400694 /* AssistantConfigurationDemoView.swift */; };
7B1268072B08247C00400694 /* AssistantConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1268062B08247C00400694 /* AssistantConfigurationProvider.swift */; };
7B2B6D562DF434670059B4BB /* ResponseStreamDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B2B6D552DF434670059B4BB /* ResponseStreamDemoView.swift */; };
7B2B6D582DF4347E0059B4BB /* ResponseStreamProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B2B6D572DF4347E0059B4BB /* ResponseStreamProvider.swift */; };
7B3DDCC52BAAA722004B5C96 /* AssistantsListDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B3DDCC42BAAA722004B5C96 /* AssistantsListDemoView.swift */; };
7B3DDCC72BAAAD34004B5C96 /* AssistantThreadConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B3DDCC62BAAAD34004B5C96 /* AssistantThreadConfigurationProvider.swift */; };
7B3DDCC92BAAAF96004B5C96 /* AssistantStreamDemoScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B3DDCC82BAAAF96004B5C96 /* AssistantStreamDemoScreen.swift */; };
Expand Down Expand Up @@ -99,6 +101,8 @@
7B029E3D2C69BEA70025681A /* ChatStructureOutputToolDemoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStructureOutputToolDemoView.swift; sourceTree = "<group>"; };
7B1268042B08246400400694 /* AssistantConfigurationDemoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantConfigurationDemoView.swift; sourceTree = "<group>"; };
7B1268062B08247C00400694 /* AssistantConfigurationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantConfigurationProvider.swift; sourceTree = "<group>"; };
7B2B6D552DF434670059B4BB /* ResponseStreamDemoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseStreamDemoView.swift; sourceTree = "<group>"; };
7B2B6D572DF4347E0059B4BB /* ResponseStreamProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseStreamProvider.swift; sourceTree = "<group>"; };
7B3DDCC42BAAA722004B5C96 /* AssistantsListDemoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantsListDemoView.swift; sourceTree = "<group>"; };
7B3DDCC62BAAAD34004B5C96 /* AssistantThreadConfigurationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantThreadConfigurationProvider.swift; sourceTree = "<group>"; };
7B3DDCC82BAAAF96004B5C96 /* AssistantStreamDemoScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantStreamDemoScreen.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -216,6 +220,15 @@
path = Assistants;
sourceTree = "<group>";
};
7B2B6D542DF434550059B4BB /* ResponseAPIDemo */ = {
isa = PBXGroup;
children = (
7B2B6D552DF434670059B4BB /* ResponseStreamDemoView.swift */,
7B2B6D572DF4347E0059B4BB /* ResponseStreamProvider.swift */,
);
path = ResponseAPIDemo;
sourceTree = "<group>";
};
7B436B972AE25045003CE281 /* Utilities */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -379,6 +392,7 @@
7BA788CB2AE23A48008825D5 /* SwiftOpenAIExample */ = {
isa = PBXGroup;
children = (
7B2B6D542DF434550059B4BB /* ResponseAPIDemo */,
7BA788CC2AE23A48008825D5 /* SwiftOpenAIExampleApp.swift */,
7BE802572D2877D30080E06A /* PredictedOutputsDemo */,
7B50DD292C2A9D1D0070A64D /* LocalChatDemo */,
Expand Down Expand Up @@ -680,6 +694,8 @@
7B436B992AE25052003CE281 /* ContentLoader.swift in Sources */,
7B436BC12AE7B01F003CE281 /* ModerationProvider.swift in Sources */,
7B436BBC2AE7ABD3003CE281 /* ModelsProvider.swift in Sources */,
7B2B6D562DF434670059B4BB /* ResponseStreamDemoView.swift in Sources */,
7B2B6D582DF4347E0059B4BB /* ResponseStreamProvider.swift in Sources */,
7B436BA62AE77F37003CE281 /* Embeddingsprovider.swift in Sources */,
7BBE7EA72B02E8AC0096A693 /* ThemeColor.swift in Sources */,
7BA788FE2AE23B95008825D5 /* AudioProvider.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ struct OptionsListView: View {
case chatStructuredOutputTool = "Chat Structured Output Tools"
case configureAssistant = "Configure Assistant"
case realTimeAPI = "Real time API"
case responseStream = "Response Stream Demo"

var id: String { rawValue }
}
Expand Down Expand Up @@ -84,6 +85,8 @@ struct OptionsListView: View {
AssistantConfigurationDemoView(service: openAIService)
case .realTimeAPI:
Text("WIP")
case .responseStream:
ResponseStreamDemoView(service: openAIService)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
//
// ResponseStreamDemoView.swift
// SwiftOpenAIExample
//
// Created by James Rochabrun on 6/7/25.
//

import SwiftOpenAI
import SwiftUI

// MARK: - ResponseStreamDemoView

struct ResponseStreamDemoView: View {

init(service: OpenAIService) {
_provider = State(initialValue: ResponseStreamProvider(service: service))
}

@Environment(\.colorScheme) var colorScheme

var body: some View {
VStack(spacing: 0) {
// Header
headerView

// Messages
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
ForEach(provider.messages) { message in
MessageBubbleView(message: message)
.id(message.id)
}

if provider.isStreaming {
HStack {
LoadingIndicatorView()
.frame(width: 30, height: 30)
Spacer()
}
.padding(.horizontal)
}
}
.padding()
}
.onChange(of: provider.messages.count) { _, _ in
withAnimation {
proxy.scrollTo(provider.messages.last?.id, anchor: .bottom)
}
}
}

// Error view
if let error = provider.error {
Text(error)
.foregroundColor(.red)
.font(.caption)
.padding(.horizontal)
.padding(.vertical, 8)
.background(Color.red.opacity(0.1))
}

// Input area
inputArea
}
.navigationTitle("Response Stream Demo")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Clear") {
provider.clearConversation()
}
.disabled(provider.isStreaming)
}
}
}

@State private var provider: ResponseStreamProvider
@State private var inputText = ""
@FocusState private var isInputFocused: Bool

// MARK: - Subviews

private var headerView: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Streaming Responses with Conversation State")
.font(.headline)

Text("This demo uses the Responses API with streaming to maintain conversation context across multiple turns.")
.font(.caption)
.foregroundColor(.secondary)

if provider.messages.isEmpty {
Label("Start a conversation below", systemImage: "bubble.left.and.bubble.right")
.font(.caption)
.foregroundColor(.blue)
.padding(.top, 4)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color(UIColor.secondarySystemBackground))
}

private var inputArea: some View {
HStack(spacing: 12) {
TextField("Type a message...", text: $inputText, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...5)
.focused($isInputFocused)
.disabled(provider.isStreaming)
.onSubmit {
sendMessage()
}

Button(action: sendMessage) {
Image(systemName: provider.isStreaming ? "stop.circle.fill" : "arrow.up.circle.fill")
.font(.title2)
.foregroundColor(provider.isStreaming ? .red : (inputText.isEmpty ? .gray : .blue))
}
.disabled(!provider.isStreaming && inputText.isEmpty)
}
.padding()
.background(Color(UIColor.systemBackground))
.overlay(
Rectangle()
.frame(height: 1)
.foregroundColor(Color(UIColor.separator)),
alignment: .top)
}

private func sendMessage() {
guard !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }

if provider.isStreaming {
provider.stopStreaming()
} else {
let message = inputText
inputText = ""
provider.sendMessage(message)
}
}
}

// MARK: - MessageBubbleView

struct MessageBubbleView: View {
let message: ResponseStreamProvider.ResponseMessage
@Environment(\.colorScheme) var colorScheme

var body: some View {
HStack {
if message.role == .assistant {
messageContent
.background(backgroundGradient)
.cornerRadius(16)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(borderColor, lineWidth: 1))
Spacer(minLength: 60)
} else {
Spacer(minLength: 60)
messageContent
.background(Color.blue)
.cornerRadius(16)
.foregroundColor(.white)
}
}
}

private var messageContent: some View {
VStack(alignment: .leading, spacing: 4) {
if message.role == .assistant, message.isStreaming {
HStack(spacing: 4) {
Image(systemName: "dot.radiowaves.left.and.right")
.font(.caption2)
.foregroundColor(.blue)
Text("Streaming...")
.font(.caption2)
.foregroundColor(.secondary)
}
}

Text(message.content.isEmpty && message.isStreaming ? " " : message.content)
.padding(.horizontal, 12)
.padding(.vertical, 8)

if message.role == .assistant, !message.isStreaming, message.responseId != nil {
Text("Response ID: \(String(message.responseId?.prefix(8) ?? ""))")
.font(.caption2)
.foregroundColor(.secondary)
.padding(.horizontal, 12)
.padding(.bottom, 4)
}
}
}

private var backgroundGradient: some View {
LinearGradient(
gradient: Gradient(colors: [
Color(UIColor.secondarySystemBackground),
Color(UIColor.tertiarySystemBackground),
]),
startPoint: .topLeading,
endPoint: .bottomTrailing)
}

private var borderColor: Color {
colorScheme == .dark ? Color.white.opacity(0.1) : Color.black.opacity(0.1)
}
}

// MARK: - LoadingIndicatorView

struct LoadingIndicatorView: View {
var body: some View {
ZStack {
ForEach(0..<3) { index in
Circle()
.fill(Color.blue)
.frame(width: 8, height: 8)
.offset(x: CGFloat(index - 1) * 12)
.opacity(0.8)
.scaleEffect(animationScale(for: index))
}
}
.onAppear {
withAnimation(
.easeInOut(duration: 0.8)
.repeatForever(autoreverses: true))
{
animationAmount = 1
}
}
}

@State private var animationAmount = 0.0

private func animationScale(for index: Int) -> Double {
let delay = Double(index) * 0.1
let progress = (animationAmount + delay).truncatingRemainder(dividingBy: 1.0)
return 0.5 + (0.5 * sin(progress * .pi))
}
}

// MARK: - Preview

#Preview {
NavigationView {
ResponseStreamDemoView(service: OpenAIServiceFactory.service(apiKey: "test"))
}
}
Loading