feat: OpenAI settings page

Add a new settings page for OpenAI configuration, including a dedicated tab in the settings layout, translations, and routing.
This commit is contained in:
n4ze3m 2024-09-28 16:08:02 +05:30
parent 2e97f6470d
commit e2e3655c47
8 changed files with 436 additions and 9 deletions

View File

@ -0,0 +1,38 @@
{
"settings": "OpenAI API Settings",
"heading": "OpenAI API Settings",
"subheading": "Manage and configure your OpenAI API Compatible providers here.",
"addBtn": "Add Provider",
"table": {
"name": "Provider Name",
"baseUrl": "Base URL",
"actions": "Action"
},
"modal": {
"titleAdd": "Add New Provider",
"name": {
"label": "Provider Name",
"required": "Provider name is required.",
"placeholder": "Enter provider name"
},
"baseUrl": {
"label": "Base URL",
"help": "The base URL of the OpenAI API provider. eg (http://loocalhost:8080/v1)",
"required": "Base URL is required.",
"placeholder": "Enter base URL"
},
"apiKey": {
"label": "API Key",
"required": "API Key is required.",
"placeholder": "Enter API Key"
},
"submit": "Submit",
"update": "Update",
"deleteConfirm": "Are you sure you want to delete this provider?"
},
"addSuccess": "Provider added successfully.",
"deleteSuccess": "Provider deleted successfully.",
"updateSuccess": "Provider updated successfully.",
"delete": "Delete",
"edit": "Edit"
}

View File

