Compare commits

...

2 Commits

Author SHA1 Message Date
pablodanswer
0d52160a6f v2 2025-01-07 14:14:56 -08:00
pablodanswer
bbe9e9db74 add chrome extension
minor clean up

additional handling

post rebase fixes

nit

quick bump

finalize

minor cleanup

organizational

Revert changes in backend directory

Revert changes in deployment directory

push misc changes

improve shortcut display + general nrf page layout

minor clean up

quick nit

update chrome

k

build fix

k

update

k
2025-01-07 14:14:45 -08:00
46 changed files with 2791 additions and 233 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,16 @@ const nextConfig = {
publicRuntimeConfig: {
version,
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "www.google.com",
port: "",
pathname: "/s2/favicons/**",
},
],
},
async headers() {
return [
{
@@ -44,17 +53,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

@@ -0,0 +1,102 @@
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";
export default function LoginPanel({
authUrl,
authTypeMetadata,
nextUrl,
searchParams,
showPageRedirect,
}: {
authUrl: string | null;
authTypeMetadata: AuthTypeMetadata | null;
nextUrl: string | null;
searchParams:
| {
[key: string]: string | string[] | undefined;
}
| undefined;
showPageRedirect?: boolean;
}) {
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/register";
} else {
window.location.href = "/auth/register";
}
}}
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,20 +7,11 @@ 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 LoginPanel from "./LoginPage";
const Page = async (props: {
const LoginPage = async (props: {
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
}) => {
const searchParams = await props.searchParams;
@@ -83,58 +74,15 @@ 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>
<LoginPanel
authUrl={authUrl}
authTypeMetadata={authTypeMetadata}
nextUrl={nextUrl!}
searchParams={searchParams}
/>
</AuthFlowContainer>
</div>
);
};
export default Page;
export default LoginPage;

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,
@@ -75,6 +75,7 @@ import {
StreamStopInfo,
StreamStopReason,
} from "@/lib/search/interfaces";
import { Filters } from "@/lib/search/interfaces";
import { buildFilters } from "@/lib/search/utils";
import { SettingsContext } from "@/components/settings/SettingsProvider";
import Dropzone from "react-dropzone";
@@ -110,7 +111,7 @@ 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";
const TEMP_USER_MESSAGE_ID = -1;
const TEMP_ASSISTANT_MESSAGE_ID = -2;
@@ -120,10 +121,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 +143,7 @@ export function ChatPage({
shouldShowWelcomeModal,
refreshChatSessions,
} = useChatContext();
function useScreenSize() {
const [screenSize, setScreenSize] = useState({
width: typeof window !== "undefined" ? window.innerWidth : 0,
@@ -210,22 +214,48 @@ export function ChatPage({
toggle(false);
}
}, [user]);
const submittingLogic = (searchParamsString: string) => {
const newSearchParams = new URLSearchParams(searchParamsString);
console.log("newSearchParams", newSearchParams);
console.log("searchParamsString", searchParamsString);
const message = newSearchParams.get("user-prompt");
newSearchParams.delete(SEARCH_PARAM_NAMES.SEND_ON_LOAD);
filterManager.buildFiltersFromQueryString(
newSearchParams.toString(),
availableSources,
documentSets.map((ds) => ds.name),
tags
);
const fileDescriptorString = newSearchParams.get("files");
let overrideFileDescriptors: FileDescriptor[] = [];
if (fileDescriptorString) {
try {
overrideFileDescriptors = JSON.parse(
decodeURIComponent(fileDescriptorString)
);
} catch (error) {
console.error("Error parsing file descriptors:", error);
}
}
// Update the URL without the send-on-load parameter
router.replace(`?${newSearchParams.toString()}`, { scroll: false });
// Update our local state to reflect the change
setSendOnLoad(null);
// If there's a message, submit it
if (message) {
setSubmittedMessage(message);
onSubmit({ messageOverride: message, overrideFileDescriptors });
}
};
// 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 });
// Update our local state to reflect the change
setSendOnLoad(null);
// If there's a message, submit it
if (message) {
onSubmit({ messageOverride: message });
}
submittingLogic(sendOnLoad);
}
}, [sendOnLoad, searchParams, router]);
@@ -312,14 +342,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(() => {
@@ -401,6 +423,9 @@ export function ChatPage({
// this is triggered every time the user switches which chat
// session they are using
const [chromSentUrls, setchromSentUrls] = useState<string[]>([]);
const [selectedChromeUrls, setSelectedChromeUrls] = useState<string[]>([]);
useEffect(() => {
const priorChatSessionId = chatSessionIdRef.current;
const loadedSessionId = loadedIdSessionRef.current;
@@ -456,7 +481,7 @@ export function ChatPage({
}
return;
}
setIsReady(true);
// setIsReady(true);
const shouldScrollToBottom =
visibleRange.get(existingChatSessionId) === undefined ||
visibleRange.get(existingChatSessionId)?.end == 0;
@@ -651,10 +676,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 +823,17 @@ export function ChatPage({
}
}, [defaultAssistantId, availableAssistants, messageHistory.length]);
useEffect(() => {
if (
submittedMessage &&
currentSessionChatState === "loading" &&
messageHistory.length == 0
) {
console.log("TRYING TO LOAD NEW CHAT PAGE", submittedMessage);
window.parent.postMessage({ type: "LOAD_NEW_CHAT_PAGE" }, "*");
}
}, [submittedMessage, currentSessionChatState]);
const [
selectedDocuments,
toggleDocumentSelection,
@@ -1001,6 +1037,34 @@ export function ChatPage({
adjustDocumentSidebarWidth(); // Adjust the width on initial render
window.addEventListener("resize", adjustDocumentSidebarWidth); // Add resize event listener
window.addEventListener("message", (event) => {
if (event.data.type === "LOAD_NEW_PAGE") {
console.log("Received LOAD_NEW_PAGE event:", event.data);
const { href } = event.data;
const url = new URL(href);
// const userPrompt = url.searchParams.get("user-prompt");
// {
// "type": "LOAD_NEW_PAGE",
// "href": "http://localhost:3000/chat?send-on-load=true&user-prompt=hi"
// }
console.log(event.data);
console.log("url.searchParams", url.searchParams);
// if (userPrompt) {
submittingLogic(url.searchParams.toString());
// setSubmittedMessage(userPrompt);
// updateChatState("loading");
// }
// Handle the new page load
// This might involve updating the application's route or state
// console.log("Loading new page:", href);
// Implement your page loading logic here, e.g., updating the URL
// router.push(href, undefined, { shallow: true });
}
});
return () => {
window.removeEventListener("resize", adjustDocumentSidebarWidth); // Cleanup the event listener
};
@@ -1078,6 +1142,7 @@ export function ChatPage({
alternativeAssistantOverride = null,
modelOverRide,
regenerationRequest,
overrideFileDescriptors,
}: {
messageIdToResend?: number;
messageOverride?: string;
@@ -1087,6 +1152,7 @@ export function ChatPage({
alternativeAssistantOverride?: Persona | null;
modelOverRide?: LlmOverride;
regenerationRequest?: RegenerationRequest | null;
overrideFileDescriptors?: FileDescriptor[];
} = {}) => {
let frozenSessionId = currentSessionId();
updateCanContinue(false, frozenSessionId);
@@ -1113,6 +1179,7 @@ export function ChatPage({
let currChatSessionId: string;
const isNewSession = chatSessionIdRef.current === null;
const searchParamBasedChatSessionName =
searchParams.get(SEARCH_PARAM_NAMES.TITLE) || null;
@@ -1228,7 +1295,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 +1882,7 @@ export function ChatPage({
end: 0,
mostVisibleMessageId: null,
};
useSendMessageToParent();
useEffect(() => {
if (noAssistants) {
@@ -1889,6 +1957,7 @@ export function ChatPage({
handleSlackChatRedirect();
}, [searchParams, router]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey) {
@@ -1957,6 +2026,10 @@ export function ChatPage({
});
};
}
if (!user) {
redirect("/auth/login");
}
if (noAssistants)
return (
<>
@@ -2039,7 +2112,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}
@@ -2246,10 +2323,14 @@ export function ChatPage({
/>
)}
{documentSidebarInitialWidth !== undefined && isReady ? (
<Dropzone onDrop={handleImageUpload} noClick>
{true ? (
<Dropzone
key={currentSessionId()}
onDrop={handleImageUpload}
noClick
>
{({ getRootProps }) => (
<div className="flex h-full w-full">
<div key={10} className="flex h-full w-full">
{!settings?.isMobile && (
<div
style={{ transition: "width 0.30s ease-out" }}
@@ -2327,7 +2408,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 +2426,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 +2444,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 +2857,34 @@ export function ChatPage({
</div>
)}
<ChatInputBar
llmOverrideManager={llmOverrideManager}
selectChromeUrl={(chromeUrl: string) => {
setSelectedChromeUrls([
...selectedChromeUrls,
chromeUrl,
]);
setchromSentUrls((chromSentUrls: string[]) =>
chromSentUrls.filter(
(url) => url !== chromeUrl
)
);
}}
selectedChromeUrls={selectedChromeUrls}
removeSelectedChromeUrl={(chromeUrl: string) => {
setSelectedChromeUrls(
selectedChromeUrls.filter(
(url) => url !== chromeUrl
)
);
}}
chromSentUrls={chromSentUrls}
removeChromeSentUrls={(chromSentUrl: string) => {
setchromSentUrls(
chromSentUrls.filter(
(url) => url !== chromSentUrl
)
);
}}
removeDocs={() => {
clearSelectedDocuments();
}}

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,10 +31,71 @@ 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;
@@ -46,6 +107,7 @@ interface ChatInputBarProps {
stopGenerating: () => void;
onSubmit: () => void;
filterManager: FilterManager;
llmOverrideManager: LlmOverrideManager;
chatState: ChatState;
alternativeAssistant: Persona | null;
// assistants
@@ -57,9 +119,17 @@ interface ChatInputBarProps {
handleFileUpload: (files: File[]) => void;
textAreaRef: React.RefObject<HTMLTextAreaElement>;
toggleFilters?: () => void;
chromSentUrls?: string[];
removeChromeSentUrls: (chromSentUrl: string) => void;
selectedChromeUrls?: string[];
removeSelectedChromeUrl: (selectedChromeUrl: string) => void;
selectChromeUrl: (chromeUrl: string) => void;
}
export function ChatInputBar({
chromSentUrls,
selectedChromeUrls,
removeSelectedChromeUrl,
removeDocs,
openModelSettings,
showDocs,
@@ -69,6 +139,7 @@ export function ChatInputBar({
setMessage,
stopGenerating,
onSubmit,
removeChromeSentUrls,
filterManager,
chatState,
@@ -82,6 +153,7 @@ export function ChatInputBar({
textAreaRef,
alternativeAssistant,
toggleFilters,
selectChromeUrl,
}: ChatInputBarProps) {
useEffect(() => {
const textarea = textAreaRef.current;
@@ -217,6 +289,26 @@ export function ChatInputBar({
}
};
// We'll store dynamic titles in state, keyed by URL
const [fetchedTitles, setFetchedTitles] = useState<Record<string, string>>(
{}
);
useEffect(() => {
if (!chromSentUrls) return;
chromSentUrls.forEach((url) => {
// Already have it? Skip
if (fetchedTitles[url]) return;
fetchTitleFromUrl(url).then((title: string | null) => {
if (title) {
setFetchedTitles((prev) => ({ ...prev, [url]: title }));
}
});
});
}, [chromSentUrls, fetchedTitles]);
return (
<div id="onyx-chat-input">
<div className="flex justify-center mx-auto">
@@ -228,6 +320,35 @@ export function ChatInputBar({
mx-auto
"
>
{(chromSentUrls || selectedChromeUrls) && (
<div className="absolute inset-x-0 top-0 w-fit flex gap-x-1 gap-y-1 flex-wrap transform -translate-y-full">
{selectedChromeUrls &&
selectedChromeUrls.map((url, index) => (
<SelectedUrlChip
key={index}
url={url}
onRemove={removeSelectedChromeUrl}
/>
))}
{chromSentUrls &&
chromSentUrls.map((url, index) => {
const parsedUrl = new URL(url);
const displayTitle = fetchedTitles[url] || parsedUrl.hostname;
return (
<SentUrlChip
key={index}
title={displayTitle}
onClick={() => {
selectChromeUrl(url);
}}
url={url}
onRemove={removeChromeSentUrls}
/>
);
})}
</div>
)}
{showSuggestions && assistantTagOptions.length > 0 && (
<div
ref={suggestionsRef}
@@ -408,7 +529,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 +574,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,249 @@
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>;
// NEW (optional) - if we want to accept the FilterManager in this component:
filterManager?: FilterManager;
existingSources: string[];
availableDocumentSets: { name: string }[];
availableTags: Tag[];
}
export function SimplifiedChatInputBar({
message,
setMessage,
onSubmit,
files,
setFiles,
handleFileUpload,
textAreaRef,
// NEW (optional) - if we want to accept the FilterManager in this component:
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,71 @@
import { redirect } from "next/navigation";
import { unstable_noStore as noStore } from "next/cache";
import { cookies } from "next/headers";
import { fetchChatData } from "@/lib/chat/fetchChatData";
import { ChatProvider } from "@/components/context/ChatContext";
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
export default async function Layout({
children,
}: // searchParams,
{
children: React.ReactNode;
// searchParams: { [key: string]: string | string[] | undefined };
}) {
noStore();
const requestCookies = cookies();
// Ensure searchParams is an object, even if it's empty
const safeSearchParams = {};
const data = await fetchChatData(
safeSearchParams as { [key: string]: string }
);
// const defaultSidebarOff = safeSearchParams.defaultSidebarOff === "true";
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,
}}
>
{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,5 +1,6 @@
import { EmphasizedClickable } from "@/components/BasicClickable";
import { FiBook } from "react-icons/fi";
import { CustomTooltip } from "@/components/tooltip/CustomTooltip";
import { FiBook, FiSearch } from "react-icons/fi";
export function SkippedSearch({
handleForceSearch,
@@ -7,22 +8,32 @@ 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>
{/* <EmphasizedClickable onClick={handleForceSearch}>
Force search?
</EmphasizedClickable> */}
<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,404 @@
"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,
NRFPreferencesProvider,
} from "@/components/context/NRFPreferencesContext";
import { SettingsPanel } from "../../components/nrf/SettingsPanel";
import { ShortcutsDisplay } from "../../components/nrf/ShortcutsDisplay";
import LoginPanel from "../../auth/login/LoginPage";
import { AuthType } from "@/lib/constants";
import { sendSetDefaultNewTabMessage } from "@/lib/extension/utils";
import { WelcomeModal } from "@/components/initialSetup/welcome/WelcomeModalWrapper";
import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
export default function NRFPage({
requestCookies,
}: {
requestCookies: ReadonlyRequestCookies;
}) {
const {
theme,
defaultLightBackgroundUrl,
defaultDarkBackgroundUrl,
shortcuts: shortCuts,
setShortcuts: setShortCuts,
setUseOnyxAsNewTab,
showShortcuts,
} = useNRFPreferences();
const { popup, setPopup } = usePopup();
const [message, setMessage] = useState("");
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
// Show modal to confirm turning off Onyx as new tab
const [showTurnOffModal, setShowTurnOffModal] = useState<boolean>(false);
// Settings sidebar open/close go
const [settingsOpen, setSettingsOpen] = useState<boolean>(false);
const [editingShortcut, setEditingShortcut] = useState<Shortcut | null>(null);
// Saved background in localStorage
const [backgroundUrl, setBackgroundUrl] = useState<string>(
theme === "light" ? defaultLightBackgroundUrl : defaultDarkBackgroundUrl
);
useEffect(() => {
setBackgroundUrl(
theme === "light" ? defaultLightBackgroundUrl : defaultDarkBackgroundUrl
);
}, [theme, defaultLightBackgroundUrl, defaultDarkBackgroundUrl]);
const filterManager = useFilters();
const { isNight } = useNightTime();
const { user } = useUser();
const { ccPairs, documentSets, tags, llmProviders, shouldShowWelcomeModal } =
useChatContext();
const inputRef = useRef<HTMLInputElement>(null);
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]);
}
});
};
// Confirm turning off Onyx
const confirmTurnOff = () => {
setUseOnyxAsNewTab(false);
setShowTurnOffModal(false);
sendSetDefaultNewTabMessage(false);
};
const [showShortCutModal, setShowShortCutModal] = useState(false);
const [showMaxShortcutsModal, setShowMaxShortcutsModal] = useState(false);
const [showLoginModal, setShowLoginModal] = useState<boolean>(!user);
const [authType, setAuthType] = useState<string | 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;
setMessage("");
let filterString = filterManager?.getFilterString();
if (currentMessageFiles.length > 0) {
filterString +=
"&files=" + encodeURIComponent(JSON.stringify(currentMessageFiles));
}
const newHref =
"http://localhost:3000/chat?send-on-load=true&user-prompt=" +
encodeURIComponent(userMessage) +
filterString;
if (typeof window !== "undefined" && window.parent) {
window.parent.postMessage({ type: "LOAD_NEW_PAGE", href: newHref }, "*");
} else {
window.location.href = newHref;
}
};
return (
<div
className="relative w-full h-full flex flex-col"
style={{
minHeight: "100vh",
backgroundImage: `url(${backgroundUrl})`,
backgroundPosition: "center center",
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
overflow: "hidden",
transition: "background-image 0.3s ease",
}}
>
<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 && showLoginModal && (
<Modal
className="max-w-md mx-auto"
onOutsideClick={() => setShowLoginModal(false)}
>
{fetchingAuth ? (
<p className="p-4">Loading login info</p>
) : authType == "basic" ? (
<LoginPanel
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>
)}
{shouldShowWelcomeModal && (
<WelcomeModal user={user} requestCookies={requestCookies} />
)}
{popup}
</div>
);
}

View File

@@ -0,0 +1,67 @@
export interface Shortcut {
name: string;
url: string;
favicon?: string;
}
// Start of Selection
// Start of Selection
export enum LightBackgroundColors {
Red = "#dc2626", // Tailwind Red 600
Blue = "#2563eb", // Tailwind Blue 600
Green = "#16a34a", // Tailwind Green 600
Yellow = "#ca8a04", // Tailwind Yellow 600
Purple = "#9333ea", // Tailwind Purple 600
Orange = "#ea580c", // Tailwind Orange 600
Pink = "#db2777", // Tailwind Pink 600
}
export enum DarkBackgroundColors {
Red = "#991b1b", // Tailwind Red 800
Blue = "#1e40af", // Tailwind Blue 800
Green = "#166534", // Tailwind Green 800
Yellow = "#854d0e", // Tailwind Yellow 800
Purple = "#5b21b6", // Tailwind Purple 800
Orange = "#9a3412", // Tailwind Orange 800
Pink = "#9d174d", // Tailwind Pink 800
}
export enum StoredBackgroundColors {
RED = "Red",
BLUE = "Blue",
GREEN = "Green",
YELLOW = "Yellow",
PURPLE = "Purple",
ORANGE = "Orange",
PINK = "Pink",
}
export type BackgroundColors = LightBackgroundColors | DarkBackgroundColors;
export interface Shortcut {
name: string;
url: string;
favicon?: string;
}
export const darkImages = [
"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 lightImages = [
"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",
];
// Local storage keys
export const SHORTCUTS_KEY = "shortCuts";
export const NEW_TAB_PAGE_VIEW_KEY = "newTabPageView";
export const USE_ONYX_AS_NEW_TAB_KEY = "useOnyxAsNewTab";
// Default values
export const DEFAULT_LIGHT_BACKGROUND_IMAGE = "onyxBackgroundLight";
export const DEFAULT_DARK_BACKGROUND_IMAGE = "onyxBackgroundDark";
export const DEFAULT_NEW_TAB_PAGE_VIEW = "chat";

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

@@ -11,55 +11,54 @@ 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;
// noStore();
// const requestCookies = await cookies();
// const data = await fetchChatData(searchParams);
// const defaultSidebarOff = searchParams.defaultSidebarOff === "true";
if ("redirect" in data) {
redirect(data.redirect);
}
// if ("redirect" in data) {
// redirect(data.redirect);
// }
const {
user,
chatSessions,
availableSources,
documentSets,
tags,
llmProviders,
folders,
toggleSidebar,
openedFolders,
defaultAssistantId,
shouldShowWelcomeModal,
ccPairs,
} = data;
// 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 (
// <>
// <InstantSSRAutoRefresh />
// {shouldShowWelcomeModal && (
// <WelcomeModal user={user} requestCookies={requestCookies} />
// )}
// <ChatProvider
// value={{
// chatSessions,
// availableSources,
// ccPairs,
// documentSets,
// tags,
// availableDocumentSets: documentSets,
// availableTags: tags,
// llmProviders,
// folders,
// openedFolders,
// shouldShowWelcomeModal,
// defaultAssistantId,
// }}
return <WrappedChat firstMessage={firstMessage} initiallyToggled={false} />;
// </ChatProvider>
// </>
// );
}

View File

@@ -0,0 +1,176 @@
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 { darkImages, lightImages } from "../../chat/nrf/interfaces";
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=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" ? darkImages : lightImages).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 { darkImages, lightImages, Shortcut } from "@/app/chat/nrf/interfaces";
function notifyExtensionOfThemeChange(newTheme: string, newBgUrl: string) {
if (typeof window !== "undefined" && window.parent) {
window.parent.postMessage(
{
type: "PREFERENCES_UPDATED",
payload: {
theme: newTheme,
backgroundUrl: newBgUrl,
},
},
"*"
);
}
}
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 defaultValue;
});
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>("onyxTheme", "dark");
const [defaultLightBackgroundUrl, setDefaultLightBackgroundUrl] =
useLocalStorageState<string>("lightBgUrl", lightImages[0]);
const [defaultDarkBackgroundUrl, setDefaultDarkBackgroundUrl] =
useLocalStorageState<string>("darkBgUrl", darkImages[0]);
const [shortcuts, setShortcuts] = useLocalStorageState<Shortcut[]>(
"shortCuts",
[]
);
const [showShortcuts, setShowShortcuts] = useLocalStorageState<boolean>(
"showShortcuts",
false
);
const [useOnyxAsNewTab, setUseOnyxAsNewTab] = useLocalStorageState<boolean>(
"useOnyxAsDefaultNewTab",
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,274 @@
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";
export const validateUrl = (input: string) => {
try {
new URL(input);
return true;
} catch {
return false;
}
};
const QuestionMarkIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="w-full h-full text-neutral-50"
>
<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>
);
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 />
)}
</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)}
/>
) : (
<div className="w-8 h-8">
<QuestionMarkIcon />
</div>
)}
</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

@@ -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_ENABLE_CHROME_EXTENSION =
process.env.NEXT_PUBLIC_ENABLE_CHROME_EXTENSION?.toLowerCase() === "true";
export const NEXT_PUBLIC_CLOUD_DOMAIN =
process.env.NEXT_PUBLIC_CLOUD_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,17 @@
import { useEffect } from "react";
export function sendSetDefaultNewTabMessage(value: boolean) {
if (typeof window !== "undefined" && window.parent) {
window.parent.postMessage({ type: "SET_DEFAULT_NEW_TAB", value }, "*");
}
}
export const sendMessageToParent = () => {
if (typeof window !== "undefined" && window.parent) {
window.parent.postMessage({ type: "ONYX_APP_LOADED" }, "*");
}
};
export const useSendMessageToParent = () => {
useEffect(() => {
sendMessageToParent();
}, []);
};

View File

@@ -5,12 +5,13 @@ import {
DocumentBoostStatus,
Tag,
UserGroup,
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";
@@ -22,6 +23,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";
@@ -120,6 +123,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 {
@@ -130,6 +140,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,
@@ -139,6 +240,8 @@ export function useFilters(): FilterManager {
setSelectedDocumentSets,
selectedTags,
setSelectedTags,
getFilterString,
buildFiltersFromQueryString,
};
}

View File

@@ -1,4 +1,5 @@
import { Tag, ValidSources } from "../types";
import { getSourceMetadata } from "../sources";
import { DocumentSet, Tag, ValidSources } from "../types";
import {
Filters,
LoadedOnyxDocument,

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