From cb7a7461a16494adebf9c4f48c281702116aff56 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Thu, 29 May 2025 13:32:09 +0545 Subject: [PATCH] Added support for multiple trigger words --- VoiceInk/Models/CustomPrompt.swift | 23 ++- VoiceInk/Services/AIEnhancementService.swift | 14 +- .../Services/PromptDetectionService.swift | 38 +++-- .../Services/PromptMigrationService.swift | 104 +++++++++++++ VoiceInk/Views/PromptEditorView.swift | 143 ++++++++++++++---- 5 files changed, 260 insertions(+), 62 deletions(-) create mode 100644 VoiceInk/Services/PromptMigrationService.swift diff --git a/VoiceInk/Models/CustomPrompt.swift b/VoiceInk/Models/CustomPrompt.swift index 4c30e7f..cd2645c 100644 --- a/VoiceInk/Models/CustomPrompt.swift +++ b/VoiceInk/Models/CustomPrompt.swift @@ -83,7 +83,7 @@ struct CustomPrompt: Identifiable, Codable, Equatable { let icon: PromptIcon let description: String? let isPredefined: Bool - let triggerWord: String? + let triggerWords: [String] init( id: UUID = UUID(), @@ -93,7 +93,7 @@ struct CustomPrompt: Identifiable, Codable, Equatable { icon: PromptIcon = .documentFill, description: String? = nil, isPredefined: Bool = false, - triggerWord: String? = nil + triggerWords: [String] = [] ) { self.id = id self.title = title @@ -102,7 +102,7 @@ struct CustomPrompt: Identifiable, Codable, Equatable { self.icon = icon self.description = description self.isPredefined = isPredefined - self.triggerWord = triggerWord + self.triggerWords = triggerWords } } @@ -206,16 +206,23 @@ extension CustomPrompt { // Trigger word section with consistent height ZStack(alignment: .center) { - if let triggerWord = triggerWord, !triggerWord.isEmpty { + if !triggerWords.isEmpty { 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) + if triggerWords.count == 1 { + Text("\"\(triggerWords[0])...\"") + .font(.system(size: 8, weight: .regular)) + .foregroundColor(isSelected ? .primary.opacity(0.8) : .secondary.opacity(0.7)) + .lineLimit(1) + } else { + Text("\"\(triggerWords[0])...\" +\(triggerWords.count - 1)") + .font(.system(size: 8, weight: .regular)) + .foregroundColor(isSelected ? .primary.opacity(0.8) : .secondary.opacity(0.7)) + .lineLimit(1) + } } .frame(maxWidth: 70) } diff --git a/VoiceInk/Services/AIEnhancementService.swift b/VoiceInk/Services/AIEnhancementService.swift index 88ba0d0..d243118 100644 --- a/VoiceInk/Services/AIEnhancementService.swift +++ b/VoiceInk/Services/AIEnhancementService.swift @@ -82,12 +82,8 @@ class AIEnhancementService: ObservableObject { self.useClipboardContext = UserDefaults.standard.bool(forKey: "useClipboardContext") self.useScreenCaptureContext = UserDefaults.standard.bool(forKey: "useScreenCaptureContext") - if let savedPromptsData = UserDefaults.standard.data(forKey: "customPrompts"), - let decodedPrompts = try? JSONDecoder().decode([CustomPrompt].self, from: savedPromptsData) { - self.customPrompts = decodedPrompts - } else { - self.customPrompts = [] - } + // Use migration service to load prompts, preserving existing data + self.customPrompts = PromptMigrationService.migratePromptsIfNeeded() if let savedPromptId = UserDefaults.standard.string(forKey: "selectedPromptId") { self.selectedPromptId = UUID(uuidString: savedPromptId) @@ -455,8 +451,8 @@ class AIEnhancementService: ObservableObject { } } - 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) + func addPrompt(title: String, promptText: String, icon: PromptIcon = .documentFill, description: String? = nil, triggerWords: [String] = []) { + let newPrompt = CustomPrompt(title: title, promptText: promptText, icon: icon, description: description, isPredefined: false, triggerWords: triggerWords) customPrompts.append(newPrompt) if customPrompts.count == 1 { selectedPromptId = newPrompt.id @@ -510,7 +506,7 @@ class AIEnhancementService: ObservableObject { icon: template.icon, description: template.description, isPredefined: true, - triggerWord: updatedPrompt.triggerWord // Preserve user's trigger word + triggerWords: updatedPrompt.triggerWords // Preserve user's trigger words ) customPrompts[existingIndex] = updatedPrompt } else { diff --git a/VoiceInk/Services/PromptDetectionService.swift b/VoiceInk/Services/PromptDetectionService.swift index d2ad3f2..779bfe0 100644 --- a/VoiceInk/Services/PromptDetectionService.swift +++ b/VoiceInk/Services/PromptDetectionService.swift @@ -21,18 +21,17 @@ class PromptDetectionService { let originalPromptId = enhancementService.selectedPromptId for prompt in enhancementService.allPrompts { - if let triggerWord = prompt.triggerWord?.trimmingCharacters(in: .whitespacesAndNewlines), - !triggerWord.isEmpty, - let result = removeTriggerWord(from: text, triggerWord: triggerWord) { - - return PromptDetectionResult( - shouldEnableAI: true, - selectedPromptId: prompt.id, - processedText: result, - detectedTriggerWord: triggerWord, - originalEnhancementState: originalEnhancementState, - originalPromptId: originalPromptId - ) + if !prompt.triggerWords.isEmpty { + if let (detectedWord, processedText) = findMatchingTriggerWord(from: text, triggerWords: prompt.triggerWords) { + return PromptDetectionResult( + shouldEnableAI: true, + selectedPromptId: prompt.id, + processedText: processedText, + detectedTriggerWord: detectedWord, + originalEnhancementState: originalEnhancementState, + originalPromptId: originalPromptId + ) + } } } @@ -105,4 +104,19 @@ class PromptDetectionService { return remainingText } + + private func findMatchingTriggerWord(from text: String, triggerWords: [String]) -> (String, String)? { + let trimmedWords = triggerWords.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + // Sort by length (longest first) to match the most specific trigger word + let sortedTriggerWords = trimmedWords.sorted { $0.count > $1.count } + + for triggerWord in sortedTriggerWords { + if let processedText = removeTriggerWord(from: text, triggerWord: triggerWord) { + return (triggerWord, processedText) + } + } + return nil + } } \ No newline at end of file diff --git a/VoiceInk/Services/PromptMigrationService.swift b/VoiceInk/Services/PromptMigrationService.swift new file mode 100644 index 0000000..907645f --- /dev/null +++ b/VoiceInk/Services/PromptMigrationService.swift @@ -0,0 +1,104 @@ +import Foundation +import os + +class PromptMigrationService { + private let logger = Logger( + subsystem: "com.prakashjoshipax.VoiceInk", + category: "migration" + ) + + private static let migrationVersionKey = "PromptMigrationVersion" + private static let currentMigrationVersion = 1 + + // Legacy CustomPrompt structure for migration + private struct LegacyCustomPrompt: Codable { + let id: UUID + let title: String + let promptText: String + var isActive: Bool + let icon: PromptIcon + let description: String? + let isPredefined: Bool + let triggerWord: String? + } + + static func migratePromptsIfNeeded() -> [CustomPrompt] { + let currentVersion = UserDefaults.standard.integer(forKey: migrationVersionKey) + + if currentVersion < currentMigrationVersion { + let logger = Logger(subsystem: "com.prakashjoshipax.VoiceInk", category: "migration") + logger.notice("Starting prompt migration from version \(currentVersion) to \(currentMigrationVersion)") + + let migratedPrompts = migrateLegacyPrompts() + + // Update migration version + UserDefaults.standard.set(currentMigrationVersion, forKey: migrationVersionKey) + + logger.notice("Prompt migration completed successfully. Migrated \(migratedPrompts.count) prompts") + return migratedPrompts + } + + // No migration needed, load current format + if let savedPromptsData = UserDefaults.standard.data(forKey: "customPrompts"), + let decodedPrompts = try? JSONDecoder().decode([CustomPrompt].self, from: savedPromptsData) { + return decodedPrompts + } + + return [] + } + + private static func migrateLegacyPrompts() -> [CustomPrompt] { + let logger = Logger(subsystem: "com.prakashjoshipax.VoiceInk", category: "migration") + + // Try to load legacy prompts + guard let savedPromptsData = UserDefaults.standard.data(forKey: "customPrompts") else { + logger.notice("No existing prompts found to migrate") + return [] + } + + // First try to decode as new format (in case migration already happened) + if let newFormatPrompts = try? JSONDecoder().decode([CustomPrompt].self, from: savedPromptsData) { + logger.notice("Prompts are already in new format, no migration needed") + return newFormatPrompts + } + + // Try to decode as legacy format + guard let legacyPrompts = try? JSONDecoder().decode([LegacyCustomPrompt].self, from: savedPromptsData) else { + logger.error("Failed to decode legacy prompts, starting with empty array") + return [] + } + + logger.notice("Migrating \(legacyPrompts.count) legacy prompts") + + // Convert legacy prompts to new format + let migratedPrompts = legacyPrompts.map { legacyPrompt in + let triggerWords: [String] = if let triggerWord = legacyPrompt.triggerWord?.trimmingCharacters(in: .whitespacesAndNewlines), + !triggerWord.isEmpty { + [triggerWord] + } else { + [] + } + + return CustomPrompt( + id: legacyPrompt.id, + title: legacyPrompt.title, + promptText: legacyPrompt.promptText, + isActive: legacyPrompt.isActive, + icon: legacyPrompt.icon, + description: legacyPrompt.description, + isPredefined: legacyPrompt.isPredefined, + triggerWords: triggerWords + ) + } + + // Save migrated prompts in new format + if let encoded = try? JSONEncoder().encode(migratedPrompts) { + UserDefaults.standard.set(encoded, forKey: "customPrompts") + logger.notice("Successfully saved migrated prompts") + } else { + logger.error("Failed to save migrated prompts") + } + + return migratedPrompts + } +} \ No newline at end of file diff --git a/VoiceInk/Views/PromptEditorView.swift b/VoiceInk/Views/PromptEditorView.swift index 9b036f1..5cbf354 100644 --- a/VoiceInk/Views/PromptEditorView.swift +++ b/VoiceInk/Views/PromptEditorView.swift @@ -24,7 +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 triggerWords: [String] @State private var showingPredefinedPrompts = false private var isEditingPredefinedPrompt: Bool { @@ -42,13 +42,13 @@ struct PromptEditorView: View { _promptText = State(initialValue: "") _selectedIcon = State(initialValue: .documentFill) _description = State(initialValue: "") - _triggerWord = State(initialValue: "") + _triggerWords = 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 ?? "") + _triggerWords = State(initialValue: prompt.triggerWords) } } @@ -56,7 +56,7 @@ struct PromptEditorView: View { VStack(spacing: 0) { // Header with modern styling HStack { - Text(isEditingPredefinedPrompt ? "Edit Trigger Word" : (mode == .add ? "New Prompt" : "Edit Prompt")) + Text(isEditingPredefinedPrompt ? "Edit Trigger Words" : (mode == .add ? "New Prompt" : "Edit Prompt")) .font(.title2) .fontWeight(.bold) Spacer() @@ -97,22 +97,14 @@ struct PromptEditorView: View { .padding(.horizontal) .padding(.top, 8) - Text("You can only customize the trigger word for system prompts.") + Text("You can only customize the trigger words for system prompts.") .font(.subheadline) .foregroundColor(.secondary) .padding(.horizontal) - // Trigger Word Field with same styling as custom prompts - VStack(alignment: .leading, spacing: 8) { - Text("Trigger Word") - .font(.headline) - .foregroundColor(.secondary) - - TextField("Enter a trigger word (optional)", text: $triggerWord) - .textFieldStyle(.roundedBorder) - .font(.body) - } - .padding(.horizontal) + // Trigger Words Field using reusable component + TriggerWordsEditor(triggerWords: $triggerWords) + .padding(.horizontal) } .padding(.vertical, 20) @@ -206,21 +198,9 @@ struct PromptEditorView: View { } .padding(.horizontal) - // Trigger Word Field - VStack(alignment: .leading, spacing: 8) { - Text("Trigger Word") - .font(.headline) - .foregroundColor(.secondary) - - Text("Optional word to quickly activate this prompt") - .font(.subheadline) - .foregroundColor(.secondary) - - TextField("Enter a trigger word (optional)", text: $triggerWord) - .textFieldStyle(.roundedBorder) - .font(.body) - } - .padding(.horizontal) + // Trigger Words Field using reusable component + TriggerWordsEditor(triggerWords: $triggerWords) + .padding(.horizontal) if case .add = mode { // Templates Section with modern styling @@ -270,7 +250,7 @@ struct PromptEditorView: View { promptText: promptText, icon: selectedIcon, description: description.isEmpty ? nil : description, - triggerWord: triggerWord.isEmpty ? nil : triggerWord + triggerWords: triggerWords ) case .edit(let prompt): let updatedPrompt = CustomPrompt( @@ -281,7 +261,7 @@ struct PromptEditorView: View { icon: prompt.isPredefined ? prompt.icon : selectedIcon, description: prompt.isPredefined ? prompt.description : (description.isEmpty ? nil : description), isPredefined: prompt.isPredefined, - triggerWord: triggerWord.isEmpty ? nil : triggerWord + triggerWords: triggerWords ) enhancementService.updatePrompt(updatedPrompt) } @@ -372,6 +352,62 @@ struct TemplateButton: View { } } +// Reusable Trigger Words Editor Component +struct TriggerWordsEditor: View { + @Binding var triggerWords: [String] + @State private var newTriggerWord: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Trigger Words") + .font(.headline) + .foregroundColor(.secondary) + + Text("Add multiple words that can activate this prompt") + .font(.subheadline) + .foregroundColor(.secondary) + + // Display existing trigger words as tags + if !triggerWords.isEmpty { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 140, maximum: 220))], spacing: 8) { + ForEach(triggerWords, id: \.self) { word in + TriggerWordItemView(word: word) { + triggerWords.removeAll { $0 == word } + } + } + } + } + + // Input for new trigger word + HStack { + TextField("Add trigger word", text: $newTriggerWord) + .textFieldStyle(.roundedBorder) + .font(.body) + .onSubmit { + addTriggerWord() + } + + Button("Add") { + addTriggerWord() + } + .disabled(newTriggerWord.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + + private func addTriggerWord() { + let trimmedWord = newTriggerWord.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedWord.isEmpty else { return } + + // Check for duplicates (case insensitive) + let lowerCaseWord = trimmedWord.lowercased() + guard !triggerWords.contains(where: { $0.lowercased() == lowerCaseWord }) else { return } + + triggerWords.append(trimmedWord) + newTriggerWord = "" + } +} + // Icon menu content for better organization struct IconMenuContent: View { @Binding var selectedIcon: PromptIcon @@ -409,4 +445,45 @@ struct IconMenuSection: View { } } } +} + +struct TriggerWordItemView: View { + let word: String + let onDelete: () -> Void + @State private var isHovered = false + + var body: some View { + HStack(spacing: 6) { + Text(word) + .font(.system(size: 13)) + .lineLimit(1) + .foregroundColor(.primary) + + Spacer(minLength: 8) + + Button(action: onDelete) { + Image(systemName: "xmark.circle.fill") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(isHovered ? .red : .secondary) + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.borderless) + .help("Remove word") + .onHover { hover in + withAnimation(.easeInOut(duration: 0.2)) { + isHovered = hover + } + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background { + RoundedRectangle(cornerRadius: 6) + .fill(Color(.windowBackgroundColor).opacity(0.4)) + } + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(Color.secondary.opacity(0.2), lineWidth: 1) + } + } } \ No newline at end of file