@ -6,12 +6,12 @@ import {
BlocksIcon, BlocksIcon,
InfoIcon, InfoIcon,
CombineIcon, CombineIcon,
ChromeIcon ChromeIcon,
CloudCogIcon
} from "lucide-react" } from "lucide-react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Link, useLocation } from "react-router-dom" import { Link, useLocation } from "react-router-dom"
import { OllamaIcon } from "../Icons/Ollama" import { OllamaIcon } from "../Icons/Ollama"
import { Tag } from "antd"
import { BetaTag } from "../Common/Beta" import { BetaTag } from "../Common/Beta"
function classNames(...classes: string[]) { function classNames(...classes: string[]) {
@ -22,12 +22,11 @@ const LinkComponent = (item: {
href: string href: string
name: string | JSX.Element name: string | JSX.Element
icon: any icon: any
current: string, current: string
beta?: boolean beta?: boolean
}) => { }) => {
return ( return (
<li className="inline-flex items-center"> <li className="inline-flex items-center">
<Link <Link
to={item.href} to={item.href}
className={classNames( className={classNames(
@ -47,16 +46,14 @@ const LinkComponent = (item: {
/> />
{item.name} {item.name}
</Link> </Link>
{ {item.beta && <BetaTag />}
item.beta && <BetaTag />
}
</li> </li>
) )
} }
export const SettingsLayout = ({ children }: { children: React.ReactNode }) => { export const SettingsLayout = ({ children }: { children: React.ReactNode }) => {
const location = useLocation() const location = useLocation()
const { t } = useTranslation(["settings", "common"]) const { t } = useTranslation(["settings", "common", "openai"])
return ( return (
<> <>
@ -93,6 +90,13 @@ export const SettingsLayout = ({ children }: { children: React.ReactNode }) => {
beta beta
/> />
)} )}
<LinkComponent
href="/settings/openai"
name={t("openai:settings")}
icon={CloudCogIcon}
current={location.pathname}
beta
/>
<LinkComponent <LinkComponent
href="/settings/model" href="/settings/model"
name={t("manageModels.title")} name={t("manageModels.title")}

View File

@ -0,0 +1,218 @@
import { Form, Input, Modal, Table, message, Tooltip } from "antd"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import {
addOpenAICofig,
getAllOpenAIConfig,
deleteOpenAIConfig,
updateOpenAIConfig
} from "@/db/openai"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Pencil, Trash2, Plus } from "lucide-react"
export const OpenAIApp = () => {
const { t } = useTranslation("openai")
const [open, setOpen] = useState(false)
const [editingConfig, setEditingConfig] = useState(null)
const queryClient = useQueryClient()
const [form] = Form.useForm()
const { data: configs, isLoading } = useQuery({
queryKey: ["openAIConfigs"],
queryFn: getAllOpenAIConfig
})
const addMutation = useMutation({
mutationFn: addOpenAICofig,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["openAIConfigs"]
})
setOpen(false)
message.success(t("addSuccess"))
}
})
const updateMutation = useMutation({
mutationFn: updateOpenAIConfig,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["openAIConfigs"]
})
setOpen(false)
message.success(t("updateSuccess"))
}
})
const deleteMutation = useMutation({
mutationFn: deleteOpenAIConfig,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["openAIConfigs"]
})
message.success(t("deleteSuccess"))
}
})
const handleSubmit = (values: {
id?: string
name: string
baseUrl: string
apiKey: string
}) => {
if (editingConfig) {
updateMutation.mutate({ id: editingConfig.id, ...values })
} else {
addMutation.mutate(values)
}
}
const handleEdit = (record: any) => {
setEditingConfig(record)
setOpen(true)
form.setFieldsValue(record)
}
const handleDelete = (id: string) => {
deleteMutation.mutate(id)
}
return (
<div>
<div>
<div>
<h2 className="text-base font-semibold leading-7 text-gray-900 dark:text-white">
{t("heading")}
</h2>
<p className="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
{t("subheading")}
</p>
<div className="border border-b border-gray-200 dark:border-gray-600 mt-3 mb-6"></div>
</div>
<div className="mb-6">
<div className="-ml-4 -mt-2 flex flex-wrap items-center justify-end sm:flex-nowrap">
<div className="ml-4 mt-2 flex-shrink-0">
<button
onClick={() => {
setEditingConfig(null)
setOpen(true)
form.resetFields()
}}
className="inline-flex items-center rounded-md border border-transparent bg-black px-2 py-2 text-md 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">
{t("addBtn")}
</button>
</div>
</div>
</div>
<Table
columns={[
{
title: t("table.name"),
dataIndex: "name",
key: "name"
},
{
title: t("table.baseUrl"),
dataIndex: "baseUrl",
key: "baseUrl"
},
{
title: t("table.actions"),
key: "actions",
render: (_, record) => (
<div className="flex gap-4">
<Tooltip title={t("edit")}>
<button
className="text-gray-700 dark:text-gray-400"
onClick={() => handleEdit(record)}>
<Pencil className="size-4" />
</button>
</Tooltip>
<Tooltip title={t("delete")}>
<button
className="text-red-500 dark:text-red-400"
onClick={() => {
// add confirmation here
if (
confirm(
t("modal.deleteConfirm", {
name: record.name
})
)
) {
handleDelete(record.id)
}
}}>
<Trash2 className="size-4" />
</button>
</Tooltip>
</div>
)
}
]}
dataSource={configs}
loading={isLoading}
rowKey="id"
/>
<Modal
open={open}
title={editingConfig ? t("modal.titleEdit") : t("modal.titleAdd")}
onCancel={() => {
setOpen(false)
setEditingConfig(null)
form.resetFields()
}}
footer={null}>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={editingConfig}>
<Form.Item
name="name"
label={t("modal.name.label")}
rules={[
{
required: true,
message: t("modal.name.required")
}
]}>
<Input size="large" placeholder={t("modal.name.placeholder")} />
</Form.Item>
<Form.Item
name="baseUrl"
label={t("modal.baseUrl.label")}
help={t("modal.baseUrl.help")}
rules={[
{
required: true,
message: t("modal.baseUrl.required")
}
]}>
<Input
size="large"
placeholder={t("modal.baseUrl.placeholder")}
/>
</Form.Item>
<Form.Item name="apiKey" label={t("modal.apiKey.label")}>
<Input.Password
size="large"
placeholder={t("modal.apiKey.placeholder")}
/>
</Form.Item>
<button
type="submit"
className="inline-flex justify-center w-full text-center 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 hover:bg-gray-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">
{editingConfig ? t("modal.update") : t("modal.submit")}
</button>
</Form>
</Modal>
</div>
</div>
)
}

