在本教程中,你将学习如何从零开始构建一个完整的RAG(检索增强生成)搜索应用程序。该应用程序允许用户上传文档、安全地存储这些文档,并利用人工智能技术进行语义搜索。

完成本指南的学习后,你将会拥有一个功能完备的应用程序,它能够:

  • 上传并处理PDF、DOCX和TXT文件
  • 将文档存储在Supabase数据库中
  • 使用OpenAI生成文档的嵌入向量
  • 对文档内容进行语义搜索
  • 根据文档内容生成人工智能生成的答案
  • 查看和管理已上传的文档

这是一个可以直接部署并使用的成熟解决方案。

目录

你将学到的内容

通过这份手册,你将学会如何:

  • 使用TypeScript搭建Next.js应用程序
  • 配置Supabase以用于数据库和文件存储
  • 集成OpenAI的嵌入模型与聊天辅助功能
  • 实现文档文本的提取与分割处理
  • 利用PostgreSQL构建向量搜索系统
  • 使用React组件打造现代化用户界面
  • 处理文件的上传与存储操作
  • 实现RAG(检索增强生成)搜索技术

先决条件

在开始学习之前,请确保你具备以下条件:

  • 你的电脑上已安装Node.js 18或更高版本
  • 拥有一个Supabase账户(免费套餐即可使用)
  • 拥有一枚OpenAI API密钥
  • 具备React和TypeScript的基础知识
  • 对Next.js有一定了解(有帮助但非必需)

理解相关技术

在开始构建应用程序之前,你需要先了解将会使用到的关键技术与概念:

什么是RAG(检索增强生成)?

RAG是一种结合信息检索与文本生成的人工智能技术。它不会仅依赖人工智能模型的训练数据,而是会从用户自身的文档中提取相关信息,然后利用这些信息来生成准确、最新的答案。这种技术具有以下优势:

  • 准确性:答案是基于用户的实际文档生成的,而不仅仅是人工智能模型的训练数据
  • 透明度:用户可以清楚地看到哪些文档内容被用于生成答案
  • 效率:只有相关的文档片段才会被使用,从而有效降低计算成本

什么是嵌入模型与向量数据库?

嵌入模型是一种能够捕捉文本语义意义的数值表示方法。当文本被转换为嵌入格式后,含义相似的文本会对应相似的数值。例如,“dog”和“puppy”的嵌入值应该是相近的,而“dog”和“airplane”的嵌入值则会有很大差异。

OpenAI的嵌入模型会将文本转换成向量形式——这些由数字组成的数组可以进行数学运算。通过这种机制,我们可以找到与搜索查询在语义上相似的文档,即使这些文档并不包含完全相同的词语。

向量数据库是一种专为高效存储和检索嵌入数据而设计的数据库。它不会直接查找精确匹配的文本内容,而是利用数学运算来找出语义最接近的结果。例如,它会使用余弦相似度这样的算法来进行检索。

在本教程中,您将使用 Supabase 提供的带 pgvector 扩展功能的 PostgreSQL 数据库。该扩展为 PostgreSQL 增加了向量存储和相似性搜索功能,这使得您能够将嵌入信息与常规数据库数据一起存储,并且还能快速进行相似性查询。

什么是文本分块?

文本分块是指将大型文档拆分成较小、更易于管理的部分。这样做有以下几个原因:

首先,AI 模型的输入长度是有限制的。其次,将文档分解成较小的部分有助于提高检索精度。第三,通过分块处理,可以确保在数据边界处不会丢失上下文信息。

您将使用 LangChain 的 RecursiveCharacterTextSplitter 工具来智能地分割文本,同时尽量保留句子和段落的结构。

什么是 Supabase?

Supabase 是一个开源的、可作为 Firebase 替代方案的数据库系统。它具备许多重要特性:

首先,它提供了功能强大的开源关系型数据库 PostgreSQL;其次,它具有类似 AWS S3 的文件存储功能;此外,它还支持实时数据订阅功能,可让您随时获取数据库中的最新变化;最后,它内置了用户认证机制。

在这个项目中,您将使用 Supabase 来存储文本分块信息及嵌入数据,同时也会利用 Supabase 的文件存储功能来保存用户上传的原始文件。

