510 lines
21 KiB
Swift
510 lines
21 KiB
Swift
import SwiftUI
|
|
|
|
struct PromptEditorView: View {
|
|
enum Mode {
|
|
case add
|
|
case edit(CustomPrompt)
|
|
|
|
static func == (lhs: Mode, rhs: Mode) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.add, .add):
|
|
return true
|
|
case let (.edit(prompt1), .edit(prompt2)):
|
|
return prompt1.id == prompt2.id
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
let mode: Mode
|
|
@Environment(\.dismiss) private var dismiss
|
|
@EnvironmentObject private var enhancementService: AIEnhancementService
|
|
@State private var title: String
|
|
@State private var promptText: String
|
|
@State private var selectedIcon: PromptIcon
|
|
@State private var description: String
|
|
@State private var triggerWords: [String]
|
|
@State private var showingPredefinedPrompts = false
|
|
@State private var useSystemInstructions: Bool
|
|
@State private var showingIconPicker = false
|
|
|
|
private var isEditingPredefinedPrompt: Bool {
|
|
if case .edit(let prompt) = mode {
|
|
return prompt.isPredefined
|
|
}
|
|
return false
|
|
}
|
|
|
|
init(mode: Mode) {
|
|
self.mode = mode
|
|
switch mode {
|
|
case .add:
|
|
_title = State(initialValue: "")
|
|
_promptText = State(initialValue: "")
|
|
_selectedIcon = State(initialValue: "doc.text.fill")
|
|
_description = State(initialValue: "")
|
|
_triggerWords = State(initialValue: [])
|
|
_useSystemInstructions = State(initialValue: true)
|
|
case .edit(let prompt):
|
|
_title = State(initialValue: prompt.title)
|
|
_promptText = State(initialValue: prompt.promptText)
|
|
_selectedIcon = State(initialValue: prompt.icon)
|
|
_description = State(initialValue: prompt.description ?? "")
|
|
_triggerWords = State(initialValue: prompt.triggerWords)
|
|
_useSystemInstructions = State(initialValue: prompt.useSystemInstructions)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Header with modern styling
|
|
HStack {
|
|
Text(isEditingPredefinedPrompt ? "Edit Trigger Words" : (mode == .add ? "New Prompt" : "Edit Prompt"))
|
|
.font(.title2)
|
|
.fontWeight(.bold)
|
|
Spacer()
|
|
HStack(spacing: 12) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundColor(.secondary)
|
|
|
|
Button {
|
|
save()
|
|
dismiss()
|
|
} label: {
|
|
Text("Save")
|
|
.fontWeight(.medium)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(isEditingPredefinedPrompt ? false : (title.isEmpty || promptText.isEmpty))
|
|
.keyboardShortcut(.return, modifiers: .command)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(
|
|
Color(NSColor.windowBackgroundColor)
|
|
.shadow(color: .black.opacity(0.1), radius: 8, y: 2)
|
|
)
|
|
|
|
ScrollView {
|
|
VStack(spacing: 24) {
|
|
if isEditingPredefinedPrompt {
|
|
// Simplified view for predefined prompts - only trigger word editing
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("Editing: \(title)")
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.primary)
|
|
.padding(.horizontal)
|
|
.padding(.top, 8)
|
|
|
|
Text("You can only customize the trigger words for system prompts.")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.padding(.horizontal)
|
|
|
|
// Trigger Words Field using reusable component
|
|
TriggerWordsEditor(triggerWords: $triggerWords)
|
|
.padding(.horizontal)
|
|
}
|
|
.padding(.vertical, 20)
|
|
|
|
} else {
|
|
// Full editing interface for custom prompts
|
|
// Title and Icon Section with improved layout
|
|
HStack(spacing: 20) {
|
|
// Title Field
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Title")
|
|
.font(.headline)
|
|
.foregroundColor(.secondary)
|
|
TextField("Enter a short, descriptive title", text: $title)
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.body)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
|
|
// Icon Selector with preview
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Icon")
|
|
.font(.headline)
|
|
.foregroundColor(.secondary)
|
|
|
|
// Preview of selected icon - clickable to open popover (square button)
|
|
Button(action: {
|
|
showingIconPicker = true
|
|
}) {
|
|
Image(systemName: selectedIcon)
|
|
.font(.system(size: 20))
|
|
.foregroundColor(.primary)
|
|
.frame(width: 48, height: 48)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(8)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.popover(isPresented: $showingIconPicker, arrowEdge: .bottom) {
|
|
IconPickerPopover(selectedIcon: $selectedIcon, isPresented: $showingIconPicker)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.top, 8)
|
|
|
|
// Description Field
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Description")
|
|
.font(.headline)
|
|
.foregroundColor(.secondary)
|
|
|
|
Text("Add a brief description of what this prompt does")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
|
|
TextField("Enter a description", text: $description)
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.body)
|
|
}
|
|
.padding(.horizontal)
|
|
|
|
// Prompt Text Section with improved styling
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Prompt Instructions")
|
|
.font(.headline)
|
|
.foregroundColor(.secondary)
|
|
|
|
Text("Define how AI should enhance your transcriptions")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
|
|
if !isEditingPredefinedPrompt {
|
|
HStack(spacing: 8) {
|
|
Toggle("Use System Instructions", isOn: $useSystemInstructions)
|
|
|
|
InfoTip(
|
|
title: "System Instructions",
|
|
message: "If enabled, your instructions are combined with a general-purpose template to improve transcription quality.\n\nDisable for full control over the AI's system prompt (for advanced users)."
|
|
)
|
|
}
|
|
.padding(.bottom, 4)
|
|
}
|
|
|
|
TextEditor(text: $promptText)
|
|
.font(.system(.body, design: .monospaced))
|
|
.frame(minHeight: 200)
|
|
.padding(12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(Color(NSColor.textBackgroundColor))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
|
)
|
|
}
|
|
.padding(.horizontal)
|
|
|
|
// Trigger Words Field using reusable component
|
|
TriggerWordsEditor(triggerWords: $triggerWords)
|
|
.padding(.horizontal)
|
|
|
|
if case .add = mode {
|
|
// Templates Section with modern styling
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("Start with a Predefined Template")
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.primary)
|
|
|
|
let columns = [
|
|
GridItem(.flexible(), spacing: 16),
|
|
GridItem(.flexible(), spacing: 16)
|
|
]
|
|
|
|
LazyVGrid(columns: columns, spacing: 16) {
|
|
ForEach(PromptTemplates.all) { template in
|
|
CleanTemplateButton(prompt: template) {
|
|
title = template.title
|
|
promptText = template.promptText
|
|
selectedIcon = template.icon
|
|
description = template.description
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 16)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(Color(.windowBackgroundColor).opacity(0.6))
|
|
)
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 20)
|
|
}
|
|
}
|
|
.frame(minWidth: 700, minHeight: 500)
|
|
}
|
|
|
|
private func save() {
|
|
switch mode {
|
|
case .add:
|
|
enhancementService.addPrompt(
|
|
title: title,
|
|
promptText: promptText,
|
|
icon: selectedIcon,
|
|
description: description.isEmpty ? nil : description,
|
|
triggerWords: triggerWords,
|
|
useSystemInstructions: useSystemInstructions
|
|
)
|
|
case .edit(let prompt):
|
|
let updatedPrompt = CustomPrompt(
|
|
id: prompt.id,
|
|
title: prompt.isPredefined ? prompt.title : title,
|
|
promptText: prompt.isPredefined ? prompt.promptText : promptText,
|
|
isActive: prompt.isActive,
|
|
icon: prompt.isPredefined ? prompt.icon : selectedIcon,
|
|
description: prompt.isPredefined ? prompt.description : (description.isEmpty ? nil : description),
|
|
isPredefined: prompt.isPredefined,
|
|
triggerWords: triggerWords,
|
|
useSystemInstructions: useSystemInstructions
|
|
)
|
|
enhancementService.updatePrompt(updatedPrompt)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean template button with minimal styling
|
|
struct CleanTemplateButton: View {
|
|
let prompt: TemplatePrompt
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
// Clean icon design
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(Color.accentColor.opacity(0.15))
|
|
.frame(width: 44, height: 44)
|
|
|
|
Image(systemName: prompt.icon)
|
|
.font(.system(size: 20, weight: .semibold))
|
|
.foregroundColor(.accentColor)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(prompt.title)
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundColor(.primary)
|
|
.lineLimit(1)
|
|
|
|
Text(prompt.description)
|
|
.font(.system(size: 13))
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.leading)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(Color(.controlBackgroundColor))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
// Keep the old TemplateButton for backward compatibility if needed elsewhere
|
|
struct TemplateButton: View {
|
|
let prompt: TemplatePrompt
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack(alignment: .center, spacing: 12) {
|
|
Image(systemName: prompt.icon)
|
|
.font(.system(size: 20, weight: .medium))
|
|
.foregroundColor(.accentColor)
|
|
.frame(width: 28, height: 28)
|
|
.background(Color.accentColor.opacity(0.12))
|
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(prompt.title)
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundColor(.primary)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(12)
|
|
.frame(height: 60)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(10)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.stroke(Color.secondary.opacity(0.18), lineWidth: 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
// 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 = ""
|
|
}
|
|
}
|
|
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Icon Picker Popover - shows icons in a grid format without category labels
|
|
struct IconPickerPopover: View {
|
|
@Binding var selectedIcon: PromptIcon
|
|
@Binding var isPresented: Bool
|
|
|
|
var body: some View {
|
|
let columns = [
|
|
GridItem(.adaptive(minimum: 45, maximum: 52), spacing: 14)
|
|
]
|
|
|
|
ScrollView {
|
|
LazyVGrid(columns: columns, spacing: 14) {
|
|
ForEach(PromptIcon.allCases, id: \.self) { icon in
|
|
Button(action: {
|
|
withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) {
|
|
selectedIcon = icon
|
|
isPresented = false
|
|
}
|
|
}) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(selectedIcon == icon ? Color(NSColor.windowBackgroundColor) : Color(NSColor.controlBackgroundColor))
|
|
.frame(width: 52, height: 52)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke(selectedIcon == icon ? Color(NSColor.separatorColor) : Color.secondary.opacity(0.2), lineWidth: selectedIcon == icon ? 2 : 1)
|
|
)
|
|
|
|
Image(systemName: icon)
|
|
.font(.system(size: 24, weight: .medium))
|
|
.foregroundColor(.primary)
|
|
}
|
|
.scaleEffect(selectedIcon == icon ? 1.1 : 1.0)
|
|
.animation(.spring(response: 0.2, dampingFraction: 0.7), value: selectedIcon == icon)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(20)
|
|
}
|
|
.frame(width: 400, height: 400)
|
|
}
|
|
}
|