feat: chat in explore support agent (#647)

Co-authored-by: StyleZhang <jasonapring2015@outlook.com>
This commit is contained in:
Joel
2023-07-27 13:27:34 +08:00
committed by GitHub
parent 4fdb37771a
commit 23e3413655
121 changed files with 4081 additions and 527 deletions

View File

@@ -9,11 +9,12 @@ import MembersPage from './members-page'
import IntegrationsPage from './Integrations-page'
import LanguagePage from './language-page'
import ProviderPage from './provider-page'
import PluginPage from './plugin-page'
import DataSourcePage from './data-source-page'
import s from './index.module.css'
import Modal from '@/app/components/base/modal'
import { Database03 } from '@/app/components/base/icons/src/vender/line/development'
import { Database03 as Database03Solid } from '@/app/components/base/icons/src/vender/solid/development'
import { Database03, PuzzlePiece01 } from '@/app/components/base/icons/src/vender/line/development'
import { Database03 as Database03Solid, PuzzlePiece01 as PuzzlePiece01Solid } from '@/app/components/base/icons/src/vender/solid/development'
const iconClassName = `
w-4 h-4 ml-3 mr-2
@@ -80,6 +81,12 @@ export default function AccountSetting({
icon: <Database03 className={iconClassName} />,
activeIcon: <Database03Solid className={iconClassName} />,
},
{
key: 'plugin',
name: t('common.settings.plugin'),
icon: <PuzzlePiece01 className={iconClassName} />,
activeIcon: <PuzzlePiece01Solid className={iconClassName} />,
},
],
},
]
@@ -148,6 +155,7 @@ export default function AccountSetting({
{activeMenu === 'language' && <LanguagePage />}
{activeMenu === 'provider' && <ProviderPage />}
{activeMenu === 'data-source' && <DataSourcePage />}
{activeMenu === 'plugin' && <PluginPage />}
</div>
</div>
</div>

View File

@@ -0,0 +1,77 @@
import type { ChangeEvent } from 'react'
import {
ValidatedErrorIcon,
ValidatedErrorMessage,
ValidatedSuccessIcon,
ValidatingTip,
} from './ValidateStatus'
import { ValidatedStatus } from './declarations'
import type { ValidatedStatusState } from './declarations'
type KeyInputProps = {
value?: string
name: string
placeholder: string
className?: string
onChange: (v: string) => void
onFocus?: () => void
validating: boolean
validatedStatusState: ValidatedStatusState
}
const KeyInput = ({
value,
name,
placeholder,
className,
onChange,
onFocus,
validating,
validatedStatusState,
}: KeyInputProps) => {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value
onChange(inputValue)
}
const getValidatedIcon = () => {
if (validatedStatusState.status === ValidatedStatus.Error || validatedStatusState.status === ValidatedStatus.Exceed)
return <ValidatedErrorIcon />
if (validatedStatusState.status === ValidatedStatus.Success)
return <ValidatedSuccessIcon />
}
const getValidatedTip = () => {
if (validating)
return <ValidatingTip />
if (validatedStatusState.status === ValidatedStatus.Error)
return <ValidatedErrorMessage errorMessage={validatedStatusState.message ?? ''} />
}
return (
<div className={className}>
<div className="mb-2 text-[13px] font-medium text-gray-800">{name}</div>
<div className='
flex items-center px-3 bg-white rounded-lg
shadow-[0_1px_2px_rgba(16,24,40,0.05)]
'>
<input
className='
w-full py-[9px] mr-2
text-xs font-medium text-gray-700 leading-[18px]
appearance-none outline-none bg-transparent
'
value={value}
placeholder={placeholder}
onChange={handleChange}
onFocus={onFocus}
/>
{getValidatedIcon()}
</div>
{getValidatedTip()}
</div>
)
}
export default KeyInput

View File

@@ -0,0 +1,85 @@
import { useTranslation } from 'react-i18next'
import Indicator from '../../indicator'
import type { Status } from './declarations'
type OperateProps = {
isOpen: boolean
status: Status
onCancel: () => void
onSave: () => void
onAdd: () => void
onEdit: () => void
}
const Operate = ({
isOpen,
status,
onCancel,
onSave,
onAdd,
onEdit,
}: OperateProps) => {
const { t } = useTranslation()
if (isOpen) {
return (
<div className='flex items-center'>
<div className='
flex items-center
mr-[5px] px-3 h-7 rounded-md cursor-pointer
text-xs font-medium text-gray-700
' onClick={onCancel} >
{t('common.operation.cancel')}
</div>
<div className='
flex items-center
px-3 h-7 rounded-md cursor-pointer bg-primary-700
text-xs font-medium text-white
' onClick={onSave}>
{t('common.operation.save')}
</div>
</div>
)
}
if (status === 'add') {
return (
<div className='
px-3 h-[28px] bg-white border border-gray-200 rounded-md cursor-pointer
text-xs font-medium text-gray-700 flex items-center
' onClick={onAdd}>
{t('common.provider.addKey')}
</div>
)
}
if (status === 'fail' || status === 'success') {
return (
<div className='flex items-center'>
{
status === 'fail' && (
<div className='flex items-center mr-4'>
<div className='text-xs text-[#D92D20]'>{t('common.provider.invalidApiKey')}</div>
<Indicator color='red' className='ml-2' />
</div>
)
}
{
status === 'success' && (
<Indicator color='green' className='mr-4' />
)
}
<div className='
px-3 h-[28px] bg-white border border-gray-200 rounded-md cursor-pointer
text-xs font-medium text-gray-700 flex items-center
' onClick={onEdit}>
{t('common.provider.editKey')}
</div>
</div>
)
}
return null
}
export default Operate

View File

@@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next'
import { AlertCircle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import { CheckCircle } from '@/app/components/base/icons/src/vender/solid/general'
export const ValidatedErrorIcon = () => {
return <AlertCircle className='w-4 h-4 text-[#D92D20]' />
}
export const ValidatedSuccessIcon = () => {
return <CheckCircle className='w-4 h-4 text-[#039855]' />
}
export const ValidatingTip = () => {
const { t } = useTranslation()
return (
<div className={'mt-2 text-primary-600 text-xs font-normal'}>
{t('common.provider.validating')}
</div>
)
}
export const ValidatedErrorMessage = ({ errorMessage }: { errorMessage: string }) => {
const { t } = useTranslation()
return (
<div className={'mt-2 text-[#D92D20] text-xs font-normal'}>
{t('common.provider.validatedError')}{errorMessage}
</div>
)
}

View File

@@ -0,0 +1,43 @@
import type { Dispatch, SetStateAction } from 'react'
export enum ValidatedStatus {
Success = 'success',
Error = 'error',
Exceed = 'exceed',
}
export type ValidatedStatusState = {
status?: ValidatedStatus
message?: string
}
export type Status = 'add' | 'fail' | 'success'
export type ValidateValue = Record<string, string | undefined>
export type ValidateCallback = {
before: (v?: ValidateValue) => boolean | undefined
run?: (v?: ValidateValue) => Promise<ValidatedStatusState>
}
export type Form = {
key: string
title: string
placeholder: string
value?: string
validate?: ValidateCallback
handleFocus?: (v: ValidateValue, dispatch: Dispatch<SetStateAction<ValidateValue>>) => void
}
export type KeyFrom = {
text: string
link: string
}
export type KeyValidatorProps = {
type: string
title: React.ReactNode
status: Status
forms: Form[]
keyFrom: KeyFrom
}

View File

@@ -0,0 +1,32 @@
import { useState } from 'react'
import { useDebounceFn } from 'ahooks'
import type { DebouncedFunc } from 'lodash-es'
import { ValidatedStatus } from './declarations'
import type { ValidateCallback, ValidateValue, ValidatedStatusState } from './declarations'
export const useValidate: (value: ValidateValue) => [DebouncedFunc<(validateCallback: ValidateCallback) => Promise<void>>, boolean, ValidatedStatusState] = (value) => {
const [validating, setValidating] = useState(false)
const [validatedStatus, setValidatedStatus] = useState<ValidatedStatusState>({})
const { run } = useDebounceFn(async (validateCallback: ValidateCallback) => {
if (!validateCallback.before(value)) {
setValidating(false)
setValidatedStatus({})
return
}
setValidating(true)
if (validateCallback.run) {
const res = await validateCallback?.run(value)
setValidatedStatus(
res.status === 'success'
? { status: ValidatedStatus.Success }
: { status: ValidatedStatus.Error, message: res.message })
setValidating(false)
}
}, { wait: 500 })
return [run, validating, validatedStatus]
}

View File

@@ -0,0 +1,119 @@
import { useState } from 'react'
import Operate from './Operate'
import KeyInput from './KeyInput'
import { useValidate } from './hooks'
import type { Form, KeyFrom, Status, ValidateValue } from './declarations'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
export type KeyValidatorProps = {
type: string
title: React.ReactNode
status: Status
forms: Form[]
keyFrom: KeyFrom
onSave: (v: ValidateValue) => Promise<boolean | undefined>
}
const KeyValidator = ({
type,
title,
status,
forms,
keyFrom,
onSave,
}: KeyValidatorProps) => {
const triggerKey = `plugins/${type}`
const { eventEmitter } = useEventEmitterContextContext()
const [isOpen, setIsOpen] = useState(false)
const prevValue = forms.reduce((prev: ValidateValue, next: Form) => {
prev[next.key] = next.value
return prev
}, {})
const [value, setValue] = useState(prevValue)
const [validate, validating, validatedStatusState] = useValidate(value)
eventEmitter?.useSubscription((v) => {
if (v !== triggerKey) {
setIsOpen(false)
setValue(prevValue)
validate({ before: () => false })
}
})
const handleCancel = () => {
eventEmitter?.emit('')
}
const handleSave = async () => {
if (await onSave(value))
eventEmitter?.emit('')
}
const handleAdd = () => {
setIsOpen(true)
eventEmitter?.emit(triggerKey)
}
const handleEdit = () => {
setIsOpen(true)
eventEmitter?.emit(triggerKey)
}
const handleChange = (form: Form, val: string) => {
setValue({ ...value, [form.key]: val })
if (form.validate)
validate(form.validate)
}
const handleFocus = (form: Form) => {
if (form.handleFocus)
form.handleFocus(value, setValue)
}
return (
<div className='mb-2 border-[0.5px] border-gray-200 bg-gray-50 rounded-md'>
<div className={
`flex items-center justify-between px-4 h-[52px] cursor-pointer ${isOpen && 'border-b-[0.5px] border-b-gray-200'}`
}>
{title}
<Operate
isOpen={isOpen}
status={status}
onCancel={handleCancel}
onSave={handleSave}
onAdd={handleAdd}
onEdit={handleEdit}
/>
</div>
{
isOpen && (
<div className='px-4 py-3'>
{
forms.map(form => (
<KeyInput
key={form.key}
className='mb-4'
name={form.title}
placeholder={form.placeholder}
value={value[form.key] || ''}
onChange={v => handleChange(form, v)}
onFocus={() => handleFocus(form)}
validating={validating}
validatedStatusState={validatedStatusState}
/>
))
}
<a className="flex items-center text-xs cursor-pointer text-primary-600" href={keyFrom.link} target={'_blank'}>
{keyFrom.text}
<LinkExternal02 className='w-3 h-3 ml-1 text-primary-600' />
</a>
</div>
)
}
</div>
)
}
export default KeyValidator

View File

@@ -0,0 +1,77 @@
import { useTranslation } from 'react-i18next'
import Image from 'next/image'
import SerpapiLogo from '../../assets/serpapi.png'
import KeyValidator from '../key-validator'
import type { Form, ValidateValue } from '../key-validator/declarations'
import { updatePluginKey, validatePluginKey } from './utils'
import { useToastContext } from '@/app/components/base/toast'
import type { PluginProvider } from '@/models/common'
type SerpapiPluginProps = {
plugin: PluginProvider
onUpdate: () => void
}
const SerpapiPlugin = ({
plugin,
onUpdate,
}: SerpapiPluginProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const forms: Form[] = [{
key: 'api_key',
title: t('common.plugin.serpapi.apiKey'),
placeholder: t('common.plugin.serpapi.apiKeyPlaceholder'),
value: plugin.credentials?.api_key,
validate: {
before: (v) => {
if (v?.api_key)
return true
},
run: async (v) => {
return validatePluginKey('serpapi', {
credentials: {
api_key: v?.api_key,
},
})
},
},
handleFocus: (v, dispatch) => {
if (v.api_key === plugin.credentials?.api_key)
dispatch({ ...v, api_key: '' })
},
}]
const handleSave = async (v: ValidateValue) => {
if (!v?.api_key || v?.api_key === plugin.credentials?.api_key)
return
const res = await updatePluginKey('serpapi', {
credentials: {
api_key: v?.api_key,
},
})
if (res.status === 'success') {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
onUpdate()
return true
}
}
return (
<KeyValidator
type='serpapi'
title={<Image alt='serpapi logo' src={SerpapiLogo} width={64} />}
status={plugin.credentials?.api_key ? 'success' : 'add'}
forms={forms}
keyFrom={{
text: t('common.plugin.serpapi.keyFrom'),
link: 'https://serpapi.com/manage-api-key',
}}
onSave={handleSave}
/>
)
}
export default SerpapiPlugin

View File

@@ -0,0 +1,38 @@
import useSWR from 'swr'
import { LockClosedIcon } from '@heroicons/react/24/solid'
import { useTranslation } from 'react-i18next'
import Link from 'next/link'
import SerpapiPlugin from './SerpapiPlugin'
import { fetchPluginProviders } from '@/service/common'
import type { PluginProvider } from '@/models/common'
const PluginPage = () => {
const { t } = useTranslation()
const { data: plugins, mutate } = useSWR('/workspaces/current/tool-providers', fetchPluginProviders)
const Plugin_MAP: Record<string, any> = {
serpapi: (plugin: PluginProvider) => <SerpapiPlugin key='serpapi' plugin={plugin} onUpdate={() => mutate()} />,
}
return (
<div className='pb-7'>
<div>
{plugins?.map(plugin => Plugin_MAP[plugin.tool_name](plugin))}
</div>
<div className='fixed bottom-0 w-[472px] h-[42px] flex items-center bg-white text-xs text-gray-500'>
<LockClosedIcon className='w-3 h-3 mr-1' />
{t('common.provider.encrypted.front')}
<Link
className='text-primary-600 mx-1'
target={'_blank'}
href='https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html'
>
PKCS1_OAEP
</Link>
{t('common.provider.encrypted.back')}
</div>
</div>
)
}
export default PluginPage

View File

@@ -0,0 +1,34 @@
import { ValidatedStatus } from '../key-validator/declarations'
import { updatePluginProviderAIKey, validatePluginProviderKey } from '@/service/common'
export const validatePluginKey = async (pluginType: string, body: any) => {
try {
const res = await validatePluginProviderKey({
url: `/workspaces/current/tool-providers/${pluginType}/credentials-validate`,
body,
})
if (res.result === 'success')
return Promise.resolve({ status: ValidatedStatus.Success })
else
return Promise.resolve({ status: ValidatedStatus.Error, message: res.error })
}
catch (e: any) {
return Promise.resolve({ status: ValidatedStatus.Error, message: e.message })
}
}
export const updatePluginKey = async (pluginType: string, body: any) => {
try {
const res = await updatePluginProviderAIKey({
url: `/workspaces/current/tool-providers/${pluginType}/credentials`,
body,
})
if (res.result === 'success')
return Promise.resolve({ status: ValidatedStatus.Success })
else
return Promise.resolve({ status: ValidatedStatus.Error, message: res.error })
}
catch (e: any) {
return Promise.resolve({ status: ValidatedStatus.Error, message: e.message })
}
}