mirror of
https://github.com/penxio/penx.git
synced 2026-05-12 03:03:12 -04:00
feat: add ai setting panel
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
219
packages/components/src/AIChat/provider-selector.tsx
Normal file
219
packages/components/src/AIChat/provider-selector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
packages/components/src/AISetting/icons.tsx
Normal file
65
packages/components/src/AISetting/icons.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
packages/components/src/AISetting/knowledge-setting.tsx
Normal file
14
packages/components/src/AISetting/knowledge-setting.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
173
packages/components/src/AISetting/model-selector.tsx
Normal file
173
packages/components/src/AISetting/model-selector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
packages/components/src/AISetting/provider-dropdown-menu.tsx
Normal file
59
packages/components/src/AISetting/provider-dropdown-menu.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
331
packages/components/src/AISetting/provider-editor-dialog.tsx
Normal file
331
packages/components/src/AISetting/provider-editor-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
120
packages/components/src/AISetting/provider-setting.tsx
Normal file
120
packages/components/src/AISetting/provider-setting.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
52
packages/libs/src/ai/helper.ts
Normal file
52
packages/libs/src/ai/helper.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
24
packages/model-type/src/IAISetting.ts
Normal file
24
packages/model-type/src/IAISetting.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from './IAISetting'
|
||||
export * from './IAsset'
|
||||
export * from './ICatalogueNode'
|
||||
export * from './IDatabase'
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
77
packages/types/src/ai/llm-provider-type.ts
Normal file
77
packages/types/src/ai/llm-provider-type.ts
Normal 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[]
|
||||
@@ -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
|
||||
|
||||
@@ -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
3194
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user