feat: add ai setting panel

This commit is contained in:
0xjojo1
2025-05-16 16:53:25 +08:00
parent 404a83dafc
commit 5947cdc91e
30 changed files with 4665 additions and 250 deletions

View File

@@ -5,18 +5,9 @@ import { createGoogleGenerativeAI } from '@ai-sdk/google'
import { createOpenAI } from '@ai-sdk/openai'
import { createPerplexity, perplexity } from '@ai-sdk/perplexity'
import { geolocation } from '@vercel/functions'
import {
appendClientMessage,
appendResponseMessages,
convertToCoreMessages,
createDataStreamResponse,
smoothStream,
streamText,
} from 'ai'
import { appendClientMessage, streamText } from 'ai'
import { NextResponse } from 'next/server'
import { isProd } from '@penx/constants'
import { AIProviderType, IMessage } from '@penx/model-type'
import { uniqueId } from '@penx/unique-id'
import { LLMProviderTypeEnum } from '@penx/types'
import { RequestHints, systemPrompt } from './prompts'
export const maxDuration = 60
@@ -41,10 +32,10 @@ export async function POST(request: Request) {
const input: Input = await request.json()
try {
const { id, message, selectedChatModel, system } = input
const { id, message, provider, selectedChatModel, system } = input
if (!input.apiKey) {
throw new Error('Please provide an API key')
if (!provider) {
throw new Error('Please select a provider')
}
const previousMessages: any[] = []
@@ -67,42 +58,53 @@ export async function POST(request: Request) {
country,
}
if (input.provider === AIProviderType.GOOGLE_AI) {
if (input.provider === LLMProviderTypeEnum.GOOGLE) {
const google = createGoogleGenerativeAI({
apiKey: input.apiKey || process.env.GOOGLE_AI_API_KEY,
})
return generate(input, google('gemini-1.5-flash'))
return generate(
input,
google(input.selectedChatModel || 'gemini-1.5-flash'),
)
}
if (input.provider === AIProviderType.DEEPSEEK) {
if (input.provider === LLMProviderTypeEnum.DEEPSEEK) {
const deepseek = createDeepSeek({
apiKey: input.apiKey,
})
return generate(input, deepseek('deepseek-chat'))
return generate(
input,
deepseek(input.selectedChatModel || 'deepseek-chat'),
)
}
if (input.provider === AIProviderType.ANTHROPIC) {
if (input.provider === LLMProviderTypeEnum.ANTHROPIC) {
const anthropic = createAnthropic({
apiKey: input.apiKey,
})
return generate(input, anthropic('claude-3-haiku-20240307'))
return generate(
input,
anthropic(input.selectedChatModel || 'claude-3-haiku-20240307'),
)
}
if (input.provider === AIProviderType.OPENAI) {
if (
input.provider === LLMProviderTypeEnum.OPENAI ||
input.provider === LLMProviderTypeEnum.OPENAI_COMPATIBLE
) {
const openai = createOpenAI({
apiKey: input.apiKey,
baseURL: input.baseURL,
})
return generate(input, openai('gpt-4o-mini'))
return generate(input, openai(input.selectedChatModel || 'gpt-4o-mini'))
}
if (input.provider === AIProviderType.PERPLEXITY) {
console.log('pp...:', process.env.PERPLEXITY_API_KEY)
if (input.provider === LLMProviderTypeEnum.PERPLEXITY) {
const perplexity = createPerplexity({
apiKey: input.apiKey,
})
return generate(input, perplexity('sonar-pro'))
return generate(input, perplexity(input.selectedChatModel || 'sonar-pro'))
}
} catch (error) {
console.log('====error:', error)
@@ -117,7 +119,7 @@ export async function POST(request: Request) {
async function generate(input: Input, llm: any) {
const {
provider = AIProviderType.OPENAI,
provider = LLMProviderTypeEnum.OPENAI,
apiKey: key,
system,
message,

View File

@@ -45,6 +45,7 @@
"@lingui/core": "5.3.1",
"@lingui/macro": "^5.3.1",
"@lingui/react": "5.3.1",
"@lobehub/icons": "^2.0.0",
"@octokit/app": "^15.1.1",
"@octokit/auth-app": "^7.1.3",
"@octokit/core": "^6.1.2",

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useChat } from '@ai-sdk/react'
import type { Attachment, UIMessage } from 'ai'
import { useSearchParams } from 'next/navigation'
@@ -29,20 +29,33 @@ interface ApplicationError extends Error {
export function Chat({
id,
initialMessages,
selectedChatModel,
selectedVisibilityType,
isReadonly,
session,
}: {
id: string
initialMessages: Array<UIMessage>
selectedChatModel: string
selectedVisibilityType: VisibilityType
isReadonly: boolean
session: SessionData
}) {
const { site } = useMySite()
const provider = site.aiProviders?.find((p) => p.enabled)
const provider = site?.props?.aiSetting?.providers?.[0]
// Track the selected provider and model
const [selectedProvider, setSelectedProvider] = useState(provider?.type || '')
const [selectedModel, setSelectedModel] = useState(
provider?.defaultModel || '',
)
// Refs to store latest values for the closure in generateId
const selectedProviderRef = useRef(selectedProvider)
const selectedModelRef = useRef(selectedModel)
// Update refs when state changes
useEffect(() => {
selectedProviderRef.current = selectedProvider
selectedModelRef.current = selectedModel
}, [selectedProvider, selectedModel])
const {
messages,
@@ -60,14 +73,22 @@ export function Chat({
experimental_throttle: 100,
sendExtraMessageFields: true,
generateId: uniqueId,
experimental_prepareRequestBody: (body) => ({
id,
message: body.messages.at(-1),
selectedChatModel: '',
provider: provider?.type,
apiKey: provider?.apiKey,
baseURL: provider?.baseURL,
}),
experimental_prepareRequestBody: (body) => {
// Find the current provider based on selected type
const currentProvider = site?.props?.aiSetting?.providers?.find(
(p) => p.type === selectedProviderRef.current,
)
return {
id,
message: body.messages.at(-1),
selectedChatModel:
selectedModelRef.current || currentProvider?.defaultModel,
provider: selectedProviderRef.current || provider?.type,
apiKey: currentProvider?.apiKey || provider?.apiKey,
baseURL: currentProvider?.baseURL || provider?.baseURL,
}
},
onFinish: async (message, options) => {
await localDB.message.add({
id: uniqueId(),
@@ -96,7 +117,7 @@ export function Chat({
})
if (error.message === 'Please provide an API key') {
store.panels.addPanel({
type: PanelType.AI_PROVIDERS,
type: PanelType.AI_SETTING,
})
const messages = await queryMessages(session.siteId)
@@ -110,6 +131,15 @@ export function Chat({
const [attachments, setAttachments] = useState<Array<Attachment>>([])
const isArtifactVisible = useArtifactSelector((state) => state.isVisible)
// Handle provider and model selection
const handleProviderChange = (providerType: string) => {
setSelectedProvider(providerType)
}
const handleModelChange = (modelId: string) => {
setSelectedModel(modelId)
}
return (
<>
<div className="flex h-full min-w-0 flex-col">
@@ -137,26 +167,14 @@ export function Chat({
messages={messages}
setMessages={setMessages}
append={append}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
onProviderChange={handleProviderChange}
onModelChange={handleModelChange}
/>
)}
</form>
</div>
{/* <Artifact
chatId={id}
input={input}
setInput={setInput}
handleSubmit={handleSubmit}
status={status}
stop={stop}
attachments={attachments}
setAttachments={setAttachments}
append={append}
messages={messages}
setMessages={setMessages}
reload={reload}
isReadonly={isReadonly}
/> */}
</>
)
}

View File

@@ -25,7 +25,7 @@ import { Textarea } from '@penx/uikit/textarea'
import { ArrowUpIcon, PaperclipIcon, StopIcon } from './icons'
import { KnowledgeSourcePicker } from './knowledge-source-picker'
import { PreviewAttachment } from './preview-attachment'
import { SuggestedActions } from './suggested-actions'
import { ProviderSelector } from './provider-selector'
function PureMultimodalInput({
chatId,
@@ -40,6 +40,10 @@ function PureMultimodalInput({
append,
handleSubmit,
className,
selectedProvider,
selectedModel,
onProviderChange,
onModelChange,
}: {
chatId: string
input: UseChatHelpers['input']
@@ -53,10 +57,23 @@ function PureMultimodalInput({
append: UseChatHelpers['append']
handleSubmit: UseChatHelpers['handleSubmit']
className?: string
selectedProvider?: string
selectedModel?: string
onProviderChange?: (providerType: string) => void
onModelChange?: (modelId: string) => void
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const { width } = useWindowSize()
const handleProviderChange = (providerType: string) => {
onProviderChange?.(providerType)
}
const handleModelChange = (modelId: string) => {
onModelChange?.(modelId)
console.log('Selected model:', modelId)
}
useEffect(() => {
if (textareaRef.current) {
adjustHeight()
@@ -103,9 +120,15 @@ function PureMultimodalInput({
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploadQueue, setUploadQueue] = useState<Array<string>>([])
const submitForm = useCallback(() => {
const submitWithModel = useCallback(() => {
handleSubmit(undefined, {
experimental_attachments: attachments,
data: selectedProvider
? {
provider: selectedProvider,
model: selectedModel,
}
: undefined,
})
setAttachments([])
@@ -122,6 +145,8 @@ function PureMultimodalInput({
setLocalStorageInput,
width,
chatId,
selectedProvider,
selectedModel,
])
const uploadFile = async (file: File) => {
@@ -179,12 +204,6 @@ function PureMultimodalInput({
return (
<div className="relative flex w-full flex-col gap-4">
{/* {messages.length === 0 &&
attachments.length === 0 &&
uploadQueue.length === 0 && (
<SuggestedActions append={append} chatId={chatId} />
)} */}
<input
type="file"
className="pointer-events-none fixed -left-4 -top-4 size-0.5 opacity-0"
@@ -238,7 +257,7 @@ function PureMultimodalInput({
if (status !== 'ready') {
toast.error('Please wait for the model to finish its response!')
} else {
submitForm()
submitWithModel()
}
}
}}
@@ -252,12 +271,16 @@ function PureMultimodalInput({
type="button"
onClick={() => {
store.panels.addPanel({
type: PanelType.AI_PROVIDERS,
type: PanelType.AI_SETTING,
})
}}
>
<CogIcon size={16} />
</Button>
<ProviderSelector
onProviderChange={onProviderChange}
onModelChange={onModelChange}
/>
</div>
<div className="absolute bottom-0 right-0 flex w-fit flex-row justify-end p-2">
<KnowledgeSourcePicker />
@@ -267,7 +290,7 @@ function PureMultimodalInput({
) : (
<SendButton
input={input}
submitForm={submitForm}
submitForm={submitWithModel}
uploadQueue={uploadQueue}
/>
)}

View File

@@ -0,0 +1,219 @@
import { useEffect, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { useMySite } from '@penx/hooks/useMySite'
import { AIProvider } from '@penx/model-type'
import { LLMProviderType, LLMProviderTypeEnum } from '@penx/types'
import { Button } from '@penx/uikit/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@penx/uikit/dropdown-menu'
import { ProviderIcon } from '../AISetting/icons'
interface ProviderSelectorProps {
onProviderChange?: (providerType: string) => void
onModelChange?: (modelId: string) => void
initialProvider?: string
initialModel?: string
}
export function ProviderSelector({
onProviderChange,
onModelChange,
initialProvider = '',
initialModel = '',
}: ProviderSelectorProps) {
const { site } = useMySite()
const providers = site?.props?.aiSetting?.providers || []
// Use initialProvider/initialModel if provided, otherwise use first provider/model
const [selectedProvider, setSelectedProvider] = useState<
LLMProviderType | string
>(initialProvider || providers[0]?.type || '')
const [selectedModel, setSelectedModel] = useState<string>(
initialModel ||
providers.find((p) => p.type === initialProvider)?.defaultModel ||
providers[0]?.defaultModel ||
'',
)
// Update state if providers change or initial values are set
useEffect(() => {
if (providers.length > 0) {
if (!selectedProvider) {
const newProvider = initialProvider || providers[0]?.type || ''
setSelectedProvider(newProvider)
// Also update model if provider changes
const provider = providers.find((p) => p.type === newProvider)
const newModel = initialModel || provider?.defaultModel || ''
if (newModel) setSelectedModel(newModel)
}
}
}, [providers, initialProvider, initialModel])
if (providers.length === 0) {
return null
}
// Notify parent components when selection changes
useEffect(() => {
if (selectedProvider && onProviderChange) {
onProviderChange(selectedProvider)
}
}, [selectedProvider, onProviderChange])
useEffect(() => {
if (selectedModel && onModelChange) {
onModelChange(selectedModel)
}
}, [selectedModel, onModelChange])
const getSelectedProviderName = () => {
const provider = providers.find((p) => p.type === selectedProvider)
return provider?.name || selectedProvider
}
const getProvider = (type: string): AIProvider | undefined => {
return providers.find((p) => p.type === type)
}
const handleSelectProvider = (providerType: string) => {
if (providerType === selectedProvider) return
setSelectedProvider(providerType as LLMProviderType)
// Auto-select first model or default model when provider changes
const provider = getProvider(providerType)
if (provider) {
if (
provider.defaultModel &&
provider.availableModels?.includes(provider.defaultModel)
) {
setSelectedModel(provider.defaultModel)
} else if (provider.availableModels?.length) {
setSelectedModel(provider.availableModels[0])
} else {
setSelectedModel('')
}
} else {
setSelectedModel('')
}
}
const handleSelectModel = (modelId: string) => {
setSelectedModel(modelId)
}
const displayName = selectedModel
? `${getSelectedProviderName()} / ${selectedModel}`
: getSelectedProviderName()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 max-w-32 gap-1 truncate rounded-md text-xs"
>
<ProviderIcon
llmProviderType={selectedProvider as LLMProviderType}
className="mr-1 shrink-0"
/>
<span className="truncate">{displayName}</span>
<ChevronDown size={14} className="ml-1 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuLabel>Model Selection</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{providers.map((provider) => {
const hasModels =
provider.availableModels && provider.availableModels.length > 0
if (hasModels) {
return (
<DropdownMenuSub key={provider.type}>
<DropdownMenuSubTrigger
className={
selectedProvider === provider.type ? 'bg-accent' : ''
}
>
<div className="flex items-center">
<ProviderIcon
llmProviderType={provider.type as LLMProviderType}
className="mr-2"
/>
<span>{provider.name || provider.type}</span>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{provider.availableModels?.map((model: string) => (
<DropdownMenuItem
key={model}
className={
selectedProvider === provider.type &&
selectedModel === model
? 'bg-accent'
: ''
}
onClick={() => {
handleSelectProvider(provider.type)
handleSelectModel(model)
}}
>
<div className="flex items-center gap-2">
{selectedProvider === provider.type &&
selectedModel === model && (
<span className="icon-[mdi--check] text-primary size-4"></span>
)}
<span>{model}</span>
{model === provider.defaultModel && (
<span className="text-muted-foreground ml-auto text-xs">
(Default)
</span>
)}
</div>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
)
}
return (
<DropdownMenuItem
key={provider.type}
className={
selectedProvider === provider.type ? 'bg-accent' : ''
}
onClick={() => handleSelectProvider(provider.type)}
>
<div className="flex items-center">
<ProviderIcon
llmProviderType={provider.type as LLMProviderType}
className="mr-2"
/>
<span>{provider.name || provider.type}</span>
</div>
</DropdownMenuItem>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,65 @@
import {
Anthropic,
DeepSeek,
Google,
OpenAI,
Perplexity,
XAI,
} from '@lobehub/icons'
import { LLMProviderType, LLMProviderTypeEnum } from '@penx/types'
interface ProviderIconProps {
llmProviderType: LLMProviderType
className?: string
size?: number
}
export function ProviderIcon({
llmProviderType,
className = '',
size,
}: ProviderIconProps) {
const sizeClass = size ? `size-${size}` : 'size-4'
const defaultClass = `${sizeClass} ${className}`
switch (llmProviderType) {
case LLMProviderTypeEnum.ANTHROPIC:
return <Anthropic className={defaultClass} />
case LLMProviderTypeEnum.DEEPSEEK:
return <DeepSeek className={defaultClass} />
case LLMProviderTypeEnum.GOOGLE:
return <Google className={defaultClass} />
case LLMProviderTypeEnum.OPENAI:
return <OpenAI className={defaultClass} />
case LLMProviderTypeEnum.OPENAI_COMPATIBLE:
return <OpenAI className={defaultClass} />
case LLMProviderTypeEnum.PERPLEXITY:
return <Perplexity className={defaultClass} />
case LLMProviderTypeEnum.XAI:
return <XAI className={defaultClass} />
default:
return null
}
}
interface ProviderTitleProps {
llmProviderType: LLMProviderType
className?: string
iconClassName?: string
}
export function ProviderTitle({
llmProviderType,
className = '',
iconClassName = 'mr-2',
}: ProviderTitleProps) {
return (
<div className={`flex items-center gap-2 ${className}`}>
<ProviderIcon
llmProviderType={llmProviderType}
className={iconClassName}
/>
<span>{llmProviderType}</span>
</div>
)
}

View File

@@ -0,0 +1,14 @@
import { Card, CardContent, CardHeader, CardTitle } from '@penx/uikit/card'
export function KnowledgeSetting() {
return (
<Card>
<CardHeader>
<CardTitle>Your Knowledge</CardTitle>
</CardHeader>
<CardContent>
<div>KnowledgeSetting</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,173 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Label } from '@penx/uikit/label'
interface ModelSelectorProps {
selectedModels: string[]
onChange: (models: string[]) => void
availableModels: string[]
isLoadingModels: boolean
modelError: string
}
export function ModelSelector({
selectedModels,
onChange,
availableModels,
isLoadingModels,
modelError,
}: ModelSelectorProps) {
const [showModelDropdown, setShowModelDropdown] = useState(false)
const [isDropdownClosing, setIsDropdownClosing] = useState(false)
const modelDropdownRef = useRef<HTMLDivElement>(null)
// Function to handle dropdown closing
const closeDropdown = () => {
setIsDropdownClosing(true)
// Close the dropdown after animation duration
setTimeout(() => {
setShowModelDropdown(false)
setIsDropdownClosing(false)
}, 150) // Match with CSS transition time
}
// Handle click outside to close dropdown
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
modelDropdownRef.current &&
!modelDropdownRef.current.contains(event.target as Node)
) {
if (showModelDropdown) {
closeDropdown()
}
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [showModelDropdown])
const toggleModel = (model: string) => {
onChange(
selectedModels.includes(model)
? selectedModels.filter((m) => m !== model)
: [...selectedModels, model],
)
}
const removeModel = (model: string) => {
onChange(selectedModels.filter((m) => m !== model))
}
return (
<div className="relative mt-2" ref={modelDropdownRef}>
<Label htmlFor="model" className="font-medium">
Models
</Label>
<div
className="focus-within:ring-primary mt-2 flex min-h-10 cursor-pointer flex-wrap gap-2 rounded-md border p-2 focus-within:ring-1"
onClick={() => !showModelDropdown && setShowModelDropdown(true)}
>
{selectedModels.length > 0 ? (
selectedModels.map((model) => (
<div
key={model}
className="bg-muted/50 border-muted-foreground/20 flex items-center rounded-md border py-1 pl-2 pr-1 text-sm"
>
<span className="max-w-[200px] truncate">{model}</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground hover:bg-muted ml-1 rounded-full"
onClick={(e) => {
e.stopPropagation()
removeModel(model)
}}
>
<span className="icon-[mdi--close] flex size-4 items-center justify-center"></span>
</button>
</div>
))
) : (
<div className="text-muted-foreground text-sm">Select models</div>
)}
</div>
{showModelDropdown && (
<div
className={`bg-background absolute z-10 mt-1 max-h-60 w-full transform overflow-hidden rounded-md border shadow-lg transition-all duration-150 ${
isDropdownClosing ? 'scale-95 opacity-0' : 'scale-100 opacity-100'
}`}
>
<div className="bg-background sticky top-0 z-10 flex items-center justify-end border-b p-1.5">
<button
className="text-muted-foreground hover:text-foreground hover:bg-muted rounded-full p-1 transition-colors duration-150"
onClick={(e) => {
e.stopPropagation()
closeDropdown()
}}
aria-label="Close dropdown"
>
<span className="icon-[mdi--close] flex size-4 items-center justify-center"></span>
</button>
</div>
<div className="max-h-[200px] overflow-y-auto">
{isLoadingModels ? (
<div className="flex flex-col items-center justify-center gap-2 p-6 text-sm">
<span className="icon-[mdi--loading] text-primary size-5 animate-spin"></span>
<span className="text-muted-foreground">Loading models...</span>
</div>
) : modelError ? (
<div className="flex flex-col items-center p-6 text-sm text-red-500">
<span className="icon-[mdi--alert-circle-outline] mb-2 size-5"></span>
{modelError}
</div>
) : availableModels.length > 0 ? (
<>
{availableModels.map((model) => (
<div
key={model}
className="hover:bg-muted flex cursor-pointer items-center p-2 transition-colors duration-150"
onClick={(e) => {
e.stopPropagation()
toggleModel(model)
}}
>
<div className="mr-2 flex h-5 w-5 items-center justify-center">
{selectedModels.includes(model) && (
<span className="icon-[mdi--check] text-primary size-4"></span>
)}
</div>
<span className="text-sm">{model}</span>
</div>
))}
</>
) : (
<div className="text-muted-foreground flex flex-col items-center p-6 text-center text-sm">
<span className="icon-[mdi--information-outline] mb-2 size-5"></span>
No models available
</div>
)}
</div>
<div className="bg-background sticky bottom-0 z-10 flex items-center justify-end border-t p-1.5">
<button
className="text-primary-foreground bg-primary hover:bg-primary/90 rounded-md px-3 py-1 text-xs transition-colors"
onClick={(e) => {
e.stopPropagation()
closeDropdown()
}}
>
Done
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,59 @@
'use client'
import { useState } from 'react'
import {
ALL_PROVIDERS,
LLM_PROVIDER_INFO,
LLMProviderType,
LLMProviderTypeEnum,
} from '@penx/types'
import { Button } from '@penx/uikit/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@penx/uikit/dropdown-menu'
import { ProviderIcon } from './icons'
import { ProviderEditorDialog } from './provider-editor-dialog'
export function ProviderDropdownMenu() {
const [isOpen, setIsOpen] = useState(false)
const [selectedProviderType, setSelectedProviderType] =
useState<LLMProviderType | null>(null)
const handleSelect = (providerType: LLMProviderType) => {
setSelectedProviderType(providerType)
setIsOpen(true)
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<span className="icon-[mdi--plus] mr-1 size-4"></span>
<span>Add Provider</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{ALL_PROVIDERS.map((providerType) => (
<DropdownMenuItem
key={providerType}
onClick={() => handleSelect(providerType)}
>
<ProviderIcon llmProviderType={providerType} className="mr-2" />
<span>{LLM_PROVIDER_INFO[providerType].name}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<ProviderEditorDialog
open={isOpen}
onOpenChange={setIsOpen}
providerType={selectedProviderType}
/>
</>
)
}

View File

@@ -0,0 +1,331 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { TrashIcon } from 'lucide-react'
import { toast } from 'sonner'
import { useMySite } from '@penx/hooks/useMySite'
import { fetchAvailableModels } from '@penx/libs/ai/helper'
import { AIProvider } from '@penx/model-type'
import { store } from '@penx/store'
import {
LLM_PROVIDER_INFO,
LLMProviderType,
LLMProviderTypeEnum,
} from '@penx/types'
import { Button } from '@penx/uikit/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@penx/uikit/dialog'
import { Input } from '@penx/uikit/input'
import { Label } from '@penx/uikit/label'
import { ProviderIcon } from './icons'
import { ModelSelector } from './model-selector'
interface ProviderDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
providerType: LLMProviderType | null
existingProvider?: AIProvider | null // Add parameter for existing provider
}
export function ProviderEditorDialog({
open,
onOpenChange,
providerType,
existingProvider = null,
}: ProviderDialogProps) {
const { site } = useMySite()
const [apiKey, setApiKey] = useState('')
const [baseURL, setBaseURL] = useState('')
const [selectedModels, setSelectedModels] = useState<string[]>([])
const [availableModels, setAvailableModels] = useState<string[]>([])
const [isLoadingModels, setIsLoadingModels] = useState(false)
const [modelError, setModelError] = useState('')
// Use ref to prevent fetching models multiple times
const modelsLoaded = useRef(false)
const apiKeyRef = useRef('')
const baseURLRef = useRef('')
// Determine if we're in edit mode
const isEditMode = Boolean(existingProvider)
// Get effective provider type (from existing provider or from prop)
const effectiveProviderType = existingProvider?.type || providerType
const resetForm = () => {
setApiKey('')
setBaseURL('')
setSelectedModels([])
setAvailableModels([])
setModelError('')
modelsLoaded.current = false
apiKeyRef.current = ''
baseURLRef.current = ''
}
// Handle provider deletion
const handleDeleteProvider = async () => {
if (!effectiveProviderType) return
if (
confirm(
`Are you sure you want to delete the ${LLM_PROVIDER_INFO[effectiveProviderType]?.name} provider?`,
)
) {
try {
await store.site.deleteAIProvider(effectiveProviderType)
toast.success(
`${LLM_PROVIDER_INFO[effectiveProviderType]?.name} provider deleted successfully`,
)
onOpenChange(false)
} catch (error) {
console.error('Failed to delete provider:', error)
toast.error('Failed to delete provider')
}
}
}
// Initialize form with existing provider data when in edit mode
useEffect(() => {
if (open) {
if (existingProvider) {
// Edit mode - load existing data
setApiKey(existingProvider.apiKey || '')
setBaseURL(existingProvider.baseURL || '')
setSelectedModels(existingProvider.availableModels || [])
if (
existingProvider.availableModels &&
existingProvider.availableModels.length > 0
) {
setAvailableModels(existingProvider.availableModels)
modelsLoaded.current = true
} else {
modelsLoaded.current = false
}
apiKeyRef.current = existingProvider.apiKey || ''
baseURLRef.current = existingProvider.baseURL || ''
} else {
// Add mode - reset form
resetForm()
}
}
}, [open, existingProvider])
// Fetch models when API key or baseURL changes
useEffect(() => {
// Skip if no provider type or apiKey
if (
!effectiveProviderType ||
!apiKey ||
(effectiveProviderType === LLMProviderTypeEnum.OPENAI_COMPATIBLE &&
!baseURL)
) {
return
}
// Skip if API key hasn't changed and models are already loaded
if (
apiKey === apiKeyRef.current &&
baseURL === baseURLRef.current &&
modelsLoaded.current
) {
return
}
// Update refs to current values
apiKeyRef.current = apiKey
baseURLRef.current = baseURL
const loadModels = async () => {
setIsLoadingModels(true)
setModelError('')
try {
const models = await fetchAvailableModels(
apiKey,
effectiveProviderType,
baseURL,
)
setAvailableModels(models.map((model) => model.id))
modelsLoaded.current = true
} catch (error) {
console.error('Error fetching models:', error)
setModelError(
'Failed to fetch models. Please check your API key and try again.',
)
modelsLoaded.current = false
} finally {
setIsLoadingModels(false)
}
}
// Delay fetching models by 1 second after API key is entered to avoid frequent requests
const timer = setTimeout(() => {
loadModels()
}, 1000)
return () => clearTimeout(timer)
}, [apiKey, baseURL, effectiveProviderType])
const handleSave = () => {
if (!effectiveProviderType) return
const provider: AIProvider = {
type: effectiveProviderType,
enabled: true,
apiKey: apiKey,
baseURL: baseURL || undefined,
}
if (selectedModels.length > 0) {
provider.defaultModel = selectedModels[0]
provider.availableModels = selectedModels
} else if (availableModels.length > 0) {
provider.availableModels = availableModels
}
// If in edit mode, preserve any fields we're not editing
if (isEditMode && existingProvider) {
// Preserve any properties that we're not changing
Object.keys(existingProvider).forEach((key) => {
if (
key !== 'type' &&
key !== 'apiKey' &&
key !== 'baseURL' &&
key !== 'defaultModel' &&
key !== 'availableModels' &&
!(key in provider)
) {
provider[key] = existingProvider[key]
}
})
}
store.site.updateAIProvider(provider)
onOpenChange(false)
resetForm()
toast.success(
isEditMode
? `${LLM_PROVIDER_INFO[effectiveProviderType]?.name} updated successfully`
: `${LLM_PROVIDER_INFO[effectiveProviderType]?.name} added successfully`,
)
}
const providerName = effectiveProviderType
? LLM_PROVIDER_INFO[effectiveProviderType]?.name
: ''
const showBaseUrlField =
effectiveProviderType === LLMProviderTypeEnum.OPENAI_COMPATIBLE
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg font-medium">
{effectiveProviderType && (
<>
<ProviderIcon
llmProviderType={effectiveProviderType}
className="size-5"
/>
<span>
{isEditMode ? 'Edit' : 'Add'} {providerName} Configuration
</span>
</>
)}
</DialogTitle>
<DialogDescription className="text-muted-foreground mt-1.5">
{isEditMode
? 'Update your API credentials for this AI provider.'
: 'Add your API credentials to enable and configure this AI provider.'}
</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4">
<div className="grid w-full items-center gap-4">
<Label htmlFor="apiKey" className="font-medium">
API Key
</Label>
<Input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="h-10 w-full"
placeholder="Enter API key"
autoComplete="off"
/>
{showBaseUrlField && (
<>
<Label htmlFor="baseURL" className="font-medium">
Base URL
</Label>
<Input
id="baseURL"
type="text"
value={baseURL}
onChange={(e) => setBaseURL(e.target.value)}
className="h-10 w-full"
placeholder="Enter Base URL"
autoComplete="off"
/>
</>
)}
{/* Use ModelSelector component instead of inline code */}
<ModelSelector
selectedModels={selectedModels}
onChange={setSelectedModels}
availableModels={availableModels}
isLoadingModels={isLoadingModels}
modelError={modelError}
/>
</div>
</div>
<DialogFooter className="flex justify-between gap-2">
<div className="flex gap-2">
{isEditMode && (
<Button
variant="destructive"
onClick={handleDeleteProvider}
className="w-full sm:w-auto"
>
<TrashIcon className="mr-1 h-4 w-4" />
Delete
</Button>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="w-full sm:w-auto"
>
Cancel
</Button>
<Button
type="submit"
onClick={handleSave}
disabled={
!apiKey ||
(effectiveProviderType ===
LLMProviderTypeEnum.OPENAI_COMPATIBLE &&
!baseURL)
}
className="w-full sm:w-auto"
>
{isEditMode ? 'Save Changes' : 'Add Provider'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,120 @@
'use client'
import { useState } from 'react'
import { EditIcon, TrashIcon } from 'lucide-react'
import { toast } from 'sonner'
import { useMySite } from '@penx/hooks/useMySite'
import { AIProvider } from '@penx/model-type'
import { store } from '@penx/store'
import { LLMProviderType } from '@penx/types'
import { Badge } from '@penx/uikit/badge'
import { Button } from '@penx/uikit/button'
import { Card, CardContent, CardHeader, CardTitle } from '@penx/uikit/card'
import { ProviderIcon } from './icons'
import { ProviderDropdownMenu } from './provider-dropdown-menu'
import { ProviderEditorDialog } from './provider-editor-dialog'
export function ProviderSetting() {
const { site } = useMySite()
const providers = site?.props?.aiSetting?.providers || []
// Track provider editing
const [isEditing, setIsEditing] = useState(false)
const [editingProvider, setEditingProvider] = useState<AIProvider | null>(
null,
)
// Handle provider deletion
const handleDeleteProvider = async (providerType: string) => {
if (
confirm(`Are you sure you want to delete the ${providerType} provider?`)
) {
try {
// Find the provider to remove
const providerToRemove = providers.find(
(p: AIProvider) => p.type === providerType,
)
if (providerToRemove) {
await store.site.deleteAIProvider(providerType)
toast.success(`${providerType} provider deleted successfully`)
}
} catch (error) {
console.error('Failed to delete provider:', error)
toast.error('Failed to delete provider')
}
}
}
// Handle edit provider
const handleEditProvider = (provider: AIProvider) => {
setEditingProvider(provider)
setIsEditing(true)
}
return (
<Card>
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
Your Providers
<div className="ml-auto">
<ProviderDropdownMenu />
</div>
</CardTitle>
</CardHeader>
<CardContent>
{providers.length === 0 ? (
<div className="text-muted-foreground py-4 text-center">
No providers added yet.
</div>
) : (
<div className="space-y-2">
{providers.map((provider: AIProvider) => (
<div
key={provider.type}
className="hover:bg-muted flex cursor-pointer flex-col rounded-md p-2"
onClick={() => handleEditProvider(provider)}
>
<div className="flex items-center justify-between">
<div className="flex items-center">
<ProviderIcon
llmProviderType={provider.type as LLMProviderType}
className="mr-2"
/>
<div className="flex flex-col">
<span>{provider.name || provider.type}</span>
</div>
</div>
{provider.availableModels &&
provider.availableModels.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{provider.availableModels.map((model: string) => (
<Badge
key={model}
variant="outline"
className={` text-xs`}
>
{model}
</Badge>
))}
</div>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
{/* Edit dialog */}
{editingProvider && (
<ProviderEditorDialog
open={isEditing}
onOpenChange={setIsEditing}
providerType={editingProvider.type as LLMProviderType}
existingProvider={editingProvider}
/>
)}
</Card>
)
}

View File

@@ -7,6 +7,7 @@ import { ClosePanelButton } from './ClosePanelButton'
import { LocalBackup } from './panel-renderer/LocalBackup/LocalBackup'
import { ManageTags } from './panel-renderer/ManageTags/ManageTags'
import { PanelAIProviders } from './panel-renderer/PanelAIProviders'
import { PanelAISetting } from './panel-renderer/PanelAISetting'
import { PanelCreation } from './panel-renderer/PanelCreation'
import { PanelHome } from './panel-renderer/PanelHome'
import { PanelWidget } from './panel-renderer/PanelWidget'
@@ -46,8 +47,8 @@ export function PanelItem({
<PanelHome index={index} panel={panel} />
)}
{panel.type === PanelType.AI_PROVIDERS && (
<PanelAIProviders index={index} panel={panel} />
{panel.type === PanelType.AI_SETTING && (
<PanelAISetting index={index} panel={panel} />
)}
{panel.type === PanelType.MANAGE_TAGS && (

View File

@@ -1,122 +0,0 @@
'use client'
import { PropsWithChildren } from 'react'
import { Trans } from '@lingui/react'
import { useCreations } from '@penx/hooks/useCreations'
import { useMySite } from '@penx/hooks/useMySite'
import { AIProviderType } from '@penx/model-type'
import { store } from '@penx/store'
import { Panel } from '@penx/types'
import { Card, CardContent, CardHeader, CardTitle } from '@penx/uikit/card'
import { Input } from '@penx/uikit/input'
import { Switch } from '@penx/uikit/switch'
import { uniqueId } from '@penx/unique-id'
import { ClosePanelButton } from '../ClosePanelButton'
import { PanelHeaderWrapper } from '../PanelHeaderWrapper'
interface Props {
panel: Panel
index: number
}
export function PanelAIProviders({ panel, index }: Props) {
const { creations: data } = useCreations()
const { site } = useMySite()
const providers = site.aiProviders || []
return (
<>
<PanelHeaderWrapper index={index}>
<div>AI providers</div>
<ClosePanelButton panel={panel} />
</PanelHeaderWrapper>
<div className="h-full flex-col overflow-auto px-4 pb-20 pt-10">
<div className="space-y-5">
{Object.keys(AIProviderType).map((type) => {
const provider = providers.find((p) => p.type === type)!
return (
<Card key={type}>
<CardHeader className="">
<CardTitle className="text-foreground flex items-center gap-2">
{type === AIProviderType.ANTHROPIC && (
<>
<span className="icon-[ri--anthropic-fill] size-4"></span>
<span>Anthropic</span>
</>
)}
{type === AIProviderType.DEEPSEEK && (
<>
<span className="icon-[arcticons--deepseek] size-5"></span>
<span>DeepSeek</span>
</>
)}
{type === AIProviderType.GOOGLE_AI && (
<>
<span className="icon-[mdi--google] size-4"></span>
<span>Google AI</span>
</>
)}
{type === AIProviderType.OPENAI && (
<>
<span className="icon-[ri--openai-fill] size-4 "></span>
<span>OpenAI</span>
</>
)}
{type === AIProviderType.PERPLEXITY && (
<>
<span className="icon-[ri--perplexity-fill] size-5"></span>
<span>Perplexity</span>
</>
)}
{type === AIProviderType.XAI && (
<>
<CardTitle className="flex items-center gap-2">
<span>xAI</span>
</CardTitle>
</>
)}
<div className="ml-auto">
<Switch
checked={!!provider?.enabled}
onCheckedChange={(v) => {
store.site.updateAIProvider({
type: type as AIProviderType,
enabled: v,
})
}}
/>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<Field>
<Label>API key</Label>
<Input
placeholder="API key"
defaultValue={provider?.apiKey || ''}
onChange={(e) => {
store.site.updateAIProvider({
type: type as AIProviderType,
apiKey: e.target.value,
})
}}
/>
</Field>
</CardContent>
</Card>
)
})}
</div>
</div>
</>
)
}
function Field({ children }: PropsWithChildren) {
return <div className="space-y-1">{children}</div>
}
function Label({ children }: PropsWithChildren) {
return <div className="text-sm">{children}</div>
}

View File

@@ -0,0 +1,26 @@
'use client'
import { Panel } from '@penx/types'
import { ProviderSetting } from '../../AISetting/provider-setting'
import { ClosePanelButton } from '../ClosePanelButton'
import { PanelHeaderWrapper } from '../PanelHeaderWrapper'
interface Props {
panel: Panel
index: number
}
export function PanelAISetting({ panel, index }: Props) {
return (
<>
<PanelHeaderWrapper index={index}>
<div>AI setting</div>
<ClosePanelButton panel={panel} />
</PanelHeaderWrapper>
<div className="h-full flex-col space-y-4 overflow-auto px-4 pb-20 pt-10">
<ProviderSetting />
</div>
</>
)
}

View File

@@ -20,12 +20,8 @@ export function PanelChat({ panel, index }: Props) {
return (
<Chat
id={uniqueId()}
// panel={panel}
// index={index}
initialMessages={data}
session={session}
selectedChatModel={''}
selectedVisibilityType="private"
isReadonly={false}
/>
)

View File

@@ -5,7 +5,8 @@ import { PlusIcon } from 'lucide-react'
import { Area } from '@penx/db/client'
import { useAddCreation } from '@penx/hooks/useAddCreation'
import { useMolds } from '@penx/hooks/useMolds'
import { Widget } from '@penx/types'
import { store } from '@penx/store'
import { PanelType, Widget } from '@penx/types'
import { Button } from '@penx/uikit/button'
interface Props {
@@ -14,7 +15,7 @@ interface Props {
export function AddChatButton({ widget }: Props) {
const { molds } = useMolds()
const addCreation = useAddCreation()
return (
<Button
variant="ghost"
@@ -24,8 +25,8 @@ export function AddChatButton({ widget }: Props) {
onPointerDown={async (e) => {
e.stopPropagation()
e.preventDefault()
const mold = molds.find((mold) => mold.id === widget.moldId)!
addCreation({ type: mold.type })
store.panels.openWidgetPanel(widget)
}}
>
<PlusIcon className="text-muted-foreground pointer-events-none size-4 transition-transform duration-200" />

View File

@@ -16,6 +16,7 @@ import { cn } from '@penx/utils'
import { WidgetIcon } from '@penx/widgets/WidgetIcon'
import { WidgetName } from '@penx/widgets/WidgetName'
import { QuickInput } from '../QuickInput'
import { AddChatButton } from './AddChatButton'
import { AddCreationButton } from './AddCreationButton'
import { AllCreationCard } from './AllCreationCard'
import { IsAllProvider } from './IsAllContext'
@@ -114,6 +115,10 @@ export const WidgetItem = forwardRef<HTMLDivElement, Props>(
<AddCreationButton area={area} widget={widget} />
)}
{widget.type === WidgetType.AI_CHAT && (
<AddChatButton widget={widget} />
)}
<ToggleButton area={area} widget={widget} />
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { WidgetType } from '@penx/constants'
import { Widget } from '@penx/types'
import { AIChatHistorys } from './widgets/AIChatHistorys'
import { AllCreations } from './widgets/AllCreations'
import { CreationList } from './widgets/CreationList'
import { Favorites } from './widgets/Favorites'
@@ -29,5 +30,9 @@ export function WidgetRender({ widget }: Props) {
if (widget.type === WidgetType.MOLD) {
return <CreationList widget={widget} />
}
if (widget.type === WidgetType.AI_CHAT) {
return <AIChatHistorys />
}
return null
}

View File

@@ -0,0 +1,11 @@
import { Trans } from '@lingui/react'
export function AIChatHistorys() {
return (
<div className="px-3 pb-2">
<div className="text-foreground/60 text-sm">
<Trans id="No chat history yet!"></Trans>
</div>
</div>
)
}

View File

@@ -268,3 +268,97 @@ export const serverSideEditor = createSlateEditor({
BaseBidirectionalLinkPlugin,
],
}) as any
export const createStaticEditor = (value: any) => {
return createSlateEditor({
plugins: [
MarkdownPlugin,
BaseColumnPlugin,
BaseColumnItemPlugin,
BaseTocPlugin,
BaseVideoPlugin,
BaseAudioPlugin,
BaseParagraphPlugin,
BaseHeadingPlugin,
BaseMediaEmbedPlugin,
BaseBoldPlugin,
BaseCodePlugin,
BaseItalicPlugin,
BaseStrikethroughPlugin,
BaseSubscriptPlugin,
BaseSuperscriptPlugin,
BaseUnderlinePlugin,
BaseBlockquotePlugin,
BaseDatePlugin,
BaseCodeBlockPlugin,
BaseIndentPlugin.extend({
inject: {
targetPlugins: [
BaseParagraphPlugin.key,
BaseBlockquotePlugin.key,
BaseCodeBlockPlugin.key,
],
},
}),
BaseIndentListPlugin.extend({
inject: {
targetPlugins: [
BaseParagraphPlugin.key,
...HEADING_LEVELS,
BaseBlockquotePlugin.key,
BaseCodeBlockPlugin.key,
BaseTogglePlugin.key,
],
},
options: {
listStyleTypes: {
fire: {
liComponent: FireLiComponent,
markerComponent: FireMarker,
type: 'fire',
},
todo: {
liComponent: TodoLiStatic,
markerComponent: TodoMarkerStatic,
type: 'todo',
},
},
},
}),
BaseLinkPlugin,
BaseTableRowPlugin,
BaseTablePlugin,
BaseTableCellPlugin,
BaseHorizontalRulePlugin,
BaseFontColorPlugin,
BaseFontBackgroundColorPlugin,
BaseFontSizePlugin,
BaseKbdPlugin,
BaseAlignPlugin.extend({
inject: {
targetPlugins: [
BaseParagraphPlugin.key,
BaseMediaEmbedPlugin.key,
...HEADING_LEVELS,
BaseImagePlugin.key,
],
},
}),
BaseLineHeightPlugin,
BaseHighlightPlugin,
BaseFilePlugin,
BaseImagePlugin,
BaseMentionPlugin,
BaseCommentsPlugin,
BaseTogglePlugin,
BaseEquationPlugin,
BaseInlineEquationPlugin,
],
value,
})
}
export const serializeMarkdown = (value: any) => {
const editor = createStaticEditor(value)
return editor.api.markdown.serialize()
}

View File

@@ -0,0 +1,52 @@
import { OpenAI } from 'openai'
import { AvailableModel, LLM_PROVIDER_INFO, LLMProviderType } from '@penx/types'
export async function fetchAvailableModels(
apiKey: string,
providerType: LLMProviderType,
baseUrl?: string,
): Promise<AvailableModel[]> {
if (!apiKey) {
return []
}
const providerInfo = LLM_PROVIDER_INFO[providerType]
const _baseUrl = baseUrl || providerInfo.baseUrl
// Special handling for Google's API
if (providerType === 'google') {
try {
const endpoint = `${_baseUrl}/models?key=${apiKey}`
const response = await fetch(endpoint)
if (!response.ok) {
throw new Error(`Failed to fetch Google models: ${response.statusText}`)
}
const data: any = await response.json()
return data.models.map(
(model: { name: string; displayName: string }) => ({
id: model.name.split('/').pop() || model.name,
label: model.name.split('/').pop() || model.displayName || model.name,
}),
)
} catch (error) {
console.error('Failed to fetch Google models:', error)
return []
}
}
const openai = new OpenAI({
apiKey: apiKey,
baseURL: _baseUrl,
dangerouslyAllowBrowser: true,
})
try {
const resp = await openai.models.list()
return resp.data.map((model) => ({
id: model.id,
label: model.id,
}))
} catch (error) {
console.error('Failed to fetch models:', error)
return []
}
}

View File

@@ -0,0 +1,24 @@
import { LLMProviderType } from '@penx/types'
export type AIProvider = {
type: LLMProviderType
apiKey?: string
baseURL?: string
availableModels?: string[]
[key: string]: any
}
export interface EmbeddingConfig {
providerType: LLMProviderType
model: string
dimensions?: number
batchSize?: number
chunkSize?: number
chunkOverlap?: number
}
export type AISetting = {
providers?: AIProvider[]
embaddingEnabled?: boolean
embedding?: EmbeddingConfig
}

View File

@@ -1,19 +1,5 @@
export enum AIProviderType {
PERPLEXITY = 'PERPLEXITY',
DEEPSEEK = 'DEEPSEEK',
OPENAI = 'OPENAI',
ANTHROPIC = 'ANTHROPIC',
GOOGLE_AI = 'GOOGLE_AI',
XAI = 'XAI',
}
import { AISetting } from './IAISetting'
export type AIProvider = {
type: AIProviderType
enabled: boolean
apiKey?: string
baseURL?: string
[key: string]: any
}
export type Widget = {
id: string
type: string
@@ -81,7 +67,7 @@ export interface ISiteNode extends INode {
navLinks: any
newsletterConfig: any
notificationConfig: any
aiProviders: AIProvider[]
aiSetting: AISetting
repo: string
installationId: number
balance: any

View File

@@ -1,20 +1,3 @@
export enum AIProviderType {
PERPLEXITY = 'PERPLEXITY',
DEEPSEEK = 'DEEPSEEK',
OPENAI = 'OPENAI',
ANTHROPIC = 'ANTHROPIC',
GOOGLE_AI = 'GOOGLE_AI',
XAI = 'XAI',
}
export type AIProvider = {
type: AIProviderType
enabled: boolean
apiKey?: string
baseURL?: string
[key: string]: any
}
export interface ISite {
id: string
name: string
@@ -41,7 +24,6 @@ export interface ISite {
navLinks: any
newsletterConfig: any
notificationConfig: any
aiProviders: AIProvider[]
repo: string
installationId: number
balance: any

View File

@@ -2,6 +2,7 @@
* @file Automatically generated by barrelsby.
*/
export * from './IAISetting'
export * from './IAsset'
export * from './ICatalogueNode'
export * from './IDatabase'

View File

@@ -3,7 +3,7 @@ import { produce } from 'immer'
import { atom } from 'jotai'
import { ACTIVE_SITE } from '@penx/constants'
import { localDB } from '@penx/local-db'
import { AIProvider, ISiteNode } from '@penx/model-type'
import { AIProvider, AISetting, ISiteNode } from '@penx/model-type'
import { StoreType } from '../store-types'
export const siteAtom = atom<ISiteNode>(null as unknown as ISiteNode)
@@ -29,24 +29,56 @@ export class SiteStore {
}
async updateAIProvider(data: Partial<AIProvider>) {
// Ensure data is valid
if (!data || typeof data !== 'object') {
console.error('Invalid provider data')
return
}
// Ensure type is specified
if (!data.type) {
console.error('Provider type is required')
return
}
const site = this.get()
const newSite = produce(site, (draft) => {
if (!draft.props.aiProviders?.length) draft.props.aiProviders = []
const index = draft.props.aiProviders.findIndex(
(p) => p.type === data.type,
)
if (index === -1) {
draft.props.aiProviders.push(data as AIProvider)
} else {
draft.props.aiProviders[index] = {
...draft.props.aiProviders[index],
...data,
}
// Initialize aiSetting if it doesn't exist
if (!draft.props) {
draft.props = {} as ISiteNode['props']
}
if (Reflect.has(data, 'enabled') && data.enabled) {
for (const item of draft.props.aiProviders) {
item.enabled = item.type === data.type
if (!draft.props.aiSetting) {
draft.props.aiSetting = {
providers: [],
} as AISetting
}
if (!draft.props.aiSetting.providers) {
draft.props.aiSetting.providers = []
}
// Check if a provider with the same type already exists
const index = draft.props.aiSetting.providers.findIndex(
(p) => p.type === data.type,
)
if (index === -1) {
// Create a new provider with all required fields
draft.props.aiSetting.providers.push({
type: data.type,
name: data.name || data.type,
apiKey: data.apiKey || '',
enabled: data.enabled !== undefined ? data.enabled : true,
availableModels: data.availableModels || [],
defaultModel: data.defaultModel || '',
baseURL: data.baseURL,
} as AIProvider)
} else {
// Update existing provider
draft.props.aiSetting.providers[index] = {
...draft.props.aiSetting.providers[index],
...data,
}
}
})
@@ -56,7 +88,30 @@ export class SiteStore {
await localDB.updateSiteProps(newSite.id, {
...site.props,
aiProviders: newSite.props.aiProviders,
aiSetting: newSite.props.aiSetting,
})
}
async deleteAIProvider(providerType: string) {
if (!providerType) {
console.error('Provider type is required for deletion')
return
}
const site = this.get()
const newSite = produce(site, (draft) => {
if (draft.props?.aiSetting?.providers) {
draft.props.aiSetting.providers =
draft.props.aiSetting.providers.filter((p) => p.type !== providerType)
}
})
this.set(newSite)
await this.save(newSite)
await localDB.updateSiteProps(newSite.id, {
...site.props,
aiSetting: newSite.props.aiSetting,
})
}
}

View File

@@ -0,0 +1,77 @@
export enum LLMProviderTypeEnum {
OPENAI = 'openai',
GOOGLE = 'google',
DEEPSEEK = 'deepseek',
XAI = 'xai',
ANTHROPIC = 'anthropic',
PERPLEXITY = 'perplexity',
OPENAI_COMPATIBLE = 'openai-compatible',
}
export type LLMProviderType =
| 'openai'
| 'google'
| 'deepseek'
| 'xai'
| 'anthropic'
| 'perplexity'
| 'openai-compatible'
export const ALL_PROVIDERS_RAW = [
'openai',
'google',
'deepseek',
'xai',
'anthropic',
'perplexity',
'openai-compatible',
]
export const LLM_PROVIDER_INFO: Record<
LLMProviderType,
{
name: string
baseUrl: string
urlForGettingApiKey?: string
}
> = {
openai: {
name: 'OpenAI',
baseUrl: 'https://api.openai.com/v1',
},
google: {
name: 'Google AI',
baseUrl: 'https://generativelanguage.googleapis.com/v1beta', // Or Vertex AI endpoint
urlForGettingApiKey: 'https://aistudio.google.com/apikey',
},
deepseek: {
name: 'DeepSeek',
baseUrl: 'https://api.deepseek.com/v1',
},
xai: {
name: 'xAI (Grok)',
baseUrl: 'https://api.x.ai/v1',
urlForGettingApiKey: 'https://docs.x.ai/docs/overview',
},
anthropic: {
name: 'Anthropic',
baseUrl: 'https://api.anthropic.com/v1',
},
perplexity: {
name: 'Perplexity AI',
baseUrl: 'https://api.perplexity.ai',
},
'openai-compatible': {
name: 'OpenAI Compatible',
baseUrl: 'YOUR_COMPATIBLE_ENDPOINT', // User specific
},
}
export interface AvailableModel {
id: string
label: string
}
export const ALL_PROVIDERS = Object.keys(
LLM_PROVIDER_INFO,
).sort() as LLMProviderType[]

View File

@@ -2,6 +2,7 @@ export * from './theme.types'
export * from './types'
export * from './session.types'
export * from './database-types'
export * from './ai/llm-provider-type'
export interface FilterItem {
label: string

View File

@@ -259,6 +259,7 @@ export enum PanelType {
MANAGE_TAGS = 'MANAGE_TAGS',
LOCAL_BACKUP = 'LOCAL_BACKUP',
AI_PROVIDERS = 'AI_PROVIDERS',
AI_SETTING = 'AI_SETTING',
}
export type Panel = {

3194
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff