Skip to content
Merged
2 changes: 2 additions & 0 deletions packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ export interface DataClient {
aiSearch(query: string, limit?: number): Promise<AiSearchResponse>;
aiTasks(goal: string, context?: string): Promise<AiTasksResponse>;
aiDownloadModel(url?: string): Promise<AiStatus['download']>;
/** False for clients without a transcription transport (for example the local store). */
readonly supportsTranscription?: boolean;
transcribeAsset(assetId: string, pageId: string): Promise<AiTranscriptionResult>;
aiComplete(text: string, onToken: (token: string) => void, opts?: {instruction?: string; signal?: AbortSignal}): Promise<string>;
aiGenerate(prompt: string, onToken: (token: string) => void, opts?: {system?: string; maxTokens?: number; signal?: AbortSignal}): Promise<string>;
Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/localClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,8 @@ export class LocalDataClient implements DataClient {
return Promise.reject(this.aiUnavailable());
}

readonly supportsTranscription = false;

transcribeAsset(): Promise<AiTranscriptionResult> {
return Promise.reject(this.aiUnavailable());
}
Expand Down
5 changes: 4 additions & 1 deletion packages/ui/src/blockeditor/BlockEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2584,7 +2584,10 @@ const BlockBody: React.FC<RowShared & {block: BlockMap}> = ({block, ...shared})
stays live for a reader; `pageReadOnly` is the document's real
lock, which that override must not hide from a block that offers
to write somewhere else. See {@link CustomBlockProps.pageReadOnly}. */}
<Custom block={block} editor={kitEditor} pageReadOnly={editor.readOnly} />
<Custom block={block} editor={kitEditor} pageReadOnly={editor.readOnly}>
{custom.type === 'meeting' && blockChildren(block) &&
<BlockList list={blockChildren(block)!} {...shared} editor={textEditor} depth={shared.depth + 1} container="group" />}
</Custom>
</div>
);
}
Expand Down
14 changes: 0 additions & 14 deletions packages/ui/src/blockeditor/MeetingBlockPlaceholder.tsx

This file was deleted.

113 changes: 113 additions & 0 deletions packages/ui/src/blockeditor/MeetingBlockView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import {useEffect, useMemo, useState, useSyncExternalStore} from 'react';
import {t} from '@/i18n';
import {assetBridge, subscribeAssetBridge} from '@/lib/assetBridge';
import {getPageIdForDoc} from '@/lib/aiBridge';
import {blockChildren, blockProp, insertBlock} from './model';
import type {CustomBlockDef, CustomBlockProps} from './registry';
import {useKitLock} from './kit/lock';
import {KitFrame, NameDescriptionFields} from './kit/KitFrame';
import {chunkKey, meetingRecorder, meetingTime, type MeetingAudio, type MeetingSegment} from './meetingRecorder';

function ChunkAudio({audio, blob}: {audio: MeetingAudio; blob?: Blob}) {
const [url, setUrl] = useState('');
useEffect(() => {
let disposed = false;
let objectUrl = '';
const load = async (): Promise<void> => {
const asset = blob ? null : await assetBridge.getAsset(audio.assetId);
const data = blob ?? (asset ? new Blob([new Uint8Array(asset.bytes)], {type: asset.mime}) : null);
if (disposed || !data) return;
objectUrl = URL.createObjectURL(data); setUrl(objectUrl);
};
void load().catch(() => {});
return () => { disposed = true; if (objectUrl) URL.revokeObjectURL(objectUrl); };
}, [audio.assetId, blob]);
return url ? <>
<audio controls preload="metadata" src={url} aria-label={t('meetingBlock.playback', {time: meetingTime(audio.startedAtMs ?? 0)})} />
{blob && <a href={url} download={`meeting-${audio.startedAtMs ?? 0}.${blob.type.includes('mp4') ? 'mp4' : blob.type.includes('ogg') ? 'ogg' : 'webm'}`}>{t('meetingBlock.saveAudio')}</a>}
</> : null;
}

