Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/frontend/src/components/document_picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/page/menubar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ function SettingsMenuItem() {
return (
<MenuItem onSelect={() => navigate("/profile")}>
<SettingsIcon />
<MenuItemLabel>{"Edit user profile"}</MenuItemLabel>
<MenuItemLabel>{"Settings"}</MenuItemLabel>
</MenuItem>
);
}
Expand Down
3 changes: 3 additions & 0 deletions packages/frontend/src/user/document_list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -17,9 +18,11 @@ export function filterDocuments(
opts: {
query: string;
deleted: boolean;
settings?: Partial<UserSettings>;
},
): (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) => {
Expand Down
1 change: 1 addition & 0 deletions packages/frontend/src/user/documents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function DocumentsSearch() {
filterDocuments(userState.documents, {
query: searchQuery().trim().toLowerCase(),
deleted: false,
settings: userState.settings,
}),
);

Expand Down
39 changes: 24 additions & 15 deletions packages/frontend/src/user/inference_key_provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
Expand Down
55 changes: 55 additions & 0 deletions packages/frontend/src/user/llm_capabilities.test.tsx
Original file line number Diff line number Diff line change
@@ -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<never>>(),
}));

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<typeof import("./user_state_context")>()),
useUserState: () => ({ settings: { llmCapabilitiesEnabled: false } }),
}));

import { InferenceKeyProvider } from "./inference_key_provider";
import { isDocumentVisible } from "./user_settings";

test("choosing no blocks LLM features", async () => {
renderToString(() => <InferenceKeyProvider>{null}</InferenceKeyProvider>);
await Promise.resolve();

assert.equal(getInferenceKey.mock.calls.length, 0);

const documents: Array<Pick<DocInfo, "typeName">> = [
{ 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"],
);
});
52 changes: 47 additions & 5 deletions packages/frontend/src/user/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,74 @@ 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 (
<>
<Title>Profile - {appTitle}</Title>
<Title>User Settings - {appTitle}</Title>
<div class="growable-container">
<BrandedToolbar />
<div class="page-container">
<LoginGate>
<h1>User settings</h1>
<hr />
<h2>Public profile</h2>
<UserProfileForm />
<hr />
<h2>Functionality</h2>
<LLMCapabilitiesSetting />
</LoginGate>
</div>
</div>
</>
);
}

/** Toggle the user's access to LLM-powered features. */
function LLMCapabilitiesSetting() {
const userState = useUserState();
const userStateDocHandle = useUserStateDocHandle();

return (
<FormGroup compact>
<CheckboxField
label={
<>
<strong>LLM capabilities</strong>
<br />
<small>
Enable LLM-powered features, including LLM Conversation documents.
</small>
</>
}
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;
});
}}
/>
</FormGroup>
);
}

/** Form to configure user proifle. */
export function UserProfileForm() {
const api = useApi();
Expand Down Expand Up @@ -96,7 +138,7 @@ export function UserProfileForm() {
</Field>
</FormGroup>
<Button type="submit" variant="positive">
Update profile
Update public profile
</Button>
</Form>
);
Expand Down
1 change: 1 addition & 0 deletions packages/frontend/src/user/trash.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ function TrashBinSearch() {
filterDocuments(userState.documents, {
query: searchQuery().trim().toLowerCase(),
deleted: true,
settings: userState.settings,
}),
);

Expand Down
13 changes: 13 additions & 0 deletions packages/frontend/src/user/user_settings.ts
Original file line number Diff line number Diff line change
@@ -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<DocInfo, "typeName">,
settings?: Partial<UserSettings>,
): boolean {
return doc.typeName !== "llmconversation" || settings?.llmCapabilitiesEnabled === true;
}
35 changes: 28 additions & 7 deletions packages/frontend/src/user/user_state_context.tsx
Original file line number Diff line number Diff line change
@@ -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<UserSettings>;
};

export const INITIAL_USER_STATE: AppUserState = {
profile: { username: null, displayName: null },
knownUsers: {},
documents: {},
};

export const UserStateContext = createContext<UserState>(INITIAL_USER_STATE);
type UserStateContextValue = {
userState: AppUserState;
docHandle: Accessor<DocHandle<AppUserState> | null>;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the substantive change here, assuming i understood the feedback we now pass around the docHandle directly for writes to the automerge doc

};

export const UserStateContext = createContext<UserStateContextValue>();

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<DocHandle<AppUserState> | null> {
return useUserStateContext().docHandle;
}

/** Get the display name for a permission entry's user. */
Expand Down
Loading
Loading