diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 3cf08ec33..8a9744ee4 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -307,6 +307,7 @@ func serve(cctx *cli.Context) error { e.GET("/settings/runes/other-additions", server.WebGeneric) e.GET("/settings/appearance", server.WebGeneric) e.GET("/settings/appearance/color-theme", server.WebGeneric) + e.GET("/settings/ai-preferences", server.WebGeneric) e.GET("/settings/account", server.WebGeneric) e.GET("/settings/automation-label", server.WebGeneric) e.GET("/settings/privacy-and-security", server.WebGeneric) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 013ecb4b8..508aba6e9 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -103,6 +103,7 @@ import {AboutSettingsScreen} from '#/screens/Settings/AboutSettings' import {AccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings' import {AccountSettingsScreen} from '#/screens/Settings/AccountSettings' import {ActivityPrivacySettingsScreen} from '#/screens/Settings/ActivityPrivacySettings' +import {AIPreferencesSettingsScreen} from '#/screens/Settings/AIPreferencesSettings' import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings' import {AppearanceColorThemeSettingsScreen} from '#/screens/Settings/AppearanceSettings/ColorThemeSettings' import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings' @@ -428,6 +429,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { requireAuth: true, }} /> + AIPreferencesSettingsScreen} + options={{ + title: title(msg`AI Preferences`), + requireAuth: true, + }} + /> RunesSettingsScreen} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 4c87e529c..016209f06 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -49,6 +49,7 @@ export type CommonNavigatorParams = { PreferencesThreads: undefined PreferencesExternalEmbeds: undefined AccessibilitySettings: undefined + AIPreferencesSettings: undefined AppearanceSettings: undefined AppearanceColorThemeSettings: undefined RunesSettings: undefined diff --git a/src/routes.ts b/src/routes.ts index 070abaadd..7bdba3993 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -48,6 +48,7 @@ export const router = new Router({ PreferencesThreads: '/settings/threads', PreferencesExternalEmbeds: '/settings/external-embeds', AccessibilitySettings: '/settings/accessibility', + AIPreferencesSettings: '/settings/ai-preferences', RunesSettings: '/settings/runes', RunesMenusSettings: '/settings/runes/menus', RunesBadgesSettings: '/settings/runes/badges', diff --git a/src/screens/Settings/AIPreferencesSettings.tsx b/src/screens/Settings/AIPreferencesSettings.tsx new file mode 100644 index 000000000..ef269da31 --- /dev/null +++ b/src/screens/Settings/AIPreferencesSettings.tsx @@ -0,0 +1,226 @@ +import {View} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' +import {type NativeStackScreenProps} from '@react-navigation/native-stack' + +import {type CommonNavigatorParams} from '#/lib/routes/types' +import { + AI_PREFERENCE_CATEGORIES, + preferenceSetToTriStates, + type AIPreferenceCategory, + type TriState, + useAIPreferencesQuery, + useUpdateAIPreferencesMutation, +} from '#/state/queries/ai-preferences' +import * as SettingsList from '#/screens/Settings/components/SettingsList' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import * as SegmentedControl from '#/components/forms/SegmentedControl' +import * as Layout from '#/components/Layout' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'AIPreferencesSettings' +> + +export function AIPreferencesSettingsScreen({}: Props) { + const {t: l} = useLingui() + const t = useTheme() + + const {data: record, isLoading} = useAIPreferencesQuery() + const {mutate} = useUpdateAIPreferencesMutation() + + const triStates = preferenceSetToTriStates(record?.preferences) + + const allowLabel = l`Allow` + const unsetLabel = l`No preference` + const denyLabel = l`Deny` + + const categoryCopy = useCategoryCopy() + + const onChange = (category: AIPreferenceCategory) => (value: TriState) => { + if (value !== 'allow' && value !== 'deny' && value !== 'unset') return + mutate({[category]: value}) + } + + return ( + + + + + + AI preferences + + + + + + + + + Determine how AI may use your data + + + + Experimental + + + + + + You can adjust these settings to configure how AI systems may use + your data across the AT Protocol network. In an open source + system, your data stays public, but you can say how AI should + handle it. + + + + + Services may follow the preferences you set here, which will be + publicly saved to your account repository. Other AT Protocol + services may ignore these signals, but that is outside the control + of Witchsky, your PDS operator, and other intermediary services on + the network. + + + + + These preferences apply across your entire account. More granular + controls will be made available in the future - for example, + allowing you to set rules for specific AI services or specific + types of content - and will appear on this page when ready. + + + + + Safety tools like spam and bot detection aren't affected. They + stay on for everyone. + + + + + + {isLoading && !record ? ( + + + + ) : ( + AI_PREFERENCE_CATEGORIES.map(category => { + const copy = categoryCopy[category] + return ( + + ) + }) + )} + + + + ) +} + +function CategoryRow({ + name, + description, + value, + allowLabel, + unsetLabel, + denyLabel, + onChange, +}: { + name: string + description: string + value: 'allow' | 'deny' | 'unset' + allowLabel: string + unsetLabel: string + denyLabel: string + onChange: (value: TriState) => void +}) { + const {t: l} = useLingui() + const t = useTheme() + const {gtPhone} = useBreakpoints() + + return ( + + + + {name} + + + {description} + + + + + + {allowLabel} + + + {unsetLabel} + + + {denyLabel} + + + + + ) +} + +function useCategoryCopy(): Record< + AIPreferenceCategory, + {name: string; description: string} +> { + const {t: l} = useLingui() + return { + training: { + name: l`Training`, + description: l`Use your data to build or train new AI models. Once your work is in a model, it stays there, even if you delete your account later.`, + }, + inference: { + name: l`Inference`, + description: l`Look up your data when AI is answering someone's question. The AI doesn't keep it, but it might quote or reference it in real time.`, + }, + syntheticContent: { + name: l`Synthetic content`, + description: l`Generate new content or interactions modeled on your data. This includes AI imitations of your writing, photos, or audio.`, + }, + embedding: { + name: l`Embedding`, + description: l`Use your data in AI search and recommendation systems. This is how AI finds similar accounts, groups users by interests, and decides what to show in some personalized feeds.`, + }, + } +} diff --git a/src/screens/Settings/PrivacyAndSecuritySettings.tsx b/src/screens/Settings/PrivacyAndSecuritySettings.tsx index 2979ace21..f56b6cfd4 100644 --- a/src/screens/Settings/PrivacyAndSecuritySettings.tsx +++ b/src/screens/Settings/PrivacyAndSecuritySettings.tsx @@ -15,6 +15,7 @@ import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/componen import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlashIcon} from '#/components/icons/EyeSlash' import {Key_Stroke2_Corner2_Rounded as KeyIcon} from '#/components/icons/Key' import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield' +import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle' import * as Layout from '#/components/Layout' import {InlineLinkText} from '#/components/Link' import {Email2FAToggle} from './components/Email2FAToggle' @@ -100,6 +101,14 @@ export function PrivacyAndSecuritySettingsScreen({}: Props) { showSkeleton={isPending} /> + + + + AI preferences + + diff --git a/src/state/queries/ai-preferences/index.ts b/src/state/queries/ai-preferences/index.ts new file mode 100644 index 000000000..4e5e3ff9e --- /dev/null +++ b/src/state/queries/ai-preferences/index.ts @@ -0,0 +1,115 @@ +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {STALE} from '#/state/queries' +import {useAgent, useSession} from '#/state/session' +import {buildGlobalRecord, type Patch} from './serde' +import {AI_PREFERENCE_NSID, type AIPreferenceRecord} from './types' + +export {preferenceSetToTriStates} from './serde' +export { + AI_PREFERENCE_CATEGORIES, + AI_PREFERENCE_NSID, + type AIPreferenceCategory, + type AIPreferenceRecord, + type TriState, +} from './types' + +const RQKEY_ROOT = 'ai-preferences' + +export const RQKEY = (did: string | undefined) => [ + RQKEY_ROOT, + did ?? '', +] + +function isRecordNotFoundError(e: unknown): boolean { + if (e instanceof Error) { + return e.message.includes('Could not locate record:') + } + return false +} + +export function useAIPreferencesQuery() { + const {currentAccount} = useSession() + const agent = useAgent() + + return useQuery({ + queryKey: RQKEY(currentAccount?.did), + enabled: !!currentAccount?.did, + staleTime: STALE.MINUTES.FIVE, + queryFn: async () => { + if (!currentAccount) throw new Error('Not signed in') + try { + const {data} = await agent.com.atproto.repo.getRecord({ + repo: currentAccount.did, + collection: AI_PREFERENCE_NSID, + rkey: 'self', + }) + return data.value as AIPreferenceRecord + } catch (e) { + if (isRecordNotFoundError(e)) return null + throw e + } + }, + }) +} + +export function useUpdateAIPreferencesMutation({ + onSuccess, + onError, +}: { + onSuccess?: () => void + onError?: (error: Error) => void +} = {}) { + const queryClient = useQueryClient() + const {currentAccount} = useSession() + const agent = useAgent() + + type Ctx = {prev: AIPreferenceRecord | null} + + return useMutation({ + mutationFn: async patch => { + if (!currentAccount) throw new Error('Not signed in') + const queryKey = RQKEY(currentAccount.did) + const prev = + queryClient.getQueryData(queryKey) ?? null + const next = buildGlobalRecord(prev, patch) + await agent.com.atproto.repo.putRecord({ + repo: currentAccount.did, + collection: AI_PREFERENCE_NSID, + rkey: 'self', + record: next, + }) + }, + onMutate: async patch => { + if (!currentAccount) return {prev: null} + const queryKey = RQKEY(currentAccount.did) + await queryClient.cancelQueries({queryKey}) + const prev = + queryClient.getQueryData(queryKey) ?? null + const next = buildGlobalRecord(prev, patch) + queryClient.setQueryData(queryKey, next) + return {prev} + }, + onSuccess: () => { + onSuccess?.() + }, + onError: (error, _patch, context) => { + logger.error('Failed to update AI preferences', {safeMessage: error}) + if (currentAccount && context) { + queryClient.setQueryData( + RQKEY(currentAccount.did), + context.prev, + ) + } + onError?.(error) + }, + onSettled: () => { + if (currentAccount) { + void queryClient.invalidateQueries({ + queryKey: RQKEY(currentAccount.did), + }) + } + }, + }) +} diff --git a/src/state/queries/ai-preferences/serde.ts b/src/state/queries/ai-preferences/serde.ts new file mode 100644 index 000000000..026efe91c --- /dev/null +++ b/src/state/queries/ai-preferences/serde.ts @@ -0,0 +1,63 @@ +import { + AI_PREFERENCE_CATEGORIES, + type AIPreferenceCategory, + type AIPreferenceRecord, + type AIPreferenceSet, + type AIPreferenceTriStates, + DEFAULT_TRI_STATES, + type TriState, +} from './types' + +export function fieldToTriState(field: {allow: boolean} | undefined): TriState { + if (!field) return 'unset' + return field.allow ? 'allow' : 'deny' +} + +export function preferenceSetToTriStates( + set: AIPreferenceSet | undefined, +): AIPreferenceTriStates { + if (!set) return {...DEFAULT_TRI_STATES} + return { + training: fieldToTriState(set.training), + inference: fieldToTriState(set.inference), + syntheticContent: fieldToTriState(set.syntheticContent), + embedding: fieldToTriState(set.embedding), + } +} + +export type Patch = Partial> + +export function applyPatch( + prev: AIPreferenceSet | undefined, + patch: Patch, + now: string, +): AIPreferenceSet { + const next: AIPreferenceSet = {...(prev ?? {})} + for (const category of AI_PREFERENCE_CATEGORIES) { + const value = patch[category] + if (value === undefined) continue + if (value === 'unset') { + delete next[category] + } else { + next[category] = { + allow: value === 'allow', + updatedAt: now, + } + } + } + return next +} + +export function buildGlobalRecord( + prev: AIPreferenceRecord | null | undefined, + patch: Patch, + now: string = new Date().toISOString(), +): AIPreferenceRecord { + const preferences = applyPatch(prev?.preferences, patch, now) + return { + $type: 'community.lexicon.preference.ai', + updatedAt: now, + scope: {$type: 'community.lexicon.preference.ai#globalScope'}, + preferences, + } +} diff --git a/src/state/queries/ai-preferences/types.ts b/src/state/queries/ai-preferences/types.ts new file mode 100644 index 000000000..265299d0b --- /dev/null +++ b/src/state/queries/ai-preferences/types.ts @@ -0,0 +1,63 @@ +export const AI_PREFERENCE_NSID = 'community.lexicon.preference.ai' + +export type AIPreferenceCategory = + | 'training' + | 'inference' + | 'syntheticContent' + | 'embedding' + +export const AI_PREFERENCE_CATEGORIES: AIPreferenceCategory[] = [ + 'training', + 'inference', + 'syntheticContent', + 'embedding', +] + +export type TriState = 'allow' | 'deny' | 'unset' + +export type AIPreferenceField = { + allow: boolean + updatedAt: string +} + +export type AIGlobalScope = { + $type: 'community.lexicon.preference.ai#globalScope' +} + +export type AIEntityScope = { + $type: 'community.lexicon.preference.ai#entityScope' + entity: string +} + +export type AICollectionScope = { + $type: 'community.lexicon.preference.ai#collectionScope' + collection: string +} + +export type AIPreferenceScope = + | AIGlobalScope + | AIEntityScope + | AICollectionScope + +export type AIPreferenceSet = { + training?: AIPreferenceField + inference?: AIPreferenceField + syntheticContent?: AIPreferenceField + embedding?: AIPreferenceField +} + +export type AIPreferenceRecord = { + $type: 'community.lexicon.preference.ai' + updatedAt: string + scope: AIPreferenceScope + preferences: AIPreferenceSet +} + +export type AIPreferenceTriStates = Record + +export const DEFAULT_TRI_STATES: AIPreferenceTriStates = { + training: 'unset', + inference: 'unset', + syntheticContent: 'unset', + embedding: 'unset', +}