feat: browseros API (#284)

* feat: created auth client

* feat: created login page for testing auth

* feat: setup logout page

* feat: setup graphql codegen

* feat: setup graphql + react query utils

* feat: setup queryprovider with localforage

* feat: created auth provider

* feat: update claude.md

* feat: documents for bulk conversation upload

* chore: install missing package

* fix: setup codegen to scan for .ts files

* chore: setup check conversation query

* feat: upload conversation by profileId

* chore: upload messages in batches

* feat: account for edge cases in conversation upload

* feat: delete uploaded conversations from localstorage

* feat: load conversation history from api

* feat: implement delete conversation using graphql

* feat: delete confirmation for conversation history

* fix: issue with clearing conversations after upload

* feat: implement pagination for graphql chat history

* chore: update CLAUDE.md

* chore: update claude.md

* feat: save conversations to server

* fix: handle streaming check on remote conversation save

* feat: restore conversation from graphql

* fix: timestamp issue on the chat history page

* feat: sync llm providers from background script

* feat: update llm providers on change via background script

* chore: added a try catch block

* feat: display incomplete providers in separate UI

* feat: delete provider on server when initiated by user

* feat: setup scheduled tasks storage to sync to graphql

* feat: auto run sync in background script

* fix: sync all keys of scheduled tasks based on updatedAt timestamp

* feat: added login dropdown on the sidebar

* feat: simplify sidenav header

* feat: update header design after login

* feat: setup profile page

* feat: added back button to profile page

* fix: scrollbar flash in profile page

* feat: finish login handshake

* feat: clear storage on logout

* fix: logout page style

* feat: added tooltip to encourage user to sign in

* feat: added back button to login page

* fix: upload logic for profile picture

* feat: account for profile name in sidebar branding

* chore: set file upload url from backend request

* chore: remove default placeholder from profile component

* chore: sync with main

* Revert "chore: sync with main"

This reverts commit 77e06b894ce30235d1bfa31c8e2699b34df423a5.

* Reapply "chore: sync with main"

This reverts commit dd921d97cc9794d1872e13689c881f68e4dfee47.

* chore: updated lock file

* fix: run codegen before build:ext

* fix: run codegen before build:gent

* fix: remove hardcoded localhost header in magic link

---------

Co-authored-by: Nikhil Sonti <nikhilsv92@gmail.com>
This commit is contained in:
Dani Akash
2026-01-30 23:33:57 +05:30
committed by GitHub
parent b4d0a53f8f
commit 8639f2b20a
61 changed files with 3716 additions and 287 deletions

View File

@@ -15,6 +15,12 @@ VITE_PUBLIC_POSTHOG_KEY=
VITE_PUBLIC_POSTHOG_HOST=
VITE_PUBLIC_SENTRY_DSN=
# BrowserOS API URL
VITE_PUBLIC_BROWSEROS_API=
# GraphQL Schema Path
GRAPHQL_SCHEMA_PATH=
# Sentry build (source maps)
SENTRY_AUTH_TOKEN=
SENTRY_ORG=

View File

@@ -30,3 +30,6 @@ stats-*.json
# Env files
.env*
!.env.example
# GraphQL generated files
generated/

View File

@@ -39,3 +39,105 @@ The key directories of the project are:
- `entrypoints/newtab`: Contains the code for the new tab page of the extension.
- `entrypoints/popup`: Contains the code for the popup that appears when the extension icon is clicked.
- `entrypoints/onboarding`: Contains the onboarding flow for new users which is triggered on first install.
## React Coding patterns
- Avoid using useCallback and useMemo as much as possible - only add them if their presence is absolutely necessary
- When writing a graphql document, create a /graphql directory under the current directory where the file is present and create a file to contain the document.
- For example: if you want to create grapqhl queries in @apps/agent/entrypoints/sidepanel/history/ChatHistory.tsx then write the graphql document in @apps/agent/entrypoints/sidepanel/history/graphql/chatHistoryDocument.ts
- Shadcn UI is setup in this project and always use shadcn components for the UI
- When need to record errors, do not use console.error -> instead use the sentry service to capture errors:
```ts
import { sentry } from '@/lib/sentry/sentry'
sentry.captureException(error, {
extra: {
message: 'Failed to fetch graph data from the server',
codeId: workflow.codeId,
},
})
```
## GraphQL Client
- The Graphql main schema file is in `@apps/agent/generated/graphql/schema.graphql` - this is the source of truth for constructing all graphql queries
- The frontend uses React Query with `graphql-codegen` to interact with the backend GraphQL API. The types are generated and stored in `@apps/agent/generated/graphql`
- When working with React Query and GraphQL, some important utilities are already created to make the interaction simpler:
- `@apps/agent/lib/graphql/useGraphqlInfiniteQuery.ts`
- `@apps/agent/lib/graphql/useGraphqlMutation.ts`
- `@apps/agent/lib/graphql/useGraphqlQuery.ts`
- `@apps/agent/lib/graphql/getQueryKeyFromDocument.ts`
This is how a standard GraphQL query and mutation looks like:
```ts
import { graphql } from "~/graphql/gql";
import { useGraphqlQuery } from "@/lib/graphql/useGraphqlQuery";
import { useGraphqlMutation } from "@/lib/graphql/useGraphqlMutation";
import { useSessionInfo } from '@/lib/auth/sessionStorage'
import { getQueryKeyFromDocument } from "@/modules/graphql/getQueryKeyFromDocument";
import { useQueryClient } from "@tanstack/react-query";
export const GetProfileByUserIdDocument = graphql(`
query GetProfileByUserId($userId: String!) {
profileByUserId(userId: $userId) {
id
rowId
name
userId
meta
profilePictureUrl
linkedInUrl
updatedAt
createdAt
deletedAt
}
}
`);
const UpdateProfileIndustryDocument = graphql(`
mutation UpdateProfileIndustry($userId: String!, $meta: JSON) {
updateProfileByUserId(input: { userId: $userId, patch: { meta: $meta } }) {
profile {
id
rowId
meta
}
}
}
`);
const { sessionInfo } = useSessionInfo()
const userId = sessionInfo.user?.id
const queryClient = useQueryClient();
const { data: profileData } = useGraphqlQuery(
GetProfileByUserIdDocument,
{
userId,
},
{
enabled: !!userId,
},
);
const updateProfileMutation = useGraphqlMutation(
UpdateProfileIndustryDocument,
{
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [getQueryKeyFromDocument(GetProfileByUserIdDocument)],
});
},
},
);
```
To run codegen to generate graphql code after creating a query, you should run codegen using the command (since .env.development is necessary for codegen):
```sh
bun --env-file=.env.development run codegen
```

39
apps/agent/codegen.ts Normal file
View File

@@ -0,0 +1,39 @@
import path from 'node:path'
import { includeIgnoreFile } from '@eslint/compat'
import type { CodegenConfig } from '@graphql-codegen/cli'
// biome-ignore lint/style/noProcessEnv: env needed for codegen config
const env = process.env
const schemaPath = env.GRAPHQL_SCHEMA_PATH as string
const gitignorePath = path.resolve(__dirname, '.gitignore')
const ignorePatterns = includeIgnoreFile(
gitignorePath,
'Imported .gitignore patterns',
)
const ignoresList = ignorePatterns.ignores?.map((each) => `!${each}`) ?? []
const config: CodegenConfig = {
schema: schemaPath,
documents: ['./**/*.tsx', './**/*.ts', ...ignoresList],
ignoreNoDocuments: true,
generates: {
'./generated/graphql/': {
preset: 'client',
config: {
documentMode: 'string',
},
},
'./generated/graphql/schema.graphql': {
plugins: ['schema-ast'],
config: {
includeDirectives: true,
},
},
},
}
export default config

View File

@@ -0,0 +1,27 @@
import { Loader2 } from 'lucide-react'
import type { FC, ReactNode } from 'react'
import { Navigate, useLocation } from 'react-router'
import { useSession } from '@/lib/auth/auth-client'
interface AuthGuardProps {
children: ReactNode
}
export const AuthGuard: FC<AuthGuardProps> = ({ children }) => {
const { data: session, isPending } = useSession()
const location = useLocation()
if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
</div>
)
}
if (!session) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}

View File

@@ -1,6 +1,19 @@
import { ChevronDown, LogIn, LogOut, User } from 'lucide-react'
import type { FC } from 'react'
import { useNavigate } from 'react-router'
import ProductLogo from '@/assets/product_logo.svg'
import { ThemeToggle } from '@/components/elements/theme-toggle'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { GetProfileByUserIdDocument } from '@/entrypoints/app/profile/graphql/profileDocument'
import { useSessionInfo } from '@/lib/auth/sessionStorage'
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
import { cn } from '@/lib/utils'
import { useWorkspace } from '@/lib/workspace/use-workspace'
@@ -12,25 +25,137 @@ export const SidebarBranding: FC<SidebarBrandingProps> = ({
expanded = true,
}) => {
const { selectedFolder } = useWorkspace()
const { sessionInfo } = useSessionInfo()
const navigate = useNavigate()
const user = sessionInfo?.user
const isLoggedIn = !!user
const { data: profileData } = useGraphqlQuery(
GetProfileByUserIdDocument,
{ userId: user?.id ?? '' },
{ enabled: !!user?.id },
)
const profile = profileData?.profileByUserId
const profileName =
profile?.firstName || profile?.lastName
? [profile.firstName, profile.lastName].filter(Boolean).join(' ')
: null
const displayName = profileName || user?.name || 'User'
const displayImage = profile?.avatarUrl || user?.image
const getInitials = (name?: string | null) => {
if (!name) return '?'
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
const headerIcon = isLoggedIn ? (
displayImage ? (
<img
src={displayImage}
alt={displayName}
className="size-8 shrink-0 rounded-full object-cover"
/>
) : (
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary font-medium text-primary-foreground text-xs">
{getInitials(displayName)}
</div>
)
) : (
<img src={ProductLogo} alt="BrowserOS" className="size-8" />
)
return (
<div className="flex h-14 items-center border-b">
<div className="flex w-14 shrink-0 items-center justify-center">
<img src={ProductLogo} alt="BrowserOS" className="size-8" />
</div>
<div className="flex h-14 items-center justify-between border-b px-2">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'flex cursor-pointer items-center gap-2 rounded-lg p-1.5 text-left transition-colors hover:bg-sidebar-accent focus-visible:outline-none',
expanded ? 'pr-3' : '',
)}
>
{headerIcon}
<div
className={cn(
'flex min-w-0 flex-col gap-0.5 leading-none transition-opacity duration-200',
expanded ? 'opacity-100' : 'hidden',
)}
>
<div className="flex items-center gap-1">
<span className="truncate font-semibold">
{isLoggedIn
? displayName
: selectedFolder?.name || 'BrowserOS'}
</span>
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
</div>
<span
className={cn(
'truncate text-xs',
isLoggedIn
? 'text-muted-foreground'
: 'font-medium text-primary',
)}
>
{isLoggedIn ? 'Personal' : 'Sign in'}
</span>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side={expanded ? 'bottom' : 'right'}
align="start"
className="w-56"
>
{isLoggedIn ? (
<>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="font-medium text-sm leading-none">
{displayName}
</p>
<p className="text-muted-foreground text-xs leading-none">
Personal
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate('/profile')}>
<User className="mr-2 size-4" />
Update Profile
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => navigate('/logout')}
variant="destructive"
>
<LogOut className="mr-2 size-4" />
Sign out
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onClick={() => navigate('/login')}>
<LogIn className="mr-2 size-4" />
Sign in
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<div
className={cn(
'flex min-w-0 flex-1 items-center justify-between pr-3 transition-opacity duration-200',
expanded ? 'opacity-100' : 'opacity-0',
'shrink-0 transition-opacity duration-200',
expanded ? 'opacity-100' : 'hidden',
)}
>
<div className="flex min-w-0 flex-col gap-0.5 leading-none">
<span className="truncate font-semibold">
{selectedFolder?.name || 'BrowserOS'}
</span>
<span className="text-muted-foreground text-xs">Personal</span>
</div>
<ThemeToggle className="h-8 w-8 shrink-0" iconClassName="h-4 w-4" />
<ThemeToggle className="h-8 w-8" iconClassName="h-4 w-4" />
</div>
</div>
)

View File

@@ -12,10 +12,15 @@ import { ConnectMCP } from './connect-mcp/ConnectMCP'
import { CreateGraphWrapper } from './create-graph/CreateGraphWrapper'
import { CustomizationPage } from './customization/CustomizationPage'
import { SurveyPage } from './jtbd-agent/SurveyPage'
import { AuthLayout } from './layout/AuthLayout'
import { SettingsSidebarLayout } from './layout/SettingsSidebarLayout'
import { SidebarLayout } from './layout/SidebarLayout'
import { LlmHubPage } from './llm-hub/LlmHubPage'
import { LoginPage } from './login/LoginPage'
import { LogoutPage } from './login/LogoutPage'
import { MagicLinkCallback } from './login/MagicLinkCallback'
import { MCPSettingsPage } from './mcp-settings/MCPSettingsPage'
import { ProfilePage } from './profile/ProfilePage'
import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
import { WorkflowsPageWrapper } from './workflows/WorkflowsPageWrapper'
@@ -53,6 +58,14 @@ export const App: FC = () => {
return (
<HashRouter>
<Routes>
{/* Public auth routes */}
<Route element={<AuthLayout />}>
<Route path="login" element={<LoginPage />} />
<Route path="logout" element={<LogoutPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="auth/magic-link" element={<MagicLinkCallback />} />
</Route>
{/* Main app with sidebar */}
<Route element={<SidebarLayout />}>
{/* Home routes */}
@@ -82,7 +95,7 @@ export const App: FC = () => {
{/* Full-screen without sidebar */}
<Route path="workflows/create-graph" element={<CreateGraphWrapper />} />
{/* Onboarding routes - no sidebar */}
{/* Onboarding routes - no sidebar, no auth required */}
<Route path="onboarding">
<Route index element={<Onboarding />} />
<Route path="steps/:stepId" element={<StepsLayout />} />

View File

@@ -1,4 +1,5 @@
import { type FC, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { type FC, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
AlertDialog,
@@ -10,12 +11,23 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { useSessionInfo } from '@/lib/auth/sessionStorage'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
import { getQueryKeyFromDocument } from '@/lib/graphql/getQueryKeyFromDocument'
import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation'
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
import type { ProviderTemplate } from '@/lib/llm-providers/providerTemplates'
import { testProvider } from '@/lib/llm-providers/testProvider'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { ConfiguredProvidersList } from './ConfiguredProvidersList'
import {
DeleteRemoteLlmProviderDocument,
GetRemoteLlmProvidersDocument,
} from './graphql/aiSettingsDocument'
import type { IncompleteProvider } from './IncompleteProviderCard'
import { IncompleteProvidersList } from './IncompleteProvidersList'
import { LlmProvidersHeader } from './LlmProvidersHeader'
import { NewProviderDialog } from './NewProviderDialog'
import { ProviderTemplatesSection } from './ProviderTemplatesSection'
@@ -33,6 +45,44 @@ export const AISettingsPage: FC = () => {
deleteProvider,
} = useLlmProviders()
const { baseUrl: agentServerUrl } = useAgentServerUrl()
const { sessionInfo } = useSessionInfo()
const queryClient = useQueryClient()
const userId = sessionInfo.user?.id
const { data: profileData } = useGraphqlQuery(
GetProfileIdByUserIdDocument,
{ userId: userId! },
{ enabled: !!userId },
)
const profileId = profileData?.profileByUserId?.rowId
const { data: remoteProvidersData } = useGraphqlQuery(
GetRemoteLlmProvidersDocument,
{ profileId: profileId! },
{ enabled: !!profileId },
)
const deleteRemoteProviderMutation = useGraphqlMutation(
DeleteRemoteLlmProviderDocument,
{
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [getQueryKeyFromDocument(GetRemoteLlmProvidersDocument)],
})
},
},
)
const incompleteProviders = useMemo<IncompleteProvider[]>(() => {
if (!remoteProvidersData?.llmProviders?.nodes) return []
const localProviderIds = new Set(providers.map((p) => p.id))
return remoteProvidersData.llmProviders.nodes
.filter((node): node is NonNullable<typeof node> => node !== null)
.filter((node) => !localProviderIds.has(node.rowId))
}, [remoteProvidersData, providers])
const [isNewDialogOpen, setIsNewDialogOpen] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
@@ -43,6 +93,8 @@ export const AISettingsPage: FC = () => {
useState<LlmProviderConfig | null>(null)
const [providerToDelete, setProviderToDelete] =
useState<LlmProviderConfig | null>(null)
const [incompleteProviderToDelete, setIncompleteProviderToDelete] =
useState<IncompleteProvider | null>(null)
const [testingProviderId, setTestingProviderId] = useState<string | null>(
null,
)
@@ -77,10 +129,43 @@ export const AISettingsPage: FC = () => {
const confirmDeleteProvider = async () => {
if (providerToDelete) {
await deleteProvider(providerToDelete.id)
deleteRemoteProviderMutation.mutate({ rowId: providerToDelete.id })
setProviderToDelete(null)
}
}
const handleAddKeysToIncomplete = (provider: IncompleteProvider) => {
const timestamp = Date.now()
setTemplateValues({
id: provider.rowId,
type: provider.type as LlmProviderConfig['type'],
name: provider.name,
baseUrl: provider.baseUrl ?? undefined,
modelId: provider.modelId,
supportsImages: provider.supportsImages,
contextWindow: provider.contextWindow ?? 128000,
temperature: provider.temperature ?? 0.2,
resourceName: provider.resourceName ?? undefined,
region: provider.region ?? undefined,
createdAt: timestamp,
updatedAt: timestamp,
})
setIsNewDialogOpen(true)
}
const handleDeleteIncompleteProvider = (provider: IncompleteProvider) => {
setIncompleteProviderToDelete(provider)
}
const confirmDeleteIncompleteProvider = () => {
if (incompleteProviderToDelete) {
deleteRemoteProviderMutation.mutate({
rowId: incompleteProviderToDelete.rowId,
})
setIncompleteProviderToDelete(null)
}
}
const handleSaveProvider = async (provider: LlmProviderConfig) => {
await saveProvider(provider)
}
@@ -161,6 +246,12 @@ export const AISettingsPage: FC = () => {
onDeleteProvider={handleDeleteProvider}
/>
<IncompleteProvidersList
providers={incompleteProviders}
onAddKeys={handleAddKeysToIncomplete}
onDelete={handleDeleteIncompleteProvider}
/>
<NewProviderDialog
open={isNewDialogOpen}
onOpenChange={setIsNewDialogOpen}
@@ -195,6 +286,28 @@ export const AISettingsPage: FC = () => {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={!!incompleteProviderToDelete}
onOpenChange={(open) => !open && setIncompleteProviderToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Synced Provider</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "
{incompleteProviderToDelete?.name}
"? This will remove it from all your devices.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDeleteIncompleteProvider}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,61 @@
import { KeyRound, Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { Button } from '@/components/ui/button'
import { ProviderIcon } from '@/lib/llm-providers/providerIcons'
import type { ProviderType } from '@/lib/llm-providers/types'
export interface IncompleteProvider {
rowId: string
type: string
name: string
baseUrl?: string | null
modelId: string
supportsImages: boolean
contextWindow?: number | null
temperature?: number | null
resourceName?: string | null
region?: string | null
}
interface IncompleteProviderCardProps {
provider: IncompleteProvider
onAddKeys: () => void
onDelete: () => void
}
export const IncompleteProviderCard: FC<IncompleteProviderCardProps> = ({
provider,
onAddKeys,
onDelete,
}) => {
return (
<div className="flex w-full items-center gap-4 rounded-xl border border-amber-500/50 border-dashed bg-amber-500/5 p-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-amber-500/10 text-amber-500">
<ProviderIcon type={provider.type as ProviderType} size={24} />
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="font-semibold">{provider.name}</span>
</div>
<p className="truncate text-muted-foreground text-sm">
{provider.modelId}
{provider.baseUrl && `${provider.baseUrl}`}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" onClick={onAddKeys}>
<KeyRound className="mr-1.5 h-4 w-4" />
Add Keys
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { CloudOff } from 'lucide-react'
import type { FC } from 'react'
import {
type IncompleteProvider,
IncompleteProviderCard,
} from './IncompleteProviderCard'
interface IncompleteProvidersListProps {
providers: IncompleteProvider[]
onAddKeys: (provider: IncompleteProvider) => void
onDelete: (provider: IncompleteProvider) => void
}
export const IncompleteProvidersList: FC<IncompleteProvidersListProps> = ({
providers,
onAddKeys,
onDelete,
}) => {
if (providers.length === 0) return null
return (
<div className="space-y-3">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<CloudOff className="h-4 w-4" />
<h3 className="font-medium text-sm">
Synced Providers (Missing API Keys)
</h3>
</div>
<p className="text-muted-foreground text-sm">
These providers were synced from another device but need API keys to be
used on this device.
</p>
<div className="space-y-3">
{providers.map((provider) => (
<IncompleteProviderCard
key={provider.rowId}
provider={provider}
onAddKeys={() => onAddKeys(provider)}
onDelete={() => onDelete(provider)}
/>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { graphql } from '@/generated/graphql/gql'
export const GetRemoteLlmProvidersDocument = graphql(`
query GetRemoteLlmProviders($profileId: String!) {
llmProviders(condition: { profileId: $profileId }) {
nodes {
rowId
type
name
baseUrl
modelId
supportsImages
contextWindow
temperature
resourceName
region
createdAt
updatedAt
}
}
}
`)
export const DeleteRemoteLlmProviderDocument = graphql(`
mutation DeleteRemoteLlmProvider($rowId: String!) {
deleteLlmProvider(input: { rowId: $rowId }) {
deletedLlmProviderId
}
}
`)

View File

@@ -0,0 +1,14 @@
import type { FC } from 'react'
import { Outlet } from 'react-router'
import ProductLogo from '@/assets/product_logo.svg'
export const AuthLayout: FC = () => {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background p-4">
<div className="mb-8 flex flex-col items-center">
<img src={ProductLogo} alt="BrowserOS" className="size-16" />
</div>
<Outlet />
</div>
)
}

View File

@@ -0,0 +1,228 @@
import {
AlertCircle,
ArrowLeft,
CheckCircle2,
Loader2,
Mail,
} from 'lucide-react'
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { signIn, useSession } from '@/lib/auth/auth-client'
type LoginState = 'idle' | 'loading' | 'magic-link-sent' | 'error'
export const LoginPage: FC = () => {
const navigate = useNavigate()
const { data: session, isPending } = useSession()
const [email, setEmail] = useState('')
const [state, setState] = useState<LoginState>('idle')
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (session && !isPending) {
navigate('/home', { replace: true })
}
}, [session, isPending, navigate])
const handleMagicLink = async (e: React.FormEvent) => {
e.preventDefault()
if (!email.trim()) return
setState('loading')
setError(null)
try {
const result = await signIn.magicLink({
email: email.trim(),
callbackURL: '/home',
})
if (result.error) {
setState('error')
setError(result.error.message || 'Failed to send magic link')
return
}
setState('magic-link-sent')
} catch (err) {
setState('error')
setError(err instanceof Error ? err.message : 'Failed to send magic link')
}
}
const handleGoogleSignIn = async () => {
setState('loading')
setError(null)
try {
await signIn.social({
provider: 'google',
callbackURL: '/home',
})
} catch (err) {
setState('error')
setError(
err instanceof Error ? err.message : 'Failed to sign in with Google',
)
}
}
if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
</div>
)
}
if (state === 'magic-link-sent') {
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex size-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<CheckCircle2 className="size-6 text-green-600 dark:text-green-400" />
</div>
<CardTitle>Check your email</CardTitle>
<CardDescription>
We sent a magic link to <strong>{email}</strong>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-center text-muted-foreground text-sm">
Click the link in the email to sign in. If you don't see it, check
your spam folder.
</p>
<Button
variant="outline"
className="w-full"
onClick={() => {
setState('idle')
setEmail('')
}}
>
Use a different email
</Button>
</CardContent>
</Card>
)
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
className="shrink-0"
>
<ArrowLeft className="size-4" />
</Button>
<div className="flex-1 pr-9 text-center">
<CardTitle className="text-2xl">Welcome to BrowserOS</CardTitle>
<CardDescription>
Sign in to your account to continue
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<form onSubmit={handleMagicLink} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={state === 'loading'}
required
/>
</div>
<Button
type="submit"
className="w-full"
disabled={state === 'loading' || !email.trim()}
>
{state === 'loading' ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Mail className="size-4" />
)}
Send Magic Link
</Button>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator className="w-full" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<Button
variant="outline"
className="w-full"
onClick={handleGoogleSignIn}
disabled={state === 'loading'}
>
{state === 'loading' ? (
<Loader2 className="size-4 animate-spin" />
) : (
<GoogleIcon />
)}
Continue with Google
</Button>
</CardContent>
</Card>
)
}
function GoogleIcon() {
return (
<svg viewBox="0 0 24 24" className="size-4" role="img" aria-label="Google">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
)
}

View File

@@ -0,0 +1,49 @@
import { useQueryClient } from '@tanstack/react-query'
import localforage from 'localforage'
import { Loader2 } from 'lucide-react'
import type { FC } from 'react'
import { useEffect } from 'react'
import { useNavigate } from 'react-router'
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { signOut } from '@/lib/auth/auth-client'
import { providersStorage } from '@/lib/llm-providers/storage'
import { scheduledJobStorage } from '@/lib/schedules/scheduleStorage'
export const LogoutPage: FC = () => {
const navigate = useNavigate()
const queryClient = useQueryClient()
// biome-ignore lint/correctness/useExhaustiveDependencies: must run only once to ensure the logout process happens successfully
useEffect(() => {
const performLogout = async () => {
await providersStorage.removeValue()
await scheduledJobStorage.removeValue()
queryClient.clear()
await localforage.clear()
await signOut()
navigate('/home', { replace: true })
}
performLogout()
}, [])
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
</div>
<CardTitle className="text-2xl">Logging out</CardTitle>
<CardDescription>
Clearing your session and synced data...
</CardDescription>
</CardHeader>
</Card>
)
}

View File

@@ -0,0 +1,70 @@
import { AlertCircle, Loader2 } from 'lucide-react'
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { useSession } from '@/lib/auth/auth-client'
export const MagicLinkCallback: FC = () => {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const { data: session, isPending } = useSession()
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const errorParam = searchParams.get('error')
if (errorParam) {
setError(decodeURIComponent(errorParam))
return
}
if (!isPending && session) {
navigate('/home', { replace: true })
}
}, [session, isPending, searchParams, navigate])
if (error) {
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle>Verification failed</CardTitle>
<CardDescription>We couldn't verify your magic link</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
<Button
variant="outline"
className="w-full"
onClick={() => navigate('/login', { replace: true })}
>
Back to login
</Button>
</CardContent>
</Card>
)
}
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle>Verifying your link</CardTitle>
<CardDescription>Please wait while we sign you in...</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-8">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
</CardContent>
</Card>
)
}

View File

@@ -4,6 +4,8 @@ import '@/styles/global.css'
import { ThemeProvider } from '@/components/theme-provider.tsx'
import { Toaster } from '@/components/ui/sonner'
import { AnalyticsProvider } from '@/lib/analytics/AnalyticsProvider.tsx'
import { AuthProvider } from '@/lib/auth/AuthProvider'
import { QueryProvider } from '@/lib/graphql/QueryProvider'
import { sentryRootErrorHandler } from '@/lib/sentry/sentryRootErrorHandler.ts'
import { App } from './App'
@@ -12,12 +14,16 @@ const $root = document.getElementById('root')
if ($root) {
ReactDOM.createRoot($root, sentryRootErrorHandler).render(
<React.StrictMode>
<AnalyticsProvider>
<ThemeProvider>
<App />
<Toaster />
</ThemeProvider>
</AnalyticsProvider>
<AuthProvider>
<QueryProvider>
<AnalyticsProvider>
<ThemeProvider>
<App />
<Toaster />
</ThemeProvider>
</AnalyticsProvider>
</QueryProvider>
</AuthProvider>
</React.StrictMode>,
)
}

View File

@@ -0,0 +1,354 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useQueryClient } from '@tanstack/react-query'
import {
AlertCircle,
ArrowLeft,
Camera,
CheckCircle2,
CircleUser,
Loader2,
UserPen,
} from 'lucide-react'
import type { FC } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useNavigate } from 'react-router'
import { z } from 'zod/v3'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { useSessionInfo } from '@/lib/auth/sessionStorage'
import { env } from '@/lib/env'
import { getQueryKeyFromDocument } from '@/lib/graphql/getQueryKeyFromDocument'
import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation'
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
import {
GetProfileByUserIdDocument,
UpdateProfileByUserIdDocument,
} from './graphql/profileDocument'
const formSchema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
})
type FormValues = z.infer<typeof formSchema>
type ProfileState = 'idle' | 'loading' | 'success' | 'error'
export const ProfilePage: FC = () => {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { sessionInfo } = useSessionInfo()
const fileInputRef = useRef<HTMLInputElement>(null)
const userId = sessionInfo?.user?.id
const isLoggedIn = !!userId
const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
const [avatarPreview, setAvatarPreview] = useState<string | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [state, setState] = useState<ProfileState>('idle')
const [error, setError] = useState<string | null>(null)
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
firstName: '',
lastName: '',
},
})
const { data: profileData, isLoading: isLoadingProfile } = useGraphqlQuery(
GetProfileByUserIdDocument,
{ userId: userId! },
{ enabled: !!userId },
)
const updateProfileMutation = useGraphqlMutation(
UpdateProfileByUserIdDocument,
{
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [getQueryKeyFromDocument(GetProfileByUserIdDocument)],
})
setState('success')
setTimeout(() => setState('idle'), 3000)
},
onError: (err) => {
setState('error')
setError(
err instanceof Error ? err.message : 'Failed to update profile',
)
},
},
)
useEffect(() => {
if (profileData?.profileByUserId) {
const profile = profileData.profileByUserId
form.reset({
firstName: profile.firstName || '',
lastName: profile.lastName || '',
})
setAvatarUrl(profile.avatarUrl || null)
}
}, [profileData, form])
useEffect(() => {
if (!isLoggedIn && !sessionInfo) {
navigate('/login', { replace: true })
}
}, [isLoggedIn, sessionInfo, navigate])
useEffect(() => {
return () => {
if (avatarPreview) URL.revokeObjectURL(avatarPreview)
}
}, [avatarPreview])
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
setError('Please select an image file')
return
}
if (file.size > 5 * 1024 * 1024) {
setError('Image must be less than 5MB')
return
}
setIsUploading(true)
setError(null)
const previewUrl = URL.createObjectURL(file)
setAvatarPreview(previewUrl)
try {
const res = await fetch(
`${env.VITE_PUBLIC_BROWSEROS_API}/upload/presigned-url`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ contentType: file.type }),
},
)
if (!res.ok) throw new Error('Failed to get upload URL')
const { presignedUrl, publicUrl, headers } = await res.json()
const uploadRes = await fetch(presignedUrl, {
method: 'PUT',
headers,
body: file,
})
if (!uploadRes.ok) throw new Error('Failed to upload image')
setAvatarUrl(publicUrl)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to upload image')
setAvatarPreview(null)
} finally {
setIsUploading(false)
}
}
const handleAvatarClick = () => {
fileInputRef.current?.click()
}
const onSubmit = (values: FormValues) => {
if (!userId) return
setState('loading')
setError(null)
updateProfileMutation.mutate({
userId,
patch: {
firstName: values.firstName.trim(),
lastName: values.lastName.trim(),
avatarUrl: avatarUrl,
},
})
}
const getInitials = () => {
const first = form.watch('firstName').trim()[0] || ''
const last = form.watch('lastName').trim()[0] || ''
return (first + last).toUpperCase()
}
if (!isLoggedIn) {
return (
<div className="flex min-h-screen items-center justify-center">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
</div>
)
}
if (isLoadingProfile) {
return (
<Card className="w-full max-w-md">
<CardContent className="flex min-h-[420px] items-center justify-center">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
</CardContent>
</Card>
)
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
className="shrink-0"
>
<ArrowLeft className="size-4" />
</Button>
<div className="flex-1 pr-9 text-center">
<CardTitle className="text-2xl">Update Profile</CardTitle>
<CardDescription>Update your profile information</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{state === 'success' && (
<Alert className="border-green-500 bg-green-50 dark:bg-green-950/30">
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
<AlertDescription className="text-green-600 dark:text-green-400">
Profile updated successfully!
</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="flex flex-col items-center gap-2">
<button
type="button"
onClick={handleAvatarClick}
disabled={isUploading || state === 'loading'}
className="group relative cursor-pointer"
>
{avatarPreview || avatarUrl ? (
<img
src={avatarPreview || avatarUrl!}
alt="Profile"
className="size-24 rounded-full object-cover transition-opacity group-hover:opacity-80"
/>
) : getInitials() ? (
<div className="flex size-24 items-center justify-center rounded-full bg-primary font-semibold text-2xl text-primary-foreground transition-opacity group-hover:opacity-80">
{getInitials()}
</div>
) : (
<div className="flex size-24 items-center justify-center rounded-full bg-muted transition-opacity group-hover:opacity-80">
<CircleUser className="size-12 text-muted-foreground" />
</div>
)}
<div className="absolute right-0 bottom-0 flex size-8 items-center justify-center rounded-full border-2 border-background bg-primary text-primary-foreground transition-transform group-hover:scale-110">
{isUploading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Camera className="size-4" />
)}
</div>
</button>
<p className="text-muted-foreground text-xs">
Click to upload profile picture
</p>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input
placeholder=""
disabled={state === 'loading'}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input
placeholder=""
disabled={state === 'loading'}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button
type="submit"
className="w-full"
disabled={state === 'loading' || isUploading}
>
{state === 'loading' ? (
<Loader2 className="size-4 animate-spin" />
) : (
<UserPen className="size-4" />
)}
Save Changes
</Button>
</form>
</Form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,25 @@
import { graphql } from '@/generated/graphql/gql'
export const GetProfileByUserIdDocument = graphql(`
query GetProfileByUserId($userId: String!) {
profileByUserId(userId: $userId) {
rowId
firstName
lastName
avatarUrl
}
}
`)
export const UpdateProfileByUserIdDocument = graphql(`
mutation UpdateProfileByUserId($userId: String!, $patch: ProfilePatch!) {
updateProfileByUserId(input: { userId: $userId, patch: $patch }) {
profile {
rowId
firstName
lastName
avatarUrl
}
}
}
`)

View File

@@ -71,7 +71,7 @@ interface NewScheduledTaskDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
initialValues?: ScheduledJob | null
onSave: (data: Omit<ScheduledJob, 'id' | 'createdAt'>) => void
onSave: (data: Omit<ScheduledJob, 'id' | 'createdAt' | 'updatedAt'>) => void
}
export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({

View File

@@ -18,7 +18,9 @@ import {
SCHEDULED_TASK_TOGGLED_EVENT,
SCHEDULED_TASK_VIEW_RESULTS_EVENT,
} from '@/lib/constants/analyticsEvents'
import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation'
import { track } from '@/lib/metrics/track'
import { DeleteScheduledJobDocument } from '@/lib/schedules/graphql/syncSchedulesDocument'
import { useScheduledJobs } from '@/lib/schedules/scheduleStorage'
import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes'
import { NewScheduledTaskDialog } from './NewScheduledTaskDialog'
@@ -34,6 +36,8 @@ export const ScheduledTasksPage: FC = () => {
const { jobs, addJob, editJob, toggleJob, removeJob, runJob } =
useScheduledJobs()
const deleteRemoteJobMutation = useGraphqlMutation(DeleteScheduledJobDocument)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingJob, setEditingJob] = useState<ScheduledJob | null>(null)
const [deleteJobId, setDeleteJobId] = useState<string | null>(null)
@@ -56,12 +60,15 @@ export const ScheduledTasksPage: FC = () => {
const confirmDelete = async () => {
if (deleteJobId) {
await removeJob(deleteJobId)
deleteRemoteJobMutation.mutate({ rowId: deleteJobId })
setDeleteJobId(null)
track(SCHEDULED_TASK_DELETED_EVENT)
}
}
const handleSave = async (data: Omit<ScheduledJob, 'id' | 'createdAt'>) => {
const handleSave = async (
data: Omit<ScheduledJob, 'id' | 'createdAt' | 'updatedAt'>,
) => {
if (editingJob) {
await editJob(editingJob.id, data)
track(SCHEDULED_TASK_EDITED_EVENT, {

View File

@@ -0,0 +1,13 @@
import { env } from '@/lib/env'
export default defineContentScript({
matches: [`${env.VITE_PUBLIC_BROWSEROS_API}/home`],
runAt: 'document_start',
main() {
window.addEventListener('message', (event) => {
if (event.data?.type === 'AUTH_SUCCESS') {
chrome.runtime.sendMessage({ type: 'AUTH_SUCCESS' })
}
})
},
})

View File

@@ -1,11 +1,20 @@
import { sessionStorage } from '@/lib/auth/sessionStorage'
import { Capabilities } from '@/lib/browseros/capabilities'
import { getHealthCheckUrl, getMcpServerUrl } from '@/lib/browseros/helpers'
import { openSidePanel, toggleSidePanel } from '@/lib/browseros/toggleSidePanel'
import { checkAndShowChangelog } from '@/lib/changelog/changelog-notifier'
import { setupLlmProvidersBackupToBrowserOS } from '@/lib/llm-providers/storage'
import {
setupLlmProvidersBackupToBrowserOS,
setupLlmProvidersSyncToBackend,
syncLlmProviders,
} from '@/lib/llm-providers/storage'
import { fetchMcpTools } from '@/lib/mcp/client'
import { onServerMessage } from '@/lib/messaging/server/serverMessages'
import { onOpenSidePanelWithSearch } from '@/lib/messaging/sidepanel/openSidepanelWithSearch'
import {
setupScheduledJobsSyncToBackend,
syncScheduledJobs,
} from '@/lib/schedules/scheduleStorage'
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
import { scheduledJobRuns } from './scheduledJobRuns'
@@ -14,6 +23,8 @@ export default defineBackground(() => {
Capabilities.initialize().catch(() => null)
setupLlmProvidersBackupToBrowserOS()
setupLlmProvidersSyncToBackend()
setupScheduledJobsSyncToBackend()
scheduledJobRuns()
@@ -52,6 +63,25 @@ export default defineBackground(() => {
}
})
chrome.runtime.onMessage.addListener((message, sender) => {
if (message?.type === 'AUTH_SUCCESS' && sender.tab?.id) {
chrome.tabs.update(sender.tab.id, {
url: chrome.runtime.getURL('app.html#/home'),
})
}
})
sessionStorage.watch(async (newSession) => {
if (newSession?.user?.id) {
try {
await syncLlmProviders()
} catch {}
try {
await syncScheduledJobs()
} catch {}
}
})
onServerMessage('checkHealth', async () => {
try {
const url = await getHealthCheckUrl()

View File

@@ -40,6 +40,7 @@ import { NewTabBranding } from './NewTabBranding'
import { ScheduleResults } from './ScheduleResults'
import { SearchSuggestions } from './SearchSuggestions'
import { ShortcutsDialog } from './ShortcutsDialog'
import { SignInHint } from './SignInHint'
import { TopSites } from './TopSites'
/**
@@ -365,6 +366,7 @@ export const NewTab = () => {
onOpenChange={setShortcutsDialogOpen}
/>
)}
<SignInHint />
</div>
)
}

View File

@@ -0,0 +1,94 @@
import { storage } from '@wxt-dev/storage'
import { Cloud, X } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { Button } from '@/components/ui/button'
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { useSession } from '@/lib/auth/auth-client'
const DISMISS_DURATION = 24 * 60 * 60 * 1000
const signInHintDismissedAtStorage = storage.defineItem<number | null>(
'local:signInHintDismissedAt',
{ fallback: null },
)
export const SignInHint = () => {
const { data: session, isPending } = useSession()
const navigate = useNavigate()
const [visible, setVisible] = useState(false)
const [dismissed, setDismissed] = useState(false)
useEffect(() => {
if (isPending || session) return
let cancelled = false
let timer: ReturnType<typeof setTimeout>
signInHintDismissedAtStorage.getValue().then((dismissedAt) => {
if (cancelled) return
if (dismissedAt && Date.now() - dismissedAt < DISMISS_DURATION) return
timer = setTimeout(() => {
if (!cancelled) setVisible(true)
}, 2000)
})
return () => {
cancelled = true
clearTimeout(timer)
}
}, [isPending, session])
const handleDismiss = async () => {
setDismissed(true)
await signInHintDismissedAtStorage.setValue(Date.now())
}
const show = visible && !dismissed
return (
<AnimatePresence>
{show && (
<motion.div
className="fixed right-4 bottom-4 z-50"
initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 100 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
>
<Card className="w-80 gap-0 py-4">
<CardHeader className="gap-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<Cloud className="size-5 text-muted-foreground" />
<CardTitle className="text-base">Sync your data</CardTitle>
</div>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={handleDismiss}
>
<X className="size-4" />
</Button>
</div>
<CardDescription>
Sign in to sync conversation history to the cloud.
</CardDescription>
<Button className="w-full" onClick={() => navigate('/login')}>
Sign in
</Button>
</CardHeader>
</Card>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -1,185 +1,118 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { MessageSquare, Trash2 } from 'lucide-react'
import { type FC, useMemo } from 'react'
import { Link } from 'react-router'
import {
type Conversation,
useConversations,
} from '@/lib/conversations/conversationStorage'
import { useQueryClient } from '@tanstack/react-query'
import type { UIMessage } from 'ai'
import type { FC } from 'react'
import { useMemo } from 'react'
import { useSessionInfo } from '@/lib/auth/sessionStorage'
import { useConversations } from '@/lib/conversations/conversationStorage'
import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
import { getQueryKeyFromDocument } from '@/lib/graphql/getQueryKeyFromDocument'
import { useGraphqlInfiniteQuery } from '@/lib/graphql/useGraphqlInfiniteQuery'
import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation'
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
import { useChatSessionContext } from '../layout/ChatSessionContext'
import { ConversationList } from './components/ConversationList'
import type { HistoryConversation } from './components/types'
import { extractLastUserMessage, groupConversations } from './components/utils'
import {
DeleteConversationDocument,
GetConversationsForHistoryDocument,
} from './graphql/chatHistoryDocument'
import { LocalChatHistory } from './local/LocalChatHistory'
dayjs.extend(relativeTime)
const RemoteChatHistory: FC<{ userId: string }> = ({ userId }) => {
const { conversationId: activeConversationId } = useChatSessionContext()
const queryClient = useQueryClient()
type TimeGroup = 'today' | 'thisWeek' | 'thisMonth' | 'older'
const { data: profileData } = useGraphqlQuery(GetProfileIdByUserIdDocument, {
userId,
})
const profileId = profileData?.profileByUserId?.rowId
interface GroupedConversations {
today: Conversation[]
thisWeek: Conversation[]
thisMonth: Conversation[]
older: Conversation[]
}
const TIME_GROUP_LABELS: Record<TimeGroup, string> = {
today: 'Today',
thisWeek: 'This Week',
thisMonth: 'This Month',
older: 'Older',
}
const getTimeGroup = (timestamp: number): TimeGroup => {
const date = dayjs(timestamp)
const now = dayjs()
if (date.isSame(now, 'day')) return 'today'
if (date.isSame(now, 'week')) return 'thisWeek'
if (date.isSame(now, 'month')) return 'thisMonth'
return 'older'
}
const getLastUserMessage = (conversation: Conversation): string => {
const userMessages = conversation.messages.filter((m) => m.role === 'user')
const lastUserMessage = userMessages[userMessages.length - 1]
if (!lastUserMessage) return 'New conversation'
const textParts = lastUserMessage.parts.filter((p) => p.type === 'text')
const text = textParts.map((p) => p.text).join(' ')
return text || 'New conversation'
}
const ConversationItem: FC<{
conversation: Conversation
onDelete: (id: string) => void
isActive: boolean
}> = ({ conversation, onDelete, isActive }) => {
const label = getLastUserMessage(conversation)
const relativeTimeAgo = dayjs(conversation.lastMessagedAt).fromNow()
return (
<Link
to={`/?conversationId=${conversation.id}`}
className={`group flex w-full items-start gap-3 rounded-lg px-3 py-2.5 transition-colors hover:bg-muted/50 ${
isActive ? 'bg-muted/70' : ''
}`}
>
<div
className={`mt-0.5 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground'}`}
>
<MessageSquare className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<p className="truncate font-medium text-foreground text-sm">{label}</p>
<p className="text-muted-foreground text-xs">{relativeTimeAgo}</p>
</div>
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onDelete(conversation.id)
}}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
title="Delete conversation"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</Link>
const {
data: graphqlData,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useGraphqlInfiniteQuery(
GetConversationsForHistoryDocument,
(cursor) => ({ profileId: profileId!, after: cursor }),
{
enabled: !!profileId,
initialPageParam: undefined,
getNextPageParam: (lastPage) =>
lastPage.conversations?.pageInfo.hasNextPage
? lastPage.conversations.pageInfo.endCursor
: undefined,
},
)
}
const ConversationGroup: FC<{
label: string
conversations: Conversation[]
onDelete: (id: string) => void
activeConversationId: string
}> = ({ label, conversations, onDelete, activeConversationId }) => {
if (conversations.length === 0) return null
const deleteConversationMutation = useGraphqlMutation(
DeleteConversationDocument,
{
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [
getQueryKeyFromDocument(GetConversationsForHistoryDocument),
],
})
},
},
)
const handleDelete = (id: string) => {
deleteConversationMutation.mutate({ rowId: id })
}
const conversations = useMemo<HistoryConversation[]>(() => {
if (!graphqlData?.pages) return []
return graphqlData.pages.flatMap((page) =>
(page.conversations?.nodes ?? [])
.filter((node): node is NonNullable<typeof node> => node !== null)
.map((node) => {
const messages = node.conversationMessages.nodes
.filter((m): m is NonNullable<typeof m> => m !== null)
.map((m) => m.message as UIMessage)
const timestamp = node.lastMessagedAt.endsWith('Z')
? node.lastMessagedAt
: `${node.lastMessagedAt}Z`
return {
id: node.rowId,
lastMessagedAt: new Date(timestamp).getTime(),
lastUserMessage: extractLastUserMessage(messages),
}
}),
)
}, [graphqlData])
const groupedConversations = useMemo(
() => groupConversations(conversations),
[conversations],
)
return (
<div className="mb-4">
<h3 className="mb-2 px-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">
{label}
</h3>
<div className="space-y-1">
{conversations.map((conversation) => (
<ConversationItem
key={conversation.id}
conversation={conversation}
onDelete={onDelete}
isActive={conversation.id === activeConversationId}
/>
))}
</div>
</div>
<ConversationList
groupedConversations={groupedConversations}
activeConversationId={activeConversationId}
onDelete={handleDelete}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={fetchNextPage}
/>
)
}
export const ChatHistory: FC = () => {
const { conversations, removeConversation } = useConversations()
const { conversationId: activeConversationId } = useChatSessionContext()
const { sessionInfo } = useSessionInfo()
const userId = sessionInfo.user?.id
// needed to initiate remote-sync
useConversations()
const groupedConversations = useMemo<GroupedConversations>(() => {
const groups: GroupedConversations = {
today: [],
thisWeek: [],
thisMonth: [],
older: [],
}
if (userId) {
return <RemoteChatHistory userId={userId} />
}
for (const conversation of conversations) {
const group = getTimeGroup(conversation.lastMessagedAt)
groups[group].push(conversation)
}
return groups
}, [conversations])
const hasConversations = conversations.length > 0
return (
<main className="mt-4 flex h-full flex-1 flex-col space-y-4 overflow-y-auto">
<div className="w-full p-3">
{!hasConversations ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/50" />
<p className="text-muted-foreground text-sm">
No conversations yet
</p>
<Link to="/" className="mt-2 text-primary text-sm hover:underline">
Start a new chat
</Link>
</div>
) : (
<>
<ConversationGroup
label={TIME_GROUP_LABELS.today}
conversations={groupedConversations.today}
onDelete={removeConversation}
activeConversationId={activeConversationId}
/>
<ConversationGroup
label={TIME_GROUP_LABELS.thisWeek}
conversations={groupedConversations.thisWeek}
onDelete={removeConversation}
activeConversationId={activeConversationId}
/>
<ConversationGroup
label={TIME_GROUP_LABELS.thisMonth}
conversations={groupedConversations.thisMonth}
onDelete={removeConversation}
activeConversationId={activeConversationId}
/>
<ConversationGroup
label={TIME_GROUP_LABELS.older}
conversations={groupedConversations.older}
onDelete={removeConversation}
activeConversationId={activeConversationId}
/>
</>
)}
</div>
</main>
)
return <LocalChatHistory />
}

View File

@@ -0,0 +1,37 @@
import type { FC } from 'react'
import { ConversationItem } from './ConversationItem'
import type { HistoryConversation } from './types'
interface ConversationGroupProps {
label: string
conversations: HistoryConversation[]
onDelete?: (id: string) => void
activeConversationId: string
}
export const ConversationGroup: FC<ConversationGroupProps> = ({
label,
conversations,
onDelete,
activeConversationId,
}) => {
if (conversations.length === 0) return null
return (
<div className="mb-4">
<h3 className="mb-2 px-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">
{label}
</h3>
<div className="space-y-1">
{conversations.map((conversation) => (
<ConversationItem
key={conversation.id}
conversation={conversation}
onDelete={onDelete}
isActive={conversation.id === activeConversationId}
/>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,99 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { MessageSquare, Trash2 } from 'lucide-react'
import { type FC, useState } from 'react'
import { Link } from 'react-router'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import type { HistoryConversation } from './types'
dayjs.extend(relativeTime)
interface ConversationItemProps {
conversation: HistoryConversation
onDelete?: (id: string) => void
isActive: boolean
}
export const ConversationItem: FC<ConversationItemProps> = ({
conversation,
onDelete,
isActive,
}) => {
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const label = conversation.lastUserMessage
const relativeTimeAgo = dayjs(conversation.lastMessagedAt).fromNow()
const handleDeleteClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setShowDeleteDialog(true)
}
const handleConfirmDelete = () => {
onDelete?.(conversation.id)
setShowDeleteDialog(false)
}
return (
<>
<Link
to={`/?conversationId=${conversation.id}`}
className={`group flex w-full items-start gap-3 rounded-lg px-3 py-2.5 transition-colors hover:bg-muted/50 ${
isActive ? 'bg-muted/70' : ''
}`}
>
<div
className={`mt-0.5 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground'}`}
>
<MessageSquare className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<p className="truncate font-medium text-foreground text-sm">
{label}
</p>
<p className="text-muted-foreground text-xs">{relativeTimeAgo}</p>
</div>
{onDelete && (
<button
type="button"
onClick={handleDeleteClick}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
title="Delete conversation"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</Link>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete conversation?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your
conversation and all its messages.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,112 @@
import { Loader2, MessageSquare } from 'lucide-react'
import { type FC, useEffect, useRef } from 'react'
import { Link } from 'react-router'
import { ConversationGroup } from './ConversationGroup'
import type { GroupedConversations } from './types'
import { TIME_GROUP_LABELS } from './utils'
interface ConversationListProps {
groupedConversations: GroupedConversations
activeConversationId: string
onDelete?: (id: string) => void
hasNextPage?: boolean
isFetchingNextPage?: boolean
onLoadMore?: () => void
}
export const ConversationList: FC<ConversationListProps> = ({
groupedConversations,
activeConversationId,
onDelete,
hasNextPage,
isFetchingNextPage,
onLoadMore,
}) => {
const loadMoreRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!hasNextPage || !onLoadMore) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isFetchingNextPage) {
onLoadMore()
}
},
{ threshold: 0.1 },
)
const currentRef = loadMoreRef.current
if (currentRef) {
observer.observe(currentRef)
}
return () => {
if (currentRef) {
observer.unobserve(currentRef)
}
}
}, [hasNextPage, isFetchingNextPage, onLoadMore])
const hasConversations =
groupedConversations.today.length > 0 ||
groupedConversations.thisWeek.length > 0 ||
groupedConversations.thisMonth.length > 0 ||
groupedConversations.older.length > 0
return (
<main className="mt-4 flex h-full flex-1 flex-col space-y-4 overflow-y-auto">
<div className="w-full p-3">
{!hasConversations ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/50" />
<p className="text-muted-foreground text-sm">
No conversations yet
</p>
<Link to="/" className="mt-2 text-primary text-sm hover:underline">
Start a new chat
</Link>
</div>
) : (
<>
<ConversationGroup
label={TIME_GROUP_LABELS.today}
conversations={groupedConversations.today}
onDelete={onDelete}
activeConversationId={activeConversationId}
/>
<ConversationGroup
label={TIME_GROUP_LABELS.thisWeek}
conversations={groupedConversations.thisWeek}
onDelete={onDelete}
activeConversationId={activeConversationId}
/>
<ConversationGroup
label={TIME_GROUP_LABELS.thisMonth}
conversations={groupedConversations.thisMonth}
onDelete={onDelete}
activeConversationId={activeConversationId}
/>
<ConversationGroup
label={TIME_GROUP_LABELS.older}
conversations={groupedConversations.older}
onDelete={onDelete}
activeConversationId={activeConversationId}
/>
{hasNextPage && (
<div
ref={loadMoreRef}
className="flex items-center justify-center py-4"
>
{isFetchingNextPage && (
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
)}
</div>
)}
</>
)}
</div>
</main>
)
}

View File

@@ -0,0 +1,14 @@
export interface HistoryConversation {
id: string
lastMessagedAt: number
lastUserMessage: string
}
export type TimeGroup = 'today' | 'thisWeek' | 'thisMonth' | 'older'
export interface GroupedConversations {
today: HistoryConversation[]
thisWeek: HistoryConversation[]
thisMonth: HistoryConversation[]
older: HistoryConversation[]
}

View File

@@ -0,0 +1,54 @@
import type { UIMessage } from 'ai'
import dayjs from 'dayjs'
import type {
GroupedConversations,
HistoryConversation,
TimeGroup,
} from './types'
export const TIME_GROUP_LABELS: Record<TimeGroup, string> = {
today: 'Today',
thisWeek: 'This Week',
thisMonth: 'This Month',
older: 'Older',
}
export const getTimeGroup = (timestamp: number): TimeGroup => {
const date = dayjs(timestamp)
const now = dayjs()
if (date.isSame(now, 'day')) return 'today'
if (date.isSame(now, 'week')) return 'thisWeek'
if (date.isSame(now, 'month')) return 'thisMonth'
return 'older'
}
export const extractLastUserMessage = (messages: UIMessage[]): string => {
const userMessages = messages.filter((m) => m.role === 'user')
const lastUserMessage = userMessages[userMessages.length - 1]
if (!lastUserMessage) return 'New conversation'
const textParts = lastUserMessage.parts.filter((p) => p.type === 'text')
const text = textParts.map((p) => (p as { text: string }).text).join(' ')
return text || 'New conversation'
}
export const groupConversations = (
conversations: HistoryConversation[],
): GroupedConversations => {
const groups: GroupedConversations = {
today: [],
thisWeek: [],
thisMonth: [],
older: [],
}
for (const conversation of conversations) {
const group = getTimeGroup(conversation.lastMessagedAt)
groups[group].push(conversation)
}
return groups
}

View File

@@ -0,0 +1,34 @@
import { graphql } from '@/generated/graphql/gql'
export const GetConversationsForHistoryDocument = graphql(`
query GetConversationsForHistory($profileId: String!, $first: Int = 50, $after: Cursor) {
conversations(
condition: { profileId: $profileId }
first: $first
after: $after
orderBy: LAST_MESSAGED_AT_DESC
) {
nodes {
rowId
lastMessagedAt
conversationMessages(last: 5, orderBy: ORDER_INDEX_ASC) {
nodes {
message
}
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
`)
export const DeleteConversationDocument = graphql(`
mutation DeleteConversation($rowId: String!) {
deleteConversation(input: { rowId: $rowId }) {
deletedConversationId
}
}
`)

View File

@@ -0,0 +1,34 @@
import type { FC } from 'react'
import { useMemo } from 'react'
import { useConversations } from '@/lib/conversations/conversationStorage'
import { useChatSessionContext } from '../../layout/ChatSessionContext'
import { ConversationList } from '../components/ConversationList'
import type { HistoryConversation } from '../components/types'
import { extractLastUserMessage, groupConversations } from '../components/utils'
export const LocalChatHistory: FC = () => {
const { conversations: localConversations, removeConversation } =
useConversations()
const { conversationId: activeConversationId } = useChatSessionContext()
const conversations = useMemo<HistoryConversation[]>(() => {
return localConversations.map((conv) => ({
id: conv.id,
lastMessagedAt: conv.lastMessagedAt,
lastUserMessage: extractLastUserMessage(conv.messages),
}))
}, [localConversations])
const groupedConversations = useMemo(
() => groupConversations(conversations),
[conversations],
)
return (
<ConversationList
groupedConversations={groupedConversations}
activeConversationId={activeConversationId}
onDelete={removeConversation}
/>
)
}

View File

@@ -0,0 +1,89 @@
import { graphql } from '@/generated/graphql/gql'
export const CreateConversationWithMessageDocument = graphql(`
mutation CreateConversationWithMessage(
$conversationId: String!
$profileId: String!
$message: JSON!
) {
createConversation(
input: {
conversation: {
rowId: $conversationId
profileId: $profileId
lastMessagedAt: "now()"
}
}
) {
conversation {
rowId
}
}
createConversationMessage(
input: {
conversationMessage: {
rowId: $conversationId
conversationId: $conversationId
orderIndex: 0
message: $message
}
}
) {
conversationMessage {
rowId
orderIndex
}
}
}
`)
export const AppendConversationMessageDocument = graphql(`
mutation AppendConversationMessage(
$messageId: String!
$conversationId: String!
$orderIndex: Int!
$message: JSON!
) {
createConversationMessage(
input: {
conversationMessage: {
rowId: $messageId
conversationId: $conversationId
orderIndex: $orderIndex
message: $message
}
}
) {
conversationMessage {
rowId
orderIndex
}
}
}
`)
export const UpdateConversationLastMessagedAtDocument = graphql(`
mutation UpdateConversationLastMessagedAt($conversationId: String!) {
updateConversation(
input: { rowId: $conversationId, patch: { lastMessagedAt: "now()" } }
) {
conversation {
rowId
lastMessagedAt
}
}
}
`)
export const GetConversationWithMessagesDocument = graphql(`
query GetConversationWithMessages($conversationId: String!) {
conversation(rowId: $conversationId) {
rowId
conversationMessages(first: 100, orderBy: ORDER_INDEX_ASC) {
nodes {
message
}
}
}
}
`)

View File

@@ -19,13 +19,16 @@ import {
useConversations,
} from '@/lib/conversations/conversationStorage'
import { formatConversationHistory } from '@/lib/conversations/formatConversationHistory'
import { execute } from '@/lib/graphql/execute'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { track } from '@/lib/metrics/track'
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
import { selectedWorkspaceStorage } from '@/lib/workspace/workspace-storage'
import type { ChatMode } from './chatTypes'
import { GetConversationWithMessagesDocument } from './graphql/chatSessionDocument'
import { useChatRefs } from './useChatRefs'
import { useNotifyActiveTab } from './useNotifyActiveTab'
import { useRemoteConversationSave } from './useRemoteConversationSave'
const getLastMessageText = (messages: UIMessage[]) => {
const lastMessage = messages[messages.length - 1]
@@ -76,7 +79,13 @@ export const useChatSession = () => {
error: agentUrlError,
} = useAgentServerUrl()
const { saveConversation } = useConversations()
const { saveConversation: saveLocalConversation } = useConversations()
const {
isLoggedIn,
saveConversation: saveRemoteConversation,
resetConversation: resetRemoteConversation,
markMessagesAsSaved,
} = useRemoteConversationSave()
const [searchParams, setSearchParams] = useSearchParams()
const conversationIdParam = searchParams.get('conversationId')
@@ -294,31 +303,63 @@ export const useChatSession = () => {
if (!conversationIdParam) return
const restoreConversation = async () => {
const conversations = await conversationStorage.getValue()
const conversation = conversations?.find(
(c) => c.id === conversationIdParam,
)
if (isLoggedIn) {
const result = await execute(GetConversationWithMessagesDocument, {
conversationId: conversationIdParam,
})
if (conversation) {
setConversationId(
conversation.id as ReturnType<typeof crypto.randomUUID>,
if (result.conversation) {
const messages = result.conversation.conversationMessages.nodes
.filter((node): node is NonNullable<typeof node> => node !== null)
.map((node) => node.message as UIMessage)
setConversationId(
conversationIdParam as ReturnType<typeof crypto.randomUUID>,
)
setMessages(messages)
markMessagesAsSaved(conversationIdParam, messages)
}
} else {
const conversations = await conversationStorage.getValue()
const conversation = conversations?.find(
(c) => c.id === conversationIdParam,
)
setMessages(conversation.messages)
if (conversation) {
setConversationId(
conversation.id as ReturnType<typeof crypto.randomUUID>,
)
setMessages(conversation.messages)
}
}
setSearchParams({}, { replace: true })
}
restoreConversation()
}, [conversationIdParam, setMessages, setSearchParams])
}, [
conversationIdParam,
setMessages,
setSearchParams,
isLoggedIn,
markMessagesAsSaved,
])
// biome-ignore lint/correctness/useExhaustiveDependencies: only need to run when messages change
useEffect(() => {
messagesRef.current = messages
if (messages.length > 0) {
saveConversation(conversationIdRef.current, messages)
// Local storage: save on every change (including during streaming)
// Remote: only save when not streaming to avoid partial message saves
if (isLoggedIn) {
if (status !== 'streaming') {
saveRemoteConversation(conversationIdRef.current, messages)
}
} else {
saveLocalConversation(conversationIdRef.current, messages)
}
}
}, [messages])
}, [messages, isLoggedIn, status])
const sendMessage = (params: { text: string; action?: ChatAction }) => {
track(MESSAGE_SENT_EVENT, {
@@ -373,6 +414,7 @@ export const useChatSession = () => {
setTextToAction(new Map())
setLiked({})
setDisliked({})
resetRemoteConversation()
}
return {

View File

@@ -0,0 +1,115 @@
import type { UIMessage } from 'ai'
import { useCallback, useRef } from 'react'
import { useSessionInfo } from '@/lib/auth/sessionStorage'
import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
import { execute } from '@/lib/graphql/execute'
import { sentry } from '@/lib/sentry/sentry'
import {
AppendConversationMessageDocument,
CreateConversationWithMessageDocument,
UpdateConversationLastMessagedAtDocument,
} from './graphql/chatSessionDocument'
export function useRemoteConversationSave() {
const { sessionInfo } = useSessionInfo()
const userId = sessionInfo.user?.id
const profileIdRef = useRef<string | null>(null)
const createdConversationsRef = useRef<Set<string>>(new Set())
const savedMessageIdsRef = useRef<Set<string>>(new Set())
const getProfileId = async (): Promise<string | null> => {
if (profileIdRef.current) return profileIdRef.current
if (!userId) return null
const result = await execute(GetProfileIdByUserIdDocument, { userId })
const profileId = result.profileByUserId?.rowId ?? null
profileIdRef.current = profileId
return profileId
}
const saveConversation = async (
conversationId: string,
messages: UIMessage[],
) => {
if (!userId || messages.length === 0) return
const profileId = await getProfileId()
if (!profileId) return
const isNewConversation =
!createdConversationsRef.current.has(conversationId)
const newMessages = messages.filter(
(msg) => !savedMessageIdsRef.current.has(msg.id),
)
if (newMessages.length === 0) return
try {
if (isNewConversation && newMessages.length > 0) {
const firstMessage = newMessages[0]
await execute(CreateConversationWithMessageDocument, {
conversationId,
profileId,
message: firstMessage,
})
createdConversationsRef.current.add(conversationId)
savedMessageIdsRef.current.add(firstMessage.id)
for (let i = 1; i < newMessages.length; i++) {
const msg = newMessages[i]
const orderIndex = messages.findIndex((m) => m.id === msg.id)
await execute(AppendConversationMessageDocument, {
messageId: msg.id,
conversationId,
orderIndex,
message: msg,
})
savedMessageIdsRef.current.add(msg.id)
}
} else {
for (const msg of newMessages) {
const orderIndex = messages.findIndex((m) => m.id === msg.id)
await execute(AppendConversationMessageDocument, {
messageId: msg.id,
conversationId,
orderIndex,
message: msg,
})
savedMessageIdsRef.current.add(msg.id)
}
await execute(UpdateConversationLastMessagedAtDocument, {
conversationId,
})
}
} catch (error) {
sentry.captureException(error, {
extra: {
message: 'Failed to save conversation to remote',
},
})
}
}
const resetConversation = () => {
savedMessageIdsRef.current = new Set()
}
const markMessagesAsSaved = useCallback(
(conversationId: string, messages: UIMessage[]) => {
createdConversationsRef.current.add(conversationId)
for (const msg of messages) {
savedMessageIdsRef.current.add(msg.id)
}
},
[],
)
return {
isLoggedIn: !!userId,
saveConversation,
resetConversation,
markMessagesAsSaved,
}
}

View File

@@ -4,6 +4,8 @@ import '@/styles/global.css'
import { ThemeProvider } from '@/components/theme-provider.tsx'
import { Toaster } from '@/components/ui/sonner'
import { AnalyticsProvider } from '@/lib/analytics/AnalyticsProvider'
import { AuthProvider } from '@/lib/auth/AuthProvider'
import { QueryProvider } from '@/lib/graphql/QueryProvider'
import { sentryRootErrorHandler } from '@/lib/sentry/sentryRootErrorHandler'
import { App } from './App'
@@ -12,12 +14,16 @@ const $root = document.getElementById('root')
if ($root) {
ReactDOM.createRoot($root, sentryRootErrorHandler).render(
<React.StrictMode>
<AnalyticsProvider>
<ThemeProvider>
<App />
<Toaster />
</ThemeProvider>
</AnalyticsProvider>
<AuthProvider>
<QueryProvider>
<AnalyticsProvider>
<ThemeProvider>
<App />
<Toaster />
</ThemeProvider>
</AnalyticsProvider>
</QueryProvider>
</AuthProvider>
</React.StrictMode>,
)
}

View File

@@ -0,0 +1,17 @@
import type { FC, PropsWithChildren } from 'react'
import { useSession } from './auth-client'
import { useSessionInfo } from './sessionStorage'
export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
const { data } = useSession()
const { updateSessionInfo } = useSessionInfo()
useEffect(() => {
updateSessionInfo({
session: data?.session,
user: data?.user,
})
}, [data])
return <>{children}</>
}

View File

@@ -0,0 +1,8 @@
import { magicLinkClient } from 'better-auth/client/plugins'
import { createAuthClient } from 'better-auth/react'
import { env } from '../env'
export const { signIn, signUp, signOut, useSession } = createAuthClient({
baseURL: env.VITE_PUBLIC_BROWSEROS_API,
plugins: [magicLinkClient()],
})

View File

@@ -0,0 +1,33 @@
import { storage } from '@wxt-dev/storage'
import type { Session, User } from 'better-auth/types'
import { useEffect, useState } from 'react'
interface SessionInfo {
session?: Session
user?: User
}
export const sessionStorage = storage.defineItem<SessionInfo>(
'local:sessionInfo',
{
fallback: {},
},
)
export const useSessionInfo = () => {
const [sessionInfo, setSessionInfo] = useState<SessionInfo>({})
useEffect(() => {
sessionStorage.getValue().then(setSessionInfo)
const unwatch = sessionStorage.watch((newValue) => {
setSessionInfo(newValue ?? {})
})
return unwatch
}, [])
const updateSessionInfo = async (info: SessionInfo) => {
await sessionStorage.setValue(info)
}
return { sessionInfo, updateSessionInfo }
}

View File

@@ -1,6 +1,8 @@
import { storage } from '@wxt-dev/storage'
import type { UIMessage } from 'ai'
import { useEffect, useState } from 'react'
import { useSessionInfo } from '../auth/sessionStorage'
import { uploadConversationsToGraphql } from './uploadConversationsToGraphql'
const MAX_CONVERSATIONS = 50
@@ -20,6 +22,15 @@ export const conversationStorage = storage.defineItem<Conversation[]>(
export function useConversations() {
const [conversations, setConversations] = useState<Conversation[]>([])
const { sessionInfo } = useSessionInfo()
useEffect(() => {
// user is logged in, could sync conversations from server here
if (sessionInfo.user?.id && conversations.length > 0) {
uploadConversationsToGraphql(conversations)
}
}, [sessionInfo.user?.id, conversations])
useEffect(() => {
conversationStorage.getValue().then(setConversations)
const unwatch = conversationStorage.watch((newValue) => {

View File

@@ -0,0 +1,50 @@
import { graphql } from '@/generated/graphql/gql'
export const GetProfileIdByUserIdDocument = graphql(`
query GetProfileIdByUserId($userId: String!) {
profileByUserId(userId: $userId) {
rowId
}
}
`)
export const CreateConversationForUploadDocument = graphql(`
mutation CreateConversationForUpload($input: CreateConversationInput!) {
createConversation(input: $input) {
conversation {
id
rowId
profileId
lastMessagedAt
createdAt
}
}
}
`)
export const BulkCreateConversationMessagesDocument = graphql(`
mutation BulkCreateConversationMessages($input: BulkCreateConversationMessagesInput!) {
bulkCreateConversationMessages(input: $input) {
result {
id
rowId
conversationId
orderIndex
}
}
}
`)
export const ConversationExistsDocument = graphql(`
query ConversationExists($pConversationId: String) {
conversationExists(pConversationId: $pConversationId)
}
`)
export const GetUploadedMessageCountDocument = graphql(`
query GetUploadedMessageCount($conversationId: String!) {
conversationMessages(condition: { conversationId: $conversationId }, first: 0) {
totalCount
}
}
`)

View File

@@ -0,0 +1,94 @@
import { execute } from '@/lib/graphql/execute'
import { sessionStorage } from '../auth/sessionStorage'
import { sentry } from '../sentry/sentry'
import { type Conversation, conversationStorage } from './conversationStorage'
import {
BulkCreateConversationMessagesDocument,
ConversationExistsDocument,
CreateConversationForUploadDocument,
GetProfileIdByUserIdDocument,
GetUploadedMessageCountDocument,
} from './graphql/uploadConversationDocument'
export async function uploadConversationsToGraphql(
conversations: Conversation[],
) {
if (conversations.length === 0) return
const sessionInfo = await sessionStorage.getValue()
const userId = sessionInfo?.user?.id
if (!userId) return
const profileResult = await execute(GetProfileIdByUserIdDocument, { userId })
const profileId = profileResult.profileByUserId?.rowId
if (!profileId) return
const uploadedIds: string[] = []
for (const conversation of conversations) {
try {
const existsResult = await execute(ConversationExistsDocument, {
pConversationId: conversation.id,
})
let uploadedCount = 0
if (existsResult.conversationExists) {
const countResult = await execute(GetUploadedMessageCountDocument, {
conversationId: conversation.id,
})
uploadedCount = countResult.conversationMessages?.totalCount ?? 0
if (uploadedCount >= conversation.messages.length) {
uploadedIds.push(conversation.id)
continue
}
} else {
await execute(CreateConversationForUploadDocument, {
input: {
conversation: {
rowId: conversation.id,
profileId,
lastMessagedAt: new Date(
conversation.lastMessagedAt,
).toISOString(),
createdAt: new Date(conversation.lastMessagedAt).toISOString(),
},
},
})
}
const remainingMessages = conversation.messages.slice(uploadedCount)
if (remainingMessages.length > 0) {
const BATCH_SIZE = 50
for (let i = 0; i < remainingMessages.length; i += BATCH_SIZE) {
const batch = remainingMessages.slice(i, i + BATCH_SIZE)
await execute(BulkCreateConversationMessagesDocument, {
input: {
pConversationId: conversation.id,
pMessages: batch.map((msg, batchIndex) => ({
orderIndex: uploadedCount + i + batchIndex,
message: msg,
})),
},
})
}
}
uploadedIds.push(conversation.id)
} catch (error) {
sentry.captureException(error, {
extra: {
conversationId: conversation.id,
messageCount: conversation.messages.length,
},
})
}
}
if (uploadedIds.length > 0) {
const remaining = conversations.filter((c) => !uploadedIds.includes(c.id))
conversationStorage.setValue(remaining)
}
}

View File

@@ -5,6 +5,7 @@ const EnvSchema = z.object({
VITE_PUBLIC_POSTHOG_KEY: z.string().optional(),
VITE_PUBLIC_POSTHOG_HOST: z.string().optional(),
VITE_PUBLIC_SENTRY_DSN: z.string().optional(),
VITE_PUBLIC_BROWSEROS_API: z.string().optional(),
PROD: z.boolean(),
})

View File

@@ -0,0 +1,28 @@
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
import { QueryClient } from '@tanstack/react-query'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import localforage from 'localforage'
import type { FC, ReactNode } from 'react'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24 hours
},
},
})
const asyncStoragePersister = createAsyncStoragePersister({
storage: localforage,
})
export const QueryProvider: FC<{ children: ReactNode }> = ({ children }) => {
return (
<PersistQueryClientProvider
persistOptions={{ persister: asyncStoragePersister }}
client={queryClient}
>
{children}
</PersistQueryClientProvider>
)
}

View File

@@ -0,0 +1,39 @@
import type { TypedDocumentString } from '@/generated/graphql/graphql'
import { env } from '../env'
export async function execute<TResult, TVariables = undefined>(
query: TypedDocumentString<TResult, TVariables>,
variables?: TVariables,
): Promise<TResult> {
const headers = new Headers()
headers.set('Content-Type', 'application/json')
headers.set('Accept', 'application/graphql-response+json')
const response = await fetch(`${env.VITE_PUBLIC_BROWSEROS_API}/graphql`, {
method: 'POST',
headers,
body: JSON.stringify({
query,
variables,
}),
credentials: 'include',
})
if (!response.ok) {
throw new Error(`Network response was not ok: ${response.statusText}`)
}
const body: { data?: TResult; errors?: { message: string }[] } =
await response.json()
if (body.errors && body.errors.length > 0) {
const messages = body.errors.map((e) => e.message)
throw new Error(`GraphQL error: ${messages.join(', ')}`)
}
if (!body.data) {
throw new Error('GraphQL response is missing data.')
}
return body.data
}

View File

@@ -0,0 +1,24 @@
import { parse } from 'graphql'
import type { TypedDocumentString } from '@/generated/graphql/graphql'
const getOperationName = <T, V>(
doc: TypedDocumentString<T, V>,
): string | null => {
// Fallback to parsing
const parsed = parse(doc.toString())
const operation = parsed.definitions.find(
(def) => def.kind === 'OperationDefinition',
)
return operation?.name ? operation.name.value : null
}
export const getQueryKeyFromDocument = <
TResult,
TVariables extends Record<string, any> | undefined = undefined,
>(
doc: TypedDocumentString<TResult, TVariables>,
) => {
const queryName = getOperationName(doc)
return queryName
}

View File

@@ -0,0 +1,48 @@
import {
type InfiniteData,
type UseInfiniteQueryOptions,
type UseInfiniteQueryResult,
useInfiniteQuery,
} from '@tanstack/react-query'
import type { TypedDocumentString } from '@/generated/graphql/graphql'
import { execute } from './execute'
import { getQueryKeyFromDocument } from './getQueryKeyFromDocument'
/**
* @public
*/
export const useGraphqlInfiniteQuery = <
TQueryFnData,
TVariables extends Record<string, any> | undefined = undefined,
TPageParam extends string | undefined | number = undefined,
>(
query: TypedDocumentString<TQueryFnData, TVariables>,
getVariables: (pageParam: TPageParam) => TVariables,
options: Omit<
UseInfiniteQueryOptions<
TQueryFnData, // TQueryFnData
Error, // TError
InfiniteData<TQueryFnData, TPageParam> // TData
>,
'queryKey' | 'queryFn'
> & {
/** Required by React Query v5 */
initialPageParam: TPageParam
},
): UseInfiniteQueryResult<InfiniteData<TQueryFnData, TPageParam>, Error> => {
const queryKey = [getQueryKeyFromDocument(query)] as const
return useInfiniteQuery<
TQueryFnData, // TQueryFnData
Error, // TError
InfiniteData<TQueryFnData, TPageParam> // TData (pages + pageParams)
>({
queryKey,
queryFn: async ({ pageParam }) =>
execute<TQueryFnData, TVariables>(
query,
getVariables(pageParam as TPageParam),
),
...options,
})
}

View File

@@ -0,0 +1,23 @@
import {
type UseMutationOptions,
type UseMutationResult,
useMutation,
} from '@tanstack/react-query'
import type { TypedDocumentString } from '@/generated/graphql/graphql'
import { execute } from './execute'
/**
* @public
*/
export function useGraphqlMutation<TResult = unknown, TVariables = object>(
document: TypedDocumentString<TResult, TVariables>,
options?: Omit<
UseMutationOptions<TResult, unknown, TVariables>,
'mutationFn' | 'mutationKey'
>,
): UseMutationResult<TResult, unknown, TVariables> {
return useMutation<TResult, unknown, TVariables>({
mutationFn: (variables: TVariables) => execute(document, variables),
...(options ?? {}),
})
}

View File

@@ -0,0 +1,24 @@
import { type UseQueryOptions, useQuery } from '@tanstack/react-query'
import type { TypedDocumentString } from '@/generated/graphql/graphql'
import { execute } from './execute'
import { getQueryKeyFromDocument } from './getQueryKeyFromDocument'
/**
* @public
*/
export const useGraphqlQuery = <
TResult,
TVariables extends Record<string, any> | undefined = undefined,
>(
query: TypedDocumentString<TResult, TVariables>,
variables?: TVariables,
options?: Omit<UseQueryOptions<TResult, Error>, 'queryKey' | 'queryFn'>,
) => {
const queryKey = getQueryKeyFromDocument(query)
return useQuery<TResult, Error>({
queryKey: variables ? [queryKey, variables] : [queryKey],
queryFn: () => execute<TResult, TVariables>(query, variables),
...(options ?? {}),
})
}

View File

@@ -0,0 +1,40 @@
import { graphql } from '@/generated/graphql/gql'
export const CreateLlmProviderForUploadDocument = graphql(`
mutation CreateLlmProviderForUpload($input: CreateLlmProviderInput!) {
createLlmProvider(input: $input) {
llmProvider {
rowId
}
}
}
`)
export const UpdateLlmProviderForUploadDocument = graphql(`
mutation UpdateLlmProviderForUpload($input: UpdateLlmProviderInput!) {
updateLlmProvider(input: $input) {
llmProvider {
rowId
}
}
}
`)
export const GetLlmProvidersByProfileIdDocument = graphql(`
query GetLlmProvidersByProfileId($profileId: String!) {
llmProviders(condition: { profileId: $profileId }) {
nodes {
rowId
type
name
baseUrl
modelId
supportsImages
contextWindow
temperature
resourceName
region
}
}
}
`)

View File

@@ -1,7 +1,9 @@
import { storage } from '@wxt-dev/storage'
import { sessionStorage } from '@/lib/auth/sessionStorage'
import { getBrowserOSAdapter } from '@/lib/browseros/adapter'
import { BROWSEROS_PREFS } from '@/lib/browseros/prefs'
import type { LlmProviderConfig, LlmProvidersBackup } from './types'
import { uploadLlmProvidersToGraphql } from './uploadLlmProvidersToGraphql'
/** Default provider ID constant */
export const DEFAULT_PROVIDER_ID = 'browseros'
@@ -35,6 +37,35 @@ export function setupLlmProvidersBackupToBrowserOS(): () => void {
return unsubscribe
}
export async function syncLlmProviders(): Promise<void> {
const providers = await providersStorage.getValue()
if (!providers || providers.length === 0) return
const session = await sessionStorage.getValue()
const userId = session?.user?.id
if (!userId) return
await uploadLlmProvidersToGraphql(providers, userId)
}
/**
* Setup one-way sync of LLM providers to GraphQL backend
* Watches for storage changes and uploads non-sensitive provider data
* @public
*/
export function setupLlmProvidersSyncToBackend(): () => void {
syncLlmProviders().catch(() => {})
const unsubscribe = providersStorage.watch(async () => {
try {
await syncLlmProviders()
} catch {
// Sync failed silently - will retry on next storage change
}
})
return unsubscribe
}
/** Load providers from storage */
export async function loadProviders(): Promise<LlmProviderConfig[]> {
const providers = await providersStorage.getValue()

View File

@@ -0,0 +1,121 @@
import { isEqual, omit } from 'es-toolkit'
import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
import { execute } from '@/lib/graphql/execute'
import { sentry } from '@/lib/sentry/sentry'
import {
CreateLlmProviderForUploadDocument,
GetLlmProvidersByProfileIdDocument,
UpdateLlmProviderForUploadDocument,
} from './graphql/uploadLlmProviderDocument'
import type { LlmProviderConfig } from './types'
type RemoteProvider = {
rowId: string
type: string
name: string
baseUrl: string | null
modelId: string
supportsImages: boolean
contextWindow: number | null
temperature: number | null
resourceName: string | null
region: string | null
}
const IGNORED_FIELDS = [
'id',
'createdAt',
'updatedAt',
'apiKey',
'accessKeyId',
'secretAccessKey',
'sessionToken',
] as const
function toComparable(provider: LlmProviderConfig) {
const data = omit(provider, IGNORED_FIELDS)
return {
...data,
baseUrl: data.baseUrl ?? null,
resourceName: data.resourceName ?? null,
region: data.region ?? null,
}
}
export async function uploadLlmProvidersToGraphql(
providers: LlmProviderConfig[],
userId: string,
) {
if (providers.length === 0) return
const profileResult = await execute(GetProfileIdByUserIdDocument, { userId })
const profileId = profileResult.profileByUserId?.rowId
if (!profileId) return
const remoteResult = await execute(GetLlmProvidersByProfileIdDocument, {
profileId,
})
const remoteProviders = new Map<string, RemoteProvider>()
for (const node of remoteResult.llmProviders?.nodes ?? []) {
if (node) {
remoteProviders.set(node.rowId, node as RemoteProvider)
}
}
for (const provider of providers) {
if (provider.type === 'browseros') continue
try {
const remote = remoteProviders.get(provider.id)
if (remote) {
if (isEqual(toComparable(provider), omit(remote, ['rowId']))) continue
await execute(UpdateLlmProviderForUploadDocument, {
input: {
rowId: provider.id,
patch: {
type: provider.type,
name: provider.name,
baseUrl: provider.baseUrl ?? null,
modelId: provider.modelId,
supportsImages: provider.supportsImages,
contextWindow: provider.contextWindow,
temperature: provider.temperature,
resourceName: provider.resourceName ?? null,
region: provider.region ?? null,
updatedAt: new Date(provider.updatedAt).toISOString(),
},
},
})
} else {
await execute(CreateLlmProviderForUploadDocument, {
input: {
llmProvider: {
rowId: provider.id,
profileId,
type: provider.type,
name: provider.name,
baseUrl: provider.baseUrl ?? null,
modelId: provider.modelId,
supportsImages: provider.supportsImages,
contextWindow: provider.contextWindow,
temperature: provider.temperature,
resourceName: provider.resourceName ?? null,
region: provider.region ?? null,
createdAt: new Date(provider.createdAt).toISOString(),
updatedAt: new Date(provider.updatedAt).toISOString(),
},
},
})
}
} catch (error) {
sentry.captureException(error, {
extra: {
providerId: provider.id,
providerName: provider.name,
},
})
}
}
}

View File

@@ -0,0 +1,48 @@
import { graphql } from '@/generated/graphql/gql'
export const GetScheduledJobsByProfileIdDocument = graphql(`
query GetScheduledJobsByProfileId($profileId: String!) {
scheduledJobs(condition: { profileId: $profileId }, first: 100) {
nodes {
rowId
name
query
scheduleType
scheduleTime
scheduleInterval
enabled
createdAt
updatedAt
lastRunAt
}
}
}
`)
export const CreateScheduledJobDocument = graphql(`
mutation CreateScheduledJob($input: CreateScheduledJobInput!) {
createScheduledJob(input: $input) {
scheduledJob {
rowId
}
}
}
`)
export const UpdateScheduledJobDocument = graphql(`
mutation UpdateScheduledJob($input: UpdateScheduledJobInput!) {
updateScheduledJob(input: $input) {
scheduledJob {
rowId
}
}
}
`)
export const DeleteScheduledJobDocument = graphql(`
mutation DeleteScheduledJob($rowId: String!) {
deleteScheduledJob(input: { rowId: $rowId }) {
deletedScheduledJobId
}
}
`)

View File

@@ -1,8 +1,10 @@
import { storage } from '@wxt-dev/storage'
import { useEffect, useState } from 'react'
import { sessionStorage } from '@/lib/auth/sessionStorage'
import { sendScheduleMessage } from '@/lib/messaging/schedules/scheduleMessages'
import { createAlarmFromJob } from './createAlarmFromJob'
import type { ScheduledJob, ScheduledJobRun } from './scheduleTypes'
import { syncSchedulesToBackend } from './syncSchedulesToBackend'
const getAlarmName = (jobId: string) => `scheduled-job-${jobId}`
@@ -31,10 +33,14 @@ export function useScheduledJobs() {
return unwatch
}, [])
const addJob = async (job: Omit<ScheduledJob, 'id' | 'createdAt'>) => {
const addJob = async (
job: Omit<ScheduledJob, 'id' | 'createdAt' | 'updatedAt'>,
) => {
const now = new Date().toISOString()
const newJob: ScheduledJob = {
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
createdAt: now,
updatedAt: now,
...job,
}
const current = (await scheduledJobStorage.getValue()) ?? []
@@ -62,8 +68,9 @@ export function useScheduledJobs() {
const job = current.find((j) => j.id === id)
if (!job) return
const updatedAt = new Date().toISOString()
await scheduledJobStorage.setValue(
current.map((j) => (j.id === id ? { ...j, enabled } : j)),
current.map((j) => (j.id === id ? { ...j, enabled, updatedAt } : j)),
)
if (enabled) {
@@ -75,7 +82,7 @@ export function useScheduledJobs() {
const editJob = async (
id: string,
updates: Omit<ScheduledJob, 'id' | 'createdAt'>,
updates: Omit<ScheduledJob, 'id' | 'createdAt' | 'updatedAt'>,
) => {
const current = (await scheduledJobStorage.getValue()) ?? []
const existingJob = current.find((j) => j.id === id)
@@ -84,6 +91,7 @@ export function useScheduledJobs() {
const updatedJob: ScheduledJob = {
id,
createdAt: existingJob.createdAt,
updatedAt: new Date().toISOString(),
...updates,
}
await scheduledJobStorage.setValue(
@@ -136,3 +144,28 @@ export function useScheduledJobRuns() {
return { jobRuns, addJobRun, removeJobRun, editJobRun }
}
export async function syncScheduledJobs(): Promise<void> {
const jobs = await scheduledJobStorage.getValue()
if (!jobs) return
const session = await sessionStorage.getValue()
const userId = session?.user?.id
if (!userId) return
await syncSchedulesToBackend(jobs, userId)
}
export function setupScheduledJobsSyncToBackend(): () => void {
syncScheduledJobs().catch(() => {})
const unsubscribe = scheduledJobStorage.watch(async () => {
try {
await syncScheduledJobs()
} catch {
// Sync failed silently - will retry on next storage change
}
})
return unsubscribe
}

View File

@@ -7,6 +7,7 @@ export interface ScheduledJob {
scheduleInterval?: number
enabled: boolean
createdAt: string
updatedAt: string
lastRunAt?: string
}

View File

@@ -0,0 +1,202 @@
import { isEqual, omit } from 'es-toolkit'
import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
import { execute } from '@/lib/graphql/execute'
import { sentry } from '@/lib/sentry/sentry'
import { createAlarmFromJob } from './createAlarmFromJob'
import {
CreateScheduledJobDocument,
GetScheduledJobsByProfileIdDocument,
UpdateScheduledJobDocument,
} from './graphql/syncSchedulesDocument'
import { scheduledJobStorage } from './scheduleStorage'
import type { ScheduledJob } from './scheduleTypes'
type RemoteScheduledJob = {
rowId: string
name: string
query: string
scheduleType: string
scheduleTime: string | null
scheduleInterval: number | null
enabled: boolean
createdAt: string
updatedAt: string
lastRunAt: string | null
}
const IGNORED_FIELDS = ['id', 'createdAt', 'lastRunAt'] as const
function toComparable(job: ScheduledJob) {
const data = omit(job, IGNORED_FIELDS)
return {
...data,
scheduleTime: data.scheduleTime ?? null,
scheduleInterval: data.scheduleInterval ?? null,
}
}
function remoteToComparable(job: RemoteScheduledJob) {
return {
name: job.name,
query: job.query,
scheduleType: job.scheduleType as ScheduledJob['scheduleType'],
scheduleTime: job.scheduleTime,
scheduleInterval: job.scheduleInterval,
enabled: job.enabled,
}
}
function normalizeTimestamp(ts: string): string {
return ts.endsWith('Z') ? ts : `${ts}Z`
}
function remoteToLocal(remote: RemoteScheduledJob): ScheduledJob {
return {
id: remote.rowId,
name: remote.name,
query: remote.query,
scheduleType: remote.scheduleType as ScheduledJob['scheduleType'],
scheduleTime: remote.scheduleTime ?? undefined,
scheduleInterval: remote.scheduleInterval ?? undefined,
enabled: remote.enabled,
createdAt: normalizeTimestamp(remote.createdAt),
updatedAt: normalizeTimestamp(remote.updatedAt),
lastRunAt: remote.lastRunAt
? normalizeTimestamp(remote.lastRunAt)
: undefined,
}
}
function getLocalUpdatedAt(job: ScheduledJob): Date {
return new Date(job.updatedAt || job.createdAt)
}
function getRemoteUpdatedAt(remote: RemoteScheduledJob): Date {
return new Date(normalizeTimestamp(remote.updatedAt))
}
export async function syncSchedulesToBackend(
localJobs: ScheduledJob[],
userId: string,
): Promise<void> {
const profileResult = await execute(GetProfileIdByUserIdDocument, { userId })
const profileId = profileResult.profileByUserId?.rowId
if (!profileId) return
const remoteResult = await execute(GetScheduledJobsByProfileIdDocument, {
profileId,
})
const remoteJobs = new Map<string, RemoteScheduledJob>()
for (const node of remoteResult.scheduledJobs?.nodes ?? []) {
if (node) {
remoteJobs.set(node.rowId, node as RemoteScheduledJob)
}
}
const localJobsMap = new Map(localJobs.map((j) => [j.id, j]))
const jobsToAddLocally: ScheduledJob[] = []
const jobsToUpdateLocally: ScheduledJob[] = []
for (const [rowId, remote] of remoteJobs) {
const localJob = localJobsMap.get(rowId)
if (!localJob) {
jobsToAddLocally.push(remoteToLocal(remote))
} else {
const localTime = getLocalUpdatedAt(localJob)
const remoteTime = getRemoteUpdatedAt(remote)
if (remoteTime > localTime) {
jobsToUpdateLocally.push(remoteToLocal(remote))
}
}
}
if (jobsToAddLocally.length > 0 || jobsToUpdateLocally.length > 0) {
const currentJobs = (await scheduledJobStorage.getValue()) ?? []
const existingIds = new Set(currentJobs.map((j) => j.id))
const newJobs = jobsToAddLocally.filter((j) => !existingIds.has(j.id))
const mergedJobs = currentJobs.map((j) => {
const updated = jobsToUpdateLocally.find((u) => u.id === j.id)
return updated ?? j
})
if (newJobs.length > 0 || jobsToUpdateLocally.length > 0) {
await scheduledJobStorage.setValue([...mergedJobs, ...newJobs])
for (const job of [...newJobs, ...jobsToUpdateLocally]) {
try {
const alarmName = `scheduled-job-${job.id}`
await chrome.alarms.clear(alarmName)
if (job.enabled) {
await createAlarmFromJob(job)
}
} catch {
// Alarm operations may fail in non-background context
}
}
}
}
for (const job of localJobs) {
try {
const remote = remoteJobs.get(job.id)
if (remote) {
const localTime = getLocalUpdatedAt(job)
const remoteTime = getRemoteUpdatedAt(remote)
if (remoteTime >= localTime) continue
if (isEqual(toComparable(job), remoteToComparable(remote))) continue
await execute(UpdateScheduledJobDocument, {
input: {
rowId: job.id,
patch: {
name: job.name,
query: job.query,
scheduleType: job.scheduleType,
scheduleTime: job.scheduleTime ?? null,
scheduleInterval: job.scheduleInterval ?? null,
enabled: job.enabled,
lastRunAt: job.lastRunAt
? new Date(job.lastRunAt).toISOString()
: null,
updatedAt: job.updatedAt || new Date().toISOString(),
},
},
})
} else {
await execute(CreateScheduledJobDocument, {
input: {
scheduledJob: {
rowId: job.id,
profileId,
name: job.name,
query: job.query,
scheduleType: job.scheduleType,
scheduleTime: job.scheduleTime ?? null,
scheduleInterval: job.scheduleInterval ?? null,
enabled: job.enabled,
createdAt: new Date(job.createdAt).toISOString(),
updatedAt: job.updatedAt || new Date().toISOString(),
lastRunAt: job.lastRunAt
? new Date(job.lastRunAt).toISOString()
: null,
},
},
})
}
} catch (error) {
sentry.captureException(error, {
extra: {
jobId: job.id,
jobName: job.name,
},
})
}
}
}

View File

@@ -9,11 +9,12 @@
"build": "wxt build",
"zip": "wxt zip",
"compile": "tsc --noEmit",
"postinstall": "wxt prepare",
"lint": "bunx biome check",
"typecheck": "tsc --noEmit",
"lint:fix": "bunx biome check --write --unsafe",
"clean:cache": "rm -rf node_modules/.cache && rm -rf .output/ && rm -rf .wxt/"
"clean:cache": "rm -rf node_modules/.cache && rm -rf .output/ && rm -rf .wxt/",
"codegen": "graphql-codegen --config codegen.ts",
"codegen:watch": "graphql-codegen --config codegen.ts --watch"
},
"dependencies": {
"@ai-sdk/react": "^2.0.95",
@@ -40,12 +41,16 @@
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@sentry/react": "^10.31.0",
"@sentry/vite-plugin": "^4.6.1",
"@tanstack/query-async-storage-persister": "^5.90.21",
"@tanstack/react-query": "^5.90.19",
"@tanstack/react-query-persist-client": "^5.90.21",
"@types/cytoscape": "^3.31.0",
"@types/dompurify": "^3.2.0",
"@webext-core/messaging": "^2.3.0",
"@wxt-dev/storage": "^1.2.6",
"@xyflow/react": "^12.9.3",
"ai": "^5.0.95",
"better-auth": "^1.4.17",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -58,8 +63,10 @@
"embla-carousel-react": "^8.6.0",
"es-toolkit": "^1.42.0",
"eventsource-parser": "^3.0.6",
"graphql": "^16.12.0",
"hono": "^4.6.0",
"klavis": "^2.15.0",
"localforage": "^1.10.0",
"lucide-react": "^0.562.0",
"motion": "^12.23.24",
"nanoid": "^5.1.6",
@@ -83,6 +90,13 @@
"zod": "^4.1.13"
},
"devDependencies": {
"@0no-co/graphqlsp": "^1.15.2",
"@eslint/compat": "^2.0.1",
"@graphql-codegen/cli": "^6.1.1",
"@graphql-codegen/client-preset": "^5.2.2",
"@graphql-codegen/schema-ast": "^5.0.0",
"@graphql-typed-document-node/core": "^3.2.0",
"@parcel/watcher": "^2.5.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@types/bun": "^1.3.5",

View File

@@ -7,6 +7,12 @@
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"plugins": [
{
"name": "@0no-co/graphqlsp",
"schema": "./generated/graphql/schema.graphql"
}
]
}
}

View File

@@ -7,6 +7,11 @@ import { PRODUCT_WEB_HOST } from './lib/constants/productWebHost'
// biome-ignore lint/style/noProcessEnv: build config file needs env access
const env = process.env
const apiUrl = new URL(env.VITE_PUBLIC_BROWSEROS_API!)
const apiPattern = apiUrl.port
? `${apiUrl.hostname}:${apiUrl.port}`
: apiUrl.hostname
// See https://wxt.dev/api/config.html
// Extension ID will be bflpfmnmnokmjhmgnolecpppdbdophmk
export default defineConfig({
@@ -17,6 +22,9 @@ export default defineConfig({
key: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvBDAaDRvv61NpBeLR8etBRw82lv9VJO3sz/mA26gDzWKtVuzW4DXCl8Zfj5oWmoXLTfv3aiTigUXo/LHOoGpSucEVroMmAc7cgu2KuQ1fZPpMvYa0npD/m4h89360q8Oz0oKKaZGS905IJ04M2IkF4CuU3YEHFJBWb+cUyK9H8YVugelYbPD0IVs63T1SkGbh/t/Tfb2DpkinduSO8+x26sKydm30SRt+iZ2+7Nolcdum3LExInUiX2Pgb65Jb+mVw8NqyTVJyCEp8uq0cSHomWFQirSJ80tsDhISp4btwaRKHrXqovQx9XHQv4hCd+3LuB830eUEVMUNuCO+OyPxQIDAQAB',
update_url: 'https://cdn.browseros.com/extensions/update-manifest.xml',
// update_url: 'https://cdn.browseros.com/extensions/update-manifest.alpha.xml',
externally_connectable: {
matches: [`https://${apiPattern}/*`, `https://*.${apiPattern}/*`],
},
web_accessible_resources: [
{
resources: ['app.html'],

633
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -14,8 +14,9 @@
"start:server": "bun run --filter @browseros/server --elide-lines=0 start",
"start:agent": "bun ./scripts/build/controller-ext.ts && bun run --filter @browseros/agent dev",
"build:server": "FORCE_COLOR=1 bun run --filter @browseros/server --elide-lines=0 build",
"build:agent": "bun run --filter @browseros/agent build",
"build:agent": "bun run codegen:agent && bun run --filter @browseros/agent build",
"build:agent-sdk": "bun run --filter @browseros-ai/agent-sdk build",
"codegen:agent": "bun run --filter @browseros/agent codegen",
"build:ext": "FORCE_COLOR=1 bun run --filter browseros-controller --elide-lines=0 build",
"test": "bun run --filter @browseros/server test",
"test:all": "bun run --filter @browseros/server test:all",