396 lines
12 KiB
HTML
396 lines
12 KiB
HTML
|
|
<!DOCTYPE html>
|
|||
|
|
<html lang="zh-CN">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<title>流式输出测试页面</title>
|
|||
|
|
<style>
|
|||
|
|
* {
|
|||
|
|
margin: 0;
|
|||
|
|
padding: 0;
|
|||
|
|
box-sizing: border-box;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
body {
|
|||
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
|||
|
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
|||
|
|
sans-serif;
|
|||
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|||
|
|
min-height: 100vh;
|
|||
|
|
padding: 20px;
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: center;
|
|||
|
|
align-items: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.container {
|
|||
|
|
background: white;
|
|||
|
|
border-radius: 16px;
|
|||
|
|
padding: 40px;
|
|||
|
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
|||
|
|
max-width: 800px;
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
h1 {
|
|||
|
|
text-align: center;
|
|||
|
|
color: #333;
|
|||
|
|
margin-bottom: 30px;
|
|||
|
|
font-size: 2.5rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.controls {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 15px;
|
|||
|
|
margin-bottom: 30px;
|
|||
|
|
justify-content: center;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
button {
|
|||
|
|
background: #667eea;
|
|||
|
|
color: white;
|
|||
|
|
border: none;
|
|||
|
|
padding: 12px 24px;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
font-size: 16px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.3s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
button:hover {
|
|||
|
|
background: #5a67d8;
|
|||
|
|
transform: translateY(-2px);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
button:active {
|
|||
|
|
transform: translateY(0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
button:disabled {
|
|||
|
|
background: #a0aec0;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
transform: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stream-output {
|
|||
|
|
background: #f7fafc;
|
|||
|
|
border: 2px solid #e2e8f0;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
padding: 24px;
|
|||
|
|
min-height: 400px;
|
|||
|
|
max-height: 600px;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|||
|
|
font-size: 16px;
|
|||
|
|
line-height: 1.8;
|
|||
|
|
color: #2d3748;
|
|||
|
|
white-space: pre-wrap;
|
|||
|
|
word-break: break-word;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stream-output::-webkit-scrollbar {
|
|||
|
|
width: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stream-output::-webkit-scrollbar-track {
|
|||
|
|
background: #edf2f7;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stream-output::-webkit-scrollbar-thumb {
|
|||
|
|
background: #cbd5e0;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stream-output::-webkit-scrollbar-thumb:hover {
|
|||
|
|
background: #a0aec0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stats {
|
|||
|
|
margin-top: 20px;
|
|||
|
|
padding: 15px;
|
|||
|
|
background: #edf2f7;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
font-size: 14px;
|
|||
|
|
color: #4a5568;
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stats-item {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 5px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.loading-dots {
|
|||
|
|
display: inline-block;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.loading-dots::after {
|
|||
|
|
content: '.';
|
|||
|
|
animation: dots 1s steps(5, end) infinite;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes dots {
|
|||
|
|
0%, 20% { content: '.'; }
|
|||
|
|
40% { content: '..'; }
|
|||
|
|
60% { content: '...'; }
|
|||
|
|
80%, 100% { content: '.'; }
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div class="container">
|
|||
|
|
<h1>✨ 流式输出测试</h1>
|
|||
|
|
|
|||
|
|
<div class="controls">
|
|||
|
|
<button id="startBtn">开始流式输出</button>
|
|||
|
|
<button id="stopBtn" disabled>停止输出</button>
|
|||
|
|
<button id="clearBtn">清空内容</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div id="output" class="stream-output">
|
|||
|
|
<div style="color: #718096;">点击上方按钮开始测试流式输出...</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="stats">
|
|||
|
|
<div class="stats-item">
|
|||
|
|
<span>状态:</span>
|
|||
|
|
<span id="status" style="font-weight: 600;">就绪</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="stats-item">
|
|||
|
|
<span>字符数:</span>
|
|||
|
|
<span id="charCount">0</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="stats-item">
|
|||
|
|
<span>耗时:</span>
|
|||
|
|
<span id="timeElapsed">0.00s</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
// 页面元素
|
|||
|
|
const startBtn = document.getElementById('startBtn');
|
|||
|
|
const stopBtn = document.getElementById('stopBtn');
|
|||
|
|
const clearBtn = document.getElementById('clearBtn');
|
|||
|
|
const output = document.getElementById('output');
|
|||
|
|
const statusEl = document.getElementById('status');
|
|||
|
|
const charCountEl = document.getElementById('charCount');
|
|||
|
|
const timeElapsedEl = document.getElementById('timeElapsed');
|
|||
|
|
|
|||
|
|
// 状态变量
|
|||
|
|
let controller = null;
|
|||
|
|
let startTime = 0;
|
|||
|
|
let charCount = 0;
|
|||
|
|
let timer = null;
|
|||
|
|
|
|||
|
|
// 模拟大模型输出的文本内容
|
|||
|
|
const sampleText = `欢迎使用流式输出测试页面!
|
|||
|
|
|
|||
|
|
这是一个模拟大语言模型生成内容的演示。
|
|||
|
|
|
|||
|
|
流式输出是一种将生成的内容逐字逐句发送给用户的技术,
|
|||
|
|
它可以显著提升用户体验,让用户感觉内容是实时生成的。
|
|||
|
|
|
|||
|
|
在实际应用中,大语言模型会在生成每个token后立即发送给客户端,
|
|||
|
|
而不需要等待整个响应完成。
|
|||
|
|
|
|||
|
|
这种技术特别适合:
|
|||
|
|
- 长文本生成
|
|||
|
|
- 对话系统
|
|||
|
|
- 代码生成
|
|||
|
|
- 实时翻译
|
|||
|
|
|
|||
|
|
流式输出的优势包括:
|
|||
|
|
1. 更快的感知响应速度
|
|||
|
|
2. 更好的用户体验
|
|||
|
|
3. 更低的内存占用
|
|||
|
|
4. 支持更长的内容生成
|
|||
|
|
|
|||
|
|
在这个测试中,我们将模拟每秒生成约50个字符的速度,
|
|||
|
|
您可以看到文本是如何逐字逐句显示的。
|
|||
|
|
|
|||
|
|
您可以随时点击"停止输出"按钮来中断流式传输,
|
|||
|
|
也可以点击"清空内容"按钮重新开始。
|
|||
|
|
|
|||
|
|
让我们继续探索流式输出的更多应用场景...
|
|||
|
|
|
|||
|
|
在Web应用中,流式输出通常使用以下技术实现:
|
|||
|
|
- Server-Sent Events (SSE)
|
|||
|
|
- WebSockets
|
|||
|
|
- HTTP/2 Server Push
|
|||
|
|
|
|||
|
|
每种技术都有其优缺点,选择合适的技术取决于具体的应用场景。
|
|||
|
|
|
|||
|
|
例如,SSE适合单向的服务器到客户端通信,
|
|||
|
|
而WebSockets适合双向通信。
|
|||
|
|
|
|||
|
|
流式输出不仅提升了用户体验,
|
|||
|
|
也为开发者提供了更多的灵活性和控制能力。
|
|||
|
|
|
|||
|
|
感谢您使用这个测试页面!
|
|||
|
|
|
|||
|
|
希望您对流式输出技术有了更深入的了解。
|
|||
|
|
|
|||
|
|
如果您有任何问题或建议,欢迎随时提出。
|
|||
|
|
|
|||
|
|
祝您使用愉快!`;
|
|||
|
|
|
|||
|
|
// 更新统计信息
|
|||
|
|
function updateStats() {
|
|||
|
|
charCountEl.textContent = charCount;
|
|||
|
|
|
|||
|
|
if (startTime > 0) {
|
|||
|
|
const elapsed = (Date.now() - startTime) / 1000;
|
|||
|
|
timeElapsedEl.textContent = elapsed.toFixed(2) + 's';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 清空输出
|
|||
|
|
function clearOutput() {
|
|||
|
|
output.innerHTML = '<div style="color: #718096;">点击上方按钮开始测试流式输出...</div>';
|
|||
|
|
charCount = 0;
|
|||
|
|
updateStats();
|
|||
|
|
statusEl.textContent = '就绪';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 模拟流式输出
|
|||
|
|
async function simulateStreaming() {
|
|||
|
|
startBtn.disabled = true;
|
|||
|
|
stopBtn.disabled = false;
|
|||
|
|
clearOutput();
|
|||
|
|
|
|||
|
|
output.textContent = '';
|
|||
|
|
statusEl.innerHTML = '生成中 <span class="loading-dots"></span>';
|
|||
|
|
|
|||
|
|
startTime = Date.now();
|
|||
|
|
charCount = 0;
|
|||
|
|
|
|||
|
|
// 创建AbortController用于取消操作
|
|||
|
|
controller = new AbortController();
|
|||
|
|
const signal = controller.signal;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// 使用ReadableStream模拟流式输出
|
|||
|
|
const stream = new ReadableStream({
|
|||
|
|
async start(controller) {
|
|||
|
|
let index = 0;
|
|||
|
|
|
|||
|
|
while (index < sampleText.length && !signal.aborted) {
|
|||
|
|
// 随机延迟,模拟真实生成速度
|
|||
|
|
const delay = Math.random() * 80 + 40; // 40-120ms
|
|||
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|||
|
|
|
|||
|
|
// 每次发送1-5个字符
|
|||
|
|
const chunkSize = Math.floor(Math.random() * 5) + 1;
|
|||
|
|
const chunk = sampleText.slice(index, index + chunkSize);
|
|||
|
|
|
|||
|
|
controller.enqueue(new TextEncoder().encode(chunk));
|
|||
|
|
index += chunkSize;
|
|||
|
|
|
|||
|
|
// 更新统计信息
|
|||
|
|
charCount += chunk.length;
|
|||
|
|
updateStats();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
controller.close();
|
|||
|
|
},
|
|||
|
|
cancel() {
|
|||
|
|
console.log('流式输出已取消');
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 读取并显示流内容
|
|||
|
|
const reader = stream.getReader();
|
|||
|
|
const decoder = new TextDecoder();
|
|||
|
|
|
|||
|
|
while (true) {
|
|||
|
|
const { done, value } = await reader.read();
|
|||
|
|
if (done) break;
|
|||
|
|
|
|||
|
|
// 追加内容到输出
|
|||
|
|
output.textContent += decoder.decode(value, { stream: true });
|
|||
|
|
|
|||
|
|
// 自动滚动到底部
|
|||
|
|
output.scrollTop = output.scrollHeight;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 完成
|
|||
|
|
statusEl.textContent = '完成';
|
|||
|
|
output.style.borderColor = '#48bb78';
|
|||
|
|
|
|||
|
|
// 添加完成标记
|
|||
|
|
const doneMarker = document.createElement('div');
|
|||
|
|
doneMarker.style.cssText = `
|
|||
|
|
margin-top: 20px;
|
|||
|
|
padding: 10px;
|
|||
|
|
background: #48bb78;
|
|||
|
|
color: white;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
text-align: center;
|
|||
|
|
font-weight: 600;
|
|||
|
|
`;
|
|||
|
|
doneMarker.textContent = '✅ 流式输出完成!';
|
|||
|
|
output.appendChild(doneMarker);
|
|||
|
|
|
|||
|
|
} catch (error) {
|
|||
|
|
if (error.name === 'AbortError') {
|
|||
|
|
statusEl.textContent = '已停止';
|
|||
|
|
output.style.borderColor = '#ed8936';
|
|||
|
|
|
|||
|
|
const stopMarker = document.createElement('div');
|
|||
|
|
stopMarker.style.cssText = `
|
|||
|
|
margin-top: 20px;
|
|||
|
|
padding: 10px;
|
|||
|
|
background: #ed8936;
|
|||
|
|
color: white;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
text-align: center;
|
|||
|
|
font-weight: 600;
|
|||
|
|
`;
|
|||
|
|
stopMarker.textContent = '⏸️ 流式输出已停止';
|
|||
|
|
output.appendChild(stopMarker);
|
|||
|
|
} else {
|
|||
|
|
statusEl.textContent = '错误';
|
|||
|
|
output.innerHTML += `\n\n❌ 发生错误: ${error.message}`;
|
|||
|
|
output.style.borderColor = '#f56565';
|
|||
|
|
}
|
|||
|
|
} finally {
|
|||
|
|
// 重置状态
|
|||
|
|
startBtn.disabled = false;
|
|||
|
|
stopBtn.disabled = true;
|
|||
|
|
controller = null;
|
|||
|
|
if (timer) {
|
|||
|
|
clearInterval(timer);
|
|||
|
|
timer = null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 停止流式输出
|
|||
|
|
function stopStreaming() {
|
|||
|
|
if (controller) {
|
|||
|
|
controller.abort();
|
|||
|
|
controller = null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 事件监听器
|
|||
|
|
startBtn.addEventListener('click', simulateStreaming);
|
|||
|
|
stopBtn.addEventListener('click', stopStreaming);
|
|||
|
|
clearBtn.addEventListener('click', clearOutput);
|
|||
|
|
|
|||
|
|
// 页面加载完成
|
|||
|
|
window.addEventListener('load', () => {
|
|||
|
|
console.log('流式输出测试页面已加载');
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|