From fac1404e141be0f754f7f2d4ae8f4b90b3d6b8b6 Mon Sep 17 00:00:00 2001 From: Akhileshrangani4 Date: Mon, 4 Nov 2024 14:21:13 -0500 Subject: [PATCH] feat: multi-file context, context tabs - added context tabs - added multifile context including file and image uploads to the context along with all the files from the project - added file/image previews on input - added code paste from the editor and file lines recognition - added image paste from clipboard and preview --- .../components/editor/AIChat/ChatInput.tsx | 158 +- .../components/editor/AIChat/ChatMessage.tsx | 129 +- .../editor/AIChat/ContextDisplay.tsx | 60 - .../components/editor/AIChat/ContextTabs.tsx | 77 +- frontend/components/editor/AIChat/index.tsx | 103 +- .../components/editor/AIChat/lib/chatUtils.ts | 61 +- .../editor/AIChat/lib/markdownComponents.tsx | 79 + .../components/editor/AIChat/types/index.ts | 93 + package-lock.json | 1669 +++++++++++++++++ package.json | 3 + 10 files changed, 2086 insertions(+), 346 deletions(-) delete mode 100644 frontend/components/editor/AIChat/ContextDisplay.tsx create mode 100644 frontend/components/editor/AIChat/lib/markdownComponents.tsx create mode 100644 frontend/components/editor/AIChat/types/index.ts diff --git a/frontend/components/editor/AIChat/ChatInput.tsx b/frontend/components/editor/AIChat/ChatInput.tsx index 499591b..0cf1e92 100644 --- a/frontend/components/editor/AIChat/ChatInput.tsx +++ b/frontend/components/editor/AIChat/ChatInput.tsx @@ -1,27 +1,10 @@ -import { Send, StopCircle, AtSign, Image as ImageIcon } from "lucide-react" +import { Send, StopCircle, Image as ImageIcon, Paperclip } from "lucide-react" import { Button } from "../../ui/button" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select" -import { useRef, useEffect, useState } from "react" -import * as monaco from 'monaco-editor' +import { useEffect } from "react" import { TFile, TFolder } from "@/lib/types" - -interface ChatInputProps { - input: string - setInput: (input: string) => void - isGenerating: boolean - handleSend: (useFullContext?: boolean) => void - handleStopGeneration: () => void - onImageUpload: (file: File) => void - onFileMention: (fileName: string) => void - addContextTab: (type: string, title: string, content: string, lineRange?: { start: number, end: number }) => void - activeFileName?: string - editorRef: React.MutableRefObject - lastCopiedRangeRef: React.MutableRefObject<{ startLine: number; endLine: number } | null> - contextTabs: { id: string; type: string; title: string; content: string; lineRange?: { start: number; end: number } }[] - onRemoveTab: (id: string) => void - textareaRef: React.RefObject - files: (TFile | TFolder)[] -} +import { ALLOWED_FILE_TYPES } from "./types" +import { looksLikeCode } from "./lib/chatUtils" +import { ChatInputProps } from "./types" export default function ChatInput({ input, @@ -30,7 +13,6 @@ export default function ChatInput({ handleSend, handleStopGeneration, onImageUpload, - onFileMention, addContextTab, activeFileName, editorRef, @@ -38,8 +20,8 @@ export default function ChatInput({ contextTabs, onRemoveTab, textareaRef, - files, }: ChatInputProps) { + // Auto-resize textarea as content changes useEffect(() => { if (textareaRef.current) { @@ -48,6 +30,7 @@ export default function ChatInput({ } }, [input]) + // Handle keyboard events for sending messages const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { if (e.ctrlKey) { @@ -65,6 +48,7 @@ export default function ChatInput({ } } + // Handle paste events for image and code const handlePaste = async (e: React.ClipboardEvent) => { // Handle image paste const items = Array.from(e.clipboardData.items); @@ -76,12 +60,17 @@ export default function ChatInput({ if (!file) continue; try { + // Convert image to base64 string for context tab title and timestamp const reader = new FileReader(); reader.onload = () => { const base64String = reader.result as string; addContextTab( "image", - `Image ${new Date().toLocaleTimeString()}`, + `Image ${new Date().toLocaleTimeString('en-US', { + hour12: true, + hour: '2-digit', + minute: '2-digit' + }).replace(/(\d{2}):(\d{2})/, '$1:$2')}`, base64String ); }; @@ -93,26 +82,8 @@ export default function ChatInput({ } } + // Get text from clipboard const text = e.clipboardData.getData('text'); - - // Helper function to detect if text looks like code - const looksLikeCode = (text: string): boolean => { - const codeIndicators = [ - /^import\s+/m, // import statements - /^function\s+/m, // function declarations - /^class\s+/m, // class declarations - /^const\s+/m, // const declarations - /^let\s+/m, // let declarations - /^var\s+/m, // var declarations - /[{}\[\]();]/, // common code syntax - /^\s*\/\//m, // comments - /^\s*\/\*/m, // multi-line comments - /=>/, // arrow functions - /^export\s+/m, // export statements - ]; - - return codeIndicators.some(pattern => pattern.test(text)); - }; // If text doesn't contain newlines or doesn't look like code, let it paste normally if (!text || !text.includes('\n') || !looksLikeCode(text)) { @@ -123,6 +94,8 @@ export default function ChatInput({ const editor = editorRef.current; const currentSelection = editor?.getSelection(); const lines = text.split('\n'); + + // TODO: FIX THIS: even when i paste the outside code, it shows the active file name,it works when no tabs are open, just does not work when the tab is open // If selection exists in editor, use file name and line numbers if (currentSelection && !currentSelection.isEmpty()) { @@ -156,6 +129,7 @@ export default function ChatInput({ ); }; + // Handle image upload from local machine via input const handleImageUpload = () => { const input = document.createElement('input') input.type = 'file' @@ -167,32 +141,7 @@ export default function ChatInput({ input.click() } - const handleMentionClick = () => { - if (textareaRef.current) { - const cursorPosition = textareaRef.current.selectionStart - const newValue = input.slice(0, cursorPosition) + '@' + input.slice(cursorPosition) - setInput(newValue) - // Focus and move cursor after the @ - textareaRef.current.focus() - const newPosition = cursorPosition + 1 - textareaRef.current.setSelectionRange(newPosition, newPosition) - } - } - - // Handle @ mentions in input - useEffect(() => { - const match = input.match(/@(\w+)$/) - if (match) { - const fileName = match[1] - const allFiles = getAllFiles(files) - const file = allFiles.find(file => file.name === fileName) - if (file) { - onFileMention(file.name) - } - } - }, [input, onFileMention, files]) - - // Add this helper function to flatten the file tree + // Helper function to flatten the file tree const getAllFiles = (items: (TFile | TFolder)[]): TFile[] => { return items.reduce((acc: TFile[], item) => { if (item.type === "file") { @@ -204,6 +153,29 @@ export default function ChatInput({ }, []) } + // Handle file upload from local machine via input + const handleFileUpload = () => { + const input = document.createElement('input') + input.type = 'file' + input.accept = '.txt,.md,.csv,.json,.js,.ts,.html,.css,.pdf' + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0] + if (file) { + if (!(file.type in ALLOWED_FILE_TYPES)) { + alert('Unsupported file type. Please upload text, code, or PDF files.') + return + } + + const reader = new FileReader() + reader.onload = () => { + addContextTab("file", file.name, reader.result as string) + } + reader.readAsText(file) + } + } + input.click() + } + return (
@@ -218,6 +190,7 @@ export default function ChatInput({ disabled={isGenerating} rows={1} /> + {/* Render stop generation button */} {isGenerating ? (
-
-
- -
-
- - + {/* Render image upload button */} + -
+ Image +
) diff --git a/frontend/components/editor/AIChat/ChatMessage.tsx b/frontend/components/editor/AIChat/ChatMessage.tsx index 17b47b4..ef8ec0c 100644 --- a/frontend/components/editor/AIChat/ChatMessage.tsx +++ b/frontend/components/editor/AIChat/ChatMessage.tsx @@ -1,24 +1,12 @@ -import { Check, ChevronDown, ChevronUp, Copy, CornerUpLeft } from "lucide-react" +import { Check, Copy, CornerUpLeft } from "lucide-react" import React, { useState } from "react" import ReactMarkdown from "react-markdown" -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" -import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism" import remarkGfm from "remark-gfm" import { Button } from "../../ui/button" import { copyToClipboard, stringifyContent } from "./lib/chatUtils" import ContextTabs from "./ContextTabs" -import { Socket } from "socket.io-client" - -interface MessageProps { - message: { - role: "user" | "assistant" - content: string - context?: string - } - setContext: (context: string | null) => void - setIsContextExpanded: (isExpanded: boolean) => void - socket: Socket | null -} +import { createMarkdownComponents } from './lib/markdownComponents' +import { MessageProps } from "./types" export default function ChatMessage({ message, @@ -26,11 +14,16 @@ export default function ChatMessage({ setIsContextExpanded, socket, }: MessageProps) { + + // State for expanded message index const [expandedMessageIndex, setExpandedMessageIndex] = useState< number | null >(null) + + // State for copied text const [copiedText, setCopiedText] = useState(null) + // Render copy button for text content const renderCopyButton = (text: any) => ( ) + // Set context for code when asking about code const askAboutCode = (code: any) => { const contextString = stringifyContent(code) - setContext(`Regarding this code:\n${contextString}`) + const newContext = `Regarding this code:\n${contextString}` + + // Format timestamp to match chat message format (HH:MM PM) + const timestamp = new Date().toLocaleTimeString('en-US', { + hour12: true, + hour: '2-digit', + minute: '2-digit', + }) + + // Instead of replacing context, append to it + if (message.role === "assistant") { + // For assistant messages, create a new context tab with the response content and timestamp + setContext(newContext, `AI Response (${timestamp})`, { + start: 1, + end: contextString.split('\n').length + }) + } else { + // For user messages, create a new context tab with the selected content and timestamp + setContext(newContext, `User Chat (${timestamp})`, { + start: 1, + end: contextString.split('\n').length + }) + } setIsContextExpanded(false) } + // Render markdown elements for code and text const renderMarkdownElement = (props: any) => { const { node, children } = props const content = stringifyContent(children) @@ -69,6 +86,7 @@ export default function ChatMessage({ + {/* Render markdown element */} {React.createElement( node.tagName, { @@ -83,6 +101,13 @@ export default function ChatMessage({ ) } + // Create markdown components + const components = createMarkdownComponents( + renderCopyButton, + renderMarkdownElement, + askAboutCode + ) + return (
+ {/* Render context tabs */} {message.role === "user" && message.context && (
+ {/* Render code textarea */} {(() => { const code = message.context.replace( /^Regarding this code:\n/, @@ -124,7 +151,10 @@ export default function ChatMessage({ value={code} onChange={(e) => { const updatedContext = `Regarding this code:\n${e.target.value}` - setContext(updatedContext) + setContext(updatedContext, "Selected Content", { + start: 1, + end: e.target.value.split('\n').length + }) }} className="w-full p-2 bg-[#1e1e1e] text-white font-mono text-sm rounded" rows={code.split("\n").length} @@ -141,8 +171,9 @@ export default function ChatMessage({ )}
)} + {/* Render copy and ask about code buttons */} {message.role === "user" && ( -
+
{renderCopyButton(message.content)}
)} + {/* Render markdown content */} {message.role === "assistant" ? ( -
- {match[1]} -
-
- {renderCopyButton(children)} - -
-
- - {stringifyContent(children)} - -
-
- ) : ( - - {children} - - ) - }, - p: renderMarkdownElement, - h1: renderMarkdownElement, - h2: renderMarkdownElement, - h3: renderMarkdownElement, - h4: renderMarkdownElement, - h5: renderMarkdownElement, - h6: renderMarkdownElement, - ul: (props) => ( -
    - {props.children} -
- ), - ol: (props) => ( -
    - {props.children} -
- ), - }} + components={components} > {message.content} @@ -226,6 +201,7 @@ export default function ChatMessage({ ) } +// Parse context to tabs for context tabs component function parseContextToTabs(context: string) { const sections = context.split(/(?=File |Code from )/) return sections.map((section, index) => { @@ -236,6 +212,7 @@ function parseContextToTabs(context: string) { // Remove code block markers for display content = content.replace(/^```[\w-]*\n/, '').replace(/\n```$/, '') + // Determine if the context is a file or code const isFile = titleLine.startsWith('File ') const name = titleLine.replace(/^(File |Code from )/, '').replace(':', '') diff --git a/frontend/components/editor/AIChat/ContextDisplay.tsx b/frontend/components/editor/AIChat/ContextDisplay.tsx deleted file mode 100644 index e57ae36..0000000 --- a/frontend/components/editor/AIChat/ContextDisplay.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { ChevronDown, ChevronUp, X } from "lucide-react" - -interface ContextDisplayProps { - context: string | null - isContextExpanded: boolean - setIsContextExpanded: (isExpanded: boolean) => void - setContext: (context: string | null) => void -} - -export default function ContextDisplay({ - context, - isContextExpanded, - setIsContextExpanded, - setContext, -}: ContextDisplayProps) { - if (!context) return null - - return ( -
-
-
setIsContextExpanded(!isContextExpanded)} - > - Context -
-
- {isContextExpanded ? ( - setIsContextExpanded(false)} - /> - ) : ( - setIsContextExpanded(true)} - /> - )} - setContext(null)} - /> -
-
- {isContextExpanded && ( -