Skip to content

Commit 9827a40

Browse files
Adding demo
1 parent 16d5f18 commit 9827a40

File tree

4 files changed

+508
-12
lines changed

4 files changed

+508
-12
lines changed

Examples/SwiftOpenAIExample/SwiftOpenAIExample.xcodeproj/project.pbxproj

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
7B029E3E2C69BEA70025681A /* ChatStructureOutputToolDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B029E3D2C69BEA70025681A /* ChatStructureOutputToolDemoView.swift */; };
1717
7B1268052B08246400400694 /* AssistantConfigurationDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1268042B08246400400694 /* AssistantConfigurationDemoView.swift */; };
1818
7B1268072B08247C00400694 /* AssistantConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1268062B08247C00400694 /* AssistantConfigurationProvider.swift */; };
19+
7B2B6D562DF434670059B4BB /* ResponseStreamDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B2B6D552DF434670059B4BB /* ResponseStreamDemoView.swift */; };
20+
7B2B6D582DF4347E0059B4BB /* ResponseStreamProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B2B6D572DF4347E0059B4BB /* ResponseStreamProvider.swift */; };
1921
7B3DDCC52BAAA722004B5C96 /* AssistantsListDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B3DDCC42BAAA722004B5C96 /* AssistantsListDemoView.swift */; };
2022
7B3DDCC72BAAAD34004B5C96 /* AssistantThreadConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B3DDCC62BAAAD34004B5C96 /* AssistantThreadConfigurationProvider.swift */; };
2123
7B3DDCC92BAAAF96004B5C96 /* AssistantStreamDemoScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B3DDCC82BAAAF96004B5C96 /* AssistantStreamDemoScreen.swift */; };
@@ -99,6 +101,8 @@
99101
7B029E3D2C69BEA70025681A /* ChatStructureOutputToolDemoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStructureOutputToolDemoView.swift; sourceTree = "<group>"; };
100102
7B1268042B08246400400694 /* AssistantConfigurationDemoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantConfigurationDemoView.swift; sourceTree = "<group>"; };
101103
7B1268062B08247C00400694 /* AssistantConfigurationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantConfigurationProvider.swift; sourceTree = "<group>"; };
104+
7B2B6D552DF434670059B4BB /* ResponseStreamDemoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseStreamDemoView.swift; sourceTree = "<group>"; };
105+
7B2B6D572DF4347E0059B4BB /* ResponseStreamProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseStreamProvider.swift; sourceTree = "<group>"; };
102106
7B3DDCC42BAAA722004B5C96 /* AssistantsListDemoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantsListDemoView.swift; sourceTree = "<group>"; };
103107
7B3DDCC62BAAAD34004B5C96 /* AssistantThreadConfigurationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantThreadConfigurationProvider.swift; sourceTree = "<group>"; };
104108
7B3DDCC82BAAAF96004B5C96 /* AssistantStreamDemoScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantStreamDemoScreen.swift; sourceTree = "<group>"; };
@@ -216,6 +220,15 @@
216220
path = Assistants;
217221
sourceTree = "<group>";
218222
};
223+
7B2B6D542DF434550059B4BB /* ResponseAPIDemo */ = {
224+
isa = PBXGroup;
225+
children = (
226+
7B2B6D552DF434670059B4BB /* ResponseStreamDemoView.swift */,
227+
7B2B6D572DF4347E0059B4BB /* ResponseStreamProvider.swift */,
228+
);
229+
path = ResponseAPIDemo;
230+
sourceTree = "<group>";
231+
};
219232
7B436B972AE25045003CE281 /* Utilities */ = {
220233
isa = PBXGroup;
221234
children = (
@@ -379,6 +392,7 @@
379392
7BA788CB2AE23A48008825D5 /* SwiftOpenAIExample */ = {
380393
isa = PBXGroup;
381394
children = (
395+
7B2B6D542DF434550059B4BB /* ResponseAPIDemo */,
382396
7BA788CC2AE23A48008825D5 /* SwiftOpenAIExampleApp.swift */,
383397
7BE802572D2877D30080E06A /* PredictedOutputsDemo */,
384398
7B50DD292C2A9D1D0070A64D /* LocalChatDemo */,
@@ -680,6 +694,8 @@
680694
7B436B992AE25052003CE281 /* ContentLoader.swift in Sources */,
681695
7B436BC12AE7B01F003CE281 /* ModerationProvider.swift in Sources */,
682696
7B436BBC2AE7ABD3003CE281 /* ModelsProvider.swift in Sources */,
697+
7B2B6D562DF434670059B4BB /* ResponseStreamDemoView.swift in Sources */,
698+
7B2B6D582DF4347E0059B4BB /* ResponseStreamProvider.swift in Sources */,
683699
7B436BA62AE77F37003CE281 /* Embeddingsprovider.swift in Sources */,
684700
7BBE7EA72B02E8AC0096A693 /* ThemeColor.swift in Sources */,
685701
7BA788FE2AE23B95008825D5 /* AudioProvider.swift in Sources */,

Examples/SwiftOpenAIExample/SwiftOpenAIExample/OptionsListView.swift

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import SwiftOpenAI
99
import SwiftUI
1010

1111
struct OptionsListView: View {
12-
12+
1313
/// https://platform.openai.com/docs/api-reference
1414
enum APIOption: String, CaseIterable, Identifiable {
1515
case audio = "Audio"
@@ -30,15 +30,15 @@ struct OptionsListView: View {
3030
case chatStructuredOutputTool = "Chat Structured Output Tools"
3131
case configureAssistant = "Configure Assistant"
3232
case realTimeAPI = "Real time API"
33-
case responseStream = "Response Stream Demo" // TODO: Add ResponseStreamDemo files to Xcode project
34-
33+
case responseStream = "Response Stream Demo"
34+
3535
var id: String { rawValue }
3636
}
37-
37+
3838
var openAIService: OpenAIService
39-
39+
4040
var options: [APIOption]
41-
41+
4242
var body: some View {
4343
List(options, id: \.self, selection: $selection) { option in
4444
Text(option.rawValue)
@@ -85,15 +85,13 @@ struct OptionsListView: View {
8585
AssistantConfigurationDemoView(service: openAIService)
8686
case .realTimeAPI:
8787
Text("WIP")
88-
case .responseStream:
89-
Text("WIP")
90-
91-
// ResponseStreamDemoView(service: openAIService)
88+
case .responseStream:
89+
ResponseStreamDemoView(service: openAIService)
9290
}
9391
}
9492
}
9593
}
96-
94+
9795
@State private var selection: APIOption? = nil
98-
96+
9997
}
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
//
2+
// ResponseStreamDemoView.swift
3+
// SwiftOpenAIExample
4+
//
5+
// Created by James Rochabrun on 6/7/25.
6+
//
7+
8+
import SwiftUI
9+
import SwiftOpenAI
10+
11+
struct ResponseStreamDemoView: View {
12+
13+
@State private var provider: ResponseStreamProvider
14+
@State private var inputText = ""
15+
@FocusState private var isInputFocused: Bool
16+
@Environment(\.colorScheme) var colorScheme
17+
18+
init(service: OpenAIService) {
19+
self._provider = State(initialValue: ResponseStreamProvider(service: service))
20+
}
21+
22+
var body: some View {
23+
VStack(spacing: 0) {
24+
// Header
25+
headerView
26+
27+
// Messages
28+
ScrollViewReader { proxy in
29+
ScrollView {
30+
LazyVStack(spacing: 12) {
31+
ForEach(provider.messages) { message in
32+
MessageBubbleView(message: message)
33+
.id(message.id)
34+
}
35+
36+
if provider.isStreaming {
37+
HStack {
38+
LoadingIndicatorView()
39+
.frame(width: 30, height: 30)
40+
Spacer()
41+
}
42+
.padding(.horizontal)
43+
}
44+
}
45+
.padding()
46+
}
47+
.onChange(of: provider.messages.count) { _, _ in
48+
withAnimation {
49+
proxy.scrollTo(provider.messages.last?.id, anchor: .bottom)
50+
}
51+
}
52+
}
53+
54+
// Error view
55+
if let error = provider.error {
56+
Text(error)
57+
.foregroundColor(.red)
58+
.font(.caption)
59+
.padding(.horizontal)
60+
.padding(.vertical, 8)
61+
.background(Color.red.opacity(0.1))
62+
}
63+
64+
// Input area
65+
inputArea
66+
}
67+
.navigationTitle("Response Stream Demo")
68+
.navigationBarTitleDisplayMode(.inline)
69+
.toolbar {
70+
ToolbarItem(placement: .navigationBarTrailing) {
71+
Button("Clear") {
72+
provider.clearConversation()
73+
}
74+
.disabled(provider.isStreaming)
75+
}
76+
}
77+
}
78+
79+
// MARK: - Subviews
80+
81+
private var headerView: some View {
82+
VStack(alignment: .leading, spacing: 8) {
83+
Text("Streaming Responses with Conversation State")
84+
.font(.headline)
85+
86+
Text("This demo uses the Responses API with streaming to maintain conversation context across multiple turns.")
87+
.font(.caption)
88+
.foregroundColor(.secondary)
89+
90+
if provider.messages.isEmpty {
91+
Label("Start a conversation below", systemImage: "bubble.left.and.bubble.right")
92+
.font(.caption)
93+
.foregroundColor(.blue)
94+
.padding(.top, 4)
95+
}
96+
}
97+
.frame(maxWidth: .infinity, alignment: .leading)
98+
.padding()
99+
.background(Color(UIColor.secondarySystemBackground))
100+
}
101+
102+
private var inputArea: some View {
103+
HStack(spacing: 12) {
104+
TextField("Type a message...", text: $inputText, axis: .vertical)
105+
.textFieldStyle(.roundedBorder)
106+
.lineLimit(1...5)
107+
.focused($isInputFocused)
108+
.disabled(provider.isStreaming)
109+
.onSubmit {
110+
sendMessage()
111+
}
112+
113+
Button(action: sendMessage) {
114+
Image(systemName: provider.isStreaming ? "stop.circle.fill" : "arrow.up.circle.fill")
115+
.font(.title2)
116+
.foregroundColor(provider.isStreaming ? .red : (inputText.isEmpty ? .gray : .blue))
117+
}
118+
.disabled(!provider.isStreaming && inputText.isEmpty)
119+
}
120+
.padding()
121+
.background(Color(UIColor.systemBackground))
122+
.overlay(
123+
Rectangle()
124+
.frame(height: 1)
125+
.foregroundColor(Color(UIColor.separator)),
126+
alignment: .top
127+
)
128+
}
129+
130+
// MARK: - Actions
131+
132+
private func sendMessage() {
133+
guard !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
134+
135+
if provider.isStreaming {
136+
provider.stopStreaming()
137+
} else {
138+
let message = inputText
139+
inputText = ""
140+
provider.sendMessage(message)
141+
}
142+
}
143+
}
144+
145+
// MARK: - Message Bubble View
146+
147+
struct MessageBubbleView: View {
148+
let message: ResponseStreamProvider.ResponseMessage
149+
@Environment(\.colorScheme) var colorScheme
150+
151+
var body: some View {
152+
HStack {
153+
if message.role == .assistant {
154+
messageContent
155+
.background(backgroundGradient)
156+
.cornerRadius(16)
157+
.overlay(
158+
RoundedRectangle(cornerRadius: 16)
159+
.stroke(borderColor, lineWidth: 1)
160+
)
161+
Spacer(minLength: 60)
162+
} else {
163+
Spacer(minLength: 60)
164+
messageContent
165+
.background(Color.blue)
166+
.cornerRadius(16)
167+
.foregroundColor(.white)
168+
}
169+
}
170+
}
171+
172+
private var messageContent: some View {
173+
VStack(alignment: .leading, spacing: 4) {
174+
if message.role == .assistant && message.isStreaming {
175+
HStack(spacing: 4) {
176+
Image(systemName: "dot.radiowaves.left.and.right")
177+
.font(.caption2)
178+
.foregroundColor(.blue)
179+
Text("Streaming...")
180+
.font(.caption2)
181+
.foregroundColor(.secondary)
182+
}
183+
}
184+
185+
Text(message.content.isEmpty && message.isStreaming ? " " : message.content)
186+
.padding(.horizontal, 12)
187+
.padding(.vertical, 8)
188+
189+
if message.role == .assistant && !message.isStreaming && message.responseId != nil {
190+
Text("Response ID: \(String(message.responseId?.prefix(8) ?? ""))")
191+
.font(.caption2)
192+
.foregroundColor(.secondary)
193+
.padding(.horizontal, 12)
194+
.padding(.bottom, 4)
195+
}
196+
}
197+
}
198+
199+
private var backgroundGradient: some View {
200+
LinearGradient(
201+
gradient: Gradient(colors: [
202+
Color(UIColor.secondarySystemBackground),
203+
Color(UIColor.tertiarySystemBackground)
204+
]),
205+
startPoint: .topLeading,
206+
endPoint: .bottomTrailing
207+
)
208+
}
209+
210+
private var borderColor: Color {
211+
colorScheme == .dark ? Color.white.opacity(0.1) : Color.black.opacity(0.1)
212+
}
213+
}
214+
215+
// MARK: - Loading Indicator
216+
217+
struct LoadingIndicatorView: View {
218+
@State private var animationAmount = 0.0
219+
220+
var body: some View {
221+
ZStack {
222+
ForEach(0..<3) { index in
223+
Circle()
224+
.fill(Color.blue)
225+
.frame(width: 8, height: 8)
226+
.offset(x: CGFloat(index - 1) * 12)
227+
.opacity(0.8)
228+
.scaleEffect(animationScale(for: index))
229+
}
230+
}
231+
.onAppear {
232+
withAnimation(
233+
.easeInOut(duration: 0.8)
234+
.repeatForever(autoreverses: true)
235+
) {
236+
animationAmount = 1
237+
}
238+
}
239+
}
240+
241+
private func animationScale(for index: Int) -> Double {
242+
let delay = Double(index) * 0.1
243+
let progress = (animationAmount + delay).truncatingRemainder(dividingBy: 1.0)
244+
return 0.5 + (0.5 * sin(progress * .pi))
245+
}
246+
}
247+
248+
// MARK: - Preview
249+
250+
#Preview {
251+
NavigationView {
252+
ResponseStreamDemoView(service: OpenAIServiceFactory.service(apiKey: "test"))
253+
}
254+
}

0 commit comments

Comments
 (0)