mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 18:04:55 +10:00
feat: add ai detection for recipients and fields (#2271)
Use Gemini to handle detection of recipients and fields within documents. Opt in using organisation or team settings. Replaces #2128 since the branch was cursed and would include dependencies that weren't even in the lock file. https://github.com/user-attachments/assets/e6cbb58f-62b9-4079-a9ae-7af5c4f2e4ec
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, FormInputIcon, ShieldCheckIcon } from 'lucide-react';
|
||||
|
||||
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
|
||||
import {
|
||||
AiApiError,
|
||||
type DetectFieldsProgressEvent,
|
||||
detectFields,
|
||||
} from '../../../server/api/ai/detect-fields.client';
|
||||
import { AnimatedDocumentScanner } from '../general/animated-document-scanner';
|
||||
|
||||
type DialogState = 'PROMPT' | 'PROCESSING' | 'REVIEW' | 'ERROR' | 'RATE_LIMITED';
|
||||
|
||||
type AiFieldDetectionDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onComplete: (fields: NormalizedFieldWithContext[]) => void;
|
||||
envelopeId: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
const PROCESSING_MESSAGES = [
|
||||
msg`Reading your document`,
|
||||
msg`Analyzing page layout`,
|
||||
msg`Looking for form fields`,
|
||||
msg`Detecting signature areas`,
|
||||
msg`Identifying input fields`,
|
||||
msg`Mapping fields to recipients`,
|
||||
msg`Almost done`,
|
||||
] as const;
|
||||
|
||||
const FIELD_TYPE_LABELS: Record<string, MessageDescriptor> = {
|
||||
SIGNATURE: msg`Signature`,
|
||||
INITIALS: msg`Initials`,
|
||||
NAME: msg`Name`,
|
||||
EMAIL: msg`Email`,
|
||||
DATE: msg`Date`,
|
||||
TEXT: msg`Text`,
|
||||
NUMBER: msg`Number`,
|
||||
CHECKBOX: msg`Checkbox`,
|
||||
RADIO: msg`Radio`,
|
||||
};
|
||||
|
||||
export const AiFieldDetectionDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onComplete,
|
||||
envelopeId,
|
||||
teamId,
|
||||
}: AiFieldDetectionDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [state, setState] = useState<DialogState>('PROMPT');
|
||||
const [messageIndex, setMessageIndex] = useState(0);
|
||||
const [detectedFields, setDetectedFields] = useState<NormalizedFieldWithContext[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [context, setContext] = useState('');
|
||||
const [progress, setProgress] = useState<DetectFieldsProgressEvent | null>(null);
|
||||
|
||||
const onDetectClick = useCallback(async () => {
|
||||
setState('PROCESSING');
|
||||
setMessageIndex(0);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
|
||||
try {
|
||||
await detectFields({
|
||||
request: {
|
||||
envelopeId,
|
||||
teamId,
|
||||
context: context || undefined,
|
||||
},
|
||||
onProgress: (progressEvent) => {
|
||||
setProgress(progressEvent);
|
||||
},
|
||||
onComplete: (event) => {
|
||||
setDetectedFields(event.fields);
|
||||
setState('REVIEW');
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err.message);
|
||||
setState('ERROR');
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err instanceof AiApiError && err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : 'Failed to detect fields');
|
||||
setState('ERROR');
|
||||
}
|
||||
}, [envelopeId, teamId, context]);
|
||||
|
||||
const onAddFields = () => {
|
||||
onComplete(detectedFields);
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedFields([]);
|
||||
setContext('');
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedFields([]);
|
||||
setError(null);
|
||||
setContext('');
|
||||
setProgress(null);
|
||||
};
|
||||
|
||||
// Group fields by type for summary display
|
||||
const fieldCountsByType = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
for (const field of detectedFields) {
|
||||
counts[field.type] = (counts[field.type] || 0) + 1;
|
||||
}
|
||||
|
||||
return Object.entries(counts).sort(([, a], [, b]) => b - a);
|
||||
}, [detectedFields]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== 'PROCESSING') {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setMessageIndex((prev) => (prev + 1) % PROCESSING_MESSAGES.length);
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="sm:max-w-lg" hideClose={true}>
|
||||
{state === 'PROMPT' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detect fields</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
We'll scan your document to find form fields like signature lines, text inputs,
|
||||
checkboxes, and more. Detected fields will be suggested for you to review.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Alert className="flex items-center gap-2 space-y-0" variant="neutral">
|
||||
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
|
||||
<AlertDescription className="mt-0">
|
||||
<Trans>
|
||||
Your document is processed securely using AI services that don't retain your
|
||||
data.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="context">
|
||||
<Trans>Context</Trans>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="context"
|
||||
placeholder={_(msg`David is the Employee, Lucas is the Manager`)}
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>Help the AI assign fields to the right recipients.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Detect</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'PROCESSING' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detecting fields</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<AnimatedDocumentScanner />
|
||||
|
||||
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
|
||||
|
||||
{progress && (
|
||||
<p className="mt-2 text-xs text-muted-foreground/60">
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} -{' '}
|
||||
{progress.fieldsDetected} field(s) found
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
|
||||
<Trans>This can take a minute or two depending on the size of your document.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex gap-1">
|
||||
{PROCESSING_MESSAGES.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`h-1.5 w-1.5 rounded-full transition-all duration-300 ${
|
||||
index === messageIndex ? 'w-4 bg-primary' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'REVIEW' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detected fields</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{detectedFields.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<FormInputIcon className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
<Trans>No fields were detected in your document.</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-center text-xs text-muted-foreground/70">
|
||||
<Trans>You can add fields manually in the editor.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>We found {detectedFields.length} field(s) in your document.</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 divide-y rounded-lg border">
|
||||
{fieldCountsByType.map(([type, count]) => (
|
||||
<li key={type} className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">{_(FIELD_TYPE_LABELS[type]) || type}</span>
|
||||
<span className="text-sm font-medium text-muted-foreground">{count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
{detectedFields.length > 0 && (
|
||||
<Button type="button" onClick={onAddFields}>
|
||||
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Add fields</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'ERROR' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detection failed</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Something went wrong while detecting fields.</Trans>
|
||||
</p>
|
||||
|
||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'RATE_LIMITED' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Too many requests</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
You've made too many detection requests. Please wait a minute before trying again.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,361 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, ShieldCheckIcon, UserIcon, XIcon } from 'lucide-react';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import {
|
||||
AiApiError,
|
||||
type DetectRecipientsProgressEvent,
|
||||
detectRecipients,
|
||||
} from '../../../server/api/ai/detect-recipients.client';
|
||||
import { AnimatedDocumentScanner } from '../general/animated-document-scanner';
|
||||
|
||||
type DialogState = 'PROMPT' | 'PROCESSING' | 'REVIEW' | 'ERROR' | 'RATE_LIMITED';
|
||||
|
||||
type AiRecipientDetectionDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onComplete: (recipients: TDetectedRecipientSchema[]) => void;
|
||||
envelopeId: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
const PROCESSING_MESSAGES = [
|
||||
msg`Reading your document`,
|
||||
msg`Analyzing pages`,
|
||||
msg`Looking for signature fields`,
|
||||
msg`Identifying recipients`,
|
||||
msg`Extracting contact details`,
|
||||
msg`Almost done`,
|
||||
] as const;
|
||||
|
||||
export const AiRecipientDetectionDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onComplete,
|
||||
envelopeId,
|
||||
teamId,
|
||||
}: AiRecipientDetectionDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [state, setState] = useState<DialogState>('PROMPT');
|
||||
const [messageIndex, setMessageIndex] = useState(0);
|
||||
const [detectedRecipients, setDetectedRecipients] = useState<TDetectedRecipientSchema[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<DetectRecipientsProgressEvent | null>(null);
|
||||
|
||||
const onDetectClick = useCallback(async () => {
|
||||
setState('PROCESSING');
|
||||
setMessageIndex(0);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
|
||||
try {
|
||||
await detectRecipients({
|
||||
request: {
|
||||
envelopeId,
|
||||
teamId,
|
||||
},
|
||||
onProgress: (progressEvent) => {
|
||||
setProgress(progressEvent);
|
||||
},
|
||||
onComplete: (event) => {
|
||||
setDetectedRecipients(event.recipients);
|
||||
setState('REVIEW');
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err.message);
|
||||
setState('ERROR');
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err instanceof AiApiError && err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : 'Failed to detect recipients');
|
||||
setState('ERROR');
|
||||
}
|
||||
}, [envelopeId, teamId]);
|
||||
|
||||
const handleRemoveRecipient = (index: number) => {
|
||||
setDetectedRecipients((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const onAddRecipients = () => {
|
||||
onComplete(detectedRecipients);
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedRecipients([]);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedRecipients([]);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== 'PROCESSING') {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setMessageIndex((prev) => (prev + 1) % PROCESSING_MESSAGES.length);
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="sm:max-w-lg" hideClose={true}>
|
||||
{state === 'PROMPT' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detect recipients</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
We'll scan your document to find signature fields and identify who needs to sign.
|
||||
Detected recipients will be suggested for you to review.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Alert className="mt-4 flex items-center gap-2 space-y-0" variant="neutral">
|
||||
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
|
||||
<AlertDescription className="mt-0">
|
||||
<Trans>
|
||||
Your document is processed securely using AI services that don't retain your
|
||||
data.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Detect</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'PROCESSING' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detecting recipients</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<AnimatedDocumentScanner />
|
||||
|
||||
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
|
||||
|
||||
{progress && (
|
||||
<p className="mt-2 text-xs text-muted-foreground/60">
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} -{' '}
|
||||
{progress.recipientsDetected} recipient(s) found
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
|
||||
<Trans>This can take a minute or two depending on the size of your document.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex gap-1">
|
||||
{PROCESSING_MESSAGES.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`h-1.5 w-1.5 rounded-full transition-all duration-300 ${
|
||||
index === messageIndex ? 'w-4 bg-primary' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'REVIEW' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detected recipients</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{detectedRecipients.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<UserIcon className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
<Trans>No recipients were detected in your document.</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-center text-xs text-muted-foreground/70">
|
||||
<Trans>You can add recipients manually in the editor.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
We found {detectedRecipients.length} recipient(s) in your document.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 divide-y rounded-lg border">
|
||||
{detectedRecipients.map((recipient, index) => (
|
||||
<li key={index} className="flex items-center justify-between px-4 py-3">
|
||||
<AvatarWithText
|
||||
avatarFallback={
|
||||
recipient.name
|
||||
? recipient.name.slice(0, 1).toUpperCase()
|
||||
: recipient.email
|
||||
? recipient.email.slice(0, 1).toUpperCase()
|
||||
: '?'
|
||||
}
|
||||
primaryText={
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{recipient.name || _(msg`Unknown name`)}
|
||||
</p>
|
||||
}
|
||||
secondaryText={
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="italic text-muted-foreground/70">
|
||||
{recipient.email || _(msg`No email detected`)}
|
||||
</p>
|
||||
<p>{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 p-0 text-muted-foreground/80 hover:text-destructive focus-visible:border-destructive focus-visible:ring-destructive"
|
||||
onClick={() => handleRemoveRecipient(index)}
|
||||
>
|
||||
<span className="sr-only">
|
||||
<Trans>Remove recipient</Trans>
|
||||
</span>
|
||||
|
||||
<XIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
{detectedRecipients.length > 0 && (
|
||||
<Button type="button" onClick={onAddRecipients}>
|
||||
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Add recipients</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'ERROR' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detection failed</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Something went wrong while detecting recipients.</Trans>
|
||||
</p>
|
||||
|
||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'RATE_LIMITED' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Too many requests</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
You've made too many detection requests. Please wait a minute before trying again.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -58,6 +58,7 @@ export type TDocumentPreferencesFormSchema = {
|
||||
includeSigningCertificate: boolean | null;
|
||||
includeAuditLog: boolean | null;
|
||||
signatureTypes: DocumentSignatureType[];
|
||||
aiFeaturesEnabled: boolean | null;
|
||||
};
|
||||
|
||||
type SettingsSubset = Pick<
|
||||
@@ -72,11 +73,13 @@ type SettingsSubset = Pick<
|
||||
| 'typedSignatureEnabled'
|
||||
| 'uploadSignatureEnabled'
|
||||
| 'drawSignatureEnabled'
|
||||
| 'aiFeaturesEnabled'
|
||||
>;
|
||||
|
||||
export type DocumentPreferencesFormProps = {
|
||||
settings: SettingsSubset;
|
||||
canInherit: boolean;
|
||||
isAiFeaturesConfigured?: boolean;
|
||||
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -84,6 +87,7 @@ export const DocumentPreferencesForm = ({
|
||||
settings,
|
||||
onFormSubmit,
|
||||
canInherit,
|
||||
isAiFeaturesConfigured = false,
|
||||
}: DocumentPreferencesFormProps) => {
|
||||
const { t } = useLingui();
|
||||
const { user, organisations } = useSession();
|
||||
@@ -105,6 +109,7 @@ export const DocumentPreferencesForm = ({
|
||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
||||
message: msg`At least one signature type must be enabled`.id,
|
||||
}),
|
||||
aiFeaturesEnabled: z.boolean().nullable(),
|
||||
});
|
||||
|
||||
const form = useForm<TDocumentPreferencesFormSchema>({
|
||||
@@ -120,6 +125,7 @@ export const DocumentPreferencesForm = ({
|
||||
includeSigningCertificate: settings.includeSigningCertificate,
|
||||
includeAuditLog: settings.includeAuditLog,
|
||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||
},
|
||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||
});
|
||||
@@ -312,7 +318,7 @@ export const DocumentPreferencesForm = ({
|
||||
}))}
|
||||
selectedValues={field.value}
|
||||
onChange={field.onChange}
|
||||
className="bg-background w-full"
|
||||
className="w-full bg-background"
|
||||
enableSearch={false}
|
||||
emptySelectionPlaceholder={
|
||||
canInherit ? t`Inherit from organisation` : t`Select signature types`
|
||||
@@ -378,7 +384,7 @@ export const DocumentPreferencesForm = ({
|
||||
</FormControl>
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
<Trans>Preview</Trans>
|
||||
</div>
|
||||
|
||||
@@ -509,6 +515,59 @@ export const DocumentPreferencesForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{isAiFeaturesConfigured && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="aiFeaturesEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>AI Features</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="true">
|
||||
<Trans>Enabled</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="false">
|
||||
<Trans>Disabled</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{canInherit && (
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Enable AI-powered features such as automatic recipient detection. When
|
||||
enabled, document content will be sent to AI providers. We only use providers
|
||||
that do not retain data for training and prefer European regions where
|
||||
available.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type AnimatedDocumentScannerProps = {
|
||||
className?: string;
|
||||
interval?: number;
|
||||
};
|
||||
|
||||
export const AnimatedDocumentScanner = ({
|
||||
className,
|
||||
interval = 2500,
|
||||
}: AnimatedDocumentScannerProps) => {
|
||||
const [magPosition, setMagPosition] = useState({ x: 0, y: 0, page: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const moveInterval = setInterval(() => {
|
||||
setMagPosition({
|
||||
x: Math.random() * 60 - 30,
|
||||
y: Math.random() * 50 - 25,
|
||||
page: Math.random() > 0.5 ? 1 : 0,
|
||||
});
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(moveInterval);
|
||||
}, [interval]);
|
||||
|
||||
return (
|
||||
<div className={cn('relative mx-auto h-36 w-56', className)}>
|
||||
{/* Magnifying glass */}
|
||||
<div
|
||||
className="pointer-events-none absolute z-50 transition-all duration-1000 ease-in-out"
|
||||
style={{
|
||||
left: magPosition.page === 0 ? '25%' : '75%',
|
||||
top: '50%',
|
||||
transform: `translate(calc(-50% + ${magPosition.x}px), calc(-50% + ${magPosition.y}px))`,
|
||||
}}
|
||||
>
|
||||
<SearchIcon className="h-8 w-8 text-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Book container */}
|
||||
<div className="relative h-full w-full animate-pulse" style={{ perspective: '800px' }}>
|
||||
<div className="relative h-full w-full" style={{ transformStyle: 'preserve-3d' }}>
|
||||
{/* Left page */}
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full w-[calc(50%)] origin-right overflow-hidden rounded-l-md border border-border bg-card shadow-md"
|
||||
style={{ transform: 'rotateY(15deg) skewY(-1deg)' }}
|
||||
>
|
||||
<div className="absolute inset-3 space-y-2">
|
||||
<div className="h-1.5 w-3/4 rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-full rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-5/6 rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-2/3 rounded-sm bg-muted" />
|
||||
<div className="mt-3 h-6 w-3/4 rounded border border-dashed border-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right page */}
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-[calc(50%)] origin-left overflow-hidden rounded-r-md border border-border bg-card shadow-md"
|
||||
style={{ transform: 'rotateY(-15deg) skewY(1deg)' }}
|
||||
>
|
||||
<div className="absolute inset-3 space-y-2">
|
||||
<div className="h-1.5 w-full rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-4/5 rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-full rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-3/5 rounded-sm bg-muted" />
|
||||
<div className="mt-3 h-6 w-2/3 rounded border border-dashed border-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,28 +1,31 @@
|
||||
import { lazy, useEffect, useMemo } from 'react';
|
||||
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { FieldType, RecipientRole } from '@prisma/client';
|
||||
import { FileTextIcon } from 'lucide-react';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||
import { FileTextIcon, SparklesIcon } from 'lucide-react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import type {
|
||||
TCheckboxFieldMeta,
|
||||
TDateFieldMeta,
|
||||
TDropdownFieldMeta,
|
||||
TEmailFieldMeta,
|
||||
TFieldMetaSchema,
|
||||
TInitialsFieldMeta,
|
||||
TNameFieldMeta,
|
||||
TNumberFieldMeta,
|
||||
TRadioFieldMeta,
|
||||
TSignatureFieldMeta,
|
||||
TTextFieldMeta,
|
||||
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||
import {
|
||||
FIELD_META_DEFAULT_VALUES,
|
||||
type TCheckboxFieldMeta,
|
||||
type TDateFieldMeta,
|
||||
type TDropdownFieldMeta,
|
||||
type TEmailFieldMeta,
|
||||
type TFieldMetaSchema,
|
||||
type TInitialsFieldMeta,
|
||||
type TNameFieldMeta,
|
||||
type TNumberFieldMeta,
|
||||
type TRadioFieldMeta,
|
||||
type TSignatureFieldMeta,
|
||||
type TTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
@@ -31,6 +34,7 @@ import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/al
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
|
||||
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
|
||||
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
||||
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
|
||||
@@ -41,6 +45,7 @@ import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-nu
|
||||
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
|
||||
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
|
||||
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
@@ -67,11 +72,15 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
||||
export const EnvelopeEditorFieldsPage = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
||||
|
||||
const selectedField = useMemo(
|
||||
() => structuredClone(editorFields.selectedField),
|
||||
@@ -96,6 +105,24 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onFieldDetectionComplete = (fields: NormalizedFieldWithContext[]) => {
|
||||
for (const field of fields) {
|
||||
editorFields.addField({
|
||||
height: field.height,
|
||||
width: field.width,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
type: field.type,
|
||||
envelopeItemId: field.envelopeItemId,
|
||||
recipientId: field.recipientId,
|
||||
page: field.pageNumber,
|
||||
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[field.type]),
|
||||
});
|
||||
}
|
||||
|
||||
setIsAiFieldDialogOpen(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the selected recipient to the first recipient in the envelope.
|
||||
*/
|
||||
@@ -202,6 +229,35 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
|
||||
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
||||
/>
|
||||
|
||||
{team.preferences.aiFeaturesEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 w-full"
|
||||
onClick={() => setIsAiFieldDialogOpen(true)}
|
||||
disabled={envelope.status !== DocumentStatus.DRAFT}
|
||||
title={
|
||||
envelope.status !== DocumentStatus.DRAFT
|
||||
? _(msg`You can only detect fields in draft envelopes`)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Detect with AI</Trans>
|
||||
</Button>
|
||||
|
||||
<AiFieldDetectionDialog
|
||||
open={isAiFieldDialogOpen}
|
||||
onOpenChange={setIsAiFieldDialogOpen}
|
||||
onComplete={onFieldDetectionComplete}
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Field details section. */}
|
||||
@@ -243,7 +299,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
<div className="px-4 [&_label]:text-xs [&_label]:text-foreground/70">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(FieldSettingsTypeTranslations[selectedField.type])}
|
||||
{_(FieldSettingsTypeTranslations[selectedField.type])}
|
||||
</h3>
|
||||
|
||||
{match(selectedField.type)
|
||||
|
||||
+126
-6
@@ -12,8 +12,9 @@ import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, TrashIcon } from 'lucide-react';
|
||||
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
|
||||
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { isDeepEqual, prop, sortBy } from 'remeda';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -22,6 +23,7 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
import {
|
||||
ZRecipientActionAuthTypesSchema,
|
||||
ZRecipientAuthOptionsSchema,
|
||||
@@ -60,6 +62,9 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
const ZEnvelopeRecipientsForm = z.object({
|
||||
signers: z.array(
|
||||
z.object({
|
||||
@@ -85,14 +90,36 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { remaining } = useLimits();
|
||||
const { user } = useSession();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
|
||||
|
||||
// AI recipient detection dialog state
|
||||
const [isAiDialogOpen, setIsAiDialogOpen] = useState(() => searchParams.get('ai') === 'true');
|
||||
|
||||
const onAiDialogOpenChange = (open: boolean) => {
|
||||
setIsAiDialogOpen(open);
|
||||
|
||||
if (!open && searchParams.get('ai') === 'true') {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
|
||||
newParams.delete('ai');
|
||||
|
||||
return newParams;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
||||
|
||||
const initialId = useId();
|
||||
@@ -244,6 +271,71 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const onAiDetectionComplete = (detectedRecipients: TDetectedRecipientSchema[]) => {
|
||||
const currentSigners = form.getValues('signers');
|
||||
|
||||
let nextSigningOrder =
|
||||
currentSigners.length > 0
|
||||
? Math.max(...currentSigners.map((s) => s.signingOrder ?? 0)) + 1
|
||||
: 1;
|
||||
|
||||
// If the only signer is the default empty signer lets just replace it with the detected recipients
|
||||
if (currentSigners.length === 1 && !currentSigners[0].name && !currentSigners[0].email) {
|
||||
form.setValue(
|
||||
'signers',
|
||||
detectedRecipients.map((recipient, index) => ({
|
||||
formId: nanoid(12),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
actionAuth: [],
|
||||
signingOrder: index + 1,
|
||||
})),
|
||||
{
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
},
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const recipient of detectedRecipients) {
|
||||
const emailExists = currentSigners.some(
|
||||
(s) => s.email.toLowerCase() === recipient.email.toLowerCase(),
|
||||
);
|
||||
|
||||
const nameExists = currentSigners.some(
|
||||
(s) => s.name.toLowerCase() === recipient.name.toLowerCase(),
|
||||
);
|
||||
|
||||
if ((emailExists && recipient.email) || (nameExists && recipient.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
currentSigners.push({
|
||||
formId: nanoid(12),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
actionAuth: [],
|
||||
signingOrder: nextSigningOrder,
|
||||
});
|
||||
|
||||
nextSigningOrder += 1;
|
||||
}
|
||||
|
||||
form.setValue('signers', normalizeSigningOrders(currentSigners), {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Recipients added`,
|
||||
description: t`${detectedRecipients.length} recipient(s) have been added from AI detection.`,
|
||||
});
|
||||
};
|
||||
|
||||
const onRemoveSigner = (index: number) => {
|
||||
const signer = signers[index];
|
||||
|
||||
@@ -549,6 +641,26 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
{team.preferences.aiFeaturesEnabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => setIsAiDialogOpen(true)}
|
||||
>
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent>
|
||||
<Trans>Detect recipients with AI</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-row items-center"
|
||||
@@ -576,7 +688,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
<CardContent>
|
||||
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
|
||||
<Form {...form}>
|
||||
<div className="bg-accent/50 -mt-2 mb-2 space-y-4 rounded-md p-4">
|
||||
<div className="-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4">
|
||||
{organisation.organisationClaim.flags.cfr21 && (
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
@@ -640,7 +752,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground ml-1 cursor-help">
|
||||
<span className="ml-1 cursor-help text-muted-foreground">
|
||||
<HelpCircleIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -685,7 +797,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground ml-1 cursor-help">
|
||||
<span className="ml-1 cursor-help text-muted-foreground">
|
||||
<HelpCircleIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -722,7 +834,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
>
|
||||
{signers.map((signer, index) => (
|
||||
<Draggable
|
||||
key={`${signer.id}-${signer.signingOrder}`}
|
||||
key={`${signer.nativeId}-${signer.signingOrder}`}
|
||||
draggableId={signer['nativeId']}
|
||||
index={index}
|
||||
isDragDisabled={
|
||||
@@ -738,7 +850,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className={cn('py-1', {
|
||||
'bg-widget-foreground pointer-events-none rounded-md pt-2':
|
||||
'pointer-events-none rounded-md bg-widget-foreground pt-2':
|
||||
snapshot.isDragging,
|
||||
})}
|
||||
>
|
||||
@@ -998,6 +1110,14 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
onOpenChange={setShowSigningOrderConfirmation}
|
||||
onConfirm={handleSigningOrderDisable}
|
||||
/>
|
||||
|
||||
<AiRecipientDetectionDialog
|
||||
open={isAiDialogOpen}
|
||||
onOpenChange={onAiDialogOpenChange}
|
||||
onComplete={onAiDetectionComplete}
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -116,7 +116,9 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
|
||||
await navigate(`${pathPrefix}/${id}/edit`);
|
||||
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
|
||||
|
||||
await navigate(`${pathPrefix}/${id}/edit${aiQueryParam}`);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
@@ -224,9 +226,9 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
{children}
|
||||
|
||||
{isDragActive && (
|
||||
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
|
||||
<div className="fixed left-0 top-0 z-[9999] h-full w-full bg-muted/60 backdrop-blur-[4px]">
|
||||
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
|
||||
<h2 className="text-foreground text-2xl font-semibold">
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
{type === EnvelopeType.DOCUMENT ? (
|
||||
<Trans>Upload Document</Trans>
|
||||
) : (
|
||||
@@ -234,7 +236,7 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground text-md mt-4">
|
||||
<p className="text-md mt-4 text-muted-foreground">
|
||||
<Trans>Drag and drop your PDF file here</Trans>
|
||||
</p>
|
||||
|
||||
@@ -251,7 +253,7 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
team?.id === undefined &&
|
||||
remaining.documents > 0 &&
|
||||
Number.isFinite(remaining.documents) && (
|
||||
<p className="text-muted-foreground/80 mt-4 text-sm">
|
||||
<p className="mt-4 text-sm text-muted-foreground/80">
|
||||
<Trans>
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</Trans>
|
||||
@@ -262,10 +264,10 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
|
||||
<div className="absolute inset-0 z-50 bg-muted/30 backdrop-blur-[2px]">
|
||||
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
|
||||
<Loader className="text-primary h-12 w-12 animate-spin" />
|
||||
<p className="text-foreground mt-8 font-medium">
|
||||
<Loader className="h-12 w-12 animate-spin text-primary" />
|
||||
<p className="mt-8 font-medium text-foreground">
|
||||
<Trans>Uploading</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,9 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
|
||||
await navigate(`${pathPrefix}/${id}/edit`);
|
||||
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
|
||||
|
||||
await navigate(`${pathPrefix}/${id}/edit${aiQueryParam}`);
|
||||
|
||||
toast({
|
||||
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useLoaderData } from 'react-router';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -19,9 +21,16 @@ export function meta() {
|
||||
return appMetaTags('Document Preferences');
|
||||
}
|
||||
|
||||
export default function OrganisationSettingsDocumentPage() {
|
||||
const { organisations } = useSession();
|
||||
export const loader = () => {
|
||||
return {
|
||||
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
|
||||
};
|
||||
};
|
||||
|
||||
export default function OrganisationSettingsDocumentPage() {
|
||||
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
|
||||
|
||||
const { organisations } = useSession();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { t } = useLingui();
|
||||
@@ -48,6 +57,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
signatureTypes,
|
||||
aiFeaturesEnabled,
|
||||
} = data;
|
||||
|
||||
if (
|
||||
@@ -56,7 +66,8 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
documentDateFormat === null ||
|
||||
includeSenderDetails === null ||
|
||||
includeSigningCertificate === null ||
|
||||
includeAuditLog === null
|
||||
includeAuditLog === null ||
|
||||
aiFeaturesEnabled === null
|
||||
) {
|
||||
throw new Error('Should not be possible.');
|
||||
}
|
||||
@@ -74,6 +85,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
aiFeaturesEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -93,7 +105,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
if (isLoadingOrganisation || !organisationWithSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -110,6 +122,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
<section>
|
||||
<DocumentPreferencesForm
|
||||
canInherit={false}
|
||||
isAiFeaturesConfigured={isAiFeaturesConfigured}
|
||||
settings={organisationWithSettings.organisationGlobalSettings}
|
||||
onFormSubmit={onDocumentPreferencesFormSubmit}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useLoaderData } from 'react-router';
|
||||
|
||||
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
@@ -17,7 +19,17 @@ export function meta() {
|
||||
return appMetaTags('Document Preferences');
|
||||
}
|
||||
|
||||
export const loader = () => {
|
||||
return {
|
||||
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
|
||||
};
|
||||
};
|
||||
|
||||
export default function TeamsSettingsPage() {
|
||||
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
|
||||
|
||||
console.log('isAiFeaturesConfigured', isAiFeaturesConfigured);
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { t } = useLingui();
|
||||
@@ -40,6 +52,7 @@ export default function TeamsSettingsPage() {
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
signatureTypes,
|
||||
aiFeaturesEnabled,
|
||||
} = data;
|
||||
|
||||
await updateTeamSettings({
|
||||
@@ -52,6 +65,7 @@ export default function TeamsSettingsPage() {
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
aiFeaturesEnabled,
|
||||
...(signatureTypes.length === 0
|
||||
? {
|
||||
typedSignatureEnabled: null,
|
||||
@@ -82,7 +96,7 @@ export default function TeamsSettingsPage() {
|
||||
if (isLoadingTeam || !teamWithSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -97,6 +111,7 @@ export default function TeamsSettingsPage() {
|
||||
<section>
|
||||
<DocumentPreferencesForm
|
||||
canInherit={true}
|
||||
isAiFeaturesConfigured={isAiFeaturesConfigured}
|
||||
settings={teamWithSettings.teamSettings}
|
||||
onFormSubmit={onDocumentPreferencesSubmit}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
type TDetectFieldsRequest,
|
||||
ZNormalizedFieldWithContextSchema,
|
||||
} from './detect-fields.types';
|
||||
|
||||
export type { TDetectFieldsRequest };
|
||||
|
||||
// Stream event schemas
|
||||
const ZProgressEventSchema = z.object({
|
||||
type: z.literal('progress'),
|
||||
pagesProcessed: z.number(),
|
||||
totalPages: z.number(),
|
||||
fieldsDetected: z.number(),
|
||||
});
|
||||
|
||||
const ZKeepaliveEventSchema = z.object({
|
||||
type: z.literal('keepalive'),
|
||||
});
|
||||
|
||||
const ZErrorEventSchema = z.object({
|
||||
type: z.literal('error'),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
const ZCompleteEventSchema = z.object({
|
||||
type: z.literal('complete'),
|
||||
fields: z.array(ZNormalizedFieldWithContextSchema),
|
||||
});
|
||||
|
||||
const ZStreamEventSchema = z.discriminatedUnion('type', [
|
||||
ZProgressEventSchema,
|
||||
ZKeepaliveEventSchema,
|
||||
ZErrorEventSchema,
|
||||
ZCompleteEventSchema,
|
||||
]);
|
||||
|
||||
export type DetectFieldsProgressEvent = z.infer<typeof ZProgressEventSchema>;
|
||||
export type DetectFieldsCompleteEvent = z.infer<typeof ZCompleteEventSchema>;
|
||||
export type DetectFieldsStreamEvent = z.infer<typeof ZStreamEventSchema>;
|
||||
|
||||
const ZApiErrorResponseSchema = z.object({
|
||||
error: z.string(),
|
||||
});
|
||||
|
||||
export class AiApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AiApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export type DetectFieldsOptions = {
|
||||
request: TDetectFieldsRequest;
|
||||
onProgress?: (event: DetectFieldsProgressEvent) => void;
|
||||
onComplete: (event: DetectFieldsCompleteEvent) => void;
|
||||
onError: (error: AiApiError) => void;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect fields from an envelope using AI with streaming support.
|
||||
*/
|
||||
export const detectFields = async ({
|
||||
request,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError,
|
||||
signal,
|
||||
}: DetectFieldsOptions): Promise<void> => {
|
||||
const response = await fetch('/api/ai/detect-fields', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
});
|
||||
|
||||
// Handle non-streaming error responses (auth failures, etc.)
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
const parsed = ZApiErrorResponseSchema.parse(JSON.parse(text));
|
||||
|
||||
throw new AiApiError(parsed.error, response.status);
|
||||
} catch (e) {
|
||||
if (e instanceof AiApiError) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
throw new AiApiError('Failed to detect fields', response.status);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle streaming response
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
throw new AiApiError('No response body', 500);
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
let done = false;
|
||||
|
||||
while (!done) {
|
||||
const result = await reader.read();
|
||||
done = result.done;
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
const value = result.value;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete lines
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const event = ZStreamEventSchema.parse(JSON.parse(line));
|
||||
|
||||
switch (event.type) {
|
||||
case 'progress':
|
||||
onProgress?.(event);
|
||||
break;
|
||||
case 'keepalive':
|
||||
// Ignore keepalive, it's just to prevent timeout
|
||||
break;
|
||||
case 'error':
|
||||
onError(new AiApiError(event.message, 500));
|
||||
return;
|
||||
case 'complete':
|
||||
onComplete(event);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed lines
|
||||
console.warn('Failed to parse stream event:', line);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { streamText } from 'hono/streaming';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { detectFieldsFromEnvelope } from '@documenso/lib/server-only/ai/envelope/detect-fields';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import type { HonoEnv } from '../../router';
|
||||
import { ZDetectFieldsRequestSchema } from './detect-fields.types';
|
||||
|
||||
const KEEPALIVE_INTERVAL_MS = 5000;
|
||||
|
||||
export const detectFieldsRoute = new Hono<HonoEnv>().post(
|
||||
'/',
|
||||
sValidator('json', ZDetectFieldsRequestSchema),
|
||||
async (c) => {
|
||||
const logger = c.get('logger');
|
||||
|
||||
try {
|
||||
const { envelopeId, teamId, context } = c.req.valid('json');
|
||||
|
||||
const session = await getSession(c);
|
||||
|
||||
if (!session.user) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You must be logged in to detect fields',
|
||||
});
|
||||
}
|
||||
|
||||
// Verify user has access to the team (abort early)
|
||||
const team = await getTeamById({
|
||||
userId: session.user.id,
|
||||
teamId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have access to this team',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if AI features are enabled for the team
|
||||
const { aiFeaturesEnabled } = team.derivedSettings;
|
||||
|
||||
if (!aiFeaturesEnabled) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'AI features are not enabled for this team',
|
||||
});
|
||||
}
|
||||
|
||||
if (!IS_AI_FEATURES_CONFIGURED()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'AI features are not configured. Please contact support to enable AI features.',
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({
|
||||
event: 'ai.detect-fields.start',
|
||||
envelopeId,
|
||||
userId: session.user.id,
|
||||
teamId: team.id,
|
||||
hasContext: !!context,
|
||||
});
|
||||
|
||||
// Return streaming response with NDJSON
|
||||
return streamText(c, async (stream) => {
|
||||
// Start keepalive to prevent connection timeout
|
||||
let interval: NodeJS.Timeout | null = setInterval(() => {
|
||||
void stream.writeln(JSON.stringify({ type: 'keepalive' }));
|
||||
}, KEEPALIVE_INTERVAL_MS);
|
||||
|
||||
try {
|
||||
const allFields = await detectFieldsFromEnvelope({
|
||||
context,
|
||||
envelopeId,
|
||||
userId: session.user.id,
|
||||
teamId: team.id,
|
||||
onProgress: (progress) => {
|
||||
void stream.writeln(
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
pagesProcessed: progress.pagesProcessed,
|
||||
totalPages: progress.totalPages,
|
||||
fieldsDetected: progress.fieldsDetected,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Clear keepalive before sending final response
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
|
||||
logger.info({
|
||||
event: 'ai.detect-fields.complete',
|
||||
envelopeId,
|
||||
userId: session.user.id,
|
||||
teamId: team.id,
|
||||
fieldCount: allFields.length,
|
||||
});
|
||||
|
||||
await stream.writeln(
|
||||
JSON.stringify({
|
||||
type: 'complete',
|
||||
fields: allFields,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
// Clear keepalive on error
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
|
||||
logger.error({
|
||||
event: 'ai.detect-fields.error',
|
||||
error,
|
||||
});
|
||||
|
||||
const message = error instanceof AppError ? error.message : 'Failed to detect fields';
|
||||
|
||||
await stream.writeln(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle errors that occur before streaming starts
|
||||
logger.error({
|
||||
event: 'ai.detect-fields.error',
|
||||
error,
|
||||
});
|
||||
|
||||
if (error instanceof AppError) {
|
||||
const { status, body } = AppError.toRestAPIError(error);
|
||||
|
||||
return c.json(body, status);
|
||||
}
|
||||
|
||||
return c.json({ error: 'Failed to detect fields' }, 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
ZConfidenceLevel,
|
||||
ZDetectableFieldType,
|
||||
} from '@documenso/lib/server-only/ai/envelope/detect-fields/schema';
|
||||
|
||||
export const ZDetectFieldsRequestSchema = z.object({
|
||||
envelopeId: z.string().min(1).describe('The ID of the envelope to detect fields from.'),
|
||||
teamId: z.number().describe('The ID of the team the envelope belongs to.'),
|
||||
context: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional context about recipients to help map fields (e.g., "David is the Employee, Lucas is the Manager").',
|
||||
),
|
||||
});
|
||||
|
||||
export type TDetectFieldsRequest = z.infer<typeof ZDetectFieldsRequestSchema>;
|
||||
|
||||
// Schema for fields returned from streaming API (before recipient resolution)
|
||||
export const ZNormalizedFieldWithPageSchema = z.object({
|
||||
type: ZDetectableFieldType,
|
||||
recipientKey: z.string(),
|
||||
positionX: z.number(),
|
||||
positionY: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
confidence: ZConfidenceLevel,
|
||||
pageNumber: z.number(),
|
||||
});
|
||||
|
||||
export type TNormalizedFieldWithPage = z.infer<typeof ZNormalizedFieldWithPageSchema>;
|
||||
|
||||
// Schema for fields after recipient resolution
|
||||
export const ZNormalizedFieldWithContextSchema = z.object({
|
||||
type: ZDetectableFieldType,
|
||||
positionX: z.number(),
|
||||
positionY: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
confidence: ZConfidenceLevel,
|
||||
pageNumber: z.number(),
|
||||
recipientId: z.number(),
|
||||
envelopeItemId: z.string(),
|
||||
});
|
||||
|
||||
export type TNormalizedFieldWithContext = z.infer<typeof ZNormalizedFieldWithContextSchema>;
|
||||
|
||||
export const ZDetectFieldsResponseSchema = z.object({
|
||||
fields: z.array(ZNormalizedFieldWithContextSchema),
|
||||
});
|
||||
|
||||
export type TDetectFieldsResponse = z.infer<typeof ZDetectFieldsResponseSchema>;
|
||||
@@ -0,0 +1,160 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
|
||||
import { type TDetectRecipientsRequest } from './detect-recipients.types';
|
||||
|
||||
export type { TDetectRecipientsRequest };
|
||||
|
||||
// Stream event schemas
|
||||
const ZProgressEventSchema = z.object({
|
||||
type: z.literal('progress'),
|
||||
pagesProcessed: z.number(),
|
||||
totalPages: z.number(),
|
||||
recipientsDetected: z.number(),
|
||||
});
|
||||
|
||||
const ZKeepaliveEventSchema = z.object({
|
||||
type: z.literal('keepalive'),
|
||||
});
|
||||
|
||||
const ZErrorEventSchema = z.object({
|
||||
type: z.literal('error'),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
const ZCompleteEventSchema = z.object({
|
||||
type: z.literal('complete'),
|
||||
recipients: z.array(ZDetectedRecipientSchema),
|
||||
});
|
||||
|
||||
const ZStreamEventSchema = z.discriminatedUnion('type', [
|
||||
ZProgressEventSchema,
|
||||
ZKeepaliveEventSchema,
|
||||
ZErrorEventSchema,
|
||||
ZCompleteEventSchema,
|
||||
]);
|
||||
|
||||
export type DetectRecipientsProgressEvent = z.infer<typeof ZProgressEventSchema>;
|
||||
export type DetectRecipientsCompleteEvent = z.infer<typeof ZCompleteEventSchema>;
|
||||
export type DetectRecipientsStreamEvent = z.infer<typeof ZStreamEventSchema>;
|
||||
|
||||
const ZApiErrorResponseSchema = z.object({
|
||||
error: z.string(),
|
||||
});
|
||||
|
||||
export class AiApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AiApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export type DetectRecipientsOptions = {
|
||||
request: TDetectRecipientsRequest;
|
||||
onProgress?: (event: DetectRecipientsProgressEvent) => void;
|
||||
onComplete: (event: DetectRecipientsCompleteEvent) => void;
|
||||
onError: (error: AiApiError) => void;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect recipients from an envelope using AI with streaming support.
|
||||
*/
|
||||
export const detectRecipients = async ({
|
||||
request,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError,
|
||||
signal,
|
||||
}: DetectRecipientsOptions): Promise<void> => {
|
||||
const response = await fetch('/api/ai/detect-recipients', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
});
|
||||
|
||||
// Handle non-streaming error responses (auth failures, etc.)
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
const parsed = ZApiErrorResponseSchema.parse(JSON.parse(text));
|
||||
|
||||
throw new AiApiError(parsed.error, response.status);
|
||||
} catch (e) {
|
||||
if (e instanceof AiApiError) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
throw new AiApiError('Failed to detect recipients', response.status);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle streaming response
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
throw new AiApiError('No response body', 500);
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
let done = false;
|
||||
|
||||
while (!done) {
|
||||
const result = await reader.read();
|
||||
done = result.done;
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
const value = result.value;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete lines
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const event = ZStreamEventSchema.parse(JSON.parse(line));
|
||||
|
||||
switch (event.type) {
|
||||
case 'progress':
|
||||
onProgress?.(event);
|
||||
break;
|
||||
case 'keepalive':
|
||||
// Ignore keepalive, it's just to prevent timeout
|
||||
break;
|
||||
case 'error':
|
||||
onError(new AiApiError(event.message, 500));
|
||||
return;
|
||||
case 'complete':
|
||||
onComplete(event);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed lines
|
||||
console.warn('Failed to parse stream event:', line);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { streamText } from 'hono/streaming';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { detectRecipientsFromEnvelope } from '@documenso/lib/server-only/ai/envelope/detect-recipients';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import type { HonoEnv } from '../../router';
|
||||
import { ZDetectRecipientsRequestSchema } from './detect-recipients.types';
|
||||
|
||||
const KEEPALIVE_INTERVAL_MS = 5000;
|
||||
|
||||
export const detectRecipientsRoute = new Hono<HonoEnv>().post(
|
||||
'/',
|
||||
sValidator('json', ZDetectRecipientsRequestSchema),
|
||||
async (c) => {
|
||||
const logger = c.get('logger');
|
||||
|
||||
try {
|
||||
const { envelopeId, teamId } = c.req.valid('json');
|
||||
|
||||
const session = await getSession(c);
|
||||
|
||||
if (!session.user) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You must be logged in to detect recipients',
|
||||
});
|
||||
}
|
||||
|
||||
// Verify user has access to the team (abort early)
|
||||
const team = await getTeamById({
|
||||
userId: session.user.id,
|
||||
teamId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have access to this team',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if AI features are enabled for the team
|
||||
const { aiFeaturesEnabled } = team.derivedSettings;
|
||||
|
||||
if (!aiFeaturesEnabled) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'AI features are not enabled for this team',
|
||||
});
|
||||
}
|
||||
|
||||
if (!IS_AI_FEATURES_CONFIGURED()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'AI features are not configured. Please contact support to enable AI features.',
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({
|
||||
event: 'ai.detect-recipients.start',
|
||||
envelopeId,
|
||||
userId: session.user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
// Return streaming response with NDJSON
|
||||
return streamText(c, async (stream) => {
|
||||
// Start keepalive to prevent connection timeout
|
||||
let interval: NodeJS.Timeout | null = setInterval(() => {
|
||||
void stream.writeln(JSON.stringify({ type: 'keepalive' }));
|
||||
}, KEEPALIVE_INTERVAL_MS);
|
||||
|
||||
try {
|
||||
const recipients = await detectRecipientsFromEnvelope({
|
||||
envelopeId,
|
||||
userId: session.user.id,
|
||||
teamId: team.id,
|
||||
onProgress: (progress) => {
|
||||
void stream.writeln(
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
pagesProcessed: progress.pagesProcessed,
|
||||
totalPages: progress.totalPages,
|
||||
recipientsDetected: progress.recipientsDetected,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Clear keepalive before sending final response
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
|
||||
logger.info({
|
||||
event: 'ai.detect-recipients.complete',
|
||||
envelopeId,
|
||||
userId: session.user.id,
|
||||
teamId: team.id,
|
||||
recipientCount: recipients.length,
|
||||
});
|
||||
|
||||
await stream.writeln(
|
||||
JSON.stringify({
|
||||
type: 'complete',
|
||||
recipients,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
// Clear keepalive on error
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
|
||||
logger.error({
|
||||
event: 'ai.detect-recipients.error',
|
||||
error,
|
||||
});
|
||||
|
||||
const message = error instanceof AppError ? error.message : 'Failed to detect recipients';
|
||||
|
||||
await stream.writeln(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle errors that occur before streaming starts
|
||||
logger.error({
|
||||
event: 'ai.detect-recipients.error',
|
||||
error,
|
||||
});
|
||||
|
||||
if (error instanceof AppError) {
|
||||
const { status, body } = AppError.toRestAPIError(error);
|
||||
|
||||
return c.json(body, status);
|
||||
}
|
||||
|
||||
return c.json({ error: 'Failed to detect recipients' }, 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
|
||||
export const ZDetectRecipientsRequestSchema = z.object({
|
||||
envelopeId: z.string().min(1).describe('The ID of the envelope to detect recipients from.'),
|
||||
teamId: z.number().describe('The ID of the team the envelope belongs to.'),
|
||||
});
|
||||
|
||||
export type TDetectRecipientsRequest = z.infer<typeof ZDetectRecipientsRequestSchema>;
|
||||
|
||||
export const ZDetectRecipientsResponseSchema = z.object({
|
||||
recipients: z.array(ZDetectedRecipientSchema),
|
||||
});
|
||||
|
||||
export type TDetectRecipientsResponse = z.infer<typeof ZDetectRecipientsResponseSchema>;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import type { HonoEnv } from '../../router';
|
||||
import { detectFieldsRoute } from './detect-fields';
|
||||
import { detectRecipientsRoute } from './detect-recipients';
|
||||
|
||||
export const aiRoute = new Hono<HonoEnv>()
|
||||
.route('/detect-recipients', detectRecipientsRoute)
|
||||
.route('/detect-fields', detectFieldsRoute);
|
||||
@@ -12,9 +12,11 @@ import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
|
||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
||||
import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client';
|
||||
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { logger } from '@documenso/lib/utils/logger';
|
||||
import { openApiDocument } from '@documenso/trpc/server/open-api';
|
||||
|
||||
import { aiRoute } from './api/ai/route';
|
||||
import { downloadRoute } from './api/download/download';
|
||||
import { filesRoute } from './api/files/files';
|
||||
import { type AppContext, appContext } from './context';
|
||||
@@ -50,6 +52,21 @@ const rateLimitMiddleware = rateLimiter({
|
||||
},
|
||||
});
|
||||
|
||||
const aiRateLimitMiddleware = rateLimiter({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
limit: 3, // 3 requests per window
|
||||
keyGenerator: (c) => {
|
||||
try {
|
||||
return getIpAddress(c.req.raw);
|
||||
} catch (error) {
|
||||
return 'unknown';
|
||||
}
|
||||
},
|
||||
message: {
|
||||
error: 'Too many requests, please try again later.',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Attach session and context to requests.
|
||||
*/
|
||||
@@ -85,6 +102,10 @@ app.route('/api/auth', auth);
|
||||
// Files route.
|
||||
app.route('/api/files', filesRoute);
|
||||
|
||||
// AI route.
|
||||
app.use('/api/ai/*', aiRateLimitMiddleware);
|
||||
app.route('/api/ai', aiRoute);
|
||||
|
||||
// API servers.
|
||||
app.use(`/api/v1/*`, cors());
|
||||
app.route('/api/v1', tsRestHonoApp);
|
||||
@@ -115,6 +136,8 @@ app.use(`${API_V2_BETA_URL}/*`, async (c) =>
|
||||
|
||||
// Start telemetry client for anonymous usage tracking.
|
||||
// Can be disabled by setting DOCUMENSO_DISABLE_TELEMETRY=true
|
||||
void TelemetryClient.start();
|
||||
if (env('NODE_ENV') !== 'development') {
|
||||
void TelemetryClient.start();
|
||||
}
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -51,6 +51,7 @@ export default defineConfig({
|
||||
ssr: {
|
||||
noExternal: ['react-dropzone', 'plausible-tracker'],
|
||||
external: [
|
||||
'@napi-rs/canvas',
|
||||
'@node-rs/bcrypt',
|
||||
'@prisma/client',
|
||||
'@documenso/tailwind-config',
|
||||
@@ -64,6 +65,7 @@ export default defineConfig({
|
||||
include: ['prop-types', 'file-selector', 'attr-accept'],
|
||||
exclude: [
|
||||
'node_modules',
|
||||
'@napi-rs/canvas',
|
||||
'@node-rs/bcrypt',
|
||||
'@documenso/pdf-sign',
|
||||
'sharp',
|
||||
@@ -94,6 +96,7 @@ export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'@napi-rs/canvas',
|
||||
'@node-rs/bcrypt',
|
||||
'@documenso/pdf-sign',
|
||||
'@aws-sdk/cloudfront-signer',
|
||||
|
||||
Reference in New Issue
Block a user