diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index 5706bca..860c376 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -27,6 +27,8 @@ import { common, createLowlight } from 'lowlight'; import { createSlashCommandPlugin } from './slash-command'; import type { ImportExportDialog } from './import-export-dialog'; import { SpeechDictation } from '../../../lib/speech-dictation'; +import { TourEngine } from '../../../shared/tour-engine'; +import { ViewHistory } from '../../../shared/view-history.js'; const lowlight = createLowlight(common); @@ -48,6 +50,7 @@ const ICONS: Record = { undo: '', redo: '', mic: '', + summarize: '', }; interface Notebook { @@ -104,6 +107,7 @@ interface NotebookDoc { sortOrder: number; createdAt: number; updatedAt: number; url?: string | null; language?: string | null; fileUrl?: string | null; mimeType?: string | null; duration?: number | null; + summary?: string; summaryModel?: string; openNotebookSourceId?: string; }>; } @@ -125,6 +129,18 @@ class FolkNotesApp extends HTMLElement { private contentZone!: HTMLDivElement; private metaZone!: HTMLDivElement; + // Navigation history + private _history = new ViewHistory<"notebooks" | "notebook" | "note">("notebooks"); + + // Guided tour + private _tour!: TourEngine; + private static readonly TOUR_STEPS = [ + { target: '#create-notebook', title: "Create a Notebook", message: "Notebooks organise your notes by topic. Click '+ New Notebook' to create one.", advanceOnClick: true }, + { target: '#create-note', title: "Create a Note", message: "Inside a notebook you can add notes, links, tasks, and more. Click '+ New Note' to add one.", advanceOnClick: true }, + { target: '#editor-toolbar', title: "Editor Toolbar", message: "Format text with the toolbar — bold, lists, code blocks, headings, and more. Click Next to continue.", advanceOnClick: false }, + { target: '[data-cmd="mic"]', title: "Voice Notes", message: "Record voice notes with live transcription. Your words appear as you speak — no uploads needed.", advanceOnClick: false }, + ]; + // Tiptap editor private editor: Editor | null = null; private editorNoteId: string | null = null; @@ -132,6 +148,13 @@ class FolkNotesApp extends HTMLElement { private editorUpdateTimer: ReturnType | null = null; private dictation: SpeechDictation | null = null; + // Audio recording (AUDIO note view) + private audioRecorder: MediaRecorder | null = null; + private audioSegments: { id: string; text: string; timestamp: number; isFinal: boolean }[] = []; + private audioRecordingStart = 0; + private audioRecordingTimer: ReturnType | null = null; + private audioRecordingDictation: SpeechDictation | null = null; + // Automerge sync state (via shared runtime) private doc: Automerge.Doc | null = null; private subscribedDocId: string | null = null; @@ -145,14 +168,23 @@ class FolkNotesApp extends HTMLElement { constructor() { super(); this.shadow = this.attachShadow({ mode: "open", delegatesFocus: true }); + this._tour = new TourEngine( + this.shadow, + FolkNotesApp.TOUR_STEPS, + "rnotes_tour_done", + () => this.shadow.host as HTMLElement, + ); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.setupShadow(); - if (this.space === "demo") { this.loadDemoData(); return; } - this.subscribeOfflineRuntime(); - this.loadNotebooks(); + if (this.space === "demo") { this.loadDemoData(); } + else { this.subscribeOfflineRuntime(); this.loadNotebooks(); } + // Auto-start tour on first visit + if (!localStorage.getItem("rnotes_tour_done")) { + setTimeout(() => this._tour.start(), 1200); + } } private async subscribeOfflineRuntime() { @@ -736,6 +768,99 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } } + // ── Note summarization ── + + private async summarizeNote(btn: HTMLElement) { + const noteId = this.editorNoteId || this.selectedNote?.id; + if (!noteId) return; + + // Get note content (plain text) + const item = this.doc?.items?.[noteId]; + const content = item?.contentPlain || item?.content || this.selectedNote?.content_plain || ''; + if (!content?.trim()) return; + + // Show loading state on button + btn.classList.add('active'); + btn.style.pointerEvents = 'none'; + + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/notes/summarize`, { + method: 'POST', + headers: this.authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ content, model: 'gemini-flash', length: 'medium' }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + console.error('[Notes] Summarize error:', err); + return; + } + + const data = await res.json() as { summary: string; model: string }; + + // Save to Automerge doc + if (this.space !== 'demo') { + this.updateNoteField(noteId, 'summary', data.summary); + this.updateNoteField(noteId, 'summaryModel', data.model); + } + + // Update local selected note for immediate display + if (this.selectedNote && this.selectedNote.id === noteId) { + (this.selectedNote as any).summary = data.summary; + (this.selectedNote as any).summaryModel = data.model; + } + + this.renderMeta(); + } catch (err) { + console.error('[Notes] Summarize failed:', err); + } finally { + btn.classList.remove('active'); + btn.style.pointerEvents = ''; + } + } + + private async sendToOpenNotebook() { + const noteId = this.editorNoteId || this.selectedNote?.id; + if (!noteId) return; + + const item = this.doc?.items?.[noteId]; + const content = item?.contentPlain || item?.content || this.selectedNote?.content_plain || ''; + const title = item?.title || this.selectedNote?.title || 'Untitled'; + if (!content?.trim()) return; + + // Disable button during request + const btn = this.metaZone.querySelector('[data-action="send-to-notebook"]') as HTMLButtonElement; + if (btn) { btn.disabled = true; btn.textContent = 'Sending...'; } + + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/notes/send-to-notebook`, { + method: 'POST', + headers: this.authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ noteId, title, content }), + }); + + if (!res.ok) { + console.error('[Notes] Send to notebook error:', res.status); + if (btn) { btn.disabled = false; btn.textContent = 'Send to Notebook'; } + return; + } + + const data = await res.json() as { sourceId: string }; + this.updateNoteField(noteId, 'openNotebookSourceId', data.sourceId); + + if (this.selectedNote && this.selectedNote.id === noteId) { + (this.selectedNote as any).openNotebookSourceId = data.sourceId; + } + + this.renderMeta(); + } catch (err) { + console.error('[Notes] Send to notebook failed:', err); + if (btn) { btn.disabled = false; btn.textContent = 'Send to Notebook'; } + } + } + // ── REST (notebook list + search) ── private getApiBase(): string { @@ -1212,6 +1337,179 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } this.wireTitleInput(note, isEditable, isDemo); + + // Wire record button + this.shadow.getElementById('btn-start-recording')?.addEventListener('click', () => { + this.startAudioRecording(note, isDemo); + }); + } + + private async startAudioRecording(note: Note, isDemo: boolean) { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') + ? 'audio/webm;codecs=opus' + : MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4'; + + const audioChunks: Blob[] = []; + this.audioSegments = []; + this.audioRecorder = new MediaRecorder(stream, { mimeType }); + + this.audioRecorder.ondataavailable = (e) => { + if (e.data.size > 0) audioChunks.push(e.data); + }; + + // Replace placeholder with recording UI + const placeholder = this.shadow.querySelector('.audio-record-placeholder'); + if (placeholder) { + placeholder.innerHTML = ` +

+
+
+ 0:00 + +
+
+
+ `; + } + + // Start timer + this.audioRecordingStart = Date.now(); + let elapsed = 0; + this.audioRecordingTimer = setInterval(() => { + elapsed = Math.floor((Date.now() - this.audioRecordingStart) / 1000); + const timerEl = this.shadow.querySelector('.audio-recording-timer'); + if (timerEl) timerEl.textContent = `${Math.floor(elapsed / 60)}:${String(elapsed % 60).padStart(2, '0')}`; + }, 1000); + + // Start speech dictation with segment tracking + if (SpeechDictation.isSupported()) { + this.audioRecordingDictation = new SpeechDictation({ + onInterim: (text) => { + const idx = this.audioSegments.findIndex(s => !s.isFinal); + const ts = Math.floor((Date.now() - this.audioRecordingStart) / 1000); + if (idx >= 0) { + this.audioSegments[idx].text = text; + } else { + this.audioSegments.push({ id: crypto.randomUUID(), text, timestamp: ts, isFinal: false }); + } + this.renderAudioSegments(); + }, + onFinal: (text) => { + const idx = this.audioSegments.findIndex(s => !s.isFinal); + const ts = Math.floor((Date.now() - this.audioRecordingStart) / 1000); + if (idx >= 0) { + this.audioSegments[idx] = { ...this.audioSegments[idx], text, isFinal: true }; + } else { + this.audioSegments.push({ id: crypto.randomUUID(), text, timestamp: ts, isFinal: true }); + } + this.renderAudioSegments(); + }, + }); + this.audioRecordingDictation.start(); + } + + this.audioRecorder.start(1000); + + // Wire stop button + this.shadow.getElementById('btn-stop-recording')?.addEventListener('click', async () => { + // Stop everything + if (this.audioRecordingTimer) { clearInterval(this.audioRecordingTimer); this.audioRecordingTimer = null; } + this.audioRecordingDictation?.stop(); + this.audioRecordingDictation?.destroy(); + this.audioRecordingDictation = null; + + this.audioRecorder!.onstop = async () => { + stream.getTracks().forEach(t => t.stop()); + const blob = new Blob(audioChunks, { type: mimeType }); + const duration = Math.floor((Date.now() - this.audioRecordingStart) / 1000); + + // Upload audio + let fileUrl = ''; + if (!isDemo) { + try { + const base = this.getApiBase(); + const fd = new FormData(); + fd.append('file', blob, 'recording.webm'); + const uploadRes = await fetch(`${base}/api/uploads`, { + method: 'POST', headers: this.authHeaders(), body: fd, + }); + if (uploadRes.ok) { fileUrl = (await uploadRes.json()).url; } + } catch { /* continue without file */ } + } + + // Convert segments to Tiptap JSON + const finalSegments = this.audioSegments.filter(s => s.isFinal); + const fmt = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; + let tiptapContent: any = { type: 'doc', content: [{ type: 'paragraph' }] }; + if (finalSegments.length > 0) { + tiptapContent = { + type: 'doc', + content: finalSegments.map(seg => ({ + type: 'paragraph', + content: [ + { type: 'text', marks: [{ type: 'code' }], text: `[${fmt(seg.timestamp)}]` }, + { type: 'text', text: ` ${seg.text}` }, + ], + })), + }; + } + + const contentJson = JSON.stringify(tiptapContent); + const contentPlain = finalSegments.map(s => s.text).join(' '); + + // Update note fields + const noteId = note.id; + if (isDemo) { + if (fileUrl) this.demoUpdateNoteField(noteId, 'fileUrl', fileUrl); + this.demoUpdateNoteField(noteId, 'content', contentJson); + this.demoUpdateNoteField(noteId, 'content_plain', contentPlain); + } else { + if (fileUrl) this.updateNoteField(noteId, 'fileUrl', fileUrl); + this.updateNoteField(noteId, 'duration', String(duration)); + this.updateNoteField(noteId, 'content', contentJson); + this.updateNoteField(noteId, 'contentPlain', contentPlain); + this.updateNoteField(noteId, 'contentFormat', 'tiptap-json'); + } + + // Update local note for immediate display + (note as any).fileUrl = fileUrl || note.fileUrl; + (note as any).duration = duration; + (note as any).content = contentJson; + (note as any).content_plain = contentPlain; + (note as any).content_format = 'tiptap-json'; + + // Re-mount audio view + this.audioRecorder = null; + this.audioSegments = []; + this.destroyEditor(); + this.editorNoteId = noteId; + this.mountAudioView(note, true, isDemo); + }; + + if (this.audioRecorder?.state === 'recording') { + this.audioRecorder.stop(); + } + }); + } catch (err) { + console.error('Failed to start audio recording:', err); + } + } + + private renderAudioSegments() { + const container = this.shadow.querySelector('.audio-live-segments'); + if (!container) return; + const esc = (s: string) => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }; + const fmt = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; + + container.innerHTML = this.audioSegments.map(seg => ` +
+ [${fmt(seg.timestamp)}] + ${esc(seg.text)} +
+ `).join(''); + container.scrollTop = container.scrollHeight; } /** Shared title input wiring for all editor types */ @@ -1238,6 +1536,18 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF this.dictation.destroy(); this.dictation = null; } + if (this.audioRecordingTimer) { + clearInterval(this.audioRecordingTimer); + this.audioRecordingTimer = null; + } + if (this.audioRecordingDictation) { + this.audioRecordingDictation.destroy(); + this.audioRecordingDictation = null; + } + if (this.audioRecorder?.state === 'recording') { + this.audioRecorder.stop(); + } + this.audioRecorder = null; if (this.editor) { this.editor.destroy(); this.editor = null; @@ -1294,6 +1604,10 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF

${btn('mic', 'Voice Dictation')}
` : ''} +
+
+ ${btn('summarize', 'Summarize Note')} +
`; } @@ -1398,6 +1712,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF case 'undo': this.editor.chain().focus().undo().run(); break; case 'redo': this.editor.chain().focus().redo().run(); break; case 'mic': this.toggleDictation(btn); break; + case 'summarize': this.summarizeNote(btn); break; } }); @@ -1420,27 +1735,51 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF if (this.dictation?.isRecording) { this.dictation.stop(); btn.classList.remove('recording'); + this.removeDictationPreview(); return; } if (!this.dictation) { this.dictation = new SpeechDictation({ + onInterim: (text) => { + this.updateDictationPreview(text); + }, onFinal: (text) => { + this.removeDictationPreview(); if (this.editor) { this.editor.chain().focus().insertContent(text + ' ').run(); } }, onStateChange: (recording) => { btn.classList.toggle('recording', recording); + if (!recording) this.removeDictationPreview(); }, onError: (err) => { console.warn('[Dictation]', err); btn.classList.remove('recording'); + this.removeDictationPreview(); }, }); } this.dictation.start(); } + private updateDictationPreview(text: string) { + let preview = this.shadow.getElementById('dictation-preview'); + if (!preview) { + preview = document.createElement('div'); + preview.id = 'dictation-preview'; + preview.className = 'dictation-preview'; + const toolbar = this.shadow.getElementById('editor-toolbar'); + if (toolbar) toolbar.insertAdjacentElement('afterend', preview); + else return; + } + preview.textContent = text; + } + + private removeDictationPreview() { + this.shadow.getElementById('dictation-preview')?.remove(); + } + private updateToolbarState() { if (!this.editor) return; const toolbar = this.shadow.getElementById('editor-toolbar'); @@ -1513,8 +1852,11 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } this.renderMeta(); this.attachListeners(); + this._tour.renderOverlay(); } + startTour() { this._tour.start(); } + private renderNav() { const isDemo = this.space === "demo"; @@ -1522,28 +1864,16 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF // Nav is handled by mountEditor's content, or we just show back button this.navZone.innerHTML = `

