From 427ea6ae8d988adf879db63be9fe314aad45f2ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=94=A6=E9=BA=9F=20=E7=8E=8B?= Date: Fri, 6 Feb 2026 10:24:28 +0800 Subject: [PATCH] feat: add light theme support and enhance LLM configuration UI --- src/minenasai/webtui/server.py | 156 ++++- .../webtui/static/webui/css/webui.css | 176 +++++- src/minenasai/webtui/static/webui/index.html | 10 +- src/minenasai/webtui/static/webui/js/app.js | 13 +- .../static/webui/js/components/llm-config.js | 585 ++++++++++++------ .../static/webui/js/components/terminal.js | 148 ++++- 6 files changed, 886 insertions(+), 202 deletions(-) diff --git a/src/minenasai/webtui/server.py b/src/minenasai/webtui/server.py index e7b8cb2..e065152 100644 --- a/src/minenasai/webtui/server.py +++ b/src/minenasai/webtui/server.py @@ -390,7 +390,7 @@ async def update_proxy_config(data: ProxyConfigUpdate) -> dict[str, Any]: @app.post("/api/llm/test/{provider}") async def test_llm_connection(provider: str) -> dict[str, Any]: - """测试 LLM 连接""" + """测试 LLM 连接(使用已保存配置)""" try: 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)} +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") async def test_proxy() -> dict[str, Any]: """测试代理连接""" diff --git a/src/minenasai/webtui/static/webui/css/webui.css b/src/minenasai/webtui/static/webui/css/webui.css index b03e05b..c2af379 100644 --- a/src/minenasai/webtui/static/webui/css/webui.css +++ b/src/minenasai/webtui/static/webui/css/webui.css @@ -1,5 +1,6 @@ /* MineNASAI WebUI 样式 */ +/* 暗色主题(默认) */ :root { --bg-primary: #1a1b26; --bg-secondary: #24283b; @@ -13,6 +14,20 @@ --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; padding: 0; @@ -196,7 +211,8 @@ body { border: 1px solid var(--border-color); border-radius: 8px; overflow: hidden; - height: calc(100vh - 180px); + height: calc(100vh - 340px); + min-height: 300px; } .terminal-header { @@ -368,6 +384,67 @@ body { 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) { .sidebar { @@ -380,3 +457,100 @@ body { 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); +} diff --git a/src/minenasai/webtui/static/webui/index.html b/src/minenasai/webtui/static/webui/index.html index c64eaa9..c830e53 100644 --- a/src/minenasai/webtui/static/webui/index.html +++ b/src/minenasai/webtui/static/webui/index.html @@ -12,7 +12,7 @@ -
+
@@ -25,8 +25,8 @@ :default-active="activeMenu" :collapse="isCollapsed" :collapse-transition="false" - background-color="#1a1b26" - text-color="#c0caf5" + :background-color="theme === 'dark' ? '#1a1b26' : '#ffffff'" + :text-color="theme === 'dark' ? '#c0caf5' : '#303133'" active-text-color="#7aa2f7" @select="handleMenuSelect" > @@ -71,6 +71,10 @@
+ + + + {{ systemStatus === 'running' ? '运行中' : '异常' }} diff --git a/src/minenasai/webtui/static/webui/js/app.js b/src/minenasai/webtui/static/webui/js/app.js index 7d18419..7d93aea 100644 --- a/src/minenasai/webtui/static/webui/js/app.js +++ b/src/minenasai/webtui/static/webui/js/app.js @@ -54,7 +54,10 @@ const api = { // 创建 Vue 应用 app = createApp({ setup() { - // 状态 + // 主题状态 + const theme = ref(localStorage.getItem('minenasai-theme') || 'light'); + + // 组件状态 const isCollapsed = ref(false); const activeMenu = ref('dashboard'); 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(() => { loadConfig(); @@ -227,6 +236,8 @@ app = createApp({ }); return { + theme, + toggleTheme, isCollapsed, activeMenu, systemStatus, diff --git a/src/minenasai/webtui/static/webui/js/components/llm-config.js b/src/minenasai/webtui/static/webui/js/components/llm-config.js index 8b3e856..6027073 100644 --- a/src/minenasai/webtui/static/webui/js/components/llm-config.js +++ b/src/minenasai/webtui/static/webui/js/components/llm-config.js @@ -1,231 +1,458 @@ /** - * LLM 配置组件 + * LLM 配置组件 - 简化版 + * 参考 OpenAI 等主流配置方式 */ app.component('llm-config-page', { props: ['config'], emits: ['save'], setup(props, { emit }) { - const { ref, reactive, watch } = Vue; + const { ref, reactive, watch, computed } = Vue; - // 表单数据 - const form = reactive({ - default_provider: '', - default_model: '', - anthropic_api_key: '', - openai_api_key: '', - deepseek_api_key: '', - zhipu_api_key: '', - minimax_api_key: '', - minimax_group_id: '', - moonshot_api_key: '', - gemini_api_key: '' + // 当前激活的提供商标签 + const activeProvider = ref('openai-compatible'); + + // 提供商配置状态 + const providerStatus = reactive({}); + + // 测试状态 + const testing = ref(null); + const testResult = reactive({ + provider: '', + success: false, + message: '', + details: '' }); - // 监听 props 变化 - watch(() => props.config, (newVal) => { - if (newVal) { - Object.assign(form, newVal); + // 保存状态 + 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 + } } - }, { immediate: true, deep: true }); + }); // 提供商列表 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'] }, - { value: 'gemini', label: 'Google Gemini', region: 'overseas', models: ['gemini-2.0-flash', 'gemini-1.5-pro'] }, - { value: 'deepseek', label: 'DeepSeek', region: 'domestic', models: ['deepseek-chat', 'deepseek-coder'] }, - { value: 'zhipu', label: '智谱 (GLM)', region: 'domestic', models: ['glm-4-flash', 'glm-4', 'glm-3-turbo'] }, - { value: 'minimax', label: 'MiniMax', region: 'domestic', models: ['abab6.5s-chat', 'abab5.5-chat'] }, - { value: 'moonshot', label: 'Moonshot (Kimi)', region: 'domestic', models: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'] } + { + id: 'openai-compatible', + name: 'OpenAI 兼容', + icon: '🔌', + description: '支持所有 OpenAI 兼容的 API(OpenAI、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 currentModels = Vue.computed(() => { - const provider = providers.find(p => p.value === form.default_provider); - return provider ? provider.models : []; + // 当前选中的提供商信息 + 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; + } + } + // 测试连接 - const testing = ref(null); - async function testConnection(provider) { - testing.value = provider; + 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/${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(); + 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 { - ElementPlus.ElMessage.error(`${provider} 连接失败: ${data.error}`); + testResult.success = false; + testResult.message = '连接失败'; + testResult.details = formatError(data.error); + ElementPlus.ElMessage.error('连接失败: ' + (data.error || '未知错误')); } } 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; } + // 格式化错误信息 + 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() { - emit('save', { ...form }); + 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 { - form, + activeProvider, providers, - currentModels, + currentProvider, + currentConfig, + form, testing, + testResult, + saving, testConnection, - handleSave + handleSave, + setAsDefault, + applyPreset }; }, template: `
+ + + +
+
+ {{ p.icon }} + {{ p.name }} + + 默认 +
+
+
+ + - - - - - {{ p.label }} - - {{ p.region === 'domestic' ? '国内' : '境外' }} - - - + + + + +
+ 快速配置: + + OpenAI + Azure + Ollama 本地 + OneAPI + +
+ + + + - - - - + + + +
+ 留空将使用默认地址。如需使用代理或中转服务,请填写完整 URL。 +
+
+ + + +
+ 留空将使用默认模型:{{ currentProvider.defaultModel }} +
+ + + + +
+ + + 测试连接 + + +
+ + + {{ testResult.message }} +
+
+ + + + +
- - - - -
-
-
- 🤖 - Anthropic (Claude) -
- 测试连接 -
- - - -
- -
-
-
- 💬 - OpenAI (GPT) -
- 测试连接 -
- - - -
- -
-
-
- - Google Gemini -
- 测试连接 -
- - - -
-
- - - - - -
-
-
- 🔍 - DeepSeek - 推荐 -
- 测试连接 -
- - - -
- -
-
-
- 🧠 - 智谱 (GLM) -
- 测试连接 -
- - - -
- -
-
-
- 🎯 - MiniMax -
- 测试连接 -
- - - - - - - - - - - - -
- -
-
-
- 🌙 - Moonshot (Kimi) -
- 测试连接 -
- - - -
-
- -
- 保存配置 + +
+ + 保存所有配置 +
` diff --git a/src/minenasai/webtui/static/webui/js/components/terminal.js b/src/minenasai/webtui/static/webui/js/components/terminal.js index a6e5912..ebfad06 100644 --- a/src/minenasai/webtui/static/webui/js/components/terminal.js +++ b/src/minenasai/webtui/static/webui/js/components/terminal.js @@ -3,13 +3,32 @@ */ app.component('terminal-page', { setup() { - const { ref, onMounted, onUnmounted } = Vue; + const { ref, reactive, onMounted, onUnmounted } = Vue; const terminal = ref(null); const fitAddon = ref(null); + const fitAddonLoaded = ref(false); // 标记 addon 是否已加载 const socket = ref(null); const isConnected = ref(false); 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() { @@ -40,13 +59,19 @@ app.component('terminal-page', { scrollback: 10000 }); - fitAddon.value = new FitAddon.FitAddon(); - terminal.value.loadAddon(fitAddon.value); + // 创建 FitAddon + 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'); if (container) { terminal.value.open(container); - fitAddon.value.fit(); + if (fitAddonLoaded.value && fitAddon.value) { + fitAddon.value.fit(); + } // 显示欢迎信息 showWelcome(); @@ -87,22 +112,30 @@ app.component('terminal-page', { // 处理窗口大小变化 function handleResize() { - if (fitAddon.value) { - fitAddon.value.fit(); - sendResize(); + if (fitAddonLoaded.value && fitAddon.value) { + try { + fitAddon.value.fit(); + sendResize(); + } catch (e) { + console.warn('Fit addon resize error:', e); + } } } // 发送终端大小 function sendResize() { - if (socket.value && socket.value.readyState === WebSocket.OPEN && fitAddon.value) { - const dims = fitAddon.value.proposeDimensions(); - if (dims) { - socket.value.send(JSON.stringify({ - type: 'resize', - cols: dims.cols, - rows: dims.rows - })); + if (socket.value && socket.value.readyState === WebSocket.OPEN && fitAddonLoaded.value && fitAddon.value) { + try { + const dims = fitAddon.value.proposeDimensions(); + if (dims) { + socket.value.send(JSON.stringify({ + type: 'resize', + 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': isConnected.value = true; 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'); sendResize(); break; @@ -183,6 +221,7 @@ app.component('terminal-page', { socket.value = null; } isConnected.value = false; + sshInfo.connected = false; terminal.value.writeln('\r\n\x1b[33m已断开连接\x1b[0m'); } @@ -203,15 +242,27 @@ app.component('terminal-page', { window.removeEventListener('resize', handleResize); if (socket.value) { socket.value.close(); + socket.value = null; } + // 安全地 dispose terminal(会自动 dispose 已加载的 addons) 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 { isConnected, sessionId, + showConfig, + sshConfig, + sshInfo, connect, disconnect, clear @@ -219,12 +270,75 @@ app.component('terminal-page', { }, template: `
+ + + + + +
+ + + 已连接 + + {{ sshInfo.host }} + {{ sshInfo.user }} + {{ sshInfo.connectedAt }} + +
+
+ 未连接 +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
终端 - + {{ sessionId.slice(0, 8) }}