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:
parent
fa3441269b
commit
9de37d7405
|
|
@ -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,25 +2197,26 @@ 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.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%)</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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue