Sfoglia il codice sorgente

Add Google Meet transcript fetching to Ai companion.

Wire Ai companion transcript viewing to live Meet artifacts, improve API error visibility, and fix conference record lookup so transcript retrieval works reliably when Meet transcription is available.

Made-with: Cursor
huzaifahayat12 1 mese fa
parent
commit
f563de8e25

+ 3 - 1
meetings_app/Auth/GoogleOAuthService.swift

@@ -47,7 +47,9 @@ final class GoogleOAuthService: NSObject {
47
         "openid",
47
         "openid",
48
         "email",
48
         "email",
49
         "profile",
49
         "profile",
50
-        "https://www.googleapis.com/auth/calendar.events"
50
+        "https://www.googleapis.com/auth/calendar.events",
51
+        // Required for Google Meet conferenceRecords/transcripts APIs.
52
+        "https://www.googleapis.com/auth/meetings.space.readonly"
51
     ]
53
     ]
52
 
54
 
53
     private let tokenStore = KeychainTokenStore()
55
     private let tokenStore = KeychainTokenStore()

+ 172 - 7
meetings_app/Google/GoogleMeetClient.swift

@@ -2,7 +2,8 @@ import Foundation
2
 
2
 
3
 enum GoogleMeetClientError: Error {
3
 enum GoogleMeetClientError: Error {
4
     case invalidResponse
4
     case invalidResponse
5
-    case httpStatus(Int)
5
+    case httpStatus(Int, String)
6
+    case decodeFailed(String)
6
 }
7
 }
7
 
8
 
8
 /// Thin Meet REST API wrapper.
9
 /// Thin Meet REST API wrapper.
@@ -14,12 +15,36 @@ final class GoogleMeetClient {
14
         self.session = session
15
         self.session = session
15
     }
16
     }
16
 
17
 
