mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-02 20:03:21 +02:00
running transcript in the transcription block
This commit is contained in:
parent
cf33e78243
commit
7a3185f201
4 changed files with 113 additions and 15 deletions
|
|
@ -3467,17 +3467,15 @@ function App() {
|
|||
const calendarEventJson = calEventMatch?.[1]?.replace(/''/g, "'")
|
||||
const { notes } = await window.ipc.invoke('meeting:summarize', { transcript: fileContent, meetingStartTime, calendarEventJson })
|
||||
if (notes) {
|
||||
// Prepend meeting notes below the title but above the transcript
|
||||
const { raw: fm, body: transcriptBody } = splitFrontmatter(fileContent)
|
||||
// Use frontmatter title as the heading (set from calendar event summary)
|
||||
// Prepend meeting notes above the existing transcript block
|
||||
const { raw: fm, body } = splitFrontmatter(fileContent)
|
||||
const fmTitleMatch = fileContent.match(/^title:\s*(.+)$/m)
|
||||
const noteTitle = fmTitleMatch?.[1]?.trim() || 'Meeting note'
|
||||
// Strip any existing top-level heading from body
|
||||
const bodyWithoutTitle = transcriptBody.replace(/^#\s+.+\s*\n*/, '')
|
||||
// Also strip any title/heading the LLM may have generated
|
||||
const cleanedNotes = notes.replace(/^#{1,2}\s+.+\n+/, '')
|
||||
const transcriptData = JSON.stringify({ transcript: bodyWithoutTitle.trim() })
|
||||
const newBody = `# ${noteTitle}\n\n` + cleanedNotes + '\n\n```transcript\n' + transcriptData + '\n```'
|
||||
// Extract the existing transcript block and preserve it as-is
|
||||
const transcriptBlockMatch = body.match(/(```transcript\n[\s\S]*?\n```)/)
|
||||
const transcriptBlock = transcriptBlockMatch?.[1] || ''
|
||||
const newBody = `# ${noteTitle}\n\n` + cleanedNotes + (transcriptBlock ? '\n\n' + transcriptBlock : '')
|
||||
const newContent = fm ? `${fm}\n${newBody}` : newBody
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: notePath,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,54 @@ import { mergeAttributes, Node } from '@tiptap/react'
|
|||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { ChevronDown, FileText } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useState } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
|
||||
function TranscriptBlockView({ node }: {
|
||||
interface TranscriptEntry {
|
||||
speaker: string
|
||||
text: string
|
||||
}
|
||||
|
||||
function parseTranscript(raw: string): TranscriptEntry[] {
|
||||
const entries: TranscriptEntry[] = []
|
||||
const lines = raw.split('\n')
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
// Match **Speaker Name:** text or **You:** text
|
||||
const match = trimmed.match(/^\*\*(.+?):\*\*\s*(.*)$/)
|
||||
if (match) {
|
||||
entries.push({ speaker: match[1], text: match[2] })
|
||||
} else if (entries.length > 0) {
|
||||
// Continuation line — append to last entry
|
||||
entries[entries.length - 1].text += ' ' + trimmed
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function speakerColor(speaker: string): string {
|
||||
// Simple hash to pick a consistent color per speaker
|
||||
let hash = 0
|
||||
for (let i = 0; i < speaker.length; i++) {
|
||||
hash = speaker.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
const colors = [
|
||||
'#3b82f6', // blue
|
||||
'#06b6d4', // cyan
|
||||
'#6366f1', // indigo
|
||||
'#8b5cf6', // purple
|
||||
'#0ea5e9', // sky
|
||||
'#2563eb', // blue darker
|
||||
'#7c3aed', // violet
|
||||
]
|
||||
return colors[Math.abs(hash) % colors.length]
|
||||
}
|
||||
|
||||
function TranscriptBlockView({ node, getPos, editor }: {
|
||||
node: { attrs: Record<string, unknown> }
|
||||
getPos: () => number | undefined
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
editor: any
|
||||
}) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.TranscriptBlock | null = null
|
||||
|
|
@ -16,7 +60,27 @@ function TranscriptBlockView({ node }: {
|
|||
// fallback below
|
||||
}
|
||||
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
// Auto-detect: expand if this is the first real block (live recording),
|
||||
// collapse if there's other content above (notes have been generated)
|
||||
const isFirstBlock = useMemo(() => {
|
||||
try {
|
||||
const pos = getPos()
|
||||
if (pos === undefined) return false
|
||||
const firstChild = editor?.state?.doc?.firstChild
|
||||
if (!firstChild) return true
|
||||
// If the transcript block is right after the first node (heading), it's the main content
|
||||
return pos <= (firstChild.nodeSize ?? 0) + 1
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, [getPos, editor])
|
||||
|
||||
const [expanded, setExpanded] = useState(isFirstBlock)
|
||||
|
||||
const entries = useMemo(() => {
|
||||
if (!config) return []
|
||||
return parseTranscript(config.transcript)
|
||||
}, [config])
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
|
|
@ -43,7 +107,18 @@ function TranscriptBlockView({ node }: {
|
|||
</button>
|
||||
{expanded && (
|
||||
<div className="transcript-block-content">
|
||||
{config.transcript}
|
||||
{entries.length > 0 ? (
|
||||
entries.map((entry, i) => (
|
||||
<div key={i} className="transcript-entry">
|
||||
<span className="transcript-speaker" style={{ color: speakerColor(entry.speaker) }}>
|
||||
{entry.speaker}
|
||||
</span>
|
||||
<span className="transcript-text">{entry.text}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="transcript-raw">{config.transcript}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -89,13 +89,18 @@ function formatTranscript(entries: TranscriptEntry[], date: string, calendarEven
|
|||
`# ${noteTitle}`,
|
||||
'',
|
||||
);
|
||||
// Build the raw transcript text
|
||||
const transcriptLines: string[] = [];
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
if (i > 0 && entries[i].speaker !== entries[i - 1].speaker) {
|
||||
lines.push('');
|
||||
transcriptLines.push('');
|
||||
}
|
||||
lines.push(`**${entries[i].speaker}:** ${entries[i].text}`);
|
||||
lines.push('');
|
||||
transcriptLines.push(`**${entries[i].speaker}:** ${entries[i].text}`);
|
||||
transcriptLines.push('');
|
||||
}
|
||||
const transcriptText = transcriptLines.join('\n').trim();
|
||||
const transcriptData = JSON.stringify({ transcript: transcriptText });
|
||||
lines.push('```transcript', transcriptData, '```');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1524,6 +1524,26 @@
|
|||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .transcript-entry {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .transcript-speaker {
|
||||
font-weight: 600;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .transcript-text {
|
||||
color: color-mix(in srgb, var(--foreground) 75%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .transcript-raw {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: color-mix(in srgb, var(--foreground) 70%, transparent);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue