Merge pull request #2 from n4ze3m/v2

v2
This commit is contained in:
Muhammed Nazeem 2024-02-08 00:19:09 +05:30 committed by GitHub
commit 8a24faf7e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
122 changed files with 16183 additions and 16774 deletions

View File

@ -1,14 +0,0 @@
# Since the ".env" file is gitignored, you can use the ".env.example" file to
# build a new ".env" file when you clone the repo. Keep this file up-to-date
# when you add new variables to `.env`.
# This file will be committed to version control, so make sure not to have any
# secrets in it. If you are cloning this repo, create a copy of this file named
# ".env" and populate it with your secrets.
# When adding additional environment variables, the schema in "/src/env.mjs"
# should be updated accordingly.
# Prisma
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
DATABASE_URL="file:./db.sqlite"

36
.gitignore vendored
View File

@ -1,3 +1,4 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies # dependencies
@ -8,17 +9,10 @@
# testing # testing
/coverage /coverage
# database #cache
/prisma/db.sqlite .turbo
/prisma/db.sqlite-journal .next
.vercel
# next.js
/.next/
/out/
next-env.d.ts
# production
/build
# misc # misc
.DS_Store .DS_Store
@ -30,13 +24,19 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# local env files
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env
.env*.local
# vercel # local env files
.vercel .env*
out/
build/
dist/
# plasmo - https://www.plasmo.com
.plasmo
# bpp - http://bpp.browser.market/
keys.json
# typescript # typescript
*.tsbuildinfo .tsbuildinfo

View File

@ -1,2 +0,0 @@
extension
py_server

View File

@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.

View File

@ -1,35 +1,22 @@
# Page Assist # Page Assist
Revolutionize your browsing experience with PageAssist, an open source Chrome extension that allows you to easily chat with any webpage using the power of ChatGPT API. A simple browser extension to assist you in talking with the current page, along with a web UI for the [Ollama](https://github.com/ollama/ollama) project.
Here's a demo of how it works:
[![PageAssist Demo](https://img.youtube.com/vi/UB1PdZ32vBc/0.jpg)](https://www.youtube.com/watch?v=UB1PdZ32vBc)
## Tools ## Features
- NextJs - [X] Fully local, no data is sent to any server
- Supabase - [x] Chat with the current page
- FastAPI - [X] Web UI for Ollama
- OpenAI's ChatGPT api - [ ] Chat with Youtube videos
- Docker - [ ] Chat with PDFs
- Plasmo for chrome extension - [ ] Other Local AI providers
## Supabase ## V1
- Used for Authentication and Database If you are looking for the V1 of this project, you can find it on v1 branch. I created it as a hackathon project and it is not maintained anymore.
- Used Supabase Auth UI in the dashboard for authentication ## License
- USed Supabase Python client for FastAPI MIT
## Demo
- [PageAssist](https://pageassist.n4ze3m.com/)
- [Chrome Extension](https://chrome.google.com/webstore/detail/page-assist/ehkjdalbpmmaddcfdilplgknkgepeakd?hl=en&authuser=2)
## Team
- [Muhammad Nazeem](https://twitter.com/n4ze3m)

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

42
extension/.gitignore vendored
View File

@ -1,42 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
#cache
.turbo
.next
.vercel
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*
out/
build/
dist/
# plasmo - https://www.plasmo.com
.plasmo
# bpp - http://bpp.browser.market/
keys.json
# typescript
.tsbuildinfo

View File

@ -1,33 +0,0 @@
This is a [Plasmo extension](https://docs.plasmo.com/) project bootstrapped with [`plasmo init`](https://www.npmjs.com/package/plasmo).
## Getting Started
First, run the development server:
```bash
pnpm dev
# or
npm run dev
```
Open your browser and load the appropriate development build. For example, if you are developing for the chrome browser, using manifest v3, use: `build/chrome-mv3-dev`.
You can start editing the popup by modifying `popup.tsx`. It should auto-update as you make changes. To add an options page, simply add a `options.tsx` file to the root of the project, with a react component default exported. Likewise to add a content page, add a `content.ts` file to the root of the project, importing some module and do some logic, then reload the extension on your browser.
For further guidance, [visit our Documentation](https://docs.plasmo.com/)
## Making production build
Run the following:
```bash
pnpm build
# or
npm run build
```
This should create a production bundle for your extension, ready to be zipped and published to the stores.
## Submit to the webstores
The easiest way to deploy your Plasmo extension is to use the built-in [bpp](https://bpp.browser.market) GitHub action. Prior to using this action however, make sure to build your extension and upload the first version to the store to establish the basic credentials. Then, simply follow [this setup instruction](https://docs.plasmo.com/framework/workflows/submit) and you should be on your way for automated submission!

View File

@ -1,25 +0,0 @@
export {};
const toogle = () => {
const iframe = document.getElementById("pageassist-iframe");
const widget = document.getElementById("pageassist-icon");
if (iframe) {
const display = iframe.style.display;
if (display === "none") {
if (widget) {
widget.style.display = "none";
}
iframe.style.display = "block";
} else {
iframe.style.display = "none";
// if user enabled show widget in settings and close from action then show widget will be disappear inorder to show widget again we need to reload the page
}
}
};
chrome.action.onClicked.addListener((tab) => {
chrome.scripting.executeScript({
target: { tabId: tab.id },
func: toogle,
});
});

View File

@ -1,104 +0,0 @@
export {};
import { Storage } from "@plasmohq/storage";
const storage = new Storage();
const main = async () => {
const isChatWidgetEnabled = await storage.get("chat-widget");
var iframe = document.createElement("iframe");
iframe.id = "pageassist-iframe";
iframe.style.backgroundColor = "white";
iframe.style.position = "fixed";
iframe.style.top = "0px";
iframe.style.right = "0px";
iframe.style.zIndex = "9000000000000000000";
iframe.style.border = "0px";
iframe.style.display = "none";
iframe.style.width = "500px";
iframe.style.height = "100%";
iframe.src = chrome.runtime.getURL("popup.html");
document.body.appendChild(iframe);
var toggleIcon = document.createElement("div");
if (isChatWidgetEnabled) {
toggleIcon.style.display = "none";
} else {
toggleIcon.style.display = "block";
}
toggleIcon.id = "pageassist-icon";
toggleIcon.style.position = "fixed";
toggleIcon.style.top = "50%";
toggleIcon.style.right = "0px";
toggleIcon.style.transform = "translateY(-50%)";
toggleIcon.style.zIndex = "9000000000000000000";
toggleIcon.style.background = "linear-gradient(to bottom, #0c0d52, #023e8a)";
toggleIcon.style.height = "50px";
toggleIcon.style.width = "50px";
toggleIcon.style.borderTopLeftRadius = "10px";
toggleIcon.style.borderBottomLeftRadius = "10px";
toggleIcon.style.cursor = "pointer";
var iconBackground = document.createElement("div");
iconBackground.style.backgroundRepeat = "no-repeat";
iconBackground.style.backgroundSize = "contain";
iconBackground.style.height = "100%";
iconBackground.style.backgroundImage =
"url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHgAAAB4CAQAAACTbf5ZAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAB3RJTUUH5gcFDDQINdz8vAAAAAJiS0dEAP+Hj8y/AAAKmklEQVR42u2ceXCU5R3Hv++72evd7ObYHJvDJGbZ3AdJCAmHWosEhKGCAuGQoC1XPJjWFhG5RJ0600qxHTuCHAmHwgyIoOPYIir0D61EiJUOGgNKolxKEJFCAsl++8f77hLJ4SLv7r7E/ea/vMPk+fB7jt/1PEBIIYUUUkghhRRSSCGFFFJIIYUUUkgh+aonsUK8y5wWMTCp1FnoKnSVOsuS0iPGmteJf+pLmONwv5hnTymOmxK9TKq1vq2vMzVIzebj5uNSs6lBX2d921JrX+aYnFYywD5XnHTjghISKvRprpgq2xpTfViL2CEQPfwIFNv1Z8z1tnWO6RkZ4/Q3gzcW7IPYI+TEx1eGbzE0ie3dAlJkd/8BYrux2bbFMbnIcV546saAHY1FQpoz6jFTva7tCoiOduZxFGdxKZ/jGm7gBq7hc1zCmRzFPNqp64StuyR9FLsg07lQ+JW2YasAOFMjlxgaBbdn8FaW8mFu5AF+zVZ2p1ae4gFu4kMcQOsVW7tNh2OW5KYCj2h1zUahwGafZTzogTWwkAv4Ls/QTV/kZgv38DEWUO+FNh90zCq3xWlvTQ/HeiG51LJTVKaxxBHcyJM+ov4Q+wQ3soKSZ3q3WXeml/5bmKgl3EwUSfYHDM3yEI2s4HZ+z+vROW7ncBoUaGNzfPVQqUgbsE/ACleidZWuVR5cHtfwLNXQt1zNHI+dWyNX5SfasS7YuPMApORKb8nr1sI5PEw11chZtMhHmTt8V0Yu8Hwwce8AcNMg0wHZCunc0MM+fD26yFrerJze0oH0cmBcsHBvA5B4i+mQjHsLP6C/9AGHKlPbfCjtFuCuYODeDiB5kIwrcjyP0p/6gncrnpn5kLMcGB94f8qKm3Llyazjffya/tYpVlGUj7z9GbnRmB1YJyMJrkTpLdm697GFgdBpTqNAUKD1nwUJzkA6I7kol6yr5Ck2PgDW9egkxynbV9TKUeayQOH+EkD0A/K5eyu/YCD1ubJ9hbU6qoGpgcB9GRYkD5S9Kif3MdB6XzmkjE3OUhv+Fwg3ssBm2Sm7GRsYDNUofrbt1SE2v7ubkwDYZ4uXQLDaD26Gb67ITCWscMz0e/B4E9LTjAdBMF9lJ/Ja1KD42NLHeakZ/sQdCiByiUDQwLUMplZRT1BkzCK/+l1JSHUZGkFwhEoR0U+PpO4gCJo+y3Q6/elMRi0QCEp8lcHWNpoJioydD0zwD7ALWQ5TPQiOvM7wXg19p9hYOjAgvtAfuM9AQOwk8RKo5yZqQbUMI6hrS64UsVd94DLco7dsAcH+PKEJ4GPMJwhGbH5UP1p94GSkZuibQPBxakWPyj7X0SyX6ofTIwDs08UO0Ma9mgF+m+EEde2J00SsURd4DD4RwteC4ECe0QzwaZYQBKNWU5ijLnAe8mLkHXoutaQHlJTAYPsgdYEdSC4JawF1fElTwOspEjS09CtOVRfYjJgpYgcYw3pNAX/IaIK69qRJVrW3rcgnBYL5Acxv+JYDySEoMHapqrBTQcG8HgRHs01TwBc5Uq5R1lBYpmaUVGEOfwcEZ1Nr+o3sfOyeZh6rHnACEiLC6kDwCZ8GcYn13Mo9/K6H7818ja+xucdC2h5uZT0v+fS3Fsml2X1Ztkz1gIvRP8n4GQj+zadptoxxFGnhBDZ18/1NFlBPPQv4ZjdfmziBFoqM4zJe8OGv/UUOEz8dmniresBF6O80fwkKPoX9W721XXAuO7pYt9D7tbCLlTs41/tV4lafUgEgaGkamq4icA6yXabjoMiNPgxhdqdujXyeuurr696qL2jg613qC/md/rUvO0YNBYLSsYEuFfPU2cjKkIE3XTPw1zcicBEKlSm97ucxpUtQlByoTav5Gjet5f7YtFKRGqn/8NqOpW29Hkuv/8ixtO0ajyWjusfSFcdjzs/D8ajCRUHSqGs5wh+uZaVmg4cT/gkeKmH6uYWHWk0A1MoJgNP9ilROAOQhL8aowRRPtZLiGRI9WF3gu3BGsCpJvBbN4J5msTeJ95C6wNUAYpQ07R7NAO/2pml1qFW/MuxJxC/QDPA8JRGf3S8TqqscE/WWzZostbz8eNgY9YGXQ/QW0zZqZIeWi2lJE3Wo90f90IlMh9x7N4LntFMu3V8a76fWlmGdCuLbgw68VSmIx82XPUE/tbSk9ZPDxAp+G1TcMxwmh4UN2c5+fm5qWSw3tawOKvBKpakldhFwtz/7eFLgTDX+BwRz2Rg03AZmy+v3Iz+3LSmNaTPluyuzf9CYdplf8j2+wTdZx1NdEjvqhoSexrSEGcB8f/fiZaDAZtkBghH8lzKEC3yD0+iilUYaGckCzuX7vOwn4LWe1sPtt1pL/N9r+RosSC41HgUjlF6ARk5neJdbhLFcyG/8gPse0xT/ylkaEZie6WEgRv166vlVbCVZzzIvpJmxtHvTsCLH85jKuEc4WG4fvuiYE6D2YQBww21wr6CbPMohCl4/Ps5dPMiPuIOzGa/8tkpVF+Ukx3oaxF8YbS5HAOWGO4HvXubDii3v4Sedrt21c69idwNfUDEYvDdYVwCAyyBY9s7hOILgmC4FFfJjxbXvr9K0Psl7vZc8MnOiMQsB1lJQSFstEEzooS/+JZoI6vmyCrifc5xyjUc65CzzozPZq8KxBwSns73bQbYo07r6unHf9+4U5kNpQ4N0UQtACj4HwRd7HKjcVDScF6/LzahRDiKB0n5nGTAZwVIWToBh3NnjYJ8kCJb3WHL5cX3GmYqbIbituzJygBoET058BYq9rNHHKF/1+WnNxt/yRcVnBnUXo1YWJkRhB4IpO+p7azZt5RiC4KQe1nhvOsdXOOzKhekmx5xfmEsQZAki1oNgSQ85rn2UD61nrwnVzePcwOGdrsTbXk0f0CRUQguaiDZQx6e6iY7OcQpB0OFzccbNFr7L+czv/OjBx44Zg6ypmnn0IBrvgGAUV15VVTzLecqwq3mZ5AUe5slenrXYzw18kCWdghDRbWqMXZybAvxOI7AQAaACp0AwnLP4Ac+xnZfZwn9wjIJbyE9JXuBvmcwcjuQMLuYKruZ6rudqruBizuCdzGX0Dx8uaZPqY+dnO5/V1tsOgABBxByclYdp5xBO4DgWe+0kndxxhG1kAxOvfoJGcRO7eZqmKWJzYmVx/Hks1QilHvFIQZRsYABhqMIX3QzejTrzHUzleP71m/duOyG09/b4kK5d3yIdiFyTMC3LVakv0MyaNWIMtuC/OII6LEe+19CFWIWv0OFFuIQGPI0UJa4SaHs01zExZml4jW23oc7UIDVLyvNShn223eE19icSJjuLB9nnifdragZb8Ay+72SZwxgbB8Fj52zcjz+jBmvxNCYgxTsDvFqJV8QpZldEeVJ5erGryFWeXp7ktE00bxP+Dm3q92jzTEEF+SgGdl3W6CNyohEEU7iE61hFs4xcgzD0UU1FO2jlK0oM86AMfASpfRV4IQgWe+v/u2giiLMY0FeB/wCCWfzSW8zSE8RpFPZV4DtxAQzjQp5lBz/l7fKUrkdsXwWOxl75tazBvIcuz069BH1YFTh2lY+0Cwl9llaAKGA09sPzbON5bIYTfV5JuA/PoxZ/xAjBghvtwc2QQgoppJBCCimkQOr/ecIE+5d512IAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjItMDctMDVUMTI6NTE6NTkrMDA6MDCsFSHZAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDIyLTA3LTA1VDEyOjUxOjU5KzAwOjAw3UiZZQAAAABJRU5ErkJggg==')";
iconBackground.style.width = "100%";
iconBackground.style.opacity = "0.7";
iconBackground.style.position = "absolute";
iconBackground.style.top = "0";
iconBackground.style.left = "0";
toggleIcon.appendChild(iconBackground);
toggleIcon.addEventListener("click", function () {
if (iframe.style.display === "none") {
iframe.style.display = "block";
toggleIcon.style.display = "none";
toggleIcon.classList.add("hidden");
} else {
iframe.style.display = "none";
toggleIcon.classList.remove("hidden");
}
});
document.body.appendChild(toggleIcon);
// iframe.addEventListener("load", function () {
// var closeButton = iframe.contentDocument.createElement("button");
// closeButton.innerText = "Close";
// closeButton.style.position = "fixed";
// closeButton.style.top = "20px";
// closeButton.style.right = "20px";
// closeButton.addEventListener("click", function () {
// toggleIcon.classList.remove("hidden");
// iframe.style.display = "none";
// });
// iframe.contentDocument.body.appendChild(closeButton);
// });
window.addEventListener("message", function (event) {
if (event.data === "pageassist-close") {
iframe.style.display = "none";
if (!isChatWidgetEnabled) {
toggleIcon.style.display = "block";
toggleIcon.classList.remove("hidden");
}
} else if (event.data === "pageassist-html") {
console.log("pageassist-html");
let html = document.documentElement.outerHTML;
let url = window.location.href;
iframe.contentWindow.postMessage({
type: "pageassist-html",
html: html,
url: url,
}, "*");
}
});
};
main();

View File

@ -1,60 +0,0 @@
{
"name": "pageassist",
"displayName": "Page Assist",
"version": "0.0.1",
"description": "Chat with any webpage using an intelligent chat feature",
"author": "n4ze3m",
"scripts": {
"dev": "plasmo dev",
"build": "plasmo build",
"package": "plasmo package"
},
"dependencies": {
"@headlessui/react": "^1.7.13",
"@heroicons/react": "^2.0.16",
"@mantine/form": "^6.0.5",
"@plasmohq/storage": "^1.4.0",
"@tailwindcss/forms": "^0.5.3",
"@tanstack/react-query": "^4.28.0",
"axios": "^1.3.4",
"plasmo": "0.67.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-markdown": "^8.0.6",
"react-router-dom": "^6.10.0",
"react-toastify": "^9.1.2"
},
"devDependencies": {
"@plasmohq/prettier-plugin-sort-imports": "3.6.3",
"@types/chrome": "0.0.210",
"@types/node": "18.11.18",
"@types/react": "18.0.27",
"@types/react-dom": "18.0.10",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.21",
"prettier": "2.8.3",
"tailwindcss": "^3.3.0",
"typescript": "4.9.4"
},
"manifest": {
"host_permissions": [
"https://*/*"
],
"web_accessible_resources": [
{
"resources": [
"popup.html"
],
"matches": [
"https://*/*",
"http://*/*"
]
}
],
"permissions": [
"storage",
"activeTab",
"scripting"
]
}
}

5993
extension/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { MemoryRouter } from "react-router-dom"
import { Routing } from "~routes"
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
const queryClient = new QueryClient()
function IndexPopup() {
return (
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Routing />
<ToastContainer />
</QueryClientProvider>
</MemoryRouter>
)
}
export default IndexPopup

View File

@ -1,294 +0,0 @@
import { useEffect, useRef, useState } from "react"
import "./tailwind.css"
import {
ArrowUpOnSquareIcon,
Cog6ToothIcon,
XMarkIcon
} from "@heroicons/react/20/solid"
import { useForm } from "@mantine/form"
import { useMutation } from "@tanstack/react-query"
import axios from "axios"
import logoImage from "data-base64:~assets/icon.png"
import ReactMarkdown from "react-markdown"
import { Link, useNavigate } from "react-router-dom"
import { toast } from "react-toastify"
import { useStorage } from "@plasmohq/storage/hook"
function Chat() {
type Message = {
isBot: boolean
message: string
}
type History = {
bot_response: string
human_message: string
}
const [messages, setMessages] = useState<Message[]>([
{
isBot: true,
message: "Hi, I'm PageAssist Bot. How can I help you?"
}
])
const [history, setHistory] = useState<History[]>([])
const [userToken] = useStorage("pa-token", null)
const route = useNavigate()
const form = useForm({
initialValues: {
message: "",
isBot: false
}
})
const divRef = useRef(null)
useEffect(() => {
divRef.current.scrollIntoView({ behavior: "smooth" })
})
const getHtmlFromParent = () => {
window.parent.postMessage("pageassist-html", "*")
return new Promise((resolve, reject) => {
window.addEventListener("message", (event) => {
if (event.data.type === "pageassist-html") {
resolve(event.data)
} else {
reject("Error")
}
})
})
}
const sendToBot = async (message: string) => {
// @ts-ignore
const { html } = await getHtmlFromParent()
const response = await axios.post(
`${process.env.PLASMO_PUBLIC_API_URL}/chat/chrome`,
{
user_message: message,
html: html,
history: history
}
)
return response.data
}
const onSave = async () => {
const data = await getHtmlFromParent()
const response = await axios.post(
`${process.env.PLASMO_PUBLIC_API_URL}/user/save`,
data,
{
headers: {
"x-auth-token": userToken
}
}
)
return response.data
}
const { mutateAsync: saveAsync, isLoading: isSaving } = useMutation(onSave, {
onSuccess: (data) => {
toast.success("Saved Successfully")
},
onError: (er) => {
console.log(er)
toast.error("Error in saving")
}
})
const { mutateAsync: sendToBotAsync, isLoading: isSending } = useMutation(
sendToBot,
{
onSuccess: (data) => {
setMessages([...messages, { isBot: true, message: data.bot_response }])
setHistory([...history, data])
},
onError: (error) => {
setMessages([
...messages,
{ isBot: true, message: "Something went wrong" }
])
}
}
)
return (
<div className="isolate bg-gray-100 text-gray-800">
<div className="absolute inset-x-0 top-[-10rem] -z-10 transform-gpu overflow-hidden blur-3xl sm:top-[-20rem]">
<svg
className="relative left-[calc(50%-11rem)] -z-10 h-[21.1875rem] max-w-none -translate-x-1/2 rotate-[30deg] sm:left-[calc(50%-30rem)] sm:h-[42.375rem]"
viewBox="0 0 1155 678"
xmlns="http://www.w3.org/2000/svg">
<path
fill="url(#45de2b6b-92d5-4d68-a6a0-9b9b2abad533)"
fillOpacity=".3"
d="M317.219 518.975L203.852 678 0 438.341l317.219 80.634 204.172-286.402c1.307 132.337 45.083 346.658 209.733 145.248C936.936 126.058 882.053-94.234 1031.02 41.331c119.18 108.451 130.68 295.337 121.53 375.223L855 299l21.173 362.054-558.954-142.079z"
/>
<defs>
<linearGradient
id="45de2b6b-92d5-4d68-a6a0-9b9b2abad533"
x1="1155.49"
x2="-78.208"
y1=".177"
y2="474.645"
gradientUnits="userSpaceOnUse">
<stop stopColor="#9089FC" />
<stop offset={1} stopColor="#FF80B5" />
</linearGradient>
</defs>
</svg>
</div>
{/* Component Start */}
<div className="flex items-center justify-between px-6 pt-4 pb-2 md:justify-start md:space-x-10">
<div>
<Link to="/" className="flex">
<img className="h-10 w-auto" src={logoImage} alt="PageAssist" />
</Link>
</div>
<div className="flex items-center space-x-4">
<button
type="button"
className="inline-flex items-center rounded-md border border-transparent bg-white px-3 py-2 text-sm font-medium leading-4 text-gray-800 hover:text-gray-500 shadow-sm hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
onClick={async () => {
// Send data to the app
await saveAsync()
}}>
<ArrowUpOnSquareIcon
className="-ml-1 mr-3 h-5 w-5"
aria-hidden="true"
/>
{isSaving ? "Saving..." : "Send to App"}
</button>
<button
type="button"
className="bg-white rounded-md p-2 inline-flex items-center justify-center text-gray-800 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 shadow-sm focus:ring-inset focus:ring-indigo-500"
onClick={() => {
route("/settings")
}}>
<Cog6ToothIcon className="h-5 w-5" aria-hidden="true" />
</button>
<div className="-my-2 -mr-2 md:hidden">
<button
type="button"
className="bg-white rounded-md p-2 inline-flex items-center justify-center text-gray-800 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 shadow-sm focus:ring-inset focus:ring-indigo-500"
aria-expanded="false"
onClick={() => {
window.parent.postMessage("pageassist-close", "*")
}}>
<XMarkIcon className="h-5 w-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
<div
style={{
minHeight: "calc(100vh - 4rem)"
}}
className="flex flex-col p-6 items-center justify-center w-screen">
<div className="flex flex-col flex-grow w-full max-w-xl bg-white shadow-sm rounded-lg overflow-hidden">
<div className="flex flex-col flex-grow h-0 p-4 overflow-auto">
{messages.map((message, index) => {
return (
<div
key={index}
className={
message.isBot
? "flex w-full mt-2 space-x-3 max-w-xs"
: "flex w-full mt-2 space-x-3 max-w-xs ml-auto justify-end"
}>
<div>
<div
className={
message.isBot
? "bg-gray-300 p-3 rounded-r-lg rounded-bl-lg"
: "bg-blue-600 text-white p-3 rounded-l-lg rounded-br-lg"
}>
<p className="text-sm">
<ReactMarkdown>{message.message}</ReactMarkdown>
</p>
</div>
</div>
</div>
)
})}
{isSending && (
<div className="flex w-full mt-2 space-x-3 max-w-xs">
<div>
<div className="bg-gray-300 p-3 rounded-r-lg rounded-bl-lg">
<p className="text-sm">Hold on, I'm looking...</p>
</div>
</div>
</div>
)}
<div ref={divRef} />
</div>
<div className="bg-gray-300 p-4">
<form
onSubmit={form.onSubmit(async (values) => {
setMessages([...messages, values])
form.reset()
await sendToBotAsync(values.message)
})}>
<div className="flex-grow space-y-6">
<div className="flex">
<span className="mr-3">
<button
disabled={isSending || isSaving}
onClick={() => {
setHistory([])
setMessages([
{
message: "Hi, I'm PageAssist. How can I help you?",
isBot: true
}
])
}}
className="inline-flex items-center rounded-md border border-gray-700 bg-white px-3 h-10 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2"
type="button">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className="h-5 w-5 text-gray-600">
<path d="M18.37 2.63 14 7l-1.59-1.59a2 2 0 0 0-2.82 0L8 7l9 9 1.59-1.59a2 2 0 0 0 0-2.82L17 10l4.37-4.37a2.12 2.12 0 1 0-3-3Z"></path>
<path d="M9 8c-2 3-4 3.5-7 4l8 10c2-1 6-5 6-7"></path>
<path d="M14.5 17.5 4.5 15"></path>
</svg>
</button>
</span>
<div className="flex-grow">
<input
disabled={isSending || isSaving}
className="flex items-center h-10 w-full rounded px-3 text-sm"
type="text"
required
placeholder="Type your message…"
{...form.getInputProps("message")}
/>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
)
}
export default Chat

View File

@ -1,12 +0,0 @@
import React from "react"
import { useStorage } from "@plasmohq/storage/hook"
import Chat from "./chat"
import Login from "./login"
export default function Home() {
const [token] = useStorage("pa-token", null)
return <>{token ? <Chat /> : <Login />}</>
}

View File

@ -1,10 +0,0 @@
import { Route, Routes } from "react-router-dom"
import Settings from "./settings"
import Home from "./home"
export const Routing = () => (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/settings" element={<Settings />} />
</Routes>
)

View File

@ -1,158 +0,0 @@
import { XMarkIcon } from "@heroicons/react/20/solid"
import { useForm } from "@mantine/form"
import { useMutation } from "@tanstack/react-query"
import axios from "axios"
import logoImage from "data-base64:~assets/icon.png"
import React from "react"
import { Link, useNavigate } from "react-router-dom"
import { useStorage } from "@plasmohq/storage/hook"
export default function Login() {
const navigate = useNavigate()
const [_, setToken] = useStorage("pa-token", null)
const [err, setErr] = React.useState<string | null>(null)
const form = useForm({
initialValues: {
passcode: ""
}
})
const onSubmit = async (token: string) => {
const response = await axios.post(
`${process.env.PLASMO_PUBLIC_API_URL}/user/validate`,
{
token
}
)
return response.data
}
const { mutateAsync: verifyToken, isLoading: isVerifyingToken } = useMutation(
onSubmit,
{
onSuccess: () => {
setToken(form.values.passcode)
navigate("/")
},
onError: (e:any) => {
if (axios.isAxiosError(e)) {
setErr(e.response?.data.detail)
} else {
setErr(e?.message)
}
}
}
)
return (
<div className="isolate bg-gray-100 text-gray-800">
<div className="absolute inset-x-0 top-[-10rem] -z-10 transform-gpu overflow-hidden blur-3xl sm:top-[-20rem]">
<svg
className="relative left-[calc(50%-11rem)] -z-10 h-[21.1875rem] max-w-none -translate-x-1/2 rotate-[30deg] sm:left-[calc(50%-30rem)] sm:h-[42.375rem]"
viewBox="0 0 1155 678"
xmlns="http://www.w3.org/2000/svg">
<path
fill="url(#45de2b6b-92d5-4d68-a6a0-9b9b2abad533)"
fillOpacity=".3"
d="M317.219 518.975L203.852 678 0 438.341l317.219 80.634 204.172-286.402c1.307 132.337 45.083 346.658 209.733 145.248C936.936 126.058 882.053-94.234 1031.02 41.331c119.18 108.451 130.68 295.337 121.53 375.223L855 299l21.173 362.054-558.954-142.079z"
/>
<defs>
<linearGradient
id="45de2b6b-92d5-4d68-a6a0-9b9b2abad533"
x1="1155.49"
x2="-78.208"
y1=".177"
y2="474.645"
gradientUnits="userSpaceOnUse">
<stop stopColor="#9089FC" />
<stop offset={1} stopColor="#FF80B5" />
</linearGradient>
</defs>
</svg>
</div>
{/* Component Start */}
<div className="flex items-center justify-between px-6 pt-4 pb-2 md:justify-start md:space-x-10">
<div>
<Link to="/" className="flex">
<img className="h-10 w-auto" src={logoImage} alt="PageAssist" />
</Link>
</div>
<div className="flex items-center space-x-4">
<div className="-my-2 -mr-2 md:hidden">
<button
type="button"
className="bg-white rounded-md p-2 inline-flex items-center justify-center text-gray-800 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 shadow-sm focus:ring-inset focus:ring-indigo-500"
aria-expanded="false"
onClick={() => {
window.parent.postMessage("pageassist-close", "*")
}}>
<XMarkIcon className="h-5 w-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
<div
style={{
minHeight: "calc(100vh - 4rem)"
}}
className="flex flex-col p-6 w-screen">
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
Sign in to your account
</h2>
<div className="bg-white py-8 px-4 shadow-sm rounded-lg sm:px-10 mt-8">
<ul className="list-disc list-inside text-gray-800 text-md">
<li>Log in to your Page Assist account.</li>
<li>Go to your account settings.</li>
<li>Find your passcode under "Chrome Extension".</li>
<li>Copy your passcode to your clipboard.</li>
<li>Open the PageAssist extension and paste your passcode.</li>
<li>Click "Save" and Happy Chatting!</li>
</ul>
<form
className="space-y-6"
onSubmit={form.onSubmit(async (values) => {
await verifyToken(values.passcode)
})}>
<div>
<div className="mt-3">
<input
id="passcode"
name="passcode"
type="password"
autoComplete="current-passcode"
placeholder="Passcode"
required
{...form.getInputProps("passcode")}
className="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
/>
{
err && (
<div className="text-red-500 text-sm mt-2">
{err}
</div>
)
}
</div>
</div>
<div>
<button
type="submit"
disabled={isVerifyingToken}
className="flex w-full justify-center rounded-md border border-transparent bg-teal-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2">
{isVerifyingToken ? "Saving..." : "Save"}
</button>
</div>
</form>
</div>
</div>
</div>
)
}

View File

@ -1,128 +0,0 @@
import { Switch } from "@headlessui/react"
import { ChevronLeftIcon, XMarkIcon } from "@heroicons/react/20/solid"
import logoImage from "data-base64:~assets/icon.png"
import { useState } from "react"
import { Link, useNavigate } from "react-router-dom"
import { useStorage } from "@plasmohq/storage/hook"
import { useChatWidget } from "~hooks/useLocal"
//@ts-ignore
function classNames(...classes) {
return classes.filter(Boolean).join(" ")
}
function Settings() {
const route = useNavigate()
const [active, setActiveValue] = useStorage("chat-widget", false)
const [_, setToken] = useStorage("pa-token", null)
return (
<div className="isolate bg-gray-100 text-gray-800">
<div className="absolute inset-x-0 top-[-10rem] -z-10 transform-gpu overflow-hidden blur-3xl sm:top-[-20rem]">
<svg
className="relative left-[calc(50%-11rem)] -z-10 h-[21.1875rem] max-w-none -translate-x-1/2 rotate-[30deg] sm:left-[calc(50%-30rem)] sm:h-[42.375rem]"
viewBox="0 0 1155 678"
xmlns="http://www.w3.org/2000/svg">
<path
fill="url(#45de2b6b-92d5-4d68-a6a0-9b9b2abad533)"
fillOpacity=".3"
d="M317.219 518.975L203.852 678 0 438.341l317.219 80.634 204.172-286.402c1.307 132.337 45.083 346.658 209.733 145.248C936.936 126.058 882.053-94.234 1031.02 41.331c119.18 108.451 130.68 295.337 121.53 375.223L855 299l21.173 362.054-558.954-142.079z"
/>
<defs>
<linearGradient
id="45de2b6b-92d5-4d68-a6a0-9b9b2abad533"
x1="1155.49"
x2="-78.208"
y1=".177"
y2="474.645"
gradientUnits="userSpaceOnUse">
<stop stopColor="#9089FC" />
<stop offset={1} stopColor="#FF80B5" />
</linearGradient>
</defs>
</svg>
</div>
<div className="flex items-center justify-between px-6 pt-4 pb-2 md:justify-start md:space-x-10">
<div>
<Link to="/" className="flex">
<img className="h-10 w-auto" src={logoImage} alt="PageAssist" />
</Link>
</div>
<div className="flex items-center space-x-4">
<button
type="button"
className="bg-white shadow-sm rounded-md p-2 inline-flex items-center justify-center text-gray-800 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
onClick={() => {
route("/")
}}>
<ChevronLeftIcon className="h-5 w-5" aria-hidden="true" />
</button>
<div className="-my-2 -mr-2 md:hidden">
<button
type="button"
className="bg-white shadow-sm rounded-md p-2 inline-flex items-center justify-center text-gray-800 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
aria-expanded="false"
onClick={() => {
window.parent.postMessage("pageassist-close", "*")
}}>
<XMarkIcon className="h-5 w-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
<div
style={{
minHeight: "calc(100vh - 4rem)"
}}
className="flex flex-col p-6 w-screen">
<ul role="list" className="mt-2 divide-y divide-gray-200">
<Switch.Group
as="li"
className="flex items-center justify-between py-4">
<div className="flex flex-col">
<Switch.Label
as="p"
className="text-sm font-medium text-gray-900"
passive>
Hide Widget Icon
</Switch.Label>
<Switch.Description className="text-sm text-gray-500">
Hide or Show the widget icon on websites you visit.
</Switch.Description>
</div>
<Switch
checked={active}
onChange={setActiveValue}
className={classNames(
active ? "bg-teal-500" : "bg-gray-200",
"relative ml-4 inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2"
)}>
<span
aria-hidden="true"
className={classNames(
active ? "translate-x-5" : "translate-x-0",
"inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
)}
/>
</Switch>
</Switch.Group>
</ul>
<button
type="button"
onClick={() => {
setToken(null)
route("/")
}}
className="flex w-full justify-center rounded-md border border-transparent bg-red-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
Logout
</button>
</div>
</div>
)
}
export default Settings

View File

@ -1,11 +0,0 @@
{
"extends": "plasmo/templates/tsconfig.base",
"exclude": ["node_modules"],
"include": [".plasmo/index.d.ts", "./**/*.ts", "./**/*.tsx"],
"compilerOptions": {
"paths": {
"~*": ["./*"]
},
"baseUrl": "."
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +0,0 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
* This is especially useful for Docker builds.
*/
!process.env.SKIP_ENV_VALIDATION && (await import("./src/env.mjs"));
/** @type {import("next").NextConfig} */
const config = {
reactStrictMode: true,
typescript: {
ignoreBuildErrors: true,
},
/**
* If you have the "experimental: { appDir: true }" setting enabled, then you
* must comment the below `i18n` config out.
*
* @see https://github.com/vercel/next.js/issues/41980
*/
i18n: {
locales: ["en"],
defaultLocale: "en",
},
};
export default config;

View File

@ -1,58 +1,84 @@
{ {
"name": "page-assist", "name": "pageassist",
"version": "0.1.0", "displayName": "Page Assist - A Web UI for Local AI Models",
"private": true, "version": "1.0.0",
"description": "Use your locally running AI models to assist you in your web browsing.",
"author": "n4ze3m",
"scripts": { "scripts": {
"build": "next build", "dev": "plasmo dev",
"dev": "next dev", "build": "plasmo build",
"postinstall": "prisma generate", "package": "plasmo package"
"lint": "next lint",
"start": "next start"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^1.7.13", "@ant-design/cssinjs": "^1.18.4",
"@heroicons/react": "^2.0.17", "@headlessui/react": "^1.7.18",
"@mantine/form": "^6.0.7", "@heroicons/react": "^2.1.1",
"@prisma/client": "^4.11.0", "@langchain/community": "^0.0.21",
"@supabase/auth-helpers-nextjs": "^0.6.0", "@langchain/core": "^0.1.22",
"@supabase/auth-helpers-react": "^0.3.1", "@mantine/form": "^7.5.0",
"@supabase/auth-ui-react": "^0.3.5", "@plasmohq/storage": "^1.9.0",
"@supabase/auth-ui-shared": "^0.1.3", "@tailwindcss/forms": "^0.5.7",
"@supabase/supabase-js": "^2.15.0", "@tailwindcss/typography": "^0.5.10",
"@tailwindcss/forms": "^0.5.3", "@tanstack/react-query": "^5.17.19",
"@tanstack/react-query": "^4.28.0", "antd": "^5.13.3",
"@trpc/client": "^10.18.0", "axios": "^1.6.7",
"@trpc/next": "^10.18.0", "html-to-text": "^9.0.5",
"@trpc/react-query": "^10.18.0", "langchain": "^0.1.9",
"@trpc/server": "^10.18.0", "lucide-react": "^0.323.0",
"axios": "^1.3.5", "plasmo": "0.84.1",
"langchain": "^0.0.55", "property-information": "^6.4.1",
"next": "^13.2.4",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-youtube": "^10.1.0", "react-markdown": "8.0.0",
"superjson": "1.12.2", "react-router-dom": "6.10.0",
"zod": "^3.21.4" "react-syntax-highlighter": "^15.5.0",
"react-toastify": "^10.0.4",
"rehype-mathjax": "4.0.3",
"remark-gfm": "3.0.1",
"remark-math": "5.1.1",
"voy-search": "^0.6.3",
"zustand": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/eslint": "^8.21.3", "@plasmohq/prettier-plugin-sort-imports": "4.0.1",
"@types/node": "^18.15.5", "@types/chrome": "0.0.259",
"@types/prettier": "^2.7.2", "@types/html-to-text": "^9.0.4",
"@types/react": "^18.0.28", "@types/node": "20.11.9",
"@types/react-dom": "^18.0.11", "@types/react": "18.2.48",
"@typescript-eslint/eslint-plugin": "^5.56.0", "@types/react-dom": "18.2.18",
"@typescript-eslint/parser": "^5.56.0", "@types/react-syntax-highlighter": "^15.5.11",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.17",
"eslint": "^8.36.0", "postcss": "^8.4.33",
"eslint-config-next": "^13.2.4", "prettier": "3.2.4",
"postcss": "^8.4.21", "tailwindcss": "^3.4.1",
"prettier": "^2.8.6", "typescript": "5.3.3"
"prettier-plugin-tailwindcss": "^0.2.6",
"prisma": "^4.11.0",
"tailwindcss": "^3.3.0",
"typescript": "^5.0.2"
}, },
"ct3aMetadata": { "manifest": {
"initVersion": "7.10.3" "host_permissions": [
"http://*/*",
"https://*/*"
],
"commands": {
"_execute_action": {
"suggested_key": {
"default": "Ctrl+Shift+L"
}
},
"execute_side_panel": {
"description": "Open the side panel",
"suggested_key": {
"default": "Ctrl+Shift+P"
}
}
},
"permissions": [
"storage",
"activeTab",
"scripting",
"declarativeNetRequest",
"action",
"unlimitedStorage",
"contextMenus"
]
} }
} }

6863
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +0,0 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
module.exports = config;

View File

@ -1,6 +0,0 @@
/** @type {import("prettier").Config} */
const config = {
plugins: [require.resolve("prettier-plugin-tailwindcss")],
};
module.exports = config;

View File

@ -1,27 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @db.Uuid
email String?
access_token String?
created_at DateTime? @default(now()) @db.Timestamptz(6)
Website Website[]
}
model Website {
id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
title String?
icon String?
html String?
user_id String? @db.Uuid
created_at DateTime? @default(now()) @db.Timestamptz(6)
url String?
User User? @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -1,5 +0,0 @@
dev.bat
*.pyc
*.pyo
.pytest_cache/
*/.env

View File

@ -1,7 +0,0 @@
FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .

View File

@ -1,40 +0,0 @@
import supabase
import os
class SupaService:
def __init__(self):
self.supabase_url = os.environ.get("SUPABASE_URL")
self.supabase_key = os.environ.get("SUPABASE_KEY")
self.supabase = supabase.create_client(self.supabase_url, self.supabase_key)
def validate_user(self, token):
user = self.supabase.table("User").select("*").eq("access_token", token).execute()
return user
def save_webiste(self, title: str, icon: str, html: str, url: str, user_id: str):
result = self.supabase.table("Website").insert( {
"title": title,
"icon": icon,
"html": html,
"url": url,
"user_id": user_id
}).execute()
return result
def find_website(self, id: str, user_id: str):
result = self.supabase.table("Website").select("*").eq("id", id).eq("user_id", user_id).execute()
return result
def get_user(self, jwt: str):
try:
result = self.supabase.auth.get_user(jwt)
return result
except:
return None

View File

@ -1,160 +0,0 @@
from models import ChatBody, ChatAppBody
from bs4 import BeautifulSoup
from langchain.docstore.document import Document as LDocument
from langchain.vectorstores.faiss import FAISS
from langchain.embeddings.openai import OpenAIEmbeddings
from langchain.llms import OpenAI
from langchain.text_splitter import CharacterTextSplitter
from langchain.chains import ConversationalRetrievalChain
from langchain.prompts.chat import (
ChatPromptTemplate,
SystemMessagePromptTemplate,
HumanMessagePromptTemplate
)
from langchain.vectorstores import Chroma
from db.supa import SupaService
supabase = SupaService()
async def chat_app_handler(body: ChatAppBody, jwt: str):
try:
user = supabase.get_user(jwt)
if not user:
return {
"bot_response": "You are not logged in",
"human_message": body.user_message,
}
user_id = user.user.id
website_response = supabase.find_website(body.id, user_id)
website = website_response.data
if len(website) == 0:
return {
"bot_response": "Website not found",
"human_message": body.user_message,
}
website = website[0]
text = website["html"]
text = text.strip()
result = [LDocument(page_content=text, metadata={"source": "test"})]
token_splitter = CharacterTextSplitter(
chunk_size=1000, chunk_overlap=0)
doc = token_splitter.split_documents(result)
print(f'Number of documents: {len(doc)}')
vectorstore = Chroma.from_documents(doc, OpenAIEmbeddings())
messages = [
SystemMessagePromptTemplate.from_template("""You are PageAssist bot. Answer the question based on the following context from the webpage you are on.
Answer must be in markdown format.
-----------------
context:
{context}
"""),
HumanMessagePromptTemplate.from_template("{question}")
]
prompt = ChatPromptTemplate.from_messages(messages)
chat = ConversationalRetrievalChain.from_llm(OpenAI(temperature=0, model_name="gpt-3.5-turbo"), vectorstore.as_retriever(
search_kwargs={"k": 1}), return_source_documents=True, qa_prompt=prompt,)
history = [(d["human_message"], d["bot_response"])
for d in body.history]
response = chat({
"question": body.user_message,
"chat_history": history
})
answer = response["answer"]
answer = answer[answer.find(":")+1:].strip()
return {
"bot_response": answer,
"human_message": body.user_message,
}
except Exception as e:
print(e)
return {
"bot_response": "Something went wrong please try again later",
"human_message": body.user_message,
}
async def chat_extension_handler(body: ChatBody):
try:
soup = BeautifulSoup(body.html, 'lxml')
iframe = soup.find('iframe', id='pageassist-iframe')
if iframe:
iframe.decompose()
div = soup.find('div', id='pageassist-icon')
if div:
div.decompose()
div = soup.find('div', id='__plasmo-loading__')
if div:
div.decompose()
text = soup.get_text()
text = text.strip()
result = [LDocument(page_content=text, metadata={"source": "test"})]
token_splitter = CharacterTextSplitter(
chunk_size=1000, chunk_overlap=0)
doc = token_splitter.split_documents(result)
print(f'Number of documents: {len(doc)}')
vectorstore = Chroma.from_documents(doc, OpenAIEmbeddings())
messages = [
SystemMessagePromptTemplate.from_template("""You are PageAssist bot. Answer the question based on the following context from the webpage you are on.
Answer must be in markdown format.
-----------------
context:
{context}
"""),
HumanMessagePromptTemplate.from_template("{question}")
]
prompt = ChatPromptTemplate.from_messages(messages)
chat = ConversationalRetrievalChain.from_llm(OpenAI(temperature=0, model_name="gpt-3.5-turbo"), vectorstore.as_retriever(
search_kwargs={"k": 1}), return_source_documents=True, qa_prompt=prompt,)
history = [(d["human_message"], d["bot_response"])
for d in body.history]
response = chat({
"question": body.user_message,
"chat_history": history
})
answer = response["answer"]
answer = answer[answer.find(":")+1:].strip()
return {
"bot_response": answer,
"human_message": body.user_message,
}
except Exception as e:
print(e)
return {
"bot_response": "Something went wrong please try again later",
"human_message": body.user_message,
}

View File

@ -1,57 +0,0 @@
from fastapi import HTTPException, Header
from models import UserValidation, SaveChatToApp
from db.supa import SupaService
from bs4 import BeautifulSoup
supabase = SupaService()
async def validate_user_handler(user: UserValidation):
if user.token is None or user.token == "":
raise HTTPException(status_code=400, detail="Token is required")
user = supabase.validate_user(user.token)
data = user.data
if len(data) == 0:
raise HTTPException(status_code=400, detail="Invalid token")
return {
"status": "success",
}
async def save_website_handler(body: SaveChatToApp, x_auth_token):
try:
if x_auth_token is None or x_auth_token == "":
raise HTTPException(status_code=400, detail="Token is required")
user = supabase.validate_user(x_auth_token)
data = user.data
if len(data) == 0:
raise HTTPException(status_code=400, detail="Invalid token")
soup = BeautifulSoup(body.html, 'lxml')
title = soup.title.string if soup.title else "Untitled Page"
icon = soup.find('link', rel='icon').get('href') if soup.find('link', rel='icon') else None
iframe = soup.find('iframe', id='pageassist-iframe')
if iframe:
iframe.decompose()
div = soup.find('div', id='pageassist-icon')
if div:
div.decompose()
div = soup.find('div', id='__plasmo-loading__')
if div:
div.decompose()
text = soup.get_text()
result = supabase.save_webiste(html=text, title=title, icon=icon, url=body.url, user_id=data[0]["id"])
return {
"status": "Success"
}
except Exception as e:
raise HTTPException(status_code=500, detail="Internal server error")

View File

@ -1,30 +0,0 @@
from fastapi import FastAPI
import os
from uvicorn import run
from fastapi.middleware.cors import CORSMiddleware
from routers import chat, user
os.environ["OPENAI_API_KEY"] = os.environ.get("OPENAI_API_KEY")
app = FastAPI()
origins = ["*"]
methods = ["*"]
headers = ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=methods,
allow_headers=headers
)
app.include_router(chat.router)
app.include_router(user.router)
if __name__ == "__main__":
port = int(os.environ.get('PORT', 5000))
run(app, host="0.0.0.0", port=port)

View File

@ -1,2 +0,0 @@
from .chat import ChatBody, ChatAppBody
from .user import UserValidation, SaveChatToApp

View File

@ -1,13 +0,0 @@
from pydantic import BaseModel
class ChatBody(BaseModel):
user_message: str
html: str
history: list
# url: str
class ChatAppBody(BaseModel):
id: str
user_message: str
url: str
history: list

View File

@ -1,11 +0,0 @@
from pydantic import BaseModel
class UserValidation(BaseModel):
token: str
class SaveChatToApp(BaseModel):
html: str
url: str

View File

@ -1,14 +0,0 @@
fastapi
uvicorn
pydantic
pandas
openai
beautifulsoup4
numpy
pydantic
langchain
lxml
faiss-cpu
supabase
tiktoken
chromadb

View File

@ -1,14 +0,0 @@
from fastapi import APIRouter, Header
from models import ChatBody, ChatAppBody
from handlers.chat import chat_extension_handler, chat_app_handler
router = APIRouter(prefix="/api/v1")
@router.post("/chat/chrome", tags=["chat"])
async def chat_extension(body: ChatBody):
return await chat_extension_handler(body)
@router.post("/chat/app", tags=["chat"])
async def chat_app(body: ChatAppBody, x_auth_token: str = Header()):
return await chat_app_handler(body, x_auth_token)

View File

@ -1,15 +0,0 @@
from fastapi import APIRouter, Header
from models import UserValidation, SaveChatToApp
from handlers.user import validate_user_handler, save_website_handler
router = APIRouter(prefix="/api/v1")
@router.post("/user/validate", tags=["user"])
async def validate_user(user: UserValidation):
return await validate_user_handler(user)
@router.post("/user/save", tags=["user"])
async def save_website(body: SaveChatToApp, x_auth_token: str = Header(None)):
return await save_website_handler(body, x_auth_token)

48
src/background.ts Normal file
View File

@ -0,0 +1,48 @@
export {}
chrome.runtime.onMessage.addListener(async (message) => {
if (message.type === "sidepanel") {
chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
const tab = tabs[0]
await chrome.sidePanel.open({
tabId: tab.id
})
})
}
})
chrome.action.onClicked.addListener((tab) => {
chrome.tabs.create({ url: chrome.runtime.getURL("options.html") })
})
chrome.commands.onCommand.addListener((command) => {
switch (command) {
case "execute_side_panel":
chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
const tab = tabs[0]
await chrome.sidePanel.open({
tabId: tab.id
})
})
break
default:
break
}
})
chrome.contextMenus.create({
id: "open-side-panel-pa",
title: "Open Side Panel to Chat",
contexts: ["all"]
})
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId === "open-side-panel-pa") {
chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
const tab = tabs[0]
await chrome.sidePanel.open({
tabId: tab.id
})
})
}
})

View File

@ -0,0 +1,171 @@
import { BaseLanguageModel } from "langchain/base_language";
import { Document } from "@langchain/core/documents";
import {
ChatPromptTemplate,
MessagesPlaceholder,
PromptTemplate,
} from "langchain/prompts";
import { AIMessage, BaseMessage, HumanMessage } from "langchain/schema";
import { StringOutputParser } from "langchain/schema/output_parser";
import {
Runnable,
RunnableBranch,
RunnableLambda,
RunnableMap,
RunnableSequence,
} from "langchain/schema/runnable";
import type { ChatHistory } from "~store";
type RetrievalChainInput = {
chat_history: string;
question: string;
};
export function groupMessagesByConversation(messages: ChatHistory) {
if (messages.length % 2 !== 0) {
messages.pop();
}
const groupedMessages = [];
for (let i = 0; i < messages.length; i += 2) {
groupedMessages.push({
human: messages[i].content,
ai: messages[i + 1].content,
});
}
return groupedMessages;
}
const formatChatHistoryAsString = (history: BaseMessage[]) => {
return history
.map((message) => `${message._getType()}: ${message.content}`)
.join("\n");
};
const formatDocs = (docs: Document[]) => {
return docs
.map((doc, i) => `<doc id='${i}'>${doc.pageContent}</doc>`)
.join("\n");
};
const serializeHistory = (input: any) => {
const chatHistory = input.chat_history || [];
const convertedChatHistory = [];
for (const message of chatHistory) {
if (message.human !== undefined) {
convertedChatHistory.push(new HumanMessage({ content: message.human }));
}
if (message["ai"] !== undefined) {
convertedChatHistory.push(new AIMessage({ content: message.ai }));
}
}
return convertedChatHistory;
};
const createRetrieverChain = (
llm: BaseLanguageModel,
retriever: Runnable,
question_template: string
) => {
const CONDENSE_QUESTION_PROMPT =
PromptTemplate.fromTemplate(question_template);
const condenseQuestionChain = RunnableSequence.from([
CONDENSE_QUESTION_PROMPT,
llm,
new StringOutputParser(),
]).withConfig({
runName: "CondenseQuestion",
});
const hasHistoryCheckFn = RunnableLambda.from(
(input: RetrievalChainInput) => input.chat_history.length > 0
).withConfig({ runName: "HasChatHistoryCheck" });
const conversationChain = condenseQuestionChain.pipe(retriever).withConfig({
runName: "RetrievalChainWithHistory",
});
const basicRetrievalChain = RunnableLambda.from(
(input: RetrievalChainInput) => input.question
)
.withConfig({
runName: "Itemgetter:question",
})
.pipe(retriever)
.withConfig({ runName: "RetrievalChainWithNoHistory" });
return RunnableBranch.from([
[hasHistoryCheckFn, conversationChain],
basicRetrievalChain,
]).withConfig({
runName: "FindDocs",
});
};
export const createChatWithWebsiteChain = ({
llm,
question_template,
question_llm,
retriever,
response_template,
}: {
llm: BaseLanguageModel;
question_llm: BaseLanguageModel;
retriever: Runnable;
question_template: string;
response_template: string;
}) => {
const retrieverChain = createRetrieverChain(
question_llm,
retriever,
question_template
);
const context = RunnableMap.from({
context: RunnableSequence.from([
({ question, chat_history }) => {
return {
question: question,
chat_history: formatChatHistoryAsString(chat_history),
};
},
retrieverChain,
RunnableLambda.from(formatDocs).withConfig({
runName: "FormatDocumentChunks",
}),
]),
question: RunnableLambda.from(
(input: RetrievalChainInput) => input.question
).withConfig({
runName: "Itemgetter:question",
}),
chat_history: RunnableLambda.from(
(input: RetrievalChainInput) => input.chat_history
).withConfig({
runName: "Itemgetter:chat_history",
}),
}).withConfig({ tags: ["RetrieveDocs"] });
const prompt = ChatPromptTemplate.fromMessages([
["system", response_template],
new MessagesPlaceholder("chat_history"),
["human", "{question}"],
]);
const responseSynthesizerChain = RunnableSequence.from([
prompt,
llm,
new StringOutputParser(),
]).withConfig({
tags: ["GenerateResponse"],
});
return RunnableSequence.from([
{
question: RunnableLambda.from(
(input: RetrievalChainInput) => input.question
).withConfig({
runName: "Itemgetter:question",
}),
chat_history: RunnableLambda.from(serializeHistory).withConfig({
runName: "SerializeHistory",
}),
},
context,
responseSynthesizerChain,
]);
};

View File

@ -1,276 +0,0 @@
import { TrashIcon } from "@heroicons/react/24/outline";
import { useSupabaseClient } from "@supabase/auth-helpers-react";
import Link from "next/link";
import { useRouter } from "next/router";
import React from "react";
import { api } from "~/utils/api";
import { iconUrl } from "~/utils/icon";
import { useForm } from "@mantine/form";
import axios from "axios";
import { useMutation } from "@tanstack/react-query";
type Message = {
isBot: boolean;
message: string;
};
type History = {
bot_response: string;
human_message: string;
};
type Props = {
title: string | null;
id: string;
created_at: Date | null;
icon: string | null;
url: string | null;
};
export const CahtBox = (props: Props) => {
const supabase = useSupabaseClient();
const form = useForm({
initialValues: {
message: "",
isBot: false,
},
});
const sendToBot = async (message: string) => {
const { data } = await supabase.auth.getSession();
const response = await axios.post(
`${process.env.NEXT_PUBLIC_PAGEASSIST_URL}/api/v1/chat/app`,
{
user_message: message,
history: history,
url: props.url,
id: props.id,
},
{
headers: {
"X-Auth-Token": data.session?.access_token,
},
}
);
return response.data;
};
const { mutateAsync: sendToBotAsync, isLoading: isSending } = useMutation(
sendToBot,
{
onSuccess: (data) => {
setMessages([...messages, { isBot: true, message: data.bot_response }]);
setHistory([...history, data]);
},
onError: (error) => {
setMessages([
...messages,
{ isBot: true, message: "Something went wrong" },
]);
},
}
);
const [messages, setMessages] = React.useState<Message[]>([
{
isBot: true,
message: "Hi, I'm PageAssist Bot. How can I help you?",
},
]);
// const fetchSession = async () => {
// const {data}= await supabase.auth.getSession();
// data.session?.access_token
// }
const [history, setHistory] = React.useState<History[]>([]);
const divRef = React.useRef(null);
React.useEffect(() => {
//@ts-ignore
divRef.current.scrollIntoView({ behavior: "smooth" });
});
const router = useRouter();
const { mutateAsync: deleteChatByIdAsync, isLoading: isDeleting } =
api.chat.deleteChatById.useMutation({
onSuccess: () => {
router.push("/dashboard");
},
});
return (
<div className="flex flex-col border bg-white">
{/* header */}
<div className="bg-grey-lighter flex flex-row items-center justify-between px-3 py-2">
<Link
target="_blank"
href={props.url ? props.url : "#"}
className="flex items-center"
>
<div>
<img
className="h-10 w-10 rounded-full"
//@ts-ignore
src={iconUrl(props?.icon, props?.url)}
/>
</div>
<div className="ml-4">
<p className="text-grey-darkest">
{props?.title && props?.title?.length > 100
? props?.title?.slice(0, 100) + "..."
: props?.title}
</p>
<p className="mt-1 text-xs text-gray-400">
{props.url && new URL(props.url).hostname}
</p>
</div>
</Link>
<div className="flex">
<button
onClick={async () => {
const isOk = confirm(
"Are you sure you want to delete this chat?"
);
if (isOk) {
await deleteChatByIdAsync({
id: props.id,
});
}
}}
disabled={isDeleting}
type="button"
className="inline-flex items-center rounded-full border border-transparent bg-red-600 p-1.5 text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
>
{isDeleting ? (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
className="h-5 w-5 animate-spin fill-white text-white dark:text-gray-600"
viewBox="0 0 100 101"
>
<path
fill="currentColor"
d="M100 50.59c0 27.615-22.386 50.001-50 50.001s-50-22.386-50-50 22.386-50 50-50 50 22.386 50 50zm-90.919 0c0 22.6 18.32 40.92 40.919 40.92 22.599 0 40.919-18.32 40.919-40.92 0-22.598-18.32-40.918-40.919-40.918-22.599 0-40.919 18.32-40.919 40.919z"
></path>
<path
fill="currentFill"
d="M93.968 39.04c2.425-.636 3.894-3.128 3.04-5.486A50 50 0 0041.735 1.279c-2.474.414-3.922 2.919-3.285 5.344.637 2.426 3.12 3.849 5.6 3.484a40.916 40.916 0 0144.131 25.769c.902 2.34 3.361 3.802 5.787 3.165z"
></path>
</svg>
) : (
<TrashIcon className="h-5 w-5" aria-hidden="true" />
)}
</button>
</div>
</div>
{/* */}
<div
style={{ height: "calc(100vh - 260px)" }}
className="flex-grow overflow-auto"
>
<div className="px-3 py-2">
{messages.map((message, index) => {
return (
<div
key={index}
className={
message.isBot
? "mt-2 flex w-full max-w-xs space-x-3"
: "ml-auto mt-2 flex w-full max-w-xs justify-end space-x-3"
}
>
<div>
<div
className={
message.isBot
? "rounded-r-lg rounded-bl-lg bg-gray-300 p-3"
: "rounded-l-lg rounded-br-lg bg-blue-600 p-3 text-white"
}
>
<p className="text-sm">
{/* <ReactMarkdown>{message.message}</ReactMarkdown> */}
{message.message}
</p>
</div>
</div>
</div>
);
})}
{isSending && (
<div className="mt-2 flex w-full max-w-xs space-x-3">
<div>
<div className="rounded-r-lg rounded-bl-lg bg-gray-300 p-3">
<p className="text-sm">Hold on, I'm looking...</p>
</div>
</div>
</div>
)}
<div ref={divRef} />
</div>
</div>
<div className="items-center bg-gray-300 px-4 py-4">
<form
onSubmit={form.onSubmit(async (values) => {
setMessages([...messages, values]);
form.reset();
await sendToBotAsync(values.message);
})}
>
<div className="flex-grow space-y-6">
<div className="flex">
<span className="mr-3">
<button
disabled={isSending}
onClick={() => {
setHistory([]);
setMessages([
{
message: "Hi, I'm PageAssist. How can I help you?",
isBot: true,
},
]);
}}
className="inline-flex h-10 items-center rounded-md border border-gray-700 bg-white px-3 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2"
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className="h-5 w-5 text-gray-600"
>
<path d="M18.37 2.63 14 7l-1.59-1.59a2 2 0 0 0-2.82 0L8 7l9 9 1.59-1.59a2 2 0 0 0 0-2.82L17 10l4.37-4.37a2.12 2.12 0 1 0-3-3Z"></path>
<path d="M9 8c-2 3-4 3.5-7 4l8 10c2-1 6-5 6-7"></path>
<path d="M14.5 17.5 4.5 15"></path>
</svg>
</button>
</span>
<div className="flex-grow">
<input
disabled={isSending}
className="flex h-10 w-full items-center rounded px-3 text-sm"
type="text"
required
placeholder="Type your message…"
{...form.getInputProps("message")}
/>
</div>
</div>
</div>
</form>
</div>
</div>
);
};

View File

@ -1,26 +0,0 @@
import { useRouter } from "next/router";
import React from "react";
import { api } from "~/utils/api";
import { CahtBox } from "./ChatBox";
export const DashboardChatBody = () => {
const router = useRouter();
const { id } = router.query;
const { data: chat, status } = api.chat.getChatById.useQuery(
{ id: id as string },
{
onError: (err) => {
router.push("/dashboard");
},
}
);
return (
<div>
{status === "loading" && <div>Loading...</div>}
{status === "success" && <CahtBox {...chat} />}
</div>
);
};

View File

@ -0,0 +1,97 @@
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import remarkGfm from "remark-gfm"
import { nightOwl } from "react-syntax-highlighter/dist/cjs/styles/prism"
import rehypeMathjax from "rehype-mathjax"
import remarkMath from "remark-math"
import ReactMarkdown from "react-markdown"
import "property-information"
import { ClipboardIcon, CheckIcon } from "@heroicons/react/24/outline"
import React from "react"
import { Tooltip } from "antd"
export default function Markdown({ message }: { message: string }) {
const [isBtnPressed, setIsBtnPressed] = React.useState(false)
React.useEffect(() => {
if (isBtnPressed) {
setTimeout(() => {
setIsBtnPressed(false)
}, 4000)
}
}, [isBtnPressed])
return (
<React.Fragment>
<ReactMarkdown
className="prose break-words dark:prose-invert text-sm prose-p:leading-relaxed prose-pre:p-0 dark:prose-dark"
// remarkPlugins={[remarkGfm, remarkMath]}
// rehypePlugins={[rehypeMathjax]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "")
return !inline ? (
<div className="code relative text-base bg-gray-800 rounded-md overflow-hidden">
<div className="flex items-center justify-between py-1.5 px-4">
<span className="text-xs lowercase text-gray-200">
{className && className.replace("language-", "")}
</span>
<div className="flex items-center">
<Tooltip title="Copy to clipboard">
<button
onClick={() => {
navigator.clipboard.writeText(children[0] as string)
setIsBtnPressed(true)
}}
className="flex gap-1.5 items-center rounded bg-none p-1 text-xs text-gray-200 hover:bg-gray-700 hover:text-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 focus:ring-offset-gray-100">
{!isBtnPressed ? (
<ClipboardIcon className="h-4 w-4" />
) : (
<CheckIcon className="h-4 w-4 text-green-400" />
)}
</button>
</Tooltip>
</div>
</div>
<SyntaxHighlighter
{...props}
children={String(children).replace(/\n$/, "")}
style={nightOwl}
key={Math.random()}
customStyle={{
margin: 0,
fontSize: "1rem",
lineHeight: "1.5rem"
}}
language={(match && match[1]) || ""}
codeTagProps={{
className: "text-sm"
}}
/>
</div>
) : (
<code className={`${className} font-semibold`} {...props}>
{children}
</code>
)
},
a({ node, ...props }) {
return (
<a
target="_blank"
rel="noreferrer"
className="text-blue-500 text-sm hover:underline"
{...props}>
{props.children}
</a>
)
},
p({ children }) {
return <p className="mb-2 last:mb-0">{children}</p>
}
}}>
{message}
</ReactMarkdown>
</React.Fragment>
)
}

