Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad995b59ad |
@@ -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;
|
||||
|
||||
@@ -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
|
||||
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
|
||||
])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// - Returns: True if API key exists, false otherwise
|
||||
func isConfigured() -> Bool {
|
||||
|
||||
@@ -12,7 +12,7 @@ extension Notification.Name {
|
||||
enum MeetingViewTab: String, CaseIterable {
|
||||
case myNotes = "My Notes"
|
||||
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
|
||||
if !hasError {
|
||||
await generateTitleIfNeeded()
|
||||
saveMeeting()
|
||||
}
|
||||
|
||||
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() {
|
||||
if isDeleted { return }
|
||||
print("💾 Saving meeting: \(meeting.id)")
|
||||
|
||||
@@ -327,7 +327,7 @@ struct MeetingDetailContentView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.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
|
||||
Button(action: {
|
||||
@@ -374,7 +374,7 @@ struct MeetingDetailContentView: View {
|
||||
|
||||
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 {
|
||||
Button(action: {
|
||||
isEditing.toggle()
|
||||
|
||||
Reference in New Issue
Block a user