diff --git a/.pnp.cjs b/.pnp.cjs index 813c34708..d0085855b 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -354,6 +354,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["@anthropic-ai/sdk", [\ + ["npm:0.57.0", {\ + "packageLocation": "./.yarn/cache/@anthropic-ai-sdk-npm-0.57.0-b4dfdf7616-3ff430ded9.zip/node_modules/@anthropic-ai/sdk/",\ + "packageDependencies": [\ + ["@anthropic-ai/sdk", "npm:0.57.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@babel/cli", [\ ["npm:7.23.0", {\ "packageLocation": "./.yarn/cache/@babel-cli-npm-7.23.0-5f9206645f-a08dab5b18.zip/node_modules/@babel/cli/",\ @@ -6599,6 +6608,7 @@ const RAW_RUNTIME_STATE = "packageLocation": "./packages/web-console/",\ "packageDependencies": [\ ["@questdb/web-console", "workspace:packages/web-console"],\ + ["@anthropic-ai/sdk", "npm:0.57.0"],\ ["@babel/cli", "virtual:b7c775051d99785ec73273707ec685f06b84c737b39cc023ebc60bda25254288f27e86643fb15b7a00bd8a6d76cc119d4628443d858e76eb62af2718d7eb18cf#npm:7.23.0"],\ ["@babel/core", "npm:7.23.0"],\ ["@babel/preset-env", "virtual:e49d39393ba37529f9a804f2bb1b00429525eeeacf4cbbbcc8cdb6781e1e2afa26e9710d4bd0ec7b88be3aaf8e9c0266d2a7856806e05f8a0512234efc8d0f28#npm:7.22.20"],\ diff --git a/.yarn/cache/@anthropic-ai-sdk-npm-0.57.0-b4dfdf7616-3ff430ded9.zip b/.yarn/cache/@anthropic-ai-sdk-npm-0.57.0-b4dfdf7616-3ff430ded9.zip new file mode 100644 index 000000000..7a2a08276 Binary files /dev/null and b/.yarn/cache/@anthropic-ai-sdk-npm-0.57.0-b4dfdf7616-3ff430ded9.zip differ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..2d14679c0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,127 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +QuestDB UI is a monorepo hosting the implementation of QuestDB user interface and surrounding tooling using TypeScript, React, and Yarn 3 with PnP (Plug and Play). + +### Package Structure + +- `@questdb/web-console` - The main GUI application for QuestDB +- `@questdb/react-components` - Shared component library for internal reuse +- `browser-tests` - Cypress-based browser tests + +## Common Development Commands + +### Initial Setup +```bash +# Clone and bootstrap (dependencies are committed via Yarn PnP) +yarn + +# Build react-components first (required dependency) +yarn workspace @questdb/react-components build +``` + +### Web Console Development +```bash +# Start development server (runs on localhost:9999) +yarn workspace @questdb/web-console start + +# Build production version +yarn workspace @questdb/web-console build + +# Run unit tests (watch mode) +yarn workspace @questdb/web-console test + +# Run unit tests (CI mode) +yarn workspace @questdb/web-console test:prod +``` + +### React Components Development +```bash +# Start Storybook +yarn workspace @questdb/react-components storybook + +# Build library +yarn workspace @questdb/react-components build +``` + +### Browser Tests +```bash +# Run browser tests (requires web-console running) +yarn workspace browser-tests test + +# Run auth tests +yarn workspace browser-tests test:auth + +# Run enterprise tests +yarn workspace browser-tests test:enterprise +``` + +### Running QuestDB Backend +The web console requires QuestDB running in the background: +```bash +docker run -p 9000:9000 -p 9009:9009 -p 8812:8812 questdb/questdb +``` + +## Architecture Overview + +### Web Console Structure + +The web console (`packages/web-console/src/`) follows a layered architecture: + +1. **Entry Points** + - `index.tsx` - React application bootstrap + - `index.html` - HTML template + +2. **State Management** + - Redux store with epics (redux-observable) + - Store modules: `Console`, `Query`, `Telemetry` + - Database persistence with Dexie.js + +3. **Core Scenes/Features** + - `Console` - Main SQL query interface + - `Editor` - Monaco-based SQL editor with QuestDB-specific language support + - `Schema` - Database schema browser with virtual table support + - `Import` - CSV file import functionality + - `Result` - Query result visualization + - `Metrics` - System metrics visualization with uPlot + +4. **Provider Hierarchy** + - `QuestProvider` - QuestDB client services + - `LocalStorageProvider` - Persistent settings + - `AuthProvider` - Authentication handling + - `SettingsProvider` - Application settings + - `PosthogProviderWrapper` - Analytics + +5. **Component Organization** + - Reusable components in `components/` + - Scene-specific components within each scene directory + - Shared hooks in `Hooks/` + - Form components with Joi validation schemas + +### Key Technologies + +- **UI Framework**: React 17 with TypeScript +- **Styling**: Styled-components + SCSS +- **State**: Redux + Redux-Observable (RxJS) +- **Editor**: Monaco Editor with custom QuestDB SQL language +- **Charts**: uPlot for time-series, ECharts for general visualizations +- **Forms**: React Hook Form with Joi validation +- **Storage**: Dexie.js for IndexedDB persistence +- **Build**: Webpack 5 with Babel + +### Development Notes + +- Node version: 16.13.1 (use fnm/nvm) +- Yarn version: 3.x (enabled via corepack) +- All dependencies are committed (Yarn Zero-Installs) +- TypeScript version locked at 4.4.4 +- Bundle size limits enforced via BundleWatch + +### Testing Approach + +- Unit tests: Jest with React Testing Library +- Browser tests: Cypress for E2E testing +- Time zone: Tests run with TZ=UTC diff --git a/packages/browser-tests/cypress/integration/console/editor.spec.js b/packages/browser-tests/cypress/integration/console/editor.spec.js index e6781a8a6..697e2d7ed 100644 --- a/packages/browser-tests/cypress/integration/console/editor.spec.js +++ b/packages/browser-tests/cypress/integration/console/editor.spec.js @@ -958,6 +958,22 @@ describe("handling comments", () => { `select\n\n --line;\n 2` ); }); + + it("should ignore quotes inside comments", () => { + cy.typeQueryDirectly( + "/*\n * Today's intraday EURUSD market activity.\n */\nselect timestamp, open, high, low, close, total_volume\nfrom market_data_ohlc_15m\nwhere date_trunc('day', now()) < timestamp and symbol = 'EURUSD';\nselect 1;\n" + ); + cy.getCursorQueryGlyph().should("have.length", 2); + + cy.clickLine(1); + cy.getCursorQueryDecoration().should("not.exist"); + + cy.clickLine(4); + cy.getCursorQueryDecoration().should("have.length", 3); + + cy.clickLine(7); + cy.getCursorQueryDecoration().should("have.length", 1); + }); }); describe("multiple run buttons with dynamic query log", () => { diff --git a/packages/browser-tests/questdb b/packages/browser-tests/questdb index f77ffe8f9..31b66f025 160000 --- a/packages/browser-tests/questdb +++ b/packages/browser-tests/questdb @@ -1 +1 @@ -Subproject commit f77ffe8f957f5c7475c7649ef662aa224b70986a +Subproject commit 31b66f0251dc0c39db14b11c60566a2609157b1e diff --git a/packages/web-console/assets/icon-compare.svg b/packages/web-console/assets/icon-compare.svg new file mode 100644 index 000000000..4ef091075 --- /dev/null +++ b/packages/web-console/assets/icon-compare.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web-console/package.json b/packages/web-console/package.json index 07c59ee15..3613a3328 100644 --- a/packages/web-console/package.json +++ b/packages/web-console/package.json @@ -14,14 +14,16 @@ "node": ">=16.13.1" }, "scripts": { - "build": "cross-env NODE_ENV=production COMMIT_HASH=$(git rev-parse --short HEAD) webpack", + "build-docs": "node scripts/build-docs-ts.js", + "build": "npm run build-docs && cross-env NODE_ENV=production COMMIT_HASH=$(git rev-parse --short HEAD) webpack", "postbuild": "node post-build.js", - "start": "COMMIT_HASH=$(git rev-parse --short HEAD) webpack-dev-server --progress", - "start:prod": "cross-env NODE_ENV=production COMMIT_HASH=$(git rev-parse --short HEAD) webpack-dev-server --progress", + "start": "npm run build-docs && COMMIT_HASH=$(git rev-parse --short HEAD) webpack-dev-server --progress", + "start:prod": "npm run build-docs && cross-env NODE_ENV=production COMMIT_HASH=$(git rev-parse --short HEAD) webpack-dev-server --progress", "test": "TZ=UTC && jest --watch --runInBand", "test:prod": "TZ=UTC && jest --ci --runInBand" }, "dependencies": { + "@anthropic-ai/sdk": "^0.57.0", "@date-fns/tz": "^1.2.0", "@docsearch/css": "^3.5.2", "@docsearch/react": "^3.5.2", diff --git a/packages/web-console/scripts/build-docs-ts.js b/packages/web-console/scripts/build-docs-ts.js new file mode 100644 index 000000000..9429d4351 --- /dev/null +++ b/packages/web-console/scripts/build-docs-ts.js @@ -0,0 +1,192 @@ +const fs = require('fs') +const path = require('path') + +// Base paths +const DOCS_BASE_PATH = path.join(__dirname, '../../../../documentation/documentation/reference') +const OUTPUT_PATH = path.join(__dirname, '../src/utils/questdb-docs-data') + +// Categories and their paths +const DOCS_CATEGORIES = { + functions: path.join(DOCS_BASE_PATH, 'function'), + operators: path.join(DOCS_BASE_PATH, 'operators'), + sql: path.join(DOCS_BASE_PATH, 'sql') +} + +/** + * Strips YAML frontmatter from markdown content + */ +function stripFrontmatter(content) { + const frontmatterRegex = /^---\s*\n[\s\S]*?\n---\s*\n/ + return content.replace(frontmatterRegex, '') +} + +/** + * Extracts headers (function/operator/keyword names) from markdown content + */ +function extractHeaders(content) { + const headers = [] + const lines = content.split('\n') + + for (const line of lines) { + // Match ## headers (main function/operator names) + const match = line.match(/^##\s+(.+)$/) + if (match && !match[1].includes('Overview') && !match[1].includes('Example')) { + headers.push(match[1].trim()) + } + } + + return headers +} + +/** + * Extract title from frontmatter + */ +function extractTitle(content) { + const match = content.match(/^---\s*\n[\s\S]*?title:\s*(.+)\n[\s\S]*?\n---/) + return match ? match[1].trim() : null +} + +/** + * Process a single markdown file + */ +function processMarkdownFile(filePath, category) { + const content = fs.readFileSync(filePath, 'utf-8') + const title = extractTitle(content) + const cleanContent = stripFrontmatter(content) + const headers = extractHeaders(cleanContent) + const relativePath = path.relative(DOCS_BASE_PATH, filePath) + + return { + path: relativePath, + title: title || path.basename(filePath, '.md'), + headers: headers, + content: cleanContent + } +} + +/** + * Recursively process all markdown files in a directory + */ +function processDirectory(dirPath, category) { + const results = [] + + try { + const items = fs.readdirSync(dirPath) + + for (const item of items) { + const fullPath = path.join(dirPath, item) + const stat = fs.statSync(fullPath) + + if (stat.isDirectory()) { + results.push(...processDirectory(fullPath, category)) + } else if (item.endsWith('.md')) { + results.push(processMarkdownFile(fullPath, category)) + } + } + } catch (error) { + console.error(`Error processing directory ${dirPath}:`, error) + } + + return results +} + +/** + * Escape string for TypeScript + */ +function escapeForTS(str) { + return str + .replace(/\\/g, '\\\\') + .replace(/`/g, '\\`') + .replace(/\${/g, '\\${') +} + +/** + * Main build function + */ +function buildDocs() { + // Create output directory if it doesn't exist + if (!fs.existsSync(OUTPUT_PATH)) { + fs.mkdirSync(OUTPUT_PATH, { recursive: true }) + } + + const tocList = {} + + // Process each category + Object.entries(DOCS_CATEGORIES).forEach(([category, categoryPath]) => { + console.log(`Building ${category} documentation...`) + + if (!fs.existsSync(categoryPath)) { + console.error(`Documentation directory not found: ${categoryPath}`) + return + } + + const files = processDirectory(categoryPath, category) + + // Extract unique items for TOC + const items = new Set() + files.forEach(file => { + // Add document title for top-level entries (readable, no underscores) + items.add(file.title) + + // Add headers (prefixed with document title for context) + file.headers.forEach(header => { + items.add(`${file.title} - ${header}`) + }) + }) + + tocList[category] = Array.from(items).sort() + + // Generate TypeScript file for this category + const tsContent = `// Auto-generated documentation data for ${category} +// Generated on ${new Date().toISOString()} + +export interface DocFile { + path: string + title: string + headers: string[] + content: string +} + +export const ${category}Docs: DocFile[] = [ +${files.map(file => ` { + path: ${JSON.stringify(file.path)}, + title: ${JSON.stringify(file.title)}, + headers: [${file.headers.map(h => JSON.stringify(h)).join(', ')}], + content: \`${escapeForTS(file.content)}\` + }`).join(',\n')} +] +` + + const outputFile = path.join(OUTPUT_PATH, `${category}-docs.ts`) + fs.writeFileSync(outputFile, tsContent, 'utf-8') + console.log(`✓ Created ${outputFile}`) + }) + + // Generate TOC list file + const tocContent = `// Auto-generated table of contents +// Generated on ${new Date().toISOString()} + +export const questdbTocList = ${JSON.stringify(tocList, null, 2)} +` + + const tocFile = path.join(OUTPUT_PATH, 'toc-list.ts') + fs.writeFileSync(tocFile, tocContent, 'utf-8') + console.log(`✓ Created ${tocFile}`) + + // Generate index file + const indexContent = `// Auto-generated index file +export { functionsDocs } from './functions-docs' +export { operatorsDocs } from './operators-docs' +export { sqlDocs } from './sql-docs' +export { questdbTocList } from './toc-list' +` + + const indexFile = path.join(OUTPUT_PATH, 'index.ts') + fs.writeFileSync(indexFile, indexContent, 'utf-8') + console.log(`✓ Created ${indexFile}`) + + console.log('\nDocumentation build complete!') +} + +// Run the build +buildDocs() \ No newline at end of file diff --git a/packages/web-console/src/components/ExplainQueryButton/index.tsx b/packages/web-console/src/components/ExplainQueryButton/index.tsx new file mode 100644 index 000000000..92148ef4e --- /dev/null +++ b/packages/web-console/src/components/ExplainQueryButton/index.tsx @@ -0,0 +1,185 @@ +import React, { useContext, useEffect, useRef, useCallback } from "react" +import styled, { css } from "styled-components" +import { Button, Box } from "@questdb/react-components" +import { platform } from "../../utils" +import { useSelector } from "react-redux" +import { useLocalStorage } from "../../providers/LocalStorageProvider" +import { useEditor } from "../../providers/EditorProvider" +import type { ClaudeAPIError, ClaudeExplanation } from "../../utils/claude" +import { explainQuery, formatExplanationAsComment, createSchemaClient, isClaudeError } from "../../utils/claude" +import { toast } from "../Toast" +import { QuestContext } from "../../providers" +import { selectors } from "../../store" +import { eventBus } from "../../modules/EventBus" +import { EventType } from "../../modules/EventBus/types" +import { RunningType } from "../../store/Query/types" +import { useAIStatus, isBlockingAIStatus } from "../../providers/AIStatusProvider" + +const Key = styled(Box).attrs({ alignItems: "center" })` + padding: 0 0.4rem; + background: ${({ theme }) => theme.color.selectionDarker}; + border-radius: 0.2rem; + font-size: 1.2rem; + height: 1.8rem; + color: inherit; + + &:not(:last-child) { + margin-right: 0.25rem; + } +` + +const KeyBinding = styled(Box).attrs({ alignItems: "center", gap: "0" })<{ $disabled: boolean }>` + margin-left: 1rem; + ${({ $disabled, theme }) => $disabled && css` + color: ${theme.color.gray1}; + `} +` + +type Props = { + onBufferContentChange?: (value?: string) => void +} + +const ctrlCmd = platform.isMacintosh || platform.isIOS ? "⌘" : "Ctrl" + +const shortcutTitle = platform.isMacintosh || platform.isIOS ? "Cmd+E" : "Ctrl+E" + +export const ExplainQueryButton = ({ onBufferContentChange }: Props) => { + const { aiAssistantSettings } = useLocalStorage() + const { quest } = useContext(QuestContext) + const { editorRef } = useEditor() + const tables = useSelector(selectors.query.getTables) + const running = useSelector(selectors.query.getRunning) + const queriesToRun = useSelector(selectors.query.getQueriesToRun) + const { status: aiStatus, setStatus, abortController } = useAIStatus() + const highlightDecorationsRef = useRef([]) + const disabled = running !== RunningType.NONE || queriesToRun.length !== 1 || isBlockingAIStatus(aiStatus) + const isSelection = queriesToRun.length === 1 && queriesToRun[0].selection + + const handleExplainQuery = useCallback(async () => { + if (!editorRef.current || disabled) return + const model = editorRef.current.getModel() + if (!model) return + + editorRef.current?.updateOptions({ + readOnly: true, + readOnlyMessage: { + value: "Query explanation in progress", + } + }) + const schemaClient = aiAssistantSettings.grantSchemaAccess ? createSchemaClient(tables, quest) : undefined + const response = await explainQuery({ + query: queriesToRun[0], + settings: aiAssistantSettings, + schemaClient, + setStatus, + abortSignal: abortController?.signal + }) + + if (isClaudeError(response)) { + const error = response as ClaudeAPIError + if (error.type !== 'aborted') { + toast.error(error.message, { autoClose: 10000 }) + } + editorRef.current?.updateOptions({ + readOnly: false, + readOnlyMessage: undefined + }) + return + } + + const result = response as ClaudeExplanation + if (!result.explanation) { + toast.error("No explanation received from Anthropic API", { autoClose: 10000 }) + editorRef.current?.updateOptions({ + readOnly: false, + readOnlyMessage: undefined + }) + return + } + + const commentBlock = formatExplanationAsComment(result.explanation) + const isSelection = !!queriesToRun[0].selection + + const queryStartLine = isSelection + ? model.getPositionAt(queriesToRun[0].selection!.startOffset).lineNumber + : queriesToRun[0].row + 1 + + const insertText = commentBlock + "\n" + const explanationEndLine = queryStartLine + insertText.split("\n").length - 1 + + editorRef.current?.updateOptions({ + readOnly: false, + readOnlyMessage: undefined + }) + editorRef.current.executeEdits("explain-query", [{ + range: { + startLineNumber: queryStartLine, + startColumn: 1, + endLineNumber: queryStartLine, + endColumn: 1 + }, + text: insertText + }]) + + if (onBufferContentChange) { + onBufferContentChange(editorRef.current.getValue()) + } + editorRef.current.revealPositionNearTop({ lineNumber: queryStartLine, column: 1 }) + editorRef.current.setPosition({ lineNumber: queryStartLine, column: 1 }) + highlightDecorationsRef.current = editorRef.current.getModel()?.deltaDecorations(highlightDecorationsRef.current, [{ + range: { + startLineNumber: queryStartLine, + startColumn: 1, + endLineNumber: explanationEndLine, + endColumn: 1 + }, + options: { + className: "aiQueryHighlight", + isWholeLine: false + } + }]) ?? [] + setTimeout(() => { + highlightDecorationsRef.current = editorRef.current?.getModel()?.deltaDecorations(highlightDecorationsRef.current, []) ?? [] + }, 1000) + + toast.success("Query explanation added!") + }, [disabled, onBufferContentChange, queriesToRun, aiAssistantSettings, tables, quest, setStatus, abortController]) + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (!((e.metaKey || e.ctrlKey) && (e.key === 'e' || e.key === 'E'))) { + return + } + e.preventDefault() + handleExplainQuery() + }, [handleExplainQuery]) + + useEffect(() => { + eventBus.subscribe(EventType.EXPLAIN_QUERY_EXEC, handleExplainQuery) + document.addEventListener('keydown', handleKeyDown) + + return () => { + eventBus.unsubscribe(EventType.EXPLAIN_QUERY_EXEC, handleExplainQuery) + document.removeEventListener('keydown', handleKeyDown) + } + }, [handleExplainQuery]) + + if (!aiAssistantSettings.apiKey) { + return null + } + + return ( + + ) +} \ No newline at end of file diff --git a/packages/web-console/src/components/GenerateSQLButton/index.tsx b/packages/web-console/src/components/GenerateSQLButton/index.tsx new file mode 100644 index 000000000..5bdb1782d --- /dev/null +++ b/packages/web-console/src/components/GenerateSQLButton/index.tsx @@ -0,0 +1,286 @@ +import React, { useCallback, useState, useContext, useEffect, useRef } from "react" +import styled, { css } from "styled-components" +import { Button, Box, Dialog, ForwardRef, Overlay } from "@questdb/react-components" +import { platform } from "../../utils" +import { useSelector } from "react-redux" +import { useLocalStorage } from "../../providers/LocalStorageProvider" +import { useEditor } from "../../providers/EditorProvider" +import type { ClaudeAPIError, GeneratedSQL } from "../../utils/claude" +import { generateSQL, formatExplanationAsComment, createSchemaClient, isClaudeError } from "../../utils/claude" +import { toast } from "../Toast" +import { QuestContext } from "../../providers" +import { selectors } from "../../store" +import { eventBus } from "../../modules/EventBus" +import { EventType } from "../../modules/EventBus/types" +import { RunningType } from "../../store/Query/types" +import { useAIStatus, isBlockingAIStatus } from "../../providers/AIStatusProvider" + +const Key = styled(Box).attrs({ alignItems: "center" })` + padding: 0 0.4rem; + background: ${({ theme }) => theme.color.selectionDarker}; + border-radius: 0.2rem; + font-size: 1.2rem; + height: 1.8rem; + color: inherit; + + &:not(:last-child) { + margin-right: 0.25rem; + } +` + +const KeyBinding = styled(Box).attrs({ alignItems: "center", gap: "0" })<{ $disabled: boolean }>` + margin-left: 1rem; + ${({ $disabled, theme }) => $disabled && css` + color: ${theme.color.gray1}; + `} +` + +const StyledDialogDescription = styled(Dialog.Description)` + font-size: 1.4rem; + color: ${({ theme }) => theme.color.gray2}; + line-height: 1.5; + margin-bottom: 2rem; +` + +const StyledDialogButton = styled(Button)` + padding: 1.2rem 1.6rem; + font-size: 1.4rem; + + &:focus { + outline: 1px solid ${({ theme }) => theme.color.foreground}; + } +` + + +const StyledTextArea = styled.textarea` + width: 100%; + min-height: 120px; + padding: 1rem; + background: ${({ theme }) => theme.color.backgroundDarker}; + border: 1px solid ${({ theme }) => theme.color.gray1}; + border-radius: 0.4rem; + color: ${({ theme }) => theme.color.foreground}; + font-size: 1.4rem; + resize: vertical; + outline: none; + margin-bottom: 2rem; + + &:focus { + border-color: ${({ theme }) => theme.color.pink}; + } + + &::placeholder { + color: ${({ theme }) => theme.color.gray2}; + } +` + + +type Props = { + onBufferContentChange?: (value?: string) => void +} + +const ctrlCmd = platform.isMacintosh || platform.isIOS ? "⌘" : "Ctrl" +const shortcutTitle = platform.isMacintosh || platform.isIOS ? "Cmd+G" : "Ctrl+G" + +export const GenerateSQLButton = ({ onBufferContentChange }: Props) => { + const { aiAssistantSettings } = useLocalStorage() + const { quest } = useContext(QuestContext) + const { editorRef } = useEditor() + const tables = useSelector(selectors.query.getTables) + const running = useSelector(selectors.query.getRunning) + const { status: aiStatus, setStatus, abortController } = useAIStatus() + const [showDialog, setShowDialog] = useState(false) + const [description, setDescription] = useState("") + const highlightDecorationsRef = useRef([]) + const disabled = running !== RunningType.NONE || !editorRef.current || isBlockingAIStatus(aiStatus) + + const handleGenerate = async () => { + setShowDialog(false) + setDescription("") + + const schemaClient = aiAssistantSettings.grantSchemaAccess ? createSchemaClient(tables, quest) : undefined + const response = await generateSQL({ + description, + settings: aiAssistantSettings, + schemaClient, + setStatus, + abortSignal: abortController?.signal + }) + + if (isClaudeError(response)) { + const error = response as ClaudeAPIError + if (error.type !== 'aborted') { + toast.error(error.message, { autoClose: 10000 }) + } + return + } + + const result = response as GeneratedSQL + if (!result.sql) { + toast.error("No query received from Anthropic API", { autoClose: 10000 }) + return + } + + if (editorRef.current) { + const model = editorRef.current.getModel() + if (!model) return + + const commentBlock = formatExplanationAsComment(`${description}\nExplanation:\n${result.explanation}`, `Prompt`) + const sqlWithComment = `\n${commentBlock}\n${result.sql}\n` + + const lineNumber = model.getLineCount() + const column = model.getLineMaxColumn(lineNumber) + + editorRef.current.executeEdits("generate-sql", [{ + range: { + startLineNumber: lineNumber, + startColumn: column, + endLineNumber: lineNumber, + endColumn: column + }, + text: sqlWithComment + }]) + + if (onBufferContentChange) { + onBufferContentChange(editorRef.current.getValue()) + } + + editorRef.current.revealLineNearTop(lineNumber) + highlightDecorationsRef.current = editorRef.current.getModel()?.deltaDecorations(highlightDecorationsRef.current, [{ + range: { + startLineNumber: lineNumber, + startColumn: column, + endLineNumber: lineNumber + sqlWithComment.split("\n").length - 1, + endColumn: column + }, + options: { + className: "aiQueryHighlight", + isWholeLine: false + } + }]) ?? [] + setTimeout(() => { + highlightDecorationsRef.current = editorRef.current?.getModel()?.deltaDecorations(highlightDecorationsRef.current, []) ?? [] + }, 1000) + editorRef.current.setPosition({ lineNumber: lineNumber + 1, column: 1 }) + editorRef.current.focus() + } + + toast.success("Query generated!") + } + + const handleOpenDialog = useCallback(() => { + setShowDialog(true) + setDescription("") + }, []) + + const handleCloseDialog = useCallback(() => { + setShowDialog(false) + setDescription("") + }, []) + + const handleGenerateQueryOpen = useCallback((e?: KeyboardEvent) => { + if (e instanceof KeyboardEvent) { + if (!((e.metaKey || e.ctrlKey) && (e.key === 'g' || e.key === 'G'))) { + return + } + e.preventDefault() + } + if (!disabled && aiAssistantSettings.apiKey) { + handleOpenDialog() + } + }, [disabled, aiAssistantSettings.apiKey]) + + useEffect(() => { + document.addEventListener('keydown', handleGenerateQueryOpen) + return () => { + document.removeEventListener('keydown', handleGenerateQueryOpen) + } + }, [handleGenerateQueryOpen]) + + useEffect(() => { + eventBus.subscribe(EventType.GENERATE_QUERY_OPEN, handleGenerateQueryOpen) + + return () => { + eventBus.unsubscribe(EventType.GENERATE_QUERY_OPEN, handleGenerateQueryOpen) + } + }, [handleGenerateQueryOpen]) + + if (!aiAssistantSettings.apiKey) { + return null + } + + return ( + <> + + + !open && handleCloseDialog()}> + + + + + + + Generate query + + +

+ Describe what data you want to query in natural language, and I'll generate the query for you. + For example: "Show me the average price by symbol for the last hour" +

+ + setDescription(e.target.value)} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter' && e.ctrlKey) { + e.preventDefault() + handleGenerate() + } + }} + /> +
+ + + + + Cancel + + + + + Generate + + +
+
+
+ + ) +} \ No newline at end of file diff --git a/packages/web-console/src/components/SetupAIAssistant/index.tsx b/packages/web-console/src/components/SetupAIAssistant/index.tsx new file mode 100644 index 000000000..968096dde --- /dev/null +++ b/packages/web-console/src/components/SetupAIAssistant/index.tsx @@ -0,0 +1,380 @@ +import React, { useState, useRef } from "react" +import styled from "styled-components" +import { Box, Button, Input, Loader, Select, Checkbox } from "@questdb/react-components" +import { Eye, EyeOff } from "@styled-icons/remix-line" +import { InfoCircle } from "@styled-icons/boxicons-regular" +import { AutoAwesome } from "@styled-icons/material" +import { Tooltip } from "../Tooltip" +import { Text } from "../Text" +import { PopperToggle } from "../PopperToggle" +import { toast } from "../Toast" +import { useLocalStorage, DEFAULT_AI_ASSISTANT_SETTINGS } from "../../providers/LocalStorageProvider" +import { testApiKey } from "../../utils/claude" +import { PopperHover } from "../PopperHover" +import { StoreKey } from "../../utils/localStorage/types" + +const Wrapper = styled.div` + margin-top: 0.5rem; + background: ${({ theme }) => theme.color.backgroundDarker}; + border: 1px solid ${({ theme }) => theme.color.gray1}; + border-radius: 0.4rem; + padding: 2rem; + width: 42rem; +` + +const StyledForm = styled.form` + display: flex; + flex-direction: column; + gap: 2rem; +` + +const FormGroup = styled(Box).attrs({ flexDirection: "column", gap: "0.8rem" })` + width: 100%; + align-items: flex-start; +` + +const InputWrapper = styled.div` + position: relative; + width: 100%; +` + +const StyledInput = styled(Input)<{ $hasError?: boolean }>` + width: 100%; + background: ${({ theme }) => theme.color.selection}; + border: 1px solid ${({ theme, $hasError }) => $hasError ? theme.color.red : theme.color.gray1}; + border-radius: 0.4rem; + color: ${({ theme }) => theme.color.foreground}; + + &::placeholder { + color: ${({ theme }) => theme.color.gray2}; + font-family: inherit; + } + + &:disabled { + background: ${({ theme }) => theme.color.selection}; + color: ${({ theme }) => theme.color.foreground}; + cursor: default; + } +` + +const ActionButton = styled(Button)` + position: absolute; + top: 50%; + height: 3rem; + transform: translateY(-50%); + padding: 0 0.75rem; + right: 0.1rem; +` + +const FormLabel = styled.label` + font-size: 1.6rem; + font-weight: 600; +` + +const ErrorText = styled(Text)` + color: ${({ theme }) => theme.color.red}; + font-size: 1.3rem; +` + + +const Buttons = styled(Box)` + gap: 1rem; + justify-content: space-between; + align-items: center; +` + +const ButtonGroup = styled(Box)` + gap: 1rem; +` + +const StyledButton = styled(Button)` + font-size: 1.4rem; + &:focus-visible { + outline: 2px solid ${({ theme }) => theme.color.cyan}; + } +` + + +const SettingsButton = styled(Button)` + padding: 0.6rem; + + &:focus-visible { + outline: 2px solid ${({ theme }) => theme.color.cyan}; + } +` + +const StyledSelect = styled(Select)` + width: 100%; + height: 3.2rem; + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + } +` + +const StyledCheckbox = styled(Checkbox)` + font-size: 1.4rem; + display: inline; + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + } +` + +const CheckboxLabel = styled.label` + display: inline; + color: ${({ theme }) => theme.color.foreground}; + font-size: 1.2rem; + cursor: pointer; + user-select: none; +` + +const StyledInfoCircle = styled(InfoCircle)` + margin-left: 0.3rem; +` + +const DisclaimerText = styled(Text)` + font-size: 1.2rem; + line-height: 1.5; +` + +const MODEL_OPTIONS = [ + { label: "Claude Opus 4.1", value: "claude-opus-4-1" }, + { label: "Claude Opus 4.0", value: "claude-opus-4-0" }, + { label: "Claude Sonnet 4.0", value: "claude-sonnet-4-0" }, + { label: "Claude 3.7 Sonnet (Latest)", value: "claude-3-7-sonnet-latest" }, + { label: "Claude 3.5 Haiku (Latest)", value: "claude-3-5-haiku-latest" }, + { label: "Claude 3.5 Sonnet (Latest)", value: "claude-3-5-sonnet-latest" }, +] + +export const SetupAIAssistant = () => { + const { aiAssistantSettings, updateSettings } = useLocalStorage() + const [active, setActive] = useState(false) + const [showApiKey, setShowApiKey] = useState(false) + const [inputValue, setInputValue] = useState(aiAssistantSettings.apiKey || "") + const [selectedModel, setSelectedModel] = useState(aiAssistantSettings.model || DEFAULT_AI_ASSISTANT_SETTINGS.model) + const [grantSchemaAccess, setGrantSchemaAccess] = useState(aiAssistantSettings.grantSchemaAccess !== false) + const [isValidating, setIsValidating] = useState(false) + const [error, setError] = useState(null) + const inputRef = useRef(null) + const dirty = inputValue !== aiAssistantSettings.apiKey || selectedModel !== aiAssistantSettings.model || grantSchemaAccess !== aiAssistantSettings.grantSchemaAccess + + const handleToggle = (newActive: boolean) => { + if (newActive) { + setTimeout(() => { + inputRef.current?.focus() + inputRef.current?.select() + }, 50) + } + setActive(newActive) + if (!newActive) { + setShowApiKey(false) + setError(null) + } + } + + const validateAndSaveKey = async (key: string) => { + if (!key) { + setError("Please enter an API key") + return false + } + + setIsValidating(true) + setError(null) + + try { + const result = await testApiKey(key, selectedModel) + if (result.valid) { + const newSettings = { + apiKey: key, + model: selectedModel, + grantSchemaAccess: grantSchemaAccess + } + updateSettings(StoreKey.AI_ASSISTANT_SETTINGS, newSettings) + toast.success("Settings saved successfully") + return true + } else { + setError(result.error || "Invalid API key") + return false + } + } catch (err) { + setError("Failed to validate API key") + return false + } finally { + setIsValidating(false) + } + } + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault() + const result = await validateAndSaveKey(inputValue) + if (result) { + handleToggle(false) + } + } + + const handleCancel = () => { + setInputValue(aiAssistantSettings.apiKey) + setSelectedModel(aiAssistantSettings.model) + setGrantSchemaAccess(aiAssistantSettings.grantSchemaAccess) + setError(null) + handleToggle(false) + } + + const handleClearAllSettings = () => { + updateSettings(StoreKey.AI_ASSISTANT_SETTINGS, DEFAULT_AI_ASSISTANT_SETTINGS) + setInputValue(DEFAULT_AI_ASSISTANT_SETTINGS.apiKey) + setSelectedModel(DEFAULT_AI_ASSISTANT_SETTINGS.model) + setGrantSchemaAccess(DEFAULT_AI_ASSISTANT_SETTINGS.grantSchemaAccess) + setError(null) + toast.success("All settings cleared") + } + + return ( + } + data-hook="anthropic-api-settings-button" + title="Anthropic API Settings" + > + Set up AI Assistant + + } + placement="bottom-start" + > + + + + + Anthropic API Key + + + + { + setInputValue(e.target.value) + setError(null) + }} + $hasError={!!error} + /> + setShowApiKey(!showApiKey)} + data-hook="anthropic-api-key-toggle" + > + {showApiKey ? : } + + + {error && {error}} + + + Enter your Anthropic API key to enable AI Assistant. + Get your API key from{" "} + + Anthropic Console + . + Your key is stored locally in your browser and never sent to QuestDB servers. + + + + + + Model + + setSelectedModel(e.target.value)} + options={MODEL_OPTIONS} + /> + + + + + setGrantSchemaAccess(e.target.checked)} + /> + + } + > + + When enabled, the AI assistant can access your database schema information to provide more accurate suggestions and explanations. Schema information helps the AI understand your table structures, column names, and relationships. + + + + Grant schema access + + + + + + + + This AI assistant may occasionally produce inaccurate information. Please verify important details and review all generated queries before execution. + + + + + {aiAssistantSettings.apiKey && ( + + Clear all settings + + )} + + + + Cancel + + : undefined} + data-hook="anthropic-api-save-button" + > + {isValidating ? "Validating..." : "Save"} + + + + + + + ) +} diff --git a/packages/web-console/src/components/TopBar/toolbar.tsx b/packages/web-console/src/components/TopBar/toolbar.tsx index 0f4c4e68d..2674de950 100644 --- a/packages/web-console/src/components/TopBar/toolbar.tsx +++ b/packages/web-console/src/components/TopBar/toolbar.tsx @@ -7,7 +7,7 @@ import { User as UserIcon, LogoutCircle, Edit } from "@styled-icons/remix-line" import { InfoCircle, Error as ErrorIcon } from "@styled-icons/boxicons-regular" import { Tools, ShieldCheck } from "@styled-icons/bootstrap" import { Flask } from "@styled-icons/boxicons-solid" -import { toast } from '../' +import { toast } from '../Toast' import { Text } from "../Text" import { selectors } from "../../store" import { useSelector } from "react-redux" diff --git a/packages/web-console/src/components/index.ts b/packages/web-console/src/components/index.ts index e8c085f35..9fab6e1b4 100644 --- a/packages/web-console/src/components/index.ts +++ b/packages/web-console/src/components/index.ts @@ -25,8 +25,11 @@ export * from "./Animation" export * from "./Button" export * from "./CenteredLayout" +export * from "./SetupAIAssistant" export * from "./Drawer" export * from "./Emoji" +export * from "./ExplainQueryButton" +export * from "./GenerateSQLButton" export * from "./Hooks" export * from "./IconWithTooltip" export * from "./Input" diff --git a/packages/web-console/src/modules/EventBus/types.ts b/packages/web-console/src/modules/EventBus/types.ts index 31325a18f..d82dbe34c 100644 --- a/packages/web-console/src/modules/EventBus/types.ts +++ b/packages/web-console/src/modules/EventBus/types.ts @@ -21,4 +21,6 @@ export enum EventType { TAB_BLUR = "tab.blur", METRICS_REFRESH_DATA = "metrics.refresh.data", BUFFERS_UPDATED = "buffers.updated", + GENERATE_QUERY_OPEN = "ai.generate.query.open", + EXPLAIN_QUERY_EXEC = "ai.explain.query.exec", } diff --git a/packages/web-console/src/modules/OAuth2/views/error.tsx b/packages/web-console/src/modules/OAuth2/views/error.tsx index 386ab5395..af2f3d5eb 100644 --- a/packages/web-console/src/modules/OAuth2/views/error.tsx +++ b/packages/web-console/src/modules/OAuth2/views/error.tsx @@ -1,5 +1,6 @@ import React from "react" -import { CenteredLayout, Text } from "../../../components" +import { CenteredLayout } from "../../../components/CenteredLayout" +import { Text } from "../../../components/Text" import { Box, Button } from "@questdb/react-components" import { User } from "@styled-icons/remix-line" diff --git a/packages/web-console/src/modules/OAuth2/views/login.tsx b/packages/web-console/src/modules/OAuth2/views/login.tsx index d0b432ecc..c5c7a561c 100644 --- a/packages/web-console/src/modules/OAuth2/views/login.tsx +++ b/packages/web-console/src/modules/OAuth2/views/login.tsx @@ -4,7 +4,7 @@ import { Button } from "@questdb/react-components" import { User } from "@styled-icons/remix-line" import { Form } from "../../../components/Form" import Joi from "joi" -import { Text } from "../../../components" +import { Text } from "../../../components/Text" import { setValue } from "../../../utils/localStorage" import { StoreKey } from "../../../utils/localStorage/types" import { useSettings } from "../../../providers" diff --git a/packages/web-console/src/providers/AIStatusProvider/index.tsx b/packages/web-console/src/providers/AIStatusProvider/index.tsx new file mode 100644 index 000000000..bb4dd7d6d --- /dev/null +++ b/packages/web-console/src/providers/AIStatusProvider/index.tsx @@ -0,0 +1,86 @@ +import React, { createContext, useCallback, useContext, useState, useRef, useEffect } from 'react' +import { useEditor } from '../EditorProvider' + +export const useAIStatus = () => { + const context = useContext(AIStatusContext) + if (!context) { + throw new Error('useAIStatus must be used within AIStatusProvider') + } + return context +} + +export const isBlockingAIStatus = (status: AIOperationStatus | null) => { + return status !== undefined && status !== null && status !== AIOperationStatus.Aborted +} + +const AIStatusContext = createContext(undefined) + +export enum AIOperationStatus { + Processing = 'Processing the request', + RetrievingTables = 'Retrieving tables', + InvestigatingTableSchema = 'Investigating table schema', + RetrievingDocumentation = 'Retrieving docs', + InvestigatingFunctions = 'Investigating functions', + InvestigatingOperators = 'Investigating operators', + InvestigatingKeywords = 'Investigating keywords', + FormattingResponse = 'Formatting response', + Aborted = 'Operation has been cancelled' +} + +export interface AIStatusContextType { + status: AIOperationStatus | null + setStatus: (status: AIOperationStatus | null) => void + abortController: AbortController | null + abortOperation: () => void +} + +interface AIStatusProviderProps { + children: React.ReactNode +} + +export const AIStatusProvider: React.FC = ({ children }) => { + const { editorRef } = useEditor() + const [status, setStatus] = useState(null) + const [abortController, setAbortController] = useState(new AbortController()) + const abortControllerRef = useRef(null) + const statusRef = useRef(null) + + const abortOperation = useCallback(() => { + if (abortControllerRef.current && statusRef.current !== null) { + abortControllerRef.current?.abort() + setAbortController(new AbortController()) + setStatus(AIOperationStatus.Aborted) + editorRef.current?.updateOptions({ + readOnly: false, + readOnlyMessage: undefined + }) + } + }, [status, editorRef]) + + useEffect(() => { + abortControllerRef.current = abortController + }, [abortController]) + + useEffect(() => { + statusRef.current = status + }, [status]) + + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + } + }, []) + + return ( + + {children} + + ) +} diff --git a/packages/web-console/src/providers/EditorProvider/index.tsx b/packages/web-console/src/providers/EditorProvider/index.tsx index f136da6a8..6146dd07b 100644 --- a/packages/web-console/src/providers/EditorProvider/index.tsx +++ b/packages/web-console/src/providers/EditorProvider/index.tsx @@ -46,7 +46,7 @@ export type EditorContext = { buffer?: Partial, options?: { shouldSelectAll?: boolean }, ) => Promise - deleteBuffer: (id: number) => Promise + deleteBuffer: (id: number, setActiveBuffer?: boolean) => Promise archiveBuffer: (id: number) => Promise deleteAllBuffers: () => Promise updateBuffer: (id: number, buffer?: Partial, setNewActiveBuffer?: boolean) => Promise @@ -318,9 +318,11 @@ export const EditorProvider = ({ children }: PropsWithChildren<{}>) => { eventBus.publish(EventType.BUFFERS_UPDATED, { type: 'archive', bufferId: id }) } - const deleteBuffer: EditorContext["deleteBuffer"] = async (id) => { + const deleteBuffer: EditorContext["deleteBuffer"] = async (id, setActiveBuffer = true) => { await bufferStore.delete(id) - await setActiveBufferOnRemoved(id) + if (setActiveBuffer) { + await setActiveBufferOnRemoved(id) + } eventBus.publish(EventType.BUFFERS_UPDATED, { type: 'delete', bufferId: id }) } diff --git a/packages/web-console/src/providers/LocalStorageProvider/index.tsx b/packages/web-console/src/providers/LocalStorageProvider/index.tsx index 7c3590b86..f79bd7106 100644 --- a/packages/web-console/src/providers/LocalStorageProvider/index.tsx +++ b/packages/web-console/src/providers/LocalStorageProvider/index.tsx @@ -32,11 +32,17 @@ import React, { import { getValue, setValue } from "../../utils/localStorage" import { StoreKey } from "../../utils/localStorage/types" import { parseInteger } from "./utils" -import { LocalConfig, SettingsType, LeftPanelState, LeftPanelType } from "./types" +import { LocalConfig, SettingsType, LeftPanelState, LeftPanelType, AiAssistantSettings } from "./types" /* eslint-disable prettier/prettier */ type Props = {} +export const DEFAULT_AI_ASSISTANT_SETTINGS = { + apiKey: "", + model: "claude-sonnet-4-0", + grantSchemaAccess: true +} + const defaultConfig: LocalConfig = { editorCol: 10, editorLine: 10, @@ -44,6 +50,7 @@ const defaultConfig: LocalConfig = { resultsSplitterBasis: 350, exampleQueriesVisited: false, autoRefreshTables: true, + aiAssistantSettings: DEFAULT_AI_ASSISTANT_SETTINGS, leftPanelState: { type: LeftPanelType.DATASOURCES, width: 350 @@ -60,6 +67,7 @@ type ContextProps = { autoRefreshTables: boolean leftPanelState: LeftPanelState updateLeftPanelState: (state: LeftPanelState) => void + aiAssistantSettings: AiAssistantSettings } const defaultValues: ContextProps = { @@ -72,6 +80,7 @@ const defaultValues: ContextProps = { autoRefreshTables: true, leftPanelState: defaultConfig.leftPanelState, updateLeftPanelState: (state: LeftPanelState) => undefined, + aiAssistantSettings: defaultConfig.aiAssistantSettings, } export const LocalStorageContext = createContext(defaultValues) @@ -122,8 +131,31 @@ export const LocalStorageProvider = ({ const [leftPanelState, setLeftPanelState] = useState(getLeftPanelState()) + const getAiAssistantSettings = (): AiAssistantSettings => { + const stored = getValue(StoreKey.AI_ASSISTANT_SETTINGS) + if (stored) { + try { + const parsed = JSON.parse(stored) as AiAssistantSettings + return { + apiKey: parsed.apiKey || "", + model: parsed.model || DEFAULT_AI_ASSISTANT_SETTINGS.model, + grantSchemaAccess: parsed.grantSchemaAccess !== undefined ? parsed.grantSchemaAccess : true + } + } catch (e) { + return defaultConfig.aiAssistantSettings + } + } + return defaultConfig.aiAssistantSettings + } + + const [aiAssistantSettings, setAiAssistantSettings] = useState(getAiAssistantSettings()) + const updateSettings = (key: StoreKey, value: SettingsType) => { - setValue(key, value.toString()) + if (key === StoreKey.AI_ASSISTANT_SETTINGS) { + setValue(key, JSON.stringify(value)) + } else { + setValue(key, value.toString()) + } refreshSettings(key) } @@ -157,6 +189,9 @@ export const LocalStorageProvider = ({ case StoreKey.AUTO_REFRESH_TABLES: setAutoRefreshTables(value === "true") break + case StoreKey.AI_ASSISTANT_SETTINGS: + setAiAssistantSettings(getAiAssistantSettings()) + break } } @@ -172,6 +207,7 @@ export const LocalStorageProvider = ({ autoRefreshTables, leftPanelState, updateLeftPanelState, + aiAssistantSettings, }} > {children} diff --git a/packages/web-console/src/providers/LocalStorageProvider/types.ts b/packages/web-console/src/providers/LocalStorageProvider/types.ts index dd1d4521b..458d9cc74 100644 --- a/packages/web-console/src/providers/LocalStorageProvider/types.ts +++ b/packages/web-console/src/providers/LocalStorageProvider/types.ts @@ -1,28 +1,10 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2022 QuestDB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - ******************************************************************************/ +export type AiAssistantSettings = { + apiKey: string + model: string + grantSchemaAccess: boolean +} -export type SettingsType = string | boolean | number +export type SettingsType = string | boolean | number | AiAssistantSettings export enum LeftPanelType { DATASOURCES = "datasources", @@ -42,4 +24,5 @@ export type LocalConfig = { exampleQueriesVisited: boolean autoRefreshTables: boolean leftPanelState: LeftPanelState + aiAssistantSettings: AiAssistantSettings } diff --git a/packages/web-console/src/providers/SettingsProvider/index.tsx b/packages/web-console/src/providers/SettingsProvider/index.tsx index 6e15fb4bf..4ee62279f 100644 --- a/packages/web-console/src/providers/SettingsProvider/index.tsx +++ b/packages/web-console/src/providers/SettingsProvider/index.tsx @@ -6,7 +6,7 @@ import React, { useState, } from "react" import { ConsoleConfig, Settings, Warning } from "./types" -import { CenteredLayout } from "../../components" +import { CenteredLayout } from "../../components/CenteredLayout" import { Box, Button, Text } from "@questdb/react-components" import { Refresh } from "@styled-icons/remix-line" import {setValue} from "../../utils/localStorage" diff --git a/packages/web-console/src/scenes/Editor/ButtonBar/FixQueryButton.tsx b/packages/web-console/src/scenes/Editor/ButtonBar/FixQueryButton.tsx new file mode 100644 index 000000000..654c92998 --- /dev/null +++ b/packages/web-console/src/scenes/Editor/ButtonBar/FixQueryButton.tsx @@ -0,0 +1,237 @@ +import React, { useContext, MutableRefObject } from "react" +import styled, { css, keyframes } from "styled-components" +import { Button } from "@questdb/react-components" +import { useSelector } from "react-redux" +import { useLocalStorage } from "../../../providers/LocalStorageProvider" +import { useEditor } from "../../../providers" +import type { ClaudeAPIError, GeneratedSQL } from "../../../utils/claude" +import { isClaudeError, createSchemaClient, fixQuery } from "../../../utils/claude" +import { toast } from "../../../components/Toast" +import { QuestContext } from "../../../providers" +import { selectors } from "../../../store" +import { RunningType } from "../../../store/Query/types" +import { formatExplanationAsComment } from "../../../utils/claude" +import { createQueryKeyFromRequest } from "../../../scenes/Editor/Monaco/utils" +import type { ExecutionRefs } from "../../../scenes/Editor" +import type { Request } from "../../../scenes/Editor/Monaco/utils" +import type { editor } from "monaco-editor" +import { isBlockingAIStatus, useAIStatus } from "../../../providers/AIStatusProvider" + +type IStandaloneCodeEditor = editor.IStandaloneCodeEditor + +const pulse = keyframes` + 0% { + box-shadow: 0 0 0 0 #50fa7b; + } + 70% { + box-shadow: 0 0 0 8px rgba(255, 85, 85, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(255, 85, 85, 0); + } +` + +const StyledFixButton = styled(Button)<{ $pulse?: boolean }>` + ${({ $pulse }) => $pulse && css` + animation: ${pulse} 1s 2; + `} +` + +const extractError = ( + queryToFix: Request, + executionRefs: React.MutableRefObject | undefined, + activeBufferId: string | number | undefined, + editorRef: MutableRefObject +): { errorMessage: string; fixStart: number; queryText: string, word: string | null } | null => { + if (!executionRefs?.current || !activeBufferId || !editorRef.current) { + return null + } + const model = editorRef.current.getModel() + if (!model) { + return null + } + + const bufferExecutions = executionRefs.current[activeBufferId as number] + if (!bufferExecutions) { + return null + } + + const queryKey = createQueryKeyFromRequest(editorRef.current, queryToFix) + const execution = bufferExecutions[queryKey] + + if (!execution || !execution.error) { + return null + } + const fixStart = execution.selection + ? execution.selection.startOffset + : execution.startOffset + + const startPosition = model.getPositionAt(fixStart) + const errorWordPosition = model.getPositionAt(fixStart + execution.error.position) + const errorWord = model.getWordAtPosition(errorWordPosition) + const endPosition = model.getPositionAt(execution.selection?.endOffset ?? execution.startOffset) + const queryText = execution.selection + ? model.getValueInRange({ + startLineNumber: startPosition.lineNumber, + startColumn: startPosition.column, + endLineNumber: endPosition.lineNumber, + endColumn: endPosition.column, + }) + : queryToFix.query + + return { + errorMessage: execution.error.error || "Query execution failed", + word: errorWord ? errorWord.word : null, + fixStart, + queryText, + } +} + +type Props = { + executionRefs?: React.MutableRefObject + onBufferContentChange?: (value?: string) => void +} + +export const FixQueryButton = ({ executionRefs, onBufferContentChange }: Props) => { + const { aiAssistantSettings } = useLocalStorage() + const { quest } = useContext(QuestContext) + const { editorRef, activeBuffer, addBuffer } = useEditor() + const tables = useSelector(selectors.query.getTables) + const running = useSelector(selectors.query.getRunning) + const queriesToRun = useSelector(selectors.query.getQueriesToRun) + const { status: aiStatus, setStatus, abortController } = useAIStatus() + + if (!aiAssistantSettings.apiKey) { + return null + } + + const handleFixQuery = async () => { + if (!editorRef.current || queriesToRun.length !== 1) return + const model = editorRef.current.getModel() + if (!model) return + + const queryToFix = queriesToRun[0] + const errorInfo = extractError(queryToFix, executionRefs, activeBuffer.id, editorRef) + if (!errorInfo) { + toast.error("Unable to retrieve error information from the editor", { autoClose: 10000 }) + return + } + const { errorMessage, fixStart, queryText, word } = errorInfo + const fixStartPosition = model.getPositionAt(fixStart) + editorRef.current?.updateOptions({ + readOnly: true, + readOnlyMessage: { + value: "Query fix in progress", + } + }) + const schemaClient = aiAssistantSettings.grantSchemaAccess ? createSchemaClient(tables, quest) : undefined + + const response = await fixQuery({ + query: queryText, + errorMessage: errorMessage, + settings: aiAssistantSettings, + schemaClient: schemaClient, + setStatus, + abortSignal: abortController?.signal, + word, + }) + + if (isClaudeError(response)) { + const error = response as ClaudeAPIError + if (error.type !== 'aborted') { + toast.error(error.message, { autoClose: 10000 }) + } + editorRef.current?.updateOptions({ + readOnly: false, + readOnlyMessage: undefined + }) + return + } + + const result = response as GeneratedSQL + + if (!result.sql && result.explanation) { + const commentBlock = formatExplanationAsComment(result.explanation, "AI Error Explanation") + const insertText = commentBlock + "\n" + + editorRef.current?.updateOptions({ + readOnly: false, + readOnlyMessage: undefined + }) + editorRef.current.executeEdits("fix-query-explanation", [{ + range: { + startLineNumber: fixStartPosition.lineNumber, + startColumn: 1, + endLineNumber: fixStartPosition.lineNumber, + endColumn: 1 + }, + text: insertText + }]) + + if (onBufferContentChange) { + onBufferContentChange(editorRef.current.getValue()) + } + + editorRef.current.revealPositionNearTop(fixStartPosition) + editorRef.current.setPosition(fixStartPosition) + + const explanationEndLine = fixStartPosition.lineNumber + insertText.split("\n").length - 1 + const highlightDecorations = editorRef.current.getModel()?.deltaDecorations([], [{ + range: { + startLineNumber: fixStartPosition.lineNumber, + startColumn: 1, + endLineNumber: explanationEndLine, + endColumn: 1 + }, + options: { + className: "aiQueryHighlight", + isWholeLine: false + } + }]) ?? [] + + setTimeout(() => { + editorRef.current?.getModel()?.deltaDecorations(highlightDecorations, []) + }, 1000) + + toast.success("Error explanation added!") + return + } + + editorRef.current?.updateOptions({ + readOnly: false, + readOnlyMessage: undefined + }) + + if (!result.sql) { + toast.error("No fixed query or explanation received from Anthropic API", { autoClose: 10000 }) + return + } + + await addBuffer({ + label: `${activeBuffer.label} (Fix Preview)`, + value: "", + isDiffBuffer: true, + originalBufferId: activeBuffer.id, + diffContent: { + original: queryText, + modified: result.sql, + explanation: result.explanation || "AI suggested fix for the SQL query", + queryStartOffset: fixStart, + originalQuery: queryText + } + }) + } + + return ( + + Fix query with AI + + ) +} \ No newline at end of file diff --git a/packages/web-console/src/scenes/Editor/ButtonBar/index.tsx b/packages/web-console/src/scenes/Editor/ButtonBar/index.tsx index 056d7603d..3944f204d 100644 --- a/packages/web-console/src/scenes/Editor/ButtonBar/index.tsx +++ b/packages/web-console/src/scenes/Editor/ButtonBar/index.tsx @@ -1,29 +1,98 @@ -import React, { useCallback, useState, useEffect } from "react" -import styled from "styled-components" +import React, { useCallback, useState, useEffect, useRef } from "react" +import styled, { css } from "styled-components" import { useDispatch, useSelector } from "react-redux" -import { Stop } from "@styled-icons/remix-line" +import { CloseOutline } from "@styled-icons/evaicons-outline" +import { Stop, Loader3 } from "@styled-icons/remix-line" import { CornerDownLeft } from "@styled-icons/evaicons-solid" import { ChevronDown } from "@styled-icons/boxicons-solid" -import { PopperToggle } from "../../../components" +import { PopperToggle, spinAnimation } from "../../../components" +import { ExplainQueryButton } from "../../../components/ExplainQueryButton" +import { GenerateSQLButton } from "../../../components/GenerateSQLButton" +import { FixQueryButton } from "./FixQueryButton" import { Box, Button } from "@questdb/react-components" import { actions, selectors } from "../../../store" import { platform, color } from "../../../utils" import { RunningType } from "../../../store/Query/types" +import { useLocalStorage } from "../../../providers/LocalStorageProvider" +import { useAIStatus, AIOperationStatus, isBlockingAIStatus } from "../../../providers/AIStatusProvider" +import type { ExecutionRefs } from "../../../scenes/Editor" -const ButtonBarWrapper = styled.div<{ $searchWidgetType: "find" | "replace" | null }>` - position: absolute; - top: ${({ $searchWidgetType }) => $searchWidgetType === "replace" ? '8.2rem' : $searchWidgetType === "find" ? '5.3rem' : '1rem'}; - right: 2.4rem; - z-index: 1; - transition: top .1s linear; +type ButtonBarProps = { + onTriggerRunScript: (runAll?: boolean) => void + isTemporary: boolean | undefined + executionRefs?: React.MutableRefObject + onBufferContentChange?: (value?: string) => void +} + +const ButtonBarWrapper = styled.div<{ $searchWidgetType: "find" | "replace" | null, $aiAssistantEnabled: boolean }>` + ${({ $aiAssistantEnabled, $searchWidgetType }) => !$aiAssistantEnabled ? css` + position: absolute; + top: ${$searchWidgetType === "replace" ? '8.2rem' : $searchWidgetType === "find" ? '5.3rem' : '1rem'}; + right: 2.4rem; + z-index: 1; + transition: top .1s linear; + display: flex; + gap: 1rem; + align-items: center; + ` : css` + padding: 1rem 0; + display: flex; + gap: 1rem; + align-items: center; + margin: 0 2.4rem; + `} +` + +const StatusIndicator = styled.div<{ $aborted: boolean, $loading: boolean }>` + display: flex; + align-items: center; + gap: 0.5rem; + color: ${color("gray2")}; + ${({ $aborted }) => $aborted && css` + color: ${color("red")}; + `} + + ${({ $loading }) => $loading && css` + @keyframes slide { + 0% { + background-position: 200% center; + } + 100% { + background-position: -200% center; + } + } + + background: linear-gradient( + 90deg, + ${color("gray2")} 0%, + ${color("gray2")} 40%, + ${color("white")} 50%, + ${color("gray2")} 60%, + ${color("gray2")} 100% + ); + background-size: 200% auto; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + text-fill-color: transparent; + animation: slide 3s linear infinite; + `} +`; + +const StatusLoader = styled(Loader3)` + width: 2rem; + color: ${color("pink")}; + ${spinAnimation}; ` const ButtonGroup = styled.div` display: flex; gap: 0; + margin-left: auto; ` const SuccessButton = styled(Button)` + margin-left: auto; background-color: ${color("greenDarker")}; border-color: ${color("greenDarker")}; color: ${color("foreground")}; @@ -31,7 +100,7 @@ const SuccessButton = styled(Button)` &:hover:not(:disabled) { background-color: ${color("green")}; border-color: ${color("green")}; - color: ${color("gray1")}; + color: ${color("selectionDarker")}; } &:disabled { @@ -55,6 +124,7 @@ const SuccessButton = styled(Button)` ` const StopButton = styled(Button)` + margin-left: auto; background-color: ${color("red")}; border-color: ${color("red")}; color: ${color("foreground")}; @@ -120,7 +190,7 @@ const DropdownMenu = styled.div` const Key = styled(Box).attrs({ alignItems: "center" })` padding: 0 0.4rem; - background: ${color("gray1")}; + background: ${({ theme }) => theme.color.selectionDarker}; border-radius: 0.2rem; font-size: 1.2rem; height: 1.8rem; @@ -141,19 +211,26 @@ const RunShortcut = styled(Box).attrs({ alignItems: "center", gap: "0" })` const ctrlCmd = platform.isMacintosh || platform.isIOS ? "⌘" : "Ctrl" const shortcutTitles = platform.isMacintosh || platform.isIOS ? { - [RunningType.QUERY]: "Cmd+Enter", - [RunningType.SCRIPT]: "Cmd+Shift+Enter", + [RunningType.QUERY]: "Run query (Cmd+Enter)", + [RunningType.SCRIPT]: "Run all queries (Cmd+Shift+Enter)", } : { - [RunningType.QUERY]: "Ctrl+Enter", - [RunningType.SCRIPT]: "Ctrl+Shift+Enter", + [RunningType.QUERY]: "Run query (Ctrl+Enter)", + [RunningType.SCRIPT]: "Run all queries (Ctrl+Shift+Enter)", } -const ButtonBar = ({ onTriggerRunScript, isTemporary }: { onTriggerRunScript: (runAll?: boolean) => void, isTemporary: boolean | undefined }) => { +const ButtonBar = ({ onTriggerRunScript, isTemporary, executionRefs, onBufferContentChange }: ButtonBarProps) => { const dispatch = useDispatch() const running = useSelector(selectors.query.getRunning) const queriesToRun = useSelector(selectors.query.getQueriesToRun) + const activeNotification = useSelector(selectors.query.getActiveNotification) + const { aiAssistantSettings } = useLocalStorage() + const { status: aiStatus } = useAIStatus() const [dropdownActive, setDropdownActive] = useState(false) const [searchWidgetType, setSearchWidgetType] = useState<"find" | "replace" | null>(null) + const observerRef = useRef(null) + const aiAssistantEnabled = !!aiAssistantSettings.apiKey && !!aiAssistantSettings.model + + const hasQueryError = activeNotification?.type === 'error' && !activeNotification?.isExplain const handleClickQueryButton = useCallback(() => { if (queriesToRun.length > 1) { @@ -173,6 +250,14 @@ const ButtonBar = ({ onTriggerRunScript, isTemporary }: { onTriggerRunScript: (r }, []) useEffect(() => { + if (aiAssistantEnabled) { + if (observerRef.current) { + observerRef.current.disconnect() + observerRef.current = null + } + return + } + const checkFindWidgetVisibility = () => { const findWidget = document.querySelector('.find-widget') const isVisible = !!findWidget && findWidget.classList.contains('visible') @@ -213,11 +298,15 @@ const ButtonBar = ({ onTriggerRunScript, isTemporary }: { onTriggerRunScript: (r attributeFilter: ['class'], attributeOldValue: false }) + observerRef.current = observer return () => { - observer.disconnect() + if (observerRef.current) { + observerRef.current.disconnect() + observerRef.current = null + } } - }, []) + }, [aiAssistantEnabled]) const renderRunScriptButton = () => { if (running === RunningType.SCRIPT) { @@ -316,7 +405,25 @@ const ButtonBar = ({ onTriggerRunScript, isTemporary }: { onTriggerRunScript: (r } return ( - + + + + {hasQueryError && queriesToRun.length === 1 && ( + + )} + {aiStatus && ( + + {aiStatus === AIOperationStatus.Aborted ? : } + {aiStatus} + + )} {running === RunningType.SCRIPT ? renderRunScriptButton() : renderRunQueryButton()} ) diff --git a/packages/web-console/src/scenes/Editor/DiffEditor/index.tsx b/packages/web-console/src/scenes/Editor/DiffEditor/index.tsx new file mode 100644 index 000000000..e37486a4d --- /dev/null +++ b/packages/web-console/src/scenes/Editor/DiffEditor/index.tsx @@ -0,0 +1,160 @@ +import React, { useRef, useState } from "react" +import styled from "styled-components" +import { AutoAwesome } from "@styled-icons/material" +import { DiffEditor } from "@monaco-editor/react" +import type { Monaco, DiffOnMount } from "@monaco-editor/react" +import { Button, Box } from "@questdb/react-components" +import { useEditor } from "../../../providers" +import { QuestDBLanguageName } from "../Monaco/utils" +import type { editor } from "monaco-editor" +import dracula from "../Monaco/dracula" +import { toast } from "../../../components/Toast" +import type { PendingFix } from "../../Editor" +import { color } from "../../../utils" + +const Container = styled.div` + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + background: ${color("backgroundLighter")}; +` + +const ExplanationBox = styled(Box)` + display: flex; + align-items: flex-start; + background: ${color("backgroundLighter")}; + padding: 1rem; + padding-bottom: 2rem; + font-size: 1.4rem; + line-height: 1.5; + white-space: pre-wrap; + max-height: 150px; + overflow-y: auto; +` + +const ButtonBar = styled(Box)` + padding: 0.5rem 1rem; + gap: 1rem; +` + +const EditorContainer = styled.div` + flex: 1; + overflow: hidden; +` + +type Props = { + pendingFixRef: React.MutableRefObject +} + +export const DiffEditorComponent = ({ pendingFixRef }: Props) => { + const { activeBuffer, setActiveBuffer, updateBuffer, deleteBuffer, buffers } = useEditor() + const [diffEditor, setDiffEditor] = useState(null) + const scrolledRef = useRef(false) + const monacoRef = useRef(null) + + if (!activeBuffer.isDiffBuffer || !activeBuffer.diffContent) { + return null + } + + const { original, modified, explanation, queryStartOffset, originalQuery } = activeBuffer.diffContent + const originalBufferId = activeBuffer.originalBufferId + + const destroyEditor = async (setActiveBuffer?: boolean) => { + diffEditor?.dispose() + if (activeBuffer.id) { + await deleteBuffer(activeBuffer.id, setActiveBuffer ?? false) + } + } + + const handleEditorDidMount: DiffOnMount = (editor, monaco) => { + monacoRef.current = monaco + setDiffEditor(editor) + + editor.getOriginalEditor().updateOptions({ readOnly: true }) + editor.onDidUpdateDiff(() => { + if (scrolledRef.current) { + return + } + + const lineChange = editor.getLineChanges()?.[0] + if (lineChange) { + scrolledRef.current = true + editor.getOriginalEditor().revealLineNearTop(lineChange.originalStartLineNumber) + editor.getModifiedEditor().revealLineNearTop(lineChange.modifiedStartLineNumber) + } + }) + + monaco.editor.defineTheme("dracula", dracula) + monaco.editor.setTheme("dracula") + } + + const handleAccept = async () => { + if (!diffEditor || !originalBufferId) return + + const originalBuffer = buffers.find(b => b.id === originalBufferId) + if (!originalBuffer || originalBuffer.archived) { + toast.error(`The tab has been ${originalBuffer ? "archived" : "deleted"}. Fix cannot be applied.`) + await destroyEditor(true) + return + } + + const modifiedContent = diffEditor.getModifiedEditor().getValue() + pendingFixRef.current = { + modifiedContent, + queryStartOffset, + originalQuery, + originalBufferId + } + + await destroyEditor() + await setActiveBuffer(originalBuffer) + } + + const handleReject = async () => { + if (!originalBufferId) return + const originalBuffer = buffers.find(b => b.id === originalBufferId) + + await destroyEditor() + if (originalBuffer && !originalBuffer.archived) { + await setActiveBuffer(originalBuffer) + } + } + + return ( + + {explanation && ( + + + {explanation} + + )} + + + + + + + + + ) +} \ No newline at end of file diff --git a/packages/web-console/src/scenes/Editor/Menu/index.tsx b/packages/web-console/src/scenes/Editor/Menu/index.tsx index 85c154e12..4c56b2533 100644 --- a/packages/web-console/src/scenes/Editor/Menu/index.tsx +++ b/packages/web-console/src/scenes/Editor/Menu/index.tsx @@ -36,6 +36,7 @@ import { TransparentButton, useKeyPress, useScreenSize, + SetupAIAssistant, } from "../../../components" import { actions, selectors } from "../../../store" import { color } from "../../../utils" @@ -67,6 +68,7 @@ const Separator = styled.div` const QueryPickerButton = styled(Button)<{ $firstTimeVisitor: boolean }>` position: relative; flex: 0 0 auto; + margin-right: 0.5rem; @keyframes pulse { 0% { @@ -171,6 +173,7 @@ const Menu = () => { /> )} + diff --git a/packages/web-console/src/scenes/Editor/Monaco/editor-addons.ts b/packages/web-console/src/scenes/Editor/Monaco/editor-addons.ts index 74c513802..bafdf77c8 100644 --- a/packages/web-console/src/scenes/Editor/Monaco/editor-addons.ts +++ b/packages/web-console/src/scenes/Editor/Monaco/editor-addons.ts @@ -33,6 +33,8 @@ import { import { QuestDBLanguageName } from "./utils" import { bufferStore } from "../../../store/buffers" import type { editor, IDisposable } from "monaco-editor" +import { eventBus } from "../../../modules/EventBus" +import { EventType } from "../../../modules/EventBus/types" enum Command { EXECUTE = "execute", @@ -41,6 +43,8 @@ enum Command { ADD_NEW_TAB = "add_new_tab", CLOSE_ACTIVE_TAB = "close_active_tab", SEARCH_DOCS = "search_docs", + GENERATE_QUERY = "generate_query", + EXPLAIN_QUERY = "explain_query", } export const registerEditorActions = ({ @@ -127,6 +131,24 @@ export const registerEditorActions = ({ }, })) + actions.push(editor.addAction({ + id: Command.GENERATE_QUERY, + label: "Generate query", + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyG], + run: () => { + eventBus.publish(EventType.GENERATE_QUERY_OPEN) + }, + })) + + actions.push(editor.addAction({ + id: Command.EXPLAIN_QUERY, + label: "Explain query", + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyE], + run: () => { + eventBus.publish(EventType.EXPLAIN_QUERY_EXEC) + }, + })) + return () => { actions.forEach(action => { action.dispose() diff --git a/packages/web-console/src/scenes/Editor/Monaco/index.tsx b/packages/web-console/src/scenes/Editor/Monaco/index.tsx index 3eccf54c9..e0361de34 100644 --- a/packages/web-console/src/scenes/Editor/Monaco/index.tsx +++ b/packages/web-console/src/scenes/Editor/Monaco/index.tsx @@ -8,12 +8,13 @@ import React, { useCallback, useContext, useEffect, useRef, useState } from "rea import type { ReactNode } from "react" import { useDispatch, useSelector } from "react-redux" import styled from "styled-components" -import type { ExecutionRefs } from "../../Editor" +import type { ExecutionRefs, PendingFix } from "../../Editor" import { PaneContent, Text } from "../../../components" import { formatTiming } from "../QueryResult" import { eventBus } from "../../../modules/EventBus" import { EventType } from "../../../modules/EventBus/types" import { QuestContext, useEditor } from "../../../providers" +import { useAIStatus } from "../../../providers/AIStatusProvider" import { actions, selectors } from "../../../store" import { RunningType } from "../../../store/Query/types" import type { NotificationShape } from "../../../store/Query/types" @@ -72,6 +73,8 @@ export const LINE_NUMBER_HARD_LIMIT = 99999 const Content = styled(PaneContent)` position: relative; + display: flex; + flex-direction: column; overflow: hidden; background: #2c2e3d; .monaco-editor .squiggly-error { @@ -108,6 +111,44 @@ const Content = styled(PaneContent)` border-radius: 2px; } + .aiQueryHighlight { + background-color: rgba(241, 250, 140, 0.5); + border-radius: 2px; + } + + .ai-fix-suggestion { + background-color: rgba(80, 250, 123, 0.15); + border-radius: 2px; + } + + .fix-action-button { + height: 2.4rem; + padding: 1px 6px; + font-size: 1.4rem; + color: #f8f8f2; + border-radius: 4px; + cursor: pointer; + &.accept-fix { + background-color: #00aa3b; + border: 1px solid #00aa3b; + } + + &.reject-fix { + background-color: #ff5555; + border: 1px solid #ff5555; + } + + &:hover { + filter: brightness(1.3); + } + } + + div[widgetid="fix-query-buttons"] { + display: inline-flex !important; + width: 30rem; + gap: 1rem !important; + } + .cursorQueryGlyph, .cancelQueryGlyph { margin-left: 2rem; @@ -164,9 +205,18 @@ const StyledDialogButton = styled(Button)` } ` +const EditorWrapper = styled.div` + flex: 1; + overflow: hidden; + position: relative; +` + const DEFAULT_LINE_CHARS = 5 -const MonacoEditor = ({ executionRefs }: { executionRefs: React.MutableRefObject }) => { +const MonacoEditor = ({ executionRefs, pendingFixRef }: { + executionRefs: React.MutableRefObject + pendingFixRef: React.MutableRefObject +}) => { const editorContext = useEditor() const { buffers, @@ -180,6 +230,7 @@ const MonacoEditor = ({ executionRefs }: { executionRefs: React.MutableRefObject isNavigatingFromSearchRef, } = editorContext const { quest } = useContext(QuestContext) + const { abortOperation: abortAIOperation } = useAIStatus() const [request, setRequest] = useState() const [editorReady, setEditorReady] = useState(false) const [lastExecutedQuery, setLastExecutedQuery] = useState("") @@ -217,6 +268,19 @@ const MonacoEditor = ({ executionRefs }: { executionRefs: React.MutableRefObject const dropdownQueriesRef = useRef([]) const isContextMenuDropdownRef = useRef(false) const cleanupActionsRef = useRef<(() => void)[]>([]) + + const handleBufferContentChange = (value: string | undefined) => { + const lineCount = editorRef.current?.getModel()?.getLineCount() + if (lineCount && lineCount > LINE_NUMBER_HARD_LIMIT) { + if (editorRef.current && currentBufferValueRef.current !== undefined) { + editorRef.current.setValue(currentBufferValueRef.current) + } + toast.error("Maximum line limit reached") + return + } + currentBufferValueRef.current = value + updateBuffer(activeBuffer.id as number, { value }) + } // Set the initial line number width in chars based on the number of lines in the active buffer const [lineNumbersMinChars, setLineNumbersMinChars] = useState( @@ -716,6 +780,8 @@ const MonacoEditor = ({ executionRefs }: { executionRefs: React.MutableRefObject }) editor.onDidChangeModel(() => { + abortAIOperation() + setTimeout(() => { if (monacoRef.current && editorRef.current) { applyGlyphsAndLineMarkings(monacoRef.current, editorRef.current) @@ -755,6 +821,39 @@ const MonacoEditor = ({ executionRefs }: { executionRefs: React.MutableRefObject }, 200) }) + if (pendingFixRef.current && pendingFixRef.current.originalBufferId === activeBuffer.id) { + const { modifiedContent, queryStartOffset, originalQuery } = pendingFixRef.current + const model = editor.getModel() + if (!model) return + const isValid = model.getValue().slice(queryStartOffset, queryStartOffset + originalQuery.length) === originalQuery + + if (isValid) { + const model = editor.getModel() + if (model) { + const startPosition = model.getPositionAt(queryStartOffset) + const endPosition = model.getPositionAt(queryStartOffset + originalQuery.length) + + editor.executeEdits('fix-query', [{ + range: { + startLineNumber: startPosition.lineNumber, + startColumn: startPosition.column, + endLineNumber: endPosition.lineNumber, + endColumn: endPosition.column + }, + text: modifiedContent, + forceMoveMarkers: true + }]) + handleBufferContentChange(model.getValue()) + editor.revealPositionInCenter(startPosition) + } + toast.success("Fix applied successfully") + } else { + toast.error("Query has been changed. Fix cannot be applied.") + } + + pendingFixRef.current = null + } + // Insert query, if one is found in the URL const params = new URLSearchParams(window.location.search) // Support multi-line queries (URL encoded) @@ -1369,6 +1468,7 @@ const MonacoEditor = ({ executionRefs }: { executionRefs: React.MutableRefObject useEffect(() => { return () => { + abortAIOperation() cleanupActionsRef.current.forEach(cleanup => cleanup()) if (cursorChangeTimeoutRef.current) { window.clearTimeout(cursorChangeTimeoutRef.current) @@ -1394,45 +1494,41 @@ const MonacoEditor = ({ executionRefs }: { executionRefs: React.MutableRefObject return ( <> - - - { - const lineCount = editorRef.current?.getModel()?.getLineCount() - if (lineCount && lineCount > LINE_NUMBER_HARD_LIMIT) { - if (editorRef.current && currentBufferValueRef.current !== undefined) { - editorRef.current.setValue(currentBufferValueRef.current) - } - toast.error("Maximum line limit reached") - return - } - currentBufferValueRef.current = value - updateBuffer(activeBuffer.id as number, { value }) - }} - options={{ - // initially null, but will be set during onMount with editor.setModel - model: null, - fixedOverflowWidgets: true, - fontSize: 14, - lineHeight: 24, - fontFamily: theme.fontMonospace, - glyphMargin: true, - renderLineHighlight: "gutter", - useShadowDOM: false, - minimap: { - enabled: false, - }, - selectOnLineNumbers: false, - scrollBeyondLastLine: false, - tabSize: 2, - lineNumbersMinChars: lineNumbersMinChars, - }} - theme="vs-dark" + + + + + diff --git a/packages/web-console/src/scenes/Editor/Monaco/tabs.tsx b/packages/web-console/src/scenes/Editor/Monaco/tabs.tsx index e6f4faf25..df0198fe4 100644 --- a/packages/web-console/src/scenes/Editor/Monaco/tabs.tsx +++ b/packages/web-console/src/scenes/Editor/Monaco/tabs.tsx @@ -54,6 +54,9 @@ const mapTabIconToType = (buffer: Buffer) => { if (buffer.metricsViewState) { return "assets/icon-chart.svg" } + if (buffer.isDiffBuffer) { + return "assets/icon-compare.svg" + } return "assets/icon-file.svg" } @@ -210,6 +213,9 @@ export const Tabs = () => { if (buffer.isTemporary) { classNames.push("temporary-tab") } + if (buffer.isDiffBuffer) { + classNames.push("diff-tab") + } const className = classNames.length > 0 ? classNames.join(" ") : undefined diff --git a/packages/web-console/src/scenes/Editor/Monaco/utils.ts b/packages/web-console/src/scenes/Editor/Monaco/utils.ts index 5e56a1605..18b4efb14 100644 --- a/packages/web-console/src/scenes/Editor/Monaco/utils.ts +++ b/packages/web-console/src/scenes/Editor/Monaco/utils.ts @@ -273,7 +273,9 @@ export const getQueriesFromPosition = ( } case "'": { - inQuote = !inQuote + if (!inSingleLineComment && !inMultiLineComment) { + inQuote = !inQuote + } column++ break } diff --git a/packages/web-console/src/scenes/Editor/index.tsx b/packages/web-console/src/scenes/Editor/index.tsx index 4935cdff7..f41ab2a4e 100644 --- a/packages/web-console/src/scenes/Editor/index.tsx +++ b/packages/web-console/src/scenes/Editor/index.tsx @@ -31,6 +31,7 @@ import Monaco from "./Monaco" import { Tabs } from "./Monaco/tabs" import { useEditor } from "../../providers/EditorProvider" import { Metrics } from "./Metrics" +import { DiffEditorComponent } from "./DiffEditor" import Notifications from "../../scenes/Notifications" import type { QueryKey } from "../../store/Query/types" import type { ErrorResult } from "../../utils" @@ -56,6 +57,13 @@ const EditorPaneWrapper = styled(PaneWrapper)` overflow: hidden; ` +export type PendingFix = { + modifiedContent: string + queryStartOffset: number + originalQuery: string + originalBufferId: number +} + const Editor = ({ innerRef, ...rest @@ -63,6 +71,7 @@ const Editor = ({ const dispatch = useDispatch() const { activeBuffer, addBuffer } = useEditor() const executionRefs = useRef({}) + const pendingFixRef = useRef(null) const handleClearNotifications = (bufferId: number) => { dispatch(actions.query.cleanupBufferNotifications(bufferId)) @@ -80,9 +89,10 @@ const Editor = ({ return ( - {activeBuffer.editorViewState && } + {activeBuffer.isDiffBuffer && } + {activeBuffer.editorViewState && !activeBuffer.isDiffBuffer && } {activeBuffer.metricsViewState && } - {activeBuffer.editorViewState && } + {activeBuffer.editorViewState && !activeBuffer.isDiffBuffer && } ) } diff --git a/packages/web-console/src/scenes/Layout/index.tsx b/packages/web-console/src/scenes/Layout/index.tsx index 44563ea8d..0c8143423 100644 --- a/packages/web-console/src/scenes/Layout/index.tsx +++ b/packages/web-console/src/scenes/Layout/index.tsx @@ -42,6 +42,7 @@ import "allotment/dist/style.css" import { eventBus } from "../../modules/EventBus" import { EventType } from "../../modules/EventBus/types" +import { AIStatusProvider } from "../../providers/AIStatusProvider" const Page = styled.div` display: flex; @@ -101,6 +102,7 @@ const Layout = () => { return ( + @@ -124,7 +126,8 @@ const Layout = () => { -