feat: 前端添加 LLM 配置设置页面

新增功能:
- 前端设置页面可切换 LLM 提供商
- 支持 DeepSeek, OpenAI, Qwen, Ollama
- API 密钥配置界面
- 配置保存到 .env 文件

后端 API:
- GET /ai/config - 获取当前配置
- POST /ai/config - 更新配置

使用方式:
1. 打开前端 "⚙️ 设置" 页面
2. 选择 LLM 提供商
3. 输入 API 密钥(云端)或设置地址(本地)
4. 保存配置并重启后端

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yuuuu 2026-02-20 22:48:38 +08:00
parent 7364c143fb
commit 689cf94d4b
3 changed files with 335 additions and 1 deletions

View File

@ -958,3 +958,82 @@ async def stop_ai_service():
"status": "not_running", "status": "not_running",
"message": "Ollama was not running" "message": "Ollama was not running"
} }
# LLM 配置管理
class LLMConfig(BaseModel):
provider: str = "deepseek" # ollama, deepseek, openai, qwen
api_key: Optional[str] = None
deepseek_api_key: Optional[str] = None
openai_api_key: Optional[str] = None
qwen_api_key: Optional[str] = None
@app.get("/ai/config")
async def get_ai_config():
"""
获取当前 LLM 配置
"""
from dotenv import load_dotenv
import os
# 重新加载环境变量
env_path = Path(__file__).parent.parent / ".env"
load_dotenv(env_path)
provider = os.getenv("LLM_PROVIDER", "ollama")
return {
"provider": provider,
"ollama_host": os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434"),
"has_deepseek_key": bool(os.getenv("DEEPSEEK_API_KEY")),
"has_openai_key": bool(os.getenv("OPENAI_API_KEY")),
"has_qwen_key": bool(os.getenv("QWEN_API_KEY")),
}
@app.post("/ai/config")
async def update_ai_config(config: LLMConfig):
"""
更新 LLM 配置(需要重启后端生效)
"""
from dotenv import load_dotenv
import os
env_path = Path(__file__).parent.parent / ".env"
# 读取现有 .env 文件
env_vars = {}
if env_path.exists():
load_dotenv(env_path)
# 保留现有配置
for key, value in os.environ.items():
if key.startswith(("DEEPSEEK_", "OPENAI_", "QWEN_", "OLLAMA_", "LLM_")):
env_vars[key] = value
# 更新配置
env_vars["LLM_PROVIDER"] = config.provider
# 保存 API 密钥
if config.deepseek_api_key:
env_vars["DEEPSEEK_API_KEY"] = config.deepseek_api_key
if config.openai_api_key:
env_vars["OPENAI_API_KEY"] = config.openai_api_key
if config.qwen_api_key:
env_vars["QWEN_API_KEY"] = config.qwen_api_key
# 写入 .env 文件
with open(env_path, "w") as f:
for key, value in env_vars.items():
# 只保存 LLM 相关的环境变量
if key.startswith(("DEEPSEEK_", "OPENAI_", "QWEN_", "OLLAMA_", "LLM_")):
f.write(f"{key}={value}\n")
print(f"[AI] LLM 配置已更新: provider={config.provider}")
return {
"status": "success",
"message": "配置已保存,请重启后端服务生效",
"provider": config.provider,
"requires_restart": True
}

View File

@ -24,6 +24,7 @@ import { darkTheme } from 'naive-ui'
import SignalDashboard from './components/SignalDashboard.vue' import SignalDashboard from './components/SignalDashboard.vue'
import ExperimentBoard from './components/ExperimentBoard.vue' import ExperimentBoard from './components/ExperimentBoard.vue'
import ContextManager from './components/ContextManager.vue' import ContextManager from './components/ContextManager.vue'
import LLMSettings from './components/LLMSettings.vue'
const theme = ref(darkTheme) const theme = ref(darkTheme)
const activeKey = ref('signals') const activeKey = ref('signals')
@ -31,7 +32,8 @@ const activeKey = ref('signals')
const menuOptions: MenuOption[] = [ const menuOptions: MenuOption[] = [
{ label: '信号总线', key: 'signals' }, { label: '信号总线', key: 'signals' },
{ label: '实验看板', key: 'experiments' }, { label: '实验看板', key: 'experiments' },
{ label: '认知核心', key: 'context' } { label: '认知核心', key: 'context' },
{ label: '⚙️ 设置', key: 'settings' }
] ]
const currentComponent = computed(() => { const currentComponent = computed(() => {
@ -39,6 +41,7 @@ const currentComponent = computed(() => {
case 'signals': return SignalDashboard case 'signals': return SignalDashboard
case 'experiments': return ExperimentBoard case 'experiments': return ExperimentBoard
case 'context': return ContextManager case 'context': return ContextManager
case 'settings': return LLMSettings
default: return SignalDashboard default: return SignalDashboard
} }
}) })

