diff --git a/quadratic-api/prisma/migrations/20250109213645_rename_save_prompt/migration.sql b/quadratic-api/prisma/migrations/20250109213645_rename_save_prompt/migration.sql new file mode 100644 index 0000000000..a00867a0cd --- /dev/null +++ b/quadratic-api/prisma/migrations/20250109213645_rename_save_prompt/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `preference_ai_save_user_prompts_enabled` on the `Team` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Team" DROP COLUMN "preference_ai_save_user_prompts_enabled", +ADD COLUMN "setting_analytics_ai" BOOLEAN NOT NULL DEFAULT true; diff --git a/quadratic-api/prisma/schema.prisma b/quadratic-api/prisma/schema.prisma index e57c7f42ce..205085497f 100644 --- a/quadratic-api/prisma/schema.prisma +++ b/quadratic-api/prisma/schema.prisma @@ -125,8 +125,8 @@ model Team { // Use for client-specific data that is not useful to the server or other services clientDataKv Json @default("{}") @map("client_data_kv") - // Preferences - preferenceAiSaveUserPromptsEnabled Boolean @default(true) @map("preference_ai_save_user_prompts_enabled") + // Settings + settingAnalyticsAi Boolean @default(true) @map("setting_analytics_ai") @@index([uuid]) } diff --git a/quadratic-api/src/routes/v0/teams.$uuid.GET.test.ts b/quadratic-api/src/routes/v0/teams.$uuid.GET.test.ts index d9c2db663d..c116362a19 100644 --- a/quadratic-api/src/routes/v0/teams.$uuid.GET.test.ts +++ b/quadratic-api/src/routes/v0/teams.$uuid.GET.test.ts @@ -1,4 +1,4 @@ -import { User } from 'auth0'; +import type { User } from 'auth0'; import request from 'supertest'; import { app } from '../../app'; import dbClient from '../../dbClient'; @@ -97,6 +97,7 @@ describe('GET /v0/teams/:uuid', () => { expect(res.body).toHaveProperty('team'); expect(res.body.team.uuid).toBe('00000000-0000-4000-8000-000000000001'); + expect(res.body.team.settings.analyticsAi).toBe(true); expect(res.body.clientDataKv).toStrictEqual({}); expect(res.body.connections).toHaveLength(1); expect(res.body.files).toHaveLength(1); diff --git a/quadratic-api/src/routes/v0/teams.$uuid.GET.ts b/quadratic-api/src/routes/v0/teams.$uuid.GET.ts index 8a7f591264..9567eb284e 100644 --- a/quadratic-api/src/routes/v0/teams.$uuid.GET.ts +++ b/quadratic-api/src/routes/v0/teams.$uuid.GET.ts @@ -129,6 +129,9 @@ async function handler(req: Request, res: Response { expect(res.body.name).toBe('Foobar'); }); }); + it('accepts setting change', async () => { + await request(app) + .patch(`/v0/teams/00000000-0000-4000-8000-000000000001`) + .send({ settings: { analyticsAi: false } }) + .set('Authorization', `Bearer ValidToken team_1_owner`) + .expect(200) + .expect((res) => { + expect(res.body.settings.analyticsAi).toBe(false); + }); + }); it('accepst key/value pair updates', async () => { // Create value await request(app) diff --git a/quadratic-api/src/routes/v0/teams.$uuid.PATCH.ts b/quadratic-api/src/routes/v0/teams.$uuid.PATCH.ts index 651bab4707..6d7aee1e09 100644 --- a/quadratic-api/src/routes/v0/teams.$uuid.PATCH.ts +++ b/quadratic-api/src/routes/v0/teams.$uuid.PATCH.ts @@ -21,7 +21,7 @@ const schema = z.object({ async function handler(req: RequestWithUser, res: Response) { const { - body: { name, clientDataKv, preferences }, + body: { name, clientDataKv, settings }, params: { uuid }, } = parseRequest(req, schema); const { @@ -36,8 +36,8 @@ async function handler(req: RequestWithUser, res: Response(null); const ref = useCallback((div: HTMLDivElement | null) => { @@ -68,7 +68,6 @@ export function AIAnalystMessages({ textareaRef }: AIAnalystMessagesProps) { if (loading) { shouldAutoScroll.current = true; scrollToBottom(true); - setShowFeedback(true); } }, [loading, scrollToBottom]); @@ -85,7 +84,6 @@ export function AIAnalystMessages({ textareaRef }: AIAnalystMessagesProps) { const handleFeedback = useRecoilCallback( ({ snapshot }) => (like: boolean) => { - setShowFeedback(false); const messages = snapshot.getLoadable(aiAnalystCurrentChatMessagesAtom).getValue(); const promptMessageLength = getPromptMessages(messages).length; @@ -167,25 +165,13 @@ export function AIAnalystMessages({ textareaRef }: AIAnalystMessagesProps) { ); })} + {messages.length > 0 && !loading && } +
- - {messages.length > 0 && !loading && showFeedback && ( -
-
- - - -
-
- )} ); } @@ -194,3 +180,38 @@ function MarkdownContent({ children }: { children: string }) { // Classes applied in Markdown.scss return {children}; } + +function FeedbackButtons({ handleFeedback }: { handleFeedback: (like: boolean) => void }) { + const [liked, setLiked] = useState(null); + + return ( +
+ + + + + + +
+ ); +} diff --git a/quadratic-client/src/dashboard/components/PreferenceControl.tsx b/quadratic-client/src/dashboard/components/SettingControl.tsx similarity index 97% rename from quadratic-client/src/dashboard/components/PreferenceControl.tsx rename to quadratic-client/src/dashboard/components/SettingControl.tsx index 8bd34f1442..f48ca8c539 100644 --- a/quadratic-client/src/dashboard/components/PreferenceControl.tsx +++ b/quadratic-client/src/dashboard/components/SettingControl.tsx @@ -7,7 +7,7 @@ import { ReactNode, useId } from 'react'; * Component that renders a preference control (a label, description, and a switch) */ -export function PreferenceControl({ +export function SettingControl({ label, description, onCheckedChange, diff --git a/quadratic-client/src/routes/teams.$teamUuid.settings.tsx b/quadratic-client/src/routes/teams.$teamUuid.settings.tsx index 6758e2ca2a..5d88df23be 100644 --- a/quadratic-client/src/routes/teams.$teamUuid.settings.tsx +++ b/quadratic-client/src/routes/teams.$teamUuid.settings.tsx @@ -1,14 +1,16 @@ import { DashboardHeader } from '@/dashboard/components/DashboardHeader'; -import { PreferenceControl } from '@/dashboard/components/PreferenceControl'; +import { SettingControl } from '@/dashboard/components/SettingControl'; import { useDashboardRouteLoaderData } from '@/routes/_dashboard'; import { getActionUpdateTeam, type TeamAction } from '@/routes/teams.$teamUuid'; import { useGlobalSnackbar } from '@/shared/components/GlobalSnackbarProvider'; import { Type } from '@/shared/components/Type'; import { ROUTES } from '@/shared/constants/routes'; +import { DOCUMENTATION_ANALYTICS_AI } from '@/shared/constants/urls'; import { Button } from '@/shared/shadcn/ui/button'; import { Input } from '@/shared/shadcn/ui/input'; import { cn } from '@/shared/shadcn/utils'; import { isJsonObject } from '@/shared/utils/isJsonObject'; +import type { TeamSettings } from 'quadratic-shared/typesAndSchemas'; import { ReactNode, useEffect, useState } from 'react'; import { Navigate, useFetcher, useSubmit } from 'react-router-dom'; @@ -17,7 +19,6 @@ export const Component = () => { activeTeam: { team, userMakingRequest: { teamPermissions }, - preferences, }, } = useDashboardRouteLoaderData(); @@ -28,12 +29,12 @@ export const Component = () => { const disabled = value === '' || value === team.name || fetcher.state !== 'idle'; // Optimistic UI - let optimisticPreferences = preferences; + let optimisticSettings = team.settings; if (fetcher.state !== 'idle' && isJsonObject(fetcher.json)) { const optimisticData = fetcher.json as TeamAction['request.update-team']; - if (optimisticData.preferences) { - optimisticPreferences = { ...preferences, ...optimisticData.preferences }; + if (optimisticData.settings) { + optimisticSettings = { ...optimisticSettings, ...optimisticData.settings }; } } @@ -54,8 +55,8 @@ export const Component = () => { }); }; - const handleUpdatePreference = (key: string, checked: boolean) => { - const data = getActionUpdateTeam({ preferences: { [key]: checked } }); + const handleUpdatePreference = (key: keyof TeamSettings, checked: boolean) => { + const data = getActionUpdateTeam({ settings: { [key]: checked } }); submit(data, { method: 'POST', action: ROUTES.TEAM(team.uuid), @@ -114,21 +115,21 @@ export const Component = () => { Privacy - - Help improve AI results by allowing Quadratic to store and analyze anonymized user prompts.{' '} - + Help improve AI results by allowing Quadratic to store and analyze user prompts.{' '} + Learn more . } onCheckedChange={(checked) => { - handleUpdatePreference('aiSaveUserPromptsEnabled', checked); + handleUpdatePreference('analyticsAi', checked); }} - checked={optimisticPreferences.aiSaveUserPromptsEnabled} + checked={optimisticSettings.analyticsAi} className="rounded border border-border p-3 shadow-sm" /> diff --git a/quadratic-client/src/shared/constants/urls.ts b/quadratic-client/src/shared/constants/urls.ts index 538e2ce482..58f5199c04 100644 --- a/quadratic-client/src/shared/constants/urls.ts +++ b/quadratic-client/src/shared/constants/urls.ts @@ -11,6 +11,7 @@ export const DOCUMENTATION_CONNECTIONS_IP_LIST_URL = `${DOCUMENTATION_CONNECTION export const DOCUMENTATION_BROWSER_COMPATIBILITY_URL = `${DOCUMENTATION_URL}/spreadsheet/browser-compatibility`; export const DOCUMENTATION_DATE_TIME_FORMATTING = `${DOCUMENTATION_URL}/spreadsheet/date-time-formatting`; export const DOCUMENTATION_NEGATIVE_OFFSETS = `${DOCUMENTATION_URL}/spreadsheet/negative-offsets`; +export const DOCUMENTATION_ANALYTICS_AI = `${DOCUMENTATION_URL}/security#ai-data-privacy-setting`; export const TRUST_CENTER = 'https://trust.quadratichq.com'; export const BUG_REPORT_URL = 'https://github.com/quadratichq/quadratic/issues'; export const TWITTER = 'https://twitter.com/quadratichq'; diff --git a/quadratic-shared/typesAndSchemas.ts b/quadratic-shared/typesAndSchemas.ts index 72746b0003..2f13a73c51 100644 --- a/quadratic-shared/typesAndSchemas.ts +++ b/quadratic-shared/typesAndSchemas.ts @@ -103,11 +103,10 @@ const TeamUserMakingRequestSchema = z.object({ export const TeamClientDataKvSchema = z.record(z.any()); -const TeamPreferencesSchema = z.object({ - aiSaveUserPromptsEnabled: z.boolean(), - // aiProcessors: z.array(z.enum(['OPENAI', 'ANTRHOPIC', 'EXA', 'AWS_BEDROCK'])), +const TeamSettingsSchema = z.object({ + analyticsAi: z.boolean(), }); -export type TeamPreferences = z.infer; +export type TeamSettings = z.infer; export const LicenseSchema = z.object({ limits: z.object({ @@ -314,7 +313,7 @@ export const ApiSchemas = { }), '/v0/teams.POST.response': TeamSchema.pick({ uuid: true, name: true }), '/v0/teams/:uuid.GET.response': z.object({ - team: TeamSchema.pick({ id: true, uuid: true, name: true }), + team: TeamSchema.pick({ id: true, uuid: true, name: true }).merge(z.object({ settings: TeamSettingsSchema })), userMakingRequest: z.object({ id: TeamUserSchema.shape.id, teamPermissions: z.array(TeamPermissionSchema), @@ -341,13 +340,12 @@ export const ApiSchemas = { license: LicenseSchema, connections: ConnectionListSchema, clientDataKv: TeamClientDataKvSchema, - preferences: TeamPreferencesSchema, }), '/v0/teams/:uuid.PATCH.request': z .object({ name: TeamSchema.shape.name.optional(), clientDataKv: TeamClientDataKvSchema.optional(), - preferences: TeamPreferencesSchema.partial().optional(), + settings: TeamSettingsSchema.partial().optional(), }) .refine( (data) => { @@ -362,7 +360,7 @@ export const ApiSchemas = { '/v0/teams/:uuid.PATCH.response': z.object({ name: TeamSchema.shape.name, clientDataKv: TeamClientDataKvSchema, - preferences: TeamPreferencesSchema, + settings: TeamSettingsSchema, }), '/v0/teams/:uuid/invites.POST.request': TeamUserSchema.pick({ email: true, role: true }), '/v0/teams/:uuid/invites.POST.response': z