Merge origin/master; keep LLM probe in refactored config UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xin Wang
2026-07-18 09:45:01 +08:00
4 changed files with 556 additions and 431 deletions

View File

@@ -6,7 +6,7 @@
import React, { useState, useEffect, useMemo } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import Header from './components/Header';
import RuleManagement from './components/RuleManagement';
import RuleConfiguration from './components/RuleConfiguration';
import SandboxSimulation from './components/SandboxSimulation';
import ToastContainer, { Toast } from './components/ToastContainer';
import {
@@ -229,7 +229,7 @@ export default function App() {
/>
{/* Main Container Area */}
<main className="flex-1 max-w-7xl w-full mx-auto px-4 py-6 sm:px-6 overflow-hidden">
<main className="mx-auto w-full max-w-[1600px] flex-1 overflow-hidden px-4 py-6 sm:px-6">
<AnimatePresence mode="wait">
{activeTab === 'rules' ? (
<motion.div
@@ -240,7 +240,7 @@ export default function App() {
transition={{ duration: 0.18, ease: 'easeOut' }}
className="h-full"
>
<RuleManagement
<RuleConfiguration
rules={rules}
setRules={handleUpdateRules}
matcher={transferMatcher(ruleSets)}

View File

@@ -4,19 +4,7 @@
*/
import { useState, type ReactNode } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import {
Bot,
Braces,
ChevronRight,
KeyRound,
LoaderCircle,
Save,
Settings2,
Sparkles,
Wifi,
X,
} from 'lucide-react';
import { Bot, Braces, KeyRound, LoaderCircle, RotateCcw, Sparkles, Wifi } from 'lucide-react';
import { ApiError, probeLlmConnection } from '../api';
import type { LlmConfig } from '../types';
@@ -51,40 +39,39 @@ export default function LlmConfiguration({
onChange,
addToast,
}: LlmConfigurationProps) {
const [isOpen, setIsOpen] = useState(false);
const [draft, setDraft] = useState<LlmConfig>(config);
const [errors, setErrors] = useState<Record<string, string>>({});
const [probing, setProbing] = useState(false);
const [probeResult, setProbeResult] = useState<string | null>(null);
const openDrawer = () => {
setDraft(config);
setErrors({});
setProbeResult(null);
setIsOpen(true);
const update = <Key extends keyof LlmConfig,>(key: Key, value: LlmConfig[Key]) => {
onChange({ ...config, [key]: value });
};
const restorePrompt = () => {
update('prompt', DEFAULT_PROMPT);
addToast('已恢复默认提示词,保存并发布后生效', 'info');
};
const validateConnectionFields = (): boolean => {
const nextErrors: Record<string, string> = {};
if (!draft.baseUrl.trim()) nextErrors.baseUrl = '请填写模型服务地址';
if (!config.baseUrl.trim()) {
addToast('请填写模型服务地址', 'warning');
return false;
}
try {
const url = new URL(draft.baseUrl);
const url = new URL(config.baseUrl);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error();
} catch {
nextErrors.baseUrl = '请填写有效的 HTTP(S) 地址';
addToast('请填写有效的 HTTP(S) 地址', 'warning');
return false;
}
if (!draft.model.trim()) nextErrors.model = '请填写模型名称';
if (draft.timeoutMs < 300 || draft.timeoutMs > 30000) {
nextErrors.timeoutMs = '超时时间需在 30030000ms 之间';
if (!config.model.trim()) {
addToast('请填写模型名称', 'warning');
return false;
}
setErrors(current => {
const merged = { ...current };
delete merged.baseUrl;
delete merged.model;
delete merged.timeoutMs;
return { ...merged, ...nextErrors };
});
return Object.keys(nextErrors).length === 0;
if (config.timeoutMs < 300 || config.timeoutMs > 30000) {
addToast('超时时间需在 30030000ms 之间', 'warning');
return false;
}
return true;
};
const testConnection = async () => {
@@ -93,9 +80,9 @@ export default function LlmConfiguration({
setProbeResult(null);
try {
const result = await probeLlmConnection({
baseUrl: draft.baseUrl.trim().replace(/\/+$/, ''),
model: draft.model.trim(),
timeoutMs: draft.timeoutMs,
baseUrl: config.baseUrl.trim().replace(/\/+$/, ''),
model: config.model.trim(),
timeoutMs: config.timeoutMs,
});
const message = `连通成功 · ${result.latencyMs}ms · 模型 ${result.model} · 回复:${result.reply}`;
setProbeResult(message);
@@ -109,309 +96,193 @@ export default function LlmConfiguration({
}
};
const saveDraft = () => {
const nextErrors: Record<string, string> = {};
if (!draft.baseUrl.trim()) nextErrors.baseUrl = '请填写模型服务地址';
try {
const url = new URL(draft.baseUrl);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error();
} catch {
nextErrors.baseUrl = '请填写有效的 HTTP(S) 地址';
}
if (!draft.model.trim()) nextErrors.model = '请填写模型名称';
if (draft.timeoutMs < 300 || draft.timeoutMs > 30000) {
nextErrors.timeoutMs = '超时时间需在 30030000ms 之间';
}
if (draft.maxOutputTokens < 8 || draft.maxOutputTokens > 256) {
nextErrors.maxOutputTokens = '最大输出需在 8256 Token 之间';
}
const missingVariable = ['{{rules}}', '{{alerted_event_ids}}', '{{conversation}}']
.find(variable => !draft.prompt.includes(variable));
if (missingVariable) {
nextErrors.prompt = `提示词需要包含 ${missingVariable}`;
}
if (Object.keys(nextErrors).length > 0) {
setErrors(nextErrors);
return;
}
onChange({
...draft,
baseUrl: draft.baseUrl.trim().replace(/\/+$/, ''),
model: draft.model.trim(),
prompt: draft.prompt.trim(),
});
setIsOpen(false);
addToast('模型与提示词已加入当前草稿,点击“保存并发布”后生效', 'success');
};
return (
<>
<section className="overflow-hidden rounded-md border border-slate-200 bg-white shadow-3xs">
<div className="flex flex-col gap-4 px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-5">
<section className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-3xs">
<div className="flex flex-col gap-4 border-b border-slate-200 px-6 py-5 xl:flex-row xl:items-center xl:justify-between">
<div className="flex min-w-0 items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-slate-900 text-white">
<Sparkles className="h-4 w-4" />
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-slate-950 text-white">
<Sparkles className="h-4.5 w-4.5" />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-sm font-bold text-slate-950"></h2>
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm font-bold text-slate-950"></h2>
<span className="rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-widest text-violet-700">
LLM
OpenAI Compatible
</span>
</div>
<p className="mt-1 text-xs leading-5 text-slate-500">
ID ID
<p className="mt-1 text-[11px] leading-5 text-slate-500">
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
<Stat label="当前模型" value={config.model} mono />
<Stat label="启用规则" value={`${enabledRuleCount}`} mono />
<Stat label="输出协议" value='["E001"]' mono />
<div className="grid gap-2 sm:grid-cols-3">
<Stat label="当前模型" value={config.model || '未配置'} />
<Stat label="启用规则" value={`${enabledRuleCount}`} />
<Stat label="输出协议" value='["E001"]' />
</div>
</div>
<div className="space-y-5 p-6">
<div className="grid gap-5 md:grid-cols-2">
<Field label="模型服务地址" hint="兼容 OpenAI Chat Completions 的服务根地址。">
<input
value={config.baseUrl}
onChange={event => update('baseUrl', event.target.value)}
placeholder="https://api.openai.com/v1"
className={inputClass()}
/>
</Field>
<Field label="模型名称" hint="填写模型服务实际支持的 model 标识。">
<input
value={config.model}
onChange={event => update('model', event.target.value)}
placeholder="gpt-4.1-mini"
className={`${inputClass()} font-mono`}
/>
</Field>
<Field label="调用超时(毫秒)" hint="允许范围 30030000ms。">
<input
type="number"
min={300}
max={30000}
value={config.timeoutMs}
onChange={event => update('timeoutMs', Number(event.target.value))}
className={`${inputClass()} font-mono`}
/>
</Field>
<Field label="最大输出 Token" hint="短 ID 数组建议保持在 8256 Token。">
<input
type="number"
min={8}
max={256}
value={config.maxOutputTokens}
onChange={event => update('maxOutputTokens', Number(event.target.value))}
className={`${inputClass()} font-mono`}
/>
</Field>
</div>
<div className="flex items-start gap-2.5 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-[10px] leading-5 text-amber-800">
<KeyRound className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<p>
API Key
<code className="mx-1 rounded bg-amber-100 px-1 font-mono font-bold">LLM_API_KEY</code>
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<button
type="button"
onClick={openDrawer}
className="inline-flex h-[43px] items-center gap-1.5 rounded-md border border-slate-900 bg-slate-900 px-3.5 text-xs font-bold text-white transition hover:bg-slate-800"
onClick={() => void testConnection()}
disabled={probing}
className="inline-flex items-center justify-center gap-1.5 rounded-md border border-slate-300 bg-white px-3.5 py-2 text-xs font-bold text-slate-800 transition hover:border-slate-900 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
>
<Settings2 className="h-3.5 w-3.5" />
<span></span>
<ChevronRight className="h-3.5 w-3.5 text-slate-400" />
{probing ? (
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
) : (
<Wifi className="h-3.5 w-3.5" />
)}
{probing ? '测试中…' : '测试连接'}
</button>
<p className="text-[10px] text-slate-400">
使/ LLM_API_KEY
</p>
</div>
{probeResult && (
<p className="rounded border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-[10px] leading-4 text-slate-700">
{probeResult}
</p>
)}
</div>
</section>
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.3 }}
exit={{ opacity: 0 }}
onClick={() => setIsOpen(false)}
className="fixed inset-0 z-50 bg-slate-950 backdrop-blur-3xs"
/>
<section className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-3xs">
<div className="flex flex-col gap-3 border-b border-slate-200 px-6 py-5 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-slate-200 bg-slate-50">
<Bot className="h-4 w-4 text-slate-700" />
</div>
<div>
<h2 className="text-sm font-bold text-slate-950"></h2>
<p className="mt-1 max-w-2xl text-[11px] leading-5 text-slate-500">
便 KV Cache
</p>
</div>
</div>
<button
type="button"
onClick={restorePrompt}
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-slate-200 bg-white px-3 py-2 text-[10px] font-bold text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900"
>
<RotateCcw className="h-3 w-3" />
</button>
</div>
<motion.aside
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 24, stiffness: 220 }}
className="fixed right-0 top-0 z-50 flex h-full w-full max-w-2xl flex-col border-l border-slate-200 bg-white shadow-2xl"
>
<header className="flex items-center justify-between border-b border-slate-200 bg-slate-50/50 px-6 py-4">
<div>
<div className="flex items-center gap-2">
<Bot className="h-4 w-4 text-slate-700" />
<h2 className="text-sm font-bold uppercase tracking-wide text-slate-900">
</h2>
</div>
<p className="mt-1 text-[11px] text-slate-400">
</p>
</div>
<button
type="button"
onClick={() => setIsOpen(false)}
className="rounded-md p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-900"
>
<X className="h-4 w-4" />
</button>
</header>
<div className="space-y-4 p-6">
<textarea
value={config.prompt}
onChange={event => update('prompt', event.target.value)}
rows={19}
spellCheck={false}
className="w-full resize-y rounded-md border border-slate-800 bg-slate-950 p-4 font-mono text-[11px] leading-5 text-slate-100 outline-none transition focus:border-slate-500"
/>
<div className="flex-1 space-y-6 overflow-y-auto p-6">
<section className="space-y-4">
<div>
<h3 className="text-xs font-bold text-slate-900"></h3>
<p className="mt-1 text-[11px] text-slate-400">
OpenAI Chat Completions
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400"></span>
{['{{rules}}', '{{alerted_event_ids}}', '{{conversation}}'].map(variable => (
<code
key={variable}
className={`rounded border px-2 py-1 font-mono text-[10px] font-bold ${
config.prompt.includes(variable)
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-rose-200 bg-rose-50 text-rose-700'
}`}
>
{variable}
</code>
))}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="模型服务地址" error={errors.baseUrl}>
<input
value={draft.baseUrl}
onChange={event => setDraft(current => ({ ...current, baseUrl: event.target.value }))}
placeholder="https://api.openai.com/v1"
className={inputClass(Boolean(errors.baseUrl))}
/>
</Field>
<Field label="模型名称" error={errors.model}>
<input
value={draft.model}
onChange={event => setDraft(current => ({ ...current, model: event.target.value }))}
placeholder="gpt-4.1-mini"
className={`${inputClass(Boolean(errors.model))} font-mono`}
/>
</Field>
<Field label="调用超时(毫秒)" error={errors.timeoutMs}>
<input
type="number"
min={300}
max={30000}
value={draft.timeoutMs}
onChange={event => setDraft(current => ({
...current,
timeoutMs: Number(event.target.value),
}))}
className={`${inputClass(Boolean(errors.timeoutMs))} font-mono`}
/>
</Field>
<Field label="最大输出 Token" error={errors.maxOutputTokens}>
<input
type="number"
min={8}
max={256}
value={draft.maxOutputTokens}
onChange={event => setDraft(current => ({
...current,
maxOutputTokens: Number(event.target.value),
}))}
className={`${inputClass(Boolean(errors.maxOutputTokens))} font-mono`}
/>
</Field>
</div>
<div className="flex items-start gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2.5 text-[10px] leading-4 text-amber-800">
<KeyRound className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<p>
API Key
<code className="mx-1 rounded bg-amber-100 px-1 font-mono font-bold">LLM_API_KEY</code>
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<button
type="button"
onClick={() => void testConnection()}
disabled={probing}
className="inline-flex items-center justify-center gap-1.5 rounded-md border border-slate-300 bg-white px-3.5 py-2 text-xs font-bold text-slate-800 transition hover:border-slate-900 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
>
{probing ? (
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
) : (
<Wifi className="h-3.5 w-3.5" />
)}
{probing ? '测试中…' : '测试连接'}
</button>
<p className="text-[10px] text-slate-400">
使/ LLM_API_KEY
</p>
</div>
{probeResult && (
<p className="rounded border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-[10px] leading-4 text-slate-700">
{probeResult}
</p>
)}
</section>
<section className="space-y-3 border-t border-slate-200 pt-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 className="text-xs font-bold text-slate-900"></h3>
<p className="mt-1 text-[11px] leading-5 text-slate-400">
便 KV Cache
</p>
</div>
<button
type="button"
onClick={() => setDraft(current => ({ ...current, prompt: DEFAULT_PROMPT }))}
className="shrink-0 text-[10px] font-bold text-slate-500 transition hover:text-slate-900"
>
</button>
</div>
<textarea
value={draft.prompt}
onChange={event => setDraft(current => ({ ...current, prompt: event.target.value }))}
rows={19}
spellCheck={false}
className={`w-full resize-y rounded-md border bg-slate-950 p-4 font-mono text-[11px] leading-5 text-slate-100 outline-none transition ${
errors.prompt ? 'border-rose-400' : 'border-slate-800 focus:border-slate-600'
}`}
/>
{errors.prompt && <p className="text-[10px] font-bold text-rose-500">{errors.prompt}</p>}
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400"></span>
{['{{rules}}', '{{alerted_event_ids}}', '{{conversation}}'].map(variable => (
<code
key={variable}
className="rounded border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-[10px] font-bold text-slate-600"
>
{variable}
</code>
))}
</div>
</section>
<section className="rounded-md border border-slate-200 bg-slate-50 p-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded border border-slate-200 bg-white">
<Braces className="h-4 w-4 text-slate-600" />
</div>
<div>
<h3 className="text-xs font-bold text-slate-900"></h3>
<p className="mt-1 text-[10px] leading-4 text-slate-500">
JSON ID ID
</p>
<code className="mt-2 block font-mono text-[11px] font-bold text-slate-800">
[&quot;E001&quot;,&quot;E003&quot;] / []
</code>
</div>
</div>
</section>
</div>
<footer className="flex items-center justify-end gap-2 border-t border-slate-200 bg-slate-50 px-6 py-4">
<button
type="button"
onClick={() => setIsOpen(false)}
className="rounded border border-slate-200 bg-white px-4 py-2 text-xs font-bold text-slate-700 transition hover:bg-slate-50"
>
</button>
<button
type="button"
onClick={saveDraft}
className="inline-flex items-center gap-1.5 rounded bg-slate-900 px-4 py-2 text-xs font-bold text-white transition hover:bg-slate-800"
>
<Save className="h-3.5 w-3.5" />
稿
</button>
</footer>
</motion.aside>
</>
)}
</AnimatePresence>
</>
<div className="flex items-start gap-3 rounded-md border border-slate-200 bg-slate-50 p-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded border border-slate-200 bg-white">
<Braces className="h-4 w-4 text-slate-600" />
</div>
<div>
<h3 className="text-xs font-bold text-slate-900"></h3>
<p className="mt-1 text-[10px] leading-4 text-slate-500">
ID ID ID
</p>
<code className="mt-2 block font-mono text-[11px] font-bold text-slate-800">
[&quot;E001&quot;,&quot;E003&quot;] / []
</code>
</div>
</div>
</div>
</section>
</div>
);
}
function Stat({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2">
<div className="min-w-0 rounded-md border border-slate-200 bg-slate-50 px-3 py-2">
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-400">{label}</p>
<p className={`mt-0.5 max-w-40 truncate text-[11px] font-bold text-slate-800 ${mono ? 'font-mono' : ''}`}>
{value}
</p>
<p className="mt-0.5 max-w-36 truncate font-mono text-[11px] font-bold text-slate-800">{value}</p>
</div>
);
}
function Field({
label,
error,
hint,
children,
}: {
label: string;
error?: string;
hint: string;
children: ReactNode;
}) {
return (
@@ -420,13 +291,11 @@ function Field({
{label} <span className="text-rose-500">*</span>
</span>
{children}
{error && <span className="block text-[10px] font-bold text-rose-500">{error}</span>}
<span className="block text-[10px] leading-4 text-slate-400">{hint}</span>
</label>
);
}
function inputClass(error: boolean) {
return `w-full rounded border px-3 py-2 text-xs text-slate-900 outline-none transition ${
error ? 'border-rose-300' : 'border-slate-200 focus:border-slate-900'
}`;
function inputClass() {
return 'w-full rounded-md border border-slate-200 bg-white px-3 py-2.5 text-xs text-slate-900 outline-none transition focus:border-slate-900';
}

View File

@@ -0,0 +1,366 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import { useMemo, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import {
BookOpen,
Bot,
Check,
ChevronRight,
Cpu,
Save,
Sparkles,
TextSearch,
} from 'lucide-react';
import type { LlmConfig, Rule } from '../types';
import LlmConfiguration from './LlmConfiguration';
import RuleManagement from './RuleManagement';
type ConfigurationSection = 'rules' | 'llm' | 'matcher';
interface RuleConfigurationProps {
rules: Rule[];
setRules: (rules: Rule[]) => void;
matcher: 'ac-keyword' | 'llm';
setMatcher: (matcher: 'ac-keyword' | 'llm') => void;
llmConfig: LlmConfig;
setLlmConfig: (config: LlmConfig) => void;
onSavePublish: () => void;
isDirty: boolean;
loading: boolean;
saving: boolean;
addToast: (message: string, type: 'success' | 'warning' | 'info') => void;
}
const NAV_ITEMS: Array<{
id: ConfigurationSection;
label: string;
description: string;
icon: typeof BookOpen;
}> = [
{ id: 'rules', label: '规则库', description: '事件、短 ID 与关键词', icon: BookOpen },
{ id: 'llm', label: '大模型', description: '模型连接与提示词', icon: Bot },
{ id: 'matcher', label: '匹配算法', description: '选择运行时识别方式', icon: Cpu },
];
const SECTION_COPY: Record<ConfigurationSection, { title: string; description: string }> = {
rules: {
title: '规则库',
description: '管理事件名称、短事件 ID 和辅助关键词。',
},
llm: {
title: '大模型',
description: '配置模型连接、输出上限和事件判断提示词。',
},
matcher: {
title: '匹配算法',
description: '选择当前发布版本实际使用的事件识别方式。',
},
};
export default function RuleConfiguration({
rules,
setRules,
matcher,
setMatcher,
llmConfig,
setLlmConfig,
onSavePublish,
isDirty,
loading,
saving,
addToast,
}: RuleConfigurationProps) {
const [activeSection, setActiveSection] = useState<ConfigurationSection>('rules');
const enabledRuleCount = useMemo(() => rules.filter(rule => rule.enabled).length, [rules]);
const copy = SECTION_COPY[activeSection];
const selectMatcher = (nextMatcher: 'ac-keyword' | 'llm') => {
if (nextMatcher === matcher) return;
setMatcher(nextMatcher);
addToast(
nextMatcher === 'llm'
? '已选择大模型事件匹配,保存并发布后生效'
: '已选择关键词匹配,保存并发布后生效',
'info',
);
};
if (loading) {
return (
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-slate-200 bg-white text-xs font-bold tracking-wider text-slate-400">
</div>
);
}
return (
<div className="space-y-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold tracking-tight text-slate-950 sm:text-2xl"></h1>
{isDirty && (
<span className="rounded border border-amber-200 bg-amber-50 px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest text-amber-700">
</span>
)}
</div>
<p className="mt-1 text-xs leading-5 text-slate-500">
</p>
</div>
<button
type="button"
onClick={onSavePublish}
disabled={!isDirty || saving}
className={`relative inline-flex h-10 items-center justify-center gap-1.5 rounded-md px-4 text-xs font-bold text-white transition ${
isDirty && !saving
? 'bg-slate-950 hover:bg-slate-800'
: 'cursor-not-allowed bg-slate-200 text-slate-400'
}`}
>
<Save className="h-3.5 w-3.5" />
<span>{saving ? '发布中…' : '保存并发布'}</span>
{isDirty && !saving && (
<span className="absolute -right-1 -top-1 flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-rose-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-rose-500" />
</span>
)}
</button>
</div>
<div className="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)] lg:items-start">
<aside className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-3xs lg:sticky lg:top-24">
<div className="border-b border-slate-200 px-4 py-3.5">
<p className="text-[9px] font-bold uppercase tracking-[0.18em] text-slate-400"></p>
</div>
<nav className="grid grid-cols-3 gap-1 p-2 lg:grid-cols-1" aria-label="规则配置导航">
{NAV_ITEMS.map(item => {
const Icon = item.icon;
const selected = activeSection === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => setActiveSection(item.id)}
aria-current={selected ? 'page' : undefined}
className={`group flex min-w-0 flex-col items-center justify-center gap-1.5 rounded-md px-2 py-2.5 text-center transition lg:flex-row lg:justify-start lg:gap-3 lg:px-3 lg:py-3 lg:text-left ${
selected
? 'bg-slate-950 text-white shadow-sm'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-950'
}`}
>
<span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md border ${
selected
? 'border-white/15 bg-white/10 text-white'
: 'border-slate-200 bg-white text-slate-500 group-hover:text-slate-900'
}`}>
<Icon className="h-4 w-4" />
</span>
<span className="hidden min-w-0 flex-1 lg:block">
<span className="block text-xs font-bold">{item.label}</span>
<span className={`mt-0.5 block truncate text-[9px] ${
selected ? 'text-slate-300' : 'text-slate-400'
}`}>
{item.description}
</span>
</span>
<span className="truncate text-[10px] font-bold lg:hidden">{item.label}</span>
<ChevronRight className={`hidden h-3.5 w-3.5 lg:block ${
selected ? 'text-slate-400' : 'text-slate-300'
}`} />
</button>
);
})}
</nav>
<div className="hidden border-t border-slate-200 bg-slate-50/70 p-4 lg:block">
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-400"></p>
<div className="mt-2 flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${matcher === 'llm' ? 'bg-violet-500' : 'bg-blue-500'}`} />
<span className="text-[11px] font-bold text-slate-800">
{matcher === 'llm' ? '大模型事件匹配' : '关键词匹配'}
</span>
</div>
<p className="mt-1 text-[9px] leading-4 text-slate-400">{enabledRuleCount} </p>
</div>
</aside>
<section className="min-w-0">
<div className="mb-4">
<h2 className="text-lg font-bold text-slate-950">{copy.title}</h2>
<p className="mt-1 text-[11px] leading-5 text-slate-500">{copy.description}</p>
</div>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={activeSection}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.14, ease: 'easeOut' }}
>
{activeSection === 'rules' && (
<RuleManagement
rules={rules}
setRules={setRules}
matcher={matcher}
addToast={addToast}
/>
)}
{activeSection === 'llm' && (
<LlmConfiguration
config={llmConfig}
enabledRuleCount={enabledRuleCount}
onChange={setLlmConfig}
addToast={addToast}
/>
)}
{activeSection === 'matcher' && (
<MatcherConfiguration matcher={matcher} onSelect={selectMatcher} />
)}
</motion.div>
</AnimatePresence>
</section>
</div>
</div>
);
}
function MatcherConfiguration({
matcher,
onSelect,
}: {
matcher: 'ac-keyword' | 'llm';
onSelect: (matcher: 'ac-keyword' | 'llm') => void;
}) {
return (
<div className="space-y-5">
<section className="rounded-lg border border-slate-200 bg-white p-6 shadow-3xs">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-slate-950 text-white">
<Cpu className="h-4.5 w-4.5" />
</div>
<div>
<h3 className="text-sm font-bold text-slate-950"></h3>
<p className="mt-1 text-[11px] leading-5 text-slate-500">
线
</p>
</div>
</div>
<div className="mt-6 grid gap-4 xl:grid-cols-2">
<MatcherCard
selected={matcher === 'ac-keyword'}
icon={TextSearch}
badge="AC"
title="关键词匹配"
description="使用 AC 自动机扫描最近通话文本,结果确定、速度快,不产生模型调用成本。"
details={['精确包含任一关键词即命中', '适合边界明确的品牌和业务词', '无需配置大模型服务']}
onClick={() => onSelect('ac-keyword')}
/>
<MatcherCard
selected={matcher === 'llm'}
icon={Sparkles}
badge="LLM"
title="大模型事件匹配"
description="将规则、已发送事件和共享上下文交给模型判断,适合语义、否定和转述场景。"
details={['模型只返回短事件 ID 数组', '后端负责名称还原和最终去重', '需要先完成“大模型”页面配置']}
onClick={() => onSelect('llm')}
/>
</div>
</section>
<section className="rounded-lg border border-slate-200 bg-slate-50/70 p-5">
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-400">稿</p>
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center">
<span className={`inline-flex w-fit items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-[10px] font-bold ${
matcher === 'llm'
? 'border-violet-200 bg-violet-50 text-violet-700'
: 'border-blue-200 bg-blue-50 text-blue-700'
}`}>
<Check className="h-3 w-3" />
{matcher === 'llm' ? '大模型事件匹配' : '关键词匹配'}
</span>
<p className="text-[10px] leading-5 text-slate-500">
ID
</p>
</div>
</section>
</div>
);
}
function MatcherCard({
selected,
icon: Icon,
badge,
title,
description,
details,
onClick,
}: {
selected: boolean;
icon: typeof BookOpen;
badge: string;
title: string;
description: string;
details: string[];
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`relative rounded-lg border p-5 text-left transition ${
selected
? 'border-slate-950 bg-slate-950 text-white shadow-md'
: 'border-slate-200 bg-white text-slate-900 hover:border-slate-400 hover:shadow-sm'
}`}
>
<div className="flex items-start justify-between gap-3">
<span className={`flex h-10 w-10 items-center justify-center rounded-md border ${
selected ? 'border-white/15 bg-white/10' : 'border-slate-200 bg-slate-50 text-slate-700'
}`}>
<Icon className="h-4.5 w-4.5" />
</span>
<span className={`rounded border px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest ${
selected
? 'border-white/15 bg-white/10 text-slate-200'
: 'border-slate-200 bg-slate-50 text-slate-500'
}`}>
{badge}
</span>
</div>
<div className="mt-4 flex items-center gap-2">
<h4 className="text-sm font-bold">{title}</h4>
{selected && (
<span className="inline-flex items-center gap-1 rounded bg-emerald-400/15 px-1.5 py-0.5 text-[9px] font-bold text-emerald-300">
<Check className="h-2.5 w-2.5" />
</span>
)}
</div>
<p className={`mt-2 text-[11px] leading-5 ${selected ? 'text-slate-300' : 'text-slate-500'}`}>
{description}
</p>
<ul className={`mt-4 space-y-2 border-t pt-4 text-[10px] ${
selected ? 'border-white/10 text-slate-300' : 'border-slate-200 text-slate-500'
}`}>
{details.map(detail => (
<li key={detail} className="flex items-start gap-2">
<Check className={`mt-0.5 h-3 w-3 shrink-0 ${selected ? 'text-emerald-300' : 'text-emerald-600'}`} />
<span>{detail}</span>
</li>
))}
</ul>
</button>
);
}

View File

@@ -7,7 +7,6 @@ import React, { useState, useMemo, useRef } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import {
Plus,
Save,
Search,
X,
Edit,
@@ -18,14 +17,13 @@ import {
Download,
FileDown
} from 'lucide-react';
import { LlmConfig, Rule } from '../types';
import { Rule } from '../types';
import {
downloadRuleTemplate,
exportRulesToExcel,
importRulesFromExcel,
} from '../rule-excel';
import { generateEventId, generateModelEventId } from '../rule-id';
import LlmConfiguration from './LlmConfiguration';
type MatcherMode = 'keyword' | 'llm';
@@ -33,13 +31,6 @@ interface RuleManagementProps {
rules: Rule[];
setRules: (rules: Rule[]) => void;
matcher: 'ac-keyword' | 'llm';
setMatcher: (matcher: 'ac-keyword' | 'llm') => void;
llmConfig: LlmConfig;
setLlmConfig: (config: LlmConfig) => void;
onSavePublish: () => void;
isDirty: boolean;
loading: boolean;
saving: boolean;
addToast: (message: string, type: 'success' | 'warning' | 'info') => void;
}
@@ -47,13 +38,6 @@ export default function RuleManagement({
rules,
setRules,
matcher,
setMatcher,
llmConfig,
setLlmConfig,
onSavePublish,
isDirty,
loading,
saving,
addToast
}: RuleManagementProps) {
const [searchTerm, setSearchTerm] = useState('');
@@ -76,16 +60,6 @@ export default function RuleManagement({
const tagInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleMatcherModeChange = (mode: MatcherMode) => {
setMatcher(mode === 'llm' ? 'llm' : 'ac-keyword');
addToast(
mode === 'keyword'
? '已切换为关键词匹配,大模型配置已隐藏'
: '已切换为大模型事件匹配',
'info',
);
};
const handleTemplateDownload = async () => {
if (excelAction) return;
setExcelAction('template');
@@ -297,22 +271,12 @@ export default function RuleManagement({
// Keyword show tooltips state
const [expandedKeywordsRuleId, setExpandedKeywordsRuleId] = useState<string | null>(null);
if (loading) {
return (
<div className="flex min-h-[360px] items-center justify-center rounded-md border border-slate-200 bg-white text-xs font-bold tracking-wider text-slate-400">
</div>
);
}
return (
<div className="flex flex-col space-y-6">
{/* 1. Header Toolbar area */}
{/* Rule library toolbar */}
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h1 className="text-xl font-bold tracking-tight text-slate-950 sm:text-2xl font-sans">
</h1>
<h3 className="text-sm font-bold text-slate-950"></h3>
<p className="mt-1 text-xs text-slate-500 font-sans">
<span className="font-bold text-slate-950 font-mono">{rules.length}</span> {' '}
<span className="font-bold text-slate-950 font-mono">{activeRulesCount}</span> {' '}
@@ -320,7 +284,7 @@ export default function RuleManagement({
</p>
</div>
<div className="flex items-center space-x-2">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => void handleTemplateDownload()}
@@ -367,84 +331,10 @@ export default function RuleManagement({
<Plus className="h-3.5 w-3.5 text-slate-900" />
<span></span>
</button>
<button
onClick={onSavePublish}
disabled={!isDirty || saving}
className={`relative inline-flex items-center justify-center space-x-1.5 rounded-md px-4 py-2 text-xs font-bold uppercase tracking-wider text-white transition-all ${
isDirty && !saving
? 'bg-slate-900 hover:bg-slate-800 cursor-pointer animate-breathe'
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
}`}
>
<Save className="h-3.5 w-3.5" />
<span>{saving ? '发布中…' : '保存并发布'}</span>
{isDirty && !saving && (
<span className="absolute -top-1 -right-1 flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-rose-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-rose-500"></span>
</span>
)}
</button>
</div>
</div>
{/* 2. Matcher selection */}
<section className="flex flex-col gap-3 rounded-md border border-slate-200 bg-white px-5 py-4 shadow-3xs sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<h2 className="text-xs font-bold text-slate-900"></h2>
<span className={`rounded px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest ${
matcherMode === 'keyword'
? 'bg-blue-50 text-blue-700'
: 'bg-violet-50 text-violet-700'
}`}>
{matcherMode === 'keyword' ? 'Keyword' : 'LLM'}
</span>
</div>
<p className="mt-1 text-[11px] leading-5 text-slate-400">
{matcherMode === 'keyword'
? '按照规则库中的监控关键词进行确定性匹配,不加载模型和提示词配置。'
: '将启用规则与通话上下文注入提示词,由大模型返回命中的短事件 ID。'}
</p>
</div>
<label className="flex shrink-0 items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
</span>
<select
value={matcherMode}
onChange={(event) => handleMatcherModeChange(event.target.value as MatcherMode)}
className="min-w-44 rounded-md border border-slate-200 bg-white px-3 py-2 text-xs font-bold text-slate-800 outline-none transition focus:border-slate-900"
>
<option value="keyword"></option>
<option value="llm"></option>
</select>
</label>
</section>
{/* 3. LLM matcher configuration */}
<AnimatePresence initial={false}>
{matcherMode === 'llm' && (
<motion.div
key="llm-configuration"
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.16, ease: 'easeOut' }}
>
<LlmConfiguration
config={llmConfig}
enabledRuleCount={activeRulesCount}
onChange={setLlmConfig}
addToast={addToast}
/>
</motion.div>
)}
</AnimatePresence>
{/* 4. Searching & Filter Options Bar */}
{/* Searching & filter */}
<div className="flex flex-col space-y-3 rounded-md border border-slate-200 bg-white p-4 shadow-3xs sm:flex-row sm:items-center sm:space-y-0 sm:space-x-4">
<div className="relative flex-1">
<Search className="absolute top-2.5 left-3 h-4 w-4 text-slate-400" />
@@ -468,7 +358,7 @@ export default function RuleManagement({
</div>
{/* 5. High Density Data Table */}
{/* High density data table */}
<div className="overflow-hidden rounded-md border border-slate-200 bg-white shadow-3xs">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left text-xs text-slate-505">