Add Meeting Intelligence browser shape for canvas integration
New canvas shape that connects to the self-hosted Meeting Intelligence API (meets-api.rspace.online) to browse meetings, pull transcripts, summaries, and speaker data onto the canvas as note shapes. - MeetingIntelligencePanel: React panel listing meetings with pull buttons - MeetingIntelligenceBrowserShapeUtil: Browser shape wrapping the panel - MeetingIntelligenceTool: Tool for placing browser on canvas - Registered in Board.tsx, overrides.tsx, automerge stores, and all shape registries Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a92f38d7ac
commit
d247b9d67b
|
|
@ -682,6 +682,7 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
'holon': 'Holon',
|
||||
'obsidianBrowser': 'ObsidianBrowser',
|
||||
'fathomMeetingsBrowser': 'FathomMeetingsBrowser',
|
||||
'meetingIntelligenceBrowser': 'MeetingIntelligenceBrowser',
|
||||
'imageGen': 'ImageGen',
|
||||
'videoGen': 'VideoGen',
|
||||
'multmux': 'Multmux',
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ const CUSTOM_SHAPE_TYPES = [
|
|||
'Holon',
|
||||
'ObsidianBrowser',
|
||||
'FathomMeetingsBrowser',
|
||||
'MeetingIntelligenceBrowser',
|
||||
'ImageGen',
|
||||
'VideoGen',
|
||||
'Multmux',
|
||||
|
|
@ -167,6 +168,7 @@ import { FathomNoteShape } from "@/shapes/FathomNoteShapeUtil"
|
|||
import { HolonShape } from "@/shapes/HolonShapeUtil"
|
||||
import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil"
|
||||
import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil"
|
||||
import { MeetingIntelligenceBrowserShape } from "@/shapes/MeetingIntelligenceBrowserShapeUtil"
|
||||
import { ImageGenShape } from "@/shapes/ImageGenShapeUtil"
|
||||
import { VideoGenShape } from "@/shapes/VideoGenShapeUtil"
|
||||
import { MultmuxShape } from "@/shapes/MultmuxShapeUtil"
|
||||
|
|
@ -218,6 +220,7 @@ export function useAutomergeStoreV2({
|
|||
HolonShape,
|
||||
ObsidianBrowserShape,
|
||||
FathomMeetingsBrowserShape,
|
||||
MeetingIntelligenceBrowserShape,
|
||||
ImageGenShape,
|
||||
VideoGenShape,
|
||||
MultmuxShape,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,262 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { useEditor } from 'tldraw'
|
||||
|
||||
/** Shape of a meeting from the Meeting Intelligence API */
|
||||
interface MIMeeting {
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
duration_seconds?: number
|
||||
participants?: string[]
|
||||
created_at: string
|
||||
has_transcript?: boolean
|
||||
has_summary?: boolean
|
||||
speaker_count?: number
|
||||
}
|
||||
|
||||
interface MeetingIntelligencePanelProps {
|
||||
onClose?: () => void
|
||||
onMeetingSelect?: (
|
||||
meeting: MIMeeting,
|
||||
options: { summary: boolean; transcript: boolean; speakers: boolean },
|
||||
) => void
|
||||
shapeMode?: boolean
|
||||
}
|
||||
|
||||
// Meeting Intelligence API base URL
|
||||
const MI_API_URL = 'https://meets-api.rspace.online'
|
||||
|
||||
function formatDuration(seconds?: number): string {
|
||||
if (!seconds) return '—'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (h > 0) return `${h}h ${m}m`
|
||||
return `${m}m`
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'completed': return '#10b981'
|
||||
case 'recording': return '#ef4444'
|
||||
case 'transcribing': case 'diarizing': return '#f59e0b'
|
||||
case 'summarizing': return '#8b5cf6'
|
||||
default: return '#6b7280'
|
||||
}
|
||||
}
|
||||
|
||||
export function MeetingIntelligencePanel({ onMeetingSelect }: MeetingIntelligencePanelProps) {
|
||||
const editor = useEditor()
|
||||
const [meetings, setMeetings] = useState<MIMeeting[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchMeetings = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${MI_API_URL}/meetings`)
|
||||
if (!res.ok) throw new Error(`API returned ${res.status}`)
|
||||
const data = await res.json() as any
|
||||
// API may return { meetings: [...] } or [...]
|
||||
const list = Array.isArray(data) ? data : (data.meetings || [])
|
||||
setMeetings(list)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load meetings')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchMeetings()
|
||||
}, [])
|
||||
|
||||
const panelStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
fontFamily: 'Inter, -apple-system, sans-serif',
|
||||
fontSize: '13px',
|
||||
color: '#1a1a1a',
|
||||
backgroundColor: '#fff',
|
||||
}
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid #e5e7eb',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
}
|
||||
|
||||
const listStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '8px',
|
||||
}
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
padding: '12px',
|
||||
marginBottom: '8px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
backgroundColor: '#fafafa',
|
||||
cursor: 'default',
|
||||
}
|
||||
|
||||
const badgeStyle = (color: string): React.CSSProperties => ({
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '9999px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
backgroundColor: color,
|
||||
})
|
||||
|
||||
const btnStyle = (color: string): React.CSSProperties => ({
|
||||
padding: '4px 10px',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
backgroundColor: color,
|
||||
cursor: 'pointer',
|
||||
transition: 'opacity 0.15s',
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
{/* Header */}
|
||||
<div style={headerStyle}>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px' }}>Meeting Intelligence</span>
|
||||
<button
|
||||
onClick={fetchMeetings}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{ ...btnStyle('#6b7280'), padding: '4px 8px' }}
|
||||
title="Refresh"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={listStyle}>
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '24px', color: '#6b7280' }}>
|
||||
Loading meetings...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ textAlign: 'center', padding: '24px', color: '#ef4444' }}>
|
||||
{error}
|
||||
<br />
|
||||
<button
|
||||
onClick={fetchMeetings}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{ ...btnStyle('#6b7280'), marginTop: '8px' }}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && meetings.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '24px', color: '#6b7280' }}>
|
||||
No meetings found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meetings.map((m) => (
|
||||
<div key={m.id} style={cardStyle}>
|
||||
{/* Meeting title + status */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '6px' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '13px', flex: 1 }}>
|
||||
{m.title || 'Untitled Meeting'}
|
||||
</span>
|
||||
<span style={badgeStyle(statusColor(m.status))}>
|
||||
{m.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Meta row */}
|
||||
<div style={{ display: 'flex', gap: '12px', fontSize: '11px', color: '#6b7280', marginBottom: '8px' }}>
|
||||
<span>{formatDate(m.created_at)}</span>
|
||||
<span>{formatDuration(m.duration_seconds)}</span>
|
||||
{m.speaker_count != null && <span>{m.speaker_count} speakers</span>}
|
||||
</div>
|
||||
|
||||
{/* Pull buttons */}
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||
{m.has_summary && (
|
||||
<button
|
||||
style={btnStyle('#8b5cf6')}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onMeetingSelect?.(m, { summary: true, transcript: false, speakers: false })
|
||||
}}
|
||||
>
|
||||
Summary
|
||||
</button>
|
||||
)}
|
||||
{m.has_transcript && (
|
||||
<button
|
||||
style={btnStyle('#2563eb')}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onMeetingSelect?.(m, { summary: false, transcript: true, speakers: false })
|
||||
}}
|
||||
>
|
||||
Transcript
|
||||
</button>
|
||||
)}
|
||||
{(m.speaker_count ?? 0) > 0 && (
|
||||
<button
|
||||
style={btnStyle('#059669')}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onMeetingSelect?.(m, { summary: false, transcript: false, speakers: true })
|
||||
}}
|
||||
>
|
||||
Speakers
|
||||
</button>
|
||||
)}
|
||||
{/* Pull All — only if multiple types available */}
|
||||
{(m.has_summary || m.has_transcript) && (
|
||||
<button
|
||||
style={btnStyle('#1e293b')}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onMeetingSelect?.(m, {
|
||||
summary: !!m.has_summary,
|
||||
transcript: !!m.has_transcript,
|
||||
speakers: (m.speaker_count ?? 0) > 0,
|
||||
})
|
||||
}}
|
||||
>
|
||||
Pull All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ const SHAPE_DISPLAY_NAMES: Record<string, string> = {
|
|||
'HolonBrowser': 'holon browser',
|
||||
'ObsidianBrowser': 'Obsidian browser',
|
||||
'FathomMeetingsBrowser': 'Fathom browser',
|
||||
'MeetingIntelligenceBrowser': 'Meeting Intelligence browser',
|
||||
'FathomNote': 'Fathom note',
|
||||
'ImageGen': 'AI image',
|
||||
'VideoGen': 'AI video',
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ import { HolonBrowserShape } from "@/shapes/HolonBrowserShapeUtil"
|
|||
import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil"
|
||||
import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil"
|
||||
import { FathomNoteShape } from "@/shapes/FathomNoteShapeUtil"
|
||||
import { MeetingIntelligenceBrowserShape } from "@/shapes/MeetingIntelligenceBrowserShapeUtil"
|
||||
import { MeetingIntelligenceTool } from "@/tools/MeetingIntelligenceTool"
|
||||
import { ImageGenShape } from "@/shapes/ImageGenShapeUtil"
|
||||
import { ImageGenTool } from "@/tools/ImageGenTool"
|
||||
import { VideoGenShape } from "@/shapes/VideoGenShapeUtil"
|
||||
|
|
@ -162,6 +164,7 @@ const customShapeUtils = [
|
|||
ObsidianBrowserShape,
|
||||
FathomMeetingsBrowserShape,
|
||||
FathomNoteShape, // Individual Fathom meeting notes created from FathomMeetingsBrowser
|
||||
MeetingIntelligenceBrowserShape, // Self-hosted meeting intelligence browser
|
||||
ImageGenShape,
|
||||
VideoGenShape,
|
||||
BlenderGenShape, // Blender 3D procedural generation
|
||||
|
|
@ -190,6 +193,7 @@ const customTools = [
|
|||
TranscriptionTool,
|
||||
HolonTool,
|
||||
FathomMeetingsTool,
|
||||
MeetingIntelligenceTool, // Self-hosted meeting intelligence tool
|
||||
ImageGenTool,
|
||||
VideoGenTool,
|
||||
BlenderGenTool, // Blender 3D procedural generation
|
||||
|
|
@ -1269,9 +1273,10 @@ export function Board() {
|
|||
// Check if any selected shapes are browser shapes that should not be deleted
|
||||
const selectedShapes = editor.getSelectedShapes();
|
||||
const hasBrowserShape = selectedShapes.some(shape =>
|
||||
shape.type === 'ObsidianBrowser' ||
|
||||
shape.type === 'HolonBrowser' ||
|
||||
shape.type === 'FathomMeetingsBrowser'
|
||||
shape.type === 'ObsidianBrowser' ||
|
||||
shape.type === 'HolonBrowser' ||
|
||||
shape.type === 'FathomMeetingsBrowser' ||
|
||||
shape.type === 'MeetingIntelligenceBrowser'
|
||||
);
|
||||
|
||||
// Prevent deletion of browser shapes with Escape
|
||||
|
|
|
|||
|
|
@ -0,0 +1,337 @@
|
|||
import {
|
||||
BaseBoxShapeUtil,
|
||||
HTMLContainer,
|
||||
TLBaseShape,
|
||||
createShapeId,
|
||||
Box,
|
||||
IndexKey,
|
||||
TLParentId,
|
||||
} from "tldraw"
|
||||
import React, { useState } from "react"
|
||||
import { MeetingIntelligencePanel } from "../components/MeetingIntelligencePanel"
|
||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||
import { usePinnedToView } from "../hooks/usePinnedToView"
|
||||
import { useMaximize } from "../hooks/useMaximize"
|
||||
import { FathomNoteShape } from "./FathomNoteShapeUtil"
|
||||
|
||||
// Meeting Intelligence API base URL
|
||||
const MI_API_URL = 'https://meets-api.rspace.online'
|
||||
|
||||
type IMeetingIntelligenceBrowser = TLBaseShape<
|
||||
"MeetingIntelligenceBrowser",
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
pinnedToView: boolean
|
||||
tags: string[]
|
||||
}
|
||||
>
|
||||
|
||||
export class MeetingIntelligenceBrowserShape extends BaseBoxShapeUtil<IMeetingIntelligenceBrowser> {
|
||||
static override type = "MeetingIntelligenceBrowser" as const
|
||||
|
||||
getDefaultProps(): IMeetingIntelligenceBrowser["props"] {
|
||||
return {
|
||||
w: 800,
|
||||
h: 600,
|
||||
pinnedToView: false,
|
||||
tags: ['meeting-intelligence', 'meetings', 'transcription'],
|
||||
}
|
||||
}
|
||||
|
||||
// Meeting Intelligence theme: purple
|
||||
static readonly PRIMARY_COLOR = "#8b5cf6"
|
||||
|
||||
component(shape: IMeetingIntelligenceBrowser) {
|
||||
const { w, h } = shape.props
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
|
||||
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
|
||||
|
||||
const { isMaximized, toggleMaximize } = useMaximize({
|
||||
editor: this.editor,
|
||||
shapeId: shape.id,
|
||||
currentW: w,
|
||||
currentH: h,
|
||||
shapeType: 'MeetingIntelligenceBrowser',
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false)
|
||||
this.editor.deleteShape(shape.id)
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
setIsMinimized(!isMinimized)
|
||||
}
|
||||
|
||||
const handlePinToggle = () => {
|
||||
this.editor.updateShape<IMeetingIntelligenceBrowser>({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: {
|
||||
...shape.props,
|
||||
pinnedToView: !shape.props.pinnedToView,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleMeetingSelect = async (
|
||||
meeting: any,
|
||||
options: { summary: boolean; transcript: boolean; speakers: boolean },
|
||||
) => {
|
||||
try {
|
||||
const meetingId = meeting.id
|
||||
if (!meetingId) {
|
||||
console.error('No meeting ID found:', meeting)
|
||||
return
|
||||
}
|
||||
|
||||
const meetingTitle = meeting.title || 'Untitled Meeting'
|
||||
|
||||
// Helper to format date
|
||||
const formatDate = (dateStr?: string): string => {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
|
||||
} catch { return '' }
|
||||
}
|
||||
const meetingDate = formatDate(meeting.created_at)
|
||||
|
||||
// Fetch requested data in parallel
|
||||
const [summaryRes, transcriptRes, speakersRes]: [any, any, any] = await Promise.all([
|
||||
options.summary
|
||||
? fetch(`${MI_API_URL}/meetings/${meetingId}/summary`).then(r => r.ok ? r.json() : null).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
options.transcript
|
||||
? fetch(`${MI_API_URL}/meetings/${meetingId}/transcript`).then(r => r.ok ? r.json() : null).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
options.speakers
|
||||
? fetch(`${MI_API_URL}/meetings/${meetingId}/speakers`).then(r => r.ok ? r.json() : null).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
])
|
||||
|
||||
// Position notes next to the browser
|
||||
const browserBounds = this.editor.getShapePageBounds(shape.id)
|
||||
const spacing = 30
|
||||
let startX = browserBounds ? browserBounds.x + browserBounds.w + spacing : 0
|
||||
const startY = browserBounds ? browserBounds.y : 0
|
||||
|
||||
const shapeWidth = 500
|
||||
const shapeHeight = 600
|
||||
const hSpacing = 20
|
||||
|
||||
// Find existing MI shapes for this meeting to avoid overlap
|
||||
const allShapes = this.editor.getCurrentPageShapes()
|
||||
const existingForMeeting = allShapes.filter(s => {
|
||||
if (s.type !== 'FathomNote') return false
|
||||
const noteId = (s as any).props?.noteId || ''
|
||||
return noteId.includes(`mi-${meetingId}`)
|
||||
})
|
||||
if (existingForMeeting.length > 0) {
|
||||
let rightmost = startX
|
||||
existingForMeeting.forEach(s => {
|
||||
const b = this.editor.getShapePageBounds(s.id)
|
||||
if (b) rightmost = Math.max(rightmost, b.x + b.w)
|
||||
})
|
||||
startX = rightmost + hSpacing
|
||||
}
|
||||
|
||||
let currentX = startX
|
||||
const shapesToCreate: any[] = []
|
||||
|
||||
// Summary note
|
||||
if (summaryRes) {
|
||||
const summaryText = summaryRes.summary_text || summaryRes.summary || summaryRes.overview || ''
|
||||
const actionItems = summaryRes.action_items || []
|
||||
const decisions = summaryRes.decisions || []
|
||||
const keyPoints = summaryRes.key_points || []
|
||||
|
||||
let content = ''
|
||||
if (meetingDate) {
|
||||
content += `<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;">
|
||||
<h1 style="margin:0;font-size:18px;font-weight:bold;flex:1;">${meetingTitle}: Summary</h1>
|
||||
<span style="font-size:11px;color:#666;margin-left:12px;">${meetingDate}</span>
|
||||
</div>\n\n`
|
||||
} else {
|
||||
content += `# ${meetingTitle}: Summary\n\n`
|
||||
}
|
||||
if (summaryText) content += `${summaryText}\n\n`
|
||||
if (keyPoints.length > 0) {
|
||||
content += `## Key Points\n${keyPoints.map((p: any) => `- ${typeof p === 'string' ? p : p.text || p.point || ''}`).join('\n')}\n\n`
|
||||
}
|
||||
if (decisions.length > 0) {
|
||||
content += `## Decisions\n${decisions.map((d: any) => `- ${typeof d === 'string' ? d : d.text || d.decision || ''}`).join('\n')}\n\n`
|
||||
}
|
||||
if (actionItems.length > 0) {
|
||||
content += `## Action Items\n${actionItems.map((a: any) => `- [ ] ${a.task || a.text || a.description || ''}`).join('\n')}\n\n`
|
||||
}
|
||||
|
||||
shapesToCreate.push({
|
||||
...FathomNoteShape.createFromData(
|
||||
{ id: `mi-summary-${meetingId}`, title: 'Meeting Summary', content, tags: ['meeting-intelligence', 'summary'], primaryColor: '#8b5cf6' },
|
||||
currentX, startY
|
||||
),
|
||||
props: {
|
||||
...FathomNoteShape.createFromData(
|
||||
{ id: `mi-summary-${meetingId}`, title: 'Meeting Summary', content, tags: ['meeting-intelligence', 'summary'], primaryColor: '#8b5cf6' },
|
||||
currentX, startY
|
||||
).props,
|
||||
w: shapeWidth, h: shapeHeight,
|
||||
},
|
||||
})
|
||||
currentX += shapeWidth + hSpacing
|
||||
}
|
||||
|
||||
// Transcript note
|
||||
if (transcriptRes) {
|
||||
const segments = Array.isArray(transcriptRes) ? transcriptRes : (transcriptRes.segments || transcriptRes.transcript || [])
|
||||
|
||||
let content = ''
|
||||
if (meetingDate) {
|
||||
content += `<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;">
|
||||
<h1 style="margin:0;font-size:18px;font-weight:bold;flex:1;">${meetingTitle}: Transcript</h1>
|
||||
<span style="font-size:11px;color:#666;margin-left:12px;">${meetingDate}</span>
|
||||
</div>\n\n`
|
||||
} else {
|
||||
content += `# ${meetingTitle}: Transcript\n\n`
|
||||
}
|
||||
|
||||
if (segments.length > 0) {
|
||||
content += segments.map((seg: any) => {
|
||||
const speaker = seg.speaker_label || seg.speaker || seg.name || 'Unknown'
|
||||
const text = seg.text || seg.content || ''
|
||||
const start = seg.start_time != null ? `${Math.floor(seg.start_time / 60)}:${String(Math.floor(seg.start_time % 60)).padStart(2, '0')}` : ''
|
||||
return start ? `**${speaker}** (${start}): ${text}` : `**${speaker}**: ${text}`
|
||||
}).join('\n\n')
|
||||
} else {
|
||||
content += '*No transcript segments available.*'
|
||||
}
|
||||
|
||||
shapesToCreate.push({
|
||||
...FathomNoteShape.createFromData(
|
||||
{ id: `mi-transcript-${meetingId}`, title: 'Meeting Transcript', content, tags: ['meeting-intelligence', 'transcript'], primaryColor: '#2563eb' },
|
||||
currentX, startY
|
||||
),
|
||||
props: {
|
||||
...FathomNoteShape.createFromData(
|
||||
{ id: `mi-transcript-${meetingId}`, title: 'Meeting Transcript', content, tags: ['meeting-intelligence', 'transcript'], primaryColor: '#2563eb' },
|
||||
currentX, startY
|
||||
).props,
|
||||
w: shapeWidth, h: shapeHeight,
|
||||
},
|
||||
})
|
||||
currentX += shapeWidth + hSpacing
|
||||
}
|
||||
|
||||
// Speakers note
|
||||
if (speakersRes) {
|
||||
const speakers = Array.isArray(speakersRes) ? speakersRes : (speakersRes.speakers || [])
|
||||
|
||||
let content = ''
|
||||
if (meetingDate) {
|
||||
content += `<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;">
|
||||
<h1 style="margin:0;font-size:18px;font-weight:bold;flex:1;">${meetingTitle}: Speakers</h1>
|
||||
<span style="font-size:11px;color:#666;margin-left:12px;">${meetingDate}</span>
|
||||
</div>\n\n`
|
||||
} else {
|
||||
content += `# ${meetingTitle}: Speakers\n\n`
|
||||
}
|
||||
|
||||
if (speakers.length > 0) {
|
||||
content += speakers.map((s: any) => {
|
||||
const name = s.speaker_label || s.name || s.label || 'Unknown'
|
||||
const time = s.speaking_time || s.speaking_time_seconds || 0
|
||||
const mins = Math.floor(time / 60)
|
||||
const secs = Math.floor(time % 60)
|
||||
const segments = s.segment_count || s.segments || '—'
|
||||
return `### ${name}\n- Speaking time: ${mins}m ${secs}s\n- Segments: ${segments}`
|
||||
}).join('\n\n')
|
||||
} else {
|
||||
content += '*No speaker data available.*'
|
||||
}
|
||||
|
||||
shapesToCreate.push({
|
||||
...FathomNoteShape.createFromData(
|
||||
{ id: `mi-speakers-${meetingId}`, title: 'Meeting Speakers', content, tags: ['meeting-intelligence', 'speakers'], primaryColor: '#059669' },
|
||||
currentX, startY
|
||||
),
|
||||
props: {
|
||||
...FathomNoteShape.createFromData(
|
||||
{ id: `mi-speakers-${meetingId}`, title: 'Meeting Speakers', content, tags: ['meeting-intelligence', 'speakers'], primaryColor: '#059669' },
|
||||
currentX, startY
|
||||
).props,
|
||||
w: shapeWidth, h: shapeHeight,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Create all shapes at once
|
||||
if (shapesToCreate.length > 0) {
|
||||
this.editor.createShapes(shapesToCreate)
|
||||
|
||||
// Zoom to show results
|
||||
setTimeout(() => {
|
||||
const firstBounds = this.editor.getShapePageBounds(shapesToCreate[0].id)
|
||||
if (firstBounds && browserBounds) {
|
||||
const combined = Box.Common([browserBounds, firstBounds])
|
||||
this.editor.zoomToBounds(combined, {
|
||||
inset: 50,
|
||||
animation: { duration: 500, easing: (t) => t * (2 - t) },
|
||||
})
|
||||
}
|
||||
this.editor.setSelectedShapes([shapesToCreate[0].id] as any)
|
||||
this.editor.setCurrentTool('select')
|
||||
}, 50)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating Meeting Intelligence shapes:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<HTMLContainer style={{ width: w, height: h }}>
|
||||
<StandardizedToolWrapper
|
||||
title="Meeting Intelligence"
|
||||
primaryColor={MeetingIntelligenceBrowserShape.PRIMARY_COLOR}
|
||||
isSelected={isSelected}
|
||||
width={w}
|
||||
height={h}
|
||||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
isMinimized={isMinimized}
|
||||
onMaximize={toggleMaximize}
|
||||
isMaximized={isMaximized}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
isPinnedToView={shape.props.pinnedToView}
|
||||
onPinToggle={handlePinToggle}
|
||||
tags={shape.props.tags}
|
||||
onTagsChange={(newTags) => {
|
||||
this.editor.updateShape<IMeetingIntelligenceBrowser>({
|
||||
id: shape.id,
|
||||
type: 'MeetingIntelligenceBrowser',
|
||||
props: { ...shape.props, tags: newTags },
|
||||
})
|
||||
}}
|
||||
tagsEditable={true}
|
||||
>
|
||||
<MeetingIntelligencePanel
|
||||
onClose={handleClose}
|
||||
onMeetingSelect={handleMeetingSelect}
|
||||
shapeMode={true}
|
||||
/>
|
||||
</StandardizedToolWrapper>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
indicator(shape: IMeetingIntelligenceBrowser) {
|
||||
return <rect width={shape.props.w} height={shape.props.h} />
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import { StateNode } from "tldraw"
|
||||
|
||||
export class MeetingIntelligenceTool extends StateNode {
|
||||
static override id = "meeting-intelligence"
|
||||
static override initial = "idle"
|
||||
static override children = () => [MeetingIntelligenceIdle]
|
||||
}
|
||||
|
||||
export class MeetingIntelligenceIdle extends StateNode {
|
||||
static override id = "idle"
|
||||
|
||||
tooltipElement?: HTMLDivElement
|
||||
mouseMoveHandler?: (e: MouseEvent) => void
|
||||
isCreatingShape = false
|
||||
|
||||
override onEnter = () => {
|
||||
this.editor.setCursor({ type: "cross", rotation: 0 })
|
||||
|
||||
this.tooltipElement = document.createElement('div')
|
||||
this.tooltipElement.style.cssText = `
|
||||
position: fixed;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
`
|
||||
this.tooltipElement.textContent = 'Click anywhere to place Meeting Intelligence browser'
|
||||
document.body.appendChild(this.tooltipElement)
|
||||
|
||||
this.mouseMoveHandler = (e: MouseEvent) => {
|
||||
if (this.tooltipElement) {
|
||||
let x = e.clientX + 15
|
||||
let y = e.clientY - 35
|
||||
const rect = this.tooltipElement.getBoundingClientRect()
|
||||
if (x + rect.width > window.innerWidth) x = e.clientX - rect.width - 15
|
||||
if (y + rect.height > window.innerHeight) y = e.clientY - rect.height - 15
|
||||
x = Math.max(10, x)
|
||||
y = Math.max(10, y)
|
||||
this.tooltipElement.style.left = `${x}px`
|
||||
this.tooltipElement.style.top = `${y}px`
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousemove', this.mouseMoveHandler)
|
||||
}
|
||||
|
||||
override onPointerDown = (info?: any) => {
|
||||
if (this.isCreatingShape) return
|
||||
if (!info || !info.point || info.button === undefined) return
|
||||
if (info.button !== 0) return
|
||||
|
||||
if (info.target && typeof info.target === 'object') {
|
||||
const target = info.target as HTMLElement
|
||||
if (target.closest('[data-tldraw-ui]') ||
|
||||
target.closest('.tlui-menu') ||
|
||||
target.closest('.tlui-toolbar') ||
|
||||
target.closest('[role="menu"]') ||
|
||||
target.closest('[role="toolbar"]')) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let clickX: number | undefined
|
||||
let clickY: number | undefined
|
||||
|
||||
if (info.point) {
|
||||
try {
|
||||
const pagePoint = this.editor.screenToPage(info.point)
|
||||
clickX = pagePoint.x
|
||||
clickY = pagePoint.y
|
||||
} catch (e) {
|
||||
console.error('MeetingIntelligenceTool: Failed to convert point', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (clickX === undefined || clickY === undefined) return
|
||||
if (clickX === 0 && clickY === 0) return
|
||||
|
||||
const currentTool = this.editor.getCurrentToolId()
|
||||
if (currentTool !== 'meeting-intelligence') return
|
||||
|
||||
this.createShape(clickX, clickY)
|
||||
}
|
||||
|
||||
override onExit = () => {
|
||||
this.cleanupTooltip()
|
||||
this.isCreatingShape = false
|
||||
}
|
||||
|
||||
private cleanupTooltip = () => {
|
||||
if (this.mouseMoveHandler) {
|
||||
document.removeEventListener('mousemove', this.mouseMoveHandler)
|
||||
this.mouseMoveHandler = undefined
|
||||
}
|
||||
if (this.tooltipElement) {
|
||||
document.body.removeChild(this.tooltipElement)
|
||||
this.tooltipElement = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private createShape(clickX: number, clickY: number) {
|
||||
this.isCreatingShape = true
|
||||
try {
|
||||
const currentCamera = this.editor.getCamera()
|
||||
this.editor.stopCameraAnimation()
|
||||
|
||||
const shapeWidth = 800
|
||||
const shapeHeight = 600
|
||||
const finalX = clickX - shapeWidth / 2
|
||||
const finalY = clickY - shapeHeight / 2
|
||||
|
||||
const browserShape = this.editor.createShape({
|
||||
type: 'MeetingIntelligenceBrowser',
|
||||
x: finalX,
|
||||
y: finalY,
|
||||
props: { w: shapeWidth, h: shapeHeight },
|
||||
})
|
||||
|
||||
const newCamera = this.editor.getCamera()
|
||||
if (currentCamera.x !== newCamera.x || currentCamera.y !== newCamera.y || currentCamera.z !== newCamera.z) {
|
||||
this.editor.setCamera(currentCamera, { animation: { duration: 0 } })
|
||||
}
|
||||
|
||||
const shapeId = browserShape.id.startsWith('shape:') ? browserShape.id : `shape:${browserShape.id}`
|
||||
const camBefore = this.editor.getCamera()
|
||||
this.editor.stopCameraAnimation()
|
||||
this.editor.setSelectedShapes([shapeId] as any)
|
||||
this.editor.setCurrentTool('select')
|
||||
|
||||
const camAfter = this.editor.getCamera()
|
||||
if (camBefore.x !== camAfter.x || camBefore.y !== camAfter.y || camBefore.z !== camAfter.z) {
|
||||
this.editor.setCamera(camBefore, { animation: { duration: 0 } })
|
||||
}
|
||||
|
||||
setTimeout(() => { this.isCreatingShape = false }, 200)
|
||||
} catch (error) {
|
||||
console.error('Error creating MeetingIntelligenceBrowser shape:', error)
|
||||
this.isCreatingShape = false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ export function CustomMainMenu() {
|
|||
const validateAndNormalizeShapeType = (shape: any): string => {
|
||||
if (!shape || !shape.type) return 'text'
|
||||
|
||||
const validCustomShapes = ['ObsNote', 'VideoChat', 'Transcription', 'Prompt', 'ChatBox', 'Embed', 'Markdown', 'MycrozineTemplate', 'Slide', 'Holon', 'ObsidianBrowser', 'HolonBrowser', 'FathomMeetingsBrowser', 'ImageGen', 'VideoGen', 'Multmux', 'FathomNote', 'GoogleItem', 'Map', 'PrivateWorkspace', 'SharedPiano', 'Drawfast', 'MycelialIntelligence']
|
||||
const validCustomShapes = ['ObsNote', 'VideoChat', 'Transcription', 'Prompt', 'ChatBox', 'Embed', 'Markdown', 'MycrozineTemplate', 'Slide', 'Holon', 'ObsidianBrowser', 'HolonBrowser', 'FathomMeetingsBrowser', 'MeetingIntelligenceBrowser', 'ImageGen', 'VideoGen', 'Multmux', 'FathomNote', 'GoogleItem', 'Map', 'PrivateWorkspace', 'SharedPiano', 'Drawfast', 'MycelialIntelligence']
|
||||
const validDefaultShapes = ['arrow', 'bookmark', 'draw', 'embed', 'frame', 'geo', 'group', 'highlight', 'image', 'line', 'note', 'text', 'video']
|
||||
const allValidShapes = [...validCustomShapes, ...validDefaultShapes]
|
||||
|
||||
|
|
|
|||
|
|
@ -211,6 +211,13 @@ export const overrides: TLUiOverrides = {
|
|||
// Shape creation is handled manually in FathomMeetingsTool.onPointerDown
|
||||
onSelect: () => editor.setCurrentTool("fathom-meetings"),
|
||||
},
|
||||
MeetingIntelligence: {
|
||||
id: "meeting-intelligence",
|
||||
icon: "microphone",
|
||||
label: "Meeting Intelligence",
|
||||
readonlyOk: true,
|
||||
onSelect: () => editor.setCurrentTool("meeting-intelligence"),
|
||||
},
|
||||
ImageGen: {
|
||||
id: "ImageGen",
|
||||
icon: "image",
|
||||
|
|
|
|||
|
|
@ -32,11 +32,11 @@ export function resolveOverlaps(editor: Editor, shapeId: string): void {
|
|||
const allShapes = editor.getCurrentPageShapes()
|
||||
const customShapeTypes = [
|
||||
'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat',
|
||||
'Transcription', 'Holon', 'FathomMeetingsBrowser', 'Prompt',
|
||||
'Transcription', 'Holon', 'FathomMeetingsBrowser', 'MeetingIntelligenceBrowser', 'Prompt',
|
||||
'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'ChatBox',
|
||||
'ImageGen', 'VideoGen', 'Multmux'
|
||||
]
|
||||
|
||||
|
||||
const shape = editor.getShape(shapeId as TLShapeId)
|
||||
if (!shape || !customShapeTypes.includes(shape.type as string)) return
|
||||
|
||||
|
|
@ -120,11 +120,11 @@ export function findNonOverlappingPosition(
|
|||
const allShapes = editor.getCurrentPageShapes()
|
||||
const customShapeTypes = [
|
||||
'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat',
|
||||
'Transcription', 'Holon', 'FathomMeetingsBrowser', 'Prompt',
|
||||
'Transcription', 'Holon', 'FathomMeetingsBrowser', 'MeetingIntelligenceBrowser', 'Prompt',
|
||||
'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'ChatBox',
|
||||
'ImageGen', 'VideoGen', 'Multmux'
|
||||
]
|
||||
|
||||
|
||||
const existingShapes = allShapes.filter(
|
||||
s => !excludeShapeIds.includes(s.id) && customShapeTypes.includes(s.type)
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue