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
This commit is contained in:
parent
f91362b847
commit
2caa8eb4ad
@ -82,6 +82,7 @@ struct CustomPrompt: Identifiable, Codable, Equatable {
|
|||||||
let icon: PromptIcon
|
let icon: PromptIcon
|
||||||
let description: String?
|
let description: String?
|
||||||
let isPredefined: Bool
|
let isPredefined: Bool
|
||||||
|
let triggerWord: String?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
@ -90,7 +91,8 @@ struct CustomPrompt: Identifiable, Codable, Equatable {
|
|||||||
isActive: Bool = false,
|
isActive: Bool = false,
|
||||||
icon: PromptIcon = .documentFill,
|
icon: PromptIcon = .documentFill,
|
||||||
description: String? = nil,
|
description: String? = nil,
|
||||||
isPredefined: Bool = false
|
isPredefined: Bool = false,
|
||||||
|
triggerWord: String? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
@ -99,5 +101,6 @@ struct CustomPrompt: Identifiable, Codable, Equatable {
|
|||||||
self.icon = icon
|
self.icon = icon
|
||||||
self.description = description
|
self.description = description
|
||||||
self.isPredefined = isPredefined
|
self.isPredefined = isPredefined
|
||||||
|
self.triggerWord = triggerWord
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -79,6 +79,9 @@ class AIEnhancementService: ObservableObject {
|
|||||||
private var lastRequestTime: Date?
|
private var lastRequestTime: Date?
|
||||||
private let modelContext: ModelContext
|
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) {
|
init(aiService: AIService = AIService(), modelContext: ModelContext) {
|
||||||
self.aiService = aiService
|
self.aiService = aiService
|
||||||
self.modelContext = modelContext
|
self.modelContext = modelContext
|
||||||
@ -144,7 +147,39 @@ class AIEnhancementService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func determineMode(text: String) -> EnhancementMode {
|
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 {
|
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 {
|
guard isConfigured else {
|
||||||
logger.error("AI Enhancement: API not configured")
|
logger.error("AI Enhancement: API not configured")
|
||||||
throw EnhancementError.notConfigured
|
throw EnhancementError.notConfigured
|
||||||
@ -198,7 +233,6 @@ class AIEnhancementService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let formattedText = "\n<TRANSCRIPT>\n\(text)\n</TRANSCRIPT>"
|
let formattedText = "\n<TRANSCRIPT>\n\(text)\n</TRANSCRIPT>"
|
||||||
let mode = determineMode(text: text)
|
|
||||||
let systemMessage = getSystemMessage(for: mode)
|
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)")
|
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 {
|
} catch {
|
||||||
if retryCount < maxRetries {
|
if retryCount < maxRetries {
|
||||||
try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(retryCount)) * 1_000_000_000))
|
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
|
throw EnhancementError.networkError
|
||||||
}
|
}
|
||||||
@ -347,7 +381,7 @@ class AIEnhancementService: ObservableObject {
|
|||||||
} catch {
|
} catch {
|
||||||
if retryCount < maxRetries {
|
if retryCount < maxRetries {
|
||||||
try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(retryCount)) * 1_000_000_000))
|
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
|
throw EnhancementError.networkError
|
||||||
}
|
}
|
||||||
@ -410,7 +444,7 @@ class AIEnhancementService: ObservableObject {
|
|||||||
} catch {
|
} catch {
|
||||||
if retryCount < maxRetries {
|
if retryCount < maxRetries {
|
||||||
try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(retryCount)) * 1_000_000_000))
|
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
|
throw EnhancementError.networkError
|
||||||
}
|
}
|
||||||
@ -419,11 +453,41 @@ class AIEnhancementService: ObservableObject {
|
|||||||
|
|
||||||
func enhance(_ text: String) async throws -> String {
|
func enhance(_ text: String) async throws -> String {
|
||||||
logger.notice("🚀 Starting AI enhancement for text (\(text.count) characters)")
|
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
|
var retryCount = 0
|
||||||
while retryCount < maxRetries {
|
while retryCount < maxRetries {
|
||||||
do {
|
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)")
|
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
|
return result
|
||||||
} catch EnhancementError.rateLimitExceeded where retryCount < maxRetries - 1 {
|
} catch EnhancementError.rateLimitExceeded where retryCount < maxRetries - 1 {
|
||||||
logger.notice("⚠️ Rate limit exceeded, retrying AI enhancement (attempt \(retryCount + 1) of \(self.maxRetries))")
|
logger.notice("⚠️ Rate limit exceeded, retrying AI enhancement (attempt \(retryCount + 1) of \(self.maxRetries))")
|
||||||
@ -432,10 +496,22 @@ class AIEnhancementService: ObservableObject {
|
|||||||
continue
|
continue
|
||||||
} catch {
|
} catch {
|
||||||
logger.notice("❌ AI enhancement failed: \(error.localizedDescription)")
|
logger.notice("❌ AI enhancement failed: \(error.localizedDescription)")
|
||||||
|
|
||||||
|
// Even if enhancement fails, we should restore the original prompt
|
||||||
|
Task { @MainActor in
|
||||||
|
self.restoreOriginalPrompt()
|
||||||
|
}
|
||||||
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.notice("❌ AI enhancement failed: maximum retries exceeded")
|
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
|
throw EnhancementError.maxRetriesExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -449,8 +525,8 @@ class AIEnhancementService: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func addPrompt(title: String, promptText: String, icon: PromptIcon = .documentFill, description: String? = nil) {
|
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)
|
let newPrompt = CustomPrompt(title: title, promptText: promptText, icon: icon, description: description, isPredefined: false, triggerWord: triggerWord)
|
||||||
customPrompts.append(newPrompt)
|
customPrompts.append(newPrompt)
|
||||||
if customPrompts.count == 1 {
|
if customPrompts.count == 1 {
|
||||||
selectedPromptId = newPrompt.id
|
selectedPromptId = newPrompt.id
|
||||||
@ -477,6 +553,15 @@ class AIEnhancementService: ObservableObject {
|
|||||||
func setActivePrompt(_ prompt: CustomPrompt) {
|
func setActivePrompt(_ prompt: CustomPrompt) {
|
||||||
selectedPromptId = prompt.id
|
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 {
|
enum EnhancementError: Error {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension CustomPrompt {
|
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) {
|
VStack(spacing: 8) {
|
||||||
ZStack {
|
ZStack {
|
||||||
// Dynamic background with blur effect
|
// Dynamic background with blur effect
|
||||||
@ -89,12 +89,46 @@ extension CustomPrompt {
|
|||||||
.frame(width: 48, height: 48)
|
.frame(width: 48, height: 48)
|
||||||
|
|
||||||
// Enhanced title styling
|
// Enhanced title styling
|
||||||
Text(title)
|
VStack(spacing: 2) {
|
||||||
.font(.system(size: 11, weight: .medium))
|
Text(title)
|
||||||
.foregroundColor(isSelected ?
|
.font(.system(size: 11, weight: .medium))
|
||||||
.primary : .secondary)
|
.foregroundColor(isSelected ?
|
||||||
.lineLimit(1)
|
.primary : .secondary)
|
||||||
.frame(maxWidth: 70)
|
.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(.horizontal, 4)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
@ -247,7 +281,8 @@ struct EnhancementSettingsView: View {
|
|||||||
enhancementService.setActivePrompt(prompt)
|
enhancementService.setActivePrompt(prompt)
|
||||||
}},
|
}},
|
||||||
onEdit: { selectedPromptForEdit = $0 },
|
onEdit: { selectedPromptForEdit = $0 },
|
||||||
onDelete: { enhancementService.deletePrompt($0) }
|
onDelete: { enhancementService.deletePrompt($0) },
|
||||||
|
assistantTriggerWord: enhancementService.assistantTriggerWord
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,7 @@ struct PromptEditorView: View {
|
|||||||
@State private var promptText: String
|
@State private var promptText: String
|
||||||
@State private var selectedIcon: PromptIcon
|
@State private var selectedIcon: PromptIcon
|
||||||
@State private var description: String
|
@State private var description: String
|
||||||
|
@State private var triggerWord: String
|
||||||
@State private var showingPredefinedPrompts = false
|
@State private var showingPredefinedPrompts = false
|
||||||
|
|
||||||
init(mode: Mode) {
|
init(mode: Mode) {
|
||||||
@ -34,11 +35,13 @@ struct PromptEditorView: View {
|
|||||||
_promptText = State(initialValue: "")
|
_promptText = State(initialValue: "")
|
||||||
_selectedIcon = State(initialValue: .documentFill)
|
_selectedIcon = State(initialValue: .documentFill)
|
||||||
_description = State(initialValue: "")
|
_description = State(initialValue: "")
|
||||||
|
_triggerWord = State(initialValue: "")
|
||||||
case .edit(let prompt):
|
case .edit(let prompt):
|
||||||
_title = State(initialValue: prompt.title)
|
_title = State(initialValue: prompt.title)
|
||||||
_promptText = State(initialValue: prompt.promptText)
|
_promptText = State(initialValue: prompt.promptText)
|
||||||
_selectedIcon = State(initialValue: prompt.icon)
|
_selectedIcon = State(initialValue: prompt.icon)
|
||||||
_description = State(initialValue: prompt.description ?? "")
|
_description = State(initialValue: prompt.description ?? "")
|
||||||
|
_triggerWord = State(initialValue: prompt.triggerWord ?? "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,6 +143,22 @@ struct PromptEditorView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.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
|
// Prompt Text Section with improved styling
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Mode Instructions")
|
Text("Mode Instructions")
|
||||||
@ -211,7 +230,8 @@ struct PromptEditorView: View {
|
|||||||
title: title,
|
title: title,
|
||||||
promptText: promptText,
|
promptText: promptText,
|
||||||
icon: selectedIcon,
|
icon: selectedIcon,
|
||||||
description: description.isEmpty ? nil : description
|
description: description.isEmpty ? nil : description,
|
||||||
|
triggerWord: triggerWord.isEmpty ? nil : triggerWord
|
||||||
)
|
)
|
||||||
case .edit(let prompt):
|
case .edit(let prompt):
|
||||||
let updatedPrompt = CustomPrompt(
|
let updatedPrompt = CustomPrompt(
|
||||||
@ -220,7 +240,8 @@ struct PromptEditorView: View {
|
|||||||
promptText: promptText,
|
promptText: promptText,
|
||||||
isActive: prompt.isActive,
|
isActive: prompt.isActive,
|
||||||
icon: selectedIcon,
|
icon: selectedIcon,
|
||||||
description: description.isEmpty ? nil : description
|
description: description.isEmpty ? nil : description,
|
||||||
|
triggerWord: triggerWord.isEmpty ? nil : triggerWord
|
||||||
)
|
)
|
||||||
enhancementService.updatePrompt(updatedPrompt)
|
enhancementService.updatePrompt(updatedPrompt)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user