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% free (bring your own OpenAI API key)
- 100% data privacy (data stored on device) - 100% privacy (all data stored on device)
- 100% open source (please contribute) - 100% open source (please contribute)
## Features ## Features
@@ -9,7 +9,7 @@ Introducing Notetaker, an open source Granola alternative for on-device AI meeti
Implemented: Implemented:
- Recording mic & system audio - Recording mic & system audio
- Live transcript using Deepgram - Live transcript
- Ability to also write down additional notes - Ability to also write down additional notes
- AI generated enhanced notes - AI generated enhanced notes
- Copy functionality - Copy functionality
@@ -24,6 +24,7 @@ Todo:
- Markdown formatting - Markdown formatting
- Auto generate notes when recording is stopped - Auto generate notes when recording is stopped
- Different note templates - Different note templates
- Fix transcript UI alignment
Later: Later:
+1 -1
View File
@@ -7,7 +7,7 @@ export default function Features() {
icon: <Mic className="w-8 h-8 text-blue-400" />, icon: <Mic className="w-8 h-8 text-blue-400" />,
title: "Live Transcription", title: "Live Transcription",
description: 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", 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" />, icon: <Download className="w-6 h-6" />,
title: "Download & Setup", title: "Download & Setup",
description: 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", number: "02",
+3 -4
View File
@@ -69,7 +69,7 @@ export default function Pricing() {
<div> <div>
<h4 className="font-semibold text-white mb-1">Bring Your Own API Keys</h4> <h4 className="font-semibold text-white mb-1">Bring Your Own API Keys</h4>
<p className="text-gray-300 text-sm"> <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> </p>
</div> </div>
</div> </div>
@@ -89,8 +89,7 @@ export default function Pricing() {
<div> <div>
<h4 className="font-semibold text-white mb-1">Typical Costs</h4> <h4 className="font-semibold text-white mb-1">Typical Costs</h4>
<p className="text-gray-300 text-sm"> <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 ~$0.20/hour using gpt-4o-mini-transcribe for transcription and gpt-4.1-mini for summarization.
subscriptions.
</p> </p>
</div> </div>
</div> </div>
@@ -104,7 +103,7 @@ export default function Pricing() {
<div className="text-center"> <div className="text-center">
<div className="font-semibold text-white">Meetingnotes</div> <div className="font-semibold text-white">Meetingnotes</div>
<div className="text-green-400">$0 + API costs</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>
<div className="text-center"> <div className="text-center">
<div className="font-semibold text-white">Granola</div> <div className="font-semibold text-white">Granola</div>
+1 -1
View File
@@ -60,7 +60,7 @@ export default function Privacy() {
</div> </div>
<h3 className="text-xl font-bold mb-3">Secure API Keys</h3> <h3 className="text-xl font-bold mb-3">Secure API Keys</h3>
<p className="text-gray-300"> <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> </p>
</div> </div>
</div> </div>
+101 -47
View File
@@ -6,7 +6,7 @@ import Foundation
import SwiftUI import SwiftUI
import ScreenCaptureKit 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 { class AudioManager: NSObject, ObservableObject {
@Published var transcriptChunks: [TranscriptChunk] = [] @Published var transcriptChunks: [TranscriptChunk] = []
@Published var isRecording = false @Published var isRecording = false
@@ -14,7 +14,7 @@ class AudioManager: NSObject, ObservableObject {
private var audioEngine = AVAudioEngine() private var audioEngine = AVAudioEngine()
private var micSocketTask: URLSessionWebSocketTask? private var micSocketTask: URLSessionWebSocketTask?
private var systemSocketTask: 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 // ScreenCaptureKit properties
private var stream: SCStream? private var stream: SCStream?
@@ -23,6 +23,9 @@ class AudioManager: NSObject, ObservableObject {
private var micRetryCount = 0 private var micRetryCount = 0
private let maxMicRetries = 3 private let maxMicRetries = 3
// Add current interim transcripts per source
private var currentInterim: [AudioSource: String] = [.mic: "", .system: ""]
override init() { override init() {
super.init() super.init()
NotificationCenter.default.addObserver(forName: .AVAudioEngineConfigurationChange, NotificationCenter.default.addObserver(forName: .AVAudioEngineConfigurationChange,
@@ -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() { private func startMicrophoneTap() {
print("🎤 Starting microphone tap...") print("🎤 Starting microphone tap...")
@@ -102,7 +105,7 @@ class AudioManager: NSObject, ObservableObject {
let recordingFormat = inputNode.outputFormat(forBus: 0) let recordingFormat = inputNode.outputFormat(forBus: 0)
guard let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, guard let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16,
sampleRate: 16000, sampleRate: 24000,
channels: 1, channels: 1,
interleaved: false) else { interleaved: false) else {
print("❌ Failed to create target audio format for mic tap") print("❌ Failed to create target audio format for mic tap")
@@ -142,7 +145,7 @@ class AudioManager: NSObject, ObservableObject {
audioEngine.prepare() audioEngine.prepare()
try audioEngine.start() try audioEngine.start()
connectToDeepgram(source: .mic) connectToOpenAIRealtime(source: .mic)
print("✅ Microphone tap started successfully") print("✅ Microphone tap started successfully")
micRetryCount = 0 // Reset on success micRetryCount = 0 // Reset on success
@@ -222,7 +225,7 @@ class AudioManager: NSObject, ObservableObject {
self.isRecording = true self.isRecording = true
} }
connectToDeepgram(source: .system) connectToOpenAIRealtime(source: .system)
print("✅ System audio capture started successfully") print("✅ System audio capture started successfully")
} catch { } catch {
@@ -263,7 +266,7 @@ class AudioManager: NSObject, ObservableObject {
private func processAudioBuffer(_ buffer: AVAudioPCMBuffer, converter: AVAudioConverter, targetFormat: AVAudioFormat, source: AudioSource) { private func processAudioBuffer(_ buffer: AVAudioPCMBuffer, converter: AVAudioConverter, targetFormat: AVAudioFormat, source: AudioSource) {
let processBuffer = buffer 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) let outputFrameCapacity = AVAudioFrameCount(Double(processBuffer.frameLength) * targetFormat.sampleRate / processBuffer.format.sampleRate)
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outputFrameCapacity) else { guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outputFrameCapacity) else {
return return
@@ -279,7 +282,7 @@ class AudioManager: NSObject, ObservableObject {
return return
} }
// Convert to Data for Deepgram // Convert to Data for OpenAI
guard let channelData = outputBuffer.int16ChannelData?[0] else { guard let channelData = outputBuffer.int16ChannelData?[0] else {
return return
} }
@@ -290,19 +293,51 @@ class AudioManager: NSObject, ObservableObject {
sendAudioData(data, source: source) sendAudioData(data, source: source)
} }
private func connectToDeepgram(source: AudioSource) { private func connectToOpenAIRealtime(source: AudioSource) {
guard let key = KeychainHelper.shared.get(forKey: "deepgramKey"), !key.isEmpty else { guard let key = KeychainHelper.shared.get(forKey: "openAIKey"), !key.isEmpty else {
print("❌ No Deepgram key found") print("❌ No OpenAI key found")
return return
} }
let session = URLSession(configuration: .default) let session = URLSession(configuration: .default)
var request = URLRequest(url: deepgramURL) var request = URLRequest(url: realtimeURL)
request.addValue("Token \(key)", forHTTPHeaderField: "Authorization") request.addValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
request.addValue("realtime=v1", forHTTPHeaderField: "OpenAI-Beta")
let task = session.webSocketTask(with: request) let task = session.webSocketTask(with: request)
task.resume() 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 { switch source {
case .mic: case .mic:
micSocketTask = task micSocketTask = task
@@ -311,7 +346,7 @@ class AudioManager: NSObject, ObservableObject {
} }
receiveMessage(for: source) receiveMessage(for: source)
print("🌐 Connected to Deepgram (\(source))") print("🌐 Connected to OpenAI Realtime (\(source))")
} }
private func receiveMessage(for source: AudioSource) { private func receiveMessage(for source: AudioSource) {
@@ -321,7 +356,7 @@ class AudioManager: NSObject, ObservableObject {
case .success(let message): case .success(let message):
switch message { switch message {
case .string(let text): case .string(let text):
self?.parseTranscription(text, source: source) self?.parseRealtimeEvent(text, source: source)
case .data: case .data:
break break
@unknown default: @unknown default:
@@ -333,45 +368,54 @@ class AudioManager: NSObject, ObservableObject {
// Attempt reconnect if still recording // Attempt reconnect if still recording
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
if self?.isRecording == true { 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), guard let data = text.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = json["type"] as? String, type == "Results", let type = json["type"] as? String else { return }
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 isFinal = json["is_final"] as? Bool ?? false switch type {
case "conversation.item.input_audio_transcription.delta":
if let delta = json["delta"] as? String {
currentInterim[source]! += delta
DispatchQueue.main.async { DispatchQueue.main.async {
let chunk = TranscriptChunk( // Remove previous interim chunk from the same source
timestamp: Date(), if let lastIndex = self.transcriptChunks.lastIndex(where: { !$0.isFinal && $0.source == source }) {
source: source, self.transcriptChunks.remove(at: lastIndex)
text: transcriptText, }
isFinal: isFinal let chunk = TranscriptChunk(
) timestamp: Date(),
source: source,
// For interim results, replace the last interim chunk from the same source text: self.currentInterim[source] ?? "",
if !isFinal { isFinal: false
// Remove the last interim chunk from the same source )
if let lastIndex = self.transcriptChunks.lastIndex(where: { !$0.isFinal && $0.source == source }) { self.transcriptChunks.append(chunk)
self.transcriptChunks.remove(at: lastIndex)
} }
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 } guard let socket = task, socket.state == .running else { return }
socket.send(.data(data)) { error in let base64 = data.base64EncodedString()
if let error = error { let message: [String: Any] = ["type": "input_audio_buffer.append", "audio": base64]
print("❌ Send error (\(source)): \(error)")
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 // Convert CMSampleBuffer to AVAudioPCMBuffer
guard let pcmBuffer = sampleBuffer.asPCMBuffer else { return } guard let pcmBuffer = sampleBuffer.asPCMBuffer else { return }
// Create converter for Deepgram format // Create converter for OpenAI format
let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16,
sampleRate: 16000, sampleRate: 24000,
channels: 1, channels: 1,
interleaved: false)! interleaved: false)!
+23 -51
View File
@@ -1,72 +1,44 @@
import Foundation import Foundation
struct Settings: Codable { struct Settings: Codable {
var deepgramKey: String
var openAIKey: String var openAIKey: String
var userBlurb: String var userBlurb: String
var systemPrompt: String var systemPrompt: String
static let defaultSystemPrompt: String = { // System prompt default loading
guard let url = Bundle.main.url(forResource: "DefaultSystemPrompt", withExtension: "txt"), static func defaultSystemPrompt() -> String {
let content = try? String(contentsOf: url) else { guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"),
assertionFailure("DefaultSystemPrompt.txt missing from bundle") let content = try? String(contentsOfFile: path) else {
return "" return "You are a helpful assistant that creates comprehensive meeting notes from transcript data."
} }
return content 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 // Add a computed property for the full prompt
var fullSystemPrompt: String {
/// Extracts all template variables from a system prompt string let defaultPrompt = Settings.defaultSystemPrompt()
static func extractTemplateVariables(from template: String) -> Set<String> { if userBlurb.isEmpty {
let pattern = #"\{\{(\w+)\}\}"# return defaultPrompt
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]))
}
} }
return variables return "\(defaultPrompt)\n\nContext about the user: \(userBlurb)"
} }
/// Validates that all required variables are present in the system prompt // Template processing method
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
static func processTemplate(_ template: String, with variables: [String: String]) -> String { static func processTemplate(_ template: String, with variables: [String: String]) -> String {
var result = template var result = template
for (key, value) in variables { for (key, value) in variables {
let placeholder = "{{\(key)}}" result = result.replacingOccurrences(of: "{{\(key)}}", with: value)
result = result.replacingOccurrences(of: placeholder, with: value)
} }
return result 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 { do {
// Load settings for generation // Load settings for generation
let userBlurb = KeychainHelper.shared.get(forKey: "userBlurb") ?? "" 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.generatedNotes = try await NotesGenerator.shared.generateNotes(
meeting: meeting, meeting: meeting,
+14 -33
View File
@@ -1,60 +1,41 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
@MainActor
class SettingsViewModel: ObservableObject { class SettingsViewModel: ObservableObject {
@Published var settings: Settings @Published var settings = Settings()
@Published var isSaving = false @Published var saveMessage = ""
@Published var saveSuccessful = false @Published var showingSaveMessage = false
@Published var errorMessage: String?
init() { init() {
self.settings = Settings()
loadSettings() loadSettings()
} }
func loadSettings() { func loadSettings() {
// Load API keys from Keychain
settings.deepgramKey = KeychainHelper.shared.get(forKey: "deepgramKey") ?? ""
settings.openAIKey = KeychainHelper.shared.get(forKey: "openAIKey") ?? "" settings.openAIKey = KeychainHelper.shared.get(forKey: "openAIKey") ?? ""
settings.userBlurb = KeychainHelper.shared.get(forKey: "userBlurb") ?? "" 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() { 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 openAISaved = KeychainHelper.shared.save(settings.openAIKey, forKey: "openAIKey")
let blurbSaved = KeychainHelper.shared.save(settings.userBlurb, forKey: "userBlurb") let blurbSaved = KeychainHelper.shared.save(settings.userBlurb, forKey: "userBlurb")
let promptSaved = KeychainHelper.shared.save(settings.systemPrompt, forKey: "systemPrompt") let promptSaved = KeychainHelper.shared.save(settings.systemPrompt, forKey: "systemPrompt")
if deepgramSaved && openAISaved && blurbSaved && promptSaved { if openAISaved && blurbSaved && promptSaved {
saveSuccessful = true saveMessage = "Settings saved successfully!"
// Show success briefly
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.saveSuccessful = false
}
} else { } 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() { func resetToDefaults() {
settings.systemPrompt = Settings.defaultSystemPrompt settings.systemPrompt = Settings.defaultSystemPrompt()
} }
} }
+1 -1
View File
@@ -71,7 +71,7 @@ struct MeetingListView: View {
} }
} }
.sheet(isPresented: $showingSettings) { .sheet(isPresented: $showingSettings) {
SettingsView() SettingsView(viewModel: SettingsViewModel())
} }
.navigationDestination(for: Meeting.self) { meeting in .navigationDestination(for: Meeting.self) { meeting in
MeetingDetailView(meeting: meeting) MeetingDetailView(meeting: meeting)
+65 -110
View File
@@ -1,146 +1,101 @@
import SwiftUI import SwiftUI
struct SettingsView: View { struct SettingsView: View {
@StateObject private var viewModel = SettingsViewModel() @ObservedObject var viewModel: SettingsViewModel
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView { ScrollView {
VStack(spacing: 32) { VStack(alignment: .leading, spacing: 24) {
// API Keys Section // API Configuration Section
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) {
Text("API Keys") Text("OpenAI API Key")
.font(.headline) .font(.headline)
.fontWeight(.semibold) .foregroundColor(.primary)
VStack(spacing: 12) { Text("Stored locally and encrypted.")
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()
}
.font(.caption) .font(.caption)
.foregroundColor(.accentColor) .foregroundColor(.secondary)
}
VStack(alignment: .leading, spacing: 8) { SecureField("OpenAI API Key", text: $viewModel.settings.openAIKey)
Text("System Prompt") .textFieldStyle(.roundedBorder)
.font(.subheadline) .frame(maxWidth: .infinity)
.foregroundColor(.secondary) }
Text("Customize how the AI generates your meeting notes.")
.font(.caption)
.foregroundColor(.secondary)
TextEditor(text: $viewModel.settings.systemPrompt) // User Information Section
.frame(minHeight: 100, maxHeight: 160) VStack(alignment: .leading, spacing: 8) {
.scrollContentBackground(.hidden) Text("User Information")
.background(Color(.controlBackgroundColor)) .font(.headline)
.cornerRadius(8) .foregroundColor(.primary)
.overlay(
RoundedRectangle(cornerRadius: 8) Text("Meetingsnotes works best when it knows a bit about you. You should give your name, role, company, and any other relevant information.")
.stroke(Color(.separatorColor), lineWidth: 1) .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") .navigationTitle("Settings")
.frame(minWidth: 600, minHeight: 500)
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { Button("Cancel") {
dismiss() dismiss()
} }
} }
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("Save") { Button("Save") {
viewModel.saveSettings() viewModel.saveSettings()
dismiss()
} }
.disabled(viewModel.isSaving)
.buttonStyle(.borderedProminent)
} }
} }
.onChange(of: viewModel.saveSuccessful) { _, isSuccessful in .frame(minWidth: 600, minHeight: 600)
if isSuccessful { }
dismiss() .onAppear {
} viewModel.loadSettings()
}
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") {
viewModel.errorMessage = nil
}
} message: {
Text(viewModel.errorMessage ?? "")
}
} }
} }
} }
#Preview { #Preview {
SettingsView() SettingsView(viewModel: SettingsViewModel())
} }