Files
live-call-asr-monitor/frontend/src/App.tsx

281 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect, useMemo } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import Header from './components/Header';
import RuleManagement from './components/RuleManagement';
import SandboxSimulation from './components/SandboxSimulation';
import ToastContainer, { Toast } from './components/ToastContainer';
import {
ApiError,
fetchCurrentRules,
fetchRuleVersions,
restoreRuleVersion,
saveAndActivateRules,
} from './api';
import { LlmConfig, Rule, RuleSet, RuleVersionSummary } from './types';
const TRANSFER_SET_ID = 'transfer';
const DEFAULT_LLM_CONFIG: LlmConfig = {
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4.1-mini',
timeoutMs: 3000,
maxOutputTokens: 64,
prompt: '',
};
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
);
}
function transferMatcher(ruleSets: RuleSet[]): 'ac-keyword' | 'llm' {
return ruleSets.find(ruleSet => ruleSet.id === TRANSFER_SET_ID)?.matcher === 'llm'
? 'llm'
: 'ac-keyword';
}
function replaceTransferMatcher(
ruleSets: RuleSet[],
matcher: 'ac-keyword' | 'llm',
): RuleSet[] {
if (!ruleSets.some(ruleSet => ruleSet.id === TRANSFER_SET_ID)) {
return [{ ...emptyTransferSet(), matcher }, ...ruleSets];
}
return ruleSets.map(ruleSet =>
ruleSet.id === TRANSFER_SET_ID ? { ...ruleSet, matcher } : ruleSet
);
}
export default function App() {
// Global Active Tab: 'rules' | 'sandbox'
const [activeTab, setActiveTab] = useState<'rules' | 'sandbox'>('rules');
const [ruleSets, setRuleSets] = useState<RuleSet[]>([]);
const [publishedRuleSets, setPublishedRuleSets] = useState<RuleSet[]>([]);
const [llmConfig, setLlmConfig] = useState<LlmConfig>(DEFAULT_LLM_CONFIG);
const [publishedLlmConfig, setPublishedLlmConfig] = useState<LlmConfig>(DEFAULT_LLM_CONFIG);
const [publishedVersion, setPublishedVersion] = useState(0);
const [loadingRules, setLoadingRules] = useState(true);
const [savingRules, setSavingRules] = useState(false);
const [versionHistory, setVersionHistory] = useState<RuleVersionSummary[]>([]);
const [versionHistoryLoading, setVersionHistoryLoading] = useState(false);
const [restoringVersion, setRestoringVersion] = useState<number | null>(null);
// Floating notifications
const [toasts, setToasts] = useState<Toast[]>([]);
// 1. Toast helper
const addToast = (message: string, type: 'success' | 'warning' | 'info') => {
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
setToasts((prev) => [...prev, { id, message, type }]);
// Auto remove after 4.5 seconds
setTimeout(() => {
removeToast(id);
}, 4500);
};
const removeToast = (id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
};
// Load the active server-side snapshot. The backend is the source of truth.
useEffect(() => {
let active = true;
void fetchCurrentRules()
.then(data => {
if (!active) return;
setRuleSets(data.ruleSets);
setPublishedRuleSets(data.ruleSets);
setLlmConfig(data.llmConfig);
setPublishedLlmConfig(data.llmConfig);
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;
};
}, []);
const handleUpdateRules = (updatedRules: Rule[]) => {
setRuleSets(current => replaceTransferRules(current, updatedRules));
};
const handleUpdateMatcher = (matcher: 'ac-keyword' | 'llm') => {
setRuleSets(current => replaceTransferMatcher(current, matcher));
};
const isDirty = useMemo(() => {
return JSON.stringify(ruleSets) !== JSON.stringify(publishedRuleSets)
|| JSON.stringify(llmConfig) !== JSON.stringify(publishedLlmConfig);
}, [llmConfig, publishedLlmConfig, publishedRuleSets, ruleSets]);
const handlePublish = async () => {
if (!isDirty || savingRules || restoringVersion !== null) return;
setSavingRules(true);
try {
const result = await saveAndActivateRules(publishedVersion, llmConfig, ruleSets);
setRuleSets(result.ruleSets);
setPublishedRuleSets(result.ruleSets);
setLlmConfig(result.llmConfig);
setPublishedLlmConfig(result.llmConfig);
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 loadVersionHistory = async () => {
if (versionHistoryLoading) return;
setVersionHistoryLoading(true);
try {
setVersionHistory(await fetchRuleVersions(20));
} catch (error) {
const message = error instanceof ApiError ? error.message : '未知错误';
addToast(`加载历史版本失败:${message}`, 'warning');
} finally {
setVersionHistoryLoading(false);
}
};
const handleRestoreVersion = async (sourceVersion: number) => {
if (restoringVersion !== null || savingRules || sourceVersion === publishedVersion) return;
const draftWarning = isDirty
? '\n当前未发布的页面修改将被回溯结果覆盖。'
: '';
const confirmed = window.confirm(
`确定回溯到 V${sourceVersion}${draftWarning}\n系统不会删除历史版本而是基于 V${sourceVersion} 创建并发布一个新版本。`
);
if (!confirmed) return;
setRestoringVersion(sourceVersion);
try {
const result = await restoreRuleVersion(sourceVersion, publishedVersion);
setRuleSets(result.ruleSets);
setPublishedRuleSets(result.ruleSets);
setLlmConfig(result.llmConfig);
setPublishedLlmConfig(result.llmConfig);
setPublishedVersion(result.version);
addToast(`已基于 V${sourceVersion} 创建并生效 V${result.version}`, 'success');
result.warnings.forEach(warning => addToast(warning, 'warning'));
void loadVersionHistory();
} catch (error) {
if (error instanceof ApiError && error.body.code === 'RULE_VERSION_CONFLICT') {
addToast(`回溯失败:服务端当前已是 V${error.body.currentVersion ?? '?'},请重新打开版本菜单`, 'warning');
void loadVersionHistory();
} else {
const message = error instanceof ApiError ? error.message : '未知错误';
addToast(`回溯失败:${message}`, 'warning');
}
} finally {
setRestoringVersion(null);
}
};
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 */}
<Header
activeTab={activeTab}
setActiveTab={setActiveTab}
publishedVersion={publishedVersion}
isDirty={isDirty}
versionHistory={versionHistory}
versionHistoryLoading={versionHistoryLoading}
restoringVersion={restoringVersion}
onLoadVersionHistory={() => void loadVersionHistory()}
onRestoreVersion={(version) => void handleRestoreVersion(version)}
/>
{/* Main Container Area */}
<main className="flex-1 max-w-7xl w-full mx-auto px-4 py-6 sm:px-6 overflow-hidden">
<AnimatePresence mode="wait">
{activeTab === 'rules' ? (
<motion.div
key="rules-page"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="h-full"
>
<RuleManagement
rules={rules}
setRules={handleUpdateRules}
matcher={transferMatcher(ruleSets)}
setMatcher={handleUpdateMatcher}
llmConfig={llmConfig}
setLlmConfig={setLlmConfig}
onSavePublish={handlePublish}
isDirty={isDirty}
loading={loadingRules}
saving={savingRules || restoringVersion !== null}
addToast={addToast}
/>
</motion.div>
) : (
<motion.div
key="sandbox-page"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="h-full"
>
<SandboxSimulation
rules={activeRules}
matcher={transferMatcher(publishedRuleSets)}
addToast={addToast}
/>
</motion.div>
)}
</AnimatePresence>
</main>
{/* Persistent Toast Notifications */}
<ToastContainer toasts={toasts} removeToast={removeToast} />
</div>
);
}