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",
|
||||
"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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,3 +9,8 @@ export function Auth() {
|
||||
|
||||
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 WEBHOOK_EVENT_COLLECTION_NAME = 'webhook_events'
|
||||
export const AI_SUMMARY_COLLECTION_NAME = 'ai_summaries'
|
||||
|
||||
export const USER_COLLECTION_NAME = 'users'
|
||||
export enum CollectionRefTypes {
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
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 { 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) {
|
||||
|
||||
@@ -75,4 +75,10 @@ export const generateDefaultConfig: () => IConfig = () => ({
|
||||
authSecurity: {
|
||||
disablePasswordLogin: false,
|
||||
},
|
||||
ai: {
|
||||
enableAutoGenerateSummary: false,
|
||||
enableSummary: false,
|
||||
openAiEndpoint: '',
|
||||
openAiKey: '',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 { 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')
|
||||
})
|
||||
})
|
||||
|
||||
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 { 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,
|
||||
|
||||
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 './aggregate'
|
||||
export * from './ai'
|
||||
export * from './base'
|
||||
export * from './category'
|
||||
export * from './comment'
|
||||
|
||||
@@ -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)
|
||||
|
||||
4
paw.paw
4
paw.paw
@@ -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
13093
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user