1. index.html
javascript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gemini Chatbot</title>
<!-- Linking Google Fonts for Icons -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0,0" />
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- 整个聊天机器人的主容器 -->
<div class="container">
<!-- App Header -->
<!-- h1 和 h2 作为标题和副标题,向用户打招呼并引导提问 -->
<header class="app-header">
<h1 class="heading">Hello, there</h1>
<h2 class="sub-heading">How can I help you?</h2>
</header>
<!-- Suggestions List -->
<!-- 这是一个无序列表,包含几个预设的用户问题,方便用户快速选择 -->
<ul class="suggestions">
<li class="suggestions-item">
<p class="text">How does a Large Language Model work?</p>
<span class="material-symbols-rounded">lightbulb</span>
</li>
<li class="suggestions-item">
<p class="text">What are the best practices for fine-tuning an LLM?</p>
<span class="material-symbols-rounded">code</span>
</li>
<li class="suggestions-item">
<p class="text">How can I use an LLM for text summarization?</p>
<span class="material-symbols-rounded">summarize</span>
</li>
<li class="suggestions-item">
<p class="text">What are the ethical concerns of deploying LLMs?</p>
<span class="material-symbols-rounded">gavel</span>
</li>
</ul>
<!-- Chats Container -->
<!-- 聊天消息(用户输入 & 机器人回复),JavaScript 代码会动态填充此部分 -->
<div class="chats-container">
</div>
<!-- Prompt Container -->
<div class="prompt-container">
<!-- 用于容纳用户输入框和相关的功能按钮 -->
<div class="prompt-wrapper">
<form action="#" class="prompt-form">
<!-- 文本输入框,用户可以输入问题 -->
<input type="text" placeholder="Ask Gemini" class="prompt-input" required>
<div class="prompt-actions">
<!-- File Upload Wrapper -->
<div class="file-upload-wrapper">
<!-- 用于预览上传的文件 -->
<img src="#" class="file-preview">
<!-- 用于上传图片、PDF、文本文件等,但默认隐藏 -->
<input type="file" accept="image/*, .pdf, .txt, .csv", id="file-input" hidden>
<button type="button" class="file-icon material-symbols-rounded">
description
</button>
<button id="add-file-btn" type="button" class="material-symbols-rounded">
attach_file
</button>
<button id="cancel-file-btn" type="button" class="material-symbols-rounded">
close
</button>
</div>
<!-- 用于停止 AI 生成的回复。 -->
<button type="button" id="stop-response-btn" class="material-symbols-rounded">
stop_circle
</button>
<!-- 提交用户的问题 -->
<button id="send-prompt-btn" class="material-symbols-rounded">
arrow_upward
</button>
</div>
</form>
<!-- 用于切换明暗模式 -->
<button id="theme-toggle-btn" class="material-symbols-rounded">
light_mode
</button>
<!-- 用于清除聊天记录 -->
<button id="delete-chats-btn" class="material-symbols-rounded">
delete
</button>
</div>
<!-- 免责声明 -->
<p class="disclaimer-text">Gemini can make mistakes, so double-check it.</p>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
这个 HTML 代码是一个 AI 聊天机器人网页前端界面,具有以下功能:
- 聊天输入框:用户可以输入问题并发送。
- 预设问题:提供几个示例问题,用户可以直接选择。
- 聊天历史容器:用于动态显示用户和 AI 之间的对话。
- 文件上传:支持上传图片、PDF、文本等文件。
- 操作按钮:发送问题、停止 AI 回复、切换深色/浅色模式、清空聊天记录
- Material Icons 设计:界面美观,使用 Google Material Symbols 作为按钮图标。
2. style.css
css
/* Importing Google Fonts - Poppins */
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: "Poppins", sans-serif;
}
/* 控制文本、背景、滚动条等颜色 */
:root {
/* Dark theme colors */
--text-color: #edf3ff;
--subheading-color: #97a7ca;
--placeholder-color: #c3cdde;
--primary-color: #101623;
--secondary-color: #283045;
--secondary-hover-color: #333e58;
--scrollbar-color: #626a7f;
}
body.light-theme {
/* Light theme colors */
--text-color: #090c13;
--subheading-color: #7b8cae;
--placeholder-color: #606982;
--primary-color: #f3f7ff;
--secondary-color: #dce6f9;
--secondary-hover-color: #d2ddf2;
--scrollbar-color: #a2aac2;
}
/* 字体颜色和背景由主题变量控制 */
body {
color: var(--text-color);
background: var(--primary-color);
}
.container {
overflow-y: auto;
/* 当内容超出容器高度时,显示纵向滚动条 */
padding: 32px 0 60px;
max-height: calc(100vh - 127px);
scrollbar-color: var(--scrollbar-color) transparent;
/* 定义滚动条颜色,轨道为透明,滑块颜色由变量--scrollbar-color决定。 */
}
.container :where(.app-header, .suggestions, .message, .prompt-wrapper, .disclaimer-text) {
margin: 0 auto;
/* 水平居中对齐 */
width: 100%;
padding: 0 20px;
max-width: 980px;
}
/* App header stylings */
.container .app-header {
margin-top: 4vh;
}
.app-header .heading {
font-size: 3rem;
width: fit-content;
background: linear-gradient(to right, #1d7efd, #8f6fff);
/* 使用线性渐变背景(从#1d7efd到#8f6fff),方向从左到右 */
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
/* 背景裁剪至文本区域,并通过透明填充实现渐变文字效果。 */
}
.app-header .sub-heading {
font-size: 2.6rem;
margin-top: -5px;
color: var(--subheading-color);
}
/* Suggestions list stylings */
.container .suggestions {
display: flex;
/* 使用flex布局,使子元素水平排列。 */
gap: 15px;
margin-top: 9.5vh;
list-style: none;
overflow-x: auto;
scrollbar-width: none;
/* 水平内容超出时允许滚动,并隐藏滚动条 */
}
/* 当body元素具有chats-active类时,.container内的.app-header和.suggestions元素会被隐藏 */
body.chats-active .container :where(.app-header, .suggestions){
display: none;
}
.suggestions .suggestions-item {
width: 228px;
padding: 18px;
flex-shrink: 0;
display: flex;
cursor: pointer;
flex-direction: column;
/* 使用弹性布局,方向为列 */
align-items: flex-end;
justify-content: space-between;
border-radius: 12px;
background: var(--secondary-color);
transition: 0.3s ease;
}
.suggestions .suggestions-item:hover {
background: var(--secondary-hover-color)
}
.suggestions .suggestions-item .text {
font-size: 1.1rem;
}
.suggestions .suggestions-item span {
height: 45px;
width: 45px;
margin-top: 35px;
display: flex;
align-self: flex-end;
align-items: center;
justify-content: center;
border-radius: 50%;
color: #1d7efd;
background: var(--primary-color);
}
.suggestions .suggestions-item:nth-child(2) span {
color: #28a745;
}
.suggestions .suggestions-item:nth-child(3) span {
color: #ffc107;
}
.suggestions .suggestions-item:nth-child(4) span {
color: #6f42c1;
}
/* Chats container stylings */
.container .chats-container {
display: flex;
gap: 20px;
flex-direction: column;
}
.chats-container .message {
display: flex;
gap: 11px;
align-items: center;
}
.chats-container .bot-message .avatar {
height: 43px;
width: 43px;
flex-shrink: 0;
/* 禁止头像在Flex布局中缩小 */
padding: 6px;
align-self: flex-start;
/* 将头像对齐到Flex容器的起始位置 */
margin-right: -7px;
border-radius: 50%;
/* 设置头像的圆角为50%,使其呈现圆形 */
background: var(--secondary-color);
border: 1px solid var(--secondary-hover-color);
}
/* 当聊天机器人正在加载时,头像会旋转 */
.chats-container .bot-message.loading .avatar {
animation: rotate 3s linear infinite;
}
/* 在3秒内线性地完成一次360度的旋转,并无限循环 */
@keyframes rotate {
100% {
transform: rotate(360deg);
}
}
.chats-container .message .message-text {
padding: 3px 16px;
word-wrap: break-word;
/* 允许长单词或URL地址换行到下一行。 */
white-space: pre-line;
}
.chats-container .bot-message {
margin: 9px auto;
}
.chats-container .user-message {
flex-direction: column;
align-items: flex-end;
}
.chats-container .user-message .message-text {
padding: 12px 16px;
max-width: 75%;
border-radius: 13px 13px 3px 13px;
background: var(--secondary-color);
}
.chats-container .user-message .img-attachment {
width: 50%;
margin-top: -7px;
border-radius: 13px 3px 13px 13px;
}
.chats-container .user-message .file-attachment {
display: flex;
gap: 6px;
align-items: center;
padding: 10px;
margin-top: -7px;
border-radius: 13px 3px 13px 13px;
background: var(--secondary-color);
}
.chats-container .user-message .file-attachment span {
color: #1d7efd;
}
/* Prompt container stylings */
.prompt-container {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
padding: 16px 0;
background: var(--primary-color);
}
.prompt-container :where(.prompt-wrapper, .prompt-form, .prompt-actions){
display: flex;
gap: 12px;
height: 56px;
align-items: center;
}
.prompt-wrapper .prompt-form {
width: 100%;
height: 100%;
border-radius: 130px;
background: var(--secondary-color);
}
.prompt-form .prompt-input {
height: 100%;
width: 100%;
background: none;
outline: none;
border: none;
font-size: 1rem;
padding-left: 24px;
color: var(--text-color);
}
.prompt-form .prompt-input::placeholder {
color: var(--placeholder-color);
}
.prompt-wrapper button {
width: 56px;
height: 100%;
border: none;
cursor: pointer;
border-radius: 50%;
font-size: 1.4rem;
flex-shrink: 0;
color: var(--text-color);
background: var(--secondary-color);
transition: 0.3s ease;
}
.prompt-wrapper :is(button:hover, .file-icon, #cancel-file-btn) {
background: var(--secondary-hover-color);
}
.prompt-form .prompt-actions {
gap: 5px;
margin-right: 7px;
}
.prompt-wrapper .prompt-form :where(.file-upload-wrapper, button, img) {
position: relative;
height: 45px;
width: 45px;
}
.prompt-form #send-prompt-btn {
color: #fff;
display: none;
/* 隐藏按钮,使其在页面上不可见 */
background: #1d7efd;
}
/* 当 .prompt-form 容器中的 .prompt-input 元素通过有效性验证时,
其后续兄弟元素 .prompt-actions 内的 #send-prompt-btn 按钮将显示为块级元素 */
.prompt-form .prompt-input:valid ~ .prompt-actions #send-prompt-btn {
display: block;
}
.prompt-form #send-prompt-btn:hover {
background: #0264e3;
}
.prompt-form .file-upload-wrapper :where(button, img) {
position: absolute;
border-radius: 50%;
object-fit: cover;
display: none;
}
.prompt-form .file-upload-wrapper #add-file-btn,
.prompt-form .file-upload-wrapper.active.img-attached img,
.prompt-form .file-upload-wrapper.active.file-attached .file-icon,
.prompt-form .file-upload-wrapper.active:hover
#cancel-file-btn {
display: block;
}
/* 当.prompt-form .file-upload-wrapper中的#add-file-btn存在时,显示该按钮。 */
/* 当.file-upload-wrapper处于.active.img-attached状态时,显示其中的img元素。 */
/* 当.file-upload-wrapper处于.active.file-attached状态时,显示其中的.file-icon元素。 */
/* 当.file-upload-wrapper.active被悬停时,显示#cancel-file-btn按钮。 */
.prompt-form .file-upload-wrapper.active #add-file-btn {
display: none;
}
.prompt-form :is(#cancel-file-btn, #stop-response-btn:hover) {
color: #d62939;
}
/* 当鼠标悬停在ID为stop-response-btn的元素上,或者针对ID为cancel-file-btn的元素时,
将这些元素的文字颜色设置为红色 */
.prompt-form .file-icon {
color: #1d7efd;
}
.prompt-form #stop-response-btn,
body.bot-responding .prompt-form .file-upload-wrapper {
display: none;
}
body.bot-responding .prompt-form #stop-response-btn {
display: block;
}
.prompt-container .disclaimer-text {
text-align: center;
font-size: 0.9rem;
padding: 16px 20px 0;
color: var(--placeholder-color);
}
/* Responsive media query code for small screens */
@media (max-width: 768px) {
.container {
padding: 20px 0 100px;
}
.app-header :is(.heading, .sub-heading) {
font-size: 2rem;
line-height: 1.4;
}
.prompt-form .file-upload-wrapper.active #cancel-file-btn {
opacity: 0;
}
.prompt-wrapper.hide-controls :where(#theme-toggle-btn, #delete-chats-btn){
display: none;
}
/* 当 .prompt-wrapper 具有 hide-controls 类时,#theme-toggle-btn 和 #delete-chats-btn 隐藏 */
}
3. script.js
javascript
const container = document.querySelector('.container'); // 聊天容器
const chatsContainer = document.querySelector('.chats-container'); // 存储所有聊天消息的容器
const promptForm = document.querySelector('.prompt-form'); // 用户输入表单
const promptInput = promptForm.querySelector('.prompt-input'); // 输入框
const fileInput = promptForm.querySelector('#file-input'); // 文件上传输入框
const fileUploadWrapper = promptForm.querySelector('.file-upload-wrapper'); // 文件上传的 UI 包装容器
const themeToggle = document.querySelector('#theme-toggle-btn');
// API Setup
const API_KEY = "";
const API_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${API_KEY}`;
let typingInterval, controller; // 全局变量,作用域覆盖整个脚本
const chatHistory = [];
const userData = { message: "", file: {} }; // 存储当前用户消息和上传的文件
// Function to create message elements
// 接收内容和多个类名作为参数,创建div元素后,为其添加类名并设置内容
const createMsgElement = (content, ...classes) => {
const div = document.createElement('div');
div.classList.add("message", ...classes)
div.innerHTML = content;
return div;
}
// Scroll to the bottom of the container
// 让聊天窗口自动滚动到底部,以保持最新消息可见
// 将容器的滚动条位置设置为容器的最大滚动高度,即滚动到容器的最底部
const scrollToBottom = () => container.scrollTo({ top: container.scrollHeight, behavior: "smooth"});
// Simulate typing effect for bot responses
const typingEffect = (text, textElement, botMsgDiv) => {
textElement.textContent = "";
const words = text.split(" ");
let wordIndex = 0;
// Set an interval to type each word
// 每隔 40 毫秒执行一次
typingInterval = setInterval(() => {
if(wordIndex < words.length){
textElement.textContent += (wordIndex === 0 ? "" : " ") + words[wordIndex++];
document.body.classList.add("bot-responding");
scrollToBottom();
// 为 body 添加 bot-responding 类,表示机器人正在响应,并调用 scrollToBottom 滚动到容器底部
} else {
// 如果所有单词都显示完毕,清除定时器
clearInterval(typingInterval);
botMsgDiv.classList.remove("loading"); // remove the loading animation only after all the words are typed
document.body.classList.remove("bot-responding");
}
}, 40);
}
// Make the API call and generate the bot's response
const generateResponse = async (botMsgDiv) => {
const textElement = botMsgDiv.querySelector(".message-text");
controller = new AbortController(); // 用于控制请求的中断
// Add user message and file data to the chat history
chatHistory.push({
role: "user",
parts: [{ text: userData.message },
...(userData.file.data ?
[{ inline_data: (({ fileName, isImage, ...rest }) => rest)(userData.file) }] : [])]
});
// 从 userData.file 对象中提取除 fileName 和 isImage 之外的其他属性,并将其赋值给 inline_data
try {
// Send the chat history to the API to get a response
const response = await fetch(API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contents: chatHistory }),
signal: controller.signal // attach the controller to terminate the fetch request
});
const data = await response.json();
if(!response.ok) throw new Error(data.error.message);
// Process the response text and display with typing effect
const responseText = data.candidates[0].content.parts[0].text.replace(/\*\*([^*]+)\*\*/g, "$1").trim();
// 匹配以 ** 开头和结尾的内容,([^*]+) 捕获 ** 之间的内容。
typingEffect(responseText, textElement, botMsgDiv);
chatHistory.push({
role: "model",
parts: [{ text: responseText }]
});
} catch (error) {
textElement.style.color = "#d62939";
textElement.textContent = error.name === "AbortError" ? "Response generation stopped." : error.message;
botMsgDiv.classList.remove("loading"); // remove the loading animation only after all the words are typed
document.body.classList.remove("bot-responding");
scrollToBottom();
} finally {
userData.file = {};
}
}
// Handle the form submission
const handleFormSubmit = (e) => {
// 阻止表单的默认提交行为
e.preventDefault();
// 从输入框中获取用户输入并去除首尾空格
const userMessage = promptInput.value.trim();
// 如果用户输入为空或机器人正在响应,则直接返回
if(!userMessage || document.body.classList.contains("bot-responding")) return;
// 将输入框的值清空
promptInput.value = "";
// 将用户输入的消息存储到 userData 对象中
userData.message = userMessage;
// 机器人正在响应且聊天处于活跃状态
document.body.classList.add("bot-responding", "chats-active");
// 移除文件上传相关的 UI 状态类
fileUploadWrapper.classList.remove("active", "img-attached", "file-attached");
// Generate user message HTML with optional file attachment
// 根据用户输入和文件附件生成用户消息的 HTML 内容
const userMsgHTML = `
<p class="message-text"></p>
${userData.file.data ? (userData.file.isImage ?
`<img src="data:${userData.file.mime_type};base64,${userData.file.data}" class="img-attachment" />` :
`<p class="file-attachment"><span class="material-symbols-rounded">description</span>${userData.file.fileName}</p>`
) : ""}
`;
// 创建用户消息的 DOM 元素,并将其添加到聊天容器中。
const userMsgDiv = createMsgElement(userMsgHTML, "user-message");
userMsgDiv.querySelector(".message-text").textContent = userMessage;
chatsContainer.appendChild(userMsgDiv);
scrollToBottom(); // 使聊天窗口自动滚动到底部,确保最新消息可见。
// 在 600 毫秒后生成机器人消息的 HTML 内容,并将其添加到聊天容器中
setTimeout(() => {
// Generate bot message HTML and add in the chats container after 600ms
const botMsgHTML = `<img src="gemini-chatbot-logo.svg" class="avatar"><p class="message-text">Just a sec..</p>`;
const botMsgDiv = createMsgElement(botMsgHTML, "bot-message", "loading");
chatsContainer.appendChild(botMsgDiv);
scrollToBottom();
generateResponse(botMsgDiv);
}, 600);
}
// Handle file input change (file upload)
fileInput.addEventListener("change", () => {
// 从文件输入框中获取用户选择的第一个文件
const file = fileInput.files[0];
if(!file) return;
// 检查文件是否为图片类型
const isImage = file.type.startsWith("image/");
// 使用 FileReader 对象读取文件内容,并将其转换为 Data URL 格式
const reader = new FileReader();
reader.readAsDataURL(file);
// 处理读取完成事件
reader.onload = (e) => {
// 清空文件输入框的值
fileInput.value = "";
// 将 Data URL 转换为 Base64 字符串
const base64String = e.target.result.split(",")[1]
// 更新文件预览的 src 属性,显示文件内容
fileUploadWrapper.querySelector(".file-preview").src = e.target.result;
// 根据文件类型(图片或非图片)为文件上传包装容器添加相应的类名
fileUploadWrapper.classList.add("active", isImage ? "img-attached" : "file-attached");
// Store file data in userData obj
userData.file = {fileName: file.name, data: base64String, mime_type: file.type, isImage }
}
});
// Cancel file upload
document.querySelector("#cancel-file-btn").addEventListener("click", () => {
// 清空文件数据
userData.file = {};
// 移除文件上传 UI 状态
fileUploadWrapper.classList.remove("active", "img-attached", "file-attached");
})
// Stop ongoing bot response
document.querySelector("#stop-response-btn").addEventListener("click", () => {
// 清空文件数据
userData.file = {};
// 中止 API 请求
controller?.abort();
// 清除机器人打字效果的定时器,停止打字动画
clearInterval(typingInterval)
// 移除加载状态
chatsContainer.querySelector(".bot-message.loading").classList.remove("loading");
// 移除机器人响应状态
document.body.classList.remove("bot-responding");
})
// Delete all chats
document.querySelector("#delete-chats-btn").addEventListener("click", () => {
chatHistory.length = 0;
chatsContainer.innerHTML = "";
document.body.classList.remove("bot-responding", "chats-active");
})
// Handle suggestions click
document.querySelectorAll(".suggestions-item").forEach(item => {
item.addEventListener("click", () => {
// 当用户点击某个建议项时,将该建议项的内容填充到输入框中
promptInput.value = item.querySelector(".text").textContent;
// 触发表单的 submit 事件,从而模拟用户提交表单的行为,启动聊天流程
promptForm.dispatchEvent(new Event("submit"));
})
})
// Show/hide controls for mobile on prompt input focus
document.addEventListener("click", ({ target}) => {
const wrapper = document.querySelector(".prompt-wrapper");
// 如果点击的目标是输入框,则需要隐藏控制按钮。
const shouldHide = target.classList.contains("prompt-input") ||
(wrapper.classList.contains("hide-controls") &&
(target.id === "add-file-btn" || target.id === "stop-response-btn"));
// 如果控制按钮已经隐藏 (wrapper.classList.contains("hide-controls")),
// 并且点击的目标是"添加文件"按钮 (target.id === "add-file-btn")
// 或"停止响应"按钮 (target.id === "stop-response-btn"),也需要隐藏控制按钮。
wrapper.classList.toggle("hide-controls", shouldHide);
// 切换控制按钮的显示状态
});
// Toggle dark/light theme
themeToggle.addEventListener("click", () => {
// 如果 light-theme 类被添加到 body 元素中,则 isLightTheme 的值为 true,表示当前是浅色主题。
const isLightTheme = document.body.classList.toggle("light-theme");
// 将当前主题状态(isLightTheme)存储到 localStorage
localStorage.setItem("themeColor", isLightTheme ? "light_mode" : "dark_mode")
// 更新按钮文本
themeToggle.textContent = isLightTheme ? "dark_mode" : "light_mode";
});
// Set initial theme from local storage
// 从本地存储中获取主题状态
const isLightTheme = localStorage.getItem("themeColor") === "light_mode";
document.body.classList.toggle("light-theme", isLightTheme);
themeToggle.textContent = isLightTheme ? "dark_mode" : "light_mode";
// 当用户提交表单时,调用 handleFormSubmit 函数。
promptForm.addEventListener("submit", handleFormSubmit);
// 模拟用户点击文件输入框,打开文件选择对话框
promptForm.querySelector("#add-file-btn").addEventListener("click", () => fileInput.click());
// 用户选择文件后,触发 fileInput 的 change 事件,执行文件上传的相关逻辑。
这段代码实现了:
✅ AI 聊天交互
✅ 打字机效果
✅ 文件上传支持
✅ 深色/浅色模式
✅ 响应管理和错误处理