Add chrome extension pages (#3629)

This commit is contained in:
pablonyx 2025-01-15 15:09:49 -08:00 committed by GitHub
parent 76ca650972
commit 66d8b8bb10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 2639 additions and 282 deletions

View File

@ -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=()",
},

307
web/package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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!",

View File

@ -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&apos;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>
);
}

View File

@ -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}

View File

@ -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>
);

View File

@ -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} />
)}

View File

@ -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={

View File

@ -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}
/>
)}
/>
);

View File

@ -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 ${

View File

@ -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">

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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}
>

View File

@ -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">

View File

@ -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);
}}

View File

@ -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&apos;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>
);
}

View File

@ -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&apos;ll see your browser&apos;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>
);
}

View File

@ -0,0 +1,5 @@
export interface Shortcut {
name: string;
url: string;
favicon?: string;
}

View File

@ -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>
);
}

View File

@ -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} />;
}

View File

@ -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",

View File

@ -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>
);
};

View File

@ -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>
);
};

0
web/src/app/ee/Hori Normal file
View File

View File

@ -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";

View File

@ -33,6 +33,7 @@ export function AssistantIcon({
return (
<CustomTooltip
className="hidden lg:block"
disabled={disableToolip || !assistant.description}
showTick
line

View File

@ -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, {

View File

@ -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]);

View File

@ -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;
}

View File

@ -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&apos;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>
);
};

View File

@ -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>

View File

@ -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>
);

View File

@ -1,4 +1,3 @@
import { cookies } from "next/headers";
import {
_CompletedWelcomeFlowDummyComponent,
_WelcomeModal,

View File

@ -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}
/>

View File

@ -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];

View File

@ -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">

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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

View File

@ -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 };

View File

@ -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 };

View File

@ -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>

View File

@ -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) {

View File

@ -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) {

View File

@ -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";

View File

@ -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();

View File

@ -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",
};

View File

@ -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,
},
},
"*"
);
}
}

View File

@ -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,
};
}

View File

@ -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 = (

View File

@ -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;
}
}