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