2b7ef65cce
CI / verify (pull_request) Has been cancelled
Add owner-scoped model API settings, uploadable knowledge bases, and WeCom bot AI/KB wiring with OpenAI-compatible URL normalization. Co-authored-by: Cursor <cursoragent@cursor.com>
513 lines
16 KiB
TypeScript
513 lines
16 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import {
|
||
Avatar,
|
||
Button,
|
||
Checkbox,
|
||
Descriptions,
|
||
Drawer,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Modal,
|
||
Popconfirm,
|
||
Select,
|
||
Space,
|
||
Switch,
|
||
Table,
|
||
Tag,
|
||
Typography,
|
||
message,
|
||
} from 'antd';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import {
|
||
WECOM_BOT_PERMISSIONS,
|
||
WECOM_BOT_PERMISSION_LABELS,
|
||
WECOM_BOT_ROLE_DEFAULT_PERMISSIONS,
|
||
WECOM_BOT_ROLE_LABELS,
|
||
WECOM_BOT_ROLES,
|
||
type LlmApiConfigOptionDto,
|
||
type KnowledgeBaseOptionDto,
|
||
type WecomBotDto,
|
||
type WecomBotPermission,
|
||
type WecomBotRole,
|
||
} from '@dukang/shared-types';
|
||
import { request } from '../lib/api';
|
||
import { fmtTime } from '../lib/constants';
|
||
import { useAdminList } from '../lib/useAdminList';
|
||
import OssUpload from '../components/OssUpload';
|
||
|
||
type ListRes = {
|
||
items: WecomBotDto[];
|
||
total: number;
|
||
page: number;
|
||
pageSize: number;
|
||
runtime?: {
|
||
masterEnabled: boolean;
|
||
bots: Array<{ id: string; connected: boolean; lastError: string | null }>;
|
||
};
|
||
};
|
||
|
||
type FormValues = {
|
||
name: string;
|
||
role: WecomBotRole;
|
||
botId: string;
|
||
secret?: string;
|
||
avatarUrl?: string;
|
||
welcome?: string;
|
||
permissions: WecomBotPermission[];
|
||
aiEnabled: boolean;
|
||
llmConfigId?: string | null;
|
||
knowledgeBaseId?: string | null;
|
||
enabled: boolean;
|
||
sortOrder: number;
|
||
};
|
||
|
||
export default function WecomBotsPage() {
|
||
const [filterForm] = Form.useForm();
|
||
const [form] = Form.useForm<FormValues>();
|
||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
const [editing, setEditing] = useState<WecomBotDto | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [detail, setDetail] = useState<WecomBotDto | null>(null);
|
||
const [runtime, setRuntime] = useState<ListRes['runtime']>();
|
||
const [llmOptions, setLlmOptions] = useState<LlmApiConfigOptionDto[]>([]);
|
||
const [kbOptions, setKbOptions] = useState<KnowledgeBaseOptionDto[]>([]);
|
||
const roleWatch = Form.useWatch('role', form);
|
||
|
||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<WecomBotDto>(
|
||
'/admin/wecom-bots',
|
||
() => {
|
||
const qs = new URLSearchParams();
|
||
if (filters.name) qs.set('name', filters.name);
|
||
if (filters.role) qs.set('role', filters.role);
|
||
if (filters.enabled) qs.set('enabled', filters.enabled);
|
||
return qs;
|
||
},
|
||
[filters],
|
||
);
|
||
|
||
useEffect(() => {
|
||
// useAdminList returns items; runtime comes from same API — fetch once for banner
|
||
void request<ListRes>('/admin/wecom-bots?page=1&pageSize=1')
|
||
.then((res) => setRuntime(res.runtime))
|
||
.catch(() => {});
|
||
}, [data]);
|
||
|
||
useEffect(() => {
|
||
void request<{
|
||
llmConfigs: LlmApiConfigOptionDto[];
|
||
knowledgeBases: KnowledgeBaseOptionDto[];
|
||
}>('/admin/wecom-bots/ai-options')
|
||
.then((res) => {
|
||
setLlmOptions(res.llmConfigs);
|
||
setKbOptions(res.knowledgeBases);
|
||
})
|
||
.catch(() => {
|
||
setLlmOptions([]);
|
||
setKbOptions([]);
|
||
});
|
||
}, []);
|
||
|
||
function openCreate() {
|
||
setEditing(null);
|
||
form.setFieldsValue({
|
||
name: '',
|
||
role: 'CUSTOMER_SERVICE',
|
||
botId: '',
|
||
secret: '',
|
||
avatarUrl: '',
|
||
welcome: '',
|
||
permissions: [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS.CUSTOMER_SERVICE],
|
||
aiEnabled: false,
|
||
llmConfigId: null,
|
||
knowledgeBaseId: null,
|
||
enabled: true,
|
||
sortOrder: 0,
|
||
});
|
||
setModalOpen(true);
|
||
}
|
||
|
||
function openEdit(row: WecomBotDto) {
|
||
setEditing(row);
|
||
form.setFieldsValue({
|
||
name: row.name,
|
||
role: row.role,
|
||
botId: row.botId,
|
||
secret: '',
|
||
avatarUrl: row.avatarUrl || '',
|
||
welcome: row.welcome || '',
|
||
permissions: row.permissions,
|
||
aiEnabled: row.aiEnabled,
|
||
llmConfigId: row.llmConfigId,
|
||
knowledgeBaseId: row.knowledgeBaseId,
|
||
enabled: row.enabled,
|
||
sortOrder: row.sortOrder,
|
||
});
|
||
setModalOpen(true);
|
||
}
|
||
|
||
async function submit() {
|
||
const values = await form.validateFields();
|
||
setSaving(true);
|
||
try {
|
||
const payload = {
|
||
name: values.name.trim(),
|
||
role: values.role,
|
||
botId: values.botId.trim(),
|
||
avatarUrl: values.avatarUrl?.trim() || null,
|
||
welcome: values.welcome?.trim() || null,
|
||
permissions: values.permissions,
|
||
aiEnabled: values.aiEnabled,
|
||
llmConfigId: values.llmConfigId || null,
|
||
knowledgeBaseId: values.knowledgeBaseId || null,
|
||
enabled: values.enabled,
|
||
sortOrder: values.sortOrder,
|
||
};
|
||
if (editing) {
|
||
await request(`/admin/wecom-bots/${editing.id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
...payload,
|
||
secret: values.secret?.trim() || undefined,
|
||
}),
|
||
});
|
||
message.success('已更新');
|
||
} else {
|
||
if (!values.secret?.trim()) {
|
||
message.error('请填写 Secret');
|
||
return;
|
||
}
|
||
await request('/admin/wecom-bots', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
...payload,
|
||
secret: values.secret.trim(),
|
||
}),
|
||
});
|
||
message.success('已创建');
|
||
}
|
||
setModalOpen(false);
|
||
reload();
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '保存失败');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function remove(id: string) {
|
||
try {
|
||
await request(`/admin/wecom-bots/${id}`, { method: 'DELETE' });
|
||
message.success('已删除');
|
||
reload();
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '删除失败');
|
||
}
|
||
}
|
||
|
||
async function reloadConnections() {
|
||
try {
|
||
const st = await request<{
|
||
masterEnabled: boolean;
|
||
bots: Array<{ id: string; connected: boolean; lastError: string | null }>;
|
||
}>('/admin/wecom-bots/reload', { method: 'POST', body: '{}' });
|
||
message.success('已重载长连接');
|
||
setRuntime(st);
|
||
reload();
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '重载失败');
|
||
}
|
||
}
|
||
|
||
const runtimeMap = new Map((runtime?.bots ?? []).map((b) => [b.id, b]));
|
||
|
||
const columns: ColumnsType<WecomBotDto> = [
|
||
{
|
||
title: '头像',
|
||
dataIndex: 'avatarUrl',
|
||
width: 64,
|
||
render: (url: string | null, row) => (
|
||
<Avatar src={url || undefined} shape="square" size={40}>
|
||
{row.name.slice(0, 1)}
|
||
</Avatar>
|
||
),
|
||
},
|
||
{ title: '名称', dataIndex: 'name', width: 140, ellipsis: true },
|
||
{
|
||
title: '角色',
|
||
dataIndex: 'role',
|
||
width: 120,
|
||
render: (r: WecomBotRole) => WECOM_BOT_ROLE_LABELS[r] || r,
|
||
},
|
||
{ title: 'BotID', dataIndex: 'botId', width: 160, ellipsis: true },
|
||
{
|
||
title: '权限',
|
||
dataIndex: 'permissions',
|
||
ellipsis: true,
|
||
render: (perms: WecomBotPermission[]) =>
|
||
perms.map((p) => (
|
||
<Tag key={p} style={{ marginBottom: 2 }}>
|
||
{WECOM_BOT_PERMISSION_LABELS[p] || p}
|
||
</Tag>
|
||
)),
|
||
},
|
||
{
|
||
title: 'AI',
|
||
width: 100,
|
||
render: (_, row) =>
|
||
row.aiEnabled ? (
|
||
<Tag color="blue">{row.llmConfigName || '已开'}</Tag>
|
||
) : (
|
||
<Tag>关</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '启用',
|
||
dataIndex: 'enabled',
|
||
width: 70,
|
||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '是' : '否'}</Tag>,
|
||
},
|
||
{
|
||
title: '连接',
|
||
width: 80,
|
||
render: (_, row) => {
|
||
const rt = runtimeMap.get(row.id);
|
||
if (!runtime?.masterEnabled) return <Tag>总开关关</Tag>;
|
||
if (!row.enabled) return <Tag>未启用</Tag>;
|
||
return (
|
||
<Tag color={rt?.connected ? 'green' : 'orange'}>{rt?.connected ? '已连接' : '未连接'}</Tag>
|
||
);
|
||
},
|
||
},
|
||
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
||
{
|
||
title: '操作',
|
||
width: 180,
|
||
fixed: 'right',
|
||
render: (_, row) => (
|
||
<Space>
|
||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||
编辑
|
||
</Button>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={async () => {
|
||
setDetail(await request<WecomBotDto>(`/admin/wecom-bots/${row.id}`));
|
||
}}
|
||
>
|
||
详情
|
||
</Button>
|
||
<Popconfirm title="确认删除该机器人?" onConfirm={() => remove(row.id)}>
|
||
<Button type="link" size="small" danger>
|
||
删除
|
||
</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} wrap>
|
||
<div>
|
||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||
企微机器人
|
||
</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
并列创建多个智能机器人,配置 BotID / Secret / 权限 / 角色 / 头像。总开关在「系统设置 → 功能开关」。
|
||
{runtime ? ` 当前总开关:${runtime.masterEnabled ? '开' : '关'}` : ''}
|
||
</Typography.Text>
|
||
</div>
|
||
<Space>
|
||
<Button onClick={() => void reloadConnections()}>重载连接</Button>
|
||
<Button type="primary" onClick={openCreate}>
|
||
创建机器人
|
||
</Button>
|
||
</Space>
|
||
</Space>
|
||
|
||
<Form
|
||
form={filterForm}
|
||
layout="inline"
|
||
style={{ marginBottom: 16 }}
|
||
onFinish={(v) => {
|
||
setFilters(v);
|
||
setPage(1);
|
||
}}
|
||
>
|
||
<Form.Item name="name" label="名称">
|
||
<Input allowClear placeholder="名称" />
|
||
</Form.Item>
|
||
<Form.Item name="role" label="角色">
|
||
<Select
|
||
allowClear
|
||
style={{ width: 160 }}
|
||
options={WECOM_BOT_ROLES.map((r) => ({ value: r, label: WECOM_BOT_ROLE_LABELS[r] }))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="enabled" label="启用">
|
||
<Select
|
||
allowClear
|
||
style={{ width: 100 }}
|
||
options={[
|
||
{ value: 'true', label: '是' },
|
||
{ value: 'false', label: '否' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button type="primary" htmlType="submit">
|
||
查询
|
||
</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
|
||
<Table
|
||
rowKey="id"
|
||
loading={loading}
|
||
columns={columns}
|
||
dataSource={data?.items ?? []}
|
||
scroll={{ x: 1200 }}
|
||
pagination={{
|
||
current: page,
|
||
pageSize,
|
||
total: data?.total ?? 0,
|
||
showSizeChanger: true,
|
||
onChange: (p, ps) => {
|
||
setPage(p);
|
||
setPageSize(ps);
|
||
},
|
||
}}
|
||
/>
|
||
|
||
<Modal
|
||
title={editing ? '编辑企微机器人' : '创建企微机器人'}
|
||
open={modalOpen}
|
||
onCancel={() => setModalOpen(false)}
|
||
onOk={() => void submit()}
|
||
confirmLoading={saving}
|
||
width={640}
|
||
destroyOnClose
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||
<Input placeholder="如:客服机器人" maxLength={64} />
|
||
</Form.Item>
|
||
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
|
||
<Select
|
||
options={WECOM_BOT_ROLES.map((r) => ({ value: r, label: WECOM_BOT_ROLE_LABELS[r] }))}
|
||
onChange={(role: WecomBotRole) => {
|
||
form.setFieldValue('permissions', [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[role]]);
|
||
}}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="avatarUrl" label="头像">
|
||
<OssUpload bizType="WECOM_BOT_AVATAR" />
|
||
</Form.Item>
|
||
<Form.Item name="botId" label="BotID" rules={[{ required: true, message: '请填写 BotID' }]}>
|
||
<Input placeholder="企业微信后台长连接 BotID" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="secret"
|
||
label="Secret"
|
||
rules={editing ? [] : [{ required: true, message: '请填写 Secret' }]}
|
||
extra={editing ? '留空表示不修改' : undefined}
|
||
>
|
||
<Input.Password placeholder={editing ? '留空不修改' : '长连接专用 Secret'} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="permissions"
|
||
label="权限"
|
||
rules={[{ required: true, message: '请至少选择一项权限' }]}
|
||
extra={
|
||
roleWatch
|
||
? `角色默认:${WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[roleWatch as WecomBotRole]?.map((p) => WECOM_BOT_PERMISSION_LABELS[p]).join('、') || '无'}`
|
||
: undefined
|
||
}
|
||
>
|
||
<Checkbox.Group
|
||
options={WECOM_BOT_PERMISSIONS.map((p) => ({
|
||
value: p,
|
||
label: WECOM_BOT_PERMISSION_LABELS[p],
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="welcome" label="欢迎语">
|
||
<Input.TextArea rows={3} placeholder="进入会话时的欢迎语,可空" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="aiEnabled"
|
||
label="启用 AI 问答"
|
||
valuePropName="checked"
|
||
extra="开启后,未匹配指令的自然语言将调用绑定的语言模型(可挂知识库)"
|
||
>
|
||
<Switch />
|
||
</Form.Item>
|
||
<Form.Item name="llmConfigId" label="语言模型">
|
||
<Select
|
||
allowClear
|
||
placeholder="选择已生效的配置"
|
||
options={llmOptions.map((o) => ({
|
||
value: o.id,
|
||
label: `${o.name}(${o.modelName})`,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="knowledgeBaseId" label="知识库">
|
||
<Select
|
||
allowClear
|
||
placeholder="可选,供 AI 检索"
|
||
options={kbOptions.map((o) => ({
|
||
value: o.id,
|
||
label: `${o.name}(${o.documentCount} 篇)`,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Space size="large">
|
||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||
<Switch />
|
||
</Form.Item>
|
||
<Form.Item name="sortOrder" label="排序">
|
||
<InputNumber style={{ width: 120 }} />
|
||
</Form.Item>
|
||
</Space>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Drawer title="机器人详情" width={480} open={!!detail} onClose={() => setDetail(null)}>
|
||
{detail && (
|
||
<Descriptions column={1} size="small" bordered>
|
||
<Descriptions.Item label="头像">
|
||
<Avatar src={detail.avatarUrl || undefined} size={64} shape="square">
|
||
{detail.name.slice(0, 1)}
|
||
</Avatar>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
|
||
<Descriptions.Item label="角色">
|
||
{WECOM_BOT_ROLE_LABELS[detail.role] || detail.role}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="BotID">{detail.botId}</Descriptions.Item>
|
||
<Descriptions.Item label="Secret">
|
||
{detail.secretConfigured ? '已配置' : '未配置'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="权限">
|
||
{detail.permissions.map((p) => WECOM_BOT_PERMISSION_LABELS[p] || p).join('、')}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="欢迎语">{detail.welcome || '—'}</Descriptions.Item>
|
||
<Descriptions.Item label="AI 问答">{detail.aiEnabled ? '开' : '关'}</Descriptions.Item>
|
||
<Descriptions.Item label="语言模型">{detail.llmConfigName || '—'}</Descriptions.Item>
|
||
<Descriptions.Item label="知识库">{detail.knowledgeBaseName || '—'}</Descriptions.Item>
|
||
<Descriptions.Item label="启用">{detail.enabled ? '是' : '否'}</Descriptions.Item>
|
||
<Descriptions.Item label="排序">{detail.sortOrder}</Descriptions.Item>
|
||
<Descriptions.Item label="创建">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||
<Descriptions.Item label="更新">{fmtTime(detail.updatedAt)}</Descriptions.Item>
|
||
</Descriptions>
|
||
)}
|
||
</Drawer>
|
||
</div>
|
||
);
|
||
}
|