Added support for multiple trigger words

This commit is contained in:
Beingpax 2025-05-29 13:32:09 +05:45
parent d120e6669a
commit cb7a7461a1
5 changed files with 260 additions and 62 deletions

View File

@ -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)
}

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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)
}
}
}