2b7ef65cce
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>
291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
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<ActorCtx> {
|
|
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<LlmApiConfigOptionDto[]> {
|
|
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<string, unknown>)[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(),
|
|
};
|
|
}
|
|
}
|