- + ` : ''}
`; // Re-attach back listener this.navZone.querySelectorAll('[data-back]').forEach((el) => { el.addEventListener('click', (e) => { e.stopPropagation(); - const target = (el as HTMLElement).dataset.back; - this.destroyEditor(); - if (target === 'notebooks') { - this.view = 'notebooks'; - if (!isDemo) this.unsubscribeNotebook(); - this.selectedNotebook = null; - this.selectedNote = null; - this.render(); - } else if (target === 'notebook') { - this.view = 'notebook'; - this.selectedNote = null; - this.render(); - } + this.goBack(); }); }); return; @@ -1564,7 +1894,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF ]; this.navZone.innerHTML = `

- + ${this._history.canGoBack ? '' : ''} ${this.esc(nb.title)}${syncBadge} +
`; } @@ -1688,6 +2019,13 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF const n = this.selectedNote; const isAutomerge = !!(this.doc?.items?.[n.id]); const isDemo = this.space === "demo"; + + // Get summary from Automerge doc or local note object + const item = this.doc?.items?.[n.id]; + const summary = item?.summary || (n as any).summary || ''; + const summaryModel = item?.summaryModel || (n as any).summaryModel || ''; + const openNotebookSourceId = item?.openNotebookSourceId || (n as any).openNotebookSourceId || ''; + this.metaZone.innerHTML = `

