fix(ui):优化模型管理器组件结构和功能

- 将文本模型和图像模型管理分离到独立组件
- 更新图像模型编辑模态框的自动完成属性
-重构模型管理器UI布局和交互逻辑
- 移除冗余的SVG图标重复代码- 改进模型管理器的标签页切换功能
-优化模型管理器的依赖注入和事件处理
- 简化模型管理器的代码结构提高可维护性
This commit is contained in:
linshen committed 2025-10-07 11:28:10 +08:00
1 parent eced9ca198
commit e7f3d23454
10 files changed
+1585 -1710

No files matched your search

@@ -45,7 +45,7 @@
:type="field.name.toLowerCase().includes('key') ? 'password' : 'text'"
:placeholder="field.placeholder"
:required="field.required"
:autocomplete="field.name.toLowerCase().includes('key') ? 'off' : 'on'"
:autocomplete="field.name.toLowerCase().includes('key') ? 'new-password' : 'on'"
@update:value="onConnectionConfigChange"
/>
</template>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,422 @@
<template>
<NModal
:show="show"
preset="card"
:style="{ width: '90vw', maxWidth: '1000px', maxHeight: '90vh' }"
:title="modalTitle.value"
size="large"
:bordered="false"
:segmented="true"
@update:show="handleUpdateShow"
>
<NScrollbar v-if="formReady" style="max-height: 75vh;">
<form @submit.prevent="handleSubmit">
<NForm label-placement="left" label-width="auto" size="small">
<NFormItem v-if="!isEditing" :label="t('modelManager.modelKey')">
<NInput
v-model:value="form.id"
:placeholder="t('modelManager.modelKeyPlaceholder')"
required
/>
</NFormItem>
<NFormItem :label="t('modelManager.displayName')">
<NInput
v-model:value="form.name"
:placeholder="t('modelManager.displayNamePlaceholder')"
required
/>
</NFormItem>
<NDivider style="margin: 12px 0 8px 0;" />
<NH4 style="margin: 0 0 12px 0; font-size: 14px;">提供商配置</NH4>
<NFormItem label="提供商">
<NSelect
v-model:value="form.providerId"
:options="providerOptions"
:loading="isLoadingProviders"
placeholder="选择提供商"
@update:value="onProviderChange"
required
/>
</NFormItem>
<NFormItem
v-for="field in connectionFields"
:key="field.name"
:label="field.name === 'apiKey' ? t('modelManager.apiKey') : (field.name === 'baseURL' ? t('modelManager.apiUrl') : field.name)"
>
<template v-if="field.name === 'baseURL'" #label>
<NSpace align="center" :size="4">
<span>{{ t('modelManager.apiUrl') }}</span>
<NText depth="3" :title="t('modelManager.apiUrlHint')" style="cursor: help;">?</NText>
</NSpace>
</template>
<template v-if="field.type === 'string'">
<NInput
v-model:value="form.connectionConfig[field.name]"
:type="field.name.toLowerCase().includes('key') ? 'password' : 'text'"
:placeholder="field.placeholder"
:required="field.required"
:autocomplete="field.name.toLowerCase().includes('key') ? 'new-password' : 'on'"
/>
</template>
<template v-else-if="field.type === 'number'">
<NInputNumber
v-model:value="form.connectionConfig[field.name]"
:placeholder="field.placeholder"
:required="field.required"
/>
</template>
<template v-else-if="field.type === 'boolean'">
<NCheckbox v-model:checked="form.connectionConfig[field.name]">
{{ field.name }}
</NCheckbox>
</template>
</NFormItem>
<NDivider style="margin: 12px 0 8px 0;" />
<NH4 style="margin: 0 0 12px 0; font-size: 14px;">模型配置</NH4>
<NFormItem :label="t('modelManager.defaultModel')">
<NSpace align="center" style="width: 100%;">
<NSelect
v-model:value="form.modelId"
:options="modelOptions"
:loading="isLoadingModelOptions"
:placeholder="t('modelManager.defaultModelPlaceholder')"
style="flex: 1; min-width: 200px;"
clearable
filterable
required
/>
<NTooltip :disabled="!canRefreshModelOptions" :show-arrow="false">
<template #trigger>
<NButton
@click="refreshModelOptions()"
:loading="isLoadingModelOptions"
:disabled="!canRefreshModelOptions"
circle
secondary
type="primary"
size="small"
style="flex-shrink: 0;"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;">
<polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
</svg>
</template>
</NButton>
</template>
{{ t('modelManager.clickToFetchModels') }}
</NTooltip>
</NSpace>
</NFormItem>
</NForm>
<NDivider style="margin: 12px 0 8px 0;" />
<NSpace justify="space-between" align="center" style="margin-bottom: 12px;">
<NSpace vertical :size="4">
<NH4 style="margin: 0; font-size: 14px;">{{ t('modelManager.advancedParameters.title') }}</NH4>
<NText depth="3" style="font-size: 12px;">
{{ t('modelManager.advancedParameters.currentProvider') }}:
<NText strong>
{{ currentProviderType === 'custom' ? t('modelManager.advancedParameters.customProvider') : currentProviderType.toUpperCase() }}
</NText>
<span v-if="availableLLMParamDefinitions.length > 0">
({{ availableLLMParamDefinitions.length }}{{ t('modelManager.advancedParameters.availableParams') }})
</span>
<NText v-else type="warning">
({{ t('modelManager.advancedParameters.noAvailableParams') }})
</NText>
</NText>
</NSpace>
<NSelect
v-model:value="selectedNewLLMParamId"
@update:value="handleQuickAddParam"
style="width: 220px;"
size="small"
:options="[
{ label: t('modelManager.advancedParameters.select'), value: '', disabled: true },
...availableLLMParamDefinitions.map(paramDef => ({
label: paramDef.labelKey ? t(paramDef.labelKey) : paramDef.name,
value: paramDef.id
})),
{ label: t('modelManager.advancedParameters.custom'), value: 'custom' }
]"
/>
</NSpace>
<NCard v-if="selectedNewLLMParamId === 'custom'" size="small" style="margin: 12px 0;">
<template #header>
<NSpace justify="space-between" align="center">
<NText strong>{{ t('modelManager.advancedParameters.custom') }}</NText>
<NButton @click="cancelCustomParam" size="tiny" quaternary circle>×</NButton>
</NSpace>
</template>
<NSpace vertical :size="12">
<NSpace vertical :size="8">
<NText depth="3" style="font-size: 12px;">参数名称</NText>
<NInput
v-model:value="customLLMParam.key"
:placeholder="t('modelManager.advancedParameters.customKeyPlaceholder')"
size="small"
/>
</NSpace>
<NSpace vertical :size="8">
<NText depth="3" style="font-size: 12px;">参数值</NText>
<NInput
v-model:value="customLLMParam.value"
:placeholder="t('modelManager.advancedParameters.customValuePlaceholder')"
size="small"
/>
</NSpace>
<NSpace justify="end">
<NButton @click="cancelCustomParam" size="small">{{ t('common.cancel') }}</NButton>
<NButton
@click="handleCustomParamAdd"
type="primary"
size="small"
:disabled="!customLLMParam.key || !customLLMParam.value"
>
{{ t('common.add') }}
</NButton>
</NSpace>
</NSpace>
</NCard>
<NText
v-if="Object.keys(currentParamOverrides || {}).length === 0"
depth="3"
style="font-size: 14px; margin-bottom: 12px;"
>
{{ t('modelManager.advancedParameters.noParamsConfigured') }}
</NText>
<NSpace vertical :size="12">
<NCard
v-for="(value, key) in currentParamOverrides"
:key="key"
size="small"
embedded
>
<template #header>
<NSpace justify="space-between" align="center">
<NSpace align="center">
<NText strong>{{ getParamMetadata(key)?.label || key }}</NText>
<NTag v-if="!getParamMetadata(key)" type="info" size="small">
{{ t('modelManager.advancedParameters.customParam') }}
</NTag>
</NSpace>
<NButton
@click="removeLLMParam(key)"
size="tiny"
type="error"
quaternary
circle
>
×
</NButton>
</NSpace>
</template>
<NSpace vertical :size="8">
<NText v-if="getParamMetadata(key)?.description" depth="3" style="font-size: 12px;">
{{ getParamMetadata(key)?.description }}
</NText>
<NCheckbox
v-if="getParamMetadata(key)?.type === 'boolean'"
v-model:checked="currentParamOverrides[key]"
>
{{ currentParamOverrides[key] ? t('common.enabled') : t('common.disabled') }}
</NCheckbox>
<NSpace
v-else-if="getParamMetadata(key)?.type === 'number' || (getParamMetadata(key)?.type === 'integer' && getParamMetadata(key)?.name !== 'stopSequences')"
vertical
:size="4"
>
<NInputNumber
v-model:value="currentParamOverrides[key] as number"
:min="getParamMetadata(key)?.minValue"
:max="getParamMetadata(key)?.maxValue"
:step="getParamMetadata(key)?.step"
:placeholder="getParamMetadata(key)?.defaultValue !== undefined ? String(getParamMetadata(key)?.defaultValue) : ''"
:status="isParamInvalid(key, currentParamOverrides[key]) ? 'error' : undefined"
/>
<NText
v-if="getParamMetadata(key)?.minValue !== undefined && getParamMetadata(key)?.maxValue !== undefined"
depth="3"
style="font-size: 12px;"
>
范围: {{ getParamMetadata(key)?.minValue }} - {{ getParamMetadata(key)?.maxValue }}{{ getParamMetadata(key)?.unit || '' }}
</NText>
<NText
v-if="isParamInvalid(key, currentParamOverrides[key])"
type="error"
style="font-size: 12px;"
>
{{ getParamValidationMessage(key, currentParamOverrides[key]) }}
</NText>
</NSpace>
<NSpace v-else vertical :size="4">
<NInput
v-model:value="currentParamOverrides[key] as string"
:placeholder="getParamMetadata(key)?.name === 'stopSequences' ? t('modelManager.advancedParameters.stopSequencesPlaceholder') : (getParamMetadata(key)?.defaultValue !== undefined ? String(getParamMetadata(key)?.defaultValue) : '')"
/>
<NText
v-if="getParamMetadata(key)?.name === 'stopSequences'"
depth="3"
style="font-size: 12px;"
>
{{ t('modelManager.advancedParameters.stopSequencesPlaceholder') }}
</NText>
</NSpace>
</NSpace>
</NCard>
</NSpace>
</form>
</NScrollbar>
<div v-else style="height: 200px; display: flex; align-items: center; justify-content: center;">
<NSpin />
</div>
<template #action>
<NSpace justify="space-between" align="center" style="width: 100%;">
<NSpace align="center">
<NButton
@click="testFormConnection"
:loading="isTestingFormConnection"
:disabled="!canTestFormConnection"
secondary
type="info"
size="small"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</template>
{{ t('modelManager.testConnection') }}
</NButton>
<NTag v-if="formConnectionStatus" :type="formConnectionStatus.type" size="small" :bordered="false">
{{ formConnectionStatus.message }}
</NTag>
</NSpace>
<NSpace>
<NButton @click="handleCancel">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="isSaving" @click="handleSubmit">
{{ isEditing ? t('common.save') : t('common.create') }}
</NButton>
</NSpace>
</NSpace>
</template>
</NModal>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import {
NModal,
NScrollbar,
NForm,
NFormItem,
NH4,
NInput,
NInputNumber,
NSelect,
NCheckbox,
NSpace,
NButton,
NDivider,
NText,
NTag,
NTooltip,
NCard,
NSpin
} from 'naive-ui'
import type { TextModelManager } from '../composables/useTextModelManager'
const props = defineProps({
show: {
type: Boolean,
default: false
},
configId: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:show', 'saved'])
const { t } = useI18n()
const manager = inject<TextModelManager>('textModelManager')
if (!manager) {
throw new Error('Text model manager not provided')
}
const modalTitle = manager.modalTitle
const form = manager.form
const formReady = manager.formReady
const providerOptions = manager.providerOptions
const isLoadingProviders = manager.isLoadingProviders
const connectionFields = manager.connectionFields
const modelOptions = manager.modelOptions
const isLoadingModelOptions = manager.isLoadingModelOptions
const availableLLMParamDefinitions = manager.availableLLMParamDefinitions
const canRefreshModelOptions = manager.canRefreshModelOptions
const refreshModelOptions = manager.refreshModelOptions
const selectedNewLLMParamId = manager.selectedNewLLMParamId
const handleQuickAddParam = manager.handleQuickAddParam
const customLLMParam = manager.customLLMParam
const cancelCustomParam = manager.cancelCustomParam
const handleCustomParamAdd = manager.handleCustomParamAdd
const currentParamOverrides = manager.currentParamOverrides
const currentProviderType = manager.currentProviderType
const getParamMetadata = manager.getParamMetadata
const removeLLMParam = manager.removeLLMParam
const isParamInvalid = manager.isParamInvalid
const getParamValidationMessage = manager.getParamValidationMessage
const formConnectionStatus = manager.formConnectionStatus
const testFormConnection = manager.testFormConnection
const isTestingFormConnection = manager.isTestingFormConnection
const canTestFormConnection = manager.canTestFormConnection
const isSaving = manager.isSaving
const isEditing = computed(() => !!manager.editingModelId.value)
const handleUpdateShow = (value: boolean) => {
if (!value) {
manager.resetFormState()
}
emit('update:show', value)
}
const handleSubmit = async () => {
const id = await manager.saveForm()
emit('saved', id || undefined)
handleUpdateShow(false)
}
const handleCancel = () => {
handleUpdateShow(false)
}
const onProviderChange = (providerId: string) => {
manager.selectProvider(providerId, !isEditing.value)
}
</script>
@@ -0,0 +1,201 @@
<template>
<NSpace vertical :size="12">
<NCard
v-for="model in models"
:key="model.id"
hoverable
:style="{ opacity: model.enabled ? 1 : 0.6 }"
>
<template #header>
<NSpace justify="space-between" align="center">
<NSpace vertical :size="2">
<NSpace align="center">
<NText strong>{{ model.name }}</NText>
<NTag v-if="!model.enabled" type="warning" size="small">
{{ t('modelManager.disabled') }}
</NTag>
</NSpace>
<NSpace :size="6">
<NTag size="small" type="info" :bordered="false">
{{ model.providerMeta?.name || model.providerMeta?.id }}
</NTag>
<NTag size="small" type="primary" :bordered="false">
{{ model.modelMeta?.name || model.modelMeta?.id }}
</NTag>
<NTag
v-if="model.modelMeta?.capabilities?.supportsTools"
size="small"
type="success"
:bordered="false"
>
{{ t('modelManager.capabilities.tools') }}
</NTag>
<NTag
v-if="model.modelMeta?.capabilities?.supportsReasoning"
size="small"
type="warning"
:bordered="false"
>
{{ t('modelManager.capabilities.reasoning') }}
</NTag>
<NTag
v-if="model.modelMeta?.capabilities?.supportsStreaming"
size="small"
:bordered="false"
>
{{ t('modelManager.capabilities.streaming') }}
</NTag>
</NSpace>
</NSpace>
</NSpace>
</template>
<template #header-extra>
<NSpace @click.stop>
<NButton
@click="emit('test', model.id)"
size="small"
quaternary
:disabled="isTestingConnectionFor(model.id)"
:loading="isTestingConnectionFor(model.id)"
>
<template #icon>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
</template>
<span class="hidden md:inline">{{ t('modelManager.testConnection') }}</span>
</NButton>
<NButton @click="emit('edit', model.id)" size="small" quaternary>
<template #icon>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-4 w-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"
/>
</svg>
</template>
<span class="hidden md:inline">{{ t('modelManager.editModel') }}</span>
</NButton>
<NButton
@click="emit(model.enabled ? 'disable' : 'enable', model.id)"
size="small"
:type="model.enabled ? 'warning' : 'success'"
quaternary
>
<template #icon>
<svg
v-if="model.enabled"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
>
<path d="M12 6v.343" />
<path d="M18.218 18.218A7 7 0 0 1 5 15V9a7 7 0 0 1 .782-3.218" />
<path d="M19 13.343V9A7 7 0 0 0 8.56 2.902" />
<path d="M22 22 2 2" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
>
<rect x="5" y="2" width="14" height="20" rx="7" />
<path d="M12 6v4" />
</svg>
</template>
<span class="hidden md:inline">
{{ model.enabled ? t('common.disable') : t('common.enable') }}
</span>
</NButton>
<NButton
v-if="!isDefaultModel(model.id)"
@click="emit('delete', model.id)"
size="small"
type="error"
quaternary
>
<template #icon>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-4 w-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</template>
<span class="hidden md:inline">{{ t('common.delete') }}</span>
</NButton>
</NSpace>
</template>
</NCard>
</NSpace>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
import { useI18n } from 'vue-i18n'
import { NButton, NCard, NTag, NText, NSpace } from 'naive-ui'
import type { TextModelConfig } from '@prompt-optimizer/core'
const props = defineProps({
models: {
type: Array as PropType<TextModelConfig[]>,
default: () => []
},
isTestingConnectionFor: {
type: Function as PropType<(id: string) => boolean>,
required: true
},
isDefaultModel: {
type: Function as PropType<(id: string) => boolean>,
required: true
}
})
const emit = defineEmits(['test', 'edit', 'enable', 'disable', 'delete'])
const { t } = useI18n()
</script>
@@ -0,0 +1,109 @@
<template>
<div class="text-model-manager">
<TextModelList
:models="manager.models.value"
:is-testing-connection-for="isTestingConnectionFor"
:is-default-model="manager.isDefaultModel"
@test="handleTestConnection"
@edit="handleEditModel"
@enable="handleEnableModel"
@disable="handleDisableModel"
@delete="handleDeleteModel"
/>
<TextModelEditModal
:show="showEditModal"
:config-id="editingModelId || ''"
@update:show="updateEditModalVisibility"
@saved="handleModelUpdated"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTextModelManager } from '../composables/useTextModelManager'
import TextModelList from './TextModelList.vue'
import TextModelEditModal from './TextModelEditModal.vue'
const emit = defineEmits(['modelsUpdated'])
const { t } = useI18n()
const manager = useTextModelManager()
provide('textModelManager', manager)
const showEditModal = ref(false)
const editingModelId = ref<string | null>(null)
const isTestingConnectionFor = (id: string) => !!manager.testingConnections.value[id]
const handleModelUpdated = async (id?: string) => {
await manager.loadModels()
const targetId = id || manager.models.value[0]?.id
if (targetId) {
emit('modelsUpdated', targetId)
}
}
const handleTestConnection = async (id: string) => {
await manager.testConfigConnection(id)
}
const handleEditModel = async (id: string) => {
await manager.prepareForEdit(id)
editingModelId.value = id
showEditModal.value = true
}
const updateEditModalVisibility = (value: boolean) => {
showEditModal.value = value
if (!value) {
editingModelId.value = null
manager.resetFormState()
}
}
const handleEnableModel = async (id: string) => {
await manager.enableModel(id)
emit('modelsUpdated', id)
}
const handleDisableModel = async (id: string) => {
await manager.disableModel(id)
emit('modelsUpdated', id)
}
const handleDeleteModel = async (id: string) => {
if (confirm(t('modelManager.deleteConfirm'))) {
await manager.deleteModel(id)
const firstId = manager.models.value[0]?.id
if (firstId) {
emit('modelsUpdated', firstId)
}
}
}
const openAddModal = async () => {
await manager.prepareForCreate()
editingModelId.value = null
showEditModal.value = true
}
defineExpose({
openAddModal,
refresh: manager.loadModels
})
onMounted(async () => {
await manager.loadProviders()
await manager.loadModels()
const firstId = manager.models.value[0]?.id
if (firstId) {
emit('modelsUpdated', firstId)
}
})
</script>
<style scoped>
.text-model-manager {
width: 100%;
}
</style>
@@ -0,0 +1,748 @@
import { ref, computed, inject, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from './useToast'
import {
advancedParameterDefinitions,
type ModelOption,
type TextModel,
type TextModelConfig,
type TextProvider
} from '@prompt-optimizer/core'
type TextConnectionValue = string | number | boolean | undefined
interface TextConnectionConfig {
[key: string]: TextConnectionValue
}
interface TextModelForm {
id: string
originalId?: string
name: string
enabled: boolean
providerId: string
modelId: string
connectionConfig: TextConnectionConfig
paramOverrides: Record<string, unknown>
displayMaskedKey: boolean
originalApiKey?: string
defaultModel?: string
}
const DEFAULT_TEXT_MODEL_IDS = ['openai', 'gemini', 'deepseek', 'zhipu', 'siliconflow', 'custom'] as const
export function useTextModelManager() {
const { t } = useI18n()
const toast = useToast()
const services = inject<any>('services')
if (!services) {
throw new Error('Services not provided!')
}
const modelManager = services.value.modelManager
const llmService = services.value.llmService
const textAdapterRegistry = services.value.textAdapterRegistry
const models = ref<TextModelConfig[]>([])
const loadingModels = ref(false)
const testingConnections = ref<Record<string, boolean>>({})
const providers = ref<TextProvider[]>([])
const providersLoaded = ref(false)
const isLoadingProviders = ref(false)
const form = ref<TextModelForm>({
id: '',
name: '',
enabled: true,
providerId: '',
modelId: '',
connectionConfig: {},
paramOverrides: {},
displayMaskedKey: false
})
const editingModelId = ref<string | null>(null)
const formReady = ref(false)
const isSaving = ref(false)
const isTestingFormConnection = ref(false)
const formConnectionStatus = ref<{
type: 'success' | 'error' | 'warning' | 'info'
message: string
} | null>(null)
const modelOptions = ref<ModelOption[]>([])
const isLoadingModelOptions = ref(false)
const selectedNewLLMParamId = ref('')
const customLLMParam = ref({ key: '', value: '' })
const currentParamOverrides = computed(() => form.value.paramOverrides || {})
const currentProviderType = computed(() => form.value.providerId || 'custom')
const providerOptions = computed(() =>
providers.value.map(provider => ({
label: provider.name,
value: provider.id,
disabled: false
}))
)
const selectedProvider = computed(() => {
if (!form.value.providerId) return null
return providers.value.find(p => p.id === form.value.providerId) || null
})
const connectionFields = computed(() => {
if (!selectedProvider.value?.connectionSchema) return []
const schema = selectedProvider.value.connectionSchema
const fields: Array<{ name: string; required: boolean; type: string; placeholder?: string }> = []
for (const fieldName of schema.required) {
fields.push({
name: fieldName,
required: true,
type: schema.fieldTypes[fieldName] || 'string',
placeholder: fieldName === 'baseURL' ? selectedProvider.value.defaultBaseURL : ''
})
}
for (const fieldName of schema.optional) {
fields.push({
name: fieldName,
required: false,
type: schema.fieldTypes[fieldName] || 'string',
placeholder: fieldName === 'baseURL' ? selectedProvider.value.defaultBaseURL : ''
})
}
return fields
})
const availableLLMParamDefinitions = computed(() => {
if (!advancedParameterDefinitions) return []
const currentParams = currentParamOverrides.value || {}
const provider = currentProviderType.value || 'custom'
return advancedParameterDefinitions.filter(def =>
def.appliesToProviders.includes(provider) &&
!Object.prototype.hasOwnProperty.call(currentParams, def.name)
)
})
const canTestFormConnection = computed(() => !!editingModelId.value && !isTestingFormConnection.value)
const canRefreshModelOptions = computed(() => !!selectedProvider.value && !isLoadingModelOptions.value)
const modalTitle = computed(() => (editingModelId.value ? t('modelManager.editModel') : t('modelManager.addModel')))
const isDefaultModel = (id: string) => {
return DEFAULT_TEXT_MODEL_IDS.includes(id as typeof DEFAULT_TEXT_MODEL_IDS[number])
}
const resetFormState = () => {
form.value = {
id: '',
name: '',
enabled: true,
providerId: '',
modelId: '',
connectionConfig: {},
paramOverrides: {},
displayMaskedKey: false
}
editingModelId.value = null
formReady.value = false
modelOptions.value = []
selectedNewLLMParamId.value = ''
customLLMParam.value = { key: '', value: '' }
formConnectionStatus.value = null
}
const ensureProvidersLoaded = async () => {
if (providersLoaded.value) return
isLoadingProviders.value = true
try {
providers.value = textAdapterRegistry?.getAllProviders?.() || []
providersLoaded.value = true
} catch (error) {
console.error('加载文本模型提供商失败:', error)
toast.error(t('modelManager.loadFailed'))
providers.value = []
} finally {
isLoadingProviders.value = false
}
}
const loadStaticModelsForProvider = (providerId: string) => {
if (!providerId || !textAdapterRegistry) {
modelOptions.value = []
return
}
try {
const staticModels = textAdapterRegistry.getStaticModels(providerId)
modelOptions.value = staticModels.map(model => ({
value: model.id,
label: model.name || model.id
}))
} catch (error) {
console.error('加载 Provider 模型失败:', error)
modelOptions.value = []
}
}
const loadModels = async () => {
loadingModels.value = true
try {
const all = await modelManager.getAllModels()
models.value = all
.map(model => ({ ...model }))
.sort((a, b) => {
if (a.enabled !== b.enabled) {
return a.enabled ? -1 : 1
}
const aDefault = isDefaultModel(a.id)
const bDefault = isDefaultModel(b.id)
if (aDefault !== bDefault) {
return aDefault ? -1 : 1
}
return a.name.localeCompare(b.name)
})
} catch (error) {
console.error('加载模型失败:', error)
toast.error(t('modelManager.loadFailed'))
} finally {
loadingModels.value = false
}
}
const testConfigConnection = async (id: string) => {
if (!id || testingConnections.value[id]) return
testingConnections.value[id] = true
try {
const model = await modelManager.getModel(id)
if (!model) {
throw new Error(t('modelManager.noModelsAvailable'))
}
await llmService.testConnection(id)
toast.success(t('modelManager.testSuccess', { provider: model.name }))
} catch (error: any) {
console.error('连接测试失败:', error)
const model = await modelManager.getModel(id)
const modelName = model?.name || id
toast.error(t('modelManager.testFailed', {
provider: modelName,
error: error?.message || 'Unknown error'
}))
} finally {
delete testingConnections.value[id]
}
}
const enableModel = async (id: string) => {
try {
const model = await modelManager.getModel(id)
if (!model) throw new Error(t('modelManager.noModelsAvailable'))
await modelManager.enableModel(id)
await loadModels()
toast.success(t('modelManager.enableSuccess'))
} catch (error: any) {
console.error('启用模型失败:', error)
toast.error(t('modelManager.enableFailed', { error: error.message }))
}
}
const disableModel = async (id: string) => {
try {
const model = await modelManager.getModel(id)
if (!model) throw new Error(t('modelManager.noModelsAvailable'))
await modelManager.disableModel(id)
await loadModels()
toast.success(t('modelManager.disableSuccess'))
} catch (error: any) {
console.error('禁用模型失败:', error)
toast.error(t('modelManager.disableFailed', { error: error.message }))
}
}
const deleteModel = async (id: string) => {
try {
await modelManager.deleteModel(id)
await loadModels()
toast.success(t('modelManager.deleteSuccess'))
} catch (error: any) {
console.error('删除模型失败:', error)
toast.error(t('modelManager.deleteFailed', { error: error.message }))
}
}
const maskApiKey = (value: string) => {
const keyLength = value.length
if (keyLength <= 8) return '*'.repeat(keyLength)
const visiblePart = 4
const prefix = value.substring(0, visiblePart)
const suffix = value.substring(keyLength - visiblePart)
const maskedLength = keyLength - visiblePart * 2
return `${prefix}${'*'.repeat(maskedLength)}${suffix}`
}
const setProvider = (providerId: string, autoSelectFirstModel = true) => {
form.value.providerId = providerId
formConnectionStatus.value = null
if (!providerId) {
form.value.connectionConfig = {}
modelOptions.value = []
return
}
loadStaticModelsForProvider(providerId)
const providerMeta = providers.value.find(p => p.id === providerId)
if (providerMeta?.defaultBaseURL) {
form.value.connectionConfig = {
...form.value.connectionConfig,
baseURL: form.value.connectionConfig.baseURL || providerMeta.defaultBaseURL
}
}
if (autoSelectFirstModel && modelOptions.value.length > 0) {
form.value.modelId = modelOptions.value[0].value
form.value.defaultModel = modelOptions.value[0].value
}
}
const prepareForCreate = async () => {
resetFormState()
await ensureProvidersLoaded()
formReady.value = false
if (providers.value.length > 0) {
setProvider(providers.value[0].id, true)
}
formReady.value = true
}
const prepareForEdit = async (id: string) => {
resetFormState()
editingModelId.value = id
await ensureProvidersLoaded()
formReady.value = false
try {
const model = await modelManager.getModel(id)
if (!model) {
throw new Error(t('modelManager.noModelsAvailable'))
}
const connectionConfig: TextConnectionConfig = { ...(model.connectionConfig ?? {}) }
const rawApiKey = connectionConfig.apiKey ?? ''
if (rawApiKey) {
connectionConfig.apiKey = maskApiKey(rawApiKey)
}
form.value = {
id: model.id,
originalId: model.id,
name: model.name,
enabled: model.enabled,
providerId: model.providerMeta?.id ?? 'custom',
modelId: model.modelMeta?.id ?? '',
connectionConfig,
paramOverrides: model.paramOverrides ? JSON.parse(JSON.stringify(model.paramOverrides)) : {},
displayMaskedKey: !!rawApiKey,
originalApiKey: rawApiKey || undefined,
defaultModel: model.modelMeta?.id ?? ''
}
setProvider(form.value.providerId, false)
if (!modelOptions.value.some(option => option.value === form.value.modelId) && form.value.modelId) {
modelOptions.value.push({ value: form.value.modelId, label: form.value.modelId })
}
await refreshModelOptions(false)
} catch (error) {
console.error('加载模型失败:', error)
toast.error(t('modelManager.loadFailed'))
} finally {
formReady.value = true
}
}
const refreshModelOptions = async (showSuccess = true) => {
if (!form.value.providerId) return
const baseURL = form.value.connectionConfig.baseURL?.trim()
if (!baseURL) {
toast.error(t('modelManager.needBaseUrl'))
return
}
isLoadingModelOptions.value = true
try {
const providerTemplateId = form.value.providerId || currentProviderType.value || 'custom'
const connectionConfig: TextConnectionConfig = {
...form.value.connectionConfig,
baseURL,
apiKey: form.value.displayMaskedKey && form.value.originalApiKey
? form.value.originalApiKey
: form.value.connectionConfig.apiKey
}
const existingConfig = form.value.originalId ? await modelManager.getModel(form.value.originalId) : undefined
let providerMeta = providers.value.find(p => p.id === providerTemplateId) || existingConfig?.providerMeta
let modelMeta = existingConfig?.modelMeta
if (textAdapterRegistry && providerTemplateId) {
try {
const adapter = textAdapterRegistry.getAdapter(providerTemplateId)
if (!providerMeta) {
providerMeta = adapter.getProvider()
}
const staticModels = adapter.getModels()
if (form.value.modelId) {
modelMeta = staticModels.find(m => m.id === form.value.modelId) || modelMeta
}
if (!modelMeta) {
modelMeta = staticModels[0]
}
if (!modelMeta && form.value.modelId) {
modelMeta = adapter.buildDefaultModel(form.value.modelId)
}
} catch (error) {
console.warn(`[useTextModelManager] Failed to load metadata for provider ${providerTemplateId}`, error)
}
}
const fetchedModels = await llmService.fetchModelList(providerTemplateId, {
connectionConfig,
providerMeta,
modelMeta: modelMeta ? { ...modelMeta, id: form.value.modelId || modelMeta.id } : undefined
} as Partial<TextModelConfig>)
modelOptions.value = fetchedModels
if (showSuccess) {
toast.success(t('modelManager.fetchModelsSuccess', { count: fetchedModels.length }))
}
if (fetchedModels.length > 0 && !fetchedModels.some(m => m.value === form.value.modelId)) {
form.value.modelId = fetchedModels[0].value
}
} catch (error: any) {
console.error('获取模型列表失败:', error)
toast.error(error?.message || t('modelManager.loadFailed'))
modelOptions.value = []
} finally {
isLoadingModelOptions.value = false
}
}
const ensureProviderMeta = (providerId: string, existing?: TextProvider) => {
if (existing) return existing
const adapter = textAdapterRegistry.getAdapter(providerId)
return adapter.getProvider()
}
const ensureModelMeta = (providerId: string, modelId: string, existing?: TextModel) => {
const adapter = textAdapterRegistry.getAdapter(providerId)
const staticModels = adapter.getModels()
const foundModel = staticModels.find(m => m.id === modelId)
if (foundModel) {
return foundModel
}
return adapter.buildDefaultModel(modelId)
}
const updateExistingModel = async () => {
if (!form.value.originalId) {
throw new Error('编辑会话无效')
}
const existingConfig = await modelManager.getModel(form.value.originalId)
if (!existingConfig) {
throw new Error('模型不存在')
}
const connectionConfig: TextConnectionConfig = {
...form.value.connectionConfig,
baseURL: form.value.connectionConfig.baseURL?.trim() || existingConfig.connectionConfig?.baseURL
}
if (form.value.displayMaskedKey) {
if (form.value.originalApiKey) {
connectionConfig.apiKey = form.value.originalApiKey
} else {
delete connectionConfig.apiKey
}
} else if (form.value.connectionConfig.apiKey) {
connectionConfig.apiKey = form.value.connectionConfig.apiKey
} else {
delete connectionConfig.apiKey
}
const providerMeta = ensureProviderMeta(form.value.providerId, existingConfig.providerMeta)
const modelMeta = ensureModelMeta(form.value.providerId, form.value.modelId, existingConfig.modelMeta)
const updates: Partial<TextModelConfig> = {
name: form.value.name,
enabled: form.value.enabled,
providerMeta,
modelMeta,
connectionConfig,
paramOverrides: { ...(form.value.paramOverrides || {}) }
}
await modelManager.updateModel(form.value.originalId, updates)
return form.value.originalId
}
const createNewModel = async () => {
if (!form.value.id) {
toast.error(t('modelManager.modelKeyRequired'))
throw new Error('模型标识必填')
}
const providerMeta = ensureProviderMeta(form.value.providerId)
const modelMeta = ensureModelMeta(form.value.providerId, form.value.defaultModel || form.value.modelId)
const newConfig: TextModelConfig = {
id: form.value.id,
name: form.value.name,
enabled: true,
providerMeta,
modelMeta,
connectionConfig: { ...form.value.connectionConfig },
paramOverrides: { ...(form.value.paramOverrides ?? {}) }
}
await modelManager.addModel(form.value.id, newConfig)
return form.value.id
}
const saveForm = async () => {
if (isSaving.value) return null
isSaving.value = true
try {
const savedId = editingModelId.value ? await updateExistingModel() : await createNewModel()
await loadModels()
return savedId
} finally {
isSaving.value = false
}
}
const testFormConnection = async () => {
if (!editingModelId.value || isTestingFormConnection.value) return
isTestingFormConnection.value = true
formConnectionStatus.value = { type: 'info', message: t('modelManager.testing') }
try {
await llmService.testConnection(editingModelId.value)
formConnectionStatus.value = { type: 'success', message: t('modelManager.testSuccess', { provider: form.value.name }) }
toast.success(t('modelManager.testSuccess', { provider: form.value.name }))
} catch (error: any) {
console.error('连接测试失败:', error)
formConnectionStatus.value = {
type: 'error',
message: t('modelManager.testFailed', { provider: form.value.name, error: error?.message || 'Unknown error' })
}
toast.error(t('modelManager.testFailed', { provider: form.value.name, error: error?.message || 'Unknown error' }))
} finally {
isTestingFormConnection.value = false
}
}
const handleQuickAddParam = () => {
if (selectedNewLLMParamId.value !== 'custom') {
quickAddLLMParam()
}
}
const quickAddLLMParam = () => {
const paramsObject = currentParamOverrides.value
if (!paramsObject) return
if (selectedNewLLMParamId.value === 'custom') {
if (customLLMParam.value.key && !Object.prototype.hasOwnProperty.call(paramsObject, customLLMParam.value.key)) {
paramsObject[customLLMParam.value.key] = customLLMParam.value.value
}
customLLMParam.value = { key: '', value: '' }
} else {
const definition = advancedParameterDefinitions.find(def => def.id === selectedNewLLMParamId.value)
if (definition && !Object.prototype.hasOwnProperty.call(paramsObject, definition.name)) {
let val = definition.defaultValue
if (definition.type === 'boolean') {
val = val === undefined ? false : Boolean(val)
} else if (definition.type === 'integer' && val !== undefined) {
val = parseInt(String(val), 10)
} else if (definition.type === 'number' && val !== undefined) {
val = parseFloat(String(val))
} else if (definition.name === 'stopSequences') {
val = Array.isArray(val)
? val
: typeof val === 'string' && val ? val.split(',').map(s => s.trim()).filter(Boolean) : []
}
paramsObject[definition.name] = val
}
}
selectedNewLLMParamId.value = ''
}
const handleCustomParamAdd = () => {
if (customLLMParam.value.key && customLLMParam.value.value) {
quickAddLLMParam()
}
}
const cancelCustomParam = () => {
selectedNewLLMParamId.value = ''
customLLMParam.value = { key: '', value: '' }
}
const removeLLMParam = (paramKey: string) => {
if (!form.value.paramOverrides) return
const overrides = { ...(form.value.paramOverrides as Record<string, unknown>) }
delete overrides[paramKey]
form.value.paramOverrides = overrides
}
const getParamMetadata = (paramName: string) => {
if (!advancedParameterDefinitions) return null
const provider = currentProviderType.value || 'custom'
const definition = advancedParameterDefinitions.find(
def => def.name === paramName && def.appliesToProviders.includes(provider)
)
if (definition) {
return {
...definition,
label: definition.labelKey ? t(definition.labelKey) : definition.name,
description: definition.descriptionKey ? t(definition.descriptionKey) : `(${paramName})`,
unit: definition.unitKey ? t(definition.unitKey) : definition.unit || ''
}
}
return null
}
const isParamInvalid = (paramName: string, value: unknown) => {
const metadata = getParamMetadata(paramName)
if (!metadata) {
if (value === undefined || value === null || value === '') return false
const dangerousNames = ['eval', 'exec', 'script', '__proto__', 'constructor']
if (dangerousNames.some(dangerous => paramName.toLowerCase().includes(dangerous))) {
return true
}
return false
}
if (value === undefined || value === null || value === '') return false
if (metadata.type === 'number' || metadata.type === 'integer') {
const numValue = Number(value)
if (Number.isNaN(numValue)) return true
if (metadata.minValue !== undefined && numValue < metadata.minValue) return true
if (metadata.maxValue !== undefined && numValue > metadata.maxValue) return true
if (metadata.type === 'integer' && !Number.isInteger(numValue)) return true
}
return false
}
const getParamValidationMessage = (paramName: string, value: unknown) => {
const metadata = getParamMetadata(paramName)
if (!metadata) {
const dangerousNames = ['eval', 'exec', 'script', '__proto__', 'constructor']
if (dangerousNames.some(dangerous => paramName.toLowerCase().includes(dangerous))) {
return t('modelManager.advancedParameters.validation.dangerousParam')
}
return ''
}
if (metadata.type === 'number' || metadata.type === 'integer') {
const numValue = Number(value)
if (Number.isNaN(numValue)) {
return t('modelManager.advancedParameters.validation.invalidNumber', {
type: metadata.type === 'integer' ? t('common.integer') : t('common.number')
})
}
if (metadata.minValue !== undefined && numValue < metadata.minValue) {
return t('modelManager.advancedParameters.validation.belowMin', {
min: metadata.minValue
})
}
if (metadata.maxValue !== undefined && numValue > metadata.maxValue) {
return t('modelManager.advancedParameters.validation.aboveMax', {
max: metadata.maxValue
})
}
if (metadata.type === 'integer' && !Number.isInteger(numValue)) {
return t('modelManager.advancedParameters.validation.mustBeInteger')
}
}
return ''
}
watch(() => form.value.connectionConfig.apiKey, (newValue) => {
if (!editingModelId.value) return
const val = newValue ?? ''
const isMasked = typeof val === 'string' && val.includes('*')
form.value.displayMaskedKey = isMasked
if (!isMasked && typeof val === 'string') {
form.value.originalApiKey = val
}
})
watch(() => form.value.modelId, (val) => {
form.value.defaultModel = val || ''
})
return {
// list state
models,
loadingModels,
loadModels,
isDefaultModel,
testingConnections,
testConfigConnection,
enableModel,
disableModel,
deleteModel,
// providers
providers,
isLoadingProviders,
loadProviders: ensureProvidersLoaded,
selectProvider: setProvider,
// form state & helpers
form,
formReady,
isSaving,
modalTitle,
editingModelId,
providerOptions,
connectionFields,
modelOptions,
isLoadingModelOptions,
availableLLMParamDefinitions,
selectedNewLLMParamId,
customLLMParam,
currentParamOverrides,
currentProviderType,
selectedProvider,
prepareForCreate,
prepareForEdit,
refreshModelOptions,
saveForm,
resetFormState,
isTestingFormConnection,
canTestFormConnection,
testFormConnection,
canRefreshModelOptions,
handleQuickAddParam,
handleCustomParamAdd,
cancelCustomParam,
removeLLMParam,
getParamMetadata,
isParamInvalid,
getParamValidationMessage,
formConnectionStatus
}
}
export type TextModelManager = ReturnType<typeof useTextModelManager>
+1
View File
@@ -429,6 +429,7 @@ export default {
deleteConfirm: 'Are you sure you want to delete this model? This action cannot be undone.',
// Operation Results
testing: 'Testing connection...',
testSuccess: 'Connection successful for {provider}!',
testFailed: 'Connection failed for {provider}: {error}',
updateSuccess: 'Update successful',
+1
View File
@@ -429,6 +429,7 @@
deleteConfirm: '确定要删除此模型吗?此操作不可恢复。',
// 操作结果
testing: '正在测试连接...',
testSuccess: '{provider}连接测试成功',
testFailed: '{provider}连接测试失败:{error}',
updateSuccess: '更新成功',
+1
View File
@@ -429,6 +429,7 @@ export default {
deleteConfirm: '確定要刪除此模型嗎?此操作無法復原。',
// 操作結果
testing: '正在測試連線...',
testSuccess: '{provider}連線測試成功',
testFailed: '{provider}連線測試失敗:{error}',
updateSuccess: '更新成功',
+1
View File
@@ -85,6 +85,7 @@ declare module 'vue-i18n' {
deleteConfirm: string;
// Operation Results
testing: string;
testSuccess: string;
testFailed: string;
updateSuccess: string;