feat(rnotes): real-time transcript rendering + open notebook integration

Add segment-based live transcription to voice recorder and in-editor
dictation, wire AUDIO note recording, and add send-to-notebook endpoint
for RAG indexing via open-notebook service.

- Add openNotebookSourceId field to NoteItem schema
- Add POST /api/notes/send-to-notebook proxy route to open-notebook
- Add dictation preview bar with interim speech below editor toolbar
- Rewrite voice recorder with TranscriptSegment-based live rendering
- Convert transcript segments to Tiptap JSON with timestamps on save
- Wire Record button in AUDIO note view with full MediaRecorder flow
- Add Send to Notebook button + Indexed badge in note meta panel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 13:20:31 -07:00
parent fa3441269b
commit 9de37d7405
4 changed files with 804 additions and 48 deletions

View File

@ -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<string, string> = {
undo: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 6 2 8 4 10"/><path d="M2 8h8a4 4 0 0 1 0 8H8"/></svg>',
redo: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="12 6 14 8 12 10"/><path d="M14 8H6a4 4 0 0 0 0 8h2"/></svg>',
mic: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="1" width="6" height="9" rx="3"/><path d="M3 7v1a5 5 0 0 0 10 0V7"/><line x1="8" y1="13" x2="8" y2="15"/><line x1="5.5" y1="15" x2="10.5" y2="15"/></svg>',
summarize: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 1l1.5 3 3.5.5-2.5 2.5.5 3.5L8 9l-3 1.5.5-3.5L3 4.5l3.5-.5z"/><line x1="4" y1="13" x2="12" y2="13"/><line x1="5" y1="15" x2="11" y2="15"/></svg>',
};
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<typeof setTimeout> | 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<typeof setInterval> | null = null;
private audioRecordingDictation: SpeechDictation | null = null;
// Automerge sync state (via shared runtime)
private doc: Automerge.Doc<NotebookDoc> | 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%)</code></pre><p><em>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%)</code></pre><p><em>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 = `
<div class="audio-recording-ui">
<div class="audio-recording-header">
<div class="recording-pulse-sm"></div>
<span class="audio-recording-timer">0:00</span>
<button class="rapp-nav__btn" id="btn-stop-recording" style="background:var(--rs-error,#ef4444)">Stop</button>
</div>
<div class="live-transcript-segments audio-live-segments"></div>
</div>
`;
}
// 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 => `
<div class="transcript-segment${seg.isFinal ? '' : ' interim'}">
<span class="segment-time">[${fmt(seg.timestamp)}]</span>
<span class="segment-text">${esc(seg.text)}</span>
</div>
`).join('');
container.scrollTop = container.scrollHeight;
}
/** Shared title input wiring for all editor types */
@ -1238,6 +1536,18 @@ Gear: EUR 400 (10%)</code></pre><p><em>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%)</code></pre><p><em>Maya is tracking expenses in rF
<div class="toolbar-group">
${btn('mic', 'Voice Dictation')}
</div>` : ''}
<div class="toolbar-sep"></div>
<div class="toolbar-group">
${btn('summarize', 'Summarize Note')}
</div>
</div>`;
}
@ -1398,6 +1712,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>Maya is tracking expenses in rF
// Nav is handled by mountEditor's content, or we just show back button
this.navZone.innerHTML = `
<div class="rapp-nav">
<button class="rapp-nav__back" data-back="${this.selectedNotebook ? "notebook" : "notebooks"}">
${this._history.canGoBack ? `<button class="rapp-nav__back" data-back="prev">
\u2190 ${this.selectedNotebook ? this.esc(this.selectedNotebook.title) : "Notebooks"}
</button>
</button>` : ''}
<span style="flex:1"></span>
</div>`;
// 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%)</code></pre><p><em>Maya is tracking expenses in rF
];
this.navZone.innerHTML = `
<div class="rapp-nav">
<button class="rapp-nav__back" data-back="notebooks">\u2190 Notebooks</button>
${this._history.canGoBack ? '<button class="rapp-nav__back" data-back="notebooks">\u2190 Notebooks</button>' : ''}
<span class="rapp-nav__title">${this.esc(nb.title)}${syncBadge}</span>
<button class="rapp-nav__btn rapp-nav__btn--secondary" id="btn-export-notebook" title="Export this notebook">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 10V2M5 5l3-3 3 3"/><path d="M2 12v2h12v-2"/></svg>
@ -1631,6 +1961,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
Import / Export
</button>
<button class="rapp-nav__btn" id="create-notebook">+ New Notebook</button>
<button class="rapp-nav__btn rapp-nav__btn--secondary" id="btn-tour" title="Guided Tour">Tour</button>
</div>
<input class="search-bar" type="text" placeholder="Search notes..." id="search-input" value="${this.esc(this.searchQuery)}">`;
}
@ -1688,6 +2019,13 @@ Gear: EUR 400 (10%)</code></pre><p><em>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 = `
<div class="note-meta-bar">
<span>Type: ${n.type}</span>
@ -1696,7 +2034,47 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
${n.tags ? n.tags.map((t) => `<span class="tag">${this.esc(t)}</span>`).join("") : ""}
${isAutomerge ? '<span class="meta-live">Live</span>' : ""}
${isDemo ? '<span class="meta-demo">Demo</span>' : ""}
</div>`;
</div>
${summary ? `
<div class="note-summary-panel">
<div class="note-summary-header" data-action="toggle-summary">
<span class="note-summary-icon">${ICONS.summarize}</span>
<span>Summary</span>
<span class="note-summary-model">${this.esc(summaryModel)}</span>
<button class="note-summary-regen" data-action="regenerate-summary" title="Regenerate summary">\u{21BB}</button>
<span class="note-summary-chevron">\u{25BC}</span>
</div>
<div class="note-summary-body">${this.esc(summary)}</div>
</div>` : ''}
${!isDemo ? `
<div class="note-actions-bar">
<button class="note-action-btn" data-action="send-to-notebook" ${openNotebookSourceId ? 'disabled' : ''}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v8M5 7l3-3 3 3"/><path d="M2 12v2h12v-2"/></svg>
${openNotebookSourceId ? 'Sent to Notebook' : 'Send to Notebook'}
</button>
${openNotebookSourceId ? '<span class="note-action-badge">Indexed</span>' : ''}
</div>` : ''}`;
// 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%)</code></pre><p><em>Maya is tracking expenses in rF
const typeBorder = this.getTypeBorderColor(n.type);
return `
<div class="note-item" data-note="${n.id}" style="border-left: 3px solid ${typeBorder}">
<div class="note-item" data-note="${n.id}" data-collab-id="note:${n.id}" style="border-left: 3px solid ${typeBorder}">
<span class="note-item__icon">${this.getNoteIcon(n.type)}</span>
<div class="note-item__body">
<div class="note-item__title">${n.is_pinned ? '<span class="note-item__pin">\u{1F4CC}</span> ' : ""}${this.esc(n.title)}</div>
@ -1769,6 +2147,9 @@ Gear: EUR 400 (10%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>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,23 +2197,24 @@ Gear: EUR 400 (10%)</code></pre><p><em>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.goBack();
});
});
}
private goBack() {
this.destroyEditor();
this.view = "notebooks";
if (!isDemo) this.unsubscribeNotebook();
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;
this.render();
}
else if (target === "notebook") {
this.destroyEditor();
this.view = "notebook";
} else if (prev.view === "notebook") {
this.selectedNote = null;
this.render();
}
});
});
this.render();
}
private demoUpdateNoteField(noteId: string, field: string, value: string) {
@ -2040,6 +2426,30 @@ Gear: EUR 400 (10%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>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;

View File

@ -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 => `
<div class="transcript-segment${seg.isFinal ? '' : ' interim'}">
<span class="segment-time">[${this.formatTime(seg.timestamp)}]</span>
<span class="segment-text">${esc(seg.text)}</span>
</div>
`).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 {
<div class="recording-pulse"></div>
<div class="recording-timer">${this.formatTime(this.elapsedSeconds)}</div>
<p class="recording-status">Recording...</p>
${this.liveTranscript ? `<div class="live-transcript">${esc(this.liveTranscript)}</div>` : ''}
<div class="live-transcript-segments"></div>
<button class="stop-btn" id="btn-stop">Stop</button>
</div>`;
break;
@ -357,6 +434,11 @@ class FolkVoiceRecorder extends HTMLElement {
${this.progressMessage && this.state === 'idle' ? `<div class="toast">${esc(this.progressMessage)}</div>` : ''}
`;
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;

View File

@ -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<string, string> = {
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

View File

@ -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;