View File

@ -0,0 +1,97 @@
import { CheckIcon, ClipboardIcon } from "@heroicons/react/24/outline"
import Markdown from "../../Common/Markdown"
import React from "react"
type Props = {
message: string
hideCopy?: boolean
botAvatar?: JSX.Element
userAvatar?: JSX.Element
isBot: boolean
name: string
images?: string[]
}
export const PlaygroundMessage = (props: Props) => {
const [isBtnPressed, setIsBtnPressed] = React.useState(false)
React.useEffect(() => {
if (isBtnPressed) {
setTimeout(() => {
setIsBtnPressed(false)
}, 4000)
}
}, [isBtnPressed])
return (
<div
className={`group w-full text-gray-800 dark:text-gray-100 border-b border-black/10 dark:border-gray-900/50 `}>
<div className="text-base gap-4 md:gap-6 md:max-w-2xl lg:max-w-xl xl:max-w-3xl flex lg:px-0 m-auto w-full">
<div className="flex flex-row gap-4 md:gap-6 md:max-w-2xl lg:max-w-xl xl:max-w-3xl p-4 md:py-6 lg:px-0 m-auto w-full">
<div className="w-8 flex flex-col relative items-end">
<div className="relative h-7 w-7 p-1 rounded-sm text-white flex items-center justify-center text-opacity-100r">
{props.isBot ? (
!props.botAvatar ? (
<div className="absolute h-8 w-8 rounded-full bg-gradient-to-r from-green-300 to-purple-400"></div>
) : (
props.botAvatar
)
) : !props.userAvatar ? (
<div className="absolute h-8 w-8 rounded-full from-blue-400 to-blue-600 bg-gradient-to-r"></div>
) : (
props.userAvatar
)}
</div>
</div>
<div className="relative flex w-[calc(100%-50px)] flex-col gap-1 md:gap-3 lg:w-[calc(100%-115px)]">
{props.isBot && (
<span className="absolute mb-8 -top-4 left-0 text-xs text-gray-400 dark:text-gray-500">
{props.name}
</span>
)}
<div className="flex flex-grow flex-col gap-3">
<Markdown message={props.message} />
</div>
{/* source if aviable */}
{props.images && (
<div className="flex md:max-w-2xl lg:max-w-xl xl:max-w-3xl mt-4 m-auto w-full">
{props.images
.filter((image) => image.length > 0)
.map((image, index) => (
<div
key={index}
className="h-full rounded-md shadow relative">
<img
src={image}
alt="Uploaded"
className="h-full w-auto object-cover rounded-md min-w-[50px]"
/>
</div>
))}
</div>
)}
{props.isBot && (
<div className="flex space-x-2">
{!props.hideCopy && (
<button
onClick={() => {
navigator.clipboard.writeText(props.message)
setIsBtnPressed(true)
}}
className="flex items-center justify-center w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">
{!isBtnPressed ? (
<ClipboardIcon className="w-3 h-3 text-gray-400 group-hover:text-gray-500" />
) : (
<CheckIcon className="w-3 h-3 text-green-400 group-hover:text-green-500" />
)}
</button>
)}
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,33 @@
import { useState } from "react"
type Props = {
onClick: () => void
disabled?: boolean
className?: string
text?: string
textOnSave?: string
}
export const SaveButton = ({
onClick,
disabled,
className,
text = "Save",
textOnSave = "Saved"
}: Props) => {
const [clickedSave, setClickedSave] = useState(false)
return (
<button
onClick={() => {
setClickedSave(true)
onClick()
setTimeout(() => {
setClickedSave(false)
}, 1000)
}}
disabled={disabled}
className={`bg-pink-500 text-r mt-4 hover:bg-pink-600 text-white px-4 py-2 rounded-md dark:bg-pink-600 dark:hover:bg-pink-700 ${className}`}>
{clickedSave ? textOnSave : text}
</button>
)
}

View File

@ -1,65 +0,0 @@
import { useRouter } from "next/router";
import React from "react";
export default function Empty() {
const router = useRouter();
return (
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className=" px-4 py-8 sm:px-10">
<div className="text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
vectorEffect="non-scaling-stroke"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">
No Chats Yet
</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by installing the Page Assist Chrome Extension.
</p>
<div className="mt-6">
<button
onClick={() => {
router.push(
"https://chrome.google.com/webstore/detail/page-assist/ehkjdalbpmmaddcfdilplgknkgepeakd?hl=en&authuser=2"
);
}}
type="button"
className="inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="-ml-1 mr-2 h-5 w-5"
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="4"></circle>
<path d="M21.17 8L12 8"></path>
<path d="M3.95 6.06L8.54 14"></path>
<path d="M10.88 21.94L15.46 14"></path>
</svg>
Install Extension
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,21 +0,0 @@
import React from "react";
export default function Loading() {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{/* create skelon loadinf */}
{[1, 2, 3, 4, 5, 6].map((item) => (
<div
className="flex h-35 cursor-pointer rounded-md shadow-sm transition-shadow duration-300 ease-in-out hover:shadow-lg"
key={item}
>
<div className="flex flex-1 items-center justify-between truncate rounded-md border border-gray-200 bg-white">
<div className="flex-1 truncate px-4 py-4 text-sm">
<h3 className="h-10 animate-pulse bg-gray-400 font-medium text-gray-900 hover:text-gray-600" />
</div>
</div>
</div>
))}
</div>
);
}

View File

@ -1,61 +0,0 @@
import React from "react";
import Empty from "./Empty";
import Loading from "./Loading";
import { api } from "~/utils/api";
import { ChevronRightIcon } from "@heroicons/react/24/outline";
import Link from "next/link";
import { iconUrl } from "~/utils/icon";
export default function DashboardBoby() {
const { data: savedSites, status } = api.chat.getSavedSitesForChat.useQuery();
return (
<>
{status === "loading" && <Loading />}
{status === "success" && savedSites.data.length === 0 && <Empty />}
{status === "success" && savedSites.data.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{savedSites.data.map((site, idx) => (
<Link
href={`/dashboard/chat/${site.id}`}
key={idx}
className="bg-panel-header-light border-panel-border-light hover:bg-panel-border-light hover:border-panel-border-hover-light h-30 group relative flex cursor-pointer flex-row rounded-md border px-6 py-4 text-left transition duration-150 ease-in-out hover:border-gray-300"
>
<div className="flex h-full w-full flex-col space-y-2 ">
<div className="text-scale-1200">
<div className="flex w-full flex-row justify-between gap-1">
<span
className={`flex-shrink ${
site?.title && site?.title?.length > 50
? "truncate"
: ""
}`}
>
{site.title}
</span>
<ChevronRightIcon className="h-10 w-10 text-gray-400 group-hover:text-gray-500" />
</div>
</div>
<div className="bottom-0">
<div className="flex w-full flex-row gap-1">
<img
className="h-5 w-5 rounded-md"
// @ts-ignore
src={iconUrl(site.icon, site.url)}
alt=""
/>
<span className="text-scale-1000 ml-3 flex-shrink truncate text-xs text-gray-400">
{site.url && new URL(site.url).hostname}
</span>
</div>
</div>
</div>
</Link>
))}
</div>
)}
</>
);
}

View File

@ -1,204 +0,0 @@
import { Fragment } from "react";
import { Popover, Transition } from "@headlessui/react";
import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline";
import Link from "next/link";
import { useUser } from "@supabase/auth-helpers-react";
import YouTube from "react-youtube";
export default function Hero() {
const user = useUser();
return (
<div>
<div className="relative overflow-hidden">
<div className="relative pb-16 pt-6 sm:pb-24">
<Popover>
<div className="mx-auto max-w-7xl px-4 sm:px-6">
<nav
className="relative flex items-center justify-between sm:h-10 md:justify-center"
aria-label="Global"
>
<div className="flex flex-1 items-center md:absolute md:inset-y-0 md:left-0">
<div className="flex w-full items-center justify-between md:w-auto">
<Link href="/">
<span className="sr-only">Feedback Board</span>
<img
className="h-8 w-auto sm:h-10"
src="/logo.png"
alt="Feedback Board"
/>
</Link>
<div className="-mr-2 flex items-center md:hidden">
<Popover.Button className="inline-flex items-center justify-center rounded-md bg-gray-50 p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-sky-500">
<span className="sr-only">Open main menu</span>
<Bars3Icon className="h-6 w-6" aria-hidden="true" />
</Popover.Button>
</div>
</div>
</div>
<div className="hidden md:absolute md:inset-y-0 md:right-0 md:flex md:items-center md:justify-end">
<span className="inline-flex rounded-md shadow">
{user ? (
<Link
href="/dashboard"
className="inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Dashboard
</Link>
) : (
<Link
href="/auth"
className="inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Login
</Link>
)}
</span>
</div>
</nav>
</div>
<Transition
as={Fragment}
enter="duration-150 ease-out"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-100 ease-in"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Popover.Panel
focus
className="absolute inset-x-0 top-0 z-10 origin-top-right transform p-2 transition md:hidden"
>
<div className="overflow-hidden rounded-lg bg-white shadow-md ring-1 ring-black ring-opacity-5">
<div className="flex items-center justify-between px-5 pt-4">
<div>
<img
className="h-8 w-auto"
src="/logo.png"
alt="PageAssist"
/>
</div>
<div className="-mr-2">
<Popover.Button className="inline-flex items-center justify-center rounded-md bg-white p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-sky-500">
<span className="sr-only">Close main menu</span>
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
</Popover.Button>
</div>
</div>
{user ? (
<Link
href="/dashboard"
className="m-3 inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Account
</Link>
) : (
<Link
href="/auth"
className="m-3 inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Login
</Link>
)}
</div>
</Popover.Panel>
</Transition>
</Popover>
<div className="py-24 sm:py-32 lg:pb-40">
<div className="mx-auto max-w-7xl px-6 lg:px-8">
<div className="mx-auto max-w-2xl text-center">
<h1 className="text-2xl font-bold tracking-tight text-gray-900 sm:text-5xl">
Chat with Any Webpage using PageAssist
</h1>
<p className="mt-6 text-lg leading-8 text-gray-600">
Revolutionize your browsing experience with PageAssist, an
open source Chrome extension that allows you to easily chat
with any webpage using the power of ChatGPT API.
</p>
<div className="mt-10 flex items-center justify-center gap-x-6">
<a
href="https://chrome.google.com/webstore/detail/page-assist/ehkjdalbpmmaddcfdilplgknkgepeakd?hl=en&authuser=2"
className="inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="-ml-0.5 mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="4"></circle>
<path d="M21.17 8L12 8"></path>
<path d="M3.95 6.06L8.54 14"></path>
<path d="M10.88 21.94L15.46 14"></path>
</svg>
Install Now
</a>
<a
href="https://github.com/n4ze3m/page-assist"
className="inline-flex items-center text-base font-semibold leading-7 text-gray-900 "
>
Star on GitHub
<svg
xmlns="http://www.w3.org/2000/svg"
className="ml-2 h-4 w-4"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M15 22v-4a4.8 4.8 0 00-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 004 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4M9 18c-4.51 2-5-2-7-2"></path>
</svg>
</a>
</div>
</div>
<iframe
className="yt-video relative mx-auto mt-12 w-full max-w-4xl rounded-3xl border border-gray-300 shadow-2xl dark:border-gray-700 lg:mt-20"
src="https://www.youtube.com/embed/UB1PdZ32vBc"
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
frameBorder={0}
/>
<div className="absolute inset-x-0 top-[calc(100%-13rem)] -z-10 transform-gpu overflow-hidden blur-3xl sm:top-[calc(100%-30rem)]">
<svg
className="relative left-[calc(50%+3rem)] h-[21.1875rem] max-w-none -translate-x-1/2 sm:left-[calc(50%+36rem)] sm:h-[42.375rem]"
viewBox="0 0 1155 678"
>
<path
fill="url(#b9e4a85f-ccd5-4151-8e84-ab55c66e5aa1)"
fillOpacity=".3"
d="M317.219 518.975L203.852 678 0 438.341l317.219 80.634 204.172-286.402c1.307 132.337 45.083 346.658 209.733 145.248C936.936 126.058 882.053-94.234 1031.02 41.331c119.18 108.451 130.68 295.337 121.53 375.223L855 299l21.173 362.054-558.954-142.079z"
/>
<defs>
<linearGradient
id="b9e4a85f-ccd5-4151-8e84-ab55c66e5aa1"
x1="1155.49"
x2="-78.208"
y1=".177"
y2="474.645"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#9089FC" />
<stop offset={1} stopColor="#FF80B5" />
</linearGradient>
</defs>
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,268 +0,0 @@
import { Fragment, useState } from "react";
import { Dialog, Menu, Transition } from "@headlessui/react";
import {
Bars3CenterLeftIcon,
CogIcon,
HomeIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import { ChevronDownIcon } from "@heroicons/react/20/solid";
import { useSupabaseClient, useUser } from "@supabase/auth-helpers-react";
import { useRouter } from "next/router";
import Link from "next/link";
const navigation = [
{ name: "Home", href: "/dashboard", icon: HomeIcon, current: true },
{
name: "Settings",
href: "/dashboard/settings",
icon: CogIcon,
current: false,
},
];
//@ts-ignore
function classNames(...classes) {
return classes.filter(Boolean).join(" ");
}
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const user = useUser();
const router = useRouter();
const supabase = useSupabaseClient();
return (
<>
<div className="min-h-full">
<Transition.Root show={sidebarOpen} as={Fragment}>
<Dialog
as="div"
className="relative z-40 lg:hidden"
onClose={setSidebarOpen}
>
<Transition.Child
as={Fragment}
enter="transition-opacity ease-linear duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-linear duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" />
</Transition.Child>
<div className="fixed inset-0 z-40 flex">
<Transition.Child
as={Fragment}
enter="transition ease-in-out duration-300 transform"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full"
>
<Dialog.Panel className="relative flex w-full max-w-xs flex-1 flex-col bg-white pb-4 pt-5">
<Transition.Child
as={Fragment}
enter="ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute right-0 top-0 -mr-12 pt-2">
<button
type="button"
className="ml-1 flex h-10 w-10 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
onClick={() => setSidebarOpen(false)}
>
<span className="sr-only">Close sidebar</span>
<XMarkIcon
className="h-6 w-6 text-white"
aria-hidden="true"
/>
</button>
</div>
</Transition.Child>
<div className="flex flex-shrink-0 items-center px-4">
<img
className="h-10 w-auto"
src="/logo.png"
alt="PageAssist"
/>
</div>
<nav
className="mt-5 h-full flex-shrink-0 divide-y divide-gray-200 overflow-y-auto"
aria-label="Sidebar"
>
<div className="space-y-1 px-2">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className={classNames(
router.pathname === item.href
? "bg-gray-100 text-gray-900"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900",
"group flex items-center rounded-md px-2 py-2 text-sm font-medium"
)}
aria-current={
router.pathname === item.href ? "page" : undefined
}
>
<item.icon
className={classNames(
router.pathname === item.href
? "text-gray-500"
: "text-gray-400 group-hover:text-gray-500",
"mr-3 h-6 w-6 flex-shrink-0"
)}
aria-hidden="true"
/>
{item.name}
</Link>
))}
</div>
</nav>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
<div className="flex flex-grow flex-col overflow-y-auto border-r border-gray-200 bg-white pb-4 pt-5">
<div className="flex flex-shrink-0 items-center px-4">
<img className="h-10 w-auto" src="/logo.png" alt="PageAssist" />
</div>
<nav
className="mt-5 flex flex-1 flex-col divide-y divide-gray-200 overflow-y-auto"
aria-label="Sidebar"
>
<div className="space-y-1 px-2">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className={classNames(
router.pathname === item.href
? "bg-gray-100 text-gray-900"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900",
"group flex items-center rounded-md px-2 py-2 text-sm font-medium"
)}
aria-current={
router.pathname === item.href ? "page" : undefined
}
>
<item.icon
className={classNames(
router.pathname === item.href
? "text-gray-500"
: "text-gray-400 group-hover:text-gray-500",
"mr-3 h-6 w-6 flex-shrink-0"
)}
aria-hidden="true"
/>
{item.name}
</Link>
))}
</div>
</nav>
</div>
</div>
<div className="flex flex-1 flex-col lg:pl-64">
<div className="flex h-16 flex-shrink-0 border-b border-gray-200 bg-white lg:border-none">
<button
type="button"
className="border-r border-gray-200 px-4 text-gray-400 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-gray-200 lg:hidden"
onClick={() => setSidebarOpen(true)}
>
<span className="sr-only">Open sidebar</span>
<Bars3CenterLeftIcon className="h-6 w-6" aria-hidden="true" />
</button>
{/* Search bar */}
<div className="flex flex-1 justify-end px-4 sm:px-6 lg:mx-auto lg:max-w-6xl lg:px-8">
<div className="ml-4 flex items-center md:ml-6">
{/* Profile dropdown */}
<Menu as="div" className="relative ml-3">
<div>
<Menu.Button className="flex max-w-xs items-center rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-gray-200 focus:ring-offset-2 lg:rounded-md lg:p-2 lg:hover:bg-gray-50">
<img
className="h-8 w-8 rounded-full"
src={`https://ui-avatars.com/api/?name=${user?.email}`}
alt=""
/>
<span className="ml-3 hidden text-sm font-medium text-gray-700 lg:block">
<span className="sr-only">Open user menu for </span>
{user?.email}
</span>
<ChevronDownIcon
className="ml-1 hidden h-5 w-5 flex-shrink-0 text-gray-400 lg:block"
aria-hidden="true"
/>
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<Menu.Item>
{({ active }) => (
<Link
href="/dashboard/settings"
className={classNames(
active ? "bg-gray-100" : "",
"block px-4 py-2 text-sm text-gray-700"
)}
>
Settings
</Link>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<div
onClick={async () => {
await supabase.auth.signOut();
router.push("/");
}}
className={classNames(
active ? "bg-gray-100" : "",
"block px-4 py-2 text-sm text-gray-700"
)}
>
Logout
</div>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
</div>
</div>
</div>
<main className="flex-1 pb-8">
<div className="mt-8">
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
{children}
</div>
</div>
</main>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,129 @@
import React, { Fragment, useState } from "react"
import { Dialog, Menu, Transition } from "@headlessui/react"
import {
Bars3BottomLeftIcon,
XMarkIcon,
TagIcon,
CircleStackIcon,
CogIcon,
ChatBubbleLeftIcon,
Bars3Icon,
Bars4Icon,
ArrowPathIcon
} from "@heroicons/react/24/outline"
import logoImage from "data-base64:~assets/icon.png"
import { Link, useParams, useLocation, useNavigate } from "react-router-dom"
import { Sidebar } from "./Sidebar"
import { Drawer, Layout, Modal, Select } from "antd"
import { useQuery } from "@tanstack/react-query"
import { fetchModels } from "~services/ollama"
import { useMessageOption } from "~hooks/useMessageOption"
import { PanelLeftIcon, Settings2 } from "lucide-react"
import { Settings } from "./Settings"
import { useDarkMode } from "~hooks/useDarkmode"
const navigation = [
{ name: "Embed", href: "/bot/:id", icon: TagIcon },
{
name: "Preview",
href: "/bot/:id/preview",
icon: ChatBubbleLeftIcon
},
{
name: "Data Sources",
href: "/bot/:id/data-sources",
icon: CircleStackIcon
},
{
name: "Settings",
href: "/bot/:id/settings",
icon: CogIcon
}
]
//@ts-ignore -
function classNames(...classes) {
return classes.filter(Boolean).join(" ")
}
export default function OptionLayout({
children
}: {
children: React.ReactNode
}) {
const [sidebarOpen, setSidebarOpen] = useState(false)
const [open, setOpen] = useState(false)
const {
data: models,
isLoading: isModelsLoading,
refetch: refetchModels,
isFetching: isModelsFetching
} = useQuery({
queryKey: ["fetchModel"],
queryFn: fetchModels
})
const { selectedModel, setSelectedModel } = useMessageOption()
return (
<Layout className="bg-white dark:bg-[#171717] md:flex">
<div className="flex items-center p-3 fixed flex-row justify-between border-b border-gray-200 dark:border-gray-600 bg-white dark:bg-[#171717] w-full z-10">
<div className="flex items-center flex-row gap-3">
<div>
<button
className="text-gray-500 dark:text-gray-400"
onClick={() => setSidebarOpen(true)}>
<PanelLeftIcon className="w-6 h-6" />
</button>
</div>
<div>
<Select
value={selectedModel}
onChange={setSelectedModel}
size="large"
loading={isModelsLoading || isModelsFetching}
placeholder="Select a model"
className="w-64"
options={models?.map((model) => ({
label: model.name,
value: model.model
}))}
/>
</div>
</div>
<button>
</button>
<button
onClick={() => setOpen(true)}
className="text-gray-500 dark:text-gray-400">
<CogIcon className="w-6 h-6" />
</button>
</div>
<Layout.Content>{children}</Layout.Content>
<Drawer
title={"Chat History"}
placement="left"
closeIcon={null}
onClose={() => setSidebarOpen(false)}
open={sidebarOpen}>
<Sidebar />
</Drawer>
<Modal
open={open}
width={800}
title={"Settings"}
onOk={() => setOpen(false)}
footer={null}
onCancel={() => setOpen(false)}>
<Settings setClose={() => setOpen(false)} />
</Modal>
</Layout>
)
}

View File

@ -0,0 +1,89 @@
import React from "react"
import { PlaygroundForm } from "./PlaygroundForm"
import { PlaygroundChat } from "./PlaygroundChat"
export const Playground = () => {
const drop = React.useRef<HTMLDivElement>(null)
const [dropedFile, setDropedFile] = React.useState<File | undefined>()
const [dropState, setDropState] = React.useState<
"idle" | "dragging" | "error"
>("idle")
React.useEffect(() => {
if (!drop.current) {
return
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDrop = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDropState("idle")
const files = Array.from(e.dataTransfer?.files || [])
const isImage = files.every((file) => file.type.startsWith("image/"))
if (!isImage) {
setDropState("error")
return
}
const newFiles = Array.from(e.dataTransfer?.files || []).slice(0, 1)
if (newFiles.length > 0) {
setDropedFile(newFiles[0])
}
}
const handleDragEnter = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDropState("dragging")
}
const handleDragLeave = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDropState("idle")
}
drop.current.addEventListener("dragover", handleDragOver)
drop.current.addEventListener("drop", handleDrop)
drop.current.addEventListener("dragenter", handleDragEnter)
drop.current.addEventListener("dragleave", handleDragLeave)
return () => {
if (drop.current) {
drop.current.removeEventListener("dragover", handleDragOver)
drop.current.removeEventListener("drop", handleDrop)
drop.current.removeEventListener("dragenter", handleDragEnter)
drop.current.removeEventListener("dragleave", handleDragLeave)
}
}
}, [])
return (
<div
ref={drop}
className={`${
dropState === "dragging" ? "bg-gray-100 dark:bg-gray-800 z-10" : ""
} min-h-screen`}>
<PlaygroundChat />
<div className="flex flex-col items-center">
<div className="flex-grow">
<div className="w-full flex justify-center">
<div className="bottom-0 w-full bg-transparent border-0 fixed pt-2">
<div className="stretch mx-2 flex flex-row gap-3 md:mx-4 lg:mx-auto lg:max-w-2xl xl:max-w-3xl justify-center items-center">
<div className="relative h-full flex-1 items-center justify-center md:flex-col">
<PlaygroundForm dropedFile={dropedFile} />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,38 @@
import React from "react"
import { useMessage } from "~hooks/useMessage"
import { useMessageOption } from "~hooks/useMessageOption"
import { PlaygroundMessage } from "./PlaygroundMessage"
import { PlaygroundEmpty } from "./PlaygroundEmpty"
export const PlaygroundChat = () => {
const { messages } = useMessageOption()
const divRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (divRef.current) {
divRef.current.scrollIntoView({ behavior: "smooth" })
}
})
return (
<div className="grow flex flex-col md:translate-x-0 transition-transform duration-300 ease-in-out">
{messages.length === 0 && (
<div className="mt-32">
<PlaygroundEmpty />
</div>
)}
{messages.length > 0 && <div className="w-full h-14 flex-shrink-0"></div>}
{messages.map((message, index) => (
<PlaygroundMessage
key={index}
isBot={message.isBot}
message={message.message}
name={message.name}
images={message.images || []}
/>
))}
{messages.length > 0 && (
<div className="w-full h-32 md:h-48 flex-shrink-0"></div>
)}
<div ref={divRef} />
</div>
)
}

View File

@ -0,0 +1,86 @@
import { useQuery } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import { useMessage } from "~hooks/useMessage"
import { useMessageOption } from "~hooks/useMessageOption"
import {
getOllamaURL,
isOllamaRunning,
setOllamaURL as saveOllamaURL
} from "~services/ollama"
export const PlaygroundEmpty = () => {
const [ollamaURL, setOllamaURL] = useState<string>("")
const {
data: ollamaInfo,
status: ollamaStatus,
refetch,
isRefetching
} = useQuery({
queryKey: ["ollamaStatus"],
queryFn: async () => {
const ollamaURL = await getOllamaURL()
const isOk = await isOllamaRunning()
return {
isOk,
ollamaURL
}
}
})
useEffect(() => {
if (ollamaInfo?.ollamaURL) {
setOllamaURL(ollamaInfo.ollamaURL)
}
}, [ollamaInfo])
return (
<div className="mx-auto sm:max-w-xl px-4 mt-10">
<div className="rounded-lg justify-center items-center flex flex-col border p-8 bg-white dark:bg-[#262626] shadow-sm dark:border-gray-600">
{(ollamaStatus === "pending" || isRefetching) && (
<div className="inline-flex items-center space-x-2">
<div className="w-3 h-3 bg-blue-500 rounded-full animate-bounce"></div>
<p className="dark:text-gray-400 text-gray-900">
Searching for Your Ollama 🦙
</p>
</div>
)}
{!isRefetching && ollamaStatus === "success" ? (
ollamaInfo.isOk ? (
<div className="inline-flex items-center space-x-2">
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
<p className="dark:text-gray-400 text-gray-900">
Ollama is running 🦙
</p>
</div>
) : (
<div className="flex flex-col space-y-2 justify-center items-center">
<div className="inline-flex space-x-2">
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
<p className="dark:text-gray-400 text-gray-900">
Unable to connect to Ollama 🦙
</p>
</div>
<input
className="bg-gray-100 dark:bg-[#262626] dark:text-gray-100 rounded-md px-4 py-2 mt-2 w-full"
type="url"
value={ollamaURL}
onChange={(e) => setOllamaURL(e.target.value)}
/>
<button
onClick={() => {
saveOllamaURL(ollamaURL)
refetch()
}}
className="bg-pink-500 mt-4 hover:bg-pink-600 text-white px-4 py-2 rounded-md dark:bg-pink-600 dark:hover:bg-pink-700">
Retry
</button>
</div>
)
) : null}
</div>
</div>
)
}

View File

@ -0,0 +1,190 @@
import { useForm } from "@mantine/form"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import React from "react"
import useDynamicTextareaSize from "~hooks/useDynamicTextareaSize"
import PhotoIcon from "@heroicons/react/24/outline/PhotoIcon"
import XMarkIcon from "@heroicons/react/24/outline/XMarkIcon"
import { toBase64 } from "~libs/to-base64"
import { useMessageOption } from "~hooks/useMessageOption"
import { ArrowPathIcon } from "@heroicons/react/24/outline"
import { Tooltip } from "antd"
type Props = {
dropedFile: File | undefined
}
export const PlaygroundForm = ({ dropedFile }: Props) => {
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
const resetHeight = () => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = "auto"
}
}
const form = useForm({
initialValues: {
message: "",
image: ""
}
})
const onInputChange = async (
e: React.ChangeEvent<HTMLInputElement> | File
) => {
if (e instanceof File) {
const base64 = await toBase64(e)
form.setFieldValue("image", base64)
} else {
if (e.target.files) {
const base64 = await toBase64(e.target.files[0])
form.setFieldValue("image", base64)
}
}
}
React.useEffect(() => {
if (dropedFile) {
onInputChange(dropedFile)
}
}, [dropedFile])
useDynamicTextareaSize(textareaRef, form.values.message, 300)
const { onSubmit, selectedModel, chatMode, clearChat } = useMessageOption()
const queryClient = useQueryClient()
const { mutateAsync: sendMessage, isPending: isSending } = useMutation({
mutationFn: onSubmit,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["fetchChatHistory"]
})
}
})
return (
<div className="p-3 md:p-6 md:bg-white dark:bg-[#262626] border rounded-t-xl border-black/10 dark:border-gray-600">
<div className="flex-grow space-y-6 ">
<div
className={`h-full rounded-md shadow relative ${
form.values.image.length === 0 ? "hidden" : "block"
}`}>
<div>
<img
src={form.values.image}
alt="Uploaded"
className="h-full w-auto object-cover rounded-md min-w-[50px]"
/>
<button
onClick={() => {
form.setFieldValue("image", "")
}}
className="absolute top-2 right-2 bg-white dark:bg-[#262626] p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-600 text-black dark:text-gray-100">
<XMarkIcon className="h-5 w-5" />
</button>
</div>
</div>
<div className="flex">
<Tooltip title="New Chat">
<button
onClick={clearChat}
className="text-gray-500 dark:text-gray-100 mr-3">
<ArrowPathIcon className="h-5 w-5" />
</button>
</Tooltip>
<form
onSubmit={form.onSubmit(async (value) => {
if (!selectedModel || selectedModel.length === 0) {
form.setFieldError("message", "Please select a model")
return
}
form.reset()
resetHeight()
await sendMessage({
image: value.image,
message: value.message.trim()
})
})}
className="shrink-0 flex-grow flex items-center ">
<div className="flex items-center p-2 rounded-2xl border bg-gray-100 w-full dark:bg-[#262626] dark:border-gray-600">
<button
type="button"
onClick={() => {
inputRef.current?.click()
}}
className={`flex ml-3 items-center justify-center dark:text-gray-100 ${
chatMode === "rag" ? "hidden" : "block"
}`}>
<PhotoIcon className="h-5 w-5" />
</button>
<input
id="file-upload"
name="file-upload"
type="file"
className="sr-only"
ref={inputRef}
accept="image/*"
multiple={false}
onChange={onInputChange}
/>
<textarea
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !isSending) {
e.preventDefault()
form.onSubmit(async (value) => {
if (value.message.trim().length === 0) {
return
}
if (!selectedModel || selectedModel.length === 0) {
form.setFieldError("message", "Please select a model")
return
}
form.reset()
resetHeight()
await sendMessage({
image: value.image,
message: value.message.trim()
})
})()
}
}}
ref={textareaRef}
className="px-2 py-2 w-full resize-none bg-transparent focus-within:outline-none sm:text-sm focus:ring-0 focus-visible:ring-0 ring-0 dark:ring-0 border-0 dark:text-gray-100"
required
rows={1}
tabIndex={0}
placeholder="Type a message..."
{...form.getInputProps("message")}
/>
<button
disabled={isSending || form.values.message.length === 0}
className="ml-2 flex items-center justify-center w-10 h-10 text-white bg-[#262626] rounded-xl disabled:opacity-50">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-6 w-6"
viewBox="0 0 24 24">
<path d="M9 10L4 15 9 20"></path>
<path d="M20 4v7a4 4 0 01-4 4H4"></path>
</svg>
</button>
</div>
</form>
</div>
{form.errors.message && (
<div className="text-red-500 text-center text-sm mt-1">
{form.errors.message}
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,98 @@
import { CheckIcon, ClipboardIcon } from "@heroicons/react/24/outline"
import Markdown from "../../Common/Markdown"
import React from "react"
type Props = {
message: string
hideCopy?: boolean
botAvatar?: JSX.Element
userAvatar?: JSX.Element
isBot: boolean
name: string
images?: string[]
}
export const PlaygroundMessage = (props: Props) => {
const [isBtnPressed, setIsBtnPressed] = React.useState(false)
React.useEffect(() => {
if (isBtnPressed) {
setTimeout(() => {
setIsBtnPressed(false)
}, 4000)
}
}, [isBtnPressed])
return (
<div
className={`group w-full text-gray-800 dark:text-gray-100 border-b border-black/10 dark:border-gray-900/50 `}>
<div className="text-base gap-4 md:gap-6 md:max-w-2xl lg:max-w-xl xl:max-w-3xl flex lg:px-0 m-auto w-full">
<div className="flex flex-row gap-4 md:gap-6 md:max-w-2xl lg:max-w-xl xl:max-w-3xl p-4 md:py-6 lg:px-0 m-auto w-full">
<div className="w-8 flex flex-col relative items-end">
<div className="relative h-7 w-7 p-1 rounded-sm text-white flex items-center justify-center text-opacity-100r">
{props.isBot ? (
!props.botAvatar ? (
<div className="absolute h-8 w-8 rounded-full bg-gradient-to-r from-green-300 to-purple-400"></div>
) : (
props.botAvatar
)
) : !props.userAvatar ? (
<div className="absolute h-8 w-8 rounded-full from-blue-400 to-blue-600 bg-gradient-to-r"></div>
) : (
props.userAvatar
)}
</div>
</div>
<div className="relative flex w-[calc(100%-50px)] flex-col gap-1 md:gap-3 lg:w-[calc(100%-115px)]">
{props.isBot && (
<span className="absolute mb-8 -top-4 left-0 text-xs text-gray-400 dark:text-gray-500">
{props.name}
</span>
)}
<div className="flex flex-grow flex-col gap-3">
<Markdown message={props.message} />
</div>
{/* source if aviable */}
{props.images && (
<div className="flex md:max-w-2xl lg:max-w-xl xl:max-w-3xl mt-4 m-auto w-full">
{props.images
.filter((image) => image.length > 0)
.map((image, index) => (
<div
key={index}
className="h-full rounded-md shadow relative">
<img
src={image}
alt="Uploaded"
className="h-full w-auto object-cover rounded-md min-w-[50px]"
/>
</div>
))}
</div>
)}
</div>
{props.isBot && (
<div className="flex space-x-2">
{!props.hideCopy && (
<button
onClick={() => {
navigator.clipboard.writeText(props.message)
setIsBtnPressed(true)
}}
className="flex items-center justify-center w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">
{!isBtnPressed ? (
<ClipboardIcon className="w-3 h-3 text-gray-400 group-hover:text-gray-500" />
) : (
<CheckIcon className="w-3 h-3 text-green-400 group-hover:text-green-500" />
)}
</button>
)}
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,25 @@
import { PencilSquareIcon } from "@heroicons/react/24/outline"
import { useMessage } from "../../../hooks/useMessage"
export const PlaygroundNewChat = () => {
const { setHistory, setMessages, setHistoryId } = useMessage()
const handleClick = () => {
setHistoryId(null)
setMessages([])
setHistory([])
// navigate(`/bot/${params.id}`);
}
return (
<button
onClick={handleClick}
className="flex w-full border bg-transparent hover:bg-gray-200 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 focus:ring-offset-gray-100 rounded-md p-2 dark:border-gray-800">
<PencilSquareIcon className="mx-3 h-5 w-5" aria-hidden="true" />
<span className="inline-flex font-semibol text-white text-sm">
New Chat
</span>
</button>
)
}

View File

@ -0,0 +1,42 @@
import { Modal } from "antd"
import { useState } from "react"
export const PlaygroundSettings = () => {
const [open, setOpen] = useState(false)
return (
<div className="flex-shrink-0 flex flex-col items-center justify-center py-1 ">
<div className="flex items-center justify-center space-x-2">
<button
onClick={() => setOpen(true)}
className="flex items-center justify-center w-8 h-8 rounded-full transition-colors duration-200 focus:outline-none">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</div>
<Modal
footer={null}
title="Playground Settings"
open={open}
onCancel={() => setOpen(false)}>
Nothing to see here
</Modal>
</div>
)
}

View File

@ -0,0 +1,39 @@
import { Tabs } from "antd"
import { SettingsOllama } from "./Settings/ollama"
import { SettingPrompt } from "./Settings/prompt"
import { SettingOther } from "./Settings/other"
type Props = {
setClose: (close: boolean) => void
}
export const Settings = ({ setClose }: Props) => {
return (
<div className="my-6">
<Tabs
tabPosition="left"
defaultActiveKey="1"
items={[
{
id: "1",
key: "1",
label: "Prompt",
children: <SettingPrompt />
},
{
id: "2",
key: "2",
label: "Ollama",
children: <SettingsOllama />
},
{
id: "3",
key: "3",
label: "Other",
children: <SettingOther />
}
]}
/>
</div>
)
}

View File

@ -0,0 +1,55 @@
import { useQuery } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import { SaveButton } from "~components/Common/SaveButton"
import { getOllamaURL, setOllamaURL as saveOllamaURL } from "~services/ollama"
export const SettingsOllama = () => {
const [ollamaURL, setOllamaURL] = useState<string>("")
const { data: ollamaInfo } = useQuery({
queryKey: ["fetchOllamURL"],
queryFn: async () => {
const ollamaURL = await getOllamaURL()
return {
ollamaURL
}
}
})
useEffect(() => {
if (ollamaInfo?.ollamaURL) {
setOllamaURL(ollamaInfo.ollamaURL)
}
}, [ollamaInfo])
return (
<div className="">
<div>
<label
htmlFor="ollamaURL"
className="text-sm font-medium dark:text-gray-200">
Ollama URL
</label>
<input
type="url"
id="ollamaURL"
value={ollamaURL}
onChange={(e) => {
setOllamaURL(e.target.value)
}}
placeholder="Your Ollama URL"
className="w-full p-2 border border-gray-300 rounded-md dark:bg-[#262626] dark:text-gray-100"
/>
</div>
<div className="flex justify-end">
<SaveButton
onClick={() => {
saveOllamaURL(ollamaURL)
}}
className="mt-2"
/>
</div>
</div>
)
}

View File

@ -0,0 +1,52 @@
import { useQueryClient } from "@tanstack/react-query"
import { useDarkMode } from "~hooks/useDarkmode"
import { useMessageOption } from "~hooks/useMessageOption"
import { PageAssitDatabase } from "~libs/db"
export const SettingOther = () => {
const { clearChat } = useMessageOption()
const queryClient = useQueryClient()
const { mode, toggleDarkMode } = useDarkMode()
return (
<div className="flex flex-col space-y-4">
<div className="flex flex-row justify-between">
<span className="text-gray-500 dark:text-gray-400 text-lg">
Change Theme
</span>
<button
onClick={toggleDarkMode}
className="bg-blue-500 dark:bg-blue-600 text-white dark:text-gray-200 px-4 py-2 rounded-md">
{mode === "dark" ? "Light" : "Dark"}
</button>
</div>
<div className="flex flex-row justify-between">
<span className="text-gray-500 dark:text-gray-400 text-lg">
Delete Chat History
</span>
<button
onClick={async () => {
const confirm = window.confirm(
"Are you sure you want to delete your chat history? This action cannot be undone."
)
if (confirm) {
const db = new PageAssitDatabase()
await db.deleteChatHistory()
queryClient.invalidateQueries({
queryKey: ["fetchChatHistory"]
})
clearChat()
}
}}
className="bg-red-500 dark:bg-red-600 text-white dark:text-gray-200 px-4 py-2 rounded-md">
Delete
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,56 @@
import { useQuery } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import { SaveButton } from "~components/Common/SaveButton"
import {
setSystemPromptForNonRagOption,
systemPromptForNonRagOption
} from "~services/ollama"
export const SettingPrompt = () => {
const [ollamaPrompt, setOllamaPrompt] = useState<string>("")
const { data: ollamaInfo } = useQuery({
queryKey: ["fetchOllaPrompt"],
queryFn: async () => {
const prompt = await systemPromptForNonRagOption()
return {
prompt
}
}
})
useEffect(() => {
if (ollamaInfo?.prompt) {
setOllamaPrompt(ollamaInfo.prompt)
}
}, [ollamaInfo])
return (
<div className="">
<div>
<label htmlFor="ollamaPrompt" className="text-sm font-medium dark:text-gray-200">
System Prompt
</label>
<textarea
value={ollamaPrompt}
rows={5}
id="ollamaPrompt"
placeholder="Your System Prompt"
onChange={(e) => {
setOllamaPrompt(e.target.value)
}}
className="w-full p-2 border border-gray-300 rounded-md dark:bg-[#262626] dark:text-gray-100"
/>
</div>
<div className="flex justify-end">
<SaveButton
onClick={() => {
setSystemPromptForNonRagOption(ollamaPrompt)
}}
className="mt-2"
/>
</div>
</div>
)
}

View File

@ -0,0 +1,61 @@
import { useQuery } from "@tanstack/react-query"
import {
PageAssitDatabase,
formatToChatHistory,
formatToMessage
} from "~libs/db"
import { Empty, Skeleton } from "antd"
import { useMessageOption } from "~hooks/useMessageOption"
type Props = {}
export const Sidebar = ({}: Props) => {
const { setMessages, setHistory, setHistoryId } = useMessageOption()
const { data: chatHistories, status } = useQuery({
queryKey: ["fetchChatHistory"],
queryFn: async () => {
const db = new PageAssitDatabase()
const history = await db.getChatHistories()
return history
}
})
return (
<div className="overflow-y-auto">
{status === "success" && chatHistories.length === 0 && (
<div className="flex justify-center items-center mt-20 overflow-hidden">
<Empty description="No history yet" />
</div>
)}
{status === "pending" && (
<div className="flex justify-center items-center mt-5">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
)}
{status === "error" && (
<div className="flex justify-center items-center">
<span className="text-red-500">Error loading history</span>
</div>
)}
{status === "success" && chatHistories.length > 0 && (
<div className="flex flex-col gap-2">
{chatHistories.map((chat, index) => (
<button
onClick={async () => {
const db = new PageAssitDatabase()
const history = await db.getChatHistory(chat.id)
setHistoryId(chat.id)
setHistory(formatToChatHistory(history))
setMessages(formatToMessage(history))
}}
key={index}
className="flex text-start py-2 px-2 cursor-pointer items-start gap-3 relative rounded-md truncate hover:pr-4 group transition-opacity duration-300 ease-in-out bg-gray-100 dark:bg-[#232222] dark:text-gray-100 text-gray-800 border hover:bg-gray-200 dark:hover:bg-[#2d2d2d] dark:border-gray-800">
<span className="flex-grow truncate">{chat.title}</span>
</button>
))}
</div>
)}
</div>
)
}

View File

@ -1,61 +0,0 @@
import React from "react";
import { ClipboardIcon } from "@heroicons/react/24/outline";
import { api } from "~/utils/api";
export default function SettingsBody() {
const { data, status } = api.settings.getAccessToken.useQuery();
const [isCopied, setIsCopied] = React.useState(false);
return (
<>
{status === "loading" && <div>Loading...</div>}
{status === "success" && (
<div className="divide-ylg:col-span-9">
<div className="px-4 py-6 sm:p-6 lg:pb-8">
<div>
<h2 className="text-lg font-medium leading-6 text-gray-900">
Chrom Extension
</h2>
<p className="mt-1 text-sm text-gray-500">
Copy the following code and paste it into the extension.
</p>
</div>
<div className="mt-6 flex flex-col lg:flex-row">
<div className="flex-grow space-y-6">
<div className="flex">
<div className="flex-grow">
<input
type="password"
readOnly
defaultValue={data?.accessToken || ""}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 sm:text-sm"
/>
</div>
<span className="ml-3">
<button
type="button"
onClick={() => {
setIsCopied(false);
navigator.clipboard.writeText(data?.accessToken || "");
setIsCopied(true);
}}
className="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2"
>
<ClipboardIcon
className="h-5 w-5 text-gray-500"
aria-hidden="true"
/>
<span className="ml-2">
{isCopied ? "Copied" : "Copy"}
</span>
</button>
</span>
</div>
</div>
</div>
</div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,30 @@
import React from "react"
import { PlaygroundMessage } from "~components/Common/Playground/Message"
import { useMessage } from "~hooks/useMessage"
import { EmptySidePanel } from "../Chat/empty"
export const SidePanelBody = () => {
const { messages } = useMessage()
const divRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (divRef.current) {
divRef.current.scrollIntoView({ behavior: "smooth" })
}
})
return (
<div className="grow flex flex-col md:translate-x-0 transition-transform duration-300 ease-in-out">
{messages.length === 0 && <EmptySidePanel />}
{messages.map((message, index) => (
<PlaygroundMessage
key={index}
isBot={message.isBot}
message={message.message}
name={message.name}
images={message.images || []}
/>
))}
<div className="w-full h-32 md:h-48 flex-shrink-0"></div>
<div ref={divRef} />
</div>
)
}

View File

@ -0,0 +1,153 @@
import { useQuery } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import { useMessage } from "~hooks/useMessage"
import {
fetchModels,
getOllamaURL,
isOllamaRunning,
setOllamaURL as saveOllamaURL
} from "~services/ollama"
export const EmptySidePanel = () => {
const [ollamaURL, setOllamaURL] = useState<string>("")
const {
data: ollamaInfo,
status: ollamaStatus,
refetch,
isRefetching
} = useQuery({
queryKey: ["ollamaStatus"],
queryFn: async () => {
const ollamaURL = await getOllamaURL()
const isOk = await isOllamaRunning()
const models = await fetchModels()
return {
isOk,
models,
ollamaURL
}
}
})
useEffect(() => {
if (ollamaInfo?.ollamaURL) {
setOllamaURL(ollamaInfo.ollamaURL)
}
}, [ollamaInfo])
const { setSelectedModel, selectedModel, chatMode, setChatMode } =
useMessage()
return (
<div className="mx-auto sm:max-w-md px-4 mt-10">
<div className="rounded-lg justify-center items-center flex flex-col border dark:border-gray-700 p-8 bg-white dark:bg-[#262626] shadow-sm">
{(ollamaStatus === "pending" || isRefetching) && (
<div className="inline-flex items-center space-x-2">
<div className="w-3 h-3 bg-blue-500 rounded-full animate-bounce"></div>
<p className="dark:text-gray-400 text-gray-900">
Searching for your Ollama 🦙
</p>
</div>
)}
{!isRefetching && ollamaStatus === "success" ? (
ollamaInfo.isOk ? (
<div className="inline-flex items-center space-x-2">
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
<p className="dark:text-gray-400 text-gray-900">
Ollama is running 🦙
</p>
</div>
) : (
<div className="flex flex-col space-y-2 justify-center items-center">
<div className="inline-flex space-x-2">
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
<p className="dark:text-gray-400 text-gray-900">
We couldn't find your Ollama 🦙
</p>
</div>
<input
className="bg-gray-100 dark:bg-black dark:text-gray-100 rounded-md px-4 py-2 mt-2 w-full"
type="url"
value={ollamaURL}
onChange={(e) => setOllamaURL(e.target.value)}
/>
<button
onClick={() => {
saveOllamaURL(ollamaURL)
refetch()
}}
className="bg-pink-500 mt-4 hover:bg-pink-600 text-white px-4 py-2 rounded-md dark:bg-pink-600 dark:hover:bg-pink-700">
Retry
</button>
</div>
)
) : null}
{ollamaStatus === "success" && ollamaInfo.isOk && (
<div className="mt-4">
<p className="dark:text-gray-400 text-gray-900">Models:</p>
<select
onChange={(e) => {
if (e.target.value === "") {
return
}
setSelectedModel(e.target.value)
}}
value={selectedModel}
className="bg-gray-100 truncate w-full dark:bg-[#171717] dark:text-gray-100 rounded-md px-4 py-2 mt-2">
<option key="0x" value={""}>
Select a model
</option>
{ollamaInfo.models.map((model, index) => (
<option key={index} value={model.name}>
{model.name}
</option>
))}
</select>
<div className="mt-4">
<div className="inline-flex items-center">
<label
className="relative flex items-center p-3 rounded-full cursor-pointer"
htmlFor="check">
<input
type="checkbox"
checked={chatMode === "rag"}
onChange={(e) => {
setChatMode(e.target.checked ? "rag" : "normal")
}}
className="before:content[''] peer relative h-5 w-5 cursor-pointer appearance-none rounded-md border border-blue-gray-200 transition-all before:absolute before:top-2/4 before:left-2/4 before:block before:h-12 before:w-12 before:-translate-y-2/4 before:-translate-x-2/4 before:rounded-full before:bg-blue-gray-500 before:opacity-0 before:transition-opacity"
id="check"
/>
<span className="absolute text-white transition-opacity opacity-0 pointer-events-none top-2/4 left-2/4 -translate-y-2/4 -translate-x-2/4 peer-checked:opacity-100 ">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-3.5 w-3.5"
viewBox="0 0 20 20"
fill="currentColor"
stroke="currentColor"
stroke-width="1">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"></path>
</svg>
</span>
</label>
<label
className="mt-px font-light cursor-pointer select-none text-gray-900 dark:text-gray-400"
htmlFor="check">
Chat with Current Page
</label>
</div>
</div>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,173 @@
import { useForm } from "@mantine/form"
import { useMutation } from "@tanstack/react-query"
import React from "react"
import useDynamicTextareaSize from "~hooks/useDynamicTextareaSize"
import { useMessage } from "~hooks/useMessage"
import PhotoIcon from "@heroicons/react/24/outline/PhotoIcon"
import XMarkIcon from "@heroicons/react/24/outline/XMarkIcon"
import { toBase64 } from "~libs/to-base64"
type Props = {
dropedFile: File | undefined
}
export const SidepanelForm = ({ dropedFile }: Props) => {
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
const resetHeight = () => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = "auto"
}
}
const form = useForm({
initialValues: {
message: "",
image: ""
}
})
const onInputChange = async (
e: React.ChangeEvent<HTMLInputElement> | File
) => {
if (e instanceof File) {
const base64 = await toBase64(e)
form.setFieldValue("image", base64)
} else {
if (e.target.files) {
const base64 = await toBase64(e.target.files[0])
form.setFieldValue("image", base64)
}
}
}
React.useEffect(() => {
if (dropedFile) {
onInputChange(dropedFile)
}
}, [dropedFile])
useDynamicTextareaSize(textareaRef, form.values.message, 120)
const { onSubmit, selectedModel, chatMode } = useMessage()
const { mutateAsync: sendMessage, isPending: isSending } = useMutation({
mutationFn: onSubmit
})
return (
<div className="p-3 md:p-6 md:bg-white dark:bg-[#262626] border rounded-t-xl border-black/10 dark:border-gray-700">
<div className="flex-grow space-y-6 ">
{chatMode === "normal" && form.values.image && (
<div className="h-full rounded-md shadow relative">
<div>
<img
src={form.values.image}
alt="Uploaded"
className="h-full w-auto object-cover rounded-md min-w-[50px]"
/>
<button
onClick={() => {
form.setFieldValue("image", "")
}}
className="absolute top-2 right-2 bg-white dark:bg-[#262626] p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 text-black dark:text-gray-100">
<XMarkIcon className="h-5 w-5" />
</button>
</div>
</div>
)}
<div className="flex">
<form
onSubmit={form.onSubmit(async (value) => {
if (!selectedModel || selectedModel.length === 0) {
form.setFieldError("message", "Please select a model")
return
}
form.reset()
resetHeight()
await sendMessage({
image: value.image,
message: value.message.trim()
})
})}
className="shrink-0 flex-grow flex items-center ">
<div className="flex items-center p-2 rounded-2xl border bg-gray-100 w-full dark:bg-[#262626] dark:border-gray-700">
<button
type="button"
onClick={() => {
inputRef.current?.click()
}}
className={`flex ml-3 items-center justify-center dark:text-gray-100 ${
chatMode === "rag" ? "hidden" : "block"
}`}>
<PhotoIcon className="h-5 w-5" />
</button>
<input
id="file-upload"
name="file-upload"
type="file"
className="sr-only"
ref={inputRef}
accept="image/*"
multiple={false}
onChange={onInputChange}
/>
<textarea
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !isSending) {
e.preventDefault()
form.onSubmit(async (value) => {
if (value.message.trim().length === 0) {
return
}
if (!selectedModel || selectedModel.length === 0) {
form.setFieldError("message", "Please select a model")
return
}
form.reset()
resetHeight()
await sendMessage({
image: value.image,
message: value.message.trim()
})
})()
}
}}
ref={textareaRef}
className="px-2 py-2 w-full resize-none bg-transparent focus-within:outline-none sm:text-sm focus:ring-0 focus-visible:ring-0 ring-0 dark:ring-0 border-0 dark:text-gray-100"
required
rows={1}
tabIndex={0}
placeholder="Type a message..."
{...form.getInputProps("message")}
/>
<button
disabled={isSending || form.values.message.length === 0}
className="ml-2 flex items-center justify-center w-10 h-10 text-white bg-black rounded-xl disabled:opacity-50">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-6 w-6"
viewBox="0 0 24 24">
<path d="M9 10L4 15 9 20"></path>
<path d="M20 4v7a4 4 0 01-4 4H4"></path>
</svg>
</button>
</div>
</form>
</div>
{form.errors.message && (
<div className="text-red-500 text-center text-sm mt-1">
{form.errors.message}
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,39 @@
import logoImage from "data-base64:~assets/icon.png"
import CogIcon from "@heroicons/react/24/outline/CogIcon"
import Squares2X2Icon from "@heroicons/react/24/outline/Squares2X2Icon"
import { ArrowPathIcon } from "@heroicons/react/24/outline"
import { useMessage } from "~hooks/useMessage"
import { Link } from "react-router-dom"
import { Tooltip } from "antd"
export const SidepanelHeader = () => {
const { clearChat, isEmbedding } = useMessage()
return (
<div className="flex px-3 justify-between bg-white dark:bg-[#171717] border-b border-gray-300 dark:border-gray-700 py-4 items-center">
<div className="focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-700 flex items-center dark:text-white">
<img className="h-6 w-auto" src={logoImage} alt="Page Assist" />
<span className="ml-1 text-sm ">Page Assist</span>
</div>
<div className="flex items-center space-x-3">
{isEmbedding ? (
<Tooltip
title="It may take a few minutes to embed the page. Please wait..."
>
<Squares2X2Icon className="h-5 w-5 text-gray-500 dark:text-gray-400 animate-bounce animate-infinite" />
</Tooltip>
) : null}
<button
onClick={() => {
clearChat()
}}
className="flex items-center space-x-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-700">
<ArrowPathIcon className="h-5 w-5 text-gray-500 dark:text-gray-400" />
</button>
<Link to="/settings">
<CogIcon className="h-5 w-5 text-gray-500 dark:text-gray-400" />
</Link>
</div>
</div>
)
}

View File

@ -0,0 +1,172 @@
import { useQuery } from "@tanstack/react-query"
import React from "react"
import {
getOllamaURL,
systemPromptForNonRag,
promptForRag,
setOllamaURL as saveOllamaURL,
setPromptForRag,
setSystemPromptForNonRag
} from "~services/ollama"
import { Skeleton, Radio } from "antd"
import { useDarkMode } from "~hooks/useDarkmode"
import { SaveButton } from "~components/Common/SaveButton"
export const SettingsBody = () => {
const [ollamaURL, setOllamaURL] = React.useState<string>("")
const [systemPrompt, setSystemPrompt] = React.useState<string>("")
const [ragPrompt, setRagPrompt] = React.useState<string>("")
const [ragQuestionPrompt, setRagQuestionPrompt] = React.useState<string>("")
const [selectedValue, setSelectedValue] = React.useState<"normal" | "rag">(
"normal"
)
const { mode, toggleDarkMode } = useDarkMode()
const { data, status } = useQuery({
queryKey: ["sidebarSettings"],
queryFn: async () => {
const [ollamaURL, systemPrompt, ragPrompt] = await Promise.all([
getOllamaURL(),
systemPromptForNonRag(),
promptForRag()
])
return {
url: ollamaURL,
normalSystemPrompt: systemPrompt,
ragSystemPrompt: ragPrompt.ragPrompt,
ragQuestionPrompt: ragPrompt.ragQuestionPrompt
}
}
})
React.useEffect(() => {
if (data) {
setOllamaURL(data.url)
setSystemPrompt(data.normalSystemPrompt)
setRagPrompt(data.ragSystemPrompt)
setRagQuestionPrompt(data.ragQuestionPrompt)
}
}, [data])
if (status === "pending") {
return (
<div className="flex flex-col gap-4 p-4">
<Skeleton active />
<Skeleton active />
<Skeleton active />
<Skeleton active />
</div>
)
}
if (status === "error") {
return <div>Error</div>
}
return (
<div className="flex flex-col gap-4 p-4 max-w-2xl mx-auto lg:max-w-3xl xl:max-w-4xl 2xl:max-w-5xl">
<div className="border border-gray-300 dark:border-gray-700 rounded p-4 bg-white dark:bg-[#171717]">
<h2 className="text-md mb-4 font-semibold dark:text-white">
Ollama URL
</h2>
<input
className="w-full border border-gray-300 dark:border-gray-700 rounded p-2 dark:bg-[#171717] dark:text-white dark:placeholder-gray-400"
value={ollamaURL}
type="url"
onChange={(e) => setOllamaURL(e.target.value)}
placeholder="Enter Ollama URL here"
/>
<div className="flex justify-end">
<SaveButton
onClick={() => {
saveOllamaURL(ollamaURL)
}}
/>
</div>
</div>
<div className="border border-gray-300 dark:border-gray-700 rounded p-4 bg-white dark:bg-[#171717]">
<h2 className="text-md font-semibold dark:text-white">Prompt</h2>
<div className="my-3 flex justify-end">
<Radio.Group
defaultValue={selectedValue}
onChange={(e) => setSelectedValue(e.target.value)}>
<Radio.Button value="normal">Normal</Radio.Button>
<Radio.Button value="rag">Rag</Radio.Button>
</Radio.Group>
</div>
{selectedValue === "normal" && (
<div>
<span className="text-md font-thin text-gray-500 dark:text-gray-400">
System Prompt
</span>
<textarea
className="w-full border border-gray-300 dark:border-gray-700 rounded p-2 dark:bg-[#171717] dark:text-white dark:placeholder-gray-400"
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
/>
<div className="flex justify-end">
<SaveButton
onClick={() => {
setSystemPromptForNonRag(systemPrompt)
}}
/>
</div>
</div>
)}
{selectedValue === "rag" && (
<div>
<div className="mb-3">
<span className="text-md font-thin text-gray-500 dark:text-gray-400">
System Prompt
</span>
<textarea
className="w-full border border-gray-300 dark:border-gray-700 rounded p-2 dark:bg-[#171717] dark:text-white dark:placeholder-gray-400"
value={ragPrompt}
onChange={(e) => setRagPrompt(e.target.value)}
/>
</div>
<div className="mb-3">
<span className="text-md font-thin text-gray-500 dark:text-gray-400">
Question Prompt
</span>
<textarea
className="w-full border border-gray-300 dark:border-gray-700 rounded p-2 dark:bg-[#171717] dark:text-white dark:placeholder-gray-400"
value={ragQuestionPrompt}
onChange={(e) => setRagQuestionPrompt(e.target.value)}
/>
</div>
<div className="flex justify-end">
<SaveButton
onClick={() => {
setPromptForRag(ragPrompt, ragQuestionPrompt)
}}
/>
</div>
</div>
)}
</div>
<div className="border border-gray-300 dark:border-gray-700 rounded p-4 bg-white dark:bg-[#171717]">
<h2 className="text-md mb-4 font-semibold dark:text-white">Theme</h2>
{mode === "dark" ? (
<button
onClick={toggleDarkMode}
className="select-none w-full rounded-lg border border-gray-900 py-3 px-6 text-center align-middle font-sans text-xs font-bold uppercase text-gray-900 transition-all hover:opacity-75 focus:ring focus:ring-gray-300 active:opacity-[0.85] disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none dark:border-gray-100 dark:text-white dark:hover:opacity-75 dark:focus:ring-dark dark:active:opacity-75 dark:disabled:pointer-events-none dark:disabled:opacity-50 dark:disabled:shadow-none">
Light Mode
</button>
) : (
<button
onClick={toggleDarkMode}
className="select-none w-full rounded-lg border border-gray-900 py-3 px-6 text-center align-middle font-sans text-xs font-bold uppercase text-gray-900 transition-all hover:opacity-75 focus:ring focus:ring-gray-300 active:opacity-[0.85] disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none dark:border-gray-100 dark:text-white dark:hover:opacity-75 dark:focus:ring-dark dark:active:opacity-75 dark:disabled:pointer-events-none dark:disabled:opacity-50 dark:disabled:shadow-none">
Dark Mode
</button>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,16 @@
import logoImage from "data-base64:~assets/icon.png"
import { ChevronLeftIcon } from "@heroicons/react/24/outline"
import { Link } from "react-router-dom"
export const SidepanelSettingsHeader = () => {
return (
<div className="flex px-3 justify-start gap-3 bg-white dark:bg-[#171717] border-b border-gray-300 dark:border-gray-700 py-4 items-center">
<Link to="/">
<ChevronLeftIcon className="h-5 w-5 text-gray-500 dark:text-gray-400" />
</Link>
<div className="focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-700 flex items-center dark:text-white">
<img className="h-6 w-auto" src={logoImage} alt="Page Assist" />
<span className="ml-1 text-sm ">Page Assist</span>
</div>
</div>
)
}

View File

@ -2,7 +2,7 @@
font-family: 'font'; font-family: 'font';
src: url('font.ttf') format('truetype'); src: url('font.ttf') format('truetype');
} }
body { * {
font-family: 'font' !important; font-family: 'font' !important;
} }

View File

@ -1,76 +0,0 @@
import { z } from "zod";
/**
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
* built with invalid env vars.
*/
const server = z.object({
DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(["development", "test", "production"]),
});
/**
* Specify your client-side environment variables schema here. This way you can ensure the app isn't
* built with invalid env vars. To expose them to the client, prefix them with `NEXT_PUBLIC_`.
*/
const client = z.object({
// NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
});
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*
* @type {Record<keyof z.infer<typeof server> | keyof z.infer<typeof client>, string | undefined>}
*/
const processEnv = {
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
};
// Don't touch the part below
// --------------------------
const merged = server.merge(client);
/** @typedef {z.input<typeof merged>} MergedInput */
/** @typedef {z.infer<typeof merged>} MergedOutput */
/** @typedef {z.SafeParseReturnType<MergedInput, MergedOutput>} MergedSafeParseReturn */
let env = /** @type {MergedOutput} */ (process.env);
if (!!process.env.SKIP_ENV_VALIDATION == false) {
const isServer = typeof window === "undefined";
const parsed = /** @type {MergedSafeParseReturn} */ (
isServer
? merged.safeParse(processEnv) // on server we can validate all env vars
: client.safeParse(processEnv) // on client we can only validate the ones that are exposed
);
if (parsed.success === false) {
console.error(
"❌ Invalid environment variables:",
parsed.error.flatten().fieldErrors,
);
throw new Error("Invalid environment variables");
}
env = new Proxy(parsed.data, {
get(target, prop) {
if (typeof prop !== "string") return undefined;
// Throw a descriptive error if a server-side env var is accessed on the client
// Otherwise it would just be returning `undefined` and be annoying to debug
if (!isServer && !prop.startsWith("NEXT_PUBLIC_"))
throw new Error(
process.env.NODE_ENV === "production"
? "❌ Attempted to access a server-side environment variable on the client"
: `❌ Attempted to access server-side environment variable '${prop}' on the client`,
);
return target[/** @type {keyof typeof target} */ (prop)];
},
});
}
export { env };

67
src/hooks/useDarkmode.tsx Normal file
View File

@ -0,0 +1,67 @@
import React from "react";
import { create } from "zustand";
type DarkModeState = {
mode: "system" | "dark" | "light";
setMode: (mode: "system" | "dark" | "light") => void;
};
export const useDarkModeStore = create<DarkModeState>((set) => ({
mode: "system",
setMode: (mode) => set({ mode }),
}));
export const useDarkMode = () => {
const { mode, setMode } = useDarkModeStore();
const getSystemTheme = () => {
const darkModeMediaQuery = window.matchMedia(
"(prefers-color-scheme: dark)"
);
const isDarkMode = darkModeMediaQuery.matches;
return isDarkMode ? "dark" : "light";
};
const handleDarkModeChange = (e: MediaQueryListEvent) => {
document.documentElement.classList.remove("dark", "light");
const mode = e.matches ? "dark" : "light";
document.documentElement.classList.add(mode);
setMode(mode);
};
React.useEffect(() => {
const theme = localStorage.getItem("theme") as "system" | "dark" | "light";
if (theme) {
if (theme !== "system") {
document.documentElement.classList.add(theme);
setMode(theme);
} else {
const systemTheme = getSystemTheme();
document.documentElement.classList.add(systemTheme);
setMode(systemTheme);
}
} else {
setMode(getSystemTheme());
localStorage.setItem("theme", getSystemTheme());
}
}, []);
React.useEffect(() => {
const darkModeMediaQuery = window.matchMedia(
"(prefers-color-scheme: dark)"
);
darkModeMediaQuery.addEventListener("change", handleDarkModeChange);
return () =>
darkModeMediaQuery.removeEventListener("change", handleDarkModeChange);
}, []);
const toggleDarkMode = () => {
const newMode = mode === "dark" ? "light" : "dark";
document.documentElement.classList.remove("dark", "light");
document.documentElement.classList.add(newMode);
setMode(newMode);
localStorage.setItem("theme", newMode);
};
return { mode, toggleDarkMode };
};

View File

@ -0,0 +1,38 @@
// copied from https://gist.github.com/KristofferEriksson/87ea5b8195339577151a236a9e9b46ff
/**
* Custom hook for dynamically resizing a textarea to fit its content.
* @param {React.RefObject<HTMLTextAreaElement>} textareaRef - Reference to the textarea element.
* @param {string} textContent - Current text content of the textarea.
* @param {number} maxHeight - Optional: maxHeight of the textarea in pixels.
*/
import { useEffect } from "react";
const useDynamicTextareaSize = (
textareaRef: React.RefObject<HTMLTextAreaElement>,
textContent: string,
// optional maximum height after which textarea becomes scrollable
maxHeight?: number
): void => {
useEffect(() => {
const currentTextarea = textareaRef.current;
if (currentTextarea) {
// Temporarily collapse the textarea to calculate the required height
currentTextarea.style.height = "0px";
const contentHeight = currentTextarea.scrollHeight;
if (maxHeight) {
// Set max-height and adjust overflow behavior if maxHeight is provided
currentTextarea.style.maxHeight = `${maxHeight}px`;
currentTextarea.style.overflowY = contentHeight > maxHeight ? "scroll" : "hidden";
currentTextarea.style.height = `${Math.min(contentHeight, maxHeight)}px`;
} else {
// Adjust height without max height constraint
currentTextarea.style.height = `${contentHeight}px`;
}
}
}, [textareaRef, textContent, maxHeight]);
};
export default useDynamicTextareaSize;

444
src/hooks/useMessage.tsx Normal file
View File

@ -0,0 +1,444 @@
import React from "react"
import { cleanUrl } from "~libs/clean-url"
import {
getOllamaURL,
promptForRag,
systemPromptForNonRag
} from "~services/ollama"
import { useStoreMessage, type ChatHistory, type Message } from "~store"
import { ChatOllama } from "@langchain/community/chat_models/ollama"
import {
HumanMessage,
AIMessage,
type MessageContent,
SystemMessage
} from "@langchain/core/messages"
import { getHtmlOfCurrentTab } from "~libs/get-html"
import { PageAssistHtmlLoader } from "~loader/html"
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter"
import { OllamaEmbeddings } from "@langchain/community/embeddings/ollama"
import { createChatWithWebsiteChain, groupMessagesByConversation } from "~chain/chat-with-website"
import { MemoryVectorStore } from "langchain/vectorstores/memory"
export type BotResponse = {
bot: {
text: string
sourceDocuments: any[]
}
history: ChatHistory
history_id: string
}
const generateHistory = (
messages: {
role: "user" | "assistant" | "system"
content: string
image?: string
}[]
) => {
let history = []
for (const message of messages) {
if (message.role === "user") {
let content: MessageContent = [
{
type: "text",
text: message.content
}
]
if (message.image) {
content = [
{
type: "image_url",
image_url: message.image
},
{
type: "text",
text: message.content
}
]
}
history.push(
new HumanMessage({
content: content
})
)
} else if (message.role === "assistant") {
history.push(
new AIMessage({
content: [
{
type: "text",
text: message.content
}
]
})
)
}
}
return history
}
export const useMessage = () => {
const {
history,
messages,
setHistory,
setMessages,
setStreaming,
streaming,
setIsFirstMessage,
historyId,
setHistoryId,
isLoading,
setIsLoading,
isProcessing,
setIsProcessing,
selectedModel,
setSelectedModel,
chatMode,
setChatMode,
setIsEmbedding,
isEmbedding
} = useStoreMessage()
const abortControllerRef = React.useRef<AbortController | null>(null)
const [keepTrackOfEmbedding, setKeepTrackOfEmbedding] = React.useState<{
[key: string]: MemoryVectorStore
}>({})
const clearChat = () => {
stopStreamingRequest()
setMessages([])
setHistory([])
setHistoryId(null)
setIsFirstMessage(true)
setIsLoading(false)
setIsProcessing(false)
setStreaming(false)
}
const memoryEmbedding = async (
url: string,
html: string,
ollamaEmbedding: OllamaEmbeddings
) => {
const loader = new PageAssistHtmlLoader({
html,
url
})
const docs = await loader.load()
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 200
})
const chunks = await textSplitter.splitDocuments(docs)
const store = new MemoryVectorStore(ollamaEmbedding)
setIsEmbedding(true)
await store.addDocuments(chunks)
setKeepTrackOfEmbedding({
...keepTrackOfEmbedding,
[url]: store
})
setIsEmbedding(false)
return store
}
const chatWithWebsiteMode = async (message: string) => {
const ollamaUrl = await getOllamaURL()
const { html, url } = await getHtmlOfCurrentTab()
const isAlreadyExistEmbedding = keepTrackOfEmbedding[url]
let newMessage: Message[] = [
...messages,
{
isBot: false,
name: "You",
message,
sources: []
},
{
isBot: true,
name: selectedModel,
message: "▋",
sources: []
}
]
const appendingIndex = newMessage.length - 1
setMessages(newMessage)
const ollamaEmbedding = new OllamaEmbeddings({
model: selectedModel,
baseUrl: cleanUrl(ollamaUrl)
})
const ollamaChat = new ChatOllama({
model: selectedModel,
baseUrl: cleanUrl(ollamaUrl)
})
let vectorstore: MemoryVectorStore
if (isAlreadyExistEmbedding) {
vectorstore = isAlreadyExistEmbedding
} else {
vectorstore = await memoryEmbedding(url, html, ollamaEmbedding)
}
const { ragPrompt: systemPrompt, ragQuestionPrompt: questionPrompt } =
await promptForRag()
const sanitizedQuestion = message.trim().replaceAll("\n", " ")
const chain = createChatWithWebsiteChain({
llm: ollamaChat,
question_llm: ollamaChat,
question_template: questionPrompt,
response_template: systemPrompt,
retriever: vectorstore.asRetriever()
})
try {
const chunks = await chain.stream({
question: sanitizedQuestion,
chat_history: groupMessagesByConversation(history),
})
let count = 0
for await (const chunk of chunks) {
if (count === 0) {
setIsProcessing(true)
newMessage[appendingIndex].message = chunk + "▋"
setMessages(newMessage)
} else {
newMessage[appendingIndex].message =
newMessage[appendingIndex].message.slice(0, -1) + chunk + "▋"
setMessages(newMessage)
}
count++
}
newMessage[appendingIndex].message = newMessage[
appendingIndex
].message.slice(0, -1)
setHistory([
...history,
{
role: "user",
content: message
},
{
role: "assistant",
content: newMessage[appendingIndex].message
}
])
setIsProcessing(false)
} catch (e) {
console.log(e)
setIsProcessing(false)
setStreaming(false)
setMessages([
...messages,
{
isBot: true,
name: selectedModel,
message: `Something went wrong. Check out the following logs:
~~~
${e?.message}
~~~
`,
sources: []
}
])
}
}
const normalChatMode = async (message: string, image: string) => {
const url = await getOllamaURL()
if (image.length > 0) {
image = `data:image/jpeg;base64,${image.split(",")[1]}`
}
abortControllerRef.current = new AbortController()
const ollama = new ChatOllama({
model: selectedModel,
baseUrl: cleanUrl(url)
})
let newMessage: Message[] = [
...messages,
{
isBot: false,
name: "You",
message,
sources: [],
images: [image]
},
{
isBot: true,
name: selectedModel,
message: "▋",
sources: []
}
]
const appendingIndex = newMessage.length - 1
setMessages(newMessage)
try {
const prompt = await systemPromptForNonRag()
message = message.trim().replaceAll("\n", " ")
let humanMessage = new HumanMessage({
content: [
{
text: message,
type: "text"
}
]
})
if (image.length > 0) {
humanMessage = new HumanMessage({
content: [
{
text: message,
type: "text"
},
{
image_url: image,
type: "image_url"
}
]
})
}
const applicationChatHistory = generateHistory(history)
if (prompt) {
applicationChatHistory.unshift(
new SystemMessage({
content: [
{
text: prompt,
type: "text"
}
]
})
)
}
const chunks = await ollama.stream(
[...applicationChatHistory, humanMessage],
{
signal: abortControllerRef.current.signal
}
)
let count = 0
for await (const chunk of chunks) {
if (count === 0) {
setIsProcessing(true)
newMessage[appendingIndex].message = chunk.content + "▋"
setMessages(newMessage)
} else {
newMessage[appendingIndex].message =
newMessage[appendingIndex].message.slice(0, -1) +
chunk.content +
"▋"
setMessages(newMessage)
}
count++
}
newMessage[appendingIndex].message = newMessage[
appendingIndex
].message.slice(0, -1)
setHistory([
...history,
{
role: "user",
content: message,
image
},
{
role: "assistant",
content: newMessage[appendingIndex].message
}
])
setIsProcessing(false)
} catch (e) {
console.log(e)
setIsProcessing(false)
setStreaming(false)
setMessages([
...messages,
{
isBot: true,
name: selectedModel,
message: `Something went wrong. Check out the following logs:
\`\`\`
${e?.message}
\`\`\`
`,
sources: []
}
])
}
}
const onSubmit = async ({
message,
image
}: {
message: string
image: string
}) => {
if (chatMode === "normal") {
await normalChatMode(message, image)
} else {
await chatWithWebsiteMode(message)
}
}
const stopStreamingRequest = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
abortControllerRef.current = null
}
}
return {
messages,
setMessages,
onSubmit,
setStreaming,
streaming,
setHistory,
historyId,
setHistoryId,
setIsFirstMessage,
isLoading,
setIsLoading,
isProcessing,
stopStreamingRequest,
clearChat,
selectedModel,
setSelectedModel,
chatMode,
setChatMode,
isEmbedding
}
}

View File

@ -0,0 +1,306 @@
import React from "react"
import { cleanUrl } from "~libs/clean-url"
import { getOllamaURL, systemPromptForNonRagOption } from "~services/ollama"
import { type ChatHistory, type Message } from "~store/option"
import { ChatOllama } from "@langchain/community/chat_models/ollama"
import {
HumanMessage,
AIMessage,
type MessageContent,
SystemMessage
} from "@langchain/core/messages"
import { useStoreMessageOption } from "~store/option"
import { saveHistory, saveMessage } from "~libs/db"
export type BotResponse = {
bot: {
text: string
sourceDocuments: any[]
}
history: ChatHistory
history_id: string
}
const generateHistory = (
messages: {
role: "user" | "assistant" | "system"
content: string
image?: string
}[]
) => {
let history = []
for (const message of messages) {
if (message.role === "user") {
let content: MessageContent = [
{
type: "text",
text: message.content
}
]
if (message.image) {
content = [
{
type: "image_url",
image_url: message.image
},
{
type: "text",
text: message.content
}
]
}
history.push(
new HumanMessage({
content: content
})
)
} else if (message.role === "assistant") {
history.push(
new AIMessage({
content: [
{
type: "text",
text: message.content
}
]
})
)
}
}
return history
}
export const useMessageOption = () => {
const {
history,
messages,
setHistory,
setMessages,
setStreaming,
streaming,
setIsFirstMessage,
historyId,
setHistoryId,
isLoading,
setIsLoading,
isProcessing,
setIsProcessing,
selectedModel,
setSelectedModel,
chatMode,
setChatMode
} = useStoreMessageOption()
const abortControllerRef = React.useRef<AbortController | null>(null)
const clearChat = () => {
// stopStreamingRequest()
setMessages([])
setHistory([])
setHistoryId(null)
setIsFirstMessage(true)
setIsLoading(false)
setIsProcessing(false)
setStreaming(false)
}
const normalChatMode = async (message: string, image: string) => {
const url = await getOllamaURL()
if (image.length > 0) {
image = `data:image/jpeg;base64,${image.split(",")[1]}`
}
abortControllerRef.current = new AbortController()
const ollama = new ChatOllama({
model: selectedModel,
baseUrl: cleanUrl(url)
})
let newMessage: Message[] = [
...messages,
{
isBot: false,
name: "You",
message,
sources: [],
images: [image]
},
{
isBot: true,
name: selectedModel,
message: "▋",
sources: []
}
]
const appendingIndex = newMessage.length - 1
setMessages(newMessage)
try {
const prompt = await systemPromptForNonRagOption()
message = message.trim().replaceAll("\n", " ")
let humanMessage = new HumanMessage({
content: [
{
text: message,
type: "text"
}
]
})
if (image.length > 0) {
humanMessage = new HumanMessage({
content: [
{
text: message,
type: "text"
},
{
image_url: image,
type: "image_url"
}
]
})
}
const applicationChatHistory = generateHistory(history)
if (prompt) {
applicationChatHistory.unshift(
new SystemMessage({
content: [
{
text: prompt,
type: "text"
}
]
})
)
}
const chunks = await ollama.stream(
[...applicationChatHistory, humanMessage],
{
signal: abortControllerRef.current.signal
}
)
let count = 0
for await (const chunk of chunks) {
if (count === 0) {
setIsProcessing(true)
newMessage[appendingIndex].message = chunk.content + "▋"
setMessages(newMessage)
} else {
newMessage[appendingIndex].message =
newMessage[appendingIndex].message.slice(0, -1) +
chunk.content +
"▋"
setMessages(newMessage)
}
count++
}
newMessage[appendingIndex].message = newMessage[
appendingIndex
].message.slice(0, -1)
setHistory([
...history,
{
role: "user",
content: message,
image
},
{
role: "assistant",
content: newMessage[appendingIndex].message
}
])
if (historyId) {
await saveMessage(historyId, selectedModel, "user", message, [image])
await saveMessage(
historyId,
selectedModel,
"assistant",
newMessage[appendingIndex].message,
[]
)
} else {
const newHistoryId = await saveHistory(message)
await saveMessage(newHistoryId.id, selectedModel, "user", message, [
image
])
await saveMessage(
newHistoryId.id,
selectedModel,
"assistant",
newMessage[appendingIndex].message,
[]
)
setHistoryId(newHistoryId.id)
}
setIsProcessing(false)
} catch (e) {
setIsProcessing(false)
setStreaming(false)
setMessages([
...messages,
{
isBot: true,
name: selectedModel,
message: `Something went wrong. Check out the following logs:
\`\`\`
${e?.message}
\`\`\`
`,
sources: []
}
])
}
}
const onSubmit = async ({
message,
image
}: {
message: string
image: string
}) => {
await normalChatMode(message, image)
}
const stopStreamingRequest = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
abortControllerRef.current = null
}
}
return {
messages,
setMessages,
onSubmit,
setStreaming,
streaming,
setHistory,
historyId,
setHistoryId,
setIsFirstMessage,
isLoading,
setIsLoading,
isProcessing,
stopStreamingRequest,
clearChat,
selectedModel,
setSelectedModel,
chatMode,
setChatMode
}
}

3
src/libs/class-name.tsx Normal file
View File

@ -0,0 +1,3 @@
export const classNames = (...classes: string[]) => {
return classes.filter(Boolean).join(" ")
}

7
src/libs/clean-url.ts Normal file
View File

@ -0,0 +1,7 @@
// clean url ending if it with /
export const cleanUrl = (url: string) => {
if (url.endsWith("/")) {
return url.slice(0, -1)
}
return url
}

147
src/libs/db.ts Normal file
View File

@ -0,0 +1,147 @@
import {
type ChatHistory as ChatHistoryType,
type Message as MessageType
} from "~store/option"
type HistoryInfo = {
id: string
title: string
createdAt: number
}
type Message = {
id: string
history_id: string
name: string
role: string
content: string
images?: string[]
sources?: string[]
createdAt: number
}
type MessageHistory = Message[]
type ChatHistory = HistoryInfo[]
export class PageAssitDatabase {
db: chrome.storage.StorageArea
constructor() {
this.db = chrome.storage.local
}
async getChatHistory(id: string): Promise<MessageHistory> {
return new Promise((resolve, reject) => {
this.db.get(id, (result) => {
resolve(result[id] || [])
})
})
}
async getChatHistories(): Promise<ChatHistory> {
return new Promise((resolve, reject) => {
this.db.get("chatHistories", (result) => {
resolve(result.chatHistories || [])
})
})
}
async addChatHistory(history: HistoryInfo) {
const chatHistories = await this.getChatHistories()
const newChatHistories = [history, ...chatHistories]
this.db.set({ chatHistories: newChatHistories })
}
async addMessage(message: Message) {
const history_id = message.history_id
const chatHistory = await this.getChatHistory(history_id)
const newChatHistory = [message, ...chatHistory]
this.db.set({ [history_id]: newChatHistory })
}
async removeChatHistory(id: string) {
const chatHistories = await this.getChatHistories()
const newChatHistories = chatHistories.filter(
(history) => history.id !== id
)
this.db.set({ chatHistories: newChatHistories })
}
async removeMessage(history_id: string, message_id: string) {
const chatHistory = await this.getChatHistory(history_id)
const newChatHistory = chatHistory.filter(
(message) => message.id !== message_id
)
this.db.set({ [history_id]: newChatHistory })
}
async clear() {
this.db.clear()
}
async deleteChatHistory() {
const chatHistories = await this.getChatHistories()
for (const history of chatHistories) {
this.db.remove(history.id)
}
this.db.remove("chatHistories")
}
}
const generateID = () => {
return "pa_xxxx-xxxx-xxx-xxxx".replace(/[x]/g, () => {
const r = Math.floor(Math.random() * 16)
return r.toString(16)
})
}
export const saveHistory = async (title: string) => {
const id = generateID()
const createdAt = Date.now()
const history = { id, title, createdAt }
const db = new PageAssitDatabase()
await db.addChatHistory(history)
return history
}
export const saveMessage = async (
history_id: string,
name: string,
role: string,
content: string,
images: string[]
) => {
const id = generateID()
const createdAt = Date.now()
const message = { id, history_id, name, role, content, images, createdAt }
const db = new PageAssitDatabase()
await db.addMessage(message)
return message
}
export const formatToChatHistory = (
messages: MessageHistory
): ChatHistoryType => {
messages.sort((a, b) => a.createdAt - b.createdAt)
return messages.map((message) => {
return {
content: message.content,
role: message.role as "user" | "assistant" | "system",
images: message.images
}
})
}
export const formatToMessage = (messages: MessageHistory): MessageType[] => {
messages.sort((a, b) => a.createdAt - b.createdAt)
return messages.map((message) => {
return {
isBot: message.role === "assistant",
message: message.content,
name: message.name,
sources: message?.sources || [],
images: message.images || []
}
})
}

31
src/libs/get-html.ts Normal file
View File

@ -0,0 +1,31 @@
const _getHtml = () => {
const url = window.location.href
const html = Array.from(document.querySelectorAll("script")).reduce(
(acc, script) => {
return acc.replace(script.outerHTML, "")
},
document.documentElement.outerHTML
)
return { url, html }
}
export const getHtmlOfCurrentTab = async () => {
const result = new Promise((resolve) => {
chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
const tab = tabs[0]
const data = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: _getHtml
})
if (data.length > 0) {
resolve(data[0].result)
}
})
}) as Promise<{
url: string
html: string
}>
return result
}

31
src/libs/runtime.ts Normal file
View File

@ -0,0 +1,31 @@
export const chromeRunTime = async function (domain: string) {
if (typeof chrome !== "undefined" && chrome.runtime && chrome.runtime.id) {
const url = new URL(domain)
const domains = [url.hostname]
const rules = [
{
id: 1,
priority: 1,
condition: {
requestDomains: domains
},
action: {
type: "modifyHeaders",
requestHeaders: [
{
header: "Origin",
operation: "set",
value: `${url.protocol}//${url.hostname}`
}
]
}
}
]
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: rules.map((r) => r.id),
// @ts-ignore
addRules: rules
})
}
}

7
src/libs/to-base64.ts Normal file
View File

@ -0,0 +1,7 @@
export const toBase64 = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result as string)
reader.onerror = (error) => reject(error)
})

31
src/loader/html.ts Normal file
View File

@ -0,0 +1,31 @@
import { BaseDocumentLoader } from "langchain/document_loaders/base"
import { Document } from "@langchain/core/documents"
import { compile } from "html-to-text"
export interface WebLoaderParams {
html: string
url: string
}
export class PageAssistHtmlLoader
extends BaseDocumentLoader
implements WebLoaderParams
{
html: string
url: string
constructor({ html, url }: WebLoaderParams) {
super()
this.html = html
this.url = url
}
async load(): Promise<Document<Record<string, any>>[]> {
const htmlCompiler = compile({
wordwrap: false
})
const text = htmlCompiler(this.html)
const metadata = { source: this.url }
return [new Document({ pageContent: text, metadata })]
}
}

31
src/options.tsx Normal file
View File

@ -0,0 +1,31 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { MemoryRouter } from "react-router-dom"
import { ToastContainer } from "react-toastify"
import "react-toastify/dist/ReactToastify.css"
const queryClient = new QueryClient()
import "./css/tailwind.css"
import { ConfigProvider, theme } from "antd"
import { StyleProvider } from "@ant-design/cssinjs"
import { useDarkMode } from "~hooks/useDarkmode"
import { OptionRouting } from "~routes"
function IndexOption() {
const { mode } = useDarkMode()
return (
<MemoryRouter>
<ConfigProvider
theme={{
algorithm:
mode === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm
}}>
<StyleProvider hashPriority="high">
<QueryClientProvider client={queryClient}>
<OptionRouting />
<ToastContainer />
</QueryClientProvider>
</StyleProvider>
</ConfigProvider>
</MemoryRouter>
)
}
export default IndexOption

