-
S3 Access Keys
+
Access keys
Manage your access keys for this project.
diff --git a/apps/studio/components/interfaces/Storage/VectorsBuckets.tsx b/apps/studio/components/interfaces/Storage/VectorsBuckets.tsx
new file mode 100644
index 0000000000000..87e2dbbab12b1
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/VectorsBuckets.tsx
@@ -0,0 +1,6 @@
+import { EmptyBucketState } from './EmptyBucketState'
+
+export const VectorsBuckets = () => {
+ // Placeholder component - will be implemented in a later PR
+ return
+}
diff --git a/apps/studio/components/layouts/StorageLayout/BucketLayout.tsx b/apps/studio/components/layouts/StorageLayout/BucketLayout.tsx
deleted file mode 100644
index 53d2a252e6f2c..0000000000000
--- a/apps/studio/components/layouts/StorageLayout/BucketLayout.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { PropsWithChildren } from 'react'
-
-import { useParams } from 'common'
-import { BUCKET_TYPES, DEFAULT_BUCKET_TYPE } from 'components/interfaces/Storage/Storage.constants'
-import { DocsButton } from 'components/ui/DocsButton'
-import DefaultLayout from '../DefaultLayout'
-import { PageLayout } from '../PageLayout/PageLayout'
-import { ScaffoldContainer } from '../Scaffold'
-import StorageLayout from './StorageLayout'
-
-export const BucketTypeLayout = ({ children }: PropsWithChildren) => {
- const { bucketType } = useParams()
- const bucketTypeKey = bucketType || DEFAULT_BUCKET_TYPE
- const config = BUCKET_TYPES[bucketTypeKey as keyof typeof BUCKET_TYPES]
- const secondaryActions = [
]
-
- return (
-
-
-
- {children}
-
-
-
- )
-}
diff --git a/apps/studio/components/layouts/StorageLayout/StorageBucketsLayout.tsx b/apps/studio/components/layouts/StorageLayout/StorageBucketsLayout.tsx
new file mode 100644
index 0000000000000..1c9c46bcef535
--- /dev/null
+++ b/apps/studio/components/layouts/StorageLayout/StorageBucketsLayout.tsx
@@ -0,0 +1,58 @@
+import { useRouter } from 'next/router'
+import { PropsWithChildren, useEffect } from 'react'
+
+import { useParams } from 'common'
+import { useIsNewStorageUIEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
+import { BUCKET_TYPES } from 'components/interfaces/Storage/Storage.constants'
+import { useStorageV2Page } from 'components/interfaces/Storage/Storage.utils'
+import { DocsButton } from 'components/ui/DocsButton'
+import { PageLayout } from '../PageLayout/PageLayout'
+import { ScaffoldContainer } from '../Scaffold'
+
+export const StorageBucketsLayout = ({
+ title,
+ hideSubtitle = false,
+ children,
+}: PropsWithChildren<{ title?: string; hideSubtitle?: boolean }>) => {
+ const { ref } = useParams()
+ const router = useRouter()
+ const page = useStorageV2Page()
+ const isStorageV2 = useIsNewStorageUIEnabled()
+
+ const config = !!page && page !== 's3' ? BUCKET_TYPES[page] : undefined
+
+ const navigationItems =
+ page === 'files'
+ ? [
+ {
+ label: 'Buckets',
+ href: `/project/${ref}/storage/files`,
+ },
+ {
+ label: 'Settings',
+ href: `/project/${ref}/storage/files/settings`,
+ },
+ {
+ label: 'Policies',
+ href: `/project/${ref}/storage/files/policies`,
+ },
+ ]
+ : []
+
+ useEffect(() => {
+ if (!isStorageV2) router.replace(`/project/${ref}/storage/buckets`)
+ }, [isStorageV2, ref, router])
+
+ return (
+
] : []}
+ >
+
{children}
+
+ )
+}
diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx
index 757ee95ff9cf0..a723b64bf16f9 100644
--- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx
@@ -10,6 +10,7 @@ import { LOCAL_STORAGE_KEYS, useFlag } from 'common'
import { useParams, useSearchParamsShallow } from 'common/hooks'
import { Markdown } from 'components/interfaces/Markdown'
import { useCheckOpenAIKeyQuery } from 'data/ai/check-api-key-query'
+import { useRateMessageMutation } from 'data/ai/rate-message-mutation'
import { constructHeaders } from 'data/fetchers'
import { useTablesQuery } from 'data/tables/tables-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
@@ -83,10 +84,13 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
const [value, setValue] = useState
(snap.initialInput || '')
const [editingMessageId, setEditingMessageId] = useState(null)
const [isResubmitting, setIsResubmitting] = useState(false)
+ const [messageRatings, setMessageRatings] = useState>({})
const { data: check, isSuccess } = useCheckOpenAIKeyQuery()
const isApiKeySet = IS_PLATFORM || !!check?.hasKey
+ const { mutateAsync: rateMessage } = useRateMessageMutation()
+
const isInSQLEditor = router.pathname.includes('/sql/[id]')
const snippet = snippets[entityId ?? '']
const snippetContent = snippet?.snippet?.content?.sql
@@ -260,6 +264,47 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
setValue('')
}, [setValue])
+ const handleRateMessage = useCallback(
+ async (messageId: string, rating: 'positive' | 'negative', reason?: string) => {
+ if (!project?.ref || !selectedOrganization?.slug) return
+
+ // Optimistically update UI
+ setMessageRatings((prev) => ({ ...prev, [messageId]: rating }))
+
+ try {
+ const result = await rateMessage({
+ rating,
+ messages: chatMessages,
+ messageId,
+ projectRef: project.ref,
+ orgSlug: selectedOrganization.slug,
+ reason,
+ })
+
+ sendEvent({
+ action: 'assistant_message_rating_submitted',
+ properties: {
+ rating,
+ category: result.category,
+ ...(reason && { reason }),
+ },
+ groups: {
+ project: project.ref,
+ organization: selectedOrganization.slug,
+ },
+ })
+ } catch (error) {
+ console.error('Failed to rate message:', error)
+ // Rollback on error
+ setMessageRatings((prev) => {
+ const { [messageId]: _, ...rest } = prev
+ return rest
+ })
+ }
+ },
+ [chatMessages, project?.ref, selectedOrganization?.slug, rateMessage, sendEvent]
+ )
+
const renderedMessages = useMemo(
() =>
chatMessages.map((message, index) => {
@@ -283,6 +328,8 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
isBeingEdited={isBeingEdited}
onCancelEdit={cancelEdit}
isLastMessage={isLastMessage}
+ onRate={handleRateMessage}
+ rating={messageRatings[message.id] ?? null}
/>
)
}),
@@ -294,6 +341,8 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
editingMessageId,
chatStatus,
addToolResult,
+ handleRateMessage,
+ messageRatings,
]
)
diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx
index 140f8c08804ae..2170e2afee825 100644
--- a/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx
@@ -1,13 +1,33 @@
-import { Pencil, Trash2 } from 'lucide-react'
-import { type PropsWithChildren } from 'react'
+import { Pencil, ThumbsDown, ThumbsUp, Trash2 } from 'lucide-react'
+import { type PropsWithChildren, useState, useEffect } from 'react'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useForm } from 'react-hook-form'
+import * as z from 'zod'
import { ButtonTooltip } from '../ButtonTooltip'
+import {
+ cn,
+ Button,
+ Popover_Shadcn_,
+ PopoverTrigger_Shadcn_,
+ PopoverContent_Shadcn_,
+ Form_Shadcn_,
+ FormField_Shadcn_,
+ FormControl_Shadcn_,
+ TextArea_Shadcn_,
+} from 'ui'
+import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
-export function MessageActions({ children }: PropsWithChildren<{}>) {
+export function MessageActions({
+ children,
+ alwaysShow = false,
+}: PropsWithChildren<{ alwaysShow?: boolean }>) {
return (
-
{children}
+
+ {children}
+
)
}
@@ -44,3 +64,147 @@ function MessageActionsDelete({ onClick }: { onClick: () => void }) {
)
}
MessageActions.Delete = MessageActionsDelete
+
+function MessageActionsThumbsUp({
+ onClick,
+ isActive,
+ disabled,
+}: {
+ onClick: () => void
+ isActive?: boolean
+ disabled?: boolean
+}) {
+ return (
+
+ }
+ onClick={onClick}
+ className={cn('p-1 rounded transition-colors', disabled && 'opacity-50 pointer-events-none')}
+ title="Good response"
+ aria-label="Good response"
+ />
+ )
+}
+MessageActions.ThumbsUp = MessageActionsThumbsUp
+
+const feedbackSchema = z.object({
+ reason: z.string().optional(),
+})
+
+type FeedbackFormValues = z.infer
+
+function MessageActionsThumbsDown({
+ onClick,
+ isActive,
+ disabled,
+}: {
+ onClick: (reason?: string) => void
+ isActive?: boolean
+ disabled?: boolean
+}) {
+ const [open, setOpen] = useState(false)
+
+ const form = useForm({
+ resolver: zodResolver(feedbackSchema),
+ defaultValues: { reason: '' },
+ mode: 'onSubmit',
+ })
+
+ const handleOpenChange = (newOpen: boolean) => {
+ if (disabled) return
+ // When popover closes, submit the rating if not already submitted
+ if (!newOpen && open && !form.formState.isSubmitSuccessful) {
+ onClick()
+ }
+ setOpen(newOpen)
+ if (!newOpen) {
+ form.reset()
+ }
+ }
+
+ const onSubmit = (values: FeedbackFormValues) => {
+ onClick(values.reason || undefined)
+ }
+
+ // Auto-close popover after showing thank you message
+ useEffect(() => {
+ if (form.formState.isSubmitSuccessful) {
+ const timer = setTimeout(() => {
+ setOpen(false)
+ }, 2000)
+ return () => clearTimeout(timer)
+ }
+ }, [form.formState.isSubmitSuccessful])
+
+ return (
+
+
+ !disabled && setOpen(true)}
+ className={cn(
+ 'p-1 rounded transition-colors',
+ disabled && 'opacity-50 pointer-events-none'
+ )}
+ title="Bad response"
+ aria-label="Bad response"
+ >
+
+
+
+
+ {form.formState.isSubmitSuccessful ? (
+ We appreciate your feedback!
+ ) : (
+
+
+
+ )}
+
+
+ )
+}
+MessageActions.ThumbsDown = MessageActionsThumbsDown
diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Context.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Context.tsx
index 70a2ff87c8ef1..e9905ce824f45 100644
--- a/apps/studio/components/ui/AIAssistantPanel/Message.Context.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/Message.Context.tsx
@@ -18,6 +18,7 @@ export interface MessageInfo {
isLastMessage?: boolean
state: 'idle' | 'editing' | 'predecessor-editing'
+ rating?: 'positive' | 'negative' | null
}
export interface MessageActions {
@@ -26,6 +27,7 @@ export interface MessageActions {
onDelete: (id: string) => void
onEdit: (id: string) => void
onCancelEdit: () => void
+ onRate?: (id: string, rating: 'positive' | 'negative', reason?: string) => void
}
const MessageInfoContext = createContext(null)
diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.tsx
index 61c3f6f2fc702..7796683852525 100644
--- a/apps/studio/components/ui/AIAssistantPanel/Message.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/Message.tsx
@@ -10,8 +10,12 @@ import { MessageDisplay } from './Message.Display'
import { MessageProvider, useMessageActionsContext, useMessageInfoContext } from './Message.Context'
function AssistantMessage({ message }: { message: VercelMessage }) {
- const { variant, state } = useMessageInfoContext()
- const { onCancelEdit } = useMessageActionsContext()
+ const { id, variant, state, isLastMessage, readOnly, rating, isLoading } = useMessageInfoContext()
+ const { onCancelEdit, onRate } = useMessageActionsContext()
+
+ const handleRate = (newRating: 'positive' | 'negative', reason?: string) => {
+ onRate?.(id, newRating, reason)
+ }
return (
+ {!readOnly && isLastMessage && onRate && !isLoading && (
+
+ handleRate('positive')}
+ isActive={rating === 'positive'}
+ disabled={!!rating}
+ />
+ handleRate('negative', reason)}
+ isActive={rating === 'negative'}
+ disabled={!!rating}
+ />
+
+ )}
)
}
@@ -81,6 +99,8 @@ interface MessageProps {
isBeingEdited: boolean
onCancelEdit: () => void
isLastMessage?: boolean
+ onRate?: (id: string, rating: 'positive' | 'negative', reason?: string) => void
+ rating?: 'positive' | 'negative' | null
}
export function Message(props: MessageProps) {
@@ -99,6 +119,7 @@ export function Message(props: MessageProps) {
? 'predecessor-editing'
: 'idle',
isLastMessage: props.isLastMessage,
+ rating: props.rating,
} satisfies MessageInfo
const messageActions = {
@@ -106,6 +127,7 @@ export function Message(props: MessageProps) {
onDelete: props.onDelete,
onEdit: props.onEdit,
onCancelEdit: props.onCancelEdit,
+ onRate: props.onRate,
}
return (
diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts b/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts
index cf80eb9572ca1..16f6125896e45 100644
--- a/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts
+++ b/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts
@@ -96,3 +96,18 @@ export const deployEdgeFunctionInputSchema = z
export const deployEdgeFunctionOutputSchema = z
.object({ success: z.boolean().optional() })
.passthrough()
+
+export const rateMessageResponseSchema = z.object({
+ category: z.enum([
+ 'sql_generation',
+ 'schema_design',
+ 'rls_policies',
+ 'edge_functions',
+ 'database_optimization',
+ 'debugging',
+ 'general_help',
+ 'other',
+ ]),
+})
+
+export type RateMessageResponse = z.infer
diff --git a/apps/studio/data/ai/rate-message-mutation.ts b/apps/studio/data/ai/rate-message-mutation.ts
new file mode 100644
index 0000000000000..923943e6cfe99
--- /dev/null
+++ b/apps/studio/data/ai/rate-message-mutation.ts
@@ -0,0 +1,74 @@
+import { useMutation, UseMutationOptions } from '@tanstack/react-query'
+import { UIMessage } from '@ai-sdk/react'
+
+import { constructHeaders, fetchHandler } from 'data/fetchers'
+import { BASE_PATH } from 'lib/constants'
+import { ResponseError } from 'types'
+import type { RateMessageResponse } from 'components/ui/AIAssistantPanel/Message.utils'
+
+export type RateMessageVariables = {
+ rating: 'positive' | 'negative'
+ messages: UIMessage[]
+ messageId: string
+ projectRef: string
+ orgSlug?: string
+ reason?: string
+}
+
+export async function rateMessage({
+ rating,
+ messages,
+ messageId,
+ projectRef,
+ orgSlug,
+ reason,
+}: RateMessageVariables) {
+ const url = `${BASE_PATH}/api/ai/feedback/rate`
+
+ const headers = await constructHeaders({ 'Content-Type': 'application/json' })
+ const response = await fetchHandler(url, {
+ headers,
+ method: 'POST',
+ body: JSON.stringify({ rating, messages, messageId, projectRef, orgSlug, reason }),
+ })
+
+ let body: any
+
+ try {
+ body = await response.json()
+ } catch {}
+
+ if (!response.ok) {
+ throw new ResponseError(body?.message, response.status)
+ }
+
+ return body as RateMessageResponse
+}
+
+type RateMessageData = Awaited>
+
+export const useRateMessageMutation = ({
+ onSuccess,
+ onError,
+ ...options
+}: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+> = {}) => {
+ return useMutation(
+ (vars) => rateMessage(vars),
+ {
+ async onSuccess(data, variables, context) {
+ await onSuccess?.(data, variables, context)
+ },
+ async onError(data, variables, context) {
+ if (onError === undefined) {
+ console.error(`Failed to rate message: ${data.message}`)
+ } else {
+ onError(data, variables, context)
+ }
+ },
+ ...options,
+ }
+ )
+}
diff --git a/apps/studio/data/config/project-storage-config-query.ts b/apps/studio/data/config/project-storage-config-query.ts
index 058fa95e9a93b..be158636207ae 100644
--- a/apps/studio/data/config/project-storage-config-query.ts
+++ b/apps/studio/data/config/project-storage-config-query.ts
@@ -2,9 +2,9 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { components } from 'data/api'
import { get, handleError } from 'data/fetchers'
+import { IS_PLATFORM } from 'lib/constants'
import type { ResponseError } from 'types'
import { configKeys } from './keys'
-import { IS_PLATFORM } from 'lib/constants'
export type ProjectStorageConfigVariables = {
projectRef?: string
@@ -23,7 +23,15 @@ export async function getProjectStorageConfig(
signal,
})
- if (error) handleError(error)
+ if (error) {
+ // [Joshen] This is due to API not returning an error message on this endpoint if a 404 is returned
+ // Should only be a temporary patch, needs to be addressed on the API end
+ if ((error as any).code === 404) {
+ handleError({ ...(error as any), message: 'Storage configuration not found.' })
+ } else {
+ handleError(error)
+ }
+ }
return data
}
diff --git a/apps/studio/lib/api/rate.test.ts b/apps/studio/lib/api/rate.test.ts
new file mode 100644
index 0000000000000..499cfd5d0c315
--- /dev/null
+++ b/apps/studio/lib/api/rate.test.ts
@@ -0,0 +1,74 @@
+import { expect, test, vi } from 'vitest'
+// End of third-party imports
+
+import rate from '../../pages/api/ai/feedback/rate'
+import { sanitizeMessagePart } from '../ai/tools/tool-sanitizer'
+
+vi.mock('../ai/tools/tool-sanitizer', () => ({
+ sanitizeMessagePart: vi.fn((part) => part),
+}))
+
+test('rate calls the tool sanitizer', async () => {
+ const mockReq = {
+ method: 'POST',
+ headers: {
+ authorization: 'Bearer test-token',
+ },
+ body: {
+ rating: 'negative',
+ messages: [
+ {
+ role: 'assistant',
+ parts: [
+ {
+ type: 'tool-execute_sql',
+ state: 'output-available',
+ output: 'test output',
+ },
+ ],
+ },
+ ],
+ messageId: 'test-message-id',
+ projectRef: 'test-project',
+ orgSlug: 'test-org',
+ reason: 'The response was not helpful',
+ },
+ on: vi.fn(),
+ }
+
+ const mockRes = {
+ status: vi.fn(() => mockRes),
+ json: vi.fn(() => mockRes),
+ setHeader: vi.fn(() => mockRes),
+ }
+
+ vi.mock('lib/ai/org-ai-details', () => ({
+ getOrgAIDetails: vi.fn().mockResolvedValue({
+ aiOptInLevel: 'schema_and_log_and_data',
+ isLimited: false,
+ }),
+ }))
+
+ vi.mock('lib/ai/model', () => ({
+ getModel: vi.fn().mockResolvedValue({
+ model: {},
+ error: null,
+ }),
+ }))
+
+ vi.mock('ai', () => ({
+ generateObject: vi.fn().mockResolvedValue({
+ object: {
+ category: 'sql_generation',
+ },
+ }),
+ }))
+
+ vi.mock('components/ui/AIAssistantPanel/Message.utils', () => ({
+ rateMessageResponseSchema: {},
+ }))
+
+ await rate(mockReq as any, mockRes as any)
+
+ expect(sanitizeMessagePart).toHaveBeenCalled()
+})
diff --git a/apps/studio/middleware.ts b/apps/studio/middleware.ts
index 5327cb589e3a9..f531e68729188 100644
--- a/apps/studio/middleware.ts
+++ b/apps/studio/middleware.ts
@@ -8,6 +8,7 @@ export const config = {
// [Joshen] Return 404 for all next.js API endpoints EXCEPT the ones we use in hosted:
const HOSTED_SUPPORTED_API_URLS = [
'/ai/sql/generate-v4',
+ '/ai/feedback/rate',
'/ai/code/complete',
'/ai/sql/cron-v2',
'/ai/sql/title-v2',
diff --git a/apps/studio/pages/api/ai/feedback/rate.ts b/apps/studio/pages/api/ai/feedback/rate.ts
new file mode 100644
index 0000000000000..2892cabb6d5ce
--- /dev/null
+++ b/apps/studio/pages/api/ai/feedback/rate.ts
@@ -0,0 +1,155 @@
+import { generateObject } from 'ai'
+import { NextApiRequest, NextApiResponse } from 'next'
+import { z } from 'zod'
+
+import { IS_PLATFORM } from 'common'
+import type { AiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi'
+import { getModel } from 'lib/ai/model'
+import { getOrgAIDetails } from 'lib/ai/org-ai-details'
+import { sanitizeMessagePart } from 'lib/ai/tools/tool-sanitizer'
+import apiWrapper from 'lib/api/apiWrapper'
+import { rateMessageResponseSchema } from 'components/ui/AIAssistantPanel/Message.utils'
+
+export const maxDuration = 30
+
+async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const { method } = req
+
+ switch (method) {
+ case 'POST':
+ return handlePost(req, res)
+ default:
+ res.setHeader('Allow', ['POST'])
+ res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
+ }
+}
+
+const requestBodySchema = z.object({
+ rating: z.enum(['positive', 'negative']),
+ messages: z.array(z.any()),
+ messageId: z.string(),
+ projectRef: z.string(),
+ orgSlug: z.string().optional(),
+ reason: z.string().optional(),
+})
+
+export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
+ const authorization = req.headers.authorization
+ const accessToken = authorization?.replace('Bearer ', '')
+
+ if (IS_PLATFORM && !accessToken) {
+ return res.status(401).json({ error: 'Authorization token is required' })
+ }
+
+ const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
+ const { data, error: parseError } = requestBodySchema.safeParse(body)
+
+ if (parseError) {
+ return res.status(400).json({ error: 'Invalid request body', issues: parseError.issues })
+ }
+
+ const { rating, messages: rawMessages, projectRef, orgSlug, reason } = data
+
+ let aiOptInLevel: AiOptInLevel = 'disabled'
+
+ if (!IS_PLATFORM) {
+ aiOptInLevel = 'schema'
+ }
+
+ if (IS_PLATFORM && orgSlug && authorization && projectRef) {
+ try {
+ // Get organizations and compute opt in level server-side
+ const { aiOptInLevel: orgAIOptInLevel, isLimited: orgAILimited } = await getOrgAIDetails({
+ orgSlug,
+ authorization,
+ projectRef,
+ })
+
+ aiOptInLevel = orgAIOptInLevel
+ } catch (error) {
+ return res.status(400).json({
+ error: 'There was an error fetching your organization details',
+ })
+ }
+ }
+
+ // Only returns last 7 messages
+ // Filters out tool outputs based on opt-in level using sanitizeMessagePart
+ const messages = (rawMessages || []).slice(-7).map((msg: any) => {
+ if (msg && msg.role === 'assistant' && 'results' in msg) {
+ const cleanedMsg = { ...msg }
+ delete cleanedMsg.results
+ return cleanedMsg
+ }
+ if (msg && msg.role === 'assistant' && msg.parts) {
+ const cleanedParts = msg.parts.map((part: any) => {
+ return sanitizeMessagePart(part, aiOptInLevel)
+ })
+ return { ...msg, parts: cleanedParts }
+ }
+ return msg
+ })
+
+ try {
+ const { model, error: modelError } = await getModel({
+ provider: 'openai',
+ isLimited: true,
+ routingKey: 'feedback',
+ })
+
+ if (modelError) {
+ return res.status(500).json({ error: modelError.message })
+ }
+
+ const { object } = await generateObject({
+ model,
+ schema: rateMessageResponseSchema,
+ prompt: `
+Your job is to look at a Supabase Assistant conversation, which the user has given feedback on, and classify it.
+
+The user gave this feedback: ${rating === 'positive' ? 'THUMBS UP (positive)' : 'THUMBS DOWN (negative)'}
+${reason ? `\nUser's reason: ${reason}` : ''}
+
+Raw conversation:
+${JSON.stringify(messages)}
+
+Instructions:
+1. Classify the conversation into ONE of these categories:
+ - sql_generation: Generating SQL queries, DML statements
+ - schema_design: Creating tables, columns, relationships
+ - rls_policies: Row Level Security policies
+ - edge_functions: Edge Functions or serverless functions
+ - database_optimization: Performance, indexes, optimization
+ - debugging: Helping debug errors or issues
+ - general_help: General questions about Supabase features
+ - other: Anything else
+`,
+ })
+
+ return res.json({
+ category: object.category,
+ })
+ } catch (error) {
+ if (error instanceof Error) {
+ console.error(`Classifying feedback failed:`, error)
+
+ // Check for context length error
+ if (error.message.includes('context_length') || error.message.includes('too long')) {
+ return res.status(400).json({
+ error: 'The conversation is too large to analyze',
+ })
+ }
+ } else {
+ console.error(`Unknown error: ${error}`)
+ }
+
+ return res.status(500).json({
+ error: 'There was an unknown error analyzing the feedback.',
+ })
+ }
+}
+
+const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
+ apiWrapper(req, res, handler, { withAuth: true })
+
+export default wrapper
diff --git a/apps/studio/pages/project/[ref]/storage/[bucketType]/index.tsx b/apps/studio/pages/project/[ref]/storage/[bucketType]/index.tsx
deleted file mode 100644
index c762752bc478d..0000000000000
--- a/apps/studio/pages/project/[ref]/storage/[bucketType]/index.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { useRouter } from 'next/router'
-import { useEffect } from 'react'
-
-import { useParams } from 'common'
-import { useIsNewStorageUIEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
-import { BUCKET_TYPES, DEFAULT_BUCKET_TYPE } from 'components/interfaces/Storage/Storage.constants'
-import { BucketTypeLayout } from 'components/layouts/StorageLayout/BucketLayout'
-import type { NextPageWithLayout } from 'types'
-
-const BucketTypePage: NextPageWithLayout = () => {
- const router = useRouter()
- const { bucketType, ref } = useParams()
- const isStorageV2 = useIsNewStorageUIEnabled()
-
- const bucketTypeKey = bucketType || DEFAULT_BUCKET_TYPE
- const config = BUCKET_TYPES[bucketTypeKey as keyof typeof BUCKET_TYPES]
-
- useEffect(() => {
- if (!isStorageV2) router.replace(`/project/${ref}/storage`)
- }, [isStorageV2, ref])
-
- useEffect(() => {
- if (!config) {
- router.replace(`/project/${ref}/storage`)
- }
- }, [config, ref, router])
-
- return (
-
- {/* [Danny] Purposefully duplicated directly below StorageLayout's config.description for now. Will be placed in a conditional empty state in next PR. TODO: consider reusing FormHeader for non-empty state.*/}
- {/*
{config.description}
*/}
-
- )
-}
-
-BucketTypePage.getLayout = (page) => {
- return {page}
-}
-
-export default BucketTypePage
diff --git a/apps/studio/pages/project/[ref]/storage/analytics/index.tsx b/apps/studio/pages/project/[ref]/storage/analytics/index.tsx
new file mode 100644
index 0000000000000..180782e9a42c6
--- /dev/null
+++ b/apps/studio/pages/project/[ref]/storage/analytics/index.tsx
@@ -0,0 +1,19 @@
+import { AnalyticsBuckets } from 'components/interfaces/Storage/AnalyticsBuckets'
+import DefaultLayout from 'components/layouts/DefaultLayout'
+import { StorageBucketsLayout } from 'components/layouts/StorageLayout/StorageBucketsLayout'
+import StorageLayout from 'components/layouts/StorageLayout/StorageLayout'
+import type { NextPageWithLayout } from 'types'
+
+const StorageAnalyticsPage: NextPageWithLayout = () => {
+ return
+}
+
+StorageAnalyticsPage.getLayout = (page) => (
+
+
+ {page}
+
+
+)
+
+export default StorageAnalyticsPage
diff --git a/apps/studio/pages/project/[ref]/storage/files/index.tsx b/apps/studio/pages/project/[ref]/storage/files/index.tsx
new file mode 100644
index 0000000000000..3d92fda273cc1
--- /dev/null
+++ b/apps/studio/pages/project/[ref]/storage/files/index.tsx
@@ -0,0 +1,19 @@
+import { FilesBuckets } from 'components/interfaces/Storage/FilesBuckets'
+import DefaultLayout from 'components/layouts/DefaultLayout'
+import { StorageBucketsLayout } from 'components/layouts/StorageLayout/StorageBucketsLayout'
+import StorageLayout from 'components/layouts/StorageLayout/StorageLayout'
+import type { NextPageWithLayout } from 'types'
+
+const StorageFilesPage: NextPageWithLayout = () => {
+ return
+}
+
+StorageFilesPage.getLayout = (page) => (
+
+
+ {page}
+
+
+)
+
+export default StorageFilesPage
diff --git a/apps/studio/pages/project/[ref]/storage/files/policies.tsx b/apps/studio/pages/project/[ref]/storage/files/policies.tsx
new file mode 100644
index 0000000000000..114bac7ebcdf8
--- /dev/null
+++ b/apps/studio/pages/project/[ref]/storage/files/policies.tsx
@@ -0,0 +1,19 @@
+import { StoragePolicies } from 'components/interfaces/Storage/StoragePolicies/StoragePolicies'
+import DefaultLayout from 'components/layouts/DefaultLayout'
+import { StorageBucketsLayout } from 'components/layouts/StorageLayout/StorageBucketsLayout'
+import StorageLayout from 'components/layouts/StorageLayout/StorageLayout'
+import type { NextPageWithLayout } from 'types'
+
+const FilesPoliciesPage: NextPageWithLayout = () => {
+ return
+}
+
+FilesPoliciesPage.getLayout = (page) => (
+
+
+ {page}
+
+
+)
+
+export default FilesPoliciesPage
diff --git a/apps/studio/pages/project/[ref]/storage/files/settings.tsx b/apps/studio/pages/project/[ref]/storage/files/settings.tsx
new file mode 100644
index 0000000000000..187c39ce2ac6f
--- /dev/null
+++ b/apps/studio/pages/project/[ref]/storage/files/settings.tsx
@@ -0,0 +1,32 @@
+import { StorageSettings } from 'components/interfaces/Storage/StorageSettings/StorageSettings'
+import DefaultLayout from 'components/layouts/DefaultLayout'
+import {
+ ScaffoldSection,
+ ScaffoldSectionDescription,
+ ScaffoldSectionTitle,
+} from 'components/layouts/Scaffold'
+import { StorageBucketsLayout } from 'components/layouts/StorageLayout/StorageBucketsLayout'
+import StorageLayout from 'components/layouts/StorageLayout/StorageLayout'
+import type { NextPageWithLayout } from 'types'
+
+const FilesSettingsPage: NextPageWithLayout = () => {
+ return (
+
+ Global settings
+
+ Set limits or transformations across all file buckets.
+
+
+
+ )
+}
+
+FilesSettingsPage.getLayout = (page) => (
+
+
+ {page}
+
+
+)
+
+export default FilesSettingsPage
diff --git a/apps/studio/pages/project/[ref]/storage/s3.tsx b/apps/studio/pages/project/[ref]/storage/s3.tsx
new file mode 100644
index 0000000000000..88e30cd3ab06e
--- /dev/null
+++ b/apps/studio/pages/project/[ref]/storage/s3.tsx
@@ -0,0 +1,21 @@
+import { S3Connection } from 'components/interfaces/Storage/StorageSettings/S3Connection'
+import DefaultLayout from 'components/layouts/DefaultLayout'
+import { StorageBucketsLayout } from 'components/layouts/StorageLayout/StorageBucketsLayout'
+import StorageLayout from 'components/layouts/StorageLayout/StorageLayout'
+import type { NextPageWithLayout } from 'types'
+
+const S3SettingsPage: NextPageWithLayout = () => {
+ return
+}
+
+S3SettingsPage.getLayout = (page) => (
+
+
+
+ {page}
+
+
+
+)
+
+export default S3SettingsPage
diff --git a/apps/studio/pages/project/[ref]/storage/vectors/index.tsx b/apps/studio/pages/project/[ref]/storage/vectors/index.tsx
new file mode 100644
index 0000000000000..edc9ae1886bdd
--- /dev/null
+++ b/apps/studio/pages/project/[ref]/storage/vectors/index.tsx
@@ -0,0 +1,19 @@
+import { VectorsBuckets } from 'components/interfaces/Storage/VectorsBuckets'
+import DefaultLayout from 'components/layouts/DefaultLayout'
+import { StorageBucketsLayout } from 'components/layouts/StorageLayout/StorageBucketsLayout'
+import StorageLayout from 'components/layouts/StorageLayout/StorageLayout'
+import type { NextPageWithLayout } from 'types'
+
+const StorageVectorsPage: NextPageWithLayout = () => {
+ return
+}
+
+StorageVectorsPage.getLayout = (page) => (
+
+
+ {page}
+
+
+)
+
+export default StorageVectorsPage
diff --git a/apps/www/_blog/2025-10-03-remote-mcp-server.mdx b/apps/www/_blog/2025-10-03-remote-mcp-server.mdx
index fdcb0bc33cf2a..737c7b0c175b5 100644
--- a/apps/www/_blog/2025-10-03-remote-mcp-server.mdx
+++ b/apps/www/_blog/2025-10-03-remote-mcp-server.mdx
@@ -13,7 +13,7 @@ date: '2025-10-03:10:00:00'
toc_depth: 2
---
-Today we are launching our remote MCP server, allowing you to connect your Supabase projects with _many_ more AI agents than before, including ChatGPT, Claude, and Builder.io. We also added support for MCP auth (OAuth2), a faster and more secure way to connect agents with your Supabase account (via browser-based authentication). Last but not least, we’re adding official MCP support for local Supabase instances created through the [CLI](https://supabase.com/docs/guides/local-development/cli/getting-started).
+Today we are launching our remote MCP server, allowing you to connect your Supabase projects with _many_ more AI agents than before, including ChatGPT, Claude, and Builder.io. We also added support for MCP auth (OAuth2), a faster and more secure way to connect agents with your Supabase account (via browser-based authentication). Last but not least, we're adding official MCP support for local Supabase instances created through the [CLI](https://supabase.com/docs/guides/local-development/cli/getting-started).
Now all you need is a single URL to connect your favorite AI agent to Supabase:
@@ -21,7 +21,7 @@ Now all you need is a single URL to connect your favorite AI agent to Supabase:
https://mcp.supabase.com/mcp
```
-Or if you’re running Supabase locally:
+Or if you're running Supabase locally:
```bash
http://localhost:54321/mcp
@@ -67,16 +67,14 @@ https://mcp.supabase.com/mcp
# or http://localhost:54321/mcp for local
```
-We built this interactive widget to help you connect popular MCP clients to Supabase and customize the URL to your preferences (like project-scoped mode and read-only mode):
-
-{/* __ */}
+We also built an [interactive widget](https://supabase.com/mcp) to help you connect popular MCP clients to Supabase and customize the URL to your preferences (like project-scoped mode and read-only mode).
## New features
Our philosophy on Supabase MCP comes down to two ideas:
-1. Supabase MCP should be used for development. It was designed from the beginning to assist with app development and shouldn’t be connected to production databases. See our post on [Defense in Depth for MCP Servers](https://supabase.com/blog/defense-in-depth-mcp).
-2. MCP is just another way to access the same platform features that you already use in the web dashboard and CLI. But - since it’s being used by an LLM, it should also lean into the strengths of an AI-first UX.
+1. Supabase MCP should be used for development. It was designed from the beginning to assist with app development and shouldn't be connected to production databases. See our post on [Defense in Depth for MCP Servers](https://supabase.com/blog/defense-in-depth-mcp).
+2. MCP is just another way to access the same platform features that you already use in the web dashboard and CLI. But - since it's being used by an LLM, it should also lean into the strengths of an AI-first UX.
With these in mind, we added the following new features to assist AI agents while they help build your app:
@@ -121,16 +119,20 @@ Our solution is a feature that already exists on our platform - advisors. [Advis
### Storage
-We also added initial support for [Supabase Storage](https://supabase.com/docs/guides/storage) on our MCP server. This first version allows your agent to see which buckets exist on your project and update their configuration, but in the future we’ll look into more abilities like listing files and their details.
+We also added initial support for [Supabase Storage](https://supabase.com/docs/guides/storage) on our MCP server. This first version allows your agent to see which buckets exist on your project and update their configuration, but in the future we'll look into more abilities like listing files and their details.
-This feature was actually a community contribution (thanks [Nico](https://github.com/Ngineer101)!). If there are ever missing features that you’d like to see, [PR’s](https://github.com/supabase-community/supabase-mcp/issues/new/choose) are always welcome!
+This feature was actually a community contribution (thanks [Nico](https://github.com/Ngineer101)!). If there are ever missing features that you'd like to see, [PR's](https://github.com/supabase-community/supabase-mcp/issues/new/choose) are always welcome!
-## What’s next?
+## What's next?
We have more exciting plans for MCP at Supabase:
-1. **Security:** Today our OAuth2 implementation requires you to make a binary decision on permissions: either grant _all_ permissions to your MCP client, or _none_. This isn’t ideal if you know that you never want to, say, allow your client to access to your Edge Functions.
+1. **Security:** Today our OAuth2 implementation requires you to make a binary decision on permissions: either grant _all_ permissions to your MCP client, or _none_. This isn't ideal if you know that you never want to, say, allow your client to access to your Edge Functions.
+
+ To improve this, we're working to support fine-grain permissions that can be toggled during authorization. It's a big task to re-work our permission infrastructure to support this, but we believe it's worth it.
+
+2. **Double down on local:** We're very excited to support local Supabase instances in this release, but we also believe there is a lot more that can be done. Supabase MCP is designed to be used for development, so we want the local experience to be first-class.
-To improve this, we’re working to support fine-grain permissions that can be toggled during authorization. It’s a big task to re-work our permission infrastructure to support this, but we believe it’s worth it. 2. **Double down on local:** We’re very excited to support local Supabase instances in this release, but we also believe there is a lot more that can be done. Supabase MCP is designed to be used for development, so we want the local experience to be first-class. 3. **Build your own MCP:** You might have thought about building _your own_ MCP server on top of Supabase. We’re using the playbook and lessons learned from our own MCP server to provide the tools you need to do the same - including remote MCP and auth. Stay tuned!
+3. **Build your own MCP:** You might have thought about building _your own_ MCP server on top of Supabase. We're using the playbook and lessons learned from our own MCP server to provide the tools you need to do the same - including remote MCP and auth. Stay tuned!
-We’re keen to continue investing in MCP and excited to see how you use these new features!
+We're keen to continue investing in MCP and excited to see how you use these new features!
diff --git a/apps/www/pages/terms.mdx b/apps/www/pages/terms.mdx
index f9ea22db845c1..17f2839f1d72b 100644
--- a/apps/www/pages/terms.mdx
+++ b/apps/www/pages/terms.mdx
@@ -12,7 +12,7 @@ export const meta = {
_Last Modified: 11 July 2025_
-These Terms of Service (this "**Agreement**") are a binding contract between you ("**Customer**," "**you**," or "**your**") and Supabase, Inc., a Delaware corporation with offices located at 970 Toa Payoh North #07-04, Singapore 318992 ("**Supabase**," "**we**," or "**us**"). This Agreement governs your access to and use of the Cloud Services. Supabase and Customer may be referred to herein collectively as the "**Parties**" or individually as a "**Party**."
+These Terms of Service (this "**Agreement**") are a binding contract between you ("**Customer**," "**you**," or "**your**") and Supabase, Inc., a Delaware corporation with offices located at 65 Chulia Street #38-02/03, OCBC Centre, Singapore 049513 ("**Supabase**," "**we**," or "**us**"). This Agreement governs your access to and use of the Cloud Services. Supabase and Customer may be referred to herein collectively as the "**Parties**" or individually as a "**Party**."
## Agreement Acceptance
diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts
index f12911ecde72d..facb0c6d65821 100644
--- a/packages/common/telemetry-constants.ts
+++ b/packages/common/telemetry-constants.ts
@@ -1189,6 +1189,35 @@ export interface AiAssistantInSupportFormClickedEvent {
groups: Partial
}
+/**
+ * User rated an AI assistant message with thumbs up or thumbs down.
+ *
+ * @group Events
+ * @source studio
+ */
+export interface AssistantMessageRatingSubmittedEvent {
+ action: 'assistant_message_rating_submitted'
+ properties: {
+ /**
+ * The rating given by the user: positive (thumbs up) or negative (thumbs down)
+ */
+ rating: 'positive' | 'negative'
+ /**
+ * The category of the conversation
+ */
+ category:
+ | 'sql_generation'
+ | 'schema_design'
+ | 'rls_policies'
+ | 'edge_functions'
+ | 'database_optimization'
+ | 'debugging'
+ | 'general_help'
+ | 'other'
+ }
+ groups: TelemetryGroups
+}
+
/**
* User copied the command for a Supabase UI component.
*
@@ -1871,6 +1900,7 @@ export type TelemetryEvent =
| AssistantSuggestionRunQueryClickedEvent
| AssistantSqlDiffHandlerEvaluatedEvent
| AssistantEditInSqlEditorClickedEvent
+ | AssistantMessageRatingSubmittedEvent
| DocsFeedbackClickedEvent
| HomepageFrameworkQuickstartClickedEvent
| HomepageProductCardClickedEvent
diff --git a/packages/ui-patterns/src/McpUrlBuilder/McpConfigPanel.tsx b/packages/ui-patterns/src/McpUrlBuilder/McpConfigPanel.tsx
index 709130225f38a..c6463094a940e 100644
--- a/packages/ui-patterns/src/McpUrlBuilder/McpConfigPanel.tsx
+++ b/packages/ui-patterns/src/McpUrlBuilder/McpConfigPanel.tsx
@@ -1,14 +1,15 @@
'use client'
import React, { useMemo, useState } from 'react'
-import { cn, Separator } from 'ui'
+import { cn, Separator, CodeBlock } from 'ui'
import { ClientSelectDropdown } from './components/ClientSelectDropdown'
import { McpConfigurationDisplay } from './components/McpConfigurationDisplay'
import { McpConfigurationOptions } from './components/McpConfigurationOptions'
-import { FEATURE_GROUPS_PLATFORM, FEATURE_GROUPS_NON_PLATFORM, MCP_CLIENTS } from './constants'
+import { FEATURE_GROUPS_NON_PLATFORM, FEATURE_GROUPS_PLATFORM, MCP_CLIENTS } from './constants'
import type { McpClient } from './types'
import { getMcpUrl } from './utils/getMcpUrl'
+import { InfoTooltip } from '../info-tooltip'
export interface McpConfigPanelProps {
basePath: string
@@ -43,7 +44,7 @@ export function McpConfigPanel({
)
}, [selectedFeatures, supportedFeatures])
- const { clientConfig } = getMcpUrl({
+ const { mcpUrl, clientConfig } = getMcpUrl({
projectRef,
isPlatform,
apiUrl,
@@ -78,6 +79,24 @@ export function McpConfigPanel({
onFeaturesChange={setSelectedFeatures}
featureGroups={isPlatform ? FEATURE_GROUPS_PLATFORM : FEATURE_GROUPS_NON_PLATFORM}
/>
+
+
+ Server URL
+
+ {`MCP clients should support the Streamable HTTP transport${isPlatform ? ' and OAuth 2.1 with dynamic client registration' : ''}`}
+
+
+ }
+ hideLineNumbers
+ language="http"
+ className="max-h-64 overflow-y-auto"
+ >
+ {mcpUrl}
+
+