View File

@ -0,0 +1,252 @@
<template>
<n-card title="🤖 AI 模型配置" size="small">
<n-space vertical>
<!-- 提供商选择 -->
<n-form-item label="LLM 提供商">
<n-select
v-model:value="config.provider"
:options="providerOptions"
@update:value="onProviderChange"
style="width: 200px"
>
<template #label="{ option }">
<n-space align="center">
<span>{{ option.label }}</span>
<n-tag v-if="option.cloud" type="info" size="small">云端</n-tag>
<n-tag v-else type="success" size="small">本地</n-tag>
</n-space>
</template>
</n-select>
</n-form-item>
<!-- 提供商说明 -->
<n-alert v-if="providerInfo" :type="providerInfo.type" size="small">
{{ providerInfo.text }}
</n-alert>
<!-- API 密钥输入 -->
<n-divider />
<n-text depth="3" style="font-size: 12px;">API 密钥配置</n-text>
<n-form-item v-if="config.provider === 'deepseek'" label="DeepSeek API Key">
<n-input
v-model:value="config.deepseek_api_key"
type="password"
show-password-on="click"
placeholder="sk-..."
style="width: 300px"
/>
<template #feedback>
<n-text depth="3" style="font-size: 11px;">
获取地址: <n-a href="https://platform.deepseek.com/api_keys" target="_blank">platform.deepseek.com</n-a>
</n-text>
</template>
</n-form-item>
<n-form-item v-if="config.provider === 'openai'" label="OpenAI API Key">
<n-input
v-model:value="config.openai_api_key"
type="password"
show-password-on="click"
placeholder="sk-..."
style="width: 300px"
/>
<template #feedback>
<n-text depth="3" style="font-size: 11px;">
获取地址: <n-a href="https://platform.openai.com/api-keys" target="_blank">platform.openai.com</n-a>
</n-text>
</template>
</n-form-item>
<n-form-item v-if="config.provider === 'qwen'" label="Qwen API Key">
<n-input
v-model:value="config.qwen_api_key"
type="password"
show-password-on="click"
placeholder="sk-..."
style="width: 300px"
/>
<template #feedback>
<n-text depth="3" style="font-size: 11px;">
获取地址: <n-a href="https://dashscope.console.aliyun.com/apiKey" target="_blank">dashscope.console.aliyun.com</n-a>
</n-text>
</template>
</n-form-item>
<n-form-item v-if="config.provider === 'ollama'" label="Ollama 地址">
<n-input
v-model:value="config.ollama_host"
placeholder="http://127.0.0.1:11434"
style="width: 300px"
/>
<template #feedback>
<n-button text type="primary" @click="checkOllamaStatus" :loading="checking">
🔄 检测状态
</n-button>
</template>
</n-form-item>
<!-- 保存按钮 -->
<n-space>
<n-button type="primary" @click="saveConfig" :loading="saving">
保存配置
</n-button>
<n-button v-if="needsRestart" type="warning" @click="showRestartModal = true">
需要重启后端
</n-button>
</n-space>
<!-- 当前配置状态 -->
<n-divider />
<n-space>
<n-tag v-if="configStatus.has_deepseek_key" type="success" size="small">DeepSeek 已配置</n-tag>
<n-tag v-if="configStatus.has_openai_key" type="success" size="small">OpenAI 已配置</n-tag>
<n-tag v-if="configStatus.has_qwen_key" type="success" size="small">Qwen 已配置</n-tag>
</n-space>
</n-space>
<!-- 重启确认弹窗 -->
<n-modal v-model:show="showRestartModal" preset="dialog" title="重启后端服务">
<template #header>
<n-icon size="24" color="#f0a020"><RestartIcon /></n-icon>
<span style="margin-left: 8px;">重启后端服务</span>
</template>
LLM 配置已更改,需要重启后端服务才能生效。
<template #action>
<n-space>
<n-button @click="showRestartModal = false">取消</n-button>
<n-button type="warning" @click="restartBackend">
确认重启
</n-button>
</n-space>
</template>
</n-modal>
</n-card>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { NCard, NSpace, NSelect, NFormItem, NInput, NButton, NTag, NAlert, NDivider, NText, NA, NModal, NIcon, useMessage } from 'naive-ui'
import axios from 'axios'
import { h } from 'vue'
const RestartIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'currentColor' }, [
h('path', { d: 'M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z' })
])
const message = useMessage()
// 配置数据
const config = reactive({
provider: 'deepseek',
deepseek_api_key: '',
openai_api_key: '',
qwen_api_key: '',
ollama_host: 'http://127.0.0.1:11434'
})
const configStatus = reactive({
has_deepseek_key: false,
has_openai_key: false,
has_qwen_key: false
})
const saving = ref(false)
const checking = ref(false)
const showRestartModal = ref(false)
const needsRestart = ref(false)
// 提供商选项
const providerOptions = [
{ label: 'DeepSeek', value: 'deepseek', cloud: true },
{ label: 'OpenAI', value: 'openai', cloud: true },
{ label: 'Qwen (阿里云)', value: 'qwen', cloud: true },
{ label: 'Ollama (本地)', value: 'ollama', cloud: false }
]
// 提供商信息
const providerInfo = computed(() => {
const info = {
deepseek: { type: 'info', text: '💰 便宜好用,¥1/百万 tokens,推荐首选' },
openai: { type: 'info', text: '🚀 GPT-4 效果最好,约 ¥15/百万 tokens' },
qwen: { type: 'info', text: '🇨🇳 阿里云,国内稳定,¥0.5/百万 tokens' },
ollama: { type: 'success', text: '🏠 完全免费,数据隐私,但占用 CPU/内存' }
}
return info[config.provider] || null
})
// 加载配置
const loadConfig = async () => {
try {
const response = await axios.get('http://127.0.0.1:8000/ai/config')
Object.assign(config, {
provider: response.data.provider,
ollama_host: response.data.ollama_host
})
Object.assign(configStatus, {
has_deepseek_key: response.data.has_deepseek_key,
has_openai_key: response.data.has_openai_key,
has_qwen_key: response.data.has_qwen_key
})
} catch (error) {
console.error('加载配置失败:', error)
}
}
// 保存配置
const saveConfig = async () => {
saving.value = true
try {
const response = await axios.post('http://127.0.0.1:8000/ai/config', {
provider: config.provider,
deepseek_api_key: config.deepseek_api_key || undefined,
openai_api_key: config.openai_api_key || undefined,
qwen_api_key: config.qwen_api_key || undefined
})
message.success('配置已保存,需要重启后端才能生效')
needsRestart.value = true
await loadConfig()
} catch (error) {
message.error('保存失败: ' + (error.response?.data?.detail || error.message))
} finally {
saving.value = false
}
}
// 检查 Ollama 状态
const checkOllamaStatus = async () => {
checking.value = true
try {
const response = await axios.get(config.ollama_host.replace(':11434', ':8000') + '/ai/status', { timeout: 3000 })
if (response.data.status === 'ready') {
message.success(`Ollama 已就绪,${response.data.model_count} 个模型`)
} else {
message.warning('Ollama 未运行')
}
} catch (error) {
message.warning('无法连接到 Ollama')
} finally {
checking.value = false
}
}
// 重启后端
const restartBackend = async () => {
try {
await axios.post('http://127.0.0.1:8000/ai/stop')
message.info('后端已停止,请手动启动: ./start.sh')
showRestartModal.value = false
needsRestart.value = false
} catch (error) {
message.error('停止失败: ' + (error.response?.data?.detail || error.message))
}
}
const onProviderChange = () => {
needsRestart.value = true
}
onMounted(() => {
loadConfig()
})
</script>