feat: 展示 AI 分析过程
This commit is contained in:
+183
-41
@@ -207,34 +207,72 @@
|
||||
</el-table>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -253,6 +291,7 @@ const analyzing = ref(false)
|
||||
const diagnosis = ref(null)
|
||||
const aiAnalysis = ref('')
|
||||
const aiModel = ref('')
|
||||
const analysisProcess = ref([])
|
||||
const chatMessages = ref([])
|
||||
const chatInput = ref('')
|
||||
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 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) {
|
||||
return marked.parse(text || '', { breaks: true })
|
||||
@@ -357,17 +402,60 @@ async function runDiagnosis() {
|
||||
|
||||
async function runAnalysis() {
|
||||
analyzing.value = true
|
||||
aiAnalysis.value = ''
|
||||
aiModel.value = ''
|
||||
analysisProcess.value = []
|
||||
try {
|
||||
const resp = await fetch(`${API}/analyze`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const data = await resp.json()
|
||||
aiAnalysis.value = data.analysis || ''
|
||||
aiModel.value = data.model || ''
|
||||
if (!data.success) ElMessage.warning('AI 分析异常')
|
||||
const resp = await fetch(`${API}/analyze/stream`)
|
||||
const reader = resp.body.getReader()
|
||||
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 === '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) {
|
||||
aiAnalysis.value = 'AI 分析失败: ' + e.message
|
||||
ElMessage.error('AI 分析失败: ' + e.message)
|
||||
} finally {
|
||||
analyzing.value = false
|
||||
@@ -382,18 +470,51 @@ async function sendChat() {
|
||||
chatLoading.value = true
|
||||
await nextTick()
|
||||
scrollChat()
|
||||
|
||||
// 添加一个空的 assistant 消息用于流式填充
|
||||
const assistantIdx = chatMessages.value.length
|
||||
chatMessages.value.push({ role: 'assistant', content: '' })
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${API}/chat`, {
|
||||
const resp = await fetch(`${API}/chat/stream`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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()
|
||||
chatMessages.value.push({ role: 'assistant', content: data.reply || '无回复' })
|
||||
const reader = resp.body.getReader()
|
||||
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) {
|
||||
chatMessages.value.push({ role: 'assistant', content: '请求失败: ' + e.message })
|
||||
chatMessages.value[assistantIdx].content = '请求失败: ' + e.message
|
||||
} finally {
|
||||
chatLoading.value = false
|
||||
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; }
|
||||
|
||||
.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 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; }
|
||||
@@ -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 pre code { background: none; }
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user