feat: use Coder for meeting processing

This commit is contained in:
2026-07-14 22:22:31 +02:00
parent e0b576ad95
commit 6d676de99e
19 changed files with 743 additions and 980 deletions
+21
View File
@@ -0,0 +1,21 @@
name: Build
on:
pull_request:
branches: [main]
workflow_dispatch:
jobs:
macos:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Build Meetingnotes
run: >-
xcodebuild
-project Meetingnotes.xcodeproj
-scheme meetingnotes
-configuration Debug
-destination 'platform=macOS'
CODE_SIGNING_ALLOWED=NO
build
-17
View File
@@ -7,7 +7,6 @@
objects = {
/* Begin PBXBuildFile section */
CC5718382E2020490042BA88 /* OpenAI in Frameworks */ = {isa = PBXBuildFile; productRef = CC5718372E2020490042BA88 /* OpenAI */; };
CCBA53E72E212F1C00469D84 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = CCBA53E62E212F1C00469D84 /* Sparkle */; };
CCC33F812E236B6F00EDE382 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = CCC33F802E236B6F00EDE382 /* PostHog */; };
/* End PBXBuildFile section */
@@ -30,7 +29,6 @@
buildActionMask = 2147483647;
files = (
CCC33F812E236B6F00EDE382 /* PostHog in Frameworks */,
CC5718382E2020490042BA88 /* OpenAI in Frameworks */,
CCBA53E72E212F1C00469D84 /* Sparkle in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -82,7 +80,6 @@
);
name = Meetingnotes;
packageProductDependencies = (
CC5718372E2020490042BA88 /* OpenAI */,
CCBA53E62E212F1C00469D84 /* Sparkle */,
CCC33F802E236B6F00EDE382 /* PostHog */,
);
@@ -115,7 +112,6 @@
mainGroup = CC57180F2E201D8D0042BA88;
minimizedProjectReferenceProxies = 1;
packageReferences = (
CC5718362E2020490042BA88 /* XCRemoteSwiftPackageReference "OpenAI" */,
CCBA53E52E212F1C00469D84 /* XCRemoteSwiftPackageReference "Sparkle" */,
CCC33F7F2E236B6F00EDE382 /* XCRemoteSwiftPackageReference "posthog-ios" */,
);
@@ -365,14 +361,6 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
CC5718362E2020490042BA88 /* XCRemoteSwiftPackageReference "OpenAI" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/MacPaw/OpenAI";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.4.4;
};
};
CCBA53E52E212F1C00469D84 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle.git";
@@ -392,11 +380,6 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
CC5718372E2020490042BA88 /* OpenAI */ = {
isa = XCSwiftPackageProductDependency;
package = CC5718362E2020490042BA88 /* XCRemoteSwiftPackageReference "OpenAI" */;
productName = OpenAI;
};
CCBA53E62E212F1C00469D84 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = CCBA53E52E212F1C00469D84 /* XCRemoteSwiftPackageReference "Sparkle" */;
@@ -1,15 +1,6 @@
{
"originHash" : "121a6c330462966c69b74d5d4f619f194dc62623e87741aa10ee74337154e7b5",
"pins" : [
{
"identity" : "openai",
"kind" : "remoteSourceControl",
"location" : "https://github.com/MacPaw/OpenAI",
"state" : {
"revision" : "cedec2fc80aedafa332f7395b7004f79f547c794",
"version" : "0.4.4"
}
},
{
"identity" : "posthog-ios",
"kind" : "remoteSourceControl",
+3 -5
View File
@@ -100,14 +100,14 @@ Instructions for cloning the repo, installing packages, configuring environment
Implemented:
- Recording mic & system audio
- Live transcript
- End-of-meeting transcription through your Coder service
- Ability to also write down additional notes
- AI generated enhanced notes
- Copy functionality
- Meeting deletion functionality
- Meeting search functionality
- Abilty to edit system prompt
- Use your own API key
- Select any compatible Coder model for transcription and note generation
- Auto updates
- Text formatting
- Different note templates
@@ -116,7 +116,7 @@ Implemented:
Todo:
- check for funds / validity of openai api key
- improve provider balance and token validation errors
- add padding to text inputs
- add confirmation when clicking the copy button
@@ -125,8 +125,6 @@ Later:
- Cool recording indicator (dancing bars)
- Connecting to your Google calendar
- AI chat for asking questions about a meeting
- Ability to use different models
- Ability to use different STT providers
- Integrations for email, Slack, Notion, etc.
## Local Development
+6 -1
View File
@@ -2,12 +2,17 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSAudioCaptureUsageDescription</key>
<string>Meetingnotes needs access to capture system audio for transcription.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Meetingnotes needs access to your microphone for transcription.</string>
<key>SUFeedURL</key>
<string>https://raw.githubusercontent.com/owengretzinger/meetingnotes/main/appcast.xml</string>
<string>https://raw.githubusercontent.com/superdooper86/meetingnotes/main/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>BVXHOV8ZxPxKZ1swhFndymzew9nyd3si7849JA9cqsg=</string>
<key>SUEnableInstallerLauncherService</key>
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -14,15 +14,15 @@ class KeychainHelper {
/// Gets the API key directly from keychain
/// - Returns: The API key string if found, nil otherwise
func getAPIKey() -> String? {
return get(forKey: "openAIKey")
func getCoderAPIKey() -> String? {
return get(forKey: "coderAPIKey")
}
/// Saves the API key to keychain
/// - Parameter apiKey: The API key to save
/// - Returns: True if the save was successful, false otherwise
func saveAPIKey(_ apiKey: String) -> Bool {
return save(apiKey, forKey: "openAIKey")
func saveCoderAPIKey(_ apiKey: String) -> Bool {
return save(apiKey, forKey: "coderAPIKey")
}
/// Saves a string value to the keychain
@@ -80,4 +80,4 @@ class KeychainHelper {
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess
}
}
}
@@ -8,6 +8,7 @@ class RecordingSessionManager: ObservableObject {
static let shared = RecordingSessionManager()
@Published var isRecording = false
@Published var isProcessing = false
@Published var activeMeetingId: UUID?
@Published var errorMessage: String?
@Published var activeRecordingTranscriptChunksUpdated: [TranscriptChunk] = []
@@ -32,6 +33,12 @@ class RecordingSessionManager: ObservableObject {
}
.store(in: &cancellables)
audioManager.$isProcessing
.sink { [weak self] isProcessing in
self?.isProcessing = isProcessing
}
.store(in: &cancellables)
audioManager.$errorMessage
.sink { [weak self] errorMessage in
self?.errorMessage = errorMessage
@@ -75,16 +82,24 @@ class RecordingSessionManager: ObservableObject {
audioManager.startRecording()
}
func stopRecording() {
func stopRecording() async -> [TranscriptChunk] {
print("🛑 Stopping recording for meeting: \(activeMeetingId?.uuidString ?? "unknown")")
audioManager.stopRecording()
// Perform a final, immediate save of transcript chunks to the meeting
if let activeMeetingId = activeMeetingId {
updateActiveMeetingTranscript(meetingId: activeMeetingId, chunks: activeRecordingTranscriptChunks)
guard let meetingId = activeMeetingId else {
audioManager.cancelRecording()
return []
}
let chunks = await audioManager.stopRecordingAndTranscribe()
activeRecordingTranscriptChunks = chunks
activeRecordingTranscriptChunksUpdated = chunks
updateActiveMeetingTranscript(meetingId: meetingId, chunks: chunks)
activeMeetingId = nil
activeRecordingTranscriptChunks = []
return chunks
}
func cancelRecording() {
audioManager.cancelRecording()
activeMeetingId = nil
activeRecordingTranscriptChunks = []
}
@@ -18,6 +18,9 @@ class UserDefaultsManager {
static let hasCompletedOnboarding = "hasCompletedOnboarding"
static let hasAcceptedTerms = "hasAcceptedTerms"
static let selectedTemplateId = "selectedTemplateId"
static let coderBaseURL = "coderBaseURL"
static let notesModel = "notesModel"
static let transcriptionModel = "transcriptionModel"
}
// MARK: - User Blurb
@@ -61,4 +64,19 @@ class UserDefaultsManager {
}
}
}
}
var coderBaseURL: String {
get { userDefaults.string(forKey: Keys.coderBaseURL) ?? "http://dev:8787/v1" }
set { userDefaults.set(newValue, forKey: Keys.coderBaseURL) }
}
var notesModel: String {
get { userDefaults.string(forKey: Keys.notesModel) ?? "gpt-5.4-mini" }
set { userDefaults.set(newValue, forKey: Keys.notesModel) }
}
var transcriptionModel: String {
get { userDefaults.string(forKey: Keys.transcriptionModel) ?? "groq/whisper-large-v3-turbo" }
set { userDefaults.set(newValue, forKey: Keys.transcriptionModel) }
}
}
+20 -6
View File
@@ -1,8 +1,7 @@
import Foundation
struct Settings: Codable {
// Only store API key in memory - will be loaded from keychain when needed
var openAIKey: String = ""
var coderAPIKey: String = ""
// Computed properties that access UserDefaults
var userBlurb: String {
@@ -30,6 +29,21 @@ struct Settings: Codable {
set { UserDefaultsManager.shared.hasAcceptedTerms = newValue }
}
var coderBaseURL: String {
get { UserDefaultsManager.shared.coderBaseURL }
set { UserDefaultsManager.shared.coderBaseURL = newValue }
}
var notesModel: String {
get { UserDefaultsManager.shared.notesModel }
set { UserDefaultsManager.shared.notesModel = newValue }
}
var transcriptionModel: String {
get { UserDefaultsManager.shared.transcriptionModel }
set { UserDefaultsManager.shared.transcriptionModel = newValue }
}
// System prompt default loading
static func defaultSystemPrompt() -> String {
guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"),
@@ -57,12 +71,12 @@ struct Settings: Codable {
return result
}
init(openAIKey: String = "") {
self.openAIKey = openAIKey
init(coderAPIKey: String = "") {
self.coderAPIKey = coderAPIKey
}
// MARK: - Codable conformance for API key only
private enum CodingKeys: String, CodingKey {
case openAIKey
case coderAPIKey
}
}
}
+14 -55
View File
@@ -1,88 +1,47 @@
// APIKeyValidator.swift
// Service to validate OpenAI API keys and check for sufficient funds
import Foundation
/// Service to validate OpenAI API keys
class APIKeyValidator {
static let shared = APIKeyValidator()
final class CoderAPIValidator {
static let shared = CoderAPIValidator()
private init() {}
/// Validates the OpenAI API key by making a test request
/// - Parameter apiKey: The API key to validate
/// - Returns: Result indicating success or failure with error message
func validateAPIKey(_ apiKey: String) async -> Result<Void, APIKeyValidationError> {
guard !apiKey.isEmpty else {
guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return .failure(.emptyKey)
}
// Make a simple request to validate the key
guard let url = URL(string: "https://api.openai.com/v1/models") else {
return .failure(.invalidURL)
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
return .failure(.networkError("Invalid response"))
}
switch httpResponse.statusCode {
case 200:
// Key is valid - check if models are available
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let models = json["data"] as? [[String: Any]], !models.isEmpty {
return .success(())
} else {
return .failure(.noModelsAvailable)
}
default:
let errorMessage = ErrorHandler.shared.handleHTTPStatusCode(httpResponse.statusCode)
return .failure(.httpError(errorMessage))
}
let models = try await CoderAPIClient.shared.models(
baseURL: UserDefaultsManager.shared.coderBaseURL,
apiKey: apiKey
)
return models.isEmpty ? .failure(.noModelsAvailable) : .success(())
} catch let error as CoderAPIError {
return .failure(.connection(error.localizedDescription))
} catch {
let errorMessage = ErrorHandler.shared.handleError(error)
return .failure(.networkError(errorMessage))
return .failure(.connection(error.localizedDescription))
}
}
/// Validates the currently stored API key
/// - Returns: Result indicating success or failure with error message
func validateCurrentAPIKey() async -> Result<Void, APIKeyValidationError> {
guard let apiKey = KeychainHelper.shared.getAPIKey() else {
guard let apiKey = KeychainHelper.shared.getCoderAPIKey() else {
return .failure(.emptyKey)
}
return await validateAPIKey(apiKey)
}
}
/// Errors that can occur during API key validation
enum APIKeyValidationError: Error, LocalizedError {
case emptyKey
case invalidURL
case noModelsAvailable
case networkError(String)
case httpError(String)
case connection(String)
var errorDescription: String? {
switch self {
case .emptyKey:
return ErrorMessage.noAPIKey
case .invalidURL:
return ErrorMessage.invalidURL
case .noModelsAvailable:
return ErrorMessage.noModelsAvailable
case .networkError(let message):
return message
case .httpError(let message):
case .connection(let message):
return message
}
}
+206
View File
@@ -0,0 +1,206 @@
import Foundation
struct CoderModel: Codable, Identifiable, Hashable {
let id: String
let name: String?
let ownedBy: String
let capabilities: [String]
enum CodingKeys: String, CodingKey {
case id
case name
case ownedBy = "owned_by"
case capabilities
}
var displayName: String {
let label = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return label.isEmpty ? id : label
}
var supportsChat: Bool { capabilities.isEmpty || capabilities.contains("chat") }
var supportsTranscription: Bool { capabilities.contains("audio_transcription") }
}
enum CoderAPIError: LocalizedError {
case invalidBaseURL
case missingAPIKey
case missingModel(String)
case invalidResponse
case serviceError(Int, String)
var errorDescription: String? {
switch self {
case .invalidBaseURL:
return "Enter a valid Coder service URL."
case .missingAPIKey:
return "Enter the Coder service token."
case .missingModel(let purpose):
return "Select a Coder model for \(purpose)."
case .invalidResponse:
return "Coder returned an invalid response."
case .serviceError(let status, let message):
return "Coder request failed (\(status)): \(message)"
}
}
}
final class CoderAPIClient {
static let shared = CoderAPIClient()
private struct ModelsResponse: Decodable {
let data: [CoderModel]
}
private struct ErrorEnvelope: Decodable {
struct ServiceError: Decodable { let message: String }
let error: ServiceError
}
private struct TranscriptionResponse: Decodable {
let text: String
}
private init() {}
func models(baseURL: String, apiKey: String) async throws -> [CoderModel] {
var request = URLRequest(url: try endpoint(baseURL: baseURL, path: "models"))
request.setValue("Bearer \(try requiredAPIKey(apiKey))", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
try validate(response: response, data: data)
return try JSONDecoder().decode(ModelsResponse.self, from: data).data
}
func models() async throws -> [CoderModel] {
try await models(
baseURL: UserDefaultsManager.shared.coderBaseURL,
apiKey: KeychainHelper.shared.getCoderAPIKey() ?? ""
)
}
func streamChat(systemPrompt: String, model: String) -> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
Task {
do {
let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("notes") }
let baseURL = UserDefaultsManager.shared.coderBaseURL
let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "")
var request = URLRequest(url: try endpoint(baseURL: baseURL, path: "chat/completions"))
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: [
"model": selectedModel,
"messages": [
["role": "system", "content": systemPrompt],
["role": "user", "content": "Create the meeting notes now."]
],
"stream": true
])
let (bytes, response) = try await URLSession.shared.bytes(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw CoderAPIError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw CoderAPIError.serviceError(httpResponse.statusCode, HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode))
}
for try await line in bytes.lines {
guard line.hasPrefix("data:") else { continue }
let payload = String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces)
if payload == "[DONE]" { break }
guard let data = payload.data(using: .utf8),
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let choices = json["choices"] as? [[String: Any]],
let delta = choices.first?["delta"] as? [String: Any],
let content = delta["content"] as? String,
!content.isEmpty else {
continue
}
continuation.yield(content)
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
func transcribe(fileURL: URL, model: String, language: String = "en") async throws -> String {
let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("transcription") }
let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "")
let boundary = "Meetingnotes-\(UUID().uuidString)"
let bodyURL = try makeMultipartBody(
audioURL: fileURL,
model: selectedModel,
language: language,
boundary: boundary
)
defer { try? FileManager.default.removeItem(at: bodyURL) }
var request = URLRequest(url: try endpoint(baseURL: UserDefaultsManager.shared.coderBaseURL, path: "audio/transcriptions"))
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
if let attributes = try? FileManager.default.attributesOfItem(atPath: bodyURL.path),
let size = attributes[.size] as? NSNumber {
request.setValue(size.stringValue, forHTTPHeaderField: "Content-Length")
}
let (data, response) = try await URLSession.shared.upload(for: request, fromFile: bodyURL)
try validate(response: response, data: data)
return try JSONDecoder().decode(TranscriptionResponse.self, from: data).text
}
private func endpoint(baseURL: String, path: String) throws -> URL {
guard var components = URLComponents(string: baseURL.trimmingCharacters(in: .whitespacesAndNewlines)),
let scheme = components.scheme?.lowercased(),
scheme == "http" || scheme == "https",
components.host != nil else {
throw CoderAPIError.invalidBaseURL
}
var basePath = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
if basePath.isEmpty { basePath = "v1" }
components.path = "/\(basePath)/\(path.trimmingCharacters(in: CharacterSet(charactersIn: "/")))"
guard let url = components.url else { throw CoderAPIError.invalidBaseURL }
return url
}
private func requiredAPIKey(_ value: String) throws -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw CoderAPIError.missingAPIKey }
return trimmed
}
private func validate(response: URLResponse, data: Data) throws {
guard let httpResponse = response as? HTTPURLResponse else { throw CoderAPIError.invalidResponse }
guard (200...299).contains(httpResponse.statusCode) else {
let message = (try? JSONDecoder().decode(ErrorEnvelope.self, from: data).error.message)
?? HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode)
throw CoderAPIError.serviceError(httpResponse.statusCode, message)
}
}
private func makeMultipartBody(audioURL: URL, model: String, language: String, boundary: String) throws -> URL {
let bodyURL = FileManager.default.temporaryDirectory.appendingPathComponent("meetingnotes-upload-\(UUID().uuidString).body")
_ = FileManager.default.createFile(atPath: bodyURL.path, contents: nil)
let output = try FileHandle(forWritingTo: bodyURL)
defer { try? output.close() }
func write(_ value: String) throws {
try output.write(contentsOf: Data(value.utf8))
}
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\n\(model)\r\n")
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"language\"\r\n\r\n\(language)\r\n")
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(audioURL.lastPathComponent)\"\r\nContent-Type: audio/mp4\r\n\r\n")
let input = try FileHandle(forReadingFrom: audioURL)
defer { try? input.close() }
while let chunk = try input.read(upToCount: 1 << 20), !chunk.isEmpty {
try output.write(contentsOf: chunk)
}
try write("\r\n--\(boundary)--\r\n")
return bodyURL
}
}
+15 -15
View File
@@ -1,5 +1,5 @@
// ErrorHandler.swift
// Centralized error handling service for OpenAI API and network errors
// Centralized error handling service for Coder API and network errors
import Foundation
@@ -9,7 +9,7 @@ class ErrorHandler {
private init() {}
/// Handles errors from OpenAI API calls and network requests
/// Handles errors from Coder API calls and network requests
/// - Parameter error: The error to handle
/// - Returns: User-friendly error message
func handleError(_ error: Error) -> String {
@@ -23,10 +23,10 @@ class ErrorHandler {
return handleHTTPError(httpError)
}
// Handle OpenAI API errors by checking error description
// Handle common API errors by checking the description.
let errorDescription = error.localizedDescription.lowercased()
if let openAIError = categorizeOpenAIError(errorDescription) {
return openAIError
if let apiError = categorizeAPIError(errorDescription) {
return apiError
}
// Generic error fallback
@@ -125,9 +125,9 @@ class ErrorHandler {
case .timedOut:
return "Request timed out. Please try again."
case .cannotFindHost:
return "Cannot reach OpenAI servers. Please check your internet connection."
return "Cannot reach the Coder service. Check its URL and network connection."
case .cannotConnectToHost:
return "Cannot connect to OpenAI servers. Please check your internet connection."
return "Cannot connect to the Coder service. Check its URL and network connection."
case .networkConnectionLost:
return "Network connection lost. Please try again."
case .httpTooManyRedirects:
@@ -145,7 +145,7 @@ class ErrorHandler {
return handleHTTPStatusCode(httpError.statusCode, message: httpError.message)
}
private func categorizeOpenAIError(_ errorDescription: String) -> String? {
private func categorizeAPIError(_ errorDescription: String) -> String? {
if errorDescription.contains("unauthorized") || errorDescription.contains("401") {
return ErrorMessage.invalidAPIKey
} else if errorDescription.contains("insufficient") || errorDescription.contains("402") {
@@ -177,23 +177,23 @@ struct HTTPError: Error {
/// Common error messages
enum ErrorMessage {
static let noAPIKey = "OpenAI API key not found. Please configure your API key in Settings."
static let noAPIKey = "Coder service token not found. Configure the connection in Settings."
static let noTemplate = "No template content found. Please select a valid template."
static let noTranscript = "No transcript available. Please record some audio first."
static let connectionTimeout = "Failed to connect to OpenAI transcription service. Please check your internet connection and API key."
static let connectionTimeout = "Failed to connect to the Coder service. Check the URL, token, and network connection."
static let configurationFailed = "Failed to configure transcription session."
static let invalidURL = "Invalid API URL configuration."
static let noModelsAvailable = "No models available with your API key. Please check your account status."
static let noModelsAvailable = "Coder did not return any available models."
// Centralized messages used across handlers
static let success = "Success"
static let badRequest = "Bad request. Please check your input."
static let invalidAPIKey = "Invalid OpenAI API key. Please check your API key in Settings."
static let insufficientFunds = "Insufficient funds in your OpenAI account. Please add credits to your account."
static let invalidAPIKey = "Invalid Coder service token. Check the token in Settings."
static let insufficientFunds = "The selected Coder provider has insufficient balance."
static let accessForbidden = "Access forbidden. Please check your API key permissions."
static let apiEndpointNotFound = "API endpoint not found. Please update the app."
static let rateLimited = "OpenAI API rate limit exceeded. Please try again later."
static let apiServerError = "OpenAI server error. Please try again later."
static let rateLimited = "The selected Coder provider is rate limited. Please try again later."
static let apiServerError = "Coder or its selected provider returned a server error."
static let requestTimeout = "Request timeout. Please try again."
static let requestTooLarge = "Request too large. Please try again."
static let unsupportedData = "Unsupported data format. Please update the app."
+12 -21
View File
@@ -1,8 +1,7 @@
// NotesGenerator.swift
// Handles AI-powered note generation using OpenAI
// Handles AI-powered note generation through Coder
import Foundation
import OpenAI
/// Result type for note generation streaming
enum GenerationResult {
@@ -10,7 +9,7 @@ enum GenerationResult {
case error(String)
}
/// Generates meeting notes using OpenAI API
/// Generates meeting notes using the selected Coder model
class NotesGenerator {
static let shared = NotesGenerator()
@@ -31,14 +30,14 @@ class NotesGenerator {
return AsyncStream<GenerationResult>(GenerationResult.self) { continuation in
Task {
do {
guard let apiKey = KeychainHelper.shared.getAPIKey(), !apiKey.isEmpty else {
guard let apiKey = KeychainHelper.shared.getCoderAPIKey(), !apiKey.isEmpty else {
continuation.yield(.error(ErrorMessage.noAPIKey))
continuation.finish()
return
}
// Validate API key before proceeding
let validationResult = await APIKeyValidator.shared.validateAPIKey(apiKey)
let validationResult = await CoderAPIValidator.shared.validateAPIKey(apiKey)
switch validationResult {
case .failure(let error):
continuation.yield(.error(error.localizedDescription))
@@ -48,8 +47,6 @@ class NotesGenerator {
break
}
let openAI = OpenAI(apiToken: apiKey)
// Create date formatter for meeting date
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .full
@@ -90,18 +87,12 @@ class NotesGenerator {
// Process the system prompt template
let systemContent = Settings.processTemplate(systemPrompt, with: templateVariables)
let systemMessage = ChatQuery.ChatCompletionMessageParam(role: .system, content: systemContent)!
print(systemContent)
let query = ChatQuery(messages: [systemMessage], model: .gpt4_1)
let stream: AsyncThrowingStream<ChatStreamResult, Error> = openAI.chatsStream(query: query)
for try await result in stream {
if let content = result.choices.first?.delta.content {
continuation.yield(.content(content))
}
let stream = CoderAPIClient.shared.streamChat(
systemPrompt: systemContent,
model: UserDefaultsManager.shared.notesModel
)
for try await content in stream {
continuation.yield(.content(content))
}
continuation.finish()
@@ -115,10 +106,10 @@ class NotesGenerator {
}
}
/// Validates if OpenAI API key is configured
/// Validates if the Coder service token is configured
/// - Returns: True if API key exists, false otherwise
func isConfigured() -> Bool {
guard let key = KeychainHelper.shared.getAPIKey(),
guard let key = KeychainHelper.shared.getCoderAPIKey(),
!key.isEmpty else {
return false
}
+33 -13
View File
@@ -28,20 +28,24 @@ class MeetingViewModel: ObservableObject {
// Computed property to determine if Generate button should animate
var shouldAnimateGenerateButton: Bool {
let generateButtonEnabled = !meeting.transcript.isEmpty && !isGeneratingNotes && !isRecording && !isStartingRecording
let generateButtonEnabled = !meeting.transcript.isEmpty && !isGeneratingNotes && !isRecording && !isProcessing && !isStartingRecording
let noEnhancedNotesYet = meeting.generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
return generateButtonEnabled && noEnhancedNotesYet
}
// Computed property to determine if Transcribe button should animate
// Computed property to determine if the record button should animate
var shouldAnimateTranscribeButton: Bool {
return !isRecording && meeting.transcriptChunks.isEmpty && !isStartingRecording
return !isRecording && !isProcessing && meeting.transcriptChunks.isEmpty && !isStartingRecording
}
// Computed property that always uses the direct RecordingSessionManager check
var isRecording: Bool {
return recordingSessionManager.isRecordingMeeting(meeting.id)
}
var isProcessing: Bool {
return recordingSessionManager.isProcessing && recordingSessionManager.activeMeetingId == meeting.id
}
@Published var selectedTab: MeetingViewTab = .transcript // Default to transcript tab
@Published var isDeleted = false
@@ -110,6 +114,12 @@ class MeetingViewModel: ObservableObject {
}
.store(in: &cancellables)
recordingSessionManager.$isProcessing
.sink { [weak self] _ in
self?.recordingStateChanged.toggle()
}
.store(in: &cancellables)
// Update error message when recording session manager encounters errors
recordingSessionManager.$errorMessage
.compactMap { $0 }
@@ -130,7 +140,7 @@ class MeetingViewModel: ObservableObject {
self.meeting.transcriptChunks = recordingSessionManager.getTranscriptChunks(for: meeting.id)
}
// Listen to real-time transcript updates for this meeting if it's being recorded
// Listen for final transcript updates for this meeting.
recordingSessionManager.$activeRecordingTranscriptChunksUpdated
.dropFirst()
.sink { [weak self] updatedChunks in
@@ -158,18 +168,20 @@ class MeetingViewModel: ObservableObject {
var recordingButtonText: String {
// Use the same computed isRecording property for perfect consistency
if isProcessing {
return "Processing"
}
if isRecording {
return "Stop"
} else {
// Check if there's existing transcript content
return meeting.transcriptChunks.isEmpty ? "Transcribe" : "Resume"
return meeting.transcriptChunks.isEmpty ? "Record" : "Resume"
}
}
func toggleRecording() {
// Prevent duplicate actions while validating API key or starting recording
if isValidatingKey || isStartingRecording { return }
if isValidatingKey || isStartingRecording || isProcessing { return }
// Use the same computed isRecording property for perfect consistency
if isRecording {
stopRecording()
@@ -183,7 +195,7 @@ class MeetingViewModel: ObservableObject {
isValidatingKey = true
isStartingRecording = true
Task {
let validationResult = await APIKeyValidator.shared.validateCurrentAPIKey()
let validationResult = await CoderAPIValidator.shared.validateCurrentAPIKey()
defer { isValidatingKey = false }
switch validationResult {
@@ -201,8 +213,16 @@ class MeetingViewModel: ObservableObject {
}
func stopRecording() {
recordingSessionManager.stopRecording()
saveMeeting()
isStartingRecording = true
Task {
let chunks = await recordingSessionManager.stopRecording()
meeting.transcriptChunks = chunks
saveMeeting()
if !meeting.formattedTranscript.isEmpty {
await generateNotes()
}
isStartingRecording = false
}
}
func loadTemplates() {
@@ -301,7 +321,7 @@ class MeetingViewModel: ObservableObject {
// If this meeting is currently being recorded, stop the recording first
if recordingSessionManager.isRecordingMeeting(meeting.id) {
print("🛑 Stopping recording for meeting being deleted: \(meeting.id)")
recordingSessionManager.stopRecording()
recordingSessionManager.cancelRecording()
}
let success = LocalStorageManager.shared.deleteMeeting(meeting)
@@ -312,11 +332,11 @@ class MeetingViewModel: ObservableObject {
}
func deleteIfEmpty() {
if isEmpty && !isRecording {
if isEmpty && !isRecording && !isProcessing {
print("🗑️ Auto-deleting empty meeting")
deleteMeeting()
} else {
saveMeeting()
}
}
}
}
@@ -7,15 +7,43 @@ class SettingsViewModel: ObservableObject {
@Published var saveMessage = ""
@Published var showingSaveMessage = false
@Published var templates: [NoteTemplate] = []
@Published var coderModels: [CoderModel] = []
@Published var isLoadingModels = false
@Published var connectionMessage = ""
init() {
loadTemplates()
}
/// Loads the API key from keychain (only called when actually needed)
/// Loads the Coder service token from Keychain.
func loadAPIKey() {
if settings.openAIKey.isEmpty {
settings.openAIKey = KeychainHelper.shared.getAPIKey() ?? ""
if settings.coderAPIKey.isEmpty {
settings.coderAPIKey = KeychainHelper.shared.getCoderAPIKey() ?? ""
}
}
@MainActor
func refreshModels() async {
isLoadingModels = true
connectionMessage = ""
defer { isLoadingModels = false }
do {
coderModels = try await CoderAPIClient.shared.models(
baseURL: settings.coderBaseURL,
apiKey: settings.coderAPIKey
)
let chatModels = coderModels.filter(\.supportsChat)
let transcriptionModels = coderModels.filter(\.supportsTranscription)
if !chatModels.contains(where: { $0.id == settings.notesModel }), let first = chatModels.first {
settings.notesModel = first.id
}
if !transcriptionModels.contains(where: { $0.id == settings.transcriptionModel }), let first = transcriptionModels.first {
settings.transcriptionModel = first.id
}
connectionMessage = "Connected to Coder"
} catch {
coderModels = []
connectionMessage = error.localizedDescription
}
}
@@ -55,10 +83,10 @@ class SettingsViewModel: ObservableObject {
// Only save API key to keychain - other values are automatically saved to UserDefaults
// via computed properties when they're modified
let openAISaved = KeychainHelper.shared.saveAPIKey(settings.openAIKey)
let coderSaved = KeychainHelper.shared.saveCoderAPIKey(settings.coderAPIKey)
if showMessage {
if openAISaved {
if coderSaved {
saveMessage = "Settings saved successfully!"
} else {
saveMessage = "Error saving settings"
+10 -4
View File
@@ -321,7 +321,7 @@ struct MeetingDetailContentView: View {
)
}
.buttonStyle(.plain)
.disabled(viewModel.meeting.transcript.isEmpty || viewModel.isGeneratingNotes || viewModel.isRecording || viewModel.isStartingRecording)
.disabled(viewModel.meeting.transcript.isEmpty || viewModel.isGeneratingNotes || viewModel.isRecording || viewModel.isProcessing || viewModel.isStartingRecording)
.help("Generate enhanced notes using a template")
// Recording Button
@@ -329,8 +329,14 @@ struct MeetingDetailContentView: View {
viewModel.toggleRecording()
}) {
HStack(spacing: 4) {
Image(systemName: viewModel.isRecording ? "stop.circle.fill" : "record.circle")
.foregroundColor(viewModel.isRecording ? .red : .accentColor)
if viewModel.isProcessing {
ProgressView()
.scaleEffect(0.55)
.frame(width: 14, height: 14)
} else {
Image(systemName: viewModel.isRecording ? "stop.circle.fill" : "record.circle")
.foregroundColor(viewModel.isRecording ? .red : .accentColor)
}
Text(viewModel.recordingButtonText)
}
.frame(minWidth: 110, minHeight: 36)
@@ -347,7 +353,7 @@ struct MeetingDetailContentView: View {
)
}
.buttonStyle(.plain)
.disabled(cannotStartRecording || viewModel.isValidatingKey || viewModel.isStartingRecording)
.disabled(cannotStartRecording || viewModel.isValidatingKey || viewModel.isStartingRecording || viewModel.isProcessing)
.help(cannotStartRecording ? "Another meeting is currently being recorded" : "Start or stop recording for this meeting")
}
}
+43 -14
View File
@@ -43,33 +43,60 @@ struct OnboardingView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
// API Key Section
// Coder service
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 4) {
Text("OpenAI API Key")
Text("Coder Service")
.font(.title2)
.fontWeight(.semibold)
Text("Uses gpt-4o-mini-transcribe and gpt-4.1. Typical cost is ~$0.20/hour. Your Mac communicates directly with OpenAI.")
Text("Meeting audio is transcribed after recording, then summarized with the Coder models you select.")
.font(.body)
.foregroundColor(.secondary)
.foregroundColor(.secondary)
}
Button("Get API Key from OpenAI") {
if let url = URL(string: "https://platform.openai.com/api-keys") {
NSWorkspace.shared.open(url)
}
}
.buttonStyle(.link)
TextField("http://coder-host:8787/v1", text: $settingsViewModel.settings.coderBaseURL)
.textFieldStyle(.roundedBorder)
.font(.body)
SecureField("OpenAI API Key", text: $apiKey)
SecureField("Coder service token", text: $apiKey)
.textFieldStyle(.roundedBorder)
.font(.body)
HStack {
Button {
settingsViewModel.settings.coderAPIKey = apiKey
Task { await settingsViewModel.refreshModels() }
} label: {
Label(settingsViewModel.isLoadingModels ? "Connecting" : "Connect", systemImage: "arrow.clockwise")
}
.disabled(settingsViewModel.isLoadingModels)
if !settingsViewModel.connectionMessage.isEmpty {
Text(settingsViewModel.connectionMessage)
.font(.caption)
.foregroundColor(settingsViewModel.coderModels.isEmpty ? .red : .secondary)
}
}
if !settingsViewModel.coderModels.isEmpty {
Picker("Notes model", selection: $settingsViewModel.settings.notesModel) {
ForEach(settingsViewModel.coderModels.filter(\.supportsChat)) { model in
Text(model.displayName).tag(model.id)
}
}
Picker("Transcription model", selection: $settingsViewModel.settings.transcriptionModel) {
ForEach(settingsViewModel.coderModels.filter(\.supportsTranscription)) { model in
Text(model.displayName).tag(model.id)
}
}
}
HStack {
Image(systemName: "info.circle")
.foregroundColor(.blue)
Text("Stored locally and encrypted in Keychain.")
Text("The token is stored locally in Keychain. The app does not connect directly to an AI provider.")
.font(.caption)
.foregroundColor(.secondary)
}
@@ -130,7 +157,7 @@ struct OnboardingView: View {
Spacer()
Button("Get Started") {
// Complete onboarding
settingsViewModel.settings.openAIKey = apiKey
settingsViewModel.settings.coderAPIKey = apiKey
settingsViewModel.completeOnboarding()
}
.buttonStyle(.borderedProminent)
@@ -155,7 +182,7 @@ struct OnboardingView: View {
.onAppear {
checkPermissions()
settingsViewModel.loadAPIKey()
apiKey = settingsViewModel.settings.openAIKey
apiKey = settingsViewModel.settings.coderAPIKey
hasAcceptedTerms = settingsViewModel.settings.hasAcceptedTerms
}
.onChange(of: audioRecordingPermission.status) { oldValue, newValue in
@@ -172,6 +199,8 @@ struct OnboardingView: View {
return micPermissionGranted &&
systemAudioPermissionGranted &&
!apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!settingsViewModel.coderModels.filter(\.supportsChat).isEmpty &&
!settingsViewModel.coderModels.filter(\.supportsTranscription).isEmpty &&
hasAcceptedTerms
}
+40 -8
View File
@@ -13,19 +13,50 @@ struct SettingsView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
// API Configuration Section
// Coder service configuration
VStack(alignment: .leading, spacing: 8) {
Text("OpenAI API Key")
Text("Coder Service")
.font(.headline)
.foregroundColor(.primary)
Text("Stored locally and encrypted in Keychain.")
TextField("http://coder-host:8787/v1", text: $viewModel.settings.coderBaseURL)
.textFieldStyle(.roundedBorder)
SecureField("Service token", text: $viewModel.settings.coderAPIKey)
.textFieldStyle(.roundedBorder)
HStack {
Button {
Task { await viewModel.refreshModels() }
} label: {
Label(viewModel.isLoadingModels ? "Loading" : "Refresh Models", systemImage: "arrow.clockwise")
}
.disabled(viewModel.isLoadingModels)
if !viewModel.connectionMessage.isEmpty {
Text(viewModel.connectionMessage)
.font(.caption)
.foregroundColor(viewModel.coderModels.isEmpty ? .red : .secondary)
}
}
if !viewModel.coderModels.isEmpty {
Picker("Notes model", selection: $viewModel.settings.notesModel) {
ForEach(viewModel.coderModels.filter(\.supportsChat)) { model in
Text(model.displayName).tag(model.id)
}
}
Picker("Transcription model", selection: $viewModel.settings.transcriptionModel) {
ForEach(viewModel.coderModels.filter(\.supportsTranscription)) { model in
Text(model.displayName).tag(model.id)
}
}
}
Text("The token is stored locally in Keychain. Audio and note generation are sent only to this Coder service.")
.font(.caption)
.foregroundColor(.secondary)
SecureField("OpenAI API Key", text: $viewModel.settings.openAIKey)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
// Note Templates Section: only the Manage Templates button
@@ -113,7 +144,7 @@ struct SettingsView: View {
// Link to GitHub repository
Link("GitHub",
destination: URL(string: "https://github.com/owengretzinger/meetingnotes")!)
destination: URL(string: "https://github.com/superdooper86/meetingnotes")!)
.foregroundColor(.blue)
// Link to landing page
@@ -171,6 +202,7 @@ struct SettingsView: View {
.onAppear {
viewModel.loadTemplates()
viewModel.loadAPIKey()
Task { await viewModel.refreshModels() }
}
.onDisappear {
DispatchQueue.main.async {