/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
文章目录
-
- 一、渲染层的真实挑战
- 二、Markdown 渲染器:占位符保护策略
-
- 2.1 列表解析的小巧思
- 2.2 表格解析
- 2.3 还原占位符(代码块 + 行内代码)
- 三、语法高亮器:一次遍历的正则扫描
- 四、单条消息的渲染与流式增量更新
- 五、事件委托:一个监听器撑起整套 UI
- 六、快捷指令:输入 `/` 触发的命令面板
- 七、主题切换与数据备份
- 八、全局快捷键与下拉交互的真实实现
- 九、安全小结:纯前端产品的三道防线
- 九、会话时间分组与友好时间显示
- 十、导出为图片:html2canvas 的可选降级
- 十一、欢迎屏与快捷操作入口
- 十二、结语:这套代码能怎么扩展
一、渲染层的真实挑战
第一篇解决了"怎么把消息发给模型、怎么把回复流式拿回来"。但拿回来的只是一段** Markdown 文本**——里面可能混着 # 标题、**加粗**、表格、还有大量 ```````````代码块。把它直接塞进 innerHTML 会有两个灾难:
难点在于:代码块里的特殊字符要原样展示(但需转义 <>),而代码块外的 Markdown 要被解析成 HTML。如果先做 Markdown 解析再转义,会破坏代码高亮要用的标签;如果先转义再解析,Markdown 的正则又会误伤转义后的实体。AI Nexus 的 MarkdownRenderer 用一套"占位符保护 → 转义 → 解析 → 还原"的流程干净地解决了这个矛盾。
二、Markdown 渲染器:占位符保护策略
看 markdown.js 的 render() 入口。它的核心设计是:先把代码块、行内代码抽出来用私有占位符(Unicode 私用区字符)替换掉,等文本转义和 Markdown 解析都做完后,再把它们按原样还原。
const MarkdownRenderer = {
render(text) {
if (!text) return '';
const PH_START = '\\uE000'; // Unicode 私用区起点
const PH_END = '\\uE001'; // Unicode 私用区终点
// 第1步:提取并保护代码块
const codeBlocks = [];
text = text.replace(/```(\\w*)\\n?([\\s\\S]*?)```/g, (_, lang, code) => {
const id = codeBlocks.length;
codeBlocks.push({ lang: lang || '', code: code.replace(/\\n$/, '') });
return PH_START + 'CB' + id + PH_END;
});
// 第2步:提取并保护行内代码
const inlineCodes = [];
text = text.replace(/`([^`]+)`/g, (_, code) => {
const id = inlineCodes.length;
inlineCodes.push(code);
return PH_START + 'IC' + id + PH_END;
});
// 第3步:HTML转义
text = this.escapeHtml(text);
// 第4步:处理Markdown语法(标题/加粗/列表/表格……)
text = text.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
text = text.replace(/^### (.+)$/gm, '<h3>$1</h3>');
// … 其余 Markdown 规则
}
};
几个设计要点:
- 占位符用 \\uE000/\\uE001:这是 Unicode 私用区(Private Use Area),正常文本里几乎不会出现,因此不会和用户内容冲突。占位符格式如 \\uE000CB0\\uE001,CB 表示代码块,0 是索引。
- 正则用非贪婪 \\s\\S*?:/```(\\w*)\\n?([\\s\\S]*?)```/g 能跨行匹配且只吃到第一个闭合的 ```````````,避免吞掉多个代码块。
- code.replace(/\\n$/, ''):去掉代码块末尾多余的换行,渲染更整洁。
代码块被"抽走"之后,剩下的纯文本才安全地经过 this.escapeHtml(text)。注意这个 escapeHtml 和 utils.js 里的略有不同——它只用字符串映射,不依赖 DOM,性能更好:
escapeHtml(text) {
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
return text.replace(/[&<>"']/g, c => map[c]);
}
转义之后,文本里已经没有 < > 能破坏结构,此时放心跑 Markdown 正则。比如标题、加粗、列表、引用、表格、分割线——都是简单的 String.replace:
text = text.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
text = text.replace(/^### (.+)$/gm, '<h3>$1</h3>');
text = text.replace(/^## (.+)$/gm, '<h2>$1</h2>');
text = text.replace(/^# (.+)$/gm, '<h2>$1</h2>');
text = text.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');
text = text.replace(/\\*(.+?)\\*/g, '<em>$1</em>');
text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
链接处理里藏着一个安全闸——禁止 javascript: 和 data: 协议,防止 XSS:
text = text.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, (_, linkText, url) => {
if (/^(javascript|data):/i.test(url)) return linkText; // 危险协议直接丢弃链接
return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + linkText + '</a>';
});
2.1 列表解析的小巧思
纯正则处理列表有个麻烦:多个 <li> 要被同一个 <ul> 包裹。AI Nexus 用了"哨兵字符"过渡:先用不可见占位符 \\x00UL\\x00…\\x00/UL\\x00 标出每个列表项,再把相邻的项合并成 <ul><li>…:
text = text.replace(/^[\\-\\*] (.+)$/gm, '\\x00UL\\x00$1\\x00/UL\\x00');
text = text.replace(/\\x00\\/UL\\x00\\s*\\x00UL\\x00/g, '\\n');
text = text.replace(/\\x00UL\\x00/g, '<ul><li>');
text = text.replace(/\\x00\\/UL\\x00/g, '</li></ul>');
\\x00 是空字符,介于"不会出现在用户输入里"和"便于正则识别"之间。先给每个项套上开闭哨兵,再把"闭合哨兵紧接着下一个开放哨兵"的情况(即相邻列表项)替换成换行,最后统一把哨兵换成真正的 <ul><li> 标签。有序列表 \\d+. 同理。这套写法虽然朴素,但足以覆盖 95% 的真实 Markdown 列表场景。
2.2 表格解析
表格用多行正则一次匹配"表头行 + 分隔行 + 表体行":
text = text.replace(/^(\\|.+\\|)\\n(\\|[\\s\\-:|]+\\|)\\n((?:\\|.+\\|\\n?)*)/gm, (_, headerRow, sepRow, bodyRows) => {
const headers = headerRow.split('|').filter(c => c.trim()).map(c => '<th>' + c.trim() + '</th>').join('');
const rows = bodyRows.trim().split('\\n').map(row => {
const cells = row.split('|').filter(c => c.trim()).map(c => '<td>' + c.trim() + '</td>').join('');
return '<tr>' + cells + '</tr>';
}).join('');
return '<table><thead><tr>' + headers + '</tr></thead><tbody>' + rows + '</tbody></table>';
});
第 1、3 步拼装好 HTML 后,还有一段"清理空段落"的后处理,专门把 <p> 包裹在 <h2>/<pre>/<ul>/<table> 等块级元素外产生的 <p><h2> 这种非法嵌套拆掉:
text = text.replace(/<p>\\s*<\\/p>/g, '');
text = text.replace(/<p>\\s*(<h[2-4]>)/g, '$1');
text = text.replace(/(<\\/h[2-4]>)\\s*<\\/p>/g, '$1');
text = text.replace(/<p>\\s*(<pre>)/g, '$1');
text = text.replace(/(<\\/pre>)\\s*<\\/p>/g, '$1');
// … ul/ol/blockquote/hr/table 同理
2.3 还原占位符(代码块 + 行内代码)
所有 Markdown 规则跑完,最后把占位符换回真实内容。行内代码直接转义后包 <code>:
inlineCodes.forEach((code, id) => {
const placeholder = PH_START + 'IC' + id + PH_END;
text = text.split(placeholder).join('<code>' + this.escapeHtml(code) + '</code>');
});
代码块则要调用语法高亮器,并拼出带"语言标签 + 复制按钮"的 <pre> 结构:
codeBlocks.forEach((block, id) => {
const placeholder = PH_START + 'CB' + id + PH_END;
const highlighted = SyntaxHighlighter.highlight(block.code, block.lang);
const langLabel = block.lang || 'code';
const html = '<pre><div class="code-block-header"><span class="code-block-lang">' +
this.escapeHtml(langLabel) +
'</span><button class="code-copy-btn" data-action="copy-code">复制</button></div><code class="language-' +
this.escapeHtml(block.lang) + '">' + highlighted + '</code></pre>';
text = text.split(placeholder).join(html);
});
注意这里用的是 text.split(placeholder).join(xxx) 而不是 text.replace(placeholder, xxx)——因为 replace 默认只替换第一个匹配,而 split/join 能替换全部,且避免了占位符里特殊字符被正则转义的麻烦。
三、语法高亮器:一次遍历的正则扫描
多数高亮库(highlight.js、Prism)很重。AI Nexus 自己写了一个轻量到"零依赖"的 SyntaxHighlighter,核心思想极巧妙:把所有语法元素(注释、字符串、关键词、函数、数字)的正则拼成一个"或"组合的大正则,一次 exec 遍历,按捕获组判定类型,包上 <span class="token-xxx">。
highlight(code, lang) {
if (!code) return '';
lang = (lang || '').toLowerCase().trim();
const { regex, groupTypes } = this.buildRegex(lang);
const result = [];
let lastIndex = 0;
let match;
while ((match = regex.exec(code)) !== null) {
if (match.index > lastIndex) {
result.push(this.escapeHtml(code.slice(lastIndex, match.index))); // 普通文本先转义
}
let type = 'plain';
for (let g = 0; g < groupTypes.length; g++) {
if (match[g + 1] !== undefined) { type = groupTypes[g]; break; } // 命中第几个捕获组,就是哪种类型
}
if (type === 'plain') {
result.push(this.escapeHtml(match[0]));
} else {
result.push('<span class="token-' + type + '">' + this.escapeHtml(match[0]) + '</span>');
}
lastIndex = match.index + match[0].length;
if (match[0].length === 0) regex.lastIndex++; // 防零宽死循环
}
if (lastIndex < code.length) {
result.push(this.escapeHtml(code.slice(lastIndex)));
}
let html = result.join('');
html = this.postProcess(html, lang);
return html;
}
遍历逻辑像"贪心切词":每次 regex.exec 抓住下一个语法片段,片段前的普通文本先转义入栈;用 match[g+1] 检测命中了第几个捕获组(注意 g 从 0 开始,捕获组从 1 开始),从而知道该片段是 comment / string / keyword / function / number 哪一种。
buildRegex 负责按语言拼这个组合正则,并把捕获组顺序和类型数组对齐:
buildRegex(lang) {
if (this._regexCache[lang]) return this._regexCache[lang];
const parts = [];
const groupTypes = [];
// 块注释
parts.push('(\\\\/\\\\*[\\\\s\\\\S]*?\\\\*\\\\/)'); groupTypes.push('comment');
// 行注释(按语言选 # 或 //)
if (this.usesHashComments(lang)) { parts.push('(#[^\\\\n]*)'); groupTypes.push('comment'); }
if (this.usesSlashComments(lang)) { parts.push('(\\\\/\\\\/[^\\\\n]*)'); groupTypes.push('comment'); }
// 字符串(三种引号)
parts.push('("(?:[^"\\\\\\\\]|\\\\\\\\.)*")'); groupTypes.push('string');
parts.push("('(?:[^'\\\\\\\\]|\\\\\\\\.)*')"); groupTypes.push('string');
parts.push('(`(?:[^`\\\\\\\\]|\\\\\\\\.)*`)'); groupTypes.push('string');
// 关键词
const kw = this.getKeywords(lang);
if (kw) {
const kwPattern = kw.split(' ').filter(w => w).map(w => w.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')).join('|');
if (kwPattern) { parts.push('(\\\\b(?:' + kwPattern + ')\\\\b)'); groupTypes.push('keyword'); }
}
// 函数调用:标识符后紧跟 (
parts.push('(\\\\b[a-zA-Z_]\\\\w*(?=\\\\s*\\\\())'); groupTypes.push('function');
// 数字
parts.push('(\\\\b\\\\d+\\\\.?\\\\d*\\\\b)'); groupTypes.push('number');
const compiled = { regex: new RegExp(parts.join('|'), 'gm'), groupTypes };
this._regexCache[lang] = compiled; // 缓存,避免重复编译
return compiled;
}
三个值得记住的工程点:
- 顺序即优先级:正则里块注释排最前、字符串其次、关键词再次。因为 (a|b|c) 的匹配按"在文本中的位置最早"而非"分支顺序",但在同一位置时按左到右——把注释放前面,能保证 // 注释里的 function 不会被误高亮成函数。这是经典的正则高亮陷阱,本工程靠排列顺序规避。
- 关键词先转义再 | 拼接:用户提供的语言关键词可能含正则元字符,先 w.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') 才安全。
- \\b…\\b 词边界:保证 class 不会高亮 classify 里的子串。
- _regexCache 缓存编译结果:每种语言的正则只编译一次,重复渲染同一语言代码块零成本。
postProcess 做语言专属增强,例如让 Python 的 def foo 里的 foo 高亮成函数名、class Foo 里的 Foo 高亮成类型名:
postProcess(html, lang) {
if (['python', 'py'].includes(lang)) {
html = html.replace(/(<span class="token-keyword">def<\\/span>)(\\s+)(\\w+)/g, '$1$2<span class="token-function">$3</span>');
html = html.replace(/(<span class="token-keyword">class<\\/span>)(\\s+)(\\w+)/g, '$1$2<span class="token-type">$3</span>');
html = html.replace(/^(\\s*)(@\\w+)/gm, '$1<span class="token-function">$2</span>');
}
if (['html', 'xml', 'svg'].includes(lang)) {
html = html.replace(/(<\\/?)([\\w-]+)/g, '$1<span class="token-tag">$2</span>');
}
// … go / rust / js / typescript 类似
return html;
}
最后这些 token-xxx 类由 CSS 上色。style.css 用 CSS 变量统一管理两套主题:
.token-keyword { color: var(–hl-keyword); }
.token-string { color: var(–hl-string); }
.token-number { color: var(–hl-number); }
.token-comment { color: var(–hl-comment); font-style: italic; }
.token-function{ color: var(–hl-function); }
而 –hl-keyword 等变量在暗色/亮色主题下取值不同(暗色 #ff7b72、亮色 #d73a49),所以高亮配色自动跟随主题切换——这是"配置即视图"思想的又一次体现。
四、单条消息的渲染与流式增量更新
回到 app.js,renderMessageHtml 决定一条消息长什么样:用户消息直接 escapeHtml(不需要 Markdown 解析),AI 消息走 MarkdownRenderer.render:
function renderMessageHtml(msg, index) {
const isUser = msg.role === 'user';
const isError = msg.role === 'error';
const avatar = isUser ? 'U' : 'AI';
const roleName = isUser ? '你' : (isError ? '错误' : 'AI助手');
const contentHtml = isUser ? escapeHtml(msg.content) : MarkdownRenderer.render(msg.content);
return '<div class="message ' + (isError ? 'error' : msg.role) + '">' +
'<div class="message-avatar">' + avatar + '</div>' +
'<div class="message-body">' +
'<div class="message-role">' + roleName + '</div>' +
'<div class="message-content" id="msg_' + index + '">' + contentHtml + '</div>' +
'<div class="message-actions">' +
'<button class="msg-action-btn" data-action="copy-msg" data-index="' + index + '">复制</button>' +
'<button class="msg-action-btn" data-action="export-msg-image" data-index="' + index + '">导图</button>' +
'<button class="msg-action-btn" data-action="delete-msg" data-index="' + index + '">删除</button>' +
(!isUser ? '<button class="msg-action-btn" data-action="regen-msg" data-index="' + index + '">重新生成</button>' : '') +
'</div>' +
'</div></div>';
}
每条消息都带 id="msg_${index}",这正是流式更新能精准改写单条 DOM 的钥匙(updateStreamingMessage 用 getElementById('msg_' + i) 定位)。注意代码里 data-index="${index}"——它把"消息在第几条"编码进 DOM,配合第四节讲的事件委托,点击"复制/删除/重新生成"时无需重新绑定监听。
"正在生成中"的占位由 renderMessagesWithTyping 渲染一个三点跳动的打字指示器:
function renderMessagesWithTyping(ti) {
const chat = getActiveChat();
const c = document.getElementById('chatMessages');
if (!c || !chat) return;
let h = '';
chat.messages.forEach((m, i) => {
if (i === ti && !m.content) {
h += '<div class="message assistant"><div class="message-avatar">AI</div>' +
'<div class="message-body"><div class="message-role">AI助手</div>' +
'<div class="message-content" id="msg_' + i + '">' +
'<div class="typing-indicator"><span></span><span></span><span></span></div>' +
'</div></div></div>';
} else {
h += renderMessageHtml(m, i);
}
});
c.innerHTML = h;
scrollToBottom();
}
打字指示器三个 <span> 的动画在 CSS 里用 animation-delay 错开,形成"呼吸"效果,纯 CSS 实现,不耗 JS:
.typing-indicator span { width: 7px; height: 7px; background: var(–accent-cyan); border-radius: 50%; animation: typing 1.2s infinite; }
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
五、事件委托:一个监听器撑起整套 UI
传统写法是给每个按钮 addEventListener,但消息是动态渲染的,按钮每次都在变,逐个绑定既慢又易漏。AI Nexus 用全局事件委托:只在 document 上挂一个 click 监听,靠 data-action 属性分发:
document.addEventListener('click', function(e) {
const actionEl = e.target.closest('[data-action]');
if (!actionEl) {
const qcp = document.getElementById('quickCommandsPanel');
if (qcp) qcp.classList.remove('show');
return;
}
const action = actionEl.dataset.action;
switch (action) {
case 'new-chat': createNewChat(); break;
case 'clear-chat': clearCurrentChat(); break;
case 'copy-msg': { /* … nav.clipboard.writeText … */ break; }
case 'delete-msg': { /* … chat.messages.splice … */ break; }
case 'regen-msg': { /* 截断到该条并重发 */ break; }
case 'toggle-theme': setTheme(getTheme() === 'dark' ? 'light' : 'dark'); break;
case 'quick-command': applyQuickCommand(actionEl.dataset.cmd); break;
// … 其余几十个 action
}
});
e.target.closest('[data-action]') 是关键:点击发生在按钮内部的某个 <span> 或 <svg> 上时,closest 会向上找到最近的带 data-action 的祖先,从而正确识别意图。这带来三大好处:① 动态 DOM 无需重新绑定;② 全局只一个监听器,内存友好;③ action 与函数映射集中在一个 switch,可读可维护。
regen-msg(重新生成)的实现很有意思——它把 messages 截到目标消息之前,再重新发一次请求:
case 'regen-msg': {
const chat3 = getActiveChat();
const idx3 = parseInt(actionEl.dataset.index);
if (chat3) { chat3.messages = chat3.messages.slice(0, idx3); saveState(); renderMessages(); sendToAPI(); }
break;
}
六、快捷指令:输入 / 触发的命令面板
输入框监听 input,当内容以 / 开头且不超过 20 字符时,弹出快捷指令面板:
function handleQuickCommandInput(value) {
const qcp = document.getElementById('quickCommandsPanel');
if (!qcp) return;
if (value.startsWith('/') && value.length <= 20) {
const query = value.slice(1).toLowerCase();
const filtered = QUICK_COMMANDS.filter(cmd =>
cmd.name.toLowerCase().includes(query) || cmd.desc.includes(query)
);
if (filtered.length > 0) {
let html = '';
filtered.forEach((cmd, idx) => {
html += '<div class="quick-command-item' + (idx === 0 ? ' active' : '') +
'" data-action="quick-command" data-cmd="' + cmd.id + '">' +
'<span class="cmd-icon">' + cmd.icon + '</span>' +
'<div class="cmd-info"><div class="cmd-name">' + escapeHtml(cmd.name) +
'</div><div class="cmd-desc">' + escapeHtml(cmd.desc) + '</div></div></div>';
});
qcp.innerHTML = html;
qcp.classList.add('show');
appState.quickCommandActive = 0;
} else {
qcp.classList.remove('show');
appState.quickCommandActive = –1;
}
} else {
qcp.classList.remove('show');
appState.quickCommandActive = –1;
}
}
QUICK_COMMANDS 在 config.js 里定义,每条带 template——选中后直接把模板填进输入框(例如 /代码审查 会填入"请审查以下代码…"加一对空代码围栏)。键盘上下键导航靠 navigateQuickCommand,Enter 选中当前 appState.quickCommandActive 项。这种设计把"常用 prompt"做成了可发现、可键盘操作的命令面板,体验接近 IDE。 
七、主题切换与数据备份
主题切换是 CSS 变量 + data-theme 属性驱动,JS 只负责切属性并存 localStorage:
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('ai_nexus_theme', theme);
}
function getTheme() {
return localStorage.getItem('ai_nexus_theme') || 'dark';
}
style.css 里 :root 是暗色默认值,[data-theme="light"] 覆盖一组变量即可整站换肤,无需任何组件重渲染——这是"设计令牌(Design Token)"思路的最小实现。
数据备份用 Blob + URL.createObjectURL 导出 JSON,用 FileReader 导入,全过程纯前端:
function exportAllChats() {
const d = { version: '2.0', exportTime: new Date().toISOString(), chats: appState.chats };
const b = new Blob([JSON.stringify(d, null, 2)], { type: 'application/json' });
const u = URL.createObjectURL(b);
const a = document.createElement('a');
a.href = u;
a.download = 'ai_nexus_chats_' + new Date().toISOString().slice(0, 10) + '.json';
a.click();
URL.revokeObjectURL(u);
showToast('对话已导出', 'success');
}
导入时用 Set 去重,已存在的会话不重复追加,避免覆盖或重复:
const ids = new Set(appState.chats.map(c => c.id));
let n = 0;
d.chats.forEach(c => { if (!ids.has(c.id)) { appState.chats.push(c); n++; } });
八、全局快捷键与下拉交互的真实实现
事件委托不止用于点击。顶部工具栏的模型选择器下拉、以及全套全局快捷键,也都集中在 bindEvents() 里,和"单一监听器分发"是同一套哲学。先看模型下拉的"点外部关闭"——它用了两个监听配合,避免自己点开又立刻被关掉:
document.getElementById('modelSelector').addEventListener('click', function(e) {
if (document.getElementById('modelDropdown').contains(e.target)) return; // 点到下拉内部不算"外部"
document.getElementById('modelDropdown').classList.toggle('show');
});
document.addEventListener('click', function(e) {
if (!document.getElementById('modelSelector').contains(e.target)) {
document.getElementById('modelDropdown').classList.remove('show'); // 点任意外部区域则收起
}
});
第一个监听挂在 .modelSelector 上负责切换;第二个挂在 document 上负责"点外部收起"。关键防御是 if (modelDropdown.contains(e.target)) return 和 if (!modelSelector.contains(e.target))——因为两个监听都会触发,必须先判断点击是否落在下拉内部,否则会出现"点开后被 document 监听马上关掉"的闪烁。这种"内部忽略、外部收起"的配对模式,是所有自定义下拉菜单的标配写法。
全局快捷键则挂在 document 的 keydown 上,用 e.ctrlKey / e.shiftKey 组合判定,全部 preventDefault 阻止浏览器默认行为:
document.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'n') { e.preventDefault(); createNewChat(); }
if (e.ctrlKey && e.key === 'b') { e.preventDefault(); toggleSidebar(); }
if (e.ctrlKey && e.key === 'k') { e.preventDefault(); openSearchInChat(); }
if (e.ctrlKey && e.shiftKey && e.key === 'S') { e.preventDefault(); setTheme(getTheme() === 'dark' ? 'light' : 'dark'); }
if (e.key === 'Escape') {
document.getElementById('rightPanel').classList.remove('open');
closeModal('addProviderModal');
}
});
Ctrl+Shift+S 里 e.key 是 'S'(大写),因为按住 Shift 后字母键返回大写形式,这点容易写错成 's' 而失效。输入框自身的 keydown 则处理 Enter 发送、Shift+Enter 换行,以及快捷指令面板打开时的上下键导航(ArrowDown/ArrowUp 调 navigateQuickCommand,Enter 选中、Esc 关闭)。把"发送/换行/命令导航"三种语义压缩进同一个 keydown,靠 e.shiftKey 和面板是否 show 两个状态分流,逻辑紧凑且不冲突。
九、安全小结:纯前端产品的三道防线
交付包虽然零后端,但安全不能零。它至少布了三道防线:
function escapeAttr(t) {
if (!t) return '';
return t.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''')
.replace(/</g, '<').replace(/>/g, '>');
}
值得多说一句:工程里其实有两套 escapeHtml——utils.js 用的是"借助 DOM"的版本(const d = document.createElement('div'); d.textContent = t; return d.innerHTML;),而 markdown.js 里的是"字符串映射"版本(只替换五个字符、不碰 DOM)。前者更省心、绝不会漏转义,但每次调用都创建 DOM 节点;后者在渲染器里被高频调用(每条消息每个片段都跑),用纯字符串替换避免了频繁 DOM 操作的开销。这个"高频路径用轻量实现、低频路径用稳妥实现"的取舍,是手写渲染器时才需要考虑的细节——直接用库往往意识不到这层成本差异。而 escapeAttr 与 escapeHtml 职责也不同:前者专门处理"属性值上下文"(额外转义引号,防 data-key="x" 注入 截断属性),后者处理"元素内容上下文",两者不可互相替代。
九、会话时间分组与友好时间显示
渲染层不止 Markdown。左侧会话列表里的"今天 / 昨天 / 本周 / 更早"分组,靠的是 utils.js 里两个纯函数:getTimeGroup(ts) 把时间戳归类,formatTime(ts) 把时间戳变成"刚刚 / 3分钟前 / 今天 14:30"这种人类友好文案。
function getTimeGroup(timestamp) {
if (!timestamp) return '更早';
const date = new Date(timestamp);
const now = new Date();
if (date.getDate() === now.getDate() && date.getMonth() === now.getMonth() && date.getFullYear() === now.getFullYear()) return '今天';
const yesterday = new Date(now – 86400000);
if (date.getDate() === yesterday.getDate() && date.getMonth() === yesterday.getMonth()) return '昨天';
const weekStart = new Date(now);
weekStart.setDate(now.getDate() – now.getDay());
weekStart.setHours(0, 0, 0, 0);
if (date >= weekStart) return '本周';
return '更早';
}
renderChatList 拿到分组后,先按"置顶"挑出来,其余按 getTimeGroup 落入对应桶,再逐个渲染带 data-action="toggle-group" 的折叠头。点击头部会翻转 _collapsedGroups[name] 并整列表重绘,折叠态通过 CSS max-height: 0 过渡实现。这里有个细节:置顶项用 chat.pinned 标记,并在 togglePinChat 里翻转;渲染时置顶组永远排在最前,未置顶的则交给时间分组。整套逻辑零依赖、可单测,是非常好的"纯函数 + 渲染分离"范例。
十、导出为图片:html2canvas 的可选降级
"导出为图片"按钮调用 utils.js 的 exportAsImage(content, title)。它先把消息 HTML 塞进一个 left:-9999px 的离屏容器(带暗色背景和标题),然后判断 window.html2canvas 是否存在:
function exportAsImage(content, title) {
const tempDiv = document.createElement('div');
tempDiv.style.cssText = 'position:fixed;left:-9999px;top:0;width:600px;…';
tempDiv.innerHTML = '<div …>' + escapeHtml(title || 'AI对话') + '</div>' + '<div …>' + content + '</div>…';
document.body.appendChild(tempDiv);
if (window.html2canvas) {
window.html2canvas(tempDiv, { backgroundColor: '#0a0e1a' }).then(canvas => {
const link = document.createElement('a');
link.download = (title || 'ai-chat') + '.png';
link.href = canvas.toDataURL();
link.click();
document.body.removeChild(tempDiv);
showToast('图片已导出', 'success');
});
} else {
const textContent = tempDiv.innerText;
navigator.clipboard.writeText(textContent).then(() => {
showToast('已复制对话文本(安装html2canvas可导出图片)', 'info');
});
document.body.removeChild(tempDiv);
}
}
这是典型的优雅降级(graceful degradation):html2canvas 是个外部库,交付包没强制引入,所以用 if (window.html2canvas) 探测。有就导出 PNG,没有就退化为"复制纯文本 + 提示装库"。注意 content 是已经过 Markdown 渲染的 HTML,所以导图里代码高亮、表格都能保留;而复制文本分支用 innerText 拿到无格式纯文本,互不干扰。离屏容器用完即 removeChild,不残留 DOM。
十一、欢迎屏与快捷操作入口
空会话不应该是空白。 getWelcomeHtml() 返回一个带 Logo、简介和 4 张快捷卡片的欢迎屏,每张卡片都是 data-action="quick-msg" 且带 data-text:
function getWelcomeHtml() {
return '<div class="welcome-screen">' +
'<div class="icon">AI</div>' +
'<h2>AI Nexus 对话平台</h2>' +
'<p>连接 Qwen / DeepSeek / GLM 等国产大模型,开启智能对话…</p>' +
'<div class="quick-actions">' +
'<div class="quick-action" data-action="quick-msg" data-text="用简洁的语言介绍一下你自己">…</div>' +
'<div class="quick-action" data-action="quick-msg" data-text="用Python写一个快速排序算法,并解释其原理">…</div>' +
'</div></div>';
}
点击卡片时,事件委托命中 quick-msg,把 data-text 填进输入框并直接 sendMessage()——用户零输入即可发起一次示例对话。这个设计与第五节讲的 /快捷指令 互补:欢迎屏面向"第一次打开、不知道问什么"的新用户,命令面板面向"正在输入、想插入模板"的熟手。两者都复用同一套 data-action 分发,没有新增任何事件绑定。
十二、结语:这套代码能怎么扩展
通读两篇涉及的源码,你会看见一个清晰、可复刻的架构:配置驱动的多供应商层 + fetch/AbortController 通信层 + 占位符保护的 Markdown 渲染层 + 单遍正则高亮层 + 事件委托交互层 + localStorage 持久层。每一层都低耦合、零外部依赖。
如果你想把它变成自己的产品,几个低风险扩展点:① 在 config.js 的 PRESET_PROVIDERS 加新厂商,通信层无需改动;② 在 AI_ROLES 加角色即获得新系统提示词预设;③ 给 SyntaxHighlighter.keywords 加语言即获得新语言高亮;④ 把 localStorage 换成 IndexedDB 即可支撑超长对话历史。

