@@ -111,6 +111,12 @@ class ChatViewModel: ObservableObject {
111
111
private var openAITTSService : OpenAITTSService ?
112
112
private var systemVoiceService : SystemVoiceService ?
113
113
114
+ /**
115
+ If we choose to add a custom OllamaService instance, we handle it the same as
116
+ OpenAI or Anthropic or GitHub. We'll store it in chatService if user picks
117
+ that provider. This is done in `initializeChatService`.
118
+ */
119
+
114
120
// MARK: - Persistence Tools
115
121
116
122
/// Reference to UserDefaults for reading/writing settings and messages.
@@ -123,7 +129,7 @@ class ChatViewModel: ObservableObject {
123
129
// MARK: - Token Batching Configuration
124
130
125
131
/// Number of tokens to accumulate before flushing to the AI message text.
126
- private let tokenBatchSize = 5
132
+ private let tokenBatchSize = 1
127
133
128
134
/// Max time interval before forcibly flushing tokens, to keep UI responsive.
129
135
private let tokenFlushInterval : TimeInterval = 0.2
@@ -240,9 +246,11 @@ class ChatViewModel: ObservableObject {
240
246
print ( " [Debug] Provider: \( appSettings. selectedProvider) " )
241
247
print ( " [Debug] Using API Key: \( maskAPIKey ( appSettings. currentAPIKey) ) " )
242
248
243
- // 1) Validate non-empty input and valid API key.
249
+ // 1) Validate non-empty input and valid API key when needed
244
250
guard !inputText. trimmingCharacters ( in: . whitespacesAndNewlines) . isEmpty else { return }
245
- guard !appSettings. currentAPIKey. isEmpty else {
251
+
252
+ // Only check for API key if the provider isn't Ollama
253
+ if appSettings. selectedProvider != . ollama && appSettings. currentAPIKey. isEmpty {
246
254
print ( " [ChatViewModel] No valid API key for provider \( appSettings. selectedProvider) . " )
247
255
handleError ( ChatServiceError . invalidAPIKey)
248
256
return
@@ -363,7 +371,8 @@ class ChatViewModel: ObservableObject {
363
371
Ensures changes to provider, model, or keys take effect immediately.
364
372
*/
365
373
internal func initializeChatService( with settings: AppSettings ) {
366
- guard !settings. currentAPIKey. isEmpty else {
374
+ // For Ollama, we don't require an API key
375
+ if settings. selectedProvider != . ollama && settings. currentAPIKey. isEmpty {
367
376
chatService = nil
368
377
print ( " [ChatViewModel] No valid API key for provider \( settings. selectedProvider) . " )
369
378
return
@@ -381,6 +390,11 @@ class ChatViewModel: ObservableObject {
381
390
case . githubModel:
382
391
chatService = GitHubModelChatService ( apiKey: settings. githubToken)
383
392
print ( " [ChatViewModel] Initialized GitHub Model Chat Service with key: \( maskAPIKey ( settings. githubToken) ) " )
393
+
394
+ case . ollama:
395
+ let service = OllamaService ( serverURL: settings. ollamaServerURL)
396
+ self . chatService = service
397
+ print ( " [ChatViewModel] Initialized Ollama service with URL: \( settings. ollamaServerURL) " )
384
398
}
385
399
}
386
400
@@ -501,7 +515,8 @@ class ChatViewModel: ObservableObject {
501
515
streaming response tokens. Also manages insertion of system messages (e.g. instructions).
502
516
*/
503
517
private func performSendFlow( ) async {
504
- print ( " [ChatViewModel] Sending message to API... " )
518
+ print ( " [ChatViewModel] *** STARTING NEW SEND FLOW *** " )
519
+ print ( " [ChatViewModel] Provider: \( appSettings. selectedProvider. rawValue) , Model: \( appSettings. selectedModelId) " )
505
520
506
521
// 1) Create a placeholder AI message that we'll update with streaming tokens.
507
522
let aiMessage = MutableMessage (
@@ -519,37 +534,48 @@ class ChatViewModel: ObservableObject {
519
534
520
535
// 2) Build the list of messages to send, possibly including relevant memories.
521
536
let payload = await prepareMessagesPayload ( )
522
-
523
- // 3) Handle system message if needed (OpenAI/GitHub typically embed as role=system).
524
- // Anthropic uses a separate `system` param.
525
- if ( appSettings. selectedProvider == . openAI || appSettings. selectedProvider == . githubModel) ,
526
- !appSettings. systemMessage. isEmpty {
527
- var updated = payload
528
- updated. insert ( [ " role " : " system " , " content " : appSettings. systemMessage] , at: 0 )
529
-
530
- // For Anthropic, pass system text separately.
531
- let systemMessage = ( appSettings. selectedProvider == . anthropic && !appSettings. systemMessage. isEmpty)
532
- ? appSettings. systemMessage
533
- : nil
534
-
535
- let stream = try await service. streamCompletion (
536
- messages: updated,
537
- model: appSettings. selectedModelId,
538
- system: systemMessage
539
- )
540
- let completeResponse = try await handleResponseStream ( stream, aiMessage: aiMessage)
541
- await finalizeResponseProcessing ( completeResponse: completeResponse)
542
-
537
+
538
+ // 3) Handle system message based on provider
539
+ // Determine which providers use system message in the payload vs. as separate parameter
540
+ let shouldIncludeSystemInPayload = ( appSettings. selectedProvider == . openAI ||
541
+ appSettings. selectedProvider == . githubModel ||
542
+ appSettings. selectedProvider == . ollama)
543
+
544
+ let systemMessageParam : String ?
545
+
546
+ if !appSettings. systemMessage. isEmpty {
547
+ if appSettings. selectedProvider == . anthropic {
548
+ systemMessageParam = appSettings. systemMessage // Anthropic expects system as separate param
549
+ } else {
550
+ systemMessageParam = appSettings. systemMessage // For Ollama, we'll send both ways for robustness
551
+ }
552
+
553
+ if shouldIncludeSystemInPayload {
554
+ var updated = payload
555
+ updated. insert ( [ " role " : " system " , " content " : appSettings. systemMessage] , at: 0 )
556
+
557
+ let stream = try await service. streamCompletion (
558
+ messages: updated,
559
+ model: appSettings. selectedModelId,
560
+ system: systemMessageParam
561
+ )
562
+ let completeResponse = try await handleResponseStream ( stream, aiMessage: aiMessage)
563
+ await finalizeResponseProcessing ( completeResponse: completeResponse)
564
+ } else {
565
+ let stream = try await service. streamCompletion (
566
+ messages: payload,
567
+ model: appSettings. selectedModelId,
568
+ system: systemMessageParam
569
+ )
570
+ let completeResponse = try await handleResponseStream ( stream, aiMessage: aiMessage)
571
+ await finalizeResponseProcessing ( completeResponse: completeResponse)
572
+ }
543
573
} else {
544
- // For Anthropic or other providers.
545
- let systemMessage = ( appSettings. selectedProvider == . anthropic && !appSettings. systemMessage. isEmpty)
546
- ? appSettings. systemMessage
547
- : nil
548
-
574
+ // No system message at all, just send the message payload
549
575
let stream = try await service. streamCompletion (
550
576
messages: payload,
551
577
model: appSettings. selectedModelId,
552
- system: systemMessage
578
+ system: nil
553
579
)
554
580
let completeResponse = try await handleResponseStream ( stream, aiMessage: aiMessage)
555
581
await finalizeResponseProcessing ( completeResponse: completeResponse)
@@ -568,13 +594,28 @@ class ChatViewModel: ObservableObject {
568
594
an empty placeholder or keep the message and optionally speak it. Then persists messages.
569
595
*/
570
596
private func finalizeResponseProcessing( completeResponse: String ) async {
571
- print ( " [ChatViewModel] Received response: \( completeResponse) " )
572
-
573
- let trimmed = completeResponse. trimmingCharacters ( in: . whitespacesAndNewlines)
597
+ print ( " [ChatViewModel] Received complete response, raw length: \( completeResponse. count) " )
598
+
599
+ // Clean up the response by removing any trailing JSON metadata
600
+ var cleanedResponse = completeResponse
601
+ if let jsonStart = completeResponse. range ( of: " { \" model \" : " ) {
602
+ cleanedResponse = String ( completeResponse [ ..< jsonStart. lowerBound] )
603
+ }
604
+
605
+ let trimmed = cleanedResponse. trimmingCharacters ( in: . whitespacesAndNewlines)
606
+ print ( " [ChatViewModel] Cleaned response length: \( trimmed. count) " )
607
+
574
608
if trimmed. isEmpty, let last = messages. last, !last. isUser {
575
609
// If the AI message is empty, remove the placeholder.
576
610
messages. removeLast ( )
577
611
} else {
612
+ // If we have text, update the message with cleaned text and optionally speak it
613
+ if let lastMessage = messages. last, !lastMessage. isUser {
614
+ // Replace the entire message text with the cleaned version
615
+ lastMessage. text = trimmed
616
+ objectWillChange. send ( )
617
+ }
618
+
578
619
// If we have text, optionally speak it if autoplay is on.
579
620
speakMessage ( trimmed)
580
621
await saveMessages ( )
@@ -664,73 +705,70 @@ class ChatViewModel: ObservableObject {
664
705
) async throws -> String {
665
706
var completeResponse = " "
666
707
var tokenCount = 0
708
+
709
+ print ( " [ChatViewModel] STREAMING STARTED - Provider: \( appSettings. selectedProvider. rawValue) , Model: \( appSettings. selectedModelId) " )
667
710
668
- // Prepare haptic feedback
669
711
let feedbackGenerator = UIImpactFeedbackGenerator ( style: . light)
670
712
let finalFeedbackGenerator = UINotificationFeedbackGenerator ( )
671
713
feedbackGenerator. prepare ( )
672
714
finalFeedbackGenerator. prepare ( )
673
715
674
- // Reset token buffering
675
716
tokenBuffer = " "
676
717
lastFlushDate = Date ( )
677
718
flushTask? . cancel ( )
678
719
flushTask = nil
679
720
680
- // Consume tokens as they arrive.
681
721
for try await content in stream {
682
- if Task . isCancelled { break }
722
+ // Filter out JSON metadata that might be included in tokens
723
+ if content. hasPrefix ( " { \" model \" : " ) {
724
+ print ( " [ChatViewModel] Skipping JSON metadata in token stream " )
725
+ continue
726
+ }
727
+
728
+ print ( " [ChatViewModel] Received token: \" \( content) \" " )
729
+
730
+ if Task . isCancelled {
731
+ print ( " [ChatViewModel] Stream cancelled " )
732
+ break
733
+ }
683
734
684
735
tokenBuffer. append ( content)
685
736
completeResponse. append ( content)
686
- tokenCount += 1
687
737
688
- // Flush every tokenBatchSize tokens for responsiveness.
689
- if tokenCount % tokenBatchSize == 0 {
690
- await flushTokens ( aiMessage: aiMessage)
691
- } else {
692
- scheduleFlush ( aiMessage: aiMessage)
693
- }
738
+ tokenCount += 1
694
739
695
- // Provide light haptic feedback every 5 tokens.
740
+ // IMPORTANT: Force flush each token immediately for debugging
741
+ await flushTokens ( aiMessage: aiMessage, force: true )
742
+
743
+ // Optional haptic every 5 tokens
696
744
if tokenCount % 5 == 0 {
697
745
feedbackGenerator. impactOccurred ( )
698
746
}
699
747
}
700
748
701
- // Ensure we flush any leftover tokens at the end.
749
+ // Make sure we flush any leftover tokens:
702
750
await flushTokens ( aiMessage: aiMessage, force: true )
703
751
finalFeedbackGenerator. notificationOccurred ( . success)
752
+
753
+ print ( " [ChatViewModel] STREAMING COMPLETE - Total tokens: \( tokenCount) " )
704
754
705
755
return completeResponse
706
756
}
707
757
708
- /**
709
- Schedules a flush after `tokenFlushInterval` if no further tokens arrive,
710
- preventing partial text from building up too long.
711
- */
712
- private func scheduleFlush( aiMessage: MutableMessage ) {
713
- flushTask? . cancel ( )
714
- flushTask = Task { [ weak self] in
715
- guard let self = self else { return }
716
- try ? await Task . sleep ( nanoseconds: UInt64 ( self . tokenFlushInterval * 1_000_000_000 ) )
717
- await self . flushTokens ( aiMessage: aiMessage)
718
- }
719
- }
720
-
721
- /**
722
- Moves any accumulated tokens into the AI message text.
723
- - Parameter aiMessage: The AI message being updated.
724
- - Parameter force: If `true`, flushes even if batch/time thresholds aren't reached.
725
- */
726
758
private func flushTokens( aiMessage: MutableMessage , force: Bool = false ) async {
727
759
guard force || !tokenBuffer. isEmpty else { return }
728
-
760
+
729
761
let tokensToApply = tokenBuffer
762
+ print ( " [ChatViewModel] Flushing tokens: \" \( tokensToApply) \" " )
763
+
730
764
tokenBuffer = " "
731
-
732
- aiMessage. text. append ( tokensToApply)
733
- objectWillChange. send ( )
765
+
766
+ // Make sure we update the UI on the main actor
767
+ await MainActor . run {
768
+ aiMessage. text += tokensToApply
769
+ objectWillChange. send ( )
770
+ }
771
+
734
772
lastFlushDate = Date ( )
735
773
}
736
774
0 commit comments