Hook up frontend-redesign and backend
This commit is contained in:
@@ -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、启用状态、关键词以及重复数据,并替换页面工作副本。
|
||||
- “导出”按照相同模板格式导出当前页面中的规则。
|
||||
- 导入不会自动发布,确认数据后仍需点击“保存并发布”。
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
80
frontend-redesign/src/api.ts
Normal file
80
frontend-redesign/src/api.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
221
frontend-redesign/src/rule-excel.ts
Normal file
221
frontend-redesign/src/rule-excel.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user