export function MeetingBlockView({block, editor, pageReadOnly, children}: CustomBlockProps) {
const locked = useKitLock();
const readOnly = editor.readOnly || pageReadOnly || locked;
const session = meetingRecorder(editor, block, getPageIdForDoc(editor.doc) ?? '');
session.readOnly = readOnly;
const chromeEditor = useMemo(() => ({...editor, readOnly}), [editor, readOnly]);
useSyncExternalStore(session.subscribe, session.snapshot, session.snapshot);
const [, tick] = useState(0);
const active = session.active;
useEffect(() => {
if (!active) return;
const timer = setInterval(() => tick((n) => n + 1), 1000);
return () => clearInterval(timer);
}, [active]);
useEffect(() => () => session.stop(), [session]);
useEffect(() => { if (readOnly) session.stop(); }, [readOnly, session]);
const saved = blockProp<MeetingAudio[]>(block, 'audioChunks') ?? [];
const completed = blockProp<string[]>(block, 'transcriptionCompleted') ?? [];
const transcript = blockProp<MeetingSegment[]>(block, 'transcript') ?? [];
const elapsed = session.elapsedMs;
const status = session.active ? session.mode : blockProp<string>(block, 'status') ?? 'idle';
const statusLabel = t(`meetingBlock.${status as 'idle' | 'recording' | 'processing' | 'done' | 'paused' | 'requesting'}`);
const canTranscribe = useSyncExternalStore(subscribeAssetBridge, assetBridge.canTranscribe, assetBridge.canTranscribe);
const control = <section className="obe-meeting" aria-label={t('meetingBlock.label')}>
<header>
<span role="status" aria-live="polite">{statusLabel}</span>
<span className="obe-meeting-elapsed" aria-hidden="true">{meetingTime(elapsed)}</span>
</header>
{readOnly && <p className="obe-meeting-lock">{t(!pageReadOnly && locked ? 'meetingBlock.locked' : 'meetingBlock.readOnly')}</p>}
{session.recordingElsewhere && <p>{t('meetingBlock.elsewhere')}</p>}
<div className="obe-meeting-controls">
<button type="button" disabled={readOnly || session.mode === 'processing' || session.recordingElsewhere}
aria-busy={session.mode === 'requesting'}
onClick={() => {
if (session.mode === 'recording') session.pause();
else if (session.mode === 'paused') session.resume();
else void session.start();
}}>
{t(`meetingBlock.${session.mode === 'recording' ? 'pause' : session.mode === 'paused' ? 'resume' : session.mode === 'requesting' ? 'requesting' : 'record'}`)}
</button>
<button type="button" disabled={readOnly || !session.active} onClick={() => session.stop()}>{t('meetingBlock.stop')}</button>
</div>
{session.error && <p role="alert">{session.error}</p>}
{!canTranscribe && <p>{t('meetingBlock.noAI')}</p>}
<ol className="obe-meeting-chunks">
{saved.map((audio) => {
const progress = session.chunks.find((chunk) => chunkKey(chunk.audio) === chunkKey(audio));
const done = completed.includes(chunkKey(audio));
return <li key={chunkKey(audio)}>
<span>{meetingTime(audio.startedAtMs ?? 0)} · {meetingTime(audio.durationMs)}</span>
<ChunkAudio audio={audio} />
<span aria-live="polite">{t(`meetingBlock.${done ? 'transcribed' : progress?.phase === 'transcribing' ? 'transcribing' : 'untranscribed'}`)}</span>
{progress?.error && <p role="alert">{progress.error}</p>}
{!done && <button type="button" disabled={readOnly || !canTranscribe || progress?.busy} onClick={() => session.retry(audio)}>{t('meetingBlock.retryTranscription')}</button>}
</li>;
})}
{session.chunks.filter((chunk) => chunk.blob).map((chunk) => <li key={chunk.id}>
<span role="status">{t(`meetingBlock.${chunk.phase === 'uploadFailed' ? 'uploadFailed' : 'uploading'}`)}</span>
<ChunkAudio audio={chunk.audio} blob={chunk.blob} />
{chunk.phase === 'uploadFailed' && <button type="button" disabled={readOnly} onClick={() => void session.upload(chunk)}>{t('meetingBlock.retryUpload')}</button>}
</li>)}
</ol>
<section className="obe-meeting-transcript" aria-label={t('meetingBlock.transcript')}>
<h4>{t('meetingBlock.transcript')}</h4>
{transcript.map((segment, index) => <p key={index}><time>{meetingTime(segment.startMs)}</time> {segment.text}</p>)}
</section>
{blockProp<string>(block, 'summary') && <p className="obe-meeting-summary">{blockProp<string>(block, 'summary')}</p>}
<section className="obe-meeting-notes" aria-label={t('meetingBlock.notes')}><h4>{t('meetingBlock.notes')}</h4>{children}
{!readOnly && !blockChildren(block)?.length && <button type="button" onClick={() => editor.doc.transact(() => {
const notes = blockChildren(block);
if (notes) insertBlock(editor.doc, notes, 0, {type: 'paragraph'});
}, 'local')}>{t('meetingBlock.addNote')}</button>}</section>
</section>;
return <KitFrame block={block} editor={chromeEditor} kind="meeting" defaultName={t('meetingBlock.label')}
labelKey="title" symbol={false} control={control}
config={<NameDescriptionFields block={block} editor={chromeEditor} nameKey="title" namePlaceholder={t('meetingBlock.label')} />} />;
}

export const MEETING_BLOCK: CustomBlockDef = {
type: 'meeting', render: MeetingBlockView,
slash: {label: 'Meeting', hint: 'Record audio, transcribe and take notes', keywords: 'meeting record recorder audio microphone transcript notes', group: 'interactive',
make: () => ({type: 'meeting', children: [{type: 'paragraph'}]})},
};
2 changes: 1 addition & 1 deletion packages/ui/src/blockeditor/SlashMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ const TYPE_ICONS: Record<string, IconComp> = {
// June-2026 additions
choicecards: GalleryHorizontalEnd, longtext: AlignLeft, richtext: TextQuote,
searchselect: Search, tagfield: Tag, progressbar: Activity,
form: ClipboardList,
form: ClipboardList, meeting: Mic,
};
const GROUP_ICON: Record<SlashGroup, IconComp> = {
pages: FileText, basic: Type, interactive: Boxes, extensions: Puzzle, ai: Sparkles,
Expand Down
Loading
Loading