🎬 个人主页:艾莉丝努力练剑
❄专栏传送门:《C语言》《数据结构与算法》《C/C++干货分享&学习过程记录》 《Linux操作系统编程详解》《笔试/面试常见算法:从基础到进阶》《Python干货分享》
⭐️为天地立心,为生民立命,为往圣继绝学,为万世开太平
🎬 艾莉丝的简介:

文章目录
- 1 ~> AI 聊天助手前端工程需求与实现
-
- 1.1 项目背景
- 1.2 后端接口完整定义
-
- 1.2.1 获取会话列表 `GET /api/sessions`
- 1.2.2 获取可用模型 `GET /api/models`
- 1.2.3 创建新会话 `POST /api/session`
- 1.2.4 获取会话历史消息 `GET /api/session/${session_id}/history`
- 1.2.5 发送消息 – 流式 SSE 响应 `POST /api/message/async`
- 1.2.6 删除会话 `DELETE /api/session/${session_id}`
- 1.3 页面功能需求规约
-
- 1.3.1 页面布局
- 1.3.2 页面生命周期行为
- 1.3.3 快捷键
- 2 ~> 完整前端实现代码
-
- 2.1 index.html
- 2.2 style.css
- 2.3 app.js
- 3 ~> Trae AI 生成前端页面提示词工程
-
- 3.1 提示词编写原则
- 3.2 后端 C++ httplib 静态资源挂载关键代码片段
- 4 ~> 部署调试注意事项
-
- 4.1 跨域问题
- 4.2 SSE 流式解析
- 4.3 XSS 安全防护
- 4.4 第三方 CDN 依赖
- 4.5 输入校验
- 结尾

1 ~> AI 聊天助手前端工程需求与实现
1.1 项目背景
- 后端服务地址:192.168.100.238:8080,C++ httplib 实现聊天服务
- 静态资源挂载:服务将./www目录挂载为/,访问ip:port / ip:port/index.html加载前端页面
- 技术选型:HTML + CSS + JavaScript;引入 marked 解析 Markdown,highlight.js 实现代码语法高亮
- 代码分离原则:HTML、CSS、JS 拆分独立文件,降低局部修改带来的牵一发而动全身的副作用,便于接口调试、功能迭代维护
- 页面原型参考:DeepSeek 网页版聊天界面
1.2 后端接口完整定义
1.2.1 获取会话列表 GET /api/sessions
- HTTP 状态码:200 OK
- 响应 JSON 结构
{
"success": true,
"message": "string",
"data": [
{
"id": "string",
"model": "string",
"created_at": "int64_t",
"updated_at": "int64_t",
"message_count": "int",
"first_user_message": "string"
}
]
}
1.2.2 获取可用模型 GET /api/models
- HTTP 状态码:200 OK
- 响应 JSON 结构
{
"success": true,
"message": "string",
"data": [
{
"name": "string",
"desc": "string"
}
]
}
1.2.3 创建新会话 POST /api/session
- 请求体
{
"model": "string"
}
- HTTP 状态码:200 OK
- 响应 JSON 结构
{
"success": true,
"message": "string",
"data": {
"sessionid": "string",
"model": "string"
}
}
1.2.4 获取会话历史消息 GET /api/session/${session_id}/history
- HTTP 状态码:200 OK
- 响应 JSON 结构
{
"success": true,
"message": "string",
"data": [
{
"id": "string",
"role": "string",
"content": "string",
"timestamp": 0
}
]
}
1.2.5 发送消息 – 流式 SSE 响应 POST /api/message/async
- 请求体
{
"session_id": "string",
"message": "string"
}
- HTTP 状态码:200 OK
- SSE 流式返回格式:
data: 文本数据\\n\\n
data: 文本数据\\n\\n
data: [DONE]\\n\\n
- [DONE]标记流式输出结束
1.2.6 删除会话 DELETE /api/session/${session_id}
- HTTP 状态码:200 OK
- 响应 JSON 结构
{
"success": true,
"message": "string"
}
1.3 页面功能需求规约
1.3.1 页面布局
- 每条会话展示:会话更新时间、会话第一条消息、模型名称、删除按钮(按钮文本…)
- 点击删除按钮调用后端接口删除服务器对应 session 会话
- 展示图标 + 提示文本:选择一个历史对话继续,或创建新的对话,开启您的AI之旅
- 附带【新建对话】按钮
- 85% 高度为聊天消息显示区域;模型消息居左侧展示,用户消息居右侧展示;每条消息附带收发时间
- 底部输入编辑框:占位提示请输入您的消息… (Enter发送,Shift+Enter换行),最大输入 2000 字符;右侧发送按钮;下方展示当前字数/2000字数统计
- 点击新建对话触发弹窗,网格布局渲染全部可用模型,单选选择模型,展示模型名称 + 描述
- 【确认创建对话】:关闭弹窗,向后端发起创建会话请求
- 【取消】:直接关闭弹窗,不创建会话
- 同一时间仅允许选中一个模型
1.3.2 页面生命周期行为
- 流式返回内容做 Markdown 解析渲染
- 代码块启用语法高亮,代码块支持一键复制
- 处理大量流式分片,防止页面 DOM 变形乱码
1.3.3 快捷键
- Enter:发送消息
- Shift + Enter:输入框内换行
2 ~> 完整前端实现代码
文件目录:www/目录下存放三个文件:index.html、style.css、app.js;后端 httplib 将./www挂载为 web 根路径
2.1 index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI聊天助手</title>
<link rel="stylesheet" href="style.css">
<!– Markdown解析库 marked –>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!– highlight.js 代码高亮 –>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github.min.css">
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/core.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/languages/javascript.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/languages/python.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/languages/cpp.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/languages/css.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/languages/bash.min.js"></script>
</head>
<body>
<div id="app">
<!– 左侧会话侧边栏 –>
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
</div>
<span class="title">AI聊天助手</span>
<button id="newChatBtn" class="new-chat-btn" title="新建对话">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
新建对话
</button>
</div>
<div id="sessionList" class="session-list">
<div class="loading">加载中…</div>
</div>
</aside>
<!– 主内容区域 –>
<main class="main-content">
<!– 欢迎页面 –>
<div id="welcomeSection" class="welcome-section">
<div class="welcome-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
</div>
<p class="welcome-text">选择一个历史对话继续,或创建新的对话,开启您的AI之旅</p>
<button id="welcomeNewChatBtn" class="primary-btn">新建对话</button>
</div>
<!– 聊天会话区域 –>
<div id="chatSection" class="chat-section" style="display: none;">
<div id="messagesContainer" class="messages-container"></div>
<div class="input-area">
<div class="input-wrapper">
<textarea id="messageInput" class="message-input"
placeholder="请输入您的消息…(Enter发送,Shift+Enter换行)"
maxlength="2000" rows="1"></textarea>
<button id="sendBtn" class="send-btn" disabled>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
</div>
<div class="char-count">
<span id="charCount">0</span>/2000
</div>
</div>
</div>
</main>
</div>
<!– 模型选择弹窗 –>
<div id="modelModal" class="modal-overlay" style="display: none;">
<div class="modal-content">
<h3>选择模型</h3>
<div id="modelGrid" class="model-grid">
<div class="loading">加载中…</div>
</div>
<div class="modal-actions">
<button id="cancelBtn" class="secondary-btn">取消</button>
<button id="confirmBtn" class="primary-btn" disabled>确认创建对话</button>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
2.2 style.css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
color: #333;
background-color: #f7f7f8;
height: 100vh;
overflow: hidden;
}
#app {
display: flex;
height: 100vh;
}
/* ========== 侧边栏样式 ========== */
.sidebar {
width: 260px;
background-color: #202123;
color: #fff;
display: flex;
flex-direction: column;
border-right: 1px solid #2d2d2d;
}
.sidebar-header {
padding: 16px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid #2d2d2d;
}
.logo {
color: #10a37f;
display: flex;
align-items: center;
}
.title {
font-size: 16px;
font-weight: 600;
flex: 1;
}
.new-chat-btn {
background: transparent;
border: 1px solid #4d4d4f;
color: #fff;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
transition: background-color 0.2s;
}
.new-chat-btn:hover {
background-color: #2d2d2d;
}
.session-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.session-item {
padding: 12px;
border-radius: 8px;
cursor: pointer;
margin-bottom: 4px;
position: relative;
transition: background-color 0.2s;
}
.session-item:hover {
background-color: #2d2d2d;
}
.session-item.active {
background-color: #343541;
}
.session-first-msg {
font-size: 14px;
color: #ececf1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 6px;
}
.session-meta {
font-size: 12px;
color: #8e8ea0;
display: flex;
justify-content: space-between;
align-items: center;
}
.session-model {
background-color: #343541;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
}
.delete-btn {
background: none;
border: none;
color: #8e8ea0;
cursor: pointer;
font-size: 16px;
padding: 0 4px;
opacity: 0;
transition: opacity 0.2s;
}
.session-item:hover .delete-btn {
opacity: 1;
}
.delete-btn:hover {
color: #fff;
}
.loading {
text-align: center;
color: #8e8ea0;
padding: 20px;
}
/* ========== 主区域 ========== */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background-color: #ffffff;
position: relative;
}
.welcome-section {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
color: #6e6e80;
}
.welcome-icon {
color: #10a37f;
}
.welcome-text {
font-size: 16px;
text-align: center;
max-width: 400px;
line-height: 1.6;
}
.primary-btn {
background-color: #10a37f;
color: #fff;
border: none;
padding: 10px 24px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background-color 0.2s;
}
.primary-btn:hover:not(:disabled) {
background-color: #0d8a6c;
}
.primary-btn:disabled {
background-color: #a8d8cd;
cursor: not-allowed;
}
.secondary-btn {
background-color: #f0f0f0;
color: #333;
border: none;
padding: 10px 24px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.secondary-btn:hover {
background-color: #e5e5e5;
}
.chat-section {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 20px 0;
height: calc(85vh – 60px);
}
.message {
max-width: 800px;
margin: 0 auto 24px;
padding: 0 20px;
display: flex;
gap: 16px;
}
.message.user {
flex-direction: row-reverse;
}
.message-avatar {
width: 36px;
height: 36px;
border-radius: 6px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
}
.message.assistant .message-avatar {
background-color: #10a37f;
color: #fff;
}
.message.user .message-avatar {
background-color: #e5e5e5;
color: #333;
}
.message-content {
flex: 1;
min-width: 0;
}
.message-text {
line-height: 1.6;
word-wrap: break-word;
}
.message-text p {
margin-bottom: 12px;
}
.message-text p:last-child {
margin-bottom: 0;
}
.message-text pre {
background-color: #f6f8fa;
border-radius: 6px;
padding: 12px;
overflow-x: auto;
margin: 12px 0;
position: relative;
}
.message-text code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 13px;
}
.message-text pre code {
background: none;
padding: 0;
}
.message-text :not(pre) > code {
background-color: #f0f2f5;
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
}
.copy-btn {
position: absolute;
top: 8px;
right: 8px;
background: #fff;
border: 1px solid #d0d7de;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
opacity: 0;
transition: opacity 0.2s;
}
.message-text pre:hover .copy-btn {
opacity: 1;
}
.message-time {
font-size: 12px;
color: #999;
margin-top: 6px;
}
.message.user .message-time {
text-align: right;
}
/* ========== 输入区域 ========== */
.input-area {
border-top: 1px solid #e5e5e5;
padding: 16px 20px;
background-color: #fff;
}
.input-wrapper {
max-width: 800px;
margin: 0 auto;
position: relative;
border: 1px solid #d0d0d5;
border-radius: 12px;
background-color: #fff;
transition: border-color 0.2s;
}
.input-wrapper:focus-within {
border-color: #10a37f;
box-shadow: 0 0 0 2px rgba(16, 163, 127, 0.1);
}
.message-input {
width: 100%;
border: none;
outline: none;
resize: none;
padding: 12px 50px 12px 16px;
font-size: 14px;
font-family: inherit;
line-height: 1.5;
max-height: 200px;
border-radius: 12px;
background: transparent;
}
.send-btn {
position: absolute;
right: 8px;
bottom: 8px;
width: 32px;
height: 32px;
border-radius: 6px;
border: none;
background-color: #10a37f;
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.send-btn:hover:not(:disabled) {
background-color: #0d8a6c;
}
.send-btn:disabled {
background-color: #d0d0d5;
cursor: not-allowed;
}
.char-count {
max-width: 800px;
margin: 6px auto 0;
text-align: right;
font-size: 12px;
color: #999;
}
/* ========== 模型弹窗 ========== */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background-color: #fff;
border-radius: 12px;
width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.modal-content h3 {
padding: 20px 24px;
border-bottom: 1px solid #e5e5e5;
font-size: 18px;
font-weight: 600;
}
.model-grid {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.model-card {
border: 1px solid #d0d0d5;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.model-card:hover {
border-color: #10a37f;
}
.model-card.selected {
border-color: #10a37f;
background-color: rgba(16, 163, 127, 0.05);
}
.model-card input[type="radio"] {
position: absolute;
top: 16px;
right: 16px;
accent-color: #10a37f;
}
.model-name {
font-weight: 600;
margin-bottom: 8px;
padding-right: 24px;
}
.model-desc {
font-size: 13px;
color: #6e6e80;
line-height: 1.5;
}
.modal-actions {
padding: 16px 24px;
border-top: 1px solid #e5e5e5;
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: #c1c1c1;
border-radius: 3px;
}
.sidebar ::-webkit-scrollbar-thumb {
background-color: #565869;
}
2.3 app.js
// ========== 后端服务基础配置 ==========
const BASE_URL = 'http://192.168.100.238:8080';
const MAX_CHARS = 2000;
// ========== 全局状态变量 ==========
let currentSessionId = null;
let selectedModel = null;
let isSending = false;
// ========== DOM节点缓存 ==========
const elements = {
sessionList: document.getElementById('sessionList'),
newChatBtn: document.getElementById('newChatBtn'),
welcomeNewChatBtn: document.getElementById('welcomeNewChatBtn'),
welcomeSection: document.getElementById('welcomeSection'),
chatSection: document.getElementById('chatSection'),
messagesContainer: document.getElementById('messagesContainer'),
messageInput: document.getElementById('messageInput'),
sendBtn: document.getElementById('sendBtn'),
charCount: document.getElementById('charCount'),
modelModal: document.getElementById('modelModal'),
modelGrid: document.getElementById('modelGrid'),
confirmBtn: document.getElementById('confirmBtn'),
cancelBtn: document.getElementById('cancelBtn')
};
// ========== 页面初始化入口 ==========
document.addEventListener('DOMContentLoaded', init);
function init() {
// marked Markdown解析配置,对接highlight.js做代码高亮
marked.setOptions({
highlight: function (code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, {language: lang}).value;
} catch (e) {
}
}
return code;
},
breaks: true
});
loadSessions();
bindEvents();
}
// ========== 事件绑定 ==========
function bindEvents() {
elements.newChatBtn.addEventListener('click', openModelModal);
elements.welcomeNewChatBtn.addEventListener('click', openModelModal);
elements.cancelBtn.addEventListener('click', closeModelModal);
elements.confirmBtn.addEventListener('click', handleCreateSession);
elements.sendBtn.addEventListener('click', handleSendMessage);
elements.messageInput.addEventListener('keydown', handleInputKeydown);
elements.messageInput.addEventListener('input', handleInputChange);
// 点击遮罩层关闭弹窗
elements.modelModal.addEventListener('click', (e) => {
if (e.target === elements.modelModal) {
closeModelModal();
}
});
}
// ========== API请求封装 ==========
/**
* 获取全部会话列表
*/
async function fetchSessions() {
const res = await fetch(`${BASE_URL}/api/sessions`);
const data = await res.json();
if (data.success) {
return data.data;
}
throw new Error(data.message || '获取会话列表失败');
}
/**
* 获取后端支持模型列表
*/
async function fetchModels() {
const res = await fetch(`${BASE_URL}/api/models`);
const data = await res.json();
if (data.success) {
return data.data;
}
throw new Error(data.message || '获取模型列表失败');
}
/**
* 创建会话
* @param {string} model 模型名称
*/
async function createSession(model) {
const res = await fetch(`${BASE_URL}/api/session`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({model})
});
const data = await res.json();
if (data.success) {
return data.data;
}
throw new Error(data.message || '创建会话失败');
}
/**
* 删除指定会话
* @param {string} sessionId
*/
async function deleteSession(sessionId) {
const res = await fetch(`${BASE_URL}/api/session/${sessionId}`, {
method: 'DELETE'
});
const data = await res.json();
if (!data.success) {
throw new Error(data.message || '删除会话失败');
}
}
/**
* 获取会话历史消息
* @param {string} sessionId
*/
async function fetchSessionHistory(sessionId) {
const res = await fetch(`${BASE_URL}/api/session/${sessionId}/history`);
const data = await res.json();
if (data.success) {
return data.data;
}
throw new Error(data.message || '获取会话历史失败');
}
/**
* SSE流式发送消息,XMLHttpRequest实现SSE分片解析
* @param {string} sessionId
* @param {string} message 用户输入消息
* @param {Function} onMessage 分片回调
* @param {Function} onDone 结束/异常回调
*/
function sendMessageStream(sessionId, message, onMessage, onDone) {
const xhr = new XMLHttpRequest();
xhr.open('POST', `${BASE_URL}/api/message/async`);
xhr.setRequestHeader('Content-Type', 'application/json');
let received = '';
let lastPos = 0;
xhr.onprogress = function () {
received = xhr.responseText;
const newData = received.slice(lastPos);
lastPos = received.length;
const lines = newData.split('\\n\\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const content = line.slice(6);
if (content === '[DONE]') {
onDone && onDone(null);
return;
}
onMessage && onMessage(content);
}
}
};
xhr.onload = function () {
if (xhr.status !== 200) {
onDone && onDone(new Error(`HTTP ${xhr.status} 请求失败`));
}
};
xhr.onerror = function () {
onDone && onDone(new Error('网络请求异常'));
};
xhr.send(JSON.stringify({session_id: sessionId, message}));
}
// ========== 业务逻辑函数 ==========
/**
* 加载会话列表并渲染侧边栏
*/
async function loadSessions() {
try {
const sessions = await fetchSessions();
renderSessions(sessions);
} catch (e) {
elements.sessionList.innerHTML = '<div class="loading">加载失败</div>';
console.error(e);
}
}
/**
* 渲染会话侧边栏
* @param {Array} sessions
*/
function renderSessions(sessions) {
if (sessions.length === 0) {
elements.sessionList.innerHTML = '<div class="loading">暂无会话</div>';
return;
}
// 更新时间倒序排序
sessions.sort((a, b) => b.updated_at – a.updated_at);
elements.sessionList.innerHTML = sessions.map(session => `
<div class="session-item ${session.id === currentSessionId ? 'active' : ''}" data-id="${escapeHtml(session.id)}">
<div class="session-first-msg">${escapeHtml(session.first_user_message || '新对话')}</div>
<div class="session-meta">
<span class="session-model">${escapeHtml(session.model)}</span>
<span>${formatTime(session.updated_at)}</span>
<button class="delete-btn" data-id="${escapeHtml(session.id)}" title="删除">…</button>
</div>
</div>
`).join('');
// 会话点击切换事件
document.querySelectorAll('.session-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.classList.contains('delete-btn')) return;
const id = item.dataset.id;
switchToSession(id);
});
});
// 删除会话按钮事件
document.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = btn.dataset.id;
if (confirm('确定要删除这个会话吗?')) {
try {
await deleteSession(id);
if (currentSessionId === id) {
switchToWelcome();
}
loadSessions();
} catch (err) {
alert('删除失败:' + err.message);
}
}
});
});
}
/**
* 打开模型选择弹窗,加载模型列表
*/
async function openModelModal() {
elements.modelModal.style.display = 'flex';
elements.modelGrid.innerHTML = '<div class="loading">加载中…</div>';
elements.confirmBtn.disabled = true;
selectedModel = null;
try {
const models = await fetchModels();
renderModels(models);
} catch (e) {
elements.modelGrid.innerHTML = '<div class="loading">加载失败</div>';
console.error(e);
}
}
/**
* 渲染模型卡片网格
* @param {Array} models
*/
function renderModels(models) {
elements.modelGrid.innerHTML = models.map(model => `
<div class="model-card" data-name="${escapeHtml(model.name)}">
<input type="radio" name="model" value="${escapeHtml(model.name)}">
<div class="model-name">${escapeHtml(model.name)}</div>
<div class="model-desc">${escapeHtml(model.desc)}</div>
</div>
`).join('');
document.querySelectorAll('.model-card').forEach(card => {
card.addEventListener('click', () => {
document.querySelectorAll('.model-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
card.querySelector('input').checked = true;
selectedModel = card.dataset.name;
elements.confirmBtn.disabled = false;
});
});
}
/**
* 关闭模型弹窗
*/
function closeModelModal() {
elements.modelModal.style.display = 'none';
}
/**
* 确认创建会话
*/
async function handleCreateSession() {
if (!selectedModel) return;
elements.confirmBtn.disabled = true;
elements.confirmBtn.textContent = '创建中…';
try {
const result = await createSession(selectedModel);
currentSessionId = result.sessionid;
closeModelModal();
switchToChat();
loadSessions();
} catch (e) {
alert('创建会话失败:' + e.message);
} finally {
elements.confirmBtn.disabled = false;
elements.confirmBtn.textContent = '确认创建对话';
}
}
/**
* 切换到指定会话,加载历史消息
* @param {string} sessionId
*/
async function switchToSession(sessionId) {
currentSessionId = sessionId;
switchToChat();
loadSessions();
elements.messagesContainer.innerHTML = '<div class="loading">加载中…</div>';
try {
const history = await fetchSessionHistory(sessionId);
renderHistory(history);
} catch (e) {
elements.messagesContainer.innerHTML = '<div class="loading">加载历史消息失败</div>';
console.error(e);
}
}
/**
* 渲染会话全部历史消息
* @param {Array} messages
*/
function renderHistory(messages) {
elements.messagesContainer.innerHTML = '';
for (const msg of messages) {
addMessage(msg.role, msg.content, msg.timestamp);
}
scrollToBottom();
}
/**
* 切换聊天界面
*/
function switchToChat() {
elements.welcomeSection.style.display = 'none';
elements.chatSection.style.display = 'flex';
elements.messageInput.focus();
}
/**
* 切回欢迎首页
*/
function switchToWelcome() {
currentSessionId = null;
elements.welcomeSection.style.display = 'flex';
elements.chatSection.style.display = 'none';
loadSessions();
}
/**
* 发送消息主逻辑
*/
async function handleSendMessage() {
if (isSending) return;
const message = elements.messageInput.value.trim();
if (!message || !currentSessionId) return;
isSending = true;
elements.sendBtn.disabled = true;
elements.messageInput.disabled = true;
// 渲染用户消息
addMessage('user', message, Date.now());
elements.messageInput.value = '';
updateCharCount();
// AI消息占位容器
const aiMsgElement = addMessage('assistant', '', Date.now());
const msgContentEl = aiMsgElement.querySelector('.message-text');
let fullContent = '';
sendMessageStream(
currentSessionId,
message,
(chunk) => {
fullContent += chunk;
msgContentEl.innerHTML = renderMarkdown(fullContent);
addCopyButtons(msgContentEl);
scrollToBottom();
},
(err) => {
isSending = false;
elements.sendBtn.disabled = false;
elements.messageInput.disabled = false;
elements.messageInput.focus();
if (err) {
msgContentEl.innerHTML += `<p style="color: red;">出错了:${escapeHtml(err.message)}</p>`;
}
const timeEl = aiMsgElement.querySelector('.message-time');
if (timeEl) {
timeEl.textContent = formatTime(Date.now());
}
loadSessions();
}
);
scrollToBottom();
}
/**
* 添加单条消息DOM
* @param {string} role user / assistant
* @param {string} content
* @param {number} timestamp
* @returns {HTMLElement}
*/
function addMessage(role, content, timestamp) {
const msgDiv = document.createElement('div');
msgDiv.className = `message ${role}`;
const avatar = role === 'assistant' ? 'AI' : '我';
msgDiv.innerHTML = `
<div class="message-avatar">${avatar}</div>
<div class="message-content">
<div class="message-text">${renderMarkdown(content)}</div>
<div class="message-time">${formatTime(timestamp)}</div>
</div>
`;
elements.messagesContainer.appendChild(msgDiv);
addCopyButtons(msgDiv);
scrollToBottom();
return msgDiv;
}
/**
* Markdown渲染,异常降级转义HTML
* @param {string} text
* @returns {string}
*/
function renderMarkdown(text) {
if (!text) return '';
try {
return marked.parse(text);
} catch (e) {
return escapeHtml(text);
}
}
/**
* 给pre代码块附加复制按钮
* @param {HTMLElement} container
*/
function addCopyButtons(container) {
const pres = container.querySelectorAll('pre');
pres.forEach(pre => {
if (pre.querySelector('.copy-btn')) return;
const btn = document.createElement('button');
btn.className = 'copy-btn';
btn.textContent = '复制';
btn.addEventListener('click', () => {
const code = pre.querySelector('code');
if (code) {
navigator.clipboard.writeText(code.innerText).then(() => {
btn.textContent = '已复制';
setTimeout(() => btn.textContent = '复制', 2000);
});
}
});
pre.appendChild(btn);
});
}
/**
* 输入框键盘事件 Enter发送 Shift+Enter换行
* @param {KeyboardEvent} e
*/
function handleInputKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}
/**
* 输入框内容变化,更新字数、自动高度
*/
function handleInputChange() {
updateCharCount();
elements.sendBtn.disabled = !elements.messageInput.value.trim() || isSending;
elements.messageInput.style.height = 'auto';
elements.messageInput.style.height = Math.min(elements.messageInput.scrollHeight, 200) + 'px';
}
/**
* 更新输入框字数统计
*/
function updateCharCount() {
const len = elements.messageInput.value.length;
elements.charCount.textContent = len;
}
/**
* 消息容器滚动到底部
*/
function scrollToBottom() {
elements.messagesContainer.scrollTop = elements.messagesContainer.scrollHeight;
}
// ========== 通用工具函数 ==========
/**
* 时间格式化,毫秒时间戳转友好中文时间
* @param {number} timestamp
* @returns {string}
*/
function formatTime(timestamp) {
const date = new Date(timestamp);
const now = new Date();
// 今天
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'});
}
// 昨天
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() – 1);
if (date.toDateString() === yesterday.toDateString()) {
return '昨天 ' + date.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'});
}
// 本年度
if (date.getFullYear() === now.getFullYear()) {
return date.toLocaleDateString('zh-CN', {month: '2-digit', day: '2-digit'}) + ' ' +
date.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'});
}
return date.toLocaleDateString('zh-CN');
}
/**
* HTML特殊字符转义,防止XSS
* @param {string} str
* @returns {string}
*/
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
3 ~> Trae AI 生成前端页面提示词工程
3.1 提示词编写原则
- 将完整接口定义、页面交互需求、样式约束全部写入文本文件Chat前端页面需求说明.txt
- 在 Trae Builder 对话窗口,使用#引用 File 类型的需求说明 txt 文件,选择 DeepSeek 模型,Auto 模式生成代码
- 优势:避免对话框输入字数限制,完整传递全部需求,便于修改维护需求文档,无需重复粘贴大段提示词
3.2 后端 C++ httplib 静态资源挂载关键代码片段
// 设置http路由
sethttpRoutes();
// 挂载www目录为web静态资源根目录
// 访问 http://ip:port 自动映射 index.html
_chaterver->set_mount_point("/", "./www");
// 后台子线程运行http服务,不阻塞主线程
std::thread serverThread([this](){
_chaterver->listen(_config.host, _config.port);
INFO("ChatServer start on {}:{}",_config.host,_config.port);
});
serverThread.detach();
_isRunning.store(true);
INFO("ChatServer start success!!!");
- 前端三个文件全部放置服务器./www目录
- 访问路径:http://192.168.100.238:8080/index.html,简写http://192.168.100.238:8080等价访问 index.html
4 ~> 部署调试注意事项
4.1 跨域问题
- 前端直接浏览器本地打开 html 文件会触发 CORS 跨域;优先通过后端 httplib 服务访问页面
- 后端服务必须配置 CORS 响应头,允许前端页面发起 fetch / XMLHttpRequest 请求
4.2 SSE 流式解析
- 使用XMLHttpRequest处理 SSE 分片,按\\n\\n分割事件块,识别data: 前缀,遇到[DONE]结束流式会话
- 不要一次性等待 response 全部返回,依靠onprogress事件逐块处理输出
4.3 XSS 安全防护
- 所有后端返回的原始文本,渲染 DOM 前使用escapeHtml()做 HTML 转义;Markdown 渲染由 marked 库处理。
4.4 第三方 CDN 依赖
- marked、highlight.js 使用公共 CDN;离线部署场景需要将 js/css 下载放到 www 目录,修改 html 中引用路径。
4.5 输入校验
- 前端限制最大输入字符 2000;后端接口同样要做字符长度校验,不能只依赖前端校验。
结尾
uu们,本文的内容到这里就全部结束了,艾莉丝在这里再次感谢您的阅读!
|
艾莉丝努力练剑
C/C++ & Linux 底层探索者 | 一个正在努力练剑的技术博主 👀 ❤️ ⭐ 💬 不要忘记给博主“一键四连”哦! “今日练剑达成!” “技术之路难免有困惑,但同行的人会让前进更有方向。” |
结语:希望对学习Linux相关内容的uu有所帮助,不要忘记给博主“一键四连”哦!
往期回顾:
【AI大模型接入SDK】ChatServer接口测试
🗡博主在这里放了一只小狗,大家看完了摸摸小狗放松一下吧!🗡
૮₍ ˶ ˊ ᴥ ˋ˶₎ა

网硕互联帮助中心




评论前必须登录!
注册