Merge pull request #473 from Beingpax/improve-enhancement-ui
Improve API key management UI and enhance provider integration layout
This commit is contained in:
commit
6a15814bf9
@ -13,48 +13,71 @@ struct APIKeyManagementView: View {
|
||||
@State private var isEditingURL = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Provider Selection
|
||||
Section("AI Provider Integration") {
|
||||
HStack {
|
||||
Picker("AI Provider", selection: $aiService.selectedProvider) {
|
||||
Picker("Provider", selection: $aiService.selectedProvider) {
|
||||
ForEach(AIProvider.allCases.filter { $0 != .elevenLabs && $0 != .deepgram && $0 != .soniox }, id: \.self) { provider in
|
||||
Text(provider.rawValue).tag(provider)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.automatic)
|
||||
.tint(.blue)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Show connected status for all providers
|
||||
if aiService.isAPIKeyValid && aiService.selectedProvider != .ollama {
|
||||
HStack(spacing: 6) {
|
||||
Spacer()
|
||||
Circle()
|
||||
.fill(Color.green)
|
||||
.frame(width: 8, height: 8)
|
||||
Text("Connected to")
|
||||
.font(.caption)
|
||||
Text(aiService.selectedProvider.rawValue)
|
||||
.font(.caption.bold())
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
Text("Connected")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else if aiService.selectedProvider == .ollama {
|
||||
Spacer()
|
||||
if isCheckingOllama {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else if !ollamaModels.isEmpty {
|
||||
Circle()
|
||||
.fill(Color.green)
|
||||
.frame(width: 8, height: 8)
|
||||
Text("Connected")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Circle()
|
||||
.fill(Color.red)
|
||||
.frame(width: 8, height: 8)
|
||||
Text("Disconnected")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.onChange(of: aiService.selectedProvider) { oldValue, newValue in
|
||||
if aiService.selectedProvider == .ollama {
|
||||
checkOllamaConnection()
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// Model Selection
|
||||
if aiService.selectedProvider == .openRouter {
|
||||
HStack {
|
||||
if aiService.availableModels.isEmpty {
|
||||
HStack {
|
||||
Text("No models loaded")
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Button(action: {
|
||||
Task {
|
||||
await aiService.fetchOpenRouterModels()
|
||||
}
|
||||
}) {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Picker("Model", selection: Binding(
|
||||
get: { aiService.currentModel },
|
||||
set: { aiService.selectModel($0) }
|
||||
@ -63,24 +86,22 @@ struct APIKeyManagementView: View {
|
||||
Text(model).tag(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
Task {
|
||||
await aiService.fetchOpenRouterModels()
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Refresh models")
|
||||
}
|
||||
}
|
||||
|
||||
} else if !aiService.availableModels.isEmpty &&
|
||||
aiService.selectedProvider != .ollama &&
|
||||
aiService.selectedProvider != .custom {
|
||||
HStack {
|
||||
Picker("Model", selection: Binding(
|
||||
get: { aiService.currentModel },
|
||||
set: { aiService.selectModel($0) }
|
||||
@ -90,62 +111,27 @@ struct APIKeyManagementView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if aiService.selectedProvider == .ollama {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Header with status
|
||||
HStack {
|
||||
Label("Ollama Configuration", systemImage: "server.rack")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(isCheckingOllama ? Color.orange : (ollamaModels.isEmpty ? Color.red : Color.green))
|
||||
.frame(width: 8, height: 8)
|
||||
Text(isCheckingOllama ? "Checking..." : (ollamaModels.isEmpty ? "Disconnected" : "Connected"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
|
||||
// Server URL
|
||||
HStack {
|
||||
Label("Server URL", systemImage: "link")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Ollama Configuration inline
|
||||
if isEditingURL {
|
||||
HStack {
|
||||
TextField("Base URL", text: $ollamaBaseURL)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.frame(maxWidth: 200)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Button("Save") {
|
||||
aiService.updateOllamaBaseURL(ollamaBaseURL)
|
||||
checkOllamaConnection()
|
||||
isEditingURL = false
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Text(ollamaBaseURL)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Button(action: { isEditingURL = true }) {
|
||||
Image(systemName: "pencil")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.controlSize(.small)
|
||||
|
||||
} else {
|
||||
HStack {
|
||||
Text("Server: \(ollamaBaseURL)")
|
||||
Spacer()
|
||||
Button("Edit") { isEditingURL = true }
|
||||
Button(action: {
|
||||
ollamaBaseURL = "http://localhost:11434"
|
||||
aiService.updateOllamaBaseURL(ollamaBaseURL)
|
||||
@ -153,30 +139,14 @@ struct APIKeyManagementView: View {
|
||||
}) {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundColor(.secondary)
|
||||
.controlSize(.small)
|
||||
.help("Reset to default")
|
||||
}
|
||||
}
|
||||
|
||||
// Model selection and refresh
|
||||
HStack {
|
||||
Label("Model", systemImage: "cpu")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
if !ollamaModels.isEmpty {
|
||||
Divider()
|
||||
|
||||
Spacer()
|
||||
|
||||
if ollamaModels.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
Text("No models available")
|
||||
.foregroundColor(.secondary)
|
||||
.italic()
|
||||
}
|
||||
} else {
|
||||
Picker("", selection: $selectedOllamaModel) {
|
||||
Picker("Model", selection: $selectedOllamaModel) {
|
||||
ForEach(ollamaModels) { model in
|
||||
Text(model.name).tag(model.name)
|
||||
}
|
||||
@ -184,93 +154,33 @@ struct APIKeyManagementView: View {
|
||||
.onChange(of: selectedOllamaModel) { oldValue, newValue in
|
||||
aiService.updateSelectedOllamaModel(newValue)
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 150)
|
||||
}
|
||||
|
||||
Button(action: { checkOllamaConnection() }) {
|
||||
Label(isCheckingOllama ? "Refreshing..." : "Refresh", systemImage: isCheckingOllama ? "arrow.triangle.2.circlepath" : "arrow.clockwise")
|
||||
.font(.caption)
|
||||
}
|
||||
.disabled(isCheckingOllama)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.secondary.opacity(0.03))
|
||||
.cornerRadius(12)
|
||||
|
||||
} else if aiService.selectedProvider == .custom {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Header
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Custom Provider Configuration")
|
||||
.font(.headline)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
.font(.caption)
|
||||
Text("Requires OpenAI-compatible API endpoint")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration Fields
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !aiService.isAPIKeyValid {
|
||||
TextField("API Endpoint URL (e.g., https://api.example.com/v1/chat/completions)", text: $aiService.customBaseURL)
|
||||
// Custom Configuration inline
|
||||
TextField("API Endpoint URL", text: $aiService.customBaseURL)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
TextField("Model Name (e.g., gpt-4o-mini, claude-3-5-sonnet-20240620)", text: $aiService.customModel)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("API Endpoint URL")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Text(aiService.customBaseURL)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Divider()
|
||||
|
||||
Text("Model")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Text(aiService.customModel)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
}
|
||||
TextField("Model Name", text: $aiService.customModel)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Divider()
|
||||
|
||||
if aiService.isAPIKeyValid {
|
||||
Text("API Key")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack {
|
||||
Text(String(repeating: "•", count: 40))
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
Text("API Key Set")
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
Button("Remove Key", role: .destructive) {
|
||||
aiService.clearAPIKey()
|
||||
}) {
|
||||
Label("Remove Key", systemImage: "trash")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
} else {
|
||||
Text("Enter your API Key")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
SecureField("API Key", text: $apiKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
HStack {
|
||||
Button(action: {
|
||||
Button("Verify and Save") {
|
||||
isVerifying = true
|
||||
aiService.saveAPIKey(apiKey) { success, errorMessage in
|
||||
isVerifying = false
|
||||
@ -280,63 +190,46 @@ struct APIKeyManagementView: View {
|
||||
}
|
||||
apiKey = ""
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
if isVerifying {
|
||||
ProgressView()
|
||||
.scaleEffect(0.5)
|
||||
.frame(width: 16, height: 16)
|
||||
} else {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
}
|
||||
Text("Verify and Save")
|
||||
}
|
||||
}
|
||||
.disabled(aiService.customBaseURL.isEmpty || aiService.customModel.isEmpty || apiKey.isEmpty)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.secondary.opacity(0.03))
|
||||
.cornerRadius(12)
|
||||
} else {
|
||||
// API Key Display for other providers if valid
|
||||
// API Key Display for other providers
|
||||
if aiService.isAPIKeyValid {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("API Key")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack {
|
||||
Text(String(repeating: "•", count: 40))
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
Text("API Key")
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
Text("••••••••")
|
||||
.foregroundColor(.secondary)
|
||||
Button("Remove", role: .destructive) {
|
||||
aiService.clearAPIKey()
|
||||
}) {
|
||||
Label("Remove Key", systemImage: "trash")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// API Key Input for other providers
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Enter your API Key")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
SecureField("API Key", text: $apiKey)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
HStack {
|
||||
// Get API Key Link
|
||||
if let url = getAPIKeyURL() {
|
||||
Link(destination: url) {
|
||||
HStack {
|
||||
Image(systemName: "key.fill")
|
||||
Text("Get API Key")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.blue)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 8)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
isVerifying = true
|
||||
aiService.saveAPIKey(apiKey) { success, errorMessage in
|
||||
@ -350,66 +243,12 @@ struct APIKeyManagementView: View {
|
||||
}) {
|
||||
HStack {
|
||||
if isVerifying {
|
||||
ProgressView()
|
||||
.scaleEffect(0.5)
|
||||
.frame(width: 16, height: 16)
|
||||
} else {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
Text("Verify and Save")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text((aiService.selectedProvider == .groq || aiService.selectedProvider == .gemini || aiService.selectedProvider == .cerebras) ? "Free" : "Paid")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.cornerRadius(4)
|
||||
|
||||
if aiService.selectedProvider != .ollama && aiService.selectedProvider != .custom {
|
||||
Button {
|
||||
let url = switch aiService.selectedProvider {
|
||||
case .groq:
|
||||
URL(string: "https://console.groq.com/keys")!
|
||||
case .openAI:
|
||||
URL(string: "https://platform.openai.com/api-keys")!
|
||||
case .gemini:
|
||||
URL(string: "https://makersuite.google.com/app/apikey")!
|
||||
case .anthropic:
|
||||
URL(string: "https://console.anthropic.com/settings/keys")!
|
||||
case .mistral:
|
||||
URL(string: "https://console.mistral.ai/api-keys")!
|
||||
case .elevenLabs:
|
||||
URL(string: "https://elevenlabs.io/speech-synthesis")!
|
||||
case .deepgram:
|
||||
URL(string: "https://console.deepgram.com/api-keys")!
|
||||
case .soniox:
|
||||
URL(string: "https://console.soniox.com/")!
|
||||
case .ollama, .custom:
|
||||
URL(string: "")! // This case should never be reached
|
||||
case .openRouter:
|
||||
URL(string: "https://openrouter.ai/keys")!
|
||||
case .cerebras:
|
||||
URL(string: "https://cloud.cerebras.ai/")!
|
||||
}
|
||||
NSWorkspace.shared.open(url)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("Get API Key")
|
||||
.foregroundColor(.accentColor)
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.disabled(apiKey.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -444,8 +283,19 @@ struct APIKeyManagementView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func formatSize(_ bytes: Int64) -> String {
|
||||
let gigabytes = Double(bytes) / 1_000_000_000
|
||||
return String(format: "%.1f GB", gigabytes)
|
||||
private func getAPIKeyURL() -> URL? {
|
||||
switch aiService.selectedProvider {
|
||||
case .groq: return URL(string: "https://console.groq.com/keys")
|
||||
case .openAI: return URL(string: "https://platform.openai.com/api-keys")
|
||||
case .gemini: return URL(string: "https://makersuite.google.com/app/apikey")
|
||||
case .anthropic: return URL(string: "https://console.anthropic.com/settings/keys")
|
||||
case .mistral: return URL(string: "https://console.mistral.ai/api-keys")
|
||||
case .elevenLabs: return URL(string: "https://elevenlabs.io/speech-synthesis")
|
||||
case .deepgram: return URL(string: "https://console.deepgram.com/api-keys")
|
||||
case .soniox: return URL(string: "https://console.soniox.com/")
|
||||
case .openRouter: return URL(string: "https://openrouter.ai/keys")
|
||||
case .cerebras: return URL(string: "https://cloud.cerebras.ai/")
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,7 +142,8 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.frame(minWidth: 940, minHeight: 730)
|
||||
.frame(width: 950)
|
||||
.frame(minHeight: 730)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToDestination)) { notification in
|
||||
if let destination = notification.userInfo?["destination"] as? String {
|
||||
switch destination {
|
||||
|
||||
@ -4,117 +4,176 @@ import UniformTypeIdentifiers
|
||||
struct EnhancementSettingsView: View {
|
||||
@EnvironmentObject private var enhancementService: AIEnhancementService
|
||||
@State private var isEditingPrompt = false
|
||||
@State private var isSettingsExpanded = true
|
||||
@State private var isShortcutsExpanded = false
|
||||
@State private var selectedPromptForEdit: CustomPrompt?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 32) {
|
||||
// Main Settings Sections
|
||||
VStack(spacing: 24) {
|
||||
// Enable/Disable Toggle Section
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("Enable Enhancement")
|
||||
.font(.headline)
|
||||
private var isPanelOpen: Bool {
|
||||
isEditingPrompt || selectedPromptForEdit != nil
|
||||
}
|
||||
|
||||
private func closePanel() {
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.9)) {
|
||||
isEditingPrompt = false
|
||||
selectedPromptForEdit = nil
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
Form {
|
||||
Section {
|
||||
Toggle(isOn: $enhancementService.isEnhancementEnabled) {
|
||||
HStack(spacing: 4) {
|
||||
Text("Enable Enhancement")
|
||||
InfoTip(
|
||||
title: "AI Enhancement",
|
||||
message: "AI enhancement lets you pass the transcribed audio through LLMS to post-process using different prompts suitable for different use cases like e-mails, summary, writing, etc.",
|
||||
learnMoreURL: "https://www.youtube.com/@tryvoiceink/videos"
|
||||
message: "AI enhancement lets you pass the transcribed audio through LLMs to post-process using different prompts suitable for different use cases like e-mails, summary, writing, etc.",
|
||||
learnMoreURL: "https://tryvoiceink.com/docs/enhancements-configuring-models"
|
||||
)
|
||||
}
|
||||
|
||||
Text("Turn on AI-powered enhancement features")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: $enhancementService.isEnhancementEnabled)
|
||||
.toggleStyle(SwitchToggleStyle(tint: .blue))
|
||||
.labelsHidden()
|
||||
.scaleEffect(1.2)
|
||||
}
|
||||
|
||||
HStack(spacing: 20) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Toggle("Clipboard Context", isOn: $enhancementService.useClipboardContext)
|
||||
.toggleStyle(.switch)
|
||||
.disabled(!enhancementService.isEnhancementEnabled)
|
||||
Text("Use text from clipboard to understand the context")
|
||||
.font(.caption)
|
||||
.foregroundColor(enhancementService.isEnhancementEnabled ? .secondary : .secondary.opacity(0.5))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Toggle("Context Awareness", isOn: $enhancementService.useScreenCaptureContext)
|
||||
HStack(spacing: 24) {
|
||||
Toggle(isOn: $enhancementService.useClipboardContext) {
|
||||
HStack(spacing: 4) {
|
||||
Text("Clipboard Context")
|
||||
InfoTip(
|
||||
title: "Clipboard Context",
|
||||
message: "Use text from clipboard to understand the context"
|
||||
)
|
||||
}
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.disabled(!enhancementService.isEnhancementEnabled)
|
||||
Text("Learn what is on the screen to understand the context")
|
||||
.font(.caption)
|
||||
.foregroundColor(enhancementService.isEnhancementEnabled ? .secondary : .secondary.opacity(0.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(CardBackground(isSelected: false))
|
||||
|
||||
// 1. AI Provider Integration Section
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("AI Provider Integration")
|
||||
.font(.headline)
|
||||
Toggle(isOn: $enhancementService.useScreenCaptureContext) {
|
||||
HStack(spacing: 4) {
|
||||
Text("Screen Context")
|
||||
InfoTip(
|
||||
title: "Context Awareness",
|
||||
message: "Learn what is on the screen to understand the context"
|
||||
)
|
||||
}
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
.opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8)
|
||||
} header: {
|
||||
Text("General")
|
||||
}
|
||||
|
||||
APIKeyManagementView()
|
||||
}
|
||||
.padding()
|
||||
.background(CardBackground(isSelected: false))
|
||||
.opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8)
|
||||
|
||||
// 3. Enhancement Modes & Assistant Section
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Enhancement Prompt")
|
||||
.font(.headline)
|
||||
|
||||
// Reorderable prompts grid with drag-and-drop
|
||||
Section {
|
||||
ReorderablePromptGrid(
|
||||
selectedPromptId: enhancementService.selectedPromptId,
|
||||
onPromptSelected: { prompt in
|
||||
enhancementService.setActivePrompt(prompt)
|
||||
},
|
||||
onEditPrompt: { prompt in
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.9)) {
|
||||
selectedPromptForEdit = prompt
|
||||
}
|
||||
},
|
||||
onDeletePrompt: { prompt in
|
||||
enhancementService.deletePrompt(prompt)
|
||||
},
|
||||
onAddNewPrompt: {
|
||||
isEditingPrompt = true
|
||||
}
|
||||
)
|
||||
.padding(.vertical, 8)
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Enhancement Prompts")
|
||||
Spacer()
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.9)) {
|
||||
isEditingPrompt = true
|
||||
}
|
||||
.padding()
|
||||
.background(CardBackground(isSelected: false))
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 18))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Add new prompt")
|
||||
}
|
||||
}
|
||||
.opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8)
|
||||
|
||||
EnhancementShortcutsSection()
|
||||
Section {
|
||||
DisclosureGroup(isExpanded: $isShortcutsExpanded) {
|
||||
EnhancementShortcutsView()
|
||||
.padding(.vertical, 8)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Shortcuts")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
withAnimation {
|
||||
isShortcutsExpanded.toggle()
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 500)
|
||||
}
|
||||
.opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.sheet(isPresented: $isEditingPrompt) {
|
||||
PromptEditorView(mode: .add)
|
||||
.disabled(isPanelOpen)
|
||||
.blur(radius: isPanelOpen ? 2 : 0)
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.9), value: isPanelOpen)
|
||||
|
||||
if isPanelOpen {
|
||||
Color.black.opacity(0.2)
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture {
|
||||
closePanel()
|
||||
}
|
||||
.sheet(item: $selectedPromptForEdit) { prompt in
|
||||
PromptEditorView(mode: .edit(prompt))
|
||||
.transition(.opacity)
|
||||
.zIndex(1)
|
||||
}
|
||||
|
||||
if isPanelOpen {
|
||||
HStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
Group {
|
||||
if let prompt = selectedPromptForEdit {
|
||||
PromptEditorView(mode: .edit(prompt)) {
|
||||
closePanel()
|
||||
}
|
||||
} else if isEditingPrompt {
|
||||
PromptEditorView(mode: .add) {
|
||||
closePanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 450)
|
||||
.frame(maxHeight: .infinity)
|
||||
.background(
|
||||
Color(NSColor.windowBackgroundColor)
|
||||
)
|
||||
.overlay(
|
||||
Divider(), alignment: .leading
|
||||
)
|
||||
.shadow(color: .black.opacity(0.15), radius: 12, x: -4, y: 0)
|
||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.zIndex(2)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 500, minHeight: 400)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drag & Drop Reorderable Grid
|
||||
// MARK: - Reorderable Grid
|
||||
private struct ReorderablePromptGrid: View {
|
||||
@EnvironmentObject private var enhancementService: AIEnhancementService
|
||||
|
||||
@ -122,7 +181,6 @@ private struct ReorderablePromptGrid: View {
|
||||
let onPromptSelected: (CustomPrompt) -> Void
|
||||
let onEditPrompt: ((CustomPrompt) -> Void)?
|
||||
let onDeletePrompt: ((CustomPrompt) -> Void)?
|
||||
let onAddNewPrompt: (() -> Void)?
|
||||
|
||||
@State private var draggingItem: CustomPrompt?
|
||||
|
||||
@ -174,20 +232,6 @@ private struct ReorderablePromptGrid: View {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if let onAddNewPrompt = onAddNewPrompt {
|
||||
CustomPrompt.addNewButton {
|
||||
onAddNewPrompt()
|
||||
}
|
||||
.help("Add new prompt")
|
||||
.onDrop(
|
||||
of: [UTType.text],
|
||||
delegate: PromptEndDropDelegate(
|
||||
prompts: $enhancementService.customPrompts,
|
||||
draggingItem: $draggingItem
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 16)
|
||||
@ -208,7 +252,7 @@ private struct ReorderablePromptGrid: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drop Delegates
|
||||
// MARK: - Drop Delegate
|
||||
private struct PromptDropDelegate: DropDelegate {
|
||||
let item: CustomPrompt
|
||||
@Binding var prompts: [CustomPrompt]
|
||||
@ -219,7 +263,6 @@ private struct PromptDropDelegate: DropDelegate {
|
||||
guard let fromIndex = prompts.firstIndex(of: draggingItem),
|
||||
let toIndex = prompts.firstIndex(of: item) else { return }
|
||||
|
||||
// Move item as you hover for immediate visual update
|
||||
if prompts[toIndex].id != draggingItem.id {
|
||||
withAnimation(.easeInOut(duration: 0.12)) {
|
||||
let from = fromIndex
|
||||
@ -238,26 +281,3 @@ private struct PromptDropDelegate: DropDelegate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private struct PromptEndDropDelegate: DropDelegate {
|
||||
@Binding var prompts: [CustomPrompt]
|
||||
@Binding var draggingItem: CustomPrompt?
|
||||
|
||||
func validateDrop(info: DropInfo) -> Bool { true }
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? { DropProposal(operation: .move) }
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
guard let draggingItem = draggingItem,
|
||||
let currentIndex = prompts.firstIndex(of: draggingItem) else {
|
||||
self.draggingItem = nil
|
||||
return false
|
||||
}
|
||||
|
||||
// Move to end if dropped on the trailing "Add New" tile
|
||||
withAnimation(.easeInOut(duration: 0.12)) {
|
||||
prompts.move(fromOffsets: IndexSet(integer: currentIndex), toOffset: prompts.endIndex)
|
||||
}
|
||||
self.draggingItem = nil
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,12 +20,12 @@ struct PromptEditorView: View {
|
||||
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 showingPredefinedPrompts = false
|
||||
@State private var useSystemInstructions: Bool
|
||||
@State private var showingIconPicker = false
|
||||
|
||||
@ -36,8 +36,9 @@ struct PromptEditorView: View {
|
||||
return false
|
||||
}
|
||||
|
||||
init(mode: Mode) {
|
||||
init(mode: Mode, onDismiss: (() -> Void)? = nil) {
|
||||
self.mode = mode
|
||||
self.onDismiss = onDismiss
|
||||
switch mode {
|
||||
case .add:
|
||||
_title = State(initialValue: "")
|
||||
@ -58,196 +59,251 @@ struct PromptEditorView: View {
|
||||
|
||||
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") {
|
||||
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)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Button {
|
||||
save()
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("Save")
|
||||
.fontWeight(.medium)
|
||||
.help("Close")
|
||||
}
|
||||
.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)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
.overlay(
|
||||
Divider().opacity(0.5), alignment: .bottom
|
||||
)
|
||||
|
||||
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)
|
||||
.font(.title3)
|
||||
.fontWeight(.medium)
|
||||
.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(.horizontal, 20)
|
||||
.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
|
||||
}) {
|
||||
VStack(spacing: 24) {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
Button(action: { showingIconPicker = true }) {
|
||||
Image(systemName: selectedIcon)
|
||||
.font(.system(size: 20))
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 48, height: 48)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(8)
|
||||
.cornerRadius(10)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.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) {
|
||||
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(.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)
|
||||
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))
|
||||
)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Prompt Text Section with improved styling
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Prompt Instructions")
|
||||
HStack(spacing: 6) {
|
||||
Text("Instructions")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Define how AI should enhance your transcriptions")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
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 Instructions", isOn: $useSystemInstructions)
|
||||
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(.bottom, 4)
|
||||
.padding(.top, 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)
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
// Trigger Words Field using reusable component
|
||||
TriggerWordsEditor(triggerWords: $triggerWords)
|
||||
.padding(.horizontal)
|
||||
|
||||
if case .add = mode {
|
||||
// Popover keeps templates accessible without taking space in the layout
|
||||
Button("Start with a Predefined Template") {
|
||||
showingPredefinedPrompts.toggle()
|
||||
}
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color(.windowBackgroundColor).opacity(0.9))
|
||||
)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal)
|
||||
.popover(isPresented: $showingPredefinedPrompts, arrowEdge: .bottom) {
|
||||
PredefinedPromptsView { template in
|
||||
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
|
||||
showingPredefinedPrompts = false
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 700, minHeight: 500)
|
||||
}
|
||||
|
||||
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() {
|
||||
@ -278,45 +334,65 @@ struct PromptEditorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Reusable Trigger Words Editor Component
|
||||
// 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(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Add multiple words that can activate this prompt")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
// Display existing trigger words as tags
|
||||
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 {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 140, maximum: 220))], spacing: 8) {
|
||||
TagLayout(alignment: .leading, spacing: 6) {
|
||||
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)
|
||||
.padding(.top, 4)
|
||||
} else {
|
||||
Text("No trigger words added")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary.opacity(0.7))
|
||||
.italic()
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -325,7 +401,6 @@ struct TriggerWordsEditor: View {
|
||||
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 }
|
||||
|
||||
@ -334,49 +409,90 @@ struct TriggerWordsEditor: View {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Trigger Word Item
|
||||
struct TriggerWordItemView: View {
|
||||
let word: String
|
||||
let onDelete: () -> Void
|
||||
@State private var isHovered = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
HStack(spacing: 4) {
|
||||
Text(word)
|
||||
.font(.system(size: 13))
|
||||
.font(.system(size: 12))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: 120, alignment: .leading)
|
||||
.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
|
||||
}
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 2)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color(.windowBackgroundColor).opacity(0.4))
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Icon Picker Popover - shows icons in a grid format without category labels
|
||||
// MARK: - Icon Picker
|
||||
struct IconPickerPopover: View {
|
||||
@Binding var selectedIcon: PromptIcon
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
@ -5,178 +5,79 @@ struct EnhancementShortcutsView: View {
|
||||
@ObservedObject private var shortcutSettings = EnhancementShortcutSettings.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
ShortcutRow(
|
||||
VStack(spacing: 8) {
|
||||
// Toggle AI Enhancement
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
HStack(spacing: 4) {
|
||||
Text("Toggle AI Enhancement")
|
||||
.font(.system(size: 13))
|
||||
|
||||
InfoTip(
|
||||
title: "Toggle AI Enhancement",
|
||||
description: "Quickly enable or disable enhancement while recording.",
|
||||
keyDisplay: ["⌘", "E"],
|
||||
isOn: $shortcutSettings.isToggleEnhancementShortcutEnabled
|
||||
message: "Quickly enable or disable AI enhancement while recording. Available only when VoiceInk is running and the recorder is visible.",
|
||||
learnMoreURL: "https://tryvoiceink.com/docs/enhancement-shortcuts"
|
||||
)
|
||||
ShortcutRow(
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: 4) {
|
||||
KeyChip(label: "⌘")
|
||||
KeyChip(label: "E")
|
||||
}
|
||||
|
||||
Toggle("", isOn: $shortcutSettings.isToggleEnhancementShortcutEnabled)
|
||||
.toggleStyle(.switch)
|
||||
.labelsHidden()
|
||||
}
|
||||
}
|
||||
|
||||
// Switch Enhancement Prompt
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
HStack(spacing: 4) {
|
||||
Text("Switch Enhancement Prompt")
|
||||
.font(.system(size: 13))
|
||||
|
||||
InfoTip(
|
||||
title: "Switch Enhancement Prompt",
|
||||
description: "Switch between your saved prompts without touching the UI. Use ⌘1–⌘0 to activate the corresponding prompt in the order they are saved.",
|
||||
keyDisplay: ["⌘", "1 – 0"]
|
||||
message: "Switch between your saved prompts using ⌘1 through ⌘0 to activate the corresponding prompt in the order they are saved. Available only when VoiceInk is running and the recorder is visible.",
|
||||
learnMoreURL: "https://tryvoiceink.com/docs/enhancement-shortcuts"
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 4) {
|
||||
KeyChip(label: "⌘")
|
||||
KeyChip(label: "1 – 0")
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
struct EnhancementShortcutsSection: View {
|
||||
@State private var isExpanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "command")
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Enhancement Shortcuts")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
Text("Keep enhancement prompts handy")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.down")
|
||||
.rotationEffect(.degrees(isExpanded ? 0 : -90))
|
||||
.foregroundColor(.secondary)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if isExpanded {
|
||||
Divider()
|
||||
.transition(.opacity)
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
EnhancementShortcutsView()
|
||||
|
||||
Text("Enhancement shortcuts are available only when the recorder is visible and VoiceInk is running.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(16)
|
||||
.transition(
|
||||
.asymmetric(
|
||||
insertion: .opacity.combined(with: .scale(scale: 0.98, anchor: .top)),
|
||||
removal: .opacity
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(CardBackground(isSelected: false))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Views
|
||||
private struct ShortcutRow: View {
|
||||
let title: String
|
||||
let description: String
|
||||
let keyDisplay: [String]
|
||||
private var isOn: Binding<Bool>?
|
||||
|
||||
init(title: String, description: String, keyDisplay: [String], isOn: Binding<Bool>? = nil) {
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.keyDisplay = keyDisplay
|
||||
self.isOn = isOn
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Text(title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
InfoTip(title: title, message: description, learnMoreURL: "https://tryvoiceink.com/docs/switching-enhancement-prompts")
|
||||
}
|
||||
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer(minLength: 12)
|
||||
|
||||
if let isOn = isOn {
|
||||
keyDisplayView(isActive: isOn.wrappedValue)
|
||||
.onTapGesture {
|
||||
withAnimation(.bouncy) {
|
||||
isOn.wrappedValue.toggle()
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
} else {
|
||||
keyDisplayView()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(CardBackground(isSelected: false))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func keyDisplayView(isActive: Bool? = nil) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(keyDisplay, id: \.self) { key in
|
||||
KeyChip(label: key, isActive: isActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct KeyChip: View {
|
||||
let label: String
|
||||
var isActive: Bool? = nil
|
||||
|
||||
var body: some View {
|
||||
let active = isActive ?? true
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(active ? .primary : .secondary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color(NSColor.controlBackgroundColor).opacity(active ? 0.9 : 0.6),
|
||||
Color(NSColor.controlBackgroundColor).opacity(active ? 0.7 : 0.5)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.fill(Color(NSColor.controlBackgroundColor))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.strokeBorder(
|
||||
Color(NSColor.separatorColor).opacity(active ? 0.4 : 0.2),
|
||||
lineWidth: 1
|
||||
Color(NSColor.separatorColor).opacity(0.5),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
.shadow(color: Color(NSColor.shadowColor).opacity(active ? 0.15 : 0.05), radius: 2, x: 0, y: 1)
|
||||
.opacity(active ? 1.0 : 0.6)
|
||||
}
|
||||
}
|
||||
|
||||
@ -276,6 +276,8 @@ struct VoiceInkApp: App {
|
||||
}
|
||||
}
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.defaultSize(width: 950, height: 730)
|
||||
.windowResizability(.contentSize)
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) { }
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user