feat(auto-title): auto on save + per-row + bulk in History
Wire generate_title_cmd into the user-visible flows. Three entry points, matching the agreed hybrid trigger (auto when conditions are met, plus on-demand for retroactive titling of old transcripts): 1. Auto on save — addToHistory in src/lib/stores/page.svelte.ts now fires `maybeAutoGenerateTitle` fire-and-forget after the SQLite write succeeds. Gate: `aiTier !== "off" && formatMode !== "Raw"`, piggybacking on the same Settings choice that drives auto-cleanup. Per the design principle "Every new setting must earn its mental real estate" (README.md:22), no new settings flag. Skipped silently when LLM isn't loaded; user retries via the per-row button. Never overwrites a title the caller already set. 2. Per-row "Title" button in HistoryPage — mirrors the existing Tag button shape exactly (Sparkles icon vs Tag icon). In-flight tracked via a `titling: Set<string>` so the same row can't fire twice; the row stays disabled with "Titling…" copy while in flight. Persists via the existing `renameHistoryEntry` (which already calls `update_transcript`) — no need to extend `saveTranscriptMeta`. 3. Bulk "Title all untitled" toolbar action — same shape as "Tag all untagged". Filters `history` to entries where `!item.title || !item.title.trim()`, iterates with progress text, surfaces a success toast at the end with the count actually written (not iterated — empty model responses count as skipped). Visible with no schema changes, no migration: transcripts.title has been nullable since v1 (migrations.rs:12-29). Verification: - `npm run check`: 0 errors, 0 warnings across 3957 files. - `cargo test --workspace --lib`: 280 passing (was 277; +3 from the sanitize_title tests landed in the kon-llm commit). Together with the prior two commits this closes the "auto-titles" plan from /home/jake/.claude/plans/delightful-meandering-moth.md. Out of scope per that plan: settings toggle, regenerate-on-edit, i18n-locale-matching titles.
This commit is contained in:
@@ -26,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, Tag } from 'lucide-svelte';
|
import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Sparkles, Star, Tag } from 'lucide-svelte';
|
||||||
|
|
||||||
const prefs = getPreferences();
|
const prefs = getPreferences();
|
||||||
const COLLAPSED_ROW_MIN_HEIGHT = 54;
|
const COLLAPSED_ROW_MIN_HEIGHT = 54;
|
||||||
@@ -439,6 +439,66 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-title flow. Mirrors the tag flow above piece-for-piece — same
|
||||||
|
// per-row + bulk shape, same in-flight Set tracking, same progress
|
||||||
|
// string. The actual generation lives in `generate_title_cmd` in
|
||||||
|
// src-tauri/src/commands/llm.rs; the on-demand button here is the
|
||||||
|
// retry path when auto-title-on-save (in addToHistory) was skipped
|
||||||
|
// because the LLM wasn't loaded yet.
|
||||||
|
let titling = $state(new Set());
|
||||||
|
let bulkTitling = $state(false);
|
||||||
|
let bulkTitlingProgress = $state("");
|
||||||
|
|
||||||
|
async function titleRow(item) {
|
||||||
|
if (titling.has(item.id)) return;
|
||||||
|
const next = new Set(titling);
|
||||||
|
next.add(item.id);
|
||||||
|
titling = next;
|
||||||
|
try {
|
||||||
|
const title = await invoke("generate_title_cmd", { transcript: item.text || "" });
|
||||||
|
const trimmed = (title || "").trim();
|
||||||
|
if (trimmed) {
|
||||||
|
await renameHistoryEntry(item.id, { title: trimmed });
|
||||||
|
} else {
|
||||||
|
toasts.warn("No title produced", "The model returned an empty response.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error("Title generation failed", String(err));
|
||||||
|
} finally {
|
||||||
|
const rest = new Set(titling);
|
||||||
|
rest.delete(item.id);
|
||||||
|
titling = rest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function titleAllUntitled() {
|
||||||
|
if (bulkTitling) return;
|
||||||
|
bulkTitling = true;
|
||||||
|
try {
|
||||||
|
const untitled = history.filter((i) => !i.title || !i.title.trim());
|
||||||
|
let done = 0;
|
||||||
|
let written = 0;
|
||||||
|
for (const item of untitled) {
|
||||||
|
done += 1;
|
||||||
|
bulkTitlingProgress = `${done} / ${untitled.length}`;
|
||||||
|
try {
|
||||||
|
const title = await invoke("generate_title_cmd", { transcript: item.text || "" });
|
||||||
|
const trimmed = (title || "").trim();
|
||||||
|
if (trimmed) {
|
||||||
|
await renameHistoryEntry(item.id, { title: trimmed });
|
||||||
|
written += 1;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("bulk title failed for", item.id, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toasts.success(`Titled ${written} transcript${written === 1 ? "" : "s"}`);
|
||||||
|
} finally {
|
||||||
|
bulkTitling = false;
|
||||||
|
bulkTitlingProgress = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function exportMarkdown(item) {
|
async function exportMarkdown(item) {
|
||||||
await saveTranscriptAsMarkdown(item);
|
await saveTranscriptAsMarkdown(item);
|
||||||
}
|
}
|
||||||
@@ -565,6 +625,17 @@
|
|||||||
<span class="text-[11px]">{bulkTagging ? `Tagging ${bulkTaggingProgress}` : "Tag all untagged"}</span>
|
<span class="text-[11px]">{bulkTagging ? `Tagging ${bulkTaggingProgress}` : "Tag all untagged"}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if history.some((i) => !i.title || !i.title.trim())}
|
||||||
|
<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={titleAllUntitled}
|
||||||
|
disabled={bulkTitling}
|
||||||
|
title="Auto-generate titles for every untitled transcript using the local LLM"
|
||||||
|
>
|
||||||
|
<Sparkles size={13} aria-hidden="true" />
|
||||||
|
<span class="text-[11px]">{bulkTitling ? `Titling ${bulkTitlingProgress}` : "Title all untitled"}</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"
|
||||||
@@ -871,6 +942,17 @@
|
|||||||
<Tag size={11} aria-hidden="true" />
|
<Tag size={11} aria-hidden="true" />
|
||||||
{tagging.has(item.id) ? "Tagging…" : "Tag"}
|
{tagging.has(item.id) ? "Tagging…" : "Tag"}
|
||||||
</button>
|
</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(); titleRow(item); }}
|
||||||
|
disabled={titling.has(item.id)}
|
||||||
|
title="Auto-generate a title for this transcript using the local LLM"
|
||||||
|
aria-label="Generate transcript title with AI"
|
||||||
|
>
|
||||||
|
<Sparkles size={11} aria-hidden="true" />
|
||||||
|
{titling.has(item.id) ? "Titling…" : "Title"}
|
||||||
|
</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"
|
||||||
|
|||||||
@@ -251,11 +251,44 @@ export async function addToHistory(entry: TranscriptWriteEntry) {
|
|||||||
antiHallucination: !!entry.antiHallucination,
|
antiHallucination: !!entry.antiHallucination,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// Fire-and-forget auto-title. Same gate as the existing auto-cleanup
|
||||||
|
// path (`aiTier !== "off" && formatMode !== "Raw"`) so users opt in
|
||||||
|
// by the same Settings choice — no new toggle. Skipped silently if
|
||||||
|
// the LLM isn't loaded; HistoryPage's per-row "Title" button is the
|
||||||
|
// retry path. Never overwrites a title the caller already set.
|
||||||
|
void maybeAutoGenerateTitle(normalised.id, normalised.text, normalised.title);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("addToHistory: SQLite write failed, entry will not persist past restart", err);
|
console.warn("addToHistory: SQLite write failed, entry will not persist past restart", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run the auto-title pass when the same gate that drives auto-cleanup
|
||||||
|
/// is met. Best-effort: any failure (LLM not loaded, model rejected the
|
||||||
|
/// transcript, sanitisation produced an empty string) is swallowed
|
||||||
|
/// because the user can still title the row manually via the per-row
|
||||||
|
/// button in History.
|
||||||
|
async function maybeAutoGenerateTitle(
|
||||||
|
id: string,
|
||||||
|
text: string,
|
||||||
|
existingTitle: string | undefined | null,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!hasTauriRuntime()) return;
|
||||||
|
if (settings.aiTier === "off" || settings.formatMode === "Raw") return;
|
||||||
|
if (!text.trim()) return;
|
||||||
|
if (existingTitle && existingTitle.trim()) return;
|
||||||
|
try {
|
||||||
|
const title = await invoke<string>("generate_title_cmd", { transcript: text });
|
||||||
|
if (title && title.trim()) {
|
||||||
|
await renameHistoryEntry(id, { title: title.trim() });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Auto-title is a nice-to-have, not core flow. The most common
|
||||||
|
// failure (LLM not loaded yet) is exactly the case where the
|
||||||
|
// user can re-trigger via the per-row "Title" button.
|
||||||
|
console.warn("maybeAutoGenerateTitle skipped", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function renameHistoryEntry(
|
export async function renameHistoryEntry(
|
||||||
id: string,
|
id: string,
|
||||||
updates: { title?: string; text?: string },
|
updates: { title?: string; text?: string },
|
||||||
|
|||||||
Reference in New Issue
Block a user