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