simple_stream_test.html 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>流式Markdown渲染测试</title>
  7. <!-- Vditor CSS -->
  8. <link rel="stylesheet" href="https://unpkg.com/vditor/dist/index.css" />
  9. <style>
  10. * {
  11. margin: 0;
  12. padding: 0;
  13. box-sizing: border-box;
  14. }
  15. body {
  16. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  17. background: #f5f5f5;
  18. padding: 20px;
  19. }
  20. .container {
  21. max-width: 1000px;
  22. margin: 0 auto;
  23. background: white;
  24. border-radius: 8px;
  25. box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  26. overflow: hidden;
  27. }
  28. .header {
  29. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  30. color: white;
  31. padding: 30px;
  32. text-align: center;
  33. }
  34. .header h1 {
  35. font-size: 28px;
  36. margin-bottom: 10px;
  37. }
  38. .header p {
  39. opacity: 0.9;
  40. font-size: 16px;
  41. }
  42. .content {
  43. padding: 30px;
  44. }
  45. .test-section {
  46. margin-bottom: 30px;
  47. padding: 25px;
  48. background: #f8f9fa;
  49. border-radius: 8px;
  50. border-left: 4px solid #667eea;
  51. }
  52. .form-group {
  53. margin-bottom: 20px;
  54. }
  55. label {
  56. display: block;
  57. margin-bottom: 8px;
  58. font-weight: 600;
  59. color: #2c3e50;
  60. font-size: 14px;
  61. }
  62. input, textarea {
  63. width: 100%;
  64. padding: 12px;
  65. border: 2px solid #e1e8ed;
  66. border-radius: 6px;
  67. font-size: 14px;
  68. transition: border-color 0.3s;
  69. }
  70. input:focus, textarea:focus {
  71. outline: none;
  72. border-color: #667eea;
  73. }
  74. textarea {
  75. height: 100px;
  76. resize: vertical;
  77. font-family: inherit;
  78. }
  79. .button-group {
  80. display: flex;
  81. gap: 15px;
  82. margin-top: 20px;
  83. }
  84. button {
  85. padding: 12px 24px;
  86. border: none;
  87. border-radius: 6px;
  88. cursor: pointer;
  89. font-size: 14px;
  90. font-weight: 600;
  91. transition: all 0.3s;
  92. min-width: 120px;
  93. }
  94. .btn-primary {
  95. background: #667eea;
  96. color: white;
  97. }
  98. .btn-primary:hover {
  99. background: #5a6fd8;
  100. transform: translateY(-1px);
  101. }
  102. .btn-secondary {
  103. background: #6c757d;
  104. color: white;
  105. }
  106. .btn-secondary:hover {
  107. background: #5a6268;
  108. }
  109. .btn-danger {
  110. background: #dc3545;
  111. color: white;
  112. }
  113. .btn-danger:hover {
  114. background: #c82333;
  115. }
  116. .status {
  117. padding: 15px;
  118. margin: 15px 0;
  119. border-radius: 6px;
  120. font-weight: 600;
  121. display: none;
  122. }
  123. .status.show {
  124. display: block;
  125. }
  126. .status.info {
  127. background: #d1ecf1;
  128. color: #0c5460;
  129. border: 1px solid #bee5eb;
  130. }
  131. .status.success {
  132. background: #d4edda;
  133. color: #155724;
  134. border: 1px solid #c3e6cb;
  135. }
  136. .status.error {
  137. background: #f8d7da;
  138. color: #721c24;
  139. border: 1px solid #f5c6cb;
  140. }
  141. .output-section {
  142. margin-top: 30px;
  143. }
  144. .output-header {
  145. display: flex;
  146. justify-content: space-between;
  147. align-items: center;
  148. margin-bottom: 20px;
  149. padding-bottom: 15px;
  150. border-bottom: 2px solid #e1e8ed;
  151. }
  152. .output-title {
  153. font-size: 18px;
  154. font-weight: 600;
  155. color: #2c3e50;
  156. }
  157. .output-tabs {
  158. display: flex;
  159. gap: 5px;
  160. }
  161. .tab {
  162. padding: 8px 16px;
  163. cursor: pointer;
  164. border-radius: 4px;
  165. font-size: 14px;
  166. font-weight: 500;
  167. transition: all 0.3s;
  168. background: #f8f9fa;
  169. color: #6c757d;
  170. }
  171. .tab.active {
  172. background: #667eea;
  173. color: white;
  174. }
  175. .tab-content {
  176. display: none;
  177. }
  178. .tab-content.active {
  179. display: block;
  180. }
  181. .raw-output {
  182. background: #f8f9fa;
  183. border: 2px solid #e1e8ed;
  184. border-radius: 6px;
  185. padding: 20px;
  186. font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
  187. font-size: 13px;
  188. line-height: 1.5;
  189. white-space: pre-wrap;
  190. max-height: 500px;
  191. overflow-y: auto;
  192. color: #2c3e50;
  193. }
  194. .markdown-container {
  195. border: 2px solid #e1e8ed;
  196. border-radius: 6px;
  197. min-height: 400px;
  198. background: white;
  199. }
  200. /* 只读编辑器样式 */
  201. .markdown-container.readonly-editor {
  202. user-select: none; /* 禁用文本选择 */
  203. }
  204. .markdown-container .vditor-toolbar {
  205. display: none !important; /* 隐藏工具栏 */
  206. }
  207. .markdown-container .vditor-content {
  208. cursor: default !important; /* 默认光标 */
  209. }
  210. .markdown-container .vditor-content:focus {
  211. outline: none !important; /* 移除焦点样式 */
  212. }
  213. /* 确保滚动条正常工作 */
  214. .markdown-container .vditor-content {
  215. overflow-y: auto !important; /* 确保垂直滚动 */
  216. pointer-events: auto !important; /* 允许滚动交互 */
  217. }
  218. .markdown-container .vditor-content::-webkit-scrollbar {
  219. width: 8px;
  220. }
  221. .markdown-container .vditor-content::-webkit-scrollbar-track {
  222. background: #f1f1f1;
  223. border-radius: 4px;
  224. }
  225. .markdown-container .vditor-content::-webkit-scrollbar-thumb {
  226. background: #c1c1c1;
  227. border-radius: 4px;
  228. }
  229. .markdown-container .vditor-content::-webkit-scrollbar-thumb:hover {
  230. background: #a8a8a8;
  231. }
  232. .loading {
  233. display: none;
  234. text-align: center;
  235. padding: 40px;
  236. color: #6c757d;
  237. }
  238. .loading.show {
  239. display: block;
  240. }
  241. .spinner {
  242. border: 4px solid #f3f3f3;
  243. border-top: 4px solid #667eea;
  244. border-radius: 50%;
  245. width: 40px;
  246. height: 40px;
  247. animation: spin 1s linear infinite;
  248. margin: 0 auto 15px;
  249. }
  250. @keyframes spin {
  251. 0% { transform: rotate(0deg); }
  252. 100% { transform: rotate(360deg); }
  253. }
  254. .stats {
  255. display: flex;
  256. gap: 20px;
  257. margin-top: 15px;
  258. font-size: 12px;
  259. color: #6c757d;
  260. }
  261. .stat-item {
  262. display: flex;
  263. align-items: center;
  264. gap: 5px;
  265. }
  266. .stat-value {
  267. font-weight: 600;
  268. color: #2c3e50;
  269. }
  270. </style>
  271. </head>
  272. <body>
  273. <div class="container">
  274. <div class="header">
  275. <h1>🚀 流式Markdown渲染测试</h1>
  276. <p>测试流式接口的实时Markdown渲染效果 - 支持三种Vditor只读预览模式</p>
  277. </div>
  278. <div class="content">
  279. <div class="test-section">
  280. <div class="form-group">
  281. <label for="apiUrl">API接口地址</label>
  282. <input type="text" id="apiUrl" value="http://localhost:22000/apiv1/stream/chat" placeholder="输入流式接口地址">
  283. </div>
  284. <div class="form-group">
  285. <label for="message">测试消息</label>
  286. <textarea id="message" placeholder="输入要测试的消息内容">你好,请介绍一下施工现场的安全防护要求,包括临边防护、脚手架管理等具体规范</textarea>
  287. </div>
  288. <div class="form-group">
  289. <label for="model">模型名称 (可选)</label>
  290. <input type="text" id="model" placeholder="模型名称,留空使用默认">
  291. </div>
  292. <div class="button-group">
  293. <button class="btn-primary" onclick="startTest()">▶️ 开始测试</button>
  294. <button class="btn-secondary" onclick="clearAll()">🗑️ 清空输出</button>
  295. <button class="btn-danger" onclick="stopTest()">⏹️ 停止测试</button>
  296. </div>
  297. </div>
  298. <div id="status" class="status"></div>
  299. <div class="loading" id="loading">
  300. <div class="spinner"></div>
  301. <div>正在接收流式数据...</div>
  302. </div>
  303. <div class="output-section">
  304. <div class="output-header">
  305. <div class="output-title">📊 测试结果</div>
  306. <div class="output-tabs">
  307. <div class="tab active" onclick="switchTab('raw')">原始数据</div>
  308. <div class="tab" onclick="switchTab('wysiwyg')">WYSIWYG预览</div>
  309. <div class="tab" onclick="switchTab('ir')">即时渲染预览(IR)</div>
  310. <div class="tab" onclick="switchTab('sv')">分屏预览(SV)</div>
  311. </div>
  312. </div>
  313. <div style="background: #f8f9fa; padding: 15px; margin-bottom: 20px; border-radius: 6px; font-size: 14px; color: #6c757d;">
  314. <strong>📝 渲染模式说明(只读预览):</strong><br>
  315. • <strong>WYSIWYG预览</strong>:所见即所得预览,显示最终渲染效果(不可编辑)<br>
  316. • <strong>即时渲染预览(IR)</strong>:实时渲染Markdown为HTML预览(不可编辑)<br>
  317. • <strong>分屏预览(SV)</strong>:左侧显示Markdown源码,右侧显示HTML预览(不可编辑)
  318. </div>
  319. <div id="rawTab" class="tab-content active">
  320. <div class="raw-output" id="rawOutput">等待测试数据...</div>
  321. </div>
  322. <div id="wysiwygTab" class="tab-content">
  323. <div class="markdown-container readonly-editor" id="wysiwygOutput"></div>
  324. </div>
  325. <div id="irTab" class="tab-content">
  326. <div class="markdown-container readonly-editor" id="irOutput"></div>
  327. </div>
  328. <div id="svTab" class="tab-content">
  329. <div class="markdown-container readonly-editor" id="svOutput"></div>
  330. </div>
  331. <div class="stats" id="stats" style="display: none;">
  332. <div class="stat-item">
  333. <span>📝 字符数:</span>
  334. <span class="stat-value" id="charCount">0</span>
  335. </div>
  336. <div class="stat-item">
  337. <span>⏱️ 耗时:</span>
  338. <span class="stat-value" id="duration">0s</span>
  339. </div>
  340. <div class="stat-item">
  341. <span>📦 数据块:</span>
  342. <span class="stat-value" id="chunkCount">0</span>
  343. </div>
  344. </div>
  345. </div>
  346. </div>
  347. </div>
  348. <!-- Vditor JavaScript -->
  349. <script src="https://unpkg.com/vditor/dist/index.min.js"></script>
  350. <script>
  351. let vditorWysiwyg = null;
  352. let vditorIR = null;
  353. let vditorSV = null;
  354. let isStreaming = false;
  355. let startTime = null;
  356. let charCount = 0;
  357. let chunkCount = 0;
  358. let currentContent = '';
  359. // 初始化所有Vditor编辑器
  360. function initVditors() {
  361. // 销毁现有的编辑器
  362. if (vditorWysiwyg) vditorWysiwyg.destroy();
  363. if (vditorIR) vditorIR.destroy();
  364. if (vditorSV) vditorSV.destroy();
  365. // 初始化WYSIWYG模式 - 只读预览
  366. vditorWysiwyg = new Vditor('wysiwygOutput', {
  367. height: 400,
  368. mode: 'wysiwyg',
  369. cache: {
  370. id: 'vditor-wysiwyg-cache'
  371. },
  372. toolbar: [], // 去掉工具栏
  373. disabled: true, // 设置为只读
  374. after: () => {
  375. console.log('✅ WYSIWYG预览初始化完成');
  376. }
  377. });
  378. // 初始化即时渲染(IR)模式 - 只读预览
  379. vditorIR = new Vditor('irOutput', {
  380. height: 400,
  381. mode: 'ir',
  382. cache: {
  383. id: 'vditor-ir-cache'
  384. },
  385. toolbar: [], // 去掉工具栏
  386. disabled: true, // 设置为只读
  387. after: () => {
  388. console.log('✅ 即时渲染预览(IR)初始化完成');
  389. }
  390. });
  391. // 初始化分屏预览(SV)模式 - 只读预览
  392. vditorSV = new Vditor('svOutput', {
  393. height: 400,
  394. mode: 'sv',
  395. cache: {
  396. id: 'vditor-sv-cache'
  397. },
  398. toolbar: [], // 去掉工具栏
  399. disabled: true, // 设置为只读
  400. after: () => {
  401. console.log('✅ 分屏预览(SV)初始化完成');
  402. }
  403. });
  404. }
  405. // 切换标签页
  406. function switchTab(tabName) {
  407. // 隐藏所有标签内容
  408. document.querySelectorAll('.tab-content').forEach(content => {
  409. content.classList.remove('active');
  410. });
  411. // 移除所有标签的active类
  412. document.querySelectorAll('.tab').forEach(tab => {
  413. tab.classList.remove('active');
  414. });
  415. // 显示选中的标签内容
  416. document.getElementById(tabName + 'Tab').classList.add('active');
  417. // 添加active类到选中的标签
  418. event.target.classList.add('active');
  419. }
  420. // 显示状态信息
  421. function showStatus(message, type = 'info') {
  422. const statusEl = document.getElementById('status');
  423. statusEl.textContent = message;
  424. statusEl.className = `status ${type}`;
  425. statusEl.classList.add('show');
  426. setTimeout(() => {
  427. statusEl.classList.remove('show');
  428. }, 5000);
  429. }
  430. // 更新统计信息
  431. function updateStats() {
  432. const duration = startTime ? Math.round((Date.now() - startTime) / 1000) : 0;
  433. document.getElementById('charCount').textContent = charCount;
  434. document.getElementById('duration').textContent = duration + 's';
  435. document.getElementById('chunkCount').textContent = chunkCount;
  436. document.getElementById('stats').style.display = 'flex';
  437. }
  438. // 清空所有输出
  439. function clearAll() {
  440. document.getElementById('rawOutput').textContent = '等待测试数据...';
  441. currentContent = '';
  442. if (vditorWysiwyg) vditorWysiwyg.setValue('');
  443. if (vditorIR) vditorIR.setValue('');
  444. if (vditorSV) vditorSV.setValue('');
  445. charCount = 0;
  446. chunkCount = 0;
  447. startTime = null;
  448. updateStats();
  449. showStatus('✅ 输出已清空', 'success');
  450. }
  451. // 防抖更新函数
  452. let debouncedUpdate = null;
  453. // 初始化防抖函数
  454. function initDebouncedUpdate() {
  455. debouncedUpdate = debounce((content) => {
  456. if (vditorWysiwyg) {
  457. vditorWysiwyg.setValue(content);
  458. }
  459. if (vditorIR) {
  460. vditorIR.setValue(content);
  461. }
  462. if (vditorSV) {
  463. vditorSV.setValue(content);
  464. }
  465. }, 30); // 30ms防抖,更流畅
  466. }
  467. // 防抖函数
  468. function debounce(func, wait) {
  469. let timeout;
  470. return function executedFunction(...args) {
  471. const later = () => {
  472. clearTimeout(timeout);
  473. func(...args);
  474. };
  475. clearTimeout(timeout);
  476. timeout = setTimeout(later, wait);
  477. };
  478. }
  479. // 更新所有编辑器的内容
  480. function updateAllEditors(content) {
  481. currentContent = content;
  482. // 使用防抖更新
  483. if (debouncedUpdate) {
  484. debouncedUpdate(content);
  485. }
  486. }
  487. // 流式完成处理
  488. function onStreamComplete() {
  489. console.log('=' + '='.repeat(80));
  490. console.log('🎉 流式输出完成!');
  491. console.log('=' + '='.repeat(80));
  492. console.log('📊 统计信息:');
  493. console.log(`📝 总字符数: ${charCount}`);
  494. console.log(`📦 数据块数: ${chunkCount}`);
  495. console.log(`⏱️ 完成时间: ${new Date().toLocaleString()}`);
  496. console.log(`⏱️ 耗时: ${startTime ? Math.round((Date.now() - startTime) / 1000) : 0}秒`);
  497. console.log('=' + '='.repeat(80));
  498. console.log('📄 完整响应内容:');
  499. console.log('=' + '='.repeat(80));
  500. console.log(currentContent);
  501. console.log('=' + '='.repeat(80));
  502. console.log('🎯 原始数据内容:');
  503. console.log('=' + '='.repeat(80));
  504. console.log(document.getElementById('rawOutput').textContent);
  505. console.log('=' + '='.repeat(80));
  506. console.log('✅ 流式输出已完全结束,所有内容已渲染完成');
  507. }
  508. // 停止测试
  509. function stopTest() {
  510. isStreaming = false;
  511. document.getElementById('loading').classList.remove('show');
  512. showStatus('⏹️ 测试已停止', 'info');
  513. }
  514. // 开始测试
  515. function startTest() {
  516. const apiUrl = document.getElementById('apiUrl').value.trim();
  517. const message = document.getElementById('message').value.trim();
  518. const model = document.getElementById('model').value.trim();
  519. if (!apiUrl || !message) {
  520. showStatus('❌ 请填写API地址和测试消息', 'error');
  521. return;
  522. }
  523. // 停止之前的测试
  524. stopTest();
  525. // 清空输出
  526. clearAll();
  527. // 显示加载状态
  528. document.getElementById('loading').classList.add('show');
  529. isStreaming = true;
  530. startTime = Date.now();
  531. showStatus('🔄 正在连接流式接口...', 'info');
  532. // 构建请求数据
  533. const requestData = {
  534. message: message
  535. };
  536. if (model) {
  537. requestData.model = model;
  538. }
  539. console.log('📤 发送请求:', requestData);
  540. // 使用fetch发送POST请求
  541. fetch(apiUrl, {
  542. method: 'POST',
  543. headers: {
  544. 'Content-Type': 'application/json',
  545. },
  546. body: JSON.stringify(requestData)
  547. })
  548. .then(response => {
  549. if (!response.ok) {
  550. throw new Error(`HTTP错误: ${response.status} ${response.statusText}`);
  551. }
  552. if (!response.body) {
  553. throw new Error('响应体为空');
  554. }
  555. showStatus('✅ 连接成功,开始接收流式数据...', 'success');
  556. // 处理流式响应
  557. const reader = response.body.getReader();
  558. const decoder = new TextDecoder();
  559. let buffer = '';
  560. let rawContent = '';
  561. let markdownContent = '';
  562. function readStream() {
  563. reader.read().then(({ done, value }) => {
  564. if (done) {
  565. console.log('✅ 流式数据接收完成');
  566. document.getElementById('loading').classList.remove('show');
  567. isStreaming = false;
  568. showStatus('✅ 流式数据接收完成', 'success');
  569. updateStats();
  570. onStreamComplete();
  571. return;
  572. }
  573. // 解码数据
  574. const chunk = decoder.decode(value, { stream: true });
  575. buffer += chunk;
  576. // 处理完整的数据行
  577. const lines = buffer.split('\n');
  578. buffer = lines.pop() || ''; // 保留最后一个不完整的行
  579. for (const line of lines) {
  580. if (line.trim() === '') continue;
  581. console.log('📥 收到数据行:', line);
  582. if (line.startsWith('data: ')) {
  583. const data = line.substring(6);
  584. if (data === '[DONE]') {
  585. console.log('🏁 流式结束');
  586. document.getElementById('loading').classList.remove('show');
  587. isStreaming = false;
  588. showStatus('✅ 流式数据接收完成', 'success');
  589. updateStats();
  590. onStreamComplete();
  591. return;
  592. }
  593. // 尝试解析JSON
  594. try {
  595. const jsonData = JSON.parse(data);
  596. if (jsonData.error) {
  597. showStatus(`❌ 错误: ${jsonData.error}`, 'error');
  598. document.getElementById('loading').classList.remove('show');
  599. isStreaming = false;
  600. return;
  601. }
  602. } catch (e) {
  603. // 不是JSON,直接作为文本内容处理
  604. console.log('📝 收到文本内容:', data);
  605. chunkCount++;
  606. // 处理转义的换行符,将\n转换回真正的换行符
  607. const processedData = data.replace(/\\n/g, '\n');
  608. // 添加到原始输出
  609. rawContent += processedData;
  610. document.getElementById('rawOutput').textContent = rawContent;
  611. // 添加到markdown内容
  612. markdownContent += processedData;
  613. charCount += processedData.length;
  614. // 实时更新所有编辑器
  615. updateAllEditors(markdownContent);
  616. // 更新统计信息
  617. updateStats();
  618. }
  619. }
  620. }
  621. // 继续读取
  622. readStream();
  623. }).catch(error => {
  624. console.error('❌ 读取流式数据时出错:', error);
  625. showStatus(`❌ 读取数据出错: ${error.message}`, 'error');
  626. document.getElementById('loading').classList.remove('show');
  627. isStreaming = false;
  628. });
  629. }
  630. readStream();
  631. })
  632. .catch(error => {
  633. console.error('❌ 请求失败:', error);
  634. showStatus(`❌ 请求失败: ${error.message}`, 'error');
  635. document.getElementById('loading').classList.remove('show');
  636. isStreaming = false;
  637. });
  638. }
  639. // 页面加载完成后初始化
  640. document.addEventListener('DOMContentLoaded', function() {
  641. initVditors();
  642. initDebouncedUpdate(); // 初始化防抖函数
  643. showStatus('✅ 页面加载完成,可以开始测试', 'success');
  644. });
  645. // 页面卸载时清理
  646. window.addEventListener('beforeunload', function() {
  647. stopTest();
  648. });
  649. </script>
  650. </body>
  651. </html>