Merge origin/master; keep LLM probe in refactored config UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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 = '超时时间需在 300–30000ms 之间';
|
||||
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('超时时间需在 300–30000ms 之间', '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,174 +96,75 @@ 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 = '超时时间需在 300–30000ms 之间';
|
||||
}
|
||||
if (draft.maxOutputTokens < 8 || draft.maxOutputTokens > 256) {
|
||||
nextErrors.maxOutputTokens = '最大输出需在 8–256 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>
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">
|
||||
模型只输出短事件 ID;后端负责 ID 映射、事件名称还原和通话内最终去重。
|
||||
</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 />
|
||||
<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"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
<span>模型与提示词</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
</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"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<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">
|
||||
OpenAI Compatible
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-slate-400">
|
||||
该配置与规则一起保存、发布和版本回溯。
|
||||
<p className="mt-1 text-[11px] leading-5 text-slate-500">
|
||||
配置后端实际调用的模型服务。模型配置和规则一起进入版本历史。
|
||||
</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="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="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="模型服务地址" error={errors.baseUrl}>
|
||||
<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={draft.baseUrl}
|
||||
onChange={event => setDraft(current => ({ ...current, baseUrl: event.target.value }))}
|
||||
value={config.baseUrl}
|
||||
onChange={event => update('baseUrl', event.target.value)}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
className={inputClass(Boolean(errors.baseUrl))}
|
||||
className={inputClass()}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="模型名称" error={errors.model}>
|
||||
<Field label="模型名称" hint="填写模型服务实际支持的 model 标识。">
|
||||
<input
|
||||
value={draft.model}
|
||||
onChange={event => setDraft(current => ({ ...current, model: event.target.value }))}
|
||||
value={config.model}
|
||||
onChange={event => update('model', event.target.value)}
|
||||
placeholder="gpt-4.1-mini"
|
||||
className={`${inputClass(Boolean(errors.model))} font-mono`}
|
||||
className={`${inputClass()} font-mono`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="调用超时(毫秒)" error={errors.timeoutMs}>
|
||||
<Field label="调用超时(毫秒)" hint="允许范围 300–30000ms。">
|
||||
<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`}
|
||||
value={config.timeoutMs}
|
||||
onChange={event => update('timeoutMs', Number(event.target.value))}
|
||||
className={`${inputClass()} font-mono`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="最大输出 Token" error={errors.maxOutputTokens}>
|
||||
<Field label="最大输出 Token" hint="短 ID 数组建议保持在 8–256 Token。">
|
||||
<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`}
|
||||
value={config.maxOutputTokens}
|
||||
onChange={event => update('maxOutputTokens', Number(event.target.value))}
|
||||
className={`${inputClass()} 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">
|
||||
<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 不在页面保存。请在后端启动环境设置
|
||||
@@ -308,110 +196,93 @@ export default function LlmConfiguration({
|
||||
{probeResult}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
<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>
|
||||
<h3 className="text-xs font-bold text-slate-900">事件判断提示词</h3>
|
||||
<p className="mt-1 text-[11px] leading-5 text-slate-400">
|
||||
后端会强制把实际通话上下文追加到请求末尾,让规则和指令形成稳定前缀,便于模型服务复用 KV Cache。
|
||||
<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={() => setDraft(current => ({ ...current, prompt: DEFAULT_PROMPT }))}
|
||||
className="shrink-0 text-[10px] font-bold text-slate-500 transition hover:text-slate-900"
|
||||
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>
|
||||
|
||||
<div className="space-y-4 p-6">
|
||||
<textarea
|
||||
value={draft.prompt}
|
||||
onChange={event => setDraft(current => ({ ...current, prompt: event.target.value }))}
|
||||
value={config.prompt}
|
||||
onChange={event => update('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'
|
||||
}`}
|
||||
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"
|
||||
/>
|
||||
{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"
|
||||
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>
|
||||
</section>
|
||||
|
||||
<section className="rounded-md border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<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">
|
||||
只接受 JSON 字符串数组。未知 ID 会被后端丢弃;重复 ID 会合并;最终仍由后端按通话去重。
|
||||
模型仅返回规则库中的短事件 ID 数组;未知 ID 被丢弃,重复 ID 被合并,最终仍由后端按通话去重。
|
||||
</p>
|
||||
<code className="mt-2 block font-mono text-[11px] font-bold text-slate-800">
|
||||
["E001","E003"] / []
|
||||
</code>
|
||||
</div>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
366
frontend/src/components/RuleConfiguration.tsx
Normal file
366
frontend/src/components/RuleConfiguration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user