feat: use openai for tts instead of deepgram
This commit is contained in:
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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?
|
||||
@@ -23,6 +23,9 @@ class AudioManager: NSObject, ObservableObject {
|
||||
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()
|
||||
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() {
|
||||
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,46 +368,55 @@ 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
|
||||
switch type {
|
||||
case "conversation.item.input_audio_transcription.delta":
|
||||
if let delta = json["delta"] as? String {
|
||||
currentInterim[source]! += delta
|
||||
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
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 }
|
||||
let chunk = TranscriptChunk(
|
||||
timestamp: Date(),
|
||||
source: source,
|
||||
text: self.currentInterim[source] ?? "",
|
||||
isFinal: false
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private func sendAudioData(_ data: Data, source: AudioSource) {
|
||||
@@ -380,12 +424,22 @@ class AudioManager: NSObject, ObservableObject {
|
||||
|
||||
guard let socket = task, socket.state == .running else { return }
|
||||
|
||||
socket.send(.data(data)) { error in
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAudioEngineConfigurationChange() {
|
||||
print("🔔 Audio engine configuration changed - restarting mic")
|
||||
@@ -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)!
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ struct MeetingListView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingSettings) {
|
||||
SettingsView()
|
||||
SettingsView(viewModel: SettingsViewModel())
|
||||
}
|
||||
.navigationDestination(for: Meeting.self) { meeting in
|
||||
MeetingDetailView(meeting: meeting)
|
||||
|
||||
@@ -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")
|
||||
.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: 24) {
|
||||
// API Configuration Section
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("About Yourself")
|
||||
.font(.subheadline)
|
||||
Text("OpenAI API Key")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Stored locally and encrypted.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text("This information will be included in the AI context when generating meeting notes.")
|
||||
|
||||
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: 80, maxHeight: 120)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color(.controlBackgroundColor))
|
||||
.cornerRadius(8)
|
||||
.frame(minHeight: 100)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color(.separatorColor), lineWidth: 1)
|
||||
.stroke(Color.gray.opacity(0.3), 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)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
|
||||
// System Prompt Section
|
||||
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)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
TextEditor(text: $viewModel.settings.systemPrompt)
|
||||
.frame(minHeight: 100, maxHeight: 160)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color(.controlBackgroundColor))
|
||||
.cornerRadius(8)
|
||||
.frame(minHeight: 200)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color(.separatorColor), lineWidth: 1)
|
||||
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer(minLength: 20)
|
||||
// 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(.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()
|
||||
}
|
||||
.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())
|
||||
}
|
||||
Reference in New Issue
Block a user