Merge pull request #96 from ckloop/feature/custom-trigger-words
This commit is contained in:
commit
3b0c09553b
@ -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
|
||||
}
|
||||
}
|
||||
@ -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<TRANSCRIPT>\n\(text)\n</TRANSCRIPT>"
|
||||
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 {
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user