From 36c0ae09a40661d33f5a5478ee3643c483b56dc4 Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Sun, 4 Oct 2026 11:02:43 +0800 Subject: [PATCH 1/2] feat(ui): add AI meeting summaries (MEET-6) --- .../ui/src/blockeditor/MeetingBlockView.tsx | 3 +- .../ui/src/blockeditor/MeetingSummary.tsx | 63 ++++++++++ .../__tests__/meetingSummary.test.tsx | 116 ++++++++++++++++++ packages/ui/src/components/AiBridgeHost.tsx | 10 +- packages/ui/src/i18n/messages/de.ts | 8 ++ packages/ui/src/i18n/messages/en.ts | 8 ++ packages/ui/src/i18n/messages/ja.ts | 8 ++ packages/ui/src/i18n/messages/zh.ts | 8 ++ packages/ui/src/index.css | 2 +- packages/ui/src/lib/aiBridge.ts | 4 + 10 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/blockeditor/MeetingSummary.tsx create mode 100644 packages/ui/src/blockeditor/__tests__/meetingSummary.test.tsx 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..d56d66e3 --- /dev/null +++ b/packages/ui/src/blockeditor/__tests__/meetingSummary.test.tsx @@ -0,0 +1,116 @@ +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')])('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.forbidden' : 'meetingBlock.summaryFailed')); + }); + 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/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 74d88ec2..ea2a7dc3 100644 --- a/packages/ui/src/i18n/messages/de.ts +++ b/packages/ui/src/i18n/messages/de.ts @@ -637,6 +637,14 @@ export const de: PartialMessages = { urlLabel: 'Link-URL', }, meetingBlock: { + summary: 'Zusammenfassung', + summaryOffer: 'Dein Transkript kann jetzt zusammengefasst werden.', + generateSummary: 'Zusammenfassung erstellen', + regenerateSummary: 'Zusammenfassung neu erstellen', + generatingSummary: 'Zusammenfassung wird erstellt…', + summaryUnavailable: 'Verbinde einen OpenBook-Server und konfiguriere KI unter Einstellungen → KI, um dieses Meeting zusammenzufassen.', + 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 298154c9..814da35c 100644 --- a/packages/ui/src/i18n/messages/en.ts +++ b/packages/ui/src/i18n/messages/en.ts @@ -1078,6 +1078,14 @@ export const en = { noDatabases: 'No databases found', }, meetingBlock: { + summary: 'Summary', + summaryOffer: 'Your transcript is ready to summarize.', + generateSummary: 'Generate summary', + regenerateSummary: 'Regenerate summary', + generatingSummary: 'Generating summary…', + summaryUnavailable: 'Connect an OpenBook server and configure AI in Settings → AI to summarize this meeting.', + 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 ab4f9a78..cbe072e3 100644 --- a/packages/ui/src/i18n/messages/ja.ts +++ b/packages/ui/src/i18n/messages/ja.ts @@ -632,6 +632,14 @@ export const ja: PartialMessages = { urlLabel: 'リンク URL', }, meetingBlock: { + summary: '要約', + summaryOffer: '文字起こしを要約できます。', + generateSummary: '要約を生成', + regenerateSummary: '要約を再生成', + generatingSummary: '要約を生成中…', + summaryUnavailable: 'OpenBook サーバーに接続し、設定 → AI で AI を構成すると会議を要約できます。', + summaryFailed: '要約の生成に失敗しました。以前の要約は変更されていません。再試行してください。', + locked: 'セクションにより会議がロックされています', readOnly: '読み取り専用の会議', elsewhere: '別のクライアントで録音または処理中です。', diff --git a/packages/ui/src/i18n/messages/zh.ts b/packages/ui/src/i18n/messages/zh.ts index 604f6096..64bbcf53 100644 --- a/packages/ui/src/i18n/messages/zh.ts +++ b/packages/ui/src/i18n/messages/zh.ts @@ -629,6 +629,14 @@ export const zh: PartialMessages = { urlLabel: '链接地址', }, meetingBlock: { + summary: '摘要', + summaryOffer: '转录已准备好,可以生成摘要。', + generateSummary: '生成摘要', + regenerateSummary: '重新生成摘要', + generatingSummary: '正在生成摘要…', + summaryUnavailable: '连接 OpenBook 服务器并在设置 → AI 中配置 AI,以生成会议摘要。', + 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 => From 33a5586c622e6051f75ea1a64d72c079911fe86a Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Sun, 4 Oct 2026 11:11:23 +0800 Subject: [PATCH 2/2] fix(ui): summary cancel, 403 copy and export paragraphs (MEET-6) --- .../ui/src/blockeditor/MeetingSummary.tsx | 6 +-- .../__tests__/meetingSummary.test.tsx | 42 ++++++++++++++++++- packages/ui/src/blockeditor/exportBlocks.ts | 2 +- packages/ui/src/i18n/messages/de.ts | 2 + packages/ui/src/i18n/messages/en.ts | 2 + packages/ui/src/i18n/messages/ja.ts | 2 + packages/ui/src/i18n/messages/zh.ts | 2 + 7 files changed, 52 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/blockeditor/MeetingSummary.tsx b/packages/ui/src/blockeditor/MeetingSummary.tsx index 8c1fc54e..50d64fc3 100644 --- a/packages/ui/src/blockeditor/MeetingSummary.tsx +++ b/packages/ui/src/blockeditor/MeetingSummary.tsx @@ -41,7 +41,7 @@ export function MeetingSummary({block, editor, readOnly, status, transcript}: { } catch (cause) { if (abort.signal.aborted) return; const forbidden = (cause as {status?: number} | null)?.status === 403 || /\b403\b/.test(String(cause)); - setError(t(forbidden ? 'meetingBlock.forbidden' : 'meetingBlock.summaryFailed')); + setError(t(forbidden ? 'meetingBlock.summaryForbidden' : 'meetingBlock.summaryFailed')); } finally { if (pending.current === abort) { pending.current = null; setStream(null); } } @@ -51,8 +51,8 @@ export function MeetingSummary({block, editor, readOnly, status, transcript}: {

