feat(admin): LLM config, knowledge base, and WeCom AI binding
CI / verify (pull_request) Has been cancelled

Add owner-scoped model API settings, uploadable knowledge bases, and WeCom bot AI/KB wiring with OpenAI-compatible URL normalization.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-26 09:31:22 +08:00
parent 745c192693
commit 2b7ef65cce
30 changed files with 2507 additions and 56 deletions
@@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
const MAX_CONTEXT_CHARS = 6000;
@Injectable()
export class KnowledgeRetrievalService {
constructor(private readonly prisma: PrismaService) {}
/** 简易关键词命中:按文档正文包含查询词打分,拼进上下文 */
async buildContext(knowledgeBaseId: bigint, query: string): Promise<string> {
const kb = await this.prisma.knowledgeBase.findUnique({
where: { id: knowledgeBaseId },
select: { id: true, enabled: true, name: true },
});
if (!kb?.enabled) return '';
const docs = await this.prisma.knowledgeDocument.findMany({
where: {
knowledgeBaseId,
status: 'READY',
contentText: { not: null },
},
select: { title: true, contentText: true },
take: 50,
});
if (!docs.length) return '';
const tokens = tokenize(query);
const scored = docs
.map((d) => {
const body = d.contentText || '';
let score = 0;
for (const t of tokens) {
if (body.includes(t) || d.title.includes(t)) score += 1;
}
if (!tokens.length) score = 1;
return { title: d.title, body, score };
})
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score);
const picked = scored.length ? scored.slice(0, 5) : docs.slice(0, 3).map((d) => ({
title: d.title,
body: d.contentText || '',
score: 0,
}));
let out = `【知识库:${kb.name}\n`;
for (const p of picked) {
const chunk = `### ${p.title}\n${p.body}\n\n`;
if (out.length + chunk.length > MAX_CONTEXT_CHARS) {
out += chunk.slice(0, Math.max(0, MAX_CONTEXT_CHARS - out.length));
break;
}
out += chunk;
}
return out.trim();
}
}
function tokenize(q: string): string[] {
return q
.split(/[\s,,。;;、!?!?\-_/\\]+/)
.map((s) => s.trim().toLowerCase())
.filter((s) => s.length >= 2)
.slice(0, 12);
}
@@ -0,0 +1,75 @@
import { Injectable, Logger } from '@nestjs/common';
export type LlmChatMessage = { role: 'system' | 'user' | 'assistant'; content: string };
export type LlmChatParams = {
baseUrl: string;
apiKey: string;
model: string;
messages: LlmChatMessage[];
temperature?: number | null;
maxTokens?: number | null;
};
/** 规范化 OpenAI 兼容根地址:去掉末尾 / 与重复的 /v1 */
export function normalizeLlmBaseUrl(raw: string): string {
let base = String(raw || '').trim().replace(/\/+$/, '');
// 用户常填 https://api.deepseek.com/v1 ,避免拼成 /v1/v1/chat/completions
if (/\/v1$/i.test(base)) {
base = base.replace(/\/v1$/i, '');
}
return base;
}
export function buildLlmChatCompletionsUrl(baseUrl: string): string {
return `${normalizeLlmBaseUrl(baseUrl)}/v1/chat/completions`;
}
@Injectable()
export class LlmChatClient {
private readonly logger = new Logger(LlmChatClient.name);
async chat(params: LlmChatParams): Promise<string> {
const url = buildLlmChatCompletionsUrl(params.baseUrl);
const body: Record<string, unknown> = {
model: params.model,
messages: params.messages,
stream: false,
};
if (params.temperature != null && !Number.isNaN(params.temperature)) {
body.temperature = params.temperature;
}
if (params.maxTokens != null && params.maxTokens > 0) {
body.max_tokens = params.maxTokens;
}
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${params.apiKey}`,
},
body: JSON.stringify(body),
});
const text = await res.text();
if (!res.ok) {
this.logger.warn(`llm chat failed ${res.status} ${url}: ${text.slice(0, 400)}`);
throw new Error(`语言模型调用失败(HTTP ${res.status}`);
}
let json: {
choices?: Array<{ message?: { content?: string } }>;
error?: { message?: string };
};
try {
json = JSON.parse(text) as typeof json;
} catch {
throw new Error('语言模型返回非 JSON');
}
if (json.error?.message) throw new Error(json.error.message);
const content = json.choices?.[0]?.message?.content?.trim();
if (!content) throw new Error('语言模型未返回内容');
return content;
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { LlmChatClient } from './llm-chat.client';
import { KnowledgeRetrievalService } from './knowledge-retrieval.service';
@Module({
providers: [LlmChatClient, KnowledgeRetrievalService],
exports: [LlmChatClient, KnowledgeRetrievalService],
})
export class LlmModule {}
@@ -12,6 +12,7 @@ import {
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { WecomBotActionsService } from './wecom-bot-actions.service';
import { WecomBotAiService } from './wecom-bot-ai.service';
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
const DEFAULT_WELCOMES: Record<WecomBotRole, string> = {
@@ -62,6 +63,7 @@ export class WecomAibotService implements OnModuleInit, OnModuleDestroy {
constructor(
private readonly prisma: PrismaService,
private readonly actions: WecomBotActionsService,
private readonly ai: WecomBotAiService,
) {}
async onModuleInit() {
@@ -150,6 +152,9 @@ export class WecomAibotService implements OnModuleInit, OnModuleDestroy {
avatarUrl: string | null;
welcome: string | null;
permissions: string;
aiEnabled: boolean;
llmConfigId: bigint | null;
knowledgeBaseId: bigint | null;
enabled: boolean;
}): WecomBotRuntimeConfig {
const role = (isWecomRole(row.role) ? row.role : 'CUSTOM') as WecomBotRole;
@@ -164,6 +169,9 @@ export class WecomAibotService implements OnModuleInit, OnModuleDestroy {
avatarUrl: row.avatarUrl,
welcome: row.welcome?.trim() || DEFAULT_WELCOMES[role],
permissions: resolveWecomBotPermissions(role, row.permissions),
aiEnabled: row.aiEnabled,
llmConfigId: row.llmConfigId?.toString() ?? null,
knowledgeBaseId: row.knowledgeBaseId?.toString() ?? null,
};
}
@@ -258,8 +266,24 @@ export class WecomAibotService implements OnModuleInit, OnModuleDestroy {
await this.replyText(client, frame, this.formatStatusMarkdown());
return;
}
const reply = await this.actions.handleCommand(cfg, wecomUserId, content);
await this.replyText(client, frame, reply);
const useAi = cfg.aiEnabled && !!cfg.llmConfigId;
const reply = await this.actions.handleCommand(cfg, wecomUserId, content, {
skipNaturalFallback: useAi,
});
if (reply != null) {
await this.replyText(client, frame, reply);
return;
}
if (useAi) {
const aiReply = await this.ai.replyIfConfigured(cfg, content);
await this.replyText(
client,
frame,
aiReply ?? `未识别指令。\n\n${this.actions.buildHelp(cfg)}`,
);
return;
}
await this.replyText(client, frame, `未识别指令。\n\n${this.actions.buildHelp(cfg)}`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.logger.error(`[${cfg.key}] handle text failed: ${msg}`);
@@ -95,6 +95,13 @@ export class WecomBotActionsService {
'',
);
}
if (bot.aiEnabled && bot.llmConfigId) {
lines.push(
'**智能问答**',
'未匹配指令的自然语言将交由绑定的语言模型回答(可挂知识库)',
'',
);
}
lines.push(`当前权限:${bot.permissions.join(', ') || '无'}`);
return lines.join('\n');
}
@@ -103,7 +110,8 @@ export class WecomBotActionsService {
bot: WecomBotRuntimeConfig,
wecomUserId: string,
text: string,
): Promise<string> {
opts?: { skipNaturalFallback?: boolean },
): Promise<string | null> {
const content = text.trim();
if (!content) return this.buildHelp(bot);
@@ -152,14 +160,19 @@ export class WecomBotActionsService {
return this.queryHandbook(q);
}
// 自然语言手册(仅团队助手有 handbook 权限时)
if (wecomBotHasPermission(bot, 'handbook.query') && content.length >= 2) {
// 自然语言手册(仅团队助手有 handbook 权限时;开启 AI 时改由模型+知识库回答
if (
!opts?.skipNaturalFallback &&
wecomBotHasPermission(bot, 'handbook.query') &&
content.length >= 2
) {
const hit = searchHandbook(content, 1);
if (hit.length) {
return formatHandbook(hit);
}
}
if (opts?.skipNaturalFallback) return null;
return `未识别指令。\n\n${this.buildHelp(bot)}`;
}
@@ -0,0 +1,66 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { LlmChatClient } from '../llm/llm-chat.client';
import { KnowledgeRetrievalService } from '../llm/knowledge-retrieval.service';
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
const DEFAULT_SYSTEM = [
'你是杜康好客企业内部助手。',
'优先依据提供的知识库内容回答;知识库未覆盖时如实说明不确定。',
'不要编造订单号、金额、权限;涉及写操作请引导用户使用指令(如「帮助」)。',
'回答简洁,使用中文。',
].join('');
@Injectable()
export class WecomBotAiService {
private readonly logger = new Logger(WecomBotAiService.name);
constructor(
private readonly prisma: PrismaService,
private readonly llm: LlmChatClient,
private readonly kb: KnowledgeRetrievalService,
) {}
async replyIfConfigured(bot: WecomBotRuntimeConfig, userText: string): Promise<string | null> {
if (!bot.aiEnabled || !bot.llmConfigId) return null;
const cfg = await this.prisma.llmApiConfig.findUnique({
where: { id: BigInt(bot.llmConfigId) },
});
if (!cfg?.enabled) {
return '已开启 AI,但绑定的语言模型未启用或已删除。请在 HQ「企微机器人」检查配置。';
}
let kbBlock = '';
if (bot.knowledgeBaseId) {
try {
kbBlock = await this.kb.buildContext(BigInt(bot.knowledgeBaseId), userText);
} catch (e) {
this.logger.warn(`kb retrieve failed: ${String(e)}`);
}
}
const systemParts = [
cfg.systemPrompt?.trim() || DEFAULT_SYSTEM,
kbBlock ? `\n\n以下为知识库检索片段:\n${kbBlock}` : '',
];
try {
return await this.llm.chat({
baseUrl: cfg.baseUrl,
apiKey: cfg.apiKey,
model: cfg.modelName,
temperature: cfg.temperature != null ? Number(cfg.temperature) : 0.3,
maxTokens: cfg.maxTokens ?? 1024,
messages: [
{ role: 'system', content: systemParts.join('') },
{ role: 'user', content: userText },
],
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.logger.error(`wecom ai reply failed: ${msg}`);
return `AI 回复失败:${msg}`;
}
}
}
@@ -11,6 +11,9 @@ export type WecomBotRuntimeConfig = {
welcome: string;
avatarUrl: string | null;
permissions: WecomBotPermission[];
aiEnabled: boolean;
llmConfigId: string | null;
knowledgeBaseId: string | null;
};
export function wecomBotHasPermission(
@@ -1,14 +1,25 @@
import { Module, forwardRef } from '@nestjs/common';
import { CommonModule } from '../../modules/common/common.module';
import { IntegrationsModule } from '../integrations.module';
import { LlmModule } from '../llm/llm.module';
import { WecomAibotService } from './wecom-aibot.service';
import { WecomBotActionsService } from './wecom-bot-actions.service';
import { WecomBotAiService } from './wecom-bot-ai.service';
import { WecomBotSessionService } from './wecom-bot-session.service';
/** 企微多机器人:依赖 Common(工单)+ Integrations(短信),不反向被 Integrations 引用 */
/** 企微多机器人:依赖 Common(工单)+ Integrations(短信)+ Llm,不反向被 Integrations 引用 */
@Module({
imports: [forwardRef(() => CommonModule), forwardRef(() => IntegrationsModule)],
providers: [WecomBotSessionService, WecomBotActionsService, WecomAibotService],
imports: [
forwardRef(() => CommonModule),
forwardRef(() => IntegrationsModule),
LlmModule,
],
providers: [
WecomBotSessionService,
WecomBotActionsService,
WecomBotAiService,
WecomAibotService,
],
exports: [WecomAibotService],
})
export class WecomModule {}