Merge pull request #16 from n4ze3m/next

latest
This commit is contained in:
Muhammed Nazeem 2024-03-10 13:48:24 +05:30 committed by GitHub
commit 912e6c8b0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 782 additions and 130 deletions

View File

@ -85,9 +85,14 @@ This will start a development server and watch for changes in the source files.
## Browser Support
- Any Chromium-based browser that supports Chrome Extensions.
- Firefox support is planned for the future.
| Browser | Sidebar | Chat With Webpage | Web UI |
| -------- | ------- | ----------------- | ------ |
| Chrome | ✅ | ✅ | ✅ |
| Brave | ✅ | ✅ | ✅ |
| Edge | ✅ | ❌ | ✅ |
| Opera GX | ❌ | ❌ | ✅ |
| Arc | ❌ | ❌ | ✅ |
| Firefox | ❌ | ❌ | ❌ |
## Local AI Provider
@ -105,11 +110,6 @@ This will start a development server and watch for changes in the source files.
Contributions are welcome. If you have any feature requests, bug reports, or questions, feel free to create an issue.
## 0.0.1
If you are looking for the v0.0.1 of this project, you can find it on v0.0.1 branch. I created it as a hackathon project and it is not maintained anymore.
## Support
If you like the project and want to support it, you can buy me a coffee. It will help me to keep working on the project.

View File

@ -1,7 +1,7 @@
{
"name": "pageassist",
"displayName": "Page Assist - A Web UI for Local AI Models",
"version": "1.0.5",
"version": "1.0.8",
"description": "Use your locally running AI models to assist you in your web browsing.",
"author": "n4ze3m",
"scripts": {
@ -26,7 +26,7 @@
"dayjs": "^1.11.10",
"html-to-text": "^9.0.5",
"langchain": "^0.1.9",
"lucide-react": "^0.340.0",
"lucide-react": "^0.350.0",
"plasmo": "0.84.1",
"property-information": "^6.4.1",
"react": "18.2.0",
@ -38,6 +38,7 @@
"rehype-mathjax": "4.0.3",
"remark-gfm": "3.0.1",
"remark-math": "5.1.1",
"yt-transcript": "^0.0.2",
"zustand": "^4.5.0"
},
"devDependencies": {

37
page-share.md Normal file
View File

@ -0,0 +1,37 @@
# Page Share
Page Share allows you to share the chat publicly, similar to ChatGPT Share. You can self-host Page Share for privacy and security.
The default Page Share is hosted at [https://pageassist.xyz](https://pageassist.xyz).
## Self-Host
You can self-host Page Share using two methods:
- Railway
- Docker
### Railway
Click the button below to deploy the code to Railway.
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/VbiS2Q?referralCode=olbszX)
### Docker
1. Clone the repository
```bash
git clone https://github.com/n4ze3m/page-assist-app.git
cd page-assist-app
```
2. Run the server
```bash
docker-compose up
```
3. Open the app
Navigate to [http://localhost:3000](http://localhost:3000) in your browser.

View File

@ -0,0 +1,193 @@
import { Form, Image, Input, Modal, Tooltip, message } from "antd"
import { Share } from "lucide-react"
import { useState } from "react"
import type { Message } from "~store/option"
import Markdown from "./Markdown"
import React from "react"
import { useMutation } from "@tanstack/react-query"
import { getPageShareUrl } from "~services/ollama"
import { cleanUrl } from "~libs/clean-url"
import { getUserId, saveWebshare } from "~libs/db"
type Props = {
messages: Message[]
}
const reformatMessages = (messages: Message[], username: string) => {
return messages.map((message, idx) => {
return {
id: idx,
name: message.isBot ? message.name : username,
isBot: message.isBot,
message: message.message,
images: message.images
}
})
}
export const PlaygroundMessage = (
props: Message & {
username: string
}
) => {
return (
<div className="group w-full text-gray-800 dark:text-gray-100">
<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 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 ? (
<div className="absolute h-8 w-8 rounded-full bg-gradient-to-r from-green-300 to-purple-400"></div>
) : (
<div className="absolute h-8 w-8 rounded-full from-blue-400 to-blue-600 bg-gradient-to-r"></div>
)}
</div>
</div>
<div className="flex w-[calc(100%-50px)] flex-col gap-3 lg:w-[calc(100%-115px)]">
<span className="text-xs font-bold text-gray-800 dark:text-white">
{props.isBot ? props.name : props.username}
</span>
<div className="flex flex-grow flex-col">
<Markdown message={props.message} />
</div>
{/* source if aviable */}
{props.images && props.images.length > 0 && (
<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) => (
<Image
key={index}
src={image}
alt="Uploaded Image"
width={180}
className="rounded-md relative"
/>
))}
</div>
)}
</div>
</div>
</div>
</div>
)
}
export const ShareBtn: React.FC<Props> = ({ messages }) => {
const [open, setOpen] = useState(false)
const [form] = Form.useForm()
const name = Form.useWatch("name", form)
React.useEffect(() => {
if (messages.length > 0) {
form.setFieldsValue({
title: messages[0].message
})
}
}, [messages])
const onSubmit = async (values: { title: string; name: string }) => {
const owner_id = await getUserId()
const chat = reformatMessages(messages, values.name)
const title = values.title
const url = await getPageShareUrl()
const res = await fetch(`${cleanUrl(url)}/api/v1/share/create`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
owner_id,
messages: chat,
title
})
})
if (!res.ok) throw new Error("Failed to create share link")
const data = await res.json()
return {
...data,
url: `${cleanUrl(url)}/share/${data.chat_id}`,
api_url: cleanUrl(url),
share_id: data.chat_id
}
}
const { mutate: createShareLink, isPending } = useMutation({
mutationFn: onSubmit,
onSuccess: async (data) => {
const url = data.url
navigator.clipboard.writeText(url)
message.success("Link copied to clipboard")
await saveWebshare({ title: data.title, url, api_url: data.api_url, share_id: data.share_id })
setOpen(false)
},
onError: (error) => {
message.error(error?.message || "Failed to create share link")
}
})
return (
<>
<Tooltip title="Share">
<button
onClick={() => setOpen(true)}
className="!text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
<Share className="w-6 h-6" />
</button>
</Tooltip>
<Modal
title="Share link to Chat"
open={open}
footer={null}
width={600}
onCancel={() => setOpen(false)}>
<Form
form={form}
layout="vertical"
onFinish={createShareLink}
initialValues={{
title: "Untitled Chat",
name: "Anonymous"
}}>
<Form.Item
name="title"
label="Chat Title"
rules={[{ required: true, message: "Please enter chat title" }]}>
<Input size="large" placeholder="Enter chat title" />
</Form.Item>
<Form.Item
name="name"
label="Your Name"
rules={[{ required: true, message: "Please enter your name" }]}>
<Input size="large" placeholder="Enter your name" />
</Form.Item>
<Form.Item>
<div className="max-h-[180px] overflow-x-auto border dark:border-gray-700 rounded-md p-2">
<div className="flex flex-col p-3">
{messages.map((message, index) => (
<PlaygroundMessage key={index} {...message} username={name} />
))}
</div>
</div>
</Form.Item>
<Form.Item>
<div className="flex justify-end">
<button
type="submit"
className="inline-flex items-center rounded-md border border-transparent bg-black px-2 py-2.5 text-md font-medium leading-4 text-white shadow-sm dark:bg-white dark:text-gray-800 disabled:opacity-50 ">
{isPending ? "Generating link..." : "Generate Link"}
</button>
</div>
</Form.Item>
</Form>
</Modal>
</>
)
}

