commit
c8a2387442
@ -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 ❤️
|
@ -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
5993
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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={{
|
||||
|
@ -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>
|
||||
|
12
src/components/Common/Playground/WebSearch.tsx
Normal file
12
src/components/Common/Playground/WebSearch.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
|
@ -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
|
||||
})
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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 && (
|
||||
|
@ -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 && (
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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" />
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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" />
|
||||
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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 })
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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 })]
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
@ -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 }),
|
||||
|
@ -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
11
src/store/webui.tsx
Normal 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
106
src/web/local-google.ts
Normal 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
33
src/web/web.ts
Normal 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: [],
|
||||
}
|
||||
}
|
||||
}
|
43
yarn.lock
43
yarn.lock
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user