mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +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:
+10
-1
@@ -147,6 +147,15 @@ NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
|
|||||||
# We only collect: app version, installation ID, and node ID. No personal data is collected.
|
# We only collect: app version, installation ID, and node ID. No personal data is collected.
|
||||||
DOCUMENSO_DISABLE_TELEMETRY=
|
DOCUMENSO_DISABLE_TELEMETRY=
|
||||||
|
|
||||||
|
# [[AI]]
|
||||||
|
# OPTIONAL: Google Cloud Project ID for Vertex AI.
|
||||||
|
GOOGLE_VERTEX_PROJECT_ID=""
|
||||||
|
# OPTIONAL: Google Cloud region for Vertex AI. Defaults to "global".
|
||||||
|
GOOGLE_VERTEX_LOCATION="global"
|
||||||
|
# OPTIONAL: API key for Google Vertex AI (Gemini). Get your key from:
|
||||||
|
# https://console.cloud.google.com/vertex-ai/studio/settings/api-keys
|
||||||
|
GOOGLE_VERTEX_API_KEY=""
|
||||||
|
|
||||||
# [[E2E Tests]]
|
# [[E2E Tests]]
|
||||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||||
@@ -157,4 +166,4 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
|||||||
NEXT_PRIVATE_LOGGER_FILE_PATH=
|
NEXT_PRIVATE_LOGGER_FILE_PATH=
|
||||||
|
|
||||||
# [[PLAIN SUPPORT]]
|
# [[PLAIN SUPPORT]]
|
||||||
NEXT_PRIVATE_PLAIN_API_KEY=
|
NEXT_PRIVATE_PLAIN_API_KEY=
|
||||||
|
|||||||
@@ -60,3 +60,6 @@ CLAUDE.md
|
|||||||
|
|
||||||
# agents
|
# agents
|
||||||
.specs
|
.specs
|
||||||
|
|
||||||
|
# scripts
|
||||||
|
scripts/output*
|
||||||
|
|||||||
@@ -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;
|
includeSigningCertificate: boolean | null;
|
||||||
includeAuditLog: boolean | null;
|
includeAuditLog: boolean | null;
|
||||||
signatureTypes: DocumentSignatureType[];
|
signatureTypes: DocumentSignatureType[];
|
||||||
|
aiFeaturesEnabled: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SettingsSubset = Pick<
|
type SettingsSubset = Pick<
|
||||||
@@ -72,11 +73,13 @@ type SettingsSubset = Pick<
|
|||||||
| 'typedSignatureEnabled'
|
| 'typedSignatureEnabled'
|
||||||
| 'uploadSignatureEnabled'
|
| 'uploadSignatureEnabled'
|
||||||
| 'drawSignatureEnabled'
|
| 'drawSignatureEnabled'
|
||||||
|
| 'aiFeaturesEnabled'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type DocumentPreferencesFormProps = {
|
export type DocumentPreferencesFormProps = {
|
||||||
settings: SettingsSubset;
|
settings: SettingsSubset;
|
||||||
canInherit: boolean;
|
canInherit: boolean;
|
||||||
|
isAiFeaturesConfigured?: boolean;
|
||||||
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
|
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,6 +87,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
settings,
|
settings,
|
||||||
onFormSubmit,
|
onFormSubmit,
|
||||||
canInherit,
|
canInherit,
|
||||||
|
isAiFeaturesConfigured = false,
|
||||||
}: DocumentPreferencesFormProps) => {
|
}: DocumentPreferencesFormProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { user, organisations } = useSession();
|
const { user, organisations } = useSession();
|
||||||
@@ -105,6 +109,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
||||||
message: msg`At least one signature type must be enabled`.id,
|
message: msg`At least one signature type must be enabled`.id,
|
||||||
}),
|
}),
|
||||||
|
aiFeaturesEnabled: z.boolean().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<TDocumentPreferencesFormSchema>({
|
const form = useForm<TDocumentPreferencesFormSchema>({
|
||||||
@@ -120,6 +125,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
includeSigningCertificate: settings.includeSigningCertificate,
|
includeSigningCertificate: settings.includeSigningCertificate,
|
||||||
includeAuditLog: settings.includeAuditLog,
|
includeAuditLog: settings.includeAuditLog,
|
||||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||||
|
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||||
});
|
});
|
||||||
@@ -312,7 +318,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
}))}
|
}))}
|
||||||
selectedValues={field.value}
|
selectedValues={field.value}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
className="bg-background w-full"
|
className="w-full bg-background"
|
||||||
enableSearch={false}
|
enableSearch={false}
|
||||||
emptySelectionPlaceholder={
|
emptySelectionPlaceholder={
|
||||||
canInherit ? t`Inherit from organisation` : t`Select signature types`
|
canInherit ? t`Inherit from organisation` : t`Select signature types`
|
||||||
@@ -378,7 +384,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<div className="pt-2">
|
<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>
|
<Trans>Preview</Trans>
|
||||||
</div>
|
</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">
|
<div className="flex flex-row justify-end space-x-4">
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
<Trans>Update</Trans>
|
<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 type { MessageDescriptor } from '@lingui/core';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react';
|
||||||
import { FieldType, RecipientRole } from '@prisma/client';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { FileTextIcon } from 'lucide-react';
|
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||||
|
import { FileTextIcon, SparklesIcon } from 'lucide-react';
|
||||||
import { Link, useSearchParams } from 'react-router';
|
import { Link, useSearchParams } from 'react-router';
|
||||||
import { isDeepEqual } from 'remeda';
|
import { isDeepEqual } from 'remeda';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import type {
|
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||||
TCheckboxFieldMeta,
|
import {
|
||||||
TDateFieldMeta,
|
FIELD_META_DEFAULT_VALUES,
|
||||||
TDropdownFieldMeta,
|
type TCheckboxFieldMeta,
|
||||||
TEmailFieldMeta,
|
type TDateFieldMeta,
|
||||||
TFieldMetaSchema,
|
type TDropdownFieldMeta,
|
||||||
TInitialsFieldMeta,
|
type TEmailFieldMeta,
|
||||||
TNameFieldMeta,
|
type TFieldMetaSchema,
|
||||||
TNumberFieldMeta,
|
type TInitialsFieldMeta,
|
||||||
TRadioFieldMeta,
|
type TNameFieldMeta,
|
||||||
TSignatureFieldMeta,
|
type TNumberFieldMeta,
|
||||||
TTextFieldMeta,
|
type TRadioFieldMeta,
|
||||||
|
type TSignatureFieldMeta,
|
||||||
|
type TTextFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
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 { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
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 { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
|
||||||
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
||||||
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-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 { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
|
||||||
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
|
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
|
||||||
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-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 { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
||||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||||
@@ -67,11 +72,15 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
|||||||
export const EnvelopeEditorFieldsPage = () => {
|
export const EnvelopeEditorFieldsPage = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
||||||
|
|
||||||
const selectedField = useMemo(
|
const selectedField = useMemo(
|
||||||
() => structuredClone(editorFields.selectedField),
|
() => 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.
|
* Set the selected recipient to the first recipient in the envelope.
|
||||||
*/
|
*/
|
||||||
@@ -202,6 +229,35 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
|
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
|
||||||
selectedEnvelopeItemId={currentEnvelopeItem?.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>
|
</section>
|
||||||
|
|
||||||
{/* Field details section. */}
|
{/* Field details section. */}
|
||||||
@@ -243,7 +299,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
|
|
||||||
<div className="px-4 [&_label]:text-xs [&_label]:text-foreground/70">
|
<div className="px-4 [&_label]:text-xs [&_label]:text-foreground/70">
|
||||||
<h3 className="text-sm font-semibold">
|
<h3 className="text-sm font-semibold">
|
||||||
{t(FieldSettingsTypeTranslations[selectedField.type])}
|
{_(FieldSettingsTypeTranslations[selectedField.type])}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{match(selectedField.type)
|
{match(selectedField.type)
|
||||||
|
|||||||
+126
-6
@@ -12,8 +12,9 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
|
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
|
||||||
import { motion } from 'framer-motion';
|
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 { useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
import { isDeepEqual, prop, sortBy } from 'remeda';
|
import { isDeepEqual, prop, sortBy } from 'remeda';
|
||||||
import { z } from 'zod';
|
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 { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||||
import {
|
import {
|
||||||
ZRecipientActionAuthTypesSchema,
|
ZRecipientActionAuthTypesSchema,
|
||||||
ZRecipientAuthOptionsSchema,
|
ZRecipientAuthOptionsSchema,
|
||||||
@@ -60,6 +62,9 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
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({
|
const ZEnvelopeRecipientsForm = z.object({
|
||||||
signers: z.array(
|
signers: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -85,14 +90,36 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
|
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { remaining } = useLimits();
|
const { remaining } = useLimits();
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
|
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 debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
||||||
|
|
||||||
const initialId = useId();
|
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 onRemoveSigner = (index: number) => {
|
||||||
const signer = signers[index];
|
const signer = signers[index];
|
||||||
|
|
||||||
@@ -549,6 +641,26 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row items-center space-x-2">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex flex-row items-center"
|
className="flex flex-row items-center"
|
||||||
@@ -576,7 +688,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
|
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
|
||||||
<Form {...form}>
|
<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 && (
|
{organisation.organisationClaim.flags.cfr21 && (
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -640,7 +752,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<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" />
|
<HelpCircleIcon className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -685,7 +797,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<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" />
|
<HelpCircleIcon className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -722,7 +834,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
>
|
>
|
||||||
{signers.map((signer, index) => (
|
{signers.map((signer, index) => (
|
||||||
<Draggable
|
<Draggable
|
||||||
key={`${signer.id}-${signer.signingOrder}`}
|
key={`${signer.nativeId}-${signer.signingOrder}`}
|
||||||
draggableId={signer['nativeId']}
|
draggableId={signer['nativeId']}
|
||||||
index={index}
|
index={index}
|
||||||
isDragDisabled={
|
isDragDisabled={
|
||||||
@@ -738,7 +850,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
{...provided.dragHandleProps}
|
{...provided.dragHandleProps}
|
||||||
className={cn('py-1', {
|
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,
|
snapshot.isDragging,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
@@ -998,6 +1110,14 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
onOpenChange={setShowSigningOrderConfirmation}
|
onOpenChange={setShowSigningOrderConfirmation}
|
||||||
onConfirm={handleSigningOrderDisable}
|
onConfirm={handleSigningOrderDisable}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AiRecipientDetectionDialog
|
||||||
|
open={isAiDialogOpen}
|
||||||
|
onOpenChange={onAiDialogOpenChange}
|
||||||
|
onComplete={onAiDetectionComplete}
|
||||||
|
envelopeId={envelope.id}
|
||||||
|
teamId={envelope.teamId}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -116,7 +116,9 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
? formatDocumentsPath(team.url)
|
? formatDocumentsPath(team.url)
|
||||||
: formatTemplatesPath(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) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
@@ -224,9 +226,9 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
{children}
|
{children}
|
||||||
|
|
||||||
{isDragActive && (
|
{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">
|
<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 ? (
|
{type === EnvelopeType.DOCUMENT ? (
|
||||||
<Trans>Upload Document</Trans>
|
<Trans>Upload Document</Trans>
|
||||||
) : (
|
) : (
|
||||||
@@ -234,7 +236,7 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
)}
|
)}
|
||||||
</h2>
|
</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>
|
<Trans>Drag and drop your PDF file here</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -251,7 +253,7 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
team?.id === undefined &&
|
team?.id === undefined &&
|
||||||
remaining.documents > 0 &&
|
remaining.documents > 0 &&
|
||||||
Number.isFinite(remaining.documents) && (
|
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>
|
<Trans>
|
||||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||||
</Trans>
|
</Trans>
|
||||||
@@ -262,10 +264,10 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading && (
|
{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">
|
<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" />
|
<Loader className="h-12 w-12 animate-spin text-primary" />
|
||||||
<p className="text-foreground mt-8 font-medium">
|
<p className="mt-8 font-medium text-foreground">
|
||||||
<Trans>Uploading</Trans>
|
<Trans>Uploading</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,7 +108,9 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
? formatDocumentsPath(team.url)
|
? formatDocumentsPath(team.url)
|
||||||
: formatTemplatesPath(team.url);
|
: formatTemplatesPath(team.url);
|
||||||
|
|
||||||
await navigate(`${pathPrefix}/${id}/edit`);
|
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
|
||||||
|
|
||||||
|
await navigate(`${pathPrefix}/${id}/edit${aiQueryParam}`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
|
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
import { useLoaderData } from 'react-router';
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
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 { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -19,9 +21,16 @@ export function meta() {
|
|||||||
return appMetaTags('Document Preferences');
|
return appMetaTags('Document Preferences');
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OrganisationSettingsDocumentPage() {
|
export const loader = () => {
|
||||||
const { organisations } = useSession();
|
return {
|
||||||
|
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OrganisationSettingsDocumentPage() {
|
||||||
|
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
const { organisations } = useSession();
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
@@ -48,6 +57,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
includeAuditLog,
|
includeAuditLog,
|
||||||
signatureTypes,
|
signatureTypes,
|
||||||
|
aiFeaturesEnabled,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -56,7 +66,8 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
documentDateFormat === null ||
|
documentDateFormat === null ||
|
||||||
includeSenderDetails === null ||
|
includeSenderDetails === null ||
|
||||||
includeSigningCertificate === null ||
|
includeSigningCertificate === null ||
|
||||||
includeAuditLog === null
|
includeAuditLog === null ||
|
||||||
|
aiFeaturesEnabled === null
|
||||||
) {
|
) {
|
||||||
throw new Error('Should not be possible.');
|
throw new Error('Should not be possible.');
|
||||||
}
|
}
|
||||||
@@ -74,6 +85,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
|
aiFeaturesEnabled,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,7 +105,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
if (isLoadingOrganisation || !organisationWithSettings) {
|
if (isLoadingOrganisation || !organisationWithSettings) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center rounded-lg py-32">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -110,6 +122,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
<section>
|
<section>
|
||||||
<DocumentPreferencesForm
|
<DocumentPreferencesForm
|
||||||
canInherit={false}
|
canInherit={false}
|
||||||
|
isAiFeaturesConfigured={isAiFeaturesConfigured}
|
||||||
settings={organisationWithSettings.organisationGlobalSettings}
|
settings={organisationWithSettings.organisationGlobalSettings}
|
||||||
onFormSubmit={onDocumentPreferencesFormSubmit}
|
onFormSubmit={onDocumentPreferencesFormSubmit}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Loader } from 'lucide-react';
|
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 { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
@@ -17,7 +19,17 @@ export function meta() {
|
|||||||
return appMetaTags('Document Preferences');
|
return appMetaTags('Document Preferences');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const loader = () => {
|
||||||
|
return {
|
||||||
|
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default function TeamsSettingsPage() {
|
export default function TeamsSettingsPage() {
|
||||||
|
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
console.log('isAiFeaturesConfigured', isAiFeaturesConfigured);
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
@@ -40,6 +52,7 @@ export default function TeamsSettingsPage() {
|
|||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
includeAuditLog,
|
includeAuditLog,
|
||||||
signatureTypes,
|
signatureTypes,
|
||||||
|
aiFeaturesEnabled,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
await updateTeamSettings({
|
await updateTeamSettings({
|
||||||
@@ -52,6 +65,7 @@ export default function TeamsSettingsPage() {
|
|||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
includeAuditLog,
|
includeAuditLog,
|
||||||
|
aiFeaturesEnabled,
|
||||||
...(signatureTypes.length === 0
|
...(signatureTypes.length === 0
|
||||||
? {
|
? {
|
||||||
typedSignatureEnabled: null,
|
typedSignatureEnabled: null,
|
||||||
@@ -82,7 +96,7 @@ export default function TeamsSettingsPage() {
|
|||||||
if (isLoadingTeam || !teamWithSettings) {
|
if (isLoadingTeam || !teamWithSettings) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center rounded-lg py-32">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -97,6 +111,7 @@ export default function TeamsSettingsPage() {
|
|||||||
<section>
|
<section>
|
||||||
<DocumentPreferencesForm
|
<DocumentPreferencesForm
|
||||||
canInherit={true}
|
canInherit={true}
|
||||||
|
isAiFeaturesConfigured={isAiFeaturesConfigured}
|
||||||
settings={teamWithSettings.teamSettings}
|
settings={teamWithSettings.teamSettings}
|
||||||
onFormSubmit={onDocumentPreferencesSubmit}
|
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 { jobsClient } from '@documenso/lib/jobs/client';
|
||||||
import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client';
|
import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client';
|
||||||
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
|
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
|
||||||
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import { logger } from '@documenso/lib/utils/logger';
|
import { logger } from '@documenso/lib/utils/logger';
|
||||||
import { openApiDocument } from '@documenso/trpc/server/open-api';
|
import { openApiDocument } from '@documenso/trpc/server/open-api';
|
||||||
|
|
||||||
|
import { aiRoute } from './api/ai/route';
|
||||||
import { downloadRoute } from './api/download/download';
|
import { downloadRoute } from './api/download/download';
|
||||||
import { filesRoute } from './api/files/files';
|
import { filesRoute } from './api/files/files';
|
||||||
import { type AppContext, appContext } from './context';
|
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.
|
* Attach session and context to requests.
|
||||||
*/
|
*/
|
||||||
@@ -85,6 +102,10 @@ app.route('/api/auth', auth);
|
|||||||
// Files route.
|
// Files route.
|
||||||
app.route('/api/files', filesRoute);
|
app.route('/api/files', filesRoute);
|
||||||
|
|
||||||
|
// AI route.
|
||||||
|
app.use('/api/ai/*', aiRateLimitMiddleware);
|
||||||
|
app.route('/api/ai', aiRoute);
|
||||||
|
|
||||||
// API servers.
|
// API servers.
|
||||||
app.use(`/api/v1/*`, cors());
|
app.use(`/api/v1/*`, cors());
|
||||||
app.route('/api/v1', tsRestHonoApp);
|
app.route('/api/v1', tsRestHonoApp);
|
||||||
@@ -115,6 +136,8 @@ app.use(`${API_V2_BETA_URL}/*`, async (c) =>
|
|||||||
|
|
||||||
// Start telemetry client for anonymous usage tracking.
|
// Start telemetry client for anonymous usage tracking.
|
||||||
// Can be disabled by setting DOCUMENSO_DISABLE_TELEMETRY=true
|
// Can be disabled by setting DOCUMENSO_DISABLE_TELEMETRY=true
|
||||||
void TelemetryClient.start();
|
if (env('NODE_ENV') !== 'development') {
|
||||||
|
void TelemetryClient.start();
|
||||||
|
}
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export default defineConfig({
|
|||||||
ssr: {
|
ssr: {
|
||||||
noExternal: ['react-dropzone', 'plausible-tracker'],
|
noExternal: ['react-dropzone', 'plausible-tracker'],
|
||||||
external: [
|
external: [
|
||||||
|
'@napi-rs/canvas',
|
||||||
'@node-rs/bcrypt',
|
'@node-rs/bcrypt',
|
||||||
'@prisma/client',
|
'@prisma/client',
|
||||||
'@documenso/tailwind-config',
|
'@documenso/tailwind-config',
|
||||||
@@ -64,6 +65,7 @@ export default defineConfig({
|
|||||||
include: ['prop-types', 'file-selector', 'attr-accept'],
|
include: ['prop-types', 'file-selector', 'attr-accept'],
|
||||||
exclude: [
|
exclude: [
|
||||||
'node_modules',
|
'node_modules',
|
||||||
|
'@napi-rs/canvas',
|
||||||
'@node-rs/bcrypt',
|
'@node-rs/bcrypt',
|
||||||
'@documenso/pdf-sign',
|
'@documenso/pdf-sign',
|
||||||
'sharp',
|
'sharp',
|
||||||
@@ -94,6 +96,7 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: [
|
external: [
|
||||||
|
'@napi-rs/canvas',
|
||||||
'@node-rs/bcrypt',
|
'@node-rs/bcrypt',
|
||||||
'@documenso/pdf-sign',
|
'@documenso/pdf-sign',
|
||||||
'@aws-sdk/cloudfront-signer',
|
'@aws-sdk/cloudfront-signer',
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ COPY --from=builder /app/out/json/ .
|
|||||||
COPY --from=builder /app/out/package-lock.json ./package-lock.json
|
COPY --from=builder /app/out/package-lock.json ./package-lock.json
|
||||||
|
|
||||||
COPY --from=builder /app/lingui.config.ts ./lingui.config.ts
|
COPY --from=builder /app/lingui.config.ts ./lingui.config.ts
|
||||||
|
COPY --from=builder /app/patches ./patches
|
||||||
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
@@ -108,6 +109,8 @@ WORKDIR /app
|
|||||||
COPY --from=builder --chown=nodejs:nodejs /app/out/json/ .
|
COPY --from=builder --chown=nodejs:nodejs /app/out/json/ .
|
||||||
# Copy the tailwind config files across
|
# Copy the tailwind config files across
|
||||||
COPY --from=builder --chown=nodejs:nodejs /app/out/full/packages/tailwind-config ./packages/tailwind-config
|
COPY --from=builder --chown=nodejs:nodejs /app/out/full/packages/tailwind-config ./packages/tailwind-config
|
||||||
|
# Copy the patches across
|
||||||
|
COPY --from=builder --chown=nodejs:nodejs /app/patches ./patches
|
||||||
|
|
||||||
RUN npm ci --only=production
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
|||||||
Generated
+439
-11
@@ -7,15 +7,18 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
|
"hasInstallScript": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/google-vertex": "3.0.81",
|
||||||
"@documenso/pdf-sign": "^0.1.0",
|
"@documenso/pdf-sign": "^0.1.0",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@lingui/conf": "^5.6.0",
|
"@lingui/conf": "^5.6.0",
|
||||||
"@lingui/core": "^5.6.0",
|
"@lingui/core": "^5.6.0",
|
||||||
|
"ai": "^5.0.104",
|
||||||
"inngest-cli": "^1.13.7",
|
"inngest-cli": "^1.13.7",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"posthog-node": "4.18.0",
|
"posthog-node": "4.18.0",
|
||||||
@@ -41,6 +44,7 @@
|
|||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"nodemailer": "^7.0.10",
|
"nodemailer": "^7.0.10",
|
||||||
|
"patch-package": "^8.0.1",
|
||||||
"pdfjs-dist": "5.4.296",
|
"pdfjs-dist": "5.4.296",
|
||||||
"pino": "^9.14.0",
|
"pino": "^9.14.0",
|
||||||
"pino-pretty": "^13.1.2",
|
"pino-pretty": "^13.1.2",
|
||||||
@@ -244,6 +248,103 @@
|
|||||||
"@esbuild/win32-x64": "0.27.0"
|
"@esbuild/win32-x64": "0.27.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ai-sdk/anthropic": {
|
||||||
|
"version": "2.0.50",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-2.0.50.tgz",
|
||||||
|
"integrity": "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider": "2.0.0",
|
||||||
|
"@ai-sdk/provider-utils": "3.0.18"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/gateway": {
|
||||||
|
"version": "2.0.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.17.tgz",
|
||||||
|
"integrity": "sha512-oVAG6q72KsjKlrYdLhWjRO7rcqAR8CjokAbYuyVZoCO4Uh2PH/VzZoxZav71w2ipwlXhHCNaInGYWNs889MMDA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider": "2.0.0",
|
||||||
|
"@ai-sdk/provider-utils": "3.0.18",
|
||||||
|
"@vercel/oidc": "3.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/google": {
|
||||||
|
"version": "2.0.44",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-2.0.44.tgz",
|
||||||
|
"integrity": "sha512-c5dck36FjqiVoeeMJQLTEmUheoURcGTU/nBT6iJu8/nZiKFT/y8pD85KMDRB7RerRYaaQOtslR2d6/5PditiRw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider": "2.0.0",
|
||||||
|
"@ai-sdk/provider-utils": "3.0.18"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/google-vertex": {
|
||||||
|
"version": "3.0.81",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/google-vertex/-/google-vertex-3.0.81.tgz",
|
||||||
|
"integrity": "sha512-yrl5Ug0Mqwo9ya45oxczgy2RWgpEA/XQQCSFYP+3NZMQ4yA3Iim1vkOjVCsGaZZ8rjVk395abi1ZMZV0/6rqVA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/anthropic": "2.0.50",
|
||||||
|
"@ai-sdk/google": "2.0.44",
|
||||||
|
"@ai-sdk/provider": "2.0.0",
|
||||||
|
"@ai-sdk/provider-utils": "3.0.18",
|
||||||
|
"google-auth-library": "^9.15.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/provider": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"json-schema": "^0.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/provider-utils": {
|
||||||
|
"version": "3.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.18.tgz",
|
||||||
|
"integrity": "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider": "2.0.0",
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"eventsource-parser": "^3.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||||
@@ -4529,7 +4630,6 @@
|
|||||||
"version": "0.1.82",
|
"version": "0.1.82",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.82.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.82.tgz",
|
||||||
"integrity": "sha512-FGjyUBoF0sl1EenSiE4UV2WYu76q6F9GSYedq5EiOCOyGYoQ/Owulcv6rd7v/tWOpljDDtefXXIaOCJrVKem4w==",
|
"integrity": "sha512-FGjyUBoF0sl1EenSiE4UV2WYu76q6F9GSYedq5EiOCOyGYoQ/Owulcv6rd7v/tWOpljDDtefXXIaOCJrVKem4w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"e2e/*"
|
"e2e/*"
|
||||||
@@ -17657,6 +17757,15 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@vercel/oidc": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vvo/tzdb": {
|
"node_modules/@vvo/tzdb": {
|
||||||
"version": "6.196.0",
|
"version": "6.196.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vvo/tzdb/-/tzdb-6.196.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vvo/tzdb/-/tzdb-6.196.0.tgz",
|
||||||
@@ -17672,6 +17781,13 @@
|
|||||||
"node": ">=14.6"
|
"node": ">=14.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@yarnpkg/lockfile": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/abbrev": {
|
"node_modules/abbrev": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
|
||||||
@@ -17751,6 +17867,24 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ai": {
|
||||||
|
"version": "5.0.104",
|
||||||
|
"resolved": "https://registry.npmjs.org/ai/-/ai-5.0.104.tgz",
|
||||||
|
"integrity": "sha512-MZOkL9++nY5PfkpWKBR3Rv+Oygxpb9S16ctv8h91GvrSif7UnNEdPMVZe3bUyMd2djxf0AtBk/csBixP0WwWZQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/gateway": "2.0.17",
|
||||||
|
"@ai-sdk/provider": "2.0.0",
|
||||||
|
"@ai-sdk/provider-utils": "3.0.18",
|
||||||
|
"@opentelemetry/api": "1.9.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "8.17.1",
|
"version": "8.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||||
@@ -18591,6 +18725,12 @@
|
|||||||
"ieee754": "^1.1.13"
|
"ieee754": "^1.1.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@@ -18946,6 +19086,22 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ci-info": {
|
||||||
|
"version": "3.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
|
||||||
|
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/sibiraj-s"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/citty": {
|
"node_modules/citty": {
|
||||||
"version": "0.1.6",
|
"version": "0.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||||
@@ -21408,6 +21564,15 @@
|
|||||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/editorconfig": {
|
"node_modules/editorconfig": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
|
||||||
@@ -23182,6 +23347,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/eventsource-parser": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/execa": {
|
"node_modules/execa": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||||
@@ -23585,6 +23759,16 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/find-yarn-workspace-root": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"micromatch": "^4.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/flat-cache": {
|
"node_modules/flat-cache": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
|
||||||
@@ -24261,6 +24445,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/google-auth-library": {
|
||||||
|
"version": "9.15.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
|
||||||
|
"integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.0",
|
||||||
|
"ecdsa-sig-formatter": "^1.0.11",
|
||||||
|
"gaxios": "^6.1.1",
|
||||||
|
"gcp-metadata": "^6.1.0",
|
||||||
|
"gtoken": "^7.0.0",
|
||||||
|
"jws": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/google-logging-utils": {
|
"node_modules/google-logging-utils": {
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
|
||||||
@@ -24340,6 +24541,19 @@
|
|||||||
"js-yaml": "bin/js-yaml.js"
|
"js-yaml": "bin/js-yaml.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gtoken": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"gaxios": "^6.0.0",
|
||||||
|
"jws": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/h3": {
|
"node_modules/h3": {
|
||||||
"version": "1.15.1",
|
"version": "1.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.1.tgz",
|
||||||
@@ -26285,6 +26499,12 @@
|
|||||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
|
||||||
|
"license": "(AFL-2.1 OR BSD-3-Clause)"
|
||||||
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
@@ -26297,6 +26517,26 @@
|
|||||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-stable-stringify": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind": "^1.0.8",
|
||||||
|
"call-bound": "^1.0.4",
|
||||||
|
"isarray": "^2.0.5",
|
||||||
|
"jsonify": "^0.0.1",
|
||||||
|
"object-keys": "^1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||||
@@ -26333,6 +26573,16 @@
|
|||||||
"graceful-fs": "^4.1.6"
|
"graceful-fs": "^4.1.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonify": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Public Domain",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsonparse": {
|
"node_modules/jsonparse": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
|
||||||
@@ -26375,6 +26625,27 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.0",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/katex": {
|
"node_modules/katex": {
|
||||||
"version": "0.16.25",
|
"version": "0.16.25",
|
||||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.25.tgz",
|
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.25.tgz",
|
||||||
@@ -26423,6 +26694,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/klaw-sync": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.1.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/kleur": {
|
"node_modules/kleur": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||||
@@ -27480,9 +27761,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mdast-util-to-hast": {
|
"node_modules/mdast-util-to-hast": {
|
||||||
"version": "13.2.0",
|
"version": "13.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
|
||||||
"integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
|
"integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/hast": "^3.0.0",
|
"@types/hast": "^3.0.0",
|
||||||
@@ -29197,9 +29478,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "7.0.10",
|
"version": "7.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||||
"integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==",
|
"integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -29582,6 +29863,52 @@
|
|||||||
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
|
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/open": {
|
||||||
|
"version": "7.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
|
||||||
|
"integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-docker": "^2.0.0",
|
||||||
|
"is-wsl": "^2.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/open/node_modules/is-docker": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"is-docker": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/open/node_modules/is-wsl": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-docker": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/openapi3-ts": {
|
"node_modules/openapi3-ts": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-2.0.2.tgz",
|
||||||
@@ -29788,7 +30115,6 @@
|
|||||||
"version": "7.0.4",
|
"version": "7.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
|
||||||
"integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==",
|
"integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -30132,6 +30458,94 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/patch-package": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@yarnpkg/lockfile": "^1.1.0",
|
||||||
|
"chalk": "^4.1.2",
|
||||||
|
"ci-info": "^3.7.0",
|
||||||
|
"cross-spawn": "^7.0.3",
|
||||||
|
"find-yarn-workspace-root": "^2.0.0",
|
||||||
|
"fs-extra": "^10.0.0",
|
||||||
|
"json-stable-stringify": "^1.0.2",
|
||||||
|
"klaw-sync": "^6.0.0",
|
||||||
|
"minimist": "^1.2.6",
|
||||||
|
"open": "^7.4.2",
|
||||||
|
"semver": "^7.5.3",
|
||||||
|
"slash": "^2.0.0",
|
||||||
|
"tmp": "^0.2.4",
|
||||||
|
"yaml": "^2.2.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"patch-package": "index.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14",
|
||||||
|
"npm": ">5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/patch-package/node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/patch-package/node_modules/chalk": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.1.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/patch-package/node_modules/fs-extra": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.0",
|
||||||
|
"jsonfile": "^6.0.1",
|
||||||
|
"universalify": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/patch-package/node_modules/slash": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-data-parser": {
|
"node_modules/path-data-parser": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz",
|
||||||
@@ -35000,6 +35414,16 @@
|
|||||||
"title": "dist/esm/bin.js"
|
"title": "dist/esm/bin.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tmp": {
|
||||||
|
"version": "0.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||||
|
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/to-fast-properties": {
|
"node_modules/to-fast-properties": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
|
||||||
@@ -35950,9 +36374,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/valibot": {
|
"node_modules/valibot": {
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
|
||||||
"integrity": "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==",
|
"integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -37072,6 +37496,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/google-vertex": "3.0.81",
|
||||||
"@aws-sdk/client-s3": "^3.936.0",
|
"@aws-sdk/client-s3": "^3.936.0",
|
||||||
"@aws-sdk/client-sesv2": "^3.936.0",
|
"@aws-sdk/client-sesv2": "^3.936.0",
|
||||||
"@aws-sdk/cloudfront-signer": "^3.935.0",
|
"@aws-sdk/cloudfront-signer": "^3.935.0",
|
||||||
@@ -37084,6 +37509,7 @@
|
|||||||
"@lingui/core": "^5.6.0",
|
"@lingui/core": "^5.6.0",
|
||||||
"@lingui/macro": "^5.6.0",
|
"@lingui/macro": "^5.6.0",
|
||||||
"@lingui/react": "^5.6.0",
|
"@lingui/react": "^5.6.0",
|
||||||
|
"@napi-rs/canvas": "^0.1.82",
|
||||||
"@noble/ciphers": "0.6.0",
|
"@noble/ciphers": "0.6.0",
|
||||||
"@noble/hashes": "1.8.0",
|
"@noble/hashes": "1.8.0",
|
||||||
"@node-rs/bcrypt": "^1.10.7",
|
"@node-rs/bcrypt": "^1.10.7",
|
||||||
@@ -37092,6 +37518,7 @@
|
|||||||
"@sindresorhus/slugify": "^3.0.0",
|
"@sindresorhus/slugify": "^3.0.0",
|
||||||
"@team-plain/typescript-sdk": "^5.11.0",
|
"@team-plain/typescript-sdk": "^5.11.0",
|
||||||
"@vvo/tzdb": "^6.196.0",
|
"@vvo/tzdb": "^6.196.0",
|
||||||
|
"ai": "^5.0.104",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
"inngest": "^3.45.1",
|
"inngest": "^3.45.1",
|
||||||
"jose": "^6.1.2",
|
"jose": "^6.1.2",
|
||||||
@@ -37100,6 +37527,7 @@
|
|||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"oslo": "^0.17.0",
|
"oslo": "^0.17.0",
|
||||||
|
"p-map": "^7.0.4",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"pino": "^9.14.0",
|
"pino": "^9.14.0",
|
||||||
"pino-pretty": "^13.1.2",
|
"pino-pretty": "^13.1.2",
|
||||||
|
|||||||
+5
-1
@@ -7,6 +7,7 @@
|
|||||||
],
|
],
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"postinstall": "patch-package",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"dev": "turbo run dev --filter=@documenso/remix",
|
"dev": "turbo run dev --filter=@documenso/remix",
|
||||||
"dev:remix": "turbo run dev --filter=@documenso/remix",
|
"dev:remix": "turbo run dev --filter=@documenso/remix",
|
||||||
@@ -83,12 +84,15 @@
|
|||||||
"zod-prisma-types": "3.3.5"
|
"zod-prisma-types": "3.3.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/google-vertex": "3.0.81",
|
||||||
"@documenso/pdf-sign": "^0.1.0",
|
"@documenso/pdf-sign": "^0.1.0",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@lingui/conf": "^5.6.0",
|
"@lingui/conf": "^5.6.0",
|
||||||
"@lingui/core": "^5.6.0",
|
"@lingui/core": "^5.6.0",
|
||||||
|
"ai": "^5.0.104",
|
||||||
"inngest-cli": "^1.13.7",
|
"inngest-cli": "^1.13.7",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
|
"patch-package": "^8.0.1",
|
||||||
"posthog-node": "4.18.0",
|
"posthog-node": "4.18.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"typescript": "5.6.2",
|
"typescript": "5.6.2",
|
||||||
@@ -99,4 +103,4 @@
|
|||||||
"typescript": "5.6.2",
|
"typescript": "5.6.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,3 +18,6 @@ export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@docume
|
|||||||
|
|
||||||
export const USE_INTERNAL_URL_BROWSERLESS = () =>
|
export const USE_INTERNAL_URL_BROWSERLESS = () =>
|
||||||
env('NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS') === 'true';
|
env('NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS') === 'true';
|
||||||
|
|
||||||
|
export const IS_AI_FEATURES_CONFIGURED = () =>
|
||||||
|
!!env('GOOGLE_VERTEX_PROJECT_ID') && !!env('GOOGLE_VERTEX_API_KEY');
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"clean": "rimraf node_modules"
|
"clean": "rimraf node_modules"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/google-vertex": "3.0.81",
|
||||||
"@aws-sdk/client-s3": "^3.936.0",
|
"@aws-sdk/client-s3": "^3.936.0",
|
||||||
"@aws-sdk/client-sesv2": "^3.936.0",
|
"@aws-sdk/client-sesv2": "^3.936.0",
|
||||||
"@aws-sdk/cloudfront-signer": "^3.935.0",
|
"@aws-sdk/cloudfront-signer": "^3.935.0",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"@lingui/core": "^5.6.0",
|
"@lingui/core": "^5.6.0",
|
||||||
"@lingui/macro": "^5.6.0",
|
"@lingui/macro": "^5.6.0",
|
||||||
"@lingui/react": "^5.6.0",
|
"@lingui/react": "^5.6.0",
|
||||||
|
"@napi-rs/canvas": "^0.1.82",
|
||||||
"@noble/ciphers": "0.6.0",
|
"@noble/ciphers": "0.6.0",
|
||||||
"@noble/hashes": "1.8.0",
|
"@noble/hashes": "1.8.0",
|
||||||
"@node-rs/bcrypt": "^1.10.7",
|
"@node-rs/bcrypt": "^1.10.7",
|
||||||
@@ -35,6 +37,7 @@
|
|||||||
"@sindresorhus/slugify": "^3.0.0",
|
"@sindresorhus/slugify": "^3.0.0",
|
||||||
"@team-plain/typescript-sdk": "^5.11.0",
|
"@team-plain/typescript-sdk": "^5.11.0",
|
||||||
"@vvo/tzdb": "^6.196.0",
|
"@vvo/tzdb": "^6.196.0",
|
||||||
|
"ai": "^5.0.104",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
"inngest": "^3.45.1",
|
"inngest": "^3.45.1",
|
||||||
"jose": "^6.1.2",
|
"jose": "^6.1.2",
|
||||||
@@ -43,6 +46,7 @@
|
|||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"oslo": "^0.17.0",
|
"oslo": "^0.17.0",
|
||||||
|
"p-map": "^7.0.4",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"pino": "^9.14.0",
|
"pino": "^9.14.0",
|
||||||
"pino-pretty": "^13.1.2",
|
"pino-pretty": "^13.1.2",
|
||||||
@@ -63,4 +67,4 @@
|
|||||||
"@types/luxon": "^3.7.1",
|
"@types/luxon": "^3.7.1",
|
||||||
"@types/pg": "^8.15.6"
|
"@types/pg": "^8.15.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { DetectedField } from './schema';
|
||||||
|
import type { NormalizedField, RecipientContext } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a message providing recipient context to the AI.
|
||||||
|
*/
|
||||||
|
export const buildRecipientContextMessage = (recipients: RecipientContext[]) => {
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
return 'No recipients have been specified for this document. Leave recipientKey empty for all fields.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientList = recipients.map((r) => `- ${formatRecipientKey(r)}`).join('\n');
|
||||||
|
|
||||||
|
return `The following recipients will sign/fill this document. Use their recipientKey when assigning fields:
|
||||||
|
|
||||||
|
${recipientList}
|
||||||
|
|
||||||
|
When you detect a field that should be filled by a specific recipient (based on nearby labels like "Tenant Signature", "Landlord", "Buyer", etc.), set the recipientKey to match one of the above. If no recipient can be determined, leave recipientKey empty.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format recipient key as id|name|email for AI context.
|
||||||
|
*/
|
||||||
|
export const formatRecipientKey = (recipient: RecipientContext) => {
|
||||||
|
return `${recipient.id}|${recipient.name}|${recipient.email}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse recipientKey (format: id|name|email) and find matching recipient.
|
||||||
|
*
|
||||||
|
* Matching logic:
|
||||||
|
* 1. Match on id === id
|
||||||
|
* 2. OR match on email && name === email && name
|
||||||
|
* 3. If no match or empty key, use first recipient
|
||||||
|
* 4. If no recipients, return null (caller creates blank recipient)
|
||||||
|
*/
|
||||||
|
export const resolveRecipientFromKey = (recipientKey: string, recipients: RecipientContext[]) => {
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty key defaults to first recipient
|
||||||
|
if (!recipientKey) {
|
||||||
|
return recipients[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the key format: id|name|email
|
||||||
|
const [idStr, name, email] = recipientKey.split('|');
|
||||||
|
|
||||||
|
const id = Number(idStr);
|
||||||
|
|
||||||
|
// Try to match by ID first
|
||||||
|
if (!Number.isNaN(id)) {
|
||||||
|
const matchById = recipients.find((r) => r.id === id);
|
||||||
|
|
||||||
|
if (matchById) {
|
||||||
|
return matchById;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to match by email AND name
|
||||||
|
if (email && name) {
|
||||||
|
const matchByEmailAndName = recipients.find((r) => r.email === email && r.name === name);
|
||||||
|
|
||||||
|
if (matchByEmailAndName) {
|
||||||
|
return matchByEmailAndName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No match found, default to first recipient
|
||||||
|
return recipients[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert AI's 0-1000 bounding box to our 0-100 percentage format.
|
||||||
|
*/
|
||||||
|
export const normalizeDetectedField = (field: DetectedField): NormalizedField => {
|
||||||
|
const { box2d } = field;
|
||||||
|
|
||||||
|
const [yMin, xMin, yMax, xMax] = box2d;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: field.type,
|
||||||
|
recipientKey: field.recipientKey,
|
||||||
|
positionX: xMin / 10,
|
||||||
|
positionY: yMin / 10,
|
||||||
|
width: (xMax - xMin) / 10,
|
||||||
|
height: (yMax - yMin) / 10,
|
||||||
|
confidence: field.confidence,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { createCanvas, loadImage } from '@napi-rs/canvas';
|
||||||
|
import { DocumentStatus, type Field, RecipientRole } from '@prisma/client';
|
||||||
|
import { generateObject } from 'ai';
|
||||||
|
import pMap from 'p-map';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../../../../errors/app-error';
|
||||||
|
import { getFileServerSide } from '../../../../universal/upload/get-file.server';
|
||||||
|
import { getEnvelopeById } from '../../../envelope/get-envelope-by-id';
|
||||||
|
import { createEnvelopeRecipients } from '../../../recipient/create-envelope-recipients';
|
||||||
|
import { vertex } from '../../google';
|
||||||
|
import { pdfToImages } from '../../pdf-to-images';
|
||||||
|
import {
|
||||||
|
buildRecipientContextMessage,
|
||||||
|
normalizeDetectedField,
|
||||||
|
resolveRecipientFromKey,
|
||||||
|
} from './helpers';
|
||||||
|
import { SYSTEM_PROMPT } from './prompt';
|
||||||
|
import { ZSubmitDetectedFieldsInputSchema } from './schema';
|
||||||
|
import type {
|
||||||
|
NormalizedFieldWithContext,
|
||||||
|
NormalizedFieldWithPage,
|
||||||
|
RecipientContext,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export type DetectFieldsFromEnvelopeOptions = {
|
||||||
|
context?: string;
|
||||||
|
envelopeId: string;
|
||||||
|
userId: number;
|
||||||
|
teamId: number;
|
||||||
|
onProgress?: (progress: DetectFieldsProgress) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const detectFieldsFromEnvelope = async ({
|
||||||
|
context,
|
||||||
|
envelopeId,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
onProgress,
|
||||||
|
}: DetectFieldsFromEnvelopeOptions) => {
|
||||||
|
const envelope = await getEnvelopeById({
|
||||||
|
id: {
|
||||||
|
type: 'envelopeId',
|
||||||
|
id: envelopeId,
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
type: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (envelope.status !== DocumentStatus.DRAFT) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Cannot detect fields for a non-draft envelope',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract recipients for field assignment context
|
||||||
|
const recipients: RecipientContext[] = envelope.recipients.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
email: r.email,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const allFields: NormalizedFieldWithContext[] = [];
|
||||||
|
|
||||||
|
for (const item of envelope.envelopeItems) {
|
||||||
|
const existingFields = await prisma.field.findMany({
|
||||||
|
where: {
|
||||||
|
envelopeItemId: item.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfBytes = await getFileServerSide(item.documentData);
|
||||||
|
const fields = await detectFieldsFromPdf({
|
||||||
|
pdfBytes,
|
||||||
|
existingFields,
|
||||||
|
recipients,
|
||||||
|
context,
|
||||||
|
onProgress,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve recipientKey to actual recipient and add context
|
||||||
|
const fieldsWithContext = await Promise.all(
|
||||||
|
fields.map(async (field) => {
|
||||||
|
const { recipientKey, ...fieldWithoutKey } = field;
|
||||||
|
|
||||||
|
let resolvedRecipient = resolveRecipientFromKey(recipientKey, recipients);
|
||||||
|
|
||||||
|
// If no recipients exist, create a blank recipient
|
||||||
|
if (!resolvedRecipient) {
|
||||||
|
const { recipients: createdRecipients } = await createEnvelopeRecipients({
|
||||||
|
id: {
|
||||||
|
id: envelope.id,
|
||||||
|
type: 'envelopeId',
|
||||||
|
},
|
||||||
|
recipients: [
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
resolvedRecipient = createdRecipients[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...fieldWithoutKey,
|
||||||
|
envelopeItemId: item.id,
|
||||||
|
recipientId: resolvedRecipient.id,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
allFields.push(...fieldsWithContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allFields;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DetectFieldsProgress = {
|
||||||
|
pagesProcessed: number;
|
||||||
|
totalPages: number;
|
||||||
|
fieldsDetected: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DetectFieldsFromPdfOptions = {
|
||||||
|
pdfBytes: Uint8Array;
|
||||||
|
recipients?: RecipientContext[];
|
||||||
|
existingFields?: Field[];
|
||||||
|
context?: string;
|
||||||
|
onProgress?: (progress: DetectFieldsProgress) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const detectFieldsFromPdf = async ({
|
||||||
|
pdfBytes,
|
||||||
|
recipients = [],
|
||||||
|
existingFields = [],
|
||||||
|
context,
|
||||||
|
onProgress,
|
||||||
|
}: DetectFieldsFromPdfOptions) => {
|
||||||
|
const pageImages = await pdfToImages(pdfBytes);
|
||||||
|
|
||||||
|
if (pageImages.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let pagesProcessed = 0;
|
||||||
|
let totalFieldsDetected = 0;
|
||||||
|
|
||||||
|
const results = await pMap(
|
||||||
|
pageImages,
|
||||||
|
async (page) => {
|
||||||
|
// Get existing fields for this page
|
||||||
|
const fieldsOnPage = existingFields.filter((f) => f.page === page.pageNumber);
|
||||||
|
|
||||||
|
// Mask existing fields on the image
|
||||||
|
const maskedImage = await maskFieldsOnImage({
|
||||||
|
image: page.image,
|
||||||
|
width: page.width,
|
||||||
|
height: page.height,
|
||||||
|
fields: fieldsOnPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawFields = await detectFieldsFromPage({
|
||||||
|
image: maskedImage,
|
||||||
|
pageNumber: page.pageNumber,
|
||||||
|
recipients,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert bounding boxes to normalized positions and add page number
|
||||||
|
const normalizedFields = rawFields.map(
|
||||||
|
(field): NormalizedFieldWithPage => ({
|
||||||
|
...normalizeDetectedField(field),
|
||||||
|
pageNumber: page.pageNumber,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
pagesProcessed += 1;
|
||||||
|
totalFieldsDetected += normalizedFields.length;
|
||||||
|
|
||||||
|
onProgress?.({
|
||||||
|
pagesProcessed,
|
||||||
|
totalPages: pageImages.length,
|
||||||
|
fieldsDetected: totalFieldsDetected,
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizedFields;
|
||||||
|
},
|
||||||
|
{ concurrency: 5 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.flat();
|
||||||
|
};
|
||||||
|
|
||||||
|
type MaskFieldsOnImageOptions = {
|
||||||
|
image: Buffer;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
fields: Field[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw black rectangles over existing fields to prevent re-detection.
|
||||||
|
*/
|
||||||
|
const maskFieldsOnImage = async ({ image, width, height, fields }: MaskFieldsOnImageOptions) => {
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = await loadImage(image);
|
||||||
|
const canvas = createCanvas(width, height);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Draw the original image
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
// Draw black rectangles over existing fields
|
||||||
|
ctx.fillStyle = '#000000';
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
// field positions and width,height are on a 0-100 percentage scale
|
||||||
|
const x = (field.positionX.toNumber() / 100) * width;
|
||||||
|
const y = (field.positionY.toNumber() / 100) * height;
|
||||||
|
const w = (field.width.toNumber() / 100) * width;
|
||||||
|
const h = (field.height.toNumber() / 100) * height;
|
||||||
|
|
||||||
|
ctx.fillRect(x, y, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvas.encode('jpeg');
|
||||||
|
};
|
||||||
|
|
||||||
|
const TARGET_SIZE = 1000;
|
||||||
|
|
||||||
|
type ResizeImageOptions = {
|
||||||
|
image: Buffer;
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize image to 1000x1000 using fill strategy.
|
||||||
|
* Scales to cover the target area and crops any overflow.
|
||||||
|
*/
|
||||||
|
const resizeImageToSquare = async ({ image, size = TARGET_SIZE }: ResizeImageOptions) => {
|
||||||
|
return await sharp(image).resize(size, size, { fit: 'fill' }).toBuffer();
|
||||||
|
};
|
||||||
|
|
||||||
|
type DetectFieldsFromPageOptions = {
|
||||||
|
image: Buffer;
|
||||||
|
pageNumber: number;
|
||||||
|
recipients: RecipientContext[];
|
||||||
|
context?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const detectFieldsFromPage = async ({
|
||||||
|
image,
|
||||||
|
pageNumber,
|
||||||
|
recipients,
|
||||||
|
context,
|
||||||
|
}: DetectFieldsFromPageOptions) => {
|
||||||
|
// Resize to 1000x1000 for consistent coordinate mapping
|
||||||
|
const resizedImage = await resizeImageToSquare({ image });
|
||||||
|
|
||||||
|
// Build messages array
|
||||||
|
const messages: Parameters<typeof generateObject>[0]['messages'] = [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: buildRecipientContextMessage(recipients),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add user-provided context if available
|
||||||
|
if (context?.trim()) {
|
||||||
|
messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: `Additional context about recipients:\n${context.trim()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the page analysis request with image
|
||||||
|
messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `Analyze this document page (page ${pageNumber}) and detect all empty fillable fields. Submit the fields using the tool. Remember: only detect EMPTY fields, exclude labels from bounding boxes, use 0-1000 normalized coordinates, and IGNORE any solid black rectangles (those are existing fields).`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
image: resizedImage,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await generateObject({
|
||||||
|
model: vertex('gemini-3-pro-preview'),
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
schema: ZSubmitDetectedFieldsInputSchema,
|
||||||
|
messages,
|
||||||
|
temperature: 0.5,
|
||||||
|
providerOptions: {
|
||||||
|
google: {
|
||||||
|
thinkingConfig: {
|
||||||
|
thinkingLevel: 'low',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.object) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.object.fields ?? [];
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export const SYSTEM_PROMPT = `You are analyzing a form document image to detect fillable fields for a document signing platform.
|
||||||
|
|
||||||
|
IMPORTANT RULES:
|
||||||
|
1. Only detect EMPTY/UNFILLED fields (ignore boxes that already contain text or data)
|
||||||
|
2. Analyze nearby text labels to determine the field type
|
||||||
|
3. Return bounding boxes for the fillable area ONLY, NOT the label text
|
||||||
|
4. Each boundingBox must be in the format {yMin, xMin, yMax, xMax} where all coordinates are NORMALIZED to a 0-1000 scale
|
||||||
|
5. IGNORE any black rectangles on the page - these are existing fields that should not be re-detected
|
||||||
|
6. Only return fields that are clearly fillable and not just labels or instructions.
|
||||||
|
|
||||||
|
CRITICAL: UNDERSTANDING FILLABLE AREAS
|
||||||
|
The "fillable area" is ONLY the empty space where a user will write, type, sign, or check.
|
||||||
|
- ✓ CORRECT: The blank underscore where someone writes their name: "Name: _________" → box ONLY the underscores
|
||||||
|
- ✓ CORRECT: The empty white rectangle inside a box outline → box ONLY the empty space
|
||||||
|
- ✓ CORRECT: The blank space to the right of a label: "Email: [ empty box ]" → box ONLY the empty box
|
||||||
|
- ✗ INCORRECT: Including the word "Signature:" that appears to the left of a signature line
|
||||||
|
- ✗ INCORRECT: Including printed labels, instructions, or descriptive text near the field
|
||||||
|
- ✗ INCORRECT: Extending the box to include text just because it's close to the fillable area
|
||||||
|
- ✗ INCORRECT: Detecting solid black rectangles (these are masked existing fields)
|
||||||
|
|
||||||
|
FIELD TYPES TO DETECT:
|
||||||
|
- SIGNATURE - Signature lines, boxes labeled 'Signature', 'Sign here', 'Authorized signature', 'X____'
|
||||||
|
- INITIALS - Small boxes labeled 'Initials', 'Initial here', typically smaller than signature fields
|
||||||
|
- NAME - Boxes labeled 'Name', 'Full name', 'Your name', 'Print name', 'Printed name'
|
||||||
|
- EMAIL - Boxes labeled 'Email', 'Email address', 'E-mail'
|
||||||
|
- DATE - Boxes labeled 'Date', 'Date signed', or showing date format placeholders like 'MM/DD/YYYY', '__/__/____'
|
||||||
|
- CHECKBOX - Empty checkbox squares (☐) with or without labels, typically small square boxes
|
||||||
|
- RADIO - Empty radio button circles (○) in groups, typically circular selection options
|
||||||
|
- NUMBER - Boxes labeled with numeric context: 'Amount', 'Quantity', 'Phone', 'ZIP', 'Age', 'Price', '#'
|
||||||
|
- TEXT - Any other empty text input boxes, general input fields, or when field type is uncertain
|
||||||
|
|
||||||
|
DETECTION GUIDELINES:
|
||||||
|
- Read text located near the box (above, to the left, or inside) to infer the field type
|
||||||
|
- Use nearby text to CLASSIFY the field type, but DO NOT include that text in the bounding box
|
||||||
|
- If you're uncertain which type fits best, default to TEXT
|
||||||
|
- For checkboxes and radio buttons: Detect each individual box/circle separately, not the label
|
||||||
|
- Signature fields are often longer horizontal lines or larger boxes
|
||||||
|
- Date fields often show format hints or date separators (slashes, dashes)
|
||||||
|
- Look for visual patterns: underscores (____), horizontal lines, box outlines
|
||||||
|
|
||||||
|
BOUNDING BOX PLACEMENT:
|
||||||
|
- Coordinates must capture ONLY the empty fillable space
|
||||||
|
- Once you find the fillable region, LOCK the box to the full boundary (top, bottom, left, right)
|
||||||
|
- If the field is defined by a line or rectangular border, extend coordinates across the entire line/border
|
||||||
|
- EXCLUDE all printed text labels even if they are:
|
||||||
|
- Directly to the left of the field (e.g., "Name: _____")
|
||||||
|
- Directly above the field (e.g., "Signature" printed above a line)
|
||||||
|
- Very close to the field with minimal spacing
|
||||||
|
- The box should never cover only the leftmost few characters of a long field
|
||||||
|
|
||||||
|
COORDINATE SYSTEM:
|
||||||
|
- {yMin, xMin, yMax, xMax} normalized to 0-1000 scale
|
||||||
|
- Top-left corner: yMin and xMin close to 0
|
||||||
|
- Bottom-right corner: yMax and xMax close to 1000
|
||||||
|
- Coordinates represent positions on a 1000x1000 grid overlaid on the image
|
||||||
|
|
||||||
|
FIELD SIZING FOR LINE-BASED FIELDS:
|
||||||
|
When detecting thin horizontal lines for SIGNATURE, INITIALS, NAME, EMAIL, DATE, TEXT, or NUMBER fields:
|
||||||
|
1. Keep yMax (bottom) at the detected line position
|
||||||
|
2. Extend yMin (top) upward into the available whitespace above the line
|
||||||
|
3. Use 60-80% of the clear whitespace above the line for comfortable writing/signing space
|
||||||
|
4. Apply minimum dimensions: height at least 30 units (3% of 1000-scale), width at least 36 units
|
||||||
|
5. Ensure yMin >= 0 (do not go off-page)
|
||||||
|
6. Do NOT apply this expansion to CHECKBOX, RADIO fields - use detected dimensions
|
||||||
|
|
||||||
|
RECIPIENT IDENTIFICATION:
|
||||||
|
- Look for labels near fields indicating who should fill them (e.g., "Tenant Signature", "Landlord", "Buyer")
|
||||||
|
- Use the recipientKey field to indicate which recipient should fill the field
|
||||||
|
- If a field has no clear recipient label, leave recipientKey empty`;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { FieldType } from '@prisma/client';
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
export const DETECTABLE_FIELD_TYPES = [
|
||||||
|
FieldType.SIGNATURE,
|
||||||
|
FieldType.INITIALS,
|
||||||
|
FieldType.NAME,
|
||||||
|
FieldType.EMAIL,
|
||||||
|
FieldType.DATE,
|
||||||
|
FieldType.TEXT,
|
||||||
|
FieldType.NUMBER,
|
||||||
|
FieldType.RADIO,
|
||||||
|
FieldType.CHECKBOX,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ZDetectableFieldType = z.enum(DETECTABLE_FIELD_TYPES);
|
||||||
|
|
||||||
|
export const ZConfidenceLevel = z.enum(['low', 'medium-low', 'medium', 'medium-high', 'high']);
|
||||||
|
|
||||||
|
export type TConfidenceLevel = z.infer<typeof ZConfidenceLevel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for a detected field's bounding box.
|
||||||
|
* All values are normalized to a 0-1000 scale relative to the page dimensions.
|
||||||
|
*/
|
||||||
|
const ZBox2DSchema = z.array(z.number().min(0).max(1000)).length(4);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for a detected field.
|
||||||
|
*/
|
||||||
|
export const ZDetectedFieldSchema = z.object({
|
||||||
|
type: ZDetectableFieldType.describe(
|
||||||
|
`The field type based on nearby labels and visual appearance`,
|
||||||
|
),
|
||||||
|
recipientKey: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
'Recipient identifier from nearby labels (e.g., "Tenant", "Landlord", "Buyer", "Seller"). Empty string if no recipient indicated.',
|
||||||
|
),
|
||||||
|
box2d: ZBox2DSchema.describe(
|
||||||
|
'Box2D [yMin, xMin, yMax, xMax] coordinates of the FILLABLE AREA only (exclude labels).',
|
||||||
|
),
|
||||||
|
confidence: ZConfidenceLevel.describe('The confidence in the detection'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DetectedField = z.infer<typeof ZDetectedFieldSchema>;
|
||||||
|
|
||||||
|
export const ZSubmitDetectedFieldsInputSchema = z.object({
|
||||||
|
fields: z
|
||||||
|
.array(ZDetectedFieldSchema)
|
||||||
|
.describe('List of detected EMPTY fillable fields. Exclude pre-filled content and label text.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SubmitDetectedFieldsInput = z.infer<typeof ZSubmitDetectedFieldsInputSchema>;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Recipient } from '@prisma/client';
|
||||||
|
|
||||||
|
import type { DETECTABLE_FIELD_TYPES, TConfidenceLevel } from './schema';
|
||||||
|
|
||||||
|
export type DetectableFieldType = (typeof DETECTABLE_FIELD_TYPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized field position using 0-100 percentage scale (matching Field model).
|
||||||
|
*/
|
||||||
|
export type NormalizedField = {
|
||||||
|
type: DetectableFieldType;
|
||||||
|
recipientKey: string;
|
||||||
|
positionX: number;
|
||||||
|
positionY: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
confidence: TConfidenceLevel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecipientContext = Pick<Recipient, 'id' | 'name' | 'email'>;
|
||||||
|
|
||||||
|
export type NormalizedFieldWithPage = NormalizedField & {
|
||||||
|
pageNumber: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NormalizedFieldWithContext = Omit<NormalizedField, 'recipientKey'> & {
|
||||||
|
pageNumber: number;
|
||||||
|
envelopeItemId: string;
|
||||||
|
recipientId: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { DocumentStatus } from '@prisma/client';
|
||||||
|
import type { ImagePart, ModelMessage } from 'ai';
|
||||||
|
import { generateObject } from 'ai';
|
||||||
|
import { chunk } from 'remeda';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../../../../errors/app-error';
|
||||||
|
import { getFileServerSide } from '../../../../universal/upload/get-file.server';
|
||||||
|
import { getEnvelopeById } from '../../../envelope/get-envelope-by-id';
|
||||||
|
import { vertex } from '../../google';
|
||||||
|
import { pdfToImages } from '../../pdf-to-images';
|
||||||
|
import { SYSTEM_PROMPT } from './prompt';
|
||||||
|
import type { TDetectedRecipientSchema } from './schema';
|
||||||
|
import { ZDetectedRecipientsSchema } from './schema';
|
||||||
|
|
||||||
|
const MAX_PAGES_PER_CHUNK = 10;
|
||||||
|
|
||||||
|
const createImageContentParts = (images: Buffer[]) => {
|
||||||
|
return images.map<ImagePart>((image) => ({
|
||||||
|
type: 'image',
|
||||||
|
image,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DetectRecipientsProgress = {
|
||||||
|
pagesProcessed: number;
|
||||||
|
totalPages: number;
|
||||||
|
recipientsDetected: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DetectRecipientsFromEnvelopeOptions = {
|
||||||
|
envelopeId: string;
|
||||||
|
userId: number;
|
||||||
|
teamId: number;
|
||||||
|
onProgress?: (progress: DetectRecipientsProgress) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const detectRecipientsFromEnvelope = async ({
|
||||||
|
envelopeId,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
onProgress,
|
||||||
|
}: DetectRecipientsFromEnvelopeOptions) => {
|
||||||
|
const envelope = await getEnvelopeById({
|
||||||
|
id: {
|
||||||
|
type: 'envelopeId',
|
||||||
|
id: envelopeId,
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
type: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (envelope.status === DocumentStatus.COMPLETED) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Cannot detect recipients for a completed envelope',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let allRecipients: TDetectedRecipientSchema[] = [];
|
||||||
|
|
||||||
|
for (const item of envelope.envelopeItems) {
|
||||||
|
const pdfBytes = await getFileServerSide(item.documentData);
|
||||||
|
const recipients = await detectRecipientsFromPdf({ pdfBytes, onProgress });
|
||||||
|
|
||||||
|
allRecipients = mergeRecipients(allRecipients, recipients);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allRecipients;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DetectRecipientsFromPdfOptions = {
|
||||||
|
pdfBytes: Uint8Array;
|
||||||
|
onProgress?: (progress: DetectRecipientsProgress) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const detectRecipientsFromPdf = async ({
|
||||||
|
pdfBytes,
|
||||||
|
onProgress,
|
||||||
|
}: DetectRecipientsFromPdfOptions) => {
|
||||||
|
const pageImages = await pdfToImages(pdfBytes);
|
||||||
|
|
||||||
|
if (pageImages.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const images = pageImages.map((p) => p.image);
|
||||||
|
|
||||||
|
return await detectRecipientsFromImages({ images, onProgress });
|
||||||
|
};
|
||||||
|
|
||||||
|
type DetectRecipientsFromImagesOptions = {
|
||||||
|
images: Buffer[];
|
||||||
|
onProgress?: (progress: DetectRecipientsProgress) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDetectedRecipients = (recipients: TDetectedRecipientSchema[]) => {
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = recipients
|
||||||
|
.map((r, i) => `${i + 1}. ${r.name || '(no name)'} - ${r.email || '(no email)'} - ${r.role}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return `\n\nRecipients detected so far:\n${formatted}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDuplicateRecipient = (
|
||||||
|
recipient: TDetectedRecipientSchema,
|
||||||
|
existing: TDetectedRecipientSchema,
|
||||||
|
) => {
|
||||||
|
if (recipient.email && existing.email) {
|
||||||
|
return recipient.email.toLowerCase() === existing.email.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipient.name && existing.name) {
|
||||||
|
return recipient.name.toLowerCase() === existing.name.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeRecipients = (
|
||||||
|
existingRecipients: TDetectedRecipientSchema[],
|
||||||
|
newRecipients: TDetectedRecipientSchema[],
|
||||||
|
) => {
|
||||||
|
const merged = [...existingRecipients];
|
||||||
|
|
||||||
|
for (const recipient of newRecipients) {
|
||||||
|
const isDuplicate = merged.some((existing) => isDuplicateRecipient(recipient, existing));
|
||||||
|
|
||||||
|
if (!isDuplicate) {
|
||||||
|
merged.push(recipient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPromptText = (options: {
|
||||||
|
chunkIndex: number;
|
||||||
|
totalChunks: number;
|
||||||
|
totalPages: number;
|
||||||
|
startPage: number;
|
||||||
|
endPage: number;
|
||||||
|
detectedRecipients: TDetectedRecipientSchema[];
|
||||||
|
}) => {
|
||||||
|
const { chunkIndex, totalChunks, totalPages, startPage, endPage, detectedRecipients } = options;
|
||||||
|
|
||||||
|
const isFirstChunk = chunkIndex === 0;
|
||||||
|
const isSingleChunk = totalChunks === 1;
|
||||||
|
const batchNumber = chunkIndex + 1;
|
||||||
|
const previouslyFoundText = formatDetectedRecipients(detectedRecipients);
|
||||||
|
|
||||||
|
if (isSingleChunk) {
|
||||||
|
return `Please analyze these ${totalPages} document page(s) and detect all recipients. Submit all detected recipients using the tool.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFirstChunk) {
|
||||||
|
return `This is a ${totalPages}-page document. I'll show you the pages in batches of ${MAX_PAGES_PER_CHUNK}.
|
||||||
|
|
||||||
|
Here are pages ${startPage}-${endPage} (batch ${batchNumber} of ${totalChunks}).
|
||||||
|
|
||||||
|
Please analyze these pages and submit any recipients you find using the tool. I will show you the remaining pages after.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Here are pages ${startPage}-${endPage} (batch ${batchNumber} of ${totalChunks}).${previouslyFoundText}
|
||||||
|
|
||||||
|
Please analyze these pages and submit any NEW recipients you find (not already listed above) using the tool.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const detectRecipientsFromImages = async ({
|
||||||
|
images,
|
||||||
|
onProgress,
|
||||||
|
}: DetectRecipientsFromImagesOptions) => {
|
||||||
|
const imageChunks = chunk(images, MAX_PAGES_PER_CHUNK);
|
||||||
|
|
||||||
|
const totalChunks = imageChunks.length;
|
||||||
|
const totalPages = images.length;
|
||||||
|
|
||||||
|
const messages: ModelMessage[] = [];
|
||||||
|
let allRecipients: TDetectedRecipientSchema[] = [];
|
||||||
|
|
||||||
|
for (const [chunkIndex, currentChunk] of imageChunks.entries()) {
|
||||||
|
const startPage = chunkIndex * MAX_PAGES_PER_CHUNK + 1;
|
||||||
|
const endPage = startPage + currentChunk.length - 1;
|
||||||
|
|
||||||
|
const promptText = buildPromptText({
|
||||||
|
chunkIndex,
|
||||||
|
totalChunks,
|
||||||
|
totalPages,
|
||||||
|
startPage,
|
||||||
|
endPage,
|
||||||
|
detectedRecipients: allRecipients,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add user message with images for this chunk
|
||||||
|
messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: promptText,
|
||||||
|
},
|
||||||
|
...createImageContentParts(currentChunk),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await generateObject({
|
||||||
|
model: vertex('gemini-2.5-flash'),
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
schema: ZDetectedRecipientsSchema,
|
||||||
|
messages,
|
||||||
|
temperature: 0.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newRecipients = result.object?.recipients ?? [];
|
||||||
|
|
||||||
|
// Merge new recipients into our accumulated list (handles duplicates)
|
||||||
|
allRecipients = mergeRecipients(allRecipients, newRecipients);
|
||||||
|
|
||||||
|
// Report progress (endPage represents pages processed so far)
|
||||||
|
onProgress?.({
|
||||||
|
pagesProcessed: endPage,
|
||||||
|
totalPages,
|
||||||
|
recipientsDetected: allRecipients.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add assistant response as context for next iteration
|
||||||
|
messages.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: `Detected recipients: ${JSON.stringify(allRecipients)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return allRecipients;
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export const SYSTEM_PROMPT = `You are analyzing a document to identify recipients who need to sign, approve, or receive copies.
|
||||||
|
|
||||||
|
TASK: Extract recipient information from this document.
|
||||||
|
|
||||||
|
RECIPIENT TYPES:
|
||||||
|
- SIGNER: People who must sign the document (look for signature lines, "Signed by:", "Signature:", "X____")
|
||||||
|
- APPROVER: People who must review/approve before signing (look for "Approved by:", "Reviewed by:", "Approval:")
|
||||||
|
- VIEWER: People who need to view the document (look for "Viewed by:", "View:", "Viewer:")
|
||||||
|
- CC: People who receive a copy for information only (look for "CC:", "Copy to:", "For information:")
|
||||||
|
|
||||||
|
EXTRACTION RULES:
|
||||||
|
1. Look for signature lines with names printed above, below, or near them
|
||||||
|
2. Check for explicit labels like "Name:", "Signer:", "Party:", "Recipient:"
|
||||||
|
3. Look for "Approved by:", "Reviewed by:", "CC:" sections
|
||||||
|
4. Extract FULL NAMES as they appear in the document
|
||||||
|
5. If the name is a placeholder name, reformat it to a more readable format (e.g. "[Insert signer A name]" -> "Signer A").
|
||||||
|
6. If an email address is visible near a name, include it exactly in the "email" field
|
||||||
|
7. If NO email is found, leave the email field empty.
|
||||||
|
8. If the email is a placeholder email, leave the email field empty.
|
||||||
|
9. Assign signing order based on document flow (numbered items, "First signer:", "Second signer:", or top-to-bottom sequence)
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Only extract recipients explicitly mentioned in the document
|
||||||
|
- Default role is SIGNER if unclear (signature lines = SIGNER)
|
||||||
|
- Signing order starts at 1 (first signer = 1, second = 2, etc.)
|
||||||
|
- If no clear ordering, omit signingOrder
|
||||||
|
- Do NOT invent recipients - only extract what's clearly present
|
||||||
|
- If a signature line exists but no name is associated with it use an empty name and the email address (if found) of the signer.
|
||||||
|
- Do not use placeholder names like "<UNKNOWN>", "Unknown", "Signer" unless they are explicitly mentioned in the document.
|
||||||
|
|
||||||
|
EXAMPLES:
|
||||||
|
Good:
|
||||||
|
- "Signed: _________ John Doe" → { name: "John Doe", role: "SIGNER", signingOrder: 1 }
|
||||||
|
- "Approved by: Jane Smith (jane@example.com)" → { name: "Jane Smith", email: "jane@example.com", role: "APPROVER" }
|
||||||
|
- "CC: Legal Team" → { name: "Legal Team", role: "CC" }
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
- Extracting the document title as a recipient name
|
||||||
|
- Making up email addresses that aren't in the document
|
||||||
|
- Adding people not mentioned in the document
|
||||||
|
- Using placeholder names like "<UNKNOWN>", "Unknown", "Signer" unless they are explicitly mentioned in the document.`;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { RecipientRole } from '@prisma/client';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZDetectedRecipientSchema = z.object({
|
||||||
|
name: z.string().describe('The detected recipient name, leave blank if unknown'),
|
||||||
|
email: z.string().describe('The detected recipient email, leave blank if unknown'),
|
||||||
|
role: z
|
||||||
|
.nativeEnum(RecipientRole)
|
||||||
|
.optional()
|
||||||
|
.default(RecipientRole.SIGNER)
|
||||||
|
.describe(
|
||||||
|
'The detected recipient role. Use SIGNER for people who need to sign, APPROVER for approvers, CC for people who should receive a copy, VIEWER for view-only recipients',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TDetectedRecipientSchema = z.infer<typeof ZDetectedRecipientSchema>;
|
||||||
|
|
||||||
|
export const ZDetectedRecipientsSchema = z.object({
|
||||||
|
recipients: z
|
||||||
|
.array(ZDetectedRecipientSchema)
|
||||||
|
.describe('The list of detected recipients from the document'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TDetectedRecipientsSchema = z.infer<typeof ZDetectedRecipientsSchema>;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { createVertex } from '@ai-sdk/google-vertex';
|
||||||
|
|
||||||
|
import { env } from '../../utils/env';
|
||||||
|
|
||||||
|
export const vertex = createVertex({
|
||||||
|
project: env('GOOGLE_VERTEX_PROJECT_ID'),
|
||||||
|
location: env('GOOGLE_VERTEX_LOCATION') || 'global',
|
||||||
|
apiKey: env('GOOGLE_VERTEX_API_KEY'),
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from 'ai';
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createCanvas } from '@napi-rs/canvas';
|
||||||
|
import pMap from 'p-map';
|
||||||
|
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||||
|
|
||||||
|
export type PdfToImagesOptions = {
|
||||||
|
scale?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOptions = {}) => {
|
||||||
|
const { scale = 2 } = options;
|
||||||
|
|
||||||
|
const pdf = await pdfjsLib.getDocument({ data: pdfBytes }).promise;
|
||||||
|
|
||||||
|
return await pMap(
|
||||||
|
Array.from({ length: pdf.numPages }),
|
||||||
|
async (_, index) => {
|
||||||
|
const pageNumber = index + 1;
|
||||||
|
const page = await pdf.getPage(pageNumber);
|
||||||
|
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
|
||||||
|
const canvas = createCanvas(viewport.width, viewport.height);
|
||||||
|
|
||||||
|
await page.render({
|
||||||
|
// @ts-expect-error napi-rs/canvas satifies the requirements
|
||||||
|
canvas,
|
||||||
|
viewport,
|
||||||
|
}).promise;
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageNumber,
|
||||||
|
image: await canvas.encode('jpeg'),
|
||||||
|
width: Math.floor(viewport.width),
|
||||||
|
height: Math.floor(viewport.height),
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ concurrency: 10 },
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -27,7 +27,7 @@ export interface CreateEnvelopeRecipientsOptions {
|
|||||||
accessAuth?: TRecipientAccessAuthTypes[];
|
accessAuth?: TRecipientAccessAuthTypes[];
|
||||||
actionAuth?: TRecipientActionAuthTypes[];
|
actionAuth?: TRecipientActionAuthTypes[];
|
||||||
}[];
|
}[];
|
||||||
requestMetadata: ApiRequestMetadata;
|
requestMetadata?: ApiRequestMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createEnvelopeRecipients = async ({
|
export const createEnvelopeRecipients = async ({
|
||||||
|
|||||||
@@ -135,5 +135,7 @@ export const generateDefaultOrganisationSettings = (): Omit<
|
|||||||
emailReplyTo: null,
|
emailReplyTo: null,
|
||||||
// emailReplyToName: null,
|
// emailReplyToName: null,
|
||||||
emailDocumentSettings: DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
emailDocumentSettings: DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
||||||
|
|
||||||
|
aiFeaturesEnabled: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
|
|||||||
emailId: null,
|
emailId: null,
|
||||||
emailReplyTo: null,
|
emailReplyTo: null,
|
||||||
// emailReplyToName: null,
|
// emailReplyToName: null,
|
||||||
|
|
||||||
|
aiFeaturesEnabled: null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "aiFeaturesEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "aiFeaturesEnabled" BOOLEAN;
|
||||||
|
|
||||||
@@ -834,6 +834,9 @@ model OrganisationGlobalSettings {
|
|||||||
brandingLogo String @default("")
|
brandingLogo String @default("")
|
||||||
brandingUrl String @default("")
|
brandingUrl String @default("")
|
||||||
brandingCompanyDetails String @default("")
|
brandingCompanyDetails String @default("")
|
||||||
|
|
||||||
|
// AI features settings.
|
||||||
|
aiFeaturesEnabled Boolean @default(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
|
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
|
||||||
@@ -865,6 +868,9 @@ model TeamGlobalSettings {
|
|||||||
brandingLogo String?
|
brandingLogo String?
|
||||||
brandingUrl String?
|
brandingUrl String?
|
||||||
brandingCompanyDetails String?
|
brandingCompanyDetails String?
|
||||||
|
|
||||||
|
// AI features settings.
|
||||||
|
aiFeaturesEnabled Boolean?
|
||||||
}
|
}
|
||||||
|
|
||||||
model Team {
|
model Team {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
|
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
|
||||||
import { buildTeamWhereQuery, getHighestTeamRoleInGroup } from '@documenso/lib/utils/teams';
|
import {
|
||||||
|
buildTeamWhereQuery,
|
||||||
|
extractDerivedTeamSettings,
|
||||||
|
getHighestTeamRoleInGroup,
|
||||||
|
} from '@documenso/lib/utils/teams';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { authenticatedProcedure } from '../trpc';
|
import { authenticatedProcedure } from '../trpc';
|
||||||
@@ -30,6 +34,7 @@ export const getOrganisationSession = async ({
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
organisationClaim: true,
|
organisationClaim: true,
|
||||||
|
organisationGlobalSettings: true,
|
||||||
subscription: true,
|
subscription: true,
|
||||||
groups: {
|
groups: {
|
||||||
where: {
|
where: {
|
||||||
@@ -45,6 +50,7 @@ export const getOrganisationSession = async ({
|
|||||||
teams: {
|
teams: {
|
||||||
where: buildTeamWhereQuery({ teamId: undefined, userId }),
|
where: buildTeamWhereQuery({ teamId: undefined, userId }),
|
||||||
include: {
|
include: {
|
||||||
|
teamGlobalSettings: true,
|
||||||
teamGroups: {
|
teamGroups: {
|
||||||
where: {
|
where: {
|
||||||
organisationGroup: {
|
organisationGroup: {
|
||||||
@@ -67,12 +73,24 @@ export const getOrganisationSession = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return organisations.map((organisation) => {
|
return organisations.map((organisation) => {
|
||||||
|
const { organisationGlobalSettings } = organisation;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...organisation,
|
...organisation,
|
||||||
teams: organisation.teams.map((team) => ({
|
teams: organisation.teams.map((team) => {
|
||||||
...team,
|
const derivedSettings = extractDerivedTeamSettings(
|
||||||
currentTeamRole: getHighestTeamRoleInGroup(team.teamGroups),
|
organisationGlobalSettings,
|
||||||
})),
|
team.teamGlobalSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...team,
|
||||||
|
currentTeamRole: getHighestTeamRoleInGroup(team.teamGroups),
|
||||||
|
preferences: {
|
||||||
|
aiFeaturesEnabled: derivedSettings.aiFeaturesEnabled,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
currentOrganisationRole: getHighestOrganisationRoleInGroup(organisation.groups),
|
currentOrganisationRole: getHighestOrganisationRoleInGroup(organisation.groups),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export const ZGetOrganisationSessionResponseSchema = ZOrganisationSchema.extend(
|
|||||||
organisationId: true,
|
organisationId: true,
|
||||||
}).extend({
|
}).extend({
|
||||||
currentTeamRole: z.nativeEnum(TeamMemberRole),
|
currentTeamRole: z.nativeEnum(TeamMemberRole),
|
||||||
|
preferences: z.object({
|
||||||
|
aiFeaturesEnabled: z.boolean(),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
subscription: SubscriptionSchema.nullable(),
|
subscription: SubscriptionSchema.nullable(),
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
|||||||
emailReplyTo,
|
emailReplyTo,
|
||||||
// emailReplyToName,
|
// emailReplyToName,
|
||||||
emailDocumentSettings,
|
emailDocumentSettings,
|
||||||
|
|
||||||
|
// AI features settings.
|
||||||
|
aiFeaturesEnabled,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
if (Object.values(data).length === 0) {
|
if (Object.values(data).length === 0) {
|
||||||
@@ -149,6 +152,9 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
|||||||
emailReplyTo,
|
emailReplyTo,
|
||||||
// emailReplyToName,
|
// emailReplyToName,
|
||||||
emailDocumentSettings,
|
emailDocumentSettings,
|
||||||
|
|
||||||
|
// AI features settings.
|
||||||
|
aiFeaturesEnabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
|
|||||||
emailReplyTo: z.string().email().nullish(),
|
emailReplyTo: z.string().email().nullish(),
|
||||||
// emailReplyToName: z.string().optional(),
|
// emailReplyToName: z.string().optional(),
|
||||||
emailDocumentSettings: ZDocumentEmailSettingsSchema.optional(),
|
emailDocumentSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||||
|
|
||||||
|
// AI features settings.
|
||||||
|
aiFeaturesEnabled: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
|||||||
emailReplyTo,
|
emailReplyTo,
|
||||||
// emailReplyToName,
|
// emailReplyToName,
|
||||||
emailDocumentSettings,
|
emailDocumentSettings,
|
||||||
|
|
||||||
|
// AI features settings.
|
||||||
|
aiFeaturesEnabled,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
if (Object.values(data).length === 0) {
|
if (Object.values(data).length === 0) {
|
||||||
@@ -160,6 +163,9 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
|||||||
// emailReplyToName,
|
// emailReplyToName,
|
||||||
emailDocumentSettings:
|
emailDocumentSettings:
|
||||||
emailDocumentSettings === null ? Prisma.DbNull : emailDocumentSettings,
|
emailDocumentSettings === null ? Prisma.DbNull : emailDocumentSettings,
|
||||||
|
|
||||||
|
// AI features settings.
|
||||||
|
aiFeaturesEnabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
|
|||||||
emailReplyTo: z.string().email().nullish(),
|
emailReplyTo: z.string().email().nullish(),
|
||||||
// emailReplyToName: z.string().nullish(),
|
// emailReplyToName: z.string().nullish(),
|
||||||
emailDocumentSettings: ZDocumentEmailSettingsSchema.nullish(),
|
emailDocumentSettings: ZDocumentEmailSettingsSchema.nullish(),
|
||||||
|
|
||||||
|
// AI features settings.
|
||||||
|
aiFeaturesEnabled: z.boolean().nullish(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Vendored
+7
@@ -86,5 +86,12 @@ declare namespace NodeJS {
|
|||||||
DATABASE_URL?: string;
|
DATABASE_URL?: string;
|
||||||
POSTGRES_PRISMA_URL?: string;
|
POSTGRES_PRISMA_URL?: string;
|
||||||
POSTGRES_URL_NON_POOLING?: string;
|
POSTGRES_URL_NON_POOLING?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Vertex AI environment variables
|
||||||
|
*/
|
||||||
|
GOOGLE_VERTEX_PROJECT_ID?: string;
|
||||||
|
GOOGLE_VERTEX_LOCATION?: string;
|
||||||
|
GOOGLE_VERTEX_API_KEY?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+24
-7
@@ -2,12 +2,20 @@
|
|||||||
"$schema": "https://turbo.build/schema.json",
|
"$schema": "https://turbo.build/schema.json",
|
||||||
"pipeline": {
|
"pipeline": {
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": ["prebuild", "^build"],
|
"dependsOn": [
|
||||||
"outputs": [".next/**", "!.next/cache/**"]
|
"prebuild",
|
||||||
|
"^build"
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
".next/**",
|
||||||
|
"!.next/cache/**"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"prebuild": {
|
"prebuild": {
|
||||||
"cache": false,
|
"cache": false,
|
||||||
"dependsOn": ["^prebuild"]
|
"dependsOn": [
|
||||||
|
"^prebuild"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"cache": false
|
"cache": false
|
||||||
@@ -23,7 +31,9 @@
|
|||||||
"persistent": true
|
"persistent": true
|
||||||
},
|
},
|
||||||
"start": {
|
"start": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": [
|
||||||
|
"^build"
|
||||||
|
],
|
||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": true
|
"persistent": true
|
||||||
},
|
},
|
||||||
@@ -31,11 +41,15 @@
|
|||||||
"cache": false
|
"cache": false
|
||||||
},
|
},
|
||||||
"test:e2e": {
|
"test:e2e": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": [
|
||||||
|
"^build"
|
||||||
|
],
|
||||||
"cache": false
|
"cache": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalDependencies": ["**/.env.*local"],
|
"globalDependencies": [
|
||||||
|
"**/.env.*local"
|
||||||
|
],
|
||||||
"globalEnv": [
|
"globalEnv": [
|
||||||
"APP_VERSION",
|
"APP_VERSION",
|
||||||
"PORT",
|
"PORT",
|
||||||
@@ -112,6 +126,9 @@
|
|||||||
"NEXT_PRIVATE_TELEMETRY_KEY",
|
"NEXT_PRIVATE_TELEMETRY_KEY",
|
||||||
"NEXT_PRIVATE_TELEMETRY_HOST",
|
"NEXT_PRIVATE_TELEMETRY_HOST",
|
||||||
"DOCUMENSO_DISABLE_TELEMETRY",
|
"DOCUMENSO_DISABLE_TELEMETRY",
|
||||||
|
"GOOGLE_VERTEX_PROJECT_ID",
|
||||||
|
"GOOGLE_VERTEX_LOCATION",
|
||||||
|
"GOOGLE_VERTEX_API_KEY",
|
||||||
"CI",
|
"CI",
|
||||||
"NODE_ENV",
|
"NODE_ENV",
|
||||||
"POSTGRES_URL",
|
"POSTGRES_URL",
|
||||||
@@ -126,4 +143,4 @@
|
|||||||
"NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS",
|
"NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS",
|
||||||
"NEXT_PRIVATE_OIDC_PROMPT"
|
"NEXT_PRIVATE_OIDC_PROMPT"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user