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:
Innei
2024-04-26 19:52:11 +08:00
committed by GitHub
parent d78a359a7d
commit c989a2a7b0
30 changed files with 7989 additions and 5764 deletions

View File

@@ -113,6 +113,7 @@
"nestjs-pretty-logger": "0.2.2",
"node-machine-id": "1.1.12",
"nodemailer": "6.9.13",
"openai": "4.38.5",
"pluralize": "^8.0.0",
"qs": "6.12.1",
"reflect-metadata": "0.2.2",

View File

@@ -24,6 +24,7 @@ import { RequestContextMiddleware } from './common/middlewares/request-context.m
import { AckModule } from './modules/ack/ack.module'
import { ActivityModule } from './modules/activity/activity.module'
import { AggregateModule } from './modules/aggregate/aggregate.module'
import { AiModule } from './modules/ai/ai.module'
import { AnalyzeModule } from './modules/analyze/analyze.module'
import { AuthModule } from './modules/auth/auth.module'
import { AuthnModule } from './modules/authn/auth.module'
@@ -73,6 +74,7 @@ import { RedisModule } from './processors/redis/redis.module'
RedisModule,
// biz module
AiModule,
AckModule,
ActivityModule,
AggregateModule,

View File

@@ -9,3 +9,8 @@ export function Auth() {
return applyDecorators(...decorators)
}
export const AuthButProd = () => {
if (isDev) return () => {}
return Auth()
}

View File

@@ -0,0 +1,4 @@
import { Transform } from 'class-transformer'
export const TransformBoolean = () =>
Transform(({ value }) => value === '1' || value === 'true')

View File

@@ -14,6 +14,7 @@ export const RECENTLY_COLLECTION_NAME = 'recentlies'
export const ANALYZE_COLLECTION_NAME = 'analyzes'
export const WEBHOOK_EVENT_COLLECTION_NAME = 'webhook_events'
export const AI_SUMMARY_COLLECTION_NAME = 'ai_summaries'
export const USER_COLLECTION_NAME = 'users'
export enum CollectionRefTypes {

View File

@@ -2,6 +2,8 @@ export enum ErrorCodeEnum {
// app
Default = 1,
NoContentCanBeModified = 1000,
ContentNotFound = 1001,
ContentNotFoundCantProcess = 1002,
// biz
SlugNotAvailable = 10000,
@@ -13,6 +15,11 @@ export enum ErrorCodeEnum {
// 422
MineZip = 100001,
// Ai
AINotEnabled = 200000,
AIKeyExpired = 200001,
AIException = 200002,
AIProcessing = 200003,
// system
MasterLost = 99998,
@@ -23,6 +30,8 @@ export const ErrorCode = Object.freeze<Record<ErrorCodeEnum, [string, number]>>(
{
[ErrorCodeEnum.Default]: ['未知错误', 500],
[ErrorCodeEnum.SlugNotAvailable]: ['slug 不可用', 400],
[ErrorCodeEnum.ContentNotFound]: ['内容不存在', 404],
[ErrorCodeEnum.ContentNotFoundCantProcess]: ['内容不存在,无法处理', 400],
[ErrorCodeEnum.MaxCountLimit]: ['已达到最大数量限制', 400],
[ErrorCodeEnum.BanInDemo]: ['Demo 模式下此操作不可用', 400],
[ErrorCodeEnum.MasterLost]: ['站点主人信息已丢失', 500],
@@ -35,6 +44,10 @@ export const ErrorCode = Object.freeze<Record<ErrorCodeEnum, [string, number]>>(
],
[ErrorCodeEnum.MineZip]: ['文件格式必须是 zip 类型', 422],
[ErrorCodeEnum.AINotEnabled]: ['AI 功能未开启', 400],
[ErrorCodeEnum.AIKeyExpired]: ['AI Key 已过期,请联系管理员', 400],
[ErrorCodeEnum.AIException]: ['AI 服务异常', 500],
[ErrorCodeEnum.AIProcessing]: ['AI 正在处理此请求,请稍后再试', 400],
[ErrorCodeEnum.EmailTemplateNotFound]: ['邮件模板不存在', 400],
},

View 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
}

View 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
}
}

View 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
}

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

