Feature/docs generator (#11858)

### Type of change

- [x] New Feature (non-breaking change which adds functionality)


### What problem does this PR solve?

This PR introduces a new Docs Generator agent component for producing
downloadable PDF, DOCX, or TXT files from Markdown content generated
within a RAGFlow workflow.

### **Key Features**

**Backend**

- New component: DocsGenerator (agent/component/docs_generator.py)
- 
- Markdown → PDF/DOCX/TXT conversion
- 
- Supports tables, lists, code blocks, headings, and rich formatting
- 
- Configurable document style (fonts, margins, colors, page size,
orientation)
- 
- Optional header logo and footer with page numbers/timestamps
- 

**Frontend**

- New configuration UI for the Docs Generator
- 
- Download button integrated into the chat interface
- 
- Output wired to the Message component
- 
- Full i18n support

**Documentation**

Added component guide:
docs/guides/agent/agent_component_reference/docs_generator.md

**Usage**

Add the Docs Generator to a workflow, connect Markdown output from an
upstream component, configure metadata/style, and feed its output into
the Message component. Users will see a document download button
directly in the chat.

**Contributor Note**

We have been following RAGFlow since more than a year and half now and
have worked extensively on personalizing the framework and integrating
it into several of our internal systems. Over the past year and a half,
we have built multiple platforms that rely on RAGFlow as a core
component, which has given us a strong appreciation for how flexible and
powerful the project is.

We also previously contributed the full Italian translation, and we were
glad to see it accepted. This new Docs Generator component was created
for our own production needs, and we believe that it may be useful for
many others in the community as well.

We want to sincerely thank the entire RAGFlow team for the remarkable
work you have done and continue to do. If there are opportunities to
contribute further, we would be glad to help whenever we have time
available. It would be a pleasure to support the project in any way we
can.

If appropriate, we would be glad to be listed among the project’s
contributors, but in any case we look forward to continuing to support
and contribute to the project.

PentaFrame Development Team

---------

Co-authored-by: PentaFrame <info@pentaframe.it>
Co-authored-by: Kevin Hu <kevinhu.sh@gmail.com>
This commit is contained in:
PentaFDevs
2025-12-12 07:59:43 +01:00
committed by GitHub
parent 6560388f2b
commit f9510edbbc
29 changed files with 3043 additions and 102 deletions

View File

@ -122,6 +122,7 @@ export function AccordionOperators({
Operator.Invoke,
Operator.WenCai,
Operator.SearXNG,
Operator.PDFGenerator,
]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}

View File

@ -932,6 +932,71 @@ export enum AgentVariableType {
Conversation = 'conversation',
}
// PDF Generator enums
export enum PDFGeneratorFontFamily {
Helvetica = 'Helvetica',
TimesRoman = 'Times-Roman',
Courier = 'Courier',
HelveticaBold = 'Helvetica-Bold',
TimesBold = 'Times-Bold',
}
export enum PDFGeneratorLogoPosition {
Left = 'left',
Center = 'center',
Right = 'right',
}
export enum PDFGeneratorPageSize {
A4 = 'A4',
Letter = 'Letter',
}
export enum PDFGeneratorOrientation {
Portrait = 'portrait',
Landscape = 'landscape',
}
export const initialPDFGeneratorValues = {
output_format: 'pdf',
content: '',
title: '',
subtitle: '',
header_text: '',
footer_text: '',
logo_image: '',
logo_position: PDFGeneratorLogoPosition.Left,
logo_width: 2.0,
logo_height: 1.0,
font_family: PDFGeneratorFontFamily.Helvetica,
font_size: 12,
title_font_size: 24,
heading1_font_size: 18,
heading2_font_size: 16,
heading3_font_size: 14,
text_color: '#000000',
title_color: '#000000',
page_size: PDFGeneratorPageSize.A4,
orientation: PDFGeneratorOrientation.Portrait,
margin_top: 1.0,
margin_bottom: 1.0,
margin_left: 1.0,
margin_right: 1.0,
line_spacing: 1.2,
filename: '',
output_directory: '/tmp/pdf_outputs',
add_page_numbers: true,
add_timestamp: true,
watermark_text: '',
enable_toc: false,
outputs: {
file_path: { type: 'string', value: '' },
pdf_base64: { type: 'string', value: '' },
download: { type: 'string', value: '' },
success: { type: 'boolean', value: false },
},
};
export enum WebhookMethod {
Post = 'POST',
Get = 'GET',

View File

@ -22,6 +22,7 @@ import ListOperationsForm from '../form/list-operations-form';
import LoopForm from '../form/loop-form';
import MessageForm from '../form/message-form';
import ParserForm from '../form/parser-form';
import PDFGeneratorForm from '../form/pdf-generator-form';
import PubMedForm from '../form/pubmed-form';
import RetrievalForm from '../form/retrieval-form/next';
import RewriteQuestionForm from '../form/rewrite-question-form';
@ -110,6 +111,9 @@ export const FormConfigMap = {
[Operator.SearXNG]: {
component: SearXNGForm,
},
[Operator.PDFGenerator]: {
component: PDFGeneratorForm,
},
[Operator.Note]: {
component: () => <></>,
},

View File

@ -0,0 +1,535 @@
import { FormContainer } from '@/components/form-container';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { RAGFlowSelect } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { zodResolver } from '@hookform/resolvers/zod';
import { t } from 'i18next';
import { memo, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import {
PDFGeneratorFontFamily,
PDFGeneratorLogoPosition,
PDFGeneratorOrientation,
PDFGeneratorPageSize,
} from '../../constant';
import { INextOperatorForm } from '../../interface';
import { FormWrapper } from '../components/form-wrapper';
import { Output, transferOutputs } from '../components/output';
import { PromptEditor } from '../components/prompt-editor';
import { useValues } from './use-values';
import { useWatchFormChange } from './use-watch-form-change';
function PDFGeneratorForm({ node }: INextOperatorForm) {
const values = useValues(node);
const FormSchema = z.object({
output_format: z.string().default('pdf'),
content: z.string().min(1, 'Content is required'),
title: z.string().optional(),
subtitle: z.string().optional(),
header_text: z.string().optional(),
footer_text: z.string().optional(),
logo_image: z.string().optional(),
logo_position: z.string(),
logo_width: z.number(),
logo_height: z.number(),
font_family: z.string(),
font_size: z.number(),
title_font_size: z.number(),
heading1_font_size: z.number(),
heading2_font_size: z.number(),
heading3_font_size: z.number(),
text_color: z.string(),
title_color: z.string(),
page_size: z.string(),
orientation: z.string(),
margin_top: z.number(),
margin_bottom: z.number(),
margin_left: z.number(),
margin_right: z.number(),
line_spacing: z.number(),
filename: z.string().optional(),
output_directory: z.string(),
add_page_numbers: z.boolean(),
add_timestamp: z.boolean(),
watermark_text: z.string().optional(),
enable_toc: z.boolean(),
outputs: z
.object({
file_path: z.object({ type: z.string() }),
pdf_base64: z.object({ type: z.string() }),
success: z.object({ type: z.string() }),
})
.optional(),
});
const form = useForm<z.infer<typeof FormSchema>>({
defaultValues: values,
resolver: zodResolver(FormSchema),
});
const outputList = useMemo(() => {
return transferOutputs(values.outputs);
}, [values.outputs]);
useWatchFormChange(node?.id, form);
return (
<Form {...form}>
<FormWrapper>
<FormContainer>
{/* Output Format Selection */}
<FormField
control={form.control}
name="output_format"
render={({ field }) => (
<FormItem>
<FormLabel>Output Format</FormLabel>
<FormControl>
<RAGFlowSelect
{...field}
options={[
{ label: 'PDF', value: 'pdf' },
{ label: 'DOCX', value: 'docx' },
{ label: 'TXT', value: 'txt' },
]}
></RAGFlowSelect>
</FormControl>
<FormDescription>
Choose the output document format
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Content Section */}
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>{t('flow.content')}</FormLabel>
<FormControl>
<PromptEditor
{...field}
showToolbar={true}
placeholder="Enter content with markdown formatting...&#10;&#10;**Bold text**, *italic*, # Heading, - List items, etc."
></PromptEditor>
</FormControl>
<FormDescription>
<div className="text-xs space-y-1">
<div>
<strong>Markdown support:</strong> **bold**, *italic*,
`code`, # Heading 1, ## Heading 2
</div>
<div>
<strong>Lists:</strong> - bullet or 1. numbered
</div>
<div>
<strong>Tables:</strong> | Column 1 | Column 2 | (use | to
separate columns, &lt;br&gt; or \n for line breaks in
cells)
</div>
<div>
<strong>Other:</strong> --- for horizontal line, ``` for
code blocks
</div>
</div>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Title & Subtitle */}
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>{t('flow.title')}</FormLabel>
<FormControl>
<Input {...field} placeholder="Document title (optional)" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subtitle"
render={({ field }) => (
<FormItem>
<FormLabel>{t('flow.subtitle')}</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Document subtitle (optional)"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Logo Settings */}
<FormField
control={form.control}
name="logo_image"
render={({ field }) => (
<FormItem>
<FormLabel>{t('flow.logoImage')}</FormLabel>
<FormControl>
<div className="space-y-2">
<Input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
field.onChange(reader.result as string);
};
reader.readAsDataURL(file);
}
}}
className="cursor-pointer"
/>
<Input
{...field}
placeholder="Or paste image path/URL/base64"
className="mt-2"
/>
</div>
</FormControl>
<FormDescription>
Upload an image file or paste a file path/URL/base64
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="logo_position"
render={({ field }) => (
<FormItem>
<FormLabel>{t('flow.logoPosition')}</FormLabel>
<FormControl>
<RAGFlowSelect
{...field}
options={Object.values(PDFGeneratorLogoPosition).map(
(val) => ({ label: val, value: val }),
)}
></RAGFlowSelect>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="logo_width"
render={({ field }) => (
<FormItem>
<FormLabel>{t('flow.logoWidth')} (inches)</FormLabel>
<FormControl>
<Input
{...field}
type="number"
step="0.1"
onChange={(e) =>
field.onChange(parseFloat(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="logo_height"
render={({ field }) => (
<FormItem>
<FormLabel>{t('flow.logoHeight')} (inches)</FormLabel>
<FormControl>
<Input
{...field}
type="number"
step="0.1"
onChange={(e) =>
field.onChange(parseFloat(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Font Settings */}
<FormField
control={form.control}
name="font_family"
render={({ field }) => (
<FormItem>
<FormLabel>{t('flow.fontFamily')}</FormLabel>
<FormControl>
<RAGFlowSelect
{...field}
options={Object.values(PDFGeneratorFontFamily).map(
(val) => ({ label: val, value: val }),
)}
></RAGFlowSelect>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="font_size"
render={({ field }) => (
<FormItem>
<FormLabel>{t('flow.fontSize')}</FormLabel>
<FormControl>
<Input
{...field}
type="number"
onChange={(e) => field.onChange(parseInt(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="title_font_size"
render={({ field }) => (
<FormItem>
<FormLabel>{t('flow.titleFontSize')}</FormLabel>
<FormControl>
<Input
{...field}
type="number"
onChange={(e) => field.onChange(parseInt(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Page Settings */}
<FormField
control={form.control}
name="page_size"
render={({ field }) => (
<FormItem>
<FormLabel>{t('flow.pageSize')}</FormLabel>
<FormControl>
<RAGFlowSelect
{...field}
options={Object.values(PDFGeneratorPageSize).map((val) => ({
label: val,
value: val,
}))}
></RAGFlowSelect>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="orientation"
render={({ field }) => (
<FormItem>
<FormLabel>{t('flow.orientation')}</FormLabel>
<FormControl>
<RAGFlowSelect
{...field}
options={Object.values(PDFGeneratorOrientation).map(
(val) => ({ label: val, value: val }),
)}
></RAGFlowSelect>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Margins */}
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="margin_top"
render={({ field }) => (
<FormItem>
<FormLabel>{t('flow.marginTop')} (inches)</FormLabel>
<FormControl>
<Input
{...field}
type="number"
step="0.1"
onChange={(e) =>
field.onChange(parseFloat(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="margin_bottom"
render={({ field }) => (
<FormItem>
<FormLabel>{t('flow.marginBottom')} (inches)</FormLabel>
<FormControl>
<Input
{...field}
type="number"
step="0.1"
onChange={(e) =>
field.onChange(parseFloat(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Output Settings */}
<FormField
control={form.control}
name="filename"
render={({ field }) => (
<FormItem>
<FormLabel>{t('flow.filename')}</FormLabel>
<FormControl>
<Input
{...field}
placeholder="document.pdf (auto-generated if empty)"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="output_directory"
render={({ field }) => (
<FormItem>
<FormLabel>{t('flow.outputDirectory')}</FormLabel>
<FormControl>
<Input {...field} placeholder="/tmp/pdf_outputs" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Additional Options */}
<FormField
control={form.control}
name="add_page_numbers"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>{t('flow.addPageNumbers')}</FormLabel>
<FormDescription>
Add page numbers to the document
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="add_timestamp"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>{t('flow.addTimestamp')}</FormLabel>
<FormDescription>
Add generation timestamp to the document
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="watermark_text"
render={({ field }) => (
<FormItem>
<FormLabel>{t('flow.watermarkText')}</FormLabel>
<FormControl>
<Input {...field} placeholder="Watermark text (optional)" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="outputs"
render={() => <div></div>}
/>
</FormContainer>
</FormWrapper>
<div className="p-5">
<Output list={outputList}></Output>
</div>
</Form>
);
}
export default memo(PDFGeneratorForm);

View File

@ -0,0 +1,11 @@
import { useMemo } from 'react';
import { Node } from 'reactflow';
import { initialPDFGeneratorValues } from '../../constant';
export const useValues = (node?: Node) => {
const values = useMemo(() => {
return node?.data.form ?? initialPDFGeneratorValues;
}, [node?.data.form]);
return values;
};

View File

@ -0,0 +1,19 @@
import { useEffect } from 'react';
import { UseFormReturn } from 'react-hook-form';
import useGraphStore from '../../store';
export const useWatchFormChange = (
nodeId: string | undefined,
form: UseFormReturn<any>,
) => {
const updateNodeForm = useGraphStore((state) => state.updateNodeForm);
useEffect(() => {
const { unsubscribe } = form.watch((value) => {
if (nodeId) {
updateNodeForm(nodeId, value);
}
});
return () => unsubscribe();
}, [form, nodeId, updateNodeForm]);
};

View File

@ -16,6 +16,7 @@ import { IconFontFill } from '@/components/icon-font';
import { cn } from '@/lib/utils';
import {
FileCode,
FileText,
HousePlus,
Infinity as InfinityIcon,
LogOut,
@ -67,6 +68,7 @@ export const LucideIconMap = {
[Operator.DataOperations]: FileCode,
[Operator.Loop]: InfinityIcon,
[Operator.ExitLoop]: LogOut,
[Operator.PDFGenerator]: FileText,
};
const Empty = () => {

View File

@ -7,11 +7,15 @@ export function useSelectFilters() {
const { data } = useFetchAgentList({});
const canvasCategory = useMemo(() => {
return groupListByType(data.canvas, 'canvas_category', 'canvas_category');
}, [data.canvas]);
return groupListByType(
data?.canvas ?? [],
'canvas_category',
'canvas_category',
);
}, [data?.canvas]);
const filters: FilterCollection[] = [
buildOwnersFilter(data.canvas),
buildOwnersFilter(data?.canvas ?? []),
{
field: 'canvasCategory',
list: canvasCategory,