Skip to content

Commit

Permalink
Merge pull request #542 from sugarforever/feat/custom-api-service
Browse files Browse the repository at this point in the history
支持自定义API服务
  • Loading branch information
satrong authored Jul 3, 2024
2 parents fce6e8f + a5e0fd2 commit b8cb215
Show file tree
Hide file tree
Showing 12 changed files with 1,710 additions and 963 deletions.
43 changes: 43 additions & 0 deletions components/settings/CreateCustomServer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<script lang="ts" setup>
import { object, string } from 'yup'
const props = defineProps<{
onClose?: () => void
onCreate?: (name: string) => void
}>()
const { t } = useI18n()
const formData = reactive({
name: '',
})
const schema = computed(() => {
return object({
name: string().required(t('global.required')),
})
})
function onSubmit() {
props.onCreate?.(formData.name)
}
</script>

<template>
<UModal prevent-close>
<UCard>
<template #header>
<h5>{{ t('settings.customApiService') }}</h5>
</template>
<UForm :state="formData" :schema="schema" @submit="onSubmit">
<UFormGroup :label="t('settings.customApiServiceName')" name="name">
<UInput v-model="formData.name" />
</UFormGroup>
<div class="flex justify-end gap-2 mt-4">
<UButton color="gray" class="mr-2" @click="onClose">{{ t('global.cancel') }}</UButton>
<UButton type="submit" color="primary">{{ t('global.create') }}</UButton>
</div>
</UForm>
</UCard>
</UModal>
</template>
120 changes: 120 additions & 0 deletions components/settings/CustomServerForm.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<script lang="ts" setup>
import { object, string, array } from 'yup'
import type { ContextKeys } from '~/server/middleware/keys'
import * as CONFIG_MODELS from '~/config/models'
const props = defineProps<{
value: ContextKeys['custom'][number]
}>()
const emits = defineEmits<{
update: [ContextKeys['custom'][number]]
remove: []
}>()
const toast = useToast()
const confirm = useDialog('confirm')
const { t } = useI18n()
const aiTypes = Object.entries(CONFIG_MODELS.MODEL_FAMILIES).filter(([key]) => key !== 'moonshot').map(([value, label]) => ({ value, label }))
const defaultModelsMap: Record<ContextKeys['custom'][number]['aiType'], string[]> = {
openai: CONFIG_MODELS.OPENAI_GPT_MODELS,
azureOpenai: CONFIG_MODELS.AZURE_OPENAI_GPT_MODELS,
anthropic: CONFIG_MODELS.ANTHROPIC_MODELS,
gemini: CONFIG_MODELS.GEMINI_MODELS,
groq: CONFIG_MODELS.GROQ_MODELS,
}
const defaultState: ContextKeys['custom'][number] = { name: '', aiType: 'openai', endpoint: '', key: '', proxy: false, models: [] }
const defaultAiType = props.value.aiType || aiTypes[0].value
const state = reactive(Object.assign({}, defaultState, props.value, {
aiType: defaultAiType,
models: props.value.models.length === 0 ? defaultModelsMap[defaultAiType] : props.value.models,
}))
const modelName = ref('')
const schema = computed(() => {
return object({
aiType: string().required(t('global.required')),
endpoint: string().url(t('global.invalidUrl')).required(t('global.required')),
key: string().required(t('global.required')),
models: array().min(1, t('global.required')),
})
})
watch(() => state.aiType, (type) => {
state.models = defaultModelsMap[type] || []
})
function onSubmit() {
emits('update', { ...state })
}
function onAddModel() {
const name = modelName.value.trim()
if (state.models.includes(name)) {
toast.add({ title: t('settings.modelNameExist'), color: 'red' })
return
}
state.models.unshift(name)
modelName.value = ''
}
function onRemove() {
confirm(t('settings.ensureRemoveCustomService')).then(() => emits('remove'))
}
</script>

