mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
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:
parent
a7fbd29a67
commit
526d92f41c
3 changed files with 185 additions and 38 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue