mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-18 11:06:19 +00:00
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:
@@ -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=
|
||||
|
||||
3
apps/agent/.gitignore
vendored
3
apps/agent/.gitignore
vendored
@@ -30,3 +30,6 @@ stats-*.json
|
||||
# Env files
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# GraphQL generated files
|
||||
generated/
|
||||
|
||||
@@ -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
39
apps/agent/codegen.ts
Normal 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
|
||||
27
apps/agent/components/auth/AuthGuard.tsx
Normal file
27
apps/agent/components/auth/AuthGuard.tsx
Normal 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}</>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`)
|
||||
14
apps/agent/entrypoints/app/layout/AuthLayout.tsx
Normal file
14
apps/agent/entrypoints/app/layout/AuthLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
228
apps/agent/entrypoints/app/login/LoginPage.tsx
Normal file
228
apps/agent/entrypoints/app/login/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
apps/agent/entrypoints/app/login/LogoutPage.tsx
Normal file
49
apps/agent/entrypoints/app/login/LogoutPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
apps/agent/entrypoints/app/login/MagicLinkCallback.tsx
Normal file
70
apps/agent/entrypoints/app/login/MagicLinkCallback.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
}
|
||||
|
||||
354
apps/agent/entrypoints/app/profile/ProfilePage.tsx
Normal file
354
apps/agent/entrypoints/app/profile/ProfilePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -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> = ({
|
||||
|
||||
@@ -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, {
|
||||
|
||||
13
apps/agent/entrypoints/auth.content/index.ts
Normal file
13
apps/agent/entrypoints/auth.content/index.ts
Normal 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' })
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
94
apps/agent/entrypoints/newtab/index/SignInHint.tsx
Normal file
94
apps/agent/entrypoints/newtab/index/SignInHint.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
14
apps/agent/entrypoints/sidepanel/history/components/types.ts
Normal file
14
apps/agent/entrypoints/sidepanel/history/components/types.ts
Normal 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[]
|
||||
}
|
||||
54
apps/agent/entrypoints/sidepanel/history/components/utils.ts
Normal file
54
apps/agent/entrypoints/sidepanel/history/components/utils.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
}
|
||||
|
||||
17
apps/agent/lib/auth/AuthProvider.tsx
Normal file
17
apps/agent/lib/auth/AuthProvider.tsx
Normal 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}</>
|
||||
}
|
||||
8
apps/agent/lib/auth/auth-client.ts
Normal file
8
apps/agent/lib/auth/auth-client.ts
Normal 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()],
|
||||
})
|
||||
33
apps/agent/lib/auth/sessionStorage.ts
Normal file
33
apps/agent/lib/auth/sessionStorage.ts
Normal 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 }
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`)
|
||||
94
apps/agent/lib/conversations/uploadConversationsToGraphql.ts
Normal file
94
apps/agent/lib/conversations/uploadConversationsToGraphql.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
28
apps/agent/lib/graphql/QueryProvider.tsx
Normal file
28
apps/agent/lib/graphql/QueryProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
apps/agent/lib/graphql/execute.ts
Normal file
39
apps/agent/lib/graphql/execute.ts
Normal 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
|
||||
}
|
||||
24
apps/agent/lib/graphql/getQueryKeyFromDocument.ts
Normal file
24
apps/agent/lib/graphql/getQueryKeyFromDocument.ts
Normal 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
|
||||
}
|
||||
48
apps/agent/lib/graphql/useGraphqlInfiniteQuery.ts
Normal file
48
apps/agent/lib/graphql/useGraphqlInfiniteQuery.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
23
apps/agent/lib/graphql/useGraphqlMutation.ts
Normal file
23
apps/agent/lib/graphql/useGraphqlMutation.ts
Normal 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 ?? {}),
|
||||
})
|
||||
}
|
||||
24
apps/agent/lib/graphql/useGraphqlQuery.ts
Normal file
24
apps/agent/lib/graphql/useGraphqlQuery.ts
Normal 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 ?? {}),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -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()
|
||||
|
||||
121
apps/agent/lib/llm-providers/uploadLlmProvidersToGraphql.ts
Normal file
121
apps/agent/lib/llm-providers/uploadLlmProvidersToGraphql.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
48
apps/agent/lib/schedules/graphql/syncSchedulesDocument.ts
Normal file
48
apps/agent/lib/schedules/graphql/syncSchedulesDocument.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface ScheduledJob {
|
||||
scheduleInterval?: number
|
||||
enabled: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastRunAt?: string
|
||||
}
|
||||
|
||||
|
||||
202
apps/agent/lib/schedules/syncSchedulesToBackend.ts
Normal file
202
apps/agent/lib/schedules/syncSchedulesToBackend.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "@0no-co/graphqlsp",
|
||||
"schema": "./generated/graphql/schema.graphql"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user