从零实现「拍照记单词」小应用(可复刻版)
读完本文,你可以用浏览器单页 + 本地 Node 代理跑通:看图 → 输出单词卡片 JSON → 语音朗读
其实就是之前几个大模型的组成使用,请务必参考前面文章的文本模型、视觉模型、语音模型。
想直接体验,代码仓库,建立.env.local配置各种参数,然后运行node server.js,然后在浏览器打开index-capture-word.html就可以了。对,三个文件就可以了。
你会学到什么
- 为什么不要把大模型与 TTS 的密钥写进前端,以及如何用同源代理解决 CORS 与保密。
- 如何把本地图片变成 data URL,并塞给支持视觉的 OpenAI 兼容 Chat Completions。
- 如何用 Prompt 约定 JSON 形状,并在前端做一层代码围栏剥离容错解析。
- 如何把卡片字段拼成朗读稿,调用火山豆包 HTTP 非流式 TTS,并把 base64 音频交给
<audio>播放。
应用长什么样、用户怎样走完流程
- 用户选一张图(手机拍照后从相册选文件即可,本质仍是
<input type="file">)。 - 前端用
FileReader.readAsDataURL得到data:image/jpeg;base64,...这类字符串,同时用于预览和后续 API。 - 点击「生成单词卡片」:把该 data URL 放进多模态消息里的
image_url.url,连同固定VISION_PROMPT发给 Moonshot(经本地代理),模型返回一段 JSON 文本。 - 页面展示:单词拼写、读音标注、中文释义、英文例句、一段对话。
- 点击「朗读卡片」:把上述字段拼成一段适合朗读的文本,请求 TTS 代理,拿到音频后解码为 Blob URL,交给
<audio>播放。
技术选型与本仓库一致:Moonshot(示例模型 moonshot-v1-8k-vision-preview)做多模态理解与结构化输出;火山豆包 TTS 经 POST /tts/api/v1/tts(由 server.js 转发到 openspeech)合成朗读音频。
整体架构(建议先理解再写代码)
浏览器只配置「代理根地址」,例如 http://127.0.0.1:3000,不直接接触 Moonshot / 火山公网域名,也不持有 sk- 密钥:
flowchart LR
subgraph browser [浏览器]
A[index-capture-word.html]
end
subgraph proxy [Node server.js]
B["/moonshot/v1/*"]
C["/tts/api/v1/tts"]
end
subgraph upstream [公网 API]
M[Moonshot]
V[火山 openspeech]
end
A --> B
A --> C
B --> M
C --> V
先决条件
- Node.js 22+(本仓库
server.js使用内置fetch,无需额外npm install)。 - 可用的 Moonshot API Key(支持视觉的模型)与 火山豆包语音 HTTP TTS 凭据(
APP_ID、ACCESS_TOKEN、CLUSTER_ID等,与server.js头注释一致)。 - 仓库根目录的
server.js,已包含/moonshot与/tts转发逻辑。
第一步:准备 .env.local
在项目根目录新建或编辑 .env.local(server.js 启动时会加载 .env.local 或 .env)。至少包含:
| 用途 | 变量(可与 server.js 中别名互换) |
|---|---|
| Moonshot | MOONSHOT_API_KEY,或 VITE_API_KEY / API_KEY |
| 火山 TTS | VOLCENGINE_TTS_APP_ID、VOLCENGINE_TTS_ACCESS_TOKEN、VOLCENGINE_TTS_CLUSTER_ID,或 VITE_APP_ID、VITE_ACCESS_TOKEN、VITE_CLUSTER_ID |
密钥不要写进前端仓库的公开配置;.env.local 应留在本机并加入 .gitignore。
第二步:启动代理
在含 server.js 的目录执行:
node server.js
默认监听 http://127.0.0.1:3000(可通过环境变量 PORT 修改)。终端无报错即表示代理就绪。
第三步:静态打开单页并填写代理
含 import type="module" 的页面不要用 file:// 打开(易触发模块或跨域限制)。在仓库根目录用任意静态服务即可,例如:
npx serve .
浏览器打开 index-capture-word.html,在页面「代理」输入框填入 http://127.0.0.1:3000(与上一步一致;页面内会对末尾斜杠做规范化)。
核心实现拆解(与单页脚本逐段对应)
以下步骤你可完全用 Vue / React / 原生 JS 重写 UI,只要保留数据流与请求体结构,行为就与参考实现一致。
1. 选图 → data URL
- 监听
<input type="file">的change,取e.target.files[0]。 FileReader.readAsDataURL(file),在onload中把reader.result存为imgDataUrl。- 同一字符串绑定到
<img src>做预览,并作为下一步视觉请求中image_url.url的值(Moonshot 接受 data URL 形式的图片)。
2. 生成卡片:POST {proxyBase}/moonshot/v1/chat/completions
- 请求头:
Content-Type: application/json,Accept: application/json。 - Body:
model使用支持视觉的 id;stream: false,便于一次取全content。 messages:一条user消息,content为数组:先{ type: 'image_url', image_url: { url: imgDataUrl } },再{ type: 'text', text: VISION_PROMPT }。- 从响应读取
choices[0].message.content,再进入解析步骤。
3. Prompt 与 JSON 契约
在 VISION_PROMPT 中明确要求:只输出合法 JSON,并列出字段:word、phonetic、meaning_zh、example、dialogue(均为字符串)。字段稳定后,你的卡片 UI 与 buildTtsText 才能长期不改。
4. 容错解析:parseVisionJson
模型有时仍会在 JSON 外包一层 markdown 代码围栏。建议逻辑为:若存在 ```json … ```,先取内部文本;再在字符串中定位第一个 { 与最后一个 },JSON.parse 得到对象。缺字段或 word 为空时,向用户展示可读错误及原文片段,便于调 Prompt。
5. 朗读:POST {proxyBase}/tts/api/v1/tts
- 用
buildTtsText()(或等价逻辑)把单词、音标、中文义、例句、对话拼成单段文本;注意厂商有长度上限,参考实现中对总长做了截断(如 1200 字符),避免合成失败。 - Body 结构需与当前
server.js转发及火山 HTTP 文档一致(单页中含user、audio、request等字段,如voice_type、encoding、text、reqid)。 - 响应中取出 base64 音频(参考实现使用字段
data),经atob→Uint8Array→Blob,再URL.createObjectURL赋给<audio>。 - 再次合成前对旧 URL
URL.revokeObjectURL;组件卸载时同样释放,避免泄漏。
6. 数据流与界面门闩(把逻辑钉死)
- 串行依赖:先有
imgDataUrl才能调用视觉接口;只有解析出word(及你需要的其它字段)后,buildTtsText与 TTS 才有稳定输入。不要跳过中间态发请求。 - 按钮是否可点:与参考页一致时,可用
canAnalyze = !!imgDataUrl控制「生成单词卡片」,hasCard = !!word.trim()控制「朗读」。 - 重新选图:新文件读入后应清空上一轮单词、例句、对话、错误提示,避免旧卡片与新照片叠在一起误导用户。
常见问题
- CORS / 网络失败:确认页面来自 http(s) 静态源,且请求指向你填写的代理根地址,而不是
file://。 - 401 / 403:确认
.env.local与运行node server.js的工作目录一致;改 env 后重启 server。 - 视觉返回无法解析:收紧 Prompt、更换模型,或增强
parseVisionJson与错误提示。 - TTS 无数据:检查响应 JSON 是否返回错误信息、
encoding与解码后的 MIME 是否匹配(如mp3→audio/mpeg)。
复刻自检清单
-
.env.local已同时配置 Moonshot 与火山 TTS。 -
node server.js启动成功,端口与页面「代理」一致。 - 通过静态服务打开
index-capture-word.html,非file://。 - 选图后预览正常,「生成单词卡片」能填满五个字段。
- 「朗读卡片」能播放合成语音(或截断后的片段)。