| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899 |
- import {
- Alert,
- Button,
- Col,
- Descriptions,
- Drawer,
- Empty,
- Form,
- Input,
- InputNumber,
- Modal,
- Row,
- Select,
- Space,
- Spin,
- Switch,
- Table,
- Tabs,
- Tag,
- Typography,
- message,
- } from 'antd';
- import type { ColumnsType } from 'antd/es/table';
- import dayjs from 'dayjs';
- import {
- BellRing,
- BotMessageSquare,
- CircleCheck,
- CirclePlus,
- FlaskConical,
- Pause,
- Pencil,
- Play,
- RefreshCw,
- Save,
- Trash2,
- } from 'lucide-react';
- import { useEffect, useMemo, useState } from 'react';
- import { Metric, PageHeader, Surface } from '@/components/Page';
- import { useBotAccess } from '@/contexts/BotAccess';
- import {
- KnowledgeCandidatesPanel,
- KnowledgeSourcesPanel,
- } from '@/pages/BusinessKnowledgeSources';
- import { apiRequest, jsonOptions } from '@/services/api';
- import type {
- AssistantAccountSettings,
- AssistantConnection,
- AssistantConversation,
- AssistantConversationStatus,
- AssistantKnowledgeEntry,
- AssistantRuntimeStatus,
- AssistantUsage,
- Paged,
- } from '@/types';
- type ProviderForm = {
- base_url: string;
- api_key?: string;
- model: string;
- timeout_seconds: number;
- max_output_tokens: number;
- };
- type KnowledgeForm = Pick<
- AssistantKnowledgeEntry,
- 'question' | 'aliases' | 'keywords' | 'answer' | 'tags' | 'priority' | 'enabled'
- >;
- const emptyPage = <T,>(): Paged<T> => ({ items: [], total: 0, page: 1, page_size: 20 });
- const conversationLabels: Record<AssistantConversationStatus, string> = {
- auto: '自动接待',
- handoff: '待人工',
- human_paused: '人工接管',
- closed: '已关闭',
- };
- const conversationColors: Record<AssistantConversationStatus, string> = {
- auto: 'success',
- handoff: 'error',
- human_paused: 'warning',
- closed: 'default',
- };
- function connectionName(connection: AssistantConnection): string {
- const name = [connection.user.first_name, connection.user.last_name].filter(Boolean).join(' ');
- return name || (connection.user.username ? `@${connection.user.username}` : connection.user.id);
- }
- function customerName(conversation: AssistantConversation): string {
- const customer = conversation.customer;
- if (!customer) return conversation.chat_id;
- const name = [customer.first_name, customer.last_name].filter(Boolean).join(' ');
- return name || (customer.username ? `@${customer.username}` : customer.id);
- }
- export default function BusinessAssistantPage() {
- const { bot } = useBotAccess();
- const [loading, setLoading] = useState(false);
- const [status, setStatus] = useState<AssistantRuntimeStatus | null>(null);
- const [connections, setConnections] = useState<AssistantConnection[]>([]);
- const [selectedConnection, setSelectedConnection] = useState('');
- const [settings, setSettings] = useState<AssistantAccountSettings | null>(null);
- const [usage, setUsage] = useState<AssistantUsage | null>(null);
- const [knowledge, setKnowledge] = useState<Paged<AssistantKnowledgeEntry>>(emptyPage());
- const [conversations, setConversations] = useState<Paged<AssistantConversation>>(emptyPage());
- const [conversationStatus, setConversationStatus] = useState('');
- const [knowledgeQuery, setKnowledgeQuery] = useState('');
- const [knowledgeOpen, setKnowledgeOpen] = useState(false);
- const [knowledgeTarget, setKnowledgeTarget] = useState<AssistantKnowledgeEntry | null>(null);
- const [conversationDetail, setConversationDetail] = useState<AssistantConversation | null>(null);
- const [detailLoading, setDetailLoading] = useState(false);
- const [testQuestion, setTestQuestion] = useState('');
- const [testResult, setTestResult] = useState<Record<string, unknown> | null>(null);
- const [feishuTesting, setFeishuTesting] = useState(false);
- const [providerForm] = Form.useForm<ProviderForm>();
- const [settingsForm] = Form.useForm<AssistantAccountSettings>();
- const [knowledgeForm] = Form.useForm<KnowledgeForm>();
- const feishuWebhookDraft = Form.useWatch('feishu_webhook_url', settingsForm);
- const provider = bot?.business_assistant;
- const reloadTopLevel = async () => {
- setLoading(true);
- try {
- const [statusValue, connectionPage] = await Promise.all([
- apiRequest<AssistantRuntimeStatus>('/business-assistant/status'),
- apiRequest<Paged<AssistantConnection>>('/business-assistant/connections?page=1&page_size=100'),
- ]);
- setStatus(statusValue);
- setConnections(connectionPage.items);
- setSelectedConnection((current) =>
- current && connectionPage.items.some((item) => item.connection_id === current)
- ? current
- : connectionPage.items[0]?.connection_id || '',
- );
- } catch (error) {
- message.error(error instanceof Error ? error.message : '智能接待状态加载失败');
- setConnections([]);
- } finally {
- setLoading(false);
- }
- };
- const loadConnectionData = async (
- connectionId: string,
- page = 1,
- filters?: { knowledgeQuery?: string; conversationStatus?: string },
- ) => {
- if (!connectionId) {
- setSettings(null);
- setKnowledge(emptyPage());
- setConversations(emptyPage());
- setUsage(null);
- return;
- }
- const params = new URLSearchParams({ connection_id: connectionId });
- const knowledgeParams = new URLSearchParams({
- connection_id: connectionId,
- query: filters?.knowledgeQuery ?? knowledgeQuery,
- page: String(page),
- page_size: '20',
- });
- const conversationParams = new URLSearchParams({
- connection_id: connectionId,
- status: filters?.conversationStatus ?? conversationStatus,
- page: String(page),
- page_size: '20',
- });
- setLoading(true);
- try {
- const [settingsValue, knowledgeValue, conversationValue, usageValue] = await Promise.all([
- apiRequest<AssistantAccountSettings>(`/business-assistant/settings?${params}`),
- apiRequest<Paged<AssistantKnowledgeEntry>>(`/business-assistant/knowledge?${knowledgeParams}`),
- apiRequest<Paged<AssistantConversation>>(
- `/business-assistant/conversations?${conversationParams}`,
- ),
- apiRequest<AssistantUsage>(`/business-assistant/usage?${params}`),
- ]);
- setSettings(settingsValue);
- settingsForm.setFieldsValue(settingsValue);
- setKnowledge(knowledgeValue);
- setConversations(conversationValue);
- setUsage(usageValue);
- } catch (error) {
- message.error(error instanceof Error ? error.message : '连接数据加载失败');
- } finally {
- setLoading(false);
- }
- };
- useEffect(() => {
- providerForm.setFieldsValue({
- base_url: provider?.base_url || '',
- model: provider?.model || '',
- timeout_seconds: provider?.timeout_seconds || 30,
- max_output_tokens: provider?.max_output_tokens || 600,
- api_key: '',
- });
- if (bot?.bot_id) void reloadTopLevel();
- }, [bot?.bot_id]);
- useEffect(() => {
- void loadConnectionData(selectedConnection);
- }, [selectedConnection, conversationStatus]);
- const activeConnection = useMemo(
- () => connections.find((item) => item.connection_id === selectedConnection) || null,
- [connections, selectedConnection],
- );
- const saveProvider = (values: ProviderForm) => {
- if (!bot) return;
- Modal.confirm({
- title: '更新模型配置并重启机器人?',
- content: 'API Key 留空会保留现有密钥。工作进程重启期间会短暂暂停接待。',
- okText: '确认保存',
- cancelText: '取消',
- onOk: async () => {
- await apiRequest(
- `/bots/${bot.bot_id}`,
- jsonOptions('PUT', {
- business_assistant: values,
- confirm: true,
- }),
- );
- message.success('模型配置已保存,机器人正在重启');
- window.dispatchEvent(new Event('bot-profiles-changed'));
- await reloadTopLevel();
- },
- });
- };
- const saveSettings = (values: AssistantAccountSettings) => {
- Modal.confirm({
- title: '保存该账号的接待设置?',
- content: values.assistant_enabled ? '保存后自动回复将按新规则运行。' : '保存后将停止自动回复。',
- okText: '确认保存',
- cancelText: '取消',
- onOk: async () => {
- const payload = { ...values };
- if (!payload.feishu_webhook_url?.trim()) delete payload.feishu_webhook_url;
- if (!payload.feishu_webhook_signing_secret?.trim()) {
- delete payload.feishu_webhook_signing_secret;
- }
- const saved = await apiRequest<AssistantAccountSettings>(
- '/business-assistant/settings',
- jsonOptions('PUT', { ...payload, connection_id: selectedConnection, confirm: true }),
- );
- setSettings(saved);
- settingsForm.setFieldsValue(saved);
- message.success('接待设置已保存');
- await reloadTopLevel();
- },
- });
- };
- const testFeishuWebhook = () => {
- const values = settingsForm.getFieldsValue([
- 'feishu_webhook_url',
- 'feishu_webhook_signing_secret',
- ]);
- const payload: Record<string, unknown> = {
- connection_id: selectedConnection,
- confirm: true,
- };
- if (values.feishu_webhook_url?.trim()) {
- payload.feishu_webhook_url = values.feishu_webhook_url.trim();
- }
- if (values.feishu_webhook_signing_secret?.trim()) {
- payload.feishu_webhook_signing_secret = values.feishu_webhook_signing_secret.trim();
- }
- Modal.confirm({
- title: '发送飞书测试提醒?',
- content: '系统会向当前填写或已保存的群机器人 Webhook 发送一条测试消息。',
- okText: '发送测试',
- cancelText: '取消',
- onOk: async () => {
- setFeishuTesting(true);
- try {
- await apiRequest(
- '/business-assistant/feishu-webhook/test',
- jsonOptions('POST', payload),
- );
- message.success('飞书测试提醒发送成功');
- } finally {
- setFeishuTesting(false);
- }
- },
- });
- };
- const openKnowledge = (entry?: AssistantKnowledgeEntry) => {
- setKnowledgeTarget(entry || null);
- knowledgeForm.setFieldsValue(
- entry || {
- question: '',
- aliases: [],
- keywords: [],
- answer: '',
- tags: [],
- priority: 0,
- enabled: true,
- },
- );
- setKnowledgeOpen(true);
- };
- const saveKnowledge = (values: KnowledgeForm) => {
- Modal.confirm({
- title: knowledgeTarget ? '保存知识条目?' : '创建知识条目?',
- okText: '确认保存',
- cancelText: '取消',
- onOk: async () => {
- const path = knowledgeTarget
- ? `/business-assistant/knowledge/${knowledgeTarget.entry_id}`
- : '/business-assistant/knowledge';
- await apiRequest(
- path,
- jsonOptions(knowledgeTarget ? 'PUT' : 'POST', {
- ...values,
- connection_id: selectedConnection,
- confirm: true,
- }),
- );
- setKnowledgeOpen(false);
- message.success(knowledgeTarget ? '知识条目已更新' : '知识条目已创建');
- await loadConnectionData(selectedConnection);
- },
- });
- };
- const runKnowledgeTest = async () => {
- if (!testQuestion.trim()) return;
- try {
- const result = await apiRequest<Record<string, unknown>>(
- '/business-assistant/knowledge/test',
- jsonOptions('POST', { connection_id: selectedConnection, text: testQuestion }),
- );
- setTestResult(result);
- } catch (error) {
- setTestResult(null);
- message.error(error instanceof Error ? error.message : '测试失败');
- }
- };
- const loadConversationDetail = async (conversation: AssistantConversation) => {
- setDetailLoading(true);
- setConversationDetail(conversation);
- try {
- setConversationDetail(
- await apiRequest<AssistantConversation>(
- `/business-assistant/conversations/${conversation.conversation_id}`,
- ),
- );
- } catch (error) {
- message.error(error instanceof Error ? error.message : '会话详情加载失败');
- } finally {
- setDetailLoading(false);
- }
- };
- const conversationAction = (
- conversation: AssistantConversation,
- action: 'pause' | 'resume' | 'close' | 'clear',
- ) => {
- const labels = { pause: '暂停自动回复', resume: '恢复自动回复', close: '关闭会话', clear: '清空会话' };
- Modal.confirm({
- title: `${labels[action]}?`,
- content: action === 'clear' ? '原始消息和长期摘要都会被删除,无法恢复。' : undefined,
- okText: '确认执行',
- okButtonProps: action === 'clear' ? { danger: true } : undefined,
- cancelText: '取消',
- onOk: async () => {
- await apiRequest(
- `/business-assistant/conversations/${conversation.conversation_id}/actions`,
- jsonOptions('POST', { action, confirm: true }),
- );
- message.success(`${labels[action]}成功`);
- if (conversationDetail?.conversation_id === conversation.conversation_id) {
- setConversationDetail(null);
- }
- await loadConnectionData(selectedConnection);
- },
- });
- };
- const knowledgeColumns: ColumnsType<AssistantKnowledgeEntry> = [
- {
- title: '标准问题',
- dataIndex: 'question',
- minWidth: 220,
- render: (value: string, entry) => (
- <div>
- <Typography.Text strong>{value}</Typography.Text>
- <div style={{ marginTop: 4 }}>
- <Space size={[4, 4]} wrap>
- {entry.keywords.map((keyword) => <Tag key={keyword}>{keyword}</Tag>)}
- </Space>
- </div>
- </div>
- ),
- },
- { title: '标准答案', dataIndex: 'answer', ellipsis: true, minWidth: 280 },
- { title: '优先级', dataIndex: 'priority', width: 88 },
- {
- title: '状态',
- dataIndex: 'enabled',
- width: 88,
- render: (enabled: boolean) => <Tag color={enabled ? 'success' : 'default'}>{enabled ? '启用' : '停用'}</Tag>,
- },
- {
- title: '操作',
- key: 'actions',
- width: 112,
- fixed: 'right',
- render: (_, entry) => (
- <Space size={4}>
- <Button aria-label={`编辑知识 ${entry.question}`} icon={<Pencil size={16} />} onClick={() => openKnowledge(entry)} />
- <Button
- danger
- aria-label={`删除知识 ${entry.question}`}
- icon={<Trash2 size={16} />}
- onClick={() =>
- Modal.confirm({
- title: `删除“${entry.question}”?`,
- content: '删除后无法用于自动回答,操作不可撤销。',
- okText: '确认删除',
- okButtonProps: { danger: true },
- cancelText: '取消',
- onOk: async () => {
- await apiRequest(
- `/business-assistant/knowledge/${entry.entry_id}`,
- jsonOptions('DELETE', { confirm: true }),
- );
- message.success('知识条目已删除');
- await loadConnectionData(selectedConnection);
- },
- })
- }
- />
- </Space>
- ),
- },
- ];
- const conversationColumns: ColumnsType<AssistantConversation> = [
- {
- title: '客户',
- key: 'customer',
- minWidth: 180,
- render: (_, conversation) => (
- <Button type="link" style={{ paddingInline: 0 }} onClick={() => void loadConversationDetail(conversation)}>
- {customerName(conversation)}
- </Button>
- ),
- },
- {
- title: '状态',
- dataIndex: 'status',
- width: 110,
- render: (value: AssistantConversationStatus) => (
- <Tag color={conversationColors[value]}>{conversationLabels[value]}</Tag>
- ),
- },
- { title: '摘要', dataIndex: 'summary', ellipsis: true, minWidth: 280, render: (value) => value || '暂无摘要' },
- { title: '转人工原因', dataIndex: 'handoff_reason', width: 180, render: (value) => value || '—' },
- {
- title: '最近消息',
- dataIndex: 'last_message_at',
- width: 160,
- render: (value: string) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—'),
- },
- {
- title: '操作',
- key: 'actions',
- fixed: 'right',
- width: 112,
- render: (_, conversation) => (
- <Space size={4}>
- {conversation.status === 'auto' ? (
- <Button aria-label="暂停自动回复" icon={<Pause size={16} />} onClick={() => conversationAction(conversation, 'pause')} />
- ) : (
- <Button aria-label="恢复自动回复" icon={<Play size={16} />} onClick={() => conversationAction(conversation, 'resume')} />
- )}
- <Button aria-label="查看会话" icon={<BotMessageSquare size={16} />} onClick={() => void loadConversationDetail(conversation)} />
- </Space>
- ),
- },
- ];
- return (
- <Spin spinning={loading}>
- <PageHeader
- title="智能接待"
- actions={<Button icon={<RefreshCw size={16} />} onClick={() => void reloadTopLevel()}>刷新状态</Button>}
- />
- <div className="metrics-grid">
- <Metric label="轮询状态" value={status?.polling_state || '未知'} />
- <Metric label="已连接账号" value={connections.length} />
- <Metric label="AI 调用" value={status?.usage.ai_calls || 0} />
- <Metric label="待人工" value={status?.usage.handoffs || 0} />
- </div>
- {status?.last_error ? (
- <Alert type="warning" showIcon message="智能接待尚未正常运行" description={status.last_error} style={{ marginBottom: 16 }} />
- ) : null}
- {status?.webhook_conflict ? (
- <Alert type="error" showIcon message="检测到 Webhook 冲突" description={`当前 URL:${status.webhook_url}。系统不会自动删除外部 Webhook。`} style={{ marginBottom: 16 }} />
- ) : null}
- <Surface title="OpenAI 兼容模型">
- <Form form={providerForm} layout="vertical" onFinish={saveProvider}>
- <Row gutter={16} align="bottom">
- <Col xs={24} lg={8}>
- <Form.Item name="base_url" label="Base URL" rules={[{ required: true, message: '请输入 Base URL' }, { type: 'url', message: '请输入有效 URL' }]}>
- <Input placeholder="https://api.example.com/v1" />
- </Form.Item>
- </Col>
- <Col xs={24} lg={6}>
- <Form.Item name="api_key" label="API Key" extra={provider?.api_key_configured ? '已配置,留空保持不变' : '尚未配置'}>
- <Input.Password autoComplete="new-password" />
- </Form.Item>
- </Col>
- <Col xs={24} md={8} lg={4}>
- <Form.Item name="model" label="模型" rules={[{ required: true, message: '请输入模型名称' }]}>
- <Input placeholder="gpt-4.1-mini" />
- </Form.Item>
- </Col>
- <Col xs={12} md={4} lg={2}>
- <Form.Item name="timeout_seconds" label="超时秒数">
- <InputNumber min={5} max={120} style={{ width: '100%' }} />
- </Form.Item>
- </Col>
- <Col xs={12} md={4} lg={2}>
- <Form.Item name="max_output_tokens" label="输出上限">
- <InputNumber min={100} max={2000} style={{ width: '100%' }} />
- </Form.Item>
- </Col>
- <Col xs={24} lg={2}>
- <Form.Item>
- <Button type="primary" htmlType="submit" icon={<Save size={16} />} block>保存</Button>
- </Form.Item>
- </Col>
- </Row>
- </Form>
- <Space wrap>
- <Tag color={provider?.configured ? 'success' : 'warning'}>{provider?.configured ? '模型已配置' : '模型未配置'}</Tag>
- <Tag color={bot?.identity?.can_connect_to_business ? 'success' : 'warning'}>
- {bot?.identity?.can_connect_to_business ? 'BotFather Business Mode 已开启' : '需要在 BotFather 开启 Business Mode'}
- </Tag>
- <Button
- icon={<FlaskConical size={16} />}
- disabled={!provider?.configured}
- onClick={() =>
- Modal.confirm({
- title: '测试模型连接?',
- okText: '开始测试',
- cancelText: '取消',
- onOk: async () => {
- const result = await apiRequest<{ model: string }>(
- '/business-assistant/model/test',
- jsonOptions('POST', { confirm: true }),
- );
- message.success(`模型连接正常:${result.model}`);
- },
- })
- }
- >
- 测试模型
- </Button>
- </Space>
- </Surface>
- <div style={{ height: 16 }} />
- <Surface
- title="Business 连接账号"
- actions={connections.length ? (
- <Select
- aria-label="当前 Business 连接"
- value={selectedConnection || undefined}
- style={{ minWidth: 240 }}
- options={connections.map((connection) => ({
- value: connection.connection_id,
- label: connectionName(connection),
- }))}
- onChange={setSelectedConnection}
- />
- ) : undefined}
- >
- {!connections.length ? (
- <Empty
- description={
- <span>
- 尚无连接。请先在 BotFather 开启 Business Mode,再由账号本人进入
- “设置 → Telegram Business/聊天自动化 → Chatbots”连接当前 Bot。
- </span>
- }
- />
- ) : activeConnection ? (
- <Descriptions column={{ xs: 1, md: 2, lg: 4 }} size="small" bordered>
- <Descriptions.Item label="账号">{connectionName(activeConnection)}</Descriptions.Item>
- <Descriptions.Item label="连接状态">
- <Tag color={activeConnection.is_enabled ? 'success' : 'default'}>{activeConnection.is_enabled ? '已连接' : '已停用'}</Tag>
- </Descriptions.Item>
- <Descriptions.Item label="回复权限">
- <Tag color={activeConnection.rights.can_reply ? 'success' : 'error'}>{activeConnection.rights.can_reply ? '允许回复' : '未授权'}</Tag>
- </Descriptions.Item>
- <Descriptions.Item label="读取权限">
- <Tag color={activeConnection.rights.can_read_messages ? 'success' : 'warning'}>
- {activeConnection.rights.can_read_messages ? '允许标记已读' : '未授权'}
- </Tag>
- </Descriptions.Item>
- <Descriptions.Item label="最后事件">
- {activeConnection.last_event_at
- ? dayjs(activeConnection.last_event_at).format('YYYY-MM-DD HH:mm:ss')
- : '—'}
- </Descriptions.Item>
- </Descriptions>
- ) : null}
- </Surface>
- {activeConnection && settings ? (
- <>
- <div style={{ height: 16 }} />
- <Tabs
- items={[
- {
- key: 'settings',
- label: '接待设置',
- children: (
- <Surface>
- <Form form={settingsForm} layout="vertical" onFinish={saveSettings}>
- <Row gutter={16}>
- <Col xs={24} md={8}>
- <Form.Item name="assistant_enabled" label="自动接待" valuePropName="checked">
- <Switch checkedChildren="开启" unCheckedChildren="关闭" />
- </Form.Item>
- </Col>
- <Col xs={12} md={4}>
- <Form.Item name="account_daily_limit" label="账号每日额度"><InputNumber min={1} max={100000} style={{ width: '100%' }} /></Form.Item>
- </Col>
- <Col xs={12} md={4}>
- <Form.Item name="customer_daily_limit" label="客户每日额度"><InputNumber min={1} max={1000} style={{ width: '100%' }} /></Form.Item>
- </Col>
- <Col xs={12} md={4}>
- <Form.Item name="human_pause_hours" label="人工暂停小时"><InputNumber min={1} max={168} style={{ width: '100%' }} /></Form.Item>
- </Col>
- <Col xs={12} md={4}>
- <Form.Item name="tone" label="接待语气">
- <Select options={[{ value: 'professional', label: '专业' }, { value: 'friendly', label: '友好' }, { value: 'concise', label: '简洁' }]} />
- </Form.Item>
- </Col>
- <Col xs={24}>
- <Form.Item name="system_prompt" label="接待规则" rules={[{ required: true }]}>
- <Input.TextArea rows={4} maxLength={4000} showCount />
- </Form.Item>
- </Col>
- <Col xs={24} md={8}>
- <Form.Item name="notification_destination" label="人工通知目的地">
- <Select options={[{ value: 'owner', label: '账号本人' }, { value: 'ops', label: '运营群' }, { value: 'both', label: '本人和运营群' }]} />
- </Form.Item>
- </Col>
- <Col xs={24} md={8}>
- <Form.Item name="ops_group_id" label="运营群 ID" extra="选择运营群或双通知时填写。"><Input /></Form.Item>
- </Col>
- <Col xs={24} md={8}>
- <Form.Item name="timezone" label="时区"><Input placeholder="Asia/Shanghai" /></Form.Item>
- </Col>
- <Col xs={24} md={8}>
- <Form.Item name="digest_enabled" label="每日运营简报" valuePropName="checked"><Switch /></Form.Item>
- </Col>
- <Col xs={24} md={8}>
- <Form.Item name="digest_time" label="简报时间" extra="24 小时制 HH:MM"><Input placeholder="09:00" /></Form.Item>
- </Col>
- <Col xs={24}>
- <Typography.Title level={5} style={{ marginTop: 8 }}>飞书新消息提醒</Typography.Title>
- <Alert
- type="info"
- showIcon
- message="第一期按每条客户消息提醒"
- description="启用后,每一条新的客户 Business 消息都会推送到飞书群;账号本人回复、机器人消息及 Telegram 原生欢迎/离线自动消息不会触发。"
- style={{ marginBottom: 16 }}
- />
- </Col>
- <Col xs={24} md={8}>
- <Form.Item name="feishu_webhook_enabled" label="飞书群提醒" valuePropName="checked">
- <Switch checkedChildren="开启" unCheckedChildren="关闭" />
- </Form.Item>
- </Col>
- <Col xs={24} md={8}>
- <Form.Item name="feishu_message_preview_enabled" label="包含消息内容" valuePropName="checked" extra="关闭时只发送账号、客户、消息类型和标识。">
- <Switch checkedChildren="包含" unCheckedChildren="隐藏" />
- </Form.Item>
- </Col>
- <Col xs={24} md={8}>
- <Form.Item label="配置状态">
- <Space wrap>
- <Tag color={settings.feishu_webhook_configured ? 'success' : 'warning'}>
- {settings.feishu_webhook_configured ? 'Webhook 已配置' : 'Webhook 未配置'}
- </Tag>
- {settings.feishu_signing_secret_configured ? <Tag color="success">签名已配置</Tag> : null}
- </Space>
- </Form.Item>
- </Col>
- <Col xs={24} lg={14}>
- <Form.Item
- name="feishu_webhook_url"
- label="飞书群机器人 Webhook"
- extra={settings.feishu_webhook_configured ? '已保存的地址不会回显;留空会保留,填写新地址会替换。' : '仅支持飞书或 Lark 官方群机器人 Webhook。'}
- dependencies={['feishu_webhook_enabled']}
- rules={[
- ({ getFieldValue }) => ({
- validator: (_, value) => {
- if (getFieldValue('feishu_webhook_enabled') && !value && !settings.feishu_webhook_configured) {
- return Promise.reject(new Error('开启提醒前请填写 Webhook 地址'));
- }
- return Promise.resolve();
- },
- }),
- ]}
- >
- <Input.Password
- autoComplete="new-password"
- placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
- style={{ minHeight: 44 }}
- />
- </Form.Item>
- </Col>
- <Col xs={24} lg={10}>
- <Form.Item
- name="feishu_webhook_signing_secret"
- label="签名密钥(可选)"
- extra={settings.feishu_signing_secret_configured ? '签名已配置;留空保留,填写新密钥会替换。' : '仅在飞书群机器人启用了“签名校验”时填写。'}
- >
- <Input.Password
- autoComplete="new-password"
- placeholder="飞书群机器人签名密钥"
- style={{ minHeight: 44 }}
- />
- </Form.Item>
- </Col>
- <Col xs={24}>
- <Form.Item>
- <Button
- icon={<BellRing size={16} />}
- loading={feishuTesting}
- disabled={!settings.feishu_webhook_configured && !feishuWebhookDraft?.trim()}
- onClick={testFeishuWebhook}
- style={{ minHeight: 44 }}
- >
- 发送测试提醒
- </Button>
- </Form.Item>
- </Col>
- <Col xs={24}>
- <Form.Item name="handoff_message" label="转人工提示"><Input.TextArea rows={2} maxLength={1000} /></Form.Item>
- </Col>
- <Col xs={24}>
- <Form.Item name="unsupported_message" label="非文本提示"><Input.TextArea rows={2} maxLength={1000} /></Form.Item>
- </Col>
- </Row>
- <Button type="primary" htmlType="submit" icon={<Save size={16} />}>保存接待设置</Button>
- </Form>
- </Surface>
- ),
- },
- {
- key: 'knowledge',
- label: `知识库 (${knowledge.total})`,
- children: (
- <Surface
- actions={
- <Space wrap>
- <Input.Search
- aria-label="搜索知识库"
- allowClear
- placeholder="问题、关键词或标签"
- onSearch={(value) => {
- setKnowledgeQuery(value);
- void loadConnectionData(selectedConnection, 1, { knowledgeQuery: value });
- }}
- />
- <Button type="primary" icon={<CirclePlus size={16} />} onClick={() => openKnowledge()}>新建知识</Button>
- </Space>
- }
- >
- <div className="table-wrap">
- <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: '尚无知识条目' }} />
- </div>
- <div style={{ marginTop: 20 }}>
- <Typography.Title level={5}>测试问答</Typography.Title>
- <Space.Compact style={{ width: '100%' }}>
- <Input value={testQuestion} onChange={(event) => setTestQuestion(event.target.value)} placeholder="输入客户可能提出的问题" onPressEnter={() => void runKnowledgeTest()} />
- <Button type="primary" icon={<FlaskConical size={16} />} onClick={() => void runKnowledgeTest()}>测试</Button>
- </Space.Compact>
- {testResult ? (
- <Alert type={testResult.action === 'handoff' ? 'warning' : 'success'} showIcon message={`处理结果:${String(testResult.action || 'unknown')}`} description={String(testResult.reply || testResult.reason || '')} style={{ marginTop: 12 }} />
- ) : null}
- </div>
- </Surface>
- ),
- },
- {
- key: 'knowledge-sources',
- label: '知识来源',
- children: <KnowledgeSourcesPanel connectionId={selectedConnection} />,
- },
- {
- key: 'knowledge-candidates',
- label: '知识候选',
- children: (
- <KnowledgeCandidatesPanel
- connectionId={selectedConnection}
- onKnowledgeChanged={() => loadConnectionData(selectedConnection)}
- />
- ),
- },
- {
- key: 'conversations',
- label: `会话台 (${conversations.total})`,
- children: (
- <Surface
- actions={
- <Select
- aria-label="会话状态"
- allowClear
- placeholder="全部状态"
- value={conversationStatus || undefined}
- style={{ width: 160 }}
- options={Object.entries(conversationLabels).map(([value, label]) => ({ value, label }))}
- onChange={(value) => setConversationStatus(value || '')}
- />
- }
- >
- <div className="metrics-grid" style={{ marginBottom: 16 }}>
- <Metric label="客户" value={usage?.customers || 0} />
- <Metric label="AI 调用" value={usage?.ai_calls || 0} />
- <Metric label="待人工" value={usage?.handoffs || 0} />
- <Metric label="人工接管" value={usage?.human_paused || 0} />
- </div>
- <div className="table-wrap">
- <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: '暂无客户会话' }} />
- </div>
- </Surface>
- ),
- },
- ]}
- />
- </>
- ) : null}
- <Modal title={knowledgeTarget ? '编辑知识条目' : '新建知识条目'} open={knowledgeOpen} onCancel={() => setKnowledgeOpen(false)} onOk={() => knowledgeForm.submit()} okText="继续" cancelText="取消" width={720} destroyOnHidden>
- <Form form={knowledgeForm} layout="vertical" onFinish={saveKnowledge} preserve={false}>
- <Form.Item name="question" label="标准问题" rules={[{ required: true, message: '请输入标准问题' }]}><Input maxLength={300} /></Form.Item>
- <Form.Item name="aliases" label="相似问法"><Select mode="tags" tokenSeparators={[',', ',']} placeholder="输入后按回车" /></Form.Item>
- <Form.Item name="keywords" label="匹配关键词"><Select mode="tags" tokenSeparators={[',', ',']} placeholder="建议填写客户消息中会出现的核心词" /></Form.Item>
- <Form.Item name="answer" label="标准答案" rules={[{ required: true, message: '请输入标准答案' }]}><Input.TextArea rows={6} maxLength={4000} showCount /></Form.Item>
- <Form.Item name="tags" label="标签"><Select mode="tags" tokenSeparators={[',', ',']} /></Form.Item>
- <Row gutter={16}>
- <Col xs={12}><Form.Item name="priority" label="优先级"><InputNumber min={-1000} max={1000} style={{ width: '100%' }} /></Form.Item></Col>
- <Col xs={12}><Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item></Col>
- </Row>
- </Form>
- </Modal>
- <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}>
- <Spin spinning={detailLoading}>
- {conversationDetail ? (
- <>
- <Descriptions column={1} size="small" bordered>
- <Descriptions.Item label="状态"><Tag color={conversationColors[conversationDetail.status]}>{conversationLabels[conversationDetail.status]}</Tag></Descriptions.Item>
- <Descriptions.Item label="摘要">{conversationDetail.summary || '暂无摘要'}</Descriptions.Item>
- <Descriptions.Item label="转人工原因">{conversationDetail.handoff_reason || '—'}</Descriptions.Item>
- </Descriptions>
- <div style={{ marginTop: 16 }}>
- <Space direction="vertical" size={10} style={{ width: '100%' }}>
- {(conversationDetail.messages || []).map((item) => (
- <div key={item.message_key} style={{ padding: 12, border: '1px solid #e2e8f0', borderRadius: 6, background: item.direction === 'incoming' ? '#fff' : '#f6f8fa' }}>
- <Space size={8}>
- <Tag>{item.direction === 'incoming' ? '客户' : item.direction === 'human' ? '人工' : 'AI'}</Tag>
- <Typography.Text type="secondary">{dayjs(item.created_at).format('YYYY-MM-DD HH:mm:ss')}</Typography.Text>
- </Space>
- <Typography.Paragraph style={{ margin: '8px 0 0', whiteSpace: 'pre-wrap' }}>{item.text}</Typography.Paragraph>
- </div>
- ))}
- {!conversationDetail.messages?.length ? <Empty description="原始消息已清理或尚无消息" /> : null}
- </Space>
- </div>
- </>
- ) : null}
- </Spin>
- </Drawer>
- </Spin>
- );
- }
|