
30款热门AI模型一站整合DeepSeek/GLM/Qwen 随心用限时 5 折。 点击领海量免费额度这类项目最值得先看的不是功能列表而是它到底解决了什么实际问题。一个多模型AI对比看板核心价值在于能让你在一个界面里直观地对比不同AI模型比如不同的大语言模型、图像生成模型或语音模型对同一输入的处理结果。它适合前端开发者、产品经理或任何需要快速评估模型效果的人。最关键的能力不是炫酷的UI而是如何稳定地对接多个AI服务、统一处理异步请求、并清晰地展示对比数据。如果只是把几个输入框和按钮堆在一起那意义不大真正的难点在于请求管理、状态同步和结果可视化。下面我会按照一个真实项目从零搭建的顺序拆解如何用纯前端技术HTML/CSS/JS实现一个既实用又稳健的对比看板。我们不依赖任何前端框架如React/Vue以便你透彻理解底层原理之后迁移到任何框架都会很轻松。1. 先想清楚你的看板到底要对比什么在动手写代码之前必须先定义清楚对比的维度和数据流。这决定了整个项目的结构。1.1 明确对比场景与模型类型你的看板可能用于以下几种场景每种场景的技术实现侧重点不同文本生成对比例如同时向 OpenAI GPT-4、Claude 3、本地部署的 Llama 模型发送同一个提示词Prompt对比它们的回复内容、速度、风格。技术重点处理文本流Streaming或非流式响应、管理多个并发的fetch请求、处理可能的大文本展示与折叠。图像生成对比例如向 Stable Diffusion、DALL-E 3、Midjourney通过API发送同样的描述对比生成图片的质量、风格一致性、生成时间。技术重点处理图像二进制数据Blob、展示多图、可能涉及图片加载状态和错误处理如图片生成失败。多模态问答对比上传一张图片让多个视觉语言模型VLMs描述或回答相关问题。技术重点处理FormData文件上传、管理包含文件的多部分请求。对于这个实战我们以“文本生成对比”作为核心场景因为它最通用涉及了异步请求、状态管理和数据展示的所有关键环节。图像对比可以在此基础上扩展。1.2 设计核心数据流与状态一个健壮的看板状态管理是关键。你需要明确有哪些状态以及它们如何变化。输入状态用户输入的提示词Prompt。这是所有模型请求的源头。请求状态每个模型独立idle: 初始状态未开始。loading: 请求发送后等待响应中。success: 成功收到响应。error: 请求失败需要记录错误信息。结果状态每个模型独立存储模型返回的文本内容。对于流式响应还需要一个“正在接收”的中间状态。模型配置一个数组定义你要对比的模型。每个模型至少包含id: 唯一标识如gpt-4。name: 显示名称如GPT-4 Turbo。apiEndpoint: 后端接口地址重要前端不能直接存API Key所有请求应通过你自己的代理后端转发。config: 模型特定参数如temperature,max_tokens。为什么状态要独立因为每个模型的请求是并发的成功、失败、加载完成的时间点都不同。独立的状态允许你单独控制每个结果区域的UI如加载动画、错误提示。2. 搭建基础骨架HTML 结构与 CSS 布局先实现一个静态的、看起来像样的界面再注入动态逻辑。2.1 HTML语义化结构创建一个index.html文件结构要清晰便于后续JS操作。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title多模型AI对比看板/title link relstylesheet hrefstyle.css link relstylesheet hrefhttps://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css /head body div classcontainer header classapp-header h1i classfas fa-robot/i AI模型对比实验室/h1 p classsubtitle输入提示词实时对比多个主流模型的生成结果/p /header main !-- 输入区 -- section classinput-section card h2i classfas fa-keyboard/i 输入你的提示词/h2 div classinput-area textarea idpromptInput placeholder例如用简单的语言解释量子计算.../textarea div classinput-actions button idsubmitBtn classbtn btn-primary i classfas fa-play/i 开始对比 /button button idclearBtn classbtn btn-secondary i classfas fa-broom/i 清空所有 /button div classmodel-selector labelinput typecheckbox classmodel-check valueall checked 全选/label !-- 模型复选框将通过JS动态生成 -- /div /div /div /section !-- 对比结果展示区 -- section classresults-section h2i classfas fa-columns/i 模型对比结果/h2 div classresults-container idresultsContainer !-- 每个模型的卡片将通过JS动态生成 -- !-- 模板如下 div classmodel-card>/* style.css */ :root { --primary-color: #4361ee; --secondary-color: #3a0ca3; --success-color: #4cc9f0; --warning-color: #f72585; --light-bg: #f8f9fa; --dark-text: #212529; --border-radius: 12px; --card-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); } * { box-sizing: border-box; margin: 0; padding: 0; font-family: Segoe UI, system-ui, sans-serif; } body { background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); color: var(--dark-text); min-height: 100vh; padding: 20px; line-height: 1.6; } .container { max-width: 1400px; margin: 0 auto; } /* 卡片通用样式 */ .card { background: white; border-radius: var(--border-radius); padding: 1.8rem; box-shadow: var(--card-shadow); margin-bottom: 2rem; border: 1px solid #e9ecef; } .app-header { text-align: center; margin-bottom: 2.5rem; padding-top: 1rem; } .app-header h1 { color: var(--secondary-color); margin-bottom: 0.5rem; } .subtitle { color: #6c757d; font-size: 1.1rem; } /* 输入区域 */ .input-section textarea { width: 100%; min-height: 120px; padding: 1rem; border: 2px solid #dee2e6; border-radius: 8px; font-size: 1rem; resize: vertical; transition: border-color 0.3s; margin-bottom: 1.2rem; } .input-section textarea:focus { outline: none; border-color: var(--primary-color); } .input-actions { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; } .btn { padding: 0.75rem 1.8rem; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 0.5rem; transition: all 0.2s; } .btn-primary { background-color: var(--primary-color); color: white; } .btn-primary:hover { background-color: var(--secondary-color); transform: translateY(-2px); } .btn-secondary { background-color: #e9ecef; color: var(--dark-text); } .btn-secondary:hover { background-color: #dee2e6; } .model-selector { margin-left: auto; display: flex; gap: 1rem; flex-wrap: wrap; } .model-selector label { cursor: pointer; display: flex; align-items: center; gap: 0.3rem; } /* 结果区域 */ .results-container { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1.5rem; margin-top: 1rem; } media (max-width: 768px) { .results-container { grid-template-columns: 1fr; } } /* 单个模型卡片 */ .model-card { background: white; border-radius: var(--border-radius); padding: 1.5rem; box-shadow: var(--card-shadow); border-top: 4px solid var(--primary-color); /* 颜色可动态设置 */ display: flex; flex-direction: column; height: 100%; transition: transform 0.3s; } .model-card:hover { transform: translateY(-5px); } .card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; border-bottom: 1px solid #eee; padding-bottom: 0.8rem; } .card-header h3 { color: var(--secondary-color); } .status-badge { font-size: 0.75rem; padding: 0.25rem 0.75rem; border-radius: 20px; font-weight: 600; } .status-idle { background: #e9ecef; color: #6c757d; } .status-loading { background: #fff3cd; color: #856404; } .status-success { background: #d1e7dd; color: #0f5132; } .status-error { background: #f8d7da; color: #721c24; } .card-content { flex-grow: 1; margin-bottom: 1rem; overflow-y: auto; max-height: 400px; } .result-text { white-space: pre-wrap; /* 保留换行符 */ word-break: break-word; line-height: 1.7; } .result-text.streaming { border-right: 2px solid var(--primary-color); animation: blink 1s infinite; } keyframes blink { 50% { border-color: transparent; } } .card-footer { display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; color: #6c757d; border-top: 1px solid #eee; padding-top: 0.8rem; } .btn-icon { background: none; border: none; color: #6c757d; cursor: pointer; padding: 0.3rem; border-radius: 4px; } .btn-icon:hover { background-color: #f8f9fa; color: var(--primary-color); } .app-footer { text-align: center; margin-top: 3rem; color: #adb5bd; font-size: 0.9rem; }3. 注入灵魂JavaScript 动态逻辑与状态管理这是核心部分。我们将创建两个JS文件config.js管理配置app.js处理所有交互。3.1 模型配置与状态初始化 (config.js)// config.js // 模型配置列表这里定义你要对比的所有模型 // 注意apiEndpoint 应指向你自己的后端代理服务而不是直接指向第三方API window.MODEL_CONFIGS [ { id: gpt-4, name: GPT-4 Turbo, apiEndpoint: /api/proxy/openai, // 示例后端端点 config: { temperature: 0.7, max_tokens: 500 }, color: #10a37f // 用于卡片顶部边框色 }, { id: claude-3, name: Claude 3 Sonnet, apiEndpoint: /api/proxy/anthropic, config: { temperature: 0.8, max_tokens: 600 }, color: #d4a574 }, { id: gemini-pro, name: Gemini Pro, apiEndpoint: /api/proxy/google-ai, config: { temperature: 0.6, max_tokens: 550 }, color: #4285f4 }, { id: llama-local, name: Llama 3 (本地), apiEndpoint: /api/proxy/llama-local, config: { temperature: 0.9, max_tokens: 700 }, color: #ff6b6b } ]; // 全局应用状态 window.APP_STATE { prompt: , // 当前输入的提示词 results: {}, // 以 modelId 为键存储每个模型的结果和状态 selectedModels: new Set(MODEL_CONFIGS.map(m m.id)) // 默认全选 }; // 初始化每个模型的结果状态 MODEL_CONFIGS.forEach(model { window.APP_STATE.results[model.id] { status: idle, // idle, loading, success, error content: , errorMsg: , latency: 0, tokenUsage: 0 }; });3.2 主应用逻辑 (app.js)这个文件较长我们分步实现。// app.js document.addEventListener(DOMContentLoaded, function() { // 获取DOM元素 const promptInput document.getElementById(promptInput); const submitBtn document.getElementById(submitBtn); const clearBtn document.getElementById(clearBtn); const resultsContainer document.getElementById(resultsContainer); const modelSelectorContainer document.querySelector(.model-selector); // 1. 初始化模型选择器 function initModelSelector() { // 清空容器除了“全选” const allCheckbox modelSelectorContainer.querySelector(label[forall]); modelSelectorContainer.innerHTML ; if (allCheckbox) { modelSelectorContainer.appendChild(allCheckbox); } // 为每个模型创建复选框 window.MODEL_CONFIGS.forEach(model { const label document.createElement(label); const checkbox document.createElement(input); checkbox.type checkbox; checkbox.className model-check; checkbox.value model.id; checkbox.checked window.APP_STATE.selectedModels.has(model.id); checkbox.addEventListener(change, (e) { if (e.target.checked) { window.APP_STATE.selectedModels.add(model.id); } else { window.APP_STATE.selectedModels.delete(model.id); } // 更新“全选”状态 updateSelectAllCheckbox(); // 重新渲染结果卡片隐藏或显示 renderResults(); }); label.appendChild(checkbox); label.appendChild(document.createTextNode( ${model.name})); modelSelectorContainer.appendChild(label); }); // “全选”复选框逻辑 const selectAllCheckbox modelSelectorContainer.querySelector(.model-check[valueall]); if (selectAllCheckbox) { selectAllCheckbox.addEventListener(change, (e) { const isChecked e.target.checked; window.MODEL_CONFIGS.forEach(model { window.APP_STATE.selectedModels[isChecked ? add : delete](model.id); }); // 更新所有复选框的UI状态 document.querySelectorAll(.model-check:not([valueall])).forEach(cb { cb.checked isChecked; }); renderResults(); }); } } function updateSelectAllCheckbox() { const allCheckbox document.querySelector(.model-check[valueall]); if (allCheckbox) { const allSelected window.MODEL_CONFIGS.every(m window.APP_STATE.selectedModels.has(m.id)); allCheckbox.checked allSelected; allCheckbox.indeterminate !allSelected window.APP_STATE.selectedModels.size 0; } } // 2. 渲染结果卡片 function renderResults() { resultsContainer.innerHTML ; // 只渲染选中的模型 window.MODEL_CONFIGS .filter(model window.APP_STATE.selectedModels.has(model.id)) .forEach(model { const result window.APP_STATE.results[model.id]; const card document.createElement(div); card.className model-card; card.style.borderTopColor model.color; card.dataset.modelId model.id; let statusText ; switch(result.status) { case idle: statusText 等待中; break; case loading: statusText 请求中...; break; case success: statusText 完成; break; case error: statusText 错误: ${result.errorMsg}; break; } let contentHtml ; if (result.status loading result.content ) { contentHtml div classresult-texti classfas fa-spinner fa-spin/i 正在获取响应.../div; } else if (result.status error) { contentHtml div classresult-text stylecolor: #dc3545;i classfas fa-exclamation-triangle/i ${result.errorMsg}/div; } else { // 如果是流式响应可以添加一个闪烁光标类 const streamingClass result.status loading ? streaming : ; contentHtml div classresult-text ${streamingClass}${escapeHtml(result.content) || 暂无内容}/div; } let metaHtml ; if (result.status success || result.status loading) { metaHtml span classmeta-info耗时: ${result.latency ? result.latency.toFixed(2) s : --} | Tokens: ${result.tokenUsage || --}/span; } card.innerHTML div classcard-header h3${model.name}/h3 span classstatus-badge status-${result.status}${statusText}/span /div div classcard-content ${contentHtml} /div div classcard-footer ${metaHtml} button classbtn-icon title复制内容 onclickcopyToClipboard(${model.id}) i classfas fa-copy/i /button /div ; resultsContainer.appendChild(card); }); } // 简单的HTML转义防止XSS function escapeHtml(text) { const div document.createElement(div); div.textContent text; return div.innerHTML; } // 3. 复制到剪贴板功能 window.copyToClipboard function(modelId) { const text window.APP_STATE.results[modelId]?.content; if (!text) return; navigator.clipboard.writeText(text).then(() { alert(已复制 ${modelId} 的内容到剪贴板); }).catch(err { console.error(复制失败:, err); }); }; // 4. 核心发送请求到后端代理 async function queryModel(modelConfig) { const modelId modelConfig.id; const state window.APP_STATE.results[modelId]; // 更新状态为加载中 state.status loading; state.content ; state.errorMsg ; state.latency 0; renderResults(); const startTime Date.now(); const requestBody { prompt: window.APP_STATE.prompt, model: modelId, ...modelConfig.config }; try { const response await fetch(modelConfig.apiEndpoint, { method: POST, headers: { Content-Type: application/json, }, body: JSON.stringify(requestBody) }); const endTime Date.now(); state.latency (endTime - startTime) / 1000; if (!response.ok) { throw new Error(HTTP ${response.status}: ${response.statusText}); } const data await response.json(); state.status success; state.content data.content || data.text || JSON.stringify(data, null, 2); state.tokenUsage data.usage?.total_tokens || data.tokenCount || 0; } catch (error) { console.error(请求模型 ${modelConfig.name} 失败:, error); state.status error; state.errorMsg error.message || 未知网络错误; } finally { renderResults(); } } // 5. 处理流式响应高级功能 // 如果你的后端支持并返回流式响应可以使用此函数替代上面的 queryModel async function queryModelStreaming(modelConfig) { const modelId modelConfig.id; const state window.APP_STATE.results[modelId]; state.status loading; state.content ; state.errorMsg ; renderResults(); const startTime Date.now(); const requestBody { prompt: window.APP_STATE.prompt, model: modelId, stream: true, // 告诉后端需要流式响应 ...modelConfig.config }; try { const response await fetch(modelConfig.apiEndpoint, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(requestBody) }); if (!response.ok) { throw new Error(HTTP ${response.status}); } const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let accumulatedText ; while (true) { const { done, value } await reader.read(); if (done) break; const chunk decoder.decode(value, { stream: true }); // 假设后端以 data: {...}\n\n 的 Server-Sent Events 格式返回 const lines chunk.split(\n); for (const line of lines) { if (line.startsWith(data: )) { const dataStr line.slice(6); if (dataStr [DONE]) continue; try { const data JSON.parse(dataStr); const delta data.choices?.[0]?.delta?.content || data.text || ; accumulatedText delta; // 实时更新UI state.content accumulatedText; renderResults(); // 注意频繁渲染可能影响性能可优化为节流更新 } catch (e) { console.warn(解析流数据失败:, e, 原始数据:, dataStr); } } } } const endTime Date.now(); state.latency (endTime - startTime) / 1000; state.status success; state.content accumulatedText; } catch (error) { console.error(流式请求模型 ${modelConfig.name} 失败:, error); state.status error; state.errorMsg error.message; } finally { renderResults(); } } // 6. 并发请求所有选中的模型 async function queryAllSelectedModels() { const prompt promptInput.value.trim(); if (!prompt) { alert(请输入提示词); return; } if (window.APP_STATE.selectedModels.size 0) { alert(请至少选择一个模型进行对比); return; } window.APP_STATE.prompt prompt; // 重置所有选中模型的状态保持未选中的不变 for (const modelId of window.APP_STATE.selectedModels) { const state window.APP_STATE.results[modelId]; if (state) { state.status idle; state.content ; state.errorMsg ; state.latency 0; } } renderResults(); // 为每个选中的模型创建请求Promise const promises window.MODEL_CONFIGS .filter(model window.APP_STATE.selectedModels.has(model.id)) .map(model queryModel(model)); // 或使用 queryModelStreaming(model) // 使用 Promise.allSettled 确保所有请求完成无论成功失败 await Promise.allSettled(promises); console.log(所有模型请求已完成); } // 7. 清空所有结果 function clearAllResults() { window.APP_STATE.prompt ; promptInput.value ; for (const modelId in window.APP_STATE.results) { const state window.APP_STATE.results[modelId]; state.status idle; state.content ; state.errorMsg ; state.latency 0; } renderResults(); } // 8. 绑定事件监听器 submitBtn.addEventListener(click, queryAllSelectedModels); // 支持按 Enter 键提交CtrlEnter 或 CmdEnter promptInput.addEventListener(keydown, (e) { if ((e.ctrlKey || e.metaKey) e.key Enter) { queryAllSelectedModels(); } }); clearBtn.addEventListener(click, clearAllResults); // 初始化应用 initModelSelector(); renderResults(); });4. 关键环节解析与生产环境考量代码能跑起来只是第一步。要让这个看板真正可用、可靠你需要关注下面这些点。4.1 安全第一为什么必须使用后端代理这是本项目最重要的安全约束。绝对不要将 OpenAI、Anthropic 等服务的 API Key 硬编码在前端 JS 或 HTML 中。任何用户打开浏览器开发者工具都能看到。正确做法搭建一个简单的后端服务可以用 Node.js Express、Python Flask、Go 等任何你熟悉的技术。在后端环境中配置 API Key通过环境变量读取。前端只向你自己的后端发送请求如POST /api/proxy/openai。后端服务验证前端请求可加入简单的身份验证或频率限制然后用自己的 API Key 转发请求到真正的 AI 服务并将结果返回给前端。示例 Node.js (Express) 代理端点// server.js (后端示例) const express require(express); const axios require(axios); require(dotenv).config(); const app express(); app.use(express.json()); app.post(/api/proxy/openai, async (req, res) { try { const { prompt, model, temperature, max_tokens } req.body; const response await axios.post( https://api.openai.com/v1/chat/completions, { model: model || gpt-4-turbo-preview, messages: [{ role: user, content: prompt }], temperature, max_tokens }, { headers: { Authorization: Bearer ${process.env.OPENAI_API_KEY}, Content-Type: application/json } } ); // 提取响应内容 const content response.data.choices[0].message.content; const usage response.data.usage; res.json({ content, usage }); } catch (error) { console.error(OpenAI代理错误:, error.response?.data || error.message); res.status(500).json({ error: error.message }); } }); // 类似地为 Claude、Gemini 等添加其他代理端点 app.listen(3001, () console.log(代理服务器运行在 3001 端口));4.2 请求管理与用户体验优化取消请求如果用户频繁点击“开始对比”应该取消之前的未完成请求。可以使用AbortController。const controller new AbortController(); fetch(url, { signal: controller.signal }); // 需要取消时调用 controller.abort()请求超时为fetch设置超时避免长时间挂起。const timeout 60000; // 60秒 const timeoutPromise new Promise((_, reject) setTimeout(() reject(new Error(请求超时)), timeout) ); await Promise.race([fetch(...), timeoutPromise]);节流渲染对于流式响应频繁调用renderResults()会导致页面卡顿。可以使用requestAnimationFrame或 Lodash 的throttle进行优化。进度指示对于非流式请求可以显示一个进度条或百分比提升等待体验。4.3 错误处理与健壮性我们的代码已经有了基本的try...catch但在生产环境中需要更细致网络错误区分超时、断网、CORS 错误、服务器 5xx 错误。API 错误AI 服务可能返回429限速、402额度不足、503服务繁忙。后端代理应将这些错误信息清晰地传递回前端。结果解析错误API 响应格式可能变化使用data.choices?.[0]?.message?.content这样的可选链操作符可以避免Cannot read property ... of undefined错误。降级处理如果某个模型完全不可用是否提供备用模型或者至少让其他模型的对比正常进行。4.4 数据持久化与分享一个实用的看板可能还需要历史记录将每次的提示词和结果保存到localStorage方便回顾。分享链接将选中的模型和提示词编码到 URL 的 hash 或 query string 中生成可分享的链接。导出结果支持将对比结果导出为 JSON、Markdown 或 PDF 格式。4.5 性能与扩展性虚拟滚动如果对比的模型非常多比如10个以上每个结果卡片内容又很长一次性渲染所有DOM节点会影响性能。可以考虑虚拟滚动只渲染可视区域内的卡片。Web Worker如果结果对比涉及复杂的数据计算比如计算文本相似度、提取关键词可以将这些计算任务放到 Web Worker 中避免阻塞主线程。配置化将MODEL_CONFIGS放到一个独立的 JSON 文件中甚至通过一个管理界面动态添加/删除模型而无需修改代码。5. 部署与后续迭代建议5.1 如何部署前端你的index.html,style.css,app.js,config.js可以放在任何静态文件服务器上如 Nginx、Apache、Vercel、Netlify 或 GitHub Pages。后端代理需要部署在一个可以安全存储环境变量的服务器或云函数上如 AWS Lambda, Vercel Serverless Function, Google Cloud Functions。确保该后端服务有 HTTPS 支持。CORS 配置在你的后端代理中设置正确的 CORS 头允许你的前端域名访问。// Express 示例 const cors require(cors); app.use(cors({ origin: https://你的前端域名.com }));5.2 从纯前端到框架的平滑过渡这个纯三件套项目是一个完美的学习原型。当你需要更复杂的状态管理、组件复用和构建流程时可以轻松地将其迁移到现代前端框架React将每个model-card变成一个ModelCard组件应用状态用useState和useContext或状态管理库如 Zustand, Redux管理。Vue将每个model-card变成一个 Vue 单文件组件状态用ref或Pinia管理。Svelte响应式状态管理更为直接几乎可以无缝转换。核心逻辑——状态定义、请求函数、UI渲染——是完全通用的。5.3 功能扩展方向输入模板提供一些预设的提示词模板如“写周报”、“代码审查”、“头脑风暴”方便用户快速测试。参数调节面板为每个模型暴露一个可折叠的面板允许用户实时调整temperature、max_tokens等参数。结果评分/投票允许用户为不同模型的结果打分数据保存在本地或后端。自动测试套件给定一组标准问题自动运行所有模型并生成对比报告。这个项目的价值不在于界面有多炫而在于它清晰地展示了一个复杂前端应用的核心模式状态驱动UI、并发异步操作、统一的错误处理、以及清晰的数据流。把这些基础打牢以后面对任何复杂的需求你都能快速拆解和实现。 30款热门AI模型一站整合DeepSeek/GLM/Qwen 随心用限时 5 折。 点击领海量免费额度