Updates Assistant snippet handling (#36948)
* ui refinements * rename chat * copy * prose message styles * add icon back * fix message save * simplify empty state * update suggestions * pass through props * button styles * onboarding icons * name button * remove results * current chat name * use type * fix down arrow * re-add chat name * refactor assistant form * remove import * snippet style * move prompts out * extract snippets
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
export const defaultPrompts = [
|
||||
{
|
||||
title: 'Create a back-end',
|
||||
prompt:
|
||||
'Create a messaging app with users, messages, and an edge function that uses OpenAI to summarize message threads.',
|
||||
},
|
||||
{
|
||||
title: 'Health check',
|
||||
prompt: 'Can you check if my database and edge functions are healthy?',
|
||||
},
|
||||
{
|
||||
title: 'Query your data',
|
||||
prompt: 'Give me a list of new users from the auth.users table who signed up in the past week',
|
||||
},
|
||||
{
|
||||
title: 'Set up RLS policies',
|
||||
prompt: 'Create RLS policies to ensure users can only access their own data',
|
||||
},
|
||||
{
|
||||
title: 'Create a function',
|
||||
prompt: 'Create an edge function that summarises the contents of a table row using OpenAI',
|
||||
},
|
||||
{
|
||||
title: 'Generate sample data',
|
||||
prompt: 'Generate sample data for a blog with users, posts, and comments tables',
|
||||
},
|
||||
]
|
||||
|
||||
export const codeSnippetPrompts = [
|
||||
{
|
||||
title: 'Explain code',
|
||||
prompt: 'Explain what this code does and how it works',
|
||||
},
|
||||
{
|
||||
title: 'Improve code',
|
||||
prompt: 'How can I improve this code for better performance and readability?',
|
||||
},
|
||||
{
|
||||
title: 'Debug issues',
|
||||
prompt: 'Help me debug any potential issues with this code',
|
||||
},
|
||||
]
|
||||
@@ -22,14 +22,14 @@ import uuidv4 from 'lib/uuid'
|
||||
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
|
||||
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
|
||||
import { AiIconAnimation, Button, cn } from 'ui'
|
||||
import { Admonition, AssistantChatForm, GenericSkeletonLoader } from 'ui-patterns'
|
||||
import { Admonition, GenericSkeletonLoader } from 'ui-patterns'
|
||||
import { ButtonTooltip } from '../ButtonTooltip'
|
||||
import { ErrorBoundary } from '../ErrorBoundary'
|
||||
import { onErrorChat } from './AIAssistant.utils'
|
||||
import { AIAssistantChatSelector } from './AIAssistantChatSelector'
|
||||
import { AIOnboarding } from './AIOnboarding'
|
||||
import { AIOptInModal } from './AIOptInModal'
|
||||
import { CollapsibleCodeBlock } from './CollapsibleCodeBlock'
|
||||
import { AssistantChatForm } from './AssistantChatForm'
|
||||
import { Message } from './Message'
|
||||
import { useAutoScroll } from './hooks'
|
||||
import type { AssistantMessageType } from 'state/ai-assistant-state'
|
||||
@@ -246,34 +246,20 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
const hasMessages = chatMessages.length > 0
|
||||
const isShowingOnboarding = !hasMessages && isApiKeySet
|
||||
|
||||
const sendMessageToAssistant = (content: string) => {
|
||||
let finalContent = content
|
||||
|
||||
// Handle SQL snippets based on opt-in level
|
||||
if (aiOptInLevel !== 'disabled') {
|
||||
const sqlSnippetsString =
|
||||
snap.sqlSnippets?.map((snippet: string) => '```sql\n' + snippet + '\n```').join('\n') || ''
|
||||
finalContent = [content, sqlSnippetsString].filter(Boolean).join('\n\n')
|
||||
} else {
|
||||
snap.setSqlSnippets([])
|
||||
}
|
||||
|
||||
const sendMessageToAssistant = (finalContent: string) => {
|
||||
const payload = {
|
||||
role: 'user',
|
||||
createdAt: new Date(),
|
||||
content: finalContent,
|
||||
id: uuidv4(),
|
||||
} as MessageType
|
||||
|
||||
snap.clearSqlSnippets()
|
||||
|
||||
// Store the user message in the ref before appending
|
||||
lastUserMessageRef.current = payload
|
||||
|
||||
append(payload)
|
||||
|
||||
setValue('')
|
||||
|
||||
if (content.includes('Help me to debug')) {
|
||||
if (finalContent.includes('Help me to debug')) {
|
||||
sendEvent({
|
||||
action: 'assistant_debug_submitted',
|
||||
groups: {
|
||||
@@ -552,23 +538,6 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
|
||||
{!isShowingOnboarding && (
|
||||
<div className="px-3 pb-3 z-20 relative">
|
||||
{snap.sqlSnippets && snap.sqlSnippets.length > 0 && (
|
||||
<div className="mb-0 mx-4">
|
||||
{snap.sqlSnippets.map((snippet: string, index: number) => (
|
||||
<CollapsibleCodeBlock
|
||||
key={index}
|
||||
hideLineNumbers
|
||||
value={snippet}
|
||||
onRemove={() => {
|
||||
const newSnippets = [...(snap.sqlSnippets ?? [])]
|
||||
newSnippets.splice(index, 1)
|
||||
snap.setSqlSnippets(newSnippets)
|
||||
}}
|
||||
className="text-xs rounded-b-none border-b-0"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{disablePrompts && (
|
||||
<Admonition
|
||||
showIcon={false}
|
||||
@@ -595,7 +564,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
<AssistantChatForm
|
||||
textAreaRef={inputRef}
|
||||
className={cn(
|
||||
'z-20 [&>textarea]:text-base [&>textarea]:md:text-sm [&>textarea]:border-1 [&>textarea]:rounded-md [&>textarea]:!outline-none [&>textarea]:!ring-offset-0 [&>textarea]:!ring-0'
|
||||
'z-20 [&>form>textarea]:text-base [&>form>textarea]:md:text-sm [&>form>textarea]:border-1 [&>form>textarea]:rounded-md [&>form>textarea]:!outline-none [&>form>textarea]:!ring-offset-0 [&>form>textarea]:!ring-0'
|
||||
)}
|
||||
loading={isChatLoading}
|
||||
disabled={!isApiKeySet || disablePrompts || isChatLoading}
|
||||
@@ -607,13 +576,18 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
: 'Chat to Postgres...'
|
||||
}
|
||||
value={value}
|
||||
autoFocus
|
||||
onValueChange={(e) => setValue(e.target.value)}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
sendMessageToAssistant(value)
|
||||
onSubmit={(finalMessage) => {
|
||||
sendMessageToAssistant(finalMessage)
|
||||
scrollToEnd()
|
||||
}}
|
||||
sqlSnippets={snap.sqlSnippets as string[] | undefined}
|
||||
onRemoveSnippet={(index) => {
|
||||
const newSnippets = [...(snap.sqlSnippets ?? [])]
|
||||
newSnippets.splice(index, 1)
|
||||
snap.setSqlSnippets(newSnippets)
|
||||
}}
|
||||
includeSnippetsInMessage={aiOptInLevel !== 'disabled'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { Code, FileText, Heart, MessageCircleMore, Shield, WandSparkles } from 'lucide-react'
|
||||
import { FileText } from 'lucide-react'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { Button, cn } from 'ui'
|
||||
import { AssistantChatForm } from 'ui-patterns'
|
||||
import { CollapsibleCodeBlock } from './CollapsibleCodeBlock'
|
||||
import { AssistantChatForm } from './AssistantChatForm'
|
||||
import { codeSnippetPrompts, defaultPrompts } from './AIAssistant.prompts'
|
||||
|
||||
interface AIOnboardingProps {
|
||||
onMessageSend: (message: string) => void
|
||||
@@ -28,60 +28,6 @@ export const AIOnboarding = ({
|
||||
}: AIOnboardingProps) => {
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const defaultPrompts = [
|
||||
{
|
||||
title: 'Create a back-end',
|
||||
prompt:
|
||||
'Create a messaging app with users, messages, and an edge function that uses OpenAI to summarize message threads.',
|
||||
icon: <WandSparkles strokeWidth={1.25} size={14} className="!w-4 !h-4" />,
|
||||
},
|
||||
{
|
||||
title: 'Health check',
|
||||
prompt: 'Can you check if my database and edge functions are healthy?',
|
||||
icon: <Heart strokeWidth={1.25} size={14} className="!w-4 !h-4" />,
|
||||
},
|
||||
{
|
||||
title: 'Query your data',
|
||||
prompt:
|
||||
'Give me a list of new users from the auth.users table who signed up in the past week',
|
||||
icon: <FileText strokeWidth={1.25} size={14} className="!w-4 !h-4" />,
|
||||
},
|
||||
{
|
||||
title: 'Set up RLS policies',
|
||||
prompt: 'Create RLS policies to ensure users can only access their own data',
|
||||
icon: <Shield strokeWidth={1.25} size={14} className="!w-4 !h-4" />,
|
||||
},
|
||||
{
|
||||
title: 'Create a function',
|
||||
prompt: 'Create an edge function that summarises the contents of a table row using OpenAI',
|
||||
icon: <Code strokeWidth={1.25} size={14} className="!w-4 !h-4" />,
|
||||
},
|
||||
{
|
||||
title: 'Generate sample data',
|
||||
prompt: 'Generate sample data for a blog with users, posts, and comments tables',
|
||||
icon: <FileText strokeWidth={1.25} size={14} className="!w-4 !h-4" />,
|
||||
},
|
||||
]
|
||||
|
||||
const codeSnippetPrompts = [
|
||||
{
|
||||
title: 'Explain code',
|
||||
prompt: 'Explain what this code does and how it works',
|
||||
icon: <FileText strokeWidth={1.25} size={14} className="!w-4 !h-4" />,
|
||||
},
|
||||
{
|
||||
title: 'Improve code',
|
||||
prompt: 'How can I improve this code for better performance and readability?',
|
||||
icon: <WandSparkles strokeWidth={1.25} size={14} className="!w-4 !h-4" />,
|
||||
},
|
||||
{
|
||||
title: 'Debug issues',
|
||||
prompt: 'Help me debug any potential issues with this code',
|
||||
icon: <MessageCircleMore strokeWidth={1.25} size={14} className="!w-4 !h-4" />,
|
||||
},
|
||||
]
|
||||
|
||||
// Use suggestions if available, otherwise use code-specific prompts if snippets exist, or default prompts
|
||||
const prompts = suggestions?.prompts
|
||||
? suggestions.prompts.map((suggestion) => ({
|
||||
title: suggestion.label,
|
||||
@@ -105,37 +51,26 @@ export const AIOnboarding = ({
|
||||
<h2 className="text-2xl mb-6">How can I assist you?</h2>
|
||||
|
||||
<div className="w-full mb-6">
|
||||
{sqlSnippets && sqlSnippets.length > 0 && (
|
||||
<div className="mx-4">
|
||||
{sqlSnippets.map((snippet: string, index: number) => (
|
||||
<CollapsibleCodeBlock
|
||||
key={index}
|
||||
hideLineNumbers
|
||||
value={snippet}
|
||||
onRemove={() => onRemoveSnippet?.(index)}
|
||||
className="text-xs rounded-b-none border-b-0 text-left"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<AssistantChatForm
|
||||
textAreaRef={inputRef}
|
||||
className={cn(
|
||||
'z-20 [&>textarea]:text-base [&>textarea]:md:text-sm [&>textarea]:border-1 [&>textarea]:rounded-md [&>textarea]:!outline-none [&>textarea]:!ring-offset-0 [&>textarea]:!ring-0'
|
||||
'z-20 [&>form>textarea]:text-base [&>form>textarea]:md:text-sm [&>form>textarea]:border-1 [&>form>textarea]:rounded-md [&>form>textarea]:!outline-none [&>form>textarea]:!ring-offset-0 [&>form>textarea]:!ring-0'
|
||||
)}
|
||||
loading={false}
|
||||
disabled={false}
|
||||
placeholder="Ask me anything..."
|
||||
value={value}
|
||||
onValueChange={(e) => onValueChange(e.target.value)}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (value.trim()) {
|
||||
onMessageSend(value)
|
||||
onSubmit={(finalMessage) => {
|
||||
if (finalMessage.trim()) {
|
||||
onMessageSend(finalMessage)
|
||||
onValueChange('')
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
sqlSnippets={sqlSnippets}
|
||||
onRemoveSnippet={onRemoveSnippet}
|
||||
snippetsClassName="text-left"
|
||||
includeSnippetsInMessage={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -149,7 +84,6 @@ export const AIOnboarding = ({
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={item.icon}
|
||||
type="outline"
|
||||
className="text-xs rounded-full !h-auto py-1 px-2 text-foreground-light"
|
||||
onClick={() => {
|
||||
|
||||
142
apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx
Normal file
142
apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { useBreakpoint } from 'common'
|
||||
import { ArrowUp, Loader2 } from 'lucide-react'
|
||||
import React, { ChangeEvent, memo, useRef } from 'react'
|
||||
import { Button, ExpandingTextArea } from 'ui'
|
||||
import { cn } from 'ui/src/lib/utils'
|
||||
import { SnippetRow } from './SnippetRow'
|
||||
|
||||
export interface FormProps {
|
||||
/* The ref for the textarea, optional. Exposed for the CommandsPopover to attach events. */
|
||||
textAreaRef?: React.RefObject<HTMLTextAreaElement>
|
||||
/* The loading state of the form */
|
||||
loading: boolean
|
||||
/* The disabled state of the form */
|
||||
disabled?: boolean
|
||||
/* The value of the textarea */
|
||||
value?: string
|
||||
/* The function to handle the value change */
|
||||
onValueChange: (value: ChangeEvent<HTMLTextAreaElement>) => void
|
||||
/**
|
||||
* If true, include SQL snippets in the message sent to onSubmit
|
||||
*/
|
||||
includeSnippetsInMessage?: boolean
|
||||
/**
|
||||
* The function to handle the form submission
|
||||
*/
|
||||
onSubmit: (message: string) => void
|
||||
/* The placeholder of the textarea */
|
||||
placeholder?: string
|
||||
/* SQL snippets to display above the form */
|
||||
sqlSnippets?: string[]
|
||||
/* Function to handle removing a SQL snippet */
|
||||
onRemoveSnippet?: (index: number) => void
|
||||
/* Additional class name for the snippets container */
|
||||
snippetsClassName?: string
|
||||
/* Additional class name for the form wrapper */
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AssistantChatFormComponent = React.forwardRef<HTMLFormElement, FormProps>(
|
||||
(
|
||||
{
|
||||
loading = false,
|
||||
disabled = false,
|
||||
value = '',
|
||||
textAreaRef,
|
||||
onValueChange,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
sqlSnippets,
|
||||
onRemoveSnippet,
|
||||
snippetsClassName,
|
||||
includeSnippetsInMessage = false,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const isMobile = useBreakpoint('md')
|
||||
|
||||
const handleSubmit = (event?: React.FormEvent<HTMLFormElement>) => {
|
||||
if (event) event.preventDefault()
|
||||
if (!value || loading) return
|
||||
|
||||
let finalMessage = value
|
||||
if (includeSnippetsInMessage && sqlSnippets && sqlSnippets.length > 0) {
|
||||
const sqlSnippetsString = sqlSnippets
|
||||
.map((snippet: string) => '```sql\n' + snippet + '\n```')
|
||||
.join('\n')
|
||||
finalMessage = [value, sqlSnippetsString].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
onSubmit(finalMessage)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = !loading && !!value
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<form
|
||||
id="assistant-chat"
|
||||
ref={formRef}
|
||||
{...props}
|
||||
onSubmit={handleSubmit}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
>
|
||||
{sqlSnippets && sqlSnippets.length > 0 && (
|
||||
<SnippetRow
|
||||
snippets={sqlSnippets}
|
||||
onRemoveSnippet={onRemoveSnippet}
|
||||
className="absolute top-0 left-0 right-0 px-1.5 py-1.5"
|
||||
/>
|
||||
)}
|
||||
<ExpandingTextArea
|
||||
ref={textAreaRef}
|
||||
autoFocus={isMobile}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'text-sm pr-10 max-h-64',
|
||||
sqlSnippets && sqlSnippets.length > 0 && 'pt-10'
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
rows={3}
|
||||
value={value}
|
||||
onChange={(event) => onValueChange(event)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<div className="absolute right-1.5 bottom-1.5 flex gap-3 items-center">
|
||||
{loading && (
|
||||
<Loader2 size={22} className="animate-spin w-7 h-7 text-muted" strokeWidth={1} />
|
||||
)}
|
||||
<Button
|
||||
htmlType="submit"
|
||||
aria-label="Send message"
|
||||
icon={<ArrowUp />}
|
||||
disabled={!canSubmit}
|
||||
className={cn(
|
||||
'w-7 h-7 rounded-full p-0 text-center flex items-center justify-center',
|
||||
!canSubmit ? 'text-muted opacity-50' : 'text-default opacity-100',
|
||||
loading && 'hidden'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
AssistantChatFormComponent.displayName = 'AssistantChatFormComponent'
|
||||
|
||||
export const AssistantChatForm = memo(AssistantChatFormComponent)
|
||||
57
apps/studio/components/ui/AIAssistantPanel/SnippetRow.tsx
Normal file
57
apps/studio/components/ui/AIAssistantPanel/SnippetRow.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react'
|
||||
import { Button, CodeBlock } from 'ui'
|
||||
import { HoverCard_Shadcn_, HoverCardTrigger_Shadcn_, HoverCardContent_Shadcn_ } from 'ui'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface SnippetRowProps {
|
||||
snippets: string[]
|
||||
onRemoveSnippet?: (index: number) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const SnippetRow: React.FC<SnippetRowProps> = ({
|
||||
snippets,
|
||||
onRemoveSnippet,
|
||||
className = '',
|
||||
}) => {
|
||||
if (!snippets || snippets.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className={`w-full overflow-x-auto flex gap-2 ${className}`}>
|
||||
{snippets.map((snippet, idx) => (
|
||||
<HoverCard_Shadcn_ key={idx}>
|
||||
<HoverCardTrigger_Shadcn_ asChild>
|
||||
<div
|
||||
tabIndex={0}
|
||||
className="border inline-flex gap-1 items-center shrink-0 py-1 pl-2 rounded-full pr-1 text-xs cursor-pointer"
|
||||
>
|
||||
Snippet {idx + 1}
|
||||
{onRemoveSnippet && (
|
||||
<Button
|
||||
size="tiny"
|
||||
type="text"
|
||||
className="!h-4 !w-4 rounded-full p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemoveSnippet(idx)
|
||||
}}
|
||||
aria-label={`Remove snippet ${idx + 1}`}
|
||||
icon={<X strokeWidth={1.5} className="!h-3 !w-3" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardTrigger_Shadcn_>
|
||||
<HoverCardContent_Shadcn_ className="w-96 max-h-64 overflow-auto p-0">
|
||||
<CodeBlock
|
||||
hideLineNumbers
|
||||
className="text-xs font-mono whitespace-pre-wrap break-words p-2 border-0"
|
||||
language="sql"
|
||||
>
|
||||
{snippet}
|
||||
</CodeBlock>
|
||||
</HoverCardContent_Shadcn_>
|
||||
</HoverCard_Shadcn_>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user