📌 文章摘要:
本文详细介绍了如何在前端通过 Fetch 实现与 FastAPI 后端的 流式响应通信,并支持图文多模态数据上传。通过构建 multipart/form-data
请求,配合 ReadableStream
实时读取 AI 回复内容,实现类似 ChatGPT 的对话效果。同时提供完整的接口调用代码和 UI 展示逻辑,帮助开发者快速搭建自然流畅的 AI 医疗助手交互界面。
正常提问
经过RAG处理提问
项目简介
AI医疗助手是一个结合了最新人工智能技术的医疗问答系统,旨在为用户提供准确、专业的医疗咨询服务。系统采用前后端分离架构,前端使用React构建友好的用户界面,后端使用FastAPI提供高性能的API服务,并结合LangChain框架和通义千问大语言模型提供智能问答能力。本项目是本人用于学习LangChain框架的练手项目,后续会继续完善。
核心功能
- 🩺 医疗问答:针对用户的医疗问题提供专业解答
- 💬 实时对话:流式响应,打字机效果,提升用户体验
- 🧠 上下文记忆:支持多轮对话,理解上下文信息
- 📚 知识检索:基于RAG技术,从医疗知识库中检索相关信息
- 🎨 美观界面:现代化的聊天UI,支持Markdown渲染
- 🔄 对话记忆:自动保存对话历史,支持会话恢复
技术栈
前端
- 框架:React 19 + TypeScript
- 状态管理:React Hooks
- 样式:CSS Modules
- 网络请求:Fetch API(支持流式响应)
- 组件:
- 自定义聊天界面
- Markdown渲染 (react-markdown)
- 弹窗组件
- 消息提示 (react-hot-toast)
后端
- 框架:FastAPI (Python)
- AI框架:LangChain 0.3.0
- 大语言模型:通义千问 (qwen-turbo/qwen-plus/qwen-max)
- 向量数据库:Chroma
- 文本嵌入:DashScope Embeddings
- 流式响应:SSE (Server-Sent Events)
- 文档处理:LangChain Text Splitters
系统架构
✅ 2. 核心功能组件代码展示
App.css
/* 基础布局样式 */
.App {
text-align: center;
display: flex;
flex-direction: column;
height: 100vh;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8fbfd;
}
/* 头部样式 */
.App-header {
background-color: #1e88e5;
background-image: linear-gradient(135deg, #1e88e5 0%, #0d47a1 100%);
min-height: 80px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 12px 20px;
position: relative;
}
.App-header::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #29b6f6, #4fc3f7, #81d4fa);
}
.logo-container {
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
font-size: 28px;
}
.App-header h1 {
margin: 0;
font-size: 1.8rem;
font-weight: 600;
letter-spacing: 0.5px;
}
.header-subtitle {
font-size: 0.9rem;
opacity: 0.9;
margin-top: 4px;
font-weight: 400;
}
/* 主体内容样式 */
main {
flex-grow: 1;
display: flex;
flex-direction: column;
max-width: 1100px;
width: 100%;
margin: 0 auto;
padding: 0;
position: relative;
}
main>div {
flex-grow: 1;
height: 100%;
border-radius: 12px;
margin: 15px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
background-color: white;
border: 1px solid #e0e6ed;
}
/* 底部样式 */
.App-footer {
background-color: #f1f5f9;
color: #64748b;
font-size: 0.8rem;
padding: 12px;
text-align: center;
border-top: 1px solid #e2e8f0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.App-header {
padding: 10px;
min-height: 60px;
}
.App-header h1 {
font-size: 1.4rem;
}
.logo-icon {
font-size: 24px;
}
main>div {
margin: 8px;
border-radius: 8px;
}
.header-subtitle {
font-size: 0.8rem;
}
}
@media (max-width: 480px) {
.App-header h1 {
font-size: 1.2rem;
}
.logo-icon {
font-size: 20px;
}
main>div {
margin: 4px;
border-radius: 6px;
}
.header-subtitle {
display: none;
}
}
MessageList.css
.message-list {
display: flex;
flex-direction: column;
padding: 1rem;
overflow-y: auto;
flex: 1;
max-height: calc(100vh - 170px);
}
.message {
margin-bottom: 1rem;
display: flex;
flex-direction: column;
max-width: 80%;
}
.user-message {
align-self: flex-end;
}
.assistant-message {
align-self: flex-start;
}
.message-content {
padding: 0.8rem;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.user-message .message-content {
background-color: #e3f2fd;
color: #0d47a1;
}
.assistant-message .message-content {
background-color: #f5f5f5;
color: #333;
}
.message-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.8rem;
color: #666;
}
.message-role {
font-weight: bold;
}
.message-time {
font-size: 0.7rem;
}
.message-text {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
}
.empty-messages {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #9e9e9e;
text-align: center;
padding: 2rem;
}
.empty-messages p {
font-size: 1.1rem;
margin-bottom: 1rem;
}
ChatInput.css
.chat-input-form {
padding: 1rem;
border-top: 1px solid #eaeaea;
background-color: #fff;
}
.input-container {
display: flex;
position: relative;
}
.message-input {
flex: 1;
min-height: 60px;
max-height: 200px;
padding: 12px;
border: 1px solid #ccc;
border-radius: 8px;
resize: vertical;
font-family: inherit;
font-size: 1rem;
outline: none;
transition: border-color 0.3s;
}
.message-input:focus {
border-color: #2196f3;
}
.message-input:disabled {
background-color: #f9f9f9;
cursor: not-allowed;
}
.send-button {
margin-left: 10px;
padding: 0 20px;
height: 40px;
align-self: flex-end;
background-color: #2196f3;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: bold;
transition: background-color 0.3s;
}
.send-button:hover:not(:disabled) {
background-color: #0d8bf2;
}
.send-button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.input-help-text {
margin-top: 0.5rem;
font-size: 0.75rem;
color: #757575;
text-align: right;
}
ChatInterface.css
.chat-container {
display: flex;
flex-direction: column;
/* height: 100vh; */
width: 100%;
max-height: 86vh;
background-color: #f8fbfd;
position: relative;
overflow: hidden;
}
/* 聊天历史区域 */
.messages-container {
flex: 1;
overflow-y: auto;
padding: 2rem;
padding-bottom: 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
scroll-behavior: smooth;
background-image: linear-gradient(rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.9) 100%), url('data:image/svg+xml;utf8,<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="M10 10h10v10H10zM30 10h10v10H30zM50 10h10v10H50zM70 10h10v10H70zM20 20h10v10H20zM40 20h10v10H40zM60 20h10v10H60zM80 20h10v10H80zM10 30h10v10H10zM30 30h10v10H30zM50 30h10v10H50zM70 30h10v10H70z" fill="%23E3F2FD" fill-opacity="0.1"/></svg>');
}
/* 美化滚动条 */
.messages-container::-webkit-scrollbar {
width: 6px;
}
.messages-container::-webkit-scrollbar-track {
background: transparent;
}
.messages-container::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.messages-container::-webkit-scrollbar-thumb:hover {
background: #a1c4e4;
}
/* 消息气泡容器 */
.message {
max-width: 85%;
display: flex;
flex-direction: column;
position: relative;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message.user {
align-self: flex-end;
}
.message.assistant {
align-self: flex-start;
}
.message.system {
align-self: center;
max-width: 90%;
margin: 8px 0;
}
/* 消息气泡 */
.message-bubble {
padding: 14px 16px;
border-radius: 18px;
word-break: break-word;
line-height: 1.5;
position: relative;
font-size: 15px;
letter-spacing: 0.2px;
transition: all 0.2s ease;
text-align: left;
}
/* 用户消息气泡 */
.user .message-bubble {
background-color: #0d6efd;
color: white;
border-bottom-right-radius: 4px;
box-shadow: 0 2px 8px rgba(13, 110, 253, 0.2);
}
/* 助手消息气泡 */
.assistant .message-bubble {
background-color: #e9f3ff;
color: #0a2642;
border-bottom-left-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border-left: 3px solid #4dabf7;
}
.assistant .message-bubble::before {
content: '🩺';
position: absolute;
left: -30px;
top: 2px;
font-size: 16px;
color: #4dabf7;
background: white;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* 系统消息气泡 */
.system .message-bubble {
background-color: #fff3cd;
color: #856404;
border-radius: 10px;
border-left: 3px solid #ffc107;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
/* 消息信息 */
.message-info {
font-size: 12px;
color: #8e8e93;
margin-top: 4px;
padding: 0 8px;
display: flex;
align-items: center;
}
.user .message-info {
justify-content: flex-end;
}
/* 输入区域 */
.input-container {
flex-shrink: 0;
padding: 1rem;
background-color: white;
border-top: 1px solid #e0e6ed;
display: flex;
gap: 10px;
z-index: 100;
position: sticky;
bottom: 0;
left: 0;
right: 0;
width: 100%;
/* box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05); */
}
.input-container::before {
content: '';
position: absolute;
top: -20px;
left: 0;
right: 0;
height: 20px;
background: linear-gradient(to bottom, rgba(248, 251, 253, 0), rgba(248, 251, 253, 1));
pointer-events: none;
}
.input-container input {
flex: 1;
border: 1px solid #d1e3f8;
padding: 12px 16px;
border-radius: 24px;
background-color: #f8fbfd;
margin-right: 10px;
font-size: 15px;
outline: none;
transition: all 0.2s ease;
color: #0a2642;
}
.input-container input:focus {
border-color: #4dabf7;
box-shadow: 0 0 0 3px rgba(77, 171, 247, 0.2);
background-color: white;
}
.input-container input::placeholder {
color: #99b2cc;
}
.input-container button {
background-color: #0d6efd;
color: white;
border: none;
border-radius: 24px;
padding: 0 24px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
height: 42px;
}
.input-container button:hover:not(:disabled) {
background-color: #0b5ed7;
transform: translateY(-1px);
box-shadow: 0 2px 5px rgba(11, 94, 215, 0.3);
}
.input-container button:active:not(:disabled) {
transform: translateY(0);
box-shadow: none;
}
.input-container button:disabled {
background-color: #b9d7ff;
cursor: not-allowed;
}
/* 打字指示器动画 */
.typing-indicator {
display: inline-block;
position: relative;
width: 60px;
height: 24px;
}
.typing-indicator::before {
content: "";
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #4dabf7;
left: 0;
animation: typing-dot 1.4s infinite ease-in-out both;
animation-delay: -0.32s;
}
.typing-indicator::after {
content: "";
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #4dabf7;
right: 0;
animation: typing-dot 1.4s infinite ease-in-out both;
animation-delay: 0s;
}
.typing-indicator span {
position: absolute;
top: 0;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #4dabf7;
left: 22px;
animation: typing-dot 1.4s infinite ease-in-out both;
animation-delay: -0.16s;
}
@keyframes typing-dot {
0%,
80%,
100% {
transform: scale(0.7);
opacity: 0.6;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.message-bubble {
padding: 12px 14px;
font-size: 14px;
}
.assistant .message-bubble::before {
display: none;
}
.input-container {
padding: 12px;
}
.input-container input {
padding: 10px 14px;
}
.input-container button {
padding: 0 16px;
height: 38px;
}
}
@media (max-width: 480px) {
.messages-container {
padding: 15px 10px;
}
.message {
max-width: 90%;
}
.message-bubble {
padding: 10px 12px;
font-size: 14px;
}
.input-container input {
padding: 10px 12px;
}
.input-container button {
padding: 0 15px;
font-size: 14px;
}
}
.chat-header {
padding: 1rem;
background-color: #2196f3;
color: white;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
z-index: 10;
}
.chat-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 500;
}
.conversation-id {
font-size: 0.8rem;
opacity: 0.8;
}
.error-message {
padding: 0.8rem;
margin: 0.5rem 1rem;
background-color: #ffebee;
color: #c62828;
border-radius: 4px;
font-size: 0.9rem;
}
/* Markdown样式 */
.markdown-content {
margin: 0;
line-height: 1.5;
text-align: left;
}
.markdown-content p,
.markdown-content ul,
.markdown-content ol,
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
text-align: left;
}
.markdown-content p {
margin: 0 0 0.8em;
}
.markdown-content p:last-child {
margin-bottom: 0;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin-top: 1em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.25;
color: #003366;
}
.markdown-content h1 {
font-size: 1.5em;
}
.markdown-content h2 {
font-size: 1.3em;
}
.markdown-content h3 {
font-size: 1.1em;
}
.markdown-content ul,
.markdown-content ol {
margin-top: 0;
margin-bottom: 1em;
padding-left: 2em;
}
.markdown-content li {
margin: 0.3em 0;
}
.markdown-content a {
color: #0d6efd;
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
.markdown-content blockquote {
margin: 0.8em 0;
padding: 0 1em;
color: #0d47a1;
border-left: 3px solid #90caf9;
background-color: rgba(144, 202, 249, 0.1);
}
.markdown-content code {
font-family: monospace;
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
border-radius: 3px;
background-color: rgba(0, 0, 0, 0.05);
color: #d32f2f;
}
.markdown-content pre {
margin: 0.8em 0;
padding: 0.8em;
overflow: auto;
background-color: #f1f5f9;
border-radius: 4px;
}
.markdown-content pre code {
padding: 0;
background-color: transparent;
color: #0a2642;
}
.markdown-content table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
.markdown-content table th,
.markdown-content table td {
padding: 6px 12px;
border: 1px solid #e0e6ed;
text-align: left;
}
.markdown-content table th {
background-color: rgba(77, 171, 247, 0.1);
font-weight: 600;
}
.markdown-content table tr:nth-child(even) {
background-color: rgba(244, 247, 250, 0.7);
}
.markdown-content img {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 0.5em 0;
}
.markdown-content hr {
height: 1px;
margin: 1em 0;
background-color: #e0e6ed;
border: none;
}
/* 医疗相关样式 */
.markdown-content .highlight-warning {
background-color: #fff3cd;
padding: 8px 12px;
border-radius: 4px;
border-left: 3px solid #ffc107;
margin: 0.8em 0;
}
.markdown-content .highlight-info {
background-color: #e9f3ff;
padding: 8px 12px;
border-radius: 4px;
border-left: 3px solid #4dabf7;
margin: 0.8em 0;
}
/* 医疗专业术语 */
.markdown-content .medical-term {
border-bottom: 1px dashed #4dabf7;
}
/* 响应式样式 */
@media (max-width: 768px) {
.markdown-content h1 {
font-size: 1.3em;
}
.markdown-content h2 {
font-size: 1.2em;
}
.markdown-content h3 {
font-size: 1.1em;
}
.markdown-content pre {
padding: 0.6em;
}
.markdown-content blockquote {
padding: 0 0.8em;
}
}
/* 添加头部样式 */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background-color: #f0f8ff;
border-bottom: 1px solid #e0e6ed;
}
.title {
font-size: 1.5rem;
font-weight: 600;
color: #003366;
}
.actions {
display: flex;
gap: 12px;
}
/* 流式响应开关样式 */
.stream-toggle {
display: flex;
align-items: center;
}
.toggle-label {
display: flex;
align-items: center;
cursor: pointer;
font-size: 0.9rem;
color: #444;
}
.toggle-label input[type="checkbox"] {
margin-right: 6px;
width: 16px;
height: 16px;
cursor: pointer;
}
.toggle-label input[type="checkbox"]:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toggle-label input[type="checkbox"]:checked+span {
color: #0078d7;
font-weight: 500;
}
/* 消息图片样式 */
.message-image-container {
margin-bottom: 8px;
max-width: 100%;
}
.message-image {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
cursor: pointer;
}
/* 图片预览区域 */
.image-preview-container {
margin: 0 16px;
padding: 8px;
position: relative;
display: inline-block;
max-width: 150px;
margin-bottom: 8px;
}
.image-preview {
width: 100%;
max-height: 150px;
object-fit: contain;
border-radius: 8px;
border: 1px solid #e1e1e1;
}
.clear-image-button {
position: absolute;
top: 0;
right: 0;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.5);
color: white;
border: none;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.clear-image-button:hover {
background-color: rgba(0, 0, 0, 0.7);
}
/* 输入区域样式 */
.input-container {
display: flex;
padding: 12px 16px;
border-top: 1px solid #e1e1e1;
background-color: #f9f9f9;
align-items: center;
}
.input-container input[type="text"] {
flex: 1;
padding: 10px 16px;
border: 1px solid #d1d1d1;
border-radius: 20px;
font-size: 16px;
outline: none;
transition: border 0.3s;
}
.input-container input[type="text"]:focus {
border-color: #4a89dc;
}
.input-container input[type="text"].with-image {
border-color: #4a89dc;
background-color: #f0f7ff;
}
/* 图片上传按钮 */
.image-upload-button {
width: 38px;
height: 38px;
border-radius: 50%;
background-color: #f0f0f0;
border: 1px solid #d1d1d1;
margin-right: 10px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.image-upload-button svg {
fill: #5a5a5a;
width: 20px;
height: 20px;
}
.image-upload-button:hover {
background-color: #e3e3e3;
}
.image-upload-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 发送按钮 */
.send-button {
margin-left: 10px;
padding: 10px 20px;
background-color: #4a89dc;
color: white;
border: none;
border-radius: 20px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.3s;
}
.send-button:hover {
background-color: #3a79d2;
}
.send-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 流式响应开关 */
.stream-toggle {
display: flex;
align-items: center;
margin-right: 10px;
}
.toggle-label {
display: flex;
align-items: center;
cursor: pointer;
font-size: 14px;
color: #666;
}
.toggle-label input {
margin-right: 6px;
}
/* 响应式设计调整 */
@media (max-width: 600px) {
.image-upload-button {
width: 34px;
height: 34px;
}
.image-upload-button svg {
width: 18px;
height: 18px;
}
.send-button {
padding: 8px 12px;
font-size: 14px;
}
}
✅ 3. API 接口调用代码(Fetch 流式响应)
在本项目中,我们通过 FastAPI 搭建了一个支持多模态聊天(图文)和图生图的 AI 医疗助手系统。前端使用 Fetch 实现与后端的流式响应通信,实现了更自然的人机交互体验。
本文将聚焦于前端调用后端接口的 流式响应实现方式,并结合项目的接口说明,展示完整的调用代码和注意事项。
🧠 为什么选择流式响应?
传统的 HTTP 请求是在服务端处理完所有数据后一次性返回,而 流式响应(Streaming Response) 能够实现像 ChatGPT 一样 “字一个一个地出来”,提高用户体验。
流式的技术基础:
服务端使用
yield
或StreamingResponse
分块发送数据;客户端使用
Fetch + ReadableStream
实现逐块接收并展示内容。
🛠️ 后端接口回顾
POST /api/chat/multimodal
Content-Type: multipart/form-data
Form fields:
- conversation_id: string
- text: string
- image: file (optional)
Response: 流式返回 AI 回复内容
📜 前端 Fetch 调用示例(流式读取)
const controller = new AbortController(); // 用于中止请求
const signal = controller.signal;
async function chatWithAI({ conversationId, inputText, imageFile }) {
const formData = new FormData();
formData.append("conversation_id", conversationId);
formData.append("text", inputText);
if (imageFile) {
formData.append("image", imageFile);
}
const response = await fetch("http://localhost:8000/api/chat/multimodal", {
method: "POST",
body: formData,
signal,
});
if (!response.ok) {
throw new Error("请求失败: " + response.statusText);
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let resultText = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
resultText += chunk;
// 你可以在此处逐步展示文本(如更新 chat UI)
appendToChatUI(chunk);
}
return resultText;
}
📦 UI 使用场景示例
<input type="file" id="imageInput" />
<input type="text" id="textInput" placeholder="请输入咨询内容" />
<button onclick="handleSubmit()">发送</button>
<div id="chatBox"></div>
<script>
async function handleSubmit() {
const imageInput = document.getElementById("imageInput").files[0];
const text = document.getElementById("textInput").value;
const chatBox = document.getElementById("chatBox");
chatBox.innerHTML += "<div class='user-msg'>" + text + "</div>";
try {
await chatWithAI({
conversationId: "user-session-123",
inputText: text,
imageFile: imageInput
});
} catch (err) {
console.error("请求失败", err);
}
}
function appendToChatUI(textChunk) {
let botMsg = document.querySelector(".bot-msg:last-child");
if (!botMsg || botMsg.getAttribute("finished")) {
botMsg = document.createElement("div");
botMsg.className = "bot-msg";
document.getElementById("chatBox").appendChild(botMsg);
}
botMsg.innerText += textChunk;
}
</script>