<template>
<UForm :state="state" :schema="schema" @submit="onSubmit">
<UFormGroup :label="t('settings.aiType')" class="mb-4" name="aiType">
<USelectMenu v-model="state.aiType"
:options="aiTypes"
size="lg"
value-attribute="value"
option-attribute="label" />
</UFormGroup>
<UFormGroup :label="t('settings.endpoint')" class="mb-4" name="endpoint">
<UInput v-model.trim="state.endpoint" size="lg" :placeholder="t('global.required')" />
</UFormGroup>
<UFormGroup :label="t('settings.apiKey')" class="mb-4" name="key">
<UInput v-model.trim="state.key" size="lg" type="password" :placeholder="t('global.required')" />
</UFormGroup>
<UFormGroup :label="t('settings.modelNameSetting')" name="models">
<div class="border border-gray-300 dark:border-gray-700 mt-2 rounded max-w-[400px]">
<div class="flex items-center px-4 py-2">
<UInput v-model.trim="modelName" autocomplete="off" class="flex-1" @keydown.enter.prevent="onAddModel" :placeholder="t('settings.modelNameTip')" />
<UButton :disabled="!modelName.trim()" class="ml-2" @click="onAddModel">{{ t('global.add') }}</UButton>
</div>
<div v-for="item, i in state.models" :key="item"
class="model-name-item box-border px-4 py-2 flex items-center justify-between border-t border-t-gray-400/40 hover:bg-primary-50 hover:dark:bg-primary-800/50">
<span class="opacity-70 text-sm">{{ item }}</span>
<UButton icon="i-heroicons-trash-20-solid" color="red" variant="ghost" size="xs" class="!p-0 hidden" @click="() => state.models.splice(i, 1)"></UButton>
</div>
</div>
</UFormGroup>
<div class="my-4">
<label>
<input type="checkbox" v-model="state.proxy" />
<span class="ml-2 text-sm text-muted">({{ t('settings.proxyTips') }})</span>
</label>
</div>
<div class="flex justify-between">
<UButton type="submit">
{{ t("global.save") }}
</UButton>
<UButton color="red" class="ml-2" @click="onRemove">
{{ t('settings.removeCustomService') }}
</UButton>
</div>
</UForm>
</template>

<style lang="scss" scoped>
.model-name-item {
&:hover {
button {
display: block;
}
}
}
</style>
126 changes: 96 additions & 30 deletions components/settings/SettingsServers.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,23 @@
import type { ContextKeys } from '~/server/middleware/keys'
import { keysStore, DEFAULT_KEYS_STORE } from '~/utils/settings'
import type { PickupPathKey, TransformTypes } from '~/types/helper'
import CreateCustomServer from './CreateCustomServer.vue'
import CustomServerForm from './CustomServerForm.vue'
import { deepClone } from '~/composables/helpers'
type PathKeys = PickupPathKey<Omit<ContextKeys, 'custom'>>
const { t } = useI18n()
const toast = useToast()
const modal = useModal()
const { loadModels } = useModels({ forceReload: true })
interface LLMListItem {
key: string
title: string
fields: Array<{
label: string
value: PickupPathKey<ContextKeys>
value: PathKeys
type: 'input' | 'password' | 'checkbox'
placeholder?: string
rule?: 'url'
Expand Down Expand Up @@ -87,8 +94,9 @@ const LLMList = computed<LLMListItem[]>(() => {
})
const currentLLM = ref(LLMList.value[0].key)
const currentLLMFields = computed(() => LLMList.value.find(el => el.key === currentLLM.value)!.fields)
const currentLLMFields = computed(() => LLMList.value.find(el => el.key === currentLLM.value)?.fields || [])
const state = reactive(getData())
const currentCustomServer = computed(() => state.custom.find(el => el.name === currentLLM.value))
const validate = (data: typeof state) => {
const errors: Array<{ path: string, message: string } | null> = []
Expand All @@ -108,10 +116,51 @@ const onSubmit = async () => {
const key = keyPaths.join('.') as keyof typeof state
return key in state ? state[key] : value
})
loadModels()
toast.add({ title: t(`settings.setSuccessfully`), color: 'green' })
}
function onAddCustomServer() {
modal.open(CreateCustomServer, {
onClose: () => modal.close(),
onCreate: name => {
if (state.custom.some(el => el.name === name)) {
toast.add({ title: t(`settings.customServiceNameExists`), color: 'red' })
return
}
const data: ContextKeys['custom'][number] = {
name,
aiType: 'openai',
endpoint: '',
key: '',
models: [],
proxy: false,
}
state.custom.push(data)
keysStore.value = Object.assign(keysStore.value, { custom: (keysStore.value.custom || []).concat(data) })
currentLLM.value = name
modal.close()
}
})
}
function onUpdateCustomServer(data: ContextKeys['custom'][number]) {
const index = state.custom.findIndex(el => el.name === currentCustomServer.value!.name)
state.custom[index] = data
keysStore.value.custom.splice(index, 1, data)
loadModels()
toast.add({ title: t(`settings.setSuccessfully`), color: 'green' })
}
function onRemoveCustomServer() {
const index = state.custom.findIndex(el => el.name === currentCustomServer.value!.name)
state.custom.splice(index, 1)
keysStore.value.custom.splice(index, 1)
currentLLM.value = LLMList.value[0].key
loadModels()
}
const checkHost = (key: keyof typeof state, title: string) => {
const url = state[key]
if (!url || (typeof url === 'string' && /^https?:\/\//i.test(url))) return null
Expand All @@ -120,12 +169,14 @@ const checkHost = (key: keyof typeof state, title: string) => {
}
function getData() {
return LLMList.value.reduce((acc, cur) => {
const data = LLMList.value.reduce((acc, cur) => {
cur.fields.forEach(el => {
(acc as any)[el.value] = el.value.split('.').reduce((a, c) => (a as any)[c], keysStore.value)
})
return acc
}, {} as TransformTypes<PickupPathKey<ContextKeys>>)
}, {} as TransformTypes<PathKeys> & Pick<ContextKeys, 'custom'>)
data.custom = deepClone(keysStore.value.custom || [])
return data
}
function recursiveObject(obj: Record<string, any>, cb: (keyPaths: string[], value: any) => any) {
Expand All @@ -135,7 +186,9 @@ function recursiveObject(obj: Record<string, any>, cb: (keyPaths: string[], valu
for (const key in oldObj) {
if (oldObj.hasOwnProperty(key)) {
const value = oldObj[key]
if (typeof value === 'object' && value !== null) {
if (key === 'custom') {
newObj[key] = cb([key], value)
} else if (typeof value === 'object' && value !== null) {
newObj[key] = {}
recursive(oldObj[key], newObj[key], [...keyPaths, key])
} else if (keyPaths.length === 0) {
Expand All @@ -156,7 +209,7 @@ function recursiveObject(obj: Record<string, any>, cb: (keyPaths: string[], valu

<template>
<ClientOnly>
<UForm :validate="validate" :state="state" class="max-w-6xl mx-auto" @submit="onSubmit">
<div class="max-w-6xl mx-auto">
<SettingsCard>
<template #header>
<div class="flex flex-wrap">
Expand All @@ -165,35 +218,48 @@ function recursiveObject(obj: Record<string, any>, cb: (keyPaths: string[], valu
:color="currentLLM == item.key ? 'primary' : 'gray'"
class="m-1"
@click="currentLLM = item.key">{{ item.title }}</UButton>
<UButton v-for="item in state.custom" :key="item.name"
:color="currentLLM == item.name ? 'primary' : 'gray'"
class="m-1"
@click="currentLLM = item.name">{{ item.name }}</UButton>
<UTooltip :text="t('settings.customApiService')">
<UButton class="m-1" icon="i-material-symbols-add" color="gray" @click="onAddCustomServer"></UButton>
</UTooltip>
</div>
</template>
<div>
<template v-for="item in currentLLMFields" :key="item.value">
<UFormGroup v-if="item.value.endsWith('proxy') ? $config.public.modelProxyEnabled : true" :label="item.label"
:name="item.value"
class="mb-4">
<UInput v-if="item.type === 'input' || item.type === 'password'"
v-model.trim="state[item.value] as string"
:type="item.type"
:placeholder="item.placeholder"
size="lg"
:rule="item.rule" />
<template v-else-if="item.type === 'checkbox'">
<label class="flex items-center">
<UCheckbox v-model="state[item.value] as boolean"></UCheckbox>
<span class="ml-2 text-sm text-muted">({{ item.placeholder }})</span>
</label>
</template>
</UFormGroup>
<UForm v-if="currentLLMFields.length > 0" :validate="validate" :state="state" @submit="onSubmit">
<template v-for="item in currentLLMFields" :key="item.value">
<UFormGroup v-if="item.value.endsWith('proxy') ? $config.public.modelProxyEnabled : true" :label="item.label"
:name="item.value"
class="mb-4">
<UInput v-if="item.type === 'input' || item.type === 'password'"
v-model.trim="(state[item.value] as string)"
:type="item.type"
:placeholder="item.placeholder"
size="lg"
:rule="item.rule" />
<template v-else-if="item.type === 'checkbox'">
<label class="flex items-center">
<UCheckbox v-model="state[item.value] as boolean"></UCheckbox>
<span class="ml-2 text-sm text-muted">({{ item.placeholder }})</span>
</label>
</template>
</UFormGroup>
</template>
<div>
<UButton type="submit">
{{ t("global.save") }}
</UButton>
</div>
</UForm>
<template v-else-if="currentCustomServer">
<CustomServerForm :value="currentCustomServer"
:key="currentLLM"
@update="d => onUpdateCustomServer(d)" @remove="onRemoveCustomServer()" />
</template>
</div>

<div class="">
<UButton type="submit">
{{ t("global.save") }}
</UButton>
</div>
</SettingsCard>
</UForm>
</div>
</ClientOnly>
</template>
20 changes: 20 additions & 0 deletions composables/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,23 @@ export function pick<O extends Record<string, any>, K extends keyof O>(obj: O, k
return acc
}, {} as Pick<O, typeof keys[number]>)
}

export function deepClone<T>(obj: T): T {
if (typeof obj !== 'object' || obj === null) {
return obj // primitive value or null
}

if (Array.isArray(obj)) {
return obj.map((item) => deepClone(item)) as T
}

const clone: { [key: string]: any } = {}

for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
clone[key] = deepClone(obj[key])
}
}

return clone as T
}
Loading

0 comments on commit b8cb215

Please sign in to comment.