feat: add light theme support and enhance LLM configuration UI
This commit is contained in:
@@ -390,7 +390,7 @@ async def update_proxy_config(data: ProxyConfigUpdate) -> dict[str, Any]:
|
|||||||
|
|
||||||
@app.post("/api/llm/test/{provider}")
|
@app.post("/api/llm/test/{provider}")
|
||||||
async def test_llm_connection(provider: str) -> dict[str, Any]:
|
async def test_llm_connection(provider: str) -> dict[str, Any]:
|
||||||
"""测试 LLM 连接"""
|
"""测试 LLM 连接(使用已保存配置)"""
|
||||||
try:
|
try:
|
||||||
from minenasai.llm import get_llm_manager
|
from minenasai.llm import get_llm_manager
|
||||||
|
|
||||||
@@ -406,6 +406,160 @@ async def test_llm_connection(provider: str) -> dict[str, Any]:
|
|||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
class LLMTestRequest(BaseModel):
|
||||||
|
"""LLM 测试请求"""
|
||||||
|
provider: str
|
||||||
|
api_key: str
|
||||||
|
base_url: str | None = None
|
||||||
|
model: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/llm/test")
|
||||||
|
async def test_llm_with_config(request: LLMTestRequest) -> dict[str, Any]:
|
||||||
|
"""使用指定配置测试 LLM 连接"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = request.provider
|
||||||
|
api_key = request.api_key
|
||||||
|
base_url = request.base_url
|
||||||
|
model = request.model
|
||||||
|
|
||||||
|
# 根据提供商进行测试
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
if provider in ["openai-compatible", "openai", "deepseek"]:
|
||||||
|
# OpenAI 兼容接口
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
url = base_url or "https://api.openai.com/v1"
|
||||||
|
model_name = model or "gpt-3.5-turbo"
|
||||||
|
|
||||||
|
# 获取代理设置
|
||||||
|
settings = get_settings()
|
||||||
|
proxy_url = None
|
||||||
|
if settings.proxy.enabled and provider not in ["deepseek"]:
|
||||||
|
proxy_url = settings.proxy.http or settings.proxy.https
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
proxy=proxy_url,
|
||||||
|
timeout=30
|
||||||
|
) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{url.rstrip('/')}/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": model_name,
|
||||||
|
"messages": [{"role": "user", "content": "Say hi"}],
|
||||||
|
"max_tokens": 5
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
latency = int((time.time() - start) * 1000)
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return {"success": True, "message": "连接正常", "latency": latency}
|
||||||
|
elif resp.status_code == 401:
|
||||||
|
return {"success": False, "error": "401 Unauthorized - API Key 无效"}
|
||||||
|
elif resp.status_code == 403:
|
||||||
|
return {"success": False, "error": "403 Forbidden - 无权访问"}
|
||||||
|
elif resp.status_code == 404:
|
||||||
|
return {"success": False, "error": "404 Not Found - API URL 不正确"}
|
||||||
|
else:
|
||||||
|
error_text = resp.text[:200] if resp.text else str(resp.status_code)
|
||||||
|
return {"success": False, "error": f"{resp.status_code}: {error_text}"}
|
||||||
|
|
||||||
|
elif provider == "anthropic":
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
url = base_url or "https://api.anthropic.com"
|
||||||
|
model_name = model or "claude-sonnet-4-20250514"
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
proxy_url = None
|
||||||
|
if settings.proxy.enabled:
|
||||||
|
proxy_url = settings.proxy.http or settings.proxy.https
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
proxy=proxy_url,
|
||||||
|
timeout=30
|
||||||
|
) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{url.rstrip('/')}/v1/messages",
|
||||||
|
headers={
|
||||||
|
"x-api-key": api_key,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": model_name,
|
||||||
|
"messages": [{"role": "user", "content": "Say hi"}],
|
||||||
|
"max_tokens": 5
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
latency = int((time.time() - start) * 1000)
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return {"success": True, "message": "连接正常", "latency": latency}
|
||||||
|
elif resp.status_code == 401:
|
||||||
|
return {"success": False, "error": "401 - API Key 无效或格式不正确"}
|
||||||
|
else:
|
||||||
|
error_text = resp.text[:200] if resp.text else str(resp.status_code)
|
||||||
|
return {"success": False, "error": f"{resp.status_code}: {error_text}"}
|
||||||
|
|
||||||
|
elif provider == "gemini":
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
model_name = model or "gemini-2.0-flash"
|
||||||
|
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent?key={api_key}"
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
proxy_url = None
|
||||||
|
if settings.proxy.enabled:
|
||||||
|
proxy_url = settings.proxy.http or settings.proxy.https
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
proxy=proxy_url,
|
||||||
|
timeout=30
|
||||||
|
) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
url,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
json={
|
||||||
|
"contents": [{"parts": [{"text": "Say hi"}]}],
|
||||||
|
"generationConfig": {"maxOutputTokens": 5}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
latency = int((time.time() - start) * 1000)
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return {"success": True, "message": "连接正常", "latency": latency}
|
||||||
|
elif resp.status_code == 400:
|
||||||
|
return {"success": False, "error": "400 - API Key 无效或模型名称错误"}
|
||||||
|
else:
|
||||||
|
error_text = resp.text[:200] if resp.text else str(resp.status_code)
|
||||||
|
return {"success": False, "error": f"{resp.status_code}: {error_text}"}
|
||||||
|
else:
|
||||||
|
return {"success": False, "error": f"不支持的提供商: {provider}"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
# 处理常见的网络错误
|
||||||
|
if "ConnectError" in error_msg or "ECONNREFUSED" in error_msg:
|
||||||
|
return {"success": False, "error": "连接被拒绝 - 请检查 URL 或代理设置"}
|
||||||
|
elif "TimeoutException" in error_msg or "timed out" in error_msg:
|
||||||
|
return {"success": False, "error": "请求超时 - 请检查网络或代理设置"}
|
||||||
|
elif "SSLError" in error_msg:
|
||||||
|
return {"success": False, "error": "SSL 错误 - 请检查代理配置"}
|
||||||
|
else:
|
||||||
|
return {"success": False, "error": error_msg}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/proxy/test")
|
@app.post("/api/proxy/test")
|
||||||
async def test_proxy() -> dict[str, Any]:
|
async def test_proxy() -> dict[str, Any]:
|
||||||
"""测试代理连接"""
|
"""测试代理连接"""
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/* MineNASAI WebUI 样式 */
|
/* MineNASAI WebUI 样式 */
|
||||||
|
|
||||||
|
/* 暗色主题(默认) */
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #1a1b26;
|
--bg-primary: #1a1b26;
|
||||||
--bg-secondary: #24283b;
|
--bg-secondary: #24283b;
|
||||||
@@ -13,6 +14,20 @@
|
|||||||
--border-color: #3b4261;
|
--border-color: #3b4261;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 亮色主题 */
|
||||||
|
.theme-light {
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-secondary: #f5f7fa;
|
||||||
|
--bg-tertiary: #fafafa;
|
||||||
|
--text-primary: #303133;
|
||||||
|
--text-secondary: #909399;
|
||||||
|
--accent-primary: #409eff;
|
||||||
|
--accent-success: #67c23a;
|
||||||
|
--accent-warning: #e6a23c;
|
||||||
|
--accent-error: #f56c6c;
|
||||||
|
--border-color: #dcdfe6;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -196,7 +211,8 @@ body {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: calc(100vh - 180px);
|
height: calc(100vh - 340px);
|
||||||
|
min-height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-header {
|
.terminal-header {
|
||||||
@@ -368,6 +384,67 @@ body {
|
|||||||
color: var(--accent-success);
|
color: var(--accent-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* LLM 配置状态卡片 */
|
||||||
|
.status-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip:hover {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
background-color: rgba(122, 162, 247, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.selected {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
background-color: rgba(122, 162, 247, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.active {
|
||||||
|
border-color: var(--accent-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.active .status-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 测试结果样式 */
|
||||||
|
.test-result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result.success {
|
||||||
|
color: var(--accent-success);
|
||||||
|
background-color: rgba(158, 206, 106, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result.error {
|
||||||
|
color: var(--accent-error);
|
||||||
|
background-color: rgba(247, 118, 142, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
/* 响应式 */
|
/* 响应式 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -380,3 +457,100 @@ body {
|
|||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 亮色主题额外样式覆盖 */
|
||||||
|
.theme-light .el-menu {
|
||||||
|
background-color: var(--bg-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .el-menu-item,
|
||||||
|
.theme-light .el-sub-menu__title {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .el-menu-item.is-active {
|
||||||
|
background-color: var(--bg-secondary) !important;
|
||||||
|
color: var(--accent-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .el-menu-item:hover,
|
||||||
|
.theme-light .el-sub-menu__title:hover {
|
||||||
|
background-color: var(--bg-tertiary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .el-card {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .el-input__inner,
|
||||||
|
.theme-light .el-textarea__inner {
|
||||||
|
background-color: var(--bg-primary) !important;
|
||||||
|
border-color: var(--border-color) !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .el-select-dropdown {
|
||||||
|
background-color: var(--bg-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .el-select-dropdown__item {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .config-card {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .provider-card {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .stat-card {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .terminal-container,
|
||||||
|
.theme-light .logs-container {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .terminal-header,
|
||||||
|
.theme-light .logs-header {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .el-breadcrumb__inner {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .el-dialog {
|
||||||
|
--el-dialog-bg-color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .el-table {
|
||||||
|
--el-table-bg-color: var(--bg-secondary);
|
||||||
|
--el-table-header-bg-color: var(--bg-tertiary);
|
||||||
|
--el-table-tr-bg-color: var(--bg-secondary);
|
||||||
|
--el-table-row-hover-bg-color: var(--bg-tertiary);
|
||||||
|
--el-table-text-color: var(--text-primary);
|
||||||
|
--el-table-header-text-color: var(--text-secondary);
|
||||||
|
--el-table-border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .status-chip {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .status-chip:hover,
|
||||||
|
.theme-light .status-chip.selected {
|
||||||
|
background-color: rgba(64, 158, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .test-result.success {
|
||||||
|
background-color: rgba(103, 194, 58, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .test-result.error {
|
||||||
|
background-color: rgba(245, 108, 108, 0.15);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<link rel="stylesheet" href="/webui/css/webui.css">
|
<link rel="stylesheet" href="/webui/css/webui.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app" :class="{ 'theme-light': theme === 'light' }">
|
||||||
<el-config-provider :locale="zhCn">
|
<el-config-provider :locale="zhCn">
|
||||||
<el-container class="app-container">
|
<el-container class="app-container">
|
||||||
<!-- 侧边栏 -->
|
<!-- 侧边栏 -->
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
:default-active="activeMenu"
|
:default-active="activeMenu"
|
||||||
:collapse="isCollapsed"
|
:collapse="isCollapsed"
|
||||||
:collapse-transition="false"
|
:collapse-transition="false"
|
||||||
background-color="#1a1b26"
|
:background-color="theme === 'dark' ? '#1a1b26' : '#ffffff'"
|
||||||
text-color="#c0caf5"
|
:text-color="theme === 'dark' ? '#c0caf5' : '#303133'"
|
||||||
active-text-color="#7aa2f7"
|
active-text-color="#7aa2f7"
|
||||||
@select="handleMenuSelect"
|
@select="handleMenuSelect"
|
||||||
>
|
>
|
||||||
@@ -71,6 +71,10 @@
|
|||||||
</el-breadcrumb>
|
</el-breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
<el-button text @click="toggleTheme" :title="theme === 'dark' ? '切换亮色主题' : '切换暗色主题'">
|
||||||
|
<el-icon v-if="theme === 'dark'"><Sunny /></el-icon>
|
||||||
|
<el-icon v-else><Moon /></el-icon>
|
||||||
|
</el-button>
|
||||||
<el-tag :type="systemStatus === 'running' ? 'success' : 'danger'" size="small">
|
<el-tag :type="systemStatus === 'running' ? 'success' : 'danger'" size="small">
|
||||||
{{ systemStatus === 'running' ? '运行中' : '异常' }}
|
{{ systemStatus === 'running' ? '运行中' : '异常' }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ const api = {
|
|||||||
// 创建 Vue 应用
|
// 创建 Vue 应用
|
||||||
app = createApp({
|
app = createApp({
|
||||||
setup() {
|
setup() {
|
||||||
// 状态
|
// 主题状态
|
||||||
|
const theme = ref(localStorage.getItem('minenasai-theme') || 'light');
|
||||||
|
|
||||||
|
// 组件状态
|
||||||
const isCollapsed = ref(false);
|
const isCollapsed = ref(false);
|
||||||
const activeMenu = ref('dashboard');
|
const activeMenu = ref('dashboard');
|
||||||
const systemStatus = ref('running');
|
const systemStatus = ref('running');
|
||||||
@@ -215,6 +218,12 @@ app = createApp({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 切换主题
|
||||||
|
function toggleTheme() {
|
||||||
|
theme.value = theme.value === 'dark' ? 'light' : 'dark';
|
||||||
|
localStorage.setItem('minenasai-theme', theme.value);
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadConfig();
|
loadConfig();
|
||||||
@@ -227,6 +236,8 @@ app = createApp({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
theme,
|
||||||
|
toggleTheme,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
activeMenu,
|
activeMenu,
|
||||||
systemStatus,
|
systemStatus,
|
||||||
|
|||||||
@@ -1,231 +1,458 @@
|
|||||||
/**
|
/**
|
||||||
* LLM 配置组件
|
* LLM 配置组件 - 简化版
|
||||||
|
* 参考 OpenAI 等主流配置方式
|
||||||
*/
|
*/
|
||||||
app.component('llm-config-page', {
|
app.component('llm-config-page', {
|
||||||
props: ['config'],
|
props: ['config'],
|
||||||
emits: ['save'],
|
emits: ['save'],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const { ref, reactive, watch } = Vue;
|
const { ref, reactive, watch, computed } = Vue;
|
||||||
|
|
||||||
// 表单数据
|
// 当前激活的提供商标签
|
||||||
const form = reactive({
|
const activeProvider = ref('openai-compatible');
|
||||||
default_provider: '',
|
|
||||||
default_model: '',
|
// 提供商配置状态
|
||||||
anthropic_api_key: '',
|
const providerStatus = reactive({});
|
||||||
openai_api_key: '',
|
|
||||||
deepseek_api_key: '',
|
// 测试状态
|
||||||
zhipu_api_key: '',
|
const testing = ref(null);
|
||||||
minimax_api_key: '',
|
const testResult = reactive({
|
||||||
minimax_group_id: '',
|
provider: '',
|
||||||
moonshot_api_key: '',
|
success: false,
|
||||||
gemini_api_key: ''
|
message: '',
|
||||||
|
details: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听 props 变化
|
// 保存状态
|
||||||
watch(() => props.config, (newVal) => {
|
const saving = ref(false);
|
||||||
if (newVal) {
|
|
||||||
Object.assign(form, newVal);
|
// 表单数据 - 简化为通用的 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, { immediate: true, deep: true });
|
});
|
||||||
|
|
||||||
// 提供商列表
|
// 提供商列表
|
||||||
const providers = [
|
const providers = [
|
||||||
{ value: 'anthropic', label: 'Anthropic (Claude)', region: 'overseas', models: ['claude-sonnet-4-20250514', 'claude-3-5-sonnet-20241022', 'claude-3-opus-20240229'] },
|
{
|
||||||
{ value: 'openai', label: 'OpenAI (GPT)', region: 'overseas', models: ['gpt-4o', 'gpt-4-turbo', 'gpt-3.5-turbo'] },
|
id: 'openai-compatible',
|
||||||
{ value: 'gemini', label: 'Google Gemini', region: 'overseas', models: ['gemini-2.0-flash', 'gemini-1.5-pro'] },
|
name: 'OpenAI 兼容',
|
||||||
{ value: 'deepseek', label: 'DeepSeek', region: 'domestic', models: ['deepseek-chat', 'deepseek-coder'] },
|
icon: '🔌',
|
||||||
{ value: 'zhipu', label: '智谱 (GLM)', region: 'domestic', models: ['glm-4-flash', 'glm-4', 'glm-3-turbo'] },
|
description: '支持所有 OpenAI 兼容的 API(OpenAI、Azure、本地模型等)',
|
||||||
{ value: 'minimax', label: 'MiniMax', region: 'domestic', models: ['abab6.5s-chat', 'abab5.5-chat'] },
|
defaultUrl: 'https://api.openai.com/v1',
|
||||||
{ value: 'moonshot', label: 'Moonshot (Kimi)', region: 'domestic', models: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'] }
|
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 currentModels = Vue.computed(() => {
|
const currentProvider = computed(() => {
|
||||||
const provider = providers.find(p => p.value === form.default_provider);
|
return providers.find(p => p.id === activeProvider.value) || providers[0];
|
||||||
return provider ? provider.models : [];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 当前提供商的配置
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 测试连接
|
// 测试连接
|
||||||
const testing = ref(null);
|
async function testConnection() {
|
||||||
async function testConnection(provider) {
|
const config = form.configs[activeProvider.value];
|
||||||
testing.value = provider;
|
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 {
|
try {
|
||||||
const res = await fetch(`/api/llm/test/${provider}`, { method: 'POST' });
|
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();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
ElementPlus.ElMessage.success(`${provider} 连接成功`);
|
testResult.success = true;
|
||||||
|
testResult.message = '连接成功!';
|
||||||
|
testResult.details = data.message || `模型响应正常,延迟: ${data.latency || 'N/A'}ms`;
|
||||||
|
config.configured = true;
|
||||||
|
ElementPlus.ElMessage.success('API 连接测试成功');
|
||||||
} else {
|
} else {
|
||||||
ElementPlus.ElMessage.error(`${provider} 连接失败: ${data.error}`);
|
testResult.success = false;
|
||||||
|
testResult.message = '连接失败';
|
||||||
|
testResult.details = formatError(data.error);
|
||||||
|
ElementPlus.ElMessage.error('连接失败: ' + (data.error || '未知错误'));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElementPlus.ElMessage.error(`测试失败: ${e.message}`);
|
testResult.success = false;
|
||||||
|
testResult.message = '请求失败';
|
||||||
|
testResult.details = formatError(e.message);
|
||||||
|
ElementPlus.ElMessage.error('测试请求失败: ' + e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
testing.value = null;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// 保存配置
|
// 保存配置
|
||||||
function handleSave() {
|
async function handleSave() {
|
||||||
emit('save', { ...form });
|
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 {
|
return {
|
||||||
form,
|
activeProvider,
|
||||||
providers,
|
providers,
|
||||||
currentModels,
|
currentProvider,
|
||||||
|
currentConfig,
|
||||||
|
form,
|
||||||
testing,
|
testing,
|
||||||
|
testResult,
|
||||||
|
saving,
|
||||||
testConnection,
|
testConnection,
|
||||||
handleSave
|
handleSave,
|
||||||
|
setAsDefault,
|
||||||
|
applyPreset
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
<div class="llm-config">
|
<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">
|
<el-card class="config-card">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
<span>默认提供商</span>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-form :model="form" label-width="120px">
|
|
||||||
<el-form-item label="默认提供商">
|
<el-alert
|
||||||
<el-select v-model="form.default_provider" style="width: 300px">
|
:title="currentProvider.description"
|
||||||
<el-option
|
type="info"
|
||||||
v-for="p in providers"
|
:closable="false"
|
||||||
:key="p.value"
|
show-icon
|
||||||
:value="p.value"
|
style="margin-bottom: 20px;"
|
||||||
:label="p.label"
|
/>
|
||||||
>
|
|
||||||
<span>{{ p.label }}</span>
|
<!-- OpenAI 兼容接口的快速预设 -->
|
||||||
<el-tag size="small" :type="p.region === 'domestic' ? 'success' : ''" style="margin-left: 8px">
|
<div v-if="activeProvider === 'openai-compatible'" style="margin-bottom: 16px;">
|
||||||
{{ p.region === 'domestic' ? '国内' : '境外' }}
|
<span style="margin-right: 8px; color: var(--text-secondary);">快速配置:</span>
|
||||||
</el-tag>
|
<el-button-group size="small">
|
||||||
</el-option>
|
<el-button @click="applyPreset('openai')">OpenAI</el-button>
|
||||||
</el-select>
|
<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>
|
||||||
<el-form-item label="默认模型">
|
|
||||||
<el-select v-model="form.default_model" style="width: 300px">
|
<el-form-item label="API Base URL">
|
||||||
<el-option v-for="m in currentModels" :key="m" :value="m" :label="m" />
|
<el-input
|
||||||
</el-select>
|
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-item>
|
||||||
</el-form>
|
</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>
|
</el-card>
|
||||||
|
|
||||||
<!-- 境外服务 -->
|
<!-- 保存按钮 -->
|
||||||
<el-card class="config-card">
|
<div style="margin-top: 20px; display: flex; gap: 12px;">
|
||||||
<template #header>
|
<el-button type="primary" size="large" @click="handleSave" :loading="saving">
|
||||||
<span>境外服务 <el-tag size="small" type="warning">需要代理</el-tag></span>
|
保存所有配置
|
||||||
</template>
|
</el-button>
|
||||||
|
|
||||||
<div class="provider-card">
|
|
||||||
<div class="provider-header">
|
|
||||||
<div class="provider-name">
|
|
||||||
<span>🤖</span>
|
|
||||||
<span>Anthropic (Claude)</span>
|
|
||||||
</div>
|
|
||||||
<el-button size="small" :loading="testing === 'anthropic'" @click="testConnection('anthropic')">测试连接</el-button>
|
|
||||||
</div>
|
|
||||||
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
|
|
||||||
<el-input v-model="form.anthropic_api_key" type="password" show-password placeholder="sk-ant-xxx" style="width: 400px" />
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="provider-card">
|
|
||||||
<div class="provider-header">
|
|
||||||
<div class="provider-name">
|
|
||||||
<span>💬</span>
|
|
||||||
<span>OpenAI (GPT)</span>
|
|
||||||
</div>
|
|
||||||
<el-button size="small" :loading="testing === 'openai'" @click="testConnection('openai')">测试连接</el-button>
|
|
||||||
</div>
|
|
||||||
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
|
|
||||||
<el-input v-model="form.openai_api_key" type="password" show-password placeholder="sk-xxx" style="width: 400px" />
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="provider-card">
|
|
||||||
<div class="provider-header">
|
|
||||||
<div class="provider-name">
|
|
||||||
<span>✨</span>
|
|
||||||
<span>Google Gemini</span>
|
|
||||||
</div>
|
|
||||||
<el-button size="small" :loading="testing === 'gemini'" @click="testConnection('gemini')">测试连接</el-button>
|
|
||||||
</div>
|
|
||||||
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
|
|
||||||
<el-input v-model="form.gemini_api_key" type="password" show-password placeholder="AIzaSy-xxx" style="width: 400px" />
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<!-- 国内服务 -->
|
|
||||||
<el-card class="config-card">
|
|
||||||
<template #header>
|
|
||||||
<span>国内服务 <el-tag size="small" type="success">无需代理</el-tag></span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="provider-card">
|
|
||||||
<div class="provider-header">
|
|
||||||
<div class="provider-name">
|
|
||||||
<span>🔍</span>
|
|
||||||
<span>DeepSeek</span>
|
|
||||||
<span class="provider-badge domestic">推荐</span>
|
|
||||||
</div>
|
|
||||||
<el-button size="small" :loading="testing === 'deepseek'" @click="testConnection('deepseek')">测试连接</el-button>
|
|
||||||
</div>
|
|
||||||
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
|
|
||||||
<el-input v-model="form.deepseek_api_key" type="password" show-password placeholder="sk-xxx" style="width: 400px" />
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="provider-card">
|
|
||||||
<div class="provider-header">
|
|
||||||
<div class="provider-name">
|
|
||||||
<span>🧠</span>
|
|
||||||
<span>智谱 (GLM)</span>
|
|
||||||
</div>
|
|
||||||
<el-button size="small" :loading="testing === 'zhipu'" @click="testConnection('zhipu')">测试连接</el-button>
|
|
||||||
</div>
|
|
||||||
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
|
|
||||||
<el-input v-model="form.zhipu_api_key" type="password" show-password style="width: 400px" />
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="provider-card">
|
|
||||||
<div class="provider-header">
|
|
||||||
<div class="provider-name">
|
|
||||||
<span>🎯</span>
|
|
||||||
<span>MiniMax</span>
|
|
||||||
</div>
|
|
||||||
<el-button size="small" :loading="testing === 'minimax'" @click="testConnection('minimax')">测试连接</el-button>
|
|
||||||
</div>
|
|
||||||
<el-row :gutter="16">
|
|
||||||
<el-col :span="12">
|
|
||||||
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
|
|
||||||
<el-input v-model="form.minimax_api_key" type="password" show-password />
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="12">
|
|
||||||
<el-form-item label="Group ID" label-width="100px" style="margin-bottom: 0">
|
|
||||||
<el-input v-model="form.minimax_group_id" />
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="provider-card">
|
|
||||||
<div class="provider-header">
|
|
||||||
<div class="provider-name">
|
|
||||||
<span>🌙</span>
|
|
||||||
<span>Moonshot (Kimi)</span>
|
|
||||||
</div>
|
|
||||||
<el-button size="small" :loading="testing === 'moonshot'" @click="testConnection('moonshot')">测试连接</el-button>
|
|
||||||
</div>
|
|
||||||
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
|
|
||||||
<el-input v-model="form.moonshot_api_key" type="password" show-password style="width: 400px" />
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<div style="margin-top: 20px;">
|
|
||||||
<el-button type="primary" @click="handleSave">保存配置</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -3,13 +3,32 @@
|
|||||||
*/
|
*/
|
||||||
app.component('terminal-page', {
|
app.component('terminal-page', {
|
||||||
setup() {
|
setup() {
|
||||||
const { ref, onMounted, onUnmounted } = Vue;
|
const { ref, reactive, onMounted, onUnmounted } = Vue;
|
||||||
|
|
||||||
const terminal = ref(null);
|
const terminal = ref(null);
|
||||||
const fitAddon = ref(null);
|
const fitAddon = ref(null);
|
||||||
|
const fitAddonLoaded = ref(false); // 标记 addon 是否已加载
|
||||||
const socket = ref(null);
|
const socket = ref(null);
|
||||||
const isConnected = ref(false);
|
const isConnected = ref(false);
|
||||||
const sessionId = ref('');
|
const sessionId = ref('');
|
||||||
|
const showConfig = ref(false);
|
||||||
|
|
||||||
|
// SSH 配置
|
||||||
|
const sshConfig = reactive({
|
||||||
|
host: 'localhost',
|
||||||
|
port: 22,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
privateKey: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// SSH 信息展示
|
||||||
|
const sshInfo = reactive({
|
||||||
|
connected: false,
|
||||||
|
host: '',
|
||||||
|
user: '',
|
||||||
|
connectedAt: ''
|
||||||
|
});
|
||||||
|
|
||||||
// 初始化终端
|
// 初始化终端
|
||||||
function initTerminal() {
|
function initTerminal() {
|
||||||
@@ -40,13 +59,19 @@ app.component('terminal-page', {
|
|||||||
scrollback: 10000
|
scrollback: 10000
|
||||||
});
|
});
|
||||||
|
|
||||||
fitAddon.value = new FitAddon.FitAddon();
|
// 创建 FitAddon
|
||||||
terminal.value.loadAddon(fitAddon.value);
|
if (typeof FitAddon !== 'undefined' && FitAddon.FitAddon) {
|
||||||
|
fitAddon.value = new FitAddon.FitAddon();
|
||||||
|
terminal.value.loadAddon(fitAddon.value);
|
||||||
|
fitAddonLoaded.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
const container = document.getElementById('webui-terminal');
|
const container = document.getElementById('webui-terminal');
|
||||||
if (container) {
|
if (container) {
|
||||||
terminal.value.open(container);
|
terminal.value.open(container);
|
||||||
fitAddon.value.fit();
|
if (fitAddonLoaded.value && fitAddon.value) {
|
||||||
|
fitAddon.value.fit();
|
||||||
|
}
|
||||||
|
|
||||||
// 显示欢迎信息
|
// 显示欢迎信息
|
||||||
showWelcome();
|
showWelcome();
|
||||||
@@ -87,22 +112,30 @@ app.component('terminal-page', {
|
|||||||
|
|
||||||
// 处理窗口大小变化
|
// 处理窗口大小变化
|
||||||
function handleResize() {
|
function handleResize() {
|
||||||
if (fitAddon.value) {
|
if (fitAddonLoaded.value && fitAddon.value) {
|
||||||
fitAddon.value.fit();
|
try {
|
||||||
sendResize();
|
fitAddon.value.fit();
|
||||||
|
sendResize();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Fit addon resize error:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送终端大小
|
// 发送终端大小
|
||||||
function sendResize() {
|
function sendResize() {
|
||||||
if (socket.value && socket.value.readyState === WebSocket.OPEN && fitAddon.value) {
|
if (socket.value && socket.value.readyState === WebSocket.OPEN && fitAddonLoaded.value && fitAddon.value) {
|
||||||
const dims = fitAddon.value.proposeDimensions();
|
try {
|
||||||
if (dims) {
|
const dims = fitAddon.value.proposeDimensions();
|
||||||
socket.value.send(JSON.stringify({
|
if (dims) {
|
||||||
type: 'resize',
|
socket.value.send(JSON.stringify({
|
||||||
cols: dims.cols,
|
type: 'resize',
|
||||||
rows: dims.rows
|
cols: dims.cols,
|
||||||
}));
|
rows: dims.rows
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Send resize error:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,6 +187,11 @@ app.component('terminal-page', {
|
|||||||
case 'auth_ok':
|
case 'auth_ok':
|
||||||
isConnected.value = true;
|
isConnected.value = true;
|
||||||
sessionId.value = msg.session_id;
|
sessionId.value = msg.session_id;
|
||||||
|
// 更新 SSH 信息
|
||||||
|
sshInfo.connected = true;
|
||||||
|
sshInfo.host = sshConfig.host + ':' + sshConfig.port;
|
||||||
|
sshInfo.user = sshConfig.username || 'anonymous';
|
||||||
|
sshInfo.connectedAt = new Date().toLocaleString('zh-CN');
|
||||||
terminal.value.writeln('\x1b[32m已连接到终端\x1b[0m\r\n');
|
terminal.value.writeln('\x1b[32m已连接到终端\x1b[0m\r\n');
|
||||||
sendResize();
|
sendResize();
|
||||||
break;
|
break;
|
||||||
@@ -183,6 +221,7 @@ app.component('terminal-page', {
|
|||||||
socket.value = null;
|
socket.value = null;
|
||||||
}
|
}
|
||||||
isConnected.value = false;
|
isConnected.value = false;
|
||||||
|
sshInfo.connected = false;
|
||||||
terminal.value.writeln('\r\n\x1b[33m已断开连接\x1b[0m');
|
terminal.value.writeln('\r\n\x1b[33m已断开连接\x1b[0m');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,15 +242,27 @@ app.component('terminal-page', {
|
|||||||
window.removeEventListener('resize', handleResize);
|
window.removeEventListener('resize', handleResize);
|
||||||
if (socket.value) {
|
if (socket.value) {
|
||||||
socket.value.close();
|
socket.value.close();
|
||||||
|
socket.value = null;
|
||||||
}
|
}
|
||||||
|
// 安全地 dispose terminal(会自动 dispose 已加载的 addons)
|
||||||
if (terminal.value) {
|
if (terminal.value) {
|
||||||
terminal.value.dispose();
|
try {
|
||||||
|
terminal.value.dispose();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Terminal dispose error:', e);
|
||||||
|
}
|
||||||
|
terminal.value = null;
|
||||||
}
|
}
|
||||||
|
fitAddon.value = null;
|
||||||
|
fitAddonLoaded.value = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isConnected,
|
isConnected,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
showConfig,
|
||||||
|
sshConfig,
|
||||||
|
sshInfo,
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
clear
|
clear
|
||||||
@@ -219,12 +270,75 @@ app.component('terminal-page', {
|
|||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
<div class="terminal-page">
|
<div class="terminal-page">
|
||||||
|
<!-- SSH 配置面板 -->
|
||||||
|
<el-card class="config-card" style="margin-bottom: 16px;">
|
||||||
|
<template #header>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<span>SSH 连接配置</span>
|
||||||
|
<el-button text @click="showConfig = !showConfig">
|
||||||
|
{{ showConfig ? '收起' : '展开' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- SSH 信息展示 -->
|
||||||
|
<div v-if="sshInfo.connected" style="margin-bottom: 12px;">
|
||||||
|
<el-descriptions :column="4" border size="small">
|
||||||
|
<el-descriptions-item label="状态">
|
||||||
|
<el-tag type="success" size="small">已连接</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="主机">{{ sshInfo.host }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="用户">{{ sshInfo.user }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="连接时间">{{ sshInfo.connectedAt }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
<div v-else style="margin-bottom: 12px;">
|
||||||
|
<el-tag type="info" size="small">未连接</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SSH 配置表单 -->
|
||||||
|
<el-collapse-transition>
|
||||||
|
<div v-show="showConfig">
|
||||||
|
<el-form :model="sshConfig" label-width="100px" size="small">
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="主机地址">
|
||||||
|
<el-input v-model="sshConfig.host" placeholder="localhost 或 192.168.1.1" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="端口">
|
||||||
|
<el-input-number v-model="sshConfig.port" :min="1" :max="65535" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="用户名">
|
||||||
|
<el-input v-model="sshConfig.username" placeholder="root" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="密码">
|
||||||
|
<el-input v-model="sshConfig.password" type="password" show-password placeholder="留空使用密钥" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-form-item label="私钥">
|
||||||
|
<el-input v-model="sshConfig.privateKey" type="textarea" :rows="3" placeholder="粘贴 SSH 私钥内容(可选)" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</el-collapse-transition>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 终端容器 -->
|
||||||
<div class="terminal-container">
|
<div class="terminal-container">
|
||||||
<div class="terminal-header">
|
<div class="terminal-header">
|
||||||
<div class="terminal-title">
|
<div class="terminal-title">
|
||||||
<span class="terminal-status" :class="{ connected: isConnected }"></span>
|
<span class="terminal-status" :class="{ connected: isConnected }"></span>
|
||||||
<span>终端</span>
|
<span>终端</span>
|
||||||
<span v-if="sessionId" style="margin-left: 8px; font-size: 12px; color: #565f89;">
|
<span v-if="sessionId" style="margin-left: 8px; font-size: 12px; color: var(--text-secondary);">
|
||||||
{{ sessionId.slice(0, 8) }}
|
{{ sessionId.slice(0, 8) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user