feat: 增加 AI Agent 自主诊断执行模式
This commit is contained in:
@@ -13,6 +13,12 @@
|
||||
- **健康评分**:100 分制量化集群健康度(严重问题 -15 分,警告 -5 分)
|
||||
- **AI 智能分析**:接入 OpenAI 兼容接口,自动分析诊断报告
|
||||
- 根因定位、影响评估、修复命令、优先级排序、预防建议
|
||||
- **AI 自主诊断(双执行模式)**:启动前可选择命令执行方式
|
||||
- **手动执行命令**:Agent 只生成命令,用户复制到自己的终端执行,服务端不会运行
|
||||
- **AI 执行命令**:自动执行 `get`、`describe`、`logs`、`top` 等只读 kubectl 命令
|
||||
- 实时展示思考步骤、执行命令和真实输出
|
||||
- 即使选择 AI 执行,`apply`、`delete`、`patch`、`scale`、`rollout`、`exec` 等命令仍必须人工确认
|
||||
- 禁止 shell、管道、重定向和多命令拼接
|
||||
- **AI 追问对话**:基于诊断上下文多轮追问,深入排查问题
|
||||
- **Web 仪表盘**:Vue3 + Element Plus,实时展示诊断结果
|
||||
|
||||
@@ -55,6 +61,8 @@ KUBECONFIG_PATH=
|
||||
| POST | `/api/diagnose` | 执行诊断 `{"namespace":"", "checks":[]}` |
|
||||
| GET | `/api/diagnosis/latest` | 获取最近诊断结果 |
|
||||
| POST | `/api/analyze` | AI 分析诊断结果 `{"question":""}` |
|
||||
| GET | `/api/agent/diagnose/stream` | 自主诊断 SSE 流,`execution_mode=manual|ai` |
|
||||
| POST | `/api/agent/approve` | 批准或拒绝待执行命令 `{"approval_id":"...","approved":true}` |
|
||||
| POST | `/api/chat` | AI 多轮对话 `{"messages":[...]}` |
|
||||
| GET | `/api/report` | 获取诊断报告文本 |
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""AI Agent 可用的 kubectl 命令工具和安全策略。"""
|
||||
import asyncio
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
|
||||
from backend.diagnosis import KUBECTL
|
||||
|
||||
|
||||
READ_ONLY_VERBS = {
|
||||
"api-resources", "api-versions", "auth", "cluster-info", "describe",
|
||||
"diff", "explain", "get", "logs", "top", "version", "wait",
|
||||
}
|
||||
WRITE_OR_INTERACTIVE_VERBS = {
|
||||
"annotate", "apply", "attach", "autoscale", "cordon", "cp", "create",
|
||||
"debug", "delete", "drain", "edit", "exec", "expose", "label", "patch",
|
||||
"port-forward", "replace", "rollout", "run", "scale", "set", "taint",
|
||||
"uncordon",
|
||||
}
|
||||
GLOBAL_FLAGS_WITH_VALUE = {
|
||||
"--as", "--as-group", "--as-uid", "--cache-dir", "--certificate-authority",
|
||||
"--client-certificate", "--client-key", "--cluster", "--context", "--kubeconfig",
|
||||
"--namespace", "-n", "--profile", "--request-timeout", "--server", "-s",
|
||||
"--tls-server-name", "--token", "--user",
|
||||
}
|
||||
FORBIDDEN_TOKENS = {";", "|", "||", "&&", ">", ">>", "<", "<<", "`", "$"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandDecision:
|
||||
mode: str
|
||||
requires_approval: bool
|
||||
verb: str
|
||||
args: list[str]
|
||||
|
||||
|
||||
def parse_kubectl_command(command: str) -> list[str]:
|
||||
"""仅解析单条 kubectl 命令,不允许 shell、重定向或命令组合。"""
|
||||
if not isinstance(command, str) or not command.strip():
|
||||
raise ValueError("命令不能为空")
|
||||
if any(token in command for token in FORBIDDEN_TOKENS) or "\n" in command or "\r" in command:
|
||||
raise ValueError("不允许 shell 操作符、重定向或多条命令")
|
||||
try:
|
||||
parts = shlex.split(command)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"命令格式错误: {exc}") from exc
|
||||
if not parts or parts[0] != "kubectl":
|
||||
raise ValueError("只允许执行 kubectl 命令")
|
||||
if len(parts) < 2:
|
||||
raise ValueError("kubectl 命令缺少操作类型")
|
||||
return parts[1:]
|
||||
|
||||
|
||||
def _find_verb(args: list[str]) -> str:
|
||||
index = 0
|
||||
while index < len(args):
|
||||
arg = args[index]
|
||||
if arg == "--":
|
||||
break
|
||||
if arg in GLOBAL_FLAGS_WITH_VALUE:
|
||||
index += 2
|
||||
continue
|
||||
if any(arg.startswith(flag + "=") for flag in GLOBAL_FLAGS_WITH_VALUE if flag.startswith("--")):
|
||||
index += 1
|
||||
continue
|
||||
if arg.startswith("-"):
|
||||
index += 1
|
||||
continue
|
||||
return arg.lower()
|
||||
raise ValueError("无法识别 kubectl 操作类型")
|
||||
|
||||
|
||||
def classify_kubectl_command(command: str) -> CommandDecision:
|
||||
args = parse_kubectl_command(command)
|
||||
verb = _find_verb(args)
|
||||
if verb in READ_ONLY_VERBS:
|
||||
return CommandDecision("read", False, verb, args)
|
||||
if verb in WRITE_OR_INTERACTIVE_VERBS:
|
||||
return CommandDecision("write", True, verb, args)
|
||||
return CommandDecision("write", True, verb, args)
|
||||
|
||||
|
||||
async def execute_kubectl(command: str, timeout: int = 30) -> dict:
|
||||
"""无 shell 执行 kubectl,并限制输出大小。"""
|
||||
decision = classify_kubectl_command(command)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
KUBECTL,
|
||||
*decision.args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
return {
|
||||
"command": command,
|
||||
"mode": decision.mode,
|
||||
"returncode": proc.returncode,
|
||||
"stdout": stdout.decode(errors="replace")[:20000].strip(),
|
||||
"stderr": stderr.decode(errors="replace")[:10000].strip(),
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.communicate()
|
||||
return {
|
||||
"command": command,
|
||||
"mode": decision.mode,
|
||||
"returncode": -1,
|
||||
"stdout": "",
|
||||
"stderr": f"命令执行超时 ({timeout}s)",
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
"""AI Agent 分析模块 - 调用 OpenAI 兼容接口分析诊断结果 (支持流式输出)"""
|
||||
import json
|
||||
import uuid
|
||||
import httpx
|
||||
from backend.config import AI_API_BASE, AI_API_KEY, AI_MODEL
|
||||
from backend.agent_tools import classify_kubectl_command, execute_kubectl
|
||||
|
||||
SYSTEM_PROMPT = """你是一位资深的 Kubernetes 运维专家和 SRE 工程师。你的任务是分析 K8S 集群诊断报告,找出根本原因并给出修复建议。
|
||||
|
||||
@@ -20,6 +22,143 @@ _HEADERS = {
|
||||
}
|
||||
|
||||
|
||||
AGENT_SYSTEM_PROMPT = """你是 Kubernetes 自主诊断 Agent。你可以根据诊断报告决定执行 kubectl 命令获取更多证据,并在证据充分后给出结论。
|
||||
|
||||
安全规则:
|
||||
1. 只允许提出单条 kubectl 命令,不得使用 shell、管道、重定向或命令连接符。
|
||||
2. get、describe、logs、top 等只读命令可以自动执行。
|
||||
3. apply、delete、patch、scale、rollout、exec 等修改或交互命令必须暂停并请求人工批准。
|
||||
4. 不要猜测命令输出;必须依据实际工具结果继续判断。
|
||||
5. 最多执行有限轮次,优先使用 namespace 和资源名缩小范围。
|
||||
|
||||
每轮必须只返回一个 JSON 对象,不要使用 Markdown 代码块:
|
||||
- 继续调查:{"action":"command","command":"kubectl ...","reason":"为什么执行"}
|
||||
- 完成分析:{"action":"final","analysis":"中文 Markdown 分析结论"}
|
||||
"""
|
||||
|
||||
_PENDING_APPROVALS: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _parse_agent_action(content: str) -> dict:
|
||||
try:
|
||||
action = json.loads(content.strip())
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("AI 未返回合法 JSON 动作") from exc
|
||||
if not isinstance(action, dict) or action.get("action") not in {"command", "final"}:
|
||||
raise ValueError("AI 返回了不支持的动作")
|
||||
if action["action"] == "command" and not action.get("command"):
|
||||
raise ValueError("AI 命令动作缺少 command")
|
||||
if action["action"] == "final" and not action.get("analysis"):
|
||||
raise ValueError("AI 最终动作缺少 analysis")
|
||||
return action
|
||||
|
||||
|
||||
async def _complete_chat(messages: list[dict]) -> str:
|
||||
timeout = httpx.Timeout(90, connect=10)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(
|
||||
f"{AI_API_BASE}/chat/completions",
|
||||
headers=_HEADERS,
|
||||
json={
|
||||
"model": AI_MODEL,
|
||||
"messages": messages,
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 4096,
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return payload["choices"][0]["message"]["content"]
|
||||
|
||||
|
||||
async def run_diagnostic_agent(
|
||||
report: str,
|
||||
question: str = "",
|
||||
max_steps: int = 6,
|
||||
execution_mode: str = "ai",
|
||||
):
|
||||
"""自主诊断循环:AI 模式执行只读命令,手动模式等待用户执行。"""
|
||||
if execution_mode not in {"ai", "manual"}:
|
||||
yield {"type": "error", "message": "不支持的命令执行模式,仅支持 ai 或 manual"}
|
||||
return
|
||||
|
||||
user_content = f"当前 K8S 诊断报告:\n\n{report}"
|
||||
if question:
|
||||
user_content += f"\n\n用户关注点:{question}"
|
||||
messages = [
|
||||
{"role": "system", "content": AGENT_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
for step in range(1, max_steps + 1):
|
||||
yield {"type": "thinking", "step": step, "message": f"AI 正在规划第 {step} 轮诊断"}
|
||||
try:
|
||||
content = await _complete_chat(messages)
|
||||
action = _parse_agent_action(content)
|
||||
except Exception as exc:
|
||||
yield {"type": "error", "message": str(exc)}
|
||||
return
|
||||
|
||||
if action["action"] == "final":
|
||||
yield {"type": "final", "content": action["analysis"], "model": AI_MODEL}
|
||||
return
|
||||
|
||||
command = action["command"]
|
||||
reason = action.get("reason", "AI 需要更多集群证据")
|
||||
try:
|
||||
decision = classify_kubectl_command(command)
|
||||
except ValueError as exc:
|
||||
messages.extend([
|
||||
{"role": "assistant", "content": content},
|
||||
{"role": "user", "content": f"命令被安全策略拒绝:{exc}。请改用单条合法 kubectl 命令。"},
|
||||
])
|
||||
yield {"type": "command_rejected", "command": command, "reason": str(exc)}
|
||||
continue
|
||||
|
||||
yield {
|
||||
"type": "command", "step": step, "command": command,
|
||||
"reason": reason, "mode": decision.mode,
|
||||
}
|
||||
if execution_mode == "manual":
|
||||
yield {
|
||||
"type": "manual_command", "command_id": uuid.uuid4().hex,
|
||||
"command": command, "reason": reason, "mode": decision.mode,
|
||||
"message": "请在终端手动执行命令,并根据结果继续排查",
|
||||
}
|
||||
return
|
||||
|
||||
if decision.requires_approval:
|
||||
approval_id = uuid.uuid4().hex
|
||||
_PENDING_APPROVALS[approval_id] = {
|
||||
"command": command,
|
||||
"reason": reason,
|
||||
"created_by": AI_MODEL,
|
||||
}
|
||||
yield {
|
||||
"type": "approval_required", "approval_id": approval_id,
|
||||
"command": command, "reason": reason,
|
||||
}
|
||||
return
|
||||
|
||||
result = await execute_kubectl(command)
|
||||
yield {"type": "result", **result}
|
||||
messages.extend([
|
||||
{"role": "assistant", "content": content},
|
||||
{"role": "user", "content": "命令实际执行结果:\n" + json.dumps(result, ensure_ascii=False)},
|
||||
])
|
||||
|
||||
yield {
|
||||
"type": "final",
|
||||
"content": f"已达到自主诊断最大轮次({max_steps}),请根据上方命令输出继续人工排查。",
|
||||
"model": AI_MODEL,
|
||||
}
|
||||
|
||||
|
||||
def take_pending_approval(approval_id: str) -> dict | None:
|
||||
return _PENDING_APPROVALS.pop(approval_id, None)
|
||||
|
||||
|
||||
async def _stream_chat(messages: list[dict]):
|
||||
"""通用流式请求 - yield 文本 chunk"""
|
||||
# connect=10s 快速失败; read=90s 防止 AI 服务无响应时前端无限卡死
|
||||
|
||||
@@ -22,7 +22,9 @@ from backend.diagnosis import (
|
||||
from backend.ai_agent import (
|
||||
analyze_with_ai, chat_with_ai,
|
||||
analyze_with_ai_stream, chat_with_ai_stream,
|
||||
run_diagnostic_agent, take_pending_approval,
|
||||
)
|
||||
from backend.agent_tools import execute_kubectl
|
||||
|
||||
app = FastAPI(title="K8S 智能诊断平台", version="1.0.0")
|
||||
|
||||
@@ -64,6 +66,11 @@ class ChatRequest(BaseModel):
|
||||
messages: list[dict]
|
||||
|
||||
|
||||
class AgentApprovalRequest(BaseModel):
|
||||
approval_id: str
|
||||
approved: bool
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "time": datetime.now().isoformat()}
|
||||
@@ -250,6 +257,48 @@ async def analyze_stream(question: str = ""):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/agent/diagnose/stream")
|
||||
async def agent_diagnose_stream(question: str = "", execution_mode: str = "ai"):
|
||||
"""自主诊断 Agent:支持手动执行和安全 AI 执行模式。"""
|
||||
if execution_mode not in {"ai", "manual"}:
|
||||
raise HTTPException(status_code=400, detail="execution_mode 仅支持 ai 或 manual")
|
||||
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():
|
||||
yield f"data: {json.dumps({'type': 'start', 'model': AI_MODEL, 'mode': execution_mode}, ensure_ascii=False)}\n\n"
|
||||
try:
|
||||
async for event in run_diagnostic_agent(
|
||||
_last_report,
|
||||
question=question,
|
||||
execution_mode=execution_mode,
|
||||
):
|
||||
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
|
||||
except Exception as exc:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': f'自主诊断失败: {exc}'}, 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/agent/approve")
|
||||
async def execute_approved_agent_command(req: AgentApprovalRequest):
|
||||
"""人工批准后执行一次待审批的修改命令。"""
|
||||
pending = take_pending_approval(req.approval_id)
|
||||
if pending is None:
|
||||
raise HTTPException(status_code=404, detail="审批请求不存在或已经处理")
|
||||
if not req.approved:
|
||||
return {"approved": False, "command": pending["command"], "message": "用户已拒绝执行"}
|
||||
result = await execute_kubectl(pending["command"])
|
||||
return {"approved": True, "reason": pending["reason"], **result}
|
||||
|
||||
|
||||
@app.post("/api/chat")
|
||||
async def chat(req: ChatRequest):
|
||||
"""AI 多轮对话 (非流式,兼容旧接口)"""
|
||||
|
||||
+171
-1
@@ -14,9 +14,16 @@
|
||||
<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">
|
||||
<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 type="warning" :loading="agentRunning" @click="runAgentDiagnosis" :disabled="!diagnosis || diagnosing || analyzing">
|
||||
{{ agentRunning ? 'Agent 诊断中...' : '自主诊断' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
@@ -257,6 +264,62 @@
|
||||
<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 命令,修改或交互命令仍需人工批准。'
|
||||
: '手动执行模式:Agent 只生成安全的 kubectl 命令,不会在服务器上执行,请复制命令到终端手动运行。'"
|
||||
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>
|
||||
@@ -288,6 +351,10 @@ 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('')
|
||||
@@ -462,6 +529,89 @@ async function runAnalysis() {
|
||||
}
|
||||
}
|
||||
|
||||
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: '命令已被安全策略拒绝',
|
||||
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 }),
|
||||
})
|
||||
const result = await resp.json()
|
||||
if (!resp.ok) throw new Error(result.detail || `HTTP ${resp.status}`)
|
||||
event.resolved = true
|
||||
event.approvalSuccess = approved && result.returncode === 0
|
||||
if (!approved) {
|
||||
event.approvalResult = '已拒绝执行该命令'
|
||||
} else {
|
||||
event.approvalResult = result.returncode === 0 ? '命令执行成功' : `命令执行失败:${result.stderr || result.returncode}`
|
||||
agentEvents.value.push({ type: 'result', ...result })
|
||||
}
|
||||
ElMessage[approved && result.returncode === 0 ? 'success' : 'info'](event.approvalResult)
|
||||
} catch (e) {
|
||||
ElMessage.error('审批处理失败: ' + e.message)
|
||||
} finally {
|
||||
event.executing = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendChat() {
|
||||
const q = chatInput.value.trim()
|
||||
if (!q || chatLoading.value) return
|
||||
@@ -611,6 +761,26 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC'
|
||||
.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; }
|
||||
|
||||
.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; }
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import asyncio
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import backend.main as main
|
||||
|
||||
|
||||
def parse_sse(event):
|
||||
return json.loads(event[6:].strip())
|
||||
|
||||
|
||||
class AgentApiTest(unittest.TestCase):
|
||||
def test_agent_stream_forwards_agent_events(self):
|
||||
async def fake_agent(report, question="", max_steps=6, execution_mode="ai"):
|
||||
self.assertEqual(execution_mode, "manual")
|
||||
yield {"type": "thinking", "step": 1, "message": "规划"}
|
||||
yield {"type": "final", "content": "完成"}
|
||||
|
||||
async def run():
|
||||
main._last_diagnosis = {"score": 60}
|
||||
main._last_report = "report"
|
||||
with patch.object(main, "run_diagnostic_agent", fake_agent):
|
||||
response = await main.agent_diagnose_stream(question="检查 Pod", execution_mode="manual")
|
||||
return [parse_sse(event) async for event in response.body_iterator]
|
||||
|
||||
events = asyncio.run(run())
|
||||
self.assertEqual(events[0]["type"], "start")
|
||||
self.assertEqual(events[-1]["type"], "done")
|
||||
self.assertEqual(events[-2]["type"], "final")
|
||||
|
||||
def test_execute_approved_command_once(self):
|
||||
approval = {"command": "kubectl scale deployment/api --replicas=2", "reason": "恢复副本"}
|
||||
|
||||
async def fake_execute(command):
|
||||
return {"command": command, "mode": "write", "returncode": 0, "stdout": "scaled", "stderr": ""}
|
||||
|
||||
async def run():
|
||||
with patch.object(main, "take_pending_approval", return_value=approval), patch.object(main, "execute_kubectl", fake_execute):
|
||||
return await main.execute_approved_agent_command(main.AgentApprovalRequest(approval_id="abc", approved=True))
|
||||
|
||||
result = asyncio.run(run())
|
||||
self.assertEqual(result["returncode"], 0)
|
||||
self.assertEqual(result["stdout"], "scaled")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,38 @@
|
||||
import unittest
|
||||
|
||||
from backend.agent_tools import classify_kubectl_command, parse_kubectl_command
|
||||
|
||||
|
||||
class KubectlCommandPolicyTest(unittest.TestCase):
|
||||
def test_allows_read_only_kubectl_command_for_autonomous_execution(self):
|
||||
decision = classify_kubectl_command("kubectl get pods -n production -o wide")
|
||||
|
||||
self.assertEqual(decision.mode, "read")
|
||||
self.assertFalse(decision.requires_approval)
|
||||
|
||||
def test_requires_approval_for_mutating_kubectl_command(self):
|
||||
decision = classify_kubectl_command("kubectl rollout restart deployment/api -n production")
|
||||
|
||||
self.assertEqual(decision.mode, "write")
|
||||
self.assertTrue(decision.requires_approval)
|
||||
|
||||
def test_rejects_shell_operators_and_non_kubectl_commands(self):
|
||||
for command in (
|
||||
"kubectl get pods; rm -rf /",
|
||||
"kubectl get pods | sh",
|
||||
"bash -c kubectl get pods",
|
||||
"sudo kubectl get pods",
|
||||
):
|
||||
with self.subTest(command=command):
|
||||
with self.assertRaises(ValueError):
|
||||
parse_kubectl_command(command)
|
||||
|
||||
def test_rejects_exec_even_when_command_looks_read_only(self):
|
||||
decision = classify_kubectl_command("kubectl exec api-0 -- cat /etc/hosts")
|
||||
|
||||
self.assertEqual(decision.mode, "write")
|
||||
self.assertTrue(decision.requires_approval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,97 @@
|
||||
import asyncio
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from backend.ai_agent import run_diagnostic_agent
|
||||
|
||||
|
||||
async def collect_events(stream):
|
||||
return [event async for event in stream]
|
||||
|
||||
|
||||
class DiagnosticAgentTest(unittest.TestCase):
|
||||
def test_executes_read_only_tool_and_returns_final_answer(self):
|
||||
responses = iter([
|
||||
'{"action":"command","command":"kubectl get pods -A","reason":"查看异常 Pod"}',
|
||||
'{"action":"final","analysis":"发现一个异常 Pod"}',
|
||||
])
|
||||
|
||||
async def fake_completion(messages):
|
||||
return next(responses)
|
||||
|
||||
async def fake_execute(command):
|
||||
return {"command": command, "mode": "read", "returncode": 0, "stdout": "pod-a Running", "stderr": ""}
|
||||
|
||||
async def run():
|
||||
with patch("backend.ai_agent._complete_chat", fake_completion), patch("backend.ai_agent.execute_kubectl", fake_execute):
|
||||
return await collect_events(run_diagnostic_agent("report", max_steps=3))
|
||||
|
||||
events = asyncio.run(run())
|
||||
self.assertEqual([event["type"] for event in events], ["thinking", "command", "result", "thinking", "final"])
|
||||
self.assertEqual(events[1]["mode"], "read")
|
||||
self.assertEqual(events[-1]["content"], "发现一个异常 Pod")
|
||||
|
||||
def test_pauses_mutating_command_for_human_approval(self):
|
||||
async def fake_completion(messages):
|
||||
return '{"action":"command","command":"kubectl rollout restart deployment/api -n prod","reason":"重启故障工作负载"}'
|
||||
|
||||
async def run():
|
||||
with patch("backend.ai_agent._complete_chat", fake_completion):
|
||||
return await collect_events(run_diagnostic_agent("report", max_steps=3))
|
||||
|
||||
events = asyncio.run(run())
|
||||
self.assertEqual(events[-1]["type"], "approval_required")
|
||||
self.assertEqual(events[-1]["command"], "kubectl rollout restart deployment/api -n prod")
|
||||
self.assertTrue(events[-1]["approval_id"])
|
||||
|
||||
def test_manual_mode_never_executes_command_and_waits_for_result(self):
|
||||
async def fake_completion(messages):
|
||||
return '{"action":"command","command":"kubectl get pods -A","reason":"查看 Pod"}'
|
||||
|
||||
async def run():
|
||||
with patch("backend.ai_agent._complete_chat", fake_completion), patch("backend.ai_agent.execute_kubectl") as execute:
|
||||
events = await collect_events(run_diagnostic_agent("report", max_steps=3, execution_mode="manual"))
|
||||
return events, execute
|
||||
|
||||
events, execute = asyncio.run(run())
|
||||
execute.assert_not_called()
|
||||
self.assertEqual(events[-1]["type"], "manual_command")
|
||||
self.assertEqual(events[-1]["command"], "kubectl get pods -A")
|
||||
self.assertTrue(events[-1]["command_id"])
|
||||
|
||||
def test_ai_mode_keeps_write_command_behind_approval(self):
|
||||
async def fake_completion(messages):
|
||||
return '{"action":"command","command":"kubectl delete pod api-0 -n prod","reason":"删除故障 Pod"}'
|
||||
|
||||
async def run():
|
||||
with patch("backend.ai_agent._complete_chat", fake_completion), patch("backend.ai_agent.execute_kubectl") as execute:
|
||||
events = await collect_events(run_diagnostic_agent("report", max_steps=3, execution_mode="ai"))
|
||||
return events, execute
|
||||
|
||||
events, execute = asyncio.run(run())
|
||||
execute.assert_not_called()
|
||||
self.assertEqual(events[-1]["type"], "approval_required")
|
||||
|
||||
def test_rejects_unknown_execution_mode(self):
|
||||
async def run():
|
||||
return await collect_events(run_diagnostic_agent("report", execution_mode="invalid"))
|
||||
|
||||
events = asyncio.run(run())
|
||||
self.assertEqual(events[-1]["type"], "error")
|
||||
self.assertIn("执行模式", events[-1]["message"])
|
||||
|
||||
def test_stops_when_model_returns_invalid_action(self):
|
||||
async def fake_completion(messages):
|
||||
return "not-json"
|
||||
|
||||
async def run():
|
||||
with patch("backend.ai_agent._complete_chat", fake_completion):
|
||||
return await collect_events(run_diagnostic_agent("report", max_steps=3))
|
||||
|
||||
events = asyncio.run(run())
|
||||
self.assertEqual(events[-1]["type"], "error")
|
||||
self.assertIn("JSON", events[-1]["message"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user