fix(memoro): diagnostic logs + loading states + transcription source label

User reported three issues after the Phase 5 + the encryption-decrypt
fix landed:

  1. Auto-title still doesn't appear (placeholder "Titel..." stays empty)
  2. No loading state visible while transcription / title are in flight
  3. Transcript should say which STT engine produced it

This commit ships diagnostics for issue 1 and concrete UX for 2 + 3.

Issue 1 — diagnostics (no fix yet, root cause unknown):

  Add console.info logs at every step of the auto-title pipeline so
  the next test session surfaces exactly where it breaks:

  - memos.svelte.ts after llmTaskQueue.enqueue() succeeds:
      "[memoro] enqueued title task { taskId, memoId }"
  - memos.svelte.ts on enqueue failure:
      "[memoro] failed to enqueue title task: <err>"
  - memoro/llm-watcher.svelte.ts on subscribe:
      "[memoro-llm-watcher] starting subscription"
  - watcher's next handler when rows arrive:
      "[memoro-llm-watcher] saw N done title task(s)"
  - applyRow logs each step: drop / skip / write / consume

  Refactor: extract per-row logic into applyRow() so the next handler
  loop can wrap each row in try/catch — a single bad row won't crash
  the watcher and prevent later rows from being processed.

  Belt-and-suspenders startup sweep: run a one-shot manual sweep of
  done rows immediately after subscribing. Dexie liveQuery sometimes
  misses the first emission when the subscription is set up in the
  same microtask as a recent table update; the sweep catches any
  done rows that already exist from a previous tab session OR that
  were written between layout mount and subscription start.

  Encryption check fix: the previous skip-if-manual-title check
  read `memo.title?.trim()` after Dexie.get(), but Dexie reads
  return the ENCRYPTED row (no decrypt hook) — so memo.title is
  either null/undefined (no manual title) OR an `enc:1:...` blob
  (manual title set). Either way, presence-check is enough; no
  need to decrypt to know whether the user filled it in. The old
  code happened to work because trim() on a non-empty string
  returns truthy regardless. Comment now spells this out.

Issue 2 — visible loading states:

  apps/mana/apps/web/src/lib/modules/memoro/views/DetailView.svelte

  Transcript area now branches on processingStatus:

    - processing → "Wird transkribiert…" with three pulsing dots
                   (CSS @keyframes loadingPulse)
    - failed     → red error message + manual retry hint
    - completed + transcript → the transcript itself + source label
    - completed + no transcript → italic "Kein Transkript vorhanden."

  Title input placeholder swaps to "Titel wird generiert…" while a
  generateTitleTask for this memo is in pending or running state.
  The check uses a Dexie liveQuery against llmQueueDb.tasks via the
  [refType+refId] compound index, returning the most recent task row.
  Reactive — the placeholder switches back to plain "Titel…" the
  moment the watcher writes the title and deletes the queue row.

Issue 3 — transcription source label:

  Below the transcript: a small italic "Voxtral via mana-stt" label.
  Hardcoded to Voxtral because that's services/mana-stt's default
  model (DEFAULT_MODEL = "mistralai/Voxtral-Mini-3B-2507" in
  voxtral_service.py). If we ever route to Whisper or another model
  per-request, the label will need to come from the response payload
  rather than be hardcoded — Phase 5.5 work.

After this commit lands, the test loop is: record a memo, watch the
browser console for the [memoro] / [memoro-llm-watcher] log lines.
Whichever step is missing identifies the broken link.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 12:41:28 +02:00
parent a7fbd29a67
commit 526d92f41c
3 changed files with 185 additions and 38 deletions

View file

@ -24,6 +24,7 @@
*/
import { liveQuery, type Subscription } from 'dexie';
import type { QueuedTask } from '@mana/shared-llm';
import { llmQueueDb } from '$lib/llm-queue';
import { encryptRecord } from '$lib/data/crypto';
import { memoTable } from './collections';
@ -35,53 +36,113 @@ export function startMemoroLlmWatcher(): void {
if (subscription) return; // already running
if (typeof window === 'undefined') return; // SSR-safe no-op
console.info('[memoro-llm-watcher] starting subscription');
const observable = liveQuery(async () =>
llmQueueDb.tasks
.where('state')
.equals('done')
.and((t) => t.taskName === 'common.generateTitle' && t.refType === 'memo')
.and((t: QueuedTask) => t.taskName === 'common.generateTitle' && t.refType === 'memo')
.toArray()
);
subscription = observable.subscribe({
next: async (rows) => {
if (rows.length === 0) return;
console.info(`[memoro-llm-watcher] saw ${rows.length} done title task(s)`);
for (const row of rows) {
if (!row.refId || typeof row.result !== 'string') {
// Result shape didn't match — drop the queue row so we
// don't keep retrying it.
await llmQueueDb.tasks.delete(row.id);
continue;
try {
await applyRow(row);
} catch (err) {
console.warn('[memoro-llm-watcher] failed to apply row', row.id, err);
// Best-effort: mark the row consumed so we don't keep
// retrying a row that crashes the watcher every cycle.
try {
await llmQueueDb.tasks.delete(row.id);
} catch {
/* ignore */
}
}
const memo = await memoTable.get(row.refId);
if (!memo) {
// Memo was deleted before the task finished — discard.
await llmQueueDb.tasks.delete(row.id);
continue;
}
// Don't overwrite a manual title that the user typed
// between enqueue time and result time.
if (memo.title?.trim()) {
await llmQueueDb.tasks.delete(row.id);
continue;
}
const diff: Partial<LocalMemo> = {
title: row.result,
updatedAt: new Date().toISOString(),
};
await encryptRecord('memos', diff);
await memoTable.update(row.refId, diff);
// Mark consumed
await llmQueueDb.tasks.delete(row.id);
}
},
error: (err) => {
console.warn('[memoro-llm-watcher] subscription error:', err);
},
});
// Belt-and-suspenders: Dexie liveQuery sometimes misses the FIRST
// emission if the subscription is set up in the same microtask as
// the table update. Trigger an immediate manual sweep on startup
// so any rows already done from a previous tab session get picked up.
void runOneSweep();
}
async function runOneSweep(): Promise<void> {
try {
const rows = await llmQueueDb.tasks
.where('state')
.equals('done')
.and((t: QueuedTask) => t.taskName === 'common.generateTitle' && t.refType === 'memo')
.toArray();
if (rows.length === 0) {
console.info('[memoro-llm-watcher] startup sweep: no pending done rows');
return;
}
console.info(`[memoro-llm-watcher] startup sweep: applying ${rows.length} row(s)`);
for (const row of rows) {
try {
await applyRow(row);
} catch (err) {
console.warn('[memoro-llm-watcher] startup sweep failed for row', row.id, err);
}
}
} catch (err) {
console.warn('[memoro-llm-watcher] startup sweep error:', err);
}
}
async function applyRow(row: QueuedTask): Promise<void> {
if (!row.refId || typeof row.result !== 'string') {
console.info(
`[memoro-llm-watcher] dropping row ${row.id} — missing refId or result not a string`
);
await llmQueueDb.tasks.delete(row.id);
return;
}
const memo = await memoTable.get(row.refId);
if (!memo) {
console.info(`[memoro-llm-watcher] dropping row ${row.id} — memo ${row.refId} not found`);
await llmQueueDb.tasks.delete(row.id);
return;
}
// Don't overwrite a manual title that the user typed
// between enqueue time and result time. The memo we just read
// from Dexie is still ENCRYPTED — title is either null/undefined
// (no manual title) or an `enc:1:...` blob (manual title set).
// Either way, presence-check is enough — we don't need to decrypt
// to know if the user filled it in.
if (typeof memo.title === 'string' && memo.title.trim()) {
console.info(
`[memoro-llm-watcher] memo ${row.refId} already has a title — skipping auto-title`
);
await llmQueueDb.tasks.delete(row.id);
return;
}
console.info(`[memoro-llm-watcher] writing title to memo ${row.refId}: "${row.result}"`);
const diff: Partial<LocalMemo> = {
title: row.result,
updatedAt: new Date().toISOString(),
};
await encryptRecord('memos', diff);
await memoTable.update(row.refId, diff);
// Mark consumed
await llmQueueDb.tasks.delete(row.id);
console.info(`[memoro-llm-watcher] applied + cleared row ${row.id}`);
}
export function stopMemoroLlmWatcher(): void {

View file

@ -126,15 +126,17 @@ export const memosStore = {
// none (regex-based first-sentence fallback).
if (!existing.title?.trim() && transcript.length > 0) {
try {
await llmTaskQueue.enqueue(
const taskId = await llmTaskQueue.enqueue(
generateTitleTask,
{ text: transcript, language: existing.language ?? result.language ?? 'de' },
{ refType: 'memo', refId: memoId, priority: 1 }
);
} catch {
console.info('[memoro] enqueued title task', { taskId, memoId });
} catch (err) {
// Don't let queue failures break the transcription path.
// Worst case the memo stays untitled — the user can still
// rename it manually.
console.warn('[memoro] failed to enqueue title task:', err);
}
}
} catch (e) {

View file

@ -4,8 +4,10 @@
-->
<script lang="ts">
import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { memosStore } from '../stores/memos.svelte';
import { llmQueueDb } from '$lib/llm-queue';
import { PushPin } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry';
import type { LocalMemo, ProcessingStatus } from '../types';
@ -74,6 +76,27 @@
completed: '#22c55e',
failed: '#ef4444',
};
// Reactive lookup of any LLM queue task tagged with this memo, so the
// UI can show "Titel wird generiert..." while a generateTitleTask is
// pending or running. Returns the most recent task row (any state).
const titleQueueRow = useLiveQueryWithDefault(
async () => {
if (!memoId) return null;
const rows = await llmQueueDb.tasks
.where('[refType+refId]')
.equals(['memo', memoId])
.and((t) => t.taskName === 'common.generateTitle')
.reverse()
.sortBy('enqueuedAt');
return rows[0] ?? null;
},
null as Awaited<ReturnType<typeof llmQueueDb.tasks.toArray>>[number] | null
);
const titleIsGenerating = $derived(
titleQueueRow.value?.state === 'pending' || titleQueueRow.value?.state === 'running'
);
</script>
<DetailViewShell
@ -98,7 +121,7 @@
bind:value={editTitle}
onfocus={detail.focus}
onblur={saveField}
placeholder="Titel..."
placeholder={titleIsGenerating && !editTitle ? 'Titel wird generiert…' : 'Titel…'}
/>
<button class="pin-btn" class:pinned={memo.isPinned} onclick={togglePin}>
<PushPin size={16} />
@ -142,12 +165,26 @@
></textarea>
</div>
{#if memo.transcript}
<div class="section">
<span class="section-label">Transkript</span>
<div class="section">
<span class="section-label">Transkript</span>
{#if memo.processingStatus === 'processing'}
<div class="transcript transcript-loading">
<span class="loading-dot"></span>
<span class="loading-dot"></span>
<span class="loading-dot"></span>
<span>Wird transkribiert…</span>
</div>
{:else if memo.processingStatus === 'failed'}
<div class="transcript transcript-failed">
Transkription fehlgeschlagen. Versuche es erneut oder gib das Transkript manuell ein.
</div>
{:else if memo.transcript}
<div class="transcript">{memo.transcript}</div>
</div>
{/if}
<div class="source-label">Voxtral via mana-stt</div>
{:else}
<div class="transcript transcript-empty">Kein Transkript vorhanden.</div>
{/if}
</div>
<div class="meta">
<span>Erstellt: {new Date(memo.createdAt ?? '').toLocaleDateString('de')}</span>
@ -187,4 +224,51 @@
max-height: 12rem;
overflow-y: auto;
}
.transcript-loading {
display: flex;
align-items: center;
gap: 0.375rem;
font-style: italic;
}
.transcript-empty {
font-style: italic;
opacity: 0.7;
}
.transcript-failed {
color: hsl(var(--color-destructive, 0 84% 60%));
}
.loading-dot {
display: inline-block;
width: 0.375rem;
height: 0.375rem;
border-radius: 50%;
background: currentColor;
opacity: 0.4;
animation: loadingPulse 1.2s ease-in-out infinite;
}
.loading-dot:nth-child(2) {
animation-delay: 0.15s;
}
.loading-dot:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes loadingPulse {
0%,
80%,
100% {
opacity: 0.4;
transform: scale(0.85);
}
40% {
opacity: 1;
transform: scale(1);
}
}
.source-label {
margin-top: 0.375rem;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
opacity: 0.7;
font-style: italic;
}
</style>