feat(tags): Generate tags button + chip-based tag editor
Generate tags mirrors Generate summary: reads the transcript (the same build_prompt assembly, non-streamed) and suggests 1-8 tags, merged into the working tag list rather than replacing it outright so a manually-added tag never silently disappears. Replaces the old comma-separated text input with GitHub-topic-style chips: typing a comma commits everything before it as its own chip immediately, each chip is removable via its 'x', and clicking a chip's label filters the meeting list to that tag (meetings.filterByTag).
This commit is contained in:
1 parent
cf4232cbae
commit
16fcb154f3
1 file changed
+126
-19
@@ -4,8 +4,15 @@
|
||||
import { meetings } from "../stores/meetings.svelte";
|
||||
import { calendar } from "../stores/calendar.svelte";
|
||||
import { settings } from "../stores/settings.svelte";
|
||||
import { api, type ActionItem, type CalendarEventDetail, type LlmStatus } from "../api";
|
||||
import {
|
||||
api,
|
||||
errorMessage,
|
||||
type ActionItem,
|
||||
type CalendarEventDetail,
|
||||
type LlmStatus,
|
||||
} from "../api";
|
||||
import { renderMarkdown } from "../markdown";
|
||||
import TagChip from "../components/TagChip.svelte";
|
||||
import {
|
||||
Tags,
|
||||
Sparkles,
|
||||
@@ -150,24 +157,79 @@
|
||||
}
|
||||
|
||||
// ---- Tags (T8.3, FR-SEARCH-2) ----
|
||||
// Writable derived: reflects meetings.selected.tags, but typing (bind:value)
|
||||
// locally overrides it until the selection changes again.
|
||||
let tagsInput = $derived((meetings.selected?.tags ?? []).join(", "));
|
||||
// pendingTags is the editable working copy — resynced from
|
||||
// meetings.selected.tags whenever a *different* meeting is selected, same
|
||||
// guard pattern as notesText in TranscriptNotes.svelte, so it isn't
|
||||
// clobbered by other reactivity while the user is mid-edit.
|
||||
let pendingTags = $state<string[]>([]);
|
||||
let tagDraft = $state("");
|
||||
let loadedTagsForId: string | null = null;
|
||||
$effect(() => {
|
||||
const m = meetings.selected;
|
||||
if (m && m.id !== loadedTagsForId) {
|
||||
pendingTags = [...m.tags];
|
||||
tagDraft = "";
|
||||
loadedTagsForId = m.id;
|
||||
} else if (!m) {
|
||||
loadedTagsForId = null;
|
||||
}
|
||||
});
|
||||
|
||||
function addTag(raw: string) {
|
||||
const t = raw.trim().toLowerCase();
|
||||
if (t && !pendingTags.includes(t)) pendingTags.push(t);
|
||||
}
|
||||
// GitHub-topics-style input: a comma commits everything before it as its
|
||||
// own chip immediately, leaving whatever's after as the live draft.
|
||||
function onTagInput() {
|
||||
if (!tagDraft.includes(",")) return;
|
||||
const parts = tagDraft.split(",");
|
||||
tagDraft = parts.pop() ?? "";
|
||||
parts.forEach(addTag);
|
||||
}
|
||||
function onTagInputKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitDraft();
|
||||
}
|
||||
}
|
||||
function commitDraft() {
|
||||
if (tagDraft.trim()) addTag(tagDraft);
|
||||
tagDraft = "";
|
||||
}
|
||||
function removeTag(t: string) {
|
||||
pendingTags = pendingTags.filter((x) => x !== t);
|
||||
}
|
||||
|
||||
let savingTags = $state(false);
|
||||
async function saveTags() {
|
||||
const m = meetings.selected;
|
||||
if (!m) return;
|
||||
commitDraft();
|
||||
savingTags = true;
|
||||
try {
|
||||
const tags = tagsInput
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
await meetings.setTags(m.id, tags);
|
||||
await meetings.setTags(m.id, pendingTags);
|
||||
} finally {
|
||||
savingTags = false;
|
||||
}
|
||||
}
|
||||
|
||||
let generatingTags = $state(false);
|
||||
let tagGenError = $state<string | null>(null);
|
||||
async function generateTagsNow() {
|
||||
const m = meetings.selected;
|
||||
if (!m) return;
|
||||
generatingTags = true;
|
||||
tagGenError = null;
|
||||
try {
|
||||
const suggested = await api.generateTags(m.id);
|
||||
suggested.forEach(addTag);
|
||||
} catch (e) {
|
||||
tagGenError = errorMessage(e);
|
||||
} finally {
|
||||
generatingTags = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wrap">
|
||||
@@ -214,21 +276,42 @@
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to tag it.</p>
|
||||
{:else}
|
||||
<input
|
||||
class="grow"
|
||||
list="known-tags"
|
||||
placeholder="project, client, topic…"
|
||||
bind:value={tagsInput}
|
||||
onkeydown={(e) => e.key === "Enter" && saveTags()}
|
||||
/>
|
||||
<div class="tag-editor">
|
||||
{#each pendingTags as t (t)}
|
||||
<TagChip
|
||||
tag={t}
|
||||
removable
|
||||
onRemove={() => removeTag(t)}
|
||||
onClick={() => meetings.filterByTag(t)}
|
||||
/>
|
||||
{/each}
|
||||
<input
|
||||
class="tag-input"
|
||||
list="known-tags"
|
||||
placeholder={pendingTags.length ? "Add tag…" : "project, client, topic…"}
|
||||
bind:value={tagDraft}
|
||||
oninput={onTagInput}
|
||||
onkeydown={onTagInputKeydown}
|
||||
onblur={commitDraft}
|
||||
/>
|
||||
</div>
|
||||
<datalist id="known-tags">
|
||||
{#each meetings.allTags as t (t)}
|
||||
<option value={t}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
<button class="link" onclick={saveTags} disabled={savingTags}>
|
||||
{savingTags ? "Saving…" : "Save tags"}
|
||||
</button>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={generateTagsNow} disabled={generatingTags}>
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
{generatingTags ? "Generating…" : "Generate tags"}
|
||||
</button>
|
||||
<button class="link" onclick={saveTags} disabled={savingTags}>
|
||||
{savingTags ? "Saving…" : "Save tags"}
|
||||
</button>
|
||||
</div>
|
||||
{#if tagGenError}
|
||||
<p class="error">{tagGenError}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<h3><Sparkles size={14} aria-hidden="true" /> Summary</h3>
|
||||
@@ -435,6 +518,30 @@
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
.tag-editor {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
.tag-input {
|
||||
flex: 1;
|
||||
min-width: 6rem;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0.2rem 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.tag-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.muted.small {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user