Compare commits

..

1 Commits

Author SHA1 Message Date
feng
62a935668b perf: Open web ui settigns 2025-11-25 17:49:06 +08:00
3 changed files with 272 additions and 123 deletions

View File

@@ -146,7 +146,7 @@ export default {
})
},
showSecretDialog() {
return this.$axios.get(this.url).then((res) => {
return this.$axios.get(this.url, { disableFlashErrorMsg: true }).then((res) => {
this.secretInfo = res
this.sshKeyFingerprint = res?.spec_info?.ssh_key_fingerprint || '-'
this.showSecret = true
@@ -167,54 +167,54 @@ export default {
</script>
<style lang="scss" scoped>
.item-textarea ::v-deep .el-textarea__inner {
height: 110px;
}
.el-form-item {
border-bottom: 1px solid #EBEEF5;
padding: 5px 0;
margin-bottom: 0;
&:last-child {
border-bottom: none;
.item-textarea ::v-deep .el-textarea__inner {
height: 110px;
}
::v-deep .el-form-item__label {
display: flex;
align-items: center;
justify-content: flex-start;
padding-right: 20px;
line-height: 30px;
word-break: keep-all;
overflow-wrap: break-word;
white-space: normal;
}
.el-form-item {
border-bottom: 1px solid #EBEEF5;
padding: 5px 0;
margin-bottom: 0;
::v-deep .el-form-item__content {
line-height: 30px;
&:last-child {
border-bottom: none;
}
pre {
margin: 0;
::v-deep .el-form-item__label {
display: flex;
align-items: center;
justify-content: flex-start;
padding-right: 20px;
line-height: 30px;
word-break: keep-all;
overflow-wrap: break-word;
white-space: normal;
}
::v-deep .el-form-item__content {
line-height: 30px;
pre {
margin: 0;
}
}
}
}
ul {
margin: 0;
}
li {
display: block;
font-size: 13px;
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.title {
color: #303133;
font-weight: 500;
ul {
margin: 0;
}
li {
display: block;
font-size: 13px;
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.title {
color: #303133;
font-weight: 500;
}
}
}
</style>

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,200 @@
<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,
defaultProvider: this.pickDefault(defaultProvider, providers)
}
},
watch: {
value: {
handler(v) {
const { providers, defaultProvider } = this.normalizeValue(v)
this.localProviders = providers
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: '' }
}
// 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)
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>