🎨 优化扩展模块,完成ai接入和对话功能
This commit is contained in:
461
web-app-vue/src/views/provider/ProviderList.vue
Normal file
461
web-app-vue/src/views/provider/ProviderList.vue
Normal 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 接口后,就可以和角色卡进行对话了。
|
||||
支持 OpenAI、Claude、Gemini,以及所有兼容 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 平台(如 OpenAI、Claude)</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>
|
||||
455
web-app-vue/src/views/provider/components/ModelDialog.vue
Normal file
455
web-app-vue/src/views/provider/components/ModelDialog.vue
Normal 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>
|
||||
338
web-app-vue/src/views/provider/components/ProviderDialog.vue
Normal file
338
web-app-vue/src/views/provider/components/ProviderDialog.vue
Normal 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>
|
||||
|
||||
<!-- 步骤4:API 地址(可选) -->
|
||||
<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>
|
||||
253
web-app-vue/src/views/provider/components/TestMessageDialog.vue
Normal file
253
web-app-vue/src/views/provider/components/TestMessageDialog.vue
Normal 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>
|
||||
Reference in New Issue
Block a user