Use Coder for end-of-meeting processing #1
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user