Type: ${n.type} @@ -1696,7 +2034,47 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF ${n.tags ? n.tags.map((t) => `${this.esc(t)}`).join("") : ""} ${isAutomerge ? 'Live' : ""} ${isDemo ? 'Demo' : ""} -

`; + + ${summary ? ` +
+
+ ${ICONS.summarize} + Summary + ${this.esc(summaryModel)} + + \u{25BC} +
+
${this.esc(summary)}
+
` : ''} + ${!isDemo ? ` +
+ + ${openNotebookSourceId ? 'Indexed' : ''} +
` : ''}`; + + // Attach summary panel event listeners + if (summary) { + const header = this.metaZone.querySelector('[data-action="toggle-summary"]'); + header?.addEventListener('click', (e) => { + if ((e.target as HTMLElement).closest('[data-action="regenerate-summary"]')) return; + const panel = this.metaZone.querySelector('.note-summary-panel'); + panel?.classList.toggle('collapsed'); + }); + const regen = this.metaZone.querySelector('[data-action="regenerate-summary"]'); + regen?.addEventListener('click', () => { + const btn = this.shadow.querySelector('[data-cmd="summarize"]') as HTMLElement; + if (btn) this.summarizeNote(btn); + }); + } + + // Send to Notebook button + const sendBtn = this.metaZone.querySelector('[data-action="send-to-notebook"]') as HTMLElement; + if (sendBtn && !openNotebookSourceId) { + sendBtn.addEventListener('click', () => this.sendToOpenNotebook()); + } } else { this.metaZone.innerHTML = ''; } @@ -1727,7 +2105,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF const typeBorder = this.getTypeBorderColor(n.type); return ` -

