前言
如今,集成多模态大模型 API 的前端项目模式愈发普遍,从电商平台调用图像识别 API实现商品检索,到教育应用接入语音合成 API 优化交互体验,各类场景都在通过 「大模型能力嫁接」 提升服务价值。这类项目的核心逻辑在于打破技术壁垒,以 API 调用实现视觉、语言等多模态能力的协同 —— 而本项目正是这一趋势的实践延伸,通过整合 「月之暗面」图像分析与「字节 TTS」语音合成 API,构建了 「图片语义理解 - 语音知识输出」 的学习闭环
一、项目效果展示
项目链接github地址github.com/Objecteee/-…
该项目可以分析用户上传的图片总结出图片中事物对应的英文单词,并给出一些例句和问题,而且支持语音跟读,接下来我会详细介绍该功能的实现
注:是有声音的
二、项目整体思路
- 视觉语义解析:调用「月之暗面」视觉大模型 API,对用户上传图片进行多模态分析,不仅识别物体、场景等视觉元素,还提炼核心语义,生成对应的英文单词、情景化例句及语法解释;语言模态转换:借助「字节 TTS」语音大模型 API,将视觉大模型输出的文字内容转化为自然语音,支持根据语义情感调整语调、重音(如描述动态场景时语速加快);跨模态协同:通过前端逻辑串联两大模型 API,使视觉理解结果直接驱动语音合成参数,形成 “图片内容→大模型语义生成→大模型语音演绎” 的全链路智能交互,最终以 “看图学英文” 的形式实现技术落地。
三、项目的功能实现
1.图片预览与上传功能
用户可以上传图片并可以实时预览
const [imgPreview, setImgPreview] = useState('https://res.bearbobo.com/resource/upload/W44yyxvl/upload-ih56twxirei.png') const uploadImgData=(e)=>{ const file = (e.target).files?.[0]; if (!file) { return; } return new Promise((resolve, reject) => { const reader = new FileReader(); reader.readAsDataURL(file); reader.onload = () => { const data = reader.result; setImgPreview(data); uploadImg(data); resolve(data); } reader.onerror = (error) => { reject(error); }; }) }
这段代码主要使用了React的useState
和JavaScript的FileReader
API。
首先使用了useState定义了响应式变量imgPreview,用于存储图片数据,默认的图片样式为res.bearbobo.com/resource/up… ,即下面的样式
接下来便是使用JavaScript的FileReader API,实现图片数据的存储
1.es6新特性的使用——可选链操作符(?.):
使用e.target.files?.[0]
中的可选链操作符(?.),即使files为null也不会报错这种写法比传统的e.target.files && e.target.files[0]
更加简洁
2.Promise的巧妙运用:
将整个读取过程封装在Promise中,使函数可以这样使用:
handleImageUpload(event) .then(data => console.log('处理成功', data)) .catch(err => console.error('出错了', err))
文件的读取是一个大任务,我们使用异步操作处理
3.FileReader的工作机制:
readAsDataURL
方法会将文件转换为Data URL格式这种格式可以直接赋值给img标签的src属性实现预览示例Data URL:data:image/png;base64,iVBORw0KGgoAAAAN...
4.双重数据流处理,更好的用户体验:
一方面通过setImgPreview
更新界面,另一方面通过uploadToServer
准备上传,这种设计实现了预览和上传的并行处理,对用户更友好
5.完善的错误处理:
通过Promise的reject机制传递错误,确保读取过程中的任何错误都能被捕获处理
2.图片解析与月之暗面Promote设计
const userPrompt = `分析图片内容,找出最能描述图片的一个英文单词,尽量选择更简单的A1~A2的词汇。 返回JSON数据: { "image_discription": "图片描述", "representative_word": "图片代表的英文单词", "example_sentence": "结合英文单词和图片描述,给出一个简单的例句", "explaination": "结合图片解释英文单词,段落以Look at...开头,将段落分句,每一句单独一行,解释的最后给一个日常生活有关的问句", "explaination_replys": ["根据explaination给出的回复1", "根据explaination给出的回复2"] }`; // 上传图片的状态 const [word, setWord] = useState('请上传图片'); // 例句 const [sentence, setSentence] = useState('') // 解释 const [explainations, setExplainations] = useState([]); const [expReply, setExpReply] = useState([]) const [imgPreview, setImgPreview] = useState('https://res.bearbobo.com/resource/upload/W44yyxvl/upload-ih56twxirei.png') const uploadImg = async (imageData) => { setImgPreview(imageData); const endpoint = 'https://api.moonshot.cn/v1/chat/completions'; const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${import.meta.env.VITE_KIMI_API_KEY}` }; setWord('分析中...'); const response = await fetch(endpoint, { method: 'POST', headers: headers, body: JSON.stringify({ model: 'moonshot-v1-8k-vision-preview', messages: [ { role: 'user', content: [ { type: "image_url", image_url: { "url": imageData, }, }, { type: "text", text: userPrompt, }] }], stream: false }) }) const data = await response.json(); const replyData = JSON.parse(data.choices[0].message.content); // console.log(replyData); setWord(replyData.representative_word); setSentence(replyData.example_sentence); setExplainations(replyData.explaination.split('\n')) setExpReply(replyData.explaination_replys); }
这段代码实现了调用月之暗面API分析图片的工作,接下来我们来分析一下这段代码的实现
这段代码的最重要的逻辑便是Promote的设计,限制了大模型回答为JSON的格式,以便于使用JSON.stringify将大模型的回答转为JSON格式,某种意义上实现了回答->文本->JSON->数据的转变,这便是调用API的核心思想
之后我们依照大模型返回的数据格式解析出我们需要的格数据即可
肥肠有石粒!
3.调用字节tts的API
const getAudioUrl = (base64Data) => { // 创建一个数组来存储字节数据 var byteArrays = []; // 使用atob()将Base64编码的字符串解码为原始二进制字符串 // atob: ASCII to Binary var byteCharacters = atob(base64Data); // 遍历解码后的二进制字符串的每个字符 for (var offset = 0; offset < byteCharacters.length; offset++) { // 将每个字符转换为其ASCII码值(0-255之间的数字) var byteArray = byteCharacters.charCodeAt(offset); // 将ASCII码值添加到字节数组中 byteArrays.push(byteArray); } // 创建一个Blob对象 // new Uint8Array(byteArrays)将普通数组转换为8位无符号整数数组 // { type: 'audio/mp3' } 指定Blob的MIME类型为MP3音频 var blob = new Blob([new Uint8Array(byteArrays)], { type: 'audio/mp3' }); // 使用URL.createObjectURL创建一个临时的URL // 这个URL可以用于<audio>标签的src属性 // 这个URL在当前页面/会话有效,页面关闭后会自动释放 // 创建一个临时 URL 供音频播放 return URL.createObjectURL(blob);}export const generateAudio= async(text)=>{ const token = import.meta.env.VITE_AUDIO_ACCESS_TOKEN; const appId = import.meta.env.VITE_AUDIO_APP_ID; const clusterId = import.meta.env.VITE_AUDIO_CLUSTER_ID; const voiceName = import.meta.env.VITE_AUDIO_VOICE_NAME; const endpoint = '/tts/api/v1/tts'; const headers = { 'Content-Type': 'application/json', Authorization: `Bearer;${token}`, }; // 请求体 const payload = { app: { appid: appId, token, cluster: clusterId, }, user: { uid: 'bearbobo', }, audio: { voice_type: voiceName, encoding: 'ogg_opus', compression_rate: 1, rate: 24000, speed_ratio: 1.0, volume_ratio: 1.0, pitch_ratio: 1.0, emotion: 'happy', // language: 'cn', }, request: { reqid: Math.random().toString(36).substring(7), text, text_type: 'plain', operation: 'query', silence_duration: '125', with_frontend: '1', frontend_type: 'unitTson', pure_english_opt: '1', }, }; const res=await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(payload), }); const data=await res.json(); console.log(data,'////////'); const url=getAudioUrl(data.data) return url;}
其实我认为这段tts的代码虽然比较长,但是是比较简单的,毕竟返回的数据很简单,而且不需要我们自己设计Promote
而各种的看似复杂没有逻辑的参数,大家去查文档就可以了,其实真正的重头戏是下面的处理tts返回的数据
4.处理字节tts大模型返回数据
const getAudioUrl = (base64Data) => { // 创建一个数组来存储字节数据 var byteArrays = []; // 使用atob()将Base64编码的字符串解码为原始二进制字符串 // atob: ASCII to Binary var byteCharacters = atob(base64Data); // 遍历解码后的二进制字符串的每个字符 for (var offset = 0; offset < byteCharacters.length; offset++) { // 将每个字符转换为其ASCII码值(0-255之间的数字) var byteArray = byteCharacters.charCodeAt(offset); // 将ASCII码值添加到字节数组中 byteArrays.push(byteArray); } // 创建一个Blob对象 // new Uint8Array(byteArrays)将普通数组转换为8位无符号整数数组 // { type: 'audio/mp3' } 指定Blob的MIME类型为MP3音频 var blob = new Blob([new Uint8Array(byteArrays)], { type: 'audio/mp3' }); // 使用URL.createObjectURL创建一个临时的URL // 这个URL可以用于<audio>标签的src属性 // 这个URL在当前页面/会话有效,页面关闭后会自动释放 // 创建一个临时 URL 供音频播放 return URL.createObjectURL(blob);}
tts返回的数据是音频的Base64格式,我们需要将其转化为Bolb+URL的格式,方便我们继续使用,至于为什么有这样做,可以看我之前发的也一篇博客,里面有详尽的解释AI时代,前端如何处理大模型返回的多模态数据?- 掘金
这段代码的主要功能是将Base64编码的音频数据转换为可直接播放的临时URL。首先,它使用atob()
函数将Base64字符串解码为二进制数据,这个过程中每个字符会被转换为对应的ASCII码值(0-255之间的数字),这些值被逐个存入字节数组中。
接着,代码通过new Uint8Array()
将普通数组转换为8位无符号整数数组,确保二进制数据的格式正确。然后,利用 new Blob()
将这些二进制数据封装成一个MP3格式的Blob对象,并指定MIME类型为audio/mp3
。
最后,通过URL.createObjectURL()
生成一个临时URL,该URL可以直接用于<audio>
标签的src
属性,从而实现在网页中播放这段音频。
需要注意的是,这个URL仅在当前页面会话中有效,页面关闭后会自动释放内存。
四、项目亮点
1.前端+AI
本项目中 “前端 + AI” 的深度协同,实则是当下技术融合浪潮的缩影。前端作为人机交互的枢纽,将用户行为转化为 AI 可理解的语言 —— 图片转 Base64、Prompt 精准定制,不仅适配月之暗面等模型的调用需求,更暗合了 “交互层智能化” 的行业趋势。
LLM 发展倒逼产品升级智能体验,前端作为交互层责无旁贷。集成 AI 可实现智能搜索等功能,重塑工程师角色,是打造智能产品的关键。
2.es6新特性的使用
const file = (e.target).files?.[0]; if (!file) { return; }
这段代码中 const file = (e.target).files?.[0];
使用了 ES6 的两个重要特性:
- 可选链操作符 (Optional Chaining Operator
?.
)数组元素的可选访问可选链操作符 (?.)
可选链操作符 ?.
允许你安全地访问嵌套的对象属性,而无需明确验证每个引用是否有效。如果引用是 null
或 undefined
,表达式会短路并返回 undefined
,而不是抛出错误。
传统写法:
const file = e.target && e.target.files && e.target.files[0];
ES6 可选链写法:
const file = e.target?.files?.[0];
代码解析
e.target?.files?.[0]
的解析过程:
e.target?
- 首先检查 e.target
是否存在(不为 null
或 undefined
)
- 如果不存在,整个表达式返回
undefined
如果存在,继续访问 .files
.files?
- 然后检查 files
属性是否存在
- 如果不存在,整个表达式返回
undefined
如果存在,继续访问 [0]
[0]
- 最后访问数组的第一个元素
3.背景的性能优化
使用CSS线性渐变(linear-gradient
)作为背景,相比传统的图片URL背景(如background: url("image.jpg")
),在性能、灵活性和开发效率上都有明显优势。
线性渐变背景—— 无HTTP请求:完全由CSS生成,不需要额外下载图片文件,减少网络请求。
URL背景—— 需要HTTP请求:必须下载图片文件,增加网络开销,尤其是在慢速连接下。可能阻塞渲染:大图片或慢网络时,背景图未加载完成可能导致布局延迟(除非优化loading="lazy"
)。
4.组件拆分
该项目在组件拆分上展现出清晰的逻辑和良好的可维护性。整体遵循组件化开发原则,将不同功能封装在独立的组件中根组件App
负责整体的状态管理、API 请求和页面结构搭建,它持有上传图片、单词、例句等多个状态,并通过fetch
方法向 API 发送请求以获取图片分析结果。这种设计使得应用的核心逻辑集中在根组件,便于管理和维护。
子组件PictureCard
则专注于图片上传和展示功能。它接收来自父组件App
的word
、audio
和uploadImg
等属性,处理图片选择和预览,并在用户上传图片时调用父组件传递的uploadImg
函数。通过这种方式,子组件仅关注自身的具体功能,实现了功能的解耦,提高了代码的复用性和可测试性组件之间通过props
进行数据传递和通信,确保了数据的流向清晰,增强了代码的可读性和可维护性。
5.html5的文件对象
这是个亮点,因为这是HTML5的新特性,而且操作方便,前文有解释,这里不再赘述
6.大模型的Promote设计
const userPrompt = `分析图片内容,找出最能描述图片的一个英文单词,尽量选择更简单的A1~A2的词汇。 返回JSON数据: { "image_discription": "图片描述", "representative_word": "图片代表的英文单词", "example_sentence": "结合英文单词和图片描述,给出一个简单的例句", "explaination": "结合图片解释英文单词,段落以Look at...开头,将段落分句,每一句单独一行,解释的最后给一个日常生活有关的问句", "explaination_replys": ["根据explaination给出的回复1", "根据explaination给出的回复2"] }`;
本项目对|月之暗面|大模型的Promote很有考究。 通过上述的Promote设计,限定了大模型的回答的格式,便于将文本转化为JSON数据,以便于后续对该数据的应用
7.环境变量的处理
项目对环境变量进行了规范处理,提高了代码的安全性和可移植性。将 API 密钥、接口地址等敏感信息和环境相关配置存储在环境变量中(如使用.env 文件),通过工具(如 Vite、Create React App)将环境变量注入到前端代码中。
在开发、测试和生产等不同环境中,只需修改对应的环境变量配置,无需修改代码即可实现环境的切换,避免了敏感信息泄露的风险,也方便了项目的部署和维护。
8.libs工具包
本项目将getAudioUrl单独封装到了libs工具包中,这样处理有很多好处
1. 实现代码复用,减少冗余
getAudioUrl 负责将 Base64 音频数据转换为可播放的 Blob URL,generateAudio 封装了字节 TTS API 的完整调用逻辑(包括环境变量获取、请求头构建、参数组装等)。这两个函数是项目中语音合成功能的核心依赖,当多个组件(如 AudioPlayer、ResultDisplay)需要生成音频时,无需重复编写相同逻辑,直接从 lib 包导入即可。 例如在不同页面调用语音合成时,只需一行 import { generateAudio } from '@/lib/audio',大幅减少了代码量。
2. 降低耦合度,提升可维护性
将音频处理逻辑与 UI 组件分离后,组件只需关注 “何时调用音频功能”,而无需关心 “音频如何生成”。当 TTS API 接口变更(如参数调整、域名修改)或音频处理逻辑优化(如支持更多格式)时,只需修改 lib 包中的函数,无需遍历所有调用组件。例如若字节 TTS 更换了认证方式,仅需在 generateAudio 中更新请求头,所有依赖该函数的组件会自动同步变更,降低了维护成本。
9.组件通信哲学
数据「自上而下」,行为「自下而上」
父组件作为数据的唯一源头,通过 props 将数据「向下」传递给子组件。例如父组件的 imgBase64
状态,仅通过 <PictureCard imgBase64={imgBase64} />
传递给子组件用于预览,子组件绝不对其直接修改。
而子组件的交互行为(如上传图片、播放音频),则通过回调函数「向上」反馈给父组件,由父组件统一处理并更新状态。 比如 PictureCard
的 onUpload
回调仅传递选中的图片文件,最终由父组件的 setImgBase64
修改状态 —— 数据始终从源头流动,子组件只做「消费」而非「生产」。
五、项目总结
本项目通过整合 「月之暗面」视觉大模型和「字节 TTS」语音合成 API,构建了一个跨模态的英语学习应用,实现了从图片解析到语音输出的完整闭环。前端采用组件化设计,结合精准的 Prompt 工程和环境变量管理,确保功能解耦与安全性,同时利用 FileReader 和 Blob URL 技术优化交互体验,为用户提供流畅的 "视觉-语义-语音"学习链路。
该项目验证了多模态 API 在前端落地的可行性,展示了 AI 能力与传统前端技术的深度协同。通过结构化数据转换和情感化语音合成,不仅提升了学习趣味性,也为教育类应用的智能化转型提供了可复用的技术方案,未来可扩展多语种支持和学习进度跟踪等功能。