c539b78a58
Takeaways categories are no longer hardcoded — they're editable templates. A
template = the always-on TLDR + an ordered list of sections, each with a title, a
type (attributed items / bulleted list / paragraph), and an instruction (the prompt
text for that category). The analyzer assembles the LLM prompt FROM the template
and parses generically, so adding/removing/renaming a category needs zero code and
the output always renders.
- RecapTemplate / TemplateSection / SectionKind + TopicGranularity; built-in
defaults (Internal Meeting, 1:1, Company/Sales Call), all editable.
- Generic extras: RecapExtras{tldr, primarySpeakers, sections:[RenderedSection]} +
RecapItem{text,who,when,note} replaces the fixed MeetingExtras. Analyzer builds
per-section sec_N fields + parses by kind; renderer + remap are generic.
- Topic granularity (coarse/auto/fine) answers 'should chunking be configurable' —
it scales the target topic count; raw window sizes stay as tuned defaults.
- AppSettings persists templates + defaultTemplateId (seeded once). Settings gets a
default-template picker + 'Manage…' → TemplatesView (CRUD, edit sections/
instructions, set default, **Preview prompt** for full transparency).
- Recap editor gains a template picker; Regenerate uses the chosen template. Auto
recap uses the default template.
54/54 XCTest (template prompt build, generic parse/remap/render updated).
178 lines
8.8 KiB
Swift
178 lines
8.8 KiB
Swift
import SwiftUI
|
|
|
|
/// Manage recap templates: the takeaways categories per meeting type. Edit titles,
|
|
/// types, and the per-category instructions that drive the LLM — and preview the
|
|
/// exact prompt that gets sent, so nothing is hidden in code.
|
|
struct TemplatesView: View {
|
|
@ObservedObject var settings: AppSettings
|
|
@State private var selectedId: String?
|
|
@State private var previewText: String?
|
|
|
|
var body: some View {
|
|
HSplitView {
|
|
sidebar.frame(minWidth: 200, maxWidth: 260)
|
|
detail.frame(minWidth: 420)
|
|
}
|
|
.frame(minWidth: 720, minHeight: 520)
|
|
.onAppear { if selectedId == nil { selectedId = settings.defaultTemplateId } }
|
|
.sheet(item: Binding(get: { previewText.map { IdString($0) } }, set: { previewText = $0?.value })) { item in
|
|
previewSheet(item.value)
|
|
}
|
|
}
|
|
|
|
// MARK: - Sidebar (template list + default picker)
|
|
|
|
private var sidebar: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
List(selection: $selectedId) {
|
|
ForEach(settings.recapTemplates) { t in
|
|
HStack {
|
|
Text(t.name)
|
|
if t.id == settings.defaultTemplateId {
|
|
Spacer(); Image(systemName: "star.fill").foregroundStyle(.yellow).font(.caption)
|
|
}
|
|
}.tag(t.id)
|
|
}
|
|
}
|
|
Divider()
|
|
HStack(spacing: 6) {
|
|
Button { addTemplate() } label: { Image(systemName: "plus") }
|
|
Button { duplicateSelected() } label: { Image(systemName: "plus.square.on.square") }
|
|
.disabled(selected == nil)
|
|
Button { deleteSelected() } label: { Image(systemName: "trash") }
|
|
.disabled(selected == nil || settings.recapTemplates.count <= 1)
|
|
Spacer()
|
|
}
|
|
.buttonStyle(.borderless).padding(8)
|
|
}
|
|
}
|
|
|
|
// MARK: - Detail (edit selected template)
|
|
|
|
private var detail: some View {
|
|
Group {
|
|
if let binding = selectedBinding {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack {
|
|
TextField("Template name", text: binding.name).textFieldStyle(.roundedBorder)
|
|
if binding.wrappedValue.id != settings.defaultTemplateId {
|
|
Button("Make default") { settings.defaultTemplateId = binding.wrappedValue.id }
|
|
} else {
|
|
Label("Default", systemImage: "star.fill").foregroundStyle(.secondary).font(.caption)
|
|
}
|
|
}
|
|
HStack(spacing: 16) {
|
|
Toggle("Include summary (TLDR)", isOn: binding.includeTLDR)
|
|
Picker("Topic detail", selection: binding.topicGranularity) {
|
|
ForEach(TopicGranularity.allCases) { Text($0.label).tag($0) }
|
|
}.frame(maxWidth: 240)
|
|
}
|
|
Divider()
|
|
Text("Takeaways sections").font(.headline)
|
|
Text("Each becomes a section of the recap. The instruction is the prompt text telling the model what to extract.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
sectionsEditor(binding)
|
|
Button { addSection(binding) } label: { Label("Add section", systemImage: "plus") }
|
|
Divider()
|
|
Button("Preview prompt…") { previewText = buildPreview(binding.wrappedValue) }
|
|
}
|
|
.padding(16)
|
|
}
|
|
} else {
|
|
Text("Select a template").foregroundStyle(.secondary).frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sectionsEditor(_ template: Binding<RecapTemplate>) -> some View {
|
|
VStack(spacing: 10) {
|
|
ForEach(Array(template.sections.enumerated()), id: \.element.id) { idx, section in
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
TextField("Section title", text: section.title).textFieldStyle(.roundedBorder)
|
|
Picker("", selection: section.kind) {
|
|
ForEach(SectionKind.allCases) { Text($0.label).tag($0) }
|
|
}.labelsHidden().frame(width: 150)
|
|
Button { move(template, idx, by: -1) } label: { Image(systemName: "arrow.up") }
|
|
.disabled(idx == 0).buttonStyle(.borderless)
|
|
Button { move(template, idx, by: 1) } label: { Image(systemName: "arrow.down") }
|
|
.disabled(idx == template.wrappedValue.sections.count - 1).buttonStyle(.borderless)
|
|
Button { removeSection(template, idx) } label: { Image(systemName: "trash") }
|
|
.buttonStyle(.borderless).foregroundStyle(.red)
|
|
}
|
|
TextEditor(text: section.instruction)
|
|
.font(.callout).frame(minHeight: 48, maxHeight: 90)
|
|
.overlay(RoundedRectangle(cornerRadius: 5).stroke(.quaternary))
|
|
}
|
|
.padding(10).background(Color(nsColor: .controlBackgroundColor)).cornerRadius(8)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Mutations
|
|
|
|
private var selected: RecapTemplate? { settings.recapTemplates.first { $0.id == selectedId } }
|
|
private var selectedBinding: Binding<RecapTemplate>? {
|
|
guard let id = selectedId, let i = settings.recapTemplates.firstIndex(where: { $0.id == id }) else { return nil }
|
|
return Binding(get: { settings.recapTemplates[i] }, set: { settings.recapTemplates[i] = $0 })
|
|
}
|
|
|
|
private func addTemplate() {
|
|
let t = RecapTemplate(name: "New Template", sections: [
|
|
.init(title: "Key Takeaways", kind: .bullets, instruction: "The main points from the conversation.")
|
|
])
|
|
settings.recapTemplates.append(t); selectedId = t.id
|
|
}
|
|
private func duplicateSelected() {
|
|
guard let s = selected else { return }
|
|
var copy = s; copy.id = UUID().uuidString; copy.name = s.name + " copy"
|
|
copy.sections = s.sections.map { var c = $0; c.id = UUID().uuidString; return c }
|
|
settings.recapTemplates.append(copy); selectedId = copy.id
|
|
}
|
|
private func deleteSelected() {
|
|
guard let id = selectedId else { return }
|
|
settings.recapTemplates.removeAll { $0.id == id }
|
|
if settings.defaultTemplateId == id { settings.defaultTemplateId = settings.recapTemplates.first?.id ?? "" }
|
|
selectedId = settings.recapTemplates.first?.id
|
|
}
|
|
private func addSection(_ t: Binding<RecapTemplate>) {
|
|
t.wrappedValue.sections.append(.init(title: "New Section", kind: .bullets, instruction: ""))
|
|
}
|
|
private func removeSection(_ t: Binding<RecapTemplate>, _ idx: Int) {
|
|
guard t.wrappedValue.sections.indices.contains(idx) else { return }
|
|
t.wrappedValue.sections.remove(at: idx)
|
|
}
|
|
private func move(_ t: Binding<RecapTemplate>, _ idx: Int, by delta: Int) {
|
|
let j = idx + delta
|
|
guard t.wrappedValue.sections.indices.contains(idx), t.wrappedValue.sections.indices.contains(j) else { return }
|
|
t.wrappedValue.sections.swapAt(idx, j)
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
private func buildPreview(_ template: RecapTemplate) -> String {
|
|
let sample = SpeakersFile(sessionId: "sample", app: "meet", durationSec: 600,
|
|
speakers: [], segments: [
|
|
.init(start: 5, end: 9, speaker: "Grant", text: "Let's review the roadmap."),
|
|
.init(start: 10, end: 16, speaker: "Caitlyn", text: "I think we should ship dual-channel first."),
|
|
], models: [:])
|
|
let entries = RecapAnalyzer.entries(from: sample)
|
|
return RecapAnalyzer.extrasPrompt(file: sample, entries: entries, sections: [], template: template)
|
|
}
|
|
|
|
private func previewSheet(_ text: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Prompt sent to the model (with sample transcript)").font(.headline)
|
|
ScrollView { Text(text).font(.system(.caption, design: .monospaced)).textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading) }
|
|
.frame(width: 560, height: 380)
|
|
.overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary))
|
|
HStack { Spacer(); Button("Done") { previewText = nil }.keyboardShortcut(.defaultAction) }
|
|
}
|
|
.padding(16)
|
|
}
|
|
}
|
|
|
|
private struct IdString: Identifiable { let value: String; var id: String { value }; init(_ v: String) { value = v } }
|