feat(phase9): History LLM tag UI — per-row Tag, chips, promote, batch

Per-row Tag button calls extract_content_tags_cmd, persists via
saveTranscriptMeta. Dashed-italic chips render the AI tags distinct
from manual; clicking a chip promotes it into manualTags (the
LLM tag disappears, the manual one stays). Top toolbar gains "Tag all
untagged" for batch tagging across the corpus, with progress text.
Existing addManualTag / removeManualTag handlers swap their no-op
saveHistory() calls for saveTranscriptMeta — picks up the latent
manualTags persistence bug as a side effect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 00:13:55 +01:00
parent 7eb52d97b1
commit 7fc971df05

View File

@@ -2,7 +2,13 @@
// @ts-nocheck
import { onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { history, saveHistory, deleteFromHistory, renameHistoryEntry } from "$lib/stores/page.svelte.js";
import {
history,
saveHistory,
saveTranscriptMeta,
deleteFromHistory,
renameHistoryEntry,
} from "$lib/stores/page.svelte.js";
import { toasts } from "$lib/stores/toasts.svelte.js";
import { convertFileSrc } from "@tauri-apps/api/core";
import {
@@ -20,7 +26,7 @@
import EmptyState from "$lib/components/EmptyState.svelte";
import { formatTime, formatDuration } from "$lib/utils/time.js";
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Star } from 'lucide-svelte';
import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Star, Tag } from 'lucide-svelte';
const prefs = getPreferences();
const COLLAPSED_ROW_MIN_HEIGHT = 54;
@@ -337,14 +343,79 @@
return;
}
item.manualTags = [...(item.manualTags || []), next];
saveHistory();
saveTranscriptMeta(item.id, { manualTags: item.manualTags });
e.target.value = "";
}
function removeManualTag(item, tag) {
const t = normaliseTag(tag);
item.manualTags = (item.manualTags || []).filter((x) => normaliseTag(x) !== t);
saveHistory();
saveTranscriptMeta(item.id, { manualTags: item.manualTags });
}
// Phase 9 LLM content tagging.
let tagging = $state(new Set());
let bulkTagging = $state(false);
let bulkTaggingProgress = $state("");
async function tagRow(item) {
if (tagging.has(item.id)) return;
const next = new Set(tagging);
next.add(item.id);
tagging = next;
try {
const result = await invoke("extract_content_tags_cmd", {
transcript: item.text || "",
});
const r = result;
item.llmTags = [`topic:${r.topic}`, `intent:${r.intent}`];
await saveTranscriptMeta(item.id, { llmTags: item.llmTags });
} catch (err) {
toasts.error("Tagging failed", String(err));
} finally {
const rest = new Set(tagging);
rest.delete(item.id);
tagging = rest;
}
}
function promoteLlmTag(item, tag) {
const normalised = normaliseTag(tag);
const manual = new Set((item.manualTags || []).map(normaliseTag));
manual.add(normalised);
item.manualTags = [...manual];
item.llmTags = (item.llmTags || []).filter((t) => normaliseTag(t) !== normalised);
saveTranscriptMeta(item.id, {
manualTags: item.manualTags,
llmTags: item.llmTags,
});
}
async function tagAllUntagged() {
if (bulkTagging) return;
bulkTagging = true;
try {
const untagged = history.filter((i) => !i.llmTags || i.llmTags.length === 0);
let done = 0;
for (const item of untagged) {
done += 1;
bulkTaggingProgress = `${done} / ${untagged.length}`;
try {
const result = await invoke("extract_content_tags_cmd", {
transcript: item.text || "",
});
const r = result;
item.llmTags = [`topic:${r.topic}`, `intent:${r.intent}`];
await saveTranscriptMeta(item.id, { llmTags: item.llmTags });
} catch (err) {
console.error("bulk tag failed for", item.id, err);
}
}
toasts.success(`Tagged ${untagged.length} transcript${untagged.length === 1 ? "" : "s"}`);
} finally {
bulkTagging = false;
bulkTaggingProgress = "";
}
}
async function exportMarkdown(item) {
@@ -459,6 +530,17 @@
<Star size={14} aria-hidden="true" />
<span class="text-[11px]">Starred</span>
</button>
{#if history.some((i) => !i.llmTags || i.llmTags.length === 0)}
<button
class="btn-md rounded-lg text-text-tertiary hover:text-text hover:bg-hover disabled:opacity-50 inline-flex items-center gap-1.5"
onclick={tagAllUntagged}
disabled={bulkTagging}
title="Run AI content tagging across every untagged transcript"
>
<Tag size={13} aria-hidden="true" />
<span class="text-[11px]">{bulkTagging ? `Tagging ${bulkTaggingProgress}` : "Tag all untagged"}</span>
</button>
{/if}
{#if history.length > 0}
<button
class="btn-md rounded-lg text-text-tertiary hover:text-danger hover:bg-hover"
@@ -708,6 +790,17 @@
>×</button>
</span>
{/each}
<!-- Phase 9 LLM-generated tags. Dashed border + italic
distinguishes from manual chips. Click to promote
into manualTags. -->
{#each (item.llmTags || []) as t (t)}
<button
class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] italic border border-dashed border-border-subtle text-text-tertiary hover:text-text hover:border-accent"
onclick={() => promoteLlmTag(item, t)}
title="Click to keep as a manual tag"
aria-label={`Promote ${t} to manual tag`}
>{t}</button>
{/each}
<input
type="text"
class="bg-bg-input border border-border rounded-full px-2 py-0.5 text-[10px]
@@ -743,6 +836,17 @@
onclick={(e) => { e.stopPropagation(); exportMarkdown(item); }}
title="Export this transcript as a Markdown file with YAML frontmatter"
>Export .md</button>
<button
class="inline-flex items-center gap-1.5 text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text disabled:opacity-50"
style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); tagRow(item); }}
disabled={tagging.has(item.id)}
title="Generate AI content tags (topic and intent)"
aria-label="Generate content tags with AI"
>
<Tag size={11} aria-hidden="true" />
{tagging.has(item.id) ? "Tagging…" : "Tag"}
</button>
{#if item.audioPath && item.segments && item.segments.length > 0}
<button
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-accent hover:text-accent-hover"