Merge pull request #96 from ckloop/feature/custom-trigger-words

This commit is contained in:
Prakash Joshi Pax 2025-05-28 09:19:36 +05:45 committed by GitHub
commit 3b0c09553b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 164 additions and 20 deletions

View File

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

View File

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

View File

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

View File

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