feat: copilot context menu for tool
This commit is contained in:
parent
9c7ebc8778
commit
ac9c9ca887
@ -1,6 +1,6 @@
|
|||||||
import Markdown from "../../Common/Markdown"
|
import Markdown from "../../Common/Markdown"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { Image, Tooltip } from "antd"
|
import { Tag, Image, Tooltip } from "antd"
|
||||||
import { WebSearch } from "./WebSearch"
|
import { WebSearch } from "./WebSearch"
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
@ -17,6 +17,7 @@ import { useTTS } from "@/hooks/useTTS"
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
message: string
|
message: string
|
||||||
|
message_type?: string
|
||||||
hideCopy?: boolean
|
hideCopy?: boolean
|
||||||
botAvatar?: JSX.Element
|
botAvatar?: JSX.Element
|
||||||
userAvatar?: JSX.Element
|
userAvatar?: JSX.Element
|
||||||
@ -76,13 +77,21 @@ export const PlaygroundMessage = (props: Props) => {
|
|||||||
props.currentMessageIndex === props.totalMessages - 1 ? (
|
props.currentMessageIndex === props.totalMessages - 1 ? (
|
||||||
<WebSearch />
|
<WebSearch />
|
||||||
) : null}
|
) : null}
|
||||||
|
<div>
|
||||||
|
{props?.message_type && (
|
||||||
|
<Tag color="blue">{props?.message_type}</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex flex-grow flex-col">
|
<div className="flex flex-grow flex-col">
|
||||||
{!editMode ? (
|
{!editMode ? (
|
||||||
props.isBot ? (
|
props.isBot ? (
|
||||||
<Markdown message={props.message} />
|
<Markdown message={props.message} />
|
||||||
) : (
|
) : (
|
||||||
<p className="prose dark:prose-invert whitespace-pre-line prose-p:leading-relaxed prose-pre:p-0 dark:prose-dark">
|
<p
|
||||||
|
className={`prose dark:prose-invert whitespace-pre-line prose-p:leading-relaxed prose-pre:p-0 dark:prose-dark ${
|
||||||
|
props.message_type &&
|
||||||
|
"italic text-gray-500 dark:text-gray-400 text-xs"
|
||||||
|
}`}>
|
||||||
{props.message}
|
{props.message}
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
|
@ -35,6 +35,7 @@ export const SidePanelBody = () => {
|
|||||||
currentMessageIndex={index}
|
currentMessageIndex={index}
|
||||||
totalMessages={messages.length}
|
totalMessages={messages.length}
|
||||||
onRengerate={regenerateLastMessage}
|
onRengerate={regenerateLastMessage}
|
||||||
|
message_type={message.messageType}
|
||||||
isProcessing={streaming}
|
isProcessing={streaming}
|
||||||
isSearchingInternet={isSearchingInternet}
|
isSearchingInternet={isSearchingInternet}
|
||||||
sources={message.sources}
|
sources={message.sources}
|
||||||
|
@ -31,6 +31,7 @@ type Message = {
|
|||||||
sources?: string[]
|
sources?: string[]
|
||||||
search?: WebSearch
|
search?: WebSearch
|
||||||
createdAt: number
|
createdAt: number
|
||||||
|
messageType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Webshare = {
|
type Webshare = {
|
||||||
@ -241,7 +242,8 @@ export const saveMessage = async (
|
|||||||
content: string,
|
content: string,
|
||||||
images: string[],
|
images: string[],
|
||||||
source?: any[],
|
source?: any[],
|
||||||
time?: number
|
time?: number,
|
||||||
|
message_type?: string
|
||||||
) => {
|
) => {
|
||||||
const id = generateID()
|
const id = generateID()
|
||||||
let createdAt = Date.now()
|
let createdAt = Date.now()
|
||||||
@ -256,7 +258,8 @@ export const saveMessage = async (
|
|||||||
content,
|
content,
|
||||||
images,
|
images,
|
||||||
createdAt,
|
createdAt,
|
||||||
sources: source
|
sources: source,
|
||||||
|
messageType: message_type
|
||||||
}
|
}
|
||||||
const db = new PageAssitDatabase()
|
const db = new PageAssitDatabase()
|
||||||
await db.addMessage(message)
|
await db.addMessage(message)
|
||||||
|
@ -1,84 +1,10 @@
|
|||||||
import { getOllamaURL, isOllamaRunning } from "../services/ollama"
|
import { getOllamaURL, isOllamaRunning } from "../services/ollama"
|
||||||
import { browser } from "wxt/browser"
|
import { browser } from "wxt/browser"
|
||||||
import { setBadgeBackgroundColor, setBadgeText, setTitle } from "@/utils/action"
|
import { clearBadge, streamDownload } from "@/utils/pull-ollama"
|
||||||
|
|
||||||
const progressHuman = (completed: number, total: number) => {
|
|
||||||
return ((completed / total) * 100).toFixed(0) + "%"
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearBadge = () => {
|
|
||||||
setBadgeText({ text: "" })
|
|
||||||
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) {
|
|
||||||
if (!reader) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
setBadgeText({
|
|
||||||
text: progressHuman(json.completed, json.total)
|
|
||||||
})
|
|
||||||
setBadgeBackgroundColor({ color: "#0000FF" })
|
|
||||||
} else {
|
|
||||||
setBadgeText({ text: "🏋️♂️" })
|
|
||||||
setBadgeBackgroundColor({ color: "#FFFFFF" })
|
|
||||||
}
|
|
||||||
|
|
||||||
setTitle({ title: json.status })
|
|
||||||
|
|
||||||
if (json.status === "success") {
|
|
||||||
isSuccess = true
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSuccess) {
|
|
||||||
setBadgeText({ text: "✅" })
|
|
||||||
setBadgeBackgroundColor({ color: "#00FF00" })
|
|
||||||
setTitle({ title: "Model pulled successfully" })
|
|
||||||
} else {
|
|
||||||
setBadgeText({ text: "❌" })
|
|
||||||
setBadgeBackgroundColor({ color: "#FF0000" })
|
|
||||||
setTitle({ title: "Model pull failed" })
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
clearBadge()
|
|
||||||
}, 5000)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineBackground({
|
export default defineBackground({
|
||||||
main() {
|
main() {
|
||||||
|
let isCopilotRunning: boolean = false
|
||||||
browser.runtime.onMessage.addListener(async (message) => {
|
browser.runtime.onMessage.addListener(async (message) => {
|
||||||
if (message.type === "sidepanel") {
|
if (message.type === "sidepanel") {
|
||||||
await browser.sidebarAction.open()
|
await browser.sidebarAction.open()
|
||||||
@ -100,6 +26,15 @@ export default defineBackground({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
browser.runtime.onConnect.addListener((port) => {
|
||||||
|
if (port.name === "pgCopilot") {
|
||||||
|
isCopilotRunning = true
|
||||||
|
port.onDisconnect.addListener(() => {
|
||||||
|
isCopilotRunning = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (import.meta.env.BROWSER === "chrome") {
|
if (import.meta.env.BROWSER === "chrome") {
|
||||||
chrome.action.onClicked.addListener((tab) => {
|
chrome.action.onClicked.addListener((tab) => {
|
||||||
chrome.tabs.create({ url: chrome.runtime.getURL("/options.html") })
|
chrome.tabs.create({ url: chrome.runtime.getURL("/options.html") })
|
||||||
@ -124,10 +59,41 @@ export default defineBackground({
|
|||||||
browser.contextMenus.create({
|
browser.contextMenus.create({
|
||||||
id: contextMenuId["sidePanel"],
|
id: contextMenuId["sidePanel"],
|
||||||
title: contextMenuTitle["sidePanel"],
|
title: contextMenuTitle["sidePanel"],
|
||||||
contexts: ["all"]
|
contexts: ["page", "selection"]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
browser.contextMenus.create({
|
||||||
|
id: "summarize-pa",
|
||||||
|
title: "Summarize",
|
||||||
|
contexts: ["selection"]
|
||||||
|
})
|
||||||
|
|
||||||
|
browser.contextMenus.create({
|
||||||
|
id: "explain-pa",
|
||||||
|
title: "Explain",
|
||||||
|
contexts: ["selection"]
|
||||||
|
})
|
||||||
|
|
||||||
|
browser.contextMenus.create({
|
||||||
|
id: "rephrase-pa",
|
||||||
|
title: "Rephrase",
|
||||||
|
contexts: ["selection"]
|
||||||
|
})
|
||||||
|
|
||||||
|
browser.contextMenus.create({
|
||||||
|
id: "translate-pg",
|
||||||
|
title: "Translate",
|
||||||
|
contexts: ["selection"]
|
||||||
|
})
|
||||||
|
|
||||||
|
// browser.contextMenus.create({
|
||||||
|
// id: "custom-pg",
|
||||||
|
// title: "Custom",
|
||||||
|
// contexts: ["selection"]
|
||||||
|
// })
|
||||||
|
|
||||||
if (import.meta.env.BROWSER === "chrome") {
|
if (import.meta.env.BROWSER === "chrome") {
|
||||||
browser.contextMenus.onClicked.addListener((info, tab) => {
|
browser.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||||
if (info.menuItemId === "open-side-panel-pa") {
|
if (info.menuItemId === "open-side-panel-pa") {
|
||||||
chrome.sidePanel.open({
|
chrome.sidePanel.open({
|
||||||
tabId: tab.id!
|
tabId: tab.id!
|
||||||
@ -136,6 +102,68 @@ export default defineBackground({
|
|||||||
browser.tabs.create({
|
browser.tabs.create({
|
||||||
url: browser.runtime.getURL("/options.html")
|
url: browser.runtime.getURL("/options.html")
|
||||||
})
|
})
|
||||||
|
} else if (info.menuItemId === "summarize-pa") {
|
||||||
|
chrome.sidePanel.open({
|
||||||
|
tabId: tab.id!
|
||||||
|
})
|
||||||
|
// this is a bad method hope somone can fix it :)
|
||||||
|
setTimeout(async () => {
|
||||||
|
await browser.runtime.sendMessage({
|
||||||
|
from: "background",
|
||||||
|
type: "summary",
|
||||||
|
text: info.selectionText
|
||||||
|
})
|
||||||
|
}, isCopilotRunning ? 0 : 5000)
|
||||||
|
|
||||||
|
} else if (info.menuItemId === "rephrase-pa") {
|
||||||
|
chrome.sidePanel.open({
|
||||||
|
tabId: tab.id!
|
||||||
|
})
|
||||||
|
setTimeout(async () => {
|
||||||
|
|
||||||
|
await browser.runtime.sendMessage({
|
||||||
|
type: "rephrase",
|
||||||
|
from: "background",
|
||||||
|
text: info.selectionText
|
||||||
|
})
|
||||||
|
}, isCopilotRunning ? 0 : 5000)
|
||||||
|
|
||||||
|
} else if (info.menuItemId === "translate-pg") {
|
||||||
|
chrome.sidePanel.open({
|
||||||
|
tabId: tab.id!
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
await browser.runtime.sendMessage({
|
||||||
|
type: "translate",
|
||||||
|
from: "background",
|
||||||
|
text: info.selectionText
|
||||||
|
})
|
||||||
|
}, isCopilotRunning ? 0 : 5000)
|
||||||
|
} else if (info.menuItemId === "explain-pa") {
|
||||||
|
chrome.sidePanel.open({
|
||||||
|
tabId: tab.id!
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
await browser.runtime.sendMessage({
|
||||||
|
type: "explain",
|
||||||
|
from: "background",
|
||||||
|
text: info.selectionText
|
||||||
|
})
|
||||||
|
}, isCopilotRunning ? 0 : 5000)
|
||||||
|
} else if (info.menuItemId === "custom-pg") {
|
||||||
|
chrome.sidePanel.open({
|
||||||
|
tabId: tab.id!
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
await browser.runtime.sendMessage({
|
||||||
|
type: "custom",
|
||||||
|
from: "background",
|
||||||
|
text: info.selectionText
|
||||||
|
})
|
||||||
|
}, isCopilotRunning ? 0 : 5000)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -13,7 +13,8 @@ export const saveMessageOnError = async ({
|
|||||||
selectedModel,
|
selectedModel,
|
||||||
setHistoryId,
|
setHistoryId,
|
||||||
isRegenerating,
|
isRegenerating,
|
||||||
message_source = "web-ui"
|
message_source = "web-ui",
|
||||||
|
message_type
|
||||||
}: {
|
}: {
|
||||||
e: any
|
e: any
|
||||||
setHistory: (history: ChatHistory) => void
|
setHistory: (history: ChatHistory) => void
|
||||||
@ -26,6 +27,7 @@ export const saveMessageOnError = async ({
|
|||||||
setHistoryId: (historyId: string) => void
|
setHistoryId: (historyId: string) => void
|
||||||
isRegenerating: boolean
|
isRegenerating: boolean
|
||||||
message_source?: "copilot" | "web-ui"
|
message_source?: "copilot" | "web-ui"
|
||||||
|
message_type?: string
|
||||||
}) => {
|
}) => {
|
||||||
if (
|
if (
|
||||||
e?.name === "AbortError" ||
|
e?.name === "AbortError" ||
|
||||||
@ -55,7 +57,8 @@ export const saveMessageOnError = async ({
|
|||||||
userMessage,
|
userMessage,
|
||||||
[image],
|
[image],
|
||||||
[],
|
[],
|
||||||
1
|
1,
|
||||||
|
message_type
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
await saveMessage(
|
await saveMessage(
|
||||||
@ -65,7 +68,8 @@ export const saveMessageOnError = async ({
|
|||||||
botMessage,
|
botMessage,
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
2
|
2,
|
||||||
|
message_type
|
||||||
)
|
)
|
||||||
await setLastUsedChatModel(historyId, selectedModel)
|
await setLastUsedChatModel(historyId, selectedModel)
|
||||||
} else {
|
} else {
|
||||||
@ -78,7 +82,8 @@ export const saveMessageOnError = async ({
|
|||||||
userMessage,
|
userMessage,
|
||||||
[image],
|
[image],
|
||||||
[],
|
[],
|
||||||
1
|
1,
|
||||||
|
message_type
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
await saveMessage(
|
await saveMessage(
|
||||||
@ -88,7 +93,8 @@ export const saveMessageOnError = async ({
|
|||||||
botMessage,
|
botMessage,
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
2
|
2,
|
||||||
|
message_type
|
||||||
)
|
)
|
||||||
setHistoryId(newHistoryId.id)
|
setHistoryId(newHistoryId.id)
|
||||||
await setLastUsedChatModel(newHistoryId.id, selectedModel)
|
await setLastUsedChatModel(newHistoryId.id, selectedModel)
|
||||||
@ -109,7 +115,8 @@ export const saveMessageOnSuccess = async ({
|
|||||||
image,
|
image,
|
||||||
fullText,
|
fullText,
|
||||||
source,
|
source,
|
||||||
message_source = "web-ui"
|
message_source = "web-ui",
|
||||||
|
message_type
|
||||||
}: {
|
}: {
|
||||||
historyId: string | null
|
historyId: string | null
|
||||||
setHistoryId: (historyId: string) => void
|
setHistoryId: (historyId: string) => void
|
||||||
@ -119,7 +126,8 @@ export const saveMessageOnSuccess = async ({
|
|||||||
image: string
|
image: string
|
||||||
fullText: string
|
fullText: string
|
||||||
source: any[]
|
source: any[]
|
||||||
message_source?: "copilot" | "web-ui"
|
message_source?: "copilot" | "web-ui",
|
||||||
|
message_type?: string
|
||||||
}) => {
|
}) => {
|
||||||
if (historyId) {
|
if (historyId) {
|
||||||
if (!isRegenerate) {
|
if (!isRegenerate) {
|
||||||
@ -130,7 +138,8 @@ export const saveMessageOnSuccess = async ({
|
|||||||
message,
|
message,
|
||||||
[image],
|
[image],
|
||||||
[],
|
[],
|
||||||
1
|
1,
|
||||||
|
message_type
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
await saveMessage(
|
await saveMessage(
|
||||||
@ -140,7 +149,8 @@ export const saveMessageOnSuccess = async ({
|
|||||||
fullText,
|
fullText,
|
||||||
[],
|
[],
|
||||||
source,
|
source,
|
||||||
2
|
2,
|
||||||
|
message_type
|
||||||
)
|
)
|
||||||
await setLastUsedChatModel(historyId, selectedModel!)
|
await setLastUsedChatModel(historyId, selectedModel!)
|
||||||
} else {
|
} else {
|
||||||
@ -152,7 +162,8 @@ export const saveMessageOnSuccess = async ({
|
|||||||
message,
|
message,
|
||||||
[image],
|
[image],
|
||||||
[],
|
[],
|
||||||
1
|
1,
|
||||||
|
message_type
|
||||||
)
|
)
|
||||||
await saveMessage(
|
await saveMessage(
|
||||||
newHistoryId.id,
|
newHistoryId.id,
|
||||||
@ -161,7 +172,8 @@ export const saveMessageOnSuccess = async ({
|
|||||||
fullText,
|
fullText,
|
||||||
[],
|
[],
|
||||||
source,
|
source,
|
||||||
2
|
2,
|
||||||
|
message_type
|
||||||
)
|
)
|
||||||
setHistoryId(newHistoryId.id)
|
setHistoryId(newHistoryId.id)
|
||||||
await setLastUsedChatModel(newHistoryId.id, selectedModel!)
|
await setLastUsedChatModel(newHistoryId.id, selectedModel!)
|
||||||
|
29
src/hooks/useBackgroundMessage.tsx
Normal file
29
src/hooks/useBackgroundMessage.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { useState, useEffect } from "react"
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
from: string
|
||||||
|
type: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function useBackgroundMessage() {
|
||||||
|
const [message, setMessage] = useState<Message | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const messageListener = (request: Message) => {
|
||||||
|
if (request.from === "background") {
|
||||||
|
setMessage(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
browser.runtime.connect({ name: 'pgCopilot' })
|
||||||
|
browser.runtime.onMessage.addListener(messageListener)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
browser.runtime.onMessage.removeListener(messageListener)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useBackgroundMessage
|
@ -31,6 +31,7 @@ import { useStoreChatModelSettings } from "@/store/model"
|
|||||||
import { getAllDefaultModelSettings } from "@/services/model-settings"
|
import { getAllDefaultModelSettings } from "@/services/model-settings"
|
||||||
import { getSystemPromptForWeb } from "@/web/web"
|
import { getSystemPromptForWeb } from "@/web/web"
|
||||||
import { pageAssistModel } from "@/models"
|
import { pageAssistModel } from "@/models"
|
||||||
|
import { getPrompt } from "@/services/application"
|
||||||
|
|
||||||
export const useMessage = () => {
|
export const useMessage = () => {
|
||||||
const {
|
const {
|
||||||
@ -51,8 +52,10 @@ export const useMessage = () => {
|
|||||||
isSearchingInternet
|
isSearchingInternet
|
||||||
} = useStoreMessageOption()
|
} = useStoreMessageOption()
|
||||||
|
|
||||||
|
const [chatWithWebsiteEmbedding] = useStorage(
|
||||||
const [chatWithWebsiteEmbedding] = useStorage("chatWithWebsiteEmbedding", true)
|
"chatWithWebsiteEmbedding",
|
||||||
|
true
|
||||||
|
)
|
||||||
const [maxWebsiteContext] = useStorage("maxWebsiteContext", 4028)
|
const [maxWebsiteContext] = useStorage("maxWebsiteContext", 4028)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -857,13 +860,206 @@ export const useMessage = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const presetChatMode = async (
|
||||||
|
message: string,
|
||||||
|
image: string,
|
||||||
|
isRegenerate: boolean,
|
||||||
|
messages: Message[],
|
||||||
|
history: ChatHistory,
|
||||||
|
signal: AbortSignal,
|
||||||
|
messageType: string
|
||||||
|
) => {
|
||||||
|
setStreaming(true)
|
||||||
|
const url = await getOllamaURL()
|
||||||
|
const userDefaultModelSettings = await getAllDefaultModelSettings()
|
||||||
|
|
||||||
|
if (image.length > 0) {
|
||||||
|
image = `data:image/jpeg;base64,${image.split(",")[1]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const ollama = await pageAssistModel({
|
||||||
|
model: selectedModel!,
|
||||||
|
baseUrl: cleanUrl(url),
|
||||||
|
keepAlive:
|
||||||
|
currentChatModelSettings?.keepAlive ??
|
||||||
|
userDefaultModelSettings?.keepAlive,
|
||||||
|
temperature:
|
||||||
|
currentChatModelSettings?.temperature ??
|
||||||
|
userDefaultModelSettings?.temperature,
|
||||||
|
topK: currentChatModelSettings?.topK ?? userDefaultModelSettings?.topK,
|
||||||
|
topP: currentChatModelSettings?.topP ?? userDefaultModelSettings?.topP,
|
||||||
|
numCtx:
|
||||||
|
currentChatModelSettings?.numCtx ?? userDefaultModelSettings?.numCtx,
|
||||||
|
seed: currentChatModelSettings?.seed
|
||||||
|
})
|
||||||
|
|
||||||
|
let newMessage: Message[] = []
|
||||||
|
let generateMessageId = generateID()
|
||||||
|
|
||||||
|
if (!isRegenerate) {
|
||||||
|
newMessage = [
|
||||||
|
...messages,
|
||||||
|
{
|
||||||
|
isBot: false,
|
||||||
|
name: "You",
|
||||||
|
message,
|
||||||
|
sources: [],
|
||||||
|
images: [image],
|
||||||
|
messageType: messageType
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isBot: true,
|
||||||
|
name: selectedModel,
|
||||||
|
message: "▋",
|
||||||
|
sources: [],
|
||||||
|
id: generateMessageId
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
newMessage = [
|
||||||
|
...messages,
|
||||||
|
{
|
||||||
|
isBot: true,
|
||||||
|
name: selectedModel,
|
||||||
|
message: "▋",
|
||||||
|
sources: [],
|
||||||
|
id: generateMessageId
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
setMessages(newMessage)
|
||||||
|
let fullText = ""
|
||||||
|
let contentToSave = ""
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
|
||||||
|
const prompt = await getPrompt(messageType)
|
||||||
|
let humanMessage = new HumanMessage({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
text: prompt.replace("{text}", message),
|
||||||
|
type: "text"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
if (image.length > 0) {
|
||||||
|
humanMessage = new HumanMessage({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
text: prompt.replace("{text}", message),
|
||||||
|
type: "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image_url: image,
|
||||||
|
type: "image_url"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = await ollama.stream([humanMessage], {
|
||||||
|
signal: signal
|
||||||
|
})
|
||||||
|
let count = 0
|
||||||
|
for await (const chunk of chunks) {
|
||||||
|
contentToSave += chunk.content
|
||||||
|
fullText += chunk.content
|
||||||
|
if (count === 0) {
|
||||||
|
setIsProcessing(true)
|
||||||
|
}
|
||||||
|
setMessages((prev) => {
|
||||||
|
return prev.map((message) => {
|
||||||
|
if (message.id === generateMessageId) {
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
message: fullText + "▋"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
})
|
||||||
|
})
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages((prev) => {
|
||||||
|
return prev.map((message) => {
|
||||||
|
if (message.id === generateMessageId) {
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
message: fullText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
setHistory([
|
||||||
|
...history,
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: message,
|
||||||
|
image,
|
||||||
|
messageType
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: fullText
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
await saveMessageOnSuccess({
|
||||||
|
historyId,
|
||||||
|
setHistoryId,
|
||||||
|
isRegenerate,
|
||||||
|
selectedModel: selectedModel,
|
||||||
|
message,
|
||||||
|
image,
|
||||||
|
fullText,
|
||||||
|
source: [],
|
||||||
|
message_source: "copilot",
|
||||||
|
message_type: messageType
|
||||||
|
})
|
||||||
|
|
||||||
|
setIsProcessing(false)
|
||||||
|
setStreaming(false)
|
||||||
|
} catch (e) {
|
||||||
|
const errorSave = await saveMessageOnError({
|
||||||
|
e,
|
||||||
|
botMessage: fullText,
|
||||||
|
history,
|
||||||
|
historyId,
|
||||||
|
image,
|
||||||
|
selectedModel,
|
||||||
|
setHistory,
|
||||||
|
setHistoryId,
|
||||||
|
userMessage: message,
|
||||||
|
isRegenerating: isRegenerate,
|
||||||
|
message_source: "copilot",
|
||||||
|
message_type: messageType
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!errorSave) {
|
||||||
|
notification.error({
|
||||||
|
message: t("error"),
|
||||||
|
description: e?.message || t("somethingWentWrong")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setIsProcessing(false)
|
||||||
|
setStreaming(false)
|
||||||
|
} finally {
|
||||||
|
setAbortController(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const onSubmit = async ({
|
const onSubmit = async ({
|
||||||
message,
|
message,
|
||||||
image,
|
image,
|
||||||
isRegenerate,
|
isRegenerate,
|
||||||
controller,
|
controller,
|
||||||
memory,
|
memory,
|
||||||
messages: chatHistory
|
messages: chatHistory,
|
||||||
|
messageType
|
||||||
}: {
|
}: {
|
||||||
message: string
|
message: string
|
||||||
image: string
|
image: string
|
||||||
@ -871,6 +1067,7 @@ export const useMessage = () => {
|
|||||||
messages?: Message[]
|
messages?: Message[]
|
||||||
memory?: ChatHistory
|
memory?: ChatHistory
|
||||||
controller?: AbortController
|
controller?: AbortController
|
||||||
|
messageType?: string
|
||||||
}) => {
|
}) => {
|
||||||
let signal: AbortSignal
|
let signal: AbortSignal
|
||||||
if (!controller) {
|
if (!controller) {
|
||||||
@ -882,6 +1079,18 @@ export const useMessage = () => {
|
|||||||
signal = controller.signal
|
signal = controller.signal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// this means that the user is trying to send something from a selected text on the web
|
||||||
|
if (messageType) {
|
||||||
|
await presetChatMode(
|
||||||
|
message,
|
||||||
|
image,
|
||||||
|
isRegenerate,
|
||||||
|
chatHistory || messages,
|
||||||
|
memory || history,
|
||||||
|
signal,
|
||||||
|
messageType
|
||||||
|
)
|
||||||
|
} else {
|
||||||
if (chatMode === "normal") {
|
if (chatMode === "normal") {
|
||||||
if (webSearch) {
|
if (webSearch) {
|
||||||
await searchChatMode(
|
await searchChatMode(
|
||||||
@ -917,6 +1126,7 @@ export const useMessage = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const stopStreamingRequest = () => {
|
const stopStreamingRequest = () => {
|
||||||
if (isEmbedding) {
|
if (isEmbedding) {
|
||||||
@ -982,7 +1192,8 @@ export const useMessage = () => {
|
|||||||
image: lastMessage.image || "",
|
image: lastMessage.image || "",
|
||||||
isRegenerate: true,
|
isRegenerate: true,
|
||||||
memory: newHistory,
|
memory: newHistory,
|
||||||
controller: newController
|
controller: newController,
|
||||||
|
messageType: lastMessage.messageType
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,8 +3,11 @@ import {
|
|||||||
formatToMessage,
|
formatToMessage,
|
||||||
getRecentChatFromCopilot
|
getRecentChatFromCopilot
|
||||||
} from "@/db"
|
} from "@/db"
|
||||||
|
import useBackgroundMessage from "@/hooks/useBackgroundMessage"
|
||||||
import { copilotResumeLastChat } from "@/services/app"
|
import { copilotResumeLastChat } from "@/services/app"
|
||||||
|
import { notification } from "antd"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
import { SidePanelBody } from "~/components/Sidepanel/Chat/body"
|
import { SidePanelBody } from "~/components/Sidepanel/Chat/body"
|
||||||
import { SidepanelForm } from "~/components/Sidepanel/Chat/form"
|
import { SidepanelForm } from "~/components/Sidepanel/Chat/form"
|
||||||
import { SidepanelHeader } from "~/components/Sidepanel/Chat/header"
|
import { SidepanelHeader } from "~/components/Sidepanel/Chat/header"
|
||||||
@ -13,17 +16,27 @@ import { useMessage } from "~/hooks/useMessage"
|
|||||||
const SidepanelChat = () => {
|
const SidepanelChat = () => {
|
||||||
const drop = React.useRef<HTMLDivElement>(null)
|
const drop = React.useRef<HTMLDivElement>(null)
|
||||||
const [dropedFile, setDropedFile] = React.useState<File | undefined>()
|
const [dropedFile, setDropedFile] = React.useState<File | undefined>()
|
||||||
|
const { t } = useTranslation(["playground"])
|
||||||
const [dropState, setDropState] = React.useState<
|
const [dropState, setDropState] = React.useState<
|
||||||
"idle" | "dragging" | "error"
|
"idle" | "dragging" | "error"
|
||||||
>("idle")
|
>("idle")
|
||||||
const { chatMode, messages, setHistory, setHistoryId, setMessages } =
|
const {
|
||||||
useMessage()
|
chatMode,
|
||||||
|
streaming,
|
||||||
|
onSubmit,
|
||||||
|
messages,
|
||||||
|
setHistory,
|
||||||
|
setHistoryId,
|
||||||
|
setMessages,
|
||||||
|
selectedModel
|
||||||
|
} = useMessage()
|
||||||
|
|
||||||
|
const bgMsg = useBackgroundMessage()
|
||||||
|
|
||||||
const setRecentMessagesOnLoad = async () => {
|
const setRecentMessagesOnLoad = async () => {
|
||||||
|
const isEnabled = await copilotResumeLastChat()
|
||||||
const isEnabled = await copilotResumeLastChat();
|
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
const recentChat = await getRecentChatFromCopilot()
|
const recentChat = await getRecentChatFromCopilot()
|
||||||
@ -92,11 +105,26 @@ const SidepanelChat = () => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setRecentMessagesOnLoad()
|
setRecentMessagesOnLoad()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (bgMsg && !streaming) {
|
||||||
|
if (selectedModel) {
|
||||||
|
onSubmit({
|
||||||
|
message: bgMsg.text,
|
||||||
|
messageType: bgMsg.type,
|
||||||
|
image: ""
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
notification.error({
|
||||||
|
message: t("formError.noModel")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [bgMsg])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={drop}
|
ref={drop}
|
||||||
|
162
src/services/application.ts
Normal file
162
src/services/application.ts
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { Storage } from "@plasmohq/storage"
|
||||||
|
const storage = new Storage()
|
||||||
|
|
||||||
|
const DEFAULT_SUMMARY_PROMPT = `Provide a concise summary of the following text, capturing its main ideas and key points:
|
||||||
|
|
||||||
|
Text:
|
||||||
|
---------
|
||||||
|
{text}
|
||||||
|
---------
|
||||||
|
|
||||||
|
Summarize the text in no more than 3-4 sentences.
|
||||||
|
|
||||||
|
Response:`
|
||||||
|
|
||||||
|
const DEFAULT_REPHRASE_PROMPT = `Rewrite the following text in a different way, maintaining its original meaning but using alternative vocabulary and sentence structures:
|
||||||
|
|
||||||
|
Text:
|
||||||
|
---------
|
||||||
|
{text}
|
||||||
|
---------
|
||||||
|
|
||||||
|
Ensure that your rephrased version conveys the same information and intent as the original.
|
||||||
|
|
||||||
|
Response:`
|
||||||
|
|
||||||
|
const DEFAULT_TRANSLATE_PROMPT = `Translate the following text from its original language into "english"0. Maintain the tone and style of the original text as much as possible:
|
||||||
|
|
||||||
|
Text:
|
||||||
|
---------
|
||||||
|
{text}
|
||||||
|
---------
|
||||||
|
|
||||||
|
Response:`
|
||||||
|
|
||||||
|
const DEFAULT_EXPLAIN_PROMPT = `Provide a detailed explanation of the following text, breaking down its key concepts, implications, and context:
|
||||||
|
|
||||||
|
Text:
|
||||||
|
---------
|
||||||
|
{text}
|
||||||
|
---------
|
||||||
|
|
||||||
|
Your explanation should:
|
||||||
|
|
||||||
|
Clarify any complex terms or ideas
|
||||||
|
Provide relevant background information
|
||||||
|
Discuss the significance or implications of the content
|
||||||
|
Address any potential questions a reader might have
|
||||||
|
Use examples or analogies to illustrate points when appropriate
|
||||||
|
|
||||||
|
Aim for a comprehensive explanation that would help someone with little prior knowledge fully understand the text.
|
||||||
|
|
||||||
|
Response:`
|
||||||
|
|
||||||
|
const DEFAULT_CUSTOM_PROMPT = `{text}`
|
||||||
|
|
||||||
|
export const getSummaryPrompt = async () => {
|
||||||
|
return (await storage.get("copilotSummaryPrompt")) || DEFAULT_SUMMARY_PROMPT
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setSummaryPrompt = async (prompt: string) => {
|
||||||
|
await storage.set("copilotSummaryPrompt", prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRephrasePrompt = async () => {
|
||||||
|
return (await storage.get("copilotRephrasePrompt")) || DEFAULT_REPHRASE_PROMPT
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setRephrasePrompt = async (prompt: string) => {
|
||||||
|
await storage.set("copilotRephrasePrompt", prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTranslatePrompt = async () => {
|
||||||
|
return (
|
||||||
|
(await storage.get("copilotTranslatePrompt")) || DEFAULT_TRANSLATE_PROMPT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setTranslatePrompt = async (prompt: string) => {
|
||||||
|
await storage.set("copilotTranslatePrompt", prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getExplainPrompt = async () => {
|
||||||
|
return (await storage.get("copilotExplainPrompt")) || DEFAULT_EXPLAIN_PROMPT
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setExplainPrompt = async (prompt: string) => {
|
||||||
|
await storage.set("copilotExplainPrompt", prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCustomPrompt = async () => {
|
||||||
|
return (await storage.get("copilotCustomPrompt")) || DEFAULT_CUSTOM_PROMPT
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setCustomPrompt = async (prompt: string) => {
|
||||||
|
await storage.set("copilotCustomPrompt", prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAllCopilotPrompts = async () => {
|
||||||
|
const [
|
||||||
|
summaryPrompt,
|
||||||
|
rephrasePrompt,
|
||||||
|
translatePrompt,
|
||||||
|
explainPrompt,
|
||||||
|
customPrompt
|
||||||
|
] = await Promise.all([
|
||||||
|
getSummaryPrompt(),
|
||||||
|
getRephrasePrompt(),
|
||||||
|
getTranslatePrompt(),
|
||||||
|
getExplainPrompt(),
|
||||||
|
getCustomPrompt()
|
||||||
|
])
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ key: "summary", prompt: summaryPrompt },
|
||||||
|
{ key: "rephrase", prompt: rephrasePrompt },
|
||||||
|
{ key: "translate", prompt: translatePrompt },
|
||||||
|
{ key: "explain", prompt: explainPrompt },
|
||||||
|
{ key: "custom", prompt: customPrompt }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setAllCopilotPrompts = async (
|
||||||
|
prompts: { key: string; prompt: string }[]
|
||||||
|
) => {
|
||||||
|
for (const { key, prompt } of prompts) {
|
||||||
|
switch (key) {
|
||||||
|
case "summary":
|
||||||
|
await setSummaryPrompt(prompt)
|
||||||
|
break
|
||||||
|
case "rephrase":
|
||||||
|
await setRephrasePrompt(prompt)
|
||||||
|
break
|
||||||
|
case "translate":
|
||||||
|
await setTranslatePrompt(prompt)
|
||||||
|
break
|
||||||
|
case "explain":
|
||||||
|
await setExplainPrompt(prompt)
|
||||||
|
break
|
||||||
|
case "custom":
|
||||||
|
await setCustomPrompt(prompt)
|
||||||
|
break
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPrompt = async (key: string) => {
|
||||||
|
switch (key) {
|
||||||
|
case "summary":
|
||||||
|
return await getSummaryPrompt()
|
||||||
|
case "rephrase":
|
||||||
|
return await getRephrasePrompt()
|
||||||
|
case "translate":
|
||||||
|
return await getTranslatePrompt()
|
||||||
|
case "explain":
|
||||||
|
return await getExplainPrompt()
|
||||||
|
case "custom":
|
||||||
|
return await getCustomPrompt()
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
@ -12,6 +12,7 @@ export type ChatHistory = {
|
|||||||
role: "user" | "assistant" | "system"
|
role: "user" | "assistant" | "system"
|
||||||
content: string
|
content: string
|
||||||
image?: string
|
image?: string
|
||||||
|
messageType?: string
|
||||||
}[]
|
}[]
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
|
@ -18,12 +18,14 @@ export type Message = {
|
|||||||
images?: string[]
|
images?: string[]
|
||||||
search?: WebSearch
|
search?: WebSearch
|
||||||
id?: string
|
id?: string
|
||||||
|
messageType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChatHistory = {
|
export type ChatHistory = {
|
||||||
role: "user" | "assistant" | "system"
|
role: "user" | "assistant" | "system"
|
||||||
content: string
|
content: string
|
||||||
image?: string
|
image?: string,
|
||||||
|
messageType?: string
|
||||||
}[]
|
}[]
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
|
@ -14,5 +14,6 @@ type WebSearch = {
|
|||||||
sources: any[]
|
sources: any[]
|
||||||
images?: string[]
|
images?: string[]
|
||||||
search?: WebSearch
|
search?: WebSearch
|
||||||
|
messageType?: string
|
||||||
id?: string
|
id?: string
|
||||||
}
|
}
|
77
src/utils/pull-ollama.ts
Normal file
77
src/utils/pull-ollama.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { setBadgeBackgroundColor, setBadgeText, setTitle } from "@/utils/action"
|
||||||
|
|
||||||
|
|
||||||
|
export const progressHuman = (completed: number, total: number) => {
|
||||||
|
return ((completed / total) * 100).toFixed(0) + "%"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearBadge = () => {
|
||||||
|
setBadgeText({ text: "" })
|
||||||
|
setTitle({ title: "" })
|
||||||
|
}
|
||||||
|
export 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) {
|
||||||
|
if (!reader) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
setBadgeText({
|
||||||
|
text: progressHuman(json.completed, json.total)
|
||||||
|
})
|
||||||
|
setBadgeBackgroundColor({ color: "#0000FF" })
|
||||||
|
} else {
|
||||||
|
setBadgeText({ text: "🏋️♂️" })
|
||||||
|
setBadgeBackgroundColor({ color: "#FFFFFF" })
|
||||||
|
}
|
||||||
|
|
||||||
|
setTitle({ title: json.status })
|
||||||
|
|
||||||
|
if (json.status === "success") {
|
||||||
|
isSuccess = true
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
setBadgeText({ text: "✅" })
|
||||||
|
setBadgeBackgroundColor({ color: "#00FF00" })
|
||||||
|
setTitle({ title: "Model pulled successfully" })
|
||||||
|
} else {
|
||||||
|
setBadgeText({ text: "❌" })
|
||||||
|
setBadgeBackgroundColor({ color: "#FF0000" })
|
||||||
|
setTitle({ title: "Model pull failed" })
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
clearBadge()
|
||||||
|
}, 5000)
|
||||||
|
}
|
@ -50,7 +50,7 @@ export default defineConfig({
|
|||||||
outDir: "build",
|
outDir: "build",
|
||||||
|
|
||||||
manifest: {
|
manifest: {
|
||||||
version: "1.1.16",
|
version: "1.2.0",
|
||||||
name:
|
name:
|
||||||
process.env.TARGET === "firefox"
|
process.env.TARGET === "firefox"
|
||||||
? "Page Assist - A Web UI for Local AI Models"
|
? "Page Assist - A Web UI for Local AI Models"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user