From 2caa8eb4ad266cbd80c7e8674edb324cfd709f7b Mon Sep 17 00:00:00 2001 From: Chetan Date: Mon, 26 May 2025 17:33:35 -0400 Subject: [PATCH] feat: Add custom trigger words for Enhancement Modes - Added support for custom trigger words in Enhancement Modes - Implemented automatic mode switching based on detected trigger words - Enhanced UI to display trigger words with microphone icons - Added visual indicators (ellipses) to show trigger words can be followed by more text - Fixed mode restoration after processing trigger-based enhancements - Improved UI consistency for modes with/without trigger words - Maintained backward compatibility with existing assistant trigger word --- VoiceInk/Models/CustomPrompt.swift | 5 +- VoiceInk/Services/AIEnhancementService.swift | 103 +++++++++++++++++-- VoiceInk/Views/EnhancementSettingsView.swift | 51 +++++++-- VoiceInk/Views/PromptEditorView.swift | 25 ++++- 4 files changed, 164 insertions(+), 20 deletions(-) diff --git a/VoiceInk/Models/CustomPrompt.swift b/VoiceInk/Models/CustomPrompt.swift index 4333886..dbc8122 100644 --- a/VoiceInk/Models/CustomPrompt.swift +++ b/VoiceInk/Models/CustomPrompt.swift @@ -82,6 +82,7 @@ struct CustomPrompt: Identifiable, Codable, Equatable { let icon: PromptIcon let description: String? let isPredefined: Bool + let triggerWord: String? init( id: UUID = UUID(), @@ -90,7 +91,8 @@ struct CustomPrompt: Identifiable, Codable, Equatable { isActive: Bool = false, icon: PromptIcon = .documentFill, description: String? = nil, - isPredefined: Bool = false + isPredefined: Bool = false, + triggerWord: String? = nil ) { self.id = id self.title = title @@ -99,5 +101,6 @@ struct CustomPrompt: Identifiable, Codable, Equatable { self.icon = icon self.description = description self.isPredefined = isPredefined + self.triggerWord = triggerWord } } \ No newline at end of file diff --git a/VoiceInk/Services/AIEnhancementService.swift b/VoiceInk/Services/AIEnhancementService.swift index 52ebe3a..c8a0179 100644 --- a/VoiceInk/Services/AIEnhancementService.swift +++ b/VoiceInk/Services/AIEnhancementService.swift @@ -79,6 +79,9 @@ class AIEnhancementService: ObservableObject { private var lastRequestTime: Date? private let modelContext: ModelContext + // Store the original prompt ID when temporarily switching due to trigger word + private var originalSelectedPromptId: UUID? + init(aiService: AIService = AIService(), modelContext: ModelContext) { self.aiService = aiService self.modelContext = modelContext @@ -144,7 +147,39 @@ class AIEnhancementService: ObservableObject { } private func determineMode(text: String) -> EnhancementMode { - text.lowercased().hasPrefix(assistantTriggerWord.lowercased()) ? .aiAssistant : .transcriptionEnhancement + let lowerText = text.lowercased() + + // First check if the text starts with the global assistant trigger word + if lowerText.hasPrefix(assistantTriggerWord.lowercased()) { + logger.notice("🔍 Detected assistant trigger word: \(self.assistantTriggerWord)") + return .aiAssistant + } + + // Then check for custom trigger words in all prompts + for prompt in allPrompts { + if let triggerWord = prompt.triggerWord?.lowercased().trimmingCharacters(in: .whitespacesAndNewlines), + !triggerWord.isEmpty, + lowerText.hasPrefix(triggerWord) { + + logger.notice("🔍 Detected custom trigger word: '\(triggerWord)' for mode: \(prompt.title)") + + // Only store the original prompt ID if we haven't already + if originalSelectedPromptId == nil { + originalSelectedPromptId = selectedPromptId + logger.notice("💾 Stored original prompt ID: \(String(describing: self.originalSelectedPromptId))") + } + + // Update to the new prompt + selectedPromptId = prompt.id + logger.notice("🔄 Switched to prompt: \(prompt.title) (ID: \(prompt.id))") + + return .transcriptionEnhancement + } + } + + // Default to transcription enhancement with currently selected prompt + logger.notice("â„šī¸ No trigger word detected, using default enhancement mode") + return .transcriptionEnhancement } private func getSystemMessage(for mode: EnhancementMode) -> String { @@ -186,7 +221,7 @@ class AIEnhancementService: ObservableObject { } } - private func makeRequest(text: String, retryCount: Int = 0) async throws -> String { + private func makeRequest(text: String, mode: EnhancementMode, retryCount: Int = 0) async throws -> String { guard isConfigured else { logger.error("AI Enhancement: API not configured") throw EnhancementError.notConfigured @@ -198,7 +233,6 @@ class AIEnhancementService: ObservableObject { } let formattedText = "\n\n\(text)\n" - let mode = determineMode(text: text) let systemMessage = getSystemMessage(for: mode) logger.notice("đŸ›°ī¸ Sending to AI provider: \(self.aiService.selectedProvider.rawValue, privacy: .public)\nSystem Message: \(systemMessage, privacy: .public)\nUser Message: \(formattedText, privacy: .public)") @@ -292,7 +326,7 @@ class AIEnhancementService: ObservableObject { } catch { if retryCount < maxRetries { try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(retryCount)) * 1_000_000_000)) - return try await makeRequest(text: text, retryCount: retryCount + 1) + return try await makeRequest(text: text, mode: mode, retryCount: retryCount + 1) } throw EnhancementError.networkError } @@ -347,7 +381,7 @@ class AIEnhancementService: ObservableObject { } catch { if retryCount < maxRetries { try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(retryCount)) * 1_000_000_000)) - return try await makeRequest(text: text, retryCount: retryCount + 1) + return try await makeRequest(text: text, mode: mode, retryCount: retryCount + 1) } throw EnhancementError.networkError } @@ -410,7 +444,7 @@ class AIEnhancementService: ObservableObject { } catch { if retryCount < maxRetries { try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(retryCount)) * 1_000_000_000)) - return try await makeRequest(text: text, retryCount: retryCount + 1) + return try await makeRequest(text: text, mode: mode, retryCount: retryCount + 1) } throw EnhancementError.networkError } @@ -419,11 +453,41 @@ class AIEnhancementService: ObservableObject { func enhance(_ text: String) async throws -> String { logger.notice("🚀 Starting AI enhancement for text (\(text.count) characters)") + + // Determine the mode and potentially set the active prompt based on trigger word + let mode = determineMode(text: text) + + // If a custom trigger word was detected, remove it from the text + var processedText = text + if mode == .transcriptionEnhancement, let activePrompt = activePrompt, let triggerWord = activePrompt.triggerWord, !triggerWord.isEmpty { + // Check if the text starts with the trigger word (case insensitive) + if text.lowercased().hasPrefix(triggerWord.lowercased()) { + // Remove the trigger word from the beginning of the text + let index = text.index(text.startIndex, offsetBy: triggerWord.count) + processedText = String(text[index...]).trimmingCharacters(in: .whitespacesAndNewlines) + logger.notice("🔍 Detected trigger word '\(triggerWord)' for mode '\(activePrompt.title)'. Processing: \(processedText)") + } + } else if mode == .aiAssistant { + // Remove the assistant trigger word if present + if text.lowercased().hasPrefix(assistantTriggerWord.lowercased()) { + let index = text.index(text.startIndex, offsetBy: assistantTriggerWord.count) + processedText = String(text[index...]).trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + // Process the text with the appropriate mode var retryCount = 0 while retryCount < maxRetries { do { - let result = try await makeRequest(text: text, retryCount: retryCount) + let result = try await makeRequest(text: processedText, mode: mode, retryCount: retryCount) logger.notice("✅ AI enhancement completed successfully (\(result.count) characters)") + + // After successful enhancement, restore the original prompt if we temporarily switched + // due to a trigger word + Task { @MainActor in + self.restoreOriginalPrompt() + } + return result } catch EnhancementError.rateLimitExceeded where retryCount < maxRetries - 1 { logger.notice("âš ī¸ Rate limit exceeded, retrying AI enhancement (attempt \(retryCount + 1) of \(self.maxRetries))") @@ -432,10 +496,22 @@ class AIEnhancementService: ObservableObject { continue } catch { logger.notice("❌ AI enhancement failed: \(error.localizedDescription)") + + // Even if enhancement fails, we should restore the original prompt + Task { @MainActor in + self.restoreOriginalPrompt() + } + throw error } } logger.notice("❌ AI enhancement failed: maximum retries exceeded") + + // If we exceed max retries, also restore the original prompt + Task { @MainActor in + self.restoreOriginalPrompt() + } + throw EnhancementError.maxRetriesExceeded } @@ -449,8 +525,8 @@ class AIEnhancementService: ObservableObject { } } - func addPrompt(title: String, promptText: String, icon: PromptIcon = .documentFill, description: String? = nil) { - let newPrompt = CustomPrompt(title: title, promptText: promptText, icon: icon, description: description, isPredefined: false) + func addPrompt(title: String, promptText: String, icon: PromptIcon = .documentFill, description: String? = nil, triggerWord: String? = nil) { + let newPrompt = CustomPrompt(title: title, promptText: promptText, icon: icon, description: description, isPredefined: false, triggerWord: triggerWord) customPrompts.append(newPrompt) if customPrompts.count == 1 { selectedPromptId = newPrompt.id @@ -477,6 +553,15 @@ class AIEnhancementService: ObservableObject { func setActivePrompt(_ prompt: CustomPrompt) { selectedPromptId = prompt.id } + + /// Restores the original prompt ID if it was temporarily changed due to a trigger word + func restoreOriginalPrompt() { + if let originalId = originalSelectedPromptId { + selectedPromptId = originalId + originalSelectedPromptId = nil + logger.notice("🔄 Restored original enhancement mode after trigger word activation") + } + } } enum EnhancementError: Error { diff --git a/VoiceInk/Views/EnhancementSettingsView.swift b/VoiceInk/Views/EnhancementSettingsView.swift index 73a334b..b8b4cf6 100644 --- a/VoiceInk/Views/EnhancementSettingsView.swift +++ b/VoiceInk/Views/EnhancementSettingsView.swift @@ -1,7 +1,7 @@ import SwiftUI extension CustomPrompt { - func promptIcon(isSelected: Bool, onTap: @escaping () -> Void, onEdit: ((CustomPrompt) -> Void)? = nil, onDelete: ((CustomPrompt) -> Void)? = nil) -> some View { + func promptIcon(isSelected: Bool, onTap: @escaping () -> Void, onEdit: ((CustomPrompt) -> Void)? = nil, onDelete: ((CustomPrompt) -> Void)? = nil, assistantTriggerWord: String? = nil) -> some View { VStack(spacing: 8) { ZStack { // Dynamic background with blur effect @@ -89,12 +89,46 @@ extension CustomPrompt { .frame(width: 48, height: 48) // Enhanced title styling - Text(title) - .font(.system(size: 11, weight: .medium)) - .foregroundColor(isSelected ? - .primary : .secondary) - .lineLimit(1) - .frame(maxWidth: 70) + VStack(spacing: 2) { + Text(title) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(isSelected ? + .primary : .secondary) + .lineLimit(1) + .frame(maxWidth: 70) + + // Trigger word section with consistent height + ZStack(alignment: .center) { + if id == PredefinedPrompts.assistantPromptId, let assistantTriggerWord = assistantTriggerWord, !assistantTriggerWord.isEmpty { + // Show the global assistant trigger word for the Assistant Mode + HStack(spacing: 2) { + Image(systemName: "mic.fill") + .font(.system(size: 7)) + .foregroundColor(isSelected ? .accentColor.opacity(0.9) : .secondary.opacity(0.7)) + + Text("\"\(assistantTriggerWord)...\"") + .font(.system(size: 8, weight: .regular)) + .foregroundColor(isSelected ? .primary.opacity(0.8) : .secondary.opacity(0.7)) + .lineLimit(1) + } + .frame(maxWidth: 70) + } else if let triggerWord = triggerWord, !triggerWord.isEmpty { + // Show custom trigger words for Enhancement Modes + HStack(spacing: 2) { + Image(systemName: "mic.fill") + .font(.system(size: 7)) + .foregroundColor(isSelected ? .accentColor.opacity(0.9) : .secondary.opacity(0.7)) + + Text("\"\(triggerWord)...\"") + .font(.system(size: 8, weight: .regular)) + .foregroundColor(isSelected ? .primary.opacity(0.8) : .secondary.opacity(0.7)) + .lineLimit(1) + } + .frame(maxWidth: 70) + } + } + .frame(height: 16) // Fixed height for all modes, with or without trigger words + } } .padding(.horizontal, 4) .padding(.vertical, 6) @@ -247,7 +281,8 @@ struct EnhancementSettingsView: View { enhancementService.setActivePrompt(prompt) }}, onEdit: { selectedPromptForEdit = $0 }, - onDelete: { enhancementService.deletePrompt($0) } + onDelete: { enhancementService.deletePrompt($0) }, + assistantTriggerWord: enhancementService.assistantTriggerWord ) } } diff --git a/VoiceInk/Views/PromptEditorView.swift b/VoiceInk/Views/PromptEditorView.swift index bf9157b..8ebf3be 100644 --- a/VoiceInk/Views/PromptEditorView.swift +++ b/VoiceInk/Views/PromptEditorView.swift @@ -24,6 +24,7 @@ struct PromptEditorView: View { @State private var promptText: String @State private var selectedIcon: PromptIcon @State private var description: String + @State private var triggerWord: String @State private var showingPredefinedPrompts = false init(mode: Mode) { @@ -34,11 +35,13 @@ struct PromptEditorView: View { _promptText = State(initialValue: "") _selectedIcon = State(initialValue: .documentFill) _description = State(initialValue: "") + _triggerWord = State(initialValue: "") case .edit(let prompt): _title = State(initialValue: prompt.title) _promptText = State(initialValue: prompt.promptText) _selectedIcon = State(initialValue: prompt.icon) _description = State(initialValue: prompt.description ?? "") + _triggerWord = State(initialValue: prompt.triggerWord ?? "") } } @@ -140,6 +143,22 @@ struct PromptEditorView: View { } .padding(.horizontal) + // Trigger Word Field + VStack(alignment: .leading, spacing: 8) { + Text("Trigger Word") + .font(.headline) + .foregroundColor(.secondary) + + Text("Add a custom word to activate this mode by voice (optional)") + .font(.subheadline) + .foregroundColor(.secondary) + + TextField("Enter a trigger word", text: $triggerWord) + .textFieldStyle(.roundedBorder) + .font(.body) + } + .padding(.horizontal) + // Prompt Text Section with improved styling VStack(alignment: .leading, spacing: 8) { Text("Mode Instructions") @@ -211,7 +230,8 @@ struct PromptEditorView: View { title: title, promptText: promptText, icon: selectedIcon, - description: description.isEmpty ? nil : description + description: description.isEmpty ? nil : description, + triggerWord: triggerWord.isEmpty ? nil : triggerWord ) case .edit(let prompt): let updatedPrompt = CustomPrompt( @@ -220,7 +240,8 @@ struct PromptEditorView: View { promptText: promptText, isActive: prompt.isActive, icon: selectedIcon, - description: description.isEmpty ? nil : description + description: description.isEmpty ? nil : description, + triggerWord: triggerWord.isEmpty ? nil : triggerWord ) enhancementService.updatePrompt(updatedPrompt) }