diff --git a/apps/app/public/static/locales/en_US/translation.json b/apps/app/public/static/locales/en_US/translation.json index 019b928257f..493a13b17cb 100644 --- a/apps/app/public/static/locales/en_US/translation.json +++ b/apps/app/public/static/locales/en_US/translation.json @@ -161,6 +161,7 @@ "not_allowed_to_see_this_page": "You cannot see this page", "Confirm": "Confirm", "Successfully requested": "Successfully requested.", + "source": "Source", "input_validation": { "target": { "page_name": "Page name", diff --git a/apps/app/public/static/locales/fr_FR/translation.json b/apps/app/public/static/locales/fr_FR/translation.json index 191fd32b2c6..f4618923d1c 100644 --- a/apps/app/public/static/locales/fr_FR/translation.json +++ b/apps/app/public/static/locales/fr_FR/translation.json @@ -161,6 +161,7 @@ "not_allowed_to_see_this_page": "Vous ne pouvez pas voir cette page", "Confirm": "Confirmer", "Successfully requested": "Demande envoyée.", + "source": "Source", "input_validation": { "target": { "page_name": "Nom de la page", diff --git a/apps/app/public/static/locales/ja_JP/translation.json b/apps/app/public/static/locales/ja_JP/translation.json index 74feeaf4b2f..fa95ead2080 100644 --- a/apps/app/public/static/locales/ja_JP/translation.json +++ b/apps/app/public/static/locales/ja_JP/translation.json @@ -162,6 +162,7 @@ "not_allowed_to_see_this_page": "このページは閲覧できません", "Confirm": "確認", "Successfully requested": "正常に処理を受け付けました", + "source": "出典", "input_validation": { "target": { "page_name": "ページ名", diff --git a/apps/app/public/static/locales/zh_CN/translation.json b/apps/app/public/static/locales/zh_CN/translation.json index e2a1ce2a810..5aa62da4551 100644 --- a/apps/app/public/static/locales/zh_CN/translation.json +++ b/apps/app/public/static/locales/zh_CN/translation.json @@ -168,6 +168,7 @@ "Confirm": "确定", "Successfully requested": "进程成功接受", "copied_to_clipboard": "它已复制到剪贴板。", + "source": "消息来源", "input_validation": { "target": { "page_name": "页面名称", diff --git a/apps/app/src/components/ReactMarkdownComponents/NextLink.tsx b/apps/app/src/components/ReactMarkdownComponents/NextLink.tsx index 91a64fbc35f..9ba01faebe0 100644 --- a/apps/app/src/components/ReactMarkdownComponents/NextLink.tsx +++ b/apps/app/src/components/ReactMarkdownComponents/NextLink.tsx @@ -45,7 +45,7 @@ type Props = Omit & { export const NextLink = (props: Props): JSX.Element => { const { - id, href, children, className, ...rest + id, href, children, className, onClick, ...rest } = props; const { data: siteUrl } = useSiteUrl(); @@ -61,7 +61,7 @@ export const NextLink = (props: Props): JSX.Element => { if (isExternalLink(href, siteUrl)) { return ( - + {children} external_link ); @@ -70,13 +70,13 @@ export const NextLink = (props: Props): JSX.Element => { // when href is an anchor link or not-creatable path if (isAnchorLink(href) || !isCreatablePage(href)) { return ( - {children} + {children} ); } return ( - {children} + {children} ); }; diff --git a/apps/app/src/features/openai/chat/components/AiChatModal/MessageCard.tsx b/apps/app/src/features/openai/chat/components/AiChatModal/MessageCard.tsx index 9bfdd2a750f..91c8cf5b7df 100644 --- a/apps/app/src/features/openai/chat/components/AiChatModal/MessageCard.tsx +++ b/apps/app/src/features/openai/chat/components/AiChatModal/MessageCard.tsx @@ -1,6 +1,13 @@ +import { useCallback } from 'react'; + +import type { LinkProps } from 'next/link'; import { useTranslation } from 'react-i18next'; import ReactMarkdown from 'react-markdown'; +import { NextLink } from '~/components/ReactMarkdownComponents/NextLink'; + +import { useRagSearchModal } from '../../../client/stores/rag-search'; + import styles from './MessageCard.module.scss'; const moduleClass = styles['message-card'] ?? ''; @@ -19,8 +26,20 @@ const UserMessageCard = ({ children }: { children: string }): JSX.Element => ( const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? ''; -const AssistantMessageCard = ({ children }: { children: string }): JSX.Element => { +const NextLinkWrapper = (props: LinkProps & {children: string, href: string}): JSX.Element => { + const { close: closeRagSearchModal } = useRagSearchModal(); + + const onClick = useCallback(() => { + closeRagSearchModal(); + }, [closeRagSearchModal]); + return ( + + {props.children} + + ); +}; +const AssistantMessageCard = ({ children }: { children: string }): JSX.Element => { const { t } = useTranslation(); return ( @@ -32,7 +51,7 @@ const AssistantMessageCard = ({ children }: { children: string }): JSX.Element =
{ children.length > 0 ? ( - {children} + {children} ) : ( diff --git a/apps/app/src/features/openai/server/models/vector-store-file-relation.ts b/apps/app/src/features/openai/server/models/vector-store-file-relation.ts index 25597b38e78..4fc49f8ab63 100644 --- a/apps/app/src/features/openai/server/models/vector-store-file-relation.ts +++ b/apps/app/src/features/openai/server/models/vector-store-file-relation.ts @@ -6,7 +6,7 @@ import { getOrCreateModel } from '~/server/util/mongoose-utils'; export interface VectorStoreFileRelation { vectorStoreRelationId: mongoose.Types.ObjectId; - pageId: mongoose.Types.ObjectId; + page: mongoose.Types.ObjectId; fileIds: string[]; isAttachedToVectorStore: boolean; } @@ -19,9 +19,9 @@ interface VectorStoreFileRelationModel extends Model { } export const prepareVectorStoreFileRelations = ( - vectorStoreRelationId: Types.ObjectId, pageId: Types.ObjectId, fileId: string, relationsMap: Map, + vectorStoreRelationId: Types.ObjectId, page: Types.ObjectId, fileId: string, relationsMap: Map, ): Map => { - const pageIdStr = pageId.toHexString(); + const pageIdStr = page.toHexString(); const existingData = relationsMap.get(pageIdStr); // If the data exists, add the fileId to the fileIds array @@ -32,7 +32,7 @@ export const prepareVectorStoreFileRelations = ( else { relationsMap.set(pageIdStr, { vectorStoreRelationId, - pageId, + page, fileIds: [fileId], isAttachedToVectorStore: false, }); @@ -47,7 +47,7 @@ const schema = new Schema { await this.bulkWrite( vectorStoreFileRelations.map((data) => { return { updateOne: { - filter: { pageId: data.pageId, vectorStoreRelationId: data.vectorStoreRelationId }, + filter: { page: data.page, vectorStoreRelationId: data.vectorStoreRelationId }, update: { $addToSet: { fileIds: { $each: data.fileIds } }, }, @@ -85,7 +85,7 @@ schema.statics.upsertVectorStoreFileRelations = async function(vectorStoreFileRe // Used when attached to VectorStore schema.statics.markAsAttachedToVectorStore = async function(pageIds: Types.ObjectId[]): Promise { await this.updateMany( - { pageId: { $in: pageIds } }, + { page: { $in: pageIds } }, { $set: { isAttachedToVectorStore: true } }, ); }; diff --git a/apps/app/src/features/openai/server/routes/message.ts b/apps/app/src/features/openai/server/routes/message.ts index f4702588ab5..769ef9ca1cc 100644 --- a/apps/app/src/features/openai/server/routes/message.ts +++ b/apps/app/src/features/openai/server/routes/message.ts @@ -1,3 +1,4 @@ +import type { IUserHasId } from '@growi/core/dist/interfaces'; import { ErrorV3 } from '@growi/core/dist/models'; import type { Request, RequestHandler, Response } from 'express'; import type { ValidationChain } from 'express-validator'; @@ -15,6 +16,7 @@ import loggerFactory from '~/utils/logger'; import { MessageErrorCode, type StreamErrorCode } from '../../interfaces/message-error'; import { openaiClient } from '../services'; import { getStreamErrorCode } from '../services/getStreamErrorCode'; +import { replaceAnnotationWithPageLink } from '../services/replace-annotation-with-page-link'; import { certifyAiService } from './middlewares/certify-ai-service'; @@ -27,7 +29,9 @@ type ReqBody = { summaryMode?: boolean, } -type Req = Request +type Req = Request & { + user: IUserHasId, +} type PostMessageHandlersFactory = (crowi: Crowi) => RequestHandler[]; @@ -86,7 +90,14 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) => 'Cache-Control': 'no-cache, no-transform', }); - const messageDeltaHandler = (delta: MessageDelta) => { + const messageDeltaHandler = async(delta: MessageDelta) => { + const content = delta.content?.[0]; + + // If annotation is found + if (content?.type === 'text' && content?.text?.annotations != null) { + await replaceAnnotationWithPageLink(content, req.user.lang); + } + res.write(`data: ${JSON.stringify(delta)}\n\n`); }; diff --git a/apps/app/src/features/openai/server/services/openai.ts b/apps/app/src/features/openai/server/services/openai.ts index 05451668a2e..1939d9445d1 100644 --- a/apps/app/src/features/openai/server/services/openai.ts +++ b/apps/app/src/features/openai/server/services/openai.ts @@ -265,7 +265,7 @@ class OpenaiService implements IOpenaiService { async deleteVectorStoreFile(vectorStoreRelationId: Types.ObjectId, pageId: Types.ObjectId, apiCallInterval?: number): Promise { // Delete vector store file and delete vector store file relation - const vectorStoreFileRelation = await VectorStoreFileRelationModel.findOne({ vectorStoreRelationId, pageId }); + const vectorStoreFileRelation = await VectorStoreFileRelationModel.findOne({ vectorStoreRelationId, page: pageId }); if (vectorStoreFileRelation == null) { return; } @@ -316,7 +316,7 @@ class OpenaiService implements IOpenaiService { // Delete obsolete VectorStoreFile for await (const vectorStoreFileRelation of obsoleteVectorStoreFileRelations) { try { - await this.deleteVectorStoreFile(vectorStoreFileRelation.vectorStoreRelationId, vectorStoreFileRelation.pageId, apiCallInterval); + await this.deleteVectorStoreFile(vectorStoreFileRelation.vectorStoreRelationId, vectorStoreFileRelation.page, apiCallInterval); } catch (err) { logger.error(err); diff --git a/apps/app/src/features/openai/server/services/replace-annotation-with-page-link.ts b/apps/app/src/features/openai/server/services/replace-annotation-with-page-link.ts new file mode 100644 index 00000000000..390f8f12902 --- /dev/null +++ b/apps/app/src/features/openai/server/services/replace-annotation-with-page-link.ts @@ -0,0 +1,29 @@ +// See: https://platform.openai.com/docs/assistants/tools/file-search#step-5-create-a-run-and-check-the-output + +import type { IPageHasId, Lang } from '@growi/core/dist/interfaces'; +import type { MessageContentDelta } from 'openai/resources/beta/threads/messages.mjs'; + +import VectorStoreFileRelationModel from '~/features/openai/server/models/vector-store-file-relation'; +import { getTranslation } from '~/server/service/i18next'; + +export const replaceAnnotationWithPageLink = async(messageContentDelta: MessageContentDelta, lang?: Lang): Promise => { + if (messageContentDelta?.type === 'text' && messageContentDelta?.text?.annotations != null) { + const annotations = messageContentDelta?.text?.annotations; + for await (const annotation of annotations) { + if (annotation.type === 'file_citation' && annotation.text != null) { + + const vectorStoreFileRelation = await VectorStoreFileRelationModel + .findOne({ fileIds: { $in: [annotation.file_citation?.file_id] } }) + .populate<{page: Pick}>('page', 'path'); + + if (vectorStoreFileRelation != null) { + const { t } = await getTranslation(lang); + messageContentDelta.text.value = messageContentDelta.text.value?.replace( + annotation.text, + ` [${t('source')}: [${vectorStoreFileRelation.page.path}](/${vectorStoreFileRelation.page._id})]`, + ); + } + } + } + } +}; diff --git a/apps/app/src/features/openai/server/services/thread-deletion-cron.ts b/apps/app/src/features/openai/server/services/thread-deletion-cron.ts index b2dc8fbbaad..948fda6e58b 100644 --- a/apps/app/src/features/openai/server/services/thread-deletion-cron.ts +++ b/apps/app/src/features/openai/server/services/thread-deletion-cron.ts @@ -56,7 +56,7 @@ class ThreadDeletionCronService { try { // Random fractional sleep to distribute request timing among GROWI apps const randomMilliseconds = getRandomIntInRange(0, this.threadDeletionCronMaxMinutesUntilRequest) * 60 * 1000; - this.sleep(randomMilliseconds); + await this.sleep(randomMilliseconds); await this.executeJob(); } diff --git a/apps/app/src/features/openai/server/services/vector-store-file-deletion-cron.ts b/apps/app/src/features/openai/server/services/vector-store-file-deletion-cron.ts index 25f8ddc644f..0839730d899 100644 --- a/apps/app/src/features/openai/server/services/vector-store-file-deletion-cron.ts +++ b/apps/app/src/features/openai/server/services/vector-store-file-deletion-cron.ts @@ -56,7 +56,7 @@ class VectorStoreFileDeletionCronService { try { // Random fractional sleep to distribute request timing among GROWI apps const randomMilliseconds = getRandomIntInRange(0, this.vectorStoreFileDeletionCronMaxMinutesUntilRequest) * 60 * 1000; - this.sleep(randomMilliseconds); + await this.sleep(randomMilliseconds); await this.executeJob(); } diff --git a/apps/app/src/migrations/20241107172359-rename-pageId-to-page.js b/apps/app/src/migrations/20241107172359-rename-pageId-to-page.js new file mode 100644 index 00000000000..82f156fa06e --- /dev/null +++ b/apps/app/src/migrations/20241107172359-rename-pageId-to-page.js @@ -0,0 +1,48 @@ +import mongoose from 'mongoose'; + +import VectorStoreFileRelationModel from '~/features/openai/server/models/vector-store-file-relation'; +import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils'; +import loggerFactory from '~/utils/logger'; + + +const logger = loggerFactory('growi:migrate:rename-pageId-to-page'); + +async function dropIndexIfExists(db, collectionName, indexName) { + // check existence of the collection + const items = await db.listCollections({ name: collectionName }, { nameOnly: true }).toArray(); + if (items.length === 0) { + return; + } + + const collection = await db.collection(collectionName); + if (await collection.indexExists(indexName)) { + await collection.dropIndex(indexName); + } +} + +module.exports = { + async up(db) { + logger.info('Apply migration'); + await mongoose.connect(getMongoUri(), mongoOptions); + + // Drop index + await dropIndexIfExists(db, 'vectorstorefilerelations', 'vectorStoreRelationId_1_pageId_1'); + + // Rename field (pageId -> page) + await VectorStoreFileRelationModel.updateMany( + {}, + [ + { $set: { page: '$pageId' } }, + { $unset: ['pageId'] }, + ], + ); + + // Create index + const collection = mongoose.connection.collection('vectorstorefilerelations'); + await collection.createIndex({ vectorStoreRelationId: 1, page: 1 }, { unique: true }); + }, + + async down() { + // No rollback + }, +};