什么是 Tailwind CSS?

Tailwind CSS 是一个以实用工具为核心的 CSS 框架。您可以直接在 HTML/JSX 代码中使用预先定义好的样式类来为应用程序添加样式,而无需编写自定义 CSS 代码。例如,您可以使用 bg-blue-600text-whiterounded-lg 这样的类来设置元素的样式。

在这个项目中选择使用 Tailwind CSS,是因为它提供了许多现成的样式工具,能够显著提升开发效率;同时,它还能确保整个应用程序的设计风格保持一致,并且便于构建响应式、现代风格的用户界面。另外,Tailwind CSS 也与 Next.js 配合得非常好。

现在您已经了解了我们将要使用的核心概念和工具,那么让我们开始着手构建这个应用程序吧。

项目概述

您的 RAG 搜索应用程序将由以下部分组成:

  1. 前端:使用 Next.js 构建的网站,其中包含用于文件上传和搜索的 React 组件
  2. 后端 API 路由:Next.js 提供的后端 API 路由,用于处理文件上传、搜索以及文档管理操作
  3. 数据库:使用带有向量存储扩展功能的 Supabase PostgreSQL 数据库来存储嵌入信息
  4. 文件存储:利用 Supabase 的文件存储服务来保存用户上传的原始文件
  5. AI 技术集成:使用 OpenAI 生成嵌入数据并实现聊天功能

该应用程序将包含两个主要页面:

  • 搜索页面:用户可以在该页面上就自己上传的文档提出问题,并获得由人工智能生成的答案。
  • 文档页面:用户可以在此页面查看所有已上传的文档,上传新文档,预览文件内容,以及管理自己的文档库。

让我们开始动手构建吧!

如果你在编写源代码时遇到困难,可以在 GitHub 上查看相关代码:

https://github.com/mayur9210/rag-search-app

步骤 1:创建你的 Next.js 项目

首先,使用 TypeScript 创建一个新的 Next.js 项目。打开终端并运行以下命令:

npx create-next-app@latest rag-search-app --typescript --tailwind --app

按照提示选择相应的选项:

  • 是否使用 TypeScript:是
  • 是否启用 ESLint:是
  • 是否使用 Tailwind CSS:是
  • 是否使用应用路由器:是(默认选项)
  • 是否自定义导入别名:否

进入你的项目目录:

cd rag-search-app

现在项目已经设置完成,接下来你需要安装一些用于处理文档、集成人工智能技术以及进行数据库操作的额外包。

步骤 2:安装所需的依赖包

这个项目需要几个特定的包,你可以使用 npm 来安装它们:

npm install @supabase/supabase-js @langchain/openai @langchain/textsplitters langchain openai mammoth pdf2json

下面介绍一下这些包的功能:

    • @supabase/supabase-js:用于与 Supabase 数据库进行交互的客户端库。
    • @langchain/openai:将 LangChain 与 OpenAI 结合起来,帮助处理文本相关任务。
    • @langchain/textsplitters:用于将文档分割成较小部分的工具。
    • langchain:LangChain 的核心库,提供了许多人工智能相关工作流程所需的工具。
    • openai:OpenAI 官方提供的 SDK,用于生成嵌入向量以及完成聊天对话。
    • mammoth:可以将 DOCX 文件转换为纯文本格式。
    • pdf2json:可以从 PDF 文件中提取文本内容。

还需要为 pdf2json 安装 TypeScript 类型定义文件:

npm install --save-dev @types/pdf-parse

所有依赖包都安装完成后,你就可以开始配置你的 Supabase 项目了,这个项目将会负责处理你的数据库和文件存储需求。

步骤 3:配置你的 Supabase 项目

创建一个 Supabase 项目

首先,你需要创建一个新的 Supabase 项目,具体操作步骤如下:

      1. 前往supabase.com,登录或创建一个账户。
      2. 点击“新项目”。
      3. 填写项目详细信息:
        • 名称:rag-search-app(或您喜欢的任何名称)
        • 数据库密码:选择一个强密码(请记住这个密码,后续会用到它)。
        • 地区:选择离您最近的地区。
      4. 点击“创建新项目”,然后等待系统完成准备(这个过程需要几分钟时间)。

