学React顺便做个App:拍照学英语的智能前端AI实现之路

117 阅读8分钟

最近在学React,突然想起之前看到的一个很酷的想法:拍照学英语。想着反正也要练手,就干脆自己撸一个出来。

整个应用的流程很简单:用户上传图片 → AI识别图片内容 → 输出英语单词和例句 → 语音播放。听起来简单,但真正动手才发现里面的门道不少。

项目结构

src/
├── components/
│   └── PictureCard/
│       ├── index.jsx
│       └── style.css
├── lib/
│   └── audio.js
├── App.jsx
├── App.css
├── index.css
└── main.jsx

没搞得太复杂,就是标准的React项目结构。每个组件一个文件夹,工具函数放lib里,这样后面维护起来不会太乱。

图片上传这块踩了个坑

一开始觉得图片上传很简单,结果发现细节挺多的:

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); };
    })
}

这里几个点值得说一下:

const file = (e.target).files?.[0]; 这行代码虽然简单,但用了可选链操作符。之前我直接写files[0],结果有时候会报错,因为files可能为空。用了?.[0]之后,即使files是null也不会崩溃,直接返回undefined。

还有就是FileReader这个API,它是异步的,我把它包装成Promise,这样后面用async/await处理起来更顺手。

另外一个小技巧是把input隐藏起来,用label来触发文件选择,这样可以自定义按钮样式。

Moonshot API确实好用

AI识别这块我选了月之暗面的Kimi API,主要是因为它对图片理解比较准确,而且支持中文prompt。(其实是新用户送15元,还是白嫖的香啊😆)

