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 = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
CC5718382E2020490042BA88 /* OpenAI in Frameworks */ = {isa = PBXBuildFile; productRef = CC5718372E2020490042BA88 /* OpenAI */; };
CCBA53E72E212F1C00469D84 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = CCBA53E62E212F1C00469D84 /* Sparkle */; }; CCBA53E72E212F1C00469D84 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = CCBA53E62E212F1C00469D84 /* Sparkle */; };
CCC33F812E236B6F00EDE382 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = CCC33F802E236B6F00EDE382 /* PostHog */; }; CCC33F812E236B6F00EDE382 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = CCC33F802E236B6F00EDE382 /* PostHog */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -30,7 +29,6 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
CCC33F812E236B6F00EDE382 /* PostHog in Frameworks */, CCC33F812E236B6F00EDE382 /* PostHog in Frameworks */,
CC5718382E2020490042BA88 /* OpenAI in Frameworks */,
CCBA53E72E212F1C00469D84 /* Sparkle in Frameworks */, CCBA53E72E212F1C00469D84 /* Sparkle in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -82,7 +80,6 @@
); );
name = Meetingnotes; name = Meetingnotes;
packageProductDependencies = ( packageProductDependencies = (
CC5718372E2020490042BA88 /* OpenAI */,
CCBA53E62E212F1C00469D84 /* Sparkle */, CCBA53E62E212F1C00469D84 /* Sparkle */,
CCC33F802E236B6F00EDE382 /* PostHog */, CCC33F802E236B6F00EDE382 /* PostHog */,
); );
@@ -115,7 +112,6 @@
mainGroup = CC57180F2E201D8D0042BA88; mainGroup = CC57180F2E201D8D0042BA88;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = ( packageReferences = (
CC5718362E2020490042BA88 /* XCRemoteSwiftPackageReference "OpenAI" */,
CCBA53E52E212F1C00469D84 /* XCRemoteSwiftPackageReference "Sparkle" */, CCBA53E52E212F1C00469D84 /* XCRemoteSwiftPackageReference "Sparkle" */,
CCC33F7F2E236B6F00EDE382 /* XCRemoteSwiftPackageReference "posthog-ios" */, CCC33F7F2E236B6F00EDE382 /* XCRemoteSwiftPackageReference "posthog-ios" */,
); );
@@ -365,14 +361,6 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference 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" */ = { CCBA53E52E212F1C00469D84 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle.git"; repositoryURL = "https://github.com/sparkle-project/Sparkle.git";
@@ -392,11 +380,6 @@
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
CC5718372E2020490042BA88 /* OpenAI */ = {
isa = XCSwiftPackageProductDependency;
package = CC5718362E2020490042BA88 /* XCRemoteSwiftPackageReference "OpenAI" */;
productName = OpenAI;
};
CCBA53E62E212F1C00469D84 /* Sparkle */ = { CCBA53E62E212F1C00469D84 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = CCBA53E52E212F1C00469D84 /* XCRemoteSwiftPackageReference "Sparkle" */; package = CCBA53E52E212F1C00469D84 /* XCRemoteSwiftPackageReference "Sparkle" */;
@@ -1,15 +1,6 @@
{ {
"originHash" : "121a6c330462966c69b74d5d4f619f194dc62623e87741aa10ee74337154e7b5", "originHash" : "121a6c330462966c69b74d5d4f619f194dc62623e87741aa10ee74337154e7b5",
"pins" : [ "pins" : [
{
"identity" : "openai",
"kind" : "remoteSourceControl",
"location" : "https://github.com/MacPaw/OpenAI",
"state" : {
"revision" : "cedec2fc80aedafa332f7395b7004f79f547c794",
"version" : "0.4.4"
}
},
{ {
"identity" : "posthog-ios", "identity" : "posthog-ios",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
+3 -5
View File
@@ -100,14 +100,14 @@ Instructions for cloning the repo, installing packages, configuring environment
Implemented: Implemented:
- Recording mic & system audio - Recording mic & system audio
- Live transcript - End-of-meeting transcription through your Coder service
- 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
- Meeting deletion functionality - Meeting deletion functionality
- Meeting search functionality - Meeting search functionality
- Abilty to edit system prompt - Abilty to edit system prompt
- Use your own API key - Select any compatible Coder model for transcription and note generation
- Auto updates - Auto updates
- Text formatting - Text formatting
- Different note templates - Different note templates
@@ -116,7 +116,7 @@ Implemented:
Todo: Todo:
- check for funds / validity of openai api key - improve provider balance and token validation errors
- add padding to text inputs - add padding to text inputs
- add confirmation when clicking the copy button - add confirmation when clicking the copy button
@@ -125,8 +125,6 @@ Later:
- Cool recording indicator (dancing bars) - Cool recording indicator (dancing bars)
- Connecting to your Google calendar - Connecting to your Google calendar
- AI chat for asking questions about a meeting - 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. - Integrations for email, Slack, Notion, etc.
## Local Development ## 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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSAudioCaptureUsageDescription</key> <key>NSAudioCaptureUsageDescription</key>
<string>Meetingnotes needs access to capture system audio for transcription.</string> <string>Meetingnotes needs access to capture system audio for transcription.</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>Meetingnotes needs access to your microphone for transcription.</string> <string>Meetingnotes needs access to your microphone for transcription.</string>
<key>SUFeedURL</key> <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> <key>SUPublicEDKey</key>
<string>BVXHOV8ZxPxKZ1swhFndymzew9nyd3si7849JA9cqsg=</string> <string>BVXHOV8ZxPxKZ1swhFndymzew9nyd3si7849JA9cqsg=</string>
<key>SUEnableInstallerLauncherService</key> <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 /// Gets the API key directly from keychain
/// - Returns: The API key string if found, nil otherwise /// - Returns: The API key string if found, nil otherwise
func getAPIKey() -> String? { func getCoderAPIKey() -> String? {
return get(forKey: "openAIKey") return get(forKey: "coderAPIKey")
} }
/// Saves the API key to keychain /// Saves the API key to keychain
/// - Parameter apiKey: The API key to save /// - Parameter apiKey: The API key to save
/// - Returns: True if the save was successful, false otherwise /// - Returns: True if the save was successful, false otherwise
func saveAPIKey(_ apiKey: String) -> Bool { func saveCoderAPIKey(_ apiKey: String) -> Bool {
return save(apiKey, forKey: "openAIKey") return save(apiKey, forKey: "coderAPIKey")
} }
/// Saves a string value to the keychain /// Saves a string value to the keychain
@@ -80,4 +80,4 @@ class KeychainHelper {
let status = SecItemDelete(query as CFDictionary) let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess return status == errSecSuccess
} }
} }
@@ -8,6 +8,7 @@ class RecordingSessionManager: ObservableObject {
static let shared = RecordingSessionManager() static let shared = RecordingSessionManager()
@Published var isRecording = false @Published var isRecording = false
@Published var isProcessing = false
@Published var activeMeetingId: UUID? @Published var activeMeetingId: UUID?
@Published var errorMessage: String? @Published var errorMessage: String?
@Published var activeRecordingTranscriptChunksUpdated: [TranscriptChunk] = [] @Published var activeRecordingTranscriptChunksUpdated: [TranscriptChunk] = []
@@ -32,6 +33,12 @@ class RecordingSessionManager: ObservableObject {
} }
.store(in: &cancellables) .store(in: &cancellables)
audioManager.$isProcessing
.sink { [weak self] isProcessing in
self?.isProcessing = isProcessing
}
.store(in: &cancellables)
audioManager.$errorMessage audioManager.$errorMessage
.sink { [weak self] errorMessage in .sink { [weak self] errorMessage in
self?.errorMessage = errorMessage self?.errorMessage = errorMessage
@@ -75,16 +82,24 @@ class RecordingSessionManager: ObservableObject {
audioManager.startRecording() audioManager.startRecording()
} }
func stopRecording() { func stopRecording() async -> [TranscriptChunk] {
print("🛑 Stopping recording for meeting: \(activeMeetingId?.uuidString ?? "unknown")") print("🛑 Stopping recording for meeting: \(activeMeetingId?.uuidString ?? "unknown")")
audioManager.stopRecording() guard let meetingId = activeMeetingId else {
audioManager.cancelRecording()
// Perform a final, immediate save of transcript chunks to the meeting return []
if let activeMeetingId = activeMeetingId {
updateActiveMeetingTranscript(meetingId: activeMeetingId, chunks: activeRecordingTranscriptChunks)
} }
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 activeMeetingId = nil
activeRecordingTranscriptChunks = [] activeRecordingTranscriptChunks = []
} }
@@ -18,6 +18,9 @@ class UserDefaultsManager {
static let hasCompletedOnboarding = "hasCompletedOnboarding" static let hasCompletedOnboarding = "hasCompletedOnboarding"
static let hasAcceptedTerms = "hasAcceptedTerms" static let hasAcceptedTerms = "hasAcceptedTerms"
static let selectedTemplateId = "selectedTemplateId" static let selectedTemplateId = "selectedTemplateId"
static let coderBaseURL = "coderBaseURL"
static let notesModel = "notesModel"
static let transcriptionModel = "transcriptionModel"
} }
// MARK: - User Blurb // 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 import Foundation
struct Settings: Codable { struct Settings: Codable {
// Only store API key in memory - will be loaded from keychain when needed var coderAPIKey: String = ""
var openAIKey: String = ""
// Computed properties that access UserDefaults // Computed properties that access UserDefaults
var userBlurb: String { var userBlurb: String {
@@ -30,6 +29,21 @@ struct Settings: Codable {
set { UserDefaultsManager.shared.hasAcceptedTerms = newValue } 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 // System prompt default loading
static func defaultSystemPrompt() -> String { static func defaultSystemPrompt() -> String {
guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"), guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"),
@@ -57,12 +71,12 @@ struct Settings: Codable {
return result return result
} }
init(openAIKey: String = "") { init(coderAPIKey: String = "") {
self.openAIKey = openAIKey self.coderAPIKey = coderAPIKey
} }
// MARK: - Codable conformance for API key only // MARK: - Codable conformance for API key only
private enum CodingKeys: String, CodingKey { 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 import Foundation
/// Service to validate OpenAI API keys final class CoderAPIValidator {
class APIKeyValidator { static let shared = CoderAPIValidator()
static let shared = APIKeyValidator()
private init() {} 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> { func validateAPIKey(_ apiKey: String) async -> Result<Void, APIKeyValidationError> {
guard !apiKey.isEmpty else { guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return .failure(.emptyKey) 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 { do {
let (data, response) = try await URLSession.shared.data(for: request) let models = try await CoderAPIClient.shared.models(
baseURL: UserDefaultsManager.shared.coderBaseURL,
guard let httpResponse = response as? HTTPURLResponse else { apiKey: apiKey
return .failure(.networkError("Invalid response")) )
} return models.isEmpty ? .failure(.noModelsAvailable) : .success(())
} catch let error as CoderAPIError {
switch httpResponse.statusCode { return .failure(.connection(error.localizedDescription))
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))
}
} catch { } catch {
let errorMessage = ErrorHandler.shared.handleError(error) return .failure(.connection(error.localizedDescription))
return .failure(.networkError(errorMessage))
} }
} }
/// Validates the currently stored API key
/// - Returns: Result indicating success or failure with error message
func validateCurrentAPIKey() async -> Result<Void, APIKeyValidationError> { func validateCurrentAPIKey() async -> Result<Void, APIKeyValidationError> {
guard let apiKey = KeychainHelper.shared.getAPIKey() else { guard let apiKey = KeychainHelper.shared.getCoderAPIKey() else {
return .failure(.emptyKey) return .failure(.emptyKey)
} }
return await validateAPIKey(apiKey) return await validateAPIKey(apiKey)
} }
} }
/// Errors that can occur during API key validation
enum APIKeyValidationError: Error, LocalizedError { enum APIKeyValidationError: Error, LocalizedError {
case emptyKey case emptyKey
case invalidURL
case noModelsAvailable case noModelsAvailable
case networkError(String) case connection(String)
case httpError(String)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .emptyKey: case .emptyKey:
return ErrorMessage.noAPIKey return ErrorMessage.noAPIKey
case .invalidURL:
return ErrorMessage.invalidURL
case .noModelsAvailable: case .noModelsAvailable:
return ErrorMessage.noModelsAvailable return ErrorMessage.noModelsAvailable
case .networkError(let message): case .connection(let message):
return message
case .httpError(let message):
return 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 // ErrorHandler.swift
// Centralized error handling service for OpenAI API and network errors // Centralized error handling service for Coder API and network errors
import Foundation import Foundation
@@ -9,7 +9,7 @@ class ErrorHandler {
private init() {} 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 /// - Parameter error: The error to handle
/// - Returns: User-friendly error message /// - Returns: User-friendly error message
func handleError(_ error: Error) -> String { func handleError(_ error: Error) -> String {
@@ -23,10 +23,10 @@ class ErrorHandler {
return handleHTTPError(httpError) return handleHTTPError(httpError)
} }
// Handle OpenAI API errors by checking error description // Handle common API errors by checking the description.
let errorDescription = error.localizedDescription.lowercased() let errorDescription = error.localizedDescription.lowercased()
if let openAIError = categorizeOpenAIError(errorDescription) { if let apiError = categorizeAPIError(errorDescription) {
return openAIError return apiError
} }
// Generic error fallback // Generic error fallback
@@ -125,9 +125,9 @@ class ErrorHandler {
case .timedOut: case .timedOut:
return "Request timed out. Please try again." return "Request timed out. Please try again."
case .cannotFindHost: 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: 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: case .networkConnectionLost:
return "Network connection lost. Please try again." return "Network connection lost. Please try again."
case .httpTooManyRedirects: case .httpTooManyRedirects:
@@ -145,7 +145,7 @@ class ErrorHandler {
return handleHTTPStatusCode(httpError.statusCode, message: httpError.message) 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") { if errorDescription.contains("unauthorized") || errorDescription.contains("401") {
return ErrorMessage.invalidAPIKey return ErrorMessage.invalidAPIKey
} else if errorDescription.contains("insufficient") || errorDescription.contains("402") { } else if errorDescription.contains("insufficient") || errorDescription.contains("402") {
@@ -177,23 +177,23 @@ struct HTTPError: Error {
/// Common error messages /// Common error messages
enum ErrorMessage { 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 noTemplate = "No template content found. Please select a valid template."
static let noTranscript = "No transcript available. Please record some audio first." 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 configurationFailed = "Failed to configure transcription session."
static let invalidURL = "Invalid API URL configuration." 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 // Centralized messages used across handlers
static let success = "Success" static let success = "Success"
static let badRequest = "Bad request. Please check your input." 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 invalidAPIKey = "Invalid Coder service token. Check the token in Settings."
static let insufficientFunds = "Insufficient funds in your OpenAI account. Please add credits to your account." static let insufficientFunds = "The selected Coder provider has insufficient balance."
static let accessForbidden = "Access forbidden. Please check your API key permissions." static let accessForbidden = "Access forbidden. Please check your API key permissions."
static let apiEndpointNotFound = "API endpoint not found. Please update the app." 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 rateLimited = "The selected Coder provider is rate limited. Please try again later."
static let apiServerError = "OpenAI server error. 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 requestTimeout = "Request timeout. Please try again."
static let requestTooLarge = "Request too large. Please try again." static let requestTooLarge = "Request too large. Please try again."
static let unsupportedData = "Unsupported data format. Please update the app." static let unsupportedData = "Unsupported data format. Please update the app."
+12 -21
View File
@@ -1,8 +1,7 @@
// NotesGenerator.swift // NotesGenerator.swift
// Handles AI-powered note generation using OpenAI // Handles AI-powered note generation through Coder
import Foundation import Foundation
import OpenAI
/// Result type for note generation streaming /// Result type for note generation streaming
enum GenerationResult { enum GenerationResult {
@@ -10,7 +9,7 @@ enum GenerationResult {
case error(String) case error(String)
} }
/// Generates meeting notes using OpenAI API /// Generates meeting notes using the selected Coder model
class NotesGenerator { class NotesGenerator {
static let shared = NotesGenerator() static let shared = NotesGenerator()
@@ -31,14 +30,14 @@ class NotesGenerator {
return AsyncStream<GenerationResult>(GenerationResult.self) { continuation in return AsyncStream<GenerationResult>(GenerationResult.self) { continuation in
Task { Task {
do { 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.yield(.error(ErrorMessage.noAPIKey))
continuation.finish() continuation.finish()
return return
} }
// Validate API key before proceeding // Validate API key before proceeding
let validationResult = await APIKeyValidator.shared.validateAPIKey(apiKey) let validationResult = await CoderAPIValidator.shared.validateAPIKey(apiKey)
switch validationResult { switch validationResult {
case .failure(let error): case .failure(let error):
continuation.yield(.error(error.localizedDescription)) continuation.yield(.error(error.localizedDescription))
@@ -48,8 +47,6 @@ class NotesGenerator {
break break
} }
let openAI = OpenAI(apiToken: apiKey)
// Create date formatter for meeting date // Create date formatter for meeting date
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .full dateFormatter.dateStyle = .full
@@ -90,18 +87,12 @@ class NotesGenerator {
// Process the system prompt template // Process the system prompt template
let systemContent = Settings.processTemplate(systemPrompt, with: templateVariables) let systemContent = Settings.processTemplate(systemPrompt, with: templateVariables)
let systemMessage = ChatQuery.ChatCompletionMessageParam(role: .system, content: systemContent)! let stream = CoderAPIClient.shared.streamChat(
systemPrompt: systemContent,
print(systemContent) model: UserDefaultsManager.shared.notesModel
)
let query = ChatQuery(messages: [systemMessage], model: .gpt4_1) for try await content in stream {
continuation.yield(.content(content))
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))
}
} }
continuation.finish() 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 /// - Returns: True if API key exists, false otherwise
func isConfigured() -> Bool { func isConfigured() -> Bool {
guard let key = KeychainHelper.shared.getAPIKey(), guard let key = KeychainHelper.shared.getCoderAPIKey(),
!key.isEmpty else { !key.isEmpty else {
return false return false
} }
+33 -13
View File
@@ -28,20 +28,24 @@ class MeetingViewModel: ObservableObject {
// Computed property to determine if Generate button should animate // Computed property to determine if Generate button should animate
var shouldAnimateGenerateButton: Bool { 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 let noEnhancedNotesYet = meeting.generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
return generateButtonEnabled && noEnhancedNotesYet 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 { 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 // Computed property that always uses the direct RecordingSessionManager check
var isRecording: Bool { var isRecording: Bool {
return recordingSessionManager.isRecordingMeeting(meeting.id) 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 selectedTab: MeetingViewTab = .transcript // Default to transcript tab
@Published var isDeleted = false @Published var isDeleted = false
@@ -110,6 +114,12 @@ class MeetingViewModel: ObservableObject {
} }
.store(in: &cancellables) .store(in: &cancellables)
recordingSessionManager.$isProcessing
.sink { [weak self] _ in
self?.recordingStateChanged.toggle()
}
.store(in: &cancellables)
// Update error message when recording session manager encounters errors // Update error message when recording session manager encounters errors
recordingSessionManager.$errorMessage recordingSessionManager.$errorMessage
.compactMap { $0 } .compactMap { $0 }
@@ -130,7 +140,7 @@ class MeetingViewModel: ObservableObject {
self.meeting.transcriptChunks = recordingSessionManager.getTranscriptChunks(for: meeting.id) 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 recordingSessionManager.$activeRecordingTranscriptChunksUpdated
.dropFirst() .dropFirst()
.sink { [weak self] updatedChunks in .sink { [weak self] updatedChunks in
@@ -158,18 +168,20 @@ class MeetingViewModel: ObservableObject {
var recordingButtonText: String { var recordingButtonText: String {
// Use the same computed isRecording property for perfect consistency if isProcessing {
return "Processing"
}
if isRecording { if isRecording {
return "Stop" return "Stop"
} else { } else {
// Check if there's existing transcript content // Check if there's existing transcript content
return meeting.transcriptChunks.isEmpty ? "Transcribe" : "Resume" return meeting.transcriptChunks.isEmpty ? "Record" : "Resume"
} }
} }
func toggleRecording() { func toggleRecording() {
// Prevent duplicate actions while validating API key or starting recording // 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 // Use the same computed isRecording property for perfect consistency
if isRecording { if isRecording {
stopRecording() stopRecording()
@@ -183,7 +195,7 @@ class MeetingViewModel: ObservableObject {
isValidatingKey = true isValidatingKey = true
isStartingRecording = true isStartingRecording = true
Task { Task {
let validationResult = await APIKeyValidator.shared.validateCurrentAPIKey() let validationResult = await CoderAPIValidator.shared.validateCurrentAPIKey()
defer { isValidatingKey = false } defer { isValidatingKey = false }
switch validationResult { switch validationResult {
@@ -201,8 +213,16 @@ class MeetingViewModel: ObservableObject {
} }
func stopRecording() { func stopRecording() {
recordingSessionManager.stopRecording() isStartingRecording = true
saveMeeting() Task {
let chunks = await recordingSessionManager.stopRecording()
meeting.transcriptChunks = chunks
saveMeeting()
if !meeting.formattedTranscript.isEmpty {
await generateNotes()
}
isStartingRecording = false
}
} }
func loadTemplates() { func loadTemplates() {
@@ -301,7 +321,7 @@ class MeetingViewModel: ObservableObject {
// If this meeting is currently being recorded, stop the recording first // If this meeting is currently being recorded, stop the recording first
if recordingSessionManager.isRecordingMeeting(meeting.id) { if recordingSessionManager.isRecordingMeeting(meeting.id) {
print("🛑 Stopping recording for meeting being deleted: \(meeting.id)") print("🛑 Stopping recording for meeting being deleted: \(meeting.id)")
recordingSessionManager.stopRecording() recordingSessionManager.cancelRecording()
} }
let success = LocalStorageManager.shared.deleteMeeting(meeting) let success = LocalStorageManager.shared.deleteMeeting(meeting)
@@ -312,11 +332,11 @@ class MeetingViewModel: ObservableObject {
} }
func deleteIfEmpty() { func deleteIfEmpty() {
if isEmpty && !isRecording { if isEmpty && !isRecording && !isProcessing {
print("🗑️ Auto-deleting empty meeting") print("🗑️ Auto-deleting empty meeting")
deleteMeeting() deleteMeeting()
} else { } else {
saveMeeting() saveMeeting()
} }
} }
} }
@@ -7,15 +7,43 @@ class SettingsViewModel: ObservableObject {
@Published var saveMessage = "" @Published var saveMessage = ""
@Published var showingSaveMessage = false @Published var showingSaveMessage = false
@Published var templates: [NoteTemplate] = [] @Published var templates: [NoteTemplate] = []
@Published var coderModels: [CoderModel] = []
@Published var isLoadingModels = false
@Published var connectionMessage = ""
init() { init() {
loadTemplates() loadTemplates()
} }
/// Loads the API key from keychain (only called when actually needed) /// Loads the Coder service token from Keychain.
func loadAPIKey() { func loadAPIKey() {
if settings.openAIKey.isEmpty { if settings.coderAPIKey.isEmpty {
settings.openAIKey = KeychainHelper.shared.getAPIKey() ?? "" 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 // Only save API key to keychain - other values are automatically saved to UserDefaults
// via computed properties when they're modified // via computed properties when they're modified
let openAISaved = KeychainHelper.shared.saveAPIKey(settings.openAIKey) let coderSaved = KeychainHelper.shared.saveCoderAPIKey(settings.coderAPIKey)
if showMessage { if showMessage {
if openAISaved { if coderSaved {
saveMessage = "Settings saved successfully!" saveMessage = "Settings saved successfully!"
} else { } else {
saveMessage = "Error saving settings" saveMessage = "Error saving settings"
+10 -4
View File
@@ -321,7 +321,7 @@ struct MeetingDetailContentView: View {
) )
} }
.buttonStyle(.plain) .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") .help("Generate enhanced notes using a template")
// Recording Button // Recording Button
@@ -329,8 +329,14 @@ struct MeetingDetailContentView: View {
viewModel.toggleRecording() viewModel.toggleRecording()
}) { }) {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: viewModel.isRecording ? "stop.circle.fill" : "record.circle") if viewModel.isProcessing {
.foregroundColor(viewModel.isRecording ? .red : .accentColor) 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) Text(viewModel.recordingButtonText)
} }
.frame(minWidth: 110, minHeight: 36) .frame(minWidth: 110, minHeight: 36)
@@ -347,7 +353,7 @@ struct MeetingDetailContentView: View {
) )
} }
.buttonStyle(.plain) .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") .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) .frame(maxWidth: .infinity, alignment: .leading)
// API Key Section // Coder service
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("OpenAI API Key") Text("Coder Service")
.font(.title2) .font(.title2)
.fontWeight(.semibold) .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) .font(.body)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
Button("Get API Key from OpenAI") { TextField("http://coder-host:8787/v1", text: $settingsViewModel.settings.coderBaseURL)
if let url = URL(string: "https://platform.openai.com/api-keys") { .textFieldStyle(.roundedBorder)
NSWorkspace.shared.open(url) .font(.body)
}
}
.buttonStyle(.link)
SecureField("OpenAI API Key", text: $apiKey) SecureField("Coder service token", text: $apiKey)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.font(.body) .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 { HStack {
Image(systemName: "info.circle") Image(systemName: "info.circle")
.foregroundColor(.blue) .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) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
@@ -130,7 +157,7 @@ struct OnboardingView: View {
Spacer() Spacer()
Button("Get Started") { Button("Get Started") {
// Complete onboarding // Complete onboarding
settingsViewModel.settings.openAIKey = apiKey settingsViewModel.settings.coderAPIKey = apiKey
settingsViewModel.completeOnboarding() settingsViewModel.completeOnboarding()
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
@@ -155,7 +182,7 @@ struct OnboardingView: View {
.onAppear { .onAppear {
checkPermissions() checkPermissions()
settingsViewModel.loadAPIKey() settingsViewModel.loadAPIKey()
apiKey = settingsViewModel.settings.openAIKey apiKey = settingsViewModel.settings.coderAPIKey
hasAcceptedTerms = settingsViewModel.settings.hasAcceptedTerms hasAcceptedTerms = settingsViewModel.settings.hasAcceptedTerms
} }
.onChange(of: audioRecordingPermission.status) { oldValue, newValue in .onChange(of: audioRecordingPermission.status) { oldValue, newValue in
@@ -172,6 +199,8 @@ struct OnboardingView: View {
return micPermissionGranted && return micPermissionGranted &&
systemAudioPermissionGranted && systemAudioPermissionGranted &&
!apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!settingsViewModel.coderModels.filter(\.supportsChat).isEmpty &&
!settingsViewModel.coderModels.filter(\.supportsTranscription).isEmpty &&
hasAcceptedTerms hasAcceptedTerms
} }
+40 -8
View File
@@ -13,19 +13,50 @@ struct SettingsView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) {
// API Configuration Section // Coder service configuration
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("OpenAI API Key") Text("Coder Service")
.font(.headline) .font(.headline)
.foregroundColor(.primary) .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) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
SecureField("OpenAI API Key", text: $viewModel.settings.openAIKey)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
} }
// Note Templates Section: only the Manage Templates button // Note Templates Section: only the Manage Templates button
@@ -113,7 +144,7 @@ struct SettingsView: View {
// Link to GitHub repository // Link to GitHub repository
Link("GitHub", Link("GitHub",
destination: URL(string: "https://github.com/owengretzinger/meetingnotes")!) destination: URL(string: "https://github.com/superdooper86/meetingnotes")!)
.foregroundColor(.blue) .foregroundColor(.blue)
// Link to landing page // Link to landing page
@@ -171,6 +202,7 @@ struct SettingsView: View {
.onAppear { .onAppear {
viewModel.loadTemplates() viewModel.loadTemplates()
viewModel.loadAPIKey() viewModel.loadAPIKey()
Task { await viewModel.refreshModels() }
} }
.onDisappear { .onDisappear {
DispatchQueue.main.async { DispatchQueue.main.async {