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
@@ -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 {}