17
-    /// Lists conference records for a given meeting space resource name.
18
-    /// This is intentionally minimal scaffolding for phase 2 enrichment.
18
+    func getSpace(accessToken: String, spaceNameOrMeetingCode: String) async throws -> Space {
19
+        let encoded = spaceNameOrMeetingCode.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spaceNameOrMeetingCode
20
+        let url = URL(string: "https://meet.googleapis.com/v2/\(encoded)")!
21
+        var request = URLRequest(url: url)
22
+        request.httpMethod = "GET"
23
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
24
+
25
+        let (data, response) = try await session.data(for: request)
26
+        guard let http = response as? HTTPURLResponse else { throw GoogleMeetClientError.invalidResponse }
27
+        guard (200..<300).contains(http.statusCode) else {
28
+            let body = String(data: data, encoding: .utf8) ?? "<no body>"
29
+            throw GoogleMeetClientError.httpStatus(http.statusCode, body)
30
+        }
31
+
32
+        let decoder = JSONDecoder()
33
+        decoder.dateDecodingStrategy = .iso8601
34
+        do {
35
+            return try decoder.decode(Space.self, from: data)
36
+        } catch {
37
+            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
38
+            throw GoogleMeetClientError.decodeFailed(raw)
39
+        }
40
+    }
41
+
42
+    /// Lists conference records filtered by meeting space resource name.
19
     func listConferenceRecords(accessToken: String, spaceResourceName: String, pageSize: Int = 10) async throws -> [ConferenceRecord] {
43
     func listConferenceRecords(accessToken: String, spaceResourceName: String, pageSize: Int = 10) async throws -> [ConferenceRecord] {
20
-        var components = URLComponents(string: "https://meet.googleapis.com/v2/\(spaceResourceName)/conferenceRecords")!
44
+        var components = URLComponents(string: "https://meet.googleapis.com/v2/conferenceRecords")!
21
         components.queryItems = [
45
         components.queryItems = [
22
-            URLQueryItem(name: "pageSize", value: String(pageSize))
46
+            URLQueryItem(name: "pageSize", value: String(pageSize)),
47
+            URLQueryItem(name: "filter", value: "space.name = \"\(spaceResourceName)\"")
23
         ]
48
         ]
24
 
49
 
25
         var request = URLRequest(url: components.url!)
50
         var request = URLRequest(url: components.url!)
@@ -28,11 +53,107 @@ final class GoogleMeetClient {
28
 
53
 
29
         let (data, response) = try await session.data(for: request)
54
         let (data, response) = try await session.data(for: request)
30
         guard let http = response as? HTTPURLResponse else { throw GoogleMeetClientError.invalidResponse }
55
         guard let http = response as? HTTPURLResponse else { throw GoogleMeetClientError.invalidResponse }
31
-        guard (200..<300).contains(http.statusCode) else { throw GoogleMeetClientError.httpStatus(http.statusCode) }
56
+        guard (200..<300).contains(http.statusCode) else {
57
+            let body = String(data: data, encoding: .utf8) ?? "<no body>"
58
+            throw GoogleMeetClientError.httpStatus(http.statusCode, body)
59
+        }
32
 
60
 
33
-        let decoded = try JSONDecoder().decode(ListConferenceRecordsResponse.self, from: data)
61
+        let decoder = JSONDecoder()
62
+        decoder.dateDecodingStrategy = .iso8601
63
+        let decoded: ListConferenceRecordsResponse
64
+        do {
65
+            decoded = try decoder.decode(ListConferenceRecordsResponse.self, from: data)
66
+        } catch {
67
+            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
68
+            throw GoogleMeetClientError.decodeFailed(raw)
69
+        }
34
         return decoded.conferenceRecords ?? []
70
         return decoded.conferenceRecords ?? []
35
     }
71
     }
72
+
73
+    func listTranscripts(accessToken: String, conferenceRecordName: String, pageSize: Int = 20) async throws -> [Transcript] {
74
+        var transcripts: [Transcript] = []
75
+        var nextPageToken: String?
76
+
77
+        repeat {
78
+            var components = URLComponents(string: "https://meet.googleapis.com/v2/\(conferenceRecordName)/transcripts")!
79
+            components.queryItems = [
80
+                URLQueryItem(name: "pageSize", value: String(pageSize)),
81
+                URLQueryItem(name: "pageToken", value: nextPageToken ?? "")
82
+            ].filter { $0.value?.isEmpty == false }
83
+
84
+            var request = URLRequest(url: components.url!)
85
+            request.httpMethod = "GET"
86
+            request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
87
+
88
+            let (data, response) = try await session.data(for: request)
89
+            guard let http = response as? HTTPURLResponse else { throw GoogleMeetClientError.invalidResponse }
90
+            guard (200..<300).contains(http.statusCode) else {
91
+                let body = String(data: data, encoding: .utf8) ?? "<no body>"
92
+                throw GoogleMeetClientError.httpStatus(http.statusCode, body)
93
+            }
94
+
95
+            let decoder = JSONDecoder()
96
+            decoder.dateDecodingStrategy = .iso8601
97
+            let decoded: ListTranscriptsResponse
98
+            do {
99
+                decoded = try decoder.decode(ListTranscriptsResponse.self, from: data)
100
+            } catch {
101
+                let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
102
+                throw GoogleMeetClientError.decodeFailed(raw)
103
+            }
104
+            transcripts.append(contentsOf: decoded.transcripts ?? [])
105
+            nextPageToken = decoded.nextPageToken
106
+        } while nextPageToken != nil
107
+
108
+        return transcripts
109
+    }
110
+
111
+    func listTranscriptEntries(
112
+        accessToken: String,
113
+        transcriptName: String,
114
+        pageSize: Int = 200,
115
+        maxEntries: Int = 10_000
116
+    ) async throws -> [TranscriptEntry] {
117
+        var entries: [TranscriptEntry] = []
118
+        var nextPageToken: String?
119
+
120
+        repeat {
121
+            var components = URLComponents(string: "https://meet.googleapis.com/v2/\(transcriptName)/entries")!
122
+            components.queryItems = [
123
+                URLQueryItem(name: "pageSize", value: String(pageSize)),
124
+                URLQueryItem(name: "pageToken", value: nextPageToken ?? "")
125
+            ].filter { $0.value?.isEmpty == false }
126
+
127
+            var request = URLRequest(url: components.url!)
128
+            request.httpMethod = "GET"
129
+            request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
130
+
131
+            let (data, response) = try await session.data(for: request)
132
+            guard let http = response as? HTTPURLResponse else { throw GoogleMeetClientError.invalidResponse }
133
+            guard (200..<300).contains(http.statusCode) else {
134
+                let body = String(data: data, encoding: .utf8) ?? "<no body>"
135
+                throw GoogleMeetClientError.httpStatus(http.statusCode, body)
136
+            }
137
+
138
+            let decoder = JSONDecoder()
139
+            decoder.dateDecodingStrategy = .iso8601
140
+            let decoded: ListTranscriptEntriesResponse
141
+            do {
142
+                decoded = try decoder.decode(ListTranscriptEntriesResponse.self, from: data)
143
+            } catch {
144
+                let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
145
+                throw GoogleMeetClientError.decodeFailed(raw)
146
+            }
147
+            entries.append(contentsOf: decoded.transcriptEntries ?? [])
148
+            nextPageToken = decoded.nextPageToken
149
+
150
+            if entries.count >= maxEntries {
151
+                break
152
+            }
153
+        } while nextPageToken != nil
154
+
155
+        return entries
156
+    }
36
 }
157
 }
37
 
158
 
38
 // MARK: - Minimal models (v2)
159
 // MARK: - Minimal models (v2)
@@ -43,8 +164,52 @@ struct ConferenceRecord: Decodable, Equatable {
43
     let endTime: Date?
164
     let endTime: Date?
44
 }
165
 }
45
 
166
 
167
+struct Space: Decodable, Equatable {
168
+    let name: String?
169
+    let meetingUri: String?
170
+    let meetingCode: String?
171
+}
172
+
173
+struct Transcript: Decodable, Equatable {
174
+    let name: String?
175
+    let state: String?
176
+    let startTime: Date?
177
+    let endTime: Date?
178
+}
179
+
180
+struct TranscriptEntry: Decodable, Equatable {
181
+    let name: String?
182
+    let participant: String?
183
+    let text: String?
184
+    let languageCode: String?
185
+    let startTime: Date?
186
+    let endTime: Date?
187
+}
188
+
46
 private struct ListConferenceRecordsResponse: Decodable {
189
 private struct ListConferenceRecordsResponse: Decodable {
47
     let conferenceRecords: [ConferenceRecord]?
190
     let conferenceRecords: [ConferenceRecord]?
48
     let nextPageToken: String?
191
     let nextPageToken: String?
49
 }
192
 }
50
 
193
 
194
+private struct ListTranscriptsResponse: Decodable {
195
+    let transcripts: [Transcript]?
196
+    let nextPageToken: String?
197
+}
198
+
199
+private struct ListTranscriptEntriesResponse: Decodable {
200
+    let transcriptEntries: [TranscriptEntry]?
201
+    let nextPageToken: String?
202
+}
203
+
204
+extension GoogleMeetClientError: LocalizedError {
205
+    var errorDescription: String? {
206
+        switch self {
207
+        case .invalidResponse:
208
+            return "Google Meet returned an invalid response."
209
+        case let .httpStatus(status, body):
210
+            return "Google Meet API error (\(status)): \(body)"
211
+        case let .decodeFailed(raw):
212
+            return "Failed to parse Google Meet response: \(raw)"
213
+        }
214
+    }
215
+}

+ 225 - 2
meetings_app/ViewController.swift

@@ -270,6 +270,12 @@ final class ViewController: NSViewController {
270
     private var settingsActionByView = [ObjectIdentifier: SettingsAction]()
270
     private var settingsActionByView = [ObjectIdentifier: SettingsAction]()
271
     private var aiCompanionAudioURLByView = [ObjectIdentifier: URL]()
271
     private var aiCompanionAudioURLByView = [ObjectIdentifier: URL]()
272
     private var aiCompanionAudioStatusLabelByView = [ObjectIdentifier: NSTextField]()
272
     private var aiCompanionAudioStatusLabelByView = [ObjectIdentifier: NSTextField]()
273
+    private var aiCompanionTranscriptMeetingIdByView = [ObjectIdentifier: String]()
274
+    private var aiCompanionTranscriptStatusLabelByView = [ObjectIdentifier: NSTextField]()
275
+    private var aiCompanionTranscriptTextByMeetingId = [String: String]()
276
+    private var aiCompanionTranscriptCurrentRequestId: UUID?
277
+    private var aiCompanionTranscriptWindow: NSWindow?
278
+    private weak var aiCompanionTranscriptTextView: NSTextView?
273
     private var aiCompanionAudioPlayer: AVPlayer?
279
     private var aiCompanionAudioPlayer: AVPlayer?
274
     private var aiCompanionCurrentlyPlayingURL: URL?
280
     private var aiCompanionCurrentlyPlayingURL: URL?
275
     private weak var aiCompanionCurrentlyPlayingButton: NSButton?
281
     private weak var aiCompanionCurrentlyPlayingButton: NSButton?
@@ -314,6 +320,7 @@ final class ViewController: NSViewController {
314
     private var mainContentHostTopToPanelConstraint: NSLayoutConstraint?
320
     private var mainContentHostTopToPanelConstraint: NSLayoutConstraint?
315
     private let googleOAuth = GoogleOAuthService.shared
321
     private let googleOAuth = GoogleOAuthService.shared
316
     private let calendarClient = GoogleCalendarClient()
322
     private let calendarClient = GoogleCalendarClient()
323
+    private let googleMeetClient = GoogleMeetClient()
317
     private let storeKitCoordinator = StoreKitCoordinator()
324
     private let storeKitCoordinator = StoreKitCoordinator()
318
     private var storeKitStartupTask: Task<Void, Never>?
325
     private var storeKitStartupTask: Task<Void, Never>?
319
     private var paywallPurchaseTask: Task<Void, Never>?
326
     private var paywallPurchaseTask: Task<Void, Never>?
@@ -1977,6 +1984,13 @@ private extension ViewController {
1977
         aiCompanionAudioURLByView.removeAll()
1984
         aiCompanionAudioURLByView.removeAll()
1978
         aiCompanionAudioStatusLabelByView.removeAll()
1985
         aiCompanionAudioStatusLabelByView.removeAll()
1979
         aiCompanionSpeechTextByView.removeAll()
1986
         aiCompanionSpeechTextByView.removeAll()
1987
+        aiCompanionTranscriptMeetingIdByView.removeAll()
1988
+        aiCompanionTranscriptStatusLabelByView.removeAll()
1989
+        aiCompanionTranscriptTextByMeetingId.removeAll()
1990
+        aiCompanionTranscriptCurrentRequestId = nil
1991
+        aiCompanionTranscriptWindow?.close()
1992
+        aiCompanionTranscriptWindow = nil
1993
+        aiCompanionTranscriptTextView = nil
1980
 
1994
 
1981
         let panel = NSView()
1995
         let panel = NSView()
1982
         panel.translatesAutoresizingMaskIntoConstraints = false
1996
         panel.translatesAutoresizingMaskIntoConstraints = false
@@ -2012,7 +2026,7 @@ private extension ViewController {
2012
         titleLabel.alignment = .left
2026
         titleLabel.alignment = .left
2013
         contentStack.addArrangedSubview(titleLabel)
2027
         contentStack.addArrangedSubview(titleLabel)
2014
 
2028
 
2015
-        let subtitle = textLabel("Ended meetings with temporary audio links", font: typography.fieldLabel, color: palette.textSecondary)
2029
+        let subtitle = textLabel("Ended meetings with transcripts", font: typography.fieldLabel, color: palette.textSecondary)
2016
         subtitle.alignment = .left
2030
         subtitle.alignment = .left
2017
         contentStack.addArrangedSubview(subtitle)
2031
         contentStack.addArrangedSubview(subtitle)
2018
         contentStack.setCustomSpacing(14, after: subtitle)
2032
         contentStack.setCustomSpacing(14, after: subtitle)
@@ -2026,7 +2040,7 @@ private extension ViewController {
2026
 
2040
 
2027
         if endedMeetings.isEmpty {
2041
         if endedMeetings.isEmpty {
2028
             let emptyLabel = textLabel(
2042
             let emptyLabel = textLabel(
2029
-                "No ended meetings yet. Audio items will appear here after meetings end.",
2043
+                "No ended meetings yet. Transcript items will appear here after meetings end.",
2030
                 font: typography.fieldLabel,
2044
                 font: typography.fieldLabel,
2031
                 color: palette.textMuted
2045
                 color: palette.textMuted
2032
             )
2046
             )
@@ -2116,11 +2130,30 @@ private extension ViewController {
2116
         audioStatusLabel.lineBreakMode = .byTruncatingTail
2130
         audioStatusLabel.lineBreakMode = .byTruncatingTail
2117
         aiCompanionAudioStatusLabelByView[ObjectIdentifier(audioButton)] = audioStatusLabel
2131
         aiCompanionAudioStatusLabelByView[ObjectIdentifier(audioButton)] = audioStatusLabel
2118
 
2132
 
2133
+        let transcriptButton = NSButton(title: "View transcript", target: self, action: #selector(aiCompanionTranscriptTapped(_:)))
2134
+        transcriptButton.translatesAutoresizingMaskIntoConstraints = false
2135
+        transcriptButton.isBordered = false
2136
+        transcriptButton.bezelStyle = .inline
2137
+        transcriptButton.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
2138
+        transcriptButton.contentTintColor = palette.primaryBlue
2139
+        transcriptButton.alignment = .left
2140
+        transcriptButton.setButtonType(.momentaryPushIn)
2141
+
2142
+        aiCompanionTranscriptMeetingIdByView[ObjectIdentifier(transcriptButton)] = meeting.id
2143
+
2144
+        let transcriptStatusLabel = textLabel("Transcript not loaded", font: typography.fieldLabel, color: palette.textMuted)
2145
+        transcriptStatusLabel.alignment = .left
2146
+        transcriptStatusLabel.maximumNumberOfLines = 2
2147
+        transcriptStatusLabel.lineBreakMode = .byTruncatingTail
2148
+        aiCompanionTranscriptStatusLabelByView[ObjectIdentifier(transcriptButton)] = transcriptStatusLabel
2149
+
2119
         stack.addArrangedSubview(title)
2150
         stack.addArrangedSubview(title)
2120
         stack.addArrangedSubview(dateLabel)
2151
         stack.addArrangedSubview(dateLabel)
2121
         stack.addArrangedSubview(audioButton)
2152
         stack.addArrangedSubview(audioButton)
2122
         stack.addArrangedSubview(audioURLLabel)
2153
         stack.addArrangedSubview(audioURLLabel)
2123
         stack.addArrangedSubview(audioStatusLabel)
2154
         stack.addArrangedSubview(audioStatusLabel)
2155
+        stack.addArrangedSubview(transcriptButton)
2156
+        stack.addArrangedSubview(transcriptStatusLabel)
2124
 
2157
 
2125
         card.addSubview(stack)
2158
         card.addSubview(stack)
2126
         NSLayoutConstraint.activate([
2159
         NSLayoutConstraint.activate([
@@ -2305,6 +2338,48 @@ private extension ViewController {
2305
         }.resume()
2338
         }.resume()
2306
     }
2339
     }
2307
 
2340
 
2341
+    @objc private func aiCompanionTranscriptTapped(_ sender: NSButton) {
2342
+        let senderId = ObjectIdentifier(sender)
2343
+        guard let meetingId = aiCompanionTranscriptMeetingIdByView[senderId] else { return }
2344
+        guard let meeting = scheduleCachedMeetings.first(where: { $0.id == meetingId }) else { return }
2345
+
2346
+        if let cached = aiCompanionTranscriptTextByMeetingId[meetingId] {
2347
+            aiCompanionPresentTranscriptWindow(meetingTitle: meeting.title, initialText: cached)
2348
+            aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript ready"
2349
+            return
2350
+        }
2351
+
2352
+        aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Loading transcript..."
2353
+
2354
+        let requestId = UUID()
2355
+        aiCompanionTranscriptCurrentRequestId = requestId
2356
+
2357
+        aiCompanionPresentTranscriptWindow(meetingTitle: meeting.title, initialText: "Loading transcript...")
2358
+
2359
+        Task { [weak self] in
2360
+            guard let self else { return }
2361
+
2362
+            do {
2363
+                let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
2364
+                let text = try await self.aiCompanionFetchTranscriptText(for: meeting, accessToken: token)
2365
+
2366
+                await MainActor.run {
2367
+                    guard self.aiCompanionTranscriptCurrentRequestId == requestId else { return } // stale request
2368
+                    self.aiCompanionTranscriptTextByMeetingId[meetingId] = text
2369
+                    self.aiCompanionTranscriptTextView?.string = text
2370
+                    self.aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript ready"
2371
+                }
2372
+            } catch {
2373
+                await MainActor.run {
2374
+                    guard self.aiCompanionTranscriptCurrentRequestId == requestId else { return }
2375
+                    let msg = error.localizedDescription.isEmpty ? "Failed to load transcript." : error.localizedDescription
2376
+                    self.aiCompanionTranscriptTextView?.string = "Transcript unavailable.\n\n\(msg)"
2377
+                    self.aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript unavailable"
2378
+                }
2379
+            }
2380
+        }
2381
+    }
2382
+
2308
     private func aiCompanionAudioTimeControlObserverResetForFailure() {
2383
     private func aiCompanionAudioTimeControlObserverResetForFailure() {
2309
         aiCompanionAudioPlayer?.pause()
2384
         aiCompanionAudioPlayer?.pause()
2310
         aiCompanionAudioPlayer = nil
2385
         aiCompanionAudioPlayer = nil
@@ -2403,6 +2478,154 @@ private extension ViewController {
2403
         return "https://mock-audio.local/\(slug).mp3"
2478
         return "https://mock-audio.local/\(slug).mp3"
2404
     }
2479
     }
2405
 
2480
 
2481
+    @MainActor
2482
+    private func aiCompanionPresentTranscriptWindow(meetingTitle: String, initialText: String) {
2483
+        if let window = aiCompanionTranscriptWindow, let textView = aiCompanionTranscriptTextView {
2484
+            window.title = "Transcript - \(meetingTitle)"
2485
+            textView.string = initialText
2486
+            window.makeKeyAndOrderFront(nil)
2487
+            NSApp.activate(ignoringOtherApps: true)
2488
+            return
2489
+        }
2490
+
2491
+        let windowWidth: CGFloat = 640
2492
+        let windowHeight: CGFloat = 560
2493
+        let window = NSWindow(
2494
+            contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight),
2495
+            styleMask: [.titled, .closable, .resizable],
2496
+            backing: .buffered,
2497
+            defer: false
2498
+        )
2499
+        window.isReleasedWhenClosed = false
2500
+        window.title = "Transcript - \(meetingTitle)"
2501
+        window.center()
2502
+
2503
+        let root = NSView()
2504
+        root.translatesAutoresizingMaskIntoConstraints = false
2505
+
2506
+        let scroll = NSScrollView()
2507
+        scroll.translatesAutoresizingMaskIntoConstraints = false
2508
+        scroll.drawsBackground = false
2509
+        scroll.hasVerticalScroller = true
2510
+
2511
+        let textView = NSTextView()
2512
+        textView.isEditable = false
2513
+        textView.isSelectable = true
2514
+        textView.backgroundColor = .clear
2515
+        textView.font = NSFont.systemFont(ofSize: 13, weight: .regular)
2516
+        textView.string = initialText
2517
+        textView.textContainer?.widthTracksTextView = true
2518
+
2519
+        scroll.documentView = textView
2520
+        root.addSubview(scroll)
2521
+
2522
+        NSLayoutConstraint.activate([
2523
+            scroll.leadingAnchor.constraint(equalTo: root.leadingAnchor),
2524
+            scroll.trailingAnchor.constraint(equalTo: root.trailingAnchor),
2525
+            scroll.topAnchor.constraint(equalTo: root.topAnchor),
2526
+            scroll.bottomAnchor.constraint(equalTo: root.bottomAnchor)
2527
+        ])
2528
+
2529
+        window.contentView = root
2530
+        window.makeKeyAndOrderFront(nil)
2531
+        NSApp.activate(ignoringOtherApps: true)
2532
+
2533
+        aiCompanionTranscriptWindow = window
2534
+        aiCompanionTranscriptTextView = textView
2535
+    }
2536
+
2537
+    private func aiCompanionMeetMeetingCode(from meetURL: URL) -> String? {
2538
+        // Typical: https://meet.google.com/abc-defg-hij
2539
+        guard let host = meetURL.host?.lowercased(),
2540
+              host == "meet.google.com" || host.hasSuffix(".meet.google.com") else { return nil }
2541
+
2542
+        let codeCandidate = meetURL.pathComponents.filter { !$0.isEmpty }.last
2543
+        guard let codeCandidate else { return nil }
2544
+
2545
+        // Allow flexible token shapes; Meet codes are usually 3 hyphen-separated chunks.
2546
+        let cleaned = codeCandidate.trimmingCharacters(in: .whitespacesAndNewlines)
2547
+        let parts = cleaned.split(separator: "-")
2548
+        guard parts.count >= 3 else { return nil }
2549
+
2550
+        return cleaned
2551
+    }
2552
+
2553
+    private func aiCompanionSelectConferenceRecord(for meeting: ScheduledMeeting, from records: [ConferenceRecord]) -> ConferenceRecord? {
2554
+        // Prefer a record whose time window overlaps the calendar meeting.
2555
+        let overlapping = records.filter { r in
2556
+            guard let start = r.startTime, let end = r.endTime else { return false }
2557
+            return start <= meeting.endDate && end >= meeting.startDate
2558
+        }
2559
+        if let best = overlapping.sorted(by: { ($0.endTime ?? .distantPast) > ($1.endTime ?? .distantPast) }).first {
2560
+            return best
2561
+        }
2562
+        // Fallback: choose the most recent one we can.
2563
+        return records.sorted(by: { ($0.startTime ?? .distantPast) > ($1.startTime ?? .distantPast) }).first
2564
+    }
2565
+
2566
+    private func aiCompanionSelectTranscript(for meeting: ScheduledMeeting, from transcripts: [Transcript]) -> Transcript? {
2567
+        let overlapping = transcripts.filter { t in
2568
+            guard let start = t.startTime, let end = t.endTime else { return false }
2569
+            return start <= meeting.endDate && end >= meeting.startDate
2570
+        }
2571
+        if let best = overlapping.sorted(by: { ($0.endTime ?? .distantPast) > ($1.endTime ?? .distantPast) }).first {
2572
+            return best
2573
+        }
2574
+        return transcripts.sorted(by: { ($0.startTime ?? .distantPast) > ($1.startTime ?? .distantPast) }).first
2575
+    }
2576
+
2577
+    private func aiCompanionFormatTranscriptText(entries: [TranscriptEntry]) -> String {
2578
+        let lines = entries.compactMap { entry -> String? in
2579
+            guard let raw = entry.text else { return nil }
2580
+            let t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
2581
+            guard t.isEmpty == false else { return nil }
2582
+
2583
+            let speaker = entry.participant?.trimmingCharacters(in: .whitespacesAndNewlines)
2584
+            guard let speaker, speaker.isEmpty == false else { return t }
2585
+            return "\(speaker): \(t)"
2586
+        }
2587
+
2588
+        if lines.isEmpty { return "(No transcript entries found.)" }
2589
+        return lines.joined(separator: "\n")
2590
+    }
2591
+
2592
+    private func aiCompanionFetchTranscriptText(for meeting: ScheduledMeeting, accessToken: String) async throws -> String {
2593
+        guard let meetingCode = aiCompanionMeetMeetingCode(from: meeting.meetURL) else {
2594
+            throw NSError(
2595
+                domain: "AiCompanionTranscript",
2596
+                code: 1,
2597
+                userInfo: [NSLocalizedDescriptionKey: "Couldn't determine Meet meeting code from URL."]
2598
+            )
2599
+        }
2600
+
2601
+        // Resolve `spaces/{meetingCode}` into a stable space resource name (`spaces/{spaceId}`).
2602
+        let space = try await googleMeetClient.getSpace(accessToken: accessToken, spaceNameOrMeetingCode: "spaces/\(meetingCode)")
2603
+        let spaceName = space.name ?? "spaces/\(meetingCode)"
2604
+
2605
+        let records = try await googleMeetClient.listConferenceRecords(accessToken: accessToken, spaceResourceName: spaceName)
2606
+        guard let conferenceRecord = aiCompanionSelectConferenceRecord(for: meeting, from: records),
2607
+              let conferenceRecordName = conferenceRecord.name else {
2608
+            throw NSError(
2609
+                domain: "AiCompanionTranscript",
2610
+                code: 2,
2611
+                userInfo: [NSLocalizedDescriptionKey: "No conference record found for this meeting."]
2612
+            )
2613
+        }
2614
+
2615
+        let transcripts = try await googleMeetClient.listTranscripts(accessToken: accessToken, conferenceRecordName: conferenceRecordName)
2616
+        guard let transcript = aiCompanionSelectTranscript(for: meeting, from: transcripts),
2617
+              let transcriptName = transcript.name else {
2618
+            throw NSError(
2619
+                domain: "AiCompanionTranscript",
2620
+                code: 3,
2621
+                userInfo: [NSLocalizedDescriptionKey: "No transcript found for this meeting."]
2622
+            )
2623
+        }
2624
+
2625
+        let entries = try await googleMeetClient.listTranscriptEntries(accessToken: accessToken, transcriptName: transcriptName)
2626
+        return aiCompanionFormatTranscriptText(entries: entries)
2627
+    }
2628
+
2406
     private func makePlaceholderPage(title: String, subtitle: String) -> NSView {
2629
     private func makePlaceholderPage(title: String, subtitle: String) -> NSView {
2407
         let panel = NSView()
2630
         let panel = NSView()
2408
         panel.translatesAutoresizingMaskIntoConstraints = false
2631
         panel.translatesAutoresizingMaskIntoConstraints = false