{t('meetingBlock.summary')}

{hasTranscript && <> {status === 'done' && !summary && stream === null &&

{t('meetingBlock.summaryOffer')}

} - {!available &&

{t('meetingBlock.summaryUnavailable')}

} } diff --git a/packages/ui/src/blockeditor/__tests__/meetingSummary.test.tsx b/packages/ui/src/blockeditor/__tests__/meetingSummary.test.tsx index d56d66e3..5e96194c 100644 --- a/packages/ui/src/blockeditor/__tests__/meetingSummary.test.tsx +++ b/packages/ui/src/blockeditor/__tests__/meetingSummary.test.tsx @@ -66,12 +66,50 @@ describe('MEET-6 meeting summary', () => { 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')])('preserves prior summary on errors: %s', async (error) => { + 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.forbidden' : 'meetingBlock.summaryFailed')); + 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; 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/i18n/messages/de.ts b/packages/ui/src/i18n/messages/de.ts index ea2a7dc3..58c86961 100644 --- a/packages/ui/src/i18n/messages/de.ts +++ b/packages/ui/src/i18n/messages/de.ts @@ -641,8 +641,10 @@ export const de: PartialMessages = { 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', diff --git a/packages/ui/src/i18n/messages/en.ts b/packages/ui/src/i18n/messages/en.ts index 814da35c..fb63d819 100644 --- a/packages/ui/src/i18n/messages/en.ts +++ b/packages/ui/src/i18n/messages/en.ts @@ -1082,8 +1082,10 @@ export const en = { 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', diff --git a/packages/ui/src/i18n/messages/ja.ts b/packages/ui/src/i18n/messages/ja.ts index cbe072e3..48d5450b 100644 --- a/packages/ui/src/i18n/messages/ja.ts +++ b/packages/ui/src/i18n/messages/ja.ts @@ -636,8 +636,10 @@ export const ja: PartialMessages = { summaryOffer: '文字起こしを要約できます。', generateSummary: '要約を生成', regenerateSummary: '要約を再生成', + cancelSummary: 'キャンセル', generatingSummary: '要約を生成中…', summaryUnavailable: 'OpenBook サーバーに接続し、設定 → AI で AI を構成すると会議を要約できます。', + summaryForbidden: '要約には、有料または対象のプラン、アカウントへのログイン、およびページへのアクセス権が必要です。', summaryFailed: '要約の生成に失敗しました。以前の要約は変更されていません。再試行してください。', locked: 'セクションにより会議がロックされています', diff --git a/packages/ui/src/i18n/messages/zh.ts b/packages/ui/src/i18n/messages/zh.ts index 64bbcf53..129d5324 100644 --- a/packages/ui/src/i18n/messages/zh.ts +++ b/packages/ui/src/i18n/messages/zh.ts @@ -633,8 +633,10 @@ export const zh: PartialMessages = { summaryOffer: '转录已准备好,可以生成摘要。', generateSummary: '生成摘要', regenerateSummary: '重新生成摘要', + cancelSummary: '取消', generatingSummary: '正在生成摘要…', summaryUnavailable: '连接 OpenBook 服务器并在设置 → AI 中配置 AI,以生成会议摘要。', + summaryForbidden: '生成摘要需要付费或符合条件的套餐、已登录的账号以及页面访问权限。', summaryFailed: '摘要生成失败。原有摘要未更改,请重试。', locked: '会议已被所在分区锁定',