View File

@ -1,48 +0,0 @@
import { AppProps, type AppType } from "next/app";
import { api } from "~/utils/api";
import "~/styles/globals.css";
import { Poppins } from "next/font/google";
import {
createBrowserSupabaseClient,
Session,
} from "@supabase/auth-helpers-nextjs";
import React from "react";
import { SessionContextProvider } from "@supabase/auth-helpers-react";
const poppins = Poppins({
weight: ["400", "500", "600", "700", "800", "900"],
style: ["normal"],
subsets: ["latin"],
});
function MyApp({
Component,
pageProps,
}: AppProps<{
initialSession: Session;
}>): JSX.Element {
const [supabaseClient] = React.useState(() => createBrowserSupabaseClient());
return (
<>
<style jsx global>
{`
html,
body {
font-family: ${poppins.style.fontFamily} !important;
}
`}
</style>
<SessionContextProvider
supabaseClient={supabaseClient}
initialSession={pageProps.initialSession}
>
<Component {...pageProps} />
</SessionContextProvider>
</>
);
}
export default api.withTRPC(MyApp);

View File

@ -1,19 +0,0 @@
import { createNextApiHandler } from "@trpc/server/adapters/next";
import { env } from "~/env.mjs";
import { createTRPCContext } from "~/server/api/trpc";
import { appRouter } from "~/server/api/root";
// export API handler
export default createNextApiHandler({
router: appRouter,
createContext: createTRPCContext,
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
);
}
: undefined,
});

View File

@ -1,23 +0,0 @@
import {NextApiRequest, NextApiResponse} from 'next'
import { prisma } from '~/server/db'
export default async (req: NextApiRequest, res: NextApiResponse) => {
const {token } = req.body
if(!token) {
return res.status(400).json({error: 'Token is required'})
}
const isUserExist = await prisma.user.findFirst({
where: {
access_token: token
}
})
if(!isUserExist) {
return res.status(400).json({error: 'Invalid token'})
}
return res.status(200).json({message: 'Token is valid'})
}

View File

@ -1,76 +0,0 @@
import { useSupabaseClient, useUser } from "@supabase/auth-helpers-react";
import { type NextPage } from "next";
import Head from "next/head";
import { useRouter } from "next/router";
import React from "react";
import { Auth } from "@supabase/auth-ui-react";
import { ThemeSupa, ThemeMinimal } from "@supabase/auth-ui-shared";
const AuthPage: NextPage = () => {
const supabaseClient = useSupabaseClient();
const user = useUser();
const router = useRouter();
React.useEffect(() => {
if (user) {
router.push("/dashboard");
}
}, [user]);
return (
<>
<Head>
<title>Get Started / Page Assist</title>
</Head>
<div className="relative isolate flex min-h-full flex-col justify-center overflow-hidden bg-white py-12 sm:px-6 lg:px-8">
<svg
className="absolute inset-0 -z-10 h-full w-full stroke-gray-200 [mask-image:radial-gradient(100%_100%_at_top_right,white,transparent)]"
aria-hidden="true"
>
<defs>
<pattern
id="0787a7c5-978c-4f66-83c7-11c213f99cb7"
width={200}
height={200}
x="50%"
y={-1}
patternUnits="userSpaceOnUse"
>
<path d="M.5 200V.5H200" fill="none" />
</pattern>
</defs>
<rect
width="100%"
height="100%"
strokeWidth={0}
fill="url(#0787a7c5-978c-4f66-83c7-11c213f99cb7)"
/>
</svg>
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<img
className="mx-auto h-12 w-auto"
src="logo.png"
alt="Page Assist"
/>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
Page Assist
</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="px-4 py-8 shadow sm:rounded-lg sm:px-10">
<Auth
supabaseClient={supabaseClient}
providers={[]}
appearance={{ theme: ThemeSupa }}
view="magic_link"
showLinks={false}
magicLink={true}
/>
</div>
</div>
</div>
</>
);
};
export default AuthPage;

