diff --git a/e2e/questdb b/e2e/questdb index 74aa75697..a29bd95f4 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 74aa75697394cb7830db242a8afacb0188e57335 +Subproject commit a29bd95f4136d7f13376686d2969f0f79708bd8b diff --git a/package.json b/package.json index 9a4a785f2..1a3a0d367 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "prepare": "husky" }, "dependencies": { + "@anthropic-ai/sdk": "^0.57.0", "@date-fns/tz": "^1.2.0", "@docsearch/css": "^3.5.2", "@docsearch/react": "^3.5.2", @@ -77,6 +78,7 @@ "lodash.isequal": "^4.5.0", "lodash.merge": "^4.6.2", "monaco-editor": "^0.44.0", + "openai": "^5.21.0", "posthog-js": "^1.131.2", "ramda": "0.27.1", "react": "17.0.2", diff --git a/public/assets/ai-sparkle-hollow.svg b/public/assets/ai-sparkle-hollow.svg new file mode 100644 index 000000000..20f830afb --- /dev/null +++ b/public/assets/ai-sparkle-hollow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/ai-sparkle.svg b/public/assets/ai-sparkle.svg new file mode 100644 index 000000000..d0d65779c --- /dev/null +++ b/public/assets/ai-sparkle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/icon-compare.svg b/public/assets/icon-compare.svg new file mode 100644 index 000000000..4ef091075 --- /dev/null +++ b/public/assets/icon-compare.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/icon-explain-queries.svg b/public/assets/icon-explain-queries.svg new file mode 100644 index 000000000..8f436e5ff --- /dev/null +++ b/public/assets/icon-explain-queries.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/icon-explain-schema.svg b/public/assets/icon-explain-schema.svg new file mode 100644 index 000000000..27f6722fd --- /dev/null +++ b/public/assets/icon-explain-schema.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/icon-fix-queries.svg b/public/assets/icon-fix-queries.svg new file mode 100644 index 000000000..da3078508 --- /dev/null +++ b/public/assets/icon-fix-queries.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/icon-generate-queries.svg b/public/assets/icon-generate-queries.svg new file mode 100644 index 000000000..2926e6fe1 --- /dev/null +++ b/public/assets/icon-generate-queries.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/models-group-icon.svg b/public/assets/models-group-icon.svg new file mode 100644 index 000000000..280f608a2 --- /dev/null +++ b/public/assets/models-group-icon.svg @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/AIStatusIndicator/index.tsx b/src/components/AIStatusIndicator/index.tsx new file mode 100644 index 000000000..bb9bf06e1 --- /dev/null +++ b/src/components/AIStatusIndicator/index.tsx @@ -0,0 +1,809 @@ +import React, { useState, useMemo, useRef, useEffect } from "react" +import styled, { css } from "styled-components" +import { CheckboxCircle, CloseCircle } from "@styled-icons/remix-fill" +import { FileText, Table } from "@styled-icons/remix-line" +import { ChevronDown, ChevronRight } from "@styled-icons/boxicons-solid" +import { + useAIStatus, + AIOperationStatus, + StatusArgs, +} from "../../providers/AIStatusProvider" +import { color } from "../../utils" +import { slideAnimation, spinAnimation } from "../Animation" +import { BrainIcon } from "../SetupAIAssistant/BrainIcon" +import { pinkLinearGradientHorizontal } from "../../theme" +import { MODEL_OPTIONS } from "../../utils/aiAssistantSettings" + +const CircleNotch = (props: React.SVGProps) => ( + + + + + + + + + +) + +const CaretGradient = (props: React.SVGProps) => ( + + + + + + + + + +) + +const Container = styled.div` + position: absolute; + bottom: 2rem; + right: 2rem; + width: 38.3rem; + background: ${color("backgroundDarker")}; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 0.8rem; + padding: 1.2rem; + display: flex; + flex-direction: column; + gap: 1rem; + z-index: 1000; + box-shadow: 0 0.4rem 1.2rem rgba(0, 0, 0, 0.3); + max-height: 50vh; +` + +const ChatStreaming = styled.div` + background: ${color("backgroundLighter")}; + border-radius: 0.4rem; + padding: 2rem; + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: 1rem; + align-items: center; + max-height: 13.8rem; + position: relative; + overflow: hidden; + flex-shrink: 0; +` + +const ChatStreamingOverlay = styled.div` + background: linear-gradient( + 180deg, + ${color("backgroundLighter")} 0%, + rgba(40, 42, 54, 0) 60% + ); + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1001; +` + +const ThoughtStreams = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + width: 32rem; +` + +const ThoughtStream = styled.div<{ + $active: boolean + $abort: boolean + $level: number +}>` + background: ${color("backgroundDarker")}; + border: 1px solid transparent; + background: + linear-gradient(${color("backgroundDarker")}, ${color("backgroundDarker")}) + padding-box, + ${pinkLinearGradientHorizontal} border-box; + ${({ $abort }) => + $abort && + css` + background: ${color("red")}; + `} + border-radius: 1rem; + display: flex; + gap: 0.8rem; + align-items: center; + padding: 0; + height: 4.3rem; + width: 32rem; + position: relative; + margin-bottom: ${({ $level }) => ($level ? `-1.2rem` : 0)}; + transition: transform 200ms; + z-index: ${({ $active }) => ($active ? 10 : 1)}; + ${({ $level }) => + $level && + css` + transform: scale(${1 - Math.abs($level) * 0.05}); + transform-origin: bottom center; + `} +` + +const ThoughtStreamContent = styled.div` + display: flex; + align-items: center; + background: ${color("backgroundDarker")}; + gap: 0.8rem; + width: 100%; + height: 100%; + border-radius: 1rem; + padding: 0.95rem 1.2rem; +` + +const CheckIcon = styled(CheckboxCircle)` + width: 2.4rem; + height: 2.4rem; + color: ${color("pink")}; + flex-shrink: 0; +` + +const CloseCircleIcon = styled(CloseCircle)` + color: ${color("red")}; + flex-shrink: 0; +` + +const SpinnerIcon = styled(CircleNotch)` + width: 2.4rem; + height: 2.4rem; + ${spinAnimation}; + flex-shrink: 0; + transform-origin: center; +` + +const ThoughtText = styled.div<{ $active: boolean }>` + font-weight: 500; + font-size: 1.6rem; + color: ${color("gray2")}; + ${({ $active }) => $active && slideAnimation} +` + +const Header = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + flex-shrink: 0; +` + +const HeaderLeft = styled.div` + display: flex; + flex: 1 0 0; + gap: 1rem; + align-items: center; + justify-content: center; + min-height: 0; + min-width: 0; +` + +const SparkleIcon = styled.img` + width: 2.4rem; + height: 2.4rem; + flex-shrink: 0; +` + +const WorkingText = styled.div` + font-family: ${({ theme }) => theme.fontMonospace}; + font-size: 1.6rem; + color: ${color("foreground")}; + text-transform: uppercase; + flex: 1 0 0; + min-height: 0; + min-width: 0; +` + +const ChevronButton = styled.button` + background: none; + border: none; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 2.4rem; + height: 2.4rem; + flex-shrink: 0; + color: ${color("foreground")}; + + &:hover { + svg { + filter: brightness(1.2); + } + } +` + +const ExtendedThinkingLabel = styled.div` + display: flex; + gap: 0.8rem; + align-items: center; + justify-content: center; + width: 100%; + flex-shrink: 0; +` + +const BrainIconWrapper = styled.div` + width: 1.6rem; + height: 1.6rem; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +` + +const ExtendedThinkingText = styled.p` + flex: 1 0 0; + font-weight: 400; + font-size: 1.1rem; + color: ${color("gray2")}; + min-height: 0; + min-width: 0; + margin: 0; +` + +const AssistantModes = styled.div` + display: flex; + flex-direction: column; + gap: 1.2rem; + align-items: flex-start; + padding-top: 0.8rem; + width: 100%; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; + flex: 1 1 auto; + max-height: 100%; + box-shadow: inset 0 0.1rem 0.4rem rgba(0, 0, 0, 0.3); +` + +const ModeHeader = styled.div<{ $expanded: boolean; $abort: boolean }>` + border: 1px solid ${color("selection")}; + border-radius: 0.4rem; + display: flex; + ${({ $expanded }) => + $expanded + ? css` + flex-direction: column; + align-items: flex-start; + ` + : css` + align-items: center; + justify-content: space-between; + padding: 1rem 1.2rem; + `} + ${({ $abort }) => + $abort && + css` + border-color: ${color("red")}; + `} + width: 100%; +` + +const ModeHeaderTop = styled.div<{ $expanded: boolean }>` + display: flex; + gap: 1rem; + align-items: center; + ${({ $expanded }) => + $expanded && + css` + border-bottom: 1px solid ${color("selection")}; + padding: 1rem 1.2rem; + width: 100%; + `} + ${({ $expanded }) => + !$expanded && + css` + flex: 1 0 0; + min-height: 0; + min-width: 0; + `} +` + +const ModeChevron = styled.div` + width: 1.6rem; + height: 1.6rem; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + color: ${color("foreground")}; + cursor: pointer; + + &:hover { + opacity: 0.8; + } +` + +const ModeTitle = styled.div` + font-weight: 500; + font-size: 1.4rem; + color: ${color("foreground")}; + text-align: center; + margin-right: auto; +` + +const ReasoningThread = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; + align-items: flex-start; + padding: 1rem 1.2rem; + width: 100%; +` + +const ReasoningItem = styled.div` + display: flex; + gap: 1rem; + align-items: center; + padding: 0.2rem 0.6rem; + padding-left: 0; + width: 100%; +` + +const ReasoningIcon = styled.div` + width: 1.6rem; + height: 1.6rem; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + color: ${color("foreground")}; +` + +const ReasoningText = styled.div` + display: flex; + flex: 1 0 0; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; + min-height: 0; + min-width: 0; +` + +const ReasoningTextPart = styled.span` + font-weight: 400; + font-size: 1.3rem; + color: ${color("gray2")}; +` + +const CodeBadge = styled.div` + background: #2d303e; + border: 1px solid #44475a; + border-radius: 0.6rem; + padding: 0.2rem 0.6rem; + display: flex; + gap: 1rem; + align-items: center; + position: relative; +` + +const CodeBadgeText = styled.span` + font-family: ${({ theme }) => theme.fontMonospace}; + font-size: 1.3rem; + color: #9089fc; +` + +type OperationSection = { + id: string + type: AIOperationStatus + active: boolean + operations: Array<{ type: AIOperationStatus; args?: StatusArgs }> + abort: boolean +} + +const formatDetailedStatusMessage = ( + status: AIOperationStatus, + args?: StatusArgs, +): string => { + if (status === AIOperationStatus.Processing && args && "type" in args) { + switch (args.type) { + case "fix": + return "Processing fix request" + case "generate": + return "Processing generate request" + case "explain": + return "Processing explain request" + default: + return status + } + } + return status +} + +const getIsExpandableSection = (section: OperationSection) => { + return ![ + AIOperationStatus.RetrievingTables, + AIOperationStatus.RetrievingDocumentation, + AIOperationStatus.FormattingResponse, + AIOperationStatus.Aborted, + AIOperationStatus.ValidatingQuery, + ].includes(section.type) +} + +export const AIStatusIndicator: React.FC = () => { + const { status, currentOperation, currentModel } = useAIStatus() + const [expanded, setExpanded] = useState(true) + const [collapsedSections, setCollapsedSections] = useState< + Record + >({}) + const assistantModesRef = useRef(null) + + const hasExtendedThinking = useMemo(() => { + return MODEL_OPTIONS.find((model) => model.value === currentModel)?.isSlow + }, [currentModel]) + + const operationSections = useMemo(() => { + const sections: OperationSection[] = [] + let currentSection: OperationSection | null = null + + for (const op of currentOperation) { + const sectionType = op.type + if (!currentSection || currentSection.type !== sectionType) { + currentSection = { + id: `section-${sections.length}-${sectionType}`, + type: sectionType, + active: false, + abort: sectionType === AIOperationStatus.Aborted, + operations: [op], + } + sections.push(currentSection) + } else { + currentSection.operations.push(op) + } + } + const lastSection = sections[sections.length - 1] + if (lastSection && lastSection.type === status) { + lastSection.active = true + } + if (lastSection && lastSection.type === AIOperationStatus.Aborted) { + lastSection.active = false + } + + return sections + }, [currentOperation, status]) + + const handleToggleExpand = () => { + setExpanded(!expanded) + if (!expanded) { + setTimeout(() => + assistantModesRef.current?.scrollTo({ + top: assistantModesRef.current.scrollHeight, + behavior: "smooth", + }), + ) + } + } + + const handleToggleSection = (sectionId: string) => { + setCollapsedSections((prev) => ({ + ...prev, + [sectionId]: !prev[sectionId], + })) + } + + useEffect(() => { + if (expanded) { + setTimeout(() => + assistantModesRef.current?.scrollTo({ + top: assistantModesRef.current.scrollHeight, + behavior: "smooth", + }), + ) + } + }, [operationSections, expanded]) + + if (!currentOperation || currentOperation.length === 0) { + return null + } + + return ( + + + {operationSections.length > 1 && } + + {operationSections.map((section, index) => ( + + + {section.active ? ( + + ) : section.abort ? ( + + ) : ( + + )} + + {section.type} + + + + ))} + + +
+ + + + {status === AIOperationStatus.Aborted ? "Cancelled" : "Working..."} + + + + {expanded ? ( + + ) : ( + + )} + +
+ + {hasExtendedThinking && ( + + + + + + Extended thinking model enabled. Responses may be slow. + + + )} + + {expanded && ( + + {operationSections.map((section) => { + const isExpandable = getIsExpandableSection(section) + const isExpanded = + collapsedSections[section.id] !== true && isExpandable + + return ( + + + + {section.active ? ( + + ) : section.abort ? ( + + ) : ( + + )} + + + {section.type} + {isExpandable && ( + handleToggleSection(section.id)} + > + {isExpanded ? ( + + ) : ( + + )} + + )} + + {isExpanded && ( + + {section.operations.map((op, idx) => { + const opKey = `${section.id}-${idx}-${JSON.stringify(op.args)}` + + if (op.type === AIOperationStatus.Processing) { + const stepMessage = formatDetailedStatusMessage( + op.type, + op.args, + ) + return ( + + + + + + + {stepMessage} + + + + ) + } + + if ( + op.type === AIOperationStatus.InvestigatingTableSchema + ) { + const tableName = + op.args && "name" in op.args ? op.args.name : "table" + return ( + + + + + + Reading + + {tableName} + + schema + + + ) + } + + if (op.type === AIOperationStatus.InvestigatingDocs) { + const items = + op.args && + "items" in op.args && + Array.isArray(op.args.items) + ? op.args.items + : null + + if (items && items.length > 0) { + return ( + <> + {items.map((item, itemIdx) => { + const itemKey = `${opKey}-item-${itemIdx}` + return ( + + + + + + {item.section ? ( + <> + + Investigating + + + + {item.section} + + + + in + + + + {item.name} + + + + documentation + + + ) : ( + <> + + Investigating + + + + {item.name} + + + + documentation + + + )} + + + ) + })} + + ) + } + + const name = + op.args && "name" in op.args ? op.args.name : null + const docSection = + op.args && "section" in op.args + ? op.args.section + : null + return ( + + + + + + {name && docSection ? ( + <> + + Investigating + + + {docSection} + + in + + {name} + + + documentation + + + ) : name ? ( + <> + + Investigating + + + {name} + + + documentation + + + ) : ( + + Investigating documentation + + )} + + + ) + } + + return null + })} + + )} + + ) + })} + + )} + + ) +} diff --git a/src/components/AlertDialog/index.tsx b/src/components/AlertDialog/index.tsx index 280241b32..add3c35fa 100644 --- a/src/components/AlertDialog/index.tsx +++ b/src/components/AlertDialog/index.tsx @@ -60,10 +60,8 @@ export const AlertDialog = { `, Title: styled(RadixAlertDialog.Title)` margin: 0; - padding: 2rem; font-size: 1.6rem; color: ${({ theme }) => theme.color.foreground}; - border-bottom: 1px ${({ theme }) => theme.color.backgroundLighter} solid; `, Description: styled.div` margin-top: 2rem; diff --git a/src/components/Animation/index.ts b/src/components/Animation/index.ts index 8100ceb99..6cf1e2dc3 100644 --- a/src/components/Animation/index.ts +++ b/src/components/Animation/index.ts @@ -23,6 +23,7 @@ ******************************************************************************/ import { css, keyframes } from "styled-components" +import { color } from "../../utils/styled" const spin = keyframes` from { @@ -37,3 +38,29 @@ const spin = keyframes` export const spinAnimation = css` animation: ${spin} 1.5s cubic-bezier(0.62, 0.28, 0.23, 0.99) infinite; ` + +export const slideAnimation = 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; +` diff --git a/src/components/Box.tsx b/src/components/Box.tsx index ca9356d47..37a017712 100644 --- a/src/components/Box.tsx +++ b/src/components/Box.tsx @@ -7,6 +7,7 @@ type Props = { margin?: React.CSSProperties["margin"] align?: React.CSSProperties["alignItems"] justifyContent?: React.CSSProperties["justifyContent"] + alignSelf?: React.CSSProperties["alignSelf"] } export const Box = styled.div.attrs((props) => ({ @@ -15,6 +16,7 @@ export const Box = styled.div.attrs((props) => ({ margin: props.margin || "0", align: props.align || "center", justifyContent: props.justifyContent || "flex-start", + alignSelf: props.alignSelf || "", }))` display: flex; flex-direction: ${({ flexDirection }) => flexDirection}; @@ -22,4 +24,5 @@ export const Box = styled.div.attrs((props) => ({ margin: ${({ margin }) => margin}; align-items: ${({ align }) => align}; justify-content: ${({ justifyContent }) => justifyContent}; + align-self: ${({ alignSelf }) => alignSelf}; ` diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index db990a457..b20f8fbb7 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -1,22 +1,47 @@ import React, { MouseEvent, ReactNode } from "react" import styled, { css } from "styled-components" +import type { DefaultTheme } from "styled-components" import type { FontSize } from "../../types" import type { Skin } from "./skin" import { makeSkin } from "./skin" +import { + pinkLinearGradientHorizontal, + pinkLinearGradientVertical, +} from "../../theme" export const sizes = ["sm", "md", "lg"] as const export type Size = (typeof sizes)[number] type Type = "button" | "submit" -export type ButtonProps = { +const getPinkGradient = (props: ButtonProps & { theme: DefaultTheme }) => + props.gradientStyle === "vertical" + ? pinkLinearGradientVertical + : pinkLinearGradientHorizontal + +const getHoverPinkGradient = (props: ButtonProps & { theme: DefaultTheme }) => { + const base = getPinkGradient(props) + return base.includes("180deg") + ? base.replace("180deg", "0deg") + : base.replace("90deg", "270deg") +} + +const getBorderWidth = (props: ButtonProps) => + "gradientWeight" in props && props.gradientWeight === "thick" ? "2px" : "1px" + +const getFillColor = (props: ButtonProps & { theme: DefaultTheme }) => + "gradientWeight" in props && props.gradientWeight === "thick" + ? props.theme.color.selectionDarker + : props.theme.color.midnight + +type BaseButtonProps = { as?: React.ElementType - skin?: Skin children?: ReactNode className?: string disabled?: boolean fontSize?: FontSize onClick?: (event: MouseEvent) => void size?: Size + fullWidth?: boolean type?: Type title?: string rounded?: boolean @@ -24,6 +49,21 @@ export type ButtonProps = { dataHook?: string } +type GradientOnlyProps = { + skin: "gradient" + gradientWeight?: "thin" | "thick" + gradientStyle?: "horizontal" | "vertical" +} + +type NonGradientProps = { + skin?: Exclude + gradientWeight?: never + gradientStyle?: never +} + +export type ButtonProps = BaseButtonProps & + (GradientOnlyProps | NonGradientProps) + const Prefix = styled.div<{ disabled?: boolean }>` display: inline-flex; align-items: center; @@ -53,7 +93,7 @@ export const Button: React.FunctionComponent = React.forwardRef( }, ) -const StyledButton = styled.div` +const StyledButton = styled.button` display: inline-flex; height: ${getSize}; padding: 0 1rem; @@ -86,7 +126,36 @@ const StyledButton = styled.div` cursor: default; `} + ${(props) => + props.fullWidth && + css` + width: 100%; + `} + ${(props) => makeSkin(props.skin ?? "primary")} + + ${(props) => + props.skin === "gradient" && + css` + border: ${getBorderWidth} solid transparent; + background: + linear-gradient(${getFillColor}, ${getFillColor}) padding-box, + ${getPinkGradient} border-box; + color: ${props.theme.color.white}; + + &:hover:not([disabled]) { + background: + linear-gradient(${getFillColor}, ${getFillColor}) padding-box, + ${getHoverPinkGradient} border-box; + filter: brightness(120%); + } + + &:disabled { + border: ${getBorderWidth(props)} solid ${props.theme.color.gray1}; + background: ${props.theme.color.selection}; + color: ${props.theme.color.gray1}; + } + `} ` function getSize({ size }: { size?: Size }) { diff --git a/src/components/Button/skin.ts b/src/components/Button/skin.ts index 47acee7ef..0e246fe64 100644 --- a/src/components/Button/skin.ts +++ b/src/components/Button/skin.ts @@ -15,6 +15,7 @@ export const skins = [ "error", "warning", "transparent", + "gradient", ] as const export type Skin = (typeof skins)[number] @@ -30,13 +31,13 @@ const themes: { } = { primary: { normal: { - background: "pink", - border: "pink", + background: "pinkDarker", + border: "pinkDarker", color: "foreground", }, hover: { - background: "pinkDarker", - border: "pinkDarker", + background: "pink", + border: "pink", color: "foreground", }, disabled: { @@ -130,6 +131,23 @@ const themes: { color: "gray1", }, }, + gradient: { + normal: { + background: "midnight", + border: "transparent", + color: "white", + }, + hover: { + background: "midnight", + border: "transparent", + color: "white", + }, + disabled: { + background: "selection", + border: "gray1", + color: "gray1", + }, + }, } export const makeSkin = (skin: Skin) => { diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 2517dd371..90c140c83 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -29,7 +29,7 @@ export const Dialog = { Trigger: RadixDialog.Trigger, Portal: RadixDialog.Portal, Content: styled(RadixDialog.Content)<{ maxwidth?: string }>` - background-color: ${({ theme }) => theme.color.background}; + background-color: ${({ theme }) => theme.color.backgroundDarker}; border-radius: ${({ theme }) => theme.borderRadius}; box-shadow: 0 7px 30px -10px ${({ theme }) => theme.color.black}; position: fixed; @@ -65,7 +65,7 @@ export const Dialog = { color: ${({ theme }) => theme.color.foreground}; border-bottom: 1px ${({ theme }) => theme.color.backgroundLighter} solid; `, - Description: styled.div` + Description: styled(RadixDialog.Description)` margin-top: 2rem; padding: 0 2rem; color: ${({ theme }) => theme.color.foreground}; diff --git a/src/components/ExplainQueryButton/index.tsx b/src/components/ExplainQueryButton/index.tsx new file mode 100644 index 000000000..80e90a451 --- /dev/null +++ b/src/components/ExplainQueryButton/index.tsx @@ -0,0 +1,255 @@ +import React, { useContext, useEffect, useRef, useCallback } from "react" +import styled, { css } from "styled-components" +import { Button, Box, Key } from "../../components" +import { color, platform } from "../../utils" +import { useSelector } from "react-redux" +import { useEditor } from "../../providers/EditorProvider" +import type { + AiAssistantAPIError, + AiAssistantExplanation, +} from "../../utils/aiAssistant" +import { + explainQuery, + formatExplanationAsComment, + createModelToolsClient, + isAiAssistantError, + type ActiveProviderSettings, +} from "../../utils/aiAssistant" +import { providerForModel } from "../../utils/aiAssistantSettings" +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 KeyBinding = styled(Box).attrs({ alignItems: "center", gap: "0" })<{ + $disabled: boolean +}>` + margin-left: 1rem; + color: ${({ theme }) => theme.color.pinkPrimary}; + ${({ $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 { 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, + canUse, + hasSchemaAccess, + currentModel, + apiKey, + } = 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 editorModel = editorRef.current.getModel() + if (!editorModel) return + if (!canUse) { + toast.error("No model selected for AI Assistant") + return + } + + editorRef.current?.updateOptions({ + readOnly: true, + readOnlyMessage: { + value: "Query explanation in progress", + }, + }) + const provider = providerForModel(currentModel) + + const settings: ActiveProviderSettings = { + model: currentModel, + provider, + apiKey, + } + + const response = await explainQuery({ + query: queriesToRun[0], + settings, + modelToolsClient: createModelToolsClient( + quest, + hasSchemaAccess ? tables : undefined, + ), + setStatus, + abortSignal: abortController?.signal, + }) + + if (isAiAssistantError(response)) { + const error = response as AiAssistantAPIError + if (error.type !== "aborted") { + toast.error(error.message, { autoClose: 10000 }) + } + editorRef.current?.updateOptions({ + readOnly: false, + readOnlyMessage: undefined, + }) + return + } + + const result = response as AiAssistantExplanation + if (!result.explanation) { + toast.error("No explanation received from AI Assistant", { + autoClose: 10000, + }) + editorRef.current?.updateOptions({ + readOnly: false, + readOnlyMessage: undefined, + }) + return + } + + const commentBlock = formatExplanationAsComment(result.explanation) + const isSelection = !!queriesToRun[0].selection + + const queryStartLine = isSelection + ? editorModel.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, + tables, + quest, + setStatus, + abortController, + canUse, + hasSchemaAccess, + currentModel, + apiKey, + ]) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!((e.metaKey || e.ctrlKey) && (e.key === "e" || e.key === "E"))) { + return + } + e.preventDefault() + void 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 (!canUse) { + return null + } + + return ( + + ) +} diff --git a/src/components/FeedbackDialog/index.tsx b/src/components/FeedbackDialog/index.tsx index 348c81bd9..2ac5c9fec 100644 --- a/src/components/FeedbackDialog/index.tsx +++ b/src/components/FeedbackDialog/index.tsx @@ -232,7 +232,9 @@ export const FeedbackDialog = ({ {title ?? "Get In Touch"} + + {title ?? "Get In Touch"} + } subtitle={ diff --git a/src/components/GenerateSQLButton/index.tsx b/src/components/GenerateSQLButton/index.tsx new file mode 100644 index 000000000..b45b16642 --- /dev/null +++ b/src/components/GenerateSQLButton/index.tsx @@ -0,0 +1,429 @@ +import React, { + useCallback, + useState, + useContext, + useEffect, + useRef, +} from "react" +import styled, { css } from "styled-components" +import { Button, Box, Dialog, ForwardRef, Overlay, Key } from "../../components" +import { color, platform } from "../../utils" +import { pinkLinearGradientVertical } from "../../theme" +import { useSelector } from "react-redux" +import { useEditor } from "../../providers/EditorProvider" +import type { AiAssistantAPIError, GeneratedSQL } from "../../utils/aiAssistant" +import { + generateSQL, + formatExplanationAsComment, + createModelToolsClient, + isAiAssistantError, + type ActiveProviderSettings, +} from "../../utils/aiAssistant" +import { providerForModel } from "../../utils/aiAssistantSettings" +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 KeyBinding = styled(Box).attrs({ alignItems: "center", gap: "0" })<{ + $disabled: boolean +}>` + margin-left: 1rem; + color: ${({ theme }) => theme.color.pinkPrimary}; + ${({ $disabled, theme }) => + $disabled && + css` + color: ${theme.color.gray1}; + `} +` + +const StyledDialogTitle = styled(Dialog.Title)` + display: flex; + align-items: center; + gap: 1rem; + font-size: 2rem; + font-weight: 500; +` + +const StyledDialogDescription = styled(Dialog.Description)` + font-size: 1.4rem; + color: ${({ theme }) => theme.color.gray2}; + line-height: 1.5; + padding: 0; + margin-bottom: 0; +` + +const StyledContent = styled.div` + display: flex; + flex-direction: column; + gap: 2rem; + margin: 0 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.background}; + 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 { + background: + linear-gradient( + ${({ theme }) => theme.color.background}, + ${({ theme }) => theme.color.background} + ) + padding-box, + ${pinkLinearGradientVertical} border-box; + border: 1px solid transparent; + } + + &::placeholder { + color: ${({ theme }) => theme.color.gray2}; + font-size: 1.3rem; + } +` + +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 { quest } = useContext(QuestContext) + const { editorRef } = useEditor() + const tables = useSelector(selectors.query.getTables) + const running = useSelector(selectors.query.getRunning) + const { + status: aiStatus, + setStatus, + abortController, + canUse, + hasSchemaAccess: hasSchemaAccessValue, + currentModel, + apiKey, + } = 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("") + + if (!canUse) { + toast.error("No model selected for AI Assistant") + return + } + const provider = providerForModel(currentModel) + + const settings: ActiveProviderSettings = { + model: currentModel, + provider, + apiKey, + } + + const response = await generateSQL({ + description, + settings, + modelToolsClient: createModelToolsClient( + quest, + hasSchemaAccessValue ? tables : undefined, + ), + setStatus, + abortSignal: abortController?.signal, + }) + + if (isAiAssistantError(response)) { + const error = response as AiAssistantAPIError + 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 AI Assistant", { 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) { + if (!(e instanceof KeyboardEvent)) { + return + } + if (!((e.metaKey || e.ctrlKey) && (e.key === "g" || e.key === "G"))) { + return + } + e.preventDefault() + } + if (!disabled && canUse) { + handleOpenDialog() + } + }, + [disabled, canUse, handleOpenDialog], + ) + + 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]) + + const handleGenerateShortcut = useCallback( + (e: KeyboardEvent) => { + if (!showDialog) return + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault() + if (description.trim()) { + void handleGenerate() + } + } + }, + [showDialog, description, handleGenerate], + ) + + useEffect(() => { + if (showDialog) { + document.addEventListener("keydown", handleGenerateShortcut) + return () => { + document.removeEventListener("keydown", handleGenerateShortcut) + } + } + }, [showDialog, handleGenerateShortcut]) + + if (!canUse) { + return null + } + + return ( + <> + + + !open && handleCloseDialog()} + > + + + + + + + + + Generate Query + + + + Describe your query in natural language to generate the + corresponding QuestDB SQL. Example: "Show bid-ask spread + for BTC/USD over the last 5 minutes." + + + setDescription(e.target.value)} + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter" && e.ctrlKey) { + e.preventDefault() + void handleGenerate() + } + }} + /> + + + + + + Cancel + + + + + Generate + + + + + + + + + + + ) +} diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx index 4b6681cbc..9f17b5e32 100644 --- a/src/components/Input/index.tsx +++ b/src/components/Input/index.tsx @@ -13,7 +13,7 @@ const errorStyle = css` ` export const Input = styled.input.attrs((props) => ({ - "data-lpignore": !!props.autoComplete, + "data-lpignore": props.autoComplete === "off", }))` background: ${({ theme }) => theme.color.selection}; border: 1px transparent solid; diff --git a/src/components/Key/index.tsx b/src/components/Key/index.tsx new file mode 100644 index 000000000..db5c6d0f5 --- /dev/null +++ b/src/components/Key/index.tsx @@ -0,0 +1,112 @@ +import React from "react" +import styled from "styled-components" +import { Box } from "../Box" +import { color } from "../../utils" +import { CornerDownLeft } from "@styled-icons/evaicons-solid" +import type { ThemeShape } from "../../types" + +type ColorFunction = (props?: { theme: ThemeShape }) => string | undefined + +const StyledKey = styled(Box).attrs({ + alignItems: "center", + justifyContent: "center", +})<{ $color?: string | ColorFunction; $hoverColor?: string | ColorFunction }>` + padding: 0 0.4rem; + background: ${color("backgroundDarker")}; + border: 0.5px solid ${color("midnight")}; + border-radius: 0.2rem; + font-size: 1.2rem; + height: 1.8rem; + min-width: 2rem; + color: ${({ $color, theme }) => { + if (typeof $color === "function") { + // Handle color() function signature which expects { theme } + const result = $color({ theme }) + return result || theme.color.foreground + } + return $color || theme.color.foreground + }}; + position: relative; + display: flex; + box-shadow: + 0px 12px 16px -4px rgba(0, 0, 0, 0.2), + 0px 4px 6px -2px rgba(0, 0, 0, 0.2), + 0px 2px 2px -1px rgba(0, 0, 0, 0.2); + transition: color 0.2s ease; + + &:hover { + color: ${({ $hoverColor, $color, theme }) => { + if (typeof $hoverColor === "function") { + const hoverResult = $hoverColor({ theme }) + const colorResult = + typeof $color === "function" ? $color({ theme }) : $color + return hoverResult || colorResult || theme.color.foreground + } + const colorResult = + typeof $color === "function" ? $color({ theme }) : $color + return $hoverColor || colorResult || theme.color.foreground + }}; + } + + svg { + color: ${({ $color, theme }) => { + if (typeof $color === "function") { + const result = $color({ theme }) + return result || theme.color.foreground + } + return $color || theme.color.foreground + }}; + fill: ${({ $color, theme }) => { + if (typeof $color === "function") { + const result = $color({ theme }) + return result || theme.color.foreground + } + return $color || theme.color.foreground + }}; + } + + &:hover svg { + color: ${({ $hoverColor, $color, theme }) => { + if (typeof $hoverColor === "function") { + const hoverResult = $hoverColor({ theme }) + const colorResult = + typeof $color === "function" ? $color({ theme }) : $color + return hoverResult || colorResult || theme.color.foreground + } + const colorResult = + typeof $color === "function" ? $color({ theme }) : $color + return $hoverColor || colorResult || theme.color.foreground + }}; + fill: ${({ $hoverColor, $color, theme }) => { + if (typeof $hoverColor === "function") { + const hoverResult = $hoverColor({ theme }) + const colorResult = + typeof $color === "function" ? $color({ theme }) : $color + return hoverResult || colorResult || theme.color.foreground + } + const colorResult = + typeof $color === "function" ? $color({ theme }) : $color + return $hoverColor || colorResult || theme.color.foreground + }}; + } + + &:not(:last-child) { + margin-right: 0.25rem; + } +` + +type Props = { + keyString: string + color?: string | ColorFunction + hoverColor?: string | ColorFunction +} + +export const Key = ({ keyString, color: keyColor, hoverColor }: Props) => { + const isEnter = keyString.toLowerCase() === "enter" + + return ( + + {isEnter ? : keyString} + + ) +} diff --git a/src/components/MultiStepModal/index.tsx b/src/components/MultiStepModal/index.tsx new file mode 100644 index 000000000..dd8c59448 --- /dev/null +++ b/src/components/MultiStepModal/index.tsx @@ -0,0 +1,371 @@ +import React, { ReactNode, useState, createContext, useContext } from "react" +import * as RadixDialog from "@radix-ui/react-dialog" +import styled, { css } from "styled-components" +import { ArrowLeft } from "@styled-icons/remix-line" +import { Overlay } from "../Overlay" +import { Box } from "../Box" +import { Button } from "../Button" +import { Text } from "../Text" +import { LoadingSpinner } from "../LoadingSpinner" +import { ForwardRef } from "../ForwardRef" + +type NavigationContextType = { + handleNext: () => void | Promise + handlePrevious: () => void + handleClose: () => void + currentStep: number + isFirstStep: boolean + isLastStep: boolean +} + +const NavigationContext = createContext(null) + +export const useModalNavigation = (): NavigationContextType => { + const context = useContext(NavigationContext) + if (!context) { + return { + handleNext: () => {}, + handlePrevious: () => {}, + handleClose: () => {}, + currentStep: 0, + isFirstStep: true, + isLastStep: false, + } + } + return context +} + +const dialogShow = css` + @keyframes dialogShow { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +` + +const dialogHide = css` + @keyframes dialogHide { + from { + opacity: 1; + } + to { + opacity: 0; + } + } +` + +const StyledContent = styled(RadixDialog.Content)<{ maxwidth?: string }>` + background-color: ${({ theme }) => theme.color.backgroundDarker}; + border-radius: ${({ theme }) => theme.borderRadius}; + box-shadow: 0 0.7rem 3rem -1rem ${({ theme }) => theme.color.black}; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 90vw; + max-width: ${({ maxwidth }) => maxwidth ?? "50rem"}; + max-height: 85vh; + padding: 0; + border: 0.1rem solid ${({ theme }) => theme.color.selection}; + z-index: 101; + display: flex; + flex-direction: column; + + ${dialogShow} + ${dialogHide} + + &[data-state="open"] { + animation: dialogShow 0.25s cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-state="closed"] { + animation: dialogHide 0.25s cubic-bezier(0.16, 1, 0.3, 1); + } + + &:focus { + outline: none; + } +` + +const StepIndicatorContainer = styled(Box).attrs({ + gap: "1rem", + align: "center", +})` + backdrop-filter: blur(0.6rem); + background: rgba(255, 255, 255, 0.06); + padding: 0.4rem; + border-radius: 10rem; + box-shadow: 0 0.1rem 0.2rem rgba(0, 0, 0, 0.08); + width: fit-content; +` + +const StepBadge = styled.div` + backdrop-filter: blur(0.6rem); + padding: 0.2rem 0.8rem; + border-radius: 10rem; + background: rgba(255, 255, 255, 0.16); +` + +const StepBadgeText = styled(Text)` + font-size: 1.2rem; + font-weight: 500; + text-transform: uppercase; + color: ${({ theme }) => theme.color.cyan}; + padding: 0.2rem 0.8rem; +` + +const StepBadgeLabel = styled(RadixDialog.Title)` + font-size: 1.2rem; + font-weight: 400; + line-height: 1.5; + color: ${({ theme }) => theme.color.white}; + margin: 0; +` + +const Content = styled.div` + flex: 1; + overflow-y: auto; +` + +const FooterSection = styled(Box).attrs({ + flexDirection: "column", + gap: "1.2rem", +})` + padding: 2.4rem; + width: 100%; + border-top: 0.1rem solid ${({ theme }) => theme.color.selection}; +` + +const FooterButtons = styled(Box).attrs({ + justifyContent: "flex-end", + align: "center", + gap: "1.6rem", +})` + width: 100%; +` + +const ValidationError = styled(Text)` + color: ${({ theme }) => theme.color.red}; + font-size: 1.3rem; + text-align: right; + width: 100%; +` + +const CancelButton = styled(Button)` + flex: 1; + padding: 1.1rem 1.2rem; + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 1.4rem; + font-weight: 500; + width: 100%; + height: 4rem; +` + +const NextButton = styled(Button)` + padding: 1.1rem 1.2rem; + font-size: 1.4rem; + font-weight: 500; + flex: 1; + height: 4rem; + width: 100%; +` + +export type Step = { + id: string + title: string + stepName: string + content: ReactNode | (() => ReactNode) + validate?: () => string | boolean | Promise +} + +type MultiStepModalProps = { + open?: boolean + onOpenChange?: (open: boolean) => void + steps: Step[] + maxWidth?: string + onComplete?: () => void | Promise + onCancel?: () => void + canProceed?: (stepIndex: number) => boolean | Promise + completeButtonText?: string + onStepChange?: (stepIndex: number, direction: "next" | "previous") => void + showValidationError?: boolean +} + +export const MultiStepModal = ({ + open, + onOpenChange, + steps, + maxWidth, + onComplete, + onCancel, + canProceed, + completeButtonText = "Complete", + onStepChange, + showValidationError = true, +}: MultiStepModalProps) => { + const [currentStep, setCurrentStep] = useState(0) + const [validationError, setValidationError] = useState(null) + const [isValidating, setIsValidating] = useState(false) + + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen && onCancel) { + onCancel() + } + onOpenChange?.(isOpen) + if (!isOpen) { + setCurrentStep(0) + setValidationError(null) + setIsValidating(false) + } + } + + const handleNext = async () => { + const currentStepData = steps[currentStep] + const canProceedResult = canProceed ? await canProceed(currentStep) : true + if (!canProceedResult) { + return + } + + if (currentStepData?.validate) { + setValidationError(null) + setIsValidating(true) + try { + const validationResult = await currentStepData.validate() + + if (typeof validationResult === "string") { + setValidationError(validationResult) + return + } else if (validationResult === false) { + setValidationError("Validation failed") + return + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Validation failed" + setValidationError(errorMessage) + return + } finally { + setIsValidating(false) + } + } + + if (currentStep < steps.length - 1) { + setValidationError(null) + const newStep = currentStep + 1 + onStepChange?.(newStep, "next") + setCurrentStep(newStep) + } else { + await onComplete?.() + handleOpenChange(false) + } + } + + const handlePrevious = () => { + if (currentStep > 0) { + setValidationError(null) + setIsValidating(false) + const newStep = currentStep - 1 + onStepChange?.(newStep, "previous") + setCurrentStep(newStep) + } + } + + const handleClose = () => { + handleOpenChange(false) + } + + const isLastStep = currentStep === steps.length - 1 + const isFirstStep = currentStep === 0 + + const navigationContextValue: NavigationContextType = { + handleNext, + handlePrevious, + handleClose, + currentStep, + isFirstStep, + isLastStep, + } + + return ( + + + + + + + + {steps.length > 1 && ( + + + + Step {currentStep + 1} of {steps.length} + + + + {steps[currentStep]?.stepName || + steps[currentStep]?.title} + + + + + )} + + {typeof steps[currentStep]?.content === "function" + ? steps[currentStep]?.content() + : steps[currentStep]?.content} + + + {showValidationError && validationError && ( + {validationError} + )} + + + {!isFirstStep && } + {isFirstStep ? "Cancel" : "Back"} + + + {isValidating ? ( + + + Validating... + + ) : isLastStep ? ( + completeButtonText + ) : ( + "Next" + )} + + + + + + + + ) +} diff --git a/src/components/Overlay/index.tsx b/src/components/Overlay/index.tsx index 8f3c459b8..b18cfc8d4 100644 --- a/src/components/Overlay/index.tsx +++ b/src/components/Overlay/index.tsx @@ -26,7 +26,7 @@ const overlayHide = css` ` const StyledOverlay = styled.div` - background-color: ${({ theme }) => theme.color.black70}; + background-color: ${({ theme }) => theme.color.overlayBackground}; position: fixed; inset: 0; z-index: 100; diff --git a/src/components/SetupAIAssistant/AIAssistantPromo.tsx b/src/components/SetupAIAssistant/AIAssistantPromo.tsx new file mode 100644 index 000000000..569961b9b --- /dev/null +++ b/src/components/SetupAIAssistant/AIAssistantPromo.tsx @@ -0,0 +1,484 @@ +import React, { useCallback, useEffect, useRef, useState } from "react" +import ReactDOM from "react-dom" +import { usePopper } from "react-popper" +import { CSSTransition } from "react-transition-group" +import styled from "styled-components" +import { Close } from "@styled-icons/remix-line" +import { Button } from "../Button" +import { Text } from "../Text" +import { Box } from "../Box" +import { TransitionDuration } from "../Transition" +import { platform } from "../../utils" + +const ctrlCmd = platform.isMacintosh || platform.isIOS ? "⌘" : "Ctrl" + +const TooltipContainer = styled.div<{ $positionReady: boolean }>` + position: relative; + z-index: 1000; + + visibility: ${({ $positionReady }) => + $positionReady ? "visible" : "hidden"}; +` + +const Arrow = styled.div<{ $styles?: React.CSSProperties }>` + position: absolute; + width: 1.6rem; + height: 0.6rem; + top: -0.6rem; + left: 50%; + transform: translateX(-50%) rotate(180deg); + pointer-events: none; + + &::before { + content: ""; + position: absolute; + width: 1.6rem; + height: 0.6rem; + background: linear-gradient( + to bottom, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.2) 100% + ); + clip-path: polygon(50% 100%, 0% 0%, 100% 0%); + transform: rotate(180deg); + } + + &::after { + content: ""; + position: absolute; + width: 0; + height: 0; + top: 0.1rem; + left: 50%; + transform: translateX(-50%); + border-left: 0.7rem solid transparent; + border-right: 0.7rem solid transparent; + border-bottom: 0.5rem solid ${({ theme }) => theme.color.backgroundDarker}; + } +` + +const Content = styled.div` + background: ${({ theme }) => theme.color.backgroundDarker}; + border: 0.1rem solid transparent; + border-radius: 0.4rem; + width: 38.3rem; + padding: 1.2rem; + display: flex; + flex-direction: column; + gap: 1rem; +` + +const Header = styled(Box).attrs({ + align: "center", + justifyContent: "space-between", +})` + width: 100%; +` + +const AssistantTitle = styled(Box).attrs({ + align: "center", + gap: "1rem", +})` + flex: 1; +` + +const SparkleIcon = styled.img` + width: 2.8rem; + height: 2.8rem; + flex-shrink: 0; +` + +const TitleText = styled(Text)` + font-family: ${({ theme }) => theme.fontMonospace}; + font-size: 1.6rem; + text-transform: uppercase; + color: ${({ theme }) => theme.color.foreground}; + line-height: 2.25rem; +` + +const CloseButton = styled.button` + background: transparent; + border: none; + cursor: pointer; + padding: 0.4rem; + display: flex; + align-items: center; + justify-content: center; + color: ${({ theme }) => theme.color.gray2}; + width: 2.8rem; + height: 2.8rem; + flex-shrink: 0; + + &:hover { + color: ${({ theme }) => theme.color.foreground}; + } +` + +const Description = styled(Text)` + font-size: 1.3rem; + line-height: 1.857rem; + color: ${({ theme }) => theme.color.foreground}; + padding-right: 0.8rem; +` + +const AssistantModes = styled(Box).attrs({ + flexDirection: "column", + gap: "1rem", +})` + padding-top: 1.4rem; +` + +const AssistantMode = styled(Box).attrs({ + gap: "1rem", + align: "flex-start", +})` + width: 100%; +` + +const IconContainer = styled(Box).attrs({ + align: "center", + justifyContent: "center", +})` + background: ${({ theme }) => theme.color.selectionDarker}; + border-radius: 0.4rem; + padding: 0.8rem; + width: 4.8rem; + height: 4rem; + flex-shrink: 0; +` + +const ModeIcon = styled.img` + width: 2.4rem; + height: 2.4rem; + color: ${({ theme }) => theme.color.pink}; +` + +const ModeContent = styled(Box).attrs({ + flexDirection: "column", + gap: "0.5rem", + align: "flex-start", +})` + flex: 1; +` + +const ModeTitleRow = styled(Box).attrs({ + align: "center", + gap: "1rem", +})` + width: 100%; +` + +const ModeTitle = styled(Text)` + font-weight: 500; + font-size: 1.4rem; + line-height: 1.8rem; + text-align: left; + color: ${({ theme }) => theme.color.white}; +` + +const KeyContainer = styled(Box).attrs({ + gap: "0.4rem", + align: "center", +})`` + +const KeyBadge = styled.div` + background: ${({ theme }) => theme.color.backgroundDarker}; + border: 0.075rem solid ${({ theme }) => theme.color.midnight}; + border-radius: 0.25rem; + width: 2.5rem; + height: 2.2rem; + display: flex; + align-items: center; + justify-content: center; + position: relative; + box-shadow: inset 0 0 0.4889rem 0 rgba(96, 96, 96, 0.2); +` + +const KeyText = styled(Text)` + font-family: ${({ theme }) => theme.font}; + font-weight: 600; + font-size: 1.5rem; + color: ${({ theme }) => theme.color.pinkPrimary}; + line-height: 0; +` + +const ModeDescription = styled(Text)` + font-size: 1.3rem; + line-height: 1.857rem; + color: ${({ theme }) => theme.color.foreground}; +` + +const Footer = styled(Box).attrs({ + align: "center", + justifyContent: "space-between", +})` + padding-top: 1.4rem; + width: 100%; +` + +const SetupButton = styled(Button).attrs({ + skin: "primary", +})` + background: ${({ theme }) => theme.color.pinkDarker}; + margin-left: auto; +` + +type Props = { + triggerRef: React.RefObject + onSetupClick: () => void + showPromo: boolean + setShowPromo: (show: boolean) => void +} + +export const AIAssistantPromo = ({ + triggerRef, + onSetupClick, + showPromo, + setShowPromo, +}: Props) => { + const [container] = useState(document.createElement("div")) + const transitionTimeoutId = useRef() + const [arrowElement, setArrowElement] = useState(null) + const [popperElement, setPopperElement] = useState(null) + const [positionReady, setPositionReady] = useState(false) + const promoKeyRef = useRef(0) + + const { attributes, styles, forceUpdate } = usePopper( + triggerRef.current || undefined, + popperElement || undefined, + { + modifiers: [ + { + name: "arrow", + options: { element: arrowElement || undefined }, + }, + { + name: "offset", + options: { offset: [0, 10] }, + }, + { + name: "eventListeners", + enabled: showPromo, + }, + ], + placement: "bottom-end", + }, + ) + + const handleClose = useCallback(() => { + setShowPromo(false) + }, []) + + const handleSetupClick = useCallback(() => { + setShowPromo(false) + onSetupClick() + }, [onSetupClick]) + + useEffect(() => { + document.body.appendChild(container) + + return () => { + clearTimeout(transitionTimeoutId.current) + if (document.body.contains(container)) { + document.body.removeChild(container) + } + } + }, [container]) + + useEffect(() => { + if (showPromo) { + promoKeyRef.current += 1 + setPositionReady(false) + } else { + setPositionReady(false) + setPopperElement(null) + setArrowElement(null) + } + }, [showPromo]) + + useEffect(() => { + if (popperElement && styles.popper && showPromo && !positionReady) { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setPositionReady(true) + }) + }) + } + }, [popperElement, styles.popper, positionReady, showPromo]) + + useEffect(() => { + if (showPromo && forceUpdate && triggerRef.current && popperElement) { + requestAnimationFrame(() => { + forceUpdate() + }) + } + }, [showPromo, forceUpdate, triggerRef, popperElement]) + + useEffect(() => { + if (!showPromo) return + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node + const isClickInsidePopper = + popperElement && popperElement.contains(target) + const isClickOnTrigger = + triggerRef.current && triggerRef.current.contains(target) + + if (!isClickInsidePopper && !isClickOnTrigger) { + setShowPromo(false) + } + } + + document.addEventListener("mousedown", handleClickOutside, true) + + return () => { + document.removeEventListener("mousedown", handleClickOutside, true) + } + }, [showPromo, popperElement, triggerRef]) + + if (!triggerRef.current && !showPromo) { + return null + } + + if (!showPromo) { + return null + } + + return ( + <> + {ReactDOM.createPortal( + + + + +
+ + + Meet QuestDB Assistant + + + + +
+ + + Our AI Assistant is a specialized programming and support agent + that makes you more effective and helps you solve problems as + you interface with your QuestDB database. It can help you in the + following ways: + + + + + + + + + + Generate Queries + + + {ctrlCmd} + + + G + + + + + Create SQL queries from natural language, with + schema-aware context. + + + + + + + + + + + Explain Queries + + + {ctrlCmd} + + + E + + + + + Get an inline explanation of your query. + + + + + + + + + + Fix Queries + + Documentation-referenced suggestions to fix query errors. + + + + + + + + + + Explain Schema + + Detailed overview and structure of tables. + + + + + +
+ + Setup Assistant + +
+
+
+
, + container, + )} + + ) +} diff --git a/src/components/SetupAIAssistant/AnthropicIcon.tsx b/src/components/SetupAIAssistant/AnthropicIcon.tsx new file mode 100644 index 000000000..d24b9c229 --- /dev/null +++ b/src/components/SetupAIAssistant/AnthropicIcon.tsx @@ -0,0 +1,21 @@ +import React from "react" + +export const AnthropicIcon = (props: React.SVGProps) => { + return ( + + + + ) +} diff --git a/src/components/SetupAIAssistant/BrainIcon.tsx b/src/components/SetupAIAssistant/BrainIcon.tsx new file mode 100644 index 000000000..eb2db9354 --- /dev/null +++ b/src/components/SetupAIAssistant/BrainIcon.tsx @@ -0,0 +1,21 @@ +import React from "react" + +export const BrainIcon = (props: React.SVGProps) => { + return ( + + + + ) +} diff --git a/src/components/SetupAIAssistant/ConfigurationModal.tsx b/src/components/SetupAIAssistant/ConfigurationModal.tsx new file mode 100644 index 000000000..2e4855c49 --- /dev/null +++ b/src/components/SetupAIAssistant/ConfigurationModal.tsx @@ -0,0 +1,910 @@ +import React, { useState, useMemo, useCallback } from "react" +import styled, { css } from "styled-components" +import { Dialog } from "../Dialog" +import { MultiStepModal, Step } from "../MultiStepModal" +import { Box } from "../Box" +import { Input } from "../Input" +import { Switch } from "../Switch" +import { Checkbox } from "../Checkbox" +import { Text } from "../Text" +import { useLocalStorage } from "../../providers/LocalStorageProvider" +import { testApiKey } from "../../utils/aiAssistant" +import { StoreKey } from "../../utils/localStorage/types" +import { toast } from "../Toast" +import { + MODEL_OPTIONS, + type ModelOption, + type Provider, +} from "../../utils/aiAssistantSettings" +import { useModalNavigation } from "../MultiStepModal" +import { OpenAIIcon } from "./OpenAIIcon" +import { AnthropicIcon } from "./AnthropicIcon" +import { BrainIcon } from "./BrainIcon" +import { theme } from "../../theme" + +const ModalContent = styled.div` + display: flex; + flex-direction: column; + width: 100%; +` + +const HeaderSection = styled(Box).attrs({ + flexDirection: "column", + gap: "1.6rem", +})` + padding: 2.4rem; + padding-top: 0; + width: 100%; +` + +const HeaderTitleRow = styled(Box).attrs({ + justifyContent: "space-between", + align: "flex-start", + gap: "1rem", +})` + width: 100%; +` + +const HeaderText = styled(Box).attrs({ + flexDirection: "column", + gap: "1.2rem", + align: "flex-start", +})` + flex: 1; +` + +const ModalTitle = styled(Dialog.Title)` + font-size: 2.4rem; + font-weight: 600; + margin: 0; + padding: 0; + color: ${({ theme }) => theme.color.foreground}; + border: 0; +` + +const ModalSubtitle = styled(Dialog.Description)` + color: ${({ theme }) => theme.color.gray2}; + margin: 0; + padding: 0; +` + +const StyledCloseButton = styled.button` + background: transparent; + border: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + color: ${({ theme }) => theme.color.gray1}; + border-radius: 0.4rem; + flex-shrink: 0; + width: 2.2rem; + height: 2.2rem; + + &:hover { + color: ${({ theme }) => theme.color.foreground}; + } +` + +const Separator = styled.div` + height: 0.1rem; + width: 100%; + background: ${({ theme }) => theme.color.selection}; +` + +const ContentSection = styled(Box).attrs({ + flexDirection: "column", + gap: "2rem", +})` + padding: 2.4rem; + width: 100%; +` + +const SectionTitle = styled(Text)` + font-size: 1.8rem; + font-weight: 600; + color: ${({ theme }) => theme.color.foreground}; +` + +const SectionDescription = styled(Text)` + font-size: 1.3rem; + font-weight: 300; + color: ${({ theme }) => theme.color.gray2}; +` + +const ProviderSelectionContainer = styled(Box).attrs({ + gap: "4rem", + align: "center", +})` + width: 100%; +` + +const ProviderCardsContainer = styled(Box).attrs({ + gap: "2rem", +})` + height: 8.5rem; +` + +const ProviderCard = styled.button<{ $selected: boolean }>` + background: #262833; + border: 0.1rem solid ${({ theme }) => theme.color.selection}; + border-radius: 0.8rem; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + padding: 1.2rem 2rem; + width: 10rem; + height: 8.5rem; + transition: all 0.2s; + + ${({ $selected, theme }) => + $selected && + ` + border-color: ${theme.color.foreground}; + box-shadow: 0 0 0 0.1rem ${theme.color.foreground}; + background: ${theme.color.midnight}; + `} + + &:hover { + border-color: ${({ theme }) => theme.color.foreground}; + } + + &:focus-visible { + outline: 0.2rem solid ${({ theme }) => theme.color.foreground}; + outline-offset: 0.2rem; + } +` + +const ProviderName = styled(Text)` + font-size: 1.3rem; + font-weight: 400; + color: rgba(249, 250, 251, 0.8); + text-align: center; +` + +const ComingSoonContainer = styled(Box).attrs({ + flexDirection: "column", + gap: "0.6rem", + align: "flex-start", +})` + width: 13.2rem; +` + +const ComingSoonIcons = styled(Box).attrs({ + align: "center", +})` + width: 100%; + padding-left: 0; + padding-right: 1.2rem; +` + +const ComingSoonIcon = styled.img` + width: 100%; + height: auto; + object-fit: contain; +` + +const ComingSoonText = styled(Text)` + font-size: 1.3rem; + font-weight: 300; + color: ${({ theme }) => theme.color.gray2}; +` + +const InputSection = styled(Box).attrs({ + flexDirection: "column", + gap: "1.2rem", +})` + width: 100%; +` + +const InputLabel = styled(Text)` + font-size: 1.6rem; + font-weight: 600; + color: ${({ theme }) => theme.color.gray2}; +` + +const StyledInput = styled(Input)<{ $hasError?: boolean; disabled?: boolean }>` + width: 100%; + background: #262833; + border: 0.1rem solid + ${({ theme, $hasError }) => ($hasError ? theme.color.red : "#6b7280")}; + border-radius: 0.8rem; + cursor: ${({ disabled }) => (disabled ? "not-allowed" : "text")}; + font-size: 1.4rem; + min-height: 3rem; + text-security: disc; + -webkit-text-security: disc; + -moz-text-security: disc; + + &::placeholder { + color: ${({ theme }) => theme.color.gray2}; + font-family: inherit; + } + + ${({ disabled }) => + disabled && + css` + opacity: 0.6; + cursor: not-allowed; + `} +` + +const ErrorText = styled(Text)` + color: ${({ theme }) => theme.color.red}; + font-size: 1.3rem; +` + +const ModelList = styled(Box).attrs({ flexDirection: "column", gap: "1.2rem" })` + width: 100%; +` + +const StyledCheckbox = styled(Checkbox)` + font-size: 1.4rem; + display: inline; +` + +const FormGroup = styled(Box).attrs({ + flexDirection: "column", + gap: "1.6rem", +})` + width: 100%; + align-items: flex-start; +` + +const ProviderBadge = styled(Box).attrs({ + gap: "0.6rem", + align: "center", +})` + background: #2d303e; + padding: 0.6rem 0.8rem; + border-radius: 0.4rem; + box-shadow: inset 0 0.1rem 0.4rem rgba(0, 0, 0, 0.1); +` + +const ProviderBadgeText = styled(Text)` + font-size: 1.3rem; + font-weight: 400; + color: ${({ theme }) => theme.color.foreground}; + font-family: "Open Sans", sans-serif; +` + +const EnableModelsSection = styled(Box).attrs({ + flexDirection: "column", + gap: "2rem", +})` + width: 100%; +` + +const EnableModelsHeader = styled(Box).attrs({ + justifyContent: "space-between", + align: "center", + gap: "1rem", +})` + width: 100%; +` + +const EnableModelsTitle = styled(Text)` + font-size: 1.8rem; + font-weight: 600; + color: ${({ theme }) => theme.color.foreground}; +` + +const ModelToggleRow = styled(Box).attrs({ + justifyContent: "space-between", + align: "center", + gap: "2.4rem", +})` + width: 100%; +` + +const ModelInfoColumn = styled(Box).attrs({ + flexDirection: "column", + gap: "0.8rem", +})` + flex: 1; + align-items: flex-start; +` + +const ModelInfoRow = styled(Box).attrs({ + gap: "0.8rem", + align: "center", +})` + width: 100%; +` + +const ModelDescriptionText = styled(Text)` + font-size: 1.1rem; + color: ${({ theme }) => theme.color.gray2}; + flex: 1; +` + +const ModelNameText = styled(Text)` + font-size: 1.4rem; + font-weight: 400; + color: ${({ theme }) => theme.color.foreground}; +` + +const SchemaAccessSection = styled(Box).attrs({ + flexDirection: "column", + gap: "1.6rem", +})` + width: 100%; +` + +const SchemaAccessHeader = styled(Box).attrs({ + justifyContent: "space-between", + align: "center", + gap: "1rem", +})` + width: 100%; +` + +const SchemaAccessTitle = styled(Text)` + font-size: 1.6rem; + font-weight: 600; + color: ${({ theme }) => theme.color.gray2}; + flex: 1; +` + +const SchemaCheckboxContainer = styled(Box).attrs({ + gap: "1.5rem", + align: "flex-start", +})` + background: rgba(68, 71, 90, 0.56); + padding: 0.75rem; + border-radius: 0.4rem; + width: 100%; +` + +const SchemaCheckboxInner = styled(Box).attrs({ + gap: "1.5rem", + align: "center", +})` + flex: 1; + padding: 0.75rem; + border-radius: 0.5rem; +` + +const SchemaCheckboxWrapper = styled.div` + flex-shrink: 0; + display: flex; + align-items: center; +` + +const SchemaCheckboxContent = styled(Box).attrs({ + flexDirection: "column", + gap: "0.6rem", +})` + flex: 1; +` + +const SchemaCheckboxLabel = styled(Text)` + font-size: 1.4rem; + font-weight: 500; + color: ${({ theme }) => theme.color.foreground}; +` + +const SchemaCheckboxDescription = styled(Text)` + font-size: 1.3rem; + font-weight: 400; + color: ${({ theme }) => theme.color.gray2}; +` + +const SchemaCheckboxDescriptionBold = styled.span` + font-weight: 500; + color: ${({ theme }) => theme.color.foreground}; +` + +const WarningText = styled(Text)` + font-size: 1.3rem; + font-weight: 400; + color: ${({ theme }) => theme.color.gray2}; + padding: 2.4rem; + text-align: left; +` + +type ConfigurationModalProps = { + open?: boolean + onOpenChange?: (open: boolean) => void +} + +const getProviderName = (provider: Provider | null) => { + if (!provider) return "" + return provider === "openai" ? "OpenAI" : "Anthropic" +} + +type StepOneContentProps = { + selectedProvider: Provider | null + apiKey: string + error: string | null + providerName: string + onProviderSelect: (provider: Provider) => void + onApiKeyChange: (value: string) => void +} + +type StepTwoContentProps = { + selectedProvider: Provider | null + enabledModels: string[] + grantSchemaAccess: boolean + modelsByProvider: { anthropic: ModelOption[]; openai: ModelOption[] } + onModelToggle: (modelValue: string) => void + onSchemaAccessChange: (checked: boolean) => void +} + +const CloseButton = ({ onClick }: { onClick: () => void }) => { + return ( + + + + + + ) +} + +const StepOneContent = ({ + selectedProvider, + apiKey, + error, + providerName, + onProviderSelect, + onApiKeyChange, +}: StepOneContentProps) => { + const navigation = useModalNavigation() + const handleClose: () => void = navigation.handleClose + + return ( + + + + + Add a model provider + + Select an AI model provider and enter your API key. You'll be + able to configure and switch between multiple providers later. + + + + + + + + + + Select Provider + + We currently only support two model providers, with support for + more coming soon. + + + + + onProviderSelect("openai")} + type="button" + > + + OpenAI + + onProviderSelect("anthropic")} + type="button" + > + + Anthropic + + + + + + + Coming soon... + + + + + + + + API Key + onApiKeyChange(e.target.value)} + placeholder={`Enter${providerName ? ` ${providerName}` : ""} API key`} + $hasError={!!error} + disabled={!selectedProvider} + /> + {error && {error}} + + Stored locally in your browser and never sent to QuestDB servers. + This API key is used to authenticate your requests to the model + provider. + + + + + ) +} + +const StepTwoContent = ({ + selectedProvider, + enabledModels, + grantSchemaAccess, + modelsByProvider, + onModelToggle, + onSchemaAccessChange, +}: StepTwoContentProps) => { + const navigation = useModalNavigation() + const handleClose: () => void = navigation.handleClose + const currentProvider = selectedProvider + + const getModelsForProvider = (provider: Provider) => { + return provider === "openai" + ? modelsByProvider.openai + : modelsByProvider.anthropic + } + + return ( + + + + + Setup your model preferences + + Enable and disable each of the models QuestDB currently supports + from this provider, and a level of data access. You'll be + able to update these settings any time. + + + + + + + + {currentProvider ? ( + + + + Enable Models + + {currentProvider === "openai" ? ( + + ) : ( + + )} + + {getProviderName(currentProvider)} + + + + + {getModelsForProvider(currentProvider).map((model) => { + const isEnabled = enabledModels.includes(model.value) + return ( + + + {model.label} + {model.isSlow && ( + + + + Due to advanced reasoning & thinking + capabilities, responses using this model can be + slow. + + + )} + + onModelToggle(model.value)} + /> + + ) + })} + + + + ) : ( + + Please configure at least one provider in step 1 before enabling + models. + + )} + + + + {currentProvider && ( + + + Schema Access + + + + + onSchemaAccessChange(e.target.checked)} + /> + + + + Grant schema access to {getProviderName(currentProvider)} + + + 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.{" "} + + The AI model will not have access to your database store. + + + + + + + )} + + + The AI assistant may occasionally produce incorrect information. Please + verify important details and review all generated queries before + execution. + + + ) +} + +export const ConfigurationModal = ({ + open, + onOpenChange, +}: ConfigurationModalProps) => { + const { aiAssistantSettings, updateSettings } = useLocalStorage() + const [selectedProvider, setSelectedProvider] = useState( + null, + ) + const providerName = useMemo( + () => getProviderName(selectedProvider), + [selectedProvider], + ) + const [apiKey, setApiKey] = useState("") + const [error, setError] = useState(null) + + const [enabledModels, setEnabledModels] = useState([]) + const [grantSchemaAccess, setGrantSchemaAccess] = useState(true) + + const modelsByProvider = useMemo(() => { + const anthropic: ModelOption[] = [] + const openai: ModelOption[] = [] + MODEL_OPTIONS.forEach((model) => { + if (model.provider === "anthropic") { + anthropic.push(model) + } else { + openai.push(model) + } + }) + return { anthropic, openai } + }, []) + + const handleProviderSelect = useCallback((provider: Provider) => { + setSelectedProvider(provider) + setError(null) + setApiKey("") + }, []) + + const handleApiKeyChange = useCallback((value: string) => { + setApiKey(value) + setError(null) + }, []) + + const handleModelToggle = useCallback((modelValue: string) => { + setEnabledModels((prev) => { + const isEnabled = prev.includes(modelValue) + return isEnabled + ? prev.filter((m) => m !== modelValue) + : [...prev, modelValue] + }) + }, []) + + const handleSchemaAccessChange = useCallback((checked: boolean) => { + setGrantSchemaAccess(checked) + }, []) + + const handleComplete = () => { + if (!selectedProvider || enabledModels.length === 0) return + + const selectedModel = + enabledModels.find( + (m) => MODEL_OPTIONS.find((mo) => mo.value === m)?.default, + ) ?? enabledModels[0] + + const newSettings = { + ...aiAssistantSettings, + selectedModel, + providers: { + ...aiAssistantSettings.providers, + [selectedProvider]: { + apiKey, + enabledModels, + grantSchemaAccess, + }, + }, + } + + updateSettings(StoreKey.AI_ASSISTANT_SETTINGS, newSettings) + toast.success("AI Assistant activated successfully") + onOpenChange?.(false) + } + + const canProceed = (stepIndex: number): boolean => { + if (stepIndex === 0) { + if (!selectedProvider) return false + return !!apiKey + } + return true + } + + const validateStepOne = useCallback(async (): Promise => { + if (!selectedProvider) { + return "Please select a provider" + } + + if (!apiKey) { + return "Please enter an API key" + } + + const testModel = + MODEL_OPTIONS.find( + (m) => m.isTestModel && m.provider === selectedProvider, + )?.value ?? modelsByProvider[selectedProvider][0].value + + try { + const result = await testApiKey(apiKey, testModel) + if (!result.valid) { + const errorMsg = result.error || "Invalid API key" + setError(errorMsg) + return errorMsg + } + const defaultModels = MODEL_OPTIONS.filter( + (m) => m.defaultEnabled && m.provider === selectedProvider, + ).map((m) => m.value) + if (defaultModels.length > 0) { + setEnabledModels(defaultModels) + } + setError(null) + return true + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Failed to validate API key" + setError(errorMessage) + return errorMessage + } + }, [selectedProvider, apiKey, modelsByProvider]) + + const validateStepTwo = useCallback((): string | boolean => { + if (!selectedProvider) return "Please select a provider" + if (enabledModels.length === 0) { + return "Please enable at least one model" + } + return true + }, [enabledModels, selectedProvider]) + + const handleStepChange = useCallback( + (newStepIndex: number, direction: "next" | "previous") => { + // When going back from step 2 to step 1, reset step 2 state but keep API key + if (newStepIndex === 0 && direction === "previous") { + setEnabledModels([]) + setGrantSchemaAccess(true) + } + }, + [], + ) + + const handleModalClose = useCallback(() => { + setSelectedProvider(null) + setApiKey("") + setError(null) + setEnabledModels([]) + setGrantSchemaAccess(true) + }, []) + + const steps: Step[] = useMemo( + () => [ + { + id: "provider", + title: "Add a model provider", + stepName: "Add model provider", + content: ( + + ), + validate: validateStepOne, + }, + { + id: "models", + title: "Configure Models", + stepName: "Configure provider settings", + content: ( + + ), + validate: validateStepTwo, + }, + ], + [ + selectedProvider, + apiKey, + error, + providerName, + handleProviderSelect, + handleApiKeyChange, + enabledModels, + grantSchemaAccess, + modelsByProvider, + handleModelToggle, + handleSchemaAccessChange, + validateStepOne, + validateStepTwo, + ], + ) + + return ( + { + if (!isOpen) { + handleModalClose() + } + onOpenChange?.(isOpen) + }} + onStepChange={handleStepChange} + steps={steps} + maxWidth="64rem" + onComplete={handleComplete} + canProceed={canProceed} + completeButtonText="Activate Assistant" + showValidationError={false} + /> + ) +} diff --git a/src/components/SetupAIAssistant/ModelDropdown.tsx b/src/components/SetupAIAssistant/ModelDropdown.tsx new file mode 100644 index 000000000..f8c2a20a7 --- /dev/null +++ b/src/components/SetupAIAssistant/ModelDropdown.tsx @@ -0,0 +1,272 @@ +import React, { useMemo, useState } from "react" +import styled, { css } from "styled-components" +import { Check } from "@styled-icons/remix-line" +import { Error as ErrorIcon } from "@styled-icons/boxicons-regular" +import { PopperToggle } from "../PopperToggle" +import { Box } from "../Box" +import { Text } from "../Text" +import { useLocalStorage } from "../../providers/LocalStorageProvider" +import { MODEL_OPTIONS } from "../../utils/aiAssistantSettings" +import { useAIStatus } from "../../providers/AIStatusProvider" +import { StoreKey } from "../../utils/localStorage/types" +import { OpenAIIcon } from "./OpenAIIcon" +import { AnthropicIcon } from "./AnthropicIcon" +import { BrainIcon } from "./BrainIcon" +import { PopperHover } from "../PopperHover" +import { Tooltip } from "../Tooltip" + +const ExpandUpDown = () => ( + + + +) + +const DropdownTrigger = styled.button<{ disabled?: boolean }>` + display: flex; + align-items: center; + gap: 0.8rem; + padding: 0.75rem 1rem; + background: ${({ theme }) => theme.color.background}; + border-radius: 0.4rem; + border: none; + color: ${({ theme }) => theme.color.gray2}; + font-size: 1.2rem; + white-space: nowrap; + height: 3rem; + min-width: 17rem; + justify-content: space-between; + cursor: pointer; + + ${({ disabled }) => + disabled && + css` + cursor: not-allowed; + gap: 0.5rem; + min-width: unset; + `} + + &:focus-visible { + outline: none; + } + &:focus { + outline: none; + } + + &:hover { + background: ${({ theme }) => theme.color.comment}; + color: ${({ theme }) => theme.color.foreground}; + } + + ${({ disabled }) => + disabled && + css` + &:hover { + background: ${({ theme }) => theme.color.background}; + color: ${({ theme }) => theme.color.gray2}; + } + `} + + > * { + color: inherit; + } +` + +const DropdownIcon = styled(ExpandUpDown)` + width: 1.6rem; + height: 1.6rem; + flex-shrink: 0; + color: inherit; +` + +const DropdownContent = styled.div` + display: flex; + flex-direction: column; + background: ${({ theme }) => theme.color.backgroundDarker}; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 0.6rem; + padding: 1.2rem; + box-shadow: 0 0.5rem 0.5rem 0 ${({ theme }) => theme.color.black40}; + min-width: 22.8rem; + gap: 0.4rem; + z-index: 9999; +` + +const Title = styled(Text)` + font-size: 1.3rem; + color: ${({ theme }) => theme.color.gray2}; + margin: 0; + margin-bottom: 0.4rem; +` + +const ModelItem = styled.div<{ $selected?: boolean }>` + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.8rem; + border-radius: 0.4rem; + cursor: pointer; + background: ${({ theme }) => theme.color.backgroundDarker}; + color: ${({ theme }) => theme.color.gray2}; + font-size: 1.2rem; + line-height: 1.5; + position: relative; + margin: 0; + box-shadow: inset 0px 1px 4px 0px rgba(0, 0, 0, 0.1); + border: none; + width: 100%; + + ${({ $selected }) => + $selected && + css` + background: ${({ theme }) => theme.color.background}; + color: ${({ theme }) => theme.color.foreground}; + `} + + &:hover { + background: ${({ theme }) => theme.color.background}; + color: ${({ theme }) => theme.color.foreground}; + } +` + +const ModelIconTitle = styled(Box)` + flex: 1; + gap: 0.6rem; + align-items: center; +` + +const ModelLabel = styled(Text)` + font-size: 1.2rem; + color: inherit; +` + +const CheckIcon = styled(Check)` + width: 1.8rem; + height: 1.8rem; + color: ${({ theme }) => theme.color.green}; + flex-shrink: 0; +` + +export const ModelDropdown = () => { + const { aiAssistantSettings, updateSettings } = useLocalStorage() + const { + isConfigured, + models: enabledModelValues, + currentModel, + } = useAIStatus() + const [dropdownActive, setDropdownActive] = useState(false) + + const enabledModels = useMemo(() => { + return MODEL_OPTIONS.filter((model) => + enabledModelValues.includes(model.value), + ) + }, [enabledModelValues]) + + const handleModelSelect = (modelValue: string) => { + updateSettings(StoreKey.AI_ASSISTANT_SETTINGS, { + ...aiAssistantSettings, + selectedModel: modelValue, + }) + setDropdownActive(false) + } + + if (!isConfigured) { + return null + } + + // currentModel is guaranteed to be from MODEL_OPTIONS (set in modals) + const displayModel = currentModel + ? (enabledModels.find((m) => m.value === currentModel) ?? enabledModels[0]) + : (enabledModels[0] ?? null) + + if (!displayModel) { + return ( + + } + placement="bottom" + modifiers={[ + { + name: "offset", + options: { + offset: [0, 8], + }, + }, + ]} + > + + + You can enable models in the AI Assistant settings + + + + + No models enabled + + + ) + } + + return ( + + {displayModel.provider === "openai" ? ( + + ) : ( + + )} + + {displayModel.label} + + + + } + > + + Select Model + {enabledModels.map((model) => { + const isSelected = currentModel === model.value + + return ( + handleModelSelect(model.value)} + $selected={isSelected} + > + + {model.provider === "openai" ? ( + + ) : ( + + )} + {model.label} + {model.isSlow && } + + {isSelected && } + + ) + })} + + + ) +} diff --git a/src/components/SetupAIAssistant/OpenAIIcon.tsx b/src/components/SetupAIAssistant/OpenAIIcon.tsx new file mode 100644 index 000000000..87fba651e --- /dev/null +++ b/src/components/SetupAIAssistant/OpenAIIcon.tsx @@ -0,0 +1,25 @@ +import React from "react" + +export const OpenAIIcon = (props: React.SVGProps) => { + return ( + + + + + + + + + + + ) +} diff --git a/src/components/SetupAIAssistant/SettingsModal.tsx b/src/components/SetupAIAssistant/SettingsModal.tsx new file mode 100644 index 000000000..39a96e450 --- /dev/null +++ b/src/components/SetupAIAssistant/SettingsModal.tsx @@ -0,0 +1,1055 @@ +import React, { useState, useCallback, useMemo, useRef } from "react" +import styled from "styled-components" +import * as RadixDialog from "@radix-ui/react-dialog" +import { Dialog } from "../Dialog" +import { Box } from "../Box" +import { Input } from "../Input" +import { Switch } from "../Switch" +import { Checkbox } from "../Checkbox" +import { Text } from "../Text" +import { Button } from "../Button" +import { useLocalStorage } from "../../providers/LocalStorageProvider" +import { testApiKey } from "../../utils/aiAssistant" +import { StoreKey } from "../../utils/localStorage/types" +import { toast } from "../Toast" +import { Edit } from "@styled-icons/remix-line" +import { OpenAIIcon } from "./OpenAIIcon" +import { AnthropicIcon } from "./AnthropicIcon" +import { BrainIcon } from "./BrainIcon" +import { LoadingSpinner } from "../LoadingSpinner" +import { Overlay } from "../Overlay" +import { + getAllProviders, + MODEL_OPTIONS, + type ModelOption, + type Provider, + getNextModel, +} from "../../utils/aiAssistantSettings" +import type { AiAssistantSettings } from "../../providers/LocalStorageProvider/types" +import { ForwardRef } from "../ForwardRef" +import { Badge, BadgeType } from "../../components/Badge" +import { CheckboxCircle } from "@styled-icons/remix-fill" + +const ModalContent = styled.div` + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 0; +` + +const StyledContent = styled(Dialog.Content).attrs({ + maxwidth: "72rem", +})` + display: flex; + flex-direction: column; + max-height: 85vh; + overflow: hidden; +` + +const HeaderSection = styled(Box).attrs({ + flexDirection: "column", + gap: "1.6rem", +})` + padding: 2.4rem; + width: 100%; + flex-shrink: 0; +` + +const HeaderTitleRow = styled(Box).attrs({ + justifyContent: "space-between", + align: "flex-start", + gap: "1rem", +})` + width: 100%; +` + +const HeaderText = styled(Box).attrs({ + flexDirection: "column", + gap: "1.2rem", + align: "flex-start", +})` + flex: 1; +` + +const ModalTitle = styled(Dialog.Title)` + font-size: 2.4rem; + font-weight: 600; + margin: 0; + padding: 0; + color: ${({ theme }) => theme.color.foreground}; + border: 0; +` + +const ModalSubtitle = styled(Dialog.Description)` + color: ${({ theme }) => theme.color.gray2}; + margin: 0; + padding: 0; +` + +const CloseButton = styled.button` + background: transparent; + border: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + color: ${({ theme }) => theme.color.gray1}; + border-radius: 0.4rem; + flex-shrink: 0; + width: 2.2rem; + height: 2.2rem; + + &:hover { + color: ${({ theme }) => theme.color.foreground}; + } +` + +const Separator = styled.div` + height: 0.1rem; + width: 100%; + background: ${({ theme }) => theme.color.selection}; +` + +const MainContentArea = styled(Box)` + display: flex; + flex-direction: row; + width: 100%; + align-items: stretch; + min-height: 0; + flex: 1; + gap: 0; + overflow: hidden; +` + +const Sidebar = styled(Box).attrs({ + flexDirection: "column", + gap: "1.2rem", +})` + padding: 0; + padding-top: 2.4rem; + width: 15.1rem; + flex-shrink: 0; + overflow-y: auto; +` + +const ProviderTab = styled.button<{ $active: boolean }>` + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.2rem 2.4rem; + background: ${({ $active, theme }) => + $active ? theme.color.midnight : "transparent"}; + border: none; + border-bottom: ${({ $active, theme }) => + $active ? "0.2rem solid " + theme.color.pinkPrimary : "none"}; + cursor: pointer; + align-items: flex-start; + width: 100%; + + &:hover { + background: ${({ $active, theme }) => + $active ? theme.color.midnight : theme.color.selection}; + } +` + +const ProviderTabTitle = styled(Box).attrs({ + gap: "0.6rem", + align: "center", +})` + width: 100%; +` + +const ProviderTabName = styled(Text)<{ $active: boolean }>` + font-size: 1.6rem; + font-weight: ${({ $active }) => ($active ? 600 : 400)}; + color: ${({ theme, $active }) => + $active ? theme.color.foreground : theme.color.gray2}; +` + +const StatusBadge = styled(Box).attrs({ + gap: "0.4rem", + align: "center", +})<{ $enabled: boolean }>` + background: ${({ $enabled }) => ($enabled ? "transparent" : "#2d303e")}; + padding: 0.3rem; + border-radius: 0.2rem; +` + +const StatusDot = styled.div<{ $enabled: boolean }>` + width: 0.6rem; + height: 0.6rem; + border-radius: 50%; + background: ${({ $enabled, theme }) => + $enabled ? theme.color.green : theme.color.gray2}; +` + +const StatusText = styled(Text)<{ $enabled: boolean }>` + font-size: 1rem; + font-weight: 400; + color: ${({ $enabled, theme }) => ($enabled ? theme.color.green : "#bbbbbb")}; +` + +const VerticalSeparator = styled.div` + width: 0.1rem; + background: ${({ theme }) => theme.color.selection}; + flex-shrink: 0; + align-self: stretch; +` + +const ContentPanel = styled(Box).attrs({ + flexDirection: "column", + gap: "2.8rem", +})` + flex: 1; + padding: 2.4rem; + min-width: 0; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; +` + +const ContentSection = styled(Box).attrs({ + flexDirection: "column", + gap: "1.2rem", + align: "stretch", +})` + width: 100%; +` + +const SectionTitle = styled(Text)` + font-size: 1.6rem; + font-weight: 600; + color: ${({ theme }) => theme.color.foreground}; +` + +const SectionDescription = styled(Text)` + font-size: 1.3rem; + color: ${({ theme }) => theme.color.gray2}; +` + +const InputWrapper = styled(Box)` + position: relative; + width: 100%; +` + +const StyledInput = styled(Input)<{ + $hasError?: boolean + $showEditButton?: boolean +}>` + width: 100%; + background: ${({ theme }) => theme.color.background}; + border: 0.1rem solid + ${({ theme, $hasError }) => ($hasError ? theme.color.red : "#6b7280")}; + border-radius: 0.8rem; + padding: 1.2rem; + padding-right: ${({ $showEditButton }) => + $showEditButton ? "4rem" : "1.2rem"}; + color: ${({ theme }) => theme.color.foreground}; + font-size: 1.4rem; + + &::placeholder { + color: ${({ theme }) => theme.color.gray2}; + font-family: inherit; + } +` + +const EditButton = styled.button` + position: absolute; + right: 1.2rem; + top: 50%; + transform: translateY(-50%); + background: transparent; + border: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + color: ${({ theme }) => theme.color.gray1}; + width: 2rem; + height: 2rem; + + &:hover { + color: ${({ theme }) => theme.color.foreground}; + } +` + +const ValidatedBadge = styled(Badge).attrs({ + type: BadgeType.SUCCESS, +})` + font-size: 1rem; + margin-right: auto; + padding: 0.3rem 0.6rem; + height: 2rem; + border: 0; +` + +const APIKeyLink = styled.a` + color: ${({ theme }) => theme.color.gray2}; + + &:hover { + text-decoration: underline; + color: ${({ theme }) => theme.color.foreground}; + } +` + +const ErrorText = styled(Text)` + color: ${({ theme }) => theme.color.red}; + font-size: 1.3rem; +` + +const ValidateRemoveButton = styled.button` + height: 3rem; + border: 0.1rem solid ${({ theme }) => theme.color.pinkDarker}; + background: ${({ theme }) => theme.color.background}; + color: ${({ theme }) => theme.color.foreground}; + border-radius: 0.4rem; + padding: 0.6rem 1.2rem; + font-size: 1.4rem; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 0.8rem; + + &:hover:not(:disabled) { + background: ${({ theme }) => theme.color.pinkDarker}; + color: ${({ theme }) => theme.color.foreground}; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +` + +const ModelsPlaceholder = styled(Box).attrs({ + flexDirection: "column", + gap: "1rem", +})` + background: rgba(68, 71, 90, 0.56); + padding: 0.75rem; + border-radius: 0.4rem; + width: 100%; +` + +const ModelsPlaceholderText = styled(Text)` + font-size: 1.3rem; + color: ${({ theme }) => theme.color.gray2}; +` + +const ModelList = styled(Box).attrs({ flexDirection: "column", gap: "1.6rem" })` + width: 100%; +` + +const ModelToggleRow = styled(Box).attrs({ + justifyContent: "space-between", + align: "center", + gap: "2.4rem", +})` + width: 100%; +` + +const ModelInfoColumn = styled(Box).attrs({ + flexDirection: "column", + gap: "0.8rem", +})` + flex: 1; + align-items: flex-start; +` + +const ModelInfoRow = styled(Box).attrs({ + gap: "0.8rem", + align: "center", +})` + width: 100%; +` + +const ModelDescriptionText = styled(Text)` + font-size: 1.1rem; + color: ${({ theme }) => theme.color.gray2}; + flex: 1; +` + +const ModelNameText = styled(Text)` + font-size: 1.4rem; + font-weight: 400; + color: ${({ theme }) => theme.color.foreground}; +` + +const EnableModelsTitle = styled(Text)` + font-size: 1.6rem; + font-weight: 600; + color: ${({ theme }) => theme.color.foreground}; +` + +const SchemaAccessSection = styled(Box).attrs({ + flexDirection: "column", + gap: "1.6rem", +})` + width: 100%; +` + +const SchemaAccessHeader = styled(Box).attrs({ + justifyContent: "space-between", + align: "center", + gap: "1rem", +})` + width: 100%; +` + +const SchemaAccessTitle = styled(Text)` + font-size: 1.6rem; + font-weight: 600; + color: ${({ theme }) => theme.color.foreground}; + flex: 1; +` + +const SchemaCheckboxContainer = styled(Box).attrs({ + gap: "1.5rem", + align: "flex-start", +})` + background: rgba(68, 71, 90, 0.56); + padding: 0.75rem; + border-radius: 0.4rem; + width: 100%; +` + +const SchemaCheckboxInner = styled(Box).attrs({ + gap: "1.5rem", + align: "center", +})` + flex: 1; + padding: 0.75rem; + border-radius: 0.5rem; +` + +const SchemaCheckboxWrapper = styled.div` + flex-shrink: 0; + display: flex; + align-items: center; +` + +const SchemaCheckboxContent = styled(Box).attrs({ + flexDirection: "column", + gap: "0.6rem", +})` + flex: 1; +` + +const SchemaCheckboxLabel = styled(Text)` + font-size: 1.4rem; + font-weight: 500; + color: ${({ theme }) => theme.color.foreground}; +` + +const SchemaCheckboxDescription = styled(Text)` + font-size: 1.3rem; + font-weight: 400; + color: ${({ theme }) => theme.color.gray2}; +` + +const SchemaCheckboxDescriptionBold = styled.span` + font-weight: 500; + color: ${({ theme }) => theme.color.foreground}; +` + +const FooterSection = styled(Box).attrs({ + flexDirection: "column", + gap: "2rem", +})` + padding: 2.4rem 2.4rem 0.4rem 2.4rem; + width: 100%; + flex-shrink: 0; +` + +const FooterButtons = styled(Box).attrs({ + justifyContent: "flex-end", + align: "center", + gap: "1.6rem", +})` + width: 100%; +` + +const CancelButton = styled(Button)` + flex: 1; + padding: 1.1rem 1.2rem; + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 1.4rem; + font-weight: 500; + width: 100%; + height: 4rem; +` + +const SaveButton = styled(Button)` + padding: 1.1rem 1.2rem; + font-size: 1.4rem; + font-weight: 500; + flex: 1; + height: 4rem; + width: 100%; +` + +type SettingsModalProps = { + open?: boolean + onOpenChange?: (open: boolean) => void +} + +const getProviderName = (provider: Provider) => { + return provider === "openai" ? "OpenAI" : "Anthropic" +} + +const getModelsForProvider = (provider: Provider): ModelOption[] => { + return MODEL_OPTIONS.filter((m) => m.provider === provider) +} + +const getProvidersWithApiKeys = (settings: AiAssistantSettings): Provider[] => { + const providers: Provider[] = [] + const allProviders = getAllProviders() + for (const provider of allProviders) { + if (settings.providers?.[provider]?.apiKey) { + providers.push(provider) + } + } + return providers +} + +export const SettingsModal = ({ open, onOpenChange }: SettingsModalProps) => { + const { aiAssistantSettings, updateSettings } = useLocalStorage() + const initializeProviderState = useCallback( + ( + getValue: (provider: Provider) => T, + defaultValue: T, + ): Record => { + const allProviders = getAllProviders() + const state = {} as Record + for (const provider of allProviders) { + state[provider] = getValue(provider) ?? defaultValue + } + return state + }, + [], + ) + + const [selectedProvider, setSelectedProvider] = useState(() => { + const providersWithKeys = getProvidersWithApiKeys(aiAssistantSettings) + return providersWithKeys[0] || getAllProviders()[0] + }) + const [apiKeys, setApiKeys] = useState>(() => + initializeProviderState( + (provider) => aiAssistantSettings.providers?.[provider]?.apiKey || "", + "", + ), + ) + const [enabledModels, setEnabledModels] = useState< + Record + >(() => + initializeProviderState( + (provider) => + aiAssistantSettings.providers?.[provider]?.enabledModels || [], + [], + ), + ) + const [grantSchemaAccess, setGrantSchemaAccess] = useState< + Record + >(() => + initializeProviderState( + (provider) => + aiAssistantSettings.providers?.[provider]?.grantSchemaAccess !== false, + true, + ), + ) + const [validatedApiKeys, setValidatedApiKeys] = useState< + Record + >(() => + initializeProviderState( + (provider) => !!aiAssistantSettings.providers?.[provider]?.apiKey, + false, + ), + ) + const [validationState, setValidationState] = useState< + Record + >(() => initializeProviderState(() => "idle" as const, "idle" as const)) + const [validationErrors, setValidationErrors] = useState< + Record + >(() => initializeProviderState(() => null, null)) + const [isInputFocused, setIsInputFocused] = useState< + Record + >(() => initializeProviderState(() => false, false)) + const inputRef = useRef(null) + + const handleProviderSelect = useCallback((provider: Provider) => { + setSelectedProvider(provider) + setValidationErrors((prev) => ({ ...prev, [provider]: null })) + }, []) + + const handleApiKeyChange = useCallback( + (provider: Provider, value: string) => { + setApiKeys((prev) => ({ ...prev, [provider]: value })) + setValidationErrors((prev) => ({ ...prev, [provider]: null })) + // If API key changes, mark as not validated + if (validatedApiKeys[provider]) { + setValidatedApiKeys((prev) => ({ ...prev, [provider]: false })) + } + }, + [validatedApiKeys], + ) + + const handleValidateApiKey = useCallback( + async (provider: Provider) => { + const apiKey = apiKeys[provider] + if (!apiKey) { + setValidationErrors((prev) => ({ + ...prev, + [provider]: "Please enter an API key", + })) + return + } + + setValidationState((prev) => ({ ...prev, [provider]: "validating" })) + setValidationErrors((prev) => ({ ...prev, [provider]: null })) + + const providerModels = getModelsForProvider(provider) + if (providerModels.length === 0) { + setValidationState((prev) => ({ ...prev, [provider]: "error" })) + setValidationErrors((prev) => ({ + ...prev, + [provider]: "No models available for this provider", + })) + return + } + + const testModel = ( + providerModels.find((m) => m.isTestModel) ?? providerModels[0] + ).value + try { + const result = await testApiKey(apiKey, testModel) + if (!result.valid) { + setValidationState((prev) => ({ ...prev, [provider]: "error" })) + setValidationErrors((prev) => ({ + ...prev, + [provider]: result.error || "Invalid API key", + })) + } else { + const defaultModels = MODEL_OPTIONS.filter( + (m) => m.defaultEnabled && m.provider === provider, + ).map((m) => m.value) + if (defaultModels.length > 0) { + setEnabledModels((prev) => ({ ...prev, [provider]: defaultModels })) + } + setValidationState((prev) => ({ ...prev, [provider]: "validated" })) + setValidatedApiKeys((prev) => ({ ...prev, [provider]: true })) + setValidationErrors((prev) => ({ ...prev, [provider]: null })) + } + } catch (err) { + setValidationState((prev) => ({ ...prev, [provider]: "error" })) + const errorMessage = + err instanceof Error ? err.message : "Failed to validate API key" + setValidationErrors((prev) => ({ ...prev, [provider]: errorMessage })) + } + }, + [apiKeys], + ) + + const handleRemoveApiKey = useCallback((provider: Provider) => { + // Remove API key from local state only + // Settings will be persisted when Save Settings is clicked + setApiKeys((prev) => ({ ...prev, [provider]: "" })) + setValidatedApiKeys((prev) => ({ ...prev, [provider]: false })) + setValidationState((prev) => ({ ...prev, [provider]: "idle" })) + setValidationErrors((prev) => ({ ...prev, [provider]: null })) + setIsInputFocused((prev) => ({ ...prev, [provider]: false })) + }, []) + + const handleModelToggle = useCallback( + (provider: Provider, modelValue: string) => { + setEnabledModels((prev) => { + const current = prev[provider] + const isEnabled = current.includes(modelValue) + return { + ...prev, + [provider]: isEnabled + ? current.filter((m) => m !== modelValue) + : [...current, modelValue], + } + }) + }, + [], + ) + + const handleSchemaAccessChange = useCallback( + (provider: Provider, checked: boolean) => { + setGrantSchemaAccess((prev) => ({ ...prev, [provider]: checked })) + }, + [], + ) + + const handleSave = useCallback(() => { + const updatedProviders = { ...aiAssistantSettings.providers } + const allProviders = getAllProviders() + + for (const provider of allProviders) { + if (validatedApiKeys[provider]) { + // Only save providers with validated API keys + updatedProviders[provider] = { + apiKey: apiKeys[provider], + enabledModels: enabledModels[provider], + grantSchemaAccess: grantSchemaAccess[provider], + } + } else { + // Remove provider entry if no validated API key + delete updatedProviders[provider] + } + } + + const updatedSettings: AiAssistantSettings = { + ...aiAssistantSettings, + providers: updatedProviders, + } + + const nextModel = getNextModel(updatedSettings.selectedModel, enabledModels) + updatedSettings.selectedModel = nextModel || undefined + + updateSettings(StoreKey.AI_ASSISTANT_SETTINGS, updatedSettings) + toast.success("Settings saved successfully") + onOpenChange?.(false) + }, [ + aiAssistantSettings, + apiKeys, + enabledModels, + grantSchemaAccess, + validatedApiKeys, + updateSettings, + onOpenChange, + ]) + + const handleClose = useCallback(() => { + onOpenChange?.(false) + }, [onOpenChange]) + + const currentProviderValidated = validatedApiKeys[selectedProvider] + const currentProviderApiKey = apiKeys[selectedProvider] + const currentProviderValidationState = validationState[selectedProvider] + const currentProviderError = validationErrors[selectedProvider] + const currentProviderIsFocused = isInputFocused[selectedProvider] + const maskInput = !!(currentProviderApiKey && !currentProviderIsFocused) + + const modelsForProvider = useMemo( + () => getModelsForProvider(selectedProvider), + [selectedProvider], + ) + + const enabledModelsForProvider = useMemo( + () => enabledModels[selectedProvider], + [enabledModels, selectedProvider], + ) + + const allProviders = useMemo(() => getAllProviders(), []) + + const renderProviderIcon = (provider: Provider, isActive: boolean) => { + const color = isActive ? "#f8f8f2" : "#9ca3af" + if (provider === "openai") { + return + } + return + } + + return ( + + + + + + + + + + + Assistant Settings + + Modify settings for your AI assistant, set up new providers, + and review access. + + + + + + + + + + + + + {allProviders.map((provider) => { + const isActive = selectedProvider === provider + return ( + handleProviderSelect(provider)} + > + + {renderProviderIcon(provider, isActive)} + + {getProviderName(provider)} + + + + + + {validatedApiKeys[provider] ? "Enabled" : "Inactive"} + + + + ) + })} + + + + + + + API Key + {validatedApiKeys[selectedProvider] && ( + }> + Validated + + )} + + Get your API key from{" "} + + {getProviderName(selectedProvider)} + + . + + + + { + handleApiKeyChange(selectedProvider, e.target.value) + }} + placeholder={`Enter ${getProviderName(selectedProvider)} API key`} + $hasError={!!currentProviderError} + $showEditButton={maskInput} + readOnly={maskInput} + onFocus={() => { + setIsInputFocused((prev) => ({ + ...prev, + [selectedProvider]: true, + })) + }} + onBlur={() => { + setIsInputFocused((prev) => ({ + ...prev, + [selectedProvider]: false, + })) + if (inputRef.current) { + inputRef.current.blur() + } + }} + onMouseDown={(e) => { + if (maskInput) { + e.preventDefault() + } + }} + tabIndex={maskInput ? -1 : 0} + style={{ + cursor: maskInput ? "default" : "text", + }} + /> + {maskInput && ( + { + inputRef.current?.focus() + }} + title="Edit API key" + > + + + )} + + {currentProviderError && ( + {currentProviderError} + )} + {!currentProviderError && ( + + Stored locally in your browser and never sent to QuestDB + servers. This API key is used to authenticate your + requests to the model provider. + + )} + + currentProviderValidated + ? handleRemoveApiKey(selectedProvider) + : handleValidateApiKey(selectedProvider) + } + disabled={ + currentProviderValidationState === "validating" || + (!currentProviderValidated && !currentProviderApiKey) + } + > + {currentProviderValidationState === "validating" ? ( + + + Validating... + + ) : currentProviderValidated ? ( + "Remove API Key" + ) : ( + "Validate API Key" + )} + + + + + + Enable Models + {currentProviderValidated ? ( + + {modelsForProvider.map((model) => { + const isEnabled = enabledModelsForProvider.includes( + model.value, + ) + return ( + + + {model.label} + {model.isSlow && ( + + + + Due to advanced reasoning & thinking + capabilities, responses using this model + can be slow. + + + )} + + + handleModelToggle( + selectedProvider, + model.value, + ) + } + /> + + ) + })} + + ) : ( + + + When you've entered and validated your API key, + you'll be able to select and enable available + models. + + + )} + + + + + + Schema Access + + + + + + handleSchemaAccessChange( + selectedProvider, + e.target.checked, + ) + } + disabled={!currentProviderValidated} + /> + + + + Grant schema access to{" "} + {getProviderName(selectedProvider)} + + + 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.{" "} + + The AI model will not have access to your data. + + + + + + + + + + + + + + Cancel + + + Save Settings + + + + + + + + ) +} diff --git a/src/components/SetupAIAssistant/index.tsx b/src/components/SetupAIAssistant/index.tsx new file mode 100644 index 000000000..9f53caf2f --- /dev/null +++ b/src/components/SetupAIAssistant/index.tsx @@ -0,0 +1,83 @@ +import React, { useState, useRef } from "react" +import styled from "styled-components" +import { Button } from "../Button" +import { Box } from "../Box" +import { AIAssistantPromo } from "./AIAssistantPromo" +import { ConfigurationModal } from "./ConfigurationModal" +import { SettingsModal } from "./SettingsModal" +import { ModelDropdown } from "./ModelDropdown" +import { useAIStatus } from "../../providers/AIStatusProvider" + +const SettingsButton = styled(Button)` + padding: 0.6rem; + + &:focus-visible { + outline: 2px solid ${({ theme }) => theme.color.cyan}; + } +` + +export const SetupAIAssistant = () => { + const [configModalOpen, setConfigModalOpen] = useState(false) + const [settingsModalOpen, setSettingsModalOpen] = useState(false) + const [showPromo, setShowPromo] = useState(false) + const configureButtonRef = useRef(null) + const { isConfigured } = useAIStatus() + + const handleSettingsClick = () => { + if (isConfigured) { + setSettingsModalOpen(true) + } else { + if (showPromo) { + setShowPromo(false) + setConfigModalOpen(true) + } else { + // First click: show promo + setShowPromo(true) + } + } + } + + return ( + <> + + +
}> + + } + data-hook="ai-assistant-settings-button" + title="AI Assistant Settings" + > + {isConfigured ? "Settings" : "Configure"} + +
+
+ { + setShowPromo(false) + setConfigModalOpen(true) + }} + /> + + {settingsModalOpen && ( + + )} + + ) +} diff --git a/src/components/Switch/index.tsx b/src/components/Switch/index.tsx index 51db66b28..79daac156 100644 --- a/src/components/Switch/index.tsx +++ b/src/components/Switch/index.tsx @@ -14,22 +14,24 @@ const Root = styled(SwitchPrimitive.Root)` display: inline-flex; align-items: center; justify-content: flex-start; - padding: 0 3px; - width: 38px; - height: 21px; - border-radius: 10px; - border: 1px solid #c4c4c9; + padding: 1px; + width: 36px; + height: 18px; + border-radius: 20px; + border: 0; background: transparent; appearance: none; position: relative; transition: 0.2s ease-out; + cursor: pointer; + background: ${({ theme }) => theme.color.selection}; &:focus { border-color: #878eb6; } &[data-state="checked"] { - background: #44475a; + background: ${({ theme }) => theme.color.greenDarker}; } &[data-disabled], @@ -40,16 +42,16 @@ const Root = styled(SwitchPrimitive.Root)` const StyledThumb = styled(SwitchPrimitive.Thumb)` display: block; - width: 14px; - height: 14px; - background-color: #d8d8d8; - border-radius: 50%; - transition: linear transform 100ms; + width: 16px; + height: 16px; + background-color: ${({ theme }) => theme.color.foreground}; + border-radius: 100%; + transition: transform 100ms linear; transform: translateX(0); will-change: transform; &[data-state="checked"] { - transform: translateX(17px); + transform: translateX(18px); } &[data-disabled] { diff --git a/src/components/Text/index.tsx b/src/components/Text/index.tsx index f3c750d67..e83d00a60 100644 --- a/src/components/Text/index.tsx +++ b/src/components/Text/index.tsx @@ -47,6 +47,8 @@ export type TextProps = Readonly<{ transform?: Transform type?: Type weight?: number + margin?: string + padding?: string }> const defaultProps: Readonly<{ @@ -71,6 +73,8 @@ export const textStyles = css` font-weight: ${({ weight }) => weight}; text-transform: ${({ transform }) => transform}; ${({ align }) => (align ? `text-align: ${align}` : "")}; + ${({ margin }) => margin && `margin: ${margin}`}; + ${({ padding }) => padding && `padding: ${padding}`}; ${({ ellipsis }) => ellipsis && ellipsisStyles}; ` diff --git a/src/components/TopBar/toolbar.tsx b/src/components/TopBar/toolbar.tsx index bde352848..cf61d493f 100644 --- a/src/components/TopBar/toolbar.tsx +++ b/src/components/TopBar/toolbar.tsx @@ -7,8 +7,8 @@ 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 "../Toast" import { Box, Button } from "../../components" -import { toast } from "../" import { Text } from "../Text" import { selectors } from "../../store" import { useSelector } from "react-redux" @@ -49,7 +49,7 @@ const CustomTooltipWrapper = styled.div<{ display: flex; flex-direction: column; padding: 1.5rem 0; - background: ${({ theme }) => theme.color.background}; + background: ${({ theme }) => theme.color.backgroundDarker}; font-size: 1.4rem; border-radius: 0.8rem; border: 1px solid ${({ $badgeColors }) => $badgeColors.primary}; diff --git a/src/components/index.ts b/src/components/index.ts index 2c63886e4..c86afc355 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -35,15 +35,19 @@ export * from "./Dialog" export * from "./Drawer" export * from "./DropdownMenu" export * from "./Emoji" +export * from "./ExplainQueryButton" export * from "./FeedbackDialog" export * from "./Form" export * from "./ForwardRef" +export * from "./GenerateSQLButton" export * from "./Heading" export * from "./IconWithTooltip" export * from "./Input" +export * from "./Key" export * from "./Link" export * from "./Loader" export * from "./LoadingSpinner" +export * from "./MultiStepModal" export * from "./Overlay" export * from "./PaneContent" export * from "./PaneMenu" @@ -52,6 +56,7 @@ export * from "./PopperHover" export * from "./PopperToggle" export * from "./Popover" export * from "./Select" +export * from "./SetupAIAssistant" export * from "./Switch" export * from "./Table" export * from "./Text" diff --git a/src/modules/EventBus/types.ts b/src/modules/EventBus/types.ts index 31325a18f..d82dbe34c 100644 --- a/src/modules/EventBus/types.ts +++ b/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/src/providers/AIStatusProvider/index.tsx b/src/providers/AIStatusProvider/index.tsx new file mode 100644 index 000000000..ce5fbd47e --- /dev/null +++ b/src/providers/AIStatusProvider/index.tsx @@ -0,0 +1,248 @@ +import React, { + createContext, + useCallback, + useContext, + useState, + useRef, + useEffect, + useMemo, +} from "react" +import { useEditor } from "../EditorProvider" +import { useLocalStorage } from "../LocalStorageProvider" +import { + isAiAssistantConfigured, + getSelectedModel, + hasSchemaAccess, + providerForModel, + canUseAiAssistant, +} from "../../utils/aiAssistantSettings" + +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 request", + RetrievingTables = "Reviewing tables", + InvestigatingTableSchema = "Investigating table schema", + RetrievingDocumentation = "Reviewing docs", + InvestigatingDocs = "Investigating docs", + ValidatingQuery = "Validating generated query", + FormattingResponse = "Formatting response", + Aborted = "Operation has been cancelled", +} + +export type StatusArgs = + | { type: "generate" } + | { type: "fix" } + | { type: "explain" } + | { name: string } + | { name: string; section: string } + | { items: Array<{ name: string; section?: string }> } + | null + +export type StatusEntry = { + type: AIOperationStatus + args?: StatusArgs +} + +export type OperationHistory = StatusEntry[] + +type BaseAIStatusContextType = { + status: AIOperationStatus | null + setStatus: (status: AIOperationStatus | null, args?: StatusArgs) => void + abortController: AbortController | null + abortOperation: () => void + hasSchemaAccess: boolean + models: string[] + currentOperation: OperationHistory +} + +export type AIStatusContextType = + | (BaseAIStatusContextType & { + isConfigured: true + canUse: boolean + currentModel: string + apiKey: string + }) + | (BaseAIStatusContextType & { + isConfigured: false + canUse: false + currentModel: string | null + apiKey: string | null + }) + +interface AIStatusProviderProps { + children: React.ReactNode +} + +export const AIStatusProvider: React.FC = ({ + children, +}) => { + const { editorRef } = useEditor() + const { aiAssistantSettings } = useLocalStorage() + const [status, setStatusState] = useState(null) + const [currentOperation, setCurrentOperation] = useState([]) + const [abortController, setAbortController] = useState( + new AbortController(), + ) + const abortControllerRef = useRef(null) + const statusRef = useRef(null) + const currentOperationRef = useRef([]) + const timeoutRef = useRef | null>(null) + const isConfigured = useMemo( + () => isAiAssistantConfigured(aiAssistantSettings), + [aiAssistantSettings], + ) + + const canUse = useMemo( + () => canUseAiAssistant(aiAssistantSettings), + [aiAssistantSettings], + ) + + const currentModel = useMemo( + () => getSelectedModel(aiAssistantSettings), + [aiAssistantSettings], + ) + + const hasSchemaAccessValue = useMemo( + () => hasSchemaAccess(aiAssistantSettings), + [aiAssistantSettings], + ) + + const apiKey = useMemo(() => { + if (!currentModel) return null + const provider = providerForModel(currentModel) + return aiAssistantSettings.providers?.[provider]?.apiKey || null + }, [currentModel, aiAssistantSettings]) + + const models = useMemo(() => { + const allModels: string[] = [] + const anthropicModels = + aiAssistantSettings.providers?.anthropic?.enabledModels || [] + const openaiModels = + aiAssistantSettings.providers?.openai?.enabledModels || [] + allModels.push(...anthropicModels, ...openaiModels) + return allModels + }, [aiAssistantSettings]) + + const setStatus = useCallback( + (newStatus: AIOperationStatus | null, args?: StatusArgs) => { + setStatusState(newStatus) + + if (newStatus === null) { + if (currentOperationRef.current.length > 0) { + currentOperationRef.current = [] + setCurrentOperation([]) + } + } else { + currentOperationRef.current.push({ + type: newStatus, + args: args || undefined, + }) + setCurrentOperation([...currentOperationRef.current]) + } + }, + [], + ) + + const abortOperation = useCallback(() => { + if ( + abortControllerRef.current && + statusRef.current !== null && + statusRef.current !== AIOperationStatus.Aborted + ) { + abortControllerRef.current?.abort() + setAbortController(new AbortController()) + setStatus(AIOperationStatus.Aborted) + editorRef.current?.updateOptions({ + readOnly: false, + readOnlyMessage: undefined, + }) + } + }, [status, editorRef, setStatus]) + + useEffect(() => { + if (status === AIOperationStatus.Aborted && timeoutRef.current === null) { + timeoutRef.current = setTimeout(() => { + currentOperationRef.current = [] + setCurrentOperation([]) + setStatus(null) + }, 3000) + } else if ( + status !== AIOperationStatus.Aborted && + timeoutRef.current !== null + ) { + currentOperationRef.current = [] + setCurrentOperation([]) + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + }, [status]) + + useEffect(() => { + abortControllerRef.current = abortController + }, [abortController]) + + useEffect(() => { + statusRef.current = status + }, [status]) + + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + } + }, []) + + const contextValue: AIStatusContextType = isConfigured + ? { + status, + setStatus, + abortController, + abortOperation, + isConfigured: true, + canUse, + hasSchemaAccess: hasSchemaAccessValue, + currentModel: currentModel!, + apiKey: apiKey!, + models, + currentOperation, + } + : { + status, + setStatus, + abortController, + abortOperation, + isConfigured: false, + canUse: false, + hasSchemaAccess: hasSchemaAccessValue, + currentModel, + apiKey, + models, + currentOperation, + } + + return ( + + {children} + + ) +} diff --git a/src/providers/EditorProvider/index.tsx b/src/providers/EditorProvider/index.tsx index ec041cf7f..8ae82bfa4 100644 --- a/src/providers/EditorProvider/index.tsx +++ b/src/providers/EditorProvider/index.tsx @@ -48,7 +48,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: ( @@ -346,9 +346,14 @@ export const EditorProvider: React.FC = ({ children }) => { }) } - 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/src/providers/LocalStorageProvider/index.tsx b/src/providers/LocalStorageProvider/index.tsx index 17040b2cf..317a6486b 100644 --- a/src/providers/LocalStorageProvider/index.tsx +++ b/src/providers/LocalStorageProvider/index.tsx @@ -27,12 +27,17 @@ import { getValue, setValue } from "../../utils/localStorage" import { StoreKey } from "../../utils/localStorage/types" import { parseInteger } from "./utils" import { + AiAssistantSettings, LocalConfig, SettingsType, LeftPanelState, LeftPanelType, } from "./types" +export const DEFAULT_AI_ASSISTANT_SETTINGS: AiAssistantSettings = { + providers: {}, +} + const defaultConfig: LocalConfig = { editorCol: 10, editorLine: 10, @@ -40,6 +45,7 @@ const defaultConfig: LocalConfig = { resultsSplitterBasis: 350, exampleQueriesVisited: false, autoRefreshTables: true, + aiAssistantSettings: DEFAULT_AI_ASSISTANT_SETTINGS, leftPanelState: { type: LeftPanelType.DATASOURCES, width: 350, @@ -56,6 +62,7 @@ type ContextProps = { autoRefreshTables: boolean leftPanelState: LeftPanelState updateLeftPanelState: (state: LeftPanelState) => void + aiAssistantSettings: AiAssistantSettings } const defaultValues: ContextProps = { @@ -68,6 +75,7 @@ const defaultValues: ContextProps = { autoRefreshTables: true, leftPanelState: defaultConfig.leftPanelState, updateLeftPanelState: (_state: LeftPanelState) => undefined, + aiAssistantSettings: defaultConfig.aiAssistantSettings, } export const LocalStorageContext = createContext(defaultValues) @@ -121,8 +129,32 @@ 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 { + selectedModel: parsed.selectedModel, + providers: parsed.providers || {}, + } + } 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 { + const typedValue = value as string | boolean | number + setValue(key, typedValue as string) + } refreshSettings(key) } @@ -156,6 +188,9 @@ export const LocalStorageProvider = ({ case StoreKey.AUTO_REFRESH_TABLES: setAutoRefreshTables(value === "true") break + case StoreKey.AI_ASSISTANT_SETTINGS: + setAiAssistantSettings(getAiAssistantSettings()) + break } } @@ -171,6 +206,7 @@ export const LocalStorageProvider = ({ autoRefreshTables, leftPanelState, updateLeftPanelState, + aiAssistantSettings, }} > {children} diff --git a/src/providers/LocalStorageProvider/types.ts b/src/providers/LocalStorageProvider/types.ts index dd1d4521b..99d494ae3 100644 --- a/src/providers/LocalStorageProvider/types.ts +++ b/src/providers/LocalStorageProvider/types.ts @@ -1,28 +1,18 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * 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 ProviderSettings = { + apiKey: string + enabledModels: string[] + grantSchemaAccess: boolean +} + +export type AiAssistantSettings = { + selectedModel?: string + providers: { + anthropic?: ProviderSettings + openai?: ProviderSettings + } +} -export type SettingsType = string | boolean | number +export type SettingsType = string | boolean | number | AiAssistantSettings export enum LeftPanelType { DATASOURCES = "datasources", @@ -42,4 +32,5 @@ export type LocalConfig = { exampleQueriesVisited: boolean autoRefreshTables: boolean leftPanelState: LeftPanelState + aiAssistantSettings: AiAssistantSettings } diff --git a/src/scenes/Editor/ButtonBar/FixQueryButton.tsx b/src/scenes/Editor/ButtonBar/FixQueryButton.tsx new file mode 100644 index 000000000..fc90ccb31 --- /dev/null +++ b/src/scenes/Editor/ButtonBar/FixQueryButton.tsx @@ -0,0 +1,285 @@ +import React, { useContext, MutableRefObject } from "react" +import { Button } from "../../../components" +import { useSelector } from "react-redux" +import { useEditor } from "../../../providers" +import type { + AiAssistantAPIError, + GeneratedSQL, +} from "../../../utils/aiAssistant" +import { + isAiAssistantError, + createModelToolsClient, + fixQuery, + type ActiveProviderSettings, +} from "../../../utils/aiAssistant" +import { providerForModel } from "../../../utils/aiAssistantSettings" +import { toast } from "../../../components/Toast" +import { QuestContext } from "../../../providers" +import { selectors } from "../../../store" +import { RunningType } from "../../../store/Query/types" +import { formatExplanationAsComment } from "../../../utils/aiAssistant" +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 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 { 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, + canUse, + hasSchemaAccess, + currentModel, + apiKey, + } = useAIStatus() + + if (!canUse) { + return null + } + + const handleFixQuery = async () => { + if (!editorRef.current || queriesToRun.length !== 1) return + const editorModel = editorRef.current.getModel() + if (!editorModel) return + + if (!canUse) { + toast.error("AI Assistant is not configured", { autoClose: 10000 }) + 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 = editorModel.getPositionAt(fixStart) + editorRef.current?.updateOptions({ + readOnly: true, + readOnlyMessage: { + value: "Query fix in progress", + }, + }) + const provider = providerForModel(currentModel) + + const settings: ActiveProviderSettings = { + model: currentModel, + provider, + apiKey, + } + + const response = await fixQuery({ + query: queryText, + errorMessage, + settings, + modelToolsClient: createModelToolsClient( + quest, + hasSchemaAccess ? tables : undefined, + ), + setStatus, + abortSignal: abortController?.signal, + word, + }) + + if (isAiAssistantError(response)) { + const error = response as AiAssistantAPIError + 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 AI Assistant", { + 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 ( + + ) +} diff --git a/src/scenes/Editor/ButtonBar/index.tsx b/src/scenes/Editor/ButtonBar/index.tsx index 8bfdfa959..9cdbfee02 100644 --- a/src/scenes/Editor/ButtonBar/index.tsx +++ b/src/scenes/Editor/ButtonBar/index.tsx @@ -1,35 +1,95 @@ -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 { CornerDownLeft } from "@styled-icons/evaicons-solid" +import { Stop, Loader3 } from "@styled-icons/remix-line" +import { Stop as StopFill } from "@styled-icons/remix-fill" +import { Key } from "../../../components" +import { CloseOutline } from "@styled-icons/evaicons-outline" import { ChevronDown } from "@styled-icons/boxicons-solid" -import { Box, Button, PopperToggle } from "../../../components" +import { + Box, + Button, + ExplainQueryButton, + GenerateSQLButton, + PopperToggle, + slideAnimation, + spinAnimation, +} from "../../../components" +import { FixQueryButton } from "./FixQueryButton" import { actions, selectors } from "../../../store" import { platform, color } from "../../../utils" import { RunningType } from "../../../store/Query/types" +import { + useAIStatus, + AIOperationStatus, + isBlockingAIStatus, +} from "../../../providers/AIStatusProvider" +import type { ExecutionRefs } from "../../../scenes/Editor" + +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 }>` - position: absolute; - top: ${({ $searchWidgetType }) => - $searchWidgetType === "replace" - ? "8.2rem" - : $searchWidgetType === "find" - ? "5.3rem" - : "1rem"}; - right: 2.4rem; - z-index: 1; - transition: top 0.1s linear; + ${({ $aiAssistantEnabled, $searchWidgetType }) => + !$aiAssistantEnabled + ? css` + position: absolute; + top: ${$searchWidgetType === "replace" + ? "8.2rem" + : $searchWidgetType === "find" + ? "5.3rem" + : "1rem"}; + right: 2.4rem; + z-index: 1; + transition: top 0.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 && slideAnimation} +` + +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")}; @@ -37,7 +97,7 @@ const SuccessButton = styled(Button)` &:hover:not(:disabled) { background-color: ${color("green")}; border-color: ${color("green")}; - color: ${color("gray1")}; + color: ${color("selectionDarker")}; } &:disabled { @@ -61,6 +121,7 @@ const SuccessButton = styled(Button)` ` const StopButton = styled(Button)` + margin-left: auto; background-color: ${color("red")}; border-color: ${color("red")}; color: ${color("foreground")}; @@ -92,6 +153,26 @@ const StopButton = styled(Button)` } ` +const AIStopButton = styled(Button)` + width: 2.2rem; + height: 2.2rem; + flex-shrink: 0; + border-radius: 100%; + background: #da152832; + border: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + + &:hover { + background: ${({ theme }) => theme.color.red} !important; + svg { + color: ${({ theme }) => theme.color.foreground}; + } + } +` + const MainRunButton = styled(SuccessButton)` border-top-right-radius: 0; border-bottom-right-radius: 0; @@ -124,23 +205,6 @@ const DropdownMenu = styled.div` } ` -const Key = styled(Box).attrs({ alignItems: "center" })` - padding: 0 0.4rem; - background: ${color("gray1")}; - border-radius: 0.2rem; - font-size: 1.2rem; - height: 1.8rem; - color: ${color("green")}; - - &:not(:last-child) { - margin-right: 0.25rem; - } - - svg { - color: ${color("green")} !important; - } -` - const RunShortcut = styled(Box).attrs({ alignItems: "center", gap: "0" })` margin-left: 1rem; ` @@ -149,25 +213,31 @@ 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 -}) => { + 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 { status: aiStatus, canUse, abortOperation } = useAIStatus() const [dropdownActive, setDropdownActive] = useState(false) + const observerRef = useRef(null) + const aiAssistantEnabled = canUse + + const hasQueryError = + activeNotification?.type === "error" && !activeNotification?.isExplain const [searchWidgetType, setSearchWidgetType] = useState< "find" | "replace" | null >(null) @@ -190,6 +260,14 @@ const ButtonBar = ({ }, []) 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") @@ -236,11 +314,15 @@ const ButtonBar = ({ 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) { @@ -265,11 +347,21 @@ const ButtonBar = ({ > Run all queries - {ctrlCmd} - ⇧ - - - + + + ) @@ -317,10 +409,16 @@ const ButtonBar = ({ > {getQueryButtonText()} - {ctrlCmd} - - - + + + + + + {hasQueryError && queriesToRun.length === 1 && ( + + )} + {aiStatus && ( + + {aiStatus === AIOperationStatus.Aborted ? ( + + ) : ( + + )} + {aiStatus} + {isBlockingAIStatus(aiStatus) && ( + + + + )} + + )} {running === RunningType.SCRIPT ? renderRunScriptButton() : renderRunQueryButton()} diff --git a/src/scenes/Editor/DiffEditor/index.tsx b/src/scenes/Editor/DiffEditor/index.tsx new file mode 100644 index 000000000..9c3ccf73b --- /dev/null +++ b/src/scenes/Editor/DiffEditor/index.tsx @@ -0,0 +1,320 @@ +import React, { useMemo, useRef, useState, useEffect } from "react" +import styled from "styled-components" +import { DiffEditor } from "@monaco-editor/react" +import type { Monaco, DiffOnMount } from "@monaco-editor/react" +import { Button, Box, Text, Key } from "../../../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, platform } from "../../../utils" + +const Container = styled.div` + display: flex; + height: 100%; + overflow: hidden; + background: ${color("backgroundLighter")}; + width: 100%; +` + +const ExplanationBox = styled(Box)` + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; + text-align: left; + background: rgba(68, 71, 90, 0.56); + padding: 0.4rem; + border-radius: 0.6rem; + flex: 0 1 auto; + min-height: 0; + overflow: hidden; +` + +const AssistantHeader = styled(Box).attrs({ + alignItems: "flex-start", + gap: "1rem", +})` + margin-right: auto; + padding: 0.4rem 0; + flex: 1 0 auto; +` + +const SparkleIcon = styled.img` + width: 2.4rem; + height: 2.4rem; + flex-shrink: 0; +` + +const AssistantLabel = styled(Text)` + font-family: ${({ theme }) => theme.fontMonospace}; + font-size: 1.6rem; + text-transform: uppercase; + color: ${color("foreground")}; + line-height: 1; +` + +const ExplanationContent = styled(Box)` + background: ${color("backgroundDarker")}; + border: 1px solid ${color("selection")}; + border-radius: 0.6rem; + padding: 0.8rem; + overflow-y: auto; + flex: 1 1 auto; + min-height: 0; + width: 100%; +` + +const ExplanationText = styled.p` + margin: 0; + font-family: ${({ theme }) => theme.font}; + font-size: 1.4rem; + line-height: 2.1rem; + color: ${color("foreground")}; + white-space: pre-wrap; + max-height: 100%; + + .explanation-code-block { + background: #2d303e; + border: 1px solid #44475a; + border-radius: 0.6rem; + padding: 0 0.3rem; + display: inline-flex; + gap: 1rem; + align-items: center; + font-family: ${({ theme }) => theme.fontMonospace}; + font-size: 1.3rem; + color: #9089fc; + } +` + +const ButtonBar = styled(Box)` + padding: 0.5rem 1rem; + gap: 1rem; + justify-content: center; + flex-shrink: 0; +` + +const KeyContainer = styled(Box).attrs({ alignItems: "center", gap: "0.3rem" })` + margin-left: 1rem; +` + +const RejectButton = styled(Button)` + background: ${color("background")}; + color: ${color("foreground")}; + border: 0.1rem solid ${({ theme }) => theme.color.pinkDarker}; + flex: 1; + + &:hover:not(:disabled) { + background: ${color("selection")}; + border-color: ${({ theme }) => theme.color.pinkDarker}; + } +` + +const AcceptButton = styled(Button)` + background: ${({ theme }) => theme.color.pinkDarker}; + color: ${color("foreground")}; + border: 0.1rem solid ${({ theme }) => theme.color.pinkDarker}; + flex: 1; + + &:hover:not(:disabled) { + background: ${({ theme }) => theme.color.pink}; + border-color: ${({ theme }) => theme.color.pink}; + filter: brightness(1.1); + } +` + +const ActionsContainer = styled(Box)` + display: flex; + flex-direction: column; + justify-content: space-between; + flex: 1; + gap: 1rem; + padding: 1.5rem; + width: 33%; + max-width: 40rem; +` + +const EditorContainer = styled.div` + flex: 1; + overflow: hidden; +` + +type Props = { + pendingFixRef: React.MutableRefObject +} + +const ctrlCmd = platform.isMacintosh || platform.isIOS ? "⌘" : "Ctrl" + +export const DiffEditorComponent = ({ pendingFixRef }: Props) => { + const { activeBuffer, setActiveBuffer, deleteBuffer, buffers } = useEditor() + const [diffEditor, setDiffEditor] = useState(null) + const scrolledRef = useRef(false) + const monacoRef = useRef(null) + + const { original, modified, explanation, queryStartOffset, originalQuery } = + activeBuffer.diffContent! + const originalBufferId = activeBuffer.originalBufferId + + const explanationWithCodeBlocks = useMemo( + () => + explanation.replace( + /`([^`]+)`/g, + "" + "$1" + "", + ), + [explanation], + ) + + 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) + } + } + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault() + void handleReject() + return + } + + if (!((e.metaKey || e.ctrlKey) && e.key === "Enter")) { + return + } + e.preventDefault() + void handleAccept() + } + + document.addEventListener("keydown", handleKeyDown) + return () => { + document.removeEventListener("keydown", handleKeyDown) + } + }, [handleAccept, handleReject]) + + return ( + + + + + + + + + Assistant + + + + + + + + Reject + + + + + + Accept + + + + + + + + + ) +} diff --git a/src/scenes/Editor/Menu/index.tsx b/src/scenes/Editor/Menu/index.tsx index 3934d2a5c..e69a7ee9f 100644 --- a/src/scenes/Editor/Menu/index.tsx +++ b/src/scenes/Editor/Menu/index.tsx @@ -34,6 +34,7 @@ import { PaneMenu, PopperToggle, TransitionDuration, + SetupAIAssistant, } from "../../../components" import { useKeyPress, useScreenSize } from "../../../hooks" import { actions, selectors } from "../../../store" @@ -65,6 +66,7 @@ const Separator = styled.div` const QueryPickerButton = styled(Button)<{ $firstTimeVisitor: boolean }>` position: relative; flex: 0 0 auto; + margin-right: 0.5rem; @keyframes pulse { 0% { @@ -187,6 +189,7 @@ const Menu = () => { /> )} + diff --git a/src/scenes/Editor/Monaco/editor-addons.ts b/src/scenes/Editor/Monaco/editor-addons.ts index d009023a2..d4dd06cbe 100644 --- a/src/scenes/Editor/Monaco/editor-addons.ts +++ b/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 = ({ @@ -141,6 +145,28 @@ 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/src/scenes/Editor/Monaco/index.tsx b/src/scenes/Editor/Monaco/index.tsx index 76ddf6110..bf2d8499a 100644 --- a/src/scenes/Editor/Monaco/index.tsx +++ b/src/scenes/Editor/Monaco/index.tsx @@ -13,7 +13,7 @@ import React, { import type { ReactNode } from "react" import { useDispatch, useSelector } from "react-redux" import styled from "styled-components" -import type { ExecutionInfo, ExecutionRefs } from "../../Editor" +import type { ExecutionInfo, ExecutionRefs, PendingFix } from "../../Editor" import { Box, Button, @@ -28,6 +28,7 @@ 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" @@ -88,6 +89,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 { @@ -124,6 +127,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; @@ -196,12 +237,20 @@ const StyledDialogButton = styled(Button)` } ` +const EditorWrapper = styled.div` + flex: 1; + overflow: hidden; + position: relative; +` + const DEFAULT_LINE_CHARS = 5 const MonacoEditor = ({ executionRefs, + pendingFixRef, }: { executionRefs: React.MutableRefObject + pendingFixRef: React.MutableRefObject }) => { const editorContext = useEditor() const { @@ -216,6 +265,7 @@ const MonacoEditor = ({ isNavigatingFromSearchRef, } = editorContext const { quest } = useContext(QuestContext) + const { abortOperation: abortAIOperation } = useAIStatus() const [request, setRequest] = useState() const [editorReady, setEditorReady] = useState(false) const [lastExecutedQuery, setLastExecutedQuery] = useState("") @@ -265,6 +315,19 @@ const MonacoEditor = ({ 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 + void 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( DEFAULT_LINE_CHARS + @@ -885,6 +948,8 @@ const MonacoEditor = ({ }) editor.onDidChangeModel(() => { + abortAIOperation() + setTimeout(() => { if (monacoRef.current && editorRef.current) { applyGlyphsAndLineMarkings(monacoRef.current, editorRef.current) @@ -932,6 +997,51 @@ const MonacoEditor = ({ }, 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) @@ -1680,6 +1790,7 @@ const MonacoEditor = ({ useEffect(() => { return () => { + abortAIOperation() cleanupActionsRef.current.forEach((cleanup) => cleanup()) if (cursorChangeTimeoutRef.current) { window.clearTimeout(cursorChangeTimeoutRef.current) @@ -1705,51 +1816,41 @@ const MonacoEditor = ({ 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 - void 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, - }} - theme="vs-dark" - /> + + + diff --git a/src/scenes/Editor/Monaco/tabs.tsx b/src/scenes/Editor/Monaco/tabs.tsx index 355b193e4..e30a3f608 100644 --- a/src/scenes/Editor/Monaco/tabs.tsx +++ b/src/scenes/Editor/Monaco/tabs.tsx @@ -1,5 +1,5 @@ import React, { useLayoutEffect, useState, useMemo } from "react" -import styled from "styled-components" +import styled, { css } from "styled-components" import { Tabs as ReactChromeTabs } from "../../../components/ReactChromeTabs" import { useEditor } from "../../../providers" import { File, History, LineChart, Trash } from "@styled-icons/boxicons-regular" @@ -13,6 +13,10 @@ import { import { fetchUserLocale, getLocaleFromLanguage } from "../../../utils" import { format, formatDistance } from "date-fns" import type { Buffer } from "../../../store/buffers" +import { + isBlockingAIStatus, + useAIStatus, +} from "../../../providers/AIStatusProvider" type Tab = { id: string @@ -25,11 +29,19 @@ type Tab = { const Root = styled(Box).attrs({ align: "center", justifyContent: "space-between", -})` +})<{ $disabled: boolean }>` width: 100%; display: flex; background: ${({ theme }) => theme.color.backgroundLighter}; padding-right: 1rem; + ${({ $disabled }) => + $disabled && + css` + * { + pointer-events: none; + opacity: 0.8; + } + `} ` const HistoryButton = styled(Button)` @@ -48,6 +60,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" } @@ -62,6 +77,7 @@ export const Tabs = () => { deleteBuffer, archiveBuffer, } = useEditor() + const { status } = useAIStatus() const [tabsVisible, setTabsVisible] = useState(false) const userLocale = useMemo(fetchUserLocale, []) const [historyOpen, setHistoryOpen] = useState(false) @@ -188,7 +204,7 @@ export const Tabs = () => { } return ( - + { 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/src/scenes/Editor/index.tsx b/src/scenes/Editor/index.tsx index f089a17b7..668fa18d8 100644 --- a/src/scenes/Editor/index.tsx +++ b/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" @@ -71,6 +72,13 @@ const EditorPaneWrapper = styled(PaneWrapper)` overflow: hidden; ` +export type PendingFix = { + modifiedContent: string + queryStartOffset: number + originalQuery: string + originalBufferId: number +} + const Editor = ({ innerRef, ...rest @@ -78,6 +86,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)) @@ -95,9 +104,14 @@ const Editor = ({ return ( - {activeBuffer.editorViewState && } + {activeBuffer.isDiffBuffer && activeBuffer.diffContent && ( + + )} + {activeBuffer.editorViewState && !activeBuffer.isDiffBuffer && ( + + )} {activeBuffer.metricsViewState && } - {activeBuffer.editorViewState && ( + {activeBuffer.editorViewState && !activeBuffer.isDiffBuffer && ( )} diff --git a/src/scenes/Layout/index.tsx b/src/scenes/Layout/index.tsx index df44608f7..2ea63b6a6 100644 --- a/src/scenes/Layout/index.tsx +++ b/src/scenes/Layout/index.tsx @@ -42,6 +42,8 @@ import "allotment/dist/style.css" import { eventBus } from "../../modules/EventBus" import { EventType } from "../../modules/EventBus/types" +import { AIStatusProvider } from "../../providers/AIStatusProvider" +import { AIStatusIndicator } from "../../components/AIStatusIndicator" const Page = styled.div` display: flex; @@ -101,30 +103,33 @@ const Layout = () => { return ( - - - -
- - - - -
+ + + + +
+ + + + + +
- + - - + + - + - - -
+ + +
- + -
+
+ ) diff --git a/src/scenes/Schema/SchemaExplanationDialog/index.tsx b/src/scenes/Schema/SchemaExplanationDialog/index.tsx new file mode 100644 index 000000000..b96f2a538 --- /dev/null +++ b/src/scenes/Schema/SchemaExplanationDialog/index.tsx @@ -0,0 +1,247 @@ +import React from "react" +import { + Dialog, + ForwardRef, + Button, + Overlay, + Box, + Text, +} from "../../../components" +import styled from "styled-components" +import { AutoAwesome } from "@styled-icons/material" +import { Check } from "@styled-icons/boxicons-regular" +import { TableSchemaExplanation } from "../../../utils/aiAssistant" + +const StyledDialogContent = styled(Dialog.Content)` + max-width: 800px; + max-height: 80vh; +` + +const StyledDescription = styled(Dialog.Description)` + display: flex; + flex-direction: column; + gap: 2rem; + overflow-y: auto; + padding-right: 1rem; + max-height: 60vh; + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: ${({ theme }) => theme.color.gray1}; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: ${({ theme }) => theme.color.gray2}; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb:hover { + background: ${({ theme }) => theme.color.gray2}; + } +` + +const ExplanationText = styled(Text)` + white-space: pre-wrap; + line-height: 1.6; + font-size: 1.4rem; +` + +const TitleIcon = styled(Box)` + display: flex; + align-items: center; + gap: 1rem; +` + +const AIBadge = styled(Box)` + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: ${({ theme }) => theme.color.purple}20; + color: ${({ theme }) => theme.color.purple}; + padding: 0.2rem 0.8rem; + border-radius: 0.4rem; + font-size: 1.2rem; +` + +const Section = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +` + +const SectionTitle = styled(Text)` + font-weight: 600; + font-size: 1.6rem; + color: ${({ theme }) => theme.color.foreground}; + margin-bottom: 0.5rem; +` + +const ColumnsTable = styled.table` + width: 100%; + border-collapse: collapse; + font-size: 1.3rem; + + th { + text-align: left; + padding: 0.8rem; + color: ${({ theme }) => theme.color.foreground}; + font-weight: 700; + border-bottom: 1px solid ${({ theme }) => theme.color.gray2}; + } + + td { + padding: 0.8rem; + border-bottom: 1px solid ${({ theme }) => theme.color.gray1}; + &:nth-child(1), + &:nth-child(2) { + padding-right: 1.5rem; + } + } + + tbody tr:hover { + background: ${({ theme }) => theme.color.gray1}20; + } +` + +const DataTypeCell = styled.td` + font-family: ${({ theme }) => theme.fontMonospace}; + color: ${({ theme }) => theme.color.cyan}; +` + +const StorageList = styled.ul` + margin: 0; + padding: 0; + list-style: none; +` + +const StorageDetail = styled.li` + display: flex; + align-items: center; + gap: 0.8rem; + color: ${({ theme }) => theme.color.foreground}; + font-size: 1.4rem; + margin-bottom: 0.8rem; + + &:last-child { + margin-bottom: 0; + } +` + +const CheckIcon = styled(Check)` + color: ${({ theme }) => theme.color.cyan}; + flex-shrink: 0; + margin-top: 0.2rem; +` + +type Props = { + open: boolean + onOpenChange: (open: boolean) => void + tableName: string + explanation: TableSchemaExplanation | null +} + +export const SchemaExplanationDialog = ({ + open, + onOpenChange, + tableName, + explanation, +}: Props) => { + return ( + + + + + + + ) => { + e.stopPropagation() + }} + onEscapeKeyDown={() => onOpenChange(false)} + onPointerDownOutside={() => onOpenChange(false)} + > + + + {tableName} + + + AI Explanation + + + + + + {explanation && ( + <> + {explanation.explanation && ( +
+ Overview + + {explanation.explanation} + +
+ )} + + {explanation.columns && explanation.columns.length > 0 && ( +
+ Columns + +
+ + + + + + + + {explanation.columns.map((column) => ( + + + {column.data_type} + + + ))} + + + + )} + + {explanation.storage_details && + explanation.storage_details.length > 0 && ( +
+ Storage Details + + {explanation.storage_details.map((detail) => ( + + + {detail} + + ))} + +
+ )} + + )} + + + + + + + + + + + ) +} diff --git a/src/scenes/Schema/SuspensionDialog/index.tsx b/src/scenes/Schema/SuspensionDialog/index.tsx index b29734c41..332b6b0f3 100644 --- a/src/scenes/Schema/SuspensionDialog/index.tsx +++ b/src/scenes/Schema/SuspensionDialog/index.tsx @@ -11,7 +11,7 @@ import { Input, CopyButton, } from "../../../components" -import { Undo } from "@styled-icons/boxicons-regular" +import { Undo, CheckCircle } from "@styled-icons/boxicons-regular" import styled from "styled-components" import * as QuestDB from "../../../utils/questdb" import { ExternalLink, Restart, Table } from "@styled-icons/remix-line" @@ -23,8 +23,9 @@ import { ErrorResult } from "../../../utils" import { errorWorkarounds } from "../../../utils/errorWorkarounds" import Joi from "joi" -const StyledDialogContent = styled(Dialog.Content)` - border-color: #723131; +const StyledDialogContent = styled(Dialog.Content)<{ $success?: boolean }>` + border-color: ${({ $success, theme }) => + $success ? theme.color.green : theme.color.red}; ` const StyledDescription = styled(Dialog.Description)` @@ -61,6 +62,11 @@ const StyledInput = styled(Input)` border-color: ${({ theme }) => theme.color.selection}; ` +const StyledTable = styled(Table)<{ $success?: boolean }>` + color: ${({ $success, theme }) => + $success ? theme.color.green : theme.color.red}; +` + type FormValues = { resume_transaction_id?: number } @@ -143,10 +149,11 @@ export const SuspensionDialog = ({ }} onEscapeKeyDown={() => onOpenChange(false)} onPointerDownOutside={() => onOpenChange(false)} + $success={isSubmitted} > -
Column NameData TypeDescription
{column.name}{column.description}
+ {kind === "table" ? "Table" : "Materialized view"} is suspended:{" "} {walTableData.name} @@ -157,10 +164,13 @@ export const SuspensionDialog = ({ {error && {error}} {isSubmitted && ( - WAL resumed successfully! + + WAL resumed successfully! + )} {walTableData.errorTag && + !isSubmitted && errorWorkarounds[walTableData.errorTag] && ( diff --git a/src/scenes/Schema/VirtualTables/index.tsx b/src/scenes/Schema/VirtualTables/index.tsx index 775811d64..960ce88da 100644 --- a/src/scenes/Schema/VirtualTables/index.tsx +++ b/src/scenes/Schema/VirtualTables/index.tsx @@ -45,6 +45,19 @@ import { } from "../../../components/ContextMenu" import { copyToClipboard } from "../../../utils/copyToClipboard" import { SuspensionDialog } from "../SuspensionDialog" +import { SchemaExplanationDialog } from "../SchemaExplanationDialog" +import { + explainTableSchema, + isAiAssistantError, + AiAssistantAPIError, + TableSchemaExplanation, + type ActiveProviderSettings, +} from "../../../utils/aiAssistant" +import { + useAIStatus, + isBlockingAIStatus, +} from "../../../providers/AIStatusProvider" +import { providerForModel } from "../../../utils/aiAssistantSettings" type VirtualTablesProps = { tables: QuestDB.Table[] @@ -162,6 +175,15 @@ const VirtualTables: FC = ({ const { query, focusedIndex, setFocusedIndex } = useSchema() const { quest } = useContext(QuestContext) const allColumns = useSelector(selectors.query.getColumns) + const { + status: aiStatus, + setStatus, + canUse, + hasSchemaAccess, + currentModel, + apiKey, + isConfigured, + } = useAIStatus() const [schemaTree, setSchemaTree] = useState({}) const [openedContextMenu, setOpenedContextMenu] = useState( @@ -170,6 +192,11 @@ const VirtualTables: FC = ({ const [openedSuspensionDialog, setOpenedSuspensionDialog] = useState< string | null >(null) + const [schemaExplanationDialog, setSchemaExplanationDialog] = useState<{ + tableName: string + isMatView: boolean + explanation: TableSchemaExplanation | null + } | null>(null) const symbolColumnDetailsRef = useRef>( new Map(), @@ -224,24 +251,82 @@ const VirtualTables: FC = ({ }, [] as FlattenedTreeItem[]) }, [schemaTree]) - const handleCopyQuery = async (tableName: string, isMatView: boolean) => { + const getTableSchema = async ( + tableName: string, + isMatView: boolean, + ): Promise => { try { - let response - if (isMatView) { - response = await quest.showMatViewDDL(tableName) - } else { - response = await quest.showTableDDL(tableName) - } + const response = isMatView + ? await quest.showMatViewDDL(tableName) + : await quest.showTableDDL(tableName) if (response?.type === QuestDB.Type.DQL && response.data?.[0]?.ddl) { - await copyToClipboard(response.data[0].ddl) - toast.success("Schema copied to clipboard") + return response.data[0].ddl } - } catch (error) { + } catch (_error) { + toast.error( + `Cannot fetch schema for ${isMatView ? "materialized view" : "table"} '${tableName}'`, + ) + } + return null + } + + const handleCopyQuery = async (tableName: string, isMatView: boolean) => { + const schema = await getTableSchema(tableName, isMatView) + if (schema) { + await copyToClipboard(schema) + toast.success("Schema copied to clipboard") + } + } + + const handleExplainSchema = async (tableName: string, isMatView: boolean) => { + if (!canUse) { toast.error( - `Cannot copy schema for ${isMatView ? "materialized view" : "table"} '${tableName}'`, + "AI Assistant is not enabled. Please configure your API key in settings.", ) + return + } + + const schema = await getTableSchema(tableName, isMatView) + if (!schema) { + return + } + + const provider = providerForModel(currentModel) + + const settings: ActiveProviderSettings = { + model: currentModel, + provider, + apiKey, } + + const response = await explainTableSchema({ + tableName, + schema, + isMatView, + settings, + setStatus, + }) + + if (isAiAssistantError(response)) { + const error = response as AiAssistantAPIError + toast.error(error.message, { autoClose: 10000 }) + return + } + + const result = response as TableSchemaExplanation + if (!result.explanation) { + toast.error("No explanation received from AI Assistant", { + autoClose: 10000, + }) + return + } + + setSchemaExplanationDialog({ + tableName, + isMatView, + explanation: result, + }) } const fetchSymbolColumnDetails = useCallback( @@ -560,6 +645,33 @@ const VirtualTables: FC = ({ > Copy schema + {isConfigured && ( + + await handleExplainSchema( + item.name, + item.kind === "matview", + ) + } + icon={ + AI Sparkle + } + disabled={ + !canUse || + !hasSchemaAccess || + isBlockingAIStatus(aiStatus) + } + > + Explain schema with AI + + )} @@ -702,19 +814,29 @@ const VirtualTables: FC = ({ } return ( -
- { - rangeRef.current = newRange - }} - data={flattenedItems} - itemContent={(index) => renderRow(index)} - style={{ height: "100%" }} - /> -
+ <> +
+ { + rangeRef.current = newRange + }} + data={flattenedItems} + itemContent={(index) => renderRow(index)} + style={{ height: "100%" }} + /> +
+ {schemaExplanationDialog && ( + !open && setSchemaExplanationDialog(null)} + tableName={schemaExplanationDialog.tableName} + explanation={schemaExplanationDialog.explanation} + /> + )} + ) } diff --git a/src/scenes/Schema/index.tsx b/src/scenes/Schema/index.tsx index e3393ed80..5d5017af7 100644 --- a/src/scenes/Schema/index.tsx +++ b/src/scenes/Schema/index.tsx @@ -75,6 +75,10 @@ import { useSchema } from "./SchemaContext" import { SchemaProvider } from "./SchemaContext" import { TreeNodeKind } from "./Row" import { toast } from "../../components/Toast" +import { + useAIStatus, + isBlockingAIStatus, +} from "../../providers/AIStatusProvider" type Props = Readonly<{ hideMenu?: boolean @@ -149,6 +153,7 @@ const Schema = ({ const listenerActiveRef = useRef(false) const latestFocusChangeTimestampRef = useRef(0) const { addBuffer } = useEditor() + const { status } = useAIStatus() const { selectOpen, setSelectOpen, selectedTables, setSelectedTables } = useSchema() @@ -447,6 +452,7 @@ const Schema = ({
("tables();") diff --git a/src/utils/questdb/types.ts b/src/utils/questdb/types.ts index b3126eedc..3e15a3146 100644 --- a/src/utils/questdb/types.ts +++ b/src/utils/questdb/types.ts @@ -128,6 +128,56 @@ export type QueryResult> = | DdlResult | NoticeResult +type QueryType = + | "INSERT" + | "TRUNCATE" + | "ALTER TABLE" + | "SET" + | "DROP" + | "COPY" + | "CREATE TABLE" + | "INSERT AS SELECT" + | "COPY REMOTE" + | "RENAME TABLE" + | "REPAIR" + | "BACKUP TABLE" + | "UPDATE" + | "VACUUM" + | "BEGIN" + | "COMMIT" + | "ROLLBACK" + | "CREATE AS SELECT" + | "CHECKPOINT CREATE" + | "CHECKPOINT RELEASE" + | "DEALLOCATE" + | "EXPLAIN" + | "TABLE RESUME" + +export type ValidateQuerySuccessResult = + | { + query: string + columns: Array<{ + name: string + type: string + dim?: number + elemType?: string + }> + timestamp: number + } + | { + queryType: QueryType + } + +export type ValidateQueryErrorResult = { + query: string + position: number + error: string +} + +export type ValidateQueryResult = + | ValidateQuerySuccessResult + | ValidateQueryErrorResult + export type PartitionBy = "HOUR" | "DAY" | "WEEK" | "MONTH" | "YEAR" | "NONE" export type Table = { diff --git a/src/utils/questdbDocsRetrieval.ts b/src/utils/questdbDocsRetrieval.ts new file mode 100644 index 000000000..00d556839 --- /dev/null +++ b/src/utils/questdbDocsRetrieval.ts @@ -0,0 +1,288 @@ +export type DocCategory = "functions" | "operators" | "sql" + +export type ParsedDocItem = { + name: string + section?: string +} + +/** + * Parse a documentation item string into name and optional section + * Handles formats like "Window Functions - avg()" or "Window Functions" + */ +export function parseDocItem(item: string): ParsedDocItem | null { + if (!item || !item.trim()) { + return null + } + + const parts = item.split(/\s+-\s+/) + if (parts.length >= 2) { + return { + name: parts[0].trim(), + section: parts.slice(1).join(" - ").trim(), + } + } + + return { name: item.trim() } +} + +/** + * Parse multiple documentation item strings into an array of parsed items + */ +export function parseDocItems( + items: string[], +): Array<{ name: string; section?: string }> { + return items + .map(parseDocItem) + .filter((item): item is ParsedDocItem => item !== null) +} + +// Base URL for documentation +const DOCS_BASE_URL = " https://questdb.com/docs" + +// Interface for metadata (no content, includes url) +export interface DocFileMetadata { + path: string + title: string + headers: string[] + url: string +} + +/** + * Fetch JSON from URL + */ +async function fetchJson(url: string): Promise { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.statusText}`) + } + return response.json() as T +} + +/** + * Fetch markdown content from URL + */ +async function fetchMarkdown(url: string): Promise { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.statusText}`) + } + return response.text() +} + +/** + * Get the table of contents for all QuestDB documentation + */ +export async function getQuestDBTableOfContents(): Promise { + const tocUrl = `${DOCS_BASE_URL}/web-console/toc-list.json` + const toc = await fetchJson>(tocUrl) + + let result = "# QuestDB Documentation Table of Contents\n\n" + + // Functions + result += "## Functions\n" + result += toc.functions.join(", ") + "\n\n" + + // Operators + result += "## Operators\n" + result += toc.operators.join(", ") + "\n\n" + + // SQL Keywords + result += "## SQL Syntax & Keywords\n" + result += toc.sql.join(", ") + "\n" + + return result +} + +/** + * Get documentation for specific items + */ +export async function getSpecificDocumentation( + category: DocCategory, + items: string[], +): Promise { + // Fetch metadata for this category + const metadataUrl = `${DOCS_BASE_URL}/web-console/${category}-docs.json` + const categoryDocs = await fetchJson(metadataUrl) + + if (!categoryDocs) { + return `Unknown category: ${category}` + } + + const chunks: string[] = [] + const processedPaths = new Set() + + for (const item of items) { + const normalizedItem = item.toLowerCase().replace(/[^a-z0-9_]/g, "_") + const parsed = parseDocItem(item) + if (!parsed) continue + + const queryTitle = parsed.name + const querySection = parsed.section + const hasTitleAndSection = !!querySection + + // Find files containing this item + for (const file of categoryDocs) { + // Handle explicit "Title - Section" lookups + if (hasTitleAndSection && queryTitle && querySection) { + if (file.title.toLowerCase() === queryTitle.toLowerCase()) { + const matchingHeaderFromTitleSection = file.headers.find( + (h) => + h.toLowerCase() === querySection.toLowerCase() || + h.toLowerCase().replace(/[^a-z0-9_]/g, "_") === + querySection.toLowerCase().replace(/[^a-z0-9_]/g, "_"), + ) + if ( + matchingHeaderFromTitleSection && + !processedPaths.has( + `${file.path}::${matchingHeaderFromTitleSection}`, + ) + ) { + processedPaths.add( + `${file.path}::${matchingHeaderFromTitleSection}`, + ) + + // Fetch the markdown content + const content = await fetchMarkdown(file.url) + const sectionContent = extractSection( + content, + matchingHeaderFromTitleSection, + ) + if (sectionContent) { + chunks.push( + `### ${file.path} - ${matchingHeaderFromTitleSection}\n\n${sectionContent}`, + ) + continue + } + } + } + } + + // Check if file name matches + const fileKey = file.path + .split("/") + .pop() + ?.replace(".md", "") + .replace(/-/g, "_") + const hasItemInPath = fileKey === normalizedItem + + // Check if title matches + const normalizedTitle = file.title + .toLowerCase() + .replace(/[^a-z0-9_]/g, "_") + const hasItemInTitle = + normalizedTitle === normalizedItem || + file.title.toLowerCase() === item.toLowerCase() + + // Check if any header matches + const hasItemInHeaders = file.headers.some( + (h) => + h.toLowerCase().replace(/[^a-z0-9_]/g, "_") === normalizedItem || + h.toLowerCase() === item.toLowerCase(), + ) + + if ( + (hasItemInPath || hasItemInTitle || hasItemInHeaders) && + !processedPaths.has(file.path) + ) { + processedPaths.add(file.path) + + // Fetch the markdown content + const content = await fetchMarkdown(file.url) + + // If looking for a specific function/operator, try to extract just that section + const matchingHeader = file.headers.find( + (h) => + h.toLowerCase() === item.toLowerCase() || + h.toLowerCase().replace(/[^a-z0-9_]/g, "_") === normalizedItem, + ) + + if (matchingHeader) { + const sectionContent = extractSection(content, matchingHeader) + if (sectionContent) { + chunks.push( + `### ${file.path} - ${matchingHeader}\n\n${sectionContent}`, + ) + continue + } + } + + // Otherwise include the whole file + chunks.push(`### ${file.path}\n\n${content}`) + } + } + } + + if (chunks.length === 0) { + return `No documentation found for: ${items.join(", ")}` + } + + return chunks.join("\n\n---\n\n") +} + +/** + * Extract a specific section from markdown content + */ +function extractSection(content: string, sectionHeader: string): string | null { + const lines = content.split("\n") + let inSection = false + const sectionContent: string[] = [] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + // Check if we found the section header + if (line === `## ${sectionHeader}`) { + inSection = true + sectionContent.push(line) + } else if (inSection) { + // Check if we reached the next section + if (line.match(/^##?\s/)) { + break + } + sectionContent.push(line) + } + } + + return sectionContent.length > 0 ? sectionContent.join("\n") : null +} + +/** + * Search for documentation by keyword + */ +export async function searchDocumentation(query: string): Promise { + const lowerQuery = query.toLowerCase() + const results: string[] = [] + + // Search in all categories + const categories: DocCategory[] = ["functions", "operators", "sql"] + + for (const category of categories) { + const metadataUrl = `${DOCS_BASE_URL}/web-console/${category}-docs.json` + const docs = await fetchJson(metadataUrl) + + for (const file of docs) { + // Check file name + if (file.path.toLowerCase().includes(lowerQuery)) { + results.push(`${category}/${file.title}`) + } + + // Check headers + for (const header of file.headers) { + if (header.toLowerCase().includes(lowerQuery)) { + results.push(`${category}/${header}`) + } + } + } + } + + if (results.length === 0) { + return `No results found for: ${query}` + } + + return `Found ${results.length} results:\n${results.join("\n")}` +} + +export async function getReferenceFull(): Promise { + const url = `${DOCS_BASE_URL}/reference-full.md` + return fetchMarkdown(url) +} diff --git a/vite.config.mts b/vite.config.mts index 150fb7912..822502bcd 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -54,7 +54,12 @@ export default defineConfig(({ mode }) => { groups: ["group1", "group2"] })) } - } + }, + "/api": { + target: 'http://127.0.0.1:9000', + changeOrigin: true, + rewrite: (path: string) => `${contextPath}${path}`, + }, } return { diff --git a/yarn.lock b/yarn.lock index 2f1240471..44038fd5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -211,6 +211,15 @@ __metadata: languageName: node linkType: hard +"@anthropic-ai/sdk@npm:^0.57.0": + version: 0.57.0 + resolution: "@anthropic-ai/sdk@npm:0.57.0" + bin: + anthropic-ai-sdk: bin/cli + checksum: 10/3ff430ded97067467e1731acd906a5ad2e5dfd2f0283ce0ce90f292e7ec57f5ddfdc76094c093f141eac272f6038d9780f7516468bfda0128fb25db6078d041d + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.27.1": version: 7.27.1 resolution: "@babel/code-frame@npm:7.27.1" @@ -2433,6 +2442,7 @@ __metadata: resolution: "@questdb/web-console@workspace:." dependencies: "@4tw/cypress-drag-drop": "npm:^2.2.5" + "@anthropic-ai/sdk": "npm:^0.57.0" "@babel/core": "npm:^7.28.5" "@babel/preset-env": "npm:^7.20.2" "@babel/preset-react": "npm:^7.17.12" @@ -2515,6 +2525,7 @@ __metadata: lodash.isequal: "npm:^4.5.0" lodash.merge: "npm:^4.6.2" monaco-editor: "npm:^0.44.0" + openai: "npm:^5.21.0" posthog-js: "npm:^1.131.2" prettier: "npm:^3.3.0" ramda: "npm:0.27.1" @@ -9209,6 +9220,23 @@ __metadata: languageName: node linkType: hard +"openai@npm:^5.21.0": + version: 5.23.2 + resolution: "openai@npm:5.23.2" + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + bin: + openai: bin/cli + checksum: 10/ee22ddc948a9b7b8ea1518bc6aea73776d0c1a5abcac029e8f494106beedbf175e6a9497de74f72754cdad504e03f8d9fd128963207bdd45cbf2b11cd02657d6 + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4"