diff --git a/package.json b/package.json index 0985d1f..38c75c0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pageassist", "displayName": "Page Assist - A Web UI for Local AI Models", - "version": "1.0.1", + "version": "1.0.2", "description": "Use your locally running AI models to assist you in your web browsing.", "author": "n4ze3m", "scripts": { @@ -11,7 +11,6 @@ }, "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", @@ -22,6 +21,7 @@ "@tanstack/react-query": "^5.17.19", "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", @@ -81,4 +81,4 @@ "contextMenus" ] } -} \ No newline at end of file +} diff --git a/src/background.ts b/src/background.ts index add4d6b..43b8dde 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,5 +1,80 @@ +import { getOllamaURL, isOllamaRunning } from "~services/ollama" + export {} +const progressHuman = (completed: number, total: number) => { + return ((completed / total) * 100).toFixed(0) + "%" +} + +const clearBadge = () => { + chrome.action.setBadgeText({ text: "" }) + chrome.action.setTitle({ title: "" }) +} + +const streamDownload = async (url: string, model: string) => { + url += "/api/pull" + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ model, stream: true }) + }) + + const reader = response.body?.getReader() + + const decoder = new TextDecoder() + + let isSuccess = true + while (true) { + const { done, value } = await reader.read() + + if (done) { + break + } + + const text = decoder.decode(value) + try { + const json = JSON.parse(text.trim()) as { + status: string + total?: number + completed?: number + } + if (json.total && json.completed) { + chrome.action.setBadgeText({ + text: progressHuman(json.completed, json.total) + }) + chrome.action.setBadgeBackgroundColor({ color: "#0000FF" }) + } else { + chrome.action.setBadgeText({ text: "🏋️‍♂️" }) + chrome.action.setBadgeBackgroundColor({ color: "#FFFFFF" }) + } + + chrome.action.setTitle({ title: json.status }) + + if (json.status === "success") { + isSuccess = true + } + } catch (e) { + console.error(e) + } + } + + if (isSuccess) { + chrome.action.setBadgeText({ text: "✅" }) + chrome.action.setBadgeBackgroundColor({ color: "#00FF00" }) + chrome.action.setTitle({ title: "Model pulled successfully" }) + } else { + chrome.action.setBadgeText({ text: "❌" }) + chrome.action.setBadgeBackgroundColor({ color: "#FF0000" }) + chrome.action.setTitle({ title: "Model pull failed" }) + } + + setTimeout(() => { + clearBadge() + }, 5000) +} + chrome.runtime.onMessage.addListener(async (message) => { if (message.type === "sidepanel") { chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => { @@ -8,6 +83,22 @@ chrome.runtime.onMessage.addListener(async (message) => { tabId: tab.id }) }) + } else if (message.type === "pull_model") { + const ollamaURL = await getOllamaURL() + + const isRunning = await isOllamaRunning() + + if (!isRunning) { + chrome.action.setBadgeText({ text: "E" }) + chrome.action.setBadgeBackgroundColor({ color: "#FF0000" }) + chrome.action.setTitle({ title: "Ollama is not running" }) + setTimeout(() => { + clearBadge() + }, 5000) + } + console.log("Pulling model", message.modelName) + + await streamDownload(ollamaURL, message.modelName) } }) diff --git a/src/components/Common/Markdown.tsx b/src/components/Common/Markdown.tsx index 688c6f2..c691ffb 100644 --- a/src/components/Common/Markdown.tsx +++ b/src/components/Common/Markdown.tsx @@ -24,8 +24,8 @@ export default function Markdown({ message }: { message: string }) { { return (
+ className={`group w-full text-gray-800 dark:text-gray-100`}>
diff --git a/src/components/Option/Layout.tsx b/src/components/Option/Layout.tsx index 37d8934..bd67272 100644 --- a/src/components/Option/Layout.tsx +++ b/src/components/Option/Layout.tsx @@ -1,51 +1,20 @@ -import React, { Fragment, useState } from "react" -import { Dialog, Menu, Transition } from "@headlessui/react" -import { - Bars3BottomLeftIcon, - XMarkIcon, - TagIcon, - CircleStackIcon, - CogIcon, - ChatBubbleLeftIcon, - Bars3Icon, - Bars4Icon, - ArrowPathIcon -} from "@heroicons/react/24/outline" -import logoImage from "data-base64:~assets/icon.png" +import React, { useState } from "react" +import { CogIcon } from "@heroicons/react/24/outline" -import { Link, useParams, useLocation, useNavigate } from "react-router-dom" +import { useLocation, NavLink } from "react-router-dom" import { Sidebar } from "./Sidebar" -import { Drawer, Layout, Modal, Select } from "antd" +import { Drawer, Layout, Modal, Select, Tooltip } from "antd" import { useQuery } from "@tanstack/react-query" import { fetchModels } from "~services/ollama" import { useMessageOption } from "~hooks/useMessageOption" -import { GithubIcon, PanelLeftIcon, Settings2, SquarePen } from "lucide-react" +import { + GithubIcon, + PanelLeftIcon, + BrainCircuit, + SquarePen, + ChevronLeft +} from "lucide-react" import { Settings } from "./Settings" -import { useDarkMode } from "~hooks/useDarkmode" - -const navigation = [ - { name: "Embed", href: "/bot/:id", icon: TagIcon }, - { - name: "Preview", - href: "/bot/:id/preview", - icon: ChatBubbleLeftIcon - }, - { - name: "Data Sources", - href: "/bot/:id/data-sources", - icon: CircleStackIcon - }, - { - name: "Settings", - href: "/bot/:id/settings", - icon: CogIcon - } -] - -//@ts-ignore - -function classNames(...classes) { - return classes.filter(Boolean).join(" ") -} export default function OptionLayout({ children @@ -58,19 +27,29 @@ export default function OptionLayout({ const { data: models, isLoading: isModelsLoading, - refetch: refetchModels, isFetching: isModelsFetching } = useQuery({ queryKey: ["fetchModel"], - queryFn: fetchModels + queryFn: fetchModels, + refetchInterval: 15000 }) + const { pathname } = useLocation() const { selectedModel, setSelectedModel, clearChat } = useMessageOption() return (
+ {pathname !== "/" && ( +
+ + + +
+ )}
-
- - - +
+ + + + + + + + + + +
+
+
+ + {status === "pending" && } + + {status === "success" && ( + ( + + {`${text?.slice(0, 5)}...${text?.slice(-4)}`} + + ) + }, + { + title: "Modified", + dataIndex: "modified_at", + key: "modified_at", + render: (text: string) => dayjs(text).fromNow(true) + }, + { + title: "Size", + dataIndex: "size", + key: "size", + render: (text: number) => bytePerSecondFormatter(text) + }, + { + title: "Action", + render: (_, record) => ( +
+ + + + + + +
+ ) + } + ]} + expandable={{ + expandedRowRender: (record) => ( +
+ ), + defaultExpandAllRows: false + }} + bordered + dataSource={data} + rowKey={(record) => `${record.model}-${record.digest}`} + /> + )} + + + setOpen(false)}> +
pullOllamaModel(values.model))}> + + + + +
+ + ) +} diff --git a/src/components/Option/Playground/PlaygroundForm.tsx b/src/components/Option/Playground/PlaygroundForm.tsx index 9366e34..291d90c 100644 --- a/src/components/Option/Playground/PlaygroundForm.tsx +++ b/src/components/Option/Playground/PlaygroundForm.tsx @@ -7,7 +7,7 @@ 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, MicOffIcon } from "lucide-react" +import { MicIcon, StopCircleIcon } from "lucide-react" import { Image } from "antd" import { useSpeechRecognition } from "~hooks/useSpeechRecognition" @@ -60,8 +60,13 @@ export const PlaygroundForm = ({ dropedFile }: Props) => { useDynamicTextareaSize(textareaRef, form.values.message, 300) - const { onSubmit, selectedModel, chatMode, speechToTextLanguage } = - useMessageOption() + const { + onSubmit, + selectedModel, + chatMode, + speechToTextLanguage, + stopStreamingRequest + } = useMessageOption() const { isListening, start, stop, transcript } = useSpeechRecognition() @@ -208,23 +213,34 @@ export const PlaygroundForm = ({ dropedFile }: Props) => { - + {!isSending ? ( + + ) : ( + + + + )} diff --git a/src/components/Option/Playground/PlaygroundMessage.tsx b/src/components/Option/Playground/PlaygroundMessage.tsx index 1882558..6d5ca19 100644 --- a/src/components/Option/Playground/PlaygroundMessage.tsx +++ b/src/components/Option/Playground/PlaygroundMessage.tsx @@ -25,8 +25,7 @@ export const PlaygroundMessage = (props: Props) => { }, [isBtnPressed]) return ( -
+
diff --git a/src/components/Option/Playground/PlaygroundNewChat.tsx b/src/components/Option/Playground/PlaygroundNewChat.tsx index 03d6d86..adf5e9d 100644 --- a/src/components/Option/Playground/PlaygroundNewChat.tsx +++ b/src/components/Option/Playground/PlaygroundNewChat.tsx @@ -4,7 +4,6 @@ import { useMessage } from "../../../hooks/useMessage" export const PlaygroundNewChat = () => { const { setHistory, setMessages, setHistoryId } = useMessage() - const handleClick = () => { setHistoryId(null) setMessages([]) diff --git a/src/contents/ollama-pull.ts b/src/contents/ollama-pull.ts new file mode 100644 index 0000000..e8c2336 --- /dev/null +++ b/src/contents/ollama-pull.ts @@ -0,0 +1,54 @@ +import type { PlasmoCSConfig } from "plasmo" + +export const config: PlasmoCSConfig = { + matches: ["*://ollama.com/library/*"], + all_frames: true +} + +const downloadModel = async (modelName: string) => { + const ok = confirm( + `[Page Assist Extension] Do you want to pull ${modelName} model? This has nothing to do with Ollama.com website. The model will be pulled locally once you confirm.` + ) + if (ok) { + alert( + `[Page Assist Extension] Pulling ${modelName} model. For more details, check the extension icon.` + ) + + await chrome.runtime.sendMessage({ + type: "pull_model", + modelName + }) + return true + } + return false +} + +const downloadSVG = ` + + +` +const codeDiv = document.querySelectorAll("div.language-none") + +for (let i = 0; i < codeDiv.length; i++) { + const button = codeDiv[i].querySelector("button") + const command = codeDiv[i].querySelector("input") + if (button && command) { + const newButton = document.createElement("button") + newButton.innerHTML = downloadSVG + newButton.className = `border-l ${button.className}` + newButton.id = `download-${i}-pageassist` + const modelName = command?.value + .replace("ollama run", "") + .replace("ollama pull", "") + .trim() + newButton.addEventListener("click", () => { + downloadModel(modelName) + }) + + const span = document.createElement("span") + span.title = "Download model via Page Assist" + span.appendChild(newButton) + + button.parentNode.appendChild(span) + } +} diff --git a/src/hooks/useMessageOption.tsx b/src/hooks/useMessageOption.tsx index 3f496ad..745b0fe 100644 --- a/src/hooks/useMessageOption.tsx +++ b/src/hooks/useMessageOption.tsx @@ -11,6 +11,8 @@ import { } from "@langchain/core/messages" import { useStoreMessageOption } from "~store/option" import { saveHistory, saveMessage } from "~libs/db" +import { useNavigate } from "react-router-dom" +import { notification } from "antd" export type BotResponse = { bot: { @@ -94,6 +96,8 @@ export const useMessageOption = () => { setSpeechToTextLanguage } = useStoreMessageOption() + const navigate = useNavigate() + const abortControllerRef = React.useRef(null) const clearChat = () => { @@ -105,6 +109,7 @@ export const useMessageOption = () => { setIsLoading(false) setIsProcessing(false) setStreaming(false) + navigate("/") } const normalChatMode = async (message: string, image: string) => { @@ -249,22 +254,58 @@ export const useMessageOption = () => { setIsProcessing(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) - - setMessages([ - ...messages, - { - isBot: true, - name: selectedModel, - message: `Something went wrong. Check out the following logs: - \`\`\` - ${e?.message} - \`\`\` - `, - sources: [] - } - ]) } } diff --git a/src/libs/byte-formater.ts b/src/libs/byte-formater.ts new file mode 100644 index 0000000..3499487 --- /dev/null +++ b/src/libs/byte-formater.ts @@ -0,0 +1,23 @@ +const UNITS = [ + "byte", + "kilobyte", + "megabyte", + "gigabyte", + "terabyte", + "petabyte" +] + +const getValueAndUnit = (n: number) => { + const i = n == 0 ? 0 : Math.floor(Math.log(n) / Math.log(1024)) + const value = n / Math.pow(1024, i) + return { value, unit: UNITS[i] } +} + +export const bytePerSecondFormatter = (n: number) => { + const { unit, value } = getValueAndUnit(n) + return new Intl.NumberFormat("en", { + notation: "compact", + style: "unit", + unit + }).format(value) +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 8010b08..f932315 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -3,6 +3,7 @@ import { SidepanelChat } from "./sidepanel-chat" import { useDarkMode } from "~hooks/useDarkmode" import { SidepanelSettings } from "./sidepanel-settings" import { OptionIndex } from "./option-index" +import { OptionModal } from "./option-model" export const OptionRouting = () => { const { mode } = useDarkMode() @@ -11,6 +12,7 @@ export const OptionRouting = () => {
} /> + } />
) diff --git a/src/routes/option-index.tsx b/src/routes/option-index.tsx index a22046b..2c31db7 100644 --- a/src/routes/option-index.tsx +++ b/src/routes/option-index.tsx @@ -1,7 +1,5 @@ import OptionLayout from "~components/Option/Layout" import { Playground } from "~components/Option/Playground/Playground" -import { SettingsBody } from "~components/Sidepanel/Settings/body" -import { SidepanelSettingsHeader } from "~components/Sidepanel/Settings/header" export const OptionIndex = () => { return ( diff --git a/src/routes/option-model.tsx b/src/routes/option-model.tsx new file mode 100644 index 0000000..3f5a020 --- /dev/null +++ b/src/routes/option-model.tsx @@ -0,0 +1,10 @@ +import OptionLayout from "~components/Option/Layout" +import { ModelsBody } from "~components/Option/Models" + +export const OptionModal = () => { + return ( + + + + ) +} diff --git a/src/services/ollama.ts b/src/services/ollama.ts index 5ca0fcd..fb256ed 100644 --- a/src/services/ollama.ts +++ b/src/services/ollama.ts @@ -53,6 +53,47 @@ export const isOllamaRunning = async () => { } } +export const getAllModels = async () => { + const baseUrl = await getOllamaURL() + const response = await fetch(`${cleanUrl(baseUrl)}/api/tags`) + if (!response.ok) { + throw new Error(response.statusText) + } + const json = await response.json() + + return json.models as { + name: string + model: string + modified_at: string + size: number + digest: string + details: { + parent_model: string + format: string + family: string + families: string[] + parameter_size: string + quantization_level: string + } + }[] +} + +export const deleteModel= async (model: string) => { + const baseUrl = await getOllamaURL() + const response = await fetch(`${cleanUrl(baseUrl)}/api/delete`, { + method: "DELETE", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ name: model }) + }) + + if (!response.ok) { + throw new Error(response.statusText) + } + return response.json() +} + export const fetchModels = async () => { try { const baseUrl = await getOllamaURL() @@ -65,6 +106,17 @@ export const fetchModels = async () => { return json.models as { name: string model: string + modified_at: string + size: number + digest: string + details: { + parent_model: string + format: string + family: string + families: string[] + parameter_size: string + quantization_level: string + } }[] } catch (e) { console.error(e) diff --git a/yarn.lock b/yarn.lock index 77bf920..df2a391 100644 --- a/yarn.lock +++ b/yarn.lock @@ -456,14 +456,6 @@ 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" @@ -2314,18 +2306,6 @@ dependencies: "@tanstack/query-core" "5.18.0" -"@tanstack/react-virtual@^3.0.0-beta.60": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.0.2.tgz#e5a979f2585d3f583944840319cddf2c2d1b0e51" - integrity sha512-9XbRLPKgnhMwwmuQMnJMv+5a9sitGNCSEtf/AZXzmJdesYk7XsjYHaEDny+IrJzvPNwZliIIDwCRiaUqR3zzCA== - dependencies: - "@tanstack/virtual-core" "3.0.0" - -"@tanstack/virtual-core@3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.0.0.tgz#637bee36f0cabf96a1d436887c90f138a7e9378b" - integrity sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg== - "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" @@ -3098,11 +3078,6 @@ 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"