Files
MineNasAI/src/minenasai/webtui/static/webui/js/components/llm-config.js

460 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* LLM 配置组件 - 简化版
* 参考 OpenAI 等主流配置方式
*/
app.component('llm-config-page', {
props: ['config'],
emits: ['save'],
setup(props, { emit }) {
const { ref, reactive, watch, computed } = Vue;
// 当前激活的提供商标签
const activeProvider = ref('openai-compatible');
// 提供商配置状态
const providerStatus = reactive({});
// 测试状态
const testing = ref(null);
const testResult = reactive({
provider: '',
success: false,
message: '',
details: ''
});
// 保存状态
const saving = ref(false);
// 表单数据 - 简化为通用的 OpenAI 兼容格式
const form = reactive({
// 当前使用的配置
provider: 'openai-compatible',
api_key: '',
base_url: '',
model: '',
// 各提供商单独配置
configs: {
'openai-compatible': {
name: 'OpenAI 兼容接口',
api_key: '',
base_url: 'https://api.openai.com/v1',
model: 'gpt-4o',
configured: false
},
'anthropic': {
name: 'Anthropic Claude',
api_key: '',
base_url: 'https://api.anthropic.com',
model: 'claude-sonnet-4-20250514',
configured: false
},
'deepseek': {
name: 'DeepSeek',
api_key: '',
base_url: 'https://api.deepseek.com/v1',
model: 'deepseek-chat',
configured: false
},
'gemini': {
name: 'Google Gemini',
api_key: '',
base_url: '',
model: 'gemini-2.5-flash-preview-05-20',
configured: false
}
}
});
// 提供商列表
const providers = [
{
id: 'openai-compatible',
name: 'OpenAI 兼容',
icon: '🔌',
description: '支持所有 OpenAI 兼容的 APIOpenAI、Azure、本地模型等',
defaultUrl: 'https://api.openai.com/v1',
defaultModel: 'gpt-4o',
keyPlaceholder: 'sk-xxx',
domestic: false
},
{
id: 'anthropic',
name: 'Anthropic',
icon: '🤖',
description: 'Claude 系列模型,需代理访问',
defaultUrl: 'https://api.anthropic.com',
defaultModel: 'claude-sonnet-4-20250514',
keyPlaceholder: 'sk-ant-xxx',
domestic: false
},
{
id: 'deepseek',
name: 'DeepSeek',
icon: '🔍',
description: '国产高性价比模型,无需代理',
defaultUrl: 'https://api.deepseek.com/v1',
defaultModel: 'deepseek-chat',
keyPlaceholder: 'sk-xxx',
domestic: true
},
{
id: 'gemini',
name: 'Google Gemini',
icon: '✨',
description: 'Google AI 模型,需代理访问',
defaultUrl: '',
defaultModel: 'gemini-2.5-flash-preview-05-20',
keyPlaceholder: 'AIzaSy-xxx',
domestic: false
}
];
// 当前选中的提供商信息
const currentProvider = computed(() => {
return providers.find(p => p.id === activeProvider.value) || providers[0];
});
// 当前提供商的配置
const currentConfig = computed(() => {
return form.configs[activeProvider.value] || {};
});
// 监听 props 变化,加载已保存的配置
watch(() => props.config, (newVal) => {
if (newVal) {
// 从旧配置迁移数据
if (newVal.default_provider) {
form.provider = newVal.default_provider;
activeProvider.value = mapProviderName(newVal.default_provider);
}
if (newVal.default_model) {
form.model = newVal.default_model;
}
// 迁移各提供商配置
migrateConfig('openai-compatible', newVal.openai_api_key, newVal.openai_base_url);
migrateConfig('anthropic', newVal.anthropic_api_key, newVal.anthropic_base_url);
migrateConfig('deepseek', newVal.deepseek_api_key, newVal.deepseek_base_url);
migrateConfig('gemini', newVal.gemini_api_key, newVal.gemini_base_url);
}
}, { immediate: true, deep: true });
// 映射旧的提供商名称
function mapProviderName(name) {
const map = {
'openai': 'openai-compatible',
'anthropic': 'anthropic',
'deepseek': 'deepseek',
'gemini': 'gemini',
'zhipu': 'openai-compatible',
'minimax': 'openai-compatible',
'moonshot': 'openai-compatible'
};
return map[name] || 'openai-compatible';
}
// 迁移旧配置
function migrateConfig(providerId, apiKey, baseUrl) {
if (apiKey && apiKey !== '***') {
form.configs[providerId].api_key = apiKey;
form.configs[providerId].configured = true;
}
if (baseUrl) {
form.configs[providerId].base_url = baseUrl;
}
}
// 测试连接
async function testConnection() {
const config = form.configs[activeProvider.value];
if (!config.api_key) {
ElementPlus.ElMessage.warning('请先填写 API Key');
return;
}
testing.value = activeProvider.value;
testResult.provider = activeProvider.value;
testResult.success = false;
testResult.message = '正在测试...';
testResult.details = '';
try {
const res = await fetch('/api/llm/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: activeProvider.value,
api_key: config.api_key,
base_url: config.base_url || currentProvider.value.defaultUrl,
model: config.model || currentProvider.value.defaultModel
})
});
const data = await res.json();
if (data.success) {
testResult.success = true;
testResult.message = '连接成功!';
testResult.details = data.message || `模型响应正常,延迟: ${data.latency || 'N/A'}ms`;
config.configured = true;
ElementPlus.ElMessage.success('API 连接测试成功');
} else {
testResult.success = false;
testResult.message = '连接失败';
testResult.details = formatError(data.error);
ElementPlus.ElMessage.error('连接失败: ' + (data.error || '未知错误'));
}
} catch (e) {
testResult.success = false;
testResult.message = '请求失败';
testResult.details = formatError(e.message);
ElementPlus.ElMessage.error('测试请求失败: ' + e.message);
}
testing.value = null;
}
// 格式化错误信息
function formatError(error) {
if (!error) return '未知错误';
// 常见错误映射
const errorMap = {
'401': '认证失败 - API Key 无效或已过期',
'403': '访问被拒绝 - 请检查 API Key 权限',
'404': 'API 地址错误 - 请检查 Base URL',
'429': '请求过于频繁 - 已达到速率限制',
'500': '服务器错误 - API 服务暂时不可用',
'502': '网关错误 - 可能需要配置代理',
'503': '服务不可用 - API 服务维护中',
'ECONNREFUSED': '连接被拒绝 - 请检查网络或代理设置',
'ETIMEDOUT': '连接超时 - 请检查网络或代理设置',
'ENOTFOUND': 'DNS 解析失败 - 请检查 Base URL',
'fetch failed': '网络请求失败 - 请检查代理设置'
};
for (const [key, msg] of Object.entries(errorMap)) {
if (error.includes(key)) {
return msg;
}
}
return error;
}
// 保存配置
async function handleSave() {
saving.value = true;
try {
// 转换为旧格式保存(兼容后端)
const saveData = {
default_provider: activeProvider.value === 'openai-compatible' ? 'openai' : activeProvider.value,
default_model: form.configs[activeProvider.value].model,
// OpenAI 兼容
openai_api_key: form.configs['openai-compatible'].api_key,
openai_base_url: form.configs['openai-compatible'].base_url,
// Anthropic
anthropic_api_key: form.configs['anthropic'].api_key,
anthropic_base_url: form.configs['anthropic'].base_url,
// DeepSeek
deepseek_api_key: form.configs['deepseek'].api_key,
deepseek_base_url: form.configs['deepseek'].base_url,
// Gemini
gemini_api_key: form.configs['gemini'].api_key,
gemini_base_url: form.configs['gemini'].base_url
};
emit('save', saveData);
} finally {
saving.value = false;
}
}
// 设为默认
function setAsDefault() {
form.provider = activeProvider.value;
ElementPlus.ElMessage.success(`已将 ${currentProvider.value.name} 设为默认提供商`);
}
// 快速配置预设
function applyPreset(preset) {
const config = form.configs[activeProvider.value];
switch (preset) {
case 'openai':
config.base_url = 'https://api.openai.com/v1';
config.model = 'gpt-4o';
break;
case 'azure':
config.base_url = 'https://YOUR_RESOURCE.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT';
config.model = 'gpt-4o';
break;
case 'ollama':
config.base_url = 'http://localhost:11434/v1';
config.model = 'llama3';
config.api_key = 'ollama';
break;
case 'oneapi':
config.base_url = 'http://localhost:3000/v1';
config.model = 'gpt-4o';
break;
}
}
return {
activeProvider,
providers,
currentProvider,
currentConfig,
form,
testing,
testResult,
saving,
testConnection,
handleSave,
setAsDefault,
applyPreset
};
},
template: `
<div class="llm-config">
<!-- 当前状态概览 -->
<el-card class="config-card" style="margin-bottom: 16px;">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>API 配置状态</span>
</div>
</template>
<div style="display: flex; gap: 16px; flex-wrap: wrap;">
<div v-for="p in providers" :key="p.id"
class="status-chip"
:class="{ active: form.configs[p.id]?.configured, selected: activeProvider === p.id }"
@click="activeProvider = p.id">
<span class="status-icon">{{ p.icon }}</span>
<span class="status-name">{{ p.name }}</span>
<el-icon v-if="form.configs[p.id]?.configured" color="#67c23a"><CircleCheck /></el-icon>
<el-tag v-if="form.provider === p.id || activeProvider === p.id && form.provider === 'openai' && p.id === 'openai-compatible'" size="small" type="primary">默认</el-tag>
</div>
</div>
</el-card>
<!-- 配置表单 -->
<el-card class="config-card">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 20px;">{{ currentProvider.icon }}</span>
<span>{{ currentProvider.name }}</span>
<el-tag v-if="!currentProvider.domestic" size="small" type="warning">需代理</el-tag>
<el-tag v-else size="small" type="success">国内直连</el-tag>
</div>
<div style="display: flex; gap: 8px;">
<el-button size="small" @click="setAsDefault" :disabled="form.provider === activeProvider">设为默认</el-button>
</div>
</div>
</template>
<el-alert
:title="currentProvider.description"
type="info"
:closable="false"
show-icon
style="margin-bottom: 20px;"
/>
<!-- OpenAI 兼容接口的快速预设 -->
<div v-if="activeProvider === 'openai-compatible'" style="margin-bottom: 16px;">
<span style="margin-right: 8px; color: var(--text-secondary);">快速配置:</span>
<el-button-group size="small">
<el-button @click="applyPreset('openai')">OpenAI</el-button>
<el-button @click="applyPreset('azure')">Azure</el-button>
<el-button @click="applyPreset('ollama')">Ollama 本地</el-button>
<el-button @click="applyPreset('oneapi')">OneAPI</el-button>
</el-button-group>
</div>
<el-form label-position="top" style="max-width: 600px;">
<el-form-item label="API Key" required>
<el-input
v-model="form.configs[activeProvider].api_key"
type="password"
show-password
:placeholder="currentProvider.keyPlaceholder"
size="large"
/>
</el-form-item>
<el-form-item label="API Base URL">
<el-input
v-model="form.configs[activeProvider].base_url"
:placeholder="currentProvider.defaultUrl + '(留空使用默认)'"
size="large"
/>
<div style="margin-top: 4px; font-size: 12px; color: var(--text-secondary);">
留空将使用默认地址。如需使用代理或中转服务,请填写完整 URL。
</div>
</el-form-item>
<el-form-item label="模型名称">
<el-input
v-model="form.configs[activeProvider].model"
:placeholder="currentProvider.defaultModel"
size="large"
/>
<div style="margin-top: 4px; font-size: 12px; color: var(--text-secondary);">
留空将使用默认模型:{{ currentProvider.defaultModel }}
</div>
</el-form-item>
</el-form>
<!-- 测试区域 -->
<el-divider />
<div style="display: flex; align-items: center; gap: 16px;">
<el-button
type="primary"
@click="testConnection"
:loading="testing === activeProvider"
:disabled="!form.configs[activeProvider].api_key"
>
<el-icon v-if="!testing"><Connection /></el-icon>
测试连接
</el-button>
<div v-if="testResult.provider === activeProvider && testResult.message !== '正在测试...'"
:class="['test-result', testResult.success ? 'success' : 'error']">
<el-icon v-if="testResult.success"><CircleCheck /></el-icon>
<el-icon v-else><CircleClose /></el-icon>
<span>{{ testResult.message }}</span>
</div>
</div>
<!-- 详细错误信息 -->
<el-alert
v-if="testResult.provider === activeProvider && testResult.details && !testResult.success"
:title="testResult.details"
type="error"
:closable="false"
style="margin-top: 16px;"
/>
<el-alert
v-if="testResult.provider === activeProvider && testResult.details && testResult.success"
:title="testResult.details"
type="success"
:closable="false"
style="margin-top: 16px;"
/>
</el-card>
<!-- 保存按钮 -->
<div style="margin-top: 20px; display: flex; gap: 12px;">
<el-button type="primary" size="large" @click="handleSave" :loading="saving">
保存所有配置
</el-button>
</div>
</div>
`
});