146
src/db/openai.ts Normal file
View File

@ -0,0 +1,146 @@
type OpenAIModelConfig = {
id: string
name: string
baseUrl: string
apiKey?: string
createdAt: number
}
export const generateID = () => {
return "openai-xxxx-xxx-xxxx".replace(/[x]/g, () => {
const r = Math.floor(Math.random() * 16)
return r.toString(16)
})
}
export class OpenAIModelDb {
db: chrome.storage.StorageArea
constructor() {
this.db = chrome.storage.local
}
getAll = async (): Promise<OpenAIModelConfig[]> => {
return new Promise((resolve, reject) => {
this.db.get(null, (result) => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError)
} else {
const data = Object.keys(result).map((key) => result[key])
resolve(data)
}
})
})
}
create = async (config: OpenAIModelConfig): Promise<void> => {
return new Promise((resolve, reject) => {
this.db.set({ [config.id]: config }, () => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError)
} else {
resolve()
}
})
})
}
getById = async (id: string): Promise<OpenAIModelConfig> => {
return new Promise((resolve, reject) => {
this.db.get(id, (result) => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError)
} else {
resolve(result[id])
}
})
})
}
update = async (config: OpenAIModelConfig): Promise<void> => {
return new Promise((resolve, reject) => {
this.db.set({ [config.id]: config }, () => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError)
} else {
resolve()
}
})
})
}
delete = async (id: string): Promise<void> => {
return new Promise((resolve, reject) => {
this.db.remove(id, () => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError)
} else {
resolve()
}
})
})
}
}
export const addOpenAICofig = async ({ name, baseUrl, apiKey }: { name: string, baseUrl: string, apiKey: string }) => {
const openaiDb = new OpenAIModelDb()
const id = generateID()
const config: OpenAIModelConfig = {
id,
name,
baseUrl,
apiKey,
createdAt: Date.now()
}
await openaiDb.create(config)
return id
}
export const getAllOpenAIConfig = async () => {
const openaiDb = new OpenAIModelDb()
const configs = await openaiDb.getAll()
return configs
}
export const updateOpenAIConfig = async ({ id, name, baseUrl, apiKey }: { id: string, name: string, baseUrl: string, apiKey: string }) => {
const openaiDb = new OpenAIModelDb()
const config: OpenAIModelConfig = {
id,
name,
baseUrl,
apiKey,
createdAt: Date.now()
}
await openaiDb.update(config)
return config
}
export const deleteOpenAIConfig = async (id: string) => {
const openaiDb = new OpenAIModelDb()
await openaiDb.delete(id)
}
export const updateOpenAIConfigApiKey = async (id: string, { name, baseUrl, apiKey }: { name: string, baseUrl: string, apiKey: string }) => {
const openaiDb = new OpenAIModelDb()
const config: OpenAIModelConfig = {
id,
name,
baseUrl,
apiKey,
createdAt: Date.now()
}
await openaiDb.update(config)
}

View File

