diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index c18337e0f..e99c10364 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -1963,6 +1963,73 @@ "note": "Note" }, "menuName": "Document", + "aiMeeting": { + "titleDefault": "Meeting", + "tab": { + "summary": "Summary", + "notes": "Notes", + "transcript": "Transcript" + }, + "readOnlyHint": "This content is read-only on web. Please edit it on the desktop app.", + "notesPlaceholder": "Add your notes… AI will turn them into a clean, share-ready summary.", + "speakerUnknown": "Unknown speaker", + "speakerFallback": "Speaker {{id}}", + "reference": { + "sourcesTooltip": "View sources", + "deletedTooltip": "Source deleted", + "deleted": "Source was deleted" + }, + "copy": { + "summary": "Copy summary", + "notes": "Copy notes", + "transcript": "Copy transcript", + "summarySuccess": "Summary copied to clipboard", + "notesSuccess": "Notes copied to clipboard", + "transcriptSuccess": "Transcript copied to clipboard", + "noContent": "No content to copy" + }, + "regenerate": { + "regenerate": "Regenerate", + "generating": "Generating", + "summaryTemplate": "Summary template", + "summaryDetail": "Summary detail", + "summaryLanguage": "Summary language", + "noSource": "No transcript or notes available to regenerate summary", + "success": "Summary regenerated", + "failed": "Failed to regenerate summary", + "template": { + "auto": "Auto", + "meetingMinutes": "Meeting minutes", + "actionFocused": "Action focused", + "executive": "Executive" + }, + "detail": { + "concise": "Concise", + "balanced": "Balanced", + "detailed": "Detailed" + }, + "language": { + "english": "English", + "chineseSimplified": "Chinese (Simplified)", + "chineseTraditional": "Chinese (Traditional)", + "spanish": "Spanish", + "french": "French", + "german": "German", + "japanese": "Japanese", + "korean": "Korean", + "portuguese": "Portuguese", + "russian": "Russian", + "thai": "Thai", + "vietnamese": "Vietnamese", + "danish": "Danish", + "finnish": "Finnish", + "norwegian": "Norwegian", + "dutch": "Dutch", + "italian": "Italian", + "swedish": "Swedish" + } + } + }, "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" diff --git a/src/@types/translations/zh-CN.json b/src/@types/translations/zh-CN.json index 52a4ed985..5801039ac 100644 --- a/src/@types/translations/zh-CN.json +++ b/src/@types/translations/zh-CN.json @@ -1243,6 +1243,72 @@ }, "document": { "menuName": "文档", + "aiMeeting": { + "titleDefault": "会议", + "tab": { + "summary": "摘要", + "notes": "笔记", + "transcript": "转录" + }, + "readOnlyHint": "该内容在 Web 上为只读,请在桌面端编辑。", + "speakerUnknown": "未知发言者", + "speakerFallback": "发言者 {{id}}", + "reference": { + "sourcesTooltip": "查看来源", + "deletedTooltip": "来源已删除", + "deleted": "来源已删除" + }, + "copy": { + "summary": "复制摘要", + "notes": "复制笔记", + "transcript": "复制转录", + "summarySuccess": "摘要已复制到剪贴板", + "notesSuccess": "笔记已复制到剪贴板", + "transcriptSuccess": "转录已复制到剪贴板", + "noContent": "没有可复制的内容" + }, + "regenerate": { + "regenerate": "重新生成", + "generating": "生成中", + "summaryTemplate": "摘要模板", + "summaryDetail": "摘要详略", + "summaryLanguage": "摘要语言", + "noSource": "没有可用于重新生成摘要的转录或笔记内容", + "success": "摘要已重新生成", + "failed": "重新生成摘要失败", + "template": { + "auto": "自动", + "meetingMinutes": "会议纪要", + "actionFocused": "行动导向", + "executive": "管理层摘要" + }, + "detail": { + "concise": "简洁", + "balanced": "均衡", + "detailed": "详细" + }, + "language": { + "english": "英语", + "chineseSimplified": "简体中文", + "chineseTraditional": "繁體中文", + "spanish": "西班牙语", + "french": "法语", + "german": "德语", + "japanese": "日语", + "korean": "韩语", + "portuguese": "葡萄牙语", + "russian": "俄语", + "thai": "泰语", + "vietnamese": "越南语", + "danish": "丹麦语", + "finnish": "芬兰语", + "norwegian": "挪威语", + "dutch": "荷兰语", + "italian": "意大利语", + "swedish": "瑞典语" + } + } + }, "date": { "timeHintTextInTwelveHour": "下午 01:00", "timeHintTextInTwentyFourHour": "13:00" diff --git a/src/application/slate-yjs/command/const.ts b/src/application/slate-yjs/command/const.ts index e718c05c0..ea247e8b8 100644 --- a/src/application/slate-yjs/command/const.ts +++ b/src/application/slate-yjs/command/const.ts @@ -18,6 +18,10 @@ export const CONTAINER_BLOCK_TYPES = [ BlockType.SimpleTableBlock, BlockType.SimpleTableRowBlock, BlockType.SimpleTableCellBlock, + BlockType.AIMeetingSummaryBlock, + BlockType.AIMeetingNotesBlock, + BlockType.AIMeetingTranscriptionBlock, + BlockType.AIMeetingSpeakerBlock, ]; export const SOFT_BREAK_TYPES = [BlockType.CodeBlock]; @@ -36,4 +40,4 @@ export const TEXT_BLOCK_TYPES = [ export const isEmbedBlockTypes = (type: BlockType) => { return ![...LIST_BLOCK_TYPES, ...CONTAINER_BLOCK_TYPES, ...SOFT_BREAK_TYPES, BlockType.HeadingBlock].includes(type); -}; \ No newline at end of file +}; diff --git a/src/application/slate-yjs/utils/convert.ts b/src/application/slate-yjs/utils/convert.ts index fc71360dd..41eccba86 100644 --- a/src/application/slate-yjs/utils/convert.ts +++ b/src/application/slate-yjs/utils/convert.ts @@ -60,7 +60,12 @@ export function traverseBlock(id: string, sharedRoot: YSharedRoot): Element | un if ( slateNode.type === BlockType.SimpleTableBlock || slateNode.type === BlockType.SimpleTableRowBlock || - slateNode.type === BlockType.SimpleTableCellBlock + slateNode.type === BlockType.SimpleTableCellBlock || + slateNode.type === BlockType.AIMeetingBlock || + slateNode.type === BlockType.AIMeetingSummaryBlock || + slateNode.type === BlockType.AIMeetingNotesBlock || + slateNode.type === BlockType.AIMeetingTranscriptionBlock || + slateNode.type === BlockType.AIMeetingSpeakerBlock ) { textId = ''; } diff --git a/src/application/types.ts b/src/application/types.ts index 5f19a284b..a11e09313 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -49,6 +49,10 @@ export enum BlockType { ColumnsBlock = 'simple_columns', ColumnBlock = 'simple_column', AIMeetingBlock = 'ai_meeting', + AIMeetingSummaryBlock = 'ai_meeting_summary', + AIMeetingNotesBlock = 'ai_meeting_notes', + AIMeetingTranscriptionBlock = 'ai_meeting_transcription', + AIMeetingSpeakerBlock = 'ai_meeting_speaker', PDFBlock = 'pdf', } @@ -152,6 +156,27 @@ export interface VideoBlockData extends BlockData { export interface AIMeetingBlockData extends BlockData { title?: string; + date?: string | number; + audio_file_path?: string; + recording_state?: string; + summary_template?: string; + summary_detail?: string; + summary_language?: string; + transcript_id?: string; + transcription_type?: string; + created_at?: string | number; + last_modified?: string | number; + selected_tab_index?: number | string; + pending_billing_duration?: number; + show_notes_directly?: boolean; + auto_start_recording?: boolean; + speaker_info_map?: string | Record>; +} + +export interface AIMeetingSpeakerBlockData extends BlockData { + speaker_id?: string; + timestamp?: number; + end_timestamp?: number; } export interface PDFBlockData extends BlockData { diff --git a/src/assets/icons/ai_meeting_transcript_tab.svg b/src/assets/icons/ai_meeting_transcript_tab.svg new file mode 100644 index 000000000..3e8e9c8da --- /dev/null +++ b/src/assets/icons/ai_meeting_transcript_tab.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/ai_notes.svg b/src/assets/icons/ai_notes.svg new file mode 100644 index 000000000..de44d1266 --- /dev/null +++ b/src/assets/icons/ai_notes.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/ai_reference_warning.svg b/src/assets/icons/ai_reference_warning.svg new file mode 100644 index 000000000..7ac605dff --- /dev/null +++ b/src/assets/icons/ai_reference_warning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/ai_summary_ref_notes.svg b/src/assets/icons/ai_summary_ref_notes.svg new file mode 100644 index 000000000..5c49e5582 --- /dev/null +++ b/src/assets/icons/ai_summary_ref_notes.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/ai_summary_ref_transcript.svg b/src/assets/icons/ai_summary_ref_transcript.svg new file mode 100644 index 000000000..f519ce3cd --- /dev/null +++ b/src/assets/icons/ai_summary_ref_transcript.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/ai_summary_tab.svg b/src/assets/icons/ai_summary_tab.svg new file mode 100644 index 000000000..1d508f5ba --- /dev/null +++ b/src/assets/icons/ai_summary_tab.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/ai_template_apply.svg b/src/assets/icons/ai_template_apply.svg new file mode 100644 index 000000000..ad0190a0f --- /dev/null +++ b/src/assets/icons/ai_template_apply.svg @@ -0,0 +1,8 @@ + + + diff --git a/src/assets/icons/check.svg b/src/assets/icons/check.svg new file mode 100644 index 000000000..476b2f36d --- /dev/null +++ b/src/assets/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/chat/request/__tests__/stream-json-parser.test.ts b/src/components/chat/request/__tests__/stream-json-parser.test.ts new file mode 100644 index 000000000..70bb7b53c --- /dev/null +++ b/src/components/chat/request/__tests__/stream-json-parser.test.ts @@ -0,0 +1,33 @@ +import { extractNextJsonObject } from '../stream-json-parser'; + +describe('extractNextJsonObject', () => { + it('extracts a simple json object', () => { + const result = extractNextJsonObject('prefix {"a":"b"} suffix'); + + expect(result).toEqual({ + jsonStr: '{"a":"b"}', + nextIndex: 'prefix {"a":"b"}'.length, + }); + }); + + it('handles braces inside string values', () => { + const streamChunk = '{"delta":"hello {ref:block_1}"}{"done":true}'; + const first = extractNextJsonObject(streamChunk); + + expect(first?.jsonStr).toBe('{"delta":"hello {ref:block_1}"}'); + + const second = extractNextJsonObject(streamChunk.slice(first?.nextIndex ?? 0)); + + expect(second?.jsonStr).toBe('{"done":true}'); + }); + + it('handles escaped quotes in strings', () => { + const result = extractNextJsonObject('{"delta":"say \\"{hello}\\" now"}'); + + expect(result?.jsonStr).toBe('{"delta":"say \\"{hello}\\" now"}'); + }); + + it('returns null for incomplete object', () => { + expect(extractNextJsonObject('{"a":"b"')).toBeNull(); + }); +}); diff --git a/src/components/chat/request/chat-request.ts b/src/components/chat/request/chat-request.ts index 7f52b6e05..95ef8f01d 100644 --- a/src/components/chat/request/chat-request.ts +++ b/src/components/chat/request/chat-request.ts @@ -21,6 +21,7 @@ import { ChatMessageMetadata, StreamType, } from '@/components/chat/types'; import { ModelList } from '@/components/chat/types/ai-model'; +import { extractNextJsonObject } from './stream-json-parser'; export type UpdateChatSettingsParams = Partial; @@ -293,28 +294,12 @@ export class ChatRequest { buffer += decoder.decode(chunk, { stream: true }); while(buffer.length > 0) { - const openBraceIndex = buffer.indexOf('{'); + const extracted = extractNextJsonObject(buffer); - if(openBraceIndex === -1) break; - - let closeBraceIndex = -1; - let depth = 0; - - for(let i = openBraceIndex; i < buffer.length; i++) { - if(buffer[i] === '{') depth++; - if(buffer[i] === '}') depth--; - if(depth === 0) { - closeBraceIndex = i; - break; - } - } - - if(closeBraceIndex === -1) break; - - const jsonStr = buffer.slice(openBraceIndex, closeBraceIndex + 1); + if(!extracted) break; try { - const data = JSON.parse(jsonStr); + const data = JSON.parse(extracted.jsonStr); Object.entries(data).forEach(([key, value]) => { if (key === StreamType.META_DATA) { @@ -341,7 +326,7 @@ export class ChatRequest { console.error('Failed to parse JSON:', e); } - buffer = buffer.slice(closeBraceIndex + 1); + buffer = buffer.slice(extracted.nextIndex); } } diff --git a/src/components/chat/request/stream-json-parser.ts b/src/components/chat/request/stream-json-parser.ts new file mode 100644 index 000000000..7117d60a5 --- /dev/null +++ b/src/components/chat/request/stream-json-parser.ts @@ -0,0 +1,59 @@ +export interface ExtractedJsonObject { + jsonStr: string; + nextIndex: number; +} + +export const extractNextJsonObject = (buffer: string): ExtractedJsonObject | null => { + const startIndex = buffer.indexOf('{'); + + if (startIndex === -1) return null; + + let depth = 0; + let inString = false; + let escaped = false; + + for (let index = startIndex; index < buffer.length; index += 1) { + const char = buffer[index]; + + if (inString) { + if (escaped) { + escaped = false; + continue; + } + + if (char === '\\') { + escaped = true; + continue; + } + + if (char === '"') { + inString = false; + } + + continue; + } + + if (char === '"') { + inString = true; + continue; + } + + if (char === '{') { + depth += 1; + continue; + } + + if (char === '}') { + depth -= 1; + + if (depth === 0) { + return { + jsonStr: buffer.slice(startIndex, index + 1), + nextIndex: index + 1, + }; + } + } + } + + return null; +}; diff --git a/src/components/chat/request/writer-request.ts b/src/components/chat/request/writer-request.ts index f82476d67..51cd6d3d0 100644 --- a/src/components/chat/request/writer-request.ts +++ b/src/components/chat/request/writer-request.ts @@ -16,6 +16,7 @@ import { View, } from '@/components/chat/types'; import { AvailableModel } from '@/components/chat/types/ai-model'; +import { extractNextJsonObject } from './stream-json-parser'; export class WriterRequest { private axiosInstance: AxiosInstance = createInitialInstance(); @@ -116,28 +117,12 @@ export class WriterRequest { buffer += decoder.decode(chunk, { stream: true }); while(buffer.length > 0) { - const openBraceIndex = buffer.indexOf('{'); + const extracted = extractNextJsonObject(buffer); - if(openBraceIndex === -1) break; - - let closeBraceIndex = -1; - let depth = 0; - - for(let i = openBraceIndex; i < buffer.length; i++) { - if(buffer[i] === '{') depth++; - if(buffer[i] === '}') depth--; - if(depth === 0) { - closeBraceIndex = i; - break; - } - } - - if(closeBraceIndex === -1) break; - - const jsonStr = buffer.slice(openBraceIndex, closeBraceIndex + 1); + if(!extracted) break; try { - const data = JSON.parse(jsonStr); + const data = JSON.parse(extracted.jsonStr); Object.entries(data).forEach(([key, value]) => { if(key === StreamType.COMMENT) { @@ -156,14 +141,19 @@ export class WriterRequest { console.error('Failed to parse JSON:', e); } - buffer = buffer.slice(closeBraceIndex + 1); + buffer = buffer.slice(extracted.nextIndex); } } + if(!text.trim() && !comment.trim()) { + throw new Error('Empty AI summary result from stream'); + } + onMessage(text, comment, true); } catch(error) { console.error('Stream reading error:', error); + throw error; } finally { reader.releaseLock(); try { diff --git a/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx b/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx index e948a2e64..d73368b11 100644 --- a/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx +++ b/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx @@ -1,35 +1,1172 @@ -import { forwardRef, memo } from 'react'; +import { CircularProgress, IconButton, Tooltip } from '@mui/material'; +import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Element, Node } from 'slate'; +import { useReadOnly, useSlateStatic } from 'slate-react'; +import { YjsEditor } from '@/application/slate-yjs'; +import { CustomEditor } from '@/application/slate-yjs/command'; +import { AIMeetingBlockData, BlockType } from '@/application/types'; +import { ReactComponent as TranscriptIcon } from '@/assets/icons/ai_meeting_transcript_tab.svg'; +import { ReactComponent as NotesIcon } from '@/assets/icons/ai_notes.svg'; +import { ReactComponent as RegenerateIcon } from '@/assets/icons/ai_summary.svg'; +import { ReactComponent as SummaryIcon } from '@/assets/icons/ai_summary_tab.svg'; +import { ReactComponent as TemplateApplyIcon } from '@/assets/icons/ai_template_apply.svg'; +import { ReactComponent as ArrowDownIcon } from '@/assets/icons/alt_arrow_down_small.svg'; +import { ReactComponent as CheckIcon } from '@/assets/icons/check.svg'; +import { ReactComponent as MoreIcon } from '@/assets/icons/more.svg'; +import { notify } from '@/components/_shared/notify'; +import { Popover } from '@/components/_shared/popover'; +import { WriterRequest } from '@/components/chat/request'; +import { AIAssistantType } from '@/components/chat/types'; import { AIMeetingNode, EditorElementProps } from '@/components/editor/editor.type'; +import { useEditorContext } from '@/components/editor/EditorContext'; +import { cn } from '@/lib/utils'; + +import { + buildSummaryRegeneratePrompt, + FALLBACK_SUMMARY_REGENERATE_TEMPLATE_CONFIG, + fetchSummaryRegenerateTemplateConfig, + getSummaryDetailId, + getSummaryLanguageCode, + getSummaryTemplateId, + normalizeGeneratedSummaryMarkdown, + replaceBlockChildrenWithMarkdown, + SUMMARY_LANGUAGE_OPTIONS, +} from './ai-meeting.summary-regenerate'; +import type { SummaryTemplateOption } from './ai-meeting.summary-regenerate'; +import { + documentFragmentToHTML, + isRangeInsideElement, + normalizeAppFlowyClipboardHTML, + plainTextToHTML, + selectionToContextualHTML, + stripTranscriptReferences, +} from './ai-meeting.utils'; +import './ai-meeting.scss'; + +const DEFAULT_TITLE = 'Meeting'; + +const TAB_DEFS = [ + { + key: 'summary', + type: BlockType.AIMeetingSummaryBlock, + labelKey: 'document.aiMeeting.tab.summary', + Icon: SummaryIcon, + }, + { + key: 'notes', + type: BlockType.AIMeetingNotesBlock, + labelKey: 'document.aiMeeting.tab.notes', + Icon: NotesIcon, + }, + { + key: 'transcript', + type: BlockType.AIMeetingTranscriptionBlock, + labelKey: 'document.aiMeeting.tab.transcript', + Icon: TranscriptIcon, + }, +] as const; + +type TabKey = (typeof TAB_DEFS)[number]['key']; + +type CopyLabelKey = + | 'document.aiMeeting.copy.summary' + | 'document.aiMeeting.copy.notes' + | 'document.aiMeeting.copy.transcript'; +type CopySuccessKey = + | 'document.aiMeeting.copy.summarySuccess' + | 'document.aiMeeting.copy.notesSuccess' + | 'document.aiMeeting.copy.transcriptSuccess'; + +interface CopyMeta { + tabKey: TabKey; + node?: Node; + labelKey: CopyLabelKey; + successKey: CopySuccessKey; + dataBlockType: string; + hasContent: boolean; +} + +const hasNodeContent = (node?: Node) => { + if (!node) return false; + + const text = CustomEditor.getBlockTextContent(node).trim(); + + return text.length > 0; +}; + +const buildCopyText = (node?: Node) => { + if (!node || !Element.isElement(node)) return ''; + + const lines = node.children + .map((child) => CustomEditor.getBlockTextContent(child).trim()) + .filter((line) => line.length > 0); + + if (lines.length) return lines.join('\n'); + + return CustomEditor.getBlockTextContent(node).trim(); +}; + +const parseSpeakerInfoMap = (raw: unknown) => { + if (!raw) return null; + + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw) as Record>; + + if (parsed && typeof parsed === 'object') return parsed; + } catch { + return null; + } + } + + if (typeof raw === 'object') { + return raw as Record>; + } + + return null; +}; + +const getBaseSpeakerId = (speakerId: string) => { + const [base] = speakerId.split('_'); + + return base || speakerId; +}; + +const buildTranscriptCopyText = (node: Node, resolveSpeakerName: (speakerId?: string) => string) => { + if (!Element.isElement(node)) return ''; + + const lines: string[] = []; + + node.children.forEach((child) => { + if (Element.isElement(child) && child.type === BlockType.AIMeetingSpeakerBlock) { + const speakerData = child.data as Record | undefined; + const speakerId = (speakerData?.speaker_id || speakerData?.speakerId) as string | undefined; + const speakerName = resolveSpeakerName(speakerId); + const transcript = stripTranscriptReferences( + child.children + .map((speakerChild) => CustomEditor.getBlockTextContent(speakerChild).trim()) + .filter((line) => line.length > 0) + .join(' ') + ); + + lines.push(transcript ? `${speakerName}: ${transcript}` : `${speakerName}:`); + } + }); + + return lines.join('\n'); +}; + +const buildTranscriptCopyTextFromElement = (element: HTMLElement) => { + const speakerElements = Array.from( + element.querySelectorAll('[data-block-type="ai_meeting_speaker"]') + ); + + if (speakerElements.length === 0) return ''; + + const lines = speakerElements + .map((speakerElement) => { + const speakerName = speakerElement.querySelector('.ai-meeting-speaker__name')?.innerText?.trim(); + const contentElement = speakerElement.querySelector('.ai-meeting-speaker__content'); + const transcript = stripTranscriptReferences( + (contentElement?.innerText ?? '').replace(/\u00a0/g, ' ').trim() + ); + + if (!transcript) return ''; + + return speakerName ? `${speakerName}: ${transcript}` : transcript; + }) + .filter((line) => line.length > 0); + + return lines.join('\n'); +}; + +const COPY_META: Record> = { + summary: { + labelKey: 'document.aiMeeting.copy.summary', + successKey: 'document.aiMeeting.copy.summarySuccess', + dataBlockType: 'ai_meeting_summary', + }, + notes: { + labelKey: 'document.aiMeeting.copy.notes', + successKey: 'document.aiMeeting.copy.notesSuccess', + dataBlockType: 'ai_meeting_notes', + }, + transcript: { + labelKey: 'document.aiMeeting.copy.transcript', + successKey: 'document.aiMeeting.copy.transcriptSuccess', + dataBlockType: 'ai_meeting_transcription', + }, +}; + +interface ClipboardPayload { + plainText: string; + html: string; +} + +interface PayloadBuildOptions { + stripReferences?: boolean; +} + +const normalizePlainText = (text: string) => text.replace(/\u00a0/g, ' '); export const AIMeetingBlock = memo( forwardRef>( - ({ node, children: _children, ...attributes }, ref) => { - const { data } = node; + ({ node, children, className, ...attributes }, ref) => { + const { t } = useTranslation(); + const editor = useSlateStatic() as YjsEditor; + const { workspaceId, viewId, requestInstance } = useEditorContext(); + const slateReadOnly = useReadOnly(); + const readOnly = slateReadOnly || editor.isElementReadOnly(node as unknown as Element); + const data = node.data ?? {}; + const containerRef = useRef(null); + const contentRef = useRef(null); + const setRefs = useCallback( + (element: HTMLDivElement | null) => { + containerRef.current = element; + if (!ref) return; + if (typeof ref === 'function') { + ref(element); + } else { + ref.current = element; + } + }, + [ref] + ); + + const storedTitle = typeof data.title === 'string' ? data.title.trim() : ''; + const hasStoredTitle = storedTitle.length > 0; + const defaultTitle = t('document.aiMeeting.titleDefault', { defaultValue: DEFAULT_TITLE }); + const displayTitle = storedTitle || defaultTitle; + const [title, setTitle] = useState(displayTitle); + + useEffect(() => { + setTitle(displayTitle); + }, [displayTitle]); + + const availableTabs = useMemo(() => { + const childrenList = (node.children ?? []) as Array; + + return TAB_DEFS.filter((tab) => { + const match = childrenList.find((child) => child.type === tab.type); + + if (!match) return false; + + if ( + tab.type === BlockType.AIMeetingSummaryBlock || + tab.type === BlockType.AIMeetingTranscriptionBlock + ) { + return hasNodeContent(match); + } + + return true; + }); + }, [node.children]); + + const showNotesDirectly = Boolean(data.show_notes_directly); + const showTabs = !showNotesDirectly && availableTabs.length > 1; + const fallbackTab = availableTabs[0] ?? TAB_DEFS[1]; + const speakerInfoMap = useMemo(() => parseSpeakerInfoMap(data.speaker_info_map), [data.speaker_info_map]); + const unknownSpeakerLabel = t('document.aiMeeting.speakerUnknown'); + const getFallbackSpeakerLabel = useCallback( + (id: string) => t('document.aiMeeting.speakerFallback', { id }), + [t] + ); + const resolveSpeakerName = useCallback( + (speakerId?: string) => { + if (!speakerId) return unknownSpeakerLabel; + + const baseId = getBaseSpeakerId(speakerId); + const info = speakerInfoMap?.[speakerId] ?? speakerInfoMap?.[baseId]; + const name = typeof info?.name === 'string' ? info?.name.trim() : ''; + + if (name) return name; + + return getFallbackSpeakerLabel(baseId); + }, + [getFallbackSpeakerLabel, speakerInfoMap, unknownSpeakerLabel] + ); + + const selectedIndex = useMemo(() => { + if (readOnly) return 0; + + const raw = data.selected_tab_index; + + if (typeof raw === 'number' && !Number.isNaN(raw)) return raw; + + if (typeof raw === 'string' && raw.trim()) { + const parsed = Number(raw); + + if (!Number.isNaN(parsed)) return parsed; + } + + return 0; + }, [readOnly, data.selected_tab_index]); + + const [activeIndex, setActiveIndex] = useState(0); + + useEffect(() => { + const maxIndex = Math.max(availableTabs.length - 1, 0); + const safeIndex = Math.min(Math.max(selectedIndex, 0), maxIndex); + + setActiveIndex(safeIndex); + }, [availableTabs.length, selectedIndex]); + + const notesTab = TAB_DEFS.find((tab) => tab.key === 'notes') ?? fallbackTab; + const activeTab = showNotesDirectly ? notesTab : (availableTabs[activeIndex] ?? fallbackTab); + const activeTabKey: TabKey = activeTab?.key ?? 'notes'; + + const handleLocalTabSwitch = useCallback( + (tabKey?: string) => { + if (!tabKey || showNotesDirectly) return; + + const index = availableTabs.findIndex((tab) => tab.key === tabKey); + + if (index < 0) return; + + setActiveIndex(index); + }, + [availableTabs, showNotesDirectly] + ); + + useEffect(() => { + const handler = (event: Event) => { + const element = containerRef.current; + + if (!element) return; + + const target = event.target as HTMLElement | null; + + if (!target) return; + if (!(target === element || element.contains(target) || target.contains(element))) return; + + const detail = (event as CustomEvent<{ tabKey?: string }>).detail; + + handleLocalTabSwitch(detail?.tabKey); + }; + + document.addEventListener('ai-meeting-switch-tab', handler as EventListener); + + return () => { + document.removeEventListener('ai-meeting-switch-tab', handler as EventListener); + }; + }, [handleLocalTabSwitch]); + + const sectionNodes = useMemo(() => { + const childrenList = (node.children ?? []) as Array; + const summaryNode = childrenList.find((child) => child.type === BlockType.AIMeetingSummaryBlock); + const notesNode = childrenList.find((child) => child.type === BlockType.AIMeetingNotesBlock); + const transcriptNode = childrenList.find((child) => child.type === BlockType.AIMeetingTranscriptionBlock); + + return { + summaryNode, + notesNode, + transcriptNode, + }; + }, [node.children]); + + const activeCopyItem = useMemo(() => { + const nodeByTab: Record = { + summary: sectionNodes.summaryNode, + notes: sectionNodes.notesNode, + transcript: sectionNodes.transcriptNode, + }; + const sectionNode = nodeByTab[activeTabKey]; + const meta = COPY_META[activeTabKey]; + + return { + tabKey: activeTabKey, + node: sectionNode, + labelKey: meta.labelKey, + successKey: meta.successKey, + dataBlockType: meta.dataBlockType, + hasContent: Boolean(buildCopyText(sectionNode)), + }; + }, [activeTabKey, sectionNodes.notesNode, sectionNodes.summaryNode, sectionNodes.transcriptNode]); + + const [summaryTemplateConfig, setSummaryTemplateConfig] = useState( + FALLBACK_SUMMARY_REGENERATE_TEMPLATE_CONFIG + ); + + const selectedSummaryTemplate = getSummaryTemplateId( + data.summary_template, + summaryTemplateConfig.templateOptions + ); + const selectedSummaryDetail = getSummaryDetailId( + data.summary_detail, + summaryTemplateConfig.detailOptions + ); + const selectedSummaryLanguage = getSummaryLanguageCode(data.summary_language); + + const [isRegeneratingSummary, setIsRegeneratingSummary] = useState(false); + const [regenerateMenuAnchor, setRegenerateMenuAnchor] = useState(null); + const regenerateMenuOpen = Boolean(regenerateMenuAnchor); + const handleRegenerateMenuClose = useCallback(() => setRegenerateMenuAnchor(null), []); + + const updateSummaryOptions = useCallback( + (updates: Partial>) => { + if (readOnly) return; + CustomEditor.setBlockData(editor, node.blockId, updates); + }, + [editor, node.blockId, readOnly] + ); + + useEffect(() => { + let cancelled = false; + + void (async () => { + const remoteConfig = await fetchSummaryRegenerateTemplateConfig(requestInstance ?? undefined); + + if (cancelled || !remoteConfig) return; + setSummaryTemplateConfig(remoteConfig); + })(); + + return () => { + cancelled = true; + }; + }, [requestInstance]); - const title = data?.title?.trim() || 'AI Meeting'; + const isProgrammaticCopyRef = useRef(false); + + const getSectionElementByTab = useCallback((tabKey: TabKey) => { + const contentElement = contentRef.current; + + if (!contentElement) return null; + + return contentElement.querySelector( + `.block-element[data-block-type="${COPY_META[tabKey].dataBlockType}"]` + ); + }, []); + + const buildPayloadFromElement = useCallback( + (element: HTMLElement, options?: PayloadBuildOptions): ClipboardPayload => { + const range = document.createRange(); + + range.selectNodeContents(element); + const rawHTML = documentFragmentToHTML(range.cloneContents()).trim(); + const html = normalizeAppFlowyClipboardHTML(rawHTML); + const rawPlainText = normalizePlainText(element.innerText ?? '').trim(); + const plainText = options?.stripReferences ? stripTranscriptReferences(rawPlainText) : rawPlainText; + + return { + plainText, + html: html.trim() || plainTextToHTML(plainText), + }; + }, + [] + ); + + const buildSelectionPayload = useCallback( + (selection: Selection, options?: PayloadBuildOptions): ClipboardPayload => { + const rawPlainText = normalizePlainText(selection.toString()).trim(); + const plainText = options?.stripReferences ? stripTranscriptReferences(rawPlainText) : rawPlainText; + const rawHTML = selectionToContextualHTML(selection).trim(); + const html = normalizeAppFlowyClipboardHTML(rawHTML); + + return { + plainText, + html: html.trim() || plainTextToHTML(plainText), + }; + }, + [] + ); + + const fallbackCopyWithExecCommand = useCallback((payload: ClipboardPayload) => { + let captured = false; + const handler = (event: ClipboardEvent) => { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + event.clipboardData?.setData('text/plain', payload.plainText); + if (payload.html) { + event.clipboardData?.setData('text/html', payload.html); + } + + captured = true; + }; + + document.addEventListener('copy', handler, true); + let commandSucceeded = false; + + try { + commandSucceeded = document.execCommand('copy'); + } catch { + commandSucceeded = false; + } + + document.removeEventListener('copy', handler, true); + return captured || commandSucceeded; + }, []); + + const writePayloadToClipboard = useCallback(async (payload: ClipboardPayload) => { + if (!payload.plainText) return false; + if (!navigator.clipboard) return false; + + try { + if (typeof navigator.clipboard.write === 'function' && typeof ClipboardItem !== 'undefined') { + const item: Record = { + 'text/plain': new Blob([payload.plainText], { type: 'text/plain' }), + }; + + if (payload.html) { + item['text/html'] = new Blob([payload.html], { type: 'text/html' }); + } + + await navigator.clipboard.write([new ClipboardItem(item)]); + return true; + } + + await navigator.clipboard.writeText(payload.plainText); + return true; + } catch { + return false; + } + }, []); + + const [menuAnchor, setMenuAnchor] = useState(null); + const menuOpen = Boolean(menuAnchor); + const handleMenuClose = useCallback(() => setMenuAnchor(null), []); + const handleCopy = useCallback(async () => { + let payload: ClipboardPayload | null = null; + + if (activeCopyItem.hasContent) { + if (activeCopyItem.tabKey === 'transcript' && activeCopyItem.node) { + const transcriptElement = getSectionElementByTab('transcript'); + const transcriptTextFromNode = buildTranscriptCopyText(activeCopyItem.node, resolveSpeakerName); + const transcriptTextFromElement = transcriptElement + ? buildTranscriptCopyTextFromElement(transcriptElement) + : ''; + const fallbackText = stripTranscriptReferences( + normalizePlainText(transcriptElement?.innerText ?? buildCopyText(activeCopyItem.node)) + ); + const plainText = transcriptTextFromNode || transcriptTextFromElement || fallbackText; + + if (plainText.trim()) { + payload = { + plainText, + html: plainTextToHTML(plainText), + }; + } + } else { + const sectionElement = getSectionElementByTab(activeCopyItem.tabKey); + const stripReferences = activeCopyItem.tabKey === 'summary'; + + if (sectionElement) { + payload = buildPayloadFromElement(sectionElement, { stripReferences }); + } else if (activeCopyItem.node) { + const rawPlainText = buildCopyText(activeCopyItem.node); + const plainText = stripReferences + ? stripTranscriptReferences(rawPlainText) + : rawPlainText; + + if (plainText) { + payload = { + plainText, + html: plainTextToHTML(plainText), + }; + } + } + } + } + + if (!payload?.plainText.trim()) { + handleMenuClose(); + return; + } + + let copied = await writePayloadToClipboard(payload); + + if (!copied) { + isProgrammaticCopyRef.current = true; + copied = fallbackCopyWithExecCommand(payload); + isProgrammaticCopyRef.current = false; + } + + if (copied) { + notify.success(t(activeCopyItem.successKey)); + } + + handleMenuClose(); + }, [ + activeCopyItem, + buildPayloadFromElement, + fallbackCopyWithExecCommand, + getSectionElementByTab, + handleMenuClose, + resolveSpeakerName, + t, + writePayloadToClipboard, + ]); + + const handleRegenerateSummary = useCallback(async (overrides?: { + templateId?: string; + detailId?: string; + languageCode?: string; + }) => { + if (readOnly || isRegeneratingSummary) return; + + const summaryBlockId = (sectionNodes.summaryNode as (Node & { blockId?: string }) | undefined)?.blockId; + + if (!summaryBlockId) return; + + const transcriptText = + sectionNodes.transcriptNode && Element.isElement(sectionNodes.transcriptNode) + ? buildTranscriptCopyText(sectionNodes.transcriptNode, resolveSpeakerName) + : ''; + const notesText = sectionNodes.notesNode ? buildCopyText(sectionNodes.notesNode) : ''; + + if (!transcriptText.trim() && !notesText.trim()) { + notify.error( + t('document.aiMeeting.regenerate.noSource', { + defaultValue: 'No transcript or notes available to regenerate summary', + }) + ); + return; + } + + if (!workspaceId || !viewId) { + notify.error( + t('document.aiMeeting.regenerate.failed', { + defaultValue: 'Failed to regenerate summary', + }) + ); + return; + } + + const sourceText = [ + transcriptText.trim() ? `Transcript:\n${transcriptText.trim()}` : '', + notesText.trim() ? `Manual Notes:\n${notesText.trim()}` : '', + ] + .filter(Boolean) + .join('\n\n'); + const templateId = getSummaryTemplateId( + overrides?.templateId ?? selectedSummaryTemplate, + summaryTemplateConfig.templateOptions + ); + const detailId = getSummaryDetailId( + overrides?.detailId ?? selectedSummaryDetail, + summaryTemplateConfig.detailOptions + ); + const languageCode = getSummaryLanguageCode(overrides?.languageCode ?? selectedSummaryLanguage); + const customPrompt = buildSummaryRegeneratePrompt({ + templateId, + detailId, + languageCode, + templateConfig: summaryTemplateConfig, + speakerInfoMap, + }); + + setIsRegeneratingSummary(true); + handleRegenerateMenuClose(); + + try { + const request = new WriterRequest(workspaceId, viewId, requestInstance ?? undefined); + let generatedContent = ''; + + const { streamPromise } = await request.fetchAIAssistant( + { + inputText: sourceText, + assistantType: AIAssistantType.CustomPrompt, + ragIds: [], + completionHistory: [], + customPrompt, + }, + (text, comment) => { + const candidate = text.trim() ? text : comment; + + generatedContent = candidate; + } + ); + + await streamPromise; + + const normalizedMarkdown = normalizeGeneratedSummaryMarkdown(generatedContent); + + if (!normalizedMarkdown) { + throw new Error('Empty generated summary'); + } + + const replaced = replaceBlockChildrenWithMarkdown({ + editor, + blockId: summaryBlockId, + markdown: normalizedMarkdown, + }); + + if (!replaced) { + throw new Error('Unable to replace summary content'); + } + + notify.success( + t('document.aiMeeting.regenerate.success', { + defaultValue: 'Summary regenerated', + }) + ); + } catch (error) { + const baseMessage = t('document.aiMeeting.regenerate.failed', { + defaultValue: 'Failed to regenerate summary', + }); + const reason = + error instanceof Error && error.message.trim() + ? error.message.trim() + : ''; + + console.error('AI meeting regenerate failed:', error); + notify.error(reason ? `${baseMessage}: ${reason}` : baseMessage); + } finally { + setIsRegeneratingSummary(false); + } + }, [ + editor, + handleRegenerateMenuClose, + isRegeneratingSummary, + readOnly, + requestInstance, + resolveSpeakerName, + sectionNodes.notesNode, + sectionNodes.summaryNode, + sectionNodes.transcriptNode, + selectedSummaryDetail, + selectedSummaryLanguage, + selectedSummaryTemplate, + speakerInfoMap, + summaryTemplateConfig, + t, + viewId, + workspaceId, + ]); + + const handleSummaryOptionSelect = useCallback( + (updates: Partial>) => { + if (readOnly || isRegeneratingSummary) return; + + updateSummaryOptions(updates); + + const templateId = getSummaryTemplateId( + updates.summary_template ?? selectedSummaryTemplate, + summaryTemplateConfig.templateOptions + ); + const detailId = getSummaryDetailId( + updates.summary_detail ?? selectedSummaryDetail, + summaryTemplateConfig.detailOptions + ); + const languageCode = getSummaryLanguageCode(updates.summary_language ?? selectedSummaryLanguage); + + void handleRegenerateSummary({ + templateId, + detailId, + languageCode, + }); + }, + [ + handleRegenerateSummary, + isRegeneratingSummary, + readOnly, + selectedSummaryDetail, + selectedSummaryLanguage, + selectedSummaryTemplate, + summaryTemplateConfig.detailOptions, + summaryTemplateConfig.templateOptions, + updateSummaryOptions, + ] + ); + + useEffect(() => { + const handleSelectionCopy = (event: ClipboardEvent) => { + if (isProgrammaticCopyRef.current) return; + if (!event.clipboardData) return; + + const contentElement = contentRef.current; + + if (!contentElement) return; + + const selection = window.getSelection(); + + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return; + + for (let index = 0; index < selection.rangeCount; index += 1) { + const range = selection.getRangeAt(index); + + if (!isRangeInsideElement(range, contentElement)) { + return; + } + } + + const transcriptElement = getSectionElementByTab('transcript'); + let isTranscriptSelection = Boolean(transcriptElement); + + if (transcriptElement) { + for (let index = 0; index < selection.rangeCount; index += 1) { + const range = selection.getRangeAt(index); + + if (!isRangeInsideElement(range, transcriptElement)) { + isTranscriptSelection = false; + break; + } + } + } + + if (isTranscriptSelection) { + const plainText = stripTranscriptReferences(normalizePlainText(selection.toString())).trim(); + + if (!plainText) return; + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + event.clipboardData.setData('text/plain', plainText); + event.clipboardData.setData('text/html', plainTextToHTML(plainText)); + return; + } + + const summaryElement = getSectionElementByTab('summary'); + let isSummarySelection = Boolean(summaryElement); + + if (summaryElement) { + for (let index = 0; index < selection.rangeCount; index += 1) { + const range = selection.getRangeAt(index); + + if (!isRangeInsideElement(range, summaryElement)) { + isSummarySelection = false; + break; + } + } + } + + const payload = buildSelectionPayload(selection, { stripReferences: isSummarySelection }); + + if (!payload.plainText) return; + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + event.clipboardData.setData('text/plain', payload.plainText); + + if (payload.html) { + event.clipboardData.setData('text/html', payload.html); + } + }; + + document.addEventListener('copy', handleSelectionCopy, true); + + return () => { + document.removeEventListener('copy', handleSelectionCopy, true); + }; + }, [buildSelectionPayload, getSectionElementByTab]); + + const commitTitle = useCallback(() => { + const trimmed = title.trim(); + + if (!trimmed) { + setTitle(displayTitle); + return; + } + + if (!hasStoredTitle && trimmed === defaultTitle) { + setTitle(defaultTitle); + return; + } + + if (!readOnly && editor && trimmed !== storedTitle) { + CustomEditor.setBlockData(editor, node.blockId, { title: trimmed }); + } + + setTitle(trimmed); + }, [defaultTitle, displayTitle, editor, hasStoredTitle, node.blockId, readOnly, storedTitle, title]); + + const handleTabChange = useCallback( + (index: number) => { + setActiveIndex(index); + if (!readOnly && editor && index !== selectedIndex) { + CustomEditor.setBlockData(editor, node.blockId, { selected_tab_index: index }); + } + }, + [editor, node.blockId, readOnly, selectedIndex] + ); + + const showSummaryRegenerate = activeTabKey === 'summary' && activeCopyItem.hasContent; + const templateSections = summaryTemplateConfig.templateSections.length + ? summaryTemplateConfig.templateSections + : FALLBACK_SUMMARY_REGENERATE_TEMPLATE_CONFIG.templateSections; + const detailOptions = summaryTemplateConfig.detailOptions.length + ? summaryTemplateConfig.detailOptions + : FALLBACK_SUMMARY_REGENERATE_TEMPLATE_CONFIG.detailOptions; + const getRegenerateOptionLabel = useCallback( + (option: Pick) => + option.labelKey ? t(option.labelKey, { defaultValue: option.defaultLabel }) : option.defaultLabel, + [t] + ); + + useEffect(() => { + if (activeTabKey !== 'summary') { + handleRegenerateMenuClose(); + } + }, [activeTabKey, handleRegenerateMenuClose]); return (
-
-

- {title} -

+
+
+ setTitle(event.target.value)} + onBlur={commitTitle} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + commitTitle(); + (event.currentTarget as HTMLInputElement).blur(); + } + }} + disabled={readOnly} + /> +
-
-
-

- This content isn't supported on the web version yet. -

-

- Please switch to the desktop or mobile app to view this content. -

+
+ {showTabs && ( +
+
+
+ {availableTabs.map((tab, index) => { + const isActive = index === activeIndex; + const Icon = tab.Icon; + + return ( + + ); + })} +
+
+ {showSummaryRegenerate && !readOnly && ( + <> +
+ + +
+ +
+ {templateSections.map((section, sectionIndex) => ( +
+
{section.title}
+ {section.options.map((option) => { + const selected = selectedSummaryTemplate === option.id; + + return ( + + ); + })} + {sectionIndex < templateSections.length - 1 && ( +
+ )} +
+ ))} +
+
+ {t('document.aiMeeting.regenerate.summaryDetail', { + defaultValue: 'Summary detail', + })} +
+ {detailOptions.map((option) => { + const selected = selectedSummaryDetail === option.id; + + return ( + + ); + })} +
+
+ {t('document.aiMeeting.regenerate.summaryLanguage', { + defaultValue: 'Summary language', + })} +
+
+ {SUMMARY_LANGUAGE_OPTIONS.map((option) => { + const selected = + selectedSummaryLanguage.toLowerCase() === option.code.toLowerCase(); + + return ( + + ); + })} +
+
+ + + )} + setMenuAnchor(event.currentTarget)} + className="rounded-md text-text-secondary hover:bg-fill-list-hover" + > + + + +
+ {activeCopyItem.hasContent ? ( + + ) : ( + + + + + + )} +
+
+
+
+
+ )} + +
+ {children}
diff --git a/src/components/editor/components/blocks/ai-meeting/AIMeetingSection.tsx b/src/components/editor/components/blocks/ai-meeting/AIMeetingSection.tsx new file mode 100644 index 000000000..3cc3804eb --- /dev/null +++ b/src/components/editor/components/blocks/ai-meeting/AIMeetingSection.tsx @@ -0,0 +1,41 @@ +import { forwardRef, memo } from 'react'; + +import { BlockType } from '@/application/types'; +import { + AIMeetingNotesNode, + AIMeetingSummaryNode, + AIMeetingTranscriptionNode, + EditorElementProps, +} from '@/components/editor/editor.type'; +import { cn } from '@/lib/utils'; + +type AIMeetingSectionNode = AIMeetingSummaryNode | AIMeetingNotesNode | AIMeetingTranscriptionNode; + +const getSectionClassName = (type: BlockType) => { + switch (type) { + case BlockType.AIMeetingSummaryBlock: + return 'ai-meeting-section ai-meeting-section-summary'; + case BlockType.AIMeetingNotesBlock: + return 'ai-meeting-section ai-meeting-section-notes'; + case BlockType.AIMeetingTranscriptionBlock: + return 'ai-meeting-section ai-meeting-section-transcription'; + default: + return 'ai-meeting-section'; + } +}; + +export const AIMeetingSection = memo( + forwardRef>(({ node, children, className, ...attributes }, ref) => { + return ( +
+ {children} +
+ ); + }) +); + +AIMeetingSection.displayName = 'AIMeetingSection'; diff --git a/src/components/editor/components/blocks/ai-meeting/AIMeetingSpeakerBlock.tsx b/src/components/editor/components/blocks/ai-meeting/AIMeetingSpeakerBlock.tsx new file mode 100644 index 000000000..4e0c9728f --- /dev/null +++ b/src/components/editor/components/blocks/ai-meeting/AIMeetingSpeakerBlock.tsx @@ -0,0 +1,205 @@ +import { forwardRef, memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Editor, Element as SlateElement } from 'slate'; +import { ReactEditor, useSlateStatic } from 'slate-react'; + +import { getUserIconUrl } from '@/application/user-metadata'; +import { BlockType } from '@/application/types'; +import { formatTimestamp } from '@/components/editor/components/blocks/ai-meeting/ai-meeting.utils'; +import { useMentionableUsersWithAutoFetch } from '@/components/database/components/cell/person/useMentionableUsers'; +import { useCurrentUserOptional } from '@/components/main/app.hooks'; +import { AIMeetingNode, AIMeetingSpeakerNode, EditorElementProps } from '@/components/editor/editor.type'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { cn } from '@/lib/utils'; + +// Returns true only for actual image/URL sources (not emojis or non-URL strings) +const isImageSource = (value?: string) => { + if (!value) return false; + return /^https?:\/\//i.test(value) || value.startsWith('data:') || value.startsWith('blob:') || value.startsWith('/'); +}; + +const parseSpeakerInfoMap = (raw: unknown) => { + if (!raw) return null; + + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw) as Record>; + + if (parsed && typeof parsed === 'object') return parsed; + } catch { + return null; + } + } + + if (typeof raw === 'object') { + return raw as Record>; + } + + return null; +}; + +const getBaseSpeakerId = (speakerId: string) => { + const [base] = speakerId.split('_'); + + return base || speakerId; +}; + +const resolveSpeakerInfo = ( + speakerId?: string, + infoMap?: Record> | null, + unknownLabel?: string, + fallbackLabel?: (id: string) => string +) => { + const resolvedUnknownLabel = unknownLabel ?? 'Unknown speaker'; + + if (!speakerId) { + return { + name: resolvedUnknownLabel, + email: '', + avatarUrl: '', + }; + } + + const baseId = getBaseSpeakerId(speakerId); + const info = infoMap?.[speakerId] ?? infoMap?.[baseId]; + const name = typeof info?.name === 'string' ? info?.name?.trim() : ''; + const email = typeof info?.email === 'string' ? info?.email?.trim() : ''; + const avatarUrl = typeof info?.avatar_url === 'string' ? info?.avatar_url?.trim() : ''; + + if (name) { + return { + name, + email, + avatarUrl, + }; + } + + if (!baseId) { + return { + name: resolvedUnknownLabel, + email, + avatarUrl, + }; + } + + return { + name: fallbackLabel ? fallbackLabel(baseId) : `Speaker ${baseId}`, + email, + avatarUrl, + }; +}; + +export const AIMeetingSpeakerBlock = memo( + forwardRef>(({ node, children, className, ...attributes }, ref) => { + const { t } = useTranslation(); + const editor = useSlateStatic(); + + const speakerId = (node.data?.speaker_id || (node.data as Record)?.speakerId) as + | string + | undefined; + const timestampRaw = node.data?.timestamp ?? (node.data as Record)?.timestamp; + const timestamp = typeof timestampRaw === 'number' ? timestampRaw : Number(timestampRaw); + + const parentAiMeeting = useMemo(() => { + try { + const path = ReactEditor.findPath(editor, node); + const match = Editor.above(editor, { + at: path, + match: (n) => { + return !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === BlockType.AIMeetingBlock; + }, + }); + + if (!match) return null; + + return match[0] as AIMeetingNode; + } catch { + return null; + } + }, [editor, node]); + + const speakerInfoMap = useMemo(() => { + const raw = parentAiMeeting?.data?.speaker_info_map; + + return parseSpeakerInfoMap(raw); + }, [parentAiMeeting?.data?.speaker_info_map]); + + const unknownSpeakerLabel = t('document.aiMeeting.speakerUnknown'); + const getFallbackLabel = useCallback( + (id: string) => t('document.aiMeeting.speakerFallback', { id }), + [t] + ); + const speakerInfo = useMemo( + () => resolveSpeakerInfo(speakerId, speakerInfoMap, unknownSpeakerLabel, getFallbackLabel), + [getFallbackLabel, speakerId, speakerInfoMap, unknownSpeakerLabel] + ); + const speakerName = speakerInfo.name; + const displayTimestamp = useMemo(() => formatTimestamp(timestamp), [timestamp]); + + const currentUser = useCurrentUserOptional(); + const { users: mentionableUsers } = useMentionableUsersWithAutoFetch(true); + + // Resolve the raw avatar value: may be an image URL, an emoji, or empty + const resolvedAvatar = useMemo(() => { + // 1. Speaker info map may already have an avatar URL (set by Flutter write-back) + if (speakerInfo.avatarUrl) return speakerInfo.avatarUrl; + + if (!speakerInfo.email) return ''; + + // 2. Try workspace member data (avatar_url or custom_image_url) + const member = mentionableUsers.find((u) => u.email === speakerInfo.email); + const memberAvatar = member?.avatar_url || member?.custom_image_url || ''; + + if (memberAvatar) return memberAvatar; + + // 3. If speaker is the current logged-in user, use their profile icon (metadata.icon_url) + // This covers emoji avatars like 🤖 that aren't exposed by the mentionable-person API + if (currentUser?.email === speakerInfo.email) { + return getUserIconUrl(currentUser); + } + + return ''; + }, [speakerInfo.avatarUrl, speakerInfo.email, mentionableUsers, currentUser]); + + const avatarColorKey = speakerName || speakerId || unknownSpeakerLabel; + const avatarLabel = useMemo(() => { + if (speakerName && speakerName !== unknownSpeakerLabel) { + return speakerName.trim().charAt(0).toUpperCase(); + } + + if (speakerId) return getBaseSpeakerId(speakerId).charAt(0).toUpperCase(); + return '?'; + }, [speakerId, speakerName, unknownSpeakerLabel]); + + return ( +
+
+ + {/* Only pass real image URLs to AvatarImage; emojis handled in fallback */} + + + {resolvedAvatar && !isImageSource(resolvedAvatar) ? ( + {resolvedAvatar} + ) : ( + {avatarLabel} + )} + + +
{speakerName}
+ {displayTimestamp &&
{displayTimestamp}
} +
+
{children}
+
+ ); + }) +); + +AIMeetingSpeakerBlock.displayName = 'AIMeetingSpeakerBlock'; diff --git a/src/components/editor/components/blocks/ai-meeting/__tests__/AIMeetingBlock.component.test.tsx b/src/components/editor/components/blocks/ai-meeting/__tests__/AIMeetingBlock.component.test.tsx new file mode 100644 index 000000000..72a44c72d --- /dev/null +++ b/src/components/editor/components/blocks/ai-meeting/__tests__/AIMeetingBlock.component.test.tsx @@ -0,0 +1,683 @@ +import { expect, describe, it, beforeEach } from '@jest/globals'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { useState } from 'react'; + +import { BlockType } from '@/application/types'; + +// Mock runtime config +jest.mock('@/utils/runtime-config', () => ({ + getConfigValue: jest.fn((key: string, defaultValue: string) => defaultValue), +})); + +// Mock translations +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { id?: string }) => { + const translations: Record = { + 'document.aiMeeting.titleDefault': 'Meeting', + 'document.aiMeeting.tab.summary': 'Summary', + 'document.aiMeeting.tab.notes': 'Notes', + 'document.aiMeeting.tab.transcript': 'Transcript', + 'document.aiMeeting.copy.summary': 'Copy summary', + 'document.aiMeeting.copy.notes': 'Copy notes', + 'document.aiMeeting.copy.transcript': 'Copy transcript', + 'document.aiMeeting.copy.summarySuccess': 'Summary copied', + 'document.aiMeeting.copy.notesSuccess': 'Notes copied', + 'document.aiMeeting.copy.transcriptSuccess': 'Transcript copied', + 'document.aiMeeting.copy.noContent': 'No content to copy', + 'document.aiMeeting.speakerUnknown': 'Unknown speaker', + 'document.aiMeeting.speakerFallback': `Speaker ${options?.id ?? ''}`, + 'document.aiMeeting.readOnlyHint': 'This content is read-only', + }; + + return translations[key] ?? key; + }, + }), +})); + +// Mock notify +const mockNotifySuccess = jest.fn(); + +jest.mock('@/components/_shared/notify', () => ({ + notify: { + success: (msg: string) => mockNotifySuccess(msg), + warning: jest.fn(), + }, +})); + +// Mock publish context +jest.mock('@/application/publish', () => ({ + usePublishContext: () => null, +})); + +/** + * Simplified AI Meeting Block component for testing + * Extracts the core UI logic without Slate editor dependencies + */ + +interface TabDef { + key: 'summary' | 'notes' | 'transcript'; + type: BlockType; + label: string; +} + +const TAB_DEFS: TabDef[] = [ + { key: 'summary', type: BlockType.AIMeetingSummaryBlock, label: 'Summary' }, + { key: 'notes', type: BlockType.AIMeetingNotesBlock, label: 'Notes' }, + { key: 'transcript', type: BlockType.AIMeetingTranscriptionBlock, label: 'Transcript' }, +]; + +interface SectionData { + type: BlockType; + content: string; +} + +interface MockAIMeetingBlockProps { + title?: string; + sections: SectionData[]; + initialTabIndex?: number; + onTabChange?: (index: number) => void; + onCopy?: (tabKey: string, content: string) => void; + readOnly?: boolean; +} + +function MockAIMeetingBlock({ + title = 'Meeting', + sections, + initialTabIndex = 0, + onTabChange, + onCopy, + readOnly = false, +}: MockAIMeetingBlockProps) { + const [activeIndex, setActiveIndex] = useState(initialTabIndex); + const [menuOpen, setMenuOpen] = useState(false); + + // Calculate available tabs based on sections + const availableTabs = TAB_DEFS.filter((tab) => { + const section = sections.find((s) => s.type === tab.type); + + if (!section) return false; + + // Summary and transcript need content + if (tab.type === BlockType.AIMeetingSummaryBlock || tab.type === BlockType.AIMeetingTranscriptionBlock) { + return section.content.trim().length > 0; + } + + return true; + }); + + const showTabs = availableTabs.length > 1; + const safeIndex = Math.min(activeIndex, availableTabs.length - 1); + const activeTab = availableTabs[safeIndex] ?? availableTabs[0]; + const activeSection = sections.find((s) => s.type === activeTab?.type); + + const handleTabChange = (index: number) => { + setActiveIndex(index); + onTabChange?.(index); + }; + + const handleCopy = () => { + if (activeTab && activeSection) { + onCopy?.(activeTab.key, activeSection.content); + mockNotifySuccess(`${activeTab.label} copied`); + } + + setMenuOpen(false); + }; + + return ( +
+ {/* Title */} +
+ undefined} + /> +
+ + {/* Tabs */} + {showTabs && ( +
+ {availableTabs.map((tab, index) => ( + + ))} + + {/* More menu button */} + +
+ )} + + {/* Copy menu */} + {menuOpen && ( +
+ +
+ )} + + {/* Content sections */} +
+ {sections.map((section) => { + const isActive = section.type === activeTab?.type; + + return ( +
+ {section.content} +
+ ); + })} +
+
+ ); +} + +describe('AIMeetingBlock Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + it('should render the meeting block with title', () => { + render( + + ); + + expect(screen.getByTestId('ai-meeting-block')).toBeTruthy(); + const titleInput = screen.getByTestId('title-input'); + + if (!(titleInput instanceof HTMLInputElement)) { + throw new Error('Expected title input to be an HTMLInputElement'); + } + + expect(titleInput.value).toBe('Weekly Standup'); + }); + + it('should not show tabs when only one section available', () => { + render( + + ); + + expect(screen.queryByTestId('tab-bar')).toBeNull(); + }); + + it('should show tabs when multiple sections available', () => { + render( + + ); + + expect(screen.getByTestId('tab-bar')).toBeTruthy(); + expect(screen.getByTestId('tab-summary')).toBeTruthy(); + expect(screen.getByTestId('tab-notes')).toBeTruthy(); + }); + + it('should hide summary tab when summary has no content', () => { + render( + + ); + + expect(screen.queryByTestId('tab-summary')).toBeNull(); + expect(screen.getByTestId('tab-notes')).toBeTruthy(); + expect(screen.getByTestId('tab-transcript')).toBeTruthy(); + }); + + it('should hide transcript tab when transcript has no content', () => { + render( + + ); + + expect(screen.getByTestId('tab-summary')).toBeTruthy(); + expect(screen.getByTestId('tab-notes')).toBeTruthy(); + expect(screen.queryByTestId('tab-transcript')).toBeNull(); + }); + }); + + describe('Tab Switching', () => { + it('should switch tab when clicking tab button', () => { + const onTabChange = jest.fn(); + + render( + + ); + + // Initially on summary (index 0) + const summaryTab = screen.getByTestId('tab-summary'); + + expect(summaryTab.getAttribute('data-active')).toBe('true'); + + // Click notes tab + fireEvent.click(screen.getByTestId('tab-notes')); + + expect(onTabChange).toHaveBeenCalledWith(1); + + const notesTab = screen.getByTestId('tab-notes'); + + expect(notesTab.getAttribute('data-active')).toBe('true'); + }); + + it('should show correct content for active tab', () => { + render( + + ); + + // Summary is active initially + const summarySection = screen.getByTestId(`section-${BlockType.AIMeetingSummaryBlock}`); + const notesSection = screen.getByTestId(`section-${BlockType.AIMeetingNotesBlock}`); + + expect(summarySection.style.display).toBe('block'); + expect(notesSection.style.display).toBe('none'); + + // Switch to notes + fireEvent.click(screen.getByTestId('tab-notes')); + + expect(summarySection.style.display).toBe('none'); + expect(notesSection.style.display).toBe('block'); + }); + + it('should maintain tab state after multiple switches', () => { + const onTabChange = jest.fn(); + + render( + + ); + + // Switch: summary -> notes -> transcript -> notes + fireEvent.click(screen.getByTestId('tab-notes')); + fireEvent.click(screen.getByTestId('tab-transcript')); + fireEvent.click(screen.getByTestId('tab-notes')); + + expect(onTabChange).toHaveBeenCalledTimes(3); + expect(onTabChange).toHaveBeenLastCalledWith(1); + + const notesTab = screen.getByTestId('tab-notes'); + + expect(notesTab.getAttribute('data-active')).toBe('true'); + }); + }); + + describe('Copy Functionality', () => { + it('should open copy menu when clicking more button', () => { + render( + + ); + + expect(screen.queryByTestId('copy-menu')).toBeNull(); + + fireEvent.click(screen.getByTestId('more-menu-button')); + + expect(screen.getByTestId('copy-menu')).toBeTruthy(); + }); + + it('should call onCopy with correct tab and content', () => { + const onCopy = jest.fn(); + + render( + + ); + + // Open menu and copy summary + fireEvent.click(screen.getByTestId('more-menu-button')); + fireEvent.click(screen.getByTestId('copy-button')); + + expect(onCopy).toHaveBeenCalledWith('summary', 'Summary content here'); + expect(mockNotifySuccess).toHaveBeenCalledWith('Summary copied'); + }); + + it('should copy content of active tab', () => { + const onCopy = jest.fn(); + + render( + + ); + + // Switch to notes tab + fireEvent.click(screen.getByTestId('tab-notes')); + + // Open menu and copy + fireEvent.click(screen.getByTestId('more-menu-button')); + fireEvent.click(screen.getByTestId('copy-button')); + + expect(onCopy).toHaveBeenCalledWith('notes', 'Notes content'); + expect(mockNotifySuccess).toHaveBeenCalledWith('Notes copied'); + }); + + it('should close menu after copying', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('more-menu-button')); + expect(screen.getByTestId('copy-menu')).toBeTruthy(); + + fireEvent.click(screen.getByTestId('copy-button')); + expect(screen.queryByTestId('copy-menu')).toBeNull(); + }); + }); + + describe('Initial State', () => { + it('should respect initialTabIndex', () => { + render( + + ); + + const transcriptTab = screen.getByTestId('tab-transcript'); + + expect(transcriptTab.getAttribute('data-active')).toBe('true'); + }); + + it('should clamp to last tab if initialTabIndex out of range', () => { + render( + + ); + + // Should clamp to last available tab (notes at index 1) + const notesTab = screen.getByTestId('tab-notes'); + + expect(notesTab).toBeTruthy(); + expect(notesTab.getAttribute('data-active')).toBe('true'); + }); + }); +}); + +/** + * Simplified Reference Badge component for testing + */ +interface MockReferenceBadgeProps { + number: number; + hasError?: boolean; + onClick?: () => void; +} + +function MockReferenceBadge({ number, hasError, onClick }: MockReferenceBadgeProps) { + return ( + + ); +} + +interface MockReferencePopoverProps { + references: Array<{ + blockId: string; + status: 'exists' | 'deleted'; + content?: string; + sourceType?: 'transcript' | 'notes'; + timestamp?: number; + }>; + onReferenceClick?: (blockId: string, sourceType: string) => void; +} + +function MockReferencePopover({ references, onReferenceClick }: MockReferencePopoverProps) { + return ( +
+ {references.map((ref, index) => ( +
+ {ref.status === 'deleted' ? ( + Source was deleted + ) : ( + + )} +
+ ))} +
+ ); +} + +describe('InlineReference Component', () => { + describe('Reference Badge', () => { + it('should render badge with number', () => { + render(); + + const badge = screen.getByTestId('reference-badge-1'); + + expect(badge.textContent).toBe('1'); + }); + + it('should show error state for deleted references', () => { + render(); + + const badge = screen.getByTestId('reference-badge-2'); + + expect(badge.getAttribute('data-has-error')).toBe('true'); + }); + + it('should call onClick when clicked', () => { + const onClick = jest.fn(); + + render(); + fireEvent.click(screen.getByTestId('reference-badge-1')); + + expect(onClick).toHaveBeenCalled(); + }); + }); + + describe('Reference Popover', () => { + it('should render all reference items', () => { + render( + + ); + + expect(screen.getByTestId('reference-item-0')).toBeTruthy(); + expect(screen.getByTestId('reference-item-1')).toBeTruthy(); + }); + + it('should show deleted warning for deleted references', () => { + render( + + ); + + const warning = screen.getByTestId('deleted-warning-0'); + + expect(warning.textContent).toBe('Source was deleted'); + }); + + it('should show timestamp for transcript references', () => { + render( + + ); + + const timestamp = screen.getByTestId('timestamp-0'); + + expect(timestamp.textContent).toBe('125s'); + }); + + it('should call onReferenceClick with blockId and sourceType', () => { + const onReferenceClick = jest.fn(); + + render( + + ); + + fireEvent.click(screen.getByTestId('reference-link-0')); + + expect(onReferenceClick).toHaveBeenCalledWith('block-123', 'transcript'); + }); + + it('should distinguish between notes and transcript sources', () => { + render( + + ); + + const notesItem = screen.getByTestId('reference-item-0'); + const transcriptItem = screen.getByTestId('reference-item-1'); + + expect(notesItem.getAttribute('data-source-type')).toBe('notes'); + expect(transcriptItem.getAttribute('data-source-type')).toBe('transcript'); + }); + }); + + describe('Reference Click Navigation', () => { + it('should trigger tab switch when clicking transcript reference', () => { + const onReferenceClick = jest.fn(); + + render( + + ); + + fireEvent.click(screen.getByTestId('reference-link-0')); + + expect(onReferenceClick).toHaveBeenCalledWith('transcript-block', 'transcript'); + }); + + it('should trigger tab switch when clicking notes reference', () => { + const onReferenceClick = jest.fn(); + + render( + + ); + + fireEvent.click(screen.getByTestId('reference-link-0')); + + expect(onReferenceClick).toHaveBeenCalledWith('notes-block', 'notes'); + }); + }); +}); diff --git a/src/components/editor/components/blocks/ai-meeting/__tests__/AIMeetingBlock.test.ts b/src/components/editor/components/blocks/ai-meeting/__tests__/AIMeetingBlock.test.ts new file mode 100644 index 000000000..54aac6e6d --- /dev/null +++ b/src/components/editor/components/blocks/ai-meeting/__tests__/AIMeetingBlock.test.ts @@ -0,0 +1,594 @@ +import { expect, describe, it } from '@jest/globals'; +import { Element, Node, Text } from 'slate'; + +import { BlockType } from '@/application/types'; + +/** + * Test helpers - replicating the logic from AIMeetingBlock.tsx + */ + +const hasNodeContent = (node?: Node) => { + if (!node) return false; + + const getBlockTextContent = (n: Node): string => { + if (Text.isText(n)) return n.text; + if (Element.isElement(n)) { + return n.children.map((child) => getBlockTextContent(child)).join(''); + } + + return ''; + }; + + const text = getBlockTextContent(node).trim(); + + return text.length > 0; +}; + +const parseSpeakerInfoMap = (raw: unknown) => { + if (!raw) return null; + + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw) as Record>; + + if (parsed && typeof parsed === 'object') return parsed; + } catch { + return null; + } + } + + if (typeof raw === 'object') { + return raw as Record>; + } + + return null; +}; + +const getBaseSpeakerId = (speakerId: string) => { + const [base] = speakerId.split('_'); + + return base || speakerId; +}; + +const resolveSpeakerName = ( + speakerId: string | undefined, + speakerInfoMap: Record> | null, + unknownLabel: string, + getFallbackLabel: (id: string) => string +) => { + if (!speakerId) return unknownLabel; + + const baseId = getBaseSpeakerId(speakerId); + const info = speakerInfoMap?.[speakerId] ?? speakerInfoMap?.[baseId]; + const name = typeof info?.name === 'string' ? info?.name?.trim() : ''; + + if (name) return name; + + return getFallbackLabel(baseId); +}; + +const cloneNode = (node: T): T => { + return JSON.parse(JSON.stringify(node)) as T; +}; + +const insertSpeakerPrefix = (node: Node, speakerName: string): Node => { + if (!Element.isElement(node)) return node; + + const cloned = cloneNode(node); + const prefix = `${speakerName}: `; + + const insertIntoChildren = (children: Node[]): boolean => { + for (let index = 0; index < children.length; index += 1) { + const child = children[index]; + + if (Text.isText(child)) { + children.splice(index, 0, { text: prefix, bold: true }); + return true; + } + + if (Element.isElement(child)) { + const inserted = insertIntoChildren(child.children as Node[]); + + if (inserted) return true; + } + } + + return false; + }; + + insertIntoChildren(cloned.children as Node[]); + + return cloned; +}; + +const buildCopyText = (node?: Node) => { + if (!node || !Element.isElement(node)) return ''; + + const getBlockTextContent = (n: Node): string => { + if (Text.isText(n)) return n.text; + if (Element.isElement(n)) { + return n.children.map((child) => getBlockTextContent(child)).join(''); + } + + return ''; + }; + + const lines = node.children + .map((child) => getBlockTextContent(child).trim()) + .filter((line) => line.length > 0); + + if (lines.length) return lines.join('\n'); + + return getBlockTextContent(node).trim(); +}; + +type TabKey = 'summary' | 'notes' | 'transcript'; + +interface TabDef { + key: TabKey; + type: BlockType; +} + +const TAB_DEFS: TabDef[] = [ + { key: 'summary', type: BlockType.AIMeetingSummaryBlock }, + { key: 'notes', type: BlockType.AIMeetingNotesBlock }, + { key: 'transcript', type: BlockType.AIMeetingTranscriptionBlock }, +]; + +const calculateAvailableTabs = (children: Array) => { + return TAB_DEFS.filter((tab) => { + const match = children.find((child) => child.type === tab.type); + + if (!match) return false; + + if ( + tab.type === BlockType.AIMeetingSummaryBlock || + tab.type === BlockType.AIMeetingTranscriptionBlock + ) { + return hasNodeContent(match); + } + + return true; + }); +}; + +/** + * Mock data factories + */ +const createTextNode = (text: string): Element => ({ + type: 'text' as unknown as BlockType, + children: [{ text }], +} as unknown as Element); + +const createParagraphNode = (text: string, blockId: string): Element => + ({ + type: BlockType.Paragraph, + blockId, + children: [createTextNode(text)], + }) as unknown as Element; + +const createSpeakerNode = (speakerId: string, timestamp: number, content: string): Element => + ({ + type: BlockType.AIMeetingSpeakerBlock, + blockId: `speaker-${speakerId}`, + data: { speaker_id: speakerId, timestamp }, + children: [createParagraphNode(content, `para-${speakerId}`)], + }) as unknown as Element; + +const createSectionNode = (type: BlockType, children: Element[]): Element => + ({ + type, + blockId: `section-${type}`, + children, + }) as unknown as Element; + +describe('AIMeetingBlock Logic', () => { + describe('parseSpeakerInfoMap', () => { + it('should return null for null/undefined input', () => { + expect(parseSpeakerInfoMap(null)).toBeNull(); + expect(parseSpeakerInfoMap(undefined)).toBeNull(); + }); + + it('should parse valid JSON string', () => { + const jsonStr = JSON.stringify({ + speaker1: { name: 'Alice', email: 'alice@example.com' }, + speaker2: { name: 'Bob' }, + }); + + const result = parseSpeakerInfoMap(jsonStr); + + expect(result).not.toBeNull(); + expect(result?.speaker1.name).toBe('Alice'); + expect(result?.speaker2.name).toBe('Bob'); + }); + + it('should return object directly if already an object', () => { + const obj = { + speaker1: { name: 'Alice' }, + }; + + const result = parseSpeakerInfoMap(obj); + + expect(result).toBe(obj); + }); + + it('should return null for invalid JSON string', () => { + expect(parseSpeakerInfoMap('not valid json')).toBeNull(); + expect(parseSpeakerInfoMap('{invalid')).toBeNull(); + }); + + it('should return null for non-object types', () => { + expect(parseSpeakerInfoMap(123)).toBeNull(); + expect(parseSpeakerInfoMap(true)).toBeNull(); + }); + }); + + describe('getBaseSpeakerId', () => { + it('should extract base id before underscore', () => { + expect(getBaseSpeakerId('speaker1_segment1')).toBe('speaker1'); + expect(getBaseSpeakerId('user_123_456')).toBe('user'); + }); + + it('should return original id if no underscore', () => { + expect(getBaseSpeakerId('speaker1')).toBe('speaker1'); + expect(getBaseSpeakerId('user')).toBe('user'); + }); + + it('should handle empty string', () => { + expect(getBaseSpeakerId('')).toBe(''); + }); + }); + + describe('resolveSpeakerName', () => { + const unknownLabel = 'Unknown speaker'; + const getFallbackLabel = (id: string) => `Speaker ${id}`; + + it('should return unknown label for undefined speaker id', () => { + expect(resolveSpeakerName(undefined, null, unknownLabel, getFallbackLabel)).toBe(unknownLabel); + }); + + it('should return name from speaker info map', () => { + const infoMap = { + speaker1: { name: 'Alice' }, + }; + + expect(resolveSpeakerName('speaker1', infoMap, unknownLabel, getFallbackLabel)).toBe('Alice'); + }); + + it('should lookup using base id if direct match not found', () => { + const infoMap = { + speaker1: { name: 'Alice' }, + }; + + expect(resolveSpeakerName('speaker1_segment2', infoMap, unknownLabel, getFallbackLabel)).toBe( + 'Alice' + ); + }); + + it('should return fallback label if no name found', () => { + const infoMap = { + speaker1: { email: 'alice@example.com' }, + }; + + expect(resolveSpeakerName('speaker1', infoMap, unknownLabel, getFallbackLabel)).toBe( + 'Speaker speaker1' + ); + }); + + it('should trim whitespace from name', () => { + const infoMap = { + speaker1: { name: ' Alice ' }, + }; + + expect(resolveSpeakerName('speaker1', infoMap, unknownLabel, getFallbackLabel)).toBe('Alice'); + }); + + it('should handle empty name as fallback', () => { + const infoMap = { + speaker1: { name: ' ' }, + }; + + expect(resolveSpeakerName('speaker1', infoMap, unknownLabel, getFallbackLabel)).toBe( + 'Speaker speaker1' + ); + }); + }); + + describe('hasNodeContent', () => { + it('should return false for undefined/null', () => { + expect(hasNodeContent(undefined)).toBe(false); + }); + + it('should return false for empty text', () => { + const node = createTextNode(''); + + expect(hasNodeContent(node)).toBe(false); + }); + + it('should return false for whitespace only', () => { + const node = createTextNode(' '); + + expect(hasNodeContent(node)).toBe(false); + }); + + it('should return true for non-empty text', () => { + const node = createTextNode('Hello'); + + expect(hasNodeContent(node)).toBe(true); + }); + + it('should check nested content', () => { + const node = createParagraphNode('Nested content', 'block1'); + + expect(hasNodeContent(node)).toBe(true); + }); + }); + + describe('calculateAvailableTabs', () => { + it('should return notes tab even if empty', () => { + const children = [createSectionNode(BlockType.AIMeetingNotesBlock, [])]; + + const tabs = calculateAvailableTabs(children as Array); + + expect(tabs).toHaveLength(1); + expect(tabs[0].key).toBe('notes'); + }); + + it('should include summary tab only if it has content', () => { + const emptyChildren = [ + createSectionNode(BlockType.AIMeetingSummaryBlock, []), + createSectionNode(BlockType.AIMeetingNotesBlock, []), + ]; + + const tabsEmpty = calculateAvailableTabs(emptyChildren as Array); + + expect(tabsEmpty.find((t) => t.key === 'summary')).toBeUndefined(); + + const withContent = [ + createSectionNode(BlockType.AIMeetingSummaryBlock, [ + createParagraphNode('Summary content', 'sum1'), + ]), + createSectionNode(BlockType.AIMeetingNotesBlock, []), + ]; + + const tabsWithContent = calculateAvailableTabs( + withContent as Array + ); + + expect(tabsWithContent.find((t) => t.key === 'summary')).toBeDefined(); + }); + + it('should include transcript tab only if it has content', () => { + const emptyChildren = [ + createSectionNode(BlockType.AIMeetingTranscriptionBlock, []), + createSectionNode(BlockType.AIMeetingNotesBlock, []), + ]; + + const tabsEmpty = calculateAvailableTabs(emptyChildren as Array); + + expect(tabsEmpty.find((t) => t.key === 'transcript')).toBeUndefined(); + + const withContent = [ + createSectionNode(BlockType.AIMeetingTranscriptionBlock, [ + createSpeakerNode('s1', 0, 'Hello'), + ]), + createSectionNode(BlockType.AIMeetingNotesBlock, []), + ]; + + const tabsWithContent = calculateAvailableTabs( + withContent as Array + ); + + expect(tabsWithContent.find((t) => t.key === 'transcript')).toBeDefined(); + }); + + it('should return all tabs when all have content', () => { + const children = [ + createSectionNode(BlockType.AIMeetingSummaryBlock, [ + createParagraphNode('Summary', 'sum1'), + ]), + createSectionNode(BlockType.AIMeetingNotesBlock, [createParagraphNode('Notes', 'note1')]), + createSectionNode(BlockType.AIMeetingTranscriptionBlock, [ + createSpeakerNode('s1', 0, 'Transcript'), + ]), + ]; + + const tabs = calculateAvailableTabs(children as Array); + + expect(tabs).toHaveLength(3); + expect(tabs.map((t) => t.key)).toEqual(['summary', 'notes', 'transcript']); + }); + }); + + describe('insertSpeakerPrefix', () => { + it('should insert speaker prefix before first text node', () => { + const node = createParagraphNode('Hello world', 'block1'); + const result = insertSpeakerPrefix(node, 'Alice'); + + expect(Element.isElement(result)).toBe(true); + const textChildren = (result as Element).children[0] as Element; + const texts = textChildren.children as Array<{ text: string; bold?: boolean }>; + + expect(texts[0].text).toBe('Alice: '); + expect(texts[0].bold).toBe(true); + expect(texts[1].text).toBe('Hello world'); + }); + + it('should return non-element nodes unchanged', () => { + const textNode = { text: 'plain text' } as unknown as Node; + const result = insertSpeakerPrefix(textNode, 'Alice'); + + expect(result).toEqual(textNode); + }); + + it('should not modify the original node', () => { + const original = createParagraphNode('Original', 'block1'); + const originalJson = JSON.stringify(original); + + insertSpeakerPrefix(original, 'Alice'); + + expect(JSON.stringify(original)).toBe(originalJson); + }); + }); + + describe('buildCopyText', () => { + it('should return empty string for undefined/null', () => { + expect(buildCopyText(undefined)).toBe(''); + }); + + it('should return empty string for non-element nodes', () => { + const textNode = { text: 'plain' } as unknown as Node; + + expect(buildCopyText(textNode)).toBe(''); + }); + + it('should join child text content with newlines', () => { + const node = { + type: BlockType.AIMeetingSummaryBlock, + children: [ + createParagraphNode('Line 1', 'p1'), + createParagraphNode('Line 2', 'p2'), + createParagraphNode('Line 3', 'p3'), + ], + } as unknown as Element; + + expect(buildCopyText(node)).toBe('Line 1\nLine 2\nLine 3'); + }); + + it('should filter out empty lines', () => { + const node = { + type: BlockType.AIMeetingSummaryBlock, + children: [ + createParagraphNode('Line 1', 'p1'), + createParagraphNode('', 'p2'), + createParagraphNode('Line 3', 'p3'), + ], + } as unknown as Element; + + expect(buildCopyText(node)).toBe('Line 1\nLine 3'); + }); + + it('should trim whitespace from lines', () => { + const node = { + type: BlockType.AIMeetingSummaryBlock, + children: [createParagraphNode(' Line 1 ', 'p1'), createParagraphNode(' Line 2 ', 'p2')], + } as unknown as Element; + + expect(buildCopyText(node)).toBe('Line 1\nLine 2'); + }); + }); + + describe('cloneNode', () => { + it('should create a deep copy', () => { + const original = createParagraphNode('Test', 'block1'); + const cloned = cloneNode(original); + + expect(cloned).toEqual(original); + expect(cloned).not.toBe(original); + }); + + it('should not share references with original', () => { + const original = createParagraphNode('Test', 'block1'); + const cloned = cloneNode(original); + + (cloned.children[0] as Element).children[0] = { text: 'Modified' }; + + const originalText = ((original.children[0] as Element).children[0] as { text: string }).text; + + expect(originalText).toBe('Test'); + }); + }); +}); + +describe('Copy with Speaker Processing', () => { + const speakerInfoMap = { + s1: { name: 'Alice' }, + s2: { name: 'Bob' }, + }; + + const processNodesForCopy = ( + nodes: Node[], + infoMap: Record> | null + ) => { + const unknownLabel = 'Unknown speaker'; + const getFallbackLabel = (id: string) => `Speaker ${id}`; + + const processed: Node[] = []; + + nodes.forEach((node) => { + if (Element.isElement(node) && node.type === BlockType.AIMeetingSpeakerBlock) { + const speakerData = node.data as Record | undefined; + const speakerId = (speakerData?.speaker_id || speakerData?.speakerId) as string | undefined; + const speakerName = resolveSpeakerName(speakerId, infoMap, unknownLabel, getFallbackLabel); + const speakerChildren = node.children ?? []; + + speakerChildren.forEach((child, index) => { + const clonedChild = cloneNode(child); + + if (index === 0) { + processed.push(insertSpeakerPrefix(clonedChild, speakerName)); + } else { + processed.push(clonedChild); + } + }); + return; + } + + processed.push(cloneNode(node)); + }); + + return processed; + }; + + it('should prepend speaker name to speaker block content', () => { + const nodes = [createSpeakerNode('s1', 0, 'Hello everyone')]; + + const processed = processNodesForCopy(nodes, speakerInfoMap); + + expect(processed.length).toBe(1); + const textContent = ((processed[0] as Element).children[0] as Element) + .children as Array<{ text: string; bold?: boolean }>; + + expect(textContent[0].text).toBe('Alice: '); + expect(textContent[0].bold).toBe(true); + }); + + it('should use fallback label for unknown speakers', () => { + const nodes = [createSpeakerNode('unknown', 0, 'Message')]; + + const processed = processNodesForCopy(nodes, speakerInfoMap); + const textContent = ((processed[0] as Element).children[0] as Element) + .children as Array<{ text: string }>; + + expect(textContent[0].text).toBe('Speaker unknown: '); + }); + + it('should handle multiple speaker blocks', () => { + const nodes = [ + createSpeakerNode('s1', 0, 'Hello'), + createSpeakerNode('s2', 10, 'Hi there'), + createSpeakerNode('s1', 20, 'How are you?'), + ]; + + const processed = processNodesForCopy(nodes, speakerInfoMap); + + expect(processed.length).toBe(3); + + const getText = (node: Node) => { + return ((node as Element).children[0] as Element).children as Array<{ text: string }>; + }; + + expect(getText(processed[0])[0].text).toBe('Alice: '); + expect(getText(processed[1])[0].text).toBe('Bob: '); + expect(getText(processed[2])[0].text).toBe('Alice: '); + }); + + it('should pass through non-speaker nodes unchanged', () => { + const nodes = [createParagraphNode('Regular paragraph', 'p1')]; + + const processed = processNodesForCopy(nodes, speakerInfoMap); + + expect(processed.length).toBe(1); + expect((processed[0] as Element).type).toBe(BlockType.Paragraph); + }); +}); diff --git a/src/components/editor/components/blocks/ai-meeting/__tests__/AIMeetingSpeakerBlock.test.ts b/src/components/editor/components/blocks/ai-meeting/__tests__/AIMeetingSpeakerBlock.test.ts new file mode 100644 index 000000000..99126b893 --- /dev/null +++ b/src/components/editor/components/blocks/ai-meeting/__tests__/AIMeetingSpeakerBlock.test.ts @@ -0,0 +1,371 @@ +import { expect, describe, it } from '@jest/globals'; + +/** + * Test helpers - replicating the logic from AIMeetingSpeakerBlock.tsx + */ + +const parseSpeakerInfoMap = (raw: unknown) => { + if (!raw) return null; + + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw) as Record>; + + if (parsed && typeof parsed === 'object') return parsed; + } catch { + return null; + } + } + + if (typeof raw === 'object') { + return raw as Record>; + } + + return null; +}; + +const getBaseSpeakerId = (speakerId: string) => { + const [base] = speakerId.split('_'); + + return base || speakerId; +}; + +interface SpeakerInfo { + name: string; + email: string; + avatarUrl: string; +} + +const resolveSpeakerInfo = ( + speakerId?: string, + infoMap?: Record> | null, + unknownLabel?: string, + fallbackLabel?: (id: string) => string +): SpeakerInfo => { + const resolvedUnknownLabel = unknownLabel ?? 'Unknown speaker'; + + if (!speakerId) { + return { + name: resolvedUnknownLabel, + email: '', + avatarUrl: '', + }; + } + + const baseId = getBaseSpeakerId(speakerId); + const info = infoMap?.[speakerId] ?? infoMap?.[baseId]; + const name = typeof info?.name === 'string' ? info?.name?.trim() : ''; + const email = typeof info?.email === 'string' ? info?.email?.trim() : ''; + const avatarUrl = typeof info?.avatar_url === 'string' ? info?.avatar_url?.trim() : ''; + + if (name) { + return { + name, + email, + avatarUrl, + }; + } + + if (!baseId) { + return { + name: resolvedUnknownLabel, + email, + avatarUrl, + }; + } + + return { + name: fallbackLabel ? fallbackLabel(baseId) : `Speaker ${baseId}`, + email, + avatarUrl, + }; +}; + +const getAvatarLabel = ( + speakerName: string, + speakerId: string | undefined, + unknownLabel: string +): string => { + if (speakerName && speakerName !== unknownLabel) { + return speakerName.trim().charAt(0).toUpperCase(); + } + + if (speakerId) return getBaseSpeakerId(speakerId).charAt(0).toUpperCase(); + return '?'; +}; + +describe('AIMeetingSpeakerBlock Logic', () => { + const unknownLabel = 'Unknown speaker'; + const getFallbackLabel = (id: string) => `Speaker ${id}`; + + describe('resolveSpeakerInfo', () => { + it('should return unknown label for undefined speaker id', () => { + const result = resolveSpeakerInfo(undefined, null, unknownLabel, getFallbackLabel); + + expect(result.name).toBe(unknownLabel); + expect(result.email).toBe(''); + expect(result.avatarUrl).toBe(''); + }); + + it('should return full speaker info when available', () => { + const infoMap = { + speaker1: { + name: 'Alice Johnson', + email: 'alice@example.com', + avatar_url: 'https://example.com/alice.jpg', + }, + }; + + const result = resolveSpeakerInfo('speaker1', infoMap, unknownLabel, getFallbackLabel); + + expect(result.name).toBe('Alice Johnson'); + expect(result.email).toBe('alice@example.com'); + expect(result.avatarUrl).toBe('https://example.com/alice.jpg'); + }); + + it('should lookup by base id when direct match not found', () => { + const infoMap = { + speaker1: { + name: 'Alice', + email: 'alice@example.com', + }, + }; + + const result = resolveSpeakerInfo('speaker1_segment5', infoMap, unknownLabel, getFallbackLabel); + + expect(result.name).toBe('Alice'); + expect(result.email).toBe('alice@example.com'); + }); + + it('should prefer direct match over base id match', () => { + const infoMap = { + speaker1: { + name: 'Alice (Base)', + }, + speaker1_segment5: { + name: 'Alice (Segment 5)', + }, + }; + + const result = resolveSpeakerInfo('speaker1_segment5', infoMap, unknownLabel, getFallbackLabel); + + expect(result.name).toBe('Alice (Segment 5)'); + }); + + it('should use fallback label when name is not available', () => { + const infoMap = { + speaker1: { + email: 'alice@example.com', + }, + }; + + const result = resolveSpeakerInfo('speaker1', infoMap, unknownLabel, getFallbackLabel); + + expect(result.name).toBe('Speaker speaker1'); + expect(result.email).toBe('alice@example.com'); + }); + + it('should handle empty name as fallback', () => { + const infoMap = { + speaker1: { + name: '', + }, + }; + + const result = resolveSpeakerInfo('speaker1', infoMap, unknownLabel, getFallbackLabel); + + expect(result.name).toBe('Speaker speaker1'); + }); + + it('should handle whitespace-only name as fallback', () => { + const infoMap = { + speaker1: { + name: ' ', + }, + }; + + const result = resolveSpeakerInfo('speaker1', infoMap, unknownLabel, getFallbackLabel); + + expect(result.name).toBe('Speaker speaker1'); + }); + + it('should trim whitespace from all fields', () => { + const infoMap = { + speaker1: { + name: ' Alice ', + email: ' alice@example.com ', + avatar_url: ' https://example.com/alice.jpg ', + }, + }; + + const result = resolveSpeakerInfo('speaker1', infoMap, unknownLabel, getFallbackLabel); + + expect(result.name).toBe('Alice'); + expect(result.email).toBe('alice@example.com'); + expect(result.avatarUrl).toBe('https://example.com/alice.jpg'); + }); + + it('should handle missing info map', () => { + const result = resolveSpeakerInfo('speaker1', null, unknownLabel, getFallbackLabel); + + expect(result.name).toBe('Speaker speaker1'); + }); + + it('should handle non-string field values', () => { + const infoMap = { + speaker1: { + name: 123, + email: true, + avatar_url: { url: 'test' }, + }, + }; + + const result = resolveSpeakerInfo('speaker1', infoMap as unknown as Record>, unknownLabel, getFallbackLabel); + + expect(result.name).toBe('Speaker speaker1'); + expect(result.email).toBe(''); + expect(result.avatarUrl).toBe(''); + }); + }); + + describe('getAvatarLabel', () => { + it('should return first character of speaker name uppercased', () => { + expect(getAvatarLabel('Alice', 'speaker1', unknownLabel)).toBe('A'); + expect(getAvatarLabel('bob', 'speaker2', unknownLabel)).toBe('B'); + expect(getAvatarLabel('Charlie Smith', 'speaker3', unknownLabel)).toBe('C'); + }); + + it('should use speaker id when name is unknown label', () => { + expect(getAvatarLabel(unknownLabel, 'speaker1', unknownLabel)).toBe('S'); + expect(getAvatarLabel(unknownLabel, 'alice_1', unknownLabel)).toBe('A'); + }); + + it('should use speaker id when name is empty', () => { + expect(getAvatarLabel('', 'speaker1', unknownLabel)).toBe('S'); + }); + + it('should return ? when no speaker id', () => { + expect(getAvatarLabel(unknownLabel, undefined, unknownLabel)).toBe('?'); + expect(getAvatarLabel('', undefined, unknownLabel)).toBe('?'); + }); + + it('should handle whitespace in name', () => { + expect(getAvatarLabel(' Alice ', 'speaker1', unknownLabel)).toBe('A'); + }); + }); + + describe('parseSpeakerInfoMap', () => { + it('should parse valid JSON string with multiple speakers', () => { + const jsonStr = JSON.stringify({ + speaker1: { name: 'Alice', email: 'alice@example.com' }, + speaker2: { name: 'Bob', avatar_url: 'https://example.com/bob.jpg' }, + speaker3: { name: 'Charlie' }, + }); + + const result = parseSpeakerInfoMap(jsonStr); + + expect(result).not.toBeNull(); + expect(Object.keys(result!)).toHaveLength(3); + expect(result?.speaker1.name).toBe('Alice'); + expect(result?.speaker2.avatar_url).toBe('https://example.com/bob.jpg'); + }); + + it('should return object directly if already parsed', () => { + const obj = { + speaker1: { name: 'Alice' }, + }; + + const result = parseSpeakerInfoMap(obj); + + expect(result).toBe(obj); + }); + + it('should handle empty object', () => { + const result = parseSpeakerInfoMap({}); + + expect(result).toEqual({}); + }); + + it('should handle empty JSON string', () => { + const result = parseSpeakerInfoMap('{}'); + + expect(result).toEqual({}); + }); + }); + + describe('getBaseSpeakerId', () => { + it('should extract base id with various suffixes', () => { + expect(getBaseSpeakerId('speaker1_0')).toBe('speaker1'); + expect(getBaseSpeakerId('speaker1_segment_1')).toBe('speaker1'); + expect(getBaseSpeakerId('user_abc_def')).toBe('user'); + }); + + it('should return full id if no underscore', () => { + expect(getBaseSpeakerId('speaker1')).toBe('speaker1'); + expect(getBaseSpeakerId('alice')).toBe('alice'); + }); + + it('should handle numeric ids', () => { + expect(getBaseSpeakerId('123_456')).toBe('123'); + expect(getBaseSpeakerId('123')).toBe('123'); + }); + }); +}); + +describe('Speaker Display Integration', () => { + const unknownLabel = 'Unknown speaker'; + const getFallbackLabel = (id: string) => `Speaker ${id}`; + + it('should correctly display speaker with full info', () => { + const infoMap = { + alice: { + name: 'Alice Johnson', + email: 'alice@company.com', + avatar_url: 'https://example.com/alice.png', + }, + }; + + const info = resolveSpeakerInfo('alice', infoMap, unknownLabel, getFallbackLabel); + const avatarLabel = getAvatarLabel(info.name, 'alice', unknownLabel); + + expect(info.name).toBe('Alice Johnson'); + expect(avatarLabel).toBe('A'); + expect(info.avatarUrl).toBeTruthy(); + }); + + it('should correctly display speaker with only name', () => { + const infoMap = { + bob: { name: 'Bob' }, + }; + + const info = resolveSpeakerInfo('bob', infoMap, unknownLabel, getFallbackLabel); + const avatarLabel = getAvatarLabel(info.name, 'bob', unknownLabel); + + expect(info.name).toBe('Bob'); + expect(avatarLabel).toBe('B'); + expect(info.avatarUrl).toBe(''); + }); + + it('should correctly display unknown speaker', () => { + const info = resolveSpeakerInfo('unknown_speaker_5', null, unknownLabel, getFallbackLabel); + const avatarLabel = getAvatarLabel(info.name, 'unknown_speaker_5', unknownLabel); + + expect(info.name).toBe('Speaker unknown'); + expect(avatarLabel).toBe('S'); + }); + + it('should handle segment-based speaker ids', () => { + const infoMap = { + speaker1: { name: 'First Speaker' }, + }; + + // Multiple segments from same speaker + const info1 = resolveSpeakerInfo('speaker1_0', infoMap, unknownLabel, getFallbackLabel); + const info2 = resolveSpeakerInfo('speaker1_1', infoMap, unknownLabel, getFallbackLabel); + const info3 = resolveSpeakerInfo('speaker1_2', infoMap, unknownLabel, getFallbackLabel); + + expect(info1.name).toBe('First Speaker'); + expect(info2.name).toBe('First Speaker'); + expect(info3.name).toBe('First Speaker'); + }); +}); diff --git a/src/components/editor/components/blocks/ai-meeting/__tests__/ai-meeting.summary-regenerate.test.ts b/src/components/editor/components/blocks/ai-meeting/__tests__/ai-meeting.summary-regenerate.test.ts new file mode 100644 index 000000000..a230718cc --- /dev/null +++ b/src/components/editor/components/blocks/ai-meeting/__tests__/ai-meeting.summary-regenerate.test.ts @@ -0,0 +1,135 @@ +import { + buildSummaryRegeneratePrompt, + DEFAULT_SUMMARY_DETAIL, + DEFAULT_SUMMARY_LANGUAGE, + DEFAULT_SUMMARY_TEMPLATE, + getSummaryDetailId, + getSummaryLanguageCode, + getSummaryTemplateId, + normalizeGeneratedSummaryMarkdown, +} from '../ai-meeting.summary-regenerate'; + +describe('ai-meeting.summary-regenerate', () => { + describe('getSummaryTemplateId', () => { + it('falls back to default for invalid values', () => { + expect(getSummaryTemplateId(undefined)).toBe(DEFAULT_SUMMARY_TEMPLATE); + expect(getSummaryTemplateId('')).toBe(DEFAULT_SUMMARY_TEMPLATE); + expect(getSummaryTemplateId('unknown_template')).toBe(DEFAULT_SUMMARY_TEMPLATE); + }); + + it('returns the valid template id', () => { + expect(getSummaryTemplateId('meeting_minutes')).toBe('meeting_minutes'); + }); + + it('matches ids case-insensitively and normalizes labels', () => { + expect(getSummaryTemplateId('MEETING_MINUTES')).toBe('meeting_minutes'); + expect(getSummaryTemplateId('Meeting Minutes')).toBe('meeting_minutes'); + }); + }); + + describe('getSummaryDetailId', () => { + it('falls back to default for invalid values', () => { + expect(getSummaryDetailId(undefined)).toBe(DEFAULT_SUMMARY_DETAIL); + expect(getSummaryDetailId('')).toBe(DEFAULT_SUMMARY_DETAIL); + expect(getSummaryDetailId('unknown_detail')).toBe(DEFAULT_SUMMARY_DETAIL); + }); + + it('returns the valid detail id', () => { + expect(getSummaryDetailId('detailed')).toBe('detailed'); + }); + + it('matches details case-insensitively and normalizes labels', () => { + expect(getSummaryDetailId('BALANCED')).toBe('balanced'); + expect( + getSummaryDetailId('Brief', [ + { id: 'brief', defaultLabel: 'Brief', prompt: 'brief prompt' }, + { id: 'balanced', defaultLabel: 'Balanced', prompt: 'balanced prompt' }, + ]) + ).toBe('brief'); + }); + }); + + describe('getSummaryLanguageCode', () => { + it('falls back to default for invalid values', () => { + expect(getSummaryLanguageCode(undefined)).toBe(DEFAULT_SUMMARY_LANGUAGE); + expect(getSummaryLanguageCode('')).toBe(DEFAULT_SUMMARY_LANGUAGE); + expect(getSummaryLanguageCode('invalid')).toBe(DEFAULT_SUMMARY_LANGUAGE); + }); + + it('accepts case-insensitive supported languages', () => { + expect(getSummaryLanguageCode('ZH-cn')).toBe('zh-CN'); + expect(getSummaryLanguageCode('en')).toBe('en'); + }); + + it('supports flutter parity languages', () => { + expect(getSummaryLanguageCode('da')).toBe('da'); + expect(getSummaryLanguageCode('sv')).toBe('sv'); + }); + }); + + describe('buildSummaryRegeneratePrompt', () => { + it('includes selected language and summary constraints', () => { + const prompt = buildSummaryRegeneratePrompt({ + templateId: 'action_focused', + detailId: 'concise', + languageCode: 'zh-CN', + }); + + expect(prompt).toContain('Output language: Chinese (Simplified) (zh-CN).'); + expect(prompt).toContain('Do not output citation markers like ^1, [1], or similar.'); + expect(prompt).toContain('1. OVERVIEW'); + expect(prompt).toContain('5. ACTION ITEMS'); + }); + + it('falls back to defaults when options are invalid', () => { + const prompt = buildSummaryRegeneratePrompt({ + templateId: 'invalid_template', + detailId: 'invalid_detail', + languageCode: 'invalid_language', + }); + + expect(prompt).toContain('Output language: English (en).'); + }); + + it('uses remote fixed prompt when template config is provided', () => { + const prompt = buildSummaryRegeneratePrompt({ + templateId: 'auto', + detailId: 'brief', + languageCode: 'zh-CN', + templateConfig: { + fixedPrompt: 'Language Code: ${LANGUAGE_CODE}', + templateOptions: [ + { id: 'auto', defaultLabel: 'Auto', prompt: 'Template prompt text' }, + ], + detailOptions: [ + { id: 'brief', defaultLabel: 'Brief', prompt: 'Detail prompt text' }, + ], + templateSections: [ + { id: 's1', title: 'AI Template', options: [{ id: 'auto', defaultLabel: 'Auto', prompt: 'Template prompt text' }] }, + ], + }, + speakerInfoMap: { + s1: { name: 'Lucas Xu', email: 'lucas.xu@appflowy.io' }, + }, + }); + + expect(prompt).toContain('Language Code: ZH-CN'); + expect(prompt).toContain('Detail Instruction: Detail prompt text'); + expect(prompt).toContain('Meeting Type Template Prompt: Template prompt text'); + expect(prompt).toContain('Meeting Participants:'); + expect(prompt).toContain('- Lucas Xu (email: lucas.xu@appflowy.io)'); + }); + }); + + describe('normalizeGeneratedSummaryMarkdown', () => { + it('unwraps fenced markdown output', () => { + const raw = '```markdown\n# OVERVIEW\n- Item\n```'; + + expect(normalizeGeneratedSummaryMarkdown(raw)).toBe('# OVERVIEW\n- Item'); + }); + + it('returns trimmed plain text when not fenced', () => { + expect(normalizeGeneratedSummaryMarkdown(' # Title ')).toBe('# Title'); + }); + }); +}); diff --git a/src/components/editor/components/blocks/ai-meeting/__tests__/ai-meeting.utils.test.ts b/src/components/editor/components/blocks/ai-meeting/__tests__/ai-meeting.utils.test.ts new file mode 100644 index 000000000..97c62524a --- /dev/null +++ b/src/components/editor/components/blocks/ai-meeting/__tests__/ai-meeting.utils.test.ts @@ -0,0 +1,287 @@ +import { expect, describe, it } from '@jest/globals'; + +import { + documentFragmentToHTML, + formatTimestamp, + isRangeInsideElement, + normalizeAppFlowyClipboardHTML, + plainTextToHTML, + selectionToContextualHTML, + selectionToHTML, + stripTranscriptReferences, + shouldUseRichCopyForTab, +} from '../ai-meeting.utils'; + +describe('formatTimestamp', () => { + describe('valid timestamps', () => { + it('should format seconds only (< 60)', () => { + expect(formatTimestamp(0)).toBe('00:00'); + expect(formatTimestamp(5)).toBe('00:05'); + expect(formatTimestamp(59)).toBe('00:59'); + }); + + it('should format minutes and seconds (< 3600)', () => { + expect(formatTimestamp(60)).toBe('01:00'); + expect(formatTimestamp(125)).toBe('02:05'); + expect(formatTimestamp(3599)).toBe('59:59'); + }); + + it('should format hours, minutes and seconds (>= 3600)', () => { + expect(formatTimestamp(3600)).toBe('01:00:00'); + expect(formatTimestamp(3661)).toBe('01:01:01'); + expect(formatTimestamp(7325)).toBe('02:02:05'); + expect(formatTimestamp(36000)).toBe('10:00:00'); + }); + + it('should pad numbers with leading zeros', () => { + expect(formatTimestamp(1)).toBe('00:01'); + expect(formatTimestamp(61)).toBe('01:01'); + expect(formatTimestamp(3601)).toBe('01:00:01'); + }); + + it('should handle decimal values by flooring', () => { + expect(formatTimestamp(5.9)).toBe('00:05'); + expect(formatTimestamp(65.5)).toBe('01:05'); + }); + }); + + describe('edge cases', () => { + it('should return empty string for undefined', () => { + expect(formatTimestamp(undefined)).toBe(''); + }); + + it('should return empty string for NaN', () => { + expect(formatTimestamp(NaN)).toBe(''); + }); + + it('should return empty string for Infinity', () => { + expect(formatTimestamp(Infinity)).toBe(''); + expect(formatTimestamp(-Infinity)).toBe(''); + }); + + it('should treat negative values as zero', () => { + expect(formatTimestamp(-1)).toBe('00:00'); + expect(formatTimestamp(-100)).toBe('00:00'); + }); + }); +}); + +describe('shouldUseRichCopyForTab', () => { + it('should return true for notes and transcript tabs', () => { + expect(shouldUseRichCopyForTab('notes')).toBe(true); + expect(shouldUseRichCopyForTab('transcript')).toBe(true); + }); + + it('should return false for other tabs', () => { + expect(shouldUseRichCopyForTab('summary')).toBe(false); + expect(shouldUseRichCopyForTab('')).toBe(false); + }); +}); + +describe('HTML copy utils', () => { + it('should strip transcript references like ^5 and dangling ^', () => { + const input = `Case discussion: John\nKey point one ^5\nKey point two ^\nKey point three ^ `; + const output = stripTranscriptReferences(input); + + expect(output).toBe(`Case discussion: John\nKey point one\nKey point two\nKey point three`); + }); + + it('should preserve non-reference caret content like x^2 and word^5', () => { + const input = `Math formula: x^2 + y^3\nToken: word^5\nReference: cited ^8`; + const output = stripTranscriptReferences(input); + + expect(output).toBe(`Math formula: x^2 + y^3\nToken: word^5\nReference: cited`); + }); + + it('should strip split-line references like "^" then "2"', () => { + const input = `DISCUSSION SUMMARY\nKey point: Text line\n^\n2\nNext line ^\n10`; + const output = stripTranscriptReferences(input); + + expect(output).toBe(`DISCUSSION SUMMARY\nKey point: Text line\nNext line\n10`); + }); + + it('should convert plain text to paragraph HTML', () => { + expect(plainTextToHTML('Line 1\nLine 2')).toBe('

Line 1

Line 2

'); + }); + + it('should escape HTML characters in plain text conversion', () => { + expect(plainTextToHTML('')).toBe('

<script>alert(1)</script>

'); + }); + + it('should serialize document fragment to HTML', () => { + const fragment = document.createDocumentFragment(); + const strong = document.createElement('strong'); + + strong.textContent = 'Hello'; + fragment.appendChild(strong); + + expect(documentFragmentToHTML(fragment)).toBe('Hello'); + }); + + it('should serialize selection ranges to HTML', () => { + document.body.innerHTML = '

Hello world

'; + + const root = document.getElementById('root'); + const paragraph = root?.querySelector('p'); + + if (!paragraph) { + throw new Error('Paragraph not found'); + } + + const selection = window.getSelection(); + + if (!selection) { + throw new Error('Selection is not available'); + } + + const range = document.createRange(); + + range.selectNodeContents(paragraph); + selection.removeAllRanges(); + selection.addRange(range); + + expect(selectionToHTML(selection)).toContain('Hello world'); + }); + + it('should add heading context for selection HTML', () => { + document.body.innerHTML = ` +
+
+ Heading text +
+
+ `; + + const headingText = document.querySelector('.text-content'); + const textNode = headingText?.firstChild; + + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) { + throw new Error('Expected heading text node not found'); + } + + const selection = window.getSelection(); + + if (!selection) { + throw new Error('Selection is not available'); + } + + const range = document.createRange(); + + range.setStart(textNode, 0); + range.setEnd(textNode, 7); + selection.removeAllRanges(); + selection.addRange(range); + + expect(selectionToContextualHTML(selection)).toContain('

Heading

'); + }); + + it('should verify range location under target element', () => { + document.body.innerHTML = '

Inside

Outside

'; + + const container = document.getElementById('container'); + const inside = document.getElementById('inside'); + const outside = document.getElementById('outside'); + + if (!container || !inside || !outside) { + throw new Error('Expected elements not found'); + } + + const insideRange = document.createRange(); + const outsideRange = document.createRange(); + + insideRange.selectNodeContents(inside); + outsideRange.selectNodeContents(outside); + + expect(isRangeInsideElement(insideRange, container)).toBe(true); + expect(isRangeInsideElement(outsideRange, container)).toBe(false); + }); + + it('should normalize heading/list/todo blocks to semantic HTML', () => { + const html = ` +
+
+ Title +
+
+
+ Bullet item +
+
+ Todo item +
+ `; + + const normalized = normalizeAppFlowyClipboardHTML(html); + + expect(normalized).toContain('

Title

'); + expect(normalized).toContain('
  • Bullet item
'); + expect(normalized).toContain(''); + expect(normalized).toContain('Todo item'); + }); + + it('should unwrap ai meeting wrapper blocks', () => { + const html = ` +
+
+ Inside summary +
+
+ `; + + const normalized = normalizeAppFlowyClipboardHTML(html); + + expect(normalized).not.toContain('ai_meeting_summary'); + expect(normalized).toContain('

Inside summary

'); + }); + + it('should convert heading containers without block wrapper', () => { + const html = ` +
+ Loose heading +
+ `; + + const normalized = normalizeAppFlowyClipboardHTML(html); + + expect(normalized).toContain('

Loose heading

'); + }); + + it('should unwrap ai meeting section container wrappers', () => { + const html = ` +
+

OVERVIEW

+
  • Key point
+
+ `; + + const normalized = normalizeAppFlowyClipboardHTML(html); + + expect(normalized).not.toContain('ai-meeting-section'); + expect(normalized).toContain('

OVERVIEW

'); + expect(normalized).toContain('
  • Key point
'); + }); + + it('should remove inline reference artifacts from copied HTML', () => { + const html = ` +
+ + + Content line + + ^ + + 2 + + +
+ `; + + const normalized = normalizeAppFlowyClipboardHTML(html); + + expect(normalized).toContain('

'); + expect(normalized).toContain('Content line'); + expect(normalized).not.toContain('ai-meeting-reference'); + expect(normalized).not.toContain('^'); + expect(normalized).not.toContain('>2<'); + }); +}); diff --git a/src/components/editor/components/blocks/ai-meeting/ai-meeting.scss b/src/components/editor/components/blocks/ai-meeting/ai-meeting.scss new file mode 100644 index 000000000..a119b2c10 --- /dev/null +++ b/src/components/editor/components/blocks/ai-meeting/ai-meeting.scss @@ -0,0 +1,80 @@ +.ai-meeting-block { + &[data-ai-meeting-active="summary"] { + .block-element[data-block-type="ai_meeting_notes"], + .block-element[data-block-type="ai_meeting_transcription"] { + display: none !important; + } + } + + &[data-ai-meeting-active="notes"] { + .block-element[data-block-type="ai_meeting_summary"], + .block-element[data-block-type="ai_meeting_transcription"] { + display: none !important; + } + } + + &[data-ai-meeting-active="transcript"] { + .block-element[data-block-type="ai_meeting_summary"], + .block-element[data-block-type="ai_meeting_notes"] { + display: none !important; + } + } + + .block-element[data-block-type="ai_meeting_summary"], + .block-element[data-block-type="ai_meeting_notes"], + .block-element[data-block-type="ai_meeting_transcription"] { + margin-left: 0 !important; + } + + // Reset margin for all child blocks inside transcript, notes, and summary sections + .block-element[data-block-type="ai_meeting_transcription"] > .flex.w-full.flex-col > .block-element, + .block-element[data-block-type="ai_meeting_summary"] > .flex.w-full.flex-col > .block-element, + .block-element[data-block-type="ai_meeting_notes"] > .flex.w-full.flex-col > .block-element { + margin-left: 0 !important; + } + + .ai-meeting-speaker { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px 0; + } + + .ai-meeting-speaker__header { + display: flex; + align-items: center; + gap: 10px; + color: var(--text-secondary); + font-size: 14px; + } + + .ai-meeting-speaker__avatar { + height: 24px; + width: 24px; + font-weight: 600; + border-radius: 9999px; + } + + .ai-meeting-speaker__name { + font-weight: 500; + color: var(--text-primary); + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .ai-meeting-speaker__timestamp { + color: var(--text-tertiary); + font-size: 14px; + } + + .ai-meeting-speaker__content { + padding-left: 0; + } + + .ai-meeting-speaker__content .block-element, + .ai-meeting-speaker .block-element { + margin-left: 0 !important; + } +} diff --git a/src/components/editor/components/blocks/ai-meeting/ai-meeting.summary-regenerate.ts b/src/components/editor/components/blocks/ai-meeting/ai-meeting.summary-regenerate.ts new file mode 100644 index 000000000..06184319b --- /dev/null +++ b/src/components/editor/components/blocks/ai-meeting/ai-meeting.summary-regenerate.ts @@ -0,0 +1,572 @@ +import type { AxiosInstance } from 'axios'; +import { Element, Text } from 'slate'; + +import { YjsEditor } from '@/application/slate-yjs'; +import { slateContentInsertToYData } from '@/application/slate-yjs/utils/convert'; +import { assertDocExists, deleteBlock, getBlock, getChildrenArray } from '@/application/slate-yjs/utils/yjs'; +import { BlockData, BlockType, YjsEditorKey } from '@/application/types'; +import { createInitialInstance, requestInterceptor } from '@/components/chat/lib/requets'; +import { parseMarkdown } from '@/components/editor/parsers/markdown-parser'; +import { ParsedBlock } from '@/components/editor/parsers/types'; + +export interface SummaryTemplateOption { + id: string; + labelKey?: string; + defaultLabel: string; + prompt: string; + icon?: string; +} + +export interface SummaryDetailOption { + id: string; + labelKey?: string; + defaultLabel: string; + prompt: string; +} + +export interface SummaryLanguageOption { + code: string; + labelKey: string; + defaultLabel: string; +} + +export const DEFAULT_SUMMARY_TEMPLATE = 'auto'; +export const DEFAULT_SUMMARY_DETAIL = 'balanced'; +export const DEFAULT_SUMMARY_LANGUAGE = 'en'; + +export const SUMMARY_TEMPLATE_OPTIONS: SummaryTemplateOption[] = [ + { + id: 'auto', + labelKey: 'document.aiMeeting.regenerate.template.auto', + defaultLabel: 'Auto', + prompt: + 'Create a structured meeting summary with key outcomes, rationale, and practical follow-ups. Prefer clear headings and bullet lists.', + }, + { + id: 'meeting_minutes', + labelKey: 'document.aiMeeting.regenerate.template.meetingMinutes', + defaultLabel: 'Meeting minutes', + prompt: + 'Write formal meeting minutes. Focus on chronology, decisions, attendees mentioned in context, and action ownership.', + }, + { + id: 'action_focused', + labelKey: 'document.aiMeeting.regenerate.template.actionFocused', + defaultLabel: 'Action focused', + prompt: + 'Prioritize actionable outcomes. Emphasize next steps, owners, deadlines, and risks that can block execution.', + }, + { + id: 'executive', + labelKey: 'document.aiMeeting.regenerate.template.executive', + defaultLabel: 'Executive', + prompt: + 'Write an executive-level summary with concise business impact, major decisions, and strategic implications.', + }, +]; + +export const SUMMARY_DETAIL_OPTIONS: SummaryDetailOption[] = [ + { + id: 'concise', + labelKey: 'document.aiMeeting.regenerate.detail.concise', + defaultLabel: 'Concise', + prompt: 'Keep it short and high-signal. Minimize wording and avoid repetition.', + }, + { + id: 'balanced', + labelKey: 'document.aiMeeting.regenerate.detail.balanced', + defaultLabel: 'Balanced', + prompt: 'Keep a balanced level of detail suitable for cross-functional collaboration.', + }, + { + id: 'detailed', + labelKey: 'document.aiMeeting.regenerate.detail.detailed', + defaultLabel: 'Detailed', + prompt: 'Provide comprehensive detail, including context, tradeoffs, and nuanced decisions.', + }, +]; + +export const SUMMARY_LANGUAGE_OPTIONS: SummaryLanguageOption[] = [ + { code: 'en', labelKey: 'document.aiMeeting.regenerate.language.english', defaultLabel: 'English' }, + { code: 'zh-CN', labelKey: 'document.aiMeeting.regenerate.language.chineseSimplified', defaultLabel: 'Chinese (Simplified)' }, + { code: 'zh-TW', labelKey: 'document.aiMeeting.regenerate.language.chineseTraditional', defaultLabel: 'Chinese (Traditional)' }, + { code: 'es', labelKey: 'document.aiMeeting.regenerate.language.spanish', defaultLabel: 'Spanish' }, + { code: 'fr', labelKey: 'document.aiMeeting.regenerate.language.french', defaultLabel: 'French' }, + { code: 'de', labelKey: 'document.aiMeeting.regenerate.language.german', defaultLabel: 'German' }, + { code: 'ja', labelKey: 'document.aiMeeting.regenerate.language.japanese', defaultLabel: 'Japanese' }, + { code: 'ko', labelKey: 'document.aiMeeting.regenerate.language.korean', defaultLabel: 'Korean' }, + { code: 'pt', labelKey: 'document.aiMeeting.regenerate.language.portuguese', defaultLabel: 'Portuguese' }, + { code: 'ru', labelKey: 'document.aiMeeting.regenerate.language.russian', defaultLabel: 'Russian' }, + { code: 'th', labelKey: 'document.aiMeeting.regenerate.language.thai', defaultLabel: 'Thai' }, + { code: 'vi', labelKey: 'document.aiMeeting.regenerate.language.vietnamese', defaultLabel: 'Vietnamese' }, + { code: 'da', labelKey: 'document.aiMeeting.regenerate.language.danish', defaultLabel: 'Danish' }, + { code: 'fi', labelKey: 'document.aiMeeting.regenerate.language.finnish', defaultLabel: 'Finnish' }, + { code: 'no', labelKey: 'document.aiMeeting.regenerate.language.norwegian', defaultLabel: 'Norwegian' }, + { code: 'nl', labelKey: 'document.aiMeeting.regenerate.language.dutch', defaultLabel: 'Dutch' }, + { code: 'it', labelKey: 'document.aiMeeting.regenerate.language.italian', defaultLabel: 'Italian' }, + { code: 'sv', labelKey: 'document.aiMeeting.regenerate.language.swedish', defaultLabel: 'Swedish' }, +]; + +export interface SummaryTemplateSection { + id: string; + title: string; + options: SummaryTemplateOption[]; +} + +export interface SummaryRegenerateTemplateConfig { + templateSections: SummaryTemplateSection[]; + templateOptions: SummaryTemplateOption[]; + detailOptions: SummaryDetailOption[]; + fixedPrompt: string; +} + +interface APIResponse { + code: number; + data: T; + message?: string; +} + +interface RemoteSummaryTemplateItem { + prompt_name?: string; + prompt?: string; + icon?: string; +} + +interface RemoteSummaryTemplateSectionV2 { + section_name?: string; + templates?: RemoteSummaryTemplateItem[]; +} + +type RemoteSummaryTemplateSectionV1 = Record; +type RemoteSummaryInstruction = Record; + +interface RemoteSummaryTemplatePayload { + sections?: Array; + instructions?: RemoteSummaryInstruction[]; + fixed_prompt?: string; +} + +const FALLBACK_TEMPLATE_SECTION_TITLE = 'AI Template'; + +export const FALLBACK_SUMMARY_REGENERATE_TEMPLATE_CONFIG: SummaryRegenerateTemplateConfig = { + templateSections: [ + { + id: 'fallback-ai-template', + title: FALLBACK_TEMPLATE_SECTION_TITLE, + options: SUMMARY_TEMPLATE_OPTIONS, + }, + ], + templateOptions: SUMMARY_TEMPLATE_OPTIONS, + detailOptions: SUMMARY_DETAIL_OPTIONS, + fixedPrompt: '', +}; + +let remoteTemplateConfigCache: SummaryRegenerateTemplateConfig | null = null; +let remoteTemplateConfigRequest: Promise | null = null; + +const languageNameByCode = SUMMARY_LANGUAGE_OPTIONS.reduce>((acc, language) => { + acc[language.code.toLowerCase()] = language.defaultLabel; + return acc; +}, {}); + +const normalizeOptionId = (value: string) => value.trim().toLowerCase().replace(/\s+/g, '_'); + +const createTemplateRequestInstance = (requestInstance?: AxiosInstance | null) => { + if (requestInstance) return requestInstance; + + const axiosInstance = createInitialInstance(); + + axiosInstance.interceptors.request.use(requestInterceptor); + return axiosInstance; +}; + +const normalizeRemoteTemplateSections = ( + sections: RemoteSummaryTemplatePayload['sections'] +): SummaryTemplateSection[] => { + if (!Array.isArray(sections)) return []; + + return sections + .map((section, sectionIndex) => { + let title = ''; + let templates: RemoteSummaryTemplateItem[] = []; + + if ( + section && + typeof section === 'object' && + 'section_name' in section && + Array.isArray((section as RemoteSummaryTemplateSectionV2).templates) + ) { + const typedSection = section as RemoteSummaryTemplateSectionV2; + + title = typeof typedSection.section_name === 'string' ? typedSection.section_name.trim() : ''; + templates = typedSection.templates ?? []; + } else if (section && typeof section === 'object') { + const [entryKey, entryValue] = Object.entries(section as RemoteSummaryTemplateSectionV1)[0] ?? []; + + title = typeof entryKey === 'string' ? entryKey.trim() : ''; + templates = Array.isArray(entryValue) ? entryValue : []; + } + + const options = templates + .map((template) => { + const defaultLabel = typeof template?.prompt_name === 'string' ? template.prompt_name.trim() : ''; + const prompt = typeof template?.prompt === 'string' ? template.prompt.trim() : ''; + + if (!defaultLabel || !prompt) return null; + + return { + id: normalizeOptionId(defaultLabel), + defaultLabel, + prompt, + icon: typeof template?.icon === 'string' ? template.icon : undefined, + } as SummaryTemplateOption; + }) + .filter((option): option is SummaryTemplateOption => Boolean(option)); + + if (options.length === 0) return null; + + return { + id: `remote-section-${sectionIndex}`, + title: title || `Template ${sectionIndex + 1}`, + options, + } as SummaryTemplateSection; + }) + .filter((section): section is SummaryTemplateSection => Boolean(section)); +}; + +const normalizeRemoteDetailOptions = ( + instructions: RemoteSummaryTemplatePayload['instructions'] +): SummaryDetailOption[] => { + if (!Array.isArray(instructions)) return []; + + return instructions + .map((instruction) => { + if (!instruction || typeof instruction !== 'object') return null; + const [label, prompt] = Object.entries(instruction)[0] ?? []; + + if (typeof label !== 'string' || typeof prompt !== 'string') return null; + const defaultLabel = label.trim(); + const promptText = prompt.trim(); + + if (!defaultLabel || !promptText) return null; + + return { + id: normalizeOptionId(defaultLabel), + defaultLabel, + prompt: promptText, + } as SummaryDetailOption; + }) + .filter((option): option is SummaryDetailOption => Boolean(option)); +}; + +const normalizeRemoteTemplateConfig = ( + payload: unknown +): SummaryRegenerateTemplateConfig | null => { + if (!payload || typeof payload !== 'object') return null; + + const templatePayload = payload as RemoteSummaryTemplatePayload; + const templateSections = normalizeRemoteTemplateSections(templatePayload.sections); + const detailOptions = normalizeRemoteDetailOptions(templatePayload.instructions); + + if (templateSections.length === 0 || detailOptions.length === 0) return null; + + const templateOptions = templateSections.flatMap((section) => section.options); + const fixedPrompt = typeof templatePayload.fixed_prompt === 'string' ? templatePayload.fixed_prompt.trim() : ''; + + return { + templateSections, + templateOptions, + detailOptions, + fixedPrompt, + }; +}; + +export const fetchSummaryRegenerateTemplateConfig = async ( + requestInstance?: AxiosInstance | null +): Promise => { + if (remoteTemplateConfigCache) return remoteTemplateConfigCache; + if (remoteTemplateConfigRequest) return remoteTemplateConfigRequest; + + remoteTemplateConfigRequest = (async () => { + try { + const axiosInstance = createTemplateRequestInstance(requestInstance); + const response = await axiosInstance.get | unknown>('/api/meeting/summary_templates'); + const responseData = response.data; + const payload = + responseData && + typeof responseData === 'object' && + 'code' in responseData && + 'data' in responseData + ? (responseData as APIResponse).data + : responseData; + const normalized = normalizeRemoteTemplateConfig(payload); + + if (!normalized) return null; + + remoteTemplateConfigCache = normalized; + return normalized; + } catch { + return null; + } finally { + remoteTemplateConfigRequest = null; + } + })(); + + return remoteTemplateConfigRequest; +}; + +export const getSummaryTemplateId = ( + raw: unknown, + options: SummaryTemplateOption[] = FALLBACK_SUMMARY_REGENERATE_TEMPLATE_CONFIG.templateOptions +) => { + const fallbackId = options.find((option) => option.id === DEFAULT_SUMMARY_TEMPLATE)?.id || options[0]?.id || DEFAULT_SUMMARY_TEMPLATE; + + if (typeof raw !== 'string' || !raw.trim()) return fallbackId; + const normalized = raw.trim(); + const normalizedOptionId = normalizeOptionId(normalized); + const matched = options.find((option) => { + const optionId = option.id.trim(); + + return optionId.toLowerCase() === normalized.toLowerCase() || optionId.toLowerCase() === normalizedOptionId; + }); + + return matched?.id ?? fallbackId; +}; + +export const getSummaryDetailId = ( + raw: unknown, + options: SummaryDetailOption[] = FALLBACK_SUMMARY_REGENERATE_TEMPLATE_CONFIG.detailOptions +) => { + const fallbackId = options.find((option) => option.id === DEFAULT_SUMMARY_DETAIL)?.id || options[0]?.id || DEFAULT_SUMMARY_DETAIL; + + if (typeof raw !== 'string' || !raw.trim()) return fallbackId; + const normalized = raw.trim(); + const normalizedOptionId = normalizeOptionId(normalized); + const matched = options.find((option) => { + const optionId = option.id.trim(); + + return optionId.toLowerCase() === normalized.toLowerCase() || optionId.toLowerCase() === normalizedOptionId; + }); + + return matched?.id ?? fallbackId; +}; + +export const getSummaryLanguageCode = (raw: unknown) => { + if (typeof raw !== 'string' || !raw.trim()) return DEFAULT_SUMMARY_LANGUAGE; + const normalized = raw.trim(); + const matched = SUMMARY_LANGUAGE_OPTIONS.find( + (option) => option.code.toLowerCase() === normalized.toLowerCase() + ); + + return matched?.code ?? DEFAULT_SUMMARY_LANGUAGE; +}; + +export const buildSummaryRegeneratePrompt = ({ + templateId, + detailId, + languageCode, + templateConfig, + speakerInfoMap, +}: { + templateId: string; + detailId: string; + languageCode: string; + templateConfig?: SummaryRegenerateTemplateConfig | null; + speakerInfoMap?: Record> | null; +}) => { + const activeTemplateConfig = templateConfig ?? FALLBACK_SUMMARY_REGENERATE_TEMPLATE_CONFIG; + const templateOptions = activeTemplateConfig.templateOptions.length + ? activeTemplateConfig.templateOptions + : FALLBACK_SUMMARY_REGENERATE_TEMPLATE_CONFIG.templateOptions; + const detailOptions = activeTemplateConfig.detailOptions.length + ? activeTemplateConfig.detailOptions + : FALLBACK_SUMMARY_REGENERATE_TEMPLATE_CONFIG.detailOptions; + const effectiveTemplateId = getSummaryTemplateId(templateId, templateOptions); + const effectiveDetailId = getSummaryDetailId(detailId, detailOptions); + const templatePrompt = + templateOptions.find((option) => option.id === effectiveTemplateId)?.prompt || + templateOptions[0]?.prompt || + ''; + const detailPrompt = + detailOptions.find((option) => option.id === effectiveDetailId)?.prompt || + detailOptions[0]?.prompt || + ''; + const normalizedLanguage = getSummaryLanguageCode(languageCode); + const languageLabel = languageNameByCode[normalizedLanguage.toLowerCase()] || 'English'; + const hasFixedPrompt = Boolean(activeTemplateConfig.fixedPrompt.trim()); + + if (hasFixedPrompt) { + const participants = Object.values(speakerInfoMap ?? {}) + .map((participant) => { + const name = typeof participant?.name === 'string' ? participant.name.trim() : ''; + const email = typeof participant?.email === 'string' ? participant.email.trim() : ''; + + if (!name || !email) return ''; + return `- ${name} (email: ${email})`; + }) + .filter((line) => line.length > 0); + const participantsSection = + participants.length > 0 + ? ['Meeting Participants:', ...participants].join('\n') + : ''; + const prompt = [ + activeTemplateConfig.fixedPrompt, + '', + `Detail Instruction: ${detailPrompt}`, + '', + `Meeting Type Template Prompt: ${templatePrompt}`, + participantsSection ? `\n${participantsSection}` : '', + ] + .filter(Boolean) + .join('\n'); + + return prompt.replace(/\$\{LANGUAGE_CODE\}/g, normalizedLanguage.toUpperCase()); + } + + return [ + 'You are generating a meeting summary in markdown format.', + `Output language: ${languageLabel} (${normalizedLanguage}).`, + `Template style requirement: ${templatePrompt}`, + `Detail requirement: ${detailPrompt}`, + 'Use markdown headings and bullet lists to keep the summary scannable.', + 'Do not use fenced code blocks in the output.', + 'Do not output citation markers like ^1, [1], or similar.', + 'Suggested section structure:', + '1. OVERVIEW', + '2. DISCUSSION SUMMARY', + '3. NEXT STEPS OR FUTURE CONSIDERATIONS', + '4. DECISIONS / RECOMMENDATIONS / CONCLUSIONS', + '5. ACTION ITEMS', + 'For action items, use bullet points and include owner/deadline only if clearly present in source content.', + 'Do not invent facts, attendees, or deadlines.', + ].join('\n'); +}; + +const parsedBlockToTextNodes = (block: ParsedBlock): Text[] => { + const { text, formats } = block; + + if (formats.length === 0) { + return [{ text }]; + } + + const boundaries = new Set([0, text.length]); + + formats.forEach((format) => { + boundaries.add(format.start); + boundaries.add(format.end); + }); + + const positions = Array.from(boundaries).sort((a, b) => a - b); + const nodes: Text[] = []; + + for (let index = 0; index < positions.length - 1; index += 1) { + const start = positions[index]; + const end = positions[index + 1]; + const segment = text.slice(start, end); + + if (!segment) continue; + + const activeFormats = formats.filter((format) => format.start <= start && format.end >= end); + const attributes: Record = {}; + + activeFormats.forEach((format) => { + switch (format.type) { + case 'bold': + attributes.bold = true; + break; + case 'italic': + attributes.italic = true; + break; + case 'underline': + attributes.underline = true; + break; + case 'strikethrough': + attributes.strikethrough = true; + break; + case 'code': + attributes.code = true; + break; + case 'link': + attributes.href = format.data?.href; + break; + case 'color': + attributes.font_color = format.data?.color; + break; + case 'bgColor': + attributes.bg_color = format.data?.bgColor; + break; + } + }); + + nodes.push({ text: segment, ...attributes } as Text); + } + + return nodes; +}; + +const parsedBlockToSlateElement = (block: ParsedBlock): Element => { + const textNodes = parsedBlockToTextNodes(block); + const slateChildren: Element[] = [ + { type: YjsEditorKey.text, children: textNodes } as Element, + ...block.children.map(parsedBlockToSlateElement), + ]; + + return { + type: block.type, + data: block.data, + children: slateChildren, + } as Element; +}; + +const buildFallbackParagraph = (): Element => { + return { + type: BlockType.Paragraph, + data: {} as BlockData, + children: [ + { + type: YjsEditorKey.text, + children: [{ text: '' }], + } as Element, + ], + } as Element; +}; + +export const normalizeGeneratedSummaryMarkdown = (content: string) => { + const trimmed = content.trim(); + const match = trimmed.match(/^```(?:markdown|md)?\s*([\s\S]*?)\s*```$/i); + + if (match) return match[1].trim(); + return trimmed; +}; + +export const replaceBlockChildrenWithMarkdown = ({ + editor, + blockId, + markdown, +}: { + editor: YjsEditor; + blockId: string; + markdown: string; +}) => { + const parsedBlocks = parseMarkdown(markdown); + const slateNodes = parsedBlocks.map(parsedBlockToSlateElement); + const nodesToInsert = slateNodes.length > 0 ? slateNodes : [buildFallbackParagraph()]; + + const parentBlock = getBlock(blockId, editor.sharedRoot); + + if (!parentBlock) return false; + + const childrenArray = getChildrenArray(parentBlock.get(YjsEditorKey.block_children), editor.sharedRoot); + + if (!childrenArray) return false; + + const existingChildIds = childrenArray.toArray(); + const doc = assertDocExists(editor.sharedRoot); + + doc.transact(() => { + existingChildIds.forEach((childId) => deleteBlock(editor.sharedRoot, childId)); + slateContentInsertToYData(blockId, 0, nodesToInsert, doc); + }); + + return true; +}; diff --git a/src/components/editor/components/blocks/ai-meeting/ai-meeting.utils.ts b/src/components/editor/components/blocks/ai-meeting/ai-meeting.utils.ts new file mode 100644 index 000000000..f0514b239 --- /dev/null +++ b/src/components/editor/components/blocks/ai-meeting/ai-meeting.utils.ts @@ -0,0 +1,372 @@ +export const formatTimestamp = (value?: number) => { + if (!Number.isFinite(value)) return ''; + + const totalSeconds = Math.max(0, Math.floor(value as number)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds + .toString() + .padStart(2, '0')}`; + } + + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; +}; + +export const shouldUseRichCopyForTab = (tabKey: string) => { + return tabKey === 'notes' || tabKey === 'transcript'; +}; + +export const documentFragmentToHTML = (fragment: DocumentFragment) => { + const container = document.createElement('div'); + + container.appendChild(fragment); + return container.innerHTML; +}; + +const escapeHTML = (text: string) => { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}; + +export const plainTextToHTML = (text: string) => { + const lines = text.split(/\r\n|\r|\n/); + + if (lines.length === 0) return ''; + + return lines.map((line) => `

${escapeHTML(line)}

`).join(''); +}; + +export const stripTranscriptReferences = (text: string) => { + if (!text) return ''; + + const normalized = text.replace(/\r\n|\r/g, '\n'); + const lines = normalized.split('\n'); + const sanitizedLines: string[] = []; + + for (let index = 0; index < lines.length; index += 1) { + const current = lines[index] ?? ''; + const trimmed = current.trim(); + + // Handle standalone reference lines in copied plain text: + // "^" on one line and its number on the next line. + if (/^\^\s*$/.test(trimmed)) { + const nextLine = lines[index + 1] ?? ''; + + if (/^\d+\s*$/.test(nextLine.trim())) { + index += 1; + } + + continue; + } + + // Handle a line that is only "^12". + if (/^\^\d+\s*$/.test(trimmed)) { + continue; + } + + const cleaned = current + // Remove citation-like markers (e.g. " ^5" or "(^5)") but keep math/text like "x^2" / "word^5". + .replace(/(^|[^A-Za-z0-9])\^\d+\b/g, '$1') + .replace(/(^|[^A-Za-z0-9])\^(?=\s|$)/g, '$1') + .trimEnd(); + + sanitizedLines.push(cleaned); + } + + const withoutRefs = sanitizedLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(); + + return withoutRefs; +}; + +const getHeadingLevel = (block: HTMLElement) => { + const heading = block.querySelector('.heading'); + const levelClass = heading?.className.match(/level-(\d)/); + const level = Number(levelClass?.[1] ?? 1); + + if (!Number.isFinite(level) || level < 1) return 1; + if (level > 6) return 6; + return level; +}; + +const getBlockInlineHTML = (block: HTMLElement) => { + const textContent = block.querySelector('.text-content'); + + if (textContent) { + return textContent.innerHTML; + } + + return ''; +}; + +const createParagraphElement = (doc: Document, html: string) => { + const paragraph = doc.createElement('p'); + + paragraph.innerHTML = html; + return paragraph; +}; + +const convertBlockElementToSemantic = (block: HTMLElement, doc: Document) => { + const blockType = block.getAttribute('data-block-type'); + + if (!blockType) return null; + + const inlineHTML = getBlockInlineHTML(block); + + switch (blockType) { + case 'heading': { + const heading = doc.createElement(`h${getHeadingLevel(block)}`); + + heading.innerHTML = inlineHTML; + return heading; + } + + case 'paragraph': { + return createParagraphElement(doc, inlineHTML); + } + + case 'bulleted_list': { + const ul = doc.createElement('ul'); + const li = doc.createElement('li'); + + li.innerHTML = inlineHTML; + ul.appendChild(li); + return ul; + } + + case 'numbered_list': { + const ol = doc.createElement('ol'); + const li = doc.createElement('li'); + + li.innerHTML = inlineHTML; + ol.appendChild(li); + return ol; + } + + case 'todo_list': { + const ul = doc.createElement('ul'); + const li = doc.createElement('li'); + const input = doc.createElement('input'); + + input.type = 'checkbox'; + if (block.classList.contains('checked') || block.querySelector('.checked')) { + input.setAttribute('checked', 'checked'); + } + + li.appendChild(input); + + const span = doc.createElement('span'); + + span.innerHTML = inlineHTML; + li.appendChild(span); + ul.appendChild(li); + return ul; + } + + case 'quote': { + const quote = doc.createElement('blockquote'); + + quote.innerHTML = inlineHTML; + return quote; + } + + case 'code': { + const pre = doc.createElement('pre'); + const code = doc.createElement('code'); + const codeText = block.querySelector('pre code')?.textContent ?? block.textContent ?? ''; + + code.innerHTML = escapeHTML(codeText); + pre.appendChild(code); + return pre; + } + + case 'divider': { + return doc.createElement('hr'); + } + + default: { + return null; + } + } +}; + +const unwrapAIMeetingWrappers = (container: HTMLElement) => { + const selectors = [ + '[data-block-type="ai_meeting_summary"]', + '[data-block-type="ai_meeting_notes"]', + '[data-block-type="ai_meeting_transcription"]', + '[data-block-type="ai_meeting_speaker"]', + '.ai-meeting-section', + '.ai-meeting-speaker', + '.ai-meeting-speaker__content', + ].join(','); + + container.querySelectorAll(selectors).forEach((element) => { + const parent = element.parentNode; + + if (!parent) return; + + while (element.firstChild) { + parent.insertBefore(element.firstChild, element); + } + + parent.removeChild(element); + }); +}; + +const convertHeadingContainers = (container: HTMLElement) => { + const headingElements = Array.from(container.querySelectorAll('.heading')); + + headingElements.forEach((headingElement) => { + if (/^h[1-6]$/i.test(headingElement.tagName)) return; + + const levelClass = headingElement.className.match(/level-(\d)/); + const level = Number(levelClass?.[1] ?? 1); + const safeLevel = Number.isFinite(level) ? Math.min(Math.max(level, 1), 6) : 1; + const headingTag = document.createElement(`h${safeLevel}`); + const content = headingElement.querySelector('.text-content'); + + headingTag.innerHTML = content ? content.innerHTML : headingElement.innerHTML; + headingElement.replaceWith(headingTag); + }); +}; + +const removeInlineReferenceArtifacts = (container: HTMLElement) => { + container.querySelectorAll('.ai-meeting-reference, .ai-meeting-reference-popover').forEach((el) => { + el.remove(); + }); + + container.querySelectorAll('span').forEach((span) => { + const text = (span.textContent || '').trim(); + const className = String(span.className || ''); + + if (text !== '^') return; + if (!className.includes('text-transparent')) return; + if (!className.includes('pointer-events-none')) return; + + span.remove(); + }); +}; + +export const normalizeAppFlowyClipboardHTML = (html: string) => { + if (!html.trim()) return ''; + + const container = document.createElement('div'); + + container.innerHTML = html; + container.querySelectorAll('meta').forEach((meta) => meta.remove()); + removeInlineReferenceArtifacts(container); + + const semanticBlockTypes = new Set([ + 'heading', + 'paragraph', + 'bulleted_list', + 'numbered_list', + 'todo_list', + 'quote', + 'code', + 'divider', + ]); + + const blocks = Array.from(container.querySelectorAll('.block-element[data-block-type]')); + + blocks.forEach((block) => { + const blockType = block.getAttribute('data-block-type'); + + if (!blockType || !semanticBlockTypes.has(blockType)) return; + + const semantic = convertBlockElementToSemantic(block, document); + + if (semantic) { + block.replaceWith(semantic); + } + }); + + convertHeadingContainers(container); + unwrapAIMeetingWrappers(container); + return container.innerHTML; +}; + +export const selectionToHTML = (selection: Selection) => { + let html = ''; + + for (let index = 0; index < selection.rangeCount; index += 1) { + const range = selection.getRangeAt(index); + + html += documentFragmentToHTML(range.cloneContents()); + } + + return html; +}; + +const closestBlockElement = (node: Node | null): HTMLElement | null => { + if (!node) return null; + + const element = node instanceof HTMLElement ? node : node.parentElement; + + if (!element) return null; + + return element.closest('.block-element[data-block-type]'); +}; + +const wrapRangeHTMLWithBlockContext = (range: Range, html: string) => { + const startBlock = closestBlockElement(range.startContainer); + const endBlock = closestBlockElement(range.endContainer); + + if (!startBlock || !endBlock || startBlock !== endBlock) { + return html; + } + + const blockType = startBlock.getAttribute('data-block-type'); + + if (!blockType) return html; + + switch (blockType) { + case 'heading': { + const level = getHeadingLevel(startBlock); + + return `${html}`; + } + + case 'bulleted_list': + return `
  • ${html}
`; + + case 'numbered_list': + return `
  1. ${html}
`; + + case 'todo_list': { + const checked = startBlock.classList.contains('checked') || startBlock.querySelector('.checked'); + + return `
  • ${html}
`; + } + + case 'quote': + return `
${html}
`; + + default: + return html; + } +}; + +export const selectionToContextualHTML = (selection: Selection) => { + let html = ''; + + for (let index = 0; index < selection.rangeCount; index += 1) { + const range = selection.getRangeAt(index); + const rawHTML = documentFragmentToHTML(range.cloneContents()); + + html += wrapRangeHTMLWithBlockContext(range, rawHTML); + } + + return html; +}; + +export const isRangeInsideElement = (range: Range, container: HTMLElement) => { + return container.contains(range.commonAncestorContainer); +}; diff --git a/src/components/editor/components/blocks/ai-meeting/index.ts b/src/components/editor/components/blocks/ai-meeting/index.ts index a87503fba..f8c462b92 100644 --- a/src/components/editor/components/blocks/ai-meeting/index.ts +++ b/src/components/editor/components/blocks/ai-meeting/index.ts @@ -1 +1,3 @@ export * from './AIMeetingBlock'; +export * from './AIMeetingSection'; +export * from './AIMeetingSpeakerBlock'; diff --git a/src/components/editor/components/blocks/text/Placeholder.tsx b/src/components/editor/components/blocks/text/Placeholder.tsx index dd9bf128f..c038406eb 100644 --- a/src/components/editor/components/blocks/text/Placeholder.tsx +++ b/src/components/editor/components/blocks/text/Placeholder.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Editor, Element, Range } from 'slate'; import { ReactEditor, useFocused, useSelected, useSlate } from 'slate-react'; + import { BlockType, ToggleListBlockData } from '@/application/types'; import { HeadingNode, ToggleListNode } from '@/components/editor/editor.type'; import { useEditorContext } from '@/components/editor/EditorContext'; @@ -44,9 +45,24 @@ function Placeholder({ node, ...attributes }: { node: Element; className?: strin const unSelectedPlaceholder = useMemo(() => { switch(block?.type) { case BlockType.Paragraph: { + try { + const path = ReactEditor.findPath(editor, node); + const inNotes = Editor.above(editor, { + at: path, + match: (n) => + !Editor.isEditor(n) && + Element.isElement(n) && + n.type === BlockType.AIMeetingNotesBlock, + }); + + if (inNotes) return t('document.aiMeeting.notesPlaceholder'); + } catch { + // ignore + } + // Show placeholder when the document is empty (single empty paragraph) // This matches the desktop "Enter a / to insert a block, or start typing" behavior - if (editor.children.length <= 1) { + if(editor.children.length <= 1) { return t('cardDetails.notesPlaceholder'); } @@ -108,7 +124,7 @@ function Placeholder({ node, ...attributes }: { node: Element; className?: strin default: return ''; } - }, [block, editor.children.length, t]); + }, [block, editor, node, t]); const selectedPlaceholder = useMemo(() => { diff --git a/src/components/editor/components/element/Element.tsx b/src/components/editor/components/element/Element.tsx index 0ea3baea5..cb81a8dca 100644 --- a/src/components/editor/components/element/Element.tsx +++ b/src/components/editor/components/element/Element.tsx @@ -7,7 +7,7 @@ import { ReactEditor, RenderElementProps, useSelected, useSlateStatic } from 'sl import { YjsEditor } from '@/application/slate-yjs'; import { CONTAINER_BLOCK_TYPES, SOFT_BREAK_TYPES } from '@/application/slate-yjs/command/const'; import { BlockData, BlockType, ColumnNodeData, YjsEditorKey } from '@/application/types'; -import { AIMeetingBlock } from '@/components/editor/components/blocks/ai-meeting'; +import { AIMeetingBlock, AIMeetingSection, AIMeetingSpeakerBlock } from '@/components/editor/components/blocks/ai-meeting'; import { BulletedList } from '@/components/editor/components/blocks/bulleted-list'; import { Callout } from '@/components/editor/components/blocks/callout'; import { CodeBlock } from '@/components/editor/components/blocks/code'; @@ -216,6 +216,12 @@ export const Element = ({ return Column; case BlockType.AIMeetingBlock: return AIMeetingBlock; + case BlockType.AIMeetingSummaryBlock: + case BlockType.AIMeetingNotesBlock: + case BlockType.AIMeetingTranscriptionBlock: + return AIMeetingSection; + case BlockType.AIMeetingSpeakerBlock: + return AIMeetingSpeakerBlock; case BlockType.PDFBlock: return PDFBlock; default: diff --git a/src/components/editor/components/leaf/Leaf.tsx b/src/components/editor/components/leaf/Leaf.tsx index 030f6a6cb..411d64d0f 100644 --- a/src/components/editor/components/leaf/Leaf.tsx +++ b/src/components/editor/components/leaf/Leaf.tsx @@ -5,6 +5,8 @@ import { Mention } from '@/application/types'; import FormulaLeaf from '@/components/editor/components/leaf/formula/FormulaLeaf'; import { Href } from '@/components/editor/components/leaf/href'; import MentionLeaf from '@/components/editor/components/leaf/mention/MentionLeaf'; +import { InlineReference } from '@/components/editor/components/leaf/reference/InlineReference'; +import { parseInlineReference } from '@/components/editor/components/leaf/reference/utils'; import { cn } from '@/lib/utils'; import { renderColor } from '@/utils/color'; import { getFontFamily } from '@/utils/font'; @@ -58,7 +60,7 @@ export function Leaf({ attributes, children, leaf, text }: RenderLeafProps) { style['backgroundColor'] = renderColor(leaf.af_background_color); } - if (leaf.code && !(leaf.formula || leaf.mention)) { + if (leaf.code && !(leaf.formula || leaf.mention || leaf.reference)) { newChildren = ( {newChildren} @@ -78,9 +80,11 @@ export function Leaf({ attributes, children, leaf, text }: RenderLeafProps) { style['fontFamily'] = getFontFamily(leaf.font_family); } - if (text.text && (leaf.mention || leaf.formula)) { + const referenceData = leaf.reference ? parseInlineReference(leaf.reference) : null; + + if (text.text && (leaf.mention || leaf.formula || referenceData)) { style['position'] = 'relative'; - if (leaf.mention) { + if (leaf.mention || referenceData) { style['display'] = 'inline-block'; } @@ -92,6 +96,10 @@ export function Leaf({ attributes, children, leaf, text }: RenderLeafProps) { {newChildren} + ) : referenceData ? ( + + {newChildren} + ) : null; newChildren = node; diff --git a/src/components/editor/components/leaf/reference/InlineReference.tsx b/src/components/editor/components/leaf/reference/InlineReference.tsx new file mode 100644 index 000000000..6f3d6cecf --- /dev/null +++ b/src/components/editor/components/leaf/reference/InlineReference.tsx @@ -0,0 +1,438 @@ +import { memo, useMemo, useState } from 'react'; +import { Editor, Element, Node, Text } from 'slate'; +import { ReactEditor, useReadOnly, useSlateStatic } from 'slate-react'; +import smoothScrollIntoViewIfNeeded from 'smooth-scroll-into-view-if-needed'; + +import { YjsEditor } from '@/application/slate-yjs'; +import { CustomEditor } from '@/application/slate-yjs/command'; +import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor'; +import { BlockType } from '@/application/types'; +import { ReactComponent as NotesIcon } from '@/assets/icons/ai_summary_ref_notes.svg'; +import { ReactComponent as TranscriptIcon } from '@/assets/icons/ai_summary_ref_transcript.svg'; +import { ReactComponent as WarningIcon } from '@/assets/icons/ai_reference_warning.svg'; +import { formatTimestamp } from '@/components/editor/components/blocks/ai-meeting/ai-meeting.utils'; +import { RichTooltip } from '@/components/_shared/popover'; +import { useLeafSelected } from '@/components/editor/components/leaf/leaf.hooks'; +import { useTranslation } from 'react-i18next'; + +import { InlineReferenceData } from './utils'; + +type ReferenceSourceType = 'transcript' | 'notes'; + +interface ReferenceBlockStatus { + blockId: string; + status: 'exists' | 'deleted'; + content?: string; + sourceType?: ReferenceSourceType; + timestamp?: number; +} + +const normalizeTimestamp = (value: unknown): number | undefined => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + + if (Number.isFinite(parsed)) return parsed; + } + + return undefined; +}; + +const buildContent = (node: Node) => CustomEditor.getBlockTextContent(node).trim(); + +const findInChildren = ( + node: Node, + blockId: string, + sourceType: ReferenceSourceType, + timestamp?: number +): ReferenceBlockStatus | null => { + if (!Element.isElement(node)) return null; + + for (const child of node.children) { + if (!Element.isElement(child)) continue; + + if (child.blockId === blockId) { + return { + blockId, + status: 'exists', + content: buildContent(child), + sourceType, + timestamp, + }; + } + + const nested = findInChildren(child, blockId, sourceType, timestamp); + + if (nested) return nested; + } + + return null; +}; + +const findInTranscript = (node: Element, blockId: string): ReferenceBlockStatus | null => { + for (const child of node.children) { + if (!Element.isElement(child)) continue; + + if (child.type === BlockType.AIMeetingSpeakerBlock) { + const timestamp = normalizeTimestamp((child.data as Record | undefined)?.timestamp); + const found = findInChildren(child, blockId, 'transcript', timestamp); + + if (found) return found; + continue; + } + + if (child.blockId === blockId) { + return { + blockId, + status: 'exists', + content: buildContent(child), + sourceType: 'transcript', + }; + } + + const nested = findInChildren(child, blockId, 'transcript'); + + if (nested) return nested; + } + + return null; +}; + +const findInNotes = (node: Element, blockId: string): ReferenceBlockStatus | null => { + for (const child of node.children) { + if (!Element.isElement(child)) continue; + + if (child.blockId === blockId) { + return { + blockId, + status: 'exists', + content: buildContent(child), + sourceType: 'notes', + }; + } + + const nested = findInChildren(child, blockId, 'notes'); + + if (nested) return nested; + } + + return null; +}; + +const getAvailableTabs = (meetingNode: Element) => { + const children = meetingNode.children ?? []; + const summaryNode = children.find( + (child) => Element.isElement(child) && child.type === BlockType.AIMeetingSummaryBlock + ) as Element | undefined; + const transcriptNode = children.find( + (child) => Element.isElement(child) && child.type === BlockType.AIMeetingTranscriptionBlock + ) as Element | undefined; + + const tabs: Array<'summary' | 'notes' | 'transcript'> = []; + + if (summaryNode && buildContent(summaryNode).length > 0) { + tabs.push('summary'); + } + + tabs.push('notes'); + + if (transcriptNode && buildContent(transcriptNode).length > 0) { + tabs.push('transcript'); + } + + return tabs; +}; + +const buildStatuses = (meetingNode: Element, blockIds: string[]): ReferenceBlockStatus[] => { + const transcriptionNode = meetingNode.children.find( + (child) => Element.isElement(child) && child.type === BlockType.AIMeetingTranscriptionBlock + ) as Element | undefined; + const notesNode = meetingNode.children.find( + (child) => Element.isElement(child) && child.type === BlockType.AIMeetingNotesBlock + ) as Element | undefined; + + return blockIds.map((blockId) => { + if (transcriptionNode) { + const found = findInTranscript(transcriptionNode, blockId); + + if (found) return found; + } + + if (notesNode) { + const found = findInNotes(notesNode, blockId); + + if (found) return found; + } + + return { + blockId, + status: 'deleted', + }; + }); +}; + +const ReferenceBadge = memo(({ number, hasError }: { number: number; hasError?: boolean }) => { + return ( + + {number} + + ); +}); + +ReferenceBadge.displayName = 'ReferenceBadge'; + +export const InlineReference = memo( + ({ reference, text, children }: { reference: InlineReferenceData; text: Text; children: React.ReactNode }) => { + const editor = useSlateStatic(); + const yjsEditor = editor as YjsEditor; + const { t } = useTranslation(); + const editorReadOnly = useReadOnly(); + const elementReadOnly = useMemo(() => { + try { + const path = ReactEditor.findPath(editor, text); + const match = Editor.above(editor, { + at: path, + match: (n) => !Editor.isEditor(n) && Element.isElement(n), + }); + + if (!match) return false; + + return editor.isElementReadOnly(match[0] as Element); + } catch { + return false; + } + }, [editor, text]); + const readOnly = editorReadOnly || elementReadOnly; + const { isSelected, isCursorBefore, select } = useLeafSelected(text); + const [open, setOpen] = useState(false); + + const meetingNode = useMemo(() => { + try { + const path = ReactEditor.findPath(editor, text); + const match = Editor.above(editor, { + at: path, + match: (n) => + !Editor.isEditor(n) && Element.isElement(n) && n.type === BlockType.AIMeetingBlock, + }); + + if (!match) return null; + + return match[0] as Element; + } catch { + return null; + } + }, [editor, text]); + + const normalizedBlockIds = useMemo(() => { + const unique = Array.from(new Set(reference.blockIds)); + + return unique; + }, [reference.blockIds]); + + const statuses = useMemo(() => { + if (!meetingNode || normalizedBlockIds.length === 0) return []; + return buildStatuses(meetingNode, normalizedBlockIds); + }, [meetingNode, normalizedBlockIds]); + + const hasDeleted = statuses.some((status) => status.status === 'deleted'); + const popoverContent = useMemo(() => { + if (!statuses.length) return null; + + return ( +
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > + {statuses.map((status) => { + if (status.status === 'deleted') { + return ( +
+ + + {t('document.aiMeeting.reference.deleted')} +
+ ); + } + + const isTranscript = status.sourceType === 'transcript'; + const timestampLabel = isTranscript ? formatTimestamp(status.timestamp) : ''; + + return ( + + ); + })} +
+ ); + }, [editor, editorReadOnly, meetingNode, reference.number, statuses, t, yjsEditor]); + + if (!meetingNode || normalizedBlockIds.length === 0) { + return <>{children}; + } + + const tooltipLabel = hasDeleted + ? t('document.aiMeeting.reference.deletedTooltip') + : t('document.aiMeeting.reference.sourcesTooltip'); + + return ( + <> + + {children} + + setOpen(false)} + placement="bottom-start" + PaperProps={{ + className: 'bg-background-primary shadow-md', + }} + content={popoverContent ??
} + > + event.preventDefault()} + onClick={(event) => { + event.stopPropagation(); + + if (!readOnly) { + select(); + } + + if (popoverContent) { + setOpen((prev) => !prev); + } + }} + title={tooltipLabel} + aria-label={tooltipLabel} + > + + + + + ); + } +); + +InlineReference.displayName = 'InlineReference'; diff --git a/src/components/editor/components/leaf/reference/__tests__/InlineReference.test.ts b/src/components/editor/components/leaf/reference/__tests__/InlineReference.test.ts new file mode 100644 index 000000000..5e525791e --- /dev/null +++ b/src/components/editor/components/leaf/reference/__tests__/InlineReference.test.ts @@ -0,0 +1,737 @@ +import { expect, describe, it } from '@jest/globals'; +import { Element, Node, Text } from 'slate'; + +import { BlockType } from '@/application/types'; + +/** + * Test helpers - replicating the logic from InlineReference.tsx + */ + +type ReferenceSourceType = 'transcript' | 'notes'; + +interface ReferenceBlockStatus { + blockId: string; + status: 'exists' | 'deleted'; + content?: string; + sourceType?: ReferenceSourceType; + timestamp?: number; +} + +const normalizeTimestamp = (value: unknown): number | undefined => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + + if (Number.isFinite(parsed)) return parsed; + } + + return undefined; +}; + +const buildContent = (node: Node) => { + const getBlockTextContent = (n: Node): string => { + if (Text.isText(n)) return n.text; + if (Element.isElement(n)) { + return n.children.map((child) => getBlockTextContent(child)).join(''); + } + + return ''; + }; + + return getBlockTextContent(node).trim(); +}; + +const findInChildren = ( + node: Node, + blockId: string, + sourceType: ReferenceSourceType, + timestamp?: number +): ReferenceBlockStatus | null => { + if (!Element.isElement(node)) return null; + + for (const child of node.children) { + if (!Element.isElement(child)) continue; + + if (child.blockId === blockId) { + return { + blockId, + status: 'exists', + content: buildContent(child), + sourceType, + timestamp, + }; + } + + const nested = findInChildren(child, blockId, sourceType, timestamp); + + if (nested) return nested; + } + + return null; +}; + +const findInTranscript = (node: Element, blockId: string): ReferenceBlockStatus | null => { + for (const child of node.children) { + if (!Element.isElement(child)) continue; + + if (child.type === BlockType.AIMeetingSpeakerBlock) { + const timestamp = normalizeTimestamp((child.data as Record | undefined)?.timestamp); + const found = findInChildren(child, blockId, 'transcript', timestamp); + + if (found) return found; + continue; + } + + if (child.blockId === blockId) { + return { + blockId, + status: 'exists', + content: buildContent(child), + sourceType: 'transcript', + }; + } + + const nested = findInChildren(child, blockId, 'transcript'); + + if (nested) return nested; + } + + return null; +}; + +const findInNotes = (node: Element, blockId: string): ReferenceBlockStatus | null => { + for (const child of node.children) { + if (!Element.isElement(child)) continue; + + if (child.blockId === blockId) { + return { + blockId, + status: 'exists', + content: buildContent(child), + sourceType: 'notes', + }; + } + + const nested = findInChildren(child, blockId, 'notes'); + + if (nested) return nested; + } + + return null; +}; + +const buildStatuses = (meetingNode: Element, blockIds: string[]): ReferenceBlockStatus[] => { + const transcriptionNode = meetingNode.children.find( + (child) => Element.isElement(child) && child.type === BlockType.AIMeetingTranscriptionBlock + ) as Element | undefined; + const notesNode = meetingNode.children.find( + (child) => Element.isElement(child) && child.type === BlockType.AIMeetingNotesBlock + ) as Element | undefined; + + return blockIds.map((blockId) => { + if (transcriptionNode) { + const found = findInTranscript(transcriptionNode, blockId); + + if (found) return found; + } + + if (notesNode) { + const found = findInNotes(notesNode, blockId); + + if (found) return found; + } + + return { + blockId, + status: 'deleted', + }; + }); +}; + +const getAvailableTabs = (meetingNode: Element) => { + const children = meetingNode.children ?? []; + const summaryNode = children.find( + (child) => Element.isElement(child) && child.type === BlockType.AIMeetingSummaryBlock + ) as Element | undefined; + const transcriptNode = children.find( + (child) => Element.isElement(child) && child.type === BlockType.AIMeetingTranscriptionBlock + ) as Element | undefined; + + const tabs: Array<'summary' | 'notes' | 'transcript'> = []; + + if (summaryNode && buildContent(summaryNode).length > 0) { + tabs.push('summary'); + } + + tabs.push('notes'); + + if (transcriptNode && buildContent(transcriptNode).length > 0) { + tabs.push('transcript'); + } + + return tabs; +}; + +/** + * Mock data factories + */ +const createTextNode = (text: string): Element => + ({ + type: 'text', + children: [{ text }], + }) as unknown as Element; + +const createParagraphNode = (text: string, blockId: string): Element => + ({ + type: BlockType.Paragraph, + blockId, + children: [createTextNode(text)], + }) as unknown as Element; + +const createSpeakerNode = ( + speakerId: string, + timestamp: number, + children: Element[], + blockIdSuffix = '' +): Element => + ({ + type: BlockType.AIMeetingSpeakerBlock, + blockId: `speaker-${speakerId}${blockIdSuffix}`, + data: { speaker_id: speakerId, timestamp }, + children, + }) as unknown as Element; + +const createSectionNode = (type: BlockType, children: Element[], blockId?: string): Element => + ({ + type, + blockId: blockId ?? `section-${type}`, + children, + }) as unknown as Element; + +const createMeetingNode = (sections: Element[]): Element => + ({ + type: BlockType.AIMeetingBlock, + blockId: 'meeting-1', + data: {}, + children: sections, + }) as unknown as Element; + +describe('InlineReference Logic', () => { + describe('normalizeTimestamp', () => { + it('should return number if already a valid number', () => { + expect(normalizeTimestamp(123)).toBe(123); + expect(normalizeTimestamp(0)).toBe(0); + expect(normalizeTimestamp(45.5)).toBe(45.5); + }); + + it('should parse valid numeric strings', () => { + expect(normalizeTimestamp('123')).toBe(123); + expect(normalizeTimestamp('45.5')).toBe(45.5); + expect(normalizeTimestamp('0')).toBe(0); + }); + + it('should return undefined for invalid inputs', () => { + expect(normalizeTimestamp(undefined)).toBeUndefined(); + expect(normalizeTimestamp(null)).toBeUndefined(); + expect(normalizeTimestamp(NaN)).toBeUndefined(); + expect(normalizeTimestamp(Infinity)).toBeUndefined(); + expect(normalizeTimestamp('')).toBeUndefined(); + expect(normalizeTimestamp(' ')).toBeUndefined(); + expect(normalizeTimestamp('not a number')).toBeUndefined(); + }); + }); + + describe('buildContent', () => { + it('should extract text from text node', () => { + const node = { text: 'Hello world' } as unknown as Node; + + expect(buildContent(node)).toBe('Hello world'); + }); + + it('should extract text from nested element', () => { + const node = createParagraphNode('Nested text', 'p1'); + + expect(buildContent(node)).toBe('Nested text'); + }); + + it('should trim whitespace', () => { + const node = createParagraphNode(' padded text ', 'p1'); + + expect(buildContent(node)).toBe('padded text'); + }); + + it('should concatenate multiple text nodes', () => { + const node = { + type: BlockType.Paragraph, + children: [{ text: 'Hello ' }, { text: 'world' }], + } as unknown as Element; + + expect(buildContent(node)).toBe('Hello world'); + }); + }); + + describe('findInTranscript', () => { + it('should find block inside speaker block with timestamp', () => { + const paragraphBlock = createParagraphNode('Speaker message', 'para-1'); + const speakerBlock = createSpeakerNode('s1', 120, [paragraphBlock]); + const transcriptSection = createSectionNode(BlockType.AIMeetingTranscriptionBlock, [ + speakerBlock, + ]); + + const result = findInTranscript(transcriptSection, 'para-1'); + + expect(result).not.toBeNull(); + expect(result?.status).toBe('exists'); + expect(result?.blockId).toBe('para-1'); + expect(result?.sourceType).toBe('transcript'); + expect(result?.timestamp).toBe(120); + expect(result?.content).toBe('Speaker message'); + }); + + it('should find block at transcript root level', () => { + const paragraphBlock = createParagraphNode('Root message', 'para-root'); + const transcriptSection = createSectionNode(BlockType.AIMeetingTranscriptionBlock, [ + paragraphBlock, + ]); + + const result = findInTranscript(transcriptSection, 'para-root'); + + expect(result?.status).toBe('exists'); + expect(result?.sourceType).toBe('transcript'); + expect(result?.timestamp).toBeUndefined(); + }); + + it('should return null if block not found', () => { + const transcriptSection = createSectionNode(BlockType.AIMeetingTranscriptionBlock, [ + createSpeakerNode('s1', 0, [createParagraphNode('Message', 'para-1')]), + ]); + + const result = findInTranscript(transcriptSection, 'non-existent'); + + expect(result).toBeNull(); + }); + }); + + describe('findInNotes', () => { + it('should find block in notes section', () => { + const notesSection = createSectionNode(BlockType.AIMeetingNotesBlock, [ + createParagraphNode('Note 1', 'note-1'), + createParagraphNode('Note 2', 'note-2'), + ]); + + const result = findInNotes(notesSection, 'note-2'); + + expect(result).not.toBeNull(); + expect(result?.status).toBe('exists'); + expect(result?.blockId).toBe('note-2'); + expect(result?.sourceType).toBe('notes'); + expect(result?.content).toBe('Note 2'); + }); + + it('should find deeply nested block', () => { + const nestedBlock = createParagraphNode('Nested note', 'nested-1'); + const containerBlock = { + type: BlockType.BulletedListBlock, + blockId: 'list-1', + children: [nestedBlock], + } as unknown as Element; + const notesSection = createSectionNode(BlockType.AIMeetingNotesBlock, [containerBlock]); + + const result = findInNotes(notesSection, 'nested-1'); + + expect(result?.status).toBe('exists'); + expect(result?.content).toBe('Nested note'); + }); + + it('should return null if block not found', () => { + const notesSection = createSectionNode(BlockType.AIMeetingNotesBlock, [ + createParagraphNode('Note 1', 'note-1'), + ]); + + const result = findInNotes(notesSection, 'non-existent'); + + expect(result).toBeNull(); + }); + }); + + describe('buildStatuses', () => { + it('should return statuses for all requested block ids', () => { + const meetingNode = createMeetingNode([ + createSectionNode(BlockType.AIMeetingNotesBlock, [ + createParagraphNode('Note 1', 'note-1'), + createParagraphNode('Note 2', 'note-2'), + ]), + ]); + + const statuses = buildStatuses(meetingNode, ['note-1', 'note-2']); + + expect(statuses).toHaveLength(2); + expect(statuses[0].blockId).toBe('note-1'); + expect(statuses[0].status).toBe('exists'); + expect(statuses[1].blockId).toBe('note-2'); + expect(statuses[1].status).toBe('exists'); + }); + + it('should mark missing blocks as deleted', () => { + const meetingNode = createMeetingNode([ + createSectionNode(BlockType.AIMeetingNotesBlock, [ + createParagraphNode('Note 1', 'note-1'), + ]), + ]); + + const statuses = buildStatuses(meetingNode, ['note-1', 'deleted-block']); + + expect(statuses[0].status).toBe('exists'); + expect(statuses[1].status).toBe('deleted'); + expect(statuses[1].blockId).toBe('deleted-block'); + }); + + it('should search transcript before notes', () => { + const meetingNode = createMeetingNode([ + createSectionNode(BlockType.AIMeetingTranscriptionBlock, [ + createSpeakerNode('s1', 60, [createParagraphNode('Transcript text', 'block-1')]), + ]), + createSectionNode(BlockType.AIMeetingNotesBlock, [ + createParagraphNode('Note text', 'block-2'), + ]), + ]); + + const statuses = buildStatuses(meetingNode, ['block-1', 'block-2']); + + expect(statuses[0].sourceType).toBe('transcript'); + expect(statuses[0].timestamp).toBe(60); + expect(statuses[1].sourceType).toBe('notes'); + }); + + it('should handle empty block ids array', () => { + const meetingNode = createMeetingNode([]); + + const statuses = buildStatuses(meetingNode, []); + + expect(statuses).toEqual([]); + }); + + it('should handle meeting with no sections', () => { + const meetingNode = createMeetingNode([]); + + const statuses = buildStatuses(meetingNode, ['block-1']); + + expect(statuses[0].status).toBe('deleted'); + }); + }); + + describe('getAvailableTabs', () => { + it('should always include notes tab', () => { + const meetingNode = createMeetingNode([ + createSectionNode(BlockType.AIMeetingNotesBlock, []), + ]); + + const tabs = getAvailableTabs(meetingNode); + + expect(tabs).toContain('notes'); + }); + + it('should include summary only if it has content', () => { + const meetingWithEmptySummary = createMeetingNode([ + createSectionNode(BlockType.AIMeetingSummaryBlock, []), + createSectionNode(BlockType.AIMeetingNotesBlock, []), + ]); + + expect(getAvailableTabs(meetingWithEmptySummary)).not.toContain('summary'); + + const meetingWithSummary = createMeetingNode([ + createSectionNode(BlockType.AIMeetingSummaryBlock, [ + createParagraphNode('Summary content', 'sum-1'), + ]), + createSectionNode(BlockType.AIMeetingNotesBlock, []), + ]); + + expect(getAvailableTabs(meetingWithSummary)).toContain('summary'); + }); + + it('should include transcript only if it has content', () => { + const meetingWithEmptyTranscript = createMeetingNode([ + createSectionNode(BlockType.AIMeetingTranscriptionBlock, []), + createSectionNode(BlockType.AIMeetingNotesBlock, []), + ]); + + expect(getAvailableTabs(meetingWithEmptyTranscript)).not.toContain('transcript'); + + const meetingWithTranscript = createMeetingNode([ + createSectionNode(BlockType.AIMeetingTranscriptionBlock, [ + createSpeakerNode('s1', 0, [createParagraphNode('Transcript', 't-1')]), + ]), + createSectionNode(BlockType.AIMeetingNotesBlock, []), + ]); + + expect(getAvailableTabs(meetingWithTranscript)).toContain('transcript'); + }); + + it('should return tabs in correct order', () => { + const meetingNode = createMeetingNode([ + createSectionNode(BlockType.AIMeetingSummaryBlock, [ + createParagraphNode('Summary', 'sum-1'), + ]), + createSectionNode(BlockType.AIMeetingNotesBlock, [ + createParagraphNode('Notes', 'note-1'), + ]), + createSectionNode(BlockType.AIMeetingTranscriptionBlock, [ + createSpeakerNode('s1', 0, [createParagraphNode('Transcript', 't-1')]), + ]), + ]); + + const tabs = getAvailableTabs(meetingNode); + + expect(tabs).toEqual(['summary', 'notes', 'transcript']); + }); + }); +}); + +describe('Reference Block Status Resolution', () => { + it('should correctly identify existing vs deleted references', () => { + const meetingNode = createMeetingNode([ + createSectionNode(BlockType.AIMeetingNotesBlock, [ + createParagraphNode('Existing note', 'existing-1'), + createParagraphNode('Another note', 'existing-2'), + ]), + ]); + + const statuses = buildStatuses(meetingNode, ['existing-1', 'deleted-ref', 'existing-2']); + + expect(statuses[0].status).toBe('exists'); + expect(statuses[1].status).toBe('deleted'); + expect(statuses[2].status).toBe('exists'); + }); + + it('should preserve timestamp from speaker block', () => { + const meetingNode = createMeetingNode([ + createSectionNode(BlockType.AIMeetingTranscriptionBlock, [ + createSpeakerNode('s1', 125, [createParagraphNode('Message at 2:05', 'msg-1')]), + createSpeakerNode('s2', 180, [createParagraphNode('Message at 3:00', 'msg-2')]), + ]), + ]); + + const statuses = buildStatuses(meetingNode, ['msg-1', 'msg-2']); + + expect(statuses[0].timestamp).toBe(125); + expect(statuses[1].timestamp).toBe(180); + }); + + it('should handle mixed sources (transcript and notes)', () => { + const meetingNode = createMeetingNode([ + createSectionNode(BlockType.AIMeetingTranscriptionBlock, [ + createSpeakerNode('s1', 60, [createParagraphNode('Transcript content', 'transcript-1')]), + ]), + createSectionNode(BlockType.AIMeetingNotesBlock, [ + createParagraphNode('Note content', 'note-1'), + ]), + ]); + + const statuses = buildStatuses(meetingNode, ['transcript-1', 'note-1']); + + expect(statuses[0].sourceType).toBe('transcript'); + expect(statuses[1].sourceType).toBe('notes'); + }); +}); + +/** + * Tab switching logic - replicating click handler behavior from InlineReference.tsx + */ + +interface TabSwitchResult { + targetTabKey: 'transcript' | 'notes'; + targetIndex: number; + shouldSwitch: boolean; +} + +const calculateTabSwitch = ( + meetingNode: Element, + sourceType: ReferenceSourceType, + currentTabIndex: number | string | undefined +): TabSwitchResult => { + const tabs = getAvailableTabs(meetingNode); + const targetKey: 'transcript' | 'notes' = sourceType === 'transcript' ? 'transcript' : 'notes'; + const targetIndex = Math.max(0, tabs.indexOf(targetKey)); + + // Parse current index + let currentIndex: number; + + if (typeof currentTabIndex === 'number') { + currentIndex = currentTabIndex; + } else if (typeof currentTabIndex === 'string') { + currentIndex = Number(currentTabIndex); + } else { + currentIndex = NaN; + } + + // Determine if switch is needed + const shouldSwitch = Number.isNaN(currentIndex) ? true : currentIndex !== targetIndex; + + return { + targetTabKey: targetKey, + targetIndex, + shouldSwitch, + }; +}; + +describe('Reference Click Tab Switching', () => { + describe('calculateTabSwitch', () => { + it('should switch to transcript tab when clicking transcript reference', () => { + const meetingNode = createMeetingNode([ + createSectionNode(BlockType.AIMeetingSummaryBlock, [ + createParagraphNode('Summary', 'sum-1'), + ]), + createSectionNode(BlockType.AIMeetingNotesBlock, [ + createParagraphNode('Notes', 'note-1'), + ]), + createSectionNode(BlockType.AIMeetingTranscriptionBlock, [ + createSpeakerNode('s1', 0, [createParagraphNode('Transcript', 't-1')]), + ]), + ]); + + // Currently on summary (index 0), clicking transcript reference + const result = calculateTabSwitch(meetingNode, 'transcript', 0); + + expect(result.targetTabKey).toBe('transcript'); + expect(result.targetIndex).toBe(2); // [summary, notes, transcript] -> index 2 + expect(result.shouldSwitch).toBe(true); + }); + + it('should switch to notes tab when clicking notes reference', () => { + const meetingNode = createMeetingNode([ + createSectionNode(BlockType.AIMeetingSummaryBlock, [ + createParagraphNode('Summary', 'sum-1'), + ]), + createSectionNode(BlockType.AIMeetingNotesBlock, [ + createParagraphNode('Notes', 'note-1'), + ]), + createSectionNode(BlockType.AIMeetingTranscriptionBlock, [ + createSpeakerNode('s1', 0, [createParagraphNode('Transcript', 't-1')]), + ]), + ]); + + // Currently on transcript (index 2), clicking notes reference + const result = calculateTabSwitch(meetingNode, 'notes', 2); + + expect(result.targetTabKey).toBe('notes'); + expect(result.targetIndex).toBe(1); // [summary, notes, transcript] -> index 1 + expect(result.shouldSwitch).toBe(true); + }); + + it('should not switch if already on target tab', () => { + const meetingNode = createMeetingNode([ + createSectionNode(BlockType.AIMeetingNotesBlock, [ + createParagraphNode('Notes', 'note-1'), + ]), + createSectionNode(BlockType.AIMeetingTranscriptionBlock, [ + createSpeakerNode('s1', 0, [createParagraphNode('Transcript', 't-1')]), + ]), + ]); + + // Currently on notes (index 0), clicking notes reference + const result = calculateTabSwitch(meetingNode, 'notes', 0); + + expect(result.targetTabKey).toBe('notes'); + expect(result.targetIndex).toBe(0); + expect(result.shouldSwitch).toBe(false); + }); + + it('should handle string tab index', () => { + const meetingNode = createMeetingNode([ + createSectionNode(BlockType.AIMeetingNotesBlock, [ + createParagraphNode('Notes', 'note-1'), + ]), + createSectionNode(BlockType.AIMeetingTranscriptionBlock, [ + createSpeakerNode('s1', 0, [createParagraphNode('Transcript', 't-1')]), + ]), + ]); + + // Current index passed as string "0" + const result = calculateTabSwitch(meetingNode, 'transcript', '0'); + + expect(result.shouldSwitch).toBe(true); + expect(result.targetIndex).toBe(1); + }); + + it('should switch when current index is undefined', () => { + const meetingNode = createMeetingNode([ + createSectionNode(BlockType.AIMeetingNotesBlock, [ + createParagraphNode('Notes', 'note-1'), + ]), + createSectionNode(BlockType.AIMeetingTranscriptionBlock, [ + createSpeakerNode('s1', 0, [createParagraphNode('Transcript', 't-1')]), + ]), + ]); + + const result = calculateTabSwitch(meetingNode, 'transcript', undefined); + + expect(result.shouldSwitch).toBe(true); + }); + + it('should default to index 0 if target tab not in available tabs', () => { + const meetingNode = createMeetingNode([ + createSectionNode(BlockType.AIMeetingNotesBlock, [ + createParagraphNode('Notes', 'note-1'), + ]), + // No transcript section + ]); + + const result = calculateTabSwitch(meetingNode, 'transcript', 0); + + // Transcript not available, indexOf returns -1, Math.max(0, -1) = 0 + expect(result.targetIndex).toBe(0); + }); + + it('should handle meeting with only notes tab', () => { + const meetingNode = createMeetingNode([ + createSectionNode(BlockType.AIMeetingNotesBlock, [ + createParagraphNode('Notes', 'note-1'), + ]), + ]); + + const result = calculateTabSwitch(meetingNode, 'notes', undefined); + + expect(result.targetTabKey).toBe('notes'); + expect(result.targetIndex).toBe(0); + expect(result.shouldSwitch).toBe(true); // undefined -> NaN -> should switch + }); + }); + + describe('Tab index edge cases', () => { + it('should correctly find transcript as last tab', () => { + const meetingNode = createMeetingNode([ + createSectionNode(BlockType.AIMeetingSummaryBlock, [ + createParagraphNode('Summary', 'sum-1'), + ]), + createSectionNode(BlockType.AIMeetingNotesBlock, [ + createParagraphNode('Notes', 'note-1'), + ]), + createSectionNode(BlockType.AIMeetingTranscriptionBlock, [ + createSpeakerNode('s1', 0, [createParagraphNode('Transcript', 't-1')]), + ]), + ]); + + const tabs = getAvailableTabs(meetingNode); + + expect(tabs).toEqual(['summary', 'notes', 'transcript']); + expect(tabs.indexOf('transcript')).toBe(2); + }); + + it('should correctly find notes when no summary', () => { + const meetingNode = createMeetingNode([ + createSectionNode(BlockType.AIMeetingNotesBlock, [ + createParagraphNode('Notes', 'note-1'), + ]), + createSectionNode(BlockType.AIMeetingTranscriptionBlock, [ + createSpeakerNode('s1', 0, [createParagraphNode('Transcript', 't-1')]), + ]), + ]); + + const tabs = getAvailableTabs(meetingNode); + + expect(tabs).toEqual(['notes', 'transcript']); + expect(tabs.indexOf('notes')).toBe(0); + expect(tabs.indexOf('transcript')).toBe(1); + }); + }); +}); diff --git a/src/components/editor/components/leaf/reference/utils.ts b/src/components/editor/components/leaf/reference/utils.ts new file mode 100644 index 000000000..801319a3e --- /dev/null +++ b/src/components/editor/components/leaf/reference/utils.ts @@ -0,0 +1,48 @@ +export interface InlineReferenceData { + blockIds: string[]; + number: number; +} + +const coerceNumber = (value: unknown): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + + if (Number.isFinite(parsed)) return parsed; + } + + return null; +}; + +export const parseInlineReference = (raw: unknown): InlineReferenceData | null => { + if (!raw) return null; + + let parsed: unknown = raw; + + if (typeof raw === 'string') { + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + } + + if (!parsed || typeof parsed !== 'object') return null; + + const data = parsed as { blockIds?: unknown; number?: unknown }; + const blockIdsRaw = data.blockIds; + const number = coerceNumber(data.number); + + if (!number || !Array.isArray(blockIdsRaw)) return null; + + const blockIds = blockIdsRaw + .map((id) => (typeof id === 'string' ? id.trim() : '')) + .filter((id) => id.length > 0); + + if (!blockIds.length) return null; + + return { + blockIds, + number, + }; +}; diff --git a/src/components/editor/components/panels/PanelsContext.tsx b/src/components/editor/components/panels/PanelsContext.tsx index ce21a2d85..9896fe1c2 100644 --- a/src/components/editor/components/panels/PanelsContext.tsx +++ b/src/components/editor/components/panels/PanelsContext.tsx @@ -1,8 +1,9 @@ import React, { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { BaseRange, Point } from 'slate'; +import { BaseRange, Editor, Element, Point } from 'slate'; import { TextInsertTextOptions } from 'slate/dist/interfaces/transforms/text'; import { ReactEditor } from 'slate-react'; +import { BlockType } from '@/application/types'; import { getRangeRect } from '@/components/editor/components/toolbar/selection-toolbar/utils'; export enum PanelType { @@ -104,6 +105,19 @@ export const PanelProvider = ({ children, editor }: { children: React.ReactNode; if (!panelType) return; + if (panelType === PanelType.Slash && selection) { + const inTranscript = Editor.above(editor, { + at: selection, + match: (n) => + !Editor.isEditor(n) && + Element.isElement(n) && + (n.type === BlockType.AIMeetingTranscriptionBlock || + n.type === BlockType.AIMeetingSpeakerBlock), + }); + + if (inTranscript) return; + } + openPanel(panelType, { top: position.top, left: position.left }); startSelection.current = { diff --git a/src/components/editor/components/panels/slash-panel/SlashPanel.tsx b/src/components/editor/components/panels/slash-panel/SlashPanel.tsx index 221c17ef2..23d63bffc 100644 --- a/src/components/editor/components/panels/slash-panel/SlashPanel.tsx +++ b/src/components/editor/components/panels/slash-panel/SlashPanel.tsx @@ -2,7 +2,7 @@ import { Button } from '@mui/material'; import { PopoverOrigin } from '@mui/material/Popover/Popover'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Transforms } from 'slate'; +import { Editor, Element, Transforms } from 'slate'; import { ReactEditor, useSlateStatic } from 'slate-react'; import { YjsEditor } from '@/application/slate-yjs'; @@ -74,6 +74,15 @@ type DatabaseOption = { view: View; }; +const AI_MEETING_DATABASE_OPTION_KEYS = new Set([ + 'grid', + 'linkedGrid', + 'board', + 'linkedKanban', + 'calendar', + 'linkedCalendar', +]); + function filterViewsByDatabases(views: View[], allowedIds: Set, keyword: string) { const lowercaseKeyword = keyword.toLowerCase(); @@ -225,6 +234,47 @@ export function SlashPanel({ return isPanelOpen(PanelType.Slash); }, [isPanelOpen]); + const shouldRestrictAIMeetingDatabaseOptions = useCallback(() => { + const domSelection = window.getSelection(); + const anchorNode = domSelection?.anchorNode ?? null; + const anchorElement = + anchorNode instanceof HTMLElement ? anchorNode : anchorNode?.parentElement; + + if (anchorElement) { + const inSummary = anchorElement.closest(`[data-block-type="${BlockType.AIMeetingSummaryBlock}"]`); + + if (inSummary) return true; + + const inNotes = anchorElement.closest(`[data-block-type="${BlockType.AIMeetingNotesBlock}"]`); + + if (inNotes) return true; + } + + const { selection } = editor; + + if (!selection) return false; + + const inSummary = Editor.above(editor, { + at: selection, + match: (n) => + !Editor.isEditor(n) && + Element.isElement(n) && + n.type === BlockType.AIMeetingSummaryBlock, + }); + + if (inSummary) return true; + + const inNotes = Editor.above(editor, { + at: selection, + match: (n) => + !Editor.isEditor(n) && + Element.isElement(n) && + n.type === BlockType.AIMeetingNotesBlock, + }); + + return Boolean(inNotes); + }, [editor]); + useEffect(() => { if (documentId && open) { void loadViewMeta?.(documentId).then((view) => { @@ -620,6 +670,8 @@ export function SlashPanel({ keywords: string[]; onClick?: () => void; }[] = useMemo(() => { + const restrictDatabaseOptionsInAIMeeting = shouldRestrictAIMeetingDatabaseOptions(); + return [ { label: t('document.slashMenu.name.askAIAnything'), @@ -1164,6 +1216,7 @@ export function SlashPanel({ }, ].filter((option) => { if (option.disabled) return false; + if (restrictDatabaseOptionsInAIMeeting && AI_MEETING_DATABASE_OPTION_KEYS.has(option.key)) return false; if (!searchText) return true; return option.keywords.some((keyword: string) => { return keyword.toLowerCase().includes(searchText.toLowerCase()); @@ -1184,6 +1237,7 @@ export function SlashPanel({ searchText, handleOpenLinkedDatabasePicker, editor, + shouldRestrictAIMeetingDatabaseOptions, ]); const resultLength = options.length; diff --git a/src/components/editor/components/toolbar/block-controls/HoverControls.hooks.ts b/src/components/editor/components/toolbar/block-controls/HoverControls.hooks.ts index 2a69d28e4..5cbaba4dc 100644 --- a/src/components/editor/components/toolbar/block-controls/HoverControls.hooks.ts +++ b/src/components/editor/components/toolbar/block-controls/HoverControls.hooks.ts @@ -136,7 +136,49 @@ export function useHoverControls({ disabled }: { disabled: boolean }) { if (shouldSkipParentTypes.some((type) => blockElement.closest(`[data-block-type="${type}"]`))) { close(); return; - } else { + } + + const aiMeetingRoot = blockElement.closest(`[data-block-type="${BlockType.AIMeetingBlock}"]`); + + if (aiMeetingRoot) { + // The outer AI meeting block itself should show controls (drag/add) + if (node.type !== BlockType.AIMeetingBlock) { + // Hide for inner section container blocks + const innerSectionTypes = [ + BlockType.AIMeetingNotesBlock, + BlockType.AIMeetingSummaryBlock, + BlockType.AIMeetingTranscriptionBlock, + BlockType.AIMeetingSpeakerBlock, + ]; + + if (innerSectionTypes.includes(node.type as BlockType)) { + close(); + return; + } + + // Hide for all blocks inside transcript/speaker sections + const inTranscript = blockElement.closest( + `[data-block-type="${BlockType.AIMeetingTranscriptionBlock}"], [data-block-type="${BlockType.AIMeetingSpeakerBlock}"]` + ); + + if (inTranscript) { + close(); + return; + } + + // Allow for child blocks inside notes/summary sections only + const inNotesSummary = blockElement.closest( + `[data-block-type="${BlockType.AIMeetingNotesBlock}"], [data-block-type="${BlockType.AIMeetingSummaryBlock}"]` + ); + + if (!inNotesSummary) { + close(); + return; + } + } + } + + { recalculatePosition(blockElement); el.style.opacity = '1'; el.style.pointerEvents = 'auto'; diff --git a/src/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks.ts b/src/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks.ts index 3434c774e..11b707419 100644 --- a/src/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks.ts +++ b/src/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks.ts @@ -1,6 +1,6 @@ import { debounce } from 'lodash-es'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { Range } from 'slate'; +import { Editor, Element, Range } from 'slate'; import { ReactEditor, useFocused, useReadOnly, useSlate, useSlateStatic } from 'slate-react'; import { CustomEditor } from '@/application/slate-yjs/command'; @@ -27,6 +27,22 @@ export function useVisible() { return CustomEditor.getTextNodes(editor).length; }, [editor, selection]); const [visible, setVisible] = useState(false); + const isSelectionInReadOnly = useMemo(() => { + if (!selection) return false; + + const elementMatch = Editor.above(editor, { + at: selection, + match: (n) => !Editor.isEditor(n) && Element.isElement(n), + }); + + if (!elementMatch) return false; + + try { + return editor.isElementReadOnly(elementMatch[0]); + } catch { + return false; + } + }, [editor, selection]); const { assistantType, @@ -45,6 +61,7 @@ export function useVisible() { if(!focus) return false; if(document.getSelection()?.isCollapsed || assistantTypeRef.current !== undefined) return false; + if(isSelectionInReadOnly) return false; return Boolean(selectedText && isExpanded && !isDragging); }); @@ -56,7 +73,7 @@ export function useVisible() { return () => { document.removeEventListener('selectionchange', handleSelectionChange); }; - }, [focus, forceShow, isDragging, isExpanded, selectedText]); + }, [focus, forceShow, isDragging, isExpanded, isSelectionInReadOnly, selectedText]); useEffect(() => { if(!visible) { diff --git a/src/components/editor/editor.type.ts b/src/components/editor/editor.type.ts index d1130aa11..c9fffbd01 100644 --- a/src/components/editor/editor.type.ts +++ b/src/components/editor/editor.type.ts @@ -3,6 +3,7 @@ import { Element } from 'slate'; import { AIMeetingBlockData, + AIMeetingSpeakerBlockData, BlockType, CalloutBlockData, CodeBlockData, @@ -197,6 +198,23 @@ export interface AIMeetingNode extends BlockNode { data: AIMeetingBlockData; } +export interface AIMeetingSummaryNode extends BlockNode { + type: BlockType.AIMeetingSummaryBlock; +} + +export interface AIMeetingNotesNode extends BlockNode { + type: BlockType.AIMeetingNotesBlock; +} + +export interface AIMeetingTranscriptionNode extends BlockNode { + type: BlockType.AIMeetingTranscriptionBlock; +} + +export interface AIMeetingSpeakerNode extends BlockNode { + type: BlockType.AIMeetingSpeakerBlock; + data: AIMeetingSpeakerBlockData; +} + export interface PDFNode extends BlockNode { type: BlockType.PDFBlock; data: PDFBlockData; diff --git a/src/components/editor/plugins/withElement.ts b/src/components/editor/plugins/withElement.ts index cf8c24c9b..23ce654b6 100644 --- a/src/components/editor/plugins/withElement.ts +++ b/src/components/editor/plugins/withElement.ts @@ -32,6 +32,7 @@ export const withElement = (editor: ReactEditor) => { return true; } + } catch (e) { // } @@ -40,4 +41,4 @@ export const withElement = (editor: ReactEditor) => { }; return editor; -}; \ No newline at end of file +}; diff --git a/src/slate-editor.d.ts b/src/slate-editor.d.ts index afd8b065e..a404883f1 100644 --- a/src/slate-editor.d.ts +++ b/src/slate-editor.d.ts @@ -11,6 +11,10 @@ interface EditorInlineAttributes { code?: boolean; font_family?: string; formula?: string; + reference?: { + blockIds?: string[]; + number?: number; + } | string; prism_token?: string; class_name?: string;