1- #if canImport(UIKit )
1+ #if os(iOS )
22import Combine
33import ElevenLabs
44import Foundation
@@ -29,14 +29,14 @@ final class ChatWidgetViewModel: ObservableObject {
2929 let endedByUser : Bool
3030 }
3131
32- /// All three mirror what the host passes to ``ChatWidget``, so changes land
32+ /// All four mirror what the host passes to ``ChatWidget``, so changes land
3333 /// on the live view model instead of needing a new conversation.
3434 var mode : WidgetConversationMode
35+ var conversationConfig : ConversationConfig
3536 var onClientToolCall : ( @MainActor ( ClientToolCallEvent) async -> ClientToolResultEvent ) ?
3637 @Published var widgetConfig : ChatWidgetConfig
3738 let client : ConversationClient
3839
39- private let conversationConfig : ConversationConfig
4040 /// Tool calls already dispatched, so a re-published snapshot doesn't run them twice.
4141 private var dispatchedToolCallIds : Set < String > = [ ]
4242 /// The in-flight startup, so overlapping callers join it instead of racing.
@@ -69,6 +69,9 @@ final class ChatWidgetViewModel: ObservableObject {
6969 . map { $0. map ( ChatMessage . init) }
7070 . assign ( to: & $messages)
7171 client. $pendingToolCalls
72+ . combineLatest ( client. $state)
73+ . filter { _, state in state. isConnected }
74+ . map { calls, _ in calls }
7275 . sink { [ weak self] in self ? . dispatchNewToolCalls ( $0) }
7376 . store ( in: & cancellables)
7477 // Only a finished session clears the kind: the client rebinds to a fresh
@@ -80,6 +83,7 @@ final class ChatWidgetViewModel: ObservableObject {
8083 switch state {
8184 case let . ended( reason) :
8285 sessionKind = nil
86+ dismissBanner ( )
8387 endedConversation = EndedConversation (
8488 id: client. conversationMetadata? . conversationId,
8589 endedByUser: reason == . userEnded
@@ -185,7 +189,6 @@ final class ChatWidgetViewModel: ObservableObject {
185189 Task {
186190 do {
187191 try await startConversationAndWait ( )
188- warnIfMicrophoneUnavailable ( )
189192 } catch is CancellationError {
190193 // The user ended the call before it connected.
191194 } catch {
@@ -219,10 +222,14 @@ final class ChatWidgetViewModel: ObservableObject {
219222 // No start in flight, so a kind here means a session already connected.
220223 guard sessionKind == nil else { return }
221224 let mode = mode
225+ let client = client
222226 let config = conversationConfig ( for: kind)
227+ dispatchedToolCallIds. removeAll ( )
223228 let task = Task {
229+ let credentials = try await mode. credentials ( for: kind)
230+ try Task . checkCancellation ( )
224231 _ = try await client. startConversation (
225- auth: mode . credentials ( for : kind ) ,
232+ auth: credentials,
226233 config: config
227234 )
228235 }
@@ -236,19 +243,22 @@ final class ChatWidgetViewModel: ObservableObject {
236243 defer { if sessionGeneration == generation { startTask = nil } }
237244 do {
238245 try await task. value
246+ warnIfMicrophoneUnavailable ( )
239247 } catch {
240248 // Minting can fail before the client ever leaves idle, and then no
241249 // state arrives to retire the session this would have been.
242250 if sessionGeneration == generation { sessionKind = nil }
251+ if task. isCancelled || error is CancellationError {
252+ throw CancellationError ( )
253+ }
243254 throw error
244255 }
245256 }
246257
247258 /// Text-only sessions run the conversation without audio.
248259 private func conversationConfig( for kind: WidgetSessionKind ) -> ConversationConfig {
249- guard kind == . textOnly else { return conversationConfig }
250260 var config = conversationConfig
251- config. conversationOverrides. textOnly = true
261+ config. conversationOverrides. textOnly = kind == . textOnly
252262 return config
253263 }
254264
@@ -259,28 +269,24 @@ final class ChatWidgetViewModel: ObservableObject {
259269 /// Ending has to outrank a start that is still resolving credentials, or the
260270 /// connection lands afterwards and leaves the microphone live.
261271 func endConversationAndWait( ) async {
262- let start = startTask
263- let generation = sessionGeneration
272+ let task = startTask? . task
264273 startTask = nil
265274 sessionKind = nil
266- start ? . task. cancel ( )
275+ task? . cancel ( )
267276 await client. endConversation ( )
268- guard let start else { return }
269- // A start that ignored the cancel still connects, so wait it out and
270- // tear it down — unless a newer session has begun in the meantime.
271- _ = try ? await start. task. value
272- if sessionGeneration == generation { await client. endConversation ( ) }
273277 }
274278
275279 func send( ) {
276280 let text = input. trimmingCharacters ( in: . whitespacesAndNewlines)
277281 guard !text. isEmpty, !isSending else { return }
278282 input = " "
279283 isSending = true
280- dismissBanner ( )
284+ if banner ? . offersSettings != true { dismissBanner ( ) }
281285 Task {
282286 do {
283287 try await send ( text)
288+ } catch is CancellationError {
289+ if input. isEmpty { input = text }
284290 } catch {
285291 // Hand the text back so the user can retry rather than losing it.
286292 if input. isEmpty { input = text }
@@ -304,17 +310,20 @@ final class ChatWidgetViewModel: ObservableObject {
304310 private func show( _ banner: ChatWidgetBanner ) {
305311 self . banner = banner
306312 bannerDismissal? . cancel ( )
313+ bannerDismissal = nil
307314 // A banner offering Settings needs to stay until it is acted on.
308315 guard !banner. offersSettings else { return }
309316 bannerDismissal = Task { [ weak self] in
310317 try ? await Task . sleep ( nanoseconds: 5_000_000_000 )
311318 guard !Task. isCancelled else { return }
312319 self ? . banner = nil
320+ self ? . bannerDismissal = nil
313321 }
314322 }
315323
316324 func dismissBanner( ) {
317325 bannerDismissal? . cancel ( )
326+ bannerDismissal = nil
318327 banner = nil
319328 }
320329
@@ -328,36 +337,33 @@ final class ChatWidgetViewModel: ObservableObject {
328337 private func dispatchNewToolCalls( _ calls: [ ClientToolCallEvent ] ) {
329338 let ids = Set ( calls. map ( \. toolCallId) )
330339 let added = ids. subtracting ( dispatchedToolCallIds)
331- dispatchedToolCallIds = ids
340+ dispatchedToolCallIds. formUnion ( added )
332341 for call in calls where added. contains ( call. toolCallId) {
333342 dispatch ( call)
334343 }
335344 }
336345
337346 private func dispatch( _ call: ClientToolCallEvent ) {
338- guard let onClientToolCall else {
339- respond ( to: call, with: . init(
340- toolCallId: call. toolCallId,
341- result: " No client tool handler is configured in the app. " ,
342- isError: true
343- ) )
344- return
345- }
346347 let generation = sessionGeneration
348+ let handler = onClientToolCall
347349 Task { @MainActor [ weak self] in
348- let result = await onClientToolCall ( call)
350+ let result = if let handler {
351+ await handler ( call)
352+ } else {
353+ ClientToolResultEvent (
354+ toolCallId: call. toolCallId,
355+ result: " No client tool handler is configured in the app. " ,
356+ isError: true
357+ )
358+ }
349359 // The host handler can outlive the conversation that asked for it.
350360 guard let self, generation == sessionGeneration else { return }
351- respond ( to: call, with: result)
352- }
353- }
354-
355- private func respond( to call: ClientToolCallEvent , with result: ClientToolResultEvent ) {
356- guard call. expectsResponse else {
357- client. markToolCallCompleted ( call. toolCallId)
358- return
361+ if call. expectsResponse {
362+ try ? await client. sendToolResult ( result)
363+ } else {
364+ client. markToolCallCompleted ( call. toolCallId)
365+ }
359366 }
360- Task { try ? await client. sendToolResult ( result) }
361367 }
362368}
363369#endif
0 commit comments