From 5b228a34fea9fa22d3beb444018e9b2d282a331e Mon Sep 17 00:00:00 2001 From: Jim Nielsen Date: Fri, 7 Jul 2023 07:04:48 -1000 Subject: [PATCH] feat: enhance AI assistant ui/x (#583) --- package-lock.json | 14 + package.json | 2 + src/ui/components/CodeSnippet.tsx | 92 ++++ .../CodeEditor/AICodeBlockParser.test.ts | 47 ++ src/ui/menus/CodeEditor/AICodeBlockParser.tsx | 47 +- src/ui/menus/CodeEditor/AITab.tsx | 122 +++-- src/ui/menus/CodeEditor/CodeEditor.tsx | 7 +- src/ui/menus/CodeEditor/Console.tsx | 457 +++++++----------- 8 files changed, 396 insertions(+), 392 deletions(-) create mode 100644 src/ui/components/CodeSnippet.tsx create mode 100644 src/ui/menus/CodeEditor/AICodeBlockParser.test.ts diff --git a/package-lock.json b/package-lock.json index b76917241c..3e129036ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "ace-builds": "^1.4.13", "axios": "^0.24.0", "color": "^4.2.3", + "common-tags": "^1.8.2", "date-fns": "^2.28.0", "fontfaceobserver": "^2.3.0", "fuzzysort": "^2.0.4", @@ -58,6 +59,7 @@ }, "devDependencies": { "@playwright/test": "^1.22.2", + "@types/common-tags": "^1.8.1", "@types/lodash.debounce": "^4.0.7", "@types/mixpanel-browser": "^2.38.1", "@types/papaparse": "^5.3.7", @@ -5635,6 +5637,12 @@ "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==" }, + "node_modules/@types/common-tags": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/common-tags/-/common-tags-1.8.1.tgz", + "integrity": "sha512-20R/mDpKSPWdJs5TOpz3e7zqbeCNuMCPhV7Yndk9KU2Rbij2r5W4RzwDPkzC+2lzUqXYu9rFzTktCBnDjHuNQg==", + "dev": true + }, "node_modules/@types/connect": { "version": "3.4.35", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", @@ -26202,6 +26210,12 @@ "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==" }, + "@types/common-tags": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/common-tags/-/common-tags-1.8.1.tgz", + "integrity": "sha512-20R/mDpKSPWdJs5TOpz3e7zqbeCNuMCPhV7Yndk9KU2Rbij2r5W4RzwDPkzC+2lzUqXYu9rFzTktCBnDjHuNQg==", + "dev": true + }, "@types/connect": { "version": "3.4.35", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", diff --git a/package.json b/package.json index 87a9ce2e21..7724617ba7 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "ace-builds": "^1.4.13", "axios": "^0.24.0", "color": "^4.2.3", + "common-tags": "^1.8.2", "date-fns": "^2.28.0", "fontfaceobserver": "^2.3.0", "fuzzysort": "^2.0.4", @@ -108,6 +109,7 @@ }, "devDependencies": { "@playwright/test": "^1.22.2", + "@types/common-tags": "^1.8.1", "@types/lodash.debounce": "^4.0.7", "@types/mixpanel-browser": "^2.38.1", "@types/papaparse": "^5.3.7", diff --git a/src/ui/components/CodeSnippet.tsx b/src/ui/components/CodeSnippet.tsx new file mode 100644 index 0000000000..8d67ef2e80 --- /dev/null +++ b/src/ui/components/CodeSnippet.tsx @@ -0,0 +1,92 @@ +import { useRef, useState } from 'react'; +import Editor from '@monaco-editor/react'; +import { Box, IconButton, Stack, useTheme } from '@mui/material'; +import { ContentCopy } from '@mui/icons-material'; +import { TooltipHint } from './TooltipHint'; +import { codeEditorBaseStyles } from '../menus/CodeEditor/styles'; + +interface Props { + code: string; + language?: string; +} + +export function CodeSnippet({ code, language = 'plaintext' }: Props) { + const [tooltipMsg, setTooltipMsg] = useState('Click to copy'); + const editorRef = useRef(null); + const theme = useTheme(); + + const handleClick = (e: any) => { + if (editorRef.current) { + navigator.clipboard.writeText(code); + setTooltipMsg('Copied!'); + setTimeout(() => { + setTooltipMsg('Click to copy'); + }, 2000); + } + }; + + const handleEditorDidMount = (editor: any) => { + editorRef.current = editor; + }; + + return ( + + + {language} + + + + + + + +
+ +
+
+ ); +} diff --git a/src/ui/menus/CodeEditor/AICodeBlockParser.test.ts b/src/ui/menus/CodeEditor/AICodeBlockParser.test.ts new file mode 100644 index 0000000000..7064e8f5b4 --- /dev/null +++ b/src/ui/menus/CodeEditor/AICodeBlockParser.test.ts @@ -0,0 +1,47 @@ +import { parseCodeBlocks } from './AICodeBlockParser'; + +describe('parseCodeBlocks()', () => { + test('A string without code blocks returns one item', () => { + const result = parseCodeBlocks(`This is a string from AI.`); + expect(result).toHaveLength(1); + expect(typeof result[0]).toBe('string'); + }); + + test('A string + 1 unclosed code block returns two items', () => { + const result = parseCodeBlocks(` +A string of text with an open code block. + +\`\`\`js +const foo = 'foo';`); + expect(result).toHaveLength(2); + expect(typeof result[0]).toBe('string'); + expect(typeof result[1]).toBe('object'); + }); + + test('A string + 1 code block returns two items', () => { + const result = parseCodeBlocks(` +A string of text with a full code block. + +\`\`\`js +const foo = 'foo'; +\`\`\``); + expect(result).toHaveLength(2); + }); + + test('A string + 1 full code block + a string returns three items', () => { + const result = parseCodeBlocks(` +A string of text with a full code block. + +\`\`\`js +const foo = 'foo'; +\`\`\` + +And another piece of text + +And more text here`); + expect(result).toHaveLength(3); + expect(typeof result[0]).toBe('string'); + expect(typeof result[1]).toBe('object'); + expect(typeof result[2]).toBe('string'); + }); +}); diff --git a/src/ui/menus/CodeEditor/AICodeBlockParser.tsx b/src/ui/menus/CodeEditor/AICodeBlockParser.tsx index 2efa894e4d..1af5110468 100644 --- a/src/ui/menus/CodeEditor/AICodeBlockParser.tsx +++ b/src/ui/menus/CodeEditor/AICodeBlockParser.tsx @@ -1,8 +1,9 @@ -import Editor from '@monaco-editor/react'; +import { Stack } from '@mui/material'; +import { CodeSnippet } from '../../components/CodeSnippet'; -const CODE_BLOCK_REGEX = /```([a-z]+)?\n([\s\S]+?)\n```/g; +const CODE_BLOCK_REGEX = /```([a-z]+)?\n([\s\S]+?)(?:\n```|$)/g; -function parseCodeBlocks(input: string): Array { +export function parseCodeBlocks(input: string): Array { const blocks: Array = []; let match; let lastIndex = 0; @@ -12,39 +13,7 @@ function parseCodeBlocks(input: string): Array { if (lastIndex < match.index) { blocks.push(input.substring(lastIndex, match.index)); } - blocks.push( -
- -
- ); + blocks.push(); lastIndex = match.index + match[0].length; } if (lastIndex < input.length) { @@ -54,5 +23,9 @@ function parseCodeBlocks(input: string): Array { } export function CodeBlockParser({ input }: { input: string }): JSX.Element { - return <>{parseCodeBlocks(input)}; + return ( + + {parseCodeBlocks(input)} + + ); } diff --git a/src/ui/menus/CodeEditor/AITab.tsx b/src/ui/menus/CodeEditor/AITab.tsx index 4cf17abbe7..91ee5f2151 100644 --- a/src/ui/menus/CodeEditor/AITab.tsx +++ b/src/ui/menus/CodeEditor/AITab.tsx @@ -1,7 +1,7 @@ import { useAuth0 } from '@auth0/auth0-react'; import { Send, Stop } from '@mui/icons-material'; import { Avatar, CircularProgress, FormControl, IconButton, InputAdornment, OutlinedInput } from '@mui/material'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import apiClientSingleton from '../../../api-client/apiClientSingleton'; import { EditorInteractionState } from '../../../atoms/editorInteractionStateAtom'; import { CellEvaluationResult } from '../../../grid/computations/types'; @@ -16,6 +16,7 @@ interface Props { editorMode: EditorInteractionState['mode']; evalResult: CellEvaluationResult | undefined; editorContent: string | undefined; + isActive: boolean; } type Message = { @@ -23,7 +24,7 @@ type Message = { content: string; }; -export const AITab = ({ evalResult, editorMode, editorContent }: Props) => { +export const AITab = ({ evalResult, editorMode, editorContent, isActive }: Props) => { // TODO: Improve these messages. Pass current location and more docs. // store in a separate location for different cells const systemMessages = [ @@ -52,6 +53,14 @@ export const AITab = ({ evalResult, editorMode, editorContent }: Props) => { const [messages, setMessages] = useState([]); const controller = useRef(); const { user } = useAuth0(); + const inputRef = useRef(undefined); + + // Focus the input when the tab comes into focus + useEffect(() => { + if (isActive && inputRef.current) { + inputRef.current.focus(); + } + }, [isActive]); const abortPrompt = () => { controller.current?.abort(); @@ -211,14 +220,12 @@ export const AITab = ({ evalResult, editorMode, editorContent }: Props) => { } size="small" fullWidth - autoFocus + inputRef={inputRef} sx={{ py: '.25rem', pr: '1rem' }} />
{ if (((e.metaKey || e.ctrlKey) && e.key === 'a') || ((e.metaKey || e.ctrlKey) && e.key === 'c')) { @@ -229,9 +236,6 @@ export const AITab = ({ evalResult, editorMode, editorContent }: Props) => { }} style={{ outline: 'none', - // fontFamily: 'monospace', - fontSize: '.875rem', - lineHeight: '1.3', whiteSpace: 'pre-wrap', paddingBottom: '5rem', }} @@ -240,67 +244,49 @@ export const AITab = ({ evalResult, editorMode, editorContent }: Props) => { data-gramm_editor="false" data-enable-grammarly="false" > - {display_message.length === 0 ? ( -
-
-
- -
-
- Ask a question to get started. -
+
+ {display_message.map((message, index) => ( +
+ {message.role === 'user' ? ( + + ) : ( + + + + )} +
-
- ) : ( -
- {display_message.map((message, index) => ( -
- {message.role === 'user' ? ( - - ) : ( - - - - )} - {CodeBlockParser({ input: message.content })} -
- ))} -
-
- )} + ))} +
+
); diff --git a/src/ui/menus/CodeEditor/CodeEditor.tsx b/src/ui/menus/CodeEditor/CodeEditor.tsx index a8f6017a7f..52dce92774 100644 --- a/src/ui/menus/CodeEditor/CodeEditor.tsx +++ b/src/ui/menus/CodeEditor/CodeEditor.tsx @@ -437,7 +437,12 @@ export const CodeEditor = (props: CodeEditorProps) => { {(editorInteractionState.mode === 'PYTHON' || editorInteractionState.mode === 'FORMULA' || editorInteractionState.mode === 'AI') && ( - + )}
diff --git a/src/ui/menus/CodeEditor/Console.tsx b/src/ui/menus/CodeEditor/Console.tsx index 4857608cae..cb0711b282 100644 --- a/src/ui/menus/CodeEditor/Console.tsx +++ b/src/ui/menus/CodeEditor/Console.tsx @@ -1,37 +1,36 @@ import { Box, Tabs, Tab, Chip } from '@mui/material'; -import { CSSProperties, useState } from 'react'; +import { useTheme } from '@mui/system'; +import { useEffect, useState } from 'react'; import { CellEvaluationResult } from '../../../grid/computations/types'; import { LinkNewTab } from '../../components/LinkNewTab'; import { colors } from '../../../theme/colors'; import { DOCUMENTATION_FORMULAS_URL, DOCUMENTATION_PYTHON_URL } from '../../../constants/urls'; import { EditorInteractionState } from '../../../atoms/editorInteractionStateAtom'; -import { useTheme } from '@mui/system'; import { AITab } from './AITab'; import { useAuth0 } from '@auth0/auth0-react'; +import { CodeSnippet } from '../../components/CodeSnippet'; +import { stripIndent } from 'common-tags'; +import { Cell } from '../../../schemas'; import { codeEditorBaseStyles, codeEditorCommentStyles } from './styles'; interface ConsoleProps { editorMode: EditorInteractionState['mode']; evalResult: CellEvaluationResult | undefined; editorContent: string | undefined; + selectedCell: Cell; } -export function Console({ evalResult, editorMode, editorContent }: ConsoleProps) { +export function Console({ evalResult, editorMode, editorContent, selectedCell }: ConsoleProps) { const [activeTabIndex, setActiveTabIndex] = useState(0); const { std_err = '', std_out = '' } = evalResult || {}; let hasOutput = Boolean(std_err.length || std_out.length); - const theme = useTheme(); const { isAuthenticated } = useAuth0(); + const theme = useTheme(); - const codeSampleStyles: CSSProperties = { - backgroundColor: colors.lightGray, - padding: theme.spacing(1), - whiteSpace: 'pre-wrap', - }; - - if (editorMode === 'AI') { - if (activeTabIndex !== 1) setActiveTabIndex(1); - } + // Whenever we change to a different cell, reset the active tab to the 1st + useEffect(() => { + setActiveTabIndex(0); + }, [selectedCell]); return ( <> @@ -67,7 +66,7 @@ export function Console({ evalResult, editorMode, editorContent }: ConsoleProps) ) : null} -
+
-
- {editorMode === 'PYTHON' ? ( -
-

Logging

-

`print()` statements and errors are logged in the CONSOLE tab.

-

Returning data to the sheet

-

The last statement in your code is returned to the sheet.

-

Example:

-
-                  1 2 *{' '}
-                  2
-                  
- ↳ 4 # number returned as the cell value -
-

Example:

-
-                  1 result = []
-                  
- 2 for x{' '} - in - range(100): -

- 3 {' '} - result.append(x) -
- 4 -
- 5 result -
- ↳ [0, 1, 2, ..., 99] # returns 100 cells counting from 0 to 99 -
+ {editorMode === 'PYTHON' ? ( +
+

Logging

+

`print()` statements and errors are logged in the CONSOLE tab.

+

Returning data to the sheet

+

The last statement in your code is returned to the sheet.

+

Example:

-

Referencing data from the sheet

-

Use the `cell(x, y)` function — or shorthand `c(x, y)` — to reference values in the sheet.

-

Example:

-
-                  1 c(1, 1) +{' '}
-                  c(2, 2)
-                  
- ↳ The sum of the cell values at x:1 y:1 and x:2 y:2 -
+ -

Advanced topics

-
    -
  • Fetching data from an API.
  • -
  • Using Pandas DataFrames.
  • -
  • Installing third-party packages.
  • -
-

- Learn more in our documenation. -

-
-
- ) : editorMode === 'AI' ? ( - <> -

- -

- Warning: AI in Quadratic as a cell type is currently experimental.

The implementation may - change without notice. - -

Data generated by AI models needs to be validated as it is often incorrect. -
-

-

AI Docs

-
Generating New Data
-

- With GPT AI as a cell type, GPT AI can directly generate data and return it to the sheet. Whether you - need to generate a list of names, dates, or any other type of data, GPT AI can do it for you quickly - and easily, saving you valuable time and resources. -

-
Working With Existing Data
-

- When you use GPT AI as a cell type, it has access to the data in your sheet and can use it to generate - new data or update existing data. This means that GPT AI can analyze the data in your sheet and - generate new data that is consistent with the existing data. GPT AI can even generate data that is - specific to your needs, such as data that fits a particular pattern or meets certain criteria. With - GPT AI support in Quadratic, you can be confident that your data is always accurate and up-to-date. -

- - ) : ( - <> -

Spreadsheet formulas

-

Use the familiar language of spreadsheet formulas.

-

Example:

-
-                  1 SUM(A0:A99)
-                  
- ↳ Returns the SUM of cells A0 to A99 -
-

Referencing cells

-

- In the positive quadrant, cells are referenced similar to other spreadsheets. In the negative - quadrant, cells are referenced using a `n` prefix. -

-

Examples:

- - - - - - - - - - - - - - - - - - - - - - - - - - - - +
-
- - - nAn0 notation - - -
-
-
- - - - (x, y) - - - -
-
-
- - - A0 - - -
-
-
- - - (0, 0) - - -
-
-
- - - A1 - - -
-
-
- - - (0, 1) - - -
-
-
- - - B1 - - -
-
-
- - - (1, 1) - - -
-
-
- - - An1 - - -
-
-
- - - (0, -1) - - -
-
-
- - - nA1 - - -
-
-
- - - (-1, 1) - - -
-
-
- - - nAn1 - - -
+

Example:

+ + + +

Referencing data from the sheet

+

Use the `cell(x, y)` function — or shorthand `c(x, y)` — to reference values in the sheet.

+

Example:

+ + + +

Advanced topics

+
    +
  • Fetching data from an API.
  • +
  • Using Pandas DataFrames.
  • +
  • Installing third-party packages.
  • +
+

+ Learn more in our documenation. +

+
+ + ) : editorMode === 'AI' ? ( + <> +

+ +

+ Warning: AI in Quadratic as a cell type is currently experimental. The implementation + may change without notice. +

+

+ Data generated by AI models needs to be validated as it is often incorrect. +

+ +

Generating New Data

+

+ With GPT AI as a cell type, GPT AI can directly generate data and return it to the sheet. Whether you + need to generate a list of names, dates, or any other type of data, GPT AI can do it for you quickly and + easily, saving you valuable time and resources. +

+

Working With Existing Data

+

+ When you use GPT AI as a cell type, it has access to the data in your sheet and can use it to generate + new data or update existing data. This means that GPT AI can analyze the data in your sheet and generate + new data that is consistent with the existing data. GPT AI can even generate data that is specific to + your needs, such as data that fits a particular pattern or meets certain criteria. With GPT AI support + in Quadratic, you can be confident that your data is always accurate and up-to-date. +

+ + ) : ( + <> +

Spreadsheet formulas

+

Use the familiar language of spreadsheet formulas.

+

Example:

+ + + +

Referencing cells

+

+ In the positive quadrant, cells are referenced similar to other spreadsheets. In the negative quadrant, + cells are referenced using a `n` prefix. +

+

Examples:

+ + + + + + + + + {[ + ['A0', '(0,0)'], + ['A1', '(0, 1)'], + ['B1', '(1, 1)'], + ['An1', '(0, -1)'], + ['nA1', '(-1, 1)'], + ['nAn1', '(-1, -1)'], + ].map(([key, val]) => ( + + - -
+ nAn0 + + (x, y) +
+ {key} -
- - - (-1, -1) - - -
+ {val}
-

Multiline formulas

-

- Line spaces are ignored when evaluating formulas. You can use them to make your formulas more - readable. -

-

Example:

-
-                  1 IF(A0 {'>'} 0,
-                  

- 2   IF(B0{' '} - {'<'} 2, -

- 3     "Valid Dataset", -

- 3     "B0 is invalid", -

- 4   ), -

- 3   "A0 is invalid", -

- 5 )

-
-

More info

-

- Check out the docs to see a full list of - supported formulas and documentation for how to use specific formula functions. -

-

- - )} - + ))} +
+ +

Multiline formulas

+

+ Line spaces are ignored when evaluating formulas. You can use them to make your formulas more readable. +

+

Example:

+ + 0, + IF(B0 < 2, + "Valid Dataset", + "B0 is invalid" + ), + "A0 is invalid" + ) + `} + /> + +

More info

+

+ Check out the docs to see a full list of + supported formulas and documentation for how to use specific formula functions. +

+

+ + )} - +