Merge pull request #40 from covespace/context-support

Context support
This commit is contained in:
Rankin Zheng 2023-05-10 17:41:20 +08:00 committed by GitHub
commit c43753bc67
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 307 additions and 157 deletions

View File

@ -32,7 +32,7 @@ class MessageUtil {
} }
// Register a message handler for a specific message type // Register a message handler for a specific message type
registerHandler(messageType: string, handler: { (message: { text: string; }): void; (message: { text: string; }): void; }) { registerHandler(messageType: string, handler: any) {
if (!this.handlers[messageType]) { if (!this.handlers[messageType]) {
this.handlers[messageType] = []; this.handlers[messageType] = [];
} }
@ -57,7 +57,7 @@ class MessageUtil {
} }
// Send a message to the VSCode API // Send a message to the VSCode API
sendMessage(message: { command: string; text: string; }) { sendMessage(message: any) {
vscodeApi.postMessage(message); vscodeApi.postMessage(message);
} }
} }

View File

@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Avatar, Center, Container, CopyButton, Divider, Flex, Grid, Stack, Textarea, TypographyStylesProvider, px, rem, useMantineTheme } from '@mantine/core'; import { Accordion, AccordionControlProps, Avatar, Box, Center, Container, CopyButton, Divider, Flex, Grid, Stack, Textarea, TypographyStylesProvider, px, rem, useMantineTheme } from '@mantine/core';
import { Input, Tooltip } from '@mantine/core'; import { Input, Tooltip } from '@mantine/core';
import { List } from '@mantine/core'; import { List } from '@mantine/core';
import { ScrollArea } from '@mantine/core'; import { ScrollArea } from '@mantine/core';
@ -8,7 +8,7 @@ import { createStyles, keyframes } from '@mantine/core';
import { ActionIcon } from '@mantine/core'; import { ActionIcon } from '@mantine/core';
import { Menu, Button, Text } from '@mantine/core'; import { Menu, Button, Text } from '@mantine/core';
import { useElementSize, useListState, useResizeObserver, useViewportSize } from '@mantine/hooks'; import { useElementSize, useListState, useResizeObserver, useViewportSize } from '@mantine/hooks';
import { IconCheck, IconClick, IconCopy, IconEdit, IconFolder, IconGitCompare, IconMessageDots, IconRobot, IconSend, IconSquareRoundedPlus, IconUser } from '@tabler/icons-react'; import { IconAdjustments, IconBulb, IconCameraSelfie, IconCheck, IconClick, IconColumnInsertRight, IconCopy, IconDots, IconEdit, IconFileDiff, IconFolder, IconGitCompare, IconMessageDots, IconMessagePlus, IconPrinter, IconPrompt, IconReplace, IconRobot, IconSend, IconSquareRoundedPlus, IconTerminal2, IconUser, IconX } from '@tabler/icons-react';
import { IconSettings, IconSearch, IconPhoto, IconMessageCircle, IconTrash, IconArrowsLeftRight } from '@tabler/icons-react'; import { IconSettings, IconSearch, IconPhoto, IconMessageCircle, IconTrash, IconArrowsLeftRight } from '@tabler/icons-react';
import { Prism } from '@mantine/prism'; import { Prism } from '@mantine/prism';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
@ -22,34 +22,8 @@ const blink = keyframes({
}); });
const useStyles = createStyles((theme, _params, classNames) => ({ const useStyles = createStyles((theme, _params, classNames) => ({
plusMenu: { menu: {
top: 'unset !important',
left: '31px !important',
bottom: 60,
},
commandMenu: {
top: 'unset !important',
left: '31px !important',
bottom: 60,
},
commandText: {
fontSize: '1.0rem',
fontWeight: 'bolder',
},
commandDesc: {
fontSize: '0.8rem',
color: theme.colors.gray[6],
},
responseContent: {
marginTop: 0,
marginLeft: 0,
marginRight: 0,
paddingLeft: 0,
paddingRight: 0,
width: 'calc(100% - 62px)',
},
icon: {
pointerEvents: 'all',
}, },
avatar: { avatar: {
marginTop: 8, marginTop: 8,
@ -62,38 +36,46 @@ const chatPanel = () => {
const theme = useMantineTheme(); const theme = useMantineTheme();
const chatContainerRef = useRef<HTMLDivElement>(null); const chatContainerRef = useRef<HTMLDivElement>(null);
const scrollViewport = useRef<HTMLDivElement>(null); const scrollViewport = useRef<HTMLDivElement>(null);
const [messages, handlers] = useListState<{ type: string; message: string; }>([]); const [messages, messageHandlers] = useListState<{ type: string; message: string; contexts?: any[] }>([]);
const [commandMenus, commandMenusHandlers] = useListState<{ pattern: string; description: string; name: string }>([]);
const [contextMenus, contextMenusHandlers] = useListState<{ pattern: string; description: string; name: string }>([]);
const [contexts, contextsHandlers] = useListState<any>([]);
const [currentMessage, setCurrentMessage] = useState(''); const [currentMessage, setCurrentMessage] = useState('');
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [responsed, setResponsed] = useState(false); const [responsed, setResponsed] = useState(false);
const [registed, setRegisted] = useState(false); const [registed, setRegisted] = useState(false);
const [opened, setOpened] = useState(false);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [commandOpened, setCommandOpened] = useState(false); const [menuOpend, setMenuOpend] = useState(false);
const [menuType, setMenuType] = useState(''); // contexts or commands
const { classes } = useStyles(); const { classes } = useStyles();
const { height, width } = useViewportSize(); const { height, width } = useViewportSize();
const [inputRef, inputRect] = useResizeObserver(); const [inputRef, inputRect] = useResizeObserver();
const handlePlusClick = (event: React.MouseEvent<HTMLButtonElement>) => { const handlePlusClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setOpened(!opened); setMenuType('contexts');
setMenuOpend(!menuOpend);
event.stopPropagation(); event.stopPropagation();
}; };
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (opened) { setOpened(false); }
};
const handleSendClick = (event: React.MouseEvent<HTMLButtonElement>) => { const handleSendClick = (event: React.MouseEvent<HTMLButtonElement>) => {
if (input) { if (input) {
// Add the user's message to the chat UI // Add the user's message to the chat UI
handlers.append({ type: 'user', message: input }); messageHandlers.append({ type: 'user', message: input, contexts: contexts ? [...contexts].map((item) => ({ ...item })) : undefined });
// Process and send the message to the extension
const contextStrs = contexts.map(({ file, context }, index) => {
return `[context|${file}]`;
});
const text = input + contextStrs.join(' ');
console.log(`message text: ${text}`);
messageUtil.sendMessage({
command: 'sendMessage',
text: text
});
// Clear the input field // Clear the input field
setInput(''); setInput('');
contexts.length = 0;
// Process and send the message to the extension
messageUtil.sendMessage({
command: 'sendMessage',
text: input
});
// start generating // start generating
setGenerating(true); setGenerating(true);
@ -101,17 +83,28 @@ const chatPanel = () => {
setCurrentMessage(''); setCurrentMessage('');
} }
}; };
const handleContextClick = (contextName: string) => {
// Process and send the message to the extension
messageUtil.sendMessage({
command: 'addContext',
selected: contextName
});
};
const scrollToBottom = () => const scrollToBottom = () =>
scrollViewport?.current?.scrollTo({ top: scrollViewport.current.scrollHeight, behavior: 'smooth' }); scrollViewport?.current?.scrollTo({ top: scrollViewport.current.scrollHeight, behavior: 'smooth' });
useEffect(() => { useEffect(() => {
inputRef.current.focus(); inputRef.current.focus();
messageUtil.sendMessage({ command: 'regContextList' });
messageUtil.sendMessage({ command: 'regCommandList' });
}, []); }, []);
useEffect(() => { useEffect(() => {
if (generating) { if (generating) {
// new a bot message // new a bot message
handlers.append({ type: 'bot', message: currentMessage }); messageHandlers.append({ type: 'bot', message: currentMessage });
} }
}, [generating]); }, [generating]);
@ -121,7 +114,7 @@ const chatPanel = () => {
const lastMessage = messages[lastIndex]; const lastMessage = messages[lastIndex];
if (currentMessage && lastMessage?.type === 'bot') { if (currentMessage && lastMessage?.type === 'bot') {
// update the last one bot message // update the last one bot message
handlers.setItem(lastIndex, { type: 'bot', message: currentMessage }); messageHandlers.setItem(lastIndex, { type: 'bot', message: currentMessage });
} }
}, [currentMessage]); }, [currentMessage]);
@ -140,15 +133,53 @@ const chatPanel = () => {
setResponsed(true); setResponsed(true);
scrollToBottom(); scrollToBottom();
}); });
messageUtil.registerHandler('regCommandList', (message: { result: { pattern: string; description: string; name: string }[] }) => {
commandMenusHandlers.append(...message.result);
});
messageUtil.registerHandler('regContextList', (message: { result: { pattern: string; description: string; name: string }[] }) => {
contextMenusHandlers.append(...message.result);
});
messageUtil.registerHandler('appendContext', (message: { command: string; context: string }) => {
// context is a temp file path
const match = /\|([^]+?)\]/.exec(message.context);
// Process and send the message to the extension
messageUtil.sendMessage({
command: 'contextDetail',
file: match && match[1],
});
});
messageUtil.registerHandler('contextDetailResponse', (message: { command: string; file: string; result: string }) => {
//result is a content json
// 1. diff json structure
// {
// languageId: languageId,
// path: fileSelected,
// content: codeSelected
// };
// 2. command json structure
// {
// command: commandString,
// content: stdout
// };
const context = JSON.parse(message.result);
if (typeof context !== 'undefined' && context) {
contextsHandlers.append({
file: message.file,
context: context,
});
console.log(context);
}
});
}, [registed]); }, [registed]);
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => { const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value; const value = event.target.value;
// if value start with '/' command show menu // if value start with '/' command show menu
if (value === '/') { if (value === '/') {
setCommandOpened(true); setMenuOpend(true);
setMenuType('commands');
} else { } else {
setCommandOpened(false); setMenuOpend(false);
} }
setInput(value); setInput(value);
}; };
@ -163,12 +194,57 @@ const chatPanel = () => {
<Text size="lg" color="gray" weight={500}>No messages yet</Text> <Text size="lg" color="gray" weight={500}>No messages yet</Text>
</Center>); </Center>);
const messageList = messages.map(({ message: messageText, type: messageType }, index) => { const commandMenusNode = commandMenus.map(({ pattern, description, name }, index) => {
return (
<Menu.Item
onClick={() => { setInput(`/${pattern} `); }}
icon={<IconTerminal2 size={16} />}
>
<Text sx={{
fontSize: 'sm',
fontWeight: 'bolder',
}}>
/{pattern}
</Text>
<Text sx={{
fontSize: 'sm',
color: theme.colors.gray[6],
}}>
{description}
</Text>
</Menu.Item>);
});
const contextMenusNode = contextMenus.map(({ pattern, description, name }, index) => {
return (
<Menu.Item
onClick={() => {
handleContextClick(name);
}}
icon={<IconMessagePlus size={16} />}
>
<Text sx={{
fontSize: 'sm',
fontWeight: 'bolder',
}}>
{name}
</Text>
<Text sx={{
fontSize: 'sm',
color: theme.colors.gray[6],
}}>
{description}
</Text>
</Menu.Item>);
});
const messageList = messages.map(({ message: messageText, type: messageType, contexts }, index) => {
// setMessage(messageText); // setMessage(messageText);
return (<> return (<>
<Flex <Flex
key={`message-${index}`} key={`message-${index}`}
mih={50} mih={50}
miw={300}
gap="md" gap="md"
justify="flex-start" justify="flex-start"
align="flex-start" align="flex-start"
@ -181,7 +257,44 @@ const chatPanel = () => {
: <Avatar color="cyan" size='md' radius="xl" className={classes.avatar}><IconUser size="1.5rem" /></Avatar> : <Avatar color="cyan" size='md' radius="xl" className={classes.avatar}><IconUser size="1.5rem" /></Avatar>
} }
<Container className={classes.responseContent}> <Container sx={{
marginTop: 0,
marginLeft: 0,
marginRight: 0,
paddingLeft: 0,
paddingRight: 0,
width: 'calc(100% - 62px)',
pre: {
whiteSpace: 'break-spaces'
}
}}>
{contexts &&
<Accordion variant="contained" chevronPosition="left" style={{ backgroundColor: '#FFF' }}>
{
contexts?.map(({ context }, index) => {
return (
<Accordion.Item value={`item-${index}`} mah='200'>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Accordion.Control >
{'command' in context ? context.command : context.path}
</Accordion.Control>
</Box>
<Accordion.Panel>
{
context.content
? context.content
: <Center>
<Text c='gray.3'>No content</Text>
</Center>
}
</Accordion.Panel>
</Accordion.Item>
);
})
}
</Accordion>
}
<ReactMarkdown <ReactMarkdown
components={{ components={{
code({ node, inline, className, children, ...props }) { code({ node, inline, className, children, ...props }) {
@ -211,17 +324,38 @@ const chatPanel = () => {
</div> </div>
)} )}
</div> </div>
<div style={{ position: 'absolute', top: 3, right: 5 }}> <Flex
gap="5px"
justify="flex-start"
align="flex-start"
direction="row"
wrap="wrap"
style={{ position: 'absolute', top: 8, right: 10 }}>
<CopyButton value={value} timeout={2000}> <CopyButton value={value} timeout={2000}>
{({ copied, copy }) => ( {({ copied, copy }) => (
<Tooltip label={copied ? 'Copied' : 'Copy'} withArrow position="right"> <Tooltip label={copied ? 'Copied' : 'Copy'} withArrow position="left" color="gray">
<ActionIcon color={copied ? 'teal' : 'gray'} onClick={copy}> <ActionIcon color={copied ? 'teal' : 'gray'} onClick={copy}>
{copied ? <IconCheck size="1rem" /> : <IconCopy size="1rem" />} {copied ? <IconCheck size="1rem" /> : <IconCopy size="1rem" />}
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
</CopyButton> </CopyButton>
</div> <Tooltip label='View-diff' withArrow position="left" color="gray">
<ActionIcon>
<IconFileDiff size="1.125rem" />
</ActionIcon>
</Tooltip>
<Tooltip label='Insert Code' withArrow position="left" color="gray">
<ActionIcon>
<IconColumnInsertRight size="1.125rem" />
</ActionIcon>
</Tooltip>
<Tooltip label='Replace' withArrow position="left" color="gray">
<ActionIcon>
<IconReplace size="1.125rem" />
</ActionIcon>
</Tooltip>
</Flex>
<SyntaxHighlighter {...props} language={match[1]} customStyle={{ padding: '2em 1em 1em 2em' }} style={okaidia} PreTag="div"> <SyntaxHighlighter {...props} language={match[1]} customStyle={{ padding: '2em 1em 1em 2em' }} style={okaidia} PreTag="div">
{value} {value}
</SyntaxHighlighter> </SyntaxHighlighter>
@ -243,113 +377,129 @@ const chatPanel = () => {
backgroundColor: 'black' backgroundColor: 'black'
}}>|</Text> : ''} }}>|</Text> : ''}
</Container> </Container>
</Flex> </Flex >
{index !== messages.length - 1 && <Divider my="sm" />} {index !== messages.length - 1 && <Divider my="sm" />
}
</>); </>);
}); });
return ( return (
<> <Container
<Container id='chat-container'
ref={chatContainerRef} ref={chatContainerRef}
sx={{ sx={{
height: '100%', height: '100%',
paddingTop: 10, paddingTop: 10,
backgroundColor: theme.colors.gray[0], backgroundColor: theme.colors.gray[0],
}} }}>
onClick={handleContainerClick}> <ScrollArea
<ScrollArea id='chat-scroll-area'
h={height - px('5rem')} h={height - px('5rem')}
type="never" type="never"
viewportRef={scrollViewport}> viewportRef={scrollViewport}>
{messageList.length > 0 ? messageList : defaultMessages} {messageList.length > 0 ? messageList : defaultMessages}
</ScrollArea> </ScrollArea>
<Textarea <Stack
disabled={generating} sx={{ position: 'absolute', bottom: 10, width: scrollViewport.current?.clientWidth }}>
value={input} {contexts &&
ref={inputRef} <Accordion variant="contained" chevronPosition="left" style={{ backgroundColor: '#FFF' }}>
onKeyDown={handleKeyDown} {
onChange={handleInputChange} contexts.map(({ context }, index) => {
autosize return (
minRows={1} <Accordion.Item value={`item-${index}`} mah='200'>
maxRows={10} <Box sx={{ display: 'flex', alignItems: 'center' }}>
radius="md" <Accordion.Control >
size="md" {'command' in context ? context.command : context.path}
sx={{ pointerEvents: 'all', position: 'absolute', bottom: 10, width: scrollViewport.current?.clientWidth }} </Accordion.Control>
placeholder="Ctrl + Enter Send a message." <ActionIcon
styles={{ icon: { alignItems: 'flex-start', paddingTop: '9px' }, rightSection: { alignItems: 'flex-start', paddingTop: '9px' } }} mr={8}
icon={ size="lg"
<ActionIcon onClick={handlePlusClick} sx={{ pointerEvents: 'all' }}> onClick={() => {
<IconSquareRoundedPlus size="1rem" /> contextsHandlers.remove(index);
</ActionIcon> }}>
<IconX size="1rem" />
</ActionIcon>
</Box>
<Accordion.Panel>
{
context.content
? context.content
: <Center>
<Text c='gray.3'>No content</Text>
</Center>
}
</Accordion.Panel>
</Accordion.Item>
);
})
}
</Accordion>
}
<Menu
id='commandMenu'
position='top-start'
closeOnClickOutside={true}
shadow="xs"
width={scrollViewport.current?.clientWidth}
opened={menuOpend}
onChange={setMenuOpend}
onClose={() => setMenuType('')}
onOpen={() => menuType !== '' ? setMenuOpend(true) : setMenuOpend(false)}
returnFocus={true}>
<Menu.Target>
<Textarea
id='chat-textarea'
disabled={generating}
value={input}
ref={inputRef}
onKeyDown={handleKeyDown}
onChange={handleInputChange}
autosize
minRows={1}
maxRows={10}
radius="md"
size="md"
sx={{ pointerEvents: 'all' }}
placeholder="Ctrl + Enter Send a message."
styles={{ icon: { alignItems: 'flex-start', paddingTop: '9px' }, rightSection: { alignItems: 'flex-start', paddingTop: '9px' } }}
icon={
<ActionIcon onClick={handlePlusClick} sx={{ pointerEvents: 'all' }}>
<IconSquareRoundedPlus size="1rem" />
</ActionIcon>
}
rightSection={
<ActionIcon onClick={handleSendClick}>
<IconSend size="1rem" />
</ActionIcon>
}
/>
</Menu.Target>
{
menuType === 'contexts'
? (<Menu.Dropdown>
<Text
c="dimmed"
ta="left"
fz='sm'
m='12px'>
<IconBulb size={14} style={{ marginTop: '2px', marginRight: '2px' }} />
Tips: Select code or file & right click
</Text>
<Divider />
<Menu.Label>DevChat Contexts</Menu.Label>
{contextMenusNode}
</Menu.Dropdown>)
: menuType === 'commands'
? <Menu.Dropdown>
<Menu.Label>DevChat Commands</Menu.Label>
{commandMenusNode}
</Menu.Dropdown>
: <></>
} }
rightSection={ </Menu>
<ActionIcon onClick={handleSendClick}> </Stack>
<IconSend size="1rem" /> </Container >
</ActionIcon>
}
/>
</Container >
<Menu id='plusMenu' shadow="md" width={300} opened={opened} onChange={setOpened} >
<Menu.Dropdown className={classes.plusMenu}>
<Menu.Item icon={<IconClick size={14} />}>Select code or file & right click</Menu.Item>
<Menu.Item icon={<IconGitCompare size={14} />}>Add `git diff --cached`</Menu.Item>
<Menu.Item icon={<IconGitCompare size={14} />}>Add `git diff HEAD`</Menu.Item>
<Menu.Item icon={<IconFolder size={14} />}>Add folder structure</Menu.Item>
<Menu.Item icon={<IconMessageDots size={14} />}>Select previous chat</Menu.Item>
</Menu.Dropdown>
</Menu>
<Menu id='commandMenu' shadow="md" width={500} opened={commandOpened} onChange={setCommandOpened} returnFocus={true}>
<Menu.Dropdown className={classes.commandMenu}>
<Menu.Label>Context Commands</Menu.Label>
<Menu.Item onClick={() => { setInput('/ref ') }}>
<Text className={classes.commandText}>
/ref
</Text>
<Text className={classes.commandDesc}>
Run a local CLI and add its output to the context (e.g., pytest .).
</Text>
</Menu.Item>
<Menu.Item onClick={() => { setInput('/local ') }}>
<Text className={classes.commandText}>
/local
</Text>
<Text className={classes.commandDesc}>
Bypass AI and run a local CLI to check its output (e.g., git status).
</Text>
</Menu.Item>
<Menu.Divider />
<Menu.Label>DevChat Bots</Menu.Label>
<Menu.Item onClick={() => { setInput('/code ') }}>
<Text className={classes.commandText}>
/code
</Text>
<Text className={classes.commandDesc}>
Generate or update code.
</Text>
</Menu.Item>
<Menu.Item onClick={() => { setInput('/commit_message ') }}>
<Text className={classes.commandText}>
/commit_message
</Text>
<Text className={classes.commandDesc}>
Write a commit message.
</Text>
</Menu.Item>
<Menu.Item onClick={() => { setInput('/doc ') }}>
<Text className={classes.commandText}>
/doc
</Text>
<Text className={classes.commandDesc}>
Write a doc for reference, wiki, or discussion.
</Text>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
); );
}; };