View 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)
}
}

View File

@@ -31,7 +31,6 @@ import { EmailService } from '~/processors/helper/helper.email.service'
import { EventManagerService } from '~/processors/helper/helper.event.service'
import { InjectModel } from '~/transformers/model.transformer'
import { getAvatar, hasChinese, scheduleManager } from '~/utils'
import { normalizeRefType } from '~/utils/database.util'
import { ConfigsService } from '../configs/configs.service'
import { createMockedContextResponse } from '../serverless/mock-response.util'
@@ -169,7 +168,7 @@ export class CommentService implements OnModuleInit {
if (result) {
const { type, document } = result
ref = document as any
refType = normalizeRefType(type)
refType = type
}
}
if (!ref) {

View File

@@ -75,4 +75,10 @@ export const generateDefaultConfig: () => IConfig = () => ({
authSecurity: {
disablePasswordLogin: false,
},
ai: {
enableAutoGenerateSummary: false,
enableSummary: false,
openAiEndpoint: '',
openAiKey: '',
},
})

View File

@@ -369,3 +369,31 @@ export class AuthSecurityDto {
@IsOptional()
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
}

View File

@@ -9,6 +9,7 @@ import type {
import {
AdminExtraDto,
AIDto,
AlgoliaSearchOptionsDto,
AuthSecurityDto,
BackupOptionsDto,
@@ -80,6 +81,9 @@ export abstract class IConfig {
@ConfigField(() => AuthSecurityDto)
authSecurity: AuthSecurityDto
@ConfigField(() => AIDto)
ai: AIDto
}
export type IConfigKeys = keyof IConfig

View File

@@ -3,6 +3,7 @@ import { FastifyReply } from 'fastify'
import { BadRequestException, Get, Param, Query, Res } from '@nestjs/common'
import { ApiController } from '~/common/decorators/api-controller.decorator'
import { CollectionRefTypes } from '~/constants/db.constant'
import { DatabaseService } from '~/processors/database/database.service'
import { UrlBuilderService } from '~/processors/helper/helper.url-builder.service'
import { MongoIdDto } from '~/shared/dto/id.dto'
@@ -26,7 +27,7 @@ export class HelperController {
@Res() res: FastifyReply,
) {
const doc = await this.databaseService.findGlobalById(params.id)
if (!doc || doc.type === 'Recently') {
if (!doc || doc.type === CollectionRefTypes.Recently) {
if (redirect) {
throw new BadRequestException(
'not found or this type can not redirect to',

View File

@@ -8,11 +8,9 @@ import {
ValidateNested,
} from 'class-validator'
import { TransformBoolean } from '~/common/decorators/transform-boolean.decorator'
import { ArticleTypeEnum } from '~/constants/article.constant'
const TransformBoolean = () =>
Transform(({ value }) => value === '1' || value === 'true')
export class MetaDto {
@IsString()
title: string

View File

@@ -13,6 +13,7 @@ import {
} from '@nestjs/common'
import { ReturnModelType } from '@typegoose/typegoose'
import { CollectionRefTypes } from '~/constants/db.constant'
import { DatabaseService } from '~/processors/database/database.service'
import { AssetService } from '~/processors/helper/helper.asset.service'
import { TextMacroService } from '~/processors/helper/helper.macro.service'
@@ -220,7 +221,7 @@ ${text.trim()}
async renderArticle(id: string) {
const result = await this.databaseService.findGlobalById(id)
if (!result || result.type === 'Recently')
if (!result || result.type === CollectionRefTypes.Recently)
throw new BadRequestException('文档不存在')
return {

View File

@@ -18,7 +18,6 @@ import { EventManagerService } from '~/processors/helper/helper.event.service'
import { CacheService } from '~/processors/redis/cache.service'
import { InjectModel } from '~/transformers/model.transformer'
import { getRedisKey, scheduleManager } from '~/utils'
import { normalizeRefType } from '~/utils/database.util'
import { CommentState } from '../comment/comment.model'
import { CommentService } from '../comment/comment.service'
@@ -281,7 +280,7 @@ export class RecentlyService {
throw new BadRequestException('ref model not found')
}
model.refType = normalizeRefType(existModel.type)
model.refType = existModel.type
}
const res = await this.model.create({

View File

@@ -1,4 +1,5 @@
import { ActivityModel } from '~/modules/activity/activity.model'
import { AISummaryModel } from '~/modules/ai/ai-summary.model'
import { AnalyzeModel } from '~/modules/analyze/analyze.model'
import { AuthnModel } from '~/modules/authn/authn.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'
export const databaseModels = [
AISummaryModel,
ActivityModel,
AnalyzeModel,
AuthnModel,

View File

@@ -92,19 +92,19 @@ export class DatabaseService {
public async findGlobalById(id: string): Promise<
| {
document: PostModel
type: 'Post'
type: CollectionRefTypes.Post
}
| {
document: NoteModel
type: 'Note'
type: CollectionRefTypes.Note
}
| {
document: PageModel
type: 'Page'
type: CollectionRefTypes.Page
}
| {
document: RecentlyModel
type: 'Recently'
type: CollectionRefTypes.Recently
}
| null
>
@@ -131,7 +131,13 @@ export class DatabaseService {
if (!document) return null
return {
document,
type: (['Post', 'Note', 'Page', 'Recently'] as const)[index],
type: [
CollectionRefTypes.Post,
CollectionRefTypes.Note,
CollectionRefTypes.Page,
CollectionRefTypes.Recently,
][index],
}
}

View 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()
})
})

View File

@@ -1,6 +1,6 @@
import camelcaseKeysLib from 'camelcase-keys'
import { camelcaseKeys } from '~/utils/camelcase-keys'
import { camelcase, camelcaseKeys } from '~/utils/camelcase-keys'
describe('test camelcase keys', () => {
it('case 1 normal', () => {
@@ -80,7 +80,33 @@ describe('test camelcase keys', () => {
]
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')
})
})

View 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,
},
})
}
}

View File

@@ -1,6 +1,7 @@
import { AckController } from './ack'
import { ActivityController } from './activity'
import { AggregateController } from './aggregate'
import { AIController } from './ai'
import { CategoryController } from './category'
import { CommentController } from './comment'
import { LinkController } from './link'
@@ -22,6 +23,7 @@ import { TopicController } from './topic'
import { UserController } from './user'
export const allControllers = [
AIController,
AckController,
ActivityController,
AggregateController,
@@ -43,6 +45,7 @@ export const allControllers = [
]
export const allControllerNames = [
'ai',
'ack',
'activity',
'aggregate',
@@ -69,6 +72,7 @@ export const allControllerNames = [
] as const
export {
AIController,
AckController,
ActivityController,
AggregateController,

View File

@@ -0,0 +1,8 @@
export interface AISummaryModel {
id: string
created: string
summary: string
hash: string
refId: string
lang: string
}

View File

@@ -1,5 +1,6 @@
export * from './activity'
export * from './aggregate'
export * from './ai'
export * from './base'
export * from './category'
export * from './comment'

View File

@@ -11,7 +11,8 @@ export const camelcaseKeys = <T = any>(obj: any): T => {
if (isPlainObject(obj)) {
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
}, {}) as any
}
@@ -20,7 +21,9 @@ export const camelcaseKeys = <T = any>(obj: any): T => {
}
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('_', '')
})
}
const isMongoId = (id: string) =>
id.length === 24 && /^[0-9a-fA-F]{24}$/.test(id)

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a199a6f6e7d9f51567233c10d67d73c465daaa16f89291663bb5f5c2b962143e
size 111717
oid sha256:73e17b9ea9ab9042f8e690ff7d14fd63bafe7f83fbfabd138f5cde884776224a
size 115659

13093
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff