Просмотр исходного кода

Merge remote-tracking branch 'origin/master' into feat/ai-detail-answer-mode

Yuki Takei 1 год назад
Родитель
Сommit
4d0ce7d5f6

+ 1 - 0
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",

+ 1 - 0
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",

+ 1 - 0
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": "ページ名",

+ 1 - 0
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": "页面名称",

+ 4 - 4
apps/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -45,7 +45,7 @@ type Props = Omit<LinkProps, 'href'> & {
 
 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 (
-      <a id={id} href={href} className={className} target="_blank" rel="noopener noreferrer" {...dataAttributes}>
+      <a id={id} href={href} className={className} target="_blank" onClick={onClick} rel="noopener noreferrer" {...dataAttributes}>
         {children}&nbsp;<span className="growi-custom-icons">external_link</span>
       </a>
     );
@@ -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 (
-      <a id={id} href={href} className={className} {...dataAttributes}>{children}</a>
+      <a id={id} href={href} className={className} onClick={onClick} {...dataAttributes}>{children}</a>
     );
   }
 
   return (
     <Link {...rest} href={href} prefetch={false} legacyBehavior>
-      <a href={href} className={className} {...dataAttributes}>{children}</a>
+      <a href={href} className={className} {...dataAttributes} onClick={onClick}>{children}</a>
     </Link>
   );
 };

+ 21 - 2
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 (
+    <NextLink href={props.href} onClick={onClick} className="link-primary">
+      {props.children}
+    </NextLink>
+  );
+};
+const AssistantMessageCard = ({ children }: { children: string }): JSX.Element => {
   const { t } = useTranslation();
 
   return (
@@ -32,7 +51,7 @@ const AssistantMessageCard = ({ children }: { children: string }): JSX.Element =
         <div>
           { children.length > 0
             ? (
-              <ReactMarkdown>{children}</ReactMarkdown>
+              <ReactMarkdown components={{ a: NextLinkWrapper }}>{children}</ReactMarkdown>
             )
             : (
               <span className="text-thinking">

+ 8 - 8
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<VectorStoreFileRelation> {
 }
 
 export const prepareVectorStoreFileRelations = (
-    vectorStoreRelationId: Types.ObjectId, pageId: Types.ObjectId, fileId: string, relationsMap: Map<string, VectorStoreFileRelation>,
+    vectorStoreRelationId: Types.ObjectId, page: Types.ObjectId, fileId: string, relationsMap: Map<string, VectorStoreFileRelation>,
 ): Map<string, VectorStoreFileRelation> => {
-  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<VectorStoreFileRelationDocument, VectorStoreFileRelati
     ref: 'VectorStore',
     required: true,
   },
-  pageId: {
+  page: {
     type: Schema.Types.ObjectId,
     ref: 'Page',
     required: true,
@@ -64,14 +64,14 @@ const schema = new Schema<VectorStoreFileRelationDocument, VectorStoreFileRelati
 });
 
 // define unique compound index
-schema.index({ vectorStoreRelationId: 1, pageId: 1 }, { unique: true });
+schema.index({ vectorStoreRelationId: 1, page: 1 }, { unique: true });
 
 schema.statics.upsertVectorStoreFileRelations = async function(vectorStoreFileRelations: VectorStoreFileRelation[]): Promise<void> {
   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<void> {
   await this.updateMany(
-    { pageId: { $in: pageIds } },
+    { page: { $in: pageIds } },
     { $set: { isAttachedToVectorStore: true } },
   );
 };

+ 13 - 2
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<undefined, Response, ReqBody>
+type Req = Request<undefined, Response, ReqBody> & {
+  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`);
       };
 

+ 2 - 2
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<void> {
     // 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);

+ 29 - 0
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<void> => {
+  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<IPageHasId, 'path' | '_id'>}>('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})]`,
+          );
+        }
+      }
+    }
+  }
+};

+ 1 - 1
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();
       }

+ 1 - 1
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();
       }

+ 48 - 0
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
+  },
+};