diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx
index 5924f37..fc7b7cd 100644
--- a/apps/admin-web/src/App.tsx
+++ b/apps/admin-web/src/App.tsx
@@ -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 ;
@@ -70,6 +72,8 @@ export default function App() {
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx
index ae4df01..74efdd4 100644
--- a/apps/admin-web/src/layouts/AdminLayout.tsx
+++ b/apps/admin-web/src/layouts/AdminLayout.tsx
@@ -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: , label: '订单' },
{ key: '/promo-codes', icon: , label: '推广码' },
{ key: '/wecom-bots', icon: , label: '企微机器人' },
+ { key: '/llm-configs', icon: , label: '语言模型' },
+ { key: '/knowledge-bases', icon: , label: '知识库' },
{
key: 'stores-group',
icon: ,
@@ -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',
diff --git a/apps/admin-web/src/pages/KnowledgeBasesPage.tsx b/apps/admin-web/src/pages/KnowledgeBasesPage.tsx
new file mode 100644
index 0000000..b79de38
--- /dev/null
+++ b/apps/admin-web/src/pages/KnowledgeBasesPage.tsx
@@ -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();
+ const [docForm] = Form.useForm();
+ const [filters, setFilters] = useState>({});
+ const [modalOpen, setModalOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [drawerKb, setDrawerKb] = useState(null);
+ const [docs, setDocs] = useState([]);
+ const [docsLoading, setDocsLoading] = useState(false);
+ const [docModalOpen, setDocModalOpen] = useState(false);
+ const [docSaving, setDocSaving] = useState(false);
+
+ const { data, loading, page, pageSize, setPage, setPageSize, reload } =
+ useAdminList(
+ '/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 = [
+ { title: '名称', dataIndex: 'name' },
+ { title: '说明', dataIndex: 'description', ellipsis: true },
+ { title: '文档数', dataIndex: 'documentCount', width: 90 },
+ {
+ title: '启用',
+ dataIndex: 'enabled',
+ width: 90,
+ render: (v: boolean) => (v ? 开 : 关),
+ },
+ {
+ 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) => (
+
+
+ {row.canEditFull ? (
+
+ ) : null}
+ {row.canEditFull ? (
+ {
+ try {
+ await request(`/admin/knowledge-bases/${row.id}`, { method: 'DELETE' });
+ message.success('已删除');
+ reload();
+ } catch (e) {
+ message.error(e instanceof Error ? e.message : String(e));
+ }
+ }}
+ >
+
+
+ ) : null}
+
+ ),
+ },
+ ];
+
+ const docColumns: ColumnsType = [
+ { title: '标题', dataIndex: 'title' },
+ { title: '文件', dataIndex: 'fileName', ellipsis: true },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 90,
+ render: (s: string) =>
+ s === 'READY' ? 可检索 : s === 'FAILED' ? 失败 : 无正文,
+ },
+ {
+ title: '时间',
+ dataIndex: 'createdAt',
+ width: 170,
+ render: (t: string) => fmtTime(t),
+ },
+ {
+ title: '操作',
+ width: 80,
+ render: (_, row) =>
+ drawerKb?.canEditFull ? (
+ {
+ 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));
+ }
+ }}
+ >
+
+
+ ) : null,
+ },
+ ];
+
+ return (
+
+
+
+ 知识库
+
+
+ 支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ setPage(p);
+ setPageSize(ps);
+ },
+ }}
+ />
+
+ setModalOpen(false)}
+ onOk={() => void onSave()}
+ confirmLoading={saving}
+ destroyOnClose
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ setDrawerKb(null)}
+ extra={
+ drawerKb?.canEditFull ? (
+
+ ) : null
+ }
+ >
+
+
+
+ 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}
+ >
+
+
+
+
+
+
+
+ {
+ 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}
+ >
+ }>上传到 OSS 并入库
+
+
+
+
+
+ );
+}
diff --git a/apps/admin-web/src/pages/LlmConfigsPage.tsx b/apps/admin-web/src/pages/LlmConfigsPage.tsx
new file mode 100644
index 0000000..071fd68
--- /dev/null
+++ b/apps/admin-web/src/pages/LlmConfigsPage.tsx
@@ -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();
+ const [filters, setFilters] = useState>({});
+ const [modalOpen, setModalOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const providerWatch = Form.useWatch('provider', form);
+
+ const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList(
+ '/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 = [
+ { 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 ? 已配 : 无),
+ },
+ {
+ title: '生效',
+ dataIndex: 'enabled',
+ width: 90,
+ render: (v: boolean, row) => (
+ {
+ 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) => (
+
+
+
+ {row.canEditFull ? (
+ {
+ try {
+ await request(`/admin/llm-configs/${row.id}`, { method: 'DELETE' });
+ message.success('已删除');
+ reload();
+ } catch (e) {
+ message.error(e instanceof Error ? e.message : String(e));
+ }
+ }}
+ >
+
+
+ ) : null}
+
+ ),
+ },
+ ];
+
+ const fullEdit = !editing || editing.canEditFull;
+
+ return (
+
+
+
+ 语言模型配置
+
+
+ 非超管仅可见自己创建的配置,且创建后只能改是否生效
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ setPage(p);
+ setPageSize(ps);
+ },
+ }}
+ />
+
+ setModalOpen(false)}
+ onOk={() => void onSave()}
+ confirmLoading={saving}
+ destroyOnClose
+ width={640}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin-web/src/pages/WecomBotsPage.tsx b/apps/admin-web/src/pages/WecomBotsPage.tsx
index 3c808ac..da5e000 100644
--- a/apps/admin-web/src/pages/WecomBotsPage.tsx
+++ b/apps/admin-web/src/pages/WecomBotsPage.tsx
@@ -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(null);
const [runtime, setRuntime] = useState();
+ const [llmOptions, setLlmOptions] = useState([]);
+ const [kbOptions, setKbOptions] = useState([]);
const roleWatch = Form.useWatch('role', form);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList(
@@ -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() {
)),
},
+ {
+ title: 'AI',
+ width: 100,
+ render: (_, row) =>
+ row.aiEnabled ? (
+ {row.llmConfigName || '已开'}
+ ) : (
+ 关
+ ),
+ },
{
title: '启用',
dataIndex: 'enabled',
@@ -401,6 +438,34 @@ export default function WecomBotsPage() {
+
+
+
+
+
+
+
@@ -432,6 +497,9 @@ export default function WecomBotsPage() {
{detail.permissions.map((p) => WECOM_BOT_PERMISSION_LABELS[p] || p).join('、')}
{detail.welcome || '—'}
+ {detail.aiEnabled ? '开' : '关'}
+ {detail.llmConfigName || '—'}
+ {detail.knowledgeBaseName || '—'}
{detail.enabled ? '是' : '否'}
{detail.sortOrder}
{fmtTime(detail.createdAt)}
diff --git a/packages/shared-types/src/hq-permissions.ts b/packages/shared-types/src/hq-permissions.ts
index ce62312..c8fe87c 100644
--- a/packages/shared-types/src/hq-permissions.ts
+++ b/packages/shared-types/src/hq-permissions.ts
@@ -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 = {
'tech_support',
'invoices',
'wecom_bots',
+ 'llm_configs',
+ 'knowledge_bases',
'resources',
'logs',
'system_settings_wechat_mini',
diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts
index 674cdea..750533a 100644
--- a/packages/shared-types/src/index.ts
+++ b/packages/shared-types/src/index.ts
@@ -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';
diff --git a/packages/shared-types/src/knowledge-base.ts b/packages/shared-types/src/knowledge-base.ts
new file mode 100644
index 0000000..fadd60d
--- /dev/null
+++ b/packages/shared-types/src/knowledge-base.ts
@@ -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;
+};
diff --git a/packages/shared-types/src/llm-config.ts b/packages/shared-types/src/llm-config.ts
new file mode 100644
index 0000000..36297e7
--- /dev/null
+++ b/packages/shared-types/src/llm-config.ts
@@ -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 = {
+ 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;
+};
diff --git a/packages/shared-types/src/wecom-bot.ts b/packages/shared-types/src/wecom-bot.ts
index ef9e96e..bf97f78 100644
--- a/packages/shared-types/src/wecom-bot.ts
+++ b/packages/shared-types/src/wecom-bot.ts
@@ -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;
};
diff --git a/server/dukang-api/prisma/ensure-wecom-ai-cols.js b/server/dukang-api/prisma/ensure-wecom-ai-cols.js
new file mode 100644
index 0000000..9aa7304
--- /dev/null
+++ b/server/dukang-api/prisma/ensure-wecom-ai-cols.js
@@ -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);
+});
diff --git a/server/dukang-api/prisma/llm_kb_wecom_ai.sql b/server/dukang-api/prisma/llm_kb_wecom_ai.sql
new file mode 100644
index 0000000..e9f832b
--- /dev/null
+++ b/server/dukang-api/prisma/llm_kb_wecom_ai.sql
@@ -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;
diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma
index 8ecfba5..68e117c 100644
--- a/server/dukang-api/prisma/schema.prisma
+++ b/server/dukang-api/prisma/schema.prisma
@@ -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)
diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
index a8f6140..927d03a 100644
--- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
+++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
@@ -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 = {
[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]: '触发系统发布',
diff --git a/server/dukang-api/src/integrations/llm/knowledge-retrieval.service.ts b/server/dukang-api/src/integrations/llm/knowledge-retrieval.service.ts
new file mode 100644
index 0000000..6548bd8
--- /dev/null
+++ b/server/dukang-api/src/integrations/llm/knowledge-retrieval.service.ts
@@ -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 {
+ 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);
+}
diff --git a/server/dukang-api/src/integrations/llm/llm-chat.client.ts b/server/dukang-api/src/integrations/llm/llm-chat.client.ts
new file mode 100644
index 0000000..5cb6824
--- /dev/null
+++ b/server/dukang-api/src/integrations/llm/llm-chat.client.ts
@@ -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 {
+ const url = buildLlmChatCompletionsUrl(params.baseUrl);
+ const body: Record = {
+ 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;
+ }
+}
diff --git a/server/dukang-api/src/integrations/llm/llm.module.ts b/server/dukang-api/src/integrations/llm/llm.module.ts
new file mode 100644
index 0000000..291ee13
--- /dev/null
+++ b/server/dukang-api/src/integrations/llm/llm.module.ts
@@ -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 {}
diff --git a/server/dukang-api/src/integrations/wecom/wecom-aibot.service.ts b/server/dukang-api/src/integrations/wecom/wecom-aibot.service.ts
index 339ebf4..0c8cafb 100644
--- a/server/dukang-api/src/integrations/wecom/wecom-aibot.service.ts
+++ b/server/dukang-api/src/integrations/wecom/wecom-aibot.service.ts
@@ -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 = {
@@ -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}`);
diff --git a/server/dukang-api/src/integrations/wecom/wecom-bot-actions.service.ts b/server/dukang-api/src/integrations/wecom/wecom-bot-actions.service.ts
index ce8b319..c65f166 100644
--- a/server/dukang-api/src/integrations/wecom/wecom-bot-actions.service.ts
+++ b/server/dukang-api/src/integrations/wecom/wecom-bot-actions.service.ts
@@ -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 {
+ opts?: { skipNaturalFallback?: boolean },
+ ): Promise {
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)}`;
}
diff --git a/server/dukang-api/src/integrations/wecom/wecom-bot-ai.service.ts b/server/dukang-api/src/integrations/wecom/wecom-bot-ai.service.ts
new file mode 100644
index 0000000..bff0731
--- /dev/null
+++ b/server/dukang-api/src/integrations/wecom/wecom-bot-ai.service.ts
@@ -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 {
+ 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}`;
+ }
+ }
+}
diff --git a/server/dukang-api/src/integrations/wecom/wecom-bot.types.ts b/server/dukang-api/src/integrations/wecom/wecom-bot.types.ts
index 8910d48..fe3b95b 100644
--- a/server/dukang-api/src/integrations/wecom/wecom-bot.types.ts
+++ b/server/dukang-api/src/integrations/wecom/wecom-bot.types.ts
@@ -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(
diff --git a/server/dukang-api/src/integrations/wecom/wecom.module.ts b/server/dukang-api/src/integrations/wecom/wecom.module.ts
index bac95e4..b3558f7 100644
--- a/server/dukang-api/src/integrations/wecom/wecom.module.ts
+++ b/server/dukang-api/src/integrations/wecom/wecom.module.ts
@@ -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 {}
diff --git a/server/dukang-api/src/modules/ops/admin-knowledge-bases.controller.ts b/server/dukang-api/src/modules/ops/admin-knowledge-bases.controller.ts
new file mode 100644
index 0000000..3919bb6
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-knowledge-bases.controller.ts
@@ -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));
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-knowledge-bases.service.ts b/server/dukang-api/src/modules/ops/admin-knowledge-bases.service.ts
new file mode 100644
index 0000000..d340885
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-knowledge-bases.service.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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);
+}
diff --git a/server/dukang-api/src/modules/ops/admin-llm-configs.controller.ts b/server/dukang-api/src/modules/ops/admin-llm-configs.controller.ts
new file mode 100644
index 0000000..e55a0f3
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-llm-configs.controller.ts
@@ -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));
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-llm-configs.service.ts b/server/dukang-api/src/modules/ops/admin-llm-configs.service.ts
new file mode 100644
index 0000000..6efb06b
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-llm-configs.service.ts
@@ -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 {
+ 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 {
+ 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)[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(),
+ };
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-wecom-bots.controller.ts b/server/dukang-api/src/modules/ops/admin-wecom-bots.controller.ts
index e503a1a..a8ca775 100644
--- a/server/dukang-api/src/modules/ops/admin-wecom-bots.controller.ts
+++ b/server/dukang-api/src/modules/ops/admin-wecom-bots.controller.ts
@@ -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));
diff --git a/server/dukang-api/src/modules/ops/admin-wecom-bots.service.ts b/server/dukang-api/src/modules/ops/admin-wecom-bots.service.ts
index 7ee3352..19f0d26 100644
--- a/server/dukang-api/src/modules/ops/admin-wecom-bots.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-wecom-bots.service.ts
@@ -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 {
+ 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 {
+ 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(),
diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts
index 4d80748..a5a7ee5 100644
--- a/server/dukang-api/src/modules/ops/ops.module.ts
+++ b/server/dukang-api/src/modules/ops/ops.module.ts
@@ -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],
diff --git a/杜康好客-知识库.md b/杜康好客-知识库.md
index 81f69c4..3a90b21 100644
--- a/杜康好客-知识库.md
+++ b/杜康好客-知识库.md
@@ -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 与总部客服 / 技术支持的协作