+
${this.getNoteIcon(n.type)}
${n.is_pinned ? '\u{1F4CC} ' : ""}${this.esc(n.title)}
@@ -1769,6 +2147,9 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF isDemo ? this.demoCreateNotebook() : this.createNotebook(); }); + // Tour button + this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour()); + // Export notebook button (in notebook detail view) this.shadow.getElementById("btn-export-notebook")?.addEventListener("click", () => { this.openImportExportDialog('export'); @@ -1794,6 +2175,8 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF this.shadow.querySelectorAll("[data-notebook]").forEach((el) => { el.addEventListener("click", () => { const id = (el as HTMLElement).dataset.notebook!; + this._history.push(this.view); + this._history.push("notebook", { notebookId: id }); this.view = "notebook"; isDemo ? this.demoLoadNotebook(id) : this.loadNotebook(id); }); @@ -1803,6 +2186,8 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF this.shadow.querySelectorAll("[data-note]").forEach((el) => { el.addEventListener("click", () => { const id = (el as HTMLElement).dataset.note!; + this._history.push(this.view); + this._history.push("note", { noteId: id }); this.view = "note"; isDemo ? this.demoLoadNote(id) : this.loadNote(id); }); @@ -1812,25 +2197,26 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF this.navZone.querySelectorAll("[data-back]").forEach((el) => { el.addEventListener("click", (e) => { e.stopPropagation(); - const target = (el as HTMLElement).dataset.back; - if (target === "notebooks") { - this.destroyEditor(); - this.view = "notebooks"; - if (!isDemo) this.unsubscribeNotebook(); - this.selectedNotebook = null; - this.selectedNote = null; - this.render(); - } - else if (target === "notebook") { - this.destroyEditor(); - this.view = "notebook"; - this.selectedNote = null; - this.render(); - } + this.goBack(); }); }); } + private goBack() { + this.destroyEditor(); + const prev = this._history.back(); + if (!prev) return; + this.view = prev.view; + if (prev.view === "notebooks") { + if (this.space !== "demo") this.unsubscribeNotebook(); + this.selectedNotebook = null; + this.selectedNote = null; + } else if (prev.view === "notebook") { + this.selectedNote = null; + } + this.render(); + } + private demoUpdateNoteField(noteId: string, field: string, value: string) { if (this.selectedNote && this.selectedNote.id === noteId) { (this.selectedNote as any)[field] = value; @@ -2040,6 +2426,30 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF .audio-player { flex: 1; max-width: 100%; height: 40px; } .audio-duration { font-size: 13px; color: var(--rs-text-muted); font-weight: 500; } .audio-record-placeholder { padding: 24px; text-align: center; } + .audio-recording-ui { text-align: left; } + .audio-recording-header { + display: flex; align-items: center; gap: 12px; margin-bottom: 8px; + } + .recording-pulse-sm { + width: 12px; height: 12px; border-radius: 50%; + background: var(--rs-error, #ef4444); animation: pulse-recording 1.5s infinite; + } + .audio-recording-timer { + font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; + color: var(--rs-text-primary); + } + .audio-live-segments { + max-height: 200px; overflow-y: auto; padding: 4px 0; + } + .transcript-segment { + display: flex; gap: 8px; padding: 3px 0; font-size: 14px; line-height: 1.6; + } + .transcript-segment.interim { font-style: italic; color: var(--rs-text-muted); } + .segment-time { + flex-shrink: 0; font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 12px; color: var(--rs-text-muted); padding-top: 2px; + } + .segment-text { flex: 1; } .audio-transcript-section { padding: 0 4px; } .audio-transcript-label { font-size: 12px; font-weight: 600; text-transform: uppercase; @@ -2078,6 +2488,57 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF .meta-live { color: var(--rs-success); font-weight: 500; } .meta-demo { color: var(--rs-warning); font-weight: 500; } + /* ── Summary Panel ── */ + .note-summary-panel { + margin-top: 8px; border: 1px solid var(--rs-border-subtle); + border-radius: 8px; background: var(--rs-bg-surface); overflow: hidden; + } + .note-summary-header { + display: flex; align-items: center; gap: 6px; padding: 8px 12px; + font-size: 12px; font-weight: 500; color: var(--rs-text-secondary); + cursor: pointer; user-select: none; + } + .note-summary-header:hover { background: var(--rs-bg-surface-raised); } + .note-summary-icon { display: flex; align-items: center; } + .note-summary-icon svg { width: 14px; height: 14px; } + .note-summary-model { + font-size: 10px; color: var(--rs-text-muted); background: var(--rs-bg-surface-raised); + padding: 1px 6px; border-radius: 3px; margin-left: auto; + } + .note-summary-regen { + background: none; border: none; color: var(--rs-text-muted); cursor: pointer; + font-size: 14px; padding: 0 4px; border-radius: 3px; transition: all 0.15s; + } + .note-summary-regen:hover { color: var(--rs-primary); background: var(--rs-bg-surface-raised); } + .note-summary-chevron { font-size: 8px; color: var(--rs-text-muted); transition: transform 0.2s; } + .note-summary-body { + padding: 8px 12px 12px; font-size: 13px; line-height: 1.6; + color: var(--rs-text-primary); border-top: 1px solid var(--rs-border-subtle); + white-space: pre-wrap; + } + .note-summary-panel.collapsed .note-summary-body { display: none; } + .note-summary-panel.collapsed .note-summary-chevron { transform: rotate(-90deg); } + + /* ── Note Actions Bar ── */ + .note-actions-bar { + display: flex; align-items: center; gap: 8px; margin-top: 8px; + } + .note-action-btn { + display: flex; align-items: center; gap: 6px; + padding: 6px 14px; border-radius: 6px; border: 1px solid var(--rs-border); + background: transparent; color: var(--rs-text-secondary); font-size: 12px; + font-weight: 500; cursor: pointer; transition: all 0.15s; font-family: inherit; + } + .note-action-btn:hover:not(:disabled) { border-color: var(--rs-primary); color: var(--rs-primary); } + .note-action-btn:disabled { opacity: 0.6; cursor: default; } + .note-action-btn svg { flex-shrink: 0; } + .note-action-badge { + display: inline-flex; align-items: center; gap: 4px; + padding: 3px 10px; border-radius: 12px; + background: color-mix(in srgb, var(--rs-success, #22c55e) 15%, transparent); + color: var(--rs-success, #22c55e); font-size: 11px; font-weight: 600; + } + /* ── Editor Toolbar ── */ .editor-toolbar { display: flex; flex-wrap: wrap; gap: 2px; align-items: center; @@ -2097,6 +2558,19 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF .toolbar-btn.active { background: var(--rs-primary); color: #fff; } .toolbar-btn.recording { background: var(--rs-error, #ef4444); color: #fff; animation: pulse-recording 1.5s infinite; } @keyframes pulse-recording { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } + + /* ── Dictation Preview ── */ + .dictation-preview { + padding: 6px 12px; margin: 0 8px 2px; border-radius: 6px; + background: var(--rs-bg-surface-raised); color: var(--rs-text-muted); + font-size: 13px; font-style: italic; line-height: 1.5; + animation: dictation-pulse 2s ease-in-out infinite; + border-left: 3px solid var(--rs-error, #ef4444); + } + @keyframes dictation-pulse { + 0%, 100% { background: var(--rs-bg-surface-raised); } + 50% { background: color-mix(in srgb, var(--rs-error, #ef4444) 8%, var(--rs-bg-surface-raised)); } + } .toolbar-select { padding: 2px 4px; border-radius: 4px; border: 1px solid var(--rs-toolbar-panel-border); background: var(--rs-toolbar-bg); color: var(--rs-text-secondary); font-size: 12px; cursor: pointer; diff --git a/modules/rnotes/components/folk-voice-recorder.ts b/modules/rnotes/components/folk-voice-recorder.ts index f719e64..df39d1a 100644 --- a/modules/rnotes/components/folk-voice-recorder.ts +++ b/modules/rnotes/components/folk-voice-recorder.ts @@ -7,12 +7,14 @@ * 2. Live (Web Speech API captured during recording) * 3. Offline (Parakeet TDT 0.6B in-browser) * - * Saves AUDIO notes to rNotes via REST API. + * Saves AUDIO notes to rNotes via REST API with Tiptap-JSON formatted + * timestamped transcript segments. */ import { SpeechDictation } from '../../../lib/speech-dictation'; import { transcribeOffline, isModelCached } from '../../../lib/parakeet-offline'; import type { TranscriptionProgress } from '../../../lib/parakeet-offline'; +import type { TranscriptSegment } from '../../../lib/folk-transcription'; import { getAccessToken } from '../../../shared/components/rstack-identity'; type RecorderState = 'idle' | 'recording' | 'processing' | 'done'; @@ -24,6 +26,7 @@ class FolkVoiceRecorder extends HTMLElement { private mediaRecorder: MediaRecorder | null = null; private audioChunks: Blob[] = []; private dictation: SpeechDictation | null = null; + private segments: TranscriptSegment[] = []; private liveTranscript = ''; private finalTranscript = ''; private recordingStartTime = 0; @@ -100,6 +103,7 @@ class FolkVoiceRecorder extends HTMLElement { : 'audio/mp4'; this.audioChunks = []; + this.segments = []; this.mediaRecorder = new MediaRecorder(stream, { mimeType }); this.mediaRecorder.ondataavailable = (e) => { @@ -116,12 +120,39 @@ class FolkVoiceRecorder extends HTMLElement { this.mediaRecorder.start(1000); // 1s timeslice - // Start live transcription via Web Speech API + // Start live transcription via Web Speech API with segment tracking this.liveTranscript = ''; if (SpeechDictation.isSupported()) { this.dictation = new SpeechDictation({ - onFinal: (text) => { this.liveTranscript += text + ' '; this.render(); }, - onInterim: () => { this.render(); }, + onInterim: (text) => { + const interimIdx = this.segments.findIndex(s => !s.isFinal); + if (interimIdx >= 0) { + this.segments[interimIdx].text = text; + } else { + this.segments.push({ + id: crypto.randomUUID(), + text, + timestamp: this.elapsedSeconds, + isFinal: false, + }); + } + this.renderTranscriptSegments(); + }, + onFinal: (text) => { + const interimIdx = this.segments.findIndex(s => !s.isFinal); + if (interimIdx >= 0) { + this.segments[interimIdx] = { ...this.segments[interimIdx], text, isFinal: true }; + } else { + this.segments.push({ + id: crypto.randomUUID(), + text, + timestamp: this.elapsedSeconds, + isFinal: true, + }); + } + this.liveTranscript = this.segments.filter(s => s.isFinal).map(s => s.text).join(' '); + this.renderTranscriptSegments(); + }, }); this.dictation.start(); } @@ -131,7 +162,8 @@ class FolkVoiceRecorder extends HTMLElement { this.elapsedSeconds = 0; this.durationTimer = setInterval(() => { this.elapsedSeconds = Math.floor((Date.now() - this.recordingStartTime) / 1000); - this.render(); + const timerEl = this.shadow.querySelector('.recording-timer'); + if (timerEl) timerEl.textContent = this.formatTime(this.elapsedSeconds); }, 1000); this.state = 'recording'; @@ -156,6 +188,41 @@ class FolkVoiceRecorder extends HTMLElement { } } + /** Targeted DOM update of transcript segments container (avoids full re-render). */ + private renderTranscriptSegments() { + const container = this.shadow.querySelector('.live-transcript-segments'); + if (!container) return; + + const esc = (s: string) => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }; + + container.innerHTML = this.segments.map(seg => ` +

