Skip to content
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