281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
/**
|
||
* @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>
|
||
);
|
||
}
|