开场三句话
- 用户说:"发张图。"
- 用户说:"发段语音。"
- 你说:"稍等,我让浏览器先开个 AI 小灶。"
今天,我们要写一个聊天 UI 的上传组件 ,它既能识图 又能辨音 ,还要保持界面优雅,像一位会魔法的管家。
(配图:一只端着托盘的小机器人,托盘上躺着一张猫咪照片和一只麦克风)
一、需求拆解:到底要上传什么?
类型 | 浏览器能做什么 | 我们要做什么 |
---|---|---|
图片 | <input type="file" accept="image/*"> |
预览、压缩、OCR/打标签 |
音频 | <input type="file" accept="audio/*"> or MediaRecorder |
波形预览、转文字、情绪分析 |
一句话:浏览器负责"拿",我们负责"看/听"。
二、技术地图:从点击到 AI 的大脑
css
┌────────────┐ ┌──────────────┐ ┌──────────┐
│ 用户点击 │──→──│ 前端预览 │──→──│ 后端识别 │
│ input file │ │ canvas / │ │ OCR / │
└────────────┘ │ Web Audio │ │ Whisper │
└──────────────┘ └──────────┘
三、前端实现:React + TypeScript(Next.js 亦可)
3.1 组件骨架:一个 Hook 统治所有上传
ts
// hooks/useUploader.ts
import { useState, useCallback } from 'react';
type FileType = 'image' | 'audio';
export function useUploader() {
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleChange = useCallback(
(type: FileType) => (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) return;
setFile(f);
setPreview(URL.createObjectURL(f));
setLoading(true);
// ⭐ 交给识别函数
recognize(type, f).then((result) => {
console.log('识别结果', result);
setLoading(false);
});
},
[]
);
return { file, preview, loading, handleChange };
}
3.2 图片识别:浏览器端就能 OCR(tesseract.js)
ts
// utils/recognize.ts
import Tesseract from 'tesseract.js';
export async function recognize(type: 'image' | 'audio', file: File) {
if (type === 'image') {
const { data: { text } } = await Tesseract.recognize(file, 'eng+chi_sim');
return { text };
}
if (type === 'audio') {
// 音频先上传,后端 Whisper 转文字,下文细讲
const form = new FormData();
form.append('audio', file);
const res = await fetch('/api/transcribe', { method: 'POST', body: form });
return res.json();
}
}
浏览器里跑 OCR 就像让小学生在操场上背圆周率------能背,但跑不快。
所以我们只在小图 或离线场景用 tesseract.js,大图还是走后端 GPU。
3.3 音频录制:边录边传,体验拉满
tsx
// components/AudioRecorder.tsx
import { useState } from 'react';
export default function AudioRecorder({ onDone }: { onDone: (f: File) => void }) {
const [recording, setRecording] = useState(false);
const mediaRef = useRef<MediaRecorder | null>(null);
const start = async () => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mr = new MediaRecorder(stream, { mimeType: 'audio/webm' });
const chunks: BlobPart[] = [];
mr.ondataavailable = (e) => chunks.push(e.data);
mr.onstop = () => {
const blob = new Blob(chunks, { type: 'audio/webm' });
onDone(new File([blob], 'speech.webm'));
};
mr.start();
mediaRef.current = mr;
setRecording(true);
};
const stop = () => {
mediaRef.current?.stop();
setRecording(false);
};
return (
<>
<button onClick={recording ? stop : start}>
{recording ? '⏹️ 停止' : '🎤 录音'}
</button>
</>
);
}
浏览器录音使用的是 MediaDevices.getUserMedia → MediaRecorder → Blob 这条"黄金管道"。
数据在内存里是 PCM 原始波形,压缩成 webm/opus 后才上传,节省 90% 流量。
四、后端识别:GPU 才是第一生产力
4.1 图片:OCR + 打标签(Python 示例,Next.js API Route 可调用)
py
# api/ocr.py (FastAPI 伪代码)
from fastapi import UploadFile
import pytesseract, torch, timm
@app.post("/ocr")
async def ocr(file: UploadFile):
img = await file.read()
text = pytesseract.image_to_string(img, lang='eng+chi_sim')
labels = model(img) # timm 预训练 ResNet
return {"text": text, "labels": labels}
4.2 音频:用 Whisper 转文字(OpenAI 开源版)
py
# api/transcribe.py
import whisper, tempfile, os
model = whisper.load_model("base")
@app.post("/transcribe")
async def transcribe(file: UploadFile):
with tempfile.NamedTemporaryFile(delete=False, suffix=".webm") as tmp:
tmp.write(await file.read())
tmp.flush()
result = model.transcribe(tmp.name, language='zh')
os.unlink(tmp.name)
return {"text": result["text"]}
Whisper 的「魔法」:把 30 秒音频切成 mel 频谱 → Transformer 编码 → 解码文字。
在 A100 上,转 30 秒音频只需 100 ms,比你泡咖啡还快。
五、前端 UI:让文件像聊天泡泡一样优雅
css
┌────────────────────────────┐
│ 用户 A │
│ [猫咪照片预览] │
│ 🖼️ 识别:一只橘猫在打盹 │
└────────────────────────────┘
实现思路:
- 上传成功 → 本地先渲染占位泡泡(带 spinner)。
- 后端返回结果 → 更新泡泡内容(图片 + 文字 / 语音 + 文字)。
- 失败 → 泡泡变红色,重试按钮出现。
六、性能 & 体验小贴士
问题 | 解法 |
---|---|
大图片 10 MB+ | 浏览器 canvas.toBlob(file, 'image/jpeg', 0.8) 压缩 |
音频长 5 min+ | 分片上传 + 后端流式转写 |
弱网 | 上传前存 IndexedDB,网络恢复后重试 |
隐私 | 敏感图片走本地 OCR,不上传 |
七、彩蛋:一行代码让上传支持拖拽
tsx
<div
onDrop={(e) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
// 复用前面 useUploader 的逻辑
}}
onDragOver={(e) => e.preventDefault()}
className="border-2 border-dashed border-gray-400 rounded p-8"
>
📂 把文件扔进来
</div>
八、结语:上传的尽头,是理解
当 AI 把猫咪照片识别成"一只橘猫在打盹",把语音转成"今晚吃什么?"时,
上传组件就不再是冷冰冰的 <input>
,而是人类与算法握手言欢的桥梁。
愿你写的每一个上传按钮,都能把比特变成诗。
祝你编码愉快,文件永不 413!