feat: use openai for tts instead of deepgram

This commit is contained in:
Owen Gretzinger
2025-07-11 17:31:54 -04:00
parent 263840570d
commit 5ee4fced42
12 changed files with 220 additions and 278 deletions
-20
View File
@@ -1,20 +0,0 @@
---
alwaysApply: true
---
I want to build an open source version of Granola
It will be a meeting notetaking macOS application
Basically it will use your mic and system audio, create a live transcript, and then at the end of the meeting use the transcript + any notes you write in a notepad to generate the meeting notes, with a button to quickly copy the notes or transcript. Can also edit the resulting notes
The user will provide their own deepgram and openai API keys (stored locally) for transcription and AI generation
The user will also be able to modify the system prompt in addition to just being able to write a blurb about themself (that gets injected into the system prompt)
All data will be stored locally on the device
That will be great for the MVP to start. Later on I will add additional features such as:
- connecting to your Google calendar
- note templates
- AI chat for asking questions about a meeting
- Integrations for email, slack, etc.
+5 -4
View File
@@ -1,7 +1,7 @@
Introducing Notetaker, an open source Granola alternative for on-device AI meeting notes:
Introducing Meetingnotes: the free, open-source AI notetaker for busy engineers.
- 100% free (bring your own Deepgram & OpenAI API keys)
- 100% data privacy (data stored on device)
- 100% free (bring your own OpenAI API key)
- 100% privacy (all data stored on device)
- 100% open source (please contribute)
## Features
@@ -9,7 +9,7 @@ Introducing Notetaker, an open source Granola alternative for on-device AI meeti
Implemented:
- Recording mic & system audio
- Live transcript using Deepgram
- Live transcript
- Ability to also write down additional notes
- AI generated enhanced notes
- Copy functionality
@@ -24,6 +24,7 @@ Todo:
- Markdown formatting
- Auto generate notes when recording is stopped
- Different note templates
- Fix transcript UI alignment
Later:
+1 -1
View File
@@ -7,7 +7,7 @@ export default function Features() {
icon: <Mic className="w-8 h-8 text-blue-400" />,
title: "Live Transcription",
description:
"Records mic and system audio using Deepgram for real-time transcripts. No meeting bots required—transcribe directly from your computer's audio.",
"Records mic & system audio and uses OpenAI for real-time transcripts. No meeting bots required—transcribe directly from your computer's audio.",
image: "/placeholder.svg?height=200&width=300",
},
{
+1 -1
View File
@@ -8,7 +8,7 @@ export default function HowItWorks() {
icon: <Download className="w-6 h-6" />,
title: "Download & Setup",
description:
"Download and install on macOS. Enter your Deepgram and OpenAI API keys (stored securely on-device).",
"Download and install on macOS. Enter your OpenAI API key (stored securely on-device).",
},
{
number: "02",
+3 -4
View File
@@ -69,7 +69,7 @@ export default function Pricing() {
<div>
<h4 className="font-semibold text-white mb-1">Bring Your Own API Keys</h4>
<p className="text-gray-300 text-sm">
Use your Deepgram and OpenAI API keys. Pay only for what you use, directly to the providers.
Use your OpenAI API key. Pay only for what you use, directly to the providers.
</p>
</div>
</div>
@@ -89,8 +89,7 @@ export default function Pricing() {
<div>
<h4 className="font-semibold text-white mb-1">Typical Costs</h4>
<p className="text-gray-300 text-sm">
~$0.28/hour using Deepgram Nova-3 and GPT-4o-mini. Pay only for what you use, no markup or
subscriptions.
~$0.20/hour using gpt-4o-mini-transcribe for transcription and gpt-4.1-mini for summarization.
</p>
</div>
</div>
@@ -104,7 +103,7 @@ export default function Pricing() {
<div className="text-center">
<div className="font-semibold text-white">Meetingnotes</div>
<div className="text-green-400">$0 + API costs</div>
<div className="text-gray-400">~$0.28/hour</div>
<div className="text-gray-400">~$0.20/hour</div>
</div>
<div className="text-center">
<div className="font-semibold text-white">Granola</div>
+1 -1
View File
@@ -60,7 +60,7 @@ export default function Privacy() {
</div>
<h3 className="text-xl font-bold mb-3">Secure API Keys</h3>
<p className="text-gray-300">
Your Deepgram and OpenAI API keys are stored securely on your device, never shared.
Your OpenAI API key is stored securely on your device, never shared.
</p>
</div>
</div>
+103 -49
View File
@@ -6,7 +6,7 @@ import Foundation
import SwiftUI
import ScreenCaptureKit
/// Manages audio capture from microphone and system audio and handles real-time transcription via Deepgram
/// Manages audio capture from microphone and system audio and handles real-time transcription via OpenAI
class AudioManager: NSObject, ObservableObject {
@Published var transcriptChunks: [TranscriptChunk] = []
@Published var isRecording = false
@@ -14,7 +14,7 @@ class AudioManager: NSObject, ObservableObject {
private var audioEngine = AVAudioEngine()
private var micSocketTask: URLSessionWebSocketTask?
private var systemSocketTask: URLSessionWebSocketTask?
private let deepgramURL = URL(string: "wss://api.deepgram.com/v1/listen?encoding=linear16&sample_rate=16000&channels=1&interim_results=true&model=nova-3")!
private let realtimeURL = URL(string: "wss://api.openai.com/v1/realtime?intent=transcription")!
// ScreenCaptureKit properties
private var stream: SCStream?
@@ -22,6 +22,9 @@ class AudioManager: NSObject, ObservableObject {
// Add properties near the top, after existing private vars
private var micRetryCount = 0
private let maxMicRetries = 3
// Add current interim transcripts per source
private var currentInterim: [AudioSource: String] = [.mic: "", .system: ""]
override init() {
super.init()
@@ -93,7 +96,7 @@ class AudioManager: NSObject, ObservableObject {
}
}
/// Starts a microphone tap without creating a new Deepgram connection (used when also capturing system audio)
/// Starts a microphone tap without creating a new OpenAI connection (used when also capturing system audio)
private func startMicrophoneTap() {
print("🎤 Starting microphone tap...")
@@ -102,7 +105,7 @@ class AudioManager: NSObject, ObservableObject {
let recordingFormat = inputNode.outputFormat(forBus: 0)
guard let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16,
sampleRate: 16000,
sampleRate: 24000,
channels: 1,
interleaved: false) else {
print("❌ Failed to create target audio format for mic tap")
@@ -142,7 +145,7 @@ class AudioManager: NSObject, ObservableObject {
audioEngine.prepare()
try audioEngine.start()
connectToDeepgram(source: .mic)
connectToOpenAIRealtime(source: .mic)
print("✅ Microphone tap started successfully")
micRetryCount = 0 // Reset on success
@@ -222,7 +225,7 @@ class AudioManager: NSObject, ObservableObject {
self.isRecording = true
}
connectToDeepgram(source: .system)
connectToOpenAIRealtime(source: .system)
print("✅ System audio capture started successfully")
} catch {
@@ -263,7 +266,7 @@ class AudioManager: NSObject, ObservableObject {
private func processAudioBuffer(_ buffer: AVAudioPCMBuffer, converter: AVAudioConverter, targetFormat: AVAudioFormat, source: AudioSource) {
let processBuffer = buffer
// Convert to target format (16kHz int16 mono) in a single step AVAudioConverter will handle resampling and downmixing
// Convert to target format (24kHz int16 mono) in a single step AVAudioConverter will handle resampling and downmixing
let outputFrameCapacity = AVAudioFrameCount(Double(processBuffer.frameLength) * targetFormat.sampleRate / processBuffer.format.sampleRate)
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outputFrameCapacity) else {
return
@@ -279,7 +282,7 @@ class AudioManager: NSObject, ObservableObject {
return
}
// Convert to Data for Deepgram
// Convert to Data for OpenAI
guard let channelData = outputBuffer.int16ChannelData?[0] else {
return
}
@@ -290,19 +293,51 @@ class AudioManager: NSObject, ObservableObject {
sendAudioData(data, source: source)
}
private func connectToDeepgram(source: AudioSource) {
guard let key = KeychainHelper.shared.get(forKey: "deepgramKey"), !key.isEmpty else {
print("❌ No Deepgram key found")
private func connectToOpenAIRealtime(source: AudioSource) {
guard let key = KeychainHelper.shared.get(forKey: "openAIKey"), !key.isEmpty else {
print("❌ No OpenAI key found")
return
}
let session = URLSession(configuration: .default)
var request = URLRequest(url: deepgramURL)
request.addValue("Token \(key)", forHTTPHeaderField: "Authorization")
var request = URLRequest(url: realtimeURL)
request.addValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
request.addValue("realtime=v1", forHTTPHeaderField: "OpenAI-Beta")
let task = session.webSocketTask(with: request)
task.resume()
// Send initial configuration
let config: [String: Any] = [
"type": "transcription_session.update",
"session": [
"input_audio_format": "pcm16",
"input_audio_transcription": [
"model": "gpt-4o-mini-transcribe",
"language": "en"
],
"turn_detection": [
"type": "server_vad",
"threshold": 0.5,
"prefix_padding_ms": 300,
"silence_duration_ms": 200
]
]
]
do {
let jsonData = try JSONSerialization.data(withJSONObject: config)
if let jsonStr = String(data: jsonData, encoding: .utf8) {
task.send(.string(jsonStr)) { error in
if let error = error {
print("❌ Config send error: \(error)")
}
}
}
} catch {
print("❌ Config JSON error: \(error)")
}
switch source {
case .mic:
micSocketTask = task
@@ -311,7 +346,7 @@ class AudioManager: NSObject, ObservableObject {
}
receiveMessage(for: source)
print("🌐 Connected to Deepgram (\(source))")
print("🌐 Connected to OpenAI Realtime (\(source))")
}
private func receiveMessage(for source: AudioSource) {
@@ -321,7 +356,7 @@ class AudioManager: NSObject, ObservableObject {
case .success(let message):
switch message {
case .string(let text):
self?.parseTranscription(text, source: source)
self?.parseRealtimeEvent(text, source: source)
case .data:
break
@unknown default:
@@ -333,45 +368,54 @@ class AudioManager: NSObject, ObservableObject {
// Attempt reconnect if still recording
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
if self?.isRecording == true {
self?.connectToDeepgram(source: source)
self?.connectToOpenAIRealtime(source: source)
}
}
}
}
}
private func parseTranscription(_ text: String, source: AudioSource) {
private func parseRealtimeEvent(_ text: String, source: AudioSource) {
guard let data = text.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = json["type"] as? String, type == "Results",
let channel = json["channel"] as? [String: Any],
let alternatives = channel["alternatives"] as? [[String: Any]],
let alt = alternatives.first,
let transcriptText = alt["transcript"] as? String,
!transcriptText.isEmpty else { return }
let type = json["type"] as? String else { return }
let isFinal = json["is_final"] as? Bool ?? false
DispatchQueue.main.async {
let chunk = TranscriptChunk(
timestamp: Date(),
source: source,
text: transcriptText,
isFinal: isFinal
)
// For interim results, replace the last interim chunk from the same source
if !isFinal {
// Remove the last interim chunk from the same source
if let lastIndex = self.transcriptChunks.lastIndex(where: { !$0.isFinal && $0.source == source }) {
self.transcriptChunks.remove(at: lastIndex)
switch type {
case "conversation.item.input_audio_transcription.delta":
if let delta = json["delta"] as? String {
currentInterim[source]! += delta
DispatchQueue.main.async {
// Remove previous interim chunk from the same source
if let lastIndex = self.transcriptChunks.lastIndex(where: { !$0.isFinal && $0.source == source }) {
self.transcriptChunks.remove(at: lastIndex)
}
let chunk = TranscriptChunk(
timestamp: Date(),
source: source,
text: self.currentInterim[source] ?? "",
isFinal: false
)
self.transcriptChunks.append(chunk)
}
self.transcriptChunks.append(chunk)
} else {
// For final results, remove any interim chunks from the same source and add the final chunk
self.transcriptChunks.removeAll { !$0.isFinal && $0.source == source }
self.transcriptChunks.append(chunk)
}
case "conversation.item.input_audio_transcription.completed":
if let transcript = json["transcript"] as? String {
DispatchQueue.main.async {
// Remove any interim chunks from the same source
self.transcriptChunks.removeAll { !$0.isFinal && $0.source == source }
let chunk = TranscriptChunk(
timestamp: Date(),
source: source,
text: transcript,
isFinal: true
)
self.transcriptChunks.append(chunk)
}
currentInterim[source] = ""
}
default:
break
}
}
@@ -380,10 +424,20 @@ class AudioManager: NSObject, ObservableObject {
guard let socket = task, socket.state == .running else { return }
socket.send(.data(data)) { error in
if let error = error {
print("❌ Send error (\(source)): \(error)")
let base64 = data.base64EncodedString()
let message: [String: Any] = ["type": "input_audio_buffer.append", "audio": base64]
do {
let jsonData = try JSONSerialization.data(withJSONObject: message)
if let jsonStr = String(data: jsonData, encoding: .utf8) {
socket.send(.string(jsonStr)) { error in
if let error = error {
print("❌ Send error (\(source)): \(error)")
}
}
}
} catch {
print("❌ JSON send error")
}
}
@@ -402,9 +456,9 @@ extension AudioManager: SCStreamDelegate, SCStreamOutput {
// Convert CMSampleBuffer to AVAudioPCMBuffer
guard let pcmBuffer = sampleBuffer.asPCMBuffer else { return }
// Create converter for Deepgram format
// Create converter for OpenAI format
let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16,
sampleRate: 16000,
sampleRate: 24000,
channels: 1,
interleaved: false)!
@@ -431,4 +485,4 @@ extension CMSampleBuffer {
return AVAudioPCMBuffer(pcmFormat: format, bufferListNoCopy: audioBufferList.unsafePointer)
}
}
}
}
+23 -51
View File
@@ -1,72 +1,44 @@
import Foundation
struct Settings: Codable {
var deepgramKey: String
var openAIKey: String
var userBlurb: String
var systemPrompt: String
static let defaultSystemPrompt: String = {
guard let url = Bundle.main.url(forResource: "DefaultSystemPrompt", withExtension: "txt"),
let content = try? String(contentsOf: url) else {
assertionFailure("DefaultSystemPrompt.txt missing from bundle")
return ""
// System prompt default loading
static func defaultSystemPrompt() -> String {
guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"),
let content = try? String(contentsOfFile: path) else {
return "You are a helpful assistant that creates comprehensive meeting notes from transcript data."
}
return content
}()
// Required variables for the template
static let requiredVariables: Set<String> = [
"meeting_title",
"meeting_date",
"transcript",
"user_blurb",
"user_notes"
]
init(deepgramKey: String = "",
openAIKey: String = "",
userBlurb: String = "",
systemPrompt: String = Settings.defaultSystemPrompt) {
self.deepgramKey = deepgramKey
self.openAIKey = openAIKey
self.userBlurb = userBlurb
self.systemPrompt = systemPrompt
}
// MARK: - Template Methods
/// Extracts all template variables from a system prompt string
static func extractTemplateVariables(from template: String) -> Set<String> {
let pattern = #"\{\{(\w+)\}\}"#
let regex = try! NSRegularExpression(pattern: pattern, options: [])
let matches = regex.matches(in: template, options: [], range: NSRange(location: 0, length: template.count))
var variables: Set<String> = []
for match in matches {
if let range = Range(match.range(at: 1), in: template) {
variables.insert(String(template[range]))
}
// Add a computed property for the full prompt
var fullSystemPrompt: String {
let defaultPrompt = Settings.defaultSystemPrompt()
if userBlurb.isEmpty {
return defaultPrompt
}
return variables
return "\(defaultPrompt)\n\nContext about the user: \(userBlurb)"
}
/// Validates that all required variables are present in the system prompt
func validateSystemPrompt() -> (isValid: Bool, missingVariables: Set<String>) {
let presentVariables = Settings.extractTemplateVariables(from: systemPrompt)
let missingVariables = Settings.requiredVariables.subtracting(presentVariables)
return (isValid: missingVariables.isEmpty, missingVariables: missingVariables)
}
/// Replaces template variables in the system prompt with actual values
// Template processing method
static func processTemplate(_ template: String, with variables: [String: String]) -> String {
var result = template
for (key, value) in variables {
let placeholder = "{{\(key)}}"
result = result.replacingOccurrences(of: placeholder, with: value)
result = result.replacingOccurrences(of: "{{\(key)}}", with: value)
}
return result
}
init(openAIKey: String = "",
userBlurb: String = "",
systemPrompt: String = "") {
self.openAIKey = openAIKey
self.userBlurb = userBlurb
self.systemPrompt = systemPrompt.isEmpty ? Settings.defaultSystemPrompt() : systemPrompt
}
}
+1 -1
View File
@@ -116,7 +116,7 @@ class MeetingViewModel: ObservableObject {
do {
// Load settings for generation
let userBlurb = KeychainHelper.shared.get(forKey: "userBlurb") ?? ""
let systemPrompt = KeychainHelper.shared.get(forKey: "systemPrompt") ?? Settings.defaultSystemPrompt
let systemPrompt = KeychainHelper.shared.get(forKey: "systemPrompt") ?? Settings.defaultSystemPrompt()
meeting.generatedNotes = try await NotesGenerator.shared.generateNotes(
meeting: meeting,
+14 -33
View File
@@ -1,60 +1,41 @@
import Foundation
import SwiftUI
@MainActor
class SettingsViewModel: ObservableObject {
@Published var settings: Settings
@Published var isSaving = false
@Published var saveSuccessful = false
@Published var errorMessage: String?
@Published var settings = Settings()
@Published var saveMessage = ""
@Published var showingSaveMessage = false
init() {
self.settings = Settings()
loadSettings()
}
func loadSettings() {
// Load API keys from Keychain
settings.deepgramKey = KeychainHelper.shared.get(forKey: "deepgramKey") ?? ""
settings.openAIKey = KeychainHelper.shared.get(forKey: "openAIKey") ?? ""
settings.userBlurb = KeychainHelper.shared.get(forKey: "userBlurb") ?? ""
settings.systemPrompt = KeychainHelper.shared.get(forKey: "systemPrompt") ?? Settings.defaultSystemPrompt
settings.systemPrompt = KeychainHelper.shared.get(forKey: "systemPrompt") ?? Settings.defaultSystemPrompt()
}
func saveSettings() {
isSaving = true
errorMessage = nil
saveSuccessful = false
// Validate system prompt template before saving
let validation = settings.validateSystemPrompt()
if !validation.isValid {
let missingVars = validation.missingVariables.joined(separator: ", ")
errorMessage = "System prompt is missing required variables: \(missingVars). Please include these variables in your template using double curly braces (e.g., {{transcript}})."
isSaving = false
return
}
// Save to Keychain
let deepgramSaved = KeychainHelper.shared.save(settings.deepgramKey, forKey: "deepgramKey")
let openAISaved = KeychainHelper.shared.save(settings.openAIKey, forKey: "openAIKey")
let blurbSaved = KeychainHelper.shared.save(settings.userBlurb, forKey: "userBlurb")
let promptSaved = KeychainHelper.shared.save(settings.systemPrompt, forKey: "systemPrompt")
if deepgramSaved && openAISaved && blurbSaved && promptSaved {
saveSuccessful = true
// Show success briefly
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.saveSuccessful = false
}
if openAISaved && blurbSaved && promptSaved {
saveMessage = "Settings saved successfully!"
} else {
errorMessage = "Failed to save some settings. Please try again."
saveMessage = "Error saving settings"
}
isSaving = false
showingSaveMessage = true
// Hide the message after 3 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.showingSaveMessage = false
}
}
func resetToDefaults() {
settings.systemPrompt = Settings.defaultSystemPrompt
settings.systemPrompt = Settings.defaultSystemPrompt()
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ struct MeetingListView: View {
}
}
.sheet(isPresented: $showingSettings) {
SettingsView()
SettingsView(viewModel: SettingsViewModel())
}
.navigationDestination(for: Meeting.self) { meeting in
MeetingDetailView(meeting: meeting)
+67 -112
View File
@@ -1,146 +1,101 @@
import SwiftUI
struct SettingsView: View {
@StateObject private var viewModel = SettingsViewModel()
@ObservedObject var viewModel: SettingsViewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 32) {
// API Keys Section
VStack(alignment: .leading, spacing: 16) {
Text("API Keys")
VStack(alignment: .leading, spacing: 24) {
// API Configuration Section
VStack(alignment: .leading, spacing: 8) {
Text("OpenAI API Key")
.font(.headline)
.fontWeight(.semibold)
VStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text("Deepgram API Key")
.font(.subheadline)
.foregroundColor(.secondary)
SecureField("Enter your Deepgram API key", text: $viewModel.settings.deepgramKey)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
VStack(alignment: .leading, spacing: 4) {
Text("OpenAI API Key")
.font(.subheadline)
.foregroundColor(.secondary)
SecureField("Enter your OpenAI API key", text: $viewModel.settings.openAIKey)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
}
}
.padding(.horizontal, 24)
Divider()
.padding(.horizontal, 24)
// Personal Information Section
VStack(alignment: .leading, spacing: 16) {
Text("Personal Information")
.font(.headline)
.fontWeight(.semibold)
VStack(alignment: .leading, spacing: 8) {
Text("About Yourself")
.font(.subheadline)
.foregroundColor(.secondary)
Text("This information will be included in the AI context when generating meeting notes.")
.font(.caption)
.foregroundColor(.secondary)
TextEditor(text: $viewModel.settings.userBlurb)
.frame(minHeight: 80, maxHeight: 120)
.scrollContentBackground(.hidden)
.background(Color(.controlBackgroundColor))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color(.separatorColor), lineWidth: 1)
)
}
}
.padding(.horizontal, 24)
Divider()
.padding(.horizontal, 24)
// AI Generation Section
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("AI Generation")
.font(.headline)
.fontWeight(.semibold)
Spacer()
Button("Reset to Default") {
viewModel.resetToDefaults()
}
.foregroundColor(.primary)
Text("Stored locally and encrypted.")
.font(.caption)
.foregroundColor(.accentColor)
}
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 8) {
Text("System Prompt")
.font(.subheadline)
.foregroundColor(.secondary)
Text("Customize how the AI generates your meeting notes.")
.font(.caption)
.foregroundColor(.secondary)
TextEditor(text: $viewModel.settings.systemPrompt)
.frame(minHeight: 100, maxHeight: 160)
.scrollContentBackground(.hidden)
.background(Color(.controlBackgroundColor))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color(.separatorColor), lineWidth: 1)
)
SecureField("OpenAI API Key", text: $viewModel.settings.openAIKey)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
// User Information Section
VStack(alignment: .leading, spacing: 8) {
Text("User Information")
.font(.headline)
.foregroundColor(.primary)
Text("Meetingsnotes works best when it knows a bit about you. You should give your name, role, company, and any other relevant information.")
.font(.caption)
.foregroundColor(.secondary)
TextEditor(text: $viewModel.settings.userBlurb)
.frame(minHeight: 100)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
}
// System Prompt Section
VStack(alignment: .leading, spacing: 8) {
Text("System Prompt")
.font(.headline)
.foregroundColor(.primary)
TextEditor(text: $viewModel.settings.systemPrompt)
.frame(minHeight: 200)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
}
// GitHub Link Section
VStack(alignment: .leading, spacing: 8) {
Text("About")
.font(.headline)
.foregroundColor(.primary)
Link(destination: URL(string: "https://github.com/owengretzinger/meetingnotes")!) {
HStack {
Image(systemName: "chevron.left.forwardslash.chevron.right")
.foregroundColor(.blue)
Text("View on GitHub")
.foregroundColor(.blue)
}
}
}
.padding(.horizontal, 24)
Spacer(minLength: 20)
}
.padding(.vertical, 24)
.padding(24)
}
.navigationTitle("Settings")
.frame(minWidth: 600, minHeight: 500)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
viewModel.saveSettings()
dismiss()
}
.disabled(viewModel.isSaving)
.buttonStyle(.borderedProminent)
}
}
.onChange(of: viewModel.saveSuccessful) { _, isSuccessful in
if isSuccessful {
dismiss()
}
}
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") {
viewModel.errorMessage = nil
}
} message: {
Text(viewModel.errorMessage ?? "")
}
.frame(minWidth: 600, minHeight: 600)
}
.onAppear {
viewModel.loadSettings()
}
}
}
#Preview {
SettingsView()
SettingsView(viewModel: SettingsViewModel())
}