diff --git a/web/next.config.js b/web/next.config.js index 2877a6469..1a8d7a53a 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -13,7 +13,6 @@ const cspHeader = ` object-src 'none'; base-uri 'self'; form-action 'self'; - frame-ancestors 'none'; ${ process.env.NEXT_PUBLIC_CLOUD_ENABLED === "true" ? "upgrade-insecure-requests;" @@ -27,6 +26,17 @@ const nextConfig = { publicRuntimeConfig: { version, }, + images: { + // Used to fetch favicons + remotePatterns: [ + { + protocol: "https", + hostname: "www.google.com", + port: "", + pathname: "/s2/favicons/**", + }, + ], + }, async headers() { return [ { @@ -44,17 +54,12 @@ const nextConfig = { key: "Referrer-Policy", value: "strict-origin-when-cross-origin", }, - { - key: "X-Frame-Options", - value: "DENY", - }, { key: "X-Content-Type-Options", value: "nosniff", }, { key: "Permissions-Policy", - // Deny all permissions by default value: "accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), cross-origin-isolated=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), navigation-override=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()", }, diff --git a/web/package-lock.json b/web/package-lock.json index d32345fe6..0d1eb688f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -17,7 +17,9 @@ "@phosphor-icons/react": "^2.0.8", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", @@ -77,6 +79,7 @@ "devDependencies": { "@chromatic-com/playwright": "^0.10.0", "@tailwindcss/typography": "^0.5.10", + "@types/chrome": "^0.0.287", "chromatic": "^11.18.1", "eslint": "^8.48.0", "eslint-config-next": "^14.1.0", @@ -2912,6 +2915,85 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.1.tgz", + "integrity": "sha512-UUw5E4e/2+4kFMH7+YxORXGWggtY6sM8WIwh5RZchhLuUg2H1hc98Py+pr8HMz6rdaYrK2t296ZEjYLOCO5uUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", + "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.2.tgz", @@ -3063,6 +3145,196 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.2.tgz", + "integrity": "sha512-E0MLLGfOP0l8P/NxgVzfXJ8w3Ch8cdO6UDzJfDChu4EJDy+/WdO5LqpdY8PYnCErkmZH3gZhDL1K7kQ41fAHuQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-roving-focus": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-collection": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz", + "integrity": "sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-presence": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", + "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz", + "integrity": "sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", @@ -4655,6 +4927,17 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@types/chrome": { + "version": "0.0.287", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.287.tgz", + "integrity": "sha512-wWhBNPNXZHwycHKNYnexUcpSbrihVZu++0rdp6GEk5ZgAglenLx+RwdEouh6FrHS0XQiOxSd62yaujM1OoQlZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.36", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.36.tgz", @@ -4738,6 +5021,30 @@ "@types/estree": "*" } }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", diff --git a/web/package.json b/web/package.json index 4a52f11c9..3fdf30f85 100644 --- a/web/package.json +++ b/web/package.json @@ -19,7 +19,9 @@ "@phosphor-icons/react": "^2.0.8", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", @@ -79,6 +81,7 @@ "devDependencies": { "@chromatic-com/playwright": "^0.10.0", "@tailwindcss/typography": "^0.5.10", + "@types/chrome": "^0.0.287", "chromatic": "^11.18.1", "eslint": "^8.48.0", "eslint-config-next": "^14.1.0", diff --git a/web/src/app/admin/settings/AnonymousUserPath.tsx b/web/src/app/admin/settings/AnonymousUserPath.tsx index 699b70f25..b35a8d9aa 100644 --- a/web/src/app/admin/settings/AnonymousUserPath.tsx +++ b/web/src/app/admin/settings/AnonymousUserPath.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import { PopupSpec } from "@/components/admin/connectors/Popup"; import { Button } from "@/components/ui/button"; -import { NEXT_PUBLIC_CLOUD_DOMAIN } from "@/lib/constants"; +import { NEXT_PUBLIC_WEB_DOMAIN } from "@/lib/constants"; import { ClipboardIcon } from "@/components/icons/icons"; import { Input } from "@/components/ui/input"; import { ThreeDotsLoader } from "@/components/Loading"; @@ -118,7 +118,7 @@ export function AnonymousUserPath({ <div className="flex flex-col gap-2 justify-center items-start"> <div className="w-full flex-grow flex items-center rounded-md shadow-sm"> <span className="inline-flex items-center rounded-l-md border border-r-0 border-gray-300 bg-gray-50 px-3 text-gray-500 sm:text-sm h-10"> - {NEXT_PUBLIC_CLOUD_DOMAIN}/anonymous/ + {NEXT_PUBLIC_WEB_DOMAIN}/anonymous/ </span> <Input type="text" @@ -143,7 +143,7 @@ export function AnonymousUserPath({ className="h-10 px-4" onClick={() => { navigator.clipboard.writeText( - `${NEXT_PUBLIC_CLOUD_DOMAIN}/anonymous/${anonymousUserPath}` + `${NEXT_PUBLIC_WEB_DOMAIN}/anonymous/${anonymousUserPath}` ); setPopup({ message: "Invite link copied!", diff --git a/web/src/app/auth/login/LoginPage.tsx b/web/src/app/auth/login/LoginPage.tsx new file mode 100644 index 000000000..cc10e5b74 --- /dev/null +++ b/web/src/app/auth/login/LoginPage.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { AuthTypeMetadata } from "@/lib/userSS"; +import { LoginText } from "./LoginText"; +import Link from "next/link"; +import { SignInButton } from "./SignInButton"; +import { EmailPasswordForm } from "./EmailPasswordForm"; +import { NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED } from "@/lib/constants"; +import Title from "@/components/ui/title"; +import { useSendAuthRequiredMessage } from "@/lib/extension/utils"; + +export default function LoginPage({ + authUrl, + authTypeMetadata, + nextUrl, + searchParams, + showPageRedirect, +}: { + authUrl: string | null; + authTypeMetadata: AuthTypeMetadata | null; + nextUrl: string | null; + searchParams: + | { + [key: string]: string | string[] | undefined; + } + | undefined; + showPageRedirect?: boolean; +}) { + useSendAuthRequiredMessage(); + return ( + <div className="flex flex-col w-full justify-center"> + {authUrl && authTypeMetadata && ( + <> + <h2 className="text-center text-xl text-strong font-bold"> + <LoginText /> + </h2> + + <SignInButton + authorizeUrl={authUrl} + authType={authTypeMetadata?.authType} + /> + </> + )} + + {authTypeMetadata?.authType === "cloud" && ( + <div className="mt-4 w-full justify-center"> + <div className="flex items-center w-full my-4"> + <div className="flex-grow border-t border-gray-300"></div> + <span className="px-4 text-gray-500">or</span> + <div className="flex-grow border-t border-gray-300"></div> + </div> + <EmailPasswordForm shouldVerify={true} nextUrl={nextUrl} /> + + <div className="flex mt-4 justify-between"> + <Link + href={`/auth/signup${ + searchParams?.next ? `?next=${searchParams.next}` : "" + }`} + className="text-link font-medium" + > + Create an account + </Link> + + {NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED && ( + <Link + href="/auth/forgot-password" + className="text-link font-medium" + > + Reset Password + </Link> + )} + </div> + </div> + )} + + {authTypeMetadata?.authType === "basic" && ( + <> + <div className="flex"> + <Title className="mb-2 mx-auto text-xl text-strong font-bold"> + <LoginText /> + </Title> + </div> + <EmailPasswordForm nextUrl={nextUrl} /> + <div className="flex flex-col gap-y-2 items-center"></div> + </> + )} + {showPageRedirect && ( + <p className="text-center mt-4"> + Don't have an account?{" "} + <span + onClick={() => { + if (typeof window !== "undefined" && window.top) { + window.top.location.href = "/auth/signup"; + } else { + window.location.href = "/auth/signup"; + } + }} + className="text-link font-medium cursor-pointer" + > + Create an account + </span> + </p> + )} + </div> + ); +} diff --git a/web/src/app/auth/login/SignInButton.tsx b/web/src/app/auth/login/SignInButton.tsx index b06f9bad7..b2d1a15be 100644 --- a/web/src/app/auth/login/SignInButton.tsx +++ b/web/src/app/auth/login/SignInButton.tsx @@ -46,7 +46,7 @@ export function SignInButton({ return ( <a - className="mx-auto mt-6 py-3 w-full text-text-100 bg-accent flex rounded cursor-pointer hover:bg-indigo-800" + className="mx-auto mb-4 mt-6 py-3 w-full text-text-100 bg-accent flex rounded cursor-pointer hover:bg-indigo-800" href={finalAuthorizeUrl} > {button} diff --git a/web/src/app/auth/login/page.tsx b/web/src/app/auth/login/page.tsx index 32ea1aa37..1d30a8242 100644 --- a/web/src/app/auth/login/page.tsx +++ b/web/src/app/auth/login/page.tsx @@ -7,18 +7,8 @@ import { AuthTypeMetadata, } from "@/lib/userSS"; import { redirect } from "next/navigation"; -import { SignInButton } from "./SignInButton"; -import { EmailPasswordForm } from "./EmailPasswordForm"; -import Title from "@/components/ui/title"; -import Text from "@/components/ui/text"; -import Link from "next/link"; -import { LoginText } from "./LoginText"; -import { getSecondsUntilExpiration } from "@/lib/time"; import AuthFlowContainer from "@/components/auth/AuthFlowContainer"; -import CardSection from "@/components/admin/CardSection"; -import { NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED } from "@/lib/constants"; -import { SettingsContext } from "@/components/settings/SettingsProvider"; -import { useContext } from "react"; +import LoginPage from "./LoginPage"; const Page = async (props: { searchParams?: Promise<{ [key: string]: string | string[] | undefined }>; @@ -49,13 +39,7 @@ const Page = async (props: { } // if user is already logged in, take them to the main app page - const secondsTillExpiration = getSecondsUntilExpiration(currentUser); - if ( - currentUser && - currentUser.is_active && - !currentUser.is_anonymous_user && - (secondsTillExpiration === null || secondsTillExpiration > 0) - ) { + if (currentUser && currentUser.is_active && !currentUser.is_anonymous_user) { if (authTypeMetadata?.requiresVerification && !currentUser.is_verified) { return redirect("/auth/waiting-on-verification"); } @@ -83,55 +67,12 @@ const Page = async (props: { <HealthCheckBanner /> </div> - <div className="flex flex-col w-full justify-center"> - {authUrl && authTypeMetadata && ( - <> - <h2 className="text-center text-xl text-strong font-bold"> - <LoginText /> - </h2> - - <SignInButton - authorizeUrl={authUrl} - authType={authTypeMetadata?.authType} - /> - </> - )} - - {authTypeMetadata?.authType === "cloud" && ( - <div className="mt-4 w-full justify-center"> - <div className="flex items-center w-full my-4"> - <div className="flex-grow border-t border-gray-300"></div> - <span className="px-4 text-gray-500">or</span> - <div className="flex-grow border-t border-gray-300"></div> - </div> - - <EmailPasswordForm shouldVerify={true} nextUrl={nextUrl} /> - - <div className="flex mt-4 justify-between"> - {NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED && ( - <Link - href="/auth/forgot-password" - className="text-link font-medium" - > - Reset Password - </Link> - )} - </div> - </div> - )} - - {authTypeMetadata?.authType === "basic" && ( - <> - <div className="flex"> - <Title className="mb-2 mx-auto text-xl text-strong font-bold"> - <LoginText /> - </Title> - </div> - <EmailPasswordForm nextUrl={nextUrl} /> - <div className="flex flex-col gap-y-2 items-center"></div> - </> - )} - </div> + <LoginPage + authUrl={authUrl} + authTypeMetadata={authTypeMetadata} + nextUrl={nextUrl!} + searchParams={searchParams} + /> </AuthFlowContainer> </div> ); diff --git a/web/src/app/chat/ChatIntro.tsx b/web/src/app/chat/ChatIntro.tsx index d86eb2315..1d9ef4353 100644 --- a/web/src/app/chat/ChatIntro.tsx +++ b/web/src/app/chat/ChatIntro.tsx @@ -14,7 +14,7 @@ export function ChatIntro({ selectedPersona }: { selectedPersona: Persona }) { <div onMouseEnter={() => setHoveredAssistant(true)} onMouseLeave={() => setHoveredAssistant(false)} - className="p-4 scale-[.7] cursor-pointer border-dashed rounded-full flex border border-gray-300 border-2 border-dashed" + className="mobile:hidden p-4 scale-[.7] cursor-pointer border-dashed rounded-full flex border border-gray-300 border-2 border-dashed" > <AssistantIcon disableToolip @@ -22,7 +22,7 @@ export function ChatIntro({ selectedPersona }: { selectedPersona: Persona }) { assistant={selectedPersona} /> </div> - <div className="absolute right-full mr-1 w-[300px] top-0"> + <div className="absolute right-full mr-1 mobile:mr-0 w-[300px] top-0"> {hoveredAssistant && ( <DisplayAssistantCard selectedPersona={selectedPersona} /> )} diff --git a/web/src/app/chat/ChatPage.tsx b/web/src/app/chat/ChatPage.tsx index 0548bc3c5..af944f0b9 100644 --- a/web/src/app/chat/ChatPage.tsx +++ b/web/src/app/chat/ChatPage.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRouter, useSearchParams } from "next/navigation"; +import { redirect, useRouter, useSearchParams } from "next/navigation"; import { BackendChatSession, BackendMessage, @@ -52,13 +52,11 @@ import { useLayoutEffect, useRef, useState, - useMemo, } from "react"; import { usePopup } from "@/components/admin/connectors/Popup"; import { SEARCH_PARAM_NAMES, shouldSubmitOnLoad } from "./searchParams"; import { useDocumentSelection } from "./useDocumentSelection"; import { LlmOverride, useFilters, useLlmOverride } from "@/lib/hooks"; -import { computeAvailableFilters } from "@/lib/filters"; import { ChatState, FeedbackType, RegenerationState } from "./types"; import { ChatFilters } from "./documentSidebar/ChatFilters"; import { OnyxInitializingLoader } from "@/components/OnyxInitializingLoader"; @@ -110,7 +108,11 @@ import AssistantBanner from "../../components/assistants/AssistantBanner"; import TextView from "@/components/chat_search/TextView"; import AssistantSelector from "@/components/chat_search/AssistantSelector"; import { Modal } from "@/components/Modal"; -import { createPostponedAbortSignal } from "next/dist/server/app-render/dynamic-rendering"; +import { useSendMessageToParent } from "@/lib/extension/utils"; +import { + CHROME_MESSAGE, + SUBMIT_MESSAGE_TYPES, +} from "@/lib/extension/constants"; const TEMP_USER_MESSAGE_ID = -1; const TEMP_ASSISTANT_MESSAGE_ID = -2; @@ -120,10 +122,12 @@ export function ChatPage({ toggle, documentSidebarInitialWidth, toggledSidebar, + firstMessage, }: { toggle: (toggled?: boolean) => void; documentSidebarInitialWidth?: number; toggledSidebar: boolean; + firstMessage?: string; }) { const router = useRouter(); const searchParams = useSearchParams(); @@ -140,6 +144,7 @@ export function ChatPage({ shouldShowWelcomeModal, refreshChatSessions, } = useChatContext(); + function useScreenSize() { const [screenSize, setScreenSize] = useState({ width: typeof window !== "undefined" ? window.innerWidth : 0, @@ -192,9 +197,6 @@ export function ChatPage({ const { user, isAdmin } = useUser(); const slackChatId = searchParams.get("slackChatId"); const existingChatIdRaw = searchParams.get("chatId"); - const [sendOnLoad, setSendOnLoad] = useState<string | null>( - searchParams.get(SEARCH_PARAM_NAMES.SEND_ON_LOAD) - ); const modelVersionFromSearchParams = searchParams.get( SEARCH_PARAM_NAMES.STRUCTURED_MODEL @@ -210,24 +212,34 @@ export function ChatPage({ toggle(false); } }, [user]); - // Effect to handle sendOnLoad - useEffect(() => { - if (sendOnLoad) { - const newSearchParams = new URLSearchParams(searchParams.toString()); - newSearchParams.delete(SEARCH_PARAM_NAMES.SEND_ON_LOAD); - // Update the URL without the send-on-load parameter - router.replace(`?${newSearchParams.toString()}`, { scroll: false }); + const processSearchParamsAndSubmitMessage = (searchParamsString: string) => { + const newSearchParams = new URLSearchParams(searchParamsString); + const message = newSearchParams.get("user-prompt"); - // Update our local state to reflect the change - setSendOnLoad(null); + filterManager.buildFiltersFromQueryString( + newSearchParams.toString(), + availableSources, + documentSets.map((ds) => ds.name), + tags + ); - // If there's a message, submit it - if (message) { - onSubmit({ messageOverride: message }); - } + const fileDescriptorString = newSearchParams.get(SEARCH_PARAM_NAMES.FILES); + const overrideFileDescriptors: FileDescriptor[] = fileDescriptorString + ? JSON.parse(decodeURIComponent(fileDescriptorString)) + : []; + + newSearchParams.delete(SEARCH_PARAM_NAMES.SEND_ON_LOAD); + + router.replace(`?${newSearchParams.toString()}`, { scroll: false }); + + // If there's a message, submit it + if (message) { + console.log("SUBMITTING MESSAGE"); + setSubmittedMessage(message); + onSubmit({ messageOverride: message, overrideFileDescriptors }); } - }, [sendOnLoad, searchParams, router]); + }; const existingChatSessionId = existingChatIdRaw ? existingChatIdRaw : null; @@ -312,14 +324,6 @@ export function ChatPage({ const noAssistants = liveAssistant == null || liveAssistant == undefined; const availableSources = ccPairs.map((ccPair) => ccPair.source); - const [finalAvailableSources, finalAvailableDocumentSets] = - computeAvailableFilters({ - selectedPersona: availableAssistants.find( - (assistant) => assistant.id === liveAssistant?.id - ), - availableSources: availableSources, - availableDocumentSets: documentSets, - }); // always set the model override for the chat session, when an assistant, llm provider, or user preference exists useEffect(() => { @@ -399,8 +403,6 @@ export function ChatPage({ setIsReady(true); }, []); - // this is triggered every time the user switches which chat - // session they are using useEffect(() => { const priorChatSessionId = chatSessionIdRef.current; const loadedSessionId = loadedIdSessionRef.current; @@ -456,7 +458,6 @@ export function ChatPage({ } return; } - setIsReady(true); const shouldScrollToBottom = visibleRange.get(existingChatSessionId) === undefined || visibleRange.get(existingChatSessionId)?.end == 0; @@ -651,10 +652,10 @@ export function ChatPage({ currentMessageMap(completeMessageDetail) ); - const [submittedMessage, setSubmittedMessage] = useState(""); + const [submittedMessage, setSubmittedMessage] = useState(firstMessage || ""); const [chatState, setChatState] = useState<Map<string | null, ChatState>>( - new Map([[chatSessionIdRef.current, "input"]]) + new Map([[chatSessionIdRef.current, firstMessage ? "loading" : "input"]]) ); const [regenerationState, setRegenerationState] = useState< @@ -798,6 +799,19 @@ export function ChatPage({ } }, [defaultAssistantId, availableAssistants, messageHistory.length]); + useEffect(() => { + if ( + submittedMessage && + currentSessionChatState === "loading" && + messageHistory.length == 0 + ) { + window.parent.postMessage( + { type: CHROME_MESSAGE.LOAD_NEW_CHAT_PAGE }, + "*" + ); + } + }, [submittedMessage, currentSessionChatState]); + const [ selectedDocuments, toggleDocumentSelection, @@ -997,12 +1011,32 @@ export function ChatPage({ } }, [chatSessionIdRef.current]); + const loadNewPageLogic = (event: MessageEvent) => { + if (event.data.type === SUBMIT_MESSAGE_TYPES.PAGE_CHANGE) { + try { + const url = new URL(event.data.href); + processSearchParamsAndSubmitMessage(url.searchParams.toString()); + } catch (error) { + console.error("Error parsing URL:", error); + } + } + }; + + // Equivalent to `loadNewPageLogic` useEffect(() => { - adjustDocumentSidebarWidth(); // Adjust the width on initial render - window.addEventListener("resize", adjustDocumentSidebarWidth); // Add resize event listener + if (searchParams.get(SEARCH_PARAM_NAMES.SEND_ON_LOAD)) { + processSearchParamsAndSubmitMessage(searchParams.toString()); + } + }, [searchParams, router]); + + useEffect(() => { + adjustDocumentSidebarWidth(); + window.addEventListener("resize", adjustDocumentSidebarWidth); + window.addEventListener("message", loadNewPageLogic); return () => { - window.removeEventListener("resize", adjustDocumentSidebarWidth); // Cleanup the event listener + window.removeEventListener("message", loadNewPageLogic); + window.removeEventListener("resize", adjustDocumentSidebarWidth); }; }, []); @@ -1078,6 +1112,7 @@ export function ChatPage({ alternativeAssistantOverride = null, modelOverRide, regenerationRequest, + overrideFileDescriptors, }: { messageIdToResend?: number; messageOverride?: string; @@ -1087,6 +1122,7 @@ export function ChatPage({ alternativeAssistantOverride?: Persona | null; modelOverRide?: LlmOverride; regenerationRequest?: RegenerationRequest | null; + overrideFileDescriptors?: FileDescriptor[]; } = {}) => { let frozenSessionId = currentSessionId(); updateCanContinue(false, frozenSessionId); @@ -1113,6 +1149,7 @@ export function ChatPage({ let currChatSessionId: string; const isNewSession = chatSessionIdRef.current === null; + const searchParamBasedChatSessionName = searchParams.get(SEARCH_PARAM_NAMES.TITLE) || null; @@ -1228,7 +1265,7 @@ export function ChatPage({ signal: controller.signal, // Add this line message: currMessage, alternateAssistantId: currentAssistantId, - fileDescriptors: currentMessageFiles, + fileDescriptors: overrideFileDescriptors || currentMessageFiles, parentMessageId: regenerationRequest?.parentMessage.messageId || lastSuccessfulMessageId, @@ -1815,6 +1852,7 @@ export function ChatPage({ end: 0, mostVisibleMessageId: null, }; + useSendMessageToParent(); useEffect(() => { if (noAssistants) { @@ -1889,6 +1927,7 @@ export function ChatPage({ handleSlackChatRedirect(); }, [searchParams, router]); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.metaKey || event.ctrlKey) { @@ -1957,6 +1996,10 @@ export function ChatPage({ }); }; } + if (!user) { + redirect("/auth/login"); + } + if (noAssistants) return ( <> @@ -2039,7 +2082,11 @@ export function ChatPage({ {retrievalEnabled && documentSidebarToggled && settings?.isMobile && ( <div className="md:hidden"> - <Modal noPadding noScroll> + <Modal + onOutsideClick={() => setDocumentSidebarToggled(false)} + noPadding + noScroll + > <ChatFilters setPresentingDocument={setPresentingDocument} modal={true} @@ -2247,7 +2294,11 @@ export function ChatPage({ )} {documentSidebarInitialWidth !== undefined && isReady ? ( - <Dropzone onDrop={handleImageUpload} noClick> + <Dropzone + key={currentSessionId()} + onDrop={handleImageUpload} + noClick + > {({ getRootProps }) => ( <div className="flex h-full w-full"> {!settings?.isMobile && ( @@ -2327,7 +2378,8 @@ export function ChatPage({ {messageHistory.length === 0 && !isFetchingChatMessages && currentSessionChatState == "input" && - !loadingError && ( + !loadingError && + !submittedMessage && ( <div className="h-full w-[95%] mx-auto mt-12 flex flex-col justify-center items-center"> <ChatIntro selectedPersona={liveAssistant} /> @@ -2344,7 +2396,7 @@ export function ChatPage({ currentSessionChatState == "input" && !loadingError && allAssistants.length > 1 && ( - <div className="mx-auto px-4 w-full max-w-[750px] flex flex-col items-center"> + <div className="mobile:hidden mx-auto px-4 w-full max-w-[750px] flex flex-col items-center"> <Separator className="mx-2 w-full my-12" /> <div className="text-sm text-black font-medium mb-4"> Recent Assistants @@ -2362,8 +2414,9 @@ export function ChatPage({ )} <div + key={currentSessionId()} className={ - "-ml-4 w-full mx-auto " + + "desktop:-ml-4 w-full mx-auto " + "absolute mobile:top-0 desktop:top-12 left-0 " + (settings?.enterpriseSettings ?.two_lines_for_chat_header @@ -2774,6 +2827,7 @@ export function ChatPage({ </div> )} <ChatInputBar + llmOverrideManager={llmOverrideManager} removeDocs={() => { clearSelectedDocuments(); }} @@ -2786,7 +2840,6 @@ export function ChatPage({ } chatState={currentSessionChatState} stopGenerating={stopGenerating} - openModelSettings={() => setSettingsToggled(true)} selectedDocuments={selectedDocuments} // assistant stuff selectedAssistant={liveAssistant} @@ -2796,7 +2849,6 @@ export function ChatPage({ message={message} setMessage={setMessage} onSubmit={onSubmit} - filterManager={filterManager} files={currentMessageFiles} setFiles={setCurrentMessageFiles} toggleFilters={ diff --git a/web/src/app/chat/WrappedChat.tsx b/web/src/app/chat/WrappedChat.tsx index 6b48e4421..55d0f91c6 100644 --- a/web/src/app/chat/WrappedChat.tsx +++ b/web/src/app/chat/WrappedChat.tsx @@ -4,14 +4,20 @@ import FunctionalWrapper from "./shared_chat_search/FunctionalWrapper"; export default function WrappedChat({ initiallyToggled, + firstMessage, }: { initiallyToggled: boolean; + firstMessage?: string; }) { return ( <FunctionalWrapper initiallyToggled={initiallyToggled} content={(toggledSidebar, toggle) => ( - <ChatPage toggle={toggle} toggledSidebar={toggledSidebar} /> + <ChatPage + toggle={toggle} + toggledSidebar={toggledSidebar} + firstMessage={firstMessage} + /> )} /> ); diff --git a/web/src/app/chat/documentSidebar/ChatDocumentDisplay.tsx b/web/src/app/chat/documentSidebar/ChatDocumentDisplay.tsx index 8ee3c6ead..13c1e1b0d 100644 --- a/web/src/app/chat/documentSidebar/ChatDocumentDisplay.tsx +++ b/web/src/app/chat/documentSidebar/ChatDocumentDisplay.tsx @@ -79,7 +79,9 @@ export function ChatDocumentDisplay({ document.updated_at || Object.keys(document.metadata).length > 0; return ( <div - className={`max-w-[400px] opacity-100 ${modal ? "w-[90vw]" : "w-full"}`} + className={`desktop:max-w-[400px] opacity-100 ${ + modal ? "w-[90vw]" : "w-full" + }`} > <div className={`flex relative flex-col gap-0.5 rounded-xl mx-2 my-1 ${ diff --git a/web/src/app/chat/input/ChatInputBar.tsx b/web/src/app/chat/input/ChatInputBar.tsx index c5c21c440..c07d6a142 100644 --- a/web/src/app/chat/input/ChatInputBar.tsx +++ b/web/src/app/chat/input/ChatInputBar.tsx @@ -3,7 +3,7 @@ import { FiPlusCircle, FiPlus, FiInfo, FiX, FiSearch } from "react-icons/fi"; import { ChatInputOption } from "./ChatInputOption"; import { Persona } from "@/app/admin/assistants/interfaces"; -import { FilterManager } from "@/lib/hooks"; +import { FilterManager, LlmOverrideManager } from "@/lib/hooks"; import { useChatContext } from "@/components/context/ChatContext"; import { getFinalLLM } from "@/lib/llm/utils"; import { ChatFileType, FileDescriptor } from "../interfaces"; @@ -31,13 +31,73 @@ import { ChatState } from "../types"; import UnconfiguredProviderText from "@/components/chat_search/UnconfiguredProviderText"; import { useAssistants } from "@/components/context/AssistantsContext"; import { XIcon } from "lucide-react"; -import FiltersDisplay from "./FilterDisplay"; +import { fetchTitleFromUrl } from "@/lib/sources"; const MAX_INPUT_HEIGHT = 200; +const SelectedUrlChip = ({ + url, + onRemove, +}: { + url: string; + onRemove: (url: string) => void; +}) => ( + <div className="bg-white border border-gray-200 shadow-sm rounded-lg p-2 flex items-center space-x-2"> + <img + src={`https://www.google.com/s2/favicons?domain=${new URL(url).hostname}`} + alt="Website favicon" + className="w-4 h-4" + /> + <p className="text-sm font-medium text-gray-700 truncate"> + {new URL(url).hostname} + </p> + <XIcon + onClick={() => onRemove(url)} + size={16} + className="text-text-400 hover:text-text-600 ml-auto cursor-pointer" + /> + </div> +); + +const SentUrlChip = ({ + url, + onRemove, + onClick, + title, +}: { + url: string; + onRemove: (url: string) => void; + onClick: () => void; + title: string; +}) => { + return ( + <button + className="bg-white/80 opacity-50 group-hover:opacity-100 border border-gray-200/50 shadow-sm rounded-lg p-2 flex items-center space-x-2 hover:bg-white hover:border-gray-200 transition-all duration-200" + onClick={onClick} + > + <img + src={`https://www.google.com/s2/favicons?domain=${ + new URL(url).hostname + }`} + alt="Website favicon" + className="w-4 h-4 " + /> + <p className="text-sm font-medium text-gray-600 truncate group-hover:text-gray-700"> + {title} + </p> + <XIcon + onClick={(e) => { + onRemove(url); + }} + size={16} + className="text-text-300 hover:text-text-500 ml-auto transition-colors duration-200" + /> + </button> + ); +}; + interface ChatInputBarProps { removeDocs: () => void; - openModelSettings: () => void; showDocs: () => void; showConfigureAPIKey: () => void; selectedDocuments: OnyxDocument[]; @@ -45,7 +105,7 @@ interface ChatInputBarProps { setMessage: (message: string) => void; stopGenerating: () => void; onSubmit: () => void; - filterManager: FilterManager; + llmOverrideManager: LlmOverrideManager; chatState: ChatState; alternativeAssistant: Persona | null; // assistants @@ -61,7 +121,6 @@ interface ChatInputBarProps { export function ChatInputBar({ removeDocs, - openModelSettings, showDocs, showConfigureAPIKey, selectedDocuments, @@ -69,7 +128,6 @@ export function ChatInputBar({ setMessage, stopGenerating, onSubmit, - filterManager, chatState, // assistants @@ -408,7 +466,7 @@ export function ChatInputBar({ style={{ scrollbarWidth: "thin" }} role="textarea" aria-multiline - placeholder="Ask me anything.." + placeholder="Ask me anything..." value={message} onKeyDown={(event) => { if ( @@ -453,16 +511,6 @@ export function ChatInputBar({ onClick={toggleFilters} /> )} - {(filterManager.selectedSources.length > 0 || - filterManager.selectedDocumentSets.length > 0 || - filterManager.selectedTags.length > 0 || - filterManager.timeRange) && - toggleFilters && ( - <FiltersDisplay - filterManager={filterManager} - toggleFilters={toggleFilters} - /> - )} </div> <div className="absolute bottom-2.5 mobile:right-4 desktop:right-10"> diff --git a/web/src/app/chat/input/SimplifiedChatInputBar.tsx b/web/src/app/chat/input/SimplifiedChatInputBar.tsx new file mode 100644 index 000000000..20a5dae80 --- /dev/null +++ b/web/src/app/chat/input/SimplifiedChatInputBar.tsx @@ -0,0 +1,244 @@ +import React, { useEffect } from "react"; +import { FiPlusCircle } from "react-icons/fi"; +import { ChatInputOption } from "./ChatInputOption"; +import { FilterManager } from "@/lib/hooks"; +import { ChatFileType, FileDescriptor } from "../interfaces"; +import { + InputBarPreview, + InputBarPreviewImageProvider, +} from "../files/InputBarPreview"; +import { SendIcon } from "@/components/icons/icons"; +import { HorizontalSourceSelector } from "@/components/search/filtering/HorizontalSourceSelector"; +import { Tag } from "@/lib/types"; + +const MAX_INPUT_HEIGHT = 200; + +interface ChatInputBarProps { + message: string; + setMessage: (message: string) => void; + onSubmit: () => void; + files: FileDescriptor[]; + setFiles: (files: FileDescriptor[]) => void; + handleFileUpload: (files: File[]) => void; + textAreaRef: React.RefObject<HTMLTextAreaElement>; + filterManager?: FilterManager; + existingSources: string[]; + availableDocumentSets: { name: string }[]; + availableTags: Tag[]; +} + +export function SimplifiedChatInputBar({ + message, + setMessage, + onSubmit, + files, + setFiles, + handleFileUpload, + textAreaRef, + filterManager, + existingSources, + availableDocumentSets, + availableTags, +}: ChatInputBarProps) { + useEffect(() => { + const textarea = textAreaRef.current; + if (textarea) { + textarea.style.height = "0px"; + textarea.style.height = `${Math.min( + textarea.scrollHeight, + MAX_INPUT_HEIGHT + )}px`; + } + }, [message, textAreaRef]); + + const handlePaste = (event: React.ClipboardEvent) => { + const items = event.clipboardData?.items; + if (items) { + const pastedFiles = []; + for (let i = 0; i < items.length; i++) { + if (items[i].kind === "file") { + const file = items[i].getAsFile(); + if (file) pastedFiles.push(file); + } + } + if (pastedFiles.length > 0) { + event.preventDefault(); + handleFileUpload(pastedFiles); + } + } + }; + + const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => { + const text = event.target.value; + setMessage(text); + }; + + return ( + <div + id="onyx-chat-input" + className=" + w-full + relative + mx-auto + " + > + <div + className=" + opacity-100 + w-full + h-fit + flex + flex-col + border + border-[#E5E7EB] + rounded-lg + relative + text-text-chatbar + bg-background-chatbar + [&:has(textarea:focus)]::ring-1 + [&:has(textarea:focus)]::ring-black + " + > + {files.length > 0 && ( + <div className="flex gap-x-2 px-2 pt-2"> + <div className="flex gap-x-1 px-2 overflow-visible overflow-x-scroll items-end miniscroll"> + {files.map((file) => ( + <div className="flex-none" key={file.id}> + {file.type === ChatFileType.IMAGE ? ( + <InputBarPreviewImageProvider + file={file} + onDelete={() => { + setFiles( + files.filter( + (fileInFilter) => fileInFilter.id !== file.id + ) + ); + }} + isUploading={file.isUploading || false} + /> + ) : ( + <InputBarPreview + file={file} + onDelete={() => { + setFiles( + files.filter( + (fileInFilter) => fileInFilter.id !== file.id + ) + ); + }} + isUploading={file.isUploading || false} + /> + )} + </div> + ))} + </div> + </div> + )} + + <textarea + onPaste={handlePaste} + onChange={handleInputChange} + ref={textAreaRef} + className={` + m-0 + w-full + shrink + resize-none + rounded-lg + border-0 + bg-background-chatbar + placeholder:text-text-chatbar-subtle + ${ + textAreaRef.current && + textAreaRef.current.scrollHeight > MAX_INPUT_HEIGHT + ? "overflow-y-auto mt-2" + : "" + } + whitespace-normal + break-word + overscroll-contain + outline-none + placeholder-subtle + resize-none + px-5 + py-4 + h-14 + `} + autoFocus + style={{ scrollbarWidth: "thin" }} + role="textarea" + aria-multiline + placeholder="Ask me anything..." + value={message} + onKeyDown={(event) => { + if ( + event.key === "Enter" && + !event.shiftKey && + !(event.nativeEvent as any).isComposing + ) { + event.preventDefault(); + if (message) { + onSubmit(); + } + } + }} + suppressContentEditableWarning={true} + /> + <div className="flex items-center space-x-3 mr-12 px-4 pb-2"> + <ChatInputOption + flexPriority="stiff" + name="File" + Icon={FiPlusCircle} + onClick={() => { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; // Allow multiple files + input.onchange = (event: any) => { + const selectedFiles = Array.from( + event?.target?.files || [] + ) as File[]; + if (selectedFiles.length > 0) { + handleFileUpload(selectedFiles); + } + }; + input.click(); + }} + /> + + {filterManager && ( + <HorizontalSourceSelector + timeRange={filterManager.timeRange} + setTimeRange={filterManager.setTimeRange} + selectedSources={filterManager.selectedSources} + setSelectedSources={filterManager.setSelectedSources} + selectedDocumentSets={filterManager.selectedDocumentSets} + setSelectedDocumentSets={filterManager.setSelectedDocumentSets} + selectedTags={filterManager.selectedTags} + setSelectedTags={filterManager.setSelectedTags} + existingSources={existingSources} + availableDocumentSets={availableDocumentSets} + availableTags={availableTags} + /> + )} + </div> + </div> + <div className="absolute bottom-2 mobile:right-4 desktop:right-4"> + <button + className="cursor-pointer" + onClick={() => { + if (message) { + onSubmit(); + } + }} + > + <SendIcon + size={28} + className={`text-emphasis text-white p-1 rounded-full ${ + message ? "bg-submit-background" : "bg-disabled-submit-background" + } `} + /> + </button> + </div> + </div> + ); +} diff --git a/web/src/app/chat/layout.tsx b/web/src/app/chat/layout.tsx new file mode 100644 index 000000000..4023ba8c4 --- /dev/null +++ b/web/src/app/chat/layout.tsx @@ -0,0 +1,62 @@ +import { redirect } from "next/navigation"; +import { unstable_noStore as noStore } from "next/cache"; +import { fetchChatData } from "@/lib/chat/fetchChatData"; +import { ChatProvider } from "@/components/context/ChatContext"; +import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh"; + +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + noStore(); + + // Ensure searchParams is an object, even if it's empty + const safeSearchParams = {}; + + const data = await fetchChatData( + safeSearchParams as { [key: string]: string } + ); + + if ("redirect" in data) { + redirect(data.redirect); + } + + const { + chatSessions, + availableSources, + user, + documentSets, + tags, + llmProviders, + folders, + openedFolders, + defaultAssistantId, + shouldShowWelcomeModal, + ccPairs, + } = data; + + return ( + <> + <InstantSSRAutoRefresh /> + <ChatProvider + value={{ + chatSessions, + availableSources, + ccPairs, + documentSets, + tags, + availableDocumentSets: documentSets, + availableTags: tags, + llmProviders, + folders, + openedFolders, + shouldShowWelcomeModal, + defaultAssistantId, + }} + > + {children} + </ChatProvider> + </> + ); +} diff --git a/web/src/app/chat/message/MemoizedTextComponents.tsx b/web/src/app/chat/message/MemoizedTextComponents.tsx index a29038e62..3ded90770 100644 --- a/web/src/app/chat/message/MemoizedTextComponents.tsx +++ b/web/src/app/chat/message/MemoizedTextComponents.tsx @@ -1,11 +1,7 @@ import { Citation } from "@/components/search/results/Citation"; -import { WebResultIcon } from "@/components/WebResultIcon"; import { LoadedOnyxDocument, OnyxDocument } from "@/lib/search/interfaces"; -import { getSourceMetadata, SOURCE_METADATA_MAP } from "@/lib/sources"; -import { ValidSources } from "@/lib/types"; import React, { memo } from "react"; import isEqual from "lodash/isEqual"; -import { SlackIcon } from "@/components/icons/icons"; import { SourceIcon } from "@/components/SourceIcon"; export const MemoizedAnchor = memo( @@ -66,7 +62,6 @@ export const MemoizedLink = memo((props: any) => { <Citation url={document?.url} icon={document?.icon as React.ReactNode} - link={rest?.href} document={document as LoadedOnyxDocument} updatePresentingDocument={updatePresentingDocument} > diff --git a/web/src/app/chat/message/Messages.tsx b/web/src/app/chat/message/Messages.tsx index c3d404f41..4d3bbbd1f 100644 --- a/web/src/app/chat/message/Messages.tsx +++ b/web/src/app/chat/message/Messages.tsx @@ -383,14 +383,14 @@ export const AIMessage = ({ <div id="onyx-ai-message" ref={trackedElementRef} - className={`py-5 ml-4 px-5 relative flex `} + className={`py-5 ml-4 lg:px-5 relative flex `} > <div className={`mx-auto ${ shared ? "w-full" : "w-[90%]" } max-w-message-max`} > - <div className={`desktop:mr-12 ${!shared && "mobile:ml-0 md:ml-8"}`}> + <div className={`lg:mr-12 ${!shared && "mobile:ml-0 md:ml-8"}`}> <div className="flex"> <AssistantIcon size="small" @@ -399,7 +399,7 @@ export const AIMessage = ({ <div className="w-full"> <div className="max-w-message-max break-words"> - <div className="w-full ml-4"> + <div className="w-full lg:ml-4"> <div className="max-w-message-max break-words"> {!toolCall || toolCall.tool_name === SEARCH_TOOL_NAME ? ( <> @@ -410,6 +410,8 @@ export const AIMessage = ({ query={query} finished={toolCall?.tool_result != undefined} handleSearchQueryEdit={handleSearchQueryEdit} + docs={docs || []} + toggleDocumentSelection={toggleDocumentSelection!} /> </div> )} @@ -465,7 +467,7 @@ export const AIMessage = ({ )} {docs && docs.length > 0 && ( - <div className="mt-2 -mx-8 w-full mb-4 flex relative"> + <div className="mobile:hidden mt-2 -mx-8 w-full mb-4 flex relative"> <div className="w-full"> <div className="px-8 flex gap-x-2"> {!settings?.isMobile && @@ -768,7 +770,7 @@ export const HumanMessage = ({ return ( <div id="onyx-human-message" - className="pt-5 pb-1 px-2 lg:px-5 flex -mr-6 relative" + className="pt-5 pb-1 w-full lg:px-5 flex -mr-6 relative" onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > @@ -778,7 +780,7 @@ export const HumanMessage = ({ } max-w-[790px]`} > <div className="xl:ml-8"> - <div className="flex flex-col mr-4"> + <div className="flex flex-col desktop:mr-4"> <FileDisplay alignBubble files={files || []} /> <div className="flex justify-end"> diff --git a/web/src/app/chat/message/SearchSummary.tsx b/web/src/app/chat/message/SearchSummary.tsx index 9c22f81d3..2b3392c98 100644 --- a/web/src/app/chat/message/SearchSummary.tsx +++ b/web/src/app/chat/message/SearchSummary.tsx @@ -4,12 +4,15 @@ import { } from "@/components/BasicClickable"; import { HoverPopup } from "@/components/HoverPopup"; import { Hoverable } from "@/components/Hoverable"; +import { SourceIcon } from "@/components/SourceIcon"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { OnyxDocument } from "@/lib/search/interfaces"; +import { ValidSources } from "@/lib/types"; import { useEffect, useRef, useState } from "react"; import { FiCheck, FiEdit2, FiSearch, FiX } from "react-icons/fi"; @@ -45,11 +48,15 @@ export function SearchSummary({ query, finished, handleSearchQueryEdit, + docs, + toggleDocumentSelection, }: { index: number; finished: boolean; query: string; handleSearchQueryEdit?: (query: string) => void; + docs: OnyxDocument[]; + toggleDocumentSelection: () => void; }) { const [isEditing, setIsEditing] = useState(false); const [finalQuery, setFinalQuery] = useState(query); @@ -87,27 +94,63 @@ export function SearchSummary({ }, [query, isEditing]); const searchingForDisplay = ( - <div className={`flex p-1 rounded ${isOverflowed && "cursor-default"}`}> - <FiSearch className="flex-none mr-2 my-auto" size={14} /> + <div className="flex flex-col gap-y-1"> <div - className={`${!finished && "loading-text"} - !text-sm !line-clamp-1 !break-all px-0.5`} - ref={searchingForRef} + className={`flex items-center w-full rounded ${ + isOverflowed && "cursor-default" + }`} > - {finished ? "Searched" : "Searching"} for:{" "} - <i> - {index === 1 - ? finalQuery.length > 50 - ? `${finalQuery.slice(0, 50)}...` - : finalQuery - : finalQuery} - </i> + <FiSearch className="mobile:hidden flex-none mr-2" size={14} /> + <div + className={`${ + !finished && "loading-text" + } text-xs desktop:text-sm mobile:ml-auto !line-clamp-1 !break-all px-0.5 flex-grow`} + ref={searchingForRef} + > + {finished ? "Searched" : "Searching"} for:{" "} + <i> + {index === 1 + ? finalQuery.length > 50 + ? `${finalQuery.slice(0, 50)}...` + : finalQuery + : finalQuery} + </i> + </div> + </div> + + <div className="desktop:hidden"> + {" "} + {docs && ( + <button + className="cursor-pointer mr-2 flex items-center gap-0.5" + onClick={() => toggleDocumentSelection()} + > + {Array.from(new Set(docs.map((doc) => doc.source_type))) + .slice(0, 3) + .map((sourceType, idx) => ( + <div key={idx} className="rounded-full"> + <SourceIcon sourceType={sourceType} iconSize={14} /> + </div> + ))} + {Array.from(new Set(docs.map((doc) => doc.source_type))).length > + 3 && ( + <div className="rounded-full bg-gray-200 w-3.5 h-3.5 flex items-center justify-center"> + <span className="text-[8px]"> + + + {Array.from(new Set(docs.map((doc) => doc.source_type))) + .length - 3} + </span> + </div> + )} + <span className="text-xs underline">View sources</span> + </button> + )} </div> </div> ); const editInput = handleSearchQueryEdit ? ( - <div className="flex w-full mr-3"> + <div className="mobile:hidden flex w-full mr-3"> <div className="my-2 w-full"> <input ref={editQueryRef} @@ -155,12 +198,12 @@ export function SearchSummary({ ) : null; return ( - <div className="flex"> + <div className="flex items-center"> {isEditing ? ( editInput ) : ( <> - <div className="text-sm"> + <div className="mobile:w-full mobile:mr-2 text-sm mobile:flex-grow"> {isOverflowed ? ( <HoverPopup mainContent={searchingForDisplay} @@ -176,12 +219,13 @@ export function SearchSummary({ searchingForDisplay )} </div> + {handleSearchQueryEdit && ( <TooltipProvider delayDuration={1000}> <Tooltip> <TooltipTrigger asChild> <button - className="my-auto hover:bg-hover p-1.5 rounded" + className="ml-2 mobile:hidden hover:bg-hover p-1 rounded flex-shrink-0" onClick={() => { setIsEditing(true); }} diff --git a/web/src/app/chat/message/SkippedSearch.tsx b/web/src/app/chat/message/SkippedSearch.tsx index 27a50d4f6..33bbd352e 100644 --- a/web/src/app/chat/message/SkippedSearch.tsx +++ b/web/src/app/chat/message/SkippedSearch.tsx @@ -1,4 +1,4 @@ -import { EmphasizedClickable } from "@/components/BasicClickable"; +import { CustomTooltip } from "@/components/tooltip/CustomTooltip"; import { FiBook } from "react-icons/fi"; export function SkippedSearch({ @@ -7,22 +7,29 @@ export function SkippedSearch({ handleForceSearch: () => void; }) { return ( - <div className="flex text-sm !pt-0 p-1"> - <div className="flex mb-auto"> - <FiBook className="my-auto flex-none mr-2" size={14} /> - <div className="my-auto cursor-default"> + <div className="flex w-full text-sm !pt-0 p-1"> + <div className="flex w-full mb-auto"> + <FiBook className="mobile:hidden my-auto flex-none mr-2" size={14} /> + <div className="my-auto flex w-full items-center justify-between cursor-default"> <span className="mobile:hidden"> The AI decided this query didn't need a search </span> - <span className="desktop:hidden">No search</span> + <p className="text-xs desktop:hidden">No search performed</p> + <CustomTooltip + content="Perform a search for this query" + showTick + line + wrap + > + <button + onClick={handleForceSearch} + className="ml-auto mr-4 text-xs desktop:hidden underline-dotted decoration-dotted underline cursor-pointer" + > + Force search? + </button> + </CustomTooltip> </div> </div> - - <div className="ml-auto my-auto" onClick={handleForceSearch}> - <EmphasizedClickable size="sm"> - <div className="w-24 text-xs">Force Search</div> - </EmphasizedClickable> - </div> </div> ); } diff --git a/web/src/app/chat/nrf/NRFPage.tsx b/web/src/app/chat/nrf/NRFPage.tsx new file mode 100644 index 000000000..c2433f56a --- /dev/null +++ b/web/src/app/chat/nrf/NRFPage.tsx @@ -0,0 +1,384 @@ +"use client"; +import React, { useState, useEffect, useRef } from "react"; +import { useUser } from "@/components/user/UserProvider"; +import { usePopup } from "@/components/admin/connectors/Popup"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { v4 as uuidv4 } from "uuid"; +import { Button } from "@/components/ui/button"; +import { SimplifiedChatInputBar } from "../input/SimplifiedChatInputBar"; +import { Menu } from "lucide-react"; +import { Shortcut } from "./interfaces"; +import { + MaxShortcutsReachedModal, + NewShortCutModal, +} from "@/components/extension/Shortcuts"; +import { Modal } from "@/components/Modal"; +import { useNightTime } from "@/lib/dateUtils"; +import { useFilters } from "@/lib/hooks"; +import { uploadFilesForChat } from "../lib"; +import { ChatFileType, FileDescriptor } from "../interfaces"; +import { useChatContext } from "@/components/context/ChatContext"; +import Dropzone from "react-dropzone"; +import { useSendMessageToParent } from "@/lib/extension/utils"; +import { useNRFPreferences } from "@/components/context/NRFPreferencesContext"; +import { SettingsPanel } from "../../components/nrf/SettingsPanel"; +import { ShortcutsDisplay } from "../../components/nrf/ShortcutsDisplay"; +import LoginPage from "../../auth/login/LoginPage"; +import { AuthType, NEXT_PUBLIC_WEB_DOMAIN } from "@/lib/constants"; +import { sendSetDefaultNewTabMessage } from "@/lib/extension/utils"; +import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; +import { CHROME_MESSAGE } from "@/lib/extension/constants"; +import { ApiKeyModal } from "@/components/llm/ApiKeyModal"; + +export default function NRFPage({ + requestCookies, +}: { + requestCookies: ReadonlyRequestCookies; +}) { + const { + theme, + defaultLightBackgroundUrl, + defaultDarkBackgroundUrl, + shortcuts: shortCuts, + setShortcuts: setShortCuts, + setUseOnyxAsNewTab, + showShortcuts, + } = useNRFPreferences(); + + const filterManager = useFilters(); + const { isNight } = useNightTime(); + const { user } = useUser(); + const { ccPairs, documentSets, tags, llmProviders } = useChatContext(); + + const { popup, setPopup } = usePopup(); + + // State + const [message, setMessage] = useState(""); + const [settingsOpen, setSettingsOpen] = useState<boolean>(false); + const [editingShortcut, setEditingShortcut] = useState<Shortcut | null>(null); + const [backgroundUrl, setBackgroundUrl] = useState<string>( + theme === "light" ? defaultLightBackgroundUrl : defaultDarkBackgroundUrl + ); + + // Modals + const [showTurnOffModal, setShowTurnOffModal] = useState<boolean>(false); + const [showShortCutModal, setShowShortCutModal] = useState(false); + const [showMaxShortcutsModal, setShowMaxShortcutsModal] = useState(false); + const [showLoginModal, setShowLoginModal] = useState<boolean>(!user); + + // Refs + const textAreaRef = useRef<HTMLTextAreaElement | null>(null); + const inputRef = useRef<HTMLInputElement>(null); + + useEffect(() => { + setBackgroundUrl( + theme === "light" ? defaultLightBackgroundUrl : defaultDarkBackgroundUrl + ); + }, [theme, defaultLightBackgroundUrl, defaultDarkBackgroundUrl]); + + useSendMessageToParent(); + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, []); + + const toggleSettings = () => { + setSettingsOpen((prev) => !prev); + }; + + // If user toggles the "Use Onyx" switch to off, prompt a modal + const handleUseOnyxToggle = (checked: boolean) => { + if (!checked) { + setShowTurnOffModal(true); + } else { + setUseOnyxAsNewTab(true); + sendSetDefaultNewTabMessage(true); + } + }; + + const availableSources = ccPairs.map((ccPair) => ccPair.source); + + const [currentMessageFiles, setCurrentMessageFiles] = useState< + FileDescriptor[] + >([]); + + const handleImageUpload = async (acceptedFiles: File[]) => { + const tempFileDescriptors = acceptedFiles.map((file) => ({ + id: uuidv4(), + type: file.type.startsWith("image/") + ? ChatFileType.IMAGE + : ChatFileType.DOCUMENT, + isUploading: true, + })); + + // only show loading spinner for reasonably large files + const totalSize = acceptedFiles.reduce((sum, file) => sum + file.size, 0); + if (totalSize > 50 * 1024) { + setCurrentMessageFiles((prev) => [...prev, ...tempFileDescriptors]); + } + + const removeTempFiles = (prev: FileDescriptor[]) => { + return prev.filter( + (file) => !tempFileDescriptors.some((newFile) => newFile.id === file.id) + ); + }; + + await uploadFilesForChat(acceptedFiles).then(([files, error]) => { + if (error) { + setCurrentMessageFiles((prev) => removeTempFiles(prev)); + setPopup({ + type: "error", + message: error, + }); + } else { + setCurrentMessageFiles((prev) => [...removeTempFiles(prev), ...files]); + } + }); + }; + + const confirmTurnOff = () => { + setUseOnyxAsNewTab(false); + setShowTurnOffModal(false); + sendSetDefaultNewTabMessage(false); + }; + + // Auth related + const [authType, setAuthType] = useState<AuthType | null>(null); + const [fetchingAuth, setFetchingAuth] = useState(false); + useEffect(() => { + // If user is already logged in, no need to fetch auth data + if (user) return; + + async function fetchAuthData() { + setFetchingAuth(true); + + try { + const res = await fetch("/api/auth/type", { + method: "GET", + credentials: "include", + }); + if (!res.ok) { + throw new Error(`Failed to fetch auth type: ${res.statusText}`); + } + + const data = await res.json(); + setAuthType(data.auth_type); + } catch (err) { + console.error("Error fetching auth data:", err); + } finally { + setFetchingAuth(false); + } + } + + fetchAuthData(); + }, [user]); + + const onSubmit = async ({ + messageOverride, + }: { + messageOverride?: string; + } = {}) => { + const userMessage = messageOverride || message; + + let filterString = filterManager?.getFilterString(); + + if (currentMessageFiles.length > 0) { + filterString += + "&files=" + encodeURIComponent(JSON.stringify(currentMessageFiles)); + } + + const newHref = + `${NEXT_PUBLIC_WEB_DOMAIN}/chat?send-on-load=true&user-prompt=` + + encodeURIComponent(userMessage) + + filterString; + + if (typeof window !== "undefined" && window.parent) { + window.parent.postMessage( + { type: CHROME_MESSAGE.LOAD_NEW_PAGE, href: newHref }, + "*" + ); + } else { + window.location.href = newHref; + } + }; + + return ( + <div + className="relative w-full h-full flex flex-col min-h-screen bg-cover bg-center bg-no-repeat overflow-hidden transition-[background-image] duration-300 ease-in-out" + style={{ + backgroundImage: `url(${backgroundUrl})`, + }} + > + <div className="absolute top-0 right-0 p-4 z-10"> + <button + aria-label="Open settings" + onClick={toggleSettings} + className="bg-white bg-opacity-70 rounded-full p-2.5 cursor-pointer hover:bg-opacity-80 transition-colors duration-200" + > + <Menu size={12} className="text-neutral-900" /> + </button> + </div> + + <Dropzone onDrop={handleImageUpload} noClick> + {({ getRootProps }) => ( + <div + {...getRootProps()} + className="absolute top-20 left-0 w-full h-full flex flex-col" + > + <div className="pointer-events-auto absolute top-[40%] left-1/2 -translate-x-1/2 -translate-y-1/2 text-center w-[90%] lg:max-w-3xl"> + <h1 + className={`pl-2 text-xl text-left w-full mb-4 ${ + theme === "light" ? "text-neutral-800" : "text-white" + }`} + > + {isNight + ? "End your day with Onyx" + : "Start your day with Onyx"} + </h1> + + <SimplifiedChatInputBar + onSubmit={onSubmit} + handleFileUpload={handleImageUpload} + message={message} + setMessage={setMessage} + files={currentMessageFiles} + setFiles={setCurrentMessageFiles} + filterManager={filterManager} + textAreaRef={textAreaRef} + existingSources={availableSources} + availableDocumentSets={documentSets} + availableTags={tags} + /> + + <ShortcutsDisplay + shortCuts={shortCuts} + showShortcuts={showShortcuts} + setEditingShortcut={setEditingShortcut} + setShowShortCutModal={setShowShortCutModal} + openShortCutModal={() => { + if (shortCuts.length >= 6) { + setShowMaxShortcutsModal(true); + } else { + setEditingShortcut(null); + setShowShortCutModal(true); + } + }} + /> + </div> + </div> + )} + </Dropzone> + {showMaxShortcutsModal && ( + <MaxShortcutsReachedModal + onClose={() => setShowMaxShortcutsModal(false)} + /> + )} + {showShortCutModal && ( + <NewShortCutModal + setPopup={setPopup} + onDelete={(shortcut: Shortcut) => { + setShortCuts( + shortCuts.filter((s: Shortcut) => s.name !== shortcut.name) + ); + setShowShortCutModal(false); + }} + isOpen={showShortCutModal} + onClose={() => { + setEditingShortcut(null); + setShowShortCutModal(false); + }} + onAdd={(shortCut: Shortcut) => { + if (editingShortcut) { + setShortCuts( + shortCuts + .filter((s) => s.name !== editingShortcut.name) + .concat(shortCut) + ); + } else { + setShortCuts([...shortCuts, shortCut]); + } + setShowShortCutModal(false); + }} + editingShortcut={editingShortcut} + /> + )} + <SettingsPanel + settingsOpen={settingsOpen} + toggleSettings={toggleSettings} + handleUseOnyxToggle={handleUseOnyxToggle} + /> + + <Dialog open={showTurnOffModal} onOpenChange={setShowTurnOffModal}> + <DialogContent className="w-fit max-w-[95%]"> + <DialogHeader> + <DialogTitle>Turn off Onyx new tab page?</DialogTitle> + <DialogDescription> + You'll see your browser's default new tab page instead. + <br /> + You can turn it back on anytime in your Onyx settings. + </DialogDescription> + </DialogHeader> + <DialogFooter className="flex gap-2 justify-center"> + <Button + variant="outline" + onClick={() => setShowTurnOffModal(false)} + > + Cancel + </Button> + <Button variant="destructive" onClick={confirmTurnOff}> + Turn off + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + {!user && authType !== "disabled" && showLoginModal ? ( + <Modal className="max-w-md mx-auto"> + {fetchingAuth ? ( + <p className="p-4">Loading login info…</p> + ) : authType == "basic" ? ( + <LoginPage + showPageRedirect + authUrl={null} + authTypeMetadata={{ + authType: authType as AuthType, + autoRedirect: false, + requiresVerification: false, + anonymousUserEnabled: null, + }} + nextUrl="/nrf" + searchParams={{}} + /> + ) : ( + <div className="flex flex-col items-center"> + <h2 className="text-center text-xl text-strong font-bold mb-4"> + Welcome to Onyx + </h2> + <Button + className="bg-accent w-full hover:bg-accent-hover text-white" + onClick={() => { + if (window.top) { + window.top.location.href = "/auth/login"; + } else { + window.location.href = "/auth/login"; + } + }} + > + Log in + </Button> + </div> + )} + </Modal> + ) : ( + llmProviders.length == 0 && <ApiKeyModal setPopup={setPopup} /> + )} + </div> + ); +} diff --git a/web/src/app/chat/nrf/interfaces.ts b/web/src/app/chat/nrf/interfaces.ts new file mode 100644 index 000000000..ad750cc71 --- /dev/null +++ b/web/src/app/chat/nrf/interfaces.ts @@ -0,0 +1,5 @@ +export interface Shortcut { + name: string; + url: string; + favicon?: string; +} diff --git a/web/src/app/chat/nrf/page.tsx b/web/src/app/chat/nrf/page.tsx new file mode 100644 index 000000000..d3682286c --- /dev/null +++ b/web/src/app/chat/nrf/page.tsx @@ -0,0 +1,20 @@ +import { unstable_noStore as noStore } from "next/cache"; +import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh"; +import { cookies } from "next/headers"; +import NRFPage from "./NRFPage"; +import { NRFPreferencesProvider } from "../../../components/context/NRFPreferencesContext"; + +export default async function Page() { + noStore(); + const requestCookies = await cookies(); + + return ( + <div className="w-full h-full bg-black"> + <InstantSSRAutoRefresh /> + + <NRFPreferencesProvider> + <NRFPage requestCookies={requestCookies} /> + </NRFPreferencesProvider> + </div> + ); +} diff --git a/web/src/app/chat/page.tsx b/web/src/app/chat/page.tsx index e742c54a5..adb0234b8 100644 --- a/web/src/app/chat/page.tsx +++ b/web/src/app/chat/page.tsx @@ -1,65 +1,10 @@ -import { redirect } from "next/navigation"; -import { unstable_noStore as noStore } from "next/cache"; -import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh"; -import { WelcomeModal } from "@/components/initialSetup/welcome/WelcomeModalWrapper"; -import { ChatProvider } from "@/components/context/ChatContext"; -import { fetchChatData } from "@/lib/chat/fetchChatData"; import WrappedChat from "./WrappedChat"; -import { cookies } from "next/headers"; export default async function Page(props: { searchParams: Promise<{ [key: string]: string }>; }) { const searchParams = await props.searchParams; - noStore(); - const requestCookies = await cookies(); - const data = await fetchChatData(searchParams); + const firstMessage = searchParams.firstMessage; - if ("redirect" in data) { - redirect(data.redirect); - } - - const { - user, - chatSessions, - availableSources, - documentSets, - tags, - llmProviders, - folders, - toggleSidebar, - openedFolders, - defaultAssistantId, - shouldShowWelcomeModal, - ccPairs, - } = data; - - return ( - <> - <InstantSSRAutoRefresh /> - {shouldShowWelcomeModal && ( - <WelcomeModal user={user} requestCookies={requestCookies} /> - )} - <ChatProvider - value={{ - chatSessions, - availableSources, - ccPairs, - documentSets, - tags, - availableDocumentSets: documentSets, - availableTags: tags, - llmProviders, - folders, - openedFolders, - shouldShowWelcomeModal, - defaultAssistantId, - }} - > - <WrappedChat - initiallyToggled={toggleSidebar && !user?.is_anonymous_user} - /> - </ChatProvider> - </> - ); + return <WrappedChat firstMessage={firstMessage} initiallyToggled={false} />; } diff --git a/web/src/app/chat/searchParams.ts b/web/src/app/chat/searchParams.ts index 7b3dc44fe..b32390aa4 100644 --- a/web/src/app/chat/searchParams.ts +++ b/web/src/app/chat/searchParams.ts @@ -15,6 +15,7 @@ export const SEARCH_PARAM_NAMES = { SUBMIT_ON_LOAD: "submit-on-load", // chat title TITLE: "title", + FILES: "files", // for seeding chats SEEDED: "seeded", SEND_ON_LOAD: "send-on-load", diff --git a/web/src/app/components/nrf/SettingsPanel.tsx b/web/src/app/components/nrf/SettingsPanel.tsx new file mode 100644 index 000000000..c61c35271 --- /dev/null +++ b/web/src/app/components/nrf/SettingsPanel.tsx @@ -0,0 +1,179 @@ +import React from "react"; +import { Switch } from "@/components/ui/switch"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Label } from "@/components/ui/label"; +import { useNRFPreferences } from "../../../components/context/NRFPreferencesContext"; +import { + darkExtensionImages, + lightExtensionImages, +} from "@/lib/extension/constants"; + +const SidebarSwitch = ({ + checked, + onCheckedChange, + label, +}: { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + label: string; +}) => ( + <div className="flex justify-between items-center py-2"> + <span className="text-sm text-gray-300">{label}</span> + <Switch + checked={checked} + onCheckedChange={onCheckedChange} + className="data-[state=checked]:bg-white data-[state=checked]:border-neutral-200 data-[state=unchecked]:bg-gray-600" + circleClassName="data-[state=checked]:bg-neutral-200" + /> + </div> +); + +const RadioOption = ({ + value, + label, + description, + groupValue, + onChange, +}: { + value: string; + label: string; + description: string; + groupValue: string; + onChange: (value: string) => void; +}) => ( + <div className="flex items-start space-x-2 mb-2"> + <RadioGroupItem + value={value} + id={value} + className="mt-1 border border-gray-600 data-[state=checked]:border-white data-[state=checked]:bg-white" + /> + <Label htmlFor={value} className="flex flex-col"> + <span className="text-sm text-gray-300">{label}</span> + {description && ( + <span className="text-xs text-gray-500">{description}</span> + )} + </Label> + </div> +); + +export const SettingsPanel = ({ + settingsOpen, + toggleSettings, + handleUseOnyxToggle, +}: { + settingsOpen: boolean; + toggleSettings: () => void; + handleUseOnyxToggle: (checked: boolean) => void; +}) => { + const { + theme, + setTheme, + defaultLightBackgroundUrl, + setDefaultLightBackgroundUrl, + defaultDarkBackgroundUrl, + setDefaultDarkBackgroundUrl, + useOnyxAsNewTab, + showShortcuts, + setShowShortcuts, + } = useNRFPreferences(); + + const toggleTheme = (newTheme: string) => { + setTheme(newTheme); + }; + + const updateBackgroundUrl = (url: string) => { + if (theme === "light") { + setDefaultLightBackgroundUrl(url); + } else { + setDefaultDarkBackgroundUrl(url); + } + }; + + return ( + <div + className="fixed top-0 right-0 w-[360px] h-full bg-[#202124] text-gray-300 overflow-y-auto z-20 transition-transform duration-300 ease-in-out transform" + style={{ + transform: settingsOpen ? "translateX(0)" : "translateX(100%)", + boxShadow: "-2px 0 10px rgba(0,0,0,0.3)", + }} + > + <div className="p-6"> + <div className="flex justify-between items-center mb-6"> + <h2 className="text-xl font-semibold text-white"> + Home page settings + </h2> + <button + aria-label="Close" + onClick={toggleSettings} + className="text-gray-400 hover:text-white" + > + ✕ + </button> + </div> + + <h3 className="text-sm font-semibold mb-2">General</h3> + <SidebarSwitch + checked={useOnyxAsNewTab} + onCheckedChange={handleUseOnyxToggle} + label="Use Onyx as new tab page" + /> + + <SidebarSwitch + checked={showShortcuts} + onCheckedChange={setShowShortcuts} + label="Show bookmarks" + /> + + <h3 className="text-sm font-semibold mt-6 mb-2">Theme</h3> + <RadioGroup + value={theme} + onValueChange={toggleTheme} + className="space-y-2" + > + <RadioOption + value="light" + label="Light theme" + description="Light theme" + groupValue={theme} + onChange={toggleTheme} + /> + <RadioOption + value="dark" + label="Dark theme" + description="Dark theme" + groupValue={theme} + onChange={toggleTheme} + /> + </RadioGroup> + + <h3 className="text-sm font-semibold mt-6 mb-2">Background</h3> + <div className="grid grid-cols-4 gap-2"> + {(theme === "dark" ? darkExtensionImages : lightExtensionImages).map( + (bg: string, index: number) => ( + <div + key={bg} + onClick={() => updateBackgroundUrl(bg)} + className={`relative ${ + index === 0 ? "col-span-2 row-span-2" : "" + } cursor-pointer rounded-sm overflow-hidden`} + style={{ + paddingBottom: index === 0 ? "100%" : "50%", + }} + > + <div + className="absolute inset-0 bg-cover bg-center" + style={{ backgroundImage: `url(${bg})` }} + /> + {(theme === "light" + ? defaultLightBackgroundUrl + : defaultDarkBackgroundUrl) === bg && ( + <div className="absolute inset-0 border-2 border-blue-400 rounded" /> + )} + </div> + ) + )} + </div> + </div> + </div> + ); +}; diff --git a/web/src/app/components/nrf/ShortcutsDisplay.tsx b/web/src/app/components/nrf/ShortcutsDisplay.tsx new file mode 100644 index 000000000..ff0d0b64d --- /dev/null +++ b/web/src/app/components/nrf/ShortcutsDisplay.tsx @@ -0,0 +1,46 @@ +"use client"; +import React from "react"; +import { ShortCut, AddShortCut } from "@/components/extension/Shortcuts"; +import { Shortcut } from "@/app/chat/nrf/interfaces"; + +interface ShortcutsDisplayProps { + shortCuts: Shortcut[]; + showShortcuts: boolean; + setEditingShortcut: (shortcut: Shortcut | null) => void; + setShowShortCutModal: (show: boolean) => void; + openShortCutModal: () => void; +} + +export const ShortcutsDisplay: React.FC<ShortcutsDisplayProps> = ({ + shortCuts, + showShortcuts, + setEditingShortcut, + setShowShortCutModal, + openShortCutModal, +}) => { + return ( + <div + className={` + mx-auto flex flex-wrap justify-center gap-x-6 gap-y-4 mt-12 + transition-all duration-700 ease-in-out + ${ + showShortcuts + ? "opacity-100 max-h-[500px]" + : "opacity-0 max-h-0 overflow-hidden pointer-events-none" + } + `} + > + {shortCuts.map((shortCut: Shortcut, index: number) => ( + <ShortCut + key={index} + onEdit={() => { + setEditingShortcut(shortCut); + setShowShortCutModal(true); + }} + shortCut={shortCut} + /> + ))} + <AddShortCut openShortCutModal={openShortCutModal} /> + </div> + ); +}; diff --git a/web/src/app/ee/Hori b/web/src/app/ee/Hori new file mode 100644 index 000000000..e69de29bb diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index c8d15792f..b5ff9d70a 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -13,7 +13,6 @@ import { Metadata } from "next"; import { buildClientUrl } from "@/lib/utilsSS"; import { Inter } from "next/font/google"; import { EnterpriseSettings, GatingType } from "./admin/settings/interfaces"; -import { HeaderTitle } from "@/components/header/HeaderTitle"; import { fetchAssistantData } from "@/lib/chat/fetchAssistantdata"; import { AppProvider } from "@/components/context/AppProvider"; import { PHProvider } from "./providers"; diff --git a/web/src/components/assistants/AssistantIcon.tsx b/web/src/components/assistants/AssistantIcon.tsx index df1ac725b..b753ebd1c 100644 --- a/web/src/components/assistants/AssistantIcon.tsx +++ b/web/src/components/assistants/AssistantIcon.tsx @@ -33,6 +33,7 @@ export function AssistantIcon({ return ( <CustomTooltip + className="hidden lg:block" disabled={disableToolip || !assistant.description} showTick line diff --git a/web/src/components/chat_search/AssistantSelector.tsx b/web/src/components/chat_search/AssistantSelector.tsx index 6192a93f7..4949fb363 100644 --- a/web/src/components/chat_search/AssistantSelector.tsx +++ b/web/src/components/chat_search/AssistantSelector.tsx @@ -66,10 +66,12 @@ const AssistantSelector = ({ const [isTemperatureExpanded, setIsTemperatureExpanded] = useState(false); // Initialize selectedTab from localStorage - const [selectedTab, setSelectedTab] = useState<number>(() => { + const [selectedTab, setSelectedTab] = useState<number | undefined>(); + useEffect(() => { const storedTab = localStorage.getItem("assistantSelectorSelectedTab"); - return storedTab !== null ? Number(storedTab) : 0; - }); + const tab = storedTab !== null ? Number(storedTab) : 0; + setSelectedTab(tab); + }, [localStorage]); const sensors = useSensors( useSensor(PointerSensor, { diff --git a/web/src/components/chat_search/hooks.ts b/web/src/components/chat_search/hooks.ts index e1cb683f8..e60a99617 100644 --- a/web/src/components/chat_search/hooks.ts +++ b/web/src/components/chat_search/hooks.ts @@ -72,15 +72,20 @@ export const useSidebarVisibility = ({ }; const handleMouseLeave = () => { - setShowDocSidebar(false); + if (!mobile) { + setShowDocSidebar(false); + } }; - - document.addEventListener("mousemove", handleEvent); - document.addEventListener("mouseleave", handleMouseLeave); + if (!mobile) { + document.addEventListener("mousemove", handleEvent); + document.addEventListener("mouseleave", handleMouseLeave); + } return () => { - document.removeEventListener("mousemove", handleEvent); - document.removeEventListener("mouseleave", handleMouseLeave); + if (!mobile) { + document.removeEventListener("mousemove", handleEvent); + document.removeEventListener("mouseleave", handleMouseLeave); + } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [showDocSidebar, toggledSidebar, sidebarElementRef, mobile]); diff --git a/web/src/components/context/NRFPreferencesContext.tsx b/web/src/components/context/NRFPreferencesContext.tsx new file mode 100644 index 000000000..a78f5bfde --- /dev/null +++ b/web/src/components/context/NRFPreferencesContext.tsx @@ -0,0 +1,123 @@ +"use client"; + +import React, { createContext, useContext, useState, useEffect } from "react"; +import { Shortcut } from "@/app/chat/nrf/interfaces"; +import { notifyExtensionOfThemeChange } from "@/lib/extension/utils"; +import { + darkExtensionImages, + lightExtensionImages, + LocalStorageKeys, +} from "@/lib/extension/constants"; + +interface NRFPreferencesContextValue { + theme: string; + setTheme: (t: string) => void; + defaultLightBackgroundUrl: string; + setDefaultLightBackgroundUrl: (val: string) => void; + defaultDarkBackgroundUrl: string; + setDefaultDarkBackgroundUrl: (val: string) => void; + shortcuts: Shortcut[]; + setShortcuts: (s: Shortcut[]) => void; + useOnyxAsNewTab: boolean; + setUseOnyxAsNewTab: (v: boolean) => void; + showShortcuts: boolean; + setShowShortcuts: (v: boolean) => void; +} + +const NRFPreferencesContext = createContext< + NRFPreferencesContextValue | undefined +>(undefined); + +function useLocalStorageState<T>( + key: string, + defaultValue: T +): [T, (value: T) => void] { + const [state, setState] = useState<T>(() => { + if (typeof window !== "undefined") { + const storedValue = localStorage.getItem(key); + return storedValue ? JSON.parse(storedValue) : defaultValue; + } + return undefined; + }); + + const setValue = (value: T) => { + setState(value); + if (typeof window !== "undefined") { + localStorage.setItem(key, JSON.stringify(value)); + } + }; + + return [state, setValue]; +} + +export function NRFPreferencesProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [theme, setTheme] = useLocalStorageState<string>( + LocalStorageKeys.THEME, + "dark" + ); + const [defaultLightBackgroundUrl, setDefaultLightBackgroundUrl] = + useLocalStorageState<string>( + LocalStorageKeys.LIGHT_BG_URL, + lightExtensionImages[0] + ); + const [defaultDarkBackgroundUrl, setDefaultDarkBackgroundUrl] = + useLocalStorageState<string>( + LocalStorageKeys.DARK_BG_URL, + darkExtensionImages[0] + ); + const [shortcuts, setShortcuts] = useLocalStorageState<Shortcut[]>( + LocalStorageKeys.SHORTCUTS, + [] + ); + const [showShortcuts, setShowShortcuts] = useLocalStorageState<boolean>( + LocalStorageKeys.SHOW_SHORTCUTS, + false + ); + const [useOnyxAsNewTab, setUseOnyxAsNewTab] = useLocalStorageState<boolean>( + LocalStorageKeys.USE_ONYX_AS_NEW_TAB, + true + ); + + useEffect(() => { + if (theme === "dark") { + notifyExtensionOfThemeChange(theme, defaultDarkBackgroundUrl); + } else { + notifyExtensionOfThemeChange(theme, defaultLightBackgroundUrl); + } + }, [theme, defaultLightBackgroundUrl, defaultDarkBackgroundUrl]); + + return ( + <NRFPreferencesContext.Provider + value={{ + theme, + setTheme, + defaultLightBackgroundUrl, + setDefaultLightBackgroundUrl, + defaultDarkBackgroundUrl, + setDefaultDarkBackgroundUrl, + shortcuts, + setShortcuts, + useOnyxAsNewTab, + setUseOnyxAsNewTab, + showShortcuts, + setShowShortcuts, + }} + > + {children} + </NRFPreferencesContext.Provider> + ); +} + +export function useNRFPreferences() { + const context = useContext(NRFPreferencesContext); + if (!context) { + throw new Error( + "useNRFPreferences must be used within an NRFPreferencesProvider" + ); + } + return context; +} diff --git a/web/src/components/extension/Shortcuts.tsx b/web/src/components/extension/Shortcuts.tsx new file mode 100644 index 000000000..a1581c792 --- /dev/null +++ b/web/src/components/extension/Shortcuts.tsx @@ -0,0 +1,257 @@ +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Shortcut } from "@/app/chat/nrf/interfaces"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { PencilIcon, PlusIcon } from "lucide-react"; +import Image from "next/image"; +import { PopupSpec } from "@/components/admin/connectors/Popup"; +import { Modal } from "../Modal"; +import { QuestionMarkIcon } from "../icons/icons"; + +export const validateUrl = (input: string) => { + try { + new URL(input); + return true; + } catch { + return false; + } +}; + +export const ShortCut = ({ + shortCut, + onEdit, +}: { + shortCut: Shortcut; + onEdit: (shortcut: Shortcut) => void; +}) => { + const [faviconError, setFaviconError] = useState(false); + + return ( + <div className="w-24 h-24 flex-none rounded-xl shadow-lg relative group transition-all duration-300 ease-in-out hover:scale-105 bg-white/10 backdrop-blur-sm"> + <button + onClick={(e) => { + e.stopPropagation(); + onEdit(shortCut); + }} + className="absolute top-1 right-1 p-1 bg-white/20 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-200" + > + <PencilIcon className="w-3 h-3 text-white" /> + </button> + <div + onClick={() => window.open(shortCut.url, "_blank")} + className="w-full h-full flex flex-col items-center justify-center cursor-pointer" + > + <div className="w-8 h-8 mb-2 relative"> + {shortCut.favicon && !faviconError ? ( + <Image + src={shortCut.favicon} + alt={shortCut.name} + width={40} + height={40} + className="rounded-sm" + onError={() => setFaviconError(true)} + /> + ) : ( + <QuestionMarkIcon size={32} className="text-white w-full h-full" /> + )} + </div> + <h1 className="text-white w-full text-center font-semibold text-sm truncate px-2"> + {shortCut.name} + </h1> + </div> + </div> + ); +}; + +export const AddShortCut = ({ + openShortCutModal, +}: { + openShortCutModal: () => void; +}) => { + return ( + <button + onClick={openShortCutModal} + className="w-24 h-24 flex-none rounded-xl bg-white/10 hover:bg-white/20 backdrop-blur-sm transition-all duration-300 ease-in-out flex flex-col items-center justify-center" + > + <PlusIcon className="w-8 h-8 text-white mb-2" /> + <h1 className="text-white text-xs font-medium">New Bookmark</h1> + </button> + ); +}; + +export const NewShortCutModal = ({ + isOpen, + onClose, + onAdd, + editingShortcut, + onDelete, + setPopup, +}: { + isOpen: boolean; + onClose: () => void; + onDelete: (shortcut: Shortcut) => void; + onAdd: (shortcut: Shortcut) => void; + editingShortcut?: Shortcut | null; + setPopup: (popup: PopupSpec) => void; +}) => { + const [name, setName] = useState(editingShortcut?.name || ""); + const [url, setUrl] = useState(editingShortcut?.url || ""); + const [faviconError, setFaviconError] = useState(false); + const [isValidUrl, setIsValidUrl] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (isValidUrl) { + const faviconUrl = `https://www.google.com/s2/favicons?domain=${ + new URL(url).hostname + }&sz=64`; + onAdd({ name, url, favicon: faviconUrl }); + onClose(); + } else { + console.error("Invalid URL submitted"); + setPopup({ + type: "error", + message: "Please enter a valid URL", + }); + } + }; + + const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const newUrl = e.target.value; + setUrl(newUrl); + setIsValidUrl(validateUrl(newUrl)); + setFaviconError(false); + }; + + useEffect(() => { + setIsValidUrl(validateUrl(url)); + }, [url]); + + const faviconUrl = isValidUrl + ? `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=64` + : ""; + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="max-w-[95%] sm:max-w-[425px] bg-neutral-900 border-none text-white"> + <DialogHeader> + <DialogTitle> + {editingShortcut ? "Edit Shortcut" : "Add New Shortcut"} + </DialogTitle> + <DialogDescription> + {editingShortcut + ? "Modify your existing shortcut." + : "Create a new shortcut for quick access to your favorite websites."} + </DialogDescription> + </DialogHeader> + <form onSubmit={handleSubmit} className="w-full space-y-6"> + <div className="space-y-4 w-full"> + <div className="flex flex-col space-y-2"> + <Label + htmlFor="name" + className="text-sm font-medium text-neutral-300" + > + Name + </Label> + <Input + id="name" + value={name} + onChange={(e) => setName(e.target.value)} + className="w-full bg-neutral-800 border-neutral-700 text-white" + placeholder="Enter shortcut name" + /> + </div> + <div className="flex flex-col space-y-2"> + <Label + htmlFor="url" + className="text-sm font-medium text-neutral-300" + > + URL + </Label> + <Input + id="url" + value={url} + onChange={handleUrlChange} + className={`bg-neutral-800 border-neutral-700 text-white ${ + !isValidUrl && url ? "border-red-500" : "" + }`} + placeholder="https://example.com" + /> + {!isValidUrl && url && ( + <p className="text-red-500 text-sm">Please enter a valid URL</p> + )} + </div> + <div className="flex items-center space-x-2"> + <Label className="text-sm font-medium text-neutral-300"> + Favicon Preview: + </Label> + <div className="w-8 h-8 relative flex items-center justify-center"> + {isValidUrl && !faviconError ? ( + <Image + src={faviconUrl} + alt="Favicon" + width={32} + height={32} + className="w-full h-full rounded-sm" + onError={() => setFaviconError(true)} + /> + ) : ( + <QuestionMarkIcon size={32} className="w-full h-full" /> + )} + </div> + </div> + </div> + <DialogFooter> + <Button + type="submit" + className="bg-blue-600 hover:bg-blue-700 text-white" + disabled={!isValidUrl || !name} + > + {editingShortcut ? "Save Changes" : "Add Shortcut"} + </Button> + {editingShortcut && ( + <Button + type="button" + variant="destructive" + onClick={() => onDelete(editingShortcut)} + > + Delete + </Button> + )} + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ); +}; + +export const MaxShortcutsReachedModal = ({ + onClose, +}: { + onClose: () => void; +}) => { + return ( + <Modal + width="max-w-md" + title="Maximum Shortcuts Reached" + onOutsideClick={onClose} + > + <div className="flex flex-col gap-4"> + <p className="text-left text-neutral-900"> + You've reached the maximum limit of 8 shortcuts. To add a new + shortcut, please remove an existing one. + </p> + <Button onClick={onClose}>Close</Button> + </div> + </Modal> + ); +}; diff --git a/web/src/components/header/LogoWithText.tsx b/web/src/components/header/LogoWithText.tsx index 90417cf67..c469f231e 100644 --- a/web/src/components/header/LogoWithText.tsx +++ b/web/src/components/header/LogoWithText.tsx @@ -119,7 +119,7 @@ export default function LogoWithText({ <Tooltip> <TooltipTrigger asChild> <button - className="mr-3 my-auto ml-auto" + className="mr-3 my-auto ml-auto" onClick={() => { toggleSidebar(); if (toggled) { @@ -138,7 +138,7 @@ export default function LogoWithText({ /> </button> </TooltipTrigger> - <TooltipContent> + <TooltipContent className="!border-none"> {toggled ? `Unpin sidebar` : "Pin sidebar"} </TooltipContent> </Tooltip> diff --git a/web/src/components/icons/icons.tsx b/web/src/components/icons/icons.tsx index 15320d170..dd8b425ca 100644 --- a/web/src/components/icons/icons.tsx +++ b/web/src/components/icons/icons.tsx @@ -2799,3 +2799,24 @@ export const AirtableIcon = ({ </div> ); }; + +export const QuestionMarkIcon = ({ + size = 16, + className = defaultTailwindCSS, +}: IconProps) => ( + <svg + style={{ width: `${size}px`, height: `${size}px` }} + className={`w-[${size}px] h-[${size}px] ` + className} + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <circle cx="12" cy="12" r="10" /> + <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /> + <line x1="12" y1="17" x2="12.01" y2="17" /> + </svg> +); diff --git a/web/src/components/initialSetup/welcome/WelcomeModalWrapper.tsx b/web/src/components/initialSetup/welcome/WelcomeModalWrapper.tsx index 3deaab065..5ca97fb6a 100644 --- a/web/src/components/initialSetup/welcome/WelcomeModalWrapper.tsx +++ b/web/src/components/initialSetup/welcome/WelcomeModalWrapper.tsx @@ -1,4 +1,3 @@ -import { cookies } from "next/headers"; import { _CompletedWelcomeFlowDummyComponent, _WelcomeModal, diff --git a/web/src/components/llm/ApiKeyModal.tsx b/web/src/components/llm/ApiKeyModal.tsx index 775c6ac9c..af37098ea 100644 --- a/web/src/components/llm/ApiKeyModal.tsx +++ b/web/src/components/llm/ApiKeyModal.tsx @@ -10,7 +10,7 @@ export const ApiKeyModal = ({ hide, setPopup, }: { - hide: () => void; + hide?: () => void; setPopup: (popup: PopupSpec) => void; }) => { const router = useRouter(); @@ -28,18 +28,25 @@ export const ApiKeyModal = ({ <Modal title="Configure a Generative AI Model" width="max-w-3xl w-full" - onOutsideClick={() => hide()} + onOutsideClick={hide ? () => hide() : undefined} > <> <div className="mb-5 text-sm text-gray-700"> Please provide an API Key – you can always change this or switch models later. <br /> - If you would rather look around first, you can{" "} - <strong onClick={() => hide()} className="text-link cursor-pointer"> - skip this step - </strong> - . + {hide && ( + <> + If you would rather look around first, you can{" "} + <strong + onClick={() => hide()} + className="text-link cursor-pointer" + > + skip this step + </strong> + . + </> + )} </div> <ApiKeyForm @@ -47,7 +54,7 @@ export const ApiKeyModal = ({ onSuccess={() => { router.refresh(); refreshProviderInfo(); - hide(); + hide?.(); }} providerOptions={providerOptions} /> diff --git a/web/src/components/popup/PopupFromQuery.tsx b/web/src/components/popup/PopupFromQuery.tsx index 71834f699..6c3cde6fc 100644 --- a/web/src/components/popup/PopupFromQuery.tsx +++ b/web/src/components/popup/PopupFromQuery.tsx @@ -14,8 +14,10 @@ export const usePopupFromQuery = (messages: PopupMessages) => { useEffect(() => { const searchParams = new URLSearchParams(window.location.search); + // Get the value for search param with key "message" const messageValue = searchParams.get("message"); + // Check if any key from messages object is present in search params if (messageValue && messageValue in messages) { const popupMessage = messages[messageValue]; diff --git a/web/src/components/search/filtering/FilterDropdown.tsx b/web/src/components/search/filtering/FilterDropdown.tsx index 431c92fbb..a7f95420d 100644 --- a/web/src/components/search/filtering/FilterDropdown.tsx +++ b/web/src/components/search/filtering/FilterDropdown.tsx @@ -5,6 +5,7 @@ interface Option { key: string; display: string | JSX.Element; displayName?: string; + icon?: JSX.Element; } export function FilterDropdown({ options, @@ -65,6 +66,7 @@ export function FilterDropdown({ flex-none w-fit text-emphasis + items-center gap-x-1 ${dropdownColor || "bg-background"} hover:bg-hover @@ -80,6 +82,7 @@ export function FilterDropdown({ event.stopPropagation(); }} > + {option.icon} {option.display} {isSelected && ( <div className="ml-auto my-auto mr-1"> diff --git a/web/src/components/search/filtering/HorizontalSourceSelector.tsx b/web/src/components/search/filtering/HorizontalSourceSelector.tsx new file mode 100644 index 000000000..da59fd01e --- /dev/null +++ b/web/src/components/search/filtering/HorizontalSourceSelector.tsx @@ -0,0 +1,226 @@ +import React from "react"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover"; // shadcn popover +import { FiBook, FiMap, FiTag, FiCalendar } from "react-icons/fi"; +import { SourceMetadata } from "@/lib/search/interfaces"; +import { Calendar } from "@/components/ui/calendar"; // or wherever your Calendar component lives +import { FilterDropdown } from "@/components/search/filtering/FilterDropdown"; +import { listSourceMetadata } from "@/lib/sources"; +import { getDateRangeString } from "@/lib/dateUtils"; +import { DateRangePickerValue } from "../../../app/ee/admin/performance/DateRangeSelector"; +import { Tag } from "@/lib/types"; +import { SourceIcon } from "@/components/SourceIcon"; +export interface SourceSelectorProps { + timeRange: DateRangePickerValue | null; + setTimeRange: React.Dispatch< + React.SetStateAction<DateRangePickerValue | null> + >; + selectedSources: SourceMetadata[]; + setSelectedSources: React.Dispatch<React.SetStateAction<SourceMetadata[]>>; + selectedDocumentSets: string[]; + setSelectedDocumentSets: React.Dispatch<React.SetStateAction<string[]>>; + selectedTags: Tag[]; + setSelectedTags: React.Dispatch<React.SetStateAction<Tag[]>>; + existingSources: string[]; // e.g. list of internalName that exist + availableDocumentSets: { name: string }[]; + availableTags: Tag[]; +} + +export function HorizontalSourceSelector({ + timeRange, + setTimeRange, + selectedSources, + setSelectedSources, + selectedDocumentSets, + setSelectedDocumentSets, + selectedTags, + setSelectedTags, + existingSources, + availableDocumentSets, + availableTags, +}: SourceSelectorProps) { + const handleSourceSelect = (source: SourceMetadata) => { + setSelectedSources((prev: SourceMetadata[]) => { + if (prev.map((s) => s.internalName).includes(source.internalName)) { + return prev.filter((s) => s.internalName !== source.internalName); + } else { + return [...prev, source]; + } + }); + }; + + const handleDocumentSetSelect = (documentSetName: string) => { + setSelectedDocumentSets((prev: string[]) => { + if (prev.includes(documentSetName)) { + return prev.filter((s) => s !== documentSetName); + } else { + return [...prev, documentSetName]; + } + }); + }; + + const handleTagSelect = (tag: Tag) => { + setSelectedTags((prev: Tag[]) => { + if ( + prev.some( + (t) => t.tag_key === tag.tag_key && t.tag_value === tag.tag_value + ) + ) { + return prev.filter( + (t) => !(t.tag_key === tag.tag_key && t.tag_value === tag.tag_value) + ); + } else { + return [...prev, tag]; + } + }); + }; + + const resetSources = () => { + setSelectedSources([]); + }; + + const resetDocuments = () => { + setSelectedDocumentSets([]); + }; + + const resetTags = () => { + setSelectedTags([]); + }; + + return ( + <div className="flex flex-row flex-wrap items-center space-x-2"> + {/* Date Range Popover */} + <Popover> + <PopoverTrigger asChild> + <button + className=" + flex items-center space-x-1 border + border-border rounded-lg px-3 py-1.5 + hover:bg-hover text-sm cursor-pointer + bg-background-search-filter + " + > + <FiCalendar size={14} /> + <span> + {timeRange?.from + ? getDateRangeString(timeRange.from, timeRange.to) + : "Date Range"} + </span> + </button> + </PopoverTrigger> + <PopoverContent + className="bg-background-search-filter border border-border rounded-md z-[200] p-2" + align="start" + > + <Calendar + mode="range" + selected={ + timeRange + ? { from: new Date(timeRange.from), to: new Date(timeRange.to) } + : undefined + } + onSelect={(daterange) => { + const initialDate = daterange?.from || new Date(); + const endDate = daterange?.to || new Date(); + setTimeRange({ + from: initialDate, + to: endDate, + selectValue: timeRange?.selectValue || "", + }); + }} + className="rounded-md" + /> + </PopoverContent> + </Popover> + + {/* Sources Popover */} + {existingSources.length > 0 && ( + <FilterDropdown + icon={<FiMap size={14} />} + backgroundColor="bg-background-search-filter" + dropdownColor="bg-background-search-filter-dropdown" + dropdownWidth="w-40" + defaultDisplay="Sources" + resetValues={resetSources} + width="w-fit" + options={listSourceMetadata() + .filter((source) => existingSources.includes(source.internalName)) + .map((source) => ({ + icon: ( + <SourceIcon sourceType={source.internalName} iconSize={14} /> + ), + key: source.internalName, + display: ( + <span className="flex items-center space-x-2"> + <span>{source.displayName}</span> + </span> + ), + }))} + optionClassName="truncate w-full break-all" + selected={selectedSources.map((src) => src.internalName)} + handleSelect={(option) => { + const s = listSourceMetadata().find( + (m) => m.internalName === option.key + ); + if (s) handleSourceSelect(s); + }} + /> + )} + + {/* Document Sets Popover */} + {availableDocumentSets.length > 0 && ( + <FilterDropdown + icon={<FiBook size={14} />} + backgroundColor="bg-background-search-filter" + dropdownColor="bg-background-search-filter-dropdown" + dropdownWidth="w-40" + defaultDisplay="Sets" + resetValues={resetDocuments} + width="w-fit" + options={availableDocumentSets.map((docSet) => ({ + key: docSet.name, + display: <>{docSet.name}</>, + }))} + optionClassName="truncate w-full break-all" + selected={selectedDocumentSets} + handleSelect={(option) => handleDocumentSetSelect(option.key)} + /> + )} + + {/* Tags Popover */} + {availableTags.length > 0 && ( + <FilterDropdown + icon={<FiTag size={14} />} + backgroundColor="bg-background-search-filter" + dropdownColor="bg-background-search-filter-dropdown" + dropdownWidth="w-64" + defaultDisplay="Tags" + resetValues={resetTags} + width="w-fit" + options={availableTags.map((tag) => ({ + key: `${tag.tag_key}=${tag.tag_value}`, + display: ( + <span className="text-sm"> + {tag.tag_key} + <b>=</b> + {tag.tag_value} + </span> + ), + }))} + optionClassName="truncate w-full break-all" + selected={selectedTags.map((t) => `${t.tag_key}=${t.tag_value}`)} + handleSelect={(option) => { + const [tKey, tValue] = option.key.split("="); + const foundTag = availableTags.find( + (tg) => tg.tag_key === tKey && tg.tag_value === tValue + ); + if (foundTag) handleTagSelect(foundTag); + }} + /> + )} + </div> + ); +} diff --git a/web/src/components/search/results/Citation.tsx b/web/src/components/search/results/Citation.tsx index 941136863..79d969a0d 100644 --- a/web/src/components/search/results/Citation.tsx +++ b/web/src/components/search/results/Citation.tsx @@ -12,14 +12,12 @@ import { openDocument } from "@/lib/search/utils"; export function Citation({ children, - link, document, index, updatePresentingDocument, icon, url, }: { - link?: string; children?: JSX.Element | string | null | ReactNode; index?: number; updatePresentingDocument: (document: OnyxDocument) => void; diff --git a/web/src/components/tooltip/CustomTooltip.tsx b/web/src/components/tooltip/CustomTooltip.tsx index 001b9fdf5..669fe811a 100644 --- a/web/src/components/tooltip/CustomTooltip.tsx +++ b/web/src/components/tooltip/CustomTooltip.tsx @@ -48,6 +48,7 @@ export const CustomTooltip = ({ delay = 500, position = "bottom", disabled = false, + className, }: { medium?: boolean; content: string | ReactNode; @@ -61,6 +62,7 @@ export const CustomTooltip = ({ citation?: boolean; position?: "top" | "bottom"; disabled?: boolean; + className?: string; }) => { const [isVisible, setIsVisible] = useState(false); const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 }); @@ -115,7 +117,7 @@ export const CustomTooltip = ({ <> <span ref={triggerRef} - className="relative inline-block" + className={`relative inline-block ${className}`} onMouseEnter={showTooltip} onMouseLeave={hideTooltip} > @@ -125,9 +127,11 @@ export const CustomTooltip = ({ !disabled && createPortal( <div - className={`min-w-8 fixed z-[1000] ${ - citation ? "max-w-[350px]" : "w-40" - } ${large ? (medium ? "w-88" : "w-96") : line && "max-w-64 w-auto"} + className={`min-w-8 fixed z-[1000] + ${className} + ${citation ? "max-w-[350px]" : "w-40"} ${ + large ? (medium ? "w-88" : "w-96") : line && "max-w-64 w-auto" + } transform -translate-x-1/2 text-sm ${ light diff --git a/web/src/components/ui/label.tsx b/web/src/components/ui/label.tsx new file mode 100644 index 000000000..40378d43a --- /dev/null +++ b/web/src/components/ui/label.tsx @@ -0,0 +1,26 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +); + +const Label = React.forwardRef< + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & + VariantProps<typeof labelVariants> +>(({ className, ...props }, ref) => ( + <LabelPrimitive.Root + ref={ref} + className={cn(labelVariants(), className)} + {...props} + /> +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/web/src/components/ui/radio-group.tsx b/web/src/components/ui/radio-group.tsx new file mode 100644 index 000000000..62bfe37f4 --- /dev/null +++ b/web/src/components/ui/radio-group.tsx @@ -0,0 +1,44 @@ +"use client"; + +import * as React from "react"; +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { Circle } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const RadioGroup = React.forwardRef< + React.ElementRef<typeof RadioGroupPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> +>(({ className, ...props }, ref) => { + return ( + <RadioGroupPrimitive.Root + className={cn("grid gap-2", className)} + {...props} + ref={ref} + /> + ); +}); +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; + +const RadioGroupItem = React.forwardRef< + React.ElementRef<typeof RadioGroupPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> +>(({ className, ...props }, ref) => { + return ( + <RadioGroupPrimitive.Item + ref={ref} + className={cn( + "aspect-square h-4 w-4 rounded-full border border-neutral-200 border-neutral-900 text-neutral-900 ring-offset-white focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-800 dark:border-neutral-50 dark:text-neutral-50 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300", + className + )} + {...props} + > + <RadioGroupPrimitive.Indicator className="flex items-center justify-center"> + <Circle className="h-2.5 w-2.5 fill-current text-current" /> + </RadioGroupPrimitive.Indicator> + </RadioGroupPrimitive.Item> + ); +}); +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; + +export { RadioGroup, RadioGroupItem }; diff --git a/web/src/components/ui/switch.tsx b/web/src/components/ui/switch.tsx index 3a9298a76..0ff41a528 100644 --- a/web/src/components/ui/switch.tsx +++ b/web/src/components/ui/switch.tsx @@ -7,8 +7,10 @@ import { cn } from "@/lib/utils"; const Switch = React.forwardRef< React.ElementRef<typeof SwitchPrimitives.Root>, - React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> ->(({ className, ...props }, ref) => ( + React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> & { + circleClassName?: string; + } +>(({ circleClassName, className, ...props }, ref) => ( <SwitchPrimitives.Root className={cn( "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950 dark:data-[state=checked]:bg-neutral-50 dark:data-[state=unchecked]:bg-neutral-800", @@ -19,7 +21,8 @@ const Switch = React.forwardRef< > <SwitchPrimitives.Thumb className={cn( - "pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 dark:bg-neutral-950" + "pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 dark:bg-neutral-950", + circleClassName )} /> </SwitchPrimitives.Root> diff --git a/web/src/lib/chat/fetchChatData.ts b/web/src/lib/chat/fetchChatData.ts index 1306f9c02..27596ed86 100644 --- a/web/src/lib/chat/fetchChatData.ts +++ b/web/src/lib/chat/fetchChatData.ts @@ -24,7 +24,10 @@ import { DOCUMENT_SIDEBAR_WIDTH_COOKIE_NAME, } from "@/components/resizable/constants"; import { hasCompletedWelcomeFlowSS } from "@/components/initialSetup/welcome/WelcomeModalWrapper"; -import { NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN } from "../constants"; +import { + NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN, + NEXT_PUBLIC_ENABLE_CHROME_EXTENSION, +} from "../constants"; import { redirect } from "next/navigation"; interface FetchChatDataResult { @@ -98,7 +101,9 @@ export async function fetchChatData(searchParams: { ? `${fullUrl}?${searchParamsString}` : fullUrl; - return redirect(`/auth/login?next=${encodeURIComponent(redirectUrl)}`); + if (!NEXT_PUBLIC_ENABLE_CHROME_EXTENSION) { + return redirect(`/auth/login?next=${encodeURIComponent(redirectUrl)}`); + } } if (user && !user.is_verified && authTypeMetadata?.requiresVerification) { diff --git a/web/src/lib/chat/fetchSomeChatData.ts b/web/src/lib/chat/fetchSomeChatData.ts index b363e233b..fb1dc5b40 100644 --- a/web/src/lib/chat/fetchSomeChatData.ts +++ b/web/src/lib/chat/fetchSomeChatData.ts @@ -91,6 +91,7 @@ export async function fetchSomeChatData( const authDisabled = authTypeMetadata?.authType === "disabled"; let user: User | null = null; + if (fetchOptions.includes("user")) { user = results.shift(); if (!authDisabled && !user) { diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 20751fef0..d9b3f60b9 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -87,5 +87,8 @@ export const NEXT_PUBLIC_TEST_ENV = export const NEXT_PUBLIC_DELETE_ALL_CHATS_ENABLED = process.env.NEXT_PUBLIC_DELETE_ALL_CHATS_ENABLED?.toLowerCase() === "true"; -export const NEXT_PUBLIC_CLOUD_DOMAIN = - process.env.NEXT_PUBLIC_CLOUD_DOMAIN || "http://127.0.0.1:3000"; +export const NEXT_PUBLIC_ENABLE_CHROME_EXTENSION = + process.env.NEXT_PUBLIC_ENABLE_CHROME_EXTENSION?.toLowerCase() === "true"; + +export const NEXT_PUBLIC_WEB_DOMAIN = + process.env.NEXT_PUBLIC_WEB_DOMAIN || "http://127.0.0.1:3000"; diff --git a/web/src/lib/dateUtils.ts b/web/src/lib/dateUtils.ts index 57ea060e0..2f2de7038 100644 --- a/web/src/lib/dateUtils.ts +++ b/web/src/lib/dateUtils.ts @@ -1,4 +1,23 @@ -import { DateRangePickerValue } from "@/app/ee/admin/performance/DateRangeSelector"; +import { useEffect } from "react"; +import { useState } from "react"; + +export const useNightTime = () => { + const [isNight, setIsNight] = useState(false); + + useEffect(() => { + const checkNightTime = () => { + const currentHour = new Date().getHours(); + setIsNight(currentHour >= 18 || currentHour < 6); + }; + + checkNightTime(); + const interval = setInterval(checkNightTime, 60000); // Check every minute + + return () => clearInterval(interval); + }, []); + + return { isNight }; +}; export function getXDaysAgo(daysAgo: number) { const today = new Date(); diff --git a/web/src/lib/extension/constants.ts b/web/src/lib/extension/constants.ts new file mode 100644 index 000000000..2be72db1c --- /dev/null +++ b/web/src/lib/extension/constants.ts @@ -0,0 +1,33 @@ +export const darkExtensionImages = [ + "https://images.unsplash.com/photo-1692520883599-d543cfe6d43d?q=80&w=2666&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + "https://images.unsplash.com/photo-1520330461350-508fab483d6a?q=80&w=2723&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", +]; + +export const lightExtensionImages = [ + "https://images.unsplash.com/photo-1473830439578-14e9a9e61d55?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + "https://images.unsplash.com/photo-1500964757637-c85e8a162699?q=80&w=2703&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + "https://images.unsplash.com/photo-1475924156734-496f6cac6ec1?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", +]; + +// Chrome message types +export const CHROME_MESSAGE = { + PREFERENCES_UPDATED: "PREFERENCES_UPDATED", + ONYX_APP_LOADED: "ONYX_APP_LOADED", + SET_DEFAULT_NEW_TAB: "SET_DEFAULT_NEW_TAB", + LOAD_NEW_CHAT_PAGE: "LOAD_NEW_CHAT_PAGE", + LOAD_NEW_PAGE: "LOAD_NEW_PAGE", + AUTH_REQUIRED: "AUTH_REQUIRED", +}; + +export const SUBMIT_MESSAGE_TYPES = { + PAGE_CHANGE: "PAGE_CHANGE", +}; + +export const LocalStorageKeys = { + THEME: "onyxTheme", + LIGHT_BG_URL: "lightBgUrl", + DARK_BG_URL: "darkBgUrl", + SHORTCUTS: "shortCuts", + SHOW_SHORTCUTS: "showShortcuts", + USE_ONYX_AS_NEW_TAB: "useOnyxAsDefaultNewTab", +}; diff --git a/web/src/lib/extension/utils.ts b/web/src/lib/extension/utils.ts new file mode 100644 index 000000000..1e5cea2be --- /dev/null +++ b/web/src/lib/extension/utils.ts @@ -0,0 +1,51 @@ +import { useEffect } from "react"; +import { CHROME_MESSAGE } from "./constants"; +export function sendSetDefaultNewTabMessage(value: boolean) { + if (typeof window !== "undefined" && window.parent) { + window.parent.postMessage( + { type: CHROME_MESSAGE.SET_DEFAULT_NEW_TAB, value }, + "*" + ); + } +} + +export const sendAuthRequiredMessage = () => { + if (typeof window !== "undefined" && window.parent) { + window.parent.postMessage({ type: CHROME_MESSAGE.AUTH_REQUIRED }, "*"); + } +}; + +export const useSendAuthRequiredMessage = () => { + useEffect(() => { + sendAuthRequiredMessage(); + }, []); +}; + +export const sendMessageToParent = () => { + if (typeof window !== "undefined" && window.parent) { + window.parent.postMessage({ type: CHROME_MESSAGE.ONYX_APP_LOADED }, "*"); + } +}; +export const useSendMessageToParent = () => { + useEffect(() => { + sendMessageToParent(); + }, []); +}; + +export function notifyExtensionOfThemeChange( + newTheme: string, + newBgUrl: string +) { + if (typeof window !== "undefined" && window.parent) { + window.parent.postMessage( + { + type: CHROME_MESSAGE.PREFERENCES_UPDATED, + payload: { + theme: newTheme, + backgroundUrl: newBgUrl, + }, + }, + "*" + ); + } +} diff --git a/web/src/lib/hooks.ts b/web/src/lib/hooks.ts index 81f425f0d..caad9cb64 100644 --- a/web/src/lib/hooks.ts +++ b/web/src/lib/hooks.ts @@ -7,12 +7,13 @@ import { UserGroup, ConnectorStatus, CCPairBasicInfo, + ValidSources, } from "@/lib/types"; import useSWR, { mutate, useSWRConfig } from "swr"; import { errorHandlingFetcher } from "./fetcher"; import { useContext, useEffect, useState } from "react"; import { DateRangePickerValue } from "@/app/ee/admin/performance/DateRangeSelector"; -import { SourceMetadata } from "./search/interfaces"; +import { Filters, SourceMetadata } from "./search/interfaces"; import { destructureValue, structureValue } from "./llm/utils"; import { ChatSession } from "@/app/chat/interfaces"; import { AllUsersResponse } from "./types"; @@ -24,6 +25,8 @@ import { LLMProviderDescriptor, } from "@/app/admin/configuration/llm/interfaces"; import { isAnthropic } from "@/app/admin/configuration/llm/interfaces"; +import { getSourceMetadata } from "./sources"; +import { buildFilters } from "./search/utils"; const CREDENTIAL_URL = "/api/manage/admin/credential"; @@ -147,6 +150,13 @@ export interface FilterManager { setSelectedDocumentSets: React.Dispatch<React.SetStateAction<string[]>>; selectedTags: Tag[]; setSelectedTags: React.Dispatch<React.SetStateAction<Tag[]>>; + getFilterString: () => string; + buildFiltersFromQueryString: ( + filterString: string, + availableSources: ValidSources[], + availableDocumentSets: string[], + availableTags: Tag[] + ) => void; } export function useFilters(): FilterManager { @@ -157,6 +167,97 @@ export function useFilters(): FilterManager { ); const [selectedTags, setSelectedTags] = useState<Tag[]>([]); + const getFilterString = () => { + const params = new URLSearchParams(); + + if (timeRange) { + params.set("from", timeRange.from.toISOString()); + params.set("to", timeRange.to.toISOString()); + } + + if (selectedSources.length > 0) { + const sourcesParam = selectedSources + .map((source) => encodeURIComponent(source.internalName)) + .join(","); + params.set("sources", sourcesParam); + } + + if (selectedDocumentSets.length > 0) { + const docSetsParam = selectedDocumentSets + .map((ds) => encodeURIComponent(ds)) + .join(","); + params.set("documentSets", docSetsParam); + } + + if (selectedTags.length > 0) { + const tagsParam = selectedTags + .map((tag) => encodeURIComponent(tag.tag_value)) + .join(","); + params.set("tags", tagsParam); + } + + const queryString = params.toString(); + return queryString ? `&${queryString}` : ""; + }; + + function buildFiltersFromQueryString( + filterString: string, + availableSources: ValidSources[], + availableDocumentSets: string[], + availableTags: Tag[] + ): void { + const params = new URLSearchParams(filterString); + + // Parse the "from" parameter as a DateRangePickerValue + let newTimeRange: DateRangePickerValue | null = null; + const fromParam = params.get("from"); + const toParam = params.get("to"); + if (fromParam && toParam) { + const fromDate = new Date(fromParam); + const toDate = new Date(toParam); + if (!isNaN(fromDate.getTime()) && !isNaN(toDate.getTime())) { + newTimeRange = { from: fromDate, to: toDate, selectValue: "" }; + } + } + + // Parse sources + const availableSourcesMetadata = availableSources.map(getSourceMetadata); + let newSelectedSources: SourceMetadata[] = []; + const sourcesParam = params.get("sources"); + if (sourcesParam) { + const sourceNames = sourcesParam.split(",").map(decodeURIComponent); + newSelectedSources = availableSourcesMetadata.filter((source) => + sourceNames.includes(source.internalName) + ); + } + + // Parse document sets + let newSelectedDocSets: string[] = []; + const docSetsParam = params.get("documentSets"); + if (docSetsParam) { + const docSetNames = docSetsParam.split(",").map(decodeURIComponent); + newSelectedDocSets = availableDocumentSets.filter((ds) => + docSetNames.includes(ds) + ); + } + + // Parse tags + let newSelectedTags: Tag[] = []; + const tagsParam = params.get("tags"); + if (tagsParam) { + const tagValues = tagsParam.split(",").map(decodeURIComponent); + newSelectedTags = availableTags.filter((tag) => + tagValues.includes(tag.tag_value) + ); + } + + // Update filter manager's values instead of returning + setTimeRange(newTimeRange); + setSelectedSources(newSelectedSources); + setSelectedDocumentSets(newSelectedDocSets); + setSelectedTags(newSelectedTags); + } + return { timeRange, setTimeRange, @@ -166,6 +267,8 @@ export function useFilters(): FilterManager { setSelectedDocumentSets, selectedTags, setSelectedTags, + getFilterString, + buildFiltersFromQueryString, }; } diff --git a/web/src/lib/search/utils.ts b/web/src/lib/search/utils.ts index 1d428c0cb..55601f9f8 100644 --- a/web/src/lib/search/utils.ts +++ b/web/src/lib/search/utils.ts @@ -1,10 +1,5 @@ import { Tag, ValidSources } from "../types"; -import { - Filters, - LoadedOnyxDocument, - OnyxDocument, - SourceMetadata, -} from "./interfaces"; +import { Filters, OnyxDocument, SourceMetadata } from "./interfaces"; import { DateRangePickerValue } from "@/app/ee/admin/performance/DateRangeSelector"; export const buildFilters = ( diff --git a/web/src/lib/sources.ts b/web/src/lib/sources.ts index a9a323c09..425c8da28 100644 --- a/web/src/lib/sources.ts +++ b/web/src/lib/sources.ts @@ -388,3 +388,26 @@ export function getSourcesForPersona(persona: Persona): ValidSources[] { }); return personaSources; } + +export async function fetchTitleFromUrl(url: string): Promise<string | null> { + try { + const response = await fetch(url, { + method: "GET", + // If the remote site has no CORS header, this may fail in the browser + mode: "cors", + }); + if (!response.ok) { + // Non-200 response, treat as a failure + return null; + } + const html = await response.text(); + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + // If the site has <title>My Demo Page</title>, we retrieve "My Demo Page" + const pageTitle = doc.querySelector("title")?.innerText.trim() ?? null; + return pageTitle; + } catch (error) { + console.error("Error fetching page title:", error); + return null; + } +}