diff --git a/packages/ui/src/blockeditor/MeetingBlockView.tsx b/packages/ui/src/blockeditor/MeetingBlockView.tsx index df1edf8a..9cb9ec24 100644 --- a/packages/ui/src/blockeditor/MeetingBlockView.tsx +++ b/packages/ui/src/blockeditor/MeetingBlockView.tsx @@ -4,6 +4,7 @@ import {assetBridge, subscribeAssetBridge} from '@/lib/assetBridge'; import {getPageIdForDoc} from '@/lib/aiBridge'; import {blockChildren, blockProp, insertBlock} from './model'; import type {CustomBlockDef, CustomBlockProps} from './registry'; +import {MeetingSummary} from './MeetingSummary'; import {useKitLock} from './kit/lock'; import {KitFrame, NameDescriptionFields} from './kit/KitFrame'; import {chunkKey, meetingRecorder, meetingTime, type MeetingAudio, type MeetingSegment} from './meetingRecorder'; @@ -94,7 +95,7 @@ export function MeetingBlockView({block, editor, pageReadOnly, children}: Custom

{t('meetingBlock.transcript')}

{transcript.map((segment, index) =>

{segment.text}

)} - {blockProp(block, 'summary') &&

{blockProp(block, 'summary')}

} +

{t('meetingBlock.notes')}

{children} {!readOnly && !blockChildren(block)?.length && + {!available &&

{t('meetingBlock.summaryUnavailable')}

} + } + {stream !== null ? t('meetingBlock.generatingSummary') : ''} + {(stream !== null || summary) &&

{stream ?? summary}

} + {error &&

{error}

} +
; +} diff --git a/packages/ui/src/blockeditor/__tests__/meetingSummary.test.tsx b/packages/ui/src/blockeditor/__tests__/meetingSummary.test.tsx new file mode 100644 index 00000000..5e96194c --- /dev/null +++ b/packages/ui/src/blockeditor/__tests__/meetingSummary.test.tsx @@ -0,0 +1,154 @@ +import {act, cleanup, fireEvent, render, screen} from '@testing-library/react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {setAiBridge} from '@/lib/aiBridge'; +import {setLocale, t} from '@/i18n'; +import {AiBridgeHost} from '@/components/AiBridgeHost'; +import {DataProvider} from '@/data'; +import type {DataClient} from '@book.dev/sdk'; +import {MeetingBlockView} from '../MeetingBlockView'; +import {meetingSummaryPrompt} from '../MeetingSummary'; +import {blockProp, createDoc, docToJSON, rootBlocks} from '../model'; +import type {BlockEditorController} from '../useBlockEditor'; +import {blocksToHtml, blocksToMarkdown, projectBlocksForExport} from '../exportBlocks'; + +const generate = vi.fn(); +const transcript = [{startMs: 0, endMs: 1000, text: 'Alex will ship on Friday.'}]; +function harness(props = {}) { + const doc = createDoc([{type: 'meeting', props: {status: 'done', transcript, ...props}, children: [{type: 'paragraph', text: 'Private manual notes'}]}]); + const block = rootBlocks(doc).get(0); + const editor = {doc, readOnly: false} as BlockEditorController; + const view = render(); + return {doc, block, ...view}; +} +function install(ready = true) { + setAiBridge({ready: () => ready, generate, complete: vi.fn(), tasks: vi.fn(), applyProposals: vi.fn(), applySuggestion: vi.fn()}); +} +beforeEach(() => { generate.mockReset(); install(); }); +afterEach(() => { cleanup(); setAiBridge(null); setLocale('en'); }); + +describe('MEET-6 meeting summary', () => { + it('offers a single-click generation on done without starting a paid call automatically', () => { + harness(); + expect(screen.getByText(t('meetingBlock.summaryOffer'))).toBeTruthy(); + expect(screen.getByRole('button', {name: 'Generate summary'})).toBeTruthy(); + expect(generate).not.toHaveBeenCalled(); + }); + it.each([{segments: []}, {segments: [{startMs: 0, endMs: 1, text: ' '}]}])('does not offer an empty transcript', ({segments}) => { + harness({transcript: segments}); + expect(screen.queryByRole('button', {name: 'Generate summary'})).toBeNull(); + expect(screen.queryByText(t('meetingBlock.summaryOffer'))).toBeNull(); + }); + it('offers explicit generation before done', () => { + harness({status: 'idle'}); + expect(screen.getByRole('button', {name: 'Generate summary'})).toBeTruthy(); + expect(screen.queryByText(t('meetingBlock.summaryOffer'))).toBeNull(); + }); + it('streams ephemerally and replaces only summary in one transaction, including exports', async () => { + let finish!: (text: string) => void; + generate.mockImplementation((_prompt, token) => { token('New partial'); return new Promise((resolve) => { finish = resolve; }); }); + const {doc, block} = harness({summary: 'Old summary'}); + const before = docToJSON(doc); + const update = vi.fn(); doc.on('update', update); + fireEvent.click(screen.getByRole('button', {name: 'Regenerate summary'})); + expect(screen.getByText('New partial')).toBeTruthy(); + expect(screen.getByText('Generating summary…').getAttribute('aria-live')).toBe('polite'); + expect(blockProp(block, 'summary')).toBe('Old summary'); expect(update).not.toHaveBeenCalled(); + await act(async () => finish('New final summary')); + expect(update).toHaveBeenCalledTimes(1); + expect(docToJSON(doc)).toEqual([{...before[0], props: {...before[0].props, summary: 'New final summary'}}]); + for (const output of [blocksToHtml(docToJSON(doc)), blocksToMarkdown(docToJSON(doc)), JSON.stringify(projectBlocksForExport(docToJSON(doc)))]) { + expect(output).toContain('New final summary'); expect(output).toContain('Private manual notes'); + } + }); + it.each([false, null])('disables generation without a ready AI bridge (%s)', (ready) => { + if (ready === null) setAiBridge(null); else install(ready); + harness(); + expect((screen.getByRole('button', {name: 'Generate summary'}) as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText(t('meetingBlock.summaryUnavailable'))).toBeTruthy(); + }); + it.each([new Error('403 Forbidden'), Object.assign(new Error('Denied'), {status: 403}), new Error('Stream disconnected'), new Error('OpenBook request failed (400 Bad Request): AI is not configured')])('preserves prior summary on errors: %s', async (error) => { + generate.mockImplementation(async (_prompt, token) => { token('Incomplete'); throw error; }); + const {doc} = harness({summary: 'Old summary'}); const before = docToJSON(doc); + await act(async () => fireEvent.click(screen.getByRole('button', {name: 'Regenerate summary'}))); + expect(docToJSON(doc)).toEqual(before); expect(screen.getByText('Old summary')).toBeTruthy(); + expect(screen.getByRole('alert').textContent).toBe(t(error.message.includes('403') || 'status' in error ? 'meetingBlock.summaryForbidden' : 'meetingBlock.summaryFailed')); + }); + it('keeps the button enabled and focused during streaming and cancels without replacing the summary', async () => { + let finish!: (text: string) => void; + let token!: (text: string) => void; + let signal!: AbortSignal; + generate.mockImplementation((_prompt, onToken, opts) => { + token = onToken; signal = opts!.signal!; + return new Promise((resolve) => { finish = resolve; }); + }); + const {doc} = harness({summary: 'Old summary'}); + const before = docToJSON(doc); + const button = screen.getByRole('button', {name: 'Regenerate summary'}) as HTMLButtonElement; + button.focus(); + fireEvent.click(button); + act(() => token('Partial summary')); + expect(screen.getByRole('button', {name: t('meetingBlock.cancelSummary')})).toBe(button); + expect(button.disabled).toBe(false); + expect(button.tabIndex).toBe(0); + expect(document.activeElement).toBe(button); + fireEvent.click(button); + expect(signal.aborted).toBe(true); + await act(async () => { token('Late token'); finish('Late result'); }); + expect(docToJSON(doc)).toEqual(before); + expect(screen.getByText('Old summary')).toBeTruthy(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.getByRole('button', {name: 'Regenerate summary'})).toBe(button); + expect(button.disabled).toBe(false); + expect(document.activeElement).toBe(button); + }); + it('exports nonempty summary lines as separate HTML and Markdown paragraphs', () => { + const {doc} = harness({summary: 'Meeting recap.\n\nDecision: ship Friday.\nAction: Alex will ship.\n'}); + const blocks = docToJSON(doc); + const html = document.createElement('div'); + html.innerHTML = blocksToHtml(blocks); + const paragraphs = Array.from(html.querySelectorAll('p'), (paragraph) => paragraph.textContent); + expect(paragraphs.slice(-4)).toEqual(['Meeting recap.', 'Decision: ship Friday.', 'Action: Alex will ship.', 'Private manual notes']); + expect(paragraphs).not.toContain(''); + expect(blocksToMarkdown(blocks)).toContain('Meeting recap.\n\nDecision: ship Friday.\n\nAction: Alex will ship.\n\nPrivate manual notes'); + }); + it('aborts on unmount and ignores even late tokens and completion', async () => { + let finish!: (text: string) => void; + let token!: (text: string) => void; + let signal!: AbortSignal; + generate.mockImplementation((_prompt, onToken, opts) => { token = onToken; signal = opts!.signal!; return new Promise((resolve) => { finish = resolve; }); }); + const {unmount, block} = harness({summary: 'Old summary'}); + fireEvent.click(screen.getByRole('button', {name: 'Regenerate summary'})); unmount(); + expect(signal.aborted).toBe(true); + await act(async () => { token('Late'); finish('Late result'); }); + expect(blockProp(block, 'summary')).toBe('Old summary'); + }); + it('publishes async readiness and forwards generation and cancellation through the host', async () => { + const client = {aiStatus: vi.fn(async () => ({ready: true})), aiGenerate: generate} as unknown as DataClient; + generate.mockResolvedValue('Host summary'); + render(); + const {block} = harness(); + await act(async () => {}); + await act(async () => fireEvent.click(screen.getByRole('button', {name: 'Generate summary'}))); + expect(generate).toHaveBeenCalledWith(expect.stringContaining(transcript[0].text), expect.any(Function), {signal: expect.any(AbortSignal)}); + expect(blockProp(block, 'summary')).toBe('Host summary'); + }); + it('disables generation for a local client whose AI status is unavailable', async () => { + const client = {aiStatus: vi.fn(async () => ({ready: false, config: {provider: 'off'}})), aiGenerate: generate} as unknown as DataClient; + render(); + harness(); + await act(async () => {}); + expect((screen.getByRole('button', {name: 'Generate summary'}) as HTMLButtonElement).disabled).toBe(true); + expect(generate).not.toHaveBeenCalled(); + }); + it.each(['en', 'de', 'ja', 'zh'] as const)('provides translated controls and errors in %s', async (locale) => { + setLocale(locale); generate.mockRejectedValue(new Error('offline')); harness(); + await act(async () => fireEvent.click(screen.getByRole('button', {name: t('meetingBlock.generateSummary')}))); + expect(screen.getByRole('alert').textContent).toBe(t('meetingBlock.summaryFailed')); + expect(screen.getByRole('region', {name: t('meetingBlock.summary')})).toBeTruthy(); + }); + it('bounds long input using the existing completion budget and discloses the excerpt', () => { + const prompt = meetingSummaryPrompt([{startMs: 0, endMs: 1, text: 'x'.repeat(10000)}]); + expect(prompt).toContain('excerpt'); expect(prompt).toContain('x'.repeat(4000)); expect(prompt).not.toContain('x'.repeat(4001)); + expect(prompt).toContain('2–3 sentence'); expect(prompt).toContain('owners only if named'); + }); +}); diff --git a/packages/ui/src/blockeditor/exportBlocks.ts b/packages/ui/src/blockeditor/exportBlocks.ts index 7a561b65..cdf06fa8 100644 --- a/packages/ui/src/blockeditor/exportBlocks.ts +++ b/packages/ui/src/blockeditor/exportBlocks.ts @@ -15,7 +15,7 @@ function meetingParagraphs(b: BlockJSON): string[] { const p = b.props ?? {}; const segments = Array.isArray(p.transcript) ? p.transcript as {startMs: number; text: string}[] : []; return [String(p.title || t('meetingBlock.label')), ...(p.status !== 'done' ? [t('meetingBlock.incomplete')] : []), - ...segments.map((segment) => `[${meetingTime(segment.startMs)}] ${segment.text}`), ...(p.summary ? [String(p.summary)] : [])]; + ...segments.map((segment) => `[${meetingTime(segment.startMs)}] ${segment.text}`), ...(p.summary ? String(p.summary).split('\n').filter(Boolean) : [])]; } // TextRun is referenced in the kit emit cases below. diff --git a/packages/ui/src/components/AiBridgeHost.tsx b/packages/ui/src/components/AiBridgeHost.tsx index 0866efea..42a135ab 100644 --- a/packages/ui/src/components/AiBridgeHost.tsx +++ b/packages/ui/src/components/AiBridgeHost.tsx @@ -1,7 +1,7 @@ import {useEffect, useRef} from 'react'; import type {AgentProposal, StoredSuggestion} from '@book.dev/sdk'; import {useData} from '@/data'; -import {applyProposal, setAiBridge, suggestionToProposal, type ProposalApplyResult} from '@/lib/aiBridge'; +import {applyProposal, setAiBridge, suggestionToProposal, type AiBridgeImpl, type ProposalApplyResult} from '@/lib/aiBridge'; /** * Installs the AI bridge (lib/aiBridge) for the provider-less block editor @@ -23,6 +23,7 @@ export function AiBridgeHost() { useEffect(() => { let cancelled = false; + readyRef.current = false; const probe = async (): Promise => { try { const status = await client.aiStatus(); @@ -30,6 +31,7 @@ export function AiBridgeHost() { } catch { if (!cancelled) readyRef.current = false; } + if (!cancelled) setAiBridge(bridge); }; void probe(); const onFocus = (): void => void probe(); @@ -58,13 +60,15 @@ export function AiBridgeHost() { await applyProposal(client, suggestionToProposal(suggestion)); }; - setAiBridge({ + const bridge: AiBridgeImpl = { ready: () => readyRef.current, + generate: (prompt, onToken, opts) => client.aiGenerate(prompt, onToken, opts), complete: (text, onToken) => client.aiComplete(text, onToken), tasks: async (goal, context) => (await client.aiTasks(goal, context)).tasks, applyProposals, applySuggestion, - }); + }; + setAiBridge(bridge); return () => { cancelled = true; window.removeEventListener('focus', onFocus); diff --git a/packages/ui/src/i18n/messages/de.ts b/packages/ui/src/i18n/messages/de.ts index c4480449..2ef7d410 100644 --- a/packages/ui/src/i18n/messages/de.ts +++ b/packages/ui/src/i18n/messages/de.ts @@ -649,6 +649,16 @@ export const de: PartialMessages = { urlLabel: 'Link-URL', }, meetingBlock: { + summary: 'Zusammenfassung', + summaryOffer: 'Dein Transkript kann jetzt zusammengefasst werden.', + generateSummary: 'Zusammenfassung erstellen', + regenerateSummary: 'Zusammenfassung neu erstellen', + cancelSummary: 'Abbrechen', + generatingSummary: 'Zusammenfassung wird erstellt…', + summaryUnavailable: 'Verbinde einen OpenBook-Server und konfiguriere KI unter Einstellungen → KI, um dieses Meeting zusammenzufassen.', + summaryForbidden: 'Zusammenfassungen erfordern einen kostenpflichtigen oder berechtigten Tarif, ein angemeldetes Konto und Zugriff auf die Seite.', + summaryFailed: 'Die Zusammenfassung konnte nicht erstellt werden. Die bisherige Zusammenfassung bleibt erhalten; versuche es erneut.', + locked: 'Besprechung durch ihren Abschnitt gesperrt', readOnly: 'Schreibgeschützte Besprechung', elsewhere: 'Aufnahme oder Verarbeitung auf einem anderen Client.', diff --git a/packages/ui/src/i18n/messages/en.ts b/packages/ui/src/i18n/messages/en.ts index d7375821..de030019 100644 --- a/packages/ui/src/i18n/messages/en.ts +++ b/packages/ui/src/i18n/messages/en.ts @@ -1090,6 +1090,16 @@ export const en = { noDatabases: 'No databases found', }, meetingBlock: { + summary: 'Summary', + summaryOffer: 'Your transcript is ready to summarize.', + generateSummary: 'Generate summary', + regenerateSummary: 'Regenerate summary', + cancelSummary: 'Cancel', + generatingSummary: 'Generating summary…', + summaryUnavailable: 'Connect an OpenBook server and configure AI in Settings → AI to summarize this meeting.', + summaryForbidden: 'Summaries require a paid or eligible plan, a signed-in account and page access.', + summaryFailed: 'Summary generation failed. Your previous summary is unchanged; try again.', + locked: 'Meeting locked by its section', readOnly: 'Read-only meeting', elsewhere: 'Recording or processing on another client.', diff --git a/packages/ui/src/i18n/messages/ja.ts b/packages/ui/src/i18n/messages/ja.ts index 89e748fa..25876bae 100644 --- a/packages/ui/src/i18n/messages/ja.ts +++ b/packages/ui/src/i18n/messages/ja.ts @@ -644,6 +644,16 @@ export const ja: PartialMessages = { urlLabel: 'リンク URL', }, meetingBlock: { + summary: '要約', + summaryOffer: '文字起こしを要約できます。', + generateSummary: '要約を生成', + regenerateSummary: '要約を再生成', + cancelSummary: 'キャンセル', + generatingSummary: '要約を生成中…', + summaryUnavailable: 'OpenBook サーバーに接続し、設定 → AI で AI を構成すると会議を要約できます。', + summaryForbidden: '要約には、有料または対象のプラン、アカウントへのログイン、およびページへのアクセス権が必要です。', + summaryFailed: '要約の生成に失敗しました。以前の要約は変更されていません。再試行してください。', + locked: 'セクションにより会議がロックされています', readOnly: '読み取り専用の会議', elsewhere: '別のクライアントで録音または処理中です。', diff --git a/packages/ui/src/i18n/messages/zh.ts b/packages/ui/src/i18n/messages/zh.ts index 769bed00..7ae8d58d 100644 --- a/packages/ui/src/i18n/messages/zh.ts +++ b/packages/ui/src/i18n/messages/zh.ts @@ -641,6 +641,16 @@ export const zh: PartialMessages = { urlLabel: '链接地址', }, meetingBlock: { + summary: '摘要', + summaryOffer: '转录已准备好,可以生成摘要。', + generateSummary: '生成摘要', + regenerateSummary: '重新生成摘要', + cancelSummary: '取消', + generatingSummary: '正在生成摘要…', + summaryUnavailable: '连接 OpenBook 服务器并在设置 → AI 中配置 AI,以生成会议摘要。', + summaryForbidden: '生成摘要需要付费或符合条件的套餐、已登录的账号以及页面访问权限。', + summaryFailed: '摘要生成失败。原有摘要未更改,请重试。', + locked: '会议已被所在分区锁定', readOnly: '只读会议', elsewhere: '正在其他客户端上录音或处理。', diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 3250ea45..f81e78ca 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -3952,5 +3952,5 @@ a.obe-mention { .obe-meeting audio { max-width: 100%; } .obe-meeting-transcript { user-select: text; white-space: pre-wrap; } .obe-meeting time { color: var(--color-muted-foreground); font-variant-numeric: tabular-nums; } -.obe-meeting-summary { white-space: pre-wrap; margin-block: 12px; } +.obe-meeting-summary { user-select: text; white-space: pre-wrap; margin-block: 12px; } .obe-meeting-notes { border-top: 1px solid var(--color-border); margin-top: 16px; padding-top: 12px; } diff --git a/packages/ui/src/lib/aiBridge.ts b/packages/ui/src/lib/aiBridge.ts index d2ec991b..967f74ee 100644 --- a/packages/ui/src/lib/aiBridge.ts +++ b/packages/ui/src/lib/aiBridge.ts @@ -94,6 +94,7 @@ export interface AiBridgeImpl { /** Engine is configured and was ready at the last status poll. */ ready: () => boolean; complete: (text: string, onToken: (token: string) => void) => Promise; + generate?: DataClient['aiGenerate']; tasks: (goal: string, context?: string) => Promise; /** Apply an approved set of agent proposals. */ applyProposals: (proposals: AgentProposal[]) => Promise; @@ -649,6 +650,9 @@ export const subscribeAiBridge = (cb: () => void): (() => void) => { export const aiBridge = { ready: (): boolean => bridge?.ready() ?? false, + canGenerate: (): boolean => !!bridge?.generate && bridge.ready(), + generate: (...args: Parameters): Promise => + bridge?.generate ? bridge.generate(...args) : Promise.reject(new Error('AI not available')), complete: (text: string, onToken: (token: string) => void): Promise => bridge ? bridge.complete(text, onToken) : Promise.reject(new Error('AI not available')), tasks: (goal: string, context?: string): Promise =>