获取您的Supabase登录凭据

当您的项目准备就绪后,前往设置选项,然后选择API

复制以下信息:

      • 项目URL(这就是您的NEXT_PUBLIC_SUPABASE_URL
      • 匿名公钥(这就是您的NEXT_PUBLIC_supABASE_PUBLISHABLE_DEFAULT_KEY
      • service_role密钥(这就是您的SUPABASE_SERVICE_ROLE_KEY

重要提示:请务必保密您的service_role密钥,切勿在客户端代码中暴露它。该密钥可用于绕过服务器端的行级安全策略,但绝对不能用于浏览器代码中。

设置数据库结构

现在您需要配置数据库结构,以便存储文档和嵌入向量数据。请在Supabase控制台中进入SQL编辑器,然后运行以下SQL语句:

-- 启用用于存储嵌入向量的扩展功能
-- 此扩展使PostgreSQL能够高效地存储和检索向量数据
CREATE EXTENSION IF NOT EXISTS vector;

-- 创建文档表
-- 该表用于存储文档片段、元数据以及嵌入向量
CREATE TABLE documents (
  id BIGSERIAL PRIMARY KEY,
  content TEXT NOT NULL,
  metadata JSONB,
  embedding vector(1536)  -- OpenAI的text-embedding-3-small生成的向量维度为1536
  file_path text NULL,
  file_url text NULL,
);

-- 为嵌入向量列创建索引,以加快相似性搜索速度
-- ivfflat索引能够显著提升向量相似性查询的效率
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops);

-- 创建一个根据相似性匹配文档的函数
-- 该函数会找出与查询嵌入向量最相似的文档片段
CREATE OR REPLACE FUNCTION match_documents(
  query_embedding vector(1536),
  match_threshold float,
  match_count int
)
RETURNS TABLE (
  id bigint,
  content text,
  metadata jsonb</span),
  similarity float
)
LANGUAGE plpgsql
AS $$
BEGIN
  RETURN QUERY
  SELECT
    documents.id,
    documents.content,
    documents.metadata,
    1 - (documentsembedding <=> query_embedding) AS similarity
  FROM documents
  WHERE 1 - (documentsembedding <=> query_embedding) > match_threshold
  ORDER BY documents.embedding <=> query_embedding
  LIMIT match_count;
END;
$$;

这段SQL代码的作用如下:

      • 启用向量扩展功能:这为PostgreSQL添加了向量存储和相似性搜索的功能。
      • 创建文档表:用于存储文档片段、元数据(文件名、类型等)以及它们的嵌入向量。
      • 创建索引:可以加快对嵌入向量的相似性搜索速度。
      • 生成匹配函数:利用余弦相似度来查找与查询嵌入向量最为相似的文档片段。

<=>运算符用于计算向量之间的余弦距离。距离越小,表示内容越相似。

配置Supabase存储空间

你需要一个存储桶来保存上传的文件。这个存储桶与数据库是分开的,用于存放原始的PDF、DOCX和TXT文件。

要配置存储桶,请按照以下步骤操作:

      1. 进入Supabase控制面板中的存储选项。
      2. 点击新建存储桶
      3. 将其命名为documents
      4. 将访问权限设置为公开(这样就可以下载文件了)。
      5. 点击创建存储桶

如果你更喜欢使用私有存储桶,也可以使用服务角色密钥来进行服务器端操作,这样可以绕过行级安全策略。但对于本教程来说,使用公开存储桶会更简单、也更适用。

现在你的Supabase项目已经配置完成,接下来你需要设置环境变量,以便将Next.js应用程序与Supabase及OpenAI连接起来。

步骤4:配置环境变量

在项目根目录下创建一个.env.local文件:

NEXT_PUBLIC_supABASE_URL=你的Supabase项目地址
NEXT_PUBLICSUPABASE_PUBLISHABLE_DEFAULT_KEY=你的Supabase匿名访问密钥
SUPABASE_SERVICE_ROLE_KEY=你的Supabase服务角色密钥
OPENAI_API_KEY=你的OpenAI API密钥

请用你自己的实际信息替换这些占位符。

        • 在Supabase控制面板的设置API中获取Supabase的相关配置信息。
        • platform.openai.com/apikeys处获取你的OpenAI API密钥。

安全提示:切勿将.env.local文件提交到版本控制系统中。默认情况下,这个文件已经被添加到了.gitignore文件中,但请再次确认以确保你的敏感信息得到妥善保护。

环境配置完成后,你就可以开始构建处理文件上传、搜索和文档管理的API路由了。

步骤5:创建文件上传API路由

现在你要创建用于处理文件上传的API路由。这个路由会接收上传的文件,提取其中的文本内容,将其分割成多个片段,生成嵌入向量,然后把这些数据存储到数据库和存储桶中。

Create 文件 `src/app/api/upload/route.ts`:

import { createClient } from '@supabase/supabase-js';
import OpenAI from 'openai';
import { NextResponse } from 'next/server';
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
import mammoth from 'mammoth';

const url = process.env.NEXT_PUBLICSUPABASE_URL!;
const anonKey = process.env.NEXTPUBLIC_supABASE_PUBLISHABLE_DEFAULT_KEY!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const supabaseStorage = createClient(url, serviceKey || anonKey);
const supabase = import supabaseStorage;
const chunks = await supabase.splitText(text);

// 将文件上传到 Supabase Storage
const fileBuffer = Buffer.from(await file.arrayBuffer());
const { error: storageError } = await supabase.storage
  .from('documents')
  .upload(filePath, fileBuffer, {
        contentType: file.type || 'application/octet-stream',
        upsert: false,
      });

if (storageError) {
  const msg = storageError.message || '未知的存储错误';
  if (msg.includes('row-level security') || msg.includes('RLS')) {
    return NextResponse.json({ 
      success: false, 
      error: `存储 RLS 错误:${msg}。请确保设置了 SUPABASE_SERVICE_ROLE_KEY`。 
    }, { status: 500 });
  }
  return NextResponse.json({ 
    success: false, 
    error: `无法存储文件:${msg}` 
  }, { status: 500 });
}

// 获取文件的公共 URL
const { data: urlData } = supabase.storage
  .from('documents')
  .getPublicUrl(filePath);

从文件中提取文本
const text = await extractTextFromFile(file);
if (!text || text.trim().length === 0) {
  return NextResponse.json({ 
    error: `无法从文件中提取文本` 
  }, { status: 400 });
}

将文本分割成多个片段
每个片段的长度为 800 个字符,相邻片段有 100 个字符的重叠部分,这样
在片段边界处就不会丢失上下文信息
const textSplitter = new RecursiveCharacterTextSplitter({
  chunkSize: 800,
  chunkOverlap: 100,
});
const chunks = await textSplitter.splitText(text);

处理每个片段:生成嵌入向量并存储到数据库中
for (let i = 0; i < chunks.length; i++) {
  const chunk = chunks[i];

  使用 OpenAI 生成嵌入向量
  这将把文本片段转换成一个 1536 维的向量
  const emb = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: chunk,
  });

  将片段及其嵌入向量存储到数据库中
  const { error } = await supabase.from('documents').insert({
    content: chunk,
    metadata: { 
      source: file.name,
      document_id: documentId,
      file_name: file.name,
      file_type: file.type || file.name.split('.').pop(),
      file_size: file.size,
      upload_date: uploadDate,
      chunk_index: i,
      total_chunks: chunks.length,
      file_path: filePath,
      file_url: urlData.publicUrl,
    },
    embedding: JSON.stringify(emb.data[0].embedding),
  });

  if (error) {
    return NextResponse.json({ 
      success: false, 
      error: error.message 
    }, { status: 500 });
  }
}

return NextResponse.json({ 
  success: true, 
  documentId, 
  fileName: file.name, 
  chunks: chunks.length, 
  textLength: text.length, 
  fileUrl: urlData.publicUrl 
});

这条路由负责完成整个上传流程:

        1. 通过FormData从客户端接收文件
        2. 使用crypto.randomUUID()生成唯一的文档ID
        3. 将文件上传到Supabase存储系统中进行保存
        4. 根据文件类型(PDF、DOCX或TXT)提取文本内容
        5. 将文本分割成每段800个字符的长度,且相邻段落之间有100个字符的重叠部分
        6. 使用OpenAI的嵌入模型为每个文本片段生成嵌入向量
        7. 将每个文本片段及其嵌入向量和元数据存储到数据库中

这种分段方式能够确保当某个句子或概念跨越了段落边界时,也不会被遗漏。现在既然我们已经能够上传和处理文档了,接下来就来创建搜索功能吧。

步骤6:创建RAG搜索API路由

这条路由实现了RAG的核心功能:它接收用户的查询请求,找到最相关的文档片段,并利用这些片段生成准确的答案。

创建文件src/app/api/search/route.ts

import { createClient } from '@supabase/supabase-js';
import OpenAI from 'openai';
import { NextResponse } from 'next/server';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXTPUBLIC_supABASE_PUBLISHABLE_DEFAULT_KEY!
);
const openai = new OpenAI();

export async function POST(req: Request) {
  try {
    const { query } = await req.json();

    // 为用户的查询生成嵌入向量
    // 这会将搜索查询转换成与文档片段相同的向量空间
    const emb = await openai.embeddings.create({ 
      model: 'text-embedding-3-small', 
      input: query 
    });

    // 使用向量相似度搜索找到相似的文档片段
    // match_documents函数会找出5个最相似的文档片段
    const { data: results, error } = await supabase.rpc('matchdocuments', {
      query_embedding: JSON.stringify(emb.data[0].embedding),
      match_threshold: 0.0,  // 可以调整这个阈值来提高匹配的严格程度
      match_count: 5,        // 返回5个最相似的文档片段
    });

    if (error) {
      return NextResponse.json({ error: error.message }, { status: 500 });
    }

    // 将找到的文档片段组合起来形成上下文
    // 这些片段将被用作AI生成答案的依据
    const context = results?.map((r: any) => r.content).join('\n---\n') || '';

    // 使用OpenAI和生成的上下文来生成答案
    ·这就是RAG中的“生成”环节
    const completion = await openai.chat.completions.create({
      model: 'gpt-4o-mini',
      messages: [
        { 
          role: 'system', 
          content: '你是一个有帮助的助手。请利用提供的上下文来回答问题。如果答案不在上下文中,就回答“不知道”。 
        },
        { 
          role: 'user', 
          content: `上下文:${context}\n问题:${query}` 
        }
      ],
    });

    return NextResponse.json({ 
      answer: completion.choices[0].message.content, 
      sources: results 
    });
  } catch (error: any) {
    return NextResponse.json({ error: error.message }, { status: 500 });
  }
}

这条路由采用了RAG模式。下面是整个RAG工作流程的详细说明:

        1. 将查询转换为嵌入向量:用户提出的问题会被转换成与文档片段相同的向量空间表示形式。这里使用的是与处理文档时相同的嵌入模型(text-embedding-3-small),从而确保它们处于同一个“向量空间”中。
        2. 搜索相似的文档片段:通过match_documents函数找出5个语义上最相似的文档片段。这一过程会利用嵌入向量之间的余弦相似度来进行判断;余弦相似度用于衡量向量之间的夹角,角度越小表示内容越相似,即使具体的词汇有所不同。
        3. 使用这些片段作为上下文:找到的文档片段会被作为上下文传递给GPT-4o-mini模型。这些片段包含了文档中最相关的信息。
        4. 生成答案:AI模型会根据提供的上下文生成答案。系统会提示AI仅依据所提供的上下文来生成答案,从而确保答案的准确性并避免产生错误内容。
        5. 返回结果:系统会同时返回答案和对应的文档片段,以便用户可以核对信息。

这种RAG方法具有多种优势:


