1.初始化项目 #
npm create vite@latest2.安装依赖 #
npm install react-spinners --save3. 绘制布局 #
3.1. DocumentTools.tsx #
src/components/DocumentTools.tsx
import React from 'react';
import DocumentUploader from './DocumentUploader';
import DocumentViewer from './DocumentViewer';
const DocumentTools: React.FC = () => {
return (
<div>
<DocumentUploader />
<DocumentViewer />
</div>
);
};
export default DocumentTools;
3.2. DocumentUploader.tsx #
src/components/DocumentUploader.tsx
const DocumentUploader = () => {
return (
<div>
<input
type='file'
name='file-input'
id='file-input'
accept='.pdf,.txt,.json,.md'
/>
<label htmlFor='file-input'>
Upload file
</label>
<div>
{'Select a file to insert'}
</div>
<button>
Submit
</button>
</div>
);
};
export default DocumentUploader;
3.3. DocumentViewer.tsx #
src/components/DocumentViewer.tsx
const DocumentViewer = () => {
return (
<div>
<div>
<div>
<p>Upload your first document!</p>
<p>You will see the title and content here</p>
</div>
</div>
</div>
);
};
export default DocumentViewer;
3.4. App.tsx #
src/App.tsx
+import DocumentTools from './components/DocumentTools';
function App() {
return (
+ <div>
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px', padding: '20px' }}>
+ <DocumentTools />
</div>
+ </div>
+ );
}
+export default App;
3.5. main.tsx #
src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
4. 上传文件 #
4.1. insertDocument.tsx #
src/apis/insertDocument.tsx
// 定义一个异步函数insertDocument,接收一个File类型的参数file
const insertDocument = async (file: File) => {
// 创建一个FormData对象,用于构建表单数据
const formData = new FormData();
// 将文件添加到FormData对象中,字段名为'file'
formData.append('file', file);
// 发送POST请求到指定的后端接口,上传文件
const response = await fetch('http://localhost:5601/uploadFile', {
mode: 'cors', // 设置CORS模式,允许跨域请求
method: 'POST', // 请求方法为POST
body: formData, // 请求体为formData
});
// 获取响应的文本内容(异步)
const responseText = response.text();
// 返回响应文本
return responseText;
};
// 导出insertDocument函数,供其他模块使用
export default insertDocument;
4.2. DocumentUploader.tsx #
src/components/DocumentUploader.tsx
+// 导入React中的ChangeEvent类型和useState钩子
+import { type ChangeEvent, useState } from 'react';
+// 导入react-spinners库中的CircleLoader组件,用于加载动画
+import { CircleLoader } from 'react-spinners';
+// 导入自定义的insertDocument函数,用于上传文档
+import insertDocument from '../apis/insertDocument';
+// 定义HTMLInputEvent接口,继承自ChangeEvent,并指定target类型
+interface HTMLInputEvent extends ChangeEvent {
+ target: HTMLInputElement & EventTarget;
+}
+// 定义DocumentUploader组件
const DocumentUploader = () => {
+ // 定义selectedFile状态,存储用户选择的文件
+ const [selectedFile, setSelectedFile] = useState<File>();
+ // 定义isFilePicked状态,标记是否已选择文件
+ const [isFilePicked, setIsFilePicked] = useState(false);
+ // 定义isLoading状态,标记是否正在上传
+ const [isLoading, setIsLoading] = useState(false);
+ // 文件选择事件处理函数
+ const changeHandler = (event: HTMLInputEvent) => {
+ // 判断event.target和event.target.files是否存在
+ if (event.target && event.target.files) {
+ // 设置selectedFile为用户选择的第一个文件
+ setSelectedFile(event.target.files[0]);
+ // 标记已选择文件
+ setIsFilePicked(true);
+ }
+ };
+ // 文件提交处理函数
+ const handleSubmission = () => {
+ // 如果已选择文件
+ if (selectedFile) {
+ // 设置加载状态为true
+ setIsLoading(true);
+ // 调用insertDocument上传文件
+ insertDocument(selectedFile).then(() => {
+ // 上传完成后重置selectedFile
+ setSelectedFile(undefined);
+ // 重置文件选择状态
+ setIsFilePicked(false);
+ // 关闭加载动画
+ setIsLoading(false);
+ });
+ }
+ };
+ // 组件渲染部分
return (
<div>
+ {/* 文件输入框,接受pdf、txt、json、md格式文件,绑定changeHandler */}
<input
type='file'
name='file-input'
id='file-input'
accept='.pdf,.txt,.json,.md'
+ onChange={changeHandler}
/>
+ {/* 文件上传标签,点击可触发文件选择 */}
<label htmlFor='file-input'>
Upload file
</label>
+ {/* 显示已选择的文件名,否则提示选择文件 */}
<div>
+ {isFilePicked && selectedFile ? selectedFile.name : 'Select a file to insert'}
</div>
+ {/* 如果已选择文件且未加载,显示提交按钮 */}
+ {isFilePicked && !isLoading && (
+ <button onClick={handleSubmission}>
+ Submit
+ </button>
+ )}
+ {/* 如果正在加载,显示加载动画 */}
+ {isLoading && <CircleLoader />}
</div>
);
};
+// 导出DocumentUploader组件
export default DocumentUploader;
5. 文件列表 #
5.1. fetchDocuments.tsx #
src/apis/fetchDocuments.tsx
// 定义Document类型,包含id和text两个字段
export type Document = {
id: string;
text: string;
};
// 定义异步函数fetchDocuments,返回Document类型的数组
const fetchDocuments = async (): Promise<Document[]> => {
// 发送GET请求到本地5601端口的/getDocuments接口,允许跨域
const response = await fetch('http://localhost:5601/getDocuments', { mode: 'cors' });
// 如果响应状态不是ok,返回空数组
if (!response.ok) {
return [];
}
// 将响应内容解析为Document数组类型
const documentList = (await response.json()) as Document[];
// 返回文档列表
return documentList;
};
// 导出fetchDocuments函数作为默认导出
export default fetchDocuments;
5.2. DocumentTools.tsx #
src/components/DocumentTools.tsx
+// 逐行中文注释如下
+// 导入React中的useEffect和useState钩子函数
+import { useEffect, useState } from 'react';
+// 导入文档上传组件
import DocumentUploader from './DocumentUploader';
+// 导入文档查看组件
import DocumentViewer from './DocumentViewer';
+// 导入获取文档列表的API和Document类型定义
+import fetchDocuments, { type Document } from '../apis/fetchDocuments';
+// 定义DocumentTools函数组件
const DocumentTools: React.FC = () => {
+ // 定义refreshViewer状态,用于控制文档查看器是否需要刷新
+ const [refreshViewer, setRefreshViewer] = useState(false);
+ // 定义documentList状态,用于存储文档列表
+ const [documentList, setDocumentList] = useState<Document[]>([]);
+ // 组件首次挂载时,获取文档列表
+ useEffect(() => {
+ // 调用fetchDocuments获取文档数据
+ fetchDocuments().then((documents) => {
+ // 设置文档列表状态
+ setDocumentList(documents);
+ });
+ }, []);
+ // 当refreshViewer为true时,重新获取文档列表并重置refreshViewer
+ useEffect(() => {
+ // 判断refreshViewer是否为true
+ if (refreshViewer) {
+ // 重置refreshViewer为false
+ setRefreshViewer(false);
+ // 重新获取文档列表
+ fetchDocuments().then((documents) => {
+ // 更新文档列表状态
+ setDocumentList(documents);
+ });
+ }
+ }, [refreshViewer]);
+ // 渲染上传组件和查看组件
return (
<div>
+ {/* 渲染文档上传组件,并传递setRefreshViewer用于刷新文档列表 */}
+ <DocumentUploader setRefreshViewer={setRefreshViewer} />
+ {/* 渲染文档查看组件,并传递当前文档列表 */}
+ <DocumentViewer documentList={documentList} />
</div>
);
};
+// 导出DocumentTools组件,供其他模块使用
export default DocumentTools;
5.3. DocumentUploader.tsx #
src/components/DocumentUploader.tsx
// 导入React中的ChangeEvent类型和useState钩子
import { type ChangeEvent, useState } from 'react';
// 导入react-spinners库中的CircleLoader组件,用于加载动画
import { CircleLoader } from 'react-spinners';
// 导入自定义的insertDocument函数,用于上传文档
import insertDocument from '../apis/insertDocument';
// 定义HTMLInputEvent接口,继承自ChangeEvent,并指定target类型
interface HTMLInputEvent extends ChangeEvent {
target: HTMLInputElement & EventTarget;
}
+// 定义DocumentUploader组件的props类型
+type DocumentUploaderProps = {
+ setRefreshViewer: (refresh: boolean) => void;
+};
// 定义DocumentUploader组件
+const DocumentUploader = ({ setRefreshViewer }: DocumentUploaderProps) => {
// 定义selectedFile状态,存储用户选择的文件
const [selectedFile, setSelectedFile] = useState<File>();
// 定义isFilePicked状态,标记是否已选择文件
const [isFilePicked, setIsFilePicked] = useState(false);
// 定义isLoading状态,标记是否正在上传
const [isLoading, setIsLoading] = useState(false);
// 文件选择事件处理函数
const changeHandler = (event: HTMLInputEvent) => {
// 判断event.target和event.target.files是否存在
if (event.target && event.target.files) {
// 设置selectedFile为用户选择的第一个文件
setSelectedFile(event.target.files[0]);
// 标记已选择文件
setIsFilePicked(true);
}
};
// 文件提交处理函数
const handleSubmission = () => {
// 如果已选择文件
if (selectedFile) {
// 设置加载状态为true
setIsLoading(true);
// 调用insertDocument上传文件
insertDocument(selectedFile).then(() => {
+ // 刷新viewer
+ setRefreshViewer(true);
// 上传完成后重置selectedFile
setSelectedFile(undefined);
// 重置文件选择状态
setIsFilePicked(false);
// 关闭加载动画
setIsLoading(false);
});
}
};
// 组件渲染部分
return (
<div>
{/* 文件输入框,接受pdf、txt、json、md格式文件,绑定changeHandler */}
<input
type='file'
name='file-input'
id='file-input'
accept='.pdf,.txt,.json,.md'
onChange={changeHandler}
/>
{/* 文件上传标签,点击可触发文件选择 */}
<label htmlFor='file-input'>
Upload file
</label>
{/* 显示已选择的文件名,否则提示选择文件 */}
<div>
{isFilePicked && selectedFile ? selectedFile.name : 'Select a file to insert'}
</div>
{/* 如果已选择文件且未加载,显示提交按钮 */}
{isFilePicked && !isLoading && (
<button onClick={handleSubmission}>
Submit
</button>
)}
{/* 如果正在加载,显示加载动画 */}
{isLoading && <CircleLoader />}
</div>
);
};
// 导出DocumentUploader组件
export default DocumentUploader;
5.4. DocumentViewer.tsx #
src/components/DocumentViewer.tsx
+// 导入JSX类型,用于类型标注
+import type { JSX } from 'react';
+// 导入Document类型,用于文档数据类型标注
+import type { Document } from '../apis/fetchDocuments';
+// 定义文档标题最大长度常量
+const MAX_TITLE_LENGTH = 32;
+// 定义文档内容最大长度常量
+const MAX_DOC_LENGTH = 150;
+// 定义DocumentViewer组件的props类型,包含文档列表
+type DocumentViewerProps = {
+ documentList: Document[];
+};
+// 定义DocumentViewer组件,接收文档列表作为参数
+const DocumentViewer = ({ documentList }: DocumentViewerProps) => {
+ // 定义prepend函数,用于在数组前插入一个元素
+ const prepend = (array: JSX.Element[], value: JSX.Element): JSX.Element[] => {
+ // 复制原数组
+ const newArray = array.slice();
+ // 在数组前插入新元素
+ newArray.unshift(value);
+ // 返回新数组
+ return newArray;
+ };
+ // 遍历文档列表,生成对应的JSX元素数组
+ let documentListElems = documentList.map((document) => {
+ // 处理文档id,超出最大长度则截断并添加省略号
+ const id =
+ document.id.length < MAX_TITLE_LENGTH
+ ? document.id
+ : document.id.substring(0, MAX_TITLE_LENGTH) + '...';
+ // 处理文档内容,超出最大长度则截断并添加省略号
+ const text =
+ document.text.length < MAX_DOC_LENGTH
+ ? document.text
+ : document.text.substring(0, MAX_DOC_LENGTH) + '...';
+ // 返回每个文档的JSX结构
+ return (
+ <div key={document.id}>
+ <p>{id}</p>
+ <p>{text}</p>
+ </div>
+ );
+ });
+ // 在文档列表前插入标题元素
+ documentListElems = prepend(
+ documentListElems,
+ <div key='viewer_title'>
+ <p>My Documents</p>
+ </div>,
+ );
+ // 渲染组件
return (
<div>
+ {/* 如果有文档则显示文档列表,否则显示提示信息 */}
+ {documentListElems.length > 1 ? (
+ documentListElems
+ ) : (
<div>
<p>Upload your first document!</p>
<p>You will see the title and content here</p>
</div>
+ )}
</div>
);
};
+// 导出DocumentViewer组件
export default DocumentViewer;
7. 搜索 #
7.1. queryIndex.tsx #
src/apis/queryIndex.tsx
// 定义响应来源的数据类型,包括文本、文档ID、起始位置、结束位置和相似度
export type ResponseSources = {
text: string;
doc_id: string;
start: number;
end: number;
similarity: number;
};
// 定义查询响应的数据类型,包括返回文本和来源数组
export type QueryResponse = {
text: string;
sources: ResponseSources[];
};
// 定义异步函数queryIndex,接收查询字符串参数,返回Promise<QueryResponse>
const queryIndex = async (query: string): Promise<QueryResponse> => {
// 创建URL对象,指向本地查询接口
const queryURL = new URL('http://localhost:5601/query?');
// 将查询文本作为参数添加到URL中
queryURL.searchParams.append('text', query);
// 发送fetch请求,mode设置为'cors'以支持跨域
const response = await fetch(queryURL, { mode: 'cors' });
// 如果响应状态不是ok,返回错误信息和空来源数组
if (!response.ok) {
return { text: 'Error in query', sources: [] };
}
// 解析响应体为JSON,并断言为QueryResponse类型
const queryResponse = (await response.json()) as QueryResponse;
// 返回解析后的查询响应
return queryResponse;
};
// 导出queryIndex函数作为默认导出
export default queryIndex;
7.2. IndexQuery.tsx #
src/components/IndexQuery.tsx
// 引入React的useState钩子
import { useState } from 'react';
// 引入react-spinners中的CircleLoader组件,用于加载动画
import { CircleLoader } from 'react-spinners';
// 引入自定义的queryIndex函数和类型ResponseSources
import queryIndex, { type ResponseSources } from '../apis/queryIndex';
// 定义IndexQuery组件
const IndexQuery = () => {
// 定义isLoading状态,表示是否正在加载
const [isLoading, setLoading] = useState(false);
// 定义responseText状态,存储查询返回的文本
const [responseText, setResponseText] = useState('');
// 定义responseSources状态,存储查询返回的来源数组
const [responseSources, setResponseSources] = useState<ResponseSources[]>([]);
// 处理输入框回车事件的函数
const handleQuery = (e: React.KeyboardEvent<HTMLInputElement>) => {
// 如果按下的是回车键
if (e.key === 'Enter') {
// 设置加载状态为true
setLoading(true);
// 调用queryIndex函数进行查询
queryIndex(e.currentTarget.value).then((response) => {
// 查询完成后设置加载状态为false
setLoading(false);
// 设置返回的文本
setResponseText(response.text);
// 设置返回的来源数组
setResponseSources(response.sources);
});
}
};
// 根据responseSources生成对应的JSX元素数组
const sourceElems = responseSources.map((source) => {
// 如果文档ID长度大于28,截断并加省略号,否则直接显示
const nodeTitle =
source.doc_id.length > 28
? source.doc_id.substring(0, 28) + '...'
: source.doc_id;
// 如果文本长度大于150,截断为130并加省略号,否则直接显示
const nodeText =
source.text.length > 150 ? source.text.substring(0, 130) + '...' : source.text;
// 返回每个来源的JSX结构
return (
<div key={source.doc_id}>
<p>{nodeTitle}</p>
<p>{nodeText}</p>
<p>Similarity={source.similarity}, start={source.start}, end={source.end}</p>
</div>
);
});
// 组件的渲染部分
return (
<div>
{/* 查询输入区域 */}
<div>
<label htmlFor='query-text'>Ask a question!</label>
<input
type='text'
name='query-text'
placeholder='Enter a question here'
onKeyDown={handleQuery}
/>
</div>
{/* 加载动画 */}
{isLoading && <CircleLoader />}
{/* 查询结果展示区域 */}
<div>
<div>Query Response</div>
<div>{responseText || 'Enter a question to get a response...'}</div>
</div>
{/* 查询来源展示区域 */}
<div>
<div>Response Sources</div>
<div>
{/* 如果有来源则显示,否则显示暂无来源 */}
{sourceElems.length > 0 ? sourceElems : (
<div>No sources available yet</div>
)}
</div>
</div>
</div>
);
};
// 导出IndexQuery组件
export default IndexQuery;
7.3. App.tsx #
src/App.tsx
+// 引入DocumentTools组件
import DocumentTools from './components/DocumentTools';
+// 引入IndexQuery组件
+import IndexQuery from './components/IndexQuery';
+// 定义App主组件
function App() {
return (
+ // 外层div容器
<div>
+ {/* 使用CSS Grid布局,分为两列,设置间距和内边距 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px', padding: '20px' }}>
+ {/* 左侧为DocumentTools组件 */}
<DocumentTools />
+ {/* 右侧为IndexQuery组件 */}
+ <IndexQuery />
</div>
</div>
);
}
+// 导出App组件作为默认导出
export default App;