Merge pull request #6 from n4ze3m/next

1.0.3
This commit is contained in:
Muhammed Nazeem 2024-02-26 00:05:56 +05:30 committed by GitHub
commit c8a2387442
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1616 additions and 6458 deletions

View File

@ -121,3 +121,7 @@ or you can sponsor me on GitHub.
## License
MIT
## Last but not least
Made in [Alappuzha](https://en.wikipedia.org/wiki/Alappuzha) with ❤️

View File

@ -1,7 +1,7 @@
{
"name": "pageassist",
"displayName": "Page Assist - A Web UI for Local AI Models",
"version": "1.0.2",
"version": "1.0.3",
"description": "Use your locally running AI models to assist you in your web browsing.",
"author": "n4ze3m",
"scripts": {
@ -11,20 +11,23 @@
},
"dependencies": {
"@ant-design/cssinjs": "^1.18.4",
"@headlessui/react": "^1.7.18",
"@heroicons/react": "^2.1.1",
"@langchain/community": "^0.0.21",
"@langchain/core": "^0.1.22",
"@mantine/form": "^7.5.0",
"@mantine/hooks": "^7.5.3",
"@plasmohq/storage": "^1.9.0",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.10",
"@tanstack/react-query": "^5.17.19",
"@types/pdf-parse": "^1.1.4",
"antd": "^5.13.3",
"axios": "^1.6.7",
"dayjs": "^1.11.10",
"html-to-text": "^9.0.5",
"langchain": "^0.1.9",
"lucide-react": "^0.323.0",
"lucide-react": "^0.340.0",
"plasmo": "0.84.1",
"property-information": "^6.4.1",
"react": "18.2.0",

5993
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -5,9 +5,9 @@ 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"
import { CheckIcon, ClipboardIcon } from "lucide-react"
export default function Markdown({ message }: { message: string }) {
const [isBtnPressed, setIsBtnPressed] = React.useState(false)
@ -23,7 +23,7 @@ export default function Markdown({ message }: { message: string }) {
return (
<React.Fragment>
<ReactMarkdown
className="prose break-words dark:prose-invert text-sm prose-p:leading-relaxed prose-pre:p-0 dark:prose-dark"
className="prose break-words dark:prose-invert prose-p:leading-relaxed prose-pre:p-0 dark:prose-dark"
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeMathjax]}
components={{

View File

@ -1,7 +1,8 @@
import { CheckIcon, ClipboardIcon } from "@heroicons/react/24/outline"
import Markdown from "../../Common/Markdown"
import React from "react"
import { Image } from "antd"
import { Image, Tooltip } from "antd"
import { WebSearch } from "./WebSearch"
import { CheckIcon, ClipboardIcon } from "lucide-react"
type Props = {
message: string
@ -11,22 +12,20 @@ type Props = {
isBot: boolean
name: string
images?: string[]
currentMessageIndex: number
totalMessages: number
onRengerate: () => void
isProcessing: boolean
webSearch?: {}
isSearchingInternet?: boolean
sources?: any[]
}
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`}>
<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 p-4 md:py-6 lg:px-0 m-auto w-full">
<div className="w-8 flex flex-col relative items-end">
@ -44,19 +43,22 @@ export const PlaygroundMessage = (props: Props) => {
)}
</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}
<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 : "You"}
</span>
)}
<div className="flex flex-grow flex-col gap-3">
{props.isBot &&
props.isSearchingInternet &&
props.currentMessageIndex === props.totalMessages - 1 ? (
<WebSearch />
) : null}
<div className="flex flex-grow flex-col">
<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 && (
{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)
@ -71,16 +73,31 @@ export const PlaygroundMessage = (props: Props) => {
))}
</div>
)}
</div>
)}
{props.isBot && (
<div className="flex space-x-2">
<div className="mb-3 flex flex-wrap gap-2">
{props?.sources?.map((source, index) => (
<a
key={index}
href={source?.url}
target="_blank"
className="inline-flex cursor-pointer transition-shadow duration-300 ease-in-out hover:shadow-lg items-center rounded-md bg-gray-100 p-1 text-xs text-gray-800 border border-gray-300 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100 opacity-80 hover:opacity-100">
<span className="text-xs">{source.name}</span>
</a>
))}
</div>
)}
{props.isBot && !props.isProcessing && (
<div className="flex space-x-2 gap-2">
{!props.hideCopy && (
<Tooltip title="Copy to clipboard">
<button
onClick={() => {
navigator.clipboard.writeText(props.message)
setIsBtnPressed(true)
setTimeout(() => {
setIsBtnPressed(false)
}, 2000)
}}
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 ? (
@ -89,7 +106,18 @@ export const PlaygroundMessage = (props: Props) => {
<CheckIcon className="w-3 h-3 text-green-400 group-hover:text-green-500" />
)}
</button>
</Tooltip>
)}
{/* {props.currentMessageIndex === props.totalMessages - 1 && (
<Tooltip title="Regenerate">
<button
onClick={props.onRengerate}
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">
<ArrowPathIcon className="w-3 h-3 text-gray-400 group-hover:text-gray-500" />
</button>
</Tooltip>
)} */}
</div>
)}
</div>

View File

@ -0,0 +1,12 @@
import { Globe } from "lucide-react"
export const WebSearch = () => {
return (
<div className="gradient-border mt-4 flex w-56 items-center gap-4 rounded-lg bg-neutral-100 p-1ccc text-slate-900 dark:bg-neutral-800 dark:text-slate-50">
<div className="rounded p-1">
<Globe className="w-6 h-6" />
</div>
<div className="text-sm font-semibold">Searching the web</div>
</div>
)
}

View File

@ -1,11 +1,12 @@
import { useState } from "react"
import { CheckIcon } from "lucide-react"
type Props = {
onClick: () => void
onClick?: () => void
disabled?: boolean
className?: string
text?: string
textOnSave?: string
btnType?: "button" | "submit" | "reset"
}
export const SaveButton = ({
@ -13,20 +14,25 @@ export const SaveButton = ({
disabled,
className,
text = "Save",
textOnSave = "Saved"
textOnSave = "Saved",
btnType = "button"
}: Props) => {
const [clickedSave, setClickedSave] = useState(false)
return (
<button
type={btnType}
onClick={() => {
setClickedSave(true)
if (onClick) {
onClick()
}
setTimeout(() => {
setClickedSave(false)
}, 1000)
}}
disabled={disabled}
className={`inline-flex mt-4 items-center rounded-md border border-transparent bg-black px-2 py-2 text-sm font-medium leading-4 text-white shadow-sm dark:bg-white dark:text-gray-800 disabled:opacity-50 ${className}`}>
{clickedSave ? <CheckIcon className="w-4 h-4 mr-2" /> : null}
{clickedSave ? textOnSave : text}
</button>
)

View File

@ -1,20 +1,14 @@
import React, { useState } from "react"
import { CogIcon } from "@heroicons/react/24/outline"
import { useLocation, NavLink } from "react-router-dom"
import { Sidebar } from "./Sidebar"
import { Drawer, Layout, Modal, Select, Tooltip } from "antd"
import { useQuery } from "@tanstack/react-query"
import { fetchModels } from "~services/ollama"
import { fetchChatModels } from "~services/ollama"
import { useMessageOption } from "~hooks/useMessageOption"
import {
GithubIcon,
PanelLeftIcon,
BrainCircuit,
SquarePen,
ChevronLeft
} from "lucide-react"
import { Settings } from "./Settings"
import { BrainCircuit, ChevronLeft, CogIcon, GithubIcon, PanelLeftIcon, SquarePen } from "lucide-react"
export default function OptionLayout({
children
@ -30,7 +24,7 @@ export default function OptionLayout({
isFetching: isModelsFetching
} = useQuery({
queryKey: ["fetchModel"],
queryFn: fetchModels,
queryFn: fetchChatModels,
refetchInterval: 15000
})

View File

@ -2,11 +2,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Skeleton, Table, Tag, Tooltip, notification, Modal, Input } from "antd"
import { bytePerSecondFormatter } from "~libs/byte-formater"
import { deleteModel, getAllModels } from "~services/ollama"
import { Trash, RotateCcw, Download } from "lucide-react"
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
import { useState } from "react"
import { useForm } from "@mantine/form"
import { Download, RotateCcw, Trash2 } from "lucide-react"
dayjs.extend(relativeTime)
@ -132,7 +132,7 @@ export const ModelsBody = () => {
}
}}
className="text-red-500 dark:text-red-400">
<Trash className="w-5 h-5" />
<Trash2 className="w-5 h-5" />
</button>
</Tooltip>
<Tooltip title="Re-Pull Model">

View File

@ -5,6 +5,7 @@ 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")
@ -77,7 +78,9 @@ export const Playground = () => {
<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} />
<PlaygroundForm
dropedFile={dropedFile}
/>
</div>
</div>
</div>

View File

@ -1,11 +1,10 @@
import React from "react"
import { useMessage } from "~hooks/useMessage"
import { useMessageOption } from "~hooks/useMessageOption"
import { PlaygroundMessage } from "./PlaygroundMessage"
import { PlaygroundEmpty } from "./PlaygroundEmpty"
import { PlaygroundMessage } from "~components/Common/Playground/Message"
export const PlaygroundChat = () => {
const { messages } = useMessageOption()
const { messages, streaming, regenerateLastMessage, isSearchingInternet } = useMessageOption()
const divRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (divRef.current) {
@ -19,7 +18,7 @@ export const PlaygroundChat = () => {
<PlaygroundEmpty />
</div>
)}
{messages.length > 0 && <div className="w-full h-14 flex-shrink-0"></div>}
{messages.length > 0 && <div className="w-full h-16 flex-shrink-0"></div>}
{messages.map((message, index) => (
<PlaygroundMessage
key={index}
@ -27,6 +26,12 @@ export const PlaygroundChat = () => {
message={message.message}
name={message.name}
images={message.images || []}
currentMessageIndex={index}
totalMessages={messages.length}
onRengerate={regenerateLastMessage}
isProcessing={streaming}
isSearchingInternet={isSearchingInternet}
sources={message.sources}
/>
))}
{messages.length > 0 && (

View File

@ -2,14 +2,14 @@ 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 { Tooltip } from "antd"
import { MicIcon, StopCircleIcon } from "lucide-react"
import { Checkbox, Dropdown, Switch, Tooltip } from "antd"
import { Image } from "antd"
import { useSpeechRecognition } from "~hooks/useSpeechRecognition"
import { useWebUI } from "~store/webui"
import { defaultEmbeddingModelForRag } from "~services/ollama"
import { ImageIcon, MicIcon, StopCircleIcon, X } from "lucide-react"
type Props = {
dropedFile: File | undefined
@ -65,10 +65,14 @@ export const PlaygroundForm = ({ dropedFile }: Props) => {
selectedModel,
chatMode,
speechToTextLanguage,
stopStreamingRequest
stopStreamingRequest,
streaming: isSending,
webSearch,
setWebSearch
} = useMessageOption()
const { isListening, start, stop, transcript } = useSpeechRecognition()
const { sendWhenEnter, setSendWhenEnter } = useWebUI()
React.useEffect(() => {
if (isListening) {
@ -78,7 +82,7 @@ export const PlaygroundForm = ({ dropedFile }: Props) => {
const queryClient = useQueryClient()
const { mutateAsync: sendMessage, isPending: isSending } = useMutation({
const { mutateAsync: sendMessage } = useMutation({
mutationFn: onSubmit,
onSuccess: () => {
queryClient.invalidateQueries({
@ -106,19 +110,10 @@ export const PlaygroundForm = ({ dropedFile }: Props) => {
form.setFieldValue("image", "")
}}
className="flex items-center justify-center absolute top-0 m-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" />
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* <div className="flex gap-3 justify-end">
<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>
</div> */}
<div>
<div className="flex">
<form
@ -127,6 +122,16 @@ export const PlaygroundForm = ({ dropedFile }: Props) => {
form.setFieldError("message", "Please select a model")
return
}
if (webSearch) {
const defaultEM = await defaultEmbeddingModelForRag()
if (!defaultEM) {
form.setFieldError(
"message",
"Please set an embedding model on the Settings > Ollama page"
)
return
}
}
form.reset()
resetHeight()
await sendMessage({
@ -148,7 +153,12 @@ 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) => {
if (e.key === "Enter" && !e.shiftKey && !isSending) {
if (
e.key === "Enter" &&
!e.shiftKey &&
!isSending &&
sendWhenEnter
) {
e.preventDefault()
form.onSubmit(async (value) => {
if (value.message.trim().length === 0) {
@ -158,6 +168,16 @@ export const PlaygroundForm = ({ dropedFile }: Props) => {
form.setFieldError("message", "Please select a model")
return
}
if (webSearch) {
const defaultEM = await defaultEmbeddingModelForRag()
if (!defaultEM) {
form.setFieldError(
"message",
"Please set an embedding model on the Settings > Ollama page"
)
return
}
}
form.reset()
resetHeight()
await sendMessage({
@ -168,7 +188,7 @@ export const PlaygroundForm = ({ dropedFile }: Props) => {
}
}}
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"
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" }}
@ -176,7 +196,33 @@ export const PlaygroundForm = ({ dropedFile }: Props) => {
placeholder="Type a message..."
{...form.getInputProps("message")}
/>
<div className="flex mt-4 justify-end gap-3">
<div className="mt-4 flex justify-between items-center">
<div className="flex">
<Tooltip title="Search Internet">
<div className="inline-flex items-center gap-2">
<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 dark:text-gray-300">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418"
/>
</svg>
<Switch
value={webSearch}
onChange={(e) => setWebSearch(e)}
checkedChildren="On"
unCheckedChildren="Off"
/>
</div>
</Tooltip>
</div>
<div className="flex !justify-end gap-3">
<Tooltip title="Voice Message">
<button
type="button"
@ -210,13 +256,47 @@ export const PlaygroundForm = ({ dropedFile }: Props) => {
className={`flex items-center justify-center dark:text-gray-300 ${
chatMode === "rag" ? "hidden" : "block"
}`}>
<PhotoIcon className="h-5 w-5" />
<ImageIcon className="h-5 w-5" />
</button>
</Tooltip>
{!isSending ? (
<button
disabled={isSending || form.values.message.length === 0}
className="inline-flex items-center rounded-md border border-transparent bg-black px-2 py-2 text-sm font-medium leading-4 text-white shadow-sm hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100 dark:focus:ring-gray-500 dark:focus:ring-offset-gray-100 disabled:opacity-50 ">
<Dropdown.Button
htmlType="submit"
disabled={isSending}
className="!justify-end !w-auto"
icon={
<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">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m19.5 8.25-7.5 7.5-7.5-7.5"
/>
</svg>
}
menu={{
items: [
{
key: 1,
label: (
<Checkbox
checked={sendWhenEnter}
onChange={(e) =>
setSendWhenEnter(e.target.checked)
}>
Send when Enter pressed
</Checkbox>
)
}
]
}}>
<div className="inline-flex gap-2">
{sendWhenEnter ? (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
@ -224,13 +304,15 @@ export const PlaygroundForm = ({ dropedFile }: Props) => {
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-4 w-4 mr-2"
className="h-5 w-5"
viewBox="0 0 24 24">
<path d="M9 10L4 15 9 20"></path>
<path d="M20 4v7a4 4 0 01-4 4H4"></path>
</svg>
Send
</button>
) : null}
Submit
</div>
</Dropdown.Button>
) : (
<Tooltip title="Stop Streaming">
<button
@ -243,6 +325,7 @@ export const PlaygroundForm = ({ dropedFile }: Props) => {
)}
</div>
</div>
</div>
</form>
</div>
{form.errors.message && (

View File

@ -1,94 +0,0 @@
import { CheckIcon, ClipboardIcon } from "@heroicons/react/24/outline"
import Markdown from "../../Common/Markdown"
import React from "react"
import { Image } from "antd"
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 `}>
<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) => (
<Image
key={index}
src={image}
alt="Uploaded Image"
width={180}
className="rounded-md relative"
/>
))}
</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

@ -1,4 +1,4 @@
import { PencilSquareIcon } from "@heroicons/react/24/outline"
import { PencilIcon } from "lucide-react"
import { useMessage } from "../../../hooks/useMessage"
export const PlaygroundNewChat = () => {
@ -8,14 +8,13 @@ export const PlaygroundNewChat = () => {
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" />
<PencilIcon className="mx-3 h-5 w-5" aria-hidden="true" />
<span className="inline-flex font-semibol text-white text-sm">
New Chat
</span>

View File

@ -1,30 +1,55 @@
import { useQuery } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import { useMutation, useQuery } from "@tanstack/react-query"
import { Form, InputNumber, Select, Skeleton } from "antd"
import { useState } from "react"
import { SaveButton } from "~components/Common/SaveButton"
import { getOllamaURL, setOllamaURL as saveOllamaURL } from "~services/ollama"
import {
defaultEmbeddingChunkOverlap,
defaultEmbeddingChunkSize,
defaultEmbeddingModelForRag,
getAllModels,
getOllamaURL,
saveForRag,
setOllamaURL as saveOllamaURL
} from "~services/ollama"
export const SettingsOllama = () => {
const [ollamaURL, setOllamaURL] = useState<string>("")
const { data: ollamaInfo } = useQuery({
const { data: ollamaInfo, status } = useQuery({
queryKey: ["fetchOllamURL"],
queryFn: async () => {
const ollamaURL = await getOllamaURL()
const [ollamaURL, allModels, chunkOverlap, chunkSize, defaultEM] =
await Promise.all([
getOllamaURL(),
getAllModels(),
defaultEmbeddingChunkOverlap(),
defaultEmbeddingChunkSize(),
defaultEmbeddingModelForRag()
])
setOllamaURL(ollamaURL)
return {
ollamaURL
models: allModels,
chunkOverlap,
chunkSize,
defaultEM
}
}
})
useEffect(() => {
if (ollamaInfo?.ollamaURL) {
setOllamaURL(ollamaInfo.ollamaURL)
const { mutate: saveRAG, isPending: isSaveRAGPending } = useMutation({
mutationFn: async (data: {
model: string
chunkSize: number
overlap: number
}) => {
await saveForRag(data.model, data.chunkSize, data.overlap)
}
}, [ollamaInfo])
})
return (
<div className="">
<div className="flex flex-col gap-3">
{status === "pending" && <Skeleton paragraph={{ rows: 4 }} active />}
{status === "success" && (
<>
<div>
<label
htmlFor="ollamaURL"
@ -50,6 +75,70 @@ export const SettingsOllama = () => {
className="mt-2"
/>
</div>
<Form
layout="vertical"
onFinish={(data) => {
saveRAG({
model: data.defaultEM,
chunkSize: data.chunkSize,
overlap: data.chunkOverlap
})
}}
initialValues={{
chunkSize: ollamaInfo?.chunkSize,
chunkOverlap: ollamaInfo?.chunkOverlap,
defaultEM: ollamaInfo?.defaultEM
}}>
<Form.Item
name="defaultEM"
label="Embedding Model"
help="Highly recommended to use embedding models like `nomic-embed-text`."
rules={[{ required: true, message: "Please select a model!" }]}>
<Select
size="large"
filterOption={(input, option) =>
option.label.toLowerCase().indexOf(input.toLowerCase()) >=
0 ||
option.value.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
showSearch
placeholder="Select a model"
style={{ width: "100%" }}
className="mt-4"
options={ollamaInfo.models?.map((model) => ({
label: model.name,
value: model.model
}))}
/>
</Form.Item>
<Form.Item
name="chunkSize"
label="Chunk Size"
rules={[
{ required: true, message: "Please input your chunk size!" }
]}>
<InputNumber style={{ width: "100%" }} placeholder="Chunk Size" />
</Form.Item>
<Form.Item
name="chunkOverlap"
label="Chunk Overlap"
rules={[
{ required: true, message: "Please input your chunk overlap!" }
]}>
<InputNumber
style={{ width: "100%" }}
placeholder="Chunk Overlap"
/>
</Form.Item>
<div className="flex justify-end">
<SaveButton disabled={isSaveRAGPending} btnType="submit" />
</div>
</Form>
</>
)}
</div>
)
}

View File

@ -3,8 +3,8 @@ import { useDarkMode } from "~hooks/useDarkmode"
import { useMessageOption } from "~hooks/useMessageOption"
import { PageAssitDatabase } from "~libs/db"
import { Select } from "antd"
import { Sun, Moon } from "lucide-react"
import { SUPPORTED_LANGUAGES } from "~utils/supporetd-languages"
import { MoonIcon, SunIcon } from "lucide-react"
export const SettingOther = () => {
const { clearChat, speechToTextLanguage, setSpeechToTextLanguage } =
@ -45,9 +45,9 @@ export const SettingOther = () => {
onClick={toggleDarkMode}
className={`inline-flex mt-4 items-center rounded-md border border-transparent bg-black px-2 py-2 text-sm font-medium leading-4 text-white shadow-sm dark:bg-white dark:text-gray-800 disabled:opacity-50 `}>
{mode === "dark" ? (
<Sun className="w-4 h-4 mr-2" />
<SunIcon className="w-4 h-4 mr-2" />
) : (
<Moon className="w-4 h-4 mr-2" />
<MoonIcon className="w-4 h-4 mr-2" />
)}
{mode === "dark" ? "Light" : "Dark"}
</button>

View File

@ -1,56 +1,146 @@
import { useQuery } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { Skeleton, Radio, Form } from "antd"
import React from "react"
import { SaveButton } from "~components/Common/SaveButton"
import {
getWebSearchPrompt,
setSystemPromptForNonRagOption,
systemPromptForNonRagOption
systemPromptForNonRagOption,
geWebSearchFollowUpPrompt,
setWebPrompts
} from "~services/ollama"
export const SettingPrompt = () => {
const [ollamaPrompt, setOllamaPrompt] = useState<string>("")
const { data: ollamaInfo } = useQuery({
const [selectedValue, setSelectedValue] = React.useState<"normal" | "web">(
"normal"
)
const queryClient = useQueryClient()
const { status, data } = useQuery({
queryKey: ["fetchOllaPrompt"],
queryFn: async () => {
const prompt = await systemPromptForNonRagOption()
const [prompt, webSearchPrompt, webSearchFollowUpPrompt] =
await Promise.all([
systemPromptForNonRagOption(),
getWebSearchPrompt(),
geWebSearchFollowUpPrompt()
])
return {
prompt
prompt,
webSearchPrompt,
webSearchFollowUpPrompt
}
}
})
useEffect(() => {
if (ollamaInfo?.prompt) {
setOllamaPrompt(ollamaInfo.prompt)
}
}, [ollamaInfo])
return (
<div className="">
<div className="flex flex-col gap-3">
{status === "pending" && <Skeleton paragraph={{ rows: 4 }} active />}
{status === "success" && (
<div>
<label htmlFor="ollamaPrompt" className="text-sm font-medium dark:text-gray-200">
System Prompt
</label>
<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="web">Web</Radio.Button>
</Radio.Group>
</div>
{selectedValue === "normal" && (
<Form
layout="vertical"
onFinish={(values) => {
setSystemPromptForNonRagOption(values?.prompt || "")
queryClient.invalidateQueries({
queryKey: ["fetchOllaPrompt"]
})
}}
initialValues={{
prompt: data.prompt
}}>
<Form.Item label="System Prompt" name="prompt">
<textarea
value={ollamaPrompt}
value={data.prompt}
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>
</Form.Item>
<Form.Item>
<div className="flex justify-end">
<SaveButton
onClick={() => {
setSystemPromptForNonRagOption(ollamaPrompt)
<SaveButton btnType="submit" />
</div>{" "}
</Form.Item>
</Form>
)}
{selectedValue === "web" && (
<Form
layout="vertical"
onFinish={(values) => {
setWebPrompts(
values?.webSearchPrompt || "",
values?.webSearchFollowUpPrompt || ""
)
queryClient.invalidateQueries({
queryKey: ["fetchOllaPrompt"]
})
}}
className="mt-2"
initialValues={{
webSearchPrompt: data.webSearchPrompt,
webSearchFollowUpPrompt: data.webSearchFollowUpPrompt
}}>
<Form.Item
label="Web Search Prompt"
name="webSearchPrompt"
help="Do not remove `{search_results}` from the prompt."
rules={[
{
required: true,
message: "Please input your Web Search Prompt!"
}
]}>
<textarea
value={data.webSearchPrompt}
rows={5}
id="ollamaWebSearchPrompt"
placeholder="Your Web Search Prompt"
className="w-full p-2 border border-gray-300 rounded-md dark:bg-[#262626] dark:text-gray-100"
/>
</Form.Item>
<Form.Item
label="Web Search Follow Up Prompt"
name="webSearchFollowUpPrompt"
help="Do not remove `{chat_history}` and `{question}` from the prompt."
rules={[
{
required: true,
message: "Please input your Web Search Follow Up Prompt!"
}
]}>
<textarea
value={data.webSearchFollowUpPrompt}
rows={5}
id="ollamaWebSearchFollowUpPrompt"
placeholder="Your Web Search Follow Up Prompt"
className="w-full p-2 border border-gray-300 rounded-md dark:bg-[#262626] dark:text-gray-100"
/>
</Form.Item>
<Form.Item>
<div className="flex justify-end">
<SaveButton btnType="submit" />
</div>{" "}
</Form.Item>
</Form>
)}
</div>
)}
</div>
)
}

View File

@ -1,16 +1,23 @@
import { useQuery } from "@tanstack/react-query"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
PageAssitDatabase,
formatToChatHistory,
formatToMessage
formatToMessage,
deleteByHistoryId,
updateHistory
} from "~libs/db"
import { Empty, Skeleton } from "antd"
import { useMessageOption } from "~hooks/useMessageOption"
import { useState } from "react"
import { PencilIcon, Trash2 } from "lucide-react"
type Props = {}
export const Sidebar = ({}: Props) => {
const { setMessages, setHistory, setHistoryId } = useMessageOption()
const { setMessages, setHistory, setHistoryId, historyId, clearChat } =
useMessageOption()
const [processingId, setProcessingId] = useState<string>("")
const client = useQueryClient()
const { data: chatHistories, status } = useQuery({
queryKey: ["fetchChatHistory"],
@ -21,8 +28,35 @@ export const Sidebar = ({}: Props) => {
}
})
const { isPending: isDeleting, 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({
mutationKey: ["editHistory"],
mutationFn: async (data: { id: string; title: string }) => {
return await updateHistory(data.id, data.title)
},
onSuccess: () => {
client.invalidateQueries({
queryKey: ["fetchChatHistory"]
})
setProcessingId("")
}
})
return (
<div className="overflow-y-auto">
<div className="overflow-y-auto z-99">
{status === "success" && chatHistories.length === 0 && (
<div className="flex justify-center items-center mt-20 overflow-hidden">
<Empty description="No history yet" />
@ -41,18 +75,49 @@ export const Sidebar = ({}: Props) => {
{status === "success" && chatHistories.length > 0 && (
<div className="flex flex-col gap-2">
{chatHistories.map((chat, index) => (
<div
key={index}
className="flex py-2 px-2 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">
<button
className="flex-1 overflow-hidden break-all text-start truncate w-full"
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 className="flex flex-row gap-3">
<button
onClick={() => {
const newTitle = prompt("Enter new title", chat.title)
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" />
</button>
<button
onClick={() => {
if (
!confirm("Are you sure you want to delete this history?")
)
return
deleteHistory(chat.id)
setProcessingId(chat.id)
}}
className="text-red-500 dark:text-red-400 opacity-80">
<Trash2 className=" w-4 h-4 " />
</button>
</div>
</div>
))}
</div>
)}

View File

@ -4,7 +4,7 @@ import { useMessage } from "~hooks/useMessage"
import { EmptySidePanel } from "../Chat/empty"
export const SidePanelBody = () => {
const { messages } = useMessage()
const { messages, streaming } = useMessage()
const divRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (divRef.current) {
@ -21,6 +21,10 @@ export const SidePanelBody = () => {
message={message.message}
name={message.name}
images={message.images || []}
currentMessageIndex={index}
totalMessages={messages.length}
onRengerate={() => {}}
isProcessing={streaming}
/>
))}
<div className="w-full h-32 md:h-48 flex-shrink-0"></div>

View File

@ -1,9 +1,10 @@
import { useQuery } from "@tanstack/react-query"
import { Select } from "antd"
import { RotateCcw } from "lucide-react"
import { useEffect, useState } from "react"
import { useMessage } from "~hooks/useMessage"
import {
fetchModels,
fetchChatModels,
getOllamaURL,
isOllamaRunning,
setOllamaURL as saveOllamaURL
@ -21,7 +22,7 @@ export const EmptySidePanel = () => {
queryFn: async () => {
const ollamaURL = await getOllamaURL()
const isOk = await isOllamaRunning()
const models = await fetchModels()
const models = await fetchChatModels()
return {
isOk,
@ -92,24 +93,25 @@ export const EmptySidePanel = () => {
<div className="mt-4">
<p className="dark:text-gray-400 text-gray-900">Models:</p>
<select
<Select
onChange={(e) => {
if (e.target.value === "") {
return
}
setSelectedModel(e.target.value)
setSelectedModel(e)
}}
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>
size="large"
filterOption={(input, option) =>
option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0 ||
option.value.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
showSearch
placeholder="Select a model"
style={{ width: "100%" }}
className="mt-4"
options={ollamaInfo.models?.map((model) => ({
label: model.name,
value: model.model
}))}
/>
<div className="mt-4">
<div className="inline-flex items-center">

View File

@ -3,12 +3,12 @@ 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"
import { MicIcon } from "lucide-react"
import { Image, Tooltip } from "antd"
import { Checkbox, Dropdown, Image, Tooltip } from "antd"
import { useSpeechRecognition } from "~hooks/useSpeechRecognition"
import { useWebUI } from "~store/webui"
import { defaultEmbeddingModelForRag } from "~services/ollama"
import { ImageIcon, MicIcon, X } from "lucide-react"
type Props = {
dropedFile: File | undefined
@ -17,6 +17,7 @@ type Props = {
export const SidepanelForm = ({ dropedFile }: Props) => {
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
const { sendWhenEnter, setSendWhenEnter } = useWebUI()
const resetHeight = () => {
const textarea = textareaRef.current
@ -85,19 +86,10 @@ export const SidepanelForm = ({ dropedFile }: Props) => {
form.setFieldValue("image", "")
}}
className="flex items-center justify-center absolute top-0 m-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" />
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* <div className="flex gap-3 justify-end">
<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>
</div> */}
<div>
<div className="flex">
<form
@ -106,6 +98,16 @@ export const SidepanelForm = ({ dropedFile }: Props) => {
form.setFieldError("message", "Please select a model")
return
}
if (chatMode === "rag") {
const defaultEM = await defaultEmbeddingModelForRag()
if (!defaultEM) {
form.setFieldError(
"message",
"Please set an embedding model on the settings page"
)
return
}
}
form.reset()
resetHeight()
await sendMessage({
@ -127,7 +129,12 @@ 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) => {
if (e.key === "Enter" && !e.shiftKey && !isSending) {
if (
e.key === "Enter" &&
!e.shiftKey &&
!isSending &&
sendWhenEnter
) {
e.preventDefault()
form.onSubmit(async (value) => {
if (value.message.trim().length === 0) {
@ -137,6 +144,16 @@ export const SidepanelForm = ({ dropedFile }: Props) => {
form.setFieldError("message", "Please select a model")
return
}
if (chatMode === "rag") {
const defaultEM = await defaultEmbeddingModelForRag()
if (!defaultEM) {
form.setFieldError(
"message",
"Please set an embedding model on the settings page"
)
return
}
}
form.reset()
resetHeight()
await sendMessage({
@ -147,7 +164,7 @@ export const SidepanelForm = ({ dropedFile }: Props) => {
}
}}
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"
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" }}
@ -189,12 +206,48 @@ export const SidepanelForm = ({ dropedFile }: Props) => {
className={`flex items-center justify-center dark:text-gray-300 ${
chatMode === "rag" ? "hidden" : "block"
}`}>
<PhotoIcon className="h-5 w-5" />
<ImageIcon className="h-5 w-5" />
</button>
</Tooltip>
<button
disabled={isSending || form.values.message.length === 0}
className="inline-flex items-center rounded-md border border-transparent bg-black px-2 py-2 text-sm font-medium leading-4 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100 dark:focus:ring-gray-500 dark:focus:ring-offset-gray-100 disabled:opacity-50 ">
<Dropdown.Button
htmlType="submit"
disabled={
isSending
}
className="!justify-end !w-auto"
icon={
<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">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m19.5 8.25-7.5 7.5-7.5-7.5"
/>
</svg>
}
menu={{
items: [
{
key: 1,
label: (
<Checkbox
checked={sendWhenEnter}
onChange={(e) =>
setSendWhenEnter(e.target.checked)
}>
Send when Enter pressed
</Checkbox>
)
}
]
}}>
<div className="inline-flex gap-2">
{sendWhenEnter ? (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
@ -202,13 +255,15 @@ export const SidepanelForm = ({ dropedFile }: Props) => {
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-4 w-4 mr-2"
className="h-5 w-5"
viewBox="0 0 24 24">
<path d="M9 10L4 15 9 20"></path>
<path d="M20 4v7a4 4 0 01-4 4H4"></path>
</svg>
Send
</button>
) : null}
Submit
</div>
</Dropdown.Button>
</div>
</div>
</form>

View File

@ -1,10 +1,8 @@
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"
import { BoxesIcon, CogIcon, RefreshCcw } from "lucide-react"
export const SidepanelHeader = () => {
const { clearChat, isEmbedding } = useMessage()
return (
@ -16,11 +14,8 @@ export const SidepanelHeader = () => {
<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 title="It may take a few minutes to embed the page. Please wait...">
<BoxesIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 animate-bounce animate-infinite" />
</Tooltip>
) : null}
<button
@ -28,7 +23,7 @@ export const SidepanelHeader = () => {
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" />
<RefreshCcw 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" />

View File

@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query"
import { useMutation, useQuery } from "@tanstack/react-query"
import React from "react"
import {
getOllamaURL,
@ -6,15 +6,20 @@ import {
promptForRag,
setOllamaURL as saveOllamaURL,
setPromptForRag,
setSystemPromptForNonRag
setSystemPromptForNonRag,
getAllModels,
defaultEmbeddingChunkOverlap,
defaultEmbeddingChunkSize,
defaultEmbeddingModelForRag,
saveForRag
} from "~services/ollama"
import { Skeleton, Radio, Select } from "antd"
import { Skeleton, Radio, Select, Form, InputNumber } from "antd"
import { useDarkMode } from "~hooks/useDarkmode"
import { SaveButton } from "~components/Common/SaveButton"
import { Moon, Sun } from "lucide-react"
import { SUPPORTED_LANGUAGES } from "~utils/supporetd-languages"
import { useMessage } from "~hooks/useMessage"
import { MoonIcon, SunIcon } from "lucide-react"
export const SettingsBody = () => {
const [ollamaURL, setOllamaURL] = React.useState<string>("")
@ -31,21 +36,47 @@ export const SettingsBody = () => {
const { data, status } = useQuery({
queryKey: ["sidebarSettings"],
queryFn: async () => {
const [ollamaURL, systemPrompt, ragPrompt] = await Promise.all([
const [
ollamaURL,
systemPrompt,
ragPrompt,
allModels,
chunkOverlap,
chunkSize,
defaultEM
] = await Promise.all([
getOllamaURL(),
systemPromptForNonRag(),
promptForRag()
promptForRag(),
getAllModels(),
defaultEmbeddingChunkOverlap(),
defaultEmbeddingChunkSize(),
defaultEmbeddingModelForRag()
])
return {
url: ollamaURL,
normalSystemPrompt: systemPrompt,
ragSystemPrompt: ragPrompt.ragPrompt,
ragQuestionPrompt: ragPrompt.ragQuestionPrompt
ragQuestionPrompt: ragPrompt.ragQuestionPrompt,
models: allModels,
chunkOverlap,
chunkSize,
defaultEM
}
}
})
const { mutate: saveRAG, isPending: isSaveRAGPending } = useMutation({
mutationFn: async (data: {
model: string
chunkSize: number
overlap: number
}) => {
await saveForRag(data.model, data.chunkSize, data.overlap)
}
})
React.useEffect(() => {
if (data) {
setOllamaURL(data.url)
@ -156,6 +187,71 @@ export const SettingsBody = () => {
/>
</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">
RAG Configuration
</h2>
<Form
onFinish={(data) => {
saveRAG({
model: data.defaultEM,
chunkSize: data.chunkSize,
overlap: data.chunkOverlap
})
}}
initialValues={{
chunkSize: data.chunkSize,
chunkOverlap: data.chunkOverlap,
defaultEM: data.defaultEM
}}>
<Form.Item
name="defaultEM"
label="Embedding Model"
help="Highly recommended to use embedding models like `nomic-embed-text`."
rules={[{ required: true, message: "Please select a model!" }]}>
<Select
size="large"
filterOption={(input, option) =>
option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0 ||
option.value.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
showSearch
placeholder="Select a model"
style={{ width: "100%" }}
className="mt-4"
options={data.models?.map((model) => ({
label: model.name,
value: model.model
}))}
/>
</Form.Item>
<Form.Item
name="chunkSize"
label="Chunk Size"
rules={[
{ required: true, message: "Please input your chunk size!" }
]}>
<InputNumber style={{ width: "100%" }} placeholder="Chunk Size" />
</Form.Item>
<Form.Item
name="chunkOverlap"
label="Chunk Overlap"
rules={[
{ required: true, message: "Please input your chunk overlap!" }
]}>
<InputNumber
style={{ width: "100%" }}
placeholder="Chunk Overlap"
/>
</Form.Item>
<div className="flex justify-end">
<SaveButton disabled={isSaveRAGPending} btnType="submit" />
</div>
</Form>
</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">
Speech Recognition Language
@ -184,14 +280,14 @@ export const SettingsBody = () => {
<button
onClick={toggleDarkMode}
className="select-none inline-flex 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">
<Sun className="h-4 w-4 mr-2" />
<SunIcon className="h-4 w-4 mr-2" />
Light
</button>
) : (
<button
onClick={toggleDarkMode}
className="select-none inline-flex 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">
<Moon className="h-4 w-4 mr-2" />
<MoonIcon className="h-4 w-4 mr-2" />
Dark
</button>
)}

View File

@ -1,11 +1,11 @@
import logoImage from "data-base64:~assets/icon.png"
import { ChevronLeftIcon } from "@heroicons/react/24/outline"
import { ChevronLeft } from "lucide-react"
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" />
<ChevronLeft 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" />

View File

@ -1,12 +1,11 @@
@font-face {
font-family: 'font';
src: url('font.ttf') format('truetype');
font-family: "font";
src: url("font.ttf") format("truetype");
}
* {
font-family: 'font' !important;
font-family: "font" !important;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
@ -15,3 +14,44 @@
border: none !important;
box-shadow: none !important;
}
.gradient-border {
--borderWidth: 3px;
position: relative;
border-radius: var(--borderWidth);
}
.gradient-border:after {
content: "";
position: absolute;
top: calc(-1 * var(--borderWidth));
left: calc(-1 * var(--borderWidth));
height: calc(100% + var(--borderWidth) * 2);
width: calc(100% + var(--borderWidth) * 2);
background: linear-gradient(
60deg,
#f79533,
#f37055,
#ef4e7b,
#a166ab,
#5073b8,
#1098ad,
#07b39b,
#6fba82
);
border-radius: calc(2 * var(--borderWidth));
z-index: -1;
animation: animatedgradient 3s ease alternate infinite;
background-size: 300% 300%;
}
@keyframes animatedgradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}

View File

@ -1,6 +1,9 @@
import React from "react"
import { cleanUrl } from "~libs/clean-url"
import {
defaultEmbeddingChunkOverlap,
defaultEmbeddingChunkSize,
defaultEmbeddingModelForRag,
getOllamaURL,
promptForRag,
systemPromptForNonRag
@ -131,9 +134,11 @@ export const useMessage = () => {
url
})
const docs = await loader.load()
const chunkSize = await defaultEmbeddingChunkSize();
const chunkOverlap = await defaultEmbeddingChunkOverlap();
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 200
chunkSize,
chunkOverlap,
})
const chunks = await textSplitter.splitDocuments(docs)
@ -174,11 +179,13 @@ export const useMessage = () => {
const appendingIndex = newMessage.length - 1
setMessages(newMessage)
const embeddingModle = await defaultEmbeddingModelForRag()
const ollamaEmbedding = new OllamaEmbeddings({
model: selectedModel,
model: embeddingModle || selectedModel,
baseUrl: cleanUrl(ollamaUrl)
})
const ollamaChat = new ChatOllama({
model: selectedModel,
baseUrl: cleanUrl(ollamaUrl)

View File

@ -1,6 +1,10 @@
import React from "react"
import { cleanUrl } from "~libs/clean-url"
import { getOllamaURL, systemPromptForNonRagOption } from "~services/ollama"
import {
geWebSearchFollowUpPrompt,
getOllamaURL,
systemPromptForNonRagOption
} from "~services/ollama"
import { type ChatHistory, type Message } from "~store/option"
import { ChatOllama } from "@langchain/community/chat_models/ollama"
import {
@ -10,9 +14,10 @@ import {
SystemMessage
} from "@langchain/core/messages"
import { useStoreMessageOption } from "~store/option"
import { saveHistory, saveMessage } from "~libs/db"
import { removeMessageUsingHistoryId, saveHistory, saveMessage } from "~libs/db"
import { useNavigate } from "react-router-dom"
import { notification } from "antd"
import { getSystemPromptForWeb } from "~web/web"
export type BotResponse = {
bot: {
@ -93,7 +98,11 @@ export const useMessageOption = () => {
chatMode,
setChatMode,
speechToTextLanguage,
setSpeechToTextLanguage
setSpeechToTextLanguage,
webSearch,
setWebSearch,
isSearchingInternet,
setIsSearchingInternet
} = useStoreMessageOption()
const navigate = useNavigate()
@ -101,7 +110,6 @@ export const useMessageOption = () => {
const abortControllerRef = React.useRef<AbortController | null>(null)
const clearChat = () => {
// stopStreamingRequest()
setMessages([])
setHistory([])
setHistoryId(null)
@ -112,7 +120,11 @@ export const useMessageOption = () => {
navigate("/")
}
const normalChatMode = async (message: string, image: string) => {
const searchChatMode = async (
message: string,
image: string,
isRegenerate: boolean
) => {
const url = await getOllamaURL()
if (image.length > 0) {
@ -143,7 +155,254 @@ export const useMessageOption = () => {
]
const appendingIndex = newMessage.length - 1
if (!isRegenerate) {
setMessages(newMessage)
}
try {
setIsSearchingInternet(true)
let query = message
if (newMessage.length > 2) {
let questionPrompt = await geWebSearchFollowUpPrompt()
const lastTenMessages = newMessage.slice(-10)
lastTenMessages.pop()
const chat_history = lastTenMessages
.map((message) => {
return `${message.isBot ? "Assistant: " : "Human: "}${message.message}`
})
.join("\n")
const promptForQuestion = questionPrompt
.replaceAll("{chat_history}", chat_history)
.replaceAll("{question}", message)
const questionOllama = new ChatOllama({
model: selectedModel,
baseUrl: cleanUrl(url)
})
const response = await questionOllama.invoke(promptForQuestion)
query = response.content.toString()
}
const { prompt, source } = await getSystemPromptForWeb(query)
setIsSearchingInternet(false)
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)
newMessage[appendingIndex].sources = source
if (!isRegenerate) {
setHistory([
...history,
{
role: "user",
content: message,
image
},
{
role: "assistant",
content: newMessage[appendingIndex].message
}
])
} else {
setHistory([
...history,
{
role: "assistant",
content: newMessage[appendingIndex].message
}
])
}
if (historyId) {
if (!isRegenerate) {
await saveMessage(historyId, selectedModel, "user", message, [image])
}
await saveMessage(
historyId,
selectedModel,
"assistant",
newMessage[appendingIndex].message,
[],
source
)
} else {
const newHistoryId = await saveHistory(message)
await saveMessage(newHistoryId.id, selectedModel, "user", message, [
image
])
await saveMessage(
newHistoryId.id,
selectedModel,
"assistant",
newMessage[appendingIndex].message,
[],
source
)
setHistoryId(newHistoryId.id)
}
setIsProcessing(false)
setStreaming(false)
} catch (e) {
console.log(e)
if (e?.name === "AbortError") {
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)
}
} else {
notification.error({
message: "Error",
description: e?.message || "Something went wrong"
})
}
setIsProcessing(false)
setStreaming(false)
}
}
const normalChatMode = async (
message: string,
image: string,
isRegenerate: boolean
) => {
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
if (!isRegenerate) {
setMessages(newMessage)
}
try {
const prompt = await systemPromptForNonRagOption()
@ -215,6 +474,7 @@ export const useMessageOption = () => {
appendingIndex
].message.slice(0, -1)
if (!isRegenerate) {
setHistory([
...history,
{
@ -227,9 +487,20 @@ export const useMessageOption = () => {
content: newMessage[appendingIndex].message
}
])
} else {
setHistory([
...history,
{
role: "assistant",
content: newMessage[appendingIndex].message
}
])
}
if (historyId) {
if (!isRegenerate) {
await saveMessage(historyId, selectedModel, "user", message, [image])
}
await saveMessage(
historyId,
selectedModel,
@ -253,6 +524,7 @@ export const useMessageOption = () => {
}
setIsProcessing(false)
setStreaming(false)
} catch (e) {
console.log(e)
@ -311,12 +583,35 @@ export const useMessageOption = () => {
const onSubmit = async ({
message,
image
image,
isRegenerate = false
}: {
message: string
image: string
isRegenerate?: boolean
}) => {
await normalChatMode(message, image)
setStreaming(true)
if (webSearch) {
await searchChatMode(message, image, isRegenerate)
} else {
await normalChatMode(message, image, isRegenerate)
}
}
const regenerateLastMessage = async () => {
if (history.length > 0) {
const lastMessage = history[history.length - 2]
setHistory(history.slice(0, -1))
setMessages(messages.slice(0, -1))
await removeMessageUsingHistoryId(historyId)
if (lastMessage.role === "user") {
await onSubmit({
message: lastMessage.content,
image: lastMessage.image || "",
isRegenerate: true
})
}
}
}
const stopStreamingRequest = () => {
@ -346,6 +641,10 @@ export const useMessageOption = () => {
chatMode,
setChatMode,
speechToTextLanguage,
setSpeechToTextLanguage
setSpeechToTextLanguage,
regenerateLastMessage,
webSearch,
setWebSearch,
isSearchingInternet
}
}

View File

@ -9,6 +9,16 @@ type HistoryInfo = {
createdAt: number
}
type WebSearch = {
search_engine: string
search_url: string
search_query: string
search_results: {
title: string
link: string
}[]
}
type Message = {
id: string
history_id: string
@ -17,6 +27,7 @@ type Message = {
content: string
images?: string[]
sources?: string[]
search?: WebSearch
createdAt: number
}
@ -87,6 +98,10 @@ export class PageAssitDatabase {
}
this.db.remove("chatHistories")
}
async deleteMessage(history_id: string) {
await this.db.remove(history_id)
}
}
const generateID = () => {
@ -110,11 +125,12 @@ export const saveMessage = async (
name: string,
role: string,
content: string,
images: string[]
images: string[],
source?: any[]
) => {
const id = generateID()
const createdAt = Date.now()
const message = { id, history_id, name, role, content, images, createdAt }
const message = { id, history_id, name, role, content, images, createdAt, sources: source }
const db = new PageAssitDatabase()
await db.addMessage(message)
return message
@ -145,3 +161,30 @@ export const formatToMessage = (messages: MessageHistory): MessageType[] => {
}
})
}
export const deleteByHistoryId = async (history_id: string) => {
const db = new PageAssitDatabase()
await db.deleteMessage(history_id)
await db.removeChatHistory(history_id)
return history_id
}
export const updateHistory = async (id: string, title: string) => {
const db = new PageAssitDatabase()
const chatHistories = await db.getChatHistories()
const newChatHistories = chatHistories.map((history) => {
if (history.id === id) {
history.title = title
}
return history
})
db.db.set({ chatHistories: newChatHistories })
}
export const removeMessageUsingHistoryId = async (history_id: string) => {
// remove the last message
const db = new PageAssitDatabase()
const chatHistory = await db.getChatHistory(history_id)
const newChatHistory = chatHistory.slice(0, -1)
await db.db.set({ [history_id]: newChatHistory })
}

View File

@ -8,11 +8,11 @@ const _getHtml = () => {
)
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

View File

@ -1,6 +1,7 @@
import { BaseDocumentLoader } from "langchain/document_loaders/base"
import { Document } from "@langchain/core/documents"
import { compile } from "html-to-text"
import { chromeRunTime } from "~libs/runtime"
export interface WebLoaderParams {
html: string
@ -9,8 +10,7 @@ export interface WebLoaderParams {
export class PageAssistHtmlLoader
extends BaseDocumentLoader
implements WebLoaderParams
{
implements WebLoaderParams {
html: string
url: string
@ -28,4 +28,20 @@ export class PageAssistHtmlLoader
const metadata = { source: this.url }
return [new Document({ pageContent: text, metadata })]
}
async loadByURL(): Promise<Document<Record<string, any>>[]> {
await chromeRunTime(this.url)
const fetchHTML = await fetch(this.url)
const html = await fetchHTML.text()
const htmlCompiler = compile({
wordwrap: false,
selectors: [
{ selector: "img", format: "skip" },
{ selector: "script", format: "skip" }
]
})
const text = htmlCompiler(html)
const metadata = { url: this.url }
return [new Document({ pageContent: text, metadata })]
}
}

View File

@ -12,6 +12,13 @@ const DEFAULT_RAG_QUESTION_PROMPT =
const DEFAUTL_RAG_SYSTEM_PROMPT = `You are a helpful AI assistant. Use the following pieces of context to answer the question at the end. If you don't know the answer, just say you don't know. DO NOT try to make up an answer. If the question is not related to the context, politely respond that you are tuned to only answer questions that are related to the context. {context} Question: {question} Helpful answer in markdown:`
const DEFAULT_WEBSEARCH_PROMP = `You are a helpful assistant that can answer any questions. You can use the following search results in case you want to answer questions about anything in real-time. The current date and time are {current_date_time}.
Search results:
{search_results}`
export const getOllamaURL = async () => {
const ollamaURL = await storage.get("ollamaURL")
if (!ollamaURL || ollamaURL.length === 0) {
@ -78,7 +85,7 @@ export const getAllModels = async () => {
}[]
}
export const deleteModel= async (model: string) => {
export const deleteModel = async (model: string) => {
const baseUrl = await getOllamaURL()
const response = await fetch(`${cleanUrl(baseUrl)}/api/delete`, {
method: "DELETE",
@ -94,7 +101,7 @@ export const deleteModel= async (model: string) => {
return response.json()
}
export const fetchModels = async () => {
export const fetchChatModels = async () => {
try {
const baseUrl = await getOllamaURL()
const response = await fetch(`${cleanUrl(baseUrl)}/api/tags`)
@ -102,8 +109,7 @@ export const fetchModels = async () => {
throw new Error(response.statusText)
}
const json = await response.json()
return json.models as {
const models = json.models as {
name: string
model: string
modified_at: string
@ -118,6 +124,12 @@ export const fetchModels = async () => {
quantization_level: string
}
}[]
return models.filter((model) => {
return (
!model.details.families.includes("bert") &&
!model.details.families.includes("nomic-bert")
)
})
} catch (e) {
console.error(e)
return []
@ -182,3 +194,95 @@ export const systemPromptForNonRagOption = async () => {
export const setSystemPromptForNonRagOption = async (prompt: string) => {
await storage.set("systemPromptForNonRagOption", prompt)
}
export const sendWhenEnter = async () => {
const sendWhenEnter = await storage.get("sendWhenEnter")
if (!sendWhenEnter || sendWhenEnter.length === 0) {
return true
}
return sendWhenEnter === "true"
}
export const setSendWhenEnter = async (sendWhenEnter: boolean) => {
await storage.set("sendWhenEnter", sendWhenEnter.toString())
}
export const defaultEmbeddingModelForRag = async () => {
const embeddingMode = await storage.get("defaultEmbeddingModel")
if (!embeddingMode || embeddingMode.length === 0) {
return null
}
return embeddingMode
}
export const defaultEmbeddingChunkSize = async () => {
const embeddingChunkSize = await storage.get("defaultEmbeddingChunkSize")
if (!embeddingChunkSize || embeddingChunkSize.length === 0) {
return 1000
}
return parseInt(embeddingChunkSize)
}
export const defaultEmbeddingChunkOverlap = async () => {
const embeddingChunkOverlap = await storage.get(
"defaultEmbeddingChunkOverlap"
)
if (!embeddingChunkOverlap || embeddingChunkOverlap.length === 0) {
return 200
}
return parseInt(embeddingChunkOverlap)
}
export const setDefaultEmbeddingModelForRag = async (model: string) => {
await storage.set("defaultEmbeddingModel", model)
}
export const setDefaultEmbeddingChunkSize = async (size: number) => {
await storage.set("defaultEmbeddingChunkSize", size.toString())
}
export const setDefaultEmbeddingChunkOverlap = async (overlap: number) => {
await storage.set("defaultEmbeddingChunkOverlap", overlap.toString())
}
export const saveForRag = async (
model: string,
chunkSize: number,
overlap: number
) => {
await setDefaultEmbeddingModelForRag(model)
await setDefaultEmbeddingChunkSize(chunkSize)
await setDefaultEmbeddingChunkOverlap(overlap)
}
export const getWebSearchPrompt = async () => {
const prompt = await storage.get("webSearchPrompt")
if (!prompt || prompt.length === 0) {
return DEFAULT_WEBSEARCH_PROMP
}
return prompt
}
export const setWebSearchPrompt = async (prompt: string) => {
await storage.set("webSearchPrompt", prompt)
}
export const geWebSearchFollowUpPrompt = async () => {
const prompt = await storage.get("webSearchFollowUpPrompt")
if (!prompt || prompt.length === 0) {
return DEFAULT_RAG_QUESTION_PROMPT;
}
return prompt
}
export const setWebSearchFollowUpPrompt = async (prompt: string) => {
await storage.set("webSearchFollowUpPrompt", prompt)
}
export const setWebPrompts = async (prompt: string, followUpPrompt: string) => {
await setWebSearchPrompt(prompt)
await setWebSearchFollowUpPrompt(followUpPrompt)
}

View File

@ -44,7 +44,7 @@ export const useStoreMessage = create<State>((set) => ({
setMessages: (messages) => set({ messages }),
history: [],
setHistory: (history) => set({ history }),
streaming: true,
streaming: false,
setStreaming: (streaming) => set({ streaming }),
isFirstMessage: true,
setIsFirstMessage: (isFirstMessage) => set({ isFirstMessage }),

View File

@ -1,11 +1,21 @@
import { create } from "zustand"
type WebSearch = {
search_engine: string
search_url: string
search_query: string
search_results: {
title: string
link: string
}[]
}
export type Message = {
isBot: boolean
name: string
message: string
sources: any[]
images?: string[]
search?: WebSearch
}
export type ChatHistory = {
@ -37,6 +47,10 @@ type State = {
setIsEmbedding: (isEmbedding: boolean) => void
speechToTextLanguage: string
setSpeechToTextLanguage: (language: string) => void
webSearch: boolean;
setWebSearch: (webSearch: boolean) => void;
isSearchingInternet: boolean;
setIsSearchingInternet: (isSearchingInternet: boolean) => void;
}
export const useStoreMessageOption = create<State>((set) => ({
@ -44,7 +58,7 @@ export const useStoreMessageOption = create<State>((set) => ({
setMessages: (messages) => set({ messages }),
history: [],
setHistory: (history) => set({ history }),
streaming: true,
streaming: false,
setStreaming: (streaming) => set({ streaming }),
isFirstMessage: true,
setIsFirstMessage: (isFirstMessage) => set({ isFirstMessage }),
@ -62,5 +76,9 @@ export const useStoreMessageOption = create<State>((set) => ({
chatMode: "normal",
setChatMode: (chatMode) => set({ chatMode }),
isEmbedding: false,
setIsEmbedding: (isEmbedding) => set({ isEmbedding })
setIsEmbedding: (isEmbedding) => set({ isEmbedding }),
webSearch: false,
setWebSearch: (webSearch) => set({ webSearch }),
isSearchingInternet: false,
setIsSearchingInternet: (isSearchingInternet) => set({ isSearchingInternet }),
}))

11
src/store/webui.tsx Normal file
View File

@ -0,0 +1,11 @@
import { create } from "zustand"
type State = {
sendWhenEnter: boolean
setSendWhenEnter: (sendWhenEnter: boolean) => void
}
export const useWebUI = create<State>((set) => ({
sendWhenEnter: true,
setSendWhenEnter: (sendWhenEnter) => set({ sendWhenEnter })
}))

106
src/web/local-google.ts Normal file
View File

@ -0,0 +1,106 @@
import { OllamaEmbeddings } from "@langchain/community/embeddings/ollama"
import type { Document } from "@langchain/core/documents"
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter"
import { MemoryVectorStore } from "langchain/vectorstores/memory"
import { cleanUrl } from "~libs/clean-url"
import { chromeRunTime } from "~libs/runtime"
import { PageAssistHtmlLoader } from "~loader/html"
import { defaultEmbeddingChunkOverlap, defaultEmbeddingChunkSize, defaultEmbeddingModelForRag, getOllamaURL } from "~services/ollama"
const BLOCKED_HOSTS = [
"google.com",
"youtube.com",
"twitter.com",
"linkedin.com",
]
const TOTAL_SEARCH_RESULTS = 2
export const localGoogleSearch = async (query: string) => {
await chromeRunTime(
cleanUrl("https://www.google.com/search?hl=en&q=" + query)
)
const abortController = new AbortController()
setTimeout(() => abortController.abort(), 10000)
const htmlString = await fetch(
"https://www.google.com/search?hl=en&q=" + query,
{
signal: abortController.signal
}
)
.then((response) => response.text())
.catch()
const parser = new DOMParser()
const doc = parser.parseFromString(htmlString, "text/html")
const searchResults = Array.from(doc.querySelectorAll("div.g")).map(
(result) => {
const title = result.querySelector("h3")?.textContent
const link = result.querySelector("a")?.getAttribute("href")
return { title, link }
}
)
const filteredSearchResults = searchResults
.filter(
(result) =>
!result.link ||
!BLOCKED_HOSTS.some((host) => result.link.includes(host))
)
.filter((result) => result.title && result.link)
return filteredSearchResults
}
export const webSearch = async (query: string) => {
const results = await localGoogleSearch(query)
const searchResults = results.slice(0, TOTAL_SEARCH_RESULTS)
const docs: Document<Record<string, any>>[] = [];
for (const result of searchResults) {
const loader = new PageAssistHtmlLoader({
html: "",
url: result.link
})
const documents = await loader.loadByURL()
documents.forEach((doc) => {
docs.push(doc)
})
}
const ollamaUrl = await getOllamaURL()
const embeddingModle = await defaultEmbeddingModelForRag()
const ollamaEmbedding = new OllamaEmbeddings({
model: embeddingModle || "",
baseUrl: cleanUrl(ollamaUrl),
})
const chunkSize = await defaultEmbeddingChunkSize();
const chunkOverlap = await defaultEmbeddingChunkOverlap();
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize,
chunkOverlap,
})
const chunks = await textSplitter.splitDocuments(docs)
const store = new MemoryVectorStore(ollamaEmbedding)
await store.addDocuments(chunks)
const resultsWithEmbeddings = await store.similaritySearch(query, 3)
const searchResult = resultsWithEmbeddings.map((result) => {
return {
url: result.metadata.url,
content: result.pageContent
}
})
return searchResult
}

33
src/web/web.ts Normal file
View File

@ -0,0 +1,33 @@
import { getWebSearchPrompt } from "~services/ollama"
import { webSearch } from "./local-google"
export const getSystemPromptForWeb = async (query: string) => {
try {
const search = await webSearch(query)
const search_results = search.map((result, idx) => `<result source="${result.url}" id="${idx}">${result.content}</result>`).join("\n")
const current_date_time = new Date().toLocaleString()
const system = await getWebSearchPrompt();
const prompt = system.replace("{current_date_time}", current_date_time).replace("{search_results}", search_results)
return {
prompt,
source: search.map((result) => {
return {
url: result.url,
name: new URL(result.url).hostname,
type: "url",
}
})
}
} catch (e) {
console.error(e)
return {
prompt: "",
source: [],
}
}
}

View File

@ -456,6 +456,14 @@
dependencies:
cross-spawn "^7.0.3"
"@headlessui/react@^1.7.18":
version "1.7.18"
resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.7.18.tgz#30af4634d2215b2ca1aa29d07f33d02bea82d9d7"
integrity sha512-4i5DOrzwN4qSgNsL4Si61VMkUcWbcSKueUV7sFhpHzQcSShdlHENE5+QBntMSRvHt8NyoFO2AGG8si9lq+w4zQ==
dependencies:
"@tanstack/react-virtual" "^3.0.0-beta.60"
client-only "^0.0.1"
"@heroicons/react@^2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-2.1.1.tgz#422deb80c4d6caf3371aec6f4bee8361a354dc13"
@ -673,6 +681,11 @@
fast-deep-equal "^3.1.3"
klona "^2.0.6"
"@mantine/hooks@^7.5.3":
version "7.5.3"
resolved "https://registry.yarnpkg.com/@mantine/hooks/-/hooks-7.5.3.tgz#34168712075ee40ff7353c840420d4568b0dd54e"
integrity sha512-mFI448mAs12v8FrgSVhytqlhTVrEjIfd/PqPEfwJu5YcZIq4YZdqpzJIUbANnRrFSvmoQpDb1PssdKx7Ds35hw==
"@mischnic/json-sourcemap@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@mischnic/json-sourcemap/-/json-sourcemap-0.1.0.tgz#38af657be4108140a548638267d02a2ea3336507"
@ -2306,6 +2319,18 @@
dependencies:
"@tanstack/query-core" "5.18.0"
"@tanstack/react-virtual@^3.0.0-beta.60":
version "3.1.2"
resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.1.2.tgz#eb62b73cc82e34860604cd3d682a17db590f3c45"
integrity sha512-qibmxtctgOZo2I+3Rw5GR9kXgaa15U5r3/idDY1ItUKW15UK7GhCfyIfE6qYuJ1fxQF6dJDsD8SbpPyuJgpxuA==
dependencies:
"@tanstack/virtual-core" "3.1.2"
"@tanstack/virtual-core@3.1.2":
version "3.1.2"
resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.1.2.tgz#ca76f28f826fbd3310f88c3cd355d9c4aba80abb"
integrity sha512-DATZJs8iejkIUqXZe6ruDAnjFo78BKnIIgqQZrc7CmEFqfLEN/TPD91n4hRfo6hpRB6xC00bwKxv7vdjFNEmOg==
"@tootallnate/once@2":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
@ -2431,6 +2456,11 @@
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239"
integrity sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==
"@types/pdf-parse@^1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@types/pdf-parse/-/pdf-parse-1.1.4.tgz#21a539efd2f16009d08aeed3350133948b5d7ed1"
integrity sha512-+gbBHbNCVGGYw1S9lAIIvrHW47UYOhMIFUsJcMkMrzy1Jf0vulBN3XQIjPgnoOXveMuHnF3b57fXROnY/Or7eg==
"@types/prop-types@*":
version "15.7.11"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.11.tgz#2596fb352ee96a1379c657734d4b913a613ad563"
@ -3078,6 +3108,11 @@ cli-width@^4.1.0:
resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-4.1.0.tgz#42daac41d3c254ef38ad8ac037672130173691c5"
integrity sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==
client-only@^0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
clone@^1.0.2:
version "1.0.4"
resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
@ -4937,10 +4972,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.323.0:
version "0.323.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.323.0.tgz#d1dfae7b212a29bbc513b9d7fd0ce5e8f93e6b13"
integrity sha512-rTXZFILl2Y4d1SG9p1Mdcf17AcPvPvpc/egFIzUrp7IUy60MUQo3Oi1mu8LGYXUVwuRZYsSMt3csHRW5mAovJg==
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==
magic-string@^0.30.0:
version "0.30.6"