巨蛋乐园
29.11M · 2026-03-22
随着大语言模型(LLM)能力的边界不断拓展,前端开发的范式正在发生微妙的变化。过去我们需要后端提供结构化的数据接口,现在前端可以直接与多模态模型对话,让应用具备“看”和“说”的能力。
今天我想分享一个小型的全栈实践案例:一个“拍照记单词”的应用。它的核心逻辑很简单:用户拍摄或上传一张生活照片,系统识别图片内容,提取一个适合初学者的英文单词,生成例句,并朗读出来。
虽然功能看似简单,但在实现过程中,涉及到了文件处理、多模态 API 调用、音频流处理以及 Prompt 工程等多个技术点。本文将剥离出核心代码逻辑,探讨其中的实现细节、设计考量以及潜在的优化空间。
在传统的文件上传场景中,我们通常将文件直接提交给后端。但在这个应用中,图片需要同时做两件事:
在 PictureCard 组件中,文件上传的实现采用了经典的 input + label 组合模式:
<input type="file" id="selecteImage" class="input" accept="image/*" @change="updateImageData">
<label for="selecteImage" class="upload">
<img :src="imgPreview" alt="camera" class="img">
</label>
这里有两个细节值得注意:
首先是无障碍访问(Accessibility)。原生的 input[type="file"] 样式难以定制,且在不同浏览器上表现不一。通过 display: none 隐藏 input,并使用 label 关联 id,我们既获得了完全自由的样式控制权,又保留了语义化。当用户点击美观的相机图标时,实际上触发的是原生文件选择器。对于使用读屏器的视障用户,label 标签能准确传达“上传图片”的意图,这是开发中容易忽视但至关重要的细节。
其次是文件读取机制。为了将图片发送给 LLM,我们需要将其转换为 Base64 格式。这里使用了 HTML5 提供的 FileReader API:
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
const data = reader.result as string;
imgPreview.value = data;
emit('update-image', data);
}
readAsDataURL 会将文件内容读取为一个包含 MIME 类型的 Base64 字符串(例如 data:image/png;base64,...)。
img 标签的 src 进行预览。应用的核心智能来源于对 Kimi(Moonshot)多模态接口的调用。在 App.vue 中,我们构建了请求体。
目前主流的多模态模型(如 GPT-4V, Moonshot-v1-vision)在接收图片时,通常要求 messages 中的 content 字段是一个数组,分别包含文本和图片对象:
messages: [
{
role: 'user',
content: [{
type: 'image_url',
image_url: { url: imageDate } // 这里是 Base64 或 HTTP URL
}, {
type: 'text',
text: userPrompt
}]
}
]
这种设计允许模型同时“看”到图片并“读”到指令。需要注意的是,虽然代码中直接使用了 Base64,但如果图片较大,建议先上传至对象存储(OSS),将 HTTP URL 传给模型,以减少请求包体大小。
在 userPrompt 的设计上,我们没有让模型自由发挥,而是严格限制了输出格式:
返回 JSON 数据:
{
"representative_word": "图片代表的英文单词",
"example_sentence": "结合英文单词和图片描述,给出一个简单的例句",
"explaination": "...",
...
}
这是开发 AI 应用的一个关键原则:机器与人对话可以自然,但机器与代码对话必须严谨。
通过要求模型返回 JSON,我们可以直接 JSON.parse 结果,将单词、句子、解释分发到不同的 UI 区域。如果让模型自由返回文本,前端就需要编写复杂的正则去提取单词,这不仅脆弱,而且容易出错。此外,Prompt 中明确了词汇难度(A1~A2),这是产品价值的体现——我们不是在做一个翻译工具,而是在做一个适合初学者的教育工具。
当模型返回例句后,应用需要调用 TTS(Text-to-Speech)服务将文本转为音频。这里涉及到了二进制数据的处理。
TTS 接口返回的通常是音频文件的 Base64 数据。在 audio.ts 中,我们实现了一个 createBlobURL 函数:
const byteCharacters = atob(base64AudioData);
// ... 转换为 Uint8Array
const audioBlob = new Blob([new Uint8Array(byteArrays)], { type: 'audio/mp3' });
const blobURL = URL.createObjectURL(audioBlob);
这里有一个常见的疑问:为什么不直接使用 data:audio/mp3;base64,... 赋值给 audio 标签?
虽然 Data URI 可以直接播放,但在处理较长音频或高频调用时,Blob URL 方案更具优势:
URL.createObjectURL 创建的引用是可以被显式释放的(通过 URL.revokeObjectURL)。虽然示例代码中为了简洁未展示释放逻辑,但在组件卸载时调用释放,可以有效防止内存泄漏。在代码审查中,我发现了一个值得注意的细节:
encoding: 'ogg_opus'。audio/mp3。这可能会导致部分浏览器播放失败或无法识别时长。严谨的做法是根据 API 实际返回的音频流格式来设定 Blob 的 type,或者在 API 请求时直接要求返回 MP3 格式。这提醒我们在对接第三方服务时,必须严格核对输入输出的格式规范。
在复盘整个项目时,除了功能实现,还有几个架构层面的问题需要深入探讨。
在 App.vue 中,我们看到了这样的代码:
'Authorization': `Bearer ${import.meta.env.VITE_KIMI_API_KEY}`
这是一个严重的安全隐患。 将 LLM 的 API Key 直接暴露在前端代码中,意味着任何查看网页源码的用户都可以获取你的密钥,从而盗用你的额度。
改进方案:
README 中提到了技术栈包含 NestJS。正确的架构应该是:
目前的实现仅适合本地学习或内部演示,绝不可直接部署到公网。
当前逻辑集中在 App.vue 中,包括图片状态、单词状态、音频状态等。随着功能增加(例如历史记录、生词本),组件会变得臃肿。
建议引入状态管理库(如 Pinia),将“学习会话”作为一个 Store 管理。同时,将 generateAudio 和 fetchLLM 封装为独立的 Service 层,与 UI 组件彻底解耦。这样不仅便于测试,也方便后续将 API 调用迁移到后端时,前端只需修改 Service 层的请求地址。
代码中实现了基础的加载状态(如“分析中..."),但在网络波动或 API 报错时,用户体验还可以更好:
new Audio(url) 进行预加载,确保用户点击播放时无延迟。FileReader 读取前,使用 Canvas 对图片进行压缩,能显著提升上传和解析速度。通过这个“拍照记单词”的小应用,我们实践了 Vue3 组合式 API 的组件通信,探索了 FileReader 与 Blob 的二进制处理,并深入体验了多模态大模型的接入流程。
技术本身并不是目的,解决用户痛点才是。在这个案例中,技术的价值在于将“生活中的任意场景”瞬间转化为“可学习的语言素材”,降低了语言学习的门槛。
对于前端开发者而言,拥抱 AI 不仅仅是学会调用 API,更在于理解如何设计 Prompt 以获得稳定的输出,如何处理多媒体数据流,以及如何在享受 AI 便利的同时,守住安全与性能的底线。希望这个案例能为你构建自己的 AI 应用提供一些实在的参考。