mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-03 10:00:22 +08:00
fix: resolve TypeScript errors in electron-standalone
- Add forwardRef support to ChatInput component with ChatInputRef type - Copy electron.d.ts to electron-standalone/electron folder - Exclude electron-standalone from root tsconfig type checking
This commit is contained in:
@@ -9,7 +9,14 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
import { ErrorToast } from "@/components/error-toast"
|
import { ErrorToast } from "@/components/error-toast"
|
||||||
@@ -138,6 +145,10 @@ function showValidationErrors(errors: string[], dict: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatInputRef {
|
||||||
|
focus: () => void
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
input: string
|
input: string
|
||||||
status: "submitted" | "streaming" | "ready" | "error"
|
status: "submitted" | "streaming" | "ready" | "error"
|
||||||
@@ -165,134 +176,212 @@ interface ChatInputProps {
|
|||||||
onFocused?: () => void
|
onFocused?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({
|
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||||
input,
|
function ChatInput(
|
||||||
status,
|
{
|
||||||
onSubmit,
|
input,
|
||||||
onChange,
|
status,
|
||||||
files = [],
|
onSubmit,
|
||||||
onFileChange = () => {},
|
onChange,
|
||||||
pdfData = new Map(),
|
files = [],
|
||||||
urlData,
|
onFileChange = () => {},
|
||||||
onUrlChange,
|
pdfData = new Map(),
|
||||||
sessionId,
|
urlData,
|
||||||
error = null,
|
onUrlChange,
|
||||||
models = [],
|
sessionId,
|
||||||
selectedModelId,
|
error = null,
|
||||||
onModelSelect = () => {},
|
models = [],
|
||||||
showUnvalidatedModels = false,
|
selectedModelId,
|
||||||
onConfigureModels = () => {},
|
onModelSelect = () => {},
|
||||||
shouldFocus = false,
|
showUnvalidatedModels = false,
|
||||||
onFocused,
|
onConfigureModels = () => {},
|
||||||
}: ChatInputProps) {
|
shouldFocus = false,
|
||||||
const dict = useDictionary()
|
onFocused,
|
||||||
const {
|
},
|
||||||
chartXML,
|
ref,
|
||||||
diagramHistory,
|
) {
|
||||||
saveDiagramToFile,
|
const dict = useDictionary()
|
||||||
showSaveDialog,
|
const {
|
||||||
setShowSaveDialog,
|
chartXML,
|
||||||
} = useDiagram()
|
diagramHistory,
|
||||||
|
saveDiagramToFile,
|
||||||
|
showSaveDialog,
|
||||||
|
setShowSaveDialog,
|
||||||
|
} = useDiagram()
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
// Focus the textarea when shouldFocus becomes true
|
// Expose focus method via ref
|
||||||
// Use setTimeout to ensure focus happens after drawio iframe settles
|
useImperativeHandle(ref, () => ({
|
||||||
useEffect(() => {
|
focus: () => {
|
||||||
if (shouldFocus) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
textareaRef.current?.focus()
|
textareaRef.current?.focus()
|
||||||
onFocused?.()
|
},
|
||||||
}, 150)
|
}))
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [shouldFocus, onFocused])
|
|
||||||
|
|
||||||
const [showHistory, setShowHistory] = useState(false)
|
// Focus the textarea when shouldFocus becomes true
|
||||||
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
// Use setTimeout to ensure focus happens after drawio iframe settles
|
||||||
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
useEffect(() => {
|
||||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
if (shouldFocus) {
|
||||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
const timer = setTimeout(() => {
|
||||||
const isDisabled =
|
textareaRef.current?.focus()
|
||||||
(status === "streaming" || status === "submitted") && !error
|
onFocused?.()
|
||||||
|
}, 150)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [shouldFocus, onFocused])
|
||||||
|
|
||||||
const adjustTextareaHeight = useCallback(() => {
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
const textarea = textareaRef.current
|
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
||||||
if (textarea) {
|
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
||||||
textarea.style.height = "auto"
|
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
||||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
||||||
}
|
const isDisabled =
|
||||||
}, [])
|
(status === "streaming" || status === "submitted") && !error
|
||||||
// Handle programmatic input changes (e.g., setInput("") after form submission)
|
|
||||||
useEffect(() => {
|
|
||||||
adjustTextareaHeight()
|
|
||||||
}, [input, adjustTextareaHeight])
|
|
||||||
|
|
||||||
// Load send shortcut preference from localStorage and listen for changes
|
const adjustTextareaHeight = useCallback(() => {
|
||||||
useEffect(() => {
|
const textarea = textareaRef.current
|
||||||
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
|
if (textarea) {
|
||||||
if (stored) setSendShortcut(stored)
|
textarea.style.height = "auto"
|
||||||
|
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
// Handle programmatic input changes (e.g., setInput("") after form submission)
|
||||||
|
useEffect(() => {
|
||||||
|
adjustTextareaHeight()
|
||||||
|
}, [input, adjustTextareaHeight])
|
||||||
|
|
||||||
const handleChange = (e: CustomEvent<string>) =>
|
// Load send shortcut preference from localStorage and listen for changes
|
||||||
setSendShortcut(e.detail)
|
useEffect(() => {
|
||||||
window.addEventListener(
|
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
|
||||||
"sendShortcutChange",
|
if (stored) setSendShortcut(stored)
|
||||||
handleChange as EventListener,
|
|
||||||
)
|
const handleChange = (e: CustomEvent<string>) =>
|
||||||
return () =>
|
setSendShortcut(e.detail)
|
||||||
window.removeEventListener(
|
window.addEventListener(
|
||||||
"sendShortcutChange",
|
"sendShortcutChange",
|
||||||
handleChange as EventListener,
|
handleChange as EventListener,
|
||||||
)
|
)
|
||||||
}, [])
|
return () =>
|
||||||
|
window.removeEventListener(
|
||||||
|
"sendShortcutChange",
|
||||||
|
handleChange as EventListener,
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
onChange(e)
|
onChange(e)
|
||||||
adjustTextareaHeight()
|
adjustTextareaHeight()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
const shouldSend =
|
const shouldSend =
|
||||||
sendShortcut === "enter"
|
sendShortcut === "enter"
|
||||||
? e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey
|
? e.key === "Enter" &&
|
||||||
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
|
!e.shiftKey &&
|
||||||
|
!e.ctrlKey &&
|
||||||
|
!e.metaKey
|
||||||
|
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
|
||||||
|
|
||||||
if (shouldSend) {
|
if (shouldSend) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const form = e.currentTarget.closest("form")
|
const form = e.currentTarget.closest("form")
|
||||||
if (form && input.trim() && !isDisabled) {
|
if (form && input.trim() && !isDisabled) {
|
||||||
form.requestSubmit()
|
form.requestSubmit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||||
if (isDisabled) return
|
if (isDisabled) return
|
||||||
|
|
||||||
const items = e.clipboardData.items
|
const items = e.clipboardData.items
|
||||||
const imageItems = Array.from(items).filter((item) =>
|
const imageItems = Array.from(items).filter((item) =>
|
||||||
item.type.startsWith("image/"),
|
item.type.startsWith("image/"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (imageItems.length > 0) {
|
if (imageItems.length > 0) {
|
||||||
const imageFiles = (
|
const imageFiles = (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
imageItems.map(async (item, index) => {
|
imageItems.map(async (item, index) => {
|
||||||
const file = item.getAsFile()
|
const file = item.getAsFile()
|
||||||
if (!file) return null
|
if (!file) return null
|
||||||
return new File(
|
return new File(
|
||||||
[file],
|
[file],
|
||||||
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
||||||
{ type: file.type },
|
{ type: file.type },
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
)
|
||||||
|
).filter((f): f is File => f !== null)
|
||||||
|
|
||||||
|
const { validFiles, errors } = validateFiles(
|
||||||
|
imageFiles,
|
||||||
|
files.length,
|
||||||
|
dict,
|
||||||
)
|
)
|
||||||
).filter((f): f is File => f !== null)
|
showValidationErrors(errors, dict)
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
onFileChange([...files, ...validFiles])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newFiles = Array.from(e.target.files || [])
|
||||||
|
const { validFiles, errors } = validateFiles(
|
||||||
|
newFiles,
|
||||||
|
files.length,
|
||||||
|
dict,
|
||||||
|
)
|
||||||
|
showValidationErrors(errors, dict)
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
onFileChange([...files, ...validFiles])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveFile = (fileToRemove: File) => {
|
||||||
|
onFileChange(files.filter((file) => file !== fileToRemove))
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerFileInput = () => {
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(false)
|
||||||
|
|
||||||
|
if (isDisabled) return
|
||||||
|
|
||||||
|
const droppedFiles = e.dataTransfer.files
|
||||||
|
const supportedFiles = Array.from(droppedFiles).filter((file) =>
|
||||||
|
isValidFileType(file),
|
||||||
|
)
|
||||||
|
|
||||||
const { validFiles, errors } = validateFiles(
|
const { validFiles, errors } = validateFiles(
|
||||||
imageFiles,
|
supportedFiles,
|
||||||
files.length,
|
files.length,
|
||||||
dict,
|
dict,
|
||||||
)
|
)
|
||||||
@@ -301,278 +390,219 @@ export function ChatInput({
|
|||||||
onFileChange([...files, ...validFiles])
|
onFileChange([...files, ...validFiles])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUrlExtract = async (url: string) => {
|
||||||
const newFiles = Array.from(e.target.files || [])
|
if (!onUrlChange) return
|
||||||
const { validFiles, errors } = validateFiles(
|
|
||||||
newFiles,
|
setIsExtractingUrl(true)
|
||||||
files.length,
|
|
||||||
dict,
|
try {
|
||||||
)
|
const existing = urlData
|
||||||
showValidationErrors(errors, dict)
|
? new Map(urlData)
|
||||||
if (validFiles.length > 0) {
|
: new Map<string, UrlData>()
|
||||||
onFileChange([...files, ...validFiles])
|
existing.set(url, {
|
||||||
|
url,
|
||||||
|
title: url,
|
||||||
|
content: "",
|
||||||
|
charCount: 0,
|
||||||
|
isExtracting: true,
|
||||||
|
})
|
||||||
|
onUrlChange(existing)
|
||||||
|
|
||||||
|
const data = await extractUrlContent(url)
|
||||||
|
|
||||||
|
const newUrlData = new Map(existing)
|
||||||
|
newUrlData.set(url, data)
|
||||||
|
onUrlChange(newUrlData)
|
||||||
|
|
||||||
|
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)
|
||||||
|
showErrorToast(
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to extract URL content"}
|
||||||
|
</span>,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsExtractingUrl(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileInputRef.current) {
|
return (
|
||||||
fileInputRef.current.value = ""
|
<form
|
||||||
}
|
onSubmit={onSubmit}
|
||||||
}
|
className={`w-full transition-all duration-200 ${
|
||||||
|
isDragging
|
||||||
const handleRemoveFile = (fileToRemove: File) => {
|
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
|
||||||
onFileChange(files.filter((file) => file !== fileToRemove))
|
: ""
|
||||||
if (fileInputRef.current) {
|
}`}
|
||||||
fileInputRef.current.value = ""
|
onDragOver={handleDragOver}
|
||||||
}
|
onDragLeave={handleDragLeave}
|
||||||
}
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
const triggerFileInput = () => {
|
{/* File & URL previews */}
|
||||||
fileInputRef.current?.click()
|
{(files.length > 0 || (urlData && urlData.size > 0)) && (
|
||||||
}
|
<div className="mb-3">
|
||||||
|
<FilePreviewList
|
||||||
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
|
files={files}
|
||||||
e.preventDefault()
|
onRemoveFile={handleRemoveFile}
|
||||||
e.stopPropagation()
|
pdfData={pdfData}
|
||||||
setIsDragging(true)
|
urlData={urlData}
|
||||||
}
|
onRemoveUrl={
|
||||||
|
onUrlChange
|
||||||
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
|
? (url) => {
|
||||||
e.preventDefault()
|
const next = new Map(urlData)
|
||||||
e.stopPropagation()
|
next.delete(url)
|
||||||
setIsDragging(false)
|
onUrlChange(next)
|
||||||
}
|
}
|
||||||
|
: undefined
|
||||||
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
|
}
|
||||||
e.preventDefault()
|
/>
|
||||||
e.stopPropagation()
|
</div>
|
||||||
setIsDragging(false)
|
)}
|
||||||
|
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
|
||||||
if (isDisabled) return
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
const droppedFiles = e.dataTransfer.files
|
value={input}
|
||||||
const supportedFiles = Array.from(droppedFiles).filter((file) =>
|
onChange={handleChange}
|
||||||
isValidFileType(file),
|
onKeyDown={handleKeyDown}
|
||||||
)
|
onPaste={handlePaste}
|
||||||
|
placeholder={dict.chat.placeholder}
|
||||||
const { validFiles, errors } = validateFiles(
|
disabled={isDisabled}
|
||||||
supportedFiles,
|
aria-label="Chat input"
|
||||||
files.length,
|
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
|
||||||
dict,
|
|
||||||
)
|
|
||||||
showValidationErrors(errors, dict)
|
|
||||||
if (validFiles.length > 0) {
|
|
||||||
onFileChange([...files, ...validFiles])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUrlExtract = async (url: string) => {
|
|
||||||
if (!onUrlChange) return
|
|
||||||
|
|
||||||
setIsExtractingUrl(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const existing = urlData
|
|
||||||
? new Map(urlData)
|
|
||||||
: new Map<string, UrlData>()
|
|
||||||
existing.set(url, {
|
|
||||||
url,
|
|
||||||
title: url,
|
|
||||||
content: "",
|
|
||||||
charCount: 0,
|
|
||||||
isExtracting: true,
|
|
||||||
})
|
|
||||||
onUrlChange(existing)
|
|
||||||
|
|
||||||
const data = await extractUrlContent(url)
|
|
||||||
|
|
||||||
const newUrlData = new Map(existing)
|
|
||||||
newUrlData.set(url, data)
|
|
||||||
onUrlChange(newUrlData)
|
|
||||||
|
|
||||||
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)
|
|
||||||
showErrorToast(
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to extract URL content"}
|
|
||||||
</span>,
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
setIsExtractingUrl(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
className={`w-full transition-all duration-200 ${
|
|
||||||
isDragging
|
|
||||||
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
{/* File & URL previews */}
|
|
||||||
{(files.length > 0 || (urlData && urlData.size > 0)) && (
|
|
||||||
<div className="mb-3">
|
|
||||||
<FilePreviewList
|
|
||||||
files={files}
|
|
||||||
onRemoveFile={handleRemoveFile}
|
|
||||||
pdfData={pdfData}
|
|
||||||
urlData={urlData}
|
|
||||||
onRemoveUrl={
|
|
||||||
onUrlChange
|
|
||||||
? (url) => {
|
|
||||||
const next = new Map(urlData)
|
|
||||||
next.delete(url)
|
|
||||||
onUrlChange(next)
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
|
|
||||||
<Textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={input}
|
|
||||||
onChange={handleChange}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onPaste={handlePaste}
|
|
||||||
placeholder={dict.chat.placeholder}
|
|
||||||
disabled={isDisabled}
|
|
||||||
aria-label="Chat input"
|
|
||||||
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
|
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
|
||||||
<div className="flex items-center gap-1 overflow-x-hidden">
|
<div className="flex items-center gap-1 overflow-x-hidden">
|
||||||
<ButtonWithTooltip
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowHistory(true)}
|
|
||||||
disabled={isDisabled || diagramHistory.length === 0}
|
|
||||||
tooltipContent={dict.chat.diagramHistory}
|
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<History className="h-4 w-4" />
|
|
||||||
</ButtonWithTooltip>
|
|
||||||
|
|
||||||
<ButtonWithTooltip
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowSaveDialog(true)}
|
|
||||||
disabled={isDisabled || !isRealDiagram(chartXML)}
|
|
||||||
tooltipContent={dict.chat.saveDiagram}
|
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
</ButtonWithTooltip>
|
|
||||||
|
|
||||||
<ButtonWithTooltip
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={triggerFileInput}
|
|
||||||
disabled={isDisabled}
|
|
||||||
tooltipContent={dict.chat.uploadFile}
|
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ImageIcon className="h-4 w-4" />
|
|
||||||
</ButtonWithTooltip>
|
|
||||||
|
|
||||||
{onUrlChange && (
|
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowUrlDialog(true)}
|
onClick={() => setShowHistory(true)}
|
||||||
disabled={isDisabled}
|
disabled={
|
||||||
tooltipContent={dict.chat.ExtractURL}
|
isDisabled || diagramHistory.length === 0
|
||||||
|
}
|
||||||
|
tooltipContent={dict.chat.diagramHistory}
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<Link className="h-4 w-4" />
|
<History className="h-4 w-4" />
|
||||||
</ButtonWithTooltip>
|
</ButtonWithTooltip>
|
||||||
)}
|
|
||||||
|
|
||||||
<input
|
<ButtonWithTooltip
|
||||||
type="file"
|
type="button"
|
||||||
ref={fileInputRef}
|
variant="ghost"
|
||||||
className="hidden"
|
size="sm"
|
||||||
onChange={handleFileChange}
|
onClick={() => setShowSaveDialog(true)}
|
||||||
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
|
disabled={
|
||||||
multiple
|
isDisabled || !isRealDiagram(chartXML)
|
||||||
|
}
|
||||||
|
tooltipContent={dict.chat.saveDiagram}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</ButtonWithTooltip>
|
||||||
|
|
||||||
|
<ButtonWithTooltip
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={triggerFileInput}
|
||||||
|
disabled={isDisabled}
|
||||||
|
tooltipContent={dict.chat.uploadFile}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
</ButtonWithTooltip>
|
||||||
|
|
||||||
|
{onUrlChange && (
|
||||||
|
<ButtonWithTooltip
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowUrlDialog(true)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
tooltipContent={dict.chat.ExtractURL}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Link className="h-4 w-4" />
|
||||||
|
</ButtonWithTooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
|
||||||
|
multiple
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ModelSelector
|
||||||
|
models={models}
|
||||||
|
selectedModelId={selectedModelId}
|
||||||
|
onSelect={onModelSelect}
|
||||||
|
onConfigure={onConfigureModels}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
showUnvalidatedModels={showUnvalidatedModels}
|
||||||
/>
|
/>
|
||||||
|
<div className="w-px h-5 bg-border mx-1" />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isDisabled || !input.trim()}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
||||||
|
aria-label={
|
||||||
|
isDisabled ? dict.chat.sending : dict.chat.send
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isDisabled ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="h-4 w-4 mr-1.5" />
|
||||||
|
{dict.chat.send}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ModelSelector
|
|
||||||
models={models}
|
|
||||||
selectedModelId={selectedModelId}
|
|
||||||
onSelect={onModelSelect}
|
|
||||||
onConfigure={onConfigureModels}
|
|
||||||
disabled={isDisabled}
|
|
||||||
showUnvalidatedModels={showUnvalidatedModels}
|
|
||||||
/>
|
|
||||||
<div className="w-px h-5 bg-border mx-1" />
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isDisabled || !input.trim()}
|
|
||||||
size="sm"
|
|
||||||
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
|
||||||
aria-label={
|
|
||||||
isDisabled ? dict.chat.sending : dict.chat.send
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isDisabled ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Send className="h-4 w-4 mr-1.5" />
|
|
||||||
{dict.chat.send}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<HistoryDialog
|
||||||
<HistoryDialog
|
showHistory={showHistory}
|
||||||
showHistory={showHistory}
|
onToggleHistory={setShowHistory}
|
||||||
onToggleHistory={setShowHistory}
|
|
||||||
/>
|
|
||||||
<SaveDialog
|
|
||||||
open={showSaveDialog}
|
|
||||||
onOpenChange={setShowSaveDialog}
|
|
||||||
onSave={(filename, format) =>
|
|
||||||
saveDiagramToFile(
|
|
||||||
filename,
|
|
||||||
format,
|
|
||||||
sessionId,
|
|
||||||
dict.save.savedSuccessfully,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
defaultFilename={`diagram-${new Date()
|
|
||||||
.toISOString()
|
|
||||||
.slice(0, 10)}`}
|
|
||||||
/>
|
|
||||||
{onUrlChange && (
|
|
||||||
<UrlInputDialog
|
|
||||||
open={showUrlDialog}
|
|
||||||
onOpenChange={setShowUrlDialog}
|
|
||||||
onSubmit={handleUrlExtract}
|
|
||||||
isExtracting={isExtractingUrl}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<SaveDialog
|
||||||
</form>
|
open={showSaveDialog}
|
||||||
)
|
onOpenChange={setShowSaveDialog}
|
||||||
}
|
onSave={(filename, format) =>
|
||||||
|
saveDiagramToFile(
|
||||||
|
filename,
|
||||||
|
format,
|
||||||
|
sessionId,
|
||||||
|
dict.save.savedSuccessfully,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
defaultFilename={`diagram-${new Date()
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10)}`}
|
||||||
|
/>
|
||||||
|
{onUrlChange && (
|
||||||
|
<UrlInputDialog
|
||||||
|
open={showUrlDialog}
|
||||||
|
onOpenChange={setShowUrlDialog}
|
||||||
|
onSubmit={handleUrlExtract}
|
||||||
|
isExtracting={isExtractingUrl}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -30,5 +30,11 @@
|
|||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".next/dev/types/**/*.ts"
|
".next/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules", "packages", "electron", "dist-electron"]
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"packages",
|
||||||
|
"electron",
|
||||||
|
"electron-standalone",
|
||||||
|
"dist-electron"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user