fix(log-center): ES 时间戳双字段兼容 + AI助手按钮去重清理
This commit is contained in:
1 parent
36bd0a6155
commit
084a3be2cf
4 files changed
+74
-207
No files matched your search
@@ -6,23 +6,6 @@ import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
import { aiApi, alertApi, agentApi, dashboardStatsApi, type LLMConfigData, type ChatHistoryItem } from '@/api';
|
||||
import {
|
||||
@@ -40,7 +23,6 @@ import {
|
||||
Brain,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
Wifi,
|
||||
Square,
|
||||
} from 'lucide-react';
|
||||
import type { JsonData } from '@/types/common';
|
||||
@@ -154,9 +136,6 @@ export function AIOpsAssistant() {
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
// 组件卸载时中止进行中的流式请求,避免 setState on unmounted // Logan.Li
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
const [isConfigOpen, setIsConfigOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [config, setConfig] = useState<LLMConfigData>(defaultConfig);
|
||||
const [configReady, setConfigReady] = useState(false); // 已配置且启用
|
||||
const [contextLoading, setContextLoading] = useState<string | null>(null); // 快捷操作拉取平台数据中
|
||||
@@ -209,50 +188,6 @@ export function AIOpsAssistant() {
|
||||
loadHistory();
|
||||
}, []);
|
||||
|
||||
// 切换提供商时自动填充默认 endpoint + 模型 // Logan.Li
|
||||
const handleProviderChange = (provider: string) => {
|
||||
const p = LLM_PROVIDERS.find((x) => x.id === provider);
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
provider,
|
||||
endpoint: p?.defaultEndpoint || prev.endpoint,
|
||||
model: p?.models[0] || prev.model,
|
||||
}));
|
||||
};
|
||||
|
||||
// 保存配置 // Logan.Li
|
||||
const handleSaveConfig = async () => {
|
||||
if (config.enabled && !config.api_key && config.provider !== 'ollama') {
|
||||
toast.error('请填写 API Key');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await aiApi.saveConfig(config);
|
||||
setConfig(saved);
|
||||
setConfigReady(!!(saved.enabled && (saved as JsonData).configured));
|
||||
toast.success('配置已保存');
|
||||
setIsConfigOpen(false);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 测试连接 // Logan.Li
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
await aiApi.testConnection();
|
||||
toast.success('LLM 服务连接成功');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '连接失败');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 发送一条消息的核心逻辑(流式输出,逐步填充助手气泡) // Logan.Li
|
||||
const sendMessage = async (text: string) => {
|
||||
const userMsg: Message = {
|
||||
@@ -367,7 +302,6 @@ export function AIOpsAssistant() {
|
||||
toast.success('已开始新对话');
|
||||
};
|
||||
|
||||
const currentProvider = LLM_PROVIDERS.find((p) => p.id === config.provider);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -383,12 +317,21 @@ export function AIOpsAssistant() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{configReady && (
|
||||
{configReady ? (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
{config.model}
|
||||
</Badge>
|
||||
)}
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-slate-700 text-slate-300 hover:text-purple-400"
|
||||
onClick={() => navigate('/ai-llm-config')}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-1" />
|
||||
模型配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -564,131 +507,6 @@ export function AIOpsAssistant() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* LLM Config Dialog */}
|
||||
<Dialog open={isConfigOpen} onOpenChange={setIsConfigOpen}>
|
||||
<DialogContent className="max-w-2xl bg-slate-900 border-slate-700 text-slate-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle>大模型配置</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
配置 AI 助手使用的大语言模型(支持 OpenAI 兼容接口)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4 max-h-[60vh] overflow-y-auto pr-1">
|
||||
{/* 启用开关 */}
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-700 p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-200">启用 AI 助手</p>
|
||||
<p className="text-xs text-slate-500">关闭后将无法进行对话</p>
|
||||
</div>
|
||||
<Switch checked={config.enabled} onCheckedChange={(v) => setConfig({ ...config, enabled: v })} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-slate-300">提供商</Label>
|
||||
<Select value={config.provider} onValueChange={handleProviderChange}>
|
||||
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-100 mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
{LLM_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id} className="text-slate-100">{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-300">模型</Label>
|
||||
{currentProvider && currentProvider.models.length > 0 ? (
|
||||
<Select value={config.model} onValueChange={(v) => setConfig({ ...config, model: v })}>
|
||||
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-100 mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
{currentProvider.models.map((m) => (
|
||||
<SelectItem key={m} value={m} className="text-slate-100">{m}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={config.model}
|
||||
onChange={(e) => setConfig({ ...config, model: e.target.value })}
|
||||
placeholder="输入模型名称"
|
||||
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-300">API Key {config.provider === 'ollama' && <span className="text-xs text-slate-500">(本地 Ollama 通常无需)</span>}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={config.api_key}
|
||||
onChange={(e) => setConfig({ ...config, api_key: e.target.value })}
|
||||
placeholder="sk-..."
|
||||
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-300">API Endpoint</Label>
|
||||
<Input
|
||||
value={config.endpoint}
|
||||
onChange={(e) => setConfig({ ...config, endpoint: e.target.value })}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
className="bg-slate-800 border-slate-700 text-slate-100 mt-1 font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">基础地址(不含 /chat/completions),OpenAI 兼容格式</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-300">系统提示词(可选)</Label>
|
||||
<textarea
|
||||
value={config.system_prompt}
|
||||
onChange={(e) => setConfig({ ...config, system_prompt: e.target.value })}
|
||||
placeholder="留空则使用内置 AIOps 运维助手角色提示词"
|
||||
className="w-full bg-slate-800 border border-slate-700 text-slate-100 mt-1 rounded-md p-2 text-sm min-h-[70px] resize-y"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-slate-300">Temperature</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={Math.round(config.temperature * 10) / 10}
|
||||
onChange={(e) => setConfig({ ...config, temperature: parseFloat(e.target.value) || 0 })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">值越高回答越创造性</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-300">Max Tokens</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.max_tokens}
|
||||
onChange={(e) => setConfig({ ...config, max_tokens: parseInt(e.target.value) || 2000 })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={handleTest} disabled={testing} className="border-slate-700 text-slate-300">
|
||||
{testing ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Wifi className="h-4 w-4 mr-2" />}
|
||||
测试连接
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setIsConfigOpen(false)} className="border-slate-700 text-slate-300">
|
||||
取消
|
||||
</Button>
|
||||
<Button className="bg-purple-600 hover:bg-purple-700" onClick={handleSaveConfig} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
|
||||
保存配置
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,16 @@ export function renderStepProgress(instance: JsonData) {
|
||||
{isRejected && <XCircle className="h-3 w-3" />}
|
||||
{isCurrent && !isRejected && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
<span>{step.name || step.approver_name || `步骤 ${index + 1}`}</span>
|
||||
{step.step_type && step.step_type !== 'submit' && (
|
||||
<span className={cn(
|
||||
'text-[10px] px-1 py-0 rounded',
|
||||
step.step_type === 'execute'
|
||||
? 'bg-green-500/15 text-green-400'
|
||||
: 'bg-purple-500/15 text-purple-400'
|
||||
)}>
|
||||
{step.step_type === 'execute' ? '执行' : '审核'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={cn('w-4 h-px', isCompleted ? 'bg-emerald-500/30' : 'bg-[var(--dg-border)]')} />
|
||||
|
||||
@@ -166,6 +166,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
|
||||
const [alertDialogOpen, setAlertDialogOpen] = useState(false);
|
||||
const [saveMenuOpen, setSaveMenuOpen] = useState(false);
|
||||
const [hasQueried, setHasQueried] = useState(false);
|
||||
|
||||
// 数据源切换后更新 dsId // Logan.Li
|
||||
useEffect(() => {
|
||||
@@ -194,7 +195,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
const mock = generateMockEntries(effEngine as LogEngine, 120);
|
||||
setEntries(mock); setHistogramData(generateHistogramData(mock)); setTotalCount(mock.length * 14);
|
||||
setQueryTime(Math.round(performance.now() - t0)); setStatTable(generateMockStatTable(effEngine as LogEngine));
|
||||
setClusters(generateMockClusters()); setPage(1); setLoading(false);
|
||||
setClusters(generateMockClusters()); setPage(1); setLoading(false); setHasQueried(true);
|
||||
historyStorage.add({ engine: effEngine as LogEngine, datasourceName: currentSource.name, query, timeRange: timeRange.label, resultCount: mock.length });
|
||||
}, 300);
|
||||
return;
|
||||
@@ -243,10 +244,11 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
setStatTable(generateMockStatTable(effEngine as LogEngine));
|
||||
setClusters(generateMockClusters());
|
||||
setPage(1);
|
||||
setHasQueried(true);
|
||||
historyStorage.add({ engine: effEngine as LogEngine, datasourceName: currentSource.name, query, timeRange: timeRange.label, resultCount: mapped.length });
|
||||
} catch (e: any) {
|
||||
toast({ title: '查询失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
setEntries([]); setTotalCount(0);
|
||||
setEntries([]); setTotalCount(0); setHasQueried(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -373,13 +375,31 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!loading && entries.length === 0 && (
|
||||
{/* 空状态:从未查询 vs 查询返回 0 结果 */}
|
||||
{!loading && entries.length === 0 && !hasQueried && (
|
||||
<div className="border rounded-lg flex flex-col items-center justify-center gap-2" style={{ borderColor: 'var(--dg-border)', minHeight: '300px' }}>
|
||||
<span className="text-sm" style={{ color: 'var(--dg-text-muted)' }}>输入查询语句并点击「查询」查看结果</span>
|
||||
<span className="text-xs font-mono px-3 py-1 rounded" style={{ color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}>{meta.queryPlaceholder}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loading && entries.length === 0 && hasQueried && (
|
||||
<div className="border rounded-lg flex flex-col items-center justify-center gap-3" style={{ borderColor: 'var(--dg-border)', minHeight: '300px' }}>
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--dg-bg-tertiary)' }}>
|
||||
<svg className="h-6 w-6" style={{ color: 'var(--dg-text-muted)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>未找到匹配的日志记录</p>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--dg-text-muted)' }}>
|
||||
查询已执行({queryTime}ms),但当前时间范围和筛选条件下无匹配结果
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={handleRun} className="px-4 py-1.5 rounded text-xs font-medium text-white transition-opacity hover:opacity-90" style={{ background: '#22c55e' }}>
|
||||
<Play className="h-3.5 w-3.5 inline mr-1" />重新查询
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && entries.length === 0 && (
|
||||
|
||||
Reference in new issue
Block a user