@@ -2,20 +2,31 @@
import { ref , onMounted , computed } from 'vue'
import { settingsApi , type LLMConfig , type SchedulerConfig , type LLMModelConfig , type LLMProvider } from '@/api/settings'
import LLMTableRow from '@/components/settings/LLMTableRow.vue'
import { Save , Eye , EyeOff , Play , RotateCcw , Plus , Trash2 , X , Check } from 'lucide-vue-next'
import { Save , RotateCcw , Plus } from 'lucide-vue-next'
// 状态
const loading = ref ( false )
const saving = ref ( false )
const showApiKey = ref < Record < string , boolean > > ( { } )
const savingModel = ref < string | null > ( null ) // 当前正在保存的模型 key
const modelSaveSuccess = ref < string | null > ( null ) // 刚刚保存成功的模型 key
const toast = ref < { show : boolean ; message : string ; type : 'success' | 'error' } > ( {
show : false ,
message : '' ,
type : 'success'
} )
// 展开的行
const expandedRow = ref < string | null > ( null ) // 'chat-0', 'vlm-0' 等
// 当前正在编辑的模型快照(用于取消时恢复)
const editingSnapshot = ref < { type : string ; index : number ; data : LLMModelConfig } | null > ( null )
// 必填警告
const showRequiredWarning = computed ( ( ) => {
return llmConfig . value . chat . length === 0 ||
llmConfig . value . embedding . length === 0 ||
llmConfig . value . rerank . length === 0
} )
// 用户资料
const profile = ref ( {
email : '' ,
@@ -69,11 +80,6 @@ function isModelDirty(type: string, index: number): boolean {
return JSON . stringify ( original ) !== JSON . stringify ( current )
}
// 获取模型唯一标识
function getModelKey ( type : string , index : number ) : string {
return ` ${ type } - ${ index } `
}
// 创建空的模型配置
function createEmptyModel ( type : string ) : LLMModelConfig {
return {
@@ -91,19 +97,64 @@ function addModel(type: string) {
if ( ! llmConfig . value [ type as keyof LLMConfig ] ) {
llmConfig . value [ type as keyof LLMConfig ] = [ ]
}
llmConfig . value [ type as keyof LLMConfig ] ! . push ( createEmptyModel ( type ) )
// embedding/rerank 最多 1 个
if ( ( type === 'embedding' || type === 'rerank' ) &&
llmConfig . value [ type as keyof LLMConfig ] ! . length >= 1 ) {
showToast ( ` ${ type === 'embedding' ? 'Embedding' : 'Rerank' } 最多配置 1 个 ` , 'error' )
return
}
const newModel = createEmptyModel ( type )
llmConfig . value [ type as keyof LLMConfig ] ! . push ( newModel )
// 自动展开新添加的行
const newIndex = llmConfig . value [ type as keyof LLMConfig ] ! . length - 1
expandedRow . value = getRowKey ( type , newIndex )
editingSnapshot . value = { type , index : newIndex , data : JSON . parse ( JSON . stringify ( newModel ) ) }
}
// 删除模型
function removeModel ( type : string , index : number ) {
// embedding/rerank 为知识库必填,至少保留 1 个
if ( ( type === 'embedding' || type === 'rerank' ) &&
llmConfig . value [ type as keyof LLMConfig ] ! . length <= 1 ) {
showToast ( ` ${ type === 'embedding' ? 'Embedding' : 'Rerank' } 为知识库必填,至少保留 1 个 ` , 'error' )
return
}
llmConfig . value [ type as keyof LLMConfig ] ! . splice ( index , 1 )
expandedRow . value = null
editingSnapshot . value = null
}
// 复制模型
function duplicateModel ( type : string , index : number ) {
const model = llmConfig . value [ type as keyof LLMConfig ] ! [ index ]
const copy = { ... model , name : ` ${ model . name } -copy- ${ Date . now ( ) } ` }
llmConfig . value [ type as keyof LLMConfig ] ! . push ( copy )
// 行标识
function getRowKey ( type : string , index : number ) : string {
return ` ${ type } - ${ index } `
}
// 切换行展开
function toggleRow ( type : string , index : number , model : LLMModelConfig ) {
const key = getRowKey ( type , index )
if ( expandedRow . value === key ) {
expandedRow . value = null
editingSnapshot . value = null
} else {
// 保存快照用于取消
editingSnapshot . value = { type , index , data : JSON . parse ( JSON . stringify ( model ) ) }
expandedRow . value = key
}
}
// 取消编辑
function cancelEdit ( type : string , index : number ) {
if ( editingSnapshot . value && editingSnapshot . value . type === type && editingSnapshot . value . index === index ) {
// 恢复原始数据
llmConfig . value [ type as keyof LLMConfig ] ! [ index ] = editingSnapshot . value . data
}
expandedRow . value = null
editingSnapshot . value = null
}
// 更新模型
function updateModel ( type : string , index : number , model : LLMModelConfig ) {
llmConfig . value [ type as keyof LLMConfig ] ! [ index ] = model
}
// 加载设置
@@ -119,16 +170,15 @@ async function loadSettings() {
originalProfile . value = { ... profile . value }
// 加载 LLM 配置
if ( res . data . llm _config && Object . keys ( res . data . llm _config ) . length > 0 ) {
llmConfig . value = res . data . llm _config as LLMConfig
} else {
// 默认添加一个空配置
if ( res . data . llm _config ) {
llmConfig . value = {
chat : [ createEmptyModel ( 'chat' ) ] ,
vlm : [ createEmptyModel ( 'vlm' ) ] ,
embedding : [ createEmptyModel ( 'embedding' ) ] ,
rerank : [ createEmptyModel ( 'rerank' ) ]
chat : res . data . llm _config . chat || [ ] ,
vlm : res . data . llm _config . vlm || [ ] ,
embedding : res . data . llm _config . embedding || [ ] ,
rerank : res . data . llm _config . rerank || [ ]
}
} else {
llmConfig . value = { chat : [ ] , vlm : [ ] , embedding : [ ] , rerank : [ ] }
}
originalLlmConfig . value = JSON . parse ( JSON . stringify ( llmConfig . value ) )
@@ -180,7 +230,7 @@ async function saveLLM() {
// 保存单个模型
async function saveModel ( type : string , index : number ) {
const key = getModel Key ( type , index )
const key = getRow Key ( type , index )
savingModel . value = key
try {
// 发送完整的配置(包含该类型的所有模型)
@@ -189,13 +239,10 @@ async function saveModel(type: string, index: number) {
// 更新原始配置(深拷贝当前完整配置)
originalLlmConfig . value = JSON . parse ( JSON . stringify ( llmConfig . value ) )
// 显示成功状态
modelSaveSuccess . value = key
setTimeout ( ( ) => {
if ( modelSaveSuccess . value === key ) {
modelSaveSuccess . value = null
}
} , 1500 )
// 关闭展开的行
expandedRow . value = null
editingSnapshot . value = null
showToast ( '保存成功' )
} catch ( e : unknown ) {
const msg = ( e as { response ? : { data ? : { detail ? : string } } } ) ? . response ? . data ? . detail || '保存失败'
showToast ( msg , 'error' )
@@ -205,15 +252,19 @@ async function saveModel(type: string, index: number) {
}
// 测试 LLM 连接
async function testLLM ( type : string , config : LLMModelConfig ) {
async function testModel ( type : string , index : number , model : LLMModelConfig ) {
try {
const res = await settingsApi . testLLM ( { type : type as any , ... config } )
const res = await settingsApi . testLLM ( { type : type as any , ... model } )
if ( res . data . success ) {
showToast ( ` 连接成功: ${ res . data . message } ` )
// 测试通过,标记为可用
llmConfig . value [ type as keyof LLMConfig ] ! [ index ] . enabled = true
showToast ( '连接成功' )
} else {
llmConfig . value [ type as keyof LLMConfig ] ! [ index ] . enabled = false
showToast ( ` 连接失败: ${ res . data . error } ` , 'error' )
}
} catch ( e ) {
llmConfig . value [ type as keyof LLMConfig ] ! [ index ] . enabled = false
showToast ( '测试连接失败' , 'error' )
}
}
@@ -247,24 +298,6 @@ function resetScheduler() {
schedulerConfig . value = JSON . parse ( JSON . stringify ( originalSchedulerConfig . value ) )
}
// Provider 默认 URL
function getDefaultBaseUrl ( provider : string ) : string {
switch ( provider ) {
case 'ollama' : return 'http://localhost:11434'
case 'openai' : return 'https://api.openai.com/v1'
case 'claude' : return 'https://api.anthropic.com'
case 'deepseek' : return 'https://api.deepseek.com/v1'
default : return ''
}
}
// Provider 变化时自动填充 base_url
function onProviderChange ( config : LLMModelConfig ) {
if ( ! config . base _url || config . base _url === getDefaultBaseUrl ( config . provider ) ) {
config . base _url = getDefaultBaseUrl ( config . provider )
}
}
// Toast 提示
function showToast ( message : string , type : 'success' | 'error' = 'success' ) {
toast . value = { show : true , message , type }
@@ -273,16 +306,6 @@ function showToast(message: string, type: 'success' | 'error' = 'success') {
} , 3000 )
}
// LLM 类型列表
const llmTypes = [ 'chat' , 'vlm' , 'embedding' , 'rerank' ] as const
// 获取模型显示名称
function getModelName ( config : LLMModelConfig ) : string {
if ( config . name ) return config . name
if ( config . model ) return config . model
return '未命名'
}
onMounted ( loadSettings )
< / script >
@@ -344,133 +367,113 @@ onMounted(loadSettings)
< / div >
<!-- LLM Config Section -- >
< div v-for = "type in llmTypes" :key="type" class= "settings-card">
< div class = "settings-card" >
< div class = "card-header" >
< span class = "card-title" > { { type . toUpperCase ( ) } } < / span >
< button class = "add-btn" @click ="addModel(type)" >
< Plus :size = "12" / > 添加模型
< / button >
< span class = "card-title" > // LLM CONFIGURATION</span>
< / div >
<!-- 模型列表 -- >
< div v-if = "llmConfig[type] && llmConfig[type]!.length > 0" class="model-list " >
< div
v-for = "(model, index) in llmConfig[type]"
:key = "index"
class = "model-item"
: class = "{ disabled: !model.enabled }"
>
< div class = "model-header" >
< div class = "model-name-group" >
< input
v-model = "model.name"
type = "text"
class = "model-name-input"
placeholder = "模型名称"
/ >
< label class = "model-enabled" >
< input type = "checkbox" v-model = "model.enabled" / >
< span class = "checkbox-custom" > < / span >
启用
< / label >
< / div >
< div class = "model-actions" >
< button
class = "icon-btn"
: class = "{
'has-changes': isModelDirty(type, index),
'is-saving': savingModel === getModelKey(type, index),
'is-saved': modelSaveSuccess === getModelKey(type, index)
}"
@click ="saveModel(type, index)"
: title = "isModelDirty(type, index) ? '保存更改' : '无更改'"
: disabled = "savingModel === getModelKey(type, index)"
>
< div v-if = "savingModel === getModelKey(type, index)" class="btn-spinner-sm" > < / div >
< Check v-else-if = "modelSaveSuccess === getModelKey(type, index)" :size="12" / >
< Save v-else :size = "12" / >
< span v-if = "isModelDirty(type, index) && modelSaveSuccess !== getModelKey(type, index)" class="unsaved-dot" > < / span >
< / button >
< button class = "icon-btn danger" @click ="removeModel(type, index)" title = "删除" >
< Trash2 :size = "12" / >
< / button >
< / div >
< / div >
<!-- 必填警告 -- >
< div v-if = "showRequiredWarning" class="warning-bar " >
⚠ chat / embedding / rerank 为知识库必填 , 请确保已配置
< / div >
< div class = "form-row" >
< div class = "form-group " >
< label class = "form-label" > // PROVIDER</label>
< select
v-model = "model.provider"
class = "form-select"
@change ="onProviderChange(model)"
>
< option value = "openai" > OpenAI < / option >
< option value = "claude" > Claude < / option >
< option value = "ollama" > Ollama < / option >
< option value = "deepseek" > DeepSeek < / option >
< option value = "custom" > Custom < / option >
< / select >
< / div >
< div class = "form-group" >
< label class = "form-label" > // MODEL</label>
< input v-model = "model.model" type="text" class="form-input" placeholder="gpt-4o" / >
< / div >
< / div >
< div class = "form-group" >
< label class = "form-label" > // BASE URL</label>
< input
v-model = "model.base_url"
type = "text"
class = "form-input"
:placeholder = "getDefaultBaseUrl(model.provider)"
< ! - - Chat Section - - >
< div class = "llm-type-section " >
< div class = "llm-type-header" >
< span class = "llm-type-title" > CHAT < / span >
< button class = "add-btn" @click ="addModel('chat')" >
< Plus :size = "12" / > 添加
< / button >
< / div >
< div v-if = "llmConfig.chat && llmConfig.chat.length > 0" class="model-table" >
< div v-for = "(model, index) in llmConfig.chat" :key="index" >
< LLMTableRow
:model = "model"
: is -expanded = " expandedRow = = = getRowKey ( ' chat ' , index ) "
@toggle ="toggleRow('chat', index, model)"
@update ="(m) => updateModel('chat', index, m)"
@delete ="removeModel('chat', index)"
@test ="(m) => testModel('chat', index, m)"
@save ="(m) => saveModel('chat', index)"
/ >
< / div >
< / div >
< div v-else class = "empty-state" > 暂无 chat 模型配置 < / div >
< / div >
< div class = "form-group" >
< label class = "form-label" > // API KEY</label>
< div class = "input-with-toggle " >
< input
v-model = "model.api_key"
: type = "showApiKey[`${type}-${index}`] ? 'text' : 'password'"
class = "form-input"
placeholder = "sk-..."
/ >
< button class = "toggle-visibility" @click ="showApiKey[`${type}-${index}`] = !showApiKey[`${type}-${ index}`] " >
< Eye v-if = "!showApiKey[`${type}-${index}`]" :size="14" / >
< EyeOff v-else :size = "14" / >
< / button >
< / div >
< / div >
< div class = "model-footer" >
< button class = "test-btn" @click ="testLLM(type, mo del )">
< Play :size = "12" / > 测试连接
< / button >
<!-- VLM Section -- >
< div class = "llm-type-section" >
< div class = "llm-type-header " >
< span class = "llm-type-title" > VLM < span class = "optional-tag" > ( 可选 ) < / span > < / span >
< button class = "add-btn" @click ="addModel('vlm')" >
< Plus :size = "12" / > 添加
< / button >
< / div >
< div v-if = "llmConfig.vlm && llmConfig.vlm.length > 0" class="model-table" >
< div v-for = "(model, index) in llmConfig.vlm" :key=" index">
< LLMTableRow
:model = "model"
: is -expanded = " expandedRow = = = getRowKey ( ' vlm ' , index ) "
@toggle ="toggleRow('vlm', index, model)"
@update ="(m) => updateModel('vlm', index, m)"
@delete ="removeModel('vlm', index)"
@test ="(m) => testModel('vlm', index, m)"
@save ="(m) => saveModel('vlm', in dex )"
/ >
< / div >
< / div >
< div v-else class = "empty-state" > 暂无 vlm 模型配置 < / div >
< / div >
<!-- 空状态 -- >
< div v-else class = "empty-state " >
< span > 暂无模型配置 < / span >
< butto n class = "add-btn" @click ="addModel(type)" >
< Plus :size = "12" / > 添加第一个模型
< / button >
<!-- Embedding Section -- >
< div class = "llm-type-section " >
< div class = "llm-type-header" >
< spa n class = "llm-type-title" > EMBEDDING < span class = "required-tag" > ( 知识库 ) < / span > < / span >
< button class = "add-btn" @click ="addModel('embedding')" >
< Plus :size = "12" / > 添加
< / button >
< / div >
< div v-if = "llmConfig.embedding && llmConfig.embedding.length > 0" class="model-table" >
< div v-for = "(model, index) in llmConfig.embedding" :key="index" >
< LLMTableRow
:model = "model"
: is -expanded = " expandedRow = = = getRowKey ( ' embedding ' , index ) "
@toggle ="toggleRow('embedding', index, model)"
@update ="(m) => updateModel('embedding', index, m)"
@delete ="removeModel('embedding', index)"
@test ="(m) => testModel('embedding', index, m)"
@save ="(m) => saveModel('embedding', index)"
/ >
< / div >
< / div >
< div v-else class = "empty-state" > 暂无 embedding 模型配置 < / div >
< / div >
<!-- Rerank Section -- >
< div class = "llm-type-section" >
< div class = "llm-type-header" >
< span class = "llm-type-title" > RERANK < span class = "required-tag" > ( 知识库 ) < / span > < / span >
< button class = "add-btn" @click ="addModel('rerank')" >
< Plus :size = "12" / > 添加
< / button >
< / div >
< div v-if = "llmConfig.rerank && llmConfig.rerank.length > 0" class="model-table" >
< div v-for = "(model, index) in llmConfig.rerank" :key="index" >
< LLMTableRow
:model = "model"
: is -expanded = " expandedRow = = = getRowKey ( ' rerank ' , index ) "
@toggle ="toggleRow('rerank', index, model)"
@update ="(m) => updateModel('rerank', index, m)"
@delete ="removeModel('rerank', index)"
@test ="(m) => testModel('rerank', index, m)"
@save ="(m) => saveModel('rerank', index)"
/ >
< / div >
< / div >
< div v-else class = "empty-state" > 暂无 rerank 模型配置 < / div >
< / div >
< / div >
< button
class = "save-btn full-width"
@click ="saveLLM"
: disabled = "saving || !isLlmDirty"
>
< div v-if = "saving" class="btn-loader" > < / div >
< Save v-else :size = "14" / >
< span > { { saving ? 'SAVING...' : 'SAVE LLM CONFIG' } } < / span >
< / button >
<!-- Scheduler Section -- >
< div class = "settings-card" >
< div class = "card-header" >
@@ -692,169 +695,47 @@ onMounted(loadSettings)
color : var ( -- accent - cyan ) ;
}
/* Model List */
. model - list {
display : fle x;
flex - direction : column ;
gap : 12 px ;
/* LLM Type Section */
. llm - type - section {
margin - bottom : 20 p x;
}
. model - item {
background : var ( -- bg - card ) ;
border : 1 px solid var ( -- border - mid ) ;
border - radius : var ( -- radius - md ) ;
padding : 14 px ;
transition : all var ( -- transition - fast ) ;
}
. model - item . disabled {
opacity : 0.5 ;
}
. model - item : hover {
border - color : var ( -- border - mid ) ;
}
. model - header {
. llm - type - header {
display : flex ;
align - items : center ;
justify - content : space - between ;
margin - bottom : 12 px ;
margin - bottom : 10 px ;
}
. model - nam e- group {
display : flex ;
align - items : center ;
gap : 12 px ;
. llm - typ e- title {
font - family : var ( -- font - display ) ;
font - size : 11 px ;
letter - spacing : 0.15 em ;
color : var ( -- accent - cyan ) ;
}
. model - name - input {
background : var ( -- bg - void ) ;
borde r: 1 px solid var ( -- border - dim ) ;
. optional - tag {
font - size : 9 px ;
colo r: var ( -- text - dim ) ;
letter - spacing : 0.1 em ;
}
. required - tag {
font - size : 9 px ;
color : var ( -- accent - red ) ;
letter - spacing : 0.1 em ;
}
/* Warning Bar */
. warning - bar {
padding : 10 px 14 px ;
background : rgba ( 239 , 68 , 68 , 0.1 ) ;
border : 1 px solid rgba ( 239 , 68 , 68 , 0.3 ) ;
border - radius : var ( -- radius - sm ) ;
padding : 4 px 8 px ;
color : var ( -- text - primary ) ;
color : var ( -- accent - red ) ;
font - family : var ( -- font - mono ) ;
font - size : 11 px ;
width : 150 px ;
}
. model - name - input : focus {
outline : none ;
border - color : var ( -- accent - cyan ) ;
}
. model - enabled {
display : flex ;
align - items : center ;
gap : 6 px ;
cursor : pointer ;
font - family : var ( -- font - mono ) ;
font - size : 10 px ;
color : var ( -- text - dim ) ;
}
. model - enabled input {
display : none ;
}
. checkbox - custom {
width : 14 px ;
height : 14 px ;
border : 1 px solid var ( -- border - mid ) ;
border - radius : 3 px ;
background : var ( -- bg - void ) ;
position : relative ;
}
. model - enabled input : checked + . checkbox - custom {
background : var ( -- accent - cyan ) ;
border - color : var ( -- accent - cyan ) ;
}
. model - enabled input : checked + . checkbox - custom : : after {
content : '✓' ;
position : absolute ;
top : 50 % ;
left : 50 % ;
transform : translate ( - 50 % , - 50 % ) ;
color : var ( -- bg - void ) ;
font - size : 10 px ;
}
. model - actions {
display : flex ;
gap : 4 px ;
}
. icon - btn {
width : 26 px ;
height : 26 px ;
display : flex ;
align - items : center ;
justify - content : center ;
background : transparent ;
border : 1 px solid var ( -- border - mid ) ;
border - radius : var ( -- radius - sm ) ;
color : var ( -- text - dim ) ;
cursor : pointer ;
transition : all var ( -- transition - fast ) ;
}
. icon - btn : hover {
border - color : var ( -- accent - cyan ) ;
color : var ( -- accent - cyan ) ;
}
. icon - btn . danger : hover {
border - color : var ( -- accent - red ) ;
color : var ( -- accent - red ) ;
}
/* 保存按钮状态 */
. icon - btn . has - changes {
border - color : var ( -- accent - cyan ) ;
color : var ( -- accent - cyan ) ;
position : relative ;
}
. icon - btn . is - saving {
opacity : 0.7 ;
cursor : not - allowed ;
}
. icon - btn . is - saved {
border - color : # 10 b981 ;
color : # 10 b981 ;
}
. unsaved - dot {
position : absolute ;
top : - 3 px ;
right : - 3 px ;
width : 6 px ;
height : 6 px ;
background : var ( -- accent - red ) ;
border - radius : 50 % ;
}
. btn - spinner - sm {
width : 12 px ;
height : 12 px ;
border : 2 px solid var ( -- border - mid ) ;
border - top - color : var ( -- accent - cyan ) ;
border - radius : 50 % ;
animation : spin 0.8 s linear infinite ;
}
@ keyframes spin {
to { transform : rotate ( 360 deg ) ; }
}
. model - footer {
margin - top : 10 px ;
padding - top : 10 px ;
border - top : 1 px solid var ( -- border - dim ) ;
margin - bottom : 16 px ;
}
/* Empty State */