export async FUNCTION NAME(req: Request) { try { const id = new URL(req.url).searchParams.get('id'(); if (!id) { return NextResponse.json({ error: Document ID is required }, { status: 400 }); } Retrieve file path from metadata const { data: docs } = await supabase .from('documents') .select('metadata') .eq('metadata->>document_id', id) .limit(1); const filePath = docs?.[0]?.metadata?.file_path; Delete file from storage if (filePath) { await supabaseStorage.storage.from('documents').remove([filePath]); } Delete all chunks from database const { error } = await supabase .from('documents') .delete() .eq('metadata->>document_id', id); if (error) { return NextResponse.json({ error: error.message }, { status: 500 }); } return NextResponse.json({ success: true, fileDeleted: !!filePath });
catch (error: any) { return NextResponse.json({ error: error.message }, { status: 500 }); }}

此路由负责处理以下操作:

  • 不带ID的GET请求:列出所有文档(由于每个文档由多个部分组成,因此会去除重复项)
  • 带ID的GET请求:返回文档详情及完整内容(所有部分合并后显示)
  • 带ID且file参数为true的GET请求:从存储中下载原始文件
  • 带ID的DELETE请求:从存储和数据库中删除文档及其文件

现在您的API路由已经配置完成,接下来让我们开始构建用户界面组件,首先从上传模态框入手。

步骤8:创建上传模态框组件

上传模态框为用户提供了便捷的文档选择与上传接口。它负责处理文件选择、上传进度显示,以及错误或成功信息的呈现。

创建文件 `src/app/components/UploadModal.tsx`:


'use client';
import { useState, useEffect } from 'react';

interface UploadModalProps {
  isOpen: boolean;
  onClose: () => void;
  onUploadSuccess?: () => void;
}

export default UploadModal({isOpen, onClose, onUploadSuccess}: UploadModalProps) ,
  const [file, setFile] = useState<File | null>(null;
  const [uploading, setUploading] = useState(false</span});
  const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null</span);

  useEffect(function() {, [file, uploading, message]) {
    if (!file && !uploading) {
      return;
    }
    setMessage(`Uploading and processing…`);
  });

  function handleFileChange(e) {
    const fileSelected = e.target.files[0];
    if (fileSelected) {
      setFile(fileSelected);
      alert(`Selected file: ${fileSelected.name}`);
    }
  };

  return (
    

 

该组件提供了一个全屏模态窗口,用于查看PDF文件及提取出的文本,并提供了标签页以便在预览模式和文本内容显示模式之间切换。现在,让我们创建一个简单的导航组件,将所有这些功能整合在一起。

步骤10:创建导航组件

导航组件让用户能够轻松访问搜索页面和文档页面。它会高亮显示当前页面,并提供简洁、一致的导航体验。

创建文件 `src/app/components/Navigation.tsx`:

'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';

export default function Navigation() </span}{
  const pathname = usePathname();

  const navItems = [
    { href: '/', label: '搜索' },
    { href: '/documents', label: '文档' },
  ];

  return (
>

“max-w-7xl mx-auto px-4 sm:px-6 lg:px-8”>

“flex space-x-8”> {navItems.map((item) => ( `py-4 px-1 border-b-2 font-medium text-sm ${ pathname === item.href ? ‘border-blue-500 text-blue-600 dark:text-blue-400’ : ‘border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300’ }`} > {item.label} ))}

 

); }

既然导航系统已经搭建完成,接下来我们就来创建主搜索页面,让用户能够查询他们的文档。

步骤11:创建首页(搜索界面)

搜索页面是用户针对自己上传的文档提出问题并获取答案的主要界面。该页面会显示人工智能生成的答案以及相关引用信息,让用户能够核实这些内容的真实性。

更新 `src/app/page.tsx`:

'use client';
import { useState } from 'react';
import Navigation from './components/Navigation';

export default function Home() {
  const [query, setQuery] = useState(''</span});
  const [answer, setAnswer] = useState('';
  const [loading, setLoading] = useState(false</span});
  const [sources, setSources] = useState<any[]&gt>];

  const handleSearch = async () => {
    if (!query.trim()) return;
    setLoading(true); 
    setAnswer(''); 
    setSources [];
    try {
      const res = await fetch('/api/search', { 
        method: 'POST', 
        headers: { 'Content-Type': 'application/json' }, 
        body: JSON.stringify({ query }) 
      });
      const data = await res.json();
      if (data.error) {
        setAnswer(`Error: ${data.error}`</span});
      } else { 
        setAnswer(data.answer || 'No answer generated'); 
        setSources(data.sources || []); 
      }
    } catch (error: any) {
      setAnswer(`Error: ${error.message}`</span});
    } finally {
      setLoading(false;
    }
  };

  const handleKeyPress = (e: React_keyboardEvent) => {
    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
      handleSearch();
    }
  };

  return (
Comments are closed.