feat: 展示 AI 分析过程

This commit is contained in:
cnbugs
2026-07-25 11:47:07 +08:00
parent 8109204843
commit 26ae846501
4 changed files with 389 additions and 95 deletions
+94 -50
View File
@@ -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}"
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
]
async for chunk in _stream_chat(messages):
yield chunk
async def chat_with_ai_stream(messages: list[dict], context: str = ""):
"""流式多轮对话 - yield 文本 chunk"""
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: try:
async with httpx.AsyncClient(timeout=120) as client: content = ""
resp = await client.post( async for chunk in analyze_with_ai_stream(report, question):
f"{AI_API_BASE}/chat/completions", content += chunk
headers={ return {"success": True, "analysis": content, "model": AI_MODEL}
"Authorization": f"Bearer {AI_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": AI_MODEL,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
"temperature": 0.3,
"max_tokens": 4096,
},
)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
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={ return {"success": True, "reply": content}
"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}
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
View File
@@ -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():
"""获取诊断报告文本""" """获取诊断报告文本"""
+183 -41
View File
@@ -207,34 +207,72 @@
</el-table> </el-table>
</el-card> </el-card>
<!-- AI 分析结果 -->
<el-card v-if="aiAnalysis" class="section-card ai-card" shadow="never">
<template #header>
<div class="card-header">
<span>🤖 AI 智能分析</span>
<el-tag size="small" type="info">{{ aiModel }}</el-tag>
</div>
</template>
<div class="ai-content" v-html="renderedAnalysis"></div>
</el-card>
<!-- AI 对话 -->
<el-card class="section-card" shadow="never">
<template #header><span>💬 追问 AI 助手</span></template>
<div class="chat-box" ref="chatBoxRef">
<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>
<div v-if="chatLoading" class="chat-msg assistant">
<div class="chat-bubble"><el-icon class="is-loading"><Loading /></el-icon> 思考中...</div>
</div>
</div>
<div class="chat-input">
<el-input v-model="chatInput" placeholder="输入问题,如:为什么 Pod 一直 CrashLoopBackOff" @keyup.enter="sendChat" :disabled="chatLoading" />
<el-button type="primary" @click="sendChat" :loading="chatLoading" :icon="Promotion">发送</el-button>
</div>
</el-card>
</template> </template>
<!-- AI 分析结果 (连接成功或失败都显示) -->
<el-card v-if="diagnosis && !diagnosing && (aiAnalysis || analyzing)" class="section-card ai-card" shadow="never">
<template #header>
<div class="card-header">
<span>🤖 AI 智能分析</span>
<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>
</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>
</el-card>
<!-- AI 对话 (连接成功或失败都显示) -->
<el-card v-if="diagnosis && !diagnosing" class="section-card" shadow="never">
<template #header><span>💬 追问 AI 助手</span></template>
<div class="chat-box" ref="chatBoxRef">
<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>
<div v-if="chatLoading && !chatMessages.length" class="chat-msg assistant">
<div class="chat-bubble"><el-icon class="is-loading"><Loading /></el-icon> 思考中...</div>
</div>
</div>
<div class="chat-input">
<el-input v-model="chatInput" placeholder="输入问题,如:为什么 Pod 一直 CrashLoopBackOff" @keyup.enter="sendChat" :disabled="chatLoading" />
<el-button type="primary" @click="sendChat" :loading="chatLoading" :icon="Promotion">发送</el-button>
</div>
</el-card>
</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 = ''
})
const data = await resp.json() while (true) {
aiAnalysis.value = data.analysis || '' const { done, value } = await reader.read()
aiModel.value = data.model || '' if (done) break
if (!data.success) ElMessage.warning('AI 分析异常')
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,
})
}
} else if (msg.type === 'chunk') {
aiAnalysis.value += msg.content
} else if (msg.type === 'error') {
aiAnalysis.value = msg.message
ElMessage.warning('AI 分析异常')
} else if (msg.type === 'done') {
ElMessage.success('AI 分析完成')
}
} catch (e) {
// ignore
}
}
}
} catch (e) { } 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) {
// ignore
}
}
}
} catch (e) { } catch (e) {
chatMessages.value.push({ role: 'assistant', content: '请求失败: ' + e.message }) 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>
+44
View File
@@ -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()