Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions plugin/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,32 @@ export const deleteTemplate = async (templateUuid: string): Promise<void> => {
}
}

export const exportPipelineResultAsDocx = async (
runId: string,
resultMarkdown: string,
): Promise<Blob> => {
const url = `${getApiBaseUrl()}/pipelines/status/${runId}/export/docx`
const response = await apiFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
resultMarkdown,
}),
})

if (!response.ok) {
// Errors still come back as JSON; only the success path is binary.
const data = await readApiResponse<{ detail?: string }>(response, url).catch(
(): { detail?: string } => ({}),
)
throw new Error(data.detail || 'Failed to export the result as a Word document.')
}

return response.blob()
}

export const saveEditedPipelineResult = async (
runId: string,
resultMarkdown: string,
Expand Down
54 changes: 54 additions & 0 deletions plugin/src/components/DownloadDropdown.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
.root {
Comment thread
hranasit marked this conversation as resolved.
position: relative;
}

.caret {
margin-left: 0.5rem;
font-size: 0.6rem;
vertical-align: middle;
}

.menu {
position: absolute;
top: calc(100% + 0.35rem);
right: 0;
z-index: 20;
min-width: 11rem;
display: grid;
overflow: hidden;
border: 1px solid var(--ai-doc-color-slate-200);
border-radius: var(--bs-border-radius);
background: var(--ai-doc-color-white);
box-shadow:
0 10px 30px rgba(15, 23, 42, 0.08),
0 2px 8px rgba(15, 23, 42, 0.05);
}

.item {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.6rem 0.9rem;
border: 0;
background: transparent;
color: var(--ai-doc-color-slate-900);
text-align: left;
line-height: 1.35;
transition: background-color 120ms ease;
}

.item:hover:not(:disabled) {
background: var(--ai-doc-color-blue-50);
}

.item:disabled {
opacity: 0.65;
cursor: not-allowed;
}

.itemIcon {
width: 1rem;
font-size: 0.85rem;
color: var(--ai-doc-color-slate-500);
}
143 changes: 143 additions & 0 deletions plugin/src/components/DownloadDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from 'react'
Comment thread
MatejFrnka marked this conversation as resolved.
import { toast } from 'sonner'

import { exportPipelineResultAsDocx } from '@/client'
import styles from '@/components/DownloadDropdown.module.css'

const triggerBlobDownload = (blob: Blob, fileName: string) => {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}

type DownloadDropdownProps = {
markdown: string
runId: string | null
baseName: string
}

/**
* Download menu for a pipeline result, offering the raw markdown or a Word export.
*
* The markdown passed in is the editor's current text, so unsaved edits are downloaded too.
*/
export function DownloadDropdown({ markdown, runId, baseName }: DownloadDropdownProps) {
const [isOpen, setIsOpen] = useState(false)
const [isDownloadingDocx, setIsDownloadingDocx] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null)

const fileBaseName = baseName || 'pipeline-output'

useEffect(() => {
if (!isOpen) {
return
}

const handlePointerDown = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
setIsOpen(false)
}
}

const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false)
}
}

document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('keydown', handleEscape)

return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('keydown', handleEscape)
}
}, [isOpen])

const onDownloadMarkdown = () => {
setIsOpen(false)

if (!markdown) {
return
}

const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })
triggerBlobDownload(blob, `${fileBaseName}.md`)
toast.success('Markdown download has started.')
}

const onDownloadDocx = async () => {
setIsOpen(false)

if (!markdown || !runId) {
toast.error('There is no pipeline result to export yet.')
return
}

setIsDownloadingDocx(true)
try {
const blob = await exportPipelineResultAsDocx(runId, markdown)
triggerBlobDownload(blob, `${fileBaseName}.docx`)
toast.success('Word download has started.')
} catch (error) {
toast.error(
error instanceof Error
? error.message
: 'Failed to export the result as a Word document.',
)
} finally {
setIsDownloadingDocx(false)
}
}

return (
<div className={styles.root} ref={rootRef}>
<button
type="button"
onClick={() => setIsOpen((currentValue) => !currentValue)}
disabled={isDownloadingDocx}
className="btn btn-outline-secondary"
aria-expanded={isOpen}
aria-haspopup="menu"
>
{isDownloadingDocx ? 'Preparing...' : 'Download'}
<span className={styles.caret} aria-hidden="true">
</span>
</button>

{isOpen ? (
<div className={styles.menu} role="menu">
<button
type="button"
role="menuitem"
onClick={onDownloadMarkdown}
className={styles.item}
>
<i className={`fa far fa-file-alt ${styles.itemIcon}`} aria-hidden="true" />
Markdown
</button>

<button
type="button"
role="menuitem"
onClick={() => void onDownloadDocx()}
disabled={!runId}
className={styles.item}
>
<i
className={`fa far fa-file-word ${styles.itemIcon}`}
aria-hidden="true"
/>
MS Word
</button>
</div>
) : null}
</div>
)
}
14 changes: 14 additions & 0 deletions plugin/src/components/PipelineResultPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@
margin-left: auto;
}

.rawEditor {
display: grid;
gap: 0.75rem;
}

.rawToolbar {
display: flex;
justify-content: flex-end;
}

.buttonIcon {
margin-right: 0.45rem;
}

.saveButton:disabled {
opacity: 1;
}
Expand Down
72 changes: 32 additions & 40 deletions plugin/src/components/PipelineResultPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { toast } from 'sonner'

import { saveEditedPipelineResult } from '@/client'
import { DownloadDropdown } from '@/components/DownloadDropdown'
import styles from '@/components/PipelineResultPanel.module.css'
import { MarkdownRenderer } from '@/markdown-utils'
import type { PipelineStatusResponse, ResultRenderMode } from '@/types'
Expand Down Expand Up @@ -46,23 +47,6 @@ export function PipelineResultPanel({
}
}

const onDownloadMarkdown = () => {
if (!displayedResultMarkdown) {
return
}

const blob = new Blob([displayedResultMarkdown], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${downloadBaseName || 'pipeline-output'}.md`
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
toast.success('Markdown download has started.')
}

const onSaveEditedVersion = async () => {
if (!resultRunId || !resultMarkdown) {
toast.error('There is no pipeline result to save yet.')
Expand Down Expand Up @@ -129,25 +113,18 @@ export function PipelineResultPanel({
onClick={() => void onCopyMarkdown()}
className="btn btn-outline-secondary"
>
<i
className={`fas fa-copy ${styles.buttonIcon}`}
aria-hidden="true"
/>
Copy markdown
</button>

<button
type="button"
onClick={onDownloadMarkdown}
className="btn btn-outline-secondary"
>
Download .md
</button>

<button
type="button"
onClick={() => void onSaveEditedVersion()}
disabled={!hasResultChanges || isSavingEditedVersion}
className={`btn btn-outline-secondary ${styles.saveButton}`}
>
{isSavingEditedVersion ? 'Saving...' : 'Save edited version'}
</button>
<DownloadDropdown
markdown={displayedResultMarkdown}
runId={resultRunId}
baseName={downloadBaseName}
/>
</div>
) : null}
</div>
Expand All @@ -159,13 +136,28 @@ export function PipelineResultPanel({
The generated markdown will appear here after a successful pipeline run.
</div>
) : resultRenderMode === 'raw' ? (
<textarea
value={editableResultMarkdown}
onChange={(event) => setEditableResultMarkdown(event.target.value)}
className={styles.textarea}
>
{editableResultMarkdown}
</textarea>
<div className={styles.rawEditor}>
<div className={styles.rawToolbar}>
<button
type="button"
onClick={() => void onSaveEditedVersion()}
disabled={!hasResultChanges || isSavingEditedVersion}
className={`btn btn-outline-secondary ${styles.saveButton}`}
>
<i
className={`fas fa-save ${styles.buttonIcon}`}
aria-hidden="true"
/>
{isSavingEditedVersion ? 'Saving...' : 'Save'}
</button>
</div>

<textarea
value={editableResultMarkdown}
onChange={(event) => setEditableResultMarkdown(event.target.value)}
className={styles.textarea}
/>
</div>
) : (
<MarkdownRenderer markdown={displayedResultMarkdown || ''} />
)}
Expand Down
2 changes: 2 additions & 0 deletions service/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ dependencies = [
"genson",
"haystack-ai>=2.30.1",
"json-repair",
"markdown-it-py[linkify]",
"openai",
"pandas",
"psycopg[binary]",
"python-docx",
"pyyaml",
"sqlalchemy[asyncio]",
"tabulate",
Expand Down
Loading