diff --git a/.env.example b/.env.example index c02f079c2..9f842a7f9 100644 --- a/.env.example +++ b/.env.example @@ -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. 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_TEST_AUTHENTICATE_USERNAME="Test User" E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com" @@ -157,4 +166,4 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123" NEXT_PRIVATE_LOGGER_FILE_PATH= # [[PLAIN SUPPORT]] -NEXT_PRIVATE_PLAIN_API_KEY= \ No newline at end of file +NEXT_PRIVATE_PLAIN_API_KEY= diff --git a/.gitignore b/.gitignore index 9e622a76f..e85bd9780 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ CLAUDE.md # agents .specs + +# scripts +scripts/output* diff --git a/apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx b/apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx new file mode 100644 index 000000000..a4798a5f7 --- /dev/null +++ b/apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx @@ -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 = { + 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('PROMPT'); + const [messageIndex, setMessageIndex] = useState(0); + const [detectedFields, setDetectedFields] = useState([]); + const [error, setError] = useState(null); + const [context, setContext] = useState(''); + const [progress, setProgress] = useState(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 = {}; + + 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 ( + + + {state === 'PROMPT' && ( + <> + + + Detect fields + + + +
+

+ + 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. + +

+ + + + + + Your document is processed securely using AI services that don't retain your + data. + + + + +
+ +