538 lines
23 KiB
Swift
538 lines
23 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
|
|
var onDismiss: (() -> Void)?
|
|
@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 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, onDismiss: (() -> Void)? = nil) {
|
|
self.mode = mode
|
|
self.onDismiss = onDismiss
|
|
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) {
|
|
HStack(spacing: 12) {
|
|
Text(isEditingPredefinedPrompt ? "Edit Trigger Words" : (mode == .add ? "New Prompt" : "Edit Prompt"))
|
|
.font(.headline)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.primary)
|
|
|
|
Spacer()
|
|
|
|
Button(action: {
|
|
if let onDismiss = onDismiss {
|
|
onDismiss()
|
|
} else {
|
|
dismiss()
|
|
}
|
|
}) {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
.padding(6)
|
|
.background(Color.secondary.opacity(0.1))
|
|
.clipShape(Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Close")
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 16)
|
|
.background(Color(NSColor.windowBackgroundColor))
|
|
.overlay(
|
|
Divider().opacity(0.5), alignment: .bottom
|
|
)
|
|
|
|
ScrollView {
|
|
VStack(spacing: 24) {
|
|
if isEditingPredefinedPrompt {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("Editing: \(title)")
|
|
.font(.title3)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(.primary)
|
|
|
|
Text("You can only customize the trigger words for system prompts.")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
|
|
TriggerWordsEditor(triggerWords: $triggerWords)
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 20)
|
|
|
|
} else {
|
|
VStack(spacing: 24) {
|
|
HStack(alignment: .top, spacing: 16) {
|
|
Button(action: { showingIconPicker = true }) {
|
|
Image(systemName: selectedIcon)
|
|
.font(.system(size: 24))
|
|
.foregroundColor(.primary)
|
|
.frame(width: 56, height: 56)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(10)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.popover(isPresented: $showingIconPicker, arrowEdge: .bottom) {
|
|
IconPickerPopover(selectedIcon: $selectedIcon, isPresented: $showingIconPicker)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Title")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
|
|
TextField("Prompt Name", text: $title)
|
|
.textFieldStyle(.plain)
|
|
.font(.system(size: 14))
|
|
.padding(8)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
|
.background(Color(NSColor.controlBackgroundColor).cornerRadius(6))
|
|
)
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Description")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
|
|
TextField("Brief description of what this prompt does", text: $description)
|
|
.textFieldStyle(.plain)
|
|
.font(.system(size: 13))
|
|
.padding(8)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
|
.background(Color(NSColor.controlBackgroundColor).cornerRadius(6))
|
|
)
|
|
}
|
|
|
|
Divider().padding(.vertical, 4)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(spacing: 6) {
|
|
Text("Instructions")
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
|
|
InfoTip(
|
|
title: "Instructions",
|
|
message: "Define how AI should process the text."
|
|
)
|
|
}
|
|
|
|
ZStack(alignment: .topLeading) {
|
|
TextEditor(text: $promptText)
|
|
.font(.system(.body, design: .monospaced))
|
|
.frame(minHeight: 180)
|
|
.padding(8)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(6)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
|
)
|
|
|
|
if promptText.isEmpty {
|
|
Text("Enter your custom prompt instructions here...")
|
|
.font(.system(.body, design: .monospaced))
|
|
.foregroundColor(.secondary.opacity(0.5))
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 12)
|
|
.allowsHitTesting(false )
|
|
}
|
|
}
|
|
|
|
if !isEditingPredefinedPrompt {
|
|
HStack(spacing: 8) {
|
|
Toggle("Use System Template", isOn: $useSystemInstructions)
|
|
.toggleStyle(.switch)
|
|
.controlSize(.small)
|
|
|
|
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(.top, 4)
|
|
}
|
|
}
|
|
|
|
Divider().padding(.vertical, 4)
|
|
|
|
TriggerWordsEditor(triggerWords: $triggerWords)
|
|
|
|
if case .add = mode, !isEditingPredefinedPrompt {
|
|
HStack {
|
|
Menu {
|
|
ForEach(PromptTemplates.all, id: \.title) { template in
|
|
Button {
|
|
title = template.title
|
|
promptText = template.promptText
|
|
selectedIcon = template.icon
|
|
description = template.description
|
|
} label: {
|
|
HStack {
|
|
Text(template.title)
|
|
Spacer()
|
|
Image(systemName: template.icon)
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "sparkles")
|
|
.foregroundColor(.accentColor)
|
|
Text("Start with Template")
|
|
.foregroundColor(.primary)
|
|
Image(systemName: "chevron.down")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.vertical, 6)
|
|
.padding(.horizontal, 10)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(6)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(Color.secondary.opacity(0.15), lineWidth: 1)
|
|
)
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
.fixedSize()
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 20)
|
|
}
|
|
}
|
|
}
|
|
|
|
VStack(spacing: 0) {
|
|
Divider()
|
|
HStack {
|
|
Button("Cancel") {
|
|
if let onDismiss = onDismiss {
|
|
onDismiss()
|
|
} else {
|
|
dismiss()
|
|
}
|
|
}
|
|
.keyboardShortcut(.escape, modifiers: [])
|
|
.buttonStyle(.plain)
|
|
.foregroundColor(.secondary)
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
save()
|
|
if let onDismiss = onDismiss {
|
|
onDismiss()
|
|
} else {
|
|
dismiss()
|
|
}
|
|
} label: {
|
|
Text("Save Changes")
|
|
.frame(minWidth: 100)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(isEditingPredefinedPrompt ? false : (title.isEmpty || promptText.isEmpty))
|
|
.keyboardShortcut(.return, modifiers: .command)
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 16)
|
|
.background(Color(NSColor.windowBackgroundColor))
|
|
}
|
|
}
|
|
.frame(minWidth: 400, minHeight: 500)
|
|
.background(Color(NSColor.windowBackgroundColor))
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Trigger Words Editor
|
|
struct TriggerWordsEditor: View {
|
|
@Binding var triggerWords: [String]
|
|
@State private var newTriggerWord: String = ""
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(spacing: 6) {
|
|
Text("Trigger Words")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
|
|
InfoTip(
|
|
title: "Trigger Words",
|
|
message: "Add multiple words that can activate this prompt."
|
|
)
|
|
}
|
|
|
|
HStack {
|
|
TextField("Add trigger word (e.g. 'summarize')", text: $newTriggerWord)
|
|
.textFieldStyle(.plain)
|
|
.font(.system(size: 13))
|
|
.padding(6)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
|
.background(Color(NSColor.controlBackgroundColor).cornerRadius(6))
|
|
)
|
|
.onSubmit {
|
|
addTriggerWord()
|
|
}
|
|
|
|
Button(action: { addTriggerWord() }) {
|
|
Image(systemName: "plus")
|
|
.font(.system(size: 12, weight: .bold))
|
|
.frame(width: 26, height: 26)
|
|
.background(Color.accentColor.opacity(0.1))
|
|
.foregroundColor(.accentColor)
|
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(newTriggerWord.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
}
|
|
|
|
if !triggerWords.isEmpty {
|
|
TagLayout(alignment: .leading, spacing: 6) {
|
|
ForEach(triggerWords, id: \.self) { word in
|
|
TriggerWordItemView(word: word) {
|
|
triggerWords.removeAll { $0 == word }
|
|
}
|
|
}
|
|
}
|
|
.padding(.top, 4)
|
|
} else {
|
|
Text("No trigger words added")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary.opacity(0.7))
|
|
.italic()
|
|
.padding(.top, 2)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func addTriggerWord() {
|
|
let trimmedWord = newTriggerWord.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmedWord.isEmpty else { return }
|
|
|
|
let lowerCaseWord = trimmedWord.lowercased()
|
|
guard !triggerWords.contains(where: { $0.lowercased() == lowerCaseWord }) else { return }
|
|
|
|
triggerWords.append(trimmedWord)
|
|
newTriggerWord = ""
|
|
}
|
|
}
|
|
|
|
// MARK: - Trigger Word Item
|
|
struct TriggerWordItemView: View {
|
|
let word: String
|
|
let onDelete: () -> Void
|
|
@State private var isHovered = false
|
|
|
|
var body: some View {
|
|
HStack(spacing: 4) {
|
|
Text(word)
|
|
.font(.system(size: 12))
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
.frame(maxWidth: 120, alignment: .leading)
|
|
.foregroundColor(.primary)
|
|
|
|
Button(action: onDelete) {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.leading, 2)
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(4)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Tag Layout
|
|
struct TagLayout: Layout {
|
|
var alignment: Alignment = .leading
|
|
var spacing: CGFloat = 8
|
|
|
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
|
let maxWidth = proposal.width ?? .infinity
|
|
var height: CGFloat = 0
|
|
var currentRowWidth: CGFloat = 0
|
|
|
|
for subview in subviews {
|
|
let size = subview.sizeThatFits(.unspecified)
|
|
|
|
if currentRowWidth + size.width > maxWidth {
|
|
// New row
|
|
height += size.height + spacing
|
|
currentRowWidth = size.width + spacing
|
|
} else {
|
|
// Same row
|
|
currentRowWidth += size.width + spacing
|
|
}
|
|
|
|
if height == 0 {
|
|
height = size.height
|
|
}
|
|
}
|
|
|
|
return CGSize(width: maxWidth, height: height)
|
|
}
|
|
|
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
|
var x = bounds.minX
|
|
var y = bounds.minY
|
|
let maxHeight = subviews.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
|
|
|
|
for subview in subviews {
|
|
let size = subview.sizeThatFits(.unspecified)
|
|
|
|
if x + size.width > bounds.maxX {
|
|
x = bounds.minX
|
|
y += maxHeight + spacing
|
|
}
|
|
|
|
subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
|
x += size.width + spacing
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Icon Picker
|
|
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)
|
|
}
|
|
}
|