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:
parent
7364c143fb
commit
689cf94d4b
|
|
@ -958,3 +958,82 @@ async def stop_ai_service():
|
|||
"status": "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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { darkTheme } from 'naive-ui'
|
|||
import SignalDashboard from './components/SignalDashboard.vue'
|
||||
import ExperimentBoard from './components/ExperimentBoard.vue'
|
||||
import ContextManager from './components/ContextManager.vue'
|
||||
import LLMSettings from './components/LLMSettings.vue'
|
||||
|
||||
const theme = ref(darkTheme)
|
||||
const activeKey = ref('signals')
|
||||
|
|
@ -31,7 +32,8 @@ const activeKey = ref('signals')
|
|||
const menuOptions: MenuOption[] = [
|
||||
{ label: '信号总线', key: 'signals' },
|
||||
{ label: '实验看板', key: 'experiments' },
|
||||
{ label: '认知核心', key: 'context' }
|
||||
{ label: '认知核心', key: 'context' },
|
||||
{ label: '⚙️ 设置', key: 'settings' }
|
||||
]
|
||||
|
||||
const currentComponent = computed(() => {
|
||||
|
|
@ -39,6 +41,7 @@ const currentComponent = computed(() => {
|
|||
case 'signals': return SignalDashboard
|
||||
case 'experiments': return ExperimentBoard
|
||||
case 'context': return ContextManager
|
||||
case 'settings': return LLMSettings
|
||||
default: return SignalDashboard
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue