mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-04 08:47:45 +08:00
fix(chat-input): stop template dialogs from sending and fix attachment races
- Template dialogs no longer submit the outer chat form - Sending is blocked while files or URLs are still extracting - File and URL extraction no longer drop or resurrect entries - IME composition Enter no longer sends - Tool call cards show the error text; keyboard handling on cards fixed - Template import available when empty, edit dialog resets, saved templates refresh - Only png/jpeg/gif/webp images accepted, SVG sent as text; PDF objects released - parse-url request sends the access code
This commit is contained in:
+68
-36
@@ -11,7 +11,9 @@ import {
|
||||
} from "lucide-react"
|
||||
import type React from "react"
|
||||
import {
|
||||
type Dispatch,
|
||||
forwardRef,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
@@ -41,9 +43,20 @@ import { FilePreviewList } from "./file-preview-list"
|
||||
|
||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
const MAX_FILES = 5
|
||||
// Image formats every supported model provider accepts (SVG is read as text)
|
||||
const SUPPORTED_IMAGE_TYPES = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
]
|
||||
|
||||
function isValidFileType(file: File): boolean {
|
||||
return file.type.startsWith("image/") || isPdfFile(file) || isTextFile(file)
|
||||
return (
|
||||
SUPPORTED_IMAGE_TYPES.includes(file.type) ||
|
||||
isPdfFile(file) ||
|
||||
isTextFile(file)
|
||||
)
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -164,7 +177,7 @@ interface ChatInputProps {
|
||||
{ text: string; charCount: number; isExtracting: boolean }
|
||||
>
|
||||
urlData?: Map<string, UrlData>
|
||||
onUrlChange?: (data: Map<string, UrlData>) => void
|
||||
onUrlChange?: Dispatch<SetStateAction<Map<string, UrlData>>>
|
||||
|
||||
sessionId?: string
|
||||
error?: Error | null
|
||||
@@ -244,6 +257,11 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
||||
const isDisabled =
|
||||
(status === "streaming" || status === "submitted") && !error
|
||||
// Block sending until attached files and URLs have their text, otherwise
|
||||
// their content would be silently dropped
|
||||
const isExtractingAttachments =
|
||||
files.some((file) => pdfData.get(file)?.isExtracting) ||
|
||||
Array.from(urlData?.values() ?? []).some((d) => d.isExtracting)
|
||||
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
@@ -281,6 +299,9 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Enter that confirms an IME candidate must not send the message
|
||||
if (e.nativeEvent.isComposing || e.keyCode === 229) return
|
||||
|
||||
const shouldSend =
|
||||
sendShortcut === "enter"
|
||||
? e.key === "Enter" &&
|
||||
@@ -292,7 +313,12 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
if (shouldSend) {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget.closest("form")
|
||||
if (form && input.trim() && !isDisabled) {
|
||||
if (
|
||||
form &&
|
||||
input.trim() &&
|
||||
!isDisabled &&
|
||||
!isExtractingAttachments
|
||||
) {
|
||||
form.requestSubmit()
|
||||
}
|
||||
}
|
||||
@@ -380,13 +406,9 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
|
||||
if (isDisabled) return
|
||||
|
||||
const droppedFiles = e.dataTransfer.files
|
||||
const supportedFiles = Array.from(droppedFiles).filter((file) =>
|
||||
isValidFileType(file),
|
||||
)
|
||||
|
||||
// Let validateFiles show a toast for unsupported types
|
||||
const { validFiles, errors } = validateFiles(
|
||||
supportedFiles,
|
||||
Array.from(e.dataTransfer.files),
|
||||
files.length,
|
||||
dict,
|
||||
)
|
||||
@@ -401,33 +423,34 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
|
||||
setIsExtractingUrl(true)
|
||||
|
||||
// Use functional updates so a removal or send made while extracting
|
||||
// is not overwritten when the request finishes
|
||||
try {
|
||||
const existing = urlData
|
||||
? new Map(urlData)
|
||||
: new Map<string, UrlData>()
|
||||
existing.set(url, {
|
||||
url,
|
||||
title: url,
|
||||
content: "",
|
||||
charCount: 0,
|
||||
isExtracting: true,
|
||||
})
|
||||
onUrlChange(existing)
|
||||
onUrlChange((prev) =>
|
||||
new Map(prev).set(url, {
|
||||
url,
|
||||
title: url,
|
||||
content: "",
|
||||
charCount: 0,
|
||||
isExtracting: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const data = await extractUrlContent(url)
|
||||
|
||||
const newUrlData = new Map(existing)
|
||||
newUrlData.set(url, data)
|
||||
onUrlChange(newUrlData)
|
||||
// Skip if the URL was removed while extracting
|
||||
onUrlChange((prev) =>
|
||||
prev.has(url) ? new Map(prev).set(url, data) : prev,
|
||||
)
|
||||
|
||||
setShowUrlDialog(false)
|
||||
} catch (error) {
|
||||
// Remove the URL from the data map on error
|
||||
const newUrlData = urlData
|
||||
? new Map(urlData)
|
||||
: new Map<string, UrlData>()
|
||||
newUrlData.delete(url)
|
||||
onUrlChange(newUrlData)
|
||||
onUrlChange((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(url)
|
||||
return next
|
||||
})
|
||||
showErrorToast(
|
||||
<span className="text-muted-foreground">
|
||||
{error instanceof Error
|
||||
@@ -463,11 +486,12 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
urlData={urlData}
|
||||
onRemoveUrl={
|
||||
onUrlChange
|
||||
? (url) => {
|
||||
const next = new Map(urlData)
|
||||
next.delete(url)
|
||||
onUrlChange(next)
|
||||
}
|
||||
? (url) =>
|
||||
onUrlChange((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(url)
|
||||
return next
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -559,7 +583,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,.svg,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
|
||||
multiple
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
@@ -588,7 +612,11 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isDisabled || !input.trim()}
|
||||
disabled={
|
||||
isDisabled ||
|
||||
isExtractingAttachments ||
|
||||
!input.trim()
|
||||
}
|
||||
size="sm"
|
||||
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
||||
aria-label={dict.chat.send}
|
||||
@@ -629,7 +657,11 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
<TemplateCreateDialog
|
||||
open={showSaveAsTemplate}
|
||||
onOpenChange={setShowSaveAsTemplate}
|
||||
onSuccess={() => setShowSaveAsTemplate(false)}
|
||||
onSuccess={() => {
|
||||
setShowSaveAsTemplate(false)
|
||||
// Let the template list in the lobby reload
|
||||
window.dispatchEvent(new Event("templatesChanged"))
|
||||
}}
|
||||
initialPrompt={input.trim()}
|
||||
/>
|
||||
</form>
|
||||
|
||||
@@ -194,6 +194,8 @@ export function ChatLobby({
|
||||
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
onKeyDown={(e) => {
|
||||
// Ignore keys bubbling up from the delete button
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (
|
||||
e.key === "Enter" ||
|
||||
e.key === " "
|
||||
|
||||
@@ -55,6 +55,9 @@ export function TemplateCreateDialog({
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// React submit events bubble through the portal; keep them away from
|
||||
// the chat form this dialog may be rendered in
|
||||
e.stopPropagation()
|
||||
|
||||
const trimmedPrompt = prompt.trim()
|
||||
if (!trimmedPrompt) {
|
||||
|
||||
@@ -39,16 +39,16 @@ export function TemplateEditDialog({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Populate form when template changes
|
||||
// Populate form each time the dialog opens, dropping any cancelled edits
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
if (open && template) {
|
||||
setTitle(template.title || "")
|
||||
setDescription(template.description || "")
|
||||
setPrompt(template.prompt || "")
|
||||
setPinned(template.pinned || false)
|
||||
setError(null)
|
||||
}
|
||||
}, [template])
|
||||
}, [open, template])
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
@@ -59,6 +59,9 @@ export function TemplateEditDialog({
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// React submit events bubble through the portal; keep them away from
|
||||
// any form this dialog may be rendered in
|
||||
e.stopPropagation()
|
||||
|
||||
if (!template) return
|
||||
|
||||
|
||||
@@ -110,6 +110,10 @@ export function TemplatePanel({
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates()
|
||||
// Reload when a template is saved elsewhere, e.g. from the chat input
|
||||
window.addEventListener("templatesChanged", loadTemplates)
|
||||
return () =>
|
||||
window.removeEventListener("templatesChanged", loadTemplates)
|
||||
}, [loadTemplates])
|
||||
|
||||
const handleCreateSuccess = () => {
|
||||
@@ -302,6 +306,28 @@ export function TemplatePanel({
|
||||
}
|
||||
}
|
||||
|
||||
// Shared by the empty state and the list, so import works in both
|
||||
const importInput = (
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleImport}
|
||||
className="hidden"
|
||||
/>
|
||||
)
|
||||
const importMessageBox = importMessage && (
|
||||
<div
|
||||
className={`text-xs px-3 py-2 rounded-lg ${
|
||||
importMessage.type === "success"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||||
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{importMessage.text}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Empty state: no templates at all
|
||||
if (!loading && templates.length === 0) {
|
||||
return (
|
||||
@@ -332,6 +358,18 @@ export function TemplatePanel({
|
||||
<Plus className="w-4 h-4" />
|
||||
{dict.templates.createFirst}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
{dict.templates.importTemplates}
|
||||
</button>
|
||||
{importInput}
|
||||
{importMessageBox && (
|
||||
<div className="mt-3">{importMessageBox}</div>
|
||||
)}
|
||||
|
||||
<TemplateCreateDialog
|
||||
open={createDialogOpen}
|
||||
@@ -389,27 +427,11 @@ export function TemplatePanel({
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
{dict.templates.importTemplates}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleImport}
|
||||
className="hidden"
|
||||
/>
|
||||
{importInput}
|
||||
</div>
|
||||
|
||||
{/* Import message */}
|
||||
{importMessage && (
|
||||
<div
|
||||
className={`text-xs px-3 py-2 rounded-lg ${
|
||||
importMessage.type === "success"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||||
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{importMessage.text}
|
||||
</div>
|
||||
)}
|
||||
{importMessageBox}
|
||||
|
||||
<div className="space-y-2">
|
||||
{loading
|
||||
@@ -447,6 +469,8 @@ export function TemplatePanel({
|
||||
handleTemplateClick(template)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
// Ignore keys bubbling up from the action buttons
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (
|
||||
e.key === "Enter" ||
|
||||
e.key === " "
|
||||
|
||||
@@ -66,7 +66,7 @@ export function ToolCallCard({
|
||||
dict,
|
||||
}: ToolCallCardProps) {
|
||||
const callId = part.toolCallId
|
||||
const { state, input, output } = part
|
||||
const { state, input, output, errorText } = part
|
||||
// Default to expanded for all states (user can manually collapse if needed)
|
||||
const isExpanded = expandedTools[callId] ?? true
|
||||
const toolName = part.type?.replace("tool-", "")
|
||||
@@ -92,6 +92,14 @@ export function ToolCallCard({
|
||||
}
|
||||
}
|
||||
|
||||
// Incomplete XML means the output hit the length limit, unless the user
|
||||
// stopped the generation themselves
|
||||
const isTruncated =
|
||||
state === "output-error" &&
|
||||
errorText !== "Stopped by user" &&
|
||||
(toolName === "display_diagram" || toolName === "append_diagram") &&
|
||||
!isMxCellXmlComplete(input?.xml)
|
||||
|
||||
const handleCopy = () => {
|
||||
let textToCopy = ""
|
||||
|
||||
@@ -161,22 +169,15 @@ export function ToolCallCard({
|
||||
</>
|
||||
)}
|
||||
{state === "output-error" &&
|
||||
(() => {
|
||||
// Check if this is a truncation (incomplete XML) vs real error
|
||||
const isTruncated =
|
||||
(toolName === "display_diagram" ||
|
||||
toolName === "append_diagram") &&
|
||||
!isMxCellXmlComplete(input?.xml)
|
||||
return isTruncated ? (
|
||||
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
||||
Truncated
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
||||
Error
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
(isTruncated ? (
|
||||
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
||||
Truncated
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
||||
Error
|
||||
</span>
|
||||
))}
|
||||
{input && Object.keys(input).length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -224,23 +225,16 @@ export function ToolCallCard({
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{output &&
|
||||
state === "output-error" &&
|
||||
(() => {
|
||||
const isTruncated =
|
||||
(toolName === "display_diagram" ||
|
||||
toolName === "append_diagram") &&
|
||||
!isMxCellXmlComplete(input?.xml)
|
||||
return (
|
||||
<div
|
||||
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
||||
>
|
||||
{isTruncated
|
||||
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
|
||||
: output}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{/* AI SDK stores tool errors in errorText */}
|
||||
{state === "output-error" && (errorText || output) && (
|
||||
<div
|
||||
className={`px-4 py-3 border-t border-border/40 text-sm whitespace-pre-wrap break-words ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
||||
>
|
||||
{isTruncated
|
||||
? "Output truncated due to length limits. Try a simpler request or increase Max Output Tokens in Settings."
|
||||
: (errorText ?? output)}
|
||||
</div>
|
||||
)}
|
||||
{/* Show get_shape_library output on success */}
|
||||
{output &&
|
||||
toolName === "get_shape_library" &&
|
||||
|
||||
@@ -13,4 +13,5 @@ export interface ToolPartLike {
|
||||
operations?: DiagramOperation[]
|
||||
} & Record<string, unknown>
|
||||
output?: string
|
||||
errorText?: string
|
||||
}
|
||||
|
||||
+6
-3
@@ -1,4 +1,4 @@
|
||||
import { extractText, getDocumentProxy } from "unpdf"
|
||||
import { extractText } from "unpdf"
|
||||
|
||||
// Maximum characters allowed for extracted text (configurable via env)
|
||||
const DEFAULT_MAX_EXTRACTED_CHARS = 150000 // 150k chars
|
||||
@@ -14,6 +14,7 @@ const TEXT_EXTENSIONS = [
|
||||
".json",
|
||||
".csv",
|
||||
".xml",
|
||||
".svg",
|
||||
".html",
|
||||
".css",
|
||||
".js",
|
||||
@@ -43,8 +44,10 @@ const TEXT_EXTENSIONS = [
|
||||
*/
|
||||
export async function extractPdfText(file: File): Promise<string> {
|
||||
const buffer = await file.arrayBuffer()
|
||||
const pdf = await getDocumentProxy(new Uint8Array(buffer))
|
||||
const { text } = await extractText(pdf, { mergePages: true })
|
||||
// Pass raw bytes so unpdf destroys the PDF document when it is done
|
||||
const { text } = await extractText(new Uint8Array(buffer), {
|
||||
mergePages: true,
|
||||
})
|
||||
return text as string
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
|
||||
export interface UrlData {
|
||||
url: string
|
||||
@@ -18,7 +19,11 @@ const UrlResponseSchema = z.object({
|
||||
export async function extractUrlContent(url: string): Promise<UrlData> {
|
||||
const response = await fetch(getApiEndpoint("/api/parse-url"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-access-code":
|
||||
localStorage.getItem(STORAGE_KEYS.accessCode) || "",
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
})
|
||||
|
||||
|
||||
+55
-61
@@ -27,78 +27,72 @@ export function useFileProcessor() {
|
||||
const handleFileChange = async (newFiles: File[]) => {
|
||||
setFiles(newFiles)
|
||||
|
||||
// Extract text immediately for new PDF/text files
|
||||
for (const file of newFiles) {
|
||||
const needsExtraction =
|
||||
(isPdfFile(file) || isTextFile(file)) && !pdfData.has(file)
|
||||
if (needsExtraction) {
|
||||
// Mark as extracting
|
||||
setPdfData((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(file, {
|
||||
text: "",
|
||||
charCount: 0,
|
||||
isExtracting: true,
|
||||
})
|
||||
return next
|
||||
})
|
||||
const pending = newFiles.filter(
|
||||
(file) =>
|
||||
(isPdfFile(file) || isTextFile(file)) && !pdfData.has(file),
|
||||
)
|
||||
|
||||
// Extract text asynchronously
|
||||
try {
|
||||
let text: string
|
||||
if (isPdfFile(file)) {
|
||||
text = await extractPdfText(file)
|
||||
} else {
|
||||
text = await extractTextFileContent(file)
|
||||
}
|
||||
// Before any await: drop data for removed files and mark every new
|
||||
// file as extracting, so queued files also block sending
|
||||
setPdfData((prev) => {
|
||||
const next = new Map<File, FileData>()
|
||||
for (const file of newFiles) {
|
||||
const existing = prev.get(file)
|
||||
if (existing) next.set(file, existing)
|
||||
}
|
||||
for (const file of pending) {
|
||||
next.set(file, { text: "", charCount: 0, isExtracting: true })
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
// Check character limit
|
||||
if (text.length > MAX_EXTRACTED_CHARS) {
|
||||
const limitK = MAX_EXTRACTED_CHARS / 1000
|
||||
toast.error(
|
||||
`${file.name}: Content exceeds ${limitK}k character limit (${(text.length / 1000).toFixed(1)}k chars)`,
|
||||
)
|
||||
setPdfData((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(file)
|
||||
return next
|
||||
})
|
||||
// Remove the file from the list
|
||||
setFiles((prev) => prev.filter((f) => f !== file))
|
||||
continue
|
||||
}
|
||||
// Extract one file at a time
|
||||
for (const file of pending) {
|
||||
try {
|
||||
let text: string
|
||||
if (isPdfFile(file)) {
|
||||
text = await extractPdfText(file)
|
||||
} else {
|
||||
text = await extractTextFileContent(file)
|
||||
}
|
||||
|
||||
setPdfData((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(file, {
|
||||
text,
|
||||
charCount: text.length,
|
||||
isExtracting: false,
|
||||
})
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to extract text:", error)
|
||||
toast.error(`Failed to read file: ${file.name}`)
|
||||
// Check character limit
|
||||
if (text.length > MAX_EXTRACTED_CHARS) {
|
||||
const limitK = MAX_EXTRACTED_CHARS / 1000
|
||||
toast.error(
|
||||
`${file.name}: Content exceeds ${limitK}k character limit (${(text.length / 1000).toFixed(1)}k chars)`,
|
||||
)
|
||||
setPdfData((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(file)
|
||||
return next
|
||||
})
|
||||
// Remove the file from the list
|
||||
setFiles((prev) => prev.filter((f) => f !== file))
|
||||
continue
|
||||
}
|
||||
|
||||
setPdfData((prev) => {
|
||||
// The file was removed while extracting
|
||||
if (!prev.has(file)) return prev
|
||||
const next = new Map(prev)
|
||||
next.set(file, {
|
||||
text,
|
||||
charCount: text.length,
|
||||
isExtracting: false,
|
||||
})
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to extract text:", error)
|
||||
toast.error(`Failed to read file: ${file.name}`)
|
||||
setPdfData((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(file)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up pdfData for removed files
|
||||
setPdfData((prev) => {
|
||||
const next = new Map(prev)
|
||||
for (const key of prev.keys()) {
|
||||
if (!newFiles.includes(key)) {
|
||||
next.delete(key)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { isTextFile } from "@/lib/pdf-utils"
|
||||
|
||||
describe("isTextFile", () => {
|
||||
it("treats SVG files as text so their markup is sent to the model", () => {
|
||||
const svg = new File(["<svg/>"], "diagram.svg", {
|
||||
type: "image/svg+xml",
|
||||
})
|
||||
expect(isTextFile(svg)).toBe(true)
|
||||
})
|
||||
|
||||
it("does not treat raster images as text", () => {
|
||||
const png = new File(["x"], "photo.png", { type: "image/png" })
|
||||
expect(isTextFile(png)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import { extractUrlContent } from "@/lib/url-utils"
|
||||
|
||||
describe("extractUrlContent", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it("sends the saved access code with the request", async () => {
|
||||
localStorage.setItem(STORAGE_KEYS.accessCode, "secret")
|
||||
const body = { title: "T", content: "body", charCount: 4 }
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(JSON.stringify(body)))
|
||||
vi.stubGlobal("fetch", fetchMock)
|
||||
|
||||
const data = await extractUrlContent("https://example.com")
|
||||
|
||||
expect(data.content).toBe("body")
|
||||
const headers = fetchMock.mock.calls[0][1].headers
|
||||
expect(headers["x-access-code"]).toBe("secret")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { act, renderHook } from "@testing-library/react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { extractPdfText, extractTextFileContent } from "@/lib/pdf-utils"
|
||||
import { useFileProcessor } from "@/lib/use-file-processor"
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }))
|
||||
vi.mock("@/lib/pdf-utils", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/pdf-utils")>()),
|
||||
extractPdfText: vi.fn(),
|
||||
extractTextFileContent: vi.fn(),
|
||||
}))
|
||||
|
||||
// A promise we can resolve from the test, to control extraction timing
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
const pdfFile = () =>
|
||||
new File(["%PDF"], "slow.pdf", { type: "application/pdf" })
|
||||
const textFile = () => new File(["notes"], "notes.txt", { type: "text/plain" })
|
||||
|
||||
describe("useFileProcessor", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(extractPdfText).mockReset()
|
||||
vi.mocked(extractTextFileContent).mockReset()
|
||||
})
|
||||
|
||||
it("marks queued files as extracting before the first one finishes", async () => {
|
||||
const pdf = deferred<string>()
|
||||
vi.mocked(extractPdfText).mockReturnValue(pdf.promise)
|
||||
vi.mocked(extractTextFileContent).mockResolvedValue("notes")
|
||||
const a = pdfFile()
|
||||
const b = textFile()
|
||||
const { result } = renderHook(() => useFileProcessor())
|
||||
|
||||
let done!: Promise<void>
|
||||
act(() => {
|
||||
done = result.current.handleFileChange([a, b])
|
||||
})
|
||||
|
||||
expect(result.current.pdfData.get(a)?.isExtracting).toBe(true)
|
||||
expect(result.current.pdfData.get(b)?.isExtracting).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
pdf.resolve("pdf text")
|
||||
await done
|
||||
})
|
||||
expect(result.current.pdfData.get(a)?.text).toBe("pdf text")
|
||||
expect(result.current.pdfData.get(b)?.text).toBe("notes")
|
||||
})
|
||||
|
||||
it("keeps text of a file added while an earlier file is extracting", async () => {
|
||||
const pdf = deferred<string>()
|
||||
vi.mocked(extractPdfText).mockReturnValue(pdf.promise)
|
||||
vi.mocked(extractTextFileContent).mockResolvedValue("notes")
|
||||
const a = pdfFile()
|
||||
const b = textFile()
|
||||
const { result } = renderHook(() => useFileProcessor())
|
||||
|
||||
let first!: Promise<void>
|
||||
act(() => {
|
||||
first = result.current.handleFileChange([a])
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleFileChange([a, b])
|
||||
})
|
||||
expect(result.current.pdfData.get(b)?.text).toBe("notes")
|
||||
|
||||
await act(async () => {
|
||||
pdf.resolve("pdf text")
|
||||
await first
|
||||
})
|
||||
expect(result.current.pdfData.get(a)?.text).toBe("pdf text")
|
||||
expect(result.current.pdfData.get(b)?.text).toBe("notes")
|
||||
})
|
||||
|
||||
it("does not bring back a file removed while extracting", async () => {
|
||||
const pdf = deferred<string>()
|
||||
vi.mocked(extractPdfText).mockReturnValue(pdf.promise)
|
||||
const a = pdfFile()
|
||||
const { result } = renderHook(() => useFileProcessor())
|
||||
|
||||
let first!: Promise<void>
|
||||
act(() => {
|
||||
first = result.current.handleFileChange([a])
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleFileChange([])
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
pdf.resolve("pdf text")
|
||||
await first
|
||||
})
|
||||
expect(result.current.pdfData.has(a)).toBe(false)
|
||||
expect(result.current.files).toEqual([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user