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:
parent
e6ff0d5e54
commit
319a422362
|
|
@ -833,13 +833,29 @@ async def get_feedback_stats(db: AsyncSession = Depends(get_db)):
|
||||||
@app.get("/ai/status")
|
@app.get("/ai/status")
|
||||||
async def get_ai_status():
|
async def get_ai_status():
|
||||||
"""
|
"""
|
||||||
Check if Ollama AI service is running and available.
|
检查 AI 服务状态
|
||||||
|
返回当前提供商类型和运行状态
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
import json
|
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:
|
try:
|
||||||
# Use curl to check Ollama status
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://127.0.0.1:11434/api/tags"],
|
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://127.0.0.1:11434/api/tags"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
|
|
@ -848,7 +864,6 @@ async def get_ai_status():
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.stdout.strip() == "200":
|
if result.stdout.strip() == "200":
|
||||||
# Try to get model list
|
|
||||||
result2 = subprocess.run(
|
result2 = subprocess.run(
|
||||||
["curl", "-s", "http://127.0.0.1:11434/api/tags"],
|
["curl", "-s", "http://127.0.0.1:11434/api/tags"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
|
|
@ -861,19 +876,25 @@ async def get_ai_status():
|
||||||
model_names = [m.get("name", "unknown") for m in models]
|
model_names = [m.get("name", "unknown") for m in models]
|
||||||
return {
|
return {
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
|
"provider": "ollama",
|
||||||
|
"provider_type": "local",
|
||||||
"ollama_running": True,
|
"ollama_running": True,
|
||||||
"available_models": model_names,
|
"available_models": model_names,
|
||||||
"model_count": len(model_names)
|
"model_count": len(model_names),
|
||||||
|
"service_name": "Ollama"
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[AI STATUS] Error: {e}")
|
print(f"[AI STATUS] Error: {e}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "not_ready",
|
"status": "not_ready",
|
||||||
|
"provider": "ollama",
|
||||||
|
"provider_type": "local",
|
||||||
"ollama_running": False,
|
"ollama_running": False,
|
||||||
"available_models": [],
|
"available_models": [],
|
||||||
"model_count": 0,
|
"model_count": 0,
|
||||||
"error": "Ollama service not available. Start with: ollama serve"
|
"service_name": "Ollama",
|
||||||
|
"error": "Ollama 服务未运行,请点击启动"
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.post("/ai/stop")
|
@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 配置管理
|
# LLM 配置管理
|
||||||
class LLMConfig(BaseModel):
|
class LLMConfig(BaseModel):
|
||||||
provider: str = "deepseek" # ollama, deepseek, openai, qwen
|
provider: str = "deepseek" # ollama, deepseek, openai, qwen
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,43 @@
|
||||||
<n-progress type="line" :percentage="analysisProgress" :indicator-placement="'inside'" processing />
|
<n-progress type="line" :percentage="analysisProgress" :indicator-placement="'inside'" processing />
|
||||||
<n-text depth="3" style="font-size: 11px;">{{ analysisStatus }}</n-text>
|
<n-text depth="3" style="font-size: 11px;">{{ analysisStatus }}</n-text>
|
||||||
</div>
|
</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">
|
<n-button size="small" :type="ollamaReady ? 'success' : 'warning'" @click="startAnalysis">
|
||||||
<template #icon>{{ ollamaReady ? '🤖' : '⚠️' }}</template>
|
<template #icon>{{ ollamaReady ? '🤖' : '⚠️' }}</template>
|
||||||
{{ ollamaReady ? '启动分析' : '启动分析 (未检测到 Ollama)' }}
|
{{ ollamaReady ? '启动分析' : (providerType === 'cloud' ? '启动分析' : '启动分析 (未就绪)') }}
|
||||||
</n-button>
|
</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>
|
<n-button size="small" @click="checkOllamaStatus" quaternary circle>
|
||||||
<template #icon>🔄</template>
|
<template #icon>🔄</template>
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|
@ -575,6 +607,11 @@ const analysisProgress = ref(0)
|
||||||
const analysisStatus = ref('')
|
const analysisStatus = ref('')
|
||||||
const ollamaReady = ref(false)
|
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
|
// Typewriter Effect State
|
||||||
const displayedAnalysis = reactive({
|
const displayedAnalysis = reactive({
|
||||||
situation_analysis: '',
|
situation_analysis: '',
|
||||||
|
|
@ -1563,17 +1600,72 @@ const submitSignal = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 Ollama 状态
|
// 检查 AI 服务状态
|
||||||
const checkOllamaStatus = async () => {
|
const checkOllamaStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('http://127.0.0.1:8000/ai/status', { timeout: 3000 })
|
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 data = response.data
|
||||||
const models = response.data.available_models || []
|
providerType.value = data.provider_type || 'cloud'
|
||||||
message.info(ollamaReady.value ? `✅ Ollama 已就绪 (${modelCount} 个模型: ${models.join(', ')})` : '❌ Ollama 未运行')
|
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) {
|
} catch (error) {
|
||||||
ollamaReady.value = false
|
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()
|
await fetchSignals()
|
||||||
|
|
||||||
// 停止 Ollama 节省资源
|
// 仅本地 Ollama 需要停止节省资源
|
||||||
analysisStatus.value = '正在停止 Ollama...'
|
if (providerType.value === 'local') {
|
||||||
await axios.post('http://127.0.0.1:8000/ai/stop')
|
analysisStatus.value = '正在停止 Ollama...'
|
||||||
ollamaReady.value = false
|
await axios.post('http://127.0.0.1:8000/ai/stop')
|
||||||
|
ollamaReady.value = false
|
||||||
analysisStatus.value = `完成!Ollama 已停止`
|
analysisStatus.value = `完成!Ollama 已停止`
|
||||||
|
} else {
|
||||||
|
analysisStatus.value = `分析完成!${llmServiceName.value} 云端服务`
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
analyzing.value = false
|
analyzing.value = false
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue