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
This commit is contained in:
parent
9c6067dcd9
commit
c6c01101f1
@ -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 { Button } from "../../ui/button"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select"
|
import { useEffect } from "react"
|
||||||
import { useRef, useEffect, useState } from "react"
|
|
||||||
import * as monaco from 'monaco-editor'
|
|
||||||
import { TFile, TFolder } from "@/lib/types"
|
import { TFile, TFolder } from "@/lib/types"
|
||||||
|
import { ALLOWED_FILE_TYPES } from "./types"
|
||||||
interface ChatInputProps {
|
import { looksLikeCode } from "./lib/chatUtils"
|
||||||
input: string
|
import { ChatInputProps } from "./types"
|
||||||
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<monaco.editor.IStandaloneCodeEditor | undefined>
|
|
||||||
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<HTMLTextAreaElement>
|
|
||||||
files: (TFile | TFolder)[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChatInput({
|
export default function ChatInput({
|
||||||
input,
|
input,
|
||||||
@ -30,7 +13,6 @@ export default function ChatInput({
|
|||||||
handleSend,
|
handleSend,
|
||||||
handleStopGeneration,
|
handleStopGeneration,
|
||||||
onImageUpload,
|
onImageUpload,
|
||||||
onFileMention,
|
|
||||||
addContextTab,
|
addContextTab,
|
||||||
activeFileName,
|
activeFileName,
|
||||||
editorRef,
|
editorRef,
|
||||||
@ -38,8 +20,8 @@ export default function ChatInput({
|
|||||||
contextTabs,
|
contextTabs,
|
||||||
onRemoveTab,
|
onRemoveTab,
|
||||||
textareaRef,
|
textareaRef,
|
||||||
files,
|
|
||||||
}: ChatInputProps) {
|
}: ChatInputProps) {
|
||||||
|
|
||||||
// Auto-resize textarea as content changes
|
// Auto-resize textarea as content changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
@ -48,6 +30,7 @@ export default function ChatInput({
|
|||||||
}
|
}
|
||||||
}, [input])
|
}, [input])
|
||||||
|
|
||||||
|
// Handle keyboard events for sending messages
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
if (e.ctrlKey) {
|
if (e.ctrlKey) {
|
||||||
@ -65,6 +48,7 @@ export default function ChatInput({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle paste events for image and code
|
||||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||||
// Handle image paste
|
// Handle image paste
|
||||||
const items = Array.from(e.clipboardData.items);
|
const items = Array.from(e.clipboardData.items);
|
||||||
@ -76,12 +60,17 @@ export default function ChatInput({
|
|||||||
if (!file) continue;
|
if (!file) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Convert image to base64 string for context tab title and timestamp
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
const base64String = reader.result as string;
|
const base64String = reader.result as string;
|
||||||
addContextTab(
|
addContextTab(
|
||||||
"image",
|
"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
|
base64String
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -93,27 +82,9 @@ export default function ChatInput({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get text from clipboard
|
||||||
const text = e.clipboardData.getData('text');
|
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 doesn't contain newlines or doesn't look like code, let it paste normally
|
||||||
if (!text || !text.includes('\n') || !looksLikeCode(text)) {
|
if (!text || !text.includes('\n') || !looksLikeCode(text)) {
|
||||||
return;
|
return;
|
||||||
@ -124,6 +95,8 @@ export default function ChatInput({
|
|||||||
const currentSelection = editor?.getSelection();
|
const currentSelection = editor?.getSelection();
|
||||||
const lines = text.split('\n');
|
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 selection exists in editor, use file name and line numbers
|
||||||
if (currentSelection && !currentSelection.isEmpty()) {
|
if (currentSelection && !currentSelection.isEmpty()) {
|
||||||
addContextTab(
|
addContextTab(
|
||||||
@ -156,6 +129,7 @@ export default function ChatInput({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle image upload from local machine via input
|
||||||
const handleImageUpload = () => {
|
const handleImageUpload = () => {
|
||||||
const input = document.createElement('input')
|
const input = document.createElement('input')
|
||||||
input.type = 'file'
|
input.type = 'file'
|
||||||
@ -167,32 +141,7 @@ export default function ChatInput({
|
|||||||
input.click()
|
input.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMentionClick = () => {
|
// Helper function to flatten the file tree
|
||||||
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
|
|
||||||
const getAllFiles = (items: (TFile | TFolder)[]): TFile[] => {
|
const getAllFiles = (items: (TFile | TFolder)[]): TFile[] => {
|
||||||
return items.reduce((acc: TFile[], item) => {
|
return items.reduce((acc: TFile[], item) => {
|
||||||
if (item.type === "file") {
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex space-x-2 min-w-0">
|
<div className="flex space-x-2 min-w-0">
|
||||||
@ -218,6 +190,7 @@ export default function ChatInput({
|
|||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
|
{/* Render stop generation button */}
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleStopGeneration}
|
onClick={handleStopGeneration}
|
||||||
@ -238,28 +211,18 @@ export default function ChatInput({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 w-full">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<div className="flex items-center gap-2 px-2 text-sm text-muted-foreground min-w-auto max-w-auto">
|
{/* Render file upload button */}
|
||||||
<Select defaultValue="claude-3.5-sonnet">
|
|
||||||
<SelectTrigger className="h-6 w-full border-none truncate">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="claude-3.5-sonnet">claude-3.5-sonnet</SelectItem>
|
|
||||||
<SelectItem value="claude-3">claude-3</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 ml-auto">
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 px-2 sm:px-3"
|
className="h-6 px-2 sm:px-3"
|
||||||
onClick={handleMentionClick}
|
onClick={handleFileUpload}
|
||||||
>
|
>
|
||||||
<AtSign className="h-3 w-3 sm:mr-1" />
|
<Paperclip className="h-3 w-3 sm:mr-1" />
|
||||||
<span className="hidden sm:inline">mention</span>
|
<span className="hidden sm:inline">File</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Render image upload button */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -271,7 +234,6 @@ export default function ChatInput({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 React, { useState } from "react"
|
||||||
import ReactMarkdown from "react-markdown"
|
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 remarkGfm from "remark-gfm"
|
||||||
import { Button } from "../../ui/button"
|
import { Button } from "../../ui/button"
|
||||||
import { copyToClipboard, stringifyContent } from "./lib/chatUtils"
|
import { copyToClipboard, stringifyContent } from "./lib/chatUtils"
|
||||||
import ContextTabs from "./ContextTabs"
|
import ContextTabs from "./ContextTabs"
|
||||||
import { Socket } from "socket.io-client"
|
import { createMarkdownComponents } from './lib/markdownComponents'
|
||||||
|
import { MessageProps } from "./types"
|
||||||
interface MessageProps {
|
|
||||||
message: {
|
|
||||||
role: "user" | "assistant"
|
|
||||||
content: string
|
|
||||||
context?: string
|
|
||||||
}
|
|
||||||
setContext: (context: string | null) => void
|
|
||||||
setIsContextExpanded: (isExpanded: boolean) => void
|
|
||||||
socket: Socket | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChatMessage({
|
export default function ChatMessage({
|
||||||
message,
|
message,
|
||||||
@ -26,11 +14,16 @@ export default function ChatMessage({
|
|||||||
setIsContextExpanded,
|
setIsContextExpanded,
|
||||||
socket,
|
socket,
|
||||||
}: MessageProps) {
|
}: MessageProps) {
|
||||||
|
|
||||||
|
// State for expanded message index
|
||||||
const [expandedMessageIndex, setExpandedMessageIndex] = useState<
|
const [expandedMessageIndex, setExpandedMessageIndex] = useState<
|
||||||
number | null
|
number | null
|
||||||
>(null)
|
>(null)
|
||||||
|
|
||||||
|
// State for copied text
|
||||||
const [copiedText, setCopiedText] = useState<string | null>(null)
|
const [copiedText, setCopiedText] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Render copy button for text content
|
||||||
const renderCopyButton = (text: any) => (
|
const renderCopyButton = (text: any) => (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => copyToClipboard(stringifyContent(text), setCopiedText)}
|
onClick={() => copyToClipboard(stringifyContent(text), setCopiedText)}
|
||||||
@ -46,12 +39,36 @@ export default function ChatMessage({
|
|||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Set context for code when asking about code
|
||||||
const askAboutCode = (code: any) => {
|
const askAboutCode = (code: any) => {
|
||||||
const contextString = stringifyContent(code)
|
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)
|
setIsContextExpanded(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render markdown elements for code and text
|
||||||
const renderMarkdownElement = (props: any) => {
|
const renderMarkdownElement = (props: any) => {
|
||||||
const { node, children } = props
|
const { node, children } = props
|
||||||
const content = stringifyContent(children)
|
const content = stringifyContent(children)
|
||||||
@ -69,6 +86,7 @@ export default function ChatMessage({
|
|||||||
<CornerUpLeft className="w-4 h-4" />
|
<CornerUpLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Render markdown element */}
|
||||||
{React.createElement(
|
{React.createElement(
|
||||||
node.tagName,
|
node.tagName,
|
||||||
{
|
{
|
||||||
@ -83,6 +101,13 @@ export default function ChatMessage({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create markdown components
|
||||||
|
const components = createMarkdownComponents(
|
||||||
|
renderCopyButton,
|
||||||
|
renderMarkdownElement,
|
||||||
|
askAboutCode
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-left relative">
|
<div className="text-left relative">
|
||||||
<div
|
<div
|
||||||
@ -92,6 +117,7 @@ export default function ChatMessage({
|
|||||||
: "bg-transparent text-white"
|
: "bg-transparent text-white"
|
||||||
} max-w-full`}
|
} max-w-full`}
|
||||||
>
|
>
|
||||||
|
{/* Render context tabs */}
|
||||||
{message.role === "user" && message.context && (
|
{message.role === "user" && message.context && (
|
||||||
<div className="mb-2 bg-input rounded-lg">
|
<div className="mb-2 bg-input rounded-lg">
|
||||||
<ContextTabs
|
<ContextTabs
|
||||||
@ -111,6 +137,7 @@ export default function ChatMessage({
|
|||||||
message.context.replace(/^Regarding this code:\n/, "")
|
message.context.replace(/^Regarding this code:\n/, "")
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Render code textarea */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const code = message.context.replace(
|
const code = message.context.replace(
|
||||||
/^Regarding this code:\n/,
|
/^Regarding this code:\n/,
|
||||||
@ -124,7 +151,10 @@ export default function ChatMessage({
|
|||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const updatedContext = `Regarding this code:\n${e.target.value}`
|
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"
|
className="w-full p-2 bg-[#1e1e1e] text-white font-mono text-sm rounded"
|
||||||
rows={code.split("\n").length}
|
rows={code.split("\n").length}
|
||||||
@ -141,8 +171,9 @@ export default function ChatMessage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Render copy and ask about code buttons */}
|
||||||
{message.role === "user" && (
|
{message.role === "user" && (
|
||||||
<div className="absolute top-0 right-0 flex opacity-0 group-hover:opacity-30 transition-opacity">
|
<div className="absolute top-0 right-0 p-1 flex opacity-40">
|
||||||
{renderCopyButton(message.content)}
|
{renderCopyButton(message.content)}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => askAboutCode(message.content)}
|
onClick={() => askAboutCode(message.content)}
|
||||||
@ -154,67 +185,11 @@ export default function ChatMessage({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Render markdown content */}
|
||||||
{message.role === "assistant" ? (
|
{message.role === "assistant" ? (
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
components={{
|
components={components}
|
||||||
code({ node, className, children, ...props }) {
|
|
||||||
const match = /language-(\w+)/.exec(className || "")
|
|
||||||
return match ? (
|
|
||||||
<div className="relative border border-input rounded-md my-4">
|
|
||||||
<div className="absolute top-0 left-0 px-2 py-1 text-xs font-semibold text-gray-200 bg-#1e1e1e rounded-tl">
|
|
||||||
{match[1]}
|
|
||||||
</div>
|
|
||||||
<div className="absolute top-0 right-0 flex">
|
|
||||||
{renderCopyButton(children)}
|
|
||||||
<Button
|
|
||||||
onClick={() => askAboutCode(children)}
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="p-1 h-6"
|
|
||||||
>
|
|
||||||
<CornerUpLeft className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="pt-6">
|
|
||||||
<SyntaxHighlighter
|
|
||||||
style={vscDarkPlus as any}
|
|
||||||
language={match[1]}
|
|
||||||
PreTag="div"
|
|
||||||
customStyle={{
|
|
||||||
margin: 0,
|
|
||||||
padding: "0.5rem",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{stringifyContent(children)}
|
|
||||||
</SyntaxHighlighter>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<code className={className} {...props}>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
p: renderMarkdownElement,
|
|
||||||
h1: renderMarkdownElement,
|
|
||||||
h2: renderMarkdownElement,
|
|
||||||
h3: renderMarkdownElement,
|
|
||||||
h4: renderMarkdownElement,
|
|
||||||
h5: renderMarkdownElement,
|
|
||||||
h6: renderMarkdownElement,
|
|
||||||
ul: (props) => (
|
|
||||||
<ul className="list-disc pl-6 mb-4 space-y-2">
|
|
||||||
{props.children}
|
|
||||||
</ul>
|
|
||||||
),
|
|
||||||
ol: (props) => (
|
|
||||||
<ol className="list-decimal pl-6 mb-4 space-y-2">
|
|
||||||
{props.children}
|
|
||||||
</ol>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{message.content}
|
{message.content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
@ -226,6 +201,7 @@ export default function ChatMessage({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse context to tabs for context tabs component
|
||||||
function parseContextToTabs(context: string) {
|
function parseContextToTabs(context: string) {
|
||||||
const sections = context.split(/(?=File |Code from )/)
|
const sections = context.split(/(?=File |Code from )/)
|
||||||
return sections.map((section, index) => {
|
return sections.map((section, index) => {
|
||||||
@ -236,6 +212,7 @@ function parseContextToTabs(context: string) {
|
|||||||
// Remove code block markers for display
|
// Remove code block markers for display
|
||||||
content = content.replace(/^```[\w-]*\n/, '').replace(/\n```$/, '')
|
content = content.replace(/^```[\w-]*\n/, '').replace(/\n```$/, '')
|
||||||
|
|
||||||
|
// Determine if the context is a file or code
|
||||||
const isFile = titleLine.startsWith('File ')
|
const isFile = titleLine.startsWith('File ')
|
||||||
const name = titleLine.replace(/^(File |Code from )/, '').replace(':', '')
|
const name = titleLine.replace(/^(File |Code from )/, '').replace(':', '')
|
||||||
|
|
||||||
|
@ -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 (
|
|
||||||
<div className="mb-2 bg-input p-2 rounded-lg">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div
|
|
||||||
className="flex-grow cursor-pointer"
|
|
||||||
onClick={() => setIsContextExpanded(!isContextExpanded)}
|
|
||||||
>
|
|
||||||
<span className="text-sm text-gray-300">Context</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
{isContextExpanded ? (
|
|
||||||
<ChevronUp
|
|
||||||
size={16}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => setIsContextExpanded(false)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ChevronDown
|
|
||||||
size={16}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => setIsContextExpanded(true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<X
|
|
||||||
size={16}
|
|
||||||
className="ml-2 cursor-pointer text-gray-400 hover:text-gray-200"
|
|
||||||
onClick={() => setContext(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isContextExpanded && (
|
|
||||||
<textarea
|
|
||||||
value={context.replace(/^Regarding this code:\n/, "")}
|
|
||||||
onChange={(e) =>
|
|
||||||
setContext(`Regarding this code:\n${e.target.value}`)
|
|
||||||
}
|
|
||||||
className="w-full mt-2 p-2 bg-#1e1e1e text-white rounded"
|
|
||||||
rows={5}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,4 +1,4 @@
|
|||||||
import { Plus, X, ChevronDown, ChevronUp, Image as ImageIcon, FileText } from "lucide-react"
|
import { Plus, X, Image as ImageIcon, FileText } from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Button } from "../../ui/button"
|
import { Button } from "../../ui/button"
|
||||||
import { TFile, TFolder } from "@/lib/types"
|
import { TFile, TFolder } from "@/lib/types"
|
||||||
@ -8,44 +8,28 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover"
|
} from "@/components/ui/popover"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Socket } from "socket.io-client"
|
import { ContextTab } from "./types"
|
||||||
|
import { ContextTabsProps } from "./types"
|
||||||
interface ContextTab {
|
|
||||||
id: string
|
|
||||||
type: "file" | "code" | "image"
|
|
||||||
name: string
|
|
||||||
content: string
|
|
||||||
sourceFile?: string
|
|
||||||
lineRange?: {
|
|
||||||
start: number
|
|
||||||
end: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContextTabsProps {
|
|
||||||
activeFileName: string
|
|
||||||
onAddFile: () => void
|
|
||||||
contextTabs: ContextTab[]
|
|
||||||
onRemoveTab: (id: string) => void
|
|
||||||
isExpanded: boolean
|
|
||||||
onToggleExpand: () => void
|
|
||||||
files?: (TFile | TFolder)[]
|
|
||||||
onFileSelect?: (file: TFile) => void
|
|
||||||
socket: Socket | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContextTabs({
|
export default function ContextTabs({
|
||||||
onAddFile,
|
|
||||||
contextTabs,
|
contextTabs,
|
||||||
onRemoveTab,
|
onRemoveTab,
|
||||||
className,
|
className,
|
||||||
files = [],
|
files = [],
|
||||||
onFileSelect,
|
onFileSelect,
|
||||||
}: ContextTabsProps & { className?: string }) {
|
}: ContextTabsProps & { className?: string }) {
|
||||||
|
|
||||||
|
// State for preview tab
|
||||||
const [previewTab, setPreviewTab] = useState<ContextTab | null>(null)
|
const [previewTab, setPreviewTab] = useState<ContextTab | null>(null)
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
|
||||||
|
// Allow preview for images and code selections from editor
|
||||||
const togglePreview = (tab: ContextTab) => {
|
const togglePreview = (tab: ContextTab) => {
|
||||||
|
if (!tab.lineRange && tab.type !== "image") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle preview for images and code selections from editor
|
||||||
if (previewTab?.id === tab.id) {
|
if (previewTab?.id === tab.id) {
|
||||||
setPreviewTab(null)
|
setPreviewTab(null)
|
||||||
} else {
|
} else {
|
||||||
@ -53,6 +37,7 @@ export default function ContextTabs({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove tab from context when clicking on X
|
||||||
const handleRemoveTab = (id: string) => {
|
const handleRemoveTab = (id: string) => {
|
||||||
if (previewTab?.id === id) {
|
if (previewTab?.id === id) {
|
||||||
setPreviewTab(null)
|
setPreviewTab(null)
|
||||||
@ -60,6 +45,7 @@ export default function ContextTabs({
|
|||||||
onRemoveTab(id)
|
onRemoveTab(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all files from the file tree to search for context
|
||||||
const getAllFiles = (items: (TFile | TFolder)[]): TFile[] => {
|
const getAllFiles = (items: (TFile | TFolder)[]): TFile[] => {
|
||||||
return items.reduce((acc: TFile[], item) => {
|
return items.reduce((acc: TFile[], item) => {
|
||||||
if (item.type === "file") {
|
if (item.type === "file") {
|
||||||
@ -71,6 +57,7 @@ export default function ContextTabs({
|
|||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all files from the file tree to search for context when adding context
|
||||||
const allFiles = getAllFiles(files)
|
const allFiles = getAllFiles(files)
|
||||||
const filteredFiles = allFiles.filter(file =>
|
const filteredFiles = allFiles.filter(file =>
|
||||||
file.name.toLowerCase().includes(searchQuery.toLowerCase())
|
file.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
@ -80,6 +67,7 @@ export default function ContextTabs({
|
|||||||
<div className={`border-none ${className || ''}`}>
|
<div className={`border-none ${className || ''}`}>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center gap-1 overflow-hidden mb-2 flex-wrap">
|
<div className="flex items-center gap-1 overflow-hidden mb-2 flex-wrap">
|
||||||
|
{/* Add context tab button */}
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@ -90,13 +78,16 @@ export default function ContextTabs({
|
|||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
{/* Add context tab popover */}
|
||||||
<PopoverContent className="w-64 p-2">
|
<PopoverContent className="w-64 p-2">
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search files..."
|
placeholder="Search files..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="mb-2"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div className="max-h-[200px] overflow-y-auto">
|
<div className="max-h-[200px] overflow-y-auto">
|
||||||
{filteredFiles.map((file) => (
|
{filteredFiles.map((file) => (
|
||||||
<Button
|
<Button
|
||||||
@ -112,11 +103,13 @@ export default function ContextTabs({
|
|||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
{/* Add context tab button */}
|
||||||
{contextTabs.length === 0 && (
|
{contextTabs.length === 0 && (
|
||||||
<div className="flex items-center gap-1 px-2 rounded">
|
<div className="flex items-center gap-1 px-2 rounded">
|
||||||
<span className="text-sm text-muted-foreground">Add Context</span>
|
<span className="text-sm text-muted-foreground">Add Context</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Render context tabs */}
|
||||||
{contextTabs.map((tab) => (
|
{contextTabs.map((tab) => (
|
||||||
<div
|
<div
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@ -143,18 +136,24 @@ export default function ContextTabs({
|
|||||||
{/* Preview Section */}
|
{/* Preview Section */}
|
||||||
{previewTab && (
|
{previewTab && (
|
||||||
<div className="p-2 bg-input rounded-md max-h-[200px] overflow-auto mb-2">
|
<div className="p-2 bg-input rounded-md max-h-[200px] overflow-auto mb-2">
|
||||||
{previewTab.lineRange && (
|
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
|
||||||
Lines {previewTab.lineRange.start}-{previewTab.lineRange.end}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{previewTab.type === "image" ? (
|
{previewTab.type === "image" ? (
|
||||||
<img
|
<img
|
||||||
src={previewTab.content}
|
src={previewTab.content}
|
||||||
alt={previewTab.name}
|
alt={previewTab.name}
|
||||||
className="max-w-full h-auto"
|
className="max-w-full h-auto"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : previewTab.lineRange && (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
Lines {previewTab.lineRange.start}-{previewTab.lineRange.end}
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs font-mono whitespace-pre-wrap">
|
||||||
|
{previewTab.content}
|
||||||
|
</pre>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Render file context tab */}
|
||||||
|
{previewTab.type === "file" && (
|
||||||
<pre className="text-xs font-mono whitespace-pre-wrap">
|
<pre className="text-xs font-mono whitespace-pre-wrap">
|
||||||
{previewTab.content}
|
{previewTab.content}
|
||||||
</pre>
|
</pre>
|
||||||
|
@ -6,32 +6,9 @@ import ChatMessage from "./ChatMessage"
|
|||||||
import ContextTabs from "./ContextTabs"
|
import ContextTabs from "./ContextTabs"
|
||||||
import { handleSend, handleStopGeneration } from "./lib/chatUtils"
|
import { handleSend, handleStopGeneration } from "./lib/chatUtils"
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import * as monaco from 'monaco-editor'
|
import { TFile } from "@/lib/types"
|
||||||
import { TFile, TFolder } from "@/lib/types"
|
|
||||||
import { useSocket } from "@/context/SocketContext"
|
import { useSocket } from "@/context/SocketContext"
|
||||||
|
import { Message, ContextTab, AIChatProps } from './types'
|
||||||
interface Message {
|
|
||||||
role: "user" | "assistant"
|
|
||||||
content: string
|
|
||||||
context?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContextTab {
|
|
||||||
id: string
|
|
||||||
type: "file" | "code" | "image"
|
|
||||||
name: string
|
|
||||||
content: string
|
|
||||||
lineRange?: { start: number; end: number }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AIChatProps {
|
|
||||||
activeFileContent: string
|
|
||||||
activeFileName: string
|
|
||||||
onClose: () => void
|
|
||||||
editorRef: React.MutableRefObject<monaco.editor.IStandaloneCodeEditor | undefined>
|
|
||||||
lastCopiedRangeRef: React.MutableRefObject<{ startLine: number; endLine: number } | null>
|
|
||||||
files: (TFile | TFolder)[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AIChat({
|
export default function AIChat({
|
||||||
activeFileContent,
|
activeFileContent,
|
||||||
@ -41,21 +18,32 @@ export default function AIChat({
|
|||||||
lastCopiedRangeRef,
|
lastCopiedRangeRef,
|
||||||
files,
|
files,
|
||||||
}: AIChatProps) {
|
}: AIChatProps) {
|
||||||
|
// Initialize socket and messages
|
||||||
const { socket } = useSocket()
|
const { socket } = useSocket()
|
||||||
const [messages, setMessages] = useState<Message[]>([])
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
|
|
||||||
|
// Initialize input and state for generating messages
|
||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const [isGenerating, setIsGenerating] = useState(false)
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
|
|
||||||
|
// Initialize chat container ref and abort controller ref
|
||||||
const chatContainerRef = useRef<HTMLDivElement>(null)
|
const chatContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const abortControllerRef = useRef<AbortController | null>(null)
|
const abortControllerRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
// Initialize context tabs and state for expanding context
|
||||||
const [contextTabs, setContextTabs] = useState<ContextTab[]>([])
|
const [contextTabs, setContextTabs] = useState<ContextTab[]>([])
|
||||||
const [isContextExpanded, setIsContextExpanded] = useState(false)
|
const [isContextExpanded, setIsContextExpanded] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
// Initialize textarea ref
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
// Scroll to bottom of chat when messages change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
|
// Scroll to bottom of chat when messages change
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
if (chatContainerRef.current) {
|
if (chatContainerRef.current) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -67,6 +55,7 @@ export default function AIChat({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add context tab to context tabs
|
||||||
const addContextTab = (type: string, name: string, content: string, lineRange?: { start: number; end: number }) => {
|
const addContextTab = (type: string, name: string, content: string, lineRange?: { start: number; end: number }) => {
|
||||||
const newTab = {
|
const newTab = {
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
@ -78,19 +67,22 @@ export default function AIChat({
|
|||||||
setContextTabs(prev => [...prev, newTab])
|
setContextTabs(prev => [...prev, newTab])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove context tab from context tabs
|
||||||
const removeContextTab = (id: string) => {
|
const removeContextTab = (id: string) => {
|
||||||
setContextTabs(prev => prev.filter(tab => tab.id !== id))
|
setContextTabs(prev => prev.filter(tab => tab.id !== id))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddFile = () => {
|
// Add file to context tabs
|
||||||
console.log("Add file to context")
|
const handleAddFile = (tab: ContextTab) => {
|
||||||
|
setContextTabs(prev => [...prev, tab])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format code content to remove starting and ending code block markers if they exist
|
||||||
const formatCodeContent = (content: string) => {
|
const formatCodeContent = (content: string) => {
|
||||||
// Remove starting and ending code block markers if they exist
|
|
||||||
return content.replace(/^```[\w-]*\n/, '').replace(/\n```$/, '')
|
return content.replace(/^```[\w-]*\n/, '').replace(/\n```$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get combined context from context tabs
|
||||||
const getCombinedContext = () => {
|
const getCombinedContext = () => {
|
||||||
if (contextTabs.length === 0) return ''
|
if (contextTabs.length === 0) return ''
|
||||||
|
|
||||||
@ -107,6 +99,7 @@ export default function AIChat({
|
|||||||
}).join('\n\n')
|
}).join('\n\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle sending message with context
|
||||||
const handleSendWithContext = () => {
|
const handleSendWithContext = () => {
|
||||||
const combinedContext = getCombinedContext()
|
const combinedContext = getCombinedContext()
|
||||||
handleSend(
|
handleSend(
|
||||||
@ -125,50 +118,20 @@ export default function AIChat({
|
|||||||
setContextTabs([])
|
setContextTabs([])
|
||||||
}
|
}
|
||||||
|
|
||||||
function setContext(context: string | null, fileName?: string, lineRange?: { start: number; end: number }): void {
|
// Set context for the chat
|
||||||
|
const setContext = (
|
||||||
|
context: string | null,
|
||||||
|
name: string,
|
||||||
|
range?: { start: number, end: number }
|
||||||
|
) => {
|
||||||
if (!context) {
|
if (!context) {
|
||||||
setContextTabs([])
|
setContextTabs([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingCodeTab = contextTabs.find(tab => tab.type === 'code')
|
// Always add a new tab instead of updating existing ones
|
||||||
|
addContextTab('code', name, context, range)
|
||||||
if (existingCodeTab) {
|
|
||||||
setContextTabs(prev =>
|
|
||||||
prev.map(tab =>
|
|
||||||
tab.id === existingCodeTab.id
|
|
||||||
? { ...tab, content: context, name: fileName || 'Code Context', lineRange }
|
|
||||||
: tab
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
addContextTab('code', fileName || 'Chat Context', context, lineRange)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (editorRef?.current) {
|
|
||||||
const editor = editorRef.current;
|
|
||||||
|
|
||||||
// Configure editor options for better copy handling
|
|
||||||
editor.updateOptions({
|
|
||||||
copyWithSyntaxHighlighting: true,
|
|
||||||
emptySelectionClipboard: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track selection changes
|
|
||||||
const disposable = editor.onDidChangeCursorSelection((e) => {
|
|
||||||
if (!e.selection.isEmpty()) {
|
|
||||||
lastCopiedRangeRef.current = {
|
|
||||||
startLine: e.selection.startLineNumber,
|
|
||||||
endLine: e.selection.endLineNumber
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => disposable.dispose();
|
|
||||||
}
|
|
||||||
}, [editorRef?.current]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen w-full">
|
<div className="flex flex-col h-screen w-full">
|
||||||
@ -193,6 +156,7 @@ export default function AIChat({
|
|||||||
className="flex-grow overflow-y-auto p-4 space-y-4"
|
className="flex-grow overflow-y-auto p-4 space-y-4"
|
||||||
>
|
>
|
||||||
{messages.map((message, messageIndex) => (
|
{messages.map((message, messageIndex) => (
|
||||||
|
// Render chat message component for each message
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
key={messageIndex}
|
key={messageIndex}
|
||||||
message={message}
|
message={message}
|
||||||
@ -204,6 +168,7 @@ export default function AIChat({
|
|||||||
{isLoading && <LoadingDots />}
|
{isLoading && <LoadingDots />}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 border-t mb-14">
|
<div className="p-4 border-t mb-14">
|
||||||
|
{/* Render context tabs component */}
|
||||||
<ContextTabs
|
<ContextTabs
|
||||||
activeFileName={activeFileName}
|
activeFileName={activeFileName}
|
||||||
onAddFile={handleAddFile}
|
onAddFile={handleAddFile}
|
||||||
@ -224,9 +189,9 @@ export default function AIChat({
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{/* Render chat input component */}
|
||||||
<ChatInput
|
<ChatInput
|
||||||
textareaRef={textareaRef}
|
textareaRef={textareaRef}
|
||||||
files={[]}
|
|
||||||
addContextTab={addContextTab}
|
addContextTab={addContextTab}
|
||||||
editorRef={editorRef}
|
editorRef={editorRef}
|
||||||
input={input}
|
input={input}
|
||||||
@ -243,13 +208,11 @@ export default function AIChat({
|
|||||||
}
|
}
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
}}
|
}}
|
||||||
onFileMention={(fileName) => {
|
|
||||||
}}
|
|
||||||
lastCopiedRangeRef={lastCopiedRangeRef}
|
lastCopiedRangeRef={lastCopiedRangeRef}
|
||||||
activeFileName={activeFileName}
|
activeFileName={activeFileName}
|
||||||
contextTabs={contextTabs.map(tab => ({
|
contextTabs={contextTabs.map(tab => ({
|
||||||
...tab,
|
...tab,
|
||||||
title: tab.id // Add missing title property
|
title: tab.id
|
||||||
}))}
|
}))}
|
||||||
onRemoveTab={removeContextTab}
|
onRemoveTab={removeContextTab}
|
||||||
/>
|
/>
|
||||||
|
@ -1,30 +1,39 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
|
|
||||||
|
// Stringify content for chat message component
|
||||||
export const stringifyContent = (
|
export const stringifyContent = (
|
||||||
content: any,
|
content: any,
|
||||||
seen = new WeakSet()
|
seen = new WeakSet()
|
||||||
): string => {
|
): string => {
|
||||||
|
// Stringify content if it's a string
|
||||||
if (typeof content === "string") {
|
if (typeof content === "string") {
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
// Stringify content if it's null
|
||||||
if (content === null) {
|
if (content === null) {
|
||||||
return "null"
|
return "null"
|
||||||
}
|
}
|
||||||
|
// Stringify content if it's undefined
|
||||||
if (content === undefined) {
|
if (content === undefined) {
|
||||||
return "undefined"
|
return "undefined"
|
||||||
}
|
}
|
||||||
|
// Stringify content if it's a number or boolean
|
||||||
if (typeof content === "number" || typeof content === "boolean") {
|
if (typeof content === "number" || typeof content === "boolean") {
|
||||||
return content.toString()
|
return content.toString()
|
||||||
}
|
}
|
||||||
|
// Stringify content if it's a function
|
||||||
if (typeof content === "function") {
|
if (typeof content === "function") {
|
||||||
return content.toString()
|
return content.toString()
|
||||||
}
|
}
|
||||||
|
// Stringify content if it's a symbol
|
||||||
if (typeof content === "symbol") {
|
if (typeof content === "symbol") {
|
||||||
return content.toString()
|
return content.toString()
|
||||||
}
|
}
|
||||||
|
// Stringify content if it's a bigint
|
||||||
if (typeof content === "bigint") {
|
if (typeof content === "bigint") {
|
||||||
return content.toString() + "n"
|
return content.toString() + "n"
|
||||||
}
|
}
|
||||||
|
// Stringify content if it's a valid React element
|
||||||
if (React.isValidElement(content)) {
|
if (React.isValidElement(content)) {
|
||||||
return React.Children.toArray(
|
return React.Children.toArray(
|
||||||
(content as React.ReactElement).props.children
|
(content as React.ReactElement).props.children
|
||||||
@ -32,11 +41,13 @@ export const stringifyContent = (
|
|||||||
.map((child) => stringifyContent(child, seen))
|
.map((child) => stringifyContent(child, seen))
|
||||||
.join("")
|
.join("")
|
||||||
}
|
}
|
||||||
|
// Stringify content if it's an array
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
return (
|
return (
|
||||||
"[" + content.map((item) => stringifyContent(item, seen)).join(", ") + "]"
|
"[" + content.map((item) => stringifyContent(item, seen)).join(", ") + "]"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Stringify content if it's an object
|
||||||
if (typeof content === "object") {
|
if (typeof content === "object") {
|
||||||
if (seen.has(content)) {
|
if (seen.has(content)) {
|
||||||
return "[Circular]"
|
return "[Circular]"
|
||||||
@ -51,19 +62,23 @@ export const stringifyContent = (
|
|||||||
return Object.prototype.toString.call(content)
|
return Object.prototype.toString.call(content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Stringify content if it's a primitive value
|
||||||
return String(content)
|
return String(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copy to clipboard for chat message component
|
||||||
export const copyToClipboard = (
|
export const copyToClipboard = (
|
||||||
text: string,
|
text: string,
|
||||||
setCopiedText: (text: string | null) => void
|
setCopiedText: (text: string | null) => void
|
||||||
) => {
|
) => {
|
||||||
|
// Copy text to clipboard for chat message component
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
setCopiedText(text)
|
setCopiedText(text)
|
||||||
setTimeout(() => setCopiedText(null), 2000)
|
setTimeout(() => setCopiedText(null), 2000)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle send for chat message component
|
||||||
export const handleSend = async (
|
export const handleSend = async (
|
||||||
input: string,
|
input: string,
|
||||||
context: string | null,
|
context: string | null,
|
||||||
@ -76,14 +91,26 @@ export const handleSend = async (
|
|||||||
abortControllerRef: React.MutableRefObject<AbortController | null>,
|
abortControllerRef: React.MutableRefObject<AbortController | null>,
|
||||||
activeFileContent: string
|
activeFileContent: string
|
||||||
) => {
|
) => {
|
||||||
|
// Return if input is empty and context is null
|
||||||
if (input.trim() === "" && !context) return
|
if (input.trim() === "" && !context) return
|
||||||
|
|
||||||
const newMessage = {
|
// Get timestamp for chat message component
|
||||||
|
const timestamp = new Date().toLocaleTimeString('en-US', {
|
||||||
|
hour12: true,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).replace(/(\d{2}):(\d{2})/, '$1:$2')
|
||||||
|
|
||||||
|
// Create user message for chat message component
|
||||||
|
const userMessage = {
|
||||||
role: "user" as const,
|
role: "user" as const,
|
||||||
content: input,
|
content: input,
|
||||||
context: context || undefined,
|
context: context || undefined,
|
||||||
|
timestamp: timestamp
|
||||||
}
|
}
|
||||||
const updatedMessages = [...messages, newMessage]
|
|
||||||
|
// Update messages for chat message component
|
||||||
|
const updatedMessages = [...messages, userMessage]
|
||||||
setMessages(updatedMessages)
|
setMessages(updatedMessages)
|
||||||
setInput("")
|
setInput("")
|
||||||
setIsContextExpanded(false)
|
setIsContextExpanded(false)
|
||||||
@ -93,11 +120,13 @@ export const handleSend = async (
|
|||||||
abortControllerRef.current = new AbortController()
|
abortControllerRef.current = new AbortController()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Create anthropic messages for chat message component
|
||||||
const anthropicMessages = updatedMessages.map((msg) => ({
|
const anthropicMessages = updatedMessages.map((msg) => ({
|
||||||
role: msg.role === "user" ? "human" : "assistant",
|
role: msg.role === "user" ? "human" : "assistant",
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Fetch AI response for chat message component
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_AI_WORKER_URL}/api`,
|
`${process.env.NEXT_PUBLIC_AI_WORKER_URL}/api`,
|
||||||
{
|
{
|
||||||
@ -114,20 +143,24 @@ export const handleSend = async (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Throw error if response is not ok
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to get AI response")
|
throw new Error("Failed to get AI response")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get reader for chat message component
|
||||||
const reader = response.body?.getReader()
|
const reader = response.body?.getReader()
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
const assistantMessage = { role: "assistant" as const, content: "" }
|
const assistantMessage = { role: "assistant" as const, content: "" }
|
||||||
setMessages([...updatedMessages, assistantMessage])
|
setMessages([...updatedMessages, assistantMessage])
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
|
||||||
|
// Initialize buffer for chat message component
|
||||||
let buffer = ""
|
let buffer = ""
|
||||||
const updateInterval = 100
|
const updateInterval = 100
|
||||||
let lastUpdateTime = Date.now()
|
let lastUpdateTime = Date.now()
|
||||||
|
|
||||||
|
// Read response from reader for chat message component
|
||||||
if (reader) {
|
if (reader) {
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
@ -146,6 +179,7 @@ export const handleSend = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update messages for chat message component
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const updatedMessages = [...prev]
|
const updatedMessages = [...prev]
|
||||||
const lastMessage = updatedMessages[updatedMessages.length - 1]
|
const lastMessage = updatedMessages[updatedMessages.length - 1]
|
||||||
@ -154,6 +188,7 @@ export const handleSend = async (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
// Handle abort error for chat message component
|
||||||
if (error.name === "AbortError") {
|
if (error.name === "AbortError") {
|
||||||
console.log("Generation aborted")
|
console.log("Generation aborted")
|
||||||
} else {
|
} else {
|
||||||
@ -171,6 +206,7 @@ export const handleSend = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle stop generation for chat message component
|
||||||
export const handleStopGeneration = (
|
export const handleStopGeneration = (
|
||||||
abortControllerRef: React.MutableRefObject<AbortController | null>
|
abortControllerRef: React.MutableRefObject<AbortController | null>
|
||||||
) => {
|
) => {
|
||||||
@ -178,3 +214,22 @@ export const handleStopGeneration = (
|
|||||||
abortControllerRef.current.abort()
|
abortControllerRef.current.abort()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if text looks like code for chat message component
|
||||||
|
export 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));
|
||||||
|
};
|
||||||
|
79
frontend/components/editor/AIChat/lib/markdownComponents.tsx
Normal file
79
frontend/components/editor/AIChat/lib/markdownComponents.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { Components } from "react-markdown"
|
||||||
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
|
||||||
|
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"
|
||||||
|
import { Button } from "../../../ui/button"
|
||||||
|
import { CornerUpLeft } from "lucide-react"
|
||||||
|
import { stringifyContent } from "./chatUtils"
|
||||||
|
|
||||||
|
// Create markdown components for chat message component
|
||||||
|
export const createMarkdownComponents = (
|
||||||
|
renderCopyButton: (text: any) => JSX.Element,
|
||||||
|
renderMarkdownElement: (props: any) => JSX.Element,
|
||||||
|
askAboutCode: (code: any) => void
|
||||||
|
): Components => ({
|
||||||
|
code: ({ node, className, children, ...props }: {
|
||||||
|
node?: import('hast').Element,
|
||||||
|
className?: string,
|
||||||
|
children?: React.ReactNode,
|
||||||
|
[key: string]: any,
|
||||||
|
}) => {
|
||||||
|
const match = /language-(\w+)/.exec(className || "")
|
||||||
|
|
||||||
|
return match ? (
|
||||||
|
<div className="relative border border-input rounded-md my-4">
|
||||||
|
<div className="absolute top-0 left-0 px-2 py-1 text-xs font-semibold text-gray-200 bg-#1e1e1e rounded-tl">
|
||||||
|
{match[1]}
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-0 right-0 flex">
|
||||||
|
{renderCopyButton(children)}
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
askAboutCode(children)
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="p-1 h-6"
|
||||||
|
>
|
||||||
|
<CornerUpLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="pt-6">
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={vscDarkPlus as any}
|
||||||
|
language={match[1]}
|
||||||
|
PreTag="div"
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stringifyContent(children)}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<code className={className} {...props}>{children}</code>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// Render markdown elements
|
||||||
|
p: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
|
||||||
|
h1: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
|
||||||
|
h2: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
|
||||||
|
h3: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
|
||||||
|
h4: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
|
||||||
|
h5: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
|
||||||
|
h6: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
|
||||||
|
ul: (props) => (
|
||||||
|
<ul className="list-disc pl-6 mb-4 space-y-2">
|
||||||
|
{props.children}
|
||||||
|
</ul>
|
||||||
|
),
|
||||||
|
ol: (props) => (
|
||||||
|
<ol className="list-decimal pl-6 mb-4 space-y-2">
|
||||||
|
{props.children}
|
||||||
|
</ol>
|
||||||
|
),
|
||||||
|
})
|
93
frontend/components/editor/AIChat/types/index.ts
Normal file
93
frontend/components/editor/AIChat/types/index.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import * as monaco from 'monaco-editor'
|
||||||
|
import { TFile, TFolder } from "@/lib/types"
|
||||||
|
import { Socket } from 'socket.io-client';
|
||||||
|
|
||||||
|
// Allowed file types for context tabs
|
||||||
|
export const ALLOWED_FILE_TYPES = {
|
||||||
|
// Text files
|
||||||
|
'text/plain': true,
|
||||||
|
'text/markdown': true,
|
||||||
|
'text/csv': true,
|
||||||
|
// Code files
|
||||||
|
'application/json': true,
|
||||||
|
'text/javascript': true,
|
||||||
|
'text/typescript': true,
|
||||||
|
'text/html': true,
|
||||||
|
'text/css': true,
|
||||||
|
// Documents
|
||||||
|
'application/pdf': true,
|
||||||
|
// Images
|
||||||
|
'image/jpeg': true,
|
||||||
|
'image/png': true,
|
||||||
|
'image/gif': true,
|
||||||
|
'image/webp': true,
|
||||||
|
'image/svg+xml': true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Message interface
|
||||||
|
export interface Message {
|
||||||
|
role: "user" | "assistant"
|
||||||
|
content: string
|
||||||
|
context?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context tab interface
|
||||||
|
export interface ContextTab {
|
||||||
|
id: string
|
||||||
|
type: "file" | "code" | "image"
|
||||||
|
name: string
|
||||||
|
content: string
|
||||||
|
lineRange?: { start: number; end: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
// AIChat props interface
|
||||||
|
export interface AIChatProps {
|
||||||
|
activeFileContent: string
|
||||||
|
activeFileName: string
|
||||||
|
onClose: () => void
|
||||||
|
editorRef: React.MutableRefObject<monaco.editor.IStandaloneCodeEditor | undefined>
|
||||||
|
lastCopiedRangeRef: React.MutableRefObject<{ startLine: number; endLine: number } | null>
|
||||||
|
files: (TFile | TFolder)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat input props interface
|
||||||
|
export interface ChatInputProps {
|
||||||
|
input: string
|
||||||
|
setInput: (input: string) => void
|
||||||
|
isGenerating: boolean
|
||||||
|
handleSend: (useFullContext?: boolean) => void
|
||||||
|
handleStopGeneration: () => void
|
||||||
|
onImageUpload: (file: File) => void
|
||||||
|
addContextTab: (type: string, title: string, content: string, lineRange?: { start: number, end: number }) => void
|
||||||
|
activeFileName?: string
|
||||||
|
editorRef: React.MutableRefObject<monaco.editor.IStandaloneCodeEditor | undefined>
|
||||||
|
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<HTMLTextAreaElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat message props interface
|
||||||
|
export interface MessageProps {
|
||||||
|
message: {
|
||||||
|
role: "user" | "assistant"
|
||||||
|
content: string
|
||||||
|
context?: string
|
||||||
|
}
|
||||||
|
setContext: (context: string | null, name: string, range?: { start: number, end: number }) => void
|
||||||
|
setIsContextExpanded: (isExpanded: boolean) => void
|
||||||
|
socket: Socket | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context tabs props interface
|
||||||
|
export interface ContextTabsProps {
|
||||||
|
activeFileName: string
|
||||||
|
onAddFile: (tab: ContextTab) => void
|
||||||
|
contextTabs: ContextTab[]
|
||||||
|
onRemoveTab: (id: string) => void
|
||||||
|
isExpanded: boolean
|
||||||
|
onToggleExpand: () => void
|
||||||
|
files?: (TFile | TFolder)[]
|
||||||
|
onFileSelect?: (file: TFile) => void
|
||||||
|
socket: Socket | null
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user