Compare commits

...
1 Commits
Author SHA1 Message Date
james ad995b59ad feat: generate meeting titles automatically 2026-07-17 12:15:04 +02:00
6 changed files with 133 additions and 10 deletions
+4 -4
View File
@@ -276,7 +276,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 21; CURRENT_PROJECT_VERSION = 22;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX; DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -290,7 +290,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.9; MARKETING_VERSION = 1.1.10;
ONLY_ACTIVE_ARCH = NO; ONLY_ACTIVE_ARCH = NO;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
@@ -312,7 +312,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 21; CURRENT_PROJECT_VERSION = 22;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX; DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -326,7 +326,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.9; MARKETING_VERSION = 1.1.10;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
+6 -2
View File
@@ -97,7 +97,11 @@ final class CoderAPIClient {
) )
} }
func streamChat(systemPrompt: String, model: String) -> AsyncThrowingStream<String, Error> { func streamChat(
systemPrompt: String,
userPrompt: String = "Create the meeting notes now.",
model: String
) -> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in AsyncThrowingStream { continuation in
Task { Task {
do { do {
@@ -113,7 +117,7 @@ final class CoderAPIClient {
"model": selectedModel, "model": selectedModel,
"messages": [ "messages": [
["role": "system", "content": systemPrompt], ["role": "system", "content": systemPrompt],
["role": "user", "content": "Create the meeting notes now."] ["role": "user", "content": userPrompt]
], ],
"stream": true "stream": true
]) ])
@@ -309,8 +309,27 @@ private final class LocalRecordingController {
generatedNotes = "" generatedNotes = ""
} }
} }
var meetingChanged = false
if !generatedNotes.isEmpty { if !generatedNotes.isEmpty {
meeting.generatedNotes = generatedNotes 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) _ = LocalStorageManager.shared.saveMeeting(meeting)
NotificationCenter.default.post(name: .meetingSaved, object: meeting) NotificationCenter.default.post(name: .meetingSaved, object: meeting)
} }
@@ -106,6 +106,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[..<lastSpace])
} else {
title = prefix
}
}
title = title.trimmingCharacters(in: .whitespacesAndNewlines)
return title.isEmpty ? nil : title
}
/// Validates if the Coder service token is configured /// Validates if the Coder service token is configured
/// - Returns: True if API key exists, false otherwise /// - Returns: True if API key exists, false otherwise
func isConfigured() -> Bool { func isConfigured() -> Bool {
+19 -1
View File
@@ -12,7 +12,7 @@ extension Notification.Name {
enum MeetingViewTab: String, CaseIterable { enum MeetingViewTab: String, CaseIterable {
case myNotes = "My Notes" case myNotes = "My Notes"
case transcript = "Transcript" case transcript = "Transcript"
case enhancedNotes = "Enhanced Notes" case enhancedNotes = "Meeting Notes"
} }
@@ -260,12 +260,30 @@ class MeetingViewModel: ObservableObject {
// Only save if there was no error // Only save if there was no error
if !hasError { if !hasError {
await generateTitleIfNeeded()
saveMeeting() saveMeeting()
} }
isGeneratingNotes = false isGeneratingNotes = false
} }
private func generateTitleIfNeeded() async {
guard meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
do {
if let title = try await NotesGenerator.shared.generateMeetingTitle(
meeting: meeting,
generatedNotes: meeting.generatedNotes,
templateId: selectedTemplateId
) {
meeting.title = title
}
} catch {
// A title is helpful but should never prevent completed notes from being saved.
print("Meeting title generation failed: \(error)")
}
}
func saveMeeting() { func saveMeeting() {
if isDeleted { return } if isDeleted { return }
print("💾 Saving meeting: \(meeting.id)") print("💾 Saving meeting: \(meeting.id)")
+2 -2
View File
@@ -327,7 +327,7 @@ struct MeetingDetailContentView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(viewModel.meeting.transcript.isEmpty || viewModel.isGeneratingNotes || viewModel.isRecording || viewModel.isProcessing || viewModel.isStartingRecording) .disabled(viewModel.meeting.transcript.isEmpty || viewModel.isGeneratingNotes || viewModel.isRecording || viewModel.isProcessing || viewModel.isStartingRecording)
.help("Generate enhanced notes using a template") .help("Generate meeting notes using a template")
// Recording Button // Recording Button
Button(action: { Button(action: {
@@ -374,7 +374,7 @@ struct MeetingDetailContentView: View {
Spacer() Spacer()
// Edit/Preview button (for My Notes and Enhanced Notes) // Edit/Preview button (for My Notes and Meeting Notes)
if viewModel.selectedTab == .myNotes || viewModel.selectedTab == .enhancedNotes { if viewModel.selectedTab == .myNotes || viewModel.selectedTab == .enhancedNotes {
Button(action: { Button(action: {
isEditing.toggle() isEditing.toggle()