feat(memoro): show title source label below the title input

Mirror the "Voxtral via mana-stt" label that already sits under the
transcript: a small italic line directly below the title input
showing which tier (and roughly which model) generated the title.
This way the user can see at a glance whether the title came from
the local rules engine, from Gemma 4 in their browser, from
gemma3:4b on the Mana server, or from Google Gemini — and can
decide whether to keep it or rewrite manually.

Storage:

  apps/mana/apps/web/src/lib/modules/memoro/llm-watcher.svelte.ts
    - When applying a completed title task, the watcher now also
      stamps memo.metadata.titleSource with the LlmTier string
      ('none' | 'browser' | 'mana-server' | 'cloud') from the queue
      row's `source` field. Stored in the existing plaintext metadata
      object — no encryption needed (the tier name isn't sensitive
      and the encryption registry for memos only covers
      title/intro/transcript). Existing metadata fields are
      spread-preserved so we don't accidentally wipe STT failure
      markers etc.

Manual override clears the marker:

  apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts
    - memosStore.update() now detects when `title` is in the diff
      and clears `metadata.titleSource` so the DetailView stops
      showing "via Mana-Server (gemma3:4b)" for a title the user
      typed themselves. Only fires when title is actually present
      in the update payload — non-title updates leave metadata alone
      so we don't blow away other markers.

Display:

  apps/mana/apps/web/src/lib/modules/memoro/views/DetailView.svelte
    - New TITLE_SOURCE_LABELS map gives each tier a human-readable
      label that surfaces the actual model name where known:
        none        → "Lokal (regelbasiert)"
        browser     → "Auf deinem Gerät (Gemma 4)"
        mana-server → "Mana-Server (gemma3:4b)"
        cloud       → "Google Gemini"
      We deliberately don't reuse @mana/shared-llm's tierLabel()
      because the model name is more informative than the abstract
      tier in this UX context.
    - $derived `titleSourceLabel` reads memo.metadata.titleSource
      and validates it via an isLlmTier type guard. Returns null
      (→ no label rendered) when:
        * the entity hasn't loaded yet
        * a title task is currently in flight (titleIsGenerating)
        * the title input is currently focused (user is editing)
        * the metadata field is missing or not a known tier value
    - New `<div class="source-label title-source-label">` slot
      between the title-row and the properties block, with a small
      CSS override (.title-source-label) for a tighter top gap and
      a slight left indent so it visually lines up under the input
      text rather than under the input border.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 14:19:59 +02:00
parent 7fa3afcdc7
commit 2f00d9c5d3
3 changed files with 75 additions and 0 deletions

View file

@ -158,9 +158,21 @@ async function applyRow(row: QueuedTask): Promise<void> {
});
}
// Stamp the title source on the memo's metadata so the DetailView can
// render a "via Mana-Server" / "Auf deinem Gerät" / "Lokal (Regeln)"
// label under the title — the same UX we already have under the
// transcript ("Voxtral via mana-stt"). Stored as plaintext metadata
// because the tier name isn't sensitive and the encryption registry
// for memos only covers title/intro/transcript.
const existingMetadata = (memo.metadata as Record<string, unknown> | null) ?? {};
const diff: Partial<LocalMemo> = {
title: titleToWrite,
updatedAt: new Date().toISOString(),
metadata: {
...existingMetadata,
titleSource: row.source,
},
};
await encryptRecord('memos', diff);
await memoTable.update(row.refId, diff);

View file

@ -158,6 +158,23 @@ export const memosStore = {
...data,
updatedAt: new Date().toISOString(),
};
// If the user is overwriting the title manually, clear the
// auto-generated titleSource marker so the DetailView stops
// showing "via Mana-Server" — the title is now the user's, not
// the LLM's. We only touch metadata when title was actually in
// the diff so we don't accidentally wipe other metadata fields
// (e.g. STT failure markers) on a non-title update.
if ('title' in data) {
const existing = await memoTable.get(id);
const existingMetadata = (existing?.metadata as Record<string, unknown> | null) ?? {};
if ('titleSource' in existingMetadata) {
const { titleSource: _omit, ...rest } = existingMetadata;
void _omit;
diff.metadata = rest;
}
}
await encryptRecord('memos', diff);
await memoTable.update(id, diff);
},

View file

@ -8,10 +8,27 @@
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { memosStore } from '../stores/memos.svelte';
import { llmQueueDb } from '$lib/llm-queue';
import type { LlmTier } from '@mana/shared-llm';
import { PushPin } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry';
import type { LocalMemo, ProcessingStatus } from '../types';
// Human-readable labels for the title-source badge below the title
// input. We use these specific strings (not @mana/shared-llm's
// generic tierLabel) so we can surface the actual model name where
// known — "gemma3:4b" for mana-server, "Gemma 4" for browser tier
// — rather than the abstract tier name.
const TITLE_SOURCE_LABELS: Record<LlmTier, string> = {
none: 'Lokal (regelbasiert)',
browser: 'Auf deinem Gerät (Gemma 4)',
'mana-server': 'Mana-Server (gemma3:4b)',
cloud: 'Google Gemini',
};
function isLlmTier(value: unknown): value is LlmTier {
return value === 'none' || value === 'browser' || value === 'mana-server' || value === 'cloud';
}
let { params, goBack }: ViewProps = $props();
let memoId = $derived(params.memoId as string);
@ -97,6 +114,23 @@
const titleIsGenerating = $derived(
titleQueueRow.value?.state === 'pending' || titleQueueRow.value?.state === 'running'
);
// Source label for the title — read from memo.metadata.titleSource
// (set by the memoro LLM watcher when it applies an auto-generated
// title, cleared by memosStore.update() when the user types over it).
// Returns a label string or null if the title was manually entered.
const titleSourceLabel = $derived.by(() => {
const memo = detail.entity;
if (!memo) return null;
// Don't show a source label while we're still mid-generation.
if (titleIsGenerating) return null;
// Don't show a source label if the user has typed into the field
// and edits haven't been saved yet — they're about to overwrite.
if (detail.focused) return null;
const metadata = (memo.metadata as Record<string, unknown> | null) ?? {};
const source = metadata.titleSource;
return isLlmTier(source) ? TITLE_SOURCE_LABELS[source] : null;
});
</script>
<DetailViewShell
@ -128,6 +162,10 @@
</button>
</div>
{#if titleSourceLabel}
<div class="source-label title-source-label">{titleSourceLabel}</div>
{/if}
<div class="properties">
<div class="prop-row">
<span class="prop-label">Status</span>
@ -271,4 +309,12 @@
opacity: 0.7;
font-style: italic;
}
.title-source-label {
/* Sit visually right under the title input rather than the
transcript box — needs a tighter top gap and a small left
indent so it lines up with the text inside the input. */
margin-top: 0.125rem;
margin-bottom: 0.5rem;
padding-left: 0.125rem;
}
</style>