feat: ai module (#1649)
* fix: pass `truncate` Signed-off-by: Innei <i@innei.in> * feat: add openai summary Signed-off-by: Innei <i@innei.in> * feat: ai list api Signed-off-by: Innei <i@innei.in> --------- Signed-off-by: Innei <i@innei.in>
This commit is contained in:
@@ -113,6 +113,7 @@
|
|||||||
"nestjs-pretty-logger": "0.2.2",
|
"nestjs-pretty-logger": "0.2.2",
|
||||||
"node-machine-id": "1.1.12",
|
"node-machine-id": "1.1.12",
|
||||||
"nodemailer": "6.9.13",
|
"nodemailer": "6.9.13",
|
||||||
|
"openai": "4.38.5",
|
||||||
"pluralize": "^8.0.0",
|
"pluralize": "^8.0.0",
|
||||||
"qs": "6.12.1",
|
"qs": "6.12.1",
|
||||||
"reflect-metadata": "0.2.2",
|
"reflect-metadata": "0.2.2",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { RequestContextMiddleware } from './common/middlewares/request-context.m
|
|||||||
import { AckModule } from './modules/ack/ack.module'
|
import { AckModule } from './modules/ack/ack.module'
|
||||||
import { ActivityModule } from './modules/activity/activity.module'
|
import { ActivityModule } from './modules/activity/activity.module'
|
||||||
import { AggregateModule } from './modules/aggregate/aggregate.module'
|
import { AggregateModule } from './modules/aggregate/aggregate.module'
|
||||||
|
import { AiModule } from './modules/ai/ai.module'
|
||||||
import { AnalyzeModule } from './modules/analyze/analyze.module'
|
import { AnalyzeModule } from './modules/analyze/analyze.module'
|
||||||
import { AuthModule } from './modules/auth/auth.module'
|
import { AuthModule } from './modules/auth/auth.module'
|
||||||
import { AuthnModule } from './modules/authn/auth.module'
|
import { AuthnModule } from './modules/authn/auth.module'
|
||||||
@@ -73,6 +74,7 @@ import { RedisModule } from './processors/redis/redis.module'
|
|||||||
RedisModule,
|
RedisModule,
|
||||||
|
|
||||||
// biz module
|
// biz module
|
||||||
|
AiModule,
|
||||||
AckModule,
|
AckModule,
|
||||||
ActivityModule,
|
ActivityModule,
|
||||||
AggregateModule,
|
AggregateModule,
|
||||||
|
|||||||
@@ -9,3 +9,8 @@ export function Auth() {
|
|||||||
|
|
||||||
return applyDecorators(...decorators)
|
return applyDecorators(...decorators)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const AuthButProd = () => {
|
||||||
|
if (isDev) return () => {}
|
||||||
|
return Auth()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { Transform } from 'class-transformer'
|
||||||
|
|
||||||
|
export const TransformBoolean = () =>
|
||||||
|
Transform(({ value }) => value === '1' || value === 'true')
|
||||||
@@ -14,6 +14,7 @@ export const RECENTLY_COLLECTION_NAME = 'recentlies'
|
|||||||
|
|
||||||
export const ANALYZE_COLLECTION_NAME = 'analyzes'
|
export const ANALYZE_COLLECTION_NAME = 'analyzes'
|
||||||
export const WEBHOOK_EVENT_COLLECTION_NAME = 'webhook_events'
|
export const WEBHOOK_EVENT_COLLECTION_NAME = 'webhook_events'
|
||||||
|
export const AI_SUMMARY_COLLECTION_NAME = 'ai_summaries'
|
||||||
|
|
||||||
export const USER_COLLECTION_NAME = 'users'
|
export const USER_COLLECTION_NAME = 'users'
|
||||||
export enum CollectionRefTypes {
|
export enum CollectionRefTypes {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export enum ErrorCodeEnum {
|
|||||||
// app
|
// app
|
||||||
Default = 1,
|
Default = 1,
|
||||||
NoContentCanBeModified = 1000,
|
NoContentCanBeModified = 1000,
|
||||||
|
ContentNotFound = 1001,
|
||||||
|
ContentNotFoundCantProcess = 1002,
|
||||||
|
|
||||||
// biz
|
// biz
|
||||||
SlugNotAvailable = 10000,
|
SlugNotAvailable = 10000,
|
||||||
@@ -13,6 +15,11 @@ export enum ErrorCodeEnum {
|
|||||||
|
|
||||||
// 422
|
// 422
|
||||||
MineZip = 100001,
|
MineZip = 100001,
|
||||||
|
// Ai
|
||||||
|
AINotEnabled = 200000,
|
||||||
|
AIKeyExpired = 200001,
|
||||||
|
AIException = 200002,
|
||||||
|
AIProcessing = 200003,
|
||||||
|
|
||||||
// system
|
// system
|
||||||
MasterLost = 99998,
|
MasterLost = 99998,
|
||||||
@@ -23,6 +30,8 @@ export const ErrorCode = Object.freeze<Record<ErrorCodeEnum, [string, number]>>(
|
|||||||
{
|
{
|
||||||
[ErrorCodeEnum.Default]: ['未知错误', 500],
|
[ErrorCodeEnum.Default]: ['未知错误', 500],
|
||||||
[ErrorCodeEnum.SlugNotAvailable]: ['slug 不可用', 400],
|
[ErrorCodeEnum.SlugNotAvailable]: ['slug 不可用', 400],
|
||||||
|
[ErrorCodeEnum.ContentNotFound]: ['内容不存在', 404],
|
||||||
|
[ErrorCodeEnum.ContentNotFoundCantProcess]: ['内容不存在,无法处理', 400],
|
||||||
[ErrorCodeEnum.MaxCountLimit]: ['已达到最大数量限制', 400],
|
[ErrorCodeEnum.MaxCountLimit]: ['已达到最大数量限制', 400],
|
||||||
[ErrorCodeEnum.BanInDemo]: ['Demo 模式下此操作不可用', 400],
|
[ErrorCodeEnum.BanInDemo]: ['Demo 模式下此操作不可用', 400],
|
||||||
[ErrorCodeEnum.MasterLost]: ['站点主人信息已丢失', 500],
|
[ErrorCodeEnum.MasterLost]: ['站点主人信息已丢失', 500],
|
||||||
@@ -35,6 +44,10 @@ export const ErrorCode = Object.freeze<Record<ErrorCodeEnum, [string, number]>>(
|
|||||||
],
|
],
|
||||||
|
|
||||||
[ErrorCodeEnum.MineZip]: ['文件格式必须是 zip 类型', 422],
|
[ErrorCodeEnum.MineZip]: ['文件格式必须是 zip 类型', 422],
|
||||||
|
[ErrorCodeEnum.AINotEnabled]: ['AI 功能未开启', 400],
|
||||||
|
[ErrorCodeEnum.AIKeyExpired]: ['AI Key 已过期,请联系管理员', 400],
|
||||||
|
[ErrorCodeEnum.AIException]: ['AI 服务异常', 500],
|
||||||
|
[ErrorCodeEnum.AIProcessing]: ['AI 正在处理此请求,请稍后再试', 400],
|
||||||
|
|
||||||
[ErrorCodeEnum.EmailTemplateNotFound]: ['邮件模板不存在', 400],
|
[ErrorCodeEnum.EmailTemplateNotFound]: ['邮件模板不存在', 400],
|
||||||
},
|
},
|
||||||
|
|||||||
29
apps/core/src/modules/ai/ai-summary.model.ts
Normal file
29
apps/core/src/modules/ai/ai-summary.model.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { modelOptions, prop } from '@typegoose/typegoose'
|
||||||
|
|
||||||
|
import { AI_SUMMARY_COLLECTION_NAME } from '~/constants/db.constant'
|
||||||
|
import { BaseModel } from '~/shared/model/base.model'
|
||||||
|
|
||||||
|
@modelOptions({
|
||||||
|
options: {
|
||||||
|
customName: AI_SUMMARY_COLLECTION_NAME,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class AISummaryModel extends BaseModel {
|
||||||
|
@prop({
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
hash: string
|
||||||
|
|
||||||
|
@prop({
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
summary: string
|
||||||
|
|
||||||
|
@prop({
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
refId: string
|
||||||
|
|
||||||
|
@prop()
|
||||||
|
lang?: string
|
||||||
|
}
|
||||||
92
apps/core/src/modules/ai/ai.controller.ts
Normal file
92
apps/core/src/modules/ai/ai.controller.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common'
|
||||||
|
|
||||||
|
import { ApiController } from '~/common/decorators/api-controller.decorator'
|
||||||
|
import { Auth, AuthButProd } from '~/common/decorators/auth.decorator'
|
||||||
|
import { MongoIdDto } from '~/shared/dto/id.dto'
|
||||||
|
import { PagerDto } from '~/shared/dto/pager.dto'
|
||||||
|
import { FastifyBizRequest } from '~/transformers/get-req.transformer'
|
||||||
|
|
||||||
|
import { ConfigsService } from '../configs/configs.service'
|
||||||
|
import {
|
||||||
|
GenerateAiSummaryDto,
|
||||||
|
GetSummaryQueryDto,
|
||||||
|
UpdateSummaryDto,
|
||||||
|
} from './ai.dto'
|
||||||
|
import { AiService } from './ai.service'
|
||||||
|
|
||||||
|
@ApiController('ai')
|
||||||
|
export class AiController {
|
||||||
|
constructor(
|
||||||
|
private readonly service: AiService,
|
||||||
|
private readonly configService: ConfigsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('/generate-summary')
|
||||||
|
@AuthButProd()
|
||||||
|
generateSummary(@Body() body: GenerateAiSummaryDto) {
|
||||||
|
return this.service.generateSummaryByOpenAI(body.refId, body.lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/summaries/ref/:id')
|
||||||
|
@AuthButProd()
|
||||||
|
async getSummaryByRefId(@Param() params: MongoIdDto) {
|
||||||
|
return this.service.getSummariesByRefId(params.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/summaries')
|
||||||
|
@AuthButProd()
|
||||||
|
async getSummaries(@Query() query: PagerDto) {
|
||||||
|
return this.service.getAllSummaries(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('/summaries/:id')
|
||||||
|
@Auth()
|
||||||
|
async updateSummary(
|
||||||
|
@Param() params: MongoIdDto,
|
||||||
|
@Body() body: UpdateSummaryDto,
|
||||||
|
) {
|
||||||
|
return this.service.updateSummaryInDb(params.id, body.summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('/summaries/:id')
|
||||||
|
@Auth()
|
||||||
|
async deleteSummary(@Param() params: MongoIdDto) {
|
||||||
|
return this.service.deleteSummaryInDb(params.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/summaries/article/:id')
|
||||||
|
async getArticleSummary(
|
||||||
|
@Param() params: MongoIdDto,
|
||||||
|
@Query() query: GetSummaryQueryDto,
|
||||||
|
@Req() req: FastifyBizRequest,
|
||||||
|
) {
|
||||||
|
const acceptLang = req.headers['accept-language']
|
||||||
|
const finalLang = query.lang || acceptLang || 'zh-CN'
|
||||||
|
const dbStored = await this.service.getSummaryByArticleId(
|
||||||
|
params.id,
|
||||||
|
finalLang,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!dbStored && !query.onlyDb) {
|
||||||
|
const shouldGenerate = await this.configService
|
||||||
|
.get('ai')
|
||||||
|
.then((config) => {
|
||||||
|
return config.enableAutoGenerateSummary && config.enableSummary
|
||||||
|
})
|
||||||
|
if (shouldGenerate) {
|
||||||
|
return this.service.generateSummaryByOpenAI(params.id, finalLang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbStored
|
||||||
|
}
|
||||||
|
}
|
||||||
26
apps/core/src/modules/ai/ai.dto.ts
Normal file
26
apps/core/src/modules/ai/ai.dto.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { IsBoolean, IsOptional, IsString } from 'class-validator'
|
||||||
|
|
||||||
|
import { TransformBoolean } from '~/common/decorators/transform-boolean.decorator'
|
||||||
|
|
||||||
|
class BaseLangQueryDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
lang: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenerateAiSummaryDto extends BaseLangQueryDto {
|
||||||
|
@IsString()
|
||||||
|
refId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetSummaryQueryDto extends BaseLangQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@TransformBoolean()
|
||||||
|
onlyDb?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateSummaryDto {
|
||||||
|
@IsString()
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
10
apps/core/src/modules/ai/ai.module.ts
Normal file
10
apps/core/src/modules/ai/ai.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
|
||||||
|
import { AiController } from './ai.controller'
|
||||||
|
import { AiService } from './ai.service'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [AiService],
|
||||||
|
controllers: [AiController],
|
||||||
|
})
|
||||||
|
export class AiModule {}
|
||||||
256
apps/core/src/modules/ai/ai.service.ts
Normal file
256
apps/core/src/modules/ai/ai.service.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import OpenAI from 'openai'
|
||||||
|
import removeMdCodeblock from 'remove-md-codeblock'
|
||||||
|
import type { PagerDto } from '~/shared/dto/pager.dto'
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common'
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter'
|
||||||
|
|
||||||
|
import { BizException } from '~/common/exceptions/biz.exception'
|
||||||
|
import { BusinessEvents } from '~/constants/business-event.constant'
|
||||||
|
import { CollectionRefTypes } from '~/constants/db.constant'
|
||||||
|
import { ErrorCodeEnum } from '~/constants/error-code.constant'
|
||||||
|
import { DatabaseService } from '~/processors/database/database.service'
|
||||||
|
import { CacheService } from '~/processors/redis/cache.service'
|
||||||
|
import { InjectModel } from '~/transformers/model.transformer'
|
||||||
|
import { transformDataToPaginate } from '~/transformers/paginate.transformer'
|
||||||
|
import { md5 } from '~/utils'
|
||||||
|
|
||||||
|
import { ConfigsService } from '../configs/configs.service'
|
||||||
|
import { AISummaryModel } from './ai-summary.model'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AiService {
|
||||||
|
private readonly logger: Logger
|
||||||
|
constructor(
|
||||||
|
@InjectModel(AISummaryModel)
|
||||||
|
private readonly aiSummaryModel: MongooseModel<AISummaryModel>,
|
||||||
|
private readonly databaseService: DatabaseService,
|
||||||
|
private readonly configService: ConfigsService,
|
||||||
|
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
|
) {
|
||||||
|
this.logger = new Logger(AiService.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private cachedTaskId2AiPromise = new Map<string, Promise<any>>()
|
||||||
|
|
||||||
|
private serializeText(text: string) {
|
||||||
|
return removeMdCodeblock(text)
|
||||||
|
}
|
||||||
|
async generateSummaryByOpenAI(articleId: string, lang = 'zh-CN') {
|
||||||
|
const {
|
||||||
|
ai: { enableSummary, openAiEndpoint, openAiKey },
|
||||||
|
} = await this.configService.waitForConfigReady()
|
||||||
|
|
||||||
|
if (!enableSummary) {
|
||||||
|
throw new BizException(ErrorCodeEnum.AINotEnabled)
|
||||||
|
}
|
||||||
|
if (!openAiKey) {
|
||||||
|
throw new BizException(ErrorCodeEnum.AIKeyExpired)
|
||||||
|
}
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: openAiKey,
|
||||||
|
baseURL: openAiEndpoint || void 0,
|
||||||
|
fetch: isDev ? fetch : void 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const article = await this.databaseService.findGlobalById(articleId)
|
||||||
|
if (!article) {
|
||||||
|
throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (article.type === CollectionRefTypes.Recently) {
|
||||||
|
throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess)
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = `ai:summary:${articleId}:${lang}`
|
||||||
|
try {
|
||||||
|
if (this.cachedTaskId2AiPromise.has(taskId)) {
|
||||||
|
return this.cachedTaskId2AiPromise.get(taskId)
|
||||||
|
}
|
||||||
|
const redis = this.cacheService.getClient()
|
||||||
|
|
||||||
|
const isProcessing = await redis.get(taskId)
|
||||||
|
|
||||||
|
if (isProcessing === 'processing') {
|
||||||
|
throw new BizException(ErrorCodeEnum.AIProcessing)
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskPromise = handle.bind(this)(
|
||||||
|
articleId,
|
||||||
|
this.serializeText(article.document.text),
|
||||||
|
) as Promise<any>
|
||||||
|
|
||||||
|
this.cachedTaskId2AiPromise.set(taskId, taskPromise)
|
||||||
|
return await taskPromise
|
||||||
|
// eslint-disable-next-line no-inner-declarations
|
||||||
|
async function handle(this: AiService, id: string, text: string) {
|
||||||
|
// 等待 30s
|
||||||
|
await redis.set(taskId, 'processing', 'EX', 30)
|
||||||
|
|
||||||
|
const completion = await openai.chat.completions.create({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `Summarize this article in "${lang}" language about 150 characters:
|
||||||
|
"${text}"
|
||||||
|
|
||||||
|
CONCISE SUMMARY:`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
model: 'gpt-3.5-turbo',
|
||||||
|
})
|
||||||
|
|
||||||
|
await redis.del(taskId)
|
||||||
|
|
||||||
|
const summary = completion.choices[0].message.content
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`OpenAI 生成文章 ${articleId} 的摘要花费了 ${completion.usage?.total_tokens}token`,
|
||||||
|
)
|
||||||
|
const contentMd5 = md5(text)
|
||||||
|
|
||||||
|
const doc = await this.aiSummaryModel.create({
|
||||||
|
hash: contentMd5,
|
||||||
|
lang,
|
||||||
|
refId: id,
|
||||||
|
summary,
|
||||||
|
})
|
||||||
|
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
} catch (er) {
|
||||||
|
this.logger.error(`OpenAI 在处理文章 ${articleId} 时出错:${er.message}`)
|
||||||
|
|
||||||
|
throw new BizException(ErrorCodeEnum.AIException, er.message)
|
||||||
|
} finally {
|
||||||
|
this.cachedTaskId2AiPromise.delete(taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSummariesByRefId(refId: string) {
|
||||||
|
const article = await this.databaseService.findGlobalById(refId)
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
throw new BizException(ErrorCodeEnum.ContentNotFound)
|
||||||
|
}
|
||||||
|
const summaries = await this.aiSummaryModel.find({
|
||||||
|
refId,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
summaries,
|
||||||
|
article,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllSummaries(pager: PagerDto) {
|
||||||
|
const { page, size } = pager
|
||||||
|
const summaries = await this.aiSummaryModel.paginate(
|
||||||
|
{
|
||||||
|
page,
|
||||||
|
limit: size,
|
||||||
|
sort: {
|
||||||
|
created: -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lean: true,
|
||||||
|
leanWithId: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const data = transformDataToPaginate(summaries)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
articles: await this.getRefArticles(summaries.docs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRefArticles(docs: AISummaryModel[]) {
|
||||||
|
return this.databaseService
|
||||||
|
.findGlobalByIds(docs.map((d) => d.refId))
|
||||||
|
.then((articles) => {
|
||||||
|
const articleMap = {} as Record<
|
||||||
|
string,
|
||||||
|
{ title: string; id: string; type: CollectionRefTypes }
|
||||||
|
>
|
||||||
|
for (const a of articles.notes) {
|
||||||
|
articleMap[a.id] = {
|
||||||
|
title: a.title,
|
||||||
|
id: a.id,
|
||||||
|
type: CollectionRefTypes.Note,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const a of articles.posts) {
|
||||||
|
articleMap[a.id] = {
|
||||||
|
title: a.title,
|
||||||
|
id: a.id,
|
||||||
|
type: CollectionRefTypes.Post,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return articleMap
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSummaryInDb(id: string, summary: string) {
|
||||||
|
const doc = await this.aiSummaryModel.findById(id)
|
||||||
|
if (!doc) {
|
||||||
|
throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess)
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.summary = summary
|
||||||
|
await doc.save()
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
async getSummaryByArticleId(articleId: string, lang = 'zh-CN') {
|
||||||
|
const article = await this.databaseService.findGlobalById(articleId)
|
||||||
|
if (!article) {
|
||||||
|
throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (article.type === CollectionRefTypes.Recently) {
|
||||||
|
throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentMd5 = md5(this.serializeText(article.document.text))
|
||||||
|
const doc = await this.aiSummaryModel.findOne({
|
||||||
|
hash: contentMd5,
|
||||||
|
|
||||||
|
lang,
|
||||||
|
})
|
||||||
|
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSummaryByArticleId(articleId: string) {
|
||||||
|
await this.aiSummaryModel.deleteMany({
|
||||||
|
refId: articleId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSummaryInDb(id: string) {
|
||||||
|
await this.aiSummaryModel.deleteOne({
|
||||||
|
_id: id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent(BusinessEvents.POST_DELETE)
|
||||||
|
@OnEvent(BusinessEvents.NOTE_DELETE)
|
||||||
|
async handleDeleteArticle(event: { id: string }) {
|
||||||
|
await this.deleteSummaryByArticleId(event.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent(BusinessEvents.POST_CREATE)
|
||||||
|
@OnEvent(BusinessEvents.NOTE_CREATE)
|
||||||
|
async handleCreateArticle(event: { id: string }) {
|
||||||
|
const enableAutoGenerate = await this.configService
|
||||||
|
.get('ai')
|
||||||
|
.then((c) => c.enableAutoGenerateSummary && c.enableSummary)
|
||||||
|
if (!enableAutoGenerate) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.generateSummaryByOpenAI(event.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,6 @@ import { EmailService } from '~/processors/helper/helper.email.service'
|
|||||||
import { EventManagerService } from '~/processors/helper/helper.event.service'
|
import { EventManagerService } from '~/processors/helper/helper.event.service'
|
||||||
import { InjectModel } from '~/transformers/model.transformer'
|
import { InjectModel } from '~/transformers/model.transformer'
|
||||||
import { getAvatar, hasChinese, scheduleManager } from '~/utils'
|
import { getAvatar, hasChinese, scheduleManager } from '~/utils'
|
||||||
import { normalizeRefType } from '~/utils/database.util'
|
|
||||||
|
|
||||||
import { ConfigsService } from '../configs/configs.service'
|
import { ConfigsService } from '../configs/configs.service'
|
||||||
import { createMockedContextResponse } from '../serverless/mock-response.util'
|
import { createMockedContextResponse } from '../serverless/mock-response.util'
|
||||||
@@ -169,7 +168,7 @@ export class CommentService implements OnModuleInit {
|
|||||||
if (result) {
|
if (result) {
|
||||||
const { type, document } = result
|
const { type, document } = result
|
||||||
ref = document as any
|
ref = document as any
|
||||||
refType = normalizeRefType(type)
|
refType = type
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!ref) {
|
if (!ref) {
|
||||||
|
|||||||
@@ -75,4 +75,10 @@ export const generateDefaultConfig: () => IConfig = () => ({
|
|||||||
authSecurity: {
|
authSecurity: {
|
||||||
disablePasswordLogin: false,
|
disablePasswordLogin: false,
|
||||||
},
|
},
|
||||||
|
ai: {
|
||||||
|
enableAutoGenerateSummary: false,
|
||||||
|
enableSummary: false,
|
||||||
|
openAiEndpoint: '',
|
||||||
|
openAiKey: '',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -369,3 +369,31 @@ export class AuthSecurityDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
disablePasswordLogin: boolean
|
disablePasswordLogin: boolean
|
||||||
}
|
}
|
||||||
|
@JSONSchema({ title: 'AI 设定' })
|
||||||
|
export class AIDto {
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
@JSONSchemaToggleField('可调用 AI 摘要', {
|
||||||
|
description: '是否开启调用 AI 去生成摘要',
|
||||||
|
})
|
||||||
|
enableSummary: boolean
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
@JSONSchemaToggleField('开启 AI 摘要自动生成', {
|
||||||
|
description:
|
||||||
|
'此选项开启后,将会在文章发布后自动生成摘要,需要开启上面的选项,否则无效',
|
||||||
|
})
|
||||||
|
enableAutoGenerateSummary: boolean
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@JSONSchemaPlainField('OpenAI Key')
|
||||||
|
@IsString()
|
||||||
|
@Encrypt
|
||||||
|
openAiKey: string
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@JSONSchemaPlainField('OpenAI Endpoint')
|
||||||
|
openAiEndpoint: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AdminExtraDto,
|
AdminExtraDto,
|
||||||
|
AIDto,
|
||||||
AlgoliaSearchOptionsDto,
|
AlgoliaSearchOptionsDto,
|
||||||
AuthSecurityDto,
|
AuthSecurityDto,
|
||||||
BackupOptionsDto,
|
BackupOptionsDto,
|
||||||
@@ -80,6 +81,9 @@ export abstract class IConfig {
|
|||||||
|
|
||||||
@ConfigField(() => AuthSecurityDto)
|
@ConfigField(() => AuthSecurityDto)
|
||||||
authSecurity: AuthSecurityDto
|
authSecurity: AuthSecurityDto
|
||||||
|
|
||||||
|
@ConfigField(() => AIDto)
|
||||||
|
ai: AIDto
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IConfigKeys = keyof IConfig
|
export type IConfigKeys = keyof IConfig
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { FastifyReply } from 'fastify'
|
|||||||
import { BadRequestException, Get, Param, Query, Res } from '@nestjs/common'
|
import { BadRequestException, Get, Param, Query, Res } from '@nestjs/common'
|
||||||
|
|
||||||
import { ApiController } from '~/common/decorators/api-controller.decorator'
|
import { ApiController } from '~/common/decorators/api-controller.decorator'
|
||||||
|
import { CollectionRefTypes } from '~/constants/db.constant'
|
||||||
import { DatabaseService } from '~/processors/database/database.service'
|
import { DatabaseService } from '~/processors/database/database.service'
|
||||||
import { UrlBuilderService } from '~/processors/helper/helper.url-builder.service'
|
import { UrlBuilderService } from '~/processors/helper/helper.url-builder.service'
|
||||||
import { MongoIdDto } from '~/shared/dto/id.dto'
|
import { MongoIdDto } from '~/shared/dto/id.dto'
|
||||||
@@ -26,7 +27,7 @@ export class HelperController {
|
|||||||
@Res() res: FastifyReply,
|
@Res() res: FastifyReply,
|
||||||
) {
|
) {
|
||||||
const doc = await this.databaseService.findGlobalById(params.id)
|
const doc = await this.databaseService.findGlobalById(params.id)
|
||||||
if (!doc || doc.type === 'Recently') {
|
if (!doc || doc.type === CollectionRefTypes.Recently) {
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'not found or this type can not redirect to',
|
'not found or this type can not redirect to',
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ import {
|
|||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator'
|
} from 'class-validator'
|
||||||
|
|
||||||
|
import { TransformBoolean } from '~/common/decorators/transform-boolean.decorator'
|
||||||
import { ArticleTypeEnum } from '~/constants/article.constant'
|
import { ArticleTypeEnum } from '~/constants/article.constant'
|
||||||
|
|
||||||
const TransformBoolean = () =>
|
|
||||||
Transform(({ value }) => value === '1' || value === 'true')
|
|
||||||
|
|
||||||
export class MetaDto {
|
export class MetaDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
title: string
|
title: string
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
import { ReturnModelType } from '@typegoose/typegoose'
|
import { ReturnModelType } from '@typegoose/typegoose'
|
||||||
|
|
||||||
|
import { CollectionRefTypes } from '~/constants/db.constant'
|
||||||
import { DatabaseService } from '~/processors/database/database.service'
|
import { DatabaseService } from '~/processors/database/database.service'
|
||||||
import { AssetService } from '~/processors/helper/helper.asset.service'
|
import { AssetService } from '~/processors/helper/helper.asset.service'
|
||||||
import { TextMacroService } from '~/processors/helper/helper.macro.service'
|
import { TextMacroService } from '~/processors/helper/helper.macro.service'
|
||||||
@@ -220,7 +221,7 @@ ${text.trim()}
|
|||||||
async renderArticle(id: string) {
|
async renderArticle(id: string) {
|
||||||
const result = await this.databaseService.findGlobalById(id)
|
const result = await this.databaseService.findGlobalById(id)
|
||||||
|
|
||||||
if (!result || result.type === 'Recently')
|
if (!result || result.type === CollectionRefTypes.Recently)
|
||||||
throw new BadRequestException('文档不存在')
|
throw new BadRequestException('文档不存在')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { EventManagerService } from '~/processors/helper/helper.event.service'
|
|||||||
import { CacheService } from '~/processors/redis/cache.service'
|
import { CacheService } from '~/processors/redis/cache.service'
|
||||||
import { InjectModel } from '~/transformers/model.transformer'
|
import { InjectModel } from '~/transformers/model.transformer'
|
||||||
import { getRedisKey, scheduleManager } from '~/utils'
|
import { getRedisKey, scheduleManager } from '~/utils'
|
||||||
import { normalizeRefType } from '~/utils/database.util'
|
|
||||||
|
|
||||||
import { CommentState } from '../comment/comment.model'
|
import { CommentState } from '../comment/comment.model'
|
||||||
import { CommentService } from '../comment/comment.service'
|
import { CommentService } from '../comment/comment.service'
|
||||||
@@ -281,7 +280,7 @@ export class RecentlyService {
|
|||||||
throw new BadRequestException('ref model not found')
|
throw new BadRequestException('ref model not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
model.refType = normalizeRefType(existModel.type)
|
model.refType = existModel.type
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await this.model.create({
|
const res = await this.model.create({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ActivityModel } from '~/modules/activity/activity.model'
|
import { ActivityModel } from '~/modules/activity/activity.model'
|
||||||
|
import { AISummaryModel } from '~/modules/ai/ai-summary.model'
|
||||||
import { AnalyzeModel } from '~/modules/analyze/analyze.model'
|
import { AnalyzeModel } from '~/modules/analyze/analyze.model'
|
||||||
import { AuthnModel } from '~/modules/authn/authn.model'
|
import { AuthnModel } from '~/modules/authn/authn.model'
|
||||||
import { CategoryModel } from '~/modules/category/category.model'
|
import { CategoryModel } from '~/modules/category/category.model'
|
||||||
@@ -23,6 +24,7 @@ import { WebhookModel } from '~/modules/webhook/webhook.model'
|
|||||||
import { getProviderByTypegooseClass } from '~/transformers/model.transformer'
|
import { getProviderByTypegooseClass } from '~/transformers/model.transformer'
|
||||||
|
|
||||||
export const databaseModels = [
|
export const databaseModels = [
|
||||||
|
AISummaryModel,
|
||||||
ActivityModel,
|
ActivityModel,
|
||||||
AnalyzeModel,
|
AnalyzeModel,
|
||||||
AuthnModel,
|
AuthnModel,
|
||||||
|
|||||||
@@ -92,19 +92,19 @@ export class DatabaseService {
|
|||||||
public async findGlobalById(id: string): Promise<
|
public async findGlobalById(id: string): Promise<
|
||||||
| {
|
| {
|
||||||
document: PostModel
|
document: PostModel
|
||||||
type: 'Post'
|
type: CollectionRefTypes.Post
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
document: NoteModel
|
document: NoteModel
|
||||||
type: 'Note'
|
type: CollectionRefTypes.Note
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
document: PageModel
|
document: PageModel
|
||||||
type: 'Page'
|
type: CollectionRefTypes.Page
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
document: RecentlyModel
|
document: RecentlyModel
|
||||||
type: 'Recently'
|
type: CollectionRefTypes.Recently
|
||||||
}
|
}
|
||||||
| null
|
| null
|
||||||
>
|
>
|
||||||
@@ -131,7 +131,13 @@ export class DatabaseService {
|
|||||||
if (!document) return null
|
if (!document) return null
|
||||||
return {
|
return {
|
||||||
document,
|
document,
|
||||||
type: (['Post', 'Note', 'Page', 'Recently'] as const)[index],
|
|
||||||
|
type: [
|
||||||
|
CollectionRefTypes.Post,
|
||||||
|
CollectionRefTypes.Note,
|
||||||
|
CollectionRefTypes.Page,
|
||||||
|
CollectionRefTypes.Recently,
|
||||||
|
][index],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
34
packages/api-client/__tests__/controllers/ai.test.ts
Normal file
34
packages/api-client/__tests__/controllers/ai.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { mockRequestInstance } from '~/__tests__/helpers/instance'
|
||||||
|
import { mockResponse } from '~/__tests__/helpers/response'
|
||||||
|
import { AIController } from '~/controllers'
|
||||||
|
|
||||||
|
describe('test ai client', () => {
|
||||||
|
const client = mockRequestInstance(AIController)
|
||||||
|
|
||||||
|
test('POST /generate-summary', async () => {
|
||||||
|
mockResponse('/ai/generate-summary', {}, 'post', {
|
||||||
|
lang: 'zh-CN',
|
||||||
|
refId: '11',
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.ai.generateSummary('11', 'zh-CN'),
|
||||||
|
).resolves.not.toThrowError()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GET /summary/:id', async () => {
|
||||||
|
mockResponse(
|
||||||
|
'/ai/summaries/article/11?articleId=11&lang=zh-CN&onlyDb=true',
|
||||||
|
{},
|
||||||
|
'get',
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.ai.getSummary({
|
||||||
|
articleId: '11',
|
||||||
|
lang: 'zh-CN',
|
||||||
|
onlyDb: true,
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrowError()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import camelcaseKeysLib from 'camelcase-keys'
|
import camelcaseKeysLib from 'camelcase-keys'
|
||||||
|
|
||||||
import { camelcaseKeys } from '~/utils/camelcase-keys'
|
import { camelcase, camelcaseKeys } from '~/utils/camelcase-keys'
|
||||||
|
|
||||||
describe('test camelcase keys', () => {
|
describe('test camelcase keys', () => {
|
||||||
it('case 1 normal', () => {
|
it('case 1 normal', () => {
|
||||||
@@ -80,7 +80,33 @@ describe('test camelcase keys', () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
expect(camelcaseKeys(arr)).toStrictEqual(
|
expect(camelcaseKeys(arr)).toStrictEqual(
|
||||||
camelcaseKeysLib(arr, { deep: true }),
|
camelcaseKeysLib(arr as any, { deep: true }),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('case 6: filter out mongo id', () => {
|
||||||
|
const obj = {
|
||||||
|
_id: '123',
|
||||||
|
a_b: 1,
|
||||||
|
collections: {
|
||||||
|
posts: {
|
||||||
|
'661bb93307d35005ba96731b': {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(camelcaseKeys(obj)).toStrictEqual({
|
||||||
|
id: '123',
|
||||||
|
aB: 1,
|
||||||
|
collections: {
|
||||||
|
posts: {
|
||||||
|
'661bb93307d35005ba96731b': {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('case 7: start with underscore should not camelcase', () => {
|
||||||
|
expect(camelcase('_id')).toBe('id')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
61
packages/api-client/controllers/ai.ts
Normal file
61
packages/api-client/controllers/ai.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { IRequestAdapter } from '~/interfaces/adapter'
|
||||||
|
import type { IController } from '~/interfaces/controller'
|
||||||
|
import type { IRequestHandler } from '~/interfaces/request'
|
||||||
|
import type { HTTPClient } from '../core'
|
||||||
|
import type { AISummaryModel } from '../models/ai'
|
||||||
|
|
||||||
|
import { autoBind } from '~/utils/auto-bind'
|
||||||
|
|
||||||
|
declare module '../core/client' {
|
||||||
|
interface HTTPClient<
|
||||||
|
T extends IRequestAdapter = IRequestAdapter,
|
||||||
|
ResponseWrapper = unknown,
|
||||||
|
> {
|
||||||
|
ai: AIController<ResponseWrapper>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @support core >= 5.6.0
|
||||||
|
*/
|
||||||
|
export class AIController<ResponseWrapper> implements IController {
|
||||||
|
base = 'ai'
|
||||||
|
name = 'ai'
|
||||||
|
|
||||||
|
constructor(private client: HTTPClient) {
|
||||||
|
autoBind(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
public get proxy(): IRequestHandler<ResponseWrapper> {
|
||||||
|
return this.client.proxy(this.base)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSummary({
|
||||||
|
articleId,
|
||||||
|
lang = 'zh-CN',
|
||||||
|
onlyDb,
|
||||||
|
}: {
|
||||||
|
articleId: string
|
||||||
|
lang?: string
|
||||||
|
onlyDb?: boolean
|
||||||
|
}) {
|
||||||
|
return this.proxy.summaries.article(articleId).get<AISummaryModel>({
|
||||||
|
params: {
|
||||||
|
lang,
|
||||||
|
onlyDb,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateSummary(articleId: string, lang = 'zh-CN', token = '') {
|
||||||
|
return this.proxy('generate-summary').post<AISummaryModel>({
|
||||||
|
params: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
lang,
|
||||||
|
refId: articleId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AckController } from './ack'
|
import { AckController } from './ack'
|
||||||
import { ActivityController } from './activity'
|
import { ActivityController } from './activity'
|
||||||
import { AggregateController } from './aggregate'
|
import { AggregateController } from './aggregate'
|
||||||
|
import { AIController } from './ai'
|
||||||
import { CategoryController } from './category'
|
import { CategoryController } from './category'
|
||||||
import { CommentController } from './comment'
|
import { CommentController } from './comment'
|
||||||
import { LinkController } from './link'
|
import { LinkController } from './link'
|
||||||
@@ -22,6 +23,7 @@ import { TopicController } from './topic'
|
|||||||
import { UserController } from './user'
|
import { UserController } from './user'
|
||||||
|
|
||||||
export const allControllers = [
|
export const allControllers = [
|
||||||
|
AIController,
|
||||||
AckController,
|
AckController,
|
||||||
ActivityController,
|
ActivityController,
|
||||||
AggregateController,
|
AggregateController,
|
||||||
@@ -43,6 +45,7 @@ export const allControllers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const allControllerNames = [
|
export const allControllerNames = [
|
||||||
|
'ai',
|
||||||
'ack',
|
'ack',
|
||||||
'activity',
|
'activity',
|
||||||
'aggregate',
|
'aggregate',
|
||||||
@@ -69,6 +72,7 @@ export const allControllerNames = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
AIController,
|
||||||
AckController,
|
AckController,
|
||||||
ActivityController,
|
ActivityController,
|
||||||
AggregateController,
|
AggregateController,
|
||||||
|
|||||||
8
packages/api-client/models/ai.ts
Normal file
8
packages/api-client/models/ai.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface AISummaryModel {
|
||||||
|
id: string
|
||||||
|
created: string
|
||||||
|
summary: string
|
||||||
|
hash: string
|
||||||
|
refId: string
|
||||||
|
lang: string
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './activity'
|
export * from './activity'
|
||||||
export * from './aggregate'
|
export * from './aggregate'
|
||||||
|
export * from './ai'
|
||||||
export * from './base'
|
export * from './base'
|
||||||
export * from './category'
|
export * from './category'
|
||||||
export * from './comment'
|
export * from './comment'
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ export const camelcaseKeys = <T = any>(obj: any): T => {
|
|||||||
|
|
||||||
if (isPlainObject(obj)) {
|
if (isPlainObject(obj)) {
|
||||||
return Object.keys(obj).reduce((result: any, key) => {
|
return Object.keys(obj).reduce((result: any, key) => {
|
||||||
result[camelcase(key)] = camelcaseKeys(obj[key])
|
const nextKey = isMongoId(key) ? key : camelcase(key)
|
||||||
|
result[nextKey] = camelcaseKeys(obj[key])
|
||||||
return result
|
return result
|
||||||
}, {}) as any
|
}, {}) as any
|
||||||
}
|
}
|
||||||
@@ -20,7 +21,9 @@ export const camelcaseKeys = <T = any>(obj: any): T => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function camelcase(str: string) {
|
export function camelcase(str: string) {
|
||||||
return str.replace(/([-_][a-z])/gi, ($1) => {
|
return str.replace(/^_+/, '').replace(/([-_][a-z])/gi, ($1) => {
|
||||||
return $1.toUpperCase().replace('-', '').replace('_', '')
|
return $1.toUpperCase().replace('-', '').replace('_', '')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const isMongoId = (id: string) =>
|
||||||
|
id.length === 24 && /^[0-9a-fA-F]{24}$/.test(id)
|
||||||
|
|||||||
4
paw.paw
4
paw.paw
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a199a6f6e7d9f51567233c10d67d73c465daaa16f89291663bb5f5c2b962143e
|
oid sha256:73e17b9ea9ab9042f8e690ff7d14fd63bafe7f83fbfabd138f5cde884776224a
|
||||||
size 111717
|
size 115659
|
||||||
|
|||||||
13093
pnpm-lock.yaml
generated
13093
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user