From 35af2d74c39df51d7b476e3e6468485262d4f7e3 Mon Sep 17 00:00:00 2001 From: Rafael Chen Date: Tue, 17 Feb 2026 23:28:16 -0500 Subject: [PATCH 01/49] feat(rewrite): add Rewrite tab with document editor and AI assistant - Add rewrite view mode and Rewrite tab in sidebar (PenLine icon) - RewriteDiffView: document interface + AI assistant, save to documents API, currently stored in the Document Generator tab - DocumentGeneratorEditor: rewrite mode (no tool palette, simplified header) --- .../components/DocumentGeneratorEditor.tsx | 89 +++++++++++-------- .../components/DocumentViewerShell.tsx | 6 ++ .../documents/components/RewriteDiffView.tsx | 60 +++++++++++++ .../employer/documents/components/Sidebar.tsx | 28 ++++++ src/app/employer/documents/types/common.ts | 23 ++--- 5 files changed, 159 insertions(+), 47 deletions(-) create mode 100644 src/app/employer/documents/components/RewriteDiffView.tsx diff --git a/src/app/employer/documents/components/DocumentGeneratorEditor.tsx b/src/app/employer/documents/components/DocumentGeneratorEditor.tsx index 1cd80fb7..86c0dfe8 100644 --- a/src/app/employer/documents/components/DocumentGeneratorEditor.tsx +++ b/src/app/employer/documents/components/DocumentGeneratorEditor.tsx @@ -44,6 +44,7 @@ interface DocumentGeneratorEditorProps { documentId?: number; onBack: () => void; onSave: (title: string, content: string, citations?: Citation[]) => void; + mode?: 'full' | 'rewrite'; } export function DocumentGeneratorEditor({ @@ -52,8 +53,10 @@ export function DocumentGeneratorEditor({ initialCitations = [], documentId: _documentId, onBack, - onSave + onSave, + mode = 'full', }: DocumentGeneratorEditorProps) { + const isRewriteMode = mode === 'rewrite'; // Core state const [title, setTitle] = useState(initialTitle); const [content, setContent] = useState(initialContent); @@ -77,20 +80,24 @@ export function DocumentGeneratorEditor({ const contentRef = useRef(null); - // Auto-save - const handleSave = useCallback(() => { + // Auto-save (supports async onSave e.g. for Rewrite tab saving to API) + const handleSave = useCallback(async () => { setIsSaving(true); - onSave(title, content, citations); - setLastSaved(new Date()); - setIsSaving(false); + try { + await Promise.resolve(onSave(title, content, citations)); + setLastSaved(new Date()); + } finally { + setIsSaving(false); + } }, [onSave, title, content, citations]); useEffect(() => { + if (isRewriteMode) return; const interval = setInterval(() => { void handleSave(); }, 30000); return () => clearInterval(interval); - }, [handleSave]); + }, [handleSave, isRewriteMode]); // Keyboard shortcuts useEffect(() => { @@ -317,41 +324,49 @@ export function DocumentGeneratorEditor({ return (
- {/* Tool Palette - Collapsible */} - - 0} - isCollapsed={isToolPaletteCollapsed} - onToggleCollapse={() => setIsToolPaletteCollapsed(!isToolPaletteCollapsed)} - /> - - + {} + {!isRewriteMode && ( + <> + + 0} + isCollapsed={isToolPaletteCollapsed} + onToggleCollapse={() => setIsToolPaletteCollapsed(!isToolPaletteCollapsed)} + /> + + + + )} {/* Main Editor */} - +
{/* Toolbar */}
{/* Top Bar */}
- -
+ {!isRewriteMode && ( + <> + +
+ + )} setTitle(e.target.value)} className="border-0 focus-visible:ring-0 font-medium text-lg px-2 bg-transparent text-foreground max-w-[300px]" - placeholder="Untitled Document" + placeholder={isRewriteMode ? "Add a title (optional)" : "Untitled Document"} />
@@ -373,7 +388,7 @@ export function DocumentGeneratorEditor({ ) : ( )} - Save + {isRewriteMode ? "Save to Documents" : "Save"}
@@ -424,7 +439,7 @@ export function DocumentGeneratorEditor({ onChange={(e) => setContent(e.target.value)} onSelect={handleTextSelection} className="w-full min-h-[900px] border-0 focus-visible:ring-0 resize-none text-base leading-relaxed bg-transparent text-foreground" - placeholder="Start writing or use the AI tools to help you..." + placeholder={isRewriteMode ? "Paste or type text here, then select and use the AI panel to rewrite..." : "Start writing or use the AI tools to help you..."} style={{ fontFamily: 'Georgia, serif' }} />
@@ -435,9 +450,9 @@ export function DocumentGeneratorEditor({ - {/* Tool Panel or AI Assistant */} - - {activeTool && activeTool !== 'ai-generate' ? ( + {/* Tool Panel or AI Assistant - in rewrite mode always show AI Assistant */} + + {activeTool && activeTool !== 'ai-generate' && !isRewriteMode ? ( renderToolPanel() ) : (
@@ -448,7 +463,9 @@ export function DocumentGeneratorEditor({

AI Assistant

- {selectedText ? 'Selected text - Ask AI to edit it' : 'Ask AI to add content'} + {isRewriteMode + ? (selectedText ? 'Selected text - Ask AI to rewrite it' : 'Paste or type text, then select and ask AI to rewrite') + : (selectedText ? 'Selected text - Ask AI to edit it' : 'Ask AI to add content')}

diff --git a/src/app/employer/documents/components/DocumentViewerShell.tsx b/src/app/employer/documents/components/DocumentViewerShell.tsx index 07fb48f0..036304f7 100644 --- a/src/app/employer/documents/components/DocumentViewerShell.tsx +++ b/src/app/employer/documents/components/DocumentViewerShell.tsx @@ -37,6 +37,10 @@ const DocumentGenerator = dynamic( () => import("./DocumentGenerator").then((module) => module.DocumentGenerator), { loading: () => } ); +const RewriteDiffView = dynamic( + () => import("./RewriteDiffView").then((module) => module.RewriteDiffView), + { loading: () => } +); const STYLE_OPTIONS = Object.entries(RESPONSE_STYLES).reduce((acc, [key, config]) => { acc[key as ResponseStyleId] = config.label; @@ -573,6 +577,8 @@ export function DocumentViewerShell({ userRole }: DocumentViewerShellProps) { case "generator": if (userRole !== 'employer') return null; return ; + case "rewrite": + return ; default: return null; } diff --git a/src/app/employer/documents/components/RewriteDiffView.tsx b/src/app/employer/documents/components/RewriteDiffView.tsx new file mode 100644 index 00000000..55a0234f --- /dev/null +++ b/src/app/employer/documents/components/RewriteDiffView.tsx @@ -0,0 +1,60 @@ +"use client"; + +import React, { useCallback, useState } from "react"; +import { DocumentGeneratorEditor } from "./DocumentGeneratorEditor"; +import type { Citation } from "./generator"; + +const DEFAULT_TITLE = "Untitled (Rewrite)"; + +export function RewriteDiffView() { + const [saveError, setSaveError] = useState(null); + + const handleSave = useCallback( + async (title: string, content: string, citations?: Citation[]) => { + setSaveError(null); + const docTitle = title.trim() || DEFAULT_TITLE; + try { + const response = await fetch("/api/document-generator/documents", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: docTitle, + content, + templateId: "rewrite", + citations: citations ?? [], + metadata: { source: "rewrite" }, + }), + }); + const data = (await response.json()) as { + success: boolean; + message?: string; + document?: { id: number }; + }; + if (!data.success) { + setSaveError(data.message ?? "Failed to save document"); + } + } catch (err) { + console.error("Save to documents failed:", err); + setSaveError("Failed to save document"); + } + }, + [] + ); + + return ( +
+ {saveError && ( +
+ {saveError} +
+ )} + {}} + onSave={handleSave} + mode="rewrite" + /> +
+ ); +} diff --git a/src/app/employer/documents/components/Sidebar.tsx b/src/app/employer/documents/components/Sidebar.tsx index 5debb121..4b4c2d14 100644 --- a/src/app/employer/documents/components/Sidebar.tsx +++ b/src/app/employer/documents/components/Sidebar.tsx @@ -13,6 +13,7 @@ import { MoreVertical, MessageCircle, PenTool, + PenLine, Clock, Brain, } from 'lucide-react'; @@ -141,6 +142,20 @@ export function Sidebar({ )} +
{/* Bottom Actions */} @@ -256,6 +271,19 @@ export function Sidebar({
)} +
diff --git a/src/app/employer/documents/types/common.ts b/src/app/employer/documents/types/common.ts index 73f7f671..7724eb7e 100644 --- a/src/app/employer/documents/types/common.ts +++ b/src/app/employer/documents/types/common.ts @@ -5,19 +5,20 @@ import type { DocumentType } from "./document"; import type { PredictiveAnalysisResponse } from "./predictive-analysis"; import type { QAHistoryEntry } from "./qa-history"; -export type ViewMode = - | "document-only" - | "with-ai-qa" - | "with-ai-qa-history" +export type ViewMode = + | "document-only" + | "with-ai-qa" + | "with-ai-qa-history" | "predictive-analysis" - | "generator"; + | "generator" + | "rewrite"; -export type AiPersona = - | 'general' - | 'learning-coach' - | 'financial-expert' - | 'legal-expert' - | 'math-reasoning'; +export type AiPersona = + | "general" + | "learning-coach" + | "financial-expert" + | "legal-expert" + | "math-reasoning"; export interface errorType { error?: string; From 4448c3c2d1a1dff4861ddc38905a052eda9701fe Mon Sep 17 00:00:00 2001 From: kien-ship-it Date: Wed, 18 Feb 2026 13:47:39 -0500 Subject: [PATCH 02/49] chore: add .kiro to .gitignore and create ai-trend-search-engine branch --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 763bb042..261762e5 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ yarn-error.log* .idea /.localFiles .windsurf/rules/markdowncreation.md + +# kiro +.kiro From 2c996d0bee36603bbe426547860fd100bbb35a12 Mon Sep 17 00:00:00 2001 From: Timothy Lin <55767165+Deodat-Lawson@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:04:15 -0500 Subject: [PATCH 03/49] updated upload process --- .../employer/upload/CategoryManagement.tsx | 119 +- src/app/employer/upload/UploadForm.tsx | 1453 +++++++++++------ src/app/employer/upload/page.tsx | 81 +- src/styles/Employer/Upload.module.css | 833 +--------- 4 files changed, 1207 insertions(+), 1279 deletions(-) diff --git a/src/app/employer/upload/CategoryManagement.tsx b/src/app/employer/upload/CategoryManagement.tsx index 417da5c6..7bf8f021 100644 --- a/src/app/employer/upload/CategoryManagement.tsx +++ b/src/app/employer/upload/CategoryManagement.tsx @@ -2,7 +2,18 @@ import React, { useState } from "react"; import { Trash2 } from "lucide-react"; -import styles from "~/styles/Employer/Upload.module.css"; +import { Button } from "~/app/employer/documents/components/ui/button"; +import { Input } from "~/app/employer/documents/components/ui/input"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/app/employer/documents/components/ui/alert-dialog"; interface Category { id: string; @@ -12,68 +23,112 @@ interface Category { interface CategoryManagementProps { categories: Category[]; onAddCategory: (newCategory: string) => Promise; - onRemoveCategory: (id: string) => Promise; + onRemoveCategory: (id: string, categoryName: string) => Promise; } const CategoryManagement: React.FC = ({ - categories, - onAddCategory, - onRemoveCategory, - }) => { + categories, + onAddCategory, + onRemoveCategory, +}) => { const [newCategory, setNewCategory] = useState(""); + const [categoryToDelete, setCategoryToDelete] = useState(null); - // Make the function async, and await the category creation const handleAddCategory = async (e: React.FormEvent) => { e.preventDefault(); - try { - // Wait for onAddCategory to finish await onAddCategory(newCategory); setNewCategory(""); - // Parent component handles state update, no need to refresh } catch (error) { console.error("Error adding category:", error); } }; - // If you want to refresh after removal as well, make this async: - const handleRemoveCategory = async (id: string) => { + const confirmDeleteCategory = async () => { + if (!categoryToDelete) return; try { - await onRemoveCategory(id); - // Parent component handles state update + await onRemoveCategory(categoryToDelete.id, categoryToDelete.name); + setCategoryToDelete(null); } catch (error) { console.error("Error removing category:", error); } }; return ( -
-

Manage Categories

+
+

+ Manage Categories +

-
- + setNewCategory(e.target.value)} + className="flex-1" /> - +
-
    - {categories.map((cat) => ( -
  • - {cat.name} - +
+ ))} +
+ )} + + !open && setCategoryToDelete(null)} + > + + + Delete Category + + Are you sure you want to delete "{categoryToDelete?.name}"? + This action cannot be undone. + + + + Cancel + void confirmDeleteCategory()} + className="bg-red-600 hover:bg-red-700" > - - - - ))} - + Delete + + + + ); }; diff --git a/src/app/employer/upload/UploadForm.tsx b/src/app/employer/upload/UploadForm.tsx index fdb0f7b4..3eef17c0 100644 --- a/src/app/employer/upload/UploadForm.tsx +++ b/src/app/employer/upload/UploadForm.tsx @@ -1,38 +1,73 @@ "use client"; import React, { useState, useRef, useCallback, useEffect } from "react"; -import { Calendar, FileText, FolderPlus, Plus, Upload, Cloud, Database, ExternalLink, AlertCircle, Cpu, Brain } from "lucide-react"; +import { + Upload, + FileText, + X, + ChevronDown, + ChevronUp, + Plus, + Trash2, + Check, + AlertCircle, + Loader2, + ExternalLink, +} from "lucide-react"; import Link from "next/link"; -import dynamic from "next/dynamic"; import { useAuth } from "@clerk/nextjs"; import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { genUploader } from "uploadthing/client"; +import type { OurFileRouter } from "~/app/api/uploadthing/core"; import { isUploadAccepted, UPLOAD_ACCEPT_STRING } from "~/lib/upload-accepted"; -import styles from "~/styles/Employer/Upload.module.css"; - -const UploadDropzone = dynamic( - () => import("~/app/utils/uploadthing").then((module) => module.UploadDropzone), - { - ssr: false, - loading: () => ( -
- -

Loading uploader...

-
- ), - } -); -const UNSUPPORTED_FILE_TYPE_MESSAGE = - "Unsupported file type. Please upload a document or image."; +import { Button } from "~/app/employer/documents/components/ui/button"; +import { Input } from "~/app/employer/documents/components/ui/input"; +import { Label } from "~/app/employer/documents/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/app/employer/documents/components/ui/select"; +import { RadioGroup, RadioGroupItem } from "~/app/employer/documents/components/ui/radio-group"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "~/app/employer/documents/components/ui/collapsible"; +import { Progress } from "~/app/employer/documents/components/ui/progress"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/app/employer/documents/components/ui/tooltip"; + +const { uploadFiles } = genUploader(); + +const MAX_FILE_SIZE = 16 * 1024 * 1024; -interface UploadFormData { +interface DocumentFile { + id: string; + file: File; title: string; category: string; uploadDate: string; - fileUrl: string | null; - fileName: string; - fileMimeType?: string; - processingMethod: string; // "standard", "azure", "datalab", "landing_ai" + processingMethod: string; + storageMethod: string; + status: "pending" | "uploading" | "success" | "error"; + progress: number; + error?: string; +} + +interface BatchSettings { + category: string; + processingMethod: string; + uploadDate: string; + storageMethod: string; } export interface AvailableProviders { @@ -48,524 +83,1018 @@ interface UploadFormProps { onToggleUploadMethod: (useUploadThing: boolean) => Promise; isUpdatingPreference: boolean; availableProviders: AvailableProviders; + onAddCategory?: (newCategory: string) => Promise; } -const UploadForm: React.FC = ({ - categories, - useUploadThing, +const UploadForm: React.FC = ({ + categories, + useUploadThing, isUploadThingConfigured, onToggleUploadMethod, isUpdatingPreference, - availableProviders + availableProviders, + onAddCategory, }) => { const { userId } = useAuth(); const router = useRouter(); const fileInputRef = useRef(null); - // --- Form State --- - const [formData, setFormData] = useState({ - title: "", + const [step, setStep] = useState<1 | 2>(1); + const [documents, setDocuments] = useState([]); + const [batchSettings, setBatchSettings] = useState({ category: "", - uploadDate: new Date().toISOString().split("T")[0]!, - fileUrl: null, - fileName: "", processingMethod: "standard", + uploadDate: new Date().toISOString().split("T")[0]!, + storageMethod: useUploadThing && isUploadThingConfigured ? "cloud" : "database", }); - - const [errors, setErrors] = useState>({}); + const [isAddingCategory, setIsAddingCategory] = useState(false); + const [newCategoryName, setNewCategoryName] = useState(""); + const [isSavingCategory, setIsSavingCategory] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + const [expandedDocId, setExpandedDocId] = useState(null); + const [errors, setErrors] = useState>({}); const [isSubmitting, setIsSubmitting] = useState(false); - const [isUploading, setIsUploading] = useState(false); - const [isDragActive, setIsDragActive] = useState(false); - const [cloudUploaderActivated, setCloudUploaderActivated] = useState(false); + const [isDragging, setIsDragging] = useState(false); - useEffect(() => { - if (!useUploadThing) { - setCloudUploaderActivated(false); - } - }, [useUploadThing]); - - // --- Handlers --- - const handleInputChange = ( - e: React.ChangeEvent, - ) => { - const { name, value } = e.target; - setFormData((prev) => ({ ...prev, [name]: value })); - setErrors((prev) => ({ ...prev, [name]: undefined })); + const processingMethods = [ + { value: "standard", label: "Standard", description: "No OCR. Use for text-based PDFs." }, + ...(availableProviders.azure + ? [{ value: "azure", label: "Azure OCR", description: "Azure Document Intelligence OCR" }] + : []), + ...(availableProviders.landingAI + ? [{ value: "landing_ai", label: "Landing AI", description: "Multimodal AI processing" }] + : []), + ...(availableProviders.datalab + ? [{ value: "datalab", label: "Datalab", description: "Advanced data extraction" }] + : []), + ]; + + const defaultDoc = useCallback( + (file: File): DocumentFile => ({ + id: `${Date.now()}-${Math.random()}`, + file, + title: file.name.replace(/\.[^/.]+$/, ""), + category: batchSettings.category, + uploadDate: batchSettings.uploadDate, + processingMethod: batchSettings.processingMethod, + storageMethod: batchSettings.storageMethod, + status: "pending", + progress: 0, + }), + [batchSettings], + ); + + const validateAndAddFiles = useCallback( + (files: File[]) => { + const validFiles: DocumentFile[] = []; + let errorCount = 0; + + files.forEach((file) => { + if (!isUploadAccepted({ name: file.name, type: file.type })) { + errorCount++; + return; + } + if (file.size > MAX_FILE_SIZE) { + toast.error(`${file.name} exceeds 16MB limit`); + errorCount++; + return; + } + validFiles.push(defaultDoc(file)); + }); + + if (errorCount > 0) { + toast.error(`${errorCount} file(s) were rejected`, { + description: "Please upload PDF, DOCX, images (PNG, JPG, etc.) under 16MB", + }); + } + if (validFiles.length > 0) { + setDocuments((prev) => [...prev, ...validFiles]); + toast.success(`${validFiles.length} file(s) added to upload queue`); + setErrors((prev) => { + const next = { ...prev }; + delete next.files; + return next; + }); + } + }, + [defaultDoc], + ); + + const handleFileSelect = useCallback( + (files: FileList | null) => { + if (!files || files.length === 0) return; + validateAndAddFiles(Array.from(files)); + if (fileInputRef.current) fileInputRef.current.value = ""; + }, + [validateAndAddFiles], + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) validateAndAddFiles(files); + }, + [validateAndAddFiles], + ); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); }; - const validateForm = (): boolean => { - const newErrors: Partial = {}; + const handleDragLeave = () => setIsDragging(false); - if (!formData.title.trim()) { - newErrors.title = "Title is required"; - } - if (!formData.category) { - newErrors.category = "Category is required"; - } - if (!formData.fileUrl) { - newErrors.fileUrl = "Please upload a file"; - } + const removeDocument = (id: string) => { + setDocuments((prev) => prev.filter((d) => d.id !== id)); + toast.success("File removed from queue"); + }; - setErrors(newErrors); - return Object.keys(newErrors).length === 0; + const updateDocument = (id: string, updates: Partial) => { + setDocuments((prev) => prev.map((d) => (d.id === id ? { ...d, ...updates } : d))); }; - // Handle local file upload (when UploadThing is disabled) - const handleLocalFileUpload = useCallback(async (file: File) => { - if (!isUploadAccepted({ name: file.name, type: file.type })) { - setErrors((prev) => ({ ...prev, fileUrl: UNSUPPORTED_FILE_TYPE_MESSAGE })); + const applyBatchSettings = () => { + if (!batchSettings.category) { + toast.error("Please select a category to apply to all documents"); return; } - if (file.size > 16 * 1024 * 1024) { - setErrors((prev) => ({ ...prev, fileUrl: "File size must be less than 16MB" })); + setDocuments((prev) => + prev.map((d) => ({ + ...d, + category: batchSettings.category, + processingMethod: batchSettings.processingMethod, + uploadDate: batchSettings.uploadDate, + storageMethod: batchSettings.storageMethod, + })), + ); + toast.success("Settings applied to all documents"); + }; + + const handleAddCategoryInline = useCallback(async () => { + if (!newCategoryName.trim() || !onAddCategory) return; + const name = newCategoryName.trim(); + if (categories.some((c) => c.name.toLowerCase() === name.toLowerCase())) { + setBatchSettings((prev) => ({ ...prev, category: name })); + setNewCategoryName(""); + setIsAddingCategory(false); return; } + setIsSavingCategory(true); + try { + await onAddCategory(name); + setBatchSettings((prev) => ({ ...prev, category: name })); + setNewCategoryName(""); + setIsAddingCategory(false); + } catch (err) { + console.error(err); + } finally { + setIsSavingCategory(false); + } + }, [newCategoryName, onAddCategory, categories]); - setIsUploading(true); - setErrors((prev) => ({ ...prev, fileUrl: undefined })); + const handleToggleChange = useCallback( + (value: string) => { + if (value === "cloud" && !isUploadThingConfigured) return; + const newUseUploadThing = value === "cloud"; + setBatchSettings((prev) => ({ ...prev, storageMethod: value })); + setDocuments((prev) => prev.map((d) => ({ ...d, storageMethod: value }))); + void onToggleUploadMethod(newUseUploadThing); + }, + [isUploadThingConfigured, onToggleUploadMethod], + ); - try { - const formDataToUpload = new FormData(); - formDataToUpload.append("file", file); + const validateStep1 = () => { + if (documents.length === 0) { + setErrors({ files: "Please add at least one file to upload" }); + return false; + } + setErrors({}); + return true; + }; - const response = await fetch("/api/upload-local", { - method: "POST", - body: formDataToUpload, + const validateStep2 = () => { + const newErrors: Record = {}; + documents.forEach((doc) => { + if (!doc.title.trim()) newErrors[`title-${doc.id}`] = "Title is required"; + if (!doc.category) newErrors[`category-${doc.id}`] = "Category is required"; + }); + if (Object.keys(newErrors).length > 0) { + toast.error("Please fill in all required fields", { + description: "Check that each document has a title and category", }); + } + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; - if (!response.ok) { - const errorData = await response.json() as { error?: string }; - throw new Error(errorData.error ?? "Upload failed"); - } + const handleNextStep = () => { + if (validateStep1()) setStep(2); + }; - const data = await response.json() as { url: string; name: string }; - - setFormData((prev) => ({ - ...prev, - fileUrl: data.url, - fileName: data.name, - fileMimeType: file.type || undefined, - })); - } catch (error) { - console.error("Upload error:", error); - setErrors((prev) => ({ - ...prev, - fileUrl: error instanceof Error ? error.message : "Failed to upload file", - })); - } finally { - setIsUploading(false); + const hasSyncedBatch = useRef(false); + useEffect(() => { + if (step === 2 && documents.length > 0 && !hasSyncedBatch.current) { + hasSyncedBatch.current = true; + const first = documents[0]!; + setBatchSettings({ + category: first.category, + processingMethod: first.processingMethod, + uploadDate: first.uploadDate, + storageMethod: first.storageMethod, + }); } - }, []); + if (step === 1) hasSyncedBatch.current = false; + }, [step, documents]); + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; + }; - // Native file input change handler - const handleFileInputChange = useCallback((e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - void handleLocalFileUpload(file); + const uploadSingleDocument = async (doc: DocumentFile) => { + updateDocument(doc.id, { status: "uploading", progress: 10 }); + + const storageType = + doc.storageMethod === "cloud" && isUploadThingConfigured ? "cloud" : "database"; + let fileUrl: string; + const mimeType: string | undefined = doc.file.type || undefined; + + if (storageType === "cloud") { + updateDocument(doc.id, { progress: 30 }); + const res = await uploadFiles("documentUploaderRestricted", { + files: [doc.file], + }); + if (!res?.[0]?.url) throw new Error("Cloud upload failed"); + fileUrl = res[0].url; + } else { + updateDocument(doc.id, { progress: 30 }); + const fd = new FormData(); + fd.append("file", doc.file); + const res = await fetch("/api/upload-local", { method: "POST", body: fd }); + if (!res.ok) { + const err = (await res.json()) as { error?: string }; + throw new Error(err.error ?? "Local upload failed"); + } + const data = (await res.json()) as { url: string }; + fileUrl = data.url; } - }, [handleLocalFileUpload]); - // Drag and drop handlers - const handleDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragActive(true); - }, []); + updateDocument(doc.id, { progress: 60 }); - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragActive(false); - }, []); + const preferredProvider = + doc.processingMethod === "standard" ? undefined : doc.processingMethod.toUpperCase(); - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - }, []); + const response = await fetch("/api/uploadDocument", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + userId, + documentName: doc.title, + category: doc.category, + documentUrl: fileUrl, + storageType, + mimeType, + preferredProvider: + preferredProvider === "LANDING_AI" ? "LANDING_AI" : preferredProvider, + }), + }); - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragActive(false); + if (!response.ok) throw new Error(`Document registration failed for ${doc.title}`); + updateDocument(doc.id, { status: "success", progress: 100 }); + }; - const file = e.dataTransfer.files?.[0]; - if (file) { - void handleLocalFileUpload(file); + const handleSubmit = async () => { + if (!validateStep2()) return; + setIsSubmitting(true); + + const pendingDocs = documents.filter((d) => d.status === "pending"); + + for (const doc of pendingDocs) { + try { + await uploadSingleDocument(doc); + } catch (err) { + console.error(err); + updateDocument(doc.id, { + status: "error", + progress: 0, + error: err instanceof Error ? err.message : "Upload failed", + }); + } } - }, [handleLocalFileUpload]); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!validateForm()) return; + setIsSubmitting(false); - try { - setIsSubmitting(true); - - // Determine storage type based on current mode - // If UploadThing is configured and enabled, use cloud; otherwise database - const storageType = useUploadThing && isUploadThingConfigured ? "cloud" : "database"; - - // Map processing method to provider - const preferredProvider = formData.processingMethod === "standard" ? undefined : formData.processingMethod.toUpperCase(); - - // Note: forceOCR logic in backend relies on options.forceOCR which we're not setting explicitly here - // unless we want to enforce it. The new pipeline treats preferredProvider being set as "use this provider". - // If processingMethod is "standard", preferredProvider is undefined and it will fall back to auto/none. - - const response = await fetch("/api/uploadDocument", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - userId, - documentName: formData.title, - category: formData.category, - documentUrl: formData.fileUrl, - storageType, - mimeType: formData.fileMimeType, - preferredProvider: preferredProvider === "LANDING_AI" ? "LANDING_AI" : preferredProvider, // Ensure correct casing if needed - }), - }); + const finalSuccess = documents.filter((d) => d.status === "success").length + + pendingDocs.filter((d) => { + const current = documents.find((dd) => dd.id === d.id); + return current?.status === "success"; + }).length; - if (!response.ok) { - console.error("Error uploading document"); - } else { + const finalErrors = documents.filter((d) => d.status === "error").length; + + if (finalErrors === 0) { + toast.success(`All documents uploaded successfully!`, { + description: "Your documents are now available in the library.", + }); + setTimeout(() => { + setDocuments([]); + setStep(1); + setShowAdvanced(false); router.push("/employer/documents"); - } - } catch (error) { - console.error("Error submitting form:", error); - } finally { - setIsSubmitting(false); + }, 1500); + } else if (finalSuccess > 0) { + toast.warning(`${finalSuccess} succeeded, ${finalErrors} failed`, { + description: "You can retry failed uploads or remove them.", + }); + } else { + toast.error("Upload failed"); } }; - // Handle toggle change - const handleToggleChange = useCallback(() => { - // Clear any uploaded file when switching methods - setFormData((prev) => ({ ...prev, fileUrl: null, fileName: "", fileMimeType: undefined })); - void onToggleUploadMethod(!useUploadThing); - }, [useUploadThing, onToggleUploadMethod]); - - // --- Render Upload Area --- - const renderUploadArea = () => { - if (formData.fileUrl) { - return ( -
- - {formData.fileName} - -
- ); - } + const retryFailedUploads = async () => { + const failedDocs = documents.filter((d) => d.status === "error"); + if (failedDocs.length === 0) return; - // Use UploadThing if enabled - if (useUploadThing) { - if (!cloudUploaderActivated) { - return ( -
- -

- Cloud uploader is ready when you need it. -

-

- Load it only when you are about to upload. -

- -
- ); - } + failedDocs.forEach((d) => + updateDocument(d.id, { status: "pending", progress: 0, error: undefined }), + ); - return ( - { - if (!res?.length) return; - const file = res[0]!; - setFormData((prev) => ({ - ...prev, - fileUrl: file.url, - fileName: file.name, - fileMimeType: "type" in file && typeof file.type === "string" ? file.type : undefined, - })); - }} - onUploadError={(error) => { - console.error("Upload Error:", error); - }} - className={styles.uploadArea} - /> - ); + setIsSubmitting(true); + + for (const doc of failedDocs) { + try { + await uploadSingleDocument(doc); + } catch (err) { + console.error(err); + updateDocument(doc.id, { + status: "error", + progress: 0, + error: err instanceof Error ? err.message : "Upload failed", + }); + } } - // Native file upload when UploadThing is disabled - return ( -
fileInputRef.current?.click()} - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - fileInputRef.current?.click(); - } - }} - > - - - {isUploading ? ( -

Uploading...

- ) : ( - <> -

- Drag & drop your file here, or{" "} - browse -

-

- Documents & images — up to 16MB -

- - )} -
- ); + setIsSubmitting(false); + + const stillFailed = documents.filter((d) => d.status === "error").length; + if (stillFailed === 0) { + toast.success("All uploads completed successfully!"); + setTimeout(() => { + setDocuments([]); + setStep(1); + router.push("/employer/documents"); + }, 1500); + } }; - // --- Render --- + const pendingCount = documents.filter((d) => d.status === "pending").length; + const successCount = documents.filter((d) => d.status === "success").length; + const errorCount = documents.filter((d) => d.status === "error").length; + + const currentStorageValue = + useUploadThing && isUploadThingConfigured ? "cloud" : "database"; + return ( -
- {/* Storage Toggle */} -
- Upload Storage: -
- - -
- {isUpdatingPreference && ( - Updating... - )} -
- - {/* UploadThing not configured message */} - {!isUploadThingConfigured && ( -
- -
- - Cloud storage (UploadThing) is not configured. - - - Set up in Deployment Guide - -
-
- )} - - {/* File Upload Area */} - {renderUploadArea()} - {errors.fileUrl && {errors.fileUrl}} - - {/* Document Details */} -
- {/* Title */} -
- -
- - + 1
- {errors.title && {errors.title}} -
- - {/* Category */} -
- -
- - +
= 2 ? "bg-purple-600" : "bg-gray-200"}`} /> +
= 2 ? "bg-purple-600 text-white" : "bg-gray-200 text-gray-600" + }`} + > + 2
- {errors.category && ( - {errors.category} - )}
- {/* Upload Date */} -
- -
- - -
-
+ {/* Step 1: Upload Files */} + {step === 1 && ( +
+
+

+ Upload Documents +

- {/* Processing Method Selection */} -
- -
-
{/* Right: Preview Toggle */} @@ -202,6 +252,7 @@ export function ChatPanel({ companyId={companyId} aiStyle={aiStyle} aiPersona={aiPersona} + aiModel={aiModel} onPageClick={setPdfPageNumber} onCreateChat={onCreateChat} /> diff --git a/src/app/employer/documents/components/DocumentViewerShell.tsx b/src/app/employer/documents/components/DocumentViewerShell.tsx index 07fb48f0..96253e2f 100644 --- a/src/app/employer/documents/components/DocumentViewerShell.tsx +++ b/src/app/employer/documents/components/DocumentViewerShell.tsx @@ -17,6 +17,7 @@ import { Button } from "~/app/employer/documents/components/ui/button"; import type { ImperativePanelHandle } from "react-resizable-panels"; import { RESPONSE_STYLES, type ResponseStyleId } from "~/lib/ai/styles"; +import type { AIModelType } from "~/app/api/agents/documentQ&A/services/types"; const ChatPanel = dynamic( () => import("./ChatPanel").then((module) => module.ChatPanel), @@ -81,6 +82,7 @@ export function DocumentViewerShell({ userRole }: DocumentViewerShellProps) { const { createChat, getChat } = useAIChatbot(); const [currentChatId, setCurrentChatId] = useState(null); const [aiPersona, setAiPersona] = useState('general'); + const [aiModel, setAiModel] = useState("gpt-5.2"); // Handle chat selection and auto-document binding useEffect(() => { @@ -323,6 +325,7 @@ export function DocumentViewerShell({ userRole }: DocumentViewerShellProps) { question: currentQuestion, searchScope, style: aiStyle as ResponseStyleId, + aiModel, documentId: searchScope === "document" && selectedDoc ? selectedDoc.id : undefined, companyId: searchScope === "company" ? resolvedCompanyId ?? undefined : undefined, }); @@ -480,6 +483,8 @@ export function DocumentViewerShell({ userRole }: DocumentViewerShellProps) { setSearchScope={handleSearchScopeChange} aiStyle={aiStyle} setAiStyle={setAiStyle} + aiModel={aiModel} + setAiModel={setAiModel} styleOptions={STYLE_OPTIONS} referencePages={referencePages} setPdfPageNumber={setPdfPageNumber} @@ -499,6 +504,8 @@ export function DocumentViewerShell({ userRole }: DocumentViewerShellProps) { setAiStyle={setAiStyle} aiPersona={aiPersona} setAiPersona={setAiPersona} + aiModel={aiModel} + setAiModel={setAiModel} searchScope={searchScope} setSearchScope={handleSearchScopeChange} companyId={companyId} diff --git a/src/app/employer/documents/components/SimpleQueryPanel.tsx b/src/app/employer/documents/components/SimpleQueryPanel.tsx index e1b01c58..209a0dde 100644 --- a/src/app/employer/documents/components/SimpleQueryPanel.tsx +++ b/src/app/employer/documents/components/SimpleQueryPanel.tsx @@ -18,8 +18,16 @@ import { import { Button } from '~/app/employer/documents/components/ui/button'; import { Textarea } from '~/app/employer/documents/components/ui/textarea'; import { ScrollArea } from '~/app/employer/documents/components/ui/scroll-area'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '~/app/employer/documents/components/ui/select'; import { cn } from "~/lib/utils"; import type { DocumentType } from '../types'; +import type { AIModelType } from '~/app/api/agents/documentQ&A/services/types'; const MarkdownMessage = dynamic( () => import("~/app/_components/MarkdownMessage"), @@ -45,6 +53,8 @@ interface SimpleQueryPanelProps { setSearchScope: (s: 'document' | 'company') => void; aiStyle: string; setAiStyle: (s: string) => void; + aiModel: AIModelType; + setAiModel: (m: AIModelType) => void; styleOptions: Record; referencePages: number[]; setPdfPageNumber: (p: number) => void; @@ -59,6 +69,17 @@ const styleIcons: Record = { "bullet-points": , // Backwards compat just in case }; +const modelConfig: Array<{ key: AIModelType; label: string }> = [ + { key: "gpt-5.2", label: "GPT-5.2" }, + { key: "claude-opus-4.5", label: "Claude Opus 4.5" }, + { key: "gemini-3-flash", label: "Gemini 3 Flash" }, + { key: "gemini-3-pro", label: "Gemini 3 Pro" }, + { key: "gpt-5.1", label: "GPT-5.1" }, + { key: "gpt-4o", label: "GPT-4o" }, + { key: "claude-sonnet-4", label: "Claude Sonnet 4" }, + { key: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, +]; + export function SimpleQueryPanel({ selectedDoc, companyId, @@ -74,6 +95,8 @@ export function SimpleQueryPanel({ setSearchScope, aiStyle, setAiStyle, + aiModel, + setAiModel, styleOptions, referencePages: _referencePages, setPdfPageNumber: _setPdfPageNumber, @@ -163,6 +186,32 @@ export function SimpleQueryPanel({
+ {/* Model Selector */} +
+ + + AI Model + + +
+ {/* Question Input */}
From 446f3a1cfb11d3cbbdf0d327cd2ae0844077f81b Mon Sep 17 00:00:00 2001 From: Rafael Chen Date: Sat, 21 Feb 2026 15:26:47 -0500 Subject: [PATCH 11/49] added before & after rewrites + validation tests --- .../components/RewritePreviewPanel.test.tsx | 86 ++++++ __tests__/lib/extractTextAtCursor.test.ts | 58 ++++ package.json | 1 + pnpm-lock.yaml | 9 + .../components/DocumentGeneratorEditor.tsx | 249 ++++++++++++++---- .../generator/InlineRewriteDiff.tsx | 65 +++++ .../generator/RewritePreviewPanel.tsx | 80 ++++++ .../documents/components/generator/index.ts | 2 + 8 files changed, 500 insertions(+), 50 deletions(-) create mode 100644 __tests__/components/RewritePreviewPanel.test.tsx create mode 100644 __tests__/lib/extractTextAtCursor.test.ts create mode 100644 src/app/employer/documents/components/generator/InlineRewriteDiff.tsx create mode 100644 src/app/employer/documents/components/generator/RewritePreviewPanel.tsx diff --git a/__tests__/components/RewritePreviewPanel.test.tsx b/__tests__/components/RewritePreviewPanel.test.tsx new file mode 100644 index 00000000..1c2b9b69 --- /dev/null +++ b/__tests__/components/RewritePreviewPanel.test.tsx @@ -0,0 +1,86 @@ +/** @jest-environment jsdom */ + +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { RewritePreviewPanel } from "~/app/employer/documents/components/generator/RewritePreviewPanel"; + +describe("RewritePreviewPanel", () => { + it("renders before/after diff and Accept/Reject/Try again buttons", () => { + const onAccept = jest.fn(); + const onReject = jest.fn(); + const onTryAgain = jest.fn(); + + render( + + ); + + expect(screen.getByText("Accept")).toBeInTheDocument(); + expect(screen.getByText("Reject")).toBeInTheDocument(); + expect(screen.getByText("Try again")).toBeInTheDocument(); + }); + + it("calls onAccept when Accept is clicked", async () => { + const onAccept = jest.fn(); + const onReject = jest.fn(); + const onTryAgain = jest.fn(); + + render( + + ); + + await userEvent.click(screen.getByText("Accept")); + expect(onAccept).toHaveBeenCalledTimes(1); + }); + + it("calls onReject when Reject is clicked", async () => { + const onAccept = jest.fn(); + const onReject = jest.fn(); + const onTryAgain = jest.fn(); + + render( + + ); + + await userEvent.click(screen.getByText("Reject")); + expect(onReject).toHaveBeenCalledTimes(1); + }); + + it("calls onTryAgain when Try again is clicked", async () => { + const onAccept = jest.fn(); + const onReject = jest.fn(); + const onTryAgain = jest.fn(); + + render( + + ); + + await userEvent.click(screen.getByText("Try again")); + expect(onTryAgain).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/lib/extractTextAtCursor.test.ts b/__tests__/lib/extractTextAtCursor.test.ts new file mode 100644 index 00000000..25f496d0 --- /dev/null +++ b/__tests__/lib/extractTextAtCursor.test.ts @@ -0,0 +1,58 @@ +/** @jest-environment node */ + +/** + * Tests for extractTextAtCursor logic (cursor rewrite - extract sentence/paragraph at cursor). + * The function is inlined in DocumentGeneratorEditor; this tests equivalent logic. + */ +function extractTextAtCursor(text: string, cursorPos: number): { text: string; start: number; end: number } { + if (text.length === 0) return { text: "", start: 0, end: 0 }; + const len = text.length; + let start = cursorPos; + let end = cursorPos; + while (start > 0) { + const c = text[start - 1] ?? ""; + const prev = text[start - 2] ?? ""; + if (c === "\n" && start > 1 && prev === "\n") break; + if ([".", "!", "?"].includes(c) && (start <= 1 || /[\s\n]/.test(prev))) break; + start--; + } + while (end < len) { + const c = text[end] ?? ""; + const next = text[end + 1] ?? ""; + if (c === "\n" && end + 1 < len && next === "\n") break; + if ([".", "!", "?"].includes(c)) { + end++; + break; + } + end++; + } + const raw = text.slice(start, end); + const trimmed = raw.trim(); + if (!trimmed) return { text: "", start: cursorPos, end: cursorPos }; + const leadSpace = raw.length - raw.trimStart().length; + const trailSpace = raw.trimEnd().length; + return { text: trimmed, start: start + leadSpace, end: start + trailSpace }; +} + +describe("extractTextAtCursor (cursor rewrite)", () => { + it("extracts text from start to next sentence boundary when cursor in middle", () => { + const text = "First sentence. Second sentence. Third."; + const result = extractTextAtCursor(text, 18); + expect(result.text.length).toBeGreaterThan(0); + expect(text.slice(result.start, result.end).trim()).toBe(result.text); + }); + + it("extracts paragraph when cursor in middle (stops at double newline)", () => { + const text = "One para.\n\nOther para."; + const result = extractTextAtCursor(text, 5); + expect(result.text).toBe("One para."); + }); + + it("returns empty and cursor pos when no extractable content", () => { + const text = ""; + const result = extractTextAtCursor(text, 0); + expect(result.text).toBe(""); + expect(result.start).toBe(0); + expect(result.end).toBe(0); + }); +}); diff --git a/package.json b/package.json index dbd6cf6a..88f24e8d 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "clsx": "*", "cmdk": "^1.1.1", "dayjs": "^1.11.18", + "diff": "^8.0.3", "dotenv": "^17.3.1", "drizzle-orm": "^0.45.1", "duck-duck-scrape": "^2.2.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71471827..c56ae2b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,6 +158,9 @@ importers: dayjs: specifier: ^1.11.18 version: 1.11.18 + diff: + specifier: ^8.0.3 + version: 8.0.3 dotenv: specifier: ^17.3.1 version: 17.3.1 @@ -4856,6 +4859,10 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + engines: {node: '>=0.3.1'} + dingbat-to-unicode@1.0.1: resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} @@ -12742,6 +12749,8 @@ snapshots: didyoumean@1.2.2: {} + diff@8.0.3: {} + dingbat-to-unicode@1.0.1: {} dlv@1.1.3: {} diff --git a/src/app/employer/documents/components/DocumentGeneratorEditor.tsx b/src/app/employer/documents/components/DocumentGeneratorEditor.tsx index 86c0dfe8..a3681291 100644 --- a/src/app/employer/documents/components/DocumentGeneratorEditor.tsx +++ b/src/app/employer/documents/components/DocumentGeneratorEditor.tsx @@ -31,12 +31,44 @@ import { OutlinePanel, GrammarPanel, ExportDialog, + InlineRewriteDiff, type ToolType, type AIAction, type Citation, type OutlineItem, } from "./generator"; +/** Extract sentence or paragraph at cursor when there is no selection (cursor rewrite). */ +function extractTextAtCursor(text: string, cursorPos: number): { text: string; start: number; end: number } { + if (text.length === 0) return { text: "", start: 0, end: 0 }; + const len = text.length; + let start = cursorPos; + let end = cursorPos; + while (start > 0) { + const c = text[start - 1] ?? ""; + const prev = text[start - 2] ?? ""; + if (c === "\n" && start > 1 && prev === "\n") break; + if ([".", "!", "?"].includes(c) && (start <= 1 || /[\s\n]/.test(prev))) break; + start--; + } + while (end < len) { + const c = text[end] ?? ""; + const next = text[end + 1] ?? ""; + if (c === "\n" && end + 1 < len && next === "\n") break; + if ([".", "!", "?"].includes(c)) { + end++; + break; + } + end++; + } + const raw = text.slice(start, end); + const trimmed = raw.trim(); + if (!trimmed) return { text: "", start: cursorPos, end: cursorPos }; + const leadSpace = raw.length - raw.trimStart().length; + const trailSpace = raw.trimEnd().length; + return { text: trimmed, start: start + leadSpace, end: start + trailSpace }; +} + interface DocumentGeneratorEditorProps { initialTitle: string; initialContent: string; @@ -77,7 +109,19 @@ export function DocumentGeneratorEditor({ const [selectionStart, setSelectionStart] = useState(0); const [selectionEnd, setSelectionEnd] = useState(0); const [chatMessages, setChatMessages] = useState>([]); - + + /** Rewrite preview: show diff, user must Accept or Reject. */ + const [rewritePreview, setRewritePreview] = useState<{ + originalText: string; + proposedText: string; + selectionStart: number; + selectionEnd: number; + prompt: string; + } | null>(null); + + /** Undo stack for content (used so Accept is one undo step). */ + const contentHistoryRef = useRef([]); + const contentRef = useRef(null); // Auto-save (supports async onSave e.g. for Rewrite tab saving to API) @@ -99,80 +143,118 @@ export function DocumentGeneratorEditor({ return () => clearInterval(interval); }, [handleSave, isRewriteMode]); - // Keyboard shortcuts + // Keyboard shortcuts (including undo) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 's') { + if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) { + const history = contentHistoryRef.current; + if (history.length > 0 && contentRef.current === document.activeElement) { + e.preventDefault(); + const prev = history.pop()!; + setContent(prev); + } + } + if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); void handleSave(); } - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); - setActiveTool('ai-generate'); + setActiveTool("ai-generate"); } }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); }, [handleSave]); // Handle AI content generation const handleAIAction = async (action: AIAction, customPrompt?: string) => { setIsProcessing(true); const prompt = customPrompt ?? aiPrompt; - + + let textToRewrite: string; + let rewriteStart: number; + let rewriteEnd: number; + + if (selectedText) { + textToRewrite = selectedText; + rewriteStart = selectionStart; + rewriteEnd = selectionEnd; + } else { + const cursorPos = contentRef.current?.selectionStart ?? content.length; + const extracted = extractTextAtCursor(content, cursorPos); + textToRewrite = extracted.text; + rewriteStart = extracted.start; + rewriteEnd = extracted.end; + } + if (prompt) { - setChatMessages(prev => [...prev, { role: 'user', content: prompt }]); - setAiPrompt(''); + setChatMessages((prev) => [...prev, { role: "user", content: prompt }]); + setAiPrompt(""); } try { - const response = await fetch('/api/document-generator/generate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const response = await fetch("/api/document-generator/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, - content: selectedText || content.slice(-500), + content: textToRewrite || content.slice(-500), prompt, context: { documentTitle: title, fullContent: content.slice(0, 3000), cursorPosition: selectionEnd, }, - options: { - tone: 'professional', - length: 'medium', - }, + options: { tone: "professional", length: "medium" }, }), }); - const data = await response.json() as { success: boolean; generatedContent?: string }; - + const data = (await response.json()) as { success: boolean; generatedContent?: string }; + if (data.success && data.generatedContent) { const generatedContent = data.generatedContent; - - if (selectedText && (action === 'expand' || action === 'rewrite' || action === 'summarize' || action === 'change_tone')) { - // Replace selected text - const newContent = content.substring(0, selectionStart) + generatedContent + content.substring(selectionEnd); + + if (action === "rewrite" && textToRewrite.trim()) { + setRewritePreview({ + originalText: textToRewrite, + proposedText: generatedContent, + selectionStart: rewriteStart, + selectionEnd: rewriteEnd, + prompt: prompt ?? "", + }); + setChatMessages((prev) => [ + ...prev, + { role: "assistant", content: "When you're ready—check the preview above your document and click Accept to apply or Reject to discard. No rush!" }, + ]); + return; + } + + if (selectedText && (action === "expand" || action === "summarize" || action === "change_tone")) { + const newContent = + content.substring(0, selectionStart) + generatedContent + content.substring(selectionEnd); setContent(newContent); } else { - // Append to content - setContent(prev => prev + '\n\n' + generatedContent); + setContent((prev) => prev + "\n\n" + generatedContent); } - - setChatMessages(prev => [...prev, { - role: 'assistant', - content: `I've ${action === 'generate_section' ? 'generated a new section' : action === 'continue' ? 'continued writing' : `${action}ed the text`}. The changes have been applied to your document.` - }]); + + setChatMessages((prev) => [ + ...prev, + { + role: "assistant", + content: `I've ${action === "generate_section" ? "generated a new section" : action === "continue" ? "continued writing" : `${action}ed the text`}. The changes have been applied to your document.`, + }, + ]); } } catch (error) { - console.error('AI generation error:', error); - setChatMessages(prev => [...prev, { - role: 'assistant', - content: 'Sorry, there was an error generating content. Please try again.' - }]); + console.error("AI generation error:", error); + setChatMessages((prev) => [ + ...prev, + { role: "assistant", content: "Sorry, there was an error generating content. Please try again." }, + ]); } finally { setIsProcessing(false); - setSelectedText(''); + if (action !== "rewrite") setSelectedText(""); } }; @@ -184,6 +266,56 @@ export function DocumentGeneratorEditor({ await handleAIAction(action, aiPrompt); }; + const handleContentChange = useCallback((e: React.ChangeEvent) => { + setRewritePreview(null); + setContent(e.target.value); + }, []); + + const handleRewriteAccept = useCallback(() => { + if (!rewritePreview) return; + contentHistoryRef.current.push(content); + const newContent = + content.slice(0, rewritePreview.selectionStart) + + rewritePreview.proposedText + + content.slice(rewritePreview.selectionEnd); + setContent(newContent); + setRewritePreview(null); + setSelectedText(""); + setChatMessages((prev) => [ + ...prev, + { role: "assistant", content: "Rewrite applied. Use Cmd+Z to undo." }, + ]); + }, [rewritePreview, content]); + + const handleRewriteReject = useCallback(() => { + setRewritePreview(null); + }, []); + + const [isRetryingRewrite, setIsRetryingRewrite] = useState(false); + const handleRewriteTryAgain = useCallback(async () => { + if (!rewritePreview) return; + setIsRetryingRewrite(true); + try { + const response = await fetch("/api/document-generator/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "rewrite", + content: rewritePreview.originalText, + prompt: rewritePreview.prompt, + context: { documentTitle: title, fullContent: content.slice(0, 3000) }, + options: { tone: "professional", length: "medium" }, + }), + }); + const data = (await response.json()) as { success: boolean; generatedContent?: string }; + if (data.success && data.generatedContent) { + setRewritePreview((p) => (p ? { ...p, proposedText: data.generatedContent! } : null)); + } + } finally { + setIsRetryingRewrite(false); + } + }, [rewritePreview, title, content]); + // Handle text selection const handleTextSelection = () => { if (!contentRef.current) return; @@ -324,7 +456,6 @@ export function DocumentGeneratorEditor({ return (
- {} {!isRewriteMode && ( <>
- {/* Editor Content */} + {/* Editor Content - when rewrite preview active, show before|diff|after inline in document */}
-
-