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:
@@ -2,7 +2,13 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { onDestroy } from "svelte";
|
import { onDestroy } from "svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
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 { toasts } from "$lib/stores/toasts.svelte.js";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +26,7 @@
|
|||||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||||
import { formatTime, formatDuration } from "$lib/utils/time.js";
|
import { formatTime, formatDuration } from "$lib/utils/time.js";
|
||||||
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.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 prefs = getPreferences();
|
||||||
const COLLAPSED_ROW_MIN_HEIGHT = 54;
|
const COLLAPSED_ROW_MIN_HEIGHT = 54;
|
||||||
@@ -337,14 +343,79 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
item.manualTags = [...(item.manualTags || []), next];
|
item.manualTags = [...(item.manualTags || []), next];
|
||||||
saveHistory();
|
saveTranscriptMeta(item.id, { manualTags: item.manualTags });
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeManualTag(item, tag) {
|
function removeManualTag(item, tag) {
|
||||||
const t = normaliseTag(tag);
|
const t = normaliseTag(tag);
|
||||||
item.manualTags = (item.manualTags || []).filter((x) => normaliseTag(x) !== t);
|
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) {
|
async function exportMarkdown(item) {
|
||||||
@@ -459,6 +530,17 @@
|
|||||||
<Star size={14} aria-hidden="true" />
|
<Star size={14} aria-hidden="true" />
|
||||||
<span class="text-[11px]">Starred</span>
|
<span class="text-[11px]">Starred</span>
|
||||||
</button>
|
</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}
|
{#if history.length > 0}
|
||||||
<button
|
<button
|
||||||
class="btn-md rounded-lg text-text-tertiary hover:text-danger hover:bg-hover"
|
class="btn-md rounded-lg text-text-tertiary hover:text-danger hover:bg-hover"
|
||||||
@@ -708,6 +790,17 @@
|
|||||||
>×</button>
|
>×</button>
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="bg-bg-input border border-border rounded-full px-2 py-0.5 text-[10px]
|
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); }}
|
onclick={(e) => { e.stopPropagation(); exportMarkdown(item); }}
|
||||||
title="Export this transcript as a Markdown file with YAML frontmatter"
|
title="Export this transcript as a Markdown file with YAML frontmatter"
|
||||||
>Export .md</button>
|
>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}
|
{#if item.audioPath && item.segments && item.segments.length > 0}
|
||||||
<button
|
<button
|
||||||
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-accent hover:text-accent-hover"
|
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-accent hover:text-accent-hover"
|
||||||
|
|||||||
Reference in New Issue
Block a user