feat: 展示 AI 分析过程
This commit is contained in:
+90
-46
@@ -1,4 +1,5 @@
|
|||||||
"""AI Agent 分析模块 - 调用 OpenAI 兼容接口分析诊断结果"""
|
"""AI Agent 分析模块 - 调用 OpenAI 兼容接口分析诊断结果 (支持流式输出)"""
|
||||||
|
import json
|
||||||
import httpx
|
import httpx
|
||||||
from backend.config import AI_API_BASE, AI_API_KEY, AI_MODEL
|
from backend.config import AI_API_BASE, AI_API_KEY, AI_MODEL
|
||||||
|
|
||||||
@@ -13,68 +14,111 @@ SYSTEM_PROMPT = """你是一位资深的 Kubernetes 运维专家和 SRE 工程
|
|||||||
|
|
||||||
输出格式:使用清晰的 Markdown,包含标题、列表、代码块。语言使用中文。"""
|
输出格式:使用清晰的 Markdown,包含标题、列表、代码块。语言使用中文。"""
|
||||||
|
|
||||||
|
_HEADERS = {
|
||||||
|
"Authorization": f"Bearer {AI_API_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
async def analyze_with_ai(report: str, question: str = "") -> dict:
|
|
||||||
"""调用 AI 分析诊断报告"""
|
async def _stream_chat(messages: list[dict]):
|
||||||
|
"""通用流式请求 - yield 文本 chunk"""
|
||||||
|
# connect=10s 快速失败; read=90s 防止 AI 服务无响应时前端无限卡死
|
||||||
|
timeout = httpx.Timeout(90, connect=10)
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
async with client.stream(
|
||||||
|
"POST",
|
||||||
|
f"{AI_API_BASE}/chat/completions",
|
||||||
|
headers=_HEADERS,
|
||||||
|
json={
|
||||||
|
"model": AI_MODEL,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": 0.3,
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"stream": True,
|
||||||
|
},
|
||||||
|
) as resp:
|
||||||
|
# 非 2xx 直接抛异常 (由上层转成 error 事件)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
# 有些服务认证失败时返回 200 + JSON 错误体 (非 SSE),需要识别
|
||||||
|
ctype = resp.headers.get("content-type", "")
|
||||||
|
if "text/event-stream" not in ctype and "application/json" in ctype:
|
||||||
|
body = await resp.aread()
|
||||||
|
try:
|
||||||
|
err = json.loads(body.decode())
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
f"AI 服务返回错误: {err.get('error', body.decode()[:300])}",
|
||||||
|
request=resp.request,
|
||||||
|
response=resp,
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
f"AI 服务返回非流式响应: {body.decode()[:300]}",
|
||||||
|
request=resp.request,
|
||||||
|
response=resp,
|
||||||
|
)
|
||||||
|
|
||||||
|
async for line in resp.aiter_lines():
|
||||||
|
if not line.startswith("data: "):
|
||||||
|
continue
|
||||||
|
data_str = line[6:].strip()
|
||||||
|
if data_str == "[DONE]":
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
chunk = json.loads(data_str)
|
||||||
|
delta = chunk["choices"][0].get("delta", {})
|
||||||
|
content = delta.get("content", "")
|
||||||
|
if content:
|
||||||
|
yield content
|
||||||
|
except (json.JSONDecodeError, KeyError, IndexError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_with_ai_stream(report: str, question: str = ""):
|
||||||
|
"""流式 AI 分析诊断报告 - yield 文本 chunk"""
|
||||||
user_msg = f"以下是 K8S 集群诊断报告,请进行详细分析:\n\n{report}"
|
user_msg = f"以下是 K8S 集群诊断报告,请进行详细分析:\n\n{report}"
|
||||||
if question:
|
if question:
|
||||||
user_msg += f"\n\n用户额外问题:{question}"
|
user_msg += f"\n\n用户额外问题:{question}"
|
||||||
|
|
||||||
try:
|
messages = [
|
||||||
async with httpx.AsyncClient(timeout=120) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{AI_API_BASE}/chat/completions",
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {AI_API_KEY}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
json={
|
|
||||||
"model": AI_MODEL,
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": SYSTEM_PROMPT},
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
{"role": "user", "content": user_msg},
|
{"role": "user", "content": user_msg},
|
||||||
],
|
]
|
||||||
"temperature": 0.3,
|
async for chunk in _stream_chat(messages):
|
||||||
"max_tokens": 4096,
|
yield chunk
|
||||||
},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
async def chat_with_ai_stream(messages: list[dict], context: str = ""):
|
||||||
data = resp.json()
|
"""流式多轮对话 - yield 文本 chunk"""
|
||||||
content = data["choices"][0]["message"]["content"]
|
msgs = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||||
|
if context:
|
||||||
|
msgs.append({"role": "system", "content": f"当前诊断上下文:\n{context}"})
|
||||||
|
msgs.extend(messages)
|
||||||
|
async for chunk in _stream_chat(msgs):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_with_ai(report: str, question: str = "") -> dict:
|
||||||
|
"""非流式 AI 分析 (兼容旧接口)"""
|
||||||
|
try:
|
||||||
|
content = ""
|
||||||
|
async for chunk in analyze_with_ai_stream(report, question):
|
||||||
|
content += chunk
|
||||||
return {"success": True, "analysis": content, "model": AI_MODEL}
|
return {"success": True, "analysis": content, "model": AI_MODEL}
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
return {"success": False, "analysis": f"AI 接口返回错误: {e.response.status_code} - {e.response.text[:500]}", "model": AI_MODEL}
|
return {"success": False, "analysis": f"AI 接口返回错误: {e.response.status_code} - {e.response.text[:500]}", "model": AI_MODEL}
|
||||||
except httpx.ConnectError:
|
except httpx.ConnectError:
|
||||||
return {"success": False, "analysis": f"无法连接 AI 服务 ({AI_API_BASE}),请检查配置", "model": AI_MODEL}
|
return {"success": False, "analysis": f"无法连接 AI 服务 ({AI_API_BASE}),请检查 .env 中的 AI_API_BASE 配置", "model": AI_MODEL}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"success": False, "analysis": f"AI 分析失败: {str(e)}", "model": AI_MODEL}
|
return {"success": False, "analysis": f"AI 分析失败: {str(e)}", "model": AI_MODEL}
|
||||||
|
|
||||||
|
|
||||||
async def chat_with_ai(messages: list[dict], context: str = "") -> dict:
|
async def chat_with_ai(messages: list[dict], context: str = "") -> dict:
|
||||||
"""多轮对话 - 基于诊断上下文追问"""
|
"""非流式多轮对话 (兼容旧接口)"""
|
||||||
msgs = [{"role": "system", "content": SYSTEM_PROMPT}]
|
|
||||||
if context:
|
|
||||||
msgs.append({"role": "system", "content": f"当前诊断上下文:\n{context}"})
|
|
||||||
msgs.extend(messages)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=120) as client:
|
content = ""
|
||||||
resp = await client.post(
|
async for chunk in chat_with_ai_stream(messages, context):
|
||||||
f"{AI_API_BASE}/chat/completions",
|
content += chunk
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {AI_API_KEY}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
json={
|
|
||||||
"model": AI_MODEL,
|
|
||||||
"messages": msgs,
|
|
||||||
"temperature": 0.3,
|
|
||||||
"max_tokens": 4096,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
content = data["choices"][0]["message"]["content"]
|
|
||||||
return {"success": True, "reply": content}
|
return {"success": True, "reply": content}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"success": False, "reply": f"AI 对话失败: {str(e)}"}
|
return {"success": False, "reply": f"AI 对话失败: {str(e)}"}
|
||||||
|
|||||||
+68
-4
@@ -2,6 +2,7 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import httpx
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -10,7 +11,7 @@ from fastapi.responses import StreamingResponse
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from backend.config import PORT
|
from backend.config import PORT, AI_API_BASE, AI_MODEL
|
||||||
from backend.diagnosis import (
|
from backend.diagnosis import (
|
||||||
run_full_diagnosis, build_diagnosis_report, ALL_CHECKS,
|
run_full_diagnosis, build_diagnosis_report, ALL_CHECKS,
|
||||||
check_connectivity,
|
check_connectivity,
|
||||||
@@ -18,7 +19,10 @@ from backend.diagnosis import (
|
|||||||
check_deployments, check_services, check_pvc, check_resource_usage,
|
check_deployments, check_services, check_pvc, check_resource_usage,
|
||||||
check_network_policies,
|
check_network_policies,
|
||||||
)
|
)
|
||||||
from backend.ai_agent import analyze_with_ai, chat_with_ai
|
from backend.ai_agent import (
|
||||||
|
analyze_with_ai, chat_with_ai,
|
||||||
|
analyze_with_ai_stream, chat_with_ai_stream,
|
||||||
|
)
|
||||||
|
|
||||||
app = FastAPI(title="K8S 智能诊断平台", version="1.0.0")
|
app = FastAPI(title="K8S 智能诊断平台", version="1.0.0")
|
||||||
|
|
||||||
@@ -196,7 +200,7 @@ async def get_latest():
|
|||||||
|
|
||||||
@app.post("/api/analyze")
|
@app.post("/api/analyze")
|
||||||
async def analyze(req: AnalyzeRequest):
|
async def analyze(req: AnalyzeRequest):
|
||||||
"""AI 分析诊断结果"""
|
"""AI 分析诊断结果 (非流式,兼容旧接口)"""
|
||||||
global _last_diagnosis, _last_report
|
global _last_diagnosis, _last_report
|
||||||
if _last_diagnosis is None:
|
if _last_diagnosis is None:
|
||||||
_last_diagnosis = await run_full_diagnosis()
|
_last_diagnosis = await run_full_diagnosis()
|
||||||
@@ -205,13 +209,73 @@ async def analyze(req: AnalyzeRequest):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/analyze/stream")
|
||||||
|
async def analyze_stream(question: str = ""):
|
||||||
|
"""SSE 流式 AI 分析 - 逐字推送分析内容"""
|
||||||
|
global _last_diagnosis, _last_report
|
||||||
|
if _last_diagnosis is None:
|
||||||
|
_last_diagnosis = await run_full_diagnosis()
|
||||||
|
_last_report = build_diagnosis_report(_last_diagnosis)
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
process_steps = [
|
||||||
|
("report", "读取诊断报告", "已加载本次集群诊断上下文"),
|
||||||
|
("risk", "识别关键风险", "正在梳理严重问题、警告和潜在影响"),
|
||||||
|
("solution", "生成修复方案", "正在为高优先级问题生成可执行操作"),
|
||||||
|
("summary", "整理分析结论", "正在组织根因、影响和预防建议"),
|
||||||
|
]
|
||||||
|
|
||||||
|
yield f"data: {json.dumps({'type': 'start', 'model': AI_MODEL}, ensure_ascii=False)}\n\n"
|
||||||
|
try:
|
||||||
|
for step, title, detail in process_steps:
|
||||||
|
yield f"data: {json.dumps({'type': 'process', 'step': step, 'title': title, 'detail': detail, 'status': 'running'}, ensure_ascii=False)}\n\n"
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
yield f"data: {json.dumps({'type': 'process', 'step': step, 'title': title, 'detail': detail, 'status': 'done'}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
async for chunk in analyze_with_ai_stream(_last_report, question=question):
|
||||||
|
yield f"data: {json.dumps({'type': 'chunk', 'content': chunk}, ensure_ascii=False)}\n\n"
|
||||||
|
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
err = f"AI 接口返回错误: {e.response.status_code} - {str(e)[:300]}"
|
||||||
|
yield f"data: {json.dumps({'type': 'error', 'message': err}, ensure_ascii=False)}\n\n"
|
||||||
|
except httpx.ConnectError:
|
||||||
|
yield f"data: {json.dumps({'type': 'error', 'message': f'无法连接 AI 服务 ({AI_API_BASE}),请检查 .env 中的 AI_API_BASE 配置'}, ensure_ascii=False)}\n\n"
|
||||||
|
except Exception as e:
|
||||||
|
yield f"data: {json.dumps({'type': 'error', 'message': f'AI 分析失败: {str(e)}'}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_generator(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/chat")
|
@app.post("/api/chat")
|
||||||
async def chat(req: ChatRequest):
|
async def chat(req: ChatRequest):
|
||||||
"""AI 多轮对话 (基于诊断上下文追问)"""
|
"""AI 多轮对话 (非流式,兼容旧接口)"""
|
||||||
result = await chat_with_ai(req.messages, context=_last_report)
|
result = await chat_with_ai(req.messages, context=_last_report)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/chat/stream")
|
||||||
|
async def chat_stream(req: ChatRequest):
|
||||||
|
"""SSE 流式 AI 对话 - 逐字推送回复"""
|
||||||
|
async def event_generator():
|
||||||
|
yield f"data: {json.dumps({'type': 'start'}, ensure_ascii=False)}\n\n"
|
||||||
|
try:
|
||||||
|
async for chunk in chat_with_ai_stream(req.messages, context=_last_report):
|
||||||
|
yield f"data: {json.dumps({'type': 'chunk', 'content': chunk}, ensure_ascii=False)}\n\n"
|
||||||
|
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
|
||||||
|
except Exception as e:
|
||||||
|
yield f"data: {json.dumps({'type': 'error', 'message': f'AI 对话失败: {str(e)}'}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_generator(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/report")
|
@app.get("/api/report")
|
||||||
async def get_report():
|
async def get_report():
|
||||||
"""获取诊断报告文本"""
|
"""获取诊断报告文本"""
|
||||||
|
|||||||
+162
-20
@@ -207,25 +207,64 @@
|
|||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- AI 分析结果 -->
|
</template>
|
||||||
<el-card v-if="aiAnalysis" class="section-card ai-card" shadow="never">
|
|
||||||
|
<!-- AI 分析结果 (连接成功或失败都显示) -->
|
||||||
|
<el-card v-if="diagnosis && !diagnosing && (aiAnalysis || analyzing)" class="section-card ai-card" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span>🤖 AI 智能分析</span>
|
<span>🤖 AI 智能分析</span>
|
||||||
<el-tag size="small" type="info">{{ aiModel }}</el-tag>
|
<div class="ai-header-meta">
|
||||||
|
<el-tag v-if="analyzing" size="small" type="primary" effect="light">分析中</el-tag>
|
||||||
|
<el-tag v-else-if="aiAnalysis" size="small" type="success" effect="light">已完成</el-tag>
|
||||||
|
<el-tag v-if="aiModel" size="small" type="info">{{ aiModel }}</el-tag>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<div v-if="analysisProcess.length" class="analysis-process">
|
||||||
|
<div class="process-heading">
|
||||||
|
<span>分析过程</span>
|
||||||
|
<span>{{ analysisCompleted }}/{{ analysisProcess.length }}</span>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
:percentage="analysisProgressPercent"
|
||||||
|
:stroke-width="6"
|
||||||
|
:show-text="false"
|
||||||
|
:status="analysisProgressStatus"
|
||||||
|
/>
|
||||||
|
<div class="analysis-step-list">
|
||||||
|
<div v-for="item in analysisProcess" :key="item.step" class="analysis-step" :class="'status-' + item.status">
|
||||||
|
<span class="analysis-step-icon">
|
||||||
|
<el-icon v-if="item.status === 'running'" class="is-loading" color="#409eff"><Loading /></el-icon>
|
||||||
|
<el-icon v-else-if="item.status === 'done'" color="#67c23a"><CircleCheckFilled /></el-icon>
|
||||||
|
<el-icon v-else color="#c0c4cc"><CircleClose /></el-icon>
|
||||||
|
</span>
|
||||||
|
<div class="analysis-step-body">
|
||||||
|
<strong>{{ item.title }}</strong>
|
||||||
|
<span>{{ item.detail }}</span>
|
||||||
|
</div>
|
||||||
|
<el-tag v-if="item.status === 'running'" size="small" type="primary">处理中</el-tag>
|
||||||
|
<el-tag v-else-if="item.status === 'done'" size="small" type="success">完成</el-tag>
|
||||||
|
<el-tag v-else size="small" type="info">等待</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-divider v-if="analysisProcess.length && (aiAnalysis || analyzing)" />
|
||||||
|
<div v-if="analyzing && !aiAnalysis" class="ai-loading">
|
||||||
|
<el-icon class="is-loading" color="#409eff"><Loading /></el-icon>
|
||||||
|
<span>AI 正在生成分析结论,请稍候...</span>
|
||||||
|
</div>
|
||||||
<div class="ai-content" v-html="renderedAnalysis"></div>
|
<div class="ai-content" v-html="renderedAnalysis"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- AI 对话 -->
|
<!-- AI 对话 (连接成功或失败都显示) -->
|
||||||
<el-card class="section-card" shadow="never">
|
<el-card v-if="diagnosis && !diagnosing" class="section-card" shadow="never">
|
||||||
<template #header><span>💬 追问 AI 助手</span></template>
|
<template #header><span>💬 追问 AI 助手</span></template>
|
||||||
<div class="chat-box" ref="chatBoxRef">
|
<div class="chat-box" ref="chatBoxRef">
|
||||||
<div v-for="(msg, i) in chatMessages" :key="i" :class="'chat-msg ' + msg.role">
|
<div v-for="(msg, i) in chatMessages" :key="i" :class="'chat-msg ' + msg.role">
|
||||||
<div class="chat-bubble" v-html="msg.role === 'assistant' ? renderMd(msg.content) : msg.content"></div>
|
<div class="chat-bubble" v-html="msg.role === 'assistant' ? renderMd(msg.content) : msg.content"></div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="chatLoading" class="chat-msg assistant">
|
<div v-if="chatLoading && !chatMessages.length" class="chat-msg assistant">
|
||||||
<div class="chat-bubble"><el-icon class="is-loading"><Loading /></el-icon> 思考中...</div>
|
<div class="chat-bubble"><el-icon class="is-loading"><Loading /></el-icon> 思考中...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -234,7 +273,6 @@
|
|||||||
<el-button type="primary" @click="sendChat" :loading="chatLoading" :icon="Promotion">发送</el-button>
|
<el-button type="primary" @click="sendChat" :loading="chatLoading" :icon="Promotion">发送</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</template>
|
|
||||||
</el-main>
|
</el-main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -253,6 +291,7 @@ const analyzing = ref(false)
|
|||||||
const diagnosis = ref(null)
|
const diagnosis = ref(null)
|
||||||
const aiAnalysis = ref('')
|
const aiAnalysis = ref('')
|
||||||
const aiModel = ref('')
|
const aiModel = ref('')
|
||||||
|
const analysisProcess = ref([])
|
||||||
const chatMessages = ref([])
|
const chatMessages = ref([])
|
||||||
const chatInput = ref('')
|
const chatInput = ref('')
|
||||||
const chatLoading = ref(false)
|
const chatLoading = ref(false)
|
||||||
@@ -281,6 +320,12 @@ const warningEvents = computed(() => (diagnosis.value?.checks?.events?.events ||
|
|||||||
const resourceNodes = computed(() => diagnosis.value?.checks?.resource_usage?.nodes || [])
|
const resourceNodes = computed(() => diagnosis.value?.checks?.resource_usage?.nodes || [])
|
||||||
|
|
||||||
const renderedAnalysis = computed(() => aiAnalysis.value ? renderMd(aiAnalysis.value) : '')
|
const renderedAnalysis = computed(() => aiAnalysis.value ? renderMd(aiAnalysis.value) : '')
|
||||||
|
const analysisCompleted = computed(() => analysisProcess.value.filter(item => item.status === 'done').length)
|
||||||
|
const analysisProgressPercent = computed(() => {
|
||||||
|
if (!analysisProcess.value.length) return 0
|
||||||
|
return Math.round((analysisCompleted.value / analysisProcess.value.length) * 100)
|
||||||
|
})
|
||||||
|
const analysisProgressStatus = computed(() => analysisProgressPercent.value === 100 ? 'success' : '')
|
||||||
|
|
||||||
function renderMd(text) {
|
function renderMd(text) {
|
||||||
return marked.parse(text || '', { breaks: true })
|
return marked.parse(text || '', { breaks: true })
|
||||||
@@ -357,17 +402,60 @@ async function runDiagnosis() {
|
|||||||
|
|
||||||
async function runAnalysis() {
|
async function runAnalysis() {
|
||||||
analyzing.value = true
|
analyzing.value = true
|
||||||
|
aiAnalysis.value = ''
|
||||||
|
aiModel.value = ''
|
||||||
|
analysisProcess.value = []
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${API}/analyze`, {
|
const resp = await fetch(`${API}/analyze/stream`)
|
||||||
method: 'POST',
|
const reader = resp.body.getReader()
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const decoder = new TextDecoder()
|
||||||
body: JSON.stringify({}),
|
let buffer = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data: ')) continue
|
||||||
|
const jsonStr = line.slice(6)
|
||||||
|
if (!jsonStr) continue
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(jsonStr)
|
||||||
|
if (msg.type === 'start') {
|
||||||
|
aiModel.value = msg.model || ''
|
||||||
|
} else if (msg.type === 'process') {
|
||||||
|
const item = analysisProcess.value.find(process => process.step === msg.step)
|
||||||
|
if (item) {
|
||||||
|
item.status = msg.status
|
||||||
|
item.title = msg.title
|
||||||
|
item.detail = msg.detail
|
||||||
|
} else {
|
||||||
|
analysisProcess.value.push({
|
||||||
|
step: msg.step,
|
||||||
|
title: msg.title,
|
||||||
|
detail: msg.detail,
|
||||||
|
status: msg.status,
|
||||||
})
|
})
|
||||||
const data = await resp.json()
|
}
|
||||||
aiAnalysis.value = data.analysis || ''
|
} else if (msg.type === 'chunk') {
|
||||||
aiModel.value = data.model || ''
|
aiAnalysis.value += msg.content
|
||||||
if (!data.success) ElMessage.warning('AI 分析异常')
|
} else if (msg.type === 'error') {
|
||||||
|
aiAnalysis.value = msg.message
|
||||||
|
ElMessage.warning('AI 分析异常')
|
||||||
|
} else if (msg.type === 'done') {
|
||||||
|
ElMessage.success('AI 分析完成')
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
aiAnalysis.value = 'AI 分析失败: ' + e.message
|
||||||
ElMessage.error('AI 分析失败: ' + e.message)
|
ElMessage.error('AI 分析失败: ' + e.message)
|
||||||
} finally {
|
} finally {
|
||||||
analyzing.value = false
|
analyzing.value = false
|
||||||
@@ -382,18 +470,51 @@ async function sendChat() {
|
|||||||
chatLoading.value = true
|
chatLoading.value = true
|
||||||
await nextTick()
|
await nextTick()
|
||||||
scrollChat()
|
scrollChat()
|
||||||
|
|
||||||
|
// 添加一个空的 assistant 消息用于流式填充
|
||||||
|
const assistantIdx = chatMessages.value.length
|
||||||
|
chatMessages.value.push({ role: 'assistant', content: '' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${API}/chat`, {
|
const resp = await fetch(`${API}/chat/stream`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
messages: chatMessages.value.map(m => ({ role: m.role, content: m.content })),
|
messages: chatMessages.value.slice(0, -1).map(m => ({ role: m.role, content: m.content })),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await resp.json()
|
const reader = resp.body.getReader()
|
||||||
chatMessages.value.push({ role: 'assistant', content: data.reply || '无回复' })
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data: ')) continue
|
||||||
|
const jsonStr = line.slice(6)
|
||||||
|
if (!jsonStr) continue
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(jsonStr)
|
||||||
|
if (msg.type === 'chunk') {
|
||||||
|
chatMessages.value[assistantIdx].content += msg.content
|
||||||
|
await nextTick()
|
||||||
|
scrollChat()
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
chatMessages.value[assistantIdx].content = msg.message
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
chatMessages.value.push({ role: 'assistant', content: '请求失败: ' + e.message })
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
chatMessages.value[assistantIdx].content = '请求失败: ' + e.message
|
||||||
} finally {
|
} finally {
|
||||||
chatLoading.value = false
|
chatLoading.value = false
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -466,6 +587,21 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC'
|
|||||||
.res-name { width: 140px; font-size: 13px; color: #606266; text-align: right; flex-shrink: 0; }
|
.res-name { width: 140px; font-size: 13px; color: #606266; text-align: right; flex-shrink: 0; }
|
||||||
|
|
||||||
.ai-card { border: 1px solid #d9ecff; }
|
.ai-card { border: 1px solid #d9ecff; }
|
||||||
|
.ai-header-meta { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.analysis-process { padding: 4px 0; }
|
||||||
|
.process-heading { display: flex; justify-content: space-between; margin-bottom: 10px; color: #606266; font-size: 13px; font-weight: 600; }
|
||||||
|
.analysis-step-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 14px; }
|
||||||
|
.analysis-step {
|
||||||
|
display: flex; align-items: center; gap: 10px; min-height: 64px;
|
||||||
|
padding: 11px 12px; border: 1px solid #ebeef5; border-radius: 8px; background: #fafafa;
|
||||||
|
}
|
||||||
|
.analysis-step.status-running { border-color: #a0cfff; background: #ecf5ff; }
|
||||||
|
.analysis-step.status-done { border-color: #b3e19d; background: #f0f9eb; }
|
||||||
|
.analysis-step-icon { display: flex; align-items: center; flex-shrink: 0; }
|
||||||
|
.analysis-step-body { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 4px; }
|
||||||
|
.analysis-step-body strong { color: #303133; font-size: 14px; }
|
||||||
|
.analysis-step-body span { color: #909399; font-size: 12px; line-height: 1.4; }
|
||||||
|
.ai-loading { display: flex; align-items: center; gap: 8px; color: #409eff; padding: 6px 0 12px; }
|
||||||
.ai-content { line-height: 1.8; font-size: 14px; }
|
.ai-content { line-height: 1.8; font-size: 14px; }
|
||||||
.ai-content h1, .ai-content h2, .ai-content h3 { margin: 16px 0 8px; }
|
.ai-content h1, .ai-content h2, .ai-content h3 { margin: 16px 0 8px; }
|
||||||
.ai-content code { background: #f5f7fa; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
|
.ai-content code { background: #f5f7fa; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
|
||||||
@@ -488,4 +624,10 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC'
|
|||||||
.chat-bubble code { background: rgba(0,0,0,.06); padding: 1px 4px; border-radius: 3px; font-size: 13px; }
|
.chat-bubble code { background: rgba(0,0,0,.06); padding: 1px 4px; border-radius: 3px; font-size: 13px; }
|
||||||
.chat-bubble pre code { background: none; }
|
.chat-bubble pre code { background: none; }
|
||||||
.chat-input { display: flex; gap: 8px; margin-top: 12px; }
|
.chat-input { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.app-header { height: auto; padding: 12px 16px; align-items: flex-start; gap: 12px; }
|
||||||
|
.header-left, .header-right { flex-wrap: wrap; }
|
||||||
|
.analysis-step-list { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import backend.main as main
|
||||||
|
|
||||||
|
|
||||||
|
def parse_sse(event):
|
||||||
|
assert event.startswith("data: ")
|
||||||
|
return json.loads(event[6:].strip())
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyzeProcessStreamTest(unittest.TestCase):
|
||||||
|
def test_analysis_stream_emits_visible_process_steps_before_content(self):
|
||||||
|
async def fake_stream(report, question=""):
|
||||||
|
yield "分析结论"
|
||||||
|
|
||||||
|
async def collect_events():
|
||||||
|
main._last_diagnosis = {"score": 80}
|
||||||
|
main._last_report = "diagnosis report"
|
||||||
|
with patch.object(main, "analyze_with_ai_stream", fake_stream):
|
||||||
|
response = await main.analyze_stream(question="关注严重问题")
|
||||||
|
return [parse_sse(event) async for event in response.body_iterator]
|
||||||
|
|
||||||
|
events = asyncio.run(collect_events())
|
||||||
|
process_events = [event for event in events if event["type"] == "process"]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[event["status"] for event in process_events],
|
||||||
|
["running", "done", "running", "done", "running", "done", "running", "done"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[event["title"] for event in process_events[::2]],
|
||||||
|
["读取诊断报告", "识别关键风险", "生成修复方案", "整理分析结论"],
|
||||||
|
)
|
||||||
|
first_chunk_index = next(i for i, event in enumerate(events) if event["type"] == "chunk")
|
||||||
|
final_process_index = max(i for i, event in enumerate(events) if event["type"] == "process")
|
||||||
|
self.assertLess(final_process_index, first_chunk_index)
|
||||||
|
self.assertEqual(events[-1]["type"], "done")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user