diff --git a/packages/frontend/src/components/document_picker.tsx b/packages/frontend/src/components/document_picker.tsx index ad976b452..5a34e81d3 100644 --- a/packages/frontend/src/components/document_picker.tsx +++ b/packages/frontend/src/components/document_picker.tsx @@ -29,6 +29,7 @@ import { import type { Document, Uuid } from "catlog-wasm"; import { useApi } from "../api"; import { TheoryLibraryContext } from "../theory"; +import { isDocumentVisible } from "../user/user_settings"; import { useUserState } from "../user/user_state_context"; import "./document_picker.css"; @@ -191,6 +192,9 @@ function DocSearchInput( const entries = Object.entries(docs) as [string, DocInfo][]; return entries .filter(([refId, doc]) => { + if (!isDocumentVisible(doc, userState.settings)) { + return false; + } if (doc.deletedAt !== null) { return false; } diff --git a/packages/frontend/src/page/menubar.tsx b/packages/frontend/src/page/menubar.tsx index d095872ba..19da98c2f 100644 --- a/packages/frontend/src/page/menubar.tsx +++ b/packages/frontend/src/page/menubar.tsx @@ -277,7 +277,7 @@ function SettingsMenuItem() { return ( navigate("/profile")}> - {"Edit user profile"} + {"Settings"} ); } diff --git a/packages/frontend/src/user/document_list.tsx b/packages/frontend/src/user/document_list.tsx index b871d43b0..03751db10 100644 --- a/packages/frontend/src/user/document_list.tsx +++ b/packages/frontend/src/user/document_list.tsx @@ -7,6 +7,7 @@ import { stringify as uuidStringify } from "uuid"; import { RelativeTime, createVirtualList, DocumentTypeIcon } from "catcolab-ui-components"; import { TheoryLibraryContext } from "../theory"; +import { isDocumentVisible, type UserSettings } from "./user_settings"; import { currentUserPermission, formatOwners, useUserState } from "./user_state_context"; import "./documents.css"; @@ -17,9 +18,11 @@ export function filterDocuments( opts: { query: string; deleted: boolean; + settings?: Partial; }, ): (DocInfo & { refId: string })[] { return (Object.entries(documents) as [string, DocInfo][]) + .filter(([, doc]) => isDocumentVisible(doc, opts.settings)) .filter(([, doc]) => (opts.deleted ? doc.deletedAt !== null : doc.deletedAt === null)) .map(([refId, doc]) => Object.assign({ refId }, doc)) .filter((doc) => { diff --git a/packages/frontend/src/user/documents.tsx b/packages/frontend/src/user/documents.tsx index f5ce6a569..3d6eabbf1 100644 --- a/packages/frontend/src/user/documents.tsx +++ b/packages/frontend/src/user/documents.tsx @@ -42,6 +42,7 @@ function DocumentsSearch() { filterDocuments(userState.documents, { query: searchQuery().trim().toLowerCase(), deleted: false, + settings: userState.settings, }), ); diff --git a/packages/frontend/src/user/inference_key_provider.tsx b/packages/frontend/src/user/inference_key_provider.tsx index 6bea8171f..3f4aeb8ca 100644 --- a/packages/frontend/src/user/inference_key_provider.tsx +++ b/packages/frontend/src/user/inference_key_provider.tsx @@ -4,30 +4,39 @@ import { type JSX, createEffect, createResource } from "solid-js"; import { useApi } from "../api"; import { type InferenceKeyResult, InferenceKeyContext } from "./inference_key_context"; +import { useUserState } from "./user_state_context"; /** Provides the authenticated user's inference key. */ export function InferenceKeyProvider(props: { children: JSX.Element }) { const api = useApi(); const firebaseApp = useFirebaseApp(); const auth = useAuth(getAuth(firebaseApp)); + const userState = useUserState(); - const [inferenceKey, { mutate }] = createResource( - () => auth.data?.uid ?? null, - async () => { - const result = await api.rpc.get_inference_key.query(); - if (result.tag === "Ok") { - return { tag: "Ready", key: result.content } as InferenceKeyResult; - } - if (result.code === 503) { - return { tag: "Unavailable" } as InferenceKeyResult; - } - throw new Error(result.message); - }, - ); + const enabledUserId = () => { + const userId = auth.data?.uid; + if (userId === undefined) { + return null; + } + if (userState.settings?.llmCapabilitiesEnabled !== true) { + return null; + } + return userId; + }; + + const [inferenceKey, { mutate }] = createResource(enabledUserId, async () => { + const result = await api.rpc.get_inference_key.query(); + if (result.tag === "Ok") { + return { tag: "Ready", key: result.content } as InferenceKeyResult; + } + if (result.code === 503) { + return { tag: "Unavailable" } as InferenceKeyResult; + } + throw new Error(result.message); + }); - // clear the resource explicitly on sign-out createEffect(() => { - if (auth.data == null) { + if (enabledUserId() === null) { mutate(undefined); } }); diff --git a/packages/frontend/src/user/llm_capabilities.test.tsx b/packages/frontend/src/user/llm_capabilities.test.tsx new file mode 100644 index 000000000..f613a9e4f --- /dev/null +++ b/packages/frontend/src/user/llm_capabilities.test.tsx @@ -0,0 +1,55 @@ +import type { DocInfo } from "catcolab-api/src/user_state"; +import { renderToString } from "solid-js/web"; +import { assert, test, vi } from "vitest"; + +import type { UserSettings } from "./user_settings"; + +const { getInferenceKey } = vi.hoisted(() => ({ + getInferenceKey: vi.fn<() => Promise>(), +})); + +vi.mock("firebase/auth", () => ({ + getAuth: vi.fn<() => unknown>(), +})); + +vi.mock("solid-firebase", () => ({ + useAuth: () => ({ data: { uid: "test-user" } }), + useFirebaseApp: () => ({}), +})); + +vi.mock("../api", () => ({ + useApi: () => ({ + rpc: { + get_inference_key: { + query: getInferenceKey, + }, + }, + }), +})); + +vi.mock("./user_state_context", async (importOriginal) => ({ + ...(await importOriginal()), + useUserState: () => ({ settings: { llmCapabilitiesEnabled: false } }), +})); + +import { InferenceKeyProvider } from "./inference_key_provider"; +import { isDocumentVisible } from "./user_settings"; + +test("choosing no blocks LLM features", async () => { + renderToString(() => {null}); + await Promise.resolve(); + + assert.equal(getInferenceKey.mock.calls.length, 0); + + const documents: Array> = [ + { typeName: "llmconversation" }, + { typeName: "model" }, + ]; + const settings: UserSettings = { llmCapabilitiesEnabled: false }; + const visibleDocuments = documents.filter((doc) => isDocumentVisible(doc, settings)); + + assert.deepEqual( + visibleDocuments.map((doc) => doc.typeName), + ["model"], + ); +}); diff --git a/packages/frontend/src/user/profile.tsx b/packages/frontend/src/user/profile.tsx index 1555d78f0..689431cdc 100644 --- a/packages/frontend/src/user/profile.tsx +++ b/packages/frontend/src/user/profile.tsx @@ -3,25 +3,30 @@ import { Title } from "@solidjs/meta"; import { createEffect } from "solid-js"; import type { UserProfile } from "catcolab-api"; -import { Button, FormGroup, TextInputField } from "catcolab-ui-components"; +import { Button, CheckboxField, FormGroup, TextInputField } from "catcolab-ui-components"; import { useApi } from "../api"; import { BrandedToolbar } from "../page"; import { LoginGate } from "./login"; -import { useUserState } from "./user_state_context"; +import { useUserState, useUserStateDocHandle } from "./user_state_context"; -/** Page to configure user profile. */ +/** Page to configure user settings. */ export default function UserProfilePage() { const appTitle = import.meta.env.VITE_APP_TITLE; return ( <> - Profile - {appTitle} + User Settings - {appTitle}
+

User settings

+

Public profile

+
+

Functionality

+
@@ -29,6 +34,43 @@ export default function UserProfilePage() { ); } +/** Toggle the user's access to LLM-powered features. */ +function LLMCapabilitiesSetting() { + const userState = useUserState(); + const userStateDocHandle = useUserStateDocHandle(); + + return ( + + + LLM capabilities +
+ + Enable LLM-powered features, including LLM Conversation documents. + + + } + checked={userState.settings?.llmCapabilitiesEnabled === true} + disabled={userStateDocHandle() === null} + onChange={(evt) => { + const docHandle = userStateDocHandle(); + if (docHandle === null) { + return; + } + const enabled = evt.currentTarget.checked; + docHandle.change((doc) => { + if (doc.settings === undefined) { + doc.settings = {}; + } + doc.settings.llmCapabilitiesEnabled = enabled; + }); + }} + /> +
+ ); +} + /** Form to configure user proifle. */ export function UserProfileForm() { const api = useApi(); @@ -96,7 +138,7 @@ export function UserProfileForm() { ); diff --git a/packages/frontend/src/user/trash.tsx b/packages/frontend/src/user/trash.tsx index 513c34604..a9d589d07 100644 --- a/packages/frontend/src/user/trash.tsx +++ b/packages/frontend/src/user/trash.tsx @@ -41,6 +41,7 @@ function TrashBinSearch() { filterDocuments(userState.documents, { query: searchQuery().trim().toLowerCase(), deleted: true, + settings: userState.settings, }), ); diff --git a/packages/frontend/src/user/user_settings.ts b/packages/frontend/src/user/user_settings.ts new file mode 100644 index 000000000..cde996709 --- /dev/null +++ b/packages/frontend/src/user/user_settings.ts @@ -0,0 +1,13 @@ +import type { DocInfo } from "catcolab-api/src/user_state"; + +export type UserSettings = { + llmCapabilitiesEnabled: boolean; +}; + +/** Whether a document should be visible with the current user settings. */ +export function isDocumentVisible( + doc: Pick, + settings?: Partial, +): boolean { + return doc.typeName !== "llmconversation" || settings?.llmCapabilitiesEnabled === true; +} diff --git a/packages/frontend/src/user/user_state_context.tsx b/packages/frontend/src/user/user_state_context.tsx index 83ac47930..aaa56558e 100644 --- a/packages/frontend/src/user/user_state_context.tsx +++ b/packages/frontend/src/user/user_state_context.tsx @@ -1,20 +1,41 @@ +import type { DocHandle } from "@automerge/automerge-repo"; import type { PermissionInfo, UserInfo, UserState } from "catcolab-api/src/user_state"; -import { createContext, useContext } from "solid-js"; +import { type Accessor, createContext, useContext } from "solid-js"; import invariant from "tiny-invariant"; -export const INITIAL_USER_STATE: UserState = { +import type { UserSettings } from "./user_settings"; + +export type AppUserState = UserState & { + settings?: Partial; +}; + +export const INITIAL_USER_STATE: AppUserState = { profile: { username: null, displayName: null }, knownUsers: {}, documents: {}, }; -export const UserStateContext = createContext(INITIAL_USER_STATE); +type UserStateContextValue = { + userState: AppUserState; + docHandle: Accessor | null>; +}; + +export const UserStateContext = createContext(); + +function useUserStateContext(): UserStateContextValue { + const context = useContext(UserStateContext); + invariant(context, "User state should be provided as context"); + return context; +} /** Retrieve user state from application context. */ -export function useUserState(): UserState { - const userState = useContext(UserStateContext); - invariant(userState, "User state should be provided as context"); - return userState; +export function useUserState(): AppUserState { + return useUserStateContext().userState; +} + +/** Retrieve the user state's Automerge document handle. */ +export function useUserStateDocHandle(): Accessor | null> { + return useUserStateContext().docHandle; } /** Get the display name for a permission entry's user. */ diff --git a/packages/frontend/src/user/user_state_provider.tsx b/packages/frontend/src/user/user_state_provider.tsx index 46dfe84b1..9914b5098 100644 --- a/packages/frontend/src/user/user_state_provider.tsx +++ b/packages/frontend/src/user/user_state_provider.tsx @@ -1,30 +1,29 @@ import type { DocHandle, DocumentId } from "@automerge/automerge-repo"; -import type { UserState } from "catcolab-api/src/user_state"; import { getAuth, onAuthStateChanged } from "firebase/auth"; import { useFirebaseApp } from "solid-firebase"; -import { type JSX, onCleanup } from "solid-js"; +import { type JSX, createSignal, onCleanup } from "solid-js"; import { createStore, reconcile } from "solid-js/store"; import { useApi } from "../api"; import { unwrap } from "../api/rpc"; import { normalizeImmutableStrings } from "../util/immutable_string"; -import { INITIAL_USER_STATE, UserStateContext } from "./user_state_context"; +import { type AppUserState, INITIAL_USER_STATE, UserStateContext } from "./user_state_context"; export function UserStateProvider(props: { children: JSX.Element }) { const api = useApi(); const firebaseApp = useFirebaseApp(); - const [userState, setUserState] = createStore(INITIAL_USER_STATE); + const [userState, setUserState] = createStore(INITIAL_USER_STATE); + const [currentDocHandle, setCurrentDocHandle] = createSignal | null>( + null, + ); - let currentDocHandle: DocHandle | null = null; - let currentChangeHandler: ((arg: { doc: UserState }) => void) | null = null; + let unsubscribeDocChanges: (() => void) | null = null; let currentUserId: string | null = null; const teardownDocHandle = () => { - if (currentDocHandle && currentChangeHandler) { - currentDocHandle.off("change", currentChangeHandler); - } - currentDocHandle = null; - currentChangeHandler = null; + unsubscribeDocChanges?.(); + unsubscribeDocChanges = null; + setCurrentDocHandle(null); }; // This will initialize on first load and re-initialize on logout/login @@ -40,19 +39,20 @@ export function UserStateProvider(props: { children: JSX.Element }) { return; } - const docHandle: DocHandle = await api.repo.find(userStateDocId as DocumentId); + const docHandle: DocHandle = await api.repo.find( + userStateDocId as DocumentId, + ); if (currentUserId !== userId) { return; } - currentDocHandle = docHandle; - const onChange = ({ doc }: { doc: UserState }) => { + const onChange = ({ doc }: { doc: AppUserState }) => { setUserState(reconcile(normalizeImmutableStrings(doc))); }; - currentChangeHandler = onChange; - setUserState(reconcile(normalizeImmutableStrings(docHandle.doc()))); docHandle.on("change", onChange); + unsubscribeDocChanges = () => docHandle.off("change", onChange); + setCurrentDocHandle(docHandle); }); onCleanup(() => { @@ -61,6 +61,8 @@ export function UserStateProvider(props: { children: JSX.Element }) { }); return ( - {props.children} + + {props.children} + ); }