BusinessAssistant.tsx 41 KB

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