+ [${this.formatTime(seg.timestamp)}] + ${esc(seg.text)} +
+ `).join(''); + + // Auto-scroll to bottom + container.scrollTop = container.scrollHeight; + } + + /** Convert final segments to Tiptap JSON document with timestamped paragraphs. */ + private segmentsToTiptapJSON(): object { + const finalSegments = this.segments.filter(s => s.isFinal); + if (finalSegments.length === 0) return { type: 'doc', content: [{ type: 'paragraph' }] }; + + return { + type: 'doc', + content: finalSegments.map(seg => ({ + type: 'paragraph', + content: [ + { type: 'text', marks: [{ type: 'code' }], text: `[${this.formatTime(seg.timestamp)}]` }, + { type: 'text', text: ` ${seg.text}` }, + ], + })), + }; + } + private async processRecording() { this.state = 'processing'; this.progressMessage = 'Processing recording...'; @@ -184,7 +251,7 @@ class FolkVoiceRecorder extends HTMLElement { } catch { /* fall through to next tier */ } } - // Tier 2: Live transcript from Web Speech API + // Tier 2: Live transcript from segments if (!transcript && this.liveTranscript.trim()) { transcript = this.liveTranscript.trim(); } @@ -229,6 +296,13 @@ class FolkVoiceRecorder extends HTMLElement { } } catch { /* continue without file */ } + // Build content: use Tiptap JSON with segments if available, else raw text + const hasFinalSegments = this.segments.some(s => s.isFinal); + const content = hasFinalSegments + ? JSON.stringify(this.segmentsToTiptapJSON()) + : (this.finalTranscript || ''); + const contentFormat = hasFinalSegments ? 'tiptap-json' : undefined; + // Create the note const tagList = this.tags.split(',').map(t => t.trim()).filter(Boolean); tagList.push('voice'); @@ -240,7 +314,8 @@ class FolkVoiceRecorder extends HTMLElement { body: JSON.stringify({ notebook_id: this.selectedNotebookId, title: `Voice Note — ${new Date().toLocaleDateString()}`, - content: this.finalTranscript || '', + content, + content_format: contentFormat, type: 'AUDIO', tags: tagList, file_url: fileUrl, @@ -252,6 +327,7 @@ class FolkVoiceRecorder extends HTMLElement { this.state = 'idle'; this.finalTranscript = ''; this.liveTranscript = ''; + this.segments = []; this.audioBlob = null; if (this.audioUrl) { URL.revokeObjectURL(this.audioUrl); this.audioUrl = null; } this.render(); @@ -271,6 +347,7 @@ class FolkVoiceRecorder extends HTMLElement { this.state = 'idle'; this.finalTranscript = ''; this.liveTranscript = ''; + this.segments = []; this.audioBlob = null; this.audioUrl = null; this.elapsedSeconds = 0; @@ -319,7 +396,7 @@ class FolkVoiceRecorder extends HTMLElement {
${this.formatTime(this.elapsedSeconds)}

Recording...

- ${this.liveTranscript ? `
${esc(this.liveTranscript)}
` : ''} +
`; break; @@ -357,6 +434,11 @@ class FolkVoiceRecorder extends HTMLElement { ${this.progressMessage && this.state === 'idle' ? `
${esc(this.progressMessage)}
` : ''} `; this.attachListeners(); + + // Re-render segments after DOM is in place (recording state) + if (this.state === 'recording' && this.segments.length > 0) { + this.renderTranscriptSegments(); + } } private attachListeners() { @@ -427,11 +509,26 @@ class FolkVoiceRecorder extends HTMLElement { } .recording-timer { font-size: 48px; font-weight: 700; font-variant-numeric: tabular-nums; } .recording-status { color: var(--rs-error, #ef4444); font-weight: 500; } - .live-transcript { - max-width: 500px; padding: 12px 16px; border-radius: 8px; - background: var(--rs-bg-surface-raised); font-size: 14px; line-height: 1.6; - text-align: left; max-height: 200px; overflow-y: auto; color: var(--rs-text-secondary); + + /* Live transcript segments */ + .live-transcript-segments { + width: 100%; max-width: 500px; max-height: 250px; overflow-y: auto; + text-align: left; padding: 8px 0; } + .transcript-segment { + display: flex; gap: 8px; padding: 4px 12px; border-radius: 4px; + font-size: 14px; line-height: 1.6; + } + .transcript-segment.interim { + font-style: italic; color: var(--rs-text-muted); + background: var(--rs-bg-surface-raised); + } + .segment-time { + flex-shrink: 0; font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 12px; color: var(--rs-text-muted); padding-top: 2px; + } + .segment-text { flex: 1; } + .stop-btn { padding: 12px 32px; border-radius: 50px; border: none; background: var(--rs-text-primary); color: var(--rs-bg-surface); font-size: 15px; font-weight: 600; diff --git a/modules/rnotes/mod.ts b/modules/rnotes/mod.ts index f10b795..003a319 100644 --- a/modules/rnotes/mod.ts +++ b/modules/rnotes/mod.ts @@ -1097,6 +1097,148 @@ routes.post("/api/voice/diarize", async (c) => { } }); +// ── Note summarization ── + +const NOTEBOOK_API_URL = process.env.NOTEBOOK_API_URL || "http://open-notebook:5055"; + +// POST /api/notes/summarize — Quick summarization via Gemini/Ollama +routes.post("/api/notes/summarize", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const { content, model = "gemini-flash", length = "medium" } = await c.req.json<{ + content: string; model?: string; length?: "short" | "medium" | "long"; + }>(); + if (!content?.trim()) return c.json({ error: "Content is required" }, 400); + + const lengthGuide: Record = { + short: "1-2 sentences", + medium: "3-5 sentences (a short paragraph)", + long: "2-3 paragraphs with key points as bullet points", + }; + + const systemPrompt = `You are a note summarizer. Summarize the following note content in ${lengthGuide[length] || lengthGuide.medium}. Be concise, capture the key ideas, and preserve any action items or decisions. Do not add commentary — return only the summary.`; + + try { + // Use the internal /api/prompt endpoint + const origin = new URL(c.req.url).origin; + const res = await fetch(`${origin}/api/prompt`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model, + messages: [ + { role: "user", content: `${systemPrompt}\n\n---\n\n${content}` }, + ], + }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return c.json({ error: (err as any).error || "Summarization failed" }, 502); + } + + const data = await res.json() as { content: string }; + return c.json({ summary: data.content, model }); + } catch (err) { + console.error("[Notes] Summarize error:", err); + return c.json({ error: "Summarization service unavailable" }, 502); + } +}); + +// POST /api/notes/deep-summarize — RAG-enhanced multi-note analysis via open-notebook +routes.post("/api/notes/deep-summarize", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const { noteIds, query } = await c.req.json<{ noteIds: string[]; query?: string }>(); + if (!noteIds?.length) return c.json({ error: "noteIds required" }, 400); + + // Gather note contents from Automerge docs + const space = c.req.param("space") || "demo"; + const contents: { id: string; title: string; content: string }[] = []; + + if (_syncServer) { + // Scan all notebook docs for matching note IDs + const prefix = `${space}:notes:notebooks:`; + for (const [docId, doc] of (_syncServer as any)._docs?.entries?.() || []) { + if (typeof docId === 'string' && docId.startsWith(prefix)) { + const nbDoc = doc as import("./schemas").NotebookDoc; + if (!nbDoc?.items) continue; + for (const nid of noteIds) { + const item = nbDoc.items[nid]; + if (item) contents.push({ id: nid, title: item.title, content: item.contentPlain || item.content }); + } + } + } + } + + if (contents.length === 0) return c.json({ error: "No matching notes found" }, 404); + + try { + // Call open-notebook API for RAG-enhanced summary + const res = await fetch(`${NOTEBOOK_API_URL}/api/v1/summarize`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sources: contents.map(n => ({ title: n.title, content: n.content })), + query: query || "Provide a comprehensive summary of these notes, highlighting key themes, decisions, and action items.", + }), + signal: AbortSignal.timeout(120000), + }); + + if (!res.ok) { + console.error("[Notes] Deep summarize upstream error:", res.status); + return c.json({ error: "Deep summarization service error" }, 502); + } + + const result = await res.json() as { summary: string }; + return c.json({ summary: result.summary, sources: contents.map(n => n.id) }); + } catch (err) { + console.error("[Notes] Deep summarize error:", err); + return c.json({ error: "Deep summarization service unavailable" }, 502); + } +}); + +// POST /api/notes/send-to-notebook — Send note content to open-notebook for RAG indexing +routes.post("/api/notes/send-to-notebook", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const { noteId, title, content } = await c.req.json<{ + noteId: string; title: string; content: string; + }>(); + if (!noteId || !content?.trim()) return c.json({ error: "noteId and content are required" }, 400); + + try { + const formData = new FormData(); + formData.append("type", "text"); + formData.append("content", content); + if (title) formData.append("title", title); + formData.append("async_processing", "true"); + + const res = await fetch(`${NOTEBOOK_API_URL}/api/sources`, { + method: "POST", + body: formData, + signal: AbortSignal.timeout(30000), + }); + + if (!res.ok) { + console.error("[Notes] Send to notebook upstream error:", res.status); + return c.json({ error: "Open notebook service error" }, 502); + } + + const result = await res.json() as { id: string; command_id?: string }; + return c.json({ sourceId: result.id, message: "Sent to open notebook for processing" }); + } catch (err) { + console.error("[Notes] Send to notebook error:", err); + return c.json({ error: "Open notebook service unavailable" }, 502); + } +}); + // POST /api/articles/unlock — Find archived version of a paywalled article routes.post("/api/articles/unlock", async (c) => { const token = extractToken(c.req.raw.headers); @@ -1114,6 +1256,46 @@ routes.post("/api/articles/unlock", async (c) => { } }); +// ── Extension download ── + +// GET /extension/download — Serve the rNotes Chrome extension as a zip +routes.get("/extension/download", async (c) => { + const JSZip = (await import("jszip")).default; + const { readdir, readFile } = await import("fs/promises"); + const { join, resolve } = await import("path"); + + const extDir = resolve(import.meta.dir, "../../../rnotes-online/browser-extension"); + const zip = new JSZip(); + + async function addDir(dir: string, prefix: string) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + const zipPath = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + await addDir(fullPath, zipPath); + } else { + const data = await readFile(fullPath); + zip.file(zipPath, data); + } + } + } + + try { + await addDir(extDir, ""); + const buf = await zip.generateAsync({ type: "arraybuffer" }); + return new Response(buf, { + headers: { + "Content-Type": "application/zip", + "Content-Disposition": 'attachment; filename="rnotes-extension.zip"', + }, + }); + } catch (err) { + console.error("[Notes] Extension zip error:", err); + return c.json({ error: "Extension files not found" }, 404); + } +}); + // ── Page routes ── // GET /voice — Standalone voice recorder page diff --git a/modules/rnotes/schemas.ts b/modules/rnotes/schemas.ts index 0ef9501..8c21b80 100644 --- a/modules/rnotes/schemas.ts +++ b/modules/rnotes/schemas.ts @@ -38,6 +38,9 @@ export interface NoteItem { isPinned: boolean; sortOrder: number; tags: string[]; + summary?: string; + summaryModel?: string; + openNotebookSourceId?: string; sourceRef?: SourceRef; createdAt: number; updatedAt: number;