From ad995b59aded142ea3927374826065432094bfaa Mon Sep 17 00:00:00 2001 From: superdooper86 Date: Fri, 17 Jul 2026 12:15:04 +0200 Subject: [PATCH] feat: generate meeting titles automatically --- Meetingnotes.xcodeproj/project.pbxproj | 8 +- meetingnotes/Services/CoderAPIClient.swift | 8 +- meetingnotes/Services/LocalAPIServer.swift | 19 +++++ meetingnotes/Services/NotesGenerator.swift | 84 ++++++++++++++++++- .../ViewModels/MeetingViewModel.swift | 20 ++++- meetingnotes/Views/MeetingListView.swift | 4 +- 6 files changed, 133 insertions(+), 10 deletions(-) diff --git a/Meetingnotes.xcodeproj/project.pbxproj b/Meetingnotes.xcodeproj/project.pbxproj index a361152..0932896 100644 --- a/Meetingnotes.xcodeproj/project.pbxproj +++ b/Meetingnotes.xcodeproj/project.pbxproj @@ -276,7 +276,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_TEAM = G9LVHZAJNX; ENABLE_HARDENED_RUNTIME = YES; @@ -290,7 +290,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.1.9; + MARKETING_VERSION = 1.1.10; ONLY_ACTIVE_ARCH = NO; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; @@ -312,7 +312,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_TEAM = G9LVHZAJNX; ENABLE_HARDENED_RUNTIME = YES; @@ -326,7 +326,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.1.9; + MARKETING_VERSION = 1.1.10; ONLY_ACTIVE_ARCH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; diff --git a/meetingnotes/Services/CoderAPIClient.swift b/meetingnotes/Services/CoderAPIClient.swift index 9ea94fa..b0e4c4d 100644 --- a/meetingnotes/Services/CoderAPIClient.swift +++ b/meetingnotes/Services/CoderAPIClient.swift @@ -97,7 +97,11 @@ final class CoderAPIClient { ) } - func streamChat(systemPrompt: String, model: String) -> AsyncThrowingStream { + func streamChat( + systemPrompt: String, + userPrompt: String = "Create the meeting notes now.", + model: String + ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in Task { do { @@ -113,7 +117,7 @@ final class CoderAPIClient { "model": selectedModel, "messages": [ ["role": "system", "content": systemPrompt], - ["role": "user", "content": "Create the meeting notes now."] + ["role": "user", "content": userPrompt] ], "stream": true ]) diff --git a/meetingnotes/Services/LocalAPIServer.swift b/meetingnotes/Services/LocalAPIServer.swift index 222c4ab..c7c33d2 100644 --- a/meetingnotes/Services/LocalAPIServer.swift +++ b/meetingnotes/Services/LocalAPIServer.swift @@ -309,8 +309,27 @@ private final class LocalRecordingController { generatedNotes = "" } } + var meetingChanged = false if !generatedNotes.isEmpty { meeting.generatedNotes = generatedNotes + meetingChanged = true + } + if meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + do { + if let title = try await NotesGenerator.shared.generateMeetingTitle( + meeting: meeting, + generatedNotes: generatedNotes, + templateId: meeting.templateId + ) { + meeting.title = title + meetingChanged = true + } + } catch { + // The recording and generated notes remain valid without a generated title. + print("Meeting title generation failed: \(error)") + } + } + if meetingChanged { _ = LocalStorageManager.shared.saveMeeting(meeting) NotificationCenter.default.post(name: .meetingSaved, object: meeting) } diff --git a/meetingnotes/Services/NotesGenerator.swift b/meetingnotes/Services/NotesGenerator.swift index 978e03e..0dfdd1c 100644 --- a/meetingnotes/Services/NotesGenerator.swift +++ b/meetingnotes/Services/NotesGenerator.swift @@ -105,6 +105,88 @@ class NotesGenerator { } } } + + /// Generates a short title after meeting notes have been created. + func generateMeetingTitle( + meeting: Meeting, + generatedNotes: String, + templateId: UUID? + ) async throws -> String? { + let templates = LocalStorageManager.shared.loadTemplates() + let template = templateId.flatMap { id in + templates.first(where: { $0.id == id }) + } + let meetingContext: String + if let template { + meetingContext = "\(template.title): \(template.context)" + } else { + meetingContext = "General meeting" + } + + let trimmedNotes = generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines) + let meetingContent = trimmedNotes.isEmpty ? meeting.formattedTranscript : trimmedNotes + guard !meetingContent.isEmpty else { return nil } + + let systemPrompt = """ + Create a concise, descriptive title for a recorded meeting from its context and content. + Return only the title in 3 to 8 words. Do not use quotation marks, Markdown, or a trailing period. + Avoid generic titles such as Meeting, Discussion, Meeting Notes, or Untitled Meeting. + """ + let userPrompt = """ + Meeting context: + \(meetingContext) + + Meeting content: + \(meetingContent) + """ + + var generatedTitle = "" + let stream = CoderAPIClient.shared.streamChat( + systemPrompt: systemPrompt, + userPrompt: userPrompt, + model: UserDefaultsManager.shared.notesModel + ) + for try await content in stream { + generatedTitle += content + } + + return normalizedTitle(generatedTitle) + } + + private func normalizedTitle(_ value: String) -> String? { + guard var title = value + .split(whereSeparator: \Character.isNewline) + .map(String.init) + .first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })? + .trimmingCharacters(in: .whitespacesAndNewlines) else { + return nil + } + + while title.hasPrefix("#") { + title.removeFirst() + title = title.trimmingCharacters(in: .whitespaces) + } + if title.lowercased().hasPrefix("title:") { + title = String(title.dropFirst("title:".count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + title = title.trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + if title.hasSuffix(".") { + title.removeLast() + } + + if title.count > 80 { + let prefix = String(title.prefix(80)) + if let lastSpace = prefix.lastIndex(of: " ") { + title = String(prefix[..