Hook up frontend-redesign and backend

This commit is contained in:
Xin Wang
2026-07-16 13:15:42 +08:00
parent d093f95197
commit 69896460cb
11 changed files with 689 additions and 207 deletions

View File

@@ -1,20 +1,49 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
</div>
# ASR 事件监控前端
# Run and deploy your AI Studio app
这是与仓库 Spring Boot 后端配套的新版 React + Vite 前端。
This contains everything you need to run your app locally.
## 本地联调
View your app in AI Studio: https://ai.studio/apps/da74db99-cca8-4fb2-ad63-46c938405741
先在仓库根目录启动后端(默认端口 `8080`),再启动前端:
## Run Locally
```bash
cd frontend-redesign
npm install
npm run dev
```
**Prerequisites:** Node.js
访问 `http://localhost:3000`。Vite 会把 `/api` 请求代理到
`http://localhost:8080`,无需额外配置 CORS。
## 生产打包
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
```bash
cd frontend-redesign
npm run build
```
构建结果位于 `frontend-redesign/dist`。如需与 Spring Boot 打成同一个 JAR
在执行 Maven 打包前,将 `dist` 内的文件复制到:
```text
src/main/resources/static/
```
之后访问 Spring Boot 的 `http://localhost:8080/` 即可使用前端。
## 后端接口
- `GET /api/v1/admin/rules`:读取当前生效规则
- `PUT /api/v1/admin/rules`:保存并发布完整规则文档
- `POST /api/v1/asr-events`:提交 ASR Final 并获取会话告警
- `POST /api/v1/calls/{callId}/close`:关闭并释放通话会话
规则数据和生效版本以后端为准;浏览器不再通过 localStorage 模拟发布。
## Excel 导入导出
- “模板下载”生成 `.xlsx` 规则模板。
- 每行填写一条规则,多个关键词在同一个单元格内换行填写。
- “导入”会校验事件 ID、启用状态、关键词以及重复数据并替换页面工作副本。
- “导出”按照相同模板格式导出当前页面中的规则。
- 导入不会自动发布,确认数据后仍需点击“保存并发布”。

View File

@@ -20,6 +20,7 @@
"vite": "^6.2.3",
"express": "^4.21.2",
"dotenv": "^17.2.3",
"exceljs": "^4.4.0",
"motion": "^12.23.24"
},
"devDependencies": {

View File

@@ -9,21 +9,43 @@ import Header from './components/Header';
import RuleManagement from './components/RuleManagement';
import SandboxSimulation from './components/SandboxSimulation';
import ToastContainer, { Toast } from './components/ToastContainer';
import { Rule } from './types';
import { INITIAL_RULES } from './data';
import { ApiError, fetchCurrentRules, saveAndActivateRules } from './api';
import { Rule, RuleSet } from './types';
const TRANSFER_SET_ID = 'transfer';
function emptyTransferSet(): RuleSet {
return {
id: TRANSFER_SET_ID,
name: '潜在转接',
enabled: true,
matcher: 'ac-keyword',
rules: [],
};
}
function transferRules(ruleSets: RuleSet[]): Rule[] {
return ruleSets.find(ruleSet => ruleSet.id === TRANSFER_SET_ID)?.rules ?? [];
}
function replaceTransferRules(ruleSets: RuleSet[], rules: Rule[]): RuleSet[] {
if (!ruleSets.some(ruleSet => ruleSet.id === TRANSFER_SET_ID)) {
return [{ ...emptyTransferSet(), rules }, ...ruleSets];
}
return ruleSets.map(ruleSet =>
ruleSet.id === TRANSFER_SET_ID ? { ...ruleSet, rules } : ruleSet
);
}
export default function App() {
// Global Active Tab: 'rules' | 'sandbox'
const [activeTab, setActiveTab] = useState<'rules' | 'sandbox'>('rules');
// Rules State: Working copy
const [rules, setRules] = useState<Rule[]>([]);
// Rules State: Published snapshot
const [publishedRules, setPublishedRules] = useState<Rule[]>([]);
// Version counter state
const [publishedVersion, setPublishedVersion] = useState<number>(6);
const [ruleSets, setRuleSets] = useState<RuleSet[]>([]);
const [publishedRuleSets, setPublishedRuleSets] = useState<RuleSet[]>([]);
const [publishedVersion, setPublishedVersion] = useState(0);
const [loadingRules, setLoadingRules] = useState(true);
const [savingRules, setSavingRules] = useState(false);
// Floating notifications
const [toasts, setToasts] = useState<Toast[]>([]);
@@ -43,70 +65,62 @@ export default function App() {
setToasts((prev) => prev.filter((t) => t.id !== id));
};
// 2. Load initially from localStorage
// Load the active server-side snapshot. The backend is the source of truth.
useEffect(() => {
try {
const storedRules = localStorage.getItem('asr_rules_working');
const storedPublished = localStorage.getItem('asr_rules_published');
const storedVersion = localStorage.getItem('asr_rules_version');
if (storedRules && storedPublished) {
setRules(JSON.parse(storedRules));
setPublishedRules(JSON.parse(storedPublished));
} else {
// First-time load: Seed initial rules
setRules(INITIAL_RULES);
setPublishedRules(INITIAL_RULES);
localStorage.setItem('asr_rules_working', JSON.stringify(INITIAL_RULES));
localStorage.setItem('asr_rules_published', JSON.stringify(INITIAL_RULES));
}
if (storedVersion) {
setPublishedVersion(Number(storedVersion));
} else {
localStorage.setItem('asr_rules_version', '6');
}
} catch (e) {
console.error('Failed to parse storage rules', e);
setRules(INITIAL_RULES);
setPublishedRules(INITIAL_RULES);
}
let active = true;
void fetchCurrentRules()
.then(data => {
if (!active) return;
setRuleSets(data.ruleSets);
setPublishedRuleSets(data.ruleSets);
setPublishedVersion(data.version);
})
.catch(error => {
if (!active) return;
const message = error instanceof ApiError ? error.message : '无法连接规则服务';
addToast(`加载规则失败:${message}`, 'warning');
})
.finally(() => {
if (active) setLoadingRules(false);
});
return () => {
active = false;
};
}, []);
// 3. Save working rules to localStorage whenever they change
const handleUpdateRules = (updatedRules: Rule[]) => {
setRules(updatedRules);
try {
localStorage.setItem('asr_rules_working', JSON.stringify(updatedRules));
} catch (e) {
console.error('Failed to write rules to local storage', e);
}
setRuleSets(current => replaceTransferRules(current, updatedRules));
};
// 4. Dirty tracking: compare working rules vs published rules
const isDirty = useMemo(() => {
if (rules.length === 0 && publishedRules.length === 0) return false;
return JSON.stringify(rules) !== JSON.stringify(publishedRules);
}, [rules, publishedRules]);
// 5. Publish action: increment version, update snapshot
const handlePublish = () => {
if (!isDirty) return;
const nextVersion = publishedVersion + 1;
setPublishedVersion(nextVersion);
setPublishedRules(rules);
return JSON.stringify(ruleSets) !== JSON.stringify(publishedRuleSets);
}, [ruleSets, publishedRuleSets]);
const handlePublish = async () => {
if (!isDirty || savingRules) return;
setSavingRules(true);
try {
localStorage.setItem('asr_rules_published', JSON.stringify(rules));
localStorage.setItem('asr_rules_version', String(nextVersion));
addToast(`🎉 保存成功!当前监控规则版本已发布并上线生效`, 'success');
} catch (e) {
console.error('Failed to publish rules', e);
addToast('发布规则时写入本地存储失败!', 'warning');
const result = await saveAndActivateRules(publishedVersion, ruleSets);
setRuleSets(result.ruleSets);
setPublishedRuleSets(result.ruleSets);
setPublishedVersion(result.version);
addToast(`保存成功,监控规则 V${result.version} 已发布生效`, 'success');
result.warnings.forEach(warning => addToast(warning, 'warning'));
} catch (error) {
if (error instanceof ApiError && error.body.code === 'RULE_VERSION_CONFLICT') {
addToast(`规则版本冲突,服务端当前为 V${error.body.currentVersion ?? '?' },请刷新页面后重试`, 'warning');
} else {
const message = error instanceof ApiError ? error.message : '未知错误';
addToast(`发布失败:${message}`, 'warning');
}
} finally {
setSavingRules(false);
}
};
const rules = transferRules(ruleSets);
const activeRules = transferRules(publishedRuleSets);
return (
<div className="min-h-screen bg-slate-50 font-sans antialiased flex flex-col selection:bg-blue-100 selection:text-blue-900">
{/* Dynamic Header */}
@@ -134,6 +148,8 @@ export default function App() {
setRules={handleUpdateRules}
onSavePublish={handlePublish}
isDirty={isDirty}
loading={loadingRules}
saving={savingRules}
addToast={addToast}
/>
</motion.div>
@@ -147,7 +163,7 @@ export default function App() {
className="h-full"
>
<SandboxSimulation
rules={rules}
rules={activeRules}
addToast={addToast}
/>
</motion.div>

View File

@@ -0,0 +1,80 @@
import type {
CurrentRulesResponse,
MonitorResponse,
RuleSet,
SaveRuleResponse,
} from './types';
export interface ApiErrorBody {
code?: string;
message?: string;
currentVersion?: number;
fields?: Record<string, string>;
}
export class ApiError extends Error {
readonly status: number;
readonly body: ApiErrorBody;
constructor(status: number, body: ApiErrorBody) {
super(body.message ?? `请求失败 (${status})`);
this.name = 'ApiError';
this.status = status;
this.body = body;
}
}
async function parseError(response: Response): Promise<ApiError> {
let body: ApiErrorBody = {};
try {
body = (await response.json()) as ApiErrorBody;
} catch {
body = { message: response.statusText || `请求失败 (${response.status})` };
}
return new ApiError(response.status, body);
}
async function parseResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
throw await parseError(response);
}
return (await response.json()) as T;
}
export async function fetchCurrentRules(): Promise<CurrentRulesResponse> {
return parseResponse<CurrentRulesResponse>(await fetch('/api/v1/admin/rules'));
}
export async function saveAndActivateRules(
baseVersion: number,
ruleSets: RuleSet[],
): Promise<SaveRuleResponse> {
return parseResponse<SaveRuleResponse>(await fetch('/api/v1/admin/rules', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseVersion, ruleSets }),
}));
}
export async function submitAsrEvent(request: {
callId: string;
seq: number;
speaker: 'citizen' | 'agent';
text: string;
final: boolean;
}): Promise<MonitorResponse> {
return parseResponse<MonitorResponse>(await fetch('/api/v1/asr-events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
}));
}
export async function closeCall(callId: string): Promise<void> {
const response = await fetch(`/api/v1/calls/${encodeURIComponent(callId)}/close`, {
method: 'POST',
});
if (!response.ok) {
throw await parseError(response);
}
}

View File

@@ -84,6 +84,9 @@ export default function Header({ activeTab, setActiveTab, publishedVersion, isDi
</span>
)}
<span className="hidden font-mono text-[10px] font-bold text-slate-500 sm:inline">
V{publishedVersion}
</span>
</div>
</div>
</header>

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo, useRef, useEffect } from 'react';
import React, { useState, useMemo, useRef } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import {
Plus,
@@ -13,21 +13,25 @@ import {
Edit,
Trash2,
AlertCircle,
SlidersHorizontal,
ChevronDown,
Info,
Check,
Upload,
Download,
FileDown
} from 'lucide-react';
import { Rule } from '../types';
import {
downloadRuleTemplate,
exportRulesToExcel,
importRulesFromExcel,
} from '../rule-excel';
interface RuleManagementProps {
rules: Rule[];
setRules: (rules: Rule[]) => void;
onSavePublish: () => void;
isDirty: boolean;
loading: boolean;
saving: boolean;
addToast: (message: string, type: 'success' | 'warning' | 'info') => void;
}
@@ -36,6 +40,8 @@ export default function RuleManagement({
setRules,
onSavePublish,
isDirty,
loading,
saving,
addToast
}: RuleManagementProps) {
const [searchTerm, setSearchTerm] = useState('');
@@ -50,9 +56,64 @@ export default function RuleManagement({
const [keywordInput, setKeywordInput] = useState('');
const [formKeywords, setFormKeywords] = useState<string[]>([]);
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const [excelAction, setExcelAction] = useState<'template' | 'import' | 'export' | null>(null);
// Tag editor input ref
const tagInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleTemplateDownload = async () => {
if (excelAction) return;
setExcelAction('template');
try {
await downloadRuleTemplate();
addToast('Excel 导入模板已下载', 'success');
} catch (error) {
const message = error instanceof Error ? error.message : '未知错误';
addToast(`模板下载失败:${message}`, 'warning');
} finally {
setExcelAction(null);
}
};
const handleExport = async () => {
if (excelAction || rules.length === 0) return;
setExcelAction('export');
try {
await exportRulesToExcel(rules);
addToast(`已导出 ${rules.length} 条规则`, 'success');
} catch (error) {
const message = error instanceof Error ? error.message : '未知错误';
addToast(`导出失败:${message}`, 'warning');
} finally {
setExcelAction(null);
}
};
const handleImportFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file || excelAction) return;
setExcelAction('import');
try {
const importedRules = await importRulesFromExcel(file);
const shouldReplace = rules.length === 0 || window.confirm(
`Excel 中读取到 ${importedRules.length} 条规则,是否替换当前页面中的 ${rules.length} 条规则?\n替换后仍需点击“保存并发布”才会生效。`
);
if (!shouldReplace) {
addToast('已取消导入,当前规则未改变', 'info');
return;
}
setRules(importedRules);
setSearchTerm('');
addToast(`已导入 ${importedRules.length} 条规则,请检查后保存并发布`, 'success');
} catch (error) {
const message = error instanceof Error ? error.message : '无法读取 Excel 文件';
addToast(`导入失败:${message}`, 'warning');
} finally {
setExcelAction(null);
}
};
// Statistics
@@ -195,6 +256,14 @@ 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 */}
@@ -212,24 +281,42 @@ export default function RuleManagement({
<div className="flex items-center space-x-2">
<button
type="button"
onClick={() => void handleTemplateDownload()}
disabled={excelAction !== null}
className="inline-flex items-center justify-center space-x-1 rounded-md border border-slate-200 bg-white px-3 py-2 text-xs font-bold text-slate-700 uppercase tracking-wider transition hover:bg-slate-50 focus:outline-none"
>
<FileDown className="h-3.5 w-3.5 text-slate-500" />
<span></span>
<span>{excelAction === 'template' ? '生成中…' : '模板下载'}</span>
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={excelAction !== null}
className="inline-flex items-center justify-center space-x-1 rounded-md border border-slate-200 bg-white px-3 py-2 text-xs font-bold text-slate-700 uppercase tracking-wider transition hover:bg-slate-50 focus:outline-none"
>
<Upload className="h-3.5 w-3.5 text-slate-500" />
<span></span>
<span>{excelAction === 'import' ? '读取中…' : '导入'}</span>
</button>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
onChange={handleImportFile}
className="hidden"
/>
<button
type="button"
onClick={() => void handleExport()}
disabled={excelAction !== null || rules.length === 0}
title={rules.length === 0 ? '当前没有可导出的规则' : '按导入模板格式导出当前页面数据'}
className="inline-flex items-center justify-center space-x-1 rounded-md border border-slate-200 bg-white px-3 py-2 text-xs font-bold text-slate-700 uppercase tracking-wider transition hover:bg-slate-50 focus:outline-none"
>
<Download className="h-3.5 w-3.5 text-slate-500" />
<span></span>
<span>{excelAction === 'export' ? '生成中…' : '导出'}</span>
</button>
<button
@@ -242,16 +329,16 @@ export default function RuleManagement({
<button
onClick={onSavePublish}
disabled={!isDirty}
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
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></span>
{isDirty && (
<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>

View File

@@ -22,17 +22,23 @@ import {
} from 'lucide-react';
import { Rule, DialogLine, Alert, SandboxSession } from '../types';
import { PRESET_SCRIPTS } from '../data';
import { ApiError, closeCall, submitAsrEvent } from '../api';
interface SandboxSimulationProps {
rules: Rule[];
addToast: (message: string, type: 'success' | 'warning' | 'info') => void;
}
function generateCallId() {
const rand = Math.floor(10000000 + Math.random() * 90000000);
return `ASR-CALL-${rand}`;
}
export default function SandboxSimulation({ rules, addToast }: SandboxSimulationProps) {
// Session State
const [session, setSession] = useState<SandboxSession>({
callId: '',
status: 'idle',
callId: generateCallId(),
status: 'listening',
lines: [],
alerts: []
});
@@ -51,41 +57,66 @@ export default function SandboxSimulation({ rules, addToast }: SandboxSimulation
const chatScrollRef = useRef<HTMLDivElement>(null);
const alertScrollRef = useRef<HTMLDivElement>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const nextSeqRef = useRef(0);
const sessionRef = useRef(session);
const requestQueueRef = useRef<Promise<void>>(Promise.resolve());
const closeTimerRef = useRef<NodeJS.Timeout | null>(null);
const resettingRef = useRef(false);
// Generate an elegant Call ID
const generateCallId = () => {
const prefix = 'ASR-CALL';
const rand = Math.floor(10000000 + Math.random() * 90000000);
return `${prefix}-${rand}`;
};
useEffect(() => {
sessionRef.current = session;
}, [session]);
// Start fresh call
const handleStartNewCall = () => {
const handleStartNewCall = async () => {
if (resettingRef.current) return;
resettingRef.current = true;
// Clear playback timer if active
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
const newId = generateCallId();
setSession({
callId: newId,
status: 'listening',
lines: [],
alerts: []
});
setIsPlayingScript(false);
setScriptLinesQueue([]);
addToast(`通话重置。已创建新会话: ${newId}`, 'info');
const previousCallId = sessionRef.current.callId;
try {
await requestQueueRef.current;
if (previousCallId) await closeCall(previousCallId);
} catch (error) {
const message = error instanceof ApiError ? error.message : '网络错误';
addToast(`关闭上一通会话失败:${message}`, 'warning');
} finally {
const newId = generateCallId();
nextSeqRef.current = 0;
const nextSession: SandboxSession = {
callId: newId,
status: 'listening',
lines: [],
alerts: []
};
sessionRef.current = nextSession;
setSession(nextSession);
addToast(`已创建新会话: ${newId}`, 'info');
resettingRef.current = false;
}
};
// On mount, auto-generate call if none
// Delay cleanup by one tick so React StrictMode's development probe can cancel it.
useEffect(() => {
if (!session.callId) {
handleStartNewCall();
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
return () => {
if (timerRef.current) clearInterval(timerRef.current);
const callId = sessionRef.current.callId;
closeTimerRef.current = setTimeout(() => {
void requestQueueRef.current.finally(() => {
if (callId) void closeCall(callId).catch(() => undefined);
});
}, 0);
};
}, []);
@@ -105,70 +136,67 @@ export default function SandboxSimulation({ rules, addToast }: SandboxSimulation
// Core sentence processing logic
const processSentence = (role: 'citizen' | 'agent', text: string) => {
if (!text.trim()) return;
if (resettingRef.current) {
addToast('正在关闭上一通会话,请稍候再发送', 'info');
return;
}
setSession(prev => {
const nextSeq = prev.lines.length + 1;
const now = new Date();
const timestamp = now.toTimeString().split(' ')[0];
const newLine: DialogLine = {
id: `line-${nextSeq}-${Date.now()}`,
role,
text: text.trim(),
const normalizedText = text.trim();
const callId = sessionRef.current.callId;
const nextSeq = ++nextSeqRef.current;
const timestamp = new Date().toTimeString().split(' ')[0];
const newLine: DialogLine = {
id: `line-${nextSeq}-${Date.now()}`,
role,
text: normalizedText,
seq: nextSeq,
timestamp
};
setSession(prev => prev.callId === callId
? { ...prev, lines: [...prev.lines, newLine] }
: prev
);
const request = requestQueueRef.current.then(async () => {
const response = await submitAsrEvent({
callId,
seq: nextSeq,
timestamp
};
const updatedLines = [...prev.lines, newLine];
// Send to backend (as requested for future monitoring)
fetch('/api/v1/asr-events', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
callId: prev.callId,
seq: nextSeq,
speaker: role,
text: text.trim(),
final: true
})
}).catch(err => console.error('Failed to report ASR event:', err));
// Perform rule matching
let currentAlerts = [...prev.alerts];
const activeRules = rules.filter(r => r.enabled);
activeRules.forEach(rule => {
// Check if any keyword matches
const matchedKeyword = rule.keywords.find(kw =>
text.toLowerCase().includes(kw.toLowerCase())
);
if (matchedKeyword) {
// Fresh alert!
const alertId = `alert-${rule.id}-${Date.now()}`;
const newAlert: Alert = {
id: alertId,
ruleId: rule.id,
ruleName: rule.name,
keyword: matchedKeyword,
text: text.trim(),
seq: nextSeq,
timestamp,
};
currentAlerts = [...currentAlerts, newAlert];
addToast(`⚠️ 触发预警: 「${rule.name}」(命中词: ${matchedKeyword}`, 'warning');
}
speaker: role,
text: normalizedText,
final: true,
});
return {
...prev,
lines: updatedLines,
alerts: currentAlerts
};
setSession(prev => {
if (prev.callId !== callId) return prev;
const existing = new Map(prev.alerts.map(alert => [alert.id, alert]));
const alerts: Alert[] = response.currentResults.map(result => {
const id = `${result.eventType}:${result.eventId}`;
return {
id,
ruleId: result.eventId,
ruleName: result.eventName,
keywords: result.matchedKeywords,
text: result.evidence,
seq: result.firstMatchedSeq,
timestamp: existing.get(id)?.timestamp ?? timestamp,
};
});
return { ...prev, alerts };
});
response.newAlerts.forEach(alert => {
addToast(
`触发预警:「${alert.eventName}」(${alert.matchedKeywords.join('、')}`,
'warning'
);
});
}).catch(error => {
const message = error instanceof ApiError ? error.message : '网络错误';
addToast(`ASR 事件提交失败seq ${nextSeq}${message}`, 'warning');
});
requestQueueRef.current = request;
};
// Send single manual input
@@ -619,11 +647,16 @@ export default function SandboxSimulation({ rules, addToast }: SandboxSimulation
</div>
{/* Matched keyword badges */}
<div className="mt-2.5 flex items-center space-x-1.5 text-xs">
<div className="mt-2.5 flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">:</span>
<span className="rounded bg-rose-50 border border-rose-100 px-1.5 py-0.5 font-mono font-bold text-rose-700 text-[10px]">
{alert.keyword}
</span>
{alert.keywords.map(keyword => (
<span
key={keyword}
className="rounded bg-rose-50 border border-rose-100 px-1.5 py-0.5 font-mono font-bold text-rose-700 text-[10px]"
>
{keyword}
</span>
))}
</div>
{/* Evidence block */}
@@ -631,18 +664,7 @@ export default function SandboxSimulation({ rules, addToast }: SandboxSimulation
<span className="font-bold text-[8px] text-slate-400 block mb-1 uppercase tracking-widest">
ASR
</span>
<p>
{alert.text.split(alert.keyword).map((chunk, i, arr) => (
<React.Fragment key={i}>
{chunk}
{i < arr.length - 1 && (
<mark className="bg-rose-50 text-rose-700 font-bold border-b-2 border-rose-400 px-0.5 rounded">
{alert.keyword}
</mark>
)}
</React.Fragment>
))}
</p>
<p> {renderHighlightedText(alert.text)} </p>
</div>
</div>
</div>

View File

@@ -3,29 +3,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Rule } from './types';
export const INITIAL_RULES: Rule[] = [
{
id: 'sf-express',
name: '顺丰速递绿色渠道服务热线',
keywords: ['顺丰', '顺丰快递', '顺丰速运'],
enabled: true
},
{
id: 'pdd',
name: '拼多多平台热线',
keywords: ['拼多多', '拼夕夕'],
enabled: true
},
{
id: 'ems',
name: 'EMS绿色通道',
keywords: ['EMS', '邮政速递'],
enabled: true
}
];
export interface PresetScript {
title: string;
description: string;

View File

@@ -0,0 +1,221 @@
import ExcelJS from 'exceljs';
import type { Rule } from './types';
const SHEET_NAME = '监控规则';
const HEADER_ROW = 3;
const FIRST_DATA_ROW = 4;
const TEMPLATE_ROWS = 50;
const HEADERS = ['事件 ID', '特征规则名称', '启用状态', '监控特征关键词'];
function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) {
workbook.creator = 'ASR 事件监控中心';
workbook.created = new Date();
const sheet = workbook.addWorksheet(SHEET_NAME, {
views: [{ state: 'frozen', ySplit: HEADER_ROW, showGridLines: false }],
});
sheet.mergeCells('A1:D1');
sheet.getCell('A1').value = 'ASR 事件监控规则';
sheet.getCell('A1').style = {
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0F172A' } },
font: { bold: true, color: { argb: 'FFFFFFFF' }, size: 16 },
alignment: { vertical: 'middle', horizontal: 'left' },
};
sheet.getRow(1).height = 32;
sheet.mergeCells('A2:D2');
sheet.getCell('A2').value =
'填写说明:每行一条规则;事件 ID 仅支持字母、数字、短横线和下划线;启用状态填写“启用”或“停用”;多个关键词请在同一单元格中换行填写。导入只更新页面草稿,仍需点击“保存并发布”。';
sheet.getCell('A2').style = {
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF7ED' } },
font: { color: { argb: 'FF9A3412' }, size: 10 },
alignment: { vertical: 'middle', wrapText: true },
};
sheet.getRow(2).height = 42;
const header = sheet.getRow(HEADER_ROW);
header.values = HEADERS;
header.height = 26;
header.eachCell(cell => {
cell.style = {
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } },
font: { bold: true, color: { argb: 'FF334155' } },
alignment: { vertical: 'middle', horizontal: 'center' },
border: { bottom: { style: 'medium', color: { argb: 'FF94A3B8' } } },
};
});
sheet.columns = [
{ key: 'id', width: 24 },
{ key: 'name', width: 36 },
{ key: 'enabled', width: 14 },
{ key: 'keywords', width: 58 },
];
rules.forEach((rule, index) => {
const row = sheet.getRow(FIRST_DATA_ROW + index);
row.values = [rule.id, rule.name, rule.enabled ? '启用' : '停用', rule.keywords.join('\n')];
});
const lastStyledRow = Math.max(FIRST_DATA_ROW + TEMPLATE_ROWS - 1, FIRST_DATA_ROW + rules.length - 1);
for (let rowNumber = FIRST_DATA_ROW; rowNumber <= lastStyledRow; rowNumber += 1) {
const row = sheet.getRow(rowNumber);
const rule = rules[rowNumber - FIRST_DATA_ROW];
row.height = rule
? Math.min(400, Math.max(32, rule.keywords.length * 15 + 8))
: 32;
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
cell.style = {
fill: {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: rowNumber % 2 === 0 ? 'FFFFFFFF' : 'FFF8FAFC' },
},
font: { color: { argb: 'FF334155' }, size: 10 },
alignment: {
vertical: 'middle',
horizontal: columnNumber === 3 ? 'center' : 'left',
wrapText: columnNumber === 4,
},
border: { bottom: { style: 'thin', color: { argb: 'FFE2E8F0' } } },
};
});
row.getCell(3).dataValidation = {
type: 'list',
allowBlank: false,
formulae: ['"启用,停用"'],
showErrorMessage: true,
errorTitle: '状态格式错误',
error: '请选择“启用”或“停用”',
};
}
sheet.autoFilter = `A${HEADER_ROW}:D${HEADER_ROW}`;
sheet.pageSetup = {
orientation: 'landscape',
fitToPage: true,
fitToWidth: 1,
fitToHeight: 0,
margins: { left: 0.3, right: 0.3, top: 0.5, bottom: 0.5, header: 0.2, footer: 0.2 },
};
return workbook;
}
async function downloadRulesWorkbook(rules: Rule[], filename: string) {
const workbook = formatWorkbook(new ExcelJS.Workbook(), rules);
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer as BlobPart], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
export async function downloadRuleTemplate() {
await downloadRulesWorkbook([], 'ASR监控规则导入模板.xlsx');
}
export async function exportRulesToExcel(rules: Rule[]) {
const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '');
await downloadRulesWorkbook(rules, `ASR监控规则_${stamp}.xlsx`);
}
function cellText(row: ExcelJS.Row, column: number) {
return row.getCell(column).text.trim();
}
function parseEnabled(value: string, rowNumber: number) {
const normalized = value.trim().toLowerCase();
if (['启用', '是', 'true', '1', 'yes'].includes(normalized)) return true;
if (['停用', '否', 'false', '0', 'no'].includes(normalized)) return false;
throw new Error(`${rowNumber} 行“启用状态”必须填写“启用”或“停用”`);
}
function parseKeywords(value: string) {
return [...new Set(
value
.split(/[\r\n,;|]+/)
.map(keyword => keyword.trim())
.filter(Boolean)
)];
}
function findHeaderRow(sheet: ExcelJS.Worksheet) {
const max = Math.min(sheet.actualRowCount, 10);
for (let rowNumber = 1; rowNumber <= max; rowNumber += 1) {
const row = sheet.getRow(rowNumber);
if (cellText(row, 1) === HEADERS[0] && cellText(row, 2) === HEADERS[1]) {
return rowNumber;
}
}
throw new Error('未找到模板表头,请使用“模板下载”生成的 Excel 文件');
}
export async function importRulesFromExcel(file: File): Promise<Rule[]> {
if (!file.name.toLowerCase().endsWith('.xlsx')) {
throw new Error('仅支持 .xlsx 格式,请使用系统提供的 Excel 模板');
}
if (file.size > 5 * 1024 * 1024) {
throw new Error('Excel 文件不能超过 5 MB');
}
const workbook = new ExcelJS.Workbook();
const bytes = await file.arrayBuffer();
await workbook.xlsx.load(bytes as unknown as Buffer);
const sheet = workbook.getWorksheet(SHEET_NAME) ?? workbook.worksheets[0];
if (!sheet) throw new Error('Excel 文件中没有可读取的工作表');
const headerRow = findHeaderRow(sheet);
const rules: Rule[] = [];
const ids = new Set<string>();
const keywordOwners = new Map<string, string>();
const errors: string[] = [];
for (let rowNumber = headerRow + 1; rowNumber <= sheet.actualRowCount; rowNumber += 1) {
const row = sheet.getRow(rowNumber);
const id = cellText(row, 1);
const name = cellText(row, 2);
const enabledText = cellText(row, 3);
const keywordText = cellText(row, 4);
if (![id, name, enabledText, keywordText].some(Boolean)) continue;
try {
if (!id) throw new Error(`${rowNumber} 行缺少事件 ID`);
if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
throw new Error(`${rowNumber} 行事件 ID 仅支持字母、数字、短横线和下划线`);
}
if (ids.has(id)) throw new Error(`${rowNumber} 行事件 ID 重复:${id}`);
if (!name) throw new Error(`${rowNumber} 行缺少特征规则名称`);
const enabled = parseEnabled(enabledText, rowNumber);
const keywords = parseKeywords(keywordText);
if (keywords.length === 0) throw new Error(`${rowNumber} 行至少需要一个关键词`);
for (const keyword of keywords) {
const normalized = keyword.toLocaleLowerCase();
const owner = keywordOwners.get(normalized);
if (owner && owner !== id) {
throw new Error(`${rowNumber} 行关键词“${keyword}”已属于事件 ${owner}`);
}
}
ids.add(id);
keywords.forEach(keyword => keywordOwners.set(keyword.toLocaleLowerCase(), id));
rules.push({ id, name, enabled, keywords });
} catch (error) {
errors.push(error instanceof Error ? error.message : `${rowNumber} 行格式错误`);
if (errors.length >= 8) break;
}
}
if (errors.length > 0) throw new Error(errors.join(''));
if (rules.length === 0) throw new Error('Excel 中没有可导入的规则数据');
return rules;
}

View File

@@ -10,6 +10,46 @@ export interface Rule {
enabled: boolean;
}
export interface RuleSet {
id: string;
name: string;
enabled: boolean;
matcher: string;
rules: Rule[];
}
export interface CurrentRulesResponse {
version: number;
ruleCount: number;
keywordCount: number;
ruleSets: RuleSet[];
}
export interface SaveRuleResponse extends CurrentRulesResponse {
warnings: string[];
}
export interface AlertResult {
eventType: string;
eventId: string;
eventName: string;
status: string;
matchedKeywords: string[];
evidence: string;
firstMatchedSeq: number;
}
export interface MonitorResponse {
accepted: boolean;
duplicate: boolean;
callId: string;
acceptedSeq: number;
revision: number;
activeRuleVersion: number;
newAlerts: AlertResult[];
currentResults: AlertResult[];
}
export interface DialogLine {
id: string;
role: 'agent' | 'citizen';
@@ -22,7 +62,7 @@ export interface Alert {
id: string;
ruleId: string;
ruleName: string;
keyword: string; // The matched keyword
keywords: string[];
text: string; // Evidence (the matched statement)
seq: number;
timestamp: string;

View File

@@ -17,6 +17,12 @@ export default defineConfig(() => {
hmr: process.env.DISABLE_HMR !== 'true',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
};
});