Compare commits

..

1 Commits

Author SHA1 Message Date
feng
3c025b494e perf: Open web ui settigns 2025-11-24 18:33:41 +08:00
3 changed files with 236 additions and 86 deletions

View File

@@ -243,12 +243,10 @@ export default {
delete routeFilter.search
}
const asFilterTags = _.cloneDeep(this.filterTags)
setTimeout(() => {
this.filterTags = {
...asFilterTags,
...routeFilter
}
}, 100)
this.filterTags = {
...asFilterTags,
...routeFilter
}
},
getValueLabel(key, value) {
for (const field of this.options) {

View File

@@ -10,6 +10,7 @@
import { GenericCreateUpdateForm } from '@/layout/components'
import IBox from '@/components/Common/IBox/index.vue'
import { mapGetters } from 'vuex'
import ChatProvidersField from './components/ChatProvidersField.vue'
export default {
components: {
@@ -17,106 +18,54 @@ export default {
GenericCreateUpdateForm
},
data() {
const vm = this
const hasProviders = (formValue) => {
const providers = formValue?.CHAT_AI_PROVIDERS?.providers || []
return Array.isArray(providers) && providers.length > 0
}
return {
url: '/api/v1/settings/setting/?category=chat',
hasReset: false,
moreButtons: [
{
title: this.$t('Test'),
loading: false,
callback: function(value, form, btn) {
btn.loading = true
vm.$axios.post(
'/api/v1/settings/chatai/testing/',
value
).then(res => {
vm.$message.success(res['msg'])
}).catch(() => {
vm.$log.error('err occur')
}).finally(() => {
btn.loading = false
})
}
}
],
encryptedFields: ['VAULT_HCP_TOKEN'],
fields: [
'CHAT_AI_ENABLED',
'CHAT_AI_METHOD',
'CHAT_AI_EMBED_URL',
'CHAT_AI_TYPE',
'DEEPSEEK_BASE_URL',
'DEEPSEEK_API_KEY',
'DEEPSEEK_PROXY',
'DEEPSEEK_MODEL',
'GPT_BASE_URL',
'GPT_API_KEY',
'GPT_PROXY',
'GPT_MODEL'
'CHAT_AI_PROVIDERS',
'CHAT_AI_EMBED_URL'
],
fieldsMeta: {
CHAT_AI_TYPE: {
hidden: (formValue) => {
return formValue.CHAT_AI_METHOD !== 'api'
return formValue.CHAT_AI_METHOD !== 'api' || hasProviders(formValue)
}
},
GPT_BASE_URL: {
el: {
autocomplete: 'new-password'
},
hidden: (formValue) => {
return formValue.CHAT_AI_METHOD !== 'api' || formValue.CHAT_AI_TYPE !== 'gpt'
}
},
GPT_API_KEY: {
el: {
autocomplete: 'new-password'
},
hidden: (formValue) => {
return formValue.CHAT_AI_METHOD !== 'api' || formValue.CHAT_AI_TYPE !== 'gpt'
}
},
GPT_PROXY: {
hidden: (formValue) => {
return formValue.CHAT_AI_METHOD !== 'api' || formValue.CHAT_AI_TYPE !== 'gpt'
}
},
GPT_MODEL: {
hidden: (formValue) => {
return formValue.CHAT_AI_METHOD !== 'api' || formValue.CHAT_AI_TYPE !== 'gpt'
}
},
DEEPSEEK_BASE_URL: {
el: {
autocomplete: 'new-password'
},
hidden: (formValue) => {
return formValue.CHAT_AI_METHOD !== 'api' || formValue.CHAT_AI_TYPE !== 'deep-seek'
}
},
DEEPSEEK_API_KEY: {
el: {
autocomplete: 'new-password'
},
hidden: (formValue) => {
return formValue.CHAT_AI_METHOD !== 'api' || formValue.CHAT_AI_TYPE !== 'deep-seek'
}
},
DEEPSEEK_PROXY: {
hidden: (formValue) => {
return formValue.CHAT_AI_METHOD !== 'api' || formValue.CHAT_AI_TYPE !== 'deep-seek'
}
},
DEEPSEEK_MODEL: {
hidden: (formValue) => {
return formValue.CHAT_AI_METHOD !== 'api' || formValue.CHAT_AI_TYPE !== 'deep-seek'
}
CHAT_AI_PROVIDERS: {
component: ChatProvidersField,
hidden: (formValue) => formValue.CHAT_AI_METHOD !== 'api'
},
CHAT_AI_EMBED_URL: {
hidden: (formValue) => formValue.CHAT_AI_METHOD !== 'embed'
}
},
afterGetFormValue(formValue) {
const providers = Array.isArray(formValue.CHAT_AI_PROVIDERS) ? formValue.CHAT_AI_PROVIDERS : []
return {
...formValue,
CHAT_AI_PROVIDERS: {
providers: providers
}
}
},
cleanFormValue(values) {
const config = values.CHAT_AI_PROVIDERS || {}
const providers = Array.isArray(config.providers) ? config.providers : []
return {
...values,
CHAT_AI_PROVIDERS: providers,
CHAT_AI_TYPE: values.CHAT_AI_TYPE || providers[0]?.type || values.CHAT_AI_TYPE
}
},
submitMethod() {
return 'patch'
}

View File

@@ -0,0 +1,203 @@
<template>
<div class="providers-card">
<div class="providers-header">
<el-button size="mini" type="primary" icon="el-icon-plus" @click="addProvider">
{{ $t('Add') }}
</el-button>
</div>
<el-table :data="localProviders" size="mini" border>
<el-table-column :label="$t('Type')" width="140">
<template #default="{ row }">
<el-select v-model="row.type" size="mini" @change="emitChange">
<el-option
v-for="item in typeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column :label="$t('Base URL')" min-width="200">
<template #default="{ row }">
<el-input
v-model="row.base_url"
size="mini"
placeholder="https://api.example.com/v1"
@input="emitChange"
/>
</template>
</el-table-column>
<el-table-column :label="$t('API Key')" min-width="180">
<template #default="{ row }">
<el-input
v-model="row.api_key"
size="mini"
show-password
autocomplete="new-password"
@input="emitChange"
/>
</template>
</el-table-column>
<el-table-column :label="$t('Proxy')" min-width="160">
<template #default="{ row }">
<el-input
v-model="row.proxy"
size="mini"
placeholder="http://ip:port"
@input="emitChange"
/>
</template>
</el-table-column>
<el-table-column :label="$t('Assistant')" min-width="160">
<template #default="{ row }">
<el-switch
v-model="row.is_assistant"
size="mini"
@change="handleAssistantChange(row)"
/>
</template>
</el-table-column>
<el-table-column :label="$t('Actions')" width="100" align="center">
<template #default="{ $index }">
<el-button
size="mini"
type="text"
class="danger-text"
icon="el-icon-delete"
@click="removeProvider($index)"
/>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
import cloneDeep from 'lodash/cloneDeep'
export default {
name: 'ChatProvidersField',
props: {
value: {
type: [Object, Array],
default: () => ({ providers: [], defaultProvider: '' })
},
typeOptions: {
type: Array,
default: () => ([
{ label: 'Ollama', value: 'ollama' },
{ label: 'OpenAI', value: 'openai' }
])
}
},
data() {
const { providers, defaultProvider } = this.normalizeValue(this.value)
return {
localProviders: providers.length ? providers : [this.emptyProvider()],
defaultProvider: defaultProvider || providers[0]?.name || ''
}
},
watch: {
value: {
handler(v) {
const { providers, defaultProvider } = this.normalizeValue(v)
this.localProviders = providers.length ? providers : [this.emptyProvider()]
this.defaultProvider = this.pickDefault(defaultProvider, providers)
},
deep: true
}
},
methods: {
emptyProvider() {
return {
type: 'openai',
base_url: 'https://api.openai.com/v1',
api_key: 'sk-JumpserveraAndWebOpenUI',
proxy: '',
is_assistant: false
}
},
normalizeValue(v) {
if (!v) {
return { providers: [], defaultProvider: 'openai' }
}
// allow legacy array value
if (Array.isArray(v)) {
return { providers: cloneDeep(v), defaultProvider: '' }
}
const providers = Array.isArray(v.providers) ? cloneDeep(v.providers) : []
return { providers, defaultProvider: v.defaultProvider || '' }
},
pickDefault(current, providers) {
const enabledProviders = providers.filter(item => item?.enabled !== false)
if (current && providers.some(item => item.name === current)) {
return current
}
return enabledProviders[0]?.name || providers[0]?.name || ''
},
emitChange() {
const providers = cloneDeep(this.localProviders)
const defaultProvider = this.pickDefault(this.defaultProvider, providers)
this.defaultProvider = defaultProvider
this.$emit('input', { providers, defaultProvider })
this.$emit('change', { providers, defaultProvider })
},
handleAssistantChange(current) {
if (current.IsAssistant) {
this.localProviders.forEach(item => {
if (item !== current) item.IsAssistant = false
})
}
this.emitChange()
},
addProvider() {
this.localProviders.push(this.emptyProvider())
this.emitChange()
},
removeProvider(index) {
this.localProviders.splice(index, 1)
if (this.localProviders.length === 0) {
this.localProviders.push(this.emptyProvider())
}
this.emitChange()
},
setDefault(name) {
this.defaultProvider = name
this.emitChange()
}
}
}
</script>
<style scoped lang="scss">
.providers-card {
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 12px;
}
.providers-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.title {
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.tip {
color: #909399;
font-weight: 400;
font-size: 12px;
}
}
.danger-text {
color: #f56c6c;
}
</style>