BusinessAssistant.tsx 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899
  1. import {
  2. Alert,
  3. Button,
  4. Col,
  5. Descriptions,
  6. Drawer,
  7. Empty,
  8. Form,
  9. Input,
  10. InputNumber,
  11. Modal,
  12. Row,
  13. Select,
  14. Space,
  15. Spin,
  16. Switch,
  17. Table,
  18. Tabs,
  19. Tag,
  20. Typography,
  21. message,
  22. } from 'antd';
  23. import type { ColumnsType } from 'antd/es/table';
  24. import dayjs from 'dayjs';
  25. import {
  26. BellRing,
  27. BotMessageSquare,
  28. CircleCheck,
  29. CirclePlus,
  30. FlaskConical,
  31. Pause,
  32. Pencil,
  33. Play,
  34. RefreshCw,
  35. Save,
  36. Trash2,
  37. } from 'lucide-react';
  38. import { useEffect, useMemo, useState } from 'react';
  39. import { Metric, PageHeader, Surface } from '@/components/Page';
  40. import { useBotAccess } from '@/contexts/BotAccess';
  41. import {
  42. KnowledgeCandidatesPanel,
  43. KnowledgeSourcesPanel,
  44. } from '@/pages/BusinessKnowledgeSources';
  45. import { apiRequest, jsonOptions } from '@/services/api';
  46. import type {
  47. AssistantAccountSettings,
  48. AssistantConnection,
  49. AssistantConversation,
  50. AssistantConversationStatus,
  51. AssistantKnowledgeEntry,
  52. AssistantRuntimeStatus,
  53. AssistantUsage,
  54. Paged,
  55. } from '@/types';
  56. type ProviderForm = {
  57. base_url: string;
  58. api_key?: string;
  59. model: string;
  60. timeout_seconds: number;
  61. max_output_tokens: number;
  62. };
  63. type KnowledgeForm = Pick<
  64. AssistantKnowledgeEntry,
  65. 'question' | 'aliases' | 'keywords' | 'answer' | 'tags' | 'priority' | 'enabled'
  66. >;
  67. const emptyPage = <T,>(): Paged<T> => ({ items: [], total: 0, page: 1, page_size: 20 });
  68. const conversationLabels: Record<AssistantConversationStatus, string> = {
  69. auto: '自动接待',
  70. handoff: '待人工',
  71. human_paused: '人工接管',
  72. closed: '已关闭',
  73. };
  74. const conversationColors: Record<AssistantConversationStatus, string> = {
  75. auto: 'success',
  76. handoff: 'error',
  77. human_paused: 'warning',
  78. closed: 'default',
  79. };
  80. function connectionName(connection: AssistantConnection): string {
  81. const name = [connection.user.first_name, connection.user.last_name].filter(Boolean).join(' ');
  82. return name || (connection.user.username ? `@${connection.user.username}` : connection.user.id);
  83. }
  84. function customerName(conversation: AssistantConversation): string {
  85. const customer = conversation.customer;
  86. if (!customer) return conversation.chat_id;
  87. const name = [customer.first_name, customer.last_name].filter(Boolean).join(' ');
  88. return name || (customer.username ? `@${customer.username}` : customer.id);
  89. }
  90. export default function BusinessAssistantPage() {
  91. const { bot } = useBotAccess();
  92. const [loading, setLoading] = useState(false);
  93. const [status, setStatus] = useState<AssistantRuntimeStatus | null>(null);
  94. const [connections, setConnections] = useState<AssistantConnection[]>([]);
  95. const [selectedConnection, setSelectedConnection] = useState('');
  96. const [settings, setSettings] = useState<AssistantAccountSettings | null>(null);
  97. const [usage, setUsage] = useState<AssistantUsage | null>(null);
  98. const [knowledge, setKnowledge] = useState<Paged<AssistantKnowledgeEntry>>(emptyPage());
  99. const [conversations, setConversations] = useState<Paged<AssistantConversation>>(emptyPage());
  100. const [conversationStatus, setConversationStatus] = useState('');
  101. const [knowledgeQuery, setKnowledgeQuery] = useState('');
  102. const [knowledgeOpen, setKnowledgeOpen] = useState(false);
  103. const [knowledgeTarget, setKnowledgeTarget] = useState<AssistantKnowledgeEntry | null>(null);
  104. const [conversationDetail, setConversationDetail] = useState<AssistantConversation | null>(null);
  105. const [detailLoading, setDetailLoading] = useState(false);
  106. const [testQuestion, setTestQuestion] = useState('');
  107. const [testResult, setTestResult] = useState<Record<string, unknown> | null>(null);
  108. const [feishuTesting, setFeishuTesting] = useState(false);
  109. const [providerForm] = Form.useForm<ProviderForm>();
  110. const [settingsForm] = Form.useForm<AssistantAccountSettings>();
  111. const [knowledgeForm] = Form.useForm<KnowledgeForm>();
  112. const feishuWebhookDraft = Form.useWatch('feishu_webhook_url', settingsForm);
  113. const provider = bot?.business_assistant;
  114. const reloadTopLevel = async () => {
  115. setLoading(true);
  116. try {
  117. const [statusValue, connectionPage] = await Promise.all([
  118. apiRequest<AssistantRuntimeStatus>('/business-assistant/status'),
  119. apiRequest<Paged<AssistantConnection>>('/business-assistant/connections?page=1&page_size=100'),
  120. ]);
  121. setStatus(statusValue);
  122. setConnections(connectionPage.items);
  123. setSelectedConnection((current) =>
  124. current && connectionPage.items.some((item) => item.connection_id === current)
  125. ? current
  126. : connectionPage.items[0]?.connection_id || '',
  127. );
  128. } catch (error) {
  129. message.error(error instanceof Error ? error.message : '智能接待状态加载失败');
  130. setConnections([]);
  131. } finally {
  132. setLoading(false);
  133. }
  134. };
  135. const loadConnectionData = async (
  136. connectionId: string,
  137. page = 1,
  138. filters?: { knowledgeQuery?: string; conversationStatus?: string },
  139. ) => {
  140. if (!connectionId) {
  141. setSettings(null);
  142. setKnowledge(emptyPage());
  143. setConversations(emptyPage());
  144. setUsage(null);
  145. return;
  146. }
  147. const params = new URLSearchParams({ connection_id: connectionId });
  148. const knowledgeParams = new URLSearchParams({
  149. connection_id: connectionId,
  150. query: filters?.knowledgeQuery ?? knowledgeQuery,
  151. page: String(page),
  152. page_size: '20',
  153. });
  154. const conversationParams = new URLSearchParams({
  155. connection_id: connectionId,
  156. status: filters?.conversationStatus ?? conversationStatus,
  157. page: String(page),
  158. page_size: '20',
  159. });
  160. setLoading(true);
  161. try {
  162. const [settingsValue, knowledgeValue, conversationValue, usageValue] = await Promise.all([
  163. apiRequest<AssistantAccountSettings>(`/business-assistant/settings?${params}`),
  164. apiRequest<Paged<AssistantKnowledgeEntry>>(`/business-assistant/knowledge?${knowledgeParams}`),
  165. apiRequest<Paged<AssistantConversation>>(
  166. `/business-assistant/conversations?${conversationParams}`,
  167. ),
  168. apiRequest<AssistantUsage>(`/business-assistant/usage?${params}`),
  169. ]);
  170. setSettings(settingsValue);
  171. settingsForm.setFieldsValue(settingsValue);
  172. setKnowledge(knowledgeValue);
  173. setConversations(conversationValue);
  174. setUsage(usageValue);
  175. } catch (error) {
  176. message.error(error instanceof Error ? error.message : '连接数据加载失败');
  177. } finally {
  178. setLoading(false);
  179. }
  180. };
  181. useEffect(() => {
  182. providerForm.setFieldsValue({
  183. base_url: provider?.base_url || '',
  184. model: provider?.model || '',
  185. timeout_seconds: provider?.timeout_seconds || 30,
  186. max_output_tokens: provider?.max_output_tokens || 600,
  187. api_key: '',
  188. });
  189. if (bot?.bot_id) void reloadTopLevel();
  190. }, [bot?.bot_id]);
  191. useEffect(() => {
  192. void loadConnectionData(selectedConnection);
  193. }, [selectedConnection, conversationStatus]);
  194. const activeConnection = useMemo(
  195. () => connections.find((item) => item.connection_id === selectedConnection) || null,
  196. [connections, selectedConnection],
  197. );
  198. const saveProvider = (values: ProviderForm) => {
  199. if (!bot) return;
  200. Modal.confirm({
  201. title: '更新模型配置并重启机器人?',
  202. content: 'API Key 留空会保留现有密钥。工作进程重启期间会短暂暂停接待。',
  203. okText: '确认保存',
  204. cancelText: '取消',
  205. onOk: async () => {
  206. await apiRequest(
  207. `/bots/${bot.bot_id}`,
  208. jsonOptions('PUT', {
  209. business_assistant: values,
  210. confirm: true,
  211. }),
  212. );
  213. message.success('模型配置已保存,机器人正在重启');
  214. window.dispatchEvent(new Event('bot-profiles-changed'));
  215. await reloadTopLevel();
  216. },
  217. });
  218. };
  219. const saveSettings = (values: AssistantAccountSettings) => {
  220. Modal.confirm({
  221. title: '保存该账号的接待设置?',
  222. content: values.assistant_enabled ? '保存后自动回复将按新规则运行。' : '保存后将停止自动回复。',
  223. okText: '确认保存',
  224. cancelText: '取消',
  225. onOk: async () => {
  226. const payload = { ...values };
  227. if (!payload.feishu_webhook_url?.trim()) delete payload.feishu_webhook_url;
  228. if (!payload.feishu_webhook_signing_secret?.trim()) {
  229. delete payload.feishu_webhook_signing_secret;
  230. }
  231. const saved = await apiRequest<AssistantAccountSettings>(
  232. '/business-assistant/settings',
  233. jsonOptions('PUT', { ...payload, connection_id: selectedConnection, confirm: true }),
  234. );
  235. setSettings(saved);
  236. settingsForm.setFieldsValue(saved);
  237. message.success('接待设置已保存');
  238. await reloadTopLevel();
  239. },
  240. });
  241. };
  242. const testFeishuWebhook = () => {
  243. const values = settingsForm.getFieldsValue([
  244. 'feishu_webhook_url',
  245. 'feishu_webhook_signing_secret',
  246. ]);
  247. const payload: Record<string, unknown> = {
  248. connection_id: selectedConnection,
  249. confirm: true,
  250. };
  251. if (values.feishu_webhook_url?.trim()) {
  252. payload.feishu_webhook_url = values.feishu_webhook_url.trim();
  253. }
  254. if (values.feishu_webhook_signing_secret?.trim()) {
  255. payload.feishu_webhook_signing_secret = values.feishu_webhook_signing_secret.trim();
  256. }
  257. Modal.confirm({
  258. title: '发送飞书测试提醒?',
  259. content: '系统会向当前填写或已保存的群机器人 Webhook 发送一条测试消息。',
  260. okText: '发送测试',
  261. cancelText: '取消',
  262. onOk: async () => {
  263. setFeishuTesting(true);
  264. try {
  265. await apiRequest(
  266. '/business-assistant/feishu-webhook/test',
  267. jsonOptions('POST', payload),
  268. );
  269. message.success('飞书测试提醒发送成功');
  270. } finally {
  271. setFeishuTesting(false);
  272. }
  273. },
  274. });
  275. };
  276. const openKnowledge = (entry?: AssistantKnowledgeEntry) => {
  277. setKnowledgeTarget(entry || null);
  278. knowledgeForm.setFieldsValue(
  279. entry || {
  280. question: '',
  281. aliases: [],
  282. keywords: [],
  283. answer: '',
  284. tags: [],
  285. priority: 0,
  286. enabled: true,
  287. },
  288. );
  289. setKnowledgeOpen(true);
  290. };
  291. const saveKnowledge = (values: KnowledgeForm) => {
  292. Modal.confirm({
  293. title: knowledgeTarget ? '保存知识条目?' : '创建知识条目?',
  294. okText: '确认保存',
  295. cancelText: '取消',
  296. onOk: async () => {
  297. const path = knowledgeTarget
  298. ? `/business-assistant/knowledge/${knowledgeTarget.entry_id}`
  299. : '/business-assistant/knowledge';
  300. await apiRequest(
  301. path,
  302. jsonOptions(knowledgeTarget ? 'PUT' : 'POST', {
  303. ...values,
  304. connection_id: selectedConnection,
  305. confirm: true,
  306. }),
  307. );
  308. setKnowledgeOpen(false);
  309. message.success(knowledgeTarget ? '知识条目已更新' : '知识条目已创建');
  310. await loadConnectionData(selectedConnection);
  311. },
  312. });
  313. };
  314. const runKnowledgeTest = async () => {
  315. if (!testQuestion.trim()) return;
  316. try {
  317. const result = await apiRequest<Record<string, unknown>>(
  318. '/business-assistant/knowledge/test',
  319. jsonOptions('POST', { connection_id: selectedConnection, text: testQuestion }),
  320. );
  321. setTestResult(result);
  322. } catch (error) {
  323. setTestResult(null);
  324. message.error(error instanceof Error ? error.message : '测试失败');
  325. }
  326. };
  327. const loadConversationDetail = async (conversation: AssistantConversation) => {
  328. setDetailLoading(true);
  329. setConversationDetail(conversation);
  330. try {
  331. setConversationDetail(
  332. await apiRequest<AssistantConversation>(
  333. `/business-assistant/conversations/${conversation.conversation_id}`,
  334. ),
  335. );
  336. } catch (error) {
  337. message.error(error instanceof Error ? error.message : '会话详情加载失败');
  338. } finally {
  339. setDetailLoading(false);
  340. }
  341. };
  342. const conversationAction = (
  343. conversation: AssistantConversation,
  344. action: 'pause' | 'resume' | 'close' | 'clear',
  345. ) => {
  346. const labels = { pause: '暂停自动回复', resume: '恢复自动回复', close: '关闭会话', clear: '清空会话' };
  347. Modal.confirm({
  348. title: `${labels[action]}?`,
  349. content: action === 'clear' ? '原始消息和长期摘要都会被删除,无法恢复。' : undefined,
  350. okText: '确认执行',
  351. okButtonProps: action === 'clear' ? { danger: true } : undefined,
  352. cancelText: '取消',
  353. onOk: async () => {
  354. await apiRequest(
  355. `/business-assistant/conversations/${conversation.conversation_id}/actions`,
  356. jsonOptions('POST', { action, confirm: true }),
  357. );
  358. message.success(`${labels[action]}成功`);
  359. if (conversationDetail?.conversation_id === conversation.conversation_id) {
  360. setConversationDetail(null);
  361. }
  362. await loadConnectionData(selectedConnection);
  363. },
  364. });
  365. };
  366. const knowledgeColumns: ColumnsType<AssistantKnowledgeEntry> = [
  367. {
  368. title: '标准问题',
  369. dataIndex: 'question',
  370. minWidth: 220,
  371. render: (value: string, entry) => (
  372. <div>
  373. <Typography.Text strong>{value}</Typography.Text>
  374. <div style={{ marginTop: 4 }}>
  375. <Space size={[4, 4]} wrap>
  376. {entry.keywords.map((keyword) => <Tag key={keyword}>{keyword}</Tag>)}
  377. </Space>
  378. </div>
  379. </div>
  380. ),
  381. },
  382. { title: '标准答案', dataIndex: 'answer', ellipsis: true, minWidth: 280 },
  383. { title: '优先级', dataIndex: 'priority', width: 88 },
  384. {
  385. title: '状态',
  386. dataIndex: 'enabled',
  387. width: 88,
  388. render: (enabled: boolean) => <Tag color={enabled ? 'success' : 'default'}>{enabled ? '启用' : '停用'}</Tag>,
  389. },
  390. {
  391. title: '操作',
  392. key: 'actions',
  393. width: 112,
  394. fixed: 'right',
  395. render: (_, entry) => (
  396. <Space size={4}>
  397. <Button aria-label={`编辑知识 ${entry.question}`} icon={<Pencil size={16} />} onClick={() => openKnowledge(entry)} />
  398. <Button
  399. danger
  400. aria-label={`删除知识 ${entry.question}`}
  401. icon={<Trash2 size={16} />}
  402. onClick={() =>
  403. Modal.confirm({
  404. title: `删除“${entry.question}”?`,
  405. content: '删除后无法用于自动回答,操作不可撤销。',
  406. okText: '确认删除',
  407. okButtonProps: { danger: true },
  408. cancelText: '取消',
  409. onOk: async () => {
  410. await apiRequest(
  411. `/business-assistant/knowledge/${entry.entry_id}`,
  412. jsonOptions('DELETE', { confirm: true }),
  413. );
  414. message.success('知识条目已删除');
  415. await loadConnectionData(selectedConnection);
  416. },
  417. })
  418. }
  419. />
  420. </Space>
  421. ),
  422. },
  423. ];
  424. const conversationColumns: ColumnsType<AssistantConversation> = [
  425. {
  426. title: '客户',
  427. key: 'customer',
  428. minWidth: 180,
  429. render: (_, conversation) => (
  430. <Button type="link" style={{ paddingInline: 0 }} onClick={() => void loadConversationDetail(conversation)}>
  431. {customerName(conversation)}
  432. </Button>
  433. ),
  434. },
  435. {
  436. title: '状态',
  437. dataIndex: 'status',
  438. width: 110,
  439. render: (value: AssistantConversationStatus) => (
  440. <Tag color={conversationColors[value]}>{conversationLabels[value]}</Tag>
  441. ),
  442. },
  443. { title: '摘要', dataIndex: 'summary', ellipsis: true, minWidth: 280, render: (value) => value || '暂无摘要' },
  444. { title: '转人工原因', dataIndex: 'handoff_reason', width: 180, render: (value) => value || '—' },
  445. {
  446. title: '最近消息',
  447. dataIndex: 'last_message_at',
  448. width: 160,
  449. render: (value: string) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—'),
  450. },
  451. {
  452. title: '操作',
  453. key: 'actions',
  454. fixed: 'right',
  455. width: 112,
  456. render: (_, conversation) => (
  457. <Space size={4}>
  458. {conversation.status === 'auto' ? (
  459. <Button aria-label="暂停自动回复" icon={<Pause size={16} />} onClick={() => conversationAction(conversation, 'pause')} />
  460. ) : (
  461. <Button aria-label="恢复自动回复" icon={<Play size={16} />} onClick={() => conversationAction(conversation, 'resume')} />
  462. )}
  463. <Button aria-label="查看会话" icon={<BotMessageSquare size={16} />} onClick={() => void loadConversationDetail(conversation)} />
  464. </Space>
  465. ),
  466. },
  467. ];
  468. return (
  469. <Spin spinning={loading}>
  470. <PageHeader
  471. title="智能接待"
  472. actions={<Button icon={<RefreshCw size={16} />} onClick={() => void reloadTopLevel()}>刷新状态</Button>}
  473. />
  474. <div className="metrics-grid">
  475. <Metric label="轮询状态" value={status?.polling_state || '未知'} />
  476. <Metric label="已连接账号" value={connections.length} />
  477. <Metric label="AI 调用" value={status?.usage.ai_calls || 0} />
  478. <Metric label="待人工" value={status?.usage.handoffs || 0} />
  479. </div>
  480. {status?.last_error ? (
  481. <Alert type="warning" showIcon message="智能接待尚未正常运行" description={status.last_error} style={{ marginBottom: 16 }} />
  482. ) : null}
  483. {status?.webhook_conflict ? (
  484. <Alert type="error" showIcon message="检测到 Webhook 冲突" description={`当前 URL:${status.webhook_url}。系统不会自动删除外部 Webhook。`} style={{ marginBottom: 16 }} />
  485. ) : null}
  486. <Surface title="OpenAI 兼容模型">
  487. <Form form={providerForm} layout="vertical" onFinish={saveProvider}>
  488. <Row gutter={16} align="bottom">
  489. <Col xs={24} lg={8}>
  490. <Form.Item name="base_url" label="Base URL" rules={[{ required: true, message: '请输入 Base URL' }, { type: 'url', message: '请输入有效 URL' }]}>
  491. <Input placeholder="https://api.example.com/v1" />
  492. </Form.Item>
  493. </Col>
  494. <Col xs={24} lg={6}>
  495. <Form.Item name="api_key" label="API Key" extra={provider?.api_key_configured ? '已配置,留空保持不变' : '尚未配置'}>
  496. <Input.Password autoComplete="new-password" />
  497. </Form.Item>
  498. </Col>
  499. <Col xs={24} md={8} lg={4}>
  500. <Form.Item name="model" label="模型" rules={[{ required: true, message: '请输入模型名称' }]}>
  501. <Input placeholder="gpt-4.1-mini" />
  502. </Form.Item>
  503. </Col>
  504. <Col xs={12} md={4} lg={2}>
  505. <Form.Item name="timeout_seconds" label="超时秒数">
  506. <InputNumber min={5} max={120} style={{ width: '100%' }} />
  507. </Form.Item>
  508. </Col>
  509. <Col xs={12} md={4} lg={2}>
  510. <Form.Item name="max_output_tokens" label="输出上限">
  511. <InputNumber min={100} max={2000} style={{ width: '100%' }} />
  512. </Form.Item>
  513. </Col>
  514. <Col xs={24} lg={2}>
  515. <Form.Item>
  516. <Button type="primary" htmlType="submit" icon={<Save size={16} />} block>保存</Button>
  517. </Form.Item>
  518. </Col>
  519. </Row>
  520. </Form>
  521. <Space wrap>
  522. <Tag color={provider?.configured ? 'success' : 'warning'}>{provider?.configured ? '模型已配置' : '模型未配置'}</Tag>
  523. <Tag color={bot?.identity?.can_connect_to_business ? 'success' : 'warning'}>
  524. {bot?.identity?.can_connect_to_business ? 'BotFather Business Mode 已开启' : '需要在 BotFather 开启 Business Mode'}
  525. </Tag>
  526. <Button
  527. icon={<FlaskConical size={16} />}
  528. disabled={!provider?.configured}
  529. onClick={() =>
  530. Modal.confirm({
  531. title: '测试模型连接?',
  532. okText: '开始测试',
  533. cancelText: '取消',
  534. onOk: async () => {
  535. const result = await apiRequest<{ model: string }>(
  536. '/business-assistant/model/test',
  537. jsonOptions('POST', { confirm: true }),
  538. );
  539. message.success(`模型连接正常:${result.model}`);
  540. },
  541. })
  542. }
  543. >
  544. 测试模型
  545. </Button>
  546. </Space>
  547. </Surface>
  548. <div style={{ height: 16 }} />
  549. <Surface
  550. title="Business 连接账号"
  551. actions={connections.length ? (
  552. <Select
  553. aria-label="当前 Business 连接"
  554. value={selectedConnection || undefined}
  555. style={{ minWidth: 240 }}
  556. options={connections.map((connection) => ({
  557. value: connection.connection_id,
  558. label: connectionName(connection),
  559. }))}
  560. onChange={setSelectedConnection}
  561. />
  562. ) : undefined}
  563. >
  564. {!connections.length ? (
  565. <Empty
  566. description={
  567. <span>
  568. 尚无连接。请先在 BotFather 开启 Business Mode,再由账号本人进入
  569. “设置 → Telegram Business/聊天自动化 → Chatbots”连接当前 Bot。
  570. </span>
  571. }
  572. />
  573. ) : activeConnection ? (
  574. <Descriptions column={{ xs: 1, md: 2, lg: 4 }} size="small" bordered>
  575. <Descriptions.Item label="账号">{connectionName(activeConnection)}</Descriptions.Item>
  576. <Descriptions.Item label="连接状态">
  577. <Tag color={activeConnection.is_enabled ? 'success' : 'default'}>{activeConnection.is_enabled ? '已连接' : '已停用'}</Tag>
  578. </Descriptions.Item>
  579. <Descriptions.Item label="回复权限">
  580. <Tag color={activeConnection.rights.can_reply ? 'success' : 'error'}>{activeConnection.rights.can_reply ? '允许回复' : '未授权'}</Tag>
  581. </Descriptions.Item>
  582. <Descriptions.Item label="读取权限">
  583. <Tag color={activeConnection.rights.can_read_messages ? 'success' : 'warning'}>
  584. {activeConnection.rights.can_read_messages ? '允许标记已读' : '未授权'}
  585. </Tag>
  586. </Descriptions.Item>
  587. <Descriptions.Item label="最后事件">
  588. {activeConnection.last_event_at
  589. ? dayjs(activeConnection.last_event_at).format('YYYY-MM-DD HH:mm:ss')
  590. : '—'}
  591. </Descriptions.Item>
  592. </Descriptions>
  593. ) : null}
  594. </Surface>
  595. {activeConnection && settings ? (
  596. <>
  597. <div style={{ height: 16 }} />
  598. <Tabs
  599. items={[
  600. {
  601. key: 'settings',
  602. label: '接待设置',
  603. children: (
  604. <Surface>
  605. <Form form={settingsForm} layout="vertical" onFinish={saveSettings}>
  606. <Row gutter={16}>
  607. <Col xs={24} md={8}>
  608. <Form.Item name="assistant_enabled" label="自动接待" valuePropName="checked">
  609. <Switch checkedChildren="开启" unCheckedChildren="关闭" />
  610. </Form.Item>
  611. </Col>
  612. <Col xs={12} md={4}>
  613. <Form.Item name="account_daily_limit" label="账号每日额度"><InputNumber min={1} max={100000} style={{ width: '100%' }} /></Form.Item>
  614. </Col>
  615. <Col xs={12} md={4}>
  616. <Form.Item name="customer_daily_limit" label="客户每日额度"><InputNumber min={1} max={1000} style={{ width: '100%' }} /></Form.Item>
  617. </Col>
  618. <Col xs={12} md={4}>
  619. <Form.Item name="human_pause_hours" label="人工暂停小时"><InputNumber min={1} max={168} style={{ width: '100%' }} /></Form.Item>
  620. </Col>
  621. <Col xs={12} md={4}>
  622. <Form.Item name="tone" label="接待语气">
  623. <Select options={[{ value: 'professional', label: '专业' }, { value: 'friendly', label: '友好' }, { value: 'concise', label: '简洁' }]} />
  624. </Form.Item>
  625. </Col>
  626. <Col xs={24}>
  627. <Form.Item name="system_prompt" label="接待规则" rules={[{ required: true }]}>
  628. <Input.TextArea rows={4} maxLength={4000} showCount />
  629. </Form.Item>
  630. </Col>
  631. <Col xs={24} md={8}>
  632. <Form.Item name="notification_destination" label="人工通知目的地">
  633. <Select options={[{ value: 'owner', label: '账号本人' }, { value: 'ops', label: '运营群' }, { value: 'both', label: '本人和运营群' }]} />
  634. </Form.Item>
  635. </Col>
  636. <Col xs={24} md={8}>
  637. <Form.Item name="ops_group_id" label="运营群 ID" extra="选择运营群或双通知时填写。"><Input /></Form.Item>
  638. </Col>
  639. <Col xs={24} md={8}>
  640. <Form.Item name="timezone" label="时区"><Input placeholder="Asia/Shanghai" /></Form.Item>
  641. </Col>
  642. <Col xs={24} md={8}>
  643. <Form.Item name="digest_enabled" label="每日运营简报" valuePropName="checked"><Switch /></Form.Item>
  644. </Col>
  645. <Col xs={24} md={8}>
  646. <Form.Item name="digest_time" label="简报时间" extra="24 小时制 HH:MM"><Input placeholder="09:00" /></Form.Item>
  647. </Col>
  648. <Col xs={24}>
  649. <Typography.Title level={5} style={{ marginTop: 8 }}>飞书新消息提醒</Typography.Title>
  650. <Alert
  651. type="info"
  652. showIcon
  653. message="第一期按每条客户消息提醒"
  654. description="启用后,每一条新的客户 Business 消息都会推送到飞书群;账号本人回复、机器人消息及 Telegram 原生欢迎/离线自动消息不会触发。"
  655. style={{ marginBottom: 16 }}
  656. />
  657. </Col>
  658. <Col xs={24} md={8}>
  659. <Form.Item name="feishu_webhook_enabled" label="飞书群提醒" valuePropName="checked">
  660. <Switch checkedChildren="开启" unCheckedChildren="关闭" />
  661. </Form.Item>
  662. </Col>
  663. <Col xs={24} md={8}>
  664. <Form.Item name="feishu_message_preview_enabled" label="包含消息内容" valuePropName="checked" extra="关闭时只发送账号、客户、消息类型和标识。">
  665. <Switch checkedChildren="包含" unCheckedChildren="隐藏" />
  666. </Form.Item>
  667. </Col>
  668. <Col xs={24} md={8}>
  669. <Form.Item label="配置状态">
  670. <Space wrap>
  671. <Tag color={settings.feishu_webhook_configured ? 'success' : 'warning'}>
  672. {settings.feishu_webhook_configured ? 'Webhook 已配置' : 'Webhook 未配置'}
  673. </Tag>
  674. {settings.feishu_signing_secret_configured ? <Tag color="success">签名已配置</Tag> : null}
  675. </Space>
  676. </Form.Item>
  677. </Col>
  678. <Col xs={24} lg={14}>
  679. <Form.Item
  680. name="feishu_webhook_url"
  681. label="飞书群机器人 Webhook"
  682. extra={settings.feishu_webhook_configured ? '已保存的地址不会回显;留空会保留,填写新地址会替换。' : '仅支持飞书或 Lark 官方群机器人 Webhook。'}
  683. dependencies={['feishu_webhook_enabled']}
  684. rules={[
  685. ({ getFieldValue }) => ({
  686. validator: (_, value) => {
  687. if (getFieldValue('feishu_webhook_enabled') && !value && !settings.feishu_webhook_configured) {
  688. return Promise.reject(new Error('开启提醒前请填写 Webhook 地址'));
  689. }
  690. return Promise.resolve();
  691. },
  692. }),
  693. ]}
  694. >
  695. <Input.Password
  696. autoComplete="new-password"
  697. placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
  698. style={{ minHeight: 44 }}
  699. />
  700. </Form.Item>
  701. </Col>
  702. <Col xs={24} lg={10}>
  703. <Form.Item
  704. name="feishu_webhook_signing_secret"
  705. label="签名密钥(可选)"
  706. extra={settings.feishu_signing_secret_configured ? '签名已配置;留空保留,填写新密钥会替换。' : '仅在飞书群机器人启用了“签名校验”时填写。'}
  707. >
  708. <Input.Password
  709. autoComplete="new-password"
  710. placeholder="飞书群机器人签名密钥"
  711. style={{ minHeight: 44 }}
  712. />
  713. </Form.Item>
  714. </Col>
  715. <Col xs={24}>
  716. <Form.Item>
  717. <Button
  718. icon={<BellRing size={16} />}
  719. loading={feishuTesting}
  720. disabled={!settings.feishu_webhook_configured && !feishuWebhookDraft?.trim()}
  721. onClick={testFeishuWebhook}
  722. style={{ minHeight: 44 }}
  723. >
  724. 发送测试提醒
  725. </Button>
  726. </Form.Item>
  727. </Col>
  728. <Col xs={24}>
  729. <Form.Item name="handoff_message" label="转人工提示"><Input.TextArea rows={2} maxLength={1000} /></Form.Item>
  730. </Col>
  731. <Col xs={24}>
  732. <Form.Item name="unsupported_message" label="非文本提示"><Input.TextArea rows={2} maxLength={1000} /></Form.Item>
  733. </Col>
  734. </Row>
  735. <Button type="primary" htmlType="submit" icon={<Save size={16} />}>保存接待设置</Button>
  736. </Form>
  737. </Surface>
  738. ),
  739. },
  740. {
  741. key: 'knowledge',
  742. label: `知识库 (${knowledge.total})`,
  743. children: (
  744. <Surface
  745. actions={
  746. <Space wrap>
  747. <Input.Search
  748. aria-label="搜索知识库"
  749. allowClear
  750. placeholder="问题、关键词或标签"
  751. onSearch={(value) => {
  752. setKnowledgeQuery(value);
  753. void loadConnectionData(selectedConnection, 1, { knowledgeQuery: value });
  754. }}
  755. />
  756. <Button type="primary" icon={<CirclePlus size={16} />} onClick={() => openKnowledge()}>新建知识</Button>
  757. </Space>
  758. }
  759. >
  760. <div className="table-wrap">
  761. <Table rowKey="entry_id" columns={knowledgeColumns} dataSource={knowledge.items} pagination={{ current: knowledge.page, pageSize: knowledge.page_size, total: knowledge.total, onChange: (page) => void loadConnectionData(selectedConnection, page) }} scroll={{ x: 900 }} locale={{ emptyText: '尚无知识条目' }} />
  762. </div>
  763. <div style={{ marginTop: 20 }}>
  764. <Typography.Title level={5}>测试问答</Typography.Title>
  765. <Space.Compact style={{ width: '100%' }}>
  766. <Input value={testQuestion} onChange={(event) => setTestQuestion(event.target.value)} placeholder="输入客户可能提出的问题" onPressEnter={() => void runKnowledgeTest()} />
  767. <Button type="primary" icon={<FlaskConical size={16} />} onClick={() => void runKnowledgeTest()}>测试</Button>
  768. </Space.Compact>
  769. {testResult ? (
  770. <Alert type={testResult.action === 'handoff' ? 'warning' : 'success'} showIcon message={`处理结果:${String(testResult.action || 'unknown')}`} description={String(testResult.reply || testResult.reason || '')} style={{ marginTop: 12 }} />
  771. ) : null}
  772. </div>
  773. </Surface>
  774. ),
  775. },
  776. {
  777. key: 'knowledge-sources',
  778. label: '知识来源',
  779. children: <KnowledgeSourcesPanel connectionId={selectedConnection} />,
  780. },
  781. {
  782. key: 'knowledge-candidates',
  783. label: '知识候选',
  784. children: (
  785. <KnowledgeCandidatesPanel
  786. connectionId={selectedConnection}
  787. onKnowledgeChanged={() => loadConnectionData(selectedConnection)}
  788. />
  789. ),
  790. },
  791. {
  792. key: 'conversations',
  793. label: `会话台 (${conversations.total})`,
  794. children: (
  795. <Surface
  796. actions={
  797. <Select
  798. aria-label="会话状态"
  799. allowClear
  800. placeholder="全部状态"
  801. value={conversationStatus || undefined}
  802. style={{ width: 160 }}
  803. options={Object.entries(conversationLabels).map(([value, label]) => ({ value, label }))}
  804. onChange={(value) => setConversationStatus(value || '')}
  805. />
  806. }
  807. >
  808. <div className="metrics-grid" style={{ marginBottom: 16 }}>
  809. <Metric label="客户" value={usage?.customers || 0} />
  810. <Metric label="AI 调用" value={usage?.ai_calls || 0} />
  811. <Metric label="待人工" value={usage?.handoffs || 0} />
  812. <Metric label="人工接管" value={usage?.human_paused || 0} />
  813. </div>
  814. <div className="table-wrap">
  815. <Table rowKey="conversation_id" columns={conversationColumns} dataSource={conversations.items} pagination={{ current: conversations.page, pageSize: conversations.page_size, total: conversations.total, onChange: (page) => void loadConnectionData(selectedConnection, page) }} scroll={{ x: 980 }} locale={{ emptyText: '暂无客户会话' }} />
  816. </div>
  817. </Surface>
  818. ),
  819. },
  820. ]}
  821. />
  822. </>
  823. ) : null}
  824. <Modal title={knowledgeTarget ? '编辑知识条目' : '新建知识条目'} open={knowledgeOpen} onCancel={() => setKnowledgeOpen(false)} onOk={() => knowledgeForm.submit()} okText="继续" cancelText="取消" width={720} destroyOnHidden>
  825. <Form form={knowledgeForm} layout="vertical" onFinish={saveKnowledge} preserve={false}>
  826. <Form.Item name="question" label="标准问题" rules={[{ required: true, message: '请输入标准问题' }]}><Input maxLength={300} /></Form.Item>
  827. <Form.Item name="aliases" label="相似问法"><Select mode="tags" tokenSeparators={[',', ',']} placeholder="输入后按回车" /></Form.Item>
  828. <Form.Item name="keywords" label="匹配关键词"><Select mode="tags" tokenSeparators={[',', ',']} placeholder="建议填写客户消息中会出现的核心词" /></Form.Item>
  829. <Form.Item name="answer" label="标准答案" rules={[{ required: true, message: '请输入标准答案' }]}><Input.TextArea rows={6} maxLength={4000} showCount /></Form.Item>
  830. <Form.Item name="tags" label="标签"><Select mode="tags" tokenSeparators={[',', ',']} /></Form.Item>
  831. <Row gutter={16}>
  832. <Col xs={12}><Form.Item name="priority" label="优先级"><InputNumber min={-1000} max={1000} style={{ width: '100%' }} /></Form.Item></Col>
  833. <Col xs={12}><Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item></Col>
  834. </Row>
  835. </Form>
  836. </Modal>
  837. <Drawer title={conversationDetail ? `客户会话 · ${customerName(conversationDetail)}` : '客户会话'} open={Boolean(conversationDetail)} onClose={() => setConversationDetail(null)} width={680} extra={conversationDetail ? <Space wrap><Button icon={conversationDetail.status === 'auto' ? <Pause size={16} /> : <Play size={16} />} onClick={() => conversationAction(conversationDetail, conversationDetail.status === 'auto' ? 'pause' : 'resume')}>{conversationDetail.status === 'auto' ? '暂停' : '恢复'}</Button>{conversationDetail.status !== 'closed' ? <Button icon={<CircleCheck size={16} />} onClick={() => conversationAction(conversationDetail, 'close')}>关闭</Button> : null}<Button danger icon={<Trash2 size={16} />} onClick={() => conversationAction(conversationDetail, 'clear')}>清空</Button></Space> : null}>
  838. <Spin spinning={detailLoading}>
  839. {conversationDetail ? (
  840. <>
  841. <Descriptions column={1} size="small" bordered>
  842. <Descriptions.Item label="状态"><Tag color={conversationColors[conversationDetail.status]}>{conversationLabels[conversationDetail.status]}</Tag></Descriptions.Item>
  843. <Descriptions.Item label="摘要">{conversationDetail.summary || '暂无摘要'}</Descriptions.Item>
  844. <Descriptions.Item label="转人工原因">{conversationDetail.handoff_reason || '—'}</Descriptions.Item>
  845. </Descriptions>
  846. <div style={{ marginTop: 16 }}>
  847. <Space direction="vertical" size={10} style={{ width: '100%' }}>
  848. {(conversationDetail.messages || []).map((item) => (
  849. <div key={item.message_key} style={{ padding: 12, border: '1px solid #e2e8f0', borderRadius: 6, background: item.direction === 'incoming' ? '#fff' : '#f6f8fa' }}>
  850. <Space size={8}>
  851. <Tag>{item.direction === 'incoming' ? '客户' : item.direction === 'human' ? '人工' : 'AI'}</Tag>
  852. <Typography.Text type="secondary">{dayjs(item.created_at).format('YYYY-MM-DD HH:mm:ss')}</Typography.Text>
  853. </Space>
  854. <Typography.Paragraph style={{ margin: '8px 0 0', whiteSpace: 'pre-wrap' }}>{item.text}</Typography.Paragraph>
  855. </div>
  856. ))}
  857. {!conversationDetail.messages?.length ? <Empty description="原始消息已清理或尚无消息" /> : null}
  858. </Space>
  859. </div>
  860. </>
  861. ) : null}
  862. </Spin>
  863. </Drawer>
  864. </Spin>
  865. );
  866. }