feat: 增加 AI Agent 自主诊断执行模式
This commit is contained in:
@@ -13,6 +13,12 @@
|
|||||||
- **健康评分**:100 分制量化集群健康度(严重问题 -15 分,警告 -5 分)
|
- **健康评分**:100 分制量化集群健康度(严重问题 -15 分,警告 -5 分)
|
||||||
- **AI 智能分析**:接入 OpenAI 兼容接口,自动分析诊断报告
|
- **AI 智能分析**:接入 OpenAI 兼容接口,自动分析诊断报告
|
||||||
- 根因定位、影响评估、修复命令、优先级排序、预防建议
|
- 根因定位、影响评估、修复命令、优先级排序、预防建议
|
||||||
|
- **AI 自主诊断(双执行模式)**:启动前可选择命令执行方式
|
||||||
|
- **手动执行命令**:Agent 只生成命令,用户复制到自己的终端执行,服务端不会运行
|
||||||
|
- **AI 执行命令**:自动执行 `get`、`describe`、`logs`、`top` 等只读 kubectl 命令
|
||||||
|
- 实时展示思考步骤、执行命令和真实输出
|
||||||
|
- 即使选择 AI 执行,`apply`、`delete`、`patch`、`scale`、`rollout`、`exec` 等命令仍必须人工确认
|
||||||
|
- 禁止 shell、管道、重定向和多命令拼接
|
||||||
- **AI 追问对话**:基于诊断上下文多轮追问,深入排查问题
|
- **AI 追问对话**:基于诊断上下文多轮追问,深入排查问题
|
||||||
- **Web 仪表盘**:Vue3 + Element Plus,实时展示诊断结果
|
- **Web 仪表盘**:Vue3 + Element Plus,实时展示诊断结果
|
||||||
|
|
||||||
@@ -55,6 +61,8 @@ KUBECONFIG_PATH=
|
|||||||
| POST | `/api/diagnose` | 执行诊断 `{"namespace":"", "checks":[]}` |
|
| POST | `/api/diagnose` | 执行诊断 `{"namespace":"", "checks":[]}` |
|
||||||
| GET | `/api/diagnosis/latest` | 获取最近诊断结果 |
|
| GET | `/api/diagnosis/latest` | 获取最近诊断结果 |
|
||||||
| POST | `/api/analyze` | AI 分析诊断结果 `{"question":""}` |
|
| 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":[...]}` |
|
| POST | `/api/chat` | AI 多轮对话 `{"messages":[...]}` |
|
||||||
| GET | `/api/report` | 获取诊断报告文本 |
|
| 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 兼容接口分析诊断结果 (支持流式输出)"""
|
"""AI Agent 分析模块 - 调用 OpenAI 兼容接口分析诊断结果 (支持流式输出)"""
|
||||||
import json
|
import json
|
||||||
|
import uuid
|
||||||
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
|
||||||
|
from backend.agent_tools import classify_kubectl_command, execute_kubectl
|
||||||
|
|
||||||
SYSTEM_PROMPT = """你是一位资深的 Kubernetes 运维专家和 SRE 工程师。你的任务是分析 K8S 集群诊断报告,找出根本原因并给出修复建议。
|
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]):
|
async def _stream_chat(messages: list[dict]):
|
||||||
"""通用流式请求 - yield 文本 chunk"""
|
"""通用流式请求 - yield 文本 chunk"""
|
||||||
# connect=10s 快速失败; read=90s 防止 AI 服务无响应时前端无限卡死
|
# connect=10s 快速失败; read=90s 防止 AI 服务无响应时前端无限卡死
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ from backend.diagnosis import (
|
|||||||
from backend.ai_agent import (
|
from backend.ai_agent import (
|
||||||
analyze_with_ai, chat_with_ai,
|
analyze_with_ai, chat_with_ai,
|
||||||
analyze_with_ai_stream, chat_with_ai_stream,
|
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")
|
app = FastAPI(title="K8S 智能诊断平台", version="1.0.0")
|
||||||
|
|
||||||
@@ -64,6 +66,11 @@ class ChatRequest(BaseModel):
|
|||||||
messages: list[dict]
|
messages: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentApprovalRequest(BaseModel):
|
||||||
|
approval_id: str
|
||||||
|
approved: bool
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok", "time": datetime.now().isoformat()}
|
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")
|
@app.post("/api/chat")
|
||||||
async def chat(req: ChatRequest):
|
async def chat(req: ChatRequest):
|
||||||
"""AI 多轮对话 (非流式,兼容旧接口)"""
|
"""AI 多轮对话 (非流式,兼容旧接口)"""
|
||||||
|
|||||||
+171
-1
@@ -14,9 +14,16 @@
|
|||||||
<el-button type="primary" :loading="diagnosing" @click="runDiagnosis" :icon="Refresh" :disabled="diagnosing">
|
<el-button type="primary" :loading="diagnosing" @click="runDiagnosis" :icon="Refresh" :disabled="diagnosing">
|
||||||
{{ diagnosing ? '诊断中...' : '一键诊断' }}
|
{{ diagnosing ? '诊断中...' : '一键诊断' }}
|
||||||
</el-button>
|
</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 分析' }}
|
{{ analyzing ? 'AI 分析中...' : 'AI 分析' }}
|
||||||
</el-button>
|
</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>
|
</div>
|
||||||
</el-header>
|
</el-header>
|
||||||
|
|
||||||
@@ -257,6 +264,62 @@
|
|||||||
<div class="ai-content" v-html="renderedAnalysis"></div>
|
<div class="ai-content" v-html="renderedAnalysis"></div>
|
||||||
</el-card>
|
</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 对话 (连接成功或失败都显示) -->
|
<!-- AI 对话 (连接成功或失败都显示) -->
|
||||||
<el-card v-if="diagnosis && !diagnosing" class="section-card" shadow="never">
|
<el-card v-if="diagnosis && !diagnosing" class="section-card" shadow="never">
|
||||||
<template #header><span>💬 追问 AI 助手</span></template>
|
<template #header><span>💬 追问 AI 助手</span></template>
|
||||||
@@ -288,6 +351,10 @@ const API = '/api'
|
|||||||
const namespace = ref('')
|
const namespace = ref('')
|
||||||
const diagnosing = ref(false)
|
const diagnosing = ref(false)
|
||||||
const analyzing = 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 diagnosis = ref(null)
|
||||||
const aiAnalysis = ref('')
|
const aiAnalysis = ref('')
|
||||||
const aiModel = 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() {
|
async function sendChat() {
|
||||||
const q = chatInput.value.trim()
|
const q = chatInput.value.trim()
|
||||||
if (!q || chatLoading.value) return
|
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 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; }
|
.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-box { max-height: 400px; overflow-y: auto; padding: 12px 0; display: flex; flex-direction: column; gap: 12px; }
|
||||||
.chat-msg { display: flex; }
|
.chat-msg { display: flex; }
|
||||||
.chat-msg.user { justify-content: flex-end; }
|
.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