View File

@ -16,6 +16,7 @@ import {
ZapIcon
} from "lucide-react"
import { getAllPrompts } from "~libs/db"
import { ShareBtn } from "~components/Common/ShareBtn"
export default function OptionLayout({
children
@ -29,7 +30,9 @@ export default function OptionLayout({
clearChat,
selectedSystemPrompt,
setSelectedQuickPrompt,
setSelectedSystemPrompt
setSelectedSystemPrompt,
messages,
streaming
} = useMessageOption()
const {
@ -67,7 +70,7 @@ export default function OptionLayout({
<div>
<div>
<div className="flex flex-col">
<div className="sticky top-0 z-[999] flex h-16 p-3 bg-white border-b border-gray-200 dark:bg-[#171717] dark:border-gray-600">
<div className="sticky top-0 z-[999] flex h-16 p-3 bg-white border-b dark:bg-[#171717] dark:border-gray-600">
<div className="flex gap-2 items-center">
{pathname !== "/" && (
<div>
@ -88,7 +91,7 @@ export default function OptionLayout({
<div>
<button
onClick={clearChat}
className="inline-flex items-center rounded-lg border dark:border-gray-700 bg-transparent px-3 py-3 text-sm font-medium leading-4 text-gray-800 shadow-sm dark:text-white disabled:opacity-50 ">
className="inline-flex items-center rounded-lg border dark:border-gray-700 bg-transparent px-3 py-3 text-sm font-medium leading-4 text-gray-800 dark:text-white disabled:opacity-50 ">
<SquarePen className="h-4 w-4 mr-3" />
New Chat
</button>
@ -155,13 +158,9 @@ export default function OptionLayout({
<div className="flex flex-1 justify-end px-4">
<div className="ml-4 flex items-center md:ml-6">
<div className="flex gap-4 items-center">
{/* <Tooltip title="Manage Prompts">
<NavLink
to="/prompts"
className="!text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
<Book className="w-6 h-6" />
</NavLink>
</Tooltip> */}
{pathname === "/" && messages.length > 0 && !streaming && (
<ShareBtn messages={messages} />
)}
<Tooltip title="Github Repository">
<a
href="https://github.com/n4ze3m/page-assist"
@ -170,13 +169,6 @@ export default function OptionLayout({
<GithubIcon className="w-6 h-6" />
</a>
</Tooltip>
{/* <Tooltip title="Manage Ollama Models">
<NavLink
to="/models"
className="!text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
<BrainCircuit className="w-6 h-6" />
</NavLink>
</Tooltip> */}
<Tooltip title="Manage Ollama Models">
<NavLink
to="/settings"
@ -198,7 +190,9 @@ export default function OptionLayout({
closeIcon={null}
onClose={() => setSidebarOpen(false)}
open={sidebarOpen}>
<Sidebar />
<Sidebar
onClose={() => setSidebarOpen(false)}
/>
</Drawer>
</div>
)

View File

@ -2,7 +2,8 @@ import {
Book,
BrainCircuit,
CircuitBoardIcon,
Orbit
Orbit,
Share
} from "lucide-react"
import { Link, useLocation } from "react-router-dom"
@ -22,15 +23,15 @@ const LinkComponent = (item: {
to={item.href}
className={classNames(
item.current === item.href
? "bg-gray-100 text-indigo-600 dark:bg-[#262626] dark:text-white"
: "text-gray-700 hover:text-indigo-600 hover:bg-gray-100 dark:text-gray-200 dark:hover:text-white dark:hover:bg-[#262626]",
? "bg-gray-100 text-gray-600 dark:bg-[#262626] dark:text-white"
: "text-gray-700 hover:text-gray-600 hover:bg-gray-100 dark:text-gray-200 dark:hover:text-white dark:hover:bg-[#262626]",
"group flex gap-x-3 rounded-md py-2 pl-2 pr-3 text-sm leading-6 font-semibold"
)}>
<item.icon
className={classNames(
item.current === item.href
? "text-indigo-600 dark:text-white"
: "text-gray-400 group-hover:text-indigo-600 dark:text-gray-200 dark:group-hover:text-white",
? "text-gray-600 dark:text-white"
: "text-gray-400 group-hover:text-gray-600 dark:text-gray-200 dark:group-hover:text-white",
"h-6 w-6 shrink-0"
)}
aria-hidden="true"
@ -46,7 +47,7 @@ export const SettingsLayout = ({ children }: { children: React.ReactNode }) => {
return (
<>
<div className="mx-auto max-w-7xl lg:flex lg:gap-x-16 lg:px-8">
<aside className="flex lg:rounded-md bg-white lg:h-56 lg:p-4 lg:mt-20 overflow-x-auto lg:border border-b py-4 lg:block lg:w-64 lg:flex-none dark:bg-[#171717] dark:border-gray-600">
<aside className="flex lg:rounded-md bg-white lg:p-4 lg:mt-20 overflow-x-auto lg:border-0 border-b py-4 lg:block lg:w-64 lg:flex-none dark:bg-[#171717] dark:border-gray-600">
<nav className="flex-none px-4 sm:px-6 lg:px-0">
<ul
role="list"
@ -75,6 +76,12 @@ export const SettingsLayout = ({ children }: { children: React.ReactNode }) => {
icon={Book}
current={location.pathname}
/>
<LinkComponent
href="/settings/share"
name="Manage Share"
icon={Share}
current={location.pathname}
/>
</ul>
</nav>
</aside>

View File

@ -35,7 +35,7 @@ export const PlaygroundEmpty = () => {
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">
<div className="rounded-lg justify-center items-center flex flex-col border p-8 bg-white dark:bg-[#262626] 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>

View File

@ -18,6 +18,7 @@ type Props = {
export const PlaygroundForm = ({ dropedFile }: Props) => {
const inputRef = React.useRef<HTMLInputElement>(null)
const [typing, setTyping] = React.useState<boolean>(false)
const {
onSubmit,
selectedModel,
@ -115,14 +116,14 @@ export const PlaygroundForm = ({ dropedFile }: Props) => {
}
})
const handleKeyDown = (e: KeyboardEvent) => {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Process" || e.key === "229" ) return
if (
!typing &&
e.key === "Enter" &&
!e.shiftKey &&
!isSending &&
sendWhenEnter &&
!e.isComposing
sendWhenEnter
) {
e.preventDefault()
form.onSubmit(async (value) => {
@ -153,7 +154,7 @@ export const PlaygroundForm = ({ dropedFile }: Props) => {
}
}
return (
<div className="px-3 pt-3 md:px-6 md:pt-6 md:bg-white dark:bg-[#262626] border rounded-t-xl border-black/10 dark:border-gray-600">
<div className="px-3 pt-3 md:px-6 md:pt-6 md:bg-white dark:bg-[#262626] border rounded-t-xl dark:border-gray-600">
<div
className={`h-full rounded-md shadow relative ${
form.values.image.length === 0 ? "hidden" : "block"
@ -213,7 +214,9 @@ export const PlaygroundForm = ({ dropedFile }: Props) => {
/>
<div className="w-full border-x border-t flex flex-col dark:border-gray-600 rounded-t-xl p-2">
<textarea
onKeyDown={(e) => handleKeyDown(e as unknown as KeyboardEvent)}
onCompositionStart={() => setTyping(true)}
onCompositionEnd={() => setTyping(false)}
onKeyDown={(e) => handleKeyDown(e)}
ref={textareaRef}
className="px-2 py-2 w-full resize-none bg-transparent focus-within:outline-none focus:ring-0 focus-visible:ring-0 ring-0 dark:ring-0 border-0 dark:text-gray-100"
required

View File

@ -24,7 +24,7 @@ export const SettingOther = () => {
<div className="border border-b border-gray-200 dark:border-gray-600 mt-3"></div>
</div>
<div className="flex flex-row justify-between">
<span className="text-gray-500 dark:text-gray-400 text-lg">
<span className="text-gray-500 dark:text-neutral-50">
Speech Recognition Language
</span>
@ -44,9 +44,7 @@ export const SettingOther = () => {
/>
</div>
<div className="flex flex-row justify-between">
<span className="text-gray-500 dark:text-gray-400 text-lg">
Change Theme
</span>
<span className="text-gray-500 dark:text-neutral-50 ">Change Theme</span>
<button
onClick={toggleDarkMode}
@ -59,8 +57,9 @@ export const SettingOther = () => {
{mode === "dark" ? "Light" : "Dark"}
</button>
</div>
<SearchModeSettings />
<div className="flex flex-row justify-between">
<span className="text-gray-500 dark:text-gray-400 text-lg">
<span className="text-gray-500 dark:text-neutral-50 ">
Delete Chat History
</span>
@ -83,7 +82,6 @@ export const SettingOther = () => {
Delete
</button>
</div>
<SearchModeSettings />
</dl>
)
}

View File

@ -19,7 +19,7 @@ export const SearchModeSettings = () => {
return (
<div className="flex flex-row justify-between">
<span className="text-gray-500 dark:text-gray-400 text-lg">
<span className="text-gray-500 dark:text-neutral-50 ">
Perform Simple Internet Search
</span>

View File

@ -0,0 +1,190 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Form, Input, Skeleton, Table, Tooltip, message } from "antd"
import { Trash2 } from "lucide-react"
import { SaveButton } from "~components/Common/SaveButton"
import { deleteWebshare, getAllWebshares, getUserId } from "~libs/db"
import { getPageShareUrl, setPageShareUrl } from "~services/ollama"
import { verifyPageShareURL } from "~utils/verify-page-share"
export const OptionShareBody = () => {
const queryClient = useQueryClient()
const { status, data } = useQuery({
queryKey: ["fetchShareInfo"],
queryFn: async () => {
const [url, shares] = await Promise.all([
getPageShareUrl(),
getAllWebshares()
])
return { url, shares }
}
})
const onSubmit = async (values: { url: string }) => {
const isOk = await verifyPageShareURL(values.url)
if (isOk) {
await setPageShareUrl(values.url)
}
}
const onDelete = async ({
api_url,
share_id,
id
}: {
id: string
share_id: string
api_url: string
}) => {
const owner_id = await getUserId()
const res = await fetch(`${api_url}/api/v1/share/delete`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
share_id,
owner_id
})
})
if (!res.ok) throw new Error("Failed to delete share link")
await deleteWebshare(id)
return "ok"
}
const { mutate: updatePageShareUrl, isPending: isUpdatePending } =
useMutation({
mutationFn: onSubmit,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["fetchShareInfo"]
})
message.success("Page Share URL updated successfully")
},
onError: (error) => {
message.error(error?.message || "Failed to update Page Share URL")
}
})
const { mutate: deleteMutation } = useMutation({
mutationFn: onDelete,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["fetchShareInfo"]
})
message.success("Webshare deleted successfully")
},
onError: (error) => {
message.error(error?.message || "Failed to delete Webshare")
}
})
return (
<div className="flex flex-col space-y-3">
{status === "pending" && <Skeleton paragraph={{ rows: 4 }} active />}
{status === "success" && (
<div>
<div>
<div>
<h2 className="text-base font-semibold leading-7 text-gray-900 dark:text-white">
Configure Page Share URL
</h2>
<div className="border border-b border-gray-200 dark:border-gray-600 mt-3 mb-6"></div>
</div>
<Form
layout="vertical"
onFinish={updatePageShareUrl}
initialValues={{
url: data.url
}}>
<Form.Item
name="url"
help={
<span>
For privacy reasons, you can self-host the page share and
provide the URL here.{" "}
<a
href="https://github.com/n4ze3m/page-assist/blob/main/page-share.md"
target="__blank"
className="text-blue-600 dark:text-blue-400">
Learn more
</a>
</span>
}
rules={[
{
required: true,
message: "Please input your Page Share URL!"
}
]}
label="Page Share URL">
<Input placeholder="Page Share URL" size="large" />
</Form.Item>
<Form.Item>
<div className="flex justify-end">
<SaveButton disabled={isUpdatePending} btnType="submit" />
</div>
</Form.Item>
</Form>
</div>
<div>
<div>
<h2 className="text-base font-semibold leading-7 text-gray-900 dark:text-white">
Webshares
</h2>
<div className="border border-b border-gray-200 dark:border-gray-600 mt-3 mb-6"></div>
</div>
<div>
<Table
dataSource={data.shares}
columns={[
{
title: "Title",
dataIndex: "title",
key: "title"
},
{
title: "URL",
dataIndex: "url",
key: "url",
render: (url: string) => (
<a
href={url}
target="__blank"
className="text-blue-600 dark:text-blue-400">
{url}
</a>
)
},
{
title: "Actions",
render: (_, render) => (
<Tooltip title="Delete Share">
<button
onClick={() => {
if (
window.confirm(
"Are you sure you want to delete this webshare?"
)
) {
deleteMutation({
id: render.id,
share_id: render.share_id,
api_url: render.api_url
})
}
}}
className="text-red-500 dark:text-red-400">
<Trash2 className="w-5 h-5" />
</button>
</Tooltip>
)
}
]}
/>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -10,14 +10,17 @@ import { Empty, Skeleton } from "antd"
import { useMessageOption } from "~hooks/useMessageOption"
import { useState } from "react"
import { PencilIcon, Trash2 } from "lucide-react"
import { useNavigate } from "react-router-dom"
type Props = {}
type Props = {
onClose: () => void
}
export const Sidebar = ({}: Props) => {
export const Sidebar = ({ onClose }: Props) => {
const { setMessages, setHistory, setHistoryId, historyId, clearChat } =
useMessageOption()
const [processingId, setProcessingId] = useState<string>("")
const client = useQueryClient()
const navigate = useNavigate()
const { data: chatHistories, status } = useQuery({
queryKey: ["fetchChatHistory"],
@ -28,21 +31,20 @@ export const Sidebar = ({}: Props) => {
}
})
const { isPending: isDeleting, mutate: deleteHistory } = useMutation({
const { mutate: deleteHistory } = useMutation({
mutationKey: ["deleteHistory"],
mutationFn: deleteByHistoryId,
onSuccess: (history_id) => {
client.invalidateQueries({
queryKey: ["fetchChatHistory"]
})
setProcessingId("")
if (historyId === history_id) {
clearChat()
}
}
})
const { isPending: isEditing, mutate: editHistory } = useMutation({
const { mutate: editHistory } = useMutation({
mutationKey: ["editHistory"],
mutationFn: async (data: { id: string; title: string }) => {
return await updateHistory(data.id, data.title)
@ -51,7 +53,6 @@ export const Sidebar = ({}: Props) => {
client.invalidateQueries({
queryKey: ["fetchChatHistory"]
})
setProcessingId("")
}
})
@ -86,6 +87,8 @@ export const Sidebar = ({}: Props) => {
setHistoryId(chat.id)
setHistory(formatToChatHistory(history))
setMessages(formatToMessage(history))
navigate("/")
onClose()
}}>
<span className="flex-grow truncate">{chat.title}</span>
</button>
@ -97,8 +100,6 @@ export const Sidebar = ({}: Props) => {
if (newTitle) {
editHistory({ id: chat.id, title: newTitle })
}
setProcessingId(chat.id)
}}
className="text-gray-500 dark:text-gray-400 opacity-80">
<PencilIcon className="w-4 h-4" />
@ -111,7 +112,6 @@ export const Sidebar = ({}: Props) => {
)
return
deleteHistory(chat.id)
setProcessingId(chat.id)
}}
className="text-red-500 dark:text-red-400 opacity-80">
<Trash2 className=" w-4 h-4 " />

View File

@ -38,7 +38,7 @@ export const EmptySidePanel = () => {
}
}, [ollamaInfo])
const { setSelectedModel, selectedModel, chatMode, setChatMode } =
const { setSelectedModel, selectedModel, chatMode, setChatMode, } =
useMessage()
return (

View File

@ -18,6 +18,7 @@ export const SidepanelForm = ({ dropedFile }: Props) => {
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
const { sendWhenEnter, setSendWhenEnter } = useWebUI()
const [typing, setTyping] = React.useState<boolean>(false)
const textAreaFocus = () => {
if (textareaRef.current) {
@ -72,14 +73,14 @@ export const SidepanelForm = ({ dropedFile }: Props) => {
}
})
const handleKeyDown = (e: KeyboardEvent) => {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Process" || e.key === "229") return
if (
e.key === "Enter" &&
!e.shiftKey &&
!isSending &&
sendWhenEnter &&
!e.isComposing
!typing
) {
e.preventDefault()
form.onSubmit(async (value) => {
@ -171,13 +172,15 @@ export const SidepanelForm = ({ dropedFile }: Props) => {
/>
<div className="w-full border-x border-t flex flex-col dark:border-gray-600 rounded-t-xl p-2">
<textarea
onKeyDown={(e) => handleKeyDown(e as unknown as KeyboardEvent)}
onKeyDown={(e) => handleKeyDown(e)}
ref={textareaRef}
className="px-2 py-2 w-full resize-none bg-transparent focus-within:outline-none focus:ring-0 focus-visible:ring-0 ring-0 dark:ring-0 border-0 dark:text-gray-100"
required
rows={1}
style={{ minHeight: "60px" }}
tabIndex={0}
onCompositionStart={() => setTyping(true)}
onCompositionEnd={() => setTyping(false)}
placeholder="Type a message..."
{...form.getInputProps("message")}
/>

View File

@ -20,9 +20,12 @@ 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 {
createChatWithWebsiteChain,
groupMessagesByConversation
} from "~chain/chat-with-website"
import { MemoryVectorStore } from "langchain/vectorstores/memory"
import { chromeRunTime } from "~libs/runtime"
export type BotResponse = {
bot: {
text: string
@ -104,7 +107,9 @@ export const useMessage = () => {
setIsEmbedding,
isEmbedding,
speechToTextLanguage,
setSpeechToTextLanguage
setSpeechToTextLanguage,
currentURL,
setCurrentURL
} = useStoreMessage()
const abortControllerRef = React.useRef<AbortController | null>(null)
@ -134,11 +139,11 @@ export const useMessage = () => {
url
})
const docs = await loader.load()
const chunkSize = await defaultEmbeddingChunkSize();
const chunkOverlap = await defaultEmbeddingChunkOverlap();
const chunkSize = await defaultEmbeddingChunkSize()
const chunkOverlap = await defaultEmbeddingChunkOverlap()
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize,
chunkOverlap,
chunkOverlap
})
const chunks = await textSplitter.splitDocuments(docs)
@ -158,9 +163,19 @@ export const useMessage = () => {
}
const chatWithWebsiteMode = async (message: string) => {
const ollamaUrl = await getOllamaURL()
try {
let isAlreadyExistEmbedding: MemoryVectorStore
let embedURL: string, embedHTML: string
if (messages.length === 0) {
const { html, url } = await getHtmlOfCurrentTab()
const isAlreadyExistEmbedding = keepTrackOfEmbedding[url]
embedHTML = html
embedURL = url
setCurrentURL(url)
isAlreadyExistEmbedding = keepTrackOfEmbedding[currentURL]
} else {
isAlreadyExistEmbedding = keepTrackOfEmbedding[currentURL]
embedURL = currentURL
}
let newMessage: Message[] = [
...messages,
{
@ -179,13 +194,14 @@ export const useMessage = () => {
const appendingIndex = newMessage.length - 1
setMessages(newMessage)
const ollamaUrl = await getOllamaURL()
const embeddingModle = await defaultEmbeddingModelForRag()
const ollamaEmbedding = new OllamaEmbeddings({
model: embeddingModle || selectedModel,
baseUrl: cleanUrl(ollamaUrl)
})
const ollamaChat = new ChatOllama({
model: selectedModel,
baseUrl: cleanUrl(ollamaUrl)
@ -196,7 +212,11 @@ export const useMessage = () => {
if (isAlreadyExistEmbedding) {
vectorstore = isAlreadyExistEmbedding
} else {
vectorstore = await memoryEmbedding(url, html, ollamaEmbedding)
vectorstore = await memoryEmbedding(
embedURL,
embedHTML,
ollamaEmbedding
)
}
const { ragPrompt: systemPrompt, ragQuestionPrompt: questionPrompt } =
@ -212,10 +232,9 @@ export const useMessage = () => {
retriever: vectorstore.asRetriever()
})
try {
const chunks = await chain.stream({
question: sanitizedQuestion,
chat_history: groupMessagesByConversation(history),
chat_history: groupMessagesByConversation(history)
})
let count = 0
for await (const chunk of chunks) {
@ -258,7 +277,8 @@ export const useMessage = () => {
{
isBot: true,
name: selectedModel,
message: `Something went wrong. Check out the following logs:
message: `Error in chat with website mode. Check out the following logs:
~~~
${e?.message}
~~~

View File

@ -32,6 +32,16 @@ type Message = {
}
type Webshare = {
id: string
title: string
url: string
api_url: string
share_id: string
createdAt: number
}
type Prompt = {
id: string
title: string
@ -154,6 +164,47 @@ export class PageAssitDatabase {
return prompts.find((prompt) => prompt.id === id)
}
async getWebshare(id: string) {
return new Promise((resolve, reject) => {
this.db.get(id, (result) => {
resolve(result[id] || [])
})
})
}
async getAllWebshares(): Promise<Webshare[]> {
return new Promise((resolve, reject) => {
this.db.get("webshares", (result) => {
resolve(result.webshares || [])
})
})
}
async addWebshare(webshare: Webshare) {
const webshares = await this.getAllWebshares()
const newWebshares = [webshare, ...webshares]
this.db.set({ webshares: newWebshares })
}
async deleteWebshare(id: string) {
const webshares = await this.getAllWebshares()
const newWebshares = webshares.filter((webshare) => webshare.id !== id)
this.db.set({ webshares: newWebshares })
}
async getUserID() {
return new Promise((resolve, reject) => {
this.db.get("user_id", (result) => {
resolve(result.user_id || "")
})
})
}
async setUserID(id: string) {
this.db.set({ user_id: id })
}
}
@ -278,3 +329,38 @@ export const getPromptById = async (id: string) => {
const db = new PageAssitDatabase()
return await db.getPromptById(id)
}
export const getAllWebshares = async () => {
const db = new PageAssitDatabase()
return await db.getAllWebshares()
}
export const deleteWebshare = async (id: string) => {
const db = new PageAssitDatabase()
await db.deleteWebshare(id)
return id
}
export const saveWebshare = async ({ title, url, api_url, share_id }: { title: string, url: string, api_url: string, share_id: string }) => {
const db = new PageAssitDatabase()
const id = generateID()
const createdAt = Date.now()
const webshare = { id, title, url, share_id, createdAt, api_url }
await db.addWebshare(webshare)
return webshare
}
export const getUserId = async () => {
const db = new PageAssitDatabase()
const id = await db.getUserID() as string
if (!id || id?.trim() === "") {
const user_id = "user_xxxx-xxxx-xxx-xxxx-xxxx".replace(/[x]/g, () => {
const r = Math.floor(Math.random() * 16)
return r.toString(16)
})
db.setUserID(user_id)
return user_id
}
return id
}

View File

@ -1,3 +1,4 @@
const _getHtml = () => {
const url = window.location.href
const html = Array.from(document.querySelectorAll("script")).reduce(
@ -29,3 +30,4 @@ export const getHtmlOfCurrentTab = async () => {
return result
}

View File

@ -2,6 +2,20 @@ import { BaseDocumentLoader } from "langchain/document_loaders/base"
import { Document } from "@langchain/core/documents"
import { compile } from "html-to-text"
import { chromeRunTime } from "~libs/runtime"
import { YtTranscript } from "yt-transcript"
const YT_REGEX =
/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=)?([a-zA-Z0-9_-]+)/
const isYoutubeLink = (url: string) => {
return YT_REGEX.test(url)
}
const getTranscript = async (url: string) => {
const ytTranscript = new YtTranscript({ url })
return await ytTranscript.getTranscript()
}
export interface WebLoaderParams {
html: string
@ -21,6 +35,29 @@ export class PageAssistHtmlLoader
}
async load(): Promise<Document<Record<string, any>>[]> {
if (isYoutubeLink(this.url)) {
const transcript = await getTranscript(this.url)
if (!transcript) {
throw new Error("Transcript not found for this video.")
}
let text = ""
transcript.forEach((item) => {
text += item.text + " "
})
return [
{
metadata: {
source: this.url,
audio: { chunks: transcript }
},
pageContent: text
}
]
}
const htmlCompiler = compile({
wordwrap: false
})
@ -30,6 +67,29 @@ export class PageAssistHtmlLoader
}
async loadByURL(): Promise<Document<Record<string, any>>[]> {
if (isYoutubeLink(this.url)) {
const transcript = await getTranscript(this.url)
if (!transcript) {
throw new Error("Transcript not found for this video.")
}
let text = ""
transcript.forEach((item) => {
text += item.text + " "
})
return [
{
metadata: {
source: this.url,
audio: { chunks: transcript }
},
pageContent: text
}
]
}
await chromeRunTime(this.url)
const fetchHTML = await fetch(this.url)
const html = await fetchHTML.text()

View File

@ -7,6 +7,7 @@ import { OptionModal } from "./option-settings-model"
import { OptionPrompt } from "./option-settings-prompt"
import { OptionOllamaSettings } from "./options-settings-ollama"
import { OptionSettings } from "./option-settings"
import { OptionShare } from "./option-settings-share"
export const OptionRouting = () => {
const { mode } = useDarkMode()
@ -19,6 +20,7 @@ export const OptionRouting = () => {
<Route path="/settings/model" element={<OptionModal />} />
<Route path="/settings/prompt" element={<OptionPrompt />} />
<Route path="/settings/ollama" element={<OptionOllamaSettings />} />
<Route path="/settings/share" element={<OptionShare />} />
</Routes>
</div>
)

View File

@ -0,0 +1,13 @@
import { SettingsLayout } from "~components/Layouts/SettingsOptionLayout"
import OptionLayout from "~components/Layouts/Layout"
import { OptionShareBody } from "~components/Option/Share"
export const OptionShare = () => {
return (
<OptionLayout>
<SettingsLayout>
<OptionShareBody />
</SettingsLayout>
</OptionLayout>
)
}

View File

@ -6,6 +6,7 @@ const storage = new Storage()
const DEFAULT_OLLAMA_URL = "http://127.0.0.1:11434"
const DEFAULT_ASK_FOR_MODEL_SELECTION_EVERY_TIME = true
const DEFAULT_PAGE_SHARE_URL = "https://pageassist.xyz"
const DEFAULT_RAG_QUESTION_PROMPT =
"Given the following conversation and a follow up question, rephrase the follow up question to be a standalone question. Chat History: {chat_history} Follow Up Input: {question} Standalone question:"
@ -303,3 +304,16 @@ export const getIsSimpleInternetSearch = async () => {
export const setIsSimpleInternetSearch = async (isSimpleInternetSearch: boolean) => {
await storage.set("isSimpleInternetSearch", isSimpleInternetSearch.toString())
}
export const getPageShareUrl = async () => {
const pageShareUrl = await storage.get("pageShareUrl")
if (!pageShareUrl || pageShareUrl.length === 0) {
return DEFAULT_PAGE_SHARE_URL
}
return pageShareUrl
}
export const setPageShareUrl = async (pageShareUrl: string) => {
await storage.set("pageShareUrl", pageShareUrl)
}

View File

@ -37,6 +37,8 @@ type State = {
setIsEmbedding: (isEmbedding: boolean) => void
speechToTextLanguage: string
setSpeechToTextLanguage: (speechToTextLanguage: string) => void
currentURL: string
setCurrentURL: (currentURL: string) => void
}
export const useStoreMessage = create<State>((set) => ({
@ -63,5 +65,7 @@ export const useStoreMessage = create<State>((set) => ({
setIsEmbedding: (isEmbedding) => set({ isEmbedding }),
speechToTextLanguage: "en-US",
setSpeechToTextLanguage: (speechToTextLanguage) =>
set({ speechToTextLanguage })
set({ speechToTextLanguage }),
currentURL: "",
setCurrentURL: (currentURL) => set({ currentURL })
}))

View File

@ -0,0 +1,10 @@
import { cleanUrl } from "~libs/clean-url"
export const verifyPageShareURL = async (url: string) => {
const res = await fetch(`${cleanUrl(url)}/api/v1/ping`)
if (!res.ok) {
throw new Error("Unable to verify page share")
}
const data = await res.text()
return data === "pong"
}

View File

@ -4967,10 +4967,10 @@ lru-cache@^6.0.0:
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3"
integrity sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==
lucide-react@^0.340.0:
version "0.340.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.340.0.tgz#67a6fac6a5e257f2036dffae0dd94d6ccb28ce8e"
integrity sha512-mWzYhbyy2d+qKuKHh+GWElPwa+kIquTnKbmSLGWOuZy+bjfZCkYD8DQWVFlqI4mQwc4HNxcqcOvtQ7ZS2PwURg==
lucide-react@^0.350.0:
version "0.350.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.350.0.tgz#78b45342f4daff4535290e37b1ea7eb0961a3dab"
integrity sha512-5IZVKsxxG8Nn81gpsz4XLNgCAXkppCh0Y0P0GLO39h5iVD2WEaB9of6cPkLtzys1GuSfxJxmwsDh487y7LAf/g==
magic-string@^0.30.0:
version "0.30.6"
@ -7772,6 +7772,13 @@ ws@^8.11.0:
resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4"
integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==
xml-js@^1.6.11:
version "1.6.11"
resolved "https://registry.yarnpkg.com/xml-js/-/xml-js-1.6.11.tgz#927d2f6947f7f1c19a316dd8eea3614e8b18f8e9"
integrity sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==
dependencies:
sax "^1.2.4"
xml-name-validator@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835"
@ -7817,6 +7824,14 @@ yaml@^2.2.1, yaml@^2.3.4:
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.4.tgz#53fc1d514be80aabf386dc6001eb29bf3b7523b2"
integrity sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==
yt-transcript@^0.0.2:
version "0.0.2"
resolved "https://registry.yarnpkg.com/yt-transcript/-/yt-transcript-0.0.2.tgz#1c54aede89bb8a03bbca3ba58520dbbd9c828571"
integrity sha512-+cNRqW6tSQNDkQDVrWNT6hc6X2TnaQLvUJIepzn9r7XdEvPtUDkfsyhptW5+j0EPIEpnlsKyA/epCUrE4QKn2g==
dependencies:
axios "^1.6.7"
xml-js "^1.6.11"
zod-to-json-schema@^3.22.3:
version "3.22.4"
resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.22.4.tgz#f8cc691f6043e9084375e85fb1f76ebafe253d70"