diff --git a/apps/mana/apps/web/src/lib/llm-task-registry.ts b/apps/mana/apps/web/src/lib/llm-task-registry.ts index ebdb7d1ae..5dac77195 100644 --- a/apps/mana/apps/web/src/lib/llm-task-registry.ts +++ b/apps/mana/apps/web/src/lib/llm-task-registry.ts @@ -19,9 +19,11 @@ import type { TaskRegistry } from '@mana/shared-llm'; import { extractDateTask } from './llm-tasks/extract-date'; +import { generateTitleTask } from './llm-tasks/generate-title'; import { summarizeTextTask } from './llm-tasks/summarize'; export const taskRegistry: TaskRegistry = { [extractDateTask.name]: extractDateTask, + [generateTitleTask.name]: generateTitleTask, [summarizeTextTask.name]: summarizeTextTask, }; diff --git a/apps/mana/apps/web/src/lib/llm-tasks/generate-title.ts b/apps/mana/apps/web/src/lib/llm-tasks/generate-title.ts new file mode 100644 index 000000000..7d895a9a6 --- /dev/null +++ b/apps/mana/apps/web/src/lib/llm-tasks/generate-title.ts @@ -0,0 +1,77 @@ +/** + * generateTitleTask — produces a short title (3–7 words) for a longer + * piece of text. Used by memoro to auto-name voice memos after STT + * finishes, by notes for untitled drafts, by chat for thread names. + * + * Has a runRules() fallback so it works even on Tier 0: the fallback + * takes the first sentence (or first ~60 chars), strips trailing + * punctuation, and uses that as the title. It's not as nice as an + * LLM-generated title but it's predictable, free, and never empty. + */ + +import type { LlmBackend, LlmTask } from '@mana/shared-llm'; + +export interface GenerateTitleInput { + text: string; + /** Optional max title length in words. Default 7. */ + maxWords?: number; + /** Optional language hint for the system prompt. Default 'de'. */ + language?: string; +} + +export type GenerateTitleOutput = string; + +export const generateTitleTask: LlmTask = { + name: 'common.generateTitle', + minTier: 'none', // works on Tier 0 via the first-sentence heuristic + contentClass: 'personal', + displayLabel: 'Titel automatisch erzeugen', + + async runLlm(input, backend: LlmBackend): Promise { + const maxWords = input.maxWords ?? 7; + const language = input.language ?? 'de'; + const result = await backend.generate({ + taskName: generateTitleTask.name, + contentClass: generateTitleTask.contentClass, + messages: [ + { + role: 'system', + content: `Du erstellst kurze, aussagekräftige Titel (max. ${maxWords} Wörter) für Texte. Sprache: ${language}. Antworte AUSSCHLIESSLICH mit dem Titel — kein Markdown, keine Anführungszeichen, keine Vorrede, kein Punkt am Ende.`, + }, + { + role: 'user', + content: input.text.slice(0, 4000), // cap context for speed + }, + ], + temperature: 0.5, + maxTokens: 32, + }); + + // Defensive: strip surrounding quotes / markdown / trailing dots in + // case the model didn't fully respect the system prompt. + return result.content + .trim() + .replace(/^["'`*_]+|["'`*_]+$/g, '') + .replace(/\.+$/, '') + .trim(); + }, + + async runRules(input): Promise { + const text = input.text.trim(); + if (!text) return 'Ohne Titel'; + + // Take the first sentence — split on .!? or newline. + const firstSentence = text.split(/[.!?\n]/)[0]?.trim() ?? text; + + // Cap at ~60 chars / maxWords words, whichever comes first. + const maxWords = input.maxWords ?? 7; + const words = firstSentence.split(/\s+/).slice(0, maxWords); + let candidate = words.join(' '); + + if (candidate.length > 60) { + candidate = candidate.slice(0, 57).trimEnd() + '…'; + } + + return candidate || 'Ohne Titel'; + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/memoro/llm-watcher.svelte.ts b/apps/mana/apps/web/src/lib/modules/memoro/llm-watcher.svelte.ts new file mode 100644 index 000000000..3963e29cd --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/memoro/llm-watcher.svelte.ts @@ -0,0 +1,90 @@ +/** + * Memoro LLM result watcher. + * + * The persistent task queue stores LlmTask results in its own Dexie + * table — but for module-side data (like a memo's title), we want + * those results to land in the module's own collection so existing + * queries / UI keep working without per-component subscriptions. + * + * This file owns the bridge for memoro: it subscribes via Dexie + * liveQuery to completed `common.generateTitle` tasks tagged + * with refType: 'memo', and for each one writes the generated title + * back into the memo row, then deletes the queue entry to mark it + * consumed. Once consumed, the queue stays empty for that memo. + * + * The watcher is started exactly once per page session — see + * startMemoroLlmWatcher() below for the idempotent guard. The + * memoro module config calls it from its initialize() hook, but + * even if a future refactor calls it twice, the duplicate call is + * a no-op. + * + * Cleanup: the subscription handle is stored module-scope; the page + * teardown is implicit (page reload kills the dev server too). For + * a long-lived SPA we'd want stop() — punt that to a follow-up. + */ + +import { liveQuery, type Subscription } from 'dexie'; +import { llmQueueDb } from '$lib/llm-queue'; +import { encryptRecord } from '$lib/data/crypto'; +import { memoTable } from './collections'; +import type { LocalMemo } from './types'; + +let subscription: Subscription | null = null; + +export function startMemoroLlmWatcher(): void { + if (subscription) return; // already running + if (typeof window === 'undefined') return; // SSR-safe no-op + + const observable = liveQuery(async () => + llmQueueDb.tasks + .where('state') + .equals('done') + .and((t) => t.taskName === 'common.generateTitle' && t.refType === 'memo') + .toArray() + ); + + subscription = observable.subscribe({ + next: async (rows) => { + 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; + } + + 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 = { + 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); + }, + }); +} + +export function stopMemoroLlmWatcher(): void { + subscription?.unsubscribe(); + subscription = null; +} diff --git a/apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts b/apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts index cc6aa3c37..011f30edf 100644 --- a/apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts @@ -10,6 +10,8 @@ import { toMemo } from '../queries'; import { createArchiveOps } from '@mana/shared-stores'; import { MemoroEvents } from '@mana/shared-utils/analytics'; import { encryptRecord } from '$lib/data/crypto'; +import { llmTaskQueue } from '$lib/llm-queue'; +import { generateTitleTask } from '$lib/llm-tasks/generate-title'; import type { LocalMemo } from '../types'; /** Archive/soft-delete ops for memos. */ @@ -106,6 +108,26 @@ export const memosStore = { }; await encryptRecord('memos', diff); await memoTable.update(memoId, diff); + + // Auto-title: if the user didn't already give the memo a title, + // queue a background task to generate one from the transcript. + // The task is fire-and-forget — the memoro LLM watcher + // (./llm-watcher.svelte.ts) picks up the result reactively and + // writes it back to memo.title. Works on every tier including + // none (regex-based first-sentence fallback). + if (!existing.title?.trim() && transcript.length > 0) { + try { + await llmTaskQueue.enqueue( + generateTitleTask, + { text: transcript, language: existing.language ?? result.language ?? 'de' }, + { refType: 'memo', refId: memoId, priority: 1 } + ); + } catch { + // Don't let queue failures break the transcription path. + // Worst case the memo stays untitled — the user can still + // rename it manually. + } + } } catch (e) { const msg = e instanceof Error ? e.message : String(e); await memoTable.update(memoId, { diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index 9adf8b370..198f2dabb 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -37,6 +37,10 @@ import { linkLocalStore, linkMutations } from '@mana/shared-links'; import { manaStore } from '$lib/data/local-store'; import { startLlmQueue, stopLlmQueue } from '$lib/llm-queue'; + import { + startMemoroLlmWatcher, + stopMemoroLlmWatcher, + } from '$lib/modules/memoro/llm-watcher.svelte'; import { createUnifiedSync } from '$lib/data/sync'; import { networkStore } from '$lib/stores/network.svelte'; import { db } from '$lib/data/database'; @@ -312,6 +316,12 @@ // from a crashed session) before going idle. See $lib/llm-queue.ts. startLlmQueue(); + // Module-side LLM result watchers. Each subscribes via Dexie + // liveQuery to completed task rows tagged for its module and + // writes the results back to the module's own collection + // (e.g. memoro auto-titles → memo.title). Idempotent. + startMemoroLlmWatcher(); + // Restore nav collapsed state if (typeof localStorage !== 'undefined') { const savedCollapsed = localStorage.getItem(STORAGE_KEYS.NAV_COLLAPSED); @@ -384,6 +394,7 @@ // will finish in the background and the next page session will // pick up where we left off. void stopLlmQueue(); + stopMemoroLlmWatcher(); }); // ── Search / Spotlight ───────────────────────────────────