feat: 添加 LLM 服务按需启动功能

新增功能:
- 后端新增 /ai/start API,支持启动本地 Ollama
- 后端改进 /ai/status API,返回提供商类型
- 前端看板页面添加启动/停止控制按钮
- 提供商感知 UI:云端 API 无需启动,本地 Ollama 可按需控制

用户体验:
- 云端模式:显示"云端服务已就绪",直接可分析
- 本地模式:显示"启动 Ollama"按钮,按需启动节省内存
- 分析完成后:仅本地模式自动停止 Ollama

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yuuuu 2026-02-21 00:01:37 +08:00
parent e6ff0d5e54
commit 319a422362
2 changed files with 215 additions and 19 deletions

View File

@ -833,13 +833,29 @@ async def get_feedback_stats(db: AsyncSession = Depends(get_db)):
@app.get("/ai/status")
async def get_ai_status():
"""
Check if Ollama AI service is running and available.
检查 AI 服务状态
返回当前提供商类型和运行状态
"""
import subprocess
import json
import os
provider = os.getenv("LLM_PROVIDER", "ollama").lower()
# 云端 API 始终就绪
if provider in ["deepseek", "openai", "qwen"]:
api_key = os.getenv(f"{provider.upper()}_API_KEY")
return {
"status": "ready",
"provider": provider,
"provider_type": "cloud",
"cloud_service": provider,
"api_key_configured": bool(api_key),
"service_name": provider.capitalize()
}
# 本地 Ollama 需要检查
try:
# Use curl to check Ollama status
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://127.0.0.1:11434/api/tags"],
capture_output=True,
@ -848,7 +864,6 @@ async def get_ai_status():
)
if result.stdout.strip() == "200":
# Try to get model list
result2 = subprocess.run(
["curl", "-s", "http://127.0.0.1:11434/api/tags"],
capture_output=True,
@ -861,19 +876,25 @@ async def get_ai_status():
model_names = [m.get("name", "unknown") for m in models]
return {
"status": "ready",
"provider": "ollama",
"provider_type": "local",
"ollama_running": True,
"available_models": model_names,
"model_count": len(model_names)
"model_count": len(model_names),
"service_name": "Ollama"
}
except Exception as e:
print(f"[AI STATUS] Error: {e}")
return {
"status": "not_ready",
"provider": "ollama",
"provider_type": "local",
"ollama_running": False,
"available_models": [],
"model_count": 0,
"error": "Ollama service not available. Start with: ollama serve"
"service_name": "Ollama",
"error": "Ollama 服务未运行,请点击启动"
}
@app.post("/ai/stop")
@ -960,6 +981,86 @@ async def stop_ai_service():
}
@app.post("/ai/start")
async def start_ai_service():
"""
启动本地 Ollama 服务
对于云端 API (DeepSeek/OpenAI/Qwen),返回无需启动
"""
import subprocess
import os
provider = os.getenv("LLM_PROVIDER", "ollama").lower()
# 云端 API 不需要启动
if provider in ["deepseek", "openai", "qwen"]:
return {
"status": "not_applicable",
"message": f"使用 {provider.upper()} 云端服务,无需启动本地服务",
"provider": provider,
"provider_type": "cloud"
}
# 检查 Ollama 是否已运行
try:
result = subprocess.run(
["lsof", "-ti", ":11434"],
capture_output=True,
timeout=2
)
if result.returncode == 0:
return {
"status": "already_running",
"message": "Ollama 已在运行",
"provider": "ollama",
"provider_type": "local"
}
except:
pass
# 启动 Ollama(尝试多种方式)
started = False
error_msgs = []
# 方法1: macOS Ollama.app
try:
subprocess.Popen(
["open", "-a", "Ollama"],
capture_output=True
)
started = True
except Exception as e:
error_msgs.append(f"Ollama.app: {e}")
# 方法2: 命令行 ollama serve
if not started:
try:
subprocess.Popen(
["ollama", "serve"],
capture_output=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
started = True
except Exception as e:
error_msgs.append(f"ollama serve: {e}")
if started:
return {
"status": "starting",
"message": "正在启动 Ollama,请稍候...",
"provider": "ollama",
"provider_type": "local"
}
else:
return {
"status": "error",
"message": f"启动失败: {'; '.join(error_msgs)}",
"provider": "ollama",
"provider_type": "local"
}
# LLM 配置管理
class LLMConfig(BaseModel):
provider: str = "deepseek" # ollama, deepseek, openai, qwen

View File

@ -54,11 +54,43 @@
<n-progress type="line" :percentage="analysisProgress" :indicator-placement="'inside'" processing />
<n-text depth="3" style="font-size: 11px;">{{ analysisStatus }}</n-text>
</div>
<n-space v-else :size="8">
<n-space v-else :size="8" align="center">
<!-- 服务状态显示 -->
<n-tag v-if="providerType === 'cloud'" type="success" size="small" :bordered="false">
🌐 {{ llmServiceName }} 云端服务
</n-tag>
<n-tag v-else type="default" size="small" :bordered="false">
🏠 本地 Ollama
</n-tag>
<!-- 启动分析按钮 -->
<n-button size="small" :type="ollamaReady ? 'success' : 'warning'" @click="startAnalysis">
<template #icon>{{ ollamaReady ? '🤖' : '⚠️' }}</template>
{{ ollamaReady ? '启动分析' : '启动分析 (未检测到 Ollama)' }}
{{ ollamaReady ? '启动分析' : (providerType === 'cloud' ? '启动分析' : '启动分析 (未就绪)') }}
</n-button>
<!-- 本地 Ollama 控制按钮 -->
<template v-if="providerType === 'local'">
<n-button
v-if="!ollamaReady"
size="small"
type="info"
@click="startOllama"
:loading="startingOllama"
>
启动 Ollama
</n-button>
<n-button
v-else
size="small"
type="error"
@click="stopOllama"
>
停止
</n-button>
</template>
<!-- 状态检查按钮 -->
<n-button size="small" @click="checkOllamaStatus" quaternary circle>
<template #icon>🔄</template>
</n-button>
@ -575,6 +607,11 @@ const analysisProgress = ref(0)
const analysisStatus = ref('')
const ollamaReady = ref(false)
// AI 服务配置
const providerType = ref('cloud') // 'cloud' | 'local'
const llmServiceName = ref('') // 'DeepSeek' | 'OpenAI' | 'Qwen' | 'Ollama'
const startingOllama = ref(false)
// Typewriter Effect State
const displayedAnalysis = reactive({
situation_analysis: '',
@ -1563,17 +1600,72 @@ const submitSignal = async () => {
}
}
// 检查 Ollama 状态
// 检查 AI 服务状态
const checkOllamaStatus = async () => {
try {
const response = await axios.get('http://127.0.0.1:8000/ai/status', { timeout: 3000 })
ollamaReady.value = response.data.status === 'ready'
const modelCount = response.data.model_count || 0
const models = response.data.available_models || []
message.info(ollamaReady.value ? `✅ Ollama 已就绪 (${modelCount} 个模型: ${models.join(', ')})` : '❌ Ollama 未运行')
const data = response.data
providerType.value = data.provider_type || 'cloud'
llmServiceName.value = data.service_name || ''
if (data.provider_type === 'cloud') {
// 云端 API
ollamaReady.value = true
message.success(`✅ 已连接 ${data.service_name} 云端服务`)
} else {
// 本地 Ollama
ollamaReady.value = data.status === 'ready'
const modelCount = data.model_count || 0
const models = data.available_models || []
if (ollamaReady.value) {
message.success(`✅ Ollama 已就绪 (${modelCount} 个模型)`)
} else {
message.warning('⚠️ Ollama 未运行,请点击启动')
}
}
} catch (error) {
ollamaReady.value = false
message.warning('⚠️ 无法检测 Ollama 状态,请确保后端服务运行中')
message.warning('⚠️ 无法检测 AI 服务状态')
}
}
// 启动本地 Ollama
const startOllama = async () => {
startingOllama.value = true
try {
const response = await axios.post('http://127.0.0.1:8000/ai/start')
if (response.data.status === 'already_running') {
message.info('Ollama 已在运行')
ollamaReady.value = true
} else if (response.data.status === 'starting') {
message.info('正在启动 Ollama,请稍候...')
// 等待几秒后检查状态
setTimeout(async () => {
await checkOllamaStatus()
}, 3000)
} else if (response.data.status === 'not_applicable') {
message.info(response.data.message)
} else {
message.error(response.data.message)
}
} catch (error) {
message.error('启动失败: ' + (error.response?.data?.detail || error.message))
} finally {
startingOllama.value = false
}
}
// 停止本地 Ollama
const stopOllama = async () => {
try {
await axios.post('http://127.0.0.1:8000/ai/stop')
ollamaReady.value = false
message.success('✅ Ollama 已停止,内存已释放')
} catch (error) {
message.error('停止失败')
}
}
@ -1614,12 +1706,15 @@ const startAnalysis = async () => {
// 刷新信号列表
await fetchSignals()
// 停止 Ollama 节省资源
// 仅本地 Ollama 需要停止节省资源
if (providerType.value === 'local') {
analysisStatus.value = '正在停止 Ollama...'
await axios.post('http://127.0.0.1:8000/ai/stop')
ollamaReady.value = false
analysisStatus.value = `完成!Ollama 已停止`
} else {
analysisStatus.value = `分析完成!${llmServiceName.value} 云端服务`
}
setTimeout(() => {
analyzing.value = false