feat: use Coder for meeting processing
This commit is contained in:
@@ -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 = {
|
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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 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."
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user