ENZH
和 AI 讨论这篇文章
ChatGPTClaude

web 端也能收到语音和自拍了

📊 幻灯片

跨平台功能对齐的概念插画跨平台功能对齐的概念插画

v0.1.0 给 Mio 加了语音,但只有 telegram 用户能听到。web 端还是纯文字:Fish Audio 生成的语音、Gemini 画的自拍,到了 web 端全被静默吞掉,前端压根不知道拿它们怎么办,服务端生成了也白生成。

v0.1.1 主要就是把这个不对称修掉,语音和自拍现在全平台都有。顺手还干了几件别的:注册流程重排了一遍,加了个成本看板,还上了个 pg_cron 任务防止数据库无限涨。下面挨个讲。

把媒体塞进 SSE

服务端其实早就在生成语音和自拍了。telegram bot 收到的是二进制附件,web 端什么都收不到——SSE 流里只有文本 token,媒体走的是 telegram 独占的旁路。所以修法也很直接:让媒体变成 SSE 里的一等公民,加两个事件类型:

event: voice
data: {"url": "https://storage.googleapis.com/.../voice-abc123.opus"}

event: selfie
data: {"url": "https://storage.googleapis.com/.../selfie-def456.jpg"}

服务端把媒体传到云存储,然后走传文本的同一条 SSE 连接把 url 推下来。不用轮询,不用单独的 API 调用,客户端就是多监听两种事件,改动很小。

服务端把语音和自拍作为一等公民塞进同一条 SSE 连接,Telegram 和 Web 端因此都能收到,消除平台不对称。服务端把语音和自拍作为一等公民塞进同一条 SSE 连接,Telegram 和 Web 端因此都能收到,消除平台不对称。

前端这边,语音要做个专门的组件。我的想法是做一个中国用户一看就懂的东西,也就是微信那个语音消息 UI:绿色气泡,播放的时候声波条跳。VoicePlayer 渲染三根竖条,CSS keyframes 做动画,每根错开 100ms:

.bar {
  animation: pulse 0.6s ease-in-out infinite;
}
.bar:nth-child(2) { animation-delay: 0.1s; }
.bar:nth-child(3) { animation-delay: 0.2s; }

这个 UI 的好处是零学习成本,微信里谁没见过一千遍。自拍就更简单了,直接在聊天流里内联渲染,跟 telegram 一样,不弹窗、不加下载按钮,就是一张图出现在对话里,像有人给你发了张照片。

比接收更麻烦的是从 web 端发送媒体。telegram 原生就支持传文件,输入框有个回形针图标,选完文件就完事;web 端这套要从零做。我做的是两阶段上传:用户选完文件,先以预览条的形式出现在输入框下面,缩略图带个 X 可以移除,点发送才真正上传。这样用户有机会确认一眼,不会误发。预览条就卡在输入框和发送按钮之间,看得到但不挡事。

注册再砍一个问题

v0.1.0 把注册从 14 个问题砍到 4 个,这个版本砍到 3 个,而且顺序重排了。

之前是先问昵称,再问关系类型。逻辑上好像说得通:先知道你是谁,再问你想怎么互动。但有个东西当时没想到——昵称的选项其实应该由关系类型决定。选了情侣,昵称建议就该是宝贝、老婆、亲爱的这类;选了好朋友,就该是同学、朋友,或者直接叫名字。反正朋友叫你宝贝很奇怪,伴侣叫你同学又太冷淡。

所以 v0.1.1 把顺序换过来了:关系类型第一个问,昵称第二个,选项跟着你第一个回答动态变:

const options_map = {
  '情侣': ['宝贝', '老婆', '亲爱的', '老公'],
  '好朋友': ['同学', '朋友', '{name}'],
  '暧昧': ['小哥哥', '小姐姐', '{name}'],
}

实现上是 depends_on + options_map:每个注册步骤声明自己依赖前面哪个答案,UI 照着渲染。没有硬编码的条件分支,全是配置驱动的。

先问关系类型再问昵称,昵称选项由第一个答案动态决定,配置驱动而非硬编码分支。先问关系类型再问昵称,昵称选项由第一个答案动态决定,配置驱动而非硬编码分支。

时区那个问题直接去掉了。v0.1.0 里它还是第 3 问,但浏览器本来就知道用户的时区,Intl.DateTimeFormat().resolvedOptions().timeZone 直接给你 Asia/Shanghai 或者 America/Los_Angeles,那还问什么呢。少一个问题,信息一点没丢。

另外整个流程的默认值做成了性别中立:默认昵称不预设性别,关系类型用包容性的标签。小改动,但对不想被二元分类的用户来说少了很多摩擦。

最后就剩三个问题:关系类型、昵称、关于你。一分钟以内能开聊。

加了个成本看板

跨多个用户跑五个 AI 角色,成本是那种不盯着就不知道花哪去了的状态:哪个操作最烧钱?一天花多少?有没有用户在疯狂消耗 API 额度?这些问题之前都答不上来。

v0.1.1 加了个管理员专用的成本看板,做成暗色主题——讲道理,仪表盘这种东西就应该是暗的。四个面板:

  1. 概览:今日总成本和历史累计支出,两个数字放正中间。
  2. 分类明细:一张表,每种操作(TTS、自拍生成、LLM 推理、记忆提取)的调用次数、总成本、单次均价。这张表一拉出来就发现自拍生成单次成本是 TTS 的 10 倍,后面要不要限流就有依据了。
  3. 最近交易:最近 20 条成本事件,最新的在前,每行有操作类型、用户、成本、时间戳。主要用来看异常——一个用户一小时生成 50 张自拍这种,一眼就能看出来。
  4. 每日成本图:每天支出的折线图。看趋势比看绝对值有用,随用户增长慢慢涨是正常的,突然一根尖刺要么是什么东西坏了,要么是有人在滥用。

所有成本查询统一用 UTC。这里顺带修了个 bug:之前有的查询用本地时间、有的用 UTC,服务器时区跟查询时区不一致的时候,每日聚合就是错的。

30 天自动清消息

聊天记录是线性涨的,每个用户每条消息都永久存着。原型阶段 10 个用户无所谓,真当产品做的话这就是个定时炸弹。

但 Mio 的记忆系统本来就会从对话里把重要的东西提取到长期记忆条目里,提取完之后原始消息基本就是冗余的了——三周前的聊天记录不需要留着,要点已经在记忆里了。所以 v0.1.1 加了个 pg_cron 任务,每天 UTC 凌晨 4 点跑:

SELECT cron.schedule(
  'delete-old-messages',
  '0 4 * * *',
  $$DELETE FROM messages WHERE created_at < NOW() - INTERVAL '30 days'$$
);

created_at 上有 B-tree 索引,删起来很高效,不用全表扫描,索引范围查找加批量删除就行。任务跑在低峰时段,用户主要在亚洲时区,这个点大部分人在睡觉。

为什么定 30 天?大概就是取了个平衡:LLM 随时拿得到近期 context,数据库又不会无限涨。反正几天前的东西记忆系统都处理过了,原始消息留着也是留着。没做软删除,没做归档,也没往冷存储迁,就是直接删——重要的部分已经进记忆了,删了不心疼。

记忆系统先把对话要点提取进长期记忆,原始消息随之变冗余,所以 30 天后直接删除也不丢信息。记忆系统先把对话要点提取进长期记忆,原始消息随之变冗余,所以 30 天后直接删除也不丢信息。

人设也调了两处

情侣关系支持:mimi-guimi 和 surou-xuejie 现在有情侣关系的专属行为模式。之前选这两个角色当情侣会觉得很泛,因为没有恋爱场景的专属规则,TA们默认就表现得像朋友。现在每个角色都有针对恋爱语境的定制回应,怎么表达喜欢、吃醋、想念、说晚安,都是分开写的。

keke-taimei 全面繁体化:可可的整套预设——身份定义文件、行为规则文件、人格配置文件、沟通准则——现在全部用繁体中文写。TA是台湾角色,就应该用繁体中文去思考,而不是简体写完做个表层转换。差别全在用词上。軟體不是软件,同样的还有訊息和消息、蠻好的和挺好的这种对子,用错一个,读起来就是大陆人在装台湾腔。

修了几个 bug

Discover 页面崩溃:浏览可用人设的页面一加载就崩,一个组件期望数组、收到了 undefined。加了个防御性检查 personas ?? [],但根本原因是 API 返回结构变了没同步到前端。

AgentResponse 类型不匹配:agent 的返回类型在服务端和客户端之间漂移了,服务端返回 { text, voice?, selfie? },客户端期望 { content, media? }。统一成了一个共享类型。

VoicePlayer 清理VoicePlayer 卸载的时候没清理 Audio 对象,用户在语音播放中跳转页面,音频还在后台继续放。useEffect 的 return 里加了清理函数。

这版做完之后

这版做完,web 端跟 telegram 基本就对等了。一半用户在 web 端,总不能一直让他们用阉割版。版本号只从 0.1.0 走到 0.1.1,反正就是个补课的版本,先这样。

和 AI 讨论这篇文章
ChatGPTClaude

订阅更新

新文章发布时发到你的邮箱,不发别的。


© Xingfan Xia 2024 - 2026 · CC BY-NC 4.0