const uploadImg = async (imageData) => {
    const endpoint = 'https://api.moonshot.cn/v1/chat/completions';
    const response = await fetch(endpoint, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${import.meta.env.VITE_KIMI_API_KEY}`
        },
        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
        })
    })
}

这个API的好处是支持多模态输入,可以同时传图片和文字指令。而且返回的结果很稳定,基本上按照我的要求来。

Prompt写了好久

这个项目最费时间的反而是写prompt,改了好几版才满意:

const userPrompt = `分析图片内容,找出最能描述图片的一个英文单词,尽量选择更简单的A1~A2的词汇。

返回JSON数据:
{ 
"image_discription": "图片描述", 
"representative_word": "图片代表的英文单词", 
"example_sentence": "结合英文单词和图片描述,给出一个简单的例句", 
"explaination": "结合图片解释英文单词,段落以Look at...开头,将段落分句,每一句单独一行,解释的最后给一个日常生活有关的问句", 
"explaination_replys": ["根据explaination给出的回复1", "根据explaination给出的回复2"]
}`;

写这个prompt的时候考虑了几个点:

  1. 词汇难度控制:专门要求A1~A2级别的词汇,因为是给初学者用的
  2. 输出格式:要求JSON格式,这样前端解析起来方便
  3. 教学习惯:要求解释以"Look at..."开头,这比较符合英语教学的习惯
  4. 互动性:最后要求一个日常问句,还有两个回复选项,这样更像真实对话

试了几次发现,prompt写得越详细,AI的输出就越稳定。

TTS这块有点复杂

语音合成用了专门的TTS服务,配置项还挺多:

const generateAudio = async (text) => {
    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'
        },
        request: {
            reqid: Math.random().toString(36).substring(7),
            text,
            text_type: 'plain',
            operation: 'query'
        }
    };
    
    const res = await fetch(endpoint, {
        method: 'POST',
        headers,
        body: JSON.stringify(payload)
    });
    
    const data = await res.json();
    const url = getAudioUrl(data.data);
    return url;
}

这里emotion: 'happy'这个参数挺有意思的,可以控制语音的情感。试了几种,happy听起来确实更适合学习场景。

跨域问题差点把我整疯

TTS API接口调用这块遇到了一个大坑:跨域问题。刚开始直接请求https://openspeech.bytedance.com,浏览器直接报错:

Access to fetch at 'https://openspeech.bytedance.com/api/v1/tts' from origin 'http://localhost:5173' has been blocked by CORS policy

试了好几种方法都不行,网上查了一圈,发现最简单的解决方案就是在开发环境用代理。

vite.config.js里加了这个配置:

export default defineConfig({
  plugins: [react()],
  server: {
    allowedHosts: true,
    proxy: {
      '/tts': {
        target: 'https://openspeech.bytedance.com',
        changeOrigin: true,
        rewrite: path => path.replace(/^/tts/, ''),
      }
    },
  }
})

然后把TTS请求的endpoint改成:

const endpoint = '/tts/api/v1/tts';

这样配置之后,所有以/tts开头的请求都会被代理到https://openspeech.bytedance.com,而且changeOrigin: true会修改请求头的origin,让目标服务器认为请求是从同源发出的。

rewrite函数把URL路径中的/tts前缀去掉,这样实际请求的还是原来的API路径。

这个配置解决了几个问题:

  1. 跨域限制:浏览器不会再拦截请求
  2. 开发便利性:不需要额外启动代理服务
  3. 调试友好:可以在浏览器Network面板正常查看请求

需要注意的是,这个代理配置只在开发环境有效。生产环境部署时,要么在服务器端配置代理,要么让API提供方开放CORS支持。

音频数据转换有门道

最有技术含量的是音频数据转换:

const getAudioUrl = (base64Data) => {
    var byteCharacters = atob(base64Data);
    var byteArrays = [];
    for (var offset = 0; offset < byteCharacters.length; offset++) {
        byteArrays.push(byteCharacters.charCodeAt(offset));
    }
    var blob = new Blob([new Uint8Array(byteArrays)], { type: 'audio/mp3' });
    return URL.createObjectURL(blob);
}

TTS API返回的是base64编码的音频数据,不能直接播放。得先解码成二进制,然后转成字节数组,再包装成Blob对象,最后用URL.createObjectURL创建临时URL。

这个转换过程看起来复杂,但有个好处:

URL.createObjectURL创建的临时URL比直接用base64数据URL省内存,而且浏览器会自动管理,页面关闭后就释放了。

最后播放就很简单了:

const playAudio = () => {
    const audioEle = new Audio(audio);
    audioEle.play();
}

一行代码搞定,完全不用考虑音频编码的问题。

组件设计思路

整个应用分了两个主要组件:

  • App组件:管理所有状态,负责API调用
  • PictureCard组件:处理图片上传和展示
// App.jsx里管理状态
const [word, setWord] = useState('请上传图片');
const [sentence, setSentence] = useState('');
const [explainations, setExplainations] = useState([]);
const [audio, setAudio] = useState('');

// PictureCard通过props接收数据
const PictureCard = (props) => {
    const { audio, word, uploadImg } = props;
    // ...
}

这种设计比较经典,父组件持有数据,子组件只负责展示和交互。数据流向清晰,后面改起来也方便。

样式上的一些想法

整个应用的视觉风格偏向现代化,用了渐变背景:

.container {
    background: linear-gradient(180deg, rgb(180, 110, 227) 0%, rgb(75, 191, 220) 100%);
}

主要交互区域用了卡片式设计,还加了阴影效果:

.card {
    border-radius: 8px;
    box-shadow: rgb(63,38,21) 0 3px 0px 0;
    background-color: rgb(105,78,62);
}

详细内容用了底部抽屉的效果,这在移动端很常见:

.details {
    position: absolute;
    bottom: 0;
    left: 50%;
    transform: translateX(-50%);
}

最后的成果

image.png


image.png


image.png


image.png

踩过的坑

环境变量问题:Vite的环境变量要以VITE_开头,一开始没注意这个,API Key一直读不到。

异步状态管理:AI分析图片需要时间,得给用户反馈。我用了setWord('分析中...')来显示加载状态。

音频播放兼容性:本来想用更复杂的Web Audio API,后来发现不同浏览器支持不一样,最后还是用了最简单的Audio构造函数。

跨域问题:这个是最折腾的,浏览器的同源策略让第三方API调用变得很麻烦。好在Vite的代理配置解决了开发环境的问题,生产环境可以考虑用Nginx反向代理。

优化空间

现在这个版本功能基本够用,但还有些优化空间:

  1. 图片压缩:上传前压缩一下,能节省带宽
  2. 结果缓存:相同图片的识别结果可以缓存起来
  3. 错误处理:网络失败、API限额这些情况还没处理
  4. 响应式设计:现在主要是按手机屏幕适配的
  5. 生产环境代理:考虑用服务端代理替代前端代理

写在最后

这个项目虽然不大,但涉及到的技术点还挺全面的:React状态管理、异步数据处理、多媒体API、AI接口调用、跨域处理等等。最有意思的是整个数据流:图片 → AI识别 → 文本 → 语音 → 播放,形成了一个完整的多模态交互链路。特别是跨域问题,这在实际项目中很常见,掌握了Vite代理配置这个技巧,以后遇到类似问题就不会手忙脚乱了。

主要是练手,顺便体验一下现在AI API的能力。确实比想象中要强,基本上按照你的要求来,很少出错。 如果你也想做类似的项目,建议先从简单的图片上传开始,功能一点点加,不要一开始就想做得很复杂。