feat(admin): LLM config, knowledge base, and WeCom AI binding
CI / verify (pull_request) Has been cancelled
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>
This commit is contained in:
@@ -43,6 +43,8 @@ import WechatBindingsPage from './pages/WechatBindingsPage';
|
||||
import HqPermissionsPage from './pages/HqPermissionsPage';
|
||||
import SystemSettingsPage from './pages/SystemSettingsPage';
|
||||
import WecomBotsPage from './pages/WecomBotsPage';
|
||||
import LlmConfigsPage from './pages/LlmConfigsPage';
|
||||
import KnowledgeBasesPage from './pages/KnowledgeBasesPage';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
if (!getToken()) return <Navigate to="/login" replace />;
|
||||
@@ -70,6 +72,8 @@ export default function App() {
|
||||
<Route path="users" element={<PromoCodeUsersPage />} />
|
||||
</Route>
|
||||
<Route path="/wecom-bots" element={<WecomBotsPage />} />
|
||||
<Route path="/llm-configs" element={<LlmConfigsPage />} />
|
||||
<Route path="/knowledge-bases" element={<KnowledgeBasesPage />} />
|
||||
<Route path="/products" element={<ProductsPage />} />
|
||||
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
||||
<Route path="/stores" element={<StoresPage />} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Layout, Menu, Typography, Button, Space } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
RobotOutlined,
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
ShoppingOutlined,
|
||||
@@ -42,6 +43,8 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
||||
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
|
||||
{ key: '/wecom-bots', icon: <TeamOutlined />, label: '企微机器人' },
|
||||
{ key: '/llm-configs', icon: <RobotOutlined />, label: '语言模型' },
|
||||
{ key: '/knowledge-bases', icon: <FileTextOutlined />, label: '知识库' },
|
||||
{
|
||||
key: 'stores-group',
|
||||
icon: <ShopOutlined />,
|
||||
@@ -137,6 +140,8 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
'/orders': 'orders',
|
||||
'/promo-codes': 'promo_codes',
|
||||
'/wecom-bots': 'wecom_bots',
|
||||
'/llm-configs': 'llm_configs',
|
||||
'/knowledge-bases': 'knowledge_bases',
|
||||
'stores-group': 'stores',
|
||||
'/stores': 'stores',
|
||||
'/store-categories': 'stores',
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Upload,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import type {
|
||||
KnowledgeBaseDto,
|
||||
KnowledgeDocumentDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { uploadFileToOss } from '../lib/upload';
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type DocForm = {
|
||||
title: string;
|
||||
contentText?: string;
|
||||
};
|
||||
|
||||
export default function KnowledgeBasesPage() {
|
||||
const [filterForm] = Form.useForm();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [docForm] = Form.useForm<DocForm>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<KnowledgeBaseDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [drawerKb, setDrawerKb] = useState<KnowledgeBaseDto | null>(null);
|
||||
const [docs, setDocs] = useState<KnowledgeDocumentDto[]>([]);
|
||||
const [docsLoading, setDocsLoading] = useState(false);
|
||||
const [docModalOpen, setDocModalOpen] = useState(false);
|
||||
const [docSaving, setDocSaving] = useState(false);
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
|
||||
useAdminList<KnowledgeBaseDto>(
|
||||
'/admin/knowledge-bases',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.enabled) qs.set('enabled', filters.enabled);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
|
||||
const loadDocs = async (kb: KnowledgeBaseDto) => {
|
||||
setDrawerKb(kb);
|
||||
setDocsLoading(true);
|
||||
try {
|
||||
const res = await request<{ items: KnowledgeDocumentDto[] }>(
|
||||
`/admin/knowledge-bases/${kb.id}/documents`,
|
||||
);
|
||||
setDocs(res.items);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setDocsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({ name: '', description: '', enabled: true });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row: KnowledgeBaseDto) => {
|
||||
setEditing(row);
|
||||
form.setFieldsValue({
|
||||
name: row.name,
|
||||
description: row.description || '',
|
||||
enabled: row.enabled,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await request(`/admin/knowledge-bases/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
message.success('已保存');
|
||||
} else {
|
||||
await request('/admin/knowledge-bases', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<KnowledgeBaseDto> = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '说明', dataIndex: 'description', ellipsis: true },
|
||||
{ title: '文档数', dataIndex: 'documentCount', width: 90 },
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'enabled',
|
||||
width: 90,
|
||||
render: (v: boolean) => (v ? <Tag color="green">开</Tag> : <Tag>关</Tag>),
|
||||
},
|
||||
{
|
||||
title: '创建人',
|
||||
dataIndex: 'createdByName',
|
||||
width: 100,
|
||||
render: (v: string | null, row) => v || row.createdByHqAccountId,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 170,
|
||||
render: (t: string) => fmtTime(t),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 240,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => void loadDocs(row)}>
|
||||
文档
|
||||
</Button>
|
||||
{row.canEditFull ? (
|
||||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
) : null}
|
||||
{row.canEditFull ? (
|
||||
<Popconfirm
|
||||
title="确认删除知识库及全部文档?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await request(`/admin/knowledge-bases/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const docColumns: ColumnsType<KnowledgeDocumentDto> = [
|
||||
{ title: '标题', dataIndex: 'title' },
|
||||
{ title: '文件', dataIndex: 'fileName', ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s: string) =>
|
||||
s === 'READY' ? <Tag color="green">可检索</Tag> : s === 'FAILED' ? <Tag color="red">失败</Tag> : <Tag>无正文</Tag>,
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (t: string) => fmtTime(t),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) =>
|
||||
drawerKb?.canEditFull ? (
|
||||
<Popconfirm
|
||||
title="删除文档?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
message.success('已删除');
|
||||
void loadDocs(drawerKb);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
知识库
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) =>
|
||||
setFilters({
|
||||
name: v.name || '',
|
||||
enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Form.Item name="name" label="名称">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建知识库
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑知识库' : '新建知识库'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => void onSave()}
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="说明">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
title={drawerKb ? `文档 · ${drawerKb.name}` : '文档'}
|
||||
open={!!drawerKb}
|
||||
width={720}
|
||||
onClose={() => setDrawerKb(null)}
|
||||
extra={
|
||||
drawerKb?.canEditFull ? (
|
||||
<Button type="primary" onClick={() => { docForm.resetFields(); setDocModalOpen(true); }}>
|
||||
上传/粘贴
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={docsLoading}
|
||||
columns={docColumns}
|
||||
dataSource={docs}
|
||||
pagination={false}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="添加知识文档"
|
||||
open={docModalOpen}
|
||||
onCancel={() => setDocModalOpen(false)}
|
||||
confirmLoading={docSaving}
|
||||
onOk={async () => {
|
||||
if (!drawerKb) return;
|
||||
const values = await docForm.validateFields();
|
||||
setDocSaving(true);
|
||||
try {
|
||||
await request(`/admin/knowledge-bases/${drawerKb.id}/documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: values.title,
|
||||
contentText: values.contentText || null,
|
||||
}),
|
||||
});
|
||||
message.success('已添加');
|
||||
setDocModalOpen(false);
|
||||
void loadDocs(drawerKb);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setDocSaving(false);
|
||||
}
|
||||
}}
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<Form form={docForm} layout="vertical">
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="contentText" label="正文(可粘贴)">
|
||||
<Input.TextArea rows={8} placeholder="支持 Markdown / 纯文本" />
|
||||
</Form.Item>
|
||||
<Form.Item label="或上传文本文件">
|
||||
<Upload
|
||||
accept=".txt,.md,.markdown,.csv,.json,.log,text/*"
|
||||
maxCount={1}
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
if (!drawerKb) return;
|
||||
try {
|
||||
const raw = file as File;
|
||||
const uploaded = await uploadFileToOss(raw, {
|
||||
bizType: 'KB_DOCUMENT',
|
||||
mediaType: 'FILE',
|
||||
});
|
||||
const title =
|
||||
docForm.getFieldValue('title') || raw.name.replace(/\.[^.]+$/, '');
|
||||
await request(`/admin/knowledge-bases/${drawerKb.id}/documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
fileName: raw.name,
|
||||
fileUrl: uploaded.url,
|
||||
mimeType: raw.type || null,
|
||||
sizeBytes: raw.size,
|
||||
}),
|
||||
});
|
||||
message.success('上传成功');
|
||||
setDocModalOpen(false);
|
||||
void loadDocs(drawerKb);
|
||||
reload();
|
||||
onSuccess?.(uploaded);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
message.error(err.message);
|
||||
onError?.(err);
|
||||
}
|
||||
}}
|
||||
showUploadList={false}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>上传到 OSS 并入库</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
LLM_PROVIDERS,
|
||||
LLM_PROVIDER_LABELS,
|
||||
LLM_PROVIDER_PRESETS,
|
||||
type LlmApiConfigDto,
|
||||
type LlmProvider,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
provider: LlmProvider;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
modelName?: string;
|
||||
temperature?: number | null;
|
||||
maxTokens?: number | null;
|
||||
systemPrompt?: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export default function LlmConfigsPage() {
|
||||
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<LlmApiConfigDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const providerWatch = Form.useWatch('provider', form);
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<LlmApiConfigDto>(
|
||||
'/admin/llm-configs',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.enabled) qs.set('enabled', filters.enabled);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!providerWatch || editing) return;
|
||||
const preset = LLM_PROVIDER_PRESETS[providerWatch as LlmProvider];
|
||||
if (!preset) return;
|
||||
form.setFieldsValue({
|
||||
baseUrl: preset.defaultBaseUrl || undefined,
|
||||
modelName: preset.defaultModel || undefined,
|
||||
});
|
||||
}, [providerWatch, editing, form]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
provider: 'DEEPSEEK',
|
||||
baseUrl: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultBaseUrl,
|
||||
apiKey: '',
|
||||
modelName: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultModel,
|
||||
temperature: 0.3,
|
||||
maxTokens: 1024,
|
||||
systemPrompt: '',
|
||||
enabled: true,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row: LlmApiConfigDto) => {
|
||||
setEditing(row);
|
||||
form.setFieldsValue({
|
||||
name: row.name,
|
||||
provider: row.provider,
|
||||
baseUrl: row.baseUrl,
|
||||
apiKey: '',
|
||||
modelName: row.modelName,
|
||||
temperature: row.temperature,
|
||||
maxTokens: row.maxTokens,
|
||||
systemPrompt: row.systemPrompt || '',
|
||||
enabled: row.enabled,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
if (!editing.canEditFull) {
|
||||
await request(`/admin/llm-configs/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled: values.enabled }),
|
||||
});
|
||||
} else {
|
||||
await request(`/admin/llm-configs/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
name: values.name,
|
||||
provider: values.provider,
|
||||
baseUrl: values.baseUrl,
|
||||
apiKey: values.apiKey || undefined,
|
||||
modelName: values.modelName,
|
||||
temperature: values.temperature ?? null,
|
||||
maxTokens: values.maxTokens ?? null,
|
||||
systemPrompt: values.systemPrompt || null,
|
||||
enabled: values.enabled,
|
||||
}),
|
||||
});
|
||||
}
|
||||
message.success('已保存');
|
||||
} else {
|
||||
await request('/admin/llm-configs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: values.name,
|
||||
provider: values.provider,
|
||||
baseUrl: values.baseUrl,
|
||||
apiKey: values.apiKey,
|
||||
modelName: values.modelName,
|
||||
temperature: values.temperature ?? null,
|
||||
maxTokens: values.maxTokens ?? null,
|
||||
systemPrompt: values.systemPrompt || null,
|
||||
enabled: values.enabled,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<LlmApiConfigDto> = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{
|
||||
title: '提供商',
|
||||
dataIndex: 'provider',
|
||||
width: 120,
|
||||
render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p,
|
||||
},
|
||||
{ title: '模型', dataIndex: 'modelName', ellipsis: true },
|
||||
{
|
||||
title: 'Key',
|
||||
dataIndex: 'apiKeyConfigured',
|
||||
width: 80,
|
||||
render: (v: boolean) => (v ? <Tag color="green">已配</Tag> : <Tag>无</Tag>),
|
||||
},
|
||||
{
|
||||
title: '生效',
|
||||
dataIndex: 'enabled',
|
||||
width: 90,
|
||||
render: (v: boolean, row) => (
|
||||
<Switch
|
||||
checked={v}
|
||||
disabled={!row.isOwner && !row.canEditFull}
|
||||
onChange={async (checked) => {
|
||||
try {
|
||||
await request(`/admin/llm-configs/${row.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled: checked }),
|
||||
});
|
||||
message.success(checked ? '已启用' : '已停用');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建人',
|
||||
dataIndex: 'createdByName',
|
||||
width: 100,
|
||||
render: (v: string | null, row) => v || row.createdByHqAccountId,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 170,
|
||||
render: (t: string) => fmtTime(t),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
{row.canEditFull ? '编辑' : '开关'}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await request<{ reply: string }>(`/admin/llm-configs/${row.id}/test`, {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
});
|
||||
message.success(res.reply || '测试成功');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
{row.canEditFull ? (
|
||||
<Popconfirm
|
||||
title="确认删除?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await request(`/admin/llm-configs/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const fullEdit = !editing || editing.canEditFull;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
语言模型配置
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
非超管仅可见自己创建的配置,且创建后只能改是否生效
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) =>
|
||||
setFilters({
|
||||
name: v.name || '',
|
||||
enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Form.Item name="name" label="名称">
|
||||
<Input allowClear placeholder="搜索" />
|
||||
</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.Item>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建配置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? (fullEdit ? '编辑语言模型' : '修改是否生效') : '新建语言模型'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => void onSave()}
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '必填' }]}>
|
||||
<Input disabled={!fullEdit} />
|
||||
</Form.Item>
|
||||
<Form.Item name="provider" label="提供商" rules={[{ required: true }]}>
|
||||
<Select
|
||||
disabled={!fullEdit}
|
||||
options={LLM_PROVIDERS.map((p) => ({ value: p, label: LLM_PROVIDER_LABELS[p] }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="baseUrl"
|
||||
label="Base URL"
|
||||
rules={[{ required: fullEdit, message: '必填' }]}
|
||||
extra="填主机根地址即可,如 https://api.deepseek.com(不要带 /v1)"
|
||||
>
|
||||
<Input disabled={!fullEdit} placeholder="https://api.deepseek.com" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="apiKey"
|
||||
label="API Key"
|
||||
rules={editing ? [] : [{ required: true, message: '必填' }]}
|
||||
extra={editing ? '留空表示不修改' : undefined}
|
||||
>
|
||||
<Input.Password disabled={!fullEdit} />
|
||||
</Form.Item>
|
||||
<Form.Item name="modelName" label="模型名" rules={[{ required: fullEdit, message: '必填' }]}>
|
||||
<Input disabled={!fullEdit} />
|
||||
</Form.Item>
|
||||
<Space style={{ display: 'flex' }} size="large">
|
||||
<Form.Item name="temperature" label="Temperature">
|
||||
<InputNumber disabled={!fullEdit} min={0} max={2} step={0.1} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxTokens" label="Max Tokens">
|
||||
<InputNumber disabled={!fullEdit} min={1} max={128000} style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="systemPrompt" label="系统提示词">
|
||||
<Input.TextArea disabled={!fullEdit} rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="生效" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
WECOM_BOT_ROLE_DEFAULT_PERMISSIONS,
|
||||
WECOM_BOT_ROLE_LABELS,
|
||||
WECOM_BOT_ROLES,
|
||||
type LlmApiConfigOptionDto,
|
||||
type KnowledgeBaseOptionDto,
|
||||
type WecomBotDto,
|
||||
type WecomBotPermission,
|
||||
type WecomBotRole,
|
||||
@@ -53,6 +55,9 @@ type FormValues = {
|
||||
avatarUrl?: string;
|
||||
welcome?: string;
|
||||
permissions: WecomBotPermission[];
|
||||
aiEnabled: boolean;
|
||||
llmConfigId?: string | null;
|
||||
knowledgeBaseId?: string | null;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
};
|
||||
@@ -66,6 +71,8 @@ export default function WecomBotsPage() {
|
||||
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>(
|
||||
@@ -87,6 +94,21 @@ export default function WecomBotsPage() {
|
||||
.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({
|
||||
@@ -97,6 +119,9 @@ export default function WecomBotsPage() {
|
||||
avatarUrl: '',
|
||||
welcome: '',
|
||||
permissions: [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS.CUSTOMER_SERVICE],
|
||||
aiEnabled: false,
|
||||
llmConfigId: null,
|
||||
knowledgeBaseId: null,
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
});
|
||||
@@ -113,6 +138,9 @@ export default function WecomBotsPage() {
|
||||
avatarUrl: row.avatarUrl || '',
|
||||
welcome: row.welcome || '',
|
||||
permissions: row.permissions,
|
||||
aiEnabled: row.aiEnabled,
|
||||
llmConfigId: row.llmConfigId,
|
||||
knowledgeBaseId: row.knowledgeBaseId,
|
||||
enabled: row.enabled,
|
||||
sortOrder: row.sortOrder,
|
||||
});
|
||||
@@ -123,19 +151,25 @@ export default function WecomBotsPage() {
|
||||
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({
|
||||
name: values.name.trim(),
|
||||
role: values.role,
|
||||
botId: values.botId.trim(),
|
||||
...payload,
|
||||
secret: values.secret?.trim() || undefined,
|
||||
avatarUrl: values.avatarUrl?.trim() || null,
|
||||
welcome: values.welcome?.trim() || null,
|
||||
permissions: values.permissions,
|
||||
enabled: values.enabled,
|
||||
sortOrder: values.sortOrder,
|
||||
}),
|
||||
});
|
||||
message.success('已更新');
|
||||
@@ -147,15 +181,8 @@ export default function WecomBotsPage() {
|
||||
await request('/admin/wecom-bots', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: values.name.trim(),
|
||||
role: values.role,
|
||||
botId: values.botId.trim(),
|
||||
...payload,
|
||||
secret: values.secret.trim(),
|
||||
avatarUrl: values.avatarUrl?.trim() || null,
|
||||
welcome: values.welcome?.trim() || null,
|
||||
permissions: values.permissions,
|
||||
enabled: values.enabled,
|
||||
sortOrder: values.sortOrder,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
@@ -225,6 +252,16 @@ export default function WecomBotsPage() {
|
||||
</Tag>
|
||||
)),
|
||||
},
|
||||
{
|
||||
title: 'AI',
|
||||
width: 100,
|
||||
render: (_, row) =>
|
||||
row.aiEnabled ? (
|
||||
<Tag color="blue">{row.llmConfigName || '已开'}</Tag>
|
||||
) : (
|
||||
<Tag>关</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'enabled',
|
||||
@@ -401,6 +438,34 @@ export default function WecomBotsPage() {
|
||||
<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 />
|
||||
@@ -432,6 +497,9 @@ export default function WecomBotsPage() {
|
||||
{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>
|
||||
|
||||
@@ -15,6 +15,8 @@ export const HQ_PERMISSION_CATALOG = [
|
||||
{ key: 'tech_support', label: '技术支持', group: '业务' },
|
||||
{ key: 'invoices', label: '发票管理', group: '业务' },
|
||||
{ key: 'wecom_bots', label: '企微机器人', group: '业务' },
|
||||
{ key: 'llm_configs', label: '语言模型配置', group: '业务' },
|
||||
{ key: 'knowledge_bases', label: '知识库', group: '业务' },
|
||||
{ key: 'resources', label: 'OSS 资源库', group: '业务' },
|
||||
{ key: 'logs', label: '日志', group: '业务' },
|
||||
{ key: 'hq_permissions', label: '权限分配', group: '管理' },
|
||||
@@ -90,6 +92,8 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
|
||||
'tech_support',
|
||||
'invoices',
|
||||
'wecom_bots',
|
||||
'llm_configs',
|
||||
'knowledge_bases',
|
||||
'resources',
|
||||
'logs',
|
||||
'system_settings_wechat_mini',
|
||||
|
||||
@@ -24,4 +24,6 @@ export * from './city-warehouse';
|
||||
export * from './fulfillment-provider';
|
||||
export * from './system-config';
|
||||
export * from './wecom-bot';
|
||||
export * from './llm-config';
|
||||
export * from './knowledge-base';
|
||||
export * from './legal';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
export type KnowledgeBaseDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
documentCount: number;
|
||||
createdByHqAccountId: string;
|
||||
createdByName: string | null;
|
||||
isOwner: boolean;
|
||||
canEditFull: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CreateKnowledgeBaseRequest = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type UpdateKnowledgeBaseRequest = {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type KnowledgeDocumentDto = {
|
||||
id: string;
|
||||
knowledgeBaseId: string;
|
||||
title: string;
|
||||
fileName: string | null;
|
||||
fileUrl: string | null;
|
||||
mimeType: string | null;
|
||||
sizeBytes: number | null;
|
||||
/** 是否已抽取可检索正文 */
|
||||
hasContent: boolean;
|
||||
status: 'READY' | 'EMPTY' | 'FAILED';
|
||||
errorMessage: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CreateKnowledgeDocumentRequest = {
|
||||
title: string;
|
||||
/** 直贴正文(优先) */
|
||||
contentText?: string | null;
|
||||
fileName?: string | null;
|
||||
fileUrl?: string | null;
|
||||
mimeType?: string | null;
|
||||
sizeBytes?: number | null;
|
||||
};
|
||||
|
||||
export type KnowledgeBaseOptionDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
documentCount: number;
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
/** 语言模型 API 提供商(OpenAI 兼容 Chat Completions) */
|
||||
export const LLM_PROVIDERS = [
|
||||
'DEEPSEEK',
|
||||
'OPENAI',
|
||||
'QWEN',
|
||||
'CUSTOM',
|
||||
] as const;
|
||||
|
||||
export type LlmProvider = (typeof LLM_PROVIDERS)[number];
|
||||
|
||||
export const LLM_PROVIDER_LABELS: Record<LlmProvider, string> = {
|
||||
DEEPSEEK: 'DeepSeek',
|
||||
OPENAI: 'OpenAI',
|
||||
QWEN: '通义千问',
|
||||
CUSTOM: '自定义(OpenAI 兼容)',
|
||||
};
|
||||
|
||||
export const LLM_PROVIDER_PRESETS: Record<
|
||||
LlmProvider,
|
||||
{ defaultBaseUrl: string; defaultModel: string }
|
||||
> = {
|
||||
DEEPSEEK: {
|
||||
defaultBaseUrl: 'https://api.deepseek.com',
|
||||
defaultModel: 'deepseek-chat',
|
||||
},
|
||||
OPENAI: {
|
||||
defaultBaseUrl: 'https://api.openai.com',
|
||||
defaultModel: 'gpt-4o-mini',
|
||||
},
|
||||
QWEN: {
|
||||
defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode',
|
||||
defaultModel: 'qwen-plus',
|
||||
},
|
||||
CUSTOM: {
|
||||
defaultBaseUrl: '',
|
||||
defaultModel: '',
|
||||
},
|
||||
};
|
||||
|
||||
export type LlmApiConfigDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: LlmProvider;
|
||||
baseUrl: string;
|
||||
modelName: string;
|
||||
/** 列表/详情不回明文 */
|
||||
apiKeyConfigured: boolean;
|
||||
temperature: number | null;
|
||||
maxTokens: number | null;
|
||||
systemPrompt: string | null;
|
||||
enabled: boolean;
|
||||
createdByHqAccountId: string;
|
||||
createdByName: string | null;
|
||||
/** 当前登录账号是否为创建人 */
|
||||
isOwner: boolean;
|
||||
/** 非超管仅可改 enabled;超管可改全部 */
|
||||
canEditFull: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CreateLlmApiConfigRequest = {
|
||||
name: string;
|
||||
provider: LlmProvider;
|
||||
baseUrl?: string;
|
||||
apiKey: string;
|
||||
modelName?: string;
|
||||
temperature?: number | null;
|
||||
maxTokens?: number | null;
|
||||
systemPrompt?: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type UpdateLlmApiConfigRequest = {
|
||||
/** 非超管仅允许传 enabled */
|
||||
name?: string;
|
||||
provider?: LlmProvider;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
modelName?: string;
|
||||
temperature?: number | null;
|
||||
maxTokens?: number | null;
|
||||
systemPrompt?: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type LlmApiConfigOptionDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: LlmProvider;
|
||||
modelName: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
@@ -75,6 +75,12 @@ export type WecomBotDto = {
|
||||
avatarUrl: string | null;
|
||||
welcome: string | null;
|
||||
permissions: WecomBotPermission[];
|
||||
/** 未匹配指令时是否调用绑定的语言模型 */
|
||||
aiEnabled: boolean;
|
||||
llmConfigId: string | null;
|
||||
llmConfigName: string | null;
|
||||
knowledgeBaseId: string | null;
|
||||
knowledgeBaseName: string | null;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
@@ -89,6 +95,9 @@ export type CreateWecomBotRequest = {
|
||||
avatarUrl?: string | null;
|
||||
welcome?: string | null;
|
||||
permissions?: WecomBotPermission[];
|
||||
aiEnabled?: boolean;
|
||||
llmConfigId?: string | null;
|
||||
knowledgeBaseId?: string | null;
|
||||
enabled?: boolean;
|
||||
sortOrder?: number;
|
||||
};
|
||||
@@ -102,6 +111,9 @@ export type UpdateWecomBotRequest = {
|
||||
avatarUrl?: string | null;
|
||||
welcome?: string | null;
|
||||
permissions?: WecomBotPermission[];
|
||||
aiEnabled?: boolean;
|
||||
llmConfigId?: string | null;
|
||||
knowledgeBaseId?: string | null;
|
||||
enabled?: boolean;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
const p = new PrismaClient();
|
||||
|
||||
async function ensureColumn(table, column, ddl) {
|
||||
const cols = await p.$queryRawUnsafe(`SHOW COLUMNS FROM \`${table}\` LIKE '${column}'`);
|
||||
if (!cols.length) {
|
||||
await p.$executeRawUnsafe(ddl);
|
||||
console.log(`added ${table}.${column}`);
|
||||
} else {
|
||||
console.log(`${table}.${column} exists`);
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await ensureColumn(
|
||||
'wecom_bot',
|
||||
'ai_enabled',
|
||||
'ALTER TABLE `wecom_bot` ADD COLUMN `ai_enabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `permissions`',
|
||||
);
|
||||
await ensureColumn(
|
||||
'wecom_bot',
|
||||
'llm_config_id',
|
||||
'ALTER TABLE `wecom_bot` ADD COLUMN `llm_config_id` BIGINT UNSIGNED NULL AFTER `ai_enabled`',
|
||||
);
|
||||
await ensureColumn(
|
||||
'wecom_bot',
|
||||
'knowledge_base_id',
|
||||
'ALTER TABLE `wecom_bot` ADD COLUMN `knowledge_base_id` BIGINT UNSIGNED NULL AFTER `llm_config_id`',
|
||||
);
|
||||
await p.$disconnect();
|
||||
})().catch(async (e) => {
|
||||
console.error(e);
|
||||
await p.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
-- LLM / 知识库 / 企微 AI 字段
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `llm_api_config` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(64) NOT NULL,
|
||||
`provider` VARCHAR(32) NOT NULL,
|
||||
`base_url` VARCHAR(512) NOT NULL,
|
||||
`api_key` VARCHAR(512) NOT NULL,
|
||||
`model_name` VARCHAR(128) NOT NULL,
|
||||
`temperature` DECIMAL(3, 2) NULL,
|
||||
`max_tokens` INT NULL,
|
||||
`system_prompt` TEXT NULL,
|
||||
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created_by_hq_account_id` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `llm_api_config_created_by_hq_account_id_enabled_idx` (`created_by_hq_account_id`, `enabled`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `knowledge_base` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(128) NOT NULL,
|
||||
`description` VARCHAR(512) NULL,
|
||||
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created_by_hq_account_id` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `knowledge_base_created_by_hq_account_id_enabled_idx` (`created_by_hq_account_id`, `enabled`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `knowledge_document` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`knowledge_base_id` BIGINT UNSIGNED NOT NULL,
|
||||
`title` VARCHAR(256) NOT NULL,
|
||||
`file_name` VARCHAR(256) NULL,
|
||||
`file_url` VARCHAR(1024) NULL,
|
||||
`mime_type` VARCHAR(128) NULL,
|
||||
`size_bytes` INT NULL,
|
||||
`content_text` LONGTEXT NULL,
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'READY',
|
||||
`error_message` VARCHAR(512) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `knowledge_document_knowledge_base_id_idx` (`knowledge_base_id`),
|
||||
CONSTRAINT `knowledge_document_knowledge_base_id_fkey`
|
||||
FOREIGN KEY (`knowledge_base_id`) REFERENCES `knowledge_base` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@@ -352,24 +352,92 @@ model SystemConfig {
|
||||
|
||||
/// 企业微信智能机器人(HQ 可创建多实例,长连接)
|
||||
model WecomBot {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
name String @db.VarChar(64)
|
||||
role String @db.VarChar(32)
|
||||
botId String @unique @map("bot_id") @db.VarChar(128)
|
||||
secret String @db.VarChar(256)
|
||||
avatarUrl String? @map("avatar_url") @db.VarChar(512)
|
||||
welcome String? @db.VarChar(1024)
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
name String @db.VarChar(64)
|
||||
role String @db.VarChar(32)
|
||||
botId String @unique @map("bot_id") @db.VarChar(128)
|
||||
secret String @db.VarChar(256)
|
||||
avatarUrl String? @map("avatar_url") @db.VarChar(512)
|
||||
welcome String? @db.VarChar(1024)
|
||||
/// JSON 字符串数组,如 ["ticket.create","user.view_sms"]
|
||||
permissions String @db.Text
|
||||
enabled Boolean @default(true)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
permissions String @db.Text
|
||||
/// 未匹配指令时是否调用语言模型
|
||||
aiEnabled Boolean @default(false) @map("ai_enabled")
|
||||
llmConfigId BigInt? @map("llm_config_id") @db.UnsignedBigInt
|
||||
knowledgeBaseId BigInt? @map("knowledge_base_id") @db.UnsignedBigInt
|
||||
enabled Boolean @default(true)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
llmConfig LlmApiConfig? @relation(fields: [llmConfigId], references: [id], onDelete: SetNull)
|
||||
knowledgeBase KnowledgeBase? @relation(fields: [knowledgeBaseId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([enabled, sortOrder])
|
||||
@@index([llmConfigId])
|
||||
@@index([knowledgeBaseId])
|
||||
@@map("wecom_bot")
|
||||
}
|
||||
|
||||
/// HQ 语言模型 API 配置(非超管仅可见/可开关自己创建的)
|
||||
model LlmApiConfig {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
name String @db.VarChar(64)
|
||||
provider String @db.VarChar(32)
|
||||
baseUrl String @map("base_url") @db.VarChar(512)
|
||||
apiKey String @map("api_key") @db.VarChar(512)
|
||||
modelName String @map("model_name") @db.VarChar(128)
|
||||
temperature Decimal? @db.Decimal(3, 2)
|
||||
maxTokens Int? @map("max_tokens")
|
||||
systemPrompt String? @map("system_prompt") @db.Text
|
||||
enabled Boolean @default(true)
|
||||
createdByHqAccountId BigInt @map("created_by_hq_account_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
wecomBots WecomBot[]
|
||||
|
||||
@@index([createdByHqAccountId, enabled])
|
||||
@@map("llm_api_config")
|
||||
}
|
||||
|
||||
/// HQ 知识库
|
||||
model KnowledgeBase {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
name String @db.VarChar(128)
|
||||
description String? @db.VarChar(512)
|
||||
enabled Boolean @default(true)
|
||||
createdByHqAccountId BigInt @map("created_by_hq_account_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
documents KnowledgeDocument[]
|
||||
wecomBots WecomBot[]
|
||||
|
||||
@@index([createdByHqAccountId, enabled])
|
||||
@@map("knowledge_base")
|
||||
}
|
||||
|
||||
model KnowledgeDocument {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
knowledgeBaseId BigInt @map("knowledge_base_id") @db.UnsignedBigInt
|
||||
title String @db.VarChar(256)
|
||||
fileName String? @map("file_name") @db.VarChar(256)
|
||||
fileUrl String? @map("file_url") @db.VarChar(1024)
|
||||
mimeType String? @map("mime_type") @db.VarChar(128)
|
||||
sizeBytes Int? @map("size_bytes")
|
||||
contentText String? @map("content_text") @db.LongText
|
||||
status String @default("READY") @db.VarChar(16)
|
||||
errorMessage String? @map("error_message") @db.VarChar(512)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([knowledgeBaseId])
|
||||
@@map("knowledge_document")
|
||||
}
|
||||
|
||||
model MockSmsCode {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
phone String @db.VarChar(20)
|
||||
|
||||
@@ -76,6 +76,15 @@ export const HqOperationAction = {
|
||||
WECOM_BOT_UPDATE: 'WECOM_BOT_UPDATE',
|
||||
WECOM_BOT_DELETE: 'WECOM_BOT_DELETE',
|
||||
WECOM_BOT_RELOAD: 'WECOM_BOT_RELOAD',
|
||||
LLM_CONFIG_CREATE: 'LLM_CONFIG_CREATE',
|
||||
LLM_CONFIG_UPDATE: 'LLM_CONFIG_UPDATE',
|
||||
LLM_CONFIG_DELETE: 'LLM_CONFIG_DELETE',
|
||||
LLM_CONFIG_TEST: 'LLM_CONFIG_TEST',
|
||||
KNOWLEDGE_BASE_CREATE: 'KNOWLEDGE_BASE_CREATE',
|
||||
KNOWLEDGE_BASE_UPDATE: 'KNOWLEDGE_BASE_UPDATE',
|
||||
KNOWLEDGE_BASE_DELETE: 'KNOWLEDGE_BASE_DELETE',
|
||||
KNOWLEDGE_DOC_CREATE: 'KNOWLEDGE_DOC_CREATE',
|
||||
KNOWLEDGE_DOC_DELETE: 'KNOWLEDGE_DOC_DELETE',
|
||||
REDEEM_PENDING_COMPLETE: 'REDEEM_PENDING_COMPLETE',
|
||||
REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT',
|
||||
DEPLOY_TRIGGER: 'DEPLOY_TRIGGER',
|
||||
@@ -163,6 +172,15 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
||||
[HqOperationAction.WECOM_BOT_UPDATE]: '编辑企微机器人',
|
||||
[HqOperationAction.WECOM_BOT_DELETE]: '删除企微机器人',
|
||||
[HqOperationAction.WECOM_BOT_RELOAD]: '重载企微机器人连接',
|
||||
[HqOperationAction.LLM_CONFIG_CREATE]: '创建语言模型配置',
|
||||
[HqOperationAction.LLM_CONFIG_UPDATE]: '更新语言模型配置',
|
||||
[HqOperationAction.LLM_CONFIG_DELETE]: '删除语言模型配置',
|
||||
[HqOperationAction.LLM_CONFIG_TEST]: '测试语言模型配置',
|
||||
[HqOperationAction.KNOWLEDGE_BASE_CREATE]: '创建知识库',
|
||||
[HqOperationAction.KNOWLEDGE_BASE_UPDATE]: '更新知识库',
|
||||
[HqOperationAction.KNOWLEDGE_BASE_DELETE]: '删除知识库',
|
||||
[HqOperationAction.KNOWLEDGE_DOC_CREATE]: '上传知识库文档',
|
||||
[HqOperationAction.KNOWLEDGE_DOC_DELETE]: '删除知识库文档',
|
||||
[HqOperationAction.REDEEM_PENDING_COMPLETE]: '弱网待处理单-补核销',
|
||||
[HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回',
|
||||
[HqOperationAction.DEPLOY_TRIGGER]: '触发系统发布',
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
|
||||
const MAX_CONTEXT_CHARS = 6000;
|
||||
|
||||
@Injectable()
|
||||
export class KnowledgeRetrievalService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** 简易关键词命中:按文档正文包含查询词打分,拼进上下文 */
|
||||
async buildContext(knowledgeBaseId: bigint, query: string): Promise<string> {
|
||||
const kb = await this.prisma.knowledgeBase.findUnique({
|
||||
where: { id: knowledgeBaseId },
|
||||
select: { id: true, enabled: true, name: true },
|
||||
});
|
||||
if (!kb?.enabled) return '';
|
||||
|
||||
const docs = await this.prisma.knowledgeDocument.findMany({
|
||||
where: {
|
||||
knowledgeBaseId,
|
||||
status: 'READY',
|
||||
contentText: { not: null },
|
||||
},
|
||||
select: { title: true, contentText: true },
|
||||
take: 50,
|
||||
});
|
||||
if (!docs.length) return '';
|
||||
|
||||
const tokens = tokenize(query);
|
||||
const scored = docs
|
||||
.map((d) => {
|
||||
const body = d.contentText || '';
|
||||
let score = 0;
|
||||
for (const t of tokens) {
|
||||
if (body.includes(t) || d.title.includes(t)) score += 1;
|
||||
}
|
||||
if (!tokens.length) score = 1;
|
||||
return { title: d.title, body, score };
|
||||
})
|
||||
.filter((x) => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const picked = scored.length ? scored.slice(0, 5) : docs.slice(0, 3).map((d) => ({
|
||||
title: d.title,
|
||||
body: d.contentText || '',
|
||||
score: 0,
|
||||
}));
|
||||
|
||||
let out = `【知识库:${kb.name}】\n`;
|
||||
for (const p of picked) {
|
||||
const chunk = `### ${p.title}\n${p.body}\n\n`;
|
||||
if (out.length + chunk.length > MAX_CONTEXT_CHARS) {
|
||||
out += chunk.slice(0, Math.max(0, MAX_CONTEXT_CHARS - out.length));
|
||||
break;
|
||||
}
|
||||
out += chunk;
|
||||
}
|
||||
return out.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function tokenize(q: string): string[] {
|
||||
return q
|
||||
.split(/[\s,,。;;、!?!?\-_/\\]+/)
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter((s) => s.length >= 2)
|
||||
.slice(0, 12);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export type LlmChatMessage = { role: 'system' | 'user' | 'assistant'; content: string };
|
||||
|
||||
export type LlmChatParams = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
messages: LlmChatMessage[];
|
||||
temperature?: number | null;
|
||||
maxTokens?: number | null;
|
||||
};
|
||||
|
||||
/** 规范化 OpenAI 兼容根地址:去掉末尾 / 与重复的 /v1 */
|
||||
export function normalizeLlmBaseUrl(raw: string): string {
|
||||
let base = String(raw || '').trim().replace(/\/+$/, '');
|
||||
// 用户常填 https://api.deepseek.com/v1 ,避免拼成 /v1/v1/chat/completions
|
||||
if (/\/v1$/i.test(base)) {
|
||||
base = base.replace(/\/v1$/i, '');
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function buildLlmChatCompletionsUrl(baseUrl: string): string {
|
||||
return `${normalizeLlmBaseUrl(baseUrl)}/v1/chat/completions`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LlmChatClient {
|
||||
private readonly logger = new Logger(LlmChatClient.name);
|
||||
|
||||
async chat(params: LlmChatParams): Promise<string> {
|
||||
const url = buildLlmChatCompletionsUrl(params.baseUrl);
|
||||
const body: Record<string, unknown> = {
|
||||
model: params.model,
|
||||
messages: params.messages,
|
||||
stream: false,
|
||||
};
|
||||
if (params.temperature != null && !Number.isNaN(params.temperature)) {
|
||||
body.temperature = params.temperature;
|
||||
}
|
||||
if (params.maxTokens != null && params.maxTokens > 0) {
|
||||
body.max_tokens = params.maxTokens;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`llm chat failed ${res.status} ${url}: ${text.slice(0, 400)}`);
|
||||
throw new Error(`语言模型调用失败(HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
let json: {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
error?: { message?: string };
|
||||
};
|
||||
try {
|
||||
json = JSON.parse(text) as typeof json;
|
||||
} catch {
|
||||
throw new Error('语言模型返回非 JSON');
|
||||
}
|
||||
if (json.error?.message) throw new Error(json.error.message);
|
||||
const content = json.choices?.[0]?.message?.content?.trim();
|
||||
if (!content) throw new Error('语言模型未返回内容');
|
||||
return content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LlmChatClient } from './llm-chat.client';
|
||||
import { KnowledgeRetrievalService } from './knowledge-retrieval.service';
|
||||
|
||||
@Module({
|
||||
providers: [LlmChatClient, KnowledgeRetrievalService],
|
||||
exports: [LlmChatClient, KnowledgeRetrievalService],
|
||||
})
|
||||
export class LlmModule {}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { WecomBotActionsService } from './wecom-bot-actions.service';
|
||||
import { WecomBotAiService } from './wecom-bot-ai.service';
|
||||
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
|
||||
|
||||
const DEFAULT_WELCOMES: Record<WecomBotRole, string> = {
|
||||
@@ -62,6 +63,7 @@ export class WecomAibotService implements OnModuleInit, OnModuleDestroy {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly actions: WecomBotActionsService,
|
||||
private readonly ai: WecomBotAiService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
@@ -150,6 +152,9 @@ export class WecomAibotService implements OnModuleInit, OnModuleDestroy {
|
||||
avatarUrl: string | null;
|
||||
welcome: string | null;
|
||||
permissions: string;
|
||||
aiEnabled: boolean;
|
||||
llmConfigId: bigint | null;
|
||||
knowledgeBaseId: bigint | null;
|
||||
enabled: boolean;
|
||||
}): WecomBotRuntimeConfig {
|
||||
const role = (isWecomRole(row.role) ? row.role : 'CUSTOM') as WecomBotRole;
|
||||
@@ -164,6 +169,9 @@ export class WecomAibotService implements OnModuleInit, OnModuleDestroy {
|
||||
avatarUrl: row.avatarUrl,
|
||||
welcome: row.welcome?.trim() || DEFAULT_WELCOMES[role],
|
||||
permissions: resolveWecomBotPermissions(role, row.permissions),
|
||||
aiEnabled: row.aiEnabled,
|
||||
llmConfigId: row.llmConfigId?.toString() ?? null,
|
||||
knowledgeBaseId: row.knowledgeBaseId?.toString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -258,8 +266,24 @@ export class WecomAibotService implements OnModuleInit, OnModuleDestroy {
|
||||
await this.replyText(client, frame, this.formatStatusMarkdown());
|
||||
return;
|
||||
}
|
||||
const reply = await this.actions.handleCommand(cfg, wecomUserId, content);
|
||||
await this.replyText(client, frame, reply);
|
||||
const useAi = cfg.aiEnabled && !!cfg.llmConfigId;
|
||||
const reply = await this.actions.handleCommand(cfg, wecomUserId, content, {
|
||||
skipNaturalFallback: useAi,
|
||||
});
|
||||
if (reply != null) {
|
||||
await this.replyText(client, frame, reply);
|
||||
return;
|
||||
}
|
||||
if (useAi) {
|
||||
const aiReply = await this.ai.replyIfConfigured(cfg, content);
|
||||
await this.replyText(
|
||||
client,
|
||||
frame,
|
||||
aiReply ?? `未识别指令。\n\n${this.actions.buildHelp(cfg)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.replyText(client, frame, `未识别指令。\n\n${this.actions.buildHelp(cfg)}`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
this.logger.error(`[${cfg.key}] handle text failed: ${msg}`);
|
||||
|
||||
@@ -95,6 +95,13 @@ export class WecomBotActionsService {
|
||||
'',
|
||||
);
|
||||
}
|
||||
if (bot.aiEnabled && bot.llmConfigId) {
|
||||
lines.push(
|
||||
'**智能问答**',
|
||||
'未匹配指令的自然语言将交由绑定的语言模型回答(可挂知识库)',
|
||||
'',
|
||||
);
|
||||
}
|
||||
lines.push(`当前权限:${bot.permissions.join(', ') || '无'}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -103,7 +110,8 @@ export class WecomBotActionsService {
|
||||
bot: WecomBotRuntimeConfig,
|
||||
wecomUserId: string,
|
||||
text: string,
|
||||
): Promise<string> {
|
||||
opts?: { skipNaturalFallback?: boolean },
|
||||
): Promise<string | null> {
|
||||
const content = text.trim();
|
||||
if (!content) return this.buildHelp(bot);
|
||||
|
||||
@@ -152,14 +160,19 @@ export class WecomBotActionsService {
|
||||
return this.queryHandbook(q);
|
||||
}
|
||||
|
||||
// 自然语言手册(仅团队助手有 handbook 权限时)
|
||||
if (wecomBotHasPermission(bot, 'handbook.query') && content.length >= 2) {
|
||||
// 自然语言手册(仅团队助手有 handbook 权限时;开启 AI 时改由模型+知识库回答)
|
||||
if (
|
||||
!opts?.skipNaturalFallback &&
|
||||
wecomBotHasPermission(bot, 'handbook.query') &&
|
||||
content.length >= 2
|
||||
) {
|
||||
const hit = searchHandbook(content, 1);
|
||||
if (hit.length) {
|
||||
return formatHandbook(hit);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts?.skipNaturalFallback) return null;
|
||||
return `未识别指令。\n\n${this.buildHelp(bot)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { LlmChatClient } from '../llm/llm-chat.client';
|
||||
import { KnowledgeRetrievalService } from '../llm/knowledge-retrieval.service';
|
||||
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
|
||||
|
||||
const DEFAULT_SYSTEM = [
|
||||
'你是杜康好客企业内部助手。',
|
||||
'优先依据提供的知识库内容回答;知识库未覆盖时如实说明不确定。',
|
||||
'不要编造订单号、金额、权限;涉及写操作请引导用户使用指令(如「帮助」)。',
|
||||
'回答简洁,使用中文。',
|
||||
].join('');
|
||||
|
||||
@Injectable()
|
||||
export class WecomBotAiService {
|
||||
private readonly logger = new Logger(WecomBotAiService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly llm: LlmChatClient,
|
||||
private readonly kb: KnowledgeRetrievalService,
|
||||
) {}
|
||||
|
||||
async replyIfConfigured(bot: WecomBotRuntimeConfig, userText: string): Promise<string | null> {
|
||||
if (!bot.aiEnabled || !bot.llmConfigId) return null;
|
||||
|
||||
const cfg = await this.prisma.llmApiConfig.findUnique({
|
||||
where: { id: BigInt(bot.llmConfigId) },
|
||||
});
|
||||
if (!cfg?.enabled) {
|
||||
return '已开启 AI,但绑定的语言模型未启用或已删除。请在 HQ「企微机器人」检查配置。';
|
||||
}
|
||||
|
||||
let kbBlock = '';
|
||||
if (bot.knowledgeBaseId) {
|
||||
try {
|
||||
kbBlock = await this.kb.buildContext(BigInt(bot.knowledgeBaseId), userText);
|
||||
} catch (e) {
|
||||
this.logger.warn(`kb retrieve failed: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const systemParts = [
|
||||
cfg.systemPrompt?.trim() || DEFAULT_SYSTEM,
|
||||
kbBlock ? `\n\n以下为知识库检索片段:\n${kbBlock}` : '',
|
||||
];
|
||||
|
||||
try {
|
||||
return await this.llm.chat({
|
||||
baseUrl: cfg.baseUrl,
|
||||
apiKey: cfg.apiKey,
|
||||
model: cfg.modelName,
|
||||
temperature: cfg.temperature != null ? Number(cfg.temperature) : 0.3,
|
||||
maxTokens: cfg.maxTokens ?? 1024,
|
||||
messages: [
|
||||
{ role: 'system', content: systemParts.join('') },
|
||||
{ role: 'user', content: userText },
|
||||
],
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
this.logger.error(`wecom ai reply failed: ${msg}`);
|
||||
return `AI 回复失败:${msg}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ export type WecomBotRuntimeConfig = {
|
||||
welcome: string;
|
||||
avatarUrl: string | null;
|
||||
permissions: WecomBotPermission[];
|
||||
aiEnabled: boolean;
|
||||
llmConfigId: string | null;
|
||||
knowledgeBaseId: string | null;
|
||||
};
|
||||
|
||||
export function wecomBotHasPermission(
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { CommonModule } from '../../modules/common/common.module';
|
||||
import { IntegrationsModule } from '../integrations.module';
|
||||
import { LlmModule } from '../llm/llm.module';
|
||||
import { WecomAibotService } from './wecom-aibot.service';
|
||||
import { WecomBotActionsService } from './wecom-bot-actions.service';
|
||||
import { WecomBotAiService } from './wecom-bot-ai.service';
|
||||
import { WecomBotSessionService } from './wecom-bot-session.service';
|
||||
|
||||
/** 企微多机器人:依赖 Common(工单)+ Integrations(短信),不反向被 Integrations 引用 */
|
||||
/** 企微多机器人:依赖 Common(工单)+ Integrations(短信)+ Llm,不反向被 Integrations 引用 */
|
||||
@Module({
|
||||
imports: [forwardRef(() => CommonModule), forwardRef(() => IntegrationsModule)],
|
||||
providers: [WecomBotSessionService, WecomBotActionsService, WecomAibotService],
|
||||
imports: [
|
||||
forwardRef(() => CommonModule),
|
||||
forwardRef(() => IntegrationsModule),
|
||||
LlmModule,
|
||||
],
|
||||
providers: [
|
||||
WecomBotSessionService,
|
||||
WecomBotActionsService,
|
||||
WecomBotAiService,
|
||||
WecomAibotService,
|
||||
],
|
||||
exports: [WecomAibotService],
|
||||
})
|
||||
export class WecomModule {}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateKnowledgeBaseRequest,
|
||||
CreateKnowledgeDocumentRequest,
|
||||
UpdateKnowledgeBaseRequest,
|
||||
} from '@dukang/shared-types';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
RequireHqPermissions,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { AdminKnowledgeBasesService } from './admin-knowledge-bases.service';
|
||||
|
||||
@Controller('admin/knowledge-bases')
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('knowledge_bases')
|
||||
export class AdminKnowledgeBasesController {
|
||||
constructor(private readonly service: AdminKnowledgeBasesService) {}
|
||||
|
||||
@Get()
|
||||
async list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('name') name?: string,
|
||||
@Query('enabled') enabled?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.list(actor, {
|
||||
name,
|
||||
enabled,
|
||||
page: page ? Number(page) : undefined,
|
||||
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('options')
|
||||
async options(@CurrentUser() user: AuthUser) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.options(actor);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.detail(actor, BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.KNOWLEDGE_BASE_CREATE,
|
||||
refType: 'KNOWLEDGE_BASE',
|
||||
includeBody: true,
|
||||
})
|
||||
async create(@CurrentUser() user: AuthUser, @Body() body: CreateKnowledgeBaseRequest) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.create(actor, body);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.KNOWLEDGE_BASE_UPDATE,
|
||||
refType: 'KNOWLEDGE_BASE',
|
||||
refIdField: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
async update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: UpdateKnowledgeBaseRequest,
|
||||
) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.update(actor, BigInt(id), body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.KNOWLEDGE_BASE_DELETE,
|
||||
refType: 'KNOWLEDGE_BASE',
|
||||
refIdField: 'id',
|
||||
})
|
||||
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.remove(actor, BigInt(id));
|
||||
}
|
||||
|
||||
@Get(':id/documents')
|
||||
async listDocuments(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.listDocuments(actor, BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/documents')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.KNOWLEDGE_DOC_CREATE,
|
||||
refType: 'KNOWLEDGE_DOCUMENT',
|
||||
includeBody: true,
|
||||
})
|
||||
async addDocument(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: CreateKnowledgeDocumentRequest,
|
||||
) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.addDocument(actor, BigInt(id), body);
|
||||
}
|
||||
|
||||
@Delete(':id/documents/:docId')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.KNOWLEDGE_DOC_DELETE,
|
||||
refType: 'KNOWLEDGE_DOCUMENT',
|
||||
refIdField: 'docId',
|
||||
})
|
||||
async removeDocument(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Param('docId') docId: string,
|
||||
) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.removeDocument(actor, BigInt(id), BigInt(docId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateKnowledgeBaseRequest,
|
||||
CreateKnowledgeDocumentRequest,
|
||||
KnowledgeBaseDto,
|
||||
KnowledgeBaseOptionDto,
|
||||
KnowledgeDocumentDto,
|
||||
UpdateKnowledgeBaseRequest,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
type ActorCtx = { actorId: bigint; isSuperAdmin: boolean };
|
||||
|
||||
const TEXT_EXT = /\.(txt|md|markdown|csv|json|log)$/i;
|
||||
|
||||
@Injectable()
|
||||
export class AdminKnowledgeBasesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async resolveActor(actorId: bigint): Promise<ActorCtx> {
|
||||
const account = await this.prisma.hqAccount.findUnique({
|
||||
where: { id: actorId },
|
||||
select: { adminRole: true, status: true },
|
||||
});
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
throw new ForbiddenException('账号不可用');
|
||||
}
|
||||
return {
|
||||
actorId,
|
||||
isSuperAdmin: account.adminRole === 'SUPER_ADMIN',
|
||||
};
|
||||
}
|
||||
|
||||
async list(
|
||||
actor: ActorCtx,
|
||||
query: { name?: string; enabled?: string; page?: number; pageSize?: number },
|
||||
) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: {
|
||||
name?: { contains: string };
|
||||
enabled?: boolean;
|
||||
createdByHqAccountId?: bigint;
|
||||
} = {};
|
||||
if (!actor.isSuperAdmin) where.createdByHqAccountId = actor.actorId;
|
||||
if (query.name?.trim()) where.name = { contains: query.name.trim() };
|
||||
if (query.enabled === 'true' || query.enabled === 'false') {
|
||||
where.enabled = query.enabled === 'true';
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.knowledgeBase.findMany({
|
||||
where,
|
||||
orderBy: [{ id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { _count: { select: { documents: true } } },
|
||||
}),
|
||||
this.prisma.knowledgeBase.count({ where }),
|
||||
]);
|
||||
|
||||
const ownerIds = [...new Set(items.map((i) => i.createdByHqAccountId))];
|
||||
const owners = ownerIds.length
|
||||
? await this.prisma.hqAccount.findMany({
|
||||
where: { id: { in: ownerIds } },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: [];
|
||||
const ownerMap = new Map(owners.map((o) => [o.id.toString(), o.name]));
|
||||
|
||||
return serializeBigInt({
|
||||
items: items.map((row) =>
|
||||
this.toKbDto(row, actor, ownerMap.get(row.createdByHqAccountId.toString()) ?? null, row._count.documents),
|
||||
),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async options(actor: ActorCtx): Promise<KnowledgeBaseOptionDto[]> {
|
||||
const where: { enabled: boolean; createdByHqAccountId?: bigint } = { enabled: true };
|
||||
if (!actor.isSuperAdmin) where.createdByHqAccountId = actor.actorId;
|
||||
const rows = await this.prisma.knowledgeBase.findMany({
|
||||
where,
|
||||
orderBy: [{ id: 'desc' }],
|
||||
include: { _count: { select: { documents: true } } },
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id.toString(),
|
||||
name: r.name,
|
||||
enabled: r.enabled,
|
||||
documentCount: r._count.documents,
|
||||
}));
|
||||
}
|
||||
|
||||
async detail(actor: ActorCtx, id: bigint) {
|
||||
const row = await this.requireKb(actor, id);
|
||||
const count = await this.prisma.knowledgeDocument.count({ where: { knowledgeBaseId: id } });
|
||||
const owner = await this.prisma.hqAccount.findUnique({
|
||||
where: { id: row.createdByHqAccountId },
|
||||
select: { name: true },
|
||||
});
|
||||
return this.toKbDto(row, actor, owner?.name ?? null, count);
|
||||
}
|
||||
|
||||
async create(actor: ActorCtx, dto: CreateKnowledgeBaseRequest) {
|
||||
const name = dto.name?.trim();
|
||||
if (!name) throw new BadRequestException('请填写名称');
|
||||
const row = await this.prisma.knowledgeBase.create({
|
||||
data: {
|
||||
name,
|
||||
description: dto.description?.trim() || null,
|
||||
enabled: dto.enabled !== false,
|
||||
createdByHqAccountId: actor.actorId,
|
||||
},
|
||||
});
|
||||
return this.toKbDto(row, actor, null, 0);
|
||||
}
|
||||
|
||||
async update(actor: ActorCtx, id: bigint, dto: UpdateKnowledgeBaseRequest) {
|
||||
const row = await this.requireKb(actor, id);
|
||||
this.requireWrite(actor, row);
|
||||
|
||||
if (!actor.isSuperAdmin) {
|
||||
// 创建人可改名称/描述/启用
|
||||
const data: {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
} = {};
|
||||
if (dto.name !== undefined) {
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('名称不能为空');
|
||||
data.name = name;
|
||||
}
|
||||
if (dto.description !== undefined) data.description = dto.description?.trim() || null;
|
||||
if (dto.enabled !== undefined) data.enabled = dto.enabled;
|
||||
const updated = await this.prisma.knowledgeBase.update({ where: { id }, data });
|
||||
const count = await this.prisma.knowledgeDocument.count({ where: { knowledgeBaseId: id } });
|
||||
return this.toKbDto(updated, actor, null, count);
|
||||
}
|
||||
|
||||
const data: {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
} = {};
|
||||
if (dto.name !== undefined) {
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('名称不能为空');
|
||||
data.name = name;
|
||||
}
|
||||
if (dto.description !== undefined) data.description = dto.description?.trim() || null;
|
||||
if (dto.enabled !== undefined) data.enabled = dto.enabled;
|
||||
const updated = await this.prisma.knowledgeBase.update({ where: { id }, data });
|
||||
const count = await this.prisma.knowledgeDocument.count({ where: { knowledgeBaseId: id } });
|
||||
return this.toKbDto(updated, actor, null, count);
|
||||
}
|
||||
|
||||
async remove(actor: ActorCtx, id: bigint) {
|
||||
const row = await this.requireKb(actor, id);
|
||||
this.requireWrite(actor, row);
|
||||
await this.prisma.knowledgeBase.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async listDocuments(actor: ActorCtx, kbId: bigint) {
|
||||
await this.requireKb(actor, kbId);
|
||||
const rows = await this.prisma.knowledgeDocument.findMany({
|
||||
where: { knowledgeBaseId: kbId },
|
||||
orderBy: [{ id: 'desc' }],
|
||||
});
|
||||
return serializeBigInt({ items: rows.map((r) => this.toDocDto(r)) });
|
||||
}
|
||||
|
||||
async addDocument(actor: ActorCtx, kbId: bigint, dto: CreateKnowledgeDocumentRequest) {
|
||||
const kb = await this.requireKb(actor, kbId);
|
||||
this.requireWrite(actor, kb);
|
||||
|
||||
const title = dto.title?.trim();
|
||||
if (!title) throw new BadRequestException('请填写标题');
|
||||
|
||||
let contentText = dto.contentText?.trim() || '';
|
||||
let status: 'READY' | 'EMPTY' | 'FAILED' = 'EMPTY';
|
||||
let errorMessage: string | null = null;
|
||||
|
||||
if (contentText) {
|
||||
status = 'READY';
|
||||
} else if (dto.fileUrl?.trim()) {
|
||||
const fileName = dto.fileName?.trim() || '';
|
||||
if (TEXT_EXT.test(fileName) || isLikelyTextMime(dto.mimeType)) {
|
||||
try {
|
||||
contentText = await fetchText(dto.fileUrl.trim());
|
||||
status = contentText.trim() ? 'READY' : 'EMPTY';
|
||||
if (!contentText.trim()) errorMessage = '文件内容为空';
|
||||
} catch (e) {
|
||||
status = 'FAILED';
|
||||
errorMessage = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
} else {
|
||||
status = 'EMPTY';
|
||||
errorMessage = '非文本文件未抽取正文,请粘贴文本或上传 .txt/.md';
|
||||
}
|
||||
} else {
|
||||
throw new BadRequestException('请粘贴正文或上传文件');
|
||||
}
|
||||
|
||||
const row = await this.prisma.knowledgeDocument.create({
|
||||
data: {
|
||||
knowledgeBaseId: kbId,
|
||||
title,
|
||||
fileName: dto.fileName?.trim() || null,
|
||||
fileUrl: dto.fileUrl?.trim() || null,
|
||||
mimeType: dto.mimeType?.trim() || null,
|
||||
sizeBytes: dto.sizeBytes ?? null,
|
||||
contentText: contentText || null,
|
||||
status,
|
||||
errorMessage,
|
||||
},
|
||||
});
|
||||
return this.toDocDto(row);
|
||||
}
|
||||
|
||||
async removeDocument(actor: ActorCtx, kbId: bigint, docId: bigint) {
|
||||
const kb = await this.requireKb(actor, kbId);
|
||||
this.requireWrite(actor, kb);
|
||||
const doc = await this.prisma.knowledgeDocument.findFirst({
|
||||
where: { id: docId, knowledgeBaseId: kbId },
|
||||
});
|
||||
if (!doc) throw new NotFoundException('文档不存在');
|
||||
await this.prisma.knowledgeDocument.delete({ where: { id: docId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async requireKb(actor: ActorCtx, id: bigint) {
|
||||
const row = await this.prisma.knowledgeBase.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('知识库不存在');
|
||||
if (!actor.isSuperAdmin && row.createdByHqAccountId !== actor.actorId) {
|
||||
throw new ForbiddenException('无权查看该知识库');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private requireWrite(
|
||||
actor: ActorCtx,
|
||||
row: { createdByHqAccountId: bigint },
|
||||
) {
|
||||
if (actor.isSuperAdmin) return;
|
||||
if (row.createdByHqAccountId !== actor.actorId) {
|
||||
throw new ForbiddenException('只能操作自己创建的知识库');
|
||||
}
|
||||
}
|
||||
|
||||
private toKbDto(
|
||||
row: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
createdByHqAccountId: bigint;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
},
|
||||
actor: ActorCtx,
|
||||
createdByName: string | null,
|
||||
documentCount: number,
|
||||
): KnowledgeBaseDto {
|
||||
const isOwner = row.createdByHqAccountId === actor.actorId;
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
enabled: row.enabled,
|
||||
documentCount,
|
||||
createdByHqAccountId: row.createdByHqAccountId.toString(),
|
||||
createdByName,
|
||||
isOwner,
|
||||
canEditFull: actor.isSuperAdmin || isOwner,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toDocDto(row: {
|
||||
id: bigint;
|
||||
knowledgeBaseId: bigint;
|
||||
title: string;
|
||||
fileName: string | null;
|
||||
fileUrl: string | null;
|
||||
mimeType: string | null;
|
||||
sizeBytes: number | null;
|
||||
contentText: string | null;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): KnowledgeDocumentDto {
|
||||
const status =
|
||||
row.status === 'READY' || row.status === 'FAILED' || row.status === 'EMPTY'
|
||||
? row.status
|
||||
: 'EMPTY';
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
knowledgeBaseId: row.knowledgeBaseId.toString(),
|
||||
title: row.title,
|
||||
fileName: row.fileName,
|
||||
fileUrl: row.fileUrl,
|
||||
mimeType: row.mimeType,
|
||||
sizeBytes: row.sizeBytes,
|
||||
hasContent: !!(row.contentText && row.contentText.trim()),
|
||||
status,
|
||||
errorMessage: row.errorMessage,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isLikelyTextMime(mime?: string | null) {
|
||||
if (!mime) return false;
|
||||
return (
|
||||
mime.startsWith('text/') ||
|
||||
mime === 'application/json' ||
|
||||
mime === 'application/markdown'
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`下载文件失败 HTTP ${res.status}`);
|
||||
const buf = await res.arrayBuffer();
|
||||
if (buf.byteLength > 2 * 1024 * 1024) throw new Error('文本文件超过 2MB');
|
||||
return new TextDecoder('utf-8', { fatal: false }).decode(buf);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateLlmApiConfigRequest,
|
||||
UpdateLlmApiConfigRequest,
|
||||
} from '@dukang/shared-types';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
RequireHqPermissions,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { AdminLlmConfigsService } from './admin-llm-configs.service';
|
||||
|
||||
@Controller('admin/llm-configs')
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('llm_configs')
|
||||
export class AdminLlmConfigsController {
|
||||
constructor(private readonly service: AdminLlmConfigsService) {}
|
||||
|
||||
@Get()
|
||||
async list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('name') name?: string,
|
||||
@Query('enabled') enabled?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.list(actor, {
|
||||
name,
|
||||
enabled,
|
||||
page: page ? Number(page) : undefined,
|
||||
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('options')
|
||||
async options(@CurrentUser() user: AuthUser) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.options(actor);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.detail(actor, BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.LLM_CONFIG_CREATE,
|
||||
refType: 'LLM_CONFIG',
|
||||
includeBody: true,
|
||||
})
|
||||
async create(@CurrentUser() user: AuthUser, @Body() body: CreateLlmApiConfigRequest) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.create(actor, body);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.LLM_CONFIG_UPDATE,
|
||||
refType: 'LLM_CONFIG',
|
||||
refIdField: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
async update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: UpdateLlmApiConfigRequest,
|
||||
) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.update(actor, BigInt(id), body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.LLM_CONFIG_DELETE,
|
||||
refType: 'LLM_CONFIG',
|
||||
refIdField: 'id',
|
||||
})
|
||||
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.remove(actor, BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/test')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.LLM_CONFIG_TEST,
|
||||
refType: 'LLM_CONFIG',
|
||||
refIdField: 'id',
|
||||
})
|
||||
async test(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.test(actor, BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
LLM_PROVIDERS,
|
||||
LLM_PROVIDER_PRESETS,
|
||||
type CreateLlmApiConfigRequest,
|
||||
type LlmApiConfigDto,
|
||||
type LlmApiConfigOptionDto,
|
||||
type LlmProvider,
|
||||
type UpdateLlmApiConfigRequest,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { LlmChatClient, normalizeLlmBaseUrl } from '../../integrations/llm/llm-chat.client';
|
||||
|
||||
type ActorCtx = { actorId: bigint; isSuperAdmin: boolean };
|
||||
|
||||
function isProvider(v: string): v is LlmProvider {
|
||||
return (LLM_PROVIDERS as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminLlmConfigsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly llm: LlmChatClient,
|
||||
) {}
|
||||
|
||||
async resolveActor(actorId: bigint): Promise<ActorCtx> {
|
||||
const account = await this.prisma.hqAccount.findUnique({
|
||||
where: { id: actorId },
|
||||
select: { adminRole: true, status: true },
|
||||
});
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
throw new ForbiddenException('账号不可用');
|
||||
}
|
||||
return {
|
||||
actorId,
|
||||
isSuperAdmin: account.adminRole === 'SUPER_ADMIN',
|
||||
};
|
||||
}
|
||||
|
||||
async list(
|
||||
actor: ActorCtx,
|
||||
query: { name?: string; enabled?: string; page?: number; pageSize?: number },
|
||||
) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: {
|
||||
name?: { contains: string };
|
||||
enabled?: boolean;
|
||||
createdByHqAccountId?: bigint;
|
||||
} = {};
|
||||
if (!actor.isSuperAdmin) where.createdByHqAccountId = actor.actorId;
|
||||
if (query.name?.trim()) where.name = { contains: query.name.trim() };
|
||||
if (query.enabled === 'true' || query.enabled === 'false') {
|
||||
where.enabled = query.enabled === 'true';
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.llmApiConfig.findMany({
|
||||
where,
|
||||
orderBy: [{ id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.llmApiConfig.count({ where }),
|
||||
]);
|
||||
|
||||
const ownerIds = [...new Set(items.map((i) => i.createdByHqAccountId))];
|
||||
const owners = ownerIds.length
|
||||
? await this.prisma.hqAccount.findMany({
|
||||
where: { id: { in: ownerIds } },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: [];
|
||||
const ownerMap = new Map(owners.map((o) => [o.id.toString(), o.name]));
|
||||
|
||||
return serializeBigInt({
|
||||
items: items.map((row) => this.toDto(row, actor, ownerMap.get(row.createdByHqAccountId.toString()) ?? null)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
/** 企微绑定下拉:已启用;非超管仅自己的 */
|
||||
async options(actor: ActorCtx): Promise<LlmApiConfigOptionDto[]> {
|
||||
const where: { enabled: boolean; createdByHqAccountId?: bigint } = { enabled: true };
|
||||
if (!actor.isSuperAdmin) where.createdByHqAccountId = actor.actorId;
|
||||
const rows = await this.prisma.llmApiConfig.findMany({
|
||||
where,
|
||||
orderBy: [{ id: 'desc' }],
|
||||
select: { id: true, name: true, provider: true, modelName: true, enabled: true },
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id.toString(),
|
||||
name: r.name,
|
||||
provider: (isProvider(r.provider) ? r.provider : 'CUSTOM') as LlmProvider,
|
||||
modelName: r.modelName,
|
||||
enabled: r.enabled,
|
||||
}));
|
||||
}
|
||||
|
||||
async detail(actor: ActorCtx, id: bigint) {
|
||||
const row = await this.requireReadable(actor, id);
|
||||
const owner = await this.prisma.hqAccount.findUnique({
|
||||
where: { id: row.createdByHqAccountId },
|
||||
select: { name: true },
|
||||
});
|
||||
return this.toDto(row, actor, owner?.name ?? null);
|
||||
}
|
||||
|
||||
async create(actor: ActorCtx, dto: CreateLlmApiConfigRequest) {
|
||||
const name = dto.name?.trim();
|
||||
if (!name) throw new BadRequestException('请填写名称');
|
||||
if (!isProvider(dto.provider)) throw new BadRequestException('无效提供商');
|
||||
const apiKey = dto.apiKey?.trim();
|
||||
if (!apiKey) throw new BadRequestException('请填写 API Key');
|
||||
|
||||
const preset = LLM_PROVIDER_PRESETS[dto.provider];
|
||||
const baseUrl = normalizeLlmBaseUrl(dto.baseUrl?.trim() || preset.defaultBaseUrl);
|
||||
const modelName = dto.modelName?.trim() || preset.defaultModel;
|
||||
if (!baseUrl) throw new BadRequestException('请填写 Base URL');
|
||||
if (!modelName) throw new BadRequestException('请填写模型名');
|
||||
|
||||
const row = await this.prisma.llmApiConfig.create({
|
||||
data: {
|
||||
name,
|
||||
provider: dto.provider,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
modelName,
|
||||
temperature: dto.temperature ?? null,
|
||||
maxTokens: dto.maxTokens ?? null,
|
||||
systemPrompt: dto.systemPrompt?.trim() || null,
|
||||
enabled: dto.enabled !== false,
|
||||
createdByHqAccountId: actor.actorId,
|
||||
},
|
||||
});
|
||||
return this.toDto(row, actor, null);
|
||||
}
|
||||
|
||||
async update(actor: ActorCtx, id: bigint, dto: UpdateLlmApiConfigRequest) {
|
||||
const row = await this.requireReadable(actor, id);
|
||||
const isOwner = row.createdByHqAccountId === actor.actorId;
|
||||
|
||||
if (!actor.isSuperAdmin) {
|
||||
if (!isOwner) throw new ForbiddenException('只能操作自己创建的配置');
|
||||
// 非超管仅可改 enabled
|
||||
const keys = Object.keys(dto).filter((k) => (dto as Record<string, unknown>)[k] !== undefined);
|
||||
if (keys.some((k) => k !== 'enabled')) {
|
||||
throw new ForbiddenException('非超级管理员只能修改配置是否生效');
|
||||
}
|
||||
if (dto.enabled === undefined) throw new BadRequestException('请指定 enabled');
|
||||
const updated = await this.prisma.llmApiConfig.update({
|
||||
where: { id },
|
||||
data: { enabled: dto.enabled },
|
||||
});
|
||||
return this.toDto(updated, actor, null);
|
||||
}
|
||||
|
||||
// 超管全量
|
||||
let provider = row.provider as LlmProvider;
|
||||
if (dto.provider !== undefined) {
|
||||
if (!isProvider(dto.provider)) throw new BadRequestException('无效提供商');
|
||||
provider = dto.provider;
|
||||
}
|
||||
const preset = LLM_PROVIDER_PRESETS[provider];
|
||||
const data: {
|
||||
name?: string;
|
||||
provider?: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
modelName?: string;
|
||||
temperature?: number | null;
|
||||
maxTokens?: number | null;
|
||||
systemPrompt?: string | null;
|
||||
enabled?: boolean;
|
||||
} = {};
|
||||
if (dto.name !== undefined) {
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('名称不能为空');
|
||||
data.name = name;
|
||||
}
|
||||
if (dto.provider !== undefined) data.provider = provider;
|
||||
if (dto.baseUrl !== undefined) {
|
||||
data.baseUrl = normalizeLlmBaseUrl(dto.baseUrl.trim() || preset.defaultBaseUrl);
|
||||
if (!data.baseUrl) throw new BadRequestException('Base URL 不能为空');
|
||||
}
|
||||
if (dto.apiKey !== undefined && dto.apiKey.trim()) data.apiKey = dto.apiKey.trim();
|
||||
if (dto.modelName !== undefined) {
|
||||
data.modelName = dto.modelName.trim() || preset.defaultModel;
|
||||
if (!data.modelName) throw new BadRequestException('模型名不能为空');
|
||||
}
|
||||
if (dto.temperature !== undefined) data.temperature = dto.temperature;
|
||||
if (dto.maxTokens !== undefined) data.maxTokens = dto.maxTokens;
|
||||
if (dto.systemPrompt !== undefined) data.systemPrompt = dto.systemPrompt?.trim() || null;
|
||||
if (dto.enabled !== undefined) data.enabled = dto.enabled;
|
||||
|
||||
const updated = await this.prisma.llmApiConfig.update({ where: { id }, data });
|
||||
return this.toDto(updated, actor, null);
|
||||
}
|
||||
|
||||
async remove(actor: ActorCtx, id: bigint) {
|
||||
if (!actor.isSuperAdmin) {
|
||||
throw new ForbiddenException('仅超级管理员可删除语言模型配置');
|
||||
}
|
||||
const row = await this.prisma.llmApiConfig.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('配置不存在');
|
||||
await this.prisma.llmApiConfig.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async test(actor: ActorCtx, id: bigint) {
|
||||
const row = await this.requireReadable(actor, id);
|
||||
if (!row.enabled) throw new BadRequestException('配置未启用');
|
||||
const reply = await this.llm.chat({
|
||||
baseUrl: row.baseUrl,
|
||||
apiKey: row.apiKey,
|
||||
model: row.modelName,
|
||||
temperature: row.temperature != null ? Number(row.temperature) : 0.2,
|
||||
maxTokens: row.maxTokens ?? 64,
|
||||
messages: [
|
||||
{ role: 'system', content: '用一句话回复:连接成功。' },
|
||||
{ role: 'user', content: 'ping' },
|
||||
],
|
||||
});
|
||||
return { ok: true, reply };
|
||||
}
|
||||
|
||||
private async requireReadable(actor: ActorCtx, id: bigint) {
|
||||
const row = await this.prisma.llmApiConfig.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('配置不存在');
|
||||
if (!actor.isSuperAdmin && row.createdByHqAccountId !== actor.actorId) {
|
||||
throw new ForbiddenException('无权查看该配置');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private toDto(
|
||||
row: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
provider: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
modelName: string;
|
||||
temperature: { toNumber?: () => number } | number | null;
|
||||
maxTokens: number | null;
|
||||
systemPrompt: string | null;
|
||||
enabled: boolean;
|
||||
createdByHqAccountId: bigint;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
},
|
||||
actor: ActorCtx,
|
||||
createdByName: string | null,
|
||||
): LlmApiConfigDto {
|
||||
const isOwner = row.createdByHqAccountId === actor.actorId;
|
||||
const temp =
|
||||
row.temperature == null
|
||||
? null
|
||||
: typeof row.temperature === 'number'
|
||||
? row.temperature
|
||||
: Number(row.temperature);
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
name: row.name,
|
||||
provider: (isProvider(row.provider) ? row.provider : 'CUSTOM') as LlmProvider,
|
||||
baseUrl: row.baseUrl,
|
||||
modelName: row.modelName,
|
||||
apiKeyConfigured: !!row.apiKey,
|
||||
temperature: temp,
|
||||
maxTokens: row.maxTokens,
|
||||
systemPrompt: row.systemPrompt,
|
||||
enabled: row.enabled,
|
||||
createdByHqAccountId: row.createdByHqAccountId.toString(),
|
||||
createdByName,
|
||||
isOwner,
|
||||
canEditFull: actor.isSuperAdmin,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,21 @@ import {
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { AdminWecomBotsService } from './admin-wecom-bots.service';
|
||||
import { AdminLlmConfigsService } from './admin-llm-configs.service';
|
||||
import { AdminKnowledgeBasesService } from './admin-knowledge-bases.service';
|
||||
|
||||
@Controller('admin/wecom-bots')
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('wecom_bots')
|
||||
export class AdminWecomBotsController {
|
||||
constructor(private readonly service: AdminWecomBotsService) {}
|
||||
constructor(
|
||||
private readonly service: AdminWecomBotsService,
|
||||
private readonly llmConfigs: AdminLlmConfigsService,
|
||||
private readonly knowledgeBases: AdminKnowledgeBasesService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@@ -52,6 +60,16 @@ export class AdminWecomBotsController {
|
||||
return this.service.reloadRuntime();
|
||||
}
|
||||
|
||||
@Get('ai-options')
|
||||
async aiOptions(@CurrentUser() user: AuthUser) {
|
||||
const actor = await this.llmConfigs.resolveActor(user.actorId);
|
||||
const [llmConfigs, knowledgeBases] = await Promise.all([
|
||||
this.llmConfigs.options(actor),
|
||||
this.knowledgeBases.options(actor),
|
||||
]);
|
||||
return { llmConfigs, knowledgeBases };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(BigInt(id));
|
||||
|
||||
@@ -21,6 +21,26 @@ function isWecomRole(v: string): v is WecomBotRole {
|
||||
return (WECOM_BOT_ROLES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
type WecomRow = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
role: string;
|
||||
botId: string;
|
||||
secret: string;
|
||||
avatarUrl: string | null;
|
||||
welcome: string | null;
|
||||
permissions: string;
|
||||
aiEnabled: boolean;
|
||||
llmConfigId: bigint | null;
|
||||
knowledgeBaseId: bigint | null;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
llmConfig?: { id: bigint; name: string } | null;
|
||||
knowledgeBase?: { id: bigint; name: string } | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminWecomBotsService {
|
||||
constructor(
|
||||
@@ -48,6 +68,10 @@ export class AdminWecomBotsService {
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
llmConfig: { select: { id: true, name: true } },
|
||||
knowledgeBase: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.wecomBot.count({ where }),
|
||||
]);
|
||||
@@ -62,7 +86,13 @@ export class AdminWecomBotsService {
|
||||
}
|
||||
|
||||
async detail(id: bigint) {
|
||||
const row = await this.prisma.wecomBot.findUnique({ where: { id } });
|
||||
const row = await this.prisma.wecomBot.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
llmConfig: { select: { id: true, name: true } },
|
||||
knowledgeBase: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('机器人不存在');
|
||||
return this.toDto(row);
|
||||
}
|
||||
@@ -79,6 +109,8 @@ export class AdminWecomBotsService {
|
||||
const exists = await this.prisma.wecomBot.findUnique({ where: { botId } });
|
||||
if (exists) throw new BadRequestException('BotID 已存在');
|
||||
|
||||
const llmConfigId = await this.resolveLlmId(dto.llmConfigId);
|
||||
const knowledgeBaseId = await this.resolveKbId(dto.knowledgeBaseId);
|
||||
const permissions = resolveWecomBotPermissions(dto.role, dto.permissions);
|
||||
const row = await this.prisma.wecomBot.create({
|
||||
data: {
|
||||
@@ -89,9 +121,16 @@ export class AdminWecomBotsService {
|
||||
avatarUrl: dto.avatarUrl?.trim() || null,
|
||||
welcome: dto.welcome?.trim() || null,
|
||||
permissions: JSON.stringify(permissions),
|
||||
aiEnabled: dto.aiEnabled === true,
|
||||
llmConfigId,
|
||||
knowledgeBaseId,
|
||||
enabled: dto.enabled !== false,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
include: {
|
||||
llmConfig: { select: { id: true, name: true } },
|
||||
knowledgeBase: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
await this.wecomAibot.reload('bot-create');
|
||||
return this.toDto(row);
|
||||
@@ -132,6 +171,11 @@ export class AdminWecomBotsService {
|
||||
const secret =
|
||||
dto.secret !== undefined && dto.secret.trim() ? dto.secret.trim() : existing.secret;
|
||||
|
||||
const llmConfigId =
|
||||
dto.llmConfigId !== undefined ? await this.resolveLlmId(dto.llmConfigId) : undefined;
|
||||
const knowledgeBaseId =
|
||||
dto.knowledgeBaseId !== undefined ? await this.resolveKbId(dto.knowledgeBaseId) : undefined;
|
||||
|
||||
const row = await this.prisma.wecomBot.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -143,9 +187,16 @@ export class AdminWecomBotsService {
|
||||
dto.avatarUrl === undefined ? undefined : dto.avatarUrl?.trim() || null,
|
||||
welcome: dto.welcome === undefined ? undefined : dto.welcome?.trim() || null,
|
||||
permissions: permissionsJson,
|
||||
aiEnabled: dto.aiEnabled,
|
||||
llmConfigId,
|
||||
knowledgeBaseId,
|
||||
enabled: dto.enabled,
|
||||
sortOrder: dto.sortOrder,
|
||||
},
|
||||
include: {
|
||||
llmConfig: { select: { id: true, name: true } },
|
||||
knowledgeBase: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
await this.wecomAibot.reload('bot-update');
|
||||
return this.toDto(row);
|
||||
@@ -163,20 +214,25 @@ export class AdminWecomBotsService {
|
||||
return this.wecomAibot.reload('manual');
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
role: string;
|
||||
botId: string;
|
||||
secret: string;
|
||||
avatarUrl: string | null;
|
||||
welcome: string | null;
|
||||
permissions: string;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): WecomBotDto {
|
||||
private async resolveLlmId(raw?: string | null): Promise<bigint | null> {
|
||||
if (raw === undefined) return null;
|
||||
if (raw === null || raw === '') return null;
|
||||
const id = BigInt(raw);
|
||||
const row = await this.prisma.llmApiConfig.findUnique({ where: { id } });
|
||||
if (!row) throw new BadRequestException('语言模型配置不存在');
|
||||
return id;
|
||||
}
|
||||
|
||||
private async resolveKbId(raw?: string | null): Promise<bigint | null> {
|
||||
if (raw === undefined) return null;
|
||||
if (raw === null || raw === '') return null;
|
||||
const id = BigInt(raw);
|
||||
const row = await this.prisma.knowledgeBase.findUnique({ where: { id } });
|
||||
if (!row) throw new BadRequestException('知识库不存在');
|
||||
return id;
|
||||
}
|
||||
|
||||
private toDto(row: WecomRow): WecomBotDto {
|
||||
const role = (isWecomRole(row.role) ? row.role : 'CUSTOM') as WecomBotRole;
|
||||
const permissions = resolveWecomBotPermissions(role, row.permissions) as WecomBotPermission[];
|
||||
return {
|
||||
@@ -188,6 +244,11 @@ export class AdminWecomBotsService {
|
||||
avatarUrl: row.avatarUrl,
|
||||
welcome: row.welcome,
|
||||
permissions,
|
||||
aiEnabled: row.aiEnabled,
|
||||
llmConfigId: row.llmConfigId?.toString() ?? null,
|
||||
llmConfigName: row.llmConfig?.name ?? null,
|
||||
knowledgeBaseId: row.knowledgeBaseId?.toString() ?? null,
|
||||
knowledgeBaseName: row.knowledgeBase?.name ?? null,
|
||||
enabled: row.enabled,
|
||||
sortOrder: row.sortOrder,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
|
||||
@@ -45,6 +45,7 @@ import { BenefitModule } from '../benefit/benefit.module';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { WecomModule } from '../../integrations/wecom/wecom.module';
|
||||
import { LlmModule } from '../../integrations/llm/llm.module';
|
||||
import { AdminXiaofeixiaController } from './admin-xiaofeixia.controller';
|
||||
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
||||
import { AdminProductDetailTemplatesController } from './admin-product-detail-templates.controller';
|
||||
@@ -62,10 +63,14 @@ import { AdminDeployService } from './admin-deploy.service';
|
||||
import { AdminSystemConfigController } from './admin-system-config.controller';
|
||||
import { AdminWecomBotsController } from './admin-wecom-bots.controller';
|
||||
import { AdminWecomBotsService } from './admin-wecom-bots.service';
|
||||
import { AdminLlmConfigsController } from './admin-llm-configs.controller';
|
||||
import { AdminLlmConfigsService } from './admin-llm-configs.service';
|
||||
import { AdminKnowledgeBasesController } from './admin-knowledge-bases.controller';
|
||||
import { AdminKnowledgeBasesService } from './admin-knowledge-bases.service';
|
||||
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
|
||||
|
||||
@Module({
|
||||
imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, RedeemModule, StoreModule],
|
||||
imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, LlmModule, RedeemModule, StoreModule],
|
||||
controllers: [
|
||||
AdminDashboardController,
|
||||
AdminDeployController,
|
||||
@@ -102,6 +107,8 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
|
||||
AdminHqPermissionsController,
|
||||
AdminSystemConfigController,
|
||||
AdminWecomBotsController,
|
||||
AdminLlmConfigsController,
|
||||
AdminKnowledgeBasesController,
|
||||
AdminFulfillmentProvidersController,
|
||||
],
|
||||
providers: [
|
||||
@@ -129,6 +136,8 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
|
||||
AdminHqPermissionsService,
|
||||
AdminDeployService,
|
||||
AdminWecomBotsService,
|
||||
AdminLlmConfigsService,
|
||||
AdminKnowledgeBasesService,
|
||||
SuperAdminGuard,
|
||||
],
|
||||
exports: [CityScopeModule],
|
||||
|
||||
+19
-3
@@ -322,7 +322,9 @@
|
||||
| 工单 | 工单中心;技术支持 |
|
||||
| 发票 | 发票管理 |
|
||||
| 资源 / 日志 | OSS;用户/商户/合伙人/HQ/第三方日志 |
|
||||
| 企微机器人 | 多实例 Bot 配置(长连接指令助手,见 §17) |
|
||||
| 企微机器人 | 多实例 Bot 配置(长连接指令助手,可绑语言模型与知识库,见 §17) |
|
||||
| 语言模型 | DeepSeek / OpenAI / 通义 / 自定义 API;非超管仅见自己的配置且只能改是否生效 |
|
||||
| 知识库 | 上传/粘贴文档,供企微机器人 AI 检索 |
|
||||
| 管理 | 权限分配;系统设置;HQ 账户 |
|
||||
|
||||
---
|
||||
@@ -788,10 +790,22 @@ C 端「联系客服 → 在线客服」跳转企业微信 **微信客服** 链
|
||||
| 头像 | OSS 上传(可选) |
|
||||
| 欢迎语 | 进入会话时推送(可选) |
|
||||
| 权限 | 可勾选能力;创建时按角色带出默认值,可改 |
|
||||
| 启用 AI 问答 | 未匹配指令时调用绑定的语言模型 |
|
||||
| 语言模型 | 选自 HQ「语言模型」中已生效配置(非超管仅能选自己创建的) |
|
||||
| 知识库 | 选自 HQ「知识库」;检索片段注入模型上下文 |
|
||||
| 启用 | 关闭则不建立长连接 |
|
||||
|
||||
保存后可用页内 **重载连接**,或依赖总开关变更后的自动重载。每个已启用 Bot 同时仅保持 **1 条** 长连接。
|
||||
|
||||
#### 语言模型与知识库(与企微同级菜单)
|
||||
|
||||
| 模块 | 权限键 | 规则摘要 |
|
||||
|------|--------|----------|
|
||||
| 语言模型 | `llm_configs` | 可选 DeepSeek / OpenAI / 通义千问 / 自定义 OpenAI 兼容 API。**超级管理员**可看改删全部;**其他 HQ** 只能看到自己创建的配置,创建后仅能改「是否生效」,不能改 Key/模型等,也不能删除 |
|
||||
| 知识库 | `knowledge_bases` | 粘贴正文或上传 `.txt/.md` 等文本;非超管仅管理自己创建的库 |
|
||||
|
||||
企微侧流程:先建语言模型(并测试)→ 建知识库并上传文档 → 机器人表单开启 AI 并绑定。指令仍优先;仅未匹配时走模型。
|
||||
|
||||
#### 总开关
|
||||
|
||||
**系统设置 → 功能开关 → 启用企微机器人长连接**(`WECOM_AIBOT_ENABLED`)。
|
||||
@@ -821,7 +835,9 @@ C 端「联系客服 → 在线客服」跳转企业微信 **微信客服** 链
|
||||
| 开发进度 | `进度`(最近)· `进度 <工单号>` |
|
||||
| 使用手册 | `手册`(目录)· `手册 <关键词>`(如:开城、核销、订单) |
|
||||
|
||||
未识别指令时回复「帮助」文案。暂仅支持 **文本**;图片/语音等会提示改发「帮助」。
|
||||
未匹配指令时:若已启用 AI 并绑定模型,则走语言模型(可带知识库);否则回复「帮助」文案。暂仅支持 **文本**。
|
||||
|
||||
> 接语言模型是**可选增强**,不是长连接生效的前提;纯指令机器人仍可独立使用。
|
||||
|
||||
#### 启用步骤(运营/研发)
|
||||
|
||||
@@ -831,7 +847,7 @@ C 端「联系客服 → 在线客服」跳转企业微信 **微信客服** 链
|
||||
4. 在企微中把机器人加到会话,发送 `帮助` 验证
|
||||
5. 改配置或凭证后点 **重载连接**(或重启 API)
|
||||
|
||||
> **说明**:不接大模型也能完成上表能力。若日后要「口语自动抽订单号建单」,才需在现有指令路由之上叠加 LLM,属体验增强而非上线前提。
|
||||
> **说明**:不接大模型也能完成指令表能力。开启 AI + 知识库后,可用自然语言问答;写操作(建单等)仍建议走固定指令。
|
||||
|
||||
### 17.3 与总部客服 / 技术支持的协作
|
||||
|
||||
|
||||
Reference in New Issue
Block a user