Added support for multiple trigger words
This commit is contained in:
parent
d120e6669a
commit
cb7a7461a1
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
104
VoiceInk/Services/PromptMigrationService.swift
Normal file
104
VoiceInk/Services/PromptMigrationService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user