chat.html•20.8 kB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>chat</title>
<!-- 引入 Marked.js 用于 Markdown 解析 -->
<script src="https://cdn.jsdelivr.net/npm/marked@4.3.0/marked.min.js"></script>
<!-- 引入 highlight.js 用于代码高亮 -->
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.7.0/build/highlight.min.js"></script>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.7.0/build/styles/github.min.css">
<!-- DOMPurify 防止 XSS 攻击 -->
<script src="https://cdn.jsdelivr.net/npm/dompurify@2.4.5/dist/purify.min.js"></script>
<style>
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.header {
padding: 0 12px;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
display: flex;
height: 56px;
align-items: center;
justify-content: space-between;
}
.header-controls {
display: flex;
gap: 10px;
align-items: center;
}
.messages {
padding: 20px;
max-width: 800px;
margin: 0 auto;
height: calc(100vh - 160px);
overflow-y: auto;
padding-bottom: 80px; /* 为输入框留出空间 */
scroll-behavior: smooth;
}
.message {
margin-bottom: 20px;
padding: 15px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.message.user {
background: #e3f2fd;
margin-left: 40px;
border-bottom-right-radius: 0;
}
.message.assistant {
background: #f8f9fa;
margin-right: 40px;
border-bottom-left-radius: 0;
}
.message-stats {
font-size: 0.8em;
color: #6c757d;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.input-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 15px;
background: white;
border-top: 1px solid #dee2e6;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
}
.input-wrapper {
max-width: 800px;
margin: 0 auto;
display: flex;
gap: 12px;
}
#messageInput {
flex: 1;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
#messageInput:focus {
border-color: #86b7fe;
outline: 0;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
/* Markdown 样式 */
.message-content * {
margin: 0;
padding: 0;
line-height: 1.5;
}
.message-content h1,
.message-content h2,
.message-content h3,
.message-content h4,
.message-content h5,
.message-content h6 {
margin: 0.5em 0 0.3em;
line-height: 1.25;
}
.message-content ul,
.message-content ol {
padding-left: 1.4em;
margin: 0.3em 0;
}
.message-content li {
margin: 0.2em 0;
}
.message-content p {
margin: 0.4em 0;
}
.message-content pre {
margin: 0.5em 0;
}
.message-content blockquote {
margin: 0.4em 0;
}
/* 工具调用和结果自定义样式 */
.tool-call {
background: #e3f2fd;
padding: 12px;
margin: 2px 0;
border-radius: 6px;
font-family: monospace;
position: relative;
}
.tool-result {
background: #f3e5f5;
padding: 12px;
margin: 2px 0;
border-radius: 6px;
font-family: monospace;
position: relative;
}
/* 复制代码按钮 */
.copy-button {
position: absolute;
top: 5px;
right: 5px;
background: rgba(255, 255, 255, 0.7);
border: none;
border-radius: 3px;
padding: 3px 8px;
font-size: 12px;
cursor: pointer;
display: none;
}
.code-block-wrapper {
position: relative;
}
.code-block-wrapper:hover .copy-button {
display: block;
}
#sendButton {
padding: 12px 24px;
background: #10a37f;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.15s ease-in-out;
}
#sendButton:hover {
background: #0d8c6f;
}
#sendButton:active {
background: #0a755e;
}
#modelSelect {
padding: 6px 10px;
border-radius: 4px;
border: 1px solid #ced4da;
}
.clear-button {
padding: 6px 12px;
background: #dc3545;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.clear-button:hover {
background: #c82333;
}
/* 特殊提示块样式 */
.message-content .info-block {
background-color: #e7f5ff;
border-left: 4px solid #4dabf7;
padding: 12px;
margin: 2px 0;
}
.message-content .warning-block {
background-color: #fff3bf;
border-left: 4px solid #fab005;
padding: 12px;
margin: 2px 0;
}
.message-content .error-block {
background-color: #ffe3e3;
border-left: 4px solid #fa5252;
padding: 12px;
margin: 2px 0;
}
.message-content .success-block {
background-color: #d3f9d8;
border-left: 4px solid #40c057;
padding: 12px;
margin: 2px 0;
}
think {
display: block;
padding: 8px !important;
background: #f3f3f3;
border-left: 4px solid #ccc;
margin: 4px 0;
font-size: 0.9em; /* 调整整体字体大小 */
}
details.think-wrapper {
font-family: sans-serif;
background: #f4f6ff;
border-left: 4px solid #91a7ff;
border-radius: 6px;
padding: 8px 12px;
margin: 10px 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
/* 工具执行标签样式 */
.tool-executing {
background-color: #e9ecef;
border-left: 4px solid #868e96;
padding: 10px;
margin: 8px 0;
font-family: monospace;
border-radius: 4px;
position: relative;
}
.tool-result-wrapper {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 10px;
margin: 8px 0;
font-family: monospace;
border-radius: 4px;
overflow-x: auto;
}
.tool-completed {
background-color: #d4edda;
border-left: 4px solid #28a745;
padding: 10px;
margin: 8px 0;
font-family: monospace;
border-radius: 4px;
color: #155724;
font-style: italic;
}
details.tool-wrapper {
font-family: sans-serif;
background: #f8f9fa;
border-left: 4px solid #6c757d;
border-radius: 6px;
padding: 8px 12px;
margin: 10px 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
details.tool-wrapper summary {
cursor: pointer;
user-select: none;
font-weight: 500;
color: #343a40;
}
</style>
</head>
<body>
<div class="header">
<div class="header-controls">
<select id="modelSelect">
<!-- 模型选项将动态加载 -->
</select>
</div>
<button class="clear-button" onclick="clearChat()">清空对话</button>
</div>
<div class="messages" id="messages">
<div class="message assistant">
<div class="message-content">你好!</div>
</div>
</div>
<div class="input-container">
<div class="input-wrapper">
<input type="text" id="messageInput" placeholder="输入消息..."/>
<button id="sendButton">发送</button>
</div>
</div>
<script>
const API_BASE = 'http://localhost:8000/v1';
let conversationHistory = [];
// 配置 Marked 选项
marked.setOptions({
renderer: new marked.Renderer(),
highlight: function (code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, {language}).value;
},
langPrefix: 'hljs language-',
pedantic: false,
gfm: true,
breaks: true,
sanitize: false, // 我们使用 DOMPurify 来替代内置的 sanitize
smartypants: false
});
// 扩展 Marked 渲染
const renderer = new marked.Renderer();
// 自定义代码块渲染,添加复制按钮
renderer.code = function (code, language) {
const validLang = hljs.getLanguage(language) ? language : 'plaintext';
const highlightedCode = hljs.highlight(code, {language: validLang}).value;
return `
<div class="code-block-wrapper">
<button class="copy-button" onclick="copyCode(this)">复制</button>
<pre><code class="hljs language-${validLang}">${highlightedCode}</code></pre>
</div>
`;
};
// 自定义表格渲染
renderer.table = function (header, body) {
return `
<div style="overflow-x: auto;">
<table>
<thead>${header}</thead>
<tbody>${body}</tbody>
</table>
</div>
`;
};
marked.use({renderer});
// 加载模型列表
window.addEventListener('DOMContentLoaded', async function () {
await loadModels();
document.getElementById('messageInput').focus();
});
async function loadModels() {
try {
const response = await fetch(`${API_BASE}/models`);
if (response.ok) {
const data = await response.json();
const modelSelect = document.getElementById('modelSelect');
modelSelect.innerHTML = '';
if (data.data && data.data.length > 0) {
data.data.forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = model.id;
modelSelect.appendChild(option);
});
}
}
} catch (error) {
console.error('获取模型列表出错:', error);
// 使用默认模型
const modelSelect = document.getElementById('modelSelect');
['qwen3:8b', 'qwen3:4b'].forEach(model => {
const option = document.createElement('option');
option.value = model;
option.textContent = model;
modelSelect.appendChild(option);
});
}
}
// 发送按钮点击事件
document.getElementById('sendButton').addEventListener('click', sendMessage);
// 回车发送
document.getElementById('messageInput').addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
sendMessage();
}
});
function addMessage(role, content, isHtml = false) {
const messages = document.getElementById('messages');
const div = document.createElement('div');
div.className = `message ${role}`;
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
if (isHtml) {
contentDiv.innerHTML = content;
} else {
// 为纯文本内容应用安全的 HTML 转义
const textNode = document.createTextNode(content);
contentDiv.appendChild(textNode);
}
div.appendChild(contentDiv);
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
return div;
}
// 增强 Markdown 解析和渲染
function parseMarkdown(text) {
// 特殊标记处理 - think标签、工具调用和结果
// 1. 处理think标签
text = text.replace(/(<think>[\s\S]*?<\/think>)/g, '<details class="think-wrapper"><summary>思考过程</summary>$1</details>');
// 2. 处理工具执行标签
text = text.replace(/\[正在执行工具\]([\s\S]*?)(?=\[执行工具|\[工具执行完成|$)/g, '<div class="tool-executing">$1</div>');
text = text.replace(/\[执行工具(.*?)结果\]([\s\S]*?)(?=\[正在执行工具|\[工具执行完成|$)/g, '<div class="tool-result-wrapper"><strong>执行工具$1结果:</strong>$2</div>');
text = text.replace(/\[工具执行完成,等待分析\.\.\.\]/g, '<div class="tool-completed">工具执行完成,等待分析...</div>');
// 3. 将工具相关内容整合到折叠面板中
const toolPattern = /(<div class="tool-executing">[\s\S]*?)(<div class="tool-completed">[\s\S]*?<\/div>)/g;
text = text.replace(toolPattern, '<details class="tool-wrapper" open><summary>工具执行过程</summary>$1$2</details>');
// 定制提示块支持
text = text
.replace(/:::info([\s\S]*?):::/g, '<div class="info-block">$1</div>')
.replace(/:::warning([\s\S]*?):::/g, '<div class="warning-block">$1</div>')
.replace(/:::error([\s\S]*?):::/g, '<div class="error-block">$1</div>')
.replace(/:::success([\s\S]*?):::/g, '<div class="success-block">$1</div>');
// 使用 Marked 解析 Markdown,然后用 DOMPurify 净化结果
const rawHtml = marked.parse(text);
const cleanHtml = DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol',
'nl', 'li', 'b', 'i', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div',
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'pre', 'button', 'span', 'img',
'details', 'summary', 'figure', 'figcaption', 'think', 'tool_calls', 'tool_calls_data'],
ALLOWED_ATTR: ['href', 'target', 'title', 'class', 'id', 'style', 'onclick', 'lang', 'src', 'alt', 'open'],
FORBID_TAGS: ['script', 'iframe', 'object', 'embed'],
FORBID_ATTR: ['onerror', 'onload', 'onmouseover']
});
return cleanHtml;
}
// 复制代码功能
window.copyCode = function (button) {
const codeBlock = button.nextElementSibling.querySelector('code');
const code = codeBlock.innerText || codeBlock.textContent;
navigator.clipboard.writeText(code).then(() => {
const originalText = button.textContent;
button.textContent = '已复制!';
button.style.background = '#40c057';
button.style.color = 'white';
setTimeout(() => {
button.textContent = originalText;
button.style.background = 'rgba(255, 255, 255, 0.7)';
button.style.color = 'inherit';
}, 2000);
}).catch(err => {
console.error('复制失败:', err);
});
};
async function sendMessage() {
const input = document.getElementById('messageInput');
const message = input.value.trim();
if (!message) return;
// 添加用户消息
addMessage('user', message);
conversationHistory.push({role: 'user', content: message});
input.value = '';
// 流式响应统计
let startTime = Date.now();
let firstTokenTime = null;
let tokenCount = 0;
try {
const model = document.getElementById('modelSelect').value;
// 添加助手消息占位
const assistantDiv = addMessage('assistant', '', true);
const contentDiv = assistantDiv.querySelector('.message-content');
let accumulated = '';
const response = await fetch(`${API_BASE}/chat/completions`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
model: model,
messages: conversationHistory,
stream: true,
temperature: 0.7,
max_tokens: 2048
})
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const {done, value} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const json = JSON.parse(data);
const chunk = json.choices[0];
if (chunk.delta && chunk.delta.content) {
if (!firstTokenTime) {
firstTokenTime = Date.now();
}
accumulated += chunk.delta.content;
contentDiv.innerHTML = parseMarkdown(accumulated);
// 高亮新添加的代码块
contentDiv.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
tokenCount = accumulated.length; // 简单计算
messages.scrollTop = messages.scrollHeight;
}
} catch (e) {
console.error('解析错误:', e);
}
}
}
}
// 完成 Markdown 最终处理
contentDiv.innerHTML = parseMarkdown(accumulated);
contentDiv.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
// 添加统计信息
const endTime = Date.now();
const duration = (endTime - startTime) / 1000;
const tokensPerSecond = (tokenCount / duration).toFixed(2);
const timeToFirstToken = ((firstTokenTime - startTime) / 1000).toFixed(2);
const statsDiv = document.createElement('div');
statsDiv.className = 'message-stats';
statsDiv.textContent = `${tokensPerSecond} tok/sec • ${tokenCount} tokens • ${timeToFirstToken}s to first token`;
assistantDiv.appendChild(statsDiv);
// 添加到对话历史
conversationHistory.push({role: 'assistant', content: accumulated});
} catch (error) {
console.error('错误:', error);
addMessage('assistant', `错误: ${error.message}`);
}
}
function clearChat() {
if (confirm('确定要清空对话吗?')) {
conversationHistory = [];
const messages = document.getElementById('messages');
messages.innerHTML = `
<div class="message assistant">
<div class="message-content">你好!</div>
</div>
`;
}
}
</script>
</body>
</html>