@@ -20,6 +20,7 @@ export enum ErrorCodeEnum {
|
||||
AIKeyExpired = 200001,
|
||||
AIException = 200002,
|
||||
AIProcessing = 200003,
|
||||
AIResultParsingError = 200004,
|
||||
|
||||
// system
|
||||
MasterLost = 99998,
|
||||
@@ -44,10 +45,12 @@ 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.AIResultParsingError]: ['AI 结果解析错误', 500],
|
||||
|
||||
[ErrorCodeEnum.EmailTemplateNotFound]: ['邮件模板不存在', 400],
|
||||
},
|
||||
|
||||
@@ -10,48 +10,48 @@ import {
|
||||
} from '@nestjs/common'
|
||||
|
||||
import { ApiController } from '~/common/decorators/api-controller.decorator'
|
||||
import { Auth, AuthButProd } from '~/common/decorators/auth.decorator'
|
||||
import { Auth } from '~/common/decorators/auth.decorator'
|
||||
import { BizException } from '~/common/exceptions/biz.exception'
|
||||
import { ErrorCodeEnum } from '~/constants/error-code.constant'
|
||||
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 { ConfigsService } from '../../configs/configs.service'
|
||||
import { DEFAULT_SUMMARY_LANG } from '../ai.constants'
|
||||
import {
|
||||
GenerateAiSummaryDto,
|
||||
GetSummaryQueryDto,
|
||||
UpdateSummaryDto,
|
||||
} from './ai.dto'
|
||||
import { AiService } from './ai.service'
|
||||
import { DEFAULT_SUMMARY_LANG } from './ai.constants'
|
||||
} from './ai-summary.dto'
|
||||
import { AiSummaryService } from './ai-summary.service'
|
||||
|
||||
@ApiController('ai')
|
||||
export class AiController {
|
||||
@ApiController('ai/summaries')
|
||||
export class AiSummaryController {
|
||||
constructor(
|
||||
private readonly service: AiService,
|
||||
private readonly service: AiSummaryService,
|
||||
private readonly configService: ConfigsService,
|
||||
) {}
|
||||
|
||||
@Post('/generate-summary')
|
||||
@Post('/generate')
|
||||
@Auth()
|
||||
generateSummary(@Body() body: GenerateAiSummaryDto) {
|
||||
return this.service.generateSummaryByOpenAI(body.refId, body.lang)
|
||||
}
|
||||
|
||||
@Get('/summaries/ref/:id')
|
||||
@Get('/ref/:id')
|
||||
@Auth()
|
||||
async getSummaryByRefId(@Param() params: MongoIdDto) {
|
||||
return this.service.getSummariesByRefId(params.id)
|
||||
}
|
||||
|
||||
@Get('/summaries')
|
||||
@Get('/')
|
||||
@Auth()
|
||||
async getSummaries(@Query() query: PagerDto) {
|
||||
return this.service.getAllSummaries(query)
|
||||
}
|
||||
|
||||
@Patch('/summaries/:id')
|
||||
@Patch('/:id')
|
||||
@Auth()
|
||||
async updateSummary(
|
||||
@Param() params: MongoIdDto,
|
||||
@@ -60,13 +60,13 @@ export class AiController {
|
||||
return this.service.updateSummaryInDb(params.id, body.summary)
|
||||
}
|
||||
|
||||
@Delete('/summaries/:id')
|
||||
@Delete('/:id')
|
||||
@Auth()
|
||||
async deleteSummary(@Param() params: MongoIdDto) {
|
||||
return this.service.deleteSummaryInDb(params.id)
|
||||
}
|
||||
|
||||
@Get('/summaries/article/:id')
|
||||
@Get('/article/:id')
|
||||
async getArticleSummary(
|
||||
@Param() params: MongoIdDto,
|
||||
@Query() query: GetSummaryQueryDto,
|
||||
262
apps/core/src/modules/ai/ai-summary/ai-summary.service.ts
Normal file
262
apps/core/src/modules/ai/ai-summary/ai-summary.service.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import removeMdCodeblock from 'remove-md-codeblock'
|
||||
|
||||
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 { DEFAULT_SUMMARY_LANG, LANGUAGE_CODE_TO_NAME } from '../ai.constants'
|
||||
import { AiService } from '../ai.service'
|
||||
import { AISummaryModel } from './ai-summary.model'
|
||||
import type { PagerDto } from '~/shared/dto/pager.dto'
|
||||
|
||||
@Injectable()
|
||||
export class AiSummaryService {
|
||||
private readonly logger: Logger
|
||||
constructor(
|
||||
@InjectModel(AISummaryModel)
|
||||
private readonly aiSummaryModel: MongooseModel<AISummaryModel>,
|
||||
private readonly databaseService: DatabaseService,
|
||||
private readonly configService: ConfigsService,
|
||||
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly aiService: AiService,
|
||||
) {
|
||||
this.logger = new Logger(AiSummaryService.name)
|
||||
}
|
||||
|
||||
private cachedTaskId2AiPromise = new Map<string, Promise<any>>()
|
||||
|
||||
private serializeText(text: string) {
|
||||
return removeMdCodeblock(text)
|
||||
}
|
||||
async generateSummaryByOpenAI(
|
||||
articleId: string,
|
||||
lang = DEFAULT_SUMMARY_LANG,
|
||||
) {
|
||||
const {
|
||||
ai: { enableSummary, openAiPreferredModel },
|
||||
} = await this.configService.waitForConfigReady()
|
||||
|
||||
if (!enableSummary) {
|
||||
throw new BizException(ErrorCodeEnum.AINotEnabled)
|
||||
}
|
||||
|
||||
const openai = await this.aiService.getOpenAiClient()
|
||||
|
||||
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),
|
||||
article.document.title,
|
||||
) as Promise<any>
|
||||
|
||||
this.cachedTaskId2AiPromise.set(taskId, taskPromise)
|
||||
return await taskPromise
|
||||
|
||||
async function handle(
|
||||
this: AiSummaryService,
|
||||
id: string,
|
||||
text: string,
|
||||
title: string,
|
||||
) {
|
||||
// 等待 30s
|
||||
await redis.set(taskId, 'processing', 'EX', 30)
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `Summarize this article in ${LANGUAGE_CODE_TO_NAME[lang] || 'Chinese'} to 150 words:
|
||||
"${text}"
|
||||
|
||||
CONCISE SUMMARY:`,
|
||||
},
|
||||
],
|
||||
model: openAiPreferredModel,
|
||||
})
|
||||
|
||||
await redis.del(taskId)
|
||||
|
||||
const summary = completion.choices[0].message.content
|
||||
|
||||
this.logger.log(
|
||||
`OpenAI 生成文章 ${id} 「${title}」的摘要花费了 ${completion.usage?.total_tokens}token`,
|
||||
)
|
||||
const contentMd5 = md5(text)
|
||||
|
||||
const doc = await this.aiSummaryModel.create({
|
||||
hash: contentMd5,
|
||||
lang,
|
||||
refId: id,
|
||||
summary,
|
||||
})
|
||||
|
||||
return doc
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`OpenAI 在处理文章 ${articleId} 时出错:${error.message}`,
|
||||
)
|
||||
|
||||
throw new BizException(ErrorCodeEnum.AIException, error.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 = DEFAULT_SUMMARY_LANG) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
19
apps/core/src/modules/ai/ai-writer/ai-writer.controller.ts
Normal file
19
apps/core/src/modules/ai/ai-writer/ai-writer.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Body, Post } from '@nestjs/common'
|
||||
import { ApiController } from '~/common/decorators/api-controller.decorator'
|
||||
import { AiQueryType, GenerateAiDto } from './ai-writer.dto'
|
||||
import { AiWriterService } from './ai-writer.service'
|
||||
|
||||
@ApiController('ai/writer')
|
||||
export class AiWriterController {
|
||||
constructor(private readonly aiWriterService: AiWriterService) {}
|
||||
|
||||
@Post('generate')
|
||||
async generate(@Body() body: GenerateAiDto) {
|
||||
switch (body.type) {
|
||||
case AiQueryType.TitleSlug:
|
||||
return this.aiWriterService.generateTitleAndSlugByOpenAI(body.text)
|
||||
case AiQueryType.Title:
|
||||
return this.aiWriterService.generateSlugByTitleViaOpenAI(body.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
apps/core/src/modules/ai/ai-writer/ai-writer.dto.ts
Normal file
18
apps/core/src/modules/ai/ai-writer/ai-writer.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { IsEnum, IsString, ValidateIf } from 'class-validator'
|
||||
|
||||
export enum AiQueryType {
|
||||
TitleSlug = 'title-slug',
|
||||
Title = 'title',
|
||||
}
|
||||
|
||||
export class GenerateAiDto {
|
||||
@IsEnum(AiQueryType)
|
||||
type: AiQueryType
|
||||
|
||||
@ValidateIf((o: GenerateAiDto) => o.type === AiQueryType.TitleSlug)
|
||||
@IsString()
|
||||
text: string
|
||||
@ValidateIf((o: GenerateAiDto) => o.type === AiQueryType.Title)
|
||||
@IsString()
|
||||
title: string
|
||||
}
|
||||
55
apps/core/src/modules/ai/ai-writer/ai-writer.service.ts
Normal file
55
apps/core/src/modules/ai/ai-writer/ai-writer.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
|
||||
import { ConfigsService } from '~/modules/configs/configs.service'
|
||||
import { safeJSONParse } from '~/utils'
|
||||
import { BizException } from '~/common/exceptions/biz.exception'
|
||||
import { ErrorCodeEnum } from '~/constants/error-code.constant'
|
||||
import { AiService } from '../ai.service'
|
||||
|
||||
@Injectable()
|
||||
export class AiWriterService {
|
||||
private readonly logger: Logger
|
||||
constructor(
|
||||
private readonly configService: ConfigsService,
|
||||
|
||||
private readonly aiService: AiService,
|
||||
) {
|
||||
this.logger = new Logger(AiWriterService.name)
|
||||
}
|
||||
|
||||
async queryOpenAI(prompt: string): Promise<any> {
|
||||
const openai = await this.aiService.getOpenAiClient()
|
||||
const { openAiPreferredModel } = await this.configService.get('ai')
|
||||
const result = await openai.chat.completions.create({
|
||||
model: openAiPreferredModel,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
})
|
||||
|
||||
const content = result.choices[0].message.content || ''
|
||||
this.logger.log(
|
||||
`查询 OpenAI 返回结果:${content} 花费了 ${result.usage?.total_tokens} 个 token`,
|
||||
)
|
||||
|
||||
const json = safeJSONParse(content)
|
||||
if (!json) {
|
||||
throw new BizException(ErrorCodeEnum.AIResultParsingError)
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
async generateTitleAndSlugByOpenAI(text: string) {
|
||||
return this
|
||||
.queryOpenAI(`Please give the following text a title in the same language as the text and a slug in English, the output format is JSON. {title:string, slug:string}:
|
||||
"${text}"
|
||||
|
||||
RESULT:`)
|
||||
}
|
||||
|
||||
async generateSlugByTitleViaOpenAI(title: string) {
|
||||
return this
|
||||
.queryOpenAI(`Please give the following title a slug in English, the output format is JSON. {slug:string}:
|
||||
"${title}"
|
||||
|
||||
RESULT:`)
|
||||
}
|
||||
}
|
||||
@@ -44,3 +44,25 @@ export const LANGUAGE_CODE_TO_NAME = {
|
||||
vi: 'Vietnamese',
|
||||
zh: 'Chinese',
|
||||
}
|
||||
|
||||
export const OpenAiSupportedModels = [
|
||||
{ label: 'gpt-4-turbo', value: 'gpt-4-turbo' },
|
||||
{ label: 'gpt-4-turbo-2024-04-09', value: 'gpt-4-turbo-2024-04-09' },
|
||||
{ label: 'gpt-4-0125-preview', value: 'gpt-4-0125-preview' },
|
||||
{ label: 'gpt-4-turbo-preview', value: 'gpt-4-turbo-preview' },
|
||||
{ label: 'gpt-4-1106-preview', value: 'gpt-4-1106-preview' },
|
||||
{ label: 'gpt-4-vision-preview', value: 'gpt-4-vision-preview' },
|
||||
{ label: 'gpt-4', value: 'gpt-4' },
|
||||
{ label: 'gpt-4-0314', value: 'gpt-4-0314' },
|
||||
{ label: 'gpt-4-0613', value: 'gpt-4-0613' },
|
||||
{ label: 'gpt-4-32k', value: 'gpt-4-32k' },
|
||||
{ label: 'gpt-4-32k-0314', value: 'gpt-4-32k-0314' },
|
||||
{ label: 'gpt-4-32k-0613', value: 'gpt-4-32k-0613' },
|
||||
{ label: 'gpt-3.5-turbo', value: 'gpt-3.5-turbo' },
|
||||
{ label: 'gpt-3.5-turbo-16k', value: 'gpt-3.5-turbo-16k' },
|
||||
{ label: 'gpt-3.5-turbo-0301', value: 'gpt-3.5-turbo-0301' },
|
||||
{ label: 'gpt-3.5-turbo-0613', value: 'gpt-3.5-turbo-0613' },
|
||||
{ label: 'gpt-3.5-turbo-1106', value: 'gpt-3.5-turbo-1106' },
|
||||
{ label: 'gpt-3.5-turbo-0125', value: 'gpt-3.5-turbo-0125' },
|
||||
{ label: 'gpt-3.5-turbo-16k-0613', value: 'gpt-3.5-turbo-16k-0613' },
|
||||
]
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
|
||||
import { AiController } from './ai.controller'
|
||||
import { AiSummaryController } from './ai-summary/ai-summary.controller'
|
||||
import { AiSummaryService } from './ai-summary/ai-summary.service'
|
||||
import { AiService } from './ai.service'
|
||||
import { AiWriterService } from './ai-writer/ai-writer.service'
|
||||
import { AiWriterController } from './ai-writer/ai-writer.controller'
|
||||
|
||||
@Module({
|
||||
providers: [AiService],
|
||||
controllers: [AiController],
|
||||
providers: [AiSummaryService, AiService, AiWriterService],
|
||||
controllers: [AiSummaryController, AiWriterController],
|
||||
exports: [AiService],
|
||||
})
|
||||
export class AiModule {}
|
||||
|
||||
@@ -1,267 +1,23 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import OpenAI from 'openai'
|
||||
import removeMdCodeblock from 'remove-md-codeblock'
|
||||
|
||||
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'
|
||||
import { DEFAULT_SUMMARY_LANG, LANGUAGE_CODE_TO_NAME } from './ai.constants'
|
||||
import type { PagerDto } from '~/shared/dto/pager.dto'
|
||||
|
||||
@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 = DEFAULT_SUMMARY_LANG,
|
||||
) {
|
||||
constructor(private readonly configService: ConfigsService) {}
|
||||
public async getOpenAiClient() {
|
||||
const {
|
||||
ai: { enableSummary, openAiEndpoint, openAiKey },
|
||||
ai: { openAiEndpoint, openAiKey },
|
||||
} = await this.configService.waitForConfigReady()
|
||||
|
||||
if (!enableSummary) {
|
||||
throw new BizException(ErrorCodeEnum.AINotEnabled)
|
||||
}
|
||||
if (!openAiKey) {
|
||||
throw new BizException(ErrorCodeEnum.AIKeyExpired)
|
||||
throw new BizException(ErrorCodeEnum.AINotEnabled, 'Key not found')
|
||||
}
|
||||
const openai = new OpenAI({
|
||||
return 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),
|
||||
article.document.title,
|
||||
) as Promise<any>
|
||||
|
||||
this.cachedTaskId2AiPromise.set(taskId, taskPromise)
|
||||
return await taskPromise
|
||||
|
||||
async function handle(
|
||||
this: AiService,
|
||||
id: string,
|
||||
text: string,
|
||||
title: string,
|
||||
) {
|
||||
// 等待 30s
|
||||
await redis.set(taskId, 'processing', 'EX', 30)
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `Summarize this article in ${LANGUAGE_CODE_TO_NAME[lang] || 'Chinese'} to 150 words:
|
||||
"${text}"
|
||||
|
||||
CONCISE SUMMARY:`,
|
||||
},
|
||||
],
|
||||
model: 'gpt-3.5-turbo',
|
||||
})
|
||||
|
||||
await redis.del(taskId)
|
||||
|
||||
const summary = completion.choices[0].message.content
|
||||
|
||||
this.logger.log(
|
||||
`OpenAI 生成文章 ${id} 「${title}」的摘要花费了 ${completion.usage?.total_tokens}token`,
|
||||
)
|
||||
const contentMd5 = md5(text)
|
||||
|
||||
const doc = await this.aiSummaryModel.create({
|
||||
hash: contentMd5,
|
||||
lang,
|
||||
refId: id,
|
||||
summary,
|
||||
})
|
||||
|
||||
return doc
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`OpenAI 在处理文章 ${articleId} 时出错:${error.message}`,
|
||||
)
|
||||
|
||||
throw new BizException(ErrorCodeEnum.AIException, error.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 = DEFAULT_SUMMARY_LANG) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export const generateDefaultConfig: () => IConfig = () => ({
|
||||
algoliaSearchOptions: { enable: false, apiKey: '', appId: '', indexName: '' },
|
||||
adminExtra: {
|
||||
enableAdminProxy: true,
|
||||
title: 'おかえり~',
|
||||
|
||||
background: '',
|
||||
gaodemapKey: null!,
|
||||
},
|
||||
@@ -78,6 +78,7 @@ export const generateDefaultConfig: () => IConfig = () => ({
|
||||
enableAutoGenerateSummary: false,
|
||||
enableSummary: false,
|
||||
openAiEndpoint: '',
|
||||
openAiPreferredModel: 'gpt-3.5-turbo',
|
||||
openAiKey: '',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
} from 'class-validator'
|
||||
import { JSONSchema } from 'class-validator-jsonschema'
|
||||
|
||||
import { ChatModel } from 'openai/resources'
|
||||
import { IsAllowedUrl } from '~/decorators/dto/isAllowedUrl'
|
||||
|
||||
import { OpenAiSupportedModels } from '../ai/ai.constants'
|
||||
import { Encrypt } from './configs.encrypt.util'
|
||||
import {
|
||||
JSONSchemaArrayField,
|
||||
@@ -246,11 +248,6 @@ export class AdminExtraDto {
|
||||
@JSONSchemaPlainField('登录页面背景')
|
||||
background?: string
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@JSONSchemaPlainField('中后台标题')
|
||||
title?: string
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@SecretField
|
||||
@@ -368,6 +365,27 @@ export class AuthSecurityDto {
|
||||
}
|
||||
@JSONSchema({ title: 'AI 设定' })
|
||||
export class AIDto {
|
||||
@IsOptional()
|
||||
@JSONSchemaPasswordField('OpenAI Key')
|
||||
@IsString()
|
||||
@SecretField
|
||||
openAiKey: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@JSONSchemaPlainField('OpenAI Endpoint')
|
||||
openAiEndpoint: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@JSONSchemaPlainField('OpenAI 默认模型', {
|
||||
'ui:options': {
|
||||
type: 'select',
|
||||
values: OpenAiSupportedModels,
|
||||
},
|
||||
})
|
||||
openAiPreferredModel: ChatModel
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@JSONSchemaToggleField('可调用 AI 摘要', {
|
||||
@@ -382,15 +400,4 @@ export class AIDto {
|
||||
'此选项开启后,将会在文章发布后自动生成摘要,需要开启上面的选项,否则无效',
|
||||
})
|
||||
enableAutoGenerateSummary: boolean
|
||||
|
||||
@IsOptional()
|
||||
@JSONSchemaPasswordField('OpenAI Key')
|
||||
@IsString()
|
||||
@SecretField
|
||||
openAiKey: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@JSONSchemaPlainField('OpenAI Endpoint')
|
||||
openAiEndpoint: string
|
||||
}
|
||||
|
||||
@@ -56,7 +56,6 @@ export class PageProxyService {
|
||||
// Define the base injectData object
|
||||
const injectData: any = {
|
||||
LOGIN_BG: adminExtra.background,
|
||||
TITLE: adminExtra.title,
|
||||
WEB_URL: webUrl,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ActivityModel } from '~/modules/activity/activity.model'
|
||||
import { AISummaryModel } from '~/modules/ai/ai-summary.model'
|
||||
import { AISummaryModel } from '~/modules/ai/ai-summary/ai-summary.model'
|
||||
import { AnalyzeModel } from '~/modules/analyze/analyze.model'
|
||||
import { AuthnModel } from '~/modules/authn/authn.model'
|
||||
import { CategoryModel } from '~/modules/category/category.model'
|
||||
|
||||
@@ -37,6 +37,8 @@ export default sxzz(
|
||||
'unicorn/filename-case': 0,
|
||||
'unicorn/prefer-math-trunc': 0,
|
||||
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
|
||||
'unused-imports/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ describe('test ai client', () => {
|
||||
const client = mockRequestInstance(AIController)
|
||||
|
||||
test('POST /generate-summary', async () => {
|
||||
mockResponse('/ai/generate-summary', {}, 'post', {
|
||||
mockResponse('/ai/summaries/generate', {}, 'post', {
|
||||
lang: 'zh-CN',
|
||||
refId: '11',
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ export class AIController<ResponseWrapper> implements IController {
|
||||
}
|
||||
|
||||
async generateSummary(articleId: string, lang = 'zh-CN', token = '') {
|
||||
return this.proxy('generate-summary').post<AISummaryModel>({
|
||||
return this.proxy.summaries.generate.post<AISummaryModel>({
|
||||
params: {
|
||||
token,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user