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:
attackerandClaude committed 2026-06-27 09:55:17 +08:00
1 parent f2069fe3f0
commit 92a2247390
37 files changed
+6449 -6

No files matched your search

+4
View File
@@ -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>} />
+62
View File
@@ -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)}`;
}
+1
View File
@@ -12,3 +12,4 @@ export * from './agent';
export * from './cloud';
export * from './system';
export * from './ai';
export * from './agentCli';
+379
View File
@@ -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>
);
}
+149
View File
@@ -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',