feat(admin): LLM config, knowledge base, and WeCom AI binding
CI / verify (pull_request) Has been cancelled
CI / verify (pull_request) Has been cancelled
Add owner-scoped model API settings, uploadable knowledge bases, and WeCom bot AI/KB wiring with OpenAI-compatible URL normalization. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user