View File

@ -1,38 +0,0 @@
import { createServerSupabaseClient } from "@supabase/auth-helpers-nextjs";
import { GetServerSideProps, NextPage } from "next";
import Head from "next/head";
import { DashboardChatBody } from "~/components/Chat";
import DashboardLayout from "~/components/Layouts/DashboardLayout";
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const supabase = createServerSupabaseClient(ctx);
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return {
redirect: {
destination: "/auth",
permanent: false,
},
};
}
return {
props: {},
};
};
const DashboardChatPage: NextPage = () => {
return (
<DashboardLayout>
<Head>
<title>Chat / PageAssist</title>
</Head>
<DashboardChatBody />
</DashboardLayout>
);
};
export default DashboardChatPage;

View File

@ -1,38 +0,0 @@
import { createServerSupabaseClient } from "@supabase/auth-helpers-nextjs";
import { GetServerSideProps, NextPage } from "next";
import Head from "next/head";
import DashboardBoby from "~/components/Dashboard";
import DashboardLayout from "~/components/Layouts/DashboardLayout";
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const supabase = createServerSupabaseClient(ctx);
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return {
redirect: {
destination: "/auth",
permanent: false,
},
};
}
return {
props: {},
};
};
const DashboardPage: NextPage = () => {
return (
<DashboardLayout>
<Head>
<title>Dashboard / PageAssist</title>
</Head>
<DashboardBoby />
</DashboardLayout>
);
};
export default DashboardPage;

View File

@ -1,38 +0,0 @@
import { createServerSupabaseClient } from "@supabase/auth-helpers-nextjs";
import { GetServerSideProps, NextPage } from "next";
import Head from "next/head";
import DashboardLayout from "~/components/Layouts/DashboardLayout";
import SettingsBody from "~/components/Settings";
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const supabase = createServerSupabaseClient(ctx);
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return {
redirect: {
destination: "/auth",
permanent: false,
},
};
}
return {
props: {},
};
};
const DashboardSettingsPage: NextPage = () => {
return (
<DashboardLayout>
<Head>
<title>Settings / PageAssist</title>
</Head>
<SettingsBody />
</DashboardLayout>
);
};
export default DashboardSettingsPage;

Some files were not shown because too many files have changed in this diff Show More