@ -5,6 +5,7 @@ import sidepanel from "@/assets/locale/en/sidepanel.json";
import settings from "@/assets/locale/en/settings.json"; import settings from "@/assets/locale/en/settings.json";
import knowledge from "@/assets/locale/en/knowledge.json"; import knowledge from "@/assets/locale/en/knowledge.json";
import chrome from "@/assets/locale/en/chrome.json"; import chrome from "@/assets/locale/en/chrome.json";
import openai from "@/assets/locale/en/openai.json";
export const en = { export const en = {
option, option,
@ -13,5 +14,6 @@ export const en = {
sidepanel, sidepanel,
settings, settings,
knowledge, knowledge,
chrome chrome,
openai
} }

View File

@ -11,6 +11,7 @@ import SidepanelChat from "./sidepanel-chat"
import SidepanelSettings from "./sidepanel-settings" import SidepanelSettings from "./sidepanel-settings"
import OptionRagSettings from "./option-rag" import OptionRagSettings from "./option-rag"
import OptionChrome from "./option-settings-chrome" import OptionChrome from "./option-settings-chrome"
import OptionOpenAI from "./option-settings-openai"
export const OptionRoutingChrome = () => { export const OptionRoutingChrome = () => {
return ( return (
@ -21,6 +22,7 @@ export const OptionRoutingChrome = () => {
<Route path="/settings/prompt" element={<OptionPrompt />} /> <Route path="/settings/prompt" element={<OptionPrompt />} />
<Route path="/settings/ollama" element={<OptionOllamaSettings />} /> <Route path="/settings/ollama" element={<OptionOllamaSettings />} />
<Route path="/settings/chrome" element={<OptionChrome />} /> <Route path="/settings/chrome" element={<OptionChrome />} />
<Route path="/settings/openai" element={<OptionOpenAI />} />
<Route path="/settings/share" element={<OptionShare />} /> <Route path="/settings/share" element={<OptionShare />} />
<Route path="/settings/knowledge" element={<OptionKnowledgeBase />} /> <Route path="/settings/knowledge" element={<OptionKnowledgeBase />} />
<Route path="/settings/rag" element={<OptionRagSettings />} /> <Route path="/settings/rag" element={<OptionRagSettings />} />

View File

@ -14,6 +14,7 @@ const OptionShare = lazy(() => import("./option-settings-share"))
const OptionKnowledgeBase = lazy(() => import("./option-settings-knowledge")) const OptionKnowledgeBase = lazy(() => import("./option-settings-knowledge"))
const OptionAbout = lazy(() => import("./option-settings-about")) const OptionAbout = lazy(() => import("./option-settings-about"))
const OptionRagSettings = lazy(() => import("./option-rag")) const OptionRagSettings = lazy(() => import("./option-rag"))
const OptionOpenAI = lazy(() => import("./option-settings-openai"))
export const OptionRoutingFirefox = () => { export const OptionRoutingFirefox = () => {
return ( return (
@ -23,6 +24,7 @@ export const OptionRoutingFirefox = () => {
<Route path="/settings/model" element={<OptionModal />} /> <Route path="/settings/model" element={<OptionModal />} />
<Route path="/settings/prompt" element={<OptionPrompt />} /> <Route path="/settings/prompt" element={<OptionPrompt />} />
<Route path="/settings/ollama" element={<OptionOllamaSettings />} /> <Route path="/settings/ollama" element={<OptionOllamaSettings />} />
<Route path="/settings/openai" element={<OptionOpenAI />} />
<Route path="/settings/share" element={<OptionShare />} /> <Route path="/settings/share" element={<OptionShare />} />
<Route path="/settings/knowledge" element={<OptionKnowledgeBase />} /> <Route path="/settings/knowledge" element={<OptionKnowledgeBase />} />
<Route path="/settings/about" element={<OptionAbout />} /> <Route path="/settings/about" element={<OptionAbout />} />

View File

@ -0,0 +1,15 @@
import { SettingsLayout } from "~/components/Layouts/SettingsOptionLayout"
import OptionLayout from "~/components/Layouts/Layout"
import { OpenAIApp } from "@/components/Option/Settings/openai"
const OptionOpenAI = () => {
return (
<OptionLayout>
<SettingsLayout>
<OpenAIApp />
</SettingsLayout>
</OptionLayout>
)
}
export default OptionOpenAI