🎨 优化扩展模块,完成ai接入和对话功能

This commit is contained in:
2026-02-12 23:12:28 +08:00
parent 4e611d3a5e
commit 572f3aa15b
779 changed files with 194400 additions and 3136 deletions

View File

@@ -0,0 +1,461 @@
<template>
<div class="provider-page">
<!-- 顶部说明 -->
<div class="page-header">
<div class="header-content">
<h2>AI 接口配置</h2>
<p class="header-desc">
配置你的 AI 接口后就可以和角色卡进行对话了
支持 OpenAIClaudeGemini以及所有兼容 OpenAI 格式的接口
</p>
</div>
<el-button type="primary" @click="showCreateDialog = true">
<el-icon class="mr-1"><Plus /></el-icon>
添加接口
</el-button>
</div>
<!-- 无数据提示 -->
<div v-if="!store.loading && !store.hasProvider" class="empty-state">
<el-empty description="还没有配置 AI 接口">
<el-button type="primary" @click="showCreateDialog = true">
立即配置
</el-button>
</el-empty>
<div class="quick-guide">
<h3>快速上手</h3>
<ol>
<li>点击"添加接口"按钮</li>
<li>选择你使用的 AI 平台 OpenAIClaude</li>
<li>填入 API Key点击测试连接</li>
<li>连接成功后保存就可以开始对话了</li>
</ol>
</div>
</div>
<!-- 提供商列表 -->
<div v-else class="provider-list">
<el-card
v-for="provider in store.providers"
:key="provider.id"
class="provider-card"
:class="{ 'is-default': provider.isDefault, 'is-disabled': !provider.isEnabled }"
shadow="hover"
>
<div class="card-header">
<div class="card-info">
<div class="provider-icon">
<img :src="getProviderIcon(provider.providerType)" :alt="provider.providerType" />
</div>
<div class="provider-meta">
<div class="provider-name">
{{ provider.providerName }}
<el-tag v-if="provider.isDefault" type="primary" size="small" class="ml-2">默认</el-tag>
<el-tag v-if="!provider.isEnabled" type="info" size="small" class="ml-2">已禁用</el-tag>
</div>
<div class="provider-type">{{ getProviderTypeLabel(provider.providerType) }}</div>
</div>
</div>
<div class="card-actions">
<el-button
text
type="primary"
:loading="testingId === provider.id"
@click="handleTest(provider.id)"
>
测试连接
</el-button>
<el-button
text
type="success"
@click="handleTestMessage(provider)"
>
测试消息
</el-button>
<el-dropdown trigger="click" @command="(cmd: string) => handleCommand(cmd, provider)">
<el-button text>
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">编辑</el-dropdown-item>
<el-dropdown-item v-if="!provider.isDefault" command="setDefault">设为默认</el-dropdown-item>
<el-dropdown-item :command="provider.isEnabled ? 'disable' : 'enable'">
{{ provider.isEnabled ? '禁用' : '启用' }}
</el-dropdown-item>
<el-dropdown-item command="delete" divided style="color: var(--el-color-danger)">
删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- Key URL 信息 -->
<div class="card-body">
<div class="info-row">
<span class="info-label">接口地址</span>
<span class="info-value">{{ provider.baseUrl || '默认' }}</span>
</div>
<div class="info-row">
<span class="info-label">API Key</span>
<span class="info-value">{{ provider.apiKeyHint || '未设置' }}</span>
</div>
</div>
<!-- 模型列表 -->
<div class="card-models">
<div class="models-header">
<span class="models-title">可用模型</span>
<el-button text type="primary" size="small" @click="handleAddModel(provider)">
+ 添加模型
</el-button>
</div>
<div class="model-tags">
<el-tag
v-for="model in provider.models"
:key="model.id"
:type="model.modelType === 'image_gen' ? 'warning' : undefined"
:effect="model.isEnabled ? 'light' : 'plain'"
closable
class="model-tag"
@close="handleDeleteModel(model)"
>
{{ model.displayName || model.modelName }}
<span v-if="model.modelType === 'image_gen'" class="model-type-badge">绘图</span>
</el-tag>
<el-tag v-if="provider.models.length === 0" type="info" effect="plain">
暂无模型
</el-tag>
</div>
</div>
</el-card>
</div>
<!-- 创建/编辑对话框 -->
<ProviderDialog
v-model="showCreateDialog"
:editing-provider="editingProvider"
@saved="handleSaved"
/>
<!-- 添加模型对话框 -->
<ModelDialog
v-model="showModelDialog"
:provider="modelDialogProvider"
@saved="handleModelSaved"
/>
<!-- 测试消息对话框 -->
<TestMessageDialog
v-model="showTestMessageDialog"
:provider="testMessageProvider"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Plus, MoreFilled } from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import { useProviderStore } from '@/stores/provider'
import ProviderDialog from './components/ProviderDialog.vue'
import ModelDialog from './components/ModelDialog.vue'
import TestMessageDialog from './components/TestMessageDialog.vue'
const store = useProviderStore()
const showCreateDialog = ref(false)
const showModelDialog = ref(false)
const showTestMessageDialog = ref(false)
const editingProvider = ref<AIProvider | null>(null)
const modelDialogProvider = ref<AIProvider | null>(null)
const testMessageProvider = ref<AIProvider | null>(null)
const testingId = ref<number | null>(null)
onMounted(async () => {
await store.fetchProviders()
await store.fetchProviderTypes()
})
/** 获取提供商图标 */
function getProviderIcon(type: string) {
const icons: Record<string, string> = {
openai: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><text y="18" font-size="16">🤖</text></svg>',
claude: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><text y="18" font-size="16">🧠</text></svg>',
gemini: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><text y="18" font-size="16">✨</text></svg>',
custom: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><text y="18" font-size="16">⚙️</text></svg>',
}
return icons[type] || icons.custom
}
/** 获取提供商类型标签 */
function getProviderTypeLabel(type: string) {
const labels: Record<string, string> = {
openai: 'OpenAI / 兼容接口',
claude: 'Anthropic Claude',
gemini: 'Google Gemini',
custom: '自定义接口',
}
return labels[type] || type
}
/** 测试连接 */
async function handleTest(providerId: number) {
testingId.value = providerId
const result = await store.testExisting(providerId)
testingId.value = null
if (result) {
if (result.success) {
ElMessage.success(`连接成功!延迟 ${result.latency}ms`)
} else {
ElMessage.error(`连接失败: ${result.message}`)
}
}
}
/** 处理下拉菜单 */
async function handleCommand(command: string, provider: AIProvider) {
switch (command) {
case 'edit':
editingProvider.value = provider
showCreateDialog.value = true
break
case 'setDefault':
await store.setDefault(provider.id)
break
case 'enable':
await store.updateProvider({ ...provider, isEnabled: true })
break
case 'disable':
await store.updateProvider({ ...provider, isEnabled: false })
break
case 'delete':
await ElMessageBox.confirm(
`确定要删除「${provider.providerName}」吗?关联的模型也会一起删除。`,
'删除确认',
{ type: 'warning' }
)
await store.deleteProvider(provider.id)
break
}
}
/** 测试消息 */
function handleTestMessage(provider: AIProvider) {
testMessageProvider.value = provider
showTestMessageDialog.value = true
}
/** 添加模型 */
function handleAddModel(provider: AIProvider) {
modelDialogProvider.value = provider
showModelDialog.value = true
}
/** 删除模型 */
async function handleDeleteModel(model: AIModel) {
try {
await ElMessageBox.confirm(
`确定要删除模型「${model.displayName || model.modelName}」吗?`,
'删除确认',
{ type: 'warning' }
)
await store.deleteModel(model.id)
} catch {
// 用户取消
}
}
/** 保存提供商回调 */
function handleSaved() {
showCreateDialog.value = false
editingProvider.value = null
}
/** 保存模型回调 */
function handleModelSaved() {
showModelDialog.value = false
modelDialogProvider.value = null
}
</script>
<style scoped lang="scss">
.provider-page {
max-width: 900px;
margin: 0 auto;
padding: 24px 16px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24px;
.header-content h2 {
margin: 0 0 8px 0;
font-size: 22px;
}
.header-desc {
margin: 0;
color: var(--el-text-color-secondary);
font-size: 14px;
line-height: 1.6;
}
}
.empty-state {
text-align: center;
padding: 40px 0;
.quick-guide {
max-width: 400px;
margin: 32px auto 0;
text-align: left;
background: var(--el-bg-color-page);
border-radius: 8px;
padding: 20px 24px;
h3 {
margin: 0 0 12px 0;
font-size: 16px;
}
ol {
margin: 0;
padding-left: 20px;
line-height: 2;
color: var(--el-text-color-regular);
}
}
}
.provider-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.provider-card {
border-radius: 12px;
transition: all 0.3s;
&.is-default {
border-left: 3px solid var(--el-color-primary);
}
&.is-disabled {
opacity: 0.6;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-info {
display: flex;
align-items: center;
gap: 12px;
}
.provider-icon {
width: 40px;
height: 40px;
border-radius: 8px;
background: var(--el-bg-color-page);
display: flex;
align-items: center;
justify-content: center;
img {
width: 28px;
height: 28px;
}
}
.provider-name {
font-size: 16px;
font-weight: 500;
display: flex;
align-items: center;
}
.provider-type {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 2px;
}
.card-actions {
display: flex;
align-items: center;
gap: 4px;
}
.card-body {
margin-top: 16px;
padding: 12px 0;
border-top: 1px solid var(--el-border-color-lighter);
.info-row {
display: flex;
align-items: center;
font-size: 13px;
line-height: 2;
.info-label {
color: var(--el-text-color-secondary);
min-width: 80px;
}
.info-value {
color: var(--el-text-color-regular);
font-family: monospace;
word-break: break-all;
}
}
}
.card-models {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--el-border-color-lighter);
.models-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.models-title {
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.model-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.model-tag {
.model-type-badge {
font-size: 10px;
margin-left: 4px;
opacity: 0.7;
}
}
}
}
.ml-1 { margin-left: 4px; }
.ml-2 { margin-left: 8px; }
.mr-1 { margin-right: 4px; }
</style>

View File

@@ -0,0 +1,455 @@
<template>
<el-dialog
:model-value="modelValue"
title="添加模型"
width="560px"
:close-on-click-modal="false"
@update:model-value="$emit('update:modelValue', $event)"
@open="onOpen"
>
<!-- 数据源切换标签 -->
<el-tabs v-model="activeTab" class="model-tabs">
<!-- Tab 1: 从接口获取 -->
<el-tab-pane label="从接口获取" name="remote">
<div class="remote-section">
<div class="remote-header">
<p class="remote-desc">自动从 API 接口获取可用模型列表勾选后一键添加</p>
<el-button
type="primary"
size="small"
:loading="fetchingModels"
@click="handleFetchModels"
>
<el-icon class="mr-1"><Refresh /></el-icon>
{{ fetchingModels ? '获取中...' : '获取模型列表' }}
</el-button>
</div>
<!-- 获取结果提示 -->
<div v-if="fetchResult" class="fetch-result" :class="{ success: fetchResult.success, error: !fetchResult.success }">
<el-icon v-if="fetchResult.success"><CircleCheckFilled /></el-icon>
<el-icon v-else><CircleCloseFilled /></el-icon>
<span>{{ fetchResult.message }}{{ fetchResult.latency ? ` (${fetchResult.latency}ms)` : '' }}</span>
</div>
<!-- 搜索过滤 -->
<el-input
v-if="remoteModels.length > 0"
v-model="searchKeyword"
placeholder="搜索模型..."
:prefix-icon="Search"
clearable
size="small"
class="search-input"
/>
<!-- 模型列表 -->
<div v-if="filteredRemoteModels.length > 0" class="remote-model-list">
<el-scrollbar max-height="320px">
<div
v-for="model in filteredRemoteModels"
:key="model.id"
class="remote-model-item"
:class="{ selected: selectedRemoteModels.has(model.id), added: existingModelNames.has(model.id) }"
@click="toggleRemoteModel(model)"
>
<el-checkbox
:model-value="selectedRemoteModels.has(model.id)"
:disabled="existingModelNames.has(model.id)"
@click.stop
@change="toggleRemoteModel(model)"
/>
<div class="model-info">
<span class="model-id">{{ model.id }}</span>
<span v-if="model.displayName" class="model-display-name">{{ model.displayName }}</span>
<el-tag v-if="existingModelNames.has(model.id)" type="info" size="small">已添加</el-tag>
</div>
<span v-if="model.ownedBy" class="model-owner">{{ model.ownedBy }}</span>
</div>
</el-scrollbar>
</div>
<!-- 没有结果 -->
<div v-else-if="fetchResult && fetchResult.success" class="empty-models">
<el-empty :image-size="60" description="没有匹配的模型" />
</div>
</div>
</el-tab-pane>
<!-- Tab 2: 预设模型 -->
<el-tab-pane label="预设模型" name="preset">
<div v-if="presetModels.length > 0" class="preset-section">
<div class="preset-list">
<el-tag
v-for="preset in availablePresets"
:key="preset.modelName"
:type="preset.modelType === 'image_gen' ? 'warning' : 'primary'"
effect="plain"
class="preset-tag"
@click="selectPreset(preset)"
>
{{ preset.displayName || preset.modelName }}
<span v-if="preset.modelType === 'image_gen'" class="type-badge">绘图</span>
</el-tag>
<el-tag v-if="availablePresets.length === 0" type="info" effect="plain">
已全部添加
</el-tag>
</div>
</div>
<div v-else class="empty-models">
<el-empty :image-size="60" description="该接口类型没有预设模型" />
</div>
</el-tab-pane>
<!-- Tab 3: 手动输入 -->
<el-tab-pane label="手动输入" name="manual">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="模型标识" prop="modelName">
<el-input
v-model="form.modelName"
placeholder="如 gpt-4o、claude-3-5-sonnet-20241022"
/>
<div class="form-tip">
填写 API 调用时使用的模型名称
</div>
</el-form-item>
<el-form-item label="显示名称(可选)">
<el-input
v-model="form.displayName"
placeholder="给模型起个好认的名字"
/>
</el-form-item>
<el-form-item label="模型类型" prop="modelType">
<el-radio-group v-model="form.modelType">
<el-radio value="chat">对话模型</el-radio>
<el-radio value="image_gen">绘图模型</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button @click="$emit('update:modelValue', false)">取消</el-button>
<!-- 远程模式批量添加选中的 -->
<el-button
v-if="activeTab === 'remote'"
type="primary"
:loading="loading"
:disabled="selectedRemoteModels.size === 0"
@click="handleAddRemoteModels"
>
添加选中{{ selectedRemoteModels.size }}
</el-button>
<!-- 手动模式添加 -->
<el-button
v-if="activeTab === 'manual'"
type="primary"
:loading="loading"
@click="handleSubmit"
>
添加
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useProviderStore } from '@/stores/provider'
import { Refresh, CircleCheckFilled, CircleCloseFilled, Search } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
const props = defineProps<{
modelValue: boolean
provider?: AIProvider | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: []
}>()
const store = useProviderStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const fetchingModels = ref(false)
const presetModels = ref<PresetModelOption[]>([])
const remoteModels = ref<RemoteModel[]>([])
const selectedRemoteModels = ref<Set<string>>(new Set())
const fetchResult = ref<FetchRemoteModelsResponse | null>(null)
const searchKeyword = ref('')
const activeTab = ref('remote')
const form = ref({
modelName: '',
displayName: '',
modelType: 'chat' as ModelType,
})
const rules: FormRules = {
modelName: [{ required: true, message: '请输入模型标识', trigger: 'blur' }],
modelType: [{ required: true, message: '请选择模型类型', trigger: 'change' }],
}
/** 已有模型名列表,用于过滤 */
const existingModelNames = computed(() => {
if (!props.provider) return new Set<string>()
return new Set(props.provider.models.map(m => m.modelName))
})
/** 过滤掉已添加的预设 */
const availablePresets = computed(() => {
return presetModels.value.filter(p => !existingModelNames.value.has(p.modelName))
})
/** 搜索过滤远程模型 */
const filteredRemoteModels = computed(() => {
if (!searchKeyword.value) return remoteModels.value
const kw = searchKeyword.value.toLowerCase()
return remoteModels.value.filter(m =>
m.id.toLowerCase().includes(kw) ||
(m.displayName && m.displayName.toLowerCase().includes(kw)) ||
(m.ownedBy && m.ownedBy.toLowerCase().includes(kw))
)
})
/** 获取远程模型列表 */
async function handleFetchModels() {
if (!props.provider) return
fetchingModels.value = true
fetchResult.value = null
const result = await store.fetchRemoteModelsExisting(props.provider.id)
fetchingModels.value = false
if (result) {
fetchResult.value = result
if (result.success) {
remoteModels.value = result.models || []
}
}
}
/** 切换远程模型选中状态 */
function toggleRemoteModel(model: RemoteModel) {
if (existingModelNames.value.has(model.id)) return
const newSet = new Set(selectedRemoteModels.value)
if (newSet.has(model.id)) {
newSet.delete(model.id)
} else {
newSet.add(model.id)
}
selectedRemoteModels.value = newSet
}
/** 批量添加远程选中的模型 */
async function handleAddRemoteModels() {
if (!props.provider || selectedRemoteModels.value.size === 0) return
loading.value = true
for (const modelId of selectedRemoteModels.value) {
const remote = remoteModels.value.find(m => m.id === modelId)
await store.addModel({
providerId: props.provider.id,
modelName: modelId,
displayName: remote?.displayName || '',
modelType: 'chat', // 默认为对话模型
})
}
selectedRemoteModels.value = new Set()
loading.value = false
// 不关闭对话框,允许继续操作
}
/** 选择预设模型直接添加 */
async function selectPreset(preset: PresetModelOption) {
if (!props.provider) return
loading.value = true
await store.addModel({
providerId: props.provider.id,
modelName: preset.modelName,
displayName: preset.displayName,
modelType: preset.modelType,
})
loading.value = false
}
/** 手动提交 */
async function handleSubmit() {
await formRef.value?.validate()
if (!props.provider) return
loading.value = true
const result = await store.addModel({
providerId: props.provider.id,
modelName: form.value.modelName,
displayName: form.value.displayName || undefined,
modelType: form.value.modelType,
})
loading.value = false
if (result) {
emit('saved')
}
}
/** 对话框打开 */
async function onOpen() {
form.value = { modelName: '', displayName: '', modelType: 'chat' }
remoteModels.value = []
selectedRemoteModels.value = new Set()
fetchResult.value = null
searchKeyword.value = ''
activeTab.value = 'remote'
if (props.provider) {
const models = await store.fetchPresetModels(props.provider.providerType)
presetModels.value = models
}
}
</script>
<style scoped lang="scss">
.model-tabs {
:deep(.el-tabs__content) {
min-height: 200px;
}
}
.remote-section {
.remote-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.remote-desc {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
flex: 1;
margin-right: 12px;
}
}
.fetch-result {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
padding: 8px 12px;
border-radius: 6px;
margin-bottom: 12px;
&.success {
color: var(--el-color-success);
background: var(--el-color-success-light-9);
}
&.error {
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
}
}
.search-input {
margin-bottom: 10px;
}
.remote-model-list {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
overflow: hidden;
}
.remote-model-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.15s;
border-bottom: 1px solid var(--el-border-color-extra-light);
&:last-child {
border-bottom: none;
}
&:hover:not(.added) {
background: var(--el-fill-color-light);
}
&.selected {
background: var(--el-color-primary-light-9);
}
&.added {
opacity: 0.5;
cursor: default;
}
.model-info {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
.model-id {
font-size: 13px;
font-family: monospace;
font-weight: 500;
}
.model-display-name {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.model-owner {
font-size: 11px;
color: var(--el-text-color-placeholder);
flex-shrink: 0;
}
}
}
.preset-section {
.preset-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.preset-tag {
cursor: pointer;
transition: all 0.2s;
&:hover {
transform: scale(1.05);
}
.type-badge {
font-size: 10px;
margin-left: 4px;
opacity: 0.7;
}
}
}
.empty-models {
padding: 20px 0;
}
.form-tip {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.mr-1 { margin-right: 4px; }
</style>

View File

@@ -0,0 +1,338 @@
<template>
<el-dialog
:model-value="modelValue"
:title="isEditing ? '编辑 AI 接口' : '添加 AI 接口'"
width="560px"
:close-on-click-modal="false"
@update:model-value="$emit('update:modelValue', $event)"
@open="onOpen"
@closed="onClosed"
>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<!-- 步骤1选择平台 -->
<el-form-item label="AI 平台" prop="providerType">
<div class="platform-selector">
<div
v-for="option in store.providerTypes"
:key="option.value"
class="platform-option"
:class="{ active: form.providerType === option.value }"
@click="selectPlatform(option)"
>
<div class="platform-icon">{{ getPlatformEmoji(option.value) }}</div>
<div class="platform-name">{{ option.label }}</div>
</div>
</div>
<p v-if="selectedPlatformDesc" class="platform-desc">{{ selectedPlatformDesc }}</p>
</el-form-item>
<!-- 步骤2配置名称 -->
<el-form-item label="配置名称" prop="providerName">
<el-input
v-model="form.providerName"
placeholder="给这个配置起个名字,如「我的 GPT」"
maxlength="100"
show-word-limit
/>
</el-form-item>
<!-- 步骤3填写 API Key -->
<el-form-item label="API Key" prop="apiKey">
<el-input
v-model="form.apiKey"
type="password"
show-password
:placeholder="isEditing ? '留空表示不修改' : '粘贴你的 API Key'"
/>
<div class="form-tip">
你的 Key 会加密保存在服务器上不会泄露
</div>
</el-form-item>
<!-- 步骤4API 地址可选 -->
<el-form-item label="API 地址(可选)">
<el-input
v-model="form.baseUrl"
:placeholder="defaultUrl || '请输入 API 地址'"
/>
<div class="form-tip">
<span v-if="form.providerType === 'custom'">
填写兼容 OpenAI 格式的接口地址 DeepSeek通义千问的地址
</span>
<span v-else>
留空使用官方地址如果你使用中转站填写中转站地址
</span>
</div>
</el-form-item>
<!-- 测试连接 -->
<div class="test-section">
<el-button
:loading="store.testLoading"
:type="testResult?.success ? 'success' : 'default'"
@click="handleTestConnection"
>
{{ store.testLoading ? '测试中...' : '测试连接' }}
</el-button>
<div v-if="testResult" class="test-result" :class="{ success: testResult.success, error: !testResult.success }">
<el-icon v-if="testResult.success"><CircleCheckFilled /></el-icon>
<el-icon v-else><CircleCloseFilled /></el-icon>
<span>{{ testResult.message }}{{ testResult.latency ? ` (${testResult.latency}ms)` : '' }}</span>
</div>
</div>
</el-form>
<template #footer>
<el-button @click="$emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="store.loading" @click="handleSubmit">
{{ isEditing ? '保存' : '添加' }}
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
import { useProviderStore } from '@/stores/provider'
import type { FormInstance, FormRules } from 'element-plus'
const props = defineProps<{
modelValue: boolean
editingProvider?: AIProvider | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: []
}>()
const store = useProviderStore()
const formRef = ref<FormInstance>()
const testResult = ref<TestProviderResponse | null>(null)
const isEditing = computed(() => !!props.editingProvider)
const form = ref({
providerType: 'openai' as ProviderType,
providerName: '',
apiKey: '',
baseUrl: '',
})
const rules: FormRules = {
providerType: [{ required: true, message: '请选择 AI 平台', trigger: 'change' }],
providerName: [{ required: true, message: '请输入配置名称', trigger: 'blur' }],
apiKey: [{
validator: (_rule, value, callback) => {
if (!isEditing.value && !value) {
callback(new Error('请输入 API Key'))
} else {
callback()
}
},
trigger: 'blur'
}],
}
const defaultUrl = computed(() => {
const option = store.providerTypes.find(t => t.value === form.value.providerType)
return option?.defaultUrl || ''
})
const selectedPlatformDesc = computed(() => {
const option = store.providerTypes.find(t => t.value === form.value.providerType)
return option?.description || ''
})
function getPlatformEmoji(type: string): string {
const emojis: Record<string, string> = {
openai: '🤖',
claude: '🧠',
gemini: '✨',
custom: '⚙️',
}
return emojis[type] || '⚙️'
}
function selectPlatform(option: ProviderTypeOption) {
form.value.providerType = option.value as ProviderType
// 自动填充名称
if (!form.value.providerName || form.value.providerName === getAutoName(form.value.providerType)) {
form.value.providerName = getAutoName(option.value)
}
testResult.value = null
}
function getAutoName(type: string): string {
const names: Record<string, string> = {
openai: '我的 OpenAI',
claude: '我的 Claude',
gemini: '我的 Gemini',
custom: '我的 AI 接口',
}
return names[type] || '我的 AI 接口'
}
/** 测试连接 */
async function handleTestConnection() {
if (!form.value.apiKey && !isEditing.value) {
testResult.value = { success: false, message: '请先填写 API Key', latency: 0 }
return
}
// 如果编辑模式且没有填新的 Key提示
if (isEditing.value && !form.value.apiKey) {
if (props.editingProvider) {
// 使用已保存的进行测试
const result = await store.testExisting(props.editingProvider.id)
testResult.value = result
return
}
}
const result = await store.testConnection({
providerType: form.value.providerType,
baseUrl: form.value.baseUrl,
apiKey: form.value.apiKey,
})
testResult.value = result
}
/** 提交表单 */
async function handleSubmit() {
await formRef.value?.validate()
if (isEditing.value && props.editingProvider) {
// 更新
const data: UpdateProviderRequest = {
id: props.editingProvider.id,
providerName: form.value.providerName,
providerType: form.value.providerType,
baseUrl: form.value.baseUrl,
apiKey: form.value.apiKey || undefined,
}
const result = await store.updateProvider(data)
if (result) {
emit('saved')
}
} else {
// 创建
const data: CreateProviderRequest = {
providerName: form.value.providerName,
providerType: form.value.providerType,
baseUrl: form.value.baseUrl,
apiKey: form.value.apiKey,
}
const result = await store.createProvider(data)
if (result) {
emit('saved')
}
}
}
/** 对话框打开时初始化表单 */
function onOpen() {
if (props.editingProvider) {
form.value = {
providerType: props.editingProvider.providerType as ProviderType,
providerName: props.editingProvider.providerName,
apiKey: '',
baseUrl: props.editingProvider.baseUrl,
}
} else {
form.value = {
providerType: 'openai',
providerName: '我的 OpenAI',
apiKey: '',
baseUrl: '',
}
}
testResult.value = null
}
/** 对话框关闭时重置 */
function onClosed() {
formRef.value?.resetFields()
testResult.value = null
}
</script>
<style scoped lang="scss">
.platform-selector {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
width: 100%;
}
.platform-option {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 8px;
border: 2px solid var(--el-border-color-light);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--el-color-primary-light-3);
background: var(--el-color-primary-light-9);
}
&.active {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.platform-icon {
font-size: 28px;
margin-bottom: 6px;
}
.platform-name {
font-size: 12px;
font-weight: 500;
text-align: center;
}
}
.platform-desc {
margin: 8px 0 0;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.5;
}
.form-tip {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-placeholder);
line-height: 1.5;
}
.test-section {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--el-bg-color-page);
border-radius: 8px;
margin-bottom: 8px;
.test-result {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
&.success {
color: var(--el-color-success);
}
&.error {
color: var(--el-color-danger);
}
}
}
</style>

