Add ShareBtn component and update SettingsOptionLayout

This commit is contained in:
n4ze3m 2024-03-09 18:43:39 +05:30
parent 3eabe10bde
commit 7ce79bb134
10 changed files with 526 additions and 3 deletions

2
page-share.md Normal file
View File

@ -0,0 +1,2 @@
# Page Share

View File

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

View File

@ -16,6 +16,7 @@ import {
ZapIcon
} from "lucide-react"
import { getAllPrompts } from "~libs/db"
import { ShareBtn } from "~components/Common/ShareBtn"
export default function OptionLayout({
children
@ -29,7 +30,9 @@ export default function OptionLayout({
clearChat,
selectedSystemPrompt,
setSelectedQuickPrompt,
setSelectedSystemPrompt
setSelectedSystemPrompt,
messages,
streaming
} = useMessageOption()
const {
@ -155,6 +158,9 @@ export default function OptionLayout({
<div className="flex flex-1 justify-end px-4">
<div className="ml-4 flex items-center md:ml-6">
<div className="flex gap-4 items-center">
{pathname === "/" && messages.length > 0 && !streaming && (
<ShareBtn messages={messages} />
)}
{/* <Tooltip title="Manage Prompts">
<NavLink
to="/prompts"

View File

@ -2,7 +2,8 @@ import {
Book,
BrainCircuit,
CircuitBoardIcon,
Orbit
Orbit,
Share
} from "lucide-react"
import { Link, useLocation } from "react-router-dom"
@ -46,7 +47,7 @@ export const SettingsLayout = ({ children }: { children: React.ReactNode }) => {
return (
<>
<div className="mx-auto max-w-7xl lg:flex lg:gap-x-16 lg:px-8">
<aside className="flex lg:rounded-md bg-white lg:h-56 lg:p-4 lg:mt-20 overflow-x-auto lg:border border-b py-4 lg:block lg:w-64 lg:flex-none dark:bg-[#171717] dark:border-gray-600">
<aside className="flex lg:rounded-md bg-white lg:p-4 lg:mt-20 overflow-x-auto lg:border-0 border-b py-4 lg:block lg:w-64 lg:flex-none dark:bg-[#171717] dark:border-gray-600">
<nav className="flex-none px-4 sm:px-6 lg:px-0">
<ul
role="list"
@ -75,6 +76,12 @@ export const SettingsLayout = ({ children }: { children: React.ReactNode }) => {
icon={Book}
current={location.pathname}
/>
<LinkComponent
href="/settings/share"
name="Manage Share"
icon={Share}
current={location.pathname}
/>
</ul>
</nav>
</aside>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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