Skip to content

Feat/update chat completion types #337

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 14 commits into from
May 27, 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
80 changes: 74 additions & 6 deletions Demo/DemoChat/Sources/ChatStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,25 @@ public final class ChatStore: ObservableObject {
public var openAIClient: OpenAIProtocol
let idProvider: () -> String

@Published var conversations: [Conversation] = []
private var conversations: [Conversation] = [] {
didSet {
updateDebouncedConversations(with: conversations)
}
}

@Published var debouncedConversations: [Conversation] = []
@Published var conversationErrors: [Conversation.ID: Error] = [:]
@Published var selectedConversationID: Conversation.ID?
@Published var isSendingMessage = false

// Used for assistants API state.
private var timer: Timer?
private var timeInterval: TimeInterval = 1.0
private var currentRunId: String?
private var currentThreadId: String?
private var currentConversationId: String?

@Published var isSendingMessage = false
private var currentUpdateTask: Task<Void, Never>?
private var conversationsForNextUpdateTask: [Conversation]?

var selectedConversation: Conversation? {
selectedConversationID.flatMap { id in
Expand Down Expand Up @@ -75,13 +82,15 @@ public final class ChatStore: ObservableObject {

switch conversations[conversationIndex].type {
case .normal:
isSendingMessage = true
conversations[conversationIndex].messages.append(message)

await completeChat(
conversationId: conversationId,
model: model,
stream: isStreamEnabled
)
isSendingMessage = false
// For assistant case we send chats to thread and then poll, polling will receive sent chat + new assistant messages.
case .assistant:

Expand Down Expand Up @@ -177,7 +186,37 @@ public final class ChatStore: ObservableObject {

let chatQuery = ChatQuery(
messages: conversation.messages.map { message in
ChatQuery.ChatCompletionMessageParam(role: message.role, content: message.content)!
switch message.role {
case .developer:
return ChatQuery.ChatCompletionMessageParam.developer(.init(content: .textContent(message.content)))
case .system:
return ChatQuery.ChatCompletionMessageParam.system(.init(content: .textContent(message.content)))
case .user:
if let image = message.image {
return ChatQuery.ChatCompletionMessageParam.user(
.init(content: .contentParts([
.text(.init(text: message.content)),
.image(.init(imageUrl: .init(imageData: image.data, detail: nil)))
]))
)
} else {
return ChatQuery.ChatCompletionMessageParam.user(.init(content: .string(message.content)))
}
case .assistant:
if message.isRefusal {
return ChatQuery.ChatCompletionMessageParam.assistant(.init(
content: .contentParts(
[.refusal(.init(_type: .refusal, refusal: message.content))]
)
))
} else {
return ChatQuery.ChatCompletionMessageParam.assistant(.init(
content: .textContent(message.content)
))
}
case .tool:
return ChatQuery.ChatCompletionMessageParam.system(.init(content: .textContent(message.content)))
}
},
model: model,
tools: functions
Expand Down Expand Up @@ -276,7 +315,7 @@ public final class ChatStore: ObservableObject {
}

// Start Polling section
func startPolling(conversationId: Conversation.ID, runId: String, threadId: String) {
private func startPolling(conversationId: Conversation.ID, runId: String, threadId: String) {
currentRunId = runId
currentThreadId = threadId
currentConversationId = conversationId
Expand All @@ -288,7 +327,7 @@ public final class ChatStore: ObservableObject {
}
}

func stopPolling() {
private func stopPolling() {
isSendingMessage = false
timer?.invalidate()
timer = nil
Expand Down Expand Up @@ -444,4 +483,33 @@ public final class ChatStore: ObservableObject {
conversations[conversationIndex].messages.append(message)
}
}

private func updateDebouncedConversations(with conversations: [Conversation]) {
if currentUpdateTask == nil {
scheduleReplaceDebouncedConversations(withConversations: conversations)
} else {
conversationsForNextUpdateTask = conversations
}
}

private func scheduleReplaceDebouncedConversations(withConversations conversations: [Conversation]) {
currentUpdateTask = .init(operation: {
// Debouncing because otherwise it's hard for ExyteChat to handle very quick updates
do {
// 1000 nano == 1 micro, 1000 micro == 1 milli, 100 milli = 0.1s
try await Task.sleep(nanoseconds: 1000 * 1000 * 100)
} catch {
assert(error is CancellationError)
return
}

self.debouncedConversations = conversations
self.currentUpdateTask = nil

if let pendingItem = conversationsForNextUpdateTask {
conversationsForNextUpdateTask = nil
scheduleReplaceDebouncedConversations(withConversations: pendingItem)
}
})
}
}
35 changes: 35 additions & 0 deletions Demo/DemoChat/Sources/Models/Conversation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import Foundation
import ExyteChat

struct Conversation {
init(id: String, messages: [Message] = [], type: ConversationType = .normal, assistantId: String? = nil) {
Expand All @@ -21,6 +22,40 @@ struct Conversation {
var messages: [Message]
var type: ConversationType
var assistantId: String?

var exyteChatMessages: [ExyteChat.Message] {
messages.map {
let attachments: [Attachment] = if let image = $0.image {
[.init(id: image.attachmentId, url: image.thumbnailURL, type: .image)]
} else {
[]
}

let userType: UserType

switch $0.role {
case .user:
userType = .current
case .assistant:
userType = .system
default:
userType = .other
}

return .init(
id: $0.id,
user: .init(
id: $0.role.rawValue,
name: $0.role.rawValue,
avatarURL: userType == .system ? .init(string: "https://openai.com/apple-icon.png") : nil,
type: userType
),
createdAt: $0.createdAt,
text: $0.content,
attachments: attachments
)
}
}
}

enum ConversationType {
Expand Down
11 changes: 10 additions & 1 deletion Demo/DemoChat/Sources/Models/Message.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,24 @@

import Foundation
import OpenAI
import ExyteChat

struct Message {
struct Image: Codable, Hashable {
let attachmentId: String
let thumbnailURL: URL
let data: Data
}

var id: String
var role: ChatQuery.ChatCompletionMessageParam.Role
var content: String
var createdAt: Date
var isRefusal: Bool = false
var image: Image?

var isLocal: Bool?
var isRunStep: Bool?
}

extension Message: Equatable, Codable, Hashable, Identifiable {}
extension Message: Codable, Hashable, Identifiable {}
14 changes: 7 additions & 7 deletions Demo/DemoChat/Sources/ResponsesStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,9 @@ public final class ResponsesStore: ObservableObject {
try await createResponse(
input: .inputItemList([
.item(.functionToolCall(toolCall)),
.item(.functionToolCallOutput(.init(
_type: .functionCallOutput,
.item(.functionCallOutputItemParam(.init(
callId: toolCall.callId,
_type: .functionCallOutput,
output: result
)))
]),
Expand Down Expand Up @@ -453,14 +453,14 @@ public final class ResponsesStore: ObservableObject {

private func updateMessageBeingStreamed(
messageId: String,
outputContent: ResponseStreamEvent.Schemas.OutputContent,
outputContent: ResponseStreamEvent.Schemas.OutputContent
) throws {
try updateMessageBeingStreamed(messageId: messageId) { message in
switch outputContent {
case .OutputText(let outputText):
case .OutputTextContent(let outputText):
message.text = outputText.text
message.annotations = outputText.annotations
case .Refusal(let refusal):
case .RefusalContent(let refusal):
message.refusalText = refusal.refusal
}
}
Expand Down Expand Up @@ -604,14 +604,14 @@ public final class ResponsesStore: ObservableObject {
username: String
) -> ExyteChat.Message {
switch outputContent {
case .OutputText(let outputText):
case .OutputTextContent(let outputText):
return makeChatMessage(
withText: outputText.text,
annotations: outputText.annotations,
messageId: messageId,
user: systemUser(withId: userId, username: username)
)
case .Refusal(let refusal):
case .RefusalContent(let refusal):
let message = ExyteChat.Message(
id: messageId,
user: systemUser(withId: userId, username: username),
Expand Down
13 changes: 8 additions & 5 deletions Demo/DemoChat/Sources/UI/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public struct ChatView: View {
ZStack {
NavigationSplitView {
ListView(
conversations: $store.conversations,
conversations: $store.debouncedConversations,
selectedConversationId: Binding<Conversation.ID?>(
get: {
store.selectedConversationID
Expand All @@ -51,23 +51,26 @@ public struct ChatView: View {
} detail: {
if let conversation = store.selectedConversation {
DetailView(
availableAssistants: assistantStore.availableAssistants, conversation: conversation,
availableAssistants: assistantStore.availableAssistants,
conversation: conversation,
error: store.conversationErrors[conversation.id],
sendMessage: { message, selectedModel, streamEnabled in
sendMessage: { message, image, selectedModel, streamEnabled in
self.sendMessageTask = Task {
await store.sendMessage(
Message(
id: idProvider(),
role: .user,
content: message,
createdAt: dateProvider()
createdAt: dateProvider(),
image: image
),
conversationId: conversation.id,
model: selectedModel,
isStreamEnabled: streamEnabled
)
}
}, isSendingMessage: $store.isSendingMessage
},
isSendingMessage: $store.isSendingMessage
)
}
}
Expand Down
Loading