32bea0d4ea
扩展 Agent 命令工具,除 kubectl 外新增节点宿主机系统诊断命令支持:
- agent_tools.py: 新增 shell 命令白名单分类器 (classify_shell_command)
- 只读白名单: systemctl status/is-active、journalctl、crictl ps/logs、
docker ps/logs、df、free、ss、ip addr、ps、mount、lsmod、dmesg、
ping、nslookup、cat (限 /proc /sys /etc/kubernetes 等安全路径) 等 30+ 命令
- 写白名单: systemctl restart/stop/start/reload 等需人工审批
- 统一分发器 classify_agent_command 按命令前缀路由
- 统一执行入口 execute_command 走 subprocess_exec (无 shell 注入风险)
- ai_agent.py: 更新 AGENT_SYSTEM_PROMPT 告知 AI 两类可用工具及安全规则
- 三个调用点 (初始探测/分类/执行) 切换到统一接口
- main.py: 审批执行处改用 execute_command
- tests: 新增 19 个用例覆盖 shell 命令分类、混合执行、白名单拒绝
- 全部 44 个测试通过
- 前端/README: 文案与文档补充 shell 命令支持说明
安全策略不变: 禁止 shell 操作符/管道/重定向/多命令拼接,
非白名单命令 (rm/vi/apt 等) 直接拒绝。
961 lines
43 KiB
Vue
961 lines
43 KiB
Vue
<template>
|
||
<div class="app-container">
|
||
<!-- 顶部导航 -->
|
||
<el-header class="app-header">
|
||
<div class="header-left">
|
||
<el-icon :size="28" color="#409eff"><Monitor /></el-icon>
|
||
<h1>K8S 智能诊断平台</h1>
|
||
<el-tag v-if="diagnosis" :type="statusTagType" size="large" effect="dark">
|
||
健康评分: {{ diagnosis.score }}/100
|
||
</el-tag>
|
||
</div>
|
||
<div class="header-right">
|
||
<el-input v-model="namespace" placeholder="命名空间 (留空=全部)" clearable style="width: 200px" />
|
||
<el-button type="primary" :loading="diagnosing" @click="runDiagnosis" :icon="Refresh" :disabled="diagnosing">
|
||
{{ diagnosing ? '诊断中...' : '一键诊断' }}
|
||
</el-button>
|
||
<el-button type="success" :loading="analyzing" @click="runAnalysis" :icon="MagicStick" :disabled="!diagnosis || diagnosing || agentRunning">
|
||
{{ analyzing ? 'AI 分析中...' : 'AI 分析' }}
|
||
</el-button>
|
||
<el-radio-group v-model="agentExecutionMode" :disabled="agentRunning" class="agent-mode-switch">
|
||
<el-radio-button value="manual">手动执行命令</el-radio-button>
|
||
<el-radio-button value="ai">AI 执行命令</el-radio-button>
|
||
</el-radio-group>
|
||
<el-button @click="openHistory" :disabled="diagnosing || analyzing || agentRunning">历史记录</el-button>
|
||
<el-button type="warning" :loading="agentRunning" @click="runAgentDiagnosis" :disabled="!diagnosis || diagnosing || analyzing">
|
||
{{ agentRunning ? 'Agent 诊断中...' : '自主诊断' }}
|
||
</el-button>
|
||
</div>
|
||
</el-header>
|
||
|
||
<el-main class="app-main">
|
||
<!-- 未诊断时的欢迎页 -->
|
||
<div v-if="!diagnosis && !diagnosing" class="welcome">
|
||
<el-empty description="点击「一键诊断」开始集群健康检查">
|
||
<el-button type="primary" size="large" @click="runDiagnosis" :icon="Refresh">开始诊断</el-button>
|
||
</el-empty>
|
||
</div>
|
||
|
||
<!-- 诊断中 - 实时进度 -->
|
||
<div v-if="diagnosing" class="progress-box">
|
||
<el-card shadow="never" class="progress-card">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>🔍 正在执行集群诊断</span>
|
||
<el-tag size="small" type="info">{{ progressStep }}/{{ progressTotal }}</el-tag>
|
||
</div>
|
||
</template>
|
||
<el-progress :percentage="progressPercent" :stroke-width="10" :color="'#409eff'" style="margin-bottom: 20px" />
|
||
<div class="progress-steps">
|
||
<div v-for="item in progressList" :key="item.name" class="progress-item" :class="'status-' + item.status">
|
||
<span class="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>
|
||
<span class="step-title">{{ item.title }}</span>
|
||
<span v-if="item.status === 'done'" class="step-result">
|
||
<el-tag v-if="item.issues > 0" size="small" :type="item.issues > 0 ? 'warning' : 'success'">
|
||
{{ item.issues }} 个问题
|
||
</el-tag>
|
||
<el-tag v-else size="small" type="success">正常</el-tag>
|
||
</span>
|
||
<span v-if="item.status === 'running'" class="step-running">检查中...</span>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
|
||
<!-- 集群连接失败 -->
|
||
<div v-if="diagnosis && !diagnosing && diagnosis.connected === false" class="conn-error-box">
|
||
<el-card shadow="never" class="conn-error-card">
|
||
<div class="conn-error-icon">
|
||
<el-icon :size="64" color="#f56c6c"><CircleCloseFilled /></el-icon>
|
||
</div>
|
||
<h2>无法连接到 Kubernetes 集群</h2>
|
||
<div class="conn-error-detail">
|
||
<el-alert type="error" :closable="false" show-icon>
|
||
<template #title>错误信息</template>
|
||
<pre class="conn-error-pre">{{ diagnosis.connectivity_error }}</pre>
|
||
</el-alert>
|
||
<el-alert v-if="diagnosis.connectivity_suggestion" type="warning" :closable="false" show-icon style="margin-top: 12px">
|
||
<template #title>排查建议</template>
|
||
<div>{{ diagnosis.connectivity_suggestion }}</div>
|
||
</el-alert>
|
||
</div>
|
||
<div class="conn-error-actions">
|
||
<el-button type="primary" @click="runDiagnosis" :icon="Refresh">重新诊断</el-button>
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
|
||
<!-- 诊断结果 -->
|
||
<template v-if="diagnosis && !diagnosing && diagnosis.connected !== false">
|
||
<!-- 概览卡片 -->
|
||
<el-row :gutter="16" class="summary-row">
|
||
<el-col :span="4">
|
||
<el-card shadow="hover" class="score-card" :class="'score-' + diagnosis.status">
|
||
<div class="score-number">{{ diagnosis.score }}</div>
|
||
<div class="score-label">健康评分</div>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="4">
|
||
<el-card shadow="hover">
|
||
<el-statistic title="严重问题" :value="diagnosis.critical_count">
|
||
<template #suffix><el-icon color="#f56c6c"><WarningFilled /></el-icon></template>
|
||
</el-statistic>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="4">
|
||
<el-card shadow="hover">
|
||
<el-statistic title="警告" :value="diagnosis.warning_count">
|
||
<template #suffix><el-icon color="#e6a23c"><Warning /></el-icon></template>
|
||
</el-statistic>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="4">
|
||
<el-card shadow="hover">
|
||
<el-statistic title="Pod 总数" :value="podSummary.total" />
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="4">
|
||
<el-card shadow="hover">
|
||
<el-statistic title="Node 就绪" :value="nodeSummary.ready + '/' + nodeSummary.total" />
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="4">
|
||
<el-card shadow="hover">
|
||
<el-statistic title="诊断耗时" :value="diagnosis.elapsed_seconds + 's'" />
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<!-- 问题列表 -->
|
||
<el-card v-if="diagnosis.issues.length" class="section-card" shadow="never">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>🔍 发现的问题 ({{ diagnosis.issues.length }})</span>
|
||
</div>
|
||
</template>
|
||
<el-table :data="diagnosis.issues" stripe size="small" max-height="300">
|
||
<el-table-column label="级别" width="80" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.level === 'critical' ? 'danger' : 'warning'" size="small" effect="dark">
|
||
{{ row.level === 'critical' ? '严重' : '警告' }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="check" label="检查项" width="160" />
|
||
<el-table-column prop="resource" label="资源" width="260" />
|
||
<el-table-column prop="message" label="问题描述" />
|
||
</el-table>
|
||
</el-card>
|
||
|
||
<!-- 各检查项详情 -->
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-card class="section-card" shadow="never">
|
||
<template #header><span>📦 Pod 状态</span></template>
|
||
<div class="pod-stats">
|
||
<el-tag type="success" size="large">Running: {{ podSummary.running }}</el-tag>
|
||
<el-tag type="warning" size="large">Pending: {{ podSummary.pending }}</el-tag>
|
||
<el-tag type="danger" size="large">Failed: {{ podSummary.failed }}</el-tag>
|
||
<el-tag type="info" size="large">CrashLoop: {{ podSummary.crashloop }}</el-tag>
|
||
</div>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-card class="section-card" shadow="never">
|
||
<template #header><span>🖥️ Node 状态</span></template>
|
||
<div class="pod-stats">
|
||
<el-tag type="success" size="large">Ready: {{ nodeSummary.ready }}</el-tag>
|
||
<el-tag type="danger" size="large">NotReady: {{ nodeSummary.not_ready }}</el-tag>
|
||
</div>
|
||
<div v-if="resourceNodes.length" style="margin-top: 12px">
|
||
<div v-for="n in resourceNodes" :key="n.name" class="resource-bar">
|
||
<span class="res-name">{{ n.name }}</span>
|
||
<el-progress :percentage="n.cpu_pct" :color="barColor(n.cpu_pct)" :stroke-width="14" style="flex:1">
|
||
<span>CPU {{ n.cpu_pct }}%</span>
|
||
</el-progress>
|
||
<el-progress :percentage="n.mem_pct" :color="barColor(n.mem_pct)" :stroke-width="14" style="flex:1">
|
||
<span>MEM {{ n.mem_pct }}%</span>
|
||
</el-progress>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<!-- Deployment 状态 -->
|
||
<el-card v-if="deployments.length" class="section-card" shadow="never">
|
||
<template #header><span>🚀 Deployment 状态</span></template>
|
||
<el-table :data="deployments" stripe size="small">
|
||
<el-table-column prop="namespace" label="命名空间" width="160" />
|
||
<el-table-column prop="name" label="名称" />
|
||
<el-table-column label="副本" width="120" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.ready >= row.desired ? 'success' : 'danger'" size="small">
|
||
{{ row.ready }}/{{ row.desired }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="updated" label="已更新" width="80" align="center" />
|
||
</el-table>
|
||
</el-card>
|
||
|
||
<!-- 异常事件 -->
|
||
<el-card v-if="warningEvents.length" class="section-card" shadow="never">
|
||
<template #header><span>⚡ 异常事件 (最近 {{ warningEvents.length }} 条)</span></template>
|
||
<el-table :data="warningEvents" stripe size="small" max-height="250">
|
||
<el-table-column prop="namespace" label="NS" width="120" />
|
||
<el-table-column prop="object" label="对象" width="220" />
|
||
<el-table-column prop="reason" label="原因" width="160" />
|
||
<el-table-column prop="message" label="消息" show-overflow-tooltip />
|
||
<el-table-column prop="count" label="次数" width="60" align="center" />
|
||
</el-table>
|
||
</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>
|
||
|
||
<!-- 自主诊断 Agent -->
|
||
<el-card v-if="diagnosis && !diagnosing && (agentRunning || agentEvents.length)" class="section-card agent-card" shadow="never">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>🧠 AI Agent 自主诊断</span>
|
||
<div class="ai-header-meta">
|
||
<el-tag :type="activeAgentMode === 'ai' ? 'success' : 'info'" size="small">
|
||
{{ activeAgentMode === 'ai' ? 'AI 执行模式' : '手动执行模式' }}
|
||
</el-tag>
|
||
<el-tag v-if="activeAgentMode === 'ai'" type="warning" size="small">修改命令人工确认</el-tag>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<el-alert
|
||
:title="activeAgentMode === 'ai'
|
||
? 'AI 执行模式:Agent 自动执行只读 kubectl 和系统诊断命令 (systemctl status、journalctl、crictl ps、df 等),修改或交互命令仍需人工批准。'
|
||
: '手动执行模式:Agent 只生成安全的诊断命令,不会在服务器上执行,请复制命令到终端手动运行。'"
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
class="agent-alert"
|
||
/>
|
||
<div class="agent-timeline">
|
||
<div v-for="(event, index) in agentEvents" :key="index" class="agent-event" :class="'event-' + event.type">
|
||
<div class="agent-event-title">
|
||
<el-icon v-if="event.type === 'thinking'" class="is-loading" color="#409eff"><Loading /></el-icon>
|
||
<el-icon v-else-if="event.type === 'result' || event.type === 'final'" color="#67c23a"><CircleCheckFilled /></el-icon>
|
||
<el-icon v-else-if="event.type === 'approval_required'" color="#e6a23c"><WarningFilled /></el-icon>
|
||
<el-icon v-else color="#909399"><Monitor /></el-icon>
|
||
<strong>{{ agentEventTitle(event) }}</strong>
|
||
<el-tag v-if="event.mode" :type="event.mode === 'read' ? 'success' : 'warning'" size="small">
|
||
{{ event.mode === 'read' ? '只读' : '需审批' }}
|
||
</el-tag>
|
||
</div>
|
||
<p v-if="event.message" class="agent-event-message">{{ event.message }}</p>
|
||
<p v-if="event.reason" class="agent-event-message">目的:{{ event.reason }}</p>
|
||
<pre v-if="event.command" class="agent-command">{{ event.command }}</pre>
|
||
<pre v-if="event.stdout || event.stderr" class="agent-output">{{ event.stdout || event.stderr }}</pre>
|
||
<div v-if="event.type === 'final'" class="ai-content" v-html="renderMd(event.content)"></div>
|
||
<div v-if="event.type === 'manual_command'" class="manual-actions">
|
||
<el-button type="primary" plain @click="copyAgentCommand(event.command)">复制命令</el-button>
|
||
<span>请在你的终端中执行,Agent 不会自动运行该命令。</span>
|
||
</div>
|
||
<div v-if="event.type === 'approval_required' && !event.resolved" class="approval-actions">
|
||
<el-button type="danger" @click="resolveApproval(event, true)" :loading="event.executing">确认执行</el-button>
|
||
<el-button @click="resolveApproval(event, false)" :disabled="event.executing">拒绝</el-button>
|
||
</div>
|
||
<el-alert v-if="event.approvalResult" :title="event.approvalResult" :type="event.approvalSuccess ? 'success' : 'info'" :closable="false" />
|
||
</div>
|
||
<div v-if="agentRunning" class="agent-running">
|
||
<el-icon class="is-loading" color="#409eff"><Loading /></el-icon>
|
||
<span>Agent 正在分析命令结果并规划下一步...</span>
|
||
</div>
|
||
</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-drawer v-model="historyVisible" title="历史诊断与处理记录" size="75%" @open="loadHistory">
|
||
<div class="history-toolbar">
|
||
<el-select v-model="historyStatus" placeholder="全部状态" clearable style="width: 150px" @change="loadHistory">
|
||
<el-option label="健康" value="healthy" />
|
||
<el-option label="警告" value="warning" />
|
||
<el-option label="严重" value="critical" />
|
||
</el-select>
|
||
<el-input v-model="historyNamespace" placeholder="命名空间" clearable style="width: 180px" @keyup.enter="loadHistory" />
|
||
<el-button type="primary" @click="loadHistory" :loading="historyLoading">查询</el-button>
|
||
</div>
|
||
<el-table :data="historyItems" stripe highlight-current-row @row-click="loadHistoryDetail" v-loading="historyLoading">
|
||
<el-table-column prop="created_at" label="诊断时间" min-width="180" />
|
||
<el-table-column prop="namespace" label="命名空间" min-width="110" />
|
||
<el-table-column prop="score" label="评分" width="80" />
|
||
<el-table-column label="状态" width="90">
|
||
<template #default="{ row }"><el-tag :type="historyStatusType(row.status)">{{ historyStatusText(row.status) }}</el-tag></template>
|
||
</el-table-column>
|
||
<el-table-column prop="critical_count" label="严重" width="75" />
|
||
<el-table-column prop="warning_count" label="警告" width="75" />
|
||
<el-table-column prop="run_count" label="处理记录" width="100" />
|
||
<el-table-column label="操作" width="100">
|
||
<template #default="{ row }"><el-button link type="primary" @click.stop="loadHistoryDetail(row)">查看详情</el-button></template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<div v-if="historyDetail" class="history-detail">
|
||
<el-divider content-position="left">诊断详情</el-divider>
|
||
<el-descriptions :column="4" border>
|
||
<el-descriptions-item label="时间">{{ historyDetail.created_at }}</el-descriptions-item>
|
||
<el-descriptions-item label="命名空间">{{ historyDetail.diagnosis.namespace }}</el-descriptions-item>
|
||
<el-descriptions-item label="健康评分">{{ historyDetail.diagnosis.score }}</el-descriptions-item>
|
||
<el-descriptions-item label="状态">{{ historyStatusText(historyDetail.diagnosis.status) }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
<el-collapse class="history-collapse">
|
||
<el-collapse-item title="完整诊断报告" name="report">
|
||
<pre class="history-report">{{ historyDetail.report }}</pre>
|
||
</el-collapse-item>
|
||
<el-collapse-item v-for="run in historyDetail.runs" :key="run.id" :name="run.id">
|
||
<template #title>
|
||
<span>{{ runKindText(run.kind) }} · {{ run.started_at }} · {{ runStatusText(run.status) }}</span>
|
||
</template>
|
||
<el-descriptions :column="3" size="small" border>
|
||
<el-descriptions-item label="模式">{{ run.mode || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="问题">{{ run.question || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="结束时间">{{ run.finished_at || '-' }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
<div v-for="(event, index) in run.events" :key="index" class="history-event">
|
||
<div class="history-event-head"><el-tag size="small">{{ event.type }}</el-tag><span>{{ event.created_at }}</span></div>
|
||
<pre>{{ formatHistoryEvent(event) }}</pre>
|
||
</div>
|
||
</el-collapse-item>
|
||
</el-collapse>
|
||
</div>
|
||
</el-drawer>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, nextTick } from 'vue'
|
||
import { marked } from 'marked'
|
||
import { Refresh, MagicStick, Loading, WarningFilled, Warning, Promotion, Monitor, CircleCheckFilled, CircleClose, CircleCloseFilled } from '@element-plus/icons-vue'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
const API = '/api'
|
||
|
||
const namespace = ref('')
|
||
const diagnosing = ref(false)
|
||
const analyzing = ref(false)
|
||
const agentRunning = ref(false)
|
||
const agentExecutionMode = ref('manual')
|
||
const activeAgentMode = ref('manual')
|
||
const agentEvents = ref([])
|
||
const diagnosis = ref(null)
|
||
const aiAnalysis = ref('')
|
||
const aiModel = ref('')
|
||
const analysisProcess = ref([])
|
||
const historyVisible = ref(false)
|
||
const historyLoading = ref(false)
|
||
const historyItems = ref([])
|
||
const historyDetail = ref(null)
|
||
const historyStatus = ref('')
|
||
const historyNamespace = ref('')
|
||
const chatMessages = ref([])
|
||
const chatInput = ref('')
|
||
const chatLoading = ref(false)
|
||
const chatBoxRef = ref(null)
|
||
|
||
// 诊断进度
|
||
const progressList = ref([])
|
||
const progressStep = ref(0)
|
||
const progressTotal = ref(9)
|
||
|
||
const progressPercent = computed(() => {
|
||
if (progressTotal.value === 0) return 0
|
||
return Math.round((progressStep.value / progressTotal.value) * 100)
|
||
})
|
||
|
||
const statusTagType = computed(() => {
|
||
if (!diagnosis.value) return 'info'
|
||
const s = diagnosis.value.status
|
||
return s === 'healthy' ? 'success' : s === 'warning' ? 'warning' : 'danger'
|
||
})
|
||
|
||
const podSummary = computed(() => diagnosis.value?.checks?.pods?.summary || { total: 0, running: 0, pending: 0, failed: 0, crashloop: 0 })
|
||
const nodeSummary = computed(() => diagnosis.value?.checks?.nodes?.summary || { total: 0, ready: 0, not_ready: 0 })
|
||
const deployments = computed(() => diagnosis.value?.checks?.deployments?.deployments || [])
|
||
const warningEvents = computed(() => (diagnosis.value?.checks?.events?.events || []).filter(e => e.type === 'Warning').slice(-30))
|
||
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 })
|
||
}
|
||
|
||
function barColor(pct) {
|
||
if (pct > 90) return '#f56c6c'
|
||
if (pct > 75) return '#e6a23c'
|
||
return '#67c23a'
|
||
}
|
||
|
||
async function runDiagnosis() {
|
||
diagnosing.value = true
|
||
aiAnalysis.value = ''
|
||
diagnosis.value = null
|
||
progressList.value = []
|
||
progressStep.value = 0
|
||
progressTotal.value = 9
|
||
|
||
try {
|
||
const params = new URLSearchParams()
|
||
if (namespace.value) params.set('namespace', namespace.value)
|
||
const url = `${API}/diagnose/stream?${params.toString()}`
|
||
|
||
const resp = await fetch(url)
|
||
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 === 'progress') {
|
||
progressTotal.value = msg.total
|
||
progressStep.value = msg.step
|
||
|
||
if (msg.status === 'running') {
|
||
progressList.value.push({ name: msg.name, title: msg.title, status: 'running', issues: 0 })
|
||
} else if (msg.status === 'done') {
|
||
const item = progressList.value.find(i => i.name === msg.name)
|
||
if (item) {
|
||
item.status = 'done'
|
||
item.issues = msg.issues
|
||
}
|
||
}
|
||
} else if (msg.type === 'complete') {
|
||
diagnosis.value = msg.diagnosis
|
||
ElMessage.success(`诊断完成,评分 ${msg.diagnosis.score}/100`)
|
||
}
|
||
} catch (e) {
|
||
// ignore parse errors on partial data
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error('诊断失败: ' + e.message)
|
||
} finally {
|
||
diagnosing.value = false
|
||
}
|
||
}
|
||
|
||
async function runAnalysis() {
|
||
analyzing.value = true
|
||
aiAnalysis.value = ''
|
||
aiModel.value = ''
|
||
analysisProcess.value = []
|
||
try {
|
||
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
|
||
}
|
||
}
|
||
|
||
function openHistory() {
|
||
historyVisible.value = true
|
||
}
|
||
|
||
async function loadHistory() {
|
||
historyLoading.value = true
|
||
historyDetail.value = null
|
||
try {
|
||
const params = new URLSearchParams({ limit: '200' })
|
||
if (historyStatus.value) params.set('status', historyStatus.value)
|
||
if (historyNamespace.value) params.set('namespace', historyNamespace.value)
|
||
const resp = await fetch(`${API}/history/diagnoses?${params.toString()}`)
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||
const data = await resp.json()
|
||
historyItems.value = data.items || []
|
||
} catch (e) {
|
||
ElMessage.error('加载历史记录失败: ' + e.message)
|
||
} finally {
|
||
historyLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadHistoryDetail(row) {
|
||
historyLoading.value = true
|
||
try {
|
||
const resp = await fetch(`${API}/history/diagnoses/${row.id}`)
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||
historyDetail.value = await resp.json()
|
||
} catch (e) {
|
||
ElMessage.error('加载历史详情失败: ' + e.message)
|
||
} finally {
|
||
historyLoading.value = false
|
||
}
|
||
}
|
||
|
||
function historyStatusType(status) {
|
||
return status === 'healthy' ? 'success' : status === 'warning' ? 'warning' : status === 'critical' ? 'danger' : 'info'
|
||
}
|
||
|
||
function historyStatusText(status) {
|
||
return { healthy: '健康', warning: '警告', critical: '严重' }[status] || status
|
||
}
|
||
|
||
function runKindText(kind) {
|
||
return { analysis: 'AI 分析', agent: 'Agent 自主诊断', chat: 'AI 对话' }[kind] || kind
|
||
}
|
||
|
||
function runStatusText(status) {
|
||
return { running: '运行中', completed: '已完成', waiting: '等待处理', failed: '失败', rejected: '已拒绝' }[status] || status
|
||
}
|
||
|
||
function formatHistoryEvent(event) {
|
||
const { type, created_at, ...payload } = event
|
||
return JSON.stringify(payload, null, 2)
|
||
}
|
||
|
||
async function runAgentDiagnosis() {
|
||
activeAgentMode.value = agentExecutionMode.value
|
||
agentRunning.value = true
|
||
agentEvents.value = []
|
||
try {
|
||
const params = new URLSearchParams()
|
||
params.set('execution_mode', activeAgentMode.value)
|
||
if (namespace.value) params.set('question', `重点检查命名空间 ${namespace.value}`)
|
||
const resp = await fetch(`${API}/agent/diagnose/stream?${params.toString()}`)
|
||
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`)
|
||
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
|
||
try {
|
||
const event = JSON.parse(line.slice(6))
|
||
if (!['start', 'done'].includes(event.type)) agentEvents.value.push(event)
|
||
if (event.type === 'error') ElMessage.error(event.message)
|
||
} catch (e) {
|
||
// ignore partial events
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error('自主诊断失败: ' + e.message)
|
||
} finally {
|
||
agentRunning.value = false
|
||
}
|
||
}
|
||
|
||
function agentEventTitle(event) {
|
||
const titles = {
|
||
thinking: 'Agent 思考', command: '准备执行命令', result: '命令执行结果',
|
||
manual_command: '请手动执行命令',
|
||
approval_required: '发现修改命令,等待审批', command_rejected: '命令已被安全策略拒绝',
|
||
verification_required: '需要继续复检', response_rejected: 'AI 响应格式已自动纠正',
|
||
final: '自主诊断结论', error: 'Agent 执行异常',
|
||
}
|
||
return titles[event.type] || event.type
|
||
}
|
||
|
||
async function copyAgentCommand(command) {
|
||
try {
|
||
await navigator.clipboard.writeText(command)
|
||
ElMessage.success('命令已复制,请在终端中手动执行')
|
||
} catch (e) {
|
||
ElMessage.error('复制失败,请手动选择命令文本')
|
||
}
|
||
}
|
||
|
||
async function resolveApproval(event, approved) {
|
||
event.executing = true
|
||
try {
|
||
const resp = await fetch(`${API}/agent/approve`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ approval_id: event.approval_id, approved }),
|
||
})
|
||
if (!resp.ok) {
|
||
const error = await resp.json()
|
||
throw new Error(error.detail || `HTTP ${resp.status}`)
|
||
}
|
||
|
||
if (!approved) {
|
||
const result = await resp.json()
|
||
event.resolved = true
|
||
event.approvalSuccess = false
|
||
event.approvalResult = '已拒绝执行该命令'
|
||
ElMessage.info(event.approvalResult)
|
||
return
|
||
}
|
||
|
||
if (!resp.body) throw new Error('审批接口未返回流式响应')
|
||
event.resolved = true
|
||
agentRunning.value = true
|
||
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 nextEvent = JSON.parse(line.slice(6))
|
||
if (nextEvent.type === 'approved_result') {
|
||
event.approvalSuccess = nextEvent.returncode === 0
|
||
event.approvalResult = nextEvent.returncode === 0 ? '命令执行成功,Agent 继续诊断中' : `命令执行失败:${nextEvent.stderr || nextEvent.returncode}`
|
||
agentEvents.value.push({ type: 'result', ...nextEvent })
|
||
} else if (!['start', 'done'].includes(nextEvent.type)) {
|
||
agentEvents.value.push(nextEvent)
|
||
if (nextEvent.type === 'error') ElMessage.error(nextEvent.message)
|
||
}
|
||
}
|
||
}
|
||
ElMessage.success('审批命令已执行,Agent 已继续诊断')
|
||
} catch (e) {
|
||
ElMessage.error('审批处理失败: ' + e.message)
|
||
} finally {
|
||
event.executing = false
|
||
agentRunning.value = false
|
||
}
|
||
}
|
||
|
||
async function sendChat() {
|
||
const q = chatInput.value.trim()
|
||
if (!q || chatLoading.value) return
|
||
chatMessages.value.push({ role: 'user', content: q })
|
||
chatInput.value = ''
|
||
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/stream`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
messages: chatMessages.value.slice(0, -1).map(m => ({ role: m.role, content: m.content })),
|
||
}),
|
||
})
|
||
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[assistantIdx].content = '请求失败: ' + e.message
|
||
} finally {
|
||
chatLoading.value = false
|
||
await nextTick()
|
||
scrollChat()
|
||
}
|
||
}
|
||
|
||
function scrollChat() {
|
||
if (chatBoxRef.value) chatBoxRef.value.scrollTop = chatBoxRef.value.scrollHeight
|
||
}
|
||
</script>
|
||
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; background: #f0f2f5; }
|
||
|
||
.app-container { min-height: 100vh; display: flex; flex-direction: column; }
|
||
|
||
.app-header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
background: #fff; padding: 0 24px; height: 64px;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,.08); position: sticky; top: 0; z-index: 100;
|
||
}
|
||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||
.header-left h1 { font-size: 20px; font-weight: 600; color: #303133; }
|
||
.header-right { display: flex; align-items: center; gap: 12px; }
|
||
|
||
.app-main { flex: 1; padding: 20px 24px; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||
|
||
.welcome { display: flex; justify-content: center; align-items: center; min-height: 400px; }
|
||
|
||
/* 诊断进度 */
|
||
.progress-box { display: flex; justify-content: center; padding-top: 40px; }
|
||
.progress-card { width: 600px; }
|
||
|
||
/* 连接失败 */
|
||
.conn-error-box { display: flex; justify-content: center; padding-top: 40px; }
|
||
.conn-error-card { width: 700px; text-align: center; padding: 20px 0; }
|
||
.conn-error-icon { margin-bottom: 16px; }
|
||
.conn-error-card h2 { font-size: 22px; color: #303133; margin-bottom: 20px; }
|
||
.conn-error-detail { text-align: left; margin: 0 20px; }
|
||
.conn-error-pre { white-space: pre-wrap; word-break: break-all; font-size: 13px; margin: 8px 0 0; color: #f56c6c; }
|
||
.conn-error-actions { margin-top: 24px; }
|
||
.progress-steps { display: flex; flex-direction: column; gap: 4px; }
|
||
.progress-item {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 10px 12px; border-radius: 6px; transition: background .2s;
|
||
}
|
||
.progress-item.status-running { background: #ecf5ff; }
|
||
.progress-item.status-done { background: #f0f9eb; }
|
||
.step-icon { display: flex; align-items: center; width: 20px; }
|
||
.step-title { flex: 1; font-size: 14px; color: #303133; }
|
||
.step-result { font-size: 12px; }
|
||
.step-running { font-size: 12px; color: #409eff; }
|
||
|
||
.summary-row { margin-bottom: 16px; }
|
||
.score-card { text-align: center; }
|
||
.score-number { font-size: 42px; font-weight: 700; line-height: 1.2; }
|
||
.score-label { font-size: 13px; color: #909399; margin-top: 4px; }
|
||
.score-healthy .score-number { color: #67c23a; }
|
||
.score-warning .score-number { color: #e6a23c; }
|
||
.score-critical .score-number { color: #f56c6c; }
|
||
|
||
.section-card { margin-bottom: 16px; }
|
||
.card-header { display: flex; align-items: center; justify-content: space-between; }
|
||
|
||
.pod-stats { display: flex; gap: 12px; flex-wrap: wrap; }
|
||
|
||
.resource-bar { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
||
.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; }
|
||
.ai-content pre { background: #1e1e1e; color: #d4d4d4; padding: 16px; border-radius: 8px; overflow-x: auto; margin: 12px 0; }
|
||
.ai-content pre code { background: none; color: inherit; }
|
||
.ai-content ul, .ai-content ol { padding-left: 24px; }
|
||
.ai-content table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
||
.ai-content th, .ai-content td { border: 1px solid #ebeef5; padding: 8px 12px; text-align: left; }
|
||
|
||
.agent-card { border: 1px solid #f3d19e; }
|
||
.agent-mode-switch { flex-shrink: 0; }
|
||
.agent-alert { margin-bottom: 16px; }
|
||
.agent-timeline { display: flex; flex-direction: column; gap: 12px; }
|
||
.agent-event { padding: 14px; border: 1px solid #ebeef5; border-radius: 8px; background: #fafafa; }
|
||
.agent-event.event-thinking { border-color: #a0cfff; background: #ecf5ff; }
|
||
.agent-event.event-approval_required { border-color: #eebe77; background: #fdf6ec; }
|
||
.agent-event.event-final { border-color: #b3e19d; background: #f0f9eb; }
|
||
.agent-event-title { display: flex; align-items: center; gap: 8px; color: #303133; }
|
||
.agent-event-message { margin-top: 8px; color: #606266; font-size: 13px; }
|
||
.agent-command, .agent-output {
|
||
margin-top: 10px; padding: 12px; border-radius: 6px; overflow-x: auto;
|
||
white-space: pre-wrap; word-break: break-all; font-size: 13px;
|
||
}
|
||
.agent-command { color: #d4d4d4; background: #1e1e1e; }
|
||
.agent-output { max-height: 320px; color: #303133; background: #f4f4f5; }
|
||
.approval-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||
.manual-actions { display: flex; align-items: center; gap: 12px; margin-top: 12px; color: #606266; font-size: 13px; }
|
||
.agent-running { display: flex; align-items: center; gap: 8px; color: #409eff; padding: 8px; }
|
||
|
||
.history-toolbar { display: flex; gap: 10px; margin-bottom: 16px; }
|
||
.history-detail { margin-top: 20px; }
|
||
.history-collapse { margin-top: 16px; }
|
||
.history-report { max-height: 480px; padding: 14px; overflow: auto; border-radius: 6px; background: #f4f4f5; white-space: pre-wrap; }
|
||
.history-event { margin-top: 10px; padding: 12px; border: 1px solid #ebeef5; border-radius: 6px; }
|
||
.history-event-head { display: flex; align-items: center; gap: 10px; color: #909399; font-size: 12px; }
|
||
.history-event pre { max-height: 300px; margin-top: 8px; padding: 10px; overflow: auto; background: #f4f4f5; white-space: pre-wrap; }
|
||
|
||
.chat-box { max-height: 400px; overflow-y: auto; padding: 12px 0; display: flex; flex-direction: column; gap: 12px; }
|
||
.chat-msg { display: flex; }
|
||
.chat-msg.user { justify-content: flex-end; }
|
||
.chat-msg.assistant { justify-content: flex-start; }
|
||
.chat-bubble {
|
||
max-width: 80%; padding: 10px 16px; border-radius: 12px; font-size: 14px; line-height: 1.6;
|
||
}
|
||
.chat-msg.user .chat-bubble { background: #409eff; color: #fff; border-bottom-right-radius: 4px; }
|
||
.chat-msg.assistant .chat-bubble { background: #f4f4f5; color: #303133; border-bottom-left-radius: 4px; }
|
||
.chat-bubble pre { background: #1e1e1e; color: #d4d4d4; padding: 12px; border-radius: 6px; overflow-x: auto; margin: 8px 0; }
|
||
.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>
|