Parcourir la Source

Add AI Companion notes UI flow and persist OpenAI key config.

Introduce notes generation actions and status handling in the ended meetings view, and add plist-based API key configuration used by notes generation.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 il y a 1 mois
Parent
commit
d89dcfe9d5
2 fichiers modifiés avec 246 ajouts et 1 suppressions
  1. 2 0
      Info.plist
  2. 244 1
      meetings_app/ViewController.swift

+ 2 - 0
Info.plist

@@ -30,6 +30,8 @@
30 30
 	<string>This app records meeting audio locally with your consent so it can be shown in AI Companion after the meeting.</string>
31 31
 	<key>NSSpeechRecognitionUsageDescription</key>
32 32
 	<string>This app converts your saved meeting audio into text transcripts for AI Companion.</string>
33
+	<key>OpenAIAPIKey</key>
34
+	<string>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</string>
33 35
 	<key>AppLaunchPlaceholderURL</key>
34 36
 	<string>https://example.com/app-link-coming-soon</string>
35 37
 	<key>AppShareURL</key>

+ 244 - 1
meetings_app/ViewController.swift

@@ -239,6 +239,13 @@ final class ViewController: NSViewController {
239 239
         case failed
240 240
     }
241 241
 
242
+    private enum MeetingNotesStatus: String, Codable {
243
+        case notRequested
244
+        case processing
245
+        case ready
246
+        case failed
247
+    }
248
+
242 249
     private enum MeetingTranscriptSource: String, Codable {
243 250
         case meetApi
244 251
         case localAudioAppleSpeech
@@ -274,6 +281,9 @@ final class ViewController: NSViewController {
274 281
         var transcriptText: String?
275 282
         var transcriptSegmentsJSON: String?
276 283
         var transcriptErrorMessage: String?
284
+        var notesStatusRaw: String?
285
+        var notesText: String?
286
+        var notesErrorMessage: String?
277 287
     }
278 288
 
279 289
     private struct ActiveMeetingRecordingSession {
@@ -316,6 +326,12 @@ final class ViewController: NSViewController {
316 326
     private var aiCompanionTranscriptWindow: NSWindow?
317 327
     private weak var aiCompanionTranscriptTextView: NSTextView?
318 328
     private var aiCompanionTranscriptTaskByMeetingId = [String: Task<Void, Never>]()
329
+    private var aiCompanionNotesMeetingIdByView = [ObjectIdentifier: String]()
330
+    private var aiCompanionNotesStatusLabelByView = [ObjectIdentifier: NSTextField]()
331
+    private var aiCompanionNotesCurrentRequestId: UUID?
332
+    private var aiCompanionNotesWindow: NSWindow?
333
+    private weak var aiCompanionNotesTextView: NSTextView?
334
+    private var aiCompanionNotesTaskByMeetingId = [String: Task<Void, Never>]()
319 335
     private var aiCompanionAudioPlayer: AVPlayer?
320 336
     private var aiCompanionLocalAudioPlayer: AVAudioPlayer?
321 337
     private var aiCompanionCurrentlyPlayingURL: URL?
@@ -479,7 +495,9 @@ final class ViewController: NSViewController {
479 495
     private let aiCompanionLocalRecordingsDefaultsKey = "aiCompanion.localRecordings"
480 496
     private let ratingEligibleUsageSeconds: TimeInterval = 30 * 60
481 497
     private let meetingTranscriptionService = MeetingTranscriptionService()
498
+    private let meetingNotesService = MeetingNotesService()
482 499
     private var aiCompanionTranscriptProgressByMeetingId: [String: String] = [:]
500
+    private var aiCompanionNotesProgressByMeetingId: [String: String] = [:]
483 501
     private var darkModeEnabled: Bool {
484 502
         get {
485 503
             let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
@@ -1413,6 +1431,29 @@ private extension ViewController {
1413 1431
         }
1414 1432
     }
1415 1433
 
1434
+    private func aiCompanionNotesStatus(for recording: MeetingRecordingSummary) -> MeetingNotesStatus {
1435
+        guard let raw = recording.notesStatusRaw, let status = MeetingNotesStatus(rawValue: raw) else {
1436
+            return .notRequested
1437
+        }
1438
+        return status
1439
+    }
1440
+
1441
+    private func aiCompanionNotesStatusText(for recording: MeetingRecordingSummary) -> String {
1442
+        switch aiCompanionNotesStatus(for: recording) {
1443
+        case .notRequested:
1444
+            return "Notes not requested"
1445
+        case .processing:
1446
+            if let progress = aiCompanionNotesProgressByMeetingId[recording.id], progress.isEmpty == false {
1447
+                return progress
1448
+            }
1449
+            return "Notes processing..."
1450
+        case .ready:
1451
+            return "Notes ready"
1452
+        case .failed:
1453
+            return "Notes unavailable (tap to retry)"
1454
+        }
1455
+    }
1456
+
1416 1457
     private func aiCompanionMeetingFromRecording(_ recording: MeetingRecordingSummary) -> ScheduledMeeting? {
1417 1458
         guard let meetURL = URL(string: recording.meetURLString) else { return nil }
1418 1459
         return ScheduledMeeting(
@@ -1442,6 +1483,14 @@ private extension ViewController {
1442 1483
         }
1443 1484
     }
1444 1485
 
1486
+    private func aiCompanionRefreshNotesStatusLabels(forMeetingID meetingId: String) {
1487
+        guard let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) else { return }
1488
+        let statusText = aiCompanionNotesStatusText(for: recording)
1489
+        for (buttonId, linkedMeetingId) in aiCompanionNotesMeetingIdByView where linkedMeetingId == meetingId {
1490
+            aiCompanionNotesStatusLabelByView[buttonId]?.stringValue = statusText
1491
+        }
1492
+    }
1493
+
1445 1494
     private func localRecordingDirectoryURL() -> URL {
1446 1495
         let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
1447 1496
             ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
@@ -1632,7 +1681,10 @@ private extension ViewController {
1632 1681
                     transcriptSourceRaw: nil,
1633 1682
                     transcriptText: nil,
1634 1683
                     transcriptSegmentsJSON: nil,
1635
-                    transcriptErrorMessage: nil
1684
+                    transcriptErrorMessage: nil,
1685
+                    notesStatusRaw: MeetingNotesStatus.notRequested.rawValue,
1686
+                    notesText: nil,
1687
+                    notesErrorMessage: nil
1636 1688
                 )
1637 1689
                 self.aiCompanionLocalRecordings.insert(summary, at: 0)
1638 1690
                 self.aiCompanionLocalRecordings.sort(by: { $0.endedAt > $1.endedAt })
@@ -2516,6 +2568,8 @@ private extension ViewController {
2516 2568
         aiCompanionSpeechTextByView.removeAll()
2517 2569
         aiCompanionTranscriptMeetingIdByView.removeAll()
2518 2570
         aiCompanionTranscriptStatusLabelByView.removeAll()
2571
+        aiCompanionNotesMeetingIdByView.removeAll()
2572
+        aiCompanionNotesStatusLabelByView.removeAll()
2519 2573
         // Keep transcript requests and window state alive across AI Companion page rebuilds.
2520 2574
         // The page is rebuilt while processing starts/completes, and cancelling here interrupts
2521 2575
         // the active request before it can update the transcript view.
@@ -2716,6 +2770,22 @@ private extension ViewController {
2716 2770
         transcriptStatusLabel.lineBreakMode = .byTruncatingTail
2717 2771
         aiCompanionTranscriptStatusLabelByView[ObjectIdentifier(transcriptButton)] = transcriptStatusLabel
2718 2772
 
2773
+        let notesButton = NSButton(title: "Get notes", target: self, action: #selector(aiCompanionNotesTapped(_:)))
2774
+        notesButton.translatesAutoresizingMaskIntoConstraints = false
2775
+        notesButton.isBordered = false
2776
+        notesButton.bezelStyle = .inline
2777
+        notesButton.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
2778
+        notesButton.contentTintColor = palette.primaryBlue
2779
+        notesButton.alignment = .left
2780
+        notesButton.setButtonType(.momentaryPushIn)
2781
+        aiCompanionNotesMeetingIdByView[ObjectIdentifier(notesButton)] = recording.id
2782
+
2783
+        let notesStatusLabel = textLabel(aiCompanionNotesStatusText(for: recording), font: typography.fieldLabel, color: palette.textMuted)
2784
+        notesStatusLabel.alignment = .left
2785
+        notesStatusLabel.maximumNumberOfLines = 2
2786
+        notesStatusLabel.lineBreakMode = .byTruncatingTail
2787
+        aiCompanionNotesStatusLabelByView[ObjectIdentifier(notesButton)] = notesStatusLabel
2788
+
2719 2789
         stack.addArrangedSubview(title)
2720 2790
         stack.addArrangedSubview(dateLabel)
2721 2791
         stack.addArrangedSubview(audioButton)
@@ -2723,6 +2793,8 @@ private extension ViewController {
2723 2793
         stack.addArrangedSubview(audioStatusLabel)
2724 2794
         stack.addArrangedSubview(transcriptButton)
2725 2795
         stack.addArrangedSubview(transcriptStatusLabel)
2796
+        stack.addArrangedSubview(notesButton)
2797
+        stack.addArrangedSubview(notesStatusLabel)
2726 2798
 
2727 2799
         card.addSubview(stack)
2728 2800
         NSLayoutConstraint.activate([
@@ -2991,6 +3063,9 @@ private extension ViewController {
2991 3063
             if forceRegenerate {
2992 3064
                 recording.transcriptText = nil
2993 3065
                 recording.transcriptSourceRaw = nil
3066
+                recording.notesStatusRaw = MeetingNotesStatus.notRequested.rawValue
3067
+                recording.notesText = nil
3068
+                recording.notesErrorMessage = nil
2994 3069
             }
2995 3070
         }
2996 3071
         aiCompanionRefreshTranscriptStatusLabels(forMeetingID: meetingId)
@@ -3119,6 +3194,118 @@ private extension ViewController {
3119 3194
         return (renderedText, segmentsJSON, source)
3120 3195
     }
3121 3196
 
3197
+    @objc private func aiCompanionNotesTapped(_ sender: NSButton) {
3198
+        let senderId = ObjectIdentifier(sender)
3199
+        guard let meetingId = aiCompanionNotesMeetingIdByView[senderId] else { return }
3200
+        guard let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) else {
3201
+            aiCompanionNotesStatusLabelByView[senderId]?.stringValue = "Notes unavailable (tap to retry)"
3202
+            showSimpleAlert(title: "Notes unavailable", message: "Could not find recording details for this meeting.")
3203
+            return
3204
+        }
3205
+
3206
+        if aiCompanionNotesStatus(for: recording) == .ready,
3207
+           let cached = recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines),
3208
+           cached.isEmpty == false {
3209
+            aiCompanionPresentNotesWindow(meetingTitle: recording.title, initialText: cached)
3210
+            aiCompanionNotesStatusLabelByView[senderId]?.stringValue = aiCompanionNotesStatusText(for: recording)
3211
+            return
3212
+        }
3213
+
3214
+        aiCompanionNotesStatusLabelByView[senderId]?.stringValue = "Notes processing..."
3215
+        let requestId = UUID()
3216
+        aiCompanionNotesCurrentRequestId = requestId
3217
+        aiCompanionPresentNotesWindow(meetingTitle: recording.title, initialText: "Generating notes...")
3218
+        aiCompanionStartNotesProcessing(forMeetingID: meetingId, requestId: requestId)
3219
+    }
3220
+
3221
+    private func aiCompanionStartNotesProcessing(forMeetingID meetingId: String, requestId: UUID?) {
3222
+        aiCompanionNotesTaskByMeetingId[meetingId]?.cancel()
3223
+        _ = aiCompanionUpdateRecording(meetingId: meetingId) { recording in
3224
+            recording.notesStatusRaw = MeetingNotesStatus.processing.rawValue
3225
+            recording.notesErrorMessage = nil
3226
+            recording.notesText = nil
3227
+        }
3228
+        aiCompanionNotesProgressByMeetingId[meetingId] = "Preparing transcript..."
3229
+        aiCompanionRefreshNotesStatusLabels(forMeetingID: meetingId)
3230
+        if selectedSidebarPage == .aiCompanion {
3231
+            pageCache[.aiCompanion] = nil
3232
+            showSidebarPage(.aiCompanion)
3233
+        }
3234
+
3235
+        let presentingWindow = view.window
3236
+        let task = Task { [weak self] in
3237
+            guard let self else { return }
3238
+            defer { Task { @MainActor [weak self] in self?.aiCompanionNotesTaskByMeetingId[meetingId] = nil } }
3239
+
3240
+            do {
3241
+                let notes = try await self.aiCompanionGenerateNotesForMeeting(meetingId: meetingId, presentingWindow: presentingWindow)
3242
+                await MainActor.run {
3243
+                    guard requestId == nil || self.aiCompanionNotesCurrentRequestId == requestId else { return }
3244
+                    self.aiCompanionNotesProgressByMeetingId[meetingId] = nil
3245
+                    _ = self.aiCompanionUpdateRecording(meetingId: meetingId) { recording in
3246
+                        recording.notesStatusRaw = MeetingNotesStatus.ready.rawValue
3247
+                        recording.notesText = notes
3248
+                        recording.notesErrorMessage = nil
3249
+                    }
3250
+                    self.aiCompanionNotesTextView?.string = notes
3251
+                    self.aiCompanionRefreshNotesStatusLabels(forMeetingID: meetingId)
3252
+                    if self.selectedSidebarPage == .aiCompanion {
3253
+                        self.pageCache[.aiCompanion] = nil
3254
+                        self.showSidebarPage(.aiCompanion)
3255
+                    }
3256
+                }
3257
+            } catch {
3258
+                await MainActor.run {
3259
+                    guard requestId == nil || self.aiCompanionNotesCurrentRequestId == requestId else { return }
3260
+                    self.aiCompanionNotesProgressByMeetingId[meetingId] = nil
3261
+                    let msg = error.localizedDescription.isEmpty ? "Failed to generate notes." : error.localizedDescription
3262
+                    _ = self.aiCompanionUpdateRecording(meetingId: meetingId) { recording in
3263
+                        recording.notesStatusRaw = MeetingNotesStatus.failed.rawValue
3264
+                        recording.notesErrorMessage = msg
3265
+                    }
3266
+                    self.aiCompanionNotesTextView?.string = "Notes unavailable.\n\n\(msg)"
3267
+                    self.aiCompanionRefreshNotesStatusLabels(forMeetingID: meetingId)
3268
+                    if self.selectedSidebarPage == .aiCompanion {
3269
+                        self.pageCache[.aiCompanion] = nil
3270
+                        self.showSidebarPage(.aiCompanion)
3271
+                    }
3272
+                }
3273
+            }
3274
+        }
3275
+        aiCompanionNotesTaskByMeetingId[meetingId] = task
3276
+    }
3277
+
3278
+    private func aiCompanionGenerateNotesForMeeting(meetingId: String, presentingWindow: NSWindow?) async throws -> String {
3279
+        let transcriptText: String
3280
+        if let existing = aiCompanionLocalRecordings.first(where: { $0.id == meetingId })?.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines),
3281
+           existing.isEmpty == false {
3282
+            transcriptText = existing
3283
+        } else {
3284
+            let transcript = try await aiCompanionFetchOrGenerateTranscript(
3285
+                meetingId: meetingId,
3286
+                interactiveAuth: true,
3287
+                presentingWindow: presentingWindow
3288
+            )
3289
+            await MainActor.run {
3290
+                _ = self.aiCompanionUpdateRecording(meetingId: meetingId) { recording in
3291
+                    recording.transcriptStatusRaw = MeetingTranscriptStatus.ready.rawValue
3292
+                    recording.transcriptSourceRaw = transcript.source.rawValue
3293
+                    recording.transcriptText = transcript.text
3294
+                    recording.transcriptSegmentsJSON = transcript.segmentsJSON
3295
+                    recording.transcriptErrorMessage = nil
3296
+                }
3297
+                self.aiCompanionRefreshTranscriptStatusLabels(forMeetingID: meetingId)
3298
+            }
3299
+            transcriptText = transcript.text
3300
+        }
3301
+
3302
+        await MainActor.run {
3303
+            self.aiCompanionNotesProgressByMeetingId[meetingId] = "Generating notes with GPT..."
3304
+            self.aiCompanionRefreshNotesStatusLabels(forMeetingID: meetingId)
3305
+        }
3306
+        return try await meetingNotesService.generateNotes(from: transcriptText)
3307
+    }
3308
+
3122 3309
     @objc private func aiCompanionStopRecordingTapped(_ sender: NSButton) {
3123 3310
         finishActiveMeetingRecording()
3124 3311
     }
@@ -3278,6 +3465,62 @@ private extension ViewController {
3278 3465
         aiCompanionTranscriptTextView = textView
3279 3466
     }
3280 3467
 
3468
+    @MainActor
3469
+    private func aiCompanionPresentNotesWindow(meetingTitle: String, initialText: String) {
3470
+        if let window = aiCompanionNotesWindow, let textView = aiCompanionNotesTextView {
3471
+            window.title = "Notes - \(meetingTitle)"
3472
+            textView.string = initialText
3473
+            window.makeKeyAndOrderFront(nil)
3474
+            NSApp.activate(ignoringOtherApps: true)
3475
+            return
3476
+        }
3477
+
3478
+        let windowWidth: CGFloat = 640
3479
+        let windowHeight: CGFloat = 560
3480
+        let window = NSWindow(
3481
+            contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight),
3482
+            styleMask: [.titled, .closable, .resizable],
3483
+            backing: .buffered,
3484
+            defer: false
3485
+        )
3486
+        window.isReleasedWhenClosed = false
3487
+        window.title = "Notes - \(meetingTitle)"
3488
+        window.center()
3489
+
3490
+        let root = NSView()
3491
+        root.translatesAutoresizingMaskIntoConstraints = false
3492
+
3493
+        let scroll = NSScrollView()
3494
+        scroll.translatesAutoresizingMaskIntoConstraints = false
3495
+        scroll.drawsBackground = false
3496
+        scroll.hasVerticalScroller = true
3497
+
3498
+        let textView = NSTextView()
3499
+        textView.isEditable = false
3500
+        textView.isSelectable = true
3501
+        textView.backgroundColor = .clear
3502
+        textView.font = NSFont.systemFont(ofSize: 13, weight: .regular)
3503
+        textView.string = initialText
3504
+        textView.textContainer?.widthTracksTextView = true
3505
+
3506
+        scroll.documentView = textView
3507
+        root.addSubview(scroll)
3508
+
3509
+        NSLayoutConstraint.activate([
3510
+            scroll.leadingAnchor.constraint(equalTo: root.leadingAnchor),
3511
+            scroll.trailingAnchor.constraint(equalTo: root.trailingAnchor),
3512
+            scroll.topAnchor.constraint(equalTo: root.topAnchor),
3513
+            scroll.bottomAnchor.constraint(equalTo: root.bottomAnchor)
3514
+        ])
3515
+
3516
+        window.contentView = root
3517
+        window.makeKeyAndOrderFront(nil)
3518
+        NSApp.activate(ignoringOtherApps: true)
3519
+
3520
+        aiCompanionNotesWindow = window
3521
+        aiCompanionNotesTextView = textView
3522
+    }
3523
+
3281 3524
     private func aiCompanionMeetMeetingCode(from meetURL: URL) -> String? {
3282 3525
         // Typical: https://meet.google.com/abc-defg-hij
3283 3526
         guard let host = meetURL.host?.lowercased(),