Przeglądaj źródła

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 2 miesięcy temu
rodzic
commit
d89dcfe9d5
2 zmienionych plików z 246 dodań i 1 usunięć
  1. 2 0
      Info.plist
  2. 244 1
      meetings_app/ViewController.swift

+ 2 - 0
Info.plist

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

+ 244 - 1
meetings_app/ViewController.swift

@@ -239,6 +239,13 @@ final class ViewController: NSViewController {
         case failed
     }
 
+    private enum MeetingNotesStatus: String, Codable {
+        case notRequested
+        case processing
+        case ready
+        case failed
+    }
+
     private enum MeetingTranscriptSource: String, Codable {
         case meetApi
         case localAudioAppleSpeech
@@ -274,6 +281,9 @@ final class ViewController: NSViewController {
         var transcriptText: String?
         var transcriptSegmentsJSON: String?
         var transcriptErrorMessage: String?
+        var notesStatusRaw: String?
+        var notesText: String?
+        var notesErrorMessage: String?
     }
 
     private struct ActiveMeetingRecordingSession {
@@ -316,6 +326,12 @@ final class ViewController: NSViewController {
     private var aiCompanionTranscriptWindow: NSWindow?
     private weak var aiCompanionTranscriptTextView: NSTextView?
     private var aiCompanionTranscriptTaskByMeetingId = [String: Task<Void, Never>]()
+    private var aiCompanionNotesMeetingIdByView = [ObjectIdentifier: String]()
+    private var aiCompanionNotesStatusLabelByView = [ObjectIdentifier: NSTextField]()
+    private var aiCompanionNotesCurrentRequestId: UUID?
+    private var aiCompanionNotesWindow: NSWindow?
+    private weak var aiCompanionNotesTextView: NSTextView?
+    private var aiCompanionNotesTaskByMeetingId = [String: Task<Void, Never>]()
     private var aiCompanionAudioPlayer: AVPlayer?
     private var aiCompanionLocalAudioPlayer: AVAudioPlayer?
     private var aiCompanionCurrentlyPlayingURL: URL?
@@ -479,7 +495,9 @@ final class ViewController: NSViewController {
     private let aiCompanionLocalRecordingsDefaultsKey = "aiCompanion.localRecordings"
     private let ratingEligibleUsageSeconds: TimeInterval = 30 * 60
     private let meetingTranscriptionService = MeetingTranscriptionService()
+    private let meetingNotesService = MeetingNotesService()
     private var aiCompanionTranscriptProgressByMeetingId: [String: String] = [:]
+    private var aiCompanionNotesProgressByMeetingId: [String: String] = [:]
     private var darkModeEnabled: Bool {
         get {
             let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
@@ -1413,6 +1431,29 @@ private extension ViewController {
         }
     }
 
+    private func aiCompanionNotesStatus(for recording: MeetingRecordingSummary) -> MeetingNotesStatus {
+        guard let raw = recording.notesStatusRaw, let status = MeetingNotesStatus(rawValue: raw) else {
+            return .notRequested
+        }
+        return status
+    }
+
+    private func aiCompanionNotesStatusText(for recording: MeetingRecordingSummary) -> String {
+        switch aiCompanionNotesStatus(for: recording) {
+        case .notRequested:
+            return "Notes not requested"
+        case .processing:
+            if let progress = aiCompanionNotesProgressByMeetingId[recording.id], progress.isEmpty == false {
+                return progress
+            }
+            return "Notes processing..."
+        case .ready:
+            return "Notes ready"
+        case .failed:
+            return "Notes unavailable (tap to retry)"
+        }
+    }
+
     private func aiCompanionMeetingFromRecording(_ recording: MeetingRecordingSummary) -> ScheduledMeeting? {
         guard let meetURL = URL(string: recording.meetURLString) else { return nil }
         return ScheduledMeeting(
@@ -1442,6 +1483,14 @@ private extension ViewController {
         }
     }
 
+    private func aiCompanionRefreshNotesStatusLabels(forMeetingID meetingId: String) {
+        guard let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) else { return }
+        let statusText = aiCompanionNotesStatusText(for: recording)
+        for (buttonId, linkedMeetingId) in aiCompanionNotesMeetingIdByView where linkedMeetingId == meetingId {
+            aiCompanionNotesStatusLabelByView[buttonId]?.stringValue = statusText
+        }
+    }
+
     private func localRecordingDirectoryURL() -> URL {
         let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
             ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
@@ -1632,7 +1681,10 @@ private extension ViewController {
                     transcriptSourceRaw: nil,
                     transcriptText: nil,
                     transcriptSegmentsJSON: nil,
-                    transcriptErrorMessage: nil
+                    transcriptErrorMessage: nil,
+                    notesStatusRaw: MeetingNotesStatus.notRequested.rawValue,
+                    notesText: nil,
+                    notesErrorMessage: nil
                 )
                 self.aiCompanionLocalRecordings.insert(summary, at: 0)
                 self.aiCompanionLocalRecordings.sort(by: { $0.endedAt > $1.endedAt })
@@ -2516,6 +2568,8 @@ private extension ViewController {
         aiCompanionSpeechTextByView.removeAll()
         aiCompanionTranscriptMeetingIdByView.removeAll()
         aiCompanionTranscriptStatusLabelByView.removeAll()
+        aiCompanionNotesMeetingIdByView.removeAll()
+        aiCompanionNotesStatusLabelByView.removeAll()
         // Keep transcript requests and window state alive across AI Companion page rebuilds.
         // The page is rebuilt while processing starts/completes, and cancelling here interrupts
         // the active request before it can update the transcript view.
@@ -2716,6 +2770,22 @@ private extension ViewController {
         transcriptStatusLabel.lineBreakMode = .byTruncatingTail
         aiCompanionTranscriptStatusLabelByView[ObjectIdentifier(transcriptButton)] = transcriptStatusLabel
 
+        let notesButton = NSButton(title: "Get notes", target: self, action: #selector(aiCompanionNotesTapped(_:)))
+        notesButton.translatesAutoresizingMaskIntoConstraints = false
+        notesButton.isBordered = false
+        notesButton.bezelStyle = .inline
+        notesButton.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
+        notesButton.contentTintColor = palette.primaryBlue
+        notesButton.alignment = .left
+        notesButton.setButtonType(.momentaryPushIn)
+        aiCompanionNotesMeetingIdByView[ObjectIdentifier(notesButton)] = recording.id
+
+        let notesStatusLabel = textLabel(aiCompanionNotesStatusText(for: recording), font: typography.fieldLabel, color: palette.textMuted)
+        notesStatusLabel.alignment = .left
+        notesStatusLabel.maximumNumberOfLines = 2
+        notesStatusLabel.lineBreakMode = .byTruncatingTail
+        aiCompanionNotesStatusLabelByView[ObjectIdentifier(notesButton)] = notesStatusLabel
+
         stack.addArrangedSubview(title)
         stack.addArrangedSubview(dateLabel)
         stack.addArrangedSubview(audioButton)
@@ -2723,6 +2793,8 @@ private extension ViewController {
         stack.addArrangedSubview(audioStatusLabel)
         stack.addArrangedSubview(transcriptButton)
         stack.addArrangedSubview(transcriptStatusLabel)
+        stack.addArrangedSubview(notesButton)
+        stack.addArrangedSubview(notesStatusLabel)
 
         card.addSubview(stack)
         NSLayoutConstraint.activate([
@@ -2991,6 +3063,9 @@ private extension ViewController {
             if forceRegenerate {
                 recording.transcriptText = nil
                 recording.transcriptSourceRaw = nil
+                recording.notesStatusRaw = MeetingNotesStatus.notRequested.rawValue
+                recording.notesText = nil
+                recording.notesErrorMessage = nil
             }
         }
         aiCompanionRefreshTranscriptStatusLabels(forMeetingID: meetingId)
@@ -3119,6 +3194,118 @@ private extension ViewController {
         return (renderedText, segmentsJSON, source)
     }
 
+    @objc private func aiCompanionNotesTapped(_ sender: NSButton) {
+        let senderId = ObjectIdentifier(sender)
+        guard let meetingId = aiCompanionNotesMeetingIdByView[senderId] else { return }
+        guard let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) else {
+            aiCompanionNotesStatusLabelByView[senderId]?.stringValue = "Notes unavailable (tap to retry)"
+            showSimpleAlert(title: "Notes unavailable", message: "Could not find recording details for this meeting.")
+            return
+        }
+
+        if aiCompanionNotesStatus(for: recording) == .ready,
+           let cached = recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines),
+           cached.isEmpty == false {
+            aiCompanionPresentNotesWindow(meetingTitle: recording.title, initialText: cached)
+            aiCompanionNotesStatusLabelByView[senderId]?.stringValue = aiCompanionNotesStatusText(for: recording)
+            return
+        }
+
+        aiCompanionNotesStatusLabelByView[senderId]?.stringValue = "Notes processing..."
+        let requestId = UUID()
+        aiCompanionNotesCurrentRequestId = requestId
+        aiCompanionPresentNotesWindow(meetingTitle: recording.title, initialText: "Generating notes...")
+        aiCompanionStartNotesProcessing(forMeetingID: meetingId, requestId: requestId)
+    }
+
+    private func aiCompanionStartNotesProcessing(forMeetingID meetingId: String, requestId: UUID?) {
+        aiCompanionNotesTaskByMeetingId[meetingId]?.cancel()
+        _ = aiCompanionUpdateRecording(meetingId: meetingId) { recording in
+            recording.notesStatusRaw = MeetingNotesStatus.processing.rawValue
+            recording.notesErrorMessage = nil
+            recording.notesText = nil
+        }
+        aiCompanionNotesProgressByMeetingId[meetingId] = "Preparing transcript..."
+        aiCompanionRefreshNotesStatusLabels(forMeetingID: meetingId)
+        if selectedSidebarPage == .aiCompanion {
+            pageCache[.aiCompanion] = nil
+            showSidebarPage(.aiCompanion)
+        }
+
+        let presentingWindow = view.window
+        let task = Task { [weak self] in
+            guard let self else { return }
+            defer { Task { @MainActor [weak self] in self?.aiCompanionNotesTaskByMeetingId[meetingId] = nil } }
+
+            do {
+                let notes = try await self.aiCompanionGenerateNotesForMeeting(meetingId: meetingId, presentingWindow: presentingWindow)
+                await MainActor.run {
+                    guard requestId == nil || self.aiCompanionNotesCurrentRequestId == requestId else { return }
+                    self.aiCompanionNotesProgressByMeetingId[meetingId] = nil
+                    _ = self.aiCompanionUpdateRecording(meetingId: meetingId) { recording in
+                        recording.notesStatusRaw = MeetingNotesStatus.ready.rawValue
+                        recording.notesText = notes
+                        recording.notesErrorMessage = nil
+                    }
+                    self.aiCompanionNotesTextView?.string = notes
+                    self.aiCompanionRefreshNotesStatusLabels(forMeetingID: meetingId)
+                    if self.selectedSidebarPage == .aiCompanion {
+                        self.pageCache[.aiCompanion] = nil
+                        self.showSidebarPage(.aiCompanion)
+                    }
+                }
+            } catch {
+                await MainActor.run {
+                    guard requestId == nil || self.aiCompanionNotesCurrentRequestId == requestId else { return }
+                    self.aiCompanionNotesProgressByMeetingId[meetingId] = nil
+                    let msg = error.localizedDescription.isEmpty ? "Failed to generate notes." : error.localizedDescription
+                    _ = self.aiCompanionUpdateRecording(meetingId: meetingId) { recording in
+                        recording.notesStatusRaw = MeetingNotesStatus.failed.rawValue
+                        recording.notesErrorMessage = msg
+                    }
+                    self.aiCompanionNotesTextView?.string = "Notes unavailable.\n\n\(msg)"
+                    self.aiCompanionRefreshNotesStatusLabels(forMeetingID: meetingId)
+                    if self.selectedSidebarPage == .aiCompanion {
+                        self.pageCache[.aiCompanion] = nil
+                        self.showSidebarPage(.aiCompanion)
+                    }
+                }
+            }
+        }
+        aiCompanionNotesTaskByMeetingId[meetingId] = task
+    }
+
+    private func aiCompanionGenerateNotesForMeeting(meetingId: String, presentingWindow: NSWindow?) async throws -> String {
+        let transcriptText: String
+        if let existing = aiCompanionLocalRecordings.first(where: { $0.id == meetingId })?.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines),
+           existing.isEmpty == false {
+            transcriptText = existing
+        } else {
+            let transcript = try await aiCompanionFetchOrGenerateTranscript(
+                meetingId: meetingId,
+                interactiveAuth: true,
+                presentingWindow: presentingWindow
+            )
+            await MainActor.run {
+                _ = self.aiCompanionUpdateRecording(meetingId: meetingId) { recording in
+                    recording.transcriptStatusRaw = MeetingTranscriptStatus.ready.rawValue
+                    recording.transcriptSourceRaw = transcript.source.rawValue
+                    recording.transcriptText = transcript.text
+                    recording.transcriptSegmentsJSON = transcript.segmentsJSON
+                    recording.transcriptErrorMessage = nil
+                }
+                self.aiCompanionRefreshTranscriptStatusLabels(forMeetingID: meetingId)
+            }
+            transcriptText = transcript.text
+        }
+
+        await MainActor.run {
+            self.aiCompanionNotesProgressByMeetingId[meetingId] = "Generating notes with GPT..."
+            self.aiCompanionRefreshNotesStatusLabels(forMeetingID: meetingId)
+        }
+        return try await meetingNotesService.generateNotes(from: transcriptText)
+    }
+
     @objc private func aiCompanionStopRecordingTapped(_ sender: NSButton) {
         finishActiveMeetingRecording()
     }
@@ -3278,6 +3465,62 @@ private extension ViewController {
         aiCompanionTranscriptTextView = textView
     }
 
+    @MainActor
+    private func aiCompanionPresentNotesWindow(meetingTitle: String, initialText: String) {
+        if let window = aiCompanionNotesWindow, let textView = aiCompanionNotesTextView {
+            window.title = "Notes - \(meetingTitle)"
+            textView.string = initialText
+            window.makeKeyAndOrderFront(nil)
+            NSApp.activate(ignoringOtherApps: true)
+            return
+        }
+
+        let windowWidth: CGFloat = 640
+        let windowHeight: CGFloat = 560
+        let window = NSWindow(
+            contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight),
+            styleMask: [.titled, .closable, .resizable],
+            backing: .buffered,
+            defer: false
+        )
+        window.isReleasedWhenClosed = false
+        window.title = "Notes - \(meetingTitle)"
+        window.center()
+
+        let root = NSView()
+        root.translatesAutoresizingMaskIntoConstraints = false
+
+        let scroll = NSScrollView()
+        scroll.translatesAutoresizingMaskIntoConstraints = false
+        scroll.drawsBackground = false
+        scroll.hasVerticalScroller = true
+
+        let textView = NSTextView()
+        textView.isEditable = false
+        textView.isSelectable = true
+        textView.backgroundColor = .clear
+        textView.font = NSFont.systemFont(ofSize: 13, weight: .regular)
+        textView.string = initialText
+        textView.textContainer?.widthTracksTextView = true
+
+        scroll.documentView = textView
+        root.addSubview(scroll)
+
+        NSLayoutConstraint.activate([
+            scroll.leadingAnchor.constraint(equalTo: root.leadingAnchor),
+            scroll.trailingAnchor.constraint(equalTo: root.trailingAnchor),
+            scroll.topAnchor.constraint(equalTo: root.topAnchor),
+            scroll.bottomAnchor.constraint(equalTo: root.bottomAnchor)
+        ])
+
+        window.contentView = root
+        window.makeKeyAndOrderFront(nil)
+        NSApp.activate(ignoringOtherApps: true)
+
+        aiCompanionNotesWindow = window
+        aiCompanionNotesTextView = textView
+    }
+
     private func aiCompanionMeetMeetingCode(from meetURL: URL) -> String? {
         // Typical: https://meet.google.com/abc-defg-hij
         guard let host = meetURL.host?.lowercased(),