前言
如今,集成多模态大模型 API 的前端项目模式 愈发普遍,从电商平台调用图像识别 API 实现商品检索,到教育应用接入语音合成 API 优化交互体验,各类场景都在通过 「大模型能力嫁接」 提升服务价值。这类项目的核心逻辑在于打破技术壁垒,以 API 调用实现视觉、语言等多模态能力的协同 ------ 而本项目正是这一趋势的实践延伸,通过整合 「月之暗面」图像分析与「字节 TTS」语音合成 API ,构建了 「图片语义理解 - 语音知识输出」 的学习闭环
一、项目效果展示
项目链接github地址 github.com/Objecteee/-...
该项目可以分析用户上传的图片总结出图片中事物对应的英文单词,并给出一些例句和问题,而且支持语音跟读,接下来我会详细介绍该功能的实现

注:是有声音的
二、项目整体思路
- 视觉语义解析 :调用「月之暗面」视觉大模型 API ,对用户上传图片进行多模态分析,不仅识别物体、场景等视觉元素,还提炼核心语义,生成对应的英文单词、情景化例句及语法解释;
- 语言模态转换 :借助「字节 TTS」语音大模型 API ,将视觉大模型输出的文字内容转化为自然语音,支持根据语义情感调整语调、重音(如描述动态场景时语速加快);
- 跨模态协同 :通过前端逻辑串联两大模型 API,使视觉理解结果直接驱动语音合成参数,形成 "图片内容→大模型语义生成→大模型语音演绎" 的全链路智能交互,最终以 "看图学英文" 的形式实现技术落地。

三、项目的功能实现
1.图片预览与上传功能
用户可以上传图片并可以实时预览
js
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中,使函数可以这样使用:
javascript
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设计
jsx
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
js
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大模型返回数据
js
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新特性的使用
ini
const file = (e.target).files?.[0];
if (!file) { return; }
这段代码中 const file = (e.target).files?.[0];
使用了 ES6 的两个重要特性:
- 可选链操作符 (Optional Chaining Operator
?.
) - 数组元素的可选访问
可选链操作符 (?.)
可选链操作符 ?.
允许你安全地访问嵌套的对象属性 ,而无需明确验证每个引用是否有效。如果引用是 null
或 undefined
,表达式会短路并返回 undefined
,而不是抛出错误。
传统写法:
ini
const file = e.target && e.target.files && e.target.files[0];
ES6 可选链写法:
ini
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设计
jsx
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 能力与传统前端技术的深度协同。通过结构化数据转换和情感化语音合成,不仅提升了学习趣味性,也为教育类应用的智能化转型提供了可复用的技术方案,未来可扩展多语种支持和学习进度跟踪等功能。