Преглед изворни кода

Add GPT meeting notes from local transcripts and fix note language.

Introduce MeetingNotesService (OpenAI chat completions, same key resolution as meetings_app). Persist notes status, text, and errors on RecordedMeeting. Add Get notes next to Transcript and Play on Ai Companion recording cards, with a sheet for viewing and copying notes. Stop passing Apple Speech locale preferences into note generation so notes follow the transcript language; tighten detection and prompt wording accordingly.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 пре 2 месеци
родитељ
комит
ede622bc39
3 измењених фајлова са 757 додато и 5 уклоњено
  1. 345 0
      zoom_app/MeetingNotesService.swift
  2. 34 2
      zoom_app/MeetingRecordingManager.swift
  3. 378 3
      zoom_app/ViewController.swift

+ 345 - 0
zoom_app/MeetingNotesService.swift

@@ -0,0 +1,345 @@
+import Foundation
+
+enum MeetingNotesError: Error, LocalizedError {
+    case missingAPIKey
+    case invalidResponse
+    case httpStatus(Int, String)
+    case emptyNotes
+
+    var errorDescription: String? {
+        switch self {
+        case .missingAPIKey:
+            return "OpenAI API key is missing. Set OPENAI_API_KEY in the environment or app key vault."
+        case .invalidResponse:
+            return "Notes generation returned an invalid response."
+        case let .httpStatus(code, body):
+            return "Notes generation failed (\(code)): \(body)"
+        case .emptyNotes:
+            return "Notes generation returned empty text."
+        }
+    }
+}
+
+/// Generates concise meeting notes from transcript text (same OpenAI flow as meetings_app).
+final class MeetingNotesService {
+    private let session: URLSession
+    private enum InAppKeyVault {
+        private static let chunkOrder = [3, 9, 0, 7, 2, 10, 5, 1, 8, 4, 11, 6]
+        private static let chunkSeeds: [UInt8] = [41, 77, 113, 149, 185, 221, 17, 53, 89, 125, 161, 197]
+        private static let encryptedChunks: [[UInt8]] = [
+            [75, 2, 114, 12, 39, 47, 198, 240, 254, 150, 129, 137, 150, 83],
+            [117, 115, 43, 206, 197, 204, 221, 174, 164, 167, 158, 125, 44, 102],
+            [2, 233, 190, 215, 195, 165, 182, 139, 154, 126, 54, 65, 115, 29],
+            [193, 200, 230, 130, 237, 157, 171, 69, 73, 113, 102, 50, 14, 31],
+            [192, 140, 136, 193, 169, 70, 105, 115, 46, 106, 91, 4, 221, 160],
+            [130, 129, 174, 117, 116, 88, 112, 14, 11, 17, 208, 249, 221, 213],
+            [69, 110, 30, 16, 30, 46, 29, 204, 210, 230, 238, 147, 234, 222],
+            [102, 127, 49, 41, 74, 184, 171, 216, 210, 248, 136, 152, 84, 104],
+            [9, 3, 35, 217, 247, 152, 220, 179, 175, 192, 83, 94, 71, 120],
+            [23, 222, 174, 247, 153, 139, 173, 189, 104, 91, 20, 97, 56, 107],
+            [238, 255, 240, 187, 150, 195, 79, 119, 106, 11, 40, 47, 44],
+            [188, 130, 212, 186, 101, 120, 64, 122, 7, 22, 54, 236, 227, 229]
+        ]
+
+        static func resolveKey() -> String? {
+            guard encryptedChunks.count == chunkOrder.count, chunkSeeds.count == chunkOrder.count else {
+                return nil
+            }
+
+            var rebuiltByOriginalIndex = Array(repeating: [UInt8](), count: chunkOrder.count)
+            for (scrambledIndex, encryptedChunk) in encryptedChunks.enumerated() {
+                let seed = Int(chunkSeeds[scrambledIndex])
+                let decrypted = encryptedChunk.enumerated().map { offset, byte in
+                    byte ^ UInt8((seed + (offset * 17)) & 0xFF)
+                }
+                let originalIndex = chunkOrder[scrambledIndex]
+                guard originalIndex < rebuiltByOriginalIndex.count else { return nil }
+                rebuiltByOriginalIndex[originalIndex] = decrypted
+            }
+
+            let joined = rebuiltByOriginalIndex.flatMap { $0 }
+            guard joined.isEmpty == false else { return nil }
+            return String(decoding: joined, as: UTF8.self)
+        }
+    }
+
+    init(session: URLSession = .shared) {
+        self.session = session
+    }
+
+    private enum APIKeySource: String {
+        case argument
+        case environment
+        case inAppVault
+    }
+
+    func resolveAPIKey() -> String? {
+        let env = normalizedAPIKey(from: ProcessInfo.processInfo.environment["OPENAI_API_KEY"])
+        if let env, env.isEmpty == false { return env }
+        let vault = normalizedAPIKey(from: InAppKeyVault.resolveKey())
+        if let vault, vault.isEmpty == false { return vault }
+        return nil
+    }
+
+    func generateNotes(
+        from transcript: String,
+        preferredLanguageIdentifier1: String? = nil,
+        preferredLanguageIdentifier2: String? = nil,
+        apiKey: String? = nil
+    ) async throws -> String {
+        let keys = resolveAPIKeyCandidates(apiKey: apiKey)
+        guard keys.isEmpty == false else { throw MeetingNotesError.missingAPIKey }
+
+        struct Message: Encodable {
+            let role: String
+            let content: String
+        }
+        struct Body: Encodable {
+            let model: String
+            let messages: [Message]
+            let temperature: Double
+        }
+
+        var lastError: MeetingNotesError?
+        for (index, candidate) in keys.enumerated() {
+            do {
+                let preference = preferredNotesLanguage(
+                    primary: preferredLanguageIdentifier1,
+                    secondary: preferredLanguageIdentifier2
+                )
+                let notesLanguage: DominantLanguage
+                if let preference {
+                    notesLanguage = preference
+                } else {
+                    notesLanguage = try await detectDominantLanguage(in: transcript, apiKey: candidate.key)
+                }
+
+                let notesPrompt = buildNotesPrompt(
+                    transcript: transcript,
+                    dominantLanguage: notesLanguage,
+                    preferredLanguage: preference
+                )
+                let body = Body(
+                    model: "gpt-4.1-mini",
+                    messages: [
+                        Message(role: "system", content: "You create practical meeting notes from imperfect transcripts."),
+                        Message(role: "user", content: notesPrompt)
+                    ],
+                    temperature: 0.2
+                )
+                return try await requestChatCompletionText(body: body, apiKey: candidate.key)
+            } catch let error as MeetingNotesError {
+                switch error {
+                case .httpStatus(let code, _):
+                    lastError = error
+                    if code == 401, index < (keys.count - 1) {
+                        continue
+                    }
+                    throw error
+                default:
+                    throw error
+                }
+            }
+        }
+
+        throw lastError ?? MeetingNotesError.invalidResponse
+    }
+
+    private func requestChatCompletionText(body: Encodable, apiKey: String) async throws -> String {
+        var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
+        request.httpMethod = "POST"
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+        if let project = normalizedConfigValue(from: ProcessInfo.processInfo.environment["OPENAI_PROJECT_ID"])
+            ?? normalizedConfigValue(from: Bundle.main.object(forInfoDictionaryKey: "OpenAIProjectID") as? String) {
+            request.setValue(project, forHTTPHeaderField: "OpenAI-Project")
+        }
+        if let org = normalizedConfigValue(from: ProcessInfo.processInfo.environment["OPENAI_ORG_ID"])
+            ?? normalizedConfigValue(from: Bundle.main.object(forInfoDictionaryKey: "OpenAIOrganizationID") as? String) {
+            request.setValue(org, forHTTPHeaderField: "OpenAI-Organization")
+        }
+        request.httpBody = try JSONEncoder().encode(AnyEncodable(body))
+
+        let (data, response) = try await session.data(for: request)
+        guard let http = response as? HTTPURLResponse else { throw MeetingNotesError.invalidResponse }
+        guard (200..<300).contains(http.statusCode) else {
+            let bodyText = String(data: data, encoding: .utf8) ?? "<no body>"
+            throw MeetingNotesError.httpStatus(http.statusCode, bodyText)
+        }
+
+        struct ChoiceMessage: Decodable {
+            let content: String?
+        }
+        struct Choice: Decodable {
+            let message: ChoiceMessage
+        }
+        struct ChatResponse: Decodable {
+            let choices: [Choice]
+        }
+
+        let decoded = try JSONDecoder().decode(ChatResponse.self, from: data)
+        let notes = decoded.choices.first?.message.content?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        guard notes.isEmpty == false else { throw MeetingNotesError.emptyNotes }
+        return notes
+    }
+
+    private struct DominantLanguage: Sendable {
+        let tag: String
+        let name: String
+    }
+
+    private func preferredNotesLanguage(primary: String?, secondary: String?) -> DominantLanguage? {
+        let cleaned = [
+            primary?.trimmingCharacters(in: .whitespacesAndNewlines),
+            secondary?.trimmingCharacters(in: .whitespacesAndNewlines)
+        ]
+            .compactMap { $0 }
+            .filter { $0.isEmpty == false }
+
+        guard let identifier = cleaned.first else { return nil }
+        let tag = identifier.replacingOccurrences(of: "_", with: "-")
+        let english = Locale(identifier: "en")
+        let languageName = english.localizedString(forIdentifier: identifier)
+            ?? english.localizedString(forIdentifier: tag)
+            ?? identifier
+        return DominantLanguage(tag: tag, name: languageName)
+    }
+
+    private func detectDominantLanguage(in transcript: String, apiKey: String) async throws -> DominantLanguage {
+        struct DetectResponse: Decodable {
+            let tag: String?
+            let name: String?
+        }
+
+        let detectPrompt = """
+        Identify the dominant language of the transcript from the actual words and script used in the text below.
+        Do not assume a language from locale metadata—only from what is written in the transcript.
+        If multiple languages appear, choose the single language that carries most of the substantive meeting content.
+        If the transcript is mostly Latin letters (English, Roman Urdu, etc.), reflect that in your choice.
+
+        Return ONLY valid JSON with this exact schema:
+        {"tag":"<BCP-47 language tag>","name":"<language name in English>"}
+
+        Transcript:
+        \(transcript)
+        """
+
+        struct Message: Encodable {
+            let role: String
+            let content: String
+        }
+        struct Body: Encodable {
+            let model: String
+            let messages: [Message]
+            let temperature: Double
+        }
+
+        let body = Body(
+            model: "gpt-4.1-mini",
+            messages: [
+                Message(role: "system", content: "You detect languages and reply with strict JSON only."),
+                Message(role: "user", content: detectPrompt)
+            ],
+            temperature: 0.0
+        )
+
+        let raw = try await requestChatCompletionText(body: body, apiKey: apiKey)
+        let data = Data(raw.utf8)
+        let decoded = try JSONDecoder().decode(DetectResponse.self, from: data)
+        let tag = (decoded.tag ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+        let name = (decoded.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+
+        if tag.isEmpty == false, name.isEmpty == false {
+            return DominantLanguage(tag: tag, name: name)
+        }
+
+        if tag.isEmpty == false {
+            return DominantLanguage(tag: tag, name: tag)
+        }
+        return DominantLanguage(tag: "und", name: "the dominant language of the transcript")
+    }
+
+    private func buildNotesPrompt(
+        transcript: String,
+        dominantLanguage: DominantLanguage,
+        preferredLanguage: DominantLanguage?
+    ) -> String {
+        let preferenceLine: String = {
+            guard let preferredLanguage else { return "" }
+            return "\nUser output preference: \(preferredLanguage.name) (\(preferredLanguage.tag)). Use this only if it matches the language of the transcript content; otherwise follow the transcript’s language."
+        }()
+
+        return """
+        You are a meeting assistant. Generate structured notes from the transcript.
+        Important: the transcript may have missing words, dropped sentences, or minor recognition errors.
+        Infer likely intent conservatively and produce useful notes without inventing specific facts.
+        \(preferenceLine)
+
+        Output language (must match the transcript, not any other setting):
+        - Write ALL notes (including headings) strictly in \(dominantLanguage.name) (\(dominantLanguage.tag)), i.e. the same language as the transcript’s dominant wording.
+        - Do NOT switch to a different language (e.g. do not write in Urdu if the transcript is English, or vice versa).
+        - Do NOT respond in English unless the transcript’s dominant language is English.
+        - Keep proper nouns, people names, product names, acronyms, URLs, and code tokens exactly as-is.
+
+        Output sections (translate these section headings into \(dominantLanguage.name)):
+        1) Summary (3-5 bullets)
+        2) Decisions
+        3) Action Items (include owner if identifiable; otherwise write the equivalent of "Unassigned" in \(dominantLanguage.name))
+        4) Risks / Open Questions
+
+        Transcript:
+        \(transcript)
+        """
+    }
+
+    private func resolveAPIKeyCandidates(apiKey: String?) -> [(source: APIKeySource, key: String)] {
+        var candidates: [(APIKeySource, String)] = []
+        if let value = normalizedAPIKey(from: apiKey) {
+            candidates.append((.argument, value))
+        }
+        if let value = normalizedAPIKey(from: ProcessInfo.processInfo.environment["OPENAI_API_KEY"]) {
+            candidates.append((.environment, value))
+        }
+        if let value = normalizedAPIKey(from: InAppKeyVault.resolveKey()) {
+            candidates.append((.inAppVault, value))
+        }
+
+        var unique: [(APIKeySource, String)] = []
+        var seen = Set<String>()
+        for candidate in candidates where seen.contains(candidate.1) == false {
+            seen.insert(candidate.1)
+            unique.append(candidate)
+        }
+        return unique
+    }
+
+    private func normalizedConfigValue(from value: String?) -> String? {
+        guard var cleaned = value?.trimmingCharacters(in: .whitespacesAndNewlines), cleaned.isEmpty == false else {
+            return nil
+        }
+        cleaned = cleaned.trimmingCharacters(in: CharacterSet(charactersIn: "\"'`"))
+        return cleaned.isEmpty ? nil : cleaned
+    }
+
+    private func normalizedAPIKey(from value: String?) -> String? {
+        guard var cleaned = normalizedConfigValue(from: value) else { return nil }
+        cleaned = cleaned.replacingOccurrences(of: "—", with: "-")
+        cleaned = cleaned.replacingOccurrences(of: "–", with: "-")
+        cleaned = cleaned.replacingOccurrences(of: " ", with: "")
+        cleaned = cleaned.replacingOccurrences(of: "\u{200B}", with: "")
+        cleaned = cleaned.replacingOccurrences(of: "\u{FEFF}", with: "")
+        return cleaned.isEmpty ? nil : cleaned
+    }
+}
+
+private struct AnyEncodable: Encodable {
+    private let encodeBlock: (Encoder) throws -> Void
+
+    init(_ wrapped: Encodable) {
+        self.encodeBlock = wrapped.encode(to:)
+    }
+
+    func encode(to encoder: Encoder) throws {
+        try encodeBlock(encoder)
+    }
+}

+ 34 - 2
zoom_app/MeetingRecordingManager.swift

@@ -18,6 +18,10 @@ final class MeetingRecordingManager: NSObject {
         /// Apple Speech transcript (timeline text). Omitted in older `index.json` entries.
         /// Apple Speech transcript (timeline text). Omitted in older `index.json` entries.
         let transcriptText: String?
         let transcriptText: String?
         let transcriptError: String?
         let transcriptError: String?
+        /// GPT-generated notes (`notRequested` / `processing` / `ready` / `failed`). Omitted in older entries.
+        let notesStatusRaw: String?
+        let notesText: String?
+        let notesError: String?
 
 
         var audioFilenameURLSafe: String { audioFilename }
         var audioFilenameURLSafe: String { audioFilename }
 
 
@@ -121,6 +125,28 @@ final class MeetingRecordingManager: NSObject {
         try recordingsDirectoryURL().appendingPathComponent(recording.audioFilename)
         try recordingsDirectoryURL().appendingPathComponent(recording.audioFilename)
     }
     }
 
 
+    /// Persists AI notes fields for a recording and posts `recordingsDidChangeNotification`.
+    func setMeetingNotes(recordingId: String, notesStatusRaw: String?, notesText: String?, notesError: String?) {
+        guard let idx = recordings.firstIndex(where: { $0.id == recordingId }) else { return }
+        let old = recordings[idx]
+        let updated = RecordedMeeting(
+            id: old.id,
+            startedAt: old.startedAt,
+            endedAt: old.endedAt,
+            sourceURLString: old.sourceURLString,
+            meetingDisplayName: old.meetingDisplayName,
+            audioFilename: old.audioFilename,
+            transcriptText: old.transcriptText,
+            transcriptError: old.transcriptError,
+            notesStatusRaw: notesStatusRaw,
+            notesText: notesText,
+            notesError: notesError
+        )
+        recordings[idx] = updated
+        saveRecordingsToDisk()
+        NotificationCenter.default.post(name: Self.recordingsDidChangeNotification, object: self)
+    }
+
     /// Full path to the Recordings folder (sandboxed apps: inside `~/Library/Containers/…/Data/Library/Application Support/`).
     /// Full path to the Recordings folder (sandboxed apps: inside `~/Library/Containers/…/Data/Library/Application Support/`).
     var recordingsDirectoryPath: String {
     var recordingsDirectoryPath: String {
         (try? recordingsDirectoryURL().path) ?? ""
         (try? recordingsDirectoryURL().path) ?? ""
@@ -215,7 +241,10 @@ final class MeetingRecordingManager: NSObject {
             meetingDisplayName: displayName,
             meetingDisplayName: displayName,
             audioFilename: mixedURL.lastPathComponent,
             audioFilename: mixedURL.lastPathComponent,
             transcriptText: nil,
             transcriptText: nil,
-            transcriptError: nil
+            transcriptError: nil,
+            notesStatusRaw: nil,
+            notesText: nil,
+            notesError: nil
         )
         )
         recordings.insert(rec, at: 0)
         recordings.insert(rec, at: 0)
         saveRecordingsToDisk()
         saveRecordingsToDisk()
@@ -300,7 +329,10 @@ final class MeetingRecordingManager: NSObject {
             meetingDisplayName: old.meetingDisplayName,
             meetingDisplayName: old.meetingDisplayName,
             audioFilename: old.audioFilename,
             audioFilename: old.audioFilename,
             transcriptText: text,
             transcriptText: text,
-            transcriptError: error
+            transcriptError: error,
+            notesStatusRaw: old.notesStatusRaw,
+            notesText: old.notesText,
+            notesError: old.notesError
         )
         )
         recordings[idx] = updated
         recordings[idx] = updated
         saveRecordingsToDisk()
         saveRecordingsToDisk()

+ 378 - 3
zoom_app/ViewController.swift

@@ -343,6 +343,16 @@ class ViewController: NSViewController {
     private weak var aiCompanionLocalTranscriptHeaderView: NSView?
     private weak var aiCompanionLocalTranscriptHeaderView: NSView?
     private weak var aiCompanionLocalTranscriptSubtitleLabel: NSTextField?
     private weak var aiCompanionLocalTranscriptSubtitleLabel: NSTextField?
     private var aiCompanionLocalTranscriptPanelRecordingID: String?
     private var aiCompanionLocalTranscriptPanelRecordingID: String?
+    private weak var aiCompanionLocalNotesPanel: NSPanel?
+    private weak var aiCompanionLocalNotesTextView: NSTextView?
+    private weak var aiCompanionLocalNotesRootView: NSView?
+    private weak var aiCompanionLocalNotesHeaderView: NSView?
+    private weak var aiCompanionLocalNotesSubtitleLabel: NSTextField?
+    private var aiCompanionLocalNotesPanelRecordingID: String?
+    private let meetingNotesService = MeetingNotesService()
+    private var localRecordingNotesTaskByRecordingId: [String: Task<Void, Never>] = [:]
+    private var localRecordingNotesRequestIdByRecordingId: [String: UUID] = [:]
+    private var localRecordingNotesProgressByRecordingId: [String: String] = [:]
     private weak var meetingsPageCategoryFilter: NSPopUpButton?
     private weak var meetingsPageCategoryFilter: NSPopUpButton?
     private weak var meetingsPageFromDatePicker: NSDatePicker?
     private weak var meetingsPageFromDatePicker: NSDatePicker?
     private weak var meetingsPageToDatePicker: NSDatePicker?
     private weak var meetingsPageToDatePicker: NSDatePicker?
@@ -420,6 +430,13 @@ class ViewController: NSViewController {
     private weak var meetingConsentSecondaryLanguagePopup: NSPopUpButton?
     private weak var meetingConsentSecondaryLanguagePopup: NSPopUpButton?
 
 
     private var aiCompanionTranscriptionActiveRecordingIDs: Set<String> = []
     private var aiCompanionTranscriptionActiveRecordingIDs: Set<String> = []
+
+    private enum LocalRecordingNotesStatus: String {
+        case notRequested
+        case processing
+        case ready
+        case failed
+    }
     private let paywallContentWidth: CGFloat = 520
     private let paywallContentWidth: CGFloat = 520
     private var selectedPremiumPlan: PremiumPlan = .yearly
     private var selectedPremiumPlan: PremiumPlan = .yearly
     private var paywallPlanViews: [PremiumPlan: [NSView]] = [:]
     private var paywallPlanViews: [PremiumPlan: [NSView]] = [:]
@@ -679,6 +696,8 @@ class ViewController: NSViewController {
                     self.aiCompanionTranscriptionActiveRecordingIDs.remove(rec.id)
                     self.aiCompanionTranscriptionActiveRecordingIDs.remove(rec.id)
                 }
                 }
                 self.renderAiCompanionList()
                 self.renderAiCompanionList()
+                self.refreshLocalRecordingTranscriptPanelContentIfNeeded()
+                self.refreshLocalRecordingNotesPanelContentIfNeeded()
             }
             }
         }
         }
         NotificationCenter.default.addObserver(
         NotificationCenter.default.addObserver(
@@ -7165,6 +7184,14 @@ class ViewController: NSViewController {
             row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
             row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
         }
         }
         refreshLocalRecordingTranscriptPanelContentIfNeeded()
         refreshLocalRecordingTranscriptPanelContentIfNeeded()
+        refreshLocalRecordingNotesPanelContentIfNeeded()
+    }
+
+    private func localRecordingNotesStatus(for recording: MeetingRecordingManager.RecordedMeeting) -> LocalRecordingNotesStatus {
+        guard let raw = recording.notesStatusRaw, let s = LocalRecordingNotesStatus(rawValue: raw) else {
+            return .notRequested
+        }
+        return s
     }
     }
 
 
     @MainActor
     @MainActor
@@ -7201,6 +7228,25 @@ class ViewController: NSViewController {
         transcriptBtn.font = .systemFont(ofSize: 12, weight: .semibold)
         transcriptBtn.font = .systemFont(ofSize: 12, weight: .semibold)
         transcriptBtn.tag = index
         transcriptBtn.tag = index
 
 
+        let getNotesBtn = NSButton(title: "Get notes", target: self, action: #selector(recordingGetNotesTapped(_:)))
+        getNotesBtn.translatesAutoresizingMaskIntoConstraints = false
+        getNotesBtn.isBordered = false
+        getNotesBtn.wantsLayer = true
+        getNotesBtn.layer?.backgroundColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06)).cgColor
+        getNotesBtn.layer?.cornerRadius = 10
+        getNotesBtn.layer?.borderWidth = 1
+        getNotesBtn.layer?.borderColor = palette.inputBorder.cgColor
+        getNotesBtn.contentTintColor = primaryText
+        getNotesBtn.font = .systemFont(ofSize: 12, weight: .semibold)
+        getNotesBtn.tag = index
+
+        let notesStatus = localRecordingNotesStatus(for: recording)
+        let hasTranscript = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
+        let transcriptionBusy = aiCompanionTranscriptionActiveRecordingIDs.contains(recording.id)
+        let getNotesEnabled = (notesStatus != .processing)
+            && (notesStatus == .ready || notesStatus == .failed || (hasTranscript && !transcriptionBusy))
+        getNotesBtn.isEnabled = getNotesEnabled
+
         let play = NSButton(title: recordedMeetingsPlayingID == recording.id ? "Stop" : "Play", target: self, action: #selector(recordingPlayTapped(_:)))
         let play = NSButton(title: recordedMeetingsPlayingID == recording.id ? "Stop" : "Play", target: self, action: #selector(recordingPlayTapped(_:)))
         play.translatesAutoresizingMaskIntoConstraints = false
         play.translatesAutoresizingMaskIntoConstraints = false
         play.isBordered = false
         play.isBordered = false
@@ -7211,7 +7257,10 @@ class ViewController: NSViewController {
         play.font = .systemFont(ofSize: 12, weight: .semibold)
         play.font = .systemFont(ofSize: 12, weight: .semibold)
         play.tag = index
         play.tag = index
 
 
-        let buttonsRow = NSStackView(views: [transcriptBtn, play])
+        let buttonsRow = NSStackView(views: [transcriptBtn, getNotesBtn, play])
+        transcriptBtn.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        getNotesBtn.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        play.setContentHuggingPriority(.required, for: .horizontal)
         buttonsRow.translatesAutoresizingMaskIntoConstraints = false
         buttonsRow.translatesAutoresizingMaskIntoConstraints = false
         buttonsRow.orientation = .horizontal
         buttonsRow.orientation = .horizontal
         buttonsRow.alignment = .centerY
         buttonsRow.alignment = .centerY
@@ -7251,6 +7300,36 @@ class ViewController: NSViewController {
             lastStackBottom = errLabel
             lastStackBottom = errLabel
         }
         }
 
 
+        if notesStatus == .processing {
+            let msg = localRecordingNotesProgressByRecordingId[recording.id] ?? "Preparing notes…"
+            let busyNotes = makeLabel("Notes: \(msg)", size: 11, color: secondaryText, weight: .regular, centered: false)
+            busyNotes.translatesAutoresizingMaskIntoConstraints = false
+            busyNotes.maximumNumberOfLines = 2
+            card.addSubview(busyNotes)
+            NSLayoutConstraint.activate([
+                busyNotes.topAnchor.constraint(equalTo: lastStackBottom.bottomAnchor, constant: 6),
+                busyNotes.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
+                busyNotes.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
+            ])
+            lastStackBottom = busyNotes
+        } else if notesStatus == .failed,
+                  let ne = recording.notesError?.trimmingCharacters(in: .whitespacesAndNewlines), ne.isEmpty == false {
+            let notesErr = makeLabel("Notes: \(ne)", size: 11, color: NSColor.systemRed, weight: .regular, centered: false)
+            notesErr.translatesAutoresizingMaskIntoConstraints = false
+            notesErr.maximumNumberOfLines = 2
+            notesErr.lineBreakMode = .byWordWrapping
+            if let cell = notesErr.cell as? NSTextFieldCell {
+                cell.wraps = true
+            }
+            card.addSubview(notesErr)
+            NSLayoutConstraint.activate([
+                notesErr.topAnchor.constraint(equalTo: lastStackBottom.bottomAnchor, constant: 6),
+                notesErr.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
+                notesErr.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12)
+            ])
+            lastStackBottom = notesErr
+        }
+
         NSLayoutConstraint.activate([
         NSLayoutConstraint.activate([
             title.topAnchor.constraint(equalTo: card.topAnchor, constant: 10),
             title.topAnchor.constraint(equalTo: card.topAnchor, constant: 10),
             title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
             title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
@@ -7260,9 +7339,11 @@ class ViewController: NSViewController {
             buttonsRow.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12),
             buttonsRow.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12),
 
 
             transcriptBtn.heightAnchor.constraint(equalToConstant: 30),
             transcriptBtn.heightAnchor.constraint(equalToConstant: 30),
-            transcriptBtn.widthAnchor.constraint(greaterThanOrEqualToConstant: 92),
+            transcriptBtn.widthAnchor.constraint(greaterThanOrEqualToConstant: 80),
+            getNotesBtn.heightAnchor.constraint(equalToConstant: 30),
+            getNotesBtn.widthAnchor.constraint(greaterThanOrEqualToConstant: 88),
             play.heightAnchor.constraint(equalToConstant: 30),
             play.heightAnchor.constraint(equalToConstant: 30),
-            play.widthAnchor.constraint(greaterThanOrEqualToConstant: 72),
+            play.widthAnchor.constraint(greaterThanOrEqualToConstant: 68),
 
 
             dateLabel.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
             dateLabel.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
             dateLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor),
             dateLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor),
@@ -7287,6 +7368,296 @@ class ViewController: NSViewController {
         return "Transcript will appear here after the audio is processed. You can leave this window open; it updates when the transcript is ready. If nothing appears, check Speech Recognition in System Settings → Privacy & Security."
         return "Transcript will appear here after the audio is processed. You can leave this window open; it updates when the transcript is ready. If nothing appears, check Speech Recognition in System Settings → Privacy & Security."
     }
     }
 
 
+    private func bodyTextForLocalRecordingNotes(recording: MeetingRecordingManager.RecordedMeeting) -> String {
+        switch localRecordingNotesStatus(for: recording) {
+        case .processing:
+            return localRecordingNotesProgressByRecordingId[recording.id] ?? "Preparing notes…"
+        case .failed:
+            let err = recording.notesError?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+            return err.isEmpty ? "Notes could not be generated." : "Notes unavailable.\n\n\(err)"
+        case .ready:
+            return recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        case .notRequested:
+            return "Tap Get notes on the recording card after the transcript is ready."
+        }
+    }
+
+    @MainActor
+    private func refreshLocalRecordingNotesPanelContentIfNeeded() {
+        guard let recordingID = aiCompanionLocalNotesPanelRecordingID,
+              let textView = aiCompanionLocalNotesTextView,
+              let recording = recordedMeetings.first(where: { $0.id == recordingID }) else { return }
+        textView.string = bodyTextForLocalRecordingNotes(recording: recording)
+    }
+
+    @MainActor
+    private func presentLocalRecordingNotesPanel(recording: MeetingRecordingManager.RecordedMeeting, initialText: String?) {
+        guard let hostWindow = view.window else { return }
+        let meetingTitle = recording.resolvedDisplayTitle
+        let trimmedInitial = initialText?.trimmingCharacters(in: .whitespacesAndNewlines)
+        let body: String = {
+            if let trimmedInitial, trimmedInitial.isEmpty == false { return trimmedInitial }
+            return bodyTextForLocalRecordingNotes(recording: recording)
+        }()
+
+        let hostIsDarkMode = darkModeEnabled
+        let panelAppearanceName: NSAppearance.Name = hostIsDarkMode ? .darkAqua : .aqua
+        let outerBackgroundColor: NSColor = hostIsDarkMode ? .windowBackgroundColor : NSColor(calibratedWhite: 0.98, alpha: 1.0)
+        let headerBackgroundColor: NSColor = hostIsDarkMode ? .controlBackgroundColor : NSColor(calibratedWhite: 0.93, alpha: 1.0)
+
+        if let panel = aiCompanionLocalNotesPanel, let textView = aiCompanionLocalNotesTextView {
+            panel.title = "Notes - \(meetingTitle)"
+            panel.appearance = NSAppearance(named: panelAppearanceName)
+            panel.backgroundColor = outerBackgroundColor
+            textView.string = body
+            aiCompanionLocalNotesPanelRecordingID = recording.id
+            aiCompanionLocalNotesRootView?.layer?.backgroundColor = outerBackgroundColor.cgColor
+            aiCompanionLocalNotesHeaderView?.layer?.backgroundColor = headerBackgroundColor.cgColor
+            aiCompanionLocalNotesSubtitleLabel?.stringValue = meetingTitle
+            if hostWindow.attachedSheet !== panel {
+                hostWindow.beginSheet(panel, completionHandler: nil)
+            }
+            panel.makeKeyAndOrderFront(nil)
+            NSApp.activate(ignoringOtherApps: true)
+            return
+        }
+
+        let panelWidth: CGFloat = 720
+        let panelHeight: CGFloat = 540
+        let panel = NSPanel(
+            contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight),
+            styleMask: [.titled, .closable],
+            backing: .buffered,
+            defer: false
+        )
+        panel.isReleasedWhenClosed = false
+        panel.title = "Notes - \(meetingTitle)"
+        panel.isFloatingPanel = false
+        panel.hidesOnDeactivate = false
+        panel.delegate = self
+        panel.appearance = NSAppearance(named: panelAppearanceName)
+        panel.backgroundColor = outerBackgroundColor
+        panel.standardWindowButton(.zoomButton)?.isHidden = true
+        panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
+
+        let root = NSView()
+        root.translatesAutoresizingMaskIntoConstraints = false
+        root.wantsLayer = true
+        root.layer?.backgroundColor = outerBackgroundColor.cgColor
+
+        let header = NSView()
+        header.translatesAutoresizingMaskIntoConstraints = false
+        header.wantsLayer = true
+        header.layer?.backgroundColor = headerBackgroundColor.cgColor
+        header.layer?.cornerRadius = 10
+
+        let headingColor: NSColor = hostIsDarkMode ? .labelColor : .black
+        let titleLabel = textLabel("Meeting notes", font: .systemFont(ofSize: 15, weight: .semibold), color: headingColor)
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
+        let subtitleLabel = textLabel(meetingTitle, font: .systemFont(ofSize: 12, weight: .regular), color: .secondaryLabelColor)
+        subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
+        subtitleLabel.lineBreakMode = .byTruncatingTail
+
+        let scroll = NSScrollView()
+        scroll.translatesAutoresizingMaskIntoConstraints = false
+        scroll.drawsBackground = true
+        scroll.backgroundColor = NSColor.textBackgroundColor
+        scroll.hasVerticalScroller = true
+        scroll.hasHorizontalScroller = false
+        scroll.borderType = .bezelBorder
+
+        let textView = NSTextView()
+        textView.isEditable = false
+        textView.isSelectable = true
+        textView.backgroundColor = NSColor.textBackgroundColor
+        textView.textColor = NSColor.labelColor
+        textView.font = .systemFont(ofSize: 13, weight: .regular)
+        textView.string = body
+        textView.textContainer?.widthTracksTextView = true
+        textView.textContainerInset = NSSize(width: 10, height: 10)
+
+        let copyButton = NSButton(title: "Copy", target: self, action: #selector(aiCompanionLocalNotesCopyTapped(_:)))
+        copyButton.translatesAutoresizingMaskIntoConstraints = false
+        copyButton.bezelStyle = .rounded
+
+        let closeButton = NSButton(title: "Close", target: self, action: #selector(aiCompanionLocalNotesCloseTapped(_:)))
+        closeButton.translatesAutoresizingMaskIntoConstraints = false
+        closeButton.bezelStyle = .rounded
+
+        scroll.documentView = textView
+        header.addSubview(titleLabel)
+        header.addSubview(subtitleLabel)
+        header.addSubview(copyButton)
+        header.addSubview(closeButton)
+        root.addSubview(header)
+        root.addSubview(scroll)
+
+        NSLayoutConstraint.activate([
+            header.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
+            header.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
+            header.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
+
+            titleLabel.topAnchor.constraint(equalTo: header.topAnchor, constant: 12),
+            titleLabel.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 12),
+            titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: copyButton.leadingAnchor, constant: -12),
+
+            subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
+            subtitleLabel.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 12),
+            subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: copyButton.leadingAnchor, constant: -12),
+            subtitleLabel.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -12),
+
+            copyButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
+            copyButton.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -8),
+
+            closeButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
+            closeButton.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -12),
+
+            scroll.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 12),
+            scroll.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
+            scroll.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
+            scroll.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -16)
+        ])
+
+        panel.contentView = root
+        aiCompanionLocalNotesPanel = panel
+        aiCompanionLocalNotesTextView = textView
+        aiCompanionLocalNotesRootView = root
+        aiCompanionLocalNotesHeaderView = header
+        aiCompanionLocalNotesSubtitleLabel = subtitleLabel
+        aiCompanionLocalNotesPanelRecordingID = recording.id
+
+        hostWindow.beginSheet(panel, completionHandler: nil)
+        panel.makeKeyAndOrderFront(nil)
+        NSApp.activate(ignoringOtherApps: true)
+    }
+
+    @MainActor
+    private func startLocalRecordingNotesProcessing(recordingId: String, transcript: String, requestId: UUID, autoPresentOnReady: Bool) {
+        localRecordingNotesTaskByRecordingId[recordingId]?.cancel()
+        localRecordingNotesRequestIdByRecordingId[recordingId] = requestId
+        localRecordingNotesProgressByRecordingId[recordingId] = "Preparing notes, please wait..."
+        MeetingRecordingManager.shared.setMeetingNotes(
+            recordingId: recordingId,
+            notesStatusRaw: LocalRecordingNotesStatus.processing.rawValue,
+            notesText: nil,
+            notesError: nil
+        )
+        recordedMeetings = MeetingRecordingManager.shared.recordings
+        renderAiCompanionList()
+
+        let task = Task { [weak self] in
+            guard let self else { return }
+            do {
+                // Notes language must follow the transcript text, not Apple Speech locale prefs
+                // (e.g. Urdu selected for recognition must not force Urdu notes for an English transcript).
+                let notes = try await self.meetingNotesService.generateNotes(from: transcript)
+                if Task.isCancelled {
+                    await MainActor.run { [weak self] in
+                        guard let self else { return }
+                        if self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId {
+                            self.localRecordingNotesTaskByRecordingId[recordingId] = nil
+                        }
+                    }
+                    return
+                }
+                await MainActor.run { [weak self] in
+                    guard let self else { return }
+                    guard self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId else { return }
+                    self.localRecordingNotesProgressByRecordingId[recordingId] = nil
+                    MeetingRecordingManager.shared.setMeetingNotes(
+                        recordingId: recordingId,
+                        notesStatusRaw: LocalRecordingNotesStatus.ready.rawValue,
+                        notesText: notes,
+                        notesError: nil
+                    )
+                    self.recordedMeetings = MeetingRecordingManager.shared.recordings
+                    self.refreshLocalRecordingNotesPanelContentIfNeeded()
+                    self.renderAiCompanionList()
+                    if autoPresentOnReady, let rec = self.recordedMeetings.first(where: { $0.id == recordingId }) {
+                        self.presentLocalRecordingNotesPanel(recording: rec, initialText: notes)
+                    }
+                    if self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId {
+                        self.localRecordingNotesTaskByRecordingId[recordingId] = nil
+                    }
+                }
+            } catch {
+                if error is CancellationError {
+                    await MainActor.run { [weak self] in
+                        guard let self else { return }
+                        if self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId {
+                            self.localRecordingNotesTaskByRecordingId[recordingId] = nil
+                        }
+                    }
+                    return
+                }
+                await MainActor.run { [weak self] in
+                    guard let self else { return }
+                    guard self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId else { return }
+                    self.localRecordingNotesProgressByRecordingId[recordingId] = nil
+                    let msg = error.localizedDescription.isEmpty ? "Failed to generate notes." : error.localizedDescription
+                    MeetingRecordingManager.shared.setMeetingNotes(
+                        recordingId: recordingId,
+                        notesStatusRaw: LocalRecordingNotesStatus.failed.rawValue,
+                        notesText: nil,
+                        notesError: msg
+                    )
+                    self.recordedMeetings = MeetingRecordingManager.shared.recordings
+                    self.refreshLocalRecordingNotesPanelContentIfNeeded()
+                    self.renderAiCompanionList()
+                    if self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId {
+                        self.localRecordingNotesTaskByRecordingId[recordingId] = nil
+                    }
+                }
+            }
+        }
+        localRecordingNotesTaskByRecordingId[recordingId] = task
+    }
+
+    @objc private func recordingGetNotesTapped(_ sender: NSButton) {
+        let idx = sender.tag
+        guard recordedMeetings.indices.contains(idx) else { return }
+        let recording = recordedMeetings[idx]
+        let status = localRecordingNotesStatus(for: recording)
+
+        if status == .ready,
+           let text = recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines),
+           text.isEmpty == false {
+            presentLocalRecordingNotesPanel(recording: recording, initialText: text)
+            return
+        }
+
+        if status == .processing { return }
+
+        if aiCompanionTranscriptionActiveRecordingIDs.contains(recording.id) {
+            showSimpleAlert(title: "Transcript not ready", message: "Wait until the transcript finishes generating, then try Get notes again.")
+            return
+        }
+
+        let transcript = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        if transcript.isEmpty {
+            if let err = recording.transcriptError?.trimmingCharacters(in: .whitespacesAndNewlines), err.isEmpty == false {
+                showSimpleAlert(title: "No transcript", message: "Transcription failed for this recording, so notes cannot be generated.")
+            } else {
+                showSimpleAlert(title: "No transcript yet", message: "Wait until the transcript is available (or open Transcript to check progress), then tap Get notes.")
+            }
+            return
+        }
+
+        let requestId = UUID()
+        startLocalRecordingNotesProcessing(recordingId: recording.id, transcript: transcript, requestId: requestId, autoPresentOnReady: true)
+    }
+
+    @objc private func aiCompanionLocalNotesCopyTapped(_ sender: NSButton) {
+        let text = aiCompanionLocalNotesTextView?.string ?? ""
+        NSPasteboard.general.clearContents()
+        NSPasteboard.general.setString(text, forType: .string)
+    }
+
+    @objc private func aiCompanionLocalNotesCloseTapped(_ sender: NSButton) {
+        guard let panel = aiCompanionLocalNotesPanel, let host = view.window else { return }
+        host.endSheet(panel)
+    }
+
     @MainActor
     @MainActor
     private func refreshLocalRecordingTranscriptPanelContentIfNeeded() {
     private func refreshLocalRecordingTranscriptPanelContentIfNeeded() {
         guard let recordingID = aiCompanionLocalTranscriptPanelRecordingID,
         guard let recordingID = aiCompanionLocalTranscriptPanelRecordingID,
@@ -8784,6 +9155,10 @@ extension ViewController: NSWindowDelegate {
             host.endSheet(panel)
             host.endSheet(panel)
             return false
             return false
         }
         }
+        if let panel = aiCompanionLocalNotesPanel, sender === panel, let host = view.window {
+            host.endSheet(panel)
+            return false
+        }
         return true
         return true
     }
     }