feat: 新增 Agent CLI — Web 版 AI 运维 Agent(参考 opsxcli 移植)
后端引擎 backend/pkg/agentcli/(纯引擎库,零业务依赖,剥离 CLI/TUI 外壳): - llm: OpenAI 兼容客户端,复用 model.LLMConfig - tools: ssh_execute(复用 pkg/remoteexec)+ analyze_output + 风险评估黑名单 - safety: 三级安全模式 + 五级风险 + WebSocket 异步确认 - prompt: 五层 System Prompt - core: ReAct 16 轮循环 + 自适应检查点 + 循环检测 - evolver: 四层记忆 + 进化循环,data/agentcli/ 文件存储 集成层: - controller/agent_cli.go: WebSocket 控制器(事件流推流 + 高危确认 + 会话持久化) - model/agent_cli.go: agent_cli_sessions/messages 表 + AutoMigrate - 路由 /ws/agent-cli + /api/v1/aiops/agent-cli/sessions - seed 权限 aiops:agent-cli / aiops:llm-config 前端: - AgentCLI.tsx: 对话流 + 工具块 + 高危确认 + 四模块 tips 面板 - LLMConfig.tsx: 大模型设置独立页 - api/agentCli.ts + Sidebar/App 菜单路由 文档: - docs/agent/ 11 篇(opsxcli 同名文档适配改写 + 00-README 映射总览) 其他: - CLAUDE.md 补充 agent-cli 模块说明 - 修正 .gitignore 根级二进制规则(前导 /,避免误伤 frontend/src/components/aiops/) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
f2069fe3f0
commit
92a2247390
37 files changed
+6449
-6
No files matched your search
@@ -54,6 +54,8 @@ const CronJobManager = lazy(() => import('@/components/automation/CronJobManager
|
||||
const AIOpsAssistant = lazy(() => import('@/components/aiops/AIOpsAssistant').then(m => ({ default: m.AIOpsAssistant })));
|
||||
const AIAlertAnalyzer = lazy(() => import('@/components/aiops/AIAlertAnalyzer').then(m => ({ default: m.AIAlertAnalyzer })));
|
||||
const AIPredictiveAnalytics = lazy(() => import('@/components/aiops/AIPredictiveAnalytics').then(m => ({ default: m.AIPredictiveAnalytics })));
|
||||
const AgentCLI = lazy(() => import('@/components/aiops/AgentCLI').then(m => ({ default: m.default })));
|
||||
const LLMConfigPage = lazy(() => import('@/components/aiops/LLMConfig').then(m => ({ default: m.default })));
|
||||
const AlertCenter = lazy(() => import('@/components/monitor/alert-center/AlertCenter').then(m => ({ default: m.default })));
|
||||
const TraceManager = lazy(() => import('@/components/monitor/trace/TraceManager').then(m => ({ default: m.default })));
|
||||
const HostMonitor = lazy(() => import('@/pages/HostMonitor').then(m => ({ default: m.HostMonitor })));
|
||||
@@ -197,6 +199,8 @@ function App() {
|
||||
<Route path="ai-assistant" element={<LazyPage><AIOpsAssistant /></LazyPage>} />
|
||||
<Route path="ai-alert-analyzer" element={<LazyPage><AIAlertAnalyzer /></LazyPage>} />
|
||||
<Route path="ai-predictive" element={<LazyPage><AIPredictiveAnalytics /></LazyPage>} />
|
||||
<Route path="ai-agent-cli" element={<LazyPage><AgentCLI /></LazyPage>} />
|
||||
<Route path="ai-llm-config" element={<LazyPage><LLMConfigPage /></LazyPage>} />
|
||||
|
||||
{/* Audit — 各 Tab 独立路由,支持 URL 直链 // Logan.Li */}
|
||||
<Route path="audit/terminal" element={<LazyPage><TerminalAuditTab /></LazyPage>} />
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Agent CLI API — WebSocket 连接 + 会话历史 REST // Logan.Li
|
||||
import { http } from '@/utils/request';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
// 会话与消息类型(与后端 model.AgentCLISession/Message 对应) // Logan.Li
|
||||
export interface AgentCliSession {
|
||||
id: number;
|
||||
user_id: number;
|
||||
server_id: number;
|
||||
server_name: string;
|
||||
server_host: string;
|
||||
title: string;
|
||||
status: string; // running/done/aborted/error
|
||||
provider: string;
|
||||
model: string;
|
||||
rounds: number;
|
||||
total_tokens: number;
|
||||
duration: number;
|
||||
error?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AgentCliMessage {
|
||||
id: number;
|
||||
session_id: number;
|
||||
round: number;
|
||||
type: string; // thought/tool_call/tool_result/done/error/user
|
||||
role: string;
|
||||
content: string;
|
||||
tool_name: string;
|
||||
tool_args: string;
|
||||
tool_output: string;
|
||||
risk: string;
|
||||
success: boolean;
|
||||
duration: string;
|
||||
tokens: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const agentCliApi = {
|
||||
// 会话列表(按当前用户隔离)
|
||||
getSessions: async (params?: { server_id?: number; page?: number; page_size?: number }) => {
|
||||
return http.get<{ list: AgentCliSession[]; total: number }>('/aiops/agent-cli/sessions', params);
|
||||
},
|
||||
// 会话详情(含完整推理消息,供回放)
|
||||
getSession: async (id: number | string) => {
|
||||
return http.get<{ session: AgentCliSession; messages: AgentCliMessage[] }>(`/aiops/agent-cli/sessions/${id}`);
|
||||
},
|
||||
// 删除会话
|
||||
deleteSession: async (id: number | string) => {
|
||||
return http.delete(`/aiops/agent-cli/sessions/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
// buildAgentCliWsUrl 构造 Agent CLI WebSocket 地址(浏览器无法设 header,用 ?token= 回退) // Logan.Li
|
||||
export function buildAgentCliWsUrl(serverId: number | string): string {
|
||||
const proto = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
// Token 双保险:store 优先,localStorage 兜底 // Logan.Li
|
||||
const token = useUserStore.getState().token || localStorage.getItem('token') || '';
|
||||
return `${proto}://${location.host}/ws/agent-cli?id=${serverId}&token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
@@ -12,3 +12,4 @@ export * from './agent';
|
||||
export * from './cloud';
|
||||
export * from './system';
|
||||
export * from './ai';
|
||||
export * from './agentCli';
|
||||
@@ -0,0 +1,379 @@
|
||||
// AgentCLI.tsx - AI 运维 Agent 执行页面(Claude Code 风格 Web 版) // Logan.Li
|
||||
// 关联主机后,用自然语言描述任务,AI 通过 SSH 工具自主排障/部署,循环重试直到解决。
|
||||
// 通过 WebSocket 实时展示思考、执行的命令、输出与结果;高危命令弹框确认。
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { serverApi, buildAgentCliWsUrl } from '@/api';
|
||||
import {
|
||||
Send, Square, Terminal, Settings, ChevronDown, ChevronRight,
|
||||
Shield, Brain, Cpu, Database, Loader2, Bot, AlertTriangle, CheckCircle2, Lightbulb, Server,
|
||||
} from 'lucide-react';
|
||||
|
||||
// 对话流条目模型 // Logan.Li
|
||||
interface ChatItem {
|
||||
id: number;
|
||||
kind: 'user' | 'thought' | 'tool' | 'done' | 'error' | 'info';
|
||||
round?: number;
|
||||
content?: string;
|
||||
toolName?: string;
|
||||
command?: string;
|
||||
risk?: string;
|
||||
output?: string;
|
||||
success?: boolean;
|
||||
duration?: string;
|
||||
pending?: boolean;
|
||||
}
|
||||
|
||||
// 四大核心模块说明(页面 tips) // Logan.Li
|
||||
const TIPS = [
|
||||
{ icon: Cpu, title: 'LLM + 工具层(llm / tools)', color: 'text-sky-400',
|
||||
desc: 'OpenAI 兼容 LLM 客户端(复用「大模型设置」中的配置);工具系统注册 ssh_execute(远程执行命令,复用 pkg/remoteexec)与 analyze_output(超长输出分析)。AI 借助工具真正动手操作服务器。' },
|
||||
{ icon: Shield, title: '安全 + Prompt(safety / prompt)', color: 'text-amber-400',
|
||||
desc: '五级风险评估 + 三级安全模式:危险命令(rm -rf /、mkfs)硬性拒绝,高危命令(reboot、restart)弹框确认;五层 System Prompt 定义角色、安全规则、工具与 ReAct 工作方式。' },
|
||||
{ icon: Brain, title: 'ReAct 核心引擎(core)', color: 'text-violet-400',
|
||||
desc: '「思考→行动→观察」循环(最多 16 轮):每轮调用工具、读取输出,失败则分析原因换法重试;自适应检查点 + 循环检测(同参超过 3 次拒绝),直到真正解决问题而非假装解决。' },
|
||||
{ icon: Database, title: '自进化引擎(evolver)', color: 'text-emerald-400',
|
||||
desc: '四层记忆(经验 / 环境 / 事实 / 程序):任务完成后异步学习经验,下次同类任务自动注入历史经验,越用越聪明;学习失败优雅降级,绝不影响主流程。' },
|
||||
];
|
||||
|
||||
// riskVariant 风险等级对应 badge 颜色 // Logan.Li
|
||||
function riskClass(risk?: string): string {
|
||||
switch ((risk || '').toLowerCase()) {
|
||||
case '安全': case 'safe': return 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30';
|
||||
case '低风险': case 'low': return 'bg-sky-500/15 text-sky-400 border-sky-500/30';
|
||||
case '中风险': case 'medium': return 'bg-amber-500/15 text-amber-400 border-amber-500/30';
|
||||
case '高风险': case 'high': return 'bg-orange-500/15 text-orange-400 border-orange-500/30';
|
||||
case '危险': case 'critical': return 'bg-red-500/15 text-red-400 border-red-500/30';
|
||||
default: return 'bg-slate-500/15 text-slate-400 border-slate-500/30';
|
||||
}
|
||||
}
|
||||
|
||||
export default function AgentCLI() {
|
||||
const navigate = useNavigate();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const [servers, setServers] = useState<{ id: number; hostname: string; ip: string }[]>([]);
|
||||
const [serverId, setServerId] = useState<string>('');
|
||||
const [items, setItems] = useState<ChatItem[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [running, setRunning] = useState(false);
|
||||
const [round, setRound] = useState(0);
|
||||
const [tokens, setTokens] = useState(0);
|
||||
const [tipsOpen, setTipsOpen] = useState(true);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const idSeq = useRef(0);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const nextId = () => ++idSeq.current;
|
||||
const push = useCallback((it: Omit<ChatItem, 'id'>) => {
|
||||
setItems((prev) => [...prev, { ...it, id: nextId() }]);
|
||||
}, []);
|
||||
|
||||
// 加载主机列表 // Logan.Li
|
||||
useEffect(() => {
|
||||
serverApi.getServers({ page: 1, page_size: 500 }).then((res) => {
|
||||
setServers((res as { list?: { id: number; hostname: string; ip: string }[] })?.list || []);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 选中主机后建立 WebSocket 连接 // Logan.Li
|
||||
useEffect(() => {
|
||||
if (!serverId) return;
|
||||
setItems([]);
|
||||
setRound(0); setTokens(0); setRunning(false);
|
||||
const ws = new WebSocket(buildAgentCliWsUrl(serverId));
|
||||
wsRef.current = ws;
|
||||
ws.onopen = () => setConnected(true);
|
||||
ws.onclose = () => { setConnected(false); setRunning(false); };
|
||||
ws.onmessage = (e) => {
|
||||
try { handleEvent(JSON.parse(e.data)); } catch { /* 忽略非 JSON */ }
|
||||
};
|
||||
return () => { ws.close(); wsRef.current = null; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serverId]);
|
||||
|
||||
// 自动滚到底部 // Logan.Li
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||||
}, [items]);
|
||||
|
||||
// 处理后端事件 // Logan.Li
|
||||
const handleEvent = (ev: Record<string, unknown>) => {
|
||||
const type = ev.type as string;
|
||||
switch (type) {
|
||||
case 'session':
|
||||
push({ kind: 'info', content: ev.message as string });
|
||||
break;
|
||||
case 'progress':
|
||||
if (ev.round) setRound(ev.round as number);
|
||||
if (ev.tokens) setTokens(ev.tokens as number);
|
||||
break;
|
||||
case 'thought':
|
||||
push({ kind: 'thought', round: ev.round as number, content: ev.thought as string });
|
||||
break;
|
||||
case 'tool_call': {
|
||||
const args = (ev.tool_args || {}) as Record<string, unknown>;
|
||||
push({
|
||||
kind: 'tool', round: ev.round as number, toolName: ev.tool_name as string,
|
||||
command: (args.command as string) || '', risk: ev.risk as string, pending: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'tool_result':
|
||||
// 配对:从末尾找最近的 pending 同名工具块填充输出 // Logan.Li
|
||||
setItems((prev) => {
|
||||
const next = [...prev];
|
||||
for (let i = next.length - 1; i >= 0; i--) {
|
||||
if (next[i].kind === 'tool' && next[i].pending && next[i].toolName === ev.tool_name) {
|
||||
next[i] = {
|
||||
...next[i], output: ev.output as string, success: ev.success as boolean,
|
||||
duration: ev.duration as string, pending: false,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
case 'confirm':
|
||||
handleConfirm(ev);
|
||||
break;
|
||||
case 'done':
|
||||
push({ kind: 'done', content: ev.answer as string, round: ev.round as number });
|
||||
setRunning(false);
|
||||
break;
|
||||
case 'error':
|
||||
case 'aborted':
|
||||
push({ kind: type === 'aborted' ? 'info' : 'error', content: (ev.message as string) || (type === 'aborted' ? '任务已中断' : '发生错误') });
|
||||
setRunning(false);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 高危命令确认(后端驱动 → 弹框 → 回传) // Logan.Li
|
||||
const handleConfirm = async (ev: Record<string, unknown>) => {
|
||||
const ok = await confirm({
|
||||
title: '⚠️ 确认执行高危操作',
|
||||
description: `工具:${ev.tool_name}\n风险:${ev.risk}\n命令:${ev.command || '(无)'}`,
|
||||
confirmText: '确认执行',
|
||||
cancelText: '拒绝',
|
||||
variant: 'destructive',
|
||||
});
|
||||
wsRef.current?.send(JSON.stringify({ type: ok ? 'confirm' : 'deny', confirm_id: ev.confirm_id }));
|
||||
if (!ok) push({ kind: 'info', content: `已拒绝执行:${ev.command || ev.tool_name}` });
|
||||
};
|
||||
|
||||
// 发送任务 // Logan.Li
|
||||
const send = () => {
|
||||
const q = input.trim();
|
||||
if (!q || running || !wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
||||
push({ kind: 'user', content: q });
|
||||
wsRef.current.send(JSON.stringify({ type: 'start', query: q }));
|
||||
setInput('');
|
||||
setRunning(true);
|
||||
};
|
||||
|
||||
// 中断任务 // Logan.Li
|
||||
const stop = () => wsRef.current?.send(JSON.stringify({ type: 'abort' }));
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-3 p-4">
|
||||
{/* 顶部工具栏:主机选择 + 状态 + 大模型设置入口 */}
|
||||
<Card className="bg-slate-900/60 border-slate-800">
|
||||
<CardContent className="p-3 flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 text-slate-200 font-medium">
|
||||
<Bot className="w-5 h-5 text-blue-400" /> Agent CLI
|
||||
</div>
|
||||
<div className="w-px h-6 bg-slate-700" />
|
||||
<Server className="w-4 h-4 text-slate-400" />
|
||||
<Select value={serverId} onValueChange={setServerId}>
|
||||
<SelectTrigger className="w-64 bg-slate-800 border-slate-700 text-slate-200">
|
||||
<SelectValue placeholder="选择目标主机" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 max-h-72">
|
||||
{servers.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.hostname}({s.ip})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{connected && (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1.5" />已连接
|
||||
</Badge>
|
||||
)}
|
||||
{running && (
|
||||
<Badge className="bg-blue-500/15 text-blue-400 border-blue-500/30">
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />第 {round} 轮 · {tokens} tokens
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={() => navigate('/ai-llm-config')}>
|
||||
<Settings className="w-4 h-4 mr-1" /> 大模型设置
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* tips 面板:核心功能 / 逻辑 / 设计思路 */}
|
||||
<Card className="bg-slate-900/40 border-slate-800">
|
||||
<CardHeader className="py-2 px-3 cursor-pointer select-none" onClick={() => setTipsOpen((v) => !v)}>
|
||||
<div className="flex items-center gap-2 text-slate-300 text-sm">
|
||||
{tipsOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
<Lightbulb className="w-4 h-4 text-yellow-400" />
|
||||
<span className="font-medium">核心能力与设计思路</span>
|
||||
<span className="text-slate-500 text-xs">— Agent CLI 由四大模块构成,参考 opsxcli AI Agent 移植</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{tipsOpen && (
|
||||
<CardContent className="pt-2 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{TIPS.map((t) => (
|
||||
<div key={t.title} className="flex gap-2.5 p-2.5 rounded-lg bg-slate-800/40 border border-slate-800">
|
||||
<t.icon className={`w-5 h-5 mt-0.5 shrink-0 ${t.color}`} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-200">{t.title}</div>
|
||||
<div className="text-xs text-slate-400 leading-relaxed mt-0.5">{t.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 对话流 */}
|
||||
<Card className="flex-1 min-h-0 bg-slate-900/40 border-slate-800 flex flex-col">
|
||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto p-4 space-y-3">
|
||||
{items.length === 0 && (
|
||||
<div className="h-full flex flex-col items-center justify-center text-slate-500 gap-2">
|
||||
<Terminal className="w-10 h-10 opacity-40" />
|
||||
<div className="text-sm">{serverId ? '描述你要执行的任务,例如「检查磁盘使用率并清理大文件」' : '请先选择一台目标主机'}</div>
|
||||
</div>
|
||||
)}
|
||||
{items.map((it) => (
|
||||
<MessageView key={it.id} item={it} />
|
||||
))}
|
||||
</div>
|
||||
{/* 输入区 */}
|
||||
<div className="border-t border-slate-800 p-3 flex items-end gap-2">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
|
||||
placeholder={serverId ? '输入任务描述,回车发送(Shift+回车换行)' : '请先选择目标主机'}
|
||||
disabled={!connected}
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-slate-800 border border-slate-700 rounded-md px-3 py-2 text-sm text-slate-200 placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-blue-500 max-h-32"
|
||||
/>
|
||||
{running ? (
|
||||
<Button variant="destructive" onClick={stop} className="bg-red-600 hover:bg-red-700">
|
||||
<Square className="w-4 h-4 mr-1" /> 停止
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={send} disabled={!connected || !input.trim()} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Send className="w-4 h-4 mr-1" /> 执行
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// MessageView 单条消息渲染(按 kind 分流) // Logan.Li
|
||||
function MessageView({ item }: { item: ChatItem }) {
|
||||
if (item.kind === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[80%] rounded-lg bg-blue-600/90 text-white px-3 py-2 text-sm whitespace-pre-wrap">{item.content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (item.kind === 'info') {
|
||||
return <div className="text-center text-xs text-slate-500 py-1">{item.content}</div>;
|
||||
}
|
||||
if (item.kind === 'thought') {
|
||||
return <ThoughtBlock text={item.content || ''} round={item.round} />;
|
||||
}
|
||||
if (item.kind === 'tool') {
|
||||
return <ToolBlock item={item} />;
|
||||
}
|
||||
if (item.kind === 'done') {
|
||||
return (
|
||||
<div className="flex gap-2 rounded-lg bg-emerald-500/10 border border-emerald-500/30 p-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-emerald-100 whitespace-pre-wrap leading-relaxed">{item.content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// error
|
||||
return (
|
||||
<div className="flex gap-2 rounded-lg bg-red-500/10 border border-red-500/30 p-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-red-100 whitespace-pre-wrap">{item.content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ThoughtBlock AI 思考(可折叠,灰底斜体) // Logan.Li
|
||||
function ThoughtBlock({ text, round }: { text: string; round?: number }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const preview = text.length > 80 ? text.slice(0, 80) + '...' : text;
|
||||
return (
|
||||
<div className="rounded-lg bg-slate-800/40 border border-slate-800 p-2.5">
|
||||
<button className="flex items-center gap-1.5 text-xs text-slate-400 w-full text-left" onClick={() => setOpen((v) => !v)}>
|
||||
{open ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
<Brain className="w-3.5 h-3.5 text-violet-400" />
|
||||
{round != null && <span className="text-slate-500">第 {round} 轮思考</span>}
|
||||
<span className="ml-1 text-slate-500 truncate">{!open && preview}</span>
|
||||
</button>
|
||||
{open && <div className="mt-1.5 text-sm text-slate-300 whitespace-pre-wrap leading-relaxed">{text}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ToolBlock 工具调用块(命令 + 风险 + 可展开输出) // Logan.Li
|
||||
function ToolBlock({ item }: { item: ChatItem }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<div className="rounded-lg bg-slate-800/40 border border-slate-800 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-slate-800/60">
|
||||
<button className="flex items-center gap-1.5 flex-1 min-w-0 text-left" onClick={() => setOpen((v) => !v)}>
|
||||
{open ? <ChevronDown className="w-3.5 h-3.5 text-slate-400" /> : <ChevronRight className="w-3.5 h-3.5 text-slate-400" />}
|
||||
<Terminal className="w-3.5 h-3.5 text-sky-400 shrink-0" />
|
||||
<span className="text-xs text-slate-400 shrink-0">{item.toolName}</span>
|
||||
{item.round != null && <span className="text-xs text-slate-600">· R{item.round}</span>}
|
||||
</button>
|
||||
{item.risk && <Badge className={`text-[10px] border ${riskClass(item.risk)}`}>{item.risk}</Badge>}
|
||||
{item.pending && <Loader2 className="w-3.5 h-3.5 text-blue-400 animate-spin" />}
|
||||
{!item.pending && item.success != null && (
|
||||
item.success
|
||||
? <CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" />
|
||||
: <AlertTriangle className="w-3.5 h-3.5 text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="px-3 py-2 space-y-2">
|
||||
{item.command && (
|
||||
<pre className="text-xs text-emerald-300 bg-slate-950/60 rounded px-2 py-1.5 overflow-x-auto"><code>$ {item.command}</code></pre>
|
||||
)}
|
||||
{!item.pending && item.output != null && (
|
||||
<>
|
||||
{item.duration && <div className="text-[10px] text-slate-600">耗时 {item.duration}</div>}
|
||||
<pre className="text-xs text-slate-300 bg-slate-950/40 rounded px-2 py-1.5 overflow-x-auto max-h-80 whitespace-pre-wrap">{item.output || '(无输出)'}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// LLMConfig.tsx - 大模型设置独立页(AIOps 智能菜单下统一 LLM 配置入口) // Logan.Li
|
||||
// 调用既有 /aiops/llm-config 接口(GET/PUT/POST test),Agent CLI 与智能助手共用同一配置。
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, Save, Wifi, KeyRound, Brain } from 'lucide-react';
|
||||
import { aiApi, type LLMConfigData } from '@/api';
|
||||
|
||||
// OpenAI 兼容提供商列表 // Logan.Li
|
||||
const PROVIDERS = [
|
||||
{ id: 'openai', name: 'OpenAI', endpoint: 'https://api.openai.com/v1', models: 'gpt-4o-mini, gpt-4o' },
|
||||
{ id: 'deepseek', name: 'DeepSeek', endpoint: 'https://api.deepseek.com/v1', models: 'deepseek-chat, deepseek-reasoner' },
|
||||
{ id: 'qwen', name: '通义千问 (DashScope)', endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1', models: 'qwen-plus, qwen-turbo, qwen-max' },
|
||||
{ id: 'moonshot', name: 'Moonshot 月之暗面', endpoint: 'https://api.moonshot.cn/v1', models: 'moonshot-v1-8k, moonshot-v1-32k' },
|
||||
{ id: 'ollama', name: 'Ollama 本地', endpoint: 'http://localhost:11434/v1', models: 'qwen2.5:7b, llama3.2' },
|
||||
{ id: 'custom', name: '自定义 (OpenAI 兼容)', endpoint: '', models: '' },
|
||||
];
|
||||
|
||||
const defaultCfg: LLMConfigData = {
|
||||
provider: 'deepseek', model: 'deepseek-chat', api_key: '',
|
||||
endpoint: 'https://api.deepseek.com/v1', temperature: 0.3, max_tokens: 4096,
|
||||
system_prompt: '', enabled: false,
|
||||
};
|
||||
|
||||
export default function LLMConfigPage() {
|
||||
const [cfg, setCfg] = useState<LLMConfigData>(defaultCfg);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try { setCfg({ ...defaultCfg, ...(await aiApi.getConfig()) }); }
|
||||
catch { /* 首次无配置,使用默认 */ }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try { await aiApi.saveConfig(cfg); toast.success('配置已保存'); }
|
||||
catch { toast.error('保存失败'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
const test = async () => {
|
||||
setTesting(true);
|
||||
try { await aiApi.testConnection(); toast.success('连接成功'); }
|
||||
catch { /* 拦截器已提示 */ }
|
||||
finally { setTesting(false); }
|
||||
};
|
||||
const onProvider = (p: string) => {
|
||||
const prov = PROVIDERS.find((x) => x.id === p);
|
||||
setCfg({ ...cfg, provider: p, endpoint: prov?.endpoint || cfg.endpoint });
|
||||
};
|
||||
const set = <K extends keyof LLMConfigData>(k: K, v: LLMConfigData[K]) => setCfg({ ...cfg, [k]: v });
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center h-full text-slate-400"><Loader2 className="w-5 h-5 animate-spin mr-2" />加载配置...</div>;
|
||||
}
|
||||
const prov = PROVIDERS.find((x) => x.id === cfg.provider);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-4 space-y-4">
|
||||
<div className="flex items-center gap-2 text-slate-200">
|
||||
<Brain className="w-5 h-5 text-blue-400" />
|
||||
<span className="text-lg font-medium">大模型设置</span>
|
||||
<span className="text-xs text-slate-500">— Agent CLI 与智能助手共用此配置</span>
|
||||
</div>
|
||||
|
||||
<Card className="bg-slate-900/60 border-slate-800">
|
||||
<CardHeader><CardTitle className="text-slate-200 text-base">模型与凭证</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">提供商</Label>
|
||||
<Select value={cfg.provider} onValueChange={onProvider}>
|
||||
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-200"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
{PROVIDERS.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">模型</Label>
|
||||
<Input value={cfg.model} onChange={(e) => set('model', e.target.value)}
|
||||
placeholder={prov?.models || '模型名'} className="bg-slate-800 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 flex items-center gap-1"><KeyRound className="w-3.5 h-3.5" />API Key</Label>
|
||||
<Input type="password" value={cfg.api_key} onChange={(e) => set('api_key', e.target.value)}
|
||||
placeholder="sk-..." className="bg-slate-800 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">接口地址(Endpoint)</Label>
|
||||
<Input value={cfg.endpoint} onChange={(e) => set('endpoint', e.target.value)}
|
||||
placeholder="https://api.xxx.com/v1" className="bg-slate-800 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-slate-900/60 border-slate-800">
|
||||
<CardHeader><CardTitle className="text-slate-200 text-base">生成参数</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">温度(Temperature)</Label>
|
||||
<Input type="number" step="0.1" min="0" max="2" value={cfg.temperature}
|
||||
onChange={(e) => set('temperature', Number(e.target.value))}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">最大输出 Token</Label>
|
||||
<Input type="number" min="256" value={cfg.max_tokens}
|
||||
onChange={(e) => set('max_tokens', Number(e.target.value))}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">系统提示词(可选,留空使用内置运维助手角色)</Label>
|
||||
<textarea value={cfg.system_prompt} onChange={(e) => set('system_prompt', e.target.value)} rows={3}
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-md px-3 py-2 text-sm text-slate-200 resize-none" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-slate-300">启用 LLM 服务</Label>
|
||||
<Switch checked={cfg.enabled} onCheckedChange={(v) => set('enabled', v)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={save} disabled={saving} className="bg-blue-600 hover:bg-blue-700">
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Save className="w-4 h-4 mr-1" />} 保存配置
|
||||
</Button>
|
||||
<Button variant="outline" onClick={test} disabled={testing || !cfg.enabled}
|
||||
className="border-slate-700 text-slate-300">
|
||||
{testing ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Wifi className="w-4 h-4 mr-1" />} 测试连接
|
||||
</Button>
|
||||
{!cfg.enabled && <span className="text-xs text-slate-500">请先保存并启用后再测试</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -144,6 +144,8 @@ const menuItems: MenuItem[] = [
|
||||
{ id: 'ai-assistant', name: '智能助手', icon: Bot, path: '/ai-assistant', permission: 'aiops:assistant' },
|
||||
{ id: 'ai-alert-analyzer', name: '告警分析', icon: AlertTriangle, path: '/ai-alert-analyzer', permission: 'aiops:analysis' },
|
||||
{ id: 'ai-predictive', name: '容量预测', icon: BarChart3, path: '/ai-predictive', permission: 'aiops:forecast' },
|
||||
{ id: 'ai-agent-cli', name: 'Agent CLI', icon: Terminal, path: '/ai-agent-cli', permission: 'aiops:agent-cli' },
|
||||
{ id: 'ai-llm-config', name: '大模型设置', icon: Settings, path: '/ai-llm-config', permission: 'aiops:llm-config' },
|
||||
],
|
||||
},
|
||||
{ id: 'approval', name: '资源申请', icon: CheckSquare, permission: 'approval:apply',
|
||||
|
||||
Reference in new issue
Block a user