From 2b7ef65cce028c19052eada0561b4933d7ba2524 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Sun, 26 Jul 2026 09:31:22 +0800 Subject: [PATCH] feat(admin): LLM config, knowledge base, and WeCom AI binding Add owner-scoped model API settings, uploadable knowledge bases, and WeCom bot AI/KB wiring with OpenAI-compatible URL normalization. Co-authored-by: Cursor --- apps/admin-web/src/App.tsx | 4 + apps/admin-web/src/layouts/AdminLayout.tsx | 5 + .../src/pages/KnowledgeBasesPage.tsx | 403 ++++++++++++++++++ apps/admin-web/src/pages/LlmConfigsPage.tsx | 380 +++++++++++++++++ apps/admin-web/src/pages/WecomBotsPage.tsx | 100 ++++- packages/shared-types/src/hq-permissions.ts | 4 + packages/shared-types/src/index.ts | 2 + packages/shared-types/src/knowledge-base.ts | 58 +++ packages/shared-types/src/llm-config.ts | 93 ++++ packages/shared-types/src/wecom-bot.ts | 12 + .../dukang-api/prisma/ensure-wecom-ai-cols.js | 36 ++ server/dukang-api/prisma/llm_kb_wecom_ai.sql | 50 +++ server/dukang-api/prisma/schema.prisma | 92 +++- .../hq-operation/hq-operation.constants.ts | 18 + .../llm/knowledge-retrieval.service.ts | 68 +++ .../src/integrations/llm/llm-chat.client.ts | 75 ++++ .../src/integrations/llm/llm.module.ts | 9 + .../integrations/wecom/wecom-aibot.service.ts | 28 +- .../wecom/wecom-bot-actions.service.ts | 19 +- .../wecom/wecom-bot-ai.service.ts | 66 +++ .../src/integrations/wecom/wecom-bot.types.ts | 3 + .../src/integrations/wecom/wecom.module.ts | 17 +- .../ops/admin-knowledge-bases.controller.ts | 136 ++++++ .../ops/admin-knowledge-bases.service.ts | 341 +++++++++++++++ .../ops/admin-llm-configs.controller.ts | 110 +++++ .../modules/ops/admin-llm-configs.service.ts | 290 +++++++++++++ .../ops/admin-wecom-bots.controller.ts | 20 +- .../modules/ops/admin-wecom-bots.service.ts | 91 +++- .../dukang-api/src/modules/ops/ops.module.ts | 11 +- 杜康好客-知识库.md | 22 +- 30 files changed, 2507 insertions(+), 56 deletions(-) create mode 100644 apps/admin-web/src/pages/KnowledgeBasesPage.tsx create mode 100644 apps/admin-web/src/pages/LlmConfigsPage.tsx create mode 100644 packages/shared-types/src/knowledge-base.ts create mode 100644 packages/shared-types/src/llm-config.ts create mode 100644 server/dukang-api/prisma/ensure-wecom-ai-cols.js create mode 100644 server/dukang-api/prisma/llm_kb_wecom_ai.sql create mode 100644 server/dukang-api/src/integrations/llm/knowledge-retrieval.service.ts create mode 100644 server/dukang-api/src/integrations/llm/llm-chat.client.ts create mode 100644 server/dukang-api/src/integrations/llm/llm.module.ts create mode 100644 server/dukang-api/src/integrations/wecom/wecom-bot-ai.service.ts create mode 100644 server/dukang-api/src/modules/ops/admin-knowledge-bases.controller.ts create mode 100644 server/dukang-api/src/modules/ops/admin-knowledge-bases.service.ts create mode 100644 server/dukang-api/src/modules/ops/admin-llm-configs.controller.ts create mode 100644 server/dukang-api/src/modules/ops/admin-llm-configs.service.ts 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 检索 + + + +
+ setFilters({ + name: v.name || '', + enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled), + }) + } + > + + + + + + + + + +
+ + { + 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} + > + + + + +
+ + ); +} 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 ( +
+ + + 语言模型配置 + + + 非超管仅可见自己创建的配置,且创建后只能改是否生效 + + + +
+ setFilters({ + name: v.name || '', + enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled), + }) + } + > + + + + +
{ + 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() { + + + + + ({ + value: o.id, + label: `${o.name}(${o.documentCount} 篇)`, + }))} + /> + @@ -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 与总部客服 / 技术支持的协作