View File

@@ -0,0 +1,253 @@
<template>
<el-dialog
:model-value="modelValue"
title="发送测试消息"
width="560px"
:close-on-click-modal="false"
@update:model-value="$emit('update:modelValue', $event)"
@open="onOpen"
@closed="onClosed"
>
<div class="test-message-content">
<!-- 提供商信息 -->
<div class="provider-info">
<span class="provider-label">{{ provider?.providerName }}</span>
<el-tag size="small">{{ getProviderTypeLabel(provider?.providerType || '') }}</el-tag>
</div>
<!-- 模型选择 -->
<el-form label-position="top">
<el-form-item label="选择模型">
<el-select
v-model="selectedModel"
placeholder="请选择要测试的模型"
style="width: 100%"
>
<el-option
v-for="model in chatModels"
:key="model.id"
:label="model.displayName || model.modelName"
:value="model.modelName"
/>
</el-select>
</el-form-item>
<!-- 消息内容 -->
<el-form-item label="测试消息">
<el-input
v-model="message"
type="textarea"
:rows="2"
placeholder="留空使用默认消息:你好,请用一句话介绍你自己。"
/>
</el-form-item>
</el-form>
<!-- 发送按钮 -->
<div class="send-section">
<el-button
type="primary"
:loading="sending"
:disabled="!selectedModel"
@click="handleSend"
>
<el-icon class="mr-1"><Promotion /></el-icon>
{{ sending ? '等待回复...' : '发送测试消息' }}
</el-button>
</div>
<!-- 测试结果 -->
<div v-if="testResult" class="result-section">
<div class="result-header" :class="{ success: testResult.success, error: !testResult.success }">
<el-icon v-if="testResult.success"><CircleCheckFilled /></el-icon>
<el-icon v-else><CircleCloseFilled /></el-icon>
<span>{{ testResult.message }}</span>
<span v-if="testResult.latency" class="result-meta">
{{ testResult.latency }}ms
</span>
<span v-if="testResult.tokens" class="result-meta">
{{ testResult.tokens }} tokens
</span>
<span v-if="testResult.model" class="result-meta">
模型: {{ testResult.model }}
</span>
</div>
<!-- AI 回复内容 -->
<div v-if="testResult.reply" class="result-reply">
<div class="reply-label">AI 回复</div>
<div class="reply-content">{{ testResult.reply }}</div>
</div>
</div>
</div>
<template #footer>
<el-button @click="$emit('update:modelValue', false)">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Promotion, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
import { useProviderStore } from '@/stores/provider'
const props = defineProps<{
modelValue: boolean
provider?: AIProvider | null
}>()
defineEmits<{
'update:modelValue': [value: boolean]
}>()
const store = useProviderStore()
const selectedModel = ref('')
const message = ref('')
const sending = ref(false)
const testResult = ref<SendTestMessageResponse | null>(null)
/** 只显示对话类型模型 */
const chatModels = computed(() => {
if (!props.provider) return []
return props.provider.models.filter(m => m.modelType === 'chat' && m.isEnabled)
})
function getProviderTypeLabel(type: string) {
const labels: Record<string, string> = {
openai: 'OpenAI / 兼容接口',
claude: 'Anthropic Claude',
gemini: 'Google Gemini',
custom: '自定义接口',
}
return labels[type] || type
}
/** 发送测试消息 */
async function handleSend() {
if (!props.provider || !selectedModel.value) return
sending.value = true
testResult.value = null
const result = await store.sendTestMessageExisting(
props.provider.id,
selectedModel.value,
message.value || undefined,
)
sending.value = false
if (result) {
testResult.value = result
} else {
testResult.value = {
success: false,
message: '请求失败,请检查网络',
reply: '',
model: '',
latency: 0,
tokens: 0,
}
}
}
/** 对话框打开 */
function onOpen() {
testResult.value = null
message.value = ''
// 默认选中第一个对话模型
if (chatModels.value.length > 0) {
selectedModel.value = chatModels.value[0].modelName
} else {
selectedModel.value = ''
}
}
/** 对话框关闭 */
function onClosed() {
testResult.value = null
sending.value = false
}
</script>
<style scoped lang="scss">
.test-message-content {
.provider-info {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 10px 14px;
background: var(--el-bg-color-page);
border-radius: 8px;
.provider-label {
font-weight: 500;
font-size: 14px;
}
}
.send-section {
margin-bottom: 16px;
}
.result-section {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
overflow: hidden;
.result-header {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
font-size: 13px;
flex-wrap: wrap;
&.success {
background: var(--el-color-success-light-9);
color: var(--el-color-success);
}
&.error {
background: var(--el-color-danger-light-9);
color: var(--el-color-danger);
}
.result-meta {
font-size: 12px;
opacity: 0.8;
margin-left: 4px;
&::before {
content: '·';
margin-right: 4px;
}
}
}
.result-reply {
padding: 14px;
.reply-label {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 6px;
}
.reply-content {
font-size: 14px;
line-height: 1.7;
color: var(--el-text-color-primary);
background: var(--el-bg-color-page);
padding: 12px 14px;
border-radius: 8px;
white-space: pre-wrap;
word-break: break-word;
}
}
}
}